use glob::Pattern;
use log::warn;
use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
use std::path::Path;
fn compile_pattern(pattern: &str) -> Result<Regex, regex::Error> {
RegexBuilder::new(pattern).multi_line(true).build()
}
fn regex_vec<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Vec::<String>::deserialize(deserializer)?;
v.into_iter()
.map(|p| {
compile_pattern(&p)
.map_err(|e| serde::de::Error::custom(format!("invalid regex pattern '{p}': {e}")))
})
.collect()
}
fn regex_opt_vec<'de, D>(deserializer: D) -> Result<Option<Vec<Regex>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Option::<Vec<String>>::deserialize(deserializer)?;
v.map(|v| {
v.into_iter()
.map(|p| {
compile_pattern(&p).map_err(|e| {
serde::de::Error::custom(format!("invalid regex pattern '{p}': {e}"))
})
})
.collect()
})
.transpose()
}
fn regexes_as_strings<S>(patterns: &[Regex], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_seq(patterns.iter().map(Regex::as_str))
}
fn opt_regexes_as_strings<S>(
patterns: &Option<Vec<Regex>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match patterns {
Some(patterns) => regexes_as_strings(patterns, serializer),
None => serializer.serialize_none(),
}
}
fn glob_vec<'de, D>(deserializer: D) -> Result<Vec<Pattern>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Vec::<String>::deserialize(deserializer)?;
v.into_iter()
.map(|p| {
Pattern::new(&p)
.map_err(|e| serde::de::Error::custom(format!("invalid glob pattern '{p}': {e}")))
})
.collect()
}
fn globs_as_strings<S>(patterns: &[Pattern], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_seq(patterns.iter().map(Pattern::as_str))
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OverrideBlock {
#[serde(
default,
deserialize_with = "glob_vec",
serialize_with = "globs_as_strings"
)]
pub paths: Vec<Pattern>,
#[serde(
default,
deserialize_with = "lowercase_opt_vec",
skip_serializing_if = "Option::is_none"
)]
pub dictionaries: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "lowercase_opt_vec",
skip_serializing_if = "Option::is_none"
)]
pub words: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "lowercase_opt_vec",
skip_serializing_if = "Option::is_none"
)]
pub flag_words: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "regex_opt_vec",
serialize_with = "opt_regexes_as_strings",
skip_serializing_if = "Option::is_none"
)]
pub ignore_patterns: Option<Vec<Regex>>,
#[serde(
default,
deserialize_with = "lowercase_opt_vec",
skip_serializing_if = "Option::is_none"
)]
pub extra_dictionaries: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "lowercase_opt_vec",
skip_serializing_if = "Option::is_none"
)]
pub extra_words: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "lowercase_opt_vec",
skip_serializing_if = "Option::is_none"
)]
pub extra_flag_words: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "regex_opt_vec",
serialize_with = "opt_regexes_as_strings",
skip_serializing_if = "Option::is_none"
)]
pub extra_ignore_patterns: Option<Vec<Regex>>,
}
fn lowercase_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Vec::<String>::deserialize(deserializer)?;
Ok(v.into_iter().map(|s| s.to_ascii_lowercase()).collect())
}
fn lowercase_opt_vec<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Option::<Vec<String>>::deserialize(deserializer)?;
Ok(v.map(|v| v.into_iter().map(|s| s.to_ascii_lowercase()).collect()))
}
fn valid_overrides<'de, D>(deserializer: D) -> Result<Vec<OverrideBlock>, D::Error>
where
D: serde::Deserializer<'de>,
{
let blocks = Vec::<OverrideBlock>::deserialize(deserializer)?;
Ok(blocks
.into_iter()
.filter(|o| {
if !o.is_valid() {
warn!("Skipping invalid override block (no paths)");
return false;
}
if !o.has_effect() {
warn!("Skipping no-op override block (no settings specified)");
return false;
}
true
})
.collect())
}
impl OverrideBlock {
pub fn is_valid(&self) -> bool {
!self.paths.is_empty()
}
pub fn matches_path(&self, relative_path: &Path) -> bool {
let path_str = normalize_separators(&relative_path.to_string_lossy());
self.paths.iter().any(|pattern| pattern.matches(&path_str))
}
pub fn has_effect(&self) -> bool {
self.dictionaries.is_some()
|| self.words.is_some()
|| self.flag_words.is_some()
|| self.ignore_patterns.is_some()
|| self.extra_dictionaries.is_some()
|| self.extra_words.is_some()
|| self.extra_flag_words.is_some()
|| self.extra_ignore_patterns.is_some()
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConfigSettings {
#[serde(
default,
deserialize_with = "lowercase_vec",
skip_serializing_if = "Vec::is_empty"
)]
pub dictionaries: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub words: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flag_words: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore_paths: Vec<String>,
#[serde(
default,
deserialize_with = "regex_vec",
serialize_with = "regexes_as_strings",
skip_serializing_if = "Vec::is_empty"
)]
pub ignore_patterns: Vec<Regex>,
#[serde(
default = "default_use_global",
skip_serializing_if = "is_default_use_global"
)]
pub use_global: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_word_length: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include_tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_tags: Vec<String>,
#[serde(
default,
deserialize_with = "valid_overrides",
skip_serializing_if = "Vec::is_empty"
)]
pub overrides: Vec<OverrideBlock>,
}
fn default_use_global() -> bool {
true
}
fn is_default_use_global(value: &bool) -> bool {
*value == default_use_global()
}
fn default_min_word_length() -> usize {
3
}
impl Default for ConfigSettings {
fn default() -> Self {
Self {
dictionaries: vec![],
words: Vec::new(),
flag_words: Vec::new(),
include_paths: Vec::new(),
ignore_paths: Vec::new(),
ignore_patterns: Vec::new(),
use_global: true,
min_word_length: None,
include_tags: Vec::new(),
exclude_tags: Vec::new(),
overrides: Vec::new(),
}
}
}
impl ConfigSettings {
pub fn merge(&mut self, other: ConfigSettings) {
self.dictionaries.extend(other.dictionaries);
self.words.extend(other.words);
self.flag_words.extend(other.flag_words);
self.include_paths.extend(other.include_paths);
self.ignore_paths.extend(other.ignore_paths);
self.ignore_patterns.extend(other.ignore_patterns);
self.include_tags.extend(other.include_tags);
self.exclude_tags.extend(other.exclude_tags);
self.overrides.extend(other.overrides);
if other.min_word_length.is_some() {
self.min_word_length = other.min_word_length;
}
self.sort_and_dedup();
}
pub fn sort_and_dedup(&mut self) {
sort_and_dedup(&mut self.dictionaries);
sort_and_dedup_unicase(&mut self.words);
sort_and_dedup_unicase(&mut self.flag_words);
sort_and_dedup(&mut self.include_paths);
sort_and_dedup(&mut self.ignore_paths);
sort_and_dedup(&mut self.include_tags);
sort_and_dedup(&mut self.exclude_tags);
}
pub fn apply_override(&mut self, over: &OverrideBlock) {
if let Some(ref v) = over.dictionaries {
self.dictionaries = v.clone();
}
if let Some(ref v) = over.words {
self.words = v.clone();
}
if let Some(ref v) = over.flag_words {
self.flag_words = v.clone();
}
if let Some(ref v) = over.ignore_patterns {
self.ignore_patterns = v.clone();
}
if let Some(ref v) = over.extra_dictionaries {
self.dictionaries.extend(v.clone());
}
if let Some(ref v) = over.extra_words {
self.words.extend(v.clone());
}
if let Some(ref v) = over.extra_flag_words {
self.flag_words.extend(v.clone());
}
if let Some(ref v) = over.extra_ignore_patterns {
self.ignore_patterns.extend(v.clone());
}
}
pub fn resolve_for_path(&self, path: &Path) -> ConfigSettings {
let mut resolved = self.clone();
resolved.overrides = vec![];
for ovr in &self.overrides {
if ovr.matches_path(path) {
resolved.apply_override(ovr);
}
}
resolved
}
}
fn tag_matches_pattern(tag: &str, pattern: &str) -> bool {
tag == pattern || tag.starts_with(pattern) && tag.as_bytes().get(pattern.len()) == Some(&b'.')
}
impl ConfigSettings {
pub fn should_check_tag(&self, tag: &str) -> bool {
if self
.exclude_tags
.iter()
.any(|p| tag_matches_pattern(tag, p))
{
return false;
}
if !self.include_tags.is_empty() {
return self
.include_tags
.iter()
.any(|p| tag_matches_pattern(tag, p));
}
true
}
pub fn insert_word(&mut self, word: &str) -> bool {
if self.words.iter().any(|w| unicase::eq(w.as_str(), word)) {
return false;
}
self.words.push(word.to_string());
self.words.sort();
true
}
pub fn insert_ignore(&mut self, file: &str) -> bool {
let file = normalize_separators(file);
if self.ignore_paths.contains(&file) {
return false;
}
self.ignore_paths.push(file);
self.ignore_paths.sort();
self.ignore_paths.dedup();
true
}
pub fn insert_include(&mut self, file: &str) -> bool {
let file = normalize_separators(file);
if self.include_paths.contains(&file) {
return false;
}
self.include_paths.push(file);
self.include_paths.sort();
self.include_paths.dedup();
true
}
pub fn dictionary_ids(&self) -> Vec<String> {
if self.dictionaries.is_empty() {
vec!["en_us".to_string()]
} else {
self.dictionaries.clone()
}
}
pub fn should_include_path(&self, path: &Path) -> bool {
if self.include_paths.is_empty() {
return true;
}
let path_str = normalize_separators(&path.to_string_lossy());
match_pattern(&self.include_paths, &path_str)
}
pub fn should_ignore_path(&self, path: &Path) -> bool {
let path_str = normalize_separators(&path.to_string_lossy());
match_pattern(&self.ignore_paths, &path_str)
}
pub fn is_allowed_word(&self, word: &str) -> bool {
self.words.iter().any(|w| unicase::eq(w.as_str(), word))
}
pub fn should_flag_word(&self, word: &str) -> bool {
self.flag_words
.iter()
.any(|w| unicase::eq(w.as_str(), word))
}
pub fn min_word_length(&self) -> usize {
self.min_word_length.unwrap_or_else(default_min_word_length)
}
}
fn match_pattern(patterns: &[String], path_str: &str) -> bool {
patterns.iter().any(|pattern| {
Pattern::new(pattern)
.map(|p| p.matches(path_str))
.unwrap_or(false)
})
}
fn normalize_separators(path_str: &str) -> String {
if cfg!(windows) {
path_str.replace('\\', "/")
} else {
path_str.to_string()
}
}
fn sort_and_dedup(vec: &mut Vec<String>) {
vec.sort();
vec.dedup();
}
fn sort_and_dedup_unicase(vec: &mut Vec<String>) {
vec.sort_by(|a, b| {
unicase::UniCase::new(a.as_str())
.cmp(&unicase::UniCase::new(b.as_str()))
.then_with(|| a.cmp(b))
});
vec.dedup_by(|a, b| unicase::eq(a.as_str(), b.as_str()));
}
#[cfg(test)]
mod tests {
use super::*;
fn pat(pattern: &str) -> Regex {
compile_pattern(pattern).unwrap()
}
fn glob(pattern: &str) -> Pattern {
Pattern::new(pattern).unwrap()
}
fn pattern_strings(patterns: &[Regex]) -> Vec<&str> {
patterns.iter().map(Regex::as_str).collect()
}
#[test]
fn test_ignore_pattern_is_multiline() {
let pattern = pat(r"^vim\..*");
let text = "let x = 1\nvim.opt.showmode = false\nlet y = 2";
let m = pattern.find(text).unwrap();
assert_eq!(m.as_str(), "vim.opt.showmode = false");
}
#[test]
fn test_invalid_ignore_pattern_fails_deserialization() {
let toml_str = r#"
ignore_patterns = ["valid.*", "[invalid"]
"#;
let err = toml::from_str::<ConfigSettings>(toml_str).unwrap_err();
assert!(err.to_string().contains("invalid regex pattern '[invalid'"));
}
#[test]
fn test_invalid_override_glob_fails_deserialization() {
let toml_str = r#"
[[overrides]]
paths = ["docs/[invalid"]
extra_words = ["test"]
"#;
let err = toml::from_str::<ConfigSettings>(toml_str).unwrap_err();
assert!(
err.to_string()
.contains("invalid glob pattern 'docs/[invalid'")
);
}
#[test]
fn test_ignore_pattern_serializes_as_string() {
let settings = ConfigSettings {
ignore_patterns: vec![pat(r"^```.*$")],
..Default::default()
};
let toml_str = toml::to_string(&settings).unwrap();
assert!(toml_str.contains(r#"ignore_patterns = ["^```.*$"]"#));
}
#[test]
fn test_default() {
let config = ConfigSettings::default();
assert_eq!(config.dictionaries, Vec::<String>::new());
assert_eq!(config.words, Vec::<String>::new());
assert_eq!(config.flag_words, Vec::<String>::new());
assert_eq!(config.include_paths, Vec::<String>::new());
assert_eq!(config.ignore_paths, Vec::<String>::new());
assert!(config.ignore_patterns.is_empty());
assert!(config.use_global);
assert_eq!(config.min_word_length, None);
assert_eq!(config.min_word_length(), 3);
assert!(config.overrides.is_empty());
}
#[test]
fn test_deserialization() {
let toml_str = r#"
dictionaries = ["EN_US", "en_GB"]
words = ["CodeBook", "Rust", "Апгрейдить"]
flag_words = ["TODO", "FIXME", "Ошибка"]
include_paths = ["src/**/*.rs", "lib/"]
ignore_paths = ["**/*.md", "target/"]
ignore_patterns = ["^```.*$", "^//.*$"]
use_global = false
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.dictionaries, vec!["en_us", "en_gb"]);
assert_eq!(config.words, vec!["CodeBook", "Rust", "Апгрейдить"]);
assert_eq!(config.flag_words, vec!["TODO", "FIXME", "Ошибка"]);
assert_eq!(config.include_paths, vec!["src/**/*.rs", "lib/"]);
assert_eq!(config.ignore_paths, vec!["**/*.md", "target/"]);
assert_eq!(
pattern_strings(&config.ignore_patterns),
["^```.*$", "^//.*$"]
);
assert!(!config.use_global);
}
#[test]
fn test_min_word_length_deserialization() {
let toml_str = r#"
min_word_length = 2
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.min_word_length, Some(2));
assert_eq!(config.min_word_length(), 2);
let toml_str = r#"
dictionaries = ["en_us"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.min_word_length, None);
assert_eq!(config.min_word_length(), 3);
}
#[test]
fn test_serialization() {
let config = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
words: vec!["rust".to_string()],
..Default::default()
};
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains("dictionaries = [\"en_us\"]"));
assert!(serialized.contains("words = [\"rust\"]"));
assert!(!serialized.contains("use_global = true"));
assert!(!serialized.contains("min_word_length = 3"));
}
#[test]
fn test_serialization_roundtrip_include_paths() {
let config = ConfigSettings {
include_paths: vec!["src/**/*.rs".to_string(), "lib/".to_string()],
..Default::default()
};
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains("include_paths"));
let deserialized: ConfigSettings = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.include_paths, vec!["src/**/*.rs", "lib/"]);
}
#[test]
fn test_merge() {
let mut base = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
words: vec!["codebook".to_string()],
flag_words: vec!["todo".to_string()],
include_paths: vec!["src/".to_string()],
ignore_paths: vec!["**/*.md".to_string()],
ignore_patterns: vec![pat("^```.*$")],
use_global: true,
min_word_length: Some(3),
..Default::default()
};
let other = ConfigSettings {
dictionaries: vec!["en_gb".to_string(), "en_us".to_string()],
words: vec!["rust".to_string()],
flag_words: vec!["fixme".to_string()],
include_paths: vec!["lib/".to_string(), "src/".to_string()],
ignore_paths: vec!["target/".to_string()],
ignore_patterns: vec![pat("^//.*$")],
use_global: false,
min_word_length: Some(2),
..Default::default()
};
base.merge(other);
assert_eq!(base.dictionaries, vec!["en_gb", "en_us"]);
assert_eq!(base.words, vec!["codebook", "rust"]);
assert_eq!(base.flag_words, vec!["fixme", "todo"]);
assert_eq!(base.include_paths, vec!["lib/", "src/"]);
assert_eq!(base.ignore_paths, vec!["**/*.md", "target/"]);
assert_eq!(
pattern_strings(&base.ignore_patterns),
["^```.*$", "^//.*$"]
);
assert!(base.use_global);
assert_eq!(base.min_word_length, Some(2));
}
#[test]
fn test_merge_min_word_length_default() {
let mut base = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
min_word_length: Some(5),
..Default::default()
};
let other = ConfigSettings {
dictionaries: vec!["en_gb".to_string()],
min_word_length: None, ..Default::default()
};
base.merge(other);
assert_eq!(base.min_word_length, Some(5));
}
#[test]
fn test_merge_min_word_length_explicit_default_wins() {
let mut base = ConfigSettings {
min_word_length: Some(5),
..Default::default()
};
let other = ConfigSettings {
min_word_length: Some(3),
..Default::default()
};
base.merge(other);
assert_eq!(base.min_word_length, Some(3));
}
#[test]
fn test_sort_and_dedup() {
let mut config = ConfigSettings {
dictionaries: vec![
"en_gb".to_string(),
"en_us".to_string(),
"en_gb".to_string(),
],
words: vec![
"rust".to_string(),
"codebook".to_string(),
"rust".to_string(),
],
flag_words: vec!["fixme".to_string(), "todo".to_string(), "fixme".to_string()],
include_paths: vec![],
ignore_paths: vec![
"target/".to_string(),
"**/*.md".to_string(),
"target/".to_string(),
],
ignore_patterns: vec![pat("^//.*$"), pat("^```.*$"), pat("^//.*$")],
use_global: true,
min_word_length: Some(3),
..Default::default()
};
config.sort_and_dedup();
assert_eq!(config.dictionaries, vec!["en_gb", "en_us"]);
assert_eq!(config.words, vec!["codebook", "rust"]);
assert_eq!(config.flag_words, vec!["fixme", "todo"]);
assert_eq!(config.ignore_paths, vec!["**/*.md", "target/"]);
assert_eq!(
pattern_strings(&config.ignore_patterns),
["^//.*$", "^```.*$", "^//.*$"]
);
}
#[test]
fn test_use_global_default() {
let toml_str = r#"
dictionaries = ["EN_US"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.use_global);
}
#[test]
fn test_empty_deserialization() {
let toml_str = "";
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(
toml::to_string(&config).unwrap(),
toml::to_string(&ConfigSettings::default()).unwrap()
);
}
#[test]
fn test_unicode_words_ignore_case() {
let mut config = ConfigSettings::default();
assert!(config.insert_word("Апгрейдить"));
assert!(!config.insert_word("апгрейдить"));
assert_eq!(config.words, vec!["Апгрейдить"]);
assert!(config.is_allowed_word("АПГРЕЙДИТЬ"));
assert!(config.is_allowed_word("апгрейдить"));
config.flag_words.push("ошибка".to_string());
assert!(config.should_flag_word("Ошибка"));
}
#[test]
fn test_include_tags_deserialization() {
let toml_str = r#"
include_tags = ["comment", "string"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.include_tags, vec!["comment", "string"]);
assert!(config.exclude_tags.is_empty());
}
#[test]
fn test_exclude_tags_deserialization() {
let toml_str = r#"
exclude_tags = ["identifier.variable", "identifier.parameter"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.include_tags.is_empty());
assert_eq!(
config.exclude_tags,
vec!["identifier.variable", "identifier.parameter"]
);
}
#[test]
fn test_tags_default_empty() {
let config = ConfigSettings::default();
assert!(config.include_tags.is_empty());
assert!(config.exclude_tags.is_empty());
}
#[test]
fn test_tags_serialization_omitted_when_empty() {
let config = ConfigSettings::default();
let serialized = toml::to_string(&config).unwrap();
assert!(!serialized.contains("include_tags"));
assert!(!serialized.contains("exclude_tags"));
}
#[test]
fn test_tags_serialization_present_when_set() {
let config = ConfigSettings {
include_tags: vec!["comment".to_string()],
..Default::default()
};
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains("include_tags"));
}
#[test]
fn test_tags_merge() {
let mut base = ConfigSettings {
include_tags: vec!["comment".to_string()],
exclude_tags: vec!["identifier.type".to_string()],
..Default::default()
};
let other = ConfigSettings {
include_tags: vec!["string".to_string(), "comment".to_string()],
exclude_tags: vec!["identifier.module".to_string()],
..Default::default()
};
base.merge(other);
assert_eq!(base.include_tags, vec!["comment", "string"]);
assert_eq!(
base.exclude_tags,
vec!["identifier.module", "identifier.type"]
);
}
#[test]
fn test_should_check_tag_no_filters() {
let config = ConfigSettings::default();
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("string"));
assert!(config.should_check_tag("identifier.function"));
}
#[test]
fn test_should_check_tag_include_only() {
let config = ConfigSettings {
include_tags: vec!["comment".to_string(), "string".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("comment.line"));
assert!(config.should_check_tag("comment.block"));
assert!(config.should_check_tag("string"));
assert!(config.should_check_tag("string.special"));
assert!(!config.should_check_tag("identifier"));
assert!(!config.should_check_tag("identifier.function"));
}
#[test]
fn test_should_check_tag_exclude_only() {
let config = ConfigSettings {
exclude_tags: vec!["identifier.variable".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("identifier.function"));
assert!(!config.should_check_tag("identifier.variable"));
}
#[test]
fn test_should_check_tag_both_include_and_exclude() {
let config = ConfigSettings {
include_tags: vec!["comment".to_string(), "string".to_string()],
exclude_tags: vec!["string.heredoc".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("comment.line"));
assert!(config.should_check_tag("string"));
assert!(config.should_check_tag("string.special"));
assert!(!config.should_check_tag("string.heredoc"));
assert!(!config.should_check_tag("identifier.function"));
}
#[test]
fn test_should_check_tag_exclude_prefix() {
let config = ConfigSettings {
exclude_tags: vec!["identifier".to_string()],
..Default::default()
};
assert!(config.should_check_tag("comment"));
assert!(config.should_check_tag("string"));
assert!(!config.should_check_tag("identifier"));
assert!(!config.should_check_tag("identifier.function"));
assert!(!config.should_check_tag("identifier.type"));
}
#[test]
fn test_partial_deserialization() {
let toml_str = r#"
dictionaries = ["EN_US"]
words = ["CodeBook"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.dictionaries, vec!["en_us"]);
assert_eq!(config.words, vec!["CodeBook"]);
assert_eq!(config.flag_words, Vec::<String>::new());
assert_eq!(config.ignore_paths, Vec::<String>::new());
assert!(config.ignore_patterns.is_empty());
assert!(config.use_global);
}
#[test]
fn test_override_block_deserialization() {
let toml_str = r#"
words = ["base"]
[[overrides]]
paths = ["**/*.md"]
extra_words = ["Markdown"]
dictionaries = ["EN_GB"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert_eq!(config.overrides.len(), 1);
let ovr = &config.overrides[0];
assert_eq!(ovr.paths, vec![glob("**/*.md")]);
assert_eq!(ovr.extra_words, Some(vec!["markdown".to_string()])); assert_eq!(ovr.dictionaries, Some(vec!["en_gb".to_string()])); assert_eq!(ovr.words, None);
assert!(ovr.ignore_patterns.is_none());
}
#[test]
fn test_override_block_empty_paths_skipped() {
let toml_str = r#"
[[overrides]]
paths = []
extra_words = ["test"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.overrides.is_empty());
}
#[test]
fn test_override_block_no_effect_skipped() {
let toml_str = r#"
[[overrides]]
paths = ["**/*.md"]
"#;
let config: ConfigSettings = toml::from_str(toml_str).unwrap();
assert!(config.overrides.is_empty());
}
#[test]
fn test_override_matches_path() {
let ovr = OverrideBlock {
paths: vec![glob("**/*.md"), glob("docs/**/*")],
extra_words: Some(vec!["test".to_string()]),
..OverrideBlock::default_for_test()
};
assert!(ovr.matches_path(Path::new("README.md")));
assert!(ovr.matches_path(Path::new("src/guide.md")));
assert!(ovr.matches_path(Path::new("docs/api/index.html")));
assert!(!ovr.matches_path(Path::new("src/main.rs")));
}
#[cfg(windows)]
#[test]
fn test_override_matches_backslash_path_on_windows() {
let ovr = OverrideBlock {
paths: vec![glob("docs/**/*")],
extra_words: Some(vec!["test".to_string()]),
..OverrideBlock::default_for_test()
};
assert!(ovr.matches_path(Path::new(r"docs\api\guide.md")));
}
#[cfg(windows)]
#[test]
fn test_ignore_and_include_match_backslash_paths_on_windows() {
let settings = ConfigSettings {
include_paths: vec!["src/**/*.rs".to_string()],
ignore_paths: vec!["target/**/*".to_string()],
..Default::default()
};
assert!(settings.should_include_path(Path::new(r"src\main.rs")));
assert!(settings.should_ignore_path(Path::new(r"target\debug\build")));
}
#[cfg(windows)]
#[test]
fn test_insert_ignore_normalizes_separators_on_windows() {
let mut settings = ConfigSettings::default();
assert!(settings.insert_ignore(r"src\notes.md"));
assert_eq!(settings.ignore_paths, vec!["src/notes.md"]);
assert!(settings.should_ignore_path(Path::new(r"src\notes.md")));
}
#[cfg(not(windows))]
#[test]
fn test_backslash_is_not_a_separator_on_unix() {
let mut settings = ConfigSettings::default();
assert!(settings.insert_ignore(r"weird\name.md"));
assert_eq!(settings.ignore_paths, vec![r"weird\name.md"]);
}
#[test]
fn test_apply_override_replace() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string(), "beta".to_string()],
..Default::default()
};
let over = OverrideBlock {
paths: vec![glob("**/*.md")],
words: Some(vec!["gamma".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&over);
assert_eq!(settings.words, vec!["gamma"]);
}
#[test]
fn test_apply_override_append() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string(), "beta".to_string()],
..Default::default()
};
let ovr = OverrideBlock {
paths: vec![glob("**/*.md")],
extra_words: Some(vec!["gamma".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&ovr);
assert_eq!(settings.words, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn test_apply_override_replace_then_append() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string(), "beta".to_string()],
..Default::default()
};
let ovr = OverrideBlock {
paths: vec![glob("**/*.md")],
words: Some(vec!["gamma".to_string()]),
extra_words: Some(vec!["delta".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&ovr);
assert_eq!(settings.words, vec!["gamma", "delta"]);
}
#[test]
fn test_apply_override_no_change() {
let mut settings = ConfigSettings {
words: vec!["alpha".to_string()],
dictionaries: vec!["en_us".to_string()],
..Default::default()
};
let over = OverrideBlock {
paths: vec![glob("**/*.md")],
extra_flag_words: Some(vec!["hack".to_string()]),
..OverrideBlock::default_for_test()
};
settings.apply_override(&over);
assert_eq!(settings.words, vec!["alpha"]);
assert_eq!(settings.dictionaries, vec!["en_us"]);
assert_eq!(settings.flag_words, vec!["hack"]);
}
#[test]
fn test_resolve_for_path_no_match() {
let settings = ConfigSettings {
words: vec!["base".to_string()],
overrides: vec![OverrideBlock {
paths: vec![glob("**/*.md")],
extra_words: Some(vec!["markdown".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("src/main.rs"));
assert_eq!(resolved.words, vec!["base"]);
assert!(resolved.overrides.is_empty());
}
#[test]
fn test_resolve_for_path_single_match() {
let settings = ConfigSettings {
words: vec!["base".to_string()],
overrides: vec![OverrideBlock {
paths: vec![glob("**/*.md")],
extra_words: Some(vec!["markdown".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("README.md"));
assert_eq!(resolved.words, vec!["base", "markdown"]);
assert!(resolved.overrides.is_empty());
}
#[test]
fn test_resolve_for_path_multiple_matches() {
let settings = ConfigSettings {
words: vec!["base".to_string()],
overrides: vec![
OverrideBlock {
paths: vec![glob("**/*.md")],
extra_words: Some(vec!["markdown".to_string()]),
..OverrideBlock::default_for_test()
},
OverrideBlock {
paths: vec![glob("docs/**/*")],
extra_words: Some(vec!["documentation".to_string()]),
..OverrideBlock::default_for_test()
},
],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("docs/guide.md"));
assert_eq!(resolved.words, vec!["base", "markdown", "documentation"]);
}
#[test]
fn test_resolve_for_path_replace_overrides_base() {
let settings = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
overrides: vec![OverrideBlock {
paths: vec![glob("docs/de/**/*")],
dictionaries: Some(vec!["de".to_string()]),
extra_words: Some(vec!["codebook".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let resolved = settings.resolve_for_path(Path::new("docs/de/guide.md"));
assert_eq!(resolved.dictionaries, vec!["de"]);
assert_eq!(resolved.words, vec!["codebook"]);
}
#[test]
fn test_merge_preserves_override_order() {
let mut global = ConfigSettings {
words: vec!["global".to_string()],
overrides: vec![OverrideBlock {
paths: vec![glob("**/*.md")],
extra_words: Some(vec!["from_global".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let project = ConfigSettings {
words: vec!["project".to_string()],
overrides: vec![OverrideBlock {
paths: vec![glob("**/*.md")],
extra_words: Some(vec!["from_project".to_string()]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
global.merge(project);
assert_eq!(global.overrides.len(), 2);
assert_eq!(
global.overrides[0].extra_words,
Some(vec!["from_global".to_string()])
);
assert_eq!(
global.overrides[1].extra_words,
Some(vec!["from_project".to_string()])
);
}
#[test]
fn test_serialization_with_overrides() {
let config = ConfigSettings {
words: vec!["base".to_string()],
ignore_patterns: vec![pat(r"^```.*$")],
overrides: vec![OverrideBlock {
paths: vec![glob("**/*.md")],
extra_words: Some(vec!["markdown".to_string()]),
ignore_patterns: Some(vec![pat(r"\bhttps?://\S+")]),
extra_ignore_patterns: Some(vec![pat(r"^\s*//.*")]),
..OverrideBlock::default_for_test()
}],
..Default::default()
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: ConfigSettings = toml::from_str(&serialized).unwrap();
assert_eq!(serialized, toml::to_string_pretty(&deserialized).unwrap());
let ovr = &deserialized.overrides[0];
assert_eq!(pattern_strings(&deserialized.ignore_patterns), [r"^```.*$"]);
assert_eq!(
pattern_strings(ovr.ignore_patterns.as_deref().unwrap()),
[r"\bhttps?://\S+"]
);
assert_eq!(
pattern_strings(ovr.extra_ignore_patterns.as_deref().unwrap()),
[r"^\s*//.*"]
);
}
#[test]
fn test_config_settings_query_methods() {
let settings = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
words: vec!["codebook".to_string()],
flag_words: vec!["todo".to_string()],
min_word_length: Some(4),
..Default::default()
};
assert_eq!(settings.dictionary_ids(), vec!["en_us"]);
assert!(settings.is_allowed_word("codebook"));
assert!(settings.is_allowed_word("CODEBOOK")); assert!(!settings.is_allowed_word("unknown"));
assert!(settings.should_flag_word("todo"));
assert!(settings.should_flag_word("TODO")); assert!(!settings.should_flag_word("done"));
assert_eq!(settings.min_word_length(), 4);
}
#[test]
fn test_dictionary_ids_default() {
let settings = ConfigSettings::default();
assert_eq!(settings.dictionary_ids(), vec!["en_us"]);
}
impl OverrideBlock {
fn default_for_test() -> Self {
Self {
paths: vec![],
dictionaries: None,
words: None,
flag_words: None,
ignore_patterns: None,
extra_dictionaries: None,
extra_words: None,
extra_flag_words: None,
extra_ignore_patterns: None,
}
}
}
}