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 architecture_request;
32pub mod capability_fixes;
33pub mod code_actions;
34pub mod completion;
35mod content;
36mod document_symbols;
37pub mod documentation_request;
38mod extract;
39pub mod hover;
40pub mod index_queries;
41mod inlay_hints;
42mod locals_nav;
43pub mod position;
44mod project;
45mod publish;
46pub mod sequence_request;
47mod signature_help;
48mod structure;
49pub mod symbols;
50pub mod transport;
51pub mod wire_contract_request;
52
53use std::path::PathBuf;
54use std::sync::Arc;
55
56use tokio::sync::RwLock;
57use tower_lsp::jsonrpc::Result as JsonRpcResult;
58use tower_lsp::lsp_types::request::{
59    GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
60    GotoTypeDefinitionResponse,
61};
62use tower_lsp::lsp_types::*;
63use tower_lsp::{Client, LanguageServer, LspService, Server};
64
65use crate::project::ProjectConfig;
66
67const SERVER_NAME: &str = "bynkc-lsp";
68const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
69
70/// In-memory document state.
71#[derive(Debug, Clone)]
72struct DocumentState {
73    text: String,
74    version: i32,
75}
76
77/// v0.25 (ADR 0053): one analysis round's retained outputs — the binding
78/// index plus the snapshots its spans are offsets into, and the open-doc
79/// versions captured when the overlay was built (rename emits versioned
80/// edits against exactly these versions).
81#[derive(Debug)]
82struct Analysis {
83    /// Slice A: the canonicalised **project root** every path in this round
84    /// resolves against. Was the single `src` directory; the round now covers
85    /// every `include` tree, and ADR 0198 makes each file's path
86    /// project-relative — so this is the one base that resolves all of them.
87    project_root: PathBuf,
88    index: bynk_check::index::ProjectIndex,
89    /// Project-relative path → the analysed text.
90    snapshots: std::collections::HashMap<PathBuf, String>,
91    /// Project-relative path → the open document's version at analysis
92    /// time (absent for files read from disk).
93    versions: std::collections::HashMap<PathBuf, i32>,
94    /// v0.26 (ADR 0054): project-relative path → the round's diagnostics,
95    /// full `CompileError`s included — the suggestions `codeAction` serves
96    /// ride on them. Every analysed file has an entry (clean files an empty
97    /// one). Replaces the v0.25 categories-only field; the rename baseline
98    /// derives from these via [`Self::diag_categories`].
99    diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>>,
100    /// v0.27 (ADR 0056): project-relative path → the round's harvested
101    /// inferred-type hints, spans against the analysed snapshots.
102    hints: bynk_check::hints::FileHints,
103    /// v0.99: project-relative path → the round's capability-requirement ledger,
104    /// driving the materializable ghost `given` inlay hint, spans against the
105    /// analysed snapshots.
106    requirements: bynk_check::requirements::FileRequirements,
107    /// v0.31 (ADR 0064): project-relative path → the round's local bindings
108    /// with scope ranges, for locals navigation (references/definition/
109    /// highlight), spans against the analysed snapshots.
110    locals: bynk_check::locals::FileLocals,
111    /// Slice 6: project-relative path → the round's expression types, spans
112    /// against the analysed snapshots — backs go-to-type-definition.
113    expr_types: bynk_check::expr_types::FileExprTypes,
114    /// T3.6b (R4.1): the round's intern table — what every `TyId` in
115    /// `expr_types` resolves against. One per analysis round, shared across
116    /// every unit it checked.
117    ty_intern: std::sync::Arc<bynk_check::checker::Types>,
118    /// Slice 6b (ADR 0095): qualified unit name → its project source file(s),
119    /// project-relative — backs document links (`uses`/`consumes` → source).
120    unit_sources: std::collections::HashMap<String, Vec<PathBuf>>,
121    /// #846: qualified context/adapter unit name → the cross-context/agent
122    /// tables the `bynk/sequenceModel` request classifies handler calls
123    /// against.
124    sequence_info: std::collections::HashMap<String, bynk_ide::ContextSequenceInfo>,
125    /// #855: qualified context/adapter unit name → the combined type table
126    /// and service/agent tables the `bynk/wireContract` request resolves a
127    /// handler's boundary shape and cross-context hash against.
128    boundary_info: std::collections::HashMap<String, bynk_ide::ContextBoundaryInfo>,
129    /// #848: qualified unit name → its doc-comment intra-doc-link search
130    /// order — itself first, then its `uses` targets, then its `consumes`
131    /// targets — backs intra-doc-link resolution in `document_link` and
132    /// `hover`. See `bynk_ide::ProjectDiagnostics::doc_scope`.
133    doc_scope: std::collections::HashMap<String, Vec<String>>,
134}
135
136impl Analysis {
137    /// Per-file diagnostic categories — the rename validator's baseline,
138    /// derived from the retained diagnostics.
139    fn diag_categories(&self) -> Vec<(PathBuf, String)> {
140        self.diagnostics
141            .iter()
142            .flat_map(|(path, diags)| {
143                diags
144                    .iter()
145                    .map(|d| (path.clone(), d.error.category.to_string()))
146            })
147            .collect()
148    }
149}
150
151/// One project's mutable state — the fields that were flat on `State` before
152/// slice D, now one set per discovered project root. Every request routes by
153/// URI (via `resolve_root`) to its owning entry, so two projects analyse,
154/// version, and publish independently.
155#[derive(Debug, Default)]
156struct ProjectState {
157    /// Parsed `bynk.toml` configuration for this root. Defaults for missing
158    /// fields. Read live for the diagnostics mode/debounce and formatting;
159    /// reloaded on a `bynk.toml` change (`did_change_watched_files`).
160    config: ProjectConfig,
161    /// v0.25: the latest analysis round's index + snapshots. References,
162    /// rename, and the re-pointed definition/hover read this; positions
163    /// convert against the analysed snapshots (v0.24 rule).
164    analysis: Option<Arc<Analysis>>,
165    /// v0.24: URIs that currently carry published project diagnostics — the
166    /// previous round's dirty set, so newly-clean files get a clearing
167    /// (empty) publish. Per-project (slice D): a round for this root must only
168    /// clear its own files, never another project's.
169    published: std::collections::HashSet<Url>,
170    /// v0.24: debounce generation. Each change bumps it; a scheduled
171    /// analysis runs only if it is still the latest when the delay elapses.
172    /// Per-project: two projects debounce independently.
173    analysis_generation: u64,
174    /// Monotonic id handed to each analysis round as it *starts*. Together
175    /// with `analysis_round_committed` this orders round completions: an old
176    /// slow round must never overwrite a newer round's results (#513).
177    /// Per-project (slice D): a global counter would let one project's round
178    /// discard another's.
179    analysis_round_started: u64,
180    /// The id of the newest round whose results have been committed.
181    analysis_round_committed: u64,
182    /// #1667: the newest round panicked and no round has committed since.
183    /// The client is told once per such streak, not on every keystroke.
184    analysis_failed: bool,
185    /// #1667: makes this project's next round panic, so a test can watch the
186    /// failure reach the client. Per project, not a global, so parallel tests'
187    /// rounds can't take it.
188    #[cfg(test)]
189    panic_next_round: bool,
190}
191
192/// #733: the client's `workspace/*/refresh` support, per pull-based decoration,
193/// captured at `initialize`. Each flag gates the corresponding round-commit
194/// nudge in [`Backend::run_project_diagnostics`].
195#[derive(Debug, Clone, Copy, Default)]
196struct RefreshSupport {
197    semantic_tokens: bool,
198    inlay_hints: bool,
199    code_lens: bool,
200}
201
202/// Mutable server state. Slice D: a map of projects (was one flat project),
203/// plus the open buffers (client-global) and the workspace-folder seeds.
204#[derive(Debug, Default)]
205struct State {
206    /// Discovered projects, keyed by **canonical project root** (Q4: the
207    /// directory a file's `resolve_root` walk lands on — a `bynk.toml`, else an
208    /// implicit `src/` parent). Empty in single-file mode. A request routes to
209    /// its entry by URI; the entry is created lazily on first touch (open or
210    /// request) and pruned when no folder covers it and it holds no open buffer.
211    projects: std::collections::HashMap<PathBuf, ProjectState>,
212    /// The workspace-folder roots the client has open (slice D). **Discovery
213    /// seeds, not routing owners** (Q4): they bound where
214    /// `did_change_workspace_folders` prunes, but a URI routes by its nearest
215    /// enclosing `bynk.toml`, which may sit above every folder.
216    folders: Vec<PathBuf>,
217    /// Open documents keyed by URI — a client-global set; each doc routes to
218    /// its project via `resolve_root`.
219    docs: std::collections::HashMap<Url, DocumentState>,
220    /// Slice E: whether the client advertised `didChangeWatchedFiles`
221    /// **dynamic registration** at `initialize`. When set, `initialized`
222    /// registers the file watchers server-side (so any client is notified);
223    /// when not, the client is expected to supply them itself (as VS Code did
224    /// before the extension's client-side watchers were removed).
225    supports_dynamic_watchers: bool,
226    /// #733: whether the client advertised `refresh_support` for each pull-based
227    /// decoration at `initialize`. When set, a committed round asks the client to
228    /// re-pull that decoration (`workspace/*/refresh`) — the "revalidate" half of
229    /// serving `committed_analysis` stale while typing. Only sent when advertised,
230    /// so a client that never supported it is never spammed with unknown requests.
231    supports_refresh: RefreshSupport,
232    /// Slice F: debounce generation for **single-file** buffers (no project),
233    /// keyed by URI. The project path holds its generation in `ProjectState`;
234    /// this is the same coalescing for a buffer that has no entry — a burst runs
235    /// one `diagnose`, not one per keystroke. Cleared on `did_close`.
236    single_file_generations: std::collections::HashMap<Url, u64>,
237    /// #682: memoised URI → canonical project root routing (`None` for
238    /// single-file mode is itself a cached answer), so the hot request path
239    /// stops re-walking the filesystem and `canonicalize()`ing on every call.
240    /// For a URI whose own path is fixed, routing depends only on `bynk.toml`
241    /// presence among its ancestors — `find_source_root`'s `src`-ancestor
242    /// fallback is a pure string match against that fixed path, with no
243    /// filesystem I/O of its own, so it can't drift independently. That makes
244    /// a `bynk.toml` create/delete/change the only event that can move an
245    /// already-cached URI's route, and this is invalidated wholesale on it
246    /// (`did_change_watched_files`). A workspace-folder change also clears it
247    /// (`did_change_workspace_folders`) even though `resolve_canonical` never
248    /// consults `folders` today — a defensive, effectively-free no-op kept in
249    /// case that ever changes, not a correctness requirement. Bounded entries
250    /// are never individually evicted (e.g. on `did_close`); only ever
251    /// wholesale-cleared, which is judged an acceptable tradeoff — bounded by
252    /// the distinct files touched in a session. See [`Backend::root_for_uri`].
253    root_cache: std::collections::HashMap<Url, Option<PathBuf>>,
254    /// #682: bumped every time `root_cache` is wholesale-cleared. `root_for_uri`
255    /// resolves a cache miss off the `state` lock (a filesystem walk must not
256    /// run while holding it); this closes the race where an invalidating clear
257    /// lands *during* that walk — the write-back re-checks the generation and
258    /// drops a stale answer instead of resurrecting it into the freshly-cleared
259    /// cache.
260    root_cache_generation: u64,
261}
262
263#[derive(Clone)]
264pub struct Backend {
265    client: Client,
266    state: Arc<RwLock<State>>,
267    /// Slice B (the freshness contract): serialises request-driven refreshes so
268    /// concurrent index-backed requests after one edit coalesce onto a single
269    /// round instead of each spawning its own. Held only across `analysis_for`'s
270    /// refresh; never across a `state` lock.
271    refresh_lock: Arc<tokio::sync::Mutex<()>>,
272    /// #1667: set when the client sends `shutdown`. [`run`] reads it once the
273    /// session ends, to exit 0 after an orderly shutdown and 1 otherwise, as
274    /// the LSP spec's `exit` notification prescribes.
275    shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
276}
277
278/// #1667: a failed `spawn_blocking` task, described for the log: the panic's
279/// message when it panicked, else that it was cancelled.
280fn describe_join_error(e: tokio::task::JoinError) -> String {
281    if !e.is_panic() {
282        return format!("cancelled ({e})");
283    }
284    let payload = e.into_panic();
285    if let Some(s) = payload.downcast_ref::<&str>() {
286        format!("panicked: {s}")
287    } else if let Some(s) = payload.downcast_ref::<String>() {
288        format!("panicked: {s}")
289    } else {
290        "panicked with a non-string payload".to_string()
291    }
292}
293
294impl Backend {
295    #[cfg(test)]
296    fn new(client: Client) -> Self {
297        Self::with_shutdown_flag(client, Arc::default())
298    }
299
300    fn with_shutdown_flag(
301        client: Client,
302        shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
303    ) -> Self {
304        Self {
305            client,
306            state: Arc::new(RwLock::new(State::default())),
307            refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
308            shutdown_requested,
309        }
310    }
311
312    /// Locate `bynk.toml` walking upward from the given path. Returns the
313    /// project root (the directory containing `bynk.toml`) on success.
314    fn find_project_root(start: &std::path::Path) -> Option<PathBuf> {
315        let mut current = if start.is_file() {
316            start.parent()?.to_path_buf()
317        } else {
318            start.to_path_buf()
319        };
320        loop {
321            let candidate = current.join("bynk.toml");
322            if candidate.is_file() {
323                return Some(current);
324            }
325            current = current.parent()?.to_path_buf();
326        }
327    }
328
329    /// Locate the nearest ancestor directory named `src`, walking upward from
330    /// `start`. This is the implicit source root of a *rootless* tree — the
331    /// same `src/`-without-`bynk.toml` layout `bynkc` compiles in its legacy
332    /// single-tree mode (`bynkc/tests/e2e.rs` `compile_fixture`), which the
333    /// compiler fixtures use. Returns that `src` directory.
334    fn find_source_root(start: &std::path::Path) -> Option<PathBuf> {
335        let mut current = if start.is_file() {
336            start.parent()?.to_path_buf()
337        } else {
338            start.to_path_buf()
339        };
340        loop {
341            if current.file_name().and_then(|n| n.to_str()) == Some("src") {
342                return Some(current);
343            }
344            current = current.parent()?.to_path_buf();
345        }
346    }
347
348    /// Resolve the analysis root for a path, with its config. A real
349    /// `bynk.toml` project (config loaded from disk) takes precedence;
350    /// otherwise (#485) fall back to the nearest enclosing `src/` as an
351    /// implicit project so a multi-file commons in a rootless tree still
352    /// analyses cross-file instead of dropping to sibling-blind single-file
353    /// mode. `None` when neither is found — the caller stays single-file.
354    fn resolve_root(start: &std::path::Path) -> Option<(PathBuf, project::ProjectConfig)> {
355        if let Some(root) = Self::find_project_root(start) {
356            let config = project::load_config(&root).unwrap_or_default();
357            return Some((root, config));
358        }
359        // The implicit project root is the parent of `src`: with the default
360        // `src_dir` ("src"), `run_project_diagnostics` re-derives exactly this
361        // `src` tree as the analysis root, so every project-mode feature works
362        // with no further plumbing.
363        let src = Self::find_source_root(start)?;
364        let root = src.parent()?.to_path_buf();
365        Some((root, project::ProjectConfig::default()))
366    }
367
368    /// Slice D (Q4): the **canonical** project root that owns `uri`, with its
369    /// config, or `None` for a file under no project (single-file mode). Routing
370    /// is `resolve_root`'s walk-up — the same project `bynkc` attributes the file
371    /// to — canonicalised so it matches the `projects` map key and every
372    /// `Analysis.project_root`. Workspace folders do not enter here: a URI routes
373    /// by its nearest enclosing `bynk.toml`, whatever folder it sits in.
374    fn resolve_canonical(uri: &Url) -> Option<(PathBuf, project::ProjectConfig)> {
375        let path = uri.to_file_path().ok()?;
376        let (root, config) = Self::resolve_root(&path)?;
377        Some((root.canonicalize().unwrap_or(root), config))
378    }
379
380    /// The canonical project root owning `uri`, or `None` in single-file mode.
381    /// Uncached — walks the filesystem and `canonicalize()`s on every call.
382    /// Kept for the one caller that must route off the `state` lock
383    /// (`prune_orphaned_projects`, #682 DECISION B) and for tests exercising
384    /// routing directly; every other caller wants the memoised
385    /// [`Self::root_for_uri`].
386    fn root_for_uri_uncached(uri: &Url) -> Option<PathBuf> {
387        Self::resolve_canonical(uri).map(|(root, _)| root)
388    }
389
390    /// #682: the cached counterpart of `root_for_uri_uncached` — the canonical
391    /// project root owning `uri`, memoised in `State.root_cache` so a repeated
392    /// request for the same URI does not re-walk the filesystem. A miss runs
393    /// the uncached walk and stores the result (`None` included — a file that
394    /// routes to no project is itself a stable answer worth caching).
395    ///
396    /// The walk runs off the `state` lock (it is synchronous filesystem I/O),
397    /// so a wholesale `root_cache.clear()` can land between the read that
398    /// found the miss and the write that stores its answer — a `bynk.toml`
399    /// created mid-walk would otherwise have this write resurrect the
400    /// pre-creation (stale) route into the just-cleared cache, and unlike
401    /// `prune_orphaned_projects`'s TOCTOU window this one would never
402    /// self-heal. `root_cache_generation` closes it: the write-back only
403    /// applies if no clear happened while the walk was in flight; otherwise
404    /// the fresh answer is simply not cached (correct either way — just an
405    /// uncached hit for that one request).
406    async fn root_for_uri(&self, uri: &Url) -> Option<PathBuf> {
407        let generation = {
408            let state = self.state.read().await;
409            if let Some(cached) = state.root_cache.get(uri) {
410                return cached.clone();
411            }
412            state.root_cache_generation
413        };
414        let root = Self::root_for_uri_uncached(uri);
415        let mut state = self.state.write().await;
416        if Self::root_cache_write_is_current(generation, state.root_cache_generation) {
417            state.root_cache.insert(uri.clone(), root.clone());
418        }
419        root
420    }
421
422    /// #682: whether a `root_for_uri` write-back computed while the cache was
423    /// at `read_generation` should still be applied, given the cache is now at
424    /// `current_generation` — `false` once an invalidating clear has bumped it
425    /// past the read, meaning the walk's answer may already be stale. Pulled
426    /// out of `root_for_uri` so the guard itself — the one thing standing
427    /// between the fix and the TOCTOU it closes — is unit-testable without
428    /// needing to actually win the race in real time.
429    fn root_cache_write_is_current(read_generation: u64, current_generation: u64) -> bool {
430        read_generation == current_generation
431    }
432
433    /// Slice E: every project root under `folder` — the folder's own
434    /// `resolve_root` (a manifest at or above it, the folder-inside-a-project
435    /// case) plus a bounded recursive walk collecting each directory that holds
436    /// a `bynk.toml`. Roots are **canonical** (the `projects` map key). The walk
437    /// skips the caches and heavy dirs it should never descend (`out`,
438    /// `node_modules`, `target`, `.git`, and dot-dirs), and a **visited-set of
439    /// canonicalised dirs** stops a symlink cycle (`ln -s . loop`) from recursing
440    /// forever. Synchronous FS I/O — callers run it via `spawn_blocking`, off the
441    /// executor. This is the "one tree-walk"
442    /// [ADR 0204](../decisions/0204-per-workspace-project-state.md) §C named —
443    /// shared by startup warming and added-folder warming.
444    fn discover_projects_under(folder: &std::path::Path) -> Vec<PathBuf> {
445        fn should_skip(name: &std::ffi::OsStr) -> bool {
446            let name = name.to_string_lossy();
447            matches!(name.as_ref(), "out" | "node_modules" | "target" | ".git")
448                || name.starts_with('.')
449        }
450        fn walk(
451            dir: &std::path::Path,
452            out: &mut Vec<PathBuf>,
453            visited: &mut std::collections::HashSet<PathBuf>,
454        ) {
455            // Guard against symlink cycles: a directory reached twice (by its
456            // canonical path) is not descended again.
457            let canon_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
458            if !visited.insert(canon_dir.clone()) {
459                return;
460            }
461            if dir.join("bynk.toml").is_file() && !out.contains(&canon_dir) {
462                out.push(canon_dir);
463            }
464            let Ok(entries) = std::fs::read_dir(dir) else {
465                return;
466            };
467            for entry in entries.flatten() {
468                let path = entry.path();
469                if path.is_dir() && !should_skip(&entry.file_name()) {
470                    walk(&path, out, visited);
471                }
472            }
473        }
474        let mut roots = Vec::new();
475        // A manifest at or above the folder (the folder sits inside a project).
476        if let Some((root, _)) = Self::resolve_root(folder) {
477            let canon = root.canonicalize().unwrap_or(root);
478            roots.push(canon);
479        }
480        // The implicit-`src/` shape (#485): a `src/` tree with no `bynk.toml`.
481        // `resolve_root` only finds a `src/` *ancestor*, so the folder-is-the-root
482        // case (folder holds `src/`, no manifest) needs an explicit check — else
483        // a rootless project would warm only lazily on first open, not at startup.
484        if folder.join("src").is_dir() && !folder.join("bynk.toml").is_file() {
485            let canon = folder
486                .canonicalize()
487                .unwrap_or_else(|_| folder.to_path_buf());
488            if !roots.contains(&canon) {
489                roots.push(canon);
490            }
491        }
492        let mut visited = std::collections::HashSet::new();
493        walk(folder, &mut roots, &mut visited);
494        roots
495    }
496
497    /// Slice F: the single diagnostics-scheduler entry point. Route `uri` to its
498    /// owning project (a debounced project round) or, if none, single-file mode
499    /// (a debounced buffer `diagnose`). **One** generation-based debounce at the
500    /// configured delay covers both — a burst coalesces to one analysis. Replaces
501    /// `recompile_and_publish`, whose route + second hardcoded debounce stacked
502    /// on `did_change`'s own sleep.
503    async fn schedule_diagnostics(&self, uri: &Url) {
504        // Slice D: route by URI to the owning project, creating its entry on
505        // first touch (a file opened before any folder scan). Q4: the root is
506        // the file's nearest enclosing `bynk.toml`, not its workspace folder.
507        // #682: routing goes through the cache; the config is only loaded from
508        // disk when the entry doesn't exist yet, not on every call.
509        if let Some(root) = self.root_for_uri(uri).await {
510            {
511                let mut state = self.state.write().await;
512                if !state.projects.contains_key(&root) {
513                    let config = project::load_config(&root).unwrap_or_default();
514                    state.projects.insert(
515                        root.clone(),
516                        ProjectState {
517                            config,
518                            ..Default::default()
519                        },
520                    );
521                }
522            }
523            self.schedule_project_diagnostics(root).await;
524        } else {
525            self.schedule_single_file(uri.clone()).await;
526        }
527    }
528
529    /// v0.24: debounce a project-wide analysis — each call bumps the project's
530    /// generation; the spawned task runs only if still the latest after the
531    /// delay, so a typing burst produces one analysis. Slice D: keyed on one
532    /// project root, so two projects debounce independently. A no-op if the
533    /// root's entry is gone (its folder was removed mid-debounce).
534    ///
535    /// Slice F: the delay is the project's **configured** `diagnostics_debounce_ms`
536    /// (was a hardcoded 200 ms stacked on `did_change`'s own sleep — the two are
537    /// now one debounce).
538    async fn schedule_project_diagnostics(&self, root: PathBuf) {
539        let (generation, debounce) = {
540            let mut state = self.state.write().await;
541            let Some(ps) = state.projects.get_mut(&root) else {
542                return;
543            };
544            ps.analysis_generation += 1;
545            (ps.analysis_generation, ps.config.diagnostics_debounce_ms)
546        };
547        let this = self.clone();
548        tokio::spawn(async move {
549            tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
550            let superseded = match this.state.read().await.projects.get(&root) {
551                Some(ps) => ps.analysis_generation != generation,
552                None => true, // entry pruned — nothing to analyse
553            };
554            if superseded {
555                return;
556            }
557            this.run_project_diagnostics(root).await;
558        });
559    }
560
561    /// Slice F: the single-file counterpart to `schedule_project_diagnostics` —
562    /// a buffer with no project. Bump the URI's generation, sleep the (default)
563    /// configured delay, and run one `diagnose` only if still latest, so a burst
564    /// coalesces to one run (before slice F single-file had no generation and ran
565    /// once per keystroke).
566    async fn schedule_single_file(&self, uri: Url) {
567        let debounce = ProjectConfig::default().diagnostics_debounce_ms;
568        let generation = {
569            let mut state = self.state.write().await;
570            let g = state
571                .single_file_generations
572                .entry(uri.clone())
573                .or_insert(0);
574            *g += 1;
575            *g
576        };
577        let this = self.clone();
578        tokio::spawn(async move {
579            tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
580            let current = this
581                .state
582                .read()
583                .await
584                .single_file_generations
585                .get(&uri)
586                .copied();
587            if current != Some(generation) {
588                return;
589            }
590            this.diagnose_single_file(&uri).await;
591        });
592    }
593
594    /// Slice F: run `bynk_ide::diagnose` on one buffer and publish — the
595    /// single-file leaf of the scheduler (extracted from `recompile_and_publish`).
596    /// Best-effort: a malformed file produces diagnostics, not a hard failure.
597    async fn diagnose_single_file(&self, uri: &Url) {
598        let (text, version) = {
599            let state = self.state.read().await;
600            match state.docs.get(uri) {
601                Some(d) => (d.text.clone(), d.version),
602                None => return,
603            }
604        };
605        let positions = crate::position::PositionMap::new(&text);
606        let lsp_diags: Vec<Diagnostic> = bynk_ide::diagnose(&text)
607            .into_iter()
608            .map(|d| make_diagnostic(&d, &positions, uri))
609            .collect();
610        self.client
611            .publish_diagnostics(uri.clone(), lsp_diags, Some(version))
612            .await;
613    }
614
615    /// #1667: an analysis round for `root` failed (`reason` says how). Log
616    /// it, and tell the client once per failure streak: the published
617    /// diagnostics are the last good round's and may be stale. The streak ends
618    /// at the next committed round, which clears the flag.
619    async fn report_failed_round(&self, root: &std::path::Path, reason: String) {
620        tracing::error!("analysis of {} {reason}", root.display());
621        let first_failure = {
622            let mut state = self.state.write().await;
623            match state.projects.get_mut(root) {
624                Some(ps) => !std::mem::replace(&mut ps.analysis_failed, true),
625                None => false,
626            }
627        };
628        if first_failure {
629            self.client
630                .show_message(
631                    MessageType::ERROR,
632                    format!(
633                        "Bynk: analysing {} failed with an internal error, so its diagnostics may \
634                         be out of date until a later edit analyses cleanly. Details are in \
635                         ~/.bynk-lsp.log; please report it.",
636                        root.display()
637                    ),
638                )
639                .await;
640        }
641    }
642
643    /// v0.24 (ADR 0052): one project-wide diagnostics round — overlay the
644    /// open buffers over disk, analyse off the async runtime, convert spans
645    /// against the **analysed snapshots**, and publish via the pure
646    /// publish-plan (clears included).
647    async fn run_project_diagnostics(&self, root: PathBuf) {
648        let (round, root, canonical_root, overlay, versions, previously_dirty) = {
649            let mut state = self.state.write().await;
650            // Slice D: the round is for one project's entry. If it was pruned
651            // (its folder removed) between scheduling and now, there is nothing
652            // to analyse — bail.
653            let Some(ps) = state.projects.get_mut(&root) else {
654                return;
655            };
656            ps.analysis_round_started += 1;
657            let round = ps.analysis_round_started;
658            // Slice A: the analysis is rooted at the *project*, not at one
659            // `include` tree, and every path it returns is project-relative
660            // (ADR 0198) — so this is the base the overlay keys against too.
661            let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
662            let previously_dirty = ps.published.clone();
663            let mut overlay = std::collections::HashMap::new();
664            let mut versions = std::collections::HashMap::new();
665            // Every open buffer overlays disk. A buffer belonging to another
666            // project keys to an absolute path outside this root, so it is inert
667            // here — discovery never matches it — and its `versions` entry is
668            // skipped by the `strip_prefix` guard. So the round stays scoped to
669            // this project without filtering the doc set.
670            for (uri, doc) in &state.docs {
671                if let Ok(p) = uri.to_file_path() {
672                    let canonical = p.canonicalize().unwrap_or(p);
673                    // v0.25: capture the version the overlay snapshot came
674                    // from, keyed project-relative like the analysis output.
675                    if let Ok(rel) = canonical.strip_prefix(&canonical_root) {
676                        versions.insert(rel.to_path_buf(), doc.version);
677                    }
678                    overlay.insert(canonical, doc.text.clone());
679                }
680            }
681            (
682                round,
683                root,
684                canonical_root,
685                overlay,
686                versions,
687                previously_dirty,
688            )
689        };
690
691        // Slice A: manifest-aware, multi-root — the same trees `bynkc` compiles.
692        let roots = bynk_ide::AnalysisRoots::Project(root.clone());
693        // Content-ownership track (#1086) slice 5: `overlay` above is only the
694        // open buffers — with `discovery.rs`'s disk fallback gone, a project's
695        // closed files need `sweep_project_content`'s full disk sweep too, or
696        // every one of them fails `bynk.project.read_failed` on every round.
697        #[cfg(test)]
698        let inject_panic = self
699            .state
700            .write()
701            .await
702            .projects
703            .get_mut(&root)
704            .is_some_and(|ps| std::mem::take(&mut ps.panic_next_round));
705        let joined = tokio::task::spawn_blocking(move || {
706            #[cfg(test)]
707            if inject_panic {
708                panic!("injected analysis panic");
709            }
710            let content = crate::content::sweep_project_content(&roots, &overlay);
711            bynk_ide::diagnose_project_with(&roots, &content)
712        })
713        .await;
714        let result = match joined {
715            Ok(result) => result,
716            Err(e) => {
717                self.report_failed_round(&root, describe_join_error(e))
718                    .await;
719                return;
720            }
721        };
722
723        let mut new_by_uri: std::collections::HashMap<Url, Vec<Diagnostic>> =
724            std::collections::HashMap::new();
725        // Slice B (DECISION C): the document version each file was analysed at,
726        // keyed by URI — so the publish can carry it and the client can drop a
727        // range computed against a buffer it has already edited past. `None` for
728        // a file read from disk (no open buffer, no version).
729        let mut version_by_uri: std::collections::HashMap<Url, Option<i32>> =
730            std::collections::HashMap::new();
731        let mut snapshots = std::collections::HashMap::new();
732        let mut diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>> =
733            std::collections::HashMap::new();
734        for file in &result.files {
735            let abs = canonical_root.join(&file.source_path);
736            let abs = abs.canonicalize().unwrap_or(abs);
737            let Ok(uri) = Url::from_file_path(&abs) else {
738                continue;
739            };
740            // Spans convert against the snapshot the analysis saw — never a
741            // newer buffer (Settled, v0.24 proposal).
742            let positions = crate::position::PositionMap::new(&file.text);
743            let diags: Vec<Diagnostic> = file
744                .diagnostics
745                .iter()
746                .map(|d| make_diagnostic(d, &positions, &uri))
747                .collect();
748            version_by_uri.insert(uri.clone(), versions.get(&file.source_path).copied());
749            new_by_uri.insert(uri, diags);
750            diagnostics.insert(file.source_path.clone(), file.diagnostics.clone());
751            snapshots.insert(file.source_path.clone(), file.text.clone());
752        }
753        // v0.25: retain the round's index + snapshots for references/rename
754        // and the binding-correct definition/hover. v0.26: plus the raw
755        // diagnostics, for `codeAction` (the suggestions ride on them).
756        {
757            let analysis = Arc::new(Analysis {
758                project_root: canonical_root.clone(),
759                index: result.index.clone(),
760                snapshots,
761                versions,
762                diagnostics,
763                hints: result.hints,
764                requirements: result.requirements,
765                locals: result.locals,
766                expr_types: result.expr_types,
767                ty_intern: result.ty_intern,
768                unit_sources: result.unit_sources,
769                sequence_info: result.sequence_info,
770                boundary_info: result.boundary_info,
771                doc_scope: result.doc_scope,
772            });
773            let mut state = self.state.write().await;
774            let Some(ps) = state.projects.get_mut(&root) else {
775                return; // pruned mid-round
776            };
777            // Completion order is not start order: a slow old round finishing
778            // after a newer one must be dropped, not committed (#513).
779            if ps.analysis_round_committed >= round {
780                return;
781            }
782            ps.analysis_round_committed = round;
783            ps.analysis_failed = false;
784            ps.analysis = Some(analysis);
785        }
786        // Project-level diagnostics with no single owning file surface at
787        // position 0:0 rather than vanishing — on `bynk.toml` when it exists,
788        // else (#485, implicit `src/` mode has no manifest) on the first
789        // analysed file, so they anchor to a real, openable document.
790        let unattributed_anchor = {
791            let toml = root.join("bynk.toml");
792            if toml.is_file() {
793                Url::from_file_path(toml).ok()
794            } else {
795                result.files.first().and_then(|f| {
796                    let abs = canonical_root.join(&f.source_path);
797                    let abs = abs.canonicalize().unwrap_or(abs);
798                    Url::from_file_path(abs).ok()
799                })
800            }
801        };
802        if !result.unattributed.is_empty()
803            && let Some(anchor_uri) = unattributed_anchor
804        {
805            let entry = new_by_uri.entry(anchor_uri).or_default();
806            for d in &result.unattributed {
807                entry.push(Diagnostic {
808                    range: Default::default(),
809                    severity: Some(match d.severity {
810                        bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
811                        bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
812                    }),
813                    code: Some(tower_lsp::lsp_types::NumberOrString::String(
814                        d.error.category.to_string(),
815                    )),
816                    message: d.error.message.clone(),
817                    ..Default::default()
818                });
819            }
820        }
821
822        let (publishes, dirty) = publish::publish_plan(&previously_dirty, new_by_uri);
823        for (uri, diags) in publishes {
824            // Slice B (DECISION C): stamp the publish with the version the round
825            // analysed this file at (was `None`), so a client can reject a range
826            // its buffer has moved past. A clearing publish for a now-absent file
827            // carries no version — it has no entry in `version_by_uri`.
828            let version = version_by_uri.get(&uri).copied().flatten();
829            self.client.publish_diagnostics(uri, diags, version).await;
830        }
831        let still_current = {
832            let mut state = self.state.write().await;
833            if let Some(ps) = state.projects.get_mut(&root)
834                && ps.analysis_round_committed == round
835            {
836                ps.published = dirty;
837                true
838            } else {
839                false
840            }
841        };
842        // #733: revalidate. Pull-based decorations are served from the committed
843        // round (`committed_analysis`) without a forced re-analysis, so a fresh
844        // round is invisible to the client until it re-pulls. Nudge it to — but
845        // only for this round if a newer one has not already superseded it (that
846        // one sends its own nudge), and only for decorations the client can
847        // refresh. Fired on a detached task: `run_project_diagnostics` also runs
848        // on the *request* path (a cursor request's forced refresh), and a
849        // `workspace/*/refresh` awaits a client round-trip — spawning keeps that
850        // off the request's critical path. Best-effort: a failed nudge just
851        // leaves the client on the previous pull until its next request.
852        if still_current {
853            let refresh = self.state.read().await.supports_refresh;
854            if refresh.semantic_tokens || refresh.inlay_hints || refresh.code_lens {
855                let client = self.client.clone();
856                tokio::spawn(async move {
857                    if refresh.semantic_tokens {
858                        let _ = client.semantic_tokens_refresh().await;
859                    }
860                    if refresh.inlay_hints {
861                        let _ = client.inlay_hint_refresh().await;
862                    }
863                    if refresh.code_lens {
864                        let _ = client.code_lens_refresh().await;
865                    }
866                });
867            }
868        }
869    }
870
871    /// Slice A: the analysis roots for the project that owns `uri` — the
872    /// manifest's, resolved by the compiler's own discovery. `None` in
873    /// single-file mode (no project root), where cross-file lookups are skipped.
874    /// Slice D: routes by URI (Q4), so a completion in project B enumerates B's
875    /// units, not the first project's.
876    ///
877    /// Replaces `project_src_root`, which returned `root.join(config.src_dir)`:
878    /// one tree, chosen by reducing `[paths] include` to its first entry and
879    /// ignoring `exclude`. That reduction is the defect slice A removed.
880    async fn analysis_roots_for(&self, uri: &Url) -> Option<bynk_ide::AnalysisRoots> {
881        Some(bynk_ide::AnalysisRoots::Project(
882            self.root_for_uri(uri).await?,
883        ))
884    }
885
886    /// Content-ownership track (#1086): the owning project's `.bynk` files
887    /// as a pre-read `(path, content)` map — every open buffer's **live**
888    /// text, a real disk read for everything else `bynk_ide::discover_files`
889    /// names. `None` in single-file mode. Backs completion, signature help
890    /// (slice 0), and the cross-file symbol lookups (slice 1) — this is now
891    /// the sole enumeration entry point; the bare-paths `project_files` it
892    /// replaced (slice 0's `Backend::project_files`) was deleted once slice 1
893    /// migrated its last two callers.
894    ///
895    /// Finding #62's exclusion carries over unchanged from `project_files`:
896    /// the cursor's own file is filtered out here, once, for every caller —
897    /// `bynk-ide`'s completion helpers already parse it fresh from the live
898    /// buffer (`for_each_unit`'s `doc_text`), so leaving it in the map would
899    /// offer a second, stale version of the same file alongside the live one.
900    async fn project_content(
901        &self,
902        uri: &Url,
903    ) -> Option<Arc<std::collections::HashMap<PathBuf, String>>> {
904        let roots = self.analysis_roots_for(uri).await?;
905        let current = uri
906            .to_file_path()
907            .ok()
908            .and_then(|p| std::fs::canonicalize(&p).ok());
909        let overlay = {
910            let state = self.state.read().await;
911            let mut ov = std::collections::HashMap::new();
912            for (u, doc) in &state.docs {
913                if let Ok(p) = u.to_file_path() {
914                    let canonical = p.canonicalize().unwrap_or(p);
915                    ov.insert(canonical, doc.text.clone());
916                }
917            }
918            ov
919        };
920        tokio::task::spawn_blocking(move || {
921            let mut content = crate::content::sweep_project_content(&roots, &overlay);
922            if let Some(current) = current {
923                content.remove(&current);
924            }
925            // `Arc`, not an owned map: a caller that needs to move this into
926            // its own `spawn_blocking` closure (signature help fires on every
927            // `(`/`,`) clones a refcount, not every project file's content.
928            Arc::new(content)
929        })
930        .await
931        .map_err(|e| tracing::error!("project content sweep {}", describe_join_error(e)))
932        .ok()
933    }
934
935    /// v0.31: the def + use spans of the local under the cursor (def first), or
936    /// `None` if the cursor is not on a local.
937    fn local_sites(
938        &self,
939        analysis: &Analysis,
940        rel: &std::path::Path,
941        offset: usize,
942    ) -> Option<Vec<bynk_syntax::span::Span>> {
943        let text = analysis.snapshots.get(rel)?;
944        let locals = analysis.locals.get(rel)?;
945        crate::locals_nav::local_sites_at(locals, text, offset)
946    }
947
948    /// v0.31 (ADR 0064): the in-scope local bindings at the cursor, as
949    /// `variable` completions, read from the **cached** analysis — so they
950    /// survive the mid-edit buffer the current keystroke produced (the last
951    /// good round's bindings around the cursor are what's wanted). Positions
952    /// convert against the cached snapshot, like the other cached-round reads.
953    async fn locals_completions(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
954        // Slice B: completion's locals sub-path resolves `pos` against the
955        // round's snapshot (like `index_position`), so it refreshes too — the
956        // one exposed reader the §4.2 table missed.
957        let analysis = self.analysis_for(uri).await;
958        let Some(analysis) = analysis else {
959            return Vec::new();
960        };
961        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
962            return Vec::new();
963        };
964        let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
965        else {
966            return Vec::new();
967        };
968        let Some(offset) = crate::position::position_to_offset(text, pos) else {
969            return Vec::new();
970        };
971        bynk_check::locals::locals_at(locals, offset)
972            .into_iter()
973            .map(|b| CompletionItem {
974                label: b.name.clone(),
975                kind: Some(CompletionItemKind::VARIABLE),
976                detail: Some(b.ty.clone()),
977                ..Default::default()
978            })
979            .collect()
980    }
981
982    /// Convert same-file local spans to LSP `Location`s.
983    fn local_locations(
984        &self,
985        analysis: &Analysis,
986        rel: &std::path::Path,
987        spans: &[bynk_syntax::span::Span],
988    ) -> Vec<Location> {
989        let Some(text) = analysis.snapshots.get(rel) else {
990            return Vec::new();
991        };
992        let Ok(uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
993            return Vec::new();
994        };
995        spans
996            .iter()
997            .map(|s| Location {
998                uri: uri.clone(),
999                range: crate::position::span_to_range(text, *s),
1000            })
1001            .collect()
1002    }
1003
1004    /// Slice 3 (ADR 0063): complete the members of a typed **value** receiver.
1005    /// Re-analyses the buffer rewritten so the receiver parses (the trailing
1006    /// `.partial` dropped), types the receiver via the retained `expr_types`,
1007    /// and maps its type to kernel methods + record fields. Silent (not
1008    /// necessarily empty — see below) when the receiver can't be typed (the
1009    /// file has errors — the clean-file ceiling).
1010    ///
1011    /// #596: additionally merges a bare `store` field receiver's own
1012    /// vocabulary (entry ops, and for `Map` the `.entries`/`.keys`/`.values`
1013    /// accessors) — dispatched by receiver *provenance* in the checker, which
1014    /// the typed `ty` alone can't distinguish from an ordinary `Query`-typed
1015    /// local (a bare store `Map` widens to `Ty::Query` too, ADR 0120). This
1016    /// half runs **independently of whether `type_receiver` succeeded**: it
1017    /// re-parses the buffer itself and needs no typed `ty` at all, so a `store`
1018    /// field still offers its entry ops/accessors even when an unresolved name
1019    /// *elsewhere* in the file bails the checker before it runs (the one
1020    /// clean-file-ceiling gap ADR 0094 didn't close) — a review on #812 flagged
1021    /// the earlier draft's single early return as undercutting that motivation.
1022    async fn value_member_completions(
1023        &self,
1024        uri: &Url,
1025        text: &str,
1026        offset: usize,
1027    ) -> Vec<CompletionItem> {
1028        let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(text, offset)
1029        else {
1030            return Vec::new();
1031        };
1032        let mut items: Vec<CompletionItem> = Vec::new();
1033        if let Some((ty, tys)) = self
1034            .type_receiver(uri, rewritten.clone(), recv_offset)
1035            .await
1036        {
1037            let files = self.project_content(uri).await;
1038            items.extend(
1039                completion::value_member_candidates(ty, &tys, text, files.as_deref())
1040                    .into_iter()
1041                    .map(to_completion_item),
1042            );
1043        }
1044        let locals = self.fast_path_locals(uri, &rewritten).await;
1045        items.extend(
1046            completion::store_field_member_candidates(&rewritten, recv_offset, &locals)
1047                .into_iter()
1048                .map(to_completion_item),
1049        );
1050        items
1051    }
1052
1053    /// #596: the current analysed round's locals for `uri`, only when its
1054    /// snapshot exactly matches `rewritten` — the same fast-path match
1055    /// [`Self::type_receiver`] uses. Empty (rather than forcing a synchronous
1056    /// re-analysis) when the round is stale or absent, so the store-field
1057    /// shadowing check degrades to "no local shadows the name".
1058    async fn fast_path_locals(
1059        &self,
1060        uri: &Url,
1061        rewritten: &str,
1062    ) -> Vec<bynk_check::locals::LocalBinding> {
1063        let Some(analysis) = self.project_analysis_for(uri).await else {
1064            return Vec::new();
1065        };
1066        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1067            return Vec::new();
1068        };
1069        if analysis.snapshots.get(&rel).map(String::as_str) != Some(rewritten) {
1070            return Vec::new();
1071        }
1072        analysis.locals.get(&rel).cloned().unwrap_or_default()
1073    }
1074
1075    /// v0.124 (slice 3): at `<expr> is <cursor>`, the scrutinee sum type's
1076    /// variants. The scrutinee is typed via `expr_types` (re-analysing through
1077    /// `type_receiver`, the value-member path), so it is subject to the clean-
1078    /// file ceiling and goes silent — never wrong — on a broken buffer.
1079    async fn is_pattern_completions(
1080        &self,
1081        uri: &Url,
1082        text: &str,
1083        offset: usize,
1084    ) -> Vec<CompletionItem> {
1085        let Some(scrut_off) = is_scrutinee_offset(text, offset) else {
1086            return Vec::new();
1087        };
1088        self.scrutinee_variant_completions(uri, text, scrut_off)
1089            .await
1090    }
1091
1092    /// v0.128: at an arm-pattern-start inside a `match <expr> { … }`, the
1093    /// scrutinee sum type's variants — the deferred half of slice 3's
1094    /// `is`-pattern completion, sharing its scrutinee typing and candidate set.
1095    async fn match_arm_completions(
1096        &self,
1097        uri: &Url,
1098        text: &str,
1099        offset: usize,
1100    ) -> Vec<CompletionItem> {
1101        let Some(scrut_off) = match_scrutinee_offset(text, offset) else {
1102            return Vec::new();
1103        };
1104        self.scrutinee_variant_completions(uri, text, scrut_off)
1105            .await
1106    }
1107
1108    /// The variants of the scrutinee whose last character is at `scrut_off` — the
1109    /// shared tail of `is`/`match` pattern completion. Types the scrutinee via
1110    /// `expr_types` (the clean-file ceiling; silent, never wrong, on a broken
1111    /// buffer) and offers its variants; empty for a non-sum, non-`Result`/`Option`
1112    /// scrutinee. v0.145 (ADR 0169): `Result`/`Option` scrutinees now fire too
1113    /// (`variants_for_ty`), not only user-declared sums.
1114    async fn scrutinee_variant_completions(
1115        &self,
1116        uri: &Url,
1117        text: &str,
1118        scrut_off: usize,
1119    ) -> Vec<CompletionItem> {
1120        let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1121            return Vec::new();
1122        };
1123        let files = self.project_content(uri).await;
1124        completion::variants_for_ty(ty, &tys, text, files.as_deref())
1125            .into_iter()
1126            .map(to_completion_item)
1127            .collect()
1128    }
1129
1130    /// v0.145 (ADR 0169): at `OuterVariant(‸` inside a match arm-pattern, the
1131    /// payload field type's variants — e.g. `Ok`/`Err` inside `Some(‸)` on an
1132    /// `Option[Result[…]]` scrutinee. `match_scrutinee_offset` deliberately bails
1133    /// on a nested constructor; `nested_pattern_offset` targets exactly it,
1134    /// yielding the scrutinee offset and the outer variant. Types the scrutinee
1135    /// via the same clean-file ceiling and resolves the payload type.
1136    async fn nested_pattern_completions(
1137        &self,
1138        uri: &Url,
1139        text: &str,
1140        offset: usize,
1141    ) -> Vec<CompletionItem> {
1142        let Some((scrut_off, variant)) = nested_pattern_offset(text, offset) else {
1143            return Vec::new();
1144        };
1145        let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1146            return Vec::new();
1147        };
1148        let files = self.project_content(uri).await;
1149        completion::nested_variant_completions(ty, &tys, &variant, text, files.as_deref())
1150            .into_iter()
1151            .map(to_completion_item)
1152            .collect()
1153    }
1154
1155    /// v0.32 (ADR 0065): the type of a receiver expression at `recv_offset` in a
1156    /// buffer `rewritten` so it parses — re-analyse the overlay and query the
1157    /// retained `expr_types`. Shared by value-member completion and signature
1158    /// help; `None` when the file doesn't check clean (the clean-file ceiling).
1159    async fn type_receiver(
1160        &self,
1161        uri: &Url,
1162        rewritten: String,
1163        recv_offset: usize,
1164    ) -> Option<(
1165        bynk_check::checker::TyId,
1166        std::sync::Arc<bynk_check::checker::Types>,
1167    )> {
1168        // T3.6b (R4.1): the id is meaningless without the table it was minted
1169        // from, so both travel together — the round's own table on the fast
1170        // path, the fresh re-analysis's on the slow one.
1171        let roots = self.analysis_roots_for(uri).await?;
1172        let project_root = roots.project_root().to_path_buf();
1173        let canonical_root = project_root
1174            .canonicalize()
1175            .unwrap_or_else(|_| project_root.clone());
1176        let cur = uri.to_file_path().ok()?;
1177        let cur = cur.canonicalize().unwrap_or(cur);
1178        // Slice A: project-relative, matching the round's identity (ADR 0198).
1179        let rel = cur.strip_prefix(&canonical_root).ok()?.to_path_buf();
1180        // Overlay every open doc, with this one rewritten so it parses.
1181        let overlay = {
1182            let state = self.state.read().await;
1183            let mut ov = std::collections::HashMap::new();
1184            for (u, doc) in &state.docs {
1185                if let Ok(p) = u.to_file_path() {
1186                    let canonical = p.canonicalize().unwrap_or(p);
1187                    let t = if u == uri {
1188                        rewritten.clone()
1189                    } else {
1190                        doc.text.clone()
1191                    };
1192                    ov.insert(canonical, t);
1193                }
1194            }
1195            ov
1196        };
1197        // Fast path (#513): completion fires on every `.` keystroke, and the
1198        // rewritten buffer (the trailing `.`-segment removed so it parses) is
1199        // usually byte-identical to the snapshot the last debounced round
1200        // analysed. Reuse that round's expression types instead of running a
1201        // synchronous whole-project re-analysis on the request path.
1202        if let Some(analysis) = self.project_analysis_for(uri).await
1203            && analysis.snapshots.get(&rel).map(String::as_str) == Some(rewritten.as_str())
1204            && let Some((_, entries)) = analysis.expr_types.iter().find(|(p, _)| **p == rel)
1205        {
1206            return bynk_check::expr_types::type_at_offset(entries, recv_offset)
1207                .map(|t| (t, std::sync::Arc::clone(&analysis.ty_intern)));
1208        }
1209        // Content-ownership track (#1086) slice 5: same complete-content
1210        // requirement as `run_project_diagnostics` — `overlay` here is only
1211        // the open buffers, and `discovery.rs`'s disk fallback is gone.
1212        let result = tokio::task::spawn_blocking(move || {
1213            let content = crate::content::sweep_project_content(&roots, &overlay);
1214            bynk_ide::diagnose_project_with(&roots, &content)
1215        })
1216        .await
1217        .map_err(|e| tracing::error!("receiver-typing analysis {}", describe_join_error(e)))
1218        .ok()?;
1219        let (_, entries) = result.expr_types.iter().find(|(p, _)| **p == rel)?;
1220        bynk_check::expr_types::type_at_offset(entries, recv_offset)
1221            .map(|t| (t, std::sync::Arc::clone(&result.ty_intern)))
1222    }
1223
1224    /// Slice D: the committed analysis for one project root, ungated — the raw
1225    /// last round, or `None` if the root has no entry or has not analysed yet.
1226    async fn project_analysis(&self, root: &std::path::Path) -> Option<Arc<Analysis>> {
1227        self.state.read().await.projects.get(root)?.analysis.clone()
1228    }
1229
1230    /// The owning project's committed analysis for `uri`, ungated. For callers
1231    /// that reuse a round opportunistically (completion's receiver-typing fast
1232    /// path); the freshness gate is [`Self::analysis_for`].
1233    async fn project_analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1234        let root = self.root_for_uri(uri).await?;
1235        self.project_analysis(&root).await
1236    }
1237
1238    /// Ensure `root` has an entry (created with `config` if absent) and a
1239    /// committed analysis (one round run if none yet), and return it. For the
1240    /// cross-project workspace-symbol scan, which must answer over every project
1241    /// including ones no request has warmed. `None` if the round produced none.
1242    async fn ensure_project_analysed(
1243        &self,
1244        root: PathBuf,
1245        config: ProjectConfig,
1246    ) -> Option<Arc<Analysis>> {
1247        {
1248            let mut state = self.state.write().await;
1249            state
1250                .projects
1251                .entry(root.clone())
1252                .or_insert_with(|| ProjectState {
1253                    config,
1254                    ..Default::default()
1255                });
1256        }
1257        if let Some(a) = self.project_analysis(&root).await {
1258            return Some(a);
1259        }
1260        self.refresh_now(root.clone()).await;
1261        self.project_analysis(&root).await
1262    }
1263
1264    /// Slice D (Q4 lifecycle): drop every project no longer reachable from a
1265    /// workspace folder **and** holding no open buffer, clearing its published
1266    /// diagnostics. A project is retained while some remaining folder relates to
1267    /// it (one is a path-prefix of the other — a file under that folder can still
1268    /// route to the root) or while any open buffer routes to it. Shared by the
1269    /// two events that can orphan a project: a folder leaving
1270    /// (`did_change_workspace_folders`) and its last buffer closing (`did_close`)
1271    /// — a project falls only when *both* its seed and its buffers are gone.
1272    /// Returns the URIs whose diagnostics were cleared so the caller can publish
1273    /// the clears (done outside the lock).
1274    async fn prune_orphaned_projects(&self) -> Vec<Url> {
1275        // #733: `root_for_uri_uncached` canonicalises and walks the filesystem
1276        // up to a `bynk.toml` for every open buffer — syscalls that must not run
1277        // while holding `state.write()`. Snapshot the inputs under a short read
1278        // lock, resolve the open roots off the lock, then take the write lock
1279        // only to mutate `projects`.
1280        //
1281        // #682 (DECISION B): this stays on the *uncached* router rather than
1282        // `root_for_uri` — pruning is not hot (it fires only on folder-removal
1283        // or close), and it runs inside a synchronous `filter_map` off the
1284        // lock, where an async, cache-consulting router can't be called inline
1285        // without either re-locking `state` here (defeating the point of
1286        // computing `open_roots` off-lock) or restructuring this into an async
1287        // stream. This opens a small TOCTOU window: `orphaned` is
1288        // computed against live `state.projects` under the write lock but against
1289        // the *snapshot's* `folders`/`open_roots`, so a `did_open` that lands in
1290        // between — newly covering a root — is not yet in `open_roots` and that
1291        // root could be pruned here. It is self-healing: the pruning callers
1292        // (`did_close`, `did_change_workspace_folders`) only ever *remove*
1293        // coverage, so a racing `did_open` re-creates the entry the moment that
1294        // buffer routes/analyses (`schedule_diagnostics` → a lazily-created
1295        // `ProjectState`) — its diagnostics clear-then-repopulate, never a
1296        // permanently-dropped project.
1297        let (folders, open_uris) = {
1298            let state = self.state.read().await;
1299            (
1300                state.folders.clone(),
1301                state.docs.keys().cloned().collect::<Vec<_>>(),
1302            )
1303        };
1304        let open_roots: std::collections::HashSet<PathBuf> = open_uris
1305            .iter()
1306            .filter_map(Self::root_for_uri_uncached)
1307            .collect();
1308        let covered = |root: &std::path::Path| {
1309            folders
1310                .iter()
1311                .any(|f| f.starts_with(root) || root.starts_with(f))
1312                || open_roots.contains(root)
1313        };
1314        let mut state = self.state.write().await;
1315        let orphaned: Vec<PathBuf> = state
1316            .projects
1317            .keys()
1318            .filter(|r| !covered(r))
1319            .cloned()
1320            .collect();
1321        let mut to_clear = Vec::new();
1322        for root in orphaned {
1323            if let Some(ps) = state.projects.remove(&root) {
1324                to_clear.extend(ps.published);
1325            }
1326        }
1327        to_clear
1328    }
1329
1330    /// Slice E: discover and warm every project under `folders` — create each
1331    /// entry (idempotent, keyed by canonical root) and schedule its round — so a
1332    /// workspace shows diagnostics without a file being opened. Non-blocking:
1333    /// entries are created synchronously (routing is immediately correct) and the
1334    /// rounds run on the debounce path. Shared by `initialized` (all folders) and
1335    /// the `did_change_workspace_folders` added branch (the new folders).
1336    async fn warm_projects(&self, folders: &[PathBuf]) {
1337        if folders.is_empty() {
1338            return;
1339        }
1340        // Discover off the lock **and** off the executor: the walk is synchronous
1341        // FS I/O, so run it on a blocking thread rather than stalling an async
1342        // worker while a workspace tree is scanned.
1343        let folders = folders.to_vec();
1344        let roots = tokio::task::spawn_blocking(move || {
1345            let mut roots: Vec<PathBuf> = Vec::new();
1346            for folder in &folders {
1347                for root in Self::discover_projects_under(folder) {
1348                    if !roots.contains(&root) {
1349                        roots.push(root);
1350                    }
1351                }
1352            }
1353            roots
1354        })
1355        .await
1356        .unwrap_or_else(|e| {
1357            tracing::error!("project discovery {}", describe_join_error(e));
1358            Vec::new()
1359        });
1360        for root in roots {
1361            let config = project::load_config(&root).unwrap_or_default();
1362            {
1363                let mut state = self.state.write().await;
1364                state
1365                    .projects
1366                    .entry(root.clone())
1367                    .or_insert_with(|| ProjectState {
1368                        config,
1369                        ..Default::default()
1370                    });
1371            }
1372            self.schedule_project_diagnostics(root).await;
1373        }
1374    }
1375
1376    /// Slice E: register the `workspace/didChangeWatchedFiles` capability with
1377    /// the client — once, with folder-independent globs (`**/*.bynk`,
1378    /// `**/bynk.toml`), per Q4 (ADR 0204 §D). So a client that supports dynamic
1379    /// registration is notified of source and manifest changes without watching
1380    /// files itself. Best-effort: a registration failure is logged, not fatal.
1381    async fn register_file_watchers(&self) {
1382        use tower_lsp::lsp_types::{
1383            DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, Registration,
1384        };
1385        let watchers = ["**/*.bynk", "**/bynk.toml"]
1386            .into_iter()
1387            .map(|g| FileSystemWatcher {
1388                glob_pattern: GlobPattern::String(g.to_string()),
1389                kind: None, // create | change | delete
1390            })
1391            .collect();
1392        let registration = Registration {
1393            id: "bynk-watched-files".to_string(),
1394            method: "workspace/didChangeWatchedFiles".to_string(),
1395            register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
1396                watchers,
1397            })
1398            .ok(),
1399        };
1400        if let Err(e) = self.client.register_capability(vec![registration]).await {
1401            self.client
1402                .log_message(
1403                    MessageType::WARNING,
1404                    format!("bynkc-lsp: file-watcher registration failed: {e}"),
1405                )
1406                .await;
1407        }
1408    }
1409
1410    /// The `bynk.toml` config governing `uri` — its project's, or the default
1411    /// (single-file mode). Backs the per-file diagnostics mode/debounce and the
1412    /// formatting options, which now differ by project.
1413    async fn config_for(&self, uri: &Url) -> ProjectConfig {
1414        let Some(root) = self.root_for_uri(uri).await else {
1415            return ProjectConfig::default();
1416        };
1417        self.state
1418            .read()
1419            .await
1420            .projects
1421            .get(&root)
1422            .map(|p| p.config.clone())
1423            .unwrap_or_default()
1424    }
1425
1426    /// Slice B — the freshness contract (Q3, settled #663). The analysis a
1427    /// request must answer from, **current for `uri`**: cold start triggers a
1428    /// round; a round that predates `uri`'s buffer triggers a refresh.
1429    ///
1430    /// The client's request position refers to `uri`'s current document
1431    /// version — messages are ordered, so `docs[uri].version` reflects every
1432    /// `didChange` sent before the request. The returned analysis is guaranteed
1433    /// to have analysed *that* version of `uri`, so `position_to_offset` against
1434    /// its snapshot is never resolved against text the user edited past.
1435    ///
1436    /// Slice D: routes to the project that owns `uri` (Q4) before gating, so the
1437    /// freshness check is against *that* project's round. A file under no
1438    /// project (single-file mode) is never index-answerable — decline.
1439    ///
1440    /// Returns `None` — decline, per Q3 — only when the request cannot be
1441    /// answered at the version the client holds: single-file mode (no project),
1442    /// a file outside every `include` root (never a snapshot key), or a
1443    /// concurrent edit that moved past the refresh (rare; the next request is
1444    /// current). Never returns an analysis whose snapshot for `uri` is stale.
1445    async fn analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1446        let root = self.root_for_uri(uri).await?;
1447        // The version the request's position is stated against. `None` when the
1448        // file is not an open buffer — then any round is as authoritative as it
1449        // gets (nothing newer to be stale against), so the freshness gate is a
1450        // no-op and only cold start matters.
1451        let want = self.state.read().await.docs.get(uri).map(|d| d.version);
1452        let current = |a: &Arc<Analysis>| {
1453            let Some(rel) = Self::uri_to_rel(a, uri) else {
1454                return false; // unmappable URI — cannot be answered
1455            };
1456            // The file must actually be *analysed* (a snapshot key), not merely
1457            // have a version entry: `versions` is built from open docs, so a
1458            // file open but outside every `include` root has a version and no
1459            // snapshot. Such a file is never answerable — decline.
1460            if !a.snapshots.contains_key(&rel) {
1461                return false;
1462            }
1463            match want {
1464                // Open buffer: the analysed snapshot must be at the client's
1465                // version, or the position resolves against text edited past.
1466                Some(v) => a.versions.get(&rel) == Some(&v),
1467                // Not an open buffer (a closed/disk file, e.g. a goto target):
1468                // the analysed round is authoritative — nothing newer to lag.
1469                None => true,
1470            }
1471        };
1472
1473        if let Some(a) = self.project_analysis(&root).await
1474            && current(&a)
1475        {
1476            return Some(a);
1477        }
1478
1479        // Refresh. The lock serialises concurrent requests: the first runs the
1480        // round, the rest wait and then find it already current below — so N
1481        // requests after one edit share one round, not N. (One lock across all
1482        // projects is fine — a refresh holds it only across its own round.)
1483        let _guard = self.refresh_lock.lock().await;
1484        if let Some(a) = self.project_analysis(&root).await
1485            && current(&a)
1486        {
1487            return Some(a);
1488        }
1489        self.refresh_now(root.clone()).await;
1490        let a = self.project_analysis(&root).await?;
1491        // Strict: only answer if the fresh round is actually current for `uri`.
1492        // An edit that landed during the round leaves us behind — decline, and
1493        // the next request refreshes again. Never a position against stale text.
1494        current(&a).then_some(a)
1495    }
1496
1497    /// #733 — the non-refreshing gate for **pull-based decoration requests**
1498    /// (`semanticTokens`, `inlayHint`, `codeLens`, `documentLink`, `codeAction`).
1499    /// Returns the last committed round for `uri`'s project **as-is**, without
1500    /// forcing a synchronous re-analysis on the request path.
1501    ///
1502    /// Why this is safe where [`Self::analysis_for`] is not: these handlers
1503    /// resolve nothing against the client's *live* cursor — every range and span
1504    /// they emit converts against the round's own `snapshots` (or, for
1505    /// `document_link`, against live text plus the project-level `unit_sources`
1506    /// map). So a committed round lagging the buffer by at most one debounce
1507    /// cycle is internally consistent; the strict version match `analysis_for`
1508    /// demands is stronger than a decoration needs. The editor auto-fires these
1509    /// on every `didChange`, so forcing a whole-project round here is exactly
1510    /// what defeated the debounce (#733).
1511    ///
1512    /// This is stale-while-revalidate: serve the committed round now; the
1513    /// already-scheduled debounce round is the revalidation, and on its commit
1514    /// [`Self::run_project_diagnostics`] nudges the client to re-pull via
1515    /// `workspace/*/refresh`. Cursor requests keep the strict gate.
1516    ///
1517    /// `None` — the handler returns empty — when the file is under no project, is
1518    /// outside every `include` root (never a snapshot key), or no round has
1519    /// committed yet (cold start; the scheduled round will produce one and the
1520    /// client re-pulls on the refresh nudge).
1521    async fn committed_analysis(&self, uri: &Url) -> Option<Arc<Analysis>> {
1522        let root = self.root_for_uri(uri).await?;
1523        let a = self.project_analysis(&root).await?;
1524        // Must actually be analysed (a snapshot key), not merely version-tracked
1525        // — the handler converts its spans against this snapshot.
1526        let rel = Self::uri_to_rel(&a, uri)?;
1527        a.snapshots.contains_key(&rel).then_some(a)
1528    }
1529
1530    /// Slice B: the analysis for a handler that emits **multi-file versioned
1531    /// edits** — today, `rename`. Per-URI freshness ([`Self::analysis_for`]) is
1532    /// not enough here: a rename touches every file that references the symbol,
1533    /// and each edit is stamped with *that* file's analysed version, so the
1534    /// round must be current for **every open buffer**, not just the cursor's.
1535    ///
1536    /// Without this, a buffer edited since the last round but not under the
1537    /// cursor keeps its stale version in the round; `rename`'s edit for it is
1538    /// then stamped with that old version and the client rejects the whole
1539    /// operation (VS Code: "document changed since the refactoring was
1540    /// requested"). This restores the whole-project guarantee the pre-v0.179
1541    /// `fresh_analysis` gave — as a version-aware refresh, not an unconditional
1542    /// one. Returns `None` on the same terms as `analysis_for` (no project, or a
1543    /// concurrent edit that raced the refresh).
1544    ///
1545    /// Slice D: takes the rename's project `root` — a rename spans one project
1546    /// (the symbol and its references live under one root), so the round must
1547    /// cover *that* project's open buffers. A buffer in another project strips
1548    /// against a different `project_root`, so `uri_to_rel` returns `None` for it
1549    /// and it does not gate this rename.
1550    async fn analysis_covering_open_buffers(
1551        &self,
1552        root: &std::path::Path,
1553    ) -> Option<Arc<Analysis>> {
1554        // Every open buffer that maps into the project must be analysed at its
1555        // current version. A buffer outside the project (no snapshot key) is not
1556        // part of a project rename and does not gate it.
1557        let all_current =
1558            |a: &Arc<Analysis>, docs: &std::collections::HashMap<Url, DocumentState>| {
1559                docs.iter()
1560                    .all(|(uri, doc)| match Self::uri_to_rel(a, uri) {
1561                        Some(rel) if a.snapshots.contains_key(&rel) => {
1562                            a.versions.get(&rel) == Some(&doc.version)
1563                        }
1564                        _ => true,
1565                    })
1566            };
1567
1568        {
1569            let state = self.state.read().await;
1570            if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1571                && all_current(&a, &state.docs)
1572            {
1573                return Some(a);
1574            }
1575        }
1576        let _guard = self.refresh_lock.lock().await;
1577        {
1578            let state = self.state.read().await;
1579            if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1580                && all_current(&a, &state.docs)
1581            {
1582                return Some(a);
1583            }
1584        }
1585        self.refresh_now(root.to_path_buf()).await;
1586        let state = self.state.read().await;
1587        let a = state.projects.get(root).and_then(|p| p.analysis.clone())?;
1588        all_current(&a, &state.docs).then_some(a)
1589    }
1590
1591    /// Run a round now for one project, superseding any pending debounced one.
1592    /// Bumping the project's generation makes a scheduled round (which checks it
1593    /// before running) bail, so a request-driven refresh does not race a
1594    /// redundant debounce round that would produce the same result 200 ms later.
1595    async fn refresh_now(&self, root: PathBuf) {
1596        if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
1597            ps.analysis_generation += 1;
1598        }
1599        self.run_project_diagnostics(root).await;
1600    }
1601
1602    /// Map a request URI to the analysis' project-relative path.
1603    fn uri_to_rel(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1604        let p = uri.to_file_path().ok()?;
1605        let canonical = p.canonicalize().unwrap_or(p);
1606        // Slice A: one `strip_prefix` still, but against the *project* root —
1607        // which is total across `include` trees, where the old `src` base could
1608        // only ever name files in one of them. A file under no root strips fine
1609        // and simply misses every lookup, which is correct: it was not analysed.
1610        canonical
1611            .strip_prefix(&analysis.project_root)
1612            .ok()
1613            .map(|r| r.to_path_buf())
1614    }
1615
1616    /// #302: like [`Self::uri_to_rel`], but for a URI whose file does not
1617    /// exist yet — `willRenameFiles`' `new_uri`, named before the physical
1618    /// move happens. `Path::canonicalize` requires the path to exist, so
1619    /// `uri_to_rel`'s fallback (`unwrap_or(p)`, dead code for every other
1620    /// caller, which only ever resolves existing files) would silently keep
1621    /// the client's raw, non-canonical path — mismatching `project_root`
1622    /// (always canonical) whenever the workspace sits behind a symlink (macOS
1623    /// `/tmp` → `/private/tmp` being the common case), and the rename would
1624    /// quietly produce no edit. Canonicalizing the *parent* directory
1625    /// instead — it does exist — and rejoining the file name sidesteps that.
1626    fn uri_to_rel_for_new_path(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1627        let p = uri.to_file_path().ok()?;
1628        let file_name = p.file_name()?;
1629        let parent = p.parent()?;
1630        let canonical_parent = parent
1631            .canonicalize()
1632            .unwrap_or_else(|_| parent.to_path_buf());
1633        canonical_parent
1634            .join(file_name)
1635            .strip_prefix(&analysis.project_root)
1636            .ok()
1637            .map(|r| r.to_path_buf())
1638    }
1639
1640    /// Slice 6a follow-up (ADR 0095): if `pos` sits on a `uses`/`consumes` unit
1641    /// name, the location of that unit's source (its first file, at the top —
1642    /// units aren't index symbols, so there is no finer def span to land on).
1643    /// Spans come from the live buffer; the target from the round's unit→source
1644    /// map. `None` for a first-party/unresolved unit or a non-unit position.
1645    async fn unit_reference_definition(&self, uri: &Url, pos: Position) -> Option<Location> {
1646        // Slice B: the position is resolved against *live* text (no stale-offset
1647        // risk), but the `uses`/`consumes` → source lookup reads the round's
1648        // `unit_sources`, so route that through the gate — fresh or decline,
1649        // never a stale unit map. Cheap here: `goto_definition` already
1650        // refreshed via `index_position`, so this hits the current-round path.
1651        let analysis = self.analysis_for(uri).await;
1652        let text = self
1653            .state
1654            .read()
1655            .await
1656            .docs
1657            .get(uri)
1658            .map(|d| d.text.clone());
1659        let (text, analysis) = (text?, analysis?);
1660        let offset = cursor_offset(&text, pos);
1661        for (unit, span) in crate::symbols::unit_reference_spans(&text) {
1662            if span.start <= offset && offset <= span.end {
1663                let rel = analysis.unit_sources.get(&unit)?.first()?;
1664                let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
1665                return Some(Location {
1666                    uri: target,
1667                    range: Range::default(),
1668                });
1669            }
1670        }
1671        None
1672    }
1673
1674    /// Convert an index site to an LSP location, spans against the analysed
1675    /// snapshot (v0.24 rule).
1676    fn site_to_location(
1677        analysis: &Analysis,
1678        site: &bynk_check::index::SiteRef,
1679    ) -> Option<Location> {
1680        let text = analysis.snapshots.get(&site.path)?;
1681        let abs = analysis.project_root.join(&site.path);
1682        let uri = Url::from_file_path(abs).ok()?;
1683        Some(Location {
1684            uri,
1685            range: crate::position::span_to_range(text, site.span),
1686        })
1687    }
1688
1689    /// v0.34 (ADR 0067): build a `CallHierarchyItem` for an index symbol from
1690    /// its key + definition site. The key is round-tripped through `data` so
1691    /// the incoming/outgoing follow-ups resolve straight off it, never
1692    /// re-inferring from a position.
1693    fn call_hierarchy_item(
1694        analysis: &Analysis,
1695        key: &bynk_check::index::SymbolKey,
1696        def: &bynk_check::index::SiteRef,
1697    ) -> Option<CallHierarchyItem> {
1698        let location = Self::site_to_location(analysis, def)?;
1699        Some(CallHierarchyItem {
1700            name: key.name.clone(),
1701            kind: lsp_symbol_kind(key.kind),
1702            tags: None,
1703            detail: Some(key.unit.clone()),
1704            uri: location.uri,
1705            range: location.range,
1706            selection_range: location.range,
1707            data: serde_json::to_value(SerKey::from(key)).ok(),
1708        })
1709    }
1710
1711    /// The call-site ranges (`fromRanges`) for a call relation, each converted
1712    /// against its file's analysed snapshot.
1713    fn call_ranges(analysis: &Analysis, sites: &[&bynk_check::index::SiteRef]) -> Vec<Range> {
1714        sites
1715            .iter()
1716            .filter_map(|s| {
1717                let text = analysis.snapshots.get(&s.path)?;
1718                Some(crate::position::span_to_range(text, s.span))
1719            })
1720            .collect()
1721    }
1722
1723    /// v0.28 (ADR 0057): the shared body of both semantic-tokens requests —
1724    /// resolve the cached round, convert the optional range against the
1725    /// analysed snapshot, and run the pure producer. Empty when no round is
1726    /// cached or the file is outside the project.
1727    async fn semantic_tokens_for(&self, uri: &Url, range: Option<Range>) -> Vec<SemanticToken> {
1728        // #733: serve the last committed round without forcing a re-analysis —
1729        // tokens convert against the round's own snapshot, so a one-cycle lag is
1730        // consistent, and the client re-pulls on the round-commit refresh nudge.
1731        let analysis = self.committed_analysis(uri).await;
1732        let Some(analysis) = analysis else {
1733            return Vec::new();
1734        };
1735        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1736            return Vec::new();
1737        };
1738        let Some(text) = analysis.snapshots.get(&rel) else {
1739            return Vec::new();
1740        };
1741        let span = match range {
1742            None => None,
1743            // The requested range converts against the analysed snapshot,
1744            // like the spans it is intersected with.
1745            Some(r) => {
1746                let (Some(start), Some(end)) = (
1747                    crate::position::position_to_offset(text, r.start),
1748                    crate::position::position_to_offset(text, r.end),
1749                ) else {
1750                    return Vec::new();
1751                };
1752                Some(bynk_syntax::span::Span::new(start, end))
1753            }
1754        };
1755        let lt = analysis
1756            .locals
1757            .get(&rel)
1758            .map(|l| crate::locals_nav::local_token_sites(l, text))
1759            .unwrap_or_default();
1760        // v0.140 (ADR 0163): handler-annotation spans (`@cache` name + argument
1761        // labels), classified as `decorator`. Parsed from the snapshot here, off
1762        // the index-read path (mirroring how locals are precomputed).
1763        let dt = crate::symbols::handler_annotation_token_spans(text);
1764        crate::index_queries::semantic_tokens(&analysis.index, &lt, &dt, &rel, text, span)
1765    }
1766
1767    /// The (analysis, rel-path, snapshot byte offset) for a request
1768    /// position — the shared front half of every index-backed handler.
1769    async fn index_position(
1770        &self,
1771        uri: &Url,
1772        position: Position,
1773    ) -> Option<(Arc<Analysis>, PathBuf, usize)> {
1774        // Slice B: `analysis_for` guarantees the round analysed `uri`'s current
1775        // version, so `position_to_offset` resolves against the same text the
1776        // client's position refers to — the `fresh` flag every caller used to
1777        // pass is gone (freshness is the contract now, not a per-call choice).
1778        let analysis = self.analysis_for(uri).await?;
1779        let rel = Self::uri_to_rel(&analysis, uri)?;
1780        let text = analysis.snapshots.get(&rel)?;
1781        let offset = crate::position::position_to_offset(text, position)?;
1782        Some((analysis, rel, offset))
1783    }
1784
1785    /// Locate the AST node at the given cursor position by re-parsing the
1786    /// document. Returns the textual identifier (if any) and its span.
1787    /// Used by hover and definition handlers.
1788    async fn identifier_at(
1789        &self,
1790        uri: &Url,
1791        position: Position,
1792    ) -> Option<(String, bynk_syntax::span::Span, String)> {
1793        let text = {
1794            let state = self.state.read().await;
1795            state.docs.get(uri)?.text.clone()
1796        };
1797        let offset = crate::position::position_to_offset(&text, position)?;
1798        // Hole-aware (issue #473): interpolation holes are expanded so a cursor
1799        // inside `"… \(name) …"` lands on the hole's identifier token, not the
1800        // opaque `InterpStr` token.
1801        let tokens = bynk_syntax::lexer::tokenize_expanding_holes(&text).ok()?;
1802        // Find the token whose span covers `offset`.
1803        for t in &tokens {
1804            if t.span.start <= offset
1805                && offset < t.span.end
1806                && matches!(
1807                    t.kind,
1808                    bynk_syntax::lexer::TokenKind::Ident
1809                        | bynk_syntax::lexer::TokenKind::Int
1810                        | bynk_syntax::lexer::TokenKind::String
1811                        | bynk_syntax::lexer::TokenKind::Bool
1812                        | bynk_syntax::lexer::TokenKind::Float
1813                        | bynk_syntax::lexer::TokenKind::Result
1814                        | bynk_syntax::lexer::TokenKind::Option
1815                        | bynk_syntax::lexer::TokenKind::Effect
1816                )
1817            {
1818                let name = text[t.span.start..t.span.end].to_string();
1819                return Some((name, t.span, text));
1820            }
1821        }
1822        None
1823    }
1824
1825    /// #846: `bynk/sequenceModel` — the sequence-diagram query for the
1826    /// handler under the cursor. This server's first custom (non-standard)
1827    /// request, registered via `custom_method` in [`run`] rather than a
1828    /// `LanguageServer` trait slot. Served from the committed round (#733),
1829    /// like `code_lens`; no refresh nudge (see the `sequence_request` module
1830    /// doc for why one isn't needed).
1831    async fn sequence_model(
1832        &self,
1833        params: sequence_request::SequenceModelParams,
1834    ) -> JsonRpcResult<Option<sequence_request::WireSequenceModel>> {
1835        let uri = params.text_document.uri;
1836        let Some(analysis) = self.committed_analysis(&uri).await else {
1837            return Ok(None);
1838        };
1839        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1840            return Ok(None);
1841        };
1842        let Some(text) = analysis.snapshots.get(&rel) else {
1843            return Ok(None);
1844        };
1845        let Some(offset) = crate::position::position_to_offset(text, params.position) else {
1846            return Ok(None);
1847        };
1848        let info = bynk_ide::symbols::own_declaration_name(text)
1849            .and_then(|(name, _)| analysis.sequence_info.get(&name));
1850        let model = sequence_request::sequence_model_at(text, offset, info);
1851        Ok(model.map(|m| sequence_request::to_wire(&m, text)))
1852    }
1853
1854    /// #847: `bynk/documentationModel` — the documentation-view query for the
1855    /// whole file under the request. This server's second custom request,
1856    /// registered via `custom_method` in [`run`] (like `sequence_model`).
1857    /// Served from the committed round (#733), on-demand: no cursor position
1858    /// (the page is the whole file, Decision A) and no refresh nudge (Decision
1859    /// D — see the `documentation_request` module doc, and #846's for why a
1860    /// custom method needs none). A non-project file / no committed round →
1861    /// `None` (empty page).
1862    async fn documentation_model(
1863        &self,
1864        params: documentation_request::DocumentationModelParams,
1865    ) -> JsonRpcResult<Option<documentation_request::WireDocModel>> {
1866        let uri = params.text_document.uri;
1867        let Some(analysis) = self.committed_analysis(&uri).await else {
1868            return Ok(None);
1869        };
1870        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1871            return Ok(None);
1872        };
1873        let Some(text) = analysis.snapshots.get(&rel) else {
1874            return Ok(None);
1875        };
1876        let model = documentation_request::documentation_model_at(text);
1877        Ok(model.map(|m| documentation_request::to_wire(&m, text)))
1878    }
1879
1880    /// #851: `bynk/architectureModel` — the whole-project architecture-map
1881    /// query. This server's third custom request, registered via
1882    /// `custom_method` in [`run`] (like `sequence_model`/`documentation_model`).
1883    /// Served from the committed round (#733); no refresh nudge, for the same
1884    /// reason neither sibling needs one. Unlike both siblings this is
1885    /// **project-scoped** — `params.text_document` only resolves which
1886    /// project's round to read (via `committed_analysis`); the result covers
1887    /// every context/adapter unit in that round, not just the request's own
1888    /// file. A non-project file / no committed round → `None` (empty map).
1889    async fn architecture_model(
1890        &self,
1891        params: architecture_request::ArchitectureModelParams,
1892    ) -> JsonRpcResult<Option<architecture_request::WireArchModel>> {
1893        let uri = params.text_document.uri;
1894        let Some(analysis) = self.committed_analysis(&uri).await else {
1895            return Ok(None);
1896        };
1897        let model = architecture_request::architecture_model_for(
1898            &analysis.unit_sources,
1899            &analysis.snapshots,
1900            &analysis.sequence_info,
1901        );
1902        Ok(Some(architecture_request::to_wire(
1903            &model,
1904            &analysis.project_root,
1905            &analysis.snapshots,
1906        )))
1907    }
1908
1909    /// #855: `bynk/wireContract` — the wire-contract peek for the handler
1910    /// under the cursor. This server's fourth custom request, modelled on
1911    /// `sequence_model` (file-scoped + position, not project-scoped like
1912    /// `architecture_model` — the panel is per-handler). Served from the
1913    /// committed round; no refresh nudge, for the same reason no custom
1914    /// request needs one. A non-project file, no committed round, an offset
1915    /// outside any handler, or a unit `boundary_info` has no entry for (the
1916    /// pipeline bailed before the checker) all answer `None` (`null` on the
1917    /// wire).
1918    async fn wire_contract(
1919        &self,
1920        params: wire_contract_request::WireContractParams,
1921    ) -> JsonRpcResult<Option<wire_contract_request::WcModel>> {
1922        let uri = params.text_document.uri;
1923        let Some(analysis) = self.committed_analysis(&uri).await else {
1924            return Ok(None);
1925        };
1926        let tys = &analysis.ty_intern;
1927        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1928            return Ok(None);
1929        };
1930        let Some(text) = analysis.snapshots.get(&rel) else {
1931            return Ok(None);
1932        };
1933        let Some(offset) = crate::position::position_to_offset(text, params.position) else {
1934            return Ok(None);
1935        };
1936        // The owning unit — same `own_declaration_name` convention
1937        // `sequence_model` uses to key `sequence_info`.
1938        let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text) else {
1939            return Ok(None);
1940        };
1941        let Some(info) = analysis.boundary_info.get(&unit) else {
1942            return Ok(None);
1943        };
1944        let expr_types = analysis
1945            .expr_types
1946            .get(&rel)
1947            .map(|v| v.as_slice())
1948            .unwrap_or(&[]);
1949        let context_count = bynk_ide::wire_contract::real_context_count(
1950            &analysis.boundary_info,
1951            &analysis.unit_sources,
1952        );
1953        let Some(model) = bynk_ide::wire_contract::wire_contract_at(
1954            &unit,
1955            text,
1956            offset,
1957            info,
1958            expr_types,
1959            tys,
1960            context_count,
1961        ) else {
1962            return Ok(None);
1963        };
1964        // #848's own search order (self, then `uses`, then `consumes`) —
1965        // reused rather than re-derived, since it is exactly the priority a
1966        // boundary type name resolves through in `ContextBoundaryInfo::types`.
1967        let search_order = analysis
1968            .doc_scope
1969            .get(&unit)
1970            .cloned()
1971            .unwrap_or_else(|| vec![unit.clone()]);
1972        Ok(Some(wire_contract_request::to_wire(
1973            &model,
1974            &analysis.project_root,
1975            text,
1976            &info.types,
1977            &analysis.index,
1978            &analysis.snapshots,
1979            &search_order,
1980        )))
1981    }
1982}
1983
1984#[tower_lsp::async_trait]
1985impl LanguageServer for Backend {
1986    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1987        // Slice D (Q4): record **every** workspace folder as a discovery seed
1988        // (was `folders.first()` only). Folders do not own URIs — a request
1989        // routes by its nearest enclosing `bynk.toml` (`resolve_root`) — so this
1990        // seeds where `did_change_workspace_folders` prunes and where slice E's
1991        // startup scan looks. Slice E: also capture whether the client accepts a
1992        // server-side `didChangeWatchedFiles` registration, used in `initialized`.
1993        let dynamic_watchers = params
1994            .capabilities
1995            .workspace
1996            .as_ref()
1997            .and_then(|w| w.did_change_watched_files.as_ref())
1998            .and_then(|d| d.dynamic_registration)
1999            .unwrap_or(false);
2000        // #733: whether the client can be nudged to re-pull each pull-based
2001        // decoration after a round commits (the "revalidate" of stale-while-
2002        // revalidate). Absent → the flag stays false and no nudge is sent.
2003        let ws = params.capabilities.workspace.as_ref();
2004        let supports_refresh = RefreshSupport {
2005            semantic_tokens: ws
2006                .and_then(|w| w.semantic_tokens.as_ref())
2007                .and_then(|s| s.refresh_support)
2008                .unwrap_or(false),
2009            inlay_hints: ws
2010                .and_then(|w| w.inlay_hint.as_ref())
2011                .and_then(|i| i.refresh_support)
2012                .unwrap_or(false),
2013            code_lens: ws
2014                .and_then(|w| w.code_lens.as_ref())
2015                .and_then(|c| c.refresh_support)
2016                .unwrap_or(false),
2017        };
2018        {
2019            let mut state = self.state.write().await;
2020            state.supports_dynamic_watchers = dynamic_watchers;
2021            state.supports_refresh = supports_refresh;
2022            if let Some(folders) = &params.workspace_folders {
2023                state.folders = folders
2024                    .iter()
2025                    .filter_map(|f| f.uri.to_file_path().ok())
2026                    .map(|p| p.canonicalize().unwrap_or(p))
2027                    .collect();
2028            }
2029        }
2030        Ok(InitializeResult {
2031            capabilities: server_capabilities(),
2032            server_info: Some(ServerInfo {
2033                name: SERVER_NAME.into(),
2034                version: Some(SERVER_VERSION.into()),
2035            }),
2036        })
2037    }
2038
2039    async fn initialized(&self, _: InitializedParams) {
2040        let (folders, dynamic) = {
2041            let s = self.state.read().await;
2042            (s.folders.clone(), s.supports_dynamic_watchers)
2043        };
2044        // Slice E (Q4/ADR 0204 §D): register the file watchers server-side, once,
2045        // with folder-independent globs — so any client is notified, and the VS
2046        // Code extension no longer supplies them (avoiding a double
2047        // notification). Only when the client accepts dynamic registration;
2048        // otherwise it is expected to watch files itself.
2049        if dynamic {
2050            self.register_file_watchers().await;
2051        }
2052        // Slice E: warm every project under the workspace folders, so diagnostics
2053        // appear at activation without a file being opened (spec §2.3).
2054        self.warm_projects(&folders).await;
2055        let msg = if folders.is_empty() {
2056            "bynkc-lsp: no workspace folders; single-file mode".to_string()
2057        } else {
2058            format!(
2059                "bynkc-lsp: {} workspace folder(s); projects resolved per file",
2060                folders.len()
2061            )
2062        };
2063        self.client.log_message(MessageType::INFO, msg).await;
2064    }
2065
2066    async fn shutdown(&self) -> JsonRpcResult<()> {
2067        self.shutdown_requested
2068            .store(true, std::sync::atomic::Ordering::SeqCst);
2069        Ok(())
2070    }
2071
2072    async fn did_open(&self, params: DidOpenTextDocumentParams) {
2073        let uri = params.text_document.uri.clone();
2074        {
2075            let mut state = self.state.write().await;
2076            state.docs.insert(
2077                uri.clone(),
2078                DocumentState {
2079                    text: params.text_document.text,
2080                    version: params.text_document.version,
2081                },
2082            );
2083        }
2084        // Slice D/F: `schedule_diagnostics` routes the URI to its project and
2085        // creates the entry on first touch — no separate root-setting step.
2086        self.schedule_diagnostics(&uri).await;
2087    }
2088
2089    async fn did_change(&self, params: DidChangeTextDocumentParams) {
2090        let uri = params.text_document.uri.clone();
2091        {
2092            let mut state = self.state.write().await;
2093            if let Some(doc) = state.docs.get_mut(&uri)
2094                && let Some(change) = params.content_changes.into_iter().next_back()
2095            {
2096                doc.text = change.text;
2097                doc.version = params.text_document.version;
2098            }
2099        }
2100        // `[lsp] diagnostics_mode = "on_save"`: no per-keystroke rounds — the
2101        // buffer state is updated above and diagnosis waits for `didSave`.
2102        // Slice D: the mode is the *owning project's* (config differs per
2103        // project); a single-file buffer uses the defaults.
2104        if self.config_for(&uri).await.diagnostics_mode == crate::project::DiagnosticsMode::OnSave {
2105            return;
2106        }
2107        // Slice F: hand off to the one scheduler — it debounces once, at the
2108        // configured delay (no manual pre-sleep stacked on the round's own
2109        // debounce), and coalesces a burst to a single analysis.
2110        self.schedule_diagnostics(&uri).await;
2111    }
2112
2113    async fn did_save(&self, params: DidSaveTextDocumentParams) {
2114        // The live path already diagnosed on change; this matters for
2115        // `diagnostics_mode = "on_save"`, where saves are the only trigger.
2116        self.schedule_diagnostics(&params.text_document.uri).await;
2117    }
2118
2119    async fn did_close(&self, params: DidCloseTextDocumentParams) {
2120        let uri = params.text_document.uri;
2121        {
2122            let mut state = self.state.write().await;
2123            state.docs.remove(&uri);
2124            // Slice F: drop the buffer's single-file debounce generation (a no-op
2125            // for a project file, which never had one).
2126            state.single_file_generations.remove(&uri);
2127        }
2128        // Slice D (Q4 §C): closing the last buffer can orphan a project whose
2129        // folder was already removed — it was retained *because* a buffer held
2130        // it. Prune it now and clear its diagnostics, the mirror of the folder
2131        // path, so a fully-orphaned project never lingers with stale squiggles.
2132        for cleared in self.prune_orphaned_projects().await {
2133            self.client
2134                .publish_diagnostics(cleared, Vec::new(), None)
2135                .await;
2136        }
2137    }
2138
2139    /// Transport only: resolve the position, gather the round's tables and the
2140    /// live buffer, and package the result. The resolution *order* — which is the
2141    /// behaviour — lives in [`crate::hover::hover_content`], so it has one
2142    /// definition a test can pin (ADR 0190; #611's gap B was a fall-through bug).
2143    async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
2144        let uri = params.text_document_position_params.text_document.uri;
2145        let pos = params.text_document_position_params.position;
2146        // The analysed round, positioned — absent for a file outside it.
2147        let positioned = self.index_position(&uri, pos).await;
2148        // The live buffer — absent when the document is not open. Distinct from
2149        // the snapshot above, which lags while the user types.
2150        let doc_text = {
2151            let state = self.state.read().await;
2152            state.docs.get(&uri).map(|d| d.text.clone())
2153        };
2154        let doc = doc_text
2155            .as_deref()
2156            .and_then(|t| Some((t, crate::position::position_to_offset(t, pos)?)));
2157        let files = self.project_content(&uri).await;
2158        let analysis = positioned
2159            .as_ref()
2160            .map(|(a, rel, offset)| crate::hover::HoverAnalysis {
2161                index: &a.index,
2162                snapshots: &a.snapshots,
2163                locals: &a.locals,
2164                expr_types: &a.expr_types,
2165                tys: &a.ty_intern,
2166                rel,
2167                offset: *offset,
2168                project_root: &a.project_root,
2169                doc_scope: &a.doc_scope,
2170                boundary_info: &a.boundary_info,
2171                // #855: computed once per round, not re-derived as a bare
2172                // `boundary_info.len()` — see `real_context_count`'s doc.
2173                context_count: bynk_ide::wire_contract::real_context_count(
2174                    &a.boundary_info,
2175                    &a.unit_sources,
2176                ),
2177            });
2178        let content = crate::hover::hover_content(&crate::hover::HoverInput {
2179            analysis,
2180            doc,
2181            uri: &uri,
2182            files: files.as_deref(),
2183        });
2184        Ok(content.map(|value| Hover {
2185            contents: HoverContents::Markup(MarkupContent {
2186                kind: MarkupKind::Markdown,
2187                value,
2188            }),
2189            range: None,
2190        }))
2191    }
2192
2193    /// v0.32 (ADR 0065): signature help for the call under the cursor.
2194    async fn signature_help(
2195        &self,
2196        params: SignatureHelpParams,
2197    ) -> JsonRpcResult<Option<SignatureHelp>> {
2198        let uri = params.text_document_position_params.text_document.uri;
2199        let pos = params.text_document_position_params.position;
2200        let text = {
2201            let s = self.state.read().await;
2202            s.docs.get(&uri).map(|d| d.text.clone())
2203        };
2204        let Some(text) = text else { return Ok(None) };
2205        let offset = cursor_offset(&text, pos);
2206        let Some(ctx) = crate::signature_help::call_context(&text, offset) else {
2207            return Ok(None);
2208        };
2209        let files = self.project_content(&uri).await;
2210        // Name callees (free fns, statics, capability ops, of/unsafe) — lexical.
2211        // #733: `resolve_label` enumerates the project's units (file stats +
2212        // recovery parse of the cache-missed ones), so run it on the blocking
2213        // pool — signature help fires on every `(`/`,` while typing a call.
2214        let resolved_label = {
2215            let callee = ctx.callee.clone();
2216            let text = text.clone();
2217            let files = files.clone();
2218            match tokio::task::spawn_blocking(move || {
2219                crate::signature_help::resolve_label(&callee, &text, files.as_deref())
2220            })
2221            .await
2222            {
2223                Ok(l) => l,
2224                Err(e) => {
2225                    tracing::error!("signature-help label task failed: {e}");
2226                    None
2227                }
2228            }
2229        };
2230        let label = match resolved_label {
2231            Some(l) => Some(l),
2232            // v0.32 slice 2: a value-receiver method (`xs.fold(`) — type the
2233            // receiver via the rewrite + re-analyse, then the kernel signature.
2234            None => match crate::signature_help::value_receiver_method(&ctx.callee) {
2235                Some((_, method)) => {
2236                    if let Some((rewritten, recv_offset)) =
2237                        crate::signature_help::value_receiver_rewrite(
2238                            &text,
2239                            &ctx.callee,
2240                            ctx.open_paren,
2241                            offset,
2242                        )
2243                        && let Some((ty, tys)) =
2244                            self.type_receiver(&uri, rewritten, recv_offset).await
2245                    {
2246                        crate::signature_help::kernel_method_signature(ty, &tys, method)
2247                    } else {
2248                        None
2249                    }
2250                }
2251                None => None,
2252            },
2253        };
2254        let Some(label) = label else { return Ok(None) };
2255        let active = ctx.active_param as u32;
2256        let parameters: Vec<ParameterInformation> = crate::signature_help::param_ranges(&label)
2257            .into_iter()
2258            .map(|(s, e)| ParameterInformation {
2259                label: ParameterLabel::LabelOffsets([s as u32, e as u32]),
2260                documentation: None,
2261            })
2262            .collect();
2263        Ok(Some(SignatureHelp {
2264            signatures: vec![SignatureInformation {
2265                label,
2266                documentation: None,
2267                parameters: Some(parameters),
2268                active_parameter: Some(active),
2269            }],
2270            active_signature: Some(0),
2271            active_parameter: Some(active),
2272        }))
2273    }
2274
2275    /// v0.33 (ADR 0066): a reference-count lens above each top-level definition,
2276    /// clickable to peek the references. Served from the cached round.
2277    async fn code_lens(&self, params: CodeLensParams) -> JsonRpcResult<Option<Vec<CodeLens>>> {
2278        let uri = params.text_document.uri;
2279        // #733: committed round, no forced re-analysis (see `committed_analysis`).
2280        let analysis = self.committed_analysis(&uri).await;
2281        let Some(analysis) = analysis else {
2282            return Ok(Some(Vec::new()));
2283        };
2284        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2285            return Ok(Some(Vec::new()));
2286        };
2287        let Some(text) = analysis.snapshots.get(&rel) else {
2288            return Ok(Some(Vec::new()));
2289        };
2290        // Peek the references/providers on click — a standard client command,
2291        // so no extension support is required (the client middleware hydrates the
2292        // three-argument shape). Shared by both the reference and provider lenses.
2293        let show_references = |range: Range, locations: Vec<Location>, title: String| CodeLens {
2294            range,
2295            command: Some(Command {
2296                title,
2297                command: "editor.action.showReferences".to_string(),
2298                arguments: Some(vec![
2299                    serde_json::to_value(&uri).unwrap_or_default(),
2300                    serde_json::to_value(range.start).unwrap_or_default(),
2301                    serde_json::to_value(&locations).unwrap_or_default(),
2302                ]),
2303            }),
2304            data: None,
2305        };
2306        let mut lenses: Vec<CodeLens> = crate::index_queries::code_lenses(&analysis.index, &rel)
2307            .into_iter()
2308            .map(|(def, refs)| {
2309                let range = crate::position::span_to_range(text, def.span);
2310                let locations: Vec<Location> = refs
2311                    .iter()
2312                    .filter_map(|r| Self::site_to_location(&analysis, r))
2313                    .collect();
2314                let n = refs.len();
2315                show_references(
2316                    range,
2317                    locations,
2318                    format!("{n} reference{}", if n == 1 { "" } else { "s" }),
2319                )
2320            })
2321            .collect();
2322        // v0.127 (editor-currency slice 6): a `N provider(s)` lens on each
2323        // capability, listing the services that `provides` it. Stacks below the
2324        // reference lens, as a referenced test stacks a reference + test lens.
2325        lenses.extend(
2326            crate::index_queries::capability_provider_lenses(&analysis.index, &rel)
2327                .into_iter()
2328                .map(|(def, providers)| {
2329                    let range = crate::position::span_to_range(text, def.span);
2330                    let locations: Vec<Location> = providers
2331                        .iter()
2332                        .filter_map(|r| Self::site_to_location(&analysis, r))
2333                        .collect();
2334                    let n = providers.len();
2335                    show_references(
2336                        range,
2337                        locations,
2338                        format!("{n} provider{}", if n == 1 { "" } else { "s" }),
2339                    )
2340                }),
2341        );
2342        // v0.129 (#259): a `N refinements of <Base>` lens on each refined/opaque
2343        // type, listing its family — every type over the same builtin base. Stacks
2344        // below the reference lens, like the provider lens on a capability.
2345        lenses.extend(
2346            crate::index_queries::refinement_family_lenses(&analysis.index, &rel)
2347                .into_iter()
2348                .map(|(def, base, family)| {
2349                    let range = crate::position::span_to_range(text, def.span);
2350                    let locations: Vec<Location> = family
2351                        .iter()
2352                        .filter_map(|r| Self::site_to_location(&analysis, r))
2353                        .collect();
2354                    let n = family.len();
2355                    show_references(
2356                        range,
2357                        locations,
2358                        format!("{n} refinements of {}", base.name()),
2359                    )
2360                }),
2361        );
2362        // #846: a "Show Sequence" lens above every handler declaration —
2363        // `bynk.showSequenceDiagram` is a plain extension command (not a
2364        // built-in VS Code command), so its arguments travel as plain JSON
2365        // with no `codelens.ts` hydration needed, unlike `show_references`
2366        // above. A direct AST walk (`handler_lens_sites`), not
2367        // `index_queries::code_lenses` — that only indexes agent handlers
2368        // (`SymbolKind::Handler`; service handlers have no per-handler name)
2369        // and would silently drop the lens for every service handler.
2370        lenses.extend(
2371            crate::sequence_request::handler_lens_sites(text)
2372                .into_iter()
2373                .map(|span| {
2374                    let range = crate::position::span_to_range(text, span);
2375                    CodeLens {
2376                        range,
2377                        command: Some(Command {
2378                            title: "Show Sequence".to_string(),
2379                            command: "bynk.showSequenceDiagram".to_string(),
2380                            arguments: Some(vec![
2381                                serde_json::to_value(&uri).unwrap_or_default(),
2382                                serde_json::to_value(range.start).unwrap_or_default(),
2383                            ]),
2384                        }),
2385                        data: None,
2386                    }
2387                }),
2388        );
2389        Ok(Some(lenses))
2390    }
2391
2392    async fn prepare_call_hierarchy(
2393        &self,
2394        params: CallHierarchyPrepareParams,
2395    ) -> JsonRpcResult<Option<Vec<CallHierarchyItem>>> {
2396        let uri = params.text_document_position_params.text_document.uri;
2397        let pos = params.text_document_position_params.position;
2398        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2399            return Ok(None);
2400        };
2401        let Some((key, def)) =
2402            crate::index_queries::prepare_call_hierarchy(&analysis.index, &rel, offset)
2403        else {
2404            return Ok(None);
2405        };
2406        Ok(Self::call_hierarchy_item(&analysis, key, def).map(|item| vec![item]))
2407    }
2408
2409    async fn incoming_calls(
2410        &self,
2411        params: CallHierarchyIncomingCallsParams,
2412    ) -> JsonRpcResult<Option<Vec<CallHierarchyIncomingCall>>> {
2413        let analysis = self.analysis_for(&params.item.uri).await;
2414        let Some(analysis) = analysis else {
2415            return Ok(Some(Vec::new()));
2416        };
2417        let Some(key) = SerKey::read(&params.item.data) else {
2418            return Ok(Some(Vec::new()));
2419        };
2420        let calls = crate::index_queries::incoming_calls(&analysis.index, &key)
2421            .into_iter()
2422            .filter_map(|rel| {
2423                let from = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2424                let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2425                Some(CallHierarchyIncomingCall { from, from_ranges })
2426            })
2427            .collect();
2428        Ok(Some(calls))
2429    }
2430
2431    async fn outgoing_calls(
2432        &self,
2433        params: CallHierarchyOutgoingCallsParams,
2434    ) -> JsonRpcResult<Option<Vec<CallHierarchyOutgoingCall>>> {
2435        let analysis = self.analysis_for(&params.item.uri).await;
2436        let Some(analysis) = analysis else {
2437            return Ok(Some(Vec::new()));
2438        };
2439        let Some(key) = SerKey::read(&params.item.data) else {
2440            return Ok(Some(Vec::new()));
2441        };
2442        let calls = crate::index_queries::outgoing_calls(&analysis.index, &key)
2443            .into_iter()
2444            .filter_map(|rel| {
2445                let to = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2446                let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2447                Some(CallHierarchyOutgoingCall { to, from_ranges })
2448            })
2449            .collect();
2450        Ok(Some(calls))
2451    }
2452
2453    /// v0.35 (ADR 0068): `textDocument/implementation` — on a capability
2454    /// symbol (its declaration, a `given Cap` use, or a `provides Cap` use),
2455    /// the providers that implement it. `None` for any other symbol (the
2456    /// reverse, provider → capability, is served by goto-definition).
2457    async fn goto_implementation(
2458        &self,
2459        params: GotoImplementationParams,
2460    ) -> JsonRpcResult<Option<GotoImplementationResponse>> {
2461        let uri = params.text_document_position_params.text_document.uri;
2462        let pos = params.text_document_position_params.position;
2463        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2464            return Ok(None);
2465        };
2466        let Some((key, _)) = analysis.index.symbol_at(&rel, offset) else {
2467            return Ok(None);
2468        };
2469        if key.kind != bynk_check::index::SymbolKind::Capability {
2470            return Ok(None);
2471        }
2472        let locations: Vec<Location> = crate::index_queries::implementations(&analysis.index, key)
2473            .into_iter()
2474            .filter_map(|d| Self::site_to_location(&analysis, d))
2475            .collect();
2476        if locations.is_empty() {
2477            return Ok(None);
2478        }
2479        Ok(Some(GotoDefinitionResponse::Array(locations)))
2480    }
2481
2482    /// Slice 6: `textDocument/typeDefinition` — from a value at the cursor to the
2483    /// definition of its (user-declared) type. Reads the value's type from the
2484    /// round's `expr_types`, unwraps it to a `Named` target, and returns that
2485    /// type's definition site(s). `None` for a built-in/function/actor type, or
2486    /// a cursor not on a typed expression in a clean round.
2487    async fn goto_type_definition(
2488        &self,
2489        params: GotoTypeDefinitionParams,
2490    ) -> JsonRpcResult<Option<GotoTypeDefinitionResponse>> {
2491        let uri = params.text_document_position_params.text_document.uri;
2492        let pos = params.text_document_position_params.position;
2493        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2494            return Ok(None);
2495        };
2496        let tys = &analysis.ty_intern;
2497        let Some(entries) = analysis.expr_types.get(&rel) else {
2498            return Ok(None);
2499        };
2500        let Some(ty) = bynk_check::expr_types::type_at_offset(entries, offset) else {
2501            return Ok(None);
2502        };
2503        let Some(name) = crate::index_queries::named_type_target(ty, tys) else {
2504            return Ok(None);
2505        };
2506        let locations: Vec<Location> =
2507            crate::index_queries::type_definitions_named(&analysis.index, &name)
2508                .into_iter()
2509                .filter_map(|d| Self::site_to_location(&analysis, d))
2510                .collect();
2511        if locations.is_empty() {
2512            return Ok(None);
2513        }
2514        Ok(Some(GotoDefinitionResponse::Array(locations)))
2515    }
2516
2517    /// Slice 6b (ADR 0095): `textDocument/documentLink` — `uses`/`consumes` unit
2518    /// names are clickable to the unit's source. Spans come from parsing the live
2519    /// buffer; the target is the unit's first source file from the round's
2520    /// unit→source map. A first-party `uses` (embedded, no on-disk file) or an
2521    /// unresolved unit yields no link.
2522    ///
2523    /// #848: plus intra-doc links inside the file's own `--- … ---` doc
2524    /// comments — `[Name]`/`[Owner.member]` resolved against the declaring
2525    /// unit's `doc_scope`. Resolves against the full `analysis.index` under
2526    /// the same `committed_analysis` gate as the unit-reference links above;
2527    /// consistent with `code_lens`/`capability_provider_lenses`, which
2528    /// already resolve full-index cross-references under this gate.
2529    async fn document_link(
2530        &self,
2531        params: DocumentLinkParams,
2532    ) -> JsonRpcResult<Option<Vec<DocumentLink>>> {
2533        let uri = params.text_document.uri;
2534        // #733: committed round. Link ranges convert against live `text` here and
2535        // the round only supplies the project-level `unit_sources` map (it changes
2536        // only on a `uses`/`consumes` edit), so a committed round is safe.
2537        let analysis = self.committed_analysis(&uri).await;
2538        let text = self
2539            .state
2540            .read()
2541            .await
2542            .docs
2543            .get(&uri)
2544            .map(|d| d.text.clone());
2545        let (Some(text), Some(analysis)) = (text, analysis) else {
2546            return Ok(None);
2547        };
2548        let mut links: Vec<DocumentLink> = crate::symbols::unit_reference_spans(&text)
2549            .into_iter()
2550            .filter_map(|(unit, span)| {
2551                let rel = analysis.unit_sources.get(&unit)?.first()?;
2552                let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
2553                Some(DocumentLink {
2554                    range: crate::position::span_to_range(&text, span),
2555                    target: Some(target),
2556                    tooltip: Some(format!("Open unit `{unit}`")),
2557                    data: None,
2558                })
2559            })
2560            .collect();
2561        // #848: a suite file's own doc comments are out of scope this
2562        // increment (own_declaration_name returns None for a suite; its
2563        // uses-clause links above are unaffected).
2564        if let Some((owner_unit, _)) = crate::symbols::own_declaration_name(&text) {
2565            for (name, span) in crate::symbols::doc_link_spans(&text) {
2566                let Some(def) = crate::index_queries::resolve_doc_link(
2567                    &analysis.index,
2568                    &analysis.doc_scope,
2569                    &owner_unit,
2570                    &name,
2571                ) else {
2572                    continue;
2573                };
2574                let Ok(target) = Url::from_file_path(analysis.project_root.join(&def.path)) else {
2575                    continue;
2576                };
2577                links.push(DocumentLink {
2578                    range: crate::position::span_to_range(&text, span),
2579                    target: Some(target),
2580                    tooltip: Some(format!("Go to `{name}`")),
2581                    data: None,
2582                });
2583            }
2584        }
2585        Ok((!links.is_empty()).then_some(links))
2586    }
2587
2588    async fn completion(
2589        &self,
2590        params: CompletionParams,
2591    ) -> JsonRpcResult<Option<CompletionResponse>> {
2592        let uri = params.text_document_position.text_document.uri;
2593        let pos = params.text_document_position.position;
2594        let text = {
2595            let s = self.state.read().await;
2596            s.docs.get(&uri).map(|d| d.text.clone())
2597        };
2598        let Some(text) = text else { return Ok(None) };
2599        let offset = cursor_offset(&text, pos);
2600        // The line up to the cursor — the context the completion keys off.
2601        // Derived from the converted offset (always a char boundary), not by
2602        // slicing the line at `pos.character` bytes.
2603        let line_prefix = text[..offset].rsplit('\n').next().unwrap_or("").to_string();
2604        let files = self.project_content(&uri).await;
2605        // `complete()` enumerates the project's units — file stats and CPU-bound
2606        // recovery parsing (of the buffer, and any project file whose parse cache
2607        // missed). Run it on the blocking pool so a keystroke on a large project
2608        // never stalls the async runtime (#733).
2609        let candidates = {
2610            let line_prefix = line_prefix.clone();
2611            let text = text.clone();
2612            match tokio::task::spawn_blocking(move || {
2613                completion::complete(&line_prefix, &text, files.as_deref())
2614            })
2615            .await
2616            {
2617                Ok(c) => c,
2618                // A panic (or cancellation) inside `complete()` degrades to empty
2619                // completions rather than a failed request — but log the
2620                // `JoinError` so the underlying bug is not silently swallowed
2621                // (#776 review).
2622                Err(e) => {
2623                    tracing::error!("completion enumeration task failed: {e}");
2624                    Vec::new()
2625                }
2626            }
2627        };
2628        let mut items: Vec<CompletionItem> =
2629            candidates.into_iter().map(to_completion_item).collect();
2630        // ADR 0064/0093 D3: offer in-scope locals/params at keyword position
2631        // (alongside keywords) and at expression position (alongside the
2632        // constructors + type names `complete()` now yields there). Both are
2633        // places a value or name can begin; the two positions are disjoint.
2634        if completion::is_keyword_position(&line_prefix)
2635            || completion::is_expression_position(&line_prefix)
2636        {
2637            items.extend(self.locals_completions(&uri, pos).await);
2638        }
2639        // v0.124 (slice 3): inside a `requires`/`ensures` predicate, offer the
2640        // enclosing function's parameters (and `result` in an `ensures`),
2641        // merged with whatever the lexical cell yields there — the same
2642        // append-in-scope-names posture as locals above.
2643        items.extend(contract_param_completions(&text, offset, &line_prefix));
2644        // v0.131: inside a `cors { }` block, offer the policy field names; at a
2645        // service-body item start, offer the `cors` section keyword alongside the
2646        // handler-kind keywords the keyword-position cell already yields.
2647        items.extend(cors_completions(&text, offset, &line_prefix));
2648        // v0.141 (ADR 0164): inside a `security { }` block, offer the policy field
2649        // names; at a service-body item start, offer the `security` section keyword.
2650        items.extend(security_completions(&text, offset, &line_prefix));
2651        // v0.140 (ADR 0163): inside `@cache( … )`, offer the annotation argument
2652        // names; at a service-body item start, offer the `@cache` snippet alongside
2653        // the `cors` keyword and handler kinds.
2654        items.extend(cache_completions(&text, offset, &line_prefix));
2655        // v0.142 (ADR 0165): inside a `limits { }` block, offer the policy field
2656        // names; at a service-body item start, offer the `limits` section keyword.
2657        items.extend(limits_completions(&text, offset, &line_prefix));
2658        // v0.142 (ADR 0165): inside `@limit( … )`, offer the annotation argument
2659        // names; at a service-body item start, offer the `@limit` snippet.
2660        items.extend(limit_completions(&text, offset, &line_prefix));
2661        // v0.128: at a `match` arm-pattern-start, prepend the scrutinee's
2662        // variants — the most relevant candidate there. Unlike an `is` position, a
2663        // fresh-line or after-comma arm already looks like a keyword/expression
2664        // position (so `items` is non-empty and the `is_empty` path below never
2665        // fires), hence the merge. The expensive scrutinee typing is gated behind
2666        // the cheap lexical `match_scrutinee_offset` check inside, so ordinary
2667        // keyword-position completion pays only a string scan.
2668        // v0.145 (ADR 0169): a nested constructor position (`Some(‸`) offers the
2669        // payload type's variants; it and the arm-start position are mutually
2670        // exclusive (one is inside a `(`, the other before any), so the two lists
2671        // never overlap. Nested is the more specific position, so it leads.
2672        let mut pattern_items = self.nested_pattern_completions(&uri, &text, offset).await;
2673        pattern_items.extend(self.match_arm_completions(&uri, &text, offset).await);
2674        if !pattern_items.is_empty() {
2675            let mut merged = pattern_items;
2676            merged.extend(items);
2677            stamp_resolve_data(&mut merged, &uri);
2678            return Ok(Some(CompletionResponse::Array(merged)));
2679        }
2680        if items.is_empty() {
2681            // Slice 3: `<expr> is <cursor>` — offer the scrutinee sum type's
2682            // variants, resolved from `expr_types` (the ADR 0063 ceiling).
2683            let is_items = self.is_pattern_completions(&uri, &text, offset).await;
2684            if !is_items.is_empty() {
2685                return Ok(Some(CompletionResponse::Array(is_items)));
2686            }
2687            // A lowercase `receiver.` is a value receiver — type it by
2688            // re-analysing the rewritten buffer and offer its members. (Value
2689            // members name no declared symbol, so they carry no resolve data.)
2690            let value_items = self.value_member_completions(&uri, &text, offset).await;
2691            return Ok((!value_items.is_empty()).then_some(CompletionResponse::Array(value_items)));
2692        }
2693        // Slice 5: stash the doc URI so `completion_resolve` can attach lazy docs.
2694        stamp_resolve_data(&mut items, &uri);
2695        Ok(Some(CompletionResponse::Array(items)))
2696    }
2697
2698    /// Slice 5: fill in hover-quality `documentation` for the focused completion
2699    /// item, reusing the hover renderer (`symbols::describe_symbol`, local then
2700    /// cross-file — §3.4). The originating doc URI is read from the item's
2701    /// `data` (a resolve request carries only the item, not a position). A no-op
2702    /// for an item that names no declared symbol (a keyword, kernel method, or
2703    /// local) — its one-line `detail` already suffices.
2704    async fn completion_resolve(&self, mut item: CompletionItem) -> JsonRpcResult<CompletionItem> {
2705        if item.documentation.is_some() {
2706            return Ok(item);
2707        }
2708        let Some(uri) = item
2709            .data
2710            .as_ref()
2711            .and_then(|d| d.get("uri"))
2712            .and_then(serde_json::Value::as_str)
2713            .and_then(|s| Url::parse(s).ok())
2714        else {
2715            return Ok(item);
2716        };
2717        let local = {
2718            let s = self.state.read().await;
2719            s.docs.get(&uri).map(|d| d.text.clone())
2720        };
2721        let doc = match local
2722            .as_deref()
2723            .and_then(|t| crate::symbols::describe_symbol(t, &item.label))
2724        {
2725            Some(md) => Some(md),
2726            // #733: the cross-file fallback enumerates the project's units (file
2727            // stats + recovery parse of the cache-missed ones); the firstparty
2728            // fallback parses the embedded surface. Both read/parse off the
2729            // blocking pool — completion-item resolve fires as the user arrows
2730            // through the completion list.
2731            None => {
2732                let files = self.project_content(&uri).await;
2733                let uri = uri.clone();
2734                let label = item.label.clone();
2735                match tokio::task::spawn_blocking(move || {
2736                    files
2737                        .and_then(|files| {
2738                            crate::symbols::describe_symbol_cross_file(&files, &uri, &label)
2739                        })
2740                        .map(|(_uri, md)| md)
2741                        // Slice 9: stdlib/surface symbols (e.g. a `uses bynk.list`
2742                        // combinator) live in the embedded first-party sources,
2743                        // not the project's files.
2744                        .or_else(|| crate::symbols::describe_firstparty_symbol(&label))
2745                })
2746                .await
2747                {
2748                    Ok(md) => md,
2749                    Err(e) => {
2750                        tracing::error!("completion-resolve describe task failed: {e}");
2751                        None
2752                    }
2753                }
2754            }
2755        };
2756        if let Some(md) = doc {
2757            item.documentation = Some(Documentation::MarkupContent(MarkupContent {
2758                kind: MarkupKind::Markdown,
2759                value: md,
2760            }));
2761        }
2762        Ok(item)
2763    }
2764
2765    async fn goto_definition(
2766        &self,
2767        params: GotoDefinitionParams,
2768    ) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
2769        let uri = params
2770            .text_document_position_params
2771            .text_document
2772            .uri
2773            .clone();
2774        let pos = params.text_document_position_params.position;
2775        // v0.25 rider: binding-correct definition via the index (fixes the
2776        // name-collision mis-navigation of the string-matching path). The
2777        // legacy path remains as fallback for not-yet-indexed symbol kinds
2778        // (locals, methods, fields, ops).
2779        if let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await {
2780            if let Some((_, def)) =
2781                crate::index_queries::definition_at(&analysis.index, &rel, offset)
2782                && let Some(location) = Self::site_to_location(&analysis, def)
2783            {
2784                return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2785            }
2786            // v0.31: a local binding — scope-correct definition (before the
2787            // string-matching fallback, which can't tell scopes apart).
2788            if let Some(text) = analysis.snapshots.get(&rel)
2789                && let Some(locals) = analysis.locals.get(&rel)
2790                && let Some(def) = crate::locals_nav::local_definition_at(locals, text, offset)
2791                && let Some(location) = self
2792                    .local_locations(&analysis, &rel, &[def])
2793                    .into_iter()
2794                    .next()
2795            {
2796                return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2797            }
2798        }
2799        // Slice 6a follow-up (ADR 0095): the cursor on a `uses`/`consumes` unit
2800        // name jumps to that unit's source. Units aren't index symbols, so the
2801        // unit→source map resolves them; runs before the name-matching path so a
2802        // unit segment can't be mistaken for a like-named type.
2803        if let Some(location) = self.unit_reference_definition(&uri, pos).await {
2804            return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2805        }
2806        let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
2807            return Ok(None);
2808        };
2809        if let Some(decl_span) = crate::symbols::find_declaration_span(&text, &name) {
2810            let range = crate::position::span_to_range(&text, decl_span);
2811            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2812                uri,
2813                range,
2814            })));
2815        }
2816        // Cross-file fallback (v1.1; LSP spec §3.4).
2817        if let Some(files) = self.project_content(&uri).await
2818            && let Some(found) = crate::symbols::find_declaration_cross_file(&files, &uri, &name)
2819        {
2820            let range = crate::position::span_to_range(&found.source, found.span);
2821            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2822                uri: found.uri,
2823                range,
2824            })));
2825        }
2826        Ok(None)
2827    }
2828
2829    async fn formatting(
2830        &self,
2831        params: DocumentFormattingParams,
2832    ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2833        let uri = params.text_document.uri;
2834        let text = {
2835            let s = self.state.read().await;
2836            s.docs.get(&uri).map(|d| d.text.clone())
2837        };
2838        let Some(text) = text else { return Ok(None) };
2839        // Slice D: the format options are the owning project's (or the defaults
2840        // in single-file mode).
2841        let opts = self.config_for(&uri).await.format_options();
2842        match bynk_fmt::format_source(&text, &opts) {
2843            Ok(formatted) => {
2844                // #1763: a CRLF buffer whose LF form is canonical needs no edit,
2845                // as on the command line; otherwise every format-on-save would
2846                // rewrite an already-canonical Windows file.
2847                if formatted == bynk_fmt::normalize_line_endings(&text) {
2848                    Ok(Some(Vec::new()))
2849                } else {
2850                    // Replace the entire document.
2851                    let end_pos = crate::position::end_position(&text);
2852                    Ok(Some(vec![TextEdit {
2853                        range: Range {
2854                            start: Position::new(0, 0),
2855                            end: end_pos,
2856                        },
2857                        new_text: formatted,
2858                    }]))
2859                }
2860            }
2861            Err(_) => {
2862                // Formatting failed (parse error). Return no edits; the
2863                // diagnostics flow will surface the parse error.
2864                Ok(Some(Vec::new()))
2865            }
2866        }
2867    }
2868
2869    async fn range_formatting(
2870        &self,
2871        params: DocumentRangeFormattingParams,
2872    ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2873        // Best-effort: format the whole document. Per spec, range
2874        // formatting may return edits wider than the requested range.
2875        self.formatting(DocumentFormattingParams {
2876            text_document: params.text_document,
2877            options: params.options,
2878            work_done_progress_params: params.work_done_progress_params,
2879        })
2880        .await
2881    }
2882
2883    async fn document_symbol(
2884        &self,
2885        params: DocumentSymbolParams,
2886    ) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
2887        // v1.1 — outline view + Cmd-Shift-O. See `design/bynk-lsp-spec.md` §3.7.
2888        let uri = params.text_document.uri;
2889        let text = {
2890            let s = self.state.read().await;
2891            s.docs.get(&uri).map(|d| d.text.clone())
2892        };
2893        let Some(text) = text else { return Ok(None) };
2894        let syms = crate::document_symbols::outline(&text);
2895        if syms.is_empty() {
2896            return Ok(None);
2897        }
2898        Ok(Some(DocumentSymbolResponse::Nested(syms)))
2899    }
2900
2901    /// v0.37 (ADR 0070): `textDocument/foldingRange` — structural folds + comment
2902    /// runs from the recovered AST (no analysis round).
2903    async fn folding_range(
2904        &self,
2905        params: FoldingRangeParams,
2906    ) -> JsonRpcResult<Option<Vec<FoldingRange>>> {
2907        let uri = params.text_document.uri;
2908        let text = {
2909            let s = self.state.read().await;
2910            s.docs.get(&uri).map(|d| d.text.clone())
2911        };
2912        let Some(text) = text else { return Ok(None) };
2913        Ok(Some(crate::structure::folding_ranges(&text)))
2914    }
2915
2916    /// v0.37 (ADR 0070): `textDocument/selectionRange` — the enclosing-node
2917    /// chain (innermost first) for each requested position.
2918    async fn selection_range(
2919        &self,
2920        params: SelectionRangeParams,
2921    ) -> JsonRpcResult<Option<Vec<SelectionRange>>> {
2922        let uri = params.text_document.uri;
2923        let text = {
2924            let s = self.state.read().await;
2925            s.docs.get(&uri).map(|d| d.text.clone())
2926        };
2927        let Some(text) = text else { return Ok(None) };
2928        Ok(Some(crate::structure::selection_ranges(
2929            &text,
2930            &params.positions,
2931        )))
2932    }
2933
2934    async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
2935        let uri = params.text_document_position.text_document.uri;
2936        let pos = params.text_document_position.position;
2937        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2938            return Ok(None);
2939        };
2940        let include_decl = params.context.include_declaration;
2941        if let Some(sites) =
2942            crate::index_queries::sites_for(&analysis.index, &rel, offset, include_decl)
2943        {
2944            let locations: Vec<Location> = sites
2945                .into_iter()
2946                .filter_map(|site| Self::site_to_location(&analysis, site))
2947                .collect();
2948            return Ok(Some(locations));
2949        }
2950        // v0.31: a local binding — its def + uses, resolved from the snapshot.
2951        if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
2952            let spans = if include_decl {
2953                &spans[..]
2954            } else {
2955                &spans[1..]
2956            }; // def first
2957            let locations = self.local_locations(&analysis, &rel, spans);
2958            return Ok(Some(locations));
2959        }
2960        Ok(None)
2961    }
2962
2963    /// v0.26 (ADR 0054): quick-fixes from structured suggestions. v0.213
2964    /// (ADR 0239) adds the extract-variable refactor
2965    /// (`CodeActionKind::REFACTOR_EXTRACT`), computed from the same snapshot.
2966    /// Track #800 adds the sibling extract-function refactor, additionally
2967    /// fed the round's `requirements`/`locals`/`expr_types` (the
2968    /// capability-free-only gate and the parameter/return type synthesis).
2969    /// Served from the **cached** analysis round only (never a fresh run —
2970    /// slow, and it could disagree with the squiggles the client is
2971    /// showing): a request before the first round, or for a file outside
2972    /// the project, returns the empty list. #804: the combined list is then
2973    /// filtered against `params.context.only`, if the client set it.
2974    async fn code_action(
2975        &self,
2976        params: CodeActionParams,
2977    ) -> JsonRpcResult<Option<CodeActionResponse>> {
2978        let uri = params.text_document.uri;
2979        // #733: committed round. The request range and the diagnostics the fixes
2980        // ride on both convert against the round's snapshot, and the emitted edits
2981        // carry the round's version, so a committed round is self-consistent.
2982        let analysis = self.committed_analysis(&uri).await;
2983        let Some(analysis) = analysis else {
2984            return Ok(Some(Vec::new()));
2985        };
2986        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2987            return Ok(Some(Vec::new()));
2988        };
2989        let (Some(text), Some(diags)) =
2990            (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
2991        else {
2992            return Ok(Some(Vec::new()));
2993        };
2994        // The request range converts against the analysed snapshot (the
2995        // v0.24 rule), like the spans it is intersected with.
2996        let (Some(start), Some(end)) = (
2997            crate::position::position_to_offset(text, params.range.start),
2998            crate::position::position_to_offset(text, params.range.end),
2999        ) else {
3000            return Ok(Some(Vec::new()));
3001        };
3002        let version = analysis.versions.get(&rel).copied();
3003        let span = bynk_syntax::span::Span::new(start, end);
3004        let mut actions = crate::code_actions::quick_fixes(text, diags, span, &uri, version);
3005        // #852: capability-aware header fixes (`add consumes`, auto-`uses`/
3006        // `consumes`), computed from the committed index + a fresh reparse.
3007        actions.extend(crate::capability_fixes::header_quick_fixes(
3008            text,
3009            diags,
3010            span,
3011            &uri,
3012            version,
3013            &analysis.index,
3014        ));
3015        actions.extend(crate::extract::extract_variable(text, span, &uri, version));
3016        let empty_reqs = Vec::new();
3017        let empty_locals = Vec::new();
3018        let empty_types = Vec::new();
3019        actions.extend(crate::extract::extract_function(
3020            text,
3021            span,
3022            &uri,
3023            version,
3024            analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
3025            analysis.locals.get(&rel).unwrap_or(&empty_locals),
3026            analysis.expr_types.get(&rel).unwrap_or(&empty_types),
3027            &analysis.ty_intern,
3028        ));
3029        // #804: honour the client's requested action kinds, if any.
3030        let actions = crate::code_actions::filter_by_only(actions, params.context.only.as_deref());
3031        Ok(Some(actions))
3032    }
3033
3034    /// v0.27 (ADR 0056): inferred-type inlay hints for the visible range,
3035    /// served from the cached round only — no cached round (pre-first-
3036    /// analysis, non-project file) returns the empty list. Positions
3037    /// convert against the analysed snapshot (the v0.24 rule).
3038    async fn inlay_hint(&self, params: InlayHintParams) -> JsonRpcResult<Option<Vec<InlayHint>>> {
3039        let uri = params.text_document.uri;
3040        // #733: committed round, no forced re-analysis (see `committed_analysis`).
3041        let analysis = self.committed_analysis(&uri).await;
3042        let Some(analysis) = analysis else {
3043            return Ok(Some(Vec::new()));
3044        };
3045        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3046            return Ok(Some(Vec::new()));
3047        };
3048        let Some(text) = analysis.snapshots.get(&rel) else {
3049            return Ok(Some(Vec::new()));
3050        };
3051        // The visible range converts against the analysed snapshot, like
3052        // the hint spans it is intersected with.
3053        let (Some(start), Some(end)) = (
3054            crate::position::position_to_offset(text, params.range.start),
3055            crate::position::position_to_offset(text, params.range.end),
3056        ) else {
3057            return Ok(Some(Vec::new()));
3058        };
3059        let visible = bynk_syntax::span::Span::new(start, end);
3060        // v0.27: inferred-type hints. v0.99: plus the materializable ghost
3061        // `given` hints for uncovered capability requirements. A file may carry
3062        // one set without the other, so each defaults to empty independently.
3063        let mut hints = analysis
3064            .hints
3065            .get(&rel)
3066            .map(|h| crate::inlay_hints::inlay_hints(text, h, visible))
3067            .unwrap_or_default();
3068        if let Some(reqs) = analysis.requirements.get(&rel) {
3069            hints.extend(crate::inlay_hints::given_hints(text, reqs, visible));
3070        }
3071        Ok(Some(hints))
3072    }
3073
3074    /// v0.28 (ADR 0057): semantic tokens for the whole document, served
3075    /// from the cached round only (no cached round / non-project file →
3076    /// empty), positions against the analysed snapshot (the v0.24 rule).
3077    async fn semantic_tokens_full(
3078        &self,
3079        params: SemanticTokensParams,
3080    ) -> JsonRpcResult<Option<SemanticTokensResult>> {
3081        let data = self
3082            .semantic_tokens_for(&params.text_document.uri, None)
3083            .await;
3084        Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
3085            result_id: None,
3086            data,
3087        })))
3088    }
3089
3090    /// v0.28 (ADR 0057): the `…/range` variant — the same pure read,
3091    /// filtered to tokens overlapping the requested range.
3092    async fn semantic_tokens_range(
3093        &self,
3094        params: SemanticTokensRangeParams,
3095    ) -> JsonRpcResult<Option<SemanticTokensRangeResult>> {
3096        let data = self
3097            .semantic_tokens_for(&params.text_document.uri, Some(params.range))
3098            .await;
3099        Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
3100            result_id: None,
3101            data,
3102        })))
3103    }
3104
3105    /// v0.26 rider (ADR 0055): workspace-wide symbol search — the index's
3106    /// definitions, filtered by the query. Slice D (Q4): one server, many
3107    /// projects — aggregate across **every** project. Candidates are the
3108    /// **already-warmed** projects (slice E warms every project under the folders
3109    /// at `initialized`, and the watcher warms one created later), plus each
3110    /// folder's own `resolve_root` — a cheap bounded walk-*up*, the pre-slice-E
3111    /// seeding. No full tree-walk on this request path: a `workspace/symbol`
3112    /// query can fire per keystroke, and the warmed set already holds the nested
3113    /// monorepo projects a walk would rediscover.
3114    async fn symbol(
3115        &self,
3116        params: WorkspaceSymbolParams,
3117    ) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
3118        let candidates: Vec<(PathBuf, ProjectConfig)> = {
3119            // Snapshot warmed projects + folders under the lock; resolve the
3120            // folders' own roots off it (a bounded walk-up, but still FS I/O).
3121            let (mut set, folders) = {
3122                let state = self.state.read().await;
3123                let known: std::collections::HashMap<PathBuf, ProjectConfig> = state
3124                    .projects
3125                    .iter()
3126                    .map(|(r, p)| (r.clone(), p.config.clone()))
3127                    .collect();
3128                (known, state.folders.clone())
3129            };
3130            for folder in &folders {
3131                if let Some((root, config)) = Self::resolve_root(folder) {
3132                    let root = root.canonicalize().unwrap_or(root);
3133                    set.entry(root).or_insert(config);
3134                }
3135            }
3136            set.into_iter().collect()
3137        };
3138        let mut symbols: Vec<SymbolInformation> = Vec::new();
3139        for (root, config) in candidates {
3140            let Some(analysis) = self.ensure_project_analysed(root, config).await else {
3141                continue;
3142            };
3143            for (key, def) in
3144                crate::index_queries::workspace_symbols(&analysis.index, &params.query)
3145            {
3146                let Some(location) = Self::site_to_location(&analysis, def) else {
3147                    continue;
3148                };
3149                #[allow(deprecated)]
3150                symbols.push(SymbolInformation {
3151                    name: key.name.clone(),
3152                    kind: lsp_symbol_kind(key.kind),
3153                    tags: None,
3154                    deprecated: None,
3155                    location,
3156                    container_name: Some(key.unit.clone()),
3157                });
3158            }
3159        }
3160        // Aggregating across projects (a `HashMap`-derived candidate list) groups
3161        // matches by project in arbitrary order; the spec (§3.11) promises a
3162        // stable `(name, unit)` ordering, so sort the merged result. `unit` is
3163        // the container name.
3164        symbols.sort_by(|a, b| {
3165            a.name
3166                .cmp(&b.name)
3167                .then_with(|| a.container_name.cmp(&b.container_name))
3168        });
3169        Ok(Some(symbols))
3170    }
3171
3172    /// v0.26 rider (ADR 0055): the symbol-at-cursor's occurrences in the
3173    /// active file. `kind` is omitted — the index does not distinguish read
3174    /// from write references.
3175    async fn document_highlight(
3176        &self,
3177        params: DocumentHighlightParams,
3178    ) -> JsonRpcResult<Option<Vec<DocumentHighlight>>> {
3179        let uri = params.text_document_position_params.text_document.uri;
3180        let pos = params.text_document_position_params.position;
3181        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3182            return Ok(None);
3183        };
3184        let Some(text) = analysis.snapshots.get(&rel) else {
3185            return Ok(None);
3186        };
3187        if let Some(sites) =
3188            crate::index_queries::document_highlights(&analysis.index, &rel, offset)
3189        {
3190            let highlights: Vec<DocumentHighlight> = sites
3191                .into_iter()
3192                .map(|s| DocumentHighlight {
3193                    range: crate::position::span_to_range(text, s.span),
3194                    kind: None,
3195                })
3196                .collect();
3197            return Ok(Some(highlights));
3198        }
3199        // v0.31: a local binding's occurrences (def + uses) in the file.
3200        if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
3201            let highlights = spans
3202                .iter()
3203                .map(|s| DocumentHighlight {
3204                    range: crate::position::span_to_range(text, *s),
3205                    kind: None,
3206                })
3207                .collect();
3208            return Ok(Some(highlights));
3209        }
3210        Ok(None)
3211    }
3212
3213    async fn prepare_rename(
3214        &self,
3215        params: TextDocumentPositionParams,
3216    ) -> JsonRpcResult<Option<PrepareRenameResponse>> {
3217        let uri = params.text_document.uri;
3218        let pos = params.position;
3219        // Refuse (None) for anything the index does not cover — locals,
3220        // methods, record fields, capability ops, unit names — rather than
3221        // falling through to a partial or name-matched rename.
3222        let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3223            return Ok(None);
3224        };
3225        let Some((key, site)) = crate::index_queries::prepare_rename(&analysis.index, &rel, offset)
3226        else {
3227            return Ok(None);
3228        };
3229        let Some(text) = analysis.snapshots.get(&rel) else {
3230            return Ok(None);
3231        };
3232        Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
3233            range: crate::position::span_to_range(text, site.span),
3234            placeholder: key.name.clone(),
3235        }))
3236    }
3237
3238    async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
3239        let uri = params.text_document_position.text_document.uri;
3240        let pos = params.text_document_position.position;
3241        let new_name = params.new_name;
3242        let refused = |msg: String| tower_lsp::jsonrpc::Error {
3243            code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3244            message: msg.into(),
3245            data: None,
3246        };
3247        // Slice B: rename emits versioned edits across *every* file that
3248        // references the symbol, so it needs the round current for **all** open
3249        // buffers, not just the cursor's (`analysis_for` would leave a dirty
3250        // non-cursor file stale and its edit would be stamped with an old
3251        // version, which the client rejects). `analysis_covering_open_buffers`
3252        // restores the whole-project freshness the pre-v0.179 `fresh_analysis`
3253        // gave. The cursor's file is one of those buffers, so it is current too;
3254        // resolve `rel`/`offset` against it here (what `index_position` did).
3255        // Slice D (Q4): route by the cursor's project — a rename spans one
3256        // project, so the round need only cover *that* project's buffers.
3257        let Some(root) = self.root_for_uri(&uri).await else {
3258            return Err(refused("rename requires a project (bynk.toml)".into()));
3259        };
3260        let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3261            return Err(refused("rename requires a project (bynk.toml)".into()));
3262        };
3263        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3264            return Ok(None);
3265        };
3266        let Some(text) = analysis.snapshots.get(&rel) else {
3267            return Ok(None);
3268        };
3269        let Some(offset) = crate::position::position_to_offset(text, pos) else {
3270            return Ok(None);
3271        };
3272        let plan = crate::index_queries::plan_rename(&analysis.index, &rel, offset, &new_name)
3273            .map_err(refused)?;
3274
3275        // Validator 1 + 2 input: re-analyse with the edits applied. Every
3276        // snapshot is pinned via the overlay so the re-analysis differs from
3277        // the plan's baseline only by the edits themselves.
3278        //
3279        // Slice A: this must re-analyse over the **same roots** the baseline
3280        // round used (`AnalysisRoots::Project`, manifest-aware), not the
3281        // single-tree `diagnose_project`. `diagnose_project(project_root)`
3282        // resolves to `Roots::Single`, which walks the whole tree with **no
3283        // `exclude`** and no `out`/`node_modules` skip — so `post` would cover a
3284        // superset of the baseline's files, and validators 1 and 2 (which
3285        // compare `post` against baselines from the manifest-aware round) would
3286        // read a diagnostic or index site in an excluded tree as *new* and
3287        // refuse a valid rename.
3288        let mut overlay = std::collections::HashMap::new();
3289        for (rel_path, text) in &analysis.snapshots {
3290            let edited = match plan.edits.get(rel_path) {
3291                Some(spans) => crate::index_queries::apply_edits(text, spans, &plan.new_name),
3292                None => text.clone(),
3293            };
3294            let abs = analysis.project_root.join(rel_path);
3295            let abs = abs.canonicalize().unwrap_or(abs);
3296            overlay.insert(abs, edited);
3297        }
3298        let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
3299        let Ok(post) =
3300            tokio::task::spawn_blocking(move || bynk_ide::diagnose_project_with(&roots, &overlay))
3301                .await
3302        else {
3303            return Err(refused("rename validation failed to run".into()));
3304        };
3305
3306        // Validator 1 — collisions: refuse on any new diagnostic.
3307        let post_diags: Vec<(PathBuf, String)> = post
3308            .files
3309            .iter()
3310            .flat_map(|f| {
3311                f.diagnostics
3312                    .iter()
3313                    .map(|d| (f.source_path.clone(), d.error.category.to_string()))
3314            })
3315            .collect();
3316        crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags)
3317            .map_err(refused)?;
3318
3319        // Validator 2 — capture/escape: the re-built index must be the old
3320        // index modulo the rename; a silent re-binding has no diagnostic.
3321        if !crate::index_queries::index_unchanged_modulo_rename(&analysis.index, &post.index, &plan)
3322        {
3323            return Err(refused(format!(
3324                "renaming `{}` to `{new_name}` would silently re-bind another name — refused",
3325                plan.key.name
3326            )));
3327        }
3328
3329        // Versioned edits: the client rejects the rename if a buffer drifted
3330        // past the analysed version rather than mis-applying it.
3331        let mut document_edits: Vec<TextDocumentEdit> = Vec::new();
3332        for (rel_path, spans) in &plan.edits {
3333            let Some(text) = analysis.snapshots.get(rel_path) else {
3334                continue;
3335            };
3336            let abs = analysis.project_root.join(rel_path);
3337            let Ok(file_uri) = Url::from_file_path(&abs) else {
3338                continue;
3339            };
3340            let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = spans
3341                .iter()
3342                .map(|span| {
3343                    OneOf::Left(TextEdit {
3344                        range: crate::position::span_to_range(text, *span),
3345                        new_text: plan.new_name.clone(),
3346                    })
3347                })
3348                .collect();
3349            document_edits.push(TextDocumentEdit {
3350                text_document: OptionalVersionedTextDocumentIdentifier {
3351                    uri: file_uri,
3352                    version: analysis.versions.get(rel_path).copied(),
3353                },
3354                edits,
3355            });
3356        }
3357        Ok(Some(WorkspaceEdit {
3358            changes: None,
3359            document_changes: Some(DocumentChanges::Edits(document_edits)),
3360            change_annotations: None,
3361        }))
3362    }
3363
3364    /// #302: `workspace/willRenameFiles` — when a `.bynk` file is renamed or
3365    /// moved, keep `uses`/`consumes` references pointing at its unit in sync.
3366    /// Uses `analysis_covering_open_buffers`, the same gate `rename`
3367    /// uses: this handler emits multi-file **versioned** edits too, so a
3368    /// stale open buffer must be refreshed first or the client rejects the
3369    /// whole edit — unlike `documentLink`'s read-only decoration, which
3370    /// tolerates a round lagging by one debounce cycle.
3371    ///
3372    /// Never refuses: a filesystem rename isn't something this soft,
3373    /// edit-only hook can block (the response is just an optional edit), so
3374    /// anything this can't confidently resolve — an unparseable file, a
3375    /// `suite` (addressed by no one), a rename that preserves the unit's
3376    /// arrangement, a cross-project move, a name collision with an existing
3377    /// unit — is simply skipped rather than erroring the whole batch. The
3378    /// collision check is a lightweight `unit_sources` lookup, not `rename`'s
3379    /// full re-analysis: good enough to avoid handing back an edit that is
3380    /// *known in advance* to break the build, without paying for a second
3381    /// analysis round on every file move.
3382    ///
3383    /// Edits for the moved file's own declaration target `old_uri`, not
3384    /// `new_uri`: the client applies the returned edit against files at
3385    /// their current (pre-move) locations, then performs the actual rename,
3386    /// so the file lands at its new path already carrying the new name.
3387    /// Single-file rename only (the capability filter matches files, not
3388    /// folders) — a folder move is a follow-up.
3389    async fn will_rename_files(
3390        &self,
3391        params: RenameFilesParams,
3392    ) -> JsonRpcResult<Option<WorkspaceEdit>> {
3393        let mut combined: std::collections::HashMap<Url, (Option<i32>, Vec<TextEdit>)> =
3394            std::collections::HashMap::new();
3395        for fr in &params.files {
3396            let (Ok(old_uri), Ok(new_uri)) = (Url::parse(&fr.old_uri), Url::parse(&fr.new_uri))
3397            else {
3398                continue;
3399            };
3400            let Some(root) = self.root_for_uri(&old_uri).await else {
3401                continue;
3402            };
3403            let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3404                continue;
3405            };
3406            let Some(old_rel) = Self::uri_to_rel(&analysis, &old_uri) else {
3407                continue;
3408            };
3409            // `new_uri` names a file that doesn't exist yet (`willRenameFiles`
3410            // fires before the physical move) — `uri_to_rel`'s canonicalize
3411            // would silently fail and fall back to the client's raw,
3412            // non-canonical path, which can mismatch `project_root` (always
3413            // canonical) whenever the workspace sits behind a symlink (macOS
3414            // `/tmp` → `/private/tmp` being the common case). Canonicalize the
3415            // *parent* directory instead — it does exist — and rejoin the
3416            // file name.
3417            let Some(new_rel) = Self::uri_to_rel_for_new_path(&analysis, &new_uri) else {
3418                continue;
3419            };
3420            let Some(text) = analysis.snapshots.get(&old_rel) else {
3421                continue;
3422            };
3423            let Some((old_name, name_span)) = crate::symbols::own_declaration_name(text) else {
3424                continue;
3425            };
3426            let Some(new_name) = bynk_ide::renamed_unit_name(&old_rel, &old_name, &new_rel) else {
3427                continue;
3428            };
3429            if new_name == old_name {
3430                continue;
3431            }
3432            // Refuse to hand back an edit that would create a duplicate unit
3433            // name — some other file already declares `new_name`.
3434            if analysis.unit_sources.contains_key(&new_name) {
3435                continue;
3436            }
3437            // Every file's Url is reconstructed the same way (never the
3438            // client's raw `old_uri`/`new_uri` strings) so the moved file's
3439            // own edit and a referencer's edit merge into the same
3440            // `TextDocumentEdit` when they're the same file — a raw client
3441            // string and a `from_file_path` reconstruction aren't guaranteed
3442            // byte-identical (percent-encoding, trailing slashes).
3443            let Ok(old_file_uri) = Url::from_file_path(analysis.project_root.join(&old_rel)) else {
3444                continue;
3445            };
3446            // The moved file's own declaration header — edited at its old
3447            // (still current) location.
3448            combined
3449                .entry(old_file_uri)
3450                .or_insert_with(|| (analysis.versions.get(&old_rel).copied(), Vec::new()))
3451                .1
3452                .push(TextEdit {
3453                    range: crate::position::span_to_range(text, name_span),
3454                    new_text: new_name.clone(),
3455                });
3456            // Every other file's `uses`/`consumes` references to the old name.
3457            for (rel, snap_text) in &analysis.snapshots {
3458                if *rel == old_rel {
3459                    continue;
3460                }
3461                let edits: Vec<TextEdit> = crate::symbols::unit_reference_spans(snap_text)
3462                    .into_iter()
3463                    .filter(|(unit, _)| *unit == old_name)
3464                    .map(|(_, span)| TextEdit {
3465                        range: crate::position::span_to_range(snap_text, span),
3466                        new_text: new_name.clone(),
3467                    })
3468                    .collect();
3469                if edits.is_empty() {
3470                    continue;
3471                }
3472                let Ok(file_uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
3473                    continue;
3474                };
3475                combined
3476                    .entry(file_uri)
3477                    .or_insert_with(|| (analysis.versions.get(rel).copied(), Vec::new()))
3478                    .1
3479                    .extend(edits);
3480            }
3481        }
3482        if combined.is_empty() {
3483            return Ok(None);
3484        }
3485        let document_edits: Vec<TextDocumentEdit> = combined
3486            .into_iter()
3487            .map(|(uri, (version, edits))| TextDocumentEdit {
3488                text_document: OptionalVersionedTextDocumentIdentifier { uri, version },
3489                edits: edits.into_iter().map(OneOf::Left).collect(),
3490            })
3491            .collect();
3492        Ok(Some(WorkspaceEdit {
3493            changes: None,
3494            document_changes: Some(DocumentChanges::Edits(document_edits)),
3495            change_annotations: None,
3496        }))
3497    }
3498
3499    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
3500        // #682: a `bynk.toml` create/delete/change is the one event that can
3501        // move an already-cached URI's route (see `State.root_cache`'s doc) —
3502        // invalidate the whole cache before this batch's lookups consult it,
3503        // so a manifest that just appeared/vanished is reflected within the
3504        // same round rather than one event late. The generation bump closes
3505        // `root_for_uri`'s TOCTOU window against a walk already in flight.
3506        if params.changes.iter().any(|ev| is_bynk_toml(&ev.uri)) {
3507            let mut state = self.state.write().await;
3508            state.root_cache.clear();
3509            state.root_cache_generation += 1;
3510        }
3511        // For every changed `.bynk` file we have open, refresh diagnostics.
3512        // Changes to files we do *not* have open (a git checkout, an external
3513        // edit) still invalidate the project index — schedule a project round
3514        // so cross-file state doesn't go stale (#513).
3515        let mut uris_to_refresh = Vec::new();
3516        // Slice D: route each change to its owning project root, so a change in
3517        // project A never re-analyses project B.
3518        let mut roots_to_reanalyse: std::collections::HashSet<PathBuf> =
3519            std::collections::HashSet::new();
3520        // A `bynk.toml` edit changes the formatting style, the diagnostics
3521        // mode/debounce, and the source root — none of which were re-read after
3522        // the initial load, so the settings only took effect on an LSP restart.
3523        // Detect the change here and reload that project's config before
3524        // re-analysing it.
3525        let mut config_changed_roots: std::collections::HashSet<PathBuf> =
3526            std::collections::HashSet::new();
3527        // #682: snapshotted off the lock — the loop below calls the
3528        // cache-consulting `root_for_uri`, which itself locks `state`, so it
3529        // must not run while a read lock from this function is still held.
3530        let open_docs: std::collections::HashSet<Url> =
3531            self.state.read().await.docs.keys().cloned().collect();
3532        for ev in &params.changes {
3533            if is_bynk_toml(&ev.uri) {
3534                // The manifest's own directory is the project root.
3535                if let Ok(p) = ev.uri.to_file_path()
3536                    && let Some(dir) = p.parent()
3537                {
3538                    let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
3539                    config_changed_roots.insert(root.clone());
3540                    roots_to_reanalyse.insert(root);
3541                }
3542            } else if open_docs.contains(&ev.uri) {
3543                uris_to_refresh.push(ev.uri.clone());
3544            } else if ev.uri.path().ends_with(".bynk")
3545                && let Some(root) = self.root_for_uri(&ev.uri).await
3546            {
3547                roots_to_reanalyse.insert(root);
3548            }
3549        }
3550        // A `bynk.toml` change reloads its project's config — and, if the
3551        // manifest was just *created*, warms the new project (create the entry).
3552        // Slice E: this is how a project added after startup is picked up now
3553        // that `workspace/symbol` no longer walks the tree per query.
3554        for root in &config_changed_roots {
3555            let config = project::load_config(root).unwrap_or_default();
3556            let mut state = self.state.write().await;
3557            state
3558                .projects
3559                .entry(root.clone())
3560                .and_modify(|ps| ps.config = config.clone())
3561                .or_insert_with(|| ProjectState {
3562                    config,
3563                    ..Default::default()
3564                });
3565        }
3566        for uri in uris_to_refresh {
3567            self.schedule_diagnostics(&uri).await;
3568        }
3569        // A reloaded config re-derives the diagnostics behaviour, so re-analyse
3570        // each affected project against it — the same debounced round a non-open
3571        // `.bynk` change schedules. A no-op for a root with no entry (a project
3572        // no file has opened): nothing is published there to go stale.
3573        for root in roots_to_reanalyse {
3574            self.schedule_project_diagnostics(root).await;
3575        }
3576    }
3577
3578    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
3579        // Slice D (Q4): folders are discovery seeds, not routing owners.
3580        // Added folders extend the seed set; removed folders shrink it, then any
3581        // project a removed folder orphaned — no remaining folder, no open
3582        // buffer — is pruned.
3583        let added_dirs: Vec<PathBuf> = {
3584            let mut state = self.state.write().await;
3585            let mut added = Vec::new();
3586            for a in &params.event.added {
3587                if let Ok(p) = a.uri.to_file_path() {
3588                    let dir = p.canonicalize().unwrap_or(p);
3589                    if !state.folders.contains(&dir) {
3590                        state.folders.push(dir.clone());
3591                        added.push(dir);
3592                    }
3593                }
3594            }
3595            for removed in &params.event.removed {
3596                if let Ok(p) = removed.uri.to_file_path() {
3597                    let dir = p.canonicalize().unwrap_or(p);
3598                    state.folders.retain(|f| f != &dir);
3599                }
3600            }
3601            // #682: `resolve_canonical` never consults `folders` — a folder
3602            // change cannot actually move any URI's route today — but clear
3603            // (and bump the generation, same as the `bynk.toml` case) as a
3604            // defensive, effectively-free no-op against that ever changing,
3605            // rather than relying on routing's independence from folders
3606            // staying true forever.
3607            state.root_cache.clear();
3608            state.root_cache_generation += 1;
3609            added
3610        };
3611        // Slice E: warm the added folders proactively — the analysis D deferred
3612        // to here, using the same discovery walk as startup.
3613        self.warm_projects(&added_dirs).await;
3614        // Clear the dropped projects' diagnostics so the client does not keep
3615        // showing stale squiggles for a folder that is gone.
3616        for uri in self.prune_orphaned_projects().await {
3617            self.client.publish_diagnostics(uri, Vec::new(), None).await;
3618        }
3619    }
3620}
3621
3622/// The advertised capability set — `design/bynk-lsp-spec.md` §4.3. Split out
3623/// of `initialize` so the advertisement is unit-testable without transport.
3624fn server_capabilities() -> ServerCapabilities {
3625    ServerCapabilities {
3626        // Full-text sync, with save notifications explicitly opted in — the
3627        // `on_save` diagnostics mode is driven by `didSave` (#513).
3628        text_document_sync: Some(TextDocumentSyncCapability::Options(
3629            TextDocumentSyncOptions {
3630                open_close: Some(true),
3631                change: Some(TextDocumentSyncKind::FULL),
3632                save: Some(TextDocumentSyncSaveOptions::Supported(true)),
3633                ..Default::default()
3634            },
3635        )),
3636        hover_provider: Some(HoverProviderCapability::Simple(true)),
3637        definition_provider: Some(OneOf::Left(true)),
3638        // v0.17: completion for `consumes` units and `given` /
3639        // `consumes U { … }` capabilities. Trigger on the space after a
3640        // keyword, the `{` of a selected-capability list, and `,`. The `.`
3641        // auto-fires the name- and value-receiver member contexts (ADR 0093 D1).
3642        completion_provider: Some(CompletionOptions {
3643            trigger_characters: Some(vec![
3644                " ".to_string(),
3645                "{".to_string(),
3646                ",".to_string(),
3647                ".".to_string(),
3648            ]),
3649            // Slice 5: resolve fills in hover-quality `documentation` lazily, on
3650            // the focused item only, so the initial list stays cheap.
3651            resolve_provider: Some(true),
3652            ..Default::default()
3653        }),
3654        // v0.32 (ADR 0065): signature help while typing a call's arguments.
3655        signature_help_provider: Some(SignatureHelpOptions {
3656            trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
3657            retrigger_characters: Some(vec![",".to_string()]),
3658            ..Default::default()
3659        }),
3660        // v0.33 (ADR 0066): reference-count lenses above top-level definitions.
3661        code_lens_provider: Some(CodeLensOptions {
3662            resolve_provider: Some(false),
3663        }),
3664        // v0.34 (ADR 0067): call hierarchy over the binding index's call graph.
3665        call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
3666        // v0.35 (ADR 0068): implementation nav — capability → its providers.
3667        implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
3668        // Slice 6: go-to-type-definition (value → its type's declaration).
3669        type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
3670        // Slice 6b: `uses`/`consumes` unit names link to their source.
3671        document_link_provider: Some(DocumentLinkOptions {
3672            resolve_provider: Some(false),
3673            work_done_progress_options: Default::default(),
3674        }),
3675        document_formatting_provider: Some(OneOf::Left(true)),
3676        document_range_formatting_provider: Some(OneOf::Left(true)),
3677        document_symbol_provider: Some(OneOf::Left(true)),
3678        // v0.37 (ADR 0070): structural folding + selection ranges (AST-driven).
3679        folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
3680        selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
3681        // v0.25 (ADR 0053): references + rename over the binding
3682        // index; prepareRename refuses out-of-scope symbols.
3683        references_provider: Some(OneOf::Left(true)),
3684        rename_provider: Some(OneOf::Right(RenameOptions {
3685            prepare_provider: Some(true),
3686            work_done_progress_options: Default::default(),
3687        })),
3688        // v0.26 (ADR 0054): quick-fixes from the diagnostics' structured
3689        // suggestions. v0.213 (ADR 0239) adds the extract-variable refactor.
3690        code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
3691            code_action_kinds: Some(vec![
3692                CodeActionKind::QUICKFIX,
3693                CodeActionKind::REFACTOR,
3694                CodeActionKind::REFACTOR_EXTRACT,
3695            ]),
3696            ..Default::default()
3697        })),
3698        // v0.27 (ADR 0056): inferred-type inlay hints from the retained
3699        // analysis round's harvested hint set.
3700        inlay_hint_provider: Some(OneOf::Left(true)),
3701        // v0.28 (ADR 0057): semantic tokens over the frozen legend — a
3702        // pure read of the cached index (`symbols` + `foreign_refs`),
3703        // additive over the client's syntactic layer. `delta` deferred.
3704        semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions(
3705            SemanticTokensOptions {
3706                legend: crate::index_queries::semantic_tokens_legend(),
3707                full: Some(SemanticTokensFullOptions::Bool(true)),
3708                range: Some(true),
3709                ..Default::default()
3710            },
3711        )),
3712        // v0.26 riders (ADR 0055): both are `ProjectIndex` queries.
3713        workspace_symbol_provider: Some(OneOf::Left(true)),
3714        document_highlight_provider: Some(OneOf::Left(true)),
3715        workspace: Some(WorkspaceServerCapabilities {
3716            workspace_folders: Some(WorkspaceFoldersServerCapabilities {
3717                supported: Some(true),
3718                change_notifications: Some(OneOf::Left(true)),
3719            }),
3720            // #302: `willRenameFiles` over `.bynk` files only (not folders) —
3721            // keeps `uses`/`consumes` references in sync on a single-file
3722            // rename/move; a folder move is a follow-up.
3723            file_operations: Some(WorkspaceFileOperationsServerCapabilities {
3724                will_rename: Some(FileOperationRegistrationOptions {
3725                    filters: vec![FileOperationFilter {
3726                        scheme: Some("file".to_string()),
3727                        pattern: FileOperationPattern {
3728                            glob: "**/*.bynk".to_string(),
3729                            matches: Some(FileOperationPatternKind::File),
3730                            options: None,
3731                        },
3732                    }],
3733                }),
3734                ..Default::default()
3735            }),
3736        }),
3737        // #846/#847: no standard `ServerCapabilities` field exists for a custom
3738        // request — `experimental` is the only feature-detection surface a
3739        // client has for `bynk/sequenceModel`, `bynk/documentationModel`, and
3740        // `bynk/architectureModel`.
3741        experimental: Some(serde_json::json!({
3742            "sequenceModel": true,
3743            "documentationModel": true,
3744            "architectureModel": true,
3745            "wireContract": true,
3746        })),
3747        ..Default::default()
3748    }
3749}
3750
3751/// Index symbol kind → LSP symbol kind, aligned with the document-symbol
3752/// outline's choices (capability=INTERFACE, service/agent=CLASS,
3753/// provider=OBJECT). The index does not distinguish type shapes, so every
3754/// type maps to STRUCT.
3755/// Map a `completion::Completion` to an LSP `CompletionItem`.
3756/// Stash the document URI in each item's `data` so `completion_resolve` can look
3757/// the symbol up — a resolve request carries only the item, not a position.
3758fn stamp_resolve_data(items: &mut [CompletionItem], uri: &Url) {
3759    let data = serde_json::json!({ "uri": uri.to_string() });
3760    for item in items.iter_mut() {
3761        item.data = Some(data.clone());
3762    }
3763}
3764
3765/// v0.124 (slice 3): the enclosing function's parameters (and `result` for an
3766/// `ensures`) as completions, when `offset` sits in a `requires`/`ensures`
3767/// predicate. Empty when not in a contract clause or no enclosing `fn` is
3768/// found. A pure parse — the params are read straight off the recovered AST.
3769/// v0.131: the CORS completion cells. Inside a `cors { }` block at a field-name
3770/// position, offer the closed field set; at a service-body item start, offer the
3771/// `cors` section keyword. Both are lexical (offset-based), matching the
3772/// `contract_param_completions` posture.
3773fn cors_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3774    if completion::in_cors_field_position(text, offset) {
3775        return completion::CORS_FIELDS
3776            .iter()
3777            .map(|(name, doc)| CompletionItem {
3778                label: name.to_string(),
3779                kind: Some(CompletionItemKind::FIELD),
3780                detail: Some((*doc).to_string()),
3781                insert_text: Some(format!("{name}: ")),
3782                ..Default::default()
3783            })
3784            .collect();
3785    }
3786    if completion::in_service_body_item_position(text, offset, line) {
3787        return vec![CompletionItem {
3788            label: "cors".to_string(),
3789            kind: Some(CompletionItemKind::KEYWORD),
3790            detail: Some("a cross-origin (CORS) policy for this HTTP service".to_string()),
3791            insert_text: Some("cors {\n\torigins: [$0],\n}".to_string()),
3792            insert_text_format: Some(InsertTextFormat::SNIPPET),
3793            ..Default::default()
3794        }];
3795    }
3796    Vec::new()
3797}
3798
3799/// v0.141 (ADR 0164): the security-headers completion cells. Inside a
3800/// `security { }` block at a field-name position, offer the closed field set
3801/// (`nosniff`/`hsts`); at a service-body item start, offer the `security` section
3802/// keyword. Both are lexical (offset-based), mirroring `cors_completions`.
3803fn security_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3804    if completion::in_security_field_position(text, offset) {
3805        return completion::SECURITY_FIELDS
3806            .iter()
3807            .map(|(name, doc)| CompletionItem {
3808                label: name.to_string(),
3809                kind: Some(CompletionItemKind::FIELD),
3810                detail: Some((*doc).to_string()),
3811                insert_text: Some(format!("{name}: ")),
3812                ..Default::default()
3813            })
3814            .collect();
3815    }
3816    if completion::in_service_body_item_position(text, offset, line) {
3817        return vec![CompletionItem {
3818            label: "security".to_string(),
3819            kind: Some(CompletionItemKind::KEYWORD),
3820            detail: Some("security response headers for this HTTP service".to_string()),
3821            insert_text: Some("security {\n\tnosniff: $0,\n}".to_string()),
3822            insert_text_format: Some(InsertTextFormat::SNIPPET),
3823            ..Default::default()
3824        }];
3825    }
3826    Vec::new()
3827}
3828
3829/// v0.140 (ADR 0163): the `@cache` completion cells. Inside `@cache( … )` at an
3830/// argument-name position, offer the closed argument set (`maxAge`/`scope`); at a
3831/// service-body item start, offer the `@cache` annotation snippet. Both are lexical
3832/// (offset-based), mirroring `cors_completions`.
3833fn cache_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3834    if completion::in_cache_arg_position(text, offset) {
3835        return completion::CACHE_ARGS
3836            .iter()
3837            .map(|(name, doc)| CompletionItem {
3838                label: name.to_string(),
3839                kind: Some(CompletionItemKind::FIELD),
3840                detail: Some((*doc).to_string()),
3841                insert_text: Some(format!("{name}: ")),
3842                ..Default::default()
3843            })
3844            .collect();
3845    }
3846    if completion::in_service_body_item_position(text, offset, line) {
3847        return vec![CompletionItem {
3848            label: "@cache".to_string(),
3849            kind: Some(CompletionItemKind::SNIPPET),
3850            detail: Some(
3851                "cache a GET read — a synthesised ETag/304 revalidation with a freshness window"
3852                    .to_string(),
3853            ),
3854            insert_text: Some("@cache(maxAge: ${1:5.minutes})".to_string()),
3855            insert_text_format: Some(InsertTextFormat::SNIPPET),
3856            ..Default::default()
3857        }];
3858    }
3859    Vec::new()
3860}
3861
3862/// v0.142 (ADR 0165): the request-limits completion cells. Inside a `limits { }`
3863/// block at a field-name position, offer the closed field set (`maxBody`); at a
3864/// service-body item start, offer the `limits` section keyword. Both are lexical
3865/// (offset-based), mirroring `security_completions`.
3866fn limits_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3867    if completion::in_limits_field_position(text, offset) {
3868        return completion::LIMITS_FIELDS
3869            .iter()
3870            .map(|(name, doc)| CompletionItem {
3871                label: name.to_string(),
3872                kind: Some(CompletionItemKind::FIELD),
3873                detail: Some((*doc).to_string()),
3874                insert_text: Some(format!("{name}: ")),
3875                ..Default::default()
3876            })
3877            .collect();
3878    }
3879    if completion::in_service_body_item_position(text, offset, line) {
3880        return vec![CompletionItem {
3881            label: "limits".to_string(),
3882            kind: Some(CompletionItemKind::KEYWORD),
3883            detail: Some("request limits for this HTTP service".to_string()),
3884            insert_text: Some("limits {\n\tmaxBody: $0,\n}".to_string()),
3885            insert_text_format: Some(InsertTextFormat::SNIPPET),
3886            ..Default::default()
3887        }];
3888    }
3889    Vec::new()
3890}
3891
3892/// v0.142 (ADR 0165): the `@limit` completion cells. Inside `@limit( … )` at an
3893/// argument-name position, offer the closed argument set (`maxBody`); at a
3894/// service-body item start, offer the `@limit` annotation snippet. Both are lexical
3895/// (offset-based), mirroring `cache_completions`.
3896fn limit_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3897    if completion::in_limit_arg_position(text, offset) {
3898        return completion::LIMIT_ARGS
3899            .iter()
3900            .map(|(name, doc)| CompletionItem {
3901                label: name.to_string(),
3902                kind: Some(CompletionItemKind::FIELD),
3903                detail: Some((*doc).to_string()),
3904                insert_text: Some(format!("{name}: ")),
3905                ..Default::default()
3906            })
3907            .collect();
3908    }
3909    if completion::in_service_body_item_position(text, offset, line) {
3910        return vec![CompletionItem {
3911            label: "@limit".to_string(),
3912            kind: Some(CompletionItemKind::SNIPPET),
3913            detail: Some(
3914                "cap the request body size — a `413` synthesised before the body is read"
3915                    .to_string(),
3916            ),
3917            insert_text: Some("@limit(maxBody: ${1:1048576})".to_string()),
3918            insert_text_format: Some(InsertTextFormat::SNIPPET),
3919            ..Default::default()
3920        }];
3921    }
3922    Vec::new()
3923}
3924
3925fn contract_param_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3926    use bynk_syntax::ast::{CommonsItem, SourceUnit};
3927    let Some(is_ensures) = completion::contract_clause_kind(line) else {
3928        return Vec::new();
3929    };
3930    let Ok(tokens) = bynk_syntax::lexer::tokenize(text) else {
3931        return Vec::new();
3932    };
3933    let (Some(unit), _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text) else {
3934        return Vec::new();
3935    };
3936    let items = match &unit {
3937        SourceUnit::Commons(c) => &c.items,
3938        SourceUnit::Context(c) => &c.items,
3939        SourceUnit::Adapter(a) => &a.items,
3940        _ => return Vec::new(),
3941    };
3942    for item in items {
3943        // The cursor sits in a fn's signature/contract region: between the fn's
3944        // start and the `{` that opens its body.
3945        if let CommonsItem::Fn(f) = item
3946            && f.span.start <= offset
3947            && offset <= f.body.span.start
3948        {
3949            // Built directly as VARIABLE items, matching `locals_completions`
3950            // (in-scope names carry no resolve data).
3951            let mut out: Vec<CompletionItem> = f
3952                .params
3953                .iter()
3954                .filter(|p| p.name.name != "_")
3955                .map(|p| CompletionItem {
3956                    label: p.name.name.clone(),
3957                    kind: Some(CompletionItemKind::VARIABLE),
3958                    detail: Some(format!(
3959                        "parameter: {}",
3960                        crate::symbols::type_ref_str(&p.type_ref)
3961                    )),
3962                    ..Default::default()
3963                })
3964                .collect();
3965            if is_ensures {
3966                out.push(CompletionItem {
3967                    label: "result".to_string(),
3968                    kind: Some(CompletionItemKind::VARIABLE),
3969                    detail: Some("the function's return value".to_string()),
3970                    ..Default::default()
3971                });
3972            }
3973            return out;
3974        }
3975    }
3976    Vec::new()
3977}
3978
3979/// v0.124 (slice 3): the byte offset of the scrutinee's last character in
3980/// `<scrutinee> is <partial>` ending at `cursor`, or `None` if the cursor is
3981/// not at an `is`-pattern position. `is` must be a standalone word (so `basis`
3982/// does not trigger it).
3983fn is_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
3984    let before = text.get(..cursor)?;
3985    // Drop the partial variant being typed, then the whitespace before it.
3986    let before = before
3987        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
3988        .trim_end();
3989    let before = before.strip_suffix("is")?;
3990    if !before.ends_with(char::is_whitespace) {
3991        return None;
3992    }
3993    let before = before.trim_end();
3994    (!before.is_empty()).then(|| before.len() - 1)
3995}
3996
3997/// v0.128: the byte offset of the scrutinee's last character in a
3998/// `match <scrutinee> { … <partial>` whose cursor sits at an **arm-pattern-start**
3999/// position, or `None` otherwise — the deferred half of slice 3's `is`-pattern
4000/// completion. Conservative: it fires only at the *start* of an arm's pattern
4001/// (after the `{` or a top-level `,`, before any `=>`), never inside an arm body
4002/// or a nested constructor pattern, so it stays honest mid-edit.
4003fn match_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
4004    let before = text.get(..cursor)?;
4005    // The innermost `{` still open at the cursor — the block the cursor is in.
4006    let brace = innermost_open_brace(before)?;
4007    // The current arm: from the last top-level `,` after the brace (or the brace
4008    // itself) to the cursor. A `=>` in it means the cursor is in the arm body.
4009    let arm_start = arm_start_offset(before, brace);
4010    let arm = before.get(arm_start..)?;
4011    if arm.contains("=>") {
4012        return None;
4013    }
4014    // Only at the pattern's *start*: nothing but the partial pattern being typed
4015    // sits between the arm boundary and the cursor.
4016    if !arm
4017        .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4018        .trim()
4019        .is_empty()
4020    {
4021        return None;
4022    }
4023    match_head_scrutinee_offset(before, brace)
4024}
4025
4026/// v0.145 (ADR 0169): the scrutinee offset and outer variant name at a
4027/// `match <scrutinee> { … OuterVariant(<partial>` position — the cursor inside a
4028/// variant's payload parens within an arm-pattern (before `=>`), the one place
4029/// `match_scrutinee_offset` bails. Conservative: the payload `(` must be still
4030/// open, the token before it an uppercase-led variant constructor, and only the
4031/// partial nested pattern may sit between the `(` and the cursor.
4032fn nested_pattern_offset(text: &str, cursor: usize) -> Option<(usize, String)> {
4033    let before = text.get(..cursor)?;
4034    let brace = innermost_open_brace(before)?;
4035    let arm_start = arm_start_offset(before, brace);
4036    let arm = before.get(arm_start..)?;
4037    if arm.contains("=>") {
4038        return None; // in the arm body, not its pattern
4039    }
4040    // The innermost `(` still open in the arm — the outer variant's payload.
4041    let paren = innermost_open_paren(arm)?;
4042    // The identifier immediately before that `(` is the outer variant; only an
4043    // uppercase-led constructor opens a nested pattern (a binding never does).
4044    let head = arm.get(..paren)?.trim_end();
4045    let variant: String = head
4046        .chars()
4047        .rev()
4048        .take_while(|c| c.is_alphanumeric() || *c == '_')
4049        .collect::<Vec<_>>()
4050        .into_iter()
4051        .rev()
4052        .collect();
4053    if !variant.chars().next().is_some_and(char::is_uppercase) {
4054        return None;
4055    }
4056    // Between the payload `(` and the cursor, only the partial nested pattern
4057    // being typed (an identifier, optionally a `Type.` qualifier) may sit.
4058    let after = arm.get(paren + 1..)?;
4059    if !after
4060        .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '.')
4061        .trim()
4062        .is_empty()
4063    {
4064        return None;
4065    }
4066    let scrut_off = match_head_scrutinee_offset(before, brace)?;
4067    Some((scrut_off, variant))
4068}
4069
4070/// The byte offset of the scrutinee's last character for the `match <scrutinee>`
4071/// whose body brace is at `brace`, or `None` if `brace` does not head a
4072/// `match`: a standalone `match` keyword, then a scrutinee expression with no
4073/// nested block or arrow between it and the brace. Shared by
4074/// `match_scrutinee_offset` and `nested_pattern_offset`.
4075fn match_head_scrutinee_offset(before: &str, brace: usize) -> Option<usize> {
4076    let head = before.get(..brace)?.trim_end();
4077    let m = head.rfind("match")?;
4078    if head[..m]
4079        .chars()
4080        .next_back()
4081        .is_some_and(|c| c.is_alphanumeric() || c == '_')
4082    {
4083        return None; // part of a longer identifier (`rematch`), not the keyword
4084    }
4085    let after = head.get(m + "match".len()..)?;
4086    if !after.starts_with(char::is_whitespace) {
4087        return None;
4088    }
4089    let scrut = after.trim();
4090    if scrut.is_empty() || scrut.contains(['{', '}']) || scrut.contains("=>") {
4091        return None;
4092    }
4093    // `head` was trimmed to end at the scrutinee's last char (the brace followed).
4094    Some(head.len() - 1)
4095}
4096
4097/// The offset (relative to `arm`) of the innermost `(` left unclosed in `arm` — a
4098/// `(`-only balance scan, the payload paren the cursor sits in — or `None` if
4099/// every `(` is closed.
4100fn innermost_open_paren(arm: &str) -> Option<usize> {
4101    let mut stack: Vec<usize> = Vec::new();
4102    for (i, c) in arm.char_indices() {
4103        match c {
4104            '(' => stack.push(i),
4105            ')' => {
4106                stack.pop();
4107            }
4108            _ => {}
4109        }
4110    }
4111    stack.pop()
4112}
4113
4114/// The byte offset of the innermost `{` left unclosed in `before` (a `{`-only
4115/// balance scan — the block the cursor sits in), or `None` if every `{` is closed.
4116fn innermost_open_brace(before: &str) -> Option<usize> {
4117    let mut stack: Vec<usize> = Vec::new();
4118    for (i, c) in before.char_indices() {
4119        match c {
4120            '{' => stack.push(i),
4121            '}' => {
4122                stack.pop();
4123            }
4124            _ => {}
4125        }
4126    }
4127    stack.pop()
4128}
4129
4130/// The offset just past the last top-level `,` inside the block opened at `brace`
4131/// (depth 0 relative to that brace), or just past the brace itself if the block
4132/// holds no top-level comma yet — the start of the arm the cursor is editing.
4133fn arm_start_offset(before: &str, brace: usize) -> usize {
4134    let mut depth = 0i32;
4135    let mut start = brace + 1; // just after the `{`
4136    for (rel, c) in before[brace + 1..].char_indices() {
4137        match c {
4138            '{' | '(' | '[' => depth += 1,
4139            '}' | ')' | ']' => depth -= 1,
4140            ',' if depth == 0 => start = brace + 1 + rel + c.len_utf8(),
4141            _ => {}
4142        }
4143    }
4144    start
4145}
4146
4147fn to_completion_item(c: completion::Completion) -> CompletionItem {
4148    CompletionItem {
4149        kind: Some(match c.kind {
4150            completion::CompletionKind::Unit => CompletionItemKind::MODULE,
4151            completion::CompletionKind::Capability => CompletionItemKind::INTERFACE,
4152            completion::CompletionKind::Type => CompletionItemKind::STRUCT,
4153            completion::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
4154            completion::CompletionKind::Snippet => CompletionItemKind::SNIPPET,
4155            completion::CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
4156            completion::CompletionKind::Member => CompletionItemKind::METHOD,
4157            completion::CompletionKind::Field => CompletionItemKind::FIELD,
4158            completion::CompletionKind::Constructor => CompletionItemKind::CONSTRUCTOR,
4159            completion::CompletionKind::Function => CompletionItemKind::FUNCTION,
4160        }),
4161        // Snippet items carry `${n:…}` tab stops; everything else inserts its
4162        // label verbatim (the default).
4163        insert_text_format: c.insert_text.as_ref().map(|_| InsertTextFormat::SNIPPET),
4164        insert_text: c.insert_text,
4165        label: c.label,
4166        detail: c.detail,
4167        ..Default::default()
4168    }
4169}
4170
4171/// The byte offset of an LSP `(line, character)` position in `text`,
4172/// clamped to the end of the document when the position lies past it.
4173/// LSP positions count UTF-16 code units, so this goes through the shared
4174/// converter — a byte-faithful reading misplaces the cursor on any line
4175/// with non-ASCII text before it.
4176fn cursor_offset(text: &str, pos: Position) -> usize {
4177    crate::position::position_to_offset(text, pos).unwrap_or(text.len())
4178}
4179
4180/// v0.34 (ADR 0067): a serializable mirror of [`bynk_check::index::SymbolKey`] for
4181/// round-tripping through `CallHierarchyItem.data` — the index kind isn't
4182/// `Serialize`, so the kind travels as its `display()` string.
4183#[derive(serde::Serialize, serde::Deserialize)]
4184struct SerKey {
4185    unit: String,
4186    kind: String,
4187    name: String,
4188}
4189
4190impl From<&bynk_check::index::SymbolKey> for SerKey {
4191    fn from(k: &bynk_check::index::SymbolKey) -> Self {
4192        SerKey {
4193            unit: k.unit.clone(),
4194            kind: k.kind.display().to_string(),
4195            name: k.name.clone(),
4196        }
4197    }
4198}
4199
4200impl SerKey {
4201    /// Recover a `SymbolKey` from a `CallHierarchyItem`'s `data`. `None` for a
4202    /// missing/garbled payload or an unknown kind — the follow-up then returns
4203    /// no calls rather than guessing.
4204    fn read(data: &Option<serde_json::Value>) -> Option<bynk_check::index::SymbolKey> {
4205        let sk: SerKey = serde_json::from_value(data.as_ref()?.clone()).ok()?;
4206        let kind = match sk.kind.as_str() {
4207            "type" => bynk_check::index::SymbolKind::Type,
4208            "fn" => bynk_check::index::SymbolKind::Fn,
4209            "capability" => bynk_check::index::SymbolKind::Capability,
4210            "service" => bynk_check::index::SymbolKind::Service,
4211            "agent" => bynk_check::index::SymbolKind::Agent,
4212            "provider" => bynk_check::index::SymbolKind::Provider,
4213            _ => return None,
4214        };
4215        Some(bynk_check::index::SymbolKey {
4216            unit: sk.unit,
4217            kind,
4218            name: sk.name,
4219        })
4220    }
4221}
4222
4223fn lsp_symbol_kind(kind: bynk_check::index::SymbolKind) -> SymbolKind {
4224    match kind {
4225        bynk_check::index::SymbolKind::Type => SymbolKind::STRUCT,
4226        bynk_check::index::SymbolKind::Fn => SymbolKind::FUNCTION,
4227        bynk_check::index::SymbolKind::Capability => SymbolKind::INTERFACE,
4228        bynk_check::index::SymbolKind::Service | bynk_check::index::SymbolKind::Agent => {
4229            SymbolKind::CLASS
4230        }
4231        bynk_check::index::SymbolKind::Provider => SymbolKind::OBJECT,
4232        bynk_check::index::SymbolKind::Method => SymbolKind::METHOD,
4233        bynk_check::index::SymbolKind::CapabilityOp => SymbolKind::METHOD,
4234        bynk_check::index::SymbolKind::Field => SymbolKind::FIELD,
4235        bynk_check::index::SymbolKind::Actor => SymbolKind::INTERFACE,
4236        bynk_check::index::SymbolKind::Handler => SymbolKind::METHOD,
4237        bynk_check::index::SymbolKind::Messages => SymbolKind::STRUCT,
4238    }
4239}
4240
4241/// Whether a watched-file URI names a `bynk.toml` manifest — the trigger for a
4242/// live config reload. Matches on the file-name component (not a path suffix),
4243/// so a file like `notbynk.toml` doesn't spuriously fire.
4244fn is_bynk_toml(uri: &Url) -> bool {
4245    let Ok(path) = uri.to_file_path() else {
4246        return false;
4247    };
4248    path.file_name().and_then(|n| n.to_str()) == Some("bynk.toml")
4249}
4250
4251/// The `codeDescription` link for a diagnostic `code` (#853): a clickable link
4252/// to the code's Book explanation when the compiler curates one, else `None`
4253/// (the designed graceful-fallback state — an uncurated code renders no link,
4254/// which is not an error). Split out from [`make_diagnostic`] so the
4255/// mapped→`Some` / uncurated→`None` contract is directly testable.
4256fn code_description(code: &str) -> Option<CodeDescription> {
4257    let href = Url::parse(&bynk_syntax::diagnostics::explain(code)?.href()).ok()?;
4258    Some(CodeDescription { href })
4259}
4260
4261#[cfg(test)]
4262mod code_description_tests {
4263    use super::code_description;
4264
4265    #[test]
4266    fn mapped_code_gets_a_valid_book_link() {
4267        let cd = code_description("bynk.resolve.unknown_type")
4268            .expect("a curated code produces a codeDescription");
4269        assert_eq!(cd.href.scheme(), "https");
4270        assert_eq!(cd.href.host_str(), Some("bynk-lang.org"));
4271        assert!(cd.href.path().starts_with("/book/"));
4272    }
4273
4274    #[test]
4275    fn uncurated_code_gets_no_link() {
4276        // A real code with no curated explanation, and a nonsense code, both
4277        // fall back to no link (graceful — not an error).
4278        assert!(code_description("bynk.resolve.duplicate_type").is_none());
4279        assert!(code_description("bynk.not.a_real_code").is_none());
4280    }
4281
4282    #[test]
4283    fn every_curated_explanation_yields_a_parseable_url() {
4284        // Guards that no curated href ever silently drops its link because
4285        // `Url::parse` rejected it.
4286        for e in bynk_syntax::diagnostics::EXPLANATIONS {
4287            assert!(
4288                code_description(e.code).is_some(),
4289                "curated explanation `{}` produced no codeDescription — its href \
4290                 `{}` did not parse as a URL",
4291                e.code,
4292                e.href()
4293            );
4294        }
4295    }
4296}
4297
4298fn make_diagnostic(
4299    d: &bynk_ide::Diagnostic,
4300    positions: &crate::position::PositionMap,
4301    uri: &Url,
4302) -> Diagnostic {
4303    let range = positions.range(d.error.span);
4304    let severity = match d.severity {
4305        bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
4306        bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
4307    };
4308    let related_information: Vec<DiagnosticRelatedInformation> = d
4309        .error
4310        .labels
4311        .iter()
4312        .map(|(span, msg)| DiagnosticRelatedInformation {
4313            location: Location {
4314                // Secondary-label spans are offsets into this same document's
4315                // `text`, so they belong to the document's own URI — not a
4316                // placeholder. (Cross-file related info is not yet modelled.)
4317                uri: uri.clone(),
4318                range: positions.range(*span),
4319            },
4320            message: msg.clone(),
4321        })
4322        .collect();
4323    let mut message = d.error.message.clone();
4324    for note in &d.error.notes {
4325        message.push_str("\n\n");
4326        message.push_str("note: ");
4327        message.push_str(note);
4328    }
4329    Diagnostic {
4330        range,
4331        severity: Some(severity),
4332        code: Some(NumberOrString::String(d.error.category.to_string())),
4333        // #853: a curated code carries a `codeDescription` link to its Book
4334        // explanation (rendered as a link on the code in Problems/hover); an
4335        // uncurated code has no entry and stays `None` — the designed
4336        // graceful-fallback state, not an error.
4337        code_description: code_description(d.error.category),
4338        source: Some(SERVER_NAME.to_string()),
4339        message,
4340        related_information: if related_information.is_empty() {
4341            None
4342        } else {
4343            Some(related_information)
4344        },
4345        tags: None,
4346        data: None,
4347    }
4348}
4349
4350/// Slice C: the server's entry point, moved out of `main.rs` so the crate
4351/// has a `[lib]` target. `main.rs` is now a thin shim over this.
4352///
4353/// #1667: returns the process's exit code: success after an orderly
4354/// `shutdown`, failure (with a line on stderr) when the session ended any
4355/// other way.
4356pub async fn run() -> std::process::ExitCode {
4357    // Answer `--version`/`-V` and exit before entering the stdio LSP loop, so
4358    // tooling (e.g. the VS Code status bar) can query the version without the
4359    // server blocking on stdin.
4360    if std::env::args()
4361        .skip(1)
4362        .any(|a| a == "--version" || a == "-V")
4363    {
4364        println!("{SERVER_NAME} {SERVER_VERSION}");
4365        return std::process::ExitCode::SUCCESS;
4366    }
4367    // Logging to ~/.bynk-lsp.log. Default level: warn; tunable via
4368    // RUST_LOG or the LSP client's trace setting.
4369    if let Some(home) = std::env::var_os("HOME") {
4370        let path: PathBuf = PathBuf::from(home).join(".bynk-lsp.log");
4371        if let Ok(file) = std::fs::OpenOptions::new()
4372            .create(true)
4373            .append(true)
4374            .open(&path)
4375        {
4376            use tracing_subscriber::prelude::*;
4377            let env_filter = tracing_subscriber::EnvFilter::try_from_env("BYNK_LSP_LOG")
4378                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
4379            let file_layer = tracing_subscriber::fmt::layer()
4380                .with_writer(std::sync::Mutex::new(file))
4381                .with_ansi(false);
4382            tracing_subscriber::registry()
4383                .with(env_filter)
4384                .with(file_layer)
4385                .try_init()
4386                .ok();
4387        }
4388    }
4389    tracing::info!("bynkc-lsp v{} starting", SERVER_VERSION);
4390    let stdout = tokio::io::stdout();
4391    // #1667: the server reads a re-framed copy of stdin, so one malformed
4392    // message is skipped rather than ending the session (see `transport`).
4393    let (server_input, pump_output) = tokio::io::duplex(1 << 16);
4394    tokio::spawn(transport::pump(tokio::io::stdin(), pump_output));
4395    let shutdown_requested = Arc::new(std::sync::atomic::AtomicBool::new(false));
4396    let flag = Arc::clone(&shutdown_requested);
4397    // #846: this server's first custom (non-standard) request — everything
4398    // else is a `LanguageServer` trait method, registered automatically by
4399    // `LspService::new`. `bynk/sequenceModel` has no trait slot, so it needs
4400    // the builder's `custom_method` instead.
4401    let (service, socket) =
4402        LspService::build(move |client| Backend::with_shutdown_flag(client, Arc::clone(&flag)))
4403            .custom_method("bynk/sequenceModel", Backend::sequence_model)
4404            .custom_method("bynk/documentationModel", Backend::documentation_model)
4405            .custom_method("bynk/architectureModel", Backend::architecture_model)
4406            .custom_method("bynk/wireContract", Backend::wire_contract)
4407            .finish();
4408    Server::new(server_input, stdout, socket)
4409        .serve(service)
4410        .await;
4411    if shutdown_requested.load(std::sync::atomic::Ordering::SeqCst) {
4412        std::process::ExitCode::SUCCESS
4413    } else {
4414        eprintln!(
4415            "bynkc-lsp: the session ended without a `shutdown` request \
4416             (the client closed the connection, or sent `exit` early); see ~/.bynk-lsp.log"
4417        );
4418        std::process::ExitCode::FAILURE
4419    }
4420}
4421
4422#[cfg(test)]
4423mod tests {
4424    use super::*;
4425
4426    // -- Slice A: the project model, driven through `Backend` ---------------
4427    //
4428    // These are the crate's first *behaviour-over-time* tests: they drive the
4429    // real `Backend` — the layer the track doc (§4.1) notes has always been
4430    // testable in-crate via `LspService::new(Backend::new)`, and never was.
4431    // Everything else in this module asserts *static* shape.
4432    //
4433    // Hermetic on purpose. `bynk-lsp` is published and `Cargo.toml`'s `exclude`
4434    // list can only drop `tests/*.rs`, never this file — so an in-crate test
4435    // reading a sibling directory would fail `cargo test` on the released
4436    // tarball. (`find_source_root_walks_up_to_the_nearest_src` below already
4437    // does exactly that; not this slice's to fix.) The sibling-reading fixtures
4438    // live in `tests/project_model.rs`, which *is* excluded.
4439
4440    /// A throwaway project, removed on drop — including on panic.
4441    struct Scratch(PathBuf);
4442    impl Drop for Scratch {
4443        fn drop(&mut self) {
4444            let _ = std::fs::remove_dir_all(&self.0);
4445        }
4446    }
4447
4448    fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
4449        let dir = std::env::temp_dir().join(format!(
4450            "bynk_lsp_sliceA_{tag}_{}_{:?}",
4451            std::process::id(),
4452            std::thread::current().id()
4453        ));
4454        let _ = std::fs::remove_dir_all(&dir);
4455        for (rel, body) in files {
4456            let p = dir.join(rel);
4457            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
4458            std::fs::write(&p, body).unwrap();
4459        }
4460        Scratch(dir)
4461    }
4462
4463    /// Build a `Backend` over a real `LspService`, rooted at `root`.
4464    ///
4465    /// `LspService::new(Backend::new)` is what `main` itself calls — the
4466    /// `Client` it hands back is the only thing `Backend` needed, and it has
4467    /// been available for this since the server was written.
4468    async fn backend_at(root: &std::path::Path) -> Backend {
4469        let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4470        let backend = service.inner().clone();
4471        // Slice D: seed one project entry, keyed by the **canonical** root so a
4472        // request's `resolve_root`-based routing lands on the same key.
4473        let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
4474        {
4475            let mut state = backend.state.write().await;
4476            state.folders.push(canonical.clone());
4477            state.projects.insert(
4478                canonical.clone(),
4479                ProjectState {
4480                    config: project::load_config(&canonical).unwrap_or_default(),
4481                    ..Default::default()
4482                },
4483            );
4484        }
4485        backend
4486    }
4487
4488    /// #1667: a panic in an analysis round reaches the client once per
4489    /// failure streak, as a `window/showMessage`, and the streak ends at the
4490    /// next clean round. Before, the `JoinError` was dropped without a trace.
4491    #[tokio::test]
4492    async fn a_panicking_round_tells_the_client_once_and_clears_on_success() {
4493        use futures::StreamExt;
4494        use tower::{Service, ServiceExt};
4495
4496        let s = scratch_project(
4497            "panic",
4498            &[
4499                ("bynk.toml", "[project]\nname = \"panic\"\n"),
4500                ("src/thing.bynk", "context thing\n"),
4501            ],
4502        );
4503        let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
4504        // The client sends nothing until the server is initialised.
4505        let initialize = tower_lsp::jsonrpc::Request::build("initialize")
4506            .params(serde_json::json!({ "capabilities": {} }))
4507            .id(1)
4508            .finish();
4509        service
4510            .ready()
4511            .await
4512            .unwrap()
4513            .call(initialize)
4514            .await
4515            .unwrap();
4516        let initialized = tower_lsp::jsonrpc::Request::build("initialized")
4517            .params(serde_json::json!({}))
4518            .finish();
4519        service
4520            .ready()
4521            .await
4522            .unwrap()
4523            .call(initialized)
4524            .await
4525            .unwrap();
4526        // Drain the client side as the server writes to it: its channel is
4527        // small, so an unread socket would block the server's next
4528        // notification (a `publishDiagnostics`, or the `showMessage` itself).
4529        let shown = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
4530        let drain = {
4531            let shown = Arc::clone(&shown);
4532            tokio::spawn(socket.for_each(move |message| {
4533                if message.method() == "window/showMessage" {
4534                    shown
4535                        .lock()
4536                        .unwrap()
4537                        .push(format!("{:?}", message.params()));
4538                }
4539                async {}
4540            }))
4541        };
4542        let backend = service.inner().clone();
4543        let canonical = s.0.canonicalize().unwrap();
4544        backend
4545            .state
4546            .write()
4547            .await
4548            .projects
4549            .insert(canonical.clone(), ProjectState::default());
4550        let failed = |b: &Backend| {
4551            let b = b.clone();
4552            let root = canonical.clone();
4553            async move { b.state.read().await.projects[&root].analysis_failed }
4554        };
4555
4556        let inject = |b: &Backend| {
4557            let b = b.clone();
4558            let root = canonical.clone();
4559            async move {
4560                b.state
4561                    .write()
4562                    .await
4563                    .projects
4564                    .get_mut(&root)
4565                    .unwrap()
4566                    .panic_next_round = true;
4567            }
4568        };
4569        inject(&backend).await;
4570        backend.run_project_diagnostics(canonical.clone()).await;
4571        assert!(failed(&backend).await, "the failed round is recorded");
4572        // A second failure in the same streak says nothing new.
4573        inject(&backend).await;
4574        backend.run_project_diagnostics(canonical.clone()).await;
4575
4576        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4577        {
4578            let shown = shown.lock().unwrap();
4579            assert_eq!(shown.len(), 1, "told once per streak: {shown:?}");
4580            assert!(shown[0].contains("internal error"), "{shown:?}");
4581        }
4582
4583        backend.run_project_diagnostics(canonical.clone()).await;
4584        assert!(!failed(&backend).await, "a clean round ends the streak");
4585        // A failure after the streak ended is a new streak, and is told.
4586        inject(&backend).await;
4587        backend.run_project_diagnostics(canonical.clone()).await;
4588        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4589        assert_eq!(shown.lock().unwrap().len(), 2, "a new streak is told again");
4590        drain.abort();
4591    }
4592
4593    /// Test helpers for the single-project behaviour tests (each builds exactly
4594    /// one project via `backend_at` or an equivalent insert). They read whatever
4595    /// the one entry's key is, so a test need not thread the canonical root.
4596    impl Backend {
4597        async fn test_root(&self) -> PathBuf {
4598            self.state
4599                .read()
4600                .await
4601                .projects
4602                .keys()
4603                .next()
4604                .cloned()
4605                .expect("a test project entry")
4606        }
4607        async fn run_round(&self) {
4608            let root = self.test_root().await;
4609            self.run_project_diagnostics(root).await;
4610        }
4611        async fn test_analysis(&self) -> Option<Arc<Analysis>> {
4612            let root = self.test_root().await;
4613            self.project_analysis(&root).await
4614        }
4615        async fn test_round_started(&self) -> u64 {
4616            let root = self.test_root().await;
4617            self.state
4618                .read()
4619                .await
4620                .projects
4621                .get(&root)
4622                .map(|p| p.analysis_round_started)
4623                .unwrap_or(0)
4624        }
4625    }
4626
4627    /// The slice, end to end through the server: a round covers **every**
4628    /// `include` tree, and each file keeps a distinct project-relative identity
4629    /// (ADR 0198). Before slice A the round was handed `<root>/src` and the
4630    /// `tests/` tree did not exist as far as the LSP was concerned.
4631    #[tokio::test]
4632    async fn a_round_covers_every_include_tree() {
4633        let s = scratch_project(
4634            "round",
4635            &[
4636                ("bynk.toml", "[project]\nname = \"round\"\n"),
4637                ("src/thing.bynk", "context thing\n"),
4638                // Same basename, second root — the ADR 0198 collision.
4639                ("tests/thing.bynk", "suite thing\n"),
4640            ],
4641        );
4642        let backend = backend_at(&s.0).await;
4643        backend.run_round().await;
4644
4645        let analysis = backend.test_analysis().await.expect("a round committed");
4646        let mut keys: Vec<String> = analysis
4647            .snapshots
4648            .keys()
4649            .map(|p| p.to_string_lossy().replace('\\', "/"))
4650            .collect();
4651        keys.sort();
4652        assert_eq!(
4653            keys,
4654            vec!["src/thing.bynk", "tests/thing.bynk"],
4655            "the round must cover both include trees, with distinct identities",
4656        );
4657    }
4658
4659    /// The identity a request resolves through. `uri_to_rel` is one
4660    /// `strip_prefix` against the project root — total across `include` trees,
4661    /// where the old `src` base could only ever name files in one of them.
4662    #[tokio::test]
4663    async fn a_uri_in_any_include_tree_resolves_to_its_analysed_file() {
4664        let s = scratch_project(
4665            "uri",
4666            &[
4667                ("bynk.toml", "[project]\nname = \"uri\"\n"),
4668                ("src/thing.bynk", "context thing\n"),
4669                ("tests/thing.bynk", "suite thing\n"),
4670            ],
4671        );
4672        let backend = backend_at(&s.0).await;
4673        backend.run_round().await;
4674        let analysis = backend.test_analysis().await.expect("round");
4675
4676        for (rel, label) in [
4677            ("src/thing.bynk", "primary"),
4678            ("tests/thing.bynk", "secondary"),
4679        ] {
4680            let abs = s.0.join(rel);
4681            let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4682            let resolved = Backend::uri_to_rel(&analysis, &uri)
4683                .unwrap_or_else(|| panic!("{label} root URI must resolve"));
4684            assert_eq!(
4685                resolved.to_string_lossy().replace('\\', "/"),
4686                rel,
4687                "a {label}-tree URI must name its own analysed file",
4688            );
4689            assert!(
4690                analysis.snapshots.contains_key(&resolved),
4691                "…and that file must be in the round",
4692            );
4693        }
4694    }
4695
4696    /// Finding #62: `project_content` must exclude the calling file's own
4697    /// path — `bynk-ide`'s completion helpers already parse it fresh from the
4698    /// live buffer, so leaving it in the map would additionally serve its
4699    /// on-disk copy (stale relative to any unsaved edit) alongside the buffer
4700    /// parse.
4701    #[tokio::test]
4702    async fn project_content_excludes_the_calling_file() {
4703        let s = scratch_project(
4704            "self_excl",
4705            &[
4706                ("bynk.toml", "[project]\nname = \"self_excl\"\n"),
4707                ("a.bynk", "context a\n"),
4708                ("b.bynk", "context b\n"),
4709            ],
4710        );
4711        let backend = backend_at(&s.0).await;
4712        let abs = s.0.join("a.bynk");
4713        let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4714        let content = backend
4715            .project_content(&uri)
4716            .await
4717            .expect("a project root resolves to a content map");
4718        assert!(
4719            !content.keys().any(|p| p.file_name().unwrap() == "a.bynk"),
4720            "the calling file's own path must not be in its own project content map: {content:?}"
4721        );
4722        assert!(
4723            content.keys().any(|p| p.file_name().unwrap() == "b.bynk"),
4724            "a sibling project file must still be present: {content:?}"
4725        );
4726        assert_eq!(
4727            content
4728                .iter()
4729                .find(|(p, _)| p.file_name().unwrap() == "b.bynk")
4730                .map(|(_, text)| text.as_str()),
4731            Some("context b\n"),
4732            "the sibling's content must be the real file content, not just its path"
4733        );
4734    }
4735
4736    /// `exclude` reaches the server, not just the compiler. `project.rs` used to
4737    /// parse it and throw it away — its own comment said the analyse walk "does
4738    /// not yet prune by `exclude`".
4739    #[tokio::test]
4740    async fn an_excluded_tree_is_not_analysed() {
4741        let s = scratch_project(
4742            "excl",
4743            &[
4744                (
4745                    "bynk.toml",
4746                    "[project]\nname = \"excl\"\n\n[paths]\ninclude = [\".\"]\nexclude = [\"generated\"]\n",
4747                ),
4748                ("a.bynk", "context a\n"),
4749                ("generated/gen.bynk", "context gen\n"),
4750            ],
4751        );
4752        let backend = backend_at(&s.0).await;
4753        backend.run_round().await;
4754        let analysis = backend.test_analysis().await.expect("round");
4755        let keys: Vec<String> = analysis
4756            .snapshots
4757            .keys()
4758            .map(|p| p.to_string_lossy().replace('\\', "/"))
4759            .collect();
4760        assert_eq!(keys, vec!["a.bynk"], "excluded trees stay out of the round");
4761    }
4762
4763    /// CI repro (#653): the VS Code extension's fixture workspace — a legacy
4764    /// `[paths] src`/`tests` manifest (keys ADR 0147 retired, so
4765    /// `read_project_paths` ignores them → `conventional()` → `["src"]`) with a
4766    /// dotted commons in `src/`. Drives the real `references` handler.
4767    #[tokio::test]
4768    async fn references_resolve_in_the_vscode_fixture_layout() {
4769        let s = scratch_project(
4770            "vsc",
4771            &[
4772                (
4773                    "bynk.toml",
4774                    "[project]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[paths]\nsrc = \"src\"\ntests = \"tests\"\n",
4775                ),
4776                (
4777                    "src/text.bynk",
4778                    "commons fixture.text\n\nfn shout(s: String) -> String {\n  s\n}\n\nfn greet(name: String) -> String {\n  \"Hi, \\(shout(name))!\"\n}\n",
4779                ),
4780            ],
4781        );
4782        // Root exactly as `initialize` does: resolve from the workspace folder.
4783        let (root, config) = Backend::resolve_root(&s.0).expect("bynk.toml is present");
4784        assert_eq!(root, s.0, "the manifest's directory is the project root");
4785
4786        let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4787        let backend = service.inner().clone();
4788        {
4789            let mut st = backend.state.write().await;
4790            // Key by the canonical root, so the entry matches `references`'
4791            // URI-based routing (`resolve_root` canonicalises).
4792            let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
4793            st.projects.insert(
4794                canonical,
4795                ProjectState {
4796                    config,
4797                    ..Default::default()
4798                },
4799            );
4800        }
4801        backend.run_round().await;
4802
4803        let analysis = backend.test_analysis().await.expect("a round committed");
4804        let keys: Vec<String> = analysis
4805            .snapshots
4806            .keys()
4807            .map(|p| p.to_string_lossy().replace('\\', "/"))
4808            .collect();
4809        assert_eq!(keys, vec!["src/text.bynk"], "the fixture file is analysed");
4810
4811        // The URI the editor sends, mapped through the round's identity.
4812        let abs = s.0.join("src/text.bynk");
4813        let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4814        let rel = Backend::uri_to_rel(&analysis, &uri).expect("URI resolves into the round");
4815        assert_eq!(rel, PathBuf::from("src/text.bynk"));
4816
4817        // `shout`'s declaration site: line 2 (0-based), at `fn shout`.
4818        let text = analysis.snapshots.get(&rel).expect("snapshot present");
4819        let decl = text.find("shout").expect("`shout` in source");
4820        let pos = crate::position::offset_to_position(text, decl);
4821
4822        let refs = backend
4823            .references(ReferenceParams {
4824                text_document_position: TextDocumentPositionParams {
4825                    text_document: TextDocumentIdentifier { uri: uri.clone() },
4826                    position: pos,
4827                },
4828                work_done_progress_params: Default::default(),
4829                partial_result_params: Default::default(),
4830                context: ReferenceContext {
4831                    include_declaration: true,
4832                },
4833            })
4834            .await
4835            .expect("references must not error");
4836        let found = refs.unwrap_or_default();
4837        assert!(
4838            !found.is_empty(),
4839            "`shout` is referenced by `greet` — references must resolve; got none",
4840        );
4841    }
4842
4843    // -- Slice B: the freshness contract, driven through a real Backend -------
4844    //
4845    // These are behaviour-over-time tests (§4.1): they edit a buffer and then
4846    // make a request, asserting the request answers against the *new* text.
4847    // The static tests above can't see this — the defect lives between the
4848    // edit and the request, which only a driven sequence exercises.
4849
4850    /// Open `src/a.bynk`, round it, then edit and drive `did_change`. `uri`,
4851    /// the round-1 relative path, and the edited version are returned.
4852    async fn open_round_edit(
4853        backend: &Backend,
4854        root: &std::path::Path,
4855        v1_text: &str,
4856        v2_text: &str,
4857    ) -> (Url, PathBuf) {
4858        let abs = root.join("src/a.bynk");
4859        let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4860
4861        backend
4862            .did_open(DidOpenTextDocumentParams {
4863                text_document: TextDocumentItem {
4864                    uri: uri.clone(),
4865                    language_id: "bynk".into(),
4866                    version: 1,
4867                    text: v1_text.to_string(),
4868                },
4869            })
4870            .await;
4871        backend.run_round().await;
4872
4873        // The round exists and is version 1.
4874        let a1 = backend.test_analysis().await.expect("round 1");
4875        let rel = Backend::uri_to_rel(&a1, &uri).expect("uri resolves");
4876        assert_eq!(a1.versions.get(&rel), Some(&1), "round 1 is version 1");
4877
4878        // Edit: the buffer becomes `v2_text` at version 2. The debounce this
4879        // schedules is superseded by the request-driven refresh below.
4880        backend
4881            .did_change(DidChangeTextDocumentParams {
4882                text_document: VersionedTextDocumentIdentifier {
4883                    uri: uri.clone(),
4884                    version: 2,
4885                },
4886                content_changes: vec![TextDocumentContentChangeEvent {
4887                    range: None,
4888                    range_length: None,
4889                    text: v2_text.to_string(),
4890                }],
4891            })
4892            .await;
4893        (uri, rel)
4894    }
4895
4896    /// The headline. After an edit that inserts a line above a symbol, a
4897    /// position request at the symbol's *new* location resolves to the symbol —
4898    /// because the gate refreshes to the edited buffer first. Under the old
4899    /// behaviour the new position was resolved against the round-1 snapshot,
4900    /// landing on the wrong text.
4901    #[tokio::test]
4902    async fn a_position_after_an_edit_resolves_against_the_new_text() {
4903        let v1 = "commons q.a\n\nfn target(x: Int) -> Int {\n  x\n}\n";
4904        // Prepend a blank line: `target` moves from line 2 to line 3.
4905        let v2 = format!("\n{v1}");
4906        let s = scratch_project(
4907            "fresh_hd",
4908            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4909        );
4910        let backend = backend_at(&s.0).await;
4911        let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4912
4913        // The gate refreshes to the edited version and text.
4914        let a = backend.analysis_for(&uri).await.expect("current analysis");
4915        assert_eq!(a.versions.get(&rel), Some(&2), "gate refreshed to the edit");
4916        assert_eq!(a.snapshots.get(&rel).map(String::as_str), Some(v2.as_str()));
4917
4918        // `target`'s new position resolves to `target` in the refreshed snapshot.
4919        let off_v2 = v2.find("target").unwrap();
4920        let new_pos = crate::position::offset_to_position(&v2, off_v2);
4921        let (a2, rel2, off) = backend
4922            .index_position(&uri, new_pos)
4923            .await
4924            .expect("position resolves");
4925        assert!(
4926            a2.snapshots.get(&rel2).unwrap()[off..].starts_with("target"),
4927            "the new position must land on `target` in the current snapshot — \
4928             the whole point of refreshing",
4929        );
4930    }
4931
4932    /// The gate never returns a round whose snapshot for the file predates the
4933    /// buffer. A cached round at version 1 with the buffer at version 2 must be
4934    /// refreshed, not served.
4935    #[tokio::test]
4936    async fn a_stale_round_is_never_served() {
4937        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
4938        let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n  y\n}}\n");
4939        let s = scratch_project(
4940            "fresh_stale",
4941            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4942        );
4943        let backend = backend_at(&s.0).await;
4944        let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4945
4946        // Precondition: the *cached* round is still version 1 (no refresh yet).
4947        let cached = backend.test_analysis().await.unwrap();
4948        assert_eq!(cached.versions.get(&rel), Some(&1), "cached round is stale");
4949
4950        // The gate must not hand back that stale round.
4951        let a = backend.analysis_for(&uri).await.unwrap();
4952        assert_eq!(
4953            a.versions.get(&rel),
4954            Some(&2),
4955            "analysis_for must refresh past a stale cached round, never serve it",
4956        );
4957    }
4958
4959    /// #733: `committed_analysis` is the non-refreshing counterpart of
4960    /// `analysis_for`. Where the strict gate refreshes past a stale round (the
4961    /// test above), this one **serves the committed round as-is** — even with the
4962    /// buffer a version ahead — and never triggers a refresh. That is what lets a
4963    /// decoration request answer from the committed round while the user types,
4964    /// instead of forcing a whole-project round on every keystroke.
4965    #[tokio::test]
4966    async fn committed_analysis_serves_the_stale_round_without_refreshing() {
4967        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
4968        let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n  y\n}}\n");
4969        let s = scratch_project(
4970            "fresh_committed",
4971            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4972        );
4973        let backend = backend_at(&s.0).await;
4974        let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4975
4976        // Precondition: the cached round is still version 1 (buffer is at 2).
4977        assert_eq!(
4978            backend.test_analysis().await.unwrap().versions.get(&rel),
4979            Some(&1),
4980            "cached round is stale",
4981        );
4982
4983        // The non-refreshing gate hands back that stale round unchanged...
4984        let a = backend
4985            .committed_analysis(&uri)
4986            .await
4987            .expect("committed round");
4988        assert_eq!(
4989            a.versions.get(&rel),
4990            Some(&1),
4991            "committed_analysis serves the committed round, stale and all",
4992        );
4993        // ...and left the cached round untouched (no refresh was triggered).
4994        assert_eq!(
4995            backend.test_analysis().await.unwrap().versions.get(&rel),
4996            Some(&1),
4997            "committed_analysis must not trigger a refresh",
4998        );
4999    }
5000
5001    /// DECISION B: concurrent requests after one edit coalesce onto a single
5002    /// round, not one each. The refresh lock serialises them; the second finds
5003    /// the first's round already current.
5004    #[tokio::test]
5005    async fn concurrent_requests_after_one_edit_share_one_round() {
5006        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
5007        let v2 = format!("\n{v1}");
5008        let s = scratch_project(
5009            "fresh_coal",
5010            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
5011        );
5012        let backend = backend_at(&s.0).await;
5013        let (uri, _rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
5014
5015        let started_before = backend.test_round_started().await;
5016
5017        // Fire several gate calls concurrently.
5018        let calls = (0..5).map(|_| {
5019            let b = backend.clone();
5020            let u = uri.clone();
5021            tokio::spawn(async move { b.analysis_for(&u).await.is_some() })
5022        });
5023        for c in calls {
5024            assert!(
5025                c.await.unwrap(),
5026                "each concurrent request must get an analysis"
5027            );
5028        }
5029
5030        let started_after = backend.test_round_started().await;
5031        assert_eq!(
5032            started_after - started_before,
5033            1,
5034            "five concurrent requests after one edit must share ONE round, not run five",
5035        );
5036    }
5037
5038    /// DECISION D: a file outside every `include` root cannot be answered at the
5039    /// client's version — the gate declines rather than serving something.
5040    #[tokio::test]
5041    async fn a_file_outside_the_project_is_declined() {
5042        let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
5043        let s = scratch_project(
5044            "fresh_out",
5045            &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
5046        );
5047        let backend = backend_at(&s.0).await;
5048        // Round the project so a cached analysis exists.
5049        backend.run_round().await;
5050
5051        // A URI for a file the project does not contain, opened as a buffer.
5052        let outside = s.0.join("elsewhere.bynk");
5053        std::fs::write(&outside, v1).unwrap();
5054        let uri = Url::from_file_path(outside.canonicalize().unwrap()).unwrap();
5055        backend
5056            .did_open(DidOpenTextDocumentParams {
5057                text_document: TextDocumentItem {
5058                    uri: uri.clone(),
5059                    language_id: "bynk".into(),
5060                    version: 1,
5061                    text: v1.to_string(),
5062                },
5063            })
5064            .await;
5065
5066        assert!(
5067            backend.analysis_for(&uri).await.is_none(),
5068            "a file outside the include roots is never a snapshot key — decline, \
5069             don't serve a round that doesn't cover it",
5070        );
5071    }
5072
5073    /// Review of #666: rename emits versioned edits across every file that
5074    /// references the symbol, so it must refresh **all** open buffers, not just
5075    /// the cursor's. Edit a non-cursor file that references the symbol, then
5076    /// rename from the (unedited) definition file: the edit for the dirty file
5077    /// must carry its *current* version, or the client rejects the whole rename.
5078    /// Under the per-URI gate the cursor's file was current, so no refresh ran
5079    /// and the dirty file kept its stale version.
5080    #[tokio::test]
5081    async fn a_multi_file_rename_stamps_a_dirty_non_cursor_file_at_its_current_version() {
5082        let util = "commons demo.util\n\ntype Money = Int where Positive\n";
5083        let thing = "commons demo.thing\n\nuses demo.util\n\nfn f(m: Money) -> Money {\n  m\n}\n";
5084        let s = scratch_project(
5085            "rename_multi",
5086            &[
5087                ("bynk.toml", "[project]\nname=\"demo\"\n"),
5088                ("src/demo/util.bynk", util),
5089                ("src/demo/thing.bynk", thing),
5090            ],
5091        );
5092        let backend = backend_at(&s.0).await;
5093        let uri = |rel: &str| {
5094            let abs = s.0.join(rel);
5095            Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
5096        };
5097        let util_uri = uri("src/demo/util.bynk");
5098        let thing_uri = uri("src/demo/thing.bynk");
5099
5100        for (u, text) in [(&util_uri, util), (&thing_uri, thing)] {
5101            backend
5102                .did_open(DidOpenTextDocumentParams {
5103                    text_document: TextDocumentItem {
5104                        uri: u.clone(),
5105                        language_id: "bynk".into(),
5106                        version: 1,
5107                        text: text.to_string(),
5108                    },
5109                })
5110                .await;
5111        }
5112        backend.run_round().await;
5113
5114        // Edit the NON-cursor file (`thing`) to version 2 — a blank line above,
5115        // so `Money`'s references shift but still resolve.
5116        let thing_v2 = format!("\n{thing}");
5117        backend
5118            .did_change(DidChangeTextDocumentParams {
5119                text_document: VersionedTextDocumentIdentifier {
5120                    uri: thing_uri.clone(),
5121                    version: 2,
5122                },
5123                content_changes: vec![TextDocumentContentChangeEvent {
5124                    range: None,
5125                    range_length: None,
5126                    text: thing_v2.clone(),
5127                }],
5128            })
5129            .await;
5130
5131        // Rename `Money` from its definition in `util` (untouched, still v1).
5132        let money_off = util.find("Money").unwrap();
5133        let pos = crate::position::offset_to_position(util, money_off);
5134        let edit = backend
5135            .rename(RenameParams {
5136                text_document_position: TextDocumentPositionParams {
5137                    text_document: TextDocumentIdentifier {
5138                        uri: util_uri.clone(),
5139                    },
5140                    position: pos,
5141                },
5142                new_name: "Amount".into(),
5143                work_done_progress_params: Default::default(),
5144            })
5145            .await
5146            .expect("rename must not error")
5147            .expect("rename must produce edits");
5148
5149        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5150            panic!("expected document-change edits");
5151        };
5152        let thing_edit = edits
5153            .iter()
5154            .find(|e| e.text_document.uri == thing_uri)
5155            .expect("the rename must edit `thing`, which references the symbol");
5156        assert_eq!(
5157            thing_edit.text_document.version,
5158            Some(2),
5159            "the dirty non-cursor file's edit must carry its current version (2), \
5160             not the stale round's (1) — else the client rejects the whole rename",
5161        );
5162    }
5163
5164    /// #302: renaming a unit's file rewrites its own declaration header
5165    /// **and** every other file's `uses`/`consumes` reference — over a split
5166    /// `src`/`tests` project, exercising the project-relative (`src/`-prefixed)
5167    /// path the `src`/`tests` split leaves on every identity path.
5168    #[tokio::test]
5169    async fn will_rename_files_updates_the_declaration_and_every_reference() {
5170        let charge = "commons billing.charge\n\ntype ChargeId = Int where Positive\n";
5171        let main = "context app.main\n\nuses billing.charge\n";
5172        let s = scratch_project(
5173            "will_rename_basic",
5174            &[
5175                ("bynk.toml", "[project]\nname=\"demo\"\n"),
5176                ("src/billing/charge.bynk", charge),
5177                ("src/app/main.bynk", main),
5178            ],
5179        );
5180        let backend = backend_at(&s.0).await;
5181        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5182        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5183        let old_uri = uri("src/billing/charge.bynk");
5184        let new_uri = uri("src/billing/pay.bynk");
5185        let main_uri = uri("src/app/main.bynk");
5186
5187        backend.run_round().await;
5188
5189        let edit = backend
5190            .will_rename_files(RenameFilesParams {
5191                files: vec![FileRename {
5192                    old_uri: old_uri.to_string(),
5193                    new_uri: new_uri.to_string(),
5194                }],
5195            })
5196            .await
5197            .expect("will_rename_files must not error")
5198            .expect("must produce edits");
5199
5200        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5201            panic!("expected document-change edits");
5202        };
5203
5204        let own = edits
5205            .iter()
5206            .find(|e| e.text_document.uri == old_uri)
5207            .expect("the moved file's own declaration must be rewritten");
5208        assert_eq!(own.edits.len(), 1);
5209        let OneOf::Left(own_edit) = &own.edits[0] else {
5210            panic!("expected a plain TextEdit");
5211        };
5212        assert_eq!(own_edit.new_text, "billing.pay");
5213
5214        let referencer = edits
5215            .iter()
5216            .find(|e| e.text_document.uri == main_uri)
5217            .expect("the referencing file must be rewritten");
5218        assert_eq!(referencer.edits.len(), 1);
5219        let OneOf::Left(ref_edit) = &referencer.edits[0] else {
5220            panic!("expected a plain TextEdit");
5221        };
5222        assert_eq!(ref_edit.new_text, "billing.pay");
5223    }
5224
5225    /// #302: renaming one member file within a multi-file unit's directory
5226    /// doesn't change the unit's qualified name (it's the directory, not the
5227    /// filename) — no edits are needed.
5228    #[tokio::test]
5229    async fn will_rename_files_is_a_noop_for_a_multi_file_unit_member() {
5230        let s = scratch_project(
5231            "will_rename_multi_file_noop",
5232            &[
5233                ("bynk.toml", "[project]\nname=\"demo\"\n"),
5234                (
5235                    "src/billing/charge/one.bynk",
5236                    "context billing.charge\n\ntype ChargeId = Int where Positive\n",
5237                ),
5238                (
5239                    "src/billing/charge/two.bynk",
5240                    "context billing.charge\n\ntype PaymentId = Int where Positive\n",
5241                ),
5242            ],
5243        );
5244        let backend = backend_at(&s.0).await;
5245        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5246        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5247
5248        backend.run_round().await;
5249
5250        let edit = backend
5251            .will_rename_files(RenameFilesParams {
5252                files: vec![FileRename {
5253                    old_uri: uri("src/billing/charge/one.bynk").to_string(),
5254                    new_uri: uri("src/billing/charge/renamed.bynk").to_string(),
5255                }],
5256            })
5257            .await
5258            .expect("will_rename_files must not error");
5259        assert!(
5260            edit.is_none(),
5261            "renaming a member file within the same directory must not edit anything"
5262        );
5263    }
5264
5265    /// #302: a `suite` file has no addressable name of its own
5266    /// (`SourceUnit::name()` is its *target*'s name) — renaming it produces
5267    /// no edits.
5268    #[tokio::test]
5269    async fn will_rename_files_is_a_noop_for_a_suite() {
5270        let s = scratch_project(
5271            "will_rename_suite_noop",
5272            &[
5273                ("bynk.toml", "[project]\nname=\"demo\"\n"),
5274                (
5275                    "src/billing/charge.bynk",
5276                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5277                ),
5278                ("tests/billing_charge.bynk", "suite billing.charge\n"),
5279            ],
5280        );
5281        let backend = backend_at(&s.0).await;
5282        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5283        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5284
5285        backend.run_round().await;
5286
5287        let edit = backend
5288            .will_rename_files(RenameFilesParams {
5289                files: vec![FileRename {
5290                    old_uri: uri("tests/billing_charge.bynk").to_string(),
5291                    new_uri: uri("tests/billing_charge_renamed.bynk").to_string(),
5292                }],
5293            })
5294            .await
5295            .expect("will_rename_files must not error");
5296        assert!(edit.is_none(), "a suite rename must produce no edits");
5297    }
5298
5299    /// #302 review: a `suite`'s own `target` is a *reference* too
5300    /// (`unit_reference_spans`' suite branch) — renaming the unit a suite
5301    /// tests must rewrite the suite's `suite <target>` header, not just
5302    /// `uses`/`consumes` clauses in ordinary units.
5303    #[tokio::test]
5304    async fn will_rename_files_updates_a_suite_s_target_reference() {
5305        let s = scratch_project(
5306            "will_rename_suite_target",
5307            &[
5308                ("bynk.toml", "[project]\nname=\"demo\"\n"),
5309                (
5310                    "src/billing/charge.bynk",
5311                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5312                ),
5313                ("tests/billing_charge.bynk", "suite billing.charge\n"),
5314            ],
5315        );
5316        let backend = backend_at(&s.0).await;
5317        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5318        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5319        let suite_uri = uri("tests/billing_charge.bynk");
5320
5321        backend.run_round().await;
5322
5323        let edit = backend
5324            .will_rename_files(RenameFilesParams {
5325                files: vec![FileRename {
5326                    old_uri: uri("src/billing/charge.bynk").to_string(),
5327                    new_uri: uri("src/billing/pay.bynk").to_string(),
5328                }],
5329            })
5330            .await
5331            .expect("will_rename_files must not error")
5332            .expect("must produce edits");
5333
5334        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5335            panic!("expected document-change edits");
5336        };
5337        let suite_edit = edits
5338            .iter()
5339            .find(|e| e.text_document.uri == suite_uri)
5340            .expect("the suite's own `suite <target>` header must be rewritten");
5341        assert_eq!(suite_edit.edits.len(), 1);
5342        let OneOf::Left(e) = &suite_edit.edits[0] else {
5343            panic!("expected a plain TextEdit");
5344        };
5345        assert_eq!(e.new_text, "billing.pay");
5346    }
5347
5348    /// #302 review: renaming into a path that implies a name some other file
5349    /// already declares must not hand back an edit that would create a
5350    /// duplicate-name project — a lightweight `unit_sources` check, not
5351    /// `rename`'s full re-analysis.
5352    #[tokio::test]
5353    async fn will_rename_files_refuses_a_rename_that_collides_with_an_existing_unit() {
5354        let s = scratch_project(
5355            "will_rename_collision",
5356            &[
5357                ("bynk.toml", "[project]\nname=\"demo\"\n"),
5358                (
5359                    "src/billing/charge.bynk",
5360                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5361                ),
5362                (
5363                    "src/billing/pay.bynk",
5364                    "commons billing.pay\n\ntype PaymentId = Int where Positive\n",
5365                ),
5366            ],
5367        );
5368        let backend = backend_at(&s.0).await;
5369        let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5370        let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5371
5372        backend.run_round().await;
5373
5374        // Renaming `charge.bynk` to `pay.bynk` would imply `billing.pay` —
5375        // already declared by the sibling file.
5376        let edit = backend
5377            .will_rename_files(RenameFilesParams {
5378                files: vec![FileRename {
5379                    old_uri: uri("src/billing/charge.bynk").to_string(),
5380                    new_uri: uri("src/billing/pay.bynk").to_string(),
5381                }],
5382            })
5383            .await
5384            .expect("will_rename_files must not error");
5385        assert!(
5386            edit.is_none(),
5387            "a rename that collides with an existing unit name must produce no edits"
5388        );
5389    }
5390
5391    /// #302 review: `willRenameFiles`' `new_uri` names a file that doesn't
5392    /// exist yet, so `uri_to_rel`'s `canonicalize` fails and previously fell
5393    /// back to the client's raw, non-canonical path — which mismatches
5394    /// `project_root` (always canonical) whenever the workspace root sits
5395    /// behind a symlink, and the handler silently produced no edit.
5396    /// `uri_to_rel_for_new_path` canonicalizes the parent directory (which
5397    /// does exist) instead, so this must still produce edits.
5398    #[cfg(unix)]
5399    #[tokio::test]
5400    async fn will_rename_files_tolerates_a_symlinked_project_root() {
5401        let real = scratch_project(
5402            "will_rename_symlink_real",
5403            &[
5404                ("bynk.toml", "[project]\nname=\"demo\"\n"),
5405                (
5406                    "src/billing/charge.bynk",
5407                    "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5408                ),
5409            ],
5410        );
5411        let alias = std::env::temp_dir().join(format!(
5412            "bynk_lsp_sliceA_will_rename_symlink_alias_{}_{:?}",
5413            std::process::id(),
5414            std::thread::current().id()
5415        ));
5416        let _ = std::fs::remove_file(&alias);
5417        std::os::unix::fs::symlink(&real.0, &alias).expect("symlink the scratch root");
5418
5419        let backend = backend_at(&alias).await;
5420        // Built through the symlink, deliberately uncanonicalized — the path
5421        // shape a client actually sends (it opened the workspace at `alias`,
5422        // not at whatever `alias` resolves to).
5423        let uri = |rel: &str| Url::from_file_path(alias.join(rel)).unwrap();
5424        let old_uri = uri("src/billing/charge.bynk");
5425        let new_uri = uri("src/billing/pay.bynk"); // does not exist on disk
5426
5427        backend.run_round().await;
5428
5429        let edit = backend
5430            .will_rename_files(RenameFilesParams {
5431                files: vec![FileRename {
5432                    old_uri: old_uri.to_string(),
5433                    new_uri: new_uri.to_string(),
5434                }],
5435            })
5436            .await
5437            .expect("will_rename_files must not error")
5438            .expect("must produce edits despite the symlinked root");
5439
5440        let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5441            panic!("expected document-change edits");
5442        };
5443        assert_eq!(
5444            edits.len(),
5445            1,
5446            "only the moved file's own header changes here"
5447        );
5448        let OneOf::Left(e) = &edits[0].edits[0] else {
5449            panic!("expected a plain TextEdit");
5450        };
5451        assert_eq!(e.new_text, "billing.pay");
5452
5453        let _ = std::fs::remove_file(&alias);
5454    }
5455
5456    /// #485: a rootless multi-file-commons file (a `src/` tree with no
5457    /// `bynk.toml`, the layout the compiler fixtures use) resolves its
5458    /// implicit source root — the nearest ancestor `src/` — so project-mode
5459    /// analysis kicks in instead of sibling-blind single-file `diagnose`.
5460    #[test]
5461    fn find_source_root_walks_up_to_the_nearest_src() {
5462        let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5463            .parent()
5464            .expect("workspace root");
5465        let make = ws.join(
5466            "bynkc/tests/fixtures/positive/\
5467             252_multi_file_commons_dotted_test/src/shipping/rates/make.bynk",
5468        );
5469        assert!(make.is_file(), "fixture present: {}", make.display());
5470
5471        let src = Backend::find_source_root(&make).expect("an ancestor src/");
5472        assert!(
5473            src.ends_with("252_multi_file_commons_dotted_test/src"),
5474            "nearest ancestor src, got {}",
5475            src.display()
5476        );
5477
5478        // No `bynk.toml` on the path, so resolution falls back to the implicit
5479        // src tree, and the project root is `src`'s parent.
5480        //
5481        // Slice A: the old invariant here was `root.join(config.src_dir) == src`
5482        // — the analysis root re-derived by reducing the manifest to one
5483        // directory. That reduction is gone: the round is rooted at the project
5484        // and `bynk_ide::AnalysisRoots::Project` resolves the trees from the
5485        // manifest (here, absent → `ProjectPaths::conventional`, which picks up
5486        // exactly this `src/`). So what must hold is that the root is `src`'s
5487        // parent, and that conventional discovery finds this file from it.
5488        let (root, _config) = Backend::resolve_root(&make).expect("implicit project");
5489        assert_eq!(root, src.parent().expect("src has a parent"));
5490
5491        // No `bynk.toml` in this fixture (the test is exactly about the
5492        // absent-manifest → conventional-layout path), so an empty overlay
5493        // is correct here, not a stand-in for a real manifest read.
5494        let found = bynk_ide::discover_files(
5495            &bynk_ide::AnalysisRoots::Project(root.clone()),
5496            &std::collections::HashMap::new(),
5497        );
5498        let make_canon = make.canonicalize().unwrap_or(make.clone());
5499        assert!(
5500            found
5501                .iter()
5502                .any(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == make_canon),
5503            "the compiler's own discovery must reach {} from the project root {}; got {found:?}",
5504            make.display(),
5505            root.display(),
5506        );
5507    }
5508
5509    /// A file with no `bynk.toml` and no ancestor `src/` stays in single-file
5510    /// mode — resolution returns `None`, so the caller keeps the per-buffer
5511    /// `diagnose` path.
5512    #[test]
5513    fn resolve_root_is_none_without_toml_or_src() {
5514        // The crate manifest sits under `bynk-lsp/`, not inside any `src/`.
5515        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
5516        assert!(p.is_file());
5517        assert!(Backend::find_source_root(&p).is_none());
5518        assert!(Backend::resolve_root(&p).is_none());
5519    }
5520
5521    // v0.124 (slice 3): the `<expr> is <cursor>` scrutinee-offset detection that
5522    // feeds `is`-pattern completion.
5523    #[test]
5524    fn is_scrutinee_offset_locates_the_scrutinee() {
5525        let text = "  order.status is Pen";
5526        let off = is_scrutinee_offset(text, text.len()).expect("at an is-position");
5527        // Lands on the last char of `order.status` (the `s` of `status`).
5528        assert_eq!(&text[off..off + 1], "s");
5529        assert!(off < text.find(" is ").unwrap());
5530        // No trailing partial, cursor right after `is `.
5531        let text2 = "  x is ";
5532        let off2 = is_scrutinee_offset(text2, text2.len()).expect("at an is-position");
5533        assert_eq!(&text2[off2..off2 + 1], "x");
5534        // `basis` is not a standalone `is`.
5535        assert!(is_scrutinee_offset("  basis ", "  basis ".len()).is_none());
5536        // Not an is-position at all.
5537        assert!(is_scrutinee_offset("  let x = ", "  let x = ".len()).is_none());
5538    }
5539
5540    // v0.128: the `match <expr> { <arm-start>` scrutinee-offset detection that
5541    // feeds match-arm variant completion.
5542    #[test]
5543    fn match_scrutinee_offset_locates_the_scrutinee() {
5544        // First arm, cursor right after the opening brace.
5545        let t = "match order.status {\n  ";
5546        let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5547        assert_eq!(&t[off..off + 1], "s"); // last char of `order.status`
5548        assert!(off < t.find(" {").unwrap());
5549
5550        // First arm with a partial pattern typed.
5551        let t = "match color { Re";
5552        let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5553        assert_eq!(&t[off..off + 1], "r"); // last char of `color`
5554
5555        // A later arm after a top-level comma, mid-partial.
5556        let t = "match c {\n  Red => 1,\n  Gr";
5557        let off = match_scrutinee_offset(t, t.len()).expect("at a later arm-start");
5558        assert_eq!(&t[off..off + 1], "c");
5559
5560        // A top-level comma inside a preceding arm body does not confuse the
5561        // header (the nested call's comma is at depth > 0).
5562        let t = "match c {\n  Red => f(a, b),\n  ";
5563        assert!(match_scrutinee_offset(t, t.len()).is_some());
5564
5565        // Inside an arm *body* (after `=>`) — not a pattern position.
5566        assert!(
5567            match_scrutinee_offset("match c {\n  Red => ", "match c {\n  Red => ".len()).is_none()
5568        );
5569
5570        // A non-`match` block offers nothing.
5571        assert!(match_scrutinee_offset("fn f() {\n  ", "fn f() {\n  ".len()).is_none());
5572
5573        // A nested constructor position (`Ok(<cursor>`) is not an arm-start.
5574        assert!(match_scrutinee_offset("match c {\n  Ok(", "match c {\n  Ok(".len()).is_none());
5575
5576        // No open brace / no scrutinee → nothing.
5577        assert!(match_scrutinee_offset("match c ", "match c ".len()).is_none());
5578        assert!(match_scrutinee_offset("match {\n  ", "match {\n  ".len()).is_none());
5579    }
5580
5581    // v0.145 (ADR 0169): the `match <expr> { … Variant(<partial>` nested-pattern
5582    // detection that feeds payload-variant completion — the position
5583    // `match_scrutinee_offset` deliberately bails on.
5584    #[test]
5585    fn nested_pattern_offset_locates_the_scrutinee_and_variant() {
5586        // Cursor right inside a variant's payload parens.
5587        let t = "match res {\n  Some(";
5588        let (off, variant) = nested_pattern_offset(t, t.len()).expect("inside a nested pattern");
5589        assert_eq!(&t[off..off + 1], "s"); // last char of `res`
5590        assert_eq!(variant, "Some");
5591
5592        // With a partial nested pattern typed, and a qualifier.
5593        let t = "match res {\n  Ok(Po";
5594        let (off, variant) = nested_pattern_offset(t, t.len()).expect("mid partial");
5595        assert_eq!(&t[off..off + 1], "s");
5596        assert_eq!(variant, "Ok");
5597
5598        // A later arm after a top-level comma.
5599        let t = "match r {\n  Ok(n) => n,\n  Err(";
5600        let (off, variant) = nested_pattern_offset(t, t.len()).expect("later arm");
5601        assert_eq!(&t[off..off + 1], "r");
5602        assert_eq!(variant, "Err");
5603
5604        // A lowercase-led token before `(` is a binding/call, not a variant
5605        // constructor — no nested completion (there is no inner type to open).
5606        assert!(nested_pattern_offset("match r {\n  ok(", "match r {\n  ok(".len()).is_none());
5607
5608        // An arm-start (no open paren) is the flat position, not a nested one.
5609        assert!(nested_pattern_offset("match c {\n  ", "match c {\n  ".len()).is_none());
5610        assert!(nested_pattern_offset("match c {\n  Ok", "match c {\n  Ok".len()).is_none());
5611
5612        // Inside an arm body (after `=>`) is not a pattern position.
5613        let t = "match c {\n  Ok(n) => g(";
5614        assert!(nested_pattern_offset(t, t.len()).is_none());
5615
5616        // A non-`match` block offers nothing.
5617        assert!(nested_pattern_offset("fn f() {\n  h(", "fn f() {\n  h(".len()).is_none());
5618    }
5619
5620    /// A watched-file change on `bynk.toml` is recognised (so the config can be
5621    /// reloaded live), while a sibling `.bynk` file or a merely `…bynk.toml`-
5622    /// suffixed name is not — the name-component match, not a path suffix.
5623    #[test]
5624    fn is_bynk_toml_matches_only_the_manifest() {
5625        // Build URIs from a host-absolute base so `from_file_path` succeeds on
5626        // Windows too (a Unix-style `/proj` path is not absolute there — no
5627        // drive letter — and would fail to convert). Mirrors the sibling
5628        // `find_source_root` test's `CARGO_MANIFEST_DIR` base.
5629        let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
5630        let toml = Url::from_file_path(base.join("bynk.toml")).expect("abs path");
5631        assert!(is_bynk_toml(&toml));
5632        let nested = Url::from_file_path(base.join("sub").join("bynk.toml")).expect("abs path");
5633        assert!(is_bynk_toml(&nested));
5634
5635        // A source file is not the manifest.
5636        let src = Url::from_file_path(base.join("src").join("main.bynk")).expect("abs path");
5637        assert!(!is_bynk_toml(&src));
5638        // A file whose name merely *ends with* `bynk.toml` must not fire.
5639        let decoy = Url::from_file_path(base.join("notbynk.toml")).expect("abs path");
5640        assert!(!is_bynk_toml(&decoy));
5641        // A non-file URI never matches.
5642        let remote = Url::parse("https://example.com/bynk.toml").expect("url");
5643        assert!(!is_bynk_toml(&remote));
5644    }
5645
5646    /// The v0.26 capability advertisements — the "trivial unit check" the
5647    /// proposal scopes in place of a transport round-trip.
5648    #[test]
5649    fn advertises_code_actions_and_the_index_riders() {
5650        let caps = server_capabilities();
5651        let Some(CodeActionProviderCapability::Options(opts)) = caps.code_action_provider else {
5652            panic!("codeActionProvider not advertised with options");
5653        };
5654        assert_eq!(
5655            opts.code_action_kinds,
5656            Some(vec![
5657                CodeActionKind::QUICKFIX,
5658                CodeActionKind::REFACTOR,
5659                CodeActionKind::REFACTOR_EXTRACT,
5660            ])
5661        );
5662        assert!(matches!(
5663            caps.workspace_symbol_provider,
5664            Some(OneOf::Left(true))
5665        ));
5666        assert!(matches!(
5667            caps.document_highlight_provider,
5668            Some(OneOf::Left(true))
5669        ));
5670    }
5671
5672    /// The v0.27 capability advertisement — the "trivial unit check" the
5673    /// proposal scopes in place of a transport round-trip.
5674    #[test]
5675    fn advertises_save_notifications() {
5676        // `diagnostics_mode = "on_save"` is driven by `didSave`; the sync
5677        // options must opt in explicitly or clients may not send it (#513).
5678        let caps = server_capabilities();
5679        let Some(TextDocumentSyncCapability::Options(opts)) = caps.text_document_sync else {
5680            panic!("textDocumentSync not advertised with options");
5681        };
5682        assert_eq!(opts.change, Some(TextDocumentSyncKind::FULL));
5683        assert!(matches!(
5684            opts.save,
5685            Some(TextDocumentSyncSaveOptions::Supported(true))
5686        ));
5687    }
5688
5689    #[test]
5690    fn advertises_inlay_hints() {
5691        let caps = server_capabilities();
5692        assert!(matches!(caps.inlay_hint_provider, Some(OneOf::Left(true))));
5693    }
5694
5695    /// Slice 6: go-to-type-definition (value → its type's declaration).
5696    #[test]
5697    fn advertises_type_definition() {
5698        let caps = server_capabilities();
5699        assert!(matches!(
5700            caps.type_definition_provider,
5701            Some(TypeDefinitionProviderCapability::Simple(true))
5702        ));
5703    }
5704
5705    /// Slice 6b: `uses`/`consumes` document links.
5706    #[test]
5707    fn advertises_document_links() {
5708        let caps = server_capabilities();
5709        assert!(caps.document_link_provider.is_some());
5710    }
5711
5712    /// #302: `willRenameFiles` over `.bynk` files, not folders.
5713    #[test]
5714    fn advertises_will_rename_files() {
5715        let caps = server_capabilities();
5716        let file_ops = caps
5717            .workspace
5718            .as_ref()
5719            .and_then(|w| w.file_operations.as_ref())
5720            .expect("workspace.fileOperations advertised");
5721        let will_rename = file_ops
5722            .will_rename
5723            .as_ref()
5724            .expect("willRename registered");
5725        let filter = &will_rename.filters[0];
5726        assert_eq!(filter.pattern.glob, "**/*.bynk");
5727        assert_eq!(filter.pattern.matches, Some(FileOperationPatternKind::File));
5728    }
5729
5730    /// Slice 5: completion advertises `.` triggers and lazy doc resolution.
5731    #[test]
5732    fn advertises_completion_with_dot_trigger_and_resolve() {
5733        let caps = server_capabilities();
5734        let opts = caps.completion_provider.expect("completion advertised");
5735        assert_eq!(opts.resolve_provider, Some(true), "resolve_provider");
5736        assert!(
5737            opts.trigger_characters
5738                .as_deref()
5739                .is_some_and(|t| t.iter().any(|c| c == ".")),
5740            "`.` trigger char"
5741        );
5742    }
5743
5744    /// The v0.28 capability advertisement: full + range with the frozen
5745    /// legend (the legend's content is pinned in `index_queries`).
5746    #[test]
5747    fn advertises_semantic_tokens() {
5748        let caps = server_capabilities();
5749        let Some(SemanticTokensServerCapabilities::SemanticTokensOptions(opts)) =
5750            caps.semantic_tokens_provider
5751        else {
5752            panic!("semanticTokensProvider not advertised with options");
5753        };
5754        assert_eq!(opts.full, Some(SemanticTokensFullOptions::Bool(true)));
5755        assert_eq!(opts.range, Some(true));
5756        assert_eq!(opts.legend, crate::index_queries::semantic_tokens_legend());
5757    }
5758
5759    // ---- Slice D: per-workspace state (Q4) ----
5760
5761    /// A backend with **no** seeded project — the real lazy-discovery flow,
5762    /// where `did_open` and requests create entries by routing (`resolve_root`).
5763    async fn bare_backend() -> Backend {
5764        let (service, _socket) = tower_lsp::LspService::new(Backend::new);
5765        service.inner().clone()
5766    }
5767
5768    fn file_uri(root: &std::path::Path, rel: &str) -> Url {
5769        let abs = root.join(rel);
5770        Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
5771    }
5772
5773    async fn set_folders(backend: &Backend, roots: &[&std::path::Path]) {
5774        backend.state.write().await.folders = roots
5775            .iter()
5776            .map(|r| r.canonicalize().unwrap_or_else(|_| r.to_path_buf()))
5777            .collect();
5778    }
5779
5780    async fn open(backend: &Backend, uri: &Url, text: &str) {
5781        backend
5782            .did_open(DidOpenTextDocumentParams {
5783                text_document: TextDocumentItem {
5784                    uri: uri.clone(),
5785                    language_id: "bynk".into(),
5786                    version: 1,
5787                    text: text.to_string(),
5788                },
5789            })
5790            .await;
5791    }
5792
5793    fn snapshot_keys(a: &Analysis) -> Vec<String> {
5794        let mut keys: Vec<String> = a
5795            .snapshots
5796            .keys()
5797            .map(|p| p.to_string_lossy().replace('\\', "/"))
5798            .collect();
5799        keys.sort();
5800        keys
5801    }
5802
5803    /// Two `bynk.toml` projects under **one** workspace folder are two projects
5804    /// (Q4: route by discovered root, not folder). Opening a file in each creates
5805    /// its own entry, and each analyses **only its own** tree — the overlay
5806    /// isolation guard, too: project A's round never sees project B's file.
5807    #[tokio::test]
5808    async fn two_projects_under_one_folder_are_two_projects() {
5809        let ax_src = "commons a.x\n\nfn ax(n: Int) -> Int {\n  n\n}\n";
5810        let by_src = "commons b.y\n\nfn by(n: Int) -> Int {\n  n\n}\n";
5811        let s = scratch_project(
5812            "d_two",
5813            &[
5814                ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5815                ("a/src/x.bynk", ax_src),
5816                ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5817                ("b/src/y.bynk", by_src),
5818            ],
5819        );
5820        let backend = bare_backend().await;
5821        set_folders(&backend, &[&s.0]).await;
5822        let ax = file_uri(&s.0, "a/src/x.bynk");
5823        let by = file_uri(&s.0, "b/src/y.bynk");
5824        open(&backend, &ax, ax_src).await;
5825        open(&backend, &by, by_src).await;
5826
5827        assert_ne!(
5828            Backend::root_for_uri_uncached(&ax).unwrap(),
5829            Backend::root_for_uri_uncached(&by).unwrap(),
5830            "the two files resolve to different project roots",
5831        );
5832        assert_eq!(
5833            backend.state.read().await.projects.len(),
5834            2,
5835            "one entry per project, not one for the shared folder",
5836        );
5837
5838        let a = backend.analysis_for(&ax).await.expect("A analysed");
5839        let b = backend.analysis_for(&by).await.expect("B analysed");
5840        assert_eq!(
5841            snapshot_keys(&a),
5842            vec!["src/x.bynk"],
5843            "A sees only A's file"
5844        );
5845        assert_eq!(
5846            snapshot_keys(&b),
5847            vec!["src/y.bynk"],
5848            "B sees only B's file"
5849        );
5850    }
5851
5852    /// Q4 lifecycle: `did_change_workspace_folders` removing a folder with **no
5853    /// open buffer** prunes the idle project entry and clears nothing it must
5854    /// keep. Routing no longer resolves it because the seed is gone.
5855    #[tokio::test]
5856    async fn removing_a_folder_prunes_an_idle_project() {
5857        let a = "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n";
5858        let s = scratch_project(
5859            "d_prune",
5860            &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5861        );
5862        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5863        let backend = bare_backend().await;
5864        set_folders(&backend, &[&s.0]).await;
5865        let uri = file_uri(&s.0, "src/a.bynk");
5866        open(&backend, &uri, a).await;
5867        backend.analysis_for(&uri).await.expect("analysed");
5868        // Close the buffer, so nothing but the folder pins the project.
5869        backend
5870            .did_close(DidCloseTextDocumentParams {
5871                text_document: TextDocumentIdentifier { uri: uri.clone() },
5872            })
5873            .await;
5874        assert_eq!(backend.state.read().await.projects.len(), 1);
5875
5876        backend
5877            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5878                event: WorkspaceFoldersChangeEvent {
5879                    added: vec![],
5880                    removed: vec![WorkspaceFolder {
5881                        uri: Url::from_file_path(&folder).unwrap(),
5882                        name: "p".into(),
5883                    }],
5884                },
5885            })
5886            .await;
5887        assert!(
5888            backend.state.read().await.projects.is_empty(),
5889            "an idle project is pruned when its last covering folder is removed",
5890        );
5891    }
5892
5893    /// Q4 lifecycle: a project that still holds an **open buffer** survives folder
5894    /// removal — routing needs it until the buffer closes.
5895    #[tokio::test]
5896    async fn removing_a_folder_retains_a_project_with_an_open_buffer() {
5897        let a = "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n";
5898        let s = scratch_project(
5899            "d_retain",
5900            &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5901        );
5902        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5903        let backend = bare_backend().await;
5904        set_folders(&backend, &[&s.0]).await;
5905        let uri = file_uri(&s.0, "src/a.bynk");
5906        open(&backend, &uri, a).await; // buffer stays open
5907
5908        backend
5909            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5910                event: WorkspaceFoldersChangeEvent {
5911                    added: vec![],
5912                    removed: vec![WorkspaceFolder {
5913                        uri: Url::from_file_path(&folder).unwrap(),
5914                        name: "p".into(),
5915                    }],
5916                },
5917            })
5918            .await;
5919        assert_eq!(
5920            backend.state.read().await.projects.len(),
5921            1,
5922            "a project with an open buffer must survive folder removal",
5923        );
5924        assert!(
5925            backend.analysis_for(&uri).await.is_some(),
5926            "and it must still answer requests",
5927        );
5928    }
5929
5930    /// Q4 §C: closing the **last** buffer of a project whose folder was already
5931    /// removed prunes it — the mirror of the folder path. Without it the project
5932    /// lingers forever with published diagnostics no folder or buffer justifies.
5933    #[tokio::test]
5934    async fn closing_the_last_buffer_of_a_folder_removed_project_prunes_it() {
5935        let a = "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n";
5936        let s = scratch_project(
5937            "d_close_prune",
5938            &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5939        );
5940        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5941        let backend = bare_backend().await;
5942        set_folders(&backend, &[&s.0]).await;
5943        let uri = file_uri(&s.0, "src/a.bynk");
5944        open(&backend, &uri, a).await;
5945        backend.analysis_for(&uri).await.expect("analysed");
5946
5947        // Remove the folder while the buffer is open — retained (its buffer pins it).
5948        backend
5949            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5950                event: WorkspaceFoldersChangeEvent {
5951                    added: vec![],
5952                    removed: vec![WorkspaceFolder {
5953                        uri: Url::from_file_path(&folder).unwrap(),
5954                        name: "p".into(),
5955                    }],
5956                },
5957            })
5958            .await;
5959        assert_eq!(
5960            backend.state.read().await.projects.len(),
5961            1,
5962            "retained while its buffer is open",
5963        );
5964
5965        // Close the last buffer — now fully orphaned (no folder, no buffer).
5966        backend
5967            .did_close(DidCloseTextDocumentParams {
5968                text_document: TextDocumentIdentifier { uri: uri.clone() },
5969            })
5970            .await;
5971        assert!(
5972            backend.state.read().await.projects.is_empty(),
5973            "closing the last buffer of a folder-removed project must prune it",
5974        );
5975    }
5976
5977    /// Q4: a rename spans **one** project — a stale buffer in another project must
5978    /// not block it (`analysis_covering_open_buffers` is per-project). Under a
5979    /// whole-server gate, B's dirty buffer would refuse A's rename.
5980    #[tokio::test]
5981    async fn a_rename_in_one_project_ignores_a_dirty_buffer_in_another() {
5982        let a_src = "commons a.x\n\ntype Money = Int where Positive\n\nfn charge(m: Money) -> Money {\n  m\n}\n";
5983        let b_src = "commons b.y\n\nfn by(n: Int) -> Int {\n  n\n}\n";
5984        let s = scratch_project(
5985            "d_rename_iso",
5986            &[
5987                ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5988                ("a/src/x.bynk", a_src),
5989                ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5990                ("b/src/y.bynk", b_src),
5991            ],
5992        );
5993        let backend = bare_backend().await;
5994        set_folders(&backend, &[&s.0]).await;
5995        let ax = file_uri(&s.0, "a/src/x.bynk");
5996        let by = file_uri(&s.0, "b/src/y.bynk");
5997        open(&backend, &ax, a_src).await;
5998        open(&backend, &by, b_src).await;
5999        backend.analysis_for(&ax).await.expect("A analysed");
6000        backend.analysis_for(&by).await.expect("B analysed");
6001
6002        // Make B's buffer dirty (version 2, not yet re-analysed).
6003        backend
6004            .did_change(DidChangeTextDocumentParams {
6005                text_document: VersionedTextDocumentIdentifier {
6006                    uri: by.clone(),
6007                    version: 2,
6008                },
6009                content_changes: vec![TextDocumentContentChangeEvent {
6010                    range: None,
6011                    range_length: None,
6012                    text: format!("\n{b_src}"),
6013                }],
6014            })
6015            .await;
6016
6017        // Rename `Money` in A — must succeed despite B being dirty.
6018        let off = a_src.find("Money").unwrap();
6019        let pos = crate::position::offset_to_position(a_src, off);
6020        let edit = backend
6021            .rename(RenameParams {
6022                text_document_position: TextDocumentPositionParams {
6023                    text_document: TextDocumentIdentifier { uri: ax.clone() },
6024                    position: pos,
6025                },
6026                new_name: "Amount".into(),
6027                work_done_progress_params: Default::default(),
6028            })
6029            .await
6030            .expect("rename must not error");
6031        assert!(
6032            edit.is_some(),
6033            "a rename in project A must not be blocked by a dirty buffer in project B",
6034        );
6035    }
6036
6037    // ---- Slice E: startup analysis & dynamic watchers ----
6038
6039    /// `initialize` captures the client's `didChangeWatchedFiles` dynamic-
6040    /// registration support, which gates the server-side watcher registration.
6041    #[tokio::test]
6042    async fn initialize_captures_the_dynamic_watcher_capability() {
6043        let backend = bare_backend().await;
6044        let params = InitializeParams {
6045            capabilities: ClientCapabilities {
6046                workspace: Some(WorkspaceClientCapabilities {
6047                    did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
6048                        dynamic_registration: Some(true),
6049                        relative_pattern_support: None,
6050                    }),
6051                    ..Default::default()
6052                }),
6053                ..Default::default()
6054            },
6055            ..Default::default()
6056        };
6057        backend.initialize(params).await.expect("initialize");
6058        assert!(
6059            backend.state.read().await.supports_dynamic_watchers,
6060            "the client's dynamic-registration support must be captured for `initialized`",
6061        );
6062    }
6063
6064    /// #733: `initialize` captures each pull-based decoration's `refresh_support`
6065    /// independently — the flag gates whether a committed round nudges the client
6066    /// to re-pull that decoration. The three `and_then` chains are easy to
6067    /// mis-wire (a swapped field reads the wrong capability), so pin each: two
6068    /// advertised, one withheld, one whole family absent.
6069    #[tokio::test]
6070    async fn initialize_captures_each_decoration_refresh_capability() {
6071        let backend = bare_backend().await;
6072        let params = InitializeParams {
6073            capabilities: ClientCapabilities {
6074                workspace: Some(WorkspaceClientCapabilities {
6075                    // Semantic tokens: advertised.
6076                    semantic_tokens: Some(SemanticTokensWorkspaceClientCapabilities {
6077                        refresh_support: Some(true),
6078                    }),
6079                    // Inlay hints: explicitly withheld.
6080                    inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
6081                        refresh_support: Some(false),
6082                    }),
6083                    // Code lens: the whole family absent (no capability at all).
6084                    ..Default::default()
6085                }),
6086                ..Default::default()
6087            },
6088            ..Default::default()
6089        };
6090        backend.initialize(params).await.expect("initialize");
6091        let refresh = backend.state.read().await.supports_refresh;
6092        assert!(
6093            refresh.semantic_tokens,
6094            "semantic tokens: advertised → true"
6095        );
6096        assert!(!refresh.inlay_hints, "inlay hints: withheld → false");
6097        assert!(!refresh.code_lens, "code lens: absent → false");
6098    }
6099
6100    /// The discovery walk finds every nested `bynk.toml` project under a folder
6101    /// (a monorepo), and skips the caches it must never descend.
6102    #[tokio::test]
6103    async fn discover_projects_under_finds_nested_projects_and_skips_caches() {
6104        let s = scratch_project(
6105            "e_discover",
6106            &[
6107                ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
6108                ("packages/a/src/x.bynk", "commons a.x\n"),
6109                ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
6110                ("packages/b/src/y.bynk", "commons b.y\n"),
6111                // A manifest under a skipped dir must NOT be discovered.
6112                ("node_modules/dep/bynk.toml", "[project]\nname=\"dep\"\n"),
6113            ],
6114        );
6115        let mut roots = Backend::discover_projects_under(&s.0);
6116        roots.sort();
6117        let names: Vec<String> = roots
6118            .iter()
6119            .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
6120            .collect();
6121        assert_eq!(
6122            names,
6123            vec!["a", "b"],
6124            "both monorepo projects found, node_modules skipped; got {roots:?}",
6125        );
6126    }
6127
6128    /// Startup analysis: `initialized` warms every project under the workspace
6129    /// folders — creating each entry so diagnostics/features are ready — **with
6130    /// no `did_open`**. This is spec §2.3's documented startup analysis.
6131    #[tokio::test]
6132    async fn initialized_warms_every_project_under_the_folders() {
6133        let s = scratch_project(
6134            "e_warm",
6135            &[
6136                ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
6137                (
6138                    "packages/a/src/x.bynk",
6139                    "commons a.x\n\nfn ax(n: Int) -> Int {\n  n\n}\n",
6140                ),
6141                ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
6142                (
6143                    "packages/b/src/y.bynk",
6144                    "commons b.y\n\nfn by(n: Int) -> Int {\n  n\n}\n",
6145                ),
6146            ],
6147        );
6148        let backend = bare_backend().await;
6149        set_folders(&backend, &[&s.0]).await;
6150
6151        // No file opened — just the handshake completion.
6152        backend.initialized(InitializedParams {}).await;
6153
6154        assert_eq!(
6155            backend.state.read().await.projects.len(),
6156            2,
6157            "both monorepo projects are warmed at `initialized`, before any open",
6158        );
6159        // And each is genuinely analysable without an open buffer.
6160        let ax = file_uri(&s.0, "packages/a/src/x.bynk");
6161        assert!(
6162            backend.analysis_for(&ax).await.is_some(),
6163            "a warmed project answers index requests with no `did_open`",
6164        );
6165    }
6166
6167    /// The implicit-`src/` project (#485 — a `src/` tree with no `bynk.toml`) is
6168    /// warmed at startup too, not only lazily on first open. `resolve_root` finds
6169    /// only a `src/` *ancestor*, so the folder-is-the-root case needs the explicit
6170    /// check in `discover_projects_under`.
6171    #[tokio::test]
6172    async fn initialized_warms_an_implicit_src_project() {
6173        let s = scratch_project(
6174            "e_implicit",
6175            &[(
6176                "src/a.bynk",
6177                "commons demo.a\n\nfn f(n: Int) -> Int {\n  n\n}\n",
6178            )],
6179        );
6180        let backend = bare_backend().await;
6181        set_folders(&backend, &[&s.0]).await;
6182        backend.initialized(InitializedParams {}).await;
6183        assert_eq!(
6184            backend.state.read().await.projects.len(),
6185            1,
6186            "a rootless `src/` project is warmed at startup, not only on open",
6187        );
6188    }
6189
6190    /// Review of #677: the discovery walk must not follow a symlink cycle into a
6191    /// stack overflow — a `loop -> .` in an ordinary directory. The visited-set
6192    /// (canonicalised dirs) bounds it.
6193    #[cfg(unix)]
6194    #[tokio::test]
6195    async fn discover_projects_under_survives_a_symlink_cycle() {
6196        let s = scratch_project(
6197            "e_cycle",
6198            &[
6199                ("bynk.toml", "[project]\nname=\"p\"\n"),
6200                ("src/a.bynk", "commons p.a\n"),
6201            ],
6202        );
6203        // A directory symlink pointing back at the folder — a cycle.
6204        std::os::unix::fs::symlink(&s.0, s.0.join("loop")).ok();
6205        let roots = Backend::discover_projects_under(&s.0); // must terminate
6206        let canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6207        assert!(
6208            roots.contains(&canon),
6209            "the project is found and the walk terminates despite the cycle",
6210        );
6211    }
6212
6213    /// Review of #677: with the per-query `workspace/symbol` walk dropped, a
6214    /// `bynk.toml` **created** after startup is picked up via its watcher event
6215    /// — the watcher warms the new project.
6216    #[tokio::test]
6217    async fn a_created_manifest_warms_a_new_project() {
6218        let s = scratch_project("e_created", &[("src/a.bynk", "commons p.a\n")]);
6219        std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
6220        let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6221        let backend = bare_backend().await;
6222        set_folders(&backend, &[&s.0]).await;
6223        assert!(
6224            backend.state.read().await.projects.is_empty(),
6225            "no entry before the watcher fires",
6226        );
6227
6228        let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
6229        backend
6230            .did_change_watched_files(DidChangeWatchedFilesParams {
6231                changes: vec![FileEvent {
6232                    uri: toml_uri,
6233                    typ: FileChangeType::CREATED,
6234                }],
6235            })
6236            .await;
6237        assert_eq!(
6238            backend.state.read().await.projects.len(),
6239            1,
6240            "a created bynk.toml warms its project via the watcher event",
6241        );
6242    }
6243
6244    /// #682: a repeated route for the same URI is served from `root_cache`
6245    /// rather than re-walking the filesystem each time — a `None` route
6246    /// (single-file mode) is cached too, since it's just as stable an answer.
6247    #[tokio::test]
6248    async fn root_for_uri_populates_the_cache() {
6249        let s = scratch_project("g_cache_hit", &[("a.bynk", "commons demo.a\n")]);
6250        let uri = file_uri(&s.0, "a.bynk");
6251        let backend = bare_backend().await;
6252
6253        assert!(
6254            backend.root_for_uri(&uri).await.is_none(),
6255            "no bynk.toml and no src/ ancestor — routes to no project",
6256        );
6257        assert_eq!(
6258            backend.state.read().await.root_cache.get(&uri),
6259            Some(&None),
6260            "the miss is cached too",
6261        );
6262    }
6263
6264    /// #682 (DECISION C): a `bynk.toml` created after a URI was already routed
6265    /// (and cached) re-routes that URI once the watcher event invalidates the
6266    /// cache — a stale cached `None` must not survive the manifest's arrival.
6267    #[tokio::test]
6268    async fn a_created_manifest_invalidates_the_cached_route() {
6269        let s = scratch_project("g_cache_invalidate", &[("a.bynk", "commons p.a\n")]);
6270        let uri = file_uri(&s.0, "a.bynk");
6271        let backend = bare_backend().await;
6272
6273        assert!(
6274            backend.root_for_uri(&uri).await.is_none(),
6275            "precondition: cached as routing to no project",
6276        );
6277
6278        std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
6279        let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6280        let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
6281        backend
6282            .did_change_watched_files(DidChangeWatchedFilesParams {
6283                changes: vec![FileEvent {
6284                    uri: toml_uri,
6285                    typ: FileChangeType::CREATED,
6286                }],
6287            })
6288            .await;
6289
6290        assert_eq!(
6291            backend.root_for_uri(&uri).await,
6292            Some(root),
6293            "re-routes to the new project now the stale cache entry is gone",
6294        );
6295    }
6296
6297    /// #822: the guard `root_for_uri` checks before writing back a cache miss
6298    /// — an accidental `!=`-for-`==` inversion here would silently reopen the
6299    /// TOCTOU the generation counter exists to close, and the real race is too
6300    /// timing-dependent to exercise deterministically, so this pins the
6301    /// predicate directly.
6302    #[test]
6303    fn root_cache_write_is_current_rejects_a_generation_that_moved() {
6304        assert!(
6305            Backend::root_cache_write_is_current(3, 3),
6306            "no clear happened since the read — the write-back applies",
6307        );
6308        assert!(
6309            !Backend::root_cache_write_is_current(3, 4),
6310            "a clear bumped the generation since the read — the write-back must be dropped",
6311        );
6312    }
6313
6314    /// #822: both `root_cache.clear()` sites must bump `root_cache_generation`
6315    /// alongside the clear — the guard only closes the TOCTOU if every
6316    /// invalidation does both. `did_change_watched_files`'s bump is covered
6317    /// indirectly by `a_created_manifest_invalidates_the_cached_route`; this
6318    /// covers `did_change_workspace_folders`'s directly, since a regression
6319    /// dropping just that one bump would reopen the race there specifically.
6320    #[tokio::test]
6321    async fn a_workspace_folder_change_bumps_the_root_cache_generation() {
6322        let s = scratch_project("g_race_folder", &[("bynk.toml", "[project]\nname=\"p\"\n")]);
6323        let backend = bare_backend().await;
6324        let before = backend.state.read().await.root_cache_generation;
6325
6326        backend
6327            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
6328                event: WorkspaceFoldersChangeEvent {
6329                    added: vec![WorkspaceFolder {
6330                        uri: Url::from_file_path(&s.0).unwrap(),
6331                        name: "p".into(),
6332                    }],
6333                    removed: vec![],
6334                },
6335            })
6336            .await;
6337
6338        assert!(
6339            backend.state.read().await.root_cache_generation > before,
6340            "a workspace-folder change must bump the generation, not just clear the cache",
6341        );
6342    }
6343
6344    /// A folder added at runtime is warmed the same way (the proactive scan
6345    /// slice D deferred to E), so its projects appear without an open.
6346    #[tokio::test]
6347    async fn an_added_folder_is_warmed() {
6348        let s = scratch_project(
6349            "e_added",
6350            &[
6351                ("bynk.toml", "[project]\nname=\"p\"\n"),
6352                (
6353                    "src/a.bynk",
6354                    "commons p.a\n\nfn f(n: Int) -> Int {\n  n\n}\n",
6355                ),
6356            ],
6357        );
6358        let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6359        let backend = bare_backend().await; // no folders yet
6360        assert!(backend.state.read().await.projects.is_empty());
6361
6362        backend
6363            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
6364                event: WorkspaceFoldersChangeEvent {
6365                    added: vec![WorkspaceFolder {
6366                        uri: Url::from_file_path(&folder).unwrap(),
6367                        name: "p".into(),
6368                    }],
6369                    removed: vec![],
6370                },
6371            })
6372            .await;
6373
6374        assert_eq!(
6375            backend.state.read().await.projects.len(),
6376            1,
6377            "an added workspace folder's project is warmed proactively",
6378        );
6379    }
6380
6381    // ---- Slice F: one diagnostics scheduler ----
6382
6383    fn change_params(uri: &Url, version: i32, text: &str) -> DidChangeTextDocumentParams {
6384        DidChangeTextDocumentParams {
6385            text_document: VersionedTextDocumentIdentifier {
6386                uri: uri.clone(),
6387                version,
6388            },
6389            content_changes: vec![TextDocumentContentChangeEvent {
6390                range: None,
6391                range_length: None,
6392                text: text.to_string(),
6393            }],
6394        }
6395    }
6396
6397    /// Slice F: a **single-file** buffer (no project) now debounces by
6398    /// generation — a burst bumps the URI's generation once per change, so only
6399    /// the last-scheduled task survives its freshness check and runs `diagnose`.
6400    /// Before F single-file had no generation and ran once per keystroke.
6401    #[tokio::test]
6402    async fn a_single_file_burst_coalesces_by_generation() {
6403        // A `.bynk` file with no `bynk.toml` and no `src/` — single-file mode.
6404        let s = scratch_project("f_single", &[("a.bynk", "commons demo.a\n")]);
6405        let uri = file_uri(&s.0, "a.bynk");
6406        assert!(
6407            Backend::root_for_uri_uncached(&uri).is_none(),
6408            "precondition: the file routes to no project",
6409        );
6410        let backend = bare_backend().await;
6411        for _ in 0..3 {
6412            backend.schedule_single_file(uri.clone()).await;
6413        }
6414        assert_eq!(
6415            backend
6416                .state
6417                .read()
6418                .await
6419                .single_file_generations
6420                .get(&uri)
6421                .copied(),
6422            Some(3),
6423            "each change bumps the generation; only the third task passes its check",
6424        );
6425    }
6426
6427    /// Slice F: `did_close` clears a single-file buffer's debounce generation, so
6428    /// the map does not grow unboundedly across a session.
6429    #[tokio::test]
6430    async fn did_close_clears_the_single_file_generation() {
6431        let s = scratch_project("f_close", &[("a.bynk", "commons demo.a\n")]);
6432        let uri = file_uri(&s.0, "a.bynk");
6433        let backend = bare_backend().await;
6434        backend.schedule_single_file(uri.clone()).await;
6435        assert!(
6436            backend
6437                .state
6438                .read()
6439                .await
6440                .single_file_generations
6441                .contains_key(&uri),
6442            "the generation exists after scheduling",
6443        );
6444        backend
6445            .did_close(DidCloseTextDocumentParams {
6446                text_document: TextDocumentIdentifier { uri: uri.clone() },
6447            })
6448            .await;
6449        assert!(
6450            !backend
6451                .state
6452                .read()
6453                .await
6454                .single_file_generations
6455                .contains_key(&uri),
6456            "did_close clears the single-file generation",
6457        );
6458    }
6459
6460    /// Slice F: `did_change` in **project** mode now feeds the one generation-
6461    /// based scheduler directly (no separate pre-sleep, no second hardcoded
6462    /// debounce). A burst bumps the project's generation once per change, so a
6463    /// single round survives — coalescing, through the real handler.
6464    #[tokio::test]
6465    async fn a_project_change_burst_coalesces_through_did_change() {
6466        let src = "commons q.a\n\nfn f(x: Int) -> Int {\n  x\n}\n";
6467        let s = scratch_project(
6468            "f_burst",
6469            &[
6470                ("bynk.toml", "[project]\nname=\"q\"\n"),
6471                ("src/a.bynk", src),
6472            ],
6473        );
6474        let backend = backend_at(&s.0).await;
6475        let root = backend.test_root().await;
6476        let uri = file_uri(&s.0, "src/a.bynk");
6477        open(&backend, &uri, src).await;
6478
6479        let gen_before = {
6480            let state = backend.state.read().await;
6481            state.projects.get(&root).unwrap().analysis_generation
6482        };
6483        for v in 2..=5 {
6484            backend
6485                .did_change(change_params(
6486                    &uri,
6487                    v,
6488                    &format!("{}{src}", "\n".repeat(v as usize)),
6489                ))
6490                .await;
6491        }
6492        let gen_after = {
6493            let state = backend.state.read().await;
6494            state.projects.get(&root).unwrap().analysis_generation
6495        };
6496        assert_eq!(
6497            gen_after - gen_before,
6498            4,
6499            "each of the four changes bumps the generation once — only the last \
6500             scheduled round runs (no per-change round, no stacked debounce)",
6501        );
6502    }
6503
6504    // -- #596: store-map query vocabulary, end to end through `completion` ----
6505    //
6506    // The unit tests in `completion.rs`/`kernel_methods.rs`/`store_ops.rs`
6507    // cover each half in isolation; a #812 review flagged the gap that no test
6508    // drove a real `textDocument/completion` request through `Backend` to
6509    // check the two halves actually merge (and, separately, that the
6510    // provenance-based half survives a project-wide resolve failure that
6511    // blanks `type_receiver`). These close both.
6512
6513    fn completion_labels(response: Option<CompletionResponse>) -> Vec<String> {
6514        match response {
6515            Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(),
6516            Some(CompletionResponse::List(list)) => {
6517                list.items.into_iter().map(|i| i.label).collect()
6518            }
6519            None => Vec::new(),
6520        }
6521    }
6522
6523    async fn complete_at(backend: &Backend, uri: &Url, text: &str, needle: &str) -> Vec<String> {
6524        let offset = text.find(needle).expect("needle present") + needle.len();
6525        let pos = crate::position::offset_to_position(text, offset);
6526        let response = backend
6527            .completion(CompletionParams {
6528                text_document_position: TextDocumentPositionParams {
6529                    text_document: TextDocumentIdentifier { uri: uri.clone() },
6530                    position: pos,
6531                },
6532                work_done_progress_params: Default::default(),
6533                partial_result_params: Default::default(),
6534                context: None,
6535            })
6536            .await
6537            .expect("completion must not error");
6538        completion_labels(response)
6539    }
6540
6541    /// A `store Map` field's `.` completion merges both halves in one
6542    /// response: the `Query` kernel methods (`filter`, `collect`, …) from
6543    /// `kernel_methods::methods_for`, and the store-field vocabulary (entry
6544    /// ops + accessors) from the provenance-based path — driven through the
6545    /// real `Backend::completion`, not the pure helpers directly.
6546    #[tokio::test]
6547    async fn store_map_receiver_completion_merges_both_vocabularies() {
6548        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";
6549        let s = scratch_project(
6550            "store_map_merge",
6551            &[
6552                ("bynk.toml", "[project]\nname=\"shop\"\n"),
6553                ("src/a.bynk", src),
6554            ],
6555        );
6556        let backend = backend_at(&s.0).await;
6557        let uri = file_uri(&s.0, "src/a.bynk");
6558        open(&backend, &uri, src).await;
6559        backend.run_round().await;
6560
6561        let labels = complete_at(&backend, &uri, src, "    items.").await;
6562        assert!(
6563            labels.contains(&"filter".to_string()),
6564            "the Query kernel vocabulary: {labels:?}"
6565        );
6566        assert!(
6567            labels.contains(&"collect".to_string()),
6568            "the Query kernel vocabulary: {labels:?}"
6569        );
6570        assert!(
6571            labels.contains(&"put".to_string()),
6572            "the store entry ops: {labels:?}"
6573        );
6574        assert!(
6575            labels.contains(&"entries".to_string()),
6576            "the Map query accessors: {labels:?}"
6577        );
6578    }
6579
6580    /// An unresolved type name elsewhere in the same file — in an unrelated
6581    /// `type` declaration, not even the agent using `items` — trips the
6582    /// *resolve* gate. That once blanked `expr_types` for the **whole file**
6583    /// (the one clean-file-ceiling gap ADR 0094 didn't close), and before the
6584    /// #812 review fix `value_member_completions` then returned early and
6585    /// never reached the store-field path. Since #1663 (Decision A) the
6586    /// checker runs past a resolve error, so both halves survive: the typed
6587    /// `Query` kernel methods and the store-field entry ops/accessors.
6588    #[tokio::test]
6589    async fn store_field_vocabulary_survives_an_unrelated_resolve_failure() {
6590        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";
6591        let s = scratch_project(
6592            "store_map_resolve_gap",
6593            &[
6594                ("bynk.toml", "[project]\nname=\"shop\"\n"),
6595                ("src/a.bynk", src),
6596            ],
6597        );
6598        let backend = backend_at(&s.0).await;
6599        let uri = file_uri(&s.0, "src/a.bynk");
6600        open(&backend, &uri, src).await;
6601        backend.run_round().await;
6602
6603        // Precondition: the round really did fail to type this file (the
6604        // fixture actually reaches the ceiling this test is about, rather
6605        // than passing vacuously because the file happened to check fine).
6606        let analysis = backend.test_analysis().await.expect("a round committed");
6607        let rel = Backend::uri_to_rel(&analysis, &uri).expect("uri resolves");
6608        assert!(
6609            analysis
6610                .diagnostics
6611                .get(&rel)
6612                .is_some_and(|ds| !ds.is_empty()),
6613            "the fixture must actually fail to check — an undeclared return \
6614             type is the trigger this test exercises",
6615        );
6616
6617        let labels = complete_at(&backend, &uri, src, "    items.").await;
6618        // #1663: the resolve error in `Bad` no longer blanks the agent's types,
6619        // so the typed half (`Query` kernel methods) survives too.
6620        assert!(
6621            labels.contains(&"filter".to_string()),
6622            "an unrelated resolve error must not blank the typed half: {labels:?}"
6623        );
6624        assert!(
6625            labels.contains(&"put".to_string()),
6626            "store entry ops must survive an unrelated resolve failure: {labels:?}"
6627        );
6628        assert!(
6629            labels.contains(&"entries".to_string()),
6630            "Map query accessors must survive an unrelated resolve failure: {labels:?}"
6631        );
6632    }
6633
6634    /// Content-ownership track (#1086) §8's "done when": an unsaved edit in
6635    /// file A is visible to a completion triggered from file B — driven
6636    /// through a real `Backend` (`did_open`/`did_change` → `completion`),
6637    /// not the pure `bynk_ide` helpers directly. `shared/widget.bynk`
6638    /// declares `Widget` with one field on disk; opening it and editing its
6639    /// buffer (never saved) to add a second field must be visible to
6640    /// `app/use.bynk`'s cross-file record-construction completion
6641    /// (`Widget { <cursor>`, via `record_field_names`/`project_content`) in
6642    /// the very same round. This exercises the sweep both files' rounds
6643    /// share, not `type_receiver` specifically — see
6644    /// `type_receivers_slow_path_sees_a_closed_files_disk_content` below for
6645    /// that path's own dedicated coverage (a review of this PR found this
6646    /// test alone doesn't reach it).
6647    #[tokio::test]
6648    async fn an_unsaved_edit_in_one_file_is_visible_to_completion_in_another() {
6649        let widget_v1 = "commons shared.widget\n\ntype Widget = { size: Int }\n";
6650        let widget_v2 = "commons shared.widget\n\ntype Widget = { size: Int, weight: Int }\n";
6651        let use_src =
6652            "commons app.use\n\nuses shared.widget\n\nfn make() -> Widget {\n  Widget { \n}\n";
6653        let s = scratch_project(
6654            "cross_file_unsaved",
6655            &[
6656                (
6657                    "bynk.toml",
6658                    "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
6659                ),
6660                ("shared/widget.bynk", widget_v1),
6661                ("app/use.bynk", use_src),
6662            ],
6663        );
6664        let backend = backend_at(&s.0).await;
6665        let widget_uri = file_uri(&s.0, "shared/widget.bynk");
6666        let use_uri = file_uri(&s.0, "app/use.bynk");
6667        open(&backend, &widget_uri, widget_v1).await;
6668        open(&backend, &use_uri, use_src).await;
6669        backend.run_round().await;
6670
6671        // Precondition: before the edit, only the on-disk field completes.
6672        let before = complete_at(&backend, &use_uri, use_src, "  Widget { ").await;
6673        assert!(before.contains(&"size".to_string()), "{before:?}");
6674        assert!(
6675            !before.contains(&"weight".to_string()),
6676            "the fixture must not already have `weight` on disk: {before:?}"
6677        );
6678
6679        // Edit `widget.bynk`'s buffer — never saved to disk — to add `weight`.
6680        backend
6681            .did_change(DidChangeTextDocumentParams {
6682                text_document: VersionedTextDocumentIdentifier {
6683                    uri: widget_uri.clone(),
6684                    version: 2,
6685                },
6686                content_changes: vec![TextDocumentContentChangeEvent {
6687                    range: None,
6688                    range_length: None,
6689                    text: widget_v2.to_string(),
6690                }],
6691            })
6692            .await;
6693
6694        let after = complete_at(&backend, &use_uri, use_src, "  Widget { ").await;
6695        assert!(
6696            after.contains(&"weight".to_string()),
6697            "an unsaved edit to `widget.bynk` must be visible to `use.bynk`'s \
6698             cross-file completion in the same round: {after:?}"
6699        );
6700        assert!(after.contains(&"size".to_string()), "{after:?}");
6701
6702        // On-disk content is untouched — the visibility came from the buffer.
6703        assert_eq!(
6704            std::fs::read_to_string(s.0.join("shared/widget.bynk")).unwrap(),
6705            widget_v1,
6706            "the edit must never have been saved to disk"
6707        );
6708    }
6709
6710    /// Content-ownership track (#1086) slice 5: dedicated coverage for
6711    /// `type_receiver`'s slow path specifically (the fix a PR review found
6712    /// the test above doesn't reach — `Widget { ` completion never calls
6713    /// `type_receiver` at all, it's pure syntax via `record_field_names`).
6714    /// `widget.bynk` is **never opened** — closed, on-disk only — so its
6715    /// content can only reach `w.`'s value-member completion in
6716    /// `use.bynk` through `type_receiver`'s own `sweep_project_content`
6717    /// call, not through any open-buffer overlay. No round runs before the
6718    /// request either, so `project_analysis_for`'s fast-path cache is empty
6719    /// and `type_receiver` must take its slow, re-analysing path — the one
6720    /// this slice fixed.
6721    #[tokio::test]
6722    async fn type_receivers_slow_path_sees_a_closed_files_disk_content() {
6723        let widget_src = "commons shared.widget\n\ntype Widget = { size: Int }\n";
6724        let use_src =
6725            "commons app.use\n\nuses shared.widget\n\nfn area(w: Widget) -> Int {\n  w.\n}\n";
6726        let s = scratch_project(
6727            "type_receiver_slow_path",
6728            &[
6729                (
6730                    "bynk.toml",
6731                    "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
6732                ),
6733                ("shared/widget.bynk", widget_src),
6734                ("app/use.bynk", use_src),
6735            ],
6736        );
6737        let backend = backend_at(&s.0).await;
6738        let use_uri = file_uri(&s.0, "app/use.bynk");
6739        // `widget.bynk` is deliberately never opened — no did_open, no round.
6740        open(&backend, &use_uri, use_src).await;
6741
6742        let labels = complete_at(&backend, &use_uri, use_src, "  w.").await;
6743        assert!(
6744            labels.contains(&"size".to_string()),
6745            "type_receiver's slow path must resolve `w: Widget` off the \
6746             closed widget.bynk's real disk content: {labels:?}"
6747        );
6748    }
6749}