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::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#[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
82type DocumentStore = DashMap<String, (String, String)>;
87
88pub 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 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 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 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 #[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#[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(¶ms.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 = ¶ms.text_document.uri;
396 let mut actions: Vec<CodeActionOrCommand> = Vec::new();
397
398 for diag in ¶ms.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 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 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
509fn 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 _ => 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
539fn 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
556fn 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 _ => "markdown",
571 }
572 .to_string()
573}
574
575fn 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
602pub 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}