Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] TS-based spell-checking with hunspell #6343

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"helix-loader",
"helix-vcs",
"helix-parsec",
"helix-spell",
"xtask",
]

Expand Down
1 change: 1 addition & 0 deletions book/src/generated/typable-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| `:write!`, `:w!` | Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write some/path.txt) |
| `:new`, `:n` | Create a new scratch buffer. |
| `:format`, `:fmt` | Format the file using the LSP formatter. |
| `:spell-check`, `:sc` | Check spelling using tree-sitter and hunspell. |
| `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.) |
| `:line-ending` | Set the document's default line ending. Options: crlf, lf. |
| `:earlier`, `:ear` | Jump back to an earlier point in edit history. Accepts a number of steps or a time span. |
Expand Down
1 change: 1 addition & 0 deletions helix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions helix-core/src/spellcheck.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<Range>> {
let mut cursor = QueryCursor::new();
let ranges: Vec<Range> = 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)
}
11 changes: 11 additions & 0 deletions helix-core/src/syntax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ pub struct LanguageConfiguration {
pub(crate) indent_query: OnceCell<Option<Query>>,
#[serde(skip)]
pub(crate) textobject_query: OnceCell<Option<TextObjectQuery>>,
#[serde(skip)]
pub(crate) spellcheck_query: OnceCell<Option<TextObjectQuery>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debugger: Option<DebugAdapterConfig>,

Expand Down Expand Up @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions helix-spell/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 70 additions & 0 deletions helix-spell/src/client.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
vec!["toto".to_owned()]
}
}

pub struct Client {
hunspell: Hunspell,
suggest_cache: HashMap<String, Vec<String>>,
}

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<String>> {
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(())
}
}
}
1 change: 1 addition & 0 deletions helix-spell/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod client;
1 change: 1 addition & 0 deletions helix-term/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 45 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<Transaction>;
Expand Down
42 changes: 42 additions & 0 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -413,6 +418,31 @@ fn format(

Ok(())
}

fn spell_check(
cx: &mut compositor::Context,
_args: &[Cow<str>],
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<job::Callback> {
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<str>],
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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: &[],
Expand Down
2 changes: 2 additions & 0 deletions helix-term/src/keymap/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ pub fn default() -> HashMap<Mode, Keymap> {
"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,
Expand All @@ -119,6 +120,7 @@ pub fn default() -> HashMap<Mode, Keymap> {
"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,
Expand Down
6 changes: 4 additions & 2 deletions helix-term/src/ui/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
});

Expand Down
Loading