Skip to main content

ailint_core/rules/semantic/
missing_examples.rs

1//! AIL101 `no-missing-examples` — required sections lacking concrete examples.
2//!
3//! See: `docs/rules/semantic/AIL101.md`
4
5use 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/// AIL101 no-missing-examples: behavioral rules should show concrete examples.
22#[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}