use super::{apply_tokenizer, Checker, Detector, Suggestion};
use crate::checker::dictaffix::DicAff;
use crate::config::WrappedRegex;
use crate::documentation::{CheckableChunk, ContentOrigin, PlainOverlay};
use crate::util::sub_chars;
use crate::Range;
use nlprule::Tokenizer;
use std::path::PathBuf;
use std::sync::Arc;
use doc_chunks::Ignores;
use crate::errors::*;
use super::quirks::{
replacements_contain_dashed, replacements_contain_dashless, transform, Transformed,
};
use super::hunspell::consists_of_vulgar_fractions_or_emojis;
#[derive(Clone)]
pub struct SpellbookCheckerInner {
spellbook: ::spellbook::Dictionary,
transform_regex: Vec<WrappedRegex>,
allow_concatenated: bool,
allow_dashed: bool,
allow_emojis: bool,
check_footnote_references: bool,
ignorelist: String,
}
impl SpellbookCheckerInner {
fn new(config: &<SpellbookChecker as Checker>::Config) -> Result<Self> {
let (
transform_regex,
allow_concatenated,
allow_dashed,
allow_emojis,
check_footnote_references,
) = {
let quirks = &config.quirks;
(
quirks.transform_regex().to_vec(),
quirks.allow_concatenated(),
quirks.allow_dashed(),
quirks.allow_emojis(),
quirks.check_footnote_references(),
)
};
let ignorelist = config.tokenization_splitchars.clone();
debug_assert!(ignorelist.contains(','));
debug_assert!(ignorelist.contains('.'));
debug_assert!(ignorelist.contains(';'));
debug_assert!(ignorelist.contains('!'));
debug_assert!(ignorelist.contains('?'));
let DicAff { dic, aff } = DicAff::load(
&config.extra_dictionaries[..],
&config.search_dirs,
config.lang(),
config.use_builtin,
config.skip_os_lookups,
)?;
let spellbook = ::spellbook::Dictionary::new(&aff, &dic)
.map_err(|e| eyre!("Failed to parse dictionary: {e}"))?;
log::debug!("Dictionary setup completed successfully.");
Ok(Self {
spellbook,
transform_regex,
allow_concatenated,
allow_dashed,
allow_emojis,
check_footnote_references,
ignorelist,
})
}
}
#[derive(Clone)]
pub struct SpellbookChecker(pub Arc<SpellbookCheckerInner>, pub Arc<Tokenizer>);
impl std::ops::Deref for SpellbookChecker {
type Target = SpellbookCheckerInner;
fn deref(&self) -> &Self::Target {
self.0.deref()
}
}
impl SpellbookChecker {
pub fn new(config: &<SpellbookChecker as Checker>::Config) -> Result<Self> {
let tokenizer = super::tokenizer::<&PathBuf>(None)?;
let inner = SpellbookCheckerInner::new(config)?;
let hunspell = Arc::new(inner);
Ok(SpellbookChecker(hunspell, tokenizer))
}
}
impl Checker for SpellbookChecker {
type Config = crate::config::SpellbookConfig;
fn detector() -> Detector {
Detector::Spellbook
}
fn check<'a, 's>(
&self,
origin: &ContentOrigin,
chunks: &'a [CheckableChunk],
) -> Result<Vec<Suggestion<'s>>>
where
'a: 's,
{
let mut acc = Vec::with_capacity(chunks.len());
for chunk in chunks {
let plain = chunk.erase_cmark(&Ignores {
footnote_references: !self.0.check_footnote_references,
});
log::trace!("{plain:?}");
let txt = plain.as_str();
'tokenization: for range in apply_tokenizer(&self.1, txt) {
let word = sub_chars(txt, range.clone());
if range.len() == 1
&& word
.chars()
.next()
.filter(|c| self.ignorelist.contains(*c))
.is_some()
{
continue 'tokenization;
}
if self.transform_regex.is_empty() {
obtain_suggestions(
&plain,
chunk,
&self.spellbook,
origin,
word,
range,
self.allow_concatenated,
self.allow_dashed,
self.allow_emojis,
&mut acc,
)
} else {
match transform(&self.transform_regex[..], word.as_str(), range.clone()) {
Transformed::Fragments(word_fragments) => {
for (range, word_fragment) in word_fragments {
obtain_suggestions(
&plain,
chunk,
&self.spellbook,
origin,
word_fragment.to_owned(),
range,
self.allow_concatenated,
self.allow_dashed,
self.allow_emojis,
&mut acc,
);
}
}
Transformed::Atomic((range, word)) => {
obtain_suggestions(
&plain,
chunk,
&self.spellbook,
origin,
word.to_owned(),
range,
self.allow_concatenated,
self.allow_dashed,
self.allow_emojis,
&mut acc,
);
}
Transformed::Whitelisted(_) => {}
}
}
}
}
Ok(acc)
}
}
#[allow(clippy::too_many_arguments)]
fn obtain_suggestions<'s>(
plain: &PlainOverlay,
chunk: &'s CheckableChunk,
dictionary: &::spellbook::Dictionary,
origin: &ContentOrigin,
word: String,
range: Range,
allow_concatenated: bool,
allow_dashed: bool,
allow_emojis: bool,
acc: &mut Vec<Suggestion<'s>>,
) {
log::trace!("Checking {word} in {range:?}..");
match dictionary.check(&word) {
false => {
log::trace!(target: "spellbook", "No match for word (plain range: {range:?}): >{word}<");
let replacements = vec![];
log::debug!(target: "spellbook", "{word} --{{suggest}}--> {replacements:?}");
if allow_emojis && consists_of_vulgar_fractions_or_emojis(&word) {
log::trace!(target: "quirks", "Found emoji or vulgar fraction character, treating {word} as ok");
return;
}
if allow_concatenated && replacements_contain_dashless(&word, replacements.as_slice()) {
log::trace!(target: "quirks", "Found dashless word in replacement suggestions, treating {word} as ok");
return;
}
if allow_dashed && replacements_contain_dashed(&word, replacements.as_slice()) {
log::trace!(target: "quirks", "Found dashed word in replacement suggestions, treating {word} as ok");
return;
}
for (range, span) in plain.find_spans(range.clone()) {
acc.push(Suggestion {
detector: Detector::Spellbook,
range,
span,
origin: origin.clone(),
replacements: replacements.clone(),
chunk,
description: Some("Possible spelling mistake found.".to_owned()),
})
}
}
true => {
log::trace!(target: "spellbook", "Found a match for word (plain range: {range:?}): >{word}<",);
}
}
}
#[cfg(test)]
mod tests {
use crate::checker::dictaffix::is_valid_hunspell_dic;
use super::*;
#[test]
fn hunspell_dic_format() {
const GOOD: &str = "2
whitespazes
catsndogs
";
const BAD_1: &str = "foo
12349
bar
";
const BAD_2: &str = "2
12349
bar
";
const BAD_3: &str = "foo
xxx
bar
";
assert!(is_valid_hunspell_dic(&mut GOOD.as_bytes()).is_ok());
assert!(is_valid_hunspell_dic(&mut BAD_1.as_bytes()).is_err());
assert!(is_valid_hunspell_dic(&mut BAD_2.as_bytes()).is_err());
assert!(is_valid_hunspell_dic(&mut BAD_3.as_bytes()).is_err());
}
macro_rules! parametrized_vulgar_fraction_or_emoji {
($($name:ident: $value:expr,)*) => {
$(
#[test]
fn $name() {
let (input, expected) = $value;
assert_eq!(expected, consists_of_vulgar_fractions_or_emojis(input));
}
)*
}
}
parametrized_vulgar_fraction_or_emoji! {
empty: ("", false),
emojis: ("ππ€π¦", true),
contains_emojis: ("π¦acean", false),
contains_only_unicode: ("β
β
β
βββ", true),
contains_emojis_and_unicodes: ("ππ€β
π¦β
ΒΎ", true),
no_emojis: ("no emoji string", false),
is_number: ("123", true),
is_latin_letter: ("a", false),
vulgar_fraction_one_quarter_and_emojis: ("ΒΌπ€π¦", true),
emojis_and_vulgar_fraction_one_half: ("π€π¦Β½", true),
emojis_and_vulgar_fraction_three_quarters: ("π€π¦ΒΎ", true),
emojis_and_vulgar_fraction_one_seventh: ("π€π¦β
", true),
emojis_and_vulgar_fraction_one_ninth: ("π€π¦β
", true),
emojis_and_vulgar_fraction_one_tenth: ("π€π¦β
", true),
emojis_and_vulgar_fraction_one_third: ("π€π¦β
", true),
emojis_and_vulgar_fraction_two_thirds: ("π€π¦β
", true),
emojis_and_vulgar_fraction_one_fifth: ("π€π¦β
", true),
emojis_and_vulgar_fraction_two_fifth: ("π€π¦β
", true),
emojis_and_vulgar_fraction_three_fifths: ("π€π¦β
", true),
emojis_and_vulgar_fraction_four_fifths: ( "πβ
", true),
emojis_and_vulgar_fraction_one_sixth: ("πβ
", true),
emojis_and_vulgar_fraction_five_sixths: ("πβ
", true),
emojis_and_vulgar_fraction_one_eighth: ("π¦πβ
", true),
emojis_and_vulgar_fraction_three_eighths: ("π¦πβ
", true),
emojis_and_vulgar_fraction_five_eights: ("π¦πβ
", true),
emojis_and_vulgar_fraction_five_eighths: ("π¦πβ
", true),
emojis_and_vulgar_fraction_seven_eighths: ("π¦πβ
", true),
emojis_and_vulgar_fraction_zero_thirds: ("π¦πβ", true),
}
}