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 run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
35        let md = match &doc.content {
36            DocumentContent::Markdown(m) => m,
37            _ => return Vec::new(),
38        };
39
40        let opts: Options = ctx
41            .options
42            .and_then(|v| serde_yaml::from_value(v.clone()).ok())
43            .unwrap_or_default();
44        let required_in: Vec<String> = opts
45            .required_in
46            .unwrap_or_else(|| {
47                DEFAULT_REQUIRED_IN
48                    .iter()
49                    .map(|s| s.to_lowercase())
50                    .collect()
51            })
52            .into_iter()
53            .map(|s| s.to_lowercase())
54            .collect();
55        let min_words = opts.min_words.unwrap_or(DEFAULT_MIN_WORDS);
56
57        let mut out = Vec::new();
58        for section in &md.sections {
59            let heading_idx = match section.heading_index {
60                Some(i) => i,
61                None => continue,
62            };
63            let heading = match md.headings.get(heading_idx) {
64                Some(h) => h,
65                None => continue,
66            };
67            let heading_lc = heading.text.to_lowercase();
68            if !required_in.iter().any(|r| heading_lc.contains(r)) {
69                continue;
70            }
71            let body = doc.raw.get(section.byte_range.clone()).unwrap_or("");
72            if body.split_whitespace().count() < min_words {
73                continue;
74            }
75            let has_code = md.code_blocks.iter().any(|cb| {
76                cb.byte_range.start >= section.byte_range.start
77                    && cb.byte_range.end <= section.byte_range.end
78            });
79            let body_lc = body.to_lowercase();
80            let has_eg = body_lc.contains("e.g.") || body_lc.contains("example:");
81            if has_code || has_eg {
82                continue;
83            }
84            let mut v = Violation::new(
85                AIL101,
86                self.default_severity(),
87                doc.path.clone(),
88                format!("section '{}' lacks concrete examples", heading.text),
89            )
90            .at(heading.line, 1);
91            v.fix_hint =
92                Some("add a fenced code block or an `e.g.` clause with a concrete example".into());
93            out.push(v);
94        }
95        out
96    }
97}