1use 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 #[default]
33 Lexical,
34 LexicalCi,
36 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
55fn 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
96struct Block {
98 start_line: usize,
99 prev: Option<String>,
100 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 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 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 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 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 let ci = "# keep-sorted start\nalpha\nBravo\ncharlie\n# keep-sorted end\n";
317 assert!(eval(&rule(Comparator::LexicalCi, false), ci).is_empty());
318 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}