Skip to main content

normalize_native_rules/
check_examples.rs

1//! Validate example references in documentation
2
3use normalize_output::OutputFormatter;
4use normalize_output::diagnostics::{DiagnosticsReport, Issue, Severity};
5use serde::Serialize;
6use std::path::Path;
7
8static MARKER_START_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
9static REF_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
10
11/// A missing example reference
12#[derive(Debug, Serialize, schemars::JsonSchema)]
13struct MissingExample {
14    doc_file: String,
15    line: usize,
16    reference: String, // path#name
17}
18
19/// Report produced by the missing-example native rule check.
20#[derive(Debug, Serialize, schemars::JsonSchema)]
21pub struct CheckExamplesReport {
22    defined_examples: usize,
23    references_found: usize,
24    missing: Vec<MissingExample>,
25}
26
27impl OutputFormatter for CheckExamplesReport {
28    fn format_text(&self) -> String {
29        let mut lines = Vec::new();
30        lines.push("Example Reference Check".to_string());
31        lines.push(String::new());
32        lines.push(format!("Defined examples: {}", self.defined_examples));
33        lines.push(format!("References found: {}", self.references_found));
34        lines.push(String::new());
35
36        if self.missing.is_empty() {
37            lines.push("All example references are valid.".to_string());
38        } else {
39            lines.push(format!("Missing examples ({}):", self.missing.len()));
40            lines.push(String::new());
41            for m in &self.missing {
42                lines.push(format!(
43                    "  {}:{}: {{{{{}}}}}",
44                    m.doc_file, m.line, m.reference
45                ));
46            }
47        }
48
49        lines.join("\n")
50    }
51}
52
53/// Build a CheckExamplesReport without printing (for service layer).
54pub fn build_check_examples_report(
55    root: &Path,
56    walk_config: &normalize_rules_config::WalkConfig,
57) -> CheckExamplesReport {
58    use std::collections::HashSet;
59
60    // normalize-syntax-allow: rust/unwrap-in-impl - compile-time-known-valid regex
61    let marker_start_re =
62        MARKER_START_RE.get_or_init(|| regex::Regex::new(r"//\s*\[example:\s*([^\]]+)\]").unwrap());
63    // normalize-syntax-allow: rust/unwrap-in-impl - compile-time-known-valid regex
64    let ref_re = REF_RE.get_or_init(|| regex::Regex::new(r"\{\{example:\s*([^}]+)\}\}").unwrap());
65
66    let mut defined_examples: HashSet<String> = HashSet::new();
67
68    for entry in crate::walk::gitignore_walk(root, walk_config)
69        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
70    {
71        let path = entry.path();
72
73        if normalize_languages::support_for_path(path).is_none() {
74            continue;
75        }
76
77        let content = match std::fs::read_to_string(path) {
78            Ok(c) => c,
79            Err(_) => continue,
80        };
81
82        let rel_path = path
83            .strip_prefix(root)
84            .unwrap_or(path)
85            .display()
86            .to_string();
87
88        for cap in marker_start_re.captures_iter(&content) {
89            let name = cap[1].trim();
90            let key = format!("{}#{}", rel_path, name);
91            defined_examples.insert(key);
92        }
93    }
94
95    let mut missing: Vec<MissingExample> = Vec::new();
96    let mut refs_found = 0;
97
98    for entry in crate::walk::gitignore_walk(root, walk_config)
99        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))
100    {
101        let path = entry.path();
102        let content = match std::fs::read_to_string(path) {
103            Ok(c) => c,
104            Err(_) => continue,
105        };
106
107        let rel_path = path
108            .strip_prefix(root)
109            .unwrap_or(path)
110            .display()
111            .to_string();
112
113        let mut in_code_block = false;
114        for (line_num, line) in content.lines().enumerate() {
115            if line.trim().starts_with("```") {
116                in_code_block = !in_code_block;
117                continue;
118            }
119            if in_code_block {
120                continue;
121            }
122
123            for cap in ref_re.captures_iter(line) {
124                // normalize-syntax-allow: rust/unwrap-in-impl - cap.get(0) is always Some (the full match)
125                let match_start = cap.get(0).unwrap().start();
126                let match_end = cap.get(0).unwrap().end();
127                // SAFETY: regex byte offsets are always at UTF-8 char boundaries for valid UTF-8 input
128                let before = &line[..match_start];
129                let after = &line[match_end..];
130
131                if before.chars().filter(|&c| c == '`').count() % 2 == 1 && after.contains('`') {
132                    continue;
133                }
134
135                refs_found += 1;
136                let reference = cap[1].trim();
137
138                if !defined_examples.contains(reference) {
139                    missing.push(MissingExample {
140                        doc_file: rel_path.clone(),
141                        line: line_num + 1,
142                        reference: reference.to_string(),
143                    });
144                }
145            }
146        }
147    }
148
149    CheckExamplesReport {
150        defined_examples: defined_examples.len(),
151        references_found: refs_found,
152        missing,
153    }
154}
155
156impl From<CheckExamplesReport> for DiagnosticsReport {
157    fn from(report: CheckExamplesReport) -> Self {
158        DiagnosticsReport {
159            issues: report
160                .missing
161                .into_iter()
162                .map(|m| Issue {
163                    file: m.doc_file,
164                    line: Some(m.line),
165                    column: None,
166                    end_line: None,
167                    end_column: None,
168                    rule_id: "missing-example".into(),
169                    message: format!("example `{}` not found in source", m.reference),
170                    severity: Severity::Warning,
171                    source: "check-examples".into(),
172                    related: vec![],
173                    suggestion: None,
174                })
175                .collect(),
176            files_checked: 0, // not tracked separately in CheckExamplesReport
177            sources_run: vec!["check-examples".into()],
178            tool_errors: vec![],
179            daemon_cached: false,
180        }
181    }
182}