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
6/// Per-document state tracked for LSP synchronization.
7///
8/// We track `mtime` and `size` so we can detect when a file has been changed
9/// outside the AFT pipeline (another tool, another session, manual edit) and
10/// resync the LSP server before issuing diagnostic requests. Without this,
11/// `ensure_file_open` would skip already-open files and return diagnostics
12/// computed from stale in-memory content.
13#[derive(Debug, Clone)]
14pub struct DocumentEntry {
15    /// Monotonically increasing LSP version, starts at 0.
16    pub version: i32,
17    /// Filesystem modification time at the moment we last synced this file
18    /// to the LSP server (didOpen or didChange). `None` if the file did not
19    /// exist on disk when we last synced (e.g. in-memory-only test fixtures
20    /// or files mid-rename).
21    pub mtime: Option<SystemTime>,
22    /// Filesystem byte size at the moment we last synced. `None` for the
23    /// same reasons as `mtime`.
24    pub size: Option<u64>,
25}
26
27/// Tracks document state for LSP synchronization.
28///
29/// LSP requires:
30/// 1. didOpen before didChange (document must be opened first)
31/// 2. Version numbers must be monotonically increasing
32/// 3. Full content sent with each change (TextDocumentSyncKind::Full)
33///
34/// This store ALSO records (mtime, size) at sync time so callers can detect
35/// disk drift via `is_stale_on_disk()` and resync stale entries before
36/// issuing pull-diagnostic requests against potentially stale server state.
37#[derive(Debug, Default)]
38pub struct DocumentStore {
39    entries: HashMap<PathBuf, DocumentEntry>,
40}
41
42impl DocumentStore {
43    pub fn new() -> Self {
44        Self {
45            entries: HashMap::new(),
46        }
47    }
48
49    /// Check if a document is already opened (tracked).
50    pub fn is_open(&self, path: &Path) -> bool {
51        self.entries.contains_key(path)
52    }
53
54    /// Open a new document, recording the current on-disk metadata. Returns
55    /// the initial version (0). If the file's metadata cannot be read, the
56    /// document is still tracked but `mtime`/`size` will be `None`, which
57    /// causes `is_stale_on_disk()` to conservatively report stale on the
58    /// next check (forcing a resync).
59    pub fn open(&mut self, path: PathBuf) -> i32 {
60        let (mtime, size) = read_metadata(&path);
61        let entry = DocumentEntry {
62            version: 0,
63            mtime,
64            size,
65        };
66        self.entries.insert(path, entry);
67        0
68    }
69
70    /// Bump the version for an already-open document and refresh the
71    /// recorded mtime/size from disk (the caller is presumed to be sending
72    /// a `didChange` with fresh content right after this call). Returns the
73    /// new version, or `None` if the document is not open.
74    pub fn bump_version(&mut self, path: &Path) -> Option<i32> {
75        let (new_mtime, new_size) = read_metadata(path);
76        let entry = self.entries.get_mut(path)?;
77        entry.version += 1;
78        entry.mtime = new_mtime;
79        entry.size = new_size;
80        Some(entry.version)
81    }
82
83    /// Get current version, or None if not open.
84    pub fn version(&self, path: &Path) -> Option<i32> {
85        self.entries.get(path).map(|e| e.version)
86    }
87
88    /// Get the full document entry, or None if not open.
89    pub fn entry(&self, path: &Path) -> Option<&DocumentEntry> {
90        self.entries.get(path)
91    }
92
93    /// Close a document and remove from tracking. Returns the last known
94    /// version, or `None` if the document was not open.
95    pub fn close(&mut self, path: &Path) -> Option<i32> {
96        self.entries.remove(path).map(|e| e.version)
97    }
98
99    /// Get all open document paths.
100    pub fn open_documents(&self) -> Vec<&PathBuf> {
101        self.entries.keys().collect()
102    }
103
104    /// Returns true if the document is currently open AND its on-disk
105    /// metadata differs from what we recorded at the last sync. Use this
106    /// before issuing pull diagnostics to decide whether `bump_version` +
107    /// `didChange` is needed first.
108    ///
109    /// Conservative semantics: returns true if the file used to have known
110    /// metadata but cannot be read now (e.g. deleted or permission error),
111    /// or if we never recorded metadata for the open entry.
112    pub fn is_stale_on_disk(&self, path: &Path) -> bool {
113        let Some(entry) = self.entries.get(path) else {
114            // Not open at all — caller should `open` instead of asking about
115            // staleness. We still return true so the caller doesn't act on
116            // stale assumptions.
117            return true;
118        };
119        let (current_mtime, current_size) = read_metadata(path);
120
121        match (entry.mtime, current_mtime) {
122            (Some(prev), Some(now)) if prev == now => {
123                // Same mtime — only stale if size somehow drifted (rare; a
124                // touch with same content but different length implies real
125                // drift even at same timestamp).
126                entry.size != current_size
127            }
128            (Some(_), Some(_)) => true, // mtimes differ
129            // Either we didn't record before or can't read now — be safe.
130            _ => true,
131        }
132    }
133}
134
135/// Read filesystem metadata, returning `(mtime, size)`. Both fields are
136/// `None` if the path cannot be statted or if the platform doesn't support
137/// the queried metadata (rare).
138fn read_metadata(path: &Path) -> (Option<SystemTime>, Option<u64>) {
139    match std::fs::metadata(path) {
140        Ok(meta) => {
141            let mtime = meta.modified().ok();
142            let size = Some(meta.len());
143            (mtime, size)
144        }
145        Err(_) => (None, None),
146    }
147}
148
149/// Public helper: read metadata for an arbitrary path. Useful for callers
150/// that want to consult disk state without going through the store.
151pub fn file_metadata(path: &Path) -> io::Result<(SystemTime, u64)> {
152    let meta = std::fs::metadata(path)?;
153    let mtime = meta.modified()?;
154    let size = meta.len();
155    Ok((mtime, size))
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::fs;
162    use std::io::Write;
163    use std::thread;
164    use std::time::Duration;
165
166    fn write_file(path: &Path, content: &str) {
167        let mut f = fs::File::create(path).expect("create test file");
168        f.write_all(content.as_bytes()).expect("write content");
169    }
170
171    #[test]
172    fn open_and_close_roundtrip() {
173        let mut store = DocumentStore::new();
174        let path = PathBuf::from("/tmp/aft-doc-test-doesnt-exist");
175        assert!(!store.is_open(&path));
176
177        let v = store.open(path.clone());
178        assert_eq!(v, 0);
179        assert!(store.is_open(&path));
180        assert_eq!(store.version(&path), Some(0));
181
182        let bumped = store.bump_version(&path);
183        assert_eq!(bumped, Some(1));
184        assert_eq!(store.version(&path), Some(1));
185
186        let closed = store.close(&path);
187        assert_eq!(closed, Some(1));
188        assert!(!store.is_open(&path));
189    }
190
191    #[test]
192    fn nonexistent_path_is_always_stale() {
193        let store = DocumentStore::new();
194        let path = PathBuf::from("/tmp/aft-doc-test-never-opened");
195        // Not open at all → stale
196        assert!(store.is_stale_on_disk(&path));
197    }
198
199    #[test]
200    fn freshly_opened_real_file_is_not_stale() {
201        let dir = tempfile::tempdir().expect("temp dir");
202        let path = dir.path().join("a.txt");
203        write_file(&path, "hello");
204
205        let mut store = DocumentStore::new();
206        store.open(path.clone());
207        assert!(!store.is_stale_on_disk(&path));
208    }
209
210    #[test]
211    fn opened_then_disk_changed_is_stale() {
212        let dir = tempfile::tempdir().expect("temp dir");
213        let path = dir.path().join("b.txt");
214        write_file(&path, "hello");
215
216        let mut store = DocumentStore::new();
217        store.open(path.clone());
218        assert!(!store.is_stale_on_disk(&path));
219
220        // Sleep enough that mtime resolution can differ (most filesystems
221        // give us at least millisecond precision, but be safe).
222        thread::sleep(Duration::from_millis(20));
223        write_file(&path, "hello world!");
224
225        assert!(store.is_stale_on_disk(&path));
226    }
227
228    #[test]
229    fn opened_file_then_deleted_is_stale() {
230        let dir = tempfile::tempdir().expect("temp dir");
231        let path = dir.path().join("c.txt");
232        write_file(&path, "data");
233
234        let mut store = DocumentStore::new();
235        store.open(path.clone());
236        assert!(!store.is_stale_on_disk(&path));
237
238        fs::remove_file(&path).expect("remove file");
239        // Cannot read metadata anymore → conservatively stale
240        assert!(store.is_stale_on_disk(&path));
241    }
242
243    #[test]
244    fn bump_version_refreshes_mtime() {
245        let dir = tempfile::tempdir().expect("temp dir");
246        let path = dir.path().join("d.txt");
247        write_file(&path, "original");
248
249        let mut store = DocumentStore::new();
250        store.open(path.clone());
251
252        thread::sleep(Duration::from_millis(20));
253        write_file(&path, "updated");
254        assert!(store.is_stale_on_disk(&path));
255
256        // After bump_version, the entry should pick up the new mtime/size
257        // (the caller is presumed to send didChange with the fresh content).
258        store.bump_version(&path);
259        assert!(!store.is_stale_on_disk(&path));
260    }
261
262    #[test]
263    fn open_documents_returns_all_paths() {
264        let mut store = DocumentStore::new();
265        store.open(PathBuf::from("/tmp/p1"));
266        store.open(PathBuf::from("/tmp/p2"));
267        let docs = store.open_documents();
268        assert_eq!(docs.len(), 2);
269    }
270
271    #[test]
272    fn entry_returns_full_state() {
273        let dir = tempfile::tempdir().expect("temp dir");
274        let path = dir.path().join("e.txt");
275        write_file(&path, "abc");
276
277        let mut store = DocumentStore::new();
278        store.open(path.clone());
279
280        let entry = store.entry(&path).expect("entry");
281        assert_eq!(entry.version, 0);
282        assert!(entry.mtime.is_some());
283        assert_eq!(entry.size, Some(3));
284    }
285}