Skip to main content

gruel_lsp/
server.rs

1//! `tower-lsp` `LanguageServer` impl (ADR-0091).
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use dashmap::DashMap;
8use gruel_compiler::PreviewFeatures;
9use gruel_manifest::Manifest;
10use gruel_target::Target;
11use lsp_types::{
12    CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse,
13    CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse,
14    Diagnostic, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
15    DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
16    DocumentFormattingParams, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents,
17    HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams,
18    InlayHint, InlayHintKind, InlayHintLabel, InlayHintParams, Location, MarkupContent, MarkupKind,
19    MessageType, OneOf, ParameterInformation, ParameterLabel, Position, PositionEncodingKind,
20    Range, ReferenceParams, ServerCapabilities, ServerInfo, SignatureHelp, SignatureHelpOptions,
21    SignatureHelpParams, SignatureInformation, SymbolInformation, SymbolKind as LspSymbolKind,
22    TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, WorkspaceSymbolParams,
23};
24use rustc_hash::{FxHashMap, FxHashSet};
25use tokio::sync::Mutex;
26use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
27use tokio_util::sync::CancellationToken;
28use tower_lsp::{Client, LanguageServer, LspService, Server, jsonrpc};
29
30use crate::analysis::{self, Snapshot, WorkspaceFile};
31use crate::diagnostics;
32use crate::document::DocState;
33use crate::position::PositionEncoding;
34
35/// Default debounce duration between the last keystroke and the next compile.
36const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(150);
37/// Hard upper bound on a single compile pass.
38const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
39
40/// LSP backend state shared by every request handler.
41pub struct Backend {
42    pub client: Client,
43    pub docs: Arc<DashMap<Url, DocState>>,
44    /// Per-root compile snapshot, keyed by the open document's URI. Each
45    /// open file is analyzed independently — its `@import` closure (the
46    /// root plus every file transitively reachable through `@import`)
47    /// becomes the compilation unit, so unrelated workspace files are
48    /// never merged together. See [`crate::workspace::build_root_closure`].
49    pub snapshots: Arc<DashMap<Url, Arc<Snapshot>>>,
50    pub preview_features: PreviewFeatures,
51    pub workspace_root: Arc<Mutex<Option<PathBuf>>>,
52    /// ADR-0092: loaded `gruel.json` for the workspace root, when one
53    /// exists. `None` falls back to per-open-buffer isolation mode
54    /// (the no-manifest default).
55    pub manifest: Arc<Mutex<Option<Manifest>>>,
56    pub encoding: Arc<Mutex<PositionEncoding>>,
57    pub analysis_tx: UnboundedSender<AnalysisRequest>,
58    pub current_cancel: Arc<Mutex<Option<CancellationToken>>>,
59    /// Most recent diagnostics keyed by URI. Updated atomically by the
60    /// analysis worker on every successful (or partial) compile. Read by
61    /// `textDocument/codeAction` to construct quick fixes for the
62    /// diagnostics overlapping a requested range.
63    pub last_diagnostics: Arc<DashMap<Url, Vec<Diagnostic>>>,
64}
65
66#[derive(Debug, Clone)]
67pub struct AnalysisRequest {
68    pub debounce: Duration,
69    pub timeout: Duration,
70}
71
72impl Backend {
73    pub fn new(client: Client, preview_features: PreviewFeatures) -> Self {
74        let (tx, rx) = unbounded_channel();
75        let me = Self {
76            client: client.clone(),
77            docs: Arc::new(DashMap::new()),
78            snapshots: Arc::new(DashMap::new()),
79            preview_features,
80            workspace_root: Arc::new(Mutex::new(None)),
81            manifest: Arc::new(Mutex::new(None)),
82            encoding: Arc::new(Mutex::new(PositionEncoding::Utf16)),
83            analysis_tx: tx,
84            current_cancel: Arc::new(Mutex::new(None)),
85            last_diagnostics: Arc::new(DashMap::new()),
86        };
87        // Spawn the analysis worker.
88        let worker = AnalysisWorker {
89            client,
90            docs: me.docs.clone(),
91            snapshots: me.snapshots.clone(),
92            preview_features: me.preview_features.clone(),
93            workspace_root: me.workspace_root.clone(),
94            manifest: me.manifest.clone(),
95            encoding: me.encoding.clone(),
96            current_cancel: me.current_cancel.clone(),
97            rx,
98            target: Target::host(),
99            published_files: DashMap::new(),
100            last_diagnostics: me.last_diagnostics.clone(),
101        };
102        tokio::spawn(worker.run());
103        me
104    }
105
106    fn queue_analysis(&self) {
107        let _ = self.analysis_tx.send(AnalysisRequest {
108            debounce: DEFAULT_DEBOUNCE,
109            timeout: DEFAULT_TIMEOUT,
110        });
111    }
112
113    /// Test-only: compile each open root synchronously and publish
114    /// diagnostics. Bypasses the debounce / spawned worker so integration
115    /// tests can poll a deterministic result.
116    pub async fn analyze_now(&self) -> Vec<Diagnostic> {
117        let workspace_root = self.workspace_root.lock().await.clone();
118        let manifest = self.manifest.lock().await.clone();
119        let target = Target::host();
120        let mut combined_diagnostics: FxHashSet<UriDiagKey> = FxHashSet::default();
121        let mut by_uri: FxHashMap<Url, Vec<Diagnostic>> = FxHashMap::default();
122
123        let roots = collect_analysis_roots(&self.docs, manifest.as_ref());
124
125        for (uri, root) in roots {
126            let docs = self.docs.clone();
127            let result = analysis::analyze_root(
128                root,
129                workspace_root.as_deref(),
130                &self.preview_features,
131                &target,
132                |path| open_text_lookup(&docs, path),
133            );
134            if let Some(snap) = result.snapshot {
135                self.snapshots.insert(uri.clone(), Arc::new(snap));
136            }
137            let by_file = diagnostics::group_by_file(
138                result.diagnostics.into_iter(),
139                workspace_root.as_deref(),
140            );
141            for (path, diags) in by_file {
142                if let Ok(diag_uri) = Url::from_file_path(&path) {
143                    for d in &diags {
144                        let key = (
145                            diag_uri.clone(),
146                            range_key(&d.range),
147                            d.message.clone(),
148                            d.code
149                                .as_ref()
150                                .map(|c| match c {
151                                    lsp_types::NumberOrString::String(s) => s.clone(),
152                                    lsp_types::NumberOrString::Number(n) => n.to_string(),
153                                })
154                                .unwrap_or_default(),
155                        );
156                        if combined_diagnostics.insert(key) {
157                            by_uri.entry(diag_uri.clone()).or_default().push(d.clone());
158                        }
159                    }
160                }
161            }
162        }
163
164        let mut flat = Vec::new();
165        for (uri, diags) in by_uri {
166            self.client
167                .publish_diagnostics(uri.clone(), diags.clone(), None)
168                .await;
169            self.last_diagnostics.insert(uri, diags.clone());
170            flat.extend(diags);
171        }
172        flat
173    }
174}
175
176fn collect_roots(docs: &DashMap<Url, DocState>) -> Vec<(Url, WorkspaceFile)> {
177    docs.iter()
178        .enumerate()
179        .map(|(idx, kv)| {
180            let doc = kv.value();
181            let file = WorkspaceFile {
182                path: doc.path.clone(),
183                text: doc.text.clone(),
184                file_id: gruel_compiler::FileId::new((idx as u32).saturating_add(1).max(1)),
185            };
186            (kv.key().clone(), file)
187        })
188        .collect()
189}
190
191/// ADR-0092: pick the set of compilation roots to analyze this pass.
192///
193/// - **Manifested mode**: one root, the manifest's entry file. The entry's
194///   text comes from any open buffer at the same path, otherwise from disk.
195/// - **Isolation mode**: one root per open buffer, current behaviour.
196pub(crate) fn collect_analysis_roots(
197    docs: &DashMap<Url, DocState>,
198    manifest: Option<&Manifest>,
199) -> Vec<(Url, WorkspaceFile)> {
200    if let Some(m) = manifest {
201        let entry_path = m.target.root().to_path_buf();
202        let text = match open_text_lookup(docs, &entry_path) {
203            Some(t) => t,
204            None => match std::fs::read_to_string(&entry_path) {
205                Ok(t) => t,
206                Err(_) => {
207                    // Entry file disappeared between manifest load and the
208                    // first compile — bail out so the user sees no false
209                    // diagnostics. The watch handler will refresh once it
210                    // reappears.
211                    return Vec::new();
212                }
213            },
214        };
215        let uri = match Url::from_file_path(&entry_path) {
216            Ok(u) => u,
217            Err(_) => return Vec::new(),
218        };
219        let file = WorkspaceFile {
220            path: entry_path,
221            text,
222            file_id: gruel_compiler::FileId::new(1),
223        };
224        vec![(uri, file)]
225    } else {
226        collect_roots(docs)
227    }
228}
229
230/// Compact, `Hash`-able view of an `lsp_types::Range`. `lsp_types::Range`
231/// itself doesn't implement `Hash`, so we serialize it to a tuple before
232/// using it as a dedup key.
233type RangeKey = (u32, u32, u32, u32);
234
235/// Dedup key for diagnostics keyed by URI: (uri, range, message, code).
236type UriDiagKey = (Url, RangeKey, String, String);
237
238/// Dedup key for diagnostics keyed by path: (path, range, message, code).
239type PathDiagKey = (PathBuf, RangeKey, String, String);
240
241fn range_key(r: &lsp_types::Range) -> RangeKey {
242    (r.start.line, r.start.character, r.end.line, r.end.character)
243}
244
245fn open_text_lookup(docs: &DashMap<Url, DocState>, path: &std::path::Path) -> Option<String> {
246    for kv in docs.iter() {
247        if kv.value().path == path {
248            return Some(kv.value().text.clone());
249        }
250    }
251    None
252}
253
254impl Backend {
255    /// ADR-0092: (re-)discover `gruel.json` at `root` and stash the
256    /// result in `self.manifest`. Logs (does not raise diagnostics) on
257    /// parse / validation failure so the editor user sees what's wrong
258    /// without the squiggles spilling into compile diagnostics.
259    pub async fn reload_manifest(&self, root: &std::path::Path) {
260        let Some(path) = gruel_manifest::discover_at_root(root) else {
261            *self.manifest.lock().await = None;
262            return;
263        };
264        match gruel_manifest::load_at(&path) {
265            Ok(m) => {
266                self.client
267                    .log_message(
268                        MessageType::INFO,
269                        format!("gruel-lsp: loaded manifest at {}", path.display()),
270                    )
271                    .await;
272                *self.manifest.lock().await = Some(m);
273            }
274            Err(err) => {
275                self.client
276                    .log_message(
277                        MessageType::WARNING,
278                        format!("gruel-lsp: invalid manifest at {}: {}", path.display(), err),
279                    )
280                    .await;
281                *self.manifest.lock().await = None;
282            }
283        }
284    }
285
286    /// Return the snapshot for queries against `uri`. If the queried URI is
287    /// open, prefer its own per-root snapshot; otherwise (e.g. a file that's
288    /// only seen as an `@import` target) fall back to any snapshot whose
289    /// closure contains the path. Returns `None` if no snapshot covers the
290    /// file yet.
291    fn snapshot_for(&self, uri: &Url) -> Option<Arc<Snapshot>> {
292        if let Some(snap) = self.snapshots.get(uri) {
293            return Some(snap.value().clone());
294        }
295        let path = uri.to_file_path().ok()?;
296        for kv in self.snapshots.iter() {
297            if kv.value().path_to_file_id.contains_key(&path) {
298                return Some(kv.value().clone());
299            }
300        }
301        None
302    }
303
304    /// Return every snapshot, used by workspace-wide queries
305    /// (workspace symbols, references). Each entry is unique by root URI.
306    fn all_snapshots(&self) -> Vec<Arc<Snapshot>> {
307        self.snapshots.iter().map(|kv| kv.value().clone()).collect()
308    }
309}
310
311struct AnalysisWorker {
312    client: Client,
313    docs: Arc<DashMap<Url, DocState>>,
314    snapshots: Arc<DashMap<Url, Arc<Snapshot>>>,
315    preview_features: PreviewFeatures,
316    workspace_root: Arc<Mutex<Option<PathBuf>>>,
317    /// ADR-0092: same atomic as `Backend::manifest`, watched by every
318    /// compile pass so swapping in / out of manifested mode is just a
319    /// snapshot read.
320    manifest: Arc<Mutex<Option<Manifest>>>,
321    /// Currently negotiated position encoding. Used by later phases when
322    /// remapping diagnostic ranges through the source text (Phase 1 publishes
323    /// byte-based positions, which clients negotiating UTF-8 see correctly).
324    #[allow(dead_code)]
325    encoding: Arc<Mutex<PositionEncoding>>,
326    current_cancel: Arc<Mutex<Option<CancellationToken>>>,
327    rx: tokio::sync::mpsc::UnboundedReceiver<AnalysisRequest>,
328    target: Target,
329    /// Track which files we've previously published diagnostics for so we
330    /// can clear stale red squiggles when a file no longer has any.
331    published_files: DashMap<Url, ()>,
332    /// Shared mirror of the most recent diagnostics per URI (Phase 2).
333    last_diagnostics: Arc<DashMap<Url, Vec<Diagnostic>>>,
334}
335
336impl AnalysisWorker {
337    async fn run(mut self) {
338        while let Some(req) = self.rx.recv().await {
339            // Coalesce: drain any further pending requests in the queue.
340            let debounce = req.debounce;
341            let timeout = req.timeout;
342            // Sleep until things settle.
343            tokio::time::sleep(debounce).await;
344            while let Ok(_extra) = self.rx.try_recv() {
345                tokio::time::sleep(debounce).await;
346            }
347
348            // Cancel any in-flight compile.
349            let token = CancellationToken::new();
350            {
351                let mut cur = self.current_cancel.lock().await;
352                if let Some(old) = cur.replace(token.clone()) {
353                    old.cancel();
354                }
355            }
356
357            let workspace_root = self.workspace_root.lock().await.clone();
358            let manifest = self.manifest.lock().await.clone();
359            let roots = collect_analysis_roots(&self.docs, manifest.as_ref());
360            let preview_features = self.preview_features.clone();
361            let target = self.target.clone();
362            let docs_for_lookup = self.docs.clone();
363            let workspace_root_for_task = workspace_root.clone();
364
365            // Run sema on a blocking thread (sema is sync + CPU-heavy).
366            //
367            // Each open root produces its own `@import` closure; we dedupe
368            // diagnostics across roots so a shared imported file with an
369            // error doesn't get the same red squiggle published twice.
370            let analysis = tokio::task::spawn_blocking(move || {
371                let mut snapshots: Vec<(Url, Snapshot)> = Vec::new();
372                let mut all_by_file: FxHashMap<PathBuf, Vec<Diagnostic>> = FxHashMap::default();
373                let mut seen_keys: FxHashSet<PathDiagKey> = FxHashSet::default();
374
375                for (uri, root) in roots {
376                    let result = analysis::analyze_root(
377                        root,
378                        workspace_root_for_task.as_deref(),
379                        &preview_features,
380                        &target,
381                        |path| open_text_lookup(&docs_for_lookup, path),
382                    );
383                    if let Some(snap) = result.snapshot {
384                        snapshots.push((uri, snap));
385                    }
386                    let by_file = diagnostics::group_by_file(
387                        result.diagnostics.into_iter(),
388                        workspace_root_for_task.as_deref(),
389                    );
390                    for (path, diags) in by_file {
391                        for d in diags {
392                            let key = (
393                                path.clone(),
394                                range_key(&d.range),
395                                d.message.clone(),
396                                d.code
397                                    .as_ref()
398                                    .map(|c| match c {
399                                        lsp_types::NumberOrString::String(s) => s.clone(),
400                                        lsp_types::NumberOrString::Number(n) => n.to_string(),
401                                    })
402                                    .unwrap_or_default(),
403                            );
404                            if seen_keys.insert(key) {
405                                all_by_file.entry(path.clone()).or_default().push(d);
406                            }
407                        }
408                    }
409                }
410                (snapshots, all_by_file)
411            });
412            let (snapshots, by_file) = tokio::select! {
413                res = analysis => match res {
414                    Ok(r) => r,
415                    Err(_) => continue,
416                },
417                _ = tokio::time::sleep(timeout) => {
418                    // Timed out — drop result, keep previous snapshots.
419                    self.client
420                        .log_message(
421                            MessageType::WARNING,
422                            "gruel-lsp: compile timed out, keeping previous snapshots",
423                        )
424                        .await;
425                    continue;
426                }
427                _ = token.cancelled() => continue,
428            };
429
430            // Drop snapshots whose root URI is neither open nor the
431            // current manifest-driven root. In isolation mode that's just
432            // open buffers; in manifested mode the entry URI persists
433            // even when no editor has it open.
434            let mut live_root_uris: FxHashSet<Url> =
435                self.docs.iter().map(|kv| kv.key().clone()).collect();
436            if let Some(m) = manifest.as_ref()
437                && let Ok(u) = Url::from_file_path(m.target.root())
438            {
439                live_root_uris.insert(u);
440            }
441            let stale_snapshot_uris: Vec<Url> = self
442                .snapshots
443                .iter()
444                .filter(|kv| !live_root_uris.contains(kv.key()))
445                .map(|kv| kv.key().clone())
446                .collect();
447            for uri in stale_snapshot_uris {
448                self.snapshots.remove(&uri);
449            }
450
451            // Install the new snapshots.
452            for (uri, snap) in snapshots {
453                self.snapshots.insert(uri, Arc::new(snap));
454            }
455
456            // Clear stale files: any URI we previously published for but
457            // doesn't appear in by_file now must be cleared.
458            let mut current_files = std::collections::HashSet::new();
459            for path in by_file.keys() {
460                if let Ok(uri) = Url::from_file_path(path) {
461                    current_files.insert(uri);
462                }
463            }
464            let previously_published: Vec<Url> = self
465                .published_files
466                .iter()
467                .map(|kv| kv.key().clone())
468                .collect();
469            for uri in previously_published {
470                if !current_files.contains(&uri) {
471                    self.client
472                        .publish_diagnostics(uri.clone(), vec![], None)
473                        .await;
474                    self.published_files.remove(&uri);
475                    self.last_diagnostics.remove(&uri);
476                }
477            }
478            // Also clear for any open doc that isn't in by_file (e.g. fixed
479            // its errors).
480            for kv in self.docs.iter() {
481                let uri = kv.key().clone();
482                if !current_files.contains(&uri) {
483                    self.client
484                        .publish_diagnostics(uri.clone(), vec![], None)
485                        .await;
486                    self.published_files.remove(&uri);
487                    self.last_diagnostics.remove(&uri);
488                }
489            }
490
491            for (path, diags) in by_file {
492                if let Ok(uri) = Url::from_file_path(&path) {
493                    self.client
494                        .publish_diagnostics(uri.clone(), diags.clone(), None)
495                        .await;
496                    self.published_files.insert(uri.clone(), ());
497                    self.last_diagnostics.insert(uri, diags);
498                }
499            }
500        }
501    }
502}
503
504#[tower_lsp::async_trait]
505impl LanguageServer for Backend {
506    async fn initialize(&self, params: InitializeParams) -> jsonrpc::Result<InitializeResult> {
507        // Pick UTF-8 if the client supports it; UTF-16 otherwise.
508        let chosen_encoding = pick_encoding(&params);
509        *self.encoding.lock().await = chosen_encoding;
510        let encoding_kind = match chosen_encoding {
511            PositionEncoding::Utf8 => PositionEncodingKind::UTF8,
512            PositionEncoding::Utf16 => PositionEncodingKind::UTF16,
513        };
514
515        // Workspace root: prefer workspaceFolders, then rootUri, then the
516        // GRUEL_LSP_ROOT env var (set by `gruel lsp --root` for clients that
517        // don't advertise a workspace).
518        let root_path = params
519            .workspace_folders
520            .as_ref()
521            .and_then(|fs| fs.first())
522            .and_then(|f| f.uri.to_file_path().ok())
523            .or_else(|| {
524                #[allow(deprecated)]
525                params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
526            })
527            .or_else(|| std::env::var_os("GRUEL_LSP_ROOT").map(PathBuf::from));
528
529        // ADR-0092: load `gruel.json` at the workspace root, if any.
530        // Missing / malformed manifests fall back to isolation mode
531        // (the no-manifest default).
532        if let Some(root) = root_path.as_deref() {
533            self.reload_manifest(root).await;
534        }
535        *self.workspace_root.lock().await = root_path;
536
537        Ok(InitializeResult {
538            capabilities: ServerCapabilities {
539                position_encoding: Some(encoding_kind),
540                text_document_sync: Some(TextDocumentSyncCapability::Kind(
541                    TextDocumentSyncKind::INCREMENTAL,
542                )),
543                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
544                hover_provider: Some(HoverProviderCapability::Simple(true)),
545                definition_provider: Some(OneOf::Left(true)),
546                signature_help_provider: Some(SignatureHelpOptions {
547                    trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
548                    retrigger_characters: None,
549                    work_done_progress_options: Default::default(),
550                }),
551                references_provider: Some(OneOf::Left(true)),
552                workspace_symbol_provider: Some(OneOf::Left(true)),
553                completion_provider: Some(CompletionOptions {
554                    resolve_provider: Some(false),
555                    trigger_characters: Some(vec![
556                        ".".to_string(),
557                        "@".to_string(),
558                        ":".to_string(),
559                        "(".to_string(),
560                    ]),
561                    all_commit_characters: None,
562                    work_done_progress_options: Default::default(),
563                    completion_item: None,
564                }),
565                inlay_hint_provider: Some(OneOf::Left(true)),
566                document_formatting_provider: Some(OneOf::Left(true)),
567                ..ServerCapabilities::default()
568            },
569            server_info: Some(ServerInfo {
570                name: "gruel-lsp".to_string(),
571                version: Some(env!("CARGO_PKG_VERSION").to_string()),
572            }),
573        })
574    }
575
576    async fn initialized(&self, _: InitializedParams) {
577        self.client
578            .log_message(MessageType::INFO, "gruel-lsp ready")
579            .await;
580        self.queue_analysis();
581    }
582
583    async fn shutdown(&self) -> jsonrpc::Result<()> {
584        Ok(())
585    }
586
587    async fn did_open(&self, params: DidOpenTextDocumentParams) {
588        let doc = params.text_document;
589        let state = DocState::new(doc.uri.clone(), doc.text, doc.version, true);
590        self.docs.insert(doc.uri, state);
591        self.queue_analysis();
592    }
593
594    async fn did_change(&self, params: DidChangeTextDocumentParams) {
595        let encoding = *self.encoding.lock().await;
596        if let Some(mut entry) = self.docs.get_mut(&params.text_document.uri) {
597            for change in params.content_changes {
598                if !entry.apply_change(change, encoding) {
599                    self.client
600                        .log_message(
601                            MessageType::WARNING,
602                            format!("gruel-lsp: invalid range in {}", entry.uri),
603                        )
604                        .await;
605                }
606            }
607            entry.version = params.text_document.version;
608        }
609        self.queue_analysis();
610    }
611
612    async fn did_save(&self, _params: DidSaveTextDocumentParams) {
613        self.queue_analysis();
614    }
615
616    async fn did_close(&self, params: DidCloseTextDocumentParams) {
617        let uri = params.text_document.uri;
618        // Once a doc is closed it stops being a root: drop its dedicated
619        // snapshot. The next analysis pass also clears any stale roots, but
620        // doing it eagerly keeps queries from racing into a snapshot the
621        // editor no longer cares about.
622        //
623        // In manifested mode (ADR-0092) the per-buffer URI is not the
624        // snapshot key, so this removal is a no-op — the manifest-keyed
625        // snapshot stays put until the manifest itself goes away.
626        self.snapshots.remove(&uri);
627        if let Some(mut entry) = self.docs.get_mut(&uri) {
628            entry.open = false;
629        }
630    }
631
632    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
633        // ADR-0092: if any of the watched events touched `gruel.json`,
634        // reload the manifest and re-queue analysis. Other watched files
635        // are ignored here (we don't currently subscribe to anything
636        // else).
637        let manifest_changed = params.changes.iter().any(|change| {
638            change
639                .uri
640                .to_file_path()
641                .ok()
642                .and_then(|p| p.file_name().map(|n| n == "gruel.json"))
643                .unwrap_or(false)
644        });
645        if !manifest_changed {
646            return;
647        }
648        let root = self.workspace_root.lock().await.clone();
649        if let Some(root) = root {
650            self.reload_manifest(&root).await;
651        } else {
652            *self.manifest.lock().await = None;
653        }
654        // Drop snapshots — the compilation unit just changed.
655        self.snapshots.clear();
656        self.queue_analysis();
657    }
658
659    async fn hover(&self, params: HoverParams) -> jsonrpc::Result<Option<Hover>> {
660        let uri = params
661            .text_document_position_params
662            .text_document
663            .uri
664            .clone();
665        let position = params.text_document_position_params.position;
666        let encoding = *self.encoding.lock().await;
667
668        // Resolve the path → file_id via the current snapshot.
669        let snap = match self.snapshot_for(&uri) {
670            Some(s) => s,
671            None => return Ok(None),
672        };
673        let path = match uri.to_file_path() {
674            Ok(p) => p,
675            Err(_) => return Ok(None),
676        };
677        let file_id = match snap.path_to_file_id.get(&path) {
678            Some(id) => *id,
679            None => return Ok(None),
680        };
681        let source = match snap.sources.get(&file_id) {
682            Some(s) => s,
683            None => return Ok(None),
684        };
685        let line_map = match snap.line_maps.get(&file_id) {
686            Some(m) => m,
687            None => return Ok(None),
688        };
689        let byte = crate::position::position_to_byte(line_map, &source.text, position, encoding);
690
691        let hover = match crate::hover::hover_at_with_expr_types(
692            &snap.ast,
693            &snap.interner,
694            &snap.expr_types,
695            snap.type_pool.as_deref(),
696            file_id,
697            byte,
698        ) {
699            Some(h) => h,
700            None => return Ok(None),
701        };
702
703        let range = crate::position::span_to_range(line_map, &source.text, hover.span, encoding);
704
705        Ok(Some(Hover {
706            contents: HoverContents::Markup(MarkupContent {
707                kind: MarkupKind::Markdown,
708                value: hover.markdown,
709            }),
710            range: Some(range),
711        }))
712    }
713
714    async fn goto_definition(
715        &self,
716        params: GotoDefinitionParams,
717    ) -> jsonrpc::Result<Option<GotoDefinitionResponse>> {
718        let uri = params
719            .text_document_position_params
720            .text_document
721            .uri
722            .clone();
723        let position = params.text_document_position_params.position;
724        let encoding = *self.encoding.lock().await;
725
726        let snap = match self.snapshot_for(&uri) {
727            Some(s) => s,
728            None => return Ok(None),
729        };
730        let path = match uri.to_file_path() {
731            Ok(p) => p,
732            Err(_) => return Ok(None),
733        };
734        let file_id = match snap.path_to_file_id.get(&path) {
735            Some(id) => *id,
736            None => return Ok(None),
737        };
738        let source = match snap.sources.get(&file_id) {
739            Some(s) => s,
740            None => return Ok(None),
741        };
742        let line_map = match snap.line_maps.get(&file_id) {
743            Some(m) => m,
744            None => return Ok(None),
745        };
746        let byte = crate::position::position_to_byte(line_map, &source.text, position, encoding);
747
748        let def_span = match crate::goto::definition_at(&snap.ast, &snap.interner, file_id, byte) {
749            Some(s) => s,
750            None => return Ok(None),
751        };
752
753        // Resolve def_span back to (uri, range). It must live in some file we
754        // know about.
755        let def_file_id = def_span.file_id;
756        let def_source = match snap.sources.get(&def_file_id) {
757            Some(s) => s,
758            None => return Ok(None),
759        };
760        let def_line_map = match snap.line_maps.get(&def_file_id) {
761            Some(m) => m,
762            None => return Ok(None),
763        };
764        let def_uri = match Url::from_file_path(&def_source.path) {
765            Ok(u) => u,
766            Err(_) => return Ok(None),
767        };
768        let range =
769            crate::position::span_to_range(def_line_map, &def_source.text, def_span, encoding);
770
771        Ok(Some(GotoDefinitionResponse::Scalar(Location {
772            uri: def_uri,
773            range,
774        })))
775    }
776
777    async fn signature_help(
778        &self,
779        params: SignatureHelpParams,
780    ) -> jsonrpc::Result<Option<SignatureHelp>> {
781        let uri = params
782            .text_document_position_params
783            .text_document
784            .uri
785            .clone();
786        let position = params.text_document_position_params.position;
787        let encoding = *self.encoding.lock().await;
788
789        let snap = match self.snapshot_for(&uri) {
790            Some(s) => s,
791            None => return Ok(None),
792        };
793        let path = match uri.to_file_path() {
794            Ok(p) => p,
795            Err(_) => return Ok(None),
796        };
797        let file_id = match snap.path_to_file_id.get(&path) {
798            Some(id) => *id,
799            None => return Ok(None),
800        };
801        let source = match snap.sources.get(&file_id) {
802            Some(s) => s,
803            None => return Ok(None),
804        };
805        let line_map = match snap.line_maps.get(&file_id) {
806            Some(m) => m,
807            None => return Ok(None),
808        };
809        let byte = crate::position::position_to_byte(line_map, &source.text, position, encoding);
810
811        let result =
812            match crate::signature::signature_help(&snap.ast, &snap.interner, file_id, byte) {
813                Some(r) => r,
814                None => return Ok(None),
815            };
816
817        let parameters = result
818            .parameters
819            .iter()
820            .map(|(s, e)| ParameterInformation {
821                label: ParameterLabel::LabelOffsets([*s, *e]),
822                documentation: None,
823            })
824            .collect();
825
826        Ok(Some(SignatureHelp {
827            signatures: vec![SignatureInformation {
828                label: result.label,
829                documentation: None,
830                parameters: Some(parameters),
831                active_parameter: Some(result.active_parameter as u32),
832            }],
833            active_signature: Some(0),
834            active_parameter: Some(result.active_parameter as u32),
835        }))
836    }
837
838    async fn references(&self, params: ReferenceParams) -> jsonrpc::Result<Option<Vec<Location>>> {
839        let uri = params.text_document_position.text_document.uri.clone();
840        let position = params.text_document_position.position;
841        let include_decl = params.context.include_declaration;
842        let encoding = *self.encoding.lock().await;
843
844        let target_path = match uri.to_file_path() {
845            Ok(p) => p,
846            Err(_) => return Ok(None),
847        };
848
849        // References can span multiple compilation units (e.g. a function in
850        // a shared `utils.gruel` is called by two different open roots). Walk
851        // every snapshot whose closure contains the target file, run the
852        // per-snapshot query, and union the results, deduping by location.
853        let mut locations = Vec::new();
854        let mut seen: FxHashSet<(Url, RangeKey)> = FxHashSet::default();
855        for snap in self.all_snapshots() {
856            let Some(&file_id) = snap.path_to_file_id.get(&target_path) else {
857                continue;
858            };
859            let Some(source) = snap.sources.get(&file_id) else {
860                continue;
861            };
862            let Some(line_map) = snap.line_maps.get(&file_id) else {
863                continue;
864            };
865            let byte =
866                crate::position::position_to_byte(line_map, &source.text, position, encoding);
867            let spans = crate::references::references_at(
868                &snap.ast,
869                &snap.interner,
870                file_id,
871                byte,
872                include_decl,
873            );
874            for s in spans {
875                let Some(src) = snap.sources.get(&s.file_id) else {
876                    continue;
877                };
878                let Some(lm) = snap.line_maps.get(&s.file_id) else {
879                    continue;
880                };
881                let Ok(loc_uri) = Url::from_file_path(&src.path) else {
882                    continue;
883                };
884                let range = crate::position::span_to_range(lm, &src.text, s, encoding);
885                if seen.insert((loc_uri.clone(), range_key(&range))) {
886                    locations.push(Location {
887                        uri: loc_uri,
888                        range,
889                    });
890                }
891            }
892        }
893
894        if locations.is_empty() {
895            Ok(None)
896        } else {
897            Ok(Some(locations))
898        }
899    }
900
901    async fn symbol(
902        &self,
903        params: WorkspaceSymbolParams,
904    ) -> jsonrpc::Result<Option<Vec<SymbolInformation>>> {
905        let encoding = *self.encoding.lock().await;
906        // Workspace symbols span every open root: walk each per-root snapshot,
907        // dedupe by definition location so a function imported by two
908        // different roots doesn't get listed twice.
909        let mut out: Vec<SymbolInformation> = Vec::new();
910        let mut seen: FxHashSet<(Url, RangeKey, String)> = FxHashSet::default();
911        for snap in self.all_snapshots() {
912            let syms = crate::workspace_symbols::workspace_symbols(
913                &snap.ast,
914                &snap.interner,
915                &params.query,
916            );
917            for sym in syms {
918                let src = match snap.sources.get(&sym.span.file_id) {
919                    Some(x) => x,
920                    None => continue,
921                };
922                let lm = match snap.line_maps.get(&sym.span.file_id) {
923                    Some(m) => m,
924                    None => continue,
925                };
926                let uri = match Url::from_file_path(&src.path) {
927                    Ok(u) => u,
928                    Err(_) => continue,
929                };
930                let range = crate::position::span_to_range(lm, &src.text, sym.span, encoding);
931                if !seen.insert((uri.clone(), range_key(&range), sym.name.clone())) {
932                    continue;
933                }
934                #[allow(deprecated)]
935                out.push(SymbolInformation {
936                    name: sym.name,
937                    kind: to_lsp_kind(sym.kind),
938                    tags: None,
939                    deprecated: None,
940                    location: Location { uri, range },
941                    container_name: sym.container,
942                });
943            }
944        }
945        if out.is_empty() {
946            Ok(None)
947        } else {
948            Ok(Some(out))
949        }
950    }
951
952    async fn completion(
953        &self,
954        params: CompletionParams,
955    ) -> jsonrpc::Result<Option<CompletionResponse>> {
956        let uri = params.text_document_position.text_document.uri.clone();
957        let position = params.text_document_position.position;
958        let encoding = *self.encoding.lock().await;
959        let trigger = params
960            .context
961            .as_ref()
962            .and_then(|c| c.trigger_character.as_ref())
963            .and_then(|s| s.chars().next());
964
965        let snap = match self.snapshot_for(&uri) {
966            Some(s) => s,
967            None => return Ok(None),
968        };
969        let path = match uri.to_file_path() {
970            Ok(p) => p,
971            Err(_) => return Ok(None),
972        };
973        let file_id = match snap.path_to_file_id.get(&path) {
974            Some(id) => *id,
975            None => return Ok(None),
976        };
977        let source = match snap.sources.get(&file_id) {
978            Some(s) => s,
979            None => return Ok(None),
980        };
981        let line_map = match snap.line_maps.get(&file_id) {
982            Some(m) => m,
983            None => return Ok(None),
984        };
985        let byte = crate::position::position_to_byte(line_map, &source.text, position, encoding);
986
987        let items =
988            crate::completion::complete_at(&snap.ast, &snap.interner, file_id, byte, trigger);
989        let lsp_items: Vec<CompletionItem> = items
990            .into_iter()
991            .map(|i| CompletionItem {
992                label: i.label,
993                kind: Some(to_completion_kind(i.kind)),
994                detail: i.detail,
995                ..CompletionItem::default()
996            })
997            .collect();
998        if lsp_items.is_empty() {
999            Ok(None)
1000        } else {
1001            Ok(Some(CompletionResponse::Array(lsp_items)))
1002        }
1003    }
1004
1005    async fn inlay_hint(&self, params: InlayHintParams) -> jsonrpc::Result<Option<Vec<InlayHint>>> {
1006        let uri = params.text_document.uri.clone();
1007        let encoding = *self.encoding.lock().await;
1008
1009        let snap = match self.snapshot_for(&uri) {
1010            Some(s) => s,
1011            None => return Ok(None),
1012        };
1013        let path = match uri.to_file_path() {
1014            Ok(p) => p,
1015            Err(_) => return Ok(None),
1016        };
1017        let file_id = match snap.path_to_file_id.get(&path) {
1018            Some(id) => *id,
1019            None => return Ok(None),
1020        };
1021        let source = match snap.sources.get(&file_id) {
1022            Some(s) => s,
1023            None => return Ok(None),
1024        };
1025        let line_map = match snap.line_maps.get(&file_id) {
1026            Some(m) => m,
1027            None => return Ok(None),
1028        };
1029
1030        let hints = crate::inlay_hints::inlay_hints(
1031            &snap.ast,
1032            &snap.interner,
1033            &snap.expr_types,
1034            snap.type_pool.as_deref(),
1035            file_id,
1036        );
1037
1038        let lsp_hints: Vec<InlayHint> = hints
1039            .into_iter()
1040            .filter(|h| h.file_id == file_id)
1041            .map(|h| {
1042                let pos =
1043                    crate::position::byte_to_position(line_map, &source.text, h.byte, encoding);
1044                InlayHint {
1045                    position: pos,
1046                    label: InlayHintLabel::String(h.label),
1047                    kind: Some(match h.kind {
1048                        crate::inlay_hints::InlayKind::Type => InlayHintKind::TYPE,
1049                        crate::inlay_hints::InlayKind::Parameter => InlayHintKind::PARAMETER,
1050                    }),
1051                    text_edits: None,
1052                    tooltip: None,
1053                    padding_left: None,
1054                    padding_right: None,
1055                    data: None,
1056                }
1057            })
1058            .collect();
1059        if lsp_hints.is_empty() {
1060            Ok(None)
1061        } else {
1062            Ok(Some(lsp_hints))
1063        }
1064    }
1065
1066    async fn formatting(
1067        &self,
1068        params: DocumentFormattingParams,
1069    ) -> jsonrpc::Result<Option<Vec<TextEdit>>> {
1070        let uri = params.text_document.uri.clone();
1071
1072        // Look up the in-memory buffer. If absent (file the editor doesn't
1073        // have open), there's nothing to format.
1074        let original = match self.docs.get(&uri) {
1075            Some(doc) => doc.text.clone(),
1076            None => return Ok(None),
1077        };
1078
1079        // Run the formatter. Parse failure returns Ok(None) so the editor
1080        // leaves the buffer alone — diagnostics from the existing pipeline
1081        // already cover the cause (ADR-0093 §LSP integration).
1082        let formatted = match gruel_fmt::format_source(&original) {
1083            Ok(s) => s,
1084            Err(e) => {
1085                tracing::debug!(uri = %uri, error = %e, "gruel-fmt: parse failed");
1086                return Ok(None);
1087            }
1088        };
1089
1090        if formatted == original {
1091            // Already canonical — return an empty edit list so the editor
1092            // records a clean save.
1093            return Ok(Some(Vec::new()));
1094        }
1095
1096        Ok(Some(diff_to_text_edits(&original, &formatted)))
1097    }
1098
1099    async fn code_action(
1100        &self,
1101        params: CodeActionParams,
1102    ) -> jsonrpc::Result<Option<CodeActionResponse>> {
1103        let uri = params.text_document.uri.clone();
1104        let range = params.range;
1105        let encoding = *self.encoding.lock().await;
1106        let workspace_root = self.workspace_root.lock().await.clone();
1107        let diagnostics = self
1108            .last_diagnostics
1109            .get(&uri)
1110            .map(|d| d.clone())
1111            .unwrap_or_default();
1112        let actions: Vec<CodeActionOrCommand> = crate::code_actions::code_actions_for_range(
1113            &diagnostics,
1114            range,
1115            &self.docs,
1116            encoding,
1117            workspace_root.as_deref(),
1118        );
1119        if actions.is_empty() {
1120            Ok(None)
1121        } else {
1122            Ok(Some(actions))
1123        }
1124    }
1125}
1126
1127/// Convert (original, formatted) into a minimal list of `TextEdit`s, one per
1128/// change hunk (ADR-0093 Phase 7).
1129///
1130/// Hunks are line-aligned: each edit replaces a contiguous range of lines
1131/// with the corresponding lines from `formatted`. Editors keep cursor /
1132/// fold state on the untouched lines, which a single whole-document replace
1133/// would clobber. Column 0 is encoding-agnostic, so this works under either
1134/// UTF-8 or UTF-16 negotiation without going through the `LineMap`.
1135fn diff_to_text_edits(original: &str, formatted: &str) -> Vec<TextEdit> {
1136    use similar::{DiffOp, TextDiff};
1137
1138    let diff = TextDiff::from_lines(original, formatted);
1139    let new_lines: Vec<&str> = formatted.split_inclusive('\n').collect();
1140
1141    let mut edits = Vec::new();
1142    // grouped_ops(0): split on Equal runs of any length, so each group is a
1143    // contiguous run of changes.
1144    for group in diff.grouped_ops(0) {
1145        if group.is_empty() {
1146            continue;
1147        }
1148        // Skip pure-Equal groups (no changes).
1149        let all_equal = group.iter().all(|op| matches!(op, DiffOp::Equal { .. }));
1150        if all_equal {
1151            continue;
1152        }
1153
1154        let mut old_start = usize::MAX;
1155        let mut old_end = 0;
1156        let mut new_start = usize::MAX;
1157        let mut new_end = 0;
1158        for op in &group {
1159            let (os, oe) = (op.old_range().start, op.old_range().end);
1160            let (ns, ne) = (op.new_range().start, op.new_range().end);
1161            old_start = old_start.min(os);
1162            old_end = old_end.max(oe);
1163            new_start = new_start.min(ns);
1164            new_end = new_end.max(ne);
1165        }
1166
1167        // Collect the new lines for this hunk back into a single replacement
1168        // string. `split_inclusive` keeps newlines, so concatenation
1169        // reconstructs the body byte-for-byte.
1170        let new_text: String = new_lines[new_start..new_end].concat();
1171
1172        let range = Range {
1173            start: Position {
1174                line: old_start as u32,
1175                character: 0,
1176            },
1177            end: Position {
1178                line: old_end as u32,
1179                character: 0,
1180            },
1181        };
1182        edits.push(TextEdit { range, new_text });
1183    }
1184    edits
1185}
1186
1187fn pick_encoding(params: &InitializeParams) -> PositionEncoding {
1188    if let Some(general) = params.capabilities.general.as_ref() {
1189        if let Some(encs) = general.position_encodings.as_ref() {
1190            for e in encs {
1191                if *e == PositionEncodingKind::UTF8 {
1192                    return PositionEncoding::Utf8;
1193                }
1194            }
1195        }
1196    }
1197    PositionEncoding::Utf16
1198}
1199
1200/// Run the LSP server reading from stdin and writing to stdout.
1201pub async fn run_stdio_server(preview_features: PreviewFeatures) -> std::io::Result<()> {
1202    let stdin = tokio::io::stdin();
1203    let stdout = tokio::io::stdout();
1204    let (service, socket) =
1205        LspService::new(move |client| Backend::new(client, preview_features.clone()));
1206    Server::new(stdin, stdout, socket).serve(service).await;
1207    Ok(())
1208}
1209
1210fn to_lsp_kind(k: crate::workspace_symbols::SymbolKind) -> LspSymbolKind {
1211    use crate::workspace_symbols::SymbolKind as K;
1212    match k {
1213        K::Function => LspSymbolKind::FUNCTION,
1214        K::Struct => LspSymbolKind::STRUCT,
1215        K::Enum => LspSymbolKind::ENUM,
1216        K::Interface => LspSymbolKind::INTERFACE,
1217        K::Derive => LspSymbolKind::CLASS,
1218        K::Constant => LspSymbolKind::CONSTANT,
1219        K::Field => LspSymbolKind::FIELD,
1220        K::EnumMember => LspSymbolKind::ENUM_MEMBER,
1221        K::Method => LspSymbolKind::METHOD,
1222    }
1223}
1224
1225fn to_completion_kind(k: crate::completion::CompletionKind) -> CompletionItemKind {
1226    use crate::completion::CompletionKind as K;
1227    match k {
1228        K::Function => CompletionItemKind::FUNCTION,
1229        K::Struct => CompletionItemKind::STRUCT,
1230        K::Enum => CompletionItemKind::ENUM,
1231        K::Interface => CompletionItemKind::INTERFACE,
1232        K::Derive => CompletionItemKind::CLASS,
1233        K::Constant => CompletionItemKind::CONSTANT,
1234        K::Field => CompletionItemKind::FIELD,
1235        K::EnumMember => CompletionItemKind::ENUM_MEMBER,
1236        K::Variable => CompletionItemKind::VARIABLE,
1237        K::Method => CompletionItemKind::METHOD,
1238        K::Keyword => CompletionItemKind::KEYWORD,
1239        K::Intrinsic => CompletionItemKind::FUNCTION,
1240    }
1241}
1242
1243/// Synchronous entry point used by the binary subcommand (creates a tokio
1244/// runtime and starts the stdio server).
1245pub fn run_server(preview_features: PreviewFeatures) -> std::io::Result<()> {
1246    let rt = tokio::runtime::Builder::new_multi_thread()
1247        .enable_all()
1248        .build()?;
1249    rt.block_on(run_stdio_server(preview_features))
1250}