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