1use 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
14pub const MAX_INCREMENTAL_REPARSE_FILES: usize = 256;
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct SessionParseCounts {
26 pub modules_parsed: usize,
28 pub disk_cache_hits: usize,
30 pub modules_reused: usize,
32}
33
34impl SessionParseCounts {
35 #[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#[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
73pub 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
92pub 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
125fn 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
132fn 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 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 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}