Skip to main content

lang_check/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5
6#[derive(Debug, Serialize, Deserialize, Clone)]
7pub struct Config {
8    #[serde(default)]
9    pub engines: EngineConfig,
10    #[serde(default)]
11    pub rules: HashMap<String, RuleConfig>,
12    #[serde(default = "default_exclude")]
13    pub exclude: Vec<String>,
14    #[serde(default)]
15    pub auto_fix: Vec<AutoFixRule>,
16    #[serde(default)]
17    pub performance: PerformanceConfig,
18    #[serde(default)]
19    pub dictionaries: DictionaryConfig,
20    #[serde(default)]
21    pub languages: LanguageConfig,
22    #[serde(default)]
23    pub workspace: WorkspaceConfig,
24}
25
26/// Language extension aliasing configuration.
27///
28/// Maps canonical language IDs to additional file extensions.
29/// Built-in extensions (e.g. `.md` → markdown, `.htm` → html) are always
30/// included; entries here add to them.
31///
32/// ```yaml
33/// languages:
34///   extensions:
35///     markdown: [mdx, Rmd]
36///     latex: [sty]
37/// ```
38#[derive(Debug, Serialize, Deserialize, Clone, Default)]
39pub struct LanguageConfig {
40    /// Additional file extensions per language ID (without leading dots).
41    #[serde(default)]
42    pub extensions: HashMap<String, Vec<String>>,
43    /// LaTeX-specific settings.
44    #[serde(default)]
45    pub latex: LaTeXConfig,
46}
47
48/// LaTeX-specific configuration.
49///
50/// ```yaml
51/// languages:
52///   latex:
53///     skip_environments:
54///       - prooftree
55///       - mycustomenv
56/// ```
57#[derive(Debug, Serialize, Deserialize, Clone, Default)]
58pub struct LaTeXConfig {
59    /// Extra environment names to skip during prose extraction.
60    /// These are checked in addition to the built-in skip list.
61    #[serde(default)]
62    pub skip_environments: Vec<String>,
63    /// Extra command names whose arguments should be skipped during prose
64    /// extraction. These are checked in addition to the built-in skip list
65    /// (which includes `texttt`, `verb`, `url`, etc.).
66    #[serde(default)]
67    pub skip_commands: Vec<String>,
68}
69
70/// Workspace-level settings.
71///
72/// ```yaml
73/// workspace:
74///   index_on_open: true
75/// ```
76#[derive(Debug, Serialize, Deserialize, Clone, Default)]
77pub struct WorkspaceConfig {
78    /// Whether to run a full workspace index when the project is opened.
79    /// Default: false (only check documents on open/change).
80    #[serde(default)]
81    pub index_on_open: bool,
82    /// Custom path for the workspace database file. When empty (default),
83    /// databases are stored in the user data directory.
84    #[serde(default)]
85    pub db_path: Option<String>,
86}
87
88/// Performance tuning options. High Performance Mode (HPM) disables
89/// expensive engines and external providers, using only harper-core.
90#[derive(Debug, Serialize, Deserialize, Clone)]
91pub struct PerformanceConfig {
92    /// Enable High Performance Mode (only harper, no LT/externals).
93    #[serde(default)]
94    pub high_performance_mode: bool,
95    /// Debounce delay in milliseconds for LSP on-type checking.
96    #[serde(default = "default_debounce_ms")]
97    pub debounce_ms: u64,
98    /// Maximum file size in bytes to check (0 = unlimited).
99    #[serde(default)]
100    pub max_file_size: usize,
101}
102
103impl Default for PerformanceConfig {
104    fn default() -> Self {
105        Self {
106            high_performance_mode: false,
107            debounce_ms: 300,
108            max_file_size: 0,
109        }
110    }
111}
112
113const fn default_debounce_ms() -> u64 {
114    300
115}
116
117/// Configuration for bundled and additional wordlist dictionaries.
118#[derive(Debug, Serialize, Deserialize, Clone)]
119pub struct DictionaryConfig {
120    /// Whether to load the bundled domain-specific dictionaries (software terms,
121    /// TypeScript, companies, jargon). Default: true.
122    #[serde(default = "default_true")]
123    pub bundled: bool,
124    /// Paths to additional wordlist files (one word per line, `#` comments).
125    /// Relative paths are resolved from the workspace root.
126    #[serde(default)]
127    pub paths: Vec<String>,
128}
129
130impl Default for DictionaryConfig {
131    fn default() -> Self {
132        Self {
133            bundled: true,
134            paths: Vec::new(),
135        }
136    }
137}
138
139/// A user-defined find->replace auto-fix rule.
140#[derive(Debug, Serialize, Deserialize, Clone)]
141pub struct AutoFixRule {
142    /// Pattern to find (plain text, case-sensitive).
143    pub find: String,
144    /// Replacement text.
145    pub replace: String,
146    /// Optional context filter: only apply when surrounding text matches.
147    #[serde(default)]
148    pub context: Option<String>,
149    /// Optional description for the rule.
150    #[serde(default)]
151    pub description: Option<String>,
152}
153
154#[derive(Debug, Serialize, Deserialize, Clone)]
155pub struct EngineConfig {
156    #[serde(
157        default = "default_harper_config",
158        deserialize_with = "deser_engine_or_bool"
159    )]
160    pub harper: HarperConfig,
161    #[serde(default, deserialize_with = "deser_engine_or_bool")]
162    pub languagetool: LanguageToolConfig,
163    #[serde(default, deserialize_with = "deser_engine_or_bool")]
164    pub vale: ValeConfig,
165    #[serde(default, deserialize_with = "deser_engine_or_bool")]
166    pub proselint: ProselintConfig,
167    /// External checker providers registered via config.
168    #[serde(default)]
169    pub external: Vec<ExternalProvider>,
170    /// WASM checker plugins loaded via Extism.
171    #[serde(default)]
172    pub wasm_plugins: Vec<WasmPlugin>,
173    /// BCP-47 natural language tag for spell/grammar checking (e.g. "en-US", "de-DE").
174    #[serde(default = "default_spell_language")]
175    pub spell_language: String,
176}
177
178/// Deserialize an engine config from either a bool shorthand or the full struct.
179/// `harper: true` → `HarperConfig { enabled: true, ..default }`.
180fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
181where
182    D: serde::Deserializer<'de>,
183    T: Deserialize<'de> + EngineToggle + Default,
184{
185    #[derive(Deserialize)]
186    #[serde(untagged)]
187    enum BoolOrStruct<T> {
188        Bool(bool),
189        Struct(T),
190    }
191
192    match BoolOrStruct::deserialize(deserializer)? {
193        BoolOrStruct::Bool(b) => {
194            let mut cfg = T::default();
195            cfg.set_enabled(b);
196            Ok(cfg)
197        }
198        BoolOrStruct::Struct(s) => Ok(s),
199    }
200}
201
202/// Trait for engine configs that can be toggled with a bool shorthand.
203pub trait EngineToggle {
204    fn enabled(&self) -> bool;
205    fn set_enabled(&mut self, v: bool);
206}
207
208/// Harper engine configuration.
209#[derive(Debug, Serialize, Deserialize, Clone)]
210pub struct HarperConfig {
211    #[serde(default = "default_true")]
212    pub enabled: bool,
213    /// Harper dialect: `American`, `British`, `Canadian`, `Australian`, `Indian`.
214    #[serde(default = "default_dialect")]
215    pub dialect: String,
216    /// Per-rule toggles. Key is the rule name (e.g. `LongSentences`), value
217    /// is `true`/`false`. Omitted rules use the curated default.
218    #[serde(default)]
219    pub linters: HashMap<String, bool>,
220}
221
222impl Default for HarperConfig {
223    fn default() -> Self {
224        Self {
225            enabled: true,
226            dialect: "American".to_string(),
227            linters: HashMap::new(),
228        }
229    }
230}
231
232fn default_harper_config() -> HarperConfig {
233    HarperConfig::default()
234}
235
236fn default_dialect() -> String {
237    "American".to_string()
238}
239
240impl EngineToggle for HarperConfig {
241    fn enabled(&self) -> bool {
242        self.enabled
243    }
244    fn set_enabled(&mut self, v: bool) {
245        self.enabled = v;
246    }
247}
248
249/// `LanguageTool` engine configuration.
250#[derive(Debug, Serialize, Deserialize, Clone)]
251pub struct LanguageToolConfig {
252    #[serde(default)]
253    pub enabled: bool,
254    /// `LanguageTool` server URL.
255    #[serde(default = "default_lt_url")]
256    pub url: String,
257    /// Checking level: `default` or `picky` (enables stricter rules).
258    #[serde(default = "default_lt_level")]
259    pub level: String,
260    /// User's native language for false-friends detection (BCP-47 tag).
261    #[serde(default)]
262    pub mother_tongue: Option<String>,
263    /// Rule IDs to disable (e.g. `["WHITESPACE_RULE"]`).
264    #[serde(default)]
265    pub disabled_rules: Vec<String>,
266    /// Rule IDs to enable beyond defaults.
267    #[serde(default)]
268    pub enabled_rules: Vec<String>,
269    /// Category IDs to disable.
270    #[serde(default)]
271    pub disabled_categories: Vec<String>,
272    /// Category IDs to enable.
273    #[serde(default)]
274    pub enabled_categories: Vec<String>,
275}
276
277impl Default for LanguageToolConfig {
278    fn default() -> Self {
279        Self {
280            enabled: false,
281            url: default_lt_url(),
282            level: "default".to_string(),
283            mother_tongue: None,
284            disabled_rules: Vec::new(),
285            enabled_rules: Vec::new(),
286            disabled_categories: Vec::new(),
287            enabled_categories: Vec::new(),
288        }
289    }
290}
291
292fn default_lt_level() -> String {
293    "default".to_string()
294}
295
296impl EngineToggle for LanguageToolConfig {
297    fn enabled(&self) -> bool {
298        self.enabled
299    }
300    fn set_enabled(&mut self, v: bool) {
301        self.enabled = v;
302    }
303}
304
305/// Vale engine configuration.
306#[derive(Debug, Default, Serialize, Deserialize, Clone)]
307pub struct ValeConfig {
308    #[serde(default)]
309    pub enabled: bool,
310    /// Path to `.vale.ini`. When empty, Vale uses its own search logic.
311    #[serde(default)]
312    pub config: Option<String>,
313}
314
315impl EngineToggle for ValeConfig {
316    fn enabled(&self) -> bool {
317        self.enabled
318    }
319    fn set_enabled(&mut self, v: bool) {
320        self.enabled = v;
321    }
322}
323
324/// Proselint engine configuration.
325#[derive(Debug, Default, Serialize, Deserialize, Clone)]
326pub struct ProselintConfig {
327    #[serde(default)]
328    pub enabled: bool,
329    /// Path to `proselint.json` config. When empty, proselint uses its own search logic.
330    #[serde(default)]
331    pub config: Option<String>,
332}
333
334impl EngineToggle for ProselintConfig {
335    fn enabled(&self) -> bool {
336        self.enabled
337    }
338    fn set_enabled(&mut self, v: bool) {
339        self.enabled = v;
340    }
341}
342
343/// An external checker binary that communicates via stdin/stdout JSON.
344///
345/// The binary receives `{"text": "...", "language_id": "..."}` on stdin
346/// and returns `[{"start_byte": N, "end_byte": N, "message": "...", ...}]` on stdout.
347#[derive(Debug, Serialize, Deserialize, Clone)]
348pub struct ExternalProvider {
349    /// Display name for this provider.
350    pub name: String,
351    /// Path to the executable.
352    pub command: String,
353    /// Optional arguments to pass to the command.
354    #[serde(default)]
355    pub args: Vec<String>,
356    /// Optional file extensions this provider supports (empty = all).
357    #[serde(default)]
358    pub extensions: Vec<String>,
359}
360
361/// A WASM plugin loaded via Extism.
362///
363/// Plugins must export a `check` function that receives a JSON string
364/// `{"text": "...", "language_id": "..."}` and returns a JSON array of diagnostics.
365#[derive(Debug, Serialize, Deserialize, Clone)]
366pub struct WasmPlugin {
367    /// Display name for this plugin.
368    pub name: String,
369    /// Path to the `.wasm` file (relative to workspace root or absolute).
370    pub path: String,
371    /// Optional file extensions this plugin supports (empty = all).
372    #[serde(default)]
373    pub extensions: Vec<String>,
374}
375
376impl Default for EngineConfig {
377    fn default() -> Self {
378        Self {
379            harper: HarperConfig::default(),
380            languagetool: LanguageToolConfig::default(),
381            vale: ValeConfig::default(),
382            proselint: ProselintConfig::default(),
383            external: Vec::new(),
384            wasm_plugins: Vec::new(),
385            spell_language: default_spell_language(),
386        }
387    }
388}
389
390#[derive(Debug, Serialize, Deserialize, Clone)]
391pub struct RuleConfig {
392    pub severity: Option<String>, // "error", "warning", "info", "hint", "off"
393}
394
395const fn default_true() -> bool {
396    true
397}
398fn default_lt_url() -> String {
399    "http://localhost:8010".to_string()
400}
401fn default_spell_language() -> String {
402    "en-US".to_string()
403}
404fn default_exclude() -> Vec<String> {
405    vec![
406        "node_modules/**".to_string(),
407        ".git/**".to_string(),
408        "target/**".to_string(),
409        "dist/**".to_string(),
410        "build/**".to_string(),
411        ".next/**".to_string(),
412        ".nuxt/**".to_string(),
413        "vendor/**".to_string(),
414        "__pycache__/**".to_string(),
415        ".venv/**".to_string(),
416        "venv/**".to_string(),
417        ".tox/**".to_string(),
418        ".mypy_cache/**".to_string(),
419        "*.min.js".to_string(),
420        "*.min.css".to_string(),
421        "*.bundle.js".to_string(),
422        "package-lock.json".to_string(),
423        "yarn.lock".to_string(),
424        "pnpm-lock.yaml".to_string(),
425    ]
426}
427
428impl Config {
429    pub fn load(workspace_root: &Path) -> Result<Self> {
430        // Prefer YAML, fall back to JSON for backward compatibility
431        let yaml_path = workspace_root.join(".languagecheck.yaml");
432        let yml_path = workspace_root.join(".languagecheck.yml");
433        let json_path = workspace_root.join(".languagecheck.json");
434
435        if yaml_path.exists() {
436            let content = std::fs::read_to_string(yaml_path)?;
437            let config: Self = serde_yaml::from_str(&content)?;
438            Ok(config)
439        } else if yml_path.exists() {
440            let content = std::fs::read_to_string(yml_path)?;
441            let config: Self = serde_yaml::from_str(&content)?;
442            Ok(config)
443        } else if json_path.exists() {
444            let content = std::fs::read_to_string(json_path)?;
445            let config: Self = serde_json::from_str(&content)?;
446            Ok(config)
447        } else {
448            Ok(Self::default())
449        }
450    }
451
452    /// Apply user-defined auto-fix rules to the given text, returning the modified text
453    /// and the number of replacements made.
454    #[must_use]
455    pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
456        let mut result = text.to_string();
457        let mut total = 0;
458
459        for rule in &self.auto_fix {
460            if let Some(ctx) = &rule.context
461                && !result.contains(ctx.as_str())
462            {
463                continue;
464            }
465            let count = result.matches(&rule.find).count();
466            if count > 0 {
467                result = result.replace(&rule.find, &rule.replace);
468                total += count;
469            }
470        }
471
472        (result, total)
473    }
474}
475
476impl Default for Config {
477    fn default() -> Self {
478        Self {
479            engines: EngineConfig::default(),
480            rules: HashMap::new(),
481            exclude: default_exclude(),
482            auto_fix: Vec::new(),
483            performance: PerformanceConfig::default(),
484            dictionaries: DictionaryConfig::default(),
485            languages: LanguageConfig::default(),
486            workspace: WorkspaceConfig::default(),
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn default_config_has_harper_enabled_lt_disabled() {
497        let config = Config::default();
498        assert!(config.engines.harper.enabled);
499        assert!(!config.engines.languagetool.enabled);
500    }
501
502    #[test]
503    fn default_config_has_standard_excludes() {
504        let config = Config::default();
505        assert!(config.exclude.contains(&"node_modules/**".to_string()));
506        assert!(config.exclude.contains(&".git/**".to_string()));
507        assert!(config.exclude.contains(&"target/**".to_string()));
508        assert!(config.exclude.contains(&"dist/**".to_string()));
509        assert!(config.exclude.contains(&"vendor/**".to_string()));
510    }
511
512    #[test]
513    fn default_lt_url() {
514        let config = Config::default();
515        assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
516    }
517
518    #[test]
519    fn load_from_json_string() {
520        let json = r#"{
521            "engines": { "harper": true, "languagetool": false },
522            "rules": { "spelling.typo": { "severity": "warning" } }
523        }"#;
524        let config: Config = serde_json::from_str(json).unwrap();
525        assert!(config.engines.harper.enabled);
526        assert!(!config.engines.languagetool.enabled);
527        assert!(config.rules.contains_key("spelling.typo"));
528        assert_eq!(
529            config.rules["spelling.typo"].severity.as_deref(),
530            Some("warning")
531        );
532    }
533
534    #[test]
535    fn load_partial_json_uses_defaults() {
536        let json = r#"{}"#;
537        let config: Config = serde_json::from_str(json).unwrap();
538        assert!(config.engines.harper.enabled);
539        assert!(!config.engines.languagetool.enabled);
540        assert!(config.rules.is_empty());
541    }
542
543    #[test]
544    fn load_from_json_file() {
545        let dir = std::env::temp_dir().join("lang_check_test_config_json");
546        let _ = std::fs::remove_dir_all(&dir);
547        std::fs::create_dir_all(&dir).unwrap();
548
549        let config_path = dir.join(".languagecheck.json");
550        std::fs::write(
551            &config_path,
552            r#"{"engines": {"harper": false, "languagetool": true}}"#,
553        )
554        .unwrap();
555
556        let config = Config::load(&dir).unwrap();
557        assert!(!config.engines.harper.enabled);
558        assert!(config.engines.languagetool.enabled);
559
560        let _ = std::fs::remove_dir_all(&dir);
561    }
562
563    #[test]
564    fn load_from_yaml_file() {
565        let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
566        let _ = std::fs::remove_dir_all(&dir);
567        std::fs::create_dir_all(&dir).unwrap();
568
569        let config_path = dir.join(".languagecheck.yaml");
570        std::fs::write(
571            &config_path,
572            "engines:\n  harper: false\n  languagetool: true\n",
573        )
574        .unwrap();
575
576        let config = Config::load(&dir).unwrap();
577        assert!(!config.engines.harper.enabled);
578        assert!(config.engines.languagetool.enabled);
579
580        let _ = std::fs::remove_dir_all(&dir);
581    }
582
583    #[test]
584    fn yaml_takes_precedence_over_json() {
585        let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
586        let _ = std::fs::remove_dir_all(&dir);
587        std::fs::create_dir_all(&dir).unwrap();
588
589        // Write both files with different values
590        std::fs::write(
591            dir.join(".languagecheck.yaml"),
592            "engines:\n  harper: false\n",
593        )
594        .unwrap();
595        std::fs::write(
596            dir.join(".languagecheck.json"),
597            r#"{"engines": {"harper": true}}"#,
598        )
599        .unwrap();
600
601        let config = Config::load(&dir).unwrap();
602        // YAML should win
603        assert!(!config.engines.harper.enabled);
604
605        let _ = std::fs::remove_dir_all(&dir);
606    }
607
608    #[test]
609    fn load_missing_file_returns_default() {
610        let dir = std::env::temp_dir().join("lang_check_test_config_missing");
611        let _ = std::fs::remove_dir_all(&dir);
612        std::fs::create_dir_all(&dir).unwrap();
613
614        let config = Config::load(&dir).unwrap();
615        assert!(config.engines.harper.enabled);
616
617        let _ = std::fs::remove_dir_all(&dir);
618    }
619
620    #[test]
621    fn auto_fix_simple_replacement() {
622        let config = Config {
623            auto_fix: vec![AutoFixRule {
624                find: "teh".to_string(),
625                replace: "the".to_string(),
626                context: None,
627                description: None,
628            }],
629            ..Config::default()
630        };
631        let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
632        assert_eq!(result, "Fix the typo in the text.");
633        assert_eq!(count, 2);
634    }
635
636    #[test]
637    fn auto_fix_with_context_filter() {
638        let config = Config {
639            auto_fix: vec![AutoFixRule {
640                find: "colour".to_string(),
641                replace: "color".to_string(),
642                context: Some("American".to_string()),
643                description: Some("Use American spelling".to_string()),
644            }],
645            ..Config::default()
646        };
647        // Context matches — replacement should happen
648        let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
649        assert_eq!(result, "American English: the color is red.");
650        assert_eq!(count, 1);
651
652        // Context does not match — no replacement
653        let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
654        assert_eq!(result, "British English: the colour is red.");
655        assert_eq!(count, 0);
656    }
657
658    #[test]
659    fn auto_fix_no_match() {
660        let config = Config {
661            auto_fix: vec![AutoFixRule {
662                find: "foo".to_string(),
663                replace: "bar".to_string(),
664                context: None,
665                description: None,
666            }],
667            ..Config::default()
668        };
669        let (result, count) = config.apply_auto_fixes("No matches here.");
670        assert_eq!(result, "No matches here.");
671        assert_eq!(count, 0);
672    }
673
674    #[test]
675    fn auto_fix_multiple_rules() {
676        let config = Config {
677            auto_fix: vec![
678                AutoFixRule {
679                    find: "recieve".to_string(),
680                    replace: "receive".to_string(),
681                    context: None,
682                    description: None,
683                },
684                AutoFixRule {
685                    find: "seperate".to_string(),
686                    replace: "separate".to_string(),
687                    context: None,
688                    description: None,
689                },
690            ],
691            ..Config::default()
692        };
693        let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
694        assert_eq!(result, "Please receive the separate package.");
695        assert_eq!(count, 2);
696    }
697
698    #[test]
699    fn auto_fix_loads_from_yaml() {
700        let yaml = r#"
701auto_fix:
702  - find: "teh"
703    replace: "the"
704    description: "Fix common typo"
705  - find: "colour"
706    replace: "color"
707    context: "American"
708"#;
709        let config: Config = serde_yaml::from_str(yaml).unwrap();
710        assert_eq!(config.auto_fix.len(), 2);
711        assert_eq!(config.auto_fix[0].find, "teh");
712        assert_eq!(config.auto_fix[0].replace, "the");
713        assert_eq!(
714            config.auto_fix[0].description.as_deref(),
715            Some("Fix common typo")
716        );
717        assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
718    }
719
720    #[test]
721    fn default_config_has_empty_auto_fix() {
722        let config = Config::default();
723        assert!(config.auto_fix.is_empty());
724    }
725
726    #[test]
727    fn external_providers_from_yaml() {
728        let yaml = r#"
729engines:
730  harper: true
731  languagetool: false
732  external:
733    - name: vale
734      command: /usr/bin/vale
735      args: ["--output", "JSON"]
736      extensions: [md, rst]
737    - name: custom-checker
738      command: ./my-checker
739"#;
740        let config: Config = serde_yaml::from_str(yaml).unwrap();
741        assert_eq!(config.engines.external.len(), 2);
742        assert_eq!(config.engines.external[0].name, "vale");
743        assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
744        assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
745        assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
746        assert_eq!(config.engines.external[1].name, "custom-checker");
747        assert!(config.engines.external[1].args.is_empty());
748    }
749
750    #[test]
751    fn default_config_has_no_external_providers() {
752        let config = Config::default();
753        assert!(config.engines.external.is_empty());
754    }
755
756    #[test]
757    fn wasm_plugins_from_yaml() {
758        let yaml = r#"
759engines:
760  harper: true
761  wasm_plugins:
762    - name: custom-checker
763      path: .languagecheck/plugins/checker.wasm
764      extensions: [md, html]
765    - name: style-linter
766      path: /opt/plugins/style.wasm
767"#;
768        let config: Config = serde_yaml::from_str(yaml).unwrap();
769        assert_eq!(config.engines.wasm_plugins.len(), 2);
770        assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
771        assert_eq!(
772            config.engines.wasm_plugins[0].path,
773            ".languagecheck/plugins/checker.wasm"
774        );
775        assert_eq!(
776            config.engines.wasm_plugins[0].extensions,
777            vec!["md", "html"]
778        );
779        assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
780        assert!(config.engines.wasm_plugins[1].extensions.is_empty());
781    }
782
783    #[test]
784    fn default_config_has_no_wasm_plugins() {
785        let config = Config::default();
786        assert!(config.engines.wasm_plugins.is_empty());
787    }
788
789    #[test]
790    fn performance_config_defaults() {
791        let config = Config::default();
792        assert!(!config.performance.high_performance_mode);
793        assert_eq!(config.performance.debounce_ms, 300);
794        assert_eq!(config.performance.max_file_size, 0);
795    }
796
797    #[test]
798    fn performance_config_from_yaml() {
799        let yaml = r#"
800performance:
801  high_performance_mode: true
802  debounce_ms: 500
803  max_file_size: 1048576
804"#;
805        let config: Config = serde_yaml::from_str(yaml).unwrap();
806        assert!(config.performance.high_performance_mode);
807        assert_eq!(config.performance.debounce_ms, 500);
808        assert_eq!(config.performance.max_file_size, 1_048_576);
809    }
810
811    #[test]
812    fn latex_skip_environments_from_yaml() {
813        let yaml = r#"
814languages:
815  latex:
816    skip_environments:
817      - prooftree
818      - mycustomenv
819"#;
820        let config: Config = serde_yaml::from_str(yaml).unwrap();
821        assert_eq!(
822            config.languages.latex.skip_environments,
823            vec!["prooftree", "mycustomenv"]
824        );
825    }
826
827    #[test]
828    fn default_config_has_empty_latex_skip_environments() {
829        let config = Config::default();
830        assert!(config.languages.latex.skip_environments.is_empty());
831    }
832
833    #[test]
834    fn latex_skip_commands_from_yaml() {
835        let yaml = r#"
836languages:
837  latex:
838    skip_commands:
839      - codefont
840      - myverb
841"#;
842        let config: Config = serde_yaml::from_str(yaml).unwrap();
843        assert_eq!(
844            config.languages.latex.skip_commands,
845            vec!["codefont", "myverb"]
846        );
847    }
848
849    #[test]
850    fn default_spell_language_is_en_us() {
851        let config = Config::default();
852        assert_eq!(config.engines.spell_language, "en-US");
853    }
854
855    #[test]
856    fn spell_language_from_yaml() {
857        let yaml = r#"
858engines:
859  spell_language: de-DE
860"#;
861        let config: Config = serde_yaml::from_str(yaml).unwrap();
862        assert_eq!(config.engines.spell_language, "de-DE");
863    }
864
865    #[test]
866    fn default_config_has_empty_latex_skip_commands() {
867        let config = Config::default();
868        assert!(config.languages.latex.skip_commands.is_empty());
869    }
870
871    #[test]
872    fn default_vale_is_disabled() {
873        let config = Config::default();
874        assert!(!config.engines.vale.enabled);
875        assert!(config.engines.vale.config.is_none());
876    }
877
878    #[test]
879    fn vale_bool_shorthand_from_yaml() {
880        let yaml = r#"
881engines:
882  vale: true
883"#;
884        let config: Config = serde_yaml::from_str(yaml).unwrap();
885        assert!(config.engines.vale.enabled);
886    }
887
888    #[test]
889    fn vale_nested_config_from_yaml() {
890        let yaml = r#"
891engines:
892  vale:
893    enabled: true
894    config: ".vale.ini"
895"#;
896        let config: Config = serde_yaml::from_str(yaml).unwrap();
897        assert!(config.engines.vale.enabled);
898        assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
899    }
900
901    #[test]
902    fn harper_nested_config_from_yaml() {
903        let yaml = r#"
904engines:
905  harper:
906    enabled: true
907    dialect: "British"
908    linters:
909      LongSentences: false
910"#;
911        let config: Config = serde_yaml::from_str(yaml).unwrap();
912        assert!(config.engines.harper.enabled);
913        assert_eq!(config.engines.harper.dialect, "British");
914        assert_eq!(
915            config.engines.harper.linters.get("LongSentences"),
916            Some(&false)
917        );
918    }
919
920    #[test]
921    fn languagetool_nested_config_from_yaml() {
922        let yaml = r#"
923engines:
924  languagetool:
925    enabled: true
926    url: "http://localhost:9090"
927    level: "picky"
928    disabled_rules:
929      - WHITESPACE_RULE
930"#;
931        let config: Config = serde_yaml::from_str(yaml).unwrap();
932        assert!(config.engines.languagetool.enabled);
933        assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
934        assert_eq!(config.engines.languagetool.level, "picky");
935        assert_eq!(
936            config.engines.languagetool.disabled_rules,
937            vec!["WHITESPACE_RULE"]
938        );
939    }
940
941    #[test]
942    fn default_proselint_is_disabled() {
943        let config = Config::default();
944        assert!(!config.engines.proselint.enabled);
945        assert!(config.engines.proselint.config.is_none());
946    }
947
948    #[test]
949    fn proselint_bool_shorthand_from_yaml() {
950        let yaml = r#"
951engines:
952  proselint: true
953"#;
954        let config: Config = serde_yaml::from_str(yaml).unwrap();
955        assert!(config.engines.proselint.enabled);
956    }
957
958    #[test]
959    fn proselint_nested_config_from_yaml() {
960        let yaml = r#"
961engines:
962  proselint:
963    enabled: true
964    config: "proselint.json"
965"#;
966        let config: Config = serde_yaml::from_str(yaml).unwrap();
967        assert!(config.engines.proselint.enabled);
968        assert_eq!(
969            config.engines.proselint.config.as_deref(),
970            Some("proselint.json")
971        );
972    }
973}