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
29pub struct Backend {
30    client: Client,
31    workspace_dir: PathBuf,
32    codebook: OnceLock<Arc<Codebook>>,
33    config: OnceLock<Arc<CodebookConfigFile>>,
34    document_cache: TextDocumentCache,
35    initialize_options: RwLock<Arc<ClientInitializationOptions>>,
36}
37
38enum CodebookCommand {
39    AddWord,
40    AddWordGlobal,
41    Unknown,
42}
43
44impl From<&str> for CodebookCommand {
45    fn from(command: &str) -> Self {
46        match command {
47            "codebook.addWord" => CodebookCommand::AddWord,
48            "codebook.addWordGlobal" => CodebookCommand::AddWordGlobal,
49            _ => CodebookCommand::Unknown,
50        }
51    }
52}
53
54impl From<CodebookCommand> for String {
55    fn from(command: CodebookCommand) -> Self {
56        match command {
57            CodebookCommand::AddWord => "codebook.addWord".to_string(),
58            CodebookCommand::AddWordGlobal => "codebook.addWordGlobal".to_string(),
59            CodebookCommand::Unknown => "codebook.unknown".to_string(),
60        }
61    }
62}
63
64#[tower_lsp::async_trait]
65impl LanguageServer for Backend {
66    async fn initialize(&self, params: InitializeParams) -> RpcResult<InitializeResult> {
67        // info!("Capabilities: {:?}", params.capabilities);
68        let client_options = ClientInitializationOptions::from_value(params.initialization_options);
69
70        // Attach the LSP client to the logger and flush buffered logs
71        lsp_logger::LspLogger::attach_client(self.client.clone(), client_options.log_level);
72        info!(
73            "LSP logger attached to client with log level: {}",
74            client_options.log_level
75        );
76
77        *self.initialize_options.write().unwrap() = Arc::new(client_options);
78
79        Ok(InitializeResult {
80            capabilities: ServerCapabilities {
81                position_encoding: Some(PositionEncodingKind::UTF16),
82                text_document_sync: Some(TextDocumentSyncCapability::Kind(
83                    TextDocumentSyncKind::FULL,
84                )),
85                execute_command_provider: Some(ExecuteCommandOptions {
86                    commands: vec![
87                        CodebookCommand::AddWord.into(),
88                        CodebookCommand::AddWordGlobal.into(),
89                    ],
90                    work_done_progress_options: Default::default(),
91                }),
92                code_action_provider: Some(CodeActionProviderCapability::Options(
93                    CodeActionOptions {
94                        code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
95                        resolve_provider: None,
96                        work_done_progress_options: WorkDoneProgressOptions {
97                            work_done_progress: None,
98                        },
99                    },
100                )),
101                ..ServerCapabilities::default()
102            },
103            server_info: Some(ServerInfo {
104                name: format!("{SOURCE_NAME} Language Server"),
105                version: Some(env!("CARGO_PKG_VERSION").to_string()),
106            }),
107        })
108    }
109
110    async fn initialized(&self, _: InitializedParams) {
111        info!("Server ready!");
112        let config = self.config_handle();
113        match config.project_config_path() {
114            Some(path) => info!("Project config: {}", path.display()),
115            None => info!("Project config: <not set>"),
116        }
117        info!(
118            "Global config: {}",
119            config.global_config_path().unwrap_or_default().display()
120        );
121    }
122
123    async fn shutdown(&self) -> RpcResult<()> {
124        info!("Server shutting down");
125        Ok(())
126    }
127
128    async fn did_open(&self, params: DidOpenTextDocumentParams) {
129        debug!(
130            "Opened document: uri {:?}, language: {}, version: {}",
131            params.text_document.uri,
132            params.text_document.language_id,
133            params.text_document.version
134        );
135        self.document_cache.insert(&params.text_document);
136        self.spell_check(&params.text_document.uri).await;
137    }
138
139    async fn did_close(&self, params: DidCloseTextDocumentParams) {
140        self.document_cache.remove(&params.text_document.uri);
141        // Clear diagnostics when a file is closed.
142        self.client
143            .publish_diagnostics(params.text_document.uri, vec![], None)
144            .await;
145    }
146
147    async fn did_save(&self, params: DidSaveTextDocumentParams) {
148        debug!("Saved document: {}", params.text_document.uri);
149        if let Some(text) = params.text {
150            self.document_cache.update(&params.text_document.uri, &text);
151            self.spell_check(&params.text_document.uri).await;
152        }
153    }
154
155    async fn did_change(&self, params: DidChangeTextDocumentParams) {
156        debug!(
157            "Changed document: uri={}, version={}",
158            params.text_document.uri, params.text_document.version
159        );
160        let uri = params.text_document.uri;
161        if let Some(change) = params.content_changes.first() {
162            self.document_cache.update(&uri, &change.text);
163            self.spell_check(&uri).await;
164        }
165    }
166
167    async fn code_action(&self, params: CodeActionParams) -> RpcResult<Option<CodeActionResponse>> {
168        let mut actions: Vec<CodeActionOrCommand> = vec![];
169        let doc = match self.document_cache.get(params.text_document.uri.as_ref()) {
170            Some(doc) => doc,
171            None => return Ok(None),
172        };
173
174        for diag in params.context.diagnostics {
175            // Only process our own diagnostics
176            if diag.source.as_deref() != Some(SOURCE_NAME) {
177                continue;
178            }
179            let line = doc
180                .text
181                .lines()
182                .nth(diag.range.start.line as usize)
183                .unwrap_or_default();
184            let start_char = diag.range.start.character as usize;
185            let end_char = diag.range.end.character as usize;
186            let word = get_word_from_string(start_char, end_char, line);
187            // info!("Word to suggest: {}", word);
188            if word.is_empty() || word.contains(" ") {
189                continue;
190            }
191            let cb = self.codebook_handle();
192            let inner_word = word.clone();
193            let suggestions = task::spawn_blocking(move || cb.get_suggestions(&inner_word)).await;
194
195            let suggestions = match suggestions {
196                Ok(suggestions) => suggestions,
197                Err(e) => {
198                    error!(
199                        "Error getting suggestions for word '{}' in file '{}'\n Error: {}",
200                        word,
201                        doc.uri.path(),
202                        e
203                    );
204                    continue;
205                }
206            };
207
208            if suggestions.is_none() {
209                continue;
210            }
211
212            suggestions.unwrap().iter().for_each(|suggestion| {
213                actions.push(CodeActionOrCommand::CodeAction(self.make_suggestion(
214                    suggestion,
215                    &diag.range,
216                    &params.text_document.uri,
217                )));
218            });
219            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
220                title: format!("Add '{word}' to dictionary"),
221                kind: Some(CodeActionKind::QUICKFIX),
222                diagnostics: None,
223                edit: None,
224                command: Some(Command {
225                    title: format!("Add '{word}' to dictionary"),
226                    command: CodebookCommand::AddWord.into(),
227                    arguments: Some(vec![word.to_string().into()]),
228                }),
229                is_preferred: None,
230                disabled: None,
231                data: None,
232            }));
233            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
234                title: format!("Add '{word}' to global dictionary"),
235                kind: Some(CodeActionKind::QUICKFIX),
236                diagnostics: None,
237                edit: None,
238                command: Some(Command {
239                    title: format!("Add '{word}' to global dictionary"),
240                    command: CodebookCommand::AddWordGlobal.into(),
241                    arguments: Some(vec![word.to_string().into()]),
242                }),
243                is_preferred: None,
244                disabled: None,
245                data: None,
246            }));
247        }
248        match actions.is_empty() {
249            true => Ok(None),
250            false => Ok(Some(actions)),
251        }
252    }
253
254    async fn execute_command(&self, params: ExecuteCommandParams) -> RpcResult<Option<Value>> {
255        match CodebookCommand::from(params.command.as_str()) {
256            CodebookCommand::AddWord => {
257                let config = self.config_handle();
258                let words = params
259                    .arguments
260                    .iter()
261                    .filter_map(|arg| arg.as_str().map(|s| s.to_string()));
262                info!(
263                    "Adding words to dictionary {}",
264                    words.clone().collect::<Vec<String>>().join(", ")
265                );
266                let updated = self.add_words(config.as_ref(), words);
267                if updated {
268                    let _ = config.save();
269                    self.recheck_all().await;
270                }
271                Ok(None)
272            }
273            CodebookCommand::AddWordGlobal => {
274                let config = self.config_handle();
275                let words = params
276                    .arguments
277                    .iter()
278                    .filter_map(|arg| arg.as_str().map(|s| s.to_string()));
279                let updated = self.add_words_global(config.as_ref(), words);
280                if updated {
281                    let _ = config.save_global();
282                    self.recheck_all().await;
283                }
284                Ok(None)
285            }
286            CodebookCommand::Unknown => Ok(None),
287        }
288    }
289}
290
291impl Backend {
292    pub fn new(client: Client, workspace_dir: &Path) -> Self {
293        Self {
294            client,
295            workspace_dir: workspace_dir.to_path_buf(),
296            codebook: OnceLock::new(),
297            config: OnceLock::new(),
298            document_cache: TextDocumentCache::default(),
299            initialize_options: RwLock::new(Arc::new(ClientInitializationOptions::default())),
300        }
301    }
302
303    fn config_handle(&self) -> Arc<CodebookConfigFile> {
304        self.config
305            .get_or_init(|| {
306                Arc::new(
307                    CodebookConfigFile::load_with_global_config(
308                        Some(self.workspace_dir.as_path()),
309                        self.initialize_options
310                            .read()
311                            .unwrap()
312                            .global_config_path
313                            .clone(),
314                    )
315                    .expect("Unable to make config: {e}"),
316                )
317            })
318            .clone()
319    }
320
321    fn codebook_handle(&self) -> Arc<Codebook> {
322        self.codebook
323            .get_or_init(|| {
324                Arc::new(Codebook::new(self.config_handle()).expect("Unable to make codebook: {e}"))
325            })
326            .clone()
327    }
328
329    fn make_diagnostic(&self, word: &str, start_pos: &Pos, end_pos: &Pos) -> Diagnostic {
330        let message = format!("Possible spelling issue '{word}'.");
331        Diagnostic {
332            range: Range {
333                start: Position {
334                    line: start_pos.line as u32,
335                    character: start_pos.col as u32,
336                },
337                end: Position {
338                    line: end_pos.line as u32,
339                    character: end_pos.col as u32,
340                },
341            },
342            severity: Some(DiagnosticSeverity::INFORMATION),
343            code: None,
344            code_description: None,
345            source: Some(SOURCE_NAME.to_string()),
346            message,
347            related_information: None,
348            tags: None,
349            data: None,
350        }
351    }
352
353    fn add_words(&self, config: &CodebookConfigFile, words: impl Iterator<Item = String>) -> bool {
354        let mut should_save = false;
355        for word in words {
356            match config.add_word(&word) {
357                Ok(true) => {
358                    should_save = true;
359                }
360                Ok(false) => {
361                    info!("Word '{word}' already exists in dictionary.");
362                }
363                Err(e) => {
364                    error!("Failed to add word: {e}");
365                }
366            }
367        }
368        should_save
369    }
370    fn add_words_global(
371        &self,
372        config: &CodebookConfigFile,
373        words: impl Iterator<Item = String>,
374    ) -> bool {
375        let mut should_save = false;
376        for word in words {
377            match config.add_word_global(&word) {
378                Ok(true) => {
379                    should_save = true;
380                }
381                Ok(false) => {
382                    info!("Word '{word}' already exists in global dictionary.");
383                }
384                Err(e) => {
385                    error!("Failed to add word: {e}");
386                }
387            }
388        }
389        should_save
390    }
391
392    fn make_suggestion(&self, suggestion: &str, range: &Range, uri: &Url) -> CodeAction {
393        let title = format!("Replace with '{suggestion}'");
394        let mut map = HashMap::new();
395        map.insert(
396            uri.clone(),
397            vec![TextEdit {
398                range: *range,
399                new_text: suggestion.to_string(),
400            }],
401        );
402        let edit = Some(WorkspaceEdit {
403            changes: Some(map),
404            document_changes: None,
405            change_annotations: None,
406        });
407        CodeAction {
408            title: title.to_string(),
409            kind: Some(CodeActionKind::QUICKFIX),
410            diagnostics: None,
411            edit,
412            command: None,
413            is_preferred: None,
414            disabled: None,
415            data: None,
416        }
417    }
418
419    async fn recheck_all(&self) {
420        let urls = self.document_cache.cached_urls();
421        debug!("Rechecking documents: {urls:?}");
422        for url in urls {
423            self.publish_spellcheck_diagnostics(&url).await;
424        }
425    }
426
427    async fn spell_check(&self, uri: &Url) {
428        let config = self.config_handle();
429        let did_reload = match config.reload() {
430            Ok(did_reload) => did_reload,
431            Err(e) => {
432                error!("Failed to reload config: {e}");
433                false
434            }
435        };
436
437        if did_reload {
438            debug!("Config reloaded, rechecking all files.");
439            self.recheck_all().await;
440        } else {
441            debug!("Checking file: {uri:?}");
442            self.publish_spellcheck_diagnostics(uri).await;
443        }
444    }
445
446    /// Helper method to publish diagnostics for spell-checking.
447    async fn publish_spellcheck_diagnostics(&self, uri: &Url) {
448        let doc = match self.document_cache.get(uri.as_ref()) {
449            Some(doc) => doc,
450            None => return,
451        };
452        // Convert the file URI to a local file path.
453        let file_path = doc.uri.to_file_path().unwrap_or_default();
454        debug!("Spell-checking file: {file_path:?}");
455
456        // Convert utf8 byte offsets to utf16
457        let offsets = StringOffsets::<AllConfig>::new(&doc.text);
458
459        // Perform spell-check.
460        let lang = doc.language_id.as_deref();
461        let lang_type = lang.and_then(|lang| LanguageType::from_str(lang).ok());
462        debug!("Document identified as type {lang_type:?} from {lang:?}");
463        let cb = self.codebook_handle();
464        let fp = file_path.clone();
465        let spell_results = task::spawn_blocking(move || {
466            cb.spell_check(&doc.text, lang_type, Some(fp.to_str().unwrap_or_default()))
467        })
468        .await;
469
470        let spell_results = match spell_results {
471            Ok(results) => results,
472            Err(err) => {
473                error!("Spell-checking failed for file '{file_path:?}' \n Error: {err}");
474                return;
475            }
476        };
477
478        // Convert the results to LSP diagnostics.
479        let diagnostics: Vec<Diagnostic> = spell_results
480            .into_iter()
481            .flat_map(|res| {
482                // For each misspelling, create a diagnostic for each location.
483                let mut new_locations = vec![];
484                for loc in &res.locations {
485                    let start_pos = offsets.utf8_to_utf16_pos(loc.start_byte);
486                    let end_pos = offsets.utf8_to_utf16_pos(loc.end_byte);
487                    let diagnostic = self.make_diagnostic(&res.word, &start_pos, &end_pos);
488                    new_locations.push(diagnostic);
489                }
490                new_locations
491            })
492            .collect();
493
494        // debug!("Diagnostics: {:?}", diagnostics);
495        // Send the diagnostics to the client.
496        self.client
497            .publish_diagnostics(doc.uri, diagnostics, None)
498            .await;
499        // debug!("Published diagnostics for: {:?}", file_path);
500    }
501}