1use std::collections::{BTreeMap, HashMap, HashSet};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::Instant;
14
15use rayon::prelude::*;
16
17use crate::cache::{ExtractionCache, Fingerprint};
18use crate::error::{Diagnostic, Severity};
19use crate::extractor::{self, ExtractOptions, ExtractionIdGenerators, InMemorySource};
20use crate::input;
21use crate::language::LangId;
22use crate::model::{FileExtraction, SnapshotId};
23use crate::pipeline::GraphAnalysis;
24
25#[derive(Debug, Clone)]
27pub struct Overlay {
28 pub uri: String,
30 pub path: PathBuf,
32 pub text: String,
34 pub version: i32,
36 pub lang: LangId,
38}
39
40#[derive(Debug, Clone, Default)]
42pub struct ChangeSet {
43 pub files_added: usize,
45 pub files_removed: usize,
47 pub files_modified: usize,
49 pub files_unchanged: usize,
51}
52
53pub type ReanalysisOutput = (Vec<Arc<FileExtraction>>, ChangeSet, Vec<Diagnostic>);
55
56pub struct WatchState {
58 pub(crate) cache: ExtractionCache,
59 pub(crate) snapshot_counter: u32,
60}
61
62impl WatchState {
63 pub fn new() -> Self {
65 Self {
66 cache: ExtractionCache::new(),
67 snapshot_counter: 0,
68 }
69 }
70
71 pub fn cache(&self) -> &ExtractionCache {
73 &self.cache
74 }
75
76 pub fn cache_mut(&mut self) -> &mut ExtractionCache {
79 &mut self.cache
80 }
81
82 pub(crate) fn next_snapshot_id(&mut self) -> Result<SnapshotId, crate::Error> {
84 self.snapshot_counter = self
85 .snapshot_counter
86 .checked_add(1)
87 .ok_or(crate::Error::IdExhausted)?;
88 SnapshotId::new(self.snapshot_counter).ok_or(crate::Error::IdExhausted)
89 }
90}
91
92impl Default for WatchState {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98pub fn reanalyze_extractions(
107 root: &Path,
108 languages: Option<&[LangId]>,
109 overlays: &[Overlay],
110 state: &mut WatchState,
111) -> Result<ReanalysisOutput, crate::Error> {
112 let files = input::discover_files(root, languages)?;
113
114 let overlay_by_path: HashMap<PathBuf, &Overlay> = overlays
117 .iter()
118 .map(|overlay| (input::simplified_path(&overlay.path), overlay))
119 .filter(|(path, _)| path.starts_with(root))
120 .collect();
121
122 let mut targets: BTreeMap<PathBuf, LangId> = files.into_iter().collect();
123 for (path, overlay) in &overlay_by_path {
124 targets.entry(path.clone()).or_insert(overlay.lang);
125 }
126
127 let (current_fingerprints, read_diagnostics): (HashMap<PathBuf, Fingerprint>, Vec<Diagnostic>) =
128 targets
129 .par_iter()
130 .fold(
131 || (HashMap::new(), Vec::new()),
132 |(mut map, mut diags), (path, _)| {
133 match overlay_by_path.get(path) {
134 Some(overlay) => {
135 map.insert(path.clone(), Fingerprint::of(overlay.text.as_bytes()));
136 }
137 None => match std::fs::read(path) {
138 Ok(bytes) => {
139 map.insert(path.clone(), Fingerprint::of(&bytes));
140 }
141 Err(err) => diags.push(Diagnostic {
142 path: path.clone(),
143 severity: Severity::Error,
144 message: format!("Failed to read file: {err}"),
145 source_range: None,
146 }),
147 },
148 }
149 (map, diags)
150 },
151 )
152 .reduce(
153 || (HashMap::new(), Vec::new()),
154 |(mut m1, mut d1), (m2, d2)| {
155 m1.extend(m2);
156 d1.extend(d2);
157 (m1, d1)
158 },
159 );
160
161 let mut change_set = ChangeSet::default();
162 let mut changed_disk: Vec<(PathBuf, LangId)> = Vec::new();
163 let mut changed_overlays: Vec<(PathBuf, &Overlay)> = Vec::new();
164
165 let failed_reads: HashSet<PathBuf> = read_diagnostics
168 .iter()
169 .map(|diag| diag.path.clone())
170 .collect();
171
172 for (path, lang) in &targets {
173 let Some(curr_fp) = current_fingerprints.get(path) else {
174 if failed_reads.contains(path) {
175 change_set.files_unchanged += 1;
176 }
177 continue;
178 };
179 let changed = match state.cache.fingerprint_of(path) {
180 Some(cached) if cached == *curr_fp => {
181 change_set.files_unchanged += 1;
182 false
183 }
184 Some(_) => {
185 change_set.files_modified += 1;
186 true
187 }
188 None => {
189 change_set.files_added += 1;
190 true
191 }
192 };
193 if !changed {
194 continue;
195 }
196 match overlay_by_path.get(path) {
197 Some(overlay) => changed_overlays.push((path.clone(), overlay)),
198 None => changed_disk.push((path.clone(), *lang)),
199 }
200 }
201
202 let stale: Vec<PathBuf> = state
203 .cache
204 .paths()
205 .filter(|path| !current_fingerprints.contains_key(*path) && !failed_reads.contains(*path))
206 .cloned()
207 .collect();
208 if !stale.is_empty() {
209 change_set.files_removed += stale.len();
210 for path in &stale {
211 state.cache.remove(path);
212 }
213 }
214
215 let max_id = state.cache.max_symbol_id();
216 let next_symbol_id = max_id.checked_add(1).ok_or(crate::Error::IdExhausted)?;
217 #[cfg(feature = "dataflow")]
218 let next_data_id = state
219 .cache
220 .max_data_node_id()
221 .checked_add(1)
222 .ok_or(crate::Error::IdExhausted)?;
223 #[cfg(feature = "dataflow")]
224 let id_generators = ExtractionIdGenerators::with_starts(next_symbol_id, next_data_id);
225 #[cfg(not(feature = "dataflow"))]
226 let id_generators = ExtractionIdGenerators::with_symbol_start(next_symbol_id);
227
228 let options = ExtractOptions {
229 skip_imports_and_refs: false,
230 keep_text: false,
231 };
232
233 let mut new_extractions: Vec<FileExtraction> = if changed_disk.is_empty() {
234 Vec::new()
235 } else {
236 extractor::extract_with_id_gen(&changed_disk, &options, &id_generators).files
237 };
238
239 let mut overlay_diagnostics: Vec<Diagnostic> = Vec::new();
240 for (path, overlay) in &changed_overlays {
241 match extractor::extract_text_with_id_gen(
242 InMemorySource {
243 uri: overlay.uri.as_str(),
244 text: overlay.text.as_str(),
245 version: overlay.version,
246 language: overlay.lang,
247 },
248 &options,
249 &id_generators,
250 ) {
251 Ok(mut versioned) => {
252 versioned.file.path = path.clone();
253 new_extractions.push(versioned.file);
254 }
255 Err(error) => {
256 let message = error.to_string();
257 overlay_diagnostics.push(Diagnostic {
258 path: path.clone(),
259 severity: Severity::Error,
260 message: message.clone(),
261 source_range: None,
262 });
263 new_extractions.push(FileExtraction::failed(path.clone(), overlay.lang, message));
264 }
265 }
266 }
267
268 let mut merged: Vec<Arc<FileExtraction>> =
269 Vec::with_capacity(state.cache.len() + new_extractions.len());
270 for path in targets.keys() {
271 let Some(fp) = current_fingerprints.get(path) else {
272 continue;
273 };
274 if state.cache.fingerprint_of(path) == Some(*fp)
275 && let Some(extraction) = state.cache.get(path)
276 {
277 merged.push(Arc::clone(extraction));
278 }
279 }
280 for extraction in new_extractions {
281 let arc = Arc::new(extraction);
282 if let Some(fp) = current_fingerprints.get(&arc.path) {
283 state.cache.update(arc.path.clone(), *fp, Arc::clone(&arc));
284 }
285 merged.push(arc);
286 }
287 merged.sort_by(|a, b| a.path.cmp(&b.path));
288
289 let mut diagnostics: Vec<Diagnostic> = merged
290 .iter()
291 .flat_map(|file| file.diagnostics.iter().cloned())
292 .collect();
293 let mut read_diagnostics = read_diagnostics;
294 read_diagnostics.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
295 diagnostics.extend(read_diagnostics);
296 diagnostics.extend(overlay_diagnostics);
297 diagnostics.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
298
299 Ok((merged, change_set, diagnostics))
300}
301
302pub fn incremental_reanalyze(
310 root: &Path,
311 languages: Option<&[LangId]>,
312 state: &mut WatchState,
313) -> Result<(GraphAnalysis, ChangeSet, Vec<Diagnostic>), crate::Error> {
314 let started = Instant::now();
315 let (merged, change_set, extraction_diagnostics) =
316 reanalyze_extractions(root, languages, &[], state)?;
317
318 let snapshot_id = state.next_snapshot_id()?;
319 let (analysis, graph_diagnostics) = crate::pipeline::build_analysis(merged, root, snapshot_id);
320
321 let mut diagnostics = extraction_diagnostics;
322 diagnostics.extend(graph_diagnostics);
323 diagnostics.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
324
325 tracing::info!(
326 total = analysis.extractions.len(),
327 added = change_set.files_added,
328 removed = change_set.files_removed,
329 modified = change_set.files_modified,
330 unchanged = change_set.files_unchanged,
331 elapsed_ms = started.elapsed().as_millis(),
332 "Incremental re-analysis complete",
333 );
334
335 Ok((analysis, change_set, diagnostics))
336}
337
338#[cfg(test)]
339mod tests {
340 use std::io::Write;
341
342 use super::*;
343
344 fn temp_dir(name: &str) -> PathBuf {
345 let dir = std::env::temp_dir().join(format!("meta_ast_reanalyze_{name}"));
346 let _ = std::fs::remove_dir_all(&dir);
347 std::fs::create_dir_all(&dir).unwrap();
348 dir
349 }
350
351 fn write_file(root: &Path, name: &str, content: &str) -> PathBuf {
352 let path = root.join(name);
353 let mut file = std::fs::File::create(&path).unwrap();
354 file.write_all(content.as_bytes()).unwrap();
355 path
356 }
357
358 fn overlay(root: &Path, name: &str, content: &str) -> Overlay {
359 let path = root.join(name);
360 let uri = url::Url::from_file_path(&path)
361 .expect("absolute path")
362 .to_string();
363 Overlay {
364 uri,
365 path,
366 text: content.to_string(),
367 version: 1,
368 lang: LangId::Python,
369 }
370 }
371
372 fn symbol_names(extractions: &[Arc<FileExtraction>]) -> Vec<String> {
373 extractions
374 .iter()
375 .flat_map(|file| file.symbols.iter().map(|s| s.name.clone()))
376 .collect()
377 }
378
379 #[cfg(unix)]
381 fn writes_as_root(path: &Path) -> bool {
382 std::os::unix::fs::MetadataExt::uid(&std::fs::metadata(path).unwrap()) == 0
383 }
384
385 #[test]
386 fn cold_analysis_populates_state() {
387 let root = temp_dir("cold");
388 write_file(&root, "a.py", "def foo(): pass\n");
389
390 let mut state = WatchState::new();
391 let (analysis, cs, diags) = incremental_reanalyze(&root, None, &mut state).unwrap();
392
393 assert!(diags.is_empty());
394 assert_eq!(cs.files_added, 1);
395 assert_eq!(cs.files_unchanged, 0);
396 assert_eq!(analysis.graph.file_count(), 1);
397 assert_eq!(analysis.graph.symbol_count(), 1);
398 assert_eq!(state.cache.extractions.len(), 1);
399 }
400
401 #[test]
402 fn warm_analysis_reuses_cached_unchanged_files() {
403 let root = temp_dir("warm");
404 write_file(&root, "a.py", "def foo(): pass\n");
405 write_file(&root, "b.py", "def bar(): pass\n");
406
407 let mut state = WatchState::new();
408 let (analysis, cs, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
409 assert_eq!(cs.files_added, 2);
410 assert_eq!(analysis.graph.symbol_count(), 2);
411
412 let (analysis2, cs2, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
413 assert_eq!(cs2.files_unchanged, 2);
414 assert_eq!(cs2.files_modified, 0);
415 assert_eq!(cs2.files_added, 0);
416 assert_eq!(cs2.files_removed, 0);
417 assert_eq!(analysis2.graph.symbol_count(), 2);
418 }
419
420 #[test]
421 fn merged_extractions_stay_path_sorted() {
422 let root = temp_dir("sorted");
423 write_file(&root, "b.py", "def bar(): pass\n");
424 write_file(&root, "a.py", "def foo(): pass\n");
425
426 let mut state = WatchState::new();
427 let (analysis, _, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
428 let paths: Vec<_> = analysis
429 .extractions
430 .iter()
431 .map(|f| f.path.clone())
432 .collect();
433 let mut sorted = paths.clone();
434 sorted.sort();
435 assert_eq!(paths, sorted);
436 }
437
438 #[test]
439 fn detects_modified_file_and_re_extracts() {
440 let root = temp_dir("mod");
441 let a = write_file(&root, "a.py", "def original(): pass\n");
442 write_file(&root, "b.py", "def bar(): pass\n");
443
444 let mut state = WatchState::new();
445 let (analysis, cs, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
446 assert_eq!(cs.files_added, 2);
447 assert_eq!(analysis.graph.symbol_count(), 2);
448
449 std::fs::write(&a, "def modified(): pass\ndef extra(): pass\n").unwrap();
450
451 let (analysis2, cs2, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
452 assert_eq!(cs2.files_unchanged, 1);
453 assert_eq!(cs2.files_modified, 1);
454 assert_eq!(analysis2.graph.symbol_count(), 3);
455
456 let names: Vec<String> = analysis2
457 .graph
458 .symbols()
459 .map(|(_, s)| s.name.clone())
460 .collect();
461 assert!(names.contains(&"modified".to_string()));
462 assert!(!names.contains(&"original".to_string()));
463 assert!(names.contains(&"bar".to_string()));
464 }
465
466 #[test]
467 fn detects_removed_file() {
468 let root = temp_dir("rem");
469 let a = write_file(&root, "a.py", "def foo(): pass\n");
470 write_file(&root, "b.py", "def bar(): pass\n");
471
472 let mut state = WatchState::new();
473 let (analysis, cs, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
474 assert_eq!(cs.files_added, 2);
475 assert_eq!(analysis.graph.file_count(), 2);
476
477 std::fs::remove_file(&a).unwrap();
478
479 let (analysis2, cs2, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
480 assert_eq!(cs2.files_removed, 1);
481 assert_eq!(analysis2.graph.file_count(), 1);
482 assert_eq!(state.cache.extractions.len(), 1);
483 }
484
485 #[test]
486 fn detects_added_file() {
487 let root = temp_dir("add");
488 write_file(&root, "a.py", "def foo(): pass\n");
489
490 let mut state = WatchState::new();
491 let (_, cs, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
492 assert_eq!(cs.files_added, 1);
493
494 write_file(&root, "b.py", "def bar(): pass\n");
495
496 let (analysis2, cs2, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
497 assert_eq!(cs2.files_added, 1);
498 assert_eq!(analysis2.graph.file_count(), 2);
499 }
500
501 #[test]
502 fn symbol_ids_no_collision_on_re_extract() {
503 let root = temp_dir("idcol");
504 write_file(&root, "a.py", "def one(): pass\n");
505 write_file(&root, "b.py", "def two(): pass\n");
506
507 let mut state = WatchState::new();
508 let (_, _, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
509
510 write_file(&root, "c.py", "def three(): pass\n");
511
512 let (analysis, _, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
513
514 let mut ids: Vec<u32> = analysis
515 .graph
516 .symbols()
517 .map(|(id, _)| id.to_raw())
518 .collect();
519 ids.sort();
520 let expected: Vec<u32> = (1..=3).collect();
521 assert_eq!(ids, expected, "symbol IDs must be unique and contiguous");
522 }
523
524 #[cfg(feature = "dataflow")]
525 #[test]
526 fn data_node_ids_no_collision_on_re_extract() {
527 let root = temp_dir("data_idcol");
528 write_file(&root, "a.py", "x = 1\ny = x + 1\n");
529 write_file(&root, "b.py", "z = 2\n");
530
531 let mut state = WatchState::new();
532 let (_, _, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
533
534 write_file(&root, "b.py", "z = 2\nw = z + 3\n");
535
536 let (_, _, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
537
538 let mut ids: Vec<u32> = state
539 .cache
540 .extractions
541 .values()
542 .flat_map(|ext| ext.data_nodes.iter().map(|d| d.id.to_raw()))
543 .collect();
544 let original_len = ids.len();
545 ids.sort();
546 ids.dedup();
547 assert_eq!(
548 ids.len(),
549 original_len,
550 "data node IDs must be unique across re-extractions"
551 );
552 }
553
554 #[test]
555 fn diff_counts_are_exact() {
556 let root = temp_dir("diffcounts");
557 let a = write_file(&root, "a.py", "def a(): pass\n");
558 let _b = write_file(&root, "b.py", "def b(): pass\n");
559
560 let mut state = WatchState::new();
561 let (_, cs1, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
562 assert_eq!(cs1.files_added, 2);
563 assert_eq!(cs1.files_removed, 0);
564
565 std::fs::remove_file(&a).unwrap();
566
567 let (_, cs2, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
568 assert_eq!(cs2.files_removed, 1);
569 assert_eq!(cs2.files_added, 0);
570 assert_eq!(cs2.files_modified, 0);
571 assert_eq!(cs2.files_unchanged, 1);
572 }
573
574 #[test]
575 fn unreadable_file_emits_diagnostic() {
576 let root = temp_dir("unread_diag");
577 let a = write_file(&root, "a.py", "def a(): pass\n");
578
579 #[cfg(unix)]
581 if writes_as_root(&a) {
582 return;
583 }
584
585 let mut state = WatchState::new();
586 let (_, _, diags1) = incremental_reanalyze(&root, None, &mut state).unwrap();
587 assert!(diags1.is_empty());
588
589 #[cfg(unix)]
590 {
591 use std::os::unix::fs::PermissionsExt;
592 let mut perms = std::fs::metadata(&a).unwrap().permissions();
593 perms.set_mode(0o000);
594 let _ = std::fs::set_permissions(&a, perms);
595 }
596
597 let (_, _, diags2) = incremental_reanalyze(&root, None, &mut state).unwrap();
598
599 #[cfg(unix)]
600 {
601 use std::os::unix::fs::PermissionsExt;
602 let mut perms = std::fs::metadata(&a).unwrap().permissions();
603 perms.set_mode(0o644);
604 let _ = std::fs::set_permissions(&a, perms);
605 }
606
607 #[cfg(unix)]
608 assert!(!diags2.is_empty(), "Unreadable file must emit diagnostic");
609 }
610
611 #[test]
612 fn empty_project_handled() {
613 let root = temp_dir("empty");
614 let mut state = WatchState::new();
615 let (analysis, cs, diags) = incremental_reanalyze(&root, None, &mut state).unwrap();
616 assert!(diags.is_empty());
617 assert_eq!(cs.files_added, 0);
618 assert_eq!(analysis.graph.node_count(), 0);
619 }
620
621 #[test]
622 fn overlay_overrides_disk_text() {
623 let root = temp_dir("overlay");
624 write_file(&root, "a.py", "def disk(): pass\n");
625
626 let mut state = WatchState::new();
627 let first = overlay(&root, "a.py", "def from_buffer(): pass\n");
628 let (extractions, cs, _) =
629 reanalyze_extractions(&root, None, std::slice::from_ref(&first), &mut state).unwrap();
630 assert_eq!(cs.files_added, 1);
631 let names = symbol_names(&extractions);
632 assert!(names.contains(&"from_buffer".to_string()));
633 assert!(!names.contains(&"disk".to_string()));
634
635 let (_, cs2, _) = reanalyze_extractions(&root, None, &[first], &mut state).unwrap();
636 assert_eq!(cs2.files_unchanged, 1);
637
638 let (extractions3, cs3, _) = reanalyze_extractions(&root, None, &[], &mut state).unwrap();
639 assert_eq!(cs3.files_modified, 1);
640 assert!(symbol_names(&extractions3).contains(&"disk".to_string()));
641 }
642
643 #[test]
644 fn overlay_adds_file_not_on_disk() {
645 let root = temp_dir("overlay_new");
646 let mut state = WatchState::new();
647 let pending = overlay(&root, "new.py", "def fresh(): pass\n");
648 let (extractions, cs, _) =
649 reanalyze_extractions(&root, None, &[pending], &mut state).unwrap();
650 assert_eq!(cs.files_added, 1);
651 assert_eq!(extractions.len(), 1);
652 assert!(symbol_names(&extractions).contains(&"fresh".to_string()));
653 }
654
655 #[test]
656 fn overlay_outside_root_is_ignored() {
657 let root = temp_dir("overlay_outside");
658 let mut state = WatchState::new();
659 let outside = Overlay {
660 uri: "file:///elsewhere.py".to_string(),
661 path: PathBuf::from("/definitely/outside/elsewhere.py"),
662 text: "def ignored(): pass\n".to_string(),
663 version: 1,
664 lang: LangId::Python,
665 };
666 let (extractions, cs, _) =
667 reanalyze_extractions(&root, None, &[outside], &mut state).unwrap();
668 assert!(extractions.is_empty());
669 assert_eq!(cs.files_added, 0);
670 }
671
672 #[cfg(unix)]
674 #[test]
675 fn read_failure_keeps_the_cached_entry() {
676 let root = temp_dir("read_failure");
677 let a = write_file(&root, "a.py", "def kept(): pass\n");
678
679 let mut state = WatchState::new();
680 let (first, cs, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
681 assert_eq!(cs.files_added, 1);
682 let id_before = first.graph.symbols().next().unwrap().0.to_raw();
683
684 if writes_as_root(&a) {
685 return;
686 }
687 #[cfg(unix)]
688 {
689 use std::os::unix::fs::PermissionsExt;
690 let mut perms = std::fs::metadata(&a).unwrap().permissions();
691 perms.set_mode(0o000);
692 std::fs::set_permissions(&a, perms).unwrap();
693 }
694
695 let (_, cs2, diags2) = incremental_reanalyze(&root, None, &mut state).unwrap();
696 assert_eq!(cs2.files_removed, 0, "a read failure is not a removal");
697 assert_eq!(
698 cs2.files_unchanged, 1,
699 "a read failure keeps the cached file"
700 );
701 assert_eq!(state.cache.extractions.len(), 1);
702 assert!(!diags2.is_empty(), "the read failure must be reported");
703
704 #[cfg(unix)]
705 {
706 use std::os::unix::fs::PermissionsExt;
707 let mut perms = std::fs::metadata(&a).unwrap().permissions();
708 perms.set_mode(0o644);
709 std::fs::set_permissions(&a, perms).unwrap();
710 }
711
712 let (third, cs3, _) = incremental_reanalyze(&root, None, &mut state).unwrap();
713 assert_eq!(cs3.files_unchanged, 1);
714 assert_eq!(
715 third.graph.symbols().next().unwrap().0.to_raw(),
716 id_before,
717 "the transient failure must not renumber the cached file"
718 );
719 }
720
721 #[test]
722 fn id_exhaustion_is_an_error() {
723 let root = temp_dir("id_exhaustion");
724 write_file(&root, "a.py", "def a(): pass\n");
725
726 let mut state = WatchState::new();
727 let mut seated = FileExtraction::empty(root.join("a.py"), LangId::Python);
728 seated.symbols.push(crate::model::Symbol {
729 id: crate::model::SymbolId::new(u32::MAX).unwrap(),
730 name: "seated".into(),
731 kind: crate::model::SymbolKind::Function,
732 language: LangId::Python,
733 file_path: root.join("a.py"),
734 source_range: crate::model::SourceRange {
735 byte_start: 0,
736 byte_end: 0,
737 start: crate::model::LineColumn { line: 0, column: 0 },
738 end: crate::model::LineColumn { line: 0, column: 0 },
739 },
740 name_range: None,
741 visibility: None,
742 signature: None,
743 docstring: None,
744 is_async: false,
745 });
746 state.cache_mut().update(
747 root.join("a.py"),
748 Fingerprint::of(b"stale"),
749 Arc::new(seated),
750 );
751
752 assert!(
753 incremental_reanalyze(&root, None, &mut state).is_err(),
754 "an exhausted id space must report an error"
755 );
756 }
757
758 #[cfg(windows)]
760 #[test]
761 fn overlay_verbatim_path_shares_the_discovered_key() {
762 let root = temp_dir("overlay_key");
763 write_file(&root, "a.py", "def shared(): pass\n");
764
765 let mut state = WatchState::new();
766 let mut verbatim = overlay(&root, "a.py", "def shared(): pass\n");
767 verbatim.path = PathBuf::from(format!(r"\\?\{}", verbatim.path.display()));
768
769 let (extractions, cs, _) =
770 reanalyze_extractions(&root, None, std::slice::from_ref(&verbatim), &mut state)
771 .unwrap();
772 assert_eq!(
773 cs.files_added, 1,
774 "one file must not be counted under two keys"
775 );
776 assert_eq!(extractions.len(), 1);
777
778 let (_, cs2, _) = reanalyze_extractions(&root, None, &[verbatim], &mut state).unwrap();
779 assert_eq!(cs2.files_unchanged, 1);
780 assert_eq!(cs2.files_added + cs2.files_modified, 0);
781 }
782
783 #[test]
784 fn snapshot_id_allocation_is_monotonic() {
785 let mut state = WatchState::new();
786 let s1 = state.next_snapshot_id().unwrap();
787 let s2 = state.next_snapshot_id().unwrap();
788 assert_eq!(s1.to_raw(), 1);
789 assert_eq!(s2.to_raw(), 2);
790 }
791
792 #[test]
793 fn snapshot_counter_exhaustion_is_an_error() {
794 let mut state = WatchState::new();
795 state.snapshot_counter = u32::MAX;
796 assert!(state.next_snapshot_id().is_err());
797 }
798}