about summary refs log tree commit diff
path: root/tvix/cli
diff options
context:
space:
mode:
Diffstat (limited to 'tvix/cli')
-rw-r--r--tvix/cli/Cargo.toml3
-rw-r--r--tvix/cli/default.nix3
-rw-r--r--tvix/cli/src/args.rs72
-rw-r--r--tvix/cli/src/lib.rs224
-rw-r--r--tvix/cli/src/main.rs268
-rw-r--r--tvix/cli/src/repl.rs218
-rw-r--r--tvix/cli/tests/repl.rs27
7 files changed, 469 insertions, 346 deletions
diff --git a/tvix/cli/Cargo.toml b/tvix/cli/Cargo.toml
index f6d03ce5ab8f..abbb7e6c2bd0 100644
--- a/tvix/cli/Cargo.toml
+++ b/tvix/cli/Cargo.toml
@@ -30,6 +30,9 @@ tracing-indicatif = "0.3.6"
 [dependencies.wu-manber]
 git = "https://github.com/tvlfyi/wu-manber.git"
 
+[dev-dependencies]
+expect-test = "1.5.0"
+
 [target.'cfg(not(target_env = "msvc"))'.dependencies]
 tikv-jemallocator = "0.5"
 
diff --git a/tvix/cli/default.nix b/tvix/cli/default.nix
index 62e93cc21333..3ed7a7ccfaec 100644
--- a/tvix/cli/default.nix
+++ b/tvix/cli/default.nix
@@ -2,6 +2,9 @@
 
 (depot.tvix.crates.workspaceMembers.tvix-cli.build.override {
   runTests = true;
+  testPreRun = ''
+    export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt;
+  '';
 }).overrideAttrs (finalAttrs: previousAttrs:
 
 let
diff --git a/tvix/cli/src/args.rs b/tvix/cli/src/args.rs
new file mode 100644
index 000000000000..ebb3c0dc107e
--- /dev/null
+++ b/tvix/cli/src/args.rs
@@ -0,0 +1,72 @@
+use std::path::PathBuf;
+
+use clap::Parser;
+use tracing::Level;
+
+#[derive(Parser, Clone)]
+pub struct Args {
+    /// A global log level to use when printing logs.
+    /// It's also possible to set `RUST_LOG` according to
+    /// `tracing_subscriber::filter::EnvFilter`, which will always have
+    /// priority.
+    #[arg(long, default_value_t=Level::INFO)]
+    pub log_level: Level,
+
+    /// Path to a script to evaluate
+    pub script: Option<PathBuf>,
+
+    #[clap(long, short = 'E')]
+    pub expr: Option<String>,
+
+    /// Dump the raw AST to stdout before interpreting
+    #[clap(long, env = "TVIX_DISPLAY_AST")]
+    pub display_ast: bool,
+
+    /// Dump the bytecode to stdout before evaluating
+    #[clap(long, env = "TVIX_DUMP_BYTECODE")]
+    pub dump_bytecode: bool,
+
+    /// Trace the runtime of the VM
+    #[clap(long, env = "TVIX_TRACE_RUNTIME")]
+    pub trace_runtime: bool,
+
+    /// Capture the time (relative to the start time of evaluation) of all events traced with
+    /// `--trace-runtime`
+    #[clap(long, env = "TVIX_TRACE_RUNTIME_TIMING", requires("trace_runtime"))]
+    pub trace_runtime_timing: bool,
+
+    /// Only compile, but do not execute code. This will make Tvix act
+    /// sort of like a linter.
+    #[clap(long)]
+    pub compile_only: bool,
+
+    /// Don't print warnings.
+    #[clap(long)]
+    pub no_warnings: bool,
+
+    /// A colon-separated list of directories to use to resolve `<...>`-style paths
+    #[clap(long, short = 'I', env = "NIX_PATH")]
+    pub nix_search_path: Option<String>,
+
+    /// Print "raw" (unquoted) output.
+    #[clap(long)]
+    pub raw: bool,
+
+    /// Strictly evaluate values, traversing them and forcing e.g.
+    /// elements of lists and attribute sets before printing the
+    /// return value.
+    #[clap(long)]
+    pub strict: bool,
+
+    #[arg(long, env, default_value = "memory://")]
+    pub blob_service_addr: String,
+
+    #[arg(long, env, default_value = "memory://")]
+    pub directory_service_addr: String,
+
+    #[arg(long, env, default_value = "memory://")]
+    pub path_info_service_addr: String,
+
+    #[arg(long, env, default_value = "dummy://")]
+    pub build_service_addr: String,
+}
diff --git a/tvix/cli/src/lib.rs b/tvix/cli/src/lib.rs
new file mode 100644
index 000000000000..008593c5d84f
--- /dev/null
+++ b/tvix/cli/src/lib.rs
@@ -0,0 +1,224 @@
+use std::{collections::HashMap, path::PathBuf, rc::Rc};
+
+use smol_str::SmolStr;
+use std::fmt::Write;
+use tracing::{instrument, Span};
+use tracing_indicatif::span_ext::IndicatifSpanExt;
+use tvix_build::buildservice;
+use tvix_eval::{
+    builtins::impure_builtins,
+    observer::{DisassemblingObserver, TracingObserver},
+    ErrorKind, EvalIO, Value,
+};
+use tvix_glue::{
+    builtins::{add_derivation_builtins, add_fetcher_builtins, add_import_builtins},
+    configure_nix_path,
+    tvix_io::TvixIO,
+    tvix_store_io::TvixStoreIO,
+};
+
+pub mod args;
+pub mod assignment;
+pub mod repl;
+
+pub use args::Args;
+pub use repl::Repl;
+
+pub fn init_io_handle(tokio_runtime: &tokio::runtime::Runtime, args: &Args) -> Rc<TvixStoreIO> {
+    let (blob_service, directory_service, path_info_service, nar_calculation_service) =
+        tokio_runtime
+            .block_on({
+                let blob_service_addr = args.blob_service_addr.clone();
+                let directory_service_addr = args.directory_service_addr.clone();
+                let path_info_service_addr = args.path_info_service_addr.clone();
+                async move {
+                    tvix_store::utils::construct_services(
+                        blob_service_addr,
+                        directory_service_addr,
+                        path_info_service_addr,
+                    )
+                    .await
+                }
+            })
+            .expect("unable to setup {blob|directory|pathinfo}service before interpreter setup");
+
+    let build_service = tokio_runtime
+        .block_on({
+            let blob_service = blob_service.clone();
+            let directory_service = directory_service.clone();
+            async move {
+                buildservice::from_addr(
+                    &args.build_service_addr,
+                    blob_service.clone(),
+                    directory_service.clone(),
+                )
+                .await
+            }
+        })
+        .expect("unable to setup buildservice before interpreter setup");
+
+    Rc::new(TvixStoreIO::new(
+        blob_service.clone(),
+        directory_service.clone(),
+        path_info_service.into(),
+        nar_calculation_service.into(),
+        build_service.into(),
+        tokio_runtime.handle().clone(),
+    ))
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum AllowIncomplete {
+    Allow,
+    #[default]
+    RequireComplete,
+}
+
+impl AllowIncomplete {
+    fn allow(&self) -> bool {
+        matches!(self, Self::Allow)
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct IncompleteInput;
+
+/// Interprets the given code snippet, printing out warnings and errors and returning the result
+pub fn evaluate(
+    tvix_store_io: Rc<TvixStoreIO>,
+    code: &str,
+    path: Option<PathBuf>,
+    args: &Args,
+    allow_incomplete: AllowIncomplete,
+    env: Option<&HashMap<SmolStr, Value>>,
+) -> Result<Option<Value>, IncompleteInput> {
+    let span = Span::current();
+    span.pb_start();
+    span.pb_set_style(&tvix_tracing::PB_SPINNER_STYLE);
+    span.pb_set_message("Setting up evaluator…");
+
+    let mut eval_builder = tvix_eval::Evaluation::builder(Box::new(TvixIO::new(
+        tvix_store_io.clone() as Rc<dyn EvalIO>,
+    )) as Box<dyn EvalIO>)
+    .enable_import()
+    .with_strict(args.strict)
+    .add_builtins(impure_builtins())
+    .env(env);
+
+    eval_builder = add_derivation_builtins(eval_builder, Rc::clone(&tvix_store_io));
+    eval_builder = add_fetcher_builtins(eval_builder, Rc::clone(&tvix_store_io));
+    eval_builder = add_import_builtins(eval_builder, tvix_store_io);
+    eval_builder = configure_nix_path(eval_builder, &args.nix_search_path);
+
+    let source_map = eval_builder.source_map().clone();
+    let result = {
+        let mut compiler_observer =
+            DisassemblingObserver::new(source_map.clone(), std::io::stderr());
+        if args.dump_bytecode {
+            eval_builder.set_compiler_observer(Some(&mut compiler_observer));
+        }
+
+        let mut runtime_observer = TracingObserver::new(std::io::stderr());
+        if args.trace_runtime {
+            if args.trace_runtime_timing {
+                runtime_observer.enable_timing()
+            }
+            eval_builder.set_runtime_observer(Some(&mut runtime_observer));
+        }
+
+        span.pb_set_message("Evaluating…");
+
+        let eval = eval_builder.build();
+        eval.evaluate(code, path)
+    };
+
+    if allow_incomplete.allow()
+        && result.errors.iter().any(|err| {
+            matches!(
+                &err.kind,
+                ErrorKind::ParseErrors(pes)
+                    if pes.iter().any(|pe| matches!(pe, rnix::parser::ParseError::UnexpectedEOF))
+            )
+        })
+    {
+        return Err(IncompleteInput);
+    }
+
+    if args.display_ast {
+        if let Some(ref expr) = result.expr {
+            eprintln!("AST: {}", tvix_eval::pretty_print_expr(expr));
+        }
+    }
+
+    for error in &result.errors {
+        error.fancy_format_stderr();
+    }
+
+    if !args.no_warnings {
+        for warning in &result.warnings {
+            warning.fancy_format_stderr(&source_map);
+        }
+    }
+
+    Ok(result.value)
+}
+
+pub struct InterpretResult {
+    output: String,
+    success: bool,
+}
+
+impl InterpretResult {
+    pub fn empty_success() -> Self {
+        Self {
+            output: String::new(),
+            success: true,
+        }
+    }
+
+    pub fn finalize(self) -> bool {
+        print!("{}", self.output);
+        self.success
+    }
+
+    pub fn output(&self) -> &str {
+        &self.output
+    }
+
+    pub fn success(&self) -> bool {
+        self.success
+    }
+}
+
+/// Interprets the given code snippet, printing out warnings, errors
+/// and the result itself. The return value indicates whether
+/// evaluation succeeded.
+#[instrument(skip_all, fields(indicatif.pb_show=1))]
+pub fn interpret(
+    tvix_store_io: Rc<TvixStoreIO>,
+    code: &str,
+    path: Option<PathBuf>,
+    args: &Args,
+    explain: bool,
+    allow_incomplete: AllowIncomplete,
+    env: Option<&HashMap<SmolStr, Value>>,
+) -> Result<InterpretResult, IncompleteInput> {
+    let mut output = String::new();
+    let result = evaluate(tvix_store_io, code, path, args, allow_incomplete, env)?;
+
+    if let Some(value) = result.as_ref() {
+        if explain {
+            writeln!(&mut output, "=> {}", value.explain()).unwrap();
+        } else if args.raw {
+            writeln!(&mut output, "{}", value.to_contextful_str().unwrap()).unwrap();
+        } else {
+            writeln!(&mut output, "=> {} :: {}", value, value.type_of()).unwrap();
+        }
+    }
+
+    // inform the caller about any errors
+    Ok(InterpretResult {
+        output,
+        success: result.is_some(),
+    })
+}
diff --git a/tvix/cli/src/main.rs b/tvix/cli/src/main.rs
index 2f7dc58a23aa..0bd3be37eeb2 100644
--- a/tvix/cli/src/main.rs
+++ b/tvix/cli/src/main.rs
@@ -1,23 +1,11 @@
-mod assignment;
-mod repl;
-
 use clap::Parser;
-use repl::Repl;
-use smol_str::SmolStr;
-use std::collections::HashMap;
 use std::rc::Rc;
 use std::{fs, path::PathBuf};
-use tracing::{instrument, Level, Span};
-use tracing_indicatif::span_ext::IndicatifSpanExt;
-use tvix_build::buildservice;
-use tvix_eval::builtins::impure_builtins;
-use tvix_eval::observer::{DisassemblingObserver, TracingObserver};
-use tvix_eval::{ErrorKind, EvalIO, Value};
-use tvix_glue::builtins::add_fetcher_builtins;
-use tvix_glue::builtins::add_import_builtins;
-use tvix_glue::tvix_io::TvixIO;
+use tvix_cli::args::Args;
+use tvix_cli::repl::Repl;
+use tvix_cli::{init_io_handle, interpret, AllowIncomplete};
+use tvix_eval::observer::DisassemblingObserver;
 use tvix_glue::tvix_store_io::TvixStoreIO;
-use tvix_glue::{builtins::add_derivation_builtins, configure_nix_path};
 
 #[cfg(not(target_env = "msvc"))]
 use tikv_jemallocator::Jemalloc;
@@ -26,240 +14,6 @@ use tikv_jemallocator::Jemalloc;
 #[global_allocator]
 static GLOBAL: Jemalloc = Jemalloc;
 
-#[derive(Parser, Clone)]
-struct Args {
-    /// A global log level to use when printing logs.
-    /// It's also possible to set `RUST_LOG` according to
-    /// `tracing_subscriber::filter::EnvFilter`, which will always have
-    /// priority.
-    #[arg(long, default_value_t=Level::INFO)]
-    log_level: Level,
-
-    /// Path to a script to evaluate
-    script: Option<PathBuf>,
-
-    #[clap(long, short = 'E')]
-    expr: Option<String>,
-
-    /// Dump the raw AST to stdout before interpreting
-    #[clap(long, env = "TVIX_DISPLAY_AST")]
-    display_ast: bool,
-
-    /// Dump the bytecode to stdout before evaluating
-    #[clap(long, env = "TVIX_DUMP_BYTECODE")]
-    dump_bytecode: bool,
-
-    /// Trace the runtime of the VM
-    #[clap(long, env = "TVIX_TRACE_RUNTIME")]
-    trace_runtime: bool,
-
-    /// Capture the time (relative to the start time of evaluation) of all events traced with
-    /// `--trace-runtime`
-    #[clap(long, env = "TVIX_TRACE_RUNTIME_TIMING", requires("trace_runtime"))]
-    trace_runtime_timing: bool,
-
-    /// Only compile, but do not execute code. This will make Tvix act
-    /// sort of like a linter.
-    #[clap(long)]
-    compile_only: bool,
-
-    /// Don't print warnings.
-    #[clap(long)]
-    no_warnings: bool,
-
-    /// A colon-separated list of directories to use to resolve `<...>`-style paths
-    #[clap(long, short = 'I', env = "NIX_PATH")]
-    nix_search_path: Option<String>,
-
-    /// Print "raw" (unquoted) output.
-    #[clap(long)]
-    raw: bool,
-
-    /// Strictly evaluate values, traversing them and forcing e.g.
-    /// elements of lists and attribute sets before printing the
-    /// return value.
-    #[clap(long)]
-    strict: bool,
-
-    #[arg(long, env, default_value = "memory://")]
-    blob_service_addr: String,
-
-    #[arg(long, env, default_value = "memory://")]
-    directory_service_addr: String,
-
-    #[arg(long, env, default_value = "memory://")]
-    path_info_service_addr: String,
-
-    #[arg(long, env, default_value = "dummy://")]
-    build_service_addr: String,
-}
-
-fn init_io_handle(tokio_runtime: &tokio::runtime::Runtime, args: &Args) -> Rc<TvixStoreIO> {
-    let (blob_service, directory_service, path_info_service, nar_calculation_service) =
-        tokio_runtime
-            .block_on({
-                let blob_service_addr = args.blob_service_addr.clone();
-                let directory_service_addr = args.directory_service_addr.clone();
-                let path_info_service_addr = args.path_info_service_addr.clone();
-                async move {
-                    tvix_store::utils::construct_services(
-                        blob_service_addr,
-                        directory_service_addr,
-                        path_info_service_addr,
-                    )
-                    .await
-                }
-            })
-            .expect("unable to setup {blob|directory|pathinfo}service before interpreter setup");
-
-    let build_service = tokio_runtime
-        .block_on({
-            let blob_service = blob_service.clone();
-            let directory_service = directory_service.clone();
-            async move {
-                buildservice::from_addr(
-                    &args.build_service_addr,
-                    blob_service.clone(),
-                    directory_service.clone(),
-                )
-                .await
-            }
-        })
-        .expect("unable to setup buildservice before interpreter setup");
-
-    Rc::new(TvixStoreIO::new(
-        blob_service.clone(),
-        directory_service.clone(),
-        path_info_service.into(),
-        nar_calculation_service.into(),
-        build_service.into(),
-        tokio_runtime.handle().clone(),
-    ))
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
-enum AllowIncomplete {
-    Allow,
-    #[default]
-    RequireComplete,
-}
-
-impl AllowIncomplete {
-    fn allow(&self) -> bool {
-        matches!(self, Self::Allow)
-    }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct IncompleteInput;
-
-/// Interprets the given code snippet, printing out warnings and errors and returning the result
-fn evaluate(
-    tvix_store_io: Rc<TvixStoreIO>,
-    code: &str,
-    path: Option<PathBuf>,
-    args: &Args,
-    allow_incomplete: AllowIncomplete,
-    env: Option<&HashMap<SmolStr, Value>>,
-) -> Result<Option<Value>, IncompleteInput> {
-    let span = Span::current();
-    span.pb_start();
-    span.pb_set_style(&tvix_tracing::PB_SPINNER_STYLE);
-    span.pb_set_message("Setting up evaluator…");
-
-    let mut eval_builder = tvix_eval::Evaluation::builder(Box::new(TvixIO::new(
-        tvix_store_io.clone() as Rc<dyn EvalIO>,
-    )) as Box<dyn EvalIO>)
-    .enable_import()
-    .with_strict(args.strict)
-    .add_builtins(impure_builtins())
-    .env(env);
-
-    eval_builder = add_derivation_builtins(eval_builder, Rc::clone(&tvix_store_io));
-    eval_builder = add_fetcher_builtins(eval_builder, Rc::clone(&tvix_store_io));
-    eval_builder = add_import_builtins(eval_builder, tvix_store_io);
-    eval_builder = configure_nix_path(eval_builder, &args.nix_search_path);
-
-    let source_map = eval_builder.source_map().clone();
-    let result = {
-        let mut compiler_observer =
-            DisassemblingObserver::new(source_map.clone(), std::io::stderr());
-        if args.dump_bytecode {
-            eval_builder.set_compiler_observer(Some(&mut compiler_observer));
-        }
-
-        let mut runtime_observer = TracingObserver::new(std::io::stderr());
-        if args.trace_runtime {
-            if args.trace_runtime_timing {
-                runtime_observer.enable_timing()
-            }
-            eval_builder.set_runtime_observer(Some(&mut runtime_observer));
-        }
-
-        span.pb_set_message("Evaluating…");
-
-        let eval = eval_builder.build();
-        eval.evaluate(code, path)
-    };
-
-    if allow_incomplete.allow()
-        && result.errors.iter().any(|err| {
-            matches!(
-                &err.kind,
-                ErrorKind::ParseErrors(pes)
-                    if pes.iter().any(|pe| matches!(pe, rnix::parser::ParseError::UnexpectedEOF))
-            )
-        })
-    {
-        return Err(IncompleteInput);
-    }
-
-    if args.display_ast {
-        if let Some(ref expr) = result.expr {
-            eprintln!("AST: {}", tvix_eval::pretty_print_expr(expr));
-        }
-    }
-
-    for error in &result.errors {
-        error.fancy_format_stderr();
-    }
-
-    if !args.no_warnings {
-        for warning in &result.warnings {
-            warning.fancy_format_stderr(&source_map);
-        }
-    }
-
-    Ok(result.value)
-}
-
-/// Interprets the given code snippet, printing out warnings, errors
-/// and the result itself. The return value indicates whether
-/// evaluation succeeded.
-#[instrument(skip_all, fields(indicatif.pb_show=1))]
-fn interpret(
-    tvix_store_io: Rc<TvixStoreIO>,
-    code: &str,
-    path: Option<PathBuf>,
-    args: &Args,
-    explain: bool,
-    allow_incomplete: AllowIncomplete,
-    env: Option<&HashMap<SmolStr, Value>>,
-) -> Result<bool, IncompleteInput> {
-    let result = evaluate(tvix_store_io, code, path, args, allow_incomplete, env)?;
-
-    if let Some(value) = result.as_ref() {
-        if explain {
-            println!("=> {}", value.explain());
-        } else {
-            println_result(value, args.raw);
-        }
-    }
-
-    // inform the caller about any errors
-    Ok(result.is_some())
-}
-
 /// Interpret the given code snippet, but only run the Tvix compiler
 /// on it and return errors and warnings.
 fn lint(code: &str, path: Option<PathBuf>, args: &Args) -> bool {
@@ -323,12 +77,13 @@ fn main() {
             None, // TODO(aspen): Pass in --arg/--argstr here
         )
         .unwrap()
+        .finalize()
         {
             std::process::exit(1);
         }
     } else {
-        let mut repl = Repl::new();
-        repl.run(io_handle, &args)
+        let mut repl = Repl::new(io_handle, &args);
+        repl.run()
     }
 }
 
@@ -351,17 +106,10 @@ fn run_file(io_handle: Rc<TvixStoreIO>, mut path: PathBuf, args: &Args) {
             None,
         )
         .unwrap()
+        .finalize()
     };
 
     if !success {
         std::process::exit(1);
     }
 }
-
-fn println_result(result: &Value, raw: bool) {
-    if raw {
-        println!("{}", result.to_contextful_str().unwrap())
-    } else {
-        println!("=> {} :: {}", result, result.type_of())
-    }
-}
diff --git a/tvix/cli/src/repl.rs b/tvix/cli/src/repl.rs
index 758874016326..5098fbaeedc3 100644
--- a/tvix/cli/src/repl.rs
+++ b/tvix/cli/src/repl.rs
@@ -6,8 +6,10 @@ use smol_str::SmolStr;
 use tvix_eval::Value;
 use tvix_glue::tvix_store_io::TvixStoreIO;
 
-use crate::evaluate;
-use crate::{assignment::Assignment, interpret, AllowIncomplete, Args, IncompleteInput};
+use crate::{
+    assignment::Assignment, evaluate, interpret, AllowIncomplete, Args, IncompleteInput,
+    InterpretResult,
+};
 
 fn state_dir() -> Option<PathBuf> {
     let mut path = dirs::data_dir();
@@ -18,7 +20,7 @@ fn state_dir() -> Option<PathBuf> {
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ReplCommand<'a> {
+pub(crate) enum ReplCommand<'a> {
     Expr(&'a str),
     Assign(Assignment<'a>),
     Explain(&'a str),
@@ -65,27 +67,47 @@ The following commands are supported:
     }
 }
 
-#[derive(Debug)]
-pub struct Repl {
+pub struct CommandResult {
+    output: String,
+    continue_: bool,
+}
+
+impl CommandResult {
+    pub fn finalize(self) -> bool {
+        print!("{}", self.output);
+        self.continue_
+    }
+
+    pub fn output(&self) -> &str {
+        &self.output
+    }
+}
+
+pub struct Repl<'a> {
     /// In-progress multiline input, when the input so far doesn't parse as a complete expression
     multiline_input: Option<String>,
     rl: Editor<()>,
     /// Local variables defined at the top-level in the repl
     env: HashMap<SmolStr, Value>,
+
+    io_handle: Rc<TvixStoreIO>,
+    args: &'a Args,
 }
 
-impl Repl {
-    pub fn new() -> Self {
+impl<'a> Repl<'a> {
+    pub fn new(io_handle: Rc<TvixStoreIO>, args: &'a Args) -> Self {
         let rl = Editor::<()>::new().expect("should be able to launch rustyline");
         Self {
             multiline_input: None,
             rl,
             env: HashMap::new(),
+            io_handle,
+            args,
         }
     }
 
-    pub fn run(&mut self, io_handle: Rc<TvixStoreIO>, args: &Args) {
-        if args.compile_only {
+    pub fn run(&mut self) {
+        if self.args.compile_only {
             eprintln!("warning: `--compile-only` has no effect on REPL usage!");
         }
 
@@ -112,83 +134,8 @@ impl Repl {
             let readline = self.rl.readline(prompt);
             match readline {
                 Ok(line) => {
-                    if line.is_empty() {
-                        continue;
-                    }
-
-                    let input = if let Some(mi) = &mut self.multiline_input {
-                        mi.push('\n');
-                        mi.push_str(&line);
-                        mi
-                    } else {
-                        &line
-                    };
-
-                    let res = match ReplCommand::parse(input) {
-                        ReplCommand::Quit => break,
-                        ReplCommand::Help => {
-                            println!("{}", ReplCommand::HELP);
-                            Ok(false)
-                        }
-                        ReplCommand::Expr(input) => interpret(
-                            Rc::clone(&io_handle),
-                            input,
-                            None,
-                            args,
-                            false,
-                            AllowIncomplete::Allow,
-                            Some(&self.env),
-                        ),
-                        ReplCommand::Assign(Assignment { ident, value }) => {
-                            match evaluate(
-                                Rc::clone(&io_handle),
-                                &value.to_string(), /* FIXME: don't re-parse */
-                                None,
-                                args,
-                                AllowIncomplete::Allow,
-                                Some(&self.env),
-                            ) {
-                                Ok(Some(value)) => {
-                                    self.env.insert(ident.into(), value);
-                                    Ok(true)
-                                }
-                                Ok(None) => Ok(true),
-                                Err(incomplete) => Err(incomplete),
-                            }
-                        }
-                        ReplCommand::Explain(input) => interpret(
-                            Rc::clone(&io_handle),
-                            input,
-                            None,
-                            args,
-                            true,
-                            AllowIncomplete::Allow,
-                            Some(&self.env),
-                        ),
-                        ReplCommand::Print(input) => interpret(
-                            Rc::clone(&io_handle),
-                            input,
-                            None,
-                            &Args {
-                                strict: true,
-                                ..(args.clone())
-                            },
-                            false,
-                            AllowIncomplete::Allow,
-                            Some(&self.env),
-                        ),
-                    };
-
-                    match res {
-                        Ok(_) => {
-                            self.rl.add_history_entry(input);
-                            self.multiline_input = None;
-                        }
-                        Err(IncompleteInput) => {
-                            if self.multiline_input.is_none() {
-                                self.multiline_input = Some(line);
-                            }
-                        }
+                    if !self.send(line).finalize() {
+                        break;
                     }
                 }
                 Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => break,
@@ -204,4 +151,103 @@ impl Repl {
             self.rl.save_history(&path).unwrap();
         }
     }
+
+    /// Send a line of user input to the REPL. Returns a result indicating the output to show to the
+    /// user, and whether or not to continue
+    pub fn send(&mut self, line: String) -> CommandResult {
+        if line.is_empty() {
+            return CommandResult {
+                output: String::new(),
+                continue_: true,
+            };
+        }
+
+        let input = if let Some(mi) = &mut self.multiline_input {
+            mi.push('\n');
+            mi.push_str(&line);
+            mi
+        } else {
+            &line
+        };
+
+        let res = match ReplCommand::parse(input) {
+            ReplCommand::Quit => {
+                return CommandResult {
+                    output: String::new(),
+                    continue_: true,
+                };
+            }
+            ReplCommand::Help => {
+                println!("{}", ReplCommand::HELP);
+                Ok(InterpretResult::empty_success())
+            }
+            ReplCommand::Expr(input) => interpret(
+                Rc::clone(&self.io_handle),
+                input,
+                None,
+                self.args,
+                false,
+                AllowIncomplete::Allow,
+                Some(&self.env),
+            ),
+            ReplCommand::Assign(Assignment { ident, value }) => {
+                match evaluate(
+                    Rc::clone(&self.io_handle),
+                    &value.to_string(), /* FIXME: don't re-parse */
+                    None,
+                    self.args,
+                    AllowIncomplete::Allow,
+                    Some(&self.env),
+                ) {
+                    Ok(Some(value)) => {
+                        self.env.insert(ident.into(), value);
+                        Ok(InterpretResult::empty_success())
+                    }
+                    Ok(None) => Ok(InterpretResult::empty_success()),
+                    Err(incomplete) => Err(incomplete),
+                }
+            }
+            ReplCommand::Explain(input) => interpret(
+                Rc::clone(&self.io_handle),
+                input,
+                None,
+                self.args,
+                true,
+                AllowIncomplete::Allow,
+                Some(&self.env),
+            ),
+            ReplCommand::Print(input) => interpret(
+                Rc::clone(&self.io_handle),
+                input,
+                None,
+                &Args {
+                    strict: true,
+                    ..(self.args.clone())
+                },
+                false,
+                AllowIncomplete::Allow,
+                Some(&self.env),
+            ),
+        };
+
+        match res {
+            Ok(InterpretResult { output, .. }) => {
+                self.rl.add_history_entry(input);
+                self.multiline_input = None;
+                CommandResult {
+                    output,
+                    continue_: true,
+                }
+            }
+            Err(IncompleteInput) => {
+                if self.multiline_input.is_none() {
+                    self.multiline_input = Some(line);
+                }
+                CommandResult {
+                    output: String::new(),
+                    continue_: true,
+                }
+            }
+        }
+    }
 }
diff --git a/tvix/cli/tests/repl.rs b/tvix/cli/tests/repl.rs
new file mode 100644
index 000000000000..8d49e7771db3
--- /dev/null
+++ b/tvix/cli/tests/repl.rs
@@ -0,0 +1,27 @@
+use std::ffi::OsString;
+
+use clap::Parser;
+use expect_test::expect;
+use tvix_cli::init_io_handle;
+
+macro_rules! test_repl {
+    ($name:ident() {$($send:expr => $expect:expr;)*}) => {
+        #[test]
+        fn $name() {
+            let tokio_runtime = tokio::runtime::Runtime::new().unwrap();
+            let args = tvix_cli::Args::parse_from(Vec::<OsString>::new());
+            let mut repl = tvix_cli::Repl::new(init_io_handle(&tokio_runtime, &args), &args);
+            $({
+                let result = repl.send($send.into());
+                $expect.assert_eq(result.output())
+                ;
+            })*
+        }
+    }
+}
+
+test_repl!(simple_expr_eval() {
+    "1" => expect![[r#"
+        => 1 :: int
+    "#]];
+});