Skip to content

Commit 04ffb68

Browse files
authored
Fix dependency tracking and show shortest path dependency cycle (#151)
1 parent ff915f2 commit 04ffb68

15 files changed

+195
-146
lines changed

src/build.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ pub fn incremental_build(
330330
pb.finish();
331331
}
332332

333-
log::error!("Could not parse source files: {}", &err);
333+
println!("Could not parse source files: {}", &err);
334334
return Err(IncrementalBuildError::SourceFileParseError);
335335
}
336336
}
@@ -405,7 +405,7 @@ pub fn incremental_build(
405405
pb.finish();
406406
if !compile_errors.is_empty() {
407407
if helpers::contains_ascii_characters(&compile_warnings) {
408-
log::error!("{}", &compile_warnings);
408+
println!("{}", &compile_warnings);
409409
}
410410
if show_progress {
411411
println!(
@@ -417,7 +417,7 @@ pub fn incremental_build(
417417
default_timing.unwrap_or(compile_duration).as_secs_f64()
418418
);
419419
}
420-
log::error!("{}", &compile_errors);
420+
println!("{}", &compile_errors);
421421
// mark the original files as dirty again, because we didn't complete a full build
422422
for (module_name, module) in build_state.modules.iter_mut() {
423423
if tracked_dirty_modules.contains(module_name) {

src/build/build_types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ pub struct Module {
7070
pub compile_dirty: bool,
7171
pub last_compiled_cmi: Option<SystemTime>,
7272
pub last_compiled_cmt: Option<SystemTime>,
73+
pub deps_dirty: bool,
7374
}
7475

7576
impl Module {

src/build/compile/dependency_cycle.rs

Lines changed: 129 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,153 @@
11
use super::super::build_types::*;
22
use crate::helpers;
33
use ahash::AHashSet;
4+
use std::collections::{HashMap, HashSet, VecDeque};
45

56
pub fn find(modules: &Vec<(&String, &Module)>) -> Vec<String> {
6-
let mut visited: AHashSet<String> = AHashSet::new();
7-
let mut stack: Vec<String> = vec![];
7+
find_shortest_cycle(modules)
8+
}
89

9-
// we want to sort the module names so that we always return the same
10-
// dependency cycle (there can be more than one)
11-
let mut module_names = modules
12-
.iter()
13-
.map(|(name, _)| name.to_string())
14-
.collect::<Vec<String>>();
10+
fn find_shortest_cycle(modules: &Vec<(&String, &Module)>) -> Vec<String> {
11+
let mut shortest_cycle: Vec<String> = Vec::new();
12+
13+
// Build a graph representation for easier traversal
1514

16-
module_names.sort();
17-
for module_name in module_names {
18-
if find_dependency_cycle_helper(&module_name, modules, &mut visited, &mut stack) {
19-
return stack;
15+
let mut graph: HashMap<&String, &AHashSet<String>> = HashMap::new();
16+
let mut in_degrees: HashMap<&String, usize> = HashMap::new();
17+
18+
let empty = AHashSet::new();
19+
// First pass: collect all nodes and initialize in-degrees
20+
for (name, _) in modules {
21+
graph.insert(name, &empty);
22+
in_degrees.insert(name, 0);
23+
}
24+
25+
// Second pass: build the graph and count in-degrees
26+
for (name, module) in modules {
27+
// Update in-degrees
28+
for dep in module.deps.iter() {
29+
if let Some(count) = in_degrees.get_mut(dep) {
30+
*count += 1;
31+
}
2032
}
21-
visited.clear();
22-
stack.clear();
33+
34+
// Update the graph
35+
*graph.get_mut(*name).unwrap() = &module.deps;
2336
}
24-
stack
25-
}
37+
// Remove all nodes in the graph that have no incoming edges
38+
graph.retain(|_, deps| !deps.is_empty());
2639

27-
fn find_dependency_cycle_helper(
28-
module_name: &String,
29-
modules: &Vec<(&String, &Module)>,
30-
visited: &mut AHashSet<String>,
31-
stack: &mut Vec<String>,
32-
) -> bool {
33-
if let Some(module) = modules
34-
.iter()
35-
.find(|(name, _)| *name == module_name)
36-
.map(|(_, module)| module)
37-
{
38-
visited.insert(module_name.to_string());
39-
// if the module is a mlmap (namespace), we don't want to show this in the path
40-
// because the namespace is not a module the user created, so only add source files
41-
// to the stack
42-
if let SourceType::SourceFile(_) = module.source_type {
43-
stack.push(module_name.to_string())
40+
// OPTIMIZATION 1: Start with nodes that are more likely to be in cycles
41+
// Sort nodes by their connectivity (in-degree + out-degree)
42+
let mut start_nodes: Vec<&String> = graph.keys().cloned().collect();
43+
start_nodes.sort_by(|a, b| {
44+
let a_connectivity = in_degrees.get(a).unwrap_or(&0) + graph.get(a).map_or(0, |v| v.len());
45+
let b_connectivity = in_degrees.get(b).unwrap_or(&0) + graph.get(b).map_or(0, |v| v.len());
46+
b_connectivity.cmp(&a_connectivity) // Sort in descending order
47+
});
48+
49+
// OPTIMIZATION 2: Keep track of the current shortest cycle length for early termination
50+
let mut current_shortest_length = usize::MAX;
51+
52+
// OPTIMIZATION 3: Cache nodes that have been checked and don't have cycles
53+
let mut no_cycle_cache: HashSet<String> = HashSet::new();
54+
55+
// Try BFS from each node to find the shortest cycle
56+
for start_node in start_nodes {
57+
// Skip nodes that we know don't have cycles
58+
if no_cycle_cache.contains(start_node) {
59+
continue;
4460
}
45-
for dep in &module.deps {
46-
if !visited.contains(dep) {
47-
if find_dependency_cycle_helper(dep, modules, visited, stack) {
48-
return true;
61+
62+
// Skip nodes with no incoming edges
63+
if in_degrees.get(&start_node).map_or(true, |&d| d == 0) {
64+
no_cycle_cache.insert(start_node.clone());
65+
continue;
66+
}
67+
68+
if let Some(cycle) = find_cycle_bfs(&start_node, &graph, current_shortest_length) {
69+
if shortest_cycle.is_empty() || cycle.len() < shortest_cycle.len() {
70+
shortest_cycle = cycle.clone();
71+
current_shortest_length = cycle.len();
72+
73+
// OPTIMIZATION 4: If we find a very short cycle (length <= 3), we can stop early
74+
// as it's unlikely to find a shorter one
75+
if cycle.len() <= 3 {
76+
break;
4977
}
50-
} else if stack.contains(dep) {
51-
stack.push(dep.to_string());
52-
return true;
5378
}
79+
} else {
80+
// Cache this node as not having a cycle
81+
no_cycle_cache.insert(start_node.to_string());
5482
}
55-
// because we only pushed source files to the stack, we also only need to
56-
// pop these from the stack if we don't find a dependency cycle
57-
if let SourceType::SourceFile(_) = module.source_type {
58-
let _ = stack.pop();
83+
}
84+
85+
shortest_cycle
86+
}
87+
88+
fn find_cycle_bfs(
89+
start: &String,
90+
graph: &HashMap<&String, &AHashSet<String>>,
91+
max_length: usize,
92+
) -> Option<Vec<String>> {
93+
// Use a BFS to find the shortest cycle
94+
let mut queue = VecDeque::new();
95+
// Store node -> (distance, parent)
96+
let mut visited: HashMap<String, (usize, Option<String>)> = HashMap::new();
97+
98+
// Initialize with start node
99+
visited.insert(start.clone(), (0, None));
100+
queue.push_back(start.clone());
101+
102+
while let Some(current) = queue.pop_front() {
103+
let (dist, _) = *visited.get(&current).unwrap();
104+
105+
// OPTIMIZATION: Early termination if we've gone too far
106+
// If we're already at max_length, we won't find a shorter cycle from here
107+
if dist >= max_length - 1 {
108+
continue;
109+
}
110+
111+
// Check all neighbors
112+
if let Some(neighbors) = graph.get(&current) {
113+
for neighbor in neighbors.iter() {
114+
// If we found the start node again, we have a cycle
115+
if neighbor == start {
116+
// Reconstruct the cycle
117+
let mut path = Vec::new();
118+
path.push(start.clone());
119+
120+
// Backtrack from current to start using parent pointers
121+
let mut curr = current.clone();
122+
while curr != *start {
123+
path.push(curr.clone());
124+
curr = visited.get(&curr).unwrap().1.clone().unwrap();
125+
}
126+
127+
return Some(path);
128+
}
129+
130+
// If not visited, add to queue
131+
if !visited.contains_key(neighbor) {
132+
visited.insert(neighbor.clone(), (dist + 1, Some(current.clone())));
133+
queue.push_back(neighbor.clone());
134+
}
135+
}
59136
}
60-
return false;
61137
}
62-
false
138+
139+
None
63140
}
64141

65142
pub fn format(cycle: &[String]) -> String {
143+
let mut cycle = cycle.to_vec();
144+
cycle.reverse();
145+
// add the first module to the end of the cycle
146+
cycle.push(cycle[0].clone());
147+
66148
cycle
67149
.iter()
68150
.map(|s| helpers::format_namespaced_module_name(s))
69151
.collect::<Vec<String>>()
70-
.join(" -> ")
152+
.join("\n ")
71153
}

src/build/deps.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use super::build_types::*;
2-
use super::is_dirty;
32
use super::packages;
43
use crate::helpers;
54
use ahash::AHashSet;
@@ -76,7 +75,7 @@ pub fn get_deps(build_state: &mut BuildState, deleted_modules: &AHashSet<String>
7675
.get_package(&module.package_name)
7776
.expect("Package not found");
7877
let ast_path = package.get_ast_path(&source_file.implementation.path);
79-
if is_dirty(module) || !build_state.deps_initialized {
78+
if module.deps_dirty || !build_state.deps_initialized {
8079
let mut deps = get_dep_modules(
8180
&ast_path,
8281
package.namespace.to_suffix(),
@@ -114,6 +113,7 @@ pub fn get_deps(build_state: &mut BuildState, deleted_modules: &AHashSet<String>
114113
.for_each(|(module_name, deps)| {
115114
if let Some(module) = build_state.modules.get_mut(&module_name) {
116115
module.deps = deps.clone();
116+
module.deps_dirty = false;
117117
}
118118
deps.iter().for_each(|dep_name| {
119119
if let Some(module) = build_state.modules.get_mut(dep_name) {

src/build/packages.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,7 @@ pub fn parse_packages(build_state: &mut BuildState) {
645645
build_state.insert_module(
646646
&helpers::file_path_to_module_name(&mlmap.to_owned(), &packages::Namespace::NoNamespace),
647647
Module {
648+
deps_dirty: false,
648649
source_type: SourceType::MlMap(MlMap { parse_dirty: false }),
649650
deps,
650651
dependents: AHashSet::new(),
@@ -685,6 +686,7 @@ pub fn parse_packages(build_state: &mut BuildState) {
685686
}
686687
})
687688
.or_insert(Module {
689+
deps_dirty: true,
688690
source_type: SourceType::SourceFile(SourceFile {
689691
implementation: Implementation {
690692
path: file.to_owned(),
@@ -732,6 +734,7 @@ pub fn parse_packages(build_state: &mut BuildState) {
732734
}
733735
})
734736
.or_insert(Module {
737+
deps_dirty: true,
735738
source_type: SourceType::SourceFile(SourceFile {
736739
// this will be overwritten later
737740
implementation: Implementation {

src/build/parse.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ pub fn generate_asts(
9898
// the compile_dirty flag if it was set before
9999
if is_dirty {
100100
module.compile_dirty = true;
101+
module.deps_dirty = true;
101102
}
102103
let package = build_state
103104
.packages
@@ -113,9 +114,6 @@ pub fn generate_asts(
113114
Ok((_path, Some(stderr_warnings))) if package.is_pinned_dep => {
114115
source_file.implementation.parse_state = ParseState::Warning;
115116
source_file.implementation.parse_dirty = true;
116-
if let Some(interface) = source_file.interface.as_mut() {
117-
interface.parse_dirty = false;
118-
}
119117
logs::append(package, &stderr_warnings);
120118
stderr.push_str(&stderr_warnings);
121119
}
@@ -124,9 +122,6 @@ pub fn generate_asts(
124122
// dependency (so some external dep). We can ignore those
125123
source_file.implementation.parse_state = ParseState::Success;
126124
source_file.implementation.parse_dirty = false;
127-
if let Some(interface) = source_file.interface.as_mut() {
128-
interface.parse_dirty = false;
129-
}
130125
}
131126
Err(err) => {
132127
// Some compilation error

src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ fn main() -> Result<()> {
114114

115115
match lock::get(&folder) {
116116
lock::Lock::Error(ref e) => {
117-
log::error!("Could not start Rewatch: {e}");
117+
println!("Could not start Rewatch: {e}");
118118
std::process::exit(1)
119119
}
120120
lock::Lock::Aquired(_) => match command {
@@ -130,7 +130,7 @@ fn main() -> Result<()> {
130130
args.dev,
131131
) {
132132
Err(e) => {
133-
log::error!("{e}");
133+
println!("{e}");
134134
std::process::exit(1)
135135
}
136136
Ok(_) => {

src/watcher.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ pub fn start(
282282
)
283283
.await
284284
{
285-
log::error!("{:?}", e)
285+
println!("{:?}", e)
286286
}
287287
})
288288
}

tests/snapshots/dependency-cycle.txt

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1-
[1/7] 📦 Building package tree...
2-
[1/7] 📦 Built package tree in 0.00s
3-
[2/7] 🕵️ Finding source files...
4-
[2/7] 🕵️ Found source files in 0.00s
5-
[3/7] 📝 Reading compile state...
6-
[3/7] 📝 Read compile state 0.00s
7-
[4/7] 🧹 Cleaning up previous build...
8-
[4/7] 🧹 Cleaned 0/11 0.00s
1+
[1/7] 📦 Building package tree...[1/7] 📦 Built package tree in 0.00s
2+
[2/7] 👀 Finding source files...[2/7] 👀 Found source files in 0.00s
3+
[3/7] 📝 Reading compile state...[3/7] 📝 Read compile state 0.00s
4+
[4/7] 🧹 Cleaning up previous build...[4/7] 🧹 Cleaned 0/11 0.00s
95
[5/7] 🧱 Parsed 1 source files in 0.00s
10-
[6/7] ️🌴 Collected deps in 0.00s
11-
[7/7] ️🛑 Compiled 0 modules in 0.00s
12-
ERROR:
6+
[6/7] 🌴 Collected deps in 0.00s
7+
[7/7] ❌ Compiled 0 modules in 0.00s
138

149
Can't continue... Found a circular dependency in your code:
15-
NewNamespace.NS_alias -> Dep01 -> Dep02 -> NS -> NewNamespace.NS_alias
10+
Dep01
11+
→ Dep02
12+
→ NS
13+
→ NewNamespace.NS_alias
14+
→ Dep01
1615

17-
ERROR:
18-
Incremental build failed. Error:  ️🛑 Failed to Compile. See Errors Above
16+
Incremental build failed. Error:  ❌ Failed to Compile. See Errors Above

tests/snapshots/remove-file.txt

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
1-
[1/7] 📦 Building package tree...
2-
[1/7] 📦 Built package tree in 0.00s
3-
[2/7] 🕵️ Finding source files...
4-
[2/7] 🕵️ Found source files in 0.00s
5-
[3/7] 📝 Reading compile state...
6-
[3/7] 📝 Read compile state 0.00s
7-
[4/7] 🧹 Cleaning up previous build...
8-
[4/7] 🧹 Cleaned 1/11 0.00s
1+
[1/7] 📦 Building package tree...[1/7] 📦 Built package tree in 0.00s
2+
[2/7] 👀 Finding source files...[2/7] 👀 Found source files in 0.00s
3+
[3/7] 📝 Reading compile state...[3/7] 📝 Read compile state 0.00s
4+
[4/7] 🧹 Cleaning up previous build...[4/7] 🧹 Cleaned 1/11 0.00s
95
[5/7] 🧱 Parsed 0 source files in 0.00s
10-
[6/7] ️🌴 Collected deps in 0.00s
11-
[7/7] ️🛑 Compiled 1 modules in 0.00s
12-
ERROR:
6+
[6/7] 🌴 Collected deps in 0.00s
7+
[7/7] ❌ Compiled 1 modules in 0.00s
138

149
We've found a bug for you!
1510
/packages/dep01/src/Dep01.res:3:9-17
@@ -27,5 +22,4 @@ ERROR:
2722

2823

2924

30-
ERROR:
31-
Incremental build failed. Error:  ️🛑 Failed to Compile. See Errors Above
25+
Incremental build failed. Error:  ❌ Failed to Compile. See Errors Above

0 commit comments

Comments
 (0)