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    /// How long after the last keystroke a check runs, in milliseconds.
153    ///
154    /// Read by the editor clients, which own the typing loop; the core checks
155    /// whatever it is handed, whenever it is handed it.
156    #[serde(default = "default_debounce_ms")]
157    pub debounce_ms: u64,
158    /// Maximum file size in bytes to check (0 = unlimited).
159    #[serde(default)]
160    pub max_file_size: usize,
161    /// How many engine answers to keep, keyed by the prose that produced them.
162    ///
163    /// A keystroke re-checks the whole document although one prose range
164    /// changed, so the cache is what keeps a long file responsive. `0`
165    /// disables it and re-checks every range on every keystroke.
166    #[serde(default = "default_result_cache_entries")]
167    pub result_cache_entries: usize,
168    /// Longest prose range handed on, in bytes; longer ones are split at
169    /// sentence boundaries. `0` disables splitting.
170    ///
171    /// A range is one cache key and one box in the inspector, so a document
172    /// written without blank lines between paragraphs otherwise becomes a
173    /// single range and neither the cache nor the inspector can say anything
174    /// useful about it.
175    #[serde(default = "default_max_range_bytes")]
176    pub max_range_bytes: usize,
177}
178
179impl Default for PerformanceConfig {
180    fn default() -> Self {
181        Self {
182            high_performance_mode: false,
183            debounce_ms: 500,
184            max_file_size: 0,
185            result_cache_entries: default_result_cache_entries(),
186            max_range_bytes: default_max_range_bytes(),
187        }
188    }
189}
190
191/// Long enough that a burst of typing produces one check, short enough that a
192/// pause feels answered. The VS Code extension defaults to the same number.
193const fn default_debounce_ms() -> u64 {
194    500
195}
196
197/// Room for several long documents at once: a 36 kB file is around 110 prose
198/// ranges, so this holds roughly thirty of them per engine before evicting.
199const fn default_result_cache_entries() -> usize {
200    4096
201}
202
203/// Several sentences, so the cross-sentence rules still have something to work
204/// with, while a keystroke dirties a paragraph's worth of cache rather than a
205/// chapter's. Splitting costs nothing on a cold check: the engines pack ranges
206/// back together up to `max_request_bytes` before sending them.
207const fn default_max_range_bytes() -> usize {
208    2048
209}
210
211/// Configuration for bundled and additional wordlist dictionaries.
212#[derive(Debug, Serialize, Deserialize, Clone)]
213pub struct DictionaryConfig {
214    /// Whether to load the bundled domain-specific dictionaries (software terms,
215    /// TypeScript, companies, jargon, mathematics). Default: true.
216    #[serde(default = "default_true")]
217    pub bundled: bool,
218    /// Names of individual bundled dictionaries to skip, e.g.
219    /// `["companies", "mathematics"]`. Every set loads by default; listing one
220    /// here turns off just that one. Ignored when `bundled` is false.
221    #[serde(default)]
222    pub disabled: Vec<String>,
223    /// Paths to additional wordlist files (one word per line, `#` comments).
224    /// Relative paths are resolved from the workspace root.
225    #[serde(default)]
226    pub paths: Vec<String>,
227}
228
229impl Default for DictionaryConfig {
230    fn default() -> Self {
231        Self {
232            bundled: true,
233            disabled: Vec::new(),
234            paths: Vec::new(),
235        }
236    }
237}
238
239/// A user-defined find->replace auto-fix rule.
240#[derive(Debug, Serialize, Deserialize, Clone)]
241pub struct AutoFixRule {
242    /// Pattern to find (plain text, case-sensitive).
243    pub find: String,
244    /// Replacement text.
245    pub replace: String,
246    /// Optional context filter: only apply when surrounding text matches.
247    #[serde(default)]
248    pub context: Option<String>,
249    /// Optional description for the rule.
250    #[serde(default)]
251    pub description: Option<String>,
252}
253
254#[derive(Debug, Serialize, Deserialize, Clone)]
255#[serde(from = "EngineConfigWire")]
256pub struct EngineConfig {
257    pub harper: HarperConfig,
258    pub languagetool: LanguageToolConfig,
259    pub vale: ValeConfig,
260    pub proselint: ProselintConfig,
261    pub hunspell: HunspellConfig,
262    /// External checker providers registered via config.
263    pub external: Vec<ExternalProvider>,
264    /// WASM checker plugins loaded via Extism.
265    pub wasm_plugins: Vec<WasmPlugin>,
266    /// BCP-47 natural language tag for spell/grammar checking (e.g. "en-US", "de-DE").
267    pub spell_language: String,
268}
269
270/// On-disk form of [`EngineConfig`], carrying the flat pre-nesting keys next to
271/// the nested ones.
272///
273/// `engines.languagetool_url` and `engines.vale_config` were folded into
274/// `engines.languagetool.url` and `engines.vale.config` when engine settings
275/// became nested structs. serde drops unknown keys without a word, so every
276/// config still written the flat way — including the one in our own README —
277/// silently fell back to the default `http://localhost:8010`, and the only
278/// symptom was a connection error naming a server the user never configured
279/// (issue #86). Both spellings are read here, and the flat one warns.
280#[derive(Deserialize)]
281struct EngineConfigWire {
282    #[serde(
283        default = "default_harper_config",
284        deserialize_with = "deser_engine_or_bool"
285    )]
286    harper: HarperConfig,
287    #[serde(default, deserialize_with = "deser_engine_or_bool")]
288    languagetool: LanguageToolConfig,
289    #[serde(default, deserialize_with = "deser_engine_or_bool")]
290    vale: ValeConfig,
291    #[serde(default, deserialize_with = "deser_engine_or_bool")]
292    proselint: ProselintConfig,
293    #[serde(default, deserialize_with = "deser_engine_or_bool")]
294    hunspell: HunspellConfig,
295    #[serde(default)]
296    external: Vec<ExternalProvider>,
297    #[serde(default)]
298    wasm_plugins: Vec<WasmPlugin>,
299    #[serde(default = "default_spell_language")]
300    spell_language: String,
301    /// Deprecated alias for `engines.languagetool.url`.
302    #[serde(default)]
303    languagetool_url: Option<String>,
304    /// Deprecated alias for `engines.vale.config`.
305    #[serde(default)]
306    vale_config: Option<String>,
307}
308
309impl From<EngineConfigWire> for EngineConfig {
310    fn from(wire: EngineConfigWire) -> Self {
311        let EngineConfigWire {
312            harper,
313            mut languagetool,
314            mut vale,
315            proselint,
316            hunspell,
317            external,
318            wasm_plugins,
319            spell_language,
320            languagetool_url,
321            vale_config,
322        } = wire;
323
324        // The nested key wins when both are present: it is the supported
325        // spelling, so a config carrying both is mid-migration.
326        if let Some(url) = languagetool_url {
327            if languagetool.url == default_lt_url() {
328                warn_deprecated_engine_key("engines.languagetool_url", "engines.languagetool.url");
329                languagetool.url = url;
330            } else {
331                warn_ignored_engine_key("engines.languagetool_url", "engines.languagetool.url");
332            }
333        }
334        if let Some(path) = vale_config {
335            if vale.config.is_none() {
336                warn_deprecated_engine_key("engines.vale_config", "engines.vale.config");
337                vale.config = Some(path);
338            } else {
339                warn_ignored_engine_key("engines.vale_config", "engines.vale.config");
340            }
341        }
342
343        Self {
344            harper,
345            languagetool,
346            vale,
347            proselint,
348            hunspell,
349            external,
350            wasm_plugins,
351            spell_language,
352        }
353    }
354}
355
356/// Report a flat pre-nesting key that was honoured but should be rewritten.
357fn warn_deprecated_engine_key(old: &str, new: &str) {
358    warn!(
359        "`{old}` is deprecated and will be removed in a future release; \
360         rename it to `{new}`. Honouring it for now."
361    );
362}
363
364/// Report a flat pre-nesting key that the nested key already overrode.
365fn warn_ignored_engine_key(old: &str, new: &str) {
366    warn!("`{old}` is ignored because `{new}` is also set; delete the deprecated key.");
367}
368
369/// Deserialize an engine config from either a bool shorthand or the full struct.
370/// `harper: true` → `HarperConfig { enabled: true, ..default }`.
371fn deser_engine_or_bool<'de, D, T>(deserializer: D) -> Result<T, D::Error>
372where
373    D: serde::Deserializer<'de>,
374    T: Deserialize<'de> + EngineToggle + Default,
375{
376    #[derive(Deserialize)]
377    #[serde(untagged)]
378    enum BoolOrStruct<T> {
379        Bool(bool),
380        Struct(T),
381    }
382
383    match BoolOrStruct::deserialize(deserializer)? {
384        BoolOrStruct::Bool(b) => {
385            let mut cfg = T::default();
386            cfg.set_enabled(b);
387            Ok(cfg)
388        }
389        BoolOrStruct::Struct(s) => Ok(s),
390    }
391}
392
393/// Trait for engine configs that can be toggled with a bool shorthand.
394pub trait EngineToggle {
395    fn enabled(&self) -> bool;
396    fn set_enabled(&mut self, v: bool);
397}
398
399/// Harper engine configuration.
400#[derive(Debug, Serialize, Deserialize, Clone)]
401pub struct HarperConfig {
402    #[serde(default = "default_true")]
403    pub enabled: bool,
404    /// Harper dialect: `American`, `British`, `Canadian`, `Australian`, `Indian`.
405    #[serde(default = "default_dialect")]
406    pub dialect: String,
407    /// Per-rule toggles. Key is the rule name (e.g. `LongSentences`), value
408    /// is `true`/`false`. Omitted rules use the curated default.
409    #[serde(default)]
410    pub linters: HashMap<String, bool>,
411}
412
413impl Default for HarperConfig {
414    fn default() -> Self {
415        Self {
416            enabled: true,
417            dialect: "American".to_string(),
418            linters: HashMap::new(),
419        }
420    }
421}
422
423fn default_harper_config() -> HarperConfig {
424    HarperConfig::default()
425}
426
427fn default_dialect() -> String {
428    "American".to_string()
429}
430
431impl EngineToggle for HarperConfig {
432    fn enabled(&self) -> bool {
433        self.enabled
434    }
435    fn set_enabled(&mut self, v: bool) {
436        self.enabled = v;
437    }
438}
439
440/// `LanguageTool` engine configuration.
441#[derive(Debug, Serialize, Deserialize, Clone)]
442pub struct LanguageToolConfig {
443    #[serde(default)]
444    pub enabled: bool,
445    /// `LanguageTool` server URL.
446    #[serde(default = "default_lt_url")]
447    pub url: String,
448    /// Checking level: `default` or `picky` (enables stricter rules).
449    #[serde(default = "default_lt_level")]
450    pub level: String,
451    /// User's native language for false-friends detection (BCP-47 tag).
452    #[serde(default)]
453    pub mother_tongue: Option<String>,
454    /// Rule IDs to disable (e.g. `["WHITESPACE_RULE"]`).
455    #[serde(default)]
456    pub disabled_rules: Vec<String>,
457    /// Rule IDs to enable beyond defaults.
458    #[serde(default)]
459    pub enabled_rules: Vec<String>,
460    /// Category IDs to disable.
461    #[serde(default)]
462    pub disabled_categories: Vec<String>,
463    /// Category IDs to enable.
464    #[serde(default)]
465    pub enabled_categories: Vec<String>,
466    /// How many `/v2/check` requests may be in flight at once.
467    ///
468    /// Lower this when pointing at a shared or rate-limited server; `1`
469    /// restores serial checking.
470    #[serde(default = "default_lt_max_concurrent_requests")]
471    pub max_concurrent_requests: usize,
472    /// How much prose to put in one `/v2/check`, in bytes.
473    ///
474    /// Prose ranges are packed up to this size before being sent. A range that
475    /// exceeds it on its own still gets a request of its own; `0` disables
476    /// packing and restores one request per range.
477    #[serde(default = "default_lt_max_request_bytes")]
478    pub max_request_bytes: usize,
479}
480
481impl Default for LanguageToolConfig {
482    fn default() -> Self {
483        Self {
484            enabled: false,
485            url: default_lt_url(),
486            level: "default".to_string(),
487            mother_tongue: None,
488            disabled_rules: Vec::new(),
489            enabled_rules: Vec::new(),
490            disabled_categories: Vec::new(),
491            enabled_categories: Vec::new(),
492            max_concurrent_requests: default_lt_max_concurrent_requests(),
493            max_request_bytes: default_lt_max_request_bytes(),
494        }
495    }
496}
497
498fn default_lt_level() -> String {
499    "default".to_string()
500}
501
502/// Enough parallelism to hide per-request latency on a local server without
503/// swamping a shared one — measured saturation point is around 8.
504const fn default_lt_max_concurrent_requests() -> usize {
505    8
506}
507
508/// Measured against a local `LanguageTool` 6.x, a `/v2/check` costs about
509/// 8 ms flat plus 20.6 us per byte. Per prose range that flat cost dominates —
510/// a 36 kB Typst document is 109 ranges of median 156 bytes, so 872 ms of the
511/// wall clock is request overhead alone. Packing to 4 kB leaves overhead under
512/// a tenth of the request and keeps each one short enough that the concurrency
513/// window stays full; 8 kB and above buys little and delays the first result.
514const fn default_lt_max_request_bytes() -> usize {
515    4096
516}
517
518/// Hunspell: spelling for the languages the other engines do not read.
519///
520/// ```yaml
521/// engines:
522///   hunspell:
523///     enabled: true
524///     languages: ["he", "la"]
525///     dictionary_paths:
526///       la: /opt/dictionaries/latin
527/// ```
528#[derive(Debug, Default, Serialize, Deserialize, Clone)]
529pub struct HunspellConfig {
530    /// Off by default, like every engine that needs something installed.
531    #[serde(default)]
532    pub enabled: bool,
533    /// Languages to check with Hunspell, as BCP-47 tags.
534    ///
535    /// Naming them ahead of time is what lets a pack be fetched before it is
536    /// needed rather than mid-document, and what keeps this engine to the gaps
537    /// -- leave English out and Harper keeps it. Empty means any language with
538    /// a pack behind it, which is the discovery mode and not the tidy one.
539    #[serde(default)]
540    pub languages: Vec<String>,
541    /// Per-language override: a directory, an `.aff`/`.dic` stem, or either
542    /// file of the pair. Beats every search path, so a pinned dictionary is
543    /// definitely the one in use.
544    #[serde(default)]
545    pub dictionary_paths: HashMap<String, String>,
546    /// Extra directories to search, before the platform's own.
547    #[serde(default)]
548    pub search_paths: Vec<String>,
549    /// Fetch a missing pack without being asked.
550    ///
551    /// Off by default: a dictionary is a third-party download under its own
552    /// licence -- Hspell is AGPL-3.0, the Latin pack GPL -- and that is a
553    /// decision to put to the user rather than to make for them.
554    #[serde(default)]
555    pub auto_install: bool,
556}
557
558impl EngineToggle for HunspellConfig {
559    fn enabled(&self) -> bool {
560        self.enabled
561    }
562    fn set_enabled(&mut self, v: bool) {
563        self.enabled = v;
564    }
565}
566
567impl EngineToggle for LanguageToolConfig {
568    fn enabled(&self) -> bool {
569        self.enabled
570    }
571    fn set_enabled(&mut self, v: bool) {
572        self.enabled = v;
573    }
574}
575
576/// Vale engine configuration.
577#[derive(Debug, Default, Serialize, Deserialize, Clone)]
578pub struct ValeConfig {
579    #[serde(default)]
580    pub enabled: bool,
581    /// Path to `.vale.ini`. When empty, Vale uses its own search logic.
582    #[serde(default)]
583    pub config: Option<String>,
584}
585
586impl EngineToggle for ValeConfig {
587    fn enabled(&self) -> bool {
588        self.enabled
589    }
590    fn set_enabled(&mut self, v: bool) {
591        self.enabled = v;
592    }
593}
594
595/// Proselint engine configuration.
596#[derive(Debug, Default, Serialize, Deserialize, Clone)]
597pub struct ProselintConfig {
598    #[serde(default)]
599    pub enabled: bool,
600    /// Path to `proselint.json` config. When empty, proselint uses its own search logic.
601    #[serde(default)]
602    pub config: Option<String>,
603}
604
605impl EngineToggle for ProselintConfig {
606    fn enabled(&self) -> bool {
607        self.enabled
608    }
609    fn set_enabled(&mut self, v: bool) {
610        self.enabled = v;
611    }
612}
613
614/// An external checker binary that communicates via stdin/stdout JSON.
615///
616/// The binary receives `{"text": "...", "language_id": "..."}` on stdin
617/// and returns `[{"start_byte": N, "end_byte": N, "message": "...", ...}]` on stdout.
618#[derive(Debug, Serialize, Deserialize, Clone)]
619pub struct ExternalProvider {
620    /// Display name for this provider.
621    pub name: String,
622    /// Path to the executable.
623    pub command: String,
624    /// Optional arguments to pass to the command.
625    #[serde(default)]
626    pub args: Vec<String>,
627    /// File extensions this provider parses, without the dot (empty = all).
628    ///
629    /// The markup it understands, which is a different question from the
630    /// language it speaks.
631    #[serde(default)]
632    pub extensions: Vec<String>,
633    /// BCP-47 tags this provider checks (empty = all).
634    ///
635    /// Without this a provider claims every language, including ones it has
636    /// no idea what to do with -- and claiming a language suppresses the
637    /// report that says nothing could check it.
638    #[serde(default)]
639    pub languages: Vec<String>,
640}
641
642/// A WASM plugin loaded via Extism.
643///
644/// Plugins must export a `check` function that receives a JSON string
645/// `{"text": "...", "language_id": "..."}` and returns a JSON array of diagnostics.
646#[derive(Debug, Serialize, Deserialize, Clone)]
647pub struct WasmPlugin {
648    /// Display name for this plugin.
649    pub name: String,
650    /// Path to the `.wasm` file (relative to workspace root or absolute).
651    pub path: String,
652    /// File extensions this plugin parses, without the dot (empty = all).
653    #[serde(default)]
654    pub extensions: Vec<String>,
655    /// BCP-47 tags this plugin checks (empty = all).
656    #[serde(default)]
657    pub languages: Vec<String>,
658}
659
660impl Default for EngineConfig {
661    fn default() -> Self {
662        Self {
663            harper: HarperConfig::default(),
664            languagetool: LanguageToolConfig::default(),
665            vale: ValeConfig::default(),
666            proselint: ProselintConfig::default(),
667            hunspell: HunspellConfig::default(),
668            external: Vec::new(),
669            wasm_plugins: Vec::new(),
670            spell_language: default_spell_language(),
671        }
672    }
673}
674
675#[derive(Debug, Serialize, Deserialize, Clone)]
676pub struct RuleConfig {
677    pub severity: Option<String>, // "error", "warning", "info", "hint", "off"
678}
679
680const fn default_true() -> bool {
681    true
682}
683fn default_lt_url() -> String {
684    "http://localhost:8010".to_string()
685}
686fn default_spell_language() -> String {
687    "en-US".to_string()
688}
689fn default_exclude() -> Vec<String> {
690    vec![
691        "node_modules/**".to_string(),
692        ".git/**".to_string(),
693        "target/**".to_string(),
694        "dist/**".to_string(),
695        "build/**".to_string(),
696        ".next/**".to_string(),
697        ".nuxt/**".to_string(),
698        "vendor/**".to_string(),
699        "__pycache__/**".to_string(),
700        ".venv/**".to_string(),
701        "venv/**".to_string(),
702        ".tox/**".to_string(),
703        ".mypy_cache/**".to_string(),
704        "*.min.js".to_string(),
705        "*.min.css".to_string(),
706        "*.bundle.js".to_string(),
707        "package-lock.json".to_string(),
708        "yarn.lock".to_string(),
709        "pnpm-lock.yaml".to_string(),
710    ]
711}
712
713impl Config {
714    /// Load configuration, warning and falling back to defaults if it cannot be read.
715    ///
716    /// `load` fails on a malformed `.languagecheck.yaml` — a bad indent, a typo'd enum — and
717    /// callers used to answer that with a bare `Config::default()`, so a rejected file was
718    /// indistinguishable from an absent one and the user's overrides silently did nothing.
719    /// A missing file is not an error and is not reported; an unreadable one is.
720    ///
721    /// Callers with no `tracing` subscriber installed (the CLI binary) must report to stderr
722    /// themselves rather than call this, or the warning goes nowhere.
723    #[must_use]
724    pub fn load_or_warn(workspace_root: &Path) -> Self {
725        Self::load(workspace_root).unwrap_or_else(|e| {
726            warn!(
727                root = %workspace_root.display(),
728                "Ignoring unreadable workspace config, using defaults: {e}"
729            );
730            Self::default()
731        })
732    }
733
734    /// Whether `exclude` covers this path.
735    ///
736    /// `path` may be absolute or already relative to `workspace_root`; it is
737    /// reduced to the workspace-relative form the patterns are written
738    /// against, because `node_modules/**` is how a user thinks about it and
739    /// an absolute path would never match.
740    ///
741    /// An unparseable pattern excludes nothing. Refusing to check a file
742    /// because a glob had a typo is the worse of the two failures.
743    #[must_use]
744    pub fn excludes(&self, path: &Path, workspace_root: &Path) -> bool {
745        if self.exclude.is_empty() {
746            return false;
747        }
748        let relative = path.strip_prefix(workspace_root).unwrap_or(path);
749        // Separators normalised, because the patterns are written with `/` --
750        // `node_modules/**` is how anyone writes it, on any platform -- while
751        // the path arrives with the platform's own. Without this, `exclude`
752        // matched nothing at all on Windows and said nothing about why.
753        let as_text = relative.to_string_lossy().replace('\\', "/");
754        // Written once, because the indexer, the CLI and the editor all have
755        // to agree about what is excluded -- a file the editor still checks
756        // after the indexer skipped it is the inconsistency this avoids.
757        let options = glob::MatchOptions {
758            require_literal_separator: false,
759            require_literal_leading_dot: false,
760            case_sensitive: true,
761        };
762        self.exclude
763            .iter()
764            .filter_map(|pattern| glob::Pattern::new(pattern).ok())
765            .any(|pattern| pattern.matches_with(&as_text, options))
766    }
767
768    /// Make workspace-relative paths in the config absolute.
769    ///
770    /// A path in `.languagecheck.yaml` means "relative to the workspace",
771    /// which is the only reading that makes sense to whoever wrote it. It was
772    /// reaching Vale as written, and Vale is spawned by the core, whose
773    /// working directory is wherever the editor started it -- so the
774    /// documented `config: ".vale.ini"` worked from the CLI, where the two
775    /// coincide, and silently did nothing in VS Code, where they do not. The
776    /// dictionary paths were already resolved against the root; this brings
777    /// the rest into line.
778    ///
779    /// An absolute path is left alone. So is an external provider's command
780    /// when it is a bare name: that form is looked up on PATH, and making it
781    /// workspace-relative would break the one spelling that has no reason to
782    /// be.
783    fn resolve_paths(&mut self, workspace_root: &Path) {
784        let absolute = |value: &str| -> String {
785            let path = Path::new(value);
786            if path.is_absolute() {
787                value.to_string()
788            } else {
789                workspace_root.join(path).to_string_lossy().into_owned()
790            }
791        };
792
793        if let Some(vale_config) = &self.engines.vale.config {
794            self.engines.vale.config = Some(absolute(vale_config));
795        }
796        if let Some(proselint_config) = &self.engines.proselint.config {
797            self.engines.proselint.config = Some(absolute(proselint_config));
798        }
799        for plugin in &mut self.engines.wasm_plugins {
800            plugin.path = absolute(&plugin.path);
801        }
802        for provider in &mut self.engines.external {
803            // Only when it is written as a path. A bare name is looked up on
804            // PATH, and turning `my-checker` into `<root>/my-checker` would
805            // break the one form that has no reason to be workspace-relative.
806            if provider.command.contains(std::path::MAIN_SEPARATOR)
807                || provider.command.contains('/')
808            {
809                provider.command = absolute(&provider.command);
810            }
811        }
812    }
813
814    pub fn load(workspace_root: &Path) -> Result<Self> {
815        // Prefer YAML, fall back to JSON for backward compatibility
816        let yaml_path = workspace_root.join(".languagecheck.yaml");
817        let yml_path = workspace_root.join(".languagecheck.yml");
818        let json_path = workspace_root.join(".languagecheck.json");
819
820        if yaml_path.exists() {
821            let content = std::fs::read_to_string(yaml_path)?;
822            warn_duplicate_rule_keys(&content);
823            let mut config: Self = serde_yaml::from_str(&content)?;
824            warn_unknown_keys(&serde_yaml::from_str(&content)?);
825            config.resolve_paths(workspace_root);
826            Ok(config)
827        } else if yml_path.exists() {
828            let content = std::fs::read_to_string(yml_path)?;
829            warn_duplicate_rule_keys(&content);
830            let mut config: Self = serde_yaml::from_str(&content)?;
831            warn_unknown_keys(&serde_yaml::from_str(&content)?);
832            config.resolve_paths(workspace_root);
833            Ok(config)
834        } else if json_path.exists() {
835            let content = std::fs::read_to_string(json_path)?;
836            let mut config: Self = serde_json::from_str(&content)?;
837            // YAML 1.2 is a superset of JSON, so one key scanner covers both formats.
838            warn_unknown_keys(&serde_yaml::from_str(&content)?);
839            config.resolve_paths(workspace_root);
840            Ok(config)
841        } else {
842            Ok(Self::default())
843        }
844    }
845
846    /// Apply user-defined auto-fix rules to the given text, returning the modified text
847    /// and the number of replacements made.
848    #[must_use]
849    pub fn apply_auto_fixes(&self, text: &str) -> (String, usize) {
850        let mut result = text.to_string();
851        let mut total = 0;
852
853        for rule in &self.auto_fix {
854            if let Some(ctx) = &rule.context
855                && !result.contains(ctx.as_str())
856            {
857                continue;
858            }
859            let count = result.matches(&rule.find).count();
860            if count > 0 {
861                result = result.replace(&rule.find, &rule.replace);
862                total += count;
863            }
864        }
865
866        (result, total)
867    }
868}
869
870/// Collect rule keys that appear more than once under the top-level `rules:`
871/// mapping of a raw YAML config, in first-seen order.
872///
873/// `serde_yaml` silently keeps only the last value for a duplicated mapping
874/// key, so duplicates vanish after parsing; this scans the raw text so they can
875/// be surfaced. Recognizes block-style child keys (`  some.rule:` on its own
876/// line) at the mapping's first child indentation.
877fn duplicate_rule_keys(content: &str) -> Vec<String> {
878    let mut in_rules = false;
879    let mut child_indent: Option<usize> = None;
880    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
881    let mut duplicates: Vec<String> = Vec::new();
882
883    for line in content.lines() {
884        if line.trim().is_empty() {
885            continue;
886        }
887        let indent = line.len() - line.trim_start().len();
888
889        if !in_rules {
890            if indent == 0 && line.trim() == "rules:" {
891                in_rules = true;
892            }
893            continue;
894        }
895
896        // A new top-level key ends the rules block.
897        if indent == 0 {
898            break;
899        }
900
901        let child = *child_indent.get_or_insert(indent);
902        if indent != child {
903            continue; // deeper line (e.g. `severity: ...`), not a rule key
904        }
905        if let Some(key) = line.trim().strip_suffix(':') {
906            let key = key.trim().to_string();
907            if !key.is_empty() && !seen.insert(key.clone()) && !duplicates.contains(&key) {
908                duplicates.push(key);
909            }
910        }
911    }
912
913    duplicates
914}
915
916/// Top-level keys [`Config`] understands.
917const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
918    "engines",
919    "rules",
920    "exclude",
921    "auto_fix",
922    "performance",
923    "dictionaries",
924    "languages",
925    "workspace",
926    "names",
927    "morphology",
928];
929
930/// Keys [`EngineConfig`] understands, including the deprecated flat aliases.
931const KNOWN_ENGINE_KEYS: &[&str] = &[
932    "harper",
933    "languagetool",
934    "vale",
935    "proselint",
936    "hunspell",
937    "external",
938    "wasm_plugins",
939    "spell_language",
940    "languagetool_url",
941    "vale_config",
942];
943
944/// Collect the keys of `value`'s `section` mapping that are not in `known`.
945fn unknown_keys(value: &serde_yaml::Value, known: &[&str]) -> Vec<String> {
946    let Some(map) = value.as_mapping() else {
947        return Vec::new();
948    };
949    map.keys()
950        .filter_map(serde_yaml::Value::as_str)
951        .filter(|k| !known.contains(k))
952        .map(ToString::to_string)
953        .collect()
954}
955
956/// Warn about config keys nothing reads.
957///
958/// serde ignores what it does not recognise, so a typo'd or renamed key is
959/// indistinguishable from an absent one: the setting simply never takes effect
960/// and the user is left debugging the default. Reporting them turns a silent
961/// no-op into a line in the log.
962fn warn_unknown_keys(value: &serde_yaml::Value) {
963    let unknown = unknown_keys(value, KNOWN_TOP_LEVEL_KEYS);
964    if !unknown.is_empty() {
965        warn!(keys = ?unknown, "Unknown keys in workspace config; they have no effect.");
966    }
967    if let Some(engines) = value.get("engines") {
968        let unknown = unknown_keys(engines, KNOWN_ENGINE_KEYS);
969        if !unknown.is_empty() {
970            warn!(keys = ?unknown, "Unknown keys under `engines:`; they have no effect.");
971        }
972    }
973}
974
975/// Log a warning if a raw YAML config contains duplicate rule keys.
976fn warn_duplicate_rule_keys(content: &str) {
977    let duplicates = duplicate_rule_keys(content);
978    if !duplicates.is_empty() {
979        warn!(
980            duplicates = ?duplicates,
981            "Duplicate rule keys in .languagecheck.yaml; only the last entry for each takes \
982             effect. Remove the extra copies to keep the ignore list clean."
983        );
984    }
985}
986
987impl Default for Config {
988    fn default() -> Self {
989        Self {
990            engines: EngineConfig::default(),
991            rules: HashMap::new(),
992            exclude: default_exclude(),
993            auto_fix: Vec::new(),
994            performance: PerformanceConfig::default(),
995            dictionaries: DictionaryConfig::default(),
996            languages: LanguageConfig::default(),
997            workspace: WorkspaceConfig::default(),
998            names: NameConfig::default(),
999            morphology: MorphologyConfig::default(),
1000        }
1001    }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006
1007    #[test]
1008    fn every_engine_key_the_config_accepts_is_declared_known() {
1009        // A field that parses but is not listed here is reported to the user
1010        // as having no effect, which is the opposite of true and reads as the
1011        // feature being unsupported. Adding an engine means adding it twice,
1012        // so this is the reminder.
1013        let yaml = "\
1014engines:
1015  harper: false
1016  languagetool: false
1017  vale: false
1018  proselint: false
1019  hunspell:
1020    enabled: true
1021  spell_language: en-US
1022";
1023        let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1024        let engines = value.get("engines").expect("engines section");
1025        assert_eq!(
1026            unknown_keys(engines, KNOWN_ENGINE_KEYS),
1027            Vec::<String>::new(),
1028            "an engine key parses but is not declared known"
1029        );
1030    }
1031    use super::*;
1032
1033    #[test]
1034    fn duplicate_rule_keys_detects_repeats() {
1035        let yaml = "rules:\n  languagetool.ARROWS:\n    severity: \"off\"\n  \
1036                    languagetool.UPPERCASE_SENTENCE_START:\n    severity: \"off\"\n  \
1037                    languagetool.ARROWS:\n    severity: \"off\"\n  \
1038                    languagetool.UPPERCASE_SENTENCE_START:\n    severity: \"off\"\n  \
1039                    languagetool.THE_SUPERLATIVE:\n    severity: \"off\"\n";
1040        let dups = duplicate_rule_keys(yaml);
1041        assert_eq!(
1042            dups,
1043            vec![
1044                "languagetool.ARROWS".to_string(),
1045                "languagetool.UPPERCASE_SENTENCE_START".to_string()
1046            ]
1047        );
1048    }
1049
1050    #[test]
1051    fn duplicate_rule_keys_clean_list_is_empty() {
1052        let yaml = "rules:\n  a.B:\n    severity: \"off\"\n  c.D:\n    severity: \"off\"\n";
1053        assert!(duplicate_rule_keys(yaml).is_empty());
1054    }
1055
1056    #[test]
1057    fn duplicate_rule_keys_stops_at_next_section() {
1058        // A repeat under a *different* top-level section must not count.
1059        let yaml = "rules:\n  a.B:\n    severity: \"off\"\nengines:\n  harper: false\n";
1060        assert!(duplicate_rule_keys(yaml).is_empty());
1061    }
1062
1063    #[test]
1064    fn morphology_is_on_by_default() {
1065        let config = Config::default();
1066        assert!(config.morphology.enabled);
1067        assert!(config.morphology.inflections);
1068    }
1069
1070    #[test]
1071    fn morphology_can_be_switched_off_from_yaml() {
1072        let yaml = "morphology:\n  enabled: false\n";
1073        let config: Config = serde_yaml::from_str(yaml).unwrap();
1074        assert!(!config.morphology.enabled);
1075        // An unmentioned field keeps its default rather than falling to `false`.
1076        assert!(config.morphology.inflections);
1077    }
1078
1079    #[test]
1080    fn default_dictionaries_load_all_bundled_sets() {
1081        let config = Config::default();
1082        assert!(config.dictionaries.bundled);
1083        assert!(config.dictionaries.disabled.is_empty());
1084        assert!(config.dictionaries.paths.is_empty());
1085    }
1086
1087    #[test]
1088    fn dictionaries_disabled_from_yaml() {
1089        let config: Config = serde_yaml::from_str(
1090            r"
1091dictionaries:
1092  disabled: [companies, mathematics]
1093",
1094        )
1095        .unwrap();
1096        assert_eq!(config.dictionaries.disabled, ["companies", "mathematics"]);
1097        // The master switch is untouched by listing individual sets.
1098        assert!(config.dictionaries.bundled);
1099    }
1100
1101    #[test]
1102    fn default_config_has_harper_enabled_lt_disabled() {
1103        let config = Config::default();
1104        assert!(config.engines.harper.enabled);
1105        assert!(!config.engines.languagetool.enabled);
1106    }
1107
1108    #[test]
1109    fn default_config_has_standard_excludes() {
1110        let config = Config::default();
1111        assert!(config.exclude.contains(&"node_modules/**".to_string()));
1112        assert!(config.exclude.contains(&".git/**".to_string()));
1113        assert!(config.exclude.contains(&"target/**".to_string()));
1114        assert!(config.exclude.contains(&"dist/**".to_string()));
1115        assert!(config.exclude.contains(&"vendor/**".to_string()));
1116    }
1117
1118    #[test]
1119    fn default_lt_url() {
1120        let config = Config::default();
1121        assert_eq!(config.engines.languagetool.url, "http://localhost:8010");
1122    }
1123
1124    #[test]
1125    fn load_from_json_string() {
1126        let json = r#"{
1127            "engines": { "harper": true, "languagetool": false },
1128            "rules": { "spelling.typo": { "severity": "warning" } }
1129        }"#;
1130        let config: Config = serde_json::from_str(json).unwrap();
1131        assert!(config.engines.harper.enabled);
1132        assert!(!config.engines.languagetool.enabled);
1133        assert!(config.rules.contains_key("spelling.typo"));
1134        assert_eq!(
1135            config.rules["spelling.typo"].severity.as_deref(),
1136            Some("warning")
1137        );
1138    }
1139
1140    #[test]
1141    fn load_partial_json_uses_defaults() {
1142        let json = r#"{}"#;
1143        let config: Config = serde_json::from_str(json).unwrap();
1144        assert!(config.engines.harper.enabled);
1145        assert!(!config.engines.languagetool.enabled);
1146        assert!(config.rules.is_empty());
1147    }
1148
1149    #[test]
1150    fn load_from_json_file() {
1151        let dir = std::env::temp_dir().join("lang_check_test_config_json");
1152        let _ = std::fs::remove_dir_all(&dir);
1153        std::fs::create_dir_all(&dir).unwrap();
1154
1155        let config_path = dir.join(".languagecheck.json");
1156        std::fs::write(
1157            &config_path,
1158            r#"{"engines": {"harper": false, "languagetool": true}}"#,
1159        )
1160        .unwrap();
1161
1162        let config = Config::load(&dir).unwrap();
1163        assert!(!config.engines.harper.enabled);
1164        assert!(config.engines.languagetool.enabled);
1165
1166        let _ = std::fs::remove_dir_all(&dir);
1167    }
1168
1169    #[test]
1170    fn load_from_yaml_file() {
1171        let dir = std::env::temp_dir().join("lang_check_test_config_yaml");
1172        let _ = std::fs::remove_dir_all(&dir);
1173        std::fs::create_dir_all(&dir).unwrap();
1174
1175        let config_path = dir.join(".languagecheck.yaml");
1176        std::fs::write(
1177            &config_path,
1178            "engines:\n  harper: false\n  languagetool: true\n",
1179        )
1180        .unwrap();
1181
1182        let config = Config::load(&dir).unwrap();
1183        assert!(!config.engines.harper.enabled);
1184        assert!(config.engines.languagetool.enabled);
1185
1186        let _ = std::fs::remove_dir_all(&dir);
1187    }
1188
1189    #[test]
1190    fn yaml_takes_precedence_over_json() {
1191        let dir = std::env::temp_dir().join("lang_check_test_config_precedence");
1192        let _ = std::fs::remove_dir_all(&dir);
1193        std::fs::create_dir_all(&dir).unwrap();
1194
1195        // Write both files with different values
1196        std::fs::write(
1197            dir.join(".languagecheck.yaml"),
1198            "engines:\n  harper: false\n",
1199        )
1200        .unwrap();
1201        std::fs::write(
1202            dir.join(".languagecheck.json"),
1203            r#"{"engines": {"harper": true}}"#,
1204        )
1205        .unwrap();
1206
1207        let config = Config::load(&dir).unwrap();
1208        // YAML should win
1209        assert!(!config.engines.harper.enabled);
1210
1211        let _ = std::fs::remove_dir_all(&dir);
1212    }
1213
1214    #[test]
1215    fn load_missing_file_returns_default() {
1216        let dir = std::env::temp_dir().join("lang_check_test_config_missing");
1217        let _ = std::fs::remove_dir_all(&dir);
1218        std::fs::create_dir_all(&dir).unwrap();
1219
1220        let config = Config::load(&dir).unwrap();
1221        assert!(config.engines.harper.enabled);
1222
1223        let _ = std::fs::remove_dir_all(&dir);
1224    }
1225
1226    #[test]
1227    fn exclude_matches_a_path_relative_to_the_workspace() {
1228        let config = Config {
1229            exclude: vec!["drafts/**".to_string(), "node_modules/**".to_string()],
1230            ..Config::default()
1231        };
1232        let root = Path::new("/home/someone/project");
1233
1234        assert!(config.excludes(&root.join("drafts/notes.md"), root));
1235        assert!(config.excludes(&root.join("node_modules/pkg/README.md"), root));
1236        assert!(!config.excludes(&root.join("docs/notes.md"), root));
1237    }
1238
1239    #[test]
1240    fn exclude_accepts_a_path_that_is_already_relative() {
1241        // The indexer has relative paths and the editor absolute ones, and
1242        // both ask the same question.
1243        let config = Config {
1244            exclude: vec!["drafts/**".to_string()],
1245            ..Config::default()
1246        };
1247        let root = Path::new("/home/someone/project");
1248        assert!(config.excludes(Path::new("drafts/notes.md"), root));
1249    }
1250
1251    #[test]
1252    fn exclude_matches_whichever_separator_the_platform_uses() {
1253        // The patterns are written with `/` on every platform; the path
1254        // arrives with the platform's own separator. Matching the two
1255        // literally meant `exclude` never matched anything on Windows.
1256        let config = Config {
1257            exclude: vec!["drafts/**".to_string()],
1258            ..Config::default()
1259        };
1260        let root = Path::new("/home/someone/project");
1261        let with_backslashes = root.join("drafts").join("notes.md");
1262        assert!(config.excludes(&with_backslashes, root));
1263    }
1264
1265    #[test]
1266    fn an_empty_exclude_list_excludes_nothing() {
1267        let config = Config::default();
1268        let root = Path::new("/tmp");
1269        assert!(!config.excludes(&root.join("anything.md"), root));
1270    }
1271
1272    #[test]
1273    fn a_malformed_pattern_excludes_nothing_rather_than_everything() {
1274        // Refusing to check a file because a glob had a typo is the worse of
1275        // the two failures: the user sees silence and no reason for it.
1276        let config = Config {
1277            exclude: vec!["[unclosed".to_string(), "drafts/**".to_string()],
1278            ..Config::default()
1279        };
1280        let root = Path::new("/tmp");
1281        assert!(!config.excludes(&root.join("notes.md"), root));
1282        assert!(config.excludes(&root.join("drafts/notes.md"), root));
1283    }
1284
1285    #[test]
1286    fn a_relative_vale_config_is_resolved_against_the_workspace() {
1287        // Vale is spawned by the core, whose working directory is wherever
1288        // the editor started it. A path left as written reached Vale meaning
1289        // something else entirely, so `config: ".vale.ini"` -- the documented
1290        // form -- worked from the CLI and silently did nothing in VS Code.
1291        let dir = std::env::temp_dir().join(format!("lc_resolve_{}", std::process::id()));
1292        std::fs::create_dir_all(&dir).unwrap();
1293        std::fs::write(
1294            dir.join(".languagecheck.yaml"),
1295            "engines:\n  vale:\n    enabled: true\n    config: \".vale.ini\"\n",
1296        )
1297        .unwrap();
1298
1299        let config = Config::load(&dir).expect("config");
1300        let resolved = config.engines.vale.config.expect("a config path");
1301        assert!(
1302            Path::new(&resolved).is_absolute(),
1303            "left relative: {resolved}"
1304        );
1305        assert!(resolved.ends_with(".vale.ini"), "{resolved}");
1306        assert!(resolved.starts_with(&*dir.to_string_lossy()), "{resolved}");
1307
1308        std::fs::remove_dir_all(&dir).ok();
1309    }
1310
1311    #[test]
1312    fn an_absolute_path_in_the_config_is_left_alone() {
1313        let dir = std::env::temp_dir().join(format!("lc_resolve_abs_{}", std::process::id()));
1314        std::fs::create_dir_all(&dir).unwrap();
1315
1316        // Taken from the platform rather than written out. `/etc/vale.ini` is
1317        // absolute on Unix and merely rooted on Windows, where it has no drive
1318        // -- so it is resolved against the workspace's drive, correctly, and a
1319        // test that hard-coded it would be testing the wrong thing there.
1320        let elsewhere = std::env::temp_dir().join("vale.ini");
1321        let elsewhere = elsewhere.to_string_lossy().into_owned();
1322        // Single-quoted, because a backslash inside a double-quoted YAML
1323        // scalar is an escape and a Windows path is full of them.
1324        std::fs::write(
1325            dir.join(".languagecheck.yaml"),
1326            format!("engines:\n  vale:\n    enabled: true\n    config: '{elsewhere}'\n"),
1327        )
1328        .unwrap();
1329
1330        let config = Config::load(&dir).expect("config");
1331        assert_eq!(
1332            config.engines.vale.config.as_deref(),
1333            Some(elsewhere.as_str())
1334        );
1335
1336        std::fs::remove_dir_all(&dir).ok();
1337    }
1338
1339    #[test]
1340    fn a_wasm_plugin_path_is_resolved_too() {
1341        // Same reasoning, same failure: a plugin named relative to the
1342        // workspace was looked for relative to the editor's cwd.
1343        let dir = std::env::temp_dir().join(format!("lc_resolve_wasm_{}", std::process::id()));
1344        std::fs::create_dir_all(&dir).unwrap();
1345        std::fs::write(
1346            dir.join(".languagecheck.yaml"),
1347            "engines:\n  wasm_plugins:\n    - name: p\n      path: plugins/p.wasm\n",
1348        )
1349        .unwrap();
1350
1351        let config = Config::load(&dir).expect("config");
1352        let resolved = &config.engines.wasm_plugins[0].path;
1353        assert!(
1354            Path::new(resolved).is_absolute(),
1355            "left relative: {resolved}"
1356        );
1357        // Compared with separators normalised: the join uses the platform's.
1358        assert!(
1359            resolved.replace('\\', "/").ends_with("plugins/p.wasm"),
1360            "{resolved}"
1361        );
1362
1363        std::fs::remove_dir_all(&dir).ok();
1364    }
1365
1366    #[test]
1367    fn a_relative_proselint_config_is_resolved_too() {
1368        // Same shape as Vale's, spawned the same way, with the same failure.
1369        let dir = std::env::temp_dir().join(format!("lc_resolve_pl_{}", std::process::id()));
1370        std::fs::create_dir_all(&dir).unwrap();
1371        std::fs::write(
1372            dir.join(".languagecheck.yaml"),
1373            "engines:\n  proselint:\n    enabled: true\n    config: \"proselint.json\"\n",
1374        )
1375        .unwrap();
1376
1377        let config = Config::load(&dir).expect("config");
1378        let resolved = config.engines.proselint.config.expect("a config path");
1379        assert!(
1380            Path::new(&resolved).is_absolute(),
1381            "left relative: {resolved}"
1382        );
1383        assert!(resolved.ends_with("proselint.json"), "{resolved}");
1384
1385        std::fs::remove_dir_all(&dir).ok();
1386    }
1387
1388    #[test]
1389    fn an_external_command_written_as_a_path_is_resolved() {
1390        let dir = std::env::temp_dir().join(format!("lc_resolve_ext_{}", std::process::id()));
1391        std::fs::create_dir_all(&dir).unwrap();
1392        std::fs::write(
1393            dir.join(".languagecheck.yaml"),
1394            "engines:\n  external:\n    - name: c\n      command: ./my-checker\n",
1395        )
1396        .unwrap();
1397
1398        let config = Config::load(&dir).expect("config");
1399        let command = &config.engines.external[0].command;
1400        assert!(Path::new(command).is_absolute(), "left relative: {command}");
1401        assert!(command.ends_with("my-checker"), "{command}");
1402
1403        std::fs::remove_dir_all(&dir).ok();
1404    }
1405
1406    #[test]
1407    fn an_external_command_that_is_a_bare_name_is_left_for_path_lookup() {
1408        // The one spelling that must not be touched: `vale` means "whatever
1409        // PATH finds", and `<root>/vale` means a file that is not there.
1410        let dir = std::env::temp_dir().join(format!("lc_resolve_bare_{}", std::process::id()));
1411        std::fs::create_dir_all(&dir).unwrap();
1412        std::fs::write(
1413            dir.join(".languagecheck.yaml"),
1414            "engines:\n  external:\n    - name: c\n      command: my-checker\n",
1415        )
1416        .unwrap();
1417
1418        let config = Config::load(&dir).expect("config");
1419        assert_eq!(config.engines.external[0].command, "my-checker");
1420
1421        std::fs::remove_dir_all(&dir).ok();
1422    }
1423
1424    #[test]
1425    fn auto_fix_simple_replacement() {
1426        let config = Config {
1427            auto_fix: vec![AutoFixRule {
1428                find: "teh".to_string(),
1429                replace: "the".to_string(),
1430                context: None,
1431                description: None,
1432            }],
1433            ..Config::default()
1434        };
1435        let (result, count) = config.apply_auto_fixes("Fix teh typo in teh text.");
1436        assert_eq!(result, "Fix the typo in the text.");
1437        assert_eq!(count, 2);
1438    }
1439
1440    #[test]
1441    fn auto_fix_with_context_filter() {
1442        let config = Config {
1443            auto_fix: vec![AutoFixRule {
1444                find: "colour".to_string(),
1445                replace: "color".to_string(),
1446                context: Some("American".to_string()),
1447                description: Some("Use American spelling".to_string()),
1448            }],
1449            ..Config::default()
1450        };
1451        // Context matches — replacement should happen
1452        let (result, count) = config.apply_auto_fixes("American English: the colour is red.");
1453        assert_eq!(result, "American English: the color is red.");
1454        assert_eq!(count, 1);
1455
1456        // Context does not match — no replacement
1457        let (result, count) = config.apply_auto_fixes("British English: the colour is red.");
1458        assert_eq!(result, "British English: the colour is red.");
1459        assert_eq!(count, 0);
1460    }
1461
1462    #[test]
1463    fn auto_fix_no_match() {
1464        let config = Config {
1465            auto_fix: vec![AutoFixRule {
1466                find: "foo".to_string(),
1467                replace: "bar".to_string(),
1468                context: None,
1469                description: None,
1470            }],
1471            ..Config::default()
1472        };
1473        let (result, count) = config.apply_auto_fixes("No matches here.");
1474        assert_eq!(result, "No matches here.");
1475        assert_eq!(count, 0);
1476    }
1477
1478    #[test]
1479    fn auto_fix_multiple_rules() {
1480        let config = Config {
1481            auto_fix: vec![
1482                AutoFixRule {
1483                    find: "recieve".to_string(),
1484                    replace: "receive".to_string(),
1485                    context: None,
1486                    description: None,
1487                },
1488                AutoFixRule {
1489                    find: "seperate".to_string(),
1490                    replace: "separate".to_string(),
1491                    context: None,
1492                    description: None,
1493                },
1494            ],
1495            ..Config::default()
1496        };
1497        let (result, count) = config.apply_auto_fixes("Please recieve the seperate package.");
1498        assert_eq!(result, "Please receive the separate package.");
1499        assert_eq!(count, 2);
1500    }
1501
1502    #[test]
1503    fn auto_fix_loads_from_yaml() {
1504        let yaml = r#"
1505auto_fix:
1506  - find: "teh"
1507    replace: "the"
1508    description: "Fix common typo"
1509  - find: "colour"
1510    replace: "color"
1511    context: "American"
1512"#;
1513        let config: Config = serde_yaml::from_str(yaml).unwrap();
1514        assert_eq!(config.auto_fix.len(), 2);
1515        assert_eq!(config.auto_fix[0].find, "teh");
1516        assert_eq!(config.auto_fix[0].replace, "the");
1517        assert_eq!(
1518            config.auto_fix[0].description.as_deref(),
1519            Some("Fix common typo")
1520        );
1521        assert_eq!(config.auto_fix[1].context.as_deref(), Some("American"));
1522    }
1523
1524    #[test]
1525    fn default_config_has_empty_auto_fix() {
1526        let config = Config::default();
1527        assert!(config.auto_fix.is_empty());
1528    }
1529
1530    #[test]
1531    fn external_providers_from_yaml() {
1532        let yaml = r#"
1533engines:
1534  harper: true
1535  languagetool: false
1536  external:
1537    - name: vale
1538      command: /usr/bin/vale
1539      args: ["--output", "JSON"]
1540      extensions: [md, rst]
1541    - name: custom-checker
1542      command: ./my-checker
1543"#;
1544        let config: Config = serde_yaml::from_str(yaml).unwrap();
1545        assert_eq!(config.engines.external.len(), 2);
1546        assert_eq!(config.engines.external[0].name, "vale");
1547        assert_eq!(config.engines.external[0].command, "/usr/bin/vale");
1548        assert_eq!(config.engines.external[0].args, vec!["--output", "JSON"]);
1549        assert_eq!(config.engines.external[0].extensions, vec!["md", "rst"]);
1550        assert_eq!(config.engines.external[1].name, "custom-checker");
1551        assert!(config.engines.external[1].args.is_empty());
1552    }
1553
1554    #[test]
1555    fn default_config_has_no_external_providers() {
1556        let config = Config::default();
1557        assert!(config.engines.external.is_empty());
1558    }
1559
1560    #[test]
1561    fn wasm_plugins_from_yaml() {
1562        let yaml = r#"
1563engines:
1564  harper: true
1565  wasm_plugins:
1566    - name: custom-checker
1567      path: .languagecheck/plugins/checker.wasm
1568      extensions: [md, html]
1569    - name: style-linter
1570      path: /opt/plugins/style.wasm
1571"#;
1572        let config: Config = serde_yaml::from_str(yaml).unwrap();
1573        assert_eq!(config.engines.wasm_plugins.len(), 2);
1574        assert_eq!(config.engines.wasm_plugins[0].name, "custom-checker");
1575        assert_eq!(
1576            config.engines.wasm_plugins[0].path,
1577            ".languagecheck/plugins/checker.wasm"
1578        );
1579        assert_eq!(
1580            config.engines.wasm_plugins[0].extensions,
1581            vec!["md", "html"]
1582        );
1583        assert_eq!(config.engines.wasm_plugins[1].name, "style-linter");
1584        assert!(config.engines.wasm_plugins[1].extensions.is_empty());
1585    }
1586
1587    #[test]
1588    fn default_config_has_no_wasm_plugins() {
1589        let config = Config::default();
1590        assert!(config.engines.wasm_plugins.is_empty());
1591    }
1592
1593    #[test]
1594    fn performance_config_defaults() {
1595        let config = Config::default();
1596        assert!(!config.performance.high_performance_mode);
1597        assert_eq!(config.performance.debounce_ms, 500);
1598        assert_eq!(config.performance.max_file_size, 0);
1599    }
1600
1601    #[test]
1602    fn performance_config_from_yaml() {
1603        let yaml = r#"
1604performance:
1605  high_performance_mode: true
1606  debounce_ms: 500
1607  max_file_size: 1048576
1608"#;
1609        let config: Config = serde_yaml::from_str(yaml).unwrap();
1610        assert!(config.performance.high_performance_mode);
1611        assert_eq!(config.performance.debounce_ms, 500);
1612        assert_eq!(config.performance.max_file_size, 1_048_576);
1613    }
1614
1615    #[test]
1616    fn latex_skip_environments_from_yaml() {
1617        let yaml = r#"
1618languages:
1619  latex:
1620    skip_environments:
1621      - prooftree
1622      - mycustomenv
1623"#;
1624        let config: Config = serde_yaml::from_str(yaml).unwrap();
1625        assert_eq!(
1626            config.languages.latex.skip_environments,
1627            vec!["prooftree", "mycustomenv"]
1628        );
1629    }
1630
1631    #[test]
1632    fn default_config_has_empty_latex_skip_environments() {
1633        let config = Config::default();
1634        assert!(config.languages.latex.skip_environments.is_empty());
1635    }
1636
1637    #[test]
1638    fn latex_skip_commands_from_yaml() {
1639        let yaml = r#"
1640languages:
1641  latex:
1642    skip_commands:
1643      - codefont
1644      - myverb
1645"#;
1646        let config: Config = serde_yaml::from_str(yaml).unwrap();
1647        assert_eq!(
1648            config.languages.latex.skip_commands,
1649            vec!["codefont", "myverb"]
1650        );
1651    }
1652
1653    #[test]
1654    fn default_spell_language_is_en_us() {
1655        let config = Config::default();
1656        assert_eq!(config.engines.spell_language, "en-US");
1657    }
1658
1659    #[test]
1660    fn spell_language_from_yaml() {
1661        let yaml = r#"
1662engines:
1663  spell_language: de-DE
1664"#;
1665        let config: Config = serde_yaml::from_str(yaml).unwrap();
1666        assert_eq!(config.engines.spell_language, "de-DE");
1667    }
1668
1669    #[test]
1670    fn default_config_has_empty_latex_skip_commands() {
1671        let config = Config::default();
1672        assert!(config.languages.latex.skip_commands.is_empty());
1673    }
1674
1675    #[test]
1676    fn default_vale_is_disabled() {
1677        let config = Config::default();
1678        assert!(!config.engines.vale.enabled);
1679        assert!(config.engines.vale.config.is_none());
1680    }
1681
1682    #[test]
1683    fn vale_bool_shorthand_from_yaml() {
1684        let yaml = r#"
1685engines:
1686  vale: true
1687"#;
1688        let config: Config = serde_yaml::from_str(yaml).unwrap();
1689        assert!(config.engines.vale.enabled);
1690    }
1691
1692    #[test]
1693    fn vale_nested_config_from_yaml() {
1694        let yaml = r#"
1695engines:
1696  vale:
1697    enabled: true
1698    config: ".vale.ini"
1699"#;
1700        let config: Config = serde_yaml::from_str(yaml).unwrap();
1701        assert!(config.engines.vale.enabled);
1702        assert_eq!(config.engines.vale.config.as_deref(), Some(".vale.ini"));
1703    }
1704
1705    #[test]
1706    fn harper_nested_config_from_yaml() {
1707        let yaml = r#"
1708engines:
1709  harper:
1710    enabled: true
1711    dialect: "British"
1712    linters:
1713      LongSentences: false
1714"#;
1715        let config: Config = serde_yaml::from_str(yaml).unwrap();
1716        assert!(config.engines.harper.enabled);
1717        assert_eq!(config.engines.harper.dialect, "British");
1718        assert_eq!(
1719            config.engines.harper.linters.get("LongSentences"),
1720            Some(&false)
1721        );
1722    }
1723
1724    #[test]
1725    fn languagetool_nested_config_from_yaml() {
1726        let yaml = r#"
1727engines:
1728  languagetool:
1729    enabled: true
1730    url: "http://localhost:9090"
1731    level: "picky"
1732    disabled_rules:
1733      - WHITESPACE_RULE
1734"#;
1735        let config: Config = serde_yaml::from_str(yaml).unwrap();
1736        assert!(config.engines.languagetool.enabled);
1737        assert_eq!(config.engines.languagetool.url, "http://localhost:9090");
1738        assert_eq!(config.engines.languagetool.level, "picky");
1739        assert_eq!(
1740            config.engines.languagetool.disabled_rules,
1741            vec!["WHITESPACE_RULE"]
1742        );
1743        assert_eq!(config.engines.languagetool.max_concurrent_requests, 8);
1744    }
1745
1746    /// Issue #86: the flat key our own docs advertised was dropped on the floor,
1747    /// so a self-hosted server was checked against `localhost:8010` instead.
1748    #[test]
1749    fn legacy_flat_languagetool_url_is_honoured() {
1750        let yaml = r#"
1751engines:
1752  spell_language: fr
1753  proselint: false
1754  vale: false
1755  languagetool: true
1756  languagetool_url: "http://10.0.10.3:8003"
1757  harper: false
1758"#;
1759        let config: Config = serde_yaml::from_str(yaml).unwrap();
1760        assert!(config.engines.languagetool.enabled);
1761        assert_eq!(config.engines.languagetool.url, "http://10.0.10.3:8003");
1762        assert_eq!(config.engines.spell_language, "fr");
1763        assert!(!config.engines.harper.enabled);
1764    }
1765
1766    #[test]
1767    fn nested_languagetool_url_beats_the_legacy_key() {
1768        let yaml = r#"
1769engines:
1770  languagetool:
1771    enabled: true
1772    url: "http://nested:9090"
1773  languagetool_url: "http://flat:8003"
1774"#;
1775        let config: Config = serde_yaml::from_str(yaml).unwrap();
1776        assert_eq!(config.engines.languagetool.url, "http://nested:9090");
1777    }
1778
1779    #[test]
1780    fn legacy_flat_vale_config_is_honoured() {
1781        let yaml = "engines:\n  vale: true\n  vale_config: \"config/.vale.ini\"\n";
1782        let config: Config = serde_yaml::from_str(yaml).unwrap();
1783        assert!(config.engines.vale.enabled);
1784        assert_eq!(
1785            config.engines.vale.config.as_deref(),
1786            Some("config/.vale.ini")
1787        );
1788    }
1789
1790    #[test]
1791    fn unknown_keys_are_reported() {
1792        let value: serde_yaml::Value =
1793            serde_yaml::from_str("engines:\n  languagetol: true\n  harper: true\nrulez: {}\n")
1794                .unwrap();
1795        assert_eq!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS), vec!["rulez"]);
1796        assert_eq!(
1797            unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS),
1798            vec!["languagetol"]
1799        );
1800    }
1801
1802    #[test]
1803    fn recognised_keys_are_not_reported() {
1804        let value: serde_yaml::Value = serde_yaml::from_str(
1805            "engines:\n  languagetool_url: \"http://x:1\"\n  harper: true\nrules: {}\n",
1806        )
1807        .unwrap();
1808        assert!(unknown_keys(&value, KNOWN_TOP_LEVEL_KEYS).is_empty());
1809        assert!(unknown_keys(value.get("engines").unwrap(), KNOWN_ENGINE_KEYS).is_empty());
1810    }
1811
1812    #[test]
1813    fn languagetool_concurrency_can_be_pinned_to_serial() {
1814        // Shared or rate-limited servers need the old one-at-a-time behaviour back.
1815        let yaml = r"
1816engines:
1817  languagetool:
1818    enabled: true
1819    max_concurrent_requests: 1
1820";
1821        let config: Config = serde_yaml::from_str(yaml).unwrap();
1822        assert_eq!(config.engines.languagetool.max_concurrent_requests, 1);
1823    }
1824
1825    #[test]
1826    fn default_proselint_is_disabled() {
1827        let config = Config::default();
1828        assert!(!config.engines.proselint.enabled);
1829        assert!(config.engines.proselint.config.is_none());
1830    }
1831
1832    #[test]
1833    fn proselint_bool_shorthand_from_yaml() {
1834        let yaml = r#"
1835engines:
1836  proselint: true
1837"#;
1838        let config: Config = serde_yaml::from_str(yaml).unwrap();
1839        assert!(config.engines.proselint.enabled);
1840    }
1841
1842    #[test]
1843    fn proselint_nested_config_from_yaml() {
1844        let yaml = r#"
1845engines:
1846  proselint:
1847    enabled: true
1848    config: "proselint.json"
1849"#;
1850        let config: Config = serde_yaml::from_str(yaml).unwrap();
1851        assert!(config.engines.proselint.enabled);
1852        assert_eq!(
1853            config.engines.proselint.config.as_deref(),
1854            Some("proselint.json")
1855        );
1856    }
1857}