use std::path::Path;
use rustc_hash::FxHashMap;
use serde::Serialize;
use fallow_config::{ResolvedConfig, WorkspaceInfo};
use fallow_types::discover::DiscoveredFile;
use fallow_types::duplicates::{CloneInstance, DuplicationReport};
use fallow_types::extract::{FunctionComplexity, ModuleInfo};
use fallow_types::results::AnalysisResults;
use crate::module_graph::RetainedModuleGraph;
const HOTSPOT_CYCLOMATIC_FLOOR: u16 = 10;
const CLONE_PREVIEW_MAX_BYTES: usize = 2000;
const CLONE_PREVIEW_MAX_LINES: usize = 32;
const CLONE_PREVIEW_CONTEXT: usize = 4;
const MAX_CLONE_GROUPS: usize = 500;
const EDGE_FLAG_TYPE_ONLY: u32 = 1;
pub struct VizBuildInput<'a> {
pub results: &'a AnalysisResults,
pub graph: &'a RetainedModuleGraph,
pub modules: Option<&'a [ModuleInfo]>,
pub files: &'a [DiscoveredFile],
pub duplication: &'a DuplicationReport,
pub workspaces: &'a [WorkspaceInfo],
pub config: &'a ResolvedConfig,
}
#[derive(Serialize)]
pub struct VizData {
pub root: String,
pub files: Vec<VizFile>,
pub edges: Vec<[u32; 3]>,
pub summary: VizSummary,
pub workspaces: Vec<VizWorkspace>,
pub zones: Vec<VizZone>,
pub cycles: Vec<Vec<u32>>,
pub clones: Vec<VizCloneGroup>,
pub violations: Vec<VizViolation>,
}
#[derive(Serialize)]
pub struct VizFile {
pub path: String,
pub size: u64,
pub status: VizFileStatus,
pub export_count: u16,
pub unused_export_count: u16,
pub is_entry: bool,
pub importer_count: u16,
pub import_count: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zone: Option<u16>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unused_exports: Vec<String>,
pub fn_count: u16,
pub max_cyclomatic: u16,
pub max_cognitive: u16,
pub react_hooks: u16,
pub jsx_depth: u16,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub functions: Vec<VizFunction>,
pub dup_lines: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub clone_groups: Vec<u32>,
pub in_cycle: bool,
}
#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum VizFileStatus {
Clean,
HasUnusedExports,
Unused,
EntryPoint,
}
#[derive(Serialize)]
pub struct VizFunction {
name: String,
line: u32,
cyclomatic: u16,
cognitive: u16,
lines: u32,
hooks: u16,
jsx_depth: u16,
props: u16,
}
#[derive(Serialize)]
pub struct VizSummary {
pub total_files: usize,
pub total_size: u64,
pub total_edges: usize,
pub unused_files: usize,
pub unused_exports: usize,
pub unused_types: usize,
pub unused_deps: usize,
pub unresolved_imports: usize,
pub circular_deps: usize,
pub clone_groups: usize,
pub duplicated_lines: usize,
pub boundary_violations: usize,
pub hotspot_files: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub clone_groups_truncated: Option<u32>,
}
#[derive(Serialize)]
pub struct VizWorkspace {
name: String,
root: String,
}
#[derive(Serialize)]
pub struct VizZone {
name: String,
files: u32,
}
#[derive(Serialize)]
pub struct VizCloneGroup {
lines: usize,
tokens: usize,
instances: Vec<VizCloneInstance>,
preview: String,
highlight_start: u32,
highlight_lines: u32,
}
#[derive(Serialize)]
pub struct VizCloneInstance {
file: u32,
start_line: u32,
end_line: u32,
}
#[derive(Serialize)]
pub struct VizViolation {
from: u32,
to: u32,
from_zone: u16,
to_zone: u16,
line: u32,
specifier: String,
}
#[must_use]
pub fn build_viz_data(input: &VizBuildInput<'_>) -> VizData {
let root = &input.config.root;
let index = FileIndex::new(input.files);
let workspaces = build_workspaces(input.workspaces, root);
let (zones, zone_by_file) = classify_zones(input, &index);
let (clones, clone_groups_by_file, dup_lines_by_file, clone_groups_truncated) =
build_clones(input.duplication, &index, MAX_CLONE_GROUPS);
let cycles = build_cycles(input.results, &index);
let violations = build_violations(input.results, &zones, &index);
let files = build_files(
input,
&index,
&FilePropertyMaps {
zone_by_file: &zone_by_file,
clone_groups_by_file: &clone_groups_by_file,
dup_lines_by_file: &dup_lines_by_file,
cycles: &cycles,
},
);
let summary = build_summary(
input,
&files,
&clones,
&cycles,
&violations,
clone_groups_truncated,
);
VizData {
root: display_root(root),
files,
edges: build_edges(input.graph, &index),
summary,
workspaces,
zones,
cycles,
clones,
violations,
}
}
struct FileIndex<'a> {
ordered: Vec<&'a DiscoveredFile>,
by_path: FxHashMap<&'a Path, u32>,
by_file_id: FxHashMap<u32, u32>,
}
impl<'a> FileIndex<'a> {
fn new(files: &'a [DiscoveredFile]) -> Self {
let mut ordered: Vec<&DiscoveredFile> = files.iter().collect();
ordered.sort_by_key(|f| f.id.0);
let mut by_path = FxHashMap::default();
let mut by_file_id = FxHashMap::default();
for (i, f) in ordered.iter().enumerate() {
let idx = clamp_u32(i);
by_path.insert(f.path.as_path(), idx);
by_file_id.insert(f.id.0, idx);
}
Self {
ordered,
by_path,
by_file_id,
}
}
fn index_of_path(&self, path: &Path) -> Option<u32> {
self.by_path.get(path).copied()
}
fn index_of_file_id(&self, file_id: u32) -> Option<u32> {
self.by_file_id.get(&file_id).copied()
}
}
struct FilePropertyMaps<'a> {
zone_by_file: &'a FxHashMap<u32, u16>,
clone_groups_by_file: &'a FxHashMap<u32, Vec<u32>>,
dup_lines_by_file: &'a FxHashMap<u32, u32>,
cycles: &'a [Vec<u32>],
}
fn display_root(root: &Path) -> String {
root.file_name().map_or_else(
|| root.to_string_lossy().into_owned(),
|n| n.to_string_lossy().into_owned(),
)
}
fn relative_path(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn build_workspaces(workspaces: &[WorkspaceInfo], root: &Path) -> Vec<VizWorkspace> {
workspaces
.iter()
.map(|ws| VizWorkspace {
name: ws.name.clone(),
root: relative_path(&ws.root, root),
})
.collect()
}
fn workspace_index_for(path: &Path, workspaces: &[WorkspaceInfo]) -> Option<u16> {
let mut best: Option<(usize, usize)> = None;
for (i, ws) in workspaces.iter().enumerate() {
if path.starts_with(&ws.root) {
let depth = ws.root.components().count();
if best.is_none_or(|(_, d)| depth > d) {
best = Some((i, depth));
}
}
}
best.map(|(i, _)| clamp_u16(i))
}
fn classify_zones(
input: &VizBuildInput<'_>,
index: &FileIndex<'_>,
) -> (Vec<VizZone>, FxHashMap<u32, u16>) {
let boundaries = &input.config.boundaries;
let mut zones: Vec<VizZone> = boundaries
.zones
.iter()
.map(|z| VizZone {
name: z.name.clone(),
files: 0,
})
.collect();
let name_to_index: FxHashMap<&str, u16> = boundaries
.zones
.iter()
.enumerate()
.map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
.collect();
let mut zone_by_file = FxHashMap::default();
if zones.is_empty() {
return (zones, zone_by_file);
}
for (i, file) in index.ordered.iter().enumerate() {
let rel = relative_path(&file.path, &input.config.root);
if let Some(zone_name) = boundaries.classify_zone(&rel)
&& let Some(&zone_idx) = name_to_index.get(zone_name)
{
zone_by_file.insert(clamp_u32(i), zone_idx);
zones[zone_idx as usize].files += 1;
}
}
(zones, zone_by_file)
}
type CloneMaps = (
Vec<VizCloneGroup>,
FxHashMap<u32, Vec<u32>>,
FxHashMap<u32, u32>,
u32,
);
fn build_clones(
duplication: &DuplicationReport,
index: &FileIndex<'_>,
max_groups: usize,
) -> CloneMaps {
let mut clones = Vec::new();
let mut groups_by_file: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
let mut dup_lines_by_file: FxHashMap<u32, u32> = FxHashMap::default();
let mut truncated: usize = 0;
for group in &duplication.clone_groups {
let instances: Vec<VizCloneInstance> = group
.instances
.iter()
.filter_map(|inst| {
index
.index_of_path(&inst.file)
.map(|file| VizCloneInstance {
file,
start_line: clamp_u32(inst.start_line),
end_line: clamp_u32(inst.end_line),
})
})
.collect();
if instances.len() < 2 {
continue;
}
if clones.len() >= max_groups {
truncated += 1;
continue;
}
let group_idx = clamp_u32(clones.len());
for inst in &instances {
let entry = groups_by_file.entry(inst.file).or_default();
if entry.last() != Some(&group_idx) {
entry.push(group_idx);
}
*dup_lines_by_file.entry(inst.file).or_default() +=
inst.end_line.saturating_sub(inst.start_line) + 1;
}
let (preview, highlight_start, highlight_lines) = group
.instances
.first()
.map(build_clone_preview)
.unwrap_or_default();
clones.push(VizCloneGroup {
lines: group.line_count,
tokens: group.token_count,
instances,
preview,
highlight_start,
highlight_lines,
});
}
(
clones,
groups_by_file,
dup_lines_by_file,
clamp_u32(truncated),
)
}
fn truncate_preview(fragment: &str) -> String {
let mut out = String::new();
for (i, line) in fragment.lines().enumerate() {
if i >= CLONE_PREVIEW_MAX_LINES || out.len() + line.len() > CLONE_PREVIEW_MAX_BYTES {
out.push('\u{2026}');
break;
}
if i > 0 {
out.push('\n');
}
out.push_str(line);
}
out
}
fn build_clone_preview(inst: &CloneInstance) -> (String, u32, u32) {
let Ok(source) = std::fs::read_to_string(&inst.file) else {
return fragment_fallback(&inst.fragment);
};
let lines: Vec<&str> = source.lines().collect();
let total = lines.len();
if total == 0 || inst.start_line == 0 || inst.start_line > total {
return fragment_fallback(&inst.fragment);
}
let block_start = inst.start_line - 1;
let block_end = inst.end_line.min(total).max(inst.start_line);
let mut block_lines = block_end - block_start;
let mut before = block_start.min(CLONE_PREVIEW_CONTEXT);
let mut after = (total - block_end).min(CLONE_PREVIEW_CONTEXT);
if before + block_lines + after > CLONE_PREVIEW_MAX_LINES {
if before + block_lines >= CLONE_PREVIEW_MAX_LINES {
after = 0;
block_lines = CLONE_PREVIEW_MAX_LINES.saturating_sub(before).max(1);
} else {
trim_context(
&mut before,
&mut after,
CLONE_PREVIEW_MAX_LINES - block_lines,
);
}
}
enforce_byte_cap(
&lines,
block_start,
&mut before,
&mut after,
&mut block_lines,
);
let win_start = block_start - before;
let win_end = win_start + before + block_lines + after;
let preview = lines[win_start..win_end].join("\n");
(preview, clamp_u32(before), clamp_u32(block_lines))
}
fn fragment_fallback(fragment: &str) -> (String, u32, u32) {
let preview = truncate_preview(fragment);
let highlight_lines = if preview.is_empty() {
0
} else {
preview.lines().count()
};
(preview, 0, clamp_u32(highlight_lines))
}
fn trim_context(before: &mut usize, after: &mut usize, budget: usize) {
while *before + *after > budget {
if *before >= *after {
*before -= 1;
} else {
*after -= 1;
}
}
}
fn enforce_byte_cap(
lines: &[&str],
block_start: usize,
before: &mut usize,
after: &mut usize,
block_lines: &mut usize,
) {
let window_bytes = |before: usize, after: usize, block_lines: usize| -> usize {
let start = block_start - before;
let end = start + before + block_lines + after;
let separators = (end - start).saturating_sub(1);
lines[start..end].iter().map(|l| l.len()).sum::<usize>() + separators
};
while window_bytes(*before, *after, *block_lines) > CLONE_PREVIEW_MAX_BYTES {
if *before + *after > 0 {
if *before >= *after {
*before -= 1;
} else {
*after -= 1;
}
} else if *block_lines > 1 {
*block_lines -= 1;
} else {
break;
}
}
}
fn build_cycles(results: &AnalysisResults, index: &FileIndex<'_>) -> Vec<Vec<u32>> {
results
.circular_dependencies
.iter()
.filter_map(|cd| {
let ids: Vec<u32> = cd
.cycle
.files
.iter()
.filter_map(|p| index.index_of_path(p))
.collect();
(ids.len() == cd.cycle.files.len()).then_some(ids)
})
.collect()
}
fn build_violations(
results: &AnalysisResults,
zones: &[VizZone],
index: &FileIndex<'_>,
) -> Vec<VizViolation> {
let name_to_index: FxHashMap<&str, u16> = zones
.iter()
.enumerate()
.map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
.collect();
results
.boundary_violations
.iter()
.filter_map(|finding| {
let v = &finding.violation;
let from = index.index_of_path(&v.from_path)?;
let to = index.index_of_path(&v.to_path)?;
let from_zone = *name_to_index.get(v.from_zone.as_str())?;
let to_zone = *name_to_index.get(v.to_zone.as_str())?;
Some(VizViolation {
from,
to,
from_zone,
to_zone,
line: v.line,
specifier: v.import_specifier.clone(),
})
})
.collect()
}
fn build_edges(graph: &RetainedModuleGraph, index: &FileIndex<'_>) -> Vec<[u32; 3]> {
let graph = graph.as_graph();
let mut edges = Vec::with_capacity(graph.edge_count());
for node in &graph.modules {
let Some(source) = index.index_of_file_id(node.file_id.0) else {
continue;
};
for (target_id, all_type_only, _span) in graph.outgoing_edge_summaries(node.file_id) {
let Some(target) = index.index_of_file_id(target_id.0) else {
continue;
};
let flags = if all_type_only {
EDGE_FLAG_TYPE_ONLY
} else {
0
};
edges.push([source, target, flags]);
}
}
edges
}
#[derive(Default)]
struct ComplexityRollup {
fn_count: u16,
max_cyclomatic: u16,
max_cognitive: u16,
react_hooks: u16,
jsx_depth: u16,
functions: Vec<VizFunction>,
}
fn rollup_complexity(functions: &[FunctionComplexity]) -> ComplexityRollup {
let mut rollup = ComplexityRollup {
fn_count: clamp_u16(functions.len()),
..ComplexityRollup::default()
};
for f in functions {
rollup.max_cyclomatic = rollup.max_cyclomatic.max(f.cyclomatic);
rollup.max_cognitive = rollup.max_cognitive.max(f.cognitive);
rollup.react_hooks = rollup.react_hooks.saturating_add(f.react_hook_count);
rollup.jsx_depth = rollup.jsx_depth.max(f.react_jsx_max_depth);
}
let mut named: Vec<&FunctionComplexity> = functions
.iter()
.filter(|f| !f.name.starts_with('<'))
.collect();
named.sort_by(|a, b| {
b.cyclomatic
.cmp(&a.cyclomatic)
.then(b.cognitive.cmp(&a.cognitive))
});
rollup.functions = named
.into_iter()
.map(|f| VizFunction {
name: f.name.clone(),
line: f.line,
cyclomatic: f.cyclomatic,
cognitive: f.cognitive,
lines: f.line_count,
hooks: f.react_hook_count,
jsx_depth: f.react_jsx_max_depth,
props: f.react_prop_count,
})
.collect();
rollup
}
fn build_files(
input: &VizBuildInput<'_>,
index: &FileIndex<'_>,
maps: &FilePropertyMaps<'_>,
) -> Vec<VizFile> {
let graph = input.graph.as_graph();
let unused_file_paths: rustc_hash::FxHashSet<&Path> = input
.results
.unused_files
.iter()
.map(|f| f.file.path.as_path())
.collect();
let mut unused_exports_by_file: FxHashMap<&Path, Vec<String>> = FxHashMap::default();
for export in &input.results.unused_exports {
unused_exports_by_file
.entry(export.export.path.as_path())
.or_default()
.push(export.export.export_name.clone());
}
for export in &input.results.unused_types {
unused_exports_by_file
.entry(export.export.path.as_path())
.or_default()
.push(export.export.export_name.clone());
}
let mut complexity_by_file_id: FxHashMap<u32, ComplexityRollup> = FxHashMap::default();
if let Some(modules) = input.modules {
for module in modules {
if !module.complexity.is_empty() {
complexity_by_file_id
.insert(module.file_id.0, rollup_complexity(&module.complexity));
}
}
}
let mut in_cycle = vec![false; index.ordered.len()];
for cycle in maps.cycles {
for &idx in cycle {
if let Some(slot) = in_cycle.get_mut(idx as usize) {
*slot = true;
}
}
}
index
.ordered
.iter()
.enumerate()
.map(|(i, file)| {
let viz_idx = clamp_u32(i);
let node_idx = file.id.0 as usize;
let node = graph.modules.get(node_idx);
let is_entry = node.is_some_and(|n| n.is_entry_point());
let export_count = node.map_or(0, |n| clamp_u16(n.exports.len()));
let import_count = clamp_u16(graph.edges_for(file.id).len());
let importer_count = clamp_u16(input.graph.direct_importer_count(file.id));
let unused_export_names = unused_exports_by_file
.remove(file.path.as_path())
.unwrap_or_default();
let unused_export_count = clamp_u16(unused_export_names.len());
let status = if unused_file_paths.contains(file.path.as_path()) {
VizFileStatus::Unused
} else if unused_export_count > 0 {
VizFileStatus::HasUnusedExports
} else if is_entry {
VizFileStatus::EntryPoint
} else {
VizFileStatus::Clean
};
let complexity = complexity_by_file_id.remove(&file.id.0).unwrap_or_default();
VizFile {
path: relative_path(&file.path, &input.config.root),
size: file.size_bytes,
status,
export_count,
unused_export_count,
is_entry,
importer_count,
import_count,
workspace: workspace_index_for(&file.path, input.workspaces),
zone: maps.zone_by_file.get(&viz_idx).copied(),
unused_exports: unused_export_names,
fn_count: complexity.fn_count,
max_cyclomatic: complexity.max_cyclomatic,
max_cognitive: complexity.max_cognitive,
react_hooks: complexity.react_hooks,
jsx_depth: complexity.jsx_depth,
functions: complexity.functions,
dup_lines: maps.dup_lines_by_file.get(&viz_idx).copied().unwrap_or(0),
clone_groups: maps
.clone_groups_by_file
.get(&viz_idx)
.cloned()
.unwrap_or_default(),
in_cycle: in_cycle[i],
}
})
.collect()
}
fn build_summary(
input: &VizBuildInput<'_>,
files: &[VizFile],
clones: &[VizCloneGroup],
cycles: &[Vec<u32>],
violations: &[VizViolation],
clone_groups_truncated: u32,
) -> VizSummary {
let results = input.results;
VizSummary {
total_files: files.len(),
total_size: files.iter().map(|f| f.size).sum(),
total_edges: input.graph.edge_count(),
unused_files: results.unused_files.len(),
unused_exports: results.unused_exports.len() + results.unused_types.len(),
unused_types: results.unused_types.len(),
unused_deps: results.unused_dependencies.len()
+ results.unused_dev_dependencies.len()
+ results.unused_optional_dependencies.len(),
unresolved_imports: results.unresolved_imports.len(),
circular_deps: cycles.len(),
clone_groups: clones.len(),
duplicated_lines: clones.iter().map(|c| c.lines * c.instances.len()).sum(),
boundary_violations: violations.len(),
hotspot_files: files
.iter()
.filter(|f| f.max_cyclomatic >= HOTSPOT_CYCLOMATIC_FLOOR)
.count(),
clone_groups_truncated: (clone_groups_truncated > 0).then_some(clone_groups_truncated),
}
}
fn clamp_u16(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}
fn clamp_u32(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fallow_config::{BoundaryConfig, BoundaryZone, FallowConfig};
use fallow_graph::graph::ModuleGraph;
use fallow_graph::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
use fallow_types::duplicates::{CloneGroup, CloneInstance};
use fallow_types::extract::{ImportInfo, ImportedName};
use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
use fallow_types::output_format::OutputFormat;
use fallow_types::results::{BoundaryViolation, CircularDependency};
use super::*;
use crate::discover::{EntryPoint, EntryPointSource, FileId};
struct Fixture {
config: ResolvedConfig,
files: Vec<DiscoveredFile>,
results: AnalysisResults,
graph: crate::module_graph::RetainedModuleGraph,
duplication: DuplicationReport,
workspaces: Vec<WorkspaceInfo>,
}
impl Fixture {
fn input(&self) -> VizBuildInput<'_> {
VizBuildInput {
results: &self.results,
graph: &self.graph,
modules: None,
files: &self.files,
duplication: &self.duplication,
workspaces: &self.workspaces,
config: &self.config,
}
}
}
fn project_root() -> PathBuf {
PathBuf::from("/viz-project")
}
fn discovered(id: u32, path: PathBuf, size_bytes: u64) -> DiscoveredFile {
DiscoveredFile {
id: FileId(id),
path,
size_bytes,
}
}
fn import_of(target: FileId, specifier: &str) -> ResolvedImport {
ResolvedImport {
info: ImportInfo {
source: specifier.to_owned(),
imported_name: ImportedName::Named("value".to_owned()),
local_name: "value".to_owned(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 0),
source_span: oxc_span::Span::new(0, 0),
},
target: ResolveResult::InternalModule(target),
}
}
fn zone(name: &str, pattern: &str) -> BoundaryZone {
BoundaryZone {
name: name.to_owned(),
patterns: vec![pattern.to_owned()],
auto_discover: Vec::new(),
root: None,
}
}
fn resolved_config(root: &Path) -> ResolvedConfig {
let config = FallowConfig {
boundaries: BoundaryConfig {
zones: vec![zone("app", "src/**"), zone("shared", "lib/**")],
..BoundaryConfig::default()
},
..FallowConfig::default()
};
config.resolve(root.to_path_buf(), OutputFormat::Json, 1, false, true, None)
}
fn cycle_finding(files: Vec<PathBuf>) -> CircularDependencyFinding {
let length = files.len();
CircularDependencyFinding::with_actions(CircularDependency {
files,
length,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
})
}
fn violation_finding(from_path: PathBuf, to_path: PathBuf) -> BoundaryViolationFinding {
BoundaryViolationFinding::with_actions(BoundaryViolation {
from_path,
to_path,
from_zone: "app".to_owned(),
to_zone: "shared".to_owned(),
import_specifier: "../lib/c".to_owned(),
line: 2,
col: 0,
})
}
fn clone_instance(file: PathBuf, start_line: usize, end_line: usize) -> CloneInstance {
CloneInstance {
file,
start_line,
end_line,
start_col: 0,
end_col: 0,
fragment: "const shared = 1;\nconst repeated = 2;\nconst block = 3;".to_owned(),
}
}
fn clone_group(instances: Vec<CloneInstance>) -> CloneGroup {
CloneGroup {
instances,
token_count: 12,
line_count: 3,
similarity: None,
}
}
fn fixture_with(extra_graph_file: bool) -> Fixture {
let root = project_root();
let a = root.join("src/a.ts");
let b = root.join("src/b.ts");
let c = root.join("lib/c.ts");
let missing = root.join("src/missing.ts");
let files = vec![
discovered(0, a.clone(), 100),
discovered(1, b.clone(), 50),
discovered(2, c.clone(), 25),
];
let mut graph_files = files.clone();
let mut imports = vec![import_of(FileId(1), "./b")];
if extra_graph_file {
graph_files.push(discovered(3, root.join("src/d.ts"), 10));
imports.push(import_of(FileId(3), "./d"));
}
let resolved = vec![ResolvedModule {
file_id: FileId(0),
path: a.clone(),
resolved_imports: imports,
..ResolvedModule::default()
}];
let entry_points = vec![EntryPoint {
path: a.clone(),
source: EntryPointSource::PackageJsonMain,
}];
let graph = crate::module_graph::RetainedModuleGraph::from(ModuleGraph::build(
&resolved,
&entry_points,
&graph_files,
));
let results = AnalysisResults {
circular_dependencies: vec![
cycle_finding(vec![a.clone(), b]),
cycle_finding(vec![a.clone(), missing.clone()]),
],
boundary_violations: vec![
violation_finding(a.clone(), c.clone()),
violation_finding(a.clone(), missing),
],
..AnalysisResults::default()
};
let duplication = DuplicationReport {
clone_groups: vec![
clone_group(vec![
clone_instance(a.clone(), 1, 3),
clone_instance(c, 10, 12),
]),
clone_group(vec![
clone_instance(a.clone(), 20, 22),
clone_instance(root.join("outside.ts"), 1, 3),
]),
clone_group(vec![
clone_instance(a.clone(), 30, 32),
clone_instance(a, 40, 42),
]),
],
..DuplicationReport::default()
};
let workspaces = vec![WorkspaceInfo {
root: root.join("lib"),
name: "shared-lib".to_owned(),
is_internal_dependency: false,
}];
Fixture {
config: resolved_config(&root),
files,
results,
graph,
duplication,
workspaces,
}
}
fn fixture() -> Fixture {
fixture_with(false)
}
#[test]
fn files_and_edges_use_stable_indices() {
let fx = fixture();
let data = build_viz_data(&fx.input());
let paths: Vec<&str> = data.files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, ["src/a.ts", "src/b.ts", "lib/c.ts"]);
assert_eq!(data.edges, vec![[0, 1, 0]]);
assert!(data.files[0].is_entry);
assert!(matches!(data.files[0].status, VizFileStatus::EntryPoint));
assert!(matches!(data.files[1].status, VizFileStatus::Clean));
assert_eq!(data.files[0].import_count, 1);
assert_eq!(data.files[1].importer_count, 1);
assert_eq!(data.files[0].workspace, None);
assert_eq!(data.files[2].workspace, Some(0));
assert_eq!(data.workspaces.len(), 1);
assert_eq!(data.workspaces[0].root, "lib");
}
#[test]
fn edges_to_files_missing_from_input_are_dropped() {
let fx = fixture_with(true);
let data = build_viz_data(&fx.input());
assert_eq!(fx.graph.edge_count(), 2);
assert_eq!(data.edges, vec![[0, 1, 0]]);
}
#[test]
fn clone_groups_drop_unresolvable_and_dedup_per_file() {
let fx = fixture();
let data = build_viz_data(&fx.input());
assert_eq!(data.clones.len(), 2);
assert_eq!(data.clones[0].instances.len(), 2);
assert_eq!(data.clones[0].instances[0].file, 0);
assert_eq!(data.clones[0].instances[1].file, 2);
assert_eq!(data.clones[0].lines, 3);
assert_eq!(data.clones[0].tokens, 12);
assert_eq!(data.files[0].clone_groups, vec![0, 1]);
assert_eq!(data.files[2].clone_groups, vec![0]);
assert_eq!(data.files[0].dup_lines, 9);
assert_eq!(data.files[2].dup_lines, 3);
assert_eq!(data.files[1].dup_lines, 0);
}
#[test]
fn truncate_preview_caps_lines_and_bytes() {
let last_kept = CLONE_PREVIEW_MAX_LINES - 1;
let many_lines = (0..CLONE_PREVIEW_MAX_LINES + 5)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>();
let out = truncate_preview(&many_lines.join("\n"));
assert_eq!(out.matches('\n').count(), CLONE_PREVIEW_MAX_LINES - 1);
assert!(out.contains(&format!("line {last_kept}")));
assert!(!out.contains(&format!("line {CLONE_PREVIEW_MAX_LINES}")));
assert!(out.ends_with('\u{2026}'));
let big = CLONE_PREVIEW_MAX_BYTES * 3 / 4;
let two_long_lines = format!("{}\n{}", "a".repeat(big), "b".repeat(big));
let out = truncate_preview(&two_long_lines);
assert_eq!(out, format!("{}\u{2026}", "a".repeat(big)));
let emoji_line = "\u{1f389}".repeat(CLONE_PREVIEW_MAX_BYTES);
let out = truncate_preview(&emoji_line);
assert_eq!(out, "\u{2026}");
}
#[test]
fn clone_preview_windows_context_around_the_block() {
use std::io::Write as _;
let mut file = tempfile::NamedTempFile::new().expect("temp file");
let body = (1..=20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
file.write_all(body.as_bytes()).expect("write source");
let inst = clone_instance(file.path().to_path_buf(), 8, 11);
let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
let preview_lines: Vec<&str> = preview.lines().collect();
assert_eq!(preview_lines.len(), 12);
assert_eq!(highlight_start, 4);
assert_eq!(highlight_lines, 4);
assert_eq!(preview_lines.first(), Some(&"line 4"));
let start = highlight_start as usize;
let end = start + highlight_lines as usize;
assert_eq!(
&preview_lines[start..end],
["line 8", "line 9", "line 10", "line 11"],
);
assert_eq!(preview_lines[start - 1], "line 7");
}
#[test]
fn clone_preview_keeps_leading_context_when_the_block_fills_the_cap() {
use std::io::Write as _;
let mut file = tempfile::NamedTempFile::new().expect("temp file");
let body = (1..=200)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
file.write_all(body.as_bytes()).expect("write source");
let inst = clone_instance(file.path().to_path_buf(), 50, 150);
let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
let preview_lines: Vec<&str> = preview.lines().collect();
assert_eq!(highlight_start, CLONE_PREVIEW_CONTEXT as u32);
assert!(
highlight_start > 0,
"leading context must survive a huge block"
);
assert_eq!(preview_lines.len(), CLONE_PREVIEW_MAX_LINES);
assert_eq!(
highlight_lines as usize,
CLONE_PREVIEW_MAX_LINES - CLONE_PREVIEW_CONTEXT,
);
assert_eq!(preview_lines[highlight_start as usize - 1], "line 49");
assert_eq!(preview_lines[highlight_start as usize], "line 50");
}
#[test]
fn clone_preview_clamps_context_at_file_start() {
use std::io::Write as _;
let mut file = tempfile::NamedTempFile::new().expect("temp file");
file.write_all(b"line 1\nline 2\nline 3\nline 4\nline 5")
.expect("write source");
let inst = clone_instance(file.path().to_path_buf(), 1, 2);
let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
assert_eq!(highlight_start, 0);
assert_eq!(highlight_lines, 2);
assert_eq!(preview, "line 1\nline 2\nline 3\nline 4\nline 5");
}
#[test]
fn clone_preview_falls_back_when_source_is_unreadable() {
let inst = clone_instance(project_root().join("does-not-exist.ts"), 1, 3);
let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
assert_eq!(preview, inst.fragment);
assert_eq!(highlight_start, 0);
assert_eq!(highlight_lines as usize, preview.lines().count());
}
#[test]
fn cycles_drop_when_any_member_unresolved() {
let fx = fixture();
let data = build_viz_data(&fx.input());
assert_eq!(data.cycles, vec![vec![0, 1]]);
assert!(data.files[0].in_cycle);
assert!(data.files[1].in_cycle);
assert!(!data.files[2].in_cycle);
assert_eq!(data.summary.circular_deps, data.cycles.len());
}
#[test]
fn violations_resolve_zone_and_file_indices() {
let fx = fixture();
let data = build_viz_data(&fx.input());
assert_eq!(data.zones.len(), 2);
assert_eq!(data.zones[0].name, "app");
assert_eq!(data.zones[0].files, 2);
assert_eq!(data.zones[1].name, "shared");
assert_eq!(data.zones[1].files, 1);
assert_eq!(data.files[0].zone, Some(0));
assert_eq!(data.files[1].zone, Some(0));
assert_eq!(data.files[2].zone, Some(1));
assert_eq!(data.violations.len(), 1);
let v = &data.violations[0];
assert_eq!((v.from, v.to), (0, 2));
assert_eq!((v.from_zone, v.to_zone), (0, 1));
assert_eq!(v.line, 2);
assert_eq!(v.specifier, "../lib/c");
}
#[test]
fn clone_group_cap_counts_truncated_groups() {
let fx = fixture();
let index = FileIndex::new(&fx.files);
let (clones, groups_by_file, _dup_lines, truncated) =
build_clones(&fx.duplication, &index, 1);
assert_eq!(clones.len(), 1);
assert_eq!(truncated, 1);
assert!(
groups_by_file
.values()
.all(|ids| ids.iter().all(|&id| (id as usize) < clones.len()))
);
let data = build_viz_data(&fx.input());
assert_eq!(data.clones.len(), 2);
assert_eq!(data.summary.clone_groups_truncated, None);
}
#[test]
fn summary_flags_clone_truncation_only_when_nonzero() {
let fx = fixture();
let data = build_viz_data(&fx.input());
let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 3);
assert_eq!(summary.clone_groups_truncated, Some(3));
let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 0);
assert_eq!(summary.clone_groups_truncated, None);
}
#[test]
fn summary_counts_match_rendered_arrays() {
let fx = fixture();
let data = build_viz_data(&fx.input());
let s = &data.summary;
assert_eq!(s.total_files, data.files.len());
assert_eq!(s.total_size, 175);
assert_eq!(s.total_edges, data.edges.len());
assert_eq!(s.clone_groups, data.clones.len());
assert_eq!(s.duplicated_lines, 12);
assert_eq!(s.hotspot_files, 0);
assert_eq!(s.unused_files, 0);
assert_eq!(s.unused_exports, 0);
assert_eq!(s.circular_deps, data.cycles.len());
assert_eq!(s.circular_deps, 1);
assert_eq!(s.boundary_violations, data.violations.len());
assert_eq!(s.boundary_violations, 1);
}
}