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    // The dictionary and the ignore store are deliberately absent. Both are
55    // filters applied after the engines have run, so what is stored is what
56    // the engines said and both are re-applied on the way out. Including them
57    // meant a single word added to the dictionary invalidated every stored
58    // result in the workspace -- re-running the engines, a LanguageTool round
59    // trip per prose range, to reach the answer already held and discard one
60    // more of it. They are still parameters so a caller cannot silently stop
61    // passing what it must go on applying.
62    //
63    // The build, not only the version. A released binary has one of each, so
64    // an upgrade retires the results of the version before it either way;
65    // during development the version stands still while the code moves, and a
66    // result stored by the previous build is an answer the current engines no
67    // longer give. `build.rs` derives the id from this crate's sources, so an
68    // unchanged checkout keeps its stored results.
69    crate::hashing::stable_hash(&format!(
70        "{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}",
71        crate::hashing::stable_hash(text),
72        crate::hashing::stable_hash(&config_repr),
73        names_enabled,
74        schemas,
75        packs,
76        env!("CARGO_PKG_VERSION"),
77        env!("LANG_CHECK_BUILD_ID"),
78    ))
79}
80
81/// A check's result, with what it was computed from.
82///
83/// The fingerprint covers everything that can change what the *engines*
84/// produce -- the document text, the config, whether name detection is on, the
85/// installed packs, and the version of this program. A stored result is served
86/// only when it still matches, so there is one decision to get right rather
87/// than one per input.
88///
89/// What it deliberately leaves out is the dictionary and the ignore store.
90/// Those filter the engines' output rather than change it, and they are
91/// re-applied to a stored result on the way out.
92///
93/// Storing the result without it was the previous state of things: every check
94/// wrote here and nothing ever read it back.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct CachedCheck {
97    pub fingerprint: u64,
98    pub diagnostics: Vec<Diagnostic>,
99}
100
101pub struct WorkspaceIndex {
102    db: Database,
103    root_path: PathBuf,
104}
105
106impl WorkspaceIndex {
107    /// Create or open a workspace index.
108    ///
109    /// If `db_path` is provided, the database is created at that exact path.
110    /// Otherwise, the database is stored in the user data directory
111    /// (`~/.local/share/language-check/dbs/` on Linux,
112    ///  `~/Library/Application Support/language-check/dbs/` on macOS,
113    ///  `%APPDATA%/language-check/dbs/` on Windows),
114    /// named by a hash of the workspace root to avoid collisions.
115    pub fn new(workspace_root: &Path, db_path: Option<&Path>) -> Result<Self> {
116        let resolved_path = match db_path {
117            Some(p) => p.to_path_buf(),
118            None => default_db_path(workspace_root)?,
119        };
120
121        if let Some(parent) = resolved_path.parent() {
122            std::fs::create_dir_all(parent)?;
123        }
124
125        let db = Database::create(&resolved_path)?;
126
127        let write_txn = db.begin_write()?;
128        {
129            let _table = write_txn.open_table(DIAGNOSTICS_TABLE)?;
130            let _table = write_txn.open_table(INSIGHTS_TABLE)?;
131            let _table = write_txn.open_table(FILE_HASHES_TABLE)?;
132        }
133        write_txn.commit()?;
134
135        Ok(Self {
136            db,
137            root_path: workspace_root.to_path_buf(),
138        })
139    }
140
141    #[must_use]
142    pub fn get_root_path(&self) -> Option<&Path> {
143        Some(&self.root_path)
144    }
145
146    /// Check if a file's content has changed since last indexing.
147    /// Returns true if unchanged (cache hit), false if changed or new.
148    #[must_use]
149    pub fn is_file_unchanged(&self, file_path: &str, content: &str) -> bool {
150        let new_hash = crate::hashing::content_hash(content);
151        let Ok(read_txn) = self.db.begin_read() else {
152            return false;
153        };
154        let Ok(table) = read_txn.open_table(FILE_HASHES_TABLE) else {
155            return false;
156        };
157        let Ok(Some(stored)) = table.get(file_path) else {
158            return false;
159        };
160
161        stored.value() == new_hash.to_le_bytes()
162    }
163
164    /// Store the content hash for a file after indexing.
165    pub fn update_file_hash(&self, file_path: &str, content: &str) -> Result<()> {
166        let hash = crate::hashing::content_hash(content);
167        self.put_bytes(FILE_HASHES_TABLE, file_path, hash.to_le_bytes().as_slice())
168    }
169
170    pub fn update_insights(&self, file_path: &str, insights: &ProseInsights) -> Result<()> {
171        self.put_cbor(INSIGHTS_TABLE, file_path, &insights)
172    }
173
174    pub fn get_insights(&self, file_path: &str) -> Result<Option<ProseInsights>> {
175        self.get_cbor(INSIGHTS_TABLE, file_path)
176    }
177
178    /// The stored result for `file_path`, if it still applies.
179    ///
180    /// A fingerprint mismatch is a miss, and so is anything unreadable: an
181    /// index written by an older version holds a different shape, and failing
182    /// a check because of it would be worse than doing the work again.
183    #[must_use]
184    pub fn cached_check(&self, file_path: &str, fingerprint: u64) -> Option<Vec<Diagnostic>> {
185        let stored: CachedCheck = self.get_cbor(DIAGNOSTICS_TABLE, file_path).ok()??;
186        (stored.fingerprint == fingerprint).then_some(stored.diagnostics)
187    }
188
189    /// Record a check's result together with what produced it.
190    pub fn store_check(
191        &self,
192        file_path: &str,
193        fingerprint: u64,
194        diagnostics: &[Diagnostic],
195    ) -> Result<()> {
196        self.put_cbor(
197            DIAGNOSTICS_TABLE,
198            file_path,
199            &CachedCheck {
200                fingerprint,
201                diagnostics: diagnostics.to_vec(),
202            },
203        )
204    }
205
206    /// Write `bytes` under `key`, in a transaction of its own.
207    fn put_bytes(&self, table: Table, key: &str, bytes: &[u8]) -> Result<()> {
208        let write_txn = self.db.begin_write()?;
209        {
210            let mut table = write_txn.open_table(table)?;
211            table.insert(key, bytes)?;
212        }
213        write_txn.commit()?;
214        Ok(())
215    }
216
217    /// Write `value` under `key` as CBOR.
218    fn put_cbor<T: serde::Serialize>(&self, table: Table, key: &str, value: &T) -> Result<()> {
219        let mut data = Vec::new();
220        // nosemgrep: workspace-blobs-through-cbor-helpers -- this is the helper.
221        ciborium::into_writer(value, &mut data)?;
222        self.put_bytes(table, key, &data)
223    }
224
225    /// Read back what [`Self::put_cbor`] stored, if anything is under `key`.
226    fn get_cbor<T: serde::de::DeserializeOwned>(
227        &self,
228        table: Table,
229        key: &str,
230    ) -> Result<Option<T>> {
231        let read_txn = self.db.begin_read()?;
232        let table = read_txn.open_table(table)?;
233        let Some(data) = table.get(key)? else {
234            return Ok(None);
235        };
236        // nosemgrep: workspace-blobs-through-cbor-helpers -- this is the helper.
237        Ok(Some(ciborium::from_reader(data.value())?))
238    }
239}
240
241/// Compute the default database path for a workspace.
242///
243/// Uses `dirs::data_dir()` (`~/.local/share` on Linux, `~/Library/Application Support`
244/// on macOS, `%APPDATA%` on Windows) as the base, then appends
245/// `language-check/dbs/<hex-hash>.db` where the hash is derived from the
246/// canonical workspace root path.
247fn default_db_path(workspace_root: &Path) -> Result<PathBuf> {
248    let data_dir = dirs::data_dir()
249        .ok_or_else(|| anyhow::anyhow!("Could not determine user data directory"))?;
250
251    let canonical = workspace_root
252        .canonicalize()
253        .unwrap_or_else(|_| workspace_root.to_path_buf());
254
255    let mut hasher = DefaultHasher::new(); // nosemgrep: use-content-hash — hashes a PATH
256    // into a database filename, not a file's contents
257    // into a cache key, and the result is written to
258    // disk. `hashing::content_hash` is documented as
259    // same-process only, so pointing this at it would
260    // make the two uses look interchangeable.
261    canonical.to_string_lossy().hash(&mut hasher);
262    let hash = hasher.finish();
263
264    let db_dir = data_dir.join("language-check").join("dbs");
265    Ok(db_dir.join(format!("{hash:016x}.db")))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn temp_workspace(name: &str) -> (WorkspaceIndex, PathBuf) {
273        let dir = std::env::temp_dir().join(format!("lang_check_ws_{}", name));
274        let _ = std::fs::remove_dir_all(&dir);
275        std::fs::create_dir_all(&dir).unwrap();
276        // Tests use explicit db_path in temp dir to avoid polluting user data dir
277        let db_path = dir.join(".languagecheck.db");
278        let idx = WorkspaceIndex::new(&dir, Some(&db_path)).unwrap();
279        (idx, dir)
280    }
281
282    /// A dictionary edit must not invalidate a stored result.
283    ///
284    /// The dictionary is applied as a suppression *after* the engines have
285    /// run, exactly as a severity override is. A word added to it can only
286    /// remove findings from an answer already computed, so re-running the
287    /// engines reaches the same raw result and discards one more of it --
288    /// which for LanguageTool is a network round trip per prose range, paid
289    /// to learn nothing.
290    ///
291    /// The same holds for the ignore store, which is the other post-engine
292    /// filter.
293    #[test]
294    fn a_dictionary_edit_does_not_invalidate_a_stored_result() {
295        let config = crate::config::Config::default();
296        let ignores = crate::hashing::IgnoreStore::default();
297        let mut dictionary = crate::dictionary::Dictionary::default();
298        let before = check_fingerprint("some prose", &config, &dictionary, &ignores, false, 0);
299
300        //  inserts before it persists; the persist has nowhere to
301        // go in a test and its failure is not what is being measured.
302        let _ = dictionary.add_word("zorblat");
303        let after = check_fingerprint("some prose", &config, &dictionary, &ignores, false, 0);
304
305        assert_eq!(
306            before, after,
307            "adding a word re-ran the engines to reach the answer already stored"
308        );
309    }
310
311    #[test]
312    fn the_things_that_do_change_the_answer_still_change_the_fingerprint() {
313        // The other half: a fingerprint that ignored everything would serve a
314        // stale result for ever.
315        let config = crate::config::Config::default();
316        let ignores = crate::hashing::IgnoreStore::default();
317        let dictionary = crate::dictionary::Dictionary::default();
318        let base = check_fingerprint("some prose", &config, &dictionary, &ignores, false, 0);
319
320        assert_ne!(
321            base,
322            check_fingerprint("other prose", &config, &dictionary, &ignores, false, 0),
323            "the text"
324        );
325        let mut other = crate::config::Config::default();
326        other.engines.languagetool.enabled = !other.engines.languagetool.enabled;
327        assert_ne!(
328            base,
329            check_fingerprint("some prose", &other, &dictionary, &ignores, false, 0),
330            "the config"
331        );
332        assert_ne!(
333            base,
334            check_fingerprint("some prose", &config, &dictionary, &ignores, true, 0),
335            "name detection"
336        );
337        assert_ne!(
338            base,
339            check_fingerprint("some prose", &config, &dictionary, &ignores, false, 7),
340            "the schemas"
341        );
342    }
343
344    fn cleanup(dir: &Path) {
345        let _ = std::fs::remove_dir_all(dir);
346    }
347
348    #[test]
349    fn create_workspace_index() {
350        let (idx, dir) = temp_workspace("create");
351        assert_eq!(idx.get_root_path().unwrap(), &dir);
352        cleanup(&dir);
353    }
354
355    #[test]
356    fn diagnostics_roundtrip() {
357        let (idx, dir) = temp_workspace("diag_rt");
358
359        let diags = vec![Diagnostic {
360            start_byte: 0,
361            end_byte: 5,
362            message: "test error".to_string(),
363            suggestions: vec!["fix".to_string()],
364            rule_id: "test.rule".to_string(),
365            severity: 2,
366            unified_id: "test.unified".to_string(),
367            confidence: 0.9,
368            language: String::new(),
369            pack_installable: false,
370        }];
371
372        idx.store_check("test.md", 7, &diags).unwrap();
373        let retrieved = idx.cached_check("test.md", 7).unwrap();
374        assert_eq!(retrieved.len(), 1);
375        assert_eq!(retrieved[0].message, "test error");
376        assert_eq!(retrieved[0].start_byte, 0);
377        assert_eq!(retrieved[0].suggestions, vec!["fix"]);
378
379        cleanup(&dir);
380    }
381
382    #[test]
383    fn diagnostics_missing_file_returns_none() {
384        let (idx, dir) = temp_workspace("diag_none");
385        let result = idx.cached_check("nonexistent.md", 7);
386        assert!(result.is_none());
387        cleanup(&dir);
388    }
389
390    #[test]
391    fn a_stored_result_is_not_served_under_a_different_fingerprint() {
392        // The whole safety of the cache. A config change, an added dictionary
393        // word, an edited buffer -- each moves the fingerprint, and each must
394        // make the stored answer stop applying rather than come back stale.
395        let (idx, dir) = temp_workspace("fingerprint_guard");
396        let diags = vec![Diagnostic {
397            start_byte: 0,
398            end_byte: 4,
399            message: "stale".to_string(),
400            suggestions: Vec::new(),
401            rule_id: "spelling.typo".to_string(),
402            severity: 2,
403            unified_id: "spelling.typo".to_string(),
404            confidence: 0.8,
405            language: String::new(),
406            pack_installable: false,
407        }];
408        idx.store_check("f.md", 100, &diags).unwrap();
409
410        assert!(
411            idx.cached_check("f.md", 100).is_some(),
412            "the same inputs must hit"
413        );
414        assert!(
415            idx.cached_check("f.md", 101).is_none(),
416            "changed inputs must miss"
417        );
418
419        cleanup(&dir);
420    }
421
422    #[test]
423    fn insights_roundtrip() {
424        let (idx, dir) = temp_workspace("insights_rt");
425
426        let insights = ProseInsights {
427            word_count: 100,
428            sentence_count: 5,
429            character_count: 450,
430            reading_level: 8.5,
431        };
432
433        idx.update_insights("doc.md", &insights).unwrap();
434        let retrieved = idx.get_insights("doc.md").unwrap().unwrap();
435        assert_eq!(retrieved.word_count, 100);
436        assert_eq!(retrieved.sentence_count, 5);
437        assert_eq!(retrieved.character_count, 450);
438        assert!((retrieved.reading_level - 8.5).abs() < 0.01);
439
440        cleanup(&dir);
441    }
442
443    #[test]
444    fn file_hash_unchanged_detection() {
445        let (idx, dir) = temp_workspace("hash_unchanged");
446
447        let content = "Hello, world!";
448        idx.update_file_hash("test.md", content).unwrap();
449        assert!(idx.is_file_unchanged("test.md", content));
450
451        cleanup(&dir);
452    }
453
454    #[test]
455    fn file_hash_changed_detection() {
456        let (idx, dir) = temp_workspace("hash_changed");
457
458        idx.update_file_hash("test.md", "original content").unwrap();
459        assert!(!idx.is_file_unchanged("test.md", "modified content"));
460
461        cleanup(&dir);
462    }
463
464    #[test]
465    fn file_hash_new_file() {
466        let (idx, dir) = temp_workspace("hash_new");
467        assert!(!idx.is_file_unchanged("new.md", "any content"));
468        cleanup(&dir);
469    }
470
471    #[test]
472    fn overwrite_diagnostics() {
473        let (idx, dir) = temp_workspace("diag_overwrite");
474
475        let diags1 = vec![Diagnostic {
476            start_byte: 0,
477            end_byte: 3,
478            message: "first".to_string(),
479            ..Default::default()
480        }];
481        idx.store_check("f.md", 1, &diags1).unwrap();
482
483        let diags2 = vec![
484            Diagnostic {
485                start_byte: 0,
486                end_byte: 3,
487                message: "second".to_string(),
488                ..Default::default()
489            },
490            Diagnostic {
491                start_byte: 10,
492                end_byte: 15,
493                message: "third".to_string(),
494                ..Default::default()
495            },
496        ];
497        idx.store_check("f.md", 2, &diags2).unwrap();
498
499        let retrieved = idx.cached_check("f.md", 2).unwrap();
500        assert_eq!(retrieved.len(), 2);
501        assert_eq!(retrieved[0].message, "second");
502
503        cleanup(&dir);
504    }
505}