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,
27 pub has_docs: bool,
29}
30
31#[derive(Debug, Default)]
33pub struct Surface {
34 pub items: BTreeMap<String, ItemInfo>,
35}
36
37pub struct ItemGroup<'a> {
43 pub representative: &'a String,
44 pub aliases: Vec<&'a String>,
45}
46
47impl Surface {
48 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
82const 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
97pub 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 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
144fn 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 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
203fn 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
222fn 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; };
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
256const 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 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 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
311fn 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
329fn 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
353fn 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 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 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 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}