Skip to main content

chainsaw/
loader.rs

1//! Entry point for building or loading a cached dependency graph.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use crate::cache::{self, CacheWriteHandle, ParseCache};
7use crate::error::Error;
8use crate::graph::ModuleGraph;
9use crate::lang::{self, LanguageSupport};
10use crate::vfs::{OsVfs, Vfs};
11use crate::walker;
12
13/// Result of loading or building a dependency graph.
14#[derive(Debug)]
15pub struct LoadedGraph {
16    pub graph: ModuleGraph,
17    /// Project root directory.
18    pub root: PathBuf,
19    /// Canonicalized entry point path.
20    pub entry: PathBuf,
21    /// File extensions recognized by the detected language.
22    pub valid_extensions: &'static [&'static str],
23    /// Whether the graph was loaded from cache (true) or built fresh (false).
24    pub from_cache: bool,
25    /// Total count of unresolvable dynamic imports.
26    pub unresolvable_dynamic_count: usize,
27    /// Files containing unresolvable dynamic imports, with per-file counts.
28    pub unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
29    /// Warnings from files that could not be opened, read, or parsed.
30    pub file_warnings: Vec<String>,
31}
32
33/// Load a dependency graph using the real filesystem.
34///
35/// Convenience wrapper around [`load_graph_with_vfs`] that uses [`OsVfs`].
36#[must_use = "the CacheWriteHandle joins a background thread on drop"]
37pub fn load_graph(entry: &Path, no_cache: bool) -> Result<(LoadedGraph, CacheWriteHandle), Error> {
38    load_graph_with_vfs(entry, no_cache, Arc::new(OsVfs))
39}
40
41/// Load a dependency graph from the given entry point using a custom VFS.
42///
43/// Validates the entry path, detects the project kind, and either loads
44/// a cached graph or builds one from scratch using BFS discovery.
45///
46/// The returned [`CacheWriteHandle`] must be kept alive until you are done
47/// with the graph — it joins a background cache-write thread on drop.
48#[must_use = "the CacheWriteHandle joins a background thread on drop"]
49#[allow(clippy::needless_pass_by_value)] // Arc is cloned into lang support implementations
50pub fn load_graph_with_vfs(
51    entry: &Path,
52    no_cache: bool,
53    vfs: Arc<dyn Vfs>,
54) -> Result<(LoadedGraph, CacheWriteHandle), Error> {
55    let entry = vfs
56        .canonicalize(entry)
57        .map_err(|e| Error::EntryNotFound(entry.to_path_buf(), e))?;
58
59    if vfs.is_dir(&entry) {
60        return Err(Error::EntryIsDirectory(entry));
61    }
62
63    let (root, kind) = lang::detect_project(&entry, &*vfs).ok_or_else(|| {
64        let ext = entry.extension().and_then(|e| e.to_str()).map(String::from);
65        Error::UnsupportedFileType(ext)
66    })?;
67
68    let lang_support: Box<dyn LanguageSupport> = match kind {
69        lang::ProjectKind::TypeScript => Box::new(lang::typescript::TypeScriptSupport::with_vfs(
70            &root,
71            vfs.clone(),
72        )),
73        lang::ProjectKind::Python => {
74            Box::new(lang::python::PythonSupport::with_vfs(&root, vfs.clone()))
75        }
76    };
77
78    let valid_extensions = lang_support.extensions();
79    let (result, handle) = build_or_load(&entry, &root, no_cache, lang_support.as_ref(), &*vfs);
80
81    Ok((
82        LoadedGraph {
83            graph: result.graph,
84            root,
85            entry,
86            valid_extensions,
87            from_cache: result.from_cache,
88            unresolvable_dynamic_count: result.unresolvable_dynamic_count,
89            unresolvable_dynamic_files: result.unresolvable_dynamic_files,
90            file_warnings: result.file_warnings,
91        },
92        handle,
93    ))
94}
95
96// ---------------------------------------------------------------------------
97// Internal helpers (moved from main.rs)
98// ---------------------------------------------------------------------------
99
100struct BuildResult {
101    graph: ModuleGraph,
102    unresolvable_dynamic_count: usize,
103    unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
104    file_warnings: Vec<String>,
105    from_cache: bool,
106}
107
108fn build_or_load(
109    entry: &Path,
110    root: &Path,
111    no_cache: bool,
112    lang: &dyn LanguageSupport,
113    vfs: &dyn Vfs,
114) -> (BuildResult, CacheWriteHandle) {
115    let mut cache = if no_cache {
116        ParseCache::new()
117    } else {
118        ParseCache::load(root)
119    };
120
121    // Tier 1: try whole-graph cache
122    if !no_cache {
123        let resolve_fn = |spec: &str| lang.resolve(root, spec).is_some();
124        match cache.try_load_graph(entry, &resolve_fn) {
125            cache::GraphCacheResult::Hit {
126                graph,
127                unresolvable_dynamic,
128                unresolvable_dynamic_files,
129                unresolved_specifiers,
130                needs_resave,
131            } => {
132                let handle = if needs_resave {
133                    cache.save(
134                        root,
135                        entry,
136                        &graph,
137                        unresolved_specifiers,
138                        unresolvable_dynamic,
139                        unresolvable_dynamic_files.clone(),
140                    )
141                } else {
142                    CacheWriteHandle::none()
143                };
144                return (
145                    BuildResult {
146                        graph,
147                        unresolvable_dynamic_count: unresolvable_dynamic,
148                        unresolvable_dynamic_files,
149                        file_warnings: Vec::new(),
150                        from_cache: true,
151                    },
152                    handle,
153                );
154            }
155            cache::GraphCacheResult::Stale {
156                mut graph,
157                unresolvable_dynamic,
158                unresolvable_dynamic_files,
159                changed_files,
160            } => {
161                // Tier 1.5: incremental update — re-parse only changed files,
162                // reuse the cached graph if imports haven't changed.
163                if let Some(result) = try_incremental_update(
164                    &mut cache,
165                    &mut graph,
166                    &changed_files,
167                    unresolvable_dynamic,
168                    unresolvable_dynamic_files,
169                    lang,
170                    vfs,
171                ) {
172                    graph.compute_package_info();
173                    let handle = cache.save_incremental(
174                        root,
175                        entry,
176                        &graph,
177                        &changed_files,
178                        result.unresolvable_dynamic,
179                        result.unresolvable_dynamic_files.clone(),
180                    );
181                    return (
182                        BuildResult {
183                            graph,
184                            unresolvable_dynamic_count: result.unresolvable_dynamic,
185                            unresolvable_dynamic_files: result.unresolvable_dynamic_files,
186                            file_warnings: Vec::new(),
187                            from_cache: true,
188                        },
189                        handle,
190                    );
191                }
192                // Imports changed — fall through to full BFS
193            }
194            cache::GraphCacheResult::Miss => {}
195        }
196    }
197
198    // Tier 2: BFS walk with per-file parse cache
199    let result = walker::build_graph(entry, root, lang, &mut cache, vfs);
200    let unresolvable_count: usize = result.unresolvable_dynamic.iter().map(|(_, c)| c).sum();
201    let handle = cache.save(
202        root,
203        entry,
204        &result.graph,
205        result.unresolved_specifiers,
206        unresolvable_count,
207        result.unresolvable_dynamic.clone(),
208    );
209    (
210        BuildResult {
211            graph: result.graph,
212            unresolvable_dynamic_count: unresolvable_count,
213            unresolvable_dynamic_files: result.unresolvable_dynamic,
214            file_warnings: result.file_warnings,
215            from_cache: false,
216        },
217        handle,
218    )
219}
220
221struct IncrementalResult {
222    unresolvable_dynamic: usize,
223    unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
224}
225
226/// Try to incrementally update the cached graph when only a few files changed.
227/// Re-parses the changed files and checks if their imports match the old parse.
228/// Returns None if imports changed (caller should fall back to full BFS).
229#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
230fn try_incremental_update(
231    cache: &mut ParseCache,
232    graph: &mut ModuleGraph,
233    changed_files: &[PathBuf],
234    old_unresolvable_total: usize,
235    mut unresolvable_files: Vec<(PathBuf, usize)>,
236    lang: &dyn LanguageSupport,
237    vfs: &dyn Vfs,
238) -> Option<IncrementalResult> {
239    let mut unresolvable_delta: isize = 0;
240
241    for path in changed_files {
242        // Get old imports without mtime check
243        let old_result = cache.lookup_unchecked(path)?;
244        let old_import_count = old_result.imports.len();
245        let old_unresolvable = old_result.unresolvable_dynamic;
246        let old_imports: Vec<_> = old_result
247            .imports
248            .iter()
249            .map(|i| (i.specifier.as_str(), i.kind))
250            .collect();
251
252        // Re-parse the changed file
253        let source = vfs.read_to_string(path).ok()?;
254        let new_result = lang.parse(path, &source).ok()?;
255
256        // Compare import lists — if anything changed, bail out
257        if new_result.imports.len() != old_import_count
258            || new_result.imports.iter().zip(old_imports.iter()).any(
259                |(new, &(old_spec, old_kind))| new.specifier != old_spec || new.kind != old_kind,
260            )
261        {
262            return None;
263        }
264
265        // Track unresolvable dynamic count changes
266        unresolvable_delta += new_result.unresolvable_dynamic as isize - old_unresolvable as isize;
267
268        // Update per-file unresolvable list
269        unresolvable_files.retain(|(p, _)| p != path);
270        if new_result.unresolvable_dynamic > 0 {
271            unresolvable_files.push((path.clone(), new_result.unresolvable_dynamic));
272        }
273
274        // Update file size in graph
275        let mid = *graph.path_to_id.get(path)?;
276        let new_size = source.len() as u64;
277        graph.modules[mid.0 as usize].size_bytes = new_size;
278
279        // Update parse cache entry
280        #[allow(clippy::or_fun_call)]
281        let dir = path.parent().unwrap_or(Path::new("."));
282        let resolved_paths: Vec<Option<PathBuf>> = new_result
283            .imports
284            .iter()
285            .map(|imp| lang.resolve(dir, &imp.specifier))
286            .collect();
287        if let Ok(meta) = vfs.metadata(path)
288            && let Some(mtime) = meta.mtime_nanos
289        {
290            cache.insert(path.clone(), new_size, mtime, new_result, resolved_paths);
291        }
292    }
293
294    let new_total = (old_unresolvable_total as isize + unresolvable_delta).max(0) as usize;
295    debug_assert_eq!(
296        new_total,
297        unresolvable_files.iter().map(|(_, c)| c).sum::<usize>(),
298        "unresolvable_dynamic total drifted from per-file sum"
299    );
300    Some(IncrementalResult {
301        unresolvable_dynamic: new_total,
302        unresolvable_dynamic_files: unresolvable_files,
303    })
304}