Skip to main content

tidepool_runtime/
lib.rs

1//! High-level runtime for compiling and executing Haskell source via Tidepool.
2//!
3//! Provides `compile_haskell` (source to Core) and `compile_and_run` (source to
4//! evaluated result), with filesystem caching of compiled CBOR artifacts.
5
6use tidepool_codegen::jit_machine::JitEffectMachine;
7pub use tidepool_codegen::jit_machine::JitError;
8pub use tidepool_effect::dispatch::DispatchEffect;
9pub use tidepool_eval::value::Value;
10use tidepool_repr::serial::{read_cbor, read_metadata, ReadError};
11use tidepool_repr::{CoreExpr, DataConTable};
12use std::fmt;
13use std::io;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use tempfile::TempDir;
17
18mod cache;
19
20/// Result of successful Haskell compilation: a Core expression and its associated DataCon metadata.
21pub type CompileResult = (CoreExpr, DataConTable);
22
23/// Errors that can occur during Haskell compilation.
24#[derive(Debug)]
25pub enum CompileError {
26    /// I/O error during file operations or process execution.
27    Io(io::Error),
28    /// The `tidepool-extract` process failed (e.g., GHC parse/type error).
29    ExtractFailed(String),
30    /// Failed to deserialize the CBOR output from `tidepool-extract`.
31    ReadError(ReadError),
32    /// A required output file (.cbor or meta.cbor) was not produced by the extractor.
33    MissingOutput(PathBuf),
34}
35
36impl fmt::Display for CompileError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            CompileError::Io(e) => write!(f, "I/O error: {}", e),
40            CompileError::ExtractFailed(msg) => write!(f, "Haskell compilation failed:\n{}", msg),
41            CompileError::ReadError(e) => write!(f, "CBOR deserialization error: {}", e),
42            CompileError::MissingOutput(path) => {
43                write!(f, "Missing output file from extractor: {}", path.display())
44            }
45        }
46    }
47}
48
49impl std::error::Error for CompileError {
50    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51        match self {
52            CompileError::Io(e) => Some(e),
53            CompileError::ReadError(e) => Some(e),
54            _ => None,
55        }
56    }
57}
58
59impl From<io::Error> for CompileError {
60    fn from(e: io::Error) -> Self {
61        CompileError::Io(e)
62    }
63}
64
65impl From<ReadError> for CompileError {
66    fn from(e: ReadError) -> Self {
67        CompileError::ReadError(e)
68    }
69}
70
71/// Unified error type for compile + run pipeline.
72#[derive(Debug)]
73pub enum RuntimeError {
74    Compile(CompileError),
75    Jit(JitError),
76}
77
78impl fmt::Display for RuntimeError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            RuntimeError::Compile(e) => write!(f, "{}", e),
82            RuntimeError::Jit(e) => write!(f, "{}", e),
83        }
84    }
85}
86
87impl std::error::Error for RuntimeError {
88    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
89        match self {
90            RuntimeError::Compile(e) => Some(e),
91            RuntimeError::Jit(e) => Some(e),
92        }
93    }
94}
95
96impl From<CompileError> for RuntimeError {
97    fn from(e: CompileError) -> Self {
98        Self::Compile(e)
99    }
100}
101
102impl From<JitError> for RuntimeError {
103    fn from(e: JitError) -> Self {
104        Self::Jit(e)
105    }
106}
107
108/// Compiles Haskell source code to Tidepool Core at runtime.
109///
110/// This function shells out to `tidepool-extract` (which must be available on the system `$PATH`)
111/// to perform GHC parsing, type-checking, and Core translation. It writes the source to a 
112/// temporary file, executes the extractor, and reads back the resulting CBOR and metadata.
113///
114/// Compiled results are cached in the XDG cache directory (typically `~/.cache/tidepool`)
115/// to speed up repeated compilations. The cache key is derived from the source code,
116/// the target binder, and a fingerprint of any included dependency directories.
117///
118/// # Arguments
119/// * `source` - The Haskell source code to compile.
120/// * `target` - The name of the top-level binder to use as the entry point (e.g., "main").
121/// * `include` - Paths to directories containing Haskell modules to include in the search path.
122///
123/// # Returns
124/// * `Ok((CoreExpr, DataConTable))` on success.
125/// * `Err(CompileError)` if compilation fails, the extractor is missing, or output is invalid.
126pub fn compile_haskell(
127    source: &str,
128    target: &str,
129    include: &[&Path],
130) -> Result<CompileResult, CompileError> {
131    let key = cache::cache_key(source, target, include);
132    if let Some((expr_bytes, meta_bytes)) = cache::cache_load(&key) {
133        // Attempt to deserialize cached data. If this fails, treat it as a cache
134        // miss and fall through to recompilation instead of propagating the error.
135        if let (Ok(expr), Ok(table)) = (read_cbor(&expr_bytes), read_metadata(&meta_bytes)) {
136            return Ok((expr, table));
137        }
138    }
139
140    // 1. Setup temporary workspace
141    let temp_dir = TempDir::new()?;
142    let input_path = temp_dir.path().join("input.hs");
143    std::fs::write(&input_path, source)?;
144
145    // 2. Execute tidepool-extract
146    // Arguments: <file.hs> --output-dir <dir> --target <name> [--include <dir> ...]
147    let mut cmd = Command::new("tidepool-extract");
148    cmd.arg(&input_path);
149    cmd.arg("--output-dir").arg(temp_dir.path());
150    cmd.arg("--target").arg(target);
151
152    for path in include {
153        cmd.arg("--include").arg(path);
154    }
155
156    let output = cmd.output().map_err(|e| {
157        if e.kind() == io::ErrorKind::NotFound {
158            io::Error::new(
159                io::ErrorKind::NotFound,
160                "tidepool-extract not found on PATH. Ensure the Tidepool harness is installed.",
161            )
162        } else {
163            e
164        }
165    })?;
166
167    if !output.status.success() {
168        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
169        return Err(CompileError::ExtractFailed(stderr));
170    }
171
172    // 3. Read and deserialize outputs
173    let expr_path = temp_dir.path().join(format!("{}.cbor", target));
174    let meta_path = temp_dir.path().join("meta.cbor");
175
176    if !expr_path.exists() {
177        return Err(CompileError::MissingOutput(expr_path));
178    }
179    if !meta_path.exists() {
180        return Err(CompileError::MissingOutput(meta_path));
181    }
182
183    let expr_bytes = std::fs::read(&expr_path)?;
184    let meta_bytes = std::fs::read(&meta_path)?;
185
186    let expr = read_cbor(&expr_bytes)?;
187    let table = read_metadata(&meta_bytes)?;
188
189    // Only store in cache if deserialization succeeded
190    cache::cache_store(&key, &expr_bytes, &meta_bytes);
191
192    Ok((expr, table))
193}
194
195const DEFAULT_NURSERY_SIZE: usize = 1 << 20; // 1 MiB
196
197/// Compile Haskell source and run it with the given effect handlers,
198/// using the specified nursery size.
199///
200/// # Arguments
201/// * `source` - The Haskell source code to compile.
202/// * `target` - The name of the entry point binder.
203/// * `include` - Search paths for Haskell modules.
204/// * `handlers` - Effect dispatchers for the JIT machine.
205/// * `user` - User context for effect handlers.
206/// * `nursery_size` - Size of the allocation nursery in bytes.
207///
208/// # Returns
209/// * `Ok(Value)` on successful execution.
210/// * `Err(RuntimeError)` for compilation or JIT execution errors.
211pub fn compile_and_run_with_nursery_size<U, H: DispatchEffect<U>>(
212    source: &str,
213    target: &str,
214    include: &[&Path],
215    handlers: &mut H,
216    user: &U,
217    nursery_size: usize,
218) -> Result<Value, RuntimeError> {
219    let (expr, table) = compile_haskell(source, target, include)?;
220    let mut machine = JitEffectMachine::compile(&expr, &table, nursery_size)?;
221    let value = machine.run(&table, handlers, user)?;
222    Ok(value)
223}
224
225/// Compile Haskell source and run it with the given effect handlers,
226/// using the default nursery size (1 MiB).
227///
228/// # Arguments
229/// * `source` - The Haskell source code to compile.
230/// * `target` - The name of the entry point binder.
231/// * `include` - Search paths for Haskell modules.
232/// * `handlers` - Effect dispatchers for the JIT machine.
233/// * `user` - User context for effect handlers.
234///
235/// # Returns
236/// * `Ok(Value)` on successful execution.
237/// * `Err(RuntimeError)` for compilation or JIT execution errors.
238pub fn compile_and_run<U, H: DispatchEffect<U>>(
239    source: &str,
240    target: &str,
241    include: &[&Path],
242    handlers: &mut H,
243    user: &U,
244) -> Result<Value, RuntimeError> {
245    compile_and_run_with_nursery_size(source, target, include, handlers, user, DEFAULT_NURSERY_SIZE)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    #[ignore] // Manual test: requires tidepool-extract on PATH
254    fn test_compile_identity() {
255        let source = "module Test where\nidentity x = x";
256        let (expr, _table) = compile_haskell(source, "identity", &[])
257            .expect("Failed to compile identity");
258
259        // identity = \x -> x, should have 2 nodes: [Var(x), Lam(x, 0)]
260        assert_eq!(expr.nodes.len(), 2);
261    }
262
263    #[test]
264    #[ignore] // Manual test: requires tidepool-extract on PATH
265    fn test_compile_error() {
266        let source = "module Test where\nfoo = garbage";
267        let res = compile_haskell(source, "foo", &[]);
268        assert!(res.is_err());
269        if let Err(CompileError::ExtractFailed(msg)) = res {
270            assert!(msg.contains("Variable not in scope: garbage") || msg.contains("not in scope: garbage"));
271        } else {
272            panic!("Expected ExtractFailed error, got {:?}", res);
273        }
274    }
275}