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")]
157 pub debounce_ms: u64,
158 #[serde(default)]
160 pub max_file_size: usize,
161 #[serde(default = "default_result_cache_entries")]
167 pub result_cache_entries: usize,
168 #[serde(default = "default_max_range_bytes")]
176 pub max_range_bytes: usize,
177}
178
179impl Default for PerformanceConfig {
180 fn default() -> Self {
181 Self {
182 high_performance_mode: false,
183 debounce_ms: 500,
184 max_file_size: 0,
185 result_cache_entries: default_result_cache_entries(),
186 max_range_bytes: default_max_range_bytes(),
187 }
188 }
189}
190
191const fn default_debounce_ms() -> u64 {
194 500
195}
196
197const fn default_result_cache_entries() -> usize {
200 4096
201}
202
203const fn default_max_range_bytes() -> usize {
208 2048
209}
210
211#[derive(Debug, Serialize, Deserialize, Clone)]
213pub struct DictionaryConfig {
214 #[serde(default = "default_true")]
217 pub bundled: bool,
218 #[serde(default)]
222 pub disabled: Vec<String>,
223 #[serde(default)]
226 pub paths: Vec<String>,
227}
228
229impl Default for DictionaryConfig {
230 fn default() -> Self {
231 Self {
232 bundled: true,
233 disabled: Vec::new(),
234 paths: Vec::new(),
235 }
236 }
237}
238
239#[derive(Debug, Serialize, Deserialize, Clone)]
241pub struct AutoFixRule {
242 pub find: String,
244 pub replace: String,
246 #[serde(default)]
248 pub context: Option<String>,
249 #[serde(default)]
251 pub description: Option<String>,
252}
253
254#[derive(Debug, Serialize, Deserialize, Clone)]
255#[serde(from = "EngineConfigWire")]
256pub struct EngineConfig {
257 pub harper: HarperConfig,
258 pub languagetool: LanguageToolConfig,
259 pub vale: ValeConfig,
260 pub proselint: ProselintConfig,
261 pub hunspell: HunspellConfig,
262 pub external: Vec<ExternalProvider>,
264 pub wasm_plugins: Vec<WasmPlugin>,
266 pub spell_language: String,
268}
269
270#[derive(Deserialize)]
281struct EngineConfigWire {
282 #[serde(
283 default = "default_harper_config",
284 deserialize_with = "deser_engine_or_bool"
285 )]
286 harper: HarperConfig,
287 #[serde(default, deserialize_with = "deser_engine_or_bool")]
288 languagetool: LanguageToolConfig,
289 #[serde(default, deserialize_with = "deser_engine_or_bool")]
290 vale: ValeConfig,
291 #[serde(default, deserialize_with = "deser_engine_or_bool")]
292 proselint: ProselintConfig,
293 #[serde(default, deserialize_with = "deser_engine_or_bool")]
294 hunspell: HunspellConfig,
295 #[serde(default)]
296 external: Vec<ExternalProvider>,
297 #[serde(default)]
298 wasm_plugins: Vec<WasmPlugin>,
299 #[serde(default = "default_spell_language")]
300 spell_language: String,
301 #[serde(default)]
303 languagetool_url: Option<String>,
304 #[serde(default)]
306 vale_config: Option<String>,
307}
308
309impl From<EngineConfigWire> for EngineConfig {
310 fn from(wire: EngineConfigWire) -> Self {
311 let EngineConfigWire {
312 harper,
313 mut languagetool,
314 mut vale,
315 proselint,
316 hunspell,
317 external,
318 wasm_plugins,
319 spell_language,
320 languagetool_url,
321 vale_config,
322 } = wire;
323
324 if let Some(url) = languagetool_url {
327 if languagetool.url == default_lt_url() {
328 warn_deprecated_engine_key("engines.languagetool_url", "engines.languagetool.url");
329 languagetool.url = url;
330 } else {
331 warn_ignored_engine_key("engines.languagetool_url", "engines.languagetool.url");
332 }
333 }
334 if let Some(path) = vale_config {
335 if vale.config.is_none() {
336 warn_deprecated_engine_key("engines.vale_config", "engines.vale.config");
337 vale.config = Some(path);
338 } else {
339 warn_ignored_engine_key("engines.vale_config", "engines.vale.config");
340 }
341 }
342
343 Self {
344 harper,
345 languagetool,
346 vale,
347 proselint,
348 hunspell,
349 external,
350 wasm_plugins,
351 spell_language,
352 }
353 }
354}
355
356fn warn_deprecated_engine_key(old: &str, new: &str) {
358 warn!(
359 "`{old}` is deprecated and will be removed in a future release; \
360 rename it to `{new}`. Honouring it for now."
361 );
362}
363
364fn warn_ignored_engine_key(old: &str, new: &str) {
366 warn!("`{old}` is ignored because `{new}` is also set; delete the deprecated key.");
367}
368
369fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
372where
373 D: serde::Deserializer<'de>,
374 T: Deserialize<'de> + EngineToggle + Default,
375{
376 #[derive(Deserialize)]
377 #[serde(untagged)]
378 enum BoolOrStruct<T> {
379 Bool(bool),
380 Struct(T),
381 }
382
383 match BoolOrStruct::deserialize(deserializer)? {
384 BoolOrStruct::Bool(b) => {
385 let mut cfg = T::default();
386 cfg.set_enabled(b);
387 Ok(cfg)
388 }
389 BoolOrStruct::Struct(s) => Ok(s),
390 }
391}
392
393pub trait EngineToggle {
395 fn enabled(&self) -> bool;
396 fn set_enabled(&mut self, v: bool);
397}
398
399#[derive(Debug, Serialize, Deserialize, Clone)]
401pub struct HarperConfig {
402 #[serde(default = "default_true")]
403 pub enabled: bool,
404 #[serde(default = "default_dialect")]
406 pub dialect: String,
407 #[serde(default)]
410 pub linters: HashMap<String, bool>,
411}
412
413impl Default for HarperConfig {
414 fn default() -> Self {
415 Self {
416 enabled: true,
417 dialect: "American".to_string(),
418 linters: HashMap::new(),
419 }
420 }
421}
422
423fn default_harper_config() -> HarperConfig {
424 HarperConfig::default()
425}
426
427fn default_dialect() -> String {
428 "American".to_string()
429}
430
431impl EngineToggle for HarperConfig {
432 fn enabled(&self) -> bool {
433 self.enabled
434 }
435 fn set_enabled(&mut self, v: bool) {
436 self.enabled = v;
437 }
438}
439
440#[derive(Debug, Serialize, Deserialize, Clone)]
442pub struct LanguageToolConfig {
443 #[serde(default)]
444 pub enabled: bool,
445 #[serde(default = "default_lt_url")]
447 pub url: String,
448 #[serde(default = "default_lt_level")]
450 pub level: String,
451 #[serde(default)]
453 pub mother_tongue: Option<String>,
454 #[serde(default)]
456 pub disabled_rules: Vec<String>,
457 #[serde(default)]
459 pub enabled_rules: Vec<String>,
460 #[serde(default)]
462 pub disabled_categories: Vec<String>,
463 #[serde(default)]
465 pub enabled_categories: Vec<String>,
466 #[serde(default = "default_lt_max_concurrent_requests")]
471 pub max_concurrent_requests: usize,
472 #[serde(default = "default_lt_max_request_bytes")]
478 pub max_request_bytes: usize,
479}
480
481impl Default for LanguageToolConfig {
482 fn default() -> Self {
483 Self {
484 enabled: false,
485 url: default_lt_url(),
486 level: "default".to_string(),
487 mother_tongue: None,
488 disabled_rules: Vec::new(),
489 enabled_rules: Vec::new(),
490 disabled_categories: Vec::new(),
491 enabled_categories: Vec::new(),
492 max_concurrent_requests: default_lt_max_concurrent_requests(),
493 max_request_bytes: default_lt_max_request_bytes(),
494 }
495 }
496}
497
498fn default_lt_level() -> String {
499 "default".to_string()
500}
501
502const fn default_lt_max_concurrent_requests() -> usize {
505 8
506}
507
508const fn default_lt_max_request_bytes() -> usize {
515 4096
516}
517
518#[derive(Debug, Default, Serialize, Deserialize, Clone)]
529pub struct HunspellConfig {
530 #[serde(default)]
532 pub enabled: bool,
533 #[serde(default)]
540 pub languages: Vec<String>,
541 #[serde(default)]
545 pub dictionary_paths: HashMap<String, String>,
546 #[serde(default)]
548 pub search_paths: Vec<String>,
549 #[serde(default)]
555 pub auto_install: bool,
556}
557
558impl EngineToggle for HunspellConfig {
559 fn enabled(&self) -> bool {
560 self.enabled
561 }
562 fn set_enabled(&mut self, v: bool) {
563 self.enabled = v;
564 }
565}
566
567impl EngineToggle for LanguageToolConfig {
568 fn enabled(&self) -> bool {
569 self.enabled
570 }
571 fn set_enabled(&mut self, v: bool) {
572 self.enabled = v;
573 }
574}
575
576#[derive(Debug, Default, Serialize, Deserialize, Clone)]
578pub struct ValeConfig {
579 #[serde(default)]
580 pub enabled: bool,
581 #[serde(default)]
583 pub config: Option<String>,
584}
585
586impl EngineToggle for ValeConfig {
587 fn enabled(&self) -> bool {
588 self.enabled
589 }
590 fn set_enabled(&mut self, v: bool) {
591 self.enabled = v;
592 }
593}
594
595#[derive(Debug, Default, Serialize, Deserialize, Clone)]
597pub struct ProselintConfig {
598 #[serde(default)]
599 pub enabled: bool,
600 #[serde(default)]
602 pub config: Option<String>,
603}
604
605impl EngineToggle for ProselintConfig {
606 fn enabled(&self) -> bool {
607 self.enabled
608 }
609 fn set_enabled(&mut self, v: bool) {
610 self.enabled = v;
611 }
612}
613
614#[derive(Debug, Serialize, Deserialize, Clone)]
619pub struct ExternalProvider {
620 pub name: String,
622 pub command: String,
624 #[serde(default)]
626 pub args: Vec<String>,
627 #[serde(default)]
632 pub extensions: Vec<String>,
633 #[serde(default)]
639 pub languages: Vec<String>,
640}
641
642#[derive(Debug, Serialize, Deserialize, Clone)]
647pub struct WasmPlugin {
648 pub name: String,
650 pub path: String,
652 #[serde(default)]
654 pub extensions: Vec<String>,
655 #[serde(default)]
657 pub languages: Vec<String>,
658}
659
660impl Default for EngineConfig {
661 fn default() -> Self {
662 Self {
663 harper: HarperConfig::default(),
664 languagetool: LanguageToolConfig::default(),
665 vale: ValeConfig::default(),
666 proselint: ProselintConfig::default(),
667 hunspell: HunspellConfig::default(),
668 external: Vec::new(),
669 wasm_plugins: Vec::new(),
670 spell_language: default_spell_language(),
671 }
672 }
673}
674
675#[derive(Debug, Serialize, Deserialize, Clone)]
676pub struct RuleConfig {
677 pub severity: Option<String>, }
679
680const fn default_true() -> bool {
681 true
682}
683fn default_lt_url() -> String {
684 "http://localhost:8010".to_string()
685}
686fn default_spell_language() -> String {
687 "en-US".to_string()
688}
689fn default_exclude() -> Vec<String> {
690 vec![
691 "node_modules/**".to_string(),
692 ".git/**".to_string(),
693 "target/**".to_string(),
694 "dist/**".to_string(),
695 "build/**".to_string(),
696 ".next/**".to_string(),
697 ".nuxt/**".to_string(),
698 "vendor/**".to_string(),
699 "__pycache__/**".to_string(),
700 ".venv/**".to_string(),
701 "venv/**".to_string(),
702 ".tox/**".to_string(),
703 ".mypy_cache/**".to_string(),
704 "*.min.js".to_string(),
705 "*.min.css".to_string(),
706 "*.bundle.js".to_string(),
707 "package-lock.json".to_string(),
708 "yarn.lock".to_string(),
709 "pnpm-lock.yaml".to_string(),
710 ]
711}
712
713impl Config {
714 #[must_use]
724 pub fn load_or_warn(workspace_root: &Path) -> Self {
725 Self::load(workspace_root).unwrap_or_else(|e| {
726 warn!(
727 root = %workspace_root.display(),
728 "Ignoring unreadable workspace config, using defaults: {e}"
729 );
730 Self::default()
731 })
732 }
733
734 #[must_use]
744 pub fn excludes(&self, path: &Path, workspace_root: &Path) -> bool {
745 if self.exclude.is_empty() {
746 return false;
747 }
748 let relative = path.strip_prefix(workspace_root).unwrap_or(path);
749 let as_text = relative.to_string_lossy().replace('\\', "/");
754 let options = glob::MatchOptions {
758 require_literal_separator: false,
759 require_literal_leading_dot: false,
760 case_sensitive: true,
761 };
762 self.exclude
763 .iter()
764 .filter_map(|pattern| glob::Pattern::new(pattern).ok())
765 .any(|pattern| pattern.matches_with(&as_text, options))
766 }
767
768 fn resolve_paths(&mut self, workspace_root: &Path) {
784 let absolute = |value: &str| -> String {
785 let path = Path::new(value);
786 if path.is_absolute() {
787 value.to_string()
788 } else {
789 workspace_root.join(path).to_string_lossy().into_owned()
790 }
791 };
792
793 if let Some(vale_config) = &self.engines.vale.config {
794 self.engines.vale.config = Some(absolute(vale_config));
795 }
796 if let Some(proselint_config) = &self.engines.proselint.config {
797 self.engines.proselint.config = Some(absolute(proselint_config));
798 }
799 for plugin in &mut self.engines.wasm_plugins {
800 plugin.path = absolute(&plugin.path);
801 }
802 for provider in &mut self.engines.external {
803 if provider.command.contains(std::path::MAIN_SEPARATOR)
807 || provider.command.contains('/')
808 {
809 provider.command = absolute(&provider.command);
810 }
811 }
812 }
813
814 pub fn load(workspace_root: &Path) -> Result<Self> {
815 let yaml_path = workspace_root.join(".languagecheck.yaml");
817 let yml_path = workspace_root.join(".languagecheck.yml");
818 let json_path = workspace_root.join(".languagecheck.json");
819
820 if yaml_path.exists() {
821 let content = std::fs::read_to_string(yaml_path)?;
822 warn_duplicate_rule_keys(&content);
823 let mut config: Self = serde_yaml::from_str(&content)?;
824 warn_unknown_keys(&serde_yaml::from_str(&content)?);
825 config.resolve_paths(workspace_root);
826 Ok(config)
827 } else if yml_path.exists() {
828 let content = std::fs::read_to_string(yml_path)?;
829 warn_duplicate_rule_keys(&content);
830 let mut config: Self = serde_yaml::from_str(&content)?;
831 warn_unknown_keys(&serde_yaml::from_str(&content)?);
832 config.resolve_paths(workspace_root);
833 Ok(config)
834 } else if json_path.exists() {
835 let content = std::fs::read_to_string(json_path)?;
836 let mut config: Self = serde_json::from_str(&content)?;
837 warn_unknown_keys(&serde_yaml::from_str(&content)?);
839 config.resolve_paths(workspace_root);
840 Ok(config)
841 } else {
842 Ok(Self::default())
843 }
844 }
845
846 #[must_use]
849 pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
850 let mut result = text.to_string();
851 let mut total = 0;
852
853 for rule in &self.auto_fix {
854 if let Some(ctx) = &rule.context
855 && !result.contains(ctx.as_str())
856 {
857 continue;
858 }
859 let count = result.matches(&rule.find).count();
860 if count > 0 {
861 result = result.replace(&rule.find, &rule.replace);
862 total += count;
863 }
864 }
865
866 (result, total)
867 }
868}
869
870fn duplicate_rule_keys(content: &str) -> Vec<String> {
878 let mut in_rules = false;
879 let mut child_indent: Option<usize> = None;
880 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
881 let mut duplicates: Vec<String> = Vec::new();
882
883 for line in content.lines() {
884 if line.trim().is_empty() {
885 continue;
886 }
887 let indent = line.len() - line.trim_start().len();
888
889 if !in_rules {
890 if indent == 0 && line.trim() == "rules:" {
891 in_rules = true;
892 }
893 continue;
894 }
895
896 if indent == 0 {
898 break;
899 }
900
901 let child = *child_indent.get_or_insert(indent);
902 if indent != child {
903 continue; }
905 if let Some(key) = line.trim().strip_suffix(':') {
906 let key = key.trim().to_string();
907 if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
908 duplicates.push(key);
909 }
910 }
911 }
912
913 duplicates
914}
915
916const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
918 "engines",
919 "rules",
920 "exclude",
921 "auto_fix",
922 "performance",
923 "dictionaries",
924 "languages",
925 "workspace",
926 "names",
927 "morphology",
928];
929
930const KNOWN_ENGINE_KEYS: &[&str] = &[
932 "harper",
933 "languagetool",
934 "vale",
935 "proselint",
936 "hunspell",
937 "external",
938 "wasm_plugins",
939 "spell_language",
940 "languagetool_url",
941 "vale_config",
942];
943
944fn unknown_keys(value: &serde_yaml::Value, known: &[&str]) -> Vec<String> {
946 let Some(map) = value.as_mapping() else {
947 return Vec::new();
948 };
949 map.keys()
950 .filter_map(serde_yaml::Value::as_str)
951 .filter(|k| !known.contains(k))
952 .map(ToString::to_string)
953 .collect()
954}
955
956fn warn_unknown_keys(value: &serde_yaml::Value) {
963 let unknown = unknown_keys(value, KNOWN_TOP_LEVEL_KEYS);
964 if !unknown.is_empty() {
965 warn!(keys = ?unknown, "Unknown keys in workspace config; they have no effect.");
966 }
967 if let Some(engines) = value.get("engines") {
968 let unknown = unknown_keys(engines, KNOWN_ENGINE_KEYS);
969 if !unknown.is_empty() {
970 warn!(keys = ?unknown, "Unknown keys under `engines:`; they have no effect.");
971 }
972 }
973}
974
975fn warn_duplicate_rule_keys(content: &str) {
977 let duplicates = duplicate_rule_keys(content);
978 if !duplicates.is_empty() {
979 warn!(
980 duplicates = ?duplicates,
981 "Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
982 effect. Remove the extra copies to keep the ignore list clean."
983 );
984 }
985}
986
987impl Default for Config {
988 fn default() -> Self {
989 Self {
990 engines: EngineConfig::default(),
991 rules: HashMap::new(),
992 exclude: default_exclude(),
993 auto_fix: Vec::new(),
994 performance: PerformanceConfig::default(),
995 dictionaries: DictionaryConfig::default(),
996 languages: LanguageConfig::default(),
997 workspace: WorkspaceConfig::default(),
998 names: NameConfig::default(),
999 morphology: MorphologyConfig::default(),
1000 }
1001 }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006
1007 #[test]
1008 fn every_engine_key_the_config_accepts_is_declared_known() {
1009 let yaml = "\
1014engines:
1015 harper: false
1016 languagetool: false
1017 vale: false
1018 proselint: false
1019 hunspell:
1020 enabled: true
1021 spell_language: en-US
1022";
1023 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1024 let engines = value.get("engines").expect("engines section");
1025 assert_eq!(
1026 unknown_keys(engines, KNOWN_ENGINE_KEYS),
1027 Vec::<String>::new(),
1028 "an engine key parses but is not declared known"
1029 );
1030 }
1031 use super::*;
1032
1033 #[test]
1034 fn duplicate_rule_keys_detects_repeats() {
1035 let yaml = "rules:\n languagetool.ARROWS:\n severity: \"off\"\n \
1036 languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
1037 languagetool.ARROWS:\n severity: \"off\"\n \
1038 languagetool.UPPERCASE_SENTENCE_START:\n severity: \"off\"\n \
1039 languagetool.THE_SUPERLATIVE:\n severity: \"off\"\n";
1040 let dups = duplicate_rule_keys(yaml);
1041 assert_eq!(
1042 dups,
1043 vec![
1044 "languagetool.ARROWS".to_string(),
1045 "languagetool.UPPERCASE_SENTENCE_START".to_string()
1046 ]
1047 );
1048 }
1049
1050 #[test]
1051 fn duplicate_rule_keys_clean_list_is_empty() {
1052 let yaml = "rules:\n a.B:\n severity: \"off\"\n c.D:\n severity: \"off\"\n";
1053 assert!(duplicate_rule_keys(yaml).is_empty());
1054 }
1055
1056 #[test]
1057 fn duplicate_rule_keys_stops_at_next_section() {
1058 let yaml = "rules:\n a.B:\n severity: \"off\"\nengines:\n harper: false\n";
1060 assert!(duplicate_rule_keys(yaml).is_empty());
1061 }
1062
1063 #[test]
1064 fn morphology_is_on_by_default() {
1065 let config = Config::default();
1066 assert!(config.morphology.enabled);
1067 assert!(config.morphology.inflections);
1068 }
1069
1070 #[test]
1071 fn morphology_can_be_switched_off_from_yaml() {
1072 let yaml = "morphology:\n enabled: false\n";
1073 let config: Config = serde_yaml::from_str(yaml).unwrap();
1074 assert!(!config.morphology.enabled);
1075 assert!(config.morphology.inflections);
1077 }
1078
1079 #[test]
1080 fn default_dictionaries_load_all_bundled_sets() {
1081 let config = Config::default();
1082 assert!(config.dictionaries.bundled);
1083 assert!(config.dictionaries.disabled.is_empty());
1084 assert!(config.dictionaries.paths.is_empty());
1085 }
1086
1087 #[test]
1088 fn dictionaries_disabled_from_yaml() {
1089 let config: Config = serde_yaml::from_str(
1090 r"
1091dictionaries:
1092 disabled: [companies, mathematics]
1093",
1094 )
1095 .unwrap();
1096 assert_eq!(config.dictionaries.disabled, ["companies", "mathematics"]);
1097 assert!(config.dictionaries.bundled);
1099 }
1100
1101 #[test]
1102 fn default_config_has_harper_enabled_lt_disabled() {
1103 let config = Config::default();
1104 assert!(config.engines.harper.enabled);
1105 assert!(!config.engines.languagetool.enabled);
1106 }
1107
1108 #[test]
1109 fn default_config_has_standard_excludes() {
1110 let config = Config::default();
1111 assert!(config.exclude.contains(&"node_modules/**".to_string()));
1112 assert!(config.exclude.contains(&".git/**".to_string()));
1113 assert!(config.exclude.contains(&"target/**".to_string()));
1114 assert!(config.exclude.contains(&"dist/**".to_string()));
1115 assert!(config.exclude.contains(&"vendor/**".to_string()));
1116 }
1117
1118 #[test]
1119 fn default_lt_url() {
1120 let config = Config::default();
1121 assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
1122 }
1123
1124 #[test]
1125 fn load_from_json_string() {
1126 let json = r#"{
1127 "engines": { "harper": true, "languagetool": false },
1128 "rules": { "spelling.typo": { "severity": "warning" } }
1129 }"#;
1130 let config: Config = serde_json::from_str(json).unwrap();
1131 assert!(config.engines.harper.enabled);
1132 assert!(!config.engines.languagetool.enabled);
1133 assert!(config.rules.contains_key("spelling.typo"));
1134 assert_eq!(
1135 config.rules["spelling.typo"].severity.as_deref(),
1136 Some("warning")
1137 );
1138 }
1139
1140 #[test]
1141 fn load_partial_json_uses_defaults() {
1142 let json = r#"{}"#;
1143 let config: Config = serde_json::from_str(json).unwrap();
1144 assert!(config.engines.harper.enabled);
1145 assert!(!config.engines.languagetool.enabled);
1146 assert!(config.rules.is_empty());
1147 }
1148
1149 #[test]
1150 fn load_from_json_file() {
1151 let dir = std::env::temp_dir().join("lang_check_test_config_json");
1152 let _ = std::fs::remove_dir_all(&dir);
1153 std::fs::create_dir_all(&dir).unwrap();
1154
1155 let config_path = dir.join(".languagecheck.json");
1156 std::fs::write(
1157 &config_path,
1158 r#"{"engines": {"harper": false, "languagetool": true}}"#,
1159 )
1160 .unwrap();
1161
1162 let config = Config::load(&dir).unwrap();
1163 assert!(!config.engines.harper.enabled);
1164 assert!(config.engines.languagetool.enabled);
1165
1166 let _ = std::fs::remove_dir_all(&dir);
1167 }
1168
1169 #[test]
1170 fn load_from_yaml_file() {
1171 let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
1172 let _ = std::fs::remove_dir_all(&dir);
1173 std::fs::create_dir_all(&dir).unwrap();
1174
1175 let config_path = dir.join(".languagecheck.yaml");
1176 std::fs::write(
1177 &config_path,
1178 "engines:\n harper: false\n languagetool: true\n",
1179 )
1180 .unwrap();
1181
1182 let config = Config::load(&dir).unwrap();
1183 assert!(!config.engines.harper.enabled);
1184 assert!(config.engines.languagetool.enabled);
1185
1186 let _ = std::fs::remove_dir_all(&dir);
1187 }
1188
1189 #[test]
1190 fn yaml_takes_precedence_over_json() {
1191 let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
1192 let _ = std::fs::remove_dir_all(&dir);
1193 std::fs::create_dir_all(&dir).unwrap();
1194
1195 std::fs::write(
1197 dir.join(".languagecheck.yaml"),
1198 "engines:\n harper: false\n",
1199 )
1200 .unwrap();
1201 std::fs::write(
1202 dir.join(".languagecheck.json"),
1203 r#"{"engines": {"harper": true}}"#,
1204 )
1205 .unwrap();
1206
1207 let config = Config::load(&dir).unwrap();
1208 assert!(!config.engines.harper.enabled);
1210
1211 let _ = std::fs::remove_dir_all(&dir);
1212 }
1213
1214 #[test]
1215 fn load_missing_file_returns_default() {
1216 let dir = std::env::temp_dir().join("lang_check_test_config_missing");
1217 let _ = std::fs::remove_dir_all(&dir);
1218 std::fs::create_dir_all(&dir).unwrap();
1219
1220 let config = Config::load(&dir).unwrap();
1221 assert!(config.engines.harper.enabled);
1222
1223 let _ = std::fs::remove_dir_all(&dir);
1224 }
1225
1226 #[test]
1227 fn exclude_matches_a_path_relative_to_the_workspace() {
1228 let config = Config {
1229 exclude: vec!["drafts/**".to_string(), "node_modules/**".to_string()],
1230 ..Config::default()
1231 };
1232 let root = Path::new("/home/someone/project");
1233
1234 assert!(config.excludes(&root.join("drafts/notes.md"), root));
1235 assert!(config.excludes(&root.join("node_modules/pkg/README.md"), root));
1236 assert!(!config.excludes(&root.join("docs/notes.md"), root));
1237 }
1238
1239 #[test]
1240 fn exclude_accepts_a_path_that_is_already_relative() {
1241 let config = Config {
1244 exclude: vec!["drafts/**".to_string()],
1245 ..Config::default()
1246 };
1247 let root = Path::new("/home/someone/project");
1248 assert!(config.excludes(Path::new("drafts/notes.md"), root));
1249 }
1250
1251 #[test]
1252 fn exclude_matches_whichever_separator_the_platform_uses() {
1253 let config = Config {
1257 exclude: vec!["drafts/**".to_string()],
1258 ..Config::default()
1259 };
1260 let root = Path::new("/home/someone/project");
1261 let with_backslashes = root.join("drafts").join("notes.md");
1262 assert!(config.excludes(&with_backslashes, root));
1263 }
1264
1265 #[test]
1266 fn an_empty_exclude_list_excludes_nothing() {
1267 let config = Config::default();
1268 let root = Path::new("/tmp");
1269 assert!(!config.excludes(&root.join("anything.md"), root));
1270 }
1271
1272 #[test]
1273 fn a_malformed_pattern_excludes_nothing_rather_than_everything() {
1274 let config = Config {
1277 exclude: vec!["[unclosed".to_string(), "drafts/**".to_string()],
1278 ..Config::default()
1279 };
1280 let root = Path::new("/tmp");
1281 assert!(!config.excludes(&root.join("notes.md"), root));
1282 assert!(config.excludes(&root.join("drafts/notes.md"), root));
1283 }
1284
1285 #[test]
1286 fn a_relative_vale_config_is_resolved_against_the_workspace() {
1287 let dir = std::env::temp_dir().join(format!("lc_resolve_{}", std::process::id()));
1292 std::fs::create_dir_all(&dir).unwrap();
1293 std::fs::write(
1294 dir.join(".languagecheck.yaml"),
1295 "engines:\n vale:\n enabled: true\n config: \".vale.ini\"\n",
1296 )
1297 .unwrap();
1298
1299 let config = Config::load(&dir).expect("config");
1300 let resolved = config.engines.vale.config.expect("a config path");
1301 assert!(
1302 Path::new(&resolved).is_absolute(),
1303 "left relative: {resolved}"
1304 );
1305 assert!(resolved.ends_with(".vale.ini"), "{resolved}");
1306 assert!(resolved.starts_with(&*dir.to_string_lossy()), "{resolved}");
1307
1308 std::fs::remove_dir_all(&dir).ok();
1309 }
1310
1311 #[test]
1312 fn an_absolute_path_in_the_config_is_left_alone() {
1313 let dir = std::env::temp_dir().join(format!("lc_resolve_abs_{}", std::process::id()));
1314 std::fs::create_dir_all(&dir).unwrap();
1315
1316 let elsewhere = std::env::temp_dir().join("vale.ini");
1321 let elsewhere = elsewhere.to_string_lossy().into_owned();
1322 std::fs::write(
1325 dir.join(".languagecheck.yaml"),
1326 format!("engines:\n vale:\n enabled: true\n config: '{elsewhere}'\n"),
1327 )
1328 .unwrap();
1329
1330 let config = Config::load(&dir).expect("config");
1331 assert_eq!(
1332 config.engines.vale.config.as_deref(),
1333 Some(elsewhere.as_str())
1334 );
1335
1336 std::fs::remove_dir_all(&dir).ok();
1337 }
1338
1339 #[test]
1340 fn a_wasm_plugin_path_is_resolved_too() {
1341 let dir = std::env::temp_dir().join(format!("lc_resolve_wasm_{}", std::process::id()));
1344 std::fs::create_dir_all(&dir).unwrap();
1345 std::fs::write(
1346 dir.join(".languagecheck.yaml"),
1347 "engines:\n wasm_plugins:\n - name: p\n path: plugins/p.wasm\n",
1348 )
1349 .unwrap();
1350
1351 let config = Config::load(&dir).expect("config");
1352 let resolved = &config.engines.wasm_plugins[0].path;
1353 assert!(
1354 Path::new(resolved).is_absolute(),
1355 "left relative: {resolved}"
1356 );
1357 assert!(
1359 resolved.replace('\\', "/").ends_with("plugins/p.wasm"),
1360 "{resolved}"
1361 );
1362
1363 std::fs::remove_dir_all(&dir).ok();
1364 }
1365
1366 #[test]
1367 fn a_relative_proselint_config_is_resolved_too() {
1368 let dir = std::env::temp_dir().join(format!("lc_resolve_pl_{}", std::process::id()));
1370 std::fs::create_dir_all(&dir).unwrap();
1371 std::fs::write(
1372 dir.join(".languagecheck.yaml"),
1373 "engines:\n proselint:\n enabled: true\n config: \"proselint.json\"\n",
1374 )
1375 .unwrap();
1376
1377 let config = Config::load(&dir).expect("config");
1378 let resolved = config.engines.proselint.config.expect("a config path");
1379 assert!(
1380 Path::new(&resolved).is_absolute(),
1381 "left relative: {resolved}"
1382 );
1383 assert!(resolved.ends_with("proselint.json"), "{resolved}");
1384
1385 std::fs::remove_dir_all(&dir).ok();
1386 }
1387
1388 #[test]
1389 fn an_external_command_written_as_a_path_is_resolved() {
1390 let dir = std::env::temp_dir().join(format!("lc_resolve_ext_{}", std::process::id()));
1391 std::fs::create_dir_all(&dir).unwrap();
1392 std::fs::write(
1393 dir.join(".languagecheck.yaml"),
1394 "engines:\n external:\n - name: c\n command: ./my-checker\n",
1395 )
1396 .unwrap();
1397
1398 let config = Config::load(&dir).expect("config");
1399 let command = &config.engines.external[0].command;
1400 assert!(Path::new(command).is_absolute(), "left relative: {command}");
1401 assert!(command.ends_with("my-checker"), "{command}");
1402
1403 std::fs::remove_dir_all(&dir).ok();
1404 }
1405
1406 #[test]
1407 fn an_external_command_that_is_a_bare_name_is_left_for_path_lookup() {
1408 let dir = std::env::temp_dir().join(format!("lc_resolve_bare_{}", std::process::id()));
1411 std::fs::create_dir_all(&dir).unwrap();
1412 std::fs::write(
1413 dir.join(".languagecheck.yaml"),
1414 "engines:\n external:\n - name: c\n command: my-checker\n",
1415 )
1416 .unwrap();
1417
1418 let config = Config::load(&dir).expect("config");
1419 assert_eq!(config.engines.external[0].command, "my-checker");
1420
1421 std::fs::remove_dir_all(&dir).ok();
1422 }
1423
1424 #[test]
1425 fn auto_fix_simple_replacement() {
1426 let config = Config {
1427 auto_fix: vec![AutoFixRule {
1428 find: "teh".to_string(),
1429 replace: "the".to_string(),
1430 context: None,
1431 description: None,
1432 }],
1433 ..Config::default()
1434 };
1435 let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
1436 assert_eq!(result, "Fix the typo in the text.");
1437 assert_eq!(count, 2);
1438 }
1439
1440 #[test]
1441 fn auto_fix_with_context_filter() {
1442 let config = Config {
1443 auto_fix: vec![AutoFixRule {
1444 find: "colour".to_string(),
1445 replace: "color".to_string(),
1446 context: Some("American".to_string()),
1447 description: Some("Use American spelling".to_string()),
1448 }],
1449 ..Config::default()
1450 };
1451 let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
1453 assert_eq!(result, "American English: the color is red.");
1454 assert_eq!(count, 1);
1455
1456 let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
1458 assert_eq!(result, "British English: the colour is red.");
1459 assert_eq!(count, 0);
1460 }
1461
1462 #[test]
1463 fn auto_fix_no_match() {
1464 let config = Config {
1465 auto_fix: vec![AutoFixRule {
1466 find: "foo".to_string(),
1467 replace: "bar".to_string(),
1468 context: None,
1469 description: None,
1470 }],
1471 ..Config::default()
1472 };
1473 let (result, count) = config.apply_auto_fixes("No matches here.");
1474 assert_eq!(result, "No matches here.");
1475 assert_eq!(count, 0);
1476 }
1477
1478 #[test]
1479 fn auto_fix_multiple_rules() {
1480 let config = Config {
1481 auto_fix: vec![
1482 AutoFixRule {
1483 find: "recieve".to_string(),
1484 replace: "receive".to_string(),
1485 context: None,
1486 description: None,
1487 },
1488 AutoFixRule {
1489 find: "seperate".to_string(),
1490 replace: "separate".to_string(),
1491 context: None,
1492 description: None,
1493 },
1494 ],
1495 ..Config::default()
1496 };
1497 let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
1498 assert_eq!(result, "Please receive the separate package.");
1499 assert_eq!(count, 2);
1500 }
1501
1502 #[test]
1503 fn auto_fix_loads_from_yaml() {
1504 let yaml = r#"
1505auto_fix:
1506 - find: "teh"
1507 replace: "the"
1508 description: "Fix common typo"
1509 - find: "colour"
1510 replace: "color"
1511 context: "American"
1512"#;
1513 let config: Config = serde_yaml::from_str(yaml).unwrap();
1514 assert_eq!(config.auto_fix.len(), 2);
1515 assert_eq!(config.auto_fix[0].find, "teh");
1516 assert_eq!(config.auto_fix[0].replace, "the");
1517 assert_eq!(
1518 config.auto_fix[0].description.as_deref(),
1519 Some("Fix common typo")
1520 );
1521 assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
1522 }
1523
1524 #[test]
1525 fn default_config_has_empty_auto_fix() {
1526 let config = Config::default();
1527 assert!(config.auto_fix.is_empty());
1528 }
1529
1530 #[test]
1531 fn external_providers_from_yaml() {
1532 let yaml = r#"
1533engines:
1534 harper: true
1535 languagetool: false
1536 external:
1537 - name: vale
1538 command: /usr/bin/vale
1539 args: ["--output", "JSON"]
1540 extensions: [md, rst]
1541 - name: custom-checker
1542 command: ./my-checker
1543"#;
1544 let config: Config = serde_yaml::from_str(yaml).unwrap();
1545 assert_eq!(config.engines.external.len(), 2);
1546 assert_eq!(config.engines.external[0].name, "vale");
1547 assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
1548 assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
1549 assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
1550 assert_eq!(config.engines.external[1].name, "custom-checker");
1551 assert!(config.engines.external[1].args.is_empty());
1552 }
1553
1554 #[test]
1555 fn default_config_has_no_external_providers() {
1556 let config = Config::default();
1557 assert!(config.engines.external.is_empty());
1558 }
1559
1560 #[test]
1561 fn wasm_plugins_from_yaml() {
1562 let yaml = r#"
1563engines:
1564 harper: true
1565 wasm_plugins:
1566 - name: custom-checker
1567 path: .languagecheck/plugins/checker.wasm
1568 extensions: [md, html]
1569 - name: style-linter
1570 path: /opt/plugins/style.wasm
1571"#;
1572 let config: Config = serde_yaml::from_str(yaml).unwrap();
1573 assert_eq!(config.engines.wasm_plugins.len(), 2);
1574 assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
1575 assert_eq!(
1576 config.engines.wasm_plugins[0].path,
1577 ".languagecheck/plugins/checker.wasm"
1578 );
1579 assert_eq!(
1580 config.engines.wasm_plugins[0].extensions,
1581 vec!["md", "html"]
1582 );
1583 assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
1584 assert!(config.engines.wasm_plugins[1].extensions.is_empty());
1585 }
1586
1587 #[test]
1588 fn default_config_has_no_wasm_plugins() {
1589 let config = Config::default();
1590 assert!(config.engines.wasm_plugins.is_empty());
1591 }
1592
1593 #[test]
1594 fn performance_config_defaults() {
1595 let config = Config::default();
1596 assert!(!config.performance.high_performance_mode);
1597 assert_eq!(config.performance.debounce_ms, 500);
1598 assert_eq!(config.performance.max_file_size, 0);
1599 }
1600
1601 #[test]
1602 fn performance_config_from_yaml() {
1603 let yaml = r#"
1604performance:
1605 high_performance_mode: true
1606 debounce_ms: 500
1607 max_file_size: 1048576
1608"#;
1609 let config: Config = serde_yaml::from_str(yaml).unwrap();
1610 assert!(config.performance.high_performance_mode);
1611 assert_eq!(config.performance.debounce_ms, 500);
1612 assert_eq!(config.performance.max_file_size, 1_048_576);
1613 }
1614
1615 #[test]
1616 fn latex_skip_environments_from_yaml() {
1617 let yaml = r#"
1618languages:
1619 latex:
1620 skip_environments:
1621 - prooftree
1622 - mycustomenv
1623"#;
1624 let config: Config = serde_yaml::from_str(yaml).unwrap();
1625 assert_eq!(
1626 config.languages.latex.skip_environments,
1627 vec!["prooftree", "mycustomenv"]
1628 );
1629 }
1630
1631 #[test]
1632 fn default_config_has_empty_latex_skip_environments() {
1633 let config = Config::default();
1634 assert!(config.languages.latex.skip_environments.is_empty());
1635 }
1636
1637 #[test]
1638 fn latex_skip_commands_from_yaml() {
1639 let yaml = r#"
1640languages:
1641 latex:
1642 skip_commands:
1643 - codefont
1644 - myverb
1645"#;
1646 let config: Config = serde_yaml::from_str(yaml).unwrap();
1647 assert_eq!(
1648 config.languages.latex.skip_commands,
1649 vec!["codefont", "myverb"]
1650 );
1651 }
1652
1653 #[test]
1654 fn default_spell_language_is_en_us() {
1655 let config = Config::default();
1656 assert_eq!(config.engines.spell_language, "en-US");
1657 }
1658
1659 #[test]
1660 fn spell_language_from_yaml() {
1661 let yaml = r#"
1662engines:
1663 spell_language: de-DE
1664"#;
1665 let config: Config = serde_yaml::from_str(yaml).unwrap();
1666 assert_eq!(config.engines.spell_language, "de-DE");
1667 }
1668
1669 #[test]
1670 fn default_config_has_empty_latex_skip_commands() {
1671 let config = Config::default();
1672 assert!(config.languages.latex.skip_commands.is_empty());
1673 }
1674
1675 #[test]
1676 fn default_vale_is_disabled() {
1677 let config = Config::default();
1678 assert!(!config.engines.vale.enabled);
1679 assert!(config.engines.vale.config.is_none());
1680 }
1681
1682 #[test]
1683 fn vale_bool_shorthand_from_yaml() {
1684 let yaml = r#"
1685engines:
1686 vale: true
1687"#;
1688 let config: Config = serde_yaml::from_str(yaml).unwrap();
1689 assert!(config.engines.vale.enabled);
1690 }
1691
1692 #[test]
1693 fn vale_nested_config_from_yaml() {
1694 let yaml = r#"
1695engines:
1696 vale:
1697 enabled: true
1698 config: ".vale.ini"
1699"#;
1700 let config: Config = serde_yaml::from_str(yaml).unwrap();
1701 assert!(config.engines.vale.enabled);
1702 assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
1703 }
1704
1705 #[test]
1706 fn harper_nested_config_from_yaml() {
1707 let yaml = r#"
1708engines:
1709 harper:
1710 enabled: true
1711 dialect: "British"
1712 linters:
1713 LongSentences: false
1714"#;
1715 let config: Config = serde_yaml::from_str(yaml).unwrap();
1716 assert!(config.engines.harper.enabled);
1717 assert_eq!(config.engines.harper.dialect, "British");
1718 assert_eq!(
1719 config.engines.harper.linters.get("LongSentences"),
1720 Some(&false)
1721 );
1722 }
1723
1724 #[test]
1725 fn languagetool_nested_config_from_yaml() {
1726 let yaml = r#"
1727engines:
1728 languagetool:
1729 enabled: true
1730 url: "http://localhost:9090"
1731 level: "picky"
1732 disabled_rules:
1733 - WHITESPACE_RULE
1734"#;
1735 let config: Config = serde_yaml::from_str(yaml).unwrap();
1736 assert!(config.engines.languagetool.enabled);
1737 assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
1738 assert_eq!(config.engines.languagetool.level, "picky");
1739 assert_eq!(
1740 config.engines.languagetool.disabled_rules,
1741 vec!["WHITESPACE_RULE"]
1742 );
1743 assert_eq!(config.engines.languagetool.max_concurrent_requests, 8);
1744 }
1745
1746 #[test]
1749 fn legacy_flat_languagetool_url_is_honoured() {
1750 let yaml = r#"
1751engines:
1752 spell_language: fr
1753 proselint: false
1754 vale: false
1755 languagetool: true
1756 languagetool_url: "http://10.0.10.3:8003"
1757 harper: false
1758"#;
1759 let config: Config = serde_yaml::from_str(yaml).unwrap();
1760 assert!(config.engines.languagetool.enabled);
1761 assert_eq!(config.engines.languagetool.url, "http://10.0.10.3:8003");
1762 assert_eq!(config.engines.spell_language, "fr");
1763 assert!(!config.engines.harper.enabled);
1764 }
1765
1766 #[test]
1767 fn nested_languagetool_url_beats_the_legacy_key() {
1768 let yaml = r#"
1769engines:
1770 languagetool:
1771 enabled: true
1772 url: "http://nested:9090"
1773 languagetool_url: "http://flat:8003"
1774"#;
1775 let config: Config = serde_yaml::from_str(yaml).unwrap();
1776 assert_eq!(config.engines.languagetool.url, "http://nested:9090");
1777 }
1778
1779 #[test]
1780 fn legacy_flat_vale_config_is_honoured() {
1781 let yaml = "engines:\n vale: true\n vale_config: \"config/.vale.ini\"\n";
1782 let config: Config = serde_yaml::from_str(yaml).unwrap();
1783 assert!(config.engines.vale.enabled);
1784 assert_eq!(
1785 config.engines.vale.config.as_deref(),
1786 Some("config/.vale.ini")
1787 );
1788 }
1789
1790 #[test]
1791 fn unknown_keys_are_reported() {
1792 let value: serde_yaml::Value =
1793 serde_yaml::from_str("engines:\n languagetol: true\n harper: true\nrulez: {}\n")
1794 .unwrap();
1795 assert_eq!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS), vec!["rulez"]);
1796 assert_eq!(
1797 unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS),
1798 vec!["languagetol"]
1799 );
1800 }
1801
1802 #[test]
1803 fn recognised_keys_are_not_reported() {
1804 let value: serde_yaml::Value = serde_yaml::from_str(
1805 "engines:\n languagetool_url: \"http://x:1\"\n harper: true\nrules: {}\n",
1806 )
1807 .unwrap();
1808 assert!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS).is_empty());
1809 assert!(unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS).is_empty());
1810 }
1811
1812 #[test]
1813 fn languagetool_concurrency_can_be_pinned_to_serial() {
1814 let yaml = r"
1816engines:
1817 languagetool:
1818 enabled: true
1819 max_concurrent_requests: 1
1820";
1821 let config: Config = serde_yaml::from_str(yaml).unwrap();
1822 assert_eq!(config.engines.languagetool.max_concurrent_requests, 1);
1823 }
1824
1825 #[test]
1826 fn default_proselint_is_disabled() {
1827 let config = Config::default();
1828 assert!(!config.engines.proselint.enabled);
1829 assert!(config.engines.proselint.config.is_none());
1830 }
1831
1832 #[test]
1833 fn proselint_bool_shorthand_from_yaml() {
1834 let yaml = r#"
1835engines:
1836 proselint: true
1837"#;
1838 let config: Config = serde_yaml::from_str(yaml).unwrap();
1839 assert!(config.engines.proselint.enabled);
1840 }
1841
1842 #[test]
1843 fn proselint_nested_config_from_yaml() {
1844 let yaml = r#"
1845engines:
1846 proselint:
1847 enabled: true
1848 config: "proselint.json"
1849"#;
1850 let config: Config = serde_yaml::from_str(yaml).unwrap();
1851 assert!(config.engines.proselint.enabled);
1852 assert_eq!(
1853 config.engines.proselint.config.as_deref(),
1854 Some("proselint.json")
1855 );
1856 }
1857}