Skip to main content

chainsaw/
session.rs

1//! Session: owns a loaded dependency graph and exposes query methods.
2//!
3//! A [`Session`] is the primary interface for library consumers (CLI, REPL,
4//! language server). It wraps graph loading, entry resolution, and keeps the
5//! background cache-write handle alive for the duration of the session.
6
7use 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
16/// The result of resolving a `--chain`/`--cut` argument against the graph.
17///
18/// The argument might be a file path (resolved to a [`ChainTarget::Module`])
19/// or a package name (resolved to a [`ChainTarget::Package`]).
20pub struct ResolvedTarget {
21    pub target: ChainTarget,
22    pub label: String,
23    pub exists: bool,
24}
25
26/// An open dependency-graph session.
27///
28/// Created via [`Session::open`], which loads (or builds) the graph and
29/// resolves the entry module. The background cache writer is joined on drop.
30pub 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    /// Load a dependency graph from `entry` and resolve the entry module.
54    ///
55    /// When `no_cache` is true the on-disk cache is bypassed entirely.
56    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    /// Trace transitive import weight from the entry module.
83    pub fn trace(&self, opts: &TraceOptions) -> TraceResult {
84        query::trace(&self.graph, self.entry_id, opts)
85    }
86
87    /// Trace transitive import weight from an arbitrary file in the graph.
88    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    /// Resolve a chain/cut argument to a [`ChainTarget`].
104    ///
105    /// If the argument looks like a file path and resolves to a module in
106    /// the graph, returns `ChainTarget::Module`. Otherwise falls through
107    /// to a package name lookup.
108    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        // File doesn't exist or isn't in the graph -- fall through to
126        // package name lookup. Handles packages like "six.py" or
127        // "highlight.js" whose names match file extensions.
128        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    /// Find all shortest import chains from the entry to a target.
139    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    /// Find import chains and optimal cut points to sever them.
155    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    /// Trace from a different entry point in the same graph and diff
180    /// against the current entry. Returns the diff and the canonical
181    /// path of the other entry (avoids redundant canonicalization by
182    /// the caller).
183    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    /// All third-party packages in the dependency graph.
202    pub fn packages(&self) -> &HashMap<String, PackageInfo> {
203        &self.graph.package_map
204    }
205
206    /// List direct imports of a file (outgoing edges).
207    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    /// List files that import a given file (reverse edge lookup).
228    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    /// Look up package info by name.
247    pub fn info(&self, package_name: &str) -> Option<&PackageInfo> {
248        self.graph.package_map.get(package_name)
249    }
250
251    /// Display label for the current entry point, including the project
252    /// directory name for disambiguation (e.g. `wrangler/src/index.ts`).
253    pub fn entry_label(&self) -> String {
254        self.entry_label_for(&self.entry)
255    }
256
257    /// Display label for an arbitrary path, relative to the project root.
258    pub fn entry_label_for(&self, path: &Path) -> String {
259        entry_label(path, &self.root)
260    }
261
262    /// Switch the default entry point to a different file in the graph.
263    ///
264    /// The file must already be in the graph (no rebuild). Accepts both
265    /// absolute paths and paths relative to the project root.
266    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    /// Check for file changes and rebuild the graph if needed.
280    ///
281    /// Returns `true` if the graph was updated (cold build or module count
282    /// changed since the last load).
283    #[allow(clippy::used_underscore_binding)] // _cache_handle held for drop
284    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        // Detect structural change: cold build (not from cache) or module count
290        // changed. When from_cache is true and module count matches, edges are
291        // guaranteed identical (tier 1.5 only returns from_cache when imports
292        // are unchanged), so we can reuse the existing reverse adjacency index.
293        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    // -- accessors --
318
319    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
356/// Build a display label for an entry point that includes the project
357/// directory name for disambiguation (e.g. `wrangler/src/index.ts`
358/// instead of just `src/index.ts`).
359pub 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
367/// Determine whether a chain/cut argument looks like a file path
368/// (as opposed to a package name).
369pub 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        // index.ts -> a.ts is a 1-hop chain, no intermediate to cut
416        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        // a.ts imports b.ts (so both are in the graph when built from a.ts)
428        // b.ts imports extra.ts (so tracing from b.ts has more weight)
429        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        // b.ts trace (b + extra) should have less weight than a.ts trace (a + b + extra)
438        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        // Test project has no third-party packages
446        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        // highlight.js is ambiguous — .js extension triggers path heuristic.
488        // resolve_target tries as file path first, falls back to package lookup.
489        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        // "six.py" looks like a file (.py extension) but no such file exists,
505        // so it falls back to package name lookup.
506        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        // Tracing from b: only b itself (no imports).
546        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        // Modify entry to add a new import; sleep for mtime granularity.
563        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        // The temp dir has a name, so label should be "dirname/index.ts"
582        assert!(label.ends_with("index.ts"));
583        assert!(label.contains('/'));
584    }
585}