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