1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use crate::cache::CacheWriteHandle;
11use crate::error::Error;
12use crate::graph::{EdgeId, EdgeKind, ModuleGraph, ModuleId, PackageInfo};
13use crate::loader;
14use crate::query::{self, ChainTarget, CutModule, DiffResult, TraceOptions, TraceResult};
15
16pub struct ResolvedTarget {
21 pub target: ChainTarget,
22 pub label: String,
23 pub exists: bool,
24}
25
26pub struct Session {
31 graph: ModuleGraph,
32 reverse_adj: Vec<Vec<EdgeId>>,
33 root: PathBuf,
34 entry: PathBuf,
35 entry_id: ModuleId,
36 valid_extensions: &'static [&'static str],
37 from_cache: bool,
38 unresolvable_dynamic_count: usize,
39 unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
40 file_warnings: Vec<String>,
41 _cache_handle: CacheWriteHandle,
42}
43
44fn build_reverse_adj(graph: &ModuleGraph) -> Vec<Vec<EdgeId>> {
45 let mut rev = vec![Vec::new(); graph.module_count()];
46 for edge in &graph.edges {
47 rev[edge.to.0 as usize].push(edge.id);
48 }
49 rev
50}
51
52impl Session {
53 pub fn open(entry: &Path, no_cache: bool) -> Result<Self, Error> {
57 let (loaded, cache_handle) = loader::load_graph(entry, no_cache)?;
58
59 let entry_id = *loaded
60 .graph
61 .path_to_id
62 .get(&loaded.entry)
63 .ok_or_else(|| Error::EntryNotInGraph(loaded.entry.clone()))?;
64
65 let reverse_adj = build_reverse_adj(&loaded.graph);
66
67 Ok(Self {
68 graph: loaded.graph,
69 reverse_adj,
70 root: loaded.root,
71 entry: loaded.entry,
72 entry_id,
73 valid_extensions: loaded.valid_extensions,
74 from_cache: loaded.from_cache,
75 unresolvable_dynamic_count: loaded.unresolvable_dynamic_count,
76 unresolvable_dynamic_files: loaded.unresolvable_dynamic_files,
77 file_warnings: loaded.file_warnings,
78 _cache_handle: cache_handle,
79 })
80 }
81
82 pub fn trace(&self, opts: &TraceOptions) -> TraceResult {
84 query::trace(&self.graph, self.entry_id, opts)
85 }
86
87 pub fn trace_from(
89 &self,
90 file: &Path,
91 opts: &TraceOptions,
92 ) -> Result<(TraceResult, PathBuf), Error> {
93 let canon = file
94 .canonicalize()
95 .or_else(|_| self.root.join(file).canonicalize())
96 .map_err(|e| Error::EntryNotFound(file.to_path_buf(), e))?;
97 let Some(&id) = self.graph.path_to_id.get(&canon) else {
98 return Err(Error::EntryNotInGraph(canon));
99 };
100 Ok((query::trace(&self.graph, id, opts), canon))
101 }
102
103 pub fn resolve_target(&self, arg: &str) -> ResolvedTarget {
109 if looks_like_path(arg, self.valid_extensions)
110 && let Ok(target_path) = self.root.join(arg).canonicalize()
111 && let Some(&id) = self.graph.path_to_id.get(&target_path)
112 {
113 let p = &self.graph.module(id).path;
114 let label = p
115 .strip_prefix(&self.root)
116 .unwrap_or(p)
117 .to_string_lossy()
118 .into_owned();
119 return ResolvedTarget {
120 target: ChainTarget::Module(id),
121 label,
122 exists: true,
123 };
124 }
125 let name = arg.to_string();
129 let exists = self.graph.package_map.contains_key(arg);
130 let label = name.clone();
131 ResolvedTarget {
132 target: ChainTarget::Package(name),
133 label,
134 exists,
135 }
136 }
137
138 pub fn chain(
140 &self,
141 target_arg: &str,
142 include_dynamic: bool,
143 ) -> (ResolvedTarget, Vec<Vec<ModuleId>>) {
144 let resolved = self.resolve_target(target_arg);
145 let chains = query::find_all_chains(
146 &self.graph,
147 self.entry_id,
148 &resolved.target,
149 include_dynamic,
150 );
151 (resolved, chains)
152 }
153
154 pub fn cut(
156 &self,
157 target_arg: &str,
158 top: i32,
159 include_dynamic: bool,
160 ) -> (ResolvedTarget, Vec<Vec<ModuleId>>, Vec<CutModule>) {
161 let resolved = self.resolve_target(target_arg);
162 let chains = query::find_all_chains(
163 &self.graph,
164 self.entry_id,
165 &resolved.target,
166 include_dynamic,
167 );
168 let cuts = query::find_cut_modules(
169 &self.graph,
170 &chains,
171 self.entry_id,
172 &resolved.target,
173 top,
174 include_dynamic,
175 );
176 (resolved, chains, cuts)
177 }
178
179 pub fn diff_entry(
184 &self,
185 other: &Path,
186 opts: &TraceOptions,
187 ) -> Result<(DiffResult, PathBuf), Error> {
188 let other_canon = other
189 .canonicalize()
190 .or_else(|_| self.root.join(other).canonicalize())
191 .map_err(|e| Error::EntryNotFound(other.to_path_buf(), e))?;
192 let Some(&other_id) = self.graph.path_to_id.get(&other_canon) else {
193 return Err(Error::EntryNotInGraph(other_canon.clone()));
194 };
195 let snap_a = self.trace(opts).to_snapshot(&self.entry_label());
196 let snap_b = query::trace(&self.graph, other_id, opts)
197 .to_snapshot(&self.entry_label_for(&other_canon));
198 Ok((query::diff_snapshots(&snap_a, &snap_b), other_canon))
199 }
200
201 pub fn packages(&self) -> &HashMap<String, PackageInfo> {
203 &self.graph.package_map
204 }
205
206 pub fn imports(&self, file: &Path) -> Result<Vec<(PathBuf, EdgeKind)>, Error> {
208 let canon = file
209 .canonicalize()
210 .or_else(|_| self.root.join(file).canonicalize())
211 .map_err(|e| Error::EntryNotFound(file.to_path_buf(), e))?;
212 let Some(&id) = self.graph.path_to_id.get(&canon) else {
213 return Err(Error::EntryNotInGraph(canon));
214 };
215 let result = self
216 .graph
217 .outgoing_edges(id)
218 .iter()
219 .map(|&eid| {
220 let edge = self.graph.edge(eid);
221 (self.graph.module(edge.to).path.clone(), edge.kind)
222 })
223 .collect();
224 Ok(result)
225 }
226
227 pub fn importers(&self, file: &Path) -> Result<Vec<(PathBuf, EdgeKind)>, Error> {
229 let canon = file
230 .canonicalize()
231 .or_else(|_| self.root.join(file).canonicalize())
232 .map_err(|e| Error::EntryNotFound(file.to_path_buf(), e))?;
233 let Some(&id) = self.graph.path_to_id.get(&canon) else {
234 return Err(Error::EntryNotInGraph(canon));
235 };
236 let result = self.reverse_adj[id.0 as usize]
237 .iter()
238 .map(|&eid| {
239 let edge = self.graph.edge(eid);
240 (self.graph.module(edge.from).path.clone(), edge.kind)
241 })
242 .collect();
243 Ok(result)
244 }
245
246 pub fn info(&self, package_name: &str) -> Option<&PackageInfo> {
248 self.graph.package_map.get(package_name)
249 }
250
251 pub fn entry_label(&self) -> String {
254 self.entry_label_for(&self.entry)
255 }
256
257 pub fn entry_label_for(&self, path: &Path) -> String {
259 entry_label(path, &self.root)
260 }
261
262 pub fn set_entry(&mut self, path: &Path) -> Result<(), Error> {
267 let canon = path
268 .canonicalize()
269 .or_else(|_| self.root.join(path).canonicalize())
270 .map_err(|e| Error::EntryNotFound(path.to_path_buf(), e))?;
271 let Some(&id) = self.graph.path_to_id.get(&canon) else {
272 return Err(Error::EntryNotInGraph(canon));
273 };
274 self.entry = canon;
275 self.entry_id = id;
276 Ok(())
277 }
278
279 #[allow(clippy::used_underscore_binding)] pub fn refresh(&mut self) -> Result<bool, Error> {
285 let (loaded, handle) = loader::load_graph(&self.entry, false)?;
286 let Some(&entry_id) = loaded.graph.path_to_id.get(&loaded.entry) else {
287 return Err(Error::EntryNotInGraph(loaded.entry));
288 };
289 let changed =
294 !loaded.from_cache || loaded.graph.module_count() != self.graph.module_count();
295 if changed {
296 self.reverse_adj = build_reverse_adj(&loaded.graph);
297 } else {
298 debug_assert_eq!(
299 self.reverse_adj,
300 build_reverse_adj(&loaded.graph),
301 "reverse_adj out of sync: cache reported unchanged but edges differ"
302 );
303 }
304 self.graph = loaded.graph;
305 self.root = loaded.root;
306 self.entry = loaded.entry;
307 self.entry_id = entry_id;
308 self.valid_extensions = loaded.valid_extensions;
309 self.from_cache = loaded.from_cache;
310 self.unresolvable_dynamic_count = loaded.unresolvable_dynamic_count;
311 self.unresolvable_dynamic_files = loaded.unresolvable_dynamic_files;
312 self.file_warnings = loaded.file_warnings;
313 self._cache_handle = handle;
314 Ok(changed)
315 }
316
317 pub fn graph(&self) -> &ModuleGraph {
320 &self.graph
321 }
322
323 pub fn root(&self) -> &Path {
324 &self.root
325 }
326
327 pub fn entry(&self) -> &Path {
328 &self.entry
329 }
330
331 pub fn entry_id(&self) -> ModuleId {
332 self.entry_id
333 }
334
335 pub fn valid_extensions(&self) -> &'static [&'static str] {
336 self.valid_extensions
337 }
338
339 pub fn from_cache(&self) -> bool {
340 self.from_cache
341 }
342
343 pub fn unresolvable_dynamic_count(&self) -> usize {
344 self.unresolvable_dynamic_count
345 }
346
347 pub fn unresolvable_dynamic_files(&self) -> &[(PathBuf, usize)] {
348 &self.unresolvable_dynamic_files
349 }
350
351 pub fn file_warnings(&self) -> &[String] {
352 &self.file_warnings
353 }
354}
355
356pub fn entry_label(path: &Path, root: &Path) -> String {
360 let rel = path.strip_prefix(root).unwrap_or(path);
361 root.file_name().map_or_else(
362 || rel.to_string_lossy().into_owned(),
363 |name| Path::new(name).join(rel).to_string_lossy().into_owned(),
364 )
365}
366
367pub fn looks_like_path(arg: &str, extensions: &[&str]) -> bool {
370 !arg.starts_with('@')
371 && (arg.contains('/')
372 || arg.contains(std::path::MAIN_SEPARATOR)
373 || arg
374 .rsplit_once('.')
375 .is_some_and(|(_, suffix)| extensions.contains(&suffix)))
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 fn test_project() -> (tempfile::TempDir, PathBuf) {
383 let tmp = tempfile::tempdir().unwrap();
384 let root = tmp.path().canonicalize().unwrap();
385 std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
386 let entry = root.join("index.ts");
387 std::fs::write(&entry, r#"import { x } from "./a";"#).unwrap();
388 std::fs::write(root.join("a.ts"), "export const x = 1;").unwrap();
389 (tmp, entry)
390 }
391
392 #[test]
393 fn open_and_trace() {
394 let (_tmp, entry) = test_project();
395 let session = Session::open(&entry, true).unwrap();
396 assert_eq!(session.graph().module_count(), 2);
397 let opts = TraceOptions::default();
398 let result = session.trace(&opts);
399 assert!(result.static_weight > 0);
400 }
401
402 #[test]
403 fn chain_finds_dependency() {
404 let (_tmp, entry) = test_project();
405 let session = Session::open(&entry, true).unwrap();
406 let (resolved, chains) = session.chain("a.ts", false);
407 assert!(resolved.exists);
408 assert!(!chains.is_empty());
409 }
410
411 #[test]
412 fn cut_finds_no_intermediate_on_direct_import() {
413 let (_tmp, entry) = test_project();
414 let session = Session::open(&entry, true).unwrap();
415 let (resolved, chains, cuts) = session.cut("a.ts", 10, false);
417 assert!(resolved.exists);
418 assert!(!chains.is_empty());
419 assert!(cuts.is_empty());
420 }
421
422 #[test]
423 fn diff_two_entries() {
424 let tmp = tempfile::tempdir().unwrap();
425 let root = tmp.path().canonicalize().unwrap();
426 std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
427 let a = root.join("a.ts");
430 std::fs::write(&a, r#"import { foo } from "./b";"#).unwrap();
431 let b = root.join("b.ts");
432 std::fs::write(&b, r#"import { bar } from "./extra";"#).unwrap();
433 std::fs::write(root.join("extra.ts"), "export const y = 2;").unwrap();
434
435 let session = Session::open(&a, true).unwrap();
436 let (diff, _) = session.diff_entry(&b, &TraceOptions::default()).unwrap();
437 assert!(diff.entry_a_weight >= diff.entry_b_weight);
439 }
440
441 #[test]
442 fn packages_returns_package_map() {
443 let (_tmp, entry) = test_project();
444 let session = Session::open(&entry, true).unwrap();
445 assert!(session.packages().is_empty());
447 }
448
449 #[test]
450 fn resolve_target_file_path() {
451 let (_tmp, entry) = test_project();
452 let session = Session::open(&entry, true).unwrap();
453 let resolved = session.resolve_target("a.ts");
454 assert!(resolved.exists);
455 assert!(matches!(resolved.target, ChainTarget::Module(_)));
456 }
457
458 #[test]
459 fn resolve_target_missing_package() {
460 let (_tmp, entry) = test_project();
461 let session = Session::open(&entry, true).unwrap();
462 let resolved = session.resolve_target("nonexistent-pkg");
463 assert!(!resolved.exists);
464 assert!(matches!(resolved.target, ChainTarget::Package(_)));
465 }
466
467 #[test]
468 fn scoped_npm_package_is_not_path() {
469 let exts = &["ts", "tsx", "js", "jsx"];
470 assert!(!looks_like_path("@slack/web-api", exts));
471 assert!(!looks_like_path("@aws-sdk/client-s3", exts));
472 assert!(!looks_like_path("@anthropic-ai/sdk", exts));
473 }
474
475 #[test]
476 fn relative_file_path_is_path() {
477 let exts = &["ts", "tsx", "js", "jsx"];
478 assert!(looks_like_path("src/index.ts", exts));
479 assert!(looks_like_path("lib/utils.js", exts));
480 }
481
482 #[test]
483 fn bare_package_name_is_not_path() {
484 let exts = &["ts", "tsx", "js", "jsx"];
485 assert!(!looks_like_path("zod", exts));
486 assert!(!looks_like_path("express", exts));
487 assert!(looks_like_path("highlight.js", exts));
490 }
491
492 #[test]
493 fn file_with_extension_is_path() {
494 let exts = &["ts", "tsx", "js", "jsx", "py"];
495 assert!(looks_like_path("utils.ts", exts));
496 assert!(looks_like_path("main.py", exts));
497 assert!(!looks_like_path("utils.txt", exts));
498 }
499
500 #[test]
501 fn resolve_target_falls_back_to_package_for_extension_name() {
502 let (_tmp, entry) = test_project();
503 let session = Session::open(&entry, true).unwrap();
504 let resolved = session.resolve_target("six.py");
507 assert!(!resolved.exists);
508 assert!(matches!(resolved.target, ChainTarget::Package(ref name) if name == "six.py"));
509 }
510
511 #[test]
512 fn imports_lists_direct_dependencies() {
513 let (_tmp, entry) = test_project();
514 let session = Session::open(&entry, true).unwrap();
515 let imports = session.imports(session.entry()).unwrap();
516 assert_eq!(imports.len(), 1);
517 assert!(imports[0].0.ends_with("a.ts"));
518 assert!(matches!(imports[0].1, EdgeKind::Static));
519 }
520
521 #[test]
522 fn importers_lists_reverse_dependencies() {
523 let (_tmp, entry) = test_project();
524 let session = Session::open(&entry, true).unwrap();
525 let a_path = session.root().join("a.ts");
526 let importers = session.importers(&a_path).unwrap();
527 assert_eq!(importers.len(), 1);
528 assert!(importers[0].0.ends_with("index.ts"));
529 }
530
531 #[test]
532 fn set_entry_switches_entry_point() {
533 let tmp = tempfile::tempdir().unwrap();
534 let root = tmp.path().canonicalize().unwrap();
535 std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
536 let a = root.join("a.ts");
537 std::fs::write(&a, r#"import { x } from "./b";"#).unwrap();
538 let b = root.join("b.ts");
539 std::fs::write(&b, "export const x = 1;").unwrap();
540
541 let mut session = Session::open(&a, true).unwrap();
542 assert!(session.entry().ends_with("a.ts"));
543 session.set_entry(&b).unwrap();
544 assert!(session.entry().ends_with("b.ts"));
545 let result = session.trace(&crate::query::TraceOptions::default());
547 assert_eq!(result.static_module_count, 1);
548 }
549
550 #[test]
551 fn refresh_detects_file_change() {
552 let tmp = tempfile::tempdir().unwrap();
553 let root = tmp.path().canonicalize().unwrap();
554 std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
555 let entry = root.join("index.ts");
556 std::fs::write(&entry, r#"import { x } from "./a";"#).unwrap();
557 std::fs::write(root.join("a.ts"), "export const x = 1;").unwrap();
558
559 let mut session = Session::open(&entry, true).unwrap();
560 assert_eq!(session.graph().module_count(), 2);
561
562 std::thread::sleep(std::time::Duration::from_millis(50));
564 std::fs::write(
565 &entry,
566 r#"import { x } from "./a"; import { y } from "./b";"#,
567 )
568 .unwrap();
569 std::fs::write(root.join("b.ts"), "export const y = 2;").unwrap();
570
571 let changed = session.refresh().unwrap();
572 assert!(changed);
573 assert_eq!(session.graph().module_count(), 3);
574 }
575
576 #[test]
577 fn entry_label_includes_project_dir() {
578 let (_tmp, entry) = test_project();
579 let session = Session::open(&entry, true).unwrap();
580 let label = session.entry_label();
581 assert!(label.ends_with("index.ts"));
583 assert!(label.contains('/'));
584 }
585}