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(
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
109fn 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
118pub 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
134pub 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
167fn 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
174fn 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 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 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}