Skip to main content

lang_check/
lsp.rs

1//! LSP JSON-RPC backend for language-check-server.
2//!
3//! Reuses the existing orchestrator, prose extraction, config, dictionary, and
4//! ignore-store logic.  Activated with `language-check-server --lsp`.
5
6#![allow(clippy::cast_possible_truncation)]
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use dashmap::DashMap;
13use tokio::sync::Mutex;
14use tower_lsp::jsonrpc::Result;
15use tower_lsp::lsp_types::{
16    CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams,
17    CodeActionProviderCapability, CodeActionResponse, Command, Diagnostic, DiagnosticSeverity,
18    DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
19    DidOpenTextDocumentParams, DidSaveTextDocumentParams, ExecuteCommandOptions,
20    ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, NumberOrString,
21    Position, Range, ServerCapabilities, ServerInfo, TextDocumentSyncCapability,
22    TextDocumentSyncKind, TextEdit, Url, WorkspaceEdit,
23};
24use tower_lsp::{Client, LanguageServer, LspService, Server};
25use tracing::{debug, info, warn};
26
27use crate::checker;
28use crate::config::Config;
29use crate::dictionary::Dictionary;
30use crate::hashing::{DiagnosticFingerprint, IgnoreStore};
31use crate::names::NameFilter;
32use crate::orchestrator::Orchestrator;
33use crate::prose;
34use crate::sls::SchemaRegistry;
35use crate::suppression::{SuppressionContext, retain_visible};
36use crate::text_util::safe_slice;
37
38// ── LSP settings ────────────────────────────────────────────────────────────
39
40/// Settings received via `workspace/didChangeConfiguration`.
41#[derive(Debug, Default, serde::Deserialize)]
42#[serde(default)]
43struct LspSettings {
44    #[serde(alias = "langCheck")]
45    lang_check: LangCheckSettings,
46}
47
48#[derive(Debug, Default, serde::Deserialize)]
49#[serde(default)]
50struct LangCheckSettings {
51    engines: Option<EngineSettings>,
52    performance: Option<PerformanceSettings>,
53    names: Option<NameSettings>,
54}
55
56#[derive(Debug, Default, serde::Deserialize)]
57#[serde(default)]
58struct NameSettings {
59    enabled: Option<bool>,
60    aggressiveness: Option<crate::names::Aggressiveness>,
61}
62
63#[derive(Debug, Default, serde::Deserialize)]
64#[serde(default)]
65struct EngineSettings {
66    harper: Option<bool>,
67    languagetool: Option<bool>,
68    languagetool_url: Option<String>,
69    vale: Option<bool>,
70    proselint: Option<bool>,
71    spell_language: Option<String>,
72}
73
74#[derive(Debug, Default, serde::Deserialize)]
75#[serde(default)]
76struct PerformanceSettings {
77    high_performance_mode: Option<bool>,
78    debounce_ms: Option<u64>,
79    max_file_size: Option<usize>,
80}
81
82// ── Document store ──────────────────────────────────────────────────────────
83
84/// In-memory text of open documents (keyed by URI string).
85/// Value is `(text, language_id)`.
86type DocumentStore = DashMap<String, (String, String)>;
87
88// ── Backend ─────────────────────────────────────────────────────────────────
89
90pub struct Backend {
91    client: Client,
92    orchestrator: Arc<Mutex<Orchestrator>>,
93    config: Arc<Mutex<Config>>,
94    dictionary: Arc<Mutex<Dictionary>>,
95    name_filter: Arc<Mutex<Option<NameFilter>>>,
96    ignore_store: Arc<Mutex<IgnoreStore>>,
97    schema_registry: Arc<Mutex<SchemaRegistry>>,
98    documents: DocumentStore,
99    workspace_root: Mutex<Option<PathBuf>>,
100}
101
102impl Backend {
103    fn new(client: Client) -> Self {
104        Self {
105            client,
106            orchestrator: Arc::new(Mutex::new(Orchestrator::new(Config::default()))),
107            config: Arc::new(Mutex::new(Config::default())),
108            dictionary: Arc::new(Mutex::new(Dictionary::new())),
109            name_filter: Arc::new(Mutex::new(None)),
110            ignore_store: Arc::new(Mutex::new(IgnoreStore::new())),
111            schema_registry: Arc::new(Mutex::new(SchemaRegistry::new())),
112            documents: DashMap::new(),
113            workspace_root: Mutex::new(None),
114        }
115    }
116
117    /// Initialise all state from the workspace root (config, dictionary, …).
118    async fn init_workspace(&self, root: &Path) {
119        let config = Config::load(root).unwrap_or_default();
120        info!(
121            harper = config.engines.harper.enabled,
122            languagetool = config.engines.languagetool.enabled,
123            vale = config.engines.vale.enabled,
124            proselint = config.engines.proselint.enabled,
125            "LSP: engines configured"
126        );
127
128        self.orchestrator.lock().await.update_config(config.clone());
129        *self.config.lock().await = config.clone();
130
131        match Dictionary::load(root) {
132            Ok(mut dict) => {
133                if config.dictionaries.bundled {
134                    dict.load_bundled();
135                }
136                for p in &config.dictionaries.paths {
137                    if let Err(e) = dict.load_wordlist_file(Path::new(p), root) {
138                        warn!(path = p, "Could not load wordlist: {e}");
139                    }
140                }
141                *self.dictionary.lock().await = dict;
142            }
143            Err(e) => warn!("Could not load dictionary: {e}"),
144        }
145
146        *self.name_filter.lock().await = config.names.enabled.then(|| {
147            info!(
148                aggressiveness = ?config.names.aggressiveness,
149                "LSP: name detection enabled"
150            );
151            NameFilter::new(config.names.aggressiveness, &config.engines.spell_language)
152        });
153
154        if let Ok(store) = IgnoreStore::load(root) {
155            *self.ignore_store.lock().await = store;
156        }
157        if let Ok(reg) = SchemaRegistry::from_workspace(root) {
158            *self.schema_registry.lock().await = reg;
159        }
160
161        *self.workspace_root.lock().await = Some(root.to_path_buf());
162    }
163
164    /// Apply LSP settings on top of the workspace config.
165    async fn apply_settings(&self, settings: &LangCheckSettings) {
166        let mut config = self.config.lock().await;
167        if let Some(ref eng) = settings.engines {
168            if let Some(v) = eng.harper {
169                config.engines.harper.enabled = v;
170            }
171            if let Some(v) = eng.languagetool {
172                config.engines.languagetool.enabled = v;
173            }
174            if let Some(ref v) = eng.languagetool_url {
175                config.engines.languagetool.url.clone_from(v);
176            }
177            if let Some(v) = eng.vale {
178                config.engines.vale.enabled = v;
179            }
180            if let Some(v) = eng.proselint {
181                config.engines.proselint.enabled = v;
182            }
183            if let Some(ref v) = eng.spell_language {
184                config.engines.spell_language.clone_from(v);
185            }
186        }
187        if let Some(ref names) = settings.names {
188            if let Some(v) = names.enabled {
189                config.names.enabled = v;
190            }
191            if let Some(v) = names.aggressiveness {
192                config.names.aggressiveness = v;
193            }
194        }
195        if let Some(ref perf) = settings.performance {
196            if let Some(v) = perf.high_performance_mode {
197                config.performance.high_performance_mode = v;
198            }
199            if let Some(v) = perf.debounce_ms {
200                config.performance.debounce_ms = v;
201            }
202            if let Some(v) = perf.max_file_size {
203                config.performance.max_file_size = v;
204            }
205        }
206        let updated = config.clone();
207        drop(config);
208        *self.name_filter.lock().await = updated.names.enabled.then(|| {
209            NameFilter::new(
210                updated.names.aggressiveness,
211                &updated.engines.spell_language,
212            )
213        });
214        self.orchestrator.lock().await.update_config(updated);
215        info!("LSP: config updated via didChangeConfiguration");
216    }
217
218    /// Re-diagnose all currently open documents.
219    async fn rediagnose_all(&self) {
220        let entries: Vec<(String, String, String)> = self
221            .documents
222            .iter()
223            .map(|r| {
224                let (text, lang_id) = r.value();
225                (r.key().clone(), text.clone(), lang_id.clone())
226            })
227            .collect();
228        for (uri_str, text, lang_id) in entries {
229            if let Ok(uri) = Url::parse(&uri_str) {
230                self.diagnose(&uri, &text, &lang_id).await;
231            }
232        }
233    }
234
235    /// Run diagnostics on a document and publish them.
236    // The three suppression-source guards are borrowed by `SuppressionContext`, so they
237    // genuinely have to outlive the `retain_visible` call; clippy's nursery lint reads
238    // the last direct mention of each guard as its last use and asks for an earlier drop
239    // that would not compile. The server binary allows this lint file-wide for the same
240    // pattern.
241    #[allow(clippy::significant_drop_tightening)]
242    async fn diagnose(&self, uri: &Url, text: &str, lang_id: &str) {
243        let canonical = crate::languages::resolve_language_id(lang_id);
244
245        let extraction = {
246            let schema_reg = self.schema_registry.lock().await;
247            let cfg = self.config.lock().await;
248            let latex_extras = prose::latex::LatexExtras {
249                skip_envs: &cfg.languages.latex.skip_environments,
250                skip_commands: &cfg.languages.latex.skip_commands,
251            };
252            let result = prose::extract_with_fallback(
253                text,
254                canonical,
255                None,
256                Some(&schema_reg),
257                &latex_extras,
258            );
259            drop(cfg);
260            drop(schema_reg);
261            result
262        };
263
264        let ranges = match extraction {
265            Ok(r) => r,
266            Err(e) => {
267                warn!(uri = %uri, "Extraction error: {e}");
268                return;
269            }
270        };
271
272        let mut all_diagnostics: Vec<Diagnostic> = Vec::new();
273
274        for range in &ranges {
275            let prose_text = range.extract_text(text);
276
277            let check_result = {
278                let mut orch = self.orchestrator.lock().await;
279                orch.check(&prose_text, lang_id).await
280            };
281
282            if let Ok(mut diags) = check_result {
283                diags.retain(|d| {
284                    !range.suppresses_diagnostic(text, d.start_byte, d.end_byte, &d.unified_id)
285                });
286
287                for d in &mut diags {
288                    d.start_byte += range.start_byte as u32;
289                    d.end_byte += range.start_byte as u32;
290                }
291
292                {
293                    let ignore = self.ignore_store.lock().await;
294                    let dict = self.dictionary.lock().await;
295                    let names = self.name_filter.lock().await;
296                    let mut ctx = SuppressionContext::new()
297                        .with_ignore(&ignore)
298                        .with_dictionary(&dict);
299                    if let Some(filter) = names.as_ref() {
300                        ctx = ctx.with_names(filter);
301                    }
302                    retain_visible(&mut diags, text, &ctx);
303                }
304
305                all_diagnostics.extend(diags.iter().map(|d| to_lsp_diagnostic(text, d)));
306            }
307        }
308
309        self.client
310            .publish_diagnostics(uri.clone(), all_diagnostics, None)
311            .await;
312    }
313}
314
315// ── LanguageServer impl ─────────────────────────────────────────────────────
316
317#[tower_lsp::async_trait]
318impl LanguageServer for Backend {
319    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
320        if let Some(root_uri) = params.root_uri
321            && let Ok(path) = root_uri.to_file_path()
322        {
323            self.init_workspace(&path).await;
324        }
325
326        Ok(InitializeResult {
327            capabilities: ServerCapabilities {
328                text_document_sync: Some(TextDocumentSyncCapability::Kind(
329                    TextDocumentSyncKind::FULL,
330                )),
331                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
332                execute_command_provider: Some(ExecuteCommandOptions {
333                    commands: vec![
334                        "langCheck.addDictionaryWord".into(),
335                        "langCheck.ignoreDiagnostic".into(),
336                    ],
337                    ..Default::default()
338                }),
339                ..Default::default()
340            },
341            server_info: Some(ServerInfo {
342                name: "language-check-server".into(),
343                version: Some(env!("CARGO_PKG_VERSION").into()),
344            }),
345        })
346    }
347
348    async fn initialized(&self, _: InitializedParams) {
349        info!("LSP client initialized");
350    }
351
352    async fn shutdown(&self) -> Result<()> {
353        Ok(())
354    }
355
356    async fn did_open(&self, params: DidOpenTextDocumentParams) {
357        let uri = params.text_document.uri;
358        let text = params.text_document.text;
359        let lang_id = params.text_document.language_id.clone();
360        self.documents
361            .insert(uri.to_string(), (text.clone(), lang_id.clone()));
362        self.diagnose(&uri, &text, &lang_id).await;
363    }
364
365    async fn did_change(&self, params: DidChangeTextDocumentParams) {
366        let uri = params.text_document.uri;
367        if let Some(change) = params.content_changes.into_iter().last() {
368            let lang_id = guess_lang_id(&uri);
369            self.documents
370                .insert(uri.to_string(), (change.text.clone(), lang_id.clone()));
371            self.diagnose(&uri, &change.text, &lang_id).await;
372        }
373    }
374
375    async fn did_save(&self, params: DidSaveTextDocumentParams) {
376        let uri = params.text_document.uri;
377        let key = uri.to_string();
378        let entry = self.documents.get(&key).map(|r| r.value().clone());
379        if let Some((text, lang_id)) = entry {
380            self.diagnose(&uri, &text, &lang_id).await;
381        }
382    }
383
384    async fn did_close(&self, params: DidCloseTextDocumentParams) {
385        self.documents.remove(&params.text_document.uri.to_string());
386    }
387
388    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
389        let settings: LspSettings = serde_json::from_value(params.settings).unwrap_or_default();
390        self.apply_settings(&settings.lang_check).await;
391        self.rediagnose_all().await;
392    }
393
394    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
395        let uri = &params.text_document.uri;
396        let mut actions: Vec<CodeActionOrCommand> = Vec::new();
397
398        for diag in &params.context.diagnostics {
399            if diag.source.as_deref() != Some("language-check") {
400                continue;
401            }
402
403            let Some(data) = &diag.data else { continue };
404            let Some(obj) = data.as_object() else {
405                continue;
406            };
407
408            // Apply suggestion actions
409            if let Some(suggestions) = obj.get("suggestions").and_then(|v| v.as_array()) {
410                for s in suggestions {
411                    if let Some(text) = s.as_str() {
412                        let edit = TextEdit {
413                            range: diag.range,
414                            new_text: text.to_string(),
415                        };
416                        let mut changes = HashMap::new();
417                        changes.insert(uri.clone(), vec![edit]);
418                        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
419                            title: format!("Replace with \"{text}\""),
420                            kind: Some(CodeActionKind::QUICKFIX),
421                            diagnostics: Some(vec![diag.clone()]),
422                            edit: Some(WorkspaceEdit {
423                                changes: Some(changes),
424                                ..Default::default()
425                            }),
426                            ..Default::default()
427                        }));
428                    }
429                }
430            }
431
432            // Add to dictionary (spelling rules)
433            if let Some(rule_id) = obj.get("rule_id").and_then(|v| v.as_str())
434                && (rule_id.contains("TYPO")
435                    || rule_id.contains("MORFOLOGIK")
436                    || rule_id.contains("spelling"))
437                && let Some(doc) = self.documents.get(&uri.to_string())
438            {
439                let word = extract_word_at_range(&doc.value().0, diag.range).unwrap_or_default();
440                if !word.is_empty() {
441                    actions.push(CodeActionOrCommand::CodeAction(CodeAction {
442                        title: format!("Add \"{word}\" to dictionary"),
443                        kind: Some(CodeActionKind::QUICKFIX),
444                        diagnostics: Some(vec![diag.clone()]),
445                        command: Some(Command {
446                            title: "Add to dictionary".into(),
447                            command: "langCheck.addDictionaryWord".into(),
448                            arguments: Some(vec![serde_json::json!(word)]),
449                        }),
450                        ..Default::default()
451                    }));
452                }
453            }
454        }
455
456        if actions.is_empty() {
457            Ok(None)
458        } else {
459            Ok(Some(actions))
460        }
461    }
462
463    async fn execute_command(
464        &self,
465        params: ExecuteCommandParams,
466    ) -> Result<Option<serde_json::Value>> {
467        match params.command.as_str() {
468            "langCheck.addDictionaryWord" => {
469                if let Some(word_val) = params.arguments.first()
470                    && let Some(word) = word_val.as_str()
471                {
472                    debug!(word, "Adding to dictionary");
473                    let mut dict = self.dictionary.lock().await;
474                    if let Err(e) = dict.add_word(word) {
475                        warn!(word, "Failed to add word: {e}");
476                    }
477                }
478            }
479            "langCheck.ignoreDiagnostic" => {
480                if let Some(args) = params.arguments.first()
481                    && let Some(obj) = args.as_object()
482                {
483                    let message = obj
484                        .get("message")
485                        .and_then(|v| v.as_str())
486                        .unwrap_or_default();
487                    let context = obj
488                        .get("context")
489                        .and_then(|v| v.as_str())
490                        .unwrap_or_default();
491                    let start = obj
492                        .get("start_byte")
493                        .and_then(serde_json::Value::as_u64)
494                        .map_or(0, |v| v as usize);
495                    let end = obj
496                        .get("end_byte")
497                        .and_then(serde_json::Value::as_u64)
498                        .map_or(0, |v| v as usize);
499                    let fp = DiagnosticFingerprint::new(message, context, start, end);
500                    self.ignore_store.lock().await.ignore(&fp);
501                }
502            }
503            _ => {}
504        }
505        Ok(None)
506    }
507}
508
509// ── Helpers ─────────────────────────────────────────────────────────────────
510
511/// Convert an internal Diagnostic to an LSP Diagnostic.
512fn to_lsp_diagnostic(text: &str, d: &checker::Diagnostic) -> Diagnostic {
513    let range = byte_range_to_lsp(text, d.start_byte as usize, d.end_byte as usize);
514    let severity = match d.severity {
515        3 => Some(DiagnosticSeverity::ERROR),
516        2 => Some(DiagnosticSeverity::WARNING),
517        4 => Some(DiagnosticSeverity::HINT),
518        // SEVERITY_UNSPECIFIED (0) and SEVERITY_INFORMATION (1)
519        _ => Some(DiagnosticSeverity::INFORMATION),
520    };
521
522    let data = serde_json::json!({
523        "suggestions": d.suggestions,
524        "rule_id": d.rule_id,
525        "unified_id": d.unified_id,
526    });
527
528    Diagnostic {
529        range,
530        severity,
531        source: Some("language-check".into()),
532        code: Some(NumberOrString::String(d.unified_id.clone())),
533        message: d.message.clone(),
534        data: Some(data),
535        ..Default::default()
536    }
537}
538
539/// Convert byte offsets to an LSP Range (line/character).
540fn byte_range_to_lsp(text: &str, start: usize, end: usize) -> Range {
541    Range {
542        start: byte_to_position(text, start),
543        end: byte_to_position(text, end),
544    }
545}
546
547fn byte_to_position(text: &str, byte_offset: usize) -> Position {
548    let offset = byte_offset.min(text.len());
549    let prefix = &text[..offset];
550    let line = prefix.matches('\n').count() as u32;
551    let last_newline = prefix.rfind('\n').map_or(0, |i| i + 1);
552    let character = prefix[last_newline..].chars().count() as u32;
553    Position { line, character }
554}
555
556/// Guess a language ID from a file URI extension.
557fn guess_lang_id(uri: &Url) -> String {
558    let path = uri.path();
559    let ext = path.rsplit('.').next().unwrap_or("");
560    match ext {
561        "html" | "htm" | "xhtml" => "html",
562        "tex" | "latex" | "ltx" => "latex",
563        "typ" => "typst",
564        "rst" => "rst",
565        "org" => "org",
566        "bib" => "bibtex",
567        "Rnw" | "rnw" | "Snw" | "snw" => "sweave",
568        "tree" => "forester",
569        // md, mdx, markdown, and everything else defaults to markdown
570        _ => "markdown",
571    }
572    .to_string()
573}
574
575/// Extract the word at a given LSP range from a document.
576fn extract_word_at_range(text: &str, range: Range) -> Option<String> {
577    let start = position_to_byte(text, range.start)?;
578    let end = position_to_byte(text, range.end)?;
579    Some(safe_slice(text, start, end).to_string())
580}
581
582fn position_to_byte(text: &str, pos: Position) -> Option<usize> {
583    let mut line = 0u32;
584    let mut byte = 0usize;
585    for (i, ch) in text.char_indices() {
586        if line == pos.line {
587            let col_offset = text[byte..].char_indices().nth(pos.character as usize);
588            return Some(col_offset.map_or(text.len(), |(off, _)| byte + off));
589        }
590        if ch == '\n' {
591            line += 1;
592            byte = i + 1;
593        }
594    }
595    if line == pos.line {
596        let col_offset = text[byte..].char_indices().nth(pos.character as usize);
597        return Some(col_offset.map_or(text.len(), |(off, _)| byte + off));
598    }
599    None
600}
601
602// ── Entry point ─────────────────────────────────────────────────────────────
603
604/// Run the LSP server on stdin/stdout.
605pub async fn run_lsp() {
606    let stdin = tokio::io::stdin();
607    let stdout = tokio::io::stdout();
608
609    let (service, socket) = LspService::new(Backend::new);
610    Server::new(stdin, stdout, socket).serve(service).await;
611}