Skip to main content

lang_check/
config.rs

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/// Opt-in suppression of spelling diagnostics on human names.
32///
33/// Off by default: the failure mode is silently hiding a real misspelling, which is
34/// much harder to notice than a stray squiggle on a surname.
35///
36/// ```yaml
37/// names:
38///   enabled: true
39///   aggressiveness: balanced   # conservative | balanced | aggressive
40/// ```
41#[derive(Debug, Serialize, Deserialize, Clone, Default)]
42pub struct NameConfig {
43    /// Whether to drop spelling diagnostics on tokens detected as human names.
44    #[serde(default)]
45    pub enabled: bool,
46    /// How much corroborating evidence a name needs before its diagnostic is dropped.
47    /// Default: `balanced`.
48    #[serde(default)]
49    pub aggressiveness: crate::names::Aggressiveness,
50}
51
52/// Acceptance of words built by affixation on material already known.
53///
54/// On by default, unlike [`NameConfig`]: a name verdict is a guess about a token, while
55/// a decomposition is a claim that can be checked — `subalgebra` is accepted only
56/// because `algebra` is a word. The failure mode both share is silently hiding a real
57/// misspelling, and here it is bounded by the engine's own suggestions.
58///
59/// ```yaml
60/// morphology:
61///   enabled: true       # accept prefixed and derived forms of known words
62///   inflections: true   # also accept the regular inflections of dictionary words
63/// ```
64#[derive(Debug, Serialize, Deserialize, Clone)]
65pub struct MorphologyConfig {
66    /// Accept a flagged token that decomposes into a known root.
67    #[serde(default = "default_true")]
68    pub enabled: bool,
69    /// Generate the regular inflections of every dictionary word and accept those too.
70    #[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/// Language extension aliasing configuration.
84///
85/// Maps canonical language IDs to additional file extensions.
86/// Built-in extensions (e.g. `.md` → markdown, `.htm` → html) are always
87/// included; entries here add to them.
88///
89/// ```yaml
90/// languages:
91///   extensions:
92///     markdown: [mdx, Rmd]
93///     latex: [sty]
94/// ```
95#[derive(Debug, Serialize, Deserialize, Clone, Default)]
96pub struct LanguageConfig {
97    /// Additional file extensions per language ID (without leading dots).
98    #[serde(default)]
99    pub extensions: HashMap<String, Vec<String>>,
100    /// LaTeX-specific settings.
101    #[serde(default)]
102    pub latex: LaTeXConfig,
103}
104
105/// LaTeX-specific configuration.
106///
107/// ```yaml
108/// languages:
109///   latex:
110///     skip_environments:
111///       - prooftree
112///       - mycustomenv
113/// ```
114#[derive(Debug, Serialize, Deserialize, Clone, Default)]
115pub struct LaTeXConfig {
116    /// Extra environment names to skip during prose extraction.
117    /// These are checked in addition to the built-in skip list.
118    #[serde(default)]
119    pub skip_environments: Vec<String>,
120    /// Extra command names whose arguments should be skipped during prose
121    /// extraction. These are checked in addition to the built-in skip list
122    /// (which includes `texttt`, `verb`, `url`, etc.).
123    #[serde(default)]
124    pub skip_commands: Vec<String>,
125}
126
127/// Workspace-level settings.
128///
129/// ```yaml
130/// workspace:
131///   index_on_open: true
132/// ```
133#[derive(Debug, Serialize, Deserialize, Clone, Default)]
134pub struct WorkspaceConfig {
135    /// Whether to run a full workspace index when the project is opened.
136    /// Default: false (only check documents on open/change).
137    #[serde(default)]
138    pub index_on_open: bool,
139    /// Custom path for the workspace database file. When empty (default),
140    /// databases are stored in the user data directory.
141    #[serde(default)]
142    pub db_path: Option<String>,
143}
144
145/// Performance tuning options. High Performance Mode (HPM) disables
146/// expensive engines and external providers, using only harper-core.
147#[derive(Debug, Serialize, Deserialize, Clone)]
148pub struct PerformanceConfig {
149    /// Enable High Performance Mode (only harper, no LT/externals).
150    #[serde(default)]
151    pub high_performance_mode: bool,
152    /// Debounce delay in milliseconds for LSP on-type checking.
153    #[serde(default = "default_debounce_ms")]
154    pub debounce_ms: u64,
155    /// Maximum file size in bytes to check (0 = unlimited).
156    #[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/// Configuration for bundled and additional wordlist dictionaries.
175#[derive(Debug, Serialize, Deserialize, Clone)]
176pub struct DictionaryConfig {
177    /// Whether to load the bundled domain-specific dictionaries (software terms,
178    /// TypeScript, companies, jargon, mathematics). Default: true.
179    #[serde(default = "default_true")]
180    pub bundled: bool,
181    /// Names of individual bundled dictionaries to skip, e.g.
182    /// `["companies", "mathematics"]`. Every set loads by default; listing one
183    /// here turns off just that one. Ignored when `bundled` is false.
184    #[serde(default)]
185    pub disabled: Vec<String>,
186    /// Paths to additional wordlist files (one word per line, `#` comments).
187    /// Relative paths are resolved from the workspace root.
188    #[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/// A user-defined find->replace auto-fix rule.
203#[derive(Debug, Serialize, Deserialize, Clone)]
204pub struct AutoFixRule {
205    /// Pattern to find (plain text, case-sensitive).
206    pub find: String,
207    /// Replacement text.
208    pub replace: String,
209    /// Optional context filter: only apply when surrounding text matches.
210    #[serde(default)]
211    pub context: Option<String>,
212    /// Optional description for the rule.
213    #[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    /// External checker providers registered via config.
225    pub external: Vec<ExternalProvider>,
226    /// WASM checker plugins loaded via Extism.
227    pub wasm_plugins: Vec<WasmPlugin>,
228    /// BCP-47 natural language tag for spell/grammar checking (e.g. "en-US", "de-DE").
229    pub spell_language: String,
230}
231
232/// On-disk form of [`EngineConfig`], carrying the flat pre-nesting keys next to
233/// the nested ones.
234///
235/// `engines.languagetool_url` and `engines.vale_config` were folded into
236/// `engines.languagetool.url` and `engines.vale.config` when engine settings
237/// became nested structs. serde drops unknown keys without a word, so every
238/// config still written the flat way — including the one in our own README —
239/// silently fell back to the default `http://localhost:8010`, and the only
240/// symptom was a connection error naming a server the user never configured
241/// (issue #86). Both spellings are read here, and the flat one warns.
242#[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    /// Deprecated alias for `engines.languagetool.url`.
262    #[serde(default)]
263    languagetool_url: Option<String>,
264    /// Deprecated alias for `engines.vale.config`.
265    #[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        // The nested key wins when both are present: it is the supported
284        // spelling, so a config carrying both is mid-migration.
285        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
314/// Report a flat pre-nesting key that was honoured but should be rewritten.
315fn 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
322/// Report a flat pre-nesting key that the nested key already overrode.
323fn warn_ignored_engine_key(old: &str, new: &str) {
324    warn!("`{old}` is ignored because `{new}` is also set; delete the deprecated key.");
325}
326
327/// Deserialize an engine config from either a bool shorthand or the full struct.
328/// `harper: true` → `HarperConfig { enabled: true, ..default }`.
329fn 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
351/// Trait for engine configs that can be toggled with a bool shorthand.
352pub trait EngineToggle {
353    fn enabled(&self) -> bool;
354    fn set_enabled(&mut self, v: bool);
355}
356
357/// Harper engine configuration.
358#[derive(Debug, Serialize, Deserialize, Clone)]
359pub struct HarperConfig {
360    #[serde(default = "default_true")]
361    pub enabled: bool,
362    /// Harper dialect: `American`, `British`, `Canadian`, `Australian`, `Indian`.
363    #[serde(default = "default_dialect")]
364    pub dialect: String,
365    /// Per-rule toggles. Key is the rule name (e.g. `LongSentences`), value
366    /// is `true`/`false`. Omitted rules use the curated default.
367    #[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/// `LanguageTool` engine configuration.
399#[derive(Debug, Serialize, Deserialize, Clone)]
400pub struct LanguageToolConfig {
401    #[serde(default)]
402    pub enabled: bool,
403    /// `LanguageTool` server URL.
404    #[serde(default = "default_lt_url")]
405    pub url: String,
406    /// Checking level: `default` or `picky` (enables stricter rules).
407    #[serde(default = "default_lt_level")]
408    pub level: String,
409    /// User's native language for false-friends detection (BCP-47 tag).
410    #[serde(default)]
411    pub mother_tongue: Option<String>,
412    /// Rule IDs to disable (e.g. `["WHITESPACE_RULE"]`).
413    #[serde(default)]
414    pub disabled_rules: Vec<String>,
415    /// Rule IDs to enable beyond defaults.
416    #[serde(default)]
417    pub enabled_rules: Vec<String>,
418    /// Category IDs to disable.
419    #[serde(default)]
420    pub disabled_categories: Vec<String>,
421    /// Category IDs to enable.
422    #[serde(default)]
423    pub enabled_categories: Vec<String>,
424    /// How many `/v2/check` requests may be in flight at once.
425    ///
426    /// A document is checked one prose range at a time, so a page of prose is
427    /// hundreds of small requests; issuing them serially makes the round-trip
428    /// latency, not `LanguageTool` itself, the bottleneck. Lower this when
429    /// pointing at a shared or rate-limited server; `1` restores serial checking.
430    #[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
454/// Enough parallelism to hide per-request latency on a local server without
455/// swamping a shared one — measured saturation point is around 8.
456const 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/// Vale engine configuration.
470#[derive(Debug, Default, Serialize, Deserialize, Clone)]
471pub struct ValeConfig {
472    #[serde(default)]
473    pub enabled: bool,
474    /// Path to `.vale.ini`. When empty, Vale uses its own search logic.
475    #[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/// Proselint engine configuration.
489#[derive(Debug, Default, Serialize, Deserialize, Clone)]
490pub struct ProselintConfig {
491    #[serde(default)]
492    pub enabled: bool,
493    /// Path to `proselint.json` config. When empty, proselint uses its own search logic.
494    #[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/// An external checker binary that communicates via stdin/stdout JSON.
508///
509/// The binary receives `{"text": "...", "language_id": "..."}` on stdin
510/// and returns `[{"start_byte": N, "end_byte": N, "message": "...", ...}]` on stdout.
511#[derive(Debug, Serialize, Deserialize, Clone)]
512pub struct ExternalProvider {
513    /// Display name for this provider.
514    pub name: String,
515    /// Path to the executable.
516    pub command: String,
517    /// Optional arguments to pass to the command.
518    #[serde(default)]
519    pub args: Vec<String>,
520    /// Optional file extensions this provider supports (empty = all).
521    #[serde(default)]
522    pub extensions: Vec<String>,
523}
524
525/// A WASM plugin loaded via Extism.
526///
527/// Plugins must export a `check` function that receives a JSON string
528/// `{"text": "...", "language_id": "..."}` and returns a JSON array of diagnostics.
529#[derive(Debug, Serialize, Deserialize, Clone)]
530pub struct WasmPlugin {
531    /// Display name for this plugin.
532    pub name: String,
533    /// Path to the `.wasm` file (relative to workspace root or absolute).
534    pub path: String,
535    /// Optional file extensions this plugin supports (empty = all).
536    #[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>, // "error", "warning", "info", "hint", "off"
557}
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    /// Load configuration, warning and falling back to defaults if it cannot be read.
594    ///
595    /// `load` fails on a malformed `.languagecheck.yaml` — a bad indent, a typo'd enum — and
596    /// callers used to answer that with a bare `Config::default()`, so a rejected file was
597    /// indistinguishable from an absent one and the user's overrides silently did nothing.
598    /// A missing file is not an error and is not reported; an unreadable one is.
599    ///
600    /// Callers with no `tracing` subscriber installed (the CLI binary) must report to stderr
601    /// themselves rather than call this, or the warning goes nowhere.
602    #[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        // Prefer YAML, fall back to JSON for backward compatibility
615        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            // YAML 1.2 is a superset of JSON, so one key scanner covers both formats.
635            warn_unknown_keys(&serde_yaml::from_str(&content)?);
636            Ok(config)
637        } else {
638            Ok(Self::default())
639        }
640    }
641
642    /// Apply user-defined auto-fix rules to the given text, returning the modified text
643    /// and the number of replacements made.
644    #[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
666/// Collect rule keys that appear more than once under the top-level `rules:`
667/// mapping of a raw YAML config, in first-seen order.
668///
669/// `serde_yaml` silently keeps only the last value for a duplicated mapping
670/// key, so duplicates vanish after parsing; this scans the raw text so they can
671/// be surfaced. Recognizes block-style child keys (`  some.rule:` on its own
672/// line) at the mapping's first child indentation.
673fn 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        // A new top-level key ends the rules block.
693        if indent == 0 {
694            break;
695        }
696
697        let child = *child_indent.get_or_insert(indent);
698        if indent != child {
699            continue; // deeper line (e.g. `severity: ...`), not a rule key
700        }
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
712/// Top-level keys [`Config`] understands.
713const 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
726/// Keys [`EngineConfig`] understands, including the deprecated flat aliases.
727const 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
739/// Collect the keys of `value`'s `section` mapping that are not in `known`.
740fn 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
751/// Warn about config keys nothing reads.
752///
753/// serde ignores what it does not recognise, so a typo'd or renamed key is
754/// indistinguishable from an absent one: the setting simply never takes effect
755/// and the user is left debugging the default. Reporting them turns a silent
756/// no-op into a line in the log.
757fn 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
770/// Log a warning if a raw YAML config contains duplicate rule keys.
771fn 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        // A repeat under a *different* top-level section must not count.
829        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        // An unmentioned field keeps its default rather than falling to `false`.
846        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        // The master switch is untouched by listing individual sets.
868        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        // Write both files with different values
966        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        // YAML should win
979        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        // Context matches — replacement should happen
1024        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        // Context does not match — no replacement
1029        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    /// Issue #86: the flat key our own docs advertised was dropped on the floor,
1319    /// so a self-hosted server was checked against `localhost:8010` instead.
1320    #[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        // Shared or rate-limited servers need the old one-at-a-time behaviour back.
1387        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}