D8: editor spell check with built-in dictionary

SpellChecker with ~300 common English words + programming terms.
Word tokenizer handles apostrophes, digits, underscores.
Editor integration: toggle_spell_check(), find_next_misspelled().
MC uses libaspell; TLC embeds dictionary for zero external deps.

17 new tests: check_word, check_line, case insensitive, editor
integration (toggle, find_next, disabled, correct text).
This commit is contained in:
2026-07-05 22:39:29 +03:00
parent b8aac3c9bc
commit 76df890074
2 changed files with 420 additions and 0 deletions
@@ -54,6 +54,7 @@ pub mod prompt;
pub mod render;
pub mod save;
pub mod search;
pub mod spell;
#[cfg(feature = "syntect")]
pub mod syntax;
pub mod tags;
@@ -221,6 +222,8 @@ pub struct Editor {
/// F2 user-menu session (MC `CK_UserMenu`). `None` when the
/// menu is closed. F2 opens the dialog; Esc closes.
usermenu_session: Option<crate::editor::usermenu::EditorUserMenu>,
spell_checker: spell::SpellChecker,
spell_check_enabled: bool,
}
/// One in-flight smooth-scroll animation.
@@ -290,6 +293,8 @@ impl Editor {
smooth_scroll: None,
menubar: None,
usermenu_session: None,
spell_checker: spell::SpellChecker::new(),
spell_check_enabled: false,
}
}
@@ -337,6 +342,8 @@ impl Editor {
smooth_scroll: None,
menubar: None,
usermenu_session: None,
spell_checker: spell::SpellChecker::new(),
spell_check_enabled: false,
}
}
@@ -713,6 +720,36 @@ impl Editor {
self.show_whitespace
}
pub fn toggle_spell_check(&mut self) -> bool {
self.spell_check_enabled = !self.spell_check_enabled;
self.spell_check_enabled
}
pub fn spell_check_enabled(&self) -> bool {
self.spell_check_enabled
}
pub fn find_next_misspelled(&self, from_byte: usize) -> Option<(usize, usize)> {
if !self.spell_check_enabled {
return None;
}
let full_text = self.buffer.as_string();
let start_line = self.buffer_line_of(from_byte);
for line_idx in start_line..self.buffer.line_count() {
let off = self.buffer.line_offset(line_idx);
let len = self.buffer.line_length(line_idx);
let line_end = (off + len).min(full_text.len());
let line_text = full_text.get(off..line_end).unwrap_or("");
for (ws, we) in self.spell_checker.check_line(line_text) {
let abs = off + ws;
if abs >= from_byte {
return Some((abs, off + we));
}
}
}
None
}
/// Borrow the in-buffer search engine state (pattern, history,
/// last match).
#[must_use]
@@ -3562,5 +3599,43 @@ mod tests {
e.add_secondary_cursor(2);
assert_eq!(e.secondary_cursor_count(), 1);
}
#[test]
fn spell_check_toggle() {
let mut e = make_empty();
assert!(!e.spell_check_enabled());
e.toggle_spell_check();
assert!(e.spell_check_enabled());
e.toggle_spell_check();
assert!(!e.spell_check_enabled());
}
#[test]
fn spell_check_finds_next_misspelled() {
let mut e = make_empty();
e.insert_str("the quikc way home");
e.toggle_spell_check();
let misspelled = e.find_next_misspelled(0);
assert!(misspelled.is_some());
let (start, end) = misspelled.unwrap();
assert_eq!(&e.buffer().as_string()[start..end], "quikc");
}
#[test]
fn spell_check_no_misspelled_when_disabled() {
let mut e = make_empty();
e.insert_str("the quikc brown fox");
let misspelled = e.find_next_misspelled(0);
assert!(misspelled.is_none());
}
#[test]
fn spell_check_returns_none_for_correct_text() {
let mut e = make_empty();
e.insert_str("the way home");
e.toggle_spell_check();
let misspelled = e.find_next_misspelled(0);
assert!(misspelled.is_none());
}
}
@@ -0,0 +1,345 @@
use std::collections::HashSet;
/// A simple spell checker with a built-in word list.
///
/// MC loads libaspell dynamically via `g_module_symbol`. TLC has no
/// external spell-check dependency — this module embeds a compact set
/// of common English words and provides the same conceptual API:
/// `check_word` returns `true` for known words, `false` for unknown.
///
/// The architecture supports future enhancement: load a dictionary
/// file at runtime, or integrate a pure-Rust spell-check crate.
pub struct SpellChecker {
words: HashSet<String>,
}
pub type Misspelled = (usize, usize);
impl SpellChecker {
pub fn new() -> Self {
let words: HashSet<String> = BUILTIN_WORDS
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
Self { words }
}
/// Returns `true` if the word is known. Short words (<=1 char),
/// words containing digits, and words with underscores are always
/// considered correct (file names, identifiers, numbers).
pub fn check_word(&self, word: &str) -> bool {
if word.len() <= 1 {
return true;
}
if word.chars().any(|c| c.is_ascii_digit()) {
return true;
}
if word.contains('_') {
return true;
}
self.words.contains(&word.to_ascii_lowercase())
}
/// Scan a line of text and return byte ranges of misspelled words.
pub fn check_line(&self, text: &str) -> Vec<Misspelled> {
let mut result = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if is_word_char(bytes[i]) {
let start = i;
while i < bytes.len() && is_word_char(bytes[i]) {
i += 1;
}
let word = &text[start..i];
if !self.check_word(word) {
result.push((start, i));
}
} else {
i += 1;
}
}
result
}
}
impl Default for SpellChecker {
fn default() -> Self {
Self::new()
}
}
fn is_word_char(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'\''
}
static BUILTIN_WORDS: &[&str] = &[
"a", "about", "above", "after", "again", "against", "all", "am", "an",
"and", "any", "are", "aren't", "as", "at", "be", "because", "been",
"before", "being", "below", "between", "both", "but", "by", "can",
"can't", "cannot", "could", "couldn't", "did", "didn't", "do", "does",
"doesn't", "doing", "don't", "down", "during", "each", "few", "for",
"from", "further", "had", "hadn't", "has", "hasn't", "have", "haven't",
"having", "he", "he'd", "he'll", "he's", "her", "here", "here's",
"hers", "herself", "him", "himself", "his", "how", "how's", "i",
"i'd", "i'll", "i'm", "i've", "if", "in", "into", "is", "isn't",
"it", "it's", "its", "itself", "let's", "me", "more", "most",
"mustn't", "my", "myself", "no", "nor", "not", "of", "off", "on",
"once", "only", "or", "other", "ought", "our", "ours", "ourselves",
"out", "over", "own", "same", "shan't", "she", "she'd", "she'll",
"she's", "should", "shouldn't", "so", "some", "such", "than",
"that", "that's", "the", "their", "theirs", "them", "themselves",
"then", "there", "there's", "these", "they", "they'd", "they'll",
"they're", "they've", "this", "those", "through", "to", "too",
"under", "until", "up", "very", "was", "wasn't", "we", "we'd",
"we'll", "we're", "we've", "were", "weren't", "what", "what's",
"when", "when's", "where", "where's", "which", "while", "who",
"who's", "whom", "why", "why's", "with", "won't", "would",
"wouldn't", "you", "you'd", "you'll", "you're", "you've", "your",
"yours", "yourself", "yourselves",
// Common verbs
"go", "going", "goes", "went", "gone", "get", "getting", "gets",
"got", "gotten", "make", "making", "makes", "made", "take", "taking",
"takes", "took", "taken", "come", "coming", "comes", "came", "see",
"seeing", "sees", "saw", "seen", "know", "knowing", "knows", "knew",
"known", "think", "thinking", "thinks", "thought", "look", "looking",
"looks", "looked", "want", "wanting", "wants", "wanted", "give",
"giving", "gives", "gave", "find", "finding", "finds", "found",
"tell", "telling", "tells", "told", "ask", "asking", "asks",
"asked", "work", "working", "works", "worked", "seem", "seeming",
"seems", "seemed", "feel", "feeling", "feels", "felt", "try",
"trying", "tries", "tried", "leave", "leaving", "leaves", "left",
"call", "calling", "calls", "called", "put", "putting", "puts",
"mean", "meaning", "means", "meant", "keep", "keeping", "keeps",
"kept", "let", "letting", "lets", "begin", "beginning", "begins",
"began", "begun", "show", "showing", "shows", "showed", "shown",
"hear", "hearing", "hears", "heard", "play", "playing", "plays",
"played", "run", "running", "runs", "move", "moving", "moves",
"moved", "live", "living", "lives", "lived", "believe", "believing",
"believes", "believed", "hold", "holding", "holds", "held",
"bring", "bringing", "brings", "brought", "happen", "happening",
"happens", "happened", "write", "writing", "writes", "written",
"wrote", "provide", "providing", "provides", "provided", "sit",
"sitting", "sits", "sat", "stand", "standing", "stands", "stood",
"lose", "losing", "loses", "lost", "pay", "paying", "pays", "paid",
"meet", "meeting", "meets", "met", "include", "including",
"includes", "included", "continue", "continuing", "continues",
"continued", "set", "setting", "sets", "learn", "learning",
"learns", "learned", "change", "changing", "changes", "changed",
"lead", "leading", "leads", "led", "understand", "understanding",
"understands", "understood", "watch", "watching", "watches",
"watched", "follow", "following", "follows", "followed", "stop",
"stopping", "stops", "stopped", "create", "creating", "creates",
"created", "speak", "speaking", "speaks", "spoke", "spoken",
"read", "reading", "reads", "allow", "allowing", "allows",
"allowed", "add", "adding", "adds", "added", "spend", "spending",
"spends", "spent", "grow", "growing", "grows", "grew", "grown",
"open", "opening", "opens", "opened", "walk", "walking", "walks",
"walked", "win", "winning", "wins", "won", "offer", "offering",
"offers", "offered", "remember", "remembering", "remembers",
"remembered", "love", "loving", "loves", "loved", "consider",
"considering", "considers", "considered", "appear", "appearing",
"appears", "appeared", "buy", "buying", "buys", "bought", "wait",
"waiting", "waits", "waited", "serve", "serving", "serves",
"served", "die", "dying", "dies", "died", "send", "sending",
"sends", "sent", "expect", "expecting", "expects", "expected",
"build", "building", "builds", "built", "stay", "staying", "stays",
"stayed", "fall", "falling", "falls", "fell", "fallen", "cut",
"cutting", "cuts", "reach", "reaching", "reaches", "reached",
"kill", "killing", "kills", "killed", "remain", "remaining",
"remains", "remained", "suggest", "suggesting", "suggests",
"suggested", "raise", "raising", "raises", "raised", "pass",
"passing", "passes", "passed", "sell", "selling", "sells", "sold",
"require", "requiring", "requires", "required", "report",
"reporting", "reports", "reported", "decide", "deciding",
"decides", "decided", "pull", "pulling", "pulls", "pulled",
"return", "returning", "returns", "returned", "explain",
"explaining", "explains", "explained", "hope", "hoping", "hopes",
"hoped", "develop", "developing", "develops", "developed", "carry",
"carrying", "carries", "carried", "break", "breaking", "breaks",
"broke", "broken", "receive", "receiving", "receives", "received",
"agree", "agreeing", "agrees", "agreed", "support", "supporting",
"supports", "supported", "hit", "hitting", "hits", "produce",
"producing", "produces", "produced", "eat", "eating", "eats",
"ate", "eaten", "cover", "covering", "covers", "covered", "catch",
"catching", "catches", "caught", "draw", "drawing", "draws",
"drew", "drawn", "choose", "choosing", "chooses", "chose",
"chosen",
// Common nouns
"time", "year", "people", "way", "day", "man", "thing", "woman",
"life", "child", "world", "school", "state", "family", "student",
"group", "country", "problem", "hand", "part", "place", "case",
"week", "company", "system", "program", "question", "work",
"government", "number", "night", "point", "home", "water", "room",
"mother", "area", "money", "story", "fact", "month", "lot",
"right", "study", "book", "eye", "job", "word", "business",
"issue", "side", "kind", "head", "house", "service", "friend",
"father", "power", "hour", "game", "line", "end", "member", "law",
"car", "city", "community", "name", "president", "team", "minute",
"idea", "body", "information", "back", "parent", "face", "others",
"level", "office", "door", "health", "person", "art", "war",
"history", "party", "result", "change", "morning", "reason",
"research", "girl", "guy", "moment", "air", "teacher", "force",
"education", "program", "development", "role", "effort",
// Common adjectives
"good", "new", "first", "last", "long", "great", "little", "own",
"other", "old", "right", "big", "high", "different", "small",
"large", "next", "early", "young", "important", "few", "public",
"bad", "same", "able", "best", "real", "sure", "whole", "common",
"poor", "natural", "significant", "similar", "tough", "weak",
"necessary", "bright", "wide", "fine", "beautiful", "full", "nice",
"dark", "warm", "cold", "cool", "hot", "fast", "slow", "free",
"open", "closed", "empty", "full", "rich", "clean", "dirty",
"true", "false", "safe", "dangerous", "happy", "sad", "angry",
"simple", "complex", "easy", "hard", "difficult", "close", "far",
"near", "short", "tall", "deep", "shallow", "thick", "thin",
"heavy", "light", "strong", "soft", "rough", "smooth", "sharp",
"dull", "dry", "wet", "sweet", "sour", "bitter", "fresh",
"stale", "raw", "cooked", "correct", "wrong", "single", "double",
"extra", "normal", "strange", "regular", "odd", "usual",
"unusual", "particular", "general", "specific", "complete",
"partial", "total", "entire", "perfect", "imperfect", "absolute",
"relative", "actual", "virtual", "present", "absent", "alive",
"dead", "awake", "asleep", "bare", "covered", "blank", "filled",
"plain", "fancy", "pure", "mixed", "clean", "clear", "cloudy",
"solid", "liquid", "gas", "empty", "stuffed", "hollow", "bent",
"straight", "crooked", "flat", "round", "square", "oval",
// Common adverbs
"up", "out", "on", "off", "over", "under", "again", "further",
"then", "once", "here", "there", "when", "where", "why", "how",
"all", "any", "both", "each", "few", "more", "most", "other",
"some", "such", "no", "nor", "not", "only", "same", "so", "than",
"too", "very", "soon", "now", "today", "yesterday", "tomorrow",
"soon", "later", "early", "late", "always", "never", "often",
"rarely", "sometimes", "usually", "frequently", "occasionally",
"seldom", "hardly", "barely", "scarcely", "almost", "nearly",
"quite", "rather", "pretty", "somewhat", "somehow", "anyway",
"anywhere", "somewhere", "nowhere", "everywhere", "above",
"below", "beside", "behind", "beyond", "within", "without",
"inside", "outside", "upstairs", "downstairs", "forward",
"backward", "left", "right", "away", "back", "apart", "aside",
"indeed", "instead", "therefore", "however", "moreover",
"otherwise", "nevertheless", "nonetheless", "meanwhile",
"finally", "lastly", "firstly", "secondly", "thirdly",
// Programming-specific terms
"fn", "let", "mut", "pub", "use", "mod", "struct", "enum", "impl",
"trait", "type", "where", "crate", "self", "super", "extern",
"move", "ref", "static", "const", "unsafe", "async", "await",
"dyn", "union", "true", "false", "none", "some", "ok", "err",
"match", "if", "else", "for", "while", "loop", "break", "continue",
"return", "yield", "in", "as", "box", "where", "abstract",
"become", "do", "final", "macro", "try", "typeof", "unsized",
"virtual", "yield",
];
#[cfg(test)]
mod tests {
use super::*;
fn checker() -> SpellChecker {
SpellChecker::new()
}
#[test]
fn known_word_is_correct() {
let c = checker();
assert!(c.check_word("the"));
assert!(c.check_word("time"));
assert!(c.check_word("people"));
}
#[test]
fn unknown_word_is_misspelled() {
let c = checker();
assert!(!c.check_word("xyzzy"));
assert!(!c.check_word("teh"));
assert!(!c.check_word("recieve"));
}
#[test]
fn short_word_always_correct() {
let c = checker();
assert!(c.check_word("a"));
assert!(c.check_word("z"));
assert!(c.check_word(""));
}
#[test]
fn word_with_digit_always_correct() {
let c = checker();
assert!(c.check_word("utf8"));
assert!(c.check_word("x86"));
assert!(c.check_word("i18n"));
}
#[test]
fn word_with_underscore_always_correct() {
let c = checker();
assert!(c.check_word("snake_case"));
assert!(c.check_word("my_variable"));
}
#[test]
fn case_insensitive_check() {
let c = checker();
assert!(c.check_word("The"));
assert!(c.check_word("THE"));
assert!(c.check_word("the"));
}
#[test]
fn check_line_finds_misspelled_words() {
let c = checker();
let text = "the quikc way home";
let misspelled = c.check_line(text);
assert_eq!(misspelled.len(), 1);
let (start, end) = misspelled[0];
assert_eq!(&text[start..end], "quikc");
}
#[test]
fn check_line_no_misspelled() {
let c = checker();
let text = "the way home";
let misspelled = c.check_line(text);
assert!(misspelled.is_empty());
}
#[test]
fn check_line_multiple_misspelled() {
let c = checker();
let text = "teh quikc recieve";
let misspelled = c.check_line(text);
assert_eq!(misspelled.len(), 3);
}
#[test]
fn check_line_handles_apostrophes() {
let c = checker();
let text = "don't go";
let misspelled = c.check_line(text);
assert!(misspelled.is_empty());
}
#[test]
fn check_line_empty_text() {
let c = checker();
let misspelled = c.check_line("");
assert!(misspelled.is_empty());
}
#[test]
fn check_line_only_punctuation() {
let c = checker();
let misspelled = c.check_line("!@#$%^&*()");
assert!(misspelled.is_empty());
}
#[test]
fn programming_terms_recognized() {
let c = checker();
assert!(c.check_word("fn"));
assert!(c.check_word("impl"));
assert!(c.check_word("match"));
}
}