forbidden_strings/rules_list.rs
1//! The rules-list grammar: how `--rules`, `FORBIDDEN_STRINGS_RULES`, and `compile-rules --rules` name several rules
2//! files in one value.
3//!
4//! The grammar is one function of the value, with no escape syntax:
5//!
6//! ```text
7//! list := entry ( "," entry )*
8//! entry := path [ "?" ]
9//! path := one or more characters, none of them ","
10//! ```
11//!
12//! - The value is split on every `,`, and nothing is trimmed.
13//! - Exactly one trailing `?` marks an entry that may be absent;
14//! a path that itself ends in `?` is therefore nameable only as an optional entry (`name??`).
15//! - An empty value, an empty entry (`a,,b`, a leading or trailing comma, a bare `?`), and a path named twice are
16//! errors, never silently dropped.
17//! - A value with no `,` and no trailing `?` is one required entry equal to the whole value, which is the meaning
18//! it had before lists existed.
19//! A path holding a `,`, or a required path ending in `?`, cannot be named; that tradeoff was accepted
20//! when the list syntax was chosen.
21
22use std::collections::HashMap;
23use std::path::PathBuf;
24
25/// The separator between entries.
26const SEPARATOR: char = ',';
27/// The suffix marking an entry that may be absent.
28const OPTIONAL_MARKER: char = '?';
29
30/// One rules file named by a list.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct RulesEntry {
33 /// The file to read, exactly as written (relative paths resolve against the process working directory).
34 pub path: PathBuf,
35 /// Whether the entry may be absent;
36 /// an absent optional entry is skipped and logged, an absent required entry is an error.
37 pub optional: bool,
38}
39
40/// Why a rules-list value was refused.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum RulesListError {
43 /// The whole value is empty.
44 Empty,
45 /// An entry names no path: an empty entry or a bare `?`.
46 EmptyEntry {
47 /// 1-based position of the entry in the list.
48 position: usize,
49 },
50 /// The same path is named by two entries, whether or not either is optional.
51 DuplicatePath {
52 /// 1-based position of the first entry naming the path.
53 first: usize,
54 /// 1-based position of the repeated entry.
55 position: usize,
56 },
57}
58
59/// Renders the refusal as one line that quotes no path text.
60impl std::fmt::Display for RulesListError {
61 /// Writes the refusal with the entry position, so a long list stays diagnosable.
62 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 Self::Empty => return write!(formatter, "the rules list is empty; name at least one rules file"),
65 Self::EmptyEntry { position } => {
66 return write!(
67 formatter,
68 "rules list entry {position} names no file (an empty entry between commas, or a bare '?')",
69 );
70 }
71 Self::DuplicatePath { first, position } => {
72 return write!(
73 formatter,
74 "rules list entry {position} names the same file as entry {first}",
75 );
76 }
77 }
78 }
79}
80
81/// Lets the refusal travel through `anyhow` and `?`.
82impl std::error::Error for RulesListError {}
83
84/// Parses a rules-list value into its ordered entries.
85///
86/// A value without `,` or a trailing `?` yields one required entry holding the whole value.
87///
88/// # Errors
89///
90/// [`RulesListError::Empty`] for an empty value, [`RulesListError::EmptyEntry`] for an entry without a path, and
91/// [`RulesListError::DuplicatePath`] when two entries name the same path.
92pub fn parse_rules_list(value: &str) -> Result<Vec<RulesEntry>, RulesListError> {
93 if value.is_empty() {
94 return Err(RulesListError::Empty);
95 }
96 let mut entries: Vec<RulesEntry> = Vec::new();
97 // Maps each named path to its first position, so duplicate detection stays linear in the value's length.
98 let mut first_positions: HashMap<&str, usize> = HashMap::new();
99 for (index, raw) in value.split(SEPARATOR).enumerate() {
100 let position: usize = index + 1;
101 let (name, optional): (&str, bool) = match raw.strip_suffix(OPTIONAL_MARKER) {
102 Some(without_marker) => (without_marker, true),
103 None => (raw, false),
104 };
105 if name.is_empty() {
106 return Err(RulesListError::EmptyEntry { position });
107 }
108 if let Some(&first) = first_positions.get(name) {
109 return Err(RulesListError::DuplicatePath { first, position });
110 }
111 first_positions.insert(name, position);
112 entries.push(RulesEntry { path: PathBuf::from(name), optional });
113 }
114 return Ok(entries);
115}
116
117/// Registers the grammar and adversarial-boundary tests (sidecar, lint-exempt).
118#[cfg(test)]
119#[path = "rules_list_tests.rs"]
120mod tests;