Skip to content

Commit ea9dd8a

Browse files
dyc3BezSaharaD
andauthored
perf(noImportCycles): exclude node_modules, add more tests (#11251)
Co-authored-by: Bez Sahara <239664238+BezSaharaD@users.noreply.github.com>
1 parent c4a07bf commit ea9dd8a

6 files changed

Lines changed: 181 additions & 18 deletions

File tree

.changeset/bitter-cats-cut.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Improved performance of [`noImportCycles`](https://biomejs.dev/linter/rules/no-import-cycles/).

crates/biome_fs/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ pub use fs::{
1111
TraversalContext, TraversalScope,
1212
};
1313
pub use interner::{PathInterner, PathInternerSet};
14-
pub use path::BiomePath;
14+
pub use path::{BiomePath, is_node_modules_path};
1515
pub use utils::*;

crates/biome_fs/src/path.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ use std::hash::Hash;
1313
use std::path::{Path, PathBuf};
1414
use std::{fs::File, io, io::Write, ops::Deref};
1515

16+
/// Returns whether `path` contains a directory component named `node_modules`.
17+
#[inline]
18+
pub fn is_node_modules_path(path: &Utf8Path) -> bool {
19+
path.components()
20+
.any(|component| component.as_str().as_bytes() == b"node_modules")
21+
}
22+
1623
/// The priority of the file
1724
// NOTE: The order of the variants is important, the one on the top has the highest priority
1825
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Ord, PartialOrd, Hash)]
@@ -196,9 +203,7 @@ impl BiomePath {
196203
/// Returns `true` if the path is inside `node_modules`
197204
#[inline(always)]
198205
pub fn is_dependency(&self) -> bool {
199-
self.path
200-
.components()
201-
.any(|component| component.as_str().as_bytes() == b"node_modules")
206+
is_node_modules_path(&self.path)
202207
}
203208

204209
/// Whether this is a file named `package.json`
@@ -327,6 +332,22 @@ impl Ord for BiomePath {
327332
#[cfg(test)]
328333
mod test {
329334
use crate::path::FileKinds;
335+
use camino::Utf8Path;
336+
337+
use super::is_node_modules_path;
338+
339+
#[test]
340+
fn detects_node_modules_paths() {
341+
assert!(is_node_modules_path(Utf8Path::new(
342+
"/project/node_modules/package/index.js"
343+
)));
344+
assert!(is_node_modules_path(Utf8Path::new(
345+
"project/packages/app/node_modules/package/index.js"
346+
)));
347+
assert!(!is_node_modules_path(Utf8Path::new(
348+
"/project/node_modules_backup/package/index.js"
349+
)));
350+
}
330351

331352
#[test]
332353
fn test_biome_paths() {

crates/biome_js_analyze/src/lint/suspicious/no_import_cycles.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ use biome_analyze::{
44
};
55
use biome_console::markup;
66
use biome_diagnostics::Severity;
7+
use biome_fs::is_node_modules_path;
78
use biome_js_syntax::AnyJsImportLike;
89
use biome_module_graph::{
910
JsImportPath, JsImportPhase, JsModuleInfo, ModuleGraphGeneration, js_module_sccs,
1011
};
1112
use biome_resolver::ResolvedPath;
1213
use biome_rowan::AstNode;
1314
use biome_rule_options::no_import_cycles::NoImportCyclesOptions;
14-
use camino::{Utf8Path, Utf8PathBuf};
15+
use camino::Utf8PathBuf;
1516
use rustc_hash::FxHashSet;
1617

1718
declare_lint_rule! {
@@ -316,8 +317,3 @@ fn find_cycle(
316317

317318
None
318319
}
319-
320-
/// Returns `true` if the given path is inside a `node_modules` directory.
321-
fn is_node_modules_path(path: &Utf8Path) -> bool {
322-
path.components().any(|c| c.as_str() == "node_modules")
323-
}

crates/biome_module_graph/src/db/queries/js_scc.rs

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
use crate::{JsImportPath, ModuleDb, ModuleGraphGeneration, ModuleInfoKind};
2+
use biome_fs::is_node_modules_path;
23
use camino::{Utf8Path, Utf8PathBuf};
34
use rustc_hash::FxHashMap;
45

56
/// Strongly connected components of the JavaScript import graph.
67
///
78
/// Two modules belong to the same component when each is reachable from the
8-
/// other by following imports. All resolved edges between indexed JavaScript
9-
/// modules are included, so callers may use this as a conservative prefilter
10-
/// when they traverse a narrower graph.
9+
/// other by following imports. Resolved edges between indexed JavaScript
10+
/// modules outside `node_modules` are included, so callers may use this as a
11+
/// conservative prefilter when they traverse a narrower graph.
1112
///
1213
/// See: <https://en.wikipedia.org/wiki/Kosaraju%27s_algorithm>
1314
#[derive(Debug, Eq, PartialEq)]
@@ -41,8 +42,9 @@ pub fn js_module_sccs(db: &dyn ModuleDb, generation: ModuleGraphGeneration) -> J
4142
let mut id_by_path = FxHashMap::default();
4243

4344
db.for_each_module(&mut |module| {
44-
if matches!(module.kind(db), ModuleInfoKind::Js(_)) {
45-
id_by_path.insert(module.path(db).to_path_buf(), id_by_path.len() as u32);
45+
let path = module.path(db);
46+
if matches!(module.kind(db), ModuleInfoKind::Js(_)) && !is_node_modules_path(path) {
47+
id_by_path.insert(path.to_path_buf(), id_by_path.len() as u32);
4648
}
4749
});
4850

@@ -84,7 +86,7 @@ pub fn js_module_sccs(db: &dyn ModuleDb, generation: ModuleGraphGeneration) -> J
8486
pub(super) fn compute_sccs(edges: &[Vec<u32>]) -> (Vec<u32>, Vec<u32>) {
8587
let node_count = edges.len();
8688
let mut visited = vec![false; node_count];
87-
let mut next_edge = vec![0; node_count];
89+
let mut next_edge = vec![0u32; node_count];
8890
let mut finish_order = Vec::with_capacity(node_count);
8991
let mut stack = Vec::new();
9092

@@ -101,7 +103,7 @@ pub(super) fn compute_sccs(edges: &[Vec<u32>]) -> (Vec<u32>, Vec<u32>) {
101103

102104
while let Some(&node) = stack.last() {
103105
let node = node as usize;
104-
if let Some(&next) = edges[node].get(next_edge[node]) {
106+
if let Some(&next) = edges[node].get(next_edge[node] as usize) {
105107
next_edge[node] += 1;
106108
if !visited[next as usize] {
107109
visited[next as usize] = true;
@@ -158,7 +160,23 @@ pub(super) fn compute_sccs(edges: &[Vec<u32>]) -> (Vec<u32>, Vec<u32>) {
158160

159161
#[cfg(test)]
160162
mod tests {
161-
use super::compute_sccs;
163+
use super::{JsModuleSccs, compute_sccs};
164+
use camino::{Utf8Path, Utf8PathBuf};
165+
166+
fn module_sccs(paths: &[&str], edges: &[Vec<u32>]) -> JsModuleSccs {
167+
assert_eq!(paths.len(), edges.len());
168+
let (component_by_id, component_sizes) = compute_sccs(edges);
169+
let component_by_path = paths
170+
.iter()
171+
.enumerate()
172+
.map(|(id, path)| (Utf8PathBuf::from(*path), component_by_id[id]))
173+
.collect();
174+
175+
JsModuleSccs {
176+
component_by_path,
177+
component_sizes: component_sizes.into_boxed_slice(),
178+
}
179+
}
162180

163181
#[test]
164182
fn separates_acyclic_nodes() {
@@ -182,4 +200,76 @@ mod tests {
182200
assert_eq!(components[4], components[5]);
183201
assert_eq!(sizes[components[4] as usize], 2);
184202
}
203+
204+
#[test]
205+
fn importing_into_cycle_does_not_join_cycle() {
206+
let (components, sizes) = compute_sccs(&[vec![1], vec![0], vec![0]]);
207+
208+
assert_eq!(components[0], components[1]);
209+
assert_eq!(sizes[components[0] as usize], 2);
210+
assert_ne!(components[2], components[0]);
211+
assert_eq!(sizes[components[2] as usize], 1);
212+
}
213+
214+
#[test]
215+
fn convergence_without_cycle_separates_all_nodes() {
216+
let (components, sizes) = compute_sccs(&[vec![1, 2], vec![3], vec![3], vec![]]);
217+
218+
for (node, &component) in components.iter().enumerate() {
219+
assert_eq!(sizes[component as usize], 1);
220+
assert!(components[..node].iter().all(|&other| other != component));
221+
}
222+
}
223+
224+
#[test]
225+
fn chord_does_not_split_cycle() {
226+
let (components, sizes) = compute_sccs(&[vec![1], vec![2, 3], vec![3], vec![0]]);
227+
228+
assert!(
229+
components
230+
.iter()
231+
.all(|&component| component == components[0])
232+
);
233+
assert_eq!(sizes[components[0] as usize], 4);
234+
}
235+
236+
#[test]
237+
fn contains_cycle_between_nodes_in_cycle() {
238+
let sccs = module_sccs(&["/a.js", "/b.js"], &[vec![1], vec![0]]);
239+
240+
assert!(sccs.contains_cycle_between(Utf8Path::new("/a.js"), Utf8Path::new("/b.js")));
241+
assert!(sccs.contains_cycle_between(Utf8Path::new("/b.js"), Utf8Path::new("/a.js")));
242+
}
243+
244+
#[test]
245+
fn does_not_contain_cycle_for_edge_exiting_cycle() {
246+
let sccs = module_sccs(&["/a.js", "/b.js", "/c.js"], &[vec![1, 2], vec![0], vec![]]);
247+
248+
assert!(sccs.contains_cycle_between(Utf8Path::new("/a.js"), Utf8Path::new("/b.js")));
249+
assert!(!sccs.contains_cycle_between(Utf8Path::new("/a.js"), Utf8Path::new("/c.js")));
250+
}
251+
252+
#[test]
253+
fn does_not_contain_cycle_for_single_self_import() {
254+
let sccs = module_sccs(&["/a.js"], &[vec![0]]);
255+
256+
assert!(!sccs.contains_cycle_between(Utf8Path::new("/a.js"), Utf8Path::new("/a.js")));
257+
}
258+
259+
#[test]
260+
fn does_not_contain_cycle_between_unrelated_cycles() {
261+
let sccs = module_sccs(
262+
&["/a.js", "/b.js", "/c.js", "/d.js"],
263+
&[vec![1], vec![0], vec![3], vec![2]],
264+
);
265+
266+
assert!(!sccs.contains_cycle_between(Utf8Path::new("/a.js"), Utf8Path::new("/c.js")));
267+
}
268+
269+
#[test]
270+
fn does_not_contain_cycle_for_unknown_path() {
271+
let sccs = module_sccs(&["/a.js", "/b.js"], &[vec![1], vec![0]]);
272+
273+
assert!(!sccs.contains_cycle_between(Utf8Path::new("/a.js"), Utf8Path::new("/unknown.js")));
274+
}
185275
}

crates/biome_module_graph/tests/js_scc.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,54 @@ fn scc_query_recomputes_after_module_graph_changes() {
9090
.contains_cycle_between(Utf8Path::new("/src/a.js"), Utf8Path::new("/src/b.js"))
9191
);
9292
}
93+
94+
#[test]
95+
fn scc_query_recomputes_after_modules_are_added_and_removed() {
96+
let fs = MemoryFileSystem::default();
97+
fs.insert("/src/a.js".into(), "import './b.js';");
98+
fs.insert("/src/b.js".into(), "import './a.js';");
99+
100+
let mut db = WorkspaceDb::default();
101+
let a_path = Utf8Path::new("/src/a.js");
102+
let b_path = Utf8Path::new("/src/b.js");
103+
db.update_or_insert_module(a_path.to_path_buf(), resolve_module(&fs, "/src/a.js"));
104+
105+
assert!(
106+
!js_module_sccs(&db, ModuleGraphGeneration::get(&db))
107+
.contains_cycle_between(a_path, b_path)
108+
);
109+
110+
let generation = db.module_graph_generation();
111+
db.update_or_insert_module(b_path.to_path_buf(), resolve_module(&fs, "/src/b.js"));
112+
113+
assert_eq!(db.module_graph_generation(), generation.wrapping_add(1));
114+
assert!(
115+
js_module_sccs(&db, ModuleGraphGeneration::get(&db)).contains_cycle_between(a_path, b_path)
116+
);
117+
118+
let generation = db.module_graph_generation();
119+
db.remove_module(b_path);
120+
121+
assert_eq!(db.module_graph_generation(), generation.wrapping_add(1));
122+
assert!(
123+
!js_module_sccs(&db, ModuleGraphGeneration::get(&db))
124+
.contains_cycle_between(a_path, b_path)
125+
);
126+
}
127+
128+
#[test]
129+
fn scc_query_ignores_paths_in_node_modules() {
130+
let (_, db) = module_db(&[
131+
("/src/a.js", "import '../node_modules/dependency/index.js';"),
132+
("/src/b.js", "import './a.js';"),
133+
(
134+
"/node_modules/dependency/index.js",
135+
"import '../../src/b.js';",
136+
),
137+
]);
138+
139+
assert!(
140+
!js_module_sccs(&db, ModuleGraphGeneration::get(&db))
141+
.contains_cycle_between(Utf8Path::new("/src/a.js"), Utf8Path::new("/src/b.js"))
142+
);
143+
}

0 commit comments

Comments
 (0)