Skip to main content

meta_ast/
reanalyze.rs

1//! Incremental re-analysis with buffer overlays.
2//!
3//! Evaluates project source files against the extraction cache, re-extracts
4//! only changed or new files, and (optionally) rebuilds the dependency graph.
5//! Open buffer text overrides disk text.
6//!
7//! This module is not gated by the `watch` feature. Only the OS watcher in
8//! [`crate::watch`] needs that feature.
9
10use 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/// In-memory buffer text that overrides disk text during re-analysis.
26#[derive(Debug, Clone)]
27pub struct Overlay {
28    /// Document URI, as sent by the editor.
29    pub uri: String,
30    /// Absolute path of the document.
31    pub path: PathBuf,
32    /// Current buffer text.
33    pub text: String,
34    /// Document version, monotonic per document.
35    pub version: i32,
36    /// Language of the buffer.
37    pub lang: LangId,
38}
39
40/// Metrics summarizing file changes between analysis passes.
41#[derive(Debug, Clone, Default)]
42pub struct ChangeSet {
43    /// Number of newly discovered source files.
44    pub files_added: usize,
45    /// Number of deleted or missing source files.
46    pub files_removed: usize,
47    /// Number of modified files re-parsed in this pass.
48    pub files_modified: usize,
49    /// Number of untouched files reusing cached extractions.
50    pub files_unchanged: usize,
51}
52
53/// Path-sorted extractions plus the change metrics and diagnostics.
54pub type ReanalysisOutput = (Vec<Arc<FileExtraction>>, ChangeSet, Vec<Diagnostic>);
55
56/// Mutable state preserved across incremental re-analysis passes.
57pub struct WatchState {
58    pub(crate) cache: ExtractionCache,
59    pub(crate) snapshot_counter: u32,
60}
61
62impl WatchState {
63    /// Create a new empty state.
64    pub fn new() -> Self {
65        Self {
66            cache: ExtractionCache::new(),
67            snapshot_counter: 0,
68        }
69    }
70
71    /// Read access to the extraction cache.
72    pub fn cache(&self) -> &ExtractionCache {
73        &self.cache
74    }
75
76    /// Write access to the extraction cache. Use it to seed extractions
77    /// loaded from shards before the first re-analysis pass.
78    pub fn cache_mut(&mut self) -> &mut ExtractionCache {
79        &mut self.cache
80    }
81
82    /// Allocate the next monotonic snapshot ID.
83    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
98/// Extract and reuse, without building a graph.
99///
100/// Discovers source files under `root`, applies overlays over disk text,
101/// diffs against the cache, and re-extracts only changed or new files.
102///
103/// Overlay paths join the file set, so a new unsaved file is indexed. An
104/// overlay path outside `root` is ignored. The returned vector is path-sorted
105/// and holds `Arc` handles to reused extractions.
106pub 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    // Key every overlay by the same path form the walk produces, so one file
115    // never enters the target set under two keys.
116    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    // A file that cannot be read keeps its cached extraction. Its fingerprint is
166    // missing, so the stale sweep must not treat it as deleted.
167    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
302/// Run one step of incremental re-analysis and rebuild the graph.
303///
304/// Discovers all source files under `root`, reads and fingerprints each,
305/// diffs against the cached state, re-extracts only changed files, and
306/// rebuilds the dependency graph plus SCC from the merged extraction set.
307///
308/// On the first call (fresh state), every file is treated as added.
309pub 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    /// Root ignores file permissions, so the read-failure case cannot run as root.
380    #[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        // Root ignores file permissions, so the read failure cannot be provoked.
580        #[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    /// Only Unix can deny a read to a normal user.
673    #[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    /// The verbatim prefix only exists on Windows, so this key-form defect is Windows-only.
759    #[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}