Skip to main content

bynk_lsp/
lib.rs

1//! `bynkc-lsp` — Bynk Language Server.
2//!
3//! Implements the LSP capabilities listed in `design/bynk-lsp-spec.md` §4.3:
4//! synchronisation (Full), diagnostics, hover, go-to-definition and -type/-impl,
5//! formatting, document symbols, completion, signature help, references, rename,
6//! code actions, code lens, call hierarchy, document links, inlay hints,
7//! semantic tokens, workspace symbols, real multi-root workspace folders, and
8//! server-registered file watching. Built on `tower-lsp`.
9//!
10//! Architecture:
11//! - [`Backend`] holds the server state (behind a `tokio::sync::RwLock`): a
12//!   **map of projects** keyed by discovered root — each with its own config,
13//!   analysis round, and published set — plus the workspace-folder discovery
14//!   seeds and the client-global map of open documents. A request routes by URI
15//!   to its project (its nearest enclosing `bynk.toml`); a file under none is
16//!   single-file.
17//! - Document changes trigger `schedule_diagnostics`, one generation-based
18//!   debounce (a project-wide round via [`bynk_ide::diagnose_project_with`], or
19//!   single-file [`bynk_ide::diagnose`]) that publishes the resulting
20//!   diagnostics.
21//! - Hover and definition consult the parsed AST for the file under the
22//!   cursor; both are best-effort (return None for unrecognised positions).
23//! - Formatting delegates to [`bynk_fmt::format_source`].
24//!
25//! Slice C (the `[lib]` seam): this crate exposes a library target so its
26//! integration tests can `use bynk_lsp::…` instead of `#[path]`-including source
27//! modules. The `pub mod`s below are exposed for that testing, **not** as a
28//! stable API — `bynk-lsp` is a language-server binary and makes no library
29//! compatibility promise.
30
31pub mod agent_state_request;
32pub mod architecture_request;
33pub mod capability_fixes;
34pub mod code_actions;
35pub mod completion;
36mod content;
37mod document_symbols;
38pub mod documentation_request;
39pub mod explain_request;
40pub mod extract;
41pub mod fill_arms;
42pub mod fix_all;
43pub mod hover;
44pub mod index_queries;
45mod inlay_hints;
46pub mod inline;
47mod locals_nav;
48pub mod organise;
49pub mod position;
50mod project;
51pub mod provider_stub;
52mod publish;
53mod schema_lock;
54pub mod sequence_request;
55mod signature_help;
56mod structure;
57pub mod symbols;
58pub mod transport;
59pub mod wire_contract_request;
60
61use std::path::PathBuf;
62use std::sync::Arc;
63
64use tokio::sync::RwLock;
65use tower_lsp::jsonrpc::Result as JsonRpcResult;
66use tower_lsp::lsp_types::request::{
67    GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
68    GotoTypeDefinitionResponse,
69};
70use tower_lsp::lsp_types::*;
71use tower_lsp::{Client, LanguageServer, LspService, Server};
72
73use crate::project::ProjectConfig;
74
75const SERVER_NAME: &str = "bynkc-lsp";
76const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
77
78/// In-memory document state.
79#[derive(Debug, Clone)]
80struct DocumentState {
81    text: String,
82    version: i32,
83}
84
85/// v0.25 (ADR 0053): one analysis round's retained outputs — the binding
86/// index plus the snapshots its spans are offsets into, and the open-doc
87/// versions captured when the overlay was built (rename emits versioned
88/// edits against exactly these versions).
89#[derive(Debug)]
90struct Analysis {
91    /// Slice A: the canonicalised **project root** every path in this round
92    /// resolves against. Was the single `src` directory; the round now covers
93    /// every `include` tree, and ADR 0198 makes each file's path
94    /// project-relative — so this is the one base that resolves all of them.
95    project_root: PathBuf,
96    index: bynk_check::index::ProjectIndex,
97    /// Project-relative path → the analysed text.
98    snapshots: std::collections::HashMap<PathBuf, String>,
99    /// Project-relative path → the open document's version at analysis
100    /// time (absent for files read from disk).
101    versions: std::collections::HashMap<PathBuf, i32>,
102    /// v0.26 (ADR 0054): project-relative path → the round's diagnostics,
103    /// full `CompileError`s included — the suggestions `codeAction` serves
104    /// ride on them. Every analysed file has an entry (clean files an empty
105    /// one). Replaces the v0.25 categories-only field; the rename baseline
106    /// derives from these via [`Self::diag_categories`].
107    diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>>,
108    /// v0.27 (ADR 0056): project-relative path → the round's harvested
109    /// inferred-type hints, spans against the analysed snapshots.
110    hints: bynk_check::hints::FileHints,
111    /// v0.99: project-relative path → the round's capability-requirement ledger,
112    /// driving the materializable ghost `given` inlay hint, spans against the
113    /// analysed snapshots.
114    requirements: bynk_check::requirements::FileRequirements,
115    /// v0.31 (ADR 0064): project-relative path → the round's local bindings
116    /// with scope ranges, for locals navigation (references/definition/
117    /// highlight), spans against the analysed snapshots.
118    locals: bynk_check::locals::FileLocals,
119    /// Slice 6: project-relative path → the round's expression types, spans
120    /// against the analysed snapshots — backs go-to-type-definition.
121    expr_types: bynk_check::expr_types::FileExprTypes,
122    /// T3.6b (R4.1): the round's intern table — what every `TyId` in
123    /// `expr_types` resolves against. One per analysis round, shared across
124    /// every unit it checked.
125    ty_intern: std::sync::Arc<bynk_check::checker::Types>,
126    /// Slice 6b (ADR 0095): qualified unit name → its project source file(s),
127    /// project-relative — backs document links (`uses`/`consumes` → source).
128    unit_sources: std::collections::HashMap<String, Vec<PathBuf>>,
129    /// #846: qualified context/adapter unit name → the cross-context/agent
130    /// tables the `bynk/sequenceModel` request classifies handler calls
131    /// against.
132    sequence_info: std::collections::HashMap<String, bynk_ide::ContextSequenceInfo>,
133    /// #855: qualified context/adapter unit name → the combined type table
134    /// and service/agent tables the `bynk/wireContract` request resolves a
135    /// handler's boundary shape and cross-context hash against.
136    boundary_info: std::collections::HashMap<String, bynk_ide::ContextBoundaryInfo>,
137    /// #848: qualified unit name → its doc-comment intra-doc-link search
138    /// order — itself first, then its `uses` targets, then its `consumes`
139    /// targets — backs intra-doc-link resolution in `document_link` and
140    /// `hover`. See `bynk_ide::ProjectDiagnostics::doc_scope`.
141    doc_scope: std::collections::HashMap<String, Vec<String>>,
142}
143
144impl Analysis {
145    /// Per-file diagnostic categories — the rename validator's baseline,
146    /// derived from the retained diagnostics.
147    fn diag_categories(&self) -> Vec<(PathBuf, String)> {
148        self.diagnostics
149            .iter()
150            .flat_map(|(path, diags)| {
151                diags
152                    .iter()
153                    .map(|d| (path.clone(), d.error.category.to_string()))
154            })
155            .collect()
156    }
157}
158
159/// One project's mutable state — the fields that were flat on `State` before
160/// slice D, now one set per discovered project root. Every request routes by
161/// URI (via `resolve_root`) to its owning entry, so two projects analyse,
162/// version, and publish independently.
163#[derive(Debug, Default)]
164struct ProjectState {
165    /// Parsed `bynk.toml` configuration for this root. Defaults for missing
166    /// fields. Read live for the diagnostics mode/debounce and formatting;
167    /// reloaded on a `bynk.toml` change (`did_change_watched_files`).
168    config: ProjectConfig,
169    /// v0.25: the latest analysis round's index + snapshots. References,
170    /// rename, and the re-pointed definition/hover read this; positions
171    /// convert against the analysed snapshots (v0.24 rule).
172    analysis: Option<Arc<Analysis>>,
173    /// v0.24: URIs that currently carry published project diagnostics — the
174    /// previous round's dirty set, so newly-clean files get a clearing
175    /// (empty) publish. Per-project (slice D): a round for this root must only
176    /// clear its own files, never another project's.
177    published: std::collections::HashSet<Url>,
178    /// v0.24: debounce generation. Each change bumps it; a scheduled
179    /// analysis runs only if it is still the latest when the delay elapses.
180    /// Per-project: two projects debounce independently.
181    analysis_generation: u64,
182    /// Monotonic id handed to each analysis round as it *starts*. Together
183    /// with `analysis_round_committed` this orders round completions: an old
184    /// slow round must never overwrite a newer round's results (#513).
185    /// Per-project (slice D): a global counter would let one project's round
186    /// discard another's.
187    analysis_round_started: u64,
188    /// The id of the newest round whose results have been committed.
189    analysis_round_committed: u64,
190    /// #1667: the newest round panicked and no round has committed since.
191    /// The client is told once per such streak, not on every keystroke.
192    analysis_failed: bool,
193    /// #1667: makes this project's next round panic, so a test can watch the
194    /// failure reach the client. Per project, not a global, so parallel tests'
195    /// rounds can't take it.
196    #[cfg(test)]
197    panic_next_round: bool,
198    /// #2081: this project's `bynk.schema.lock`, read-only and cached by its
199    /// `(mtime, len)` stamp, handed to every diagnostics round so the editor
200    /// reports a non-additive event change as `bynk check` does. Cleared by
201    /// the `**/bynk.schema.lock` watcher.
202    schema_lock: schema_lock::SchemaLockCache,
203}
204
205/// #733: the client's `workspace/*/refresh` support, per pull-based decoration,
206/// captured at `initialize`. Each flag gates the corresponding round-commit
207/// nudge in [`Backend::run_project_diagnostics`].
208#[derive(Debug, Clone, Copy, Default)]
209struct RefreshSupport {
210    semantic_tokens: bool,
211    inlay_hints: bool,
212    code_lens: bool,
213}
214
215/// Mutable server state. Slice D: a map of projects (was one flat project),
216/// plus the open buffers (client-global) and the workspace-folder seeds.
217#[derive(Debug, Default)]
218struct State {
219    /// Discovered projects, keyed by **canonical project root** (Q4: the
220    /// directory a file's `resolve_root` walk lands on — a `bynk.toml`, else an
221    /// implicit `src/` parent). Empty in single-file mode. A request routes to
222    /// its entry by URI; the entry is created lazily on first touch (open or
223    /// request) and pruned when no folder covers it and it holds no open buffer.
224    projects: std::collections::HashMap<PathBuf, ProjectState>,
225    /// The workspace-folder roots the client has open (slice D). **Discovery
226    /// seeds, not routing owners** (Q4): they bound where
227    /// `did_change_workspace_folders` prunes, but a URI routes by its nearest
228    /// enclosing `bynk.toml`, which may sit above every folder.
229    folders: Vec<PathBuf>,
230    /// Open documents keyed by URI — a client-global set; each doc routes to
231    /// its project via `resolve_root`.
232    docs: std::collections::HashMap<Url, DocumentState>,
233    /// Slice E: whether the client advertised `didChangeWatchedFiles`
234    /// **dynamic registration** at `initialize`. When set, `initialized`
235    /// registers the file watchers server-side (so any client is notified);
236    /// when not, the client is expected to supply them itself (as VS Code did
237    /// before the extension's client-side watchers were removed).
238    supports_dynamic_watchers: bool,
239    /// #733: whether the client advertised `refresh_support` for each pull-based
240    /// decoration at `initialize`. When set, a committed round asks the client to
241    /// re-pull that decoration (`workspace/*/refresh`) — the "revalidate" half of
242    /// serving `committed_analysis` stale while typing. Only sent when advertised,
243    /// so a client that never supported it is never spammed with unknown requests.
244    supports_refresh: RefreshSupport,
245    /// #2098: whether the client advertised
246    /// `experimental.explainDiagnosticCommand` at `initialize` — it implements
247    /// the client-side `bynk.explainDiagnostic` command, so `code_action`
248    /// offers the "Explain …" action. Off for any other client.
249    supports_explain_command: bool,
250    /// #2100 slice 2: whether the client shows a disabled code action's
251    /// reason (`codeAction.disabledSupport`) — extract-function tells the user
252    /// why a capability-using selection can't be extracted that way.
253    supports_code_action_disabled: bool,
254    /// Slice F: debounce generation for **single-file** buffers (no project),
255    /// keyed by URI. The project path holds its generation in `ProjectState`;
256    /// this is the same coalescing for a buffer that has no entry — a burst runs
257    /// one `diagnose`, not one per keystroke. Cleared on `did_close`.
258    single_file_generations: std::collections::HashMap<Url, u64>,
259    /// #682: memoised URI → canonical project root routing (`None` for
260    /// single-file mode is itself a cached answer), so the hot request path
261    /// stops re-walking the filesystem and `canonicalize()`ing on every call.
262    /// For a URI whose own path is fixed, routing depends only on `bynk.toml`
263    /// presence among its ancestors — `find_source_root`'s `src`-ancestor
264    /// fallback is a pure string match against that fixed path, with no
265    /// filesystem I/O of its own, so it can't drift independently. That makes
266    /// a `bynk.toml` create/delete/change the only event that can move an
267    /// already-cached URI's route, and this is invalidated wholesale on it
268    /// (`did_change_watched_files`). A workspace-folder change also clears it
269    /// (`did_change_workspace_folders`) even though `resolve_canonical` never
270    /// consults `folders` today — a defensive, effectively-free no-op kept in
271    /// case that ever changes, not a correctness requirement. Bounded entries
272    /// are never individually evicted (e.g. on `did_close`); only ever
273    /// wholesale-cleared, which is judged an acceptable tradeoff — bounded by
274    /// the distinct files touched in a session. See [`Backend::root_for_uri`].
275    root_cache: std::collections::HashMap<Url, Option<PathBuf>>,
276    /// #682: bumped every time `root_cache` is wholesale-cleared. `root_for_uri`
277    /// resolves a cache miss off the `state` lock (a filesystem walk must not
278    /// run while holding it); this closes the race where an invalidating clear
279    /// lands *during* that walk — the write-back re-checks the generation and
280    /// drops a stale answer instead of resurrecting it into the freshly-cleared
281    /// cache.
282    root_cache_generation: u64,
283}
284
285#[derive(Clone)]
286pub struct Backend {
287    client: Client,
288    state: Arc<RwLock<State>>,
289    /// Slice B (the freshness contract): serialises request-driven refreshes so
290    /// concurrent index-backed requests after one edit coalesce onto a single
291    /// round instead of each spawning its own. Held only across `analysis_for`'s
292    /// refresh; never across a `state` lock.
293    refresh_lock: Arc<tokio::sync::Mutex<()>>,
294    /// #1667: set when the client sends `shutdown`. [`run`] reads it once the
295    /// session ends, to exit 0 after an orderly shutdown and 1 otherwise, as
296    /// the LSP spec's `exit` notification prescribes.
297    shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
298}
299
300/// #1667: a failed `spawn_blocking` task, described for the log: the panic's
301/// message when it panicked, else that it was cancelled.
302fn describe_join_error(e: tokio::task::JoinError) -> String {
303    if !e.is_panic() {
304        return format!("cancelled ({e})");
305    }
306    let payload = e.into_panic();
307    if let Some(s) = payload.downcast_ref::<&str>() {
308        format!("panicked: {s}")
309    } else if let Some(s) = payload.downcast_ref::<String>() {
310        format!("panicked: {s}")
311    } else {
312        "panicked with a non-string payload".to_string()
313    }
314}
315
316impl Backend {
317    #[cfg(test)]
318    fn new(client: Client) -> Self {
319        Self::with_shutdown_flag(client, Arc::default())
320    }
321
322    fn with_shutdown_flag(
323        client: Client,
324        shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
325    ) -> Self {
326        Self {
327            client,
328            state: Arc::new(RwLock::new(State::default())),
329            refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
330            shutdown_requested,
331        }
332    }
333
334    /// Locate `bynk.toml` walking upward from the given path. Returns the
335    /// project root (the directory containing `bynk.toml`) on success.
336    fn find_project_root(start: &std::path::Path) -> Option<PathBuf> {
337        let mut current = if start.is_file() {
338            start.parent()?.to_path_buf()
339        } else {
340            start.to_path_buf()
341        };
342        loop {
343            let candidate = current.join("bynk.toml");
344            if candidate.is_file() {
345                return Some(current);
346            }
347            current = current.parent()?.to_path_buf();
348        }
349    }
350
351    /// Locate the nearest ancestor directory named `src`, walking upward from
352    /// `start`. This is the implicit source root of a *rootless* tree — the
353    /// same `src/`-without-`bynk.toml` layout `bynkc` compiles in its legacy
354    /// single-tree mode (`bynkc/tests/e2e.rs` `compile_fixture`), which the
355    /// compiler fixtures use. Returns that `src` directory.
356    fn find_source_root(start: &std::path::Path) -> Option<PathBuf> {
357        let mut current = if start.is_file() {
358            start.parent()?.to_path_buf()
359        } else {
360            start.to_path_buf()
361        };
362        loop {
363            if current.file_name().and_then(|n| n.to_str()) == Some("src") {
364                return Some(current);
365            }
366            current = current.parent()?.to_path_buf();
367        }
368    }
369
370    /// Resolve the analysis root for a path, with its config. A real
371    /// `bynk.toml` project (config loaded from disk) takes precedence;
372    /// otherwise (#485) fall back to the nearest enclosing `src/` as an
373    /// implicit project so a multi-file commons in a rootless tree still
374    /// analyses cross-file instead of dropping to sibling-blind single-file
375    /// mode. `None` when neither is found — the caller stays single-file.
376    fn resolve_root(start: &std::path::Path) -> Option<(PathBuf, project::ProjectConfig)> {
377        if let Some(root) = Self::find_project_root(start) {
378            let config = project::load_config(&root).unwrap_or_default();
379            return Some((root, config));
380        }
381        // The implicit project root is the parent of `src`: with the default
382        // `src_dir` ("src"), `run_project_diagnostics` re-derives exactly this
383        // `src` tree as the analysis root, so every project-mode feature works
384        // with no further plumbing.
385        let src = Self::find_source_root(start)?;
386        let root = src.parent()?.to_path_buf();
387        Some((root, project::ProjectConfig::default()))
388    }
389
390    /// Slice D (Q4): the **canonical** project root that owns `uri`, with its
391    /// config, or `None` for a file under no project (single-file mode). Routing
392    /// is `resolve_root`'s walk-up — the same project `bynkc` attributes the file
393    /// to — canonicalised so it matches the `projects` map key and every
394    /// `Analysis.project_root`. Workspace folders do not enter here: a URI routes
395    /// by its nearest enclosing `bynk.toml`, whatever folder it sits in.
396    fn resolve_canonical(uri: &Url) -> Option<(PathBuf, project::ProjectConfig)> {
397        let path = uri.to_file_path().ok()?;
398        let (root, config) = Self::resolve_root(&path)?;
399        Some((root.canonicalize().unwrap_or(root), config))
400    }
401
402    /// The canonical project root owning `uri`, or `None` in single-file mode.
403    /// Uncached — walks the filesystem and `canonicalize()`s on every call.
404    /// Kept for the one caller that must route off the `state` lock
405    /// (`prune_orphaned_projects`, #682 DECISION B) and for tests exercising
406    /// routing directly; every other caller wants the memoised
407    /// [`Self::root_for_uri`].
408    fn root_for_uri_uncached(uri: &Url) -> Option<PathBuf> {
409        Self::resolve_canonical(uri).map(|(root, _)| root)
410    }
411
412    /// #682: the cached counterpart of `root_for_uri_uncached` — the canonical
413    /// project root owning `uri`, memoised in `State.root_cache` so a repeated
414    /// request for the same URI does not re-walk the filesystem. A miss runs
415    /// the uncached walk and stores the result (`None` included — a file that
416    /// routes to no project is itself a stable answer worth caching).
417    ///
418    /// The walk runs off the `state` lock (it is synchronous filesystem I/O),
419    /// so a wholesale `root_cache.clear()` can land between the read that
420    /// found the miss and the write that stores its answer — a `bynk.toml`
421    /// created mid-walk would otherwise have this write resurrect the
422    /// pre-creation (stale) route into the just-cleared cache, and unlike
423    /// `prune_orphaned_projects`'s TOCTOU window this one would never
424    /// self-heal. `root_cache_generation` closes it: the write-back only
425    /// applies if no clear happened while the walk was in flight; otherwise
426    /// the fresh answer is simply not cached (correct either way — just an
427    /// uncached hit for that one request).
428    async fn root_for_uri(&self, uri: &Url) -> Option<PathBuf> {
429        let generation = {
430            let state = self.state.read().await;
431            if let Some(cached) = state.root_cache.get(uri) {
432                return cached.clone();
433            }
434            state.root_cache_generation
435        };
436        let root = Self::root_for_uri_uncached(uri);
437        let mut state = self.state.write().await;
438        if Self::root_cache_write_is_current(generation, state.root_cache_generation) {
439            state.root_cache.insert(uri.clone(), root.clone());
440        }
441        root
442    }
443
444    /// #682: whether a `root_for_uri` write-back computed while the cache was
445    /// at `read_generation` should still be applied, given the cache is now at
446    /// `current_generation` — `false` once an invalidating clear has bumped it
447    /// past the read, meaning the walk's answer may already be stale. Pulled
448    /// out of `root_for_uri` so the guard itself — the one thing standing
449    /// between the fix and the TOCTOU it closes — is unit-testable without
450    /// needing to actually win the race in real time.
451    fn root_cache_write_is_current(read_generation: u64, current_generation: u64) -> bool {
452        read_generation == current_generation
453    }
454
455    /// Slice E: every project root under `folder` — the folder's own
456    /// `resolve_root` (a manifest at or above it, the folder-inside-a-project
457    /// case) plus a bounded recursive walk collecting each directory that holds
458    /// a `bynk.toml`. Roots are **canonical** (the `projects` map key). The walk
459    /// skips the caches and heavy dirs it should never descend (`out`,
460    /// `node_modules`, `target`, `.git`, and dot-dirs), and a **visited-set of
461    /// canonicalised dirs** stops a symlink cycle (`ln -s . loop`) from recursing
462    /// forever. Synchronous FS I/O — callers run it via `spawn_blocking`, off the
463    /// executor. This is the "one tree-walk"
464    /// [ADR 0204](../decisions/0204-per-workspace-project-state.md) §C named —
465    /// shared by startup warming and added-folder warming.
466    fn discover_projects_under(folder: &std::path::Path) -> Vec<PathBuf> {
467        fn should_skip(name: &std::ffi::OsStr) -> bool {
468            let name = name.to_string_lossy();
469            matches!(name.as_ref(), "out" | "node_modules" | "target" | ".git")
470                || name.starts_with('.')
471        }
472        fn walk(
473            dir: &std::path::Path,
474            out: &mut Vec<PathBuf>,
475            visited: &mut std::collections::HashSet<PathBuf>,
476        ) {
477            // Guard against symlink cycles: a directory reached twice (by its
478            // canonical path) is not descended again.
479            let canon_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
480            if !visited.insert(canon_dir.clone()) {
481                return;
482            }
483            if dir.join("bynk.toml").is_file() && !out.contains(&canon_dir) {
484                out.push(canon_dir);
485            }
486            let Ok(entries) = std::fs::read_dir(dir) else {
487                return;
488            };
489            for entry in entries.flatten() {
490                let path = entry.path();
491                if path.is_dir() && !should_skip(&entry.file_name()) {
492                    walk(&path, out, visited);
493                }
494            }
495        }
496        let mut roots = Vec::new();
497        // A manifest at or above the folder (the folder sits inside a project).
498        if let Some((root, _)) = Self::resolve_root(folder) {
499            let canon = root.canonicalize().unwrap_or(root);
500            roots.push(canon);
501        }
502        // The implicit-`src/` shape (#485): a `src/` tree with no `bynk.toml`.
503        // `resolve_root` only finds a `src/` *ancestor*, so the folder-is-the-root
504        // case (folder holds `src/`, no manifest) needs an explicit check — else
505        // a rootless project would warm only lazily on first open, not at startup.
506        if folder.join("src").is_dir() && !folder.join("bynk.toml").is_file() {
507            let canon = folder
508                .canonicalize()
509                .unwrap_or_else(|_| folder.to_path_buf());
510            if !roots.contains(&canon) {
511                roots.push(canon);
512            }
513        }
514        let mut visited = std::collections::HashSet::new();
515        walk(folder, &mut roots, &mut visited);
516        // #2207: a workspace root isn't a project. Analysing it as one would
517        // read every member's sources as a single tree; each member is found
518        // by the walk on its own.
519        roots.retain(|root| !bynk_ide::is_workspace_root(root));
520        roots
521    }
522
523    /// Slice F: the single diagnostics-scheduler entry point. Route `uri` to its
524    /// owning project (a debounced project round) or, if none, single-file mode
525    /// (a debounced buffer `diagnose`). **One** generation-based debounce at the
526    /// configured delay covers both — a burst coalesces to one analysis. Replaces
527    /// `recompile_and_publish`, whose route + second hardcoded debounce stacked
528    /// on `did_change`'s own sleep.
529    async fn schedule_diagnostics(&self, uri: &Url) {
530        // Slice D: route by URI to the owning project, creating its entry on
531        // first touch (a file opened before any folder scan). Q4: the root is
532        // the file's nearest enclosing `bynk.toml`, not its workspace folder.
533        // #682: routing goes through the cache; the config is only loaded from
534        // disk when the entry doesn't exist yet, not on every call.
535        if let Some(root) = self.root_for_uri(uri).await {
536            {
537                let mut state = self.state.write().await;
538                if !state.projects.contains_key(&root) {
539                    let config = project::load_config(&root).unwrap_or_default();
540                    state.projects.insert(
541                        root.clone(),
542                        ProjectState {
543                            config,
544                            ..Default::default()
545                        },
546                    );
547                }
548            }
549            self.schedule_project_diagnostics(root).await;
550        } else {
551            self.schedule_single_file(uri.clone()).await;
552        }
553    }
554
555    /// v0.24: debounce a project-wide analysis — each call bumps the project's
556    /// generation; the spawned task runs only if still the latest after the
557    /// delay, so a typing burst produces one analysis. Slice D: keyed on one
558    /// project root, so two projects debounce independently. A no-op if the
559    /// root's entry is gone (its folder was removed mid-debounce).
560    ///
561    /// Slice F: the delay is the project's **configured** `diagnostics_debounce_ms`
562    /// (was a hardcoded 200 ms stacked on `did_change`'s own sleep — the two are
563    /// now one debounce).
564    async fn schedule_project_diagnostics(&self, root: PathBuf) {
565        let (generation, debounce) = {
566            let mut state = self.state.write().await;
567            let Some(ps) = state.projects.get_mut(&root) else {
568                return;
569            };
570            ps.analysis_generation += 1;
571            (ps.analysis_generation, ps.config.diagnostics_debounce_ms)
572        };
573        let this = self.clone();
574        tokio::spawn(async move {
575            tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
576            let superseded = match this.state.read().await.projects.get(&root) {
577                Some(ps) => ps.analysis_generation != generation,
578                None => true, // entry pruned — nothing to analyse
579            };
580            if superseded {
581                return;
582            }
583            this.run_project_diagnostics(root).await;
584        });
585    }
586
587    /// Slice F: the single-file counterpart to `schedule_project_diagnostics` —
588    /// a buffer with no project. Bump the URI's generation, sleep the (default)
589    /// configured delay, and run one `diagnose` only if still latest, so a burst
590    /// coalesces to one run (before slice F single-file had no generation and ran
591    /// once per keystroke).
592    async fn schedule_single_file(&self, uri: Url) {
593        let debounce = ProjectConfig::default().diagnostics_debounce_ms;
594        let generation = {
595            let mut state = self.state.write().await;
596            let g = state
597                .single_file_generations
598                .entry(uri.clone())
599                .or_insert(0);
600            *g += 1;
601            *g
602        };
603        let this = self.clone();
604        tokio::spawn(async move {
605            tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
606            let current = this
607                .state
608                .read()
609                .await
610                .single_file_generations
611                .get(&uri)
612                .copied();
613            if current != Some(generation) {
614                return;
615            }
616            this.diagnose_single_file(&uri).await;
617        });
618    }
619
620    /// Slice F: run `bynk_ide::diagnose` on one buffer and publish — the
621    /// single-file leaf of the scheduler (extracted from `recompile_and_publish`).
622    /// Best-effort: a malformed file produces diagnostics, not a hard failure.
623    async fn diagnose_single_file(&self, uri: &Url) {
624        let (text, version) = {
625            let state = self.state.read().await;
626            match state.docs.get(uri) {
627                Some(d) => (d.text.clone(), d.version),
628                None => return,
629            }
630        };
631        let positions = crate::position::PositionMap::new(&text);
632        let lsp_diags: Vec<Diagnostic> = bynk_ide::diagnose(&text)
633            .into_iter()
634            .map(|d| make_diagnostic(&d, &positions, uri, &ProjectFiles::new()))
635            .collect();
636        self.client
637            .publish_diagnostics(uri.clone(), lsp_diags, Some(version))
638            .await;
639    }
640
641    /// #1667: an analysis round for `root` failed (`reason` says how). Log
642    /// it, and tell the client once per failure streak: the published
643    /// diagnostics are the last good round's and may be stale. The streak ends
644    /// at the next committed round, which clears the flag.
645    async fn report_failed_round(&self, root: &std::path::Path, reason: String) {
646        tracing::error!("analysis of {} {reason}", root.display());
647        let first_failure = {
648            let mut state = self.state.write().await;
649            match state.projects.get_mut(root) {
650                Some(ps) => !std::mem::replace(&mut ps.analysis_failed, true),
651                None => false,
652            }
653        };
654        if first_failure {
655            self.client
656                .show_message(
657                    MessageType::ERROR,
658                    format!(
659                        "Bynk: analysing {} failed with an internal error, so its diagnostics may \
660                         be out of date until a later edit analyses cleanly. Details are in \
661                         ~/.bynk-lsp.log; please report it.",
662                        root.display()
663                    ),
664                )
665                .await;
666        }
667    }
668
669    /// #2081: `root`'s `bynk.schema.lock`, through its project's cache. The
670    /// cache is copied out, refreshed with no `state` lock held (a `stat`,
671    /// and a read only when the file changed), then stored back. A root with
672    /// no project entry gets a one-off read. Never writes the lock.
673    async fn schema_lock_for(&self, root: &std::path::Path) -> bynk_ide::SchemaLock {
674        let mut cache = self
675            .state
676            .read()
677            .await
678            .projects
679            .get(root)
680            .map(|ps| ps.schema_lock.clone())
681            .unwrap_or_default();
682        let lock = cache.current(root);
683        if let Some(ps) = self.state.write().await.projects.get_mut(root) {
684            ps.schema_lock = cache;
685        }
686        lock
687    }
688
689    /// v0.24 (ADR 0052): one project-wide diagnostics round — overlay the
690    /// open buffers over disk, analyse off the async runtime, convert spans
691    /// against the **analysed snapshots**, and publish via the pure
692    /// publish-plan (clears included).
693    async fn run_project_diagnostics(&self, root: PathBuf) {
694        let (round, root, canonical_root, overlay, versions, previously_dirty) = {
695            let mut state = self.state.write().await;
696            // Slice D: the round is for one project's entry. If it was pruned
697            // (its folder removed) between scheduling and now, there is nothing
698            // to analyse — bail.
699            let Some(ps) = state.projects.get_mut(&root) else {
700                return;
701            };
702            ps.analysis_round_started += 1;
703            let round = ps.analysis_round_started;
704            // Slice A: the analysis is rooted at the *project*, not at one
705            // `include` tree, and every path it returns is project-relative
706            // (ADR 0198) — so this is the base the overlay keys against too.
707            let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
708            let previously_dirty = ps.published.clone();
709            let mut overlay = std::collections::HashMap::new();
710            let mut versions = std::collections::HashMap::new();
711            // Every open buffer overlays disk. A buffer belonging to another
712            // project keys to an absolute path outside this root, so it is inert
713            // here — discovery never matches it — and its `versions` entry is
714            // skipped by the `strip_prefix` guard. So the round stays scoped to
715            // this project without filtering the doc set.
716            for (uri, doc) in &state.docs {
717                if let Ok(p) = uri.to_file_path() {
718                    let canonical = p.canonicalize().unwrap_or(p);
719                    // v0.25: capture the version the overlay snapshot came
720                    // from, keyed project-relative like the analysis output.
721                    if let Ok(rel) = canonical.strip_prefix(&canonical_root) {
722                        versions.insert(rel.to_path_buf(), doc.version);
723                    }
724                    overlay.insert(canonical, doc.text.clone());
725                }
726            }
727            (
728                round,
729                root,
730                canonical_root,
731                overlay,
732                versions,
733                previously_dirty,
734            )
735        };
736
737        // Slice A: manifest-aware, multi-root — the same trees `bynkc` compiles.
738        let roots = bynk_ide::AnalysisRoots::Project(root.clone());
739        // Content-ownership track (#1086) slice 5: `overlay` above is only the
740        // open buffers — with `discovery.rs`'s disk fallback gone, a project's
741        // closed files need `sweep_project_content`'s full disk sweep too, or
742        // every one of them fails `bynk.project.read_failed` on every round.
743        #[cfg(test)]
744        let inject_panic = self
745            .state
746            .write()
747            .await
748            .projects
749            .get_mut(&root)
750            .is_some_and(|ps| std::mem::take(&mut ps.panic_next_round));
751        // #2081: the committed schema lock, so a non-additive event change is
752        // diagnosed here as `bynk check` diagnoses it.
753        let schema_lock = self.schema_lock_for(&root).await;
754        let joined = tokio::task::spawn_blocking(move || {
755            #[cfg(test)]
756            if inject_panic {
757                panic!("injected analysis panic");
758            }
759            let content = crate::content::sweep_project_content(&roots, &overlay);
760            bynk_ide::diagnose_project_with(&roots, &content, &schema_lock)
761        })
762        .await;
763        let result = match joined {
764            Ok(result) => result,
765            Err(e) => {
766                self.report_failed_round(&root, describe_join_error(e))
767                    .await;
768                return;
769            }
770        };
771
772        let mut new_by_uri: std::collections::HashMap<Url, Vec<Diagnostic>> =
773            std::collections::HashMap::new();
774        // Slice B (DECISION C): the document version each file was analysed at,
775        // keyed by URI — so the publish can carry it and the client can drop a
776        // range computed against a buffer it has already edited past. `None` for
777        // a file read from disk (no open buffer, no version).
778        let mut version_by_uri: std::collections::HashMap<Url, Option<i32>> =
779            std::collections::HashMap::new();
780        let mut snapshots = std::collections::HashMap::new();
781        let mut diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>> =
782            std::collections::HashMap::new();
783        // Spans convert against the snapshot the analysis saw — never a newer
784        // buffer (Settled, v0.24 proposal). Built for every file up front: a
785        // diagnostic's secondary label may lie in a sibling file.
786        let files: ProjectFiles<'_> = result
787            .files
788            .iter()
789            .filter_map(|file| {
790                let abs = canonical_root.join(&file.source_path);
791                let abs = abs.canonicalize().unwrap_or(abs);
792                let uri = Url::from_file_path(&abs).ok()?;
793                let positions = crate::position::PositionMap::new(&file.text);
794                Some((file.source_path.clone(), (uri, positions)))
795            })
796            .collect();
797        for file in &result.files {
798            let Some((uri, positions)) = files.get(&file.source_path) else {
799                continue;
800            };
801            let diags: Vec<Diagnostic> = file
802                .diagnostics
803                .iter()
804                .map(|d| make_diagnostic(d, positions, uri, &files))
805                .collect();
806            version_by_uri.insert(uri.clone(), versions.get(&file.source_path).copied());
807            new_by_uri.insert(uri.clone(), diags);
808            diagnostics.insert(file.source_path.clone(), file.diagnostics.clone());
809            snapshots.insert(file.source_path.clone(), file.text.clone());
810        }
811        // v0.25: retain the round's index + snapshots for references/rename
812        // and the binding-correct definition/hover. v0.26: plus the raw
813        // diagnostics, for `codeAction` (the suggestions ride on them).
814        {
815            let analysis = Arc::new(Analysis {
816                project_root: canonical_root.clone(),
817                index: result.index.clone(),
818                snapshots,
819                versions,
820                diagnostics,
821                hints: result.hints,
822                requirements: result.requirements,
823                locals: result.locals,
824                expr_types: result.expr_types,
825                ty_intern: result.ty_intern,
826                unit_sources: result.unit_sources,
827                sequence_info: result.sequence_info,
828                boundary_info: result.boundary_info,
829                doc_scope: result.doc_scope,
830            });
831            let mut state = self.state.write().await;
832            let Some(ps) = state.projects.get_mut(&root) else {
833                return; // pruned mid-round
834            };
835            // Completion order is not start order: a slow old round finishing
836            // after a newer one must be dropped, not committed (#513).
837            if ps.analysis_round_committed >= round {
838                return;
839            }
840            ps.analysis_round_committed = round;
841            ps.analysis_failed = false;
842            ps.analysis = Some(analysis);
843        }
844        // Project-level diagnostics with no single owning file surface at
845        // position 0:0 rather than vanishing — on `bynk.toml` when it exists,
846        // else (#485, implicit `src/` mode has no manifest) on the first
847        // analysed file, so they anchor to a real, openable document.
848        let unattributed_anchor = {
849            let toml = root.join("bynk.toml");
850            if toml.is_file() {
851                Url::from_file_path(toml).ok()
852            } else {
853                result.files.first().and_then(|f| {
854                    let abs = canonical_root.join(&f.source_path);
855                    let abs = abs.canonicalize().unwrap_or(abs);
856                    Url::from_file_path(abs).ok()
857                })
858            }
859        };
860        if !result.unattributed.is_empty()
861            && let Some(anchor_uri) = unattributed_anchor
862        {
863            let entry = new_by_uri.entry(anchor_uri).or_default();
864            for d in &result.unattributed {
865                entry.push(Diagnostic {
866                    range: Default::default(),
867                    severity: Some(match d.severity {
868                        bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
869                        bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
870                    }),
871                    code: Some(tower_lsp::lsp_types::NumberOrString::String(
872                        d.error.category.to_string(),
873                    )),
874                    message: d.error.message.clone(),
875                    ..Default::default()
876                });
877            }
878        }
879
880        let (publishes, dirty) = publish::publish_plan(&previously_dirty, new_by_uri);
881        for (uri, diags) in publishes {
882            // Slice B (DECISION C): stamp the publish with the version the round
883            // analysed this file at (was `None`), so a client can reject a range
884            // its buffer has moved past. A clearing publish for a now-absent file
885            // carries no version — it has no entry in `version_by_uri`.
886            let version = version_by_uri.get(&uri).copied().flatten();
887            self.client.publish_diagnostics(uri, diags, version).await;
888        }
889        let still_current = {
890            let mut state = self.state.write().await;
891            if let Some(ps) = state.projects.get_mut(&root)
892                && ps.analysis_round_committed == round
893            {
894                ps.published = dirty;
895                true
896            } else {
897                false
898            }
899        };
900        // #733: revalidate. Pull-based decorations are served from the committed
901        // round (`committed_analysis`) without a forced re-analysis, so a fresh
902        // round is invisible to the client until it re-pulls. Nudge it to — but
903        // only for this round if a newer one has not already superseded it (that
904        // one sends its own nudge), and only for decorations the client can
905        // refresh. Fired on a detached task: `run_project_diagnostics` also runs
906        // on the *request* path (a cursor request's forced refresh), and a
907        // `workspace/*/refresh` awaits a client round-trip — spawning keeps that
908        // off the request's critical path. Best-effort: a failed nudge just
909        // leaves the client on the previous pull until its next request.
910        if still_current {
911            let refresh = self.state.read().await.supports_refresh;
912            if refresh.semantic_tokens || refresh.inlay_hints || refresh.code_lens {
913                let client = self.client.clone();
914                tokio::spawn(async move {
915                    if refresh.semantic_tokens {
916                        let _ = client.semantic_tokens_refresh().await;
917                    }
918                    if refresh.inlay_hints {
919                        let _ = client.inlay_hint_refresh().await;
920                    }
921                    if refresh.code_lens {
922                        let _ = client.code_lens_refresh().await;
923                    }
924                });
925            }
926        }
927    }
928
929    /// Slice A: the analysis roots for the project that owns `uri` — the
930    /// manifest's, resolved by the compiler's own discovery. `None` in
931    /// single-file mode (no project root), where cross-file lookups are skipped.
932    /// Slice D: routes by URI (Q4), so a completion in project B enumerates B's
933    /// units, not the first project's.
934    ///
935    /// Replaces `project_src_root`, which returned `root.join(config.src_dir)`:
936    /// one tree, chosen by reducing `[paths] include` to its first entry and
937    /// ignoring `exclude`. That reduction is the defect slice A removed.
938    async fn analysis_roots_for(&self, uri: &Url) -> Option<bynk_ide::AnalysisRoots> {
939        Some(bynk_ide::AnalysisRoots::Project(
940            self.root_for_uri(uri).await?,
941        ))
942    }
943
944    /// Content-ownership track (#1086): the owning project's `.bynk` files
945    /// as a pre-read `(path, content)` map — every open buffer's **live**
946    /// text, a real disk read for everything else `bynk_ide::discover_files`
947    /// names. `None` in single-file mode. Backs completion, signature help
948    /// (slice 0), and the cross-file symbol lookups (slice 1) — this is now
949    /// the sole enumeration entry point; the bare-paths `project_files` it
950    /// replaced (slice 0's `Backend::project_files`) was deleted once slice 1
951    /// migrated its last two callers.
952    ///
953    /// Finding #62's exclusion carries over unchanged from `project_files`:
954    /// the cursor's own file is filtered out here, once, for every caller —
955    /// `bynk-ide`'s completion helpers already parse it fresh from the live
956    /// buffer (`for_each_unit`'s `doc_text`), so leaving it in the map would
957    /// offer a second, stale version of the same file alongside the live one.
958    async fn project_content(
959        &self,
960        uri: &Url,
961    ) -> Option<Arc<std::collections::HashMap<PathBuf, String>>> {
962        let roots = self.analysis_roots_for(uri).await?;
963        let current = uri
964            .to_file_path()
965            .ok()
966            .and_then(|p| std::fs::canonicalize(&p).ok());
967        let overlay = {
968            let state = self.state.read().await;
969            let mut ov = std::collections::HashMap::new();
970            for (u, doc) in &state.docs {
971                if let Ok(p) = u.to_file_path() {
972                    let canonical = p.canonicalize().unwrap_or(p);
973                    ov.insert(canonical, doc.text.clone());
974                }
975            }
976            ov
977        };
978        tokio::task::spawn_blocking(move || {
979            let mut content = crate::content::sweep_project_content(&roots, &overlay);
980            if let Some(current) = current {
981                content.remove(&current);
982            }
983            // `Arc`, not an owned map: a caller that needs to move this into
984            // its own `spawn_blocking` closure (signature help fires on every
985            // `(`/`,`) clones a refcount, not every project file's content.
986            Arc::new(content)
987        })
988        .await
989        .map_err(|e| tracing::error!("project content sweep {}", describe_join_error(e)))
990        .ok()
991    }
992
993    /// v0.31: the def + use spans of the local under the cursor (def first), or
994    /// `None` if the cursor is not on a local.
995    fn local_sites(
996        &self,
997        analysis: &Analysis,
998        rel: &std::path::Path,
999        offset: usize,
1000    ) -> Option<Vec<bynk_syntax::span::Span>> {
1001        let text = analysis.snapshots.get(rel)?;
1002        let locals = analysis.locals.get(rel)?;
1003        crate::locals_nav::local_sites_at(locals, text, offset)
1004    }
1005
1006    /// v0.31 (ADR 0064): the in-scope local bindings at the cursor, as
1007    /// `variable` completions, read from the **cached** analysis — so they
1008    /// survive the mid-edit buffer the current keystroke produced (the last
1009    /// good round's bindings around the cursor are what's wanted). Positions
1010    /// convert against the cached snapshot, like the other cached-round reads.
1011    async fn locals_completions(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
1012        // Slice B: completion's locals sub-path resolves `pos` against the
1013        // round's snapshot (like `index_position`), so it refreshes too — the
1014        // one exposed reader the §4.2 table missed.
1015        let analysis = self.analysis_for(uri).await;
1016        let Some(analysis) = analysis else {
1017            return Vec::new();
1018        };
1019        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1020            return Vec::new();
1021        };
1022        let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
1023        else {
1024            return Vec::new();
1025        };
1026        let Some(offset) = crate::position::position_to_offset(text, pos) else {
1027            return Vec::new();
1028        };
1029        bynk_check::locals::locals_at(locals, offset)
1030            .into_iter()
1031            .map(|b| CompletionItem {
1032                label: b.name.clone(),
1033                kind: Some(CompletionItemKind::VARIABLE),
1034                detail: Some(b.ty.clone()),
1035                ..Default::default()
1036            })
1037            .collect()
1038    }
1039
1040    /// Convert same-file local spans to LSP `Location`s.
1041    fn local_locations(
1042        &self,
1043        analysis: &Analysis,
1044        rel: &std::path::Path,
1045        spans: &[bynk_syntax::span::Span],
1046    ) -> Vec<Location> {
1047        let Some(text) = analysis.snapshots.get(rel) else {
1048            return Vec::new();
1049        };
1050        let Ok(uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
1051            return Vec::new();
1052        };
1053        spans
1054            .iter()
1055            .map(|s| Location {
1056                uri: uri.clone(),
1057                range: crate::position::span_to_range(text, *s),
1058            })
1059            .collect()
1060    }
1061
1062    /// Slice 3 (ADR 0063): complete the members of a typed **value** receiver.
1063    /// Re-analyses the buffer rewritten so the receiver parses (the trailing
1064    /// `.partial` dropped), types the receiver via the retained `expr_types`,
1065    /// and maps its type to kernel methods + record fields. Silent (not
1066    /// necessarily empty — see below) when the receiver itself has no
1067    /// recorded type; an error elsewhere in the file no longer silences it
1068    /// (ADR 0094's partial types lifted the clean-file ceiling).
1069    ///
1070    /// #596: additionally merges a bare `store` field receiver's own
1071    /// vocabulary (entry ops, and for `Map` the `.entries`/`.keys`/`.values`
1072    /// accessors) — dispatched by receiver *provenance* in the checker, which
1073    /// the typed `ty` alone can't distinguish from an ordinary `Query`-typed
1074    /// local (a bare store `Map` widens to `Ty::Query` too, ADR 0120). This
1075    /// half runs **independently of whether `type_receiver` succeeded**: it
1076    /// re-parses the buffer itself and needs no typed `ty` at all, so a `store`
1077    /// field still offers its entry ops/accessors even when the receiver goes
1078    /// untyped. (Its original motivation — an unresolved name *elsewhere* in
1079    /// the file bailing the checker before it ran, the one clean-file-ceiling
1080    /// gap ADR 0094 didn't close — has gone since #1663 let the checker run
1081    /// past a resolve error.) A review on #812 flagged the earlier draft's
1082    /// single early return as undercutting that motivation.
1083    async fn value_member_completions(
1084        &self,
1085        uri: &Url,
1086        text: &str,
1087        offset: usize,
1088    ) -> Vec<CompletionItem> {
1089        let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(text, offset)
1090        else {
1091            return Vec::new();
1092        };
1093        let mut items: Vec<CompletionItem> = Vec::new();
1094        if let Some((ty, tys)) = self
1095            .type_receiver(uri, rewritten.clone(), recv_offset)
1096            .await
1097        {
1098            let files = self.project_content(uri).await;
1099            items.extend(
1100                completion::value_member_candidates(ty, &tys, text, files.as_deref())
1101                    .into_iter()
1102                    .map(to_completion_item),
1103            );
1104        }
1105        // #2138: `run.id`, when `run` is the test run (it has no type then).
1106        if items.is_empty() {
1107            items.extend(
1108                completion::run_id_member_candidates(&rewritten, recv_offset)
1109                    .into_iter()
1110                    .map(to_completion_item),
1111            );
1112        }
1113        let locals = self.fast_path_locals(uri, &rewritten).await;
1114        items.extend(
1115            completion::store_field_member_candidates(&rewritten, recv_offset, &locals)
1116                .into_iter()
1117                .map(to_completion_item),
1118        );
1119        items
1120    }
1121
1122    /// #596: the current analysed round's locals for `uri`, only when its
1123    /// snapshot exactly matches `rewritten` — the same fast-path match
1124    /// [`Self::type_receiver`] uses. Empty (rather than forcing a synchronous
1125    /// re-analysis) when the round is stale or absent, so the store-field
1126    /// shadowing check degrades to "no local shadows the name".
1127    async fn fast_path_locals(
1128        &self,
1129        uri: &Url,
1130        rewritten: &str,
1131    ) -> Vec<bynk_check::locals::LocalBinding> {
1132        let Some(analysis) = self.project_analysis_for(uri).await else {
1133            return Vec::new();
1134        };
1135        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1136            return Vec::new();
1137        };
1138        if analysis.snapshots.get(&rel).map(String::as_str) != Some(rewritten) {
1139            return Vec::new();
1140        }
1141        analysis.locals.get(&rel).cloned().unwrap_or_default()
1142    }
1143
1144    /// v0.124 (slice 3): at `<expr> is <cursor>`, the scrutinee sum type's
1145    /// variants. The scrutinee is typed via `expr_types` (re-analysing through
1146    /// `type_receiver`, the value-member path), so it is subject to the clean-
1147    /// file ceiling and goes silent — never wrong — on a broken buffer.
1148    async fn is_pattern_completions(
1149        &self,
1150        uri: &Url,
1151        text: &str,
1152        offset: usize,
1153    ) -> Vec<CompletionItem> {
1154        let Some(scrut_off) = is_scrutinee_offset(text, offset) else {
1155            return Vec::new();
1156        };
1157        self.scrutinee_variant_completions(uri, text, scrut_off)
1158            .await
1159    }
1160
1161    /// v0.128: at an arm-pattern-start inside a `match <expr> { … }`, the
1162    /// scrutinee sum type's variants — the deferred half of slice 3's
1163    /// `is`-pattern completion, sharing its scrutinee typing and candidate set.
1164    async fn match_arm_completions(
1165        &self,
1166        uri: &Url,
1167        text: &str,
1168        offset: usize,
1169    ) -> Vec<CompletionItem> {
1170        let Some(scrut_off) = match_scrutinee_offset(text, offset) else {
1171            return self.let_else_match_completions(uri, text, offset).await;
1172        };
1173        self.scrutinee_variant_completions(uri, text, scrut_off)
1174            .await
1175    }
1176
1177    /// #2010: at an arm-pattern-start inside `let <pattern> (= | <-) <value>
1178    /// else match { … }`, the variants of the value's type (its `Effect`
1179    /// payload for `<-`) that the `let` pattern leaves for the arms to cover.
1180    async fn let_else_match_completions(
1181        &self,
1182        uri: &Url,
1183        text: &str,
1184        offset: usize,
1185    ) -> Vec<CompletionItem> {
1186        let Some(head) = let_else_match_head(text, offset) else {
1187            return Vec::new();
1188        };
1189        // Just past the value's last character: an offset *on* it would also
1190        // sit at the end of its innermost sub-expression (the `o` in
1191        // `Effect.pure(o)`), which `type_at_offset` prefers as the smaller span.
1192        let Some((ty, tys)) = self
1193            .type_receiver(uri, text.to_string(), head.value_end + 1)
1194            .await
1195        else {
1196            return Vec::new();
1197        };
1198        let ty = if head.effectful {
1199            match &*tys.get(ty) {
1200                bynk_check::checker::Ty::Effect(inner) => *inner,
1201                _ => return Vec::new(),
1202            }
1203        } else {
1204            ty
1205        };
1206        let files = self.project_content(uri).await;
1207        completion::variants_for_ty(ty, &tys, text, files.as_deref())
1208            .into_iter()
1209            .filter(|c| head.covered.as_deref() != Some(c.label.as_str()))
1210            .map(to_completion_item)
1211            .collect()
1212    }
1213
1214    /// The variants of the scrutinee whose last character is at `scrut_off` — the
1215    /// shared tail of `is`/`match` pattern completion. Types the scrutinee via
1216    /// `expr_types` (ADR 0094's partial types on a broken buffer; silent when
1217    /// the scrutinee itself has no recorded type) and offers its variants;
1218    /// empty for a non-sum, non-`Result`/`Option`
1219    /// scrutinee. v0.145 (ADR 0169): `Result`/`Option` scrutinees now fire too
1220    /// (`variants_for_ty`), not only user-declared sums.
1221    async fn scrutinee_variant_completions(
1222        &self,
1223        uri: &Url,
1224        text: &str,
1225        scrut_off: usize,
1226    ) -> Vec<CompletionItem> {
1227        let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1228            return Vec::new();
1229        };
1230        let files = self.project_content(uri).await;
1231        completion::variants_for_ty(ty, &tys, text, files.as_deref())
1232            .into_iter()
1233            .map(to_completion_item)
1234            .collect()
1235    }
1236
1237    /// v0.145 (ADR 0169): at `OuterVariant(‸` inside a match arm-pattern, the
1238    /// payload field type's variants — e.g. `Ok`/`Err` inside `Some(‸)` on an
1239    /// `Option[Result[…]]` scrutinee. `match_scrutinee_offset` deliberately bails
1240    /// on a nested constructor; `nested_pattern_offset` targets exactly it,
1241    /// yielding the scrutinee offset and the outer variant. Types the scrutinee
1242    /// via the same `type_receiver` path and resolves the payload type.
1243    async fn nested_pattern_completions(
1244        &self,
1245        uri: &Url,
1246        text: &str,
1247        offset: usize,
1248    ) -> Vec<CompletionItem> {
1249        let Some((scrut_off, variant)) = nested_pattern_offset(text, offset) else {
1250            return Vec::new();
1251        };
1252        let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1253            return Vec::new();
1254        };
1255        let files = self.project_content(uri).await;
1256        completion::nested_variant_completions(ty, &tys, &variant, text, files.as_deref())
1257            .into_iter()
1258            .map(to_completion_item)
1259            .collect()
1260    }
1261
1262    /// v0.32 (ADR 0065): the type of a receiver expression at `recv_offset` in a
1263    /// buffer `rewritten` so it parses — re-analyse the overlay and query the
1264    /// retained `expr_types`. Shared by value-member completion and signature
1265    /// help; `None` when the receiver has no recorded type. A file with errors
1266    /// elsewhere still types it (ADR 0094's best-effort partial types).
1267    async fn type_receiver(
1268        &self,
1269        uri: &Url,
1270        rewritten: String,
1271        recv_offset: usize,
1272    ) -> Option<(
1273        bynk_check::checker::TyId,
1274        std::sync::Arc<bynk_check::checker::Types>,
1275    )> {
1276        // T3.6b (R4.1): the id is meaningless without the table it was minted
1277        // from, so both travel together — the round's own table on the fast
1278        // path, the fresh re-analysis's on the slow one.
1279        let roots = self.analysis_roots_for(uri).await?;
1280        let project_root = roots.project_root().to_path_buf();
1281        let canonical_root = project_root
1282            .canonicalize()
1283            .unwrap_or_else(|_| project_root.clone());
1284        let cur = uri.to_file_path().ok()?;
1285        let cur = cur.canonicalize().unwrap_or(cur);
1286        // Slice A: project-relative, matching the round's identity (ADR 0198).
1287        let rel = cur.strip_prefix(&canonical_root).ok()?.to_path_buf();
1288        // Overlay every open doc, with this one rewritten so it parses.
1289        let overlay = {
1290            let state = self.state.read().await;
1291            let mut ov = std::collections::HashMap::new();
1292            for (u, doc) in &state.docs {
1293                if let Ok(p) = u.to_file_path() {
1294                    let canonical = p.canonicalize().unwrap_or(p);
1295                    let t = if u == uri {
1296                        rewritten.clone()
1297                    } else {
1298                        doc.text.clone()
1299                    };
1300                    ov.insert(canonical, t);
1301                }
1302            }
1303            ov
1304        };
1305        // Fast path (#513): completion fires on every `.` keystroke, and the
1306        // rewritten buffer (the trailing `.`-segment removed so it parses) is
1307        // usually byte-identical to the snapshot the last debounced round
1308        // analysed. Reuse that round's expression types instead of running a
1309        // synchronous whole-project re-analysis on the request path.
1310        if let Some(analysis) = self.project_analysis_for(uri).await
1311            && analysis.snapshots.get(&rel).map(String::as_str) == Some(rewritten.as_str())
1312            && let Some((_, entries)) = analysis.expr_types.iter().find(|(p, _)| **p == rel)
1313        {
1314            return bynk_check::expr_types::type_at_offset(
1315                entries,
1316                recv_offset,
1317                &analysis.ty_intern,
1318            )
1319            .map(|t| (t, std::sync::Arc::clone(&analysis.ty_intern)));
1320        }
1321        // Content-ownership track (#1086) slice 5: same complete-content
1322        // requirement as `run_project_diagnostics` — `overlay` here is only
1323        // the open buffers, and `discovery.rs`'s disk fallback is gone.
1324        // #2081: `Off` — this re-analysis only reads expression types, which
1325        // the schema lock cannot change, so it skips the event sweep.
1326        let result = tokio::task::spawn_blocking(move || {
1327            let content = crate::content::sweep_project_content(&roots, &overlay);
1328            bynk_ide::diagnose_project_with(&roots, &content, &bynk_ide::SchemaLock::Off)
1329        })
1330        .await
1331        .map_err(|e| tracing::error!("receiver-typing analysis {}", describe_join_error(e)))
1332        .ok()?;
1333        let (_, entries) = result.expr_types.iter().find(|(p, _)| **p == rel)?;
1334        bynk_check::expr_types::type_at_offset(entries, recv_offset, &result.ty_intern)
1335            .map(|t| (t, std::sync::Arc::clone(&result.ty_intern)))
1336    }
1337
1338    /// Slice D: the committed analysis for one project root, ungated — the raw
1339    /// last round, or `None` if the root has no entry or has not analysed yet.
1340    async fn project_analysis(&self, root: &std::path::Path) -> Option<Arc<Analysis>> {
1341        self.state.read().await.projects.get(root)?.analysis.clone()
1342    }
1343
1344    /// The owning project's committed analysis for `uri`, ungated. For callers
1345    /// that reuse a round opportunistically (completion's receiver-typing fast
1346    /// path); the freshness gate is [`Self::analysis_for`].
1347    async fn project_analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1348        let root = self.root_for_uri(uri).await?;
1349        self.project_analysis(&root).await
1350    }
1351
1352    /// Ensure `root` has an entry (created with `config` if absent) and a
1353    /// committed analysis (one round run if none yet), and return it. For the
1354    /// cross-project workspace-symbol scan, which must answer over every project
1355    /// including ones no request has warmed. `None` if the round produced none.
1356    async fn ensure_project_analysed(
1357        &self,
1358        root: PathBuf,
1359        config: ProjectConfig,
1360    ) -> Option<Arc<Analysis>> {
1361        {
1362            let mut state = self.state.write().await;
1363            state
1364                .projects
1365                .entry(root.clone())
1366                .or_insert_with(|| ProjectState {
1367                    config,
1368                    ..Default::default()
1369                });
1370        }
1371        if let Some(a) = self.project_analysis(&root).await {
1372            return Some(a);
1373        }
1374        self.refresh_now(root.clone()).await;
1375        self.project_analysis(&root).await
1376    }
1377
1378    /// Slice D (Q4 lifecycle): drop every project no longer reachable from a
1379    /// workspace folder **and** holding no open buffer, clearing its published
1380    /// diagnostics. A project is retained while some remaining folder relates to
1381    /// it (one is a path-prefix of the other — a file under that folder can still
1382    /// route to the root) or while any open buffer routes to it. Shared by the
1383    /// two events that can orphan a project: a folder leaving
1384    /// (`did_change_workspace_folders`) and its last buffer closing (`did_close`)
1385    /// — a project falls only when *both* its seed and its buffers are gone.
1386    /// Returns the URIs whose diagnostics were cleared so the caller can publish
1387    /// the clears (done outside the lock).
1388    async fn prune_orphaned_projects(&self) -> Vec<Url> {
1389        // #733: `root_for_uri_uncached` canonicalises and walks the filesystem
1390        // up to a `bynk.toml` for every open buffer — syscalls that must not run
1391        // while holding `state.write()`. Snapshot the inputs under a short read
1392        // lock, resolve the open roots off the lock, then take the write lock
1393        // only to mutate `projects`.
1394        //
1395        // #682 (DECISION B): this stays on the *uncached* router rather than
1396        // `root_for_uri` — pruning is not hot (it fires only on folder-removal
1397        // or close), and it runs inside a synchronous `filter_map` off the
1398        // lock, where an async, cache-consulting router can't be called inline
1399        // without either re-locking `state` here (defeating the point of
1400        // computing `open_roots` off-lock) or restructuring this into an async
1401        // stream. This opens a small TOCTOU window: `orphaned` is
1402        // computed against live `state.projects` under the write lock but against
1403        // the *snapshot's* `folders`/`open_roots`, so a `did_open` that lands in
1404        // between — newly covering a root — is not yet in `open_roots` and that
1405        // root could be pruned here. It is self-healing: the pruning callers
1406        // (`did_close`, `did_change_workspace_folders`) only ever *remove*
1407        // coverage, so a racing `did_open` re-creates the entry the moment that
1408        // buffer routes/analyses (`schedule_diagnostics` → a lazily-created
1409        // `ProjectState`) — its diagnostics clear-then-repopulate, never a
1410        // permanently-dropped project.
1411        let (folders, open_uris) = {
1412            let state = self.state.read().await;
1413            (
1414                state.folders.clone(),
1415                state.docs.keys().cloned().collect::<Vec<_>>(),
1416            )
1417        };
1418        let open_roots: std::collections::HashSet<PathBuf> = open_uris
1419            .iter()
1420            .filter_map(Self::root_for_uri_uncached)
1421            .collect();
1422        let covered = |root: &std::path::Path| {
1423            folders
1424                .iter()
1425                .any(|f| f.starts_with(root) || root.starts_with(f))
1426                || open_roots.contains(root)
1427        };
1428        let mut state = self.state.write().await;
1429        let orphaned: Vec<PathBuf> = state
1430            .projects
1431            .keys()
1432            .filter(|r| !covered(r))
1433            .cloned()
1434            .collect();
1435        let mut to_clear = Vec::new();
1436        for root in orphaned {
1437            if let Some(ps) = state.projects.remove(&root) {
1438                to_clear.extend(ps.published);
1439            }
1440        }
1441        to_clear
1442    }
1443
1444    /// Slice E: discover and warm every project under `folders` — create each
1445    /// entry (idempotent, keyed by canonical root) and schedule its round — so a
1446    /// workspace shows diagnostics without a file being opened. Non-blocking:
1447    /// entries are created synchronously (routing is immediately correct) and the
1448    /// rounds run on the debounce path. Shared by `initialized` (all folders) and
1449    /// the `did_change_workspace_folders` added branch (the new folders).
1450    async fn warm_projects(&self, folders: &[PathBuf]) {
1451        if folders.is_empty() {
1452            return;
1453        }
1454        // Discover off the lock **and** off the executor: the walk is synchronous
1455        // FS I/O, so run it on a blocking thread rather than stalling an async
1456        // worker while a workspace tree is scanned.
1457        let folders = folders.to_vec();
1458        let roots = tokio::task::spawn_blocking(move || {
1459            let mut roots: Vec<PathBuf> = Vec::new();
1460            for folder in &folders {
1461                for root in Self::discover_projects_under(folder) {
1462                    if !roots.contains(&root) {
1463                        roots.push(root);
1464                    }
1465                }
1466            }
1467            roots
1468        })
1469        .await
1470        .unwrap_or_else(|e| {
1471            tracing::error!("project discovery {}", describe_join_error(e));
1472            Vec::new()
1473        });
1474        for root in roots {
1475            let config = project::load_config(&root).unwrap_or_default();
1476            {
1477                let mut state = self.state.write().await;
1478                state
1479                    .projects
1480                    .entry(root.clone())
1481                    .or_insert_with(|| ProjectState {
1482                        config,
1483                        ..Default::default()
1484                    });
1485            }
1486            self.schedule_project_diagnostics(root).await;
1487        }
1488    }
1489
1490    /// Slice E: register the `workspace/didChangeWatchedFiles` capability with
1491    /// the client — once, with folder-independent globs (`**/*.bynk`,
1492    /// `**/bynk.toml`, and since #2081 `**/bynk.schema.lock`), per Q4 (ADR
1493    /// 0204 §D). So a client that supports dynamic
1494    /// registration is notified of source and manifest changes without watching
1495    /// files itself. Best-effort: a registration failure is logged, not fatal.
1496    async fn register_file_watchers(&self) {
1497        use tower_lsp::lsp_types::{
1498            DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, Registration,
1499        };
1500        let watchers = ["**/*.bynk", "**/bynk.toml", "**/bynk.schema.lock"]
1501            .into_iter()
1502            .map(|g| FileSystemWatcher {
1503                glob_pattern: GlobPattern::String(g.to_string()),
1504                kind: None, // create | change | delete
1505            })
1506            .collect();
1507        let registration = Registration {
1508            id: "bynk-watched-files".to_string(),
1509            method: "workspace/didChangeWatchedFiles".to_string(),
1510            register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
1511                watchers,
1512            })
1513            .ok(),
1514        };
1515        if let Err(e) = self.client.register_capability(vec![registration]).await {
1516            self.client
1517                .log_message(
1518                    MessageType::WARNING,
1519                    format!("bynkc-lsp: file-watcher registration failed: {e}"),
1520                )
1521                .await;
1522        }
1523    }
1524
1525    /// The `bynk.toml` config governing `uri` — its project's, or the default
1526    /// (single-file mode). Backs the per-file diagnostics mode/debounce and the
1527    /// formatting options, which now differ by project.
1528    async fn config_for(&self, uri: &Url) -> ProjectConfig {
1529        let Some(root) = self.root_for_uri(uri).await else {
1530            return ProjectConfig::default();
1531        };
1532        self.state
1533            .read()
1534            .await
1535            .projects
1536            .get(&root)
1537            .map(|p| p.config.clone())
1538            .unwrap_or_default()
1539    }
1540
1541    /// Slice B — the freshness contract (Q3, settled #663). The analysis a
1542    /// request must answer from, **current for `uri`**: cold start triggers a
1543    /// round; a round that predates `uri`'s buffer triggers a refresh.
1544    ///
1545    /// The client's request position refers to `uri`'s current document
1546    /// version — messages are ordered, so `docs[uri].version` reflects every
1547    /// `didChange` sent before the request. The returned analysis is guaranteed
1548    /// to have analysed *that* version of `uri`, so `position_to_offset` against
1549    /// its snapshot is never resolved against text the user edited past.
1550    ///
1551    /// Slice D: routes to the project that owns `uri` (Q4) before gating, so the
1552    /// freshness check is against *that* project's round. A file under no
1553    /// project (single-file mode) is never index-answerable — decline.
1554    ///
1555    /// Returns `None` — decline, per Q3 — only when the request cannot be
1556    /// answered at the version the client holds: single-file mode (no project),
1557    /// a file outside every `include` root (never a snapshot key), or a
1558    /// concurrent edit that moved past the refresh (rare; the next request is
1559    /// current). Never returns an analysis whose snapshot for `uri` is stale.
1560    async fn analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1561        let root = self.root_for_uri(uri).await?;
1562        // The version the request's position is stated against. `None` when the
1563        // file is not an open buffer — then any round is as authoritative as it
1564        // gets (nothing newer to be stale against), so the freshness gate is a
1565        // no-op and only cold start matters.
1566        let want = self.state.read().await.docs.get(uri).map(|d| d.version);
1567        let current = |a: &Arc<Analysis>| {
1568            let Some(rel) = Self::uri_to_rel(a, uri) else {
1569                return false; // unmappable URI — cannot be answered
1570            };
1571            // The file must actually be *analysed* (a snapshot key), not merely
1572            // have a version entry: `versions` is built from open docs, so a
1573            // file open but outside every `include` root has a version and no
1574            // snapshot. Such a file is never answerable — decline.
1575            if !a.snapshots.contains_key(&rel) {
1576                return false;
1577            }
1578            match want {
1579                // Open buffer: the analysed snapshot must be at the client's
1580                // version, or the position resolves against text edited past.
1581                Some(v) => a.versions.get(&rel) == Some(&v),
1582                // Not an open buffer (a closed/disk file, e.g. a goto target):
1583                // the analysed round is authoritative — nothing newer to lag.
1584                None => true,
1585            }
1586        };
1587
1588        if let Some(a) = self.project_analysis(&root).await
1589            && current(&a)
1590        {
1591            return Some(a);
1592        }
1593
1594        // Refresh. The lock serialises concurrent requests: the first runs the
1595        // round, the rest wait and then find it already current below — so N
1596        // requests after one edit share one round, not N. (One lock across all
1597        // projects is fine — a refresh holds it only across its own round.)
1598        let _guard = self.refresh_lock.lock().await;
1599        if let Some(a) = self.project_analysis(&root).await
1600            && current(&a)
1601        {
1602            return Some(a);
1603        }
1604        self.refresh_now(root.clone()).await;
1605        let a = self.project_analysis(&root).await?;
1606        // Strict: only answer if the fresh round is actually current for `uri`.
1607        // An edit that landed during the round leaves us behind — decline, and
1608        // the next request refreshes again. Never a position against stale text.
1609        current(&a).then_some(a)
1610    }
1611
1612    /// #733 — the non-refreshing gate for **pull-based decoration requests**
1613    /// (`semanticTokens`, `inlayHint`, `codeLens`, `documentLink`, `codeAction`).
1614    /// Returns the last committed round for `uri`'s project **as-is**, without
1615    /// forcing a synchronous re-analysis on the request path.
1616    ///
1617    /// Why this is safe where [`Self::analysis_for`] is not: these handlers
1618    /// resolve nothing against the client's *live* cursor — every range and span
1619    /// they emit converts against the round's own `snapshots` (or, for
1620    /// `document_link`, against live text plus the project-level `unit_sources`
1621    /// map). So a committed round lagging the buffer by at most one debounce
1622    /// cycle is internally consistent; the strict version match `analysis_for`
1623    /// demands is stronger than a decoration needs. The editor auto-fires these
1624    /// on every `didChange`, so forcing a whole-project round here is exactly
1625    /// what defeated the debounce (#733).
1626    ///
1627    /// This is stale-while-revalidate: serve the committed round now; the
1628    /// already-scheduled debounce round is the revalidation, and on its commit
1629    /// [`Self::run_project_diagnostics`] nudges the client to re-pull via
1630    /// `workspace/*/refresh`. Cursor requests keep the strict gate.
1631    ///
1632    /// `None` — the handler returns empty — when the file is under no project, is
1633    /// outside every `include` root (never a snapshot key), or no round has
1634    /// committed yet (cold start; the scheduled round will produce one and the
1635    /// client re-pulls on the refresh nudge).
1636    async fn committed_analysis(&self, uri: &Url) -> Option<Arc<Analysis>> {
1637        let root = self.root_for_uri(uri).await?;
1638        let a = self.project_analysis(&root).await?;
1639        // Must actually be analysed (a snapshot key), not merely version-tracked
1640        // — the handler converts its spans against this snapshot.
1641        let rel = Self::uri_to_rel(&a, uri)?;
1642        a.snapshots.contains_key(&rel).then_some(a)
1643    }
1644
1645    /// #2100 (DECISION B): the organise-imports action for `rel`, offered
1646    /// only if its edit re-checks clean. The full organise (drops included)
1647    /// is tried first; if a drop would introduce a diagnostic, the sort-and-
1648    /// dedupe-only edit is tried instead, so one wrongly-judged drop doesn't
1649    /// withhold the whole action.
1650    async fn organise_action(
1651        &self,
1652        analysis: &Arc<Analysis>,
1653        rel: &std::path::Path,
1654        uri: &Url,
1655        version: Option<i32>,
1656    ) -> Option<CodeActionOrCommand> {
1657        use crate::organise::{Mode, Project};
1658        let text = analysis.snapshots.get(rel)?;
1659        let project = Project {
1660            index: &analysis.index,
1661            snapshots: &analysis.snapshots,
1662            unit_sources: &analysis.unit_sources,
1663        };
1664        let mut tried: Option<String> = None;
1665        for mode in [Mode::Full, Mode::SortOnly] {
1666            let Some(edit) = crate::organise::organise(text, rel, mode, &project) else {
1667                continue;
1668            };
1669            let edited = crate::organise::apply(text, &edit);
1670            if tried.as_deref() == Some(edited.as_str()) {
1671                continue;
1672            }
1673            if self.rechecks_clean(analysis, rel, edited.clone()).await {
1674                return Some(crate::organise::action(text, edit, uri, version));
1675            }
1676            tried = Some(edited);
1677        }
1678        None
1679    }
1680
1681    /// #2100 slice 1: resolve a listed inline action — re-plan it against the
1682    /// committed round (which must still be the version it was listed from)
1683    /// and offer the edit only if the inlined project re-checks clean.
1684    async fn resolve_inline(
1685        &self,
1686        action: CodeAction,
1687        target: crate::inline::Target,
1688    ) -> JsonRpcResult<CodeAction> {
1689        let refused = |msg: &str| tower_lsp::jsonrpc::Error {
1690            code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
1691            message: msg.to_string().into(),
1692            data: None,
1693        };
1694        let stale = "the document changed since the inline was offered";
1695        let analysis = self
1696            .committed_analysis(&target.uri)
1697            .await
1698            .ok_or_else(|| refused(stale))?;
1699        let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
1700        let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
1701        else {
1702            return Err(refused(stale));
1703        };
1704        let version = analysis.versions.get(&rel).copied();
1705        if version != target.version {
1706            return Err(refused(stale));
1707        }
1708        let plan = crate::inline::plan(text, target.def, locals)
1709            .ok_or_else(|| refused("this binding can't be inlined"))?;
1710        let inlined = crate::fix_all::apply(text, &plan.edits);
1711        if !self.rechecks_clean(&analysis, &rel, inlined).await {
1712            return Err(refused(&format!(
1713                "inlining `{}` would introduce a new problem",
1714                plan.name
1715            )));
1716        }
1717        Ok(crate::fill_arms::resolve(
1718            action,
1719            text,
1720            &plan.edits,
1721            &target.uri,
1722            version,
1723        ))
1724    }
1725
1726    /// #2100 slice 4: resolve a listed provider-stub action — re-plan it
1727    /// against the committed round (which must still be the version it was
1728    /// listed from) and offer the edit only if the project re-checks clean.
1729    async fn resolve_provider_stub(
1730        &self,
1731        action: CodeAction,
1732        target: crate::provider_stub::Target,
1733    ) -> JsonRpcResult<CodeAction> {
1734        let refused = |msg: &str| tower_lsp::jsonrpc::Error {
1735            code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
1736            message: msg.to_string().into(),
1737            data: None,
1738        };
1739        let stale = "the document changed since the provider stub was offered";
1740        let analysis = self
1741            .committed_analysis(&target.uri)
1742            .await
1743            .ok_or_else(|| refused(stale))?;
1744        let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
1745        let Some(text) = analysis.snapshots.get(&rel) else {
1746            return Err(refused(stale));
1747        };
1748        let version = analysis.versions.get(&rel).copied();
1749        if version != target.version {
1750            return Err(refused(stale));
1751        }
1752        let plan = crate::provider_stub::plan(text, &target.capability, &analysis.index)
1753            .ok_or_else(|| refused("a provider stub can't be generated here"))?;
1754        let stubbed = crate::fix_all::apply(text, &plan.edits);
1755        if !self.rechecks_clean(&analysis, &rel, stubbed).await {
1756            return Err(refused(&format!(
1757                "a provider stub for `{}` would introduce a new problem",
1758                plan.capability
1759            )));
1760        }
1761        Ok(crate::fill_arms::resolve(
1762            action,
1763            text,
1764            &plan.edits,
1765            &target.uri,
1766            version,
1767        ))
1768    }
1769
1770    /// #2100 slice 2: resolve a listed extract-function action — re-plan it
1771    /// against the committed round (which must still be the version it was
1772    /// listed from) and offer the edit only if the project re-checks clean.
1773    async fn resolve_extract_function(
1774        &self,
1775        action: CodeAction,
1776        target: crate::extract::FunctionTarget,
1777    ) -> JsonRpcResult<CodeAction> {
1778        let refused = |msg: &str| tower_lsp::jsonrpc::Error {
1779            code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
1780            message: msg.to_string().into(),
1781            data: None,
1782        };
1783        let stale = "the document changed since the extraction was offered";
1784        let analysis = self
1785            .committed_analysis(&target.uri)
1786            .await
1787            .ok_or_else(|| refused(stale))?;
1788        let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
1789        let Some(text) = analysis.snapshots.get(&rel) else {
1790            return Err(refused(stale));
1791        };
1792        let version = analysis.versions.get(&rel).copied();
1793        if version != target.version {
1794            return Err(refused(stale));
1795        }
1796        let empty_reqs = Vec::new();
1797        let empty_locals = Vec::new();
1798        let empty_types = Vec::new();
1799        let plan = crate::extract::plan_function(
1800            text,
1801            target.requested,
1802            analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
1803            analysis.locals.get(&rel).unwrap_or(&empty_locals),
1804            analysis.expr_types.get(&rel).unwrap_or(&empty_types),
1805            &analysis.ty_intern,
1806        )
1807        .map_err(|r| {
1808            refused(
1809                r.reason
1810                    .as_deref()
1811                    .unwrap_or("the selection can't be extracted into a function"),
1812            )
1813        })?;
1814        let extracted = crate::fix_all::apply(text, &plan.edits);
1815        if !self.rechecks_clean(&analysis, &rel, extracted).await {
1816            return Err(refused(&format!(
1817                "extracting `{}` would introduce a new problem",
1818                plan.name
1819            )));
1820        }
1821        Ok(crate::fill_arms::resolve(
1822            action,
1823            text,
1824            &plan.edits,
1825            &target.uri,
1826            version,
1827        ))
1828    }
1829
1830    /// #2100 (DECISION B): re-analyse the round's project with `rel`'s text
1831    /// replaced by `edited`, and report whether that introduces no diagnostic
1832    /// the round didn't already have (rename's validator 1, for one file).
1833    /// Every other file is pinned to its round snapshot, so the re-analysis
1834    /// differs from the round only by the edit.
1835    async fn rechecks_clean(
1836        &self,
1837        analysis: &Arc<Analysis>,
1838        rel: &std::path::Path,
1839        edited: String,
1840    ) -> bool {
1841        let mut overlay = std::collections::HashMap::new();
1842        for (rel_path, text) in &analysis.snapshots {
1843            let abs = analysis.project_root.join(rel_path);
1844            let abs = abs.canonicalize().unwrap_or(abs);
1845            let text = if rel_path == rel {
1846                edited.clone()
1847            } else {
1848                text.clone()
1849            };
1850            overlay.insert(abs, text);
1851        }
1852        let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
1853        // #2081: `Off`, as rename's re-check: schema-lock diagnostics are
1854        // project-level and never compared here.
1855        let Ok(post) = tokio::task::spawn_blocking(move || {
1856            bynk_ide::diagnose_project_with(&roots, &overlay, &bynk_ide::SchemaLock::Off)
1857        })
1858        .await
1859        else {
1860            return false;
1861        };
1862        let post_diags: Vec<(PathBuf, String)> = post
1863            .files
1864            .iter()
1865            .flat_map(|f| {
1866                f.diagnostics
1867                    .iter()
1868                    .map(|d| (f.source_path.clone(), d.error.category.to_string()))
1869            })
1870            .collect();
1871        crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags).is_ok()
1872    }
1873
1874    /// Slice B: the analysis for a handler that emits **multi-file versioned
1875    /// edits** — today, `rename`. Per-URI freshness ([`Self::analysis_for`]) is
1876    /// not enough here: a rename touches every file that references the symbol,
1877    /// and each edit is stamped with *that* file's analysed version, so the
1878    /// round must be current for **every open buffer**, not just the cursor's.
1879    ///
1880    /// Without this, a buffer edited since the last round but not under the
1881    /// cursor keeps its stale version in the round; `rename`'s edit for it is
1882    /// then stamped with that old version and the client rejects the whole
1883    /// operation (VS Code: "document changed since the refactoring was
1884    /// requested"). This restores the whole-project guarantee the pre-v0.179
1885    /// `fresh_analysis` gave — as a version-aware refresh, not an unconditional
1886    /// one. Returns `None` on the same terms as `analysis_for` (no project, or a
1887    /// concurrent edit that raced the refresh).
1888    ///
1889    /// Slice D: takes the rename's project `root` — a rename spans one project
1890    /// (the symbol and its references live under one root), so the round must
1891    /// cover *that* project's open buffers. A buffer in another project strips
1892    /// against a different `project_root`, so `uri_to_rel` returns `None` for it
1893    /// and it does not gate this rename.
1894    async fn analysis_covering_open_buffers(
1895        &self,
1896        root: &std::path::Path,
1897    ) -> Option<Arc<Analysis>> {
1898        // Every open buffer that maps into the project must be analysed at its
1899        // current version. A buffer outside the project (no snapshot key) is not
1900        // part of a project rename and does not gate it.
1901        let all_current =
1902            |a: &Arc<Analysis>, docs: &std::collections::HashMap<Url, DocumentState>| {
1903                docs.iter()
1904                    .all(|(uri, doc)| match Self::uri_to_rel(a, uri) {
1905                        Some(rel) if a.snapshots.contains_key(&rel) => {
1906                            a.versions.get(&rel) == Some(&doc.version)
1907                        }
1908                        _ => true,
1909                    })
1910            };
1911
1912        {
1913            let state = self.state.read().await;
1914            if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1915                && all_current(&a, &state.docs)
1916            {
1917                return Some(a);
1918            }
1919        }
1920        let _guard = self.refresh_lock.lock().await;
1921        {
1922            let state = self.state.read().await;
1923            if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1924                && all_current(&a, &state.docs)
1925            {
1926                return Some(a);
1927            }
1928        }
1929        self.refresh_now(root.to_path_buf()).await;
1930        let state = self.state.read().await;
1931        let a = state.projects.get(root).and_then(|p| p.analysis.clone())?;
1932        all_current(&a, &state.docs).then_some(a)
1933    }
1934
1935    /// Run a round now for one project, superseding any pending debounced one.
1936    /// Bumping the project's generation makes a scheduled round (which checks it
1937    /// before running) bail, so a request-driven refresh does not race a
1938    /// redundant debounce round that would produce the same result 200 ms later.
1939    async fn refresh_now(&self, root: PathBuf) {
1940        if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
1941            ps.analysis_generation += 1;
1942        }
1943        self.run_project_diagnostics(root).await;
1944    }
1945
1946    /// Map a request URI to the analysis' project-relative path.
1947    fn uri_to_rel(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1948        let p = uri.to_file_path().ok()?;
1949        let canonical = p.canonicalize().unwrap_or(p);
1950        // Slice A: one `strip_prefix` still, but against the *project* root —
1951        // which is total across `include` trees, where the old `src` base could
1952        // only ever name files in one of them. A file under no root strips fine
1953        // and simply misses every lookup, which is correct: it was not analysed.
1954        canonical
1955            .strip_prefix(&analysis.project_root)
1956            .ok()
1957            .map(|r| r.to_path_buf())
1958    }
1959
1960    /// #302: like [`Self::uri_to_rel`], but for a URI whose file does not
1961    /// exist yet — `willRenameFiles`' `new_uri`, named before the physical
1962    /// move happens. `Path::canonicalize` requires the path to exist, so
1963    /// `uri_to_rel`'s fallback (`unwrap_or(p)`, dead code for every other
1964    /// caller, which only ever resolves existing files) would silently keep
1965    /// the client's raw, non-canonical path — mismatching `project_root`
1966    /// (always canonical) whenever the workspace sits behind a symlink (macOS
1967    /// `/tmp` → `/private/tmp` being the common case), and the rename would
1968    /// quietly produce no edit. Canonicalizing the *parent* directory
1969    /// instead — it does exist — and rejoining the file name sidesteps that.
1970    fn uri_to_rel_for_new_path(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1971        let p = uri.to_file_path().ok()?;
1972        let file_name = p.file_name()?;
1973        let parent = p.parent()?;
1974        let canonical_parent = parent
1975            .canonicalize()
1976            .unwrap_or_else(|_| parent.to_path_buf());
1977        canonical_parent
1978            .join(file_name)
1979            .strip_prefix(&analysis.project_root)
1980            .ok()
1981            .map(|r| r.to_path_buf())
1982    }
1983
1984    /// #2105: the per-file renames one `FileRename` stands for, as
1985    /// project-relative `(old, new)` paths. A path the round has a snapshot
1986    /// for is a file rename. Anything else is treated as a folder: every
1987    /// snapshot under it (a component-wise prefix match, so `src/bill` never
1988    /// matches `src/billing/…`), each mapped to the same relative position
1989    /// under the new folder. A folder with no project file under it, or an
1990    /// old/new path outside the project, yields nothing. Sorted, so the edit
1991    /// set is deterministic.
1992    fn expand_rename(analysis: &Analysis, old_uri: &Url, new_uri: &Url) -> Vec<(PathBuf, PathBuf)> {
1993        let (Some(old_rel), Some(new_rel)) = (
1994            Self::uri_to_rel(analysis, old_uri),
1995            Self::uri_to_rel_for_new_path(analysis, new_uri),
1996        ) else {
1997            return Vec::new();
1998        };
1999        if analysis.snapshots.contains_key(&old_rel) {
2000            return vec![(old_rel, new_rel)];
2001        }
2002        let mut pairs: Vec<(PathBuf, PathBuf)> = analysis
2003            .snapshots
2004            .keys()
2005            .filter_map(|rel| {
2006                let suffix = rel.strip_prefix(&old_rel).ok()?;
2007                (!suffix.as_os_str().is_empty()).then(|| (rel.clone(), new_rel.join(suffix)))
2008            })
2009            .collect();
2010        pairs.sort();
2011        pairs
2012    }
2013
2014    /// #2105 (DECISION B): the canonical project root a rename's destination
2015    /// would belong to. The destination does not exist yet (`willRenameFiles`
2016    /// fires before the move), so its existing parent is canonicalized and the
2017    /// last segment rejoined, as [`Self::uri_to_rel_for_new_path`] does, before
2018    /// the usual walk up to a `bynk.toml` (or implicit `src/` parent).
2019    fn new_path_root(new_uri: &Url) -> Option<PathBuf> {
2020        let p = new_uri.to_file_path().ok()?;
2021        let parent = p.parent()?;
2022        let canonical = parent
2023            .canonicalize()
2024            .unwrap_or_else(|_| parent.to_path_buf())
2025            .join(p.file_name()?);
2026        let (root, _) = Self::resolve_root(&canonical)?;
2027        Some(root.canonicalize().unwrap_or(root))
2028    }
2029
2030    /// Slice 6a follow-up (ADR 0095): if `pos` sits on a `uses`/`consumes` unit
2031    /// name, the location of that unit's source (its first file, at the top —
2032    /// units aren't index symbols, so there is no finer def span to land on).
2033    /// Spans come from the live buffer; the target from the round's unit→source
2034    /// map. `None` for a first-party/unresolved unit or a non-unit position.
2035    async fn unit_reference_definition(&self, uri: &Url, pos: Position) -> Option<Location> {
2036        // Slice B: the position is resolved against *live* text (no stale-offset
2037        // risk), but the `uses`/`consumes` → source lookup reads the round's
2038        // `unit_sources`, so route that through the gate — fresh or decline,
2039        // never a stale unit map. Cheap here: `goto_definition` already
2040        // refreshed via `index_position`, so this hits the current-round path.
2041        let analysis = self.analysis_for(uri).await;
2042        let text = self
2043            .state
2044            .read()
2045            .await
2046            .docs
2047            .get(uri)
2048            .map(|d| d.text.clone());
2049        let (text, analysis) = (text?, analysis?);
2050        let offset = cursor_offset(&text, pos);
2051        for (unit, span) in crate::symbols::unit_reference_spans(&text) {
2052            if span.start <= offset && offset <= span.end {
2053                // #2223: a dependency unit is keyed by its canonical name,
2054                // resolved against the live content map (an unsaved alias
2055                // edit counts, as it does for the analysed round).
2056                let view = if analysis.unit_sources.contains_key(&unit) {
2057                    None
2058                } else {
2059                    let files = self.project_content(uri).await;
2060                    let empty = std::collections::HashMap::new();
2061                    bynk_ide::packages::DependencyView::for_root(
2062                        &analysis.project_root,
2063                        files.as_deref().unwrap_or(&empty),
2064                    )
2065                };
2066                let rel = bynk_ide::packages::unit_sources_for(
2067                    &analysis.unit_sources,
2068                    view.as_ref(),
2069                    &unit,
2070                )?
2071                .first()?;
2072                let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
2073                return Some(Location {
2074                    uri: target,
2075                    range: Range::default(),
2076                });
2077            }
2078        }
2079        None
2080    }
2081
2082    /// Convert an index site to an LSP location, spans against the analysed
2083    /// snapshot (v0.24 rule).
2084    fn site_to_location(
2085        analysis: &Analysis,
2086        site: &bynk_check::index::SiteRef,
2087    ) -> Option<Location> {
2088        let text = analysis.snapshots.get(&site.path)?;
2089        let abs = analysis.project_root.join(&site.path);
2090        let uri = Url::from_file_path(abs).ok()?;
2091        Some(Location {
2092            uri,
2093            range: crate::position::span_to_range(text, site.span),
2094        })
2095    }
2096
2097    /// v0.34 (ADR 0067): build a `CallHierarchyItem` for an index symbol from
2098    /// its key + definition site. The key is round-tripped through `data` so
2099    /// the incoming/outgoing follow-ups resolve straight off it, never
2100    /// re-inferring from a position.
2101    fn call_hierarchy_item(
2102        analysis: &Analysis,
2103        key: &bynk_check::index::SymbolKey,
2104        def: &bynk_check::index::SiteRef,
2105    ) -> Option<CallHierarchyItem> {
2106        let location = Self::site_to_location(analysis, def)?;
2107        Some(CallHierarchyItem {
2108            name: key.name.clone(),
2109            kind: lsp_symbol_kind(key.kind),
2110            tags: None,
2111            detail: Some(key.unit.clone()),
2112            uri: location.uri,
2113            range: location.range,
2114            selection_range: location.range,
2115            data: serde_json::to_value(SerKey::from(key)).ok(),
2116        })
2117    }
2118
2119    /// The call-site ranges (`fromRanges`) for a call relation, each converted
2120    /// against its file's analysed snapshot.
2121    fn call_ranges(analysis: &Analysis, sites: &[&bynk_check::index::SiteRef]) -> Vec<Range> {
2122        sites
2123            .iter()
2124            .filter_map(|s| {
2125                let text = analysis.snapshots.get(&s.path)?;
2126                Some(crate::position::span_to_range(text, s.span))
2127            })
2128            .collect()
2129    }
2130
2131    /// v0.28 (ADR 0057): the shared body of both semantic-tokens requests —
2132    /// resolve the cached round, convert the optional range against the
2133    /// analysed snapshot, and run the pure producer. Empty when no round is
2134    /// cached or the file is outside the project.
2135    async fn semantic_tokens_for(&self, uri: &Url, range: Option<Range>) -> Vec<SemanticToken> {
2136        // #733: serve the last committed round without forcing a re-analysis —
2137        // tokens convert against the round's own snapshot, so a one-cycle lag is
2138        // consistent, and the client re-pulls on the round-commit refresh nudge.
2139        let analysis = self.committed_analysis(uri).await;
2140        let Some(analysis) = analysis else {
2141            return Vec::new();
2142        };
2143        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
2144            return Vec::new();
2145        };
2146        let Some(text) = analysis.snapshots.get(&rel) else {
2147            return Vec::new();
2148        };
2149        let span = match range {
2150            None => None,
2151            // The requested range converts against the analysed snapshot,
2152            // like the spans it is intersected with.
2153            Some(r) => {
2154                let (Some(start), Some(end)) = (
2155                    crate::position::position_to_offset(text, r.start),
2156                    crate::position::position_to_offset(text, r.end),
2157                ) else {
2158                    return Vec::new();
2159                };
2160                Some(bynk_syntax::span::Span::new(start, end))
2161            }
2162        };
2163        let lt = analysis
2164            .locals
2165            .get(&rel)
2166            .map(|l| crate::locals_nav::local_token_sites(l, text))
2167            .unwrap_or_default();
2168        // v0.140 (ADR 0163): handler-annotation spans (`@cache` name + argument
2169        // labels), classified as `decorator`. Parsed from the snapshot here, off
2170        // the index-read path (mirroring how locals are precomputed).
2171        let dt = crate::symbols::handler_annotation_token_spans(text);
2172        crate::index_queries::semantic_tokens(&analysis.index, &lt, &dt, &rel, text, span)
2173    }
2174
2175    /// The (analysis, rel-path, snapshot byte offset) for a request
2176    /// position — the shared front half of every index-backed handler.
2177    async fn index_position(
2178        &self,
2179        uri: &Url,
2180        position: Position,
2181    ) -> Option<(Arc<Analysis>, PathBuf, usize)> {
2182        // Slice B: `analysis_for` guarantees the round analysed `uri`'s current
2183        // version, so `position_to_offset` resolves against the same text the
2184        // client's position refers to — the `fresh` flag every caller used to
2185        // pass is gone (freshness is the contract now, not a per-call choice).
2186        let analysis = self.analysis_for(uri).await?;
2187        let rel = Self::uri_to_rel(&analysis, uri)?;
2188        let text = analysis.snapshots.get(&rel)?;
2189        let offset = crate::position::position_to_offset(text, position)?;
2190        Some((analysis, rel, offset))
2191    }
2192
2193    /// Locate the AST node at the given cursor position by re-parsing the
2194    /// document. Returns the textual identifier (if any) and its span.
2195    /// Used by hover and definition handlers.
2196    async fn identifier_at(
2197        &self,
2198        uri: &Url,
2199        position: Position,
2200    ) -> Option<(String, bynk_syntax::span::Span, String)> {
2201        let text = {
2202            let state = self.state.read().await;
2203            state.docs.get(uri)?.text.clone()
2204        };
2205        let offset = crate::position::position_to_offset(&text, position)?;
2206        // Hole-aware (issue #473): interpolation holes are expanded so a cursor
2207        // inside `"… \(name) …"` lands on the hole's identifier token, not the
2208        // opaque `InterpStr` token.
2209        let tokens = bynk_syntax::lexer::tokenize_expanding_holes(&text).ok()?;
2210        // Find the token whose span covers `offset`.
2211        for t in &tokens {
2212            if t.span.start <= offset
2213                && offset < t.span.end
2214                && matches!(
2215                    t.kind,
2216                    bynk_syntax::lexer::TokenKind::Ident
2217                        | bynk_syntax::lexer::TokenKind::Int
2218                        | bynk_syntax::lexer::TokenKind::String
2219                        | bynk_syntax::lexer::TokenKind::Bool
2220                        | bynk_syntax::lexer::TokenKind::Float
2221                        | bynk_syntax::lexer::TokenKind::Result
2222                        | bynk_syntax::lexer::TokenKind::Option
2223                        | bynk_syntax::lexer::TokenKind::Effect
2224                )
2225            {
2226                let name = text[t.span.start..t.span.end].to_string();
2227                return Some((name, t.span, text));
2228            }
2229        }
2230        None
2231    }
2232
2233    /// #846: `bynk/sequenceModel` — the sequence-diagram query for the
2234    /// handler under the cursor. This server's first custom (non-standard)
2235    /// request, registered via `custom_method` in [`run`] rather than a
2236    /// `LanguageServer` trait slot. Served from the committed round (#733),
2237    /// like `code_lens`; no refresh nudge (see the `sequence_request` module
2238    /// doc for why one isn't needed).
2239    async fn sequence_model(
2240        &self,
2241        params: sequence_request::SequenceModelParams,
2242    ) -> JsonRpcResult<Option<sequence_request::WireSequenceModel>> {
2243        let uri = params.text_document.uri;
2244        let Some(analysis) = self.committed_analysis(&uri).await else {
2245            return Ok(None);
2246        };
2247        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2248            return Ok(None);
2249        };
2250        let Some(text) = analysis.snapshots.get(&rel) else {
2251            return Ok(None);
2252        };
2253        let Some(offset) = crate::position::position_to_offset(text, params.position) else {
2254            return Ok(None);
2255        };
2256        let info = bynk_ide::symbols::own_declaration_name(text)
2257            .and_then(|(name, _)| analysis.sequence_info.get(&name));
2258        let model = sequence_request::sequence_model_at(text, offset, info);
2259        Ok(model.map(|m| sequence_request::to_wire(&m, text)))
2260    }
2261
2262    /// #2196: `bynk/agentStateModel` — the agent state view's query for the
2263    /// agent enclosing the cursor: its state machines, derived from its
2264    /// handlers. Agent-scoped (the cursor anywhere in the `agent`), served from
2265    /// the committed round, no refresh nudge — like `sequence_model`. A
2266    /// non-project file, no committed round, an offset outside every agent, or
2267    /// a unit `boundary_info` has no entry for all answer `None`.
2268    async fn agent_state_model(
2269        &self,
2270        params: agent_state_request::AgentStateModelParams,
2271    ) -> JsonRpcResult<Option<agent_state_request::WireAgentStateModel>> {
2272        let uri = params.text_document.uri;
2273        let Some(analysis) = self.committed_analysis(&uri).await else {
2274            return Ok(None);
2275        };
2276        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2277            return Ok(None);
2278        };
2279        let Some(text) = analysis.snapshots.get(&rel) else {
2280            return Ok(None);
2281        };
2282        let Some(offset) = crate::position::position_to_offset(text, params.position) else {
2283            return Ok(None);
2284        };
2285        let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text) else {
2286            return Ok(None);
2287        };
2288        let Some(info) = analysis.boundary_info.get(&unit) else {
2289            return Ok(None);
2290        };
2291        // The agents a handler can call: the context's own, across its files.
2292        let agents: std::collections::HashSet<String> = analysis
2293            .sequence_info
2294            .get(&unit)
2295            .map(|s| s.agents.keys().cloned().collect())
2296            .unwrap_or_default();
2297        Ok(
2298            agent_state_request::agent_state_at(text, offset, &info.types).map(|(agent, model)| {
2299                let effects = agent_state_request::effects_of(&agent, &agents);
2300                agent_state_request::to_wire(&agent, &model, &effects, text)
2301            }),
2302        )
2303    }
2304
2305    /// #847: `bynk/documentationModel` — the documentation-view query for the
2306    /// unit under the request. This server's second custom request,
2307    /// registered via `custom_method` in [`run`] (like `sequence_model`).
2308    /// Served from the committed round (#733), on-demand: no cursor position
2309    /// (the page is the whole unit — every file of a multi-file unit since
2310    /// #1885) and no refresh nudge (Decision D — see the
2311    /// `documentation_request` module doc, and #846's for why a custom method
2312    /// needs none). A non-project file / no committed round → `None` (empty
2313    /// page).
2314    async fn documentation_model(
2315        &self,
2316        params: documentation_request::DocumentationModelParams,
2317    ) -> JsonRpcResult<Option<documentation_request::WireDocModel>> {
2318        let uri = params.text_document.uri;
2319        let Some(analysis) = self.committed_analysis(&uri).await else {
2320            return Ok(None);
2321        };
2322        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2323            return Ok(None);
2324        };
2325        Ok(documentation_request::documentation_model_for(
2326            &rel,
2327            &analysis.snapshots,
2328            &analysis.unit_sources,
2329            &analysis.project_root,
2330        ))
2331    }
2332
2333    /// #2098: `bynk/explain` — the curated, offline explanation of a diagnostic
2334    /// code, from the toolchain's own compiled-in table (so it always matches
2335    /// the installed compiler). Not document-scoped and reads no analysis
2336    /// round: the code alone is the key. `None` for an uncurated or unknown
2337    /// code. Registered via `custom_method` in [`run`].
2338    async fn explain(
2339        &self,
2340        params: explain_request::ExplainParams,
2341    ) -> JsonRpcResult<Option<explain_request::WireExplanation>> {
2342        Ok(explain_request::explanation_for(&params.code))
2343    }
2344
2345    /// #851: `bynk/architectureModel` — the whole-project architecture-map
2346    /// query. This server's third custom request, registered via
2347    /// `custom_method` in [`run`] (like `sequence_model`/`documentation_model`).
2348    /// Served from the committed round (#733); no refresh nudge, for the same
2349    /// reason neither sibling needs one. Unlike both siblings this is
2350    /// **project-scoped** — `params.text_document` only resolves which
2351    /// project's round to read (via `committed_analysis`); the result covers
2352    /// every context/adapter unit in that round, not just the request's own
2353    /// file. A non-project file / no committed round → `None` (empty map).
2354    async fn architecture_model(
2355        &self,
2356        params: architecture_request::ArchitectureModelParams,
2357    ) -> JsonRpcResult<Option<architecture_request::WireArchModel>> {
2358        let uri = params.text_document.uri;
2359        let Some(analysis) = self.committed_analysis(&uri).await else {
2360            return Ok(None);
2361        };
2362        let model = architecture_request::architecture_model_for(
2363            &analysis.unit_sources,
2364            &analysis.snapshots,
2365            &analysis.sequence_info,
2366        );
2367        Ok(Some(architecture_request::to_wire(
2368            &model,
2369            &analysis.project_root,
2370            &analysis.snapshots,
2371        )))
2372    }
2373
2374    /// #855: `bynk/wireContract` — the wire-contract peek for the handler
2375    /// under the cursor. This server's fourth custom request, modelled on
2376    /// `sequence_model` (file-scoped + position, not project-scoped like
2377    /// `architecture_model` — the panel is per-handler). Served from the
2378    /// committed round; no refresh nudge, for the same reason no custom
2379    /// request needs one. A non-project file, no committed round, an offset
2380    /// outside any handler, or a unit `boundary_info` has no entry for (the
2381    /// pipeline bailed before the checker) all answer `None` (`null` on the
2382    /// wire).
2383    async fn wire_contract(
2384        &self,
2385        params: wire_contract_request::WireContractParams,
2386    ) -> JsonRpcResult<Option<wire_contract_request::WcModel>> {
2387        let uri = params.text_document.uri;
2388        let Some(analysis) = self.committed_analysis(&uri).await else {
2389            return Ok(None);
2390        };
2391        let tys = &analysis.ty_intern;
2392        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2393            return Ok(None);
2394        };
2395        let Some(text) = analysis.snapshots.get(&rel) else {
2396            return Ok(None);
2397        };
2398        let Some(offset) = crate::position::position_to_offset(text, params.position) else {
2399            return Ok(None);
2400        };
2401        // The owning unit — same `own_declaration_name` convention
2402        // `sequence_model` uses to key `sequence_info`.
2403        let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text) else {
2404            return Ok(None);
2405        };
2406        let Some(info) = analysis.boundary_info.get(&unit) else {
2407            return Ok(None);
2408        };
2409        let expr_types = analysis
2410            .expr_types
2411            .get(&rel)
2412            .map(|v| v.as_slice())
2413            .unwrap_or(&[]);
2414        let context_count = bynk_ide::wire_contract::real_context_count(
2415            &analysis.boundary_info,
2416            &analysis.unit_sources,
2417        );
2418        let Some(model) = bynk_ide::wire_contract::wire_contract_at(
2419            &unit,
2420            text,
2421            offset,
2422            info,
2423            expr_types,
2424            tys,
2425            context_count,
2426        ) else {
2427            return Ok(None);
2428        };
2429        // #848's own search order (self, then `uses`, then `consumes`) —
2430        // reused rather than re-derived, since it is exactly the priority a
2431        // boundary type name resolves through in `ContextBoundaryInfo::types`.
2432        let search_order = analysis
2433            .doc_scope
2434            .get(&unit)
2435            .cloned()
2436            .unwrap_or_else(|| vec![unit.clone()]);
2437        Ok(Some(wire_contract_request::to_wire(
2438            &model,
2439            &analysis.project_root,
2440            text,
2441            &info.types,
2442            &analysis.index,
2443            &analysis.snapshots,
2444            &search_order,
2445        )))
2446    }
2447}
2448
2449#[tower_lsp::async_trait]
2450impl LanguageServer for Backend {
2451    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
2452        // Slice D (Q4): record **every** workspace folder as a discovery seed
2453        // (was `folders.first()` only). Folders do not own URIs — a request
2454        // routes by its nearest enclosing `bynk.toml` (`resolve_root`) — so this
2455        // seeds where `did_change_workspace_folders` prunes and where slice E's
2456        // startup scan looks. Slice E: also capture whether the client accepts a
2457        // server-side `didChangeWatchedFiles` registration, used in `initialized`.
2458        let dynamic_watchers = params
2459            .capabilities
2460            .workspace
2461            .as_ref()
2462            .and_then(|w| w.did_change_watched_files.as_ref())
2463            .and_then(|d| d.dynamic_registration)
2464            .unwrap_or(false);
2465        // #733: whether the client can be nudged to re-pull each pull-based
2466        // decoration after a round commits (the "revalidate" of stale-while-
2467        // revalidate). Absent → the flag stays false and no nudge is sent.
2468        let ws = params.capabilities.workspace.as_ref();
2469        let supports_refresh = RefreshSupport {
2470            semantic_tokens: ws
2471                .and_then(|w| w.semantic_tokens.as_ref())
2472                .and_then(|s| s.refresh_support)
2473                .unwrap_or(false),
2474            inlay_hints: ws
2475                .and_then(|w| w.inlay_hint.as_ref())
2476                .and_then(|i| i.refresh_support)
2477                .unwrap_or(false),
2478            code_lens: ws
2479                .and_then(|w| w.code_lens.as_ref())
2480                .and_then(|c| c.refresh_support)
2481                .unwrap_or(false),
2482        };
2483        {
2484            let mut state = self.state.write().await;
2485            state.supports_dynamic_watchers = dynamic_watchers;
2486            state.supports_refresh = supports_refresh;
2487            state.supports_explain_command = explain_request::client_supports_explain_command(
2488                params.capabilities.experimental.as_ref(),
2489            );
2490            state.supports_code_action_disabled = params
2491                .capabilities
2492                .text_document
2493                .as_ref()
2494                .and_then(|t| t.code_action.as_ref())
2495                .and_then(|c| c.disabled_support)
2496                .unwrap_or(false);
2497            if let Some(folders) = &params.workspace_folders {
2498                state.folders = folders
2499                    .iter()
2500                    .filter_map(|f| f.uri.to_file_path().ok())
2501                    .map(|p| p.canonicalize().unwrap_or(p))
2502                    .collect();
2503            }
2504        }
2505        Ok(InitializeResult {
2506            capabilities: server_capabilities(),
2507            server_info: Some(ServerInfo {
2508                name: SERVER_NAME.into(),
2509                version: Some(SERVER_VERSION.into()),
2510            }),
2511        })
2512    }
2513
2514    async fn initialized(&self, _: InitializedParams) {
2515        let (folders, dynamic) = {
2516            let s = self.state.read().await;
2517            (s.folders.clone(), s.supports_dynamic_watchers)
2518        };
2519        // Slice E (Q4/ADR 0204 §D): register the file watchers server-side, once,
2520        // with folder-independent globs — so any client is notified, and the VS
2521        // Code extension no longer supplies them (avoiding a double
2522        // notification). Only when the client accepts dynamic registration;
2523        // otherwise it is expected to watch files itself.
2524        if dynamic {
2525            self.register_file_watchers().await;
2526        }
2527        // Slice E: warm every project under the workspace folders, so diagnostics
2528        // appear at activation without a file being opened (spec §2.3).
2529        self.warm_projects(&folders).await;
2530        let msg = if folders.is_empty() {
2531            "bynkc-lsp: no workspace folders; single-file mode".to_string()
2532        } else {
2533            format!(
2534                "bynkc-lsp: {} workspace folder(s); projects resolved per file",
2535                folders.len()
2536            )
2537        };
2538        self.client.log_message(MessageType::INFO, msg).await;
2539    }
2540
2541    async fn shutdown(&self) -> JsonRpcResult<()> {
2542        self.shutdown_requested
2543            .store(true, std::sync::atomic::Ordering::SeqCst);
2544        Ok(())
2545    }
2546
2547    async fn did_open(&self, params: DidOpenTextDocumentParams) {
2548        let uri = params.text_document.uri.clone();
2549        {
2550            let mut state = self.state.write().await;
2551            state.docs.insert(
2552                uri.clone(),
2553                DocumentState {
2554                    text: params.text_document.text,
2555                    version: params.text_document.version,
2556                },
2557            );
2558        }
2559        // Slice D/F: `schedule_diagnostics` routes the URI to its project and
2560        // creates the entry on first touch — no separate root-setting step.
2561        self.schedule_diagnostics(&uri).await;
2562    }
2563
2564    async fn did_change(&self, params: DidChangeTextDocumentParams) {
2565        let uri = params.text_document.uri.clone();
2566        {
2567            let mut state = self.state.write().await;
2568            if let Some(doc) = state.docs.get_mut(&uri)
2569                && let Some(change) = params.content_changes.into_iter().next_back()
2570            {
2571                doc.text = change.text;
2572                doc.version = params.text_document.version;
2573            }
2574        }
2575        // `[lsp] diagnostics_mode = "on_save"`: no per-keystroke rounds — the
2576        // buffer state is updated above and diagnosis waits for `didSave`.
2577        // Slice D: the mode is the *owning project's* (config differs per
2578        // project); a single-file buffer uses the defaults.
2579        if self.config_for(&uri).await.diagnostics_mode == crate::project::DiagnosticsMode::OnSave {
2580            return;
2581        }
2582        // Slice F: hand off to the one scheduler — it debounces once, at the
2583        // configured delay (no manual pre-sleep stacked on the round's own
2584        // debounce), and coalesces a burst to a single analysis.
2585        self.schedule_diagnostics(&uri).await;
2586    }
2587
2588    async fn did_save(&self, params: DidSaveTextDocumentParams) {
2589        // The live path already diagnosed on change; this matters for
2590        // `diagnostics_mode = "on_save"`, where saves are the only trigger.
2591        self.schedule_diagnostics(&params.text_document.uri).await;
2592    }
2593
2594    async fn did_close(&self, params: DidCloseTextDocumentParams) {
2595        let uri = params.text_document.uri;
2596        {
2597            let mut state = self.state.write().await;
2598            state.docs.remove(&uri);
2599            // Slice F: drop the buffer's single-file debounce generation (a no-op
2600            // for a project file, which never had one).
2601            state.single_file_generations.remove(&uri);
2602        }
2603        // Slice D (Q4 §C): closing the last buffer can orphan a project whose
2604        // folder was already removed — it was retained *because* a buffer held
2605        // it. Prune it now and clear its diagnostics, the mirror of the folder
2606        // path, so a fully-orphaned project never lingers with stale squiggles.
2607        for cleared in self.prune_orphaned_projects().await {
2608            self.client
2609                .publish_diagnostics(cleared, Vec::new(), None)
2610                .await;
2611        }
2612    }
2613
2614    /// Transport only: resolve the position, gather the round's tables and the
2615    /// live buffer, and package the result. The resolution *order* — which is the
2616    /// behaviour — lives in [`crate::hover::hover_content`], so it has one
2617    /// definition a test can pin (ADR 0190; #611's gap B was a fall-through bug).
2618    async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
2619        let uri = params.text_document_position_params.text_document.uri;
2620        let pos = params.text_document_position_params.position;
2621        // The analysed round, positioned — absent for a file outside it.
2622        let positioned = self.index_position(&uri, pos).await;
2623        // The live buffer — absent when the document is not open. Distinct from
2624        // the snapshot above, which lags while the user types.
2625        let doc_text = {
2626            let state = self.state.read().await;
2627            state.docs.get(&uri).map(|d| d.text.clone())
2628        };
2629        let doc = doc_text
2630            .as_deref()
2631            .and_then(|t| Some((t, crate::position::position_to_offset(t, pos)?)));
2632        let files = self.project_content(&uri).await;
2633        let analysis = positioned
2634            .as_ref()
2635            .map(|(a, rel, offset)| crate::hover::HoverAnalysis {
2636                index: &a.index,
2637                snapshots: &a.snapshots,
2638                locals: &a.locals,
2639                expr_types: &a.expr_types,
2640                tys: &a.ty_intern,
2641                rel,
2642                offset: *offset,
2643                project_root: &a.project_root,
2644                doc_scope: &a.doc_scope,
2645                boundary_info: &a.boundary_info,
2646                // #855: computed once per round, not re-derived as a bare
2647                // `boundary_info.len()` — see `real_context_count`'s doc.
2648                context_count: bynk_ide::wire_contract::real_context_count(
2649                    &a.boundary_info,
2650                    &a.unit_sources,
2651                ),
2652            });
2653        let content = crate::hover::hover_content(&crate::hover::HoverInput {
2654            analysis,
2655            doc,
2656            uri: &uri,
2657            files: files.as_deref(),
2658        });
2659        Ok(content.map(|value| Hover {
2660            contents: HoverContents::Markup(MarkupContent {
2661                kind: MarkupKind::Markdown,
2662                value,
2663            }),
2664            range: None,
2665        }))
2666    }
2667
2668    /// v0.32 (ADR 0065): signature help for the call under the cursor.
2669    async fn signature_help(
2670        &self,
2671        params: SignatureHelpParams,
2672    ) -> JsonRpcResult<Option<SignatureHelp>> {
2673        let uri = params.text_document_position_params.text_document.uri;
2674        let pos = params.text_document_position_params.position;
2675        let text = {
2676            let s = self.state.read().await;
2677            s.docs.get(&uri).map(|d| d.text.clone())
2678        };
2679        let Some(text) = text else { return Ok(None) };
2680        let offset = cursor_offset(&text, pos);
2681        let Some(ctx) = crate::signature_help::call_context(&text, offset) else {
2682            return Ok(None);
2683        };
2684        let files = self.project_content(&uri).await;
2685        // Name callees (free fns, statics, capability ops, of/unsafe) — lexical.
2686        // #733: `resolve_label` enumerates the project's units (file stats +
2687        // recovery parse of the cache-missed ones), so run it on the blocking
2688        // pool — signature help fires on every `(`/`,` while typing a call.
2689        let resolved_label = {
2690            let callee = ctx.callee.clone();
2691            let text = text.clone();
2692            let files = files.clone();
2693            match tokio::task::spawn_blocking(move || {
2694                crate::signature_help::resolve_label(&callee, &text, files.as_deref())
2695            })
2696            .await
2697            {
2698                Ok(l) => l,
2699                Err(e) => {
2700                    tracing::error!("signature-help label task failed: {e}");
2701                    None
2702                }
2703            }
2704        };
2705        let label = match resolved_label {
2706            Some(l) => Some(l),
2707            // v0.32 slice 2: a value-receiver method (`xs.fold(`) — type the
2708            // receiver via the rewrite + re-analyse, then the kernel signature.
2709            None => match crate::signature_help::value_receiver_method(&ctx.callee) {
2710                Some((_, method)) => {
2711                    if let Some((rewritten, recv_offset)) =
2712                        crate::signature_help::value_receiver_rewrite(
2713                            &text,
2714                            &ctx.callee,
2715                            ctx.open_paren,
2716                            offset,
2717                        )
2718                        && let Some((ty, tys)) =
2719                            self.type_receiver(&uri, rewritten, recv_offset).await
2720                    {
2721                        crate::signature_help::kernel_method_signature(ty, &tys, method)
2722                    } else {
2723                        None
2724                    }
2725                }
2726                None => None,
2727            },
2728        };
2729        let Some(label) = label else { return Ok(None) };
2730        let active = ctx.active_param as u32;
2731        let parameters: Vec<ParameterInformation> = crate::signature_help::param_ranges(&label)
2732            .into_iter()
2733            .map(|(s, e)| ParameterInformation {
2734                label: ParameterLabel::LabelOffsets([s as u32, e as u32]),
2735                documentation: None,
2736            })
2737            .collect();
2738        Ok(Some(SignatureHelp {
2739            signatures: vec![SignatureInformation {
2740                label,
2741                documentation: None,
2742                parameters: Some(parameters),
2743                active_parameter: Some(active),
2744            }],
2745            active_signature: Some(0),
2746            active_parameter: Some(active),
2747        }))
2748    }
2749
2750    /// v0.33 (ADR 0066): a reference-count lens above each top-level definition,
2751    /// clickable to peek the references. Served from the cached round.
2752    async fn code_lens(&self, params: CodeLensParams) -> JsonRpcResult<Option<Vec<CodeLens>>> {
2753        let uri = params.text_document.uri;
2754        // #733: committed round, no forced re-analysis (see `committed_analysis`).
2755        let analysis = self.committed_analysis(&uri).await;
2756        let Some(analysis) = analysis else {
2757            return Ok(Some(Vec::new()));
2758        };
2759        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2760            return Ok(Some(Vec::new()));
2761        };
2762        let Some(text) = analysis.snapshots.get(&rel) else {
2763            return Ok(Some(Vec::new()));
2764        };
2765        // Peek the references/providers on click — a standard client command,
2766        // so no extension support is required (the client middleware hydrates the
2767        // three-argument shape). Shared by both the reference and provider lenses.
2768        let show_references = |range: Range, locations: Vec<Location>, title: String| CodeLens {
2769            range,
2770            command: Some(Command {
2771                title,
2772                command: "editor.action.showReferences".to_string(),
2773                arguments: Some(vec![
2774                    serde_json::to_value(&uri).unwrap_or_default(),
2775                    serde_json::to_value(range.start).unwrap_or_default(),
2776                    serde_json::to_value(&locations).unwrap_or_default(),
2777                ]),
2778            }),
2779            data: None,
2780        };
2781        let mut lenses: Vec<CodeLens> = crate::index_queries::code_lenses(&analysis.index, &rel)
2782            .into_iter()
2783            .map(|(def, refs)| {
2784                let range = crate::position::span_to_range(text, def.span);
2785                let locations: Vec<Location> = refs
2786                    .iter()
2787                    .filter_map(|r| Self::site_to_location(&analysis, r))
2788                    .collect();
2789                let n = refs.len();
2790                show_references(
2791                    range,
2792                    locations,
2793                    format!("{n} reference{}", if n == 1 { "" } else { "s" }),
2794                )
2795            })
2796            .collect();
2797        // v0.127 (editor-currency slice 6): a `N provider(s)` lens on each
2798        // capability, listing the services that `provides` it. Stacks below the
2799        // reference lens, as a referenced test stacks a reference + test lens.
2800        lenses.extend(
2801            crate::index_queries::capability_provider_lenses(&analysis.index, &rel)
2802                .into_iter()
2803                .map(|(def, providers)| {
2804                    let range = crate::position::span_to_range(text, def.span);
2805                    let locations: Vec<Location> = providers
2806                        .iter()
2807                        .filter_map(|r| Self::site_to_location(&analysis, r))
2808                        .collect();
2809                    let n = providers.len();
2810                    show_references(
2811                        range,
2812                        locations,
2813                        format!("{n} provider{}", if n == 1 { "" } else { "s" }),
2814                    )
2815                }),
2816        );
2817        // v0.129 (#259): a `N refinements of <Base>` lens on each refined/opaque
2818        // type, listing its family — every type over the same builtin base. Stacks
2819        // below the reference lens, like the provider lens on a capability.
2820        lenses.extend(
2821            crate::index_queries::refinement_family_lenses(&analysis.index, &rel)
2822                .into_iter()
2823                .map(|(def, base, family)| {
2824                    let range = crate::position::span_to_range(text, def.span);
2825                    let locations: Vec<Location> = family
2826                        .iter()
2827                        .filter_map(|r| Self::site_to_location(&analysis, r))
2828                        .collect();
2829                    let n = family.len();
2830                    show_references(
2831                        range,
2832                        locations,
2833                        format!("{n} refinements of {}", base.name()),
2834                    )
2835                }),
2836        );
2837        // #846: a "Show Sequence" lens above every handler declaration —
2838        // `bynk.showSequenceDiagram` is a plain extension command (not a
2839        // built-in VS Code command), so its arguments travel as plain JSON
2840        // with no `codelens.ts` hydration needed, unlike `show_references`
2841        // above. A direct AST walk (`handler_lens_sites`), not
2842        // `index_queries::code_lenses` — that only indexes agent handlers
2843        // (`SymbolKind::Handler`; service handlers have no per-handler name)
2844        // and would silently drop the lens for every service handler.
2845        lenses.extend(
2846            crate::sequence_request::handler_lens_sites(text)
2847                .into_iter()
2848                .map(|span| {
2849                    let range = crate::position::span_to_range(text, span);
2850                    CodeLens {
2851                        range,
2852                        command: Some(Command {
2853                            title: "Show Sequence".to_string(),
2854                            command: "bynk.showSequenceDiagram".to_string(),
2855                            arguments: Some(vec![
2856                                serde_json::to_value(&uri).unwrap_or_default(),
2857                                serde_json::to_value(range.start).unwrap_or_default(),
2858                            ]),
2859                        }),
2860                        data: None,
2861                    }
2862                }),
2863        );
2864        // #2196: a "Show State" lens on every agent's name — the state view is
2865        // agent-scoped, so one lens per agent, not per handler.
2866        lenses.extend(
2867            crate::agent_state_request::agent_lens_sites(text)
2868                .into_iter()
2869                .map(|span| {
2870                    let range = crate::position::span_to_range(text, span);
2871                    CodeLens {
2872                        range,
2873                        command: Some(Command {
2874                            title: "Show State".to_string(),
2875                            command: "bynk.showAgentState".to_string(),
2876                            arguments: Some(vec![
2877                                serde_json::to_value(&uri).unwrap_or_default(),
2878                                serde_json::to_value(range.start).unwrap_or_default(),
2879                            ]),
2880                        }),
2881                        data: None,
2882                    }
2883                }),
2884        );
2885        Ok(Some(lenses))
2886    }
2887
2888    async fn prepare_call_hierarchy(
2889        &self,
2890        params: CallHierarchyPrepareParams,
2891    ) -> JsonRpcResult<Option<Vec<CallHierarchyItem>>> {
2892        let uri = params.text_document_position_params.text_document.uri;
2893        let pos = params.text_document_position_params.position;
2894        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2895            return Ok(None);
2896        };
2897        let Some((key, def)) =
2898            crate::index_queries::prepare_call_hierarchy(&analysis.index, &rel, offset)
2899        else {
2900            return Ok(None);
2901        };
2902        Ok(Self::call_hierarchy_item(&analysis, key, def).map(|item| vec![item]))
2903    }
2904
2905    async fn incoming_calls(
2906        &self,
2907        params: CallHierarchyIncomingCallsParams,
2908    ) -> JsonRpcResult<Option<Vec<CallHierarchyIncomingCall>>> {
2909        let analysis = self.analysis_for(&params.item.uri).await;
2910        let Some(analysis) = analysis else {
2911            return Ok(Some(Vec::new()));
2912        };
2913        let Some(key) = SerKey::read(&params.item.data) else {
2914            return Ok(Some(Vec::new()));
2915        };
2916        let calls = crate::index_queries::incoming_calls(&analysis.index, &key)
2917            .into_iter()
2918            .filter_map(|rel| {
2919                let from = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2920                let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2921                Some(CallHierarchyIncomingCall { from, from_ranges })
2922            })
2923            .collect();
2924        Ok(Some(calls))
2925    }
2926
2927    async fn outgoing_calls(
2928        &self,
2929        params: CallHierarchyOutgoingCallsParams,
2930    ) -> JsonRpcResult<Option<Vec<CallHierarchyOutgoingCall>>> {
2931        let analysis = self.analysis_for(&params.item.uri).await;
2932        let Some(analysis) = analysis else {
2933            return Ok(Some(Vec::new()));
2934        };
2935        let Some(key) = SerKey::read(&params.item.data) else {
2936            return Ok(Some(Vec::new()));
2937        };
2938        let calls = crate::index_queries::outgoing_calls(&analysis.index, &key)
2939            .into_iter()
2940            .filter_map(|rel| {
2941                let to = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2942                let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2943                Some(CallHierarchyOutgoingCall { to, from_ranges })
2944            })
2945            .collect();
2946        Ok(Some(calls))
2947    }
2948
2949    /// v0.35 (ADR 0068): `textDocument/implementation` — on a capability
2950    /// symbol (its declaration, a `given Cap` use, or a `provides Cap` use),
2951    /// the providers that implement it. `None` for any other symbol (the
2952    /// reverse, provider → capability, is served by goto-definition).
2953    async fn goto_implementation(
2954        &self,
2955        params: GotoImplementationParams,
2956    ) -> JsonRpcResult<Option<GotoImplementationResponse>> {
2957        let uri = params.text_document_position_params.text_document.uri;
2958        let pos = params.text_document_position_params.position;
2959        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2960            return Ok(None);
2961        };
2962        let Some((key, _)) = analysis.index.symbol_at(&rel, offset) else {
2963            return Ok(None);
2964        };
2965        if key.kind != bynk_check::index::SymbolKind::Capability {
2966            return Ok(None);
2967        }
2968        let locations: Vec<Location> = crate::index_queries::implementations(&analysis.index, key)
2969            .into_iter()
2970            .filter_map(|d| Self::site_to_location(&analysis, d))
2971            .collect();
2972        if locations.is_empty() {
2973            return Ok(None);
2974        }
2975        Ok(Some(GotoDefinitionResponse::Array(locations)))
2976    }
2977
2978    /// Slice 6: `textDocument/typeDefinition` — from a value at the cursor to the
2979    /// definition of its (user-declared) type. Reads the value's type from the
2980    /// round's `expr_types`, unwraps it to a `Named` target, and returns that
2981    /// type's definition site(s). `None` for a built-in/function/actor type, or
2982    /// a cursor not on a typed expression in a clean round.
2983    async fn goto_type_definition(
2984        &self,
2985        params: GotoTypeDefinitionParams,
2986    ) -> JsonRpcResult<Option<GotoTypeDefinitionResponse>> {
2987        let uri = params.text_document_position_params.text_document.uri;
2988        let pos = params.text_document_position_params.position;
2989        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2990            return Ok(None);
2991        };
2992        let tys = &analysis.ty_intern;
2993        let Some(entries) = analysis.expr_types.get(&rel) else {
2994            return Ok(None);
2995        };
2996        let Some(ty) = bynk_check::expr_types::type_at_offset(entries, offset, tys) else {
2997            return Ok(None);
2998        };
2999        let Some(name) = crate::index_queries::named_type_target(ty, tys) else {
3000            return Ok(None);
3001        };
3002        let locations: Vec<Location> =
3003            crate::index_queries::type_definitions_named(&analysis.index, &name)
3004                .into_iter()
3005                .filter_map(|d| Self::site_to_location(&analysis, d))
3006                .collect();
3007        if locations.is_empty() {
3008            return Ok(None);
3009        }
3010        Ok(Some(GotoDefinitionResponse::Array(locations)))
3011    }
3012
3013    /// Slice 6b (ADR 0095): `textDocument/documentLink` — `uses`/`consumes` unit
3014    /// names are clickable to the unit's source. Spans come from parsing the live
3015    /// buffer; the target is the unit's first source file from the round's
3016    /// unit→source map. A first-party `uses` (embedded, no on-disk file) or an
3017    /// unresolved unit yields no link.
3018    ///
3019    /// #848: plus intra-doc links inside the file's own `--- … ---` doc
3020    /// comments — `[Name]`/`[Owner.member]` resolved against the declaring
3021    /// unit's `doc_scope`. Resolves against the full `analysis.index` under
3022    /// the same `committed_analysis` gate as the unit-reference links above;
3023    /// consistent with `code_lens`/`capability_provider_lenses`, which
3024    /// already resolve full-index cross-references under this gate.
3025    async fn document_link(
3026        &self,
3027        params: DocumentLinkParams,
3028    ) -> JsonRpcResult<Option<Vec<DocumentLink>>> {
3029        let uri = params.text_document.uri;
3030        // #733: committed round. Link ranges convert against live `text` here and
3031        // the round only supplies the project-level `unit_sources` map (it changes
3032        // only on a `uses`/`consumes` edit), so a committed round is safe.
3033        let analysis = self.committed_analysis(&uri).await;
3034        let text = self
3035            .state
3036            .read()
3037            .await
3038            .docs
3039            .get(&uri)
3040            .map(|d| d.text.clone());
3041        let (Some(text), Some(analysis)) = (text, analysis) else {
3042            return Ok(None);
3043        };
3044        let mut links: Vec<DocumentLink> = crate::symbols::unit_reference_spans(&text)
3045            .into_iter()
3046            .filter_map(|(unit, span)| {
3047                let rel = analysis.unit_sources.get(&unit)?.first()?;
3048                let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
3049                Some(DocumentLink {
3050                    range: crate::position::span_to_range(&text, span),
3051                    target: Some(target),
3052                    tooltip: Some(format!("Open unit `{unit}`")),
3053                    data: None,
3054                })
3055            })
3056            .collect();
3057        // #848: a suite file's own doc comments are out of scope this
3058        // increment (own_declaration_name returns None for a suite; its
3059        // uses-clause links above are unaffected).
3060        if let Some((owner_unit, _)) = crate::symbols::own_declaration_name(&text) {
3061            for (name, span) in crate::symbols::doc_link_spans(&text) {
3062                let Some(def) = crate::index_queries::resolve_doc_link(
3063                    &analysis.index,
3064                    &analysis.doc_scope,
3065                    &owner_unit,
3066                    &name,
3067                ) else {
3068                    continue;
3069                };
3070                let Ok(target) = Url::from_file_path(analysis.project_root.join(&def.path)) else {
3071                    continue;
3072                };
3073                links.push(DocumentLink {
3074                    range: crate::position::span_to_range(&text, span),
3075                    target: Some(target),
3076                    tooltip: Some(format!("Go to `{name}`")),
3077                    data: None,
3078                });
3079            }
3080        }
3081        Ok((!links.is_empty()).then_some(links))
3082    }
3083
3084    async fn completion(
3085        &self,
3086        params: CompletionParams,
3087    ) -> JsonRpcResult<Option<CompletionResponse>> {
3088        let uri = params.text_document_position.text_document.uri;
3089        let pos = params.text_document_position.position;
3090        let text = {
3091            let s = self.state.read().await;
3092            s.docs.get(&uri).map(|d| d.text.clone())
3093        };
3094        let Some(text) = text else { return Ok(None) };
3095        let offset = cursor_offset(&text, pos);
3096        // The line up to the cursor — the context the completion keys off.
3097        // Derived from the converted offset (always a char boundary), not by
3098        // slicing the line at `pos.character` bytes.
3099        let line_prefix = text[..offset].rsplit('\n').next().unwrap_or("").to_string();
3100        let files = self.project_content(&uri).await;
3101        // #2134: inside a suite's or case's `env { }` block, at a name position,
3102        // offer the secret names actors declare and the `Secrets.get` literals.
3103        if completion::in_env_name_position(&text, offset) {
3104            let items = completion::env_name_candidates(&text, files.as_deref())
3105                .into_iter()
3106                .map(to_completion_item)
3107                .collect();
3108            return Ok(Some(CompletionResponse::Array(items)));
3109        }
3110        // `complete()` enumerates the project's units — file stats and CPU-bound
3111        // recovery parsing (of the buffer, and any project file whose parse cache
3112        // missed). Run it on the blocking pool so a keystroke on a large project
3113        // never stalls the async runtime (#733).
3114        let candidates = {
3115            let line_prefix = line_prefix.clone();
3116            let text = text.clone();
3117            let root = self.root_for_uri(&uri).await;
3118            match tokio::task::spawn_blocking(move || {
3119                completion::complete_in(root.as_deref(), &line_prefix, &text, files.as_deref())
3120            })
3121            .await
3122            {
3123                Ok(c) => c,
3124                // A panic (or cancellation) inside `complete()` degrades to empty
3125                // completions rather than a failed request — but log the
3126                // `JoinError` so the underlying bug is not silently swallowed
3127                // (#776 review).
3128                Err(e) => {
3129                    tracing::error!("completion enumeration task failed: {e}");
3130                    Vec::new()
3131                }
3132            }
3133        };
3134        let mut items: Vec<CompletionItem> =
3135            candidates.into_iter().map(to_completion_item).collect();
3136        // ADR 0064/0093 D3: offer in-scope locals/params at keyword position
3137        // (alongside keywords) and at expression position (alongside the
3138        // constructors + type names `complete()` now yields there). Both are
3139        // places a value or name can begin; the two positions are disjoint.
3140        if completion::is_keyword_position(&line_prefix)
3141            || completion::is_expression_position(&line_prefix)
3142        {
3143            items.extend(self.locals_completions(&uri, pos).await);
3144        }
3145        // v0.124 (slice 3): inside a `requires`/`ensures` predicate, offer the
3146        // enclosing function's parameters (and `result` in an `ensures`),
3147        // merged with whatever the lexical cell yields there — the same
3148        // append-in-scope-names posture as locals above.
3149        items.extend(contract_param_completions(&text, offset, &line_prefix));
3150        // v0.131: inside a `cors { }` block, offer the policy field names; at a
3151        // service-body item start, offer the `cors` section keyword alongside the
3152        // handler-kind keywords the keyword-position cell already yields.
3153        items.extend(cors_completions(&text, offset, &line_prefix));
3154        // v0.141 (ADR 0164): inside a `security { }` block, offer the policy field
3155        // names; at a service-body item start, offer the `security` section keyword.
3156        items.extend(security_completions(&text, offset, &line_prefix));
3157        // v0.140 (ADR 0163): inside `@cache( … )`, offer the annotation argument
3158        // names; at a service-body item start, offer the `@cache` snippet alongside
3159        // the `cors` keyword and handler kinds.
3160        items.extend(cache_completions(&text, offset, &line_prefix));
3161        // v0.142 (ADR 0165): inside a `limits { }` block, offer the policy field
3162        // names; at a service-body item start, offer the `limits` section keyword.
3163        items.extend(limits_completions(&text, offset, &line_prefix));
3164        // v0.142 (ADR 0165): inside `@limit( … )`, offer the annotation argument
3165        // names; at a service-body item start, offer the `@limit` snippet.
3166        items.extend(limit_completions(&text, offset, &line_prefix));
3167        // Inside `@raw( … )`, offer the `contentType` argument; at a service-body
3168        // item start, offer the `@raw` snippet.
3169        items.extend(raw_completions(&text, offset, &line_prefix));
3170        // P3 (#2342): on a `capability X = …` line, the instantiable bases and
3171        // then a base's required options.
3172        items.extend(
3173            completion::instance_declaration_completions(&line_prefix)
3174                .into_iter()
3175                .map(|(label, detail, insert)| CompletionItem {
3176                    label,
3177                    kind: Some(CompletionItemKind::SNIPPET),
3178                    detail: Some(detail),
3179                    insert_text: Some(insert),
3180                    insert_text_format: Some(InsertTextFormat::SNIPPET),
3181                    ..Default::default()
3182                }),
3183        );
3184        // P3 (#2342): inside `capability X = RateLimit @period(`, offer the
3185        // platform's two windows.
3186        items.extend(
3187            completion::instance_option_values(&text, offset)
3188                .iter()
3189                .map(|(value, doc)| CompletionItem {
3190                    label: value.to_string(),
3191                    kind: Some(CompletionItemKind::VALUE),
3192                    detail: Some((*doc).to_string()),
3193                    ..Default::default()
3194                }),
3195        );
3196        // v0.128: at a `match` arm-pattern-start, prepend the scrutinee's
3197        // variants — the most relevant candidate there. Unlike an `is` position, a
3198        // fresh-line or after-comma arm already looks like a keyword/expression
3199        // position (so `items` is non-empty and the `is_empty` path below never
3200        // fires), hence the merge. The expensive scrutinee typing is gated behind
3201        // the cheap lexical `match_scrutinee_offset` check inside, so ordinary
3202        // keyword-position completion pays only a string scan.
3203        // v0.145 (ADR 0169): a nested constructor position (`Some(‸`) offers the
3204        // payload type's variants; it and the arm-start position are mutually
3205        // exclusive (one is inside a `(`, the other before any), so the two lists
3206        // never overlap. Nested is the more specific position, so it leads.
3207        let mut pattern_items = self.nested_pattern_completions(&uri, &text, offset).await;
3208        pattern_items.extend(self.match_arm_completions(&uri, &text, offset).await);
3209        if !pattern_items.is_empty() {
3210            let mut merged = pattern_items;
3211            merged.extend(items);
3212            stamp_resolve_data(&mut merged, &uri);
3213            return Ok(Some(CompletionResponse::Array(merged)));
3214        }
3215        if items.is_empty() {
3216            // Slice 3: `<expr> is <cursor>` — offer the scrutinee sum type's
3217            // variants, resolved from `expr_types` (the ADR 0063 ceiling).
3218            let is_items = self.is_pattern_completions(&uri, &text, offset).await;
3219            if !is_items.is_empty() {
3220                return Ok(Some(CompletionResponse::Array(is_items)));
3221            }
3222            // A lowercase `receiver.` is a value receiver — type it by
3223            // re-analysing the rewritten buffer and offer its members. (Value
3224            // members name no declared symbol, so they carry no resolve data.)
3225            let value_items = self.value_member_completions(&uri, &text, offset).await;
3226            return Ok((!value_items.is_empty()).then_some(CompletionResponse::Array(value_items)));
3227        }
3228        // Slice 5: stash the doc URI so `completion_resolve` can attach lazy docs.
3229        stamp_resolve_data(&mut items, &uri);
3230        Ok(Some(CompletionResponse::Array(items)))
3231    }
3232
3233    /// Slice 5: fill in hover-quality `documentation` for the focused completion
3234    /// item, reusing the hover renderer (`symbols::describe_symbol`, local then
3235    /// cross-file — §3.4). The originating doc URI is read from the item's
3236    /// `data` (a resolve request carries only the item, not a position). A no-op
3237    /// for an item that names no declared symbol (a keyword, kernel method, or
3238    /// local) — its one-line `detail` already suffices.
3239    async fn completion_resolve(&self, mut item: CompletionItem) -> JsonRpcResult<CompletionItem> {
3240        if item.documentation.is_some() {
3241            return Ok(item);
3242        }
3243        let Some(uri) = item
3244            .data
3245            .as_ref()
3246            .and_then(|d| d.get("uri"))
3247            .and_then(serde_json::Value::as_str)
3248            .and_then(|s| Url::parse(s).ok())
3249        else {
3250            return Ok(item);
3251        };
3252        let local = {
3253            let s = self.state.read().await;
3254            s.docs.get(&uri).map(|d| d.text.clone())
3255        };
3256        let doc = match local
3257            .as_deref()
3258            .and_then(|t| crate::symbols::describe_symbol(t, &item.label))
3259        {
3260            Some(md) => Some(md),
3261            // #733: the cross-file fallback enumerates the project's units (file
3262            // stats + recovery parse of the cache-missed ones); the firstparty
3263            // fallback parses the embedded surface. Both read/parse off the
3264            // blocking pool — completion-item resolve fires as the user arrows
3265            // through the completion list.
3266            None => {
3267                let files = self.project_content(&uri).await;
3268                let uri = uri.clone();
3269                let label = item.label.clone();
3270                match tokio::task::spawn_blocking(move || {
3271                    files
3272                        .and_then(|files| {
3273                            crate::symbols::describe_symbol_cross_file(&files, &uri, &label)
3274                        })
3275                        .map(|(_uri, md)| md)
3276                        // Slice 9: stdlib/surface symbols (e.g. a `uses bynk.list`
3277                        // combinator) live in the embedded first-party sources,
3278                        // not the project's files.
3279                        .or_else(|| crate::symbols::describe_firstparty_symbol(&label))
3280                })
3281                .await
3282                {
3283                    Ok(md) => md,
3284                    Err(e) => {
3285                        tracing::error!("completion-resolve describe task failed: {e}");
3286                        None
3287                    }
3288                }
3289            }
3290        };
3291        if let Some(md) = doc {
3292            item.documentation = Some(Documentation::MarkupContent(MarkupContent {
3293                kind: MarkupKind::Markdown,
3294                value: md,
3295            }));
3296        }
3297        Ok(item)
3298    }
3299
3300    async fn goto_definition(
3301        &self,
3302        params: GotoDefinitionParams,
3303    ) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
3304        let uri = params
3305            .text_document_position_params
3306            .text_document
3307            .uri
3308            .clone();
3309        let pos = params.text_document_position_params.position;
3310        // v0.25 rider: binding-correct definition via the index (fixes the
3311        // name-collision mis-navigation of the string-matching path). The
3312        // legacy path remains as fallback for not-yet-indexed symbol kinds
3313        // (locals, methods, fields, ops).
3314        if let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await {
3315            if let Some((_, def)) =
3316                crate::index_queries::definition_at(&analysis.index, &rel, offset)
3317                && let Some(location) = Self::site_to_location(&analysis, def)
3318            {
3319                return Ok(Some(GotoDefinitionResponse::Scalar(location)));
3320            }
3321            // v0.31: a local binding — scope-correct definition (before the
3322            // string-matching fallback, which can't tell scopes apart).
3323            if let Some(text) = analysis.snapshots.get(&rel)
3324                && let Some(locals) = analysis.locals.get(&rel)
3325                && let Some(def) = crate::locals_nav::local_definition_at(locals, text, offset)
3326                && let Some(location) = self
3327                    .local_locations(&analysis, &rel, &[def])
3328                    .into_iter()
3329                    .next()
3330            {
3331                return Ok(Some(GotoDefinitionResponse::Scalar(location)));
3332            }
3333        }
3334        // Slice 6a follow-up (ADR 0095): the cursor on a `uses`/`consumes` unit
3335        // name jumps to that unit's source. Units aren't index symbols, so the
3336        // unit→source map resolves them; runs before the name-matching path so a
3337        // unit segment can't be mistaken for a like-named type.
3338        if let Some(location) = self.unit_reference_definition(&uri, pos).await {
3339            return Ok(Some(GotoDefinitionResponse::Scalar(location)));
3340        }
3341        let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
3342            return Ok(None);
3343        };
3344        if let Some(decl_span) = crate::symbols::find_declaration_span(&text, &name) {
3345            let range = crate::position::span_to_range(&text, decl_span);
3346            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
3347                uri,
3348                range,
3349            })));
3350        }
3351        // Cross-file fallback (v1.1; LSP spec §3.4).
3352        if let Some(files) = self.project_content(&uri).await
3353            && let Some(found) = crate::symbols::find_declaration_cross_file(&files, &uri, &name)
3354        {
3355            let range = crate::position::span_to_range(&found.source, found.span);
3356            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
3357                uri: found.uri,
3358                range,
3359            })));
3360        }
3361        Ok(None)
3362    }
3363
3364    async fn formatting(
3365        &self,
3366        params: DocumentFormattingParams,
3367    ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
3368        let uri = params.text_document.uri;
3369        let text = {
3370            let s = self.state.read().await;
3371            s.docs.get(&uri).map(|d| d.text.clone())
3372        };
3373        let Some(text) = text else { return Ok(None) };
3374        // Slice D: the format options are the owning project's (or the defaults
3375        // in single-file mode).
3376        let opts = self.config_for(&uri).await.format_options();
3377        match bynk_fmt::format_source(&text, &opts) {
3378            Ok(formatted) => {
3379                // #1763: a CRLF buffer whose LF form is canonical needs no edit,
3380                // as on the command line; otherwise every format-on-save would
3381                // rewrite an already-canonical Windows file.
3382                if formatted == bynk_fmt::normalize_line_endings(&text) {
3383                    Ok(Some(Vec::new()))
3384                } else {
3385                    // Replace the entire document.
3386                    let end_pos = crate::position::end_position(&text);
3387                    Ok(Some(vec![TextEdit {
3388                        range: Range {
3389                            start: Position::new(0, 0),
3390                            end: end_pos,
3391                        },
3392                        new_text: formatted,
3393                    }]))
3394                }
3395            }
3396            Err(_) => {
3397                // Formatting failed (parse error). Return no edits; the
3398                // diagnostics flow will surface the parse error.
3399                Ok(Some(Vec::new()))
3400            }
3401        }
3402    }
3403
3404    async fn range_formatting(
3405        &self,
3406        params: DocumentRangeFormattingParams,
3407    ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
3408        // Best-effort: format the whole document. Per spec, range
3409        // formatting may return edits wider than the requested range.
3410        self.formatting(DocumentFormattingParams {
3411            text_document: params.text_document,
3412            options: params.options,
3413            work_done_progress_params: params.work_done_progress_params,
3414        })
3415        .await
3416    }
3417
3418    async fn document_symbol(
3419        &self,
3420        params: DocumentSymbolParams,
3421    ) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
3422        // v1.1 — outline view + Cmd-Shift-O. See `design/bynk-lsp-spec.md` §3.7.
3423        let uri = params.text_document.uri;
3424        let text = {
3425            let s = self.state.read().await;
3426            s.docs.get(&uri).map(|d| d.text.clone())
3427        };
3428        let Some(text) = text else { return Ok(None) };
3429        let syms = crate::document_symbols::outline(&text);
3430        if syms.is_empty() {
3431            return Ok(None);
3432        }
3433        Ok(Some(DocumentSymbolResponse::Nested(syms)))
3434    }
3435
3436    /// v0.37 (ADR 0070): `textDocument/foldingRange` — structural folds + comment
3437    /// runs from the recovered AST (no analysis round).
3438    async fn folding_range(
3439        &self,
3440        params: FoldingRangeParams,
3441    ) -> JsonRpcResult<Option<Vec<FoldingRange>>> {
3442        let uri = params.text_document.uri;
3443        let text = {
3444            let s = self.state.read().await;
3445            s.docs.get(&uri).map(|d| d.text.clone())
3446        };
3447        let Some(text) = text else { return Ok(None) };
3448        Ok(Some(crate::structure::folding_ranges(&text)))
3449    }
3450
3451    /// v0.37 (ADR 0070): `textDocument/selectionRange` — the enclosing-node
3452    /// chain (innermost first) for each requested position.
3453    async fn selection_range(
3454        &self,
3455        params: SelectionRangeParams,
3456    ) -> JsonRpcResult<Option<Vec<SelectionRange>>> {
3457        let uri = params.text_document.uri;
3458        let text = {
3459            let s = self.state.read().await;
3460            s.docs.get(&uri).map(|d| d.text.clone())
3461        };
3462        let Some(text) = text else { return Ok(None) };
3463        Ok(Some(crate::structure::selection_ranges(
3464            &text,
3465            &params.positions,
3466        )))
3467    }
3468
3469    async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
3470        let uri = params.text_document_position.text_document.uri;
3471        let pos = params.text_document_position.position;
3472        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3473            return Ok(None);
3474        };
3475        let include_decl = params.context.include_declaration;
3476        if let Some(sites) =
3477            crate::index_queries::sites_for(&analysis.index, &rel, offset, include_decl)
3478        {
3479            let locations: Vec<Location> = sites
3480                .into_iter()
3481                .filter_map(|site| Self::site_to_location(&analysis, site))
3482                .collect();
3483            return Ok(Some(locations));
3484        }
3485        // v0.31: a local binding — its def + uses, resolved from the snapshot.
3486        if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
3487            let spans = if include_decl {
3488                &spans[..]
3489            } else {
3490                &spans[1..]
3491            }; // def first
3492            let locations = self.local_locations(&analysis, &rel, spans);
3493            return Ok(Some(locations));
3494        }
3495        Ok(None)
3496    }
3497
3498    /// v0.26 (ADR 0054): quick-fixes from structured suggestions. v0.213
3499    /// (ADR 0239) adds the extract-variable refactor
3500    /// (`CodeActionKind::REFACTOR_EXTRACT`), computed from the same snapshot.
3501    /// Track #800 adds the sibling extract-function refactor, additionally
3502    /// fed the round's `requirements`/`locals`/`expr_types` (the
3503    /// capability-free-only gate and the parameter/return type synthesis).
3504    /// Served from the **cached** analysis round only (never a fresh run —
3505    /// slow, and it could disagree with the squiggles the client is
3506    /// showing): a request before the first round, or for a file outside
3507    /// the project, returns the empty list. #804: the combined list is then
3508    /// filtered against `params.context.only`, if the client set it.
3509    async fn code_action(
3510        &self,
3511        params: CodeActionParams,
3512    ) -> JsonRpcResult<Option<CodeActionResponse>> {
3513        let uri = params.text_document.uri;
3514        // #733: committed round. The request range and the diagnostics the fixes
3515        // ride on both convert against the round's snapshot, and the emitted edits
3516        // carry the round's version, so a committed round is self-consistent.
3517        let analysis = self.committed_analysis(&uri).await;
3518        let Some(analysis) = analysis else {
3519            return Ok(Some(Vec::new()));
3520        };
3521        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3522            return Ok(Some(Vec::new()));
3523        };
3524        let (Some(text), Some(diags)) =
3525            (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
3526        else {
3527            return Ok(Some(Vec::new()));
3528        };
3529        // The request range converts against the analysed snapshot (the
3530        // v0.24 rule), like the spans it is intersected with.
3531        let (Some(start), Some(end)) = (
3532            crate::position::position_to_offset(text, params.range.start),
3533            crate::position::position_to_offset(text, params.range.end),
3534        ) else {
3535            return Ok(Some(Vec::new()));
3536        };
3537        let version = analysis.versions.get(&rel).copied();
3538        let span = bynk_syntax::span::Span::new(start, end);
3539        let mut actions = crate::code_actions::quick_fixes(text, diags, span, &uri, version);
3540        // #852: capability-aware header fixes (`add consumes`, auto-`uses`/
3541        // `consumes`), computed from the committed index + a fresh reparse.
3542        actions.extend(crate::capability_fixes::header_quick_fixes(
3543            text,
3544            diags,
3545            span,
3546            &uri,
3547            version,
3548            &analysis.index,
3549        ));
3550        // #2100 slice 3: fill-match-arms, listed without its edit; the edit
3551        // and its re-check run in `code_action_resolve`.
3552        for match_span in crate::fill_arms::fillable(diags, span) {
3553            actions.push(crate::fill_arms::listed(diags, match_span, &uri, version));
3554        }
3555        // #2100 slice 1: inline the local binding under the cursor, listed
3556        // without its edit (resolved and re-checked lazily, like fill-arms).
3557        if let Some(plan) = analysis
3558            .locals
3559            .get(&rel)
3560            .and_then(|locals| crate::inline::offerable(text, span.start, locals))
3561        {
3562            actions.push(crate::inline::listed(&plan, &uri, version));
3563        }
3564        // #2100 slice 4: a provider stub for a capability with none, listed
3565        // without its edit (resolved and re-checked lazily).
3566        for request in crate::provider_stub::requested(text, diags, span) {
3567            if let Some(plan) =
3568                crate::provider_stub::plan(text, &request.capability, &analysis.index)
3569            {
3570                actions.push(crate::provider_stub::listed(
3571                    &plan,
3572                    request.quick_fix,
3573                    &uri,
3574                    version,
3575                ));
3576            }
3577        }
3578        actions.extend(crate::extract::extract_variable(text, span, &uri, version));
3579        let empty_reqs = Vec::new();
3580        let empty_locals = Vec::new();
3581        let empty_types = Vec::new();
3582        // #2100 slice 2: extract-function is listed without its edit and
3583        // re-checked in `code_action_resolve`; a capability-using (or `?`-
3584        // containing) selection gets a disabled action saying why, for a
3585        // client that shows those.
3586        let planned = crate::extract::plan_function(
3587            text,
3588            span,
3589            analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
3590            analysis.locals.get(&rel).unwrap_or(&empty_locals),
3591            analysis.expr_types.get(&rel).unwrap_or(&empty_types),
3592            &analysis.ty_intern,
3593        );
3594        let disabled_support = self.state.read().await.supports_code_action_disabled;
3595        actions.extend(crate::extract::function_listed(
3596            &planned,
3597            span,
3598            &uri,
3599            version,
3600            disabled_support,
3601        ));
3602        // #2100: source actions are computed only when the client asks for
3603        // their kind (the Source Action menu, the organise-imports command,
3604        // `editor.codeActionsOnSave`), never for the unfiltered lightbulb
3605        // request every cursor move sends: each one re-analyses the project
3606        // before it is offered (DECISION B).
3607        let only = params.context.only.as_deref();
3608        let wants = |kind: &CodeActionKind| {
3609            only.is_some_and(|only| {
3610                only.iter()
3611                    .any(|requested| crate::code_actions::kind_matches(kind, requested))
3612            })
3613        };
3614        if wants(&CodeActionKind::SOURCE_ORGANIZE_IMPORTS)
3615            && let Some(action) = self.organise_action(&analysis, &rel, &uri, version).await
3616        {
3617            actions.push(action);
3618        }
3619        if wants(&crate::fix_all::kind()) {
3620            let edits = crate::fix_all::edits(text, diags);
3621            if !edits.is_empty()
3622                && self
3623                    .rechecks_clean(&analysis, &rel, crate::fix_all::apply(text, &edits))
3624                    .await
3625            {
3626                actions.push(crate::fix_all::action(text, &edits, &uri, version));
3627            }
3628        }
3629        // #2098: "Explain `code`" for curated codes in range — last, so it never
3630        // preempts a real fix, and only for a client that runs the command.
3631        if self.state.read().await.supports_explain_command {
3632            actions.extend(crate::explain_request::explain_actions(diags, span));
3633        }
3634        // #804: honour the client's requested action kinds, if any.
3635        let actions = crate::code_actions::filter_by_only(actions, only);
3636        Ok(Some(actions))
3637    }
3638
3639    /// #2100 slice 3: resolve a listed fill-match-arms action — compute its
3640    /// edit from the committed round and offer it only if the edited project
3641    /// re-checks clean (DECISION B). The round must still be the one the
3642    /// action was listed from (same document version); otherwise, or when
3643    /// the fill doesn't re-check, the resolve is refused with a message the
3644    /// client shows. Any other action resolves to itself.
3645    async fn code_action_resolve(&self, action: CodeAction) -> JsonRpcResult<CodeAction> {
3646        if let Some(target) = crate::inline::Target::of(&action) {
3647            return self.resolve_inline(action, target).await;
3648        }
3649        if let Some(target) = crate::provider_stub::Target::of(&action) {
3650            return self.resolve_provider_stub(action, target).await;
3651        }
3652        if let Some(target) = crate::extract::FunctionTarget::of(&action) {
3653            return self.resolve_extract_function(action, target).await;
3654        }
3655        let Some(target) = crate::fill_arms::Target::of(&action) else {
3656            return Ok(action);
3657        };
3658        let refused = |msg: &str| tower_lsp::jsonrpc::Error {
3659            code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3660            message: msg.to_string().into(),
3661            data: None,
3662        };
3663        let stale = "the document changed since the match arms were offered";
3664        let analysis = self
3665            .committed_analysis(&target.uri)
3666            .await
3667            .ok_or_else(|| refused(stale))?;
3668        let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
3669        let (Some(text), Some(diags)) =
3670            (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
3671        else {
3672            return Err(refused(stale));
3673        };
3674        let version = analysis.versions.get(&rel).copied();
3675        if version != target.version {
3676            return Err(refused(stale));
3677        }
3678        let edits = crate::fill_arms::edits(text, diags, target.match_span)
3679            .ok_or_else(|| refused("the missing match arms can't be filled here"))?;
3680        let filled = crate::fix_all::apply(text, &edits);
3681        if !self.rechecks_clean(&analysis, &rel, filled).await {
3682            return Err(refused(
3683                "filling the missing match arms would introduce a new problem",
3684            ));
3685        }
3686        Ok(crate::fill_arms::resolve(
3687            action,
3688            text,
3689            &edits,
3690            &target.uri,
3691            version,
3692        ))
3693    }
3694
3695    /// v0.27 (ADR 0056): inferred-type inlay hints for the visible range,
3696    /// served from the cached round only — no cached round (pre-first-
3697    /// analysis, non-project file) returns the empty list. Positions
3698    /// convert against the analysed snapshot (the v0.24 rule).
3699    async fn inlay_hint(&self, params: InlayHintParams) -> JsonRpcResult<Option<Vec<InlayHint>>> {
3700        let uri = params.text_document.uri;
3701        // #733: committed round, no forced re-analysis (see `committed_analysis`).
3702        let analysis = self.committed_analysis(&uri).await;
3703        let Some(analysis) = analysis else {
3704            return Ok(Some(Vec::new()));
3705        };
3706        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3707            return Ok(Some(Vec::new()));
3708        };
3709        let Some(text) = analysis.snapshots.get(&rel) else {
3710            return Ok(Some(Vec::new()));
3711        };
3712        // The visible range converts against the analysed snapshot, like
3713        // the hint spans it is intersected with.
3714        let (Some(start), Some(end)) = (
3715            crate::position::position_to_offset(text, params.range.start),
3716            crate::position::position_to_offset(text, params.range.end),
3717        ) else {
3718            return Ok(Some(Vec::new()));
3719        };
3720        let visible = bynk_syntax::span::Span::new(start, end);
3721        // v0.27: inferred-type hints. v0.99: plus the materializable ghost
3722        // `given` hints for uncovered capability requirements. A file may carry
3723        // one set without the other, so each defaults to empty independently.
3724        let mut hints = analysis
3725            .hints
3726            .get(&rel)
3727            .map(|h| crate::inlay_hints::inlay_hints(text, h, visible))
3728            .unwrap_or_default();
3729        if let Some(reqs) = analysis.requirements.get(&rel) {
3730            hints.extend(crate::inlay_hints::given_hints(text, reqs, visible));
3731        }
3732        Ok(Some(hints))
3733    }
3734
3735    /// v0.28 (ADR 0057): semantic tokens for the whole document, served
3736    /// from the cached round only (no cached round / non-project file →
3737    /// empty), positions against the analysed snapshot (the v0.24 rule).
3738    async fn semantic_tokens_full(
3739        &self,
3740        params: SemanticTokensParams,
3741    ) -> JsonRpcResult<Option<SemanticTokensResult>> {
3742        let data = self
3743            .semantic_tokens_for(&params.text_document.uri, None)
3744            .await;
3745        Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
3746            result_id: None,
3747            data,
3748        })))
3749    }
3750
3751    /// v0.28 (ADR 0057): the `…/range` variant — the same pure read,
3752    /// filtered to tokens overlapping the requested range.
3753    async fn semantic_tokens_range(
3754        &self,
3755        params: SemanticTokensRangeParams,
3756    ) -> JsonRpcResult<Option<SemanticTokensRangeResult>> {
3757        let data = self
3758            .semantic_tokens_for(&params.text_document.uri, Some(params.range))
3759            .await;
3760        Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
3761            result_id: None,
3762            data,
3763        })))
3764    }
3765
3766    /// v0.26 rider (ADR 0055): workspace-wide symbol search — the index's
3767    /// definitions, filtered by the query. Slice D (Q4): one server, many
3768    /// projects — aggregate across **every** project. Candidates are the
3769    /// **already-warmed** projects (slice E warms every project under the folders
3770    /// at `initialized`, and the watcher warms one created later), plus each
3771    /// folder's own `resolve_root` — a cheap bounded walk-*up*, the pre-slice-E
3772    /// seeding. No full tree-walk on this request path: a `workspace/symbol`
3773    /// query can fire per keystroke, and the warmed set already holds the nested
3774    /// monorepo projects a walk would rediscover.
3775    async fn symbol(
3776        &self,
3777        params: WorkspaceSymbolParams,
3778    ) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
3779        let candidates: Vec<(PathBuf, ProjectConfig)> = {
3780            // Snapshot warmed projects + folders under the lock; resolve the
3781            // folders' own roots off it (a bounded walk-up, but still FS I/O).
3782            let (mut set, folders) = {
3783                let state = self.state.read().await;
3784                let known: std::collections::HashMap<PathBuf, ProjectConfig> = state
3785                    .projects
3786                    .iter()
3787                    .map(|(r, p)| (r.clone(), p.config.clone()))
3788                    .collect();
3789                (known, state.folders.clone())
3790            };
3791            for folder in &folders {
3792                if let Some((root, config)) = Self::resolve_root(folder) {
3793                    let root = root.canonicalize().unwrap_or(root);
3794                    set.entry(root).or_insert(config);
3795                }
3796            }
3797            set.into_iter().collect()
3798        };
3799        let mut symbols: Vec<SymbolInformation> = Vec::new();
3800        for (root, config) in candidates {
3801            let Some(analysis) = self.ensure_project_analysed(root, config).await else {
3802                continue;
3803            };
3804            for (key, def) in
3805                crate::index_queries::workspace_symbols(&analysis.index, &params.query)
3806            {
3807                let Some(location) = Self::site_to_location(&analysis, def) else {
3808                    continue;
3809                };
3810                #[allow(deprecated)]
3811                symbols.push(SymbolInformation {
3812                    name: key.name.clone(),
3813                    kind: lsp_symbol_kind(key.kind),
3814                    tags: None,
3815                    deprecated: None,
3816                    location,
3817                    container_name: Some(key.unit.clone()),
3818                });
3819            }
3820        }
3821        // Aggregating across projects (a `HashMap`-derived candidate list) groups
3822        // matches by project in arbitrary order; the spec (§3.11) promises a
3823        // stable `(name, unit)` ordering, so sort the merged result. `unit` is
3824        // the container name.
3825        symbols.sort_by(|a, b| {
3826            a.name
3827                .cmp(&b.name)
3828                .then_with(|| a.container_name.cmp(&b.container_name))
3829        });
3830        Ok(Some(symbols))
3831    }
3832
3833    /// v0.26 rider (ADR 0055): the symbol-at-cursor's occurrences in the
3834    /// active file. `kind` is omitted — the index does not distinguish read
3835    /// from write references.
3836    async fn document_highlight(
3837        &self,
3838        params: DocumentHighlightParams,
3839    ) -> JsonRpcResult<Option<Vec<DocumentHighlight>>> {
3840        let uri = params.text_document_position_params.text_document.uri;
3841        let pos = params.text_document_position_params.position;
3842        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3843            return Ok(None);
3844        };
3845        let Some(text) = analysis.snapshots.get(&rel) else {
3846            return Ok(None);
3847        };
3848        if let Some(sites) =
3849            crate::index_queries::document_highlights(&analysis.index, &rel, offset)
3850        {
3851            let highlights: Vec<DocumentHighlight> = sites
3852                .into_iter()
3853                .map(|s| DocumentHighlight {
3854                    range: crate::position::span_to_range(text, s.span),
3855                    kind: None,
3856                })
3857                .collect();
3858            return Ok(Some(highlights));
3859        }
3860        // v0.31: a local binding's occurrences (def + uses) in the file.
3861        if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
3862            let highlights = spans
3863                .iter()
3864                .map(|s| DocumentHighlight {
3865                    range: crate::position::span_to_range(text, *s),
3866                    kind: None,
3867                })
3868                .collect();
3869            return Ok(Some(highlights));
3870        }
3871        Ok(None)
3872    }
3873
3874    async fn prepare_rename(
3875        &self,
3876        params: TextDocumentPositionParams,
3877    ) -> JsonRpcResult<Option<PrepareRenameResponse>> {
3878        let uri = params.text_document.uri;
3879        let pos = params.position;
3880        // Refuse (None) for anything the index does not cover — locals,
3881        // methods, record fields, capability ops, unit names — rather than
3882        // falling through to a partial or name-matched rename.
3883        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3884            return Ok(None);
3885        };
3886        let Some((key, site)) = crate::index_queries::prepare_rename(&analysis.index, &rel, offset)
3887        else {
3888            return Ok(None);
3889        };
3890        let Some(text) = analysis.snapshots.get(&rel) else {
3891            return Ok(None);
3892        };
3893        Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
3894            range: crate::position::span_to_range(text, site.span),
3895            placeholder: key.name.clone(),
3896        }))
3897    }
3898
3899    async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
3900        let uri = params.text_document_position.text_document.uri;
3901        let pos = params.text_document_position.position;
3902        let new_name = params.new_name;
3903        let refused = |msg: String| tower_lsp::jsonrpc::Error {
3904            code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3905            message: msg.into(),
3906            data: None,
3907        };
3908        // Slice B: rename emits versioned edits across *every* file that
3909        // references the symbol, so it needs the round current for **all** open
3910        // buffers, not just the cursor's (`analysis_for` would leave a dirty
3911        // non-cursor file stale and its edit would be stamped with an old
3912        // version, which the client rejects). `analysis_covering_open_buffers`
3913        // restores the whole-project freshness the pre-v0.179 `fresh_analysis`
3914        // gave. The cursor's file is one of those buffers, so it is current too;
3915        // resolve `rel`/`offset` against it here (what `index_position` did).
3916        // Slice D (Q4): route by the cursor's project — a rename spans one
3917        // project, so the round need only cover *that* project's buffers.
3918        let Some(root) = self.root_for_uri(&uri).await else {
3919            return Err(refused("rename requires a project (bynk.toml)".into()));
3920        };
3921        let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3922            return Err(refused("rename requires a project (bynk.toml)".into()));
3923        };
3924        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3925            return Ok(None);
3926        };
3927        let Some(text) = analysis.snapshots.get(&rel) else {
3928            return Ok(None);
3929        };
3930        let Some(offset) = crate::position::position_to_offset(text, pos) else {
3931            return Ok(None);
3932        };
3933        let plan = crate::index_queries::plan_rename(&analysis.index, &rel, offset, &new_name)
3934            .map_err(refused)?;
3935
3936        // Validator 1 + 2 input: re-analyse with the edits applied. Every
3937        // snapshot is pinned via the overlay so the re-analysis differs from
3938        // the plan's baseline only by the edits themselves.
3939        //
3940        // Slice A: this must re-analyse over the **same roots** the baseline
3941        // round used (`AnalysisRoots::Project`, manifest-aware), not the
3942        // single-tree `diagnose_project`. `diagnose_project(project_root)`
3943        // resolves to `Roots::Single`, which walks the whole tree with **no
3944        // `exclude`** and no `out`/`node_modules` skip — so `post` would cover a
3945        // superset of the baseline's files, and validators 1 and 2 (which
3946        // compare `post` against baselines from the manifest-aware round) would
3947        // read a diagnostic or index site in an excluded tree as *new* and
3948        // refuse a valid rename.
3949        let mut overlay = std::collections::HashMap::new();
3950        for (rel_path, text) in &analysis.snapshots {
3951            let edited = match plan.edits.get(rel_path) {
3952                Some(spans) => crate::index_queries::apply_edits(text, spans, &plan.new_name),
3953                None => text.clone(),
3954            };
3955            let abs = analysis.project_root.join(rel_path);
3956            let abs = abs.canonicalize().unwrap_or(abs);
3957            overlay.insert(abs, edited);
3958        }
3959        let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
3960        // #2081: `Off`. Schema-lock diagnostics are project-level: they land
3961        // in `unattributed`, which neither validator compares (both read
3962        // `post.files`), so reading the lock here would cost a `stat` and an
3963        // event sweep for nothing. A rename that changes an event
3964        // non-additively is not refused; the next diagnostics round reports
3965        // it on `bynk.toml`.
3966        let Ok(post) = tokio::task::spawn_blocking(move || {
3967            bynk_ide::diagnose_project_with(&roots, &overlay, &bynk_ide::SchemaLock::Off)
3968        })
3969        .await
3970        else {
3971            return Err(refused("rename validation failed to run".into()));
3972        };
3973
3974        // Validator 1 — collisions: refuse on any new diagnostic.
3975        let post_diags: Vec<(PathBuf, String)> = post
3976            .files
3977            .iter()
3978            .flat_map(|f| {
3979                f.diagnostics
3980                    .iter()
3981                    .map(|d| (f.source_path.clone(), d.error.category.to_string()))
3982            })
3983            .collect();
3984        crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags)
3985            .map_err(refused)?;
3986
3987        // Validator 2 — capture/escape: the re-built index must be the old
3988        // index modulo the rename; a silent re-binding has no diagnostic.
3989        if !crate::index_queries::index_unchanged_modulo_rename(&analysis.index, &post.index, &plan)
3990        {
3991            return Err(refused(format!(
3992                "renaming `{}` to `{new_name}` would silently re-bind another name — refused",
3993                plan.key.name
3994            )));
3995        }
3996
3997        // Versioned edits: the client rejects the rename if a buffer drifted
3998        // past the analysed version rather than mis-applying it.
3999        let mut document_edits: Vec<TextDocumentEdit> = Vec::new();
4000        for (rel_path, spans) in &plan.edits {
4001            let Some(text) = analysis.snapshots.get(rel_path) else {
4002                continue;
4003            };
4004            let abs = analysis.project_root.join(rel_path);
4005            let Ok(file_uri) = Url::from_file_path(&abs) else {
4006                continue;
4007            };
4008            let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = spans
4009                .iter()
4010                .map(|span| {
4011                    OneOf::Left(TextEdit {
4012                        range: crate::position::span_to_range(text, *span),
4013                        new_text: plan.new_name.clone(),
4014                    })
4015                })
4016                .collect();
4017            document_edits.push(TextDocumentEdit {
4018                text_document: OptionalVersionedTextDocumentIdentifier {
4019                    uri: file_uri,
4020                    version: analysis.versions.get(rel_path).copied(),
4021                },
4022                edits,
4023            });
4024        }
4025        Ok(Some(WorkspaceEdit {
4026            changes: None,
4027            document_changes: Some(DocumentChanges::Edits(document_edits)),
4028            change_annotations: None,
4029        }))
4030    }
4031
4032    /// #302: `workspace/willRenameFiles` — when a `.bynk` file is renamed or
4033    /// moved, keep `uses`/`consumes` references pointing at its unit in sync.
4034    /// Uses `analysis_covering_open_buffers`, the same gate `rename`
4035    /// uses: this handler emits multi-file **versioned** edits too, so a
4036    /// stale open buffer must be refreshed first or the client rejects the
4037    /// whole edit — unlike `documentLink`'s read-only decoration, which
4038    /// tolerates a round lagging by one debounce cycle.
4039    ///
4040    /// Never refuses: a filesystem rename isn't something this soft,
4041    /// edit-only hook can block (the response is just an optional edit), so
4042    /// anything this can't confidently resolve — an unparseable file, a
4043    /// `suite` (addressed by no one), a rename that preserves the unit's
4044    /// arrangement, a cross-project move, a name collision with an existing
4045    /// unit — is simply skipped rather than erroring the whole batch. The
4046    /// collision check is a lightweight `unit_sources` lookup, not `rename`'s
4047    /// full re-analysis: good enough to avoid handing back an edit that is
4048    /// *known in advance* to break the build, without paying for a second
4049    /// analysis round on every file move.
4050    ///
4051    /// Edits for the moved file's own declaration target `old_uri`, not
4052    /// `new_uri`: the client applies the returned edit against files at
4053    /// their current (pre-move) locations, then performs the actual rename,
4054    /// so the file lands at its new path already carrying the new name.
4055    ///
4056    /// #2105: a `FileRename` may name a **folder** (the capability registers a
4057    /// folder filter too). A folder rename is the same operation as one file
4058    /// rename per `.bynk` file under it, so it is expanded server-side from
4059    /// the round's snapshots (`Self::expand_rename`) and each file goes
4060    /// through the single-file computation. A folder holding no project file
4061    /// expands to nothing. Across the whole request, a file is handled once
4062    /// (a request naming a file *and* its enclosing folder is deduplicated by
4063    /// resolved path), and each renamed unit's references are rewritten once
4064    /// (the files of a multi-file unit all map to the same new name). A move
4065    /// whose destination belongs to a different project root is skipped with
4066    /// a log line (DECISION B): the units' identity changes beyond a rename.
4067    async fn will_rename_files(
4068        &self,
4069        params: RenameFilesParams,
4070    ) -> JsonRpcResult<Option<WorkspaceEdit>> {
4071        let mut combined: std::collections::HashMap<Url, (Option<i32>, Vec<TextEdit>)> =
4072            std::collections::HashMap::new();
4073        // #2105: (project root, project-relative path) of every file handled,
4074        // and (project root, old unit name) of every unit whose references
4075        // were rewritten, across the whole request.
4076        let mut handled_files: std::collections::HashSet<(PathBuf, PathBuf)> =
4077            std::collections::HashSet::new();
4078        let mut renamed_units: std::collections::HashSet<(PathBuf, String)> =
4079            std::collections::HashSet::new();
4080        for fr in &params.files {
4081            let (Ok(old_uri), Ok(new_uri)) = (Url::parse(&fr.old_uri), Url::parse(&fr.new_uri))
4082            else {
4083                continue;
4084            };
4085            let Some(root) = self.root_for_uri(&old_uri).await else {
4086                continue;
4087            };
4088            // DECISION B: a move into another project changes the units'
4089            // identity beyond a rename; leave its references to the user.
4090            if Self::new_path_root(&new_uri).as_ref() != Some(&root) {
4091                tracing::info!(
4092                    "willRenameFiles: {} -> {} leaves project {}; no reference edits",
4093                    fr.old_uri,
4094                    fr.new_uri,
4095                    root.display()
4096                );
4097                continue;
4098            }
4099            let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
4100                continue;
4101            };
4102            for (old_rel, new_rel) in Self::expand_rename(&analysis, &old_uri, &new_uri) {
4103                if !handled_files.insert((root.clone(), old_rel.clone())) {
4104                    continue;
4105                }
4106                let Some(text) = analysis.snapshots.get(&old_rel) else {
4107                    continue;
4108                };
4109                let Some((old_name, name_span)) = crate::symbols::own_declaration_name(text) else {
4110                    continue;
4111                };
4112                let Some(new_name) = bynk_ide::renamed_unit_name(&old_rel, &old_name, &new_rel)
4113                else {
4114                    // #2105: logged, since inside a folder rename the other
4115                    // units are still edited and this one is moved unchanged.
4116                    tracing::info!(
4117                        "willRenameFiles: {} -> {}: no unit name follows from the new path \
4118                         for `{old_name}`; left unchanged",
4119                        old_rel.display(),
4120                        new_rel.display()
4121                    );
4122                    continue;
4123                };
4124                if new_name == old_name {
4125                    continue;
4126                }
4127                // Refuse to hand back an edit that would create a duplicate unit
4128                // name — some other file already declares `new_name`.
4129                if analysis.unit_sources.contains_key(&new_name) {
4130                    tracing::info!(
4131                        "willRenameFiles: {} -> {}: `{new_name}` is already declared; \
4132                         `{old_name}` left unchanged",
4133                        old_rel.display(),
4134                        new_rel.display()
4135                    );
4136                    continue;
4137                }
4138                // Every file's Url is reconstructed the same way (never the
4139                // client's raw `old_uri`/`new_uri` strings) so the moved file's
4140                // own edit and a referencer's edit merge into the same
4141                // `TextDocumentEdit` when they're the same file — a raw client
4142                // string and a `from_file_path` reconstruction aren't guaranteed
4143                // byte-identical (percent-encoding, trailing slashes).
4144                let Ok(old_file_uri) = Url::from_file_path(analysis.project_root.join(&old_rel))
4145                else {
4146                    continue;
4147                };
4148                // The moved file's own declaration header — edited at its old
4149                // (still current) location.
4150                combined
4151                    .entry(old_file_uri)
4152                    .or_insert_with(|| (analysis.versions.get(&old_rel).copied(), Vec::new()))
4153                    .1
4154                    .push(TextEdit {
4155                        range: crate::position::span_to_range(text, name_span),
4156                        new_text: new_name.clone(),
4157                    });
4158                // #2105: the files of a multi-file unit share its name, so its
4159                // references are rewritten for the first of them only — a
4160                // second pass would emit the same, overlapping, edits again.
4161                if !renamed_units.insert((root.clone(), old_name.clone())) {
4162                    continue;
4163                }
4164                // Every other file's `uses`/`consumes` references to the old name.
4165                for (rel, snap_text) in &analysis.snapshots {
4166                    if *rel == old_rel {
4167                        continue;
4168                    }
4169                    let edits: Vec<TextEdit> = crate::symbols::unit_reference_spans(snap_text)
4170                        .into_iter()
4171                        .filter(|(unit, _)| *unit == old_name)
4172                        .map(|(_, span)| TextEdit {
4173                            range: crate::position::span_to_range(snap_text, span),
4174                            new_text: new_name.clone(),
4175                        })
4176                        .collect();
4177                    if edits.is_empty() {
4178                        continue;
4179                    }
4180                    let Ok(file_uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
4181                        continue;
4182                    };
4183                    combined
4184                        .entry(file_uri)
4185                        .or_insert_with(|| (analysis.versions.get(rel).copied(), Vec::new()))
4186                        .1
4187                        .extend(edits);
4188                }
4189            }
4190        }
4191        if combined.is_empty() {
4192            return Ok(None);
4193        }
4194        let document_edits: Vec<TextDocumentEdit> = combined
4195            .into_iter()
4196            .map(|(uri, (version, edits))| TextDocumentEdit {
4197                text_document: OptionalVersionedTextDocumentIdentifier { uri, version },
4198                edits: edits.into_iter().map(OneOf::Left).collect(),
4199            })
4200            .collect();
4201        Ok(Some(WorkspaceEdit {
4202            changes: None,
4203            document_changes: Some(DocumentChanges::Edits(document_edits)),
4204            change_annotations: None,
4205        }))
4206    }
4207
4208    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
4209        // #682: a `bynk.toml` create/delete/change is the one event that can
4210        // move an already-cached URI's route (see `State.root_cache`'s doc) —
4211        // invalidate the whole cache before this batch's lookups consult it,
4212        // so a manifest that just appeared/vanished is reflected within the
4213        // same round rather than one event late. The generation bump closes
4214        // `root_for_uri`'s TOCTOU window against a walk already in flight.
4215        if params.changes.iter().any(|ev| is_bynk_toml(&ev.uri)) {
4216            let mut state = self.state.write().await;
4217            state.root_cache.clear();
4218            state.root_cache_generation += 1;
4219        }
4220        // For every changed `.bynk` file we have open, refresh diagnostics.
4221        // Changes to files we do *not* have open (a git checkout, an external
4222        // edit) still invalidate the project index — schedule a project round
4223        // so cross-file state doesn't go stale (#513).
4224        let mut uris_to_refresh = Vec::new();
4225        // Slice D: route each change to its owning project root, so a change in
4226        // project A never re-analyses project B.
4227        let mut roots_to_reanalyse: std::collections::HashSet<PathBuf> =
4228            std::collections::HashSet::new();
4229        // A `bynk.toml` edit changes the formatting style, the diagnostics
4230        // mode/debounce, and the source root — none of which were re-read after
4231        // the initial load, so the settings only took effect on an LSP restart.
4232        // Detect the change here and reload that project's config before
4233        // re-analysing it.
4234        let mut config_changed_roots: std::collections::HashSet<PathBuf> =
4235            std::collections::HashSet::new();
4236        // #682: snapshotted off the lock — the loop below calls the
4237        // cache-consulting `root_for_uri`, which itself locks `state`, so it
4238        // must not run while a read lock from this function is still held.
4239        let open_docs: std::collections::HashSet<Url> =
4240            self.state.read().await.docs.keys().cloned().collect();
4241        for ev in &params.changes {
4242            if is_bynk_toml(&ev.uri) {
4243                // The manifest's own directory is the project root.
4244                if let Ok(p) = ev.uri.to_file_path()
4245                    && let Some(dir) = p.parent()
4246                {
4247                    let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
4248                    config_changed_roots.insert(root.clone());
4249                    roots_to_reanalyse.insert(root);
4250                }
4251            } else if schema_lock::is_schema_lock(&ev.uri) {
4252                // #2081: the lock's own directory is the project root. Drop
4253                // its cached content and re-analyse, so the editor shows the
4254                // new lock's verdict without waiting for an edit.
4255                if let Ok(p) = ev.uri.to_file_path()
4256                    && let Some(dir) = p.parent()
4257                {
4258                    let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
4259                    if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
4260                        ps.schema_lock.invalidate();
4261                    }
4262                    roots_to_reanalyse.insert(root);
4263                }
4264            } else if open_docs.contains(&ev.uri) {
4265                uris_to_refresh.push(ev.uri.clone());
4266            } else if ev.uri.path().ends_with(".bynk")
4267                && let Some(root) = self.root_for_uri(&ev.uri).await
4268            {
4269                roots_to_reanalyse.insert(root);
4270            }
4271        }
4272        // A `bynk.toml` change reloads its project's config — and, if the
4273        // manifest was just *created*, warms the new project (create the entry).
4274        // Slice E: this is how a project added after startup is picked up now
4275        // that `workspace/symbol` no longer walks the tree per query.
4276        for root in &config_changed_roots {
4277            let config = project::load_config(root).unwrap_or_default();
4278            let mut state = self.state.write().await;
4279            state
4280                .projects
4281                .entry(root.clone())
4282                .and_modify(|ps| ps.config = config.clone())
4283                .or_insert_with(|| ProjectState {
4284                    config,
4285                    ..Default::default()
4286                });
4287        }
4288        for uri in uris_to_refresh {
4289            self.schedule_diagnostics(&uri).await;
4290        }
4291        // A reloaded config re-derives the diagnostics behaviour, so re-analyse
4292        // each affected project against it — the same debounced round a non-open
4293        // `.bynk` change schedules. A no-op for a root with no entry (a project
4294        // no file has opened): nothing is published there to go stale.
4295        for root in roots_to_reanalyse {
4296            self.schedule_project_diagnostics(root).await;
4297        }
4298    }
4299
4300    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
4301        // Slice D (Q4): folders are discovery seeds, not routing owners.
4302        // Added folders extend the seed set; removed folders shrink it, then any
4303        // project a removed folder orphaned — no remaining folder, no open
4304        // buffer — is pruned.
4305        let added_dirs: Vec<PathBuf> = {
4306            let mut state = self.state.write().await;
4307            let mut added = Vec::new();
4308            for a in &params.event.added {
4309                if let Ok(p) = a.uri.to_file_path() {
4310                    let dir = p.canonicalize().unwrap_or(p);
4311                    if !state.folders.contains(&dir) {
4312                        state.folders.push(dir.clone());
4313                        added.push(dir);
4314                    }
4315                }
4316            }
4317            for removed in &params.event.removed {
4318                if let Ok(p) = removed.uri.to_file_path() {
4319                    let dir = p.canonicalize().unwrap_or(p);
4320                    state.folders.retain(|f| f != &dir);
4321                }
4322            }
4323            // #682: `resolve_canonical` never consults `folders` — a folder
4324            // change cannot actually move any URI's route today — but clear
4325            // (and bump the generation, same as the `bynk.toml` case) as a
4326            // defensive, effectively-free no-op against that ever changing,
4327            // rather than relying on routing's independence from folders
4328            // staying true forever.
4329            state.root_cache.clear();
4330            state.root_cache_generation += 1;
4331            added
4332        };
4333        // Slice E: warm the added folders proactively — the analysis D deferred
4334        // to here, using the same discovery walk as startup.
4335        self.warm_projects(&added_dirs).await;
4336        // Clear the dropped projects' diagnostics so the client does not keep
4337        // showing stale squiggles for a folder that is gone.
4338        for uri in self.prune_orphaned_projects().await {
4339            self.client.publish_diagnostics(uri, Vec::new(), None).await;
4340        }
4341    }
4342}
4343
4344/// The advertised capability set — `design/bynk-lsp-spec.md` §4.3. Split out
4345/// of `initialize` so the advertisement is unit-testable without transport.
4346fn server_capabilities() -> ServerCapabilities {
4347    ServerCapabilities {
4348        // Full-text sync, with save notifications explicitly opted in — the
4349        // `on_save` diagnostics mode is driven by `didSave` (#513).
4350        text_document_sync: Some(TextDocumentSyncCapability::Options(
4351            TextDocumentSyncOptions {
4352                open_close: Some(true),
4353                change: Some(TextDocumentSyncKind::FULL),
4354                save: Some(TextDocumentSyncSaveOptions::Supported(true)),
4355                ..Default::default()
4356            },
4357        )),
4358        hover_provider: Some(HoverProviderCapability::Simple(true)),
4359        definition_provider: Some(OneOf::Left(true)),
4360        // v0.17: completion for `consumes` units and `given` /
4361        // `consumes U { … }` capabilities. Trigger on the space after a
4362        // keyword, the `{` of a selected-capability list, and `,`. The `.`
4363        // auto-fires the name- and value-receiver member contexts (ADR 0093 D1).
4364        completion_provider: Some(CompletionOptions {
4365            trigger_characters: Some(vec![
4366                " ".to_string(),
4367                "{".to_string(),
4368                ",".to_string(),
4369                ".".to_string(),
4370            ]),
4371            // Slice 5: resolve fills in hover-quality `documentation` lazily, on
4372            // the focused item only, so the initial list stays cheap.
4373            resolve_provider: Some(true),
4374            ..Default::default()
4375        }),
4376        // v0.32 (ADR 0065): signature help while typing a call's arguments.
4377        signature_help_provider: Some(SignatureHelpOptions {
4378            trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
4379            retrigger_characters: Some(vec![",".to_string()]),
4380            ..Default::default()
4381        }),
4382        // v0.33 (ADR 0066): reference-count lenses above top-level definitions.
4383        code_lens_provider: Some(CodeLensOptions {
4384            resolve_provider: Some(false),
4385        }),
4386        // v0.34 (ADR 0067): call hierarchy over the binding index's call graph.
4387        call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
4388        // v0.35 (ADR 0068): implementation nav — capability → its providers.
4389        implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
4390        // Slice 6: go-to-type-definition (value → its type's declaration).
4391        type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
4392        // Slice 6b: `uses`/`consumes` unit names link to their source.
4393        document_link_provider: Some(DocumentLinkOptions {
4394            resolve_provider: Some(false),
4395            work_done_progress_options: Default::default(),
4396        }),
4397        document_formatting_provider: Some(OneOf::Left(true)),
4398        document_range_formatting_provider: Some(OneOf::Left(true)),
4399        document_symbol_provider: Some(OneOf::Left(true)),
4400        // v0.37 (ADR 0070): structural folding + selection ranges (AST-driven).
4401        folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
4402        selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
4403        // v0.25 (ADR 0053): references + rename over the binding
4404        // index; prepareRename refuses out-of-scope symbols.
4405        references_provider: Some(OneOf::Left(true)),
4406        rename_provider: Some(OneOf::Right(RenameOptions {
4407            prepare_provider: Some(true),
4408            work_done_progress_options: Default::default(),
4409        })),
4410        // v0.26 (ADR 0054): quick-fixes from the diagnostics' structured
4411        // suggestions. v0.213 (ADR 0239) adds the extract-variable refactor.
4412        code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
4413            code_action_kinds: Some(vec![
4414                CodeActionKind::QUICKFIX,
4415                CodeActionKind::REFACTOR,
4416                CodeActionKind::REFACTOR_EXTRACT,
4417                CodeActionKind::REFACTOR_INLINE,
4418                CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
4419                crate::fix_all::kind(),
4420            ]),
4421            // #2100 slice 3: fill-match-arms computes its edit lazily.
4422            resolve_provider: Some(true),
4423            ..Default::default()
4424        })),
4425        // v0.27 (ADR 0056): inferred-type inlay hints from the retained
4426        // analysis round's harvested hint set.
4427        inlay_hint_provider: Some(OneOf::Left(true)),
4428        // v0.28 (ADR 0057): semantic tokens over the frozen legend — a
4429        // pure read of the cached index (`symbols` + `foreign_refs`),
4430        // additive over the client's syntactic layer. `delta` deferred.
4431        semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions(
4432            SemanticTokensOptions {
4433                legend: crate::index_queries::semantic_tokens_legend(),
4434                full: Some(SemanticTokensFullOptions::Bool(true)),
4435                range: Some(true),
4436                ..Default::default()
4437            },
4438        )),
4439        // v0.26 riders (ADR 0055): both are `ProjectIndex` queries.
4440        workspace_symbol_provider: Some(OneOf::Left(true)),
4441        document_highlight_provider: Some(OneOf::Left(true)),
4442        workspace: Some(WorkspaceServerCapabilities {
4443            workspace_folders: Some(WorkspaceFoldersServerCapabilities {
4444                supported: Some(true),
4445                change_notifications: Some(OneOf::Left(true)),
4446            }),
4447            // #302: `willRenameFiles` over `.bynk` files keeps `uses`/`consumes`
4448            // references in sync on a file rename/move. #2105: a second filter
4449            // matches every folder (a folder's name can't say whether it holds
4450            // `.bynk` files); the handler expands it from the round's snapshots,
4451            // and one holding no project file produces no edit.
4452            file_operations: Some(WorkspaceFileOperationsServerCapabilities {
4453                will_rename: Some(FileOperationRegistrationOptions {
4454                    filters: vec![
4455                        FileOperationFilter {
4456                            scheme: Some("file".to_string()),
4457                            pattern: FileOperationPattern {
4458                                glob: "**/*.bynk".to_string(),
4459                                matches: Some(FileOperationPatternKind::File),
4460                                options: None,
4461                            },
4462                        },
4463                        FileOperationFilter {
4464                            scheme: Some("file".to_string()),
4465                            pattern: FileOperationPattern {
4466                                glob: "**".to_string(),
4467                                matches: Some(FileOperationPatternKind::Folder),
4468                                options: None,
4469                            },
4470                        },
4471                    ],
4472                }),
4473                ..Default::default()
4474            }),
4475        }),
4476        // #846/#847: no standard `ServerCapabilities` field exists for a custom
4477        // request — `experimental` is the only feature-detection surface a
4478        // client has for `bynk/sequenceModel`, `bynk/documentationModel`, and
4479        // `bynk/architectureModel`.
4480        experimental: Some(serde_json::json!({
4481            "sequenceModel": true,
4482            "agentStateModel": true,
4483            "documentationModel": true,
4484            "architectureModel": true,
4485            "wireContract": true,
4486            "explain": true,
4487        })),
4488        ..Default::default()
4489    }
4490}
4491
4492/// Index symbol kind → LSP symbol kind, aligned with the document-symbol
4493/// outline's choices (capability=INTERFACE, service/agent=CLASS,
4494/// provider=OBJECT). The index does not distinguish type shapes, so every
4495/// type maps to STRUCT.
4496/// Map a `completion::Completion` to an LSP `CompletionItem`.
4497/// Stash the document URI in each item's `data` so `completion_resolve` can look
4498/// the symbol up — a resolve request carries only the item, not a position.
4499fn stamp_resolve_data(items: &mut [CompletionItem], uri: &Url) {
4500    let data = serde_json::json!({ "uri": uri.to_string() });
4501    for item in items.iter_mut() {
4502        item.data = Some(data.clone());
4503    }
4504}
4505
4506/// v0.124 (slice 3): the enclosing function's parameters (and `result` for an
4507/// `ensures`) as completions, when `offset` sits in a `requires`/`ensures`
4508/// predicate. Empty when not in a contract clause or no enclosing `fn` is
4509/// found. A pure parse — the params are read straight off the recovered AST.
4510/// v0.131: the CORS completion cells. Inside a `cors { }` block at a field-name
4511/// position, offer the closed field set; at a service-body item start, offer the
4512/// `cors` section keyword. Both are lexical (offset-based), matching the
4513/// `contract_param_completions` posture.
4514fn cors_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4515    if completion::in_cors_field_position(text, offset) {
4516        return completion::CORS_FIELDS
4517            .iter()
4518            .map(|(name, doc)| CompletionItem {
4519                label: name.to_string(),
4520                kind: Some(CompletionItemKind::FIELD),
4521                detail: Some((*doc).to_string()),
4522                insert_text: Some(format!("{name}: ")),
4523                ..Default::default()
4524            })
4525            .collect();
4526    }
4527    if completion::in_service_body_item_position(text, offset, line) {
4528        return vec![CompletionItem {
4529            label: "cors".to_string(),
4530            kind: Some(CompletionItemKind::KEYWORD),
4531            detail: Some("a cross-origin (CORS) policy for this HTTP service".to_string()),
4532            insert_text: Some("cors {\n\torigins: [$0],\n}".to_string()),
4533            insert_text_format: Some(InsertTextFormat::SNIPPET),
4534            ..Default::default()
4535        }];
4536    }
4537    Vec::new()
4538}
4539
4540/// v0.141 (ADR 0164): the security-headers completion cells. Inside a
4541/// `security { }` block at a field-name position, offer the closed field set
4542/// (`nosniff`/`hsts`); at a service-body item start, offer the `security` section
4543/// keyword. Both are lexical (offset-based), mirroring `cors_completions`.
4544fn security_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4545    if completion::in_security_field_position(text, offset) {
4546        return completion::SECURITY_FIELDS
4547            .iter()
4548            .map(|(name, doc)| CompletionItem {
4549                label: name.to_string(),
4550                kind: Some(CompletionItemKind::FIELD),
4551                detail: Some((*doc).to_string()),
4552                insert_text: Some(format!("{name}: ")),
4553                ..Default::default()
4554            })
4555            .collect();
4556    }
4557    if completion::in_service_body_item_position(text, offset, line) {
4558        return vec![CompletionItem {
4559            label: "security".to_string(),
4560            kind: Some(CompletionItemKind::KEYWORD),
4561            detail: Some("security response headers for this HTTP service".to_string()),
4562            insert_text: Some("security {\n\tnosniff: $0,\n}".to_string()),
4563            insert_text_format: Some(InsertTextFormat::SNIPPET),
4564            ..Default::default()
4565        }];
4566    }
4567    Vec::new()
4568}
4569
4570/// v0.140 (ADR 0163): the `@cache` completion cells. Inside `@cache( … )` at an
4571/// argument-name position, offer the closed argument set (`maxAge`/`scope`/`immutable`); at a
4572/// service-body item start, offer the `@cache` annotation snippet. Both are lexical
4573/// (offset-based), mirroring `cors_completions`.
4574fn cache_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4575    if completion::in_cache_arg_position(text, offset) {
4576        return completion::CACHE_ARGS
4577            .iter()
4578            .map(|(name, doc)| CompletionItem {
4579                label: name.to_string(),
4580                kind: Some(CompletionItemKind::FIELD),
4581                detail: Some((*doc).to_string()),
4582                insert_text: Some(format!("{name}: ")),
4583                ..Default::default()
4584            })
4585            .collect();
4586    }
4587    if completion::in_service_body_item_position(text, offset, line) {
4588        return vec![CompletionItem {
4589            label: "@cache".to_string(),
4590            kind: Some(CompletionItemKind::SNIPPET),
4591            detail: Some(
4592                "cache a GET read — a synthesised ETag/304 revalidation with a freshness window"
4593                    .to_string(),
4594            ),
4595            insert_text: Some("@cache(maxAge: ${1:5.minutes})".to_string()),
4596            insert_text_format: Some(InsertTextFormat::SNIPPET),
4597            ..Default::default()
4598        }];
4599    }
4600    Vec::new()
4601}
4602
4603/// v0.142 (ADR 0165): the request-limits completion cells. Inside a `limits { }`
4604/// block at a field-name position, offer the closed field set (`maxBody`); at a
4605/// service-body item start, offer the `limits` section keyword. Both are lexical
4606/// (offset-based), mirroring `security_completions`.
4607fn limits_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4608    if completion::in_limits_field_position(text, offset) {
4609        return completion::LIMITS_FIELDS
4610            .iter()
4611            .map(|(name, doc)| CompletionItem {
4612                label: name.to_string(),
4613                kind: Some(CompletionItemKind::FIELD),
4614                detail: Some((*doc).to_string()),
4615                insert_text: Some(format!("{name}: ")),
4616                ..Default::default()
4617            })
4618            .collect();
4619    }
4620    if completion::in_service_body_item_position(text, offset, line) {
4621        return vec![CompletionItem {
4622            label: "limits".to_string(),
4623            kind: Some(CompletionItemKind::KEYWORD),
4624            detail: Some("request limits for this HTTP service".to_string()),
4625            insert_text: Some("limits {\n\tmaxBody: $0,\n}".to_string()),
4626            insert_text_format: Some(InsertTextFormat::SNIPPET),
4627            ..Default::default()
4628        }];
4629    }
4630    Vec::new()
4631}
4632
4633/// v0.142 (ADR 0165): the `@limit` completion cells. Inside `@limit( … )` at an
4634/// argument-name position, offer the closed argument set (`maxBody`); at a
4635/// service-body item start, offer the `@limit` annotation snippet. Both are lexical
4636/// (offset-based), mirroring `cache_completions`.
4637fn limit_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4638    if completion::in_limit_arg_position(text, offset) {
4639        return completion::LIMIT_ARGS
4640            .iter()
4641            .map(|(name, doc)| CompletionItem {
4642                label: name.to_string(),
4643                kind: Some(CompletionItemKind::FIELD),
4644                detail: Some((*doc).to_string()),
4645                insert_text: Some(format!("{name}: ")),
4646                ..Default::default()
4647            })
4648            .collect();
4649    }
4650    if completion::in_service_body_item_position(text, offset, line) {
4651        return vec![CompletionItem {
4652            label: "@limit".to_string(),
4653            kind: Some(CompletionItemKind::SNIPPET),
4654            detail: Some(
4655                "cap the request body size — a `413` synthesised before the body is read"
4656                    .to_string(),
4657            ),
4658            insert_text: Some("@limit(maxBody: ${1:1048576})".to_string()),
4659            insert_text_format: Some(InsertTextFormat::SNIPPET),
4660            ..Default::default()
4661        }];
4662    }
4663    Vec::new()
4664}
4665
4666/// The `@raw` completion cells, mirroring `limit_completions`: inside `@raw( … )` at
4667/// an argument-name position, the closed argument set (`contentType`); at a
4668/// service-body item start, the `@raw` annotation snippet.
4669fn raw_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4670    if completion::in_raw_arg_position(text, offset) {
4671        return completion::RAW_ARGS
4672            .iter()
4673            .map(|(name, doc)| CompletionItem {
4674                label: name.to_string(),
4675                kind: Some(CompletionItemKind::FIELD),
4676                detail: Some((*doc).to_string()),
4677                insert_text: Some(format!("{name}: ")),
4678                ..Default::default()
4679            })
4680            .collect();
4681    }
4682    if completion::in_service_body_item_position(text, offset, line) {
4683        return vec![CompletionItem {
4684            label: "@raw".to_string(),
4685            kind: Some(CompletionItemKind::SNIPPET),
4686            detail: Some(
4687                "take `body: Bytes` as the raw request body — a `415` for any other content type"
4688                    .to_string(),
4689            ),
4690            insert_text: Some("@raw(contentType: \"${1:application/octet-stream}\")".to_string()),
4691            insert_text_format: Some(InsertTextFormat::SNIPPET),
4692            ..Default::default()
4693        }];
4694    }
4695    Vec::new()
4696}
4697
4698fn contract_param_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4699    use bynk_syntax::ast::{CommonsItem, SourceUnit};
4700    let Some(is_ensures) = completion::contract_clause_kind(line) else {
4701        return Vec::new();
4702    };
4703    let Ok(tokens) = bynk_syntax::lexer::tokenize(text) else {
4704        return Vec::new();
4705    };
4706    let (Some(unit), _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text) else {
4707        return Vec::new();
4708    };
4709    let items = match &unit {
4710        SourceUnit::Commons(c) => &c.items,
4711        SourceUnit::Context(c) => &c.items,
4712        SourceUnit::Adapter(a) => &a.items,
4713        _ => return Vec::new(),
4714    };
4715    for item in items {
4716        // The cursor sits in a fn's signature/contract region: between the fn's
4717        // start and the `{` that opens its body.
4718        if let CommonsItem::Fn(f) = item
4719            && f.span.start <= offset
4720            && offset <= f.body.span.start
4721        {
4722            // Built directly as VARIABLE items, matching `locals_completions`
4723            // (in-scope names carry no resolve data).
4724            let mut out: Vec<CompletionItem> = f
4725                .params
4726                .iter()
4727                .filter(|p| p.name.name != "_")
4728                .map(|p| CompletionItem {
4729                    label: p.name.name.clone(),
4730                    kind: Some(CompletionItemKind::VARIABLE),
4731                    detail: Some(format!(
4732                        "parameter: {}",
4733                        crate::symbols::type_ref_str(&p.type_ref)
4734                    )),
4735                    ..Default::default()
4736                })
4737                .collect();
4738            if is_ensures {
4739                out.push(CompletionItem {
4740                    label: "result".to_string(),
4741                    kind: Some(CompletionItemKind::VARIABLE),
4742                    detail: Some("the function's return value".to_string()),
4743                    ..Default::default()
4744                });
4745            }
4746            return out;
4747        }
4748    }
4749    Vec::new()
4750}
4751
4752/// v0.124 (slice 3): the byte offset of the scrutinee's last character in
4753/// `<scrutinee> is <partial>` ending at `cursor`, or `None` if the cursor is
4754/// not at an `is`-pattern position. `is` must be a standalone word (so `basis`
4755/// does not trigger it).
4756fn is_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
4757    let before = text.get(..cursor)?;
4758    // Drop the partial variant being typed, then the whitespace before it.
4759    let before = before
4760        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4761        .trim_end();
4762    let before = before.strip_suffix("is")?;
4763    if !before.ends_with(char::is_whitespace) {
4764        return None;
4765    }
4766    let before = before.trim_end();
4767    (!before.is_empty()).then(|| before.len() - 1)
4768}
4769
4770/// v0.128: the byte offset of the scrutinee's last character in a
4771/// `match <scrutinee> { … <partial>` whose cursor sits at an **arm-pattern-start**
4772/// position, or `None` otherwise — the deferred half of slice 3's `is`-pattern
4773/// completion. Conservative: it fires only at the *start* of an arm's pattern
4774/// (after the `{` or a top-level `,`, before any `=>`), never inside an arm body
4775/// or a nested constructor pattern, so it stays honest mid-edit.
4776fn match_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
4777    let before = text.get(..cursor)?;
4778    // The innermost `{` still open at the cursor — the block the cursor is in.
4779    let brace = innermost_open_brace(before)?;
4780    // The current arm: from the last top-level `,` after the brace (or the brace
4781    // itself) to the cursor. A `=>` in it means the cursor is in the arm body.
4782    let arm_start = arm_start_offset(before, brace);
4783    let arm = before.get(arm_start..)?;
4784    if arm.contains("=>") {
4785        return None;
4786    }
4787    // Only at the pattern's *start*: nothing but the partial pattern being typed
4788    // sits between the arm boundary and the cursor.
4789    if !arm
4790        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4791        .trim()
4792        .is_empty()
4793    {
4794        return None;
4795    }
4796    match_head_scrutinee_offset(before, brace)
4797}
4798
4799/// v0.145 (ADR 0169): the scrutinee offset and outer variant name at a
4800/// `match <scrutinee> { … OuterVariant(<partial>` position — the cursor inside a
4801/// variant's payload parens within an arm-pattern (before `=>`), the one place
4802/// `match_scrutinee_offset` bails. Conservative: the payload `(` must be still
4803/// open, the token before it an uppercase-led variant constructor, and only the
4804/// partial nested pattern may sit between the `(` and the cursor.
4805fn nested_pattern_offset(text: &str, cursor: usize) -> Option<(usize, String)> {
4806    let before = text.get(..cursor)?;
4807    let brace = innermost_open_brace(before)?;
4808    let arm_start = arm_start_offset(before, brace);
4809    let arm = before.get(arm_start..)?;
4810    if arm.contains("=>") {
4811        return None; // in the arm body, not its pattern
4812    }
4813    // The innermost `(` still open in the arm — the outer variant's payload.
4814    let paren = innermost_open_paren(arm)?;
4815    // The identifier immediately before that `(` is the outer variant; only an
4816    // uppercase-led constructor opens a nested pattern (a binding never does).
4817    let head = arm.get(..paren)?.trim_end();
4818    let variant: String = head
4819        .chars()
4820        .rev()
4821        .take_while(|c| c.is_alphanumeric() || *c == '_')
4822        .collect::<Vec<_>>()
4823        .into_iter()
4824        .rev()
4825        .collect();
4826    if !variant.chars().next().is_some_and(char::is_uppercase) {
4827        return None;
4828    }
4829    // Between the payload `(` and the cursor, only the partial nested pattern
4830    // being typed (an identifier, optionally a `Type.` qualifier) may sit.
4831    let after = arm.get(paren + 1..)?;
4832    if !after
4833        .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '.')
4834        .trim()
4835        .is_empty()
4836    {
4837        return None;
4838    }
4839    let scrut_off = match_head_scrutinee_offset(before, brace)?;
4840    Some((scrut_off, variant))
4841}
4842
4843/// The byte offset of the scrutinee's last character for the `match <scrutinee>`
4844/// whose body brace is at `brace`, or `None` if `brace` does not head a
4845/// `match`: a standalone `match` keyword, then a scrutinee expression with no
4846/// nested block or arrow between it and the brace. Shared by
4847/// `match_scrutinee_offset` and `nested_pattern_offset`.
4848fn match_head_scrutinee_offset(before: &str, brace: usize) -> Option<usize> {
4849    let head = before.get(..brace)?.trim_end();
4850    let m = head.rfind("match")?;
4851    if head[..m]
4852        .chars()
4853        .next_back()
4854        .is_some_and(|c| c.is_alphanumeric() || c == '_')
4855    {
4856        return None; // part of a longer identifier (`rematch`), not the keyword
4857    }
4858    let after = head.get(m + "match".len()..)?;
4859    if !after.starts_with(char::is_whitespace) {
4860        return None;
4861    }
4862    let scrut = after.trim();
4863    if scrut.is_empty() || scrut.contains(['{', '}']) || scrut.contains("=>") {
4864        return None;
4865    }
4866    // `head` was trimmed to end at the scrutinee's last char (the brace followed).
4867    Some(head.len() - 1)
4868}
4869
4870/// #2010: what `let_else_match_completions` needs from the head of a
4871/// `let <pattern> (= | <-) <value> else match {` — read from text, like the
4872/// other pattern-position scanners here, so it works on a half-typed buffer.
4873#[derive(Debug, PartialEq, Eq)]
4874struct LetElseMatchHead {
4875    /// The offset of the value expression's last character.
4876    value_end: usize,
4877    /// `<-`: the value is an `Effect`, whose payload the arms match.
4878    effectful: bool,
4879    /// The variant the `let` pattern matches outright (`Ok(x)`, `Some(_)`,
4880    /// `P.Late`), which the arms never need; `None` when the pattern only
4881    /// covers part of a variant (`Err(Missing)`) or is not a variant.
4882    covered: Option<String>,
4883}
4884
4885/// #2010: the [`LetElseMatchHead`] when the cursor is at an arm-pattern-start
4886/// inside a `let … else match { … }`.
4887fn let_else_match_head(text: &str, cursor: usize) -> Option<LetElseMatchHead> {
4888    let before = text.get(..cursor)?;
4889    let brace = innermost_open_brace(before)?;
4890    let arm = before.get(arm_start_offset(before, brace)..)?;
4891    if arm.contains("=>")
4892        || !arm
4893            .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4894            .trim()
4895            .is_empty()
4896    {
4897        return None;
4898    }
4899    let is_ident = |c: char| c.is_alphanumeric() || c == '_';
4900    let strip_word = |s: &'_ str, word: &str| -> Option<usize> {
4901        let rest = s.strip_suffix(word)?;
4902        (!rest.ends_with(is_ident)).then_some(rest.trim_end().len())
4903    };
4904    let head = before.get(..brace)?.trim_end();
4905    let head = &head[..strip_word(head, "match")?];
4906    let head = &head[..strip_word(head, "else")?];
4907    let value_end = head.len().checked_sub(1)?;
4908    // The statement's own `let`: the last one at the start of a word.
4909    let let_at = head
4910        .match_indices("let")
4911        .map(|(i, _)| i)
4912        .filter(|&i| {
4913            !head[..i].ends_with(is_ident) && head[i + 3..].starts_with(char::is_whitespace)
4914        })
4915        .last()?;
4916    let rest = &head[let_at + 3..];
4917    // The first `=` (not `==`/`=>`) or `<-` outside brackets ends the pattern.
4918    let mut depth = 0i32;
4919    let mut split = None;
4920    let bytes = rest.as_bytes();
4921    for (i, &b) in bytes.iter().enumerate() {
4922        match b {
4923            b'(' | b'[' | b'{' => depth += 1,
4924            b')' | b']' | b'}' => depth -= 1,
4925            b'<' if depth == 0 && bytes.get(i + 1) == Some(&b'-') => {
4926                split = Some((i, true));
4927                break;
4928            }
4929            b'=' if depth == 0
4930                && !matches!(bytes.get(i + 1), Some(b'=' | b'>'))
4931                && !matches!(
4932                    i.checked_sub(1).map(|j| bytes[j]),
4933                    Some(b'=' | b'!' | b'<' | b'>')
4934                ) =>
4935            {
4936                split = Some((i, false));
4937                break;
4938            }
4939            _ => {}
4940        }
4941    }
4942    let (at, effectful) = split?;
4943    let pattern = rest[..at].trim();
4944    let (name, payload) = match pattern.split_once('(') {
4945        Some((name, payload)) => (name, Some(payload.trim_end_matches(')'))),
4946        None => (pattern, None),
4947    };
4948    let name = name.rsplit('.').next()?.trim();
4949    let whole_variant = name.starts_with(char::is_uppercase)
4950        && payload.is_none_or(|p| {
4951            p.split(',').all(|part| {
4952                let binder = part.rsplit(':').next().unwrap_or(part).trim();
4953                binder == "_"
4954                    || binder.starts_with(char::is_lowercase) && binder.chars().all(is_ident)
4955            })
4956        });
4957    Some(LetElseMatchHead {
4958        value_end,
4959        effectful,
4960        covered: whole_variant.then(|| name.to_string()),
4961    })
4962}
4963
4964/// The offset (relative to `arm`) of the innermost `(` left unclosed in `arm` — a
4965/// `(`-only balance scan, the payload paren the cursor sits in — or `None` if
4966/// every `(` is closed.
4967fn innermost_open_paren(arm: &str) -> Option<usize> {
4968    let mut stack: Vec<usize> = Vec::new();
4969    for (i, c) in arm.char_indices() {
4970        match c {
4971            '(' => stack.push(i),
4972            ')' => {
4973                stack.pop();
4974            }
4975            _ => {}
4976        }
4977    }
4978    stack.pop()
4979}
4980
4981/// The byte offset of the innermost `{` left unclosed in `before` (a `{`-only
4982/// balance scan — the block the cursor sits in), or `None` if every `{` is closed.
4983fn innermost_open_brace(before: &str) -> Option<usize> {
4984    let mut stack: Vec<usize> = Vec::new();
4985    for (i, c) in before.char_indices() {
4986        match c {
4987            '{' => stack.push(i),
4988            '}' => {
4989                stack.pop();
4990            }
4991            _ => {}
4992        }
4993    }
4994    stack.pop()
4995}
4996
4997/// The offset just past the last top-level `,` inside the block opened at `brace`
4998/// (depth 0 relative to that brace), or just past the brace itself if the block
4999/// holds no top-level comma yet — the start of the arm the cursor is editing.
5000fn arm_start_offset(before: &str, brace: usize) -> usize {
5001    let mut depth = 0i32;
5002    let mut start = brace + 1; // just after the `{`
5003    for (rel, c) in before[brace + 1..].char_indices() {
5004        match c {
5005            '{' | '(' | '[' => depth += 1,
5006            '}' | ')' | ']' => depth -= 1,
5007            ',' if depth == 0 => start = brace + 1 + rel + c.len_utf8(),
5008            _ => {}
5009        }
5010    }
5011    start
5012}
5013
5014fn to_completion_item(c: completion::Completion) -> CompletionItem {
5015    CompletionItem {
5016        kind: Some(match c.kind {
5017            completion::CompletionKind::Unit => CompletionItemKind::MODULE,
5018            completion::CompletionKind::Capability => CompletionItemKind::INTERFACE,
5019            completion::CompletionKind::Type => CompletionItemKind::STRUCT,
5020            completion::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
5021            completion::CompletionKind::Snippet => CompletionItemKind::SNIPPET,
5022            completion::CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
5023            completion::CompletionKind::Member => CompletionItemKind::METHOD,
5024            completion::CompletionKind::Field => CompletionItemKind::FIELD,
5025            completion::CompletionKind::Constructor => CompletionItemKind::CONSTRUCTOR,
5026            completion::CompletionKind::Function => CompletionItemKind::FUNCTION,
5027            completion::CompletionKind::Constant => CompletionItemKind::CONSTANT,
5028        }),
5029        // Snippet items carry `${n:…}` tab stops; everything else inserts its
5030        // label verbatim (the default).
5031        insert_text_format: c.insert_text.as_ref().map(|_| InsertTextFormat::SNIPPET),
5032        insert_text: c.insert_text,
5033        label: c.label,
5034        detail: c.detail,
5035        // #1888: a field's or variant's doc, rendered as Markdown.
5036        documentation: c.documentation.map(|value| {
5037            Documentation::MarkupContent(MarkupContent {
5038                kind: MarkupKind::Markdown,
5039                value,
5040            })
5041        }),
5042        ..Default::default()
5043    }
5044}
5045
5046/// The byte offset of an LSP `(line, character)` position in `text`,
5047/// clamped to the end of the document when the position lies past it.
5048/// LSP positions count UTF-16 code units, so this goes through the shared
5049/// converter — a byte-faithful reading misplaces the cursor on any line
5050/// with non-ASCII text before it.
5051fn cursor_offset(text: &str, pos: Position) -> usize {
5052    crate::position::position_to_offset(text, pos).unwrap_or(text.len())
5053}
5054
5055/// v0.34 (ADR 0067): a serializable mirror of [`bynk_check::index::SymbolKey`] for
5056/// round-tripping through `CallHierarchyItem.data` — the index kind isn't
5057/// `Serialize`, so the kind travels as its `display()` string.
5058#[derive(serde::Serialize, serde::Deserialize)]
5059struct SerKey {
5060    unit: String,
5061    kind: String,
5062    name: String,
5063}
5064
5065impl From<&bynk_check::index::SymbolKey> for SerKey {
5066    fn from(k: &bynk_check::index::SymbolKey) -> Self {
5067        SerKey {
5068            unit: k.unit.clone(),
5069            kind: k.kind.display().to_string(),
5070            name: k.name.clone(),
5071        }
5072    }
5073}
5074
5075impl SerKey {
5076    /// Recover a `SymbolKey` from a `CallHierarchyItem`'s `data`. `None` for a
5077    /// missing/garbled payload or an unknown kind — the follow-up then returns
5078    /// no calls rather than guessing.
5079    fn read(data: &Option<serde_json::Value>) -> Option<bynk_check::index::SymbolKey> {
5080        let sk: SerKey = serde_json::from_value(data.as_ref()?.clone()).ok()?;
5081        let kind = match sk.kind.as_str() {
5082            "type" => bynk_check::index::SymbolKind::Type,
5083            "fn" => bynk_check::index::SymbolKind::Fn,
5084            "capability" => bynk_check::index::SymbolKind::Capability,
5085            "service" => bynk_check::index::SymbolKind::Service,
5086            "agent" => bynk_check::index::SymbolKind::Agent,
5087            "provider" => bynk_check::index::SymbolKind::Provider,
5088            _ => return None,
5089        };
5090        Some(bynk_check::index::SymbolKey {
5091            unit: sk.unit,
5092            kind,
5093            name: sk.name,
5094        })
5095    }
5096}
5097
5098fn lsp_symbol_kind(kind: bynk_check::index::SymbolKind) -> SymbolKind {
5099    match kind {
5100        bynk_check::index::SymbolKind::Type => SymbolKind::STRUCT,
5101        bynk_check::index::SymbolKind::Fn => SymbolKind::FUNCTION,
5102        bynk_check::index::SymbolKind::Capability => SymbolKind::INTERFACE,
5103        bynk_check::index::SymbolKind::Service | bynk_check::index::SymbolKind::Agent => {
5104            SymbolKind::CLASS
5105        }
5106        bynk_check::index::SymbolKind::Provider => SymbolKind::OBJECT,
5107        bynk_check::index::SymbolKind::Method => SymbolKind::METHOD,
5108        bynk_check::index::SymbolKind::CapabilityOp => SymbolKind::METHOD,
5109        bynk_check::index::SymbolKind::Field => SymbolKind::FIELD,
5110        bynk_check::index::SymbolKind::Actor => SymbolKind::INTERFACE,
5111        bynk_check::index::SymbolKind::Handler => SymbolKind::METHOD,
5112        bynk_check::index::SymbolKind::Messages => SymbolKind::STRUCT,
5113        // #1883: an asset is a named value that never changes — as the
5114        // document outline already shows it (`document_symbols::asset_symbol`).
5115        bynk_check::index::SymbolKind::Asset => SymbolKind::CONSTANT,
5116        // #2387: likewise a named string constant.
5117        bynk_check::index::SymbolKind::Embed => SymbolKind::CONSTANT,
5118    }
5119}
5120
5121/// Whether a watched-file URI names a `bynk.toml` manifest — the trigger for a
5122/// live config reload. Matches on the file-name component (not a path suffix),
5123/// so a file like `notbynk.toml` doesn't spuriously fire.
5124fn is_bynk_toml(uri: &Url) -> bool {
5125    let Ok(path) = uri.to_file_path() else {
5126        return false;
5127    };
5128    path.file_name().and_then(|n| n.to_str()) == Some("bynk.toml")
5129}
5130
5131/// The `codeDescription` link for a diagnostic `code` (#853): a clickable link
5132/// to the code's Book explanation when the compiler curates one, else `None`
5133/// (the designed graceful-fallback state — an uncurated code renders no link,
5134/// which is not an error). Split out from [`make_diagnostic`] so the
5135/// mapped→`Some` / uncurated→`None` contract is directly testable.
5136fn code_description(code: &str) -> Option<CodeDescription> {
5137    let href = Url::parse(&bynk_syntax::diagnostics::explain(code)?.href()).ok()?;
5138    Some(CodeDescription { href })
5139}
5140
5141#[cfg(test)]
5142mod code_description_tests {
5143    use super::code_description;
5144
5145    #[test]
5146    fn mapped_code_gets_a_valid_book_link() {
5147        let cd = code_description("bynk.resolve.unknown_type")
5148            .expect("a curated code produces a codeDescription");
5149        assert_eq!(cd.href.scheme(), "https");
5150        assert_eq!(cd.href.host_str(), Some("bynk-lang.org"));
5151        assert!(cd.href.path().starts_with("/book/"));
5152    }
5153
5154    #[test]
5155    fn uncurated_code_gets_no_link() {
5156        // A real code with no curated explanation, and a nonsense code, both
5157        // fall back to no link (graceful — not an error).
5158        assert!(code_description("bynk.resolve.duplicate_type").is_none());
5159        assert!(code_description("bynk.not.a_real_code").is_none());
5160    }
5161
5162    #[test]
5163    fn every_curated_explanation_yields_a_parseable_url() {
5164        // Guards that no curated href ever silently drops its link because
5165        // `Url::parse` rejected it.
5166        for e in bynk_syntax::diagnostics::EXPLANATIONS {
5167            assert!(
5168                code_description(e.code).is_some(),
5169                "curated explanation `{}` produced no codeDescription — its href \
5170                 `{}` did not parse as a URL",
5171                e.code,
5172                e.href()
5173            );
5174        }
5175    }
5176}
5177
5178/// Every analysed project file's URI and the positions of the snapshot it
5179/// was analysed at, by identity path — where [`make_diagnostic`] places a
5180/// secondary label that lies in a sibling file.
5181type ProjectFiles<'a> = std::collections::HashMap<PathBuf, (Url, crate::position::PositionMap<'a>)>;
5182
5183fn make_diagnostic(
5184    d: &bynk_ide::Diagnostic,
5185    positions: &crate::position::PositionMap,
5186    uri: &Url,
5187    files: &ProjectFiles<'_>,
5188) -> Diagnostic {
5189    let range = positions.range(d.error.span);
5190    let severity = match d.severity {
5191        bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
5192        bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
5193    };
5194    let mut related_information: Vec<DiagnosticRelatedInformation> = Vec::new();
5195    let mut label_notes: Vec<&str> = Vec::new();
5196    for (i, (span, msg)) in d.error.labels.iter().enumerate() {
5197        // A label's span is an offset into the text of the file it lies in:
5198        // this document's, or a sibling's in the same project, which has its
5199        // own URI and positions. A label in a file with no snapshot (a
5200        // toolchain-injected unit) has nowhere to point, so it is a note.
5201        let (label_uri, label_positions) = match d.label_file(i) {
5202            bynk_ide::LabelFile::Primary => (uri, positions),
5203            bynk_ide::LabelFile::Other(path) => match files.get(path) {
5204                Some((u, p)) => (u, p),
5205                None => {
5206                    label_notes.push(msg);
5207                    continue;
5208                }
5209            },
5210            // A first-party unit (`bynk.bynk`) has no document to point at.
5211            bynk_ide::LabelFile::FirstParty { .. } | bynk_ide::LabelFile::Unavailable => {
5212                label_notes.push(msg);
5213                continue;
5214            }
5215        };
5216        related_information.push(DiagnosticRelatedInformation {
5217            location: Location {
5218                uri: label_uri.clone(),
5219                range: label_positions.range(*span),
5220            },
5221            message: msg.clone(),
5222        });
5223    }
5224    let mut message = d.error.message.clone();
5225    for note in d.error.notes.iter().map(String::as_str).chain(label_notes) {
5226        message.push_str("\n\n");
5227        message.push_str("note: ");
5228        message.push_str(note);
5229    }
5230    Diagnostic {
5231        range,
5232        severity: Some(severity),
5233        code: Some(NumberOrString::String(d.error.category.to_string())),
5234        // #853: a curated code carries a `codeDescription` link to its Book
5235        // explanation (rendered as a link on the code in Problems/hover); an
5236        // uncurated code has no entry and stays `None` — the designed
5237        // graceful-fallback state, not an error.
5238        code_description: code_description(d.error.category),
5239        source: Some(SERVER_NAME.to_string()),
5240        message,
5241        related_information: if related_information.is_empty() {
5242            None
5243        } else {
5244            Some(related_information)
5245        },
5246        tags: None,
5247        data: None,
5248    }
5249}
5250
5251/// Slice C: the server's entry point, moved out of `main.rs` so the crate
5252/// has a `[lib]` target. `main.rs` is now a thin shim over this.
5253///
5254/// #1667: returns the process's exit code: success after an orderly
5255/// `shutdown`, failure (with a line on stderr) when the session ended any
5256/// other way.
5257pub async fn run() -> std::process::ExitCode {
5258    // Answer `--version`/`-V` and exit before entering the stdio LSP loop, so
5259    // tooling (e.g. the VS Code status bar) can query the version without the
5260    // server blocking on stdin.
5261    if std::env::args()
5262        .skip(1)
5263        .any(|a| a == "--version" || a == "-V")
5264    {
5265        println!("{SERVER_NAME} {SERVER_VERSION}");
5266        return std::process::ExitCode::SUCCESS;
5267    }
5268    // Logging to ~/.bynk-lsp.log. Default level: warn; tunable via
5269    // RUST_LOG or the LSP client's trace setting.
5270    if let Some(home) = std::env::var_os("HOME") {
5271        let path: PathBuf = PathBuf::from(home).join(".bynk-lsp.log");
5272        if let Ok(file) = std::fs::OpenOptions::new()
5273            .create(true)
5274            .append(true)
5275            .open(&path)
5276        {
5277            use tracing_subscriber::prelude::*;
5278            let env_filter = tracing_subscriber::EnvFilter::try_from_env("BYNK_LSP_LOG")
5279                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
5280            let file_layer = tracing_subscriber::fmt::layer()
5281                .with_writer(std::sync::Mutex::new(file))
5282                .with_ansi(false);
5283            tracing_subscriber::registry()
5284                .with(env_filter)
5285                .with(file_layer)
5286                .try_init()
5287                .ok();
5288        }
5289    }
5290    tracing::info!("bynkc-lsp v{} starting", SERVER_VERSION);
5291    let stdout = tokio::io::stdout();
5292    // #1667: the server reads a re-framed copy of stdin, so one malformed
5293    // message is skipped rather than ending the session (see `transport`).
5294    let (server_input, pump_output) = tokio::io::duplex(1 << 16);
5295    tokio::spawn(transport::pump(tokio::io::stdin(), pump_output));
5296    let shutdown_requested = Arc::new(std::sync::atomic::AtomicBool::new(false));
5297    let flag = Arc::clone(&shutdown_requested);
5298    // #846: this server's first custom (non-standard) request — everything
5299    // else is a `LanguageServer` trait method, registered automatically by
5300    // `LspService::new`. `bynk/sequenceModel` has no trait slot, so it needs
5301    // the builder's `custom_method` instead.
5302    let (service, socket) =
5303        LspService::build(move |client| Backend::with_shutdown_flag(client, Arc::clone(&flag)))
5304            .custom_method("bynk/sequenceModel", Backend::sequence_model)
5305            .custom_method("bynk/agentStateModel", Backend::agent_state_model)
5306            .custom_method("bynk/documentationModel", Backend::documentation_model)
5307            .custom_method("bynk/architectureModel", Backend::architecture_model)
5308            .custom_method("bynk/wireContract", Backend::wire_contract)
5309            .custom_method("bynk/explain", Backend::explain)
5310            .finish();
5311    Server::new(server_input, stdout, socket)
5312        .serve(service)
5313        .await;
5314    if shutdown_requested.load(std::sync::atomic::Ordering::SeqCst) {
5315        std::process::ExitCode::SUCCESS
5316    } else {
5317        eprintln!(
5318            "bynkc-lsp: the session ended without a `shutdown` request \
5319             (the client closed the connection, or sent `exit` early); see ~/.bynk-lsp.log"
5320        );
5321        std::process::ExitCode::FAILURE
5322    }
5323}
5324
5325#[cfg(test)]
5326mod tests {
5327    use super::*;
5328
5329    // -- Slice A: the project model, driven through `Backend` ---------------
5330    //
5331    // These are the crate's first *behaviour-over-time* tests: they drive the
5332    // real `Backend` — the layer the track doc (§4.1) notes has always been
5333    // testable in-crate via `LspService::new(Backend::new)`, and never was.
5334    // Everything else in this module asserts *static* shape.
5335    //
5336    // Hermetic on purpose. `bynk-lsp` is published and `Cargo.toml`'s `exclude`
5337    // list can only drop `tests/*.rs`, never this file — so an in-crate test
5338    // reading a sibling directory would fail `cargo test` on the released
5339    // tarball. (`find_source_root_walks_up_to_the_nearest_src` below already
5340    // does exactly that; not this slice's to fix.) The sibling-reading fixtures
5341    // live in `tests/project_model.rs`, which *is* excluded.
5342
5343    /// A throwaway project, removed on drop — including on panic.
5344    struct Scratch(PathBuf);
5345    impl Drop for Scratch {
5346        fn drop(&mut self) {
5347            let _ = std::fs::remove_dir_all(&self.0);
5348        }
5349    }
5350
5351    fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
5352        let dir = std::env::temp_dir().join(format!(
5353            "bynk_lsp_sliceA_{tag}_{}_{:?}",
5354            std::process::id(),
5355            std::thread::current().id()
5356        ));
5357        let _ = std::fs::remove_dir_all(&dir);
5358        for (rel, body) in files {
5359            let p = dir.join(rel);
5360            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
5361            std::fs::write(&p, body).unwrap();
5362        }
5363        Scratch(dir)
5364    }
5365
5366    /// Build a `Backend` over a real `LspService`, rooted at `root`.
5367    ///
5368    /// `LspService::new(Backend::new)` is what `main` itself calls — the
5369    /// `Client` it hands back is the only thing `Backend` needed, and it has
5370    /// been available for this since the server was written.
5371    async fn backend_at(root: &std::path::Path) -> Backend {
5372        let (service, _socket) = tower_lsp::LspService::new(Backend::new);
5373        let backend = service.inner().clone();
5374        // Slice D: seed one project entry, keyed by the **canonical** root so a
5375        // request's `resolve_root`-based routing lands on the same key.
5376        let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
5377        {
5378            let mut state = backend.state.write().await;
5379            state.folders.push(canonical.clone());
5380            state.projects.insert(
5381                canonical.clone(),
5382                ProjectState {
5383                    config: project::load_config(&canonical).unwrap_or_default(),
5384                    ..Default::default()
5385                },
5386            );
5387        }
5388        backend
5389    }
5390
5391    /// #1667: a panic in an analysis round reaches the client once per
5392    /// failure streak, as a `window/showMessage`, and the streak ends at the
5393    /// next clean round. Before, the `JoinError` was dropped without a trace.
5394    #[tokio::test]
5395    async fn a_panicking_round_tells_the_client_once_and_clears_on_success() {
5396        use futures::StreamExt;
5397        use tower::{Service, ServiceExt};
5398
5399        let s = scratch_project(
5400            "panic",
5401            &[
5402                ("bynk.toml", "[project]\nname = \"panic\"\n"),
5403                ("src/thing.bynk", "context thing\n"),
5404            ],
5405        );
5406        let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5407        // The client sends nothing until the server is initialised.
5408        let initialize = tower_lsp::jsonrpc::Request::build("initialize")
5409            .params(serde_json::json!({ "capabilities": {} }))
5410            .id(1)
5411            .finish();
5412        service
5413            .ready()
5414            .await
5415            .unwrap()
5416            .call(initialize)
5417            .await
5418            .unwrap();
5419        let initialized = tower_lsp::jsonrpc::Request::build("initialized")
5420            .params(serde_json::json!({}))
5421            .finish();
5422        service
5423            .ready()
5424            .await
5425            .unwrap()
5426            .call(initialized)
5427            .await
5428            .unwrap();
5429        // Drain the client side as the server writes to it: its channel is
5430        // small, so an unread socket would block the server's next
5431        // notification (a `publishDiagnostics`, or the `showMessage` itself).
5432        let shown = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
5433        let drain = {
5434            let shown = Arc::clone(&shown);
5435            tokio::spawn(socket.for_each(move |message| {
5436                if message.method() == "window/showMessage" {
5437                    shown
5438                        .lock()
5439                        .unwrap()
5440                        .push(format!("{:?}", message.params()));
5441                }
5442                async {}
5443            }))
5444        };
5445        let backend = service.inner().clone();
5446        let canonical = s.0.canonicalize().unwrap();
5447        backend
5448            .state
5449            .write()
5450            .await
5451            .projects
5452            .insert(canonical.clone(), ProjectState::default());
5453        let failed = |b: &Backend| {
5454            let b = b.clone();
5455            let root = canonical.clone();
5456            async move { b.state.read().await.projects[&root].analysis_failed }
5457        };
5458
5459        let inject = |b: &Backend| {
5460            let b = b.clone();
5461            let root = canonical.clone();
5462            async move {
5463                b.state
5464                    .write()
5465                    .await
5466                    .projects
5467                    .get_mut(&root)
5468                    .unwrap()
5469                    .panic_next_round = true;
5470            }
5471        };
5472        inject(&backend).await;
5473        backend.run_project_diagnostics(canonical.clone()).await;
5474        assert!(failed(&backend).await, "the failed round is recorded");
5475        // A second failure in the same streak says nothing new.
5476        inject(&backend).await;
5477        backend.run_project_diagnostics(canonical.clone()).await;
5478
5479        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5480        {
5481            let shown = shown.lock().unwrap();
5482            assert_eq!(shown.len(), 1, "told once per streak: {shown:?}");
5483            assert!(shown[0].contains("internal error"), "{shown:?}");
5484        }
5485
5486        backend.run_project_diagnostics(canonical.clone()).await;
5487        assert!(!failed(&backend).await, "a clean round ends the streak");
5488        // A failure after the streak ended is a new streak, and is told.
5489        inject(&backend).await;
5490        backend.run_project_diagnostics(canonical.clone()).await;
5491        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5492        assert_eq!(shown.lock().unwrap().len(), 2, "a new streak is told again");
5493        drain.abort();
5494    }
5495
5496    /// A diagnostic's secondary label in a sibling file of a multi-file unit
5497    /// points at that file's URI, at its position there. Before, every label
5498    /// took the diagnostic's own URI, so "previously declared here" for a
5499    /// service declared in `a_web.bynk` pointed into `z_more.bynk`, at the same
5500    /// byte offsets.
5501    #[tokio::test]
5502    async fn a_label_in_a_sibling_file_points_at_that_file() {
5503        use futures::StreamExt;
5504        use tower::{Service, ServiceExt};
5505
5506        let service_src =
5507            "service api {\n  on call() -> Effect[Int] {\n    Effect.pure(1)\n  }\n}\n";
5508        let s = scratch_project(
5509            "sibling_label",
5510            &[
5511                ("bynk.toml", "[project]\nname = \"demo\"\n"),
5512                (
5513                    "src/app/a_web.bynk",
5514                    &format!("context app\n\n{service_src}"),
5515                ),
5516                (
5517                    "src/app/z_more.bynk",
5518                    &format!("context app\n\n\n\n\n{service_src}"),
5519                ),
5520            ],
5521        );
5522        let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5523        for request in [
5524            tower_lsp::jsonrpc::Request::build("initialize")
5525                .params(serde_json::json!({ "capabilities": {} }))
5526                .id(1)
5527                .finish(),
5528            tower_lsp::jsonrpc::Request::build("initialized")
5529                .params(serde_json::json!({}))
5530                .finish(),
5531        ] {
5532            service.ready().await.unwrap().call(request).await.unwrap();
5533        }
5534        let published = Arc::new(std::sync::Mutex::new(Vec::<PublishDiagnosticsParams>::new()));
5535        let drain = {
5536            let published = Arc::clone(&published);
5537            tokio::spawn(socket.for_each(move |message| {
5538                if message.method() == "textDocument/publishDiagnostics"
5539                    && let Some(params) = message.params()
5540                {
5541                    published
5542                        .lock()
5543                        .unwrap()
5544                        .push(serde_json::from_value(params.clone()).unwrap());
5545                }
5546                async {}
5547            }))
5548        };
5549        let backend = service.inner().clone();
5550        let canonical = s.0.canonicalize().unwrap();
5551        backend
5552            .state
5553            .write()
5554            .await
5555            .projects
5556            .insert(canonical.clone(), ProjectState::default());
5557        backend.run_project_diagnostics(canonical.clone()).await;
5558        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5559        drain.abort();
5560
5561        let a_web = Url::from_file_path(canonical.join("src/app/a_web.bynk")).unwrap();
5562        let z_more = Url::from_file_path(canonical.join("src/app/z_more.bynk")).unwrap();
5563        let published = published.lock().unwrap();
5564        let duplicate = published
5565            .iter()
5566            .filter(|p| p.uri == z_more)
5567            .flat_map(|p| &p.diagnostics)
5568            .find(|d| {
5569                d.code
5570                    == Some(NumberOrString::String(
5571                        "bynk.resolve.duplicate_service".into(),
5572                    ))
5573            })
5574            .unwrap_or_else(|| panic!("z_more.bynk must report the duplicate: {published:?}"));
5575        let related = duplicate
5576            .related_information
5577            .as_deref()
5578            .expect("the duplicate carries its \"previously declared here\" label");
5579        assert_eq!(related.len(), 1, "{related:?}");
5580        assert_eq!(related[0].message, "previously declared here");
5581        assert_eq!(
5582            related[0].location.uri, a_web,
5583            "the label lies in a_web.bynk, not the diagnostic's own file"
5584        );
5585        // `service api {` is line 2 (0-based) of a_web.bynk; `api` at column 8.
5586        assert_eq!(
5587            related[0].location.range,
5588            Range::new(Position::new(2, 8), Position::new(2, 11)),
5589        );
5590    }
5591
5592    /// #2081: a round reconciles events against the committed
5593    /// `bynk.schema.lock`, read-only, so a non-additive change reaches the
5594    /// editor as `bynk.event.non_additive_schema_change`, as it reaches
5595    /// `bynk check`. The lock is never written, and a changed lock is re-read
5596    /// on the next round (its stamp moved), clearing the diagnostic.
5597    #[tokio::test]
5598    async fn a_round_reports_a_non_additive_event_change_against_the_lock() {
5599        use futures::StreamExt;
5600        use tower::{Service, ServiceExt};
5601
5602        const CODE: &str = "bynk.event.non_additive_schema_change";
5603        const LOCK_V1: &str = "version = 1\n\n\
5604            [events.\"commerce.order.PaymentConfirmed\"]\nschema = 1\n\n\
5605            [[events.\"commerce.order.PaymentConfirmed\".fields]]\n\
5606            name = \"orderId\"\ntype = \"String\"\ndefault = false\n";
5607        // The same event recorded with the source's current shape.
5608        const LOCK_V2: &str = "version = 1\n\n\
5609            [events.\"commerce.order.PaymentConfirmed\"]\nschema = 1\n\n\
5610            [[events.\"commerce.order.PaymentConfirmed\".fields]]\n\
5611            name = \"orderId\"\ntype = \"String\"\ndefault = false\n\n\
5612            [[events.\"commerce.order.PaymentConfirmed\".fields]]\n\
5613            name = \"region\"\ntype = \"String\"\ndefault = false\n";
5614        // `region` added with no default: not additive against `LOCK_V1`.
5615        const SOURCE: &str = "context commerce.order\n\n\
5616            consumes bynk { Events }\n\n\
5617            event PaymentConfirmed = {\n  orderId: String,\n  region: String,\n}\n\n\
5618            service markPaid {\n  on call(orderId: String) -> Effect[()] given Events {\n    \
5619            Events.emit[PaymentConfirmed](PaymentConfirmed { orderId: orderId, region: \"US\" })\n  \
5620            }\n}\n";
5621
5622        let s = scratch_project(
5623            "schema_lock",
5624            &[
5625                ("bynk.toml", "[project]\nname = \"shop\"\n"),
5626                ("src/commerce/order.bynk", SOURCE),
5627                ("bynk.schema.lock", LOCK_V1),
5628            ],
5629        );
5630        let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5631        for request in [
5632            tower_lsp::jsonrpc::Request::build("initialize")
5633                .params(serde_json::json!({ "capabilities": {} }))
5634                .id(1)
5635                .finish(),
5636            tower_lsp::jsonrpc::Request::build("initialized")
5637                .params(serde_json::json!({}))
5638                .finish(),
5639        ] {
5640            service.ready().await.unwrap().call(request).await.unwrap();
5641        }
5642        let published = Arc::new(std::sync::Mutex::new(Vec::<PublishDiagnosticsParams>::new()));
5643        let drain = {
5644            let published = Arc::clone(&published);
5645            tokio::spawn(socket.for_each(move |message| {
5646                if message.method() == "textDocument/publishDiagnostics"
5647                    && let Some(params) = message.params()
5648                {
5649                    published
5650                        .lock()
5651                        .unwrap()
5652                        .push(serde_json::from_value(params.clone()).unwrap());
5653                }
5654                async {}
5655            }))
5656        };
5657        let backend = service.inner().clone();
5658        let canonical = s.0.canonicalize().unwrap();
5659        backend
5660            .state
5661            .write()
5662            .await
5663            .projects
5664            .insert(canonical.clone(), ProjectState::default());
5665        // The project-level diagnostic anchors on `bynk.toml`.
5666        let manifest = Url::from_file_path(canonical.join("bynk.toml")).unwrap();
5667        let reported = |published: &[PublishDiagnosticsParams]| {
5668            published
5669                .iter()
5670                .rfind(|p| p.uri == manifest)
5671                .is_some_and(|p| {
5672                    p.diagnostics
5673                        .iter()
5674                        .any(|d| d.code == Some(NumberOrString::String(CODE.into())))
5675                })
5676        };
5677
5678        backend.run_project_diagnostics(canonical.clone()).await;
5679        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5680        assert!(
5681            reported(&published.lock().unwrap()),
5682            "the round must report {CODE} against the committed lock: {:?}",
5683            published.lock().unwrap()
5684        );
5685        assert_eq!(
5686            std::fs::read_to_string(canonical.join("bynk.schema.lock")).unwrap(),
5687            LOCK_V1,
5688            "the editor must never write the lock"
5689        );
5690
5691        // Record the new shape, as `bynkc compile` would after a fix: the next
5692        // round re-reads the lock and the diagnostic clears.
5693        std::fs::write(canonical.join("bynk.schema.lock"), LOCK_V2).unwrap();
5694        backend.run_project_diagnostics(canonical.clone()).await;
5695        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5696        assert!(
5697            !reported(&published.lock().unwrap()),
5698            "a lock matching the source must clear {CODE}: {:?}",
5699            published.lock().unwrap()
5700        );
5701
5702        // Restore the stale lock: it is read again and the diagnostic returns.
5703        std::fs::write(canonical.join("bynk.schema.lock"), LOCK_V1).unwrap();
5704        backend.run_project_diagnostics(canonical.clone()).await;
5705        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5706        assert!(
5707            reported(&published.lock().unwrap()),
5708            "the old lock is read again"
5709        );
5710        // No lock at all: a fresh project, nothing to report.
5711        std::fs::remove_file(canonical.join("bynk.schema.lock")).unwrap();
5712        backend.run_project_diagnostics(canonical.clone()).await;
5713        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5714        drain.abort();
5715        assert!(
5716            !reported(&published.lock().unwrap()),
5717            "with no lock there is nothing to report: {:?}",
5718            published.lock().unwrap()
5719        );
5720        assert!(
5721            !canonical.join("bynk.schema.lock").exists(),
5722            "the editor must not create a lock"
5723        );
5724    }
5725
5726    /// State-migrations S0 (#2204): a round reconciles agent state shapes
5727    /// against the committed lock too, so renaming a `store` field reaches the
5728    /// editor as `bynk.agent.non_additive_state_change` on the agent's own
5729    /// file, as it reaches `bynk check`. The lock is never written.
5730    #[tokio::test]
5731    async fn a_round_reports_a_non_additive_agent_state_change_against_the_lock() {
5732        use futures::StreamExt;
5733        use tower::{Service, ServiceExt};
5734
5735        const CODE: &str = "bynk.agent.non_additive_state_change";
5736        const LOCK: &str = "version = 2\n\n[events]\n\n\
5737            [agents.\"shop.Cart\"]\nversion = 1\nfloor = 1\n\n\
5738            [[agents.\"shop.Cart\".shapes]]\nversion = 1\nadditive = false\n\n\
5739            [[agents.\"shop.Cart\".shapes.fields]]\n\
5740            name = \"owner\"\nkind = \"Cell\"\ntype = \"String\"\nrefinement = \"String\"\n";
5741        const SOURCE: &str = "context shop\n\nagent Cart {\n  key id: String\n\n  \
5742            store ownerName: Cell[String]\n\n  on call ping() -> Effect[Int] {\n    1\n  }\n}\n";
5743
5744        let s = scratch_project(
5745            "agent_schema_lock",
5746            &[
5747                ("bynk.toml", "[project]\nname = \"shop\"\n"),
5748                ("src/shop/cart.bynk", SOURCE),
5749                ("bynk.schema.lock", LOCK),
5750            ],
5751        );
5752        let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5753        for request in [
5754            tower_lsp::jsonrpc::Request::build("initialize")
5755                .params(serde_json::json!({ "capabilities": {} }))
5756                .id(1)
5757                .finish(),
5758            tower_lsp::jsonrpc::Request::build("initialized")
5759                .params(serde_json::json!({}))
5760                .finish(),
5761        ] {
5762            service.ready().await.unwrap().call(request).await.unwrap();
5763        }
5764        let published = Arc::new(std::sync::Mutex::new(Vec::<PublishDiagnosticsParams>::new()));
5765        let drain = {
5766            let published = Arc::clone(&published);
5767            tokio::spawn(socket.for_each(move |message| {
5768                if message.method() == "textDocument/publishDiagnostics"
5769                    && let Some(params) = message.params()
5770                {
5771                    published
5772                        .lock()
5773                        .unwrap()
5774                        .push(serde_json::from_value(params.clone()).unwrap());
5775                }
5776                async {}
5777            }))
5778        };
5779        let backend = service.inner().clone();
5780        let canonical = s.0.canonicalize().unwrap();
5781        backend
5782            .state
5783            .write()
5784            .await
5785            .projects
5786            .insert(canonical.clone(), ProjectState::default());
5787        let source = Url::from_file_path(canonical.join("src/shop/cart.bynk")).unwrap();
5788
5789        backend.run_project_diagnostics(canonical.clone()).await;
5790        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5791        drain.abort();
5792        let published = published.lock().unwrap();
5793        assert!(
5794            published
5795                .iter()
5796                .rfind(|p| p.uri == source)
5797                .is_some_and(|p| p
5798                    .diagnostics
5799                    .iter()
5800                    .any(|d| d.code == Some(NumberOrString::String(CODE.into())))),
5801            "the round must report {CODE} on the agent's file: {published:?}"
5802        );
5803        assert_eq!(
5804            std::fs::read_to_string(canonical.join("bynk.schema.lock")).unwrap(),
5805            LOCK,
5806            "the editor must never write the lock"
5807        );
5808    }
5809
5810    /// A label with nowhere to point — in a toolchain-injected unit, or in a
5811    /// file the round has no snapshot of — is a `note:` on the message, never
5812    /// a `relatedInformation` entry at this document's own positions.
5813    #[test]
5814    fn a_label_with_no_file_to_point_at_becomes_a_note() {
5815        let text = "context app\n";
5816        let positions = crate::position::PositionMap::new(text);
5817        let uri = Url::parse("file:///z.bynk").unwrap();
5818        let d = bynk_ide::Diagnostic {
5819            severity: bynk_syntax::Severity::Error,
5820            error: bynk_syntax::CompileError::new(
5821                "bynk.test",
5822                bynk_syntax::span::Span::new(0, 7),
5823                "msg",
5824            )
5825            .with_label(bynk_syntax::span::Span::new(0, 1), "in an injected unit")
5826            .with_label(bynk_syntax::span::Span::new(0, 1), "in an unanalysed file")
5827            .with_label(bynk_syntax::span::Span::new(0, 1), "in no known file"),
5828            label_files: vec![
5829                bynk_ide::LabelFile::FirstParty {
5830                    identity_path: "bynk.bynk",
5831                    source: "commons bynk\n",
5832                },
5833                bynk_ide::LabelFile::Other(PathBuf::from("src/missing.bynk")),
5834                bynk_ide::LabelFile::Unavailable,
5835            ],
5836        };
5837        let diagnostic = make_diagnostic(&d, &positions, &uri, &ProjectFiles::new());
5838        assert!(
5839            diagnostic.related_information.is_none(),
5840            "{:?}",
5841            diagnostic.related_information
5842        );
5843        assert_eq!(
5844            diagnostic.message,
5845            "msg\n\nnote: in an injected unit\n\nnote: in an unanalysed file\n\nnote: in no known file"
5846        );
5847    }
5848
5849    /// Test helpers for the single-project behaviour tests (each builds exactly
5850    /// one project via `backend_at` or an equivalent insert). They read whatever
5851    /// the one entry's key is, so a test need not thread the canonical root.
5852    impl Backend {
5853        async fn test_root(&self) -> PathBuf {
5854            self.state
5855                .read()
5856                .await
5857                .projects
5858                .keys()
5859                .next()
5860                .cloned()
5861                .expect("a test project entry")
5862        }
5863        async fn run_round(&self) {
5864            let root = self.test_root().await;
5865            self.run_project_diagnostics(root).await;
5866        }
5867        async fn test_analysis(&self) -> Option<Arc<Analysis>> {
5868            let root = self.test_root().await;
5869            self.project_analysis(&root).await
5870        }
5871        async fn test_round_started(&self) -> u64 {
5872            let root = self.test_root().await;
5873            self.state
5874                .read()
5875                .await
5876                .projects
5877                .get(&root)
5878                .map(|p| p.analysis_round_started)
5879                .unwrap_or(0)
5880        }
5881    }
5882
5883    /// The slice, end to end through the server: a round covers **every**
5884    /// `include` tree, and each file keeps a distinct project-relative identity
5885    /// (ADR 0198). Before slice A the round was handed `<root>/src` and the
5886    /// `tests/` tree did not exist as far as the LSP was concerned.
5887    #[tokio::test]
5888    async fn a_round_covers_every_include_tree() {
5889        let s = scratch_project(
5890            "round",
5891            &[
5892                ("bynk.toml", "[project]\nname = \"round\"\n"),
5893                ("src/thing.bynk", "context thing\n"),
5894                // Same basename, second root — the ADR 0198 collision.
5895                ("tests/thing.bynk", "suite thing\n"),
5896            ],
5897        );
5898        let backend = backend_at(&s.0).await;
5899        backend.run_round().await;
5900
5901        let analysis = backend.test_analysis().await.expect("a round committed");
5902        let mut keys: Vec<String> = analysis
5903            .snapshots
5904            .keys()
5905            .map(|p| p.to_string_lossy().replace('\\', "/"))
5906            .collect();
5907        keys.sort();
5908        assert_eq!(
5909            keys,
5910            vec!["src/thing.bynk", "tests/thing.bynk"],
5911            "the round must cover both include trees, with distinct identities",
5912        );
5913    }
5914
5915    /// The identity a request resolves through. `uri_to_rel` is one
5916    /// `strip_prefix` against the project root — total across `include` trees,
5917    /// where the old `src` base could only ever name files in one of them.
5918    #[tokio::test]
5919    async fn a_uri_in_any_include_tree_resolves_to_its_analysed_file() {
5920        let s = scratch_project(
5921            "uri",
5922            &[
5923                ("bynk.toml", "[project]\nname = \"uri\"\n"),
5924                ("src/thing.bynk", "context thing\n"),
5925                ("tests/thing.bynk", "suite thing\n"),
5926            ],
5927        );
5928        let backend = backend_at(&s.0).await;
5929        backend.run_round().await;
5930        let analysis = backend.test_analysis().await.expect("round");
5931
5932        for (rel, label) in [
5933            ("src/thing.bynk", "primary"),
5934            ("tests/thing.bynk", "secondary"),
5935        ] {
5936            let abs = s.0.join(rel);
5937            let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
5938            let resolved = Backend::uri_to_rel(&analysis, &uri)
5939                .unwrap_or_else(|| panic!("{label} root URI must resolve"));
5940            assert_eq!(
5941                resolved.to_string_lossy().replace('\\', "/"),
5942                rel,
5943                "a {label}-tree URI must name its own analysed file",
5944            );
5945            assert!(
5946                analysis.snapshots.contains_key(&resolved),
5947                "…and that file must be in the round",
5948            );
5949        }
5950    }
5951
5952    /// Finding #62: `project_content` must exclude the calling file's own
5953    /// path — `bynk-ide`'s completion helpers already parse it fresh from the
5954    /// live buffer, so leaving it in the map would additionally serve its
5955    /// on-disk copy (stale relative to any unsaved edit) alongside the buffer
5956    /// parse.
5957    #[tokio::test]
5958    async fn project_content_excludes_the_calling_file() {
5959        let s = scratch_project(
5960            "self_excl",
5961            &[
5962                ("bynk.toml", "[project]\nname = \"self_excl\"\n"),
5963                ("a.bynk", "context a\n"),
5964                ("b.bynk", "context b\n"),
5965            ],
5966        );
5967        let backend = backend_at(&s.0).await;
5968        let abs = s.0.join("a.bynk");
5969        let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
5970        let content = backend
5971            .project_content(&uri)
5972            .await
5973            .expect("a project root resolves to a content map");
5974        assert!(
5975            !content.keys().any(|p| p.file_name().unwrap() == "a.bynk"),
5976            "the calling file's own path must not be in its own project content map: {content:?}"
5977        );
5978        assert!(
5979            content.keys().any(|p| p.file_name().unwrap() == "b.bynk"),
5980            "a sibling project file must still be present: {content:?}"
5981        );
5982        assert_eq!(
5983            content
5984                .iter()
5985                .find(|(p, _)| p.file_name().unwrap() == "b.bynk")
5986                .map(|(_, text)| text.as_str()),
5987            Some("context b\n"),
5988            "the sibling's content must be the real file content, not just its path"
5989        );
5990    }
5991
5992    /// `exclude` reaches the server, not just the compiler. `project.rs` used to
5993    /// parse it and throw it away — its own comment said the analyse walk "does
5994    /// not yet prune by `exclude`".
5995    #[tokio::test]
5996    async fn an_excluded_tree_is_not_analysed() {
5997        let s = scratch_project(
5998            "excl",
5999            &[
6000                (
6001                    "bynk.toml",
6002                    "[project]\nname = \"excl\"\n\n[paths]\ninclude = [\".\"]\nexclude = [\"generated\"]\n",
6003                ),
6004                ("a.bynk", "context a\n"),
6005                ("generated/gen.bynk", "context gen\n"),
6006            ],
6007        );
6008        let backend = backend_at(&s.0).await;
6009        backend.run_round().await;
6010        let analysis = backend.test_analysis().await.expect("round");
6011        let keys: Vec<String> = analysis
6012            .snapshots
6013            .keys()
6014            .map(|p| p.to_string_lossy().replace('\\', "/"))
6015            .collect();
6016        assert_eq!(keys, vec!["a.bynk"], "excluded trees stay out of the round");
6017    }
6018
6019    /// CI repro (#653): the VS Code extension's fixture workspace — a legacy
6020    /// `[paths] src`/`tests` manifest (keys ADR 0147 retired, so
6021    /// `read_project_paths` ignores them → `conventional()` → `["src"]`) with a
6022    /// dotted commons in `src/`. Drives the real `references` handler.
6023    #[tokio::test]
6024    async fn references_resolve_in_the_vscode_fixture_layout() {
6025        let s = scratch_project(
6026            "vsc",
6027            &[
6028                (
6029                    "bynk.toml",
6030                    "[project]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[paths]\nsrc = \"src\"\ntests = \"tests\"\n",
6031                ),
6032                (
6033                    "src/text.bynk",
6034                    "commons fixture.text\n\nfn shout(s: String) -> String {\n  s\n}\n\nfn greet(name: String) -> String {\n  \"Hi, \\(shout(name))!\"\n}\n",
6035                ),
6036            ],
6037        );
6038        // Root exactly as `initialize` does: resolve from the workspace folder.
6039        let (root, config) = Backend::resolve_root(&s.0).expect("bynk.toml is present");
6040        assert_eq!(root, s.0, "the manifest's directory is the project root");
6041
6042        let (service, _socket) = tower_lsp::LspService::new(Backend::new);
6043        let backend = service.inner().clone();
6044        {
6045            let mut st = backend.state.write().await;
6046            // Key by the canonical root, so the entry matches `references`'
6047            // URI-based routing (`resolve_root` canonicalises).
6048            let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
6049            st.projects.insert(
6050                canonical,
6051                ProjectState {
6052                    config,
6053                    ..Default::default()
6054                },
6055            );
6056        }
6057        backend.run_round().await;
6058
6059        let analysis = backend.test_analysis().await.expect("a round committed");
6060        let keys: Vec<String> = analysis
6061            .snapshots
6062            .keys()
6063            .map(|p| p.to_string_lossy().replace('\\', "/"))
6064            .collect();
6065        assert_eq!(keys, vec!["src/text.bynk"], "the fixture file is analysed");
6066
6067        // The URI the editor sends, mapped through the round's identity.
6068        let abs = s.0.join("src/text.bynk");
6069        let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
6070        let rel = Backend::uri_to_rel(&analysis, &uri).expect("URI resolves into the round");
6071        assert_eq!(rel, PathBuf::from("src/text.bynk"));
6072
6073        // `shout`'s declaration site: line 2 (0-based), at `fn shout`.
6074        let text = analysis.snapshots.get(&rel).expect("snapshot present");
6075        let decl = text.find("shout").expect("`shout` in source");
6076        let pos = crate::position::offset_to_position(text, decl);
6077
6078        let refs = backend
6079            .references(ReferenceParams {
6080                text_document_position: TextDocumentPositionParams {
6081                    text_document: TextDocumentIdentifier { uri: uri.clone() },
6082                    position: pos,
6083                },
6084                work_done_progress_params: Default::default(),
6085                partial_result_params: Default::default(),
6086                context: ReferenceContext {
6087                    include_declaration: true,
6088                },
6089            })
6090            .await
6091            .expect("references must not error");
6092        let found = refs.unwrap_or_default();
6093        assert!(
6094            !found.is_empty(),
6095            "`shout` is referenced by `greet` — references must resolve; got none",
6096        );
6097    }
6098
6099    // -- Slice B: the freshness contract, driven through a real Backend -------
6100    //
6101    // These are behaviour-over-time tests (§4.1): they edit a buffer and then
6102    // make a request, asserting the request answers against the *new* text.
6103    // The static tests above can't see this — the defect lives between the
6104    // edit and the request, which only a driven sequence exercises.
6105
6106    /// Open `src/a.bynk`, round it, then edit and drive `did_change`. `uri`,
6107    /// the round-1 relative path, and the edited version are returned.
6108    async fn open_round_edit(
6109        backend: &Backend,
6110        root: &std::path::Path,
6111        v1_text: &str,
6112        v2_text: &str,
6113    ) -> (Url, PathBuf) {
6114        let abs = root.join("src/a.bynk");
6115        let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
6116
6117        backend
6118            .did_open(DidOpenTextDocumentParams {
6119                text_document: TextDocumentItem {
6120                    uri: uri.clone(),
6121                    language_id: "bynk".into(),
6122                    version: 1,
6123                    text: v1_text.to_string(),
6124                },
6125            })
6126            .await;
6127        backend.run_round().await;
6128
6129        // The round exists and is version 1.
6130        let a1 = backend.test_analysis().await.expect("round 1");
6131        let rel = Backend::uri_to_rel(&a1, &uri).expect("uri resolves");
6132        assert_eq!(a1.versions.get(&rel), Some(&1), "round 1 is version 1");
6133
6134        // Edit: the buffer becomes `v2_text` at version 2. The debounce this
6135        // schedules is superseded by the request-driven refresh below.
6136        backend
6137            .did_change(DidChangeTextDocumentParams {
6138                text_document: VersionedTextDocumentIdentifier {
6139                    uri: uri.clone(),
6140                    version: 2,
6141                },
6142                content_changes: vec![TextDocumentContentChangeEvent {
6143                    range: None,
6144                    range_length: None,
6145                    text: v2_text.to_string(),
6146                }],
6147            })
6148            .await;
6149        (uri, rel)
6150    }
6151
6152    /// The headline. After an edit that inserts a line above a symbol, a
6153    /// position request at the symbol's *new* location resolves to the symbol —
6154    /// because the gate refreshes to the edited buffer first. Under the old
6155    /// behaviour the new position was resolved against the round-1 snapshot,
6156    /// landing on the wrong text.
6157    #[tokio::test]
6158    async fn a_position_after_an_edit_resolves_against_the_new_text() {
6159        let v1 = "commons q.a\n\nfn target(x: Int) -> Int {\n  x\n}\n";
6160        // Prepend a blank line: `target` moves from line 2 to line 3.
6161        let v2 = format!("\n{v1}");
6162        let s = scratch_project(
6163            "fresh_hd",
6164            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6165        );
6166        let backend = backend_at(&s.0).await;
6167        let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6168
6169        // The gate refreshes to the edited version and text.
6170        let a = backend.analysis_for(&uri).await.expect("current analysis");
6171        assert_eq!(a.versions.get(&rel), Some(&2), "gate refreshed to the edit");
6172        assert_eq!(a.snapshots.get(&rel).map(String::as_str), Some(v2.as_str()));
6173
6174        // `target`'s new position resolves to `target` in the refreshed snapshot.
6175        let off_v2 = v2.find("target").unwrap();
6176        let new_pos = crate::position::offset_to_position(&v2, off_v2);
6177        let (a2, rel2, off) = backend
6178            .index_position(&uri, new_pos)
6179            .await
6180            .expect("position resolves");
6181        assert!(
6182            a2.snapshots.get(&rel2).unwrap()[off..].starts_with("target"),
6183            "the new position must land on `target` in the current snapshot — \
6184             the whole point of refreshing",
6185        );
6186    }
6187
6188    /// The gate never returns a round whose snapshot for the file predates the
6189    /// buffer. A cached round at version 1 with the buffer at version 2 must be
6190    /// refreshed, not served.
6191    #[tokio::test]
6192    async fn a_stale_round_is_never_served() {
6193        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
6194        let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n  y\n}}\n");
6195        let s = scratch_project(
6196            "fresh_stale",
6197            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6198        );
6199        let backend = backend_at(&s.0).await;
6200        let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6201
6202        // Precondition: the *cached* round is still version 1 (no refresh yet).
6203        let cached = backend.test_analysis().await.unwrap();
6204        assert_eq!(cached.versions.get(&rel), Some(&1), "cached round is stale");
6205
6206        // The gate must not hand back that stale round.
6207        let a = backend.analysis_for(&uri).await.unwrap();
6208        assert_eq!(
6209            a.versions.get(&rel),
6210            Some(&2),
6211            "analysis_for must refresh past a stale cached round, never serve it",
6212        );
6213    }
6214
6215    /// #733: `committed_analysis` is the non-refreshing counterpart of
6216    /// `analysis_for`. Where the strict gate refreshes past a stale round (the
6217    /// test above), this one **serves the committed round as-is** — even with the
6218    /// buffer a version ahead — and never triggers a refresh. That is what lets a
6219    /// decoration request answer from the committed round while the user types,
6220    /// instead of forcing a whole-project round on every keystroke.
6221    #[tokio::test]
6222    async fn committed_analysis_serves_the_stale_round_without_refreshing() {
6223        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
6224        let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n  y\n}}\n");
6225        let s = scratch_project(
6226            "fresh_committed",
6227            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6228        );
6229        let backend = backend_at(&s.0).await;
6230        let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6231
6232        // Precondition: the cached round is still version 1 (buffer is at 2).
6233        assert_eq!(
6234            backend.test_analysis().await.unwrap().versions.get(&rel),
6235            Some(&1),
6236            "cached round is stale",
6237        );
6238
6239        // The non-refreshing gate hands back that stale round unchanged...
6240        let a = backend
6241            .committed_analysis(&uri)
6242            .await
6243            .expect("committed round");
6244        assert_eq!(
6245            a.versions.get(&rel),
6246            Some(&1),
6247            "committed_analysis serves the committed round, stale and all",
6248        );
6249        // ...and left the cached round untouched (no refresh was triggered).
6250        assert_eq!(
6251            backend.test_analysis().await.unwrap().versions.get(&rel),
6252            Some(&1),
6253            "committed_analysis must not trigger a refresh",
6254        );
6255    }
6256
6257    /// DECISION B: concurrent requests after one edit coalesce onto a single
6258    /// round, not one each. The refresh lock serialises them; the second finds
6259    /// the first's round already current.
6260    #[tokio::test]
6261    async fn concurrent_requests_after_one_edit_share_one_round() {
6262        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
6263        let v2 = format!("\n{v1}");
6264        let s = scratch_project(
6265            "fresh_coal",
6266            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6267        );
6268        let backend = backend_at(&s.0).await;
6269        let (uri, _rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6270
6271        let started_before = backend.test_round_started().await;
6272
6273        // Fire several gate calls concurrently.
6274        let calls = (0..5).map(|_| {
6275            let b = backend.clone();
6276            let u = uri.clone();
6277            tokio::spawn(async move { b.analysis_for(&u).await.is_some() })
6278        });
6279        for c in calls {
6280            assert!(
6281                c.await.unwrap(),
6282                "each concurrent request must get an analysis"
6283            );
6284        }
6285
6286        let started_after = backend.test_round_started().await;
6287        assert_eq!(
6288            started_after - started_before,
6289            1,
6290            "five concurrent requests after one edit must share ONE round, not run five",
6291        );
6292    }
6293
6294    /// DECISION D: a file outside every `include` root cannot be answered at the
6295    /// client's version — the gate declines rather than serving something.
6296    #[tokio::test]
6297    async fn a_file_outside_the_project_is_declined() {
6298        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
6299        let s = scratch_project(
6300            "fresh_out",
6301            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6302        );
6303        let backend = backend_at(&s.0).await;
6304        // Round the project so a cached analysis exists.
6305        backend.run_round().await;
6306
6307        // A URI for a file the project does not contain, opened as a buffer.
6308        let outside = s.0.join("elsewhere.bynk");
6309        std::fs::write(&outside, v1).unwrap();
6310        let uri = Url::from_file_path(outside.canonicalize().unwrap()).unwrap();
6311        backend
6312            .did_open(DidOpenTextDocumentParams {
6313                text_document: TextDocumentItem {
6314                    uri: uri.clone(),
6315                    language_id: "bynk".into(),
6316                    version: 1,
6317                    text: v1.to_string(),
6318                },
6319            })
6320            .await;
6321
6322        assert!(
6323            backend.analysis_for(&uri).await.is_none(),
6324            "a file outside the include roots is never a snapshot key — decline, \
6325             don't serve a round that doesn't cover it",
6326        );
6327    }
6328
6329    /// Review of #666: rename emits versioned edits across every file that
6330    /// references the symbol, so it must refresh **all** open buffers, not just
6331    /// the cursor's. Edit a non-cursor file that references the symbol, then
6332    /// rename from the (unedited) definition file: the edit for the dirty file
6333    /// must carry its *current* version, or the client rejects the whole rename.
6334    /// Under the per-URI gate the cursor's file was current, so no refresh ran
6335    /// and the dirty file kept its stale version.
6336    #[tokio::test]
6337    async fn a_multi_file_rename_stamps_a_dirty_non_cursor_file_at_its_current_version() {
6338        let util = "commons demo.util\n\ntype Money = Int where Positive\n";
6339        let thing = "commons demo.thing\n\nuses demo.util\n\nfn f(m: Money) -> Money {\n  m\n}\n";
6340        let s = scratch_project(
6341            "rename_multi",
6342            &[
6343                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6344                ("src/demo/util.bynk", util),
6345                ("src/demo/thing.bynk", thing),
6346            ],
6347        );
6348        let backend = backend_at(&s.0).await;
6349        let uri = |rel: &str| {
6350            let abs = s.0.join(rel);
6351            Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
6352        };
6353        let util_uri = uri("src/demo/util.bynk");
6354        let thing_uri = uri("src/demo/thing.bynk");
6355
6356        for (u, text) in [(&util_uri, util), (&thing_uri, thing)] {
6357            backend
6358                .did_open(DidOpenTextDocumentParams {
6359                    text_document: TextDocumentItem {
6360                        uri: u.clone(),
6361                        language_id: "bynk".into(),
6362                        version: 1,
6363                        text: text.to_string(),
6364                    },
6365                })
6366                .await;
6367        }
6368        backend.run_round().await;
6369
6370        // Edit the NON-cursor file (`thing`) to version 2 — a blank line above,
6371        // so `Money`'s references shift but still resolve.
6372        let thing_v2 = format!("\n{thing}");
6373        backend
6374            .did_change(DidChangeTextDocumentParams {
6375                text_document: VersionedTextDocumentIdentifier {
6376                    uri: thing_uri.clone(),
6377                    version: 2,
6378                },
6379                content_changes: vec![TextDocumentContentChangeEvent {
6380                    range: None,
6381                    range_length: None,
6382                    text: thing_v2.clone(),
6383                }],
6384            })
6385            .await;
6386
6387        // Rename `Money` from its definition in `util` (untouched, still v1).
6388        let money_off = util.find("Money").unwrap();
6389        let pos = crate::position::offset_to_position(util, money_off);
6390        let edit = backend
6391            .rename(RenameParams {
6392                text_document_position: TextDocumentPositionParams {
6393                    text_document: TextDocumentIdentifier {
6394                        uri: util_uri.clone(),
6395                    },
6396                    position: pos,
6397                },
6398                new_name: "Amount".into(),
6399                work_done_progress_params: Default::default(),
6400            })
6401            .await
6402            .expect("rename must not error")
6403            .expect("rename must produce edits");
6404
6405        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6406            panic!("expected document-change edits");
6407        };
6408        let thing_edit = edits
6409            .iter()
6410            .find(|e| e.text_document.uri == thing_uri)
6411            .expect("the rename must edit `thing`, which references the symbol");
6412        assert_eq!(
6413            thing_edit.text_document.version,
6414            Some(2),
6415            "the dirty non-cursor file's edit must carry its current version (2), \
6416             not the stale round's (1) — else the client rejects the whole rename",
6417        );
6418    }
6419
6420    /// #302: renaming a unit's file rewrites its own declaration header
6421    /// **and** every other file's `uses`/`consumes` reference — over a split
6422    /// `src`/`tests` project, exercising the project-relative (`src/`-prefixed)
6423    /// path the `src`/`tests` split leaves on every identity path.
6424    #[tokio::test]
6425    async fn will_rename_files_updates_the_declaration_and_every_reference() {
6426        let charge = "commons billing.charge\n\ntype ChargeId = Int where Positive\n";
6427        let main = "context app.main\n\nuses billing.charge\n";
6428        let s = scratch_project(
6429            "will_rename_basic",
6430            &[
6431                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6432                ("src/billing/charge.bynk", charge),
6433                ("src/app/main.bynk", main),
6434            ],
6435        );
6436        let backend = backend_at(&s.0).await;
6437        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6438        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6439        let old_uri = uri("src/billing/charge.bynk");
6440        let new_uri = uri("src/billing/pay.bynk");
6441        let main_uri = uri("src/app/main.bynk");
6442
6443        backend.run_round().await;
6444
6445        let edit = backend
6446            .will_rename_files(RenameFilesParams {
6447                files: vec![FileRename {
6448                    old_uri: old_uri.to_string(),
6449                    new_uri: new_uri.to_string(),
6450                }],
6451            })
6452            .await
6453            .expect("will_rename_files must not error")
6454            .expect("must produce edits");
6455
6456        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6457            panic!("expected document-change edits");
6458        };
6459
6460        let own = edits
6461            .iter()
6462            .find(|e| e.text_document.uri == old_uri)
6463            .expect("the moved file's own declaration must be rewritten");
6464        assert_eq!(own.edits.len(), 1);
6465        let OneOf::Left(own_edit) = &own.edits[0] else {
6466            panic!("expected a plain TextEdit");
6467        };
6468        assert_eq!(own_edit.new_text, "billing.pay");
6469
6470        let referencer = edits
6471            .iter()
6472            .find(|e| e.text_document.uri == main_uri)
6473            .expect("the referencing file must be rewritten");
6474        assert_eq!(referencer.edits.len(), 1);
6475        let OneOf::Left(ref_edit) = &referencer.edits[0] else {
6476            panic!("expected a plain TextEdit");
6477        };
6478        assert_eq!(ref_edit.new_text, "billing.pay");
6479    }
6480
6481    /// #302: renaming one member file within a multi-file unit's directory
6482    /// doesn't change the unit's qualified name (it's the directory, not the
6483    /// filename) — no edits are needed.
6484    #[tokio::test]
6485    async fn will_rename_files_is_a_noop_for_a_multi_file_unit_member() {
6486        let s = scratch_project(
6487            "will_rename_multi_file_noop",
6488            &[
6489                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6490                (
6491                    "src/billing/charge/one.bynk",
6492                    "context billing.charge\n\ntype ChargeId = Int where Positive\n",
6493                ),
6494                (
6495                    "src/billing/charge/two.bynk",
6496                    "context billing.charge\n\ntype PaymentId = Int where Positive\n",
6497                ),
6498            ],
6499        );
6500        let backend = backend_at(&s.0).await;
6501        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6502        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6503
6504        backend.run_round().await;
6505
6506        let edit = backend
6507            .will_rename_files(RenameFilesParams {
6508                files: vec![FileRename {
6509                    old_uri: uri("src/billing/charge/one.bynk").to_string(),
6510                    new_uri: uri("src/billing/charge/renamed.bynk").to_string(),
6511                }],
6512            })
6513            .await
6514            .expect("will_rename_files must not error");
6515        assert!(
6516            edit.is_none(),
6517            "renaming a member file within the same directory must not edit anything"
6518        );
6519    }
6520
6521    /// #302: a `suite` file has no addressable name of its own
6522    /// (`SourceUnit::name()` is its *target*'s name) — renaming it produces
6523    /// no edits.
6524    #[tokio::test]
6525    async fn will_rename_files_is_a_noop_for_a_suite() {
6526        let s = scratch_project(
6527            "will_rename_suite_noop",
6528            &[
6529                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6530                (
6531                    "src/billing/charge.bynk",
6532                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6533                ),
6534                ("tests/billing_charge.bynk", "suite billing.charge\n"),
6535            ],
6536        );
6537        let backend = backend_at(&s.0).await;
6538        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6539        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6540
6541        backend.run_round().await;
6542
6543        let edit = backend
6544            .will_rename_files(RenameFilesParams {
6545                files: vec![FileRename {
6546                    old_uri: uri("tests/billing_charge.bynk").to_string(),
6547                    new_uri: uri("tests/billing_charge_renamed.bynk").to_string(),
6548                }],
6549            })
6550            .await
6551            .expect("will_rename_files must not error");
6552        assert!(edit.is_none(), "a suite rename must produce no edits");
6553    }
6554
6555    /// #302 review: a `suite`'s own `target` is a *reference* too
6556    /// (`unit_reference_spans`' suite branch) — renaming the unit a suite
6557    /// tests must rewrite the suite's `suite <target>` header, not just
6558    /// `uses`/`consumes` clauses in ordinary units.
6559    #[tokio::test]
6560    async fn will_rename_files_updates_a_suite_s_target_reference() {
6561        let s = scratch_project(
6562            "will_rename_suite_target",
6563            &[
6564                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6565                (
6566                    "src/billing/charge.bynk",
6567                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6568                ),
6569                ("tests/billing_charge.bynk", "suite billing.charge\n"),
6570            ],
6571        );
6572        let backend = backend_at(&s.0).await;
6573        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6574        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6575        let suite_uri = uri("tests/billing_charge.bynk");
6576
6577        backend.run_round().await;
6578
6579        let edit = backend
6580            .will_rename_files(RenameFilesParams {
6581                files: vec![FileRename {
6582                    old_uri: uri("src/billing/charge.bynk").to_string(),
6583                    new_uri: uri("src/billing/pay.bynk").to_string(),
6584                }],
6585            })
6586            .await
6587            .expect("will_rename_files must not error")
6588            .expect("must produce edits");
6589
6590        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6591            panic!("expected document-change edits");
6592        };
6593        let suite_edit = edits
6594            .iter()
6595            .find(|e| e.text_document.uri == suite_uri)
6596            .expect("the suite's own `suite <target>` header must be rewritten");
6597        assert_eq!(suite_edit.edits.len(), 1);
6598        let OneOf::Left(e) = &suite_edit.edits[0] else {
6599            panic!("expected a plain TextEdit");
6600        };
6601        assert_eq!(e.new_text, "billing.pay");
6602    }
6603
6604    /// #302 review: renaming into a path that implies a name some other file
6605    /// already declares must not hand back an edit that would create a
6606    /// duplicate-name project — a lightweight `unit_sources` check, not
6607    /// `rename`'s full re-analysis.
6608    #[tokio::test]
6609    async fn will_rename_files_refuses_a_rename_that_collides_with_an_existing_unit() {
6610        let s = scratch_project(
6611            "will_rename_collision",
6612            &[
6613                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6614                (
6615                    "src/billing/charge.bynk",
6616                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6617                ),
6618                (
6619                    "src/billing/pay.bynk",
6620                    "commons billing.pay\n\ntype PaymentId = Int where Positive\n",
6621                ),
6622            ],
6623        );
6624        let backend = backend_at(&s.0).await;
6625        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6626        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6627
6628        backend.run_round().await;
6629
6630        // Renaming `charge.bynk` to `pay.bynk` would imply `billing.pay` —
6631        // already declared by the sibling file.
6632        let edit = backend
6633            .will_rename_files(RenameFilesParams {
6634                files: vec![FileRename {
6635                    old_uri: uri("src/billing/charge.bynk").to_string(),
6636                    new_uri: uri("src/billing/pay.bynk").to_string(),
6637                }],
6638            })
6639            .await
6640            .expect("will_rename_files must not error");
6641        assert!(
6642            edit.is_none(),
6643            "a rename that collides with an existing unit name must produce no edits"
6644        );
6645    }
6646
6647    /// #302 review: `willRenameFiles`' `new_uri` names a file that doesn't
6648    /// exist yet, so `uri_to_rel`'s `canonicalize` fails and previously fell
6649    /// back to the client's raw, non-canonical path — which mismatches
6650    /// `project_root` (always canonical) whenever the workspace root sits
6651    /// behind a symlink, and the handler silently produced no edit.
6652    /// `uri_to_rel_for_new_path` canonicalizes the parent directory (which
6653    /// does exist) instead, so this must still produce edits.
6654    #[cfg(unix)]
6655    #[tokio::test]
6656    async fn will_rename_files_tolerates_a_symlinked_project_root() {
6657        let real = scratch_project(
6658            "will_rename_symlink_real",
6659            &[
6660                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6661                (
6662                    "src/billing/charge.bynk",
6663                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6664                ),
6665            ],
6666        );
6667        let alias = std::env::temp_dir().join(format!(
6668            "bynk_lsp_sliceA_will_rename_symlink_alias_{}_{:?}",
6669            std::process::id(),
6670            std::thread::current().id()
6671        ));
6672        let _ = std::fs::remove_file(&alias);
6673        std::os::unix::fs::symlink(&real.0, &alias).expect("symlink the scratch root");
6674
6675        let backend = backend_at(&alias).await;
6676        // Built through the symlink, deliberately uncanonicalized — the path
6677        // shape a client actually sends (it opened the workspace at `alias`,
6678        // not at whatever `alias` resolves to).
6679        let uri = |rel: &str| Url::from_file_path(alias.join(rel)).unwrap();
6680        let old_uri = uri("src/billing/charge.bynk");
6681        let new_uri = uri("src/billing/pay.bynk"); // does not exist on disk
6682
6683        backend.run_round().await;
6684
6685        let edit = backend
6686            .will_rename_files(RenameFilesParams {
6687                files: vec![FileRename {
6688                    old_uri: old_uri.to_string(),
6689                    new_uri: new_uri.to_string(),
6690                }],
6691            })
6692            .await
6693            .expect("will_rename_files must not error")
6694            .expect("must produce edits despite the symlinked root");
6695
6696        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6697            panic!("expected document-change edits");
6698        };
6699        assert_eq!(
6700            edits.len(),
6701            1,
6702            "only the moved file's own header changes here"
6703        );
6704        let OneOf::Left(e) = &edits[0].edits[0] else {
6705            panic!("expected a plain TextEdit");
6706        };
6707        assert_eq!(e.new_text, "billing.pay");
6708
6709        let _ = std::fs::remove_file(&alias);
6710    }
6711
6712    /// #2105: run `willRenameFiles` over `renames` (project-relative old →
6713    /// new paths) against a fresh round of `files`, and flatten the result to
6714    /// `project-relative file → sorted new texts`. `None` when no edit comes
6715    /// back. Also asserts that no file's edits overlap, the failure mode of a
6716    /// multi-file unit or a file-plus-folder request emitting an edit twice.
6717    async fn folder_rename(
6718        tag: &str,
6719        files: &[(&str, &str)],
6720        renames: &[(&str, &str)],
6721    ) -> Option<std::collections::BTreeMap<String, Vec<String>>> {
6722        let s = scratch_project(tag, files);
6723        let backend = backend_at(&s.0).await;
6724        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6725        // Joined component by component, so a `/`-separated fixture path is a
6726        // real path on Windows too (a `\\?\` canonical root does not split on
6727        // `/`).
6728        let uri = |rel: &str| {
6729            Url::from_file_path(root_canon.join(rel.split('/').collect::<PathBuf>())).unwrap()
6730        };
6731        // An edited document's URI maps back to its fixture path by comparing
6732        // file paths after a `Url` round trip on both sides, which normalises
6733        // the platform's spelling (drive letter, verbatim prefix, separators).
6734        let fixture_of = |edited: &Url| -> String {
6735            let edited = edited.to_file_path().unwrap();
6736            files
6737                .iter()
6738                .map(|(rel, _)| *rel)
6739                .find(|rel| uri(rel).to_file_path().ok().as_ref() == Some(&edited))
6740                .unwrap_or_else(|| panic!("edit to a non-fixture file {}", edited.display()))
6741                .to_string()
6742        };
6743        backend.run_round().await;
6744        let edit = backend
6745            .will_rename_files(RenameFilesParams {
6746                files: renames
6747                    .iter()
6748                    .map(|(old, new)| FileRename {
6749                        old_uri: uri(old).to_string(),
6750                        new_uri: uri(new).to_string(),
6751                    })
6752                    .collect(),
6753            })
6754            .await
6755            .expect("will_rename_files must not error")?;
6756        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6757            panic!("expected document-change edits");
6758        };
6759        let mut out = std::collections::BTreeMap::new();
6760        for doc in edits {
6761            let rel = fixture_of(&doc.text_document.uri);
6762            let mut ranges: Vec<Range> = Vec::new();
6763            let mut texts = Vec::new();
6764            for e in doc.edits {
6765                let OneOf::Left(e) = e else {
6766                    panic!("expected a plain TextEdit");
6767                };
6768                assert!(
6769                    !ranges
6770                        .iter()
6771                        .any(|r| r.start <= e.range.end && e.range.start <= r.end),
6772                    "overlapping edits in {rel}: {:?} and {ranges:?}",
6773                    e.range
6774                );
6775                ranges.push(e.range);
6776                texts.push(e.new_text);
6777            }
6778            texts.sort();
6779            assert!(
6780                out.insert(rel.clone(), texts).is_none(),
6781                "{rel} edited twice"
6782            );
6783        }
6784        Some(out)
6785    }
6786
6787    fn expect_edits(pairs: &[(&str, &[&str])]) -> std::collections::BTreeMap<String, Vec<String>> {
6788        pairs
6789            .iter()
6790            .map(|(f, texts)| (f.to_string(), texts.iter().map(|t| t.to_string()).collect()))
6791            .collect()
6792    }
6793
6794    /// The project the folder-rename tests share: two units in `billing/`, one
6795    /// referencing the other, and a context outside referencing both.
6796    const BILLING: &[(&str, &str)] = &[
6797        ("bynk.toml", "[project]\nname=\"demo\"\n"),
6798        (
6799            "src/billing/charge.bynk",
6800            "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6801        ),
6802        (
6803            "src/billing/refund.bynk",
6804            "commons billing.refund\n\nuses billing.charge\n\ntype RefundId = Int where Positive\n",
6805        ),
6806        (
6807            "src/app/main.bynk",
6808            "context app.main\n\nuses billing.charge\nuses billing.refund\n",
6809        ),
6810    ];
6811
6812    /// #2105: renaming a folder renames every unit in it and rewrites every
6813    /// reference to them, including one between two units inside the folder.
6814    #[tokio::test]
6815    async fn will_rename_files_renames_a_folder_of_units() {
6816        let edits = folder_rename(
6817            "will_rename_folder",
6818            BILLING,
6819            &[("src/billing", "src/payments")],
6820        )
6821        .await
6822        .expect("a folder rename must produce edits");
6823        assert_eq!(
6824            edits,
6825            expect_edits(&[
6826                ("src/app/main.bynk", &["payments.charge", "payments.refund"]),
6827                ("src/billing/charge.bynk", &["payments.charge"]),
6828                (
6829                    "src/billing/refund.bynk",
6830                    &["payments.charge", "payments.refund"]
6831                ),
6832            ])
6833        );
6834    }
6835
6836    /// #2105: moving a folder under a different parent prefixes every unit
6837    /// in it, nested folders included.
6838    #[tokio::test]
6839    async fn will_rename_files_moves_a_folder_with_nested_units() {
6840        let mut files = BILLING.to_vec();
6841        files.push((
6842            "src/billing/core/ledger.bynk",
6843            "commons billing.core.ledger\n\ntype Entry = Int where Positive\n",
6844        ));
6845        files.push((
6846            "src/app/books.bynk",
6847            "context app.books\n\nuses billing.core.ledger\n",
6848        ));
6849        files.push((
6850            "src/finance/tax.bynk",
6851            "commons finance.tax\n\ntype Rate = Int where Positive\n",
6852        ));
6853        let edits = folder_rename(
6854            "will_rename_folder_move",
6855            &files,
6856            &[("src/billing", "src/finance/billing")],
6857        )
6858        .await
6859        .expect("a folder move must produce edits");
6860        assert_eq!(
6861            edits,
6862            expect_edits(&[
6863                ("src/app/books.bynk", &["finance.billing.core.ledger"]),
6864                (
6865                    "src/app/main.bynk",
6866                    &["finance.billing.charge", "finance.billing.refund"]
6867                ),
6868                ("src/billing/charge.bynk", &["finance.billing.charge"]),
6869                (
6870                    "src/billing/core/ledger.bynk",
6871                    &["finance.billing.core.ledger"]
6872                ),
6873                (
6874                    "src/billing/refund.bynk",
6875                    &["finance.billing.charge", "finance.billing.refund"]
6876                ),
6877            ])
6878        );
6879    }
6880
6881    /// #2105: renaming a nested folder renames only the units under it; a
6882    /// sibling folder sharing its name as a prefix (`core` vs `corex`) is not
6883    /// "under" it.
6884    #[tokio::test]
6885    async fn will_rename_files_renames_only_the_nested_folder() {
6886        let edits = folder_rename(
6887            "will_rename_folder_nested",
6888            &[
6889                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6890                (
6891                    "src/billing/core/ledger.bynk",
6892                    "commons billing.core.ledger\n",
6893                ),
6894                (
6895                    "src/billing/corex/extra.bynk",
6896                    "commons billing.corex.extra\n",
6897                ),
6898                (
6899                    "src/app/main.bynk",
6900                    "context app.main\n\nuses billing.core.ledger\nuses billing.corex.extra\n",
6901                ),
6902            ],
6903            &[("src/billing/core", "src/billing/base")],
6904        )
6905        .await
6906        .expect("a nested folder rename must produce edits");
6907        assert_eq!(
6908            edits,
6909            expect_edits(&[
6910                ("src/app/main.bynk", &["billing.base.ledger"]),
6911                ("src/billing/core/ledger.bynk", &["billing.base.ledger"]),
6912            ])
6913        );
6914    }
6915
6916    /// #2105: a folder that *is* a multi-file unit renames the unit: every
6917    /// member's header changes, and each reference is rewritten exactly once
6918    /// (`folder_rename` fails on an overlapping or duplicated edit).
6919    #[tokio::test]
6920    async fn will_rename_files_renames_a_multi_file_unit_folder_once() {
6921        let edits = folder_rename(
6922            "will_rename_folder_multi_file",
6923            &[
6924                ("bynk.toml", "[project]\nname=\"demo\"\n"),
6925                (
6926                    "src/billing/charge/one.bynk",
6927                    "context billing.charge\n\ntype ChargeId = Int where Positive\n",
6928                ),
6929                (
6930                    "src/billing/charge/two.bynk",
6931                    "context billing.charge\n\ntype PaymentId = Int where Positive\n",
6932                ),
6933                (
6934                    "src/app/main.bynk",
6935                    "context app.main\n\nconsumes billing.charge\n",
6936                ),
6937            ],
6938            &[("src/billing/charge", "src/billing/payment")],
6939        )
6940        .await
6941        .expect("a multi-file unit folder rename must produce edits");
6942        assert_eq!(
6943            edits,
6944            expect_edits(&[
6945                ("src/app/main.bynk", &["billing.payment"]),
6946                ("src/billing/charge/one.bynk", &["billing.payment"]),
6947                ("src/billing/charge/two.bynk", &["billing.payment"]),
6948            ])
6949        );
6950    }
6951
6952    /// #2105: a folder holding no `.bynk` file produces no edit.
6953    #[tokio::test]
6954    async fn will_rename_files_is_a_noop_for_a_folder_without_units() {
6955        let mut files = BILLING.to_vec();
6956        files.push(("src/assets/logo.txt", "not bynk\n"));
6957        let edits = folder_rename(
6958            "will_rename_folder_empty",
6959            &files,
6960            &[("src/assets", "src/static")],
6961        )
6962        .await;
6963        assert!(edits.is_none(), "{edits:?}");
6964    }
6965
6966    /// #2105: a request naming a file and also its enclosing folder (both
6967    /// moving the same way) edits each file once, like the folder alone.
6968    #[tokio::test]
6969    async fn will_rename_files_deduplicates_a_file_inside_a_renamed_folder() {
6970        let edits = folder_rename(
6971            "will_rename_folder_mixed",
6972            BILLING,
6973            &[
6974                ("src/billing/charge.bynk", "src/payments/charge.bynk"),
6975                ("src/billing", "src/payments"),
6976            ],
6977        )
6978        .await
6979        .expect("a mixed rename must produce edits");
6980        assert_eq!(
6981            edits,
6982            expect_edits(&[
6983                ("src/app/main.bynk", &["payments.charge", "payments.refund"]),
6984                ("src/billing/charge.bynk", &["payments.charge"]),
6985                (
6986                    "src/billing/refund.bynk",
6987                    &["payments.charge", "payments.refund"]
6988                ),
6989            ])
6990        );
6991    }
6992
6993    /// #2105 (DECISION B): moving a folder into another project (here a
6994    /// nested `bynk.toml`, which the old `strip_prefix` check let through)
6995    /// produces no edit.
6996    #[tokio::test]
6997    async fn will_rename_files_refuses_a_move_into_another_project() {
6998        let mut files = BILLING.to_vec();
6999        files.push(("vendor/bynk.toml", "[project]\nname=\"vendor\"\n"));
7000        files.push(("vendor/src/keep.bynk", "commons keep\n"));
7001        let folder = folder_rename(
7002            "will_rename_folder_cross_project",
7003            &files,
7004            &[("src/billing", "vendor/src/billing")],
7005        )
7006        .await;
7007        assert!(folder.is_none(), "{folder:?}");
7008        let file = folder_rename(
7009            "will_rename_file_cross_project",
7010            &files,
7011            &[("src/billing/charge.bynk", "vendor/src/charge.bynk")],
7012        )
7013        .await;
7014        assert!(file.is_none(), "{file:?}");
7015    }
7016
7017    /// #485: a rootless multi-file-commons file (a `src/` tree with no
7018    /// `bynk.toml`, the layout the compiler fixtures use) resolves its
7019    /// implicit source root — the nearest ancestor `src/` — so project-mode
7020    /// analysis kicks in instead of sibling-blind single-file `diagnose`.
7021    #[test]
7022    fn find_source_root_walks_up_to_the_nearest_src() {
7023        let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
7024            .parent()
7025            .expect("workspace root");
7026        let make = ws.join(
7027            "bynkc/tests/fixtures/positive/\
7028             252_multi_file_commons_dotted_test/src/shipping/rates/make.bynk",
7029        );
7030        assert!(make.is_file(), "fixture present: {}", make.display());
7031
7032        let src = Backend::find_source_root(&make).expect("an ancestor src/");
7033        assert!(
7034            src.ends_with("252_multi_file_commons_dotted_test/src"),
7035            "nearest ancestor src, got {}",
7036            src.display()
7037        );
7038
7039        // No `bynk.toml` on the path, so resolution falls back to the implicit
7040        // src tree, and the project root is `src`'s parent.
7041        //
7042        // Slice A: the old invariant here was `root.join(config.src_dir) == src`
7043        // — the analysis root re-derived by reducing the manifest to one
7044        // directory. That reduction is gone: the round is rooted at the project
7045        // and `bynk_ide::AnalysisRoots::Project` resolves the trees from the
7046        // manifest (here, absent → `ProjectPaths::conventional`, which picks up
7047        // exactly this `src/`). So what must hold is that the root is `src`'s
7048        // parent, and that conventional discovery finds this file from it.
7049        let (root, _config) = Backend::resolve_root(&make).expect("implicit project");
7050        assert_eq!(root, src.parent().expect("src has a parent"));
7051
7052        // No `bynk.toml` in this fixture (the test is exactly about the
7053        // absent-manifest → conventional-layout path), so an empty overlay
7054        // is correct here, not a stand-in for a real manifest read.
7055        let found = bynk_ide::discover_files(
7056            &bynk_ide::AnalysisRoots::Project(root.clone()),
7057            &std::collections::HashMap::new(),
7058        );
7059        let make_canon = make.canonicalize().unwrap_or(make.clone());
7060        assert!(
7061            found
7062                .iter()
7063                .any(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == make_canon),
7064            "the compiler's own discovery must reach {} from the project root {}; got {found:?}",
7065            make.display(),
7066            root.display(),
7067        );
7068    }
7069
7070    /// A file with no `bynk.toml` and no ancestor `src/` stays in single-file
7071    /// mode — resolution returns `None`, so the caller keeps the per-buffer
7072    /// `diagnose` path.
7073    #[test]
7074    fn resolve_root_is_none_without_toml_or_src() {
7075        // The crate manifest sits under `bynk-lsp/`, not inside any `src/`.
7076        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
7077        assert!(p.is_file());
7078        assert!(Backend::find_source_root(&p).is_none());
7079        assert!(Backend::resolve_root(&p).is_none());
7080    }
7081
7082    // v0.124 (slice 3): the `<expr> is <cursor>` scrutinee-offset detection that
7083    // feeds `is`-pattern completion.
7084    #[test]
7085    fn is_scrutinee_offset_locates_the_scrutinee() {
7086        let text = "  order.status is Pen";
7087        let off = is_scrutinee_offset(text, text.len()).expect("at an is-position");
7088        // Lands on the last char of `order.status` (the `s` of `status`).
7089        assert_eq!(&text[off..off + 1], "s");
7090        assert!(off < text.find(" is ").unwrap());
7091        // No trailing partial, cursor right after `is `.
7092        let text2 = "  x is ";
7093        let off2 = is_scrutinee_offset(text2, text2.len()).expect("at an is-position");
7094        assert_eq!(&text2[off2..off2 + 1], "x");
7095        // `basis` is not a standalone `is`.
7096        assert!(is_scrutinee_offset("  basis ", "  basis ".len()).is_none());
7097        // Not an is-position at all.
7098        assert!(is_scrutinee_offset("  let x = ", "  let x = ".len()).is_none());
7099    }
7100
7101    // v0.128: the `match <expr> { <arm-start>` scrutinee-offset detection that
7102    // feeds match-arm variant completion.
7103    #[test]
7104    fn let_else_match_head_reads_the_value_and_the_covered_variant() {
7105        // #2010: the first arm, right after the brace.
7106        let t = "  let Ok(n) = load(r) else match {\n    ";
7107        let h = let_else_match_head(t, t.len()).expect("at an arm-start");
7108        assert_eq!(&t[h.value_end..h.value_end + 1], ")"); // last char of `load(r)`
7109        assert!(!h.effectful);
7110        assert_eq!(h.covered.as_deref(), Some("Ok"));
7111
7112        // `<-`, a later arm, a partial pattern typed.
7113        let t = "let Some(x) <- find(k) else match {\n  None => 0,\n  Er";
7114        let h = let_else_match_head(t, t.len()).expect("at a later arm-start");
7115        assert!(h.effectful);
7116        assert_eq!(&t[h.value_end..h.value_end + 1], ")");
7117        assert_eq!(h.covered.as_deref(), Some("Some"));
7118
7119        // A pattern that covers only part of a variant leaves it offerable.
7120        let t = "let Err(Missing) = r else match {\n  ";
7121        assert_eq!(let_else_match_head(t, t.len()).unwrap().covered, None);
7122
7123        // A qualified nullary variant is covered whole.
7124        let t = "let Status.Done = s else match {\n  ";
7125        assert_eq!(
7126            let_else_match_head(t, t.len()).unwrap().covered.as_deref(),
7127            Some("Done")
7128        );
7129
7130        // An `==` in the value is not the binding `=`.
7131        let t = "let Ok(b) = check(a == 1) else match {\n  ";
7132        let h = let_else_match_head(t, t.len()).unwrap();
7133        assert_eq!(&t[h.value_end..h.value_end + 1], ")");
7134
7135        // Not a let-else: a plain `match`, or an arm body.
7136        assert!(let_else_match_head("match c {\n  ", 11).is_none());
7137        let t = "let Ok(n) = r else match {\n  Err(e) => ";
7138        assert!(let_else_match_head(t, t.len()).is_none());
7139    }
7140
7141    #[test]
7142    fn match_scrutinee_offset_locates_the_scrutinee() {
7143        // First arm, cursor right after the opening brace.
7144        let t = "match order.status {\n  ";
7145        let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
7146        assert_eq!(&t[off..off + 1], "s"); // last char of `order.status`
7147        assert!(off < t.find(" {").unwrap());
7148
7149        // First arm with a partial pattern typed.
7150        let t = "match color { Re";
7151        let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
7152        assert_eq!(&t[off..off + 1], "r"); // last char of `color`
7153
7154        // A later arm after a top-level comma, mid-partial.
7155        let t = "match c {\n  Red => 1,\n  Gr";
7156        let off = match_scrutinee_offset(t, t.len()).expect("at a later arm-start");
7157        assert_eq!(&t[off..off + 1], "c");
7158
7159        // A top-level comma inside a preceding arm body does not confuse the
7160        // header (the nested call's comma is at depth > 0).
7161        let t = "match c {\n  Red => f(a, b),\n  ";
7162        assert!(match_scrutinee_offset(t, t.len()).is_some());
7163
7164        // Inside an arm *body* (after `=>`) — not a pattern position.
7165        assert!(
7166            match_scrutinee_offset("match c {\n  Red => ", "match c {\n  Red => ".len()).is_none()
7167        );
7168
7169        // A non-`match` block offers nothing.
7170        assert!(match_scrutinee_offset("fn f() {\n  ", "fn f() {\n  ".len()).is_none());
7171
7172        // A nested constructor position (`Ok(<cursor>`) is not an arm-start.
7173        assert!(match_scrutinee_offset("match c {\n  Ok(", "match c {\n  Ok(".len()).is_none());
7174
7175        // No open brace / no scrutinee → nothing.
7176        assert!(match_scrutinee_offset("match c ", "match c ".len()).is_none());
7177        assert!(match_scrutinee_offset("match {\n  ", "match {\n  ".len()).is_none());
7178    }
7179
7180    // v0.145 (ADR 0169): the `match <expr> { … Variant(<partial>` nested-pattern
7181    // detection that feeds payload-variant completion — the position
7182    // `match_scrutinee_offset` deliberately bails on.
7183    #[test]
7184    fn nested_pattern_offset_locates_the_scrutinee_and_variant() {
7185        // Cursor right inside a variant's payload parens.
7186        let t = "match res {\n  Some(";
7187        let (off, variant) = nested_pattern_offset(t, t.len()).expect("inside a nested pattern");
7188        assert_eq!(&t[off..off + 1], "s"); // last char of `res`
7189        assert_eq!(variant, "Some");
7190
7191        // With a partial nested pattern typed, and a qualifier.
7192        let t = "match res {\n  Ok(Po";
7193        let (off, variant) = nested_pattern_offset(t, t.len()).expect("mid partial");
7194        assert_eq!(&t[off..off + 1], "s");
7195        assert_eq!(variant, "Ok");
7196
7197        // A later arm after a top-level comma.
7198        let t = "match r {\n  Ok(n) => n,\n  Err(";
7199        let (off, variant) = nested_pattern_offset(t, t.len()).expect("later arm");
7200        assert_eq!(&t[off..off + 1], "r");
7201        assert_eq!(variant, "Err");
7202
7203        // A lowercase-led token before `(` is a binding/call, not a variant
7204        // constructor — no nested completion (there is no inner type to open).
7205        assert!(nested_pattern_offset("match r {\n  ok(", "match r {\n  ok(".len()).is_none());
7206
7207        // An arm-start (no open paren) is the flat position, not a nested one.
7208        assert!(nested_pattern_offset("match c {\n  ", "match c {\n  ".len()).is_none());
7209        assert!(nested_pattern_offset("match c {\n  Ok", "match c {\n  Ok".len()).is_none());
7210
7211        // Inside an arm body (after `=>`) is not a pattern position.
7212        let t = "match c {\n  Ok(n) => g(";
7213        assert!(nested_pattern_offset(t, t.len()).is_none());
7214
7215        // A non-`match` block offers nothing.
7216        assert!(nested_pattern_offset("fn f() {\n  h(", "fn f() {\n  h(".len()).is_none());
7217    }
7218
7219    /// A watched-file change on `bynk.toml` is recognised (so the config can be
7220    /// reloaded live), while a sibling `.bynk` file or a merely `…bynk.toml`-
7221    /// suffixed name is not — the name-component match, not a path suffix.
7222    #[test]
7223    fn is_bynk_toml_matches_only_the_manifest() {
7224        // Build URIs from a host-absolute base so `from_file_path` succeeds on
7225        // Windows too (a Unix-style `/proj` path is not absolute there — no
7226        // drive letter — and would fail to convert). Mirrors the sibling
7227        // `find_source_root` test's `CARGO_MANIFEST_DIR` base.
7228        let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
7229        let toml = Url::from_file_path(base.join("bynk.toml")).expect("abs path");
7230        assert!(is_bynk_toml(&toml));
7231        let nested = Url::from_file_path(base.join("sub").join("bynk.toml")).expect("abs path");
7232        assert!(is_bynk_toml(&nested));
7233
7234        // A source file is not the manifest.
7235        let src = Url::from_file_path(base.join("src").join("main.bynk")).expect("abs path");
7236        assert!(!is_bynk_toml(&src));
7237        // A file whose name merely *ends with* `bynk.toml` must not fire.
7238        let decoy = Url::from_file_path(base.join("notbynk.toml")).expect("abs path");
7239        assert!(!is_bynk_toml(&decoy));
7240        // A non-file URI never matches.
7241        let remote = Url::parse("https://example.com/bynk.toml").expect("url");
7242        assert!(!is_bynk_toml(&remote));
7243    }
7244
7245    /// #2100 slice 5: the organise-imports source action is computed only
7246    /// when the client asks for its kind (directly, or via its `source`
7247    /// parent), never for an unfiltered lightbulb request or another kind —
7248    /// and when it is, its edit is the re-checked organise.
7249    #[tokio::test]
7250    async fn organise_imports_is_offered_only_when_asked_for() {
7251        let other = "commons shared.other\n\ntype Other = { n: Int }\n";
7252        let widget = "commons shared.widget\n\ntype Widget = { size: Int }\n";
7253        let app = "commons app.a\n\nuses shared.widget\nuses shared.other\n\nfn area(w: Widget) -> Int {\n  w.size\n}\n";
7254        let s = scratch_project(
7255            "organise_only",
7256            &[
7257                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7258                ("src/shared/other.bynk", other),
7259                ("src/shared/widget.bynk", widget),
7260                ("src/app/a.bynk", app),
7261            ],
7262        );
7263        let backend = backend_at(&s.0).await;
7264        let uri = file_uri(&s.0, "src/app/a.bynk");
7265        open(&backend, &uri, app).await;
7266        backend.run_round().await;
7267
7268        let request = |only: Option<Vec<CodeActionKind>>| CodeActionParams {
7269            text_document: TextDocumentIdentifier { uri: uri.clone() },
7270            range: Range::default(),
7271            context: CodeActionContext {
7272                diagnostics: Vec::new(),
7273                only,
7274                trigger_kind: None,
7275            },
7276            work_done_progress_params: Default::default(),
7277            partial_result_params: Default::default(),
7278        };
7279        let organise_titles = |actions: Vec<CodeActionOrCommand>| -> Vec<String> {
7280            actions
7281                .into_iter()
7282                .filter_map(|a| match a {
7283                    CodeActionOrCommand::CodeAction(a)
7284                        if a.kind == Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS) =>
7285                    {
7286                        Some(a.title)
7287                    }
7288                    _ => None,
7289                })
7290                .collect()
7291        };
7292
7293        for only in [None, Some(vec![CodeActionKind::QUICKFIX])] {
7294            let actions = backend
7295                .code_action(request(only.clone()))
7296                .await
7297                .unwrap()
7298                .unwrap();
7299            assert!(organise_titles(actions).is_empty(), "offered for {only:?}");
7300        }
7301        for only in [
7302            CodeActionKind::SOURCE,
7303            CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
7304        ] {
7305            let actions = backend
7306                .code_action(request(Some(vec![only.clone()])))
7307                .await
7308                .unwrap()
7309                .unwrap();
7310            assert_eq!(actions.len(), 1, "one action for {only:?}");
7311            let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
7312                panic!("expected a CodeAction");
7313            };
7314            assert_eq!(action.kind, Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS));
7315            let Some(DocumentChanges::Edits(doc_edits)) =
7316                &action.edit.as_ref().unwrap().document_changes
7317            else {
7318                panic!("expected versioned document edits");
7319            };
7320            assert_eq!(doc_edits[0].text_document.version, Some(1));
7321            let OneOf::Left(edit) = &doc_edits[0].edits[0] else {
7322                panic!("expected a plain TextEdit");
7323            };
7324            // `shared.other` is unused: the header keeps only `shared.widget`.
7325            assert_eq!(edit.new_text, "uses shared.widget");
7326        }
7327    }
7328
7329    /// #2100 slice 6: fix-all is computed only when asked for (`source`,
7330    /// the generic `source.fixAll` VS Code sends on save, or its own
7331    /// `source.fixAll.bynk`), and carries every machine-applicable fix.
7332    #[tokio::test]
7333    async fn fix_all_is_offered_only_when_asked_for() {
7334        let src = "context app.c\n\ncapability Clock {\n  fn now() -> Effect[Int]\n}\n\nprovides Clock = Sys {\n  fn now() -> Effect[Int] {\n    1\n  }\n}\n\nservice one {\n  on call(x: Int) -> Effect[Int] given Clock {\n    x\n  }\n}\n\nservice two {\n  on call(x: Int) -> Effect[Int] given Clock {\n    x\n  }\n}\n";
7335        let s = scratch_project(
7336            "fix_all_only",
7337            &[
7338                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7339                ("src/app/c.bynk", src),
7340            ],
7341        );
7342        let backend = backend_at(&s.0).await;
7343        let uri = file_uri(&s.0, "src/app/c.bynk");
7344        open(&backend, &uri, src).await;
7345        backend.run_round().await;
7346
7347        let request = |only: Option<Vec<CodeActionKind>>| CodeActionParams {
7348            text_document: TextDocumentIdentifier { uri: uri.clone() },
7349            range: Range::default(),
7350            context: CodeActionContext {
7351                diagnostics: Vec::new(),
7352                only,
7353                trigger_kind: None,
7354            },
7355            work_done_progress_params: Default::default(),
7356            partial_result_params: Default::default(),
7357        };
7358        let fix_all = |actions: Vec<CodeActionOrCommand>| -> Vec<CodeAction> {
7359            actions
7360                .into_iter()
7361                .filter_map(|a| match a {
7362                    CodeActionOrCommand::CodeAction(a)
7363                        if a.kind == Some(crate::fix_all::kind()) =>
7364                    {
7365                        Some(a)
7366                    }
7367                    _ => None,
7368                })
7369                .collect()
7370        };
7371        for only in [None, Some(vec![CodeActionKind::QUICKFIX])] {
7372            let actions = backend
7373                .code_action(request(only.clone()))
7374                .await
7375                .unwrap()
7376                .unwrap();
7377            assert!(fix_all(actions).is_empty(), "offered for {only:?}");
7378        }
7379        for only in [
7380            CodeActionKind::SOURCE,
7381            CodeActionKind::SOURCE_FIX_ALL,
7382            crate::fix_all::kind(),
7383        ] {
7384            let actions = backend
7385                .code_action(request(Some(vec![only.clone()])))
7386                .await
7387                .unwrap()
7388                .unwrap();
7389            let found = fix_all(actions);
7390            assert_eq!(found.len(), 1, "one fix-all for {only:?}");
7391            let Some(DocumentChanges::Edits(doc_edits)) =
7392                &found[0].edit.as_ref().unwrap().document_changes
7393            else {
7394                panic!("expected versioned document edits");
7395            };
7396            // Both handlers' unused `given Clock` go in the one action.
7397            assert_eq!(doc_edits[0].edits.len(), 2);
7398        }
7399    }
7400
7401    /// #2100 slice 3: fill-match-arms is listed on the unfiltered lightbulb
7402    /// request without an edit, and `codeAction/resolve` fills in the
7403    /// re-checked, versioned edit. A resolve for a stale version is refused.
7404    #[tokio::test]
7405    async fn fill_match_arms_lists_then_resolves() {
7406        let src = "commons app.m\n\ntype Shape =\n  | Circle(r: Int)\n  | Dot\n\nfn area(s: Shape) -> Int {\n  match s {\n    Dot => 1,\n  }\n}\n";
7407        let s = scratch_project(
7408            "fill_arms",
7409            &[
7410                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7411                ("src/app/m.bynk", src),
7412            ],
7413        );
7414        let backend = backend_at(&s.0).await;
7415        let uri = file_uri(&s.0, "src/app/m.bynk");
7416        open(&backend, &uri, src).await;
7417        backend.run_round().await;
7418
7419        let at = crate::position::offset_to_position(src, src.find("Dot =>").unwrap());
7420        let actions = backend
7421            .code_action(CodeActionParams {
7422                text_document: TextDocumentIdentifier { uri: uri.clone() },
7423                range: Range { start: at, end: at },
7424                context: CodeActionContext::default(),
7425                work_done_progress_params: Default::default(),
7426                partial_result_params: Default::default(),
7427            })
7428            .await
7429            .unwrap()
7430            .unwrap();
7431        let listed: Vec<CodeAction> = actions
7432            .into_iter()
7433            .filter_map(|a| match a {
7434                CodeActionOrCommand::CodeAction(a) if a.title.starts_with("Fill") => Some(a),
7435                _ => None,
7436            })
7437            .collect();
7438        assert_eq!(listed.len(), 1, "one fill action");
7439        assert!(listed[0].edit.is_none(), "listed without its edit");
7440
7441        let resolved = backend
7442            .code_action_resolve(listed[0].clone())
7443            .await
7444            .unwrap();
7445        let Some(DocumentChanges::Edits(doc_edits)) =
7446            &resolved.edit.as_ref().unwrap().document_changes
7447        else {
7448            panic!("expected versioned document edits");
7449        };
7450        assert_eq!(doc_edits[0].text_document.version, Some(1));
7451        let inserted: String = doc_edits[0]
7452            .edits
7453            .iter()
7454            .map(|e| match e {
7455                OneOf::Left(e) => e.new_text.clone(),
7456                OneOf::Right(e) => e.text_edit.new_text.clone(),
7457            })
7458            .collect();
7459        assert_eq!(inserted, "\n    Circle(_) => 0,");
7460
7461        let mut stale = listed[0].clone();
7462        stale.data.as_mut().unwrap()["version"] = serde_json::json!(0);
7463        assert!(backend.code_action_resolve(stale).await.is_err());
7464
7465        // Any other action resolves to itself.
7466        let other = CodeAction {
7467            title: "x".into(),
7468            ..Default::default()
7469        };
7470        assert_eq!(
7471            backend.code_action_resolve(other.clone()).await.unwrap(),
7472            other
7473        );
7474    }
7475
7476    /// #2100 slice 1: inline-variable is listed on the unfiltered lightbulb
7477    /// request at a `let`'s name without an edit, and `codeAction/resolve`
7478    /// fills in the re-checked, versioned edit.
7479    #[tokio::test]
7480    async fn inline_variable_lists_then_resolves() {
7481        let src = "commons app.m\n\nfn f(a: Int) -> Int {\n  let y = a + 1\n  y * 3\n}\n";
7482        let s = scratch_project(
7483            "inline_var",
7484            &[
7485                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7486                ("src/app/m.bynk", src),
7487            ],
7488        );
7489        let backend = backend_at(&s.0).await;
7490        let uri = file_uri(&s.0, "src/app/m.bynk");
7491        open(&backend, &uri, src).await;
7492        backend.run_round().await;
7493
7494        let at = crate::position::offset_to_position(src, src.find("y =").unwrap());
7495        let actions = backend
7496            .code_action(CodeActionParams {
7497                text_document: TextDocumentIdentifier { uri: uri.clone() },
7498                range: Range { start: at, end: at },
7499                context: CodeActionContext::default(),
7500                work_done_progress_params: Default::default(),
7501                partial_result_params: Default::default(),
7502            })
7503            .await
7504            .unwrap()
7505            .unwrap();
7506        let listed: Vec<CodeAction> = actions
7507            .into_iter()
7508            .filter_map(|a| match a {
7509                CodeActionOrCommand::CodeAction(a)
7510                    if a.kind == Some(CodeActionKind::REFACTOR_INLINE) =>
7511                {
7512                    Some(a)
7513                }
7514                _ => None,
7515            })
7516            .collect();
7517        assert_eq!(listed.len(), 1, "one inline action");
7518        assert_eq!(listed[0].title, "Inline `y`");
7519        assert!(listed[0].edit.is_none());
7520
7521        let resolved = backend
7522            .code_action_resolve(listed[0].clone())
7523            .await
7524            .unwrap();
7525        let Some(DocumentChanges::Edits(doc_edits)) =
7526            &resolved.edit.as_ref().unwrap().document_changes
7527        else {
7528            panic!("expected versioned document edits");
7529        };
7530        assert_eq!(doc_edits[0].text_document.version, Some(1));
7531        let texts: Vec<String> = doc_edits[0]
7532            .edits
7533            .iter()
7534            .map(|e| match e {
7535                OneOf::Left(e) => e.new_text.clone(),
7536                OneOf::Right(e) => e.text_edit.new_text.clone(),
7537            })
7538            .collect();
7539        // Not canonical (two-space indent), so the raw edits apply: the `let`
7540        // line is deleted and the use replaced.
7541        assert_eq!(texts, vec![String::new(), "(a + 1)".to_string()]);
7542
7543        let mut stale = listed[0].clone();
7544        stale.data.as_mut().unwrap()["version"] = serde_json::json!(0);
7545        assert!(backend.code_action_resolve(stale).await.is_err());
7546    }
7547
7548    /// #2100 slice 4: a provider stub is listed on the unfiltered lightbulb
7549    /// request at a capability's name without an edit, and
7550    /// `codeAction/resolve` fills in the re-checked, versioned edit.
7551    #[tokio::test]
7552    async fn provider_stub_lists_then_resolves() {
7553        let src = "context app.c\n\ncapability Clock {\n  fn now() -> Effect[Int]\n}\n";
7554        let s = scratch_project(
7555            "provider_stub",
7556            &[
7557                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7558                ("src/app/c.bynk", src),
7559            ],
7560        );
7561        let backend = backend_at(&s.0).await;
7562        let uri = file_uri(&s.0, "src/app/c.bynk");
7563        open(&backend, &uri, src).await;
7564        backend.run_round().await;
7565
7566        let at = crate::position::offset_to_position(src, src.find("Clock").unwrap());
7567        let actions = backend
7568            .code_action(CodeActionParams {
7569                text_document: TextDocumentIdentifier { uri: uri.clone() },
7570                range: Range { start: at, end: at },
7571                context: CodeActionContext::default(),
7572                work_done_progress_params: Default::default(),
7573                partial_result_params: Default::default(),
7574            })
7575            .await
7576            .unwrap()
7577            .unwrap();
7578        let listed: Vec<CodeAction> = actions
7579            .into_iter()
7580            .filter_map(|a| match a {
7581                CodeActionOrCommand::CodeAction(a) if a.title.starts_with("Generate provider") => {
7582                    Some(a)
7583                }
7584                _ => None,
7585            })
7586            .collect();
7587        assert_eq!(listed.len(), 1, "one stub action");
7588        assert_eq!(listed[0].kind, Some(CodeActionKind::REFACTOR));
7589        assert!(listed[0].edit.is_none());
7590
7591        let resolved = backend
7592            .code_action_resolve(listed[0].clone())
7593            .await
7594            .unwrap();
7595        let Some(DocumentChanges::Edits(doc_edits)) =
7596            &resolved.edit.as_ref().unwrap().document_changes
7597        else {
7598            panic!("expected versioned document edits");
7599        };
7600        assert_eq!(doc_edits[0].text_document.version, Some(1));
7601        let OneOf::Left(edit) = &doc_edits[0].edits[0] else {
7602            panic!("expected a plain TextEdit");
7603        };
7604        assert_eq!(
7605            edit.new_text,
7606            "\n\nprovides Clock = ClockImpl {\n  fn now() -> Effect[Int] {\n    0\n  }\n}"
7607        );
7608
7609        let mut stale = listed[0].clone();
7610        stale.data.as_mut().unwrap()["version"] = serde_json::json!(0);
7611        assert!(backend.code_action_resolve(stale).await.is_err());
7612    }
7613
7614    /// #2100 slice 2: extract-function is listed without an edit and
7615    /// resolved (re-checked) lazily; a capability-using selection gets a
7616    /// disabled action with its reason, only for a client that shows those.
7617    #[tokio::test]
7618    async fn extract_function_lists_then_resolves_or_says_why_not() {
7619        let src = "context app.m\n\ncapability Clock {\n  fn now() -> Effect[Int]\n}\n\nprovides Clock = Sys {\n  fn now() -> Effect[Int] {\n    1\n  }\n}\n\nservice s {\n  on call(x: Int) -> Effect[Int] given Clock {\n    let n <- Clock.now()\n    let y = x * 2 + 1\n    n + y\n  }\n}\n";
7620        let s = scratch_project(
7621            "extract_fn",
7622            &[
7623                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7624                ("src/app/m.bynk", src),
7625            ],
7626        );
7627        let backend = backend_at(&s.0).await;
7628        let uri = file_uri(&s.0, "src/app/m.bynk");
7629        open(&backend, &uri, src).await;
7630        backend.run_round().await;
7631
7632        let request = |sel: &str| {
7633            let start = src.find(sel).unwrap();
7634            CodeActionParams {
7635                text_document: TextDocumentIdentifier { uri: uri.clone() },
7636                range: Range {
7637                    start: crate::position::offset_to_position(src, start),
7638                    end: crate::position::offset_to_position(src, start + sel.len()),
7639                },
7640                context: CodeActionContext::default(),
7641                work_done_progress_params: Default::default(),
7642                partial_result_params: Default::default(),
7643            }
7644        };
7645        let extract_fn = |actions: Vec<CodeActionOrCommand>| -> Vec<CodeAction> {
7646            actions
7647                .into_iter()
7648                .filter_map(|a| match a {
7649                    CodeActionOrCommand::CodeAction(a)
7650                        if a.title.starts_with("Extract function") =>
7651                    {
7652                        Some(a)
7653                    }
7654                    _ => None,
7655                })
7656                .collect()
7657        };
7658
7659        let listed = extract_fn(
7660            backend
7661                .code_action(request("x * 2 + 1"))
7662                .await
7663                .unwrap()
7664                .unwrap(),
7665        );
7666        assert_eq!(listed.len(), 1);
7667        assert!(listed[0].edit.is_none());
7668        let resolved = backend
7669            .code_action_resolve(listed[0].clone())
7670            .await
7671            .unwrap();
7672        let Some(DocumentChanges::Edits(doc_edits)) =
7673            &resolved.edit.as_ref().unwrap().document_changes
7674        else {
7675            panic!("expected versioned document edits");
7676        };
7677        assert_eq!(doc_edits[0].text_document.version, Some(1));
7678        assert_eq!(doc_edits[0].edits.len(), 2);
7679
7680        // Without `disabledSupport` a capability-using selection lists nothing.
7681        let refused = extract_fn(
7682            backend
7683                .code_action(request("Clock.now()"))
7684                .await
7685                .unwrap()
7686                .unwrap(),
7687        );
7688        assert!(refused.is_empty());
7689        // With it, a disabled action says why.
7690        backend.state.write().await.supports_code_action_disabled = true;
7691        let refused = extract_fn(
7692            backend
7693                .code_action(request("Clock.now()"))
7694                .await
7695                .unwrap()
7696                .unwrap(),
7697        );
7698        assert_eq!(refused.len(), 1);
7699        let reason = &refused[0].disabled.as_ref().expect("disabled").reason;
7700        assert!(reason.contains("capability-free"), "{reason}");
7701    }
7702
7703    /// #2117 review: fix-all is withheld when its combined edit fails the
7704    /// DECISION B re-check. The committed round's diagnostics are replaced
7705    /// with one machine-applicable suggestion: a harmless one is offered, one
7706    /// that introduces a type error is not.
7707    #[tokio::test]
7708    async fn fix_all_is_withheld_when_the_recheck_fails() {
7709        use bynk_syntax::error::{Applicability, CompileError};
7710        let src = "commons app.m\n\nfn f() -> Int {\n  1\n}\n";
7711        let s = scratch_project(
7712            "fix_all_gate",
7713            &[
7714                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7715                ("src/app/m.bynk", src),
7716            ],
7717        );
7718        let backend = backend_at(&s.0).await;
7719        let uri = file_uri(&s.0, "src/app/m.bynk");
7720        open(&backend, &uri, src).await;
7721        backend.run_round().await;
7722
7723        let one = src.find("  1\n").unwrap() + 2;
7724        let one = bynk_syntax::span::Span::new(one, one + 1);
7725        // Swap the round's diagnostics for one carrying `replacement` as a
7726        // machine-applicable fix of the `1` body. It reuses a registered code:
7727        // the diagnostics registry rejects any `bynk.*` literal it doesn't
7728        // list.
7729        let inject = |replacement: &'static str| {
7730            let backend = backend.clone();
7731            async move {
7732                let root = backend.test_root().await;
7733                let mut state = backend.state.write().await;
7734                let project = state.projects.get_mut(&root).unwrap();
7735                let analysis = Arc::get_mut(project.analysis.as_mut().unwrap())
7736                    .expect("the round is not shared");
7737                let rel = analysis.snapshots.keys().next().unwrap().clone();
7738                let error = CompileError::new("bynk.given.unused_capability", one, "injected")
7739                    .with_suggestion(
7740                        "replace",
7741                        vec![(one, replacement.to_string())],
7742                        Applicability::MachineApplicable,
7743                    );
7744                analysis.diagnostics.insert(
7745                    rel,
7746                    vec![bynk_ide::Diagnostic {
7747                        severity: bynk_syntax::Severity::Warning,
7748                        label_files: Vec::new(),
7749                        error,
7750                    }],
7751                );
7752            }
7753        };
7754        let fix_all_count = || {
7755            let backend = backend.clone();
7756            let uri = uri.clone();
7757            async move {
7758                backend
7759                    .code_action(CodeActionParams {
7760                        text_document: TextDocumentIdentifier { uri },
7761                        range: Range::default(),
7762                        context: CodeActionContext {
7763                            diagnostics: Vec::new(),
7764                            only: Some(vec![crate::fix_all::kind()]),
7765                            trigger_kind: None,
7766                        },
7767                        work_done_progress_params: Default::default(),
7768                        partial_result_params: Default::default(),
7769                    })
7770                    .await
7771                    .unwrap()
7772                    .unwrap()
7773                    .len()
7774            }
7775        };
7776
7777        inject("2").await;
7778        assert_eq!(fix_all_count().await, 1, "a clean fix is offered");
7779        inject("\"one\"").await;
7780        assert_eq!(fix_all_count().await, 0, "a breaking fix is withheld");
7781    }
7782
7783    /// The v0.26 capability advertisements — the "trivial unit check" the
7784    /// proposal scopes in place of a transport round-trip.
7785    #[test]
7786    fn advertises_code_actions_and_the_index_riders() {
7787        let caps = server_capabilities();
7788        let Some(CodeActionProviderCapability::Options(opts)) = caps.code_action_provider else {
7789            panic!("codeActionProvider not advertised with options");
7790        };
7791        assert_eq!(
7792            opts.code_action_kinds,
7793            Some(vec![
7794                CodeActionKind::QUICKFIX,
7795                CodeActionKind::REFACTOR,
7796                CodeActionKind::REFACTOR_EXTRACT,
7797                CodeActionKind::REFACTOR_INLINE,
7798                CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
7799                CodeActionKind::new("source.fixAll.bynk"),
7800            ])
7801        );
7802        assert_eq!(
7803            opts.resolve_provider,
7804            Some(true),
7805            "fill-arms resolves lazily"
7806        );
7807        assert!(matches!(
7808            caps.workspace_symbol_provider,
7809            Some(OneOf::Left(true))
7810        ));
7811        assert!(matches!(
7812            caps.document_highlight_provider,
7813            Some(OneOf::Left(true))
7814        ));
7815    }
7816
7817    /// The v0.27 capability advertisement — the "trivial unit check" the
7818    /// proposal scopes in place of a transport round-trip.
7819    #[test]
7820    fn advertises_save_notifications() {
7821        // `diagnostics_mode = "on_save"` is driven by `didSave`; the sync
7822        // options must opt in explicitly or clients may not send it (#513).
7823        let caps = server_capabilities();
7824        let Some(TextDocumentSyncCapability::Options(opts)) = caps.text_document_sync else {
7825            panic!("textDocumentSync not advertised with options");
7826        };
7827        assert_eq!(opts.change, Some(TextDocumentSyncKind::FULL));
7828        assert!(matches!(
7829            opts.save,
7830            Some(TextDocumentSyncSaveOptions::Supported(true))
7831        ));
7832    }
7833
7834    #[test]
7835    fn advertises_inlay_hints() {
7836        let caps = server_capabilities();
7837        assert!(matches!(caps.inlay_hint_provider, Some(OneOf::Left(true))));
7838    }
7839
7840    /// Slice 6: go-to-type-definition (value → its type's declaration).
7841    #[test]
7842    fn advertises_type_definition() {
7843        let caps = server_capabilities();
7844        assert!(matches!(
7845            caps.type_definition_provider,
7846            Some(TypeDefinitionProviderCapability::Simple(true))
7847        ));
7848    }
7849
7850    /// Slice 6b: `uses`/`consumes` document links.
7851    #[test]
7852    fn advertises_document_links() {
7853        let caps = server_capabilities();
7854        assert!(caps.document_link_provider.is_some());
7855    }
7856
7857    /// #302: `willRenameFiles` over `.bynk` files; #2105: and every folder.
7858    #[test]
7859    fn advertises_will_rename_files() {
7860        let caps = server_capabilities();
7861        let file_ops = caps
7862            .workspace
7863            .as_ref()
7864            .and_then(|w| w.file_operations.as_ref())
7865            .expect("workspace.fileOperations advertised");
7866        let will_rename = file_ops
7867            .will_rename
7868            .as_ref()
7869            .expect("willRename registered");
7870        let filter = &will_rename.filters[0];
7871        assert_eq!(filter.pattern.glob, "**/*.bynk");
7872        assert_eq!(filter.pattern.matches, Some(FileOperationPatternKind::File));
7873        let folders = &will_rename.filters[1];
7874        assert_eq!(folders.scheme.as_deref(), Some("file"));
7875        assert_eq!(folders.pattern.glob, "**");
7876        assert_eq!(
7877            folders.pattern.matches,
7878            Some(FileOperationPatternKind::Folder)
7879        );
7880    }
7881
7882    /// #2098: `bynk/explain` is advertised under `experimental.explain`.
7883    #[test]
7884    fn advertises_the_explain_request() {
7885        let caps = server_capabilities();
7886        let experimental = caps.experimental.expect("experimental advertised");
7887        assert_eq!(experimental.get("explain"), Some(&serde_json::json!(true)));
7888    }
7889
7890    /// #2098: `initialize` records whether the client runs the explain command.
7891    #[tokio::test]
7892    async fn initialize_records_the_explain_command_capability() {
7893        for (experimental, expected) in [
7894            (None, false),
7895            (
7896                Some(serde_json::json!({ "explainDiagnosticCommand": true })),
7897                true,
7898            ),
7899        ] {
7900            let backend = bare_backend().await;
7901            backend
7902                .initialize(InitializeParams {
7903                    capabilities: ClientCapabilities {
7904                        experimental,
7905                        ..Default::default()
7906                    },
7907                    ..Default::default()
7908                })
7909                .await
7910                .expect("initialize");
7911            assert_eq!(
7912                backend.state.read().await.supports_explain_command,
7913                expected
7914            );
7915        }
7916    }
7917
7918    /// #2098: a curated diagnostic in range gets one "Explain …" quick fix,
7919    /// carrying the client command and the code, placed after every real fix
7920    /// and never preferred — but only for a client that runs the command.
7921    #[tokio::test]
7922    async fn code_action_offers_explain_for_a_curated_code_when_the_client_supports_it() {
7923        let src = "commons demo.a\n\nfn f(u: Usr) -> Int {\n  1\n}\n";
7924        let s = scratch_project(
7925            "explain_action",
7926            &[
7927                ("bynk.toml", "[project]\nname=\"demo\"\n"),
7928                ("src/demo/a.bynk", src),
7929            ],
7930        );
7931        let backend = backend_at(&s.0).await;
7932        let uri = file_uri(&s.0, "src/demo/a.bynk");
7933        backend.run_round().await;
7934        let at = crate::position::offset_to_position(src, src.find("Usr").unwrap());
7935        let request = || CodeActionParams {
7936            text_document: TextDocumentIdentifier { uri: uri.clone() },
7937            range: Range::new(at, at),
7938            context: CodeActionContext::default(),
7939            work_done_progress_params: Default::default(),
7940            partial_result_params: Default::default(),
7941        };
7942        let titles = |actions: &CodeActionResponse| -> Vec<String> {
7943            actions
7944                .iter()
7945                .map(|a| match a {
7946                    CodeActionOrCommand::CodeAction(c) => c.title.clone(),
7947                    CodeActionOrCommand::Command(c) => c.title.clone(),
7948                })
7949                .collect()
7950        };
7951        let explain_title = "Explain `bynk.resolve.unknown_type`";
7952
7953        // Without the client capability: no explain action.
7954        let without = backend.code_action(request()).await.unwrap().unwrap();
7955        assert!(
7956            !titles(&without).iter().any(|t| t == explain_title),
7957            "{:?}",
7958            titles(&without)
7959        );
7960
7961        backend.state.write().await.supports_explain_command = true;
7962        let with = backend.code_action(request()).await.unwrap().unwrap();
7963        let names = titles(&with);
7964        let pos = names
7965            .iter()
7966            .position(|t| t == explain_title)
7967            .unwrap_or_else(|| panic!("explain action offered: {names:?}"));
7968        assert_eq!(pos, names.len() - 1, "explain comes last: {names:?}");
7969        let CodeActionOrCommand::CodeAction(action) = &with[pos] else {
7970            panic!("a CodeAction");
7971        };
7972        assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
7973        assert_eq!(action.is_preferred, Some(false));
7974        assert!(action.edit.is_none());
7975        let cmd = action.command.as_ref().expect("carries a command");
7976        assert_eq!(cmd.command, "bynk.explainDiagnostic");
7977        assert_eq!(
7978            cmd.arguments,
7979            Some(vec![serde_json::json!("bynk.resolve.unknown_type")])
7980        );
7981    }
7982
7983    /// #2098: the request itself, through the backend handler.
7984    #[tokio::test]
7985    async fn explain_request_serves_curated_and_declines_the_rest() {
7986        let backend = bare_backend().await;
7987        let ask = |code: &str| explain_request::ExplainParams { code: code.into() };
7988        let curated = backend
7989            .explain(ask("bynk.resolve.unknown_type"))
7990            .await
7991            .unwrap()
7992            .expect("curated");
7993        assert_eq!(curated.title, "bynk.resolve.unknown_type");
7994        assert!(
7995            backend
7996                .explain(ask("bynk.resolve.duplicate_type"))
7997                .await
7998                .unwrap()
7999                .is_none()
8000        );
8001        assert!(backend.explain(ask("nonsense")).await.unwrap().is_none());
8002    }
8003
8004    /// Slice 5: completion advertises `.` triggers and lazy doc resolution.
8005    #[test]
8006    fn advertises_completion_with_dot_trigger_and_resolve() {
8007        let caps = server_capabilities();
8008        let opts = caps.completion_provider.expect("completion advertised");
8009        assert_eq!(opts.resolve_provider, Some(true), "resolve_provider");
8010        assert!(
8011            opts.trigger_characters
8012                .as_deref()
8013                .is_some_and(|t| t.iter().any(|c| c == ".")),
8014            "`.` trigger char"
8015        );
8016    }
8017
8018    /// The v0.28 capability advertisement: full + range with the frozen
8019    /// legend (the legend's content is pinned in `index_queries`).
8020    #[test]
8021    fn advertises_semantic_tokens() {
8022        let caps = server_capabilities();
8023        let Some(SemanticTokensServerCapabilities::SemanticTokensOptions(opts)) =
8024            caps.semantic_tokens_provider
8025        else {
8026            panic!("semanticTokensProvider not advertised with options");
8027        };
8028        assert_eq!(opts.full, Some(SemanticTokensFullOptions::Bool(true)));
8029        assert_eq!(opts.range, Some(true));
8030        assert_eq!(opts.legend, crate::index_queries::semantic_tokens_legend());
8031    }
8032
8033    // ---- Slice D: per-workspace state (Q4) ----
8034
8035    /// A backend with **no** seeded project — the real lazy-discovery flow,
8036    /// where `did_open` and requests create entries by routing (`resolve_root`).
8037    async fn bare_backend() -> Backend {
8038        let (service, _socket) = tower_lsp::LspService::new(Backend::new);
8039        service.inner().clone()
8040    }
8041
8042    fn file_uri(root: &std::path::Path, rel: &str) -> Url {
8043        let abs = root.join(rel);
8044        Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
8045    }
8046
8047    async fn set_folders(backend: &Backend, roots: &[&std::path::Path]) {
8048        backend.state.write().await.folders = roots
8049            .iter()
8050            .map(|r| r.canonicalize().unwrap_or_else(|_| r.to_path_buf()))
8051            .collect();
8052    }
8053
8054    async fn open(backend: &Backend, uri: &Url, text: &str) {
8055        backend
8056            .did_open(DidOpenTextDocumentParams {
8057                text_document: TextDocumentItem {
8058                    uri: uri.clone(),
8059                    language_id: "bynk".into(),
8060                    version: 1,
8061                    text: text.to_string(),
8062                },
8063            })
8064            .await;
8065    }
8066
8067    fn snapshot_keys(a: &Analysis) -> Vec<String> {
8068        let mut keys: Vec<String> = a
8069            .snapshots
8070            .keys()
8071            .map(|p| p.to_string_lossy().replace('\\', "/"))
8072            .collect();
8073        keys.sort();
8074        keys
8075    }
8076
8077    /// Two `bynk.toml` projects under **one** workspace folder are two projects
8078    /// (Q4: route by discovered root, not folder). Opening a file in each creates
8079    /// its own entry, and each analyses **only its own** tree — the overlay
8080    /// isolation guard, too: project A's round never sees project B's file.
8081    #[tokio::test]
8082    async fn two_projects_under_one_folder_are_two_projects() {
8083        let ax_src = "commons a.x\n\nfn ax(n: Int) -> Int {\n  n\n}\n";
8084        let by_src = "commons b.y\n\nfn by(n: Int) -> Int {\n  n\n}\n";
8085        let s = scratch_project(
8086            "d_two",
8087            &[
8088                ("a/bynk.toml", "[project]\nname=\"a\"\n"),
8089                ("a/src/x.bynk", ax_src),
8090                ("b/bynk.toml", "[project]\nname=\"b\"\n"),
8091                ("b/src/y.bynk", by_src),
8092            ],
8093        );
8094        let backend = bare_backend().await;
8095        set_folders(&backend, &[&s.0]).await;
8096        let ax = file_uri(&s.0, "a/src/x.bynk");
8097        let by = file_uri(&s.0, "b/src/y.bynk");
8098        open(&backend, &ax, ax_src).await;
8099        open(&backend, &by, by_src).await;
8100
8101        assert_ne!(
8102            Backend::root_for_uri_uncached(&ax).unwrap(),
8103            Backend::root_for_uri_uncached(&by).unwrap(),
8104            "the two files resolve to different project roots",
8105        );
8106        assert_eq!(
8107            backend.state.read().await.projects.len(),
8108            2,
8109            "one entry per project, not one for the shared folder",
8110        );
8111
8112        let a = backend.analysis_for(&ax).await.expect("A analysed");
8113        let b = backend.analysis_for(&by).await.expect("B analysed");
8114        assert_eq!(
8115            snapshot_keys(&a),
8116            vec!["src/x.bynk"],
8117            "A sees only A's file"
8118        );
8119        assert_eq!(
8120            snapshot_keys(&b),
8121            vec!["src/y.bynk"],
8122            "B sees only B's file"
8123        );
8124    }
8125
8126    /// Q4 lifecycle: `did_change_workspace_folders` removing a folder with **no
8127    /// open buffer** prunes the idle project entry and clears nothing it must
8128    /// keep. Routing no longer resolves it because the seed is gone.
8129    #[tokio::test]
8130    async fn removing_a_folder_prunes_an_idle_project() {
8131        let a = "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n";
8132        let s = scratch_project(
8133            "d_prune",
8134            &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
8135        );
8136        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8137        let backend = bare_backend().await;
8138        set_folders(&backend, &[&s.0]).await;
8139        let uri = file_uri(&s.0, "src/a.bynk");
8140        open(&backend, &uri, a).await;
8141        backend.analysis_for(&uri).await.expect("analysed");
8142        // Close the buffer, so nothing but the folder pins the project.
8143        backend
8144            .did_close(DidCloseTextDocumentParams {
8145                text_document: TextDocumentIdentifier { uri: uri.clone() },
8146            })
8147            .await;
8148        assert_eq!(backend.state.read().await.projects.len(), 1);
8149
8150        backend
8151            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8152                event: WorkspaceFoldersChangeEvent {
8153                    added: vec![],
8154                    removed: vec![WorkspaceFolder {
8155                        uri: Url::from_file_path(&folder).unwrap(),
8156                        name: "p".into(),
8157                    }],
8158                },
8159            })
8160            .await;
8161        assert!(
8162            backend.state.read().await.projects.is_empty(),
8163            "an idle project is pruned when its last covering folder is removed",
8164        );
8165    }
8166
8167    /// Q4 lifecycle: a project that still holds an **open buffer** survives folder
8168    /// removal — routing needs it until the buffer closes.
8169    #[tokio::test]
8170    async fn removing_a_folder_retains_a_project_with_an_open_buffer() {
8171        let a = "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n";
8172        let s = scratch_project(
8173            "d_retain",
8174            &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
8175        );
8176        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8177        let backend = bare_backend().await;
8178        set_folders(&backend, &[&s.0]).await;
8179        let uri = file_uri(&s.0, "src/a.bynk");
8180        open(&backend, &uri, a).await; // buffer stays open
8181
8182        backend
8183            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8184                event: WorkspaceFoldersChangeEvent {
8185                    added: vec![],
8186                    removed: vec![WorkspaceFolder {
8187                        uri: Url::from_file_path(&folder).unwrap(),
8188                        name: "p".into(),
8189                    }],
8190                },
8191            })
8192            .await;
8193        assert_eq!(
8194            backend.state.read().await.projects.len(),
8195            1,
8196            "a project with an open buffer must survive folder removal",
8197        );
8198        assert!(
8199            backend.analysis_for(&uri).await.is_some(),
8200            "and it must still answer requests",
8201        );
8202    }
8203
8204    /// Q4 §C: closing the **last** buffer of a project whose folder was already
8205    /// removed prunes it — the mirror of the folder path. Without it the project
8206    /// lingers forever with published diagnostics no folder or buffer justifies.
8207    #[tokio::test]
8208    async fn closing_the_last_buffer_of_a_folder_removed_project_prunes_it() {
8209        let a = "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n";
8210        let s = scratch_project(
8211            "d_close_prune",
8212            &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
8213        );
8214        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8215        let backend = bare_backend().await;
8216        set_folders(&backend, &[&s.0]).await;
8217        let uri = file_uri(&s.0, "src/a.bynk");
8218        open(&backend, &uri, a).await;
8219        backend.analysis_for(&uri).await.expect("analysed");
8220
8221        // Remove the folder while the buffer is open — retained (its buffer pins it).
8222        backend
8223            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8224                event: WorkspaceFoldersChangeEvent {
8225                    added: vec![],
8226                    removed: vec![WorkspaceFolder {
8227                        uri: Url::from_file_path(&folder).unwrap(),
8228                        name: "p".into(),
8229                    }],
8230                },
8231            })
8232            .await;
8233        assert_eq!(
8234            backend.state.read().await.projects.len(),
8235            1,
8236            "retained while its buffer is open",
8237        );
8238
8239        // Close the last buffer — now fully orphaned (no folder, no buffer).
8240        backend
8241            .did_close(DidCloseTextDocumentParams {
8242                text_document: TextDocumentIdentifier { uri: uri.clone() },
8243            })
8244            .await;
8245        assert!(
8246            backend.state.read().await.projects.is_empty(),
8247            "closing the last buffer of a folder-removed project must prune it",
8248        );
8249    }
8250
8251    /// Q4: a rename spans **one** project — a stale buffer in another project must
8252    /// not block it (`analysis_covering_open_buffers` is per-project). Under a
8253    /// whole-server gate, B's dirty buffer would refuse A's rename.
8254    #[tokio::test]
8255    async fn a_rename_in_one_project_ignores_a_dirty_buffer_in_another() {
8256        let a_src = "commons a.x\n\ntype Money = Int where Positive\n\nfn charge(m: Money) -> Money {\n  m\n}\n";
8257        let b_src = "commons b.y\n\nfn by(n: Int) -> Int {\n  n\n}\n";
8258        let s = scratch_project(
8259            "d_rename_iso",
8260            &[
8261                ("a/bynk.toml", "[project]\nname=\"a\"\n"),
8262                ("a/src/x.bynk", a_src),
8263                ("b/bynk.toml", "[project]\nname=\"b\"\n"),
8264                ("b/src/y.bynk", b_src),
8265            ],
8266        );
8267        let backend = bare_backend().await;
8268        set_folders(&backend, &[&s.0]).await;
8269        let ax = file_uri(&s.0, "a/src/x.bynk");
8270        let by = file_uri(&s.0, "b/src/y.bynk");
8271        open(&backend, &ax, a_src).await;
8272        open(&backend, &by, b_src).await;
8273        backend.analysis_for(&ax).await.expect("A analysed");
8274        backend.analysis_for(&by).await.expect("B analysed");
8275
8276        // Make B's buffer dirty (version 2, not yet re-analysed).
8277        backend
8278            .did_change(DidChangeTextDocumentParams {
8279                text_document: VersionedTextDocumentIdentifier {
8280                    uri: by.clone(),
8281                    version: 2,
8282                },
8283                content_changes: vec![TextDocumentContentChangeEvent {
8284                    range: None,
8285                    range_length: None,
8286                    text: format!("\n{b_src}"),
8287                }],
8288            })
8289            .await;
8290
8291        // Rename `Money` in A — must succeed despite B being dirty.
8292        let off = a_src.find("Money").unwrap();
8293        let pos = crate::position::offset_to_position(a_src, off);
8294        let edit = backend
8295            .rename(RenameParams {
8296                text_document_position: TextDocumentPositionParams {
8297                    text_document: TextDocumentIdentifier { uri: ax.clone() },
8298                    position: pos,
8299                },
8300                new_name: "Amount".into(),
8301                work_done_progress_params: Default::default(),
8302            })
8303            .await
8304            .expect("rename must not error");
8305        assert!(
8306            edit.is_some(),
8307            "a rename in project A must not be blocked by a dirty buffer in project B",
8308        );
8309    }
8310
8311    // ---- Slice E: startup analysis & dynamic watchers ----
8312
8313    /// `initialize` captures the client's `didChangeWatchedFiles` dynamic-
8314    /// registration support, which gates the server-side watcher registration.
8315    #[tokio::test]
8316    async fn initialize_captures_the_dynamic_watcher_capability() {
8317        let backend = bare_backend().await;
8318        let params = InitializeParams {
8319            capabilities: ClientCapabilities {
8320                workspace: Some(WorkspaceClientCapabilities {
8321                    did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
8322                        dynamic_registration: Some(true),
8323                        relative_pattern_support: None,
8324                    }),
8325                    ..Default::default()
8326                }),
8327                ..Default::default()
8328            },
8329            ..Default::default()
8330        };
8331        backend.initialize(params).await.expect("initialize");
8332        assert!(
8333            backend.state.read().await.supports_dynamic_watchers,
8334            "the client's dynamic-registration support must be captured for `initialized`",
8335        );
8336    }
8337
8338    /// #733: `initialize` captures each pull-based decoration's `refresh_support`
8339    /// independently — the flag gates whether a committed round nudges the client
8340    /// to re-pull that decoration. The three `and_then` chains are easy to
8341    /// mis-wire (a swapped field reads the wrong capability), so pin each: two
8342    /// advertised, one withheld, one whole family absent.
8343    #[tokio::test]
8344    async fn initialize_captures_each_decoration_refresh_capability() {
8345        let backend = bare_backend().await;
8346        let params = InitializeParams {
8347            capabilities: ClientCapabilities {
8348                workspace: Some(WorkspaceClientCapabilities {
8349                    // Semantic tokens: advertised.
8350                    semantic_tokens: Some(SemanticTokensWorkspaceClientCapabilities {
8351                        refresh_support: Some(true),
8352                    }),
8353                    // Inlay hints: explicitly withheld.
8354                    inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
8355                        refresh_support: Some(false),
8356                    }),
8357                    // Code lens: the whole family absent (no capability at all).
8358                    ..Default::default()
8359                }),
8360                ..Default::default()
8361            },
8362            ..Default::default()
8363        };
8364        backend.initialize(params).await.expect("initialize");
8365        let refresh = backend.state.read().await.supports_refresh;
8366        assert!(
8367            refresh.semantic_tokens,
8368            "semantic tokens: advertised → true"
8369        );
8370        assert!(!refresh.inlay_hints, "inlay hints: withheld → false");
8371        assert!(!refresh.code_lens, "code lens: absent → false");
8372    }
8373
8374    /// #2207: a workspace root's `bynk.toml` isn't a project; its members are.
8375    #[tokio::test]
8376    async fn discover_projects_under_skips_a_workspace_root() {
8377        let s = scratch_project(
8378            "e_discover_workspace",
8379            &[
8380                ("bynk.toml", "[workspace]\nmembers = [\"packages/*\"]\n"),
8381                ("packages/a/bynk.toml", "[package]\nname=\"acme/a\"\n"),
8382                ("packages/a/src/x.bynk", "commons a.x\n"),
8383            ],
8384        );
8385        let roots = Backend::discover_projects_under(&s.0);
8386        let names: Vec<String> = roots
8387            .iter()
8388            .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
8389            .collect();
8390        assert_eq!(names, vec!["a"], "the member, not the root; got {roots:?}");
8391    }
8392
8393    /// The discovery walk finds every nested `bynk.toml` project under a folder
8394    /// (a monorepo), and skips the caches it must never descend.
8395    #[tokio::test]
8396    async fn discover_projects_under_finds_nested_projects_and_skips_caches() {
8397        let s = scratch_project(
8398            "e_discover",
8399            &[
8400                ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
8401                ("packages/a/src/x.bynk", "commons a.x\n"),
8402                ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
8403                ("packages/b/src/y.bynk", "commons b.y\n"),
8404                // A manifest under a skipped dir must NOT be discovered.
8405                ("node_modules/dep/bynk.toml", "[project]\nname=\"dep\"\n"),
8406            ],
8407        );
8408        let mut roots = Backend::discover_projects_under(&s.0);
8409        roots.sort();
8410        let names: Vec<String> = roots
8411            .iter()
8412            .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
8413            .collect();
8414        assert_eq!(
8415            names,
8416            vec!["a", "b"],
8417            "both monorepo projects found, node_modules skipped; got {roots:?}",
8418        );
8419    }
8420
8421    /// Startup analysis: `initialized` warms every project under the workspace
8422    /// folders — creating each entry so diagnostics/features are ready — **with
8423    /// no `did_open`**. This is spec §2.3's documented startup analysis.
8424    #[tokio::test]
8425    async fn initialized_warms_every_project_under_the_folders() {
8426        let s = scratch_project(
8427            "e_warm",
8428            &[
8429                ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
8430                (
8431                    "packages/a/src/x.bynk",
8432                    "commons a.x\n\nfn ax(n: Int) -> Int {\n  n\n}\n",
8433                ),
8434                ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
8435                (
8436                    "packages/b/src/y.bynk",
8437                    "commons b.y\n\nfn by(n: Int) -> Int {\n  n\n}\n",
8438                ),
8439            ],
8440        );
8441        let backend = bare_backend().await;
8442        set_folders(&backend, &[&s.0]).await;
8443
8444        // No file opened — just the handshake completion.
8445        backend.initialized(InitializedParams {}).await;
8446
8447        assert_eq!(
8448            backend.state.read().await.projects.len(),
8449            2,
8450            "both monorepo projects are warmed at `initialized`, before any open",
8451        );
8452        // And each is genuinely analysable without an open buffer.
8453        let ax = file_uri(&s.0, "packages/a/src/x.bynk");
8454        assert!(
8455            backend.analysis_for(&ax).await.is_some(),
8456            "a warmed project answers index requests with no `did_open`",
8457        );
8458    }
8459
8460    /// The implicit-`src/` project (#485 — a `src/` tree with no `bynk.toml`) is
8461    /// warmed at startup too, not only lazily on first open. `resolve_root` finds
8462    /// only a `src/` *ancestor*, so the folder-is-the-root case needs the explicit
8463    /// check in `discover_projects_under`.
8464    #[tokio::test]
8465    async fn initialized_warms_an_implicit_src_project() {
8466        let s = scratch_project(
8467            "e_implicit",
8468            &[(
8469                "src/a.bynk",
8470                "commons demo.a\n\nfn f(n: Int) -> Int {\n  n\n}\n",
8471            )],
8472        );
8473        let backend = bare_backend().await;
8474        set_folders(&backend, &[&s.0]).await;
8475        backend.initialized(InitializedParams {}).await;
8476        assert_eq!(
8477            backend.state.read().await.projects.len(),
8478            1,
8479            "a rootless `src/` project is warmed at startup, not only on open",
8480        );
8481    }
8482
8483    /// Review of #677: the discovery walk must not follow a symlink cycle into a
8484    /// stack overflow — a `loop -> .` in an ordinary directory. The visited-set
8485    /// (canonicalised dirs) bounds it.
8486    #[cfg(unix)]
8487    #[tokio::test]
8488    async fn discover_projects_under_survives_a_symlink_cycle() {
8489        let s = scratch_project(
8490            "e_cycle",
8491            &[
8492                ("bynk.toml", "[project]\nname=\"p\"\n"),
8493                ("src/a.bynk", "commons p.a\n"),
8494            ],
8495        );
8496        // A directory symlink pointing back at the folder — a cycle.
8497        std::os::unix::fs::symlink(&s.0, s.0.join("loop")).ok();
8498        let roots = Backend::discover_projects_under(&s.0); // must terminate
8499        let canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8500        assert!(
8501            roots.contains(&canon),
8502            "the project is found and the walk terminates despite the cycle",
8503        );
8504    }
8505
8506    /// Review of #677: with the per-query `workspace/symbol` walk dropped, a
8507    /// `bynk.toml` **created** after startup is picked up via its watcher event
8508    /// — the watcher warms the new project.
8509    #[tokio::test]
8510    async fn a_created_manifest_warms_a_new_project() {
8511        let s = scratch_project("e_created", &[("src/a.bynk", "commons p.a\n")]);
8512        std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
8513        let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8514        let backend = bare_backend().await;
8515        set_folders(&backend, &[&s.0]).await;
8516        assert!(
8517            backend.state.read().await.projects.is_empty(),
8518            "no entry before the watcher fires",
8519        );
8520
8521        let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
8522        backend
8523            .did_change_watched_files(DidChangeWatchedFilesParams {
8524                changes: vec![FileEvent {
8525                    uri: toml_uri,
8526                    typ: FileChangeType::CREATED,
8527                }],
8528            })
8529            .await;
8530        assert_eq!(
8531            backend.state.read().await.projects.len(),
8532            1,
8533            "a created bynk.toml warms its project via the watcher event",
8534        );
8535    }
8536
8537    /// #2081: a `bynk.schema.lock` watcher event clears that project's cached
8538    /// lock, so a rewrite the stamp cannot see (same length, same mtime: an
8539    /// edit within one mtime tick) is still read on the next round.
8540    #[tokio::test]
8541    async fn a_schema_lock_watcher_event_invalidates_the_cached_lock() {
8542        const OLD: &str = "version = 1\n# aaaa\n";
8543        const NEW: &str = "version = 1\n# bbbb\n";
8544        let s = scratch_project(
8545            "lock_watch",
8546            &[
8547                ("bynk.toml", "[project]\nname = \"p\"\n"),
8548                ("bynk.schema.lock", OLD),
8549            ],
8550        );
8551        let backend = backend_at(&s.0).await;
8552        let root = backend.test_root().await;
8553        let lock = root.join("bynk.schema.lock");
8554        let on = |text: &str| bynk_ide::SchemaLock::On {
8555            existing: Some(text.to_string()),
8556        };
8557        assert_eq!(backend.schema_lock_for(&root).await, on(OLD));
8558
8559        // Rewrite with the same length, then put the old mtime back: the
8560        // stamp is unchanged, so only the watcher can tell the cache.
8561        let mtime = std::fs::metadata(&lock).unwrap().modified().unwrap();
8562        std::fs::write(&lock, NEW).unwrap();
8563        std::fs::File::options()
8564            .write(true)
8565            .open(&lock)
8566            .unwrap()
8567            .set_modified(mtime)
8568            .unwrap();
8569        assert_eq!(
8570            backend.schema_lock_for(&root).await,
8571            on(OLD),
8572            "an unchanged stamp serves the cache"
8573        );
8574
8575        backend
8576            .did_change_watched_files(DidChangeWatchedFilesParams {
8577                changes: vec![FileEvent {
8578                    uri: Url::from_file_path(&lock).unwrap(),
8579                    typ: FileChangeType::CHANGED,
8580                }],
8581            })
8582            .await;
8583        assert_eq!(
8584            backend.schema_lock_for(&root).await,
8585            on(NEW),
8586            "the watcher event must clear the cached lock"
8587        );
8588    }
8589
8590    /// #682: a repeated route for the same URI is served from `root_cache`
8591    /// rather than re-walking the filesystem each time — a `None` route
8592    /// (single-file mode) is cached too, since it's just as stable an answer.
8593    #[tokio::test]
8594    async fn root_for_uri_populates_the_cache() {
8595        let s = scratch_project("g_cache_hit", &[("a.bynk", "commons demo.a\n")]);
8596        let uri = file_uri(&s.0, "a.bynk");
8597        let backend = bare_backend().await;
8598
8599        assert!(
8600            backend.root_for_uri(&uri).await.is_none(),
8601            "no bynk.toml and no src/ ancestor — routes to no project",
8602        );
8603        assert_eq!(
8604            backend.state.read().await.root_cache.get(&uri),
8605            Some(&None),
8606            "the miss is cached too",
8607        );
8608    }
8609
8610    /// #682 (DECISION C): a `bynk.toml` created after a URI was already routed
8611    /// (and cached) re-routes that URI once the watcher event invalidates the
8612    /// cache — a stale cached `None` must not survive the manifest's arrival.
8613    #[tokio::test]
8614    async fn a_created_manifest_invalidates_the_cached_route() {
8615        let s = scratch_project("g_cache_invalidate", &[("a.bynk", "commons p.a\n")]);
8616        let uri = file_uri(&s.0, "a.bynk");
8617        let backend = bare_backend().await;
8618
8619        assert!(
8620            backend.root_for_uri(&uri).await.is_none(),
8621            "precondition: cached as routing to no project",
8622        );
8623
8624        std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
8625        let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8626        let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
8627        backend
8628            .did_change_watched_files(DidChangeWatchedFilesParams {
8629                changes: vec![FileEvent {
8630                    uri: toml_uri,
8631                    typ: FileChangeType::CREATED,
8632                }],
8633            })
8634            .await;
8635
8636        assert_eq!(
8637            backend.root_for_uri(&uri).await,
8638            Some(root),
8639            "re-routes to the new project now the stale cache entry is gone",
8640        );
8641    }
8642
8643    /// #822: the guard `root_for_uri` checks before writing back a cache miss
8644    /// — an accidental `!=`-for-`==` inversion here would silently reopen the
8645    /// TOCTOU the generation counter exists to close, and the real race is too
8646    /// timing-dependent to exercise deterministically, so this pins the
8647    /// predicate directly.
8648    #[test]
8649    fn root_cache_write_is_current_rejects_a_generation_that_moved() {
8650        assert!(
8651            Backend::root_cache_write_is_current(3, 3),
8652            "no clear happened since the read — the write-back applies",
8653        );
8654        assert!(
8655            !Backend::root_cache_write_is_current(3, 4),
8656            "a clear bumped the generation since the read — the write-back must be dropped",
8657        );
8658    }
8659
8660    /// #822: both `root_cache.clear()` sites must bump `root_cache_generation`
8661    /// alongside the clear — the guard only closes the TOCTOU if every
8662    /// invalidation does both. `did_change_watched_files`'s bump is covered
8663    /// indirectly by `a_created_manifest_invalidates_the_cached_route`; this
8664    /// covers `did_change_workspace_folders`'s directly, since a regression
8665    /// dropping just that one bump would reopen the race there specifically.
8666    #[tokio::test]
8667    async fn a_workspace_folder_change_bumps_the_root_cache_generation() {
8668        let s = scratch_project("g_race_folder", &[("bynk.toml", "[project]\nname=\"p\"\n")]);
8669        let backend = bare_backend().await;
8670        let before = backend.state.read().await.root_cache_generation;
8671
8672        backend
8673            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8674                event: WorkspaceFoldersChangeEvent {
8675                    added: vec![WorkspaceFolder {
8676                        uri: Url::from_file_path(&s.0).unwrap(),
8677                        name: "p".into(),
8678                    }],
8679                    removed: vec![],
8680                },
8681            })
8682            .await;
8683
8684        assert!(
8685            backend.state.read().await.root_cache_generation > before,
8686            "a workspace-folder change must bump the generation, not just clear the cache",
8687        );
8688    }
8689
8690    /// A folder added at runtime is warmed the same way (the proactive scan
8691    /// slice D deferred to E), so its projects appear without an open.
8692    #[tokio::test]
8693    async fn an_added_folder_is_warmed() {
8694        let s = scratch_project(
8695            "e_added",
8696            &[
8697                ("bynk.toml", "[project]\nname=\"p\"\n"),
8698                (
8699                    "src/a.bynk",
8700                    "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n",
8701                ),
8702            ],
8703        );
8704        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8705        let backend = bare_backend().await; // no folders yet
8706        assert!(backend.state.read().await.projects.is_empty());
8707
8708        backend
8709            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8710                event: WorkspaceFoldersChangeEvent {
8711                    added: vec![WorkspaceFolder {
8712                        uri: Url::from_file_path(&folder).unwrap(),
8713                        name: "p".into(),
8714                    }],
8715                    removed: vec![],
8716                },
8717            })
8718            .await;
8719
8720        assert_eq!(
8721            backend.state.read().await.projects.len(),
8722            1,
8723            "an added workspace folder's project is warmed proactively",
8724        );
8725    }
8726
8727    // ---- Slice F: one diagnostics scheduler ----
8728
8729    fn change_params(uri: &Url, version: i32, text: &str) -> DidChangeTextDocumentParams {
8730        DidChangeTextDocumentParams {
8731            text_document: VersionedTextDocumentIdentifier {
8732                uri: uri.clone(),
8733                version,
8734            },
8735            content_changes: vec![TextDocumentContentChangeEvent {
8736                range: None,
8737                range_length: None,
8738                text: text.to_string(),
8739            }],
8740        }
8741    }
8742
8743    /// Slice F: a **single-file** buffer (no project) now debounces by
8744    /// generation — a burst bumps the URI's generation once per change, so only
8745    /// the last-scheduled task survives its freshness check and runs `diagnose`.
8746    /// Before F single-file had no generation and ran once per keystroke.
8747    #[tokio::test]
8748    async fn a_single_file_burst_coalesces_by_generation() {
8749        // A `.bynk` file with no `bynk.toml` and no `src/` — single-file mode.
8750        let s = scratch_project("f_single", &[("a.bynk", "commons demo.a\n")]);
8751        let uri = file_uri(&s.0, "a.bynk");
8752        assert!(
8753            Backend::root_for_uri_uncached(&uri).is_none(),
8754            "precondition: the file routes to no project",
8755        );
8756        let backend = bare_backend().await;
8757        for _ in 0..3 {
8758            backend.schedule_single_file(uri.clone()).await;
8759        }
8760        assert_eq!(
8761            backend
8762                .state
8763                .read()
8764                .await
8765                .single_file_generations
8766                .get(&uri)
8767                .copied(),
8768            Some(3),
8769            "each change bumps the generation; only the third task passes its check",
8770        );
8771    }
8772
8773    /// Slice F: `did_close` clears a single-file buffer's debounce generation, so
8774    /// the map does not grow unboundedly across a session.
8775    #[tokio::test]
8776    async fn did_close_clears_the_single_file_generation() {
8777        let s = scratch_project("f_close", &[("a.bynk", "commons demo.a\n")]);
8778        let uri = file_uri(&s.0, "a.bynk");
8779        let backend = bare_backend().await;
8780        backend.schedule_single_file(uri.clone()).await;
8781        assert!(
8782            backend
8783                .state
8784                .read()
8785                .await
8786                .single_file_generations
8787                .contains_key(&uri),
8788            "the generation exists after scheduling",
8789        );
8790        backend
8791            .did_close(DidCloseTextDocumentParams {
8792                text_document: TextDocumentIdentifier { uri: uri.clone() },
8793            })
8794            .await;
8795        assert!(
8796            !backend
8797                .state
8798                .read()
8799                .await
8800                .single_file_generations
8801                .contains_key(&uri),
8802            "did_close clears the single-file generation",
8803        );
8804    }
8805
8806    /// Slice F: `did_change` in **project** mode now feeds the one generation-
8807    /// based scheduler directly (no separate pre-sleep, no second hardcoded
8808    /// debounce). A burst bumps the project's generation once per change, so a
8809    /// single round survives — coalescing, through the real handler.
8810    #[tokio::test]
8811    async fn a_project_change_burst_coalesces_through_did_change() {
8812        let src = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
8813        let s = scratch_project(
8814            "f_burst",
8815            &[
8816                ("bynk.toml", "[project]\nname=\"q\"\n"),
8817                ("src/a.bynk", src),
8818            ],
8819        );
8820        let backend = backend_at(&s.0).await;
8821        let root = backend.test_root().await;
8822        let uri = file_uri(&s.0, "src/a.bynk");
8823        open(&backend, &uri, src).await;
8824
8825        let gen_before = {
8826            let state = backend.state.read().await;
8827            state.projects.get(&root).unwrap().analysis_generation
8828        };
8829        for v in 2..=5 {
8830            backend
8831                .did_change(change_params(
8832                    &uri,
8833                    v,
8834                    &format!("{}{src}", "\n".repeat(v as usize)),
8835                ))
8836                .await;
8837        }
8838        let gen_after = {
8839            let state = backend.state.read().await;
8840            state.projects.get(&root).unwrap().analysis_generation
8841        };
8842        assert_eq!(
8843            gen_after - gen_before,
8844            4,
8845            "each of the four changes bumps the generation once — only the last \
8846             scheduled round runs (no per-change round, no stacked debounce)",
8847        );
8848    }
8849
8850    // -- #596: store-map query vocabulary, end to end through `completion` ----
8851    //
8852    // The unit tests in `completion.rs`/`kernel_methods.rs`/`store_ops.rs`
8853    // cover each half in isolation; a #812 review flagged the gap that no test
8854    // drove a real `textDocument/completion` request through `Backend` to
8855    // check the two halves actually merge (and, separately, that the
8856    // provenance-based half survives a project-wide resolve failure that
8857    // blanks `type_receiver`). These close both.
8858
8859    fn completion_labels(response: Option<CompletionResponse>) -> Vec<String> {
8860        match response {
8861            Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(),
8862            Some(CompletionResponse::List(list)) => {
8863                list.items.into_iter().map(|i| i.label).collect()
8864            }
8865            None => Vec::new(),
8866        }
8867    }
8868
8869    async fn complete_at(backend: &Backend, uri: &Url, text: &str, needle: &str) -> Vec<String> {
8870        let offset = text.find(needle).expect("needle present") + needle.len();
8871        let pos = crate::position::offset_to_position(text, offset);
8872        let response = backend
8873            .completion(CompletionParams {
8874                text_document_position: TextDocumentPositionParams {
8875                    text_document: TextDocumentIdentifier { uri: uri.clone() },
8876                    position: pos,
8877                },
8878                work_done_progress_params: Default::default(),
8879                partial_result_params: Default::default(),
8880                context: None,
8881            })
8882            .await
8883            .expect("completion must not error");
8884        completion_labels(response)
8885    }
8886
8887    /// A `store Map` field's `.` completion merges both halves in one
8888    /// response: the `Query` kernel methods (`filter`, `collect`, …) from
8889    /// `kernel_methods::methods_for`, and the store-field vocabulary (entry
8890    /// ops + accessors) from the provenance-based path — driven through the
8891    /// real `Backend::completion`, not the pure helpers directly.
8892    #[tokio::test]
8893    async fn store_map_receiver_completion_merges_both_vocabularies() {
8894        let src = "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";
8895        let s = scratch_project(
8896            "store_map_merge",
8897            &[
8898                ("bynk.toml", "[project]\nname=\"shop\"\n"),
8899                ("src/a.bynk", src),
8900            ],
8901        );
8902        let backend = backend_at(&s.0).await;
8903        let uri = file_uri(&s.0, "src/a.bynk");
8904        open(&backend, &uri, src).await;
8905        backend.run_round().await;
8906
8907        let labels = complete_at(&backend, &uri, src, "    items.").await;
8908        assert!(
8909            labels.contains(&"filter".to_string()),
8910            "the Query kernel vocabulary: {labels:?}"
8911        );
8912        assert!(
8913            labels.contains(&"collect".to_string()),
8914            "the Query kernel vocabulary: {labels:?}"
8915        );
8916        assert!(
8917            labels.contains(&"put".to_string()),
8918            "the store entry ops: {labels:?}"
8919        );
8920        assert!(
8921            labels.contains(&"entries".to_string()),
8922            "the Map query accessors: {labels:?}"
8923        );
8924    }
8925
8926    /// #2010: inside `let <pattern> … else match { … }`, arm-start completion
8927    /// offers the value's variants minus the one the pattern already matches —
8928    /// through the real `Backend::completion`, for `=` and for `<-` (whose
8929    /// `Effect` payload is what the arms match).
8930    #[tokio::test]
8931    async fn let_else_match_offers_the_residual_variants() {
8932        let src = "context shop\n\nfn f(r: Result[Int, String]) -> Int {\n  let Ok(n) = r else match {\n    Err(e) => 0,\n  }\n  n\n}\n\nfn g(o: Option[Int]) -> Effect[Int] {\n  let Some(m) <- Effect.pure(o) else match {\n    None => 0,\n  }\n  m\n}\n";
8933        let s = scratch_project(
8934            "let_else_match_residual",
8935            &[
8936                ("bynk.toml", "[project]\nname=\"shop\"\n"),
8937                ("src/shop.bynk", src),
8938            ],
8939        );
8940        let backend = backend_at(&s.0).await;
8941        let uri = file_uri(&s.0, "src/shop.bynk");
8942        open(&backend, &uri, src).await;
8943        backend.run_round().await;
8944
8945        let labels = complete_at(&backend, &uri, src, "r else match {\n    ").await;
8946        assert!(labels.contains(&"Err".to_string()), "{labels:?}");
8947        assert!(
8948            !labels.contains(&"Ok".to_string()),
8949            "`Ok(n)` covers `Ok`: {labels:?}"
8950        );
8951
8952        let labels = complete_at(&backend, &uri, src, "pure(o) else match {\n    ").await;
8953        assert!(labels.contains(&"None".to_string()), "{labels:?}");
8954        assert!(
8955            !labels.contains(&"Some".to_string()),
8956            "`Some(m)` covers `Some`: {labels:?}"
8957        );
8958    }
8959
8960    /// An unresolved type name elsewhere in the same file — in an unrelated
8961    /// `type` declaration, not even the agent using `items` — trips the
8962    /// *resolve* gate. That once blanked `expr_types` for the **whole file**
8963    /// (the one clean-file-ceiling gap ADR 0094 didn't close), and before the
8964    /// #812 review fix `value_member_completions` then returned early and
8965    /// never reached the store-field path. Since #1663 (Decision A) the
8966    /// checker runs past a resolve error, so both halves survive: the typed
8967    /// `Query` kernel methods and the store-field entry ops/accessors.
8968    #[tokio::test]
8969    async fn store_field_vocabulary_survives_an_unrelated_resolve_failure() {
8970        let src = "context shop\n\ntype Bad = { x: NoSuchType }\n\nagent Inventory {\n  key id: String\n  store items: Map[String, Int]\n\n  on call f() -> Effect[()] {\n    items.\n  }\n}\n";
8971        let s = scratch_project(
8972            "store_map_resolve_gap",
8973            &[
8974                ("bynk.toml", "[project]\nname=\"shop\"\n"),
8975                ("src/a.bynk", src),
8976            ],
8977        );
8978        let backend = backend_at(&s.0).await;
8979        let uri = file_uri(&s.0, "src/a.bynk");
8980        open(&backend, &uri, src).await;
8981        backend.run_round().await;
8982
8983        // Precondition: the round really did fail to type this file (the
8984        // fixture actually reaches the ceiling this test is about, rather
8985        // than passing vacuously because the file happened to check fine).
8986        let analysis = backend.test_analysis().await.expect("a round committed");
8987        let rel = Backend::uri_to_rel(&analysis, &uri).expect("uri resolves");
8988        assert!(
8989            analysis
8990                .diagnostics
8991                .get(&rel)
8992                .is_some_and(|ds| !ds.is_empty()),
8993            "the fixture must actually fail to check — an undeclared return \
8994             type is the trigger this test exercises",
8995        );
8996
8997        let labels = complete_at(&backend, &uri, src, "    items.").await;
8998        // #1663: the resolve error in `Bad` no longer blanks the agent's types,
8999        // so the typed half (`Query` kernel methods) survives too.
9000        assert!(
9001            labels.contains(&"filter".to_string()),
9002            "an unrelated resolve error must not blank the typed half: {labels:?}"
9003        );
9004        assert!(
9005            labels.contains(&"put".to_string()),
9006            "store entry ops must survive an unrelated resolve failure: {labels:?}"
9007        );
9008        assert!(
9009            labels.contains(&"entries".to_string()),
9010            "Map query accessors must survive an unrelated resolve failure: {labels:?}"
9011        );
9012    }
9013
9014    /// Content-ownership track (#1086) §8's "done when": an unsaved edit in
9015    /// file A is visible to a completion triggered from file B — driven
9016    /// through a real `Backend` (`did_open`/`did_change` → `completion`),
9017    /// not the pure `bynk_ide` helpers directly. `shared/widget.bynk`
9018    /// declares `Widget` with one field on disk; opening it and editing its
9019    /// buffer (never saved) to add a second field must be visible to
9020    /// `app/use.bynk`'s cross-file record-construction completion
9021    /// (`Widget { <cursor>`, via `record_field_names`/`project_content`) in
9022    /// the very same round. This exercises the sweep both files' rounds
9023    /// share, not `type_receiver` specifically — see
9024    /// `type_receivers_slow_path_sees_a_closed_files_disk_content` below for
9025    /// that path's own dedicated coverage (a review of this PR found this
9026    /// test alone doesn't reach it).
9027    #[tokio::test]
9028    async fn an_unsaved_edit_in_one_file_is_visible_to_completion_in_another() {
9029        let widget_v1 = "commons shared.widget\n\ntype Widget = { size: Int }\n";
9030        let widget_v2 = "commons shared.widget\n\ntype Widget = { size: Int, weight: Int }\n";
9031        let use_src =
9032            "commons app.use\n\nuses shared.widget\n\nfn make() -> Widget {\n  Widget { \n}\n";
9033        let s = scratch_project(
9034            "cross_file_unsaved",
9035            &[
9036                (
9037                    "bynk.toml",
9038                    "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
9039                ),
9040                ("shared/widget.bynk", widget_v1),
9041                ("app/use.bynk", use_src),
9042            ],
9043        );
9044        let backend = backend_at(&s.0).await;
9045        let widget_uri = file_uri(&s.0, "shared/widget.bynk");
9046        let use_uri = file_uri(&s.0, "app/use.bynk");
9047        open(&backend, &widget_uri, widget_v1).await;
9048        open(&backend, &use_uri, use_src).await;
9049        backend.run_round().await;
9050
9051        // Precondition: before the edit, only the on-disk field completes.
9052        let before = complete_at(&backend, &use_uri, use_src, "  Widget { ").await;
9053        assert!(before.contains(&"size".to_string()), "{before:?}");
9054        assert!(
9055            !before.contains(&"weight".to_string()),
9056            "the fixture must not already have `weight` on disk: {before:?}"
9057        );
9058
9059        // Edit `widget.bynk`'s buffer — never saved to disk — to add `weight`.
9060        backend
9061            .did_change(DidChangeTextDocumentParams {
9062                text_document: VersionedTextDocumentIdentifier {
9063                    uri: widget_uri.clone(),
9064                    version: 2,
9065                },
9066                content_changes: vec![TextDocumentContentChangeEvent {
9067                    range: None,
9068                    range_length: None,
9069                    text: widget_v2.to_string(),
9070                }],
9071            })
9072            .await;
9073
9074        let after = complete_at(&backend, &use_uri, use_src, "  Widget { ").await;
9075        assert!(
9076            after.contains(&"weight".to_string()),
9077            "an unsaved edit to `widget.bynk` must be visible to `use.bynk`'s \
9078             cross-file completion in the same round: {after:?}"
9079        );
9080        assert!(after.contains(&"size".to_string()), "{after:?}");
9081
9082        // On-disk content is untouched — the visibility came from the buffer.
9083        assert_eq!(
9084            std::fs::read_to_string(s.0.join("shared/widget.bynk")).unwrap(),
9085            widget_v1,
9086            "the edit must never have been saved to disk"
9087        );
9088    }
9089
9090    /// Content-ownership track (#1086) slice 5: dedicated coverage for
9091    /// `type_receiver`'s slow path specifically (the fix a PR review found
9092    /// the test above doesn't reach — `Widget { ` completion never calls
9093    /// `type_receiver` at all, it's pure syntax via `record_field_names`).
9094    /// `widget.bynk` is **never opened** — closed, on-disk only — so its
9095    /// content can only reach `w.`'s value-member completion in
9096    /// `use.bynk` through `type_receiver`'s own `sweep_project_content`
9097    /// call, not through any open-buffer overlay. No round runs before the
9098    /// request either, so `project_analysis_for`'s fast-path cache is empty
9099    /// and `type_receiver` must take its slow, re-analysing path — the one
9100    /// this slice fixed.
9101    #[tokio::test]
9102    async fn type_receivers_slow_path_sees_a_closed_files_disk_content() {
9103        let widget_src = "commons shared.widget\n\ntype Widget = { size: Int }\n";
9104        let use_src =
9105            "commons app.use\n\nuses shared.widget\n\nfn area(w: Widget) -> Int {\n  w.\n}\n";
9106        let s = scratch_project(
9107            "type_receiver_slow_path",
9108            &[
9109                (
9110                    "bynk.toml",
9111                    "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
9112                ),
9113                ("shared/widget.bynk", widget_src),
9114                ("app/use.bynk", use_src),
9115            ],
9116        );
9117        let backend = backend_at(&s.0).await;
9118        let use_uri = file_uri(&s.0, "app/use.bynk");
9119        // `widget.bynk` is deliberately never opened — no did_open, no round.
9120        open(&backend, &use_uri, use_src).await;
9121
9122        let labels = complete_at(&backend, &use_uri, use_src, "  w.").await;
9123        assert!(
9124            labels.contains(&"size".to_string()),
9125            "type_receiver's slow path must resolve `w: Widget` off the \
9126             closed widget.bynk's real disk content: {labels:?}"
9127        );
9128    }
9129}