Skip to main content

spec_drift/analyzers/
docs.rs

1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, RuleId, Severity, SpecClaim};
4use crate::parsers::MarkdownParser;
5use regex::Regex;
6
7/// DocsAnalyzer — enforces the `symbol_absence` rule.
8///
9/// Strategy: every Markdown inline-code span is checked against an identifier
10/// regex. If it looks like a Rust symbol path (`Type::method`, `fn_name`,
11/// `module::Ty`), the leaf identifier is looked up against the `CodeFact`
12/// index built from the Rust AST. Missing leaves become divergences.
13pub struct DocsAnalyzer {
14    ident_re: Regex,
15}
16
17impl Default for DocsAnalyzer {
18    fn default() -> Self {
19        Self {
20            ident_re: Regex::new(
21                r"^([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)(\(\))?$",
22            )
23            .expect("static regex"),
24        }
25    }
26}
27
28impl DriftAnalyzer for DocsAnalyzer {
29    fn id(&self) -> &'static str {
30        "docs"
31    }
32
33    fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
34        let mut out = Vec::new();
35
36        for md in &ctx.markdown_files {
37            let claims = match MarkdownParser::parse(md) {
38                Ok(c) => c,
39                Err(_) => continue,
40            };
41
42            for claim in claims {
43                let Some(symbol) = self.extract_symbol(&claim) else {
44                    continue;
45                };
46                let leaf = symbol.rsplit("::").next().unwrap_or(&symbol);
47
48                // Skip stdlib / language keywords that look like identifiers.
49                if is_language_intrinsic(leaf) {
50                    continue;
51                }
52
53                if ctx.facts_named(leaf).next().is_none() {
54                    out.push(Divergence {
55                        rule: RuleId::SymbolAbsence,
56                        severity: Severity::Critical,
57                        location: claim.location.clone(),
58                        stated: format!("`{symbol}` exists in the codebase"),
59                        reality: format!(
60                            "no symbol named `{leaf}` found in the parsed Rust sources"
61                        ),
62                        risk: "New developers and AI agents will reach for a non-existent API."
63                            .to_string(),
64                        attribution: None,
65
66                    });
67                }
68            }
69        }
70
71        out
72    }
73}
74
75impl DocsAnalyzer {
76    /// Extract a Rust-shaped symbol reference from a Markdown inline code span.
77    ///
78    /// Returns `Some(symbol)` only when the span is clearly a Rust symbol:
79    /// - ends in `()` (a function/method call), OR
80    /// - contains a `::` path (qualified path), OR
81    /// - starts with an uppercase letter (type name).
82    ///
83    /// Bare lowercase words like `ignore`, `syn`, `HEAD` are ignored to avoid
84    /// flagging ordinary English prose, crate names, filenames, and tool names
85    /// that happen to be code-fenced.
86    fn extract_symbol(&self, claim: &SpecClaim) -> Option<String> {
87        let text = claim.text.trim();
88        let caps = self.ident_re.captures(text)?;
89        let symbol = caps.get(1)?.as_str().to_string();
90        let has_call_parens = caps.get(2).is_some();
91        let has_path_sep = symbol.contains("::");
92        // A type-looking name is CamelCase: starts uppercase AND contains at
93        // least one lowercase letter. This rejects all-caps words like
94        // `README`, `HEAD`, `AGENTS` that are common in English prose but
95        // aren't Rust type names.
96        let looks_like_type = symbol.starts_with(|c: char| c.is_ascii_uppercase())
97            && symbol.chars().any(|c| c.is_ascii_lowercase());
98
99        if has_call_parens || has_path_sep || looks_like_type {
100            Some(symbol)
101        } else {
102            None
103        }
104    }
105}
106
107/// A small stop-list of identifiers that Rust docs commonly reference but that
108/// `spec-drift` cannot resolve against a project-local AST (primitive types,
109/// `std` items, common tool names and filenames). This is a heuristic — users
110/// can extend or disable it via config once the rule engine lands.
111fn is_language_intrinsic(name: &str) -> bool {
112    matches!(
113        name,
114        // Primitives and keywords
115        "bool"
116            | "char"
117            | "str"
118            | "String"
119            | "i8"
120            | "i16"
121            | "i32"
122            | "i64"
123            | "i128"
124            | "isize"
125            | "u8"
126            | "u16"
127            | "u32"
128            | "u64"
129            | "u128"
130            | "usize"
131            | "f32"
132            | "f64"
133            | "Option"
134            | "Some"
135            | "None"
136            | "Result"
137            | "Ok"
138            | "Err"
139            | "Vec"
140            | "Box"
141            | "Arc"
142            | "Rc"
143            | "RefCell"
144            | "Cell"
145            | "Mutex"
146            | "RwLock"
147            | "HashMap"
148            | "HashSet"
149            | "BTreeMap"
150            | "BTreeSet"
151            | "fn"
152            | "impl"
153            | "trait"
154            | "struct"
155            | "enum"
156            | "pub"
157            | "mod"
158            | "use"
159            | "self"
160            | "Self"
161            | "super"
162            | "crate"
163            // Common tool / config filenames that appear CamelCased in prose
164            | "Makefile"
165            | "GNUmakefile"
166            | "Dockerfile"
167            | "Cargo"
168            | "Cargo.toml"
169            | "Cargo.lock"
170    )
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::domain::{CodeFact, FactKind, Location};
177
178    fn fact(name: &str) -> CodeFact {
179        CodeFact {
180            location: Location::new("src/lib.rs", 1),
181            kind: FactKind::Function,
182            name: name.to_string(),
183        }
184    }
185
186    #[test]
187    fn flags_missing_symbol_referenced_in_markdown() {
188        let tmp = tempfile::tempdir().unwrap();
189        let md = tmp.path().join("README.md");
190        std::fs::write(&md, "Use `connect_to_db()` to start.\n").unwrap();
191
192        let mut ctx = ProjectContext::new(tmp.path());
193        ctx.markdown_files.push(md.clone());
194        ctx.code_facts.push(fact("init_connection"));
195
196        let divergences = DocsAnalyzer::default().analyze(&ctx);
197        assert_eq!(divergences.len(), 1);
198        assert_eq!(divergences[0].rule, RuleId::SymbolAbsence);
199        assert_eq!(divergences[0].location.line, 1);
200    }
201
202    #[test]
203    fn does_not_flag_symbol_that_exists() {
204        let tmp = tempfile::tempdir().unwrap();
205        let md = tmp.path().join("README.md");
206        std::fs::write(&md, "Use `connect_to_db()` to start.\n").unwrap();
207
208        let mut ctx = ProjectContext::new(tmp.path());
209        ctx.markdown_files.push(md);
210        ctx.code_facts.push(fact("connect_to_db"));
211
212        assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
213    }
214
215    #[test]
216    fn ignores_language_intrinsics() {
217        let tmp = tempfile::tempdir().unwrap();
218        let md = tmp.path().join("README.md");
219        std::fs::write(&md, "Returns `Option<String>` on failure.\n").unwrap();
220
221        let mut ctx = ProjectContext::new(tmp.path());
222        ctx.markdown_files.push(md);
223
224        // No code facts — but Option/String are intrinsics so must not flag.
225        assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
226    }
227
228    #[test]
229    fn ignores_bare_lowercase_words() {
230        // Words like `ignore`, `syn`, `git2` in backticks are usually crate
231        // names or prose, not API references. They must not generate drift.
232        let tmp = tempfile::tempdir().unwrap();
233        let md = tmp.path().join("README.md");
234        std::fs::write(&md, "Uses the `ignore` crate and `syn` for parsing.\n").unwrap();
235
236        let mut ctx = ProjectContext::new(tmp.path());
237        ctx.markdown_files.push(md);
238        assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
239    }
240
241    #[test]
242    fn ignores_all_caps_words() {
243        // `README`, `HEAD`, `AGENTS` look uppercase but aren't Rust type names.
244        let tmp = tempfile::tempdir().unwrap();
245        let md = tmp.path().join("README.md");
246        std::fs::write(&md, "See `README` and `HEAD` and `AGENTS`.\n").unwrap();
247
248        let mut ctx = ProjectContext::new(tmp.path());
249        ctx.markdown_files.push(md);
250        assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
251    }
252
253    #[test]
254    fn resolves_method_path_by_leaf() {
255        let tmp = tempfile::tempdir().unwrap();
256        let md = tmp.path().join("README.md");
257        std::fs::write(&md, "Call `Client::new()` to build.\n").unwrap();
258
259        let mut ctx = ProjectContext::new(tmp.path());
260        ctx.markdown_files.push(md);
261        ctx.code_facts.push(fact("new"));
262
263        assert!(DocsAnalyzer::default().analyze(&ctx).is_empty());
264    }
265}