Skip to main content

bynkc_lsp/
main.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, formatting,
5//! range formatting, document symbols, references, rename, code actions,
6//! workspace symbols, document highlights, and file watching. Built on
7//! `tower-lsp`.
8//!
9//! Architecture:
10//! - [`Backend`] holds the project state: root path (the directory
11//!   containing `bynk.toml`), parsed configuration, and an in-memory map of
12//!   open files. State is guarded by a `tokio::sync::RwLock`.
13//! - Document changes trigger `recompile_and_publish` which re-runs the
14//!   compiler (via [`bynk_ide::diagnose`]) and publishes resulting diagnostics.
15//! - Hover and definition consult the parsed AST for the file under the
16//!   cursor; both are best-effort (return None for unrecognised positions).
17//! - Formatting delegates to [`bynk_fmt::format_source`].
18
19mod code_actions;
20mod completion;
21mod document_symbols;
22mod index_queries;
23mod inlay_hints;
24mod locals_nav;
25mod position;
26mod project;
27mod publish;
28mod signature_help;
29mod structure;
30mod symbols;
31
32use std::path::PathBuf;
33use std::sync::Arc;
34
35use tokio::sync::RwLock;
36use tower_lsp::jsonrpc::Result as JsonRpcResult;
37use tower_lsp::lsp_types::request::{
38    GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
39    GotoTypeDefinitionResponse,
40};
41use tower_lsp::lsp_types::*;
42use tower_lsp::{Client, LanguageServer, LspService, Server};
43
44use crate::project::ProjectConfig;
45
46const SERVER_NAME: &str = "bynkc-lsp";
47const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
48
49/// In-memory document state.
50#[derive(Debug, Clone)]
51struct DocumentState {
52    text: String,
53    version: i32,
54}
55
56/// v0.25 (ADR 0053): one analysis round's retained outputs — the binding
57/// index plus the snapshots its spans are offsets into, and the open-doc
58/// versions captured when the overlay was built (rename emits versioned
59/// edits against exactly these versions).
60#[derive(Debug)]
61struct Analysis {
62    /// Canonicalised source root the snapshots' relative paths resolve
63    /// against.
64    src_root: PathBuf,
65    index: bynk_check::index::ProjectIndex,
66    /// Project-relative path → the analysed text.
67    snapshots: std::collections::HashMap<PathBuf, String>,
68    /// Project-relative path → the open document's version at analysis
69    /// time (absent for files read from disk).
70    versions: std::collections::HashMap<PathBuf, i32>,
71    /// v0.26 (ADR 0054): project-relative path → the round's diagnostics,
72    /// full `CompileError`s included — the suggestions `codeAction` serves
73    /// ride on them. Every analysed file has an entry (clean files an empty
74    /// one). Replaces the v0.25 categories-only field; the rename baseline
75    /// derives from these via [`Self::diag_categories`].
76    diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>>,
77    /// v0.27 (ADR 0056): project-relative path → the round's harvested
78    /// inferred-type hints, spans against the analysed snapshots.
79    hints: bynk_check::hints::FileHints,
80    /// v0.99: project-relative path → the round's capability-requirement ledger,
81    /// driving the materializable ghost `given` inlay hint, spans against the
82    /// analysed snapshots.
83    requirements: bynk_check::requirements::FileRequirements,
84    /// v0.31 (ADR 0064): project-relative path → the round's local bindings
85    /// with scope ranges, for locals navigation (references/definition/
86    /// highlight), spans against the analysed snapshots.
87    locals: bynk_check::locals::FileLocals,
88    /// Slice 6: project-relative path → the round's expression types, spans
89    /// against the analysed snapshots — backs go-to-type-definition.
90    expr_types: bynk_check::expr_types::FileExprTypes,
91    /// Slice 6b (ADR 0095): qualified unit name → its project source file(s),
92    /// project-relative — backs document links (`uses`/`consumes` → source).
93    unit_sources: std::collections::HashMap<String, Vec<PathBuf>>,
94}
95
96impl Analysis {
97    /// Per-file diagnostic categories — the rename validator's baseline,
98    /// derived from the retained diagnostics.
99    fn diag_categories(&self) -> Vec<(PathBuf, String)> {
100        self.diagnostics
101            .iter()
102            .flat_map(|(path, diags)| {
103                diags
104                    .iter()
105                    .map(|d| (path.clone(), d.error.category.to_string()))
106            })
107            .collect()
108    }
109}
110
111/// Mutable project state.
112#[derive(Debug, Default)]
113struct State {
114    /// Path to the project root (the directory containing `bynk.toml`). If
115    /// no project root is found, this is None and the server operates in
116    /// single-file mode for any open file.
117    project_root: Option<PathBuf>,
118    /// Parsed `bynk.toml` configuration. Defaults applied for missing fields.
119    config: ProjectConfig,
120    /// Open documents keyed by URI.
121    docs: std::collections::HashMap<Url, DocumentState>,
122    /// v0.24: URIs that currently carry published project diagnostics — the
123    /// previous round's dirty set, so newly-clean files get a clearing
124    /// (empty) publish.
125    published: std::collections::HashSet<Url>,
126    /// v0.24: debounce generation. Each change bumps it; a scheduled
127    /// analysis runs only if it is still the latest when the delay elapses.
128    analysis_generation: u64,
129    /// v0.25: the latest analysis round's index + snapshots. References,
130    /// rename, and the re-pointed definition/hover read this; positions
131    /// convert against the analysed snapshots (v0.24 rule).
132    analysis: Option<Arc<Analysis>>,
133}
134
135#[derive(Clone)]
136struct Backend {
137    client: Client,
138    state: Arc<RwLock<State>>,
139}
140
141impl Backend {
142    fn new(client: Client) -> Self {
143        Self {
144            client,
145            state: Arc::new(RwLock::new(State::default())),
146        }
147    }
148
149    /// Locate `bynk.toml` walking upward from the given path. Returns the
150    /// project root (the directory containing `bynk.toml`) on success.
151    fn find_project_root(start: &std::path::Path) -> Option<PathBuf> {
152        let mut current = if start.is_file() {
153            start.parent()?.to_path_buf()
154        } else {
155            start.to_path_buf()
156        };
157        loop {
158            let candidate = current.join("bynk.toml");
159            if candidate.is_file() {
160                return Some(current);
161            }
162            current = current.parent()?.to_path_buf();
163        }
164    }
165
166    /// Re-run the compiler on the document at `uri` and publish diagnostics.
167    /// Best-effort: a malformed file produces diagnostics rather than a
168    /// hard failure.
169    async fn recompile_and_publish(&self, uri: &Url) {
170        // v0.24 (ADR 0052): with a project root, diagnostics are
171        // project-wide (every file, contexts included) on a debounce.
172        // Single-file mode (no bynk.toml) keeps the per-buffer path below.
173        if self.state.read().await.project_root.is_some() {
174            self.schedule_project_diagnostics().await;
175            return;
176        }
177        let text = {
178            let state = self.state.read().await;
179            state.docs.get(uri).map(|d| d.text.clone())
180        };
181        let Some(text) = text else { return };
182        let diagnostics = bynk_ide::diagnose(&text);
183        let lsp_diags: Vec<Diagnostic> = diagnostics
184            .into_iter()
185            .map(|d| make_diagnostic(&d, &text, uri))
186            .collect();
187        let version = {
188            let state = self.state.read().await;
189            state.docs.get(uri).map(|d| d.version)
190        };
191        self.client
192            .publish_diagnostics(uri.clone(), lsp_diags, version)
193            .await;
194    }
195
196    /// v0.24: debounce a project-wide analysis — each call bumps the
197    /// generation; the spawned task runs only if still the latest after the
198    /// delay, so a typing burst produces one analysis.
199    async fn schedule_project_diagnostics(&self) {
200        let generation = {
201            let mut state = self.state.write().await;
202            state.analysis_generation += 1;
203            state.analysis_generation
204        };
205        let this = self.clone();
206        tokio::spawn(async move {
207            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
208            if this.state.read().await.analysis_generation != generation {
209                return;
210            }
211            this.run_project_diagnostics().await;
212        });
213    }
214
215    /// v0.24 (ADR 0052): one project-wide diagnostics round — overlay the
216    /// open buffers over disk, analyse off the async runtime, convert spans
217    /// against the **analysed snapshots**, and publish via the pure
218    /// publish-plan (clears included).
219    async fn run_project_diagnostics(&self) {
220        let (root, src_root, overlay, versions, previously_dirty) = {
221            let state = self.state.read().await;
222            let Some(root) = state.project_root.clone() else {
223                return;
224            };
225            let src_root = root.join(&state.config.src_dir);
226            let canonical_src_root = src_root.canonicalize().unwrap_or_else(|_| src_root.clone());
227            let mut overlay = std::collections::HashMap::new();
228            let mut versions = std::collections::HashMap::new();
229            for (uri, doc) in &state.docs {
230                if let Ok(p) = uri.to_file_path() {
231                    let canonical = p.canonicalize().unwrap_or(p);
232                    // v0.25: capture the version the overlay snapshot came
233                    // from, keyed project-relative like the analysis output.
234                    if let Ok(rel) = canonical.strip_prefix(&canonical_src_root) {
235                        versions.insert(rel.to_path_buf(), doc.version);
236                    }
237                    overlay.insert(canonical, doc.text.clone());
238                }
239            }
240            (root, src_root, overlay, versions, state.published.clone())
241        };
242
243        let analysis_root = src_root.clone();
244        let Ok(result) = tokio::task::spawn_blocking(move || {
245            bynk_ide::diagnose_project(&analysis_root, &overlay)
246        })
247        .await
248        else {
249            return;
250        };
251
252        let mut new_by_uri: std::collections::HashMap<Url, Vec<Diagnostic>> =
253            std::collections::HashMap::new();
254        let mut snapshots = std::collections::HashMap::new();
255        let mut diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>> =
256            std::collections::HashMap::new();
257        for file in &result.files {
258            let abs = src_root.join(&file.source_path);
259            let abs = abs.canonicalize().unwrap_or(abs);
260            let Ok(uri) = Url::from_file_path(&abs) else {
261                continue;
262            };
263            // Spans convert against the snapshot the analysis saw — never a
264            // newer buffer (Settled, v0.24 proposal).
265            let diags: Vec<Diagnostic> = file
266                .diagnostics
267                .iter()
268                .map(|d| make_diagnostic(d, &file.text, &uri))
269                .collect();
270            new_by_uri.insert(uri, diags);
271            diagnostics.insert(file.source_path.clone(), file.diagnostics.clone());
272            snapshots.insert(file.source_path.clone(), file.text.clone());
273        }
274        // v0.25: retain the round's index + snapshots for references/rename
275        // and the binding-correct definition/hover. v0.26: plus the raw
276        // diagnostics, for `codeAction` (the suggestions ride on them).
277        {
278            let analysis = Arc::new(Analysis {
279                src_root: src_root.canonicalize().unwrap_or_else(|_| src_root.clone()),
280                index: result.index.clone(),
281                snapshots,
282                versions,
283                diagnostics,
284                hints: result.hints,
285                requirements: result.requirements,
286                locals: result.locals,
287                expr_types: result.expr_types,
288                unit_sources: result.unit_sources,
289            });
290            self.state.write().await.analysis = Some(analysis);
291        }
292        // Project-level diagnostics with no single owning file surface on
293        // bynk.toml (position 0:0) rather than vanishing.
294        if !result.unattributed.is_empty()
295            && let Ok(toml_uri) = Url::from_file_path(root.join("bynk.toml"))
296        {
297            let entry = new_by_uri.entry(toml_uri).or_default();
298            for d in &result.unattributed {
299                entry.push(Diagnostic {
300                    range: Default::default(),
301                    severity: Some(match d.severity {
302                        bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
303                        bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
304                    }),
305                    code: Some(tower_lsp::lsp_types::NumberOrString::String(
306                        d.error.category.to_string(),
307                    )),
308                    message: d.error.message.clone(),
309                    ..Default::default()
310                });
311            }
312        }
313
314        let (publishes, dirty) = publish::publish_plan(&previously_dirty, new_by_uri);
315        for (uri, diags) in publishes {
316            self.client.publish_diagnostics(uri, diags, None).await;
317        }
318        self.state.write().await.published = dirty;
319    }
320
321    /// Project source root resolved against the active `bynk.toml`'s
322    /// `[paths].src`. Returns `None` when no project root is known (single-
323    /// file mode), in which case cross-file lookups are skipped.
324    async fn project_src_root(&self) -> Option<PathBuf> {
325        let state = self.state.read().await;
326        let root = state.project_root.as_ref()?;
327        Some(root.join(&state.config.src_dir))
328    }
329
330    /// v0.31: the def + use spans of the local under the cursor (def first), or
331    /// `None` if the cursor is not on a local.
332    fn local_sites(
333        &self,
334        analysis: &Analysis,
335        rel: &std::path::Path,
336        offset: usize,
337    ) -> Option<Vec<bynk_syntax::span::Span>> {
338        let text = analysis.snapshots.get(rel)?;
339        let locals = analysis.locals.get(rel)?;
340        crate::locals_nav::local_sites_at(locals, text, offset)
341    }
342
343    /// v0.31 (ADR 0064): the in-scope local bindings at the cursor, as
344    /// `variable` completions, read from the **cached** analysis — so they
345    /// survive the mid-edit buffer the current keystroke produced (the last
346    /// good round's bindings around the cursor are what's wanted). Positions
347    /// convert against the cached snapshot, like the other cached-round reads.
348    async fn locals_completions(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
349        let analysis = self.state.read().await.analysis.clone();
350        let Some(analysis) = analysis else {
351            return Vec::new();
352        };
353        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
354            return Vec::new();
355        };
356        let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
357        else {
358            return Vec::new();
359        };
360        let Some(offset) = crate::position::position_to_offset(text, pos) else {
361            return Vec::new();
362        };
363        bynk_check::locals::locals_at(locals, offset)
364            .into_iter()
365            .map(|b| CompletionItem {
366                label: b.name.clone(),
367                kind: Some(CompletionItemKind::VARIABLE),
368                detail: Some(b.ty.clone()),
369                ..Default::default()
370            })
371            .collect()
372    }
373
374    /// Convert same-file local spans to LSP `Location`s.
375    fn local_locations(
376        &self,
377        analysis: &Analysis,
378        rel: &std::path::Path,
379        spans: &[bynk_syntax::span::Span],
380    ) -> Vec<Location> {
381        let Some(text) = analysis.snapshots.get(rel) else {
382            return Vec::new();
383        };
384        let Ok(uri) = Url::from_file_path(analysis.src_root.join(rel)) else {
385            return Vec::new();
386        };
387        spans
388            .iter()
389            .map(|s| Location {
390                uri: uri.clone(),
391                range: crate::position::span_to_range(text, *s),
392            })
393            .collect()
394    }
395
396    /// Slice 3 (ADR 0063): complete the members of a typed **value** receiver.
397    /// Re-analyses the buffer rewritten so the receiver parses (the trailing
398    /// `.partial` dropped), types the receiver via the retained `expr_types`,
399    /// and maps its type to kernel methods + record fields. Empty when the
400    /// receiver can't be typed (the file has errors — the clean-file ceiling).
401    async fn value_member_completions(
402        &self,
403        uri: &Url,
404        text: &str,
405        offset: usize,
406    ) -> Vec<CompletionItem> {
407        let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(text, offset)
408        else {
409            return Vec::new();
410        };
411        let Some(ty) = self.type_receiver(uri, rewritten, recv_offset).await else {
412            return Vec::new();
413        };
414        let src_root = self.project_src_root().await;
415        completion::value_member_candidates(&ty, text, src_root.as_deref())
416            .into_iter()
417            .map(to_completion_item)
418            .collect()
419    }
420
421    /// v0.32 (ADR 0065): the type of a receiver expression at `recv_offset` in a
422    /// buffer `rewritten` so it parses — re-analyse the overlay and query the
423    /// retained `expr_types`. Shared by value-member completion and signature
424    /// help; `None` when the file doesn't check clean (the clean-file ceiling).
425    async fn type_receiver(
426        &self,
427        uri: &Url,
428        rewritten: String,
429        recv_offset: usize,
430    ) -> Option<bynk_check::checker::Ty> {
431        let src_root = self.project_src_root().await?;
432        let canonical_src_root = src_root.canonicalize().unwrap_or_else(|_| src_root.clone());
433        let cur = uri.to_file_path().ok()?;
434        let cur = cur.canonicalize().unwrap_or(cur);
435        let rel = cur.strip_prefix(&canonical_src_root).ok()?.to_path_buf();
436        // Overlay every open doc, with this one rewritten so it parses.
437        let overlay = {
438            let state = self.state.read().await;
439            let mut ov = std::collections::HashMap::new();
440            for (u, doc) in &state.docs {
441                if let Ok(p) = u.to_file_path() {
442                    let canonical = p.canonicalize().unwrap_or(p);
443                    let t = if u == uri {
444                        rewritten.clone()
445                    } else {
446                        doc.text.clone()
447                    };
448                    ov.insert(canonical, t);
449                }
450            }
451            ov
452        };
453        let result =
454            tokio::task::spawn_blocking(move || bynk_ide::diagnose_project(&src_root, &overlay))
455                .await
456                .ok()?;
457        let (_, entries) = result.expr_types.iter().find(|(p, _)| **p == rel)?;
458        bynk_check::expr_types::type_at_offset(entries, recv_offset).cloned()
459    }
460
461    /// v0.25: the latest analysis, running one synchronously if none has
462    /// completed yet (a request can arrive before the first debounced
463    /// round).
464    async fn ensure_analysis(&self) -> Option<Arc<Analysis>> {
465        if let Some(a) = self.state.read().await.analysis.clone() {
466            return Some(a);
467        }
468        self.run_project_diagnostics().await;
469        self.state.read().await.analysis.clone()
470    }
471
472    /// v0.25: a fresh analysis of the current buffers — rename plans against
473    /// live state, not the last debounced round.
474    async fn fresh_analysis(&self) -> Option<Arc<Analysis>> {
475        self.run_project_diagnostics().await;
476        self.state.read().await.analysis.clone()
477    }
478
479    /// Map a request URI to the analysis' project-relative path.
480    fn uri_to_rel(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
481        let p = uri.to_file_path().ok()?;
482        let canonical = p.canonicalize().unwrap_or(p);
483        canonical
484            .strip_prefix(&analysis.src_root)
485            .ok()
486            .map(|r| r.to_path_buf())
487    }
488
489    /// Slice 6a follow-up (ADR 0095): if `pos` sits on a `uses`/`consumes` unit
490    /// name, the location of that unit's source (its first file, at the top —
491    /// units aren't index symbols, so there is no finer def span to land on).
492    /// Spans come from the live buffer; the target from the round's unit→source
493    /// map. `None` for a first-party/unresolved unit or a non-unit position.
494    async fn unit_reference_definition(&self, uri: &Url, pos: Position) -> Option<Location> {
495        let (text, analysis) = {
496            let s = self.state.read().await;
497            (s.docs.get(uri).map(|d| d.text.clone()), s.analysis.clone())
498        };
499        let (text, analysis) = (text?, analysis?);
500        let offset = cursor_byte_offset(&text, pos);
501        for (unit, span) in crate::symbols::unit_reference_spans(&text) {
502            if span.start <= offset && offset <= span.end {
503                let rel = analysis.unit_sources.get(&unit)?.first()?;
504                let target = Url::from_file_path(analysis.src_root.join(rel)).ok()?;
505                return Some(Location {
506                    uri: target,
507                    range: Range::default(),
508                });
509            }
510        }
511        None
512    }
513
514    /// Convert an index site to an LSP location, spans against the analysed
515    /// snapshot (v0.24 rule).
516    fn site_to_location(
517        analysis: &Analysis,
518        site: &bynk_check::index::SiteRef,
519    ) -> Option<Location> {
520        let text = analysis.snapshots.get(&site.path)?;
521        let abs = analysis.src_root.join(&site.path);
522        let uri = Url::from_file_path(abs).ok()?;
523        Some(Location {
524            uri,
525            range: crate::position::span_to_range(text, site.span),
526        })
527    }
528
529    /// v0.34 (ADR 0067): build a `CallHierarchyItem` for an index symbol from
530    /// its key + definition site. The key is round-tripped through `data` so
531    /// the incoming/outgoing follow-ups resolve straight off it, never
532    /// re-inferring from a position.
533    fn call_hierarchy_item(
534        analysis: &Analysis,
535        key: &bynk_check::index::SymbolKey,
536        def: &bynk_check::index::SiteRef,
537    ) -> Option<CallHierarchyItem> {
538        let location = Self::site_to_location(analysis, def)?;
539        Some(CallHierarchyItem {
540            name: key.name.clone(),
541            kind: lsp_symbol_kind(key.kind),
542            tags: None,
543            detail: Some(key.unit.clone()),
544            uri: location.uri,
545            range: location.range,
546            selection_range: location.range,
547            data: serde_json::to_value(SerKey::from(key)).ok(),
548        })
549    }
550
551    /// The call-site ranges (`fromRanges`) for a call relation, each converted
552    /// against its file's analysed snapshot.
553    fn call_ranges(analysis: &Analysis, sites: &[&bynk_check::index::SiteRef]) -> Vec<Range> {
554        sites
555            .iter()
556            .filter_map(|s| {
557                let text = analysis.snapshots.get(&s.path)?;
558                Some(crate::position::span_to_range(text, s.span))
559            })
560            .collect()
561    }
562
563    /// v0.28 (ADR 0057): the shared body of both semantic-tokens requests —
564    /// resolve the cached round, convert the optional range against the
565    /// analysed snapshot, and run the pure producer. Empty when no round is
566    /// cached or the file is outside the project.
567    async fn semantic_tokens_for(&self, uri: &Url, range: Option<Range>) -> Vec<SemanticToken> {
568        let analysis = { self.state.read().await.analysis.clone() };
569        let Some(analysis) = analysis else {
570            return Vec::new();
571        };
572        let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
573            return Vec::new();
574        };
575        let Some(text) = analysis.snapshots.get(&rel) else {
576            return Vec::new();
577        };
578        let span = match range {
579            None => None,
580            // The requested range converts against the analysed snapshot,
581            // like the spans it is intersected with.
582            Some(r) => {
583                let (Some(start), Some(end)) = (
584                    crate::position::position_to_offset(text, r.start),
585                    crate::position::position_to_offset(text, r.end),
586                ) else {
587                    return Vec::new();
588                };
589                Some(bynk_syntax::span::Span::new(start, end))
590            }
591        };
592        let lt = analysis
593            .locals
594            .get(&rel)
595            .map(|l| crate::locals_nav::local_token_sites(l, text))
596            .unwrap_or_default();
597        crate::index_queries::semantic_tokens(&analysis.index, &lt, &rel, text, span)
598    }
599
600    /// The (analysis, rel-path, snapshot byte offset) for a request
601    /// position — the shared front half of every index-backed handler.
602    async fn index_position(
603        &self,
604        uri: &Url,
605        position: Position,
606        fresh: bool,
607    ) -> Option<(Arc<Analysis>, PathBuf, usize)> {
608        let analysis = if fresh {
609            self.fresh_analysis().await?
610        } else {
611            self.ensure_analysis().await?
612        };
613        let rel = Self::uri_to_rel(&analysis, uri)?;
614        let text = analysis.snapshots.get(&rel)?;
615        let offset = crate::position::position_to_offset(text, position)?;
616        Some((analysis, rel, offset))
617    }
618
619    /// Locate the AST node at the given cursor position by re-parsing the
620    /// document. Returns the textual identifier (if any) and its span.
621    /// Used by hover and definition handlers.
622    async fn identifier_at(
623        &self,
624        uri: &Url,
625        position: Position,
626    ) -> Option<(String, bynk_syntax::span::Span, String)> {
627        let text = {
628            let state = self.state.read().await;
629            state.docs.get(uri)?.text.clone()
630        };
631        let offset = crate::position::position_to_offset(&text, position)?;
632        let tokens = bynk_syntax::lexer::tokenize(&text).ok()?;
633        // Find the token whose span covers `offset`.
634        for t in &tokens {
635            if t.span.start <= offset
636                && offset < t.span.end
637                && matches!(
638                    t.kind,
639                    bynk_syntax::lexer::TokenKind::Ident
640                        | bynk_syntax::lexer::TokenKind::Int
641                        | bynk_syntax::lexer::TokenKind::String
642                        | bynk_syntax::lexer::TokenKind::Bool
643                        | bynk_syntax::lexer::TokenKind::Float
644                        | bynk_syntax::lexer::TokenKind::Result
645                        | bynk_syntax::lexer::TokenKind::Option
646                        | bynk_syntax::lexer::TokenKind::Effect
647                )
648            {
649                let name = text[t.span.start..t.span.end].to_string();
650                return Some((name, t.span, text));
651            }
652        }
653        None
654    }
655}
656
657#[tower_lsp::async_trait]
658impl LanguageServer for Backend {
659    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
660        // Resolve project root from workspace folders or the first folder URI.
661        if let Some(folders) = &params.workspace_folders
662            && let Some(first) = folders.first()
663            && let Ok(path) = first.uri.to_file_path()
664        {
665            let mut state = self.state.write().await;
666            if let Some(root) = Self::find_project_root(&path) {
667                state.config = project::load_config(&root).unwrap_or_default();
668                state.project_root = Some(root);
669            }
670        }
671        Ok(InitializeResult {
672            capabilities: server_capabilities(),
673            server_info: Some(ServerInfo {
674                name: SERVER_NAME.into(),
675                version: Some(SERVER_VERSION.into()),
676            }),
677        })
678    }
679
680    async fn initialized(&self, _: InitializedParams) {
681        let root = { self.state.read().await.project_root.clone() };
682        match root {
683            Some(root) => {
684                self.client
685                    .log_message(
686                        MessageType::INFO,
687                        format!("bynkc-lsp: project root at {}", root.display()),
688                    )
689                    .await;
690            }
691            None => {
692                self.client
693                    .log_message(
694                        MessageType::INFO,
695                        "bynkc-lsp: no bynk.toml found; single-file mode",
696                    )
697                    .await;
698            }
699        }
700    }
701
702    async fn shutdown(&self) -> JsonRpcResult<()> {
703        Ok(())
704    }
705
706    async fn did_open(&self, params: DidOpenTextDocumentParams) {
707        let uri = params.text_document.uri.clone();
708        {
709            let mut state = self.state.write().await;
710            // First open in a single-file context may need to set project root.
711            if state.project_root.is_none()
712                && let Ok(path) = uri.to_file_path()
713                && let Some(root) = Self::find_project_root(&path)
714            {
715                state.config = project::load_config(&root).unwrap_or_default();
716                state.project_root = Some(root);
717            }
718            state.docs.insert(
719                uri.clone(),
720                DocumentState {
721                    text: params.text_document.text,
722                    version: params.text_document.version,
723                },
724            );
725        }
726        self.recompile_and_publish(&uri).await;
727    }
728
729    async fn did_change(&self, params: DidChangeTextDocumentParams) {
730        let uri = params.text_document.uri.clone();
731        {
732            let mut state = self.state.write().await;
733            if let Some(doc) = state.docs.get_mut(&uri)
734                && let Some(change) = params.content_changes.into_iter().next_back()
735            {
736                doc.text = change.text;
737                doc.version = params.text_document.version;
738            }
739        }
740        // Debounce: use the configured value. For simplicity, sleep then
741        // recompile. Multiple rapid changes effectively coalesce because
742        // each tasks reads the latest text at recompile time.
743        let debounce_ms = {
744            let s = self.state.read().await;
745            s.config.diagnostics_debounce_ms
746        };
747        let backend = self.clone();
748        tokio::spawn(async move {
749            tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
750            backend.recompile_and_publish(&uri).await;
751        });
752    }
753
754    async fn did_close(&self, params: DidCloseTextDocumentParams) {
755        let uri = params.text_document.uri;
756        let mut state = self.state.write().await;
757        state.docs.remove(&uri);
758    }
759
760    async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
761        let uri = params.text_document_position_params.text_document.uri;
762        let pos = params.text_document_position_params.position;
763        // v0.25 rider: binding-correct hover — find the definition through
764        // the index, then describe it in its defining file (names are unique
765        // per file, so the per-file lookup is exact). Falls back to the
766        // legacy name-matching path for not-yet-indexed symbol kinds.
767        if let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await
768            && let Some((key, def)) =
769                crate::index_queries::definition_at(&analysis.index, &rel, offset)
770            && let Some(def_text) = analysis.snapshots.get(&def.path)
771            && let Some(content) = crate::symbols::describe_symbol(def_text, &key.name)
772        {
773            return Ok(Some(Hover {
774                contents: HoverContents::Markup(MarkupContent {
775                    kind: MarkupKind::Markdown,
776                    value: content,
777                }),
778                range: None,
779            }));
780        }
781        let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
782            return Ok(None);
783        };
784        // Local lookup first (fast path).
785        let content = match crate::symbols::describe_symbol(&text, &name) {
786            Some(local) => local,
787            None => {
788                // Fall back to a project-wide scan (v1.1), then the embedded
789                // first-party sources (slice 9) — so `uses` / `consumes` names
790                // resolve across file boundaries (§3.4) and stdlib/surface
791                // symbols surface their signature + doc too.
792                let src_root = self.project_src_root().await;
793                match src_root
794                    .and_then(|root| crate::symbols::describe_symbol_cross_file(&root, &uri, &name))
795                    .map(|(_other_uri, desc)| desc)
796                    .or_else(|| crate::symbols::describe_firstparty_symbol(&name))
797                {
798                    Some(desc) => desc,
799                    None => return Ok(None),
800                }
801            }
802        };
803        Ok(Some(Hover {
804            contents: HoverContents::Markup(MarkupContent {
805                kind: MarkupKind::Markdown,
806                value: content,
807            }),
808            range: None,
809        }))
810    }
811
812    /// v0.32 (ADR 0065): signature help for the call under the cursor.
813    async fn signature_help(
814        &self,
815        params: SignatureHelpParams,
816    ) -> JsonRpcResult<Option<SignatureHelp>> {
817        let uri = params.text_document_position_params.text_document.uri;
818        let pos = params.text_document_position_params.position;
819        let text = {
820            let s = self.state.read().await;
821            s.docs.get(&uri).map(|d| d.text.clone())
822        };
823        let Some(text) = text else { return Ok(None) };
824        let offset = cursor_byte_offset(&text, pos);
825        let Some(ctx) = crate::signature_help::call_context(&text, offset) else {
826            return Ok(None);
827        };
828        let src_root = self.project_src_root().await;
829        // Name callees (free fns, statics, capability ops, of/unsafe) — lexical.
830        let label =
831            match crate::signature_help::resolve_label(&ctx.callee, &text, src_root.as_deref()) {
832                Some(l) => Some(l),
833                // v0.32 slice 2: a value-receiver method (`xs.fold(`) — type the
834                // receiver via the rewrite + re-analyse, then the kernel signature.
835                None => match crate::signature_help::value_receiver_method(&ctx.callee) {
836                    Some((_, method)) => {
837                        if let Some((rewritten, recv_offset)) =
838                            crate::signature_help::value_receiver_rewrite(
839                                &text,
840                                &ctx.callee,
841                                ctx.open_paren,
842                                offset,
843                            )
844                            && let Some(ty) = self.type_receiver(&uri, rewritten, recv_offset).await
845                        {
846                            crate::signature_help::kernel_method_signature(&ty, method)
847                        } else {
848                            None
849                        }
850                    }
851                    None => None,
852                },
853            };
854        let Some(label) = label else { return Ok(None) };
855        let active = ctx.active_param as u32;
856        let parameters: Vec<ParameterInformation> = crate::signature_help::param_ranges(&label)
857            .into_iter()
858            .map(|(s, e)| ParameterInformation {
859                label: ParameterLabel::LabelOffsets([s as u32, e as u32]),
860                documentation: None,
861            })
862            .collect();
863        Ok(Some(SignatureHelp {
864            signatures: vec![SignatureInformation {
865                label,
866                documentation: None,
867                parameters: Some(parameters),
868                active_parameter: Some(active),
869            }],
870            active_signature: Some(0),
871            active_parameter: Some(active),
872        }))
873    }
874
875    /// v0.33 (ADR 0066): a reference-count lens above each top-level definition,
876    /// clickable to peek the references. Served from the cached round.
877    async fn code_lens(&self, params: CodeLensParams) -> JsonRpcResult<Option<Vec<CodeLens>>> {
878        let uri = params.text_document.uri;
879        let analysis = { self.state.read().await.analysis.clone() };
880        let Some(analysis) = analysis else {
881            return Ok(Some(Vec::new()));
882        };
883        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
884            return Ok(Some(Vec::new()));
885        };
886        let Some(text) = analysis.snapshots.get(&rel) else {
887            return Ok(Some(Vec::new()));
888        };
889        let lenses: Vec<CodeLens> = crate::index_queries::code_lenses(&analysis.index, &rel)
890            .into_iter()
891            .map(|(def, refs)| {
892                let range = crate::position::span_to_range(text, def.span);
893                let locations: Vec<Location> = refs
894                    .iter()
895                    .filter_map(|r| Self::site_to_location(&analysis, r))
896                    .collect();
897                let n = refs.len();
898                CodeLens {
899                    range,
900                    command: Some(Command {
901                        title: format!("{n} reference{}", if n == 1 { "" } else { "s" }),
902                        // Peek the references on click — a standard client command,
903                        // so no extension support is required.
904                        command: "editor.action.showReferences".to_string(),
905                        arguments: Some(vec![
906                            serde_json::to_value(&uri).unwrap_or_default(),
907                            serde_json::to_value(range.start).unwrap_or_default(),
908                            serde_json::to_value(&locations).unwrap_or_default(),
909                        ]),
910                    }),
911                    data: None,
912                }
913            })
914            .collect();
915        Ok(Some(lenses))
916    }
917
918    async fn prepare_call_hierarchy(
919        &self,
920        params: CallHierarchyPrepareParams,
921    ) -> JsonRpcResult<Option<Vec<CallHierarchyItem>>> {
922        let uri = params.text_document_position_params.text_document.uri;
923        let pos = params.text_document_position_params.position;
924        let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await else {
925            return Ok(None);
926        };
927        let Some((key, def)) =
928            crate::index_queries::prepare_call_hierarchy(&analysis.index, &rel, offset)
929        else {
930            return Ok(None);
931        };
932        Ok(Self::call_hierarchy_item(&analysis, key, def).map(|item| vec![item]))
933    }
934
935    async fn incoming_calls(
936        &self,
937        params: CallHierarchyIncomingCallsParams,
938    ) -> JsonRpcResult<Option<Vec<CallHierarchyIncomingCall>>> {
939        let analysis = { self.state.read().await.analysis.clone() };
940        let Some(analysis) = analysis else {
941            return Ok(Some(Vec::new()));
942        };
943        let Some(key) = SerKey::read(&params.item.data) else {
944            return Ok(Some(Vec::new()));
945        };
946        let calls = crate::index_queries::incoming_calls(&analysis.index, &key)
947            .into_iter()
948            .filter_map(|rel| {
949                let from = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
950                let from_ranges = Self::call_ranges(&analysis, &rel.sites);
951                Some(CallHierarchyIncomingCall { from, from_ranges })
952            })
953            .collect();
954        Ok(Some(calls))
955    }
956
957    async fn outgoing_calls(
958        &self,
959        params: CallHierarchyOutgoingCallsParams,
960    ) -> JsonRpcResult<Option<Vec<CallHierarchyOutgoingCall>>> {
961        let analysis = { self.state.read().await.analysis.clone() };
962        let Some(analysis) = analysis else {
963            return Ok(Some(Vec::new()));
964        };
965        let Some(key) = SerKey::read(&params.item.data) else {
966            return Ok(Some(Vec::new()));
967        };
968        let calls = crate::index_queries::outgoing_calls(&analysis.index, &key)
969            .into_iter()
970            .filter_map(|rel| {
971                let to = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
972                let from_ranges = Self::call_ranges(&analysis, &rel.sites);
973                Some(CallHierarchyOutgoingCall { to, from_ranges })
974            })
975            .collect();
976        Ok(Some(calls))
977    }
978
979    /// v0.35 (ADR 0068): `textDocument/implementation` — on a capability
980    /// symbol (its declaration, a `given Cap` use, or a `provides Cap` use),
981    /// the providers that implement it. `None` for any other symbol (the
982    /// reverse, provider → capability, is served by goto-definition).
983    async fn goto_implementation(
984        &self,
985        params: GotoImplementationParams,
986    ) -> JsonRpcResult<Option<GotoImplementationResponse>> {
987        let uri = params.text_document_position_params.text_document.uri;
988        let pos = params.text_document_position_params.position;
989        let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await else {
990            return Ok(None);
991        };
992        let Some((key, _)) = analysis.index.symbol_at(&rel, offset) else {
993            return Ok(None);
994        };
995        if key.kind != bynk_check::index::SymbolKind::Capability {
996            return Ok(None);
997        }
998        let locations: Vec<Location> = crate::index_queries::implementations(&analysis.index, key)
999            .into_iter()
1000            .filter_map(|d| Self::site_to_location(&analysis, d))
1001            .collect();
1002        if locations.is_empty() {
1003            return Ok(None);
1004        }
1005        Ok(Some(GotoDefinitionResponse::Array(locations)))
1006    }
1007
1008    /// Slice 6: `textDocument/typeDefinition` — from a value at the cursor to the
1009    /// definition of its (user-declared) type. Reads the value's type from the
1010    /// round's `expr_types`, unwraps it to a `Named` target, and returns that
1011    /// type's definition site(s). `None` for a built-in/function/actor type, or
1012    /// a cursor not on a typed expression in a clean round.
1013    async fn goto_type_definition(
1014        &self,
1015        params: GotoTypeDefinitionParams,
1016    ) -> JsonRpcResult<Option<GotoTypeDefinitionResponse>> {
1017        let uri = params.text_document_position_params.text_document.uri;
1018        let pos = params.text_document_position_params.position;
1019        let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await else {
1020            return Ok(None);
1021        };
1022        let Some(entries) = analysis.expr_types.get(&rel) else {
1023            return Ok(None);
1024        };
1025        let Some(ty) = bynk_check::expr_types::type_at_offset(entries, offset) else {
1026            return Ok(None);
1027        };
1028        let Some(name) = crate::index_queries::named_type_target(ty) else {
1029            return Ok(None);
1030        };
1031        let locations: Vec<Location> =
1032            crate::index_queries::type_definitions_named(&analysis.index, name)
1033                .into_iter()
1034                .filter_map(|d| Self::site_to_location(&analysis, d))
1035                .collect();
1036        if locations.is_empty() {
1037            return Ok(None);
1038        }
1039        Ok(Some(GotoDefinitionResponse::Array(locations)))
1040    }
1041
1042    /// Slice 6b (ADR 0095): `textDocument/documentLink` — `uses`/`consumes` unit
1043    /// names are clickable to the unit's source. Spans come from parsing the live
1044    /// buffer; the target is the unit's first source file from the round's
1045    /// unit→source map. A first-party `uses` (embedded, no on-disk file) or an
1046    /// unresolved unit yields no link.
1047    async fn document_link(
1048        &self,
1049        params: DocumentLinkParams,
1050    ) -> JsonRpcResult<Option<Vec<DocumentLink>>> {
1051        let uri = params.text_document.uri;
1052        let (text, analysis) = {
1053            let s = self.state.read().await;
1054            (s.docs.get(&uri).map(|d| d.text.clone()), s.analysis.clone())
1055        };
1056        let (Some(text), Some(analysis)) = (text, analysis) else {
1057            return Ok(None);
1058        };
1059        let links: Vec<DocumentLink> = crate::symbols::unit_reference_spans(&text)
1060            .into_iter()
1061            .filter_map(|(unit, span)| {
1062                let rel = analysis.unit_sources.get(&unit)?.first()?;
1063                let target = Url::from_file_path(analysis.src_root.join(rel)).ok()?;
1064                Some(DocumentLink {
1065                    range: crate::position::span_to_range(&text, span),
1066                    target: Some(target),
1067                    tooltip: Some(format!("Open unit `{unit}`")),
1068                    data: None,
1069                })
1070            })
1071            .collect();
1072        Ok((!links.is_empty()).then_some(links))
1073    }
1074
1075    async fn completion(
1076        &self,
1077        params: CompletionParams,
1078    ) -> JsonRpcResult<Option<CompletionResponse>> {
1079        let uri = params.text_document_position.text_document.uri;
1080        let pos = params.text_document_position.position;
1081        let text = {
1082            let s = self.state.read().await;
1083            s.docs.get(&uri).map(|d| d.text.clone())
1084        };
1085        let Some(text) = text else { return Ok(None) };
1086        // The line up to the cursor — the context the completion keys off.
1087        let line_prefix = text
1088            .lines()
1089            .nth(pos.line as usize)
1090            .map(|l| {
1091                let end = (pos.character as usize).min(l.len());
1092                l.get(..end).unwrap_or(l)
1093            })
1094            .unwrap_or("")
1095            .to_string();
1096        let src_root = self.project_src_root().await;
1097        let candidates = completion::complete(&line_prefix, &text, src_root.as_deref());
1098        let mut items: Vec<CompletionItem> =
1099            candidates.into_iter().map(to_completion_item).collect();
1100        // ADR 0064/0093 D3: offer in-scope locals/params at keyword position
1101        // (alongside keywords) and at expression position (alongside the
1102        // constructors + type names `complete()` now yields there). Both are
1103        // places a value or name can begin; the two positions are disjoint.
1104        if completion::is_keyword_position(&line_prefix)
1105            || completion::is_expression_position(&line_prefix)
1106        {
1107            items.extend(self.locals_completions(&uri, pos).await);
1108        }
1109        if items.is_empty() {
1110            // Slice 3: a lowercase `receiver.` is a value receiver — type it by
1111            // re-analysing the rewritten buffer and offer its members. (Value
1112            // members name no declared symbol, so they carry no resolve data.)
1113            let offset = cursor_byte_offset(&text, pos);
1114            let value_items = self.value_member_completions(&uri, &text, offset).await;
1115            return Ok((!value_items.is_empty()).then_some(CompletionResponse::Array(value_items)));
1116        }
1117        // Slice 5: stash the doc URI so `completion_resolve` can attach lazy docs.
1118        stamp_resolve_data(&mut items, &uri);
1119        Ok(Some(CompletionResponse::Array(items)))
1120    }
1121
1122    /// Slice 5: fill in hover-quality `documentation` for the focused completion
1123    /// item, reusing the hover renderer (`symbols::describe_symbol`, local then
1124    /// cross-file — §3.4). The originating doc URI is read from the item's
1125    /// `data` (a resolve request carries only the item, not a position). A no-op
1126    /// for an item that names no declared symbol (a keyword, kernel method, or
1127    /// local) — its one-line `detail` already suffices.
1128    async fn completion_resolve(&self, mut item: CompletionItem) -> JsonRpcResult<CompletionItem> {
1129        if item.documentation.is_some() {
1130            return Ok(item);
1131        }
1132        let Some(uri) = item
1133            .data
1134            .as_ref()
1135            .and_then(|d| d.get("uri"))
1136            .and_then(serde_json::Value::as_str)
1137            .and_then(|s| Url::parse(s).ok())
1138        else {
1139            return Ok(item);
1140        };
1141        let local = {
1142            let s = self.state.read().await;
1143            s.docs.get(&uri).map(|d| d.text.clone())
1144        };
1145        let doc = match local
1146            .as_deref()
1147            .and_then(|t| crate::symbols::describe_symbol(t, &item.label))
1148        {
1149            Some(md) => Some(md),
1150            None => self
1151                .project_src_root()
1152                .await
1153                .and_then(|root| {
1154                    crate::symbols::describe_symbol_cross_file(&root, &uri, &item.label)
1155                })
1156                .map(|(_uri, md)| md)
1157                // Slice 9: stdlib/surface symbols (e.g. a `uses bynk.list` combinator)
1158                // live in the embedded first-party sources, not the project's files.
1159                .or_else(|| crate::symbols::describe_firstparty_symbol(&item.label)),
1160        };
1161        if let Some(md) = doc {
1162            item.documentation = Some(Documentation::MarkupContent(MarkupContent {
1163                kind: MarkupKind::Markdown,
1164                value: md,
1165            }));
1166        }
1167        Ok(item)
1168    }
1169
1170    async fn goto_definition(
1171        &self,
1172        params: GotoDefinitionParams,
1173    ) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1174        let uri = params
1175            .text_document_position_params
1176            .text_document
1177            .uri
1178            .clone();
1179        let pos = params.text_document_position_params.position;
1180        // v0.25 rider: binding-correct definition via the index (fixes the
1181        // name-collision mis-navigation of the string-matching path). The
1182        // legacy path remains as fallback for not-yet-indexed symbol kinds
1183        // (locals, methods, fields, ops).
1184        if let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await {
1185            if let Some((_, def)) =
1186                crate::index_queries::definition_at(&analysis.index, &rel, offset)
1187                && let Some(location) = Self::site_to_location(&analysis, def)
1188            {
1189                return Ok(Some(GotoDefinitionResponse::Scalar(location)));
1190            }
1191            // v0.31: a local binding — scope-correct definition (before the
1192            // string-matching fallback, which can't tell scopes apart).
1193            if let Some(text) = analysis.snapshots.get(&rel)
1194                && let Some(locals) = analysis.locals.get(&rel)
1195                && let Some(def) = crate::locals_nav::local_definition_at(locals, text, offset)
1196                && let Some(location) = self
1197                    .local_locations(&analysis, &rel, &[def])
1198                    .into_iter()
1199                    .next()
1200            {
1201                return Ok(Some(GotoDefinitionResponse::Scalar(location)));
1202            }
1203        }
1204        // Slice 6a follow-up (ADR 0095): the cursor on a `uses`/`consumes` unit
1205        // name jumps to that unit's source. Units aren't index symbols, so the
1206        // unit→source map resolves them; runs before the name-matching path so a
1207        // unit segment can't be mistaken for a like-named type.
1208        if let Some(location) = self.unit_reference_definition(&uri, pos).await {
1209            return Ok(Some(GotoDefinitionResponse::Scalar(location)));
1210        }
1211        let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
1212            return Ok(None);
1213        };
1214        if let Some(decl_span) = crate::symbols::find_declaration_span(&text, &name) {
1215            let range = crate::position::span_to_range(&text, decl_span);
1216            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
1217                uri,
1218                range,
1219            })));
1220        }
1221        // Cross-file fallback (v1.1; LSP spec §3.4).
1222        if let Some(root) = self.project_src_root().await
1223            && let Some(found) = crate::symbols::find_declaration_cross_file(&root, &uri, &name)
1224        {
1225            let range = crate::position::span_to_range(&found.source, found.span);
1226            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
1227                uri: found.uri,
1228                range,
1229            })));
1230        }
1231        Ok(None)
1232    }
1233
1234    async fn formatting(
1235        &self,
1236        params: DocumentFormattingParams,
1237    ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1238        let uri = params.text_document.uri;
1239        let text = {
1240            let s = self.state.read().await;
1241            s.docs.get(&uri).map(|d| d.text.clone())
1242        };
1243        let Some(text) = text else { return Ok(None) };
1244        let opts = {
1245            let s = self.state.read().await;
1246            s.config.format_options()
1247        };
1248        match bynk_fmt::format_source(&text, &opts) {
1249            Ok(formatted) => {
1250                if formatted == text {
1251                    Ok(Some(Vec::new()))
1252                } else {
1253                    // Replace the entire document.
1254                    let end_pos = crate::position::end_position(&text);
1255                    Ok(Some(vec![TextEdit {
1256                        range: Range {
1257                            start: Position::new(0, 0),
1258                            end: end_pos,
1259                        },
1260                        new_text: formatted,
1261                    }]))
1262                }
1263            }
1264            Err(_) => {
1265                // Formatting failed (parse error). Return no edits; the
1266                // diagnostics flow will surface the parse error.
1267                Ok(Some(Vec::new()))
1268            }
1269        }
1270    }
1271
1272    async fn range_formatting(
1273        &self,
1274        params: DocumentRangeFormattingParams,
1275    ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1276        // Best-effort: format the whole document. Per spec, range
1277        // formatting may return edits wider than the requested range.
1278        self.formatting(DocumentFormattingParams {
1279            text_document: params.text_document,
1280            options: params.options,
1281            work_done_progress_params: params.work_done_progress_params,
1282        })
1283        .await
1284    }
1285
1286    async fn document_symbol(
1287        &self,
1288        params: DocumentSymbolParams,
1289    ) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
1290        // v1.1 — outline view + Cmd-Shift-O. See `design/bynk-lsp-spec.md` §3.7.
1291        let uri = params.text_document.uri;
1292        let text = {
1293            let s = self.state.read().await;
1294            s.docs.get(&uri).map(|d| d.text.clone())
1295        };
1296        let Some(text) = text else { return Ok(None) };
1297        let syms = crate::document_symbols::outline(&text);
1298        if syms.is_empty() {
1299            return Ok(None);
1300        }
1301        Ok(Some(DocumentSymbolResponse::Nested(syms)))
1302    }
1303
1304    /// v0.37 (ADR 0070): `textDocument/foldingRange` — structural folds + comment
1305    /// runs from the recovered AST (no analysis round).
1306    async fn folding_range(
1307        &self,
1308        params: FoldingRangeParams,
1309    ) -> JsonRpcResult<Option<Vec<FoldingRange>>> {
1310        let uri = params.text_document.uri;
1311        let text = {
1312            let s = self.state.read().await;
1313            s.docs.get(&uri).map(|d| d.text.clone())
1314        };
1315        let Some(text) = text else { return Ok(None) };
1316        Ok(Some(crate::structure::folding_ranges(&text)))
1317    }
1318
1319    /// v0.37 (ADR 0070): `textDocument/selectionRange` — the enclosing-node
1320    /// chain (innermost first) for each requested position.
1321    async fn selection_range(
1322        &self,
1323        params: SelectionRangeParams,
1324    ) -> JsonRpcResult<Option<Vec<SelectionRange>>> {
1325        let uri = params.text_document.uri;
1326        let text = {
1327            let s = self.state.read().await;
1328            s.docs.get(&uri).map(|d| d.text.clone())
1329        };
1330        let Some(text) = text else { return Ok(None) };
1331        Ok(Some(crate::structure::selection_ranges(
1332            &text,
1333            &params.positions,
1334        )))
1335    }
1336
1337    async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1338        let uri = params.text_document_position.text_document.uri;
1339        let pos = params.text_document_position.position;
1340        let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await else {
1341            return Ok(None);
1342        };
1343        let include_decl = params.context.include_declaration;
1344        if let Some(sites) =
1345            crate::index_queries::sites_for(&analysis.index, &rel, offset, include_decl)
1346        {
1347            let locations: Vec<Location> = sites
1348                .into_iter()
1349                .filter_map(|site| Self::site_to_location(&analysis, site))
1350                .collect();
1351            return Ok(Some(locations));
1352        }
1353        // v0.31: a local binding — its def + uses, resolved from the snapshot.
1354        if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
1355            let spans = if include_decl {
1356                &spans[..]
1357            } else {
1358                &spans[1..]
1359            }; // def first
1360            let locations = self.local_locations(&analysis, &rel, spans);
1361            return Ok(Some(locations));
1362        }
1363        Ok(None)
1364    }
1365
1366    /// v0.26 (ADR 0054): quick-fixes from structured suggestions. Served
1367    /// from the **cached** analysis round only (never a fresh run — slow,
1368    /// and it could disagree with the squiggles the client is showing): a
1369    /// request before the first round, or for a file outside the project,
1370    /// returns the empty list.
1371    async fn code_action(
1372        &self,
1373        params: CodeActionParams,
1374    ) -> JsonRpcResult<Option<CodeActionResponse>> {
1375        let uri = params.text_document.uri;
1376        let analysis = { self.state.read().await.analysis.clone() };
1377        let Some(analysis) = analysis else {
1378            return Ok(Some(Vec::new()));
1379        };
1380        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1381            return Ok(Some(Vec::new()));
1382        };
1383        let (Some(text), Some(diags)) =
1384            (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
1385        else {
1386            return Ok(Some(Vec::new()));
1387        };
1388        // The request range converts against the analysed snapshot (the
1389        // v0.24 rule), like the spans it is intersected with.
1390        let (Some(start), Some(end)) = (
1391            crate::position::position_to_offset(text, params.range.start),
1392            crate::position::position_to_offset(text, params.range.end),
1393        ) else {
1394            return Ok(Some(Vec::new()));
1395        };
1396        let actions = crate::code_actions::quick_fixes(
1397            text,
1398            diags,
1399            bynk_syntax::span::Span::new(start, end),
1400            &uri,
1401            analysis.versions.get(&rel).copied(),
1402        );
1403        Ok(Some(actions))
1404    }
1405
1406    /// v0.27 (ADR 0056): inferred-type inlay hints for the visible range,
1407    /// served from the cached round only — no cached round (pre-first-
1408    /// analysis, non-project file) returns the empty list. Positions
1409    /// convert against the analysed snapshot (the v0.24 rule).
1410    async fn inlay_hint(&self, params: InlayHintParams) -> JsonRpcResult<Option<Vec<InlayHint>>> {
1411        let uri = params.text_document.uri;
1412        let analysis = { self.state.read().await.analysis.clone() };
1413        let Some(analysis) = analysis else {
1414            return Ok(Some(Vec::new()));
1415        };
1416        let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1417            return Ok(Some(Vec::new()));
1418        };
1419        let Some(text) = analysis.snapshots.get(&rel) else {
1420            return Ok(Some(Vec::new()));
1421        };
1422        // The visible range converts against the analysed snapshot, like
1423        // the hint spans it is intersected with.
1424        let (Some(start), Some(end)) = (
1425            crate::position::position_to_offset(text, params.range.start),
1426            crate::position::position_to_offset(text, params.range.end),
1427        ) else {
1428            return Ok(Some(Vec::new()));
1429        };
1430        let visible = bynk_syntax::span::Span::new(start, end);
1431        // v0.27: inferred-type hints. v0.99: plus the materializable ghost
1432        // `given` hints for uncovered capability requirements. A file may carry
1433        // one set without the other, so each defaults to empty independently.
1434        let mut hints = analysis
1435            .hints
1436            .get(&rel)
1437            .map(|h| crate::inlay_hints::inlay_hints(text, h, visible))
1438            .unwrap_or_default();
1439        if let Some(reqs) = analysis.requirements.get(&rel) {
1440            hints.extend(crate::inlay_hints::given_hints(text, reqs, visible));
1441        }
1442        Ok(Some(hints))
1443    }
1444
1445    /// v0.28 (ADR 0057): semantic tokens for the whole document, served
1446    /// from the cached round only (no cached round / non-project file →
1447    /// empty), positions against the analysed snapshot (the v0.24 rule).
1448    async fn semantic_tokens_full(
1449        &self,
1450        params: SemanticTokensParams,
1451    ) -> JsonRpcResult<Option<SemanticTokensResult>> {
1452        let data = self
1453            .semantic_tokens_for(&params.text_document.uri, None)
1454            .await;
1455        Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1456            result_id: None,
1457            data,
1458        })))
1459    }
1460
1461    /// v0.28 (ADR 0057): the `…/range` variant — the same pure read,
1462    /// filtered to tokens overlapping the requested range.
1463    async fn semantic_tokens_range(
1464        &self,
1465        params: SemanticTokensRangeParams,
1466    ) -> JsonRpcResult<Option<SemanticTokensRangeResult>> {
1467        let data = self
1468            .semantic_tokens_for(&params.text_document.uri, Some(params.range))
1469            .await;
1470        Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1471            result_id: None,
1472            data,
1473        })))
1474    }
1475
1476    /// v0.26 rider (ADR 0055): project-wide symbol search — the index's
1477    /// definitions, filtered by the query.
1478    async fn symbol(
1479        &self,
1480        params: WorkspaceSymbolParams,
1481    ) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
1482        let Some(analysis) = self.ensure_analysis().await else {
1483            return Ok(None);
1484        };
1485        let matches = crate::index_queries::workspace_symbols(&analysis.index, &params.query);
1486        let symbols: Vec<SymbolInformation> = matches
1487            .into_iter()
1488            .filter_map(|(key, def)| {
1489                let location = Self::site_to_location(&analysis, def)?;
1490                #[allow(deprecated)]
1491                Some(SymbolInformation {
1492                    name: key.name.clone(),
1493                    kind: lsp_symbol_kind(key.kind),
1494                    tags: None,
1495                    deprecated: None,
1496                    location,
1497                    container_name: Some(key.unit.clone()),
1498                })
1499            })
1500            .collect();
1501        Ok(Some(symbols))
1502    }
1503
1504    /// v0.26 rider (ADR 0055): the symbol-at-cursor's occurrences in the
1505    /// active file. `kind` is omitted — the index does not distinguish read
1506    /// from write references.
1507    async fn document_highlight(
1508        &self,
1509        params: DocumentHighlightParams,
1510    ) -> JsonRpcResult<Option<Vec<DocumentHighlight>>> {
1511        let uri = params.text_document_position_params.text_document.uri;
1512        let pos = params.text_document_position_params.position;
1513        let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await else {
1514            return Ok(None);
1515        };
1516        let Some(text) = analysis.snapshots.get(&rel) else {
1517            return Ok(None);
1518        };
1519        if let Some(sites) =
1520            crate::index_queries::document_highlights(&analysis.index, &rel, offset)
1521        {
1522            let highlights: Vec<DocumentHighlight> = sites
1523                .into_iter()
1524                .map(|s| DocumentHighlight {
1525                    range: crate::position::span_to_range(text, s.span),
1526                    kind: None,
1527                })
1528                .collect();
1529            return Ok(Some(highlights));
1530        }
1531        // v0.31: a local binding's occurrences (def + uses) in the file.
1532        if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
1533            let highlights = spans
1534                .iter()
1535                .map(|s| DocumentHighlight {
1536                    range: crate::position::span_to_range(text, *s),
1537                    kind: None,
1538                })
1539                .collect();
1540            return Ok(Some(highlights));
1541        }
1542        Ok(None)
1543    }
1544
1545    async fn prepare_rename(
1546        &self,
1547        params: TextDocumentPositionParams,
1548    ) -> JsonRpcResult<Option<PrepareRenameResponse>> {
1549        let uri = params.text_document.uri;
1550        let pos = params.position;
1551        // Refuse (None) for anything the index does not cover — locals,
1552        // methods, record fields, capability ops, unit names — rather than
1553        // falling through to a partial or name-matched rename.
1554        let Some((analysis, rel, offset)) = self.index_position(&uri, pos, false).await else {
1555            return Ok(None);
1556        };
1557        let Some((key, site)) = crate::index_queries::prepare_rename(&analysis.index, &rel, offset)
1558        else {
1559            return Ok(None);
1560        };
1561        let Some(text) = analysis.snapshots.get(&rel) else {
1562            return Ok(None);
1563        };
1564        Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
1565            range: crate::position::span_to_range(text, site.span),
1566            placeholder: key.name.clone(),
1567        }))
1568    }
1569
1570    async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
1571        let uri = params.text_document_position.text_document.uri;
1572        let pos = params.text_document_position.position;
1573        let new_name = params.new_name;
1574        let refused = |msg: String| tower_lsp::jsonrpc::Error {
1575            code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
1576            message: msg.into(),
1577            data: None,
1578        };
1579        // Plan against a *fresh* analysis of the current buffers, so the
1580        // edits and the captured versions describe live state.
1581        let Some((analysis, rel, offset)) = self.index_position(&uri, pos, true).await else {
1582            return Err(refused("rename requires a project (bynk.toml)".into()));
1583        };
1584        let plan = crate::index_queries::plan_rename(&analysis.index, &rel, offset, &new_name)
1585            .map_err(refused)?;
1586
1587        // Validator 1 + 2 input: re-analyse with the edits applied. Every
1588        // snapshot is pinned via the overlay so the re-analysis differs from
1589        // the plan's baseline only by the edits themselves.
1590        let mut overlay = std::collections::HashMap::new();
1591        for (rel_path, text) in &analysis.snapshots {
1592            let edited = match plan.edits.get(rel_path) {
1593                Some(spans) => crate::index_queries::apply_edits(text, spans, &plan.new_name),
1594                None => text.clone(),
1595            };
1596            let abs = analysis.src_root.join(rel_path);
1597            let abs = abs.canonicalize().unwrap_or(abs);
1598            overlay.insert(abs, edited);
1599        }
1600        let analysis_root = analysis.src_root.clone();
1601        let Ok(post) = tokio::task::spawn_blocking(move || {
1602            bynk_ide::diagnose_project(&analysis_root, &overlay)
1603        })
1604        .await
1605        else {
1606            return Err(refused("rename validation failed to run".into()));
1607        };
1608
1609        // Validator 1 — collisions: refuse on any new diagnostic.
1610        let post_diags: Vec<(PathBuf, String)> = post
1611            .files
1612            .iter()
1613            .flat_map(|f| {
1614                f.diagnostics
1615                    .iter()
1616                    .map(|d| (f.source_path.clone(), d.error.category.to_string()))
1617            })
1618            .collect();
1619        crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags)
1620            .map_err(refused)?;
1621
1622        // Validator 2 — capture/escape: the re-built index must be the old
1623        // index modulo the rename; a silent re-binding has no diagnostic.
1624        if !crate::index_queries::index_unchanged_modulo_rename(&analysis.index, &post.index, &plan)
1625        {
1626            return Err(refused(format!(
1627                "renaming `{}` to `{new_name}` would silently re-bind another name — refused",
1628                plan.key.name
1629            )));
1630        }
1631
1632        // Versioned edits: the client rejects the rename if a buffer drifted
1633        // past the analysed version rather than mis-applying it.
1634        let mut document_edits: Vec<TextDocumentEdit> = Vec::new();
1635        for (rel_path, spans) in &plan.edits {
1636            let Some(text) = analysis.snapshots.get(rel_path) else {
1637                continue;
1638            };
1639            let abs = analysis.src_root.join(rel_path);
1640            let Ok(file_uri) = Url::from_file_path(&abs) else {
1641                continue;
1642            };
1643            let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = spans
1644                .iter()
1645                .map(|span| {
1646                    OneOf::Left(TextEdit {
1647                        range: crate::position::span_to_range(text, *span),
1648                        new_text: plan.new_name.clone(),
1649                    })
1650                })
1651                .collect();
1652            document_edits.push(TextDocumentEdit {
1653                text_document: OptionalVersionedTextDocumentIdentifier {
1654                    uri: file_uri,
1655                    version: analysis.versions.get(rel_path).copied(),
1656                },
1657                edits,
1658            });
1659        }
1660        Ok(Some(WorkspaceEdit {
1661            changes: None,
1662            document_changes: Some(DocumentChanges::Edits(document_edits)),
1663            change_annotations: None,
1664        }))
1665    }
1666
1667    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1668        // For every changed `.bynk` file we have open, refresh diagnostics.
1669        let mut uris_to_refresh = Vec::new();
1670        {
1671            let state = self.state.read().await;
1672            for ev in &params.changes {
1673                if state.docs.contains_key(&ev.uri) {
1674                    uris_to_refresh.push(ev.uri.clone());
1675                }
1676            }
1677        }
1678        for uri in uris_to_refresh {
1679            self.recompile_and_publish(&uri).await;
1680        }
1681    }
1682}
1683
1684/// The advertised capability set — `design/bynk-lsp-spec.md` §4.3. Split out
1685/// of `initialize` so the advertisement is unit-testable without transport.
1686fn server_capabilities() -> ServerCapabilities {
1687    ServerCapabilities {
1688        text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
1689        hover_provider: Some(HoverProviderCapability::Simple(true)),
1690        definition_provider: Some(OneOf::Left(true)),
1691        // v0.17: completion for `consumes` units and `given` /
1692        // `consumes U { … }` capabilities. Trigger on the space after a
1693        // keyword, the `{` of a selected-capability list, and `,`. The `.`
1694        // auto-fires the name- and value-receiver member contexts (ADR 0093 D1).
1695        completion_provider: Some(CompletionOptions {
1696            trigger_characters: Some(vec![
1697                " ".to_string(),
1698                "{".to_string(),
1699                ",".to_string(),
1700                ".".to_string(),
1701            ]),
1702            // Slice 5: resolve fills in hover-quality `documentation` lazily, on
1703            // the focused item only, so the initial list stays cheap.
1704            resolve_provider: Some(true),
1705            ..Default::default()
1706        }),
1707        // v0.32 (ADR 0065): signature help while typing a call's arguments.
1708        signature_help_provider: Some(SignatureHelpOptions {
1709            trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
1710            retrigger_characters: Some(vec![",".to_string()]),
1711            ..Default::default()
1712        }),
1713        // v0.33 (ADR 0066): reference-count lenses above top-level definitions.
1714        code_lens_provider: Some(CodeLensOptions {
1715            resolve_provider: Some(false),
1716        }),
1717        // v0.34 (ADR 0067): call hierarchy over the binding index's call graph.
1718        call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
1719        // v0.35 (ADR 0068): implementation nav — capability → its providers.
1720        implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
1721        // Slice 6: go-to-type-definition (value → its type's declaration).
1722        type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
1723        // Slice 6b: `uses`/`consumes` unit names link to their source.
1724        document_link_provider: Some(DocumentLinkOptions {
1725            resolve_provider: Some(false),
1726            work_done_progress_options: Default::default(),
1727        }),
1728        document_formatting_provider: Some(OneOf::Left(true)),
1729        document_range_formatting_provider: Some(OneOf::Left(true)),
1730        document_symbol_provider: Some(OneOf::Left(true)),
1731        // v0.37 (ADR 0070): structural folding + selection ranges (AST-driven).
1732        folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
1733        selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
1734        // v0.25 (ADR 0053): references + rename over the binding
1735        // index; prepareRename refuses out-of-scope symbols.
1736        references_provider: Some(OneOf::Left(true)),
1737        rename_provider: Some(OneOf::Right(RenameOptions {
1738            prepare_provider: Some(true),
1739            work_done_progress_options: Default::default(),
1740        })),
1741        // v0.26 (ADR 0054): quick-fixes from the diagnostics' structured
1742        // suggestions.
1743        code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
1744            code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
1745            ..Default::default()
1746        })),
1747        // v0.27 (ADR 0056): inferred-type inlay hints from the retained
1748        // analysis round's harvested hint set.
1749        inlay_hint_provider: Some(OneOf::Left(true)),
1750        // v0.28 (ADR 0057): semantic tokens over the frozen legend — a
1751        // pure read of the cached index (`symbols` + `foreign_refs`),
1752        // additive over the client's syntactic layer. `delta` deferred.
1753        semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions(
1754            SemanticTokensOptions {
1755                legend: crate::index_queries::semantic_tokens_legend(),
1756                full: Some(SemanticTokensFullOptions::Bool(true)),
1757                range: Some(true),
1758                ..Default::default()
1759            },
1760        )),
1761        // v0.26 riders (ADR 0055): both are `ProjectIndex` queries.
1762        workspace_symbol_provider: Some(OneOf::Left(true)),
1763        document_highlight_provider: Some(OneOf::Left(true)),
1764        workspace: Some(WorkspaceServerCapabilities {
1765            workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1766                supported: Some(true),
1767                change_notifications: Some(OneOf::Left(true)),
1768            }),
1769            file_operations: None,
1770        }),
1771        ..Default::default()
1772    }
1773}
1774
1775/// Index symbol kind → LSP symbol kind, aligned with the document-symbol
1776/// outline's choices (capability=INTERFACE, service/agent=CLASS,
1777/// provider=OBJECT). The index does not distinguish type shapes, so every
1778/// type maps to STRUCT.
1779/// Map a `completion::Completion` to an LSP `CompletionItem`.
1780/// Stash the document URI in each item's `data` so `completion_resolve` can look
1781/// the symbol up — a resolve request carries only the item, not a position.
1782fn stamp_resolve_data(items: &mut [CompletionItem], uri: &Url) {
1783    let data = serde_json::json!({ "uri": uri.to_string() });
1784    for item in items.iter_mut() {
1785        item.data = Some(data.clone());
1786    }
1787}
1788
1789fn to_completion_item(c: completion::Completion) -> CompletionItem {
1790    CompletionItem {
1791        kind: Some(match c.kind {
1792            completion::CompletionKind::Unit => CompletionItemKind::MODULE,
1793            completion::CompletionKind::Capability => CompletionItemKind::INTERFACE,
1794            completion::CompletionKind::Type => CompletionItemKind::STRUCT,
1795            completion::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
1796            completion::CompletionKind::Snippet => CompletionItemKind::SNIPPET,
1797            completion::CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
1798            completion::CompletionKind::Member => CompletionItemKind::METHOD,
1799            completion::CompletionKind::Field => CompletionItemKind::FIELD,
1800            completion::CompletionKind::Constructor => CompletionItemKind::CONSTRUCTOR,
1801            completion::CompletionKind::Function => CompletionItemKind::FUNCTION,
1802        }),
1803        // Snippet items carry `${n:…}` tab stops; everything else inserts its
1804        // label verbatim (the default).
1805        insert_text_format: c.insert_text.as_ref().map(|_| InsertTextFormat::SNIPPET),
1806        insert_text: c.insert_text,
1807        label: c.label,
1808        detail: c.detail,
1809        ..Default::default()
1810    }
1811}
1812
1813/// The byte offset of an LSP `(line, character)` position in `text`. Mirrors
1814/// the `line_prefix` computation (character as a byte index — ASCII-faithful).
1815fn cursor_byte_offset(text: &str, pos: Position) -> usize {
1816    let mut offset = 0;
1817    for (i, line) in text.split_inclusive('\n').enumerate() {
1818        if i == pos.line as usize {
1819            let bare = line.strip_suffix('\n').unwrap_or(line);
1820            return offset + (pos.character as usize).min(bare.len());
1821        }
1822        offset += line.len();
1823    }
1824    offset.min(text.len())
1825}
1826
1827/// v0.34 (ADR 0067): a serializable mirror of [`bynk_check::index::SymbolKey`] for
1828/// round-tripping through `CallHierarchyItem.data` — the index kind isn't
1829/// `Serialize`, so the kind travels as its `display()` string.
1830#[derive(serde::Serialize, serde::Deserialize)]
1831struct SerKey {
1832    unit: String,
1833    kind: String,
1834    name: String,
1835}
1836
1837impl From<&bynk_check::index::SymbolKey> for SerKey {
1838    fn from(k: &bynk_check::index::SymbolKey) -> Self {
1839        SerKey {
1840            unit: k.unit.clone(),
1841            kind: k.kind.display().to_string(),
1842            name: k.name.clone(),
1843        }
1844    }
1845}
1846
1847impl SerKey {
1848    /// Recover a `SymbolKey` from a `CallHierarchyItem`'s `data`. `None` for a
1849    /// missing/garbled payload or an unknown kind — the follow-up then returns
1850    /// no calls rather than guessing.
1851    fn read(data: &Option<serde_json::Value>) -> Option<bynk_check::index::SymbolKey> {
1852        let sk: SerKey = serde_json::from_value(data.as_ref()?.clone()).ok()?;
1853        let kind = match sk.kind.as_str() {
1854            "type" => bynk_check::index::SymbolKind::Type,
1855            "fn" => bynk_check::index::SymbolKind::Fn,
1856            "capability" => bynk_check::index::SymbolKind::Capability,
1857            "service" => bynk_check::index::SymbolKind::Service,
1858            "agent" => bynk_check::index::SymbolKind::Agent,
1859            "provider" => bynk_check::index::SymbolKind::Provider,
1860            _ => return None,
1861        };
1862        Some(bynk_check::index::SymbolKey {
1863            unit: sk.unit,
1864            kind,
1865            name: sk.name,
1866        })
1867    }
1868}
1869
1870fn lsp_symbol_kind(kind: bynk_check::index::SymbolKind) -> SymbolKind {
1871    match kind {
1872        bynk_check::index::SymbolKind::Type => SymbolKind::STRUCT,
1873        bynk_check::index::SymbolKind::Fn => SymbolKind::FUNCTION,
1874        bynk_check::index::SymbolKind::Capability => SymbolKind::INTERFACE,
1875        bynk_check::index::SymbolKind::Service | bynk_check::index::SymbolKind::Agent => {
1876            SymbolKind::CLASS
1877        }
1878        bynk_check::index::SymbolKind::Provider => SymbolKind::OBJECT,
1879        bynk_check::index::SymbolKind::Method => SymbolKind::METHOD,
1880        bynk_check::index::SymbolKind::CapabilityOp => SymbolKind::METHOD,
1881        bynk_check::index::SymbolKind::Field => SymbolKind::FIELD,
1882        bynk_check::index::SymbolKind::Actor => SymbolKind::INTERFACE,
1883    }
1884}
1885
1886fn make_diagnostic(d: &bynk_ide::Diagnostic, text: &str, uri: &Url) -> Diagnostic {
1887    let range = crate::position::span_to_range(text, d.error.span);
1888    let severity = match d.severity {
1889        bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
1890        bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
1891    };
1892    let related_information: Vec<DiagnosticRelatedInformation> = d
1893        .error
1894        .labels
1895        .iter()
1896        .map(|(span, msg)| DiagnosticRelatedInformation {
1897            location: Location {
1898                // Secondary-label spans are offsets into this same document's
1899                // `text`, so they belong to the document's own URI — not a
1900                // placeholder. (Cross-file related info is not yet modelled.)
1901                uri: uri.clone(),
1902                range: crate::position::span_to_range(text, *span),
1903            },
1904            message: msg.clone(),
1905        })
1906        .collect();
1907    let mut message = d.error.message.clone();
1908    for note in &d.error.notes {
1909        message.push_str("\n\n");
1910        message.push_str("note: ");
1911        message.push_str(note);
1912    }
1913    Diagnostic {
1914        range,
1915        severity: Some(severity),
1916        code: Some(NumberOrString::String(d.error.category.to_string())),
1917        code_description: None,
1918        source: Some(SERVER_NAME.to_string()),
1919        message,
1920        related_information: if related_information.is_empty() {
1921            None
1922        } else {
1923            Some(related_information)
1924        },
1925        tags: None,
1926        data: None,
1927    }
1928}
1929
1930#[tokio::main]
1931async fn main() {
1932    // Answer `--version`/`-V` and exit before entering the stdio LSP loop, so
1933    // tooling (e.g. the VS Code status bar) can query the version without the
1934    // server blocking on stdin.
1935    if std::env::args()
1936        .skip(1)
1937        .any(|a| a == "--version" || a == "-V")
1938    {
1939        println!("{SERVER_NAME} {SERVER_VERSION}");
1940        return;
1941    }
1942    // Logging to ~/.bynk-lsp.log. Default level: warn; tunable via
1943    // RUST_LOG or the LSP client's trace setting.
1944    if let Some(home) = std::env::var_os("HOME") {
1945        let path: PathBuf = PathBuf::from(home).join(".bynk-lsp.log");
1946        if let Ok(file) = std::fs::OpenOptions::new()
1947            .create(true)
1948            .append(true)
1949            .open(&path)
1950        {
1951            use tracing_subscriber::prelude::*;
1952            let env_filter = tracing_subscriber::EnvFilter::try_from_env("BYNK_LSP_LOG")
1953                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
1954            let file_layer = tracing_subscriber::fmt::layer()
1955                .with_writer(std::sync::Mutex::new(file))
1956                .with_ansi(false);
1957            tracing_subscriber::registry()
1958                .with(env_filter)
1959                .with(file_layer)
1960                .try_init()
1961                .ok();
1962        }
1963    }
1964    tracing::info!("bynkc-lsp v{} starting", SERVER_VERSION);
1965    let stdin = tokio::io::stdin();
1966    let stdout = tokio::io::stdout();
1967    let (service, socket) = LspService::new(Backend::new);
1968    Server::new(stdin, stdout, socket).serve(service).await;
1969}
1970
1971#[cfg(test)]
1972mod tests {
1973    use super::*;
1974
1975    /// The v0.26 capability advertisements — the "trivial unit check" the
1976    /// proposal scopes in place of a transport round-trip.
1977    #[test]
1978    fn advertises_code_actions_and_the_index_riders() {
1979        let caps = server_capabilities();
1980        let Some(CodeActionProviderCapability::Options(opts)) = caps.code_action_provider else {
1981            panic!("codeActionProvider not advertised with options");
1982        };
1983        assert_eq!(opts.code_action_kinds, Some(vec![CodeActionKind::QUICKFIX]));
1984        assert!(matches!(
1985            caps.workspace_symbol_provider,
1986            Some(OneOf::Left(true))
1987        ));
1988        assert!(matches!(
1989            caps.document_highlight_provider,
1990            Some(OneOf::Left(true))
1991        ));
1992    }
1993
1994    /// The v0.27 capability advertisement — the "trivial unit check" the
1995    /// proposal scopes in place of a transport round-trip.
1996    #[test]
1997    fn advertises_inlay_hints() {
1998        let caps = server_capabilities();
1999        assert!(matches!(caps.inlay_hint_provider, Some(OneOf::Left(true))));
2000    }
2001
2002    /// Slice 6: go-to-type-definition (value → its type's declaration).
2003    #[test]
2004    fn advertises_type_definition() {
2005        let caps = server_capabilities();
2006        assert!(matches!(
2007            caps.type_definition_provider,
2008            Some(TypeDefinitionProviderCapability::Simple(true))
2009        ));
2010    }
2011
2012    /// Slice 6b: `uses`/`consumes` document links.
2013    #[test]
2014    fn advertises_document_links() {
2015        let caps = server_capabilities();
2016        assert!(caps.document_link_provider.is_some());
2017    }
2018
2019    /// Slice 5: completion advertises `.` triggers and lazy doc resolution.
2020    #[test]
2021    fn advertises_completion_with_dot_trigger_and_resolve() {
2022        let caps = server_capabilities();
2023        let opts = caps.completion_provider.expect("completion advertised");
2024        assert_eq!(opts.resolve_provider, Some(true), "resolve_provider");
2025        assert!(
2026            opts.trigger_characters
2027                .as_deref()
2028                .is_some_and(|t| t.iter().any(|c| c == ".")),
2029            "`.` trigger char"
2030        );
2031    }
2032
2033    /// The v0.28 capability advertisement: full + range with the frozen
2034    /// legend (the legend's content is pinned in `index_queries`).
2035    #[test]
2036    fn advertises_semantic_tokens() {
2037        let caps = server_capabilities();
2038        let Some(SemanticTokensServerCapabilities::SemanticTokensOptions(opts)) =
2039            caps.semantic_tokens_provider
2040        else {
2041            panic!("semanticTokensProvider not advertised with options");
2042        };
2043        assert_eq!(opts.full, Some(SemanticTokensFullOptions::Bool(true)));
2044        assert_eq!(opts.range, Some(true));
2045        assert_eq!(opts.legend, crate::index_queries::semantic_tokens_legend());
2046    }
2047}