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 let client_options = ClientInitializationOptions::from_value(params.initialization_options);
69
70 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(¶ms.text_document);
136 self.spell_check(¶ms.text_document.uri).await;
137 }
138
139 async fn did_close(&self, params: DidCloseTextDocumentParams) {
140 self.document_cache.remove(¶ms.text_document.uri);
141 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(¶ms.text_document.uri, &text);
151 self.spell_check(¶ms.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 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 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 ¶ms.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 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 let file_path = doc.uri.to_file_path().unwrap_or_default();
454 debug!("Spell-checking file: {file_path:?}");
455
456 let offsets = StringOffsets::<AllConfig>::new(&doc.text);
458
459 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 let diagnostics: Vec<Diagnostic> = spell_results
480 .into_iter()
481 .flat_map(|res| {
482 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 self.client
497 .publish_diagnostics(doc.uri, diagnostics, None)
498 .await;
499 }
501}