1use std::collections::{BTreeMap, HashMap};
10use std::path::Path;
11
12use serde_json::Value;
13
14#[derive(Debug, Clone)]
18pub struct ItemInfo {
19 pub kind: String,
21 pub fingerprint: u64,
23 pub signature: String,
25 pub has_docs: bool,
27}
28
29#[derive(Debug, Default)]
31pub struct Surface {
32 pub items: BTreeMap<String, ItemInfo>,
33}
34
35pub struct ItemGroup<'a> {
41 pub representative: &'a String,
42 pub aliases: Vec<&'a String>,
43}
44
45impl Surface {
46 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
80const 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
95pub 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 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
142fn 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 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
201fn 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
220fn 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; };
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 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 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 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 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}