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 run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation> {
40 let opts: Options = ctx
41 .options
42 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
43 .unwrap_or_default();
44 let prefixes: Vec<String> = opts
45 .negation_prefixes
46 .unwrap_or_else(|| {
47 dictionary_lines(NEGATION_PREFIXES)
48 .into_iter()
49 .map(String::from)
50 .collect()
51 })
52 .into_iter()
53 .map(|p| normalize(&p))
54 .filter(|p| !p.is_empty())
55 .collect();
56 let Some(matcher) = prefix_matcher(&prefixes) else {
57 return Vec::new();
58 };
59 let min_core_words = opts.min_core_words.unwrap_or(DEFAULT_MIN_CORE_WORDS);
60
61 #[derive(Debug, Clone)]
63 struct Entry {
64 doc_idx: usize,
65 item_idx: usize,
66 negated: bool,
67 }
68
69 let mut groups: HashMap<String, Vec<Entry>> = HashMap::new();
70 for (doc_idx, doc) in docs.iter().enumerate() {
71 let md = match &doc.content {
72 DocumentContent::Markdown(m) => m,
73 _ => continue,
74 };
75 for (item_idx, item) in md.list_items.iter().enumerate() {
76 let base = normalize(&item.text);
77 if base.is_empty() {
78 continue;
79 }
80 let (core, negated) = strip_negation(&base, &matcher);
81 if core.split_whitespace().count() < min_core_words {
82 continue;
83 }
84 groups.entry(core.to_string()).or_default().push(Entry {
85 doc_idx,
86 item_idx,
87 negated,
88 });
89 }
90 }
91
92 let mut out = Vec::new();
93 for entries in groups.values() {
94 let mut has_pos = None::<&Entry>;
96 let mut has_neg = None::<&Entry>;
97 for e in entries {
98 if e.negated {
99 if has_neg.is_none() {
100 has_neg = Some(e);
101 }
102 } else if has_pos.is_none() {
103 has_pos = Some(e);
104 }
105 }
106 let (Some(pos), Some(neg)) = (has_pos, has_neg) else {
107 continue;
108 };
109 if docs[pos.doc_idx].path == docs[neg.doc_idx].path {
110 continue;
111 }
112 for e in entries {
113 let counter = if e.negated { pos } else { neg };
114 if docs[e.doc_idx].path == docs[counter.doc_idx].path {
115 continue;
116 }
117 let doc = &docs[e.doc_idx];
118 let other = &docs[counter.doc_idx];
119 let md = match &doc.content {
120 DocumentContent::Markdown(m) => m,
121 _ => continue,
122 };
123 let other_md = match &other.content {
124 DocumentContent::Markdown(m) => m,
125 _ => continue,
126 };
127 let item = &md.list_items[e.item_idx];
128 let other_item = &other_md.list_items[counter.item_idx];
129 let other_text = truncate_chars(other_item.text.trim(), 80);
130 let other_name = other
131 .path
132 .file_name()
133 .and_then(|s| s.to_str())
134 .unwrap_or("<unknown>");
135 let mut v = Violation::new(
136 AIL300,
137 self.default_severity(),
138 doc.path.clone(),
139 format!("conflicts with '{}' in {}", other_text, other_name),
140 )
141 .at(item.line, 1);
142 v.snippet = Some(truncate_chars(&item.text, 120));
143 v.fix_hint = Some(
144 "reconcile the two files so they don't give contradictory instructions".into(),
145 );
146 out.push(v);
147 }
148 }
149 out
150 }
151}
152
153fn normalize(s: &str) -> String {
156 s.unicode_words()
157 .map(str::to_lowercase)
158 .collect::<Vec<_>>()
159 .join(" ")
160}
161
162fn prefix_matcher(prefixes: &[String]) -> Option<AhoCorasick> {
164 AhoCorasick::builder()
165 .match_kind(MatchKind::LeftmostLongest)
166 .start_kind(StartKind::Anchored)
167 .build(prefixes)
168 .ok()
169}
170
171fn strip_negation<'a>(s: &'a str, matcher: &AhoCorasick) -> (&'a str, bool) {
174 if let Some(m) = matcher.find(Input::new(s).anchored(Anchored::Yes)) {
175 let rest = &s[m.end()..];
176 if rest.is_empty() {
177 return ("", true);
178 }
179 if rest.starts_with(' ') {
180 return (rest.trim_start(), true);
181 }
182 }
183 (s, false)
184}
185
186fn truncate_chars(s: &str, max: usize) -> String {
187 if s.chars().count() <= max {
188 return s.to_string();
189 }
190 let mut out: String = s.chars().take(max).collect();
191 out.push('…');
192 out
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn default_matcher() -> AhoCorasick {
200 let prefixes: Vec<String> = dictionary_lines(NEGATION_PREFIXES)
201 .into_iter()
202 .map(normalize)
203 .collect();
204 prefix_matcher(&prefixes).expect("default prefixes must compile")
205 }
206
207 #[test]
208 fn normalize_strips_punct_and_lowercases() {
209 assert_eq!(normalize("Use TABS, please!"), "use tabs please");
210 }
211
212 #[test]
213 fn normalize_keeps_inword_apostrophe() {
214 assert_eq!(normalize("Don't panic"), "don't panic");
215 }
216
217 #[test]
218 fn strip_negation_detects_dont() {
219 let (core, neg) = strip_negation("don't use tabs for indentation", &default_matcher());
220 assert!(neg);
221 assert_eq!(core, "use tabs for indentation");
222 }
223
224 #[test]
225 fn strip_negation_leaves_positive_alone() {
226 let (core, neg) = strip_negation("use tabs for indentation", &default_matcher());
227 assert!(!neg);
228 assert_eq!(core, "use tabs for indentation");
229 }
230
231 #[test]
232 fn strip_negation_requires_word_boundary() {
233 let (core, neg) = strip_negation("notice the pattern here", &default_matcher());
235 assert!(!neg);
236 assert_eq!(core, "notice the pattern here");
237 }
238}