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.
75pub fn changed_file_indices(
76    previous: &[SourceFingerprint],
77    current: &[SourceFingerprint],
78) -> Option<Vec<usize>> {
79    if previous.len() != current.len() {
80        return None;
81    }
82    Some(
83        previous
84            .iter()
85            .zip(current)
86            .enumerate()
87            .filter_map(|(index, (before, after))| (before != after).then_some(index))
88            .collect(),
89    )
90}
91
92/// Put the modules of the parsed files in place of their old modules.
93///
94/// `modules` is in file order, one module for each file that could be read.
95/// `reparsed` names the files that were parsed again, and `fresh` holds their
96/// new modules in file order. A file that could not be read has no module, so
97/// a file can gain or lose its module here.
98///
99/// When no other owner shares `modules` and each parsed file keeps exactly
100/// one module, the modules are replaced in place. Otherwise the function
101/// builds a new module list.
102pub fn merge_reparsed_modules(
103    modules: &mut Arc<[ModuleInfo]>,
104    reparsed: &[FileId],
105    fresh: Vec<ModuleInfo>,
106) {
107    let fresh = match replace_in_place(modules, reparsed, fresh) {
108        Ok(()) => return,
109        Err(fresh) => fresh,
110    };
111    let mut merged = Vec::with_capacity(modules.len() + fresh.len());
112    let mut fresh = fresh.into_iter().peekable();
113    for module in modules.iter() {
114        while let Some(next) = fresh.next_if(|next| next.file_id.0 < module.file_id.0) {
115            merged.push(next);
116        }
117        if !is_reparsed(reparsed, module.file_id) {
118            merged.push(module.clone());
119        }
120    }
121    merged.extend(fresh);
122    *modules = merged.into();
123}
124
125/// `reparsed` is sorted by file id.
126fn is_reparsed(reparsed: &[FileId], file_id: FileId) -> bool {
127    reparsed
128        .binary_search_by_key(&file_id.0, |reparsed| reparsed.0)
129        .is_ok()
130}
131
132/// Replace the old modules in place. Returns the fresh modules when that is
133/// not possible.
134fn replace_in_place(
135    modules: &mut Arc<[ModuleInfo]>,
136    reparsed: &[FileId],
137    fresh: Vec<ModuleInfo>,
138) -> Result<(), Vec<ModuleInfo>> {
139    let old_count = modules
140        .iter()
141        .filter(|module| is_reparsed(reparsed, module.file_id))
142        .count();
143    if old_count != fresh.len() {
144        return Err(fresh);
145    }
146    let Some(slots) = Arc::get_mut(modules) else {
147        return Err(fresh);
148    };
149    let positions: Option<Vec<usize>> = fresh
150        .iter()
151        .map(|module| {
152            slots
153                .binary_search_by_key(&module.file_id.0, |slot| slot.file_id.0)
154                .ok()
155        })
156        .collect();
157    let Some(positions) = positions else {
158        return Err(fresh);
159    };
160    for (position, module) in positions.into_iter().zip(fresh) {
161        slots[position] = module;
162    }
163    Ok(())
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn module(file_id: u32, content_hash: u64) -> ModuleInfo {
171        let mut module = fallow_extract::parse_source_to_module(
172            FileId(file_id),
173            std::path::Path::new("module.ts"),
174            "export const value = 1;\n",
175            content_hash,
176            false,
177        );
178        module.content_hash = content_hash;
179        module
180    }
181
182    fn ids_and_hashes(modules: &[ModuleInfo]) -> Vec<(u32, u64)> {
183        modules
184            .iter()
185            .map(|module| (module.file_id.0, module.content_hash))
186            .collect()
187    }
188
189    #[test]
190    fn changed_file_indices_lists_the_moved_fingerprints() {
191        let before = [
192            SourceFingerprint::new(1, 10),
193            SourceFingerprint::new(2, 20),
194            SourceFingerprint::new(3, 30),
195        ];
196        let after = [
197            SourceFingerprint::new(1, 10),
198            SourceFingerprint::new(9, 21),
199            SourceFingerprint::new(3, 30),
200        ];
201
202        assert_eq!(changed_file_indices(&before, &after), Some(vec![1]));
203        assert_eq!(changed_file_indices(&before, &after[..2]), None);
204    }
205
206    #[test]
207    fn an_unshared_module_list_is_updated_in_place() {
208        let mut modules: Arc<[ModuleInfo]> = vec![module(0, 1), module(1, 1), module(2, 1)].into();
209        let before = Arc::as_ptr(&modules).cast::<ModuleInfo>();
210
211        merge_reparsed_modules(&mut modules, &[FileId(1)], vec![module(1, 2)]);
212
213        assert_eq!(ids_and_hashes(&modules), vec![(0, 1), (1, 2), (2, 1)]);
214        assert_eq!(
215            Arc::as_ptr(&modules).cast::<ModuleInfo>(),
216            before,
217            "no other owner holds the list, so the update needs no copy"
218        );
219    }
220
221    #[test]
222    fn a_shared_module_list_is_copied_and_the_other_owner_keeps_the_old_modules() {
223        let mut modules: Arc<[ModuleInfo]> = vec![module(0, 1), module(1, 1)].into();
224        let other_owner = Arc::clone(&modules);
225
226        merge_reparsed_modules(&mut modules, &[FileId(0)], vec![module(0, 2)]);
227
228        assert_eq!(ids_and_hashes(&modules), vec![(0, 2), (1, 1)]);
229        assert_eq!(ids_and_hashes(&other_owner), vec![(0, 1), (1, 1)]);
230    }
231
232    #[test]
233    fn a_file_can_gain_or_lose_its_module() {
234        let mut modules: Arc<[ModuleInfo]> = vec![module(0, 1), module(2, 1)].into();
235
236        merge_reparsed_modules(&mut modules, &[FileId(1), FileId(2)], vec![module(1, 2)]);
237
238        assert_eq!(
239            ids_and_hashes(&modules),
240            vec![(0, 1), (1, 2)],
241            "file 1 became readable and file 2 did not, so the list follows the files"
242        );
243    }
244
245    const MODULE_COUNT: usize = 4;
246
247    /// A project with an entry that imports each module. Each module has one
248    /// unused export.
249    fn write_project(root: &std::path::Path) {
250        std::fs::create_dir_all(root.join("src")).expect("create source directory");
251        std::fs::write(
252            root.join("package.json"),
253            r#"{"name":"session-reuse","private":true,"main":"src/index.ts"}"#,
254        )
255        .expect("write package");
256        let mut entry = String::new();
257        for index in 0..MODULE_COUNT {
258            use std::fmt::Write as _;
259            std::fs::write(
260                root.join(format!("src/module{index}.ts")),
261                format!(
262                    "export const used{index} = {index};\nexport const unused{index} = {index};\n"
263                ),
264            )
265            .expect("write module");
266            write!(
267                entry,
268                "import {{ used{index} }} from './module{index}';\nconsole.log(used{index});\n"
269            )
270            .expect("write to a String");
271        }
272        std::fs::write(root.join("src/index.ts"), entry).expect("write entry");
273    }
274
275    fn unused_export_names(session: &crate::session::AnalysisSession) -> Vec<String> {
276        let mut names: Vec<String> = session
277            .analyze_dead_code()
278            .expect("analysis succeeds")
279            .results
280            .unused_exports
281            .iter()
282            .map(|finding| finding.export.export_name.clone())
283            .collect();
284        names.sort();
285        names
286    }
287
288    fn file_count(session: &crate::session::AnalysisSession) -> usize {
289        session.files().len()
290    }
291
292    #[test]
293    fn a_warm_session_parses_only_the_changed_file() {
294        let project = tempfile::tempdir().expect("project");
295        write_project(project.path());
296        let session = crate::session::AnalysisSession::load_default(project.path());
297        unused_export_names(&session);
298        let first = session.parse_counts();
299
300        std::fs::write(
301            project.path().join("src/module1.ts"),
302            "export const used1 = 1;\nexport const unused1 = 1;\nexport const added = 1;\n",
303        )
304        .expect("add an unused export");
305        let names = unused_export_names(&session);
306
307        assert!(names.contains(&"added".to_string()), "{names:?}");
308        assert_eq!(
309            session.parse_counts().since(first),
310            SessionParseCounts {
311                modules_parsed: 1,
312                disk_cache_hits: 0,
313                modules_reused: MODULE_COUNT,
314            },
315            "only the changed file is parsed, and the persisted cache is not read"
316        );
317    }
318
319    #[test]
320    fn a_refreshed_session_sees_created_and_deleted_files() {
321        let project = tempfile::tempdir().expect("project");
322        write_project(project.path());
323        let mut session = crate::session::AnalysisSession::load_default(project.path());
324        unused_export_names(&session);
325
326        assert!(!session.refresh_discovery(), "nothing changed on disk");
327        std::fs::remove_file(project.path().join("src/module3.ts")).expect("delete module");
328        std::fs::write(
329            project.path().join("src/index.ts"),
330            "import { used0 } from './module0';\nconsole.log(used0);\n",
331        )
332        .expect("drop the imports");
333        std::fs::write(
334            project.path().join("src/created.ts"),
335            "export const fresh = 1;\n",
336        )
337        .expect("create a file");
338
339        assert!(session.refresh_discovery(), "the file set changed");
340        assert_eq!(file_count(&session), MODULE_COUNT + 1);
341        let unused_files: Vec<String> = session
342            .analyze_dead_code()
343            .expect("analysis succeeds")
344            .results
345            .unused_files
346            .iter()
347            .map(|finding| {
348                finding
349                    .file
350                    .path
351                    .file_name()
352                    .expect("file name")
353                    .to_string_lossy()
354                    .into_owned()
355            })
356            .collect();
357        assert!(
358            unused_files.contains(&"created.ts".to_string()),
359            "the created file is analyzed: {unused_files:?}"
360        );
361        assert!(
362            !unused_files.contains(&"module3.ts".to_string()),
363            "the deleted file leaves no finding: {unused_files:?}"
364        );
365    }
366
367    #[test]
368    fn a_flush_stores_the_reparsed_module_for_the_next_session() {
369        let project = tempfile::tempdir().expect("project");
370        write_project(project.path());
371        let session = crate::session::AnalysisSession::load_default(project.path());
372        unused_export_names(&session);
373        std::fs::write(
374            project.path().join("src/module1.ts"),
375            "export const used1 = 1;\nexport const changed = 1;\n",
376        )
377        .expect("change a module");
378        unused_export_names(&session);
379        session.flush_parse_cache();
380        drop(session);
381
382        let next = crate::session::AnalysisSession::load_default(project.path());
383        let names = unused_export_names(&next);
384
385        assert!(names.contains(&"changed".to_string()), "{names:?}");
386        assert_eq!(
387            next.parse_counts().modules_parsed,
388            0,
389            "the persisted cache holds the module of the incremental parse"
390        );
391    }
392
393    #[test]
394    fn a_flush_never_stores_an_older_module_under_a_newer_fingerprint() {
395        let project = tempfile::tempdir().expect("project");
396        write_project(project.path());
397        let session = crate::session::AnalysisSession::load_default(project.path());
398        unused_export_names(&session);
399        std::fs::write(
400            project.path().join("src/module1.ts"),
401            "export const used1 = 1;\nexport const first = 1;\n",
402        )
403        .expect("first change");
404        unused_export_names(&session);
405        std::fs::write(
406            project.path().join("src/module1.ts"),
407            "export const used1 = 1;\nexport const secondChange = 1;\n",
408        )
409        .expect("second change, not analyzed");
410        session.flush_parse_cache();
411        drop(session);
412
413        let next = crate::session::AnalysisSession::load_default(project.path());
414        let names = unused_export_names(&next);
415
416        assert!(names.contains(&"secondChange".to_string()), "{names:?}");
417        assert!(!names.contains(&"first".to_string()), "{names:?}");
418    }
419
420    /// The read failures and parse degradations of the project, as file
421    /// names with a kind.
422    fn source_diagnostics(session: &crate::session::AnalysisSession) -> Vec<String> {
423        use fallow_types::workspace::WorkspaceDiagnosticKind;
424        let mut found: Vec<String> = session
425            .current_workspace_diagnostics()
426            .into_iter()
427            .filter_map(|diagnostic| {
428                let kind = match diagnostic.kind {
429                    WorkspaceDiagnosticKind::SourceReadFailure { .. } => "read",
430                    WorkspaceDiagnosticKind::SourceParseDegraded { .. } => "parse",
431                    _ => return None,
432                };
433                let name = diagnostic.path.file_name()?.to_string_lossy().into_owned();
434                Some(format!("{kind}:{name}"))
435            })
436            .collect();
437        found.sort();
438        found
439    }
440
441    #[test]
442    fn an_incremental_parse_keeps_the_source_diagnostics_of_the_project_current() {
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        assert!(source_diagnostics(&session).is_empty());
448
449        std::fs::write(
450            project.path().join("src/module1.ts"),
451            "export const used1 = ;\n",
452        )
453        .expect("break the syntax");
454        std::fs::write(project.path().join("src/module2.ts"), [0xff, 0xfe, 0x00])
455            .expect("invalid UTF-8");
456        unused_export_names(&session);
457        assert_eq!(
458            source_diagnostics(&session),
459            ["parse:module1.ts", "read:module2.ts"],
460            "the incremental parse reports the new problems"
461        );
462
463        std::fs::write(
464            project.path().join("src/module1.ts"),
465            "export const used1 = 1;\n",
466        )
467        .expect("fix the syntax");
468        unused_export_names(&session);
469        assert_eq!(
470            source_diagnostics(&session),
471            ["read:module2.ts"],
472            "the fixed file loses its entry, and the unchanged file keeps its entry"
473        );
474        assert_eq!(session.parse_counts().disk_cache_hits, 0, "no full parse");
475    }
476
477    #[test]
478    fn counts_since_an_earlier_snapshot_give_the_work_of_one_run() {
479        let cells = ParseCountCells::default();
480        cells.record(SessionParseCounts {
481            modules_parsed: 3,
482            disk_cache_hits: 0,
483            modules_reused: 0,
484        });
485        let first = cells.snapshot();
486        cells.record(SessionParseCounts {
487            modules_parsed: 1,
488            disk_cache_hits: 0,
489            modules_reused: 2,
490        });
491
492        assert_eq!(
493            cells.snapshot().since(first),
494            SessionParseCounts {
495                modules_parsed: 1,
496                disk_cache_hits: 0,
497                modules_reused: 2,
498            }
499        );
500    }
501}