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 is_type_only_star: false,
972 from_style: false,
973 span: oxc_span::Span::new(0, 0),
974 source_span: oxc_span::Span::new(0, 0),
975 },
976 target: ResolveResult::InternalModule(target),
977 }
978 }
979
980 fn zone(name: &str, pattern: &str) -> BoundaryZone {
981 BoundaryZone {
982 name: name.to_owned(),
983 patterns: vec![pattern.to_owned()],
984 auto_discover: Vec::new(),
985 root: None,
986 }
987 }
988
989 fn resolved_config(root: &Path) -> ResolvedConfig {
990 let config = FallowConfig {
991 boundaries: BoundaryConfig {
992 zones: vec![zone("app", "src/**"), zone("shared", "lib/**")],
993 ..BoundaryConfig::default()
994 },
995 ..FallowConfig::default()
996 };
997 config.resolve(root.to_path_buf(), OutputFormat::Json, 1, false, true, None)
998 }
999
1000 fn cycle_finding(files: Vec<PathBuf>) -> CircularDependencyFinding {
1001 let length = files.len();
1002 CircularDependencyFinding::with_actions(CircularDependency {
1003 files,
1004 length,
1005 line: 1,
1006 col: 0,
1007 edges: Vec::new(),
1008 is_cross_package: false,
1009 })
1010 }
1011
1012 fn violation_finding(from_path: PathBuf, to_path: PathBuf) -> BoundaryViolationFinding {
1013 BoundaryViolationFinding::with_actions(BoundaryViolation {
1014 from_path,
1015 to_path,
1016 from_zone: "app".to_owned(),
1017 to_zone: "shared".to_owned(),
1018 import_specifier: "../lib/c".to_owned(),
1019 line: 2,
1020 col: 0,
1021 })
1022 }
1023
1024 fn clone_instance(file: PathBuf, start_line: usize, end_line: usize) -> CloneInstance {
1025 CloneInstance {
1026 file,
1027 start_line,
1028 end_line,
1029 start_col: 0,
1030 end_col: 0,
1031 fragment: "const shared = 1;\nconst repeated = 2;\nconst block = 3;".to_owned(),
1032 }
1033 }
1034
1035 fn clone_group(instances: Vec<CloneInstance>) -> CloneGroup {
1036 CloneGroup {
1037 instances,
1038 token_count: 12,
1039 line_count: 3,
1040 similarity: None,
1041 }
1042 }
1043
1044 fn fixture_with(extra_graph_file: bool) -> Fixture {
1049 let root = project_root();
1050 let a = root.join("src/a.ts");
1051 let b = root.join("src/b.ts");
1052 let c = root.join("lib/c.ts");
1053 let missing = root.join("src/missing.ts");
1054
1055 let files = vec![
1056 discovered(0, a.clone(), 100),
1057 discovered(1, b.clone(), 50),
1058 discovered(2, c.clone(), 25),
1059 ];
1060
1061 let mut graph_files = files.clone();
1062 let mut imports = vec![import_of(FileId(1), "./b")];
1063 if extra_graph_file {
1064 graph_files.push(discovered(3, root.join("src/d.ts"), 10));
1065 imports.push(import_of(FileId(3), "./d"));
1066 }
1067 let resolved = vec![ResolvedModule {
1068 file_id: FileId(0),
1069 path: a.clone(),
1070 resolved_imports: imports,
1071 ..ResolvedModule::default()
1072 }];
1073 let entry_points = vec![EntryPoint {
1074 path: a.clone(),
1075 source: EntryPointSource::PackageJsonMain,
1076 }];
1077 let graph = crate::module_graph::RetainedModuleGraph::from(ModuleGraph::build(
1078 &resolved,
1079 &entry_points,
1080 &graph_files,
1081 ));
1082
1083 let results = AnalysisResults {
1084 circular_dependencies: vec![
1085 cycle_finding(vec![a.clone(), b]),
1086 cycle_finding(vec![a.clone(), missing.clone()]),
1087 ],
1088 boundary_violations: vec![
1089 violation_finding(a.clone(), c.clone()),
1090 violation_finding(a.clone(), missing),
1091 ],
1092 ..AnalysisResults::default()
1093 };
1094
1095 let duplication = DuplicationReport {
1096 clone_groups: vec![
1097 clone_group(vec![
1098 clone_instance(a.clone(), 1, 3),
1099 clone_instance(c, 10, 12),
1100 ]),
1101 clone_group(vec![
1102 clone_instance(a.clone(), 20, 22),
1103 clone_instance(root.join("outside.ts"), 1, 3),
1104 ]),
1105 clone_group(vec![
1106 clone_instance(a.clone(), 30, 32),
1107 clone_instance(a, 40, 42),
1108 ]),
1109 ],
1110 ..DuplicationReport::default()
1111 };
1112
1113 let workspaces = vec![WorkspaceInfo {
1114 root: root.join("lib"),
1115 name: "shared-lib".to_owned(),
1116 is_internal_dependency: false,
1117 }];
1118
1119 Fixture {
1120 config: resolved_config(&root),
1121 files,
1122 results,
1123 graph,
1124 duplication,
1125 workspaces,
1126 }
1127 }
1128
1129 fn fixture() -> Fixture {
1130 fixture_with(false)
1131 }
1132
1133 #[test]
1134 fn files_and_edges_use_stable_indices() {
1135 let fx = fixture();
1136 let data = build_viz_data(&fx.input());
1137
1138 let paths: Vec<&str> = data.files.iter().map(|f| f.path.as_str()).collect();
1139 assert_eq!(paths, ["src/a.ts", "src/b.ts", "lib/c.ts"]);
1140 assert_eq!(data.edges, vec![[0, 1, 0]]);
1141 assert!(data.files[0].is_entry);
1142 assert!(matches!(data.files[0].status, VizFileStatus::EntryPoint));
1143 assert!(matches!(data.files[1].status, VizFileStatus::Clean));
1144 assert_eq!(data.files[0].import_count, 1);
1145 assert_eq!(data.files[1].importer_count, 1);
1146 assert_eq!(data.files[0].workspace, None);
1147 assert_eq!(data.files[2].workspace, Some(0));
1148 assert_eq!(data.workspaces.len(), 1);
1149 assert_eq!(data.workspaces[0].root, "lib");
1150 }
1151
1152 #[test]
1153 fn edges_to_files_missing_from_input_are_dropped() {
1154 let fx = fixture_with(true);
1155 let data = build_viz_data(&fx.input());
1156
1157 assert_eq!(fx.graph.edge_count(), 2);
1161 assert_eq!(data.edges, vec![[0, 1, 0]]);
1162 }
1163
1164 #[test]
1165 fn clone_groups_drop_unresolvable_and_dedup_per_file() {
1166 let fx = fixture();
1167 let data = build_viz_data(&fx.input());
1168
1169 assert_eq!(data.clones.len(), 2);
1172 assert_eq!(data.clones[0].instances.len(), 2);
1173 assert_eq!(data.clones[0].instances[0].file, 0);
1174 assert_eq!(data.clones[0].instances[1].file, 2);
1175 assert_eq!(data.clones[0].lines, 3);
1176 assert_eq!(data.clones[0].tokens, 12);
1177 assert_eq!(data.files[0].clone_groups, vec![0, 1]);
1179 assert_eq!(data.files[2].clone_groups, vec![0]);
1180 assert_eq!(data.files[0].dup_lines, 9);
1182 assert_eq!(data.files[2].dup_lines, 3);
1183 assert_eq!(data.files[1].dup_lines, 0);
1184 }
1185
1186 #[test]
1187 fn truncate_preview_caps_lines_and_bytes() {
1188 let last_kept = CLONE_PREVIEW_MAX_LINES - 1;
1191 let many_lines = (0..CLONE_PREVIEW_MAX_LINES + 5)
1192 .map(|i| format!("line {i}"))
1193 .collect::<Vec<_>>();
1194 let out = truncate_preview(&many_lines.join("\n"));
1195 assert_eq!(out.matches('\n').count(), CLONE_PREVIEW_MAX_LINES - 1);
1196 assert!(out.contains(&format!("line {last_kept}")));
1197 assert!(!out.contains(&format!("line {CLONE_PREVIEW_MAX_LINES}")));
1198 assert!(out.ends_with('\u{2026}'));
1199
1200 let big = CLONE_PREVIEW_MAX_BYTES * 3 / 4;
1203 let two_long_lines = format!("{}\n{}", "a".repeat(big), "b".repeat(big));
1204 let out = truncate_preview(&two_long_lines);
1205 assert_eq!(out, format!("{}\u{2026}", "a".repeat(big)));
1206
1207 let emoji_line = "\u{1f389}".repeat(CLONE_PREVIEW_MAX_BYTES);
1210 let out = truncate_preview(&emoji_line);
1211 assert_eq!(out, "\u{2026}");
1212 }
1213
1214 #[test]
1215 fn clone_preview_windows_context_around_the_block() {
1216 use std::io::Write as _;
1217
1218 let mut file = tempfile::NamedTempFile::new().expect("temp file");
1220 let body = (1..=20)
1221 .map(|i| format!("line {i}"))
1222 .collect::<Vec<_>>()
1223 .join("\n");
1224 file.write_all(body.as_bytes()).expect("write source");
1225 let inst = clone_instance(file.path().to_path_buf(), 8, 11);
1226
1227 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1228 let preview_lines: Vec<&str> = preview.lines().collect();
1229
1230 assert_eq!(preview_lines.len(), 12);
1233 assert_eq!(highlight_start, 4);
1234 assert_eq!(highlight_lines, 4);
1235 assert_eq!(preview_lines.first(), Some(&"line 4"));
1236 let start = highlight_start as usize;
1237 let end = start + highlight_lines as usize;
1238 assert_eq!(
1239 &preview_lines[start..end],
1240 ["line 8", "line 9", "line 10", "line 11"],
1241 );
1242 assert_eq!(preview_lines[start - 1], "line 7");
1244 }
1245
1246 #[test]
1247 fn clone_preview_keeps_leading_context_when_the_block_fills_the_cap() {
1248 use std::io::Write as _;
1249
1250 let mut file = tempfile::NamedTempFile::new().expect("temp file");
1254 let body = (1..=200)
1255 .map(|i| format!("line {i}"))
1256 .collect::<Vec<_>>()
1257 .join("\n");
1258 file.write_all(body.as_bytes()).expect("write source");
1259 let inst = clone_instance(file.path().to_path_buf(), 50, 150);
1260
1261 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1262 let preview_lines: Vec<&str> = preview.lines().collect();
1263
1264 assert_eq!(highlight_start, CLONE_PREVIEW_CONTEXT as u32);
1265 assert!(
1266 highlight_start > 0,
1267 "leading context must survive a huge block"
1268 );
1269 assert_eq!(preview_lines.len(), CLONE_PREVIEW_MAX_LINES);
1270 assert_eq!(
1271 highlight_lines as usize,
1272 CLONE_PREVIEW_MAX_LINES - CLONE_PREVIEW_CONTEXT,
1273 );
1274 assert_eq!(preview_lines[highlight_start as usize - 1], "line 49");
1275 assert_eq!(preview_lines[highlight_start as usize], "line 50");
1276 }
1277
1278 #[test]
1279 fn clone_preview_clamps_context_at_file_start() {
1280 use std::io::Write as _;
1281
1282 let mut file = tempfile::NamedTempFile::new().expect("temp file");
1283 file.write_all(b"line 1\nline 2\nline 3\nline 4\nline 5")
1284 .expect("write source");
1285 let inst = clone_instance(file.path().to_path_buf(), 1, 2);
1288
1289 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1290 assert_eq!(highlight_start, 0);
1291 assert_eq!(highlight_lines, 2);
1292 assert_eq!(preview, "line 1\nline 2\nline 3\nline 4\nline 5");
1293 }
1294
1295 #[test]
1296 fn clone_preview_falls_back_when_source_is_unreadable() {
1297 let inst = clone_instance(project_root().join("does-not-exist.ts"), 1, 3);
1300 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1301 assert_eq!(preview, inst.fragment);
1302 assert_eq!(highlight_start, 0);
1303 assert_eq!(highlight_lines as usize, preview.lines().count());
1304 }
1305
1306 #[test]
1307 fn cycles_drop_when_any_member_unresolved() {
1308 let fx = fixture();
1309 let data = build_viz_data(&fx.input());
1310
1311 assert_eq!(data.cycles, vec![vec![0, 1]]);
1314 assert!(data.files[0].in_cycle);
1315 assert!(data.files[1].in_cycle);
1316 assert!(!data.files[2].in_cycle);
1317 assert_eq!(data.summary.circular_deps, data.cycles.len());
1320 }
1321
1322 #[test]
1323 fn violations_resolve_zone_and_file_indices() {
1324 let fx = fixture();
1325 let data = build_viz_data(&fx.input());
1326
1327 assert_eq!(data.zones.len(), 2);
1328 assert_eq!(data.zones[0].name, "app");
1329 assert_eq!(data.zones[0].files, 2);
1330 assert_eq!(data.zones[1].name, "shared");
1331 assert_eq!(data.zones[1].files, 1);
1332 assert_eq!(data.files[0].zone, Some(0));
1333 assert_eq!(data.files[1].zone, Some(0));
1334 assert_eq!(data.files[2].zone, Some(1));
1335
1336 assert_eq!(data.violations.len(), 1);
1338 let v = &data.violations[0];
1339 assert_eq!((v.from, v.to), (0, 2));
1340 assert_eq!((v.from_zone, v.to_zone), (0, 1));
1341 assert_eq!(v.line, 2);
1342 assert_eq!(v.specifier, "../lib/c");
1343 }
1344
1345 #[test]
1346 fn clone_group_cap_counts_truncated_groups() {
1347 let fx = fixture();
1348 let index = FileIndex::new(&fx.files);
1349
1350 let (clones, groups_by_file, _dup_lines, truncated) =
1355 build_clones(&fx.duplication, &index, 1);
1356 assert_eq!(clones.len(), 1);
1357 assert_eq!(truncated, 1);
1358 assert!(
1359 groups_by_file
1360 .values()
1361 .all(|ids| ids.iter().all(|&id| (id as usize) < clones.len()))
1362 );
1363
1364 let data = build_viz_data(&fx.input());
1366 assert_eq!(data.clones.len(), 2);
1367 assert_eq!(data.summary.clone_groups_truncated, None);
1368 }
1369
1370 #[test]
1371 fn summary_flags_clone_truncation_only_when_nonzero() {
1372 let fx = fixture();
1373 let data = build_viz_data(&fx.input());
1374
1375 let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 3);
1376 assert_eq!(summary.clone_groups_truncated, Some(3));
1377 let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 0);
1378 assert_eq!(summary.clone_groups_truncated, None);
1379 }
1380
1381 #[test]
1382 fn summary_counts_match_rendered_arrays() {
1383 let fx = fixture();
1384 let data = build_viz_data(&fx.input());
1385 let s = &data.summary;
1386
1387 assert_eq!(s.total_files, data.files.len());
1388 assert_eq!(s.total_size, 175);
1389 assert_eq!(s.total_edges, data.edges.len());
1390 assert_eq!(s.clone_groups, data.clones.len());
1391 assert_eq!(s.duplicated_lines, 12);
1392 assert_eq!(s.hotspot_files, 0);
1393 assert_eq!(s.unused_files, 0);
1394 assert_eq!(s.unused_exports, 0);
1395 assert_eq!(s.circular_deps, data.cycles.len());
1398 assert_eq!(s.circular_deps, 1);
1399 assert_eq!(s.boundary_violations, data.violations.len());
1400 assert_eq!(s.boundary_violations, 1);
1401 }
1402}