Skip to main content

declint_core/
config.rs

1//! Rule-file loading: schema validation with rule ids and file lines in
2//! every error.
3
4use std::collections::{HashMap, HashSet};
5use std::fmt;
6use std::path::Path;
7
8use serde_yaml::Value;
9
10use crate::callback::CallbackRef;
11use crate::presets;
12use crate::template::Template;
13use crate::Severity;
14
15/// How a rule finds its matches: a compiled regex (the default) or a
16/// registered parser function (see [`Rule::parser`]).
17#[derive(Debug, Clone)]
18pub(crate) enum Matcher {
19    /// `pattern:` — compiled at config-load time.
20    Regex(regex::Regex),
21    /// `parser:` — resolved against the registry at linter build time.
22    Parser,
23}
24
25/// One resolved `import:` entry.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub(crate) enum ImportSource {
28    /// `preset:<name>` — an embedded library config.
29    Preset(String),
30    /// A path ending in `.yaml`/`.yml`, relative to the importing file.
31    File(String),
32    /// `global:<owner>/<repo>` — a package from the global store
33    /// (`declint install -g`).
34    Global(String),
35}
36
37impl ImportSource {
38    /// The entry as written in the config, for error messages.
39    fn as_written(&self) -> String {
40        match self {
41            Self::Preset(name) => format!("preset:{name}"),
42            Self::Global(pkg) => format!("global:{pkg}"),
43            Self::File(path) => path.clone(),
44        }
45    }
46}
47
48/// Classifies an `import:` entry.
49pub(crate) fn classify_import(entry: &str) -> Result<ImportSource, String> {
50    if let Some(source) = entry.strip_prefix("gh:") {
51        return Err(format!(
52            "gh: sources are installed, not imported — run `declint install -g gh:{source}` \
53             (or without -g to vendor into this project)"
54        ));
55    }
56    if let Some(pkg) = entry.strip_prefix("global:") {
57        if pkg.is_empty() {
58            return Err("global imports need a package (`global:owner/repo`)".into());
59        }
60        return Ok(ImportSource::Global(pkg.to_string()));
61    }
62    if let Some(name) = entry.strip_prefix("preset:") {
63        if name.is_empty() {
64            return Err("preset imports need a name (`preset:python`)".into());
65        }
66        Ok(ImportSource::Preset(name.to_string()))
67    } else if entry.ends_with(".yaml") || entry.ends_with(".yml") {
68        Ok(ImportSource::File(entry.to_string()))
69    } else {
70        Err(
71            "import entries must be `preset:<name>`, `global:<pkg>`, or a path ending in \
72             `.yaml`/`.yml`"
73                .into(),
74        )
75    }
76}
77
78/// The config schema version this declint understands.
79pub const SUPPORTED_VERSION: u64 = 1;
80
81/// Everything that can go wrong while loading a config file.
82///
83/// Carries a 1-based file line whenever the problem can be pinned to one
84/// (`declint.yaml:5: rule 0 ('no-tabs'): invalid pattern ...`). Rule-level
85/// errors point at the rule's `- ` entry line; YAML syntax errors point at
86/// the exact position reported by the parser.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ConfigError {
89    message: String,
90    line: Option<usize>,
91    column: Option<usize>,
92    path: Option<String>,
93}
94
95impl ConfigError {
96    /// Creates an error from a message alone (no file position).
97    pub fn new(message: impl Into<String>) -> Self {
98        Self {
99            message: message.into(),
100            line: None,
101            column: None,
102            path: None,
103        }
104    }
105
106    fn at_line(message: impl Into<String>, line: Option<usize>) -> Self {
107        Self {
108            message: message.into(),
109            line,
110            column: None,
111            path: None,
112        }
113    }
114
115    fn with_syntax_location(mut self, line: usize, column: usize) -> Self {
116        self.line = Some(line);
117        self.column = Some(column);
118        self
119    }
120
121    /// Attaches a config file path, for errors from
122    /// [`Config::load`](Config::load).
123    pub fn with_path(mut self, path: impl Into<String>) -> Self {
124        self.path = Some(path.into());
125        self
126    }
127
128    /// Annotates an error with the import entry it occurred behind.
129    fn at_import(mut self, entry: &str, parent_origin: &str) -> Self {
130        self.message = format!("import '{entry}': {}", self.message);
131        if self.path.is_none() {
132            self.path = Some(parent_origin.to_string());
133        }
134        self
135    }
136
137    /// The problem's 1-based line in the config file, when known.
138    pub fn line(&self) -> Option<usize> {
139        self.line
140    }
141
142    /// The problem's 1-based column in the config file, when known.
143    pub fn column(&self) -> Option<usize> {
144        self.column
145    }
146}
147
148impl fmt::Display for ConfigError {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        if let Some(path) = &self.path {
151            write!(f, "{path}")?;
152        }
153        if let Some(line) = self.line {
154            write!(f, ":{line}")?;
155            if let Some(column) = self.column {
156                write!(f, ":{column}")?;
157            }
158        }
159        if self.path.is_some() || self.line.is_some() {
160            write!(f, ": ")?;
161        }
162        f.write_str(&self.message)
163    }
164}
165
166impl std::error::Error for ConfigError {}
167
168/// A validated configuration: the schema version, global rules, and
169/// scopes.
170#[derive(Debug, Clone)]
171pub struct Config {
172    /// The config's declared schema version (always
173    /// [`SUPPORTED_VERSION`]).
174    pub version: u64,
175    /// The editor language ids this config applies to (exact match
176    /// against the client's `languageId`; in Neovim, the filetype).
177    /// Empty = every language.
178    pub languages: Vec<String>,
179    /// The raw `import:` entries, as written.
180    pub imports: Vec<String>,
181    /// The classified `import:` entries (drained during resolution).
182    import_entries: Vec<ImportSource>,
183    /// The validated global rules, in file order (imported rules
184    /// first).
185    pub rules: Vec<Rule>,
186    /// The validated scopes, in file order (imported scopes first).
187    pub scopes: Vec<Scope>,
188}
189
190impl Config {
191    /// Whether this config applies to documents with language id `id`.
192    /// Configs without a `languages` key apply to everything.
193    pub fn matches_language(&self, id: &str) -> bool {
194        self.languages.is_empty() || self.languages.iter().any(|l| l == id)
195    }
196}
197
198/// One embedded rule test: a snippet and what the rule should do with
199/// it. Run by `declint test`.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct RuleTest {
202    /// Optional label shown in test output.
203    pub name: Option<String>,
204    /// The snippet the rule runs against.
205    pub text: String,
206    /// How many violations of the rule the snippet should produce
207    /// (default 0).
208    pub violations: usize,
209    /// Each of these must appear among the rendered messages.
210    pub messages: Vec<String>,
211}
212
213/// One validated lint rule.
214#[derive(Debug, Clone)]
215pub struct Rule {
216    /// The rule's unique id — what shows up as the diagnostic's code, and
217    /// what suppressions (a future feature) will name. Unique across all
218    /// global rules and every scope's rules.
219    pub id: String,
220    /// The pattern, as written in the config. Empty when the rule uses a
221    /// `parser` instead.
222    pub pattern: String,
223    /// How serious a hit is (default: [`Severity::Warning`]).
224    pub severity: Severity,
225    /// The message template, parsed and capture-validated. Optional when
226    /// the rule has a `callback` (then it doubles as the message for
227    /// callbacks that return "violate with the default message").
228    pub message: Option<Template>,
229    /// The rule's callback reference, if any.
230    pub callback: Option<CallbackRef>,
231    /// The rule's parser reference, if any — mutually exclusive with
232    /// [`Rule::pattern`].
233    pub parser: Option<CallbackRef>,
234    /// The fix template, if any: replaces each match, with captures
235    /// interpolated like messages. Surfaces as a quickfix in editors
236    /// and as `declint check --fix`.
237    pub fix: Option<Template>,
238    /// Embedded fixtures, run by `declint test`. Unused by the lint
239    /// engine itself.
240    pub tests: Vec<RuleTest>,
241    /// How the rule finds matches — construction only succeeds when the
242    /// regex (if any) is valid.
243    pub(crate) matcher: Matcher,
244}
245
246/// One validated scope: a segmenter (`start`/`end`) plus the rules that
247/// run only inside its regions.
248///
249/// `start` and `end` are compiled with multi-line mode forced on, so `^`
250/// and `$` anchor to lines — the natural way to write region boundaries.
251#[derive(Debug, Clone)]
252pub struct Scope {
253    /// The scope's unique id. Shares a namespace with rule ids.
254    pub id: String,
255    /// Where a region begins, as written.
256    pub start: String,
257    /// Where a region ends, as written (`None` = run to end of file).
258    pub end: Option<String>,
259    /// The rules that run inside this scope's regions.
260    pub rules: Vec<Rule>,
261    /// The compiled `start` pattern (multi-line forced).
262    pub(crate) start_re: regex::Regex,
263    /// The compiled `end` pattern (multi-line forced).
264    pub(crate) end_re: Option<regex::Regex>,
265}
266
267impl Config {
268    /// Loads and validates a config from YAML text.
269    ///
270    /// `preset:` imports resolve against the embedded library; relative
271    /// file imports need a file — use [`Config::load`] for those.
272    #[allow(clippy::should_implement_trait)]
273    pub fn from_str(yaml: &str) -> Result<Self, ConfigError> {
274        Self::resolve(yaml, None, "<config>", "<config>", &mut Vec::new())
275    }
276
277    /// Loads and validates the config site at `path` (a file), resolving
278    /// its `import:` entries recursively.
279    pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
280        let path = path.as_ref();
281        let shown = path.display().to_string();
282        let yaml = std::fs::read_to_string(path).map_err(|e| {
283            ConfigError::new(format!("cannot read config file: {e}")).with_path(&shown)
284        })?;
285        let canonical = path
286            .canonicalize()
287            .unwrap_or_else(|_| path.to_path_buf());
288        let mut stack = Vec::new();
289        let base = path.parent().map(Path::to_path_buf);
290        Self::resolve(
291            &yaml,
292            base.as_deref(),
293            &shown,
294            &canonical.display().to_string(),
295            &mut stack,
296        )
297        .map_err(|e| e.with_path(shown))
298    }
299
300    /// Parses `yaml` (origin: a display label for error messages) and
301    /// resolves its imports depth-first. `stack` holds the markers of
302    /// every ancestor — canonical file paths and `preset:<name>` — for
303    /// cycle detection.
304    fn resolve(
305        yaml: &str,
306        base: Option<&std::path::Path>,
307        origin: &str,
308        marker: &str,
309        stack: &mut Vec<String>,
310    ) -> Result<Self, ConfigError> {
311        if stack.iter().any(|m| m == marker) {
312            return Err(ConfigError::new(format!(
313                "import cycle detected ({origin} imports itself, directly or indirectly)"
314            )));
315        }
316        let value: Value = serde_yaml::from_str(yaml).map_err(|e| {
317            let err = ConfigError::new(e.to_string());
318            match e.location() {
319                Some(loc) => err.with_syntax_location(loc.line(), loc.column()),
320                None => err,
321            }
322        })?;
323        let mut config = Self::from_value(value, yaml)?;
324        stack.push(marker.to_string());
325
326        let mut rules: Vec<(Rule, String)> = Vec::new();
327        let mut scopes: Vec<(Scope, String)> = Vec::new();
328        for entry in std::mem::take(&mut config.import_entries) {
329            let entry_text = entry.as_written();
330            let entry_text = &entry_text;
331            match &entry {
332                ImportSource::Preset(name) => {
333                    let Some(preset) = presets::lookup(name) else {
334                        return Err(ConfigError::new(format!(
335                            "unknown preset `{name}` (available: {})",
336                            presets::names()
337                        ))
338                        .at_import(entry_text, origin));
339                    };
340                    let child_origin = format!("preset:{name}");
341                    let child =
342                        Self::resolve(preset.content, None, &child_origin, &child_origin, stack)
343                            .map_err(|e| in_import(entry_text, e))?;
344                    Self::absorb(
345                        &child,
346                        &child_origin,
347                        entry_text,
348                        origin,
349                        &mut rules,
350                        &mut scopes,
351                    )?;
352                }
353                ImportSource::Global(pkg) => {
354                    let Some(entry_path) = crate::store::lookup_global(pkg) else {
355                        return Err(ConfigError::new(format!(
356                            "package `{pkg}` is not installed globally — run \
357                             `declint install -g gh:<owner>/<repo>`"
358                        ))
359                        .at_import(entry_text, origin));
360                    };
361                    let child_yaml = std::fs::read_to_string(&entry_path).map_err(|e| {
362                        ConfigError::new(format!(
363                            "cannot read global package `{pkg}`: {e}"
364                        ))
365                        .at_import(entry_text, origin)
366                    })?;
367                    let canonical = entry_path.canonicalize().map_err(|e| {
368                        ConfigError::new(format!(
369                            "cannot resolve global package `{pkg}`: {e}"
370                        ))
371                        .at_import(entry_text, origin)
372                    })?;
373                    let child_origin = format!("global:{pkg}");
374                    let child = Self::resolve(
375                        &child_yaml,
376                        Some(
377                            entry_path
378                                .parent()
379                                .unwrap_or(Path::new(".")),
380                        ),
381                        &child_origin,
382                        &canonical.display().to_string(),
383                        stack,
384                    )
385                    .map_err(|e| in_import(entry_text, e))?;
386                    Self::absorb(
387                        &child,
388                        &child_origin,
389                        entry_text,
390                        origin,
391                        &mut rules,
392                        &mut scopes,
393                    )?;
394                }
395                ImportSource::File(relative) => {
396                    let Some(base) = base else {
397                        return Err(ConfigError::new(
398                            "relative import requires loading the config from a file \
399                             (use Config::load)",
400                        )
401                        .at_import(entry_text, origin));
402                    };
403                    let path = base.join(relative);
404                    let child_yaml = std::fs::read_to_string(&path).map_err(|e| {
405                        ConfigError::new(format!(
406                            "cannot read imported config `{relative}`: {e}"
407                        ))
408                        .at_import(entry_text, origin)
409                    })?;
410                    let canonical = path.canonicalize().map_err(|e| {
411                        ConfigError::new(format!(
412                            "cannot resolve imported config `{relative}`: {e}"
413                        ))
414                        .at_import(entry_text, origin)
415                    })?;
416                    let child_origin = path.display().to_string();
417                    let child = Self::resolve(
418                        &child_yaml,
419                        Some(path.parent().unwrap_or(Path::new("."))),
420                        &child_origin,
421                        &canonical.display().to_string(),
422                        stack,
423                    )
424                    .map_err(|e| in_import(entry_text, e))?;
425                    Self::absorb(
426                        &child,
427                        &child_origin,
428                        entry_text,
429                        origin,
430                        &mut rules,
431                        &mut scopes,
432                    )?;
433                }
434            }
435        }
436        for rule in std::mem::take(&mut config.rules) {
437            rules.push((rule, origin.to_string()));
438        }
439        for scope in std::mem::take(&mut config.scopes) {
440            scopes.push((scope, origin.to_string()));
441        }
442
443        // One id namespace across everything, imported and own.
444        let mut seen: HashMap<&str, &str> = HashMap::new();
445        for (rule, rule_origin) in &rules {
446            if let Some(first) = seen.get(rule.id.as_str()) {
447                return Err(ConfigError::new(format!(
448                    "duplicate rule id `{}` (defined in {first} and {rule_origin})",
449                    rule.id
450                )));
451            }
452            seen.insert(rule.id.as_str(), rule_origin);
453        }
454        for (scope, scope_origin) in &scopes {
455            if let Some(first) = seen.get(scope.id.as_str()) {
456                return Err(ConfigError::new(format!(
457                    "duplicate scope id `{}` (defined in {first} and {scope_origin})",
458                    scope.id
459                )));
460            }
461            seen.insert(scope.id.as_str(), scope_origin);
462        }
463
464        // The presence check happens after imports merge: a config that
465        // only imports (no own rules/scopes) is legitimate.
466        if rules.is_empty() && scopes.is_empty() {
467            return Err(ConfigError::new(
468                "config must define `rules` or `scopes` (directly or via imports)",
469            ));
470        }
471
472        stack.pop();
473        config.rules = rules.into_iter().map(|(rule, _)| rule).collect();
474        config.scopes = scopes.into_iter().map(|(scope, _)| scope).collect();
475        Ok(config)
476    }
477
478    /// Merges one imported fragment: appended first-in (before the
479    /// importer's own items), with its language policy checked.
480    fn absorb(
481        child: &Config,
482        child_origin: &str,
483        entry: &str,
484        parent_origin: &str,
485        rules: &mut Vec<(Rule, String)>,
486        scopes: &mut Vec<(Scope, String)>,
487    ) -> Result<(), ConfigError> {
488        if !child.languages.is_empty() {
489            return Err(ConfigError::new(format!(
490                "imported config declares `languages` ({}) — language policy belongs to \
491                 the importing config",
492                child.languages.join(", ")
493            ))
494            .at_import(entry, parent_origin));
495        }
496        rules.extend(
497            child
498                .rules
499                .iter()
500                .cloned()
501                .map(|rule| (rule, child_origin.to_string())),
502        );
503        scopes.extend(
504            child
505                .scopes
506                .iter()
507                .cloned()
508                .map(|scope| (scope, child_origin.to_string())),
509        );
510        Ok(())
511    }
512
513    fn from_value(value: Value, yaml: &str) -> Result<Self, ConfigError> {
514        let Value::Mapping(map) = value else {
515            return Err(ConfigError::new(
516                "config must be a YAML mapping with `version` and `rules` and/or `scopes` keys",
517            ));
518        };
519
520        let version_value = map
521            .get(Value::from("version"))
522            .ok_or_else(|| ConfigError::new("missing `version` key"))?;
523        let version = version_value
524            .as_u64()
525            .ok_or_else(|| ConfigError::at_line("`version` must be an integer", None))?;
526        if version != SUPPORTED_VERSION {
527            return Err(ConfigError::at_line(
528                format!(
529                    "unsupported config version {version} (this declint understands version \
530                     {SUPPORTED_VERSION})"
531                ),
532                None,
533            ));
534        }
535
536        let rules_value = map.get(Value::from("rules"));
537        let scopes_value = map.get(Value::from("scopes"));
538
539        let languages = match map.get(Value::from("languages")) {
540            None => Vec::new(),
541            Some(value) => {
542                let Value::Sequence(entries) = value else {
543                    return Err(ConfigError::new(
544                        "`languages` must be a list of language ids (e.g. `[markdown, sh]`)",
545                    ));
546                };
547                let mut seen = HashSet::new();
548                let mut languages = Vec::new();
549                for entry in entries {
550                    let Some(language) = entry.as_str().filter(|s| !s.is_empty()) else {
551                        return Err(ConfigError::new(
552                            "`languages` entries must be non-empty strings",
553                        ));
554                    };
555                    if seen.insert(language.to_string()) {
556                        languages.push(language.to_string());
557                    }
558                }
559                languages
560            }
561        };
562
563        let mut seen_ids = HashSet::new();
564
565        let rules = match rules_value {
566            None => Vec::new(),
567            Some(v) => {
568                let Value::Sequence(entries) = v else {
569                    return Err(ConfigError::new("`rules` must be a list of rule mappings"));
570                };
571                let lines = sequence_item_lines(yaml, "rules");
572                entries
573                    .iter()
574                    .enumerate()
575                    .map(|(index, entry)| {
576                        parse_rule(entry, index, "", lines.get(index).copied(), &mut seen_ids)
577                    })
578                    .collect::<Result<Vec<_>, _>>()?
579            }
580        };
581
582        let scopes = match scopes_value {
583            None => Vec::new(),
584            Some(v) => {
585                let Value::Sequence(entries) = v else {
586                    return Err(ConfigError::new("`scopes` must be a list of scope mappings"));
587                };
588                let lines = sequence_item_lines(yaml, "scopes");
589                entries
590                    .iter()
591                    .enumerate()
592                    .map(|(index, entry)| {
593                        parse_scope(
594                            entry,
595                            index,
596                            lines.get(index).copied(),
597                            yaml,
598                            &mut seen_ids,
599                        )
600                    })
601                    .collect::<Result<Vec<_>, _>>()?
602            }
603        };
604
605        let import_entries = match map.get(Value::from("import")) {
606            None => Vec::new(),
607            Some(value) => {
608                let Value::Sequence(entries) = value else {
609                    return Err(ConfigError::new(
610                        "`import` must be a list of entries (`preset:<name>` or a \
611                         `.yaml`/`.yml` path)",
612                    ));
613                };
614                let mut classified = Vec::new();
615                for entry in entries {
616                    let Some(text) = entry.as_str().filter(|s| !s.is_empty()) else {
617                        return Err(ConfigError::new(
618                            "`import` entries must be non-empty strings",
619                        ));
620                    };
621                    match classify_import(text) {
622                        Ok(source) => classified.push(source),
623                        Err(message) => return Err(ConfigError::new(message)),
624                    }
625                }
626                classified
627            }
628        };
629
630        Ok(Self {
631            version,
632            languages,
633            imports: import_entries
634                .iter()
635                .map(|source| match source {
636                    ImportSource::Preset(name) => format!("preset:{name}"),
637                    ImportSource::Global(pkg) => format!("global:{pkg}"),
638                    ImportSource::File(path) => path.clone(),
639                })
640                .collect(),
641            import_entries,
642            rules,
643            scopes,
644        })
645    }
646}
647
648impl Rule {
649    /// The capture-group names of the rule's regex, by group index.
650    /// Empty for parser rules (their capture names are free-form).
651    pub(crate) fn capture_names(&self) -> Vec<Option<&str>> {
652        match &self.matcher {
653            Matcher::Regex(regex) => regex.capture_names().collect(),
654            Matcher::Parser => Vec::new(),
655        }
656    }
657}
658
659/// Compiles a scope boundary pattern with multi-line and CRLF modes
660/// forced on, so `^`/`$` anchor to lines — including `\r\n` line
661/// endings, which Windows-edited files use.
662fn compile_boundary(pattern: &str) -> Result<regex::Regex, regex::Error> {
663    regex::RegexBuilder::new(pattern)
664        .multi_line(true)
665        .crlf(true)
666        .build()
667}
668
669fn parse_scope(
670    entry: &Value,
671    index: usize,
672    line: Option<usize>,
673    yaml: &str,
674    seen_ids: &mut HashSet<String>,
675) -> Result<Scope, ConfigError> {
676    let at_scope = |message: &str| -> ConfigError {
677        let id = entry
678            .get(Value::from("id"))
679            .and_then(|v| v.as_str())
680            .map(|id| format!(" ('{id}')"))
681            .unwrap_or_default();
682        ConfigError::at_line(format!("scope {index}{id}: {message}"), line)
683    };
684
685    let Value::Mapping(map) = entry else {
686        return Err(at_scope("must be a mapping with `id`, `start`, and `rules` keys"));
687    };
688
689    for key in map.keys() {
690        if let Some(key) = key.as_str() {
691            if !matches!(key, "id" | "start" | "end" | "rules") {
692                return Err(at_scope(&format!(
693                    "unknown key `{key}` (expected one of `id`, `start`, `end`, `rules`)"
694                )));
695            }
696        }
697    }
698
699    let missing = |key: &str| at_scope(&format!("missing `{key}` key"));
700
701    let id_value = map.get(Value::from("id")).ok_or_else(|| missing("id"))?;
702    let id = id_value
703        .as_str()
704        .filter(|s| !s.is_empty())
705        .ok_or_else(|| at_scope("`id` must be a non-empty string"))?
706        .to_string();
707    if !seen_ids.insert(id.clone()) {
708        return Err(at_scope(&format!(
709            "duplicate id `{id}` — rule and scope ids share one namespace and must be unique"
710        )));
711    }
712
713    let start_value = map.get(Value::from("start")).ok_or_else(|| missing("start"))?;
714    let start = start_value
715        .as_str()
716        .filter(|s| !s.is_empty())
717        .ok_or_else(|| at_scope("`start` must be a non-empty string"))?
718        .to_string();
719    let start_re =
720        compile_boundary(&start).map_err(|e| at_scope(&format!("invalid start pattern: {e}")))?;
721
722    let end = match map.get(Value::from("end")) {
723        None => None,
724        Some(end_value) => match end_value.as_str().filter(|s| !s.is_empty()) {
725            Some(end) => {
726                compile_boundary(end)
727                    .map_err(|e| at_scope(&format!("invalid end pattern: {e}")))?;
728                Some(end.to_string())
729            }
730            None => {
731                return Err(at_scope("`end` must be a non-empty string"));
732            }
733        },
734    };
735    let end_re = end
736        .as_ref()
737        .map(|pattern| compile_boundary(pattern).expect("validated above"));
738
739    let rules = match map.get(Value::from("rules")) {
740        None => Vec::new(),
741        Some(rules_value) => {
742            let Value::Sequence(entries) = rules_value else {
743                return Err(at_scope("`rules` must be a list of rule mappings"));
744            };
745            let nested_lines = nested_sequence_item_lines(yaml, line, "rules");
746            entries
747                .iter()
748                .enumerate()
749                .map(|(rule_index, rule_entry)| {
750                    let label = format!("scope '{id}' ");
751                    parse_rule(
752                        rule_entry,
753                        rule_index,
754                        &label,
755                        nested_lines.get(rule_index).copied(),
756                        seen_ids,
757                    )
758                })
759                .collect::<Result<Vec<_>, _>>()?
760        }
761    };
762    if rules.is_empty() {
763        return Err(at_scope("scope has no `rules` — a scope without rules does nothing"));
764    }
765
766    Ok(Scope {
767        id,
768        start,
769        end,
770        rules,
771        start_re,
772        end_re,
773    })
774}
775
776fn parse_rule(
777    entry: &Value,
778    index: usize,
779    prefix: &str,
780    line: Option<usize>,
781    seen_ids: &mut HashSet<String>,
782) -> Result<Rule, ConfigError> {
783    let at_rule = |message: String| -> ConfigError {
784        let id = entry
785            .get(Value::from("id"))
786            .and_then(|v| v.as_str())
787            .map(|id| format!(" ('{id}')"))
788            .unwrap_or_default();
789        ConfigError::at_line(format!("{prefix}rule {index}{id}: {message}"), line)
790    };
791
792    let Value::Mapping(map) = entry else {
793        return Err(at_rule(
794            "must be a mapping with `id`, `pattern`, and `message` keys".into(),
795        ));
796    };
797
798    for key in map.keys() {
799        if let Some(key) = key.as_str() {
800            if !matches!(
801                key,
802                "id" | "pattern" | "message" | "severity" | "callback" | "parser"
803                    | "fix" | "tests"
804            ) {
805                return Err(at_rule(format!(
806                    "unknown key `{key}` (expected one of `id`, `pattern`, `parser`, \
807                     `message`, `severity`, `callback`)"
808                )));
809            }
810        }
811    }
812
813    let missing = |key: &str| at_rule(format!("missing `{key}` key"));
814
815    let pattern_value = map.get(Value::from("pattern"));
816    let parser_value = map.get(Value::from("parser"));
817
818    // Exactly one of `pattern` / `parser` — the rule's matcher.
819    let (pattern, parser, matcher) = match (pattern_value, parser_value) {
820        (None, None) => {
821            return Err(at_rule(
822                "rule must have a `pattern` or a `parser` to find matches".into(),
823            ));
824        }
825        (Some(_), Some(_)) => {
826            return Err(at_rule(
827                "`pattern` and `parser` are mutually exclusive — a rule finds matches one way"
828                    .into(),
829            ));
830        }
831        (Some(value), None) => {
832            let pattern = value
833                .as_str()
834                .filter(|s| !s.is_empty())
835                .ok_or_else(|| at_rule("`pattern` must be a non-empty string".into()))?
836                .to_string();
837            let regex = regex::Regex::new(&pattern)
838                .map_err(|e| at_rule(format!("invalid pattern: {e}")))?;
839            (pattern, None, Matcher::Regex(regex))
840        }
841        (None, Some(value)) => {
842            let reference = value
843                .as_str()
844                .filter(|s| !s.is_empty())
845                .map(CallbackRef::parse)
846                .ok_or_else(|| at_rule("`parser` must be a non-empty string".into()))?;
847            (String::new(), Some(reference), Matcher::Parser)
848        }
849    };
850
851    let id_value = map.get(Value::from("id")).ok_or_else(|| missing("id"))?;
852    let id = id_value
853        .as_str()
854        .filter(|s| !s.is_empty())
855        .ok_or_else(|| at_rule("`id` must be a non-empty string".into()))?
856        .to_string();
857    if !seen_ids.insert(id.clone()) {
858        return Err(at_rule(format!(
859            "duplicate id `{id}` — rule and scope ids share one namespace and must be unique"
860        )));
861    }
862
863    let callback = match map.get(Value::from("callback")) {
864        None => None,
865        Some(value) => match value.as_str().filter(|s| !s.is_empty()) {
866            Some(source) => Some(CallbackRef::parse(source)),
867            None => {
868                return Err(at_rule("`callback` must be a non-empty string".into()));
869            }
870        },
871    };
872
873    let message = match map.get(Value::from("message")) {
874        None => {
875            if callback.is_none() {
876                return Err(at_rule(
877                    "rule must have a `message` (or a `callback` to compute one)".into(),
878                ));
879            }
880            None
881        }
882        Some(message_value) => {
883            let message_src = message_value
884                .as_str()
885                .filter(|s| !s.is_empty())
886                .ok_or_else(|| at_rule("`message` must be a non-empty string".into()))?;
887            let template = Template::parse(message_src)
888                .map_err(|e| at_rule(format!("invalid message template: {e}")))?;
889            // Placeholder names are checked against the regex's capture
890            // groups; parser rules provide their own names at runtime.
891            if let Matcher::Regex(regex) = &matcher {
892                template
893                    .validate(regex)
894                    .map_err(|e| at_rule(format!("invalid message template: {e}")))?;
895            }
896            Some(template)
897        }
898    };
899
900    let fix = match map.get(Value::from("fix")) {
901        None => None,
902        Some(value) => {
903            let source = value
904                .as_str()
905                .filter(|s| !s.is_empty())
906                .ok_or_else(|| at_rule("`fix` must be a non-empty string".into()))?;
907            let template = Template::parse(source)
908                .map_err(|e| at_rule(format!("invalid fix template: {e}")))?;
909            // Placeholder names are checked against the regex's capture
910            // groups; parser rules provide their own names at runtime.
911            if let Matcher::Regex(regex) = &matcher {
912                template
913                    .validate(regex)
914                    .map_err(|e| at_rule(format!("invalid fix template: {e}")))?;
915            }
916            Some(template)
917        }
918    };
919
920    let severity = match map.get(Value::from("severity")) {
921        None => Severity::Warning,
922        Some(value) => match value.as_str().and_then(Severity::parse) {
923            Some(severity) => severity,
924            None => {
925                return Err(at_rule(format!(
926                    "`severity` must be one of `error`, `warning`, `info`, `hint` (found `{}`)",
927                    value.as_str().unwrap_or("<non-string>"),
928                )))
929            }
930        },
931    };
932
933    let tests = parse_tests(map, &at_rule)?;
934
935    Ok(Rule {
936        id,
937        pattern,
938        severity,
939        message,
940        callback,
941        parser,
942        fix,
943        matcher,
944        tests,
945    })
946}
947
948/// Parses a rule's embedded `tests:` fixtures.
949fn parse_tests(
950    map: &serde_yaml::Mapping,
951    at_rule: &dyn Fn(String) -> ConfigError,
952) -> Result<Vec<RuleTest>, ConfigError> {
953    let Some(value) = map.get(Value::from("tests")) else {
954        return Ok(Vec::new());
955    };
956    let Value::Sequence(entries) = value else {
957        return Err(at_rule("`tests` must be a list of test mappings".into()));
958    };
959    let mut tests = Vec::new();
960    for entry in entries {
961        let Value::Mapping(test) = entry else {
962            return Err(at_rule("each test must be a mapping".into()));
963        };
964        for key in test.keys() {
965            if let Some(key) = key.as_str() {
966                if !matches!(key, "name" | "text" | "violations" | "messages") {
967                    return Err(at_rule(format!(
968                        "unknown test key `{key}` (expected one of `name`, `text`, \
969                         `violations`, `messages`)"
970                    )));
971                }
972            }
973        }
974        let name = match test.get(Value::from("name")) {
975            None => None,
976            Some(value) => Some(
977                value
978                    .as_str()
979                    .filter(|s| !s.is_empty())
980                    .ok_or_else(|| at_rule("test `name` must be a non-empty string".into()))?
981                    .to_string(),
982            ),
983        };
984        let text = test
985            .get(Value::from("text"))
986            .and_then(|v| v.as_str())
987            .filter(|s| !s.is_empty())
988            .ok_or_else(|| at_rule("test must have a non-empty `text` snippet".into()))?
989            .to_string();
990        let violations = match test.get(Value::from("violations")) {
991            None => 0,
992            Some(value) => value
993                .as_u64()
994                .ok_or_else(|| at_rule("test `violations` must be a non-negative integer".into()))?
995                as usize,
996        };
997        let messages = match test.get(Value::from("messages")) {
998            None => Vec::new(),
999            Some(value) => {
1000                let Value::Sequence(entries) = value else {
1001                    return Err(at_rule("test `messages` must be a list of strings".into()));
1002                };
1003                let mut messages = Vec::new();
1004                for entry in entries {
1005                    let Some(message) = entry.as_str().filter(|s| !s.is_empty()) else {
1006                        return Err(at_rule(
1007                            "test `messages` entries must be non-empty strings".into(),
1008                        ));
1009                    };
1010                    messages.push(message.to_string());
1011                }
1012                messages
1013            }
1014        };
1015        tests.push(RuleTest {
1016            name,
1017            text,
1018            violations,
1019            messages,
1020        });
1021    }
1022    Ok(tests)
1023}
1024
1025/// Finds the 1-based line of each `- ` item of the block sequence stored
1026/// under `key` — the rule/scope entry lines. Flow-style sequences
1027/// (`rules: [...]`) yield nothing, and errors then simply carry no line.
1028fn sequence_item_lines(yaml: &str, key: &str) -> Vec<usize> {
1029    block_seq_item_lines(
1030        yaml.lines().enumerate().map(|(i, l)| (i + 1, l)),
1031        key,
1032    )
1033}
1034
1035/// Like [`sequence_item_lines`], but scanning only after `from_line`
1036/// (1-based, exclusive) — for the `rules` nested inside a scope whose
1037/// `- ` entry is on that line.
1038fn nested_sequence_item_lines(yaml: &str, from_line: Option<usize>, key: &str) -> Vec<usize> {
1039    let skip = from_line.unwrap_or(usize::MAX);
1040    block_seq_item_lines(
1041        yaml.lines()
1042            .enumerate()
1043            .skip(skip)
1044            .map(|(i, l)| (i + 1, l)),
1045        key,
1046    )
1047}
1048
1049fn block_seq_item_lines<'a>(
1050    lines: impl Iterator<Item = (usize, &'a str)>,
1051    key: &str,
1052) -> Vec<usize> {
1053    let key_prefix = format!("{key}:");
1054    let mut lines = Vec::from_iter(lines);
1055    let mut items = Vec::new();
1056    let mut in_sequence = false;
1057    let mut key_indent = 0usize;
1058    for (no, line) in lines.drain(..) {
1059        let trimmed = line.trim_start();
1060        if trimmed.is_empty() || trimmed.starts_with('#') {
1061            continue;
1062        }
1063        let indent = line.len() - trimmed.len();
1064        if !in_sequence {
1065            if trimmed.starts_with(&key_prefix) && trimmed[key_prefix.len()..].trim().is_empty() {
1066                in_sequence = true;
1067                key_indent = indent;
1068            }
1069        } else if indent <= key_indent {
1070            break; // the sequence is over
1071        } else if trimmed.starts_with("- ") || trimmed == "-" {
1072            items.push(no);
1073        }
1074    }
1075    items
1076}
1077
1078/// Annotates a child config's error with the import entry that pulled it
1079/// in — the message chains through every import level.
1080fn in_import(entry: &str, err: ConfigError) -> ConfigError {
1081    ConfigError {
1082        message: format!("in import '{entry}': {}", err.message),
1083        line: err.line,
1084        column: err.column,
1085        path: err.path,
1086    }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092
1093    #[test]
1094    fn finds_block_sequence_items() {
1095        let yaml = "\
1096version: 1
1097rules:
1098  - id: a
1099    pattern: x
1100
1101  # comment
1102  - id: b
1103    pattern: y
1104other: key
1105";
1106        assert_eq!(sequence_item_lines(yaml, "rules"), vec![3, 7]);
1107    }
1108
1109    #[test]
1110    fn finds_nested_items_after_a_line() {
1111        let yaml = "\
1112version: 1
1113scopes:
1114  - id: s
1115    start: 'x'
1116    rules:
1117      - id: r1
1118        pattern: p
1119      - id: r2
1120        pattern: q
1121";
1122        // The scope's `- ` entry is on line 3; its nested rule items are
1123        // lines 6 and 8.
1124        assert_eq!(nested_sequence_item_lines(yaml, Some(3), "rules"), vec![6, 8]);
1125    }
1126
1127    #[test]
1128    fn flow_style_yields_nothing() {
1129        assert!(sequence_item_lines("rules: [{id: a}]\n", "rules").is_empty());
1130    }
1131}