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