Skip to main content

lang_check/
cache.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::num::NonZeroUsize;
4use std::path::{Path, PathBuf};
5
6use lru::LruCache;
7
8use crate::prose::ProseRange;
9
10/// A cached parse result for a single file.
11#[derive(Debug, Clone)]
12pub struct ParseCacheEntry {
13    pub content_hash: u64,
14    pub prose_ranges: Vec<ProseRange>,
15}
16
17/// LRU cache for parsed prose extraction results keyed by file path.
18///
19/// Only returns cached results when the content hash matches, ensuring
20/// stale entries are automatically invalidated on file change.
21pub struct ParseCache {
22    cache: LruCache<PathBuf, ParseCacheEntry>,
23}
24
25impl ParseCache {
26    /// Create a new cache with the given capacity (number of files).
27    #[must_use]
28    pub fn new(capacity: usize) -> Self {
29        Self {
30            cache: LruCache::new(
31                NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(128).unwrap()),
32            ),
33        }
34    }
35
36    /// Look up cached prose ranges for a file. Returns `None` if the file is not
37    /// cached or the content has changed since the last parse.
38    #[must_use]
39    pub fn get(&mut self, path: &Path, content: &str) -> Option<Vec<ProseRange>> {
40        let hash = Self::hash_content(content);
41        self.cache
42            .get(path)
43            .filter(|entry| entry.content_hash == hash)
44            .map(|entry| entry.prose_ranges.clone())
45    }
46
47    /// Insert (or update) a cache entry for the given file.
48    pub fn put(&mut self, path: PathBuf, content: &str, prose_ranges: Vec<ProseRange>) {
49        let entry = ParseCacheEntry {
50            content_hash: Self::hash_content(content),
51            prose_ranges,
52        };
53        self.cache.put(path, entry);
54    }
55
56    /// Number of entries currently in the cache.
57    #[must_use]
58    pub fn len(&self) -> usize {
59        self.cache.len()
60    }
61
62    /// Whether the cache is empty.
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.cache.is_empty()
66    }
67
68    /// Evict a specific file from the cache.
69    pub fn invalidate(&mut self, path: &Path) {
70        self.cache.pop(path);
71    }
72
73    /// Clear all entries.
74    pub fn clear(&mut self) {
75        self.cache.clear();
76    }
77
78    fn hash_content(content: &str) -> u64 {
79        let mut hasher = DefaultHasher::new();
80        content.hash(&mut hasher);
81        hasher.finish()
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn cache_miss_on_empty() {
91        let mut cache = ParseCache::new(10);
92        assert!(cache.get(Path::new("foo.md"), "hello").is_none());
93    }
94
95    #[test]
96    fn cache_hit_after_put() {
97        let mut cache = ParseCache::new(10);
98        let ranges = vec![ProseRange {
99            start_byte: 0,
100            end_byte: 5,
101            exclusions: vec![],
102        }];
103        cache.put(PathBuf::from("foo.md"), "hello", ranges.clone());
104        let result = cache.get(Path::new("foo.md"), "hello");
105        assert_eq!(result, Some(ranges));
106    }
107
108    #[test]
109    fn cache_invalidated_on_content_change() {
110        let mut cache = ParseCache::new(10);
111        let ranges = vec![ProseRange {
112            start_byte: 0,
113            end_byte: 5,
114            exclusions: vec![],
115        }];
116        cache.put(PathBuf::from("foo.md"), "hello", ranges);
117        assert!(cache.get(Path::new("foo.md"), "hello world").is_none());
118    }
119
120    #[test]
121    fn cache_eviction_at_capacity() {
122        let mut cache = ParseCache::new(2);
123        let r = vec![ProseRange {
124            start_byte: 0,
125            end_byte: 1,
126            exclusions: vec![],
127        }];
128        cache.put(PathBuf::from("a.md"), "a", r.clone());
129        cache.put(PathBuf::from("b.md"), "b", r.clone());
130        cache.put(PathBuf::from("c.md"), "c", r.clone());
131
132        // "a.md" should have been evicted (LRU)
133        assert!(cache.get(Path::new("a.md"), "a").is_none());
134        assert!(cache.get(Path::new("b.md"), "b").is_some());
135        assert!(cache.get(Path::new("c.md"), "c").is_some());
136    }
137
138    #[test]
139    fn explicit_invalidation() {
140        let mut cache = ParseCache::new(10);
141        let r = vec![ProseRange {
142            start_byte: 0,
143            end_byte: 1,
144            exclusions: vec![],
145        }];
146        cache.put(PathBuf::from("foo.md"), "x", r);
147        cache.invalidate(Path::new("foo.md"));
148        assert!(cache.get(Path::new("foo.md"), "x").is_none());
149    }
150
151    #[test]
152    fn len_and_clear() {
153        let mut cache = ParseCache::new(10);
154        assert!(cache.is_empty());
155        cache.put(PathBuf::from("a.md"), "a", vec![]);
156        cache.put(PathBuf::from("b.md"), "b", vec![]);
157        assert_eq!(cache.len(), 2);
158        cache.clear();
159        assert!(cache.is_empty());
160    }
161}