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.rs208
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(())
+ });
+}