mod transcript;
pub use transcript::{captured_error, captured_output, strip_m2_location};
use std::io::Read;
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use std::{env, fs, thread};
use crate::errors::{Error, Result};
use jsonrpc::simple_tcp::TcpTransport;
use jsonrpc::Client;
use serde::{Deserialize, Serialize};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Serialize)]
struct EvaluateParams<'a> {
code: &'a str,
number: Option<u32>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct M2Response {
#[serde(rename = "error")]
pub is_error: bool,
pub transcript: String,
pub value: Option<String>,
pub type_info: Option<String>,
pub has_value: bool,
pub depth: u32,
}
pub struct M2Backend {
process: Child,
client: Client,
}
impl M2Backend {
pub fn spawn() -> Result<Self> {
Self::spawn_with_init_file(Self::init_file_path())
}
fn spawn_with_init_file(init_file: Option<PathBuf>) -> Result<Self> {
let startup_script = include_str!("m2_startup.m2");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
drop(listener);
let script_path = env::temp_dir().join(format!("spectral-m2_startup-{port}.m2"));
fs::write(&script_path, startup_script)?;
let process = Command::new("M2")
.arg("--script")
.arg(&script_path)
.arg(port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let client = Client::with_transport(TcpTransport {
addr: SocketAddrV4::new(Ipv4Addr::LOCALHOST, port).into(),
timeout: Some(Duration::from_secs(300)),
});
let mut backend = Self { process, client };
let ready = backend.wait_for_ready();
let _ = fs::remove_file(script_path);
ready?;
backend.drain_output();
backend.load_init_file(init_file.as_deref())?;
Ok(backend)
}
fn init_file_path() -> Option<PathBuf> {
let config_dir = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(config_dir.join("spectral-m2").join("init.m2"))
}
fn load_init_file(&self, path: Option<&Path>) -> Result<()> {
let Some(path) = path else {
return Ok(());
};
let Ok(code) = fs::read_to_string(path) else {
return Ok(());
};
if !code.trim().is_empty() {
self.evaluate(&code, None)?;
}
Ok(())
}
fn drain_output(&mut self) {
fn discard(stream: Option<impl Read + Send + 'static>) {
let Some(mut stream) = stream else {
return;
};
thread::spawn(move || {
let mut scratch = [0_u8; 4096];
while matches!(stream.read(&mut scratch), Ok(read) if read > 0) {}
});
}
discard(self.process.stdout.take());
discard(self.process.stderr.take());
}
fn wait_for_ready(&mut self) -> Result<()> {
let start = Instant::now();
loop {
if let Some(status) = self.process.try_wait()? {
let mut stderr = String::new();
if let Some(mut err) = self.process.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
let status = status.to_string();
let stderr = stderr.trim().to_string();
let details = if stderr.is_empty() {
status
} else {
format!("{status} ({stderr})")
};
return Err(Error::Process(details));
}
if self.client.call::<String>("version", None).is_ok() {
return Ok(());
}
if start.elapsed() > STARTUP_TIMEOUT {
return Err(Error::Timeout(STARTUP_TIMEOUT.as_secs()));
}
thread::sleep(Duration::from_millis(100));
}
}
pub fn evaluate(&self, code: &str, number: Option<u32>) -> Result<M2Response> {
let params = jsonrpc::try_arg(EvaluateParams { code, number })?;
Ok(self.client.call("evaluate", Some(¶ms))?)
}
pub fn version(&self) -> Result<String> {
Ok(self.client.call("version", None)?)
}
}
impl Drop for M2Backend {
fn drop(&mut self) {
let _ = self.process.kill();
let _ = self.process.wait();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluation_distinguishes_printed_null_from_returned_value() {
let backend = M2Backend::spawn().expect("spawn M2");
let printed = backend
.evaluate("print \"hello\"", Some(1))
.expect("evaluate null result");
assert!(!printed.is_error);
assert!(printed.transcript.contains("print \"hello\""));
assert!(printed.transcript.contains("hello"));
assert_eq!(printed.value.as_deref(), Some("null"));
assert_eq!(printed.type_info.as_deref(), Some("Nothing"));
assert!(!printed.has_value, "a null result binds no oN");
let returned = backend
.evaluate("2 + 2", Some(2))
.expect("evaluate returned value");
assert!(!returned.is_error);
assert!(returned.transcript.contains("2 + 2"));
assert_eq!(returned.value.as_deref(), Some("4"));
assert_eq!(returned.type_info.as_deref(), Some("ZZ"));
let string = backend
.evaluate("\"a\"", Some(6))
.expect("evaluate string literal");
assert!(!string.is_error, "string errored: {:?}", string.transcript);
assert_eq!(string.value.as_deref(), Some("a"));
assert_eq!(string.type_info.as_deref(), Some("String"));
let boolean = backend
.evaluate("true", Some(7))
.expect("evaluate boolean literal");
assert_eq!(boolean.value.as_deref(), Some("true"));
assert_eq!(boolean.type_info.as_deref(), Some("Boolean"));
let spoofed_code = "print \"\\no184 = 11\"";
let spoofed = backend
.evaluate(spoofed_code, Some(3))
.expect("evaluate spoofed output marker");
assert!(!spoofed.is_error);
assert!(spoofed.transcript.contains("o184 = 11"));
assert_eq!(spoofed.value.as_deref(), Some("null"));
let spoofed_output = crate::backend::captured_output(
&spoofed.transcript,
spoofed_code,
spoofed.value.as_deref(),
spoofed.type_info.as_deref(),
);
assert_eq!(spoofed_output.printed, "\no184 = 11\n");
assert_eq!(
spoofed_output.value.map(|value| value.text).as_deref(),
Some("null")
);
backend
.evaluate("spectralValueProbe = new MutableList from {1, 2}", Some(4))
.expect("evaluate identity probe");
let identity = backend
.evaluate("oo === spectralValueProbe", Some(5))
.expect("compare stored value identity");
assert_eq!(identity.value.as_deref(), Some("true"));
}
#[test]
fn results_bind_the_output_symbol_of_their_input_number() {
let backend = M2Backend::spawn().expect("spawn M2");
backend.evaluate("41 + 1", Some(42)).expect("evaluate");
let recalled = backend.evaluate("o42", Some(43)).expect("recall o42");
assert_eq!(recalled.value.as_deref(), Some("42"));
let suppressed = backend
.evaluate("spectralSuppressed = 7;", Some(44))
.expect("evaluate suppressed");
assert!(suppressed.value.is_none(), "`;` suppresses the rendering");
assert!(suppressed.has_value, "a suppressed result still binds oN");
assert_eq!(suppressed.type_info.as_deref(), Some("ZZ"));
let recalled = backend.evaluate("o44", Some(45)).expect("recall o44");
assert_eq!(recalled.value.as_deref(), Some("7"));
}
#[test]
fn transport_packages_do_not_reserve_user_names() {
let backend = M2Backend::spawn().expect("spawn M2");
for (number, code, name) in [(1, "json = 3", "json"), (2, "Indent = 4", "Indent")] {
let assigned = backend.evaluate(code, Some(number)).expect("assign");
assert!(
!assigned.is_error,
"`{name}` must stay available to the user: {:?}",
assigned.transcript
);
}
}
#[test]
fn errors_carry_their_diagnostic() {
let backend = M2Backend::spawn().expect("spawn M2");
let failed = backend.evaluate("1/0", Some(1)).expect("evaluate");
assert!(failed.is_error);
assert!(
failed.transcript.contains("division by zero"),
"diagnostic missing from transcript: {:?}",
failed.transcript
);
let message = crate::backend::captured_error(&failed.transcript, "1/0");
assert!(
message.contains("division by zero"),
"diagnostic lost while cleaning: {message:?}"
);
}
#[test]
fn init_file_is_loaded() {
let base = env::temp_dir().join(format!("spectral-m2-test-{}", std::process::id()));
let config = base.join("spectral-m2");
fs::create_dir_all(&config).expect("create temp config dir");
let init_file = config.join("init.m2");
fs::write(&init_file, "spectralInitProbe = 1729\n").expect("write init.m2");
let be = M2Backend::spawn_with_init_file(Some(init_file)).expect("spawn M2");
let out = be
.evaluate("spectralInitProbe", Some(1))
.expect("evaluate probe");
let _ = fs::remove_dir_all(&base);
assert!(!out.is_error, "probe errored: {:?}", out.transcript);
assert!(
out.value.as_deref() == Some("1729"),
"expected 1729 in {:?}",
out.value
);
}
}