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