use anyhow::{Context, Result};
use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::config::StandardConfig;
use crate::errors;
use crate::file_index::FileIndex;
use crate::graph::{BuildGraph, Product};
use crate::processors::{Processor, discover_checker_products};
use crate::word_manager::WordManager;
fn default_zspell_language() -> String {
"en_US".into()
}
fn default_zspell_words_file() -> String {
".zspell-words".into()
}
fn default_zspell_dict_dir() -> String {
"/usr/share/hunspell".into()
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ZspellConfig {
#[serde(default = "default_zspell_language")]
pub language: String,
#[serde(default = "default_zspell_words_file")]
pub words_file: String,
#[serde(default)]
pub auto_add_words: bool,
#[serde(default = "default_zspell_dict_dir")]
pub dict_dir: String,
#[serde(flatten)]
pub standard: StandardConfig,
}
impl Default for ZspellConfig {
fn default() -> Self {
Self {
language: "en_US".into(),
words_file: ".zspell-words".into(),
auto_add_words: false,
dict_dir: default_zspell_dict_dir(),
standard: StandardConfig::default(),
}
}
}
pub struct ZspellProcessor {
config: ZspellConfig,
cached_dict: OnceLock<Result<zspell::Dictionary, String>>,
words: WordManager,
}
impl ZspellProcessor {
pub fn new(config: ZspellConfig) -> Result<Self> {
let words_path = Path::new(&config.words_file);
let custom_words = if words_path.exists() {
Self::load_custom_words(words_path)?
} else {
HashSet::new()
};
let words = WordManager::new(custom_words, config.words_file.clone(), None);
Ok(Self {
config,
cached_dict: OnceLock::new(),
words,
})
}
fn load_custom_words(words_path: &Path) -> Result<HashSet<String>> {
let content = crate::errors::ctx(
fs::read_to_string(words_path),
&format!("Failed to read words file: {}", words_path.display()),
)?;
let mut words = HashSet::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
words.insert(trimmed.to_lowercase());
}
}
Ok(words)
}
fn build_dictionary(&self) -> Result<zspell::Dictionary> {
let lang = &self.config.language;
let dict_dir = Path::new(&self.config.dict_dir);
let aff_path = dict_dir.join(format!("{lang}.aff"));
let dic_path = dict_dir.join(format!("{lang}.dic"));
let aff_content = fs::read_to_string(&aff_path).with_context(|| {
format!(
"Failed to read affix file: {}. Is the hunspell dictionary for '{}' installed?",
aff_path.display(),
lang
)
})?;
let dic_content = fs::read_to_string(&dic_path)
.with_context(|| format!("Failed to read dictionary file: {}. Is the hunspell dictionary for '{}' installed?", dic_path.display(), lang))?;
let dict = zspell::builder()
.config_str(&aff_content)
.dict_str(&dic_content)
.build()
.context("Failed to build zspell dictionary")?;
Ok(dict)
}
fn get_dictionary(&self) -> Result<&zspell::Dictionary> {
let result = self
.cached_dict
.get_or_init(|| self.build_dictionary().map_err(|e| e.to_string()));
match result {
Ok(dict) => Ok(dict),
Err(msg) => anyhow::bail!("{msg}"),
}
}
fn extract_words(text: &str) -> Vec<String> {
let mut result = Vec::new();
let mut in_fenced_block = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_fenced_block = !in_fenced_block;
continue;
}
if in_fenced_block {
continue;
}
if line.starts_with(" ") || line.starts_with('\t') {
continue;
}
let cleaned = Self::strip_markdown(line);
for word in cleaned.split(|c: char| !c.is_alphabetic()) {
if word.len() >= 2 {
result.push(word.to_string());
}
}
}
result
}
fn strip_markdown(line: &str) -> String {
static MARKDOWN_RE: OnceLock<Regex> = OnceLock::new();
let re = MARKDOWN_RE.get_or_init(|| {
Regex::new(concat!(
r"`[^`]*`", r"|\[([^\]]*)\]\([^)]*\)", r#"|https?://[^\s)>""]+"#, r"|<[^>]*>", ))
.expect(errors::INVALID_REGEX)
});
re.replace_all(line, |caps: ®ex::Captures| {
caps.get(1)
.map_or_else(|| " ".to_string(), |m| m.as_str().to_string())
})
.into_owned()
}
fn check_file(&self, doc_file: &Path) -> Result<()> {
let dict = self.get_dictionary()?;
let content = fs::read_to_string(doc_file)
.with_context(|| format!("Failed to read document file: {}", doc_file.display()))?;
let words = Self::extract_words(&content);
let mut misspelled: Vec<String> = Vec::new();
let mut seen = HashSet::new();
for word in &words {
let lower = word.to_lowercase();
if seen.contains(&lower) {
continue;
}
seen.insert(lower.clone());
if self.words.is_known(&lower) {
continue;
}
if !dict.check_word(&lower) {
misspelled.push(word.clone());
}
}
misspelled.sort();
self.words
.handle_misspelled(&misspelled, doc_file, self.config.auto_add_words)
}
}
impl Processor for ZspellProcessor {
fn scan_config(&self) -> &crate::config::StandardConfig {
&self.config.standard
}
fn config_json(&self) -> Option<String> {
crate::processors::ProcessorBase::config_json(&self.config)
}
fn discover(
&self,
graph: &mut BuildGraph,
file_index: &FileIndex,
instance_name: &str,
) -> Result<()> {
let mut dep_auto = self.config.standard.dep_auto.clone();
dep_auto.push(self.config.words_file.clone());
let dict_dir = Path::new(&self.config.dict_dir);
dep_auto.push(
dict_dir
.join(format!("{}.aff", self.config.language))
.to_string_lossy()
.into_owned(),
);
dep_auto.push(
dict_dir
.join(format!("{}.dic", self.config.language))
.to_string_lossy()
.into_owned(),
);
discover_checker_products(
graph,
&self.config.standard,
file_index,
&self.config.standard.dep_inputs,
&dep_auto,
&self.config,
&crate::config::checksum_fields_of(instance_name),
instance_name,
)
}
fn execute(&self, _ctx: &crate::build_context::BuildContext, product: &Product) -> Result<()> {
self.words.execute_with_flush(
product,
self.config.auto_add_words,
|file| self.check_file(file),
"zspell",
)
}
fn execute_batch(
&self,
_ctx: &crate::build_context::BuildContext,
products: &[&Product],
) -> Vec<Result<()>> {
self.words.execute_batch_with_flush(
products,
self.config.auto_add_words,
|file| self.check_file(file),
"zspell",
)
}
}
fn plugin_create(toml: &toml::Value) -> anyhow::Result<Box<dyn crate::processors::Processor>> {
crate::registries::deserialize_and_try_create(toml, |cfg| {
Ok(Box::new(ZspellProcessor::new(cfg)?))
})
}
inventory::submit! {
crate::registries::ProcessorPlugin {
version: 1,
name: "zspell",
processor_type: crate::processors::ProcessorType::Checker,
create: plugin_create,
fields: &[
crate::config::FieldSpec { name: "language", ty: crate::config::FieldType::String,
affects_output: true, required: false,
doc: "Language/locale for spell checking" },
crate::config::FieldSpec { name: "words_file", ty: crate::config::FieldType::String,
affects_output: false, required: false,
doc: "Path to a personal dictionary file" },
crate::config::FieldSpec { name: "auto_add_words", ty: crate::config::FieldType::Bool,
affects_output: true, required: false,
doc: "When true, automatically add misspelled words to words_file instead of failing" },
crate::config::FieldSpec { name: "dict_dir", ty: crate::config::FieldType::String,
affects_output: true, required: false,
doc: "Directory containing hunspell .aff/.dic files" },
],
omit_standard_fields: &["command", "formats", "args", "output_dir"],
scan_defaults: Some(crate::config::ScanDefaultsData { src_dirs: &[], src_extensions: &[".md"], src_exclude_dirs: &[] }),
defaults: None,
defconfig_json: crate::registries::default_config_json::<ZspellConfig>,
keywords: &["spellcheck", "spelling", "markdown", "md", "english"],
description: "Check documentation files for spelling errors",
is_native: true,
can_fix: false,
supports_batch: true,
max_jobs_cap: None,
}
}