Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/handlers/spelling.rs')
| -rw-r--r-- | helix-term/src/handlers/spelling.rs | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/helix-term/src/handlers/spelling.rs b/helix-term/src/handlers/spelling.rs new file mode 100644 index 00000000..5957b366 --- /dev/null +++ b/helix-term/src/handlers/spelling.rs @@ -0,0 +1,208 @@ +use std::{borrow::Cow, collections::HashSet, future::Future, sync::Arc, time::Duration}; + +use anyhow::Result; +use helix_core::{Rope, SpellingLanguage}; +use helix_event::{cancelable_future, register_hook, send_blocking}; +use helix_stdx::rope::{Regex, RopeSliceExt as _}; +use helix_view::{ + diagnostic::DiagnosticProvider, + editor::Severity, + events::{DocumentDidChange, DocumentDidOpen}, + handlers::{spelling::SpellingEvent, Handlers}, + Diagnostic, Dictionary, DocumentId, Editor, +}; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use tokio::time::Instant; + +use crate::job; + +const PROVIDER: DiagnosticProvider = DiagnosticProvider::Spelling; + +#[derive(Debug, Default)] +pub(super) struct SpellingHandler { + changed_docs: HashSet<DocumentId>, +} + +impl helix_event::AsyncHook for SpellingHandler { + type Event = SpellingEvent; + + fn handle_event(&mut self, event: Self::Event, timeout: Option<Instant>) -> Option<Instant> { + match event { + SpellingEvent::DictionaryLoaded { language } => { + job::dispatch_blocking(move |editor, _compositor| { + let docs: Vec<_> = editor + .documents + .iter() + .filter_map(|(&doc_id, doc)| { + (doc.spelling_language() == Some(language)).then_some(doc_id) + }) + .collect(); + for doc in docs { + check_document(editor, doc); + } + }); + timeout + } + SpellingEvent::DocumentOpened { doc } => { + job::dispatch_blocking(move |editor, _compositor| { + check_document(editor, doc); + }); + timeout + } + SpellingEvent::DocumentChanged { doc } => { + self.changed_docs.insert(doc); + Some(Instant::now() + Duration::from_secs(3)) + } + } + } + + fn finish_debounce(&mut self) { + let docs = std::mem::take(&mut self.changed_docs); + job::dispatch_blocking(move |editor, _compositor| { + for doc in docs { + check_document(editor, doc); + } + }); + } +} + +fn check_document(editor: &mut Editor, doc_id: DocumentId) { + let Some(doc) = editor.documents.get(&doc_id) else { + return; + }; + let Some(language) = doc.spelling_language() else { + return; + }; + let Some(dictionary) = editor.dictionaries.get(&language).cloned() else { + if editor + .handlers + .spelling + .loading_dictionaries + .insert(language) + { + load_dictionary(language); + } + return; + }; + + let uri = doc.uri(); + let future = check_text(dictionary, doc.text().clone()); + let cancel = editor.handlers.spelling.open_request(doc_id); + + tokio::spawn(async move { + match cancelable_future(future, cancel).await { + Some(Ok(diagnostics)) => { + job::dispatch_blocking(move |editor, _compositor| { + editor.handlers.spelling.requests.remove(&doc_id); + editor.handle_diagnostics(&PROVIDER, uri, None, diagnostics); + }); + } + Some(Err(err)) => log::error!("spelling background job failed: {err}"), + None => (), + } + }); +} + +fn load_dictionary(language: SpellingLanguage) { + tokio::task::spawn_blocking(move || { + let aff = std::fs::read_to_string(helix_loader::runtime_file(format!( + "dictionaries/{language}/{language}.aff" + ))) + .unwrap(); + let dic = std::fs::read_to_string(helix_loader::runtime_file(format!( + "dictionaries/{language}/{language}.dic" + ))) + .unwrap(); + + let mut dictionary = Dictionary::new(&aff, &dic).unwrap(); + // TODO: personal dictionaries should be namespaced under runtime directories under the + // language. + if let Ok(file) = std::fs::File::open(helix_loader::personal_dictionary_file()) { + use std::io::{BufRead as _, BufReader}; + let reader = BufReader::with_capacity(8 * 1024, file); + for line in reader.lines() { + let line = line.unwrap(); + let line = line.trim(); + if line.is_empty() { + continue; + } + dictionary.add(line).unwrap(); + } + } + + job::dispatch_blocking(move |editor, _compositor| { + let was_removed = editor + .handlers + .spelling + .loading_dictionaries + .remove(&language); + // Other processes should respect that a dictionary is loading and not change + // `loading_dictionaries`. So this should always be true. + debug_assert!(was_removed); + editor + .dictionaries + .insert(language, Arc::new(RwLock::new(dictionary))); + send_blocking( + &editor.handlers.spelling.event_tx, + SpellingEvent::DictionaryLoaded { language }, + ); + }) + }); +} + +fn check_text( + dictionary: Arc<RwLock<Dictionary>>, + text: Rope, +) -> impl Future<Output = Result<Vec<Diagnostic>, tokio::task::JoinError>> { + tokio::task::spawn_blocking(move || { + static WORDS: Lazy<Regex> = Lazy::new(|| Regex::new(r#"[0-9A-Z]*(['-]?[a-z]+)*"#).unwrap()); + + let dict = dictionary.read(); + let text = text.slice(..); + let mut diagnostics = Vec::new(); + for match_ in WORDS.find_iter(text.regex_input()) { + let word = Cow::from(text.byte_slice(match_.range())); + if !dict.check(&word) { + diagnostics.push(Diagnostic { + range: helix_view::Range::Document(helix_stdx::Range { + start: text.byte_to_char(match_.start()), + end: text.byte_to_char(match_.end()), + }), + message: format!("Possible spelling issue '{word}'"), + severity: Some(Severity::Error), + code: None, + provider: PROVIDER, + tags: Default::default(), + source: None, + data: None, + }); + } + } + diagnostics + }) +} + +pub(super) fn register_hooks(handlers: &Handlers) { + let tx = handlers.spelling.event_tx.clone(); + register_hook!(move |event: &mut DocumentDidOpen<'_>| { + let doc = doc!(event.editor, &event.doc); + if doc.spelling_language().is_some() { + send_blocking(&tx, SpellingEvent::DocumentOpened { doc: event.doc }); + } + Ok(()) + }); + + let tx = handlers.spelling.event_tx.clone(); + register_hook!(move |event: &mut DocumentDidChange<'_>| { + if event.doc.spelling_language().is_some() { + send_blocking( + &tx, + SpellingEvent::DocumentChanged { + doc: event.doc.id(), + }, + ); + } + Ok(()) + }); +} |