1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5use tracing::warn;
6
7#[derive(Debug, Serialize, Deserialize, Clone)]
8pub struct Config {
9 #[serde(default)]
10 pub engines: EngineConfig,
11 #[serde(default)]
12 pub rules: HashMap<String, RuleConfig>,
13 #[serde(default)]
27 pub include: Vec<String>,
28 #[serde(default)]
39 pub file_types: Vec<String>,
40 #[serde(default = "default_exclude")]
48 pub exclude: Vec<String>,
49 #[serde(default)]
50 pub auto_fix: Vec<AutoFixRule>,
51 #[serde(default)]
52 pub performance: PerformanceConfig,
53 #[serde(default)]
54 pub dictionaries: DictionaryConfig,
55 #[serde(default)]
56 pub languages: LanguageConfig,
57 #[serde(default)]
58 pub workspace: WorkspaceConfig,
59 #[serde(default)]
60 pub names: NameConfig,
61 #[serde(default)]
62 pub morphology: MorphologyConfig,
63}
64
65#[derive(Debug, Serialize, Deserialize, Clone, Default)]
76pub struct NameConfig {
77 #[serde(default)]
79 pub enabled: bool,
80 #[serde(default)]
83 pub aggressiveness: crate::names::Aggressiveness,
84}
85
86#[derive(Debug, Serialize, Deserialize, Clone)]
99pub struct MorphologyConfig {
100 #[serde(default = "default_true")]
102 pub enabled: bool,
103 #[serde(default = "default_true")]
105 pub inflections: bool,
106}
107
108impl Default for MorphologyConfig {
109 fn default() -> Self {
110 Self {
111 enabled: true,
112 inflections: true,
113 }
114 }
115}
116
117#[derive(Debug, Serialize, Deserialize, Clone, Default)]
130pub struct LanguageConfig {
131 #[serde(default)]
133 pub extensions: HashMap<String, Vec<String>>,
134 #[serde(default)]
136 pub latex: LaTeXConfig,
137}
138
139#[derive(Debug, Serialize, Deserialize, Clone, Default)]
149pub struct LaTeXConfig {
150 #[serde(default)]
153 pub skip_environments: Vec<String>,
154 #[serde(default)]
158 pub skip_commands: Vec<String>,
159}
160
161#[derive(Debug, Serialize, Deserialize, Clone, Default)]
168pub struct WorkspaceConfig {
169 #[serde(default)]
172 pub index_on_open: bool,
173 #[serde(default)]
176 pub db_path: Option<String>,
177}
178
179#[derive(Debug, Serialize, Deserialize, Clone)]
182pub struct PerformanceConfig {
183 #[serde(default)]
185 pub high_performance_mode: bool,
186 #[serde(default = "default_debounce_ms")]
191 pub debounce_ms: u64,
192 #[serde(default)]
194 pub max_file_size: usize,
195 #[serde(default = "default_result_cache_entries")]
201 pub result_cache_entries: usize,
202 #[serde(default = "default_max_range_bytes")]
210 pub max_range_bytes: usize,
211}
212
213impl Default for PerformanceConfig {
214 fn default() -> Self {
215 Self {
216 high_performance_mode: false,
217 debounce_ms: 500,
218 max_file_size: 0,
219 result_cache_entries: default_result_cache_entries(),
220 max_range_bytes: default_max_range_bytes(),
221 }
222 }
223}
224
225const fn default_debounce_ms() -> u64 {
228 500
229}
230
231const fn default_result_cache_entries() -> usize {
234 4096
235}
236
237const fn default_max_range_bytes() -> usize {
242 2048
243}
244
245#[derive(Debug, Serialize, Deserialize, Clone)]
247pub struct DictionaryConfig {
248 #[serde(default = "default_true")]
251 pub bundled: bool,
252 #[serde(default)]
256 pub disabled: Vec<String>,
257 #[serde(default)]
260 pub paths: Vec<String>,
261}
262
263impl Default for DictionaryConfig {
264 fn default() -> Self {
265 Self {
266 bundled: true,
267 disabled: Vec::new(),
268 paths: Vec::new(),
269 }
270 }
271}
272
273#[derive(Debug, Serialize, Deserialize, Clone)]
275pub struct AutoFixRule {
276 pub find: String,
278 pub replace: String,
280 #[serde(default)]
282 pub context: Option<String>,
283 #[serde(default)]
285 pub description: Option<String>,
286}
287
288#[derive(Debug, Serialize, Deserialize, Clone)]
289#[serde(from = "EngineConfigWire")]
290pub struct EngineConfig {
291 pub harper: HarperConfig,
292 pub languagetool: LanguageToolConfig,
293 pub vale: ValeConfig,
294 pub proselint: ProselintConfig,
295 pub hunspell: HunspellConfig,
296 pub external: Vec<ExternalProvider>,
298 pub wasm_plugins: Vec<WasmPlugin>,
300 pub spell_language: String,
302}
303
304#[derive(Deserialize)]
315struct EngineConfigWire {
316 #[serde(
317 default = "default_harper_config",
318 deserialize_with = "deser_engine_or_bool"
319 )]
320 harper: HarperConfig,
321 #[serde(default, deserialize_with = "deser_engine_or_bool")]
322 languagetool: LanguageToolConfig,
323 #[serde(default, deserialize_with = "deser_engine_or_bool")]
324 vale: ValeConfig,
325 #[serde(default, deserialize_with = "deser_engine_or_bool")]
326 proselint: ProselintConfig,
327 #[serde(default, deserialize_with = "deser_engine_or_bool")]
328 hunspell: HunspellConfig,
329 #[serde(default)]
330 external: Vec<ExternalProvider>,
331 #[serde(default)]
332 wasm_plugins: Vec<WasmPlugin>,
333 #[serde(default = "default_spell_language")]
334 spell_language: String,
335 #[serde(default)]
337 languagetool_url: Option<String>,
338 #[serde(default)]
340 vale_config: Option<String>,
341}
342
343impl From<EngineConfigWire> for EngineConfig {
344 fn from(wire: EngineConfigWire) -> Self {
345 let EngineConfigWire {
346 harper,
347 mut languagetool,
348 mut vale,
349 proselint,
350 hunspell,
351 external,
352 wasm_plugins,
353 spell_language,
354 languagetool_url,
355 vale_config,
356 } = wire;
357
358 if let Some(url) = languagetool_url {
361 if languagetool.url == default_lt_url() {
362 warn_deprecated_engine_key("engines.languagetool_url", "engines.languagetool.url");
363 languagetool.url = url;
364 } else {
365 warn_ignored_engine_key("engines.languagetool_url", "engines.languagetool.url");
366 }
367 }
368 if let Some(path) = vale_config {
369 if vale.config.is_none() {
370 warn_deprecated_engine_key("engines.vale_config", "engines.vale.config");
371 vale.config = Some(path);
372 } else {
373 warn_ignored_engine_key("engines.vale_config", "engines.vale.config");
374 }
375 }
376
377 Self {
378 harper,
379 languagetool,
380 vale,
381 proselint,
382 hunspell,
383 external,
384 wasm_plugins,
385 spell_language,
386 }
387 }
388}
389
390fn warn_deprecated_engine_key(old: &str, new: &str) {
392 warn!(
393 "`{old}` is deprecated and will be removed in a future release; \
394 rename it to `{new}`. Honouring it for now."
395 );
396}
397
398fn warn_ignored_engine_key(old: &str, new: &str) {
400 warn!("`{old}` is ignored because `{new}` is also set; delete the deprecated key.");
401}
402
403fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
406where
407 D: serde::Deserializer<'de>,
408 T: Deserialize<'de> + EngineToggle + Default,
409{
410 #[derive(Deserialize)]
411 #[serde(untagged)]
412 enum BoolOrStruct<T> {
413 Bool(bool),
414 Struct(T),
415 }
416
417 match BoolOrStruct::deserialize(deserializer)? {
418 BoolOrStruct::Bool(b) => {
419 let mut cfg = T::default();
420 cfg.set_enabled(b);
421 Ok(cfg)
422 }
423 BoolOrStruct::Struct(s) => Ok(s),
424 }
425}
426
427pub trait EngineToggle {
429 fn enabled(&self) -> bool;
430 fn set_enabled(&mut self, v: bool);
431}
432
433#[derive(Debug, Serialize, Deserialize, Clone)]
435pub struct HarperConfig {
436 #[serde(default = "default_true")]
437 pub enabled: bool,
438 #[serde(default = "default_dialect")]
440 pub dialect: String,
441 #[serde(default)]
444 pub linters: HashMap<String, bool>,
445}
446
447impl Default for HarperConfig {
448 fn default() -> Self {
449 Self {
450 enabled: true,
451 dialect: "American".to_string(),
452 linters: HashMap::new(),
453 }
454 }
455}
456
457fn default_harper_config() -> HarperConfig {
458 HarperConfig::default()
459}
460
461fn default_dialect() -> String {
462 "American".to_string()
463}
464
465impl EngineToggle for HarperConfig {
466 fn enabled(&self) -> bool {
467 self.enabled
468 }
469 fn set_enabled(&mut self, v: bool) {
470 self.enabled = v;
471 }
472}
473
474#[derive(Debug, Serialize, Deserialize, Clone)]
476pub struct LanguageToolConfig {
477 #[serde(default)]
478 pub enabled: bool,
479 #[serde(default = "default_lt_url")]
481 pub url: String,
482 #[serde(default = "default_lt_level")]
484 pub level: String,
485 #[serde(default)]
487 pub mother_tongue: Option<String>,
488 #[serde(default)]
490 pub disabled_rules: Vec<String>,
491 #[serde(default)]
493 pub enabled_rules: Vec<String>,
494 #[serde(default)]
496 pub disabled_categories: Vec<String>,
497 #[serde(default)]
499 pub enabled_categories: Vec<String>,
500 #[serde(default = "default_lt_max_concurrent_requests")]
505 pub max_concurrent_requests: usize,
506 #[serde(default = "default_lt_max_request_bytes")]
512 pub max_request_bytes: usize,
513}
514
515impl Default for LanguageToolConfig {
516 fn default() -> Self {
517 Self {
518 enabled: false,
519 url: default_lt_url(),
520 level: "default".to_string(),
521 mother_tongue: None,
522 disabled_rules: Vec::new(),
523 enabled_rules: Vec::new(),
524 disabled_categories: Vec::new(),
525 enabled_categories: Vec::new(),
526 max_concurrent_requests: default_lt_max_concurrent_requests(),
527 max_request_bytes: default_lt_max_request_bytes(),
528 }
529 }
530}
531
532fn default_lt_level() -> String {
533 "default".to_string()
534}
535
536const fn default_lt_max_concurrent_requests() -> usize {
539 8
540}
541
542const fn default_lt_max_request_bytes() -> usize {
549 4096
550}
551
552#[derive(Debug, Default, Serialize, Deserialize, Clone)]
563pub struct HunspellConfig {
564 #[serde(default)]
566 pub enabled: bool,
567 #[serde(default)]
574 pub languages: Vec<String>,
575 #[serde(default)]
579 pub dictionary_paths: HashMap<String, String>,
580 #[serde(default)]
582 pub search_paths: Vec<String>,
583 #[serde(default)]
589 pub auto_install: bool,
590}
591
592impl EngineToggle for HunspellConfig {
593 fn enabled(&self) -> bool {
594 self.enabled
595 }
596 fn set_enabled(&mut self, v: bool) {
597 self.enabled = v;
598 }
599}
600
601impl EngineToggle for LanguageToolConfig {
602 fn enabled(&self) -> bool {
603 self.enabled
604 }
605 fn set_enabled(&mut self, v: bool) {
606 self.enabled = v;
607 }
608}
609
610#[derive(Debug, Default, Serialize, Deserialize, Clone)]
612pub struct ValeConfig {
613 #[serde(default)]
614 pub enabled: bool,
615 #[serde(default)]
617 pub config: Option<String>,
618}
619
620impl EngineToggle for ValeConfig {
621 fn enabled(&self) -> bool {
622 self.enabled
623 }
624 fn set_enabled(&mut self, v: bool) {
625 self.enabled = v;
626 }
627}
628
629#[derive(Debug, Default, Serialize, Deserialize, Clone)]
631pub struct ProselintConfig {
632 #[serde(default)]
633 pub enabled: bool,
634 #[serde(default)]
636 pub config: Option<String>,
637}
638
639impl EngineToggle for ProselintConfig {
640 fn enabled(&self) -> bool {
641 self.enabled
642 }
643 fn set_enabled(&mut self, v: bool) {
644 self.enabled = v;
645 }
646}
647
648#[derive(Debug, Serialize, Deserialize, Clone)]
653pub struct ExternalProvider {
654 pub name: String,
656 pub command: String,
658 #[serde(default)]
660 pub args: Vec<String>,
661 #[serde(default)]
666 pub extensions: Vec<String>,
667 #[serde(default)]
673 pub languages: Vec<String>,
674}
675
676#[derive(Debug, Serialize, Deserialize, Clone)]
681pub struct WasmPlugin {
682 pub name: String,
684 pub path: String,
686 #[serde(default)]
688 pub extensions: Vec<String>,
689 #[serde(default)]
691 pub languages: Vec<String>,
692}
693
694impl Default for EngineConfig {
695 fn default() -> Self {
696 Self {
697 harper: HarperConfig::default(),
698 languagetool: LanguageToolConfig::default(),
699 vale: ValeConfig::default(),
700 proselint: ProselintConfig::default(),
701 hunspell: HunspellConfig::default(),
702 external: Vec::new(),
703 wasm_plugins: Vec::new(),
704 spell_language: default_spell_language(),
705 }
706 }
707}
708
709#[derive(Debug, Serialize, Deserialize, Clone)]
710pub struct RuleConfig {
711 pub severity: Option<String>, }
713
714const fn default_true() -> bool {
715 true
716}
717fn default_lt_url() -> String {
718 "http://localhost:8010".to_string()
719}
720fn default_spell_language() -> String {
721 "en-US".to_string()
722}
723fn default_exclude() -> Vec<String> {
724 vec![
730 "**/node_modules/**".to_string(),
731 "**/.git/**".to_string(),
732 "**/target/**".to_string(),
733 "**/dist/**".to_string(),
734 "**/build/**".to_string(),
735 "**/.next/**".to_string(),
736 "**/.nuxt/**".to_string(),
737 "**/vendor/**".to_string(),
738 "**/__pycache__/**".to_string(),
739 "**/.venv/**".to_string(),
740 "**/venv/**".to_string(),
741 "**/.tox/**".to_string(),
742 "**/.mypy_cache/**".to_string(),
743 "*.min.js".to_string(),
744 "*.min.css".to_string(),
745 "*.bundle.js".to_string(),
746 "package-lock.json".to_string(),
747 "yarn.lock".to_string(),
748 "pnpm-lock.yaml".to_string(),
749 ]
750}
751
752impl Config {
753 #[must_use]
763 pub fn load_or_warn(workspace_root: &Path) -> Self {
764 Self::load(workspace_root).unwrap_or_else(|e| {
765 warn!(
766 root = %workspace_root.display(),
767 "Ignoring unreadable workspace config, using defaults: {e}"
768 );
769 Self::default()
770 })
771 }
772
773 #[must_use]
783 pub fn excludes(&self, path: &Path, workspace_root: &Path) -> bool {
784 matches_any(&self.exclude, path, workspace_root)
785 }
786
787 #[must_use]
792 pub fn includes(&self, path: &Path, workspace_root: &Path) -> bool {
793 self.include.is_empty() || matches_any(&self.include, path, workspace_root)
794 }
795
796 #[must_use]
803 pub fn admits_type(&self, path: &Path) -> bool {
804 if self.file_types.is_empty() {
805 return true;
806 }
807 let Some(extension) = path.extension().and_then(|e| e.to_str()) else {
808 return true;
809 };
810 self.file_types.iter().any(|wanted| {
811 wanted
812 .trim_start_matches('.')
813 .eq_ignore_ascii_case(extension)
814 })
815 }
816
817 #[must_use]
825 pub fn checks(&self, path: &Path, workspace_root: &Path) -> bool {
826 self.admits_type(path)
827 && self.includes(path, workspace_root)
828 && !self.excludes(path, workspace_root)
829 }
830}
831
832fn matches_any(patterns: &[String], path: &Path, workspace_root: &Path) -> bool {
839 if patterns.is_empty() {
840 return false;
841 }
842 let relative = path.strip_prefix(workspace_root).unwrap_or(path);
843 let as_text = relative.to_string_lossy().replace('\\', "/");
844 let options = glob::MatchOptions {
845 require_literal_separator: false,
846 require_literal_leading_dot: false,
847 case_sensitive: true,
848 };
849 patterns
850 .iter()
851 .filter_map(|pattern| glob::Pattern::new(pattern).ok())
852 .any(|pattern| pattern.matches_with(&as_text, options))
853}
854
855impl Config {
856 fn merge_default_excludes(&mut self) {
862 for pattern in default_exclude() {
863 if !self.exclude.contains(&pattern) {
864 self.exclude.push(pattern);
865 }
866 }
867 }
868
869 fn resolve_paths(&mut self, workspace_root: &Path) {
885 let absolute = |value: &str| -> String {
886 let path = Path::new(value);
887 if path.is_absolute() {
888 value.to_string()
889 } else {
890 workspace_root.join(path).to_string_lossy().into_owned()
891 }
892 };
893
894 if let Some(vale_config) = &self.engines.vale.config {
895 self.engines.vale.config = Some(absolute(vale_config));
896 }
897 if let Some(proselint_config) = &self.engines.proselint.config {
898 self.engines.proselint.config = Some(absolute(proselint_config));
899 }
900 for plugin in &mut self.engines.wasm_plugins {
901 plugin.path = absolute(&plugin.path);
902 }
903 for provider in &mut self.engines.external {
904 if provider.command.contains(std::path::MAIN_SEPARATOR)
908 || provider.command.contains('/')
909 {
910 provider.command = absolute(&provider.command);
911 }
912 }
913 }
914
915 pub fn parse_text(text: &str, workspace_root: &Path, format: &str) -> Result<Self> {
928 let mut config: Self = if format.eq_ignore_ascii_case("json") {
929 serde_json::from_str(text)?
930 } else {
931 serde_yaml::from_str(text)?
932 };
933 config.merge_default_excludes();
934 config.resolve_paths(workspace_root);
935 Ok(config)
936 }
937
938 #[must_use]
944 pub fn unknown_key_paths(text: &str) -> Vec<String> {
945 let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(text) else {
946 return Vec::new();
947 };
948 let mut out: Vec<String> = unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS);
949 if let Some(engines) = value.get("engines") {
950 out.extend(
951 unknown_keys(engines, KNOWN_ENGINE_KEYS)
952 .into_iter()
953 .map(|k| format!("engines.{k}")),
954 );
955 }
956 out
957 }
958
959 pub fn load(workspace_root: &Path) -> Result<Self> {
960 let yaml_path = workspace_root.join(".languagecheck.yaml");
962 let yml_path = workspace_root.join(".languagecheck.yml");
963 let json_path = workspace_root.join(".languagecheck.json");
964
965 if yaml_path.exists() {
966 let content = std::fs::read_to_string(yaml_path)?;
967 warn_duplicate_rule_keys(&content);
968 let mut config: Self = serde_yaml::from_str(&content)?;
969 warn_unknown_keys(&serde_yaml::from_str(&content)?);
970 config.merge_default_excludes();
971 config.resolve_paths(workspace_root);
972 Ok(config)
973 } else if yml_path.exists() {
974 let content = std::fs::read_to_string(yml_path)?;
975 warn_duplicate_rule_keys(&content);
976 let mut config: Self = serde_yaml::from_str(&content)?;
977 warn_unknown_keys(&serde_yaml::from_str(&content)?);
978 config.merge_default_excludes();
979 config.resolve_paths(workspace_root);
980 Ok(config)
981 } else if json_path.exists() {
982 let content = std::fs::read_to_string(json_path)?;
983 let mut config: Self = serde_json::from_str(&content)?;
984 warn_unknown_keys(&serde_yaml::from_str(&content)?);
986 config.merge_default_excludes();
987 config.resolve_paths(workspace_root);
988 Ok(config)
989 } else {
990 Ok(Self::default())
991 }
992 }
993
994 #[must_use]
997 pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
998 let mut result = text.to_string();
999 let mut total = 0;
1000
1001 for rule in &self.auto_fix {
1002 if let Some(ctx) = &rule.context
1003 && !result.contains(ctx.as_str())
1004 {
1005 continue;
1006 }
1007 let count = result.matches(&rule.find).count();
1008 if count > 0 {
1009 result = result.replace(&rule.find, &rule.replace);
1010 total += count;
1011 }
1012 }
1013
1014 (result, total)
1015 }
1016}
1017
1018fn duplicate_rule_keys(content: &str) -> Vec<String> {
1026 let mut in_rules = false;
1027 let mut child_indent: Option<usize> = None;
1028 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1029 let mut duplicates: Vec<String> = Vec::new();
1030
1031 for line in content.lines() {
1032 if line.trim().is_empty() {
1033 continue;
1034 }
1035 let indent = line.len() - line.trim_start().len();
1036
1037 if !in_rules {
1038 if indent == 0 && line.trim() == "rules:" {
1039 in_rules = true;
1040 }
1041 continue;
1042 }
1043
1044 if indent == 0 {
1046 break;
1047 }
1048
1049 let child = *child_indent.get_or_insert(indent);
1050 if indent != child {
1051 continue; }
1053 if let Some(key) = line.trim().strip_suffix(':') {
1054 let key = key.trim().to_string();
1055 if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
1056 duplicates.push(key);
1057 }
1058 }
1059 }
1060
1061 duplicates
1062}
1063
1064const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
1066 "engines",
1067 "rules",
1068 "include",
1069 "file_types",
1070 "exclude",
1071 "auto_fix",
1072 "performance",
1073 "dictionaries",
1074 "languages",
1075 "workspace",
1076 "names",
1077 "morphology",
1078];
1079
1080const KNOWN_ENGINE_KEYS: &[&str] = &[
1082 "harper",
1083 "languagetool",
1084 "vale",
1085 "proselint",
1086 "hunspell",
1087 "external",
1088 "wasm_plugins",
1089 "spell_language",
1090 "languagetool_url",
1091 "vale_config",
1092];
1093
1094fn unknown_keys(value: &serde_yaml::Value, known: &[&str]) -> Vec<String> {
1096 let Some(map) = value.as_mapping() else {
1097 return Vec::new();
1098 };
1099 map.keys()
1100 .filter_map(serde_yaml::Value::as_str)
1101 .filter(|k| !known.contains(k))
1102 .map(ToString::to_string)
1103 .collect()
1104}
1105
1106fn warn_unknown_keys(value: &serde_yaml::Value) {
1113 let unknown = unknown_keys(value, KNOWN_TOP_LEVEL_KEYS);
1114 if !unknown.is_empty() {
1115 warn!(keys = ?unknown, "Unknown keys in workspace config; they have no effect.");
1116 }
1117 if let Some(engines) = value.get("engines") {
1118 let unknown = unknown_keys(engines, KNOWN_ENGINE_KEYS);
1119 if !unknown.is_empty() {
1120 warn!(keys = ?unknown, "Unknown keys under `engines:`; they have no effect.");
1121 }
1122 }
1123}
1124
1125fn warn_duplicate_rule_keys(content: &str) {
1127 let duplicates = duplicate_rule_keys(content);
1128 if !duplicates.is_empty() {
1129 warn!(
1130 duplicates = ?duplicates,
1131 "Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
1132 effect. Remove the extra copies to keep the ignore list clean."
1133 );
1134 }
1135}
1136
1137impl Default for Config {
1138 fn default() -> Self {
1139 Self {
1140 engines: EngineConfig::default(),
1141 rules: HashMap::new(),
1142 include: Vec::new(),
1145 file_types: Vec::new(),
1146 exclude: default_exclude(),
1147 auto_fix: Vec::new(),
1148 performance: PerformanceConfig::default(),
1149 dictionaries: DictionaryConfig::default(),
1150 languages: LanguageConfig::default(),
1151 workspace: WorkspaceConfig::default(),
1152 names: NameConfig::default(),
1153 morphology: MorphologyConfig::default(),
1154 }
1155 }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160
1161 #[test]
1162 fn every_engine_key_the_config_accepts_is_declared_known() {
1163 let yaml = "\
1168engines:
1169 harper: false
1170 languagetool: false
1171 vale: false
1172 proselint: false
1173 hunspell:
1174 enabled: true
1175 spell_language: en-US
1176";
1177 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1178 let engines = value.get("engines").expect("engines section");
1179 assert_eq!(
1180 unknown_keys(engines, KNOWN_ENGINE_KEYS),
1181 Vec::<String>::new(),
1182 "an engine key parses but is not declared known"
1183 );
1184 }
1185 use super::*;
1186
1187 #[test]
1188 fn duplicate_rule_keys_detects_repeats() {
1189 let yaml = "rules:\n languagetool.ARROWS:\n severity: \"off\"\n \
1190 languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
1191 languagetool.ARROWS:\n severity: \"off\"\n \
1192 languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
1193 languagetool.THE_SUPERLATIVE:\n severity: \"off\"\n";
1194 let dups = duplicate_rule_keys(yaml);
1195 assert_eq!(
1196 dups,
1197 vec![
1198 "languagetool.ARROWS".to_string(),
1199 "languagetool.UPPERCASE_SENTENCE_START".to_string()
1200 ]
1201 );
1202 }
1203
1204 #[test]
1205 fn duplicate_rule_keys_clean_list_is_empty() {
1206 let yaml = "rules:\n a.B:\n severity: \"off\"\n c.D:\n severity: \"off\"\n";
1207 assert!(duplicate_rule_keys(yaml).is_empty());
1208 }
1209
1210 #[test]
1211 fn duplicate_rule_keys_stops_at_next_section() {
1212 let yaml = "rules:\n a.B:\n severity: \"off\"\nengines:\n harper: false\n";
1214 assert!(duplicate_rule_keys(yaml).is_empty());
1215 }
1216
1217 #[test]
1218 fn morphology_is_on_by_default() {
1219 let config = Config::default();
1220 assert!(config.morphology.enabled);
1221 assert!(config.morphology.inflections);
1222 }
1223
1224 #[test]
1225 fn morphology_can_be_switched_off_from_yaml() {
1226 let yaml = "morphology:\n enabled: false\n";
1227 let config: Config = serde_yaml::from_str(yaml).unwrap();
1228 assert!(!config.morphology.enabled);
1229 assert!(config.morphology.inflections);
1231 }
1232
1233 #[test]
1234 fn default_dictionaries_load_all_bundled_sets() {
1235 let config = Config::default();
1236 assert!(config.dictionaries.bundled);
1237 assert!(config.dictionaries.disabled.is_empty());
1238 assert!(config.dictionaries.paths.is_empty());
1239 }
1240
1241 #[test]
1242 fn dictionaries_disabled_from_yaml() {
1243 let config: Config = serde_yaml::from_str(
1244 r"
1245dictionaries:
1246 disabled: [companies, mathematics]
1247",
1248 )
1249 .unwrap();
1250 assert_eq!(config.dictionaries.disabled, ["companies", "mathematics"]);
1251 assert!(config.dictionaries.bundled);
1253 }
1254
1255 #[test]
1256 fn default_config_has_harper_enabled_lt_disabled() {
1257 let config = Config::default();
1258 assert!(config.engines.harper.enabled);
1259 assert!(!config.engines.languagetool.enabled);
1260 }
1261
1262 #[test]
1263 fn default_config_has_standard_excludes() {
1264 let config = Config::default();
1268 let root = Path::new("/ws");
1269 for directory in ["node_modules", ".git", "target", "dist", "vendor"] {
1270 assert!(
1271 !config.checks(&root.join(directory).join("a.md"), root),
1272 "{directory} at the root should be skipped"
1273 );
1274 assert!(
1275 !config.checks(&root.join("nested").join(directory).join("a.md"), root),
1276 "{directory} one level down should be skipped too"
1277 );
1278 }
1279 }
1280
1281 #[test]
1282 fn default_lt_url() {
1283 let config = Config::default();
1284 assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
1285 }
1286
1287 #[test]
1288 fn load_from_json_string() {
1289 let json = r#"{
1290 "engines": { "harper": true, "languagetool": false },
1291 "rules": { "spelling.typo": { "severity": "warning" } }
1292 }"#;
1293 let config: Config = serde_json::from_str(json).unwrap();
1294 assert!(config.engines.harper.enabled);
1295 assert!(!config.engines.languagetool.enabled);
1296 assert!(config.rules.contains_key("spelling.typo"));
1297 assert_eq!(
1298 config.rules["spelling.typo"].severity.as_deref(),
1299 Some("warning")
1300 );
1301 }
1302
1303 #[test]
1304 fn load_partial_json_uses_defaults() {
1305 let json = r#"{}"#;
1306 let config: Config = serde_json::from_str(json).unwrap();
1307 assert!(config.engines.harper.enabled);
1308 assert!(!config.engines.languagetool.enabled);
1309 assert!(config.rules.is_empty());
1310 }
1311
1312 #[test]
1313 fn load_from_json_file() {
1314 let dir = std::env::temp_dir().join("lang_check_test_config_json");
1315 let _ = std::fs::remove_dir_all(&dir);
1316 std::fs::create_dir_all(&dir).unwrap();
1317
1318 let config_path = dir.join(".languagecheck.json");
1319 std::fs::write(
1320 &config_path,
1321 r#"{"engines": {"harper": false, "languagetool": true}}"#,
1322 )
1323 .unwrap();
1324
1325 let config = Config::load(&dir).unwrap();
1326 assert!(!config.engines.harper.enabled);
1327 assert!(config.engines.languagetool.enabled);
1328
1329 let _ = std::fs::remove_dir_all(&dir);
1330 }
1331
1332 #[test]
1333 fn load_from_yaml_file() {
1334 let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
1335 let _ = std::fs::remove_dir_all(&dir);
1336 std::fs::create_dir_all(&dir).unwrap();
1337
1338 let config_path = dir.join(".languagecheck.yaml");
1339 std::fs::write(
1340 &config_path,
1341 "engines:\n harper: false\n languagetool: true\n",
1342 )
1343 .unwrap();
1344
1345 let config = Config::load(&dir).unwrap();
1346 assert!(!config.engines.harper.enabled);
1347 assert!(config.engines.languagetool.enabled);
1348
1349 let _ = std::fs::remove_dir_all(&dir);
1350 }
1351
1352 #[test]
1353 fn yaml_takes_precedence_over_json() {
1354 let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
1355 let _ = std::fs::remove_dir_all(&dir);
1356 std::fs::create_dir_all(&dir).unwrap();
1357
1358 std::fs::write(
1360 dir.join(".languagecheck.yaml"),
1361 "engines:\n harper: false\n",
1362 )
1363 .unwrap();
1364 std::fs::write(
1365 dir.join(".languagecheck.json"),
1366 r#"{"engines": {"harper": true}}"#,
1367 )
1368 .unwrap();
1369
1370 let config = Config::load(&dir).unwrap();
1371 assert!(!config.engines.harper.enabled);
1373
1374 let _ = std::fs::remove_dir_all(&dir);
1375 }
1376
1377 #[test]
1378 fn load_missing_file_returns_default() {
1379 let dir = std::env::temp_dir().join("lang_check_test_config_missing");
1380 let _ = std::fs::remove_dir_all(&dir);
1381 std::fs::create_dir_all(&dir).unwrap();
1382
1383 let config = Config::load(&dir).unwrap();
1384 assert!(config.engines.harper.enabled);
1385
1386 let _ = std::fs::remove_dir_all(&dir);
1387 }
1388
1389 #[test]
1390 fn exclude_matches_a_path_relative_to_the_workspace() {
1391 let config = Config {
1392 exclude: vec!["drafts/**".to_string(), "node_modules/**".to_string()],
1393 ..Config::default()
1394 };
1395 let root = Path::new("/home/someone/project");
1396
1397 assert!(config.excludes(&root.join("drafts/notes.md"), root));
1398 assert!(config.excludes(&root.join("node_modules/pkg/README.md"), root));
1399 assert!(!config.excludes(&root.join("docs/notes.md"), root));
1400 }
1401
1402 #[test]
1403 fn exclude_accepts_a_path_that_is_already_relative() {
1404 let config = Config {
1407 exclude: vec!["drafts/**".to_string()],
1408 ..Config::default()
1409 };
1410 let root = Path::new("/home/someone/project");
1411 assert!(config.excludes(Path::new("drafts/notes.md"), root));
1412 }
1413
1414 #[test]
1415 fn exclude_matches_whichever_separator_the_platform_uses() {
1416 let config = Config {
1420 exclude: vec!["drafts/**".to_string()],
1421 ..Config::default()
1422 };
1423 let root = Path::new("/home/someone/project");
1424 let with_backslashes = root.join("drafts").join("notes.md");
1425 assert!(config.excludes(&with_backslashes, root));
1426 }
1427
1428 #[test]
1429 fn a_config_that_excludes_something_of_its_own_still_excludes_node_modules() {
1430 let config = Config::parse_text(
1434 "exclude:\n - \"docs/_build/**\"\n",
1435 Path::new("/ws"),
1436 "yaml",
1437 )
1438 .expect("parses");
1439 let root = Path::new("/ws");
1440 assert!(!config.checks(&root.join("docs/_build/html/a.md"), root));
1441 assert!(!config.checks(&root.join("docs/.venv/lib/pkg/README.md"), root));
1442 assert!(!config.checks(&root.join("extension/node_modules/p/readme.md"), root));
1443 assert!(config.checks(&root.join("docs/guide.md"), root));
1444 }
1445
1446 #[test]
1447 fn merging_the_defaults_does_not_duplicate_a_pattern_the_file_repeats() {
1448 let config = Config::parse_text(
1449 "exclude:\n - \"**/node_modules/**\"\n",
1450 Path::new("/ws"),
1451 "yaml",
1452 )
1453 .expect("parses");
1454 assert_eq!(
1455 config
1456 .exclude
1457 .iter()
1458 .filter(|p| p.as_str() == "**/node_modules/**")
1459 .count(),
1460 1
1461 );
1462 }
1463
1464 #[test]
1465 fn an_absent_file_types_list_admits_every_type() {
1466 let config = Config::default();
1467 let root = Path::new("/ws");
1468 assert!(config.checks(&root.join("docs/a.md"), root));
1469 assert!(config.checks(&root.join("docs/a.tex"), root));
1470 }
1471
1472 #[test]
1473 fn file_types_restricts_to_the_extensions_it_names() {
1474 let config = Config {
1475 file_types: vec!["md".to_string()],
1476 exclude: Vec::new(),
1477 ..Config::default()
1478 };
1479 let root = Path::new("/ws");
1480 assert!(config.checks(&root.join("docs/a.md"), root));
1481 assert!(!config.checks(&root.join("docs/a.tex"), root));
1482 assert!(!config.checks(&root.join("docs/a.typ"), root));
1483 }
1484
1485 #[test]
1486 fn file_types_accepts_a_leading_dot_and_ignores_case() {
1487 let config = Config {
1490 file_types: vec![".MD".to_string()],
1491 exclude: Vec::new(),
1492 ..Config::default()
1493 };
1494 let root = Path::new("/ws");
1495 assert!(config.checks(&root.join("docs/a.md"), root));
1496 assert!(!config.checks(&root.join("docs/a.rst"), root));
1497 }
1498
1499 #[test]
1500 fn file_types_and_include_both_have_to_admit_a_path() {
1501 let config = Config {
1502 file_types: vec!["md".to_string()],
1503 include: vec!["docs/**".to_string()],
1504 exclude: Vec::new(),
1505 ..Config::default()
1506 };
1507 let root = Path::new("/ws");
1508 assert!(config.checks(&root.join("docs/a.md"), root));
1509 assert!(!config.checks(&root.join("src/a.md"), root));
1511 assert!(!config.checks(&root.join("docs/a.tex"), root));
1513 }
1514
1515 #[test]
1516 fn an_absent_include_selects_everything() {
1517 let config = Config::default();
1520 let root = Path::new("/ws");
1521 assert!(config.checks(&root.join("docs/guide.md"), root));
1522 assert!(config.checks(&root.join("src/deep/notes.md"), root));
1523 }
1524
1525 #[test]
1526 fn an_include_narrows_to_what_it_names() {
1527 let config = Config {
1528 include: vec!["docs/**".to_string(), "README.md".to_string()],
1529 exclude: Vec::new(),
1530 ..Config::default()
1531 };
1532 let root = Path::new("/ws");
1533 assert!(config.checks(&root.join("docs/guide/languages.md"), root));
1534 assert!(config.checks(&root.join("README.md"), root));
1535 assert!(!config.checks(&root.join("extension/src/test/fixtures/a.md"), root));
1538 assert!(!config.checks(&root.join("rust-core/target/doc/x.md"), root));
1539 }
1540
1541 #[test]
1542 fn a_star_crosses_directory_separators_in_both_lists() {
1543 let config = Config {
1548 include: vec!["*.md".to_string()],
1549 exclude: Vec::new(),
1550 ..Config::default()
1551 };
1552 let root = Path::new("/ws");
1553 assert!(config.checks(&root.join("deep/nested/note.md"), root));
1554 }
1555
1556 #[test]
1557 fn a_nested_build_directory_is_excluded_by_default() {
1558 let config = Config::default();
1561 let root = Path::new("/ws");
1562 assert!(!config.checks(&root.join("docs/.venv/lib/pkg/README.md"), root));
1563 assert!(!config.checks(&root.join("extension/node_modules/p/readme.md"), root));
1564 assert!(!config.checks(&root.join(".venv/lib/a.md"), root));
1565 }
1566
1567 #[test]
1568 fn exclude_subtracts_from_include_and_not_the_other_way_round() {
1569 let config = Config {
1572 include: vec!["docs/**".to_string()],
1573 exclude: vec!["docs/_build/**".to_string()],
1574 ..Config::default()
1575 };
1576 let root = Path::new("/ws");
1577 assert!(config.checks(&root.join("docs/index.md"), root));
1578 assert!(!config.checks(&root.join("docs/_build/html/index.md"), root));
1579 }
1580
1581 #[test]
1582 fn an_empty_include_list_is_not_an_empty_selection() {
1583 let config = Config {
1587 include: Vec::new(),
1588 exclude: Vec::new(),
1589 ..Config::default()
1590 };
1591 assert!(config.checks(Path::new("/ws/anything.md"), Path::new("/ws")));
1592 }
1593
1594 #[test]
1595 fn an_empty_exclude_list_excludes_nothing() {
1596 let config = Config::default();
1597 let root = Path::new("/tmp");
1598 assert!(!config.excludes(&root.join("anything.md"), root));
1599 }
1600
1601 #[test]
1602 fn a_malformed_pattern_excludes_nothing_rather_than_everything() {
1603 let config = Config {
1606 exclude: vec!["[unclosed".to_string(), "drafts/**".to_string()],
1607 ..Config::default()
1608 };
1609 let root = Path::new("/tmp");
1610 assert!(!config.excludes(&root.join("notes.md"), root));
1611 assert!(config.excludes(&root.join("drafts/notes.md"), root));
1612 }
1613
1614 #[test]
1615 fn a_relative_vale_config_is_resolved_against_the_workspace() {
1616 let dir = std::env::temp_dir().join(format!("lc_resolve_{}", std::process::id()));
1621 std::fs::create_dir_all(&dir).unwrap();
1622 std::fs::write(
1623 dir.join(".languagecheck.yaml"),
1624 "engines:\n vale:\n enabled: true\n config: \".vale.ini\"\n",
1625 )
1626 .unwrap();
1627
1628 let config = Config::load(&dir).expect("config");
1629 let resolved = config.engines.vale.config.expect("a config path");
1630 assert!(
1631 Path::new(&resolved).is_absolute(),
1632 "left relative: {resolved}"
1633 );
1634 assert!(resolved.ends_with(".vale.ini"), "{resolved}");
1635 assert!(resolved.starts_with(&*dir.to_string_lossy()), "{resolved}");
1636
1637 std::fs::remove_dir_all(&dir).ok();
1638 }
1639
1640 #[test]
1641 fn an_absolute_path_in_the_config_is_left_alone() {
1642 let dir = std::env::temp_dir().join(format!("lc_resolve_abs_{}", std::process::id()));
1643 std::fs::create_dir_all(&dir).unwrap();
1644
1645 let elsewhere = std::env::temp_dir().join("vale.ini");
1650 let elsewhere = elsewhere.to_string_lossy().into_owned();
1651 std::fs::write(
1654 dir.join(".languagecheck.yaml"),
1655 format!("engines:\n vale:\n enabled: true\n config: '{elsewhere}'\n"),
1656 )
1657 .unwrap();
1658
1659 let config = Config::load(&dir).expect("config");
1660 assert_eq!(
1661 config.engines.vale.config.as_deref(),
1662 Some(elsewhere.as_str())
1663 );
1664
1665 std::fs::remove_dir_all(&dir).ok();
1666 }
1667
1668 #[test]
1669 fn a_wasm_plugin_path_is_resolved_too() {
1670 let dir = std::env::temp_dir().join(format!("lc_resolve_wasm_{}", std::process::id()));
1673 std::fs::create_dir_all(&dir).unwrap();
1674 std::fs::write(
1675 dir.join(".languagecheck.yaml"),
1676 "engines:\n wasm_plugins:\n - name: p\n path: plugins/p.wasm\n",
1677 )
1678 .unwrap();
1679
1680 let config = Config::load(&dir).expect("config");
1681 let resolved = &config.engines.wasm_plugins[0].path;
1682 assert!(
1683 Path::new(resolved).is_absolute(),
1684 "left relative: {resolved}"
1685 );
1686 assert!(
1688 resolved.replace('\\', "/").ends_with("plugins/p.wasm"),
1689 "{resolved}"
1690 );
1691
1692 std::fs::remove_dir_all(&dir).ok();
1693 }
1694
1695 #[test]
1696 fn a_relative_proselint_config_is_resolved_too() {
1697 let dir = std::env::temp_dir().join(format!("lc_resolve_pl_{}", std::process::id()));
1699 std::fs::create_dir_all(&dir).unwrap();
1700 std::fs::write(
1701 dir.join(".languagecheck.yaml"),
1702 "engines:\n proselint:\n enabled: true\n config: \"proselint.json\"\n",
1703 )
1704 .unwrap();
1705
1706 let config = Config::load(&dir).expect("config");
1707 let resolved = config.engines.proselint.config.expect("a config path");
1708 assert!(
1709 Path::new(&resolved).is_absolute(),
1710 "left relative: {resolved}"
1711 );
1712 assert!(resolved.ends_with("proselint.json"), "{resolved}");
1713
1714 std::fs::remove_dir_all(&dir).ok();
1715 }
1716
1717 #[test]
1718 fn an_external_command_written_as_a_path_is_resolved() {
1719 let dir = std::env::temp_dir().join(format!("lc_resolve_ext_{}", std::process::id()));
1720 std::fs::create_dir_all(&dir).unwrap();
1721 std::fs::write(
1722 dir.join(".languagecheck.yaml"),
1723 "engines:\n external:\n - name: c\n command: ./my-checker\n",
1724 )
1725 .unwrap();
1726
1727 let config = Config::load(&dir).expect("config");
1728 let command = &config.engines.external[0].command;
1729 assert!(Path::new(command).is_absolute(), "left relative: {command}");
1730 assert!(command.ends_with("my-checker"), "{command}");
1731
1732 std::fs::remove_dir_all(&dir).ok();
1733 }
1734
1735 #[test]
1736 fn an_external_command_that_is_a_bare_name_is_left_for_path_lookup() {
1737 let dir = std::env::temp_dir().join(format!("lc_resolve_bare_{}", std::process::id()));
1740 std::fs::create_dir_all(&dir).unwrap();
1741 std::fs::write(
1742 dir.join(".languagecheck.yaml"),
1743 "engines:\n external:\n - name: c\n command: my-checker\n",
1744 )
1745 .unwrap();
1746
1747 let config = Config::load(&dir).expect("config");
1748 assert_eq!(config.engines.external[0].command, "my-checker");
1749
1750 std::fs::remove_dir_all(&dir).ok();
1751 }
1752
1753 #[test]
1754 fn auto_fix_simple_replacement() {
1755 let config = Config {
1756 auto_fix: vec![AutoFixRule {
1757 find: "teh".to_string(),
1758 replace: "the".to_string(),
1759 context: None,
1760 description: None,
1761 }],
1762 ..Config::default()
1763 };
1764 let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
1765 assert_eq!(result, "Fix the typo in the text.");
1766 assert_eq!(count, 2);
1767 }
1768
1769 #[test]
1770 fn auto_fix_with_context_filter() {
1771 let config = Config {
1772 auto_fix: vec![AutoFixRule {
1773 find: "colour".to_string(),
1774 replace: "color".to_string(),
1775 context: Some("American".to_string()),
1776 description: Some("Use American spelling".to_string()),
1777 }],
1778 ..Config::default()
1779 };
1780 let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
1782 assert_eq!(result, "American English: the color is red.");
1783 assert_eq!(count, 1);
1784
1785 let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
1787 assert_eq!(result, "British English: the colour is red.");
1788 assert_eq!(count, 0);
1789 }
1790
1791 #[test]
1792 fn auto_fix_no_match() {
1793 let config = Config {
1794 auto_fix: vec![AutoFixRule {
1795 find: "foo".to_string(),
1796 replace: "bar".to_string(),
1797 context: None,
1798 description: None,
1799 }],
1800 ..Config::default()
1801 };
1802 let (result, count) = config.apply_auto_fixes("No matches here.");
1803 assert_eq!(result, "No matches here.");
1804 assert_eq!(count, 0);
1805 }
1806
1807 #[test]
1808 fn auto_fix_multiple_rules() {
1809 let config = Config {
1810 auto_fix: vec![
1811 AutoFixRule {
1812 find: "recieve".to_string(),
1813 replace: "receive".to_string(),
1814 context: None,
1815 description: None,
1816 },
1817 AutoFixRule {
1818 find: "seperate".to_string(),
1819 replace: "separate".to_string(),
1820 context: None,
1821 description: None,
1822 },
1823 ],
1824 ..Config::default()
1825 };
1826 let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
1827 assert_eq!(result, "Please receive the separate package.");
1828 assert_eq!(count, 2);
1829 }
1830
1831 #[test]
1832 fn auto_fix_loads_from_yaml() {
1833 let yaml = r#"
1834auto_fix:
1835 - find: "teh"
1836 replace: "the"
1837 description: "Fix common typo"
1838 - find: "colour"
1839 replace: "color"
1840 context: "American"
1841"#;
1842 let config: Config = serde_yaml::from_str(yaml).unwrap();
1843 assert_eq!(config.auto_fix.len(), 2);
1844 assert_eq!(config.auto_fix[0].find, "teh");
1845 assert_eq!(config.auto_fix[0].replace, "the");
1846 assert_eq!(
1847 config.auto_fix[0].description.as_deref(),
1848 Some("Fix common typo")
1849 );
1850 assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
1851 }
1852
1853 #[test]
1854 fn default_config_has_empty_auto_fix() {
1855 let config = Config::default();
1856 assert!(config.auto_fix.is_empty());
1857 }
1858
1859 #[test]
1860 fn external_providers_from_yaml() {
1861 let yaml = r#"
1862engines:
1863 harper: true
1864 languagetool: false
1865 external:
1866 - name: vale
1867 command: /usr/bin/vale
1868 args: ["--output", "JSON"]
1869 extensions: [md, rst]
1870 - name: custom-checker
1871 command: ./my-checker
1872"#;
1873 let config: Config = serde_yaml::from_str(yaml).unwrap();
1874 assert_eq!(config.engines.external.len(), 2);
1875 assert_eq!(config.engines.external[0].name, "vale");
1876 assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
1877 assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
1878 assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
1879 assert_eq!(config.engines.external[1].name, "custom-checker");
1880 assert!(config.engines.external[1].args.is_empty());
1881 }
1882
1883 #[test]
1884 fn default_config_has_no_external_providers() {
1885 let config = Config::default();
1886 assert!(config.engines.external.is_empty());
1887 }
1888
1889 #[test]
1890 fn wasm_plugins_from_yaml() {
1891 let yaml = r#"
1892engines:
1893 harper: true
1894 wasm_plugins:
1895 - name: custom-checker
1896 path: .languagecheck/plugins/checker.wasm
1897 extensions: [md, html]
1898 - name: style-linter
1899 path: /opt/plugins/style.wasm
1900"#;
1901 let config: Config = serde_yaml::from_str(yaml).unwrap();
1902 assert_eq!(config.engines.wasm_plugins.len(), 2);
1903 assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
1904 assert_eq!(
1905 config.engines.wasm_plugins[0].path,
1906 ".languagecheck/plugins/checker.wasm"
1907 );
1908 assert_eq!(
1909 config.engines.wasm_plugins[0].extensions,
1910 vec!["md", "html"]
1911 );
1912 assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
1913 assert!(config.engines.wasm_plugins[1].extensions.is_empty());
1914 }
1915
1916 #[test]
1917 fn default_config_has_no_wasm_plugins() {
1918 let config = Config::default();
1919 assert!(config.engines.wasm_plugins.is_empty());
1920 }
1921
1922 #[test]
1923 fn performance_config_defaults() {
1924 let config = Config::default();
1925 assert!(!config.performance.high_performance_mode);
1926 assert_eq!(config.performance.debounce_ms, 500);
1927 assert_eq!(config.performance.max_file_size, 0);
1928 }
1929
1930 #[test]
1931 fn performance_config_from_yaml() {
1932 let yaml = r#"
1933performance:
1934 high_performance_mode: true
1935 debounce_ms: 500
1936 max_file_size: 1048576
1937"#;
1938 let config: Config = serde_yaml::from_str(yaml).unwrap();
1939 assert!(config.performance.high_performance_mode);
1940 assert_eq!(config.performance.debounce_ms, 500);
1941 assert_eq!(config.performance.max_file_size, 1_048_576);
1942 }
1943
1944 #[test]
1945 fn latex_skip_environments_from_yaml() {
1946 let yaml = r#"
1947languages:
1948 latex:
1949 skip_environments:
1950 - prooftree
1951 - mycustomenv
1952"#;
1953 let config: Config = serde_yaml::from_str(yaml).unwrap();
1954 assert_eq!(
1955 config.languages.latex.skip_environments,
1956 vec!["prooftree", "mycustomenv"]
1957 );
1958 }
1959
1960 #[test]
1961 fn default_config_has_empty_latex_skip_environments() {
1962 let config = Config::default();
1963 assert!(config.languages.latex.skip_environments.is_empty());
1964 }
1965
1966 #[test]
1967 fn latex_skip_commands_from_yaml() {
1968 let yaml = r#"
1969languages:
1970 latex:
1971 skip_commands:
1972 - codefont
1973 - myverb
1974"#;
1975 let config: Config = serde_yaml::from_str(yaml).unwrap();
1976 assert_eq!(
1977 config.languages.latex.skip_commands,
1978 vec!["codefont", "myverb"]
1979 );
1980 }
1981
1982 #[test]
1983 fn default_spell_language_is_en_us() {
1984 let config = Config::default();
1985 assert_eq!(config.engines.spell_language, "en-US");
1986 }
1987
1988 #[test]
1989 fn spell_language_from_yaml() {
1990 let yaml = r#"
1991engines:
1992 spell_language: de-DE
1993"#;
1994 let config: Config = serde_yaml::from_str(yaml).unwrap();
1995 assert_eq!(config.engines.spell_language, "de-DE");
1996 }
1997
1998 #[test]
1999 fn default_config_has_empty_latex_skip_commands() {
2000 let config = Config::default();
2001 assert!(config.languages.latex.skip_commands.is_empty());
2002 }
2003
2004 #[test]
2005 fn default_vale_is_disabled() {
2006 let config = Config::default();
2007 assert!(!config.engines.vale.enabled);
2008 assert!(config.engines.vale.config.is_none());
2009 }
2010
2011 #[test]
2012 fn vale_bool_shorthand_from_yaml() {
2013 let yaml = r#"
2014engines:
2015 vale: true
2016"#;
2017 let config: Config = serde_yaml::from_str(yaml).unwrap();
2018 assert!(config.engines.vale.enabled);
2019 }
2020
2021 #[test]
2022 fn vale_nested_config_from_yaml() {
2023 let yaml = r#"
2024engines:
2025 vale:
2026 enabled: true
2027 config: ".vale.ini"
2028"#;
2029 let config: Config = serde_yaml::from_str(yaml).unwrap();
2030 assert!(config.engines.vale.enabled);
2031 assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
2032 }
2033
2034 #[test]
2035 fn harper_nested_config_from_yaml() {
2036 let yaml = r#"
2037engines:
2038 harper:
2039 enabled: true
2040 dialect: "British"
2041 linters:
2042 LongSentences: false
2043"#;
2044 let config: Config = serde_yaml::from_str(yaml).unwrap();
2045 assert!(config.engines.harper.enabled);
2046 assert_eq!(config.engines.harper.dialect, "British");
2047 assert_eq!(
2048 config.engines.harper.linters.get("LongSentences"),
2049 Some(&false)
2050 );
2051 }
2052
2053 #[test]
2054 fn languagetool_nested_config_from_yaml() {
2055 let yaml = r#"
2056engines:
2057 languagetool:
2058 enabled: true
2059 url: "http://localhost:9090"
2060 level: "picky"
2061 disabled_rules:
2062 - WHITESPACE_RULE
2063"#;
2064 let config: Config = serde_yaml::from_str(yaml).unwrap();
2065 assert!(config.engines.languagetool.enabled);
2066 assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
2067 assert_eq!(config.engines.languagetool.level, "picky");
2068 assert_eq!(
2069 config.engines.languagetool.disabled_rules,
2070 vec!["WHITESPACE_RULE"]
2071 );
2072 assert_eq!(config.engines.languagetool.max_concurrent_requests, 8);
2073 }
2074
2075 #[test]
2078 fn legacy_flat_languagetool_url_is_honoured() {
2079 let yaml = r#"
2080engines:
2081 spell_language: fr
2082 proselint: false
2083 vale: false
2084 languagetool: true
2085 languagetool_url: "http://10.0.10.3:8003"
2086 harper: false
2087"#;
2088 let config: Config = serde_yaml::from_str(yaml).unwrap();
2089 assert!(config.engines.languagetool.enabled);
2090 assert_eq!(config.engines.languagetool.url, "http://10.0.10.3:8003");
2091 assert_eq!(config.engines.spell_language, "fr");
2092 assert!(!config.engines.harper.enabled);
2093 }
2094
2095 #[test]
2096 fn nested_languagetool_url_beats_the_legacy_key() {
2097 let yaml = r#"
2098engines:
2099 languagetool:
2100 enabled: true
2101 url: "http://nested:9090"
2102 languagetool_url: "http://flat:8003"
2103"#;
2104 let config: Config = serde_yaml::from_str(yaml).unwrap();
2105 assert_eq!(config.engines.languagetool.url, "http://nested:9090");
2106 }
2107
2108 #[test]
2109 fn legacy_flat_vale_config_is_honoured() {
2110 let yaml = "engines:\n vale: true\n vale_config: \"config/.vale.ini\"\n";
2111 let config: Config = serde_yaml::from_str(yaml).unwrap();
2112 assert!(config.engines.vale.enabled);
2113 assert_eq!(
2114 config.engines.vale.config.as_deref(),
2115 Some("config/.vale.ini")
2116 );
2117 }
2118
2119 #[test]
2120 fn unknown_keys_are_reported() {
2121 let value: serde_yaml::Value =
2122 serde_yaml::from_str("engines:\n languagetol: true\n harper: true\nrulez: {}\n")
2123 .unwrap();
2124 assert_eq!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS), vec!["rulez"]);
2125 assert_eq!(
2126 unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS),
2127 vec!["languagetol"]
2128 );
2129 }
2130
2131 #[test]
2132 fn recognised_keys_are_not_reported() {
2133 let value: serde_yaml::Value = serde_yaml::from_str(
2134 "engines:\n languagetool_url: \"http://x:1\"\n harper: true\nrules: {}\n",
2135 )
2136 .unwrap();
2137 assert!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS).is_empty());
2138 assert!(unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS).is_empty());
2139 }
2140
2141 #[test]
2142 fn languagetool_concurrency_can_be_pinned_to_serial() {
2143 let yaml = r"
2145engines:
2146 languagetool:
2147 enabled: true
2148 max_concurrent_requests: 1
2149";
2150 let config: Config = serde_yaml::from_str(yaml).unwrap();
2151 assert_eq!(config.engines.languagetool.max_concurrent_requests, 1);
2152 }
2153
2154 #[test]
2155 fn default_proselint_is_disabled() {
2156 let config = Config::default();
2157 assert!(!config.engines.proselint.enabled);
2158 assert!(config.engines.proselint.config.is_none());
2159 }
2160
2161 #[test]
2162 fn proselint_bool_shorthand_from_yaml() {
2163 let yaml = r#"
2164engines:
2165 proselint: true
2166"#;
2167 let config: Config = serde_yaml::from_str(yaml).unwrap();
2168 assert!(config.engines.proselint.enabled);
2169 }
2170
2171 #[test]
2172 fn proselint_nested_config_from_yaml() {
2173 let yaml = r#"
2174engines:
2175 proselint:
2176 enabled: true
2177 config: "proselint.json"
2178"#;
2179 let config: Config = serde_yaml::from_str(yaml).unwrap();
2180 assert!(config.engines.proselint.enabled);
2181 assert_eq!(
2182 config.engines.proselint.config.as_deref(),
2183 Some("proselint.json")
2184 );
2185 }
2186}