1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::str::FromStr as _;
4use std::sync::{Arc, Mutex, OnceLock, RwLock};
5use std::time::{Duration, Instant};
6
7use codebook::parser::get_word_from_string;
8use codebook::queries::LanguageType;
9use string_offsets::AllConfig;
10use string_offsets::Pos;
11use string_offsets::StringOffsets;
12
13use log::error;
14use serde_json::Value;
15use tokio::task;
16use tower_lsp::jsonrpc::Result as RpcResult;
17use tower_lsp::lsp_types::*;
18use tower_lsp::{Client, LanguageServer};
19
20use codebook::Codebook;
21use codebook_config::{CodebookConfig, CodebookConfigFile};
22use log::{debug, info};
23
24use crate::file_cache::TextDocumentCache;
25use crate::init_options::ClientInitializationOptions;
26use crate::lsp_logger;
27
28const SOURCE_NAME: &str = "Codebook";
29
30const CONFIG_POLL_INTERVAL: Duration = Duration::from_secs(5);
35
36fn compute_relative_path(
40 workspace_dir: &Path,
41 workspace_dir_canonical: Option<&Path>,
42 file_path: &Path,
43) -> String {
44 let workspace_canonical = match workspace_dir_canonical {
45 Some(dir) => dir.to_path_buf(),
46 None => match workspace_dir.canonicalize() {
47 Ok(dir) => dir,
48 Err(err) => {
49 info!("Could not canonicalize workspace directory. Error: {err}.");
50 return file_path.to_string_lossy().to_string();
51 }
52 },
53 };
54
55 match file_path.canonicalize() {
56 Ok(canon_file_path) => match canon_file_path.strip_prefix(&workspace_canonical) {
57 Ok(relative) => relative.to_string_lossy().to_string(),
58 Err(_) => file_path.to_string_lossy().to_string(),
59 },
60 Err(_) => file_path.to_string_lossy().to_string(),
61 }
62}
63
64pub struct Backend {
65 client: Client,
66 workspace_dir: PathBuf,
67 workspace_dir_canonical: Option<PathBuf>,
69 codebook: OnceLock<Arc<Codebook>>,
70 config: OnceLock<Arc<CodebookConfigFile>>,
71 document_cache: TextDocumentCache,
72 initialize_options: RwLock<Arc<ClientInitializationOptions>>,
73 last_config_poll: Mutex<Option<Instant>>,
75}
76
77enum CodebookCommand {
78 AddWord,
79 AddWordGlobal,
80 IgnoreFile,
81 Unknown,
82}
83
84impl From<&str> for CodebookCommand {
85 fn from(command: &str) -> Self {
86 match command {
87 "codebook.addWord" => CodebookCommand::AddWord,
88 "codebook.addWordGlobal" => CodebookCommand::AddWordGlobal,
89 "codebook.ignoreFile" => CodebookCommand::IgnoreFile,
90 _ => CodebookCommand::Unknown,
91 }
92 }
93}
94
95impl From<CodebookCommand> for String {
96 fn from(command: CodebookCommand) -> Self {
97 match command {
98 CodebookCommand::AddWord => "codebook.addWord".to_string(),
99 CodebookCommand::AddWordGlobal => "codebook.addWordGlobal".to_string(),
100 CodebookCommand::IgnoreFile => "codebook.ignoreFile".to_string(),
101 CodebookCommand::Unknown => "codebook.unknown".to_string(),
102 }
103 }
104}
105
106#[tower_lsp::async_trait]
107impl LanguageServer for Backend {
108 async fn initialize(&self, params: InitializeParams) -> RpcResult<InitializeResult> {
109 let client_options = ClientInitializationOptions::from_value(params.initialization_options);
111 info!("Client options: {:?}", client_options);
112
113 lsp_logger::LspLogger::attach_client(self.client.clone(), client_options.log_level);
115 info!(
116 "LSP logger attached to client with log level: {}",
117 client_options.log_level
118 );
119
120 *self.initialize_options.write().unwrap() = Arc::new(client_options);
121
122 Ok(InitializeResult {
123 capabilities: ServerCapabilities {
124 position_encoding: Some(PositionEncodingKind::UTF16),
125 text_document_sync: Some(TextDocumentSyncCapability::Options(
126 TextDocumentSyncOptions {
127 open_close: Some(true),
128 change: Some(TextDocumentSyncKind::FULL),
129 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
130 include_text: Some(true),
131 })),
132 ..TextDocumentSyncOptions::default()
133 },
134 )),
135 execute_command_provider: Some(ExecuteCommandOptions {
136 commands: vec![
137 CodebookCommand::AddWord.into(),
138 CodebookCommand::AddWordGlobal.into(),
139 CodebookCommand::IgnoreFile.into(),
140 ],
141 work_done_progress_options: Default::default(),
142 }),
143 code_action_provider: Some(CodeActionProviderCapability::Options(
144 CodeActionOptions {
145 code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
146 resolve_provider: None,
147 work_done_progress_options: WorkDoneProgressOptions {
148 work_done_progress: None,
149 },
150 },
151 )),
152 ..ServerCapabilities::default()
153 },
154 server_info: Some(ServerInfo {
155 name: format!("{SOURCE_NAME} Language Server"),
156 version: Some(env!("CARGO_PKG_VERSION").to_string()),
157 }),
158 })
159 }
160
161 async fn initialized(&self, _: InitializedParams) {
162 info!("Server ready!");
163 let config = self.config_handle();
164 match config.project_config_path() {
165 Some(path) => info!("Project config: {}", path.display()),
166 None => info!("Project config: <not set>"),
167 }
168 info!(
169 "Global config: {}",
170 config.global_config_path().unwrap_or_default().display()
171 );
172 }
173
174 async fn shutdown(&self) -> RpcResult<()> {
175 info!("Server shutting down");
176 Ok(())
177 }
178
179 async fn did_open(&self, params: DidOpenTextDocumentParams) {
180 debug!(
181 "Opened document: uri {:?}, language: {}, version: {}",
182 params.text_document.uri,
183 params.text_document.language_id,
184 params.text_document.version
185 );
186 self.document_cache.insert(¶ms.text_document);
187 if self.should_spellcheck_while_typing() {
188 self.spell_check(¶ms.text_document.uri).await;
189 }
190 }
191
192 async fn did_close(&self, params: DidCloseTextDocumentParams) {
193 self.document_cache.remove(¶ms.text_document.uri);
194 self.client
196 .publish_diagnostics(params.text_document.uri, vec![], None)
197 .await;
198 }
199
200 async fn did_save(&self, params: DidSaveTextDocumentParams) {
201 debug!("Saved document: {}", params.text_document.uri);
202 if let Some(text) = params.text {
203 self.document_cache
204 .update(¶ms.text_document.uri, &text, None);
205 }
206 self.spell_check(¶ms.text_document.uri).await;
207 }
208
209 async fn did_change(&self, params: DidChangeTextDocumentParams) {
210 debug!(
211 "Changed document: uri={}, version={}",
212 params.text_document.uri, params.text_document.version
213 );
214 let uri = params.text_document.uri;
215 if let Some(change) = params.content_changes.last() {
218 self.document_cache
219 .update(&uri, &change.text, Some(params.text_document.version));
220 if self.should_spellcheck_while_typing() {
221 self.spell_check(&uri).await;
222 }
223 }
224 }
225
226 async fn code_action(&self, params: CodeActionParams) -> RpcResult<Option<CodeActionResponse>> {
227 let mut actions: Vec<CodeActionOrCommand> = vec![];
228 let doc = match self.document_cache.get(params.text_document.uri.as_ref()) {
229 Some(doc) => doc,
230 None => return Ok(None),
231 };
232
233 let mut has_codebook_diagnostic = false;
234 for diag in params.context.diagnostics {
235 if diag.source.as_deref() != Some(SOURCE_NAME) {
237 continue;
238 }
239 has_codebook_diagnostic = true;
240 let line = doc
241 .text
242 .lines()
243 .nth(diag.range.start.line as usize)
244 .unwrap_or_default();
245 let start_char = diag.range.start.character as usize;
246 let end_char = diag.range.end.character as usize;
247 let word = get_word_from_string(start_char, end_char, line);
248 if word.is_empty() || word.contains(" ") {
250 continue;
251 }
252 let cb = self.codebook_handle();
253 let inner_word = word.clone();
254 let suggestions = task::spawn_blocking(move || cb.get_suggestions(&inner_word)).await;
255
256 let suggestions = match suggestions {
257 Ok(suggestions) => suggestions,
258 Err(e) => {
259 error!(
260 "Error getting suggestions for word '{}' in file '{}'\n Error: {}",
261 word,
262 doc.uri.path(),
263 e
264 );
265 continue;
266 }
267 };
268
269 if suggestions.is_none() {
270 continue;
271 }
272
273 suggestions.unwrap().iter().for_each(|suggestion| {
274 actions.push(CodeActionOrCommand::CodeAction(self.make_suggestion(
275 suggestion,
276 &diag.range,
277 ¶ms.text_document.uri,
278 )));
279 });
280 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
281 title: format!("Add '{word}' to dictionary"),
282 kind: Some(CodeActionKind::QUICKFIX),
283 diagnostics: None,
284 edit: None,
285 command: Some(Command {
286 title: format!("Add '{word}' to dictionary"),
287 command: CodebookCommand::AddWord.into(),
288 arguments: Some(vec![word.to_string().into()]),
289 }),
290 is_preferred: None,
291 disabled: None,
292 data: None,
293 }));
294 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
295 title: format!("Add '{word}' to global dictionary"),
296 kind: Some(CodeActionKind::QUICKFIX),
297 diagnostics: None,
298 edit: None,
299 command: Some(Command {
300 title: format!("Add '{word}' to global dictionary"),
301 command: CodebookCommand::AddWordGlobal.into(),
302 arguments: Some(vec![word.to_string().into()]),
303 }),
304 is_preferred: None,
305 disabled: None,
306 data: None,
307 }));
308 }
309 if has_codebook_diagnostic {
310 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
311 title: "Add current file to ignore list".to_string(),
312 kind: Some(CodeActionKind::QUICKFIX),
313 diagnostics: None,
314 edit: None,
315 command: Some(Command {
316 title: "Add current file to ignore list".to_string(),
317 command: CodebookCommand::IgnoreFile.into(),
318 arguments: Some(vec![params.text_document.uri.to_string().into()]),
319 }),
320 is_preferred: None,
321 disabled: None,
322 data: None,
323 }));
324 }
325 if actions.is_empty() {
326 return Ok(None);
327 }
328 Ok(Some(actions))
329 }
330
331 async fn execute_command(&self, params: ExecuteCommandParams) -> RpcResult<Option<Value>> {
332 match CodebookCommand::from(params.command.as_str()) {
333 CodebookCommand::AddWord => {
334 let config = self.config_handle();
335 let words = params
336 .arguments
337 .iter()
338 .filter_map(|arg| arg.as_str().map(|s| s.to_string()));
339 info!(
340 "Adding words to dictionary {}",
341 words.clone().collect::<Vec<String>>().join(", ")
342 );
343 let updated = self.add_words(config.as_ref(), words);
344 if updated {
345 if let Err(e) = config.save() {
346 error!("Failed to save config: {e}");
347 }
348 self.recheck_all().await;
349 }
350 Ok(None)
351 }
352 CodebookCommand::AddWordGlobal => {
353 let config = self.config_handle();
354 let words = params
355 .arguments
356 .iter()
357 .filter_map(|arg| arg.as_str().map(|s| s.to_string()));
358 let updated = self.add_words_global(config.as_ref(), words);
359 if updated {
360 if let Err(e) = config.save_global() {
361 error!("Failed to save global config: {e}");
362 }
363 self.recheck_all().await;
364 }
365 Ok(None)
366 }
367 CodebookCommand::IgnoreFile => {
368 let Some(file_uri) = params.arguments.first().and_then(|arg| arg.as_str()) else {
369 error!("IgnoreFile command missing or invalid file URI argument");
370 return Ok(None);
371 };
372 let config = self.config_handle();
373 let updated = self.add_ignore_file(config.as_ref(), file_uri);
374 if updated {
375 if let Err(e) = config.save() {
376 error!("Failed to save config: {e}");
377 }
378 self.recheck_all().await;
379 }
380 Ok(None)
381 }
382 CodebookCommand::Unknown => Ok(None),
383 }
384 }
385}
386
387impl Backend {
388 pub fn new(client: Client, workspace_dir: &Path) -> Self {
389 let workspace_dir_canonical = workspace_dir.canonicalize().ok();
390 Self {
391 client,
392 workspace_dir: workspace_dir.to_path_buf(),
393 workspace_dir_canonical,
394 codebook: OnceLock::new(),
395 config: OnceLock::new(),
396 document_cache: TextDocumentCache::default(),
397 initialize_options: RwLock::new(Arc::new(ClientInitializationOptions::default())),
398 last_config_poll: Mutex::new(None),
399 }
400 }
401
402 fn config_handle(&self) -> Arc<CodebookConfigFile> {
403 self.config
404 .get_or_init(|| {
405 let options = self.initialize_options.read().unwrap();
406 let global_config_path = options.global_config_path.clone();
407 let project_config_path = options
408 .config_path
409 .clone()
410 .map(|p| self.resolve_workspace_path(&p));
411 drop(options);
412
413 Arc::new(
418 CodebookConfigFile::load_with_overrides(
419 Some(self.workspace_dir.as_path()),
420 global_config_path,
421 project_config_path,
422 )
423 .unwrap_or_else(|e| panic!("Unable to load configuration: {e}")),
424 )
425 })
426 .clone()
427 }
428
429 fn resolve_workspace_path(&self, path: &Path) -> PathBuf {
432 if path.is_absolute() {
433 path.to_path_buf()
434 } else {
435 self.workspace_dir.join(path)
436 }
437 }
438
439 fn codebook_handle(&self) -> Arc<Codebook> {
440 self.codebook
441 .get_or_init(|| Arc::new(Codebook::new(self.config_handle())))
442 .clone()
443 }
444
445 fn should_spellcheck_while_typing(&self) -> bool {
446 self.initialize_options.read().unwrap().check_while_typing
447 }
448
449 fn add_words(&self, config: &CodebookConfigFile, words: impl Iterator<Item = String>) -> bool {
450 let mut should_save = false;
451 for word in words {
452 if config.add_word(&word) {
453 should_save = true;
454 } else {
455 info!("Word '{word}' already exists in dictionary.");
456 }
457 }
458 should_save
459 }
460
461 fn add_words_global(
462 &self,
463 config: &CodebookConfigFile,
464 words: impl Iterator<Item = String>,
465 ) -> bool {
466 let mut should_save = false;
467 for word in words {
468 if config.add_word_global(&word) {
469 should_save = true;
470 } else {
471 info!("Word '{word}' already exists in global dictionary.");
472 }
473 }
474 should_save
475 }
476
477 fn get_relative_path(&self, uri: &str) -> Option<String> {
478 let parsed_uri = match Url::parse(uri) {
479 Ok(u) => u,
480 Err(e) => {
481 error!("Failed to parse URI '{uri}': {e}");
482 return None;
483 }
484 };
485 let file_path = parsed_uri.to_file_path().unwrap_or_default();
486 Some(compute_relative_path(
487 &self.workspace_dir,
488 self.workspace_dir_canonical.as_deref(),
489 &file_path,
490 ))
491 }
492
493 fn add_ignore_file(&self, config: &CodebookConfigFile, file_uri: &str) -> bool {
494 let Some(relative_path) = self.get_relative_path(file_uri) else {
495 return false;
496 };
497 if config.add_ignore(&relative_path) {
498 true
499 } else {
500 info!("File {file_uri} already exists in the ignored files.");
501 false
502 }
503 }
504
505 fn make_suggestion(&self, suggestion: &str, range: &Range, uri: &Url) -> CodeAction {
506 let title = format!("Replace with '{suggestion}'");
507 let mut map = HashMap::new();
508 map.insert(
509 uri.clone(),
510 vec![TextEdit {
511 range: *range,
512 new_text: suggestion.to_string(),
513 }],
514 );
515 let edit = Some(WorkspaceEdit {
516 changes: Some(map),
517 document_changes: None,
518 change_annotations: None,
519 });
520 CodeAction {
521 title: title.to_string(),
522 kind: Some(CodeActionKind::QUICKFIX),
523 diagnostics: None,
524 edit,
525 command: None,
526 is_preferred: None,
527 disabled: None,
528 data: None,
529 }
530 }
531
532 async fn recheck_all(&self) {
533 let urls = self.document_cache.cached_urls();
534 debug!("Rechecking documents: {urls:?}");
535 for url in urls {
536 self.publish_spellcheck_diagnostics(&url).await;
537 }
538 }
539
540 fn reload_config_debounced(&self) -> bool {
543 {
544 let mut last_poll = self.last_config_poll.lock().unwrap();
545 if last_poll.is_some_and(|at| at.elapsed() < CONFIG_POLL_INTERVAL) {
546 return false;
547 }
548 *last_poll = Some(Instant::now());
549 }
550
551 self.config_handle().reload()
552 }
553
554 async fn spell_check(&self, uri: &Url) {
555 let did_reload = self.reload_config_debounced();
556
557 if did_reload {
558 debug!("Config reloaded, rechecking all files.");
559 self.recheck_all().await;
560 } else {
561 debug!("Checking file: {uri:?}");
562 self.publish_spellcheck_diagnostics(uri).await;
563 }
564 }
565
566 async fn publish_spellcheck_diagnostics(&self, uri: &Url) {
568 let doc = match self.document_cache.get(uri.as_ref()) {
569 Some(doc) => doc,
570 None => return,
571 };
572 let file_path = doc.uri.to_file_path().unwrap_or_default();
574 debug!("Spell-checking file: {file_path:?}");
575
576 let lang = doc.language_id.as_deref();
577 let lang_type = lang.and_then(|lang| LanguageType::from_str(lang).ok());
578 debug!("Document identified as type {lang_type:?} from {lang:?}");
579
580 let severity = self.initialize_options.read().unwrap().diagnostic_severity;
581 let workspace_dir = self.workspace_dir.clone();
582 let workspace_dir_canonical = self.workspace_dir_canonical.clone();
583 let cb = self.codebook_handle();
584 let checked_version = doc.version;
585 let doc_uri = doc.uri.clone();
586
587 let diagnostics = task::spawn_blocking(move || {
591 let relative_path = compute_relative_path(
592 &workspace_dir,
593 workspace_dir_canonical.as_deref(),
594 &file_path,
595 );
596 let offsets = StringOffsets::<AllConfig>::new(&doc.text);
597 let spell_results = cb.spell_check(&doc.text, lang_type, Some(&relative_path));
598 spell_results
599 .into_iter()
600 .flat_map(|res| {
601 res.locations
603 .iter()
604 .map(|loc| {
605 let start_pos = offsets.utf8_to_utf16_pos(loc.start_byte);
606 let end_pos = offsets.utf8_to_utf16_pos(loc.end_byte);
607 make_diagnostic(&res.word, &start_pos, &end_pos, severity)
608 })
609 .collect::<Vec<_>>()
610 })
611 .collect::<Vec<Diagnostic>>()
612 })
613 .await;
614
615 let diagnostics = match diagnostics {
616 Ok(diagnostics) => diagnostics,
617 Err(err) => {
618 error!("Spell-checking failed for '{uri}': {err}");
619 return;
620 }
621 };
622
623 match self.document_cache.get(uri.as_ref()) {
628 Some(current) if current.version == checked_version => {}
629 _ => {
630 debug!("Skipping stale diagnostics for {uri}");
631 return;
632 }
633 }
634 self.client
635 .publish_diagnostics(doc_uri, diagnostics, checked_version)
636 .await;
637 }
638}
639
640fn make_diagnostic(
642 word: &str,
643 start_pos: &Pos,
644 end_pos: &Pos,
645 severity: DiagnosticSeverity,
646) -> Diagnostic {
647 let message = format!("Possible spelling issue '{word}'.");
648 Diagnostic {
649 range: Range {
650 start: Position {
651 line: start_pos.line as u32,
652 character: start_pos.col as u32,
653 },
654 end: Position {
655 line: end_pos.line as u32,
656 character: end_pos.col as u32,
657 },
658 },
659 severity: Some(severity),
660 code: None,
661 code_description: None,
662 source: Some(SOURCE_NAME.to_string()),
663 message,
664 related_information: None,
665 tags: None,
666 data: None,
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use std::fs;
674 use tempfile::tempdir;
675
676 #[test]
677 fn test_compute_relative_path_within_workspace() {
678 let workspace = tempdir().unwrap();
679 let workspace_path = workspace.path();
680
681 let subdir = workspace_path.join("src");
683 fs::create_dir_all(&subdir).unwrap();
684 let file_path = subdir.join("test.rs");
685 fs::write(&file_path, "test").unwrap();
686
687 let result = compute_relative_path(workspace_path, None, &file_path);
688 assert_eq!(result, "src/test.rs");
689 }
690
691 #[test]
692 fn test_compute_relative_path_with_cached_canonical() {
693 let workspace = tempdir().unwrap();
694 let workspace_path = workspace.path();
695 let workspace_canonical = workspace_path.canonicalize().unwrap();
696
697 let subdir = workspace_path.join("src");
699 fs::create_dir_all(&subdir).unwrap();
700 let file_path = subdir.join("test.rs");
701 fs::write(&file_path, "test").unwrap();
702
703 let result = compute_relative_path(workspace_path, Some(&workspace_canonical), &file_path);
705 assert_eq!(result, "src/test.rs");
706 }
707
708 #[test]
709 fn test_compute_relative_path_outside_workspace() {
710 let workspace = tempdir().unwrap();
711 let other_dir = tempdir().unwrap();
712
713 let file_path = other_dir.path().join("outside.rs");
715 fs::write(&file_path, "test").unwrap();
716
717 let result = compute_relative_path(workspace.path(), None, &file_path);
718 assert!(result.contains("outside.rs"));
720 }
721
722 #[test]
723 fn test_compute_relative_path_nonexistent_file() {
724 let workspace = tempdir().unwrap();
725 let file_path = workspace.path().join("nonexistent.rs");
726
727 let result = compute_relative_path(workspace.path(), None, &file_path);
728 assert!(result.contains("nonexistent.rs"));
730 }
731
732 #[test]
733 fn test_compute_relative_path_nested_directory() {
734 let workspace = tempdir().unwrap();
735 let workspace_path = workspace.path();
736
737 let nested_dir = workspace_path.join("src").join("components").join("ui");
739 fs::create_dir_all(&nested_dir).unwrap();
740 let file_path = nested_dir.join("button.rs");
741 fs::write(&file_path, "test").unwrap();
742
743 let result = compute_relative_path(workspace_path, None, &file_path);
744 assert_eq!(result, "src/components/ui/button.rs");
745 }
746}