1use std::collections::{BTreeMap, HashMap};
9use std::path::Path;
10
11use serde_json::Value;
12
13#[derive(Debug, Clone)]
17pub struct ItemInfo {
18 pub kind: String,
20 pub fingerprint: u64,
22 pub signature: String,
24 pub has_docs: bool,
26}
27
28#[derive(Debug, Default)]
30pub struct Surface {
31 pub items: BTreeMap<String, ItemInfo>,
32}
33
34pub struct ItemGroup<'a> {
40 pub representative: &'a String,
41 pub aliases: Vec<&'a String>,
42}
43
44impl Surface {
45 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
79const 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
94pub 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 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
141fn 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 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
200fn 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
219fn 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; };
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 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 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 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 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}