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 = "default_exclude")]
14 pub exclude: Vec<String>,
15 #[serde(default)]
16 pub auto_fix: Vec<AutoFixRule>,
17 #[serde(default)]
18 pub performance: PerformanceConfig,
19 #[serde(default)]
20 pub dictionaries: DictionaryConfig,
21 #[serde(default)]
22 pub languages: LanguageConfig,
23 #[serde(default)]
24 pub workspace: WorkspaceConfig,
25 #[serde(default)]
26 pub names: NameConfig,
27 #[serde(default)]
28 pub morphology: MorphologyConfig,
29}
30
31#[derive(Debug, Serialize, Deserialize, Clone, Default)]
42pub struct NameConfig {
43 #[serde(default)]
45 pub enabled: bool,
46 #[serde(default)]
49 pub aggressiveness: crate::names::Aggressiveness,
50}
51
52#[derive(Debug, Serialize, Deserialize, Clone)]
65pub struct MorphologyConfig {
66 #[serde(default = "default_true")]
68 pub enabled: bool,
69 #[serde(default = "default_true")]
71 pub inflections: bool,
72}
73
74impl Default for MorphologyConfig {
75 fn default() -> Self {
76 Self {
77 enabled: true,
78 inflections: true,
79 }
80 }
81}
82
83#[derive(Debug, Serialize, Deserialize, Clone, Default)]
96pub struct LanguageConfig {
97 #[serde(default)]
99 pub extensions: HashMap<String, Vec<String>>,
100 #[serde(default)]
102 pub latex: LaTeXConfig,
103}
104
105#[derive(Debug, Serialize, Deserialize, Clone, Default)]
115pub struct LaTeXConfig {
116 #[serde(default)]
119 pub skip_environments: Vec<String>,
120 #[serde(default)]
124 pub skip_commands: Vec<String>,
125}
126
127#[derive(Debug, Serialize, Deserialize, Clone, Default)]
134pub struct WorkspaceConfig {
135 #[serde(default)]
138 pub index_on_open: bool,
139 #[serde(default)]
142 pub db_path: Option<String>,
143}
144
145#[derive(Debug, Serialize, Deserialize, Clone)]
148pub struct PerformanceConfig {
149 #[serde(default)]
151 pub high_performance_mode: bool,
152 #[serde(default = "default_debounce_ms")]
154 pub debounce_ms: u64,
155 #[serde(default)]
157 pub max_file_size: usize,
158}
159
160impl Default for PerformanceConfig {
161 fn default() -> Self {
162 Self {
163 high_performance_mode: false,
164 debounce_ms: 300,
165 max_file_size: 0,
166 }
167 }
168}
169
170const fn default_debounce_ms() -> u64 {
171 300
172}
173
174#[derive(Debug, Serialize, Deserialize, Clone)]
176pub struct DictionaryConfig {
177 #[serde(default = "default_true")]
180 pub bundled: bool,
181 #[serde(default)]
185 pub disabled: Vec<String>,
186 #[serde(default)]
189 pub paths: Vec<String>,
190}
191
192impl Default for DictionaryConfig {
193 fn default() -> Self {
194 Self {
195 bundled: true,
196 disabled: Vec::new(),
197 paths: Vec::new(),
198 }
199 }
200}
201
202#[derive(Debug, Serialize, Deserialize, Clone)]
204pub struct AutoFixRule {
205 pub find: String,
207 pub replace: String,
209 #[serde(default)]
211 pub context: Option<String>,
212 #[serde(default)]
214 pub description: Option<String>,
215}
216
217#[derive(Debug, Serialize, Deserialize, Clone)]
218#[serde(from = "EngineConfigWire")]
219pub struct EngineConfig {
220 pub harper: HarperConfig,
221 pub languagetool: LanguageToolConfig,
222 pub vale: ValeConfig,
223 pub proselint: ProselintConfig,
224 pub external: Vec<ExternalProvider>,
226 pub wasm_plugins: Vec<WasmPlugin>,
228 pub spell_language: String,
230}
231
232#[derive(Deserialize)]
243struct EngineConfigWire {
244 #[serde(
245 default = "default_harper_config",
246 deserialize_with = "deser_engine_or_bool"
247 )]
248 harper: HarperConfig,
249 #[serde(default, deserialize_with = "deser_engine_or_bool")]
250 languagetool: LanguageToolConfig,
251 #[serde(default, deserialize_with = "deser_engine_or_bool")]
252 vale: ValeConfig,
253 #[serde(default, deserialize_with = "deser_engine_or_bool")]
254 proselint: ProselintConfig,
255 #[serde(default)]
256 external: Vec<ExternalProvider>,
257 #[serde(default)]
258 wasm_plugins: Vec<WasmPlugin>,
259 #[serde(default = "default_spell_language")]
260 spell_language: String,
261 #[serde(default)]
263 languagetool_url: Option<String>,
264 #[serde(default)]
266 vale_config: Option<String>,
267}
268
269impl From<EngineConfigWire> for EngineConfig {
270 fn from(wire: EngineConfigWire) -> Self {
271 let EngineConfigWire {
272 harper,
273 mut languagetool,
274 mut vale,
275 proselint,
276 external,
277 wasm_plugins,
278 spell_language,
279 languagetool_url,
280 vale_config,
281 } = wire;
282
283 if let Some(url) = languagetool_url {
286 if languagetool.url == default_lt_url() {
287 warn_deprecated_engine_key("engines.languagetool_url", "engines.languagetool.url");
288 languagetool.url = url;
289 } else {
290 warn_ignored_engine_key("engines.languagetool_url", "engines.languagetool.url");
291 }
292 }
293 if let Some(path) = vale_config {
294 if vale.config.is_none() {
295 warn_deprecated_engine_key("engines.vale_config", "engines.vale.config");
296 vale.config = Some(path);
297 } else {
298 warn_ignored_engine_key("engines.vale_config", "engines.vale.config");
299 }
300 }
301
302 Self {
303 harper,
304 languagetool,
305 vale,
306 proselint,
307 external,
308 wasm_plugins,
309 spell_language,
310 }
311 }
312}
313
314fn warn_deprecated_engine_key(old: &str, new: &str) {
316 warn!(
317 "`{old}` is deprecated and will be removed in a future release; \
318 rename it to `{new}`. Honouring it for now."
319 );
320}
321
322fn warn_ignored_engine_key(old: &str, new: &str) {
324 warn!("`{old}` is ignored because `{new}` is also set; delete the deprecated key.");
325}
326
327fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
330where
331 D: serde::Deserializer<'de>,
332 T: Deserialize<'de> + EngineToggle + Default,
333{
334 #[derive(Deserialize)]
335 #[serde(untagged)]
336 enum BoolOrStruct<T> {
337 Bool(bool),
338 Struct(T),
339 }
340
341 match BoolOrStruct::deserialize(deserializer)? {
342 BoolOrStruct::Bool(b) => {
343 let mut cfg = T::default();
344 cfg.set_enabled(b);
345 Ok(cfg)
346 }
347 BoolOrStruct::Struct(s) => Ok(s),
348 }
349}
350
351pub trait EngineToggle {
353 fn enabled(&self) -> bool;
354 fn set_enabled(&mut self, v: bool);
355}
356
357#[derive(Debug, Serialize, Deserialize, Clone)]
359pub struct HarperConfig {
360 #[serde(default = "default_true")]
361 pub enabled: bool,
362 #[serde(default = "default_dialect")]
364 pub dialect: String,
365 #[serde(default)]
368 pub linters: HashMap<String, bool>,
369}
370
371impl Default for HarperConfig {
372 fn default() -> Self {
373 Self {
374 enabled: true,
375 dialect: "American".to_string(),
376 linters: HashMap::new(),
377 }
378 }
379}
380
381fn default_harper_config() -> HarperConfig {
382 HarperConfig::default()
383}
384
385fn default_dialect() -> String {
386 "American".to_string()
387}
388
389impl EngineToggle for HarperConfig {
390 fn enabled(&self) -> bool {
391 self.enabled
392 }
393 fn set_enabled(&mut self, v: bool) {
394 self.enabled = v;
395 }
396}
397
398#[derive(Debug, Serialize, Deserialize, Clone)]
400pub struct LanguageToolConfig {
401 #[serde(default)]
402 pub enabled: bool,
403 #[serde(default = "default_lt_url")]
405 pub url: String,
406 #[serde(default = "default_lt_level")]
408 pub level: String,
409 #[serde(default)]
411 pub mother_tongue: Option<String>,
412 #[serde(default)]
414 pub disabled_rules: Vec<String>,
415 #[serde(default)]
417 pub enabled_rules: Vec<String>,
418 #[serde(default)]
420 pub disabled_categories: Vec<String>,
421 #[serde(default)]
423 pub enabled_categories: Vec<String>,
424 #[serde(default = "default_lt_max_concurrent_requests")]
431 pub max_concurrent_requests: usize,
432}
433
434impl Default for LanguageToolConfig {
435 fn default() -> Self {
436 Self {
437 enabled: false,
438 url: default_lt_url(),
439 level: "default".to_string(),
440 mother_tongue: None,
441 disabled_rules: Vec::new(),
442 enabled_rules: Vec::new(),
443 disabled_categories: Vec::new(),
444 enabled_categories: Vec::new(),
445 max_concurrent_requests: default_lt_max_concurrent_requests(),
446 }
447 }
448}
449
450fn default_lt_level() -> String {
451 "default".to_string()
452}
453
454const fn default_lt_max_concurrent_requests() -> usize {
457 8
458}
459
460impl EngineToggle for LanguageToolConfig {
461 fn enabled(&self) -> bool {
462 self.enabled
463 }
464 fn set_enabled(&mut self, v: bool) {
465 self.enabled = v;
466 }
467}
468
469#[derive(Debug, Default, Serialize, Deserialize, Clone)]
471pub struct ValeConfig {
472 #[serde(default)]
473 pub enabled: bool,
474 #[serde(default)]
476 pub config: Option<String>,
477}
478
479impl EngineToggle for ValeConfig {
480 fn enabled(&self) -> bool {
481 self.enabled
482 }
483 fn set_enabled(&mut self, v: bool) {
484 self.enabled = v;
485 }
486}
487
488#[derive(Debug, Default, Serialize, Deserialize, Clone)]
490pub struct ProselintConfig {
491 #[serde(default)]
492 pub enabled: bool,
493 #[serde(default)]
495 pub config: Option<String>,
496}
497
498impl EngineToggle for ProselintConfig {
499 fn enabled(&self) -> bool {
500 self.enabled
501 }
502 fn set_enabled(&mut self, v: bool) {
503 self.enabled = v;
504 }
505}
506
507#[derive(Debug, Serialize, Deserialize, Clone)]
512pub struct ExternalProvider {
513 pub name: String,
515 pub command: String,
517 #[serde(default)]
519 pub args: Vec<String>,
520 #[serde(default)]
522 pub extensions: Vec<String>,
523}
524
525#[derive(Debug, Serialize, Deserialize, Clone)]
530pub struct WasmPlugin {
531 pub name: String,
533 pub path: String,
535 #[serde(default)]
537 pub extensions: Vec<String>,
538}
539
540impl Default for EngineConfig {
541 fn default() -> Self {
542 Self {
543 harper: HarperConfig::default(),
544 languagetool: LanguageToolConfig::default(),
545 vale: ValeConfig::default(),
546 proselint: ProselintConfig::default(),
547 external: Vec::new(),
548 wasm_plugins: Vec::new(),
549 spell_language: default_spell_language(),
550 }
551 }
552}
553
554#[derive(Debug, Serialize, Deserialize, Clone)]
555pub struct RuleConfig {
556 pub severity: Option<String>, }
558
559const fn default_true() -> bool {
560 true
561}
562fn default_lt_url() -> String {
563 "http://localhost:8010".to_string()
564}
565fn default_spell_language() -> String {
566 "en-US".to_string()
567}
568fn default_exclude() -> Vec<String> {
569 vec![
570 "node_modules/**".to_string(),
571 ".git/**".to_string(),
572 "target/**".to_string(),
573 "dist/**".to_string(),
574 "build/**".to_string(),
575 ".next/**".to_string(),
576 ".nuxt/**".to_string(),
577 "vendor/**".to_string(),
578 "__pycache__/**".to_string(),
579 ".venv/**".to_string(),
580 "venv/**".to_string(),
581 ".tox/**".to_string(),
582 ".mypy_cache/**".to_string(),
583 "*.min.js".to_string(),
584 "*.min.css".to_string(),
585 "*.bundle.js".to_string(),
586 "package-lock.json".to_string(),
587 "yarn.lock".to_string(),
588 "pnpm-lock.yaml".to_string(),
589 ]
590}
591
592impl Config {
593 #[must_use]
603 pub fn load_or_warn(workspace_root: &Path) -> Self {
604 Self::load(workspace_root).unwrap_or_else(|e| {
605 warn!(
606 root = %workspace_root.display(),
607 "Ignoring unreadable workspace config, using defaults: {e}"
608 );
609 Self::default()
610 })
611 }
612
613 pub fn load(workspace_root: &Path) -> Result<Self> {
614 let yaml_path = workspace_root.join(".languagecheck.yaml");
616 let yml_path = workspace_root.join(".languagecheck.yml");
617 let json_path = workspace_root.join(".languagecheck.json");
618
619 if yaml_path.exists() {
620 let content = std::fs::read_to_string(yaml_path)?;
621 warn_duplicate_rule_keys(&content);
622 let config: Self = serde_yaml::from_str(&content)?;
623 warn_unknown_keys(&serde_yaml::from_str(&content)?);
624 Ok(config)
625 } else if yml_path.exists() {
626 let content = std::fs::read_to_string(yml_path)?;
627 warn_duplicate_rule_keys(&content);
628 let config: Self = serde_yaml::from_str(&content)?;
629 warn_unknown_keys(&serde_yaml::from_str(&content)?);
630 Ok(config)
631 } else if json_path.exists() {
632 let content = std::fs::read_to_string(json_path)?;
633 let config: Self = serde_json::from_str(&content)?;
634 warn_unknown_keys(&serde_yaml::from_str(&content)?);
636 Ok(config)
637 } else {
638 Ok(Self::default())
639 }
640 }
641
642 #[must_use]
645 pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
646 let mut result = text.to_string();
647 let mut total = 0;
648
649 for rule in &self.auto_fix {
650 if let Some(ctx) = &rule.context
651 && !result.contains(ctx.as_str())
652 {
653 continue;
654 }
655 let count = result.matches(&rule.find).count();
656 if count > 0 {
657 result = result.replace(&rule.find, &rule.replace);
658 total += count;
659 }
660 }
661
662 (result, total)
663 }
664}
665
666fn duplicate_rule_keys(content: &str) -> Vec<String> {
674 let mut in_rules = false;
675 let mut child_indent: Option<usize> = None;
676 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
677 let mut duplicates: Vec<String> = Vec::new();
678
679 for line in content.lines() {
680 if line.trim().is_empty() {
681 continue;
682 }
683 let indent = line.len() - line.trim_start().len();
684
685 if !in_rules {
686 if indent == 0 && line.trim() == "rules:" {
687 in_rules = true;
688 }
689 continue;
690 }
691
692 if indent == 0 {
694 break;
695 }
696
697 let child = *child_indent.get_or_insert(indent);
698 if indent != child {
699 continue; }
701 if let Some(key) = line.trim().strip_suffix(':') {
702 let key = key.trim().to_string();
703 if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
704 duplicates.push(key);
705 }
706 }
707 }
708
709 duplicates
710}
711
712const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
714 "engines",
715 "rules",
716 "exclude",
717 "auto_fix",
718 "performance",
719 "dictionaries",
720 "languages",
721 "workspace",
722 "names",
723 "morphology",
724];
725
726const KNOWN_ENGINE_KEYS: &[&str] = &[
728 "harper",
729 "languagetool",
730 "vale",
731 "proselint",
732 "external",
733 "wasm_plugins",
734 "spell_language",
735 "languagetool_url",
736 "vale_config",
737];
738
739fn unknown_keys(value: &serde_yaml::Value, known: &[&str]) -> Vec<String> {
741 let Some(map) = value.as_mapping() else {
742 return Vec::new();
743 };
744 map.keys()
745 .filter_map(serde_yaml::Value::as_str)
746 .filter(|k| !known.contains(k))
747 .map(ToString::to_string)
748 .collect()
749}
750
751fn warn_unknown_keys(value: &serde_yaml::Value) {
758 let unknown = unknown_keys(value, KNOWN_TOP_LEVEL_KEYS);
759 if !unknown.is_empty() {
760 warn!(keys = ?unknown, "Unknown keys in workspace config; they have no effect.");
761 }
762 if let Some(engines) = value.get("engines") {
763 let unknown = unknown_keys(engines, KNOWN_ENGINE_KEYS);
764 if !unknown.is_empty() {
765 warn!(keys = ?unknown, "Unknown keys under `engines:`; they have no effect.");
766 }
767 }
768}
769
770fn warn_duplicate_rule_keys(content: &str) {
772 let duplicates = duplicate_rule_keys(content);
773 if !duplicates.is_empty() {
774 warn!(
775 duplicates = ?duplicates,
776 "Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
777 effect. Remove the extra copies to keep the ignore list clean."
778 );
779 }
780}
781
782impl Default for Config {
783 fn default() -> Self {
784 Self {
785 engines: EngineConfig::default(),
786 rules: HashMap::new(),
787 exclude: default_exclude(),
788 auto_fix: Vec::new(),
789 performance: PerformanceConfig::default(),
790 dictionaries: DictionaryConfig::default(),
791 languages: LanguageConfig::default(),
792 workspace: WorkspaceConfig::default(),
793 names: NameConfig::default(),
794 morphology: MorphologyConfig::default(),
795 }
796 }
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802
803 #[test]
804 fn duplicate_rule_keys_detects_repeats() {
805 let yaml = "rules:\n languagetool.ARROWS:\n severity: \"off\"\n \
806 languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
807 languagetool.ARROWS:\n severity: \"off\"\n \
808 languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
809 languagetool.THE_SUPERLATIVE:\n severity: \"off\"\n";
810 let dups = duplicate_rule_keys(yaml);
811 assert_eq!(
812 dups,
813 vec![
814 "languagetool.ARROWS".to_string(),
815 "languagetool.UPPERCASE_SENTENCE_START".to_string()
816 ]
817 );
818 }
819
820 #[test]
821 fn duplicate_rule_keys_clean_list_is_empty() {
822 let yaml = "rules:\n a.B:\n severity: \"off\"\n c.D:\n severity: \"off\"\n";
823 assert!(duplicate_rule_keys(yaml).is_empty());
824 }
825
826 #[test]
827 fn duplicate_rule_keys_stops_at_next_section() {
828 let yaml = "rules:\n a.B:\n severity: \"off\"\nengines:\n harper: false\n";
830 assert!(duplicate_rule_keys(yaml).is_empty());
831 }
832
833 #[test]
834 fn morphology_is_on_by_default() {
835 let config = Config::default();
836 assert!(config.morphology.enabled);
837 assert!(config.morphology.inflections);
838 }
839
840 #[test]
841 fn morphology_can_be_switched_off_from_yaml() {
842 let yaml = "morphology:\n enabled: false\n";
843 let config: Config = serde_yaml::from_str(yaml).unwrap();
844 assert!(!config.morphology.enabled);
845 assert!(config.morphology.inflections);
847 }
848
849 #[test]
850 fn default_dictionaries_load_all_bundled_sets() {
851 let config = Config::default();
852 assert!(config.dictionaries.bundled);
853 assert!(config.dictionaries.disabled.is_empty());
854 assert!(config.dictionaries.paths.is_empty());
855 }
856
857 #[test]
858 fn dictionaries_disabled_from_yaml() {
859 let config: Config = serde_yaml::from_str(
860 r"
861dictionaries:
862 disabled: [companies, mathematics]
863",
864 )
865 .unwrap();
866 assert_eq!(config.dictionaries.disabled, ["companies", "mathematics"]);
867 assert!(config.dictionaries.bundled);
869 }
870
871 #[test]
872 fn default_config_has_harper_enabled_lt_disabled() {
873 let config = Config::default();
874 assert!(config.engines.harper.enabled);
875 assert!(!config.engines.languagetool.enabled);
876 }
877
878 #[test]
879 fn default_config_has_standard_excludes() {
880 let config = Config::default();
881 assert!(config.exclude.contains(&"node_modules/**".to_string()));
882 assert!(config.exclude.contains(&".git/**".to_string()));
883 assert!(config.exclude.contains(&"target/**".to_string()));
884 assert!(config.exclude.contains(&"dist/**".to_string()));
885 assert!(config.exclude.contains(&"vendor/**".to_string()));
886 }
887
888 #[test]
889 fn default_lt_url() {
890 let config = Config::default();
891 assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
892 }
893
894 #[test]
895 fn load_from_json_string() {
896 let json = r#"{
897 "engines": { "harper": true, "languagetool": false },
898 "rules": { "spelling.typo": { "severity": "warning" } }
899 }"#;
900 let config: Config = serde_json::from_str(json).unwrap();
901 assert!(config.engines.harper.enabled);
902 assert!(!config.engines.languagetool.enabled);
903 assert!(config.rules.contains_key("spelling.typo"));
904 assert_eq!(
905 config.rules["spelling.typo"].severity.as_deref(),
906 Some("warning")
907 );
908 }
909
910 #[test]
911 fn load_partial_json_uses_defaults() {
912 let json = r#"{}"#;
913 let config: Config = serde_json::from_str(json).unwrap();
914 assert!(config.engines.harper.enabled);
915 assert!(!config.engines.languagetool.enabled);
916 assert!(config.rules.is_empty());
917 }
918
919 #[test]
920 fn load_from_json_file() {
921 let dir = std::env::temp_dir().join("lang_check_test_config_json");
922 let _ = std::fs::remove_dir_all(&dir);
923 std::fs::create_dir_all(&dir).unwrap();
924
925 let config_path = dir.join(".languagecheck.json");
926 std::fs::write(
927 &config_path,
928 r#"{"engines": {"harper": false, "languagetool": true}}"#,
929 )
930 .unwrap();
931
932 let config = Config::load(&dir).unwrap();
933 assert!(!config.engines.harper.enabled);
934 assert!(config.engines.languagetool.enabled);
935
936 let _ = std::fs::remove_dir_all(&dir);
937 }
938
939 #[test]
940 fn load_from_yaml_file() {
941 let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
942 let _ = std::fs::remove_dir_all(&dir);
943 std::fs::create_dir_all(&dir).unwrap();
944
945 let config_path = dir.join(".languagecheck.yaml");
946 std::fs::write(
947 &config_path,
948 "engines:\n harper: false\n languagetool: true\n",
949 )
950 .unwrap();
951
952 let config = Config::load(&dir).unwrap();
953 assert!(!config.engines.harper.enabled);
954 assert!(config.engines.languagetool.enabled);
955
956 let _ = std::fs::remove_dir_all(&dir);
957 }
958
959 #[test]
960 fn yaml_takes_precedence_over_json() {
961 let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
962 let _ = std::fs::remove_dir_all(&dir);
963 std::fs::create_dir_all(&dir).unwrap();
964
965 std::fs::write(
967 dir.join(".languagecheck.yaml"),
968 "engines:\n harper: false\n",
969 )
970 .unwrap();
971 std::fs::write(
972 dir.join(".languagecheck.json"),
973 r#"{"engines": {"harper": true}}"#,
974 )
975 .unwrap();
976
977 let config = Config::load(&dir).unwrap();
978 assert!(!config.engines.harper.enabled);
980
981 let _ = std::fs::remove_dir_all(&dir);
982 }
983
984 #[test]
985 fn load_missing_file_returns_default() {
986 let dir = std::env::temp_dir().join("lang_check_test_config_missing");
987 let _ = std::fs::remove_dir_all(&dir);
988 std::fs::create_dir_all(&dir).unwrap();
989
990 let config = Config::load(&dir).unwrap();
991 assert!(config.engines.harper.enabled);
992
993 let _ = std::fs::remove_dir_all(&dir);
994 }
995
996 #[test]
997 fn auto_fix_simple_replacement() {
998 let config = Config {
999 auto_fix: vec![AutoFixRule {
1000 find: "teh".to_string(),
1001 replace: "the".to_string(),
1002 context: None,
1003 description: None,
1004 }],
1005 ..Config::default()
1006 };
1007 let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
1008 assert_eq!(result, "Fix the typo in the text.");
1009 assert_eq!(count, 2);
1010 }
1011
1012 #[test]
1013 fn auto_fix_with_context_filter() {
1014 let config = Config {
1015 auto_fix: vec![AutoFixRule {
1016 find: "colour".to_string(),
1017 replace: "color".to_string(),
1018 context: Some("American".to_string()),
1019 description: Some("Use American spelling".to_string()),
1020 }],
1021 ..Config::default()
1022 };
1023 let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
1025 assert_eq!(result, "American English: the color is red.");
1026 assert_eq!(count, 1);
1027
1028 let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
1030 assert_eq!(result, "British English: the colour is red.");
1031 assert_eq!(count, 0);
1032 }
1033
1034 #[test]
1035 fn auto_fix_no_match() {
1036 let config = Config {
1037 auto_fix: vec![AutoFixRule {
1038 find: "foo".to_string(),
1039 replace: "bar".to_string(),
1040 context: None,
1041 description: None,
1042 }],
1043 ..Config::default()
1044 };
1045 let (result, count) = config.apply_auto_fixes("No matches here.");
1046 assert_eq!(result, "No matches here.");
1047 assert_eq!(count, 0);
1048 }
1049
1050 #[test]
1051 fn auto_fix_multiple_rules() {
1052 let config = Config {
1053 auto_fix: vec![
1054 AutoFixRule {
1055 find: "recieve".to_string(),
1056 replace: "receive".to_string(),
1057 context: None,
1058 description: None,
1059 },
1060 AutoFixRule {
1061 find: "seperate".to_string(),
1062 replace: "separate".to_string(),
1063 context: None,
1064 description: None,
1065 },
1066 ],
1067 ..Config::default()
1068 };
1069 let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
1070 assert_eq!(result, "Please receive the separate package.");
1071 assert_eq!(count, 2);
1072 }
1073
1074 #[test]
1075 fn auto_fix_loads_from_yaml() {
1076 let yaml = r#"
1077auto_fix:
1078 - find: "teh"
1079 replace: "the"
1080 description: "Fix common typo"
1081 - find: "colour"
1082 replace: "color"
1083 context: "American"
1084"#;
1085 let config: Config = serde_yaml::from_str(yaml).unwrap();
1086 assert_eq!(config.auto_fix.len(), 2);
1087 assert_eq!(config.auto_fix[0].find, "teh");
1088 assert_eq!(config.auto_fix[0].replace, "the");
1089 assert_eq!(
1090 config.auto_fix[0].description.as_deref(),
1091 Some("Fix common typo")
1092 );
1093 assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
1094 }
1095
1096 #[test]
1097 fn default_config_has_empty_auto_fix() {
1098 let config = Config::default();
1099 assert!(config.auto_fix.is_empty());
1100 }
1101
1102 #[test]
1103 fn external_providers_from_yaml() {
1104 let yaml = r#"
1105engines:
1106 harper: true
1107 languagetool: false
1108 external:
1109 - name: vale
1110 command: /usr/bin/vale
1111 args: ["--output", "JSON"]
1112 extensions: [md, rst]
1113 - name: custom-checker
1114 command: ./my-checker
1115"#;
1116 let config: Config = serde_yaml::from_str(yaml).unwrap();
1117 assert_eq!(config.engines.external.len(), 2);
1118 assert_eq!(config.engines.external[0].name, "vale");
1119 assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
1120 assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
1121 assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
1122 assert_eq!(config.engines.external[1].name, "custom-checker");
1123 assert!(config.engines.external[1].args.is_empty());
1124 }
1125
1126 #[test]
1127 fn default_config_has_no_external_providers() {
1128 let config = Config::default();
1129 assert!(config.engines.external.is_empty());
1130 }
1131
1132 #[test]
1133 fn wasm_plugins_from_yaml() {
1134 let yaml = r#"
1135engines:
1136 harper: true
1137 wasm_plugins:
1138 - name: custom-checker
1139 path: .languagecheck/plugins/checker.wasm
1140 extensions: [md, html]
1141 - name: style-linter
1142 path: /opt/plugins/style.wasm
1143"#;
1144 let config: Config = serde_yaml::from_str(yaml).unwrap();
1145 assert_eq!(config.engines.wasm_plugins.len(), 2);
1146 assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
1147 assert_eq!(
1148 config.engines.wasm_plugins[0].path,
1149 ".languagecheck/plugins/checker.wasm"
1150 );
1151 assert_eq!(
1152 config.engines.wasm_plugins[0].extensions,
1153 vec!["md", "html"]
1154 );
1155 assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
1156 assert!(config.engines.wasm_plugins[1].extensions.is_empty());
1157 }
1158
1159 #[test]
1160 fn default_config_has_no_wasm_plugins() {
1161 let config = Config::default();
1162 assert!(config.engines.wasm_plugins.is_empty());
1163 }
1164
1165 #[test]
1166 fn performance_config_defaults() {
1167 let config = Config::default();
1168 assert!(!config.performance.high_performance_mode);
1169 assert_eq!(config.performance.debounce_ms, 300);
1170 assert_eq!(config.performance.max_file_size, 0);
1171 }
1172
1173 #[test]
1174 fn performance_config_from_yaml() {
1175 let yaml = r#"
1176performance:
1177 high_performance_mode: true
1178 debounce_ms: 500
1179 max_file_size: 1048576
1180"#;
1181 let config: Config = serde_yaml::from_str(yaml).unwrap();
1182 assert!(config.performance.high_performance_mode);
1183 assert_eq!(config.performance.debounce_ms, 500);
1184 assert_eq!(config.performance.max_file_size, 1_048_576);
1185 }
1186
1187 #[test]
1188 fn latex_skip_environments_from_yaml() {
1189 let yaml = r#"
1190languages:
1191 latex:
1192 skip_environments:
1193 - prooftree
1194 - mycustomenv
1195"#;
1196 let config: Config = serde_yaml::from_str(yaml).unwrap();
1197 assert_eq!(
1198 config.languages.latex.skip_environments,
1199 vec!["prooftree", "mycustomenv"]
1200 );
1201 }
1202
1203 #[test]
1204 fn default_config_has_empty_latex_skip_environments() {
1205 let config = Config::default();
1206 assert!(config.languages.latex.skip_environments.is_empty());
1207 }
1208
1209 #[test]
1210 fn latex_skip_commands_from_yaml() {
1211 let yaml = r#"
1212languages:
1213 latex:
1214 skip_commands:
1215 - codefont
1216 - myverb
1217"#;
1218 let config: Config = serde_yaml::from_str(yaml).unwrap();
1219 assert_eq!(
1220 config.languages.latex.skip_commands,
1221 vec!["codefont", "myverb"]
1222 );
1223 }
1224
1225 #[test]
1226 fn default_spell_language_is_en_us() {
1227 let config = Config::default();
1228 assert_eq!(config.engines.spell_language, "en-US");
1229 }
1230
1231 #[test]
1232 fn spell_language_from_yaml() {
1233 let yaml = r#"
1234engines:
1235 spell_language: de-DE
1236"#;
1237 let config: Config = serde_yaml::from_str(yaml).unwrap();
1238 assert_eq!(config.engines.spell_language, "de-DE");
1239 }
1240
1241 #[test]
1242 fn default_config_has_empty_latex_skip_commands() {
1243 let config = Config::default();
1244 assert!(config.languages.latex.skip_commands.is_empty());
1245 }
1246
1247 #[test]
1248 fn default_vale_is_disabled() {
1249 let config = Config::default();
1250 assert!(!config.engines.vale.enabled);
1251 assert!(config.engines.vale.config.is_none());
1252 }
1253
1254 #[test]
1255 fn vale_bool_shorthand_from_yaml() {
1256 let yaml = r#"
1257engines:
1258 vale: true
1259"#;
1260 let config: Config = serde_yaml::from_str(yaml).unwrap();
1261 assert!(config.engines.vale.enabled);
1262 }
1263
1264 #[test]
1265 fn vale_nested_config_from_yaml() {
1266 let yaml = r#"
1267engines:
1268 vale:
1269 enabled: true
1270 config: ".vale.ini"
1271"#;
1272 let config: Config = serde_yaml::from_str(yaml).unwrap();
1273 assert!(config.engines.vale.enabled);
1274 assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
1275 }
1276
1277 #[test]
1278 fn harper_nested_config_from_yaml() {
1279 let yaml = r#"
1280engines:
1281 harper:
1282 enabled: true
1283 dialect: "British"
1284 linters:
1285 LongSentences: false
1286"#;
1287 let config: Config = serde_yaml::from_str(yaml).unwrap();
1288 assert!(config.engines.harper.enabled);
1289 assert_eq!(config.engines.harper.dialect, "British");
1290 assert_eq!(
1291 config.engines.harper.linters.get("LongSentences"),
1292 Some(&false)
1293 );
1294 }
1295
1296 #[test]
1297 fn languagetool_nested_config_from_yaml() {
1298 let yaml = r#"
1299engines:
1300 languagetool:
1301 enabled: true
1302 url: "http://localhost:9090"
1303 level: "picky"
1304 disabled_rules:
1305 - WHITESPACE_RULE
1306"#;
1307 let config: Config = serde_yaml::from_str(yaml).unwrap();
1308 assert!(config.engines.languagetool.enabled);
1309 assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
1310 assert_eq!(config.engines.languagetool.level, "picky");
1311 assert_eq!(
1312 config.engines.languagetool.disabled_rules,
1313 vec!["WHITESPACE_RULE"]
1314 );
1315 assert_eq!(config.engines.languagetool.max_concurrent_requests, 8);
1316 }
1317
1318 #[test]
1321 fn legacy_flat_languagetool_url_is_honoured() {
1322 let yaml = r#"
1323engines:
1324 spell_language: fr
1325 proselint: false
1326 vale: false
1327 languagetool: true
1328 languagetool_url: "http://10.0.10.3:8003"
1329 harper: false
1330"#;
1331 let config: Config = serde_yaml::from_str(yaml).unwrap();
1332 assert!(config.engines.languagetool.enabled);
1333 assert_eq!(config.engines.languagetool.url, "http://10.0.10.3:8003");
1334 assert_eq!(config.engines.spell_language, "fr");
1335 assert!(!config.engines.harper.enabled);
1336 }
1337
1338 #[test]
1339 fn nested_languagetool_url_beats_the_legacy_key() {
1340 let yaml = r#"
1341engines:
1342 languagetool:
1343 enabled: true
1344 url: "http://nested:9090"
1345 languagetool_url: "http://flat:8003"
1346"#;
1347 let config: Config = serde_yaml::from_str(yaml).unwrap();
1348 assert_eq!(config.engines.languagetool.url, "http://nested:9090");
1349 }
1350
1351 #[test]
1352 fn legacy_flat_vale_config_is_honoured() {
1353 let yaml = "engines:\n vale: true\n vale_config: \"config/.vale.ini\"\n";
1354 let config: Config = serde_yaml::from_str(yaml).unwrap();
1355 assert!(config.engines.vale.enabled);
1356 assert_eq!(
1357 config.engines.vale.config.as_deref(),
1358 Some("config/.vale.ini")
1359 );
1360 }
1361
1362 #[test]
1363 fn unknown_keys_are_reported() {
1364 let value: serde_yaml::Value =
1365 serde_yaml::from_str("engines:\n languagetol: true\n harper: true\nrulez: {}\n")
1366 .unwrap();
1367 assert_eq!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS), vec!["rulez"]);
1368 assert_eq!(
1369 unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS),
1370 vec!["languagetol"]
1371 );
1372 }
1373
1374 #[test]
1375 fn recognised_keys_are_not_reported() {
1376 let value: serde_yaml::Value = serde_yaml::from_str(
1377 "engines:\n languagetool_url: \"http://x:1\"\n harper: true\nrules: {}\n",
1378 )
1379 .unwrap();
1380 assert!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS).is_empty());
1381 assert!(unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS).is_empty());
1382 }
1383
1384 #[test]
1385 fn languagetool_concurrency_can_be_pinned_to_serial() {
1386 let yaml = r"
1388engines:
1389 languagetool:
1390 enabled: true
1391 max_concurrent_requests: 1
1392";
1393 let config: Config = serde_yaml::from_str(yaml).unwrap();
1394 assert_eq!(config.engines.languagetool.max_concurrent_requests, 1);
1395 }
1396
1397 #[test]
1398 fn default_proselint_is_disabled() {
1399 let config = Config::default();
1400 assert!(!config.engines.proselint.enabled);
1401 assert!(config.engines.proselint.config.is_none());
1402 }
1403
1404 #[test]
1405 fn proselint_bool_shorthand_from_yaml() {
1406 let yaml = r#"
1407engines:
1408 proselint: true
1409"#;
1410 let config: Config = serde_yaml::from_str(yaml).unwrap();
1411 assert!(config.engines.proselint.enabled);
1412 }
1413
1414 #[test]
1415 fn proselint_nested_config_from_yaml() {
1416 let yaml = r#"
1417engines:
1418 proselint:
1419 enabled: true
1420 config: "proselint.json"
1421"#;
1422 let config: Config = serde_yaml::from_str(yaml).unwrap();
1423 assert!(config.engines.proselint.enabled);
1424 assert_eq!(
1425 config.engines.proselint.config.as_deref(),
1426 Some("proselint.json")
1427 );
1428 }
1429}