Skip to main content

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;