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