ailint_core/rules/semantic/
duplicate_rules.rs1use 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#[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}