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 info!("Client options: {:?}", client_options);
70
71 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(¶ms.text_document);
143 if self.should_spellcheck_while_typing() {
144 self.spell_check(¶ms.text_document.uri).await;
145 }
146 }
147
148 async fn did_close(&self, params: DidCloseTextDocumentParams) {
149 self.document_cache.remove(¶ms.text_document.uri);
150 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(¶ms.text_document.uri, &text);
160 }
161 self.spell_check(¶ms.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 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 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 ¶ms.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 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 let file_path = doc.uri.to_file_path().unwrap_or_default();
469 debug!("Spell-checking file: {file_path:?}");
470
471 let offsets = StringOffsets::<AllConfig>::new(&doc.text);
473
474 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 let diagnostics: Vec<Diagnostic> = spell_results
495 .into_iter()
496 .flat_map(|res| {
497 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 self.client
512 .publish_diagnostics(doc.uri, diagnostics, None)
513 .await;
514 }
516}