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    /// Which files this project checks, as workspace-relative globs.
14    ///
15    /// Empty means every file the editor opens and every file the indexer
16    /// finds, which is the behaviour this had before the key existed and the
17    /// right default for a repository that is mostly prose. It is the wrong
18    /// one for a repository that is mostly code with a `docs/` in it: there
19    /// the exclude list has to name every directory that is *not* prose, and
20    /// it silently stops being right the moment someone adds another one.
21    ///
22    /// `include` and `exclude` compose the way a type-checker's do -- include
23    /// selects, exclude subtracts from the selection -- so narrowing to
24    /// `docs/**` and then dropping `docs/_build/**` needs no knowledge of
25    /// what else is in the tree.
26    #[serde(default)]
27    pub include: Vec<String>,
28    /// Which file types to check, as extensions without the leading dot.
29    ///
30    /// Empty means every type the grammars recognise, which is the default
31    /// and what this did before the key existed. `["md"]` restricts the whole
32    /// project to Markdown however wide `include` is, which is the common
33    /// case that `include` alone can only express by repeating the extension
34    /// in every pattern.
35    ///
36    /// Matched case-insensitively, and a leading dot is accepted and ignored,
37    /// because `.md` is how half of everyone will write it.
38    #[serde(default)]
39    pub file_types: Vec<String>,
40    /// Paths not to check, as workspace-relative globs.
41    ///
42    /// The built-in list is always in force; anything written here is added
43    /// to it. Replacing it instead was the obvious reading and the wrong one:
44    /// a config that excluded one directory of its own silently stopped
45    /// excluding `node_modules/**`, so adding a single pattern could multiply
46    /// the work by a hundred with nothing to say it had.
47    #[serde(default = "default_exclude")]
48    pub exclude: Vec<String>,
49    #[serde(default)]
50    pub auto_fix: Vec<AutoFixRule>,
51    #[serde(default)]
52    pub performance: PerformanceConfig,
53    #[serde(default)]
54    pub dictionaries: DictionaryConfig,
55    #[serde(default)]
56    pub languages: LanguageConfig,
57    #[serde(default)]
58    pub workspace: WorkspaceConfig,
59    #[serde(default)]
60    pub names: NameConfig,
61    #[serde(default)]
62    pub morphology: MorphologyConfig,
63}
64
65/// Opt-in suppression of spelling diagnostics on human names.
66///
67/// Off by default: the failure mode is silently hiding a real misspelling, which is
68/// much harder to notice than a stray squiggle on a surname.
69///
70/// ```yaml
71/// names:
72///   enabled: true
73///   aggressiveness: balanced   # conservative | balanced | aggressive
74/// ```
75#[derive(Debug, Serialize, Deserialize, Clone, Default)]
76pub struct NameConfig {
77    /// Whether to drop spelling diagnostics on tokens detected as human names.
78    #[serde(default)]
79    pub enabled: bool,
80    /// How much corroborating evidence a name needs before its diagnostic is dropped.
81    /// Default: `balanced`.
82    #[serde(default)]
83    pub aggressiveness: crate::names::Aggressiveness,
84}
85
86/// Acceptance of words built by affixation on material already known.
87///
88/// On by default, unlike [`NameConfig`]: a name verdict is a guess about a token, while
89/// a decomposition is a claim that can be checked — `subalgebra` is accepted only
90/// because `algebra` is a word. The failure mode both share is silently hiding a real
91/// misspelling, and here it is bounded by the engine's own suggestions.
92///
93/// ```yaml
94/// morphology:
95///   enabled: true       # accept prefixed and derived forms of known words
96///   inflections: true   # also accept the regular inflections of dictionary words
97/// ```
98#[derive(Debug, Serialize, Deserialize, Clone)]
99pub struct MorphologyConfig {
100    /// Accept a flagged token that decomposes into a known root.
101    #[serde(default = "default_true")]
102    pub enabled: bool,
103    /// Generate the regular inflections of every dictionary word and accept those too.
104    #[serde(default = "default_true")]
105    pub inflections: bool,
106}
107
108impl Default for MorphologyConfig {
109    fn default() -> Self {
110        Self {
111            enabled: true,
112            inflections: true,
113        }
114    }
115}
116
117/// Language extension aliasing configuration.
118///
119/// Maps canonical language IDs to additional file extensions.
120/// Built-in extensions (e.g. `.md` → markdown, `.htm` → html) are always
121/// included; entries here add to them.
122///
123/// ```yaml
124/// languages:
125///   extensions:
126///     markdown: [mdx, Rmd]
127///     latex: [sty]
128/// ```
129#[derive(Debug, Serialize, Deserialize, Clone, Default)]
130pub struct LanguageConfig {
131    /// Additional file extensions per language ID (without leading dots).
132    #[serde(default)]
133    pub extensions: HashMap<String, Vec<String>>,
134    /// LaTeX-specific settings.
135    #[serde(default)]
136    pub latex: LaTeXConfig,
137}
138
139/// LaTeX-specific configuration.
140///
141/// ```yaml
142/// languages:
143///   latex:
144///     skip_environments:
145///       - prooftree
146///       - mycustomenv
147/// ```
148#[derive(Debug, Serialize, Deserialize, Clone, Default)]
149pub struct LaTeXConfig {
150    /// Extra environment names to skip during prose extraction.
151    /// These are checked in addition to the built-in skip list.
152    #[serde(default)]
153    pub skip_environments: Vec<String>,
154    /// Extra command names whose arguments should be skipped during prose
155    /// extraction. These are checked in addition to the built-in skip list
156    /// (which includes `texttt`, `verb`, `url`, etc.).
157    #[serde(default)]
158    pub skip_commands: Vec<String>,
159}
160
161/// Workspace-level settings.
162///
163/// ```yaml
164/// workspace:
165///   index_on_open: true
166/// ```
167#[derive(Debug, Serialize, Deserialize, Clone, Default)]
168pub struct WorkspaceConfig {
169    /// Whether to run a full workspace index when the project is opened.
170    /// Default: false (only check documents on open/change).
171    #[serde(default)]
172    pub index_on_open: bool,
173    /// Custom path for the workspace database file. When empty (default),
174    /// databases are stored in the user data directory.
175    #[serde(default)]
176    pub db_path: Option<String>,
177}
178
179/// Performance tuning options. High Performance Mode (HPM) disables
180/// expensive engines and external providers, using only harper-core.
181#[derive(Debug, Serialize, Deserialize, Clone)]
182pub struct PerformanceConfig {
183    /// Enable High Performance Mode (only harper, no LT/externals).
184    #[serde(default)]
185    pub high_performance_mode: bool,
186    /// How long after the last keystroke a check runs, in milliseconds.
187    ///
188    /// Read by the editor clients, which own the typing loop; the core checks
189    /// whatever it is handed, whenever it is handed it.
190    #[serde(default = "default_debounce_ms")]
191    pub debounce_ms: u64,
192    /// Maximum file size in bytes to check (0 = unlimited).
193    #[serde(default)]
194    pub max_file_size: usize,
195    /// How many engine answers to keep, keyed by the prose that produced them.
196    ///
197    /// A keystroke re-checks the whole document although one prose range
198    /// changed, so the cache is what keeps a long file responsive. `0`
199    /// disables it and re-checks every range on every keystroke.
200    #[serde(default = "default_result_cache_entries")]
201    pub result_cache_entries: usize,
202    /// Longest prose range handed on, in bytes; longer ones are split at
203    /// sentence boundaries. `0` disables splitting.
204    ///
205    /// A range is one cache key and one box in the inspector, so a document
206    /// written without blank lines between paragraphs otherwise becomes a
207    /// single range and neither the cache nor the inspector can say anything
208    /// useful about it.
209    #[serde(default = "default_max_range_bytes")]
210    pub max_range_bytes: usize,
211}
212
213impl Default for PerformanceConfig {
214    fn default() -> Self {
215        Self {
216            high_performance_mode: false,
217            debounce_ms: 500,
218            max_file_size: 0,
219            result_cache_entries: default_result_cache_entries(),
220            max_range_bytes: default_max_range_bytes(),
221        }
222    }
223}
224
225/// Long enough that a burst of typing produces one check, short enough that a
226/// pause feels answered. The VS Code extension defaults to the same number.
227const fn default_debounce_ms() -> u64 {
228    500
229}
230
231/// Room for several long documents at once: a 36 kB file is around 110 prose
232/// ranges, so this holds roughly thirty of them per engine before evicting.
233const fn default_result_cache_entries() -> usize {
234    4096
235}
236
237/// Several sentences, so the cross-sentence rules still have something to work
238/// with, while a keystroke dirties a paragraph's worth of cache rather than a
239/// chapter's. Splitting costs nothing on a cold check: the engines pack ranges
240/// back together up to `max_request_bytes` before sending them.
241const fn default_max_range_bytes() -> usize {
242    2048
243}
244
245/// Configuration for bundled and additional wordlist dictionaries.
246#[derive(Debug, Serialize, Deserialize, Clone)]
247pub struct DictionaryConfig {
248    /// Whether to load the bundled domain-specific dictionaries (software terms,
249    /// TypeScript, companies, jargon, mathematics). Default: true.
250    #[serde(default = "default_true")]
251    pub bundled: bool,
252    /// Names of individual bundled dictionaries to skip, e.g.
253    /// `["companies", "mathematics"]`. Every set loads by default; listing one
254    /// here turns off just that one. Ignored when `bundled` is false.
255    #[serde(default)]
256    pub disabled: Vec<String>,
257    /// Paths to additional wordlist files (one word per line, `#` comments).
258    /// Relative paths are resolved from the workspace root.
259    #[serde(default)]
260    pub paths: Vec<String>,
261}
262
263impl Default for DictionaryConfig {
264    fn default() -> Self {
265        Self {
266            bundled: true,
267            disabled: Vec::new(),
268            paths: Vec::new(),
269        }
270    }
271}
272
273/// A user-defined find->replace auto-fix rule.
274#[derive(Debug, Serialize, Deserialize, Clone)]
275pub struct AutoFixRule {
276    /// Pattern to find (plain text, case-sensitive).
277    pub find: String,
278    /// Replacement text.
279    pub replace: String,
280    /// Optional context filter: only apply when surrounding text matches.
281    #[serde(default)]
282    pub context: Option<String>,
283    /// Optional description for the rule.
284    #[serde(default)]
285    pub description: Option<String>,
286}
287
288#[derive(Debug, Serialize, Deserialize, Clone)]
289#[serde(from = "EngineConfigWire")]
290pub struct EngineConfig {
291    pub harper: HarperConfig,
292    pub languagetool: LanguageToolConfig,
293    pub vale: ValeConfig,
294    pub proselint: ProselintConfig,
295    pub hunspell: HunspellConfig,
296    /// External checker providers registered via config.
297    pub external: Vec<ExternalProvider>,
298    /// WASM checker plugins loaded via Extism.
299    pub wasm_plugins: Vec<WasmPlugin>,
300    /// BCP-47 natural language tag for spell/grammar checking (e.g. "en-US", "de-DE").
301    pub spell_language: String,
302}
303
304/// On-disk form of [`EngineConfig`], carrying the flat pre-nesting keys next to
305/// the nested ones.
306///
307/// `engines.languagetool_url` and `engines.vale_config` were folded into
308/// `engines.languagetool.url` and `engines.vale.config` when engine settings
309/// became nested structs. serde drops unknown keys without a word, so every
310/// config still written the flat way — including the one in our own README —
311/// silently fell back to the default `http://localhost:8010`, and the only
312/// symptom was a connection error naming a server the user never configured
313/// (issue #86). Both spellings are read here, and the flat one warns.
314#[derive(Deserialize)]
315struct EngineConfigWire {
316    #[serde(
317        default = "default_harper_config",
318        deserialize_with = "deser_engine_or_bool"
319    )]
320    harper: HarperConfig,
321    #[serde(default, deserialize_with = "deser_engine_or_bool")]
322    languagetool: LanguageToolConfig,
323    #[serde(default, deserialize_with = "deser_engine_or_bool")]
324    vale: ValeConfig,
325    #[serde(default, deserialize_with = "deser_engine_or_bool")]
326    proselint: ProselintConfig,
327    #[serde(default, deserialize_with = "deser_engine_or_bool")]
328    hunspell: HunspellConfig,
329    #[serde(default)]
330    external: Vec<ExternalProvider>,
331    #[serde(default)]
332    wasm_plugins: Vec<WasmPlugin>,
333    #[serde(default = "default_spell_language")]
334    spell_language: String,
335    /// Deprecated alias for `engines.languagetool.url`.
336    #[serde(default)]
337    languagetool_url: Option<String>,
338    /// Deprecated alias for `engines.vale.config`.
339    #[serde(default)]
340    vale_config: Option<String>,
341}
342
343impl From<EngineConfigWire> for EngineConfig {
344    fn from(wire: EngineConfigWire) -> Self {
345        let EngineConfigWire {
346            harper,
347            mut languagetool,
348            mut vale,
349            proselint,
350            hunspell,
351            external,
352            wasm_plugins,
353            spell_language,
354            languagetool_url,
355            vale_config,
356        } = wire;
357
358        // The nested key wins when both are present: it is the supported
359        // spelling, so a config carrying both is mid-migration.
360        if let Some(url) = languagetool_url {
361            if languagetool.url == default_lt_url() {
362                warn_deprecated_engine_key("engines.languagetool_url", "engines.languagetool.url");
363                languagetool.url = url;
364            } else {
365                warn_ignored_engine_key("engines.languagetool_url", "engines.languagetool.url");
366            }
367        }
368        if let Some(path) = vale_config {
369            if vale.config.is_none() {
370                warn_deprecated_engine_key("engines.vale_config", "engines.vale.config");
371                vale.config = Some(path);
372            } else {
373                warn_ignored_engine_key("engines.vale_config", "engines.vale.config");
374            }
375        }
376
377        Self {
378            harper,
379            languagetool,
380            vale,
381            proselint,
382            hunspell,
383            external,
384            wasm_plugins,
385            spell_language,
386        }
387    }
388}
389
390/// Report a flat pre-nesting key that was honoured but should be rewritten.
391fn warn_deprecated_engine_key(old: &str, new: &str) {
392    warn!(
393        "`{old}` is deprecated and will be removed in a future release; \
394         rename it to `{new}`. Honouring it for now."
395    );
396}
397
398/// Report a flat pre-nesting key that the nested key already overrode.
399fn warn_ignored_engine_key(old: &str, new: &str) {
400    warn!("`{old}` is ignored because `{new}` is also set; delete the deprecated key.");
401}
402
403/// Deserialize an engine config from either a bool shorthand or the full struct.
404/// `harper: true` → `HarperConfig { enabled: true, ..default }`.
405fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
406where
407    D: serde::Deserializer<'de>,
408    T: Deserialize<'de> + EngineToggle + Default,
409{
410    #[derive(Deserialize)]
411    #[serde(untagged)]
412    enum BoolOrStruct<T> {
413        Bool(bool),
414        Struct(T),
415    }
416
417    match BoolOrStruct::deserialize(deserializer)? {
418        BoolOrStruct::Bool(b) => {
419            let mut cfg = T::default();
420            cfg.set_enabled(b);
421            Ok(cfg)
422        }
423        BoolOrStruct::Struct(s) => Ok(s),
424    }
425}
426
427/// Trait for engine configs that can be toggled with a bool shorthand.
428pub trait EngineToggle {
429    fn enabled(&self) -> bool;
430    fn set_enabled(&mut self, v: bool);
431}
432
433/// Harper engine configuration.
434#[derive(Debug, Serialize, Deserialize, Clone)]
435pub struct HarperConfig {
436    #[serde(default = "default_true")]
437    pub enabled: bool,
438    /// Harper dialect: `American`, `British`, `Canadian`, `Australian`, `Indian`.
439    #[serde(default = "default_dialect")]
440    pub dialect: String,
441    /// Per-rule toggles. Key is the rule name (e.g. `LongSentences`), value
442    /// is `true`/`false`. Omitted rules use the curated default.
443    #[serde(default)]
444    pub linters: HashMap<String, bool>,
445}
446
447impl Default for HarperConfig {
448    fn default() -> Self {
449        Self {
450            enabled: true,
451            dialect: "American".to_string(),
452            linters: HashMap::new(),
453        }
454    }
455}
456
457fn default_harper_config() -> HarperConfig {
458    HarperConfig::default()
459}
460
461fn default_dialect() -> String {
462    "American".to_string()
463}
464
465impl EngineToggle for HarperConfig {
466    fn enabled(&self) -> bool {
467        self.enabled
468    }
469    fn set_enabled(&mut self, v: bool) {
470        self.enabled = v;
471    }
472}
473
474/// `LanguageTool` engine configuration.
475#[derive(Debug, Serialize, Deserialize, Clone)]
476pub struct LanguageToolConfig {
477    #[serde(default)]
478    pub enabled: bool,
479    /// `LanguageTool` server URL.
480    #[serde(default = "default_lt_url")]
481    pub url: String,
482    /// Checking level: `default` or `picky` (enables stricter rules).
483    #[serde(default = "default_lt_level")]
484    pub level: String,
485    /// User's native language for false-friends detection (BCP-47 tag).
486    #[serde(default)]
487    pub mother_tongue: Option<String>,
488    /// Rule IDs to disable (e.g. `["WHITESPACE_RULE"]`).
489    #[serde(default)]
490    pub disabled_rules: Vec<String>,
491    /// Rule IDs to enable beyond defaults.
492    #[serde(default)]
493    pub enabled_rules: Vec<String>,
494    /// Category IDs to disable.
495    #[serde(default)]
496    pub disabled_categories: Vec<String>,
497    /// Category IDs to enable.
498    #[serde(default)]
499    pub enabled_categories: Vec<String>,
500    /// How many `/v2/check` requests may be in flight at once.
501    ///
502    /// Lower this when pointing at a shared or rate-limited server; `1`
503    /// restores serial checking.
504    #[serde(default = "default_lt_max_concurrent_requests")]
505    pub max_concurrent_requests: usize,
506    /// How much prose to put in one `/v2/check`, in bytes.
507    ///
508    /// Prose ranges are packed up to this size before being sent. A range that
509    /// exceeds it on its own still gets a request of its own; `0` disables
510    /// packing and restores one request per range.
511    #[serde(default = "default_lt_max_request_bytes")]
512    pub max_request_bytes: usize,
513}
514
515impl Default for LanguageToolConfig {
516    fn default() -> Self {
517        Self {
518            enabled: false,
519            url: default_lt_url(),
520            level: "default".to_string(),
521            mother_tongue: None,
522            disabled_rules: Vec::new(),
523            enabled_rules: Vec::new(),
524            disabled_categories: Vec::new(),
525            enabled_categories: Vec::new(),
526            max_concurrent_requests: default_lt_max_concurrent_requests(),
527            max_request_bytes: default_lt_max_request_bytes(),
528        }
529    }
530}
531
532fn default_lt_level() -> String {
533    "default".to_string()
534}
535
536/// Enough parallelism to hide per-request latency on a local server without
537/// swamping a shared one — measured saturation point is around 8.
538const fn default_lt_max_concurrent_requests() -> usize {
539    8
540}
541
542/// Measured against a local `LanguageTool` 6.x, a `/v2/check` costs about
543/// 8 ms flat plus 20.6 us per byte. Per prose range that flat cost dominates —
544/// a 36 kB Typst document is 109 ranges of median 156 bytes, so 872 ms of the
545/// wall clock is request overhead alone. Packing to 4 kB leaves overhead under
546/// a tenth of the request and keeps each one short enough that the concurrency
547/// window stays full; 8 kB and above buys little and delays the first result.
548const fn default_lt_max_request_bytes() -> usize {
549    4096
550}
551
552/// Hunspell: spelling for the languages the other engines do not read.
553///
554/// ```yaml
555/// engines:
556///   hunspell:
557///     enabled: true
558///     languages: ["he", "la"]
559///     dictionary_paths:
560///       la: /opt/dictionaries/latin
561/// ```
562#[derive(Debug, Default, Serialize, Deserialize, Clone)]
563pub struct HunspellConfig {
564    /// Off by default, like every engine that needs something installed.
565    #[serde(default)]
566    pub enabled: bool,
567    /// Languages to check with Hunspell, as BCP-47 tags.
568    ///
569    /// Naming them ahead of time is what lets a pack be fetched before it is
570    /// needed rather than mid-document, and what keeps this engine to the gaps
571    /// -- leave English out and Harper keeps it. Empty means any language with
572    /// a pack behind it, which is the discovery mode and not the tidy one.
573    #[serde(default)]
574    pub languages: Vec<String>,
575    /// Per-language override: a directory, an `.aff`/`.dic` stem, or either
576    /// file of the pair. Beats every search path, so a pinned dictionary is
577    /// definitely the one in use.
578    #[serde(default)]
579    pub dictionary_paths: HashMap<String, String>,
580    /// Extra directories to search, before the platform's own.
581    #[serde(default)]
582    pub search_paths: Vec<String>,
583    /// Fetch a missing pack without being asked.
584    ///
585    /// Off by default: a dictionary is a third-party download under its own
586    /// licence -- Hspell is AGPL-3.0, the Latin pack GPL -- and that is a
587    /// decision to put to the user rather than to make for them.
588    #[serde(default)]
589    pub auto_install: bool,
590}
591
592impl EngineToggle for HunspellConfig {
593    fn enabled(&self) -> bool {
594        self.enabled
595    }
596    fn set_enabled(&mut self, v: bool) {
597        self.enabled = v;
598    }
599}
600
601impl EngineToggle for LanguageToolConfig {
602    fn enabled(&self) -> bool {
603        self.enabled
604    }
605    fn set_enabled(&mut self, v: bool) {
606        self.enabled = v;
607    }
608}
609
610/// Vale engine configuration.
611#[derive(Debug, Default, Serialize, Deserialize, Clone)]
612pub struct ValeConfig {
613    #[serde(default)]
614    pub enabled: bool,
615    /// Path to `.vale.ini`. When empty, Vale uses its own search logic.
616    #[serde(default)]
617    pub config: Option<String>,
618}
619
620impl EngineToggle for ValeConfig {
621    fn enabled(&self) -> bool {
622        self.enabled
623    }
624    fn set_enabled(&mut self, v: bool) {
625        self.enabled = v;
626    }
627}
628
629/// Proselint engine configuration.
630#[derive(Debug, Default, Serialize, Deserialize, Clone)]
631pub struct ProselintConfig {
632    #[serde(default)]
633    pub enabled: bool,
634    /// Path to `proselint.json` config. When empty, proselint uses its own search logic.
635    #[serde(default)]
636    pub config: Option<String>,
637}
638
639impl EngineToggle for ProselintConfig {
640    fn enabled(&self) -> bool {
641        self.enabled
642    }
643    fn set_enabled(&mut self, v: bool) {
644        self.enabled = v;
645    }
646}
647
648/// An external checker binary that communicates via stdin/stdout JSON.
649///
650/// The binary receives `{"text": "...", "language_id": "..."}` on stdin
651/// and returns `[{"start_byte": N, "end_byte": N, "message": "...", ...}]` on stdout.
652#[derive(Debug, Serialize, Deserialize, Clone)]
653pub struct ExternalProvider {
654    /// Display name for this provider.
655    pub name: String,
656    /// Path to the executable.
657    pub command: String,
658    /// Optional arguments to pass to the command.
659    #[serde(default)]
660    pub args: Vec<String>,
661    /// File extensions this provider parses, without the dot (empty = all).
662    ///
663    /// The markup it understands, which is a different question from the
664    /// language it speaks.
665    #[serde(default)]
666    pub extensions: Vec<String>,
667    /// BCP-47 tags this provider checks (empty = all).
668    ///
669    /// Without this a provider claims every language, including ones it has
670    /// no idea what to do with -- and claiming a language suppresses the
671    /// report that says nothing could check it.
672    #[serde(default)]
673    pub languages: Vec<String>,
674}
675
676/// A WASM plugin loaded via Extism.
677///
678/// Plugins must export a `check` function that receives a JSON string
679/// `{"text": "...", "language_id": "..."}` and returns a JSON array of diagnostics.
680#[derive(Debug, Serialize, Deserialize, Clone)]
681pub struct WasmPlugin {
682    /// Display name for this plugin.
683    pub name: String,
684    /// Path to the `.wasm` file (relative to workspace root or absolute).
685    pub path: String,
686    /// File extensions this plugin parses, without the dot (empty = all).
687    #[serde(default)]
688    pub extensions: Vec<String>,
689    /// BCP-47 tags this plugin checks (empty = all).
690    #[serde(default)]
691    pub languages: Vec<String>,
692}
693
694impl Default for EngineConfig {
695    fn default() -> Self {
696        Self {
697            harper: HarperConfig::default(),
698            languagetool: LanguageToolConfig::default(),
699            vale: ValeConfig::default(),
700            proselint: ProselintConfig::default(),
701            hunspell: HunspellConfig::default(),
702            external: Vec::new(),
703            wasm_plugins: Vec::new(),
704            spell_language: default_spell_language(),
705        }
706    }
707}
708
709#[derive(Debug, Serialize, Deserialize, Clone)]
710pub struct RuleConfig {
711    pub severity: Option<String>, // "error", "warning", "info", "hint", "off"
712}
713
714const fn default_true() -> bool {
715    true
716}
717fn default_lt_url() -> String {
718    "http://localhost:8010".to_string()
719}
720fn default_spell_language() -> String {
721    "en-US".to_string()
722}
723fn default_exclude() -> Vec<String> {
724    // Each directory name carries a `**/` prefix because a glob is anchored
725    // at the start of the workspace-relative path. Written as `.venv/**`
726    // these covered a virtualenv at the repository root and nothing else, so
727    // `docs/.venv/` was walked in full -- a thousand findings from
728    // site-packages READMEs nobody here wrote.
729    vec![
730        "**/node_modules/**".to_string(),
731        "**/.git/**".to_string(),
732        "**/target/**".to_string(),
733        "**/dist/**".to_string(),
734        "**/build/**".to_string(),
735        "**/.next/**".to_string(),
736        "**/.nuxt/**".to_string(),
737        "**/vendor/**".to_string(),
738        "**/__pycache__/**".to_string(),
739        "**/.venv/**".to_string(),
740        "**/venv/**".to_string(),
741        "**/.tox/**".to_string(),
742        "**/.mypy_cache/**".to_string(),
743        "*.min.js".to_string(),
744        "*.min.css".to_string(),
745        "*.bundle.js".to_string(),
746        "package-lock.json".to_string(),
747        "yarn.lock".to_string(),
748        "pnpm-lock.yaml".to_string(),
749    ]
750}
751
752impl Config {
753    /// Load configuration, warning and falling back to defaults if it cannot be read.
754    ///
755    /// `load` fails on a malformed `.languagecheck.yaml` — a bad indent, a typo'd enum — and
756    /// callers used to answer that with a bare `Config::default()`, so a rejected file was
757    /// indistinguishable from an absent one and the user's overrides silently did nothing.
758    /// A missing file is not an error and is not reported; an unreadable one is.
759    ///
760    /// Callers with no `tracing` subscriber installed (the CLI binary) must report to stderr
761    /// themselves rather than call this, or the warning goes nowhere.
762    #[must_use]
763    pub fn load_or_warn(workspace_root: &Path) -> Self {
764        Self::load(workspace_root).unwrap_or_else(|e| {
765            warn!(
766                root = %workspace_root.display(),
767                "Ignoring unreadable workspace config, using defaults: {e}"
768            );
769            Self::default()
770        })
771    }
772
773    /// Whether `exclude` covers this path.
774    ///
775    /// `path` may be absolute or already relative to `workspace_root`; it is
776    /// reduced to the workspace-relative form the patterns are written
777    /// against, because `node_modules/**` is how a user thinks about it and
778    /// an absolute path would never match.
779    ///
780    /// An unparseable pattern excludes nothing. Refusing to check a file
781    /// because a glob had a typo is the worse of the two failures.
782    #[must_use]
783    pub fn excludes(&self, path: &Path, workspace_root: &Path) -> bool {
784        matches_any(&self.exclude, path, workspace_root)
785    }
786
787    /// Whether `include` selects this path.
788    ///
789    /// An empty `include` selects everything, so a config that never mentions
790    /// the key behaves as it always did.
791    #[must_use]
792    pub fn includes(&self, path: &Path, workspace_root: &Path) -> bool {
793        self.include.is_empty() || matches_any(&self.include, path, workspace_root)
794    }
795
796    /// Whether `file_types` admits this path's extension.
797    ///
798    /// An empty list admits everything, so a config that never mentions the
799    /// key behaves as it always did. A path with no extension is admitted
800    /// too: the list is a restriction on types, and a file that has no type
801    /// was never selected by one.
802    #[must_use]
803    pub fn admits_type(&self, path: &Path) -> bool {
804        if self.file_types.is_empty() {
805            return true;
806        }
807        let Some(extension) = path.extension().and_then(|e| e.to_str()) else {
808            return true;
809        };
810        self.file_types.iter().any(|wanted| {
811            wanted
812                .trim_start_matches('.')
813                .eq_ignore_ascii_case(extension)
814        })
815    }
816
817    /// Whether this project checks this file at all.
818    ///
819    /// The one question the indexer, the CLI and the editor all have to
820    /// answer the same way: a file the editor still checks after the indexer
821    /// skipped it is the inconsistency this exists to avoid. `include`
822    /// selects and `exclude` subtracts, so an excluded path stays excluded
823    /// however explicitly `include` names it.
824    #[must_use]
825    pub fn checks(&self, path: &Path, workspace_root: &Path) -> bool {
826        self.admits_type(path)
827            && self.includes(path, workspace_root)
828            && !self.excludes(path, workspace_root)
829    }
830}
831
832/// Whether any of `patterns` matches `path`, relative to the workspace.
833///
834/// Separators are normalised because the patterns are written with `/` --
835/// `node_modules/**` is how anyone writes it, on any platform -- while the
836/// path arrives with the platform's own. Without this, `exclude` matched
837/// nothing at all on Windows and said nothing about why.
838fn matches_any(patterns: &[String], path: &Path, workspace_root: &Path) -> bool {
839    if patterns.is_empty() {
840        return false;
841    }
842    let relative = path.strip_prefix(workspace_root).unwrap_or(path);
843    let as_text = relative.to_string_lossy().replace('\\', "/");
844    let options = glob::MatchOptions {
845        require_literal_separator: false,
846        require_literal_leading_dot: false,
847        case_sensitive: true,
848    };
849    patterns
850        .iter()
851        .filter_map(|pattern| glob::Pattern::new(pattern).ok())
852        .any(|pattern| pattern.matches_with(&as_text, options))
853}
854
855impl Config {
856    /// Fold the built-in excludes into whatever the file supplied.
857    ///
858    /// Done at load so every later reader -- the editor, the indexer, the
859    /// CLI and `config show` -- sees one list, and the list it sees is the
860    /// one in force.
861    fn merge_default_excludes(&mut self) {
862        for pattern in default_exclude() {
863            if !self.exclude.contains(&pattern) {
864                self.exclude.push(pattern);
865            }
866        }
867    }
868
869    /// Make workspace-relative paths in the config absolute.
870    ///
871    /// A path in `.languagecheck.yaml` means "relative to the workspace",
872    /// which is the only reading that makes sense to whoever wrote it. It was
873    /// reaching Vale as written, and Vale is spawned by the core, whose
874    /// working directory is wherever the editor started it -- so the
875    /// documented `config: ".vale.ini"` worked from the CLI, where the two
876    /// coincide, and silently did nothing in VS Code, where they do not. The
877    /// dictionary paths were already resolved against the root; this brings
878    /// the rest into line.
879    ///
880    /// An absolute path is left alone. So is an external provider's command
881    /// when it is a bare name: that form is looked up on PATH, and making it
882    /// workspace-relative would break the one spelling that has no reason to
883    /// be.
884    fn resolve_paths(&mut self, workspace_root: &Path) {
885        let absolute = |value: &str| -> String {
886            let path = Path::new(value);
887            if path.is_absolute() {
888                value.to_string()
889            } else {
890                workspace_root.join(path).to_string_lossy().into_owned()
891            }
892        };
893
894        if let Some(vale_config) = &self.engines.vale.config {
895            self.engines.vale.config = Some(absolute(vale_config));
896        }
897        if let Some(proselint_config) = &self.engines.proselint.config {
898            self.engines.proselint.config = Some(absolute(proselint_config));
899        }
900        for plugin in &mut self.engines.wasm_plugins {
901            plugin.path = absolute(&plugin.path);
902        }
903        for provider in &mut self.engines.external {
904            // Only when it is written as a path. A bare name is looked up on
905            // PATH, and turning `my-checker` into `<root>/my-checker` would
906            // break the one form that has no reason to be workspace-relative.
907            if provider.command.contains(std::path::MAIN_SEPARATOR)
908                || provider.command.contains('/')
909            {
910                provider.command = absolute(&provider.command);
911            }
912        }
913    }
914
915    /// Parse a config from text that is not on disk yet.
916    ///
917    /// The editor asks about the buffer it is showing, which is not the file
918    /// the engines are running under: a half-typed URL has to be answerable
919    /// before it is saved, or the feedback arrives after the mistake has
920    /// already been committed. Relative paths still resolve against
921    /// `workspace_root`, because that is what they will mean once the file is
922    /// written.
923    ///
924    /// `format` is the file's extension when the caller knows it. YAML 1.2 is
925    /// a superset of JSON, so the YAML parser reads both and "json" only
926    /// picks the stricter reader for a better error message.
927    pub fn parse_text(text: &str, workspace_root: &Path, format: &str) -> Result<Self> {
928        let mut config: Self = if format.eq_ignore_ascii_case("json") {
929            serde_json::from_str(text)?
930        } else {
931            serde_yaml::from_str(text)?
932        };
933        config.merge_default_excludes();
934        config.resolve_paths(workspace_root);
935        Ok(config)
936    }
937
938    /// Every unknown key in `text`, as dotted paths, for a caller that wants
939    /// to report them against a position instead of a log line.
940    ///
941    /// [`warn_unknown_keys`] writes the same finding to the log, which is
942    /// where it went before anything could draw it.
943    #[must_use]
944    pub fn unknown_key_paths(text: &str) -> Vec<String> {
945        let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(text) else {
946            return Vec::new();
947        };
948        let mut out: Vec<String> = unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS);
949        if let Some(engines) = value.get("engines") {
950            out.extend(
951                unknown_keys(engines, KNOWN_ENGINE_KEYS)
952                    .into_iter()
953                    .map(|k| format!("engines.{k}")),
954            );
955        }
956        out
957    }
958
959    pub fn load(workspace_root: &Path) -> Result<Self> {
960        // Prefer YAML, fall back to JSON for backward compatibility
961        let yaml_path = workspace_root.join(".languagecheck.yaml");
962        let yml_path = workspace_root.join(".languagecheck.yml");
963        let json_path = workspace_root.join(".languagecheck.json");
964
965        if yaml_path.exists() {
966            let content = std::fs::read_to_string(yaml_path)?;
967            warn_duplicate_rule_keys(&content);
968            let mut config: Self = serde_yaml::from_str(&content)?;
969            warn_unknown_keys(&serde_yaml::from_str(&content)?);
970            config.merge_default_excludes();
971            config.resolve_paths(workspace_root);
972            Ok(config)
973        } else if yml_path.exists() {
974            let content = std::fs::read_to_string(yml_path)?;
975            warn_duplicate_rule_keys(&content);
976            let mut config: Self = serde_yaml::from_str(&content)?;
977            warn_unknown_keys(&serde_yaml::from_str(&content)?);
978            config.merge_default_excludes();
979            config.resolve_paths(workspace_root);
980            Ok(config)
981        } else if json_path.exists() {
982            let content = std::fs::read_to_string(json_path)?;
983            let mut config: Self = serde_json::from_str(&content)?;
984            // YAML 1.2 is a superset of JSON, so one key scanner covers both formats.
985            warn_unknown_keys(&serde_yaml::from_str(&content)?);
986            config.merge_default_excludes();
987            config.resolve_paths(workspace_root);
988            Ok(config)
989        } else {
990            Ok(Self::default())
991        }
992    }
993
994    /// Apply user-defined auto-fix rules to the given text, returning the modified text
995    /// and the number of replacements made.
996    #[must_use]
997    pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
998        let mut result = text.to_string();
999        let mut total = 0;
1000
1001        for rule in &self.auto_fix {
1002            if let Some(ctx) = &rule.context
1003                && !result.contains(ctx.as_str())
1004            {
1005                continue;
1006            }
1007            let count = result.matches(&rule.find).count();
1008            if count > 0 {
1009                result = result.replace(&rule.find, &rule.replace);
1010                total += count;
1011            }
1012        }
1013
1014        (result, total)
1015    }
1016}
1017
1018/// Collect rule keys that appear more than once under the top-level `rules:`
1019/// mapping of a raw YAML config, in first-seen order.
1020///
1021/// `serde_yaml` silently keeps only the last value for a duplicated mapping
1022/// key, so duplicates vanish after parsing; this scans the raw text so they can
1023/// be surfaced. Recognizes block-style child keys (`  some.rule:` on its own
1024/// line) at the mapping's first child indentation.
1025fn duplicate_rule_keys(content: &str) -> Vec<String> {
1026    let mut in_rules = false;
1027    let mut child_indent: Option<usize> = None;
1028    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1029    let mut duplicates: Vec<String> = Vec::new();
1030
1031    for line in content.lines() {
1032        if line.trim().is_empty() {
1033            continue;
1034        }
1035        let indent = line.len() - line.trim_start().len();
1036
1037        if !in_rules {
1038            if indent == 0 && line.trim() == "rules:" {
1039                in_rules = true;
1040            }
1041            continue;
1042        }
1043
1044        // A new top-level key ends the rules block.
1045        if indent == 0 {
1046            break;
1047        }
1048
1049        let child = *child_indent.get_or_insert(indent);
1050        if indent != child {
1051            continue; // deeper line (e.g. `severity: ...`), not a rule key
1052        }
1053        if let Some(key) = line.trim().strip_suffix(':') {
1054            let key = key.trim().to_string();
1055            if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
1056                duplicates.push(key);
1057            }
1058        }
1059    }
1060
1061    duplicates
1062}
1063
1064/// Top-level keys [`Config`] understands.
1065const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
1066    "engines",
1067    "rules",
1068    "include",
1069    "file_types",
1070    "exclude",
1071    "auto_fix",
1072    "performance",
1073    "dictionaries",
1074    "languages",
1075    "workspace",
1076    "names",
1077    "morphology",
1078];
1079
1080/// Keys [`EngineConfig`] understands, including the deprecated flat aliases.
1081const KNOWN_ENGINE_KEYS: &[&str] = &[
1082    "harper",
1083    "languagetool",
1084    "vale",
1085    "proselint",
1086    "hunspell",
1087    "external",
1088    "wasm_plugins",
1089    "spell_language",
1090    "languagetool_url",
1091    "vale_config",
1092];
1093
1094/// Collect the keys of `value`'s `section` mapping that are not in `known`.
1095fn unknown_keys(value: &serde_yaml::Value, known: &[&str]) -> Vec<String> {
1096    let Some(map) = value.as_mapping() else {
1097        return Vec::new();
1098    };
1099    map.keys()
1100        .filter_map(serde_yaml::Value::as_str)
1101        .filter(|k| !known.contains(k))
1102        .map(ToString::to_string)
1103        .collect()
1104}
1105
1106/// Warn about config keys nothing reads.
1107///
1108/// serde ignores what it does not recognise, so a typo'd or renamed key is
1109/// indistinguishable from an absent one: the setting simply never takes effect
1110/// and the user is left debugging the default. Reporting them turns a silent
1111/// no-op into a line in the log.
1112fn warn_unknown_keys(value: &serde_yaml::Value) {
1113    let unknown = unknown_keys(value, KNOWN_TOP_LEVEL_KEYS);
1114    if !unknown.is_empty() {
1115        warn!(keys = ?unknown, "Unknown keys in workspace config; they have no effect.");
1116    }
1117    if let Some(engines) = value.get("engines") {
1118        let unknown = unknown_keys(engines, KNOWN_ENGINE_KEYS);
1119        if !unknown.is_empty() {
1120            warn!(keys = ?unknown, "Unknown keys under `engines:`; they have no effect.");
1121        }
1122    }
1123}
1124
1125/// Log a warning if a raw YAML config contains duplicate rule keys.
1126fn warn_duplicate_rule_keys(content: &str) {
1127    let duplicates = duplicate_rule_keys(content);
1128    if !duplicates.is_empty() {
1129        warn!(
1130            duplicates = ?duplicates,
1131            "Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
1132             effect. Remove the extra copies to keep the ignore list clean."
1133        );
1134    }
1135}
1136
1137impl Default for Config {
1138    fn default() -> Self {
1139        Self {
1140            engines: EngineConfig::default(),
1141            rules: HashMap::new(),
1142            // Empty: a config that never mentions `include` checks
1143            // everything, which is what this did before the key existed.
1144            include: Vec::new(),
1145            file_types: Vec::new(),
1146            exclude: default_exclude(),
1147            auto_fix: Vec::new(),
1148            performance: PerformanceConfig::default(),
1149            dictionaries: DictionaryConfig::default(),
1150            languages: LanguageConfig::default(),
1151            workspace: WorkspaceConfig::default(),
1152            names: NameConfig::default(),
1153            morphology: MorphologyConfig::default(),
1154        }
1155    }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160
1161    #[test]
1162    fn every_engine_key_the_config_accepts_is_declared_known() {
1163        // A field that parses but is not listed here is reported to the user
1164        // as having no effect, which is the opposite of true and reads as the
1165        // feature being unsupported. Adding an engine means adding it twice,
1166        // so this is the reminder.
1167        let yaml = "\
1168engines:
1169  harper: false
1170  languagetool: false
1171  vale: false
1172  proselint: false
1173  hunspell:
1174    enabled: true
1175  spell_language: en-US
1176";
1177        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1178        let engines = value.get("engines").expect("engines section");
1179        assert_eq!(
1180            unknown_keys(engines, KNOWN_ENGINE_KEYS),
1181            Vec::<String>::new(),
1182            "an engine key parses but is not declared known"
1183        );
1184    }
1185    use super::*;
1186
1187    #[test]
1188    fn duplicate_rule_keys_detects_repeats() {
1189        let yaml = "rules:\n  languagetool.ARROWS:\n    severity: \"off\"\n  \
1190                    languagetool.UPPERCASE_SENTENCE_START:\n    severity: \"off\"\n  \
1191                    languagetool.ARROWS:\n    severity: \"off\"\n  \
1192                    languagetool.UPPERCASE_SENTENCE_START:\n    severity: \"off\"\n  \
1193                    languagetool.THE_SUPERLATIVE:\n    severity: \"off\"\n";
1194        let dups = duplicate_rule_keys(yaml);
1195        assert_eq!(
1196            dups,
1197            vec![
1198                "languagetool.ARROWS".to_string(),
1199                "languagetool.UPPERCASE_SENTENCE_START".to_string()
1200            ]
1201        );
1202    }
1203
1204    #[test]
1205    fn duplicate_rule_keys_clean_list_is_empty() {
1206        let yaml = "rules:\n  a.B:\n    severity: \"off\"\n  c.D:\n    severity: \"off\"\n";
1207        assert!(duplicate_rule_keys(yaml).is_empty());
1208    }
1209
1210    #[test]
1211    fn duplicate_rule_keys_stops_at_next_section() {
1212        // A repeat under a *different* top-level section must not count.
1213        let yaml = "rules:\n  a.B:\n    severity: \"off\"\nengines:\n  harper: false\n";
1214        assert!(duplicate_rule_keys(yaml).is_empty());
1215    }
1216
1217    #[test]
1218    fn morphology_is_on_by_default() {
1219        let config = Config::default();
1220        assert!(config.morphology.enabled);
1221        assert!(config.morphology.inflections);
1222    }
1223
1224    #[test]
1225    fn morphology_can_be_switched_off_from_yaml() {
1226        let yaml = "morphology:\n  enabled: false\n";
1227        let config: Config = serde_yaml::from_str(yaml).unwrap();
1228        assert!(!config.morphology.enabled);
1229        // An unmentioned field keeps its default rather than falling to `false`.
1230        assert!(config.morphology.inflections);
1231    }
1232
1233    #[test]
1234    fn default_dictionaries_load_all_bundled_sets() {
1235        let config = Config::default();
1236        assert!(config.dictionaries.bundled);
1237        assert!(config.dictionaries.disabled.is_empty());
1238        assert!(config.dictionaries.paths.is_empty());
1239    }
1240
1241    #[test]
1242    fn dictionaries_disabled_from_yaml() {
1243        let config: Config = serde_yaml::from_str(
1244            r"
1245dictionaries:
1246  disabled: [companies, mathematics]
1247",
1248        )
1249        .unwrap();
1250        assert_eq!(config.dictionaries.disabled, ["companies", "mathematics"]);
1251        // The master switch is untouched by listing individual sets.
1252        assert!(config.dictionaries.bundled);
1253    }
1254
1255    #[test]
1256    fn default_config_has_harper_enabled_lt_disabled() {
1257        let config = Config::default();
1258        assert!(config.engines.harper.enabled);
1259        assert!(!config.engines.languagetool.enabled);
1260    }
1261
1262    #[test]
1263    fn default_config_has_standard_excludes() {
1264        // Asserted through `checks` rather than on the pattern strings: what
1265        // matters is that these directories are skipped wherever they sit,
1266        // and spelling them out again here only pins the spelling.
1267        let config = Config::default();
1268        let root = Path::new("/ws");
1269        for directory in ["node_modules", ".git", "target", "dist", "vendor"] {
1270            assert!(
1271                !config.checks(&root.join(directory).join("a.md"), root),
1272                "{directory} at the root should be skipped"
1273            );
1274            assert!(
1275                !config.checks(&root.join("nested").join(directory).join("a.md"), root),
1276                "{directory} one level down should be skipped too"
1277            );
1278        }
1279    }
1280
1281    #[test]
1282    fn default_lt_url() {
1283        let config = Config::default();
1284        assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
1285    }
1286
1287    #[test]
1288    fn load_from_json_string() {
1289        let json = r#"{
1290            "engines": { "harper": true, "languagetool": false },
1291            "rules": { "spelling.typo": { "severity": "warning" } }
1292        }"#;
1293        let config: Config = serde_json::from_str(json).unwrap();
1294        assert!(config.engines.harper.enabled);
1295        assert!(!config.engines.languagetool.enabled);
1296        assert!(config.rules.contains_key("spelling.typo"));
1297        assert_eq!(
1298            config.rules["spelling.typo"].severity.as_deref(),
1299            Some("warning")
1300        );
1301    }
1302
1303    #[test]
1304    fn load_partial_json_uses_defaults() {
1305        let json = r#"{}"#;
1306        let config: Config = serde_json::from_str(json).unwrap();
1307        assert!(config.engines.harper.enabled);
1308        assert!(!config.engines.languagetool.enabled);
1309        assert!(config.rules.is_empty());
1310    }
1311
1312    #[test]
1313    fn load_from_json_file() {
1314        let dir = std::env::temp_dir().join("lang_check_test_config_json");
1315        let _ = std::fs::remove_dir_all(&dir);
1316        std::fs::create_dir_all(&dir).unwrap();
1317
1318        let config_path = dir.join(".languagecheck.json");
1319        std::fs::write(
1320            &config_path,
1321            r#"{"engines": {"harper": false, "languagetool": true}}"#,
1322        )
1323        .unwrap();
1324
1325        let config = Config::load(&dir).unwrap();
1326        assert!(!config.engines.harper.enabled);
1327        assert!(config.engines.languagetool.enabled);
1328
1329        let _ = std::fs::remove_dir_all(&dir);
1330    }
1331
1332    #[test]
1333    fn load_from_yaml_file() {
1334        let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
1335        let _ = std::fs::remove_dir_all(&dir);
1336        std::fs::create_dir_all(&dir).unwrap();
1337
1338        let config_path = dir.join(".languagecheck.yaml");
1339        std::fs::write(
1340            &config_path,
1341            "engines:\n  harper: false\n  languagetool: true\n",
1342        )
1343        .unwrap();
1344
1345        let config = Config::load(&dir).unwrap();
1346        assert!(!config.engines.harper.enabled);
1347        assert!(config.engines.languagetool.enabled);
1348
1349        let _ = std::fs::remove_dir_all(&dir);
1350    }
1351
1352    #[test]
1353    fn yaml_takes_precedence_over_json() {
1354        let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
1355        let _ = std::fs::remove_dir_all(&dir);
1356        std::fs::create_dir_all(&dir).unwrap();
1357
1358        // Write both files with different values
1359        std::fs::write(
1360            dir.join(".languagecheck.yaml"),
1361            "engines:\n  harper: false\n",
1362        )
1363        .unwrap();
1364        std::fs::write(
1365            dir.join(".languagecheck.json"),
1366            r#"{"engines": {"harper": true}}"#,
1367        )
1368        .unwrap();
1369
1370        let config = Config::load(&dir).unwrap();
1371        // YAML should win
1372        assert!(!config.engines.harper.enabled);
1373
1374        let _ = std::fs::remove_dir_all(&dir);
1375    }
1376
1377    #[test]
1378    fn load_missing_file_returns_default() {
1379        let dir = std::env::temp_dir().join("lang_check_test_config_missing");
1380        let _ = std::fs::remove_dir_all(&dir);
1381        std::fs::create_dir_all(&dir).unwrap();
1382
1383        let config = Config::load(&dir).unwrap();
1384        assert!(config.engines.harper.enabled);
1385
1386        let _ = std::fs::remove_dir_all(&dir);
1387    }
1388
1389    #[test]
1390    fn exclude_matches_a_path_relative_to_the_workspace() {
1391        let config = Config {
1392            exclude: vec!["drafts/**".to_string(), "node_modules/**".to_string()],
1393            ..Config::default()
1394        };
1395        let root = Path::new("/home/someone/project");
1396
1397        assert!(config.excludes(&root.join("drafts/notes.md"), root));
1398        assert!(config.excludes(&root.join("node_modules/pkg/README.md"), root));
1399        assert!(!config.excludes(&root.join("docs/notes.md"), root));
1400    }
1401
1402    #[test]
1403    fn exclude_accepts_a_path_that_is_already_relative() {
1404        // The indexer has relative paths and the editor absolute ones, and
1405        // both ask the same question.
1406        let config = Config {
1407            exclude: vec!["drafts/**".to_string()],
1408            ..Config::default()
1409        };
1410        let root = Path::new("/home/someone/project");
1411        assert!(config.excludes(Path::new("drafts/notes.md"), root));
1412    }
1413
1414    #[test]
1415    fn exclude_matches_whichever_separator_the_platform_uses() {
1416        // The patterns are written with `/` on every platform; the path
1417        // arrives with the platform's own separator. Matching the two
1418        // literally meant `exclude` never matched anything on Windows.
1419        let config = Config {
1420            exclude: vec!["drafts/**".to_string()],
1421            ..Config::default()
1422        };
1423        let root = Path::new("/home/someone/project");
1424        let with_backslashes = root.join("drafts").join("notes.md");
1425        assert!(config.excludes(&with_backslashes, root));
1426    }
1427
1428    #[test]
1429    fn a_config_that_excludes_something_of_its_own_still_excludes_node_modules() {
1430        // The trap this closes: writing one exclude used to replace the
1431        // built-in list wholesale, so adding a pattern could multiply the
1432        // work by a hundred and nothing said so.
1433        let config = Config::parse_text(
1434            "exclude:\n  - \"docs/_build/**\"\n",
1435            Path::new("/ws"),
1436            "yaml",
1437        )
1438        .expect("parses");
1439        let root = Path::new("/ws");
1440        assert!(!config.checks(&root.join("docs/_build/html/a.md"), root));
1441        assert!(!config.checks(&root.join("docs/.venv/lib/pkg/README.md"), root));
1442        assert!(!config.checks(&root.join("extension/node_modules/p/readme.md"), root));
1443        assert!(config.checks(&root.join("docs/guide.md"), root));
1444    }
1445
1446    #[test]
1447    fn merging_the_defaults_does_not_duplicate_a_pattern_the_file_repeats() {
1448        let config = Config::parse_text(
1449            "exclude:\n  - \"**/node_modules/**\"\n",
1450            Path::new("/ws"),
1451            "yaml",
1452        )
1453        .expect("parses");
1454        assert_eq!(
1455            config
1456                .exclude
1457                .iter()
1458                .filter(|p| p.as_str() == "**/node_modules/**")
1459                .count(),
1460            1
1461        );
1462    }
1463
1464    #[test]
1465    fn an_absent_file_types_list_admits_every_type() {
1466        let config = Config::default();
1467        let root = Path::new("/ws");
1468        assert!(config.checks(&root.join("docs/a.md"), root));
1469        assert!(config.checks(&root.join("docs/a.tex"), root));
1470    }
1471
1472    #[test]
1473    fn file_types_restricts_to_the_extensions_it_names() {
1474        let config = Config {
1475            file_types: vec!["md".to_string()],
1476            exclude: Vec::new(),
1477            ..Config::default()
1478        };
1479        let root = Path::new("/ws");
1480        assert!(config.checks(&root.join("docs/a.md"), root));
1481        assert!(!config.checks(&root.join("docs/a.tex"), root));
1482        assert!(!config.checks(&root.join("docs/a.typ"), root));
1483    }
1484
1485    #[test]
1486    fn file_types_accepts_a_leading_dot_and_ignores_case() {
1487        // `.md` is how half of everyone will write it, and `MD` is how the
1488        // other half will write it on a case-insensitive filesystem.
1489        let config = Config {
1490            file_types: vec![".MD".to_string()],
1491            exclude: Vec::new(),
1492            ..Config::default()
1493        };
1494        let root = Path::new("/ws");
1495        assert!(config.checks(&root.join("docs/a.md"), root));
1496        assert!(!config.checks(&root.join("docs/a.rst"), root));
1497    }
1498
1499    #[test]
1500    fn file_types_and_include_both_have_to_admit_a_path() {
1501        let config = Config {
1502            file_types: vec!["md".to_string()],
1503            include: vec!["docs/**".to_string()],
1504            exclude: Vec::new(),
1505            ..Config::default()
1506        };
1507        let root = Path::new("/ws");
1508        assert!(config.checks(&root.join("docs/a.md"), root));
1509        // Right type, wrong place.
1510        assert!(!config.checks(&root.join("src/a.md"), root));
1511        // Right place, wrong type.
1512        assert!(!config.checks(&root.join("docs/a.tex"), root));
1513    }
1514
1515    #[test]
1516    fn an_absent_include_selects_everything() {
1517        // The key is new, so every config written before it exists has to go
1518        // on meaning what it meant.
1519        let config = Config::default();
1520        let root = Path::new("/ws");
1521        assert!(config.checks(&root.join("docs/guide.md"), root));
1522        assert!(config.checks(&root.join("src/deep/notes.md"), root));
1523    }
1524
1525    #[test]
1526    fn an_include_narrows_to_what_it_names() {
1527        let config = Config {
1528            include: vec!["docs/**".to_string(), "README.md".to_string()],
1529            exclude: Vec::new(),
1530            ..Config::default()
1531        };
1532        let root = Path::new("/ws");
1533        assert!(config.checks(&root.join("docs/guide/languages.md"), root));
1534        assert!(config.checks(&root.join("README.md"), root));
1535        // The point of the key: everything else stops being looked at
1536        // without having to be named.
1537        assert!(!config.checks(&root.join("extension/src/test/fixtures/a.md"), root));
1538        assert!(!config.checks(&root.join("rust-core/target/doc/x.md"), root));
1539    }
1540
1541    #[test]
1542    fn a_star_crosses_directory_separators_in_both_lists() {
1543        // Pinned because it is the one place these globs differ from a
1544        // type-checker's, and the difference decides what `include: ["*.md"]`
1545        // means: every Markdown file in the tree, not the ones beside the
1546        // config. Write `docs/**` to anchor.
1547        let config = Config {
1548            include: vec!["*.md".to_string()],
1549            exclude: Vec::new(),
1550            ..Config::default()
1551        };
1552        let root = Path::new("/ws");
1553        assert!(config.checks(&root.join("deep/nested/note.md"), root));
1554    }
1555
1556    #[test]
1557    fn a_nested_build_directory_is_excluded_by_default() {
1558        // `docs/.venv/` is the case that motivated the `**/` prefixes: a
1559        // virtualenv one level down was checked in full.
1560        let config = Config::default();
1561        let root = Path::new("/ws");
1562        assert!(!config.checks(&root.join("docs/.venv/lib/pkg/README.md"), root));
1563        assert!(!config.checks(&root.join("extension/node_modules/p/readme.md"), root));
1564        assert!(!config.checks(&root.join(".venv/lib/a.md"), root));
1565    }
1566
1567    #[test]
1568    fn exclude_subtracts_from_include_and_not_the_other_way_round() {
1569        // A path both name is excluded. Otherwise narrowing to `docs/**` and
1570        // then dropping `docs/_build/**` would be impossible to express.
1571        let config = Config {
1572            include: vec!["docs/**".to_string()],
1573            exclude: vec!["docs/_build/**".to_string()],
1574            ..Config::default()
1575        };
1576        let root = Path::new("/ws");
1577        assert!(config.checks(&root.join("docs/index.md"), root));
1578        assert!(!config.checks(&root.join("docs/_build/html/index.md"), root));
1579    }
1580
1581    #[test]
1582    fn an_empty_include_list_is_not_an_empty_selection() {
1583        // `include: []` reads as "no opinion", not "check nothing". The
1584        // opposite reading turns an accidental empty list into a checker
1585        // that silently does nothing at all.
1586        let config = Config {
1587            include: Vec::new(),
1588            exclude: Vec::new(),
1589            ..Config::default()
1590        };
1591        assert!(config.checks(Path::new("/ws/anything.md"), Path::new("/ws")));
1592    }
1593
1594    #[test]
1595    fn an_empty_exclude_list_excludes_nothing() {
1596        let config = Config::default();
1597        let root = Path::new("/tmp");
1598        assert!(!config.excludes(&root.join("anything.md"), root));
1599    }
1600
1601    #[test]
1602    fn a_malformed_pattern_excludes_nothing_rather_than_everything() {
1603        // Refusing to check a file because a glob had a typo is the worse of
1604        // the two failures: the user sees silence and no reason for it.
1605        let config = Config {
1606            exclude: vec!["[unclosed".to_string(), "drafts/**".to_string()],
1607            ..Config::default()
1608        };
1609        let root = Path::new("/tmp");
1610        assert!(!config.excludes(&root.join("notes.md"), root));
1611        assert!(config.excludes(&root.join("drafts/notes.md"), root));
1612    }
1613
1614    #[test]
1615    fn a_relative_vale_config_is_resolved_against_the_workspace() {
1616        // Vale is spawned by the core, whose working directory is wherever
1617        // the editor started it. A path left as written reached Vale meaning
1618        // something else entirely, so `config: ".vale.ini"` -- the documented
1619        // form -- worked from the CLI and silently did nothing in VS Code.
1620        let dir = std::env::temp_dir().join(format!("lc_resolve_{}", std::process::id()));
1621        std::fs::create_dir_all(&dir).unwrap();
1622        std::fs::write(
1623            dir.join(".languagecheck.yaml"),
1624            "engines:\n  vale:\n    enabled: true\n    config: \".vale.ini\"\n",
1625        )
1626        .unwrap();
1627
1628        let config = Config::load(&dir).expect("config");
1629        let resolved = config.engines.vale.config.expect("a config path");
1630        assert!(
1631            Path::new(&resolved).is_absolute(),
1632            "left relative: {resolved}"
1633        );
1634        assert!(resolved.ends_with(".vale.ini"), "{resolved}");
1635        assert!(resolved.starts_with(&*dir.to_string_lossy()), "{resolved}");
1636
1637        std::fs::remove_dir_all(&dir).ok();
1638    }
1639
1640    #[test]
1641    fn an_absolute_path_in_the_config_is_left_alone() {
1642        let dir = std::env::temp_dir().join(format!("lc_resolve_abs_{}", std::process::id()));
1643        std::fs::create_dir_all(&dir).unwrap();
1644
1645        // Taken from the platform rather than written out. `/etc/vale.ini` is
1646        // absolute on Unix and merely rooted on Windows, where it has no drive
1647        // -- so it is resolved against the workspace's drive, correctly, and a
1648        // test that hard-coded it would be testing the wrong thing there.
1649        let elsewhere = std::env::temp_dir().join("vale.ini");
1650        let elsewhere = elsewhere.to_string_lossy().into_owned();
1651        // Single-quoted, because a backslash inside a double-quoted YAML
1652        // scalar is an escape and a Windows path is full of them.
1653        std::fs::write(
1654            dir.join(".languagecheck.yaml"),
1655            format!("engines:\n  vale:\n    enabled: true\n    config: '{elsewhere}'\n"),
1656        )
1657        .unwrap();
1658
1659        let config = Config::load(&dir).expect("config");
1660        assert_eq!(
1661            config.engines.vale.config.as_deref(),
1662            Some(elsewhere.as_str())
1663        );
1664
1665        std::fs::remove_dir_all(&dir).ok();
1666    }
1667
1668    #[test]
1669    fn a_wasm_plugin_path_is_resolved_too() {
1670        // Same reasoning, same failure: a plugin named relative to the
1671        // workspace was looked for relative to the editor's cwd.
1672        let dir = std::env::temp_dir().join(format!("lc_resolve_wasm_{}", std::process::id()));
1673        std::fs::create_dir_all(&dir).unwrap();
1674        std::fs::write(
1675            dir.join(".languagecheck.yaml"),
1676            "engines:\n  wasm_plugins:\n    - name: p\n      path: plugins/p.wasm\n",
1677        )
1678        .unwrap();
1679
1680        let config = Config::load(&dir).expect("config");
1681        let resolved = &config.engines.wasm_plugins[0].path;
1682        assert!(
1683            Path::new(resolved).is_absolute(),
1684            "left relative: {resolved}"
1685        );
1686        // Compared with separators normalised: the join uses the platform's.
1687        assert!(
1688            resolved.replace('\\', "/").ends_with("plugins/p.wasm"),
1689            "{resolved}"
1690        );
1691
1692        std::fs::remove_dir_all(&dir).ok();
1693    }
1694
1695    #[test]
1696    fn a_relative_proselint_config_is_resolved_too() {
1697        // Same shape as Vale's, spawned the same way, with the same failure.
1698        let dir = std::env::temp_dir().join(format!("lc_resolve_pl_{}", std::process::id()));
1699        std::fs::create_dir_all(&dir).unwrap();
1700        std::fs::write(
1701            dir.join(".languagecheck.yaml"),
1702            "engines:\n  proselint:\n    enabled: true\n    config: \"proselint.json\"\n",
1703        )
1704        .unwrap();
1705
1706        let config = Config::load(&dir).expect("config");
1707        let resolved = config.engines.proselint.config.expect("a config path");
1708        assert!(
1709            Path::new(&resolved).is_absolute(),
1710            "left relative: {resolved}"
1711        );
1712        assert!(resolved.ends_with("proselint.json"), "{resolved}");
1713
1714        std::fs::remove_dir_all(&dir).ok();
1715    }
1716
1717    #[test]
1718    fn an_external_command_written_as_a_path_is_resolved() {
1719        let dir = std::env::temp_dir().join(format!("lc_resolve_ext_{}", std::process::id()));
1720        std::fs::create_dir_all(&dir).unwrap();
1721        std::fs::write(
1722            dir.join(".languagecheck.yaml"),
1723            "engines:\n  external:\n    - name: c\n      command: ./my-checker\n",
1724        )
1725        .unwrap();
1726
1727        let config = Config::load(&dir).expect("config");
1728        let command = &config.engines.external[0].command;
1729        assert!(Path::new(command).is_absolute(), "left relative: {command}");
1730        assert!(command.ends_with("my-checker"), "{command}");
1731
1732        std::fs::remove_dir_all(&dir).ok();
1733    }
1734
1735    #[test]
1736    fn an_external_command_that_is_a_bare_name_is_left_for_path_lookup() {
1737        // The one spelling that must not be touched: `vale` means "whatever
1738        // PATH finds", and `<root>/vale` means a file that is not there.
1739        let dir = std::env::temp_dir().join(format!("lc_resolve_bare_{}", std::process::id()));
1740        std::fs::create_dir_all(&dir).unwrap();
1741        std::fs::write(
1742            dir.join(".languagecheck.yaml"),
1743            "engines:\n  external:\n    - name: c\n      command: my-checker\n",
1744        )
1745        .unwrap();
1746
1747        let config = Config::load(&dir).expect("config");
1748        assert_eq!(config.engines.external[0].command, "my-checker");
1749
1750        std::fs::remove_dir_all(&dir).ok();
1751    }
1752
1753    #[test]
1754    fn auto_fix_simple_replacement() {
1755        let config = Config {
1756            auto_fix: vec![AutoFixRule {
1757                find: "teh".to_string(),
1758                replace: "the".to_string(),
1759                context: None,
1760                description: None,
1761            }],
1762            ..Config::default()
1763        };
1764        let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
1765        assert_eq!(result, "Fix the typo in the text.");
1766        assert_eq!(count, 2);
1767    }
1768
1769    #[test]
1770    fn auto_fix_with_context_filter() {
1771        let config = Config {
1772            auto_fix: vec![AutoFixRule {
1773                find: "colour".to_string(),
1774                replace: "color".to_string(),
1775                context: Some("American".to_string()),
1776                description: Some("Use American spelling".to_string()),
1777            }],
1778            ..Config::default()
1779        };
1780        // Context matches — replacement should happen
1781        let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
1782        assert_eq!(result, "American English: the color is red.");
1783        assert_eq!(count, 1);
1784
1785        // Context does not match — no replacement
1786        let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
1787        assert_eq!(result, "British English: the colour is red.");
1788        assert_eq!(count, 0);
1789    }
1790
1791    #[test]
1792    fn auto_fix_no_match() {
1793        let config = Config {
1794            auto_fix: vec![AutoFixRule {
1795                find: "foo".to_string(),
1796                replace: "bar".to_string(),
1797                context: None,
1798                description: None,
1799            }],
1800            ..Config::default()
1801        };
1802        let (result, count) = config.apply_auto_fixes("No matches here.");
1803        assert_eq!(result, "No matches here.");
1804        assert_eq!(count, 0);
1805    }
1806
1807    #[test]
1808    fn auto_fix_multiple_rules() {
1809        let config = Config {
1810            auto_fix: vec![
1811                AutoFixRule {
1812                    find: "recieve".to_string(),
1813                    replace: "receive".to_string(),
1814                    context: None,
1815                    description: None,
1816                },
1817                AutoFixRule {
1818                    find: "seperate".to_string(),
1819                    replace: "separate".to_string(),
1820                    context: None,
1821                    description: None,
1822                },
1823            ],
1824            ..Config::default()
1825        };
1826        let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
1827        assert_eq!(result, "Please receive the separate package.");
1828        assert_eq!(count, 2);
1829    }
1830
1831    #[test]
1832    fn auto_fix_loads_from_yaml() {
1833        let yaml = r#"
1834auto_fix:
1835  - find: "teh"
1836    replace: "the"
1837    description: "Fix common typo"
1838  - find: "colour"
1839    replace: "color"
1840    context: "American"
1841"#;
1842        let config: Config = serde_yaml::from_str(yaml).unwrap();
1843        assert_eq!(config.auto_fix.len(), 2);
1844        assert_eq!(config.auto_fix[0].find, "teh");
1845        assert_eq!(config.auto_fix[0].replace, "the");
1846        assert_eq!(
1847            config.auto_fix[0].description.as_deref(),
1848            Some("Fix common typo")
1849        );
1850        assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
1851    }
1852
1853    #[test]
1854    fn default_config_has_empty_auto_fix() {
1855        let config = Config::default();
1856        assert!(config.auto_fix.is_empty());
1857    }
1858
1859    #[test]
1860    fn external_providers_from_yaml() {
1861        let yaml = r#"
1862engines:
1863  harper: true
1864  languagetool: false
1865  external:
1866    - name: vale
1867      command: /usr/bin/vale
1868      args: ["--output", "JSON"]
1869      extensions: [md, rst]
1870    - name: custom-checker
1871      command: ./my-checker
1872"#;
1873        let config: Config = serde_yaml::from_str(yaml).unwrap();
1874        assert_eq!(config.engines.external.len(), 2);
1875        assert_eq!(config.engines.external[0].name, "vale");
1876        assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
1877        assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
1878        assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
1879        assert_eq!(config.engines.external[1].name, "custom-checker");
1880        assert!(config.engines.external[1].args.is_empty());
1881    }
1882
1883    #[test]
1884    fn default_config_has_no_external_providers() {
1885        let config = Config::default();
1886        assert!(config.engines.external.is_empty());
1887    }
1888
1889    #[test]
1890    fn wasm_plugins_from_yaml() {
1891        let yaml = r#"
1892engines:
1893  harper: true
1894  wasm_plugins:
1895    - name: custom-checker
1896      path: .languagecheck/plugins/checker.wasm
1897      extensions: [md, html]
1898    - name: style-linter
1899      path: /opt/plugins/style.wasm
1900"#;
1901        let config: Config = serde_yaml::from_str(yaml).unwrap();
1902        assert_eq!(config.engines.wasm_plugins.len(), 2);
1903        assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
1904        assert_eq!(
1905            config.engines.wasm_plugins[0].path,
1906            ".languagecheck/plugins/checker.wasm"
1907        );
1908        assert_eq!(
1909            config.engines.wasm_plugins[0].extensions,
1910            vec!["md", "html"]
1911        );
1912        assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
1913        assert!(config.engines.wasm_plugins[1].extensions.is_empty());
1914    }
1915
1916    #[test]
1917    fn default_config_has_no_wasm_plugins() {
1918        let config = Config::default();
1919        assert!(config.engines.wasm_plugins.is_empty());
1920    }
1921
1922    #[test]
1923    fn performance_config_defaults() {
1924        let config = Config::default();
1925        assert!(!config.performance.high_performance_mode);
1926        assert_eq!(config.performance.debounce_ms, 500);
1927        assert_eq!(config.performance.max_file_size, 0);
1928    }
1929
1930    #[test]
1931    fn performance_config_from_yaml() {
1932        let yaml = r#"
1933performance:
1934  high_performance_mode: true
1935  debounce_ms: 500
1936  max_file_size: 1048576
1937"#;
1938        let config: Config = serde_yaml::from_str(yaml).unwrap();
1939        assert!(config.performance.high_performance_mode);
1940        assert_eq!(config.performance.debounce_ms, 500);
1941        assert_eq!(config.performance.max_file_size, 1_048_576);
1942    }
1943
1944    #[test]
1945    fn latex_skip_environments_from_yaml() {
1946        let yaml = r#"
1947languages:
1948  latex:
1949    skip_environments:
1950      - prooftree
1951      - mycustomenv
1952"#;
1953        let config: Config = serde_yaml::from_str(yaml).unwrap();
1954        assert_eq!(
1955            config.languages.latex.skip_environments,
1956            vec!["prooftree", "mycustomenv"]
1957        );
1958    }
1959
1960    #[test]
1961    fn default_config_has_empty_latex_skip_environments() {
1962        let config = Config::default();
1963        assert!(config.languages.latex.skip_environments.is_empty());
1964    }
1965
1966    #[test]
1967    fn latex_skip_commands_from_yaml() {
1968        let yaml = r#"
1969languages:
1970  latex:
1971    skip_commands:
1972      - codefont
1973      - myverb
1974"#;
1975        let config: Config = serde_yaml::from_str(yaml).unwrap();
1976        assert_eq!(
1977            config.languages.latex.skip_commands,
1978            vec!["codefont", "myverb"]
1979        );
1980    }
1981
1982    #[test]
1983    fn default_spell_language_is_en_us() {
1984        let config = Config::default();
1985        assert_eq!(config.engines.spell_language, "en-US");
1986    }
1987
1988    #[test]
1989    fn spell_language_from_yaml() {
1990        let yaml = r#"
1991engines:
1992  spell_language: de-DE
1993"#;
1994        let config: Config = serde_yaml::from_str(yaml).unwrap();
1995        assert_eq!(config.engines.spell_language, "de-DE");
1996    }
1997
1998    #[test]
1999    fn default_config_has_empty_latex_skip_commands() {
2000        let config = Config::default();
2001        assert!(config.languages.latex.skip_commands.is_empty());
2002    }
2003
2004    #[test]
2005    fn default_vale_is_disabled() {
2006        let config = Config::default();
2007        assert!(!config.engines.vale.enabled);
2008        assert!(config.engines.vale.config.is_none());
2009    }
2010
2011    #[test]
2012    fn vale_bool_shorthand_from_yaml() {
2013        let yaml = r#"
2014engines:
2015  vale: true
2016"#;
2017        let config: Config = serde_yaml::from_str(yaml).unwrap();
2018        assert!(config.engines.vale.enabled);
2019    }
2020
2021    #[test]
2022    fn vale_nested_config_from_yaml() {
2023        let yaml = r#"
2024engines:
2025  vale:
2026    enabled: true
2027    config: ".vale.ini"
2028"#;
2029        let config: Config = serde_yaml::from_str(yaml).unwrap();
2030        assert!(config.engines.vale.enabled);
2031        assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
2032    }
2033
2034    #[test]
2035    fn harper_nested_config_from_yaml() {
2036        let yaml = r#"
2037engines:
2038  harper:
2039    enabled: true
2040    dialect: "British"
2041    linters:
2042      LongSentences: false
2043"#;
2044        let config: Config = serde_yaml::from_str(yaml).unwrap();
2045        assert!(config.engines.harper.enabled);
2046        assert_eq!(config.engines.harper.dialect, "British");
2047        assert_eq!(
2048            config.engines.harper.linters.get("LongSentences"),
2049            Some(&false)
2050        );
2051    }
2052
2053    #[test]
2054    fn languagetool_nested_config_from_yaml() {
2055        let yaml = r#"
2056engines:
2057  languagetool:
2058    enabled: true
2059    url: "http://localhost:9090"
2060    level: "picky"
2061    disabled_rules:
2062      - WHITESPACE_RULE
2063"#;
2064        let config: Config = serde_yaml::from_str(yaml).unwrap();
2065        assert!(config.engines.languagetool.enabled);
2066        assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
2067        assert_eq!(config.engines.languagetool.level, "picky");
2068        assert_eq!(
2069            config.engines.languagetool.disabled_rules,
2070            vec!["WHITESPACE_RULE"]
2071        );
2072        assert_eq!(config.engines.languagetool.max_concurrent_requests, 8);
2073    }
2074
2075    /// Issue #86: the flat key our own docs advertised was dropped on the floor,
2076    /// so a self-hosted server was checked against `localhost:8010` instead.
2077    #[test]
2078    fn legacy_flat_languagetool_url_is_honoured() {
2079        let yaml = r#"
2080engines:
2081  spell_language: fr
2082  proselint: false
2083  vale: false
2084  languagetool: true
2085  languagetool_url: "http://10.0.10.3:8003"
2086  harper: false
2087"#;
2088        let config: Config = serde_yaml::from_str(yaml).unwrap();
2089        assert!(config.engines.languagetool.enabled);
2090        assert_eq!(config.engines.languagetool.url, "http://10.0.10.3:8003");
2091        assert_eq!(config.engines.spell_language, "fr");
2092        assert!(!config.engines.harper.enabled);
2093    }
2094
2095    #[test]
2096    fn nested_languagetool_url_beats_the_legacy_key() {
2097        let yaml = r#"
2098engines:
2099  languagetool:
2100    enabled: true
2101    url: "http://nested:9090"
2102  languagetool_url: "http://flat:8003"
2103"#;
2104        let config: Config = serde_yaml::from_str(yaml).unwrap();
2105        assert_eq!(config.engines.languagetool.url, "http://nested:9090");
2106    }
2107
2108    #[test]
2109    fn legacy_flat_vale_config_is_honoured() {
2110        let yaml = "engines:\n  vale: true\n  vale_config: \"config/.vale.ini\"\n";
2111        let config: Config = serde_yaml::from_str(yaml).unwrap();
2112        assert!(config.engines.vale.enabled);
2113        assert_eq!(
2114            config.engines.vale.config.as_deref(),
2115            Some("config/.vale.ini")
2116        );
2117    }
2118
2119    #[test]
2120    fn unknown_keys_are_reported() {
2121        let value: serde_yaml::Value =
2122            serde_yaml::from_str("engines:\n  languagetol: true\n  harper: true\nrulez: {}\n")
2123                .unwrap();
2124        assert_eq!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS), vec!["rulez"]);
2125        assert_eq!(
2126            unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS),
2127            vec!["languagetol"]
2128        );
2129    }
2130
2131    #[test]
2132    fn recognised_keys_are_not_reported() {
2133        let value: serde_yaml::Value = serde_yaml::from_str(
2134            "engines:\n  languagetool_url: \"http://x:1\"\n  harper: true\nrules: {}\n",
2135        )
2136        .unwrap();
2137        assert!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS).is_empty());
2138        assert!(unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS).is_empty());
2139    }
2140
2141    #[test]
2142    fn languagetool_concurrency_can_be_pinned_to_serial() {
2143        // Shared or rate-limited servers need the old one-at-a-time behaviour back.
2144        let yaml = r"
2145engines:
2146  languagetool:
2147    enabled: true
2148    max_concurrent_requests: 1
2149";
2150        let config: Config = serde_yaml::from_str(yaml).unwrap();
2151        assert_eq!(config.engines.languagetool.max_concurrent_requests, 1);
2152    }
2153
2154    #[test]
2155    fn default_proselint_is_disabled() {
2156        let config = Config::default();
2157        assert!(!config.engines.proselint.enabled);
2158        assert!(config.engines.proselint.config.is_none());
2159    }
2160
2161    #[test]
2162    fn proselint_bool_shorthand_from_yaml() {
2163        let yaml = r#"
2164engines:
2165  proselint: true
2166"#;
2167        let config: Config = serde_yaml::from_str(yaml).unwrap();
2168        assert!(config.engines.proselint.enabled);
2169    }
2170
2171    #[test]
2172    fn proselint_nested_config_from_yaml() {
2173        let yaml = r#"
2174engines:
2175  proselint:
2176    enabled: true
2177    config: "proselint.json"
2178"#;
2179        let config: Config = serde_yaml::from_str(yaml).unwrap();
2180        assert!(config.engines.proselint.enabled);
2181        assert_eq!(
2182            config.engines.proselint.config.as_deref(),
2183            Some("proselint.json")
2184        );
2185    }
2186}