Skip to main content

chainsaw/
loader.rs

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