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