ailint_core/rules/consistency/
conflicting_rules.rs1use std::collections::HashMap;
6
7use aho_corasick::{AhoCorasick, Anchored, Input, MatchKind, StartKind};
8use serde::Deserialize;
9use unicode_segmentation::UnicodeSegmentation;
10
11use crate::parser::{DocumentContent, ParsedDocument};
12use crate::rules::consistency::AIL300;
13use crate::rules::{dictionary_lines, BatchRule, RuleContext, RuleId, Severity, Violation};
14
15const NEGATION_PREFIXES: &str = include_str!("negation_prefixes.txt");
16
17const DEFAULT_MIN_CORE_WORDS: usize = 3;
18
19#[derive(Debug, Default, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21struct Options {
22 negation_prefixes: Option<Vec<String>>,
23 min_core_words: Option<usize>,
24}
25
26#[derive(Debug, Default)]
28pub struct NoConflictingRulesRule;
29
30impl BatchRule for NoConflictingRulesRule {
31 fn id(&self) -> RuleId {
32 AIL300
33 }
34
35 fn default_severity(&self) -> Severity {
36 Severity::Warning
37 }
38
39 fn description(&self) -> &'static str {
40 "Rule contradicts another rule elsewhere in the corpus."
41 }
42
43 fn fix_hint(&self) -> &'static str {
44 "Reconcile the two rules, or delete one."
45 }
46
47 fn run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation> {
48 let opts: Options = ctx
49 .options
50 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
51 .unwrap_or_default();
52 let prefixes: Vec<String> = opts
53 .negation_prefixes
54 .unwrap_or_else(|| {
55 dictionary_lines(NEGATION_PREFIXES)
56 .into_iter()
57 .map(String::from)
58 .collect()
59 })
60 .into_iter()
61 .map(|p| normalize(&p))
62 .filter(|p| !p.is_empty())
63 .collect();
64 let Some(matcher) = prefix_matcher(&prefixes) else {
65 return Vec::new();
66 };
67 let min_core_words = opts.min_core_words.unwrap_or(DEFAULT_MIN_CORE_WORDS);
68
69 #[derive(Debug, Clone)]
71 struct Entry {
72 doc_idx: usize,
73 item_idx: usize,
74 negated: bool,
75 }
76
77 let mut groups: HashMap<String, Vec<Entry>> = HashMap::new();
78 for (doc_idx, doc) in docs.iter().enumerate() {
79 let md = match &doc.content {
80 DocumentContent::Markdown(m) => m,
81 _ => continue,
82 };
83 for (item_idx, item) in md.list_items.iter().enumerate() {
84 let base = normalize(&item.text);
85 if base.is_empty() {
86 continue;
87 }
88 let (core, negated) = strip_negation(&base, &matcher);
89 if core.split_whitespace().count() < min_core_words {
90 continue;
91 }
92 groups.entry(core.to_string()).or_default().push(Entry {
93 doc_idx,
94 item_idx,
95 negated,
96 });
97 }
98 }
99
100 let mut out = Vec::new();
101 for entries in groups.values() {
102 let mut has_pos = None::<&Entry>;
104 let mut has_neg = None::<&Entry>;
105 for e in entries {
106 if e.negated {
107 if has_neg.is_none() {
108 has_neg = Some(e);
109 }
110 } else if has_pos.is_none() {
111 has_pos = Some(e);
112 }
113 }
114 let (Some(pos), Some(neg)) = (has_pos, has_neg) else {
115 continue;
116 };
117 if docs[pos.doc_idx].path == docs[neg.doc_idx].path {
118 continue;
119 }
120 for e in entries {
121 let counter = if e.negated { pos } else { neg };
122 if docs[e.doc_idx].path == docs[counter.doc_idx].path {
123 continue;
124 }
125 let doc = &docs[e.doc_idx];
126 let other = &docs[counter.doc_idx];
127 let md = match &doc.content {
128 DocumentContent::Markdown(m) => m,
129 _ => continue,
130 };
131 let other_md = match &other.content {
132 DocumentContent::Markdown(m) => m,
133 _ => continue,
134 };
135 let item = &md.list_items[e.item_idx];
136 let other_item = &other_md.list_items[counter.item_idx];
137 let other_text = truncate_chars(other_item.text.trim(), 80);
138 let other_name = other
139 .path
140 .file_name()
141 .and_then(|s| s.to_str())
142 .unwrap_or("<unknown>");
143 let mut v = Violation::new(
144 AIL300,
145 self.default_severity(),
146 doc.path.clone(),
147 "conflicting rule",
148 )
149 .at(item.line, 1)
150 .with_detail(format!("conflicts with '{}' in {}", other_text, other_name));
151 v.snippet = Some(truncate_chars(&item.text, 120));
152 out.push(v);
153 }
154 }
155 out
156 }
157}
158
159fn normalize(s: &str) -> String {
162 s.unicode_words()
163 .map(str::to_lowercase)
164 .collect::<Vec<_>>()
165 .join(" ")
166}
167
168fn prefix_matcher(prefixes: &[String]) -> Option<AhoCorasick> {
170 AhoCorasick::builder()
171 .match_kind(MatchKind::LeftmostLongest)
172 .start_kind(StartKind::Anchored)
173 .build(prefixes)
174 .ok()
175}
176
177fn strip_negation<'a>(s: &'a str, matcher: &AhoCorasick) -> (&'a str, bool) {
180 if let Some(m) = matcher.find(Input::new(s).anchored(Anchored::Yes)) {
181 let rest = &s[m.end()..];
182 if rest.is_empty() {
183 return ("", true);
184 }
185 if rest.starts_with(' ') {
186 return (rest.trim_start(), true);
187 }
188 }
189 (s, false)
190}
191
192fn truncate_chars(s: &str, max: usize) -> String {
193 if s.chars().count() <= max {
194 return s.to_string();
195 }
196 let mut out: String = s.chars().take(max).collect();
197 out.push('…');
198 out
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 fn default_matcher() -> AhoCorasick {
206 let prefixes: Vec<String> = dictionary_lines(NEGATION_PREFIXES)
207 .into_iter()
208 .map(normalize)
209 .collect();
210 prefix_matcher(&prefixes).expect("default prefixes must compile")
211 }
212
213 #[test]
214 fn normalize_strips_punct_and_lowercases() {
215 assert_eq!(normalize("Use TABS, please!"), "use tabs please");
216 }
217
218 #[test]
219 fn normalize_keeps_inword_apostrophe() {
220 assert_eq!(normalize("Don't panic"), "don't panic");
221 }
222
223 #[test]
224 fn strip_negation_detects_dont() {
225 let (core, neg) = strip_negation("don't use tabs for indentation", &default_matcher());
226 assert!(neg);
227 assert_eq!(core, "use tabs for indentation");
228 }
229
230 #[test]
231 fn strip_negation_leaves_positive_alone() {
232 let (core, neg) = strip_negation("use tabs for indentation", &default_matcher());
233 assert!(!neg);
234 assert_eq!(core, "use tabs for indentation");
235 }
236
237 #[test]
238 fn strip_negation_requires_word_boundary() {
239 let (core, neg) = strip_negation("notice the pattern here", &default_matcher());
241 assert!(!neg);
242 assert_eq!(core, "notice the pattern here");
243 }
244}