1#![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#[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
92type DocumentStore = DashMap<String, (String, String)>;
97
98pub 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 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 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 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 #[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#[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(¶ms.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 = ¶ms.text_document.uri;
449 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
450
451 for diag in ¶ms.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 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 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
562fn 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 _ => 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
592fn 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
609fn 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 _ => "markdown",
624 }
625 .to_string()
626}
627
628fn 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
655pub 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}