Add offline live spell checking (Hunspell via spellbook)
Spelling is now checked live as you type, fully offline, using the pure-Rust `spellbook` crate to read Hunspell .aff/.dic dictionaries (no C libhunspell — the binary stays self-contained; ldd unchanged). - Canadian (en-CA, default) and British (en-GB) English dictionaries are compiled in; more languages are auto-discovered from system Hunspell folders and ~/.config/md-manuscript/dictionaries/. A "Spelling" row in the top bar toggles live checking and picks the dictionary. - Misspellings are underlined in red; right-clicking a word opens a menu of suggested corrections. A results-panel and one-click fixes work too. - Checks run on a background thread, debounced ~400ms after the last edit. Prose is extracted with pulldown-cmark so code spans, code blocks and link targets are skipped; ALL-CAPS initialisms are ignored. - LanguageTool still layers on top: when its results are fresh they own the underlines (spelling + grammar); once you edit, the offline checker resumes. The editor underlines, issues panel and fix-apply logic are now shared between the two sources. Bundled dictionaries are SCOWL-derived under a permissive license (kept in dictionaries/<lang>/license). Adds spell-check unit tests (tokenizer, code-block skipping, offset mapping, en-CA/en-GB spelling); 40 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
+428
-64
@@ -5,12 +5,40 @@ use crate::order;
|
||||
use eframe::egui;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Result of a background grammar check: the matches, or an error message.
|
||||
type LtCheckResult = Result<Vec<crate::langtool::Match>, String>;
|
||||
/// Channel payload from a background check: the text that was checked, paired
|
||||
/// with its result (so the app can confirm the buffer hasn't changed since).
|
||||
type LtCheckMsg = (String, LtCheckResult);
|
||||
/// Channel payload from a background spell check: the checked text and the
|
||||
/// spelling matches found in it.
|
||||
type SpellCheckMsg = (String, Vec<crate::langtool::Match>);
|
||||
|
||||
/// Which check currently owns the editor's underlines and the issues panel.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum IssueSource {
|
||||
/// Fresh LanguageTool results for the current buffer (spelling + grammar).
|
||||
LanguageTool,
|
||||
/// Live offline spell-check results (spelling only).
|
||||
Spell,
|
||||
/// Nothing current to show.
|
||||
None,
|
||||
}
|
||||
|
||||
/// An owned snapshot of one issue for the results panel, decoupled from `self`
|
||||
/// so the panel can render without holding a borrow across its click handling.
|
||||
struct IssueItem {
|
||||
/// Index of this item in the source match list (for applying a fix).
|
||||
idx: usize,
|
||||
/// The offending text.
|
||||
snippet: String,
|
||||
message: String,
|
||||
spelling: bool,
|
||||
replacements: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
@@ -86,6 +114,25 @@ pub struct App {
|
||||
find_focus: Option<bool>,
|
||||
/// Request the editor to scroll the active match into view next frame.
|
||||
find_scroll: bool,
|
||||
/// The loaded offline spell-check dictionary (shared with the worker thread).
|
||||
spell_dict: Option<Arc<spellbook::Dictionary>>,
|
||||
/// Dictionaries available to choose from (bundled + discovered on disk).
|
||||
spell_dicts: Vec<crate::spell::DictEntry>,
|
||||
/// Live spelling matches and the buffer text they were computed against.
|
||||
spell_matches: Vec<crate::langtool::Match>,
|
||||
spell_checked_text: String,
|
||||
/// Set when the buffer changed and a re-check is owed.
|
||||
spell_dirty: bool,
|
||||
/// When the buffer was last edited, for debouncing the live check. `None`
|
||||
/// means "check as soon as possible" (e.g. right after opening a file).
|
||||
spell_last_edit: Option<Instant>,
|
||||
/// In-flight background spell check, if any.
|
||||
spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>,
|
||||
/// One-line status for the spell checker (dictionary name, count, or error).
|
||||
spell_status: String,
|
||||
/// Index (into the currently displayed matches) of the word a right-click
|
||||
/// suggestion menu is open for, if any.
|
||||
spell_menu: Option<usize>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -131,7 +178,17 @@ impl App {
|
||||
find_needs_refresh: false,
|
||||
find_focus: None,
|
||||
find_scroll: false,
|
||||
spell_dict: None,
|
||||
spell_dicts: crate::spell::available(),
|
||||
spell_matches: Vec::new(),
|
||||
spell_checked_text: String::new(),
|
||||
spell_dirty: true,
|
||||
spell_last_edit: None,
|
||||
spell_rx: None,
|
||||
spell_status: String::new(),
|
||||
spell_menu: None,
|
||||
};
|
||||
app.load_spell_dict();
|
||||
app.open_workspace();
|
||||
app
|
||||
}
|
||||
@@ -295,6 +352,12 @@ impl App {
|
||||
self.find_matches.clear();
|
||||
self.find_active = 0;
|
||||
self.find_needs_refresh = true;
|
||||
// Re-run the live spell check on the newly loaded buffer immediately.
|
||||
self.spell_matches.clear();
|
||||
self.spell_checked_text.clear();
|
||||
self.spell_dirty = true;
|
||||
self.spell_last_edit = None;
|
||||
self.spell_menu = None;
|
||||
if let Some(name) = self.files.get(idx) {
|
||||
let path = self.path_for(name);
|
||||
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
@@ -643,33 +706,222 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply replacement `rep_idx` of match `match_idx` to the buffer, then shift
|
||||
/// the remaining matches so their highlights stay valid.
|
||||
/// Apply a LanguageTool suggestion to the buffer, then shift the remaining
|
||||
/// matches so their highlights stay valid.
|
||||
fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) {
|
||||
// Only safe while the buffer still matches what was checked.
|
||||
if self.buffer != self.lt_checked_text {
|
||||
return;
|
||||
}
|
||||
let Some(m) = self.lt_matches.get(match_idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(replacement) = m.replacements.get(rep_idx).cloned() else {
|
||||
return;
|
||||
};
|
||||
if m.end > self.buffer.len() || !self.buffer.is_char_boundary(m.start) {
|
||||
if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) {
|
||||
self.dirty = true;
|
||||
self.find_needs_refresh = true;
|
||||
self.lt_checked_text = self.buffer.clone();
|
||||
self.lt_status = match self.lt_matches.len() {
|
||||
0 => "No issues remaining".to_string(),
|
||||
1 => "1 issue".to_string(),
|
||||
n => format!("{n} issues"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a spell-check suggestion to the buffer, keeping the remaining
|
||||
/// misspelling underlines aligned.
|
||||
fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) {
|
||||
if self.buffer != self.spell_checked_text {
|
||||
return;
|
||||
}
|
||||
self.buffer.replace_range(m.start..m.end, &replacement);
|
||||
self.dirty = true;
|
||||
self.lt_checked_text = self.buffer.clone();
|
||||
if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) {
|
||||
self.dirty = true;
|
||||
self.find_needs_refresh = true;
|
||||
// Keep these matches valid without forcing a full re-check.
|
||||
self.spell_checked_text = self.buffer.clone();
|
||||
self.spell_status = match self.spell_matches.len() {
|
||||
0 => "No spelling issues".to_string(),
|
||||
1 => "1 spelling issue".to_string(),
|
||||
n => format!("{n} spelling issues"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The applied match overlaps its own region, so it is dropped here too.
|
||||
remap_matches(&mut self.lt_matches, m.start, m.end, replacement.len());
|
||||
self.lt_status = match self.lt_matches.len() {
|
||||
0 => "No issues remaining".to_string(),
|
||||
1 => "1 issue".to_string(),
|
||||
n => format!("{n} issues"),
|
||||
/// Apply suggestion `rep_idx` of the match at `match_idx` from whichever
|
||||
/// source currently owns the issues (LanguageTool or the spell checker).
|
||||
fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) {
|
||||
match self.issue_source() {
|
||||
IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx),
|
||||
IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx),
|
||||
IssueSource::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Spell check (offline) ---------------------------------------------
|
||||
|
||||
/// (Re)load the dictionary named by `config.spell_language`, falling back to
|
||||
/// the default and then to whatever is available. Resets the live results so
|
||||
/// the buffer is re-checked against the new dictionary.
|
||||
fn load_spell_dict(&mut self) {
|
||||
let want = self.config.spell_language.clone();
|
||||
let entry = self
|
||||
.spell_dicts
|
||||
.iter()
|
||||
.find(|d| d.id == want)
|
||||
.or_else(|| {
|
||||
self.spell_dicts
|
||||
.iter()
|
||||
.find(|d| d.id == crate::spell::DEFAULT_LANGUAGE)
|
||||
})
|
||||
.or_else(|| self.spell_dicts.first())
|
||||
.cloned();
|
||||
|
||||
match entry {
|
||||
Some(e) => match e.load() {
|
||||
Ok(dict) => {
|
||||
self.spell_dict = Some(Arc::new(dict));
|
||||
self.spell_status = format!("Dictionary: {}", e.id);
|
||||
}
|
||||
Err(err) => {
|
||||
self.spell_dict = None;
|
||||
self.spell_status = err;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
self.spell_dict = None;
|
||||
self.spell_status = "No spelling dictionary available".to_string();
|
||||
}
|
||||
}
|
||||
self.spell_matches.clear();
|
||||
self.spell_checked_text.clear();
|
||||
self.spell_dirty = true;
|
||||
self.spell_last_edit = None;
|
||||
}
|
||||
|
||||
/// Switch the active dictionary and persist the choice.
|
||||
fn set_spell_language(&mut self, id: &str) {
|
||||
if self.config.spell_language == id {
|
||||
return;
|
||||
}
|
||||
self.config.spell_language = id.to_string();
|
||||
self.config.save();
|
||||
self.load_spell_dict();
|
||||
}
|
||||
|
||||
/// True when LanguageTool results describe the current buffer exactly (so
|
||||
/// they, rather than the live spell checker, own the underlines).
|
||||
fn lt_is_current(&self) -> bool {
|
||||
!self.lt_checked_text.is_empty() && self.buffer == self.lt_checked_text
|
||||
}
|
||||
|
||||
/// Which check currently owns the issues shown in the editor and panel.
|
||||
fn issue_source(&self) -> IssueSource {
|
||||
if self.lt_is_current() {
|
||||
IssueSource::LanguageTool
|
||||
} else if self.config.spell_check
|
||||
&& self.spell_dict.is_some()
|
||||
&& self.buffer == self.spell_checked_text
|
||||
{
|
||||
IssueSource::Spell
|
||||
} else {
|
||||
IssueSource::None
|
||||
}
|
||||
}
|
||||
|
||||
/// The matches currently owning the editor's underlines (may be empty).
|
||||
fn current_matches(&self) -> &[crate::langtool::Match] {
|
||||
match self.issue_source() {
|
||||
IssueSource::LanguageTool => &self.lt_matches,
|
||||
IssueSource::Spell => &self.spell_matches,
|
||||
IssueSource::None => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the current match covering byte offset `byte`, if any.
|
||||
fn match_index_at(&self, byte: usize) -> Option<usize> {
|
||||
self.current_matches()
|
||||
.iter()
|
||||
.position(|m| byte >= m.start && byte < m.end)
|
||||
}
|
||||
|
||||
/// An owned snapshot of the current issues for the results panel.
|
||||
fn issue_items(&self) -> (IssueSource, Vec<IssueItem>) {
|
||||
let source = self.issue_source();
|
||||
let (matches, checked) = match source {
|
||||
IssueSource::LanguageTool => (&self.lt_matches, self.lt_checked_text.as_str()),
|
||||
IssueSource::Spell => (&self.spell_matches, self.spell_checked_text.as_str()),
|
||||
IssueSource::None => return (source, Vec::new()),
|
||||
};
|
||||
let items = matches
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, m)| IssueItem {
|
||||
idx,
|
||||
snippet: checked.get(m.start..m.end).unwrap_or("").to_string(),
|
||||
message: m.message.clone(),
|
||||
spelling: m.spelling,
|
||||
replacements: m.replacements.clone(),
|
||||
})
|
||||
.collect();
|
||||
(source, items)
|
||||
}
|
||||
|
||||
/// The status line for the spelling checker given a match count.
|
||||
fn spell_count_status(n: usize) -> String {
|
||||
match n {
|
||||
0 => "No spelling issues".to_string(),
|
||||
1 => "1 spelling issue".to_string(),
|
||||
n => format!("{n} spelling issues"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick up a finished background spell check.
|
||||
fn poll_spell(&mut self) {
|
||||
let received = self.spell_rx.as_ref().and_then(|rx| rx.try_recv().ok());
|
||||
if let Some((text, matches)) = received {
|
||||
self.spell_rx = None;
|
||||
if self.spell_dict.is_some() {
|
||||
self.spell_status = Self::spell_count_status(matches.len());
|
||||
}
|
||||
self.spell_matches = matches;
|
||||
self.spell_checked_text = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a background spell check of the current buffer if one is due:
|
||||
/// enabled, a dictionary loaded, a file open, the buffer changed, no check
|
||||
/// already running, and the debounce interval elapsed since the last edit.
|
||||
fn maybe_start_spell_check(&mut self, ctx: &egui::Context) {
|
||||
if !self.config.spell_check || self.selected.is_none() || !self.spell_dirty {
|
||||
return;
|
||||
}
|
||||
// LanguageTool results already cover this exact buffer (spelling too), so
|
||||
// don't spend effort on a spell pass that wouldn't be shown.
|
||||
if self.lt_is_current() {
|
||||
return;
|
||||
}
|
||||
let Some(dict) = self.spell_dict.clone() else {
|
||||
return;
|
||||
};
|
||||
if self.spell_rx.is_some() {
|
||||
return;
|
||||
}
|
||||
// Debounce so we don't re-check on every keystroke.
|
||||
const DEBOUNCE: Duration = Duration::from_millis(400);
|
||||
if let Some(edited) = self.spell_last_edit {
|
||||
let elapsed = edited.elapsed();
|
||||
if elapsed < DEBOUNCE {
|
||||
ctx.request_repaint_after(DEBOUNCE - elapsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.spell_dirty = false;
|
||||
let text = self.buffer.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.spell_rx = Some(rx);
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let matches = crate::spell::check(&dict, &text);
|
||||
let _ = tx.send((text, matches));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Find / replace ----------------------------------------------------
|
||||
@@ -903,6 +1155,47 @@ impl App {
|
||||
ui.label(egui::RichText::new(&self.lt_status).weak());
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Spelling:").on_hover_text(
|
||||
"Offline spell check (Hunspell dictionaries). Underlines misspellings \
|
||||
as you type when LanguageTool results aren't current; right-click a \
|
||||
word for suggestions.",
|
||||
);
|
||||
if ui
|
||||
.checkbox(&mut self.config.spell_check, "Check as I type")
|
||||
.on_hover_text("Underline misspellings live using the dictionary below")
|
||||
.changed()
|
||||
{
|
||||
self.config.save();
|
||||
if self.config.spell_check {
|
||||
// Re-check the current buffer straight away.
|
||||
self.spell_dirty = true;
|
||||
self.spell_last_edit = None;
|
||||
}
|
||||
}
|
||||
ui.label("Dictionary:");
|
||||
let current = self.config.spell_language.clone();
|
||||
let mut pick: Option<String> = None;
|
||||
egui::ComboBox::from_id_salt("spell_dict")
|
||||
.selected_text(current.clone())
|
||||
.show_ui(ui, |ui| {
|
||||
for d in &self.spell_dicts {
|
||||
if ui
|
||||
.selectable_label(current == d.id, &d.label)
|
||||
.clicked()
|
||||
{
|
||||
pick = Some(d.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(id) = pick {
|
||||
self.set_spell_language(&id);
|
||||
}
|
||||
if !self.spell_status.is_empty() {
|
||||
ui.label(egui::RichText::new(&self.spell_status).weak());
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
@@ -1229,24 +1522,23 @@ impl App {
|
||||
self.show_settings = now_open;
|
||||
}
|
||||
|
||||
/// Bottom panel listing grammar/spelling issues with one-click fixes.
|
||||
/// Bottom panel listing the current grammar/spelling issues with one-click
|
||||
/// fixes. Shows LanguageTool results when they're fresh, otherwise the live
|
||||
/// spell-check results.
|
||||
fn lt_panel(&mut self, ctx: &egui::Context) {
|
||||
let (source, items) = self.issue_items();
|
||||
egui::TopBottomPanel::bottom("ltpanel")
|
||||
.resizable(true)
|
||||
.default_height(190.0)
|
||||
.show(ctx, |ui| {
|
||||
let stale = self.buffer != self.lt_checked_text;
|
||||
let (title, status) = match source {
|
||||
IssueSource::LanguageTool => ("Grammar & spelling", self.lt_status.clone()),
|
||||
_ => ("Spelling", self.spell_status.clone()),
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Grammar & spelling").strong());
|
||||
if !self.lt_status.is_empty() {
|
||||
ui.label(egui::RichText::new(&self.lt_status).weak());
|
||||
}
|
||||
if stale && !self.lt_matches.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new("· edited since check — re-check to apply fixes")
|
||||
.weak()
|
||||
.italics(),
|
||||
);
|
||||
ui.label(egui::RichText::new(title).strong());
|
||||
if !status.is_empty() {
|
||||
ui.label(egui::RichText::new(status).weak());
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Hide").clicked() {
|
||||
@@ -1256,14 +1548,11 @@ impl App {
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
if self.lt_matches.is_empty() {
|
||||
if items.is_empty() {
|
||||
let busy = self.lt_rx.is_some() || self.spell_rx.is_some();
|
||||
ui.label(
|
||||
egui::RichText::new(if self.lt_rx.is_some() {
|
||||
"Checking…"
|
||||
} else {
|
||||
"No issues to show."
|
||||
})
|
||||
.weak(),
|
||||
egui::RichText::new(if busy { "Checking…" } else { "No issues to show." })
|
||||
.weak(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1272,34 +1561,31 @@ impl App {
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
for (i, m) in self.lt_matches.iter().enumerate() {
|
||||
for item in &items {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let (dot, col) = if m.spelling {
|
||||
("●", egui::Color32::from_rgb(0xE0, 0x40, 0x40))
|
||||
let col = if item.spelling {
|
||||
egui::Color32::from_rgb(0xE0, 0x40, 0x40)
|
||||
} else {
|
||||
("●", egui::Color32::from_rgb(0x3B, 0x82, 0xF6))
|
||||
egui::Color32::from_rgb(0x3B, 0x82, 0xF6)
|
||||
};
|
||||
ui.label(egui::RichText::new(dot).color(col));
|
||||
if let Some(snippet) = self.lt_checked_text.get(m.start..m.end) {
|
||||
ui.label(egui::RichText::new("●").color(col));
|
||||
if !item.snippet.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("“{snippet}”")).strong(),
|
||||
egui::RichText::new(format!("“{}”", item.snippet)).strong(),
|
||||
);
|
||||
}
|
||||
ui.label(&m.message);
|
||||
ui.label(&item.message);
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(16.0);
|
||||
if m.replacements.is_empty() {
|
||||
if item.replacements.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new("(no suggestion)").weak().italics(),
|
||||
);
|
||||
} else {
|
||||
for (j, rep) in m.replacements.iter().take(8).enumerate() {
|
||||
if ui
|
||||
.add_enabled(!stale, egui::Button::new(rep).small())
|
||||
.clicked()
|
||||
{
|
||||
apply = Some((i, j));
|
||||
for (j, rep) in item.replacements.iter().enumerate() {
|
||||
if ui.button(egui::RichText::new(rep).small()).clicked() {
|
||||
apply = Some((item.idx, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1309,7 +1595,7 @@ impl App {
|
||||
});
|
||||
|
||||
if let Some((i, j)) = apply {
|
||||
self.apply_replacement(i, j);
|
||||
self.apply_current_fix(i, j);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1435,16 +1721,13 @@ impl App {
|
||||
// Editor text colour, brightened toward max contrast by the contrast slider.
|
||||
let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast);
|
||||
|
||||
// Underline grammar/spelling matches, but only while the buffer still
|
||||
// equals the text they were computed against (edits invalidate offsets).
|
||||
let ranges: Vec<(usize, usize, bool)> = if self.buffer == self.lt_checked_text {
|
||||
self.lt_matches
|
||||
.iter()
|
||||
.map(|m| (m.start, m.end, m.spelling))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Underline whichever check currently owns the buffer's issues
|
||||
// (LanguageTool when fresh, otherwise the live spell checker).
|
||||
let ranges: Vec<(usize, usize, bool)> = self
|
||||
.current_matches()
|
||||
.iter()
|
||||
.map(|m| (m.start, m.end, m.spelling))
|
||||
.collect();
|
||||
|
||||
// Search-match highlights (all matches shaded, the active one stronger).
|
||||
let find_ranges: Vec<(usize, usize)> = if self.show_find {
|
||||
@@ -1482,8 +1765,11 @@ impl App {
|
||||
.show(ui);
|
||||
if output.response.changed() {
|
||||
self.dirty = true;
|
||||
// The buffer changed, so any search matches are now stale.
|
||||
// The buffer changed, so search matches and the live spell
|
||||
// check both need refreshing (the latter debounced).
|
||||
self.find_needs_refresh = true;
|
||||
self.spell_dirty = true;
|
||||
self.spell_last_edit = Some(Instant::now());
|
||||
}
|
||||
// Markdown formatting hotkeys, applied to the current selection
|
||||
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
|
||||
@@ -1524,6 +1810,55 @@ impl App {
|
||||
}
|
||||
self.find_scroll = false;
|
||||
}
|
||||
|
||||
// Right-click a misspelling → a menu of suggested corrections.
|
||||
if output.response.secondary_clicked() {
|
||||
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
|
||||
let local = pos - output.galley_pos;
|
||||
let cursor = output.galley.cursor_from_pos(local);
|
||||
let byte = char_to_byte(&self.buffer, cursor.ccursor.index);
|
||||
self.match_index_at(byte)
|
||||
});
|
||||
}
|
||||
// Snapshot the target's suggestions (owned) so the menu closure
|
||||
// doesn't borrow self; compute them on demand if the background
|
||||
// check hadn't produced any for this word.
|
||||
let menu: Option<(usize, Vec<String>)> = self.spell_menu.and_then(|i| {
|
||||
let m = self.current_matches().get(i)?;
|
||||
let mut reps = m.replacements.clone();
|
||||
if reps.is_empty() {
|
||||
if let (Some(dict), Some(word)) =
|
||||
(&self.spell_dict, self.buffer.get(m.start..m.end))
|
||||
{
|
||||
reps = crate::spell::suggestions(dict, word);
|
||||
}
|
||||
}
|
||||
Some((i, reps))
|
||||
});
|
||||
let mut chosen: Option<(usize, usize)> = None;
|
||||
output.response.context_menu(|ui| {
|
||||
match &menu {
|
||||
Some((i, reps)) if !reps.is_empty() => {
|
||||
ui.label(egui::RichText::new("Suggestions").strong());
|
||||
for (j, rep) in reps.iter().enumerate() {
|
||||
if ui.button(rep).clicked() {
|
||||
chosen = Some((*i, j));
|
||||
ui.close_menu();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
ui.label(egui::RichText::new("No suggestions").weak());
|
||||
}
|
||||
None => {
|
||||
ui.label(egui::RichText::new("No spelling issue here").weak());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((i, j)) = chosen {
|
||||
self.apply_current_fix(i, j);
|
||||
self.spell_menu = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1690,6 +2025,8 @@ impl eframe::App for App {
|
||||
|
||||
self.poll_lt();
|
||||
self.poll_settings_test();
|
||||
self.poll_spell();
|
||||
self.maybe_start_spell_check(ctx);
|
||||
|
||||
self.menu_bar(ctx);
|
||||
self.top_bar(ctx);
|
||||
@@ -2180,6 +2517,33 @@ fn resolve_chapter_title(
|
||||
.unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width))
|
||||
}
|
||||
|
||||
/// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and
|
||||
/// remap the remaining matches. Returns whether a replacement was made (false if
|
||||
/// the indices are out of range or the match's bounds aren't valid boundaries).
|
||||
fn splice_fix(
|
||||
buffer: &mut String,
|
||||
matches: &mut Vec<crate::langtool::Match>,
|
||||
match_idx: usize,
|
||||
rep_idx: usize,
|
||||
) -> bool {
|
||||
let Some(m) = matches.get(match_idx).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(replacement) = m.replacements.get(rep_idx).cloned() else {
|
||||
return false;
|
||||
};
|
||||
if m.end > buffer.len()
|
||||
|| !buffer.is_char_boundary(m.start)
|
||||
|| !buffer.is_char_boundary(m.end)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
buffer.replace_range(m.start..m.end, &replacement);
|
||||
// The applied match overlaps its own region, so it is dropped here too.
|
||||
remap_matches(matches, m.start, m.end, replacement.len());
|
||||
true
|
||||
}
|
||||
|
||||
/// Re-position matches after the byte range `[s, e)` was replaced with `new_len`
|
||||
/// bytes. Matches that overlapped the edited region (including the one that was
|
||||
/// just applied) are dropped; matches entirely after it are shifted by the
|
||||
|
||||
@@ -45,6 +45,24 @@ pub struct Config {
|
||||
/// Language passed to LanguageTool (`auto` to detect, or a code like `en-US`).
|
||||
#[serde(default = "default_languagetool_language")]
|
||||
pub languagetool_language: String,
|
||||
/// Whether the offline (Hunspell) live spell checker underlines misspellings
|
||||
/// as you type. Used whenever LanguageTool results aren't current.
|
||||
#[serde(default = "default_spell_check")]
|
||||
pub spell_check: bool,
|
||||
/// Id of the spell-check dictionary to use (e.g. `en-CA`, `en_GB`), matching
|
||||
/// a [`crate::spell::DictEntry::id`].
|
||||
#[serde(default = "default_spell_language")]
|
||||
pub spell_language: String,
|
||||
}
|
||||
|
||||
/// Live spell checking is on by default.
|
||||
pub fn default_spell_check() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Default dictionary: the built-in Canadian English one.
|
||||
pub fn default_spell_language() -> String {
|
||||
crate::spell::DEFAULT_LANGUAGE.to_string()
|
||||
}
|
||||
|
||||
/// Default scheme: plain HTTP, matching a local server.
|
||||
@@ -89,6 +107,8 @@ impl Default for Config {
|
||||
languagetool_port: default_languagetool_port(),
|
||||
languagetool_token: String::new(),
|
||||
languagetool_language: default_languagetool_language(),
|
||||
spell_check: default_spell_check(),
|
||||
spell_language: default_spell_language(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-4
@@ -140,12 +140,21 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
|
||||
blank to export the whole file. Comments are removed everywhere.",
|
||||
);
|
||||
|
||||
section(ui, "Grammar & spelling");
|
||||
section(ui, "Spelling & grammar");
|
||||
body(
|
||||
ui,
|
||||
"Press ✓ Check (or the Grammar panel) to run the current file through your \
|
||||
LanguageTool server — configure it in Settings ▸ LanguageTool. Spelling issues \
|
||||
are underlined in red, grammar/style in blue.",
|
||||
"Spelling is checked live and offline: misspellings are underlined in red \
|
||||
as you type — right-click a word for correction suggestions. Choose the \
|
||||
dictionary (Canadian or British English are built in) on the Spelling row \
|
||||
of the top bar, or drop more Hunspell .aff/.dic files in \
|
||||
~/.config/md-manuscript/dictionaries/.",
|
||||
);
|
||||
note(
|
||||
ui,
|
||||
"For grammar and style too, press ✓ Check to run the file through a \
|
||||
LanguageTool server (configure it in Settings ▸ LanguageTool). Its results \
|
||||
(grammar in blue, spelling in red) take over until you edit again, then the \
|
||||
offline spell check resumes.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ mod langtool;
|
||||
mod odt;
|
||||
mod order;
|
||||
mod preprocess;
|
||||
mod spell;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
//! Offline spell checking with [`spellbook`], a pure-Rust reader of Hunspell
|
||||
//! `.aff`/`.dic` dictionaries (so the binary stays self-contained — no C
|
||||
//! `libhunspell` to link against).
|
||||
//!
|
||||
//! Two dictionaries are compiled into the program (Canadian and British
|
||||
//! English); additional Hunspell dictionaries are discovered at runtime from
|
||||
//! the usual system folders and a per-user folder. The checker walks the prose
|
||||
//! of a markdown buffer (skipping code spans, code blocks and link targets via
|
||||
//! `pulldown-cmark`) and reports each unknown word as a spelling
|
||||
//! [`Match`](crate::langtool::Match), reusing the same type LanguageTool
|
||||
//! produces so the editor's underlines, results panel and one-click fixes work
|
||||
//! unchanged.
|
||||
|
||||
use crate::langtool::Match;
|
||||
use spellbook::Dictionary;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// --- Bundled dictionaries (SCOWL-derived, permissive license; see the files
|
||||
// under `dictionaries/<lang>/license`) --------------------------------------
|
||||
|
||||
const EN_CA_AFF: &str = include_str!("../dictionaries/en-CA/index.aff");
|
||||
const EN_CA_DIC: &str = include_str!("../dictionaries/en-CA/index.dic");
|
||||
const EN_GB_AFF: &str = include_str!("../dictionaries/en-GB/index.aff");
|
||||
const EN_GB_DIC: &str = include_str!("../dictionaries/en-GB/index.dic");
|
||||
|
||||
/// The language id selected by default when none is configured.
|
||||
pub const DEFAULT_LANGUAGE: &str = "en-CA";
|
||||
|
||||
/// A dictionary the app can load, either compiled in or found on disk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DictEntry {
|
||||
/// Stable identifier stored in the config (e.g. `en-CA`, `en_GB`, `de_DE`).
|
||||
pub id: String,
|
||||
/// Human-readable label for the picker.
|
||||
pub label: String,
|
||||
kind: DictKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum DictKind {
|
||||
/// Compiled into the binary.
|
||||
Bundled {
|
||||
aff: &'static str,
|
||||
dic: &'static str,
|
||||
},
|
||||
/// A pair of files on disk (`<stem>.aff` + `<stem>.dic`).
|
||||
Files { aff: PathBuf, dic: PathBuf },
|
||||
}
|
||||
|
||||
impl DictEntry {
|
||||
/// Read and parse this dictionary into a usable [`Dictionary`].
|
||||
pub fn load(&self) -> Result<Dictionary, String> {
|
||||
match &self.kind {
|
||||
DictKind::Bundled { aff, dic } => Dictionary::new(aff, dic)
|
||||
.map_err(|e| format!("Could not parse the built-in {} dictionary: {e}", self.id)),
|
||||
DictKind::Files { aff, dic } => {
|
||||
let aff_text = std::fs::read_to_string(aff)
|
||||
.map_err(|e| format!("Cannot read {}: {e}", aff.display()))?;
|
||||
let dic_text = std::fs::read_to_string(dic)
|
||||
.map_err(|e| format!("Cannot read {}: {e}", dic.display()))?;
|
||||
Dictionary::new(&aff_text, &dic_text)
|
||||
.map_err(|e| format!("Could not parse {}: {e}", dic.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The list of dictionaries available to the app: the two bundled English
|
||||
/// variants first, then any others discovered on disk (deduplicated by id),
|
||||
/// sorted by label.
|
||||
pub fn available() -> Vec<DictEntry> {
|
||||
let mut entries = vec![
|
||||
DictEntry {
|
||||
id: "en-CA".to_string(),
|
||||
label: "English (Canada) — built-in".to_string(),
|
||||
kind: DictKind::Bundled {
|
||||
aff: EN_CA_AFF,
|
||||
dic: EN_CA_DIC,
|
||||
},
|
||||
},
|
||||
DictEntry {
|
||||
id: "en-GB".to_string(),
|
||||
label: "English (UK) — built-in".to_string(),
|
||||
kind: DictKind::Bundled {
|
||||
aff: EN_GB_AFF,
|
||||
dic: EN_GB_DIC,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for dir in search_dirs() {
|
||||
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("dic") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let aff = path.with_extension("aff");
|
||||
if !aff.exists() {
|
||||
continue;
|
||||
}
|
||||
// Don't shadow a bundled entry (or an earlier directory's copy).
|
||||
if entries.iter().any(|e| e.id == stem) {
|
||||
continue;
|
||||
}
|
||||
entries.push(DictEntry {
|
||||
id: stem.to_string(),
|
||||
label: format!("{stem} ({})", dir.display()),
|
||||
kind: DictKind::Files { aff, dic: path },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
entries
|
||||
}
|
||||
|
||||
/// Directories scanned for extra `.aff`/`.dic` pairs, most specific last so a
|
||||
/// user copy can be preferred. Missing directories are simply skipped.
|
||||
fn search_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = vec![
|
||||
PathBuf::from("/usr/share/hunspell"),
|
||||
PathBuf::from("/usr/share/myspell"),
|
||||
PathBuf::from("/usr/share/myspell/dicts"),
|
||||
PathBuf::from("/usr/local/share/hunspell"),
|
||||
];
|
||||
if let Some(data) = dirs::data_dir() {
|
||||
dirs.push(data.join("hunspell"));
|
||||
}
|
||||
if let Some(config) = dirs::config_dir() {
|
||||
dirs.push(config.join("md-manuscript").join("dictionaries"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// How many distinct misspellings we compute suggestions for per check. Beyond
|
||||
/// this the words are still underlined but carry no suggestions (a right-click
|
||||
/// falls back to computing them on demand). Keeps a document full of unknown
|
||||
/// words — names, invented terms — from stalling the background check.
|
||||
const MAX_SUGGEST_WORDS: usize = 250;
|
||||
|
||||
/// Spell-check the prose in `text`, returning a spelling [`Match`] for every
|
||||
/// unknown word (byte offsets into `text`, best suggestions first).
|
||||
pub fn check(dict: &Dictionary, text: &str) -> Vec<Match> {
|
||||
let mut out = Vec::new();
|
||||
let mut cache: HashMap<&str, Vec<String>> = HashMap::new();
|
||||
let mut suggested = 0usize;
|
||||
|
||||
for (offset, word) in prose_words(text) {
|
||||
if should_skip(word) || dict.check(word) {
|
||||
continue;
|
||||
}
|
||||
let replacements = if let Some(cached) = cache.get(word) {
|
||||
cached.clone()
|
||||
} else if suggested < MAX_SUGGEST_WORDS {
|
||||
let sugg = suggestions(dict, word);
|
||||
suggested += 1;
|
||||
cache.insert(word, sugg.clone());
|
||||
sugg
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
out.push(Match {
|
||||
start: offset,
|
||||
end: offset + word.len(),
|
||||
message: format!("“{word}” may be misspelled"),
|
||||
replacements,
|
||||
spelling: true,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Suggestions for a single word (best first, capped), for on-demand use.
|
||||
pub fn suggestions(dict: &Dictionary, word: &str) -> Vec<String> {
|
||||
let mut sugg = Vec::new();
|
||||
dict.suggest(word, &mut sugg);
|
||||
sugg.truncate(8);
|
||||
sugg
|
||||
}
|
||||
|
||||
/// Whether a token should not be checked: too short, an all-caps initialism, or
|
||||
/// containing no alphabetic character.
|
||||
fn should_skip(word: &str) -> bool {
|
||||
let mut letters = 0usize;
|
||||
let mut all_upper = true;
|
||||
for ch in word.chars() {
|
||||
if ch.is_alphabetic() {
|
||||
letters += 1;
|
||||
if !ch.is_uppercase() {
|
||||
all_upper = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if letters < 2 {
|
||||
return true;
|
||||
}
|
||||
// Skip ALL-CAPS tokens (acronyms/initialisms like ODT, HTTP, NASA).
|
||||
all_upper
|
||||
}
|
||||
|
||||
/// Extract the checkable prose words of a markdown buffer as `(byte_offset,
|
||||
/// word)` pairs. Code spans, fenced/indented code blocks and link/image targets
|
||||
/// are excluded (they arrive as non-`Text` events), and surrounding apostrophes
|
||||
/// are trimmed so contractions like `don't` stay intact but `'quoted'` doesn't
|
||||
/// keep its quotes.
|
||||
fn prose_words(text: &str) -> Vec<(usize, &str)> {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
let mut words = Vec::new();
|
||||
let mut in_code_block = false;
|
||||
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH).into_offset_iter();
|
||||
for (event, range) in parser {
|
||||
match event {
|
||||
// Fenced/indented code-block *content* arrives as Text events, so
|
||||
// suppress tokenizing while inside one. (Inline code is Event::Code.)
|
||||
Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
|
||||
Event::End(TagEnd::CodeBlock) => in_code_block = false,
|
||||
Event::Text(_) if !in_code_block => {
|
||||
// Use the source slice (not the decoded Cow) so offsets are exact.
|
||||
tokenize(range.start, &text[range.clone()], &mut words);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// Split a run of prose into words, pushing `(absolute_byte_offset, word)`.
|
||||
fn tokenize<'a>(base: usize, s: &'a str, out: &mut Vec<(usize, &'a str)>) {
|
||||
let is_word = |c: char| c.is_alphabetic() || c == '\'' || c == '’';
|
||||
let mut start: Option<usize> = None;
|
||||
for (idx, ch) in s.char_indices() {
|
||||
match (start, is_word(ch)) {
|
||||
(None, true) => start = Some(idx),
|
||||
(Some(st), false) => {
|
||||
push_word(base, s, st, idx, out);
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(st) = start {
|
||||
push_word(base, s, st, s.len(), out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim leading/trailing apostrophes from `s[st..en]` and, if anything remains,
|
||||
/// push it with its absolute byte offset.
|
||||
fn push_word<'a>(base: usize, s: &'a str, st: usize, en: usize, out: &mut Vec<(usize, &'a str)>) {
|
||||
let raw = &s[st..en];
|
||||
let quote = |c: char| c == '\'' || c == '’';
|
||||
let trimmed = raw.trim_matches(quote);
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lead = raw.len() - raw.trim_start_matches(quote).len();
|
||||
out.push((base + st + lead, trimmed));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn en_ca() -> Dictionary {
|
||||
DictEntry {
|
||||
id: "en-CA".to_string(),
|
||||
label: String::new(),
|
||||
kind: DictKind::Bundled {
|
||||
aff: EN_CA_AFF,
|
||||
dic: EN_CA_DIC,
|
||||
},
|
||||
}
|
||||
.load()
|
||||
.expect("bundled en-CA dictionary parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_dictionaries_are_listed_and_parse() {
|
||||
let list = available();
|
||||
for id in ["en-CA", "en-GB"] {
|
||||
let entry = list
|
||||
.iter()
|
||||
.find(|d| d.id == id)
|
||||
.unwrap_or_else(|| panic!("{id} should be available"));
|
||||
entry
|
||||
.load()
|
||||
.unwrap_or_else(|e| panic!("bundled {id} should parse: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn en_gb_prefers_british_spelling() {
|
||||
let dict = available()
|
||||
.into_iter()
|
||||
.find(|d| d.id == "en-GB")
|
||||
.unwrap()
|
||||
.load()
|
||||
.unwrap();
|
||||
// "realise" is British; "color" (American) is not en-GB.
|
||||
assert!(check(&dict, "realise").is_empty());
|
||||
assert_eq!(check(&dict, "color").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenizer_yields_prose_words_with_offsets() {
|
||||
let text = "The cat sat.";
|
||||
let mut words = Vec::new();
|
||||
tokenize(0, text, &mut words);
|
||||
assert_eq!(words, vec![(0, "The"), (4, "cat"), (8, "sat")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenizer_keeps_contractions_but_trims_quotes() {
|
||||
let text = "don't 'quoted'";
|
||||
let mut words = Vec::new();
|
||||
tokenize(0, text, &mut words);
|
||||
assert_eq!(words, vec![(0, "don't"), (7, "quoted")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_words_skip_code_spans_and_blocks() {
|
||||
// Inline code and a fenced block must not be tokenized.
|
||||
let text = "real word `codeword` end\n\n```\nblockword\n```\n";
|
||||
let words = prose_words(text);
|
||||
let found: Vec<&str> = words.iter().map(|(_, w)| *w).collect();
|
||||
assert!(found.contains(&"real"));
|
||||
assert!(found.contains(&"word"));
|
||||
assert!(found.contains(&"end"));
|
||||
assert!(!found.contains(&"codeword"));
|
||||
assert!(!found.contains(&"blockword"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_skip_short_and_allcaps() {
|
||||
assert!(should_skip("a")); // too short
|
||||
assert!(should_skip("ODT")); // acronym
|
||||
assert!(should_skip("HTTP"));
|
||||
assert!(!should_skip("word"));
|
||||
assert!(!should_skip("The")); // initial cap is fine
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_flags_misspellings_with_correct_offsets() {
|
||||
let dict = en_ca();
|
||||
let text = "The quikc brown fox.";
|
||||
let matches = check(&dict, text);
|
||||
assert_eq!(matches.len(), 1, "only 'quikc' is misspelled");
|
||||
let m = &matches[0];
|
||||
assert_eq!(&text[m.start..m.end], "quikc");
|
||||
assert!(m.spelling);
|
||||
// spellbook should suggest the obvious correction.
|
||||
assert!(
|
||||
m.replacements.iter().any(|r| r == "quick"),
|
||||
"expected 'quick' among suggestions, got {:?}",
|
||||
m.replacements
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_respects_canadian_spelling() {
|
||||
let dict = en_ca();
|
||||
// "colour" is correct in en-CA; "color" is not.
|
||||
assert!(check(&dict, "colour").is_empty());
|
||||
assert_eq!(check(&dict, "color").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_offsets_survive_multibyte_prose() {
|
||||
let dict = en_ca();
|
||||
// The é is two bytes; a misspelling after it must still map to the right
|
||||
// bytes (whether or not "café" itself is in the dictionary).
|
||||
let text = "café qmzxk";
|
||||
let matches = check(&dict, text);
|
||||
let target = matches
|
||||
.iter()
|
||||
.find(|m| text.get(m.start..m.end) == Some("qmzxk"));
|
||||
assert!(
|
||||
target.is_some(),
|
||||
"expected a match slicing to 'qmzxk', got {:?}",
|
||||
matches
|
||||
.iter()
|
||||
.map(|m| &text[m.start..m.end])
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user