Skip to main content

bynk_lsp/
lib.rs

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