use anyhow::{Context, Result};
use clap::Parser;
use quarb_session::{
DaemonExecutor, Doc, FileStore, LocalExecutor, MemStore, Options, Session, Store,
};
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
paths: Vec<PathBuf>,
#[arg(long)]
hidden: bool,
#[arg(long = "no-ignore")]
no_ignore: bool,
#[arg(long)]
descend: bool,
#[arg(long)]
allow_shell: bool,
#[arg(long, value_name = "ISO")]
now: Option<String>,
#[arg(long, value_name = "FILE")]
defs: Option<PathBuf>,
#[arg(long)]
daemon: bool,
#[arg(long)]
cache: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.paths.is_empty() {
anyhow::bail!("quai needs at least one source (a directory, a document, or git:PATH)");
}
let mut session = if cli.daemon {
let executor = Box::new(DaemonExecutor::new(
cli.paths.clone(),
cli.now.clone(),
cli.allow_shell,
cli.hidden,
cli.no_ignore,
cli.descend,
cli.cache,
)?);
let store: Box<dyn Store> = match FileStore::new(&cli.paths) {
Ok(fs) => Box::new(fs),
Err(_) => Box::new(MemStore),
};
Session::new(executor, store)
} else {
let now = bind_now(cli.now.as_deref())?;
let opts = Options {
hidden: cli.hidden,
respect_ignore: !cli.no_ignore,
descend: cli.descend,
};
let doc = match cli.paths.as_slice() {
[one] => Doc::open(one, &opts)?,
many => Doc::mount(many, &opts)?,
};
let executor = Box::new(LocalExecutor::with_respec(
doc,
now,
cli.allow_shell,
cli.paths.clone(),
opts,
));
Session::new(executor, Box::new(MemStore))
};
if let Some(p) = &cli.defs {
let text =
std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?;
session.seed_defs(&text)?;
}
let sources = cli
.paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
let mode = if cli.daemon { "daemon-backed" } else { "in-process" };
println!(
"quai — interactive Quarb over {sources} ({mode}). :help for commands, :quit (or Ctrl-D) to leave."
);
repl(&mut session)
}
fn bind_now(spec: Option<&str>) -> Result<(i64, u32)> {
match spec {
Some(text) => {
let (secs, nanos, _) = quarb::temporal::parse_iso(text)
.ok_or_else(|| anyhow::anyhow!("--now needs an ISO-8601 instant, got '{text}'"))?;
Ok((secs, nanos))
}
None => {
let since = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
Ok((since.as_secs() as i64, since.subsec_nanos()))
}
}
}
fn repl(session: &mut Session) -> Result<()> {
use std::io::BufRead;
let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
let stdin = std::io::stdin();
let mut lines = stdin.lock().lines();
loop {
print_prompt(session.line_no(), color)?;
let Some(line) = lines.next() else {
println!();
break;
};
let line = line?;
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with(':') && !line.starts_with("::") {
if command(session, line) {
break;
}
continue;
}
if line.starts_with("def ")
|| line == "def"
|| line.starts_with("macro ")
|| line == "macro"
{
if let Err(e) = session.add_def(line) {
eprintln!("error: {e:#}");
}
continue;
}
match prepare(line) {
Err(e) => eprintln!("error: {e}"),
Ok(Prepared::Frozen(n)) => match session.frozen(n) {
Some(cells) => {
let cells = cells.clone();
for c in &cells {
println!("{}", c.display());
}
session.record_frozen(cells);
}
None => eprintln!("error: &{n}# has no captured result (line {n} hasn't run)"),
},
Ok(Prepared::Live(q)) => run_and_commit(session, &q, true),
Ok(Prepared::Eval(q)) => run_and_commit(session, &q, false),
}
}
Ok(())
}
fn run_and_commit(session: &mut Session, q: &str, fresh: bool) {
let result = if fresh {
session.eval_fresh(q)
} else {
session.eval(q)
};
match result {
Ok(cells) => {
for c in &cells {
println!("{}", c.display());
}
let n = session.line_no();
if !session.commit(q, cells) {
eprintln!("note: &{n} is not referenceable (its shape can't be a macro body)");
}
}
Err(e) => eprintln!("error: {e:#}"),
}
}
enum Prepared {
Frozen(usize),
Live(String),
Eval(String),
}
fn prepare(line: &str) -> Result<Prepared> {
if let Some(n) = numeric_ref_with(line, '#') {
return Ok(Prepared::Frozen(n));
}
if let Some(n) = numeric_ref_with(line, '!') {
return Ok(Prepared::Live(format!("&{n}")));
}
if line.contains('#') {
anyhow::bail!(
"'#' is the frozen-history suffix, valid only as a standalone '&N#' in this build; \
continuation off a frozen closure ('&N# | …') rides the daemon"
);
}
Ok(Prepared::Eval(line.to_string()))
}
fn numeric_ref_with(line: &str, suffix: char) -> Option<usize> {
line.strip_suffix(suffix)?
.strip_prefix('&')?
.parse::<usize>()
.ok()
}
fn print_prompt(n: usize, color: bool) -> Result<()> {
if color {
print!("\x1b[36m&{n}\x1b[0m ");
} else {
print!("&{n} ");
}
std::io::stdout().flush()?;
Ok(())
}
fn command(session: &mut Session, line: &str) -> bool {
match line {
":q" | ":quit" => return true,
":help" | ":?" => {
println!(
" <query> run a query; its result is labelled &N and reusable\n \
&N re-run line N (a macro); continue with a pipe: &N | /key::\n \
&N# replay line N's frozen output (as it was when it ran)\n \
&N! re-run line N live — re-reads the source; diverges from &N# under drift\n \
def &x: …; add a named fragment to the session\n \
:history show the macro table (&1, &2, …)\n \
:reset clear the history and restart numbering\n \
:quit leave (also Ctrl-D)"
);
}
":history" => {
let h = session.history();
if h.trim().is_empty() {
println!("(no history yet)");
} else {
print!("{h}");
}
}
":reset" => session.reset(),
other => println!("unknown command '{other}' (:help lists them)"),
}
false
}