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