Skip to main content

alint_rules/
ordered_block.rs

1//! `ordered_block` — the lines between a `start` / `end` marker
2//! pair must stay sorted (optionally unique) under a configurable
3//! comparator. The generic form of the per-project `keep-sorted`
4//! / `keep_sorted` scripts (protobuf `failure_lists` is the
5//! highest-stakes source). Per-file rule (the `PerFileRule` fast
6//! path), not cross-file. Design + open-question resolutions:
7//! `docs/design/v0.10/ordered_block.md`.
8//!
9//! ```yaml
10//! - id: keep-sorted
11//!   kind: ordered_block
12//!   paths: ["**/.gitignore", "CODEOWNERS"]
13//!   start: "# keep-sorted start"   # matched on the trimmed line
14//!   end: "# keep-sorted end"
15//!   comparator: lexical            # lexical (default) | lexical-ci | numeric
16//!   unique: false                  # also forbid duplicate entries
17//!   level: warning
18//! ```
19
20use std::cmp::Ordering;
21use std::path::Path;
22
23use alint_core::{
24    Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation, eval_per_file,
25};
26use serde::Deserialize;
27
28#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
29#[serde(rename_all = "kebab-case")]
30enum Comparator {
31    /// Rust `str` `Ord` — byte-wise over the UTF-8.
32    #[default]
33    Lexical,
34    /// ASCII-case-insensitive lexical.
35    LexicalCi,
36    /// Leading-integer order; entries without a leading integer
37    /// fall back to `lexical` so a mixed block degrades
38    /// predictably rather than panicking.
39    Numeric,
40}
41
42impl Comparator {
43    fn order(self, a: &str, b: &str) -> Ordering {
44        match self {
45            Self::Lexical => a.cmp(b),
46            Self::LexicalCi => a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()),
47            Self::Numeric => match (leading_int(a), leading_int(b)) {
48                (Some(x), Some(y)) => x.cmp(&y).then_with(|| a.cmp(b)),
49                _ => a.cmp(b),
50            },
51        }
52    }
53}
54
55/// The leading (optionally negative) integer of `s`, or `None`
56/// when it doesn't start with one.
57fn leading_int(s: &str) -> Option<i64> {
58    let s = s.trim_start();
59    let b = s.as_bytes();
60    let neg = b.first() == Some(&b'-');
61    let digits_start = usize::from(neg);
62    let digits_end = b[digits_start..]
63        .iter()
64        .position(|c| !c.is_ascii_digit())
65        .map_or(b.len(), |p| digits_start + p);
66    if digits_end == digits_start {
67        return None;
68    }
69    s[..digits_end].parse::<i64>().ok()
70}
71
72#[derive(Debug, Deserialize)]
73#[serde(deny_unknown_fields)]
74struct Options {
75    start: String,
76    end: String,
77    #[serde(default)]
78    comparator: Comparator,
79    #[serde(default)]
80    unique: bool,
81}
82
83#[derive(Debug)]
84pub struct OrderedBlockRule {
85    id: String,
86    level: Level,
87    policy_url: Option<String>,
88    message: Option<String>,
89    scope: Scope,
90    start: String,
91    end: String,
92    comparator: Comparator,
93    unique: bool,
94}
95
96/// In-flight block state while scanning a file.
97struct Block {
98    start_line: usize,
99    prev: Option<String>,
100    /// One violation per block: once set, further entries are
101    /// skipped until the `end` marker (keeps output actionable).
102    reported: bool,
103}
104
105impl Rule for OrderedBlockRule {
106    alint_core::rule_common_impl!();
107
108    fn path_scope(&self) -> Option<&Scope> {
109        Some(&self.scope)
110    }
111
112    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
113        eval_per_file(self, ctx)
114    }
115
116    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
117        Some(self)
118    }
119}
120
121impl PerFileRule for OrderedBlockRule {
122    fn path_scope(&self) -> &Scope {
123        &self.scope
124    }
125
126    fn evaluate_file(
127        &self,
128        _ctx: &Context<'_>,
129        path: &Path,
130        bytes: &[u8],
131    ) -> Result<Vec<Violation>> {
132        let Ok(text) = std::str::from_utf8(bytes) else {
133            // Non-UTF-8 is degenerate for a line-sorted region.
134            return Ok(Vec::new());
135        };
136        let mut violations = Vec::new();
137        let mut block: Option<Block> = None;
138
139        for (i, raw) in text.lines().enumerate() {
140            let line_no = i + 1;
141            let trimmed = raw.trim();
142
143            let Some(b) = block.as_mut() else {
144                if trimmed == self.start {
145                    block = Some(Block {
146                        start_line: line_no,
147                        prev: None,
148                        reported: false,
149                    });
150                }
151                continue;
152            };
153
154            if trimmed == self.end {
155                block = None;
156                continue;
157            }
158            // Blank lines inside a block are not entries.
159            if trimmed.is_empty() || b.reported {
160                continue;
161            }
162
163            let entry = trimmed.to_string();
164            if let Some(prev) = &b.prev {
165                let ord = self.comparator.order(&entry, prev);
166                if ord == Ordering::Less {
167                    violations.push(self.violation(
168                        path,
169                        line_no,
170                        b.start_line,
171                        &format!("{entry:?} is out of order (it comes after {prev:?})"),
172                    ));
173                    b.reported = true;
174                } else if self.unique && ord == Ordering::Equal {
175                    violations.push(self.violation(
176                        path,
177                        line_no,
178                        b.start_line,
179                        &format!("{entry:?} is a duplicate entry"),
180                    ));
181                    b.reported = true;
182                }
183            }
184            b.prev = Some(entry);
185        }
186
187        if let Some(b) = block {
188            violations.push(self.violation(
189                path,
190                b.start_line,
191                b.start_line,
192                &format!(
193                    "unclosed ordered_block — no {:?} line after the start",
194                    self.end
195                ),
196            ));
197        }
198        Ok(violations)
199    }
200}
201
202impl OrderedBlockRule {
203    fn violation(&self, path: &Path, line: usize, start_line: usize, desc: &str) -> Violation {
204        let msg = self
205            .message
206            .clone()
207            .unwrap_or_else(|| format!("ordered_block (start at line {start_line}): {desc}"));
208        Violation::new(msg)
209            .with_path(std::sync::Arc::<Path>::from(path))
210            .with_location(line, 1)
211    }
212}
213
214pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
215    if spec.paths.is_none() {
216        return Err(Error::rule_config(
217            &spec.id,
218            "ordered_block requires a `paths` field (the files whose marked blocks to check)",
219        ));
220    }
221    let opts: Options = spec
222        .deserialize_options()
223        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
224    if opts.start.trim().is_empty() || opts.end.trim().is_empty() {
225        return Err(Error::rule_config(
226            &spec.id,
227            "ordered_block `start` and `end` marker lines must not be empty",
228        ));
229    }
230    if opts.start.trim() == opts.end.trim() {
231        return Err(Error::rule_config(
232            &spec.id,
233            "ordered_block `start` and `end` markers must differ",
234        ));
235    }
236    Ok(Box::new(OrderedBlockRule {
237        id: spec.id.clone(),
238        level: spec.level,
239        policy_url: spec.policy_url.clone(),
240        message: spec.message.clone(),
241        scope: Scope::from_spec(spec)?,
242        start: opts.start.trim().to_string(),
243        end: opts.end.trim().to_string(),
244        comparator: opts.comparator,
245        unique: opts.unique,
246    }))
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn rule(comparator: Comparator, unique: bool) -> OrderedBlockRule {
254        OrderedBlockRule {
255            id: "t".into(),
256            level: Level::Warning,
257            policy_url: None,
258            message: None,
259            scope: Scope::from_patterns(&["**/*".to_string()]).unwrap(),
260            start: "# keep-sorted start".into(),
261            end: "# keep-sorted end".into(),
262            comparator,
263            unique,
264        }
265    }
266
267    fn eval(r: &OrderedBlockRule, text: &str) -> Vec<Violation> {
268        let ctx = Context {
269            root: Path::new("/"),
270            index: &alint_core::FileIndex::from_entries(Vec::new()),
271            registry: None,
272            facts: None,
273            vars: None,
274            git_tracked: None,
275            git_blame: None,
276        };
277        r.evaluate_file(&ctx, Path::new("f.txt"), text.as_bytes())
278            .unwrap()
279    }
280
281    #[test]
282    fn sorted_block_passes() {
283        let t = "x\n# keep-sorted start\nalpha\nbravo\ncharlie\n# keep-sorted end\ny\n";
284        assert!(eval(&rule(Comparator::Lexical, false), t).is_empty());
285    }
286
287    #[test]
288    fn unsorted_block_fails_once_at_the_offending_line() {
289        let t = "# keep-sorted start\nalpha\ncharlie\nbravo\ndelta\n# keep-sorted end\n";
290        let v = eval(&rule(Comparator::Lexical, false), t);
291        assert_eq!(v.len(), 1, "one violation per block: {v:?}");
292        // `bravo` (line 4) is out of order after `charlie`.
293        assert_eq!(v[0].line, Some(4));
294        assert!(v[0].message.contains("bravo"));
295    }
296
297    #[test]
298    fn no_markers_is_silent() {
299        let t = "just\nsome\nunsorted\nlines\nz\na\n";
300        assert!(eval(&rule(Comparator::Lexical, false), t).is_empty());
301    }
302
303    #[test]
304    fn unique_flags_duplicate_only_when_set() {
305        let t = "# keep-sorted start\nalpha\nalpha\nbravo\n# keep-sorted end\n";
306        // Non-decreasing: a duplicate is fine without `unique`.
307        assert!(eval(&rule(Comparator::Lexical, false), t).is_empty());
308        let v = eval(&rule(Comparator::Lexical, true), t);
309        assert_eq!(v.len(), 1);
310        assert!(v[0].message.contains("duplicate"));
311    }
312
313    #[test]
314    fn lexical_ci_and_numeric_comparators() {
315        // Bravo < alpha lexically (uppercase), but ci-sorted.
316        let ci = "# keep-sorted start\nalpha\nBravo\ncharlie\n# keep-sorted end\n";
317        assert!(eval(&rule(Comparator::LexicalCi, false), ci).is_empty());
318        // Numeric: "9" before "10" (lexical would flip them).
319        let num = "# keep-sorted start\n2\n9\n10\n100\n# keep-sorted end\n";
320        assert!(eval(&rule(Comparator::Numeric, false), num).is_empty());
321        let bad = "# keep-sorted start\n10\n9\n# keep-sorted end\n";
322        assert_eq!(eval(&rule(Comparator::Numeric, false), bad).len(), 1);
323    }
324
325    #[test]
326    fn multiple_blocks_checked_independently() {
327        let t = "# keep-sorted start\na\nb\n# keep-sorted end\nmid\n\
328                 # keep-sorted start\nz\nq\n# keep-sorted end\n";
329        let v = eval(&rule(Comparator::Lexical, false), t);
330        assert_eq!(v.len(), 1, "only the 2nd block (z, q) is unsorted: {v:?}");
331    }
332
333    #[test]
334    fn unclosed_start_is_a_violation() {
335        let t = "before\n# keep-sorted start\nalpha\nbravo\n";
336        let v = eval(&rule(Comparator::Lexical, false), t);
337        assert_eq!(v.len(), 1);
338        assert!(v[0].message.contains("unclosed"));
339        assert_eq!(v[0].line, Some(2));
340    }
341
342    #[test]
343    fn blank_lines_inside_a_block_are_ignored() {
344        let t = "# keep-sorted start\nalpha\n\nbravo\n\ncharlie\n# keep-sorted end\n";
345        assert!(eval(&rule(Comparator::Lexical, false), t).is_empty());
346    }
347}