Skip to main content

codebook_lsp/
lsp.rs

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
29/// Computes the relative path of a file from a workspace directory.
30/// Returns the relative path if the file is within the workspace, otherwise returns the absolute path.
31/// If `workspace_dir_canonical` is provided, skips canonicalizing the workspace directory (optimization).
32fn 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    /// Cached canonicalized workspace directory for efficient relative path computation
61    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        // info!("Capabilities: {:?}", params.capabilities);
101        let client_options = ClientInitializationOptions::from_value(params.initialization_options);
102        info!("Client options: {:?}", client_options);
103
104        // Attach the LSP client to the logger and flush buffered logs
105        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(&params.text_document);
178        if self.should_spellcheck_while_typing() {
179            self.spell_check(&params.text_document.uri).await;
180        }
181    }
182
183    async fn did_close(&self, params: DidCloseTextDocumentParams) {
184        self.document_cache.remove(&params.text_document.uri);
185        // Clear diagnostics when a file is closed.
186        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(&params.text_document.uri, &text);
195        }
196        self.spell_check(&params.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            // Only process our own diagnostics
223            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            // info!("Word to suggest: {}", word);
236            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                    &params.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        if actions.is_empty() {
313            return Ok(None);
314        }
315        Ok(Some(actions))
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.arguments.first().and_then(|arg| arg.as_str()) else {
352                    error!("IgnoreFile command missing or invalid file URI argument");
353                    return Ok(None);
354                };
355                let config = self.config_handle();
356                let updated = self.add_ignore_file(config.as_ref(), file_uri);
357                if updated {
358                    let _ = config.save();
359                    self.recheck_all().await;
360                }
361                Ok(None)
362            }
363            CodebookCommand::Unknown => Ok(None),
364        }
365    }
366}
367
368impl Backend {
369    pub fn new(client: Client, workspace_dir: &Path) -> Self {
370        let workspace_dir_canonical = workspace_dir.canonicalize().ok();
371        Self {
372            client,
373            workspace_dir: workspace_dir.to_path_buf(),
374            workspace_dir_canonical,
375            codebook: OnceLock::new(),
376            config: OnceLock::new(),
377            document_cache: TextDocumentCache::default(),
378            initialize_options: RwLock::new(Arc::new(ClientInitializationOptions::default())),
379        }
380    }
381
382    fn config_handle(&self) -> Arc<CodebookConfigFile> {
383        self.config
384            .get_or_init(|| {
385                let options = self.initialize_options.read().unwrap();
386                let global_config_path = options.global_config_path.clone();
387                let project_config_path = options
388                    .config_path
389                    .clone()
390                    .map(|p| self.resolve_workspace_path(&p));
391                drop(options);
392
393                Arc::new(
394                    CodebookConfigFile::load_with_overrides(
395                        Some(self.workspace_dir.as_path()),
396                        global_config_path,
397                        project_config_path,
398                    )
399                    .expect("Unable to make config: {e}"),
400                )
401            })
402            .clone()
403    }
404
405    /// Resolve a user-provided path against the workspace directory.
406    /// Absolute paths are returned unchanged; relative paths are joined onto `workspace_dir`.
407    fn resolve_workspace_path(&self, path: &Path) -> PathBuf {
408        if path.is_absolute() {
409            path.to_path_buf()
410        } else {
411            self.workspace_dir.join(path)
412        }
413    }
414
415    fn codebook_handle(&self) -> Arc<Codebook> {
416        self.codebook
417            .get_or_init(|| {
418                Arc::new(Codebook::new(self.config_handle()).expect("Unable to make codebook: {e}"))
419            })
420            .clone()
421    }
422
423    fn should_spellcheck_while_typing(&self) -> bool {
424        self.initialize_options.read().unwrap().check_while_typing
425    }
426
427    fn make_diagnostic(&self, word: &str, start_pos: &Pos, end_pos: &Pos) -> Diagnostic {
428        let message = format!("Possible spelling issue '{word}'.");
429        Diagnostic {
430            range: Range {
431                start: Position {
432                    line: start_pos.line as u32,
433                    character: start_pos.col as u32,
434                },
435                end: Position {
436                    line: end_pos.line as u32,
437                    character: end_pos.col as u32,
438                },
439            },
440            severity: Some(self.initialize_options.read().unwrap().diagnostic_severity),
441            code: None,
442            code_description: None,
443            source: Some(SOURCE_NAME.to_string()),
444            message,
445            related_information: None,
446            tags: None,
447            data: None,
448        }
449    }
450
451    fn add_words(&self, config: &CodebookConfigFile, words: impl Iterator<Item = String>) -> bool {
452        let mut should_save = false;
453        for word in words {
454            match config.add_word(&word) {
455                Ok(true) => {
456                    should_save = true;
457                }
458                Ok(false) => {
459                    info!("Word '{word}' already exists in dictionary.");
460                }
461                Err(e) => {
462                    error!("Failed to add word: {e}");
463                }
464            }
465        }
466        should_save
467    }
468
469    fn add_words_global(
470        &self,
471        config: &CodebookConfigFile,
472        words: impl Iterator<Item = String>,
473    ) -> bool {
474        let mut should_save = false;
475        for word in words {
476            match config.add_word_global(&word) {
477                Ok(true) => {
478                    should_save = true;
479                }
480                Ok(false) => {
481                    info!("Word '{word}' already exists in global dictionary.");
482                }
483                Err(e) => {
484                    error!("Failed to add word: {e}");
485                }
486            }
487        }
488        should_save
489    }
490
491    fn get_relative_path(&self, uri: &str) -> Option<String> {
492        let parsed_uri = match Url::parse(uri) {
493            Ok(u) => u,
494            Err(e) => {
495                error!("Failed to parse URI '{uri}': {e}");
496                return None;
497            }
498        };
499        let file_path = parsed_uri.to_file_path().unwrap_or_default();
500        Some(compute_relative_path(
501            &self.workspace_dir,
502            self.workspace_dir_canonical.as_deref(),
503            &file_path,
504        ))
505    }
506
507    fn add_ignore_file(&self, config: &CodebookConfigFile, file_uri: &str) -> bool {
508        let Some(relative_path) = self.get_relative_path(file_uri) else {
509            return false;
510        };
511        match config.add_ignore(&relative_path) {
512            Ok(true) => true,
513            Ok(false) => {
514                info!("File {file_uri} already exists in the ignored files.");
515                false
516            }
517            Err(e) => {
518                error!("Failed to add ignore file: {e}");
519                false
520            }
521        }
522    }
523
524    fn make_suggestion(&self, suggestion: &str, range: &Range, uri: &Url) -> CodeAction {
525        let title = format!("Replace with '{suggestion}'");
526        let mut map = HashMap::new();
527        map.insert(
528            uri.clone(),
529            vec![TextEdit {
530                range: *range,
531                new_text: suggestion.to_string(),
532            }],
533        );
534        let edit = Some(WorkspaceEdit {
535            changes: Some(map),
536            document_changes: None,
537            change_annotations: None,
538        });
539        CodeAction {
540            title: title.to_string(),
541            kind: Some(CodeActionKind::QUICKFIX),
542            diagnostics: None,
543            edit,
544            command: None,
545            is_preferred: None,
546            disabled: None,
547            data: None,
548        }
549    }
550
551    async fn recheck_all(&self) {
552        let urls = self.document_cache.cached_urls();
553        debug!("Rechecking documents: {urls:?}");
554        for url in urls {
555            self.publish_spellcheck_diagnostics(&url).await;
556        }
557    }
558
559    async fn spell_check(&self, uri: &Url) {
560        let config = self.config_handle();
561        let did_reload = match config.reload() {
562            Ok(did_reload) => did_reload,
563            Err(e) => {
564                error!("Failed to reload config: {e}");
565                false
566            }
567        };
568
569        if did_reload {
570            debug!("Config reloaded, rechecking all files.");
571            self.recheck_all().await;
572        } else {
573            debug!("Checking file: {uri:?}");
574            self.publish_spellcheck_diagnostics(uri).await;
575        }
576    }
577
578    /// Helper method to publish diagnostics for spell-checking.
579    async fn publish_spellcheck_diagnostics(&self, uri: &Url) {
580        let doc = match self.document_cache.get(uri.as_ref()) {
581            Some(doc) => doc,
582            None => return,
583        };
584        // Convert the file URI to a local file path.
585        let file_path = doc.uri.to_file_path().unwrap_or_default();
586        debug!("Spell-checking file: {file_path:?}");
587
588        // Compute relative path for ignore pattern matching
589        let relative_path = compute_relative_path(
590            &self.workspace_dir,
591            self.workspace_dir_canonical.as_deref(),
592            &file_path,
593        );
594
595        // Convert utf8 byte offsets to utf16
596        let offsets = StringOffsets::<AllConfig>::new(&doc.text);
597
598        // Perform spell-check.
599        let lang = doc.language_id.as_deref();
600        let lang_type = lang.and_then(|lang| LanguageType::from_str(lang).ok());
601        debug!("Document identified as type {lang_type:?} from {lang:?}");
602        let cb = self.codebook_handle();
603        let spell_results = task::spawn_blocking(move || {
604            cb.spell_check(&doc.text, lang_type, Some(&relative_path))
605        })
606        .await;
607
608        let spell_results = match spell_results {
609            Ok(results) => results,
610            Err(err) => {
611                error!("Spell-checking failed for file '{file_path:?}' \n Error: {err}");
612                return;
613            }
614        };
615
616        // Convert the results to LSP diagnostics.
617        let diagnostics: Vec<Diagnostic> = spell_results
618            .into_iter()
619            .flat_map(|res| {
620                // For each misspelling, create a diagnostic for each location.
621                let mut new_locations = vec![];
622                for loc in &res.locations {
623                    let start_pos = offsets.utf8_to_utf16_pos(loc.start_byte);
624                    let end_pos = offsets.utf8_to_utf16_pos(loc.end_byte);
625                    let diagnostic = self.make_diagnostic(&res.word, &start_pos, &end_pos);
626                    new_locations.push(diagnostic);
627                }
628                new_locations
629            })
630            .collect();
631
632        // debug!("Diagnostics: {:?}", diagnostics);
633        // Send the diagnostics to the client.
634        self.client
635            .publish_diagnostics(doc.uri, diagnostics, None)
636            .await;
637        // debug!("Published diagnostics for: {:?}", file_path);
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use std::fs;
645    use tempfile::tempdir;
646
647    #[test]
648    fn test_compute_relative_path_within_workspace() {
649        let workspace = tempdir().unwrap();
650        let workspace_path = workspace.path();
651
652        // Create a file inside the workspace
653        let subdir = workspace_path.join("src");
654        fs::create_dir_all(&subdir).unwrap();
655        let file_path = subdir.join("test.rs");
656        fs::write(&file_path, "test").unwrap();
657
658        let result = compute_relative_path(workspace_path, None, &file_path);
659        assert_eq!(result, "src/test.rs");
660    }
661
662    #[test]
663    fn test_compute_relative_path_with_cached_canonical() {
664        let workspace = tempdir().unwrap();
665        let workspace_path = workspace.path();
666        let workspace_canonical = workspace_path.canonicalize().unwrap();
667
668        // Create a file inside the workspace
669        let subdir = workspace_path.join("src");
670        fs::create_dir_all(&subdir).unwrap();
671        let file_path = subdir.join("test.rs");
672        fs::write(&file_path, "test").unwrap();
673
674        // Using cached canonical path should produce the same result
675        let result = compute_relative_path(workspace_path, Some(&workspace_canonical), &file_path);
676        assert_eq!(result, "src/test.rs");
677    }
678
679    #[test]
680    fn test_compute_relative_path_outside_workspace() {
681        let workspace = tempdir().unwrap();
682        let other_dir = tempdir().unwrap();
683
684        // Create a file outside the workspace
685        let file_path = other_dir.path().join("outside.rs");
686        fs::write(&file_path, "test").unwrap();
687
688        let result = compute_relative_path(workspace.path(), None, &file_path);
689        // Should return the original path since it's outside workspace
690        assert!(result.contains("outside.rs"));
691    }
692
693    #[test]
694    fn test_compute_relative_path_nonexistent_file() {
695        let workspace = tempdir().unwrap();
696        let file_path = workspace.path().join("nonexistent.rs");
697
698        let result = compute_relative_path(workspace.path(), None, &file_path);
699        // Should return the original path since file doesn't exist
700        assert!(result.contains("nonexistent.rs"));
701    }
702
703    #[test]
704    fn test_compute_relative_path_nested_directory() {
705        let workspace = tempdir().unwrap();
706        let workspace_path = workspace.path();
707
708        // Create a deeply nested file
709        let nested_dir = workspace_path.join("src").join("components").join("ui");
710        fs::create_dir_all(&nested_dir).unwrap();
711        let file_path = nested_dir.join("button.rs");
712        fs::write(&file_path, "test").unwrap();
713
714        let result = compute_relative_path(workspace_path, None, &file_path);
715        assert_eq!(result, "src/components/ui/button.rs");
716    }
717}