use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use tracing::warn;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub engines: EngineConfig,
#[serde(default)]
pub rules: HashMap<String, RuleConfig>,
#[serde(default = "default_exclude")]
pub exclude: Vec<String>,
#[serde(default)]
pub auto_fix: Vec<AutoFixRule>,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub dictionaries: DictionaryConfig,
#[serde(default)]
pub languages: LanguageConfig,
#[serde(default)]
pub workspace: WorkspaceConfig,
#[serde(default)]
pub names: NameConfig,
#[serde(default)]
pub morphology: MorphologyConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct NameConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub aggressiveness: crate::names::Aggressiveness,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MorphologyConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub inflections: bool,
}
impl Default for MorphologyConfig {
fn default() -> Self {
Self {
enabled: true,
inflections: true,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct LanguageConfig {
#[serde(default)]
pub extensions: HashMap<String, Vec<String>>,
#[serde(default)]
pub latex: LaTeXConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct LaTeXConfig {
#[serde(default)]
pub skip_environments: Vec<String>,
#[serde(default)]
pub skip_commands: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct WorkspaceConfig {
#[serde(default)]
pub index_on_open: bool,
#[serde(default)]
pub db_path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PerformanceConfig {
#[serde(default)]
pub high_performance_mode: bool,
#[serde(default = "default_debounce_ms")]
pub debounce_ms: u64,
#[serde(default)]
pub max_file_size: usize,
#[serde(default = "default_result_cache_entries")]
pub result_cache_entries: usize,
#[serde(default = "default_max_range_bytes")]
pub max_range_bytes: usize,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
high_performance_mode: false,
debounce_ms: 500,
max_file_size: 0,
result_cache_entries: default_result_cache_entries(),
max_range_bytes: default_max_range_bytes(),
}
}
}
const fn default_debounce_ms() -> u64 {
500
}
const fn default_result_cache_entries() -> usize {
4096
}
const fn default_max_range_bytes() -> usize {
2048
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DictionaryConfig {
#[serde(default = "default_true")]
pub bundled: bool,
#[serde(default)]
pub disabled: Vec<String>,
#[serde(default)]
pub paths: Vec<String>,
}
impl Default for DictionaryConfig {
fn default() -> Self {
Self {
bundled: true,
disabled: Vec::new(),
paths: Vec::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AutoFixRule {
pub find: String,
pub replace: String,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(from = "EngineConfigWire")]
pub struct EngineConfig {
pub harper: HarperConfig,
pub languagetool: LanguageToolConfig,
pub vale: ValeConfig,
pub proselint: ProselintConfig,
pub hunspell: HunspellConfig,
pub external: Vec<ExternalProvider>,
pub wasm_plugins: Vec<WasmPlugin>,
pub spell_language: String,
}
#[derive(Deserialize)]
struct EngineConfigWire {
#[serde(
default = "default_harper_config",
deserialize_with = "deser_engine_or_bool"
)]
harper: HarperConfig,
#[serde(default, deserialize_with = "deser_engine_or_bool")]
languagetool: LanguageToolConfig,
#[serde(default, deserialize_with = "deser_engine_or_bool")]
vale: ValeConfig,
#[serde(default, deserialize_with = "deser_engine_or_bool")]
proselint: ProselintConfig,
#[serde(default, deserialize_with = "deser_engine_or_bool")]
hunspell: HunspellConfig,
#[serde(default)]
external: Vec<ExternalProvider>,
#[serde(default)]
wasm_plugins: Vec<WasmPlugin>,
#[serde(default = "default_spell_language")]
spell_language: String,
#[serde(default)]
languagetool_url: Option<String>,
#[serde(default)]
vale_config: Option<String>,
}
impl From<EngineConfigWire> for EngineConfig {
fn from(wire: EngineConfigWire) -> Self {
let EngineConfigWire {
harper,
mut languagetool,
mut vale,
proselint,
hunspell,
external,
wasm_plugins,
spell_language,
languagetool_url,
vale_config,
} = wire;
if let Some(url) = languagetool_url {
if languagetool.url == default_lt_url() {
warn_deprecated_engine_key("engines.languagetool_url", "engines.languagetool.url");
languagetool.url = url;
} else {
warn_ignored_engine_key("engines.languagetool_url", "engines.languagetool.url");
}
}
if let Some(path) = vale_config {
if vale.config.is_none() {
warn_deprecated_engine_key("engines.vale_config", "engines.vale.config");
vale.config = Some(path);
} else {
warn_ignored_engine_key("engines.vale_config", "engines.vale.config");
}
}
Self {
harper,
languagetool,
vale,
proselint,
hunspell,
external,
wasm_plugins,
spell_language,
}
}
}
fn warn_deprecated_engine_key(old: &str, new: &str) {
warn!(
"`{old}` is deprecated and will be removed in a future release; \
rename it to `{new}`. Honouring it for now."
);
}
fn warn_ignored_engine_key(old: &str, new: &str) {
warn!("`{old}` is ignored because `{new}` is also set; delete the deprecated key.");
}
fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de> + EngineToggle + Default,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum BoolOrStruct<T> {
Bool(bool),
Struct(T),
}
match BoolOrStruct::deserialize(deserializer)? {
BoolOrStruct::Bool(b) => {
let mut cfg = T::default();
cfg.set_enabled(b);
Ok(cfg)
}
BoolOrStruct::Struct(s) => Ok(s),
}
}
pub trait EngineToggle {
fn enabled(&self) -> bool;
fn set_enabled(&mut self, v: bool);
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HarperConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_dialect")]
pub dialect: String,
#[serde(default)]
pub linters: HashMap<String, bool>,
}
impl Default for HarperConfig {
fn default() -> Self {
Self {
enabled: true,
dialect: "American".to_string(),
linters: HashMap::new(),
}
}
}
fn default_harper_config() -> HarperConfig {
HarperConfig::default()
}
fn default_dialect() -> String {
"American".to_string()
}
impl EngineToggle for HarperConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LanguageToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_lt_url")]
pub url: String,
#[serde(default = "default_lt_level")]
pub level: String,
#[serde(default)]
pub mother_tongue: Option<String>,
#[serde(default)]
pub disabled_rules: Vec<String>,
#[serde(default)]
pub enabled_rules: Vec<String>,
#[serde(default)]
pub disabled_categories: Vec<String>,
#[serde(default)]
pub enabled_categories: Vec<String>,
#[serde(default = "default_lt_max_concurrent_requests")]
pub max_concurrent_requests: usize,
#[serde(default = "default_lt_max_request_bytes")]
pub max_request_bytes: usize,
}
impl Default for LanguageToolConfig {
fn default() -> Self {
Self {
enabled: false,
url: default_lt_url(),
level: "default".to_string(),
mother_tongue: None,
disabled_rules: Vec::new(),
enabled_rules: Vec::new(),
disabled_categories: Vec::new(),
enabled_categories: Vec::new(),
max_concurrent_requests: default_lt_max_concurrent_requests(),
max_request_bytes: default_lt_max_request_bytes(),
}
}
}
fn default_lt_level() -> String {
"default".to_string()
}
const fn default_lt_max_concurrent_requests() -> usize {
8
}
const fn default_lt_max_request_bytes() -> usize {
4096
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct HunspellConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub languages: Vec<String>,
#[serde(default)]
pub dictionary_paths: HashMap<String, String>,
#[serde(default)]
pub search_paths: Vec<String>,
#[serde(default)]
pub auto_install: bool,
}
impl EngineToggle for HunspellConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
impl EngineToggle for LanguageToolConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct ValeConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub config: Option<String>,
}
impl EngineToggle for ValeConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct ProselintConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub config: Option<String>,
}
impl EngineToggle for ProselintConfig {
fn enabled(&self) -> bool {
self.enabled
}
fn set_enabled(&mut self, v: bool) {
self.enabled = v;
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ExternalProvider {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub languages: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WasmPlugin {
pub name: String,
pub path: String,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub languages: Vec<String>,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
harper: HarperConfig::default(),
languagetool: LanguageToolConfig::default(),
vale: ValeConfig::default(),
proselint: ProselintConfig::default(),
hunspell: HunspellConfig::default(),
external: Vec::new(),
wasm_plugins: Vec::new(),
spell_language: default_spell_language(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RuleConfig {
pub severity: Option<String>, }
const fn default_true() -> bool {
true
}
fn default_lt_url() -> String {
"http://localhost:8010".to_string()
}
fn default_spell_language() -> String {
"en-US".to_string()
}
fn default_exclude() -> Vec<String> {
vec![
"node_modules/**".to_string(),
".git/**".to_string(),
"target/**".to_string(),
"dist/**".to_string(),
"build/**".to_string(),
".next/**".to_string(),
".nuxt/**".to_string(),
"vendor/**".to_string(),
"__pycache__/**".to_string(),
".venv/**".to_string(),
"venv/**".to_string(),
".tox/**".to_string(),
".mypy_cache/**".to_string(),
"*.min.js".to_string(),
"*.min.css".to_string(),
"*.bundle.js".to_string(),
"package-lock.json".to_string(),
"yarn.lock".to_string(),
"pnpm-lock.yaml".to_string(),
]
}
impl Config {
#[must_use]
pub fn load_or_warn(workspace_root: &Path) -> Self {
Self::load(workspace_root).unwrap_or_else(|e| {
warn!(
root = %workspace_root.display(),
"Ignoring unreadable workspace config, using defaults: {e}"
);
Self::default()
})
}
#[must_use]
pub fn excludes(&self, path: &Path, workspace_root: &Path) -> bool {
if self.exclude.is_empty() {
return false;
}
let relative = path.strip_prefix(workspace_root).unwrap_or(path);
let as_text = relative.to_string_lossy().replace('\\', "/");
let options = glob::MatchOptions {
require_literal_separator: false,
require_literal_leading_dot: false,
case_sensitive: true,
};
self.exclude
.iter()
.filter_map(|pattern| glob::Pattern::new(pattern).ok())
.any(|pattern| pattern.matches_with(&as_text, options))
}
fn resolve_paths(&mut self, workspace_root: &Path) {
let absolute = |value: &str| -> String {
let path = Path::new(value);
if path.is_absolute() {
value.to_string()
} else {
workspace_root.join(path).to_string_lossy().into_owned()
}
};
if let Some(vale_config) = &self.engines.vale.config {
self.engines.vale.config = Some(absolute(vale_config));
}
if let Some(proselint_config) = &self.engines.proselint.config {
self.engines.proselint.config = Some(absolute(proselint_config));
}
for plugin in &mut self.engines.wasm_plugins {
plugin.path = absolute(&plugin.path);
}
for provider in &mut self.engines.external {
if provider.command.contains(std::path::MAIN_SEPARATOR)
|| provider.command.contains('/')
{
provider.command = absolute(&provider.command);
}
}
}
pub fn load(workspace_root: &Path) -> Result<Self> {
let yaml_path = workspace_root.join(".languagecheck.yaml");
let yml_path = workspace_root.join(".languagecheck.yml");
let json_path = workspace_root.join(".languagecheck.json");
if yaml_path.exists() {
let content = std::fs::read_to_string(yaml_path)?;
warn_duplicate_rule_keys(&content);
let mut config: Self = serde_yaml::from_str(&content)?;
warn_unknown_keys(&serde_yaml::from_str(&content)?);
config.resolve_paths(workspace_root);
Ok(config)
} else if yml_path.exists() {
let content = std::fs::read_to_string(yml_path)?;
warn_duplicate_rule_keys(&content);
let mut config: Self = serde_yaml::from_str(&content)?;
warn_unknown_keys(&serde_yaml::from_str(&content)?);
config.resolve_paths(workspace_root);
Ok(config)
} else if json_path.exists() {
let content = std::fs::read_to_string(json_path)?;
let mut config: Self = serde_json::from_str(&content)?;
warn_unknown_keys(&serde_yaml::from_str(&content)?);
config.resolve_paths(workspace_root);
Ok(config)
} else {
Ok(Self::default())
}
}
#[must_use]
pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
let mut result = text.to_string();
let mut total = 0;
for rule in &self.auto_fix {
if let Some(ctx) = &rule.context
&& !result.contains(ctx.as_str())
{
continue;
}
let count = result.matches(&rule.find).count();
if count > 0 {
result = result.replace(&rule.find, &rule.replace);
total += count;
}
}
(result, total)
}
}
fn duplicate_rule_keys(content: &str) -> Vec<String> {
let mut in_rules = false;
let mut child_indent: Option<usize> = None;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut duplicates: Vec<String> = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let indent = line.len() - line.trim_start().len();
if !in_rules {
if indent == 0 && line.trim() == "rules:" {
in_rules = true;
}
continue;
}
if indent == 0 {
break;
}
let child = *child_indent.get_or_insert(indent);
if indent != child {
continue; }
if let Some(key) = line.trim().strip_suffix(':') {
let key = key.trim().to_string();
if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
duplicates.push(key);
}
}
}
duplicates
}
const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
"engines",
"rules",
"exclude",
"auto_fix",
"performance",
"dictionaries",
"languages",
"workspace",
"names",
"morphology",
];
const KNOWN_ENGINE_KEYS: &[&str] = &[
"harper",
"languagetool",
"vale",
"proselint",
"hunspell",
"external",
"wasm_plugins",
"spell_language",
"languagetool_url",
"vale_config",
];
fn unknown_keys(value: &serde_yaml::Value, known: &[&str]) -> Vec<String> {
let Some(map) = value.as_mapping() else {
return Vec::new();
};
map.keys()
.filter_map(serde_yaml::Value::as_str)
.filter(|k| !known.contains(k))
.map(ToString::to_string)
.collect()
}
fn warn_unknown_keys(value: &serde_yaml::Value) {
let unknown = unknown_keys(value, KNOWN_TOP_LEVEL_KEYS);
if !unknown.is_empty() {
warn!(keys = ?unknown, "Unknown keys in workspace config; they have no effect.");
}
if let Some(engines) = value.get("engines") {
let unknown = unknown_keys(engines, KNOWN_ENGINE_KEYS);
if !unknown.is_empty() {
warn!(keys = ?unknown, "Unknown keys under `engines:`; they have no effect.");
}
}
}
fn warn_duplicate_rule_keys(content: &str) {
let duplicates = duplicate_rule_keys(content);
if !duplicates.is_empty() {
warn!(
duplicates = ?duplicates,
"Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
effect. Remove the extra copies to keep the ignore list clean."
);
}
}
impl Default for Config {
fn default() -> Self {
Self {
engines: EngineConfig::default(),
rules: HashMap::new(),
exclude: default_exclude(),
auto_fix: Vec::new(),
performance: PerformanceConfig::default(),
dictionaries: DictionaryConfig::default(),
languages: LanguageConfig::default(),
workspace: WorkspaceConfig::default(),
names: NameConfig::default(),
morphology: MorphologyConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn every_engine_key_the_config_accepts_is_declared_known() {
let yaml = "\
engines:
harper: false
languagetool: false
vale: false
proselint: false
hunspell:
enabled: true
spell_language: en-US
";
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let engines = value.get("engines").expect("engines section");
assert_eq!(
unknown_keys(engines, KNOWN_ENGINE_KEYS),
Vec::<String>::new(),
"an engine key parses but is not declared known"
);
}
use super::*;
#[test]
fn duplicate_rule_keys_detects_repeats() {
let yaml = "rules:\n languagetool.ARROWS:\n severity: \"off\"\n \
languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
languagetool.ARROWS:\n severity: \"off\"\n \
languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
languagetool.THE_SUPERLATIVE:\n severity: \"off\"\n";
let dups = duplicate_rule_keys(yaml);
assert_eq!(
dups,
vec![
"languagetool.ARROWS".to_string(),
"languagetool.UPPERCASE_SENTENCE_START".to_string()
]
);
}
#[test]
fn duplicate_rule_keys_clean_list_is_empty() {
let yaml = "rules:\n a.B:\n severity: \"off\"\n c.D:\n severity: \"off\"\n";
assert!(duplicate_rule_keys(yaml).is_empty());
}
#[test]
fn duplicate_rule_keys_stops_at_next_section() {
let yaml = "rules:\n a.B:\n severity: \"off\"\nengines:\n harper: false\n";
assert!(duplicate_rule_keys(yaml).is_empty());
}
#[test]
fn morphology_is_on_by_default() {
let config = Config::default();
assert!(config.morphology.enabled);
assert!(config.morphology.inflections);
}
#[test]
fn morphology_can_be_switched_off_from_yaml() {
let yaml = "morphology:\n enabled: false\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(!config.morphology.enabled);
assert!(config.morphology.inflections);
}
#[test]
fn default_dictionaries_load_all_bundled_sets() {
let config = Config::default();
assert!(config.dictionaries.bundled);
assert!(config.dictionaries.disabled.is_empty());
assert!(config.dictionaries.paths.is_empty());
}
#[test]
fn dictionaries_disabled_from_yaml() {
let config: Config = serde_yaml::from_str(
r"
dictionaries:
disabled: [companies, mathematics]
",
)
.unwrap();
assert_eq!(config.dictionaries.disabled, ["companies", "mathematics"]);
assert!(config.dictionaries.bundled);
}
#[test]
fn default_config_has_harper_enabled_lt_disabled() {
let config = Config::default();
assert!(config.engines.harper.enabled);
assert!(!config.engines.languagetool.enabled);
}
#[test]
fn default_config_has_standard_excludes() {
let config = Config::default();
assert!(config.exclude.contains(&"node_modules/**".to_string()));
assert!(config.exclude.contains(&".git/**".to_string()));
assert!(config.exclude.contains(&"target/**".to_string()));
assert!(config.exclude.contains(&"dist/**".to_string()));
assert!(config.exclude.contains(&"vendor/**".to_string()));
}
#[test]
fn default_lt_url() {
let config = Config::default();
assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
}
#[test]
fn load_from_json_string() {
let json = r#"{
"engines": { "harper": true, "languagetool": false },
"rules": { "spelling.typo": { "severity": "warning" } }
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.engines.harper.enabled);
assert!(!config.engines.languagetool.enabled);
assert!(config.rules.contains_key("spelling.typo"));
assert_eq!(
config.rules["spelling.typo"].severity.as_deref(),
Some("warning")
);
}
#[test]
fn load_partial_json_uses_defaults() {
let json = r#"{}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.engines.harper.enabled);
assert!(!config.engines.languagetool.enabled);
assert!(config.rules.is_empty());
}
#[test]
fn load_from_json_file() {
let dir = std::env::temp_dir().join("lang_check_test_config_json");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join(".languagecheck.json");
std::fs::write(
&config_path,
r#"{"engines": {"harper": false, "languagetool": true}}"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(!config.engines.harper.enabled);
assert!(config.engines.languagetool.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_from_yaml_file() {
let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join(".languagecheck.yaml");
std::fs::write(
&config_path,
"engines:\n harper: false\n languagetool: true\n",
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(!config.engines.harper.enabled);
assert!(config.engines.languagetool.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn yaml_takes_precedence_over_json() {
let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".languagecheck.yaml"),
"engines:\n harper: false\n",
)
.unwrap();
std::fs::write(
dir.join(".languagecheck.json"),
r#"{"engines": {"harper": true}}"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(!config.engines.harper.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_returns_default() {
let dir = std::env::temp_dir().join("lang_check_test_config_missing");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = Config::load(&dir).unwrap();
assert!(config.engines.harper.enabled);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exclude_matches_a_path_relative_to_the_workspace() {
let config = Config {
exclude: vec!["drafts/**".to_string(), "node_modules/**".to_string()],
..Config::default()
};
let root = Path::new("/home/someone/project");
assert!(config.excludes(&root.join("drafts/notes.md"), root));
assert!(config.excludes(&root.join("node_modules/pkg/README.md"), root));
assert!(!config.excludes(&root.join("docs/notes.md"), root));
}
#[test]
fn exclude_accepts_a_path_that_is_already_relative() {
let config = Config {
exclude: vec!["drafts/**".to_string()],
..Config::default()
};
let root = Path::new("/home/someone/project");
assert!(config.excludes(Path::new("drafts/notes.md"), root));
}
#[test]
fn exclude_matches_whichever_separator_the_platform_uses() {
let config = Config {
exclude: vec!["drafts/**".to_string()],
..Config::default()
};
let root = Path::new("/home/someone/project");
let with_backslashes = root.join("drafts").join("notes.md");
assert!(config.excludes(&with_backslashes, root));
}
#[test]
fn an_empty_exclude_list_excludes_nothing() {
let config = Config::default();
let root = Path::new("/tmp");
assert!(!config.excludes(&root.join("anything.md"), root));
}
#[test]
fn a_malformed_pattern_excludes_nothing_rather_than_everything() {
let config = Config {
exclude: vec!["[unclosed".to_string(), "drafts/**".to_string()],
..Config::default()
};
let root = Path::new("/tmp");
assert!(!config.excludes(&root.join("notes.md"), root));
assert!(config.excludes(&root.join("drafts/notes.md"), root));
}
#[test]
fn a_relative_vale_config_is_resolved_against_the_workspace() {
let dir = std::env::temp_dir().join(format!("lc_resolve_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".languagecheck.yaml"),
"engines:\n vale:\n enabled: true\n config: \".vale.ini\"\n",
)
.unwrap();
let config = Config::load(&dir).expect("config");
let resolved = config.engines.vale.config.expect("a config path");
assert!(
Path::new(&resolved).is_absolute(),
"left relative: {resolved}"
);
assert!(resolved.ends_with(".vale.ini"), "{resolved}");
assert!(resolved.starts_with(&*dir.to_string_lossy()), "{resolved}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_absolute_path_in_the_config_is_left_alone() {
let dir = std::env::temp_dir().join(format!("lc_resolve_abs_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let elsewhere = std::env::temp_dir().join("vale.ini");
let elsewhere = elsewhere.to_string_lossy().into_owned();
std::fs::write(
dir.join(".languagecheck.yaml"),
format!("engines:\n vale:\n enabled: true\n config: '{elsewhere}'\n"),
)
.unwrap();
let config = Config::load(&dir).expect("config");
assert_eq!(
config.engines.vale.config.as_deref(),
Some(elsewhere.as_str())
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_wasm_plugin_path_is_resolved_too() {
let dir = std::env::temp_dir().join(format!("lc_resolve_wasm_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".languagecheck.yaml"),
"engines:\n wasm_plugins:\n - name: p\n path: plugins/p.wasm\n",
)
.unwrap();
let config = Config::load(&dir).expect("config");
let resolved = &config.engines.wasm_plugins[0].path;
assert!(
Path::new(resolved).is_absolute(),
"left relative: {resolved}"
);
assert!(
resolved.replace('\\', "/").ends_with("plugins/p.wasm"),
"{resolved}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_relative_proselint_config_is_resolved_too() {
let dir = std::env::temp_dir().join(format!("lc_resolve_pl_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".languagecheck.yaml"),
"engines:\n proselint:\n enabled: true\n config: \"proselint.json\"\n",
)
.unwrap();
let config = Config::load(&dir).expect("config");
let resolved = config.engines.proselint.config.expect("a config path");
assert!(
Path::new(&resolved).is_absolute(),
"left relative: {resolved}"
);
assert!(resolved.ends_with("proselint.json"), "{resolved}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_external_command_written_as_a_path_is_resolved() {
let dir = std::env::temp_dir().join(format!("lc_resolve_ext_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".languagecheck.yaml"),
"engines:\n external:\n - name: c\n command: ./my-checker\n",
)
.unwrap();
let config = Config::load(&dir).expect("config");
let command = &config.engines.external[0].command;
assert!(Path::new(command).is_absolute(), "left relative: {command}");
assert!(command.ends_with("my-checker"), "{command}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_external_command_that_is_a_bare_name_is_left_for_path_lookup() {
let dir = std::env::temp_dir().join(format!("lc_resolve_bare_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".languagecheck.yaml"),
"engines:\n external:\n - name: c\n command: my-checker\n",
)
.unwrap();
let config = Config::load(&dir).expect("config");
assert_eq!(config.engines.external[0].command, "my-checker");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_fix_simple_replacement() {
let config = Config {
auto_fix: vec![AutoFixRule {
find: "teh".to_string(),
replace: "the".to_string(),
context: None,
description: None,
}],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
assert_eq!(result, "Fix the typo in the text.");
assert_eq!(count, 2);
}
#[test]
fn auto_fix_with_context_filter() {
let config = Config {
auto_fix: vec![AutoFixRule {
find: "colour".to_string(),
replace: "color".to_string(),
context: Some("American".to_string()),
description: Some("Use American spelling".to_string()),
}],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
assert_eq!(result, "American English: the color is red.");
assert_eq!(count, 1);
let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
assert_eq!(result, "British English: the colour is red.");
assert_eq!(count, 0);
}
#[test]
fn auto_fix_no_match() {
let config = Config {
auto_fix: vec![AutoFixRule {
find: "foo".to_string(),
replace: "bar".to_string(),
context: None,
description: None,
}],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("No matches here.");
assert_eq!(result, "No matches here.");
assert_eq!(count, 0);
}
#[test]
fn auto_fix_multiple_rules() {
let config = Config {
auto_fix: vec![
AutoFixRule {
find: "recieve".to_string(),
replace: "receive".to_string(),
context: None,
description: None,
},
AutoFixRule {
find: "seperate".to_string(),
replace: "separate".to_string(),
context: None,
description: None,
},
],
..Config::default()
};
let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
assert_eq!(result, "Please receive the separate package.");
assert_eq!(count, 2);
}
#[test]
fn auto_fix_loads_from_yaml() {
let yaml = r#"
auto_fix:
- find: "teh"
replace: "the"
description: "Fix common typo"
- find: "colour"
replace: "color"
context: "American"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.auto_fix.len(), 2);
assert_eq!(config.auto_fix[0].find, "teh");
assert_eq!(config.auto_fix[0].replace, "the");
assert_eq!(
config.auto_fix[0].description.as_deref(),
Some("Fix common typo")
);
assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
}
#[test]
fn default_config_has_empty_auto_fix() {
let config = Config::default();
assert!(config.auto_fix.is_empty());
}
#[test]
fn external_providers_from_yaml() {
let yaml = r#"
engines:
harper: true
languagetool: false
external:
- name: vale
command: /usr/bin/vale
args: ["--output", "JSON"]
extensions: [md, rst]
- name: custom-checker
command: ./my-checker
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.external.len(), 2);
assert_eq!(config.engines.external[0].name, "vale");
assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
assert_eq!(config.engines.external[1].name, "custom-checker");
assert!(config.engines.external[1].args.is_empty());
}
#[test]
fn default_config_has_no_external_providers() {
let config = Config::default();
assert!(config.engines.external.is_empty());
}
#[test]
fn wasm_plugins_from_yaml() {
let yaml = r#"
engines:
harper: true
wasm_plugins:
- name: custom-checker
path: .languagecheck/plugins/checker.wasm
extensions: [md, html]
- name: style-linter
path: /opt/plugins/style.wasm
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.wasm_plugins.len(), 2);
assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
assert_eq!(
config.engines.wasm_plugins[0].path,
".languagecheck/plugins/checker.wasm"
);
assert_eq!(
config.engines.wasm_plugins[0].extensions,
vec!["md", "html"]
);
assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
assert!(config.engines.wasm_plugins[1].extensions.is_empty());
}
#[test]
fn default_config_has_no_wasm_plugins() {
let config = Config::default();
assert!(config.engines.wasm_plugins.is_empty());
}
#[test]
fn performance_config_defaults() {
let config = Config::default();
assert!(!config.performance.high_performance_mode);
assert_eq!(config.performance.debounce_ms, 500);
assert_eq!(config.performance.max_file_size, 0);
}
#[test]
fn performance_config_from_yaml() {
let yaml = r#"
performance:
high_performance_mode: true
debounce_ms: 500
max_file_size: 1048576
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.performance.high_performance_mode);
assert_eq!(config.performance.debounce_ms, 500);
assert_eq!(config.performance.max_file_size, 1_048_576);
}
#[test]
fn latex_skip_environments_from_yaml() {
let yaml = r#"
languages:
latex:
skip_environments:
- prooftree
- mycustomenv
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.languages.latex.skip_environments,
vec!["prooftree", "mycustomenv"]
);
}
#[test]
fn default_config_has_empty_latex_skip_environments() {
let config = Config::default();
assert!(config.languages.latex.skip_environments.is_empty());
}
#[test]
fn latex_skip_commands_from_yaml() {
let yaml = r#"
languages:
latex:
skip_commands:
- codefont
- myverb
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.languages.latex.skip_commands,
vec!["codefont", "myverb"]
);
}
#[test]
fn default_spell_language_is_en_us() {
let config = Config::default();
assert_eq!(config.engines.spell_language, "en-US");
}
#[test]
fn spell_language_from_yaml() {
let yaml = r#"
engines:
spell_language: de-DE
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.spell_language, "de-DE");
}
#[test]
fn default_config_has_empty_latex_skip_commands() {
let config = Config::default();
assert!(config.languages.latex.skip_commands.is_empty());
}
#[test]
fn default_vale_is_disabled() {
let config = Config::default();
assert!(!config.engines.vale.enabled);
assert!(config.engines.vale.config.is_none());
}
#[test]
fn vale_bool_shorthand_from_yaml() {
let yaml = r#"
engines:
vale: true
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.vale.enabled);
}
#[test]
fn vale_nested_config_from_yaml() {
let yaml = r#"
engines:
vale:
enabled: true
config: ".vale.ini"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.vale.enabled);
assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
}
#[test]
fn harper_nested_config_from_yaml() {
let yaml = r#"
engines:
harper:
enabled: true
dialect: "British"
linters:
LongSentences: false
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.harper.enabled);
assert_eq!(config.engines.harper.dialect, "British");
assert_eq!(
config.engines.harper.linters.get("LongSentences"),
Some(&false)
);
}
#[test]
fn languagetool_nested_config_from_yaml() {
let yaml = r#"
engines:
languagetool:
enabled: true
url: "http://localhost:9090"
level: "picky"
disabled_rules:
- WHITESPACE_RULE
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.languagetool.enabled);
assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
assert_eq!(config.engines.languagetool.level, "picky");
assert_eq!(
config.engines.languagetool.disabled_rules,
vec!["WHITESPACE_RULE"]
);
assert_eq!(config.engines.languagetool.max_concurrent_requests, 8);
}
#[test]
fn legacy_flat_languagetool_url_is_honoured() {
let yaml = r#"
engines:
spell_language: fr
proselint: false
vale: false
languagetool: true
languagetool_url: "http://10.0.10.3:8003"
harper: false
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.languagetool.enabled);
assert_eq!(config.engines.languagetool.url, "http://10.0.10.3:8003");
assert_eq!(config.engines.spell_language, "fr");
assert!(!config.engines.harper.enabled);
}
#[test]
fn nested_languagetool_url_beats_the_legacy_key() {
let yaml = r#"
engines:
languagetool:
enabled: true
url: "http://nested:9090"
languagetool_url: "http://flat:8003"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.languagetool.url, "http://nested:9090");
}
#[test]
fn legacy_flat_vale_config_is_honoured() {
let yaml = "engines:\n vale: true\n vale_config: \"config/.vale.ini\"\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.vale.enabled);
assert_eq!(
config.engines.vale.config.as_deref(),
Some("config/.vale.ini")
);
}
#[test]
fn unknown_keys_are_reported() {
let value: serde_yaml::Value =
serde_yaml::from_str("engines:\n languagetol: true\n harper: true\nrulez: {}\n")
.unwrap();
assert_eq!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS), vec!["rulez"]);
assert_eq!(
unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS),
vec!["languagetol"]
);
}
#[test]
fn recognised_keys_are_not_reported() {
let value: serde_yaml::Value = serde_yaml::from_str(
"engines:\n languagetool_url: \"http://x:1\"\n harper: true\nrules: {}\n",
)
.unwrap();
assert!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS).is_empty());
assert!(unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS).is_empty());
}
#[test]
fn languagetool_concurrency_can_be_pinned_to_serial() {
let yaml = r"
engines:
languagetool:
enabled: true
max_concurrent_requests: 1
";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.engines.languagetool.max_concurrent_requests, 1);
}
#[test]
fn default_proselint_is_disabled() {
let config = Config::default();
assert!(!config.engines.proselint.enabled);
assert!(config.engines.proselint.config.is_none());
}
#[test]
fn proselint_bool_shorthand_from_yaml() {
let yaml = r#"
engines:
proselint: true
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.proselint.enabled);
}
#[test]
fn proselint_nested_config_from_yaml() {
let yaml = r#"
engines:
proselint:
enabled: true
config: "proselint.json"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.engines.proselint.enabled);
assert_eq!(
config.engines.proselint.config.as_deref(),
Some("proselint.json")
);
}
}