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 std::fmt;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use tempfile::TempDir;
11pub use tidepool_codegen::host_fns::{drain_diagnostics, push_diagnostic};
12use tidepool_codegen::jit_machine::JitEffectMachine;
13pub use tidepool_codegen::jit_machine::JitError;
14pub use tidepool_effect::dispatch::DispatchEffect;
15pub use tidepool_eval::value::Value;
16use tidepool_repr::serial::{read_cbor, read_metadata, MetaWarnings, ReadError};
17use tidepool_repr::{CoreExpr, DataConTable};
18
19mod cache;
20mod render;
21
22pub use render::{value_to_json, EvalResult};
23
24/// Result of successful Haskell compilation: a Core expression, DataCon metadata, and warnings.
25pub type CompileResult = (CoreExpr, DataConTable, MetaWarnings);
26
27/// Errors that can occur during Haskell compilation.
28#[derive(Debug)]
29pub enum CompileError {
30    /// I/O error during file operations or process execution.
31    Io(io::Error),
32    /// The `tidepool-extract` process failed (e.g., GHC parse/type error).
33    ExtractFailed(String),
34    /// Failed to deserialize the CBOR output from `tidepool-extract`.
35    ReadError(ReadError),
36    /// A required output file (.cbor or meta.cbor) was not produced by the extractor.
37    MissingOutput(PathBuf),
38    /// The target binding has IO type, which is not supported.
39    IOTypeDetected,
40}
41
42impl fmt::Display for CompileError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            CompileError::Io(e) => write!(f, "I/O error: {}", e),
46            CompileError::ExtractFailed(msg) => write!(f, "Haskell compilation failed:\n{}", msg),
47            CompileError::ReadError(e) => write!(f, "CBOR deserialization error: {}", e),
48            CompileError::MissingOutput(path) => {
49                write!(f, "Missing output file from extractor: {}", path.display())
50            }
51            CompileError::IOTypeDetected => {
52                write!(f, "IO type detected in result binding. IO operations (unsafePerformIO, etc.) are not supported in the Tidepool sandbox.")
53            }
54        }
55    }
56}
57
58impl std::error::Error for CompileError {
59    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
60        match self {
61            CompileError::Io(e) => Some(e),
62            CompileError::ReadError(e) => Some(e),
63            _ => None,
64        }
65    }
66}
67
68impl From<io::Error> for CompileError {
69    fn from(e: io::Error) -> Self {
70        CompileError::Io(e)
71    }
72}
73
74impl From<ReadError> for CompileError {
75    fn from(e: ReadError) -> Self {
76        CompileError::ReadError(e)
77    }
78}
79
80/// Unified error type for compile + run pipeline.
81#[derive(Debug)]
82pub enum RuntimeError {
83    Compile(CompileError),
84    Jit(JitError),
85}
86
87impl fmt::Display for RuntimeError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            RuntimeError::Compile(e) => write!(f, "{}", e),
91            RuntimeError::Jit(e) => write!(f, "{}", e),
92        }
93    }
94}
95
96impl std::error::Error for RuntimeError {
97    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
98        match self {
99            RuntimeError::Compile(e) => Some(e),
100            RuntimeError::Jit(e) => Some(e),
101        }
102    }
103}
104
105impl From<CompileError> for RuntimeError {
106    fn from(e: CompileError) -> Self {
107        Self::Compile(e)
108    }
109}
110
111impl From<JitError> for RuntimeError {
112    fn from(e: JitError) -> Self {
113        Self::Jit(e)
114    }
115}
116
117/// Extract module name from Haskell source (e.g. "module Expr where" -> "Expr").
118fn extract_module_name(source: &str) -> Option<String> {
119    for line in source.lines() {
120        let trimmed = line.trim();
121        if let Some(rest) = trimmed.strip_prefix("module ") {
122            // "module Foo.Bar where" or "module Foo (" → take until whitespace/paren
123            let name: String = rest
124                .trim_start()
125                .chars()
126                .take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_')
127                .collect();
128            if !name.is_empty() {
129                return Some(name);
130            }
131        }
132    }
133    None
134}
135
136/// Compiles Haskell source code to Tidepool Core at runtime.
137///
138/// This function shells out to `tidepool-extract` (which must be available on the system `$PATH`)
139/// to perform GHC parsing, type-checking, and Core translation. It writes the source to a
140/// temporary file, executes the extractor, and reads back the resulting CBOR and metadata.
141///
142/// Compiled results are cached in the XDG cache directory (typically `~/.cache/tidepool`)
143/// to speed up repeated compilations. The cache key is derived from the source code,
144/// the target binder, and a fingerprint of any included dependency directories.
145///
146/// # Arguments
147/// * `source` - The Haskell source code to compile.
148/// * `target` - The name of the top-level binder to use as the entry point (e.g., "main").
149/// * `include` - Paths to directories containing Haskell modules to include in the search path.
150///
151/// # Returns
152/// * `Ok((CoreExpr, DataConTable))` on success.
153/// * `Err(CompileError)` if compilation fails, the extractor is missing, or output is invalid.
154pub fn compile_haskell(
155    source: &str,
156    target: &str,
157    include: &[&Path],
158) -> Result<CompileResult, CompileError> {
159    let key = cache::cache_key(source, target, include);
160    if let Some((expr_bytes, meta_bytes)) = cache::cache_load(&key) {
161        // Attempt to deserialize cached data. If this fails, treat it as a cache
162        // miss and fall through to recompilation instead of propagating the error.
163        if let (Ok(expr), Ok((table, warnings))) =
164            (read_cbor(&expr_bytes), read_metadata(&meta_bytes))
165        {
166            return Ok((expr, table, warnings));
167        }
168    }
169
170    // 1. Setup temporary workspace
171    // Derive filename from the module declaration so GHC's module name matches
172    // the filename (GhcPipeline uses capitalize(takeBaseName(path)) as target).
173    let temp_dir = TempDir::new()?;
174    let filename = extract_module_name(source)
175        .map(|m| format!("{}.hs", m))
176        .unwrap_or_else(|| "Input.hs".to_string());
177    let input_path = temp_dir.path().join(&filename);
178    std::fs::write(&input_path, source)?;
179
180    // 2. Execute tidepool-extract
181    // Arguments: <file.hs> --output-dir <dir> --target <name> [--include <dir> ...]
182    let extract_bin =
183        std::env::var("TIDEPOOL_EXTRACT").unwrap_or_else(|_| "tidepool-extract".to_string());
184    let mut cmd = Command::new(&extract_bin);
185    cmd.arg(&input_path);
186    cmd.arg("--output-dir").arg(temp_dir.path());
187    cmd.arg("--target").arg(target);
188
189    for path in include {
190        cmd.arg("--include").arg(path);
191    }
192
193    let output = cmd.output().map_err(|e| {
194        if e.kind() == io::ErrorKind::NotFound {
195            io::Error::new(
196                io::ErrorKind::NotFound,
197                "tidepool-extract not found on PATH. Ensure the Tidepool harness is installed.",
198            )
199        } else {
200            e
201        }
202    })?;
203
204    // Always print stderr for diagnostics (trace output from Haskell)
205    let stderr_str = String::from_utf8_lossy(&output.stderr);
206    if !stderr_str.is_empty() {
207        eprintln!("[tidepool-extract stderr]\n{}", stderr_str);
208    }
209
210    if !output.status.success() {
211        return Err(CompileError::ExtractFailed(stderr_str.into_owned()));
212    }
213
214    // 3. Read and deserialize outputs
215    let expr_path = temp_dir.path().join(format!("{}.cbor", target));
216    let meta_path = temp_dir.path().join("meta.cbor");
217
218    if !expr_path.exists() {
219        return Err(CompileError::MissingOutput(expr_path));
220    }
221    if !meta_path.exists() {
222        return Err(CompileError::MissingOutput(meta_path));
223    }
224
225    let expr_bytes = std::fs::read(&expr_path)?;
226    let meta_bytes = std::fs::read(&meta_path)?;
227
228    let expr = read_cbor(&expr_bytes)?;
229    let (table, warnings) = read_metadata(&meta_bytes)?;
230
231    // Only store in cache if deserialization succeeded
232    cache::cache_store(&key, &expr_bytes, &meta_bytes);
233
234    Ok((expr, table, warnings))
235}
236
237const DEFAULT_NURSERY_SIZE: usize = 1 << 26; // 64 MiB
238
239/// Compile Haskell source and run it with the given effect handlers,
240/// using the specified nursery size.
241///
242/// # Arguments
243/// * `source` - The Haskell source code to compile.
244/// * `target` - The name of the entry point binder.
245/// * `include` - Search paths for Haskell modules.
246/// * `handlers` - Effect dispatchers for the JIT machine.
247/// * `user` - User context for effect handlers.
248/// * `nursery_size` - Size of the allocation nursery in bytes.
249///
250/// # Returns
251/// * `Ok(EvalResult)` on successful execution.
252/// * `Err(RuntimeError)` for compilation or JIT execution errors.
253pub fn compile_and_run_with_nursery_size<U, H: DispatchEffect<U>>(
254    source: &str,
255    target: &str,
256    include: &[&Path],
257    handlers: &mut H,
258    user: &U,
259    nursery_size: usize,
260) -> Result<EvalResult, RuntimeError> {
261    let (expr, mut table, warnings) = compile_haskell(source, target, include)?;
262    if warnings.has_io {
263        return Err(RuntimeError::Compile(CompileError::IOTypeDetected));
264    }
265    // Populate type-sibling groups from case branches so that get_companion
266    // can disambiguate constructors sharing unqualified names (e.g. Bin/Tip
267    // from Data.Map vs Data.Set).
268    table.populate_siblings_from_expr(&expr);
269    let mut machine = JitEffectMachine::compile(&expr, &table, nursery_size)?;
270    let value = machine.run(&table, handlers, user)?;
271    Ok(EvalResult::new(value, table))
272}
273
274/// Compile Haskell source and run it as a pure (non-effectful) program.
275///
276/// Skips freer-simple effect dispatch — the result is converted directly
277/// from the heap. Use this for programs that don't use an `Eff` wrapper.
278pub fn compile_and_run_pure(
279    source: &str,
280    target: &str,
281    include: &[&Path],
282) -> Result<EvalResult, RuntimeError> {
283    let (expr, mut table, warnings) = compile_haskell(source, target, include)?;
284    if warnings.has_io {
285        return Err(RuntimeError::Compile(CompileError::IOTypeDetected));
286    }
287    table.populate_siblings_from_expr(&expr);
288    let mut machine = JitEffectMachine::compile(&expr, &table, DEFAULT_NURSERY_SIZE)?;
289    let value = machine.run_pure()?;
290    Ok(EvalResult::new(value, table))
291}
292
293/// Compile Haskell source and run it with the given effect handlers,
294/// using the default nursery size (64 MiB).
295///
296/// # Arguments
297/// * `source` - The Haskell source code to compile.
298/// * `target` - The name of the entry point binder.
299/// * `include` - Search paths for Haskell modules.
300/// * `handlers` - Effect dispatchers for the JIT machine.
301/// * `user` - User context for effect handlers.
302///
303/// # Returns
304/// * `Ok(EvalResult)` on successful execution.
305/// * `Err(RuntimeError)` for compilation or JIT execution errors.
306pub fn compile_and_run<U, H: DispatchEffect<U>>(
307    source: &str,
308    target: &str,
309    include: &[&Path],
310    handlers: &mut H,
311    user: &U,
312) -> Result<EvalResult, RuntimeError> {
313    compile_and_run_with_nursery_size(
314        source,
315        target,
316        include,
317        handlers,
318        user,
319        DEFAULT_NURSERY_SIZE,
320    )
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    /// Set up TIDEPOOL_EXTRACT env var and check GHC availability.
328    /// Returns false if GHC is not available (test should skip).
329    fn ensure_extract_available() -> bool {
330        if std::env::var("TIDEPOOL_EXTRACT").is_err() {
331            let bin = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
332                .parent()
333                .unwrap()
334                .join("haskell")
335                .join("tidepool-extract");
336            if bin.exists() {
337                std::env::set_var("TIDEPOOL_EXTRACT", &bin);
338            }
339        }
340        // GHC is needed by tidepool-extract; only available inside `nix develop`
341        std::process::Command::new("ghc")
342            .arg("--version")
343            .stdout(std::process::Stdio::null())
344            .stderr(std::process::Stdio::null())
345            .status()
346            .map(|s| s.success())
347            .unwrap_or(false)
348    }
349
350    #[test]
351    fn test_compile_identity() {
352        if !ensure_extract_available() {
353            eprintln!("Skipping: GHC not available (run inside `nix develop`)");
354            return;
355        }
356        let source = "module Test where\nidentity x = x";
357        let (expr, _table, _warnings) =
358            compile_haskell(source, "identity", &[]).expect("Failed to compile identity");
359
360        // identity = \x -> x — node count varies with GHC optimization level
361        assert!(expr.nodes.len() >= 2);
362    }
363
364    #[test]
365    fn test_compile_error() {
366        if !ensure_extract_available() {
367            eprintln!("Skipping: GHC not available (run inside `nix develop`)");
368            return;
369        }
370        let source = "module Test where\nfoo = garbage";
371        let res = compile_haskell(source, "foo", &[]);
372        assert!(res.is_err());
373        if let Err(CompileError::ExtractFailed(msg)) = res {
374            assert!(
375                msg.contains("Variable not in scope: garbage")
376                    || msg.contains("not in scope: garbage")
377            );
378        } else {
379            panic!("Expected ExtractFailed error, got {:?}", res);
380        }
381    }
382}