Lines
38.05 %
Functions
30.99 %
Branches
100 %
use std::fs;
use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::path::Path;
use anyhow::Context as _;
use clap::Parser;
use scripting::nomiscript::{Reader, Value};
use uuid::Uuid;
use nms::interpreter;
use scripting::runtime::ProfilerStrategy;
mod repl;
mod rpc_eval;
mod slynk;
mod ssh_eval;
fn parse_profiler(s: Option<&str>) -> anyhow::Result<ProfilerStrategy> {
match s {
None => Ok(ProfilerStrategy::None),
Some("jitdump") => Ok(ProfilerStrategy::JitDump),
Some("perfmap") => Ok(ProfilerStrategy::PerfMap),
Some(other) => Err(anyhow::anyhow!(
"unknown --profile strategy '{other}' (expected: jitdump, perfmap)"
)),
}
#[derive(Parser)]
#[command(name = "nms")]
#[command(about = "Nomiscript interpreter", long_about = None)]
struct Cli {
/// File to evaluate (use - for stdin)
#[arg(value_name = "FILE")]
file: Option<String>,
/// Evaluate a string expression
#[arg(short, long, value_name = "EXPR")]
eval: Option<String>,
/// Compile a source file to WASM bytecode
#[arg(long, value_name = "FILE")]
compile: Option<String>,
/// Load and run a pre-compiled WASM file
load: Option<String>,
/// Use TUI mode for the REPL
#[arg(short, long)]
tui: bool,
/// Enable debug-level tracing output
#[arg(long)]
debug: bool,
/// Route forms through `rpc::Session` for the given user, exposing
/// the DB-touching natives (list-accounts, get-commodity, ...).
/// Requires DATABASE_URL. Without this flag, `nms` is a pure language
/// sandbox and the rpc/server crates are dormant.
#[arg(long, value_name = "UUID")]
rpc_user: Option<Uuid>,
/// Start a SLYNK server on the given TCP port instead of a terminal REPL,
/// so Emacs SLY can drive nomiscript via `M-x sly-connect localhost PORT`.
/// Evaluates through `rpc::Session` — pass `--rpc-user <UUID>` (and set
/// DATABASE_URL) to expose the DB-backed natives; without it the session
/// runs as the nil user.
#[arg(long, value_name = "PORT")]
slynk_port: Option<u16>,
/// Connect to a remote `nomisync-eval` subsystem over SSH and run
/// forms there instead of a local session. Shells out to the system
/// `ssh`, so authentication (key, ssh-agent, `~/.ssh/config`, or a
/// password prompt) is OpenSSH's job and the SSH identity maps to a
/// nomisync user server-side — no DATABASE_URL is needed locally.
/// Value: `[user@]host`. Combine with `-e`/FILE for one-shot runs,
/// or omit both for an interactive REPL.
#[arg(long, value_name = "[USER@]HOST")]
ssh: Option<String>,
/// TCP port for `--ssh` (defaults to your ssh config / port 22).
ssh_port: Option<u16>,
/// Disable inline kitty-graphics rendering of `Value::Bytes`
/// image payloads. Forces the textual `#u8(...)` fallback even
/// when the surrounding terminal advertises kitty support — use
/// this in CI captures and when piping output through a tool
/// that can't strip APC sequences.
no_graphics: bool,
/// Load every `*.nms` file under PATH (file or directory),
/// then run `(run-tests)`. Exits non-zero if any test failed.
/// PATH defaults to `tests/` when omitted as a bare flag.
#[arg(long, value_name = "PATH")]
test: Option<String>,
/// With `--test`: also print the `(coverage-dump)` output after
/// the test run. Useful for verifying every native fn the host
/// exposes has at least one test that compiles against it (the
/// parity contract from plan §"All-32 enumeration"). Off by
/// default to keep CI output terse.
coverage: bool,
/// Enable wasmtime's JitDump profiler — emits a `jit-<pid>.dump`
/// next to the working directory consumable by `perf record` and
/// flame-graph tooling. Linux only; requires building with
/// `--features profile-jitdump` (off by default to avoid the
/// extra dep on non-Linux). Use `perfmap` for the lighter symbol-
/// only variant.
#[arg(long, value_name = "STRATEGY")]
profile: Option<String>,
/// Render policy for `Value::Bytes` image payloads. Computed once at
/// startup from the `--no-graphics` flag + current env + stdout
/// terminal-state so every value printer sees the same decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GraphicsPolicy {
inline: bool,
impl GraphicsPolicy {
fn from_cli(no_graphics: bool) -> Self {
// `--no-graphics` or non-TTY stdout disables inline. Otherwise
// we honour the terminal capability check in
// `nms::graphics::supports_kitty`.
let inline = !no_graphics
&& io::stdout().is_terminal()
&& nms::graphics::supports_kitty(|name| std::env::var(name).ok());
Self { inline }
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let graphics = GraphicsPolicy::from_cli(cli.no_graphics);
let profiler = parse_profiler(cli.profile.as_deref())?;
if let Some(target) = cli.ssh.as_deref() {
return run_ssh_mode(target, cli.ssh_port, &cli);
} else if let Some(port) = cli.slynk_port {
return run_slynk_mode(port, cli.rpc_user);
} else if let Some(path) = cli.test.as_deref() {
return run_test_mode(path, cli.debug, cli.coverage);
} else if let Some(user_id) = cli.rpc_user {
run_rpc_mode(user_id, &cli)?;
} else if let Some(source) = cli.compile {
compile_file(&source, cli.debug)?;
} else if let Some(wasm_path) = cli.load {
load_wasm(&wasm_path, cli.debug, graphics)?;
} else if let Some(expr_str) = cli.eval {
eval_string(&expr_str, cli.debug, graphics, profiler)?;
} else if let Some(file) = cli.file {
if file == "-" {
eval_stdin(cli.debug, graphics, profiler)?;
} else {
eval_file(&file, cli.debug, graphics, profiler)?;
} else if cli.tui {
repl::run()?;
plain_repl(cli.debug, graphics)?;
Ok(())
/// `nms --slynk-port PORT [--rpc-user UUID]`: serve SLY over SLYNK. Uses a
/// multi-thread runtime so the connection's reader task can act on an
/// `(:emacs-interrupt)` (epoch-bump the engine) while an eval is in flight on
/// the eval task. `user_id` defaults to the nil user when `--rpc-user` is
/// omitted; the DB-backed natives then error per-call rather than at startup.
fn run_slynk_mode(port: u16, user_id: Option<Uuid>) -> anyhow::Result<()> {
// With a user, the DB-backed natives (list-accounts, set-split-tag, …) run
// through `server::command::*`, which lazily connects to `DATABASE_URL`.
// Verify it up front so a missing env surfaces as a clean startup error
// rather than a panic deep inside the first DB-touching native (matching
// the `--rpc-user` REPL). Without a user, nms is a pure-language sandbox
// and no DB is needed.
let database_url_set = std::env::var("DATABASE_URL")
.map(|url| !url.trim().is_empty())
.unwrap_or(false);
if user_id.is_some() && !database_url_set {
anyhow::bail!(
"--rpc-user needs DATABASE_URL so the DB-backed natives can reach \
Postgres; export it (or drop --rpc-user for a sandbox-only server)"
);
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("slynk-mode tokio runtime build failed")?;
runtime.block_on(slynk::serve(port, user_id.unwrap_or_else(Uuid::nil)))
fn run_rpc_mode(user_id: Uuid, cli: &Cli) -> anyhow::Result<()> {
let mut rpc = rpc_eval::RpcEval::new(user_id)?;
if let Some(expr) = cli.eval.as_deref() {
println!("{}", rpc.eval(expr));
return Ok(());
if let Some(file) = cli.file.as_deref() {
let content = if file == "-" {
let mut s = String::new();
io::stdin().read_to_string(&mut s)?;
s
fs::read_to_string(file)?
};
for form in split_forms(&content) {
println!("{}", rpc.eval(&form));
rpc_repl(&mut rpc)
/// `nms --ssh [USER@]HOST`: drive a remote `nomisync-eval` subsystem
/// over the system `ssh`. `-e EXPR` runs one form, a FILE argument runs
/// each top-level form, and neither yields an interactive REPL — the
/// same dispatch shape as `--rpc-user`, but the session lives on the
/// server and auth is the SSH identity, so no local DATABASE_URL.
fn run_ssh_mode(target: &str, port: Option<u16>, cli: &Cli) -> anyhow::Result<()> {
let mut ssh = ssh_eval::SshEval::connect(target, port)?;
println!("{}", ssh.eval(expr)?);
println!("{}", ssh.eval(&form)?);
ssh_repl(&mut ssh)
/// Line REPL over an [`ssh_eval::SshEval`] connection. Buffers input
/// until the form is balanced (same rule as the local REPLs), sends it,
/// and prints the response envelope. A transport error (the ssh process
/// died / the connection dropped) ends the loop.
fn ssh_repl(ssh: &mut ssh_eval::SshEval) -> anyhow::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
let mut buffer = String::new();
loop {
if buffer.is_empty() {
print!("\nssh-nms> ");
print!(" ");
stdout.flush()?;
let mut line = String::new();
if stdin.lock().read_line(&mut line)? == 0 {
break;
if buffer.is_empty() && line.trim().is_empty() {
continue;
buffer.push_str(&line);
if Reader::is_incomplete(&buffer) {
let input = buffer.trim();
if !input.is_empty() {
match ssh.eval(input) {
Ok(response) => println!("{response}"),
Err(err) => {
eprintln!("ssh-eval: {err}");
buffer.clear();
/// `nms --test PATH`: collect every `*.nms` file under PATH (file
/// argument is treated as a single file; directories are walked one
/// level), load each into a single Interpreter so test registrations
/// accumulate, then call `(run-tests)`. Exits 1 if any test failed.
fn run_test_mode(path: &str, debug: bool, coverage: bool) -> anyhow::Result<()> {
let files = collect_nms_files(path)?;
if files.is_empty() {
return Err(anyhow::anyhow!("no .nms files found at {path}"));
let mut interp = interpreter::Interpreter::new(debug)?;
let use_color = io::stderr().is_terminal();
for file in &files {
let content = fs::read_to_string(file)?;
if let Err(e) = interp.eval(&content) {
eprintln!("loading {}:", file.display());
eprint!("{}", e.render(use_color));
std::process::exit(1);
let results = interp
.eval("(run-tests)")
.map_err(|e| anyhow::anyhow!("run-tests failed: {}", e.render(use_color)))?;
let summary = match results.last() {
Some(Value::String(s)) => s.clone(),
Some(other) => format!("{other:?}"),
None => return Err(anyhow::anyhow!("run-tests produced no result")),
println!("{summary}");
if coverage {
let cov = interp
.eval("(coverage-dump)")
.map_err(|e| anyhow::anyhow!("coverage-dump failed: {}", e.render(use_color)))?;
let dump = match cov.last() {
Some(Value::String(s)) if s.is_empty() => "(no native fns referenced)".to_string(),
None => "(no coverage output)".to_string(),
println!("--- coverage ---");
println!("{dump}");
if test_summary_failed(&summary) {
fn collect_nms_files(path: &str) -> anyhow::Result<Vec<std::path::PathBuf>> {
let p = Path::new(path);
if !p.exists() {
return Err(anyhow::anyhow!("path does not exist: {path}"));
if p.is_file() {
return Ok(vec![p.to_path_buf()]);
let mut files: Vec<_> = fs::read_dir(p)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("nms"))
.collect();
files.sort();
Ok(files)
fn test_summary_failed(summary: &str) -> bool {
summary
.split_once("passed, ")
.and_then(|(_, rest)| rest.split_once(" failed"))
.and_then(|(num, _)| num.trim().parse::<usize>().ok())
.is_some_and(|n| n > 0)
fn rpc_repl(rpc: &mut rpc_eval::RpcEval) -> anyhow::Result<()> {
print!("\nrpc> ");
println!("{}", rpc.eval(input));
/// Splits whitespace-separated top-level s-expressions in source order.
/// Used by file/stdin rpc-mode runs so each form gets its own envelope.
/// Splits a source file into the top-level forms to dispatch (one rpc
/// envelope per form). A chunk is emitted only once it parses as a COMPLETE
/// program carrying at least one expression — comment-only / blank chunks
/// (which `Reader::parse` yields as zero-expression programs) are dropped
/// rather than wrapped into a bogus `(:id N :form ; comment)` envelope whose
/// `;` would comment out the closing paren.
fn split_forms(content: &str) -> Vec<String> {
let mut forms = Vec::new();
let mut current = String::new();
for line in content.lines() {
current.push_str(line);
current.push('\n');
let trimmed = current.trim();
if trimmed.is_empty() || Reader::is_incomplete(trimmed) {
// A complete chunk with no expression is pure comments/whitespace —
// discard it; otherwise emit and reset.
if Reader::parse(trimmed)
.map(|p| !p.exprs.is_empty())
.unwrap_or(true)
{
forms.push(trimmed.to_string());
current.clear();
let tail = current.trim();
if !tail.is_empty()
&& Reader::parse(tail)
forms.push(tail.to_string());
forms
fn plain_repl(debug: bool, graphics: GraphicsPolicy) -> anyhow::Result<()> {
let use_color = stdout.is_terminal();
print!("\n> ");
if input.is_empty() {
match interp.eval(input) {
Ok(values) => {
for value in values {
println!("{}", format_value(&value, use_color, graphics));
Err(e) => eprint!("{}", e.render(use_color)),
fn compile_file(path: &str, debug: bool) -> anyhow::Result<()> {
let content = fs::read_to_string(path)?;
match interp.compile_to_wasm(&content) {
Ok(wasm) => {
let out_path = Path::new(path).with_extension("wasm");
fs::write(&out_path, &wasm)?;
eprintln!("wrote {}", out_path.display());
Err(e) => {
fn load_wasm(path: &str, debug: bool, graphics: GraphicsPolicy) -> anyhow::Result<()> {
let wasm = fs::read(path)?;
let interp = interpreter::Interpreter::new(debug)?;
match interp.run_wasm(&wasm) {
Ok(value) => {
let use_color = io::stdout().is_terminal();
fn eval_string(
input: &str,
graphics: GraphicsPolicy,
profiler: ProfilerStrategy,
) -> anyhow::Result<()> {
let mut interp = interpreter::Interpreter::with_profiler(debug, profiler)?;
print_results(&mut interp, input, graphics)
fn eval_file(
path: &str,
print_results(&mut interp, &content, graphics)
fn eval_stdin(
let mut content = String::new();
io::stdin().read_to_string(&mut content)?;
fn print_results(
interp: &mut interpreter::Interpreter,
fn format_value(value: &Value, use_color: bool, graphics: GraphicsPolicy) -> String {
match value {
Value::Nil | Value::Bool(false) => {
if use_color {
"\x1b[90mNIL\x1b[0m".to_string()
"NIL".to_string()
Value::Bool(true) => {
"\x1b[32m#T\x1b[0m".to_string()
"#T".to_string()
Value::Number(n) => {
let text = if *n.denom() == 1 {
n.numer().to_string()
format!("{}/{}", n.numer(), n.denom())
format!("\x1b[36m{text}\x1b[0m")
text
Value::String(s) => {
format!("\x1b[33m{s}\x1b[0m")
s.clone()
Value::Symbol(s) => {
format!("\x1b[35m{s}\x1b[0m")
Value::Bytes(b) => {
if graphics.inline
&& let Some(apc) =
nms::graphics::try_render_inline(b, |name| std::env::var(name).ok())
return apc;
let parts: Vec<String> = b.iter().map(u8::to_string).collect();
let text = format!("#u8({})", parts.join(" "));
format!("\x1b[33m{text}\x1b[0m")
Value::Pair(_) => "<pair>".to_string(),
Value::Vector(_) => "<vector>".to_string(),
Value::Closure(_) => "<closure>".to_string(),
Value::Struct { name, fields } => {
format!("\x1b[94m#{name}({} fields)\x1b[0m", fields.len())
format!("#{name}({} fields)", fields.len())
Value::Commodity {
amount,
commodity_id,
} => {
let amt = if *amount.denom() == 1 {
amount.numer().to_string()
format!("{}/{}", amount.numer(), amount.denom())
let text = format!("(:commodity {amt} :id \"{commodity_id}\")");
#[cfg(test)]
mod tests {
use super::split_forms;
#[test]
fn split_forms_drops_comment_only_chunks() {
// Regression: leading comment lines used to each become a bogus form,
// wrapped as `(:id N :form ; comment)` whose `;` ate the closing paren.
let src = "\
; a leading comment
; another comment
(defun f (x) x)
; trailing-style comment between forms
(f 1)
";
assert_eq!(split_forms(src), vec!["(defun f (x) x)", "(f 1)"]);
fn split_forms_handles_multiline_forms() {
let src = "(defun g (x)\n (+ x\n 1))\n(g 2)\n";
let forms = split_forms(src);
assert_eq!(forms.len(), 2);
assert!(forms[0].contains("defun g"));
assert_eq!(forms[1], "(g 2)");
fn split_forms_all_comments_yields_nothing() {
assert!(split_forms("; just a comment\n;; and another\n").is_empty());
fn split_forms_keeps_form_with_trailing_comment() {
// A real form with a same-line trailing comment parses to a non-empty
// program, so it is emitted (not dropped as a comment-only chunk).
assert_eq!(split_forms("(f 1) ; note\n"), vec!["(f 1) ; note"]);
fn split_forms_tolerates_blank_and_comment_lines_inside_a_form() {
// Blank + comment lines inside an incomplete multi-line form are reader
// whitespace; the form stays one chunk and is emitted intact.
let src = "(defun h (x)\n\n ; midway comment\n (+ x 1))\n";
assert_eq!(forms.len(), 1);
assert!(forms[0].contains("defun h") && forms[0].contains("(+ x 1)"));