From 66986a04715cf2cc71032cff27eb1785af1a6fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Delafargue?= Date: Fri, 17 Mar 2023 11:02:42 +0100 Subject: [PATCH 1/2] [WIP] TS-based spell-checking with hunspell Co-authored-by: Connor Lay (Clay) --- Cargo.lock | 9 +++ Cargo.toml | 1 + helix-core/src/lib.rs | 1 + helix-core/src/spellcheck.rs | 22 +++++++ helix-core/src/syntax.rs | 11 ++++ helix-spell/Cargo.toml | 11 ++++ helix-spell/src/client.rs | 70 +++++++++++++++++++++ helix-spell/src/lib.rs | 1 + helix-term/Cargo.toml | 1 + helix-term/src/commands.rs | 45 ++++++++++++++ helix-term/src/commands/typed.rs | 42 +++++++++++++ helix-term/src/keymap/default.rs | 2 + helix-term/src/ui/editor.rs | 6 +- helix-term/src/ui/statusline.rs | 27 ++++---- helix-view/Cargo.toml | 1 + helix-view/src/document.rs | 78 ++++++++++++++++++++++++ helix-view/src/editor.rs | 9 +++ helix-view/src/gutter.rs | 2 +- runtime/queries/elixir/spellchecks.scm | 13 ++++ runtime/queries/markdown/spellchecks.scm | 1 + runtime/queries/rust/spellchecks.scm | 4 ++ 21 files changed, 341 insertions(+), 16 deletions(-) create mode 100644 helix-core/src/spellcheck.rs create mode 100644 helix-spell/Cargo.toml create mode 100644 helix-spell/src/client.rs create mode 100644 helix-spell/src/lib.rs create mode 100644 runtime/queries/elixir/spellchecks.scm create mode 100644 runtime/queries/markdown/spellchecks.scm create mode 100644 runtime/queries/rust/spellchecks.scm diff --git a/Cargo.lock b/Cargo.lock index af0858efe4d7..5fb1c7e41e1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1154,6 +1154,13 @@ dependencies = [ name = "helix-parsec" version = "0.6.0" +[[package]] +name = "helix-spell" +version = "0.6.0" +dependencies = [ + "log", +] + [[package]] name = "helix-term" version = "0.6.0" @@ -1172,6 +1179,7 @@ dependencies = [ "helix-dap", "helix-loader", "helix-lsp", + "helix-spell", "helix-tui", "helix-vcs", "helix-view", @@ -1238,6 +1246,7 @@ dependencies = [ "helix-dap", "helix-loader", "helix-lsp", + "helix-spell", "helix-tui", "helix-vcs", "libc", diff --git a/Cargo.toml b/Cargo.toml index aaa21659ada3..67e8c78de0b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "helix-loader", "helix-vcs", "helix-parsec", + "helix-spell", "xtask", ] diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index e3f862a6054c..834cd089bfe1 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod register; pub mod search; pub mod selection; pub mod shellwords; +pub mod spellcheck; pub mod surround; pub mod syntax; pub mod test; diff --git a/helix-core/src/spellcheck.rs b/helix-core/src/spellcheck.rs new file mode 100644 index 000000000000..6b74ddb09fb8 --- /dev/null +++ b/helix-core/src/spellcheck.rs @@ -0,0 +1,22 @@ +use crate::selection::Range; +use crate::syntax::LanguageConfiguration; +use crate::RopeSlice; +use tree_sitter::{Node, QueryCursor}; + +pub fn spellcheck_treesitter( + doc_tree: Node, + doc_slice: RopeSlice, + lang_config: &LanguageConfiguration, +) -> Option> { + let mut cursor = QueryCursor::new(); + let ranges: Vec = lang_config + .spellcheck_query()? + .capture_nodes("spell", doc_tree, doc_slice, &mut cursor)? + .map(|node| { + let start_char = doc_slice.byte_to_char(node.start_byte()); + let end_char = doc_slice.byte_to_char(node.end_byte()); + Range::new(start_char, end_char) + }) + .collect(); + Some(ranges) +} diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 941e3ba7bd3b..8f332883e399 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -116,6 +116,8 @@ pub struct LanguageConfiguration { pub(crate) indent_query: OnceCell>, #[serde(skip)] pub(crate) textobject_query: OnceCell>, + #[serde(skip)] + pub(crate) spellcheck_query: OnceCell>, #[serde(skip_serializing_if = "Option::is_none")] pub debugger: Option, @@ -525,6 +527,15 @@ impl LanguageConfiguration { .as_ref() } + pub fn spellcheck_query(&self) -> Option<&TextObjectQuery> { + self.spellcheck_query + .get_or_init(|| { + self.load_query("spellchecks.scm") + .map(|query| TextObjectQuery { query }) + }) + .as_ref() + } + pub fn scope(&self) -> &str { &self.scope } diff --git a/helix-spell/Cargo.toml b/helix-spell/Cargo.toml new file mode 100644 index 000000000000..072413c3a9a6 --- /dev/null +++ b/helix-spell/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "helix-spell" +version = "0.6.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# i can't have it compile on my machine yet +#hunspell-rs = "0.4.0" +log = "0.4" diff --git a/helix-spell/src/client.rs b/helix-spell/src/client.rs new file mode 100644 index 000000000000..2f92e6a59c5f --- /dev/null +++ b/helix-spell/src/client.rs @@ -0,0 +1,70 @@ +//use hunspell_rs::Hunspell; +use std::collections::HashMap; + +// dummy placeholder to test behaviour while i figure out how to make hunspell-rs compile +struct Hunspell; + +mod hunspell_rs { + pub enum CheckResult { + MissingInDictionary, + CheckOk, + } +} + +impl Hunspell { + pub fn new(_: &str, _: &str) -> Self { + Hunspell + } + + pub fn check(&self, word: &str) -> hunspell_rs::CheckResult { + if word == "bad" { + hunspell_rs::CheckResult::MissingInDictionary + } else { + hunspell_rs::CheckResult::CheckOk + } + } + + fn suggest(&self, _: &str) -> Vec { + vec!["toto".to_owned()] + } +} + +pub struct Client { + hunspell: Hunspell, + suggest_cache: HashMap>, +} + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + +impl Client { + pub fn new() -> Self { + let hunspell = Hunspell::new( + "/usr/share/hunspell/en_US.aff", + "/usr/share/hunspell/en_US.dic", + ); + let suggest_cache = HashMap::new(); + Self { + hunspell, + suggest_cache, + } + } + + pub fn check(&mut self, word: &str) -> Result<(), Vec> { + if let hunspell_rs::CheckResult::MissingInDictionary = self.hunspell.check(word) { + let suggestions = if let Some((_, words)) = self.suggest_cache.get_key_value(word) { + words + } else { + let words = self.hunspell.suggest(word); + self.suggest_cache.insert(word.to_string(), words); + self.suggest_cache.get(word).unwrap() + }; + Err(suggestions.to_vec()) + } else { + Ok(()) + } + } +} diff --git a/helix-spell/src/lib.rs b/helix-spell/src/lib.rs new file mode 100644 index 000000000000..b9babe5bc1d6 --- /dev/null +++ b/helix-spell/src/lib.rs @@ -0,0 +1 @@ +pub mod client; diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 5222ddaa15f6..a4640f66dad2 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -29,6 +29,7 @@ helix-lsp = { version = "0.6", path = "../helix-lsp" } helix-dap = { version = "0.6", path = "../helix-dap" } helix-vcs = { version = "0.6", path = "../helix-vcs" } helix-loader = { version = "0.6", path = "../helix-loader" } +helix-spell = { version = "0.6", path = "../helix-spell" } anyhow = "1" once_cell = "1.17" diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 1c1edece1a31..645001bc727c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -339,6 +339,8 @@ impl MappableCommand { goto_prev_change, "Goto previous change", goto_first_change, "Goto first change", goto_last_change, "Goto last change", + goto_next_misspell, "Goto next misspell", + goto_prev_misspell, "Goto previous misspell", goto_line_start, "Goto line start", goto_line_end, "Goto line end", goto_next_buffer, "Goto next buffer", @@ -3115,6 +3117,49 @@ fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range { Range::new(anchor, head) } +fn goto_next_misspell(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + + let diag = doc + .spell_diagnostics() + .iter() + .find(|diag| diag.range.start > cursor_pos) + .or_else(|| doc.diagnostics().first()); + + let selection = match diag { + Some(diag) => Selection::single(diag.range.start, diag.range.end), + None => return, + }; + doc.set_selection(view.id, selection); +} + +fn goto_prev_misspell(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let cursor_pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + + let diag = doc + .spell_diagnostics() + .iter() + .rev() + .find(|diag| diag.range.start < cursor_pos) + .or_else(|| doc.spell_diagnostics().last()); + + let selection = match diag { + Some(diag) => Selection::single(diag.range.start, diag.range.end), + None => return, + }; + doc.set_selection(view.id, selection); +} + pub mod insert { use super::*; pub type Hook = fn(&Rope, &Selection, char) -> Option; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 6fbdc0d7afa3..0b9fca519801 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -330,10 +330,15 @@ fn write_impl( force: bool, ) -> anyhow::Result<()> { let editor_auto_fmt = cx.editor.config().auto_format; + let editor_auto_spellcheck = cx.editor.config().auto_spellcheck; let jobs = &mut cx.jobs; let (view, doc) = current!(cx.editor); let path = path.map(AsRef::as_ref); + if editor_auto_spellcheck { + jobs.callback(make_spell_check_callback(doc.id())); + }; + let fmt = if editor_auto_fmt { doc.auto_format().map(|fmt| { let callback = make_format_callback( @@ -413,6 +418,31 @@ fn format( Ok(()) } + +fn spell_check( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + let doc = doc!(cx.editor); + cx.jobs.callback(make_spell_check_callback(doc.id())); + Ok(()) +} + +async fn make_spell_check_callback(doc_id: DocumentId) -> anyhow::Result { + let call: job::Callback = job::Callback::EditorCompositor(Box::new( + move |editor: &mut Editor, _: &mut Compositor| { + if let Some(doc) = editor.document_mut(doc_id) { + doc.spell_check(); + }; + }, + )); + Ok(call) +} + fn set_indent_style( cx: &mut compositor::Context, args: &[Cow], @@ -628,6 +658,7 @@ pub fn write_all_impl( ) -> anyhow::Result<()> { let mut errors: Vec<&'static str> = Vec::new(); let auto_format = cx.editor.config().auto_format; + let auto_spellcheck = cx.editor.config().auto_spellcheck; let jobs = &mut cx.jobs; let current_view = view!(cx.editor); @@ -662,6 +693,10 @@ pub fn write_all_impl( current_view.id }; + if auto_spellcheck { + jobs.callback(make_spell_check_callback(doc.id())); + }; + let fmt = if auto_format { doc.auto_format().map(|fmt| { let callback = make_format_callback( @@ -2257,6 +2292,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: format, signature: CommandSignature::none(), }, + TypableCommand { + name: "spell-check", + aliases: &["sc"], + doc: "Check spelling using tree-sitter and hunspell.", + fun: spell_check, + signature: CommandSignature::none(), + }, TypableCommand { name: "indent-style", aliases: &[], diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 9bd002809d61..559da63a7f65 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -106,6 +106,7 @@ pub fn default() -> HashMap { "D" => goto_first_diag, "g" => goto_prev_change, "G" => goto_first_change, + "z" => goto_prev_misspell, "f" => goto_prev_function, "t" => goto_prev_class, "a" => goto_prev_parameter, @@ -119,6 +120,7 @@ pub fn default() -> HashMap { "D" => goto_last_diag, "g" => goto_next_change, "G" => goto_last_change, + "z" => goto_next_misspell, "f" => goto_next_function, "t" => goto_next_class, "a" => goto_next_parameter, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 7c22df747642..8cd3fd38f679 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -372,7 +372,8 @@ impl EditorView { let mut warning_vec = Vec::new(); let mut error_vec = Vec::new(); - for diagnostic in doc.diagnostics() { + let all_diagnostics = doc.all_diagnostics(); + for diagnostic in all_diagnostics { // Separate diagnostics into different Vecs by severity. let (vec, scope) = match diagnostic.severity { Some(Severity::Info) => (&mut info_vec, info), @@ -662,7 +663,8 @@ impl EditorView { .primary() .cursor(doc.text().slice(..)); - let diagnostics = doc.diagnostics().iter().filter(|diagnostic| { + let all_diagnostics = doc.all_diagnostics(); + let diagnostics = all_diagnostics.iter().filter(|diagnostic| { diagnostic.range.start <= cursor && diagnostic.range.end >= cursor }); diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 887863519319..76782122bcb3 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -223,19 +223,20 @@ fn render_diagnostics(context: &mut RenderContext, write: F) where F: Fn(&mut RenderContext, String, Option