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
10type 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#[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 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!(
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#[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 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 #[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 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 #[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 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 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 fn put_cbor<T: serde::Serialize>(&self, table: Table, key: &str, value: &T) -> Result<()> {
219 let mut data = Vec::new();
220 ciborium::into_writer(value, &mut data)?;
222 self.put_bytes(table, key, &data)
223 }
224
225 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 Ok(Some(ciborium::from_reader(data.value())?))
238 }
239}
240
241fn 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(); 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 let db_path = dir.join(".languagecheck.db");
278 let idx = WorkspaceIndex::new(&dir, Some(&db_path)).unwrap();
279 (idx, dir)
280 }
281
282 #[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 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 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 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}