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