Skip to main content

lang_check/
workspace.rs

1use crate::checker::Diagnostic;
2use crate::insights::ProseInsights;
3use anyhow::Result;
4use redb::{Database, ReadableDatabase, TableDefinition};
5use serde::{Deserialize, Serialize};
6use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8use std::path::{Path, PathBuf};
9
10/// Every table in the index maps a file path to an opaque byte blob, so one
11/// pair of accessors serves all three.
12type Table = TableDefinition<'static, &'static str, &'static [u8]>;
13
14const DIAGNOSTICS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("diagnostics");
15const INSIGHTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("insights");
16const FILE_HASHES_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("file_hashes");
17
18/// What a check's answer depends on, as one value.
19///
20/// A stored result is served only when this still matches, so every input that
21/// can change the answer has to be in here. The document text, because an
22/// edited buffer must be re-checked -- VS Code restores unsaved buffers across
23/// a reload, so a cache keyed on the file path alone would answer a dirty
24/// buffer with diagnostics computed from the saved version: right words, wrong
25/// offsets. The config, because it decides which engines run and at what
26/// severity. The dictionary and the ignored set, because both remove
27/// diagnostics after the engines produced them. Whether names are detected,
28/// for the same reason. The loaded SLS schemas, because a schema decides which
29/// lines of a document are prose at all. And the Hunspell packs on disk,
30/// because installing one is how a language stops being unreadable. And the version of this program, because an upgrade
31/// changes what the engines say without any of the above moving.
32///
33/// `stable_hash` and not `content_hash`: this value is written to disk in one
34/// process and compared in another.
35#[must_use]
36pub fn check_fingerprint(
37    text: &str,
38    config: &crate::config::Config,
39    dictionary: &crate::dictionary::Dictionary,
40    ignore_store: &crate::hashing::IgnoreStore,
41    names_enabled: bool,
42    schemas: u64,
43) -> u64 {
44    let config_repr = serde_json::to_string(config).unwrap_or_default();
45    // Installing a dictionary changes neither the document nor the config, so
46    // the packs have to be looked at directly or a language the user has just
47    // made readable goes on being reported as unreadable.
48    let packs = if config.engines.hunspell.enabled {
49        crate::packs::PackRegistry::for_hunspell(&config.engines.hunspell)
50            .fingerprint(&config.engines.hunspell.languages)
51    } else {
52        0
53    };
54    crate::hashing::stable_hash(&format!(
55        "{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}",
56        crate::hashing::stable_hash(text),
57        crate::hashing::stable_hash(&config_repr),
58        dictionary.fingerprint(),
59        ignore_store.fingerprint(),
60        names_enabled,
61        schemas,
62        packs,
63        env!("CARGO_PKG_VERSION"),
64    ))
65}
66
67/// A check's result, with what it was computed from.
68///
69/// The fingerprint covers everything that can change the answer -- the
70/// document text, the config, the user dictionary, the ignored diagnostics,
71/// whether name detection is on, and the version of this program. A stored
72/// result is served only when the fingerprint still matches, so there is one
73/// decision to get right rather than one per input.
74///
75/// Storing the result without it was the previous state of things: every check
76/// wrote here and nothing ever read it back.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CachedCheck {
79    pub fingerprint: u64,
80    pub diagnostics: Vec<Diagnostic>,
81}
82
83pub struct WorkspaceIndex {
84    db: Database,
85    root_path: PathBuf,
86}
87
88impl WorkspaceIndex {
89    /// Create or open a workspace index.
90    ///
91    /// If `db_path` is provided, the database is created at that exact path.
92    /// Otherwise, the database is stored in the user data directory
93    /// (`~/.local/share/language-check/dbs/` on Linux,
94    ///  `~/Library/Application Support/language-check/dbs/` on macOS,
95    ///  `%APPDATA%/language-check/dbs/` on Windows),
96    /// named by a hash of the workspace root to avoid collisions.
97    pub fn new(workspace_root: &Path, db_path: Option<&Path>) -> Result<Self> {
98        let resolved_path = match db_path {
99            Some(p) => p.to_path_buf(),
100            None => default_db_path(workspace_root)?,
101        };
102
103        if let Some(parent) = resolved_path.parent() {
104            std::fs::create_dir_all(parent)?;
105        }
106
107        let db = Database::create(&resolved_path)?;
108
109        let write_txn = db.begin_write()?;
110        {
111            let _table = write_txn.open_table(DIAGNOSTICS_TABLE)?;
112            let _table = write_txn.open_table(INSIGHTS_TABLE)?;
113            let _table = write_txn.open_table(FILE_HASHES_TABLE)?;
114        }
115        write_txn.commit()?;
116
117        Ok(Self {
118            db,
119            root_path: workspace_root.to_path_buf(),
120        })
121    }
122
123    #[must_use]
124    pub fn get_root_path(&self) -> Option<&Path> {
125        Some(&self.root_path)
126    }
127
128    /// Check if a file's content has changed since last indexing.
129    /// Returns true if unchanged (cache hit), false if changed or new.
130    #[must_use]
131    pub fn is_file_unchanged(&self, file_path: &str, content: &str) -> bool {
132        let new_hash = crate::hashing::content_hash(content);
133        let Ok(read_txn) = self.db.begin_read() else {
134            return false;
135        };
136        let Ok(table) = read_txn.open_table(FILE_HASHES_TABLE) else {
137            return false;
138        };
139        let Ok(Some(stored)) = table.get(file_path) else {
140            return false;
141        };
142
143        stored.value() == new_hash.to_le_bytes()
144    }
145
146    /// Store the content hash for a file after indexing.
147    pub fn update_file_hash(&self, file_path: &str, content: &str) -> Result<()> {
148        let hash = crate::hashing::content_hash(content);
149        self.put_bytes(FILE_HASHES_TABLE, file_path, hash.to_le_bytes().as_slice())
150    }
151
152    pub fn update_insights(&self, file_path: &str, insights: &ProseInsights) -> Result<()> {
153        self.put_cbor(INSIGHTS_TABLE, file_path, &insights)
154    }
155
156    pub fn get_insights(&self, file_path: &str) -> Result<Option<ProseInsights>> {
157        self.get_cbor(INSIGHTS_TABLE, file_path)
158    }
159
160    /// The stored result for `file_path`, if it still applies.
161    ///
162    /// A fingerprint mismatch is a miss, and so is anything unreadable: an
163    /// index written by an older version holds a different shape, and failing
164    /// a check because of it would be worse than doing the work again.
165    #[must_use]
166    pub fn cached_check(&self, file_path: &str, fingerprint: u64) -> Option<Vec<Diagnostic>> {
167        let stored: CachedCheck = self.get_cbor(DIAGNOSTICS_TABLE, file_path).ok()??;
168        (stored.fingerprint == fingerprint).then_some(stored.diagnostics)
169    }
170
171    /// Record a check's result together with what produced it.
172    pub fn store_check(
173        &self,
174        file_path: &str,
175        fingerprint: u64,
176        diagnostics: &[Diagnostic],
177    ) -> Result<()> {
178        self.put_cbor(
179            DIAGNOSTICS_TABLE,
180            file_path,
181            &CachedCheck {
182                fingerprint,
183                diagnostics: diagnostics.to_vec(),
184            },
185        )
186    }
187
188    /// Write `bytes` under `key`, in a transaction of its own.
189    fn put_bytes(&self, table: Table, key: &str, bytes: &[u8]) -> Result<()> {
190        let write_txn = self.db.begin_write()?;
191        {
192            let mut table = write_txn.open_table(table)?;
193            table.insert(key, bytes)?;
194        }
195        write_txn.commit()?;
196        Ok(())
197    }
198
199    /// Write `value` under `key` as CBOR.
200    fn put_cbor<T: serde::Serialize>(&self, table: Table, key: &str, value: &T) -> Result<()> {
201        let mut data = Vec::new();
202        // nosemgrep: workspace-blobs-through-cbor-helpers -- this is the helper.
203        ciborium::into_writer(value, &mut data)?;
204        self.put_bytes(table, key, &data)
205    }
206
207    /// Read back what [`Self::put_cbor`] stored, if anything is under `key`.
208    fn get_cbor<T: serde::de::DeserializeOwned>(
209        &self,
210        table: Table,
211        key: &str,
212    ) -> Result<Option<T>> {
213        let read_txn = self.db.begin_read()?;
214        let table = read_txn.open_table(table)?;
215        let Some(data) = table.get(key)? else {
216            return Ok(None);
217        };
218        // nosemgrep: workspace-blobs-through-cbor-helpers -- this is the helper.
219        Ok(Some(ciborium::from_reader(data.value())?))
220    }
221}
222
223/// Compute the default database path for a workspace.
224///
225/// Uses `dirs::data_dir()` (`~/.local/share` on Linux, `~/Library/Application Support`
226/// on macOS, `%APPDATA%` on Windows) as the base, then appends
227/// `language-check/dbs/<hex-hash>.db` where the hash is derived from the
228/// canonical workspace root path.
229fn default_db_path(workspace_root: &Path) -> Result<PathBuf> {
230    let data_dir = dirs::data_dir()
231        .ok_or_else(|| anyhow::anyhow!("Could not determine user data directory"))?;
232
233    let canonical = workspace_root
234        .canonicalize()
235        .unwrap_or_else(|_| workspace_root.to_path_buf());
236
237    let mut hasher = DefaultHasher::new(); // nosemgrep: use-content-hash — hashes a PATH
238    // into a database filename, not a file's contents
239    // into a cache key, and the result is written to
240    // disk. `hashing::content_hash` is documented as
241    // same-process only, so pointing this at it would
242    // make the two uses look interchangeable.
243    canonical.to_string_lossy().hash(&mut hasher);
244    let hash = hasher.finish();
245
246    let db_dir = data_dir.join("language-check").join("dbs");
247    Ok(db_dir.join(format!("{hash:016x}.db")))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn temp_workspace(name: &str) -> (WorkspaceIndex, PathBuf) {
255        let dir = std::env::temp_dir().join(format!("lang_check_ws_{}", name));
256        let _ = std::fs::remove_dir_all(&dir);
257        std::fs::create_dir_all(&dir).unwrap();
258        // Tests use explicit db_path in temp dir to avoid polluting user data dir
259        let db_path = dir.join(".languagecheck.db");
260        let idx = WorkspaceIndex::new(&dir, Some(&db_path)).unwrap();
261        (idx, dir)
262    }
263
264    fn cleanup(dir: &Path) {
265        let _ = std::fs::remove_dir_all(dir);
266    }
267
268    #[test]
269    fn create_workspace_index() {
270        let (idx, dir) = temp_workspace("create");
271        assert_eq!(idx.get_root_path().unwrap(), &dir);
272        cleanup(&dir);
273    }
274
275    #[test]
276    fn diagnostics_roundtrip() {
277        let (idx, dir) = temp_workspace("diag_rt");
278
279        let diags = vec![Diagnostic {
280            start_byte: 0,
281            end_byte: 5,
282            message: "test error".to_string(),
283            suggestions: vec!["fix".to_string()],
284            rule_id: "test.rule".to_string(),
285            severity: 2,
286            unified_id: "test.unified".to_string(),
287            confidence: 0.9,
288            language: String::new(),
289            pack_installable: false,
290        }];
291
292        idx.store_check("test.md", 7, &diags).unwrap();
293        let retrieved = idx.cached_check("test.md", 7).unwrap();
294        assert_eq!(retrieved.len(), 1);
295        assert_eq!(retrieved[0].message, "test error");
296        assert_eq!(retrieved[0].start_byte, 0);
297        assert_eq!(retrieved[0].suggestions, vec!["fix"]);
298
299        cleanup(&dir);
300    }
301
302    #[test]
303    fn diagnostics_missing_file_returns_none() {
304        let (idx, dir) = temp_workspace("diag_none");
305        let result = idx.cached_check("nonexistent.md", 7);
306        assert!(result.is_none());
307        cleanup(&dir);
308    }
309
310    #[test]
311    fn a_stored_result_is_not_served_under_a_different_fingerprint() {
312        // The whole safety of the cache. A config change, an added dictionary
313        // word, an edited buffer -- each moves the fingerprint, and each must
314        // make the stored answer stop applying rather than come back stale.
315        let (idx, dir) = temp_workspace("fingerprint_guard");
316        let diags = vec![Diagnostic {
317            start_byte: 0,
318            end_byte: 4,
319            message: "stale".to_string(),
320            suggestions: Vec::new(),
321            rule_id: "spelling.typo".to_string(),
322            severity: 2,
323            unified_id: "spelling.typo".to_string(),
324            confidence: 0.8,
325            language: String::new(),
326            pack_installable: false,
327        }];
328        idx.store_check("f.md", 100, &diags).unwrap();
329
330        assert!(
331            idx.cached_check("f.md", 100).is_some(),
332            "the same inputs must hit"
333        );
334        assert!(
335            idx.cached_check("f.md", 101).is_none(),
336            "changed inputs must miss"
337        );
338
339        cleanup(&dir);
340    }
341
342    #[test]
343    fn insights_roundtrip() {
344        let (idx, dir) = temp_workspace("insights_rt");
345
346        let insights = ProseInsights {
347            word_count: 100,
348            sentence_count: 5,
349            character_count: 450,
350            reading_level: 8.5,
351        };
352
353        idx.update_insights("doc.md", &insights).unwrap();
354        let retrieved = idx.get_insights("doc.md").unwrap().unwrap();
355        assert_eq!(retrieved.word_count, 100);
356        assert_eq!(retrieved.sentence_count, 5);
357        assert_eq!(retrieved.character_count, 450);
358        assert!((retrieved.reading_level - 8.5).abs() < 0.01);
359
360        cleanup(&dir);
361    }
362
363    #[test]
364    fn file_hash_unchanged_detection() {
365        let (idx, dir) = temp_workspace("hash_unchanged");
366
367        let content = "Hello, world!";
368        idx.update_file_hash("test.md", content).unwrap();
369        assert!(idx.is_file_unchanged("test.md", content));
370
371        cleanup(&dir);
372    }
373
374    #[test]
375    fn file_hash_changed_detection() {
376        let (idx, dir) = temp_workspace("hash_changed");
377
378        idx.update_file_hash("test.md", "original content").unwrap();
379        assert!(!idx.is_file_unchanged("test.md", "modified content"));
380
381        cleanup(&dir);
382    }
383
384    #[test]
385    fn file_hash_new_file() {
386        let (idx, dir) = temp_workspace("hash_new");
387        assert!(!idx.is_file_unchanged("new.md", "any content"));
388        cleanup(&dir);
389    }
390
391    #[test]
392    fn overwrite_diagnostics() {
393        let (idx, dir) = temp_workspace("diag_overwrite");
394
395        let diags1 = vec![Diagnostic {
396            start_byte: 0,
397            end_byte: 3,
398            message: "first".to_string(),
399            ..Default::default()
400        }];
401        idx.store_check("f.md", 1, &diags1).unwrap();
402
403        let diags2 = vec![
404            Diagnostic {
405                start_byte: 0,
406                end_byte: 3,
407                message: "second".to_string(),
408                ..Default::default()
409            },
410            Diagnostic {
411                start_byte: 10,
412                end_byte: 15,
413                message: "third".to_string(),
414                ..Default::default()
415            },
416        ];
417        idx.store_check("f.md", 2, &diags2).unwrap();
418
419        let retrieved = idx.cached_check("f.md", 2).unwrap();
420        assert_eq!(retrieved.len(), 2);
421        assert_eq!(retrieved[0].message, "second");
422
423        cleanup(&dir);
424    }
425}