1use 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#[derive(Debug, Clone)]
18pub(crate) enum Matcher {
19 Regex(regex::Regex),
21 Parser,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub(crate) enum ImportSource {
28 Preset(String),
30 File(String),
32 Global(String),
35}
36
37impl ImportSource {
38 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
48pub(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
78pub const SUPPORTED_VERSION: u64 = 1;
80
81#[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 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 pub fn with_path(mut self, path: impl Into<String>) -> Self {
124 self.path = Some(path.into());
125 self
126 }
127
128 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 pub fn line(&self) -> Option<usize> {
139 self.line
140 }
141
142 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#[derive(Debug, Clone)]
171pub struct Config {
172 pub version: u64,
175 pub languages: Vec<String>,
179 pub imports: Vec<String>,
181 import_entries: Vec<ImportSource>,
183 pub rules: Vec<Rule>,
186 pub scopes: Vec<Scope>,
188}
189
190impl Config {
191 pub fn matches_language(&self, id: &str) -> bool {
194 self.languages.is_empty() || self.languages.iter().any(|l| l == id)
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct RuleTest {
202 pub name: Option<String>,
204 pub text: String,
206 pub violations: usize,
209 pub messages: Vec<String>,
211}
212
213#[derive(Debug, Clone)]
215pub struct Rule {
216 pub id: String,
220 pub pattern: String,
223 pub severity: Severity,
225 pub message: Option<Template>,
229 pub callback: Option<CallbackRef>,
231 pub parser: Option<CallbackRef>,
234 pub fix: Option<Template>,
238 pub tests: Vec<RuleTest>,
241 pub(crate) matcher: Matcher,
244}
245
246#[derive(Debug, Clone)]
252pub struct Scope {
253 pub id: String,
255 pub start: String,
257 pub end: Option<String>,
259 pub rules: Vec<Rule>,
261 pub(crate) start_re: regex::Regex,
263 pub(crate) end_re: Option<regex::Regex>,
265}
266
267impl Config {
268 #[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 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 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 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 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 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 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
659fn 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 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 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 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
948fn 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
1025fn 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
1035fn 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; } else if trimmed.starts_with("- ") || trimmed == "-" {
1072 items.push(no);
1073 }
1074 }
1075 items
1076}
1077
1078fn 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 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}