Skip to main content

fallow_engine/
session_reuse.rs

1//! Parse reuse for a session that lives across several analysis runs.
2//!
3//! An editor keeps one session per project root. Between two runs, only a few
4//! files change. The session then parses those files again and keeps the other
5//! modules in memory, so a run does not read the persisted parse cache again.
6
7use std::sync::Arc;
8use std::sync::atomic::{AtomicUsize, Ordering};
9
10use fallow_types::discover::FileId;
11use fallow_types::extract::ModuleInfo;
12use fallow_types::source_fingerprint::SourceFingerprint;
13
14/// The most files that one incremental parse handles. A larger change set,
15/// such as a branch switch, takes the full parse path. That path can serve
16/// files from the persisted parse cache and writes that cache back.
17pub const MAX_INCREMENTAL_REPARSE_FILES: usize = 256;
18
19/// The parse work that a session did since it was created.
20///
21/// The counts cover the parses that go through the module cache of the
22/// session. They grow over the life of the session, so a caller takes the
23/// difference of two snapshots to get the work of one run.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct SessionParseCounts {
26    /// Files parsed from source.
27    pub modules_parsed: usize,
28    /// Files served from the persisted parse cache.
29    pub disk_cache_hits: usize,
30    /// Files served from the in-memory modules of the session.
31    pub modules_reused: usize,
32}
33
34impl SessionParseCounts {
35    /// The work between an earlier snapshot and this one.
36    #[must_use]
37    pub const fn since(self, earlier: Self) -> Self {
38        Self {
39            modules_parsed: self.modules_parsed.saturating_sub(earlier.modules_parsed),
40            disk_cache_hits: self.disk_cache_hits.saturating_sub(earlier.disk_cache_hits),
41            modules_reused: self.modules_reused.saturating_sub(earlier.modules_reused),
42        }
43    }
44}
45
46/// Thread-safe counters behind [`SessionParseCounts`].
47#[derive(Debug, Default)]
48pub struct ParseCountCells {
49    modules_parsed: AtomicUsize,
50    disk_cache_hits: AtomicUsize,
51    modules_reused: AtomicUsize,
52}
53
54impl ParseCountCells {
55    pub fn record(&self, counts: SessionParseCounts) {
56        self.modules_parsed
57            .fetch_add(counts.modules_parsed, Ordering::Relaxed);
58        self.disk_cache_hits
59            .fetch_add(counts.disk_cache_hits, Ordering::Relaxed);
60        self.modules_reused
61            .fetch_add(counts.modules_reused, Ordering::Relaxed);
62    }
63
64    pub fn snapshot(&self) -> SessionParseCounts {
65        SessionParseCounts {
66            modules_parsed: self.modules_parsed.load(Ordering::Relaxed),
67            disk_cache_hits: self.disk_cache_hits.load(Ordering::Relaxed),
68            modules_reused: self.modules_reused.load(Ordering::Relaxed),
69        }
70    }
71}
72
73/// The positions whose fingerprint changed. `None` when the two lists do not
74/// describe the same files, because then the positions do not line up.
75///
76/// `previous_read_started_ns` is the time before the session read `previous`.
77/// A same-length write in the same timestamp tick keeps the fingerprint, so a
78/// previous fingerprint that did not settle before that time proves nothing.
79/// For such a file, `content_matches` reads the source and compares it with
80/// the kept module. The file counts as changed when the content differs or
81/// cannot be read. A fingerprint without a ctime compares by equality, as
82/// before; the session drops such a parse on
83/// [`refresh_discovery`](crate::session::AnalysisSession::refresh_discovery).
84pub fn changed_file_indices(
85    previous: &[SourceFingerprint],
86    previous_read_started_ns: u64,
87    current: &[SourceFingerprint],
88    content_matches: &dyn Fn(usize) -> bool,
89) -> Option<Vec<usize>> {
90    if previous.len() != current.len() {
91        return None;
92    }
93    Some(
94        previous
95            .iter()
96            .zip(current)
97            .enumerate()
98            .filter_map(|(index, (before, after))| {
99                let changed = before != after
100                    || (before.is_trustworthy_without_content()
101                        && !before.is_settled_before(previous_read_started_ns)
102                        && !content_matches(index));
103                changed.then_some(index)
104            })
105            .collect(),
106    )
107}
108
109/// Whether the source at `path` has the content hash `expected`, with the
110/// same BOM handling as the parse. An unreadable file does not match.
111fn source_has_content_hash(path: &std::path::Path, expected: u64) -> bool {
112    std::fs::read_to_string(path).is_ok_and(|source| {
113        let source = source.strip_prefix('\u{feff}').unwrap_or(&source);
114        xxhash_rust::xxh3::xxh3_64(source.as_bytes()) == expected
115    })
116}
117
118/// Whether the module of `file_id` in `modules` has the same content hash as
119/// the source at `path`. `modules` is in file order. A file without a module
120/// could not be read, so it matches while it still cannot be read.
121pub fn module_matches_source(
122    modules: &[ModuleInfo],
123    file_id: FileId,
124    path: &std::path::Path,
125) -> bool {
126    match modules.binary_search_by_key(&file_id.0, |module| module.file_id.0) {
127        Ok(position) => modules
128            .get(position)
129            .is_some_and(|module| source_has_content_hash(path, module.content_hash)),
130        Err(_) => std::fs::read_to_string(path).is_err(),
131    }
132}
133
134/// Put the modules of the parsed files in place of their old modules.
135///
136/// `modules` is in file order, one module for each file that could be read.
137/// `reparsed` names the files that were parsed again, and `fresh` holds their
138/// new modules in file order. A file that could not be read has no module, so
139/// a file can gain or lose its module here.
140///
141/// When no other owner shares `modules` and each parsed file keeps exactly
142/// one module, the modules are replaced in place. Otherwise the function
143/// builds a new module list.
144pub fn merge_reparsed_modules(
145    modules: &mut Arc<[ModuleInfo]>,
146    reparsed: &[FileId],
147    fresh: Vec<ModuleInfo>,
148) {
149    let fresh = match replace_in_place(modules, reparsed, fresh) {
150        Ok(()) => return,
151        Err(fresh) => fresh,
152    };
153    let mut merged = Vec::with_capacity(modules.len() + fresh.len());
154    let mut fresh = fresh.into_iter().peekable();
155    for module in modules.iter() {
156        while let Some(next) = fresh.next_if(|next| next.file_id.0 < module.file_id.0) {
157            merged.push(next);
158        }
159        if !is_reparsed(reparsed, module.file_id) {
160            merged.push(module.clone());
161        }
162    }
163    merged.extend(fresh);
164    *modules = merged.into();
165}
166
167/// `reparsed` is sorted by file id.
168fn is_reparsed(reparsed: &[FileId], file_id: FileId) -> bool {
169    reparsed
170        .binary_search_by_key(&file_id.0, |reparsed| reparsed.0)
171        .is_ok()
172}
173
174/// Replace the old modules in place. Returns the fresh modules when that is
175/// not possible.
176fn replace_in_place(
177    modules: &mut Arc<[ModuleInfo]>,
178    reparsed: &[FileId],
179    fresh: Vec<ModuleInfo>,
180) -> Result<(), Vec<ModuleInfo>> {
181    let old_count = modules
182        .iter()
183        .filter(|module| is_reparsed(reparsed, module.file_id))
184        .count();
185    if old_count != fresh.len() {
186        return Err(fresh);
187    }
188    let Some(slots) = Arc::get_mut(modules) else {
189        return Err(fresh);
190    };
191    let positions: Option<Vec<usize>> = fresh
192        .iter()
193        .map(|module| {
194            slots
195                .binary_search_by_key(&module.file_id.0, |slot| slot.file_id.0)
196                .ok()
197        })
198        .collect();
199    let Some(positions) = positions else {
200        return Err(fresh);
201    };
202    for (position, module) in positions.into_iter().zip(fresh) {
203        slots[position] = module;
204    }
205    Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use fallow_types::source_fingerprint::TIMESTAMP_SETTLE_WINDOW_NS;
212
213    fn module(file_id: u32, content_hash: u64) -> ModuleInfo {
214        let mut module = fallow_extract::parse_source_to_module(
215            FileId(file_id),
216            std::path::Path::new("module.ts"),
217            "export const value = 1;\n",
218            content_hash,
219            false,
220        );
221        module.content_hash = content_hash;
222        module
223    }
224
225    fn ids_and_hashes(modules: &[ModuleInfo]) -> Vec<(u32, u64)> {
226        modules
227            .iter()
228            .map(|module| (module.file_id.0, module.content_hash))
229            .collect()
230    }
231
232    #[test]
233    fn changed_file_indices_lists_the_moved_fingerprints() {
234        let before = [
235            SourceFingerprint::new(1, 10),
236            SourceFingerprint::new(2, 20),
237            SourceFingerprint::new(3, 30),
238        ];
239        let after = [
240            SourceFingerprint::new(1, 10),
241            SourceFingerprint::new(9, 21),
242            SourceFingerprint::new(3, 30),
243        ];
244
245        assert_eq!(
246            changed_file_indices(&before, 0, &after, &|_| false),
247            Some(vec![1])
248        );
249        assert_eq!(
250            changed_file_indices(&before, 0, &after[..2], &|_| false),
251            None
252        );
253    }
254
255    #[test]
256    fn changed_file_indices_checks_the_content_of_a_recent_fingerprint() {
257        let settled = SourceFingerprint::with_ctime(1_000, 1_000, 10);
258        let recent = SourceFingerprint::with_ctime(5_000, 5_000, 20);
259        let read_started = 1_000 + TIMESTAMP_SETTLE_WINDOW_NS;
260        let fingerprints = [settled, recent];
261        let checked = std::cell::RefCell::new(Vec::new());
262        let differs = |index: usize| {
263            checked.borrow_mut().push(index);
264            false
265        };
266
267        assert_eq!(
268            changed_file_indices(&fingerprints, read_started, &fingerprints, &differs),
269            Some(vec![1]),
270            "an unchanged fingerprint inside the window can hide a same-tick write"
271        );
272        assert_eq!(*checked.borrow(), vec![1], "a settled file needs no read");
273        assert_eq!(
274            changed_file_indices(&fingerprints, read_started, &fingerprints, &|_| true),
275            Some(vec![]),
276            "a recent file with the same content keeps its module"
277        );
278    }
279
280    #[test]
281    fn an_unshared_module_list_is_updated_in_place() {
282        let mut modules: Arc<[ModuleInfo]> = vec![module(0, 1), module(1, 1), module(2, 1)].into();
283        let before = Arc::as_ptr(&modules).cast::<ModuleInfo>();
284
285        merge_reparsed_modules(&mut modules, &[FileId(1)], vec![module(1, 2)]);
286
287        assert_eq!(ids_and_hashes(&modules), vec![(0, 1), (1, 2), (2, 1)]);
288        assert_eq!(
289            Arc::as_ptr(&modules).cast::<ModuleInfo>(),
290            before,
291            "no other owner holds the list, so the update needs no copy"
292        );
293    }
294
295    #[test]
296    fn a_shared_module_list_is_copied_and_the_other_owner_keeps_the_old_modules() {
297        let mut modules: Arc<[ModuleInfo]> = vec![module(0, 1), module(1, 1)].into();
298        let other_owner = Arc::clone(&modules);
299
300        merge_reparsed_modules(&mut modules, &[FileId(0)], vec![module(0, 2)]);
301
302        assert_eq!(ids_and_hashes(&modules), vec![(0, 2), (1, 1)]);
303        assert_eq!(ids_and_hashes(&other_owner), vec![(0, 1), (1, 1)]);
304    }
305
306    #[test]
307    fn a_file_can_gain_or_lose_its_module() {
308        let mut modules: Arc<[ModuleInfo]> = vec![module(0, 1), module(2, 1)].into();
309
310        merge_reparsed_modules(&mut modules, &[FileId(1), FileId(2)], vec![module(1, 2)]);
311
312        assert_eq!(
313            ids_and_hashes(&modules),
314            vec![(0, 1), (1, 2)],
315            "file 1 became readable and file 2 did not, so the list follows the files"
316        );
317    }
318
319    const MODULE_COUNT: usize = 4;
320
321    /// A project with an entry that imports each module. Each module has one
322    /// unused export.
323    fn write_project(root: &std::path::Path) {
324        std::fs::create_dir_all(root.join("src")).expect("create source directory");
325        std::fs::write(
326            root.join("package.json"),
327            r#"{"name":"session-reuse","private":true,"main":"src/index.ts"}"#,
328        )
329        .expect("write package");
330        let mut entry = String::new();
331        for index in 0..MODULE_COUNT {
332            use std::fmt::Write as _;
333            std::fs::write(
334                root.join(format!("src/module{index}.ts")),
335                format!(
336                    "export const used{index} = {index};\nexport const unused{index} = {index};\n"
337                ),
338            )
339            .expect("write module");
340            write!(
341                entry,
342                "import {{ used{index} }} from './module{index}';\nconsole.log(used{index});\n"
343            )
344            .expect("write to a String");
345        }
346        std::fs::write(root.join("src/index.ts"), entry).expect("write entry");
347    }
348
349    fn unused_export_names(session: &crate::session::AnalysisSession) -> Vec<String> {
350        let mut names: Vec<String> = session
351            .analyze_dead_code()
352            .expect("analysis succeeds")
353            .results
354            .unused_exports
355            .iter()
356            .map(|finding| finding.export.export_name.clone())
357            .collect();
358        names.sort();
359        names
360    }
361
362    fn file_count(session: &crate::session::AnalysisSession) -> usize {
363        session.files().len()
364    }
365
366    #[test]
367    fn a_warm_session_parses_only_the_changed_file() {
368        let project = tempfile::tempdir().expect("project");
369        write_project(project.path());
370        let session = crate::session::AnalysisSession::load_default(project.path());
371        unused_export_names(&session);
372        let first = session.parse_counts();
373
374        std::fs::write(
375            project.path().join("src/module1.ts"),
376            "export const used1 = 1;\nexport const unused1 = 1;\nexport const added = 1;\n",
377        )
378        .expect("add an unused export");
379        let names = unused_export_names(&session);
380
381        assert!(names.contains(&"added".to_string()), "{names:?}");
382        assert_eq!(
383            session.parse_counts().since(first),
384            SessionParseCounts {
385                modules_parsed: 1,
386                disk_cache_hits: 0,
387                modules_reused: MODULE_COUNT,
388            },
389            "only the changed file is parsed, and the persisted cache is not read"
390        );
391    }
392
393    #[test]
394    fn a_refreshed_session_sees_created_and_deleted_files() {
395        let project = tempfile::tempdir().expect("project");
396        write_project(project.path());
397        let mut session = crate::session::AnalysisSession::load_default(project.path());
398        unused_export_names(&session);
399
400        assert!(!session.refresh_discovery(), "nothing changed on disk");
401        std::fs::remove_file(project.path().join("src/module3.ts")).expect("delete module");
402        std::fs::write(
403            project.path().join("src/index.ts"),
404            "import { used0 } from './module0';\nconsole.log(used0);\n",
405        )
406        .expect("drop the imports");
407        std::fs::write(
408            project.path().join("src/created.ts"),
409            "export const fresh = 1;\n",
410        )
411        .expect("create a file");
412
413        assert!(session.refresh_discovery(), "the file set changed");
414        assert_eq!(file_count(&session), MODULE_COUNT + 1);
415        let unused_files: Vec<String> = session
416            .analyze_dead_code()
417            .expect("analysis succeeds")
418            .results
419            .unused_files
420            .iter()
421            .map(|finding| {
422                finding
423                    .file
424                    .path
425                    .file_name()
426                    .expect("file name")
427                    .to_string_lossy()
428                    .into_owned()
429            })
430            .collect();
431        assert!(
432            unused_files.contains(&"created.ts".to_string()),
433            "the created file is analyzed: {unused_files:?}"
434        );
435        assert!(
436            !unused_files.contains(&"module3.ts".to_string()),
437            "the deleted file leaves no finding: {unused_files:?}"
438        );
439    }
440
441    #[test]
442    fn a_flush_stores_the_reparsed_module_for_the_next_session() {
443        let project = tempfile::tempdir().expect("project");
444        write_project(project.path());
445        let session = crate::session::AnalysisSession::load_default(project.path());
446        unused_export_names(&session);
447        std::fs::write(
448            project.path().join("src/module1.ts"),
449            "export const used1 = 1;\nexport const changed = 1;\n",
450        )
451        .expect("change a module");
452        unused_export_names(&session);
453        session.flush_parse_cache();
454        drop(session);
455
456        let next = crate::session::AnalysisSession::load_default(project.path());
457        let names = unused_export_names(&next);
458
459        assert!(names.contains(&"changed".to_string()), "{names:?}");
460        assert_eq!(
461            next.parse_counts().modules_parsed,
462            0,
463            "the persisted cache holds the module of the incremental parse"
464        );
465    }
466
467    #[test]
468    fn a_flush_never_stores_an_older_module_under_a_newer_fingerprint() {
469        let project = tempfile::tempdir().expect("project");
470        write_project(project.path());
471        let session = crate::session::AnalysisSession::load_default(project.path());
472        unused_export_names(&session);
473        std::fs::write(
474            project.path().join("src/module1.ts"),
475            "export const used1 = 1;\nexport const first = 1;\n",
476        )
477        .expect("first change");
478        unused_export_names(&session);
479        std::fs::write(
480            project.path().join("src/module1.ts"),
481            "export const used1 = 1;\nexport const secondChange = 1;\n",
482        )
483        .expect("second change, not analyzed");
484        session.flush_parse_cache();
485        drop(session);
486
487        let next = crate::session::AnalysisSession::load_default(project.path());
488        let names = unused_export_names(&next);
489
490        assert!(names.contains(&"secondChange".to_string()), "{names:?}");
491        assert!(!names.contains(&"first".to_string()), "{names:?}");
492    }
493
494    /// The read failures and parse degradations of the project, as file
495    /// names with a kind.
496    fn source_diagnostics(session: &crate::session::AnalysisSession) -> Vec<String> {
497        use fallow_types::workspace::WorkspaceDiagnosticKind;
498        let mut found: Vec<String> = session
499            .current_workspace_diagnostics()
500            .into_iter()
501            .filter_map(|diagnostic| {
502                let kind = match diagnostic.kind {
503                    WorkspaceDiagnosticKind::SourceReadFailure { .. } => "read",
504                    WorkspaceDiagnosticKind::SourceParseDegraded { .. } => "parse",
505                    _ => return None,
506                };
507                let name = diagnostic.path.file_name()?.to_string_lossy().into_owned();
508                Some(format!("{kind}:{name}"))
509            })
510            .collect();
511        found.sort();
512        found
513    }
514
515    #[test]
516    fn an_incremental_parse_keeps_the_source_diagnostics_of_the_project_current() {
517        let project = tempfile::tempdir().expect("project");
518        write_project(project.path());
519        let session = crate::session::AnalysisSession::load_default(project.path());
520        unused_export_names(&session);
521        assert!(source_diagnostics(&session).is_empty());
522
523        std::fs::write(
524            project.path().join("src/module1.ts"),
525            "export const used1 = ;\n",
526        )
527        .expect("break the syntax");
528        std::fs::write(project.path().join("src/module2.ts"), [0xff, 0xfe, 0x00])
529            .expect("invalid UTF-8");
530        unused_export_names(&session);
531        assert_eq!(
532            source_diagnostics(&session),
533            ["parse:module1.ts", "read:module2.ts"],
534            "the incremental parse reports the new problems"
535        );
536
537        std::fs::write(
538            project.path().join("src/module1.ts"),
539            "export const used1 = 1;\n",
540        )
541        .expect("fix the syntax");
542        unused_export_names(&session);
543        assert_eq!(
544            source_diagnostics(&session),
545            ["read:module2.ts"],
546            "the fixed file loses its entry, and the unchanged file keeps its entry"
547        );
548        assert_eq!(session.parse_counts().disk_cache_hits, 0, "no full parse");
549    }
550
551    #[test]
552    fn counts_since_an_earlier_snapshot_give_the_work_of_one_run() {
553        let cells = ParseCountCells::default();
554        cells.record(SessionParseCounts {
555            modules_parsed: 3,
556            disk_cache_hits: 0,
557            modules_reused: 0,
558        });
559        let first = cells.snapshot();
560        cells.record(SessionParseCounts {
561            modules_parsed: 1,
562            disk_cache_hits: 0,
563            modules_reused: 2,
564        });
565
566        assert_eq!(
567            cells.snapshot().since(first),
568            SessionParseCounts {
569                modules_parsed: 1,
570                disk_cache_hits: 0,
571                modules_reused: 2,
572            }
573        );
574    }
575}