use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct ItemInfo {
pub kind: String,
pub fingerprint: u64,
pub signature: String,
pub has_docs: bool,
}
#[derive(Debug, Default)]
pub struct Surface {
pub items: BTreeMap<String, ItemInfo>,
}
pub struct ItemGroup<'a> {
pub representative: &'a String,
pub aliases: Vec<&'a String>,
}
impl Surface {
pub fn grouped(&self) -> Vec<ItemGroup<'_>> {
let mut groups: HashMap<(&str, u64), Vec<&String>> = HashMap::new();
let mut unfingerprinted = Vec::new();
for (path, info) in &self.items {
if info.fingerprint == 0 {
unfingerprinted.push(path);
continue;
}
groups
.entry((info.kind.as_str(), info.fingerprint))
.or_default()
.push(path);
}
let mut out: Vec<ItemGroup<'_>> = groups
.into_values()
.map(|mut aliases| {
aliases.sort();
let representative = aliases.iter().min_by_key(|p| p.len()).copied().unwrap();
ItemGroup {
representative,
aliases,
}
})
.chain(unfingerprinted.into_iter().map(|p| ItemGroup {
representative: p,
aliases: vec![p],
}))
.collect();
out.sort_by(|a, b| a.representative.cmp(b.representative));
out
}
}
const KINDS: &[&str] = &[
"function",
"struct",
"enum",
"trait",
"constant",
"type_alias",
"module",
"static",
"union",
"macro",
"proc_macro",
];
pub fn from_rustdoc(doc: &Value, source_root: &Path) -> Surface {
let mut surface = Surface::default();
let (Some(index), Some(paths)) = (doc["index"].as_object(), doc["paths"].as_object()) else {
return surface;
};
let mut file_cache: HashMap<String, Vec<String>> = HashMap::new();
for (id, item) in index {
if item["visibility"] != "public" {
continue;
}
let Some(inner) = item["inner"].as_object() else {
continue;
};
let Some(kind) = inner.keys().next().map(String::as_str) else {
continue;
};
if !KINDS.contains(&kind) {
continue;
}
let Some(full) = item_path(paths, id) else {
continue;
};
let (fingerprint, signature) =
span_fingerprint(&item["span"], source_root, &mut file_cache)
.unwrap_or((0, String::new()));
let has_docs = item["docs"].as_str().is_some_and(|d| !d.trim().is_empty());
surface.items.insert(
full,
ItemInfo {
kind: kind.to_string(),
fingerprint,
signature,
has_docs,
},
);
}
alias_reexports(index, paths, &mut surface);
surface
}
fn alias_reexports(
index: &serde_json::Map<String, Value>,
paths: &serde_json::Map<String, Value>,
surface: &mut Surface,
) {
for (mod_id, item) in index {
if item["visibility"] != "public" {
continue;
}
let Some(children) = item["inner"]["module"]["items"].as_array() else {
continue;
};
let Some(mod_path) = item_path(paths, mod_id) else {
continue;
};
for child in children {
let Some(child_id) = child.as_u64() else {
continue;
};
let child_id = child_id.to_string();
let Some(use_) = index.get(&child_id).map(|c| &c["inner"]["use"]) else {
continue;
};
let Some(target_id) = use_["id"].as_u64() else {
continue;
};
let targets: Vec<(String, u64)> = if use_["is_glob"].as_bool().unwrap_or(false) {
glob_targets(index, target_id)
} else {
match use_["name"].as_str() {
Some(name) => vec![(name.to_string(), target_id)],
None => continue,
}
};
for (name, target_id) in targets {
alias_one(paths, surface, &mod_path, &name, target_id);
}
}
}
}
fn glob_targets(index: &serde_json::Map<String, Value>, module_id: u64) -> Vec<(String, u64)> {
let Some(children) = index
.get(&module_id.to_string())
.and_then(|m| m["inner"]["module"]["items"].as_array())
else {
return Vec::new();
};
children
.iter()
.filter_map(|c| {
let id = c.as_u64()?;
let name = index.get(&id.to_string())?["name"].as_str()?;
Some((name.to_string(), id))
})
.collect()
}
fn alias_one(
paths: &serde_json::Map<String, Value>,
surface: &mut Surface,
mod_path: &str,
name: &str,
target_id: u64,
) {
let Some(target_path) = item_path(paths, &target_id.to_string()) else {
return;
};
let Some(info) = surface.items.get(&target_path).cloned() else {
return; };
let alias = format!("{mod_path}::{name}");
if alias == target_path {
return;
}
surface.items.entry(alias).or_insert(info);
}
fn item_path(paths: &serde_json::Map<String, Value>, id: &str) -> Option<String> {
let segs = paths.get(id)?["path"].as_array()?;
Some(
segs.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("::"),
)
}
fn span_fingerprint(
span: &Value,
root: &Path,
cache: &mut HashMap<String, Vec<String>>,
) -> Option<(u64, String)> {
let filename = span["filename"].as_str()?;
let begin = span["begin"].as_array()?;
let end = span["end"].as_array()?;
let (bl, el) = (begin[0].as_u64()? as usize, end[0].as_u64()? as usize);
let lines = match cache.get(filename) {
Some(l) => l,
None => {
let abs = root.join(filename);
let text = std::fs::read_to_string(&abs).ok()?;
cache.insert(
filename.to_string(),
text.lines().map(str::to_string).collect(),
);
&cache[filename]
}
};
if bl == 0 || el > lines.len() || bl > el {
return None;
}
let source = lines[bl - 1..el].join("\n");
let normalized: String = source.split_whitespace().collect::<Vec<_>>().join(" ");
let sig_end = normalized
.find('{')
.or_else(|| normalized.find(';'))
.unwrap_or(normalized.len());
let signature = normalized[..sig_end].trim().to_string();
Some((soothfast_registry::fnv1a(normalized.as_bytes()), signature))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn private_submodule_reexport_doc() -> Value {
json!({
"index": {
"1": {
"visibility": "public",
"inner": { "module": { "items": [2, 3] } },
},
"2": {
"name": "sma",
"visibility": "public",
"inner": { "function": {} },
"span": { "filename": "src/sma.rs", "begin": [1, 0], "end": [1, 0] },
"docs": "Simple moving average.",
},
"3": {
"visibility": "public",
"inner": {
"use": { "source": "sma::sma", "name": "sma", "id": 2, "is_glob": false },
},
},
},
"paths": {
"1": { "path": ["myc", "indicators"], "kind": "module" },
"2": { "path": ["myc", "indicators", "sma", "sma"], "kind": "function" },
},
})
}
#[test]
fn reexport_alias_resolves_alongside_canonical_path() {
let dir =
std::env::temp_dir().join(format!("soothfast-surface-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
let doc = private_submodule_reexport_doc();
let surface = from_rustdoc(&doc, &dir);
assert!(surface.items.contains_key("myc::indicators::sma::sma"));
assert!(
surface.items.contains_key("myc::indicators::sma"),
"expected the flattened re-export path to resolve too: {:?}",
surface.items.keys().collect::<Vec<_>>()
);
let canonical = &surface.items["myc::indicators::sma::sma"];
let alias = &surface.items["myc::indicators::sma"];
assert_eq!(canonical.fingerprint, alias.fingerprint);
assert_eq!(alias.kind, "function");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn glob_reexport_aliases_every_child_by_its_own_name() {
let mut doc = private_submodule_reexport_doc();
doc["index"]["3"]["inner"]["use"] =
json!({ "source": "sma", "name": "sma", "id": 7, "is_glob": true });
doc["index"]["7"] = json!({
"visibility": "public",
"inner": { "module": { "items": [2] } },
});
let dir =
std::env::temp_dir().join(format!("soothfast-surface-glob-{}", std::process::id()));
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
let surface = from_rustdoc(&doc, &dir);
assert!(surface.items.contains_key("myc::indicators::sma::sma"));
assert!(surface.items.contains_key("myc::indicators::sma"));
let _ = std::fs::remove_dir_all(&dir);
}
fn item(kind: &str, fingerprint: u64) -> ItemInfo {
ItemInfo {
kind: kind.to_string(),
fingerprint,
signature: String::new(),
has_docs: false,
}
}
fn surface_of(items: Vec<(&str, ItemInfo)>) -> Surface {
Surface {
items: items.into_iter().map(|(p, i)| (p.to_string(), i)).collect(),
}
}
#[test]
fn three_aliases_of_one_function_collapse_to_one_group() {
let surf = surface_of(vec![
("myc::atr", item("function", 42)),
("myc::indicators::atr", item("function", 42)),
("myc::indicators::atr::atr", item("function", 42)),
]);
let groups = surf.grouped();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].representative, "myc::atr");
assert_eq!(groups[0].aliases.len(), 3);
}
#[test]
fn covers_tag_on_a_longer_alias_still_counts_as_measured() {
let surf = surface_of(vec![
("myc::atr", item("function", 42)),
("myc::indicators::atr", item("function", 42)),
]);
let g = &surf.grouped()[0];
assert!(
g.aliases
.iter()
.any(|a| a.as_str() == "myc::indicators::atr")
);
}
#[test]
fn distinct_functions_with_no_fingerprint_are_never_merged() {
let surf = surface_of(vec![
("myc::a", item("function", 0)),
("myc::b", item("function", 0)),
]);
assert_eq!(surf.grouped().len(), 2);
}
#[test]
fn different_kinds_with_the_same_fingerprint_are_not_merged() {
let surf = surface_of(vec![
("myc::a", item("function", 7)),
("myc::A", item("struct", 7)),
]);
assert_eq!(surf.grouped().len(), 2);
}
}