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    /// Check if a document is already opened (tracked).
57    pub fn is_open(&self, path: &Path) -> bool {
58        self.entries.contains_key(path)
59    }
60
61    /// Open a new document, recording the current on-disk metadata/hash. Returns
62    /// the initial version (0). If the file's metadata cannot be read, the
63    /// document is still tracked but `mtime`/`size` will be `None`, which
64    /// causes `is_stale_on_disk()` to conservatively report stale on the
65    /// next check (forcing a resync).
66    pub fn open(&mut self, path: PathBuf) -> i32 {
67        let (mtime, size, content_hash) = read_metadata_and_hash(&path);
68        let entry = DocumentEntry {
69            version: 0,
70            mtime,
71            size,
72            content_hash,
73        };
74        self.entries.insert(path, entry);
75        0
76    }
77
78    /// Bump the version for an already-open document and refresh the
79    /// recorded mtime/size/hash from disk (the caller is presumed to be sending
80    /// a `didChange` with fresh content right after this call). Returns the
81    /// new version, or `None` if the document is not open.
82    pub fn bump_version(&mut self, path: &Path) -> Option<i32> {
83        let (new_mtime, new_size, new_content_hash) = read_metadata_and_hash(path);
84        let entry = self.entries.get_mut(path)?;
85        entry.version += 1;
86        entry.mtime = new_mtime;
87        entry.size = new_size;
88        entry.content_hash = new_content_hash;
89        Some(entry.version)
90    }
91
92    /// Get current version, or None if not open.
93    pub fn version(&self, path: &Path) -> Option<i32> {
94        self.entries.get(path).map(|e| e.version)
95    }
96
97    /// Get the full document entry, or None if not open.
98    pub fn entry(&self, path: &Path) -> Option<&DocumentEntry> {
99        self.entries.get(path)
100    }
101
102    /// Close a document and remove from tracking. Returns the last known
103    /// version, or `None` if the document was not open.
104    pub fn close(&mut self, path: &Path) -> Option<i32> {
105        self.entries.remove(path).map(|e| e.version)
106    }
107
108    /// Get all open document paths.
109    pub fn open_documents(&self) -> Vec<&PathBuf> {
110        self.entries.keys().collect()
111    }
112
113    /// Returns true if the document is currently open AND its on-disk
114    /// metadata differs from what we recorded at the last sync. Use this
115    /// before issuing pull diagnostics to decide whether `bump_version` +
116    /// `didChange` is needed first.
117    ///
118    /// Conservative semantics: returns true if the file used to have known
119    /// metadata but cannot be read now (e.g. deleted or permission error),
120    /// or if we never recorded metadata for the open entry.
121    pub fn is_stale_on_disk(&self, path: &Path) -> bool {
122        let Some(entry) = self.entries.get(path) else {
123            // Not open at all — caller should `open` instead of asking about
124            // staleness. We still return true so the caller doesn't act on
125            // stale assumptions.
126            return true;
127        };
128        let (current_mtime, current_size, current_content_hash) = read_metadata_and_hash(path);
129
130        match (entry.mtime, current_mtime) {
131            (Some(prev), Some(now)) if prev == now => {
132                if entry.size != current_size {
133                    return true;
134                }
135
136                match current_size {
137                    Some(size) if size > CONTENT_HASH_SIZE_LIMIT_BYTES => false,
138                    Some(_) => match (entry.content_hash, current_content_hash) {
139                        (Some(prev_hash), Some(now_hash)) => prev_hash != now_hash,
140                        _ => true,
141                    },
142                    None => true,
143                }
144            }
145            (Some(_), Some(_)) => true, // mtimes differ
146            // Either we didn't record before or can't read now — be safe.
147            _ => true,
148        }
149    }
150}
151
152/// Read filesystem metadata and, for small files, a BLAKE3 content hash.
153/// Metadata fields are `None` if the path cannot be statted or if the
154/// platform doesn't support the queried metadata (rare). The hash is `None`
155/// for unreadable files and files above `CONTENT_HASH_SIZE_LIMIT_BYTES`.
156fn read_metadata_and_hash(path: &Path) -> (Option<SystemTime>, Option<u64>, Option<[u8; 32]>) {
157    match std::fs::metadata(path) {
158        Ok(meta) => {
159            let mtime = meta.modified().ok();
160            let size = Some(meta.len());
161            let content_hash = if meta.len() <= CONTENT_HASH_SIZE_LIMIT_BYTES {
162                std::fs::read(path)
163                    .ok()
164                    .map(|bytes| *blake3::hash(&bytes).as_bytes())
165            } else {
166                None
167            };
168            (mtime, size, content_hash)
169        }
170        Err(_) => (None, None, None),
171    }
172}
173
174/// Public helper: read metadata for an arbitrary path. Useful for callers
175/// that want to consult disk state without going through the store.
176pub fn file_metadata(path: &Path) -> io::Result<(SystemTime, u64)> {
177    let meta = std::fs::metadata(path)?;
178    let mtime = meta.modified()?;
179    let size = meta.len();
180    Ok((mtime, size))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::fs;
187    use std::io::Write;
188    use std::time::Duration;
189
190    fn write_file(path: &Path, content: &str) {
191        let mut f = fs::File::create(path).expect("create test file");
192        f.write_all(content.as_bytes()).expect("write content");
193    }
194
195    fn advance_mtime(path: &Path, duration: Duration) {
196        let modified = fs::metadata(path)
197            .expect("stat test file")
198            .modified()
199            .expect("test file mtime");
200        let advanced = modified.checked_add(duration).expect("advanced mtime");
201        filetime::set_file_mtime(path, filetime::FileTime::from_system_time(advanced))
202            .expect("set advanced mtime");
203    }
204
205    #[test]
206    fn open_and_close_roundtrip() {
207        let mut store = DocumentStore::new();
208        let path = PathBuf::from("/tmp/aft-doc-test-doesnt-exist");
209        assert!(!store.is_open(&path));
210
211        let v = store.open(path.clone());
212        assert_eq!(v, 0);
213        assert!(store.is_open(&path));
214        assert_eq!(store.version(&path), Some(0));
215
216        let bumped = store.bump_version(&path);
217        assert_eq!(bumped, Some(1));
218        assert_eq!(store.version(&path), Some(1));
219
220        let closed = store.close(&path);
221        assert_eq!(closed, Some(1));
222        assert!(!store.is_open(&path));
223    }
224
225    #[test]
226    fn nonexistent_path_is_always_stale() {
227        let store = DocumentStore::new();
228        let path = PathBuf::from("/tmp/aft-doc-test-never-opened");
229        // Not open at all → stale
230        assert!(store.is_stale_on_disk(&path));
231    }
232
233    #[test]
234    fn freshly_opened_real_file_is_not_stale() {
235        let dir = tempfile::tempdir().expect("temp dir");
236        let path = dir.path().join("a.txt");
237        write_file(&path, "hello");
238
239        let mut store = DocumentStore::new();
240        store.open(path.clone());
241        assert!(!store.is_stale_on_disk(&path));
242    }
243
244    #[test]
245    fn opened_then_disk_changed_is_stale() {
246        let dir = tempfile::tempdir().expect("temp dir");
247        let path = dir.path().join("b.txt");
248        write_file(&path, "hello");
249
250        let mut store = DocumentStore::new();
251        store.open(path.clone());
252        assert!(!store.is_stale_on_disk(&path));
253
254        write_file(&path, "hello world!");
255        advance_mtime(&path, Duration::from_secs(2));
256
257        assert!(store.is_stale_on_disk(&path));
258    }
259
260    #[test]
261    fn opened_file_same_size_and_mtime_but_different_hash_is_stale() {
262        let dir = tempfile::tempdir().expect("temp dir");
263        let path = dir.path().join("same-size.txt");
264        write_file(&path, "hello");
265        let original_mtime = fs::metadata(&path)
266            .expect("stat original")
267            .modified()
268            .expect("original mtime");
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, "jello");
275        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(original_mtime))
276            .expect("restore original mtime");
277
278        assert!(
279            store.is_stale_on_disk(&path),
280            "same mtime+size should still be stale when content hash differs"
281        );
282
283        store.bump_version(&path);
284        assert!(!store.is_stale_on_disk(&path));
285    }
286
287    #[test]
288    fn opened_file_then_deleted_is_stale() {
289        let dir = tempfile::tempdir().expect("temp dir");
290        let path = dir.path().join("c.txt");
291        write_file(&path, "data");
292
293        let mut store = DocumentStore::new();
294        store.open(path.clone());
295        assert!(!store.is_stale_on_disk(&path));
296
297        fs::remove_file(&path).expect("remove file");
298        // Cannot read metadata anymore → conservatively stale
299        assert!(store.is_stale_on_disk(&path));
300    }
301
302    #[test]
303    fn bump_version_refreshes_mtime() {
304        let dir = tempfile::tempdir().expect("temp dir");
305        let path = dir.path().join("d.txt");
306        write_file(&path, "original");
307
308        let mut store = DocumentStore::new();
309        store.open(path.clone());
310
311        write_file(&path, "updated");
312        advance_mtime(&path, Duration::from_secs(2));
313        assert!(store.is_stale_on_disk(&path));
314
315        // After bump_version, the entry should pick up the new mtime/size
316        // (the caller is presumed to send didChange with the fresh content).
317        store.bump_version(&path);
318        assert!(!store.is_stale_on_disk(&path));
319    }
320
321    #[test]
322    fn open_documents_returns_all_paths() {
323        let mut store = DocumentStore::new();
324        store.open(PathBuf::from("/tmp/p1"));
325        store.open(PathBuf::from("/tmp/p2"));
326        let docs = store.open_documents();
327        assert_eq!(docs.len(), 2);
328    }
329
330    #[test]
331    fn entry_returns_full_state() {
332        let dir = tempfile::tempdir().expect("temp dir");
333        let path = dir.path().join("e.txt");
334        write_file(&path, "abc");
335
336        let mut store = DocumentStore::new();
337        store.open(path.clone());
338
339        let entry = store.entry(&path).expect("entry");
340        assert_eq!(entry.version, 0);
341        assert!(entry.mtime.is_some());
342        assert_eq!(entry.size, Some(3));
343        assert!(entry.content_hash.is_some());
344    }
345}