Skip to main content

aft/lsp/
document.rs

1use std::collections::HashMap;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6const CONTENT_HASH_SIZE_LIMIT_BYTES: u64 = 4 * 1024 * 1024;
7
8/// Per-document state tracked for LSP synchronization.
9///
10/// We track `mtime`, `size`, and (for small files) a content hash so we can
11/// detect when a file has been changed
12/// outside the AFT pipeline (another tool, another session, manual edit) and
13/// resync the LSP server before issuing diagnostic requests. Without this,
14/// `ensure_file_open` would skip already-open files and return diagnostics
15/// computed from stale in-memory content.
16#[derive(Debug, Clone)]
17pub struct DocumentEntry {
18    /// Monotonically increasing LSP version, starts at 0.
19    pub version: i32,
20    /// Filesystem modification time at the moment we last synced this file
21    /// to the LSP server (didOpen or didChange). `None` if the file did not
22    /// exist on disk when we last synced (e.g. in-memory-only test fixtures
23    /// or files mid-rename).
24    pub mtime: Option<SystemTime>,
25    /// Filesystem byte size at the moment we last synced. `None` for the
26    /// same reasons as `mtime`.
27    pub size: Option<u64>,
28    /// BLAKE3 content hash captured at the last sync for files up to
29    /// `CONTENT_HASH_SIZE_LIMIT_BYTES`. Large files skip hashing and rely on
30    /// `(mtime, size)` to avoid expensive reads.
31    pub content_hash: Option<[u8; 32]>,
32}
33
34/// Tracks document state for LSP synchronization.
35///
36/// LSP requires:
37/// 1. didOpen before didChange (document must be opened first)
38/// 2. Version numbers must be monotonically increasing
39/// 3. Full content sent with each change (TextDocumentSyncKind::Full)
40///
41/// This store ALSO records (mtime, size, content hash) at sync time so callers can detect
42/// disk drift via `is_stale_on_disk()` and resync stale entries before
43/// issuing pull-diagnostic requests against potentially stale server state.
44#[derive(Debug, Default)]
45pub struct DocumentStore {
46    entries: HashMap<PathBuf, DocumentEntry>,
47}
48
49impl DocumentStore {
50    pub fn new() -> Self {
51        Self {
52            entries: HashMap::new(),
53        }
54    }
55
56    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
57        let bytes = self.entries.keys().fold(0u64, |bytes, path| {
58            bytes
59                .saturating_add(std::mem::size_of::<PathBuf>() as u64)
60                .saturating_add(std::mem::size_of::<DocumentEntry>() as u64)
61                .saturating_add(crate::memory::path_bytes(path))
62        });
63        crate::memory::MemoryEstimate::estimated(bytes).count("documents", self.entries.len())
64    }
65
66    /// Check if a document is already opened (tracked).
67    pub fn is_open(&self, path: &Path) -> bool {
68        self.entries.contains_key(path)
69    }
70
71    /// Open a new document, recording the current on-disk metadata/hash. Returns
72    /// the initial version (0). If the file's metadata cannot be read, the
73    /// document is still tracked but `mtime`/`size` will be `None`, which
74    /// causes `is_stale_on_disk()` to conservatively report stale on the
75    /// next check (forcing a resync).
76    pub fn open(&mut self, path: PathBuf) -> i32 {
77        let (mtime, size, content_hash) = read_metadata_and_hash(&path);
78        let entry = DocumentEntry {
79            version: 0,
80            mtime,
81            size,
82            content_hash,
83        };
84        self.entries.insert(path, entry);
85        0
86    }
87
88    /// Bump the version for an already-open document and refresh the
89    /// recorded mtime/size/hash from disk (the caller is presumed to be sending
90    /// a `didChange` with fresh content right after this call). Returns the
91    /// new version, or `None` if the document is not open.
92    pub fn bump_version(&mut self, path: &Path) -> Option<i32> {
93        let (new_mtime, new_size, new_content_hash) = read_metadata_and_hash(path);
94        let entry = self.entries.get_mut(path)?;
95        entry.version += 1;
96        entry.mtime = new_mtime;
97        entry.size = new_size;
98        entry.content_hash = new_content_hash;
99        Some(entry.version)
100    }
101
102    /// Get current version, or None if not open.
103    pub fn version(&self, path: &Path) -> Option<i32> {
104        self.entries.get(path).map(|e| e.version)
105    }
106
107    /// Get the full document entry, or None if not open.
108    pub fn entry(&self, path: &Path) -> Option<&DocumentEntry> {
109        self.entries.get(path)
110    }
111
112    /// Close a document and remove from tracking. Returns the last known
113    /// version, or `None` if the document was not open.
114    pub fn close(&mut self, path: &Path) -> Option<i32> {
115        self.entries.remove(path).map(|e| e.version)
116    }
117
118    /// Get all open document paths.
119    pub fn open_documents(&self) -> Vec<&PathBuf> {
120        self.entries.keys().collect()
121    }
122
123    /// Returns true if the document is currently open AND its on-disk
124    /// metadata differs from what we recorded at the last sync. Use this
125    /// before issuing pull diagnostics to decide whether `bump_version` +
126    /// `didChange` is needed first.
127    ///
128    /// Conservative semantics: returns true if the file used to have known
129    /// metadata but cannot be read now (e.g. deleted or permission error),
130    /// or if we never recorded metadata for the open entry.
131    pub fn is_stale_on_disk(&self, path: &Path) -> bool {
132        let Some(entry) = self.entries.get(path) else {
133            // Not open at all — caller should `open` instead of asking about
134            // staleness. We still return true so the caller doesn't act on
135            // stale assumptions.
136            return true;
137        };
138        let (current_mtime, current_size, current_content_hash) = read_metadata_and_hash(path);
139
140        match (entry.mtime, current_mtime) {
141            (Some(prev), Some(now)) if prev == now => {
142                if entry.size != current_size {
143                    return true;
144                }
145
146                match current_size {
147                    Some(size) if size > CONTENT_HASH_SIZE_LIMIT_BYTES => false,
148                    Some(_) => match (entry.content_hash, current_content_hash) {
149                        (Some(prev_hash), Some(now_hash)) => prev_hash != now_hash,
150                        _ => true,
151                    },
152                    None => true,
153                }
154            }
155            (Some(_), Some(_)) => true, // mtimes differ
156            // Either we didn't record before or can't read now — be safe.
157            _ => true,
158        }
159    }
160}
161
162/// Read filesystem metadata and, for small files, a BLAKE3 content hash.
163/// Metadata fields are `None` if the path cannot be statted or if the
164/// platform doesn't support the queried metadata (rare). The hash is `None`
165/// for unreadable files and files above `CONTENT_HASH_SIZE_LIMIT_BYTES`.
166fn read_metadata_and_hash(path: &Path) -> (Option<SystemTime>, Option<u64>, Option<[u8; 32]>) {
167    match std::fs::metadata(path) {
168        Ok(meta) => {
169            let mtime = meta.modified().ok();
170            let size = Some(meta.len());
171            let content_hash = if meta.len() <= CONTENT_HASH_SIZE_LIMIT_BYTES {
172                std::fs::read(path)
173                    .ok()
174                    .map(|bytes| *blake3::hash(&bytes).as_bytes())
175            } else {
176                None
177            };
178            (mtime, size, content_hash)
179        }
180        Err(_) => (None, None, None),
181    }
182}
183
184/// Public helper: read metadata for an arbitrary path. Useful for callers
185/// that want to consult disk state without going through the store.
186pub fn file_metadata(path: &Path) -> io::Result<(SystemTime, u64)> {
187    let meta = std::fs::metadata(path)?;
188    let mtime = meta.modified()?;
189    let size = meta.len();
190    Ok((mtime, size))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::fs;
197    use std::io::Write;
198    use std::time::Duration;
199
200    fn write_file(path: &Path, content: &str) {
201        let mut f = fs::File::create(path).expect("create test file");
202        f.write_all(content.as_bytes()).expect("write content");
203    }
204
205    fn advance_mtime(path: &Path, duration: Duration) {
206        let modified = fs::metadata(path)
207            .expect("stat test file")
208            .modified()
209            .expect("test file mtime");
210        let advanced = modified.checked_add(duration).expect("advanced mtime");
211        filetime::set_file_mtime(path, filetime::FileTime::from_system_time(advanced))
212            .expect("set advanced mtime");
213    }
214
215    #[test]
216    fn document_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
217        let mut store = DocumentStore::new();
218        assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
219        store.open(PathBuf::from("/tmp/aft-document-memory"));
220        let estimate = store.estimated_memory();
221        assert!(estimate.estimated_bytes.unwrap() > 0);
222        assert_eq!(estimate.counts["documents"], 1);
223    }
224
225    #[test]
226    fn open_and_close_roundtrip() {
227        let mut store = DocumentStore::new();
228        let path = PathBuf::from("/tmp/aft-doc-test-doesnt-exist");
229        assert!(!store.is_open(&path));
230
231        let v = store.open(path.clone());
232        assert_eq!(v, 0);
233        assert!(store.is_open(&path));
234        assert_eq!(store.version(&path), Some(0));
235
236        let bumped = store.bump_version(&path);
237        assert_eq!(bumped, Some(1));
238        assert_eq!(store.version(&path), Some(1));
239
240        let closed = store.close(&path);
241        assert_eq!(closed, Some(1));
242        assert!(!store.is_open(&path));
243    }
244
245    #[test]
246    fn nonexistent_path_is_always_stale() {
247        let store = DocumentStore::new();
248        let path = PathBuf::from("/tmp/aft-doc-test-never-opened");
249        // Not open at all → stale
250        assert!(store.is_stale_on_disk(&path));
251    }
252
253    #[test]
254    fn freshly_opened_real_file_is_not_stale() {
255        let dir = tempfile::tempdir().expect("temp dir");
256        let path = dir.path().join("a.txt");
257        write_file(&path, "hello");
258
259        let mut store = DocumentStore::new();
260        store.open(path.clone());
261        assert!(!store.is_stale_on_disk(&path));
262    }
263
264    #[test]
265    fn opened_then_disk_changed_is_stale() {
266        let dir = tempfile::tempdir().expect("temp dir");
267        let path = dir.path().join("b.txt");
268        write_file(&path, "hello");
269
270        let mut store = DocumentStore::new();
271        store.open(path.clone());
272        assert!(!store.is_stale_on_disk(&path));
273
274        write_file(&path, "hello world!");
275        advance_mtime(&path, Duration::from_secs(2));
276
277        assert!(store.is_stale_on_disk(&path));
278    }
279
280    #[test]
281    fn opened_file_same_size_and_mtime_but_different_hash_is_stale() {
282        let dir = tempfile::tempdir().expect("temp dir");
283        let path = dir.path().join("same-size.txt");
284        write_file(&path, "hello");
285        let original_mtime = fs::metadata(&path)
286            .expect("stat original")
287            .modified()
288            .expect("original mtime");
289
290        let mut store = DocumentStore::new();
291        store.open(path.clone());
292        assert!(!store.is_stale_on_disk(&path));
293
294        write_file(&path, "jello");
295        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(original_mtime))
296            .expect("restore original mtime");
297
298        assert!(
299            store.is_stale_on_disk(&path),
300            "same mtime+size should still be stale when content hash differs"
301        );
302
303        store.bump_version(&path);
304        assert!(!store.is_stale_on_disk(&path));
305    }
306
307    #[test]
308    fn opened_file_then_deleted_is_stale() {
309        let dir = tempfile::tempdir().expect("temp dir");
310        let path = dir.path().join("c.txt");
311        write_file(&path, "data");
312
313        let mut store = DocumentStore::new();
314        store.open(path.clone());
315        assert!(!store.is_stale_on_disk(&path));
316
317        fs::remove_file(&path).expect("remove file");
318        // Cannot read metadata anymore → conservatively stale
319        assert!(store.is_stale_on_disk(&path));
320    }
321
322    #[test]
323    fn bump_version_refreshes_mtime() {
324        let dir = tempfile::tempdir().expect("temp dir");
325        let path = dir.path().join("d.txt");
326        write_file(&path, "original");
327
328        let mut store = DocumentStore::new();
329        store.open(path.clone());
330
331        write_file(&path, "updated");
332        advance_mtime(&path, Duration::from_secs(2));
333        assert!(store.is_stale_on_disk(&path));
334
335        // After bump_version, the entry should pick up the new mtime/size
336        // (the caller is presumed to send didChange with the fresh content).
337        store.bump_version(&path);
338        assert!(!store.is_stale_on_disk(&path));
339    }
340
341    #[test]
342    fn open_documents_returns_all_paths() {
343        let mut store = DocumentStore::new();
344        store.open(PathBuf::from("/tmp/p1"));
345        store.open(PathBuf::from("/tmp/p2"));
346        let docs = store.open_documents();
347        assert_eq!(docs.len(), 2);
348    }
349
350    #[test]
351    fn entry_returns_full_state() {
352        let dir = tempfile::tempdir().expect("temp dir");
353        let path = dir.path().join("e.txt");
354        write_file(&path, "abc");
355
356        let mut store = DocumentStore::new();
357        store.open(path.clone());
358
359        let entry = store.entry(&path).expect("entry");
360        assert_eq!(entry.version, 0);
361        assert!(entry.mtime.is_some());
362        assert_eq!(entry.size, Some(3));
363        assert!(entry.content_hash.is_some());
364    }
365}