Skip to main content

shape_vm/
bytecode_cache.rs

1//! Bytecode cache for compiled Shape modules
2//!
3//! Caches compiled bytecode programs on disk as `.shapec` files,
4//! keyed by the SHA-256 hash of the source content + compiler version.
5//! This avoids redundant recompilation when source files haven't changed.
6
7use sha2::{Digest, Sha256};
8use std::path::PathBuf;
9
10use crate::bytecode::BytecodeProgram;
11
12/// Compiler version embedded in cache keys to invalidate on upgrades
13const COMPILER_VERSION: &str = env!("CARGO_PKG_VERSION");
14
15/// On-disk bytecode cache stored under `~/.shape/cache/bytecode/`
16pub struct BytecodeCache {
17    cache_dir: PathBuf,
18}
19
20impl BytecodeCache {
21    /// Create a new bytecode cache, creating the cache directory if needed.
22    ///
23    /// The cache lives at `~/.shape/cache/bytecode/`. Returns `None` if the
24    /// home directory cannot be determined or the directory cannot be created.
25    pub fn new() -> Option<Self> {
26        let home = dirs::home_dir()?;
27        let cache_dir = home.join(".shape").join("cache").join("bytecode");
28        std::fs::create_dir_all(&cache_dir).ok()?;
29        Some(Self { cache_dir })
30    }
31
32    /// Create a cache at a specific directory (for testing).
33    pub fn with_dir(cache_dir: PathBuf) -> std::io::Result<Self> {
34        std::fs::create_dir_all(&cache_dir)?;
35        Ok(Self { cache_dir })
36    }
37
38    /// Look up cached bytecode for the given source content.
39    ///
40    /// Returns `Some(program)` on cache hit, `None` on miss or deserialization error.
41    pub fn get(&self, source: &str) -> Option<BytecodeProgram> {
42        let key = Self::cache_key(source);
43        let path = self.cache_path(&key);
44        let data = std::fs::read(&path).ok()?;
45        rmp_serde::from_slice(&data).ok()
46    }
47
48    /// Store compiled bytecode for the given source content.
49    ///
50    /// **W17-make-closure note.** Programs whose compiler produced any
51    /// `closure_function_layouts` entries (i.e. the source contains at
52    /// least one closure literal) are not cached. The `ClosureLayout`
53    /// side-table is currently `#[serde(skip)]` on `BytecodeProgram`
54    /// and `ContentAddressedProgram` — a deserialized program would
55    /// load with an empty layouts vector, and `op_make_closure` would
56    /// then surface `no ClosureLayout registered for function N`
57    /// (`crates/shape-vm/src/executor/control_flow/mod.rs:447`). Until
58    /// `ClosureLayout` (+ `ConcreteType` / `FieldInfo` / `NativeKind` /
59    /// `CaptureKind`) grow `Serialize`/`Deserialize` derives, the safe
60    /// disposition is to skip caching for closure-bearing programs.
61    /// Caching for closure-free programs remains in force.
62    pub fn put(&self, source: &str, program: &BytecodeProgram) -> std::io::Result<()> {
63        let has_closure_layouts = program
64            .closure_function_layouts
65            .iter()
66            .any(|opt| opt.is_some());
67        let has_ca_closure_layouts = program
68            .content_addressed
69            .as_ref()
70            .map(|ca| !ca.closure_function_layouts_by_name.is_empty())
71            .unwrap_or(false);
72        // ADR-006 §2.7.24 Q25.C: same `#[serde(skip)]` concern applies
73        // to `trait_vtables` — vtables hold `Arc<VTable>` which is not
74        // a stable wire shape; a deserialized program would load with
75        // empty vtables and `op_box_trait_object` would fail with
76        // "no vtable registered". Skip caching for trait-bearing
77        // programs until a kinded vtable wire format lands.
78        let has_trait_vtables = !program.trait_vtables.is_empty();
79        let has_ca_trait_vtables = program
80            .content_addressed
81            .as_ref()
82            .map(|ca| !ca.trait_vtables.is_empty())
83            .unwrap_or(false);
84        if has_closure_layouts || has_ca_closure_layouts
85            || has_trait_vtables || has_ca_trait_vtables
86        {
87            // Skip caching for closure-bearing OR trait-bearing programs.
88            return Ok(());
89        }
90        let key = Self::cache_key(source);
91        let path = self.cache_path(&key);
92        let data = rmp_serde::to_vec(program)
93            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
94        std::fs::write(&path, data)
95    }
96
97    /// Remove all cached bytecode files.
98    pub fn clear(&self) -> std::io::Result<()> {
99        for entry in std::fs::read_dir(&self.cache_dir)? {
100            let entry = entry?;
101            if entry
102                .path()
103                .extension()
104                .map_or(false, |ext| ext == "shapec")
105            {
106                std::fs::remove_file(entry.path())?;
107            }
108        }
109        Ok(())
110    }
111
112    /// Compute the cache key for a source string.
113    ///
114    /// Key = SHA-256(source_content + "\0" + compiler_version) as hex.
115    fn cache_key(source: &str) -> String {
116        let mut hasher = Sha256::new();
117        hasher.update(source.as_bytes());
118        hasher.update(b"\0");
119        hasher.update(COMPILER_VERSION.as_bytes());
120        format!("{:x}", hasher.finalize())
121    }
122
123    /// Map a cache key to a file path: `<cache_dir>/<key>.shapec`
124    fn cache_path(&self, key: &str) -> PathBuf {
125        self.cache_dir.join(format!("{}.shapec", key))
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn temp_cache() -> (BytecodeCache, tempfile::TempDir) {
134        let tmp = tempfile::tempdir().unwrap();
135        let cache = BytecodeCache::with_dir(tmp.path().join("bytecode")).unwrap();
136        (cache, tmp)
137    }
138
139    #[test]
140    fn test_put_get_roundtrip() {
141        let (cache, _tmp) = temp_cache();
142        let program = BytecodeProgram::new();
143        cache.put("let x = 1", &program).unwrap();
144        let cached = cache.get("let x = 1");
145        assert!(cached.is_some(), "Cache hit expected after put");
146    }
147
148    #[test]
149    fn test_cache_miss() {
150        let (cache, _tmp) = temp_cache();
151        let result = cache.get("nonexistent source");
152        assert!(result.is_none(), "Cache miss expected for unknown source");
153    }
154
155    #[test]
156    fn test_different_source_different_key() {
157        let (cache, _tmp) = temp_cache();
158        let program = BytecodeProgram::new();
159        cache.put("let x = 1", &program).unwrap();
160        let result = cache.get("let x = 2");
161        assert!(result.is_none(), "Different source should miss cache");
162    }
163
164    #[test]
165    fn test_clear() {
166        let (cache, _tmp) = temp_cache();
167        let program = BytecodeProgram::new();
168        cache.put("source_a", &program).unwrap();
169        cache.put("source_b", &program).unwrap();
170
171        cache.clear().unwrap();
172
173        assert!(
174            cache.get("source_a").is_none(),
175            "Cache should be empty after clear"
176        );
177        assert!(
178            cache.get("source_b").is_none(),
179            "Cache should be empty after clear"
180        );
181    }
182
183    #[test]
184    fn test_cache_key_deterministic() {
185        let key1 = BytecodeCache::cache_key("hello");
186        let key2 = BytecodeCache::cache_key("hello");
187        assert_eq!(key1, key2, "Same source should produce same key");
188    }
189
190    #[test]
191    fn test_cache_key_different_for_different_source() {
192        let key1 = BytecodeCache::cache_key("hello");
193        let key2 = BytecodeCache::cache_key("world");
194        assert_ne!(key1, key2, "Different source should produce different key");
195    }
196}