Skip to content

Commit

Permalink
Naive spellbook integration
Browse files Browse the repository at this point in the history
  • Loading branch information
the-mikedavis committed Sep 17, 2024
1 parent 84fbadb commit 794b2be
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 4 deletions.
11 changes: 11 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 @@ -42,6 +42,7 @@ tree-sitter = { version = "0.22" }
nucleo = "0.5.0"
slotmap = "1.0.7"
thiserror = "1.0"
spellbook = "0.1"

[workspace.package]
version = "24.7.0"
Expand Down
2 changes: 1 addition & 1 deletion helix-loader/src/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,6 @@ fn mtime(path: &Path) -> Result<SystemTime> {
/// Gives the contents of a file from a language's `runtime/queries/<lang>`
/// directory
pub fn load_runtime_file(language: &str, filename: &str) -> Result<String, std::io::Error> {
let path = crate::runtime_file(&PathBuf::new().join("queries").join(language).join(filename));
let path = crate::runtime_file(PathBuf::new().join("queries").join(language).join(filename));
std::fs::read_to_string(path)
}
16 changes: 15 additions & 1 deletion helix-loader/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ fn find_runtime_file(rel_path: &Path) -> Option<PathBuf> {
/// The valid runtime directories are searched in priority order and the first
/// file found to exist is returned, otherwise the path to the final attempt
/// that failed.
pub fn runtime_file(rel_path: &Path) -> PathBuf {
pub fn runtime_file<P: AsRef<Path>>(rel_path: P) -> PathBuf {
let rel_path = rel_path.as_ref();
find_runtime_file(rel_path).unwrap_or_else(|| {
RUNTIME_DIRS
.last()
Expand All @@ -132,6 +133,14 @@ pub fn cache_dir() -> PathBuf {
path
}

pub fn state_dir() -> PathBuf {
let strategy = choose_base_strategy().expect("Unable to find the cache directory!");
// BaseStrategy always return Some in `state_dir`.
let mut path = strategy.state_dir().unwrap();
path.push("helix");
path
}

pub fn config_file() -> PathBuf {
CONFIG_FILE.get().map(|path| path.to_path_buf()).unwrap()
}
Expand All @@ -152,6 +161,11 @@ pub fn default_log_file() -> PathBuf {
cache_dir().join("helix.log")
}

// HACK: there should a personal dictionary per-locale.
pub fn personal_dictionary_file() -> PathBuf {
state_dir().join("personal-dictionary.txt")
}

/// Merge two TOML documents, merging values from `right` onto `left`
///
/// When an array exists in both `left` and `right`, `right`'s array is
Expand Down
59 changes: 59 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ impl MappableCommand {
command_palette, "Open command palette",
goto_word, "Jump to a two-character label",
extend_to_word, "Extend to a two-character label",
add_word_to_personal_dictionary, "Add the word under the primary cursor to the personal dictionary for the current locale",
);
}

Expand Down Expand Up @@ -6298,3 +6299,61 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) {
}
jump_to_label(cx, words, behaviour)
}

// HACK: this should be folded into code actions.
fn add_word_to_personal_dictionary(cx: &mut Context) {
let (view, doc) = current_ref!(cx.editor);
let text = doc.text().slice(..);
let selection = doc.selection(view.id).primary();
let range = if selection.len() == 1 {
textobject::textobject_word(text, selection, textobject::TextObject::Inside, 1, false)
} else {
selection
};
let word = range.fragment(text);

let prompt = ui::Prompt::new(
"add-word:".into(),
None,
ui::completers::none,
move |cx, input: &str, event: PromptEvent| {
fn append_word(word: &str) -> std::io::Result<()> {
use std::io::Write;
let path = helix_loader::state_dir().join("personal-dictionary.txt");
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
file.write_all(word.as_bytes())?;
file.write_all(helix_core::NATIVE_LINE_ENDING.as_str().as_bytes())?;
file.sync_data()?;

Ok(())
}

if event != PromptEvent::Validate {
return;
}

if let Err(err) = cx.editor.dictionary.add(input) {
cx.editor.set_error(format!(
"Failed to add \"{input}\" to the dictionary: {err}"
));
return;
}

if let Err(err) = append_word(input) {
cx.editor.set_error(format!(
"Failed to persist \"{input}\" to the on-disk dictionary: {err}"
));
return;
}

cx.editor
.set_status(format!("Added \"{input}\" to the dictionary"));
},
)
.with_line(word.into(), cx.editor);

cx.push_layer(Box::new(prompt));
}
1 change: 1 addition & 0 deletions helix-term/src/keymap/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"D" => workspace_diagnostics_picker,
"g" => changed_file_picker,
"a" => code_action,
"A" => add_word_to_personal_dictionary,
"'" => last_picker,
"G" => { "Debug (experimental)" sticky=true
"l" => dap_launch,
Expand Down
56 changes: 55 additions & 1 deletion helix-term/src/ui/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ use helix_core::{
unicode::width::UnicodeWidthStr,
visual_offset_from_block, Change, Position, Range, Selection, Transaction,
};
use helix_stdx::rope::RopeSliceExt;
use helix_view::{
annotations::diagnostics::DiagnosticFilter,
document::{Mode, SavePoint, SCRATCH_BUFFER_NAME},
editor::{CompleteAction, CursorShapeConfig},
graphics::{Color, CursorKind, Modifier, Rect, Style},
input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
keyboard::{KeyCode, KeyModifiers},
Document, Editor, Theme, View,
Dictionary, Document, Editor, Theme, View,
};
use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};

Expand Down Expand Up @@ -144,6 +145,10 @@ impl EditorView {
}
overlay_highlights = Box::new(syntax::merge(overlay_highlights, diagnostic));
}
let spell = Self::doc_spell_highlights(&editor.dictionary, doc, view, theme);
if !spell.is_empty() {
overlay_highlights = Box::new(syntax::merge(overlay_highlights, spell));
}

if is_focused {
let highlights = syntax::merge(
Expand Down Expand Up @@ -460,6 +465,55 @@ impl EditorView {
]
}

pub fn doc_spell_highlights(
dict: &Dictionary,
doc: &Document,
view: &View,
theme: &Theme,
) -> Vec<(usize, std::ops::Range<usize>)> {
// This is **very** ***very*** naive and not at all reflective of what the actual
// integration will look like. Doing this per-render is very needlessly expensive.
// Instead it should be done in the background and possibly incrementally (only
// re-checking ranges that are affected by document changes). However regex-cursor
// is very fast and so is spellbook (degenerate cases max out at 1μs in a release
// build on my machine, i.e. a worst case throughput of 2 million words / second) so
// this is suitable for my testing. I mostly want to find cases where spellbook's
// results are surprising.
// Also we want to use tree-sitter to mark nodes as ones that should be spellchecked
// and maybe specify strategies for doing tokenization (try to tokenize prose vs.
// programming languages).
// Plus these should really be proper diagnostics so that we can pull them up in the
// diagnostics picker and jump to them.
use helix_stdx::rope::Regex;
use once_cell::sync::Lazy;
use std::borrow::Cow;
static WORDS: Lazy<Regex> = Lazy::new(|| Regex::new(r#"[0-9A-Z]*(['-]?[a-z]+)*"#).unwrap());

let mut spans = Vec::new();
let error = theme.find_scope_index("diagnostic.error").unwrap();

let text = doc.text().slice(..);
let start = text.line_to_char(text.char_to_line(doc.view_offset(view.id).anchor));
let end = text.line_to_char(view.estimate_last_doc_line(doc) + 1);

for match_ in WORDS.find_iter(text.regex_input_at(start..end)) {
let range = text.byte_to_char(match_.start())..text.byte_to_char(match_.end());
// TODO: consider how to allow passing the RopeSlice to spellbook:
// * Use an Input trait like regex-cursor?
// * Accept `impl Iterator<Item = char>`?
// * Maybe spellbook should have an internal `String` buffer and it should try to copy
// the word into that? Only in the best case do you not have to allocate at all.
// Maybe we should use a single string buffer and perform all changes to the string
// in-place instead of using `replace` from the stdlib and Cows.
let word = Cow::from(text.slice(range.clone()));
if !dict.check(&word) {
spans.push((error, range))
}
}

spans
}

/// Get highlight spans for selections in a document view.
pub fn doc_selection_highlights(
mode: Mode,
Expand Down
2 changes: 2 additions & 0 deletions helix-view/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ log = "~0.4"
parking_lot = "0.12.3"
thiserror.workspace = true

spellbook.workspace = true

[target.'cfg(windows)'.dependencies]
clipboard-win = { version = "5.4", features = ["std"] }

Expand Down
30 changes: 29 additions & 1 deletion helix-view/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::{
register::Registers,
theme::{self, Theme},
tree::{self, Tree},
Document, DocumentId, View, ViewId,
Dictionary, Document, DocumentId, View, ViewId,
};
use dap::StackFrame;
use helix_vcs::DiffProviderRegistry;
Expand Down Expand Up @@ -1078,6 +1078,9 @@ pub struct Editor {

pub mouse_down_range: Option<Range>,
pub cursor_cache: CursorCache,

/// HACK:
pub dictionary: Dictionary,
}

pub type Motion = Box<dyn Fn(&mut Editor)>;
Expand Down Expand Up @@ -1157,6 +1160,30 @@ impl Editor {
// HAXX: offset the render area height by 1 to account for prompt/commandline
area.height -= 1;

// HACK: what's the right interface for Spellbook to expose so we don't have to
// read these entire files into strings? (See associated TODO in Spellbook.)
let aff =
std::fs::read_to_string(helix_loader::runtime_file("dictionaries/en_US/en_US.aff"))
.unwrap();
let dic =
std::fs::read_to_string(helix_loader::runtime_file("dictionaries/en_US/en_US.dic"))
.unwrap();
// HACK: All this stuff should happen off the main thread.
let mut dictionary = Dictionary::new(&aff, &dic).unwrap();
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();
}
}

Self {
mode: Mode::Normal,
tree: Tree::new(area),
Expand Down Expand Up @@ -1195,6 +1222,7 @@ impl Editor {
handlers,
mouse_down_range: None,
cursor_cache: CursorCache::default(),
dictionary,
}
}

Expand Down
1 change: 1 addition & 0 deletions helix-view/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,6 @@ pub fn align_view(doc: &mut Document, view: &View, align: Align) {
pub use document::Document;
pub use editor::Editor;
use helix_core::char_idx_at_visual_offset;
pub use spellbook::Dictionary;
pub use theme::Theme;
pub use view::View;

0 comments on commit 794b2be

Please sign in to comment.