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:
landon
2026-08-14 06:25:08 -05:00
parent 691d1a0515
commit 19dddf30c7
14 changed files with 101168 additions and 69 deletions
+428 -64
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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.",
);
}
+1
View File
@@ -10,6 +10,7 @@ mod langtool;
mod odt;
mod order;
mod preprocess;
mod spell;
use eframe::egui;
+392
View File
@@ -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<_>>()
);
}
}