ailint_core/rules/semantic/
missing_examples.rs1use serde::Deserialize;
6
7use crate::parser::{DocumentContent, ParsedDocument};
8use crate::rules::semantic::AIL101;
9use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
10
11const DEFAULT_REQUIRED_IN: &[&str] = &["Examples", "Usage", "Example"];
12const DEFAULT_MIN_WORDS: usize = 20;
13
14#[derive(Debug, Default, Deserialize)]
15#[serde(default, deny_unknown_fields)]
16struct Options {
17 required_in: Option<Vec<String>>,
18 min_words: Option<usize>,
19}
20
21#[derive(Debug, Default)]
23pub struct NoMissingExamplesRule;
24
25impl Rule for NoMissingExamplesRule {
26 fn id(&self) -> RuleId {
27 AIL101
28 }
29
30 fn default_severity(&self) -> Severity {
31 Severity::Info
32 }
33
34 fn description(&self) -> &'static str {
35 "Behavioral section is missing a concrete example."
36 }
37
38 fn fix_hint(&self) -> &'static str {
39 "Add a fenced code block or an `e.g.` clause with a concrete example."
40 }
41
42 fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
43 let md = match &doc.content {
44 DocumentContent::Markdown(m) => m,
45 _ => return Vec::new(),
46 };
47
48 let opts: Options = ctx
49 .options
50 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
51 .unwrap_or_default();
52 let required_in: Vec<String> = opts
53 .required_in
54 .unwrap_or_else(|| {
55 DEFAULT_REQUIRED_IN
56 .iter()
57 .map(|s| s.to_lowercase())
58 .collect()
59 })
60 .into_iter()
61 .map(|s| s.to_lowercase())
62 .collect();
63 let min_words = opts.min_words.unwrap_or(DEFAULT_MIN_WORDS);
64
65 let mut out = Vec::new();
66 for section in &md.sections {
67 let heading_idx = match section.heading_index {
68 Some(i) => i,
69 None => continue,
70 };
71 let heading = match md.headings.get(heading_idx) {
72 Some(h) => h,
73 None => continue,
74 };
75 let heading_lc = heading.text.to_lowercase();
76 if !required_in.iter().any(|r| heading_lc.contains(r)) {
77 continue;
78 }
79 let body = doc.raw.get(section.byte_range.clone()).unwrap_or("");
80 if body.split_whitespace().count() < min_words {
81 continue;
82 }
83 let has_code = md.code_blocks.iter().any(|cb| {
84 cb.byte_range.start >= section.byte_range.start
85 && cb.byte_range.end <= section.byte_range.end
86 });
87 let body_lc = body.to_lowercase();
88 let has_eg = body_lc.contains("e.g.") || body_lc.contains("example:");
89 if has_code || has_eg {
90 continue;
91 }
92 let v = Violation::new(
93 AIL101,
94 self.default_severity(),
95 doc.path.clone(),
96 "section lacks concrete examples",
97 )
98 .at(heading.line, 1)
99 .with_detail(heading.text.clone());
100 out.push(v);
101 }
102 out
103 }
104}