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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user