Skip to main content

ailint_core/rules/semantic/
duplicate_rules.rs

1//! AIL103 `no-duplicate-rules` — list items that normalize to identical text.
2//!
3//! See: `docs/rules/semantic/AIL103.md`
4
5use std::collections::HashMap;
6
7use serde::Deserialize;
8
9use crate::parser::{DocumentContent, ParsedDocument};
10use crate::rules::semantic::AIL103;
11use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
12
13#[derive(Debug, Default, Deserialize)]
14#[serde(default, deny_unknown_fields)]
15struct Options {
16    normalize: Option<bool>,
17}
18
19/// AIL103 no-duplicate-rules: flags a rule stated more than once in a document.
20#[derive(Debug, Default)]
21pub struct NoDuplicateRulesRule;
22
23impl Rule for NoDuplicateRulesRule {
24    fn id(&self) -> RuleId {
25        AIL103
26    }
27
28    fn default_severity(&self) -> Severity {
29        Severity::Info
30    }
31
32    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
33        let md = match &doc.content {
34            DocumentContent::Markdown(m) => m,
35            _ => return Vec::new(),
36        };
37        let opts: Options = ctx
38            .options
39            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
40            .unwrap_or_default();
41        let _normalize = opts.normalize.unwrap_or(true);
42
43        let mut first_seen: HashMap<String, usize> = HashMap::new();
44        let mut out = Vec::new();
45        for item in &md.list_items {
46            let norm = normalize(&item.text);
47            if norm.chars().count() < 5 {
48                continue;
49            }
50            match first_seen.get(&norm) {
51                Some(&first_line) => {
52                    let snippet: String = item.text.chars().take(120).collect();
53                    let mut v = Violation::new(
54                        AIL103,
55                        self.default_severity(),
56                        doc.path.clone(),
57                        format!("duplicate of rule at line {}", first_line),
58                    )
59                    .at(item.line, 1);
60                    v.snippet = Some(snippet);
61                    out.push(v);
62                }
63                None => {
64                    first_seen.insert(norm, item.line);
65                }
66            }
67        }
68        out
69    }
70}
71
72fn normalize(s: &str) -> String {
73    let lower = s.to_lowercase();
74    let mut buf = String::with_capacity(lower.len());
75    let mut prev_space = true;
76    for ch in lower.chars() {
77        if ch.is_alphanumeric() {
78            buf.push(ch);
79            prev_space = false;
80        } else if (ch.is_whitespace() || ch.is_ascii_punctuation()) && !prev_space {
81            buf.push(' ');
82            prev_space = true;
83        }
84    }
85    buf.trim().to_string()
86}