Skip to main content

soothfast_docs/
surface.rs

1//! Public API surface from rustdoc JSON: item paths, span-derived
2//! fingerprints, doc presence, and literal source signatures.
3//!
4//! Fingerprints hash the item's *source span text* (whitespace-normalized),
5//! not rustdoc's structured types — rustdoc item ids shift across builds and
6//! would churn a structure-based hash.
7
8use std::collections::{BTreeMap, HashMap};
9use std::path::Path;
10
11use serde_json::Value;
12
13/// What the surface records about one public item — enough to detect any
14/// change to it (fingerprint), classify the change (signature), and audit
15/// coverage (kind, has_docs).
16#[derive(Debug, Clone)]
17pub struct ItemInfo {
18    /// Rustdoc item kind (`function`, `struct`, `module`, ...).
19    pub kind: String,
20    /// FNV-1a of the whitespace-normalized span source.
21    pub fingerprint: u64,
22    /// Declaration text (span up to the first `{` or `;`), normalized.
23    pub signature: String,
24    /// Whether the item carries a `///` doc comment.
25    pub has_docs: bool,
26}
27
28/// Public items keyed by full path (`crate::module::item`).
29#[derive(Debug, Default)]
30pub struct Surface {
31    pub items: BTreeMap<String, ItemInfo>,
32}
33
34/// One distinct item, grouped by (kind, fingerprint) to collapse the extra
35/// path keys `alias_reexports` adds for the same underlying function —
36/// `representative` is the shortest path (for display), `aliases` holds
37/// every spelling (for lookups, since a `covers=` tag may name any of them).
38/// Un-fingerprinted items (span unreadable) are never merged with others.
39pub struct ItemGroup<'a> {
40    pub representative: &'a String,
41    pub aliases: Vec<&'a String>,
42}
43
44impl Surface {
45    /// Every item in this surface, deduplicated across `pub use` aliases.
46    pub fn grouped(&self) -> Vec<ItemGroup<'_>> {
47        let mut groups: HashMap<(&str, u64), Vec<&String>> = HashMap::new();
48        let mut unfingerprinted = Vec::new();
49        for (path, info) in &self.items {
50            if info.fingerprint == 0 {
51                unfingerprinted.push(path);
52                continue;
53            }
54            groups
55                .entry((info.kind.as_str(), info.fingerprint))
56                .or_default()
57                .push(path);
58        }
59        let mut out: Vec<ItemGroup<'_>> = groups
60            .into_values()
61            .map(|mut aliases| {
62                aliases.sort();
63                let representative = aliases.iter().min_by_key(|p| p.len()).copied().unwrap();
64                ItemGroup {
65                    representative,
66                    aliases,
67                }
68            })
69            .chain(unfingerprinted.into_iter().map(|p| ItemGroup {
70                representative: p,
71                aliases: vec![p],
72            }))
73            .collect();
74        out.sort_by(|a, b| a.representative.cmp(b.representative));
75        out
76    }
77}
78
79/// Item kinds surfaced in reports (impl blocks and imports are noise).
80const KINDS: &[&str] = &[
81    "function",
82    "struct",
83    "enum",
84    "trait",
85    "constant",
86    "type_alias",
87    "module",
88    "static",
89    "union",
90    "macro",
91    "proc_macro",
92];
93
94/// Build a surface from a rustdoc JSON document. `source_root` resolves the
95/// relative filenames in spans (the directory rustdoc ran in).
96pub fn from_rustdoc(doc: &Value, source_root: &Path) -> Surface {
97    let mut surface = Surface::default();
98    let (Some(index), Some(paths)) = (doc["index"].as_object(), doc["paths"].as_object()) else {
99        return surface;
100    };
101    let mut file_cache: HashMap<String, Vec<String>> = HashMap::new();
102
103    for (id, item) in index {
104        if item["visibility"] != "public" {
105            continue;
106        }
107        let Some(inner) = item["inner"].as_object() else {
108            continue;
109        };
110        let Some(kind) = inner.keys().next().map(String::as_str) else {
111            continue;
112        };
113        if !KINDS.contains(&kind) {
114            continue;
115        }
116        // unaddressable (e.g. non-re-exported assoc items) if absent
117        let Some(full) = item_path(paths, id) else {
118            continue;
119        };
120
121        let (fingerprint, signature) =
122            span_fingerprint(&item["span"], source_root, &mut file_cache)
123                .unwrap_or((0, String::new()));
124        let has_docs = item["docs"].as_str().is_some_and(|d| !d.trim().is_empty());
125
126        surface.items.insert(
127            full,
128            ItemInfo {
129                kind: kind.to_string(),
130                fingerprint,
131                signature,
132                has_docs,
133            },
134        );
135    }
136
137    alias_reexports(index, paths, &mut surface);
138    surface
139}
140
141/// Register every `pub use inner::item;` re-export under an additional key:
142/// the *importing* module's path + the local alias name.
143///
144/// rustdoc's `paths` map gives each item its structural definition site,
145/// which for a private-submodule-plus-flattening `pub use` (e.g. `mod sma;
146/// pub use sma::sma;`) runs through a module that's never itself `pub` — so
147/// rustdoc reports `myc::indicators::sma::sma` even though only
148/// `myc::indicators::sma` is externally valid. Bind markers, `covers = "..."`
149/// tags, and doc prose all read naturally as the flattened path a consumer
150/// would actually import, so both spellings are registered (see
151/// `Surface::grouped` for how counting avoids double-charging the alias).
152fn alias_reexports(
153    index: &serde_json::Map<String, Value>,
154    paths: &serde_json::Map<String, Value>,
155    surface: &mut Surface,
156) {
157    for (mod_id, item) in index {
158        if item["visibility"] != "public" {
159            continue;
160        }
161        let Some(children) = item["inner"]["module"]["items"].as_array() else {
162            continue;
163        };
164        let Some(mod_path) = item_path(paths, mod_id) else {
165            continue;
166        };
167
168        for child in children {
169            let Some(child_id) = child.as_u64() else {
170                continue;
171            };
172            let child_id = child_id.to_string();
173            let Some(use_) = index.get(&child_id).map(|c| &c["inner"]["use"]) else {
174                continue;
175            };
176            let Some(target_id) = use_["id"].as_u64() else {
177                continue;
178            };
179            // A named re-export (`pub use mod::item;`) aliases exactly one
180            // item; a glob (`pub use mod::*;`) — the near-universal way this
181            // codebase flattens a whole file-per-item submodule — aliases
182            // every one of the target module's own children under its own
183            // name instead.
184            let targets: Vec<(String, u64)> = if use_["is_glob"].as_bool().unwrap_or(false) {
185                glob_targets(index, target_id)
186            } else {
187                match use_["name"].as_str() {
188                    Some(name) => vec![(name.to_string(), target_id)],
189                    None => continue,
190                }
191            };
192
193            for (name, target_id) in targets {
194                alias_one(paths, surface, &mod_path, &name, target_id);
195            }
196        }
197    }
198}
199
200/// Every direct child of a glob-imported module, as `(its own name, id)` —
201/// what `pub use module::*;` actually brings into scope.
202fn glob_targets(index: &serde_json::Map<String, Value>, module_id: u64) -> Vec<(String, u64)> {
203    let Some(children) = index
204        .get(&module_id.to_string())
205        .and_then(|m| m["inner"]["module"]["items"].as_array())
206    else {
207        return Vec::new();
208    };
209    children
210        .iter()
211        .filter_map(|c| {
212            let id = c.as_u64()?;
213            let name = index.get(&id.to_string())?["name"].as_str()?;
214            Some((name.to_string(), id))
215        })
216        .collect()
217}
218
219/// Register one alias (`{mod_path}::{name}` -> the target's info) alongside
220/// its canonical entry, never replacing it — existing bind markers and
221/// `covers=` tags may reference either spelling, so both must keep
222/// resolving. `Surface::grouped` handles deduping for consumer-facing counts.
223fn alias_one(
224    paths: &serde_json::Map<String, Value>,
225    surface: &mut Surface,
226    mod_path: &str,
227    name: &str,
228    target_id: u64,
229) {
230    let Some(target_path) = item_path(paths, &target_id.to_string()) else {
231        return;
232    };
233    let Some(info) = surface.items.get(&target_path).cloned() else {
234        return; // target wasn't a `KINDS` item (or was private/unaddressable)
235    };
236    let alias = format!("{mod_path}::{name}");
237    if alias == target_path {
238        return;
239    }
240    surface.items.entry(alias).or_insert(info);
241}
242
243fn item_path(paths: &serde_json::Map<String, Value>, id: &str) -> Option<String> {
244    let segs = paths.get(id)?["path"].as_array()?;
245    Some(
246        segs.iter()
247            .filter_map(Value::as_str)
248            .collect::<Vec<_>>()
249            .join("::"),
250    )
251}
252
253fn span_fingerprint(
254    span: &Value,
255    root: &Path,
256    cache: &mut HashMap<String, Vec<String>>,
257) -> Option<(u64, String)> {
258    let filename = span["filename"].as_str()?;
259    let begin = span["begin"].as_array()?;
260    let end = span["end"].as_array()?;
261    let (bl, el) = (begin[0].as_u64()? as usize, end[0].as_u64()? as usize);
262
263    let lines = match cache.get(filename) {
264        Some(l) => l,
265        None => {
266            // Strict root-relative read: a CWD fallback would silently read
267            // the wrong tree when fingerprinting another worktree's build.
268            let abs = root.join(filename);
269            let text = std::fs::read_to_string(&abs).ok()?;
270            cache.insert(
271                filename.to_string(),
272                text.lines().map(str::to_string).collect(),
273            );
274            &cache[filename]
275        }
276    };
277    if bl == 0 || el > lines.len() || bl > el {
278        return None;
279    }
280    let source = lines[bl - 1..el].join("\n");
281    let normalized: String = source.split_whitespace().collect::<Vec<_>>().join(" ");
282    let sig_end = normalized
283        .find('{')
284        .or_else(|| normalized.find(';'))
285        .unwrap_or(normalized.len());
286    let signature = normalized[..sig_end].trim().to_string();
287    Some((soothfast_registry::fnv1a(normalized.as_bytes()), signature))
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use serde_json::json;
294
295    /// A rustdoc JSON slice for `mod sma;` (private) + `pub use sma::sma;`
296    /// (flattening): the canonical path runs through the private submodule.
297    fn private_submodule_reexport_doc() -> Value {
298        json!({
299            "index": {
300                "1": {
301                    "visibility": "public",
302                    "inner": { "module": { "items": [2, 3] } },
303                },
304                "2": {
305                    "name": "sma",
306                    "visibility": "public",
307                    "inner": { "function": {} },
308                    "span": { "filename": "src/sma.rs", "begin": [1, 0], "end": [1, 0] },
309                    "docs": "Simple moving average.",
310                },
311                "3": {
312                    "visibility": "public",
313                    "inner": {
314                        "use": { "source": "sma::sma", "name": "sma", "id": 2, "is_glob": false },
315                    },
316                },
317            },
318            "paths": {
319                "1": { "path": ["myc", "indicators"], "kind": "module" },
320                "2": { "path": ["myc", "indicators", "sma", "sma"], "kind": "function" },
321            },
322        })
323    }
324
325    #[test]
326    fn reexport_alias_resolves_alongside_canonical_path() {
327        let dir =
328            std::env::temp_dir().join(format!("soothfast-surface-test-{}", std::process::id()));
329        std::fs::create_dir_all(dir.join("src")).unwrap();
330        std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
331
332        let doc = private_submodule_reexport_doc();
333        let surface = from_rustdoc(&doc, &dir);
334
335        assert!(surface.items.contains_key("myc::indicators::sma::sma"));
336        assert!(
337            surface.items.contains_key("myc::indicators::sma"),
338            "expected the flattened re-export path to resolve too: {:?}",
339            surface.items.keys().collect::<Vec<_>>()
340        );
341        let canonical = &surface.items["myc::indicators::sma::sma"];
342        let alias = &surface.items["myc::indicators::sma"];
343        assert_eq!(canonical.fingerprint, alias.fingerprint);
344        assert_eq!(alias.kind, "function");
345
346        let _ = std::fs::remove_dir_all(&dir);
347    }
348
349    #[test]
350    fn glob_reexport_aliases_every_child_by_its_own_name() {
351        // `mod sma; pub use sma::*;`, vs. the single-item form above.
352        let mut doc = private_submodule_reexport_doc();
353        doc["index"]["3"]["inner"]["use"] =
354            json!({ "source": "sma", "name": "sma", "id": 7, "is_glob": true });
355        doc["index"]["7"] = json!({
356            "visibility": "public",
357            "inner": { "module": { "items": [2] } },
358        });
359
360        let dir =
361            std::env::temp_dir().join(format!("soothfast-surface-glob-{}", std::process::id()));
362        std::fs::create_dir_all(dir.join("src")).unwrap();
363        std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
364
365        let surface = from_rustdoc(&doc, &dir);
366        assert!(surface.items.contains_key("myc::indicators::sma::sma"));
367        assert!(surface.items.contains_key("myc::indicators::sma"));
368
369        let _ = std::fs::remove_dir_all(&dir);
370    }
371
372    fn item(kind: &str, fingerprint: u64) -> ItemInfo {
373        ItemInfo {
374            kind: kind.to_string(),
375            fingerprint,
376            signature: String::new(),
377            has_docs: false,
378        }
379    }
380
381    fn surface_of(items: Vec<(&str, ItemInfo)>) -> Surface {
382        Surface {
383            items: items.into_iter().map(|(p, i)| (p.to_string(), i)).collect(),
384        }
385    }
386
387    #[test]
388    fn three_aliases_of_one_function_collapse_to_one_group() {
389        let surf = surface_of(vec![
390            ("myc::atr", item("function", 42)),
391            ("myc::indicators::atr", item("function", 42)),
392            ("myc::indicators::atr::atr", item("function", 42)),
393        ]);
394        let groups = surf.grouped();
395        assert_eq!(groups.len(), 1);
396        assert_eq!(groups[0].representative, "myc::atr");
397        assert_eq!(groups[0].aliases.len(), 3);
398    }
399
400    #[test]
401    fn covers_tag_on_a_longer_alias_still_counts_as_measured() {
402        // A `covers=` tag might name the module-level alias even though the
403        // crate-root re-export is shorter — checking "is this covered" must
404        // consult every alias, not just the (display-only) shortest one.
405        let surf = surface_of(vec![
406            ("myc::atr", item("function", 42)),
407            ("myc::indicators::atr", item("function", 42)),
408        ]);
409        let g = &surf.grouped()[0];
410        assert!(
411            g.aliases
412                .iter()
413                .any(|a| a.as_str() == "myc::indicators::atr")
414        );
415    }
416
417    #[test]
418    fn distinct_functions_with_no_fingerprint_are_never_merged() {
419        let surf = surface_of(vec![
420            ("myc::a", item("function", 0)),
421            ("myc::b", item("function", 0)),
422        ]);
423        assert_eq!(surf.grouped().len(), 2);
424    }
425
426    #[test]
427    fn different_kinds_with_the_same_fingerprint_are_not_merged() {
428        let surf = surface_of(vec![
429            ("myc::a", item("function", 7)),
430            ("myc::A", item("struct", 7)),
431        ]);
432        assert_eq!(surf.grouped().len(), 2);
433    }
434}