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, normalized: the span up to the first `{` or `;`,
24    /// except that a struct or union keeps its `pub` fields and an enum its
25    /// variants, since those are what a consumer writes against.
26    pub signature: String,
27    /// Whether the item carries a `///` doc comment.
28    pub has_docs: bool,
29}
30
31/// Public items keyed by full path (`crate::module::item`).
32#[derive(Debug, Default)]
33pub struct Surface {
34    pub items: BTreeMap<String, ItemInfo>,
35}
36
37/// One distinct item, grouped by (kind, fingerprint) to collapse the extra
38/// path keys `alias_reexports` adds for the same underlying function —
39/// `representative` is the shortest path (for display), `aliases` holds
40/// every spelling (for lookups, since a `covers=` tag may name any of them).
41/// Un-fingerprinted items (span unreadable) are never merged with others.
42pub struct ItemGroup<'a> {
43    pub representative: &'a String,
44    pub aliases: Vec<&'a String>,
45}
46
47impl Surface {
48    /// Every item in this surface, deduplicated across `pub use` aliases.
49    pub fn grouped(&self) -> Vec<ItemGroup<'_>> {
50        let mut groups: HashMap<(&str, u64), Vec<&String>> = HashMap::new();
51        let mut unfingerprinted = Vec::new();
52        for (path, info) in &self.items {
53            if info.fingerprint == 0 {
54                unfingerprinted.push(path);
55                continue;
56            }
57            groups
58                .entry((info.kind.as_str(), info.fingerprint))
59                .or_default()
60                .push(path);
61        }
62        let mut out: Vec<ItemGroup<'_>> = groups
63            .into_values()
64            .map(|mut aliases| {
65                aliases.sort();
66                let representative = aliases.iter().min_by_key(|p| p.len()).copied().unwrap();
67                ItemGroup {
68                    representative,
69                    aliases,
70                }
71            })
72            .chain(unfingerprinted.into_iter().map(|p| ItemGroup {
73                representative: p,
74                aliases: vec![p],
75            }))
76            .collect();
77        out.sort_by(|a, b| a.representative.cmp(b.representative));
78        out
79    }
80}
81
82/// Item kinds surfaced in reports (impl blocks and imports are noise).
83const KINDS: &[&str] = &[
84    "function",
85    "struct",
86    "enum",
87    "trait",
88    "constant",
89    "type_alias",
90    "module",
91    "static",
92    "union",
93    "macro",
94    "proc_macro",
95];
96
97/// Build a surface from a rustdoc JSON document. `source_root` resolves the
98/// relative filenames in spans (the directory rustdoc ran in).
99pub fn from_rustdoc(doc: &Value, source_root: &Path) -> Surface {
100    let mut surface = Surface::default();
101    let (Some(index), Some(paths)) = (doc["index"].as_object(), doc["paths"].as_object()) else {
102        return surface;
103    };
104    let mut file_cache: HashMap<String, Vec<String>> = HashMap::new();
105
106    for (id, item) in index {
107        if item["visibility"] != "public" {
108            continue;
109        }
110        let Some(inner) = item["inner"].as_object() else {
111            continue;
112        };
113        let Some(kind) = inner.keys().next().map(String::as_str) else {
114            continue;
115        };
116        if !KINDS.contains(&kind) {
117            continue;
118        }
119        // unaddressable (e.g. non-re-exported assoc items) if absent
120        let Some(full) = item_path(paths, id) else {
121            continue;
122        };
123
124        let (fingerprint, signature) =
125            span_fingerprint(&item["span"], kind, source_root, &mut file_cache)
126                .unwrap_or((0, String::new()));
127        let has_docs = item["docs"].as_str().is_some_and(|d| !d.trim().is_empty());
128
129        surface.items.insert(
130            full,
131            ItemInfo {
132                kind: kind.to_string(),
133                fingerprint,
134                signature,
135                has_docs,
136            },
137        );
138    }
139
140    alias_reexports(index, paths, &mut surface);
141    surface
142}
143
144/// Register every `pub use inner::item;` re-export under an additional key:
145/// the *importing* module's path + the local alias name.
146///
147/// rustdoc's `paths` map gives each item its structural definition site,
148/// which for a private-submodule-plus-flattening `pub use` (e.g. `mod sma;
149/// pub use sma::sma;`) runs through a module that's never itself `pub` — so
150/// rustdoc reports `myc::indicators::sma::sma` even though only
151/// `myc::indicators::sma` is externally valid. Bind markers, `covers = "..."`
152/// tags, and doc prose all read naturally as the flattened path a consumer
153/// would actually import, so both spellings are registered (see
154/// `Surface::grouped` for how counting avoids double-charging the alias).
155fn alias_reexports(
156    index: &serde_json::Map<String, Value>,
157    paths: &serde_json::Map<String, Value>,
158    surface: &mut Surface,
159) {
160    for (mod_id, item) in index {
161        if item["visibility"] != "public" {
162            continue;
163        }
164        let Some(children) = item["inner"]["module"]["items"].as_array() else {
165            continue;
166        };
167        let Some(mod_path) = item_path(paths, mod_id) else {
168            continue;
169        };
170
171        for child in children {
172            let Some(child_id) = child.as_u64() else {
173                continue;
174            };
175            let child_id = child_id.to_string();
176            let Some(use_) = index.get(&child_id).map(|c| &c["inner"]["use"]) else {
177                continue;
178            };
179            let Some(target_id) = use_["id"].as_u64() else {
180                continue;
181            };
182            // A named re-export (`pub use mod::item;`) aliases exactly one
183            // item; a glob (`pub use mod::*;`) — the near-universal way this
184            // codebase flattens a whole file-per-item submodule — aliases
185            // every one of the target module's own children under its own
186            // name instead.
187            let targets: Vec<(String, u64)> = if use_["is_glob"].as_bool().unwrap_or(false) {
188                glob_targets(index, target_id)
189            } else {
190                match use_["name"].as_str() {
191                    Some(name) => vec![(name.to_string(), target_id)],
192                    None => continue,
193                }
194            };
195
196            for (name, target_id) in targets {
197                alias_one(paths, surface, &mod_path, &name, target_id);
198            }
199        }
200    }
201}
202
203/// Every direct child of a glob-imported module, as `(its own name, id)` —
204/// what `pub use module::*;` actually brings into scope.
205fn glob_targets(index: &serde_json::Map<String, Value>, module_id: u64) -> Vec<(String, u64)> {
206    let Some(children) = index
207        .get(&module_id.to_string())
208        .and_then(|m| m["inner"]["module"]["items"].as_array())
209    else {
210        return Vec::new();
211    };
212    children
213        .iter()
214        .filter_map(|c| {
215            let id = c.as_u64()?;
216            let name = index.get(&id.to_string())?["name"].as_str()?;
217            Some((name.to_string(), id))
218        })
219        .collect()
220}
221
222/// Register one alias (`{mod_path}::{name}` -> the target's info) alongside
223/// its canonical entry, never replacing it — existing bind markers and
224/// `covers=` tags may reference either spelling, so both must keep
225/// resolving. `Surface::grouped` handles deduping for consumer-facing counts.
226fn alias_one(
227    paths: &serde_json::Map<String, Value>,
228    surface: &mut Surface,
229    mod_path: &str,
230    name: &str,
231    target_id: u64,
232) {
233    let Some(target_path) = item_path(paths, &target_id.to_string()) else {
234        return;
235    };
236    let Some(info) = surface.items.get(&target_path).cloned() else {
237        return; // target wasn't a `KINDS` item (or was private/unaddressable)
238    };
239    let alias = format!("{mod_path}::{name}");
240    if alias == target_path {
241        return;
242    }
243    surface.items.entry(alias).or_insert(info);
244}
245
246fn item_path(paths: &serde_json::Map<String, Value>, id: &str) -> Option<String> {
247    let segs = paths.get(id)?["path"].as_array()?;
248    Some(
249        segs.iter()
250            .filter_map(Value::as_str)
251            .collect::<Vec<_>>()
252            .join("::"),
253    )
254}
255
256/// Kinds whose braces enclose part of the public contract rather than an
257/// implementation: every enum variant, and a struct's or union's `pub`
258/// fields.
259const MEMBER_KINDS: [&str; 3] = ["struct", "enum", "union"];
260
261fn span_fingerprint(
262    span: &Value,
263    kind: &str,
264    root: &Path,
265    cache: &mut HashMap<String, Vec<String>>,
266) -> Option<(u64, String)> {
267    let filename = span["filename"].as_str()?;
268    let begin = span["begin"].as_array()?;
269    let end = span["end"].as_array()?;
270    let (bl, el) = (begin[0].as_u64()? as usize, end[0].as_u64()? as usize);
271
272    let lines = match cache.get(filename) {
273        Some(l) => l,
274        None => {
275            // Strict root-relative read: a CWD fallback would silently read
276            // the wrong tree when fingerprinting another worktree's build.
277            let abs = root.join(filename);
278            let text = std::fs::read_to_string(&abs).ok()?;
279            cache.insert(
280                filename.to_string(),
281                text.lines().map(str::to_string).collect(),
282            );
283            &cache[filename]
284        }
285    };
286    if bl == 0 || el > lines.len() || bl > el {
287        return None;
288    }
289    let span_text = lines[bl - 1..el].join("\n");
290    let normalized = normalize(&crate::comments::strip(&span_text));
291    // Doc comments are part of the fingerprint (prose is bound to them)
292    // but not of the signature, where they would hide a member's `pub`.
293    let bare = normalize(&crate::comments::strip_all(&span_text));
294    let sig_end = bare
295        .find('{')
296        .or_else(|| bare.find(';'))
297        .unwrap_or(bare.len());
298    let declaration = bare[..sig_end].trim();
299    let signature = if MEMBER_KINDS.contains(&kind) {
300        member_signature(declaration, &bare[sig_end..], kind == "enum")
301    } else {
302        declaration.to_string()
303    };
304    Some((soothfast_registry::fnv1a(normalized.as_bytes()), signature))
305}
306
307fn normalize(source: &str) -> String {
308    source.split_whitespace().collect::<Vec<_>>().join(" ")
309}
310
311/// Declaration plus the members a consumer can name: every variant of an
312/// enum, only the `pub` fields of a struct or union. A private field edit is
313/// a body change, a public one is not.
314fn member_signature(declaration: &str, rest: &str, all_public: bool) -> String {
315    let Some(body) = rest
316        .strip_prefix('{')
317        .and_then(|inner| inner.trim_end().strip_suffix('}'))
318    else {
319        return declaration.to_string();
320    };
321    let members: Vec<String> = split_top_level(body)
322        .into_iter()
323        .map(|member| strip_attributes(member.trim()).to_string())
324        .filter(|member| !member.is_empty() && (all_public || member.starts_with("pub ")))
325        .collect();
326    format!("{declaration} {{ {} }}", members.join(", "))
327}
328
329/// Split on commas outside brackets, so `HashMap<K, V>` stays one member.
330/// The `>` of a `->` return arrow is not a bracket.
331fn split_top_level(text: &str) -> Vec<&str> {
332    let mut parts = Vec::new();
333    let mut depth = 0i32;
334    let mut start = 0;
335    let mut prev = ' ';
336    for (i, c) in text.char_indices() {
337        match c {
338            '<' | '(' | '[' | '{' => depth += 1,
339            '>' if prev == '-' => {}
340            '>' | ')' | ']' | '}' => depth -= 1,
341            ',' if depth == 0 => {
342                parts.push(&text[start..i]);
343                start = i + 1;
344            }
345            _ => {}
346        }
347        prev = c;
348    }
349    parts.push(&text[start..]);
350    parts
351}
352
353/// Drop leading `#[...]` attributes so `#[serde(rename = "x")] pub a: u8`
354/// is recognised as public.
355fn strip_attributes(member: &str) -> &str {
356    let mut rest = member;
357    while let Some(after) = rest.strip_prefix("#[") {
358        let mut depth = 1;
359        let mut end = None;
360        for (i, c) in after.char_indices() {
361            depth += match c {
362                '[' => 1,
363                ']' => -1,
364                _ => 0,
365            };
366            if depth == 0 {
367                end = Some(i + 1);
368                break;
369            }
370        }
371        match end {
372            Some(e) => rest = after[e..].trim_start(),
373            None => break,
374        }
375    }
376    rest
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use serde_json::json;
383
384    /// A rustdoc JSON slice for `mod sma;` (private) + `pub use sma::sma;`
385    /// (flattening): the canonical path runs through the private submodule.
386    fn private_submodule_reexport_doc() -> Value {
387        json!({
388            "index": {
389                "1": {
390                    "visibility": "public",
391                    "inner": { "module": { "items": [2, 3] } },
392                },
393                "2": {
394                    "name": "sma",
395                    "visibility": "public",
396                    "inner": { "function": {} },
397                    "span": { "filename": "src/sma.rs", "begin": [1, 0], "end": [1, 0] },
398                    "docs": "Simple moving average.",
399                },
400                "3": {
401                    "visibility": "public",
402                    "inner": {
403                        "use": { "source": "sma::sma", "name": "sma", "id": 2, "is_glob": false },
404                    },
405                },
406            },
407            "paths": {
408                "1": { "path": ["myc", "indicators"], "kind": "module" },
409                "2": { "path": ["myc", "indicators", "sma", "sma"], "kind": "function" },
410            },
411        })
412    }
413
414    #[test]
415    fn reexport_alias_resolves_alongside_canonical_path() {
416        let dir =
417            std::env::temp_dir().join(format!("soothfast-surface-test-{}", std::process::id()));
418        std::fs::create_dir_all(dir.join("src")).unwrap();
419        std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
420
421        let doc = private_submodule_reexport_doc();
422        let surface = from_rustdoc(&doc, &dir);
423
424        assert!(surface.items.contains_key("myc::indicators::sma::sma"));
425        assert!(
426            surface.items.contains_key("myc::indicators::sma"),
427            "expected the flattened re-export path to resolve too: {:?}",
428            surface.items.keys().collect::<Vec<_>>()
429        );
430        let canonical = &surface.items["myc::indicators::sma::sma"];
431        let alias = &surface.items["myc::indicators::sma"];
432        assert_eq!(canonical.fingerprint, alias.fingerprint);
433        assert_eq!(alias.kind, "function");
434
435        let _ = std::fs::remove_dir_all(&dir);
436    }
437
438    #[test]
439    fn glob_reexport_aliases_every_child_by_its_own_name() {
440        // `mod sma; pub use sma::*;`, vs. the single-item form above.
441        let mut doc = private_submodule_reexport_doc();
442        doc["index"]["3"]["inner"]["use"] =
443            json!({ "source": "sma", "name": "sma", "id": 7, "is_glob": true });
444        doc["index"]["7"] = json!({
445            "visibility": "public",
446            "inner": { "module": { "items": [2] } },
447        });
448
449        let dir =
450            std::env::temp_dir().join(format!("soothfast-surface-glob-{}", std::process::id()));
451        std::fs::create_dir_all(dir.join("src")).unwrap();
452        std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
453
454        let surface = from_rustdoc(&doc, &dir);
455        assert!(surface.items.contains_key("myc::indicators::sma::sma"));
456        assert!(surface.items.contains_key("myc::indicators::sma"));
457
458        let _ = std::fs::remove_dir_all(&dir);
459    }
460
461    fn item(kind: &str, fingerprint: u64) -> ItemInfo {
462        ItemInfo {
463            kind: kind.to_string(),
464            fingerprint,
465            signature: String::new(),
466            has_docs: false,
467        }
468    }
469
470    fn surface_of(items: Vec<(&str, ItemInfo)>) -> Surface {
471        Surface {
472            items: items.into_iter().map(|(p, i)| (p.to_string(), i)).collect(),
473        }
474    }
475
476    #[test]
477    fn three_aliases_of_one_function_collapse_to_one_group() {
478        let surf = surface_of(vec![
479            ("myc::atr", item("function", 42)),
480            ("myc::indicators::atr", item("function", 42)),
481            ("myc::indicators::atr::atr", item("function", 42)),
482        ]);
483        let groups = surf.grouped();
484        assert_eq!(groups.len(), 1);
485        assert_eq!(groups[0].representative, "myc::atr");
486        assert_eq!(groups[0].aliases.len(), 3);
487    }
488
489    #[test]
490    fn covers_tag_on_a_longer_alias_still_counts_as_measured() {
491        // A `covers=` tag might name the module-level alias even though the
492        // crate-root re-export is shorter — checking "is this covered" must
493        // consult every alias, not just the (display-only) shortest one.
494        let surf = surface_of(vec![
495            ("myc::atr", item("function", 42)),
496            ("myc::indicators::atr", item("function", 42)),
497        ]);
498        let g = &surf.grouped()[0];
499        assert!(
500            g.aliases
501                .iter()
502                .any(|a| a.as_str() == "myc::indicators::atr")
503        );
504    }
505
506    #[test]
507    fn distinct_functions_with_no_fingerprint_are_never_merged() {
508        let surf = surface_of(vec![
509            ("myc::a", item("function", 0)),
510            ("myc::b", item("function", 0)),
511        ]);
512        assert_eq!(surf.grouped().len(), 2);
513    }
514
515    #[test]
516    fn different_kinds_with_the_same_fingerprint_are_not_merged() {
517        let surf = surface_of(vec![
518            ("myc::a", item("function", 7)),
519            ("myc::A", item("struct", 7)),
520        ]);
521        assert_eq!(surf.grouped().len(), 2);
522    }
523
524    fn fingerprint_of(tag: &str, source: &str) -> (u64, String) {
525        fingerprint_of_kind(tag, "struct", source)
526    }
527
528    fn fingerprint_of_kind(tag: &str, kind: &str, source: &str) -> (u64, String) {
529        let dir = std::env::temp_dir().join(format!(
530            "soothfast-fingerprint-{}-{tag}",
531            std::process::id()
532        ));
533        std::fs::create_dir_all(&dir).unwrap();
534        std::fs::write(dir.join("lib.rs"), source).unwrap();
535        let span = json!({
536            "filename": "lib.rs",
537            "begin": [1, 0],
538            "end": [source.lines().count(), 0],
539        });
540        let got = span_fingerprint(&span, kind, &dir, &mut HashMap::new()).unwrap();
541        let _ = std::fs::remove_dir_all(&dir);
542        got
543    }
544
545    #[test]
546    fn a_comment_only_edit_does_not_move_the_fingerprint() {
547        let plain = fingerprint_of("plain", "pub struct S {\n    a: u8,\n}\n");
548        let noted = fingerprint_of(
549            "noted",
550            "pub struct S {\n    // widened for the wire format\n    a: u8,\n}\n",
551        );
552        assert_eq!(plain.0, noted.0);
553    }
554
555    #[test]
556    fn a_field_change_still_moves_the_fingerprint() {
557        let before = fingerprint_of("before", "pub struct S {\n    a: u8,\n}\n");
558        let after = fingerprint_of("after", "pub struct S {\n    a: u16,\n}\n");
559        assert_ne!(before.0, after.0);
560    }
561
562    #[test]
563    fn a_struct_field_addition_changes_the_signature() {
564        let before = fingerprint_of_kind(
565            "sig-before",
566            "struct",
567            "pub struct S {\n    pub a: u8,\n}\n",
568        );
569        let after = fingerprint_of_kind(
570            "sig-after",
571            "struct",
572            "pub struct S {\n    pub a: u8,\n    pub b: u8,\n}\n",
573        );
574        assert_ne!(before.1, after.1);
575        assert_eq!(after.1, "pub struct S { pub a: u8, pub b: u8 }");
576    }
577
578    #[test]
579    fn a_private_field_edit_is_body_only() {
580        let before = fingerprint_of_kind(
581            "priv-before",
582            "struct",
583            "pub struct S {\n    pub a: u8,\n    b: u8,\n}\n",
584        );
585        let after = fingerprint_of_kind(
586            "priv-after",
587            "struct",
588            "pub struct S {\n    pub a: u8,\n    b: u16,\n    c: u8,\n}\n",
589        );
590        assert_ne!(before.0, after.0);
591        assert_eq!(before.1, after.1);
592        assert_eq!(before.1, "pub struct S { pub a: u8 }");
593    }
594
595    #[test]
596    fn generic_fields_and_attributes_are_read_as_members() {
597        let got = fingerprint_of_kind(
598            "generic",
599            "struct",
600            "pub struct S {\n    #[serde(rename = \"m\")]\n    pub map: HashMap<String, Vec<u8>>,\n    pub(crate) hidden: u8,\n}\n",
601        );
602        assert_eq!(got.1, "pub struct S { pub map: HashMap<String, Vec<u8>> }");
603    }
604
605    #[test]
606    fn a_documented_public_field_counts_as_signature() {
607        let before = fingerprint_of_kind(
608            "pubdoc-before",
609            "struct",
610            "pub struct S {\n    /// count\n    pub a: u8,\n}\n",
611        );
612        let after = fingerprint_of_kind(
613            "pubdoc-after",
614            "struct",
615            "pub struct S {\n    /// count\n    pub a: u8,\n    /// total\n    pub b: u8,\n}\n",
616        );
617        assert_ne!(before.1, after.1);
618        assert_eq!(after.1, "pub struct S { pub a: u8, pub b: u8 }");
619    }
620
621    #[test]
622    fn a_variant_doc_edit_moves_the_fingerprint_but_not_the_signature() {
623        let before = fingerprint_of_kind(
624            "vdoc-before",
625            "enum",
626            "pub enum E {\n    /// first\n    A,\n    B,\n}\n",
627        );
628        let after = fingerprint_of_kind(
629            "vdoc-after",
630            "enum",
631            "pub enum E {\n    /// the first\n    A,\n    B,\n}\n",
632        );
633        assert_ne!(before.0, after.0);
634        assert_eq!(before.1, after.1);
635        assert_eq!(before.1, "pub enum E { A, B }");
636    }
637
638    #[test]
639    fn a_function_pointer_field_does_not_swallow_its_neighbours() {
640        let got = fingerprint_of_kind(
641            "fnptr",
642            "struct",
643            "pub struct S {\n    pub f: fn(u8) -> u8,\n    pub g: u8,\n}\n",
644        );
645        assert_eq!(got.1, "pub struct S { pub f: fn(u8) -> u8, pub g: u8 }");
646    }
647
648    #[test]
649    fn an_enum_variant_addition_changes_the_signature() {
650        let before = fingerprint_of_kind("var-before", "enum", "pub enum E {\n    A,\n}\n");
651        let after = fingerprint_of_kind("var-after", "enum", "pub enum E {\n    A,\n    B,\n}\n");
652        assert_ne!(before.1, after.1);
653    }
654
655    #[test]
656    fn a_function_body_edit_keeps_its_signature() {
657        let before = fingerprint_of_kind("fn-before", "function", "pub fn f() -> u8 {\n    1\n}\n");
658        let after = fingerprint_of_kind("fn-after", "function", "pub fn f() -> u8 {\n    2\n}\n");
659        assert_ne!(before.0, after.0);
660        assert_eq!(before.1, after.1);
661        assert_eq!(before.1, "pub fn f() -> u8");
662    }
663
664    #[test]
665    fn a_doc_comment_on_a_field_is_part_of_the_fingerprint() {
666        let before = fingerprint_of(
667            "doc-before",
668            "pub struct S {\n    /// count\n    a: u8,\n}\n",
669        );
670        let after = fingerprint_of(
671            "doc-after",
672            "pub struct S {\n    /// total\n    a: u8,\n}\n",
673        );
674        assert_ne!(before.0, after.0);
675    }
676}