1use std::path::Path;
13
14use rustc_hash::FxHashMap;
15use serde::Serialize;
16
17use fallow_config::{ResolvedConfig, WorkspaceInfo};
18use fallow_types::discover::DiscoveredFile;
19use fallow_types::duplicates::{CloneInstance, DuplicationReport};
20use fallow_types::extract::{FunctionComplexity, ModuleInfo};
21use fallow_types::results::AnalysisResults;
22
23use crate::module_graph::RetainedModuleGraph;
24
25const HOTSPOT_CYCLOMATIC_FLOOR: u16 = 10;
27const CLONE_PREVIEW_MAX_BYTES: usize = 2000;
31const CLONE_PREVIEW_MAX_LINES: usize = 32;
35const CLONE_PREVIEW_CONTEXT: usize = 4;
40const MAX_CLONE_GROUPS: usize = 500;
44const EDGE_FLAG_TYPE_ONLY: u32 = 1;
46
47pub struct VizBuildInput<'a> {
49 pub results: &'a AnalysisResults,
51 pub graph: &'a RetainedModuleGraph,
53 pub modules: Option<&'a [ModuleInfo]>,
55 pub files: &'a [DiscoveredFile],
57 pub duplication: &'a DuplicationReport,
59 pub workspaces: &'a [WorkspaceInfo],
61 pub config: &'a ResolvedConfig,
63}
64
65#[derive(Serialize)]
67pub struct VizData {
68 pub root: String,
70 pub files: Vec<VizFile>,
72 pub edges: Vec<[u32; 3]>,
75 pub summary: VizSummary,
77 pub workspaces: Vec<VizWorkspace>,
79 pub zones: Vec<VizZone>,
81 pub cycles: Vec<Vec<u32>>,
83 pub clones: Vec<VizCloneGroup>,
85 pub violations: Vec<VizViolation>,
87}
88
89#[derive(Serialize)]
91pub struct VizFile {
92 pub path: String,
94 pub size: u64,
96 pub status: VizFileStatus,
98 pub export_count: u16,
100 pub unused_export_count: u16,
102 pub is_entry: bool,
104 pub importer_count: u16,
106 pub import_count: u16,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub workspace: Option<u16>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub zone: Option<u16>,
114 #[serde(skip_serializing_if = "Vec::is_empty")]
116 pub unused_exports: Vec<String>,
117 pub fn_count: u16,
119 pub max_cyclomatic: u16,
121 pub max_cognitive: u16,
123 pub react_hooks: u16,
125 pub jsx_depth: u16,
127 #[serde(skip_serializing_if = "Vec::is_empty")]
129 pub functions: Vec<VizFunction>,
130 pub dup_lines: u32,
132 #[serde(skip_serializing_if = "Vec::is_empty")]
134 pub clone_groups: Vec<u32>,
135 pub in_cycle: bool,
137}
138
139#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
141#[serde(rename_all = "camelCase")]
142pub enum VizFileStatus {
143 Clean,
145 HasUnusedExports,
147 Unused,
149 EntryPoint,
151}
152
153#[derive(Serialize)]
155pub struct VizFunction {
156 name: String,
158 line: u32,
160 cyclomatic: u16,
162 cognitive: u16,
164 lines: u32,
166 hooks: u16,
168 jsx_depth: u16,
170 props: u16,
172}
173
174#[derive(Serialize)]
176pub struct VizSummary {
177 pub total_files: usize,
179 pub total_size: u64,
181 pub total_edges: usize,
183 pub unused_files: usize,
185 pub unused_exports: usize,
187 pub unused_types: usize,
189 pub unused_deps: usize,
191 pub unresolved_imports: usize,
193 pub circular_deps: usize,
195 pub clone_groups: usize,
197 pub duplicated_lines: usize,
199 pub boundary_violations: usize,
201 pub hotspot_files: usize,
203 #[serde(skip_serializing_if = "Option::is_none")]
206 pub clone_groups_truncated: Option<u32>,
207}
208
209#[derive(Serialize)]
211pub struct VizWorkspace {
212 name: String,
214 root: String,
216}
217
218#[derive(Serialize)]
220pub struct VizZone {
221 name: String,
223 files: u32,
225}
226
227#[derive(Serialize)]
229pub struct VizCloneGroup {
230 lines: usize,
232 tokens: usize,
234 instances: Vec<VizCloneInstance>,
236 preview: String,
240 highlight_start: u32,
243 highlight_lines: u32,
247}
248
249#[derive(Serialize)]
251pub struct VizCloneInstance {
252 file: u32,
254 start_line: u32,
256 end_line: u32,
258}
259
260#[derive(Serialize)]
262pub struct VizViolation {
263 from: u32,
265 to: u32,
267 from_zone: u16,
269 to_zone: u16,
271 line: u32,
273 specifier: String,
275}
276
277#[must_use]
279pub fn build_viz_data(input: &VizBuildInput<'_>) -> VizData {
280 let root = &input.config.root;
281 let index = FileIndex::new(input.files);
282 let workspaces = build_workspaces(input.workspaces, root);
283 let (zones, zone_by_file) = classify_zones(input, &index);
284 let (clones, clone_groups_by_file, dup_lines_by_file, clone_groups_truncated) =
285 build_clones(input.duplication, &index, MAX_CLONE_GROUPS);
286 let cycles = build_cycles(input.results, &index);
287 let violations = build_violations(input.results, &zones, &index);
288
289 let files = build_files(
290 input,
291 &index,
292 &FilePropertyMaps {
293 zone_by_file: &zone_by_file,
294 clone_groups_by_file: &clone_groups_by_file,
295 dup_lines_by_file: &dup_lines_by_file,
296 cycles: &cycles,
297 },
298 );
299
300 let summary = build_summary(
301 input,
302 &files,
303 &clones,
304 &cycles,
305 &violations,
306 clone_groups_truncated,
307 );
308
309 VizData {
310 root: display_root(root),
311 files,
312 edges: build_edges(input.graph, &index),
313 summary,
314 workspaces,
315 zones,
316 cycles,
317 clones,
318 violations,
319 }
320}
321
322struct FileIndex<'a> {
324 ordered: Vec<&'a DiscoveredFile>,
325 by_path: FxHashMap<&'a Path, u32>,
326 by_file_id: FxHashMap<u32, u32>,
327}
328
329impl<'a> FileIndex<'a> {
330 fn new(files: &'a [DiscoveredFile]) -> Self {
331 let mut ordered: Vec<&DiscoveredFile> = files.iter().collect();
332 ordered.sort_by_key(|f| f.id.0);
333 let mut by_path = FxHashMap::default();
334 let mut by_file_id = FxHashMap::default();
335 for (i, f) in ordered.iter().enumerate() {
336 let idx = clamp_u32(i);
337 by_path.insert(f.path.as_path(), idx);
338 by_file_id.insert(f.id.0, idx);
339 }
340 Self {
341 ordered,
342 by_path,
343 by_file_id,
344 }
345 }
346
347 fn index_of_path(&self, path: &Path) -> Option<u32> {
348 self.by_path.get(path).copied()
349 }
350
351 fn index_of_file_id(&self, file_id: u32) -> Option<u32> {
352 self.by_file_id.get(&file_id).copied()
353 }
354}
355
356struct FilePropertyMaps<'a> {
358 zone_by_file: &'a FxHashMap<u32, u16>,
359 clone_groups_by_file: &'a FxHashMap<u32, Vec<u32>>,
360 dup_lines_by_file: &'a FxHashMap<u32, u32>,
361 cycles: &'a [Vec<u32>],
362}
363
364fn display_root(root: &Path) -> String {
365 root.file_name().map_or_else(
366 || root.to_string_lossy().into_owned(),
367 |n| n.to_string_lossy().into_owned(),
368 )
369}
370
371fn relative_path(path: &Path, root: &Path) -> String {
372 path.strip_prefix(root)
373 .unwrap_or(path)
374 .to_string_lossy()
375 .replace('\\', "/")
376}
377
378fn build_workspaces(workspaces: &[WorkspaceInfo], root: &Path) -> Vec<VizWorkspace> {
379 workspaces
380 .iter()
381 .map(|ws| VizWorkspace {
382 name: ws.name.clone(),
383 root: relative_path(&ws.root, root),
384 })
385 .collect()
386}
387
388fn workspace_index_for(path: &Path, workspaces: &[WorkspaceInfo]) -> Option<u16> {
389 let mut best: Option<(usize, usize)> = None;
390 for (i, ws) in workspaces.iter().enumerate() {
391 if path.starts_with(&ws.root) {
392 let depth = ws.root.components().count();
393 if best.is_none_or(|(_, d)| depth > d) {
394 best = Some((i, depth));
395 }
396 }
397 }
398 best.map(|(i, _)| clamp_u16(i))
399}
400
401fn classify_zones(
402 input: &VizBuildInput<'_>,
403 index: &FileIndex<'_>,
404) -> (Vec<VizZone>, FxHashMap<u32, u16>) {
405 let boundaries = &input.config.boundaries;
406 let mut zones: Vec<VizZone> = boundaries
407 .zones
408 .iter()
409 .map(|z| VizZone {
410 name: z.name.clone(),
411 files: 0,
412 })
413 .collect();
414 let name_to_index: FxHashMap<&str, u16> = boundaries
415 .zones
416 .iter()
417 .enumerate()
418 .map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
419 .collect();
420
421 let mut zone_by_file = FxHashMap::default();
422 if zones.is_empty() {
423 return (zones, zone_by_file);
424 }
425
426 for (i, file) in index.ordered.iter().enumerate() {
427 let rel = relative_path(&file.path, &input.config.root);
428 if let Some(zone_name) = boundaries.classify_zone(&rel)
429 && let Some(&zone_idx) = name_to_index.get(zone_name)
430 {
431 zone_by_file.insert(clamp_u32(i), zone_idx);
432 zones[zone_idx as usize].files += 1;
433 }
434 }
435
436 (zones, zone_by_file)
437}
438
439type CloneMaps = (
442 Vec<VizCloneGroup>,
443 FxHashMap<u32, Vec<u32>>,
444 FxHashMap<u32, u32>,
445 u32,
446);
447
448fn build_clones(
449 duplication: &DuplicationReport,
450 index: &FileIndex<'_>,
451 max_groups: usize,
452) -> CloneMaps {
453 let mut clones = Vec::new();
454 let mut groups_by_file: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
455 let mut dup_lines_by_file: FxHashMap<u32, u32> = FxHashMap::default();
456 let mut truncated: usize = 0;
457
458 for group in &duplication.clone_groups {
459 let instances: Vec<VizCloneInstance> = group
460 .instances
461 .iter()
462 .filter_map(|inst| {
463 index
464 .index_of_path(&inst.file)
465 .map(|file| VizCloneInstance {
466 file,
467 start_line: clamp_u32(inst.start_line),
468 end_line: clamp_u32(inst.end_line),
469 })
470 })
471 .collect();
472 if instances.len() < 2 {
473 continue;
474 }
475 if clones.len() >= max_groups {
476 truncated += 1;
477 continue;
478 }
479
480 let group_idx = clamp_u32(clones.len());
481 for inst in &instances {
482 let entry = groups_by_file.entry(inst.file).or_default();
483 if entry.last() != Some(&group_idx) {
484 entry.push(group_idx);
485 }
486 *dup_lines_by_file.entry(inst.file).or_default() +=
487 inst.end_line.saturating_sub(inst.start_line) + 1;
488 }
489
490 let (preview, highlight_start, highlight_lines) = group
491 .instances
492 .first()
493 .map(build_clone_preview)
494 .unwrap_or_default();
495
496 clones.push(VizCloneGroup {
497 lines: group.line_count,
498 tokens: group.token_count,
499 instances,
500 preview,
501 highlight_start,
502 highlight_lines,
503 });
504 }
505
506 (
507 clones,
508 groups_by_file,
509 dup_lines_by_file,
510 clamp_u32(truncated),
511 )
512}
513
514fn truncate_preview(fragment: &str) -> String {
515 let mut out = String::new();
516 for (i, line) in fragment.lines().enumerate() {
517 if i >= CLONE_PREVIEW_MAX_LINES || out.len() + line.len() > CLONE_PREVIEW_MAX_BYTES {
518 out.push('\u{2026}');
519 break;
520 }
521 if i > 0 {
522 out.push('\n');
523 }
524 out.push_str(line);
525 }
526 out
527}
528
529fn build_clone_preview(inst: &CloneInstance) -> (String, u32, u32) {
539 let Ok(source) = std::fs::read_to_string(&inst.file) else {
540 return fragment_fallback(&inst.fragment);
541 };
542 let lines: Vec<&str> = source.lines().collect();
543 let total = lines.len();
544 if total == 0 || inst.start_line == 0 || inst.start_line > total {
545 return fragment_fallback(&inst.fragment);
546 }
547
548 let block_start = inst.start_line - 1;
551 let block_end = inst.end_line.min(total).max(inst.start_line);
552 let mut block_lines = block_end - block_start;
553 let mut before = block_start.min(CLONE_PREVIEW_CONTEXT);
554 let mut after = (total - block_end).min(CLONE_PREVIEW_CONTEXT);
555
556 if before + block_lines + after > CLONE_PREVIEW_MAX_LINES {
561 if before + block_lines >= CLONE_PREVIEW_MAX_LINES {
562 after = 0;
563 block_lines = CLONE_PREVIEW_MAX_LINES.saturating_sub(before).max(1);
564 } else {
565 trim_context(
566 &mut before,
567 &mut after,
568 CLONE_PREVIEW_MAX_LINES - block_lines,
569 );
570 }
571 }
572
573 enforce_byte_cap(
574 &lines,
575 block_start,
576 &mut before,
577 &mut after,
578 &mut block_lines,
579 );
580
581 let win_start = block_start - before;
582 let win_end = win_start + before + block_lines + after;
583 let preview = lines[win_start..win_end].join("\n");
584 (preview, clamp_u32(before), clamp_u32(block_lines))
585}
586
587fn fragment_fallback(fragment: &str) -> (String, u32, u32) {
590 let preview = truncate_preview(fragment);
591 let highlight_lines = if preview.is_empty() {
592 0
593 } else {
594 preview.lines().count()
595 };
596 (preview, 0, clamp_u32(highlight_lines))
597}
598
599fn trim_context(before: &mut usize, after: &mut usize, budget: usize) {
603 while *before + *after > budget {
604 if *before >= *after {
605 *before -= 1;
606 } else {
607 *after -= 1;
608 }
609 }
610}
611
612fn enforce_byte_cap(
617 lines: &[&str],
618 block_start: usize,
619 before: &mut usize,
620 after: &mut usize,
621 block_lines: &mut usize,
622) {
623 let window_bytes = |before: usize, after: usize, block_lines: usize| -> usize {
624 let start = block_start - before;
625 let end = start + before + block_lines + after;
626 let separators = (end - start).saturating_sub(1);
627 lines[start..end].iter().map(|l| l.len()).sum::<usize>() + separators
628 };
629 while window_bytes(*before, *after, *block_lines) > CLONE_PREVIEW_MAX_BYTES {
630 if *before + *after > 0 {
631 if *before >= *after {
632 *before -= 1;
633 } else {
634 *after -= 1;
635 }
636 } else if *block_lines > 1 {
637 *block_lines -= 1;
638 } else {
639 break;
640 }
641 }
642}
643
644fn build_cycles(results: &AnalysisResults, index: &FileIndex<'_>) -> Vec<Vec<u32>> {
645 results
646 .circular_dependencies
647 .iter()
648 .filter_map(|cd| {
649 let ids: Vec<u32> = cd
650 .cycle
651 .files
652 .iter()
653 .filter_map(|p| index.index_of_path(p))
654 .collect();
655 (ids.len() == cd.cycle.files.len()).then_some(ids)
656 })
657 .collect()
658}
659
660fn build_violations(
661 results: &AnalysisResults,
662 zones: &[VizZone],
663 index: &FileIndex<'_>,
664) -> Vec<VizViolation> {
665 let name_to_index: FxHashMap<&str, u16> = zones
666 .iter()
667 .enumerate()
668 .map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
669 .collect();
670
671 results
672 .boundary_violations
673 .iter()
674 .filter_map(|finding| {
675 let v = &finding.violation;
676 let from = index.index_of_path(&v.from_path)?;
677 let to = index.index_of_path(&v.to_path)?;
678 let from_zone = *name_to_index.get(v.from_zone.as_str())?;
679 let to_zone = *name_to_index.get(v.to_zone.as_str())?;
680 Some(VizViolation {
681 from,
682 to,
683 from_zone,
684 to_zone,
685 line: v.line,
686 specifier: v.import_specifier.clone(),
687 })
688 })
689 .collect()
690}
691
692fn build_edges(graph: &RetainedModuleGraph, index: &FileIndex<'_>) -> Vec<[u32; 3]> {
693 let graph = graph.as_graph();
694 let mut edges = Vec::with_capacity(graph.edge_count());
695 for node in &graph.modules {
696 let Some(source) = index.index_of_file_id(node.file_id.0) else {
697 continue;
698 };
699 for (target_id, all_type_only, _span) in graph.outgoing_edge_summaries(node.file_id) {
700 let Some(target) = index.index_of_file_id(target_id.0) else {
701 continue;
702 };
703 let flags = if all_type_only {
704 EDGE_FLAG_TYPE_ONLY
705 } else {
706 0
707 };
708 edges.push([source, target, flags]);
709 }
710 }
711 edges
712}
713
714#[derive(Default)]
716struct ComplexityRollup {
717 fn_count: u16,
718 max_cyclomatic: u16,
719 max_cognitive: u16,
720 react_hooks: u16,
721 jsx_depth: u16,
722 functions: Vec<VizFunction>,
723}
724
725fn rollup_complexity(functions: &[FunctionComplexity]) -> ComplexityRollup {
726 let mut rollup = ComplexityRollup {
727 fn_count: clamp_u16(functions.len()),
728 ..ComplexityRollup::default()
729 };
730 for f in functions {
731 rollup.max_cyclomatic = rollup.max_cyclomatic.max(f.cyclomatic);
732 rollup.max_cognitive = rollup.max_cognitive.max(f.cognitive);
733 rollup.react_hooks = rollup.react_hooks.saturating_add(f.react_hook_count);
734 rollup.jsx_depth = rollup.jsx_depth.max(f.react_jsx_max_depth);
735 }
736
737 let mut named: Vec<&FunctionComplexity> = functions
742 .iter()
743 .filter(|f| !f.name.starts_with('<'))
744 .collect();
745 named.sort_by(|a, b| {
746 b.cyclomatic
747 .cmp(&a.cyclomatic)
748 .then(b.cognitive.cmp(&a.cognitive))
749 });
750 rollup.functions = named
751 .into_iter()
752 .map(|f| VizFunction {
753 name: f.name.clone(),
754 line: f.line,
755 cyclomatic: f.cyclomatic,
756 cognitive: f.cognitive,
757 lines: f.line_count,
758 hooks: f.react_hook_count,
759 jsx_depth: f.react_jsx_max_depth,
760 props: f.react_prop_count,
761 })
762 .collect();
763 rollup
764}
765
766fn build_files(
767 input: &VizBuildInput<'_>,
768 index: &FileIndex<'_>,
769 maps: &FilePropertyMaps<'_>,
770) -> Vec<VizFile> {
771 let graph = input.graph.as_graph();
772 let unused_file_paths: rustc_hash::FxHashSet<&Path> = input
773 .results
774 .unused_files
775 .iter()
776 .map(|f| f.file.path.as_path())
777 .collect();
778
779 let mut unused_exports_by_file: FxHashMap<&Path, Vec<String>> = FxHashMap::default();
780 for export in &input.results.unused_exports {
781 unused_exports_by_file
782 .entry(export.export.path.as_path())
783 .or_default()
784 .push(export.export.export_name.clone());
785 }
786 for export in &input.results.unused_types {
787 unused_exports_by_file
788 .entry(export.export.path.as_path())
789 .or_default()
790 .push(export.export.export_name.clone());
791 }
792
793 let mut complexity_by_file_id: FxHashMap<u32, ComplexityRollup> = FxHashMap::default();
794 if let Some(modules) = input.modules {
795 for module in modules {
796 if !module.complexity.is_empty() {
797 complexity_by_file_id
798 .insert(module.file_id.0, rollup_complexity(&module.complexity));
799 }
800 }
801 }
802
803 let mut in_cycle = vec![false; index.ordered.len()];
804 for cycle in maps.cycles {
805 for &idx in cycle {
806 if let Some(slot) = in_cycle.get_mut(idx as usize) {
807 *slot = true;
808 }
809 }
810 }
811
812 index
813 .ordered
814 .iter()
815 .enumerate()
816 .map(|(i, file)| {
817 let viz_idx = clamp_u32(i);
818 let node_idx = file.id.0 as usize;
819 let node = graph.modules.get(node_idx);
820 let is_entry = node.is_some_and(|n| n.is_entry_point());
821 let export_count = node.map_or(0, |n| clamp_u16(n.exports.len()));
822 let import_count = clamp_u16(graph.edges_for(file.id).len());
823 let importer_count = clamp_u16(input.graph.direct_importer_count(file.id));
824
825 let unused_export_names = unused_exports_by_file
826 .remove(file.path.as_path())
827 .unwrap_or_default();
828 let unused_export_count = clamp_u16(unused_export_names.len());
829
830 let status = if unused_file_paths.contains(file.path.as_path()) {
831 VizFileStatus::Unused
832 } else if unused_export_count > 0 {
833 VizFileStatus::HasUnusedExports
834 } else if is_entry {
835 VizFileStatus::EntryPoint
836 } else {
837 VizFileStatus::Clean
838 };
839
840 let complexity = complexity_by_file_id.remove(&file.id.0).unwrap_or_default();
841
842 VizFile {
843 path: relative_path(&file.path, &input.config.root),
844 size: file.size_bytes,
845 status,
846 export_count,
847 unused_export_count,
848 is_entry,
849 importer_count,
850 import_count,
851 workspace: workspace_index_for(&file.path, input.workspaces),
852 zone: maps.zone_by_file.get(&viz_idx).copied(),
853 unused_exports: unused_export_names,
854 fn_count: complexity.fn_count,
855 max_cyclomatic: complexity.max_cyclomatic,
856 max_cognitive: complexity.max_cognitive,
857 react_hooks: complexity.react_hooks,
858 jsx_depth: complexity.jsx_depth,
859 functions: complexity.functions,
860 dup_lines: maps.dup_lines_by_file.get(&viz_idx).copied().unwrap_or(0),
861 clone_groups: maps
862 .clone_groups_by_file
863 .get(&viz_idx)
864 .cloned()
865 .unwrap_or_default(),
866 in_cycle: in_cycle[i],
867 }
868 })
869 .collect()
870}
871
872fn build_summary(
873 input: &VizBuildInput<'_>,
874 files: &[VizFile],
875 clones: &[VizCloneGroup],
876 cycles: &[Vec<u32>],
877 violations: &[VizViolation],
878 clone_groups_truncated: u32,
879) -> VizSummary {
880 let results = input.results;
881 VizSummary {
882 total_files: files.len(),
883 total_size: files.iter().map(|f| f.size).sum(),
884 total_edges: input.graph.edge_count(),
885 unused_files: results.unused_files.len(),
886 unused_exports: results.unused_exports.len() + results.unused_types.len(),
887 unused_types: results.unused_types.len(),
888 unused_deps: results.unused_dependencies.len()
889 + results.unused_dev_dependencies.len()
890 + results.unused_optional_dependencies.len(),
891 unresolved_imports: results.unresolved_imports.len(),
892 circular_deps: cycles.len(),
893 clone_groups: clones.len(),
894 duplicated_lines: clones.iter().map(|c| c.lines * c.instances.len()).sum(),
895 boundary_violations: violations.len(),
896 hotspot_files: files
897 .iter()
898 .filter(|f| f.max_cyclomatic >= HOTSPOT_CYCLOMATIC_FLOOR)
899 .count(),
900 clone_groups_truncated: (clone_groups_truncated > 0).then_some(clone_groups_truncated),
901 }
902}
903
904fn clamp_u16(value: usize) -> u16 {
905 u16::try_from(value).unwrap_or(u16::MAX)
906}
907
908fn clamp_u32(value: usize) -> u32 {
909 u32::try_from(value).unwrap_or(u32::MAX)
910}
911
912#[cfg(test)]
913mod tests {
914 use std::path::PathBuf;
915
916 use fallow_config::{BoundaryConfig, BoundaryZone, FallowConfig};
917 use fallow_graph::graph::ModuleGraph;
918 use fallow_graph::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
919 use fallow_types::duplicates::{CloneGroup, CloneInstance};
920 use fallow_types::extract::{ImportInfo, ImportedName};
921 use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
922 use fallow_types::output_format::OutputFormat;
923 use fallow_types::results::{BoundaryViolation, CircularDependency};
924
925 use super::*;
926 use crate::discover::{EntryPoint, EntryPointSource, FileId};
927
928 struct Fixture {
930 config: ResolvedConfig,
931 files: Vec<DiscoveredFile>,
932 results: AnalysisResults,
933 graph: crate::module_graph::RetainedModuleGraph,
934 duplication: DuplicationReport,
935 workspaces: Vec<WorkspaceInfo>,
936 }
937
938 impl Fixture {
939 fn input(&self) -> VizBuildInput<'_> {
940 VizBuildInput {
941 results: &self.results,
942 graph: &self.graph,
943 modules: None,
944 files: &self.files,
945 duplication: &self.duplication,
946 workspaces: &self.workspaces,
947 config: &self.config,
948 }
949 }
950 }
951
952 fn project_root() -> PathBuf {
953 PathBuf::from("/viz-project")
954 }
955
956 fn discovered(id: u32, path: PathBuf, size_bytes: u64) -> DiscoveredFile {
957 DiscoveredFile {
958 id: FileId(id),
959 path,
960 size_bytes,
961 }
962 }
963
964 fn import_of(target: FileId, specifier: &str) -> ResolvedImport {
965 ResolvedImport {
966 info: ImportInfo {
967 source: specifier.to_owned(),
968 imported_name: ImportedName::Named("value".to_owned()),
969 local_name: "value".to_owned(),
970 is_type_only: false,
971 from_style: false,
972 span: oxc_span::Span::new(0, 0),
973 source_span: oxc_span::Span::new(0, 0),
974 },
975 target: ResolveResult::InternalModule(target),
976 }
977 }
978
979 fn zone(name: &str, pattern: &str) -> BoundaryZone {
980 BoundaryZone {
981 name: name.to_owned(),
982 patterns: vec![pattern.to_owned()],
983 auto_discover: Vec::new(),
984 root: None,
985 }
986 }
987
988 fn resolved_config(root: &Path) -> ResolvedConfig {
989 let config = FallowConfig {
990 boundaries: BoundaryConfig {
991 zones: vec![zone("app", "src/**"), zone("shared", "lib/**")],
992 ..BoundaryConfig::default()
993 },
994 ..FallowConfig::default()
995 };
996 config.resolve(root.to_path_buf(), OutputFormat::Json, 1, false, true, None)
997 }
998
999 fn cycle_finding(files: Vec<PathBuf>) -> CircularDependencyFinding {
1000 let length = files.len();
1001 CircularDependencyFinding::with_actions(CircularDependency {
1002 files,
1003 length,
1004 line: 1,
1005 col: 0,
1006 edges: Vec::new(),
1007 is_cross_package: false,
1008 })
1009 }
1010
1011 fn violation_finding(from_path: PathBuf, to_path: PathBuf) -> BoundaryViolationFinding {
1012 BoundaryViolationFinding::with_actions(BoundaryViolation {
1013 from_path,
1014 to_path,
1015 from_zone: "app".to_owned(),
1016 to_zone: "shared".to_owned(),
1017 import_specifier: "../lib/c".to_owned(),
1018 line: 2,
1019 col: 0,
1020 })
1021 }
1022
1023 fn clone_instance(file: PathBuf, start_line: usize, end_line: usize) -> CloneInstance {
1024 CloneInstance {
1025 file,
1026 start_line,
1027 end_line,
1028 start_col: 0,
1029 end_col: 0,
1030 fragment: "const shared = 1;\nconst repeated = 2;\nconst block = 3;".to_owned(),
1031 }
1032 }
1033
1034 fn clone_group(instances: Vec<CloneInstance>) -> CloneGroup {
1035 CloneGroup {
1036 instances,
1037 token_count: 12,
1038 line_count: 3,
1039 similarity: None,
1040 }
1041 }
1042
1043 fn fixture_with(extra_graph_file: bool) -> Fixture {
1048 let root = project_root();
1049 let a = root.join("src/a.ts");
1050 let b = root.join("src/b.ts");
1051 let c = root.join("lib/c.ts");
1052 let missing = root.join("src/missing.ts");
1053
1054 let files = vec![
1055 discovered(0, a.clone(), 100),
1056 discovered(1, b.clone(), 50),
1057 discovered(2, c.clone(), 25),
1058 ];
1059
1060 let mut graph_files = files.clone();
1061 let mut imports = vec![import_of(FileId(1), "./b")];
1062 if extra_graph_file {
1063 graph_files.push(discovered(3, root.join("src/d.ts"), 10));
1064 imports.push(import_of(FileId(3), "./d"));
1065 }
1066 let resolved = vec![ResolvedModule {
1067 file_id: FileId(0),
1068 path: a.clone(),
1069 resolved_imports: imports,
1070 ..ResolvedModule::default()
1071 }];
1072 let entry_points = vec![EntryPoint {
1073 path: a.clone(),
1074 source: EntryPointSource::PackageJsonMain,
1075 }];
1076 let graph = crate::module_graph::RetainedModuleGraph::from(ModuleGraph::build(
1077 &resolved,
1078 &entry_points,
1079 &graph_files,
1080 ));
1081
1082 let results = AnalysisResults {
1083 circular_dependencies: vec![
1084 cycle_finding(vec![a.clone(), b]),
1085 cycle_finding(vec![a.clone(), missing.clone()]),
1086 ],
1087 boundary_violations: vec![
1088 violation_finding(a.clone(), c.clone()),
1089 violation_finding(a.clone(), missing),
1090 ],
1091 ..AnalysisResults::default()
1092 };
1093
1094 let duplication = DuplicationReport {
1095 clone_groups: vec![
1096 clone_group(vec![
1097 clone_instance(a.clone(), 1, 3),
1098 clone_instance(c, 10, 12),
1099 ]),
1100 clone_group(vec![
1101 clone_instance(a.clone(), 20, 22),
1102 clone_instance(root.join("outside.ts"), 1, 3),
1103 ]),
1104 clone_group(vec![
1105 clone_instance(a.clone(), 30, 32),
1106 clone_instance(a, 40, 42),
1107 ]),
1108 ],
1109 ..DuplicationReport::default()
1110 };
1111
1112 let workspaces = vec![WorkspaceInfo {
1113 root: root.join("lib"),
1114 name: "shared-lib".to_owned(),
1115 is_internal_dependency: false,
1116 }];
1117
1118 Fixture {
1119 config: resolved_config(&root),
1120 files,
1121 results,
1122 graph,
1123 duplication,
1124 workspaces,
1125 }
1126 }
1127
1128 fn fixture() -> Fixture {
1129 fixture_with(false)
1130 }
1131
1132 #[test]
1133 fn files_and_edges_use_stable_indices() {
1134 let fx = fixture();
1135 let data = build_viz_data(&fx.input());
1136
1137 let paths: Vec<&str> = data.files.iter().map(|f| f.path.as_str()).collect();
1138 assert_eq!(paths, ["src/a.ts", "src/b.ts", "lib/c.ts"]);
1139 assert_eq!(data.edges, vec![[0, 1, 0]]);
1140 assert!(data.files[0].is_entry);
1141 assert!(matches!(data.files[0].status, VizFileStatus::EntryPoint));
1142 assert!(matches!(data.files[1].status, VizFileStatus::Clean));
1143 assert_eq!(data.files[0].import_count, 1);
1144 assert_eq!(data.files[1].importer_count, 1);
1145 assert_eq!(data.files[0].workspace, None);
1146 assert_eq!(data.files[2].workspace, Some(0));
1147 assert_eq!(data.workspaces.len(), 1);
1148 assert_eq!(data.workspaces[0].root, "lib");
1149 }
1150
1151 #[test]
1152 fn edges_to_files_missing_from_input_are_dropped() {
1153 let fx = fixture_with(true);
1154 let data = build_viz_data(&fx.input());
1155
1156 assert_eq!(fx.graph.edge_count(), 2);
1160 assert_eq!(data.edges, vec![[0, 1, 0]]);
1161 }
1162
1163 #[test]
1164 fn clone_groups_drop_unresolvable_and_dedup_per_file() {
1165 let fx = fixture();
1166 let data = build_viz_data(&fx.input());
1167
1168 assert_eq!(data.clones.len(), 2);
1171 assert_eq!(data.clones[0].instances.len(), 2);
1172 assert_eq!(data.clones[0].instances[0].file, 0);
1173 assert_eq!(data.clones[0].instances[1].file, 2);
1174 assert_eq!(data.clones[0].lines, 3);
1175 assert_eq!(data.clones[0].tokens, 12);
1176 assert_eq!(data.files[0].clone_groups, vec![0, 1]);
1178 assert_eq!(data.files[2].clone_groups, vec![0]);
1179 assert_eq!(data.files[0].dup_lines, 9);
1181 assert_eq!(data.files[2].dup_lines, 3);
1182 assert_eq!(data.files[1].dup_lines, 0);
1183 }
1184
1185 #[test]
1186 fn truncate_preview_caps_lines_and_bytes() {
1187 let last_kept = CLONE_PREVIEW_MAX_LINES - 1;
1190 let many_lines = (0..CLONE_PREVIEW_MAX_LINES + 5)
1191 .map(|i| format!("line {i}"))
1192 .collect::<Vec<_>>();
1193 let out = truncate_preview(&many_lines.join("\n"));
1194 assert_eq!(out.matches('\n').count(), CLONE_PREVIEW_MAX_LINES - 1);
1195 assert!(out.contains(&format!("line {last_kept}")));
1196 assert!(!out.contains(&format!("line {CLONE_PREVIEW_MAX_LINES}")));
1197 assert!(out.ends_with('\u{2026}'));
1198
1199 let big = CLONE_PREVIEW_MAX_BYTES * 3 / 4;
1202 let two_long_lines = format!("{}\n{}", "a".repeat(big), "b".repeat(big));
1203 let out = truncate_preview(&two_long_lines);
1204 assert_eq!(out, format!("{}\u{2026}", "a".repeat(big)));
1205
1206 let emoji_line = "\u{1f389}".repeat(CLONE_PREVIEW_MAX_BYTES);
1209 let out = truncate_preview(&emoji_line);
1210 assert_eq!(out, "\u{2026}");
1211 }
1212
1213 #[test]
1214 fn clone_preview_windows_context_around_the_block() {
1215 use std::io::Write as _;
1216
1217 let mut file = tempfile::NamedTempFile::new().expect("temp file");
1219 let body = (1..=20)
1220 .map(|i| format!("line {i}"))
1221 .collect::<Vec<_>>()
1222 .join("\n");
1223 file.write_all(body.as_bytes()).expect("write source");
1224 let inst = clone_instance(file.path().to_path_buf(), 8, 11);
1225
1226 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1227 let preview_lines: Vec<&str> = preview.lines().collect();
1228
1229 assert_eq!(preview_lines.len(), 12);
1232 assert_eq!(highlight_start, 4);
1233 assert_eq!(highlight_lines, 4);
1234 assert_eq!(preview_lines.first(), Some(&"line 4"));
1235 let start = highlight_start as usize;
1236 let end = start + highlight_lines as usize;
1237 assert_eq!(
1238 &preview_lines[start..end],
1239 ["line 8", "line 9", "line 10", "line 11"],
1240 );
1241 assert_eq!(preview_lines[start - 1], "line 7");
1243 }
1244
1245 #[test]
1246 fn clone_preview_keeps_leading_context_when_the_block_fills_the_cap() {
1247 use std::io::Write as _;
1248
1249 let mut file = tempfile::NamedTempFile::new().expect("temp file");
1253 let body = (1..=200)
1254 .map(|i| format!("line {i}"))
1255 .collect::<Vec<_>>()
1256 .join("\n");
1257 file.write_all(body.as_bytes()).expect("write source");
1258 let inst = clone_instance(file.path().to_path_buf(), 50, 150);
1259
1260 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1261 let preview_lines: Vec<&str> = preview.lines().collect();
1262
1263 assert_eq!(highlight_start, CLONE_PREVIEW_CONTEXT as u32);
1264 assert!(
1265 highlight_start > 0,
1266 "leading context must survive a huge block"
1267 );
1268 assert_eq!(preview_lines.len(), CLONE_PREVIEW_MAX_LINES);
1269 assert_eq!(
1270 highlight_lines as usize,
1271 CLONE_PREVIEW_MAX_LINES - CLONE_PREVIEW_CONTEXT,
1272 );
1273 assert_eq!(preview_lines[highlight_start as usize - 1], "line 49");
1274 assert_eq!(preview_lines[highlight_start as usize], "line 50");
1275 }
1276
1277 #[test]
1278 fn clone_preview_clamps_context_at_file_start() {
1279 use std::io::Write as _;
1280
1281 let mut file = tempfile::NamedTempFile::new().expect("temp file");
1282 file.write_all(b"line 1\nline 2\nline 3\nline 4\nline 5")
1283 .expect("write source");
1284 let inst = clone_instance(file.path().to_path_buf(), 1, 2);
1287
1288 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1289 assert_eq!(highlight_start, 0);
1290 assert_eq!(highlight_lines, 2);
1291 assert_eq!(preview, "line 1\nline 2\nline 3\nline 4\nline 5");
1292 }
1293
1294 #[test]
1295 fn clone_preview_falls_back_when_source_is_unreadable() {
1296 let inst = clone_instance(project_root().join("does-not-exist.ts"), 1, 3);
1299 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1300 assert_eq!(preview, inst.fragment);
1301 assert_eq!(highlight_start, 0);
1302 assert_eq!(highlight_lines as usize, preview.lines().count());
1303 }
1304
1305 #[test]
1306 fn cycles_drop_when_any_member_unresolved() {
1307 let fx = fixture();
1308 let data = build_viz_data(&fx.input());
1309
1310 assert_eq!(data.cycles, vec![vec![0, 1]]);
1313 assert!(data.files[0].in_cycle);
1314 assert!(data.files[1].in_cycle);
1315 assert!(!data.files[2].in_cycle);
1316 assert_eq!(data.summary.circular_deps, data.cycles.len());
1319 }
1320
1321 #[test]
1322 fn violations_resolve_zone_and_file_indices() {
1323 let fx = fixture();
1324 let data = build_viz_data(&fx.input());
1325
1326 assert_eq!(data.zones.len(), 2);
1327 assert_eq!(data.zones[0].name, "app");
1328 assert_eq!(data.zones[0].files, 2);
1329 assert_eq!(data.zones[1].name, "shared");
1330 assert_eq!(data.zones[1].files, 1);
1331 assert_eq!(data.files[0].zone, Some(0));
1332 assert_eq!(data.files[1].zone, Some(0));
1333 assert_eq!(data.files[2].zone, Some(1));
1334
1335 assert_eq!(data.violations.len(), 1);
1337 let v = &data.violations[0];
1338 assert_eq!((v.from, v.to), (0, 2));
1339 assert_eq!((v.from_zone, v.to_zone), (0, 1));
1340 assert_eq!(v.line, 2);
1341 assert_eq!(v.specifier, "../lib/c");
1342 }
1343
1344 #[test]
1345 fn clone_group_cap_counts_truncated_groups() {
1346 let fx = fixture();
1347 let index = FileIndex::new(&fx.files);
1348
1349 let (clones, groups_by_file, _dup_lines, truncated) =
1354 build_clones(&fx.duplication, &index, 1);
1355 assert_eq!(clones.len(), 1);
1356 assert_eq!(truncated, 1);
1357 assert!(
1358 groups_by_file
1359 .values()
1360 .all(|ids| ids.iter().all(|&id| (id as usize) < clones.len()))
1361 );
1362
1363 let data = build_viz_data(&fx.input());
1365 assert_eq!(data.clones.len(), 2);
1366 assert_eq!(data.summary.clone_groups_truncated, None);
1367 }
1368
1369 #[test]
1370 fn summary_flags_clone_truncation_only_when_nonzero() {
1371 let fx = fixture();
1372 let data = build_viz_data(&fx.input());
1373
1374 let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 3);
1375 assert_eq!(summary.clone_groups_truncated, Some(3));
1376 let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 0);
1377 assert_eq!(summary.clone_groups_truncated, None);
1378 }
1379
1380 #[test]
1381 fn summary_counts_match_rendered_arrays() {
1382 let fx = fixture();
1383 let data = build_viz_data(&fx.input());
1384 let s = &data.summary;
1385
1386 assert_eq!(s.total_files, data.files.len());
1387 assert_eq!(s.total_size, 175);
1388 assert_eq!(s.total_edges, data.edges.len());
1389 assert_eq!(s.clone_groups, data.clones.len());
1390 assert_eq!(s.duplicated_lines, 12);
1391 assert_eq!(s.hotspot_files, 0);
1392 assert_eq!(s.unused_files, 0);
1393 assert_eq!(s.unused_exports, 0);
1394 assert_eq!(s.circular_deps, data.cycles.len());
1397 assert_eq!(s.circular_deps, 1);
1398 assert_eq!(s.boundary_violations, data.violations.len());
1399 assert_eq!(s.boundary_violations, 1);
1400 }
1401}