use std::io::{self, BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde_json::{json, Map, Value};
use tabnas_transduce::{Code, Fail, JsonEvent, Metrics};
use crate::alchemy::{self, ProgramArg};
use crate::doc::{Doc, Key, Kind, NodeId, NO_NODE};
use crate::export::{self, ExportError, Input, Job, Nearest, Plan, Renderer, What};
use crate::fmt;
use crate::grammar::GrammarError;
use crate::load::{self, Format, Limits, LoadError, Loaded};
use crate::search;
use crate::translate::{self, Part};
pub mod status {
pub const OK: i32 = 0;
pub const PARSE: i32 = 1;
pub const USAGE: i32 = 2;
pub const IO: i32 = 3;
pub const NOT_FOUND: i32 = 4;
pub const TOO_LARGE: i32 = 5;
pub const TIMEOUT: i32 = 6;
}
pub const DEFAULT_LIMIT: usize = 200;
pub const VALUE_CHARS: usize = 200;
pub(crate) const KEYS_LISTED: usize = 20;
pub const RENDER_TAKES_NO_AT: &str = "--render reads the input once, front to back, and \
cannot find a source position in it: start it with --path, or use --where --at to find \
the path";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Op {
Json,
Paths,
Find(String),
Where,
Check,
Render(Renderer),
Alchemy {
program: ProgramArg,
render: Option<Renderer>,
explain: bool,
},
}
impl Op {
pub fn flag(&self) -> &'static str {
match self {
Op::Json => "--json",
Op::Paths => "--paths",
Op::Find(_) => "--find",
Op::Where => "--where",
Op::Check => "--check",
Op::Render(_) => "--render",
Op::Alchemy { .. } => "--alchemy",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Start {
#[default]
Root,
Path(String),
At(u32, Option<u32>),
}
impl Start {
pub fn parse_at(text: &str) -> Result<Start, String> {
let bad = || format!("--at needs LINE or LINE:COL, counted from 1, not {text:?}");
let (line, col) = match text.split_once(':') {
Some((l, c)) => (l, Some(c)),
None => (text, None),
};
let line: u32 = line.trim().parse().map_err(|_| bad())?;
let col: Option<u32> = match col {
Some(c) => Some(c.trim().parse().map_err(|_| bad())?),
None => None,
};
if line == 0 || col == Some(0) {
return Err(bad());
}
Ok(Start::At(line, col))
}
}
#[derive(Clone, Debug)]
pub struct Request {
pub op: Op,
pub files: Vec<PathBuf>,
pub kind: Option<Format>,
pub start: Start,
pub depth: Option<u32>,
pub limit: usize,
pub compact: bool,
pub indent: usize,
pub max_size: Option<u64>,
pub timeout: Option<Duration>,
pub max_output: Option<u64>,
pub key: String,
pub started: Option<Instant>,
}
impl Request {
pub fn new(op: Op) -> Request {
Request {
op,
files: Vec::new(),
kind: None,
start: Start::Root,
depth: None,
limit: DEFAULT_LIMIT,
compact: false,
indent: 2,
max_size: Limits::DEFAULT.max_size,
timeout: Limits::DEFAULT.timeout,
max_output: Some(load::DEFAULT_MAX_OUTPUT),
key: translate::Options::default().key,
started: None,
}
}
pub fn translate_options(&self) -> translate::Options {
translate::Options {
key: self.key.clone(),
}
}
fn limits(&self) -> Limits {
Limits {
max_size: self.max_size,
timeout: self.timeout,
started: self.started,
}
}
fn limits_for(&self, source: &Source) -> Limits {
Limits {
started: match source {
Source::Stdin => self.started,
Source::File(_) => None,
},
..self.limits()
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Output {
pub stdout: String,
pub stderr: String,
pub status: i32,
}
pub type Stdin<'a> = Option<&'a mut (dyn Read + Send)>;
pub type Stdout = Box<dyn Write + Send>;
pub fn run(req: &Request, stdin: Stdin<'_>) -> Output {
let collected = Arc::new(Mutex::new(Vec::new()));
let mut out = run_to(req, stdin, Box::new(Collect(collected.clone())));
let bytes = std::mem::take(&mut *collected.lock().unwrap_or_else(|e| e.into_inner()));
if !bytes.is_empty() {
out.stdout = String::from_utf8_lossy(&bytes).into_owned();
}
out
}
struct Collect(Arc<Mutex<Vec<u8>>>);
impl Write for Collect {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0
.lock()
.unwrap_or_else(|e| e.into_inner())
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub fn run_to(req: &Request, mut stdin: Stdin<'_>, out: Stdout) -> Output {
let mut note = String::new();
let result = match &req.op {
Op::Check => check(req, &mut stdin),
Op::Render(Renderer::Part(id)) => translate(req, id, &mut stdin, out).map(|loss| {
note = loss;
(String::new(), status::OK)
}),
Op::Render(renderer) => export(req, *renderer, None, None, &mut stdin, out)
.map(|()| (String::new(), status::OK)),
Op::Alchemy {
program,
render,
explain,
} => run_program(req, program, *render, *explain, &mut stdin, out).map(|(stdout, loss)| {
note = loss;
(stdout, status::OK)
}),
_ => single(req, &mut stdin),
};
match result {
Ok((stdout, status)) => Output {
stdout,
stderr: note,
status,
},
Err(f) => Output {
stdout: String::new(),
stderr: render(&json!({ "error": f.error }), req.compact),
status: f.status,
},
}
}
fn one_source(req: &Request) -> Result<Source, Failure> {
let mut sources = sources(req);
if sources.len() > 1 {
return Err(Failure::usage(format!(
"{} reads one input, and was given {}: run aless once per file, or use --check to check several",
req.op.flag(),
sources.len()
)));
}
Ok(sources.remove(0))
}
fn single(req: &Request, stdin: &mut Stdin<'_>) -> Result<(String, i32), Failure> {
let pattern = match &req.op {
Op::Find(p) => Some(search::compile(p).map_err(Failure::usage)?),
_ => None,
};
let source = one_source(req)?;
let (name, loaded) = load(&source, req, stdin)?;
let doc = &loaded.doc;
let text = load::parser_text(&loaded.source);
let start = select(doc, text, &req.start, &name, loaded.format)?;
let head = |m: &mut Map<String, Value>| {
m.insert("file".into(), name.clone().into());
m.insert("format".into(), loaded.format.name().into());
m.insert("path".into(), fmt::path_jq(&doc.path(start)).into());
};
let out = match &req.op {
Op::Json => {
let mut s = if req.compact {
fmt::to_json_compact(doc, start)
} else {
fmt::to_json_pretty(doc, start, req.indent)
};
s.push('\n');
s
}
Op::Paths => {
let (entries, total) = walk(doc, start, req.depth, req.limit, |_| true);
let mut m = Map::new();
head(&mut m);
m.insert("entries".into(), Value::Array(entries));
listed(&mut m, total, req.limit);
render(&Value::Object(m), req.compact)
}
Op::Find(p) => {
let regex = &pattern.as_ref().expect("compiled above").regex;
let (matches, total) = walk(doc, start, req.depth, req.limit, |id| {
regex.is_match(&fmt::search_text(doc, id))
});
let mut m = Map::new();
head(&mut m);
m.insert("pattern".into(), p.clone().into());
m.insert("matches".into(), Value::Array(matches));
listed(&mut m, total, req.limit);
render(&Value::Object(m), req.compact)
}
Op::Where => {
let mut m = Map::new();
m.insert("file".into(), name.clone().into());
m.insert("format".into(), loaded.format.name().into());
m.extend(entry(doc, start));
render(&Value::Object(m), req.compact)
}
Op::Check => unreachable!("--check is run by check()"),
Op::Render(_) => unreachable!("--render is run by export()"),
Op::Alchemy { .. } => unreachable!("--alchemy is run by run_program()"),
};
Ok((out, status::OK))
}
fn export(
req: &Request,
renderer: Renderer,
loss: Option<&[String]>,
whole: Option<&str>,
stdin: &mut Stdin<'_>,
out: Stdout,
) -> Result<(), Failure> {
let path = match &req.start {
Start::Root => Vec::new(),
Start::Path(text) => parse_path(text).map_err(Failure::usage)?,
Start::At(..) => return Err(Failure::usage(RENDER_TAKES_NO_AT)),
};
let (source, name, origin, format) = streamed_source(req)?;
let Some(plan) = export::plan(format) else {
return Err(Failure::usage(format!(
"{name} is plain text, which has no records to export: name its format with -k, \
such as -k jsonl or -k csv"
)));
};
let job = Job {
name: name.clone(),
origin,
format,
what: What::Render(renderer),
path,
compact: req.compact,
indent: req.indent,
timeout: req.timeout,
started: req.limits_for(&source).started,
max_output: None,
};
let mut text = String::new();
let opened = open_input(req, &source, &name, format, plan, stdin)?;
let result = export::export(&job, opened.input(&mut text, req), out);
let failure = match result {
Ok(()) => return Ok(()),
Err(ExportError::ReaderGone) => return Ok(()),
Err(e) => e,
};
let f = match failure {
ExportError::Usage(m) => return Err(Failure::usage(m)),
ExportError::NotFound { message, nearest } => {
let path = match &req.start {
Start::Path(text) => text.trim().to_string(),
_ => ".".to_string(),
};
return Err(Failure::not_found_streamed(
&name, format, &path, message, &nearest,
));
}
ExportError::Load { error, partial } => {
let mut f = Failure::load(&name, format, &error).limited(None, req);
f.error.insert(
"output".into(),
if partial { "partial" } else { "none" }.into(),
);
f
}
ExportError::Transduce(fail) | ExportError::Program(fail) => {
Failure::transduce(&name, format, &fail)
}
ExportError::ReaderGone => unreachable!("handled above"),
};
Err(match loss {
Some(loss) => f.with_loss(loss, whole),
None => f,
})
}
fn translate(
req: &Request,
id: &str,
stdin: &mut Stdin<'_>,
out: Stdout,
) -> Result<String, Failure> {
let part = translate::part(id).expect("--render names a part the registry has");
let path = match &req.start {
Start::Root => Vec::new(),
Start::Path(text) => parse_path(text).map_err(Failure::usage)?,
Start::At(..) => return Err(Failure::usage(RENDER_TAKES_NO_AT)),
};
let (source, name, origin, format) = streamed_source(req)?;
let source_part = if path.is_empty() {
translate::source_part(format)
} else {
None
};
let translation =
translate::translation(source_part, part, &req.translate_options()).map_err(|fail| {
match translate::schema_only(&fail, part, &name) {
Some(message) => Failure::usage(message),
None => Failure::alchemy(part_file(part, &fail), &fail),
}
})?;
let loss = translation.loss().to_vec();
let whole = source_part.and_then(|p| p.whole.as_deref());
let composition = match translation {
translate::Translation::Composed(composition) => composition,
translate::Translation::Native {
renderer, adapters, ..
} => {
return export(req, renderer, Some(&loss), whole, stdin, out)
.map(|()| loss_note(part, &loss, whole, &adapters, &name, req.compact));
}
};
let Some(plan) = export::plan(format) else {
return Err(Failure::usage(format!(
"{name} is plain text, which has no values to write as {id}: name its format with \
-k, such as -k jsonl or -k csv"
)));
};
let job = Job {
name: name.clone(),
origin,
format,
what: What::Part,
path,
compact: req.compact,
indent: req.indent,
timeout: req.timeout,
started: req.limits_for(&source).started,
max_output: None,
};
let metrics = Metrics::new();
let records = translate::records(part);
let mut text = String::new();
let opened = open_input(req, &source, &name, format, plan, stdin)?;
let result = translate::run(
&job,
&composition,
records,
opened.input(&mut text, req),
out,
metrics,
);
let failure = match result {
Ok(()) | Err(ExportError::ReaderGone) => {
return Ok(loss_note(
part,
&loss,
whole,
&composition.adapters,
&name,
req.compact,
))
}
Err(e) => e,
};
Err(translation_failure(req, &name, format, part, failure)?.with_loss(&loss, whole))
}
fn part_file<'a>(part: &'a Part, fail: &'a tabnas_transduce::Fail) -> &'a str {
match (&fail.file, &part.render) {
(Some(file), _) => file,
(None, translate::Render::Alc(alc)) => &alc.file,
(None, _) => &part.id,
}
}
fn translation_failure(
req: &Request,
name: &str,
format: Format,
part: &Part,
failure: ExportError,
) -> Result<Failure, Failure> {
Ok(match failure {
ExportError::Usage(m) => return Err(Failure::usage(m)),
ExportError::NotFound { message, nearest } => {
let path = match &req.start {
Start::Path(text) => text.trim().to_string(),
_ => ".".to_string(),
};
return Err(Failure::not_found_streamed(
name, format, &path, message, &nearest,
));
}
ExportError::Load { error, partial } => {
let mut f = Failure::load(name, format, &error).limited(None, req);
f.error.insert(
"output".into(),
if partial { "partial" } else { "none" }.into(),
);
f
}
ExportError::Program(fail) if alchemy::is_placed(&fail) => {
let mut f = Failure::alchemy(part_file(part, &fail), &fail);
f.error.insert("input".into(), name.into());
f
}
ExportError::Program(fail) | ExportError::Transduce(fail) => {
Failure::transduce(name, format, &fail)
}
ExportError::ReaderGone => unreachable!("handled above"),
})
}
fn loss_note(
part: &Part,
loss: &[String],
whole: Option<&str>,
adapters: &[translate::Adapter],
file: &str,
compact: bool,
) -> String {
if loss.is_empty() {
return String::new();
}
let mut warning = json!({
"kind": "loss",
"message": format!(
"the document was written as {}, which does not keep everything a document \
can hold",
part.id
),
"file": file,
"render": part.id,
"loss": loss,
});
if let Some(adapter) = translate::shape_adapter(adapters) {
warning["adapter"] = json!(adapter.name());
}
if !adapters.is_empty() {
warning["adapters"] = json!(adapters.iter().map(|a| a.name()).collect::<Vec<_>>());
}
if let Some(whole) = whole {
warning["whole"] = json!(whole);
}
render(&json!({ "warning": warning }), compact)
}
fn run_program(
req: &Request,
program: &ProgramArg,
render: Option<Renderer>,
explain: bool,
stdin: &mut Stdin<'_>,
out: Stdout,
) -> Result<(String, String), Failure> {
let file = program.name();
let text = program.read(req.max_size).map_err(|e| {
let size = program.path().and_then(file_size);
Failure::program_read(&file, &e).limited(size, req)
})?;
let compiled = alchemy::compile(&text, &file).map_err(|fail| Failure::alchemy(&file, &fail))?;
alchemy::check_render(&compiled, render).map_err(Failure::usage)?;
if explain {
if !req.files.is_empty() {
return Err(Failure::usage(
"--explain reports the program's plan and reads no input: give no FILE",
));
}
return Ok((
self::render(&compiled.explain_json(), req.compact),
String::new(),
));
}
let composition = match render {
Some(Renderer::Part(id)) => {
let part = translate::part(id).expect("--render names a part the registry has");
let composition = translate::compose_program(
tabnas_alchemy::Source::new(&file, &text),
compiled.output(),
part,
&req.translate_options(),
)
.map_err(|fail| {
let in_program = fail.file.as_deref() == Some(file.as_str());
let mut f = Failure::alchemy(
if in_program {
&file
} else {
part_file(part, &fail)
},
&fail,
);
if !in_program {
f.error.insert("program".into(), file.clone().into());
}
f
})?;
Some((part, composition))
}
_ => None,
};
let (source, name, origin, format) = streamed_source(req)?;
let Some(plan) = export::plan(format) else {
return Err(Failure::usage(format!(
"{name} is plain text, which has no values for a program to read: name its format \
with -k, such as -k jsonl or -k csv"
)));
};
let job = Job {
name: name.clone(),
origin,
format,
what: What::Program {
rows: compiled.row_selector().cloned(),
},
path: Vec::new(),
compact: req.compact,
indent: req.indent,
timeout: req.timeout,
started: req.limits_for(&source).started,
max_output: req.max_output,
};
let run = |input: Input<'_>| match &composition {
Some((part, composition)) => translate::run(
&job,
composition,
translate::records(part),
input,
out,
Metrics::new(),
),
None => alchemy::run(&job, &compiled, render, input, out),
};
let mut text = String::new();
let opened = open_input(req, &source, &name, format, plan, stdin)?;
let result = run(opened.input(&mut text, req));
let loss = match &composition {
Some((_, composition)) => composition.loss.clone(),
None => Vec::new(),
};
let failure = match result {
Ok(()) | Err(ExportError::ReaderGone) => {
let note = match &composition {
Some((part, composition)) => {
loss_note(part, &loss, None, &composition.adapters, &name, req.compact)
}
None => String::new(),
};
return Ok((String::new(), note));
}
Err(e) => e,
};
let failed = match failure {
ExportError::Usage(m) | ExportError::NotFound { message: m, .. } => {
return Err(Failure::usage(m))
}
ExportError::Load { error, partial } => {
let mut f = Failure::load(&name, format, &error).limited(None, req);
f.error.insert(
"output".into(),
if partial { "partial" } else { "none" }.into(),
);
f
}
ExportError::Program(fail) if alchemy::is_placed(&fail) => {
let mut f = Failure::alchemy(&file, &fail);
f.error.insert("input".into(), name.into());
f
}
ExportError::Program(fail) | ExportError::Transduce(fail) => {
Failure::transduce(&name, format, &fail)
}
ExportError::ReaderGone => unreachable!("handled above"),
};
Err(match composition {
Some(_) => failed.with_loss(&loss, None),
None => failed,
})
}
fn streamed_source(req: &Request) -> Result<(Source, String, String, Format), Failure> {
let source = one_source(req)?;
let (name, origin, format) = match &source {
Source::Stdin => (
"-".to_string(),
"(stdin)".to_string(),
req.kind.unwrap_or(Format::Json),
),
Source::File(p) => {
let name = p.display().to_string();
if p.is_dir() {
return Err(Failure::usage(format!(
"{name} is a directory: aless reads files (list a directory with ls or find)"
)));
}
(
name,
load::origin_of(p),
req.kind.unwrap_or_else(|| Format::detect(p)),
)
}
};
Ok((source, name, origin, format))
}
enum Opened<'a> {
Text(String),
Lines(Box<dyn BufRead + Send + 'a>, Option<PathBuf>),
}
impl<'a> Opened<'a> {
fn input<'t>(self, text: &'t mut String, req: &Request) -> Input<'t>
where
'a: 't,
{
match self {
Opened::Text(t) => {
*text = t;
Input::Text(text)
}
Opened::Lines(lines, Some(path)) => Input::File {
lines,
path,
max_size: req.max_size,
},
Opened::Lines(lines, None) => Input::Lines(lines),
}
}
}
fn open_input<'a>(
req: &Request,
source: &Source,
name: &str,
format: Format,
plan: Plan,
stdin: &'a mut Stdin<'_>,
) -> Result<Opened<'a>, Failure> {
let implicit = req.files.is_empty();
let too_large = |e: &LoadError, size| Failure::load(name, format, e).limited(size, req);
match (source, plan) {
(Source::Stdin, plan) => {
let Some(read) = stdin.as_mut() else {
return Err(Failure::usage(if implicit {
"no input: name a FILE, or pipe a document into aless"
} else {
"standard input is a terminal: pipe a document into aless, or name a FILE"
}));
};
if plan == Plan::Lines {
let mut reader = io::BufReader::new(read);
let empty = match reader.fill_buf() {
Ok(bytes) => bytes.is_empty(),
Err(e) => match load::read_timed_out(&e) {
Some(limit) => {
let e = LoadError::input_timed_out(limit).with_origin("(stdin)");
return Err(too_large(&e, None));
}
None => false,
},
};
if implicit && empty {
return Err(Failure::usage(
"no input: standard input is empty; name a FILE, or pipe a document into aless",
));
}
Ok(Opened::Lines(Box::new(reader), None))
} else {
let bytes = load::read_within(read, req.max_size)
.map_err(|e| too_large(&e.with_origin("(stdin)"), None))?;
if implicit && bytes.is_empty() {
return Err(Failure::usage(
"no input: standard input is empty; name a FILE, or pipe a document into aless",
));
}
Ok(Opened::Text(String::from_utf8_lossy(&bytes).into_owned()))
}
}
(Source::File(p), Plan::Lines) => {
let file = std::fs::File::open(p).map_err(|e| {
let e = LoadError::new(e.to_string()).with_origin(&load::origin_of(p));
Failure::load(name, format, &e)
})?;
Ok(Opened::Lines(
Box::new(io::BufReader::new(file)),
Some(p.clone()),
))
}
(Source::File(p), _) => {
let text =
load::read_path_within(p, req.max_size).map_err(|e| too_large(&e, file_size(p)))?;
Ok(Opened::Text(text))
}
}
}
fn check(req: &Request, stdin: &mut Stdin<'_>) -> Result<(String, i32), Failure> {
let mut files = Vec::new();
let mut all_ok = true;
for source in sources(req) {
let (name, format) = match &source {
Source::Stdin => ("-".to_string(), req.kind.unwrap_or(Format::Json)),
Source::File(p) => (
p.display().to_string(),
req.kind.unwrap_or_else(|| Format::detect(p)),
),
};
let verdict = match load(&source, req, stdin) {
Ok(_) => Value::Null,
Err(f) if f.status == status::USAGE && req.files.is_empty() => return Err(f),
Err(f) => Value::Object(f.error),
};
all_ok &= verdict.is_null();
files.push(json!({
"file": name,
"format": format.name(),
"ok": verdict.is_null(),
"error": verdict,
}));
}
let out = render(&json!({ "ok": all_ok, "files": files }), req.compact);
Ok((out, if all_ok { status::OK } else { status::PARSE }))
}
fn listed(m: &mut Map<String, Value>, total: usize, limit: usize) {
m.insert("total".into(), total.into());
m.insert("limit".into(), limit.into());
m.insert("truncated".into(), (limit != 0 && total > limit).into());
}
enum Source {
File(PathBuf),
Stdin,
}
fn sources(req: &Request) -> Vec<Source> {
if req.files.is_empty() {
return vec![Source::Stdin];
}
req.files
.iter()
.map(|f| {
if f.as_os_str() == "-" {
Source::Stdin
} else {
Source::File(f.clone())
}
})
.collect()
}
fn load(
source: &Source,
req: &Request,
stdin: &mut Stdin<'_>,
) -> Result<(String, Loaded), Failure> {
let implicit = req.files.is_empty();
match source {
Source::Stdin => {
let format = req.kind.unwrap_or(Format::Json);
let Some(read) = stdin.as_mut() else {
return Err(Failure::usage(if implicit {
"no input: name a FILE, or pipe a document into aless"
} else {
"standard input is a terminal: pipe a document into aless, or name a FILE"
}));
};
let bytes = load::read_within(read, req.max_size).map_err(|e| {
Failure::load("-", format, &e.with_origin("(stdin)")).limited(None, req)
})?;
if implicit && bytes.is_empty() {
return Err(Failure::usage(
"no input: standard input is empty; name a FILE, or pipe a document into aless",
));
}
let text = match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
};
let loaded = load::load_str_within(text, format, req.limits()).map_err(|e| {
Failure::load("-", format, &e.with_origin("(stdin)")).limited(None, req)
})?;
Ok(("-".to_string(), loaded))
}
Source::File(path) => {
let name = path.display().to_string();
if path.is_dir() {
return Err(Failure::usage(format!(
"{name} is a directory: aless reads files (list a directory with ls or find)"
)));
}
let format = req.kind.unwrap_or_else(|| Format::detect(path));
let loaded = load::load_path_within(path, req.kind, req.limits_for(source))
.map_err(|e| Failure::load(&name, format, &e).limited(file_size(path), req))?;
Ok((name, loaded))
}
}
}
fn file_size(path: &Path) -> Option<u64> {
std::fs::metadata(path)
.ok()
.filter(|m| m.is_file())
.map(|m| m.len())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Seg {
Name(String),
Index(i64),
}
pub fn parse_path(text: &str) -> Result<Vec<Seg>, String> {
let t = text.trim();
if let Some(pointer) = t.strip_prefix('/') {
return Ok(pointer
.split('/')
.map(|tok| Seg::Name(tok.replace("~1", "/").replace("~0", "~")))
.collect());
}
let t = t.strip_prefix('$').unwrap_or(t);
let chars: Vec<char> = t.chars().collect();
let err = |at: usize, what: &str| format!("bad path {text:?} at character {}: {what}", at + 1);
let mut segs = Vec::new();
let mut i = 0;
if i < chars.len() && chars[i] != '.' && chars[i] != '[' {
let (name, next) = bare(&chars, i);
segs.push(Seg::Name(name));
i = next;
}
while i < chars.len() {
match chars[i] {
'.' => {
i += 1;
match chars.get(i) {
None if segs.is_empty() => {}
None => return Err(err(i - 1, "a key is missing after the last '.'")),
Some('.') => {
return Err(err(
i - 1,
"'..' (recursive descent) is not supported: use --find, or pipe --json into jq",
))
}
Some('[') => {}
Some('"') => {
let (name, next) = quoted(&chars, i).map_err(|w| err(i, &w))?;
segs.push(Seg::Name(name));
i = next;
}
Some(_) => {
let (name, next) = bare(&chars, i);
segs.push(Seg::Name(name));
i = next;
}
}
}
'[' => {
let open = i;
i = skip_space(&chars, i + 1);
match chars.get(i) {
Some('"') | Some('\'') => {
let (name, next) = quoted(&chars, i).map_err(|w| err(i, &w))?;
segs.push(Seg::Name(name));
i = next;
}
Some(c) if c.is_ascii_digit() || *c == '-' => {
let from = i;
i += 1;
while chars.get(i).is_some_and(|c| c.is_ascii_digit()) {
i += 1;
}
let digits: String = chars[from..i].iter().collect();
let n: i64 = digits
.parse()
.map_err(|_| err(from, "expected an index such as [0] or [-1]"))?;
segs.push(Seg::Index(n));
}
Some(']') | Some('*') => {
return Err(err(
open,
"wildcards are not supported: name one item, or pipe --json into jq",
))
}
_ => {
return Err(err(
i,
"expected an index such as [0], or a quoted key such as [\"a b\"]",
))
}
}
i = skip_space(&chars, i);
match chars.get(i) {
Some(']') => i += 1,
Some(':') => {
return Err(err(
i,
"slices are not supported: name one item, or pipe --json into jq",
))
}
_ => return Err(err(i, "expected ']'")),
}
}
_ => return Err(err(i, "expected '.' or '['")),
}
}
Ok(segs)
}
fn bare(chars: &[char], from: usize) -> (String, usize) {
let mut i = from;
while i < chars.len() && chars[i] != '.' && chars[i] != '[' {
i += 1;
}
(chars[from..i].iter().collect(), i)
}
fn skip_space(chars: &[char], mut i: usize) -> usize {
while chars.get(i).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
i
}
fn quoted(chars: &[char], from: usize) -> Result<(String, usize), String> {
let q = chars[from];
let mut i = from + 1;
while i < chars.len() && chars[i] != q {
i += if chars[i] == '\\' { 2 } else { 1 };
}
if i >= chars.len() {
return Err(format!("the key quoted with {q} is not closed"));
}
let body: String = chars[from + 1..i].iter().collect();
let key = if q == '"' {
serde_json::from_str::<String>(&format!("\"{body}\""))
.map_err(|e| format!("bad quoted key: {e}"))?
} else {
let mut out = String::new();
let mut it = body.chars();
while let Some(c) = it.next() {
match (c, c == '\\') {
(_, true) => match it.next() {
Some(e @ ('\'' | '\\')) => out.push(e),
Some(e) => {
out.push('\\');
out.push(e);
}
None => out.push('\\'),
},
(c, false) => out.push(c),
}
}
out
};
Ok((key, i + 1))
}
fn step(doc: &Doc, id: NodeId, seg: &Seg) -> Option<NodeId> {
let node = doc.node(id);
match (&node.kind, seg) {
(Kind::Array, Seg::Index(i)) => {
let n = i64::from(node.children);
let i = if *i < 0 { n + i } else { *i };
if !(0..n).contains(&i) {
return None;
}
doc.child_by_key(id, &Key::Index(i as u32))
}
(Kind::Array, Seg::Name(s)) => {
let canonical = s == "0" || (!s.starts_with('0') && !s.is_empty());
let i: u32 = s
.parse()
.ok()
.filter(|_| canonical && s.bytes().all(|b| b.is_ascii_digit()))?;
doc.child_by_key(id, &Key::Index(i))
}
(Kind::Object, Seg::Name(s)) => doc.child_by_key(id, &Key::Name(s.as_str().into())),
(Kind::Object, Seg::Index(i)) => {
doc.child_by_key(id, &Key::Name(i.to_string().as_str().into()))
}
_ => None,
}
}
pub fn resolve(doc: &Doc, segs: &[Seg]) -> Result<NodeId, (NodeId, usize)> {
let mut cur: NodeId = 0;
for (i, seg) in segs.iter().enumerate() {
cur = step(doc, cur, seg).ok_or((cur, i))?;
}
Ok(cur)
}
pub fn node_at(doc: &Doc, line: u32, col: Option<u32>) -> Option<NodeId> {
let known = || (0..doc.len() as NodeId).filter(|&i| doc.node(i).line != 0);
let on_line: Vec<NodeId> = known().filter(|&i| doc.node(i).line == line).collect();
if let Some(first) = on_line.iter().map(|&i| doc.node(i).col).min() {
let target = col.unwrap_or(first).max(first);
let best = on_line
.iter()
.map(|&i| doc.node(i).col)
.filter(|&c| c <= target)
.max()
.unwrap_or(first);
return on_line
.iter()
.rev()
.find(|&&i| doc.node(i).col == best)
.copied();
}
known()
.filter(|&i| doc.node(i).line < line)
.max_by_key(|&i| (doc.node(i).line, doc.node(i).col, i))
.or_else(|| known().next())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outside {
Line { lines: usize },
Col { col: u32, len: usize },
}
pub fn outside(text: &str, line: u32, col: Option<u32>) -> Option<Outside> {
let past = || Outside::Line {
lines: load::line_count(text),
};
let Some(index) = (line as usize).checked_sub(1) else {
return Some(past());
};
let mut pieces = text.split('\n').peekable();
let Some(piece) = pieces.nth(index) else {
return Some(past());
};
let text = match pieces.peek() {
Some(_) => piece.strip_suffix('\r').unwrap_or(piece),
None if piece.is_empty() => return Some(past()),
None => piece,
};
let len = text.chars().count();
match col {
Some(col) if col as usize > len => Some(Outside::Col { col, len }),
_ => None,
}
}
fn select(
doc: &Doc,
text: &str,
start: &Start,
file: &str,
format: Format,
) -> Result<NodeId, Failure> {
match start {
Start::Root => Ok(0),
Start::Path(text) => {
let segs = parse_path(text).map_err(Failure::usage)?;
resolve(doc, &segs).map_err(|(near, at)| {
let why = match &doc.node(near).kind {
Kind::Object => match &segs[at] {
Seg::Name(k) => format!("has no key {}", fmt::quote(k)),
Seg::Index(i) => format!("has no key \"{i}\""),
},
Kind::Array => {
let n = doc.node(near).children;
match n {
0 => "is an empty array".to_string(),
1 => "has 1 item, [0]".to_string(),
n => format!("has {n} items, [0] to [{}]", n - 1),
}
}
k => format!("is {}, which has no keys", with_article(kind_name(k))),
};
let near_path = match near {
0 => "the root (.)".to_string(),
_ => fmt::path_jq(&doc.path(near)),
};
let message = format!("no {} in {file}: {near_path} {why}", text.trim());
Failure::not_found(doc, file, format, ("path", text.trim()), near, message)
})
}
Start::At(line, col) => {
let at = match col {
Some(c) => format!("{line}:{c}"),
None => line.to_string(),
};
let near = node_at(doc, *line, *col);
if let Some(why) = outside(text, *line, *col) {
let counted = |n: usize, what: &str| match n {
1 => format!("1 {what}"),
n => format!("{n} {what}s"),
};
let message = match why {
Outside::Line { lines: 0 } => format!("no line {line} in {file}: it is empty"),
Outside::Line { lines } => {
format!(
"no line {line} in {file}: it has {}",
counted(lines, "line")
)
}
Outside::Col { col, len: 0 } => {
format!("no column {col} on line {line} of {file}: the line is empty")
}
Outside::Col { col, len } => format!(
"no column {col} on line {line} of {file}: the line has {}",
counted(len, "character")
),
};
return Err(Failure::not_found(
doc,
file,
format,
("at", &at),
near.unwrap_or(NO_NODE),
message,
));
}
near.ok_or_else(|| {
let message = format!("no node in {file} has a known source position");
Failure::not_found(doc, file, format, ("at", &at), NO_NODE, message)
})
}
}
}
pub fn kind_name(kind: &Kind) -> &'static str {
match kind {
Kind::Object => "object",
Kind::Array => "array",
Kind::Str(_) => "string",
Kind::Number(_) => "number",
Kind::Bool(_) => "boolean",
Kind::Null => "null",
}
}
pub fn kind_of_event(ev: &JsonEvent<'_>) -> &'static str {
match ev {
JsonEvent::ObjectStart | JsonEvent::ObjectEnd => "object",
JsonEvent::ArrayStart | JsonEvent::ArrayEnd => "array",
JsonEvent::String(_) => "string",
JsonEvent::Number(_) => "number",
JsonEvent::Bool(_) => "boolean",
JsonEvent::Null => "null",
JsonEvent::Key(_) | JsonEvent::End => "none",
}
}
fn with_article(word: &str) -> String {
match word.chars().next() {
Some('a' | 'e' | 'i' | 'o' | 'u') => format!("an {word}"),
_ => format!("a {word}"),
}
}
fn number_value(n: f64) -> Value {
if !n.is_finite() {
return Value::String(fmt::number(n));
}
if n.fract() == 0.0 && n.abs() < 1e16 {
return Value::from(n as i64);
}
serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number)
}
pub fn entry(doc: &Doc, id: NodeId) -> Map<String, Value> {
let node = doc.node(id);
let mut m = Map::new();
m.insert("path".into(), fmt::path_jq(&doc.path(id)).into());
m.insert("kind".into(), kind_name(&node.kind).into());
let pos = |n: u32| if n == 0 { Value::Null } else { n.into() };
m.insert("line".into(), pos(node.line));
m.insert("col".into(), pos(node.col));
match &node.kind {
Kind::Object | Kind::Array => {
m.insert("length".into(), node.children.into());
}
Kind::Str(s) => {
let n = s.chars().count();
if n > VALUE_CHARS {
let cut: String = s.chars().take(VALUE_CHARS).collect();
m.insert("value".into(), cut.into());
m.insert("truncated".into(), true.into());
m.insert("length".into(), n.into());
} else {
m.insert("value".into(), s.to_string().into());
}
}
Kind::Number(n) => {
m.insert("value".into(), number_value(*n));
}
Kind::Bool(b) => {
m.insert("value".into(), (*b).into());
}
Kind::Null => {
m.insert("value".into(), Value::Null);
}
}
m
}
fn walk(
doc: &Doc,
start: NodeId,
depth: Option<u32>,
limit: usize,
keep: impl Fn(NodeId) -> bool,
) -> (Vec<Value>, usize) {
let base = doc.node(start).depth;
let end = doc.subtree_end(start);
let mut out = Vec::new();
let mut total = 0;
let mut id = start;
while id < end {
if keep(id) {
total += 1;
if limit == 0 || out.len() < limit {
out.push(Value::Object(entry(doc, id)));
}
}
id = if depth.is_some_and(|d| doc.node(id).depth - base >= d) {
doc.subtree_end(id)
} else {
id + 1
};
}
(out, total)
}
struct Failure {
status: i32,
error: Map<String, Value>,
}
impl Failure {
fn usage(message: impl Into<String>) -> Failure {
let mut error = Map::new();
error.insert("kind".into(), "usage".into());
error.insert("message".into(), message.into().into());
Failure {
status: status::USAGE,
error,
}
}
fn grammar(e: &GrammarError) -> Failure {
let (kind, status) = if e.error.is_io() {
("io", status::IO)
} else if e.error.is_too_large() {
("too_large", status::TOO_LARGE)
} else if e.error.is_timeout() {
("timeout", status::TIMEOUT)
} else {
("usage", status::USAGE)
};
let mut error = Map::new();
error.insert("kind".into(), kind.into());
error.insert("message".into(), e.to_string().into());
error.insert("grammar".into(), e.grammar.clone().into());
if let Some(file) = &e.file {
error.insert("file".into(), file.clone().into());
}
if kind == "usage" {
return Failure { status, error };
}
error.entry("file").or_insert(Value::Null);
error.insert("format".into(), Value::Null);
error.insert("code".into(), e.error.code.clone().into());
error.insert("line".into(), Value::Null);
error.insert("col".into(), Value::Null);
error.insert(
"hint".into(),
if e.error.hint.is_empty() {
Value::Null
} else {
e.error.hint.as_ref().into()
},
);
error.insert("source_line".into(), Value::Null);
error.insert("report".into(), e.error.plain_report().into());
if status == status::TOO_LARGE {
error.insert("size".into(), e.size.map_or(Value::Null, Value::from));
error.insert(
"limit".into(),
e.limits.max_size.map_or(Value::Null, Value::from),
);
}
if status == status::TIMEOUT {
let secs = e
.limits
.timeout
.map_or(Value::Null, |t| t.as_secs_f64().into());
error.insert("seconds".into(), secs);
}
Failure { status, error }
}
fn load(file: &str, format: Format, e: &LoadError) -> Failure {
let (kind, status) = if e.is_io() {
("io", status::IO)
} else if e.is_too_large() {
("too_large", status::TOO_LARGE)
} else if e.is_timeout() {
("timeout", status::TIMEOUT)
} else {
("parse", status::PARSE)
};
let some = |s: &str| {
if s.is_empty() {
Value::Null
} else {
s.into()
}
};
let pos = |n: u32| if n == 0 { Value::Null } else { n.into() };
let mut error = Map::new();
error.insert("kind".into(), kind.into());
error.insert("file".into(), file.into());
error.insert("format".into(), format.name().into());
error.insert("code".into(), e.code.clone().into());
error.insert("message".into(), e.message.clone().into());
error.insert("line".into(), pos(e.line));
error.insert("col".into(), pos(e.col));
error.insert("hint".into(), some(&e.hint));
error.insert(
"source_line".into(),
e.source_line.as_deref().map_or(Value::Null, Value::from),
);
error.insert("report".into(), e.plain_report().into());
Failure { status, error }
}
fn limited(mut self, size: Option<u64>, req: &Request) -> Failure {
if self.status == status::TOO_LARGE {
let limit = req.max_size.map_or(Value::Null, Value::from);
self.error
.insert("size".into(), size.map_or(Value::Null, Value::from));
self.error.insert("limit".into(), limit);
}
if self.status == status::TIMEOUT {
let secs = req.timeout.map_or(Value::Null, |t| t.as_secs_f64().into());
self.error.insert("seconds".into(), secs);
}
self
}
fn with_loss(mut self, loss: &[String], whole: Option<&str>) -> Failure {
self.error.insert("loss".into(), json!(loss));
if let Some(whole) = whole {
self.error.insert("whole".into(), whole.into());
}
self
}
fn not_found(
doc: &Doc,
file: &str,
format: Format,
asked: (&str, &str),
near: NodeId,
message: String,
) -> Failure {
let mut error = Map::new();
error.insert("kind".into(), "not_found".into());
error.insert("file".into(), file.into());
error.insert("format".into(), format.name().into());
error.insert(asked.0.into(), asked.1.into());
error.insert("message".into(), message.into());
let (nearest, keys) = if (near as usize) < doc.len() {
let keys = match doc.node(near).kind {
Kind::Object => Value::Array(
doc.children(near)
.take(KEYS_LISTED)
.filter_map(|c| doc.node(c).key.name().map(|k| k.into()))
.collect(),
),
_ => Value::Null,
};
(Value::Object(entry(doc, near)), keys)
} else {
(Value::Null, Value::Null)
};
error.insert("nearest".into(), nearest);
error.insert("keys".into(), keys);
Failure {
status: status::NOT_FOUND,
error,
}
}
fn not_found_streamed(
file: &str,
format: Format,
path: &str,
message: String,
near: &Nearest,
) -> Failure {
let mut error = Map::new();
error.insert("kind".into(), "not_found".into());
error.insert("file".into(), file.into());
error.insert("format".into(), format.name().into());
error.insert("path".into(), path.into());
error.insert("message".into(), message.into());
let mut entry = Map::new();
entry.insert("path".into(), near.path.clone().into());
entry.insert("kind".into(), near.kind.into());
entry.insert("line".into(), Value::Null);
entry.insert("col".into(), Value::Null);
match (&near.length, &near.value) {
(Some(n), _) => {
entry.insert("length".into(), (*n).into());
}
(None, Some(v)) => {
entry.insert("value".into(), v.clone());
}
(None, None) => {}
}
error.insert("nearest".into(), Value::Object(entry));
error.insert(
"keys".into(),
near.keys.as_ref().map_or(Value::Null, |k| {
Value::Array(k.iter().map(|k| k.as_str().into()).collect())
}),
);
Failure {
status: status::NOT_FOUND,
error,
}
}
fn program_read(file: &str, e: &LoadError) -> Failure {
let mut f = Failure::load(file, Format::Text, e);
f.error.insert("format".into(), Value::Null);
f
}
fn alchemy(file: &str, fail: &Fail) -> Failure {
let programs = alchemy::is_programs(fail.code);
let status = if programs {
status::USAGE
} else {
match fail.code {
Code::ResourceLimitExceeded => status::TOO_LARGE,
Code::OutputFailed => status::IO,
Code::Aborted => status::TIMEOUT,
_ => status::PARSE,
}
};
let mut error = Map::new();
error.insert(
"kind".into(),
if programs { "alchemy" } else { "transduce" }.into(),
);
error.insert("file".into(), file.into());
error.insert("format".into(), Value::Null);
let Value::Object(detail) = fail.to_json() else {
unreachable!("a failure is an object")
};
for (k, v) in detail {
let k = if k == "row" { "line".to_string() } else { k };
error.insert(k, v);
}
Failure { status, error }
}
fn transduce(file: &str, format: Format, fail: &Fail) -> Failure {
let status = match fail.code {
Code::ResourceLimitExceeded => status::TOO_LARGE,
Code::OutputFailed => status::IO,
Code::Aborted => status::TIMEOUT,
_ => status::PARSE,
};
let mut error = Map::new();
error.insert("kind".into(), "transduce".into());
error.insert("file".into(), file.into());
error.insert("format".into(), format.name().into());
let Value::Object(detail) = fail.to_json() else {
unreachable!("a failure is an object")
};
for (k, v) in detail {
let k = if k == "row" { "line".to_string() } else { k };
error.insert(k, v);
}
if fail
.limit
.as_ref()
.is_some_and(|l| l.name == "max_output_bytes")
{
error.insert(
"hint".into(),
"Pass --max-output with a larger size to let the program write more, or \
--max-output 0 for no limit."
.into(),
);
}
Failure { status, error }
}
}
pub fn usage_failure(message: &str, compact: bool) -> String {
render(&json!({ "error": Failure::usage(message).error }), compact)
}
pub fn grammar_failure(e: &GrammarError, compact: bool) -> (String, i32) {
let f = Failure::grammar(e);
(render(&json!({ "error": f.error }), compact), f.status)
}
pub fn write_failure(e: &io::Error, compact: bool) -> String {
let error = json!({ "error": {
"kind": "io",
"file": null,
"format": null,
"code": "io",
"message": format!("cannot write standard output: {e}"),
"line": null,
"col": null,
"hint": null,
"source_line": null,
"report": null,
}});
render(&error, compact)
}
const OPEN_LEVELS: usize = 2;
pub fn render(v: &Value, compact: bool) -> String {
let mut out = String::new();
if compact {
out.push_str(&v.to_string());
} else {
write(&mut out, v, 0);
}
out.push('\n');
out
}
fn write(out: &mut String, v: &Value, depth: usize) {
let pad = |out: &mut String, d: usize| out.extend(std::iter::repeat_n(' ', d * 2));
match v {
Value::Object(m) if depth < OPEN_LEVELS && !m.is_empty() => {
out.push_str("{\n");
for (i, (k, v)) in m.iter().enumerate() {
pad(out, depth + 1);
out.push_str(&Value::String(k.clone()).to_string());
out.push_str(": ");
write(out, v, depth + 1);
if i + 1 < m.len() {
out.push(',');
}
out.push('\n');
}
pad(out, depth);
out.push('}');
}
Value::Array(a) if depth < OPEN_LEVELS && !a.is_empty() => {
out.push_str("[\n");
for (i, v) in a.iter().enumerate() {
pad(out, depth + 1);
write(out, v, depth + 1);
if i + 1 < a.len() {
out.push(',');
}
out.push('\n');
}
pad(out, depth);
out.push(']');
}
v => out.push_str(&v.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
const DOC: &str = "{\n \"a\": 1,\n \"b\": [true, null, \"x\"],\n \"c\": {\"d\": 2.5, \"odd key\": \"v\"}\n}\n";
fn doc(src: &str) -> Doc {
load::parse(src, Format::Json).unwrap()
}
fn with_stdin(req: &Request, src: &str) -> Output {
let mut input = src.as_bytes();
run(req, Some(&mut input))
}
fn json_of(text: &str) -> Value {
serde_json::from_str(text).unwrap_or_else(|e| panic!("{e}: {text}"))
}
fn req(op: Op) -> Request {
Request::new(op)
}
#[test]
fn path_syntaxes() {
use Seg::{Index as I, Name as N};
let n = |s: &str| N(s.to_string());
for root in ["", ".", "$", " . ", "$."] {
assert_eq!(parse_path(root), Ok(vec![]), "{root:?}");
}
let want = vec![n("a"), n("b"), I(0), n("c")];
for p in [
".a.b[0].c",
"a.b[0].c",
"$.a.b[0].c",
".a.b.[0].c",
"$['a'][\"b\"][ 0 ].c",
] {
assert_eq!(parse_path(p), Ok(want.clone()), "{p:?}");
}
assert_eq!(
parse_path("/a/b/0/c"),
Ok(vec![n("a"), n("b"), n("0"), n("c")])
);
assert_eq!(parse_path("/"), Ok(vec![n("")]));
assert_eq!(parse_path("/a~1b/c~0d"), Ok(vec![n("a/b"), n("c~d")]));
assert_eq!(
parse_path(r#"."odd key"."q\"t""#),
Ok(vec![n("odd key"), n("q\"t")])
);
assert_eq!(parse_path(r#".["a.b"]"#), Ok(vec![n("a.b")]));
assert_eq!(parse_path(r"['it\'s']"), Ok(vec![n("it's")]));
assert_eq!(parse_path(".x[-1]"), Ok(vec![n("x"), I(-1)]));
assert_eq!(parse_path(".0"), Ok(vec![n("0")]));
assert_eq!(parse_path(r#"."é""#), Ok(vec![n("é")]));
for (bad, why) in [
(".a.", "missing after the last '.'"),
("..a", "recursive descent"),
(".a[*]", "wildcards"),
(".a[]", "wildcards"),
(".a[1:2]", "slices"),
(".a[x]", "expected an index"),
(".a[0", "expected ']'"),
(r#"."open"#, "not closed"),
(".a]b[", "expected an index"),
] {
let e = parse_path(bad).unwrap_err();
assert!(e.contains(why), "{bad:?}: {e}");
}
}
#[test]
fn resolution_is_lenient_about_names_and_indices() {
let d = doc(r#"{"a": [10, 20, {"0": "zero", "k": [1]}], "1": "one"}"#);
let at = |p: &str| resolve(&d, &parse_path(p).unwrap()).ok();
let path = |id: Option<NodeId>| id.map(|i| fmt::path_jq(&d.path(i)));
assert_eq!(path(at(".a[1]")), Some(".a[1]".into()));
assert_eq!(
path(at(".a.1")),
Some(".a[1]".into()),
"a decimal name indexes an array"
);
assert_eq!(path(at("/a/1")), Some(".a[1]".into()));
assert_eq!(path(at(".a[-1]")), Some(".a[2]".into()));
assert_eq!(path(at(".a[-3]")), Some(".a[0]".into()));
assert_eq!(at(".a[-4]"), None);
assert_eq!(at(".a[3]"), None);
assert_eq!(at(".a.01"), None, "only the canonical decimal is an index");
assert_eq!(
path(at("[1]")),
Some(r#"."1""#.into()),
"an index on an object is its key"
);
assert_eq!(path(at(".a[2][0]")), Some(r#".a[2]."0""#.into()));
assert_eq!(resolve(&d, &parse_path(".a[2].k[5]").unwrap()), Err((6, 3)));
}
#[test]
fn every_printed_path_resolves_back() {
let src =
r#"{"plain": 1, "odd key": {"é": [0, {"a.b": 2, "q\"t": 3, "": 4, "$x": 5, "0": 6}]}}"#;
let d = doc(src);
for id in 0..d.len() as NodeId {
let printed = fmt::path_jq(&d.path(id));
let segs = parse_path(&printed).unwrap_or_else(|e| panic!("{printed}: {e}"));
assert_eq!(resolve(&d, &segs), Ok(id), "{printed}");
}
}
#[test]
fn a_root_arrays_paths_are_jq_and_resolve_back() {
let d = doc(r#"[{"name": "a", "tags": [1, [2]]}, 3]"#);
let printed: Vec<String> = (0..d.len() as NodeId)
.map(|id| fmt::path_jq(&d.path(id)))
.collect();
assert_eq!(
printed,
[
".",
".[0]",
".[0].name",
".[0].tags",
".[0].tags[0]",
".[0].tags[1]",
".[0].tags[1][0]",
".[1]",
]
);
for (id, path) in printed.iter().enumerate() {
let segs = parse_path(path).unwrap_or_else(|e| panic!("{path}: {e}"));
assert_eq!(resolve(&d, &segs), Ok(id as NodeId), "{path}");
let bare = path.strip_prefix('.').unwrap_or(path);
assert_eq!(parse_path(bare), Ok(segs), "{bare:?}");
}
assert_eq!(
parse_path("[0].name"),
Ok(vec![Seg::Index(0), Seg::Name("name".into())])
);
let mut r = req(Op::Where);
r.start = Start::Path("[0].name".into());
r.compact = true;
let out = with_stdin(&r, r#"[{"name": "a"}]"#);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(json_of(&out.stdout)["path"], json!(".[0].name"));
r.start = Start::Path(".[-1]".into());
let out = with_stdin(&r, r#"[{"name": "a"}, 2]"#);
assert_eq!(json_of(&out.stdout)["path"], json!(".[1]"));
}
#[test]
fn positions_pick_the_node_a_tool_means() {
let d = doc(DOC);
let at = |l, c| node_at(&d, l, c).map(|i| fmt::path_jq(&d.path(i)));
assert_eq!(at(1, None).as_deref(), Some("."));
assert_eq!(at(2, None).as_deref(), Some(".a"));
assert_eq!(at(3, None).as_deref(), Some(".b"), "the line's own node");
assert_eq!(
at(3, Some(1)).as_deref(),
Some(".b"),
"before the first node"
);
assert_eq!(at(3, Some(16)).as_deref(), Some(".b[1]"), "inside `null`");
assert_eq!(at(3, Some(99)).as_deref(), Some(".b[2]"));
assert_eq!(at(4, Some(12)).as_deref(), Some(".c.d"));
assert_eq!(
at(5, None).as_deref(),
Some(r#".c."odd key""#),
"the node before"
);
assert_eq!(at(99, None).as_deref(), Some(r#".c."odd key""#));
let y = load::parse("items:\n - name: web\n port: 80\n", Format::Yaml).unwrap();
let path = node_at(&y, 2, None).map(|i| fmt::path_jq(&y.path(i)));
assert_eq!(path.as_deref(), Some(".items[0].name"));
let none = Doc::from_lines(&[]);
assert_eq!(node_at(&none, 1, None), None);
}
#[test]
fn a_position_is_inside_the_text_or_not() {
use Outside::{Col, Line};
assert_eq!(outside("a\n", 1, None), None);
assert_eq!(outside("a\n", 1, Some(1)), None);
assert_eq!(outside("a\n", 1, Some(2)), Some(Col { col: 2, len: 1 }));
assert_eq!(outside("a\n", 2, None), Some(Line { lines: 1 }));
assert_eq!(
outside("a", 1, Some(1)),
None,
"no terminator: a line still"
);
assert_eq!(outside("a", 2, None), Some(Line { lines: 1 }));
assert_eq!(outside("ab\r\ncd", 1, Some(2)), None);
assert_eq!(
outside("ab\r\ncd", 1, Some(3)),
Some(Col { col: 3, len: 2 }),
"the CR is not text"
);
assert_eq!(outside("ab\r\ncd", 2, Some(2)), None);
assert_eq!(
outside("ab\r\ncd", 2, Some(3)),
Some(Col { col: 3, len: 2 })
);
assert_eq!(outside("ab\r\ncd", 3, None), Some(Line { lines: 2 }));
assert_eq!(outside("{\"a\":1}\r", 1, Some(8)), None);
assert_eq!(
outside("{\"a\":1}\r", 1, Some(9)),
Some(Col { col: 9, len: 8 })
);
assert_eq!(outside("{\"a\":1}\r", 2, None), Some(Line { lines: 1 }));
assert_eq!(outside("a\rb\n", 1, Some(3)), None, "a CR inside a line");
assert_eq!(outside("a\r\n", 1, Some(2)), Some(Col { col: 2, len: 1 }));
assert_eq!(outside("a\n\r", 2, Some(1)), None, "a last line of one CR");
assert_eq!(outside("a\n\r\n", 2, None), None, "an empty second line");
assert_eq!(outside("a\n\r\n", 2, Some(1)), Some(Col { col: 1, len: 0 }));
assert_eq!(
outside("a\n\nb\n", 2, None),
None,
"an empty line is a line"
);
assert_eq!(
outside("a\n\nb\n", 2, Some(1)),
Some(Col { col: 1, len: 0 })
);
assert_eq!(outside("", 1, None), Some(Line { lines: 0 }));
assert_eq!(outside("\n", 1, None), None, "one empty line");
assert_eq!(outside("\n", 2, None), Some(Line { lines: 1 }));
assert_eq!(
outside("é1\n", 1, Some(2)),
None,
"columns count characters"
);
assert_eq!(outside("é1\n", 1, Some(3)), Some(Col { col: 3, len: 2 }));
}
#[test]
fn positions_outside_the_text_are_not_found() {
let at = |op: Op, line, col, src: &str| {
let mut r = req(op);
r.start = Start::At(line, col);
with_stdin(&r, src)
};
let path = |out: &Output| json_of(&out.stdout)["path"].clone();
let error = |out: &Output| json_of(&out.stderr)["error"].clone();
let out = at(Op::Where, 5, Some(1), DOC);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(path(&out), json!(r#".c."odd key""#));
assert_eq!(path(&at(Op::Where, 4, Some(17), DOC)), json!(".c.d"));
assert_eq!(
path(&at(Op::Where, 4, Some(33), DOC)),
json!(r#".c."odd key""#)
);
assert_eq!(path(&at(Op::Where, 2, Some(9), DOC)), json!(".a"));
let out = at(Op::Where, 6, None, DOC);
assert_eq!(out.status, status::NOT_FOUND);
assert!(out.stdout.is_empty());
let e = error(&out);
assert_eq!(e["kind"], json!("not_found"));
assert_eq!(e["file"], json!("-"));
assert_eq!(e["format"], json!("json"));
assert_eq!(e["at"], json!("6"));
assert_eq!(e["message"], json!("no line 6 in -: it has 5 lines"));
assert_eq!(e["nearest"]["path"], json!(r#".c."odd key""#));
assert_eq!(e["nearest"]["line"], json!(4));
assert_eq!(e["keys"], Value::Null);
for (line, col, nearest, why) in [
(5, 2, r#".c."odd key""#, "the line has 1 character"),
(2, 10, ".a", "the line has 9 characters"),
(4, 34, r#".c."odd key""#, "the line has 33 characters"),
] {
let out = at(Op::Where, line, Some(col), DOC);
assert_eq!(out.status, status::NOT_FOUND, "{line}:{col}");
let e = error(&out);
assert_eq!(e["at"], json!(format!("{line}:{col}")));
assert_eq!(
e["message"],
json!(format!("no column {col} on line {line} of -: {why}"))
);
assert_eq!(e["nearest"]["path"], json!(nearest));
}
for op in [Op::Json, Op::Paths, Op::Find("a".into())] {
let flag = op.flag();
let out = at(op, 6, None, DOC);
assert_eq!(out.status, status::NOT_FOUND, "{flag}");
assert_eq!(error(&out)["nearest"]["path"], json!(r#".c."odd key""#));
}
let out = at(Op::Where, 1, Some(8), "[1, {}]\n");
assert_eq!(out.status, status::NOT_FOUND);
let e = error(&out);
assert_eq!(e["nearest"]["path"], json!(".[1]"));
assert_eq!(e["keys"], json!([]));
assert_eq!(path(&at(Op::Where, 1, Some(7), "[1, {}]\n")), json!(".[1]"));
let crlf = "{\r\n \"a\": 1\r\n}\r\n";
assert_eq!(path(&at(Op::Where, 2, Some(8), crlf)), json!(".a"));
let out = at(Op::Where, 2, Some(9), crlf);
assert_eq!(out.status, status::NOT_FOUND);
assert_eq!(
error(&out)["message"],
json!("no column 9 on line 2 of -: the line has 8 characters")
);
assert_eq!(path(&at(Op::Where, 3, Some(1), crlf)), json!(".a"));
let out = at(Op::Where, 4, None, crlf);
assert_eq!(out.status, status::NOT_FOUND);
assert_eq!(
error(&out)["message"],
json!("no line 4 in -: it has 3 lines")
);
let bare = "{\n \"a\": 1\n}";
assert_eq!(path(&at(Op::Where, 3, Some(1), bare)), json!(".a"));
assert_eq!(at(Op::Where, 3, Some(2), bare).status, status::NOT_FOUND);
let out = at(Op::Where, 4, None, bare);
assert_eq!(out.status, status::NOT_FOUND);
assert_eq!(error(&out)["nearest"]["path"], json!(".a"));
let mut r = req(Op::Where);
r.files = vec!["-".into()];
r.kind = Some(Format::Text);
r.start = Start::At(1, None);
let out = with_stdin(&r, "");
assert_eq!(out.status, status::NOT_FOUND);
let e = error(&out);
assert_eq!(e["message"], json!("no line 1 in -: it is empty"));
assert_eq!(e["nearest"], Value::Null);
assert_eq!(e["keys"], Value::Null);
}
#[test]
fn at_parses_line_and_column() {
assert_eq!(Start::parse_at("42"), Ok(Start::At(42, None)));
assert_eq!(Start::parse_at("42:7"), Ok(Start::At(42, Some(7))));
for bad in ["0", "4:0", "x", "4:", ":4", "-1", "1:2:3"] {
assert!(Start::parse_at(bad).is_err(), "{bad}");
}
}
#[test]
fn entries_bound_their_values() {
let long = "é".repeat(VALUE_CHARS + 5);
let d = doc(&format!(
r#"[1, 2.5, 1e300, "{long}", "short", false, null, {{}}, [1, 2]]"#
));
let e = |i: NodeId| Value::Object(entry(&d, i));
assert_eq!(
e(0),
json!({"path": ".", "kind": "array", "line": 1, "col": 1, "length": 9})
);
assert_eq!(e(1)["value"], json!(1));
assert_eq!(e(2)["value"], json!(2.5));
assert_eq!(e(3)["value"], json!(1e300));
let s = e(4);
assert_eq!(s["truncated"], json!(true));
assert_eq!(s["length"], json!(VALUE_CHARS + 5));
assert_eq!(s["value"].as_str().unwrap().chars().count(), VALUE_CHARS);
assert_eq!(e(5)["value"], json!("short"));
assert!(e(5).get("truncated").is_none());
assert_eq!(e(6)["value"], json!(false));
assert_eq!(e(7)["value"], Value::Null);
assert_eq!(e(8)["length"], json!(0));
assert_eq!(e(9)["kind"], json!("array"));
let nan = load::parse("[NaN, -Infinity]", Format::Json5).unwrap();
assert_eq!(entry(&nan, 1)["value"], json!("NaN"));
assert_eq!(entry(&nan, 2)["value"], json!("-Infinity"));
assert_eq!(entry(&nan, 1)["kind"], json!("number"));
let mut unknown = doc("[1]");
unknown.nodes[1].line = 0;
unknown.nodes[1].col = 0;
assert_eq!(entry(&unknown, 1)["line"], Value::Null);
}
#[test]
fn json_prints_the_start() {
let mut r = req(Op::Json);
let out = with_stdin(&r, DOC);
assert_eq!(out.status, status::OK);
assert_eq!(out.stderr, "");
assert_eq!(json_of(&out.stdout)["c"]["odd key"], json!("v"));
assert!(out.stdout.ends_with("}\n"), "{}", out.stdout);
r.start = Start::Path(".c".into());
r.compact = true;
let out = with_stdin(&r, DOC);
assert_eq!(out.stdout, "{\"d\":2.5,\"odd key\":\"v\"}\n");
r.start = Start::At(3, Some(16));
assert_eq!(with_stdin(&r, DOC).stdout, "null\n");
}
#[test]
fn paths_list_to_a_depth_and_a_limit() {
let mut r = req(Op::Paths);
let all = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(all["file"], json!("-"));
assert_eq!(all["format"], json!("json"));
assert_eq!(all["path"], json!("."));
assert_eq!(all["total"], json!(9));
assert_eq!(all["truncated"], json!(false));
let paths: Vec<&str> = all["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["path"].as_str().unwrap())
.collect();
assert_eq!(
paths,
[
".",
".a",
".b",
".b[0]",
".b[1]",
".b[2]",
".c",
".c.d",
r#".c."odd key""#
]
);
assert_eq!(
all["entries"][2],
json!({"path": ".b", "kind": "array", "line": 3, "col": 3, "length": 3})
);
r.depth = Some(1);
let top = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(top["total"], json!(4));
r.depth = Some(0);
r.start = Start::Path(".c".into());
let one = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(one["path"], json!(".c"));
assert_eq!(one["total"], json!(1));
r.depth = None;
r.start = Start::Root;
r.limit = 2;
let cut = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(cut["entries"].as_array().unwrap().len(), 2);
assert_eq!(cut["total"], json!(9));
assert_eq!(cut["limit"], json!(2));
assert_eq!(cut["truncated"], json!(true));
r.limit = 0;
let whole = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(whole["entries"].as_array().unwrap().len(), 9);
assert_eq!(whole["truncated"], json!(false));
}
#[test]
fn find_uses_the_viewers_search() {
let mut r = req(Op::Find("\"d\":".into()));
let out = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(out["pattern"], json!("\"d\":"));
assert_eq!(out["total"], json!(1));
assert_eq!(
out["matches"][0],
json!({"path": ".c.d", "kind": "number", "line": 4, "col": 9, "value": 2.5})
);
r.op = Op::Find("NULL".into());
assert_eq!(json_of(&with_stdin(&r, DOC).stdout)["total"], json!(0));
r.op = Op::Find("null".into());
assert_eq!(json_of(&with_stdin(&r, DOC).stdout)["total"], json!(1));
r.op = Op::Find("ODD/s".into());
assert_eq!(json_of(&with_stdin(&r, DOC).stdout)["total"], json!(0));
r.op = Op::Find("[0-9]".into());
assert_eq!(json_of(&with_stdin(&r, DOC).stdout)["total"], json!(0));
r.op = Op::Find(r"\d".into());
r.start = Start::Path(".c".into());
let under = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(under["path"], json!(".c"));
assert_eq!(under["total"], json!(1));
r.op = Op::Find("(".into());
struct Untouchable;
impl Read for Untouchable {
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
panic!("read input for a bad pattern")
}
}
let bad = run(&r, Some(&mut Untouchable));
assert_eq!(bad.status, status::USAGE);
assert!(json_of(&bad.stderr)["error"]["message"]
.as_str()
.unwrap()
.starts_with("Invalid regex"));
}
#[test]
fn where_gives_the_entry_and_its_file() {
let mut r = req(Op::Where);
r.start = Start::Path("/c/odd key".into());
let out = json_of(&with_stdin(&r, DOC).stdout);
assert_eq!(
out,
json!({"file": "-", "format": "json", "path": ".c.\"odd key\"", "kind": "string", "line": 4, "col": 19, "value": "v"})
);
}
#[test]
fn parse_errors_carry_everything_the_report_does() {
let out = with_stdin(&req(Op::Json), "{\"a\": 1,\n \"b\" 2\n}\n");
assert_eq!(out.status, status::PARSE);
assert_eq!(out.stdout, "");
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("parse"));
assert_eq!(e["file"], json!("-"));
assert_eq!(e["format"], json!("json"));
assert_eq!(e["code"], json!("unexpected"));
assert_eq!((e["line"].clone(), e["col"].clone()), (json!(2), json!(3)));
assert_eq!(e["source_line"], json!(" \"b\" 2"));
assert!(e["hint"].as_str().unwrap().contains("do not match"), "{e}");
let report = e["report"].as_str().unwrap();
assert!(report.contains("--> (stdin):2:3"), "{report}");
assert!(!report.contains('\u{1b}'), "no colour codes: {report}");
}
#[test]
fn failures_say_what_kind_they_are() {
let usage = |out: Output| {
assert_eq!(out.status, status::USAGE, "{}", out.stderr);
assert_eq!(out.stdout, "");
let e = json_of(&out.stderr);
assert_eq!(e["error"]["kind"], json!("usage"));
e["error"]["message"].as_str().unwrap().to_string()
};
assert!(usage(run(&req(Op::Json), None)).starts_with("no input"));
assert!(usage(with_stdin(&req(Op::Json), "")).contains("standard input is empty"));
let mut dash = req(Op::Json);
dash.files = vec!["-".into()];
assert!(usage(run(&dash, None)).contains("standard input is a terminal"));
let mut two = req(Op::Paths);
two.files = vec!["a.json".into(), "b.json".into()];
assert!(usage(run(&two, None)).contains("--paths reads one input, and was given 2"));
let mut dir = req(Op::Json);
dir.files = vec![std::env::temp_dir()];
assert!(usage(run(&dir, None)).contains("is a directory"));
let mut bad = req(Op::Json);
bad.start = Start::Path(".a[".into());
assert!(usage(with_stdin(&bad, DOC)).starts_with("bad path"));
let mut missing = req(Op::Json);
missing.files = vec!["no/such/file.yaml".into()];
let out = run(&missing, None);
assert_eq!(out.status, status::IO);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("io"));
assert_eq!(e["code"], json!("io"));
assert_eq!(e["file"], json!("no/such/file.yaml"));
assert_eq!(e["format"], json!("yaml"));
assert_eq!(e["line"], Value::Null);
let mut absent = req(Op::Json);
absent.start = Start::Path(".c.nope".into());
let out = with_stdin(&absent, DOC);
assert_eq!(out.status, status::NOT_FOUND);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("not_found"));
assert_eq!(e["path"], json!(".c.nope"));
assert_eq!(e["nearest"]["path"], json!(".c"));
assert_eq!(e["keys"], json!(["d", "odd key"]));
assert_eq!(
e["message"],
json!("no .c.nope in -: .c has no key \"nope\"")
);
absent.start = Start::Path(".b[7]".into());
let e = json_of(&with_stdin(&absent, DOC).stderr);
assert_eq!(
e["error"]["message"],
json!("no .b[7] in -: .b has 3 items, [0] to [2]")
);
assert_eq!(e["error"]["keys"], Value::Null);
absent.start = Start::Path(".a.x".into());
let e = json_of(&with_stdin(&absent, DOC).stderr);
assert_eq!(
e["error"]["message"],
json!("no .a.x in -: .a is a number, which has no keys")
);
}
#[test]
fn check_reports_every_input() {
let dir = std::env::temp_dir().join(format!("aless-check-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("good.toml");
let bad = dir.join("bad.json");
std::fs::write(&good, "a = 1\n").unwrap();
std::fs::write(&bad, "{\"a\": }\n").unwrap();
let mut r = req(Op::Check);
r.files = vec![good.clone()];
let out = run(&r, None);
assert_eq!(out.status, status::OK);
let v = json_of(&out.stdout);
assert_eq!(v["ok"], json!(true));
assert_eq!(
v["files"][0],
json!({"file": good.display().to_string(), "format": "toml", "ok": true, "error": null})
);
r.files = vec![good, bad.clone(), dir.join("gone.yaml"), dir.clone()];
let out = run(&r, None);
assert_eq!(out.status, status::PARSE);
assert_eq!(out.stderr, "");
let v = json_of(&out.stdout);
assert_eq!(v["ok"], json!(false));
let files = v["files"].as_array().unwrap();
assert_eq!(files.len(), 4);
assert_eq!(files[1]["ok"], json!(false));
assert_eq!(files[1]["error"]["kind"], json!("parse"));
assert_eq!(files[1]["error"]["line"], json!(1));
assert_eq!(files[2]["error"]["kind"], json!("io"));
assert_eq!(files[3]["error"]["kind"], json!("usage"));
assert_eq!(out.stdout.lines().count(), 4 + 5);
let r = req(Op::Check);
let v = json_of(&with_stdin(&r, "[1").stdout);
assert_eq!(v["files"][0]["file"], json!("-"));
assert_eq!(v["files"][0]["ok"], json!(false));
assert_eq!(run(&r, None).status, status::USAGE);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn kind_overrides_detection() {
let mut r = req(Op::Json);
r.kind = Some(Format::Yaml);
r.compact = true;
let out = with_stdin(&r, "a: [1, 2]\n");
assert_eq!(out.stdout, "{\"a\":[1,2]}\n");
let out = with_stdin(&req(Op::Json), "a: [1, 2]\n");
assert_eq!(out.status, status::PARSE);
}
#[test]
fn inputs_over_the_size_limit_are_refused() {
let mut r = req(Op::Json);
r.max_size = Some(10);
let out = with_stdin(&r, DOC);
assert_eq!(out.status, status::TOO_LARGE);
assert_eq!(out.stdout, "");
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("too_large"));
assert_eq!(e["code"], json!("too_large"));
assert_eq!(e["file"], json!("-"));
assert_eq!(e["size"], Value::Null);
assert_eq!(e["limit"], json!(10));
assert!(e["hint"].as_str().unwrap().contains("--max-size"), "{e}");
let dir = std::env::temp_dir().join(format!("aless-limit-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("doc.json");
std::fs::write(&file, DOC).unwrap();
r.files = vec![file.clone()];
let out = run(&r, None);
assert_eq!(out.status, status::TOO_LARGE);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["size"], json!(DOC.len()));
assert_eq!(e["format"], json!("json"));
r.op = Op::Check;
let v = json_of(&run(&r, None).stdout);
assert_eq!(v["files"][0]["error"]["kind"], json!("too_large"));
r.op = Op::Json;
r.max_size = None;
assert_eq!(run(&r, None).status, status::OK);
r.max_size = Some(DOC.len() as u64);
assert_eq!(run(&r, None).status, status::OK);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_parse_past_its_timeout_fails_with_status_6() {
let toml: String = (0..2_000)
.map(|i| format!("[[item]]\nid = {i}\nname = \"item {i}\"\n\n"))
.collect();
let mut r = req(Op::Paths);
r.kind = Some(Format::Toml);
r.timeout = Some(Duration::from_millis(1));
let out = with_stdin(&r, &toml);
assert_eq!(out.status, status::TIMEOUT, "{}", out.stderr);
assert_eq!(out.stdout, "");
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("timeout"));
assert_eq!(e["code"], json!("timeout"));
assert_eq!(e["format"], json!("toml"));
assert_eq!(e["seconds"], json!(0.001));
assert!(e["line"].as_u64().unwrap() > 0, "{e}");
r.timeout = None;
assert_eq!(with_stdin(&r, "[[item]]\nid = 1\n").status, status::OK);
}
#[test]
fn a_run_on_quiet_stdin_stops_at_its_deadline() {
let program = ProgramArg::Expr("def export [input] input".into());
let modes = [
(Op::Json, None),
(Op::Paths, Some(Format::Yaml)),
(Op::Render(Renderer::Json), None),
(Op::Render(Renderer::Csv), Some(Format::Jsonl)),
(Op::Render(Renderer::Csv), Some(Format::Csv)),
(
Op::Alchemy {
program,
render: None,
explain: false,
},
Some(Format::Jsonl),
),
];
for (op, kind) in modes {
for first in [&b""[..], b"{\"a\": 1}\n"] {
let mut r = req(op.clone());
r.kind = kind;
r.timeout = Some(Duration::from_millis(300));
let started = Instant::now();
r.started = Some(started);
let quiet = load::quiet(first);
let mut input =
load::DeadlineReader::new(quiet, started, Duration::from_millis(300)).unwrap();
let out = run(&r, Some(&mut input));
let took = started.elapsed();
let what = format!("{:?} {kind:?} after {} bytes", r.op, first.len());
assert_eq!(out.status, status::TIMEOUT, "{what}: {}", out.stderr);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("timeout"), "{what}");
assert_eq!(
e["message"],
json!("timeout: the input was still being read after 0.3 s"),
"{what}"
);
assert_eq!(e["seconds"], json!(0.3), "{what}");
assert!(took < Duration::from_secs(10), "{what}: {took:?}");
}
}
}
#[test]
fn only_standard_input_is_timed_from_the_start_of_the_run() {
let mut r = req(Op::Check);
r.timeout = Some(Duration::from_secs(5));
let started = Instant::now();
r.started = Some(started);
assert_eq!(r.limits_for(&Source::Stdin).started, Some(started));
let file = Source::File(PathBuf::from("a.json"));
assert_eq!(r.limits_for(&file).started, None);
assert_eq!(r.limits_for(&file).timeout, r.timeout);
}
#[test]
fn a_programs_output_is_bounded() {
let mut doubling = String::from("def a0 \"0123456789\"\n");
for i in 1..=40 {
doubling.push_str(&format!("def a{i} (concat a{} a{})\n", i - 1, i - 1));
}
doubling.push_str("def export [input] (concat a40 (json input))\n");
let program = |text: &str| Op::Alchemy {
program: ProgramArg::Expr(text.into()),
render: None,
explain: false,
};
assert_eq!(
req(Op::Json).max_output,
Some(load::DEFAULT_MAX_OUTPUT),
"bounded unless asked otherwise"
);
let mut r = req(program(&doubling));
r.max_output = Some(4096);
let out = with_stdin(&r, "{\"a\": 1}");
assert_eq!(out.status, status::TOO_LARGE, "{}", out.stderr);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("transduce"));
assert_eq!(e["code"], json!("RESOURCE_LIMIT_EXCEEDED"));
assert_eq!(
e["limit"],
json!({"name": "max_output_bytes", "value": 4096})
);
assert!(
e["hint"].as_str().unwrap().contains("--max-output 0"),
"{e}"
);
assert!(out.stdout.len() <= 4096, "{}", out.stdout.len());
let echo = req(program("def export [input] input"));
assert_eq!(with_stdin(&echo, "[1, 2]").stdout, "[1,2]\n");
let mut render = req(Op::Render(Renderer::Json));
render.max_output = Some(4);
render.compact = true;
let out = with_stdin(&render, "[1, 2, 3, 4, 5]");
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "[1,2,3,4,5]\n");
}
#[test]
fn an_unwritable_output_is_an_io_error_with_no_file() {
let e = io::Error::new(io::ErrorKind::StorageFull, "no space left");
let v = json_of(&write_failure(&e, false));
assert_eq!(v["error"]["kind"], json!("io"));
assert_eq!(v["error"]["code"], json!("io"));
assert_eq!(v["error"]["file"], Value::Null);
assert_eq!(
v["error"]["message"],
json!("cannot write standard output: no space left")
);
let missing = run(
&{
let mut r = req(Op::Json);
r.files = vec!["no/such.json".into()];
r
},
None,
);
let keys = |v: &Value| -> Vec<String> {
v["error"].as_object().unwrap().keys().cloned().collect()
};
assert_eq!(keys(&v), keys(&json_of(&missing.stderr)));
assert!(!write_failure(&e, true).trim_end().contains('\n'));
}
const RECORDS: &str = r#"{"rows": [{"a": 1.50, "b": "x"}, {"b": "y", "c": null}], "n": 2}"#;
#[test]
fn render_streams_csv_and_json() {
let mut r = req(Op::Render(Renderer::Csv));
r.start = Start::Path(".rows".into());
let out = with_stdin(&r, RECORDS);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stderr, "");
assert_eq!(
out.stdout,
"\"a\",\"b\"\r\n\"1.50\",\"x\"\r\n\"\",\"y\"\r\n"
);
r.op = Op::Render(Renderer::Json);
r.compact = true;
let out = with_stdin(&r, RECORDS);
assert_eq!(
out.stdout,
"[{\"a\":1.50,\"b\":\"x\"},{\"b\":\"y\",\"c\":null}]\n"
);
r.start = Start::Root;
r.compact = false;
let out = with_stdin(&r, r#"{"a": [1]}"#);
assert_eq!(out.stdout, "{\n \"a\": [\n 1\n ]\n}\n");
let mut lines = req(Op::Render(Renderer::Csv));
lines.kind = Some(Format::Jsonl);
let out = with_stdin(&lines, "{\"id\": 1}\n{\"id\": 2, \"x\": true}\n");
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "\"id\"\r\n\"1\"\r\n\"2\"\r\n");
lines.kind = Some(Format::Csv);
let out = with_stdin(&lines, "a,b\n1,2\n");
assert_eq!(out.stdout, "\"a\",\"b\"\r\n\"1\",\"2\"\r\n");
let mut jsonic = req(Op::Render(Renderer::Json));
jsonic.kind = Some(Format::Jsonic);
jsonic.compact = true;
let out = with_stdin(&jsonic, "{a:1}\n{b:2}\n");
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "[{\"a\":1},{\"b\":2}]\n");
lines.max_size = Some(4);
let out = with_stdin(&lines, "a,b\n1,2\n");
assert_eq!(out.status, status::OK, "{}", out.stderr);
r.max_size = Some(4);
assert_eq!(with_stdin(&r, RECORDS).status, status::TOO_LARGE);
assert_eq!(Op::Render(Renderer::Csv).flag(), "--render");
}
#[test]
fn programs_run_over_stdin_and_report_their_own_failures() {
let echo = || Op::Alchemy {
program: ProgramArg::Expr("def export [input] input".into()),
render: None,
explain: false,
};
assert_eq!(echo().flag(), "--alchemy");
let mut r = req(echo());
r.compact = true;
let out = with_stdin(&r, RECORDS);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stderr, "");
assert_eq!(
out.stdout,
"{\"rows\":[{\"a\":1.50,\"b\":\"x\"},{\"b\":\"y\",\"c\":null}],\"n\":2}\n"
);
let doc = r#"{"cols": [{"title": "A", "path": ["a"]}, {"title": "B", "path": ["b"]}], "rows": [{"a": 1.50, "b": "x"}, {"a": 2, "b": "y"}]}"#;
let table = "def col [c]\n record\n entry :label (get \"title\" c)\n entry :source (as-path (get \"path\" c))\ndef export [input]\n table-from-json (record (entry :columns (path \"cols\")) (entry :rows (path \"rows\" each-index)) (entry :column col)) input\n";
let mut t = req(Op::Alchemy {
program: ProgramArg::Expr(table.into()),
render: None,
explain: false,
});
let out = with_stdin(&t, doc);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(
out.stdout,
"\"A\",\"B\"\r\n\"1.50\",\"x\"\r\n\"2\",\"y\"\r\n"
);
t.op = Op::Alchemy {
program: ProgramArg::Expr(table.into()),
render: Some(Renderer::Json),
explain: false,
};
let out = with_stdin(&t, doc);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(
json_of(&out.stdout),
json!([{"A": 1.50, "B": "x"}, {"A": 2, "B": "y"}])
);
let mut x = req(Op::Alchemy {
program: ProgramArg::Expr("def export [input] input".into()),
render: None,
explain: true,
});
x.compact = true;
let out = with_stdin(&x, RECORDS);
assert_eq!(out.status, status::OK, "{}", out.stderr);
let plan = json_of(&out.stdout);
assert_eq!(plan["entry"], json!("export"));
assert_eq!(plan["output"], json!("JsonEvents/1"));
let bad = req(Op::Alchemy {
program: ProgramArg::Expr("def export [input] (nope input)".into()),
render: None,
explain: false,
});
let out = with_stdin(&bad, RECORDS);
assert_eq!(out.status, status::USAGE);
assert_eq!(out.stdout, "");
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("alchemy"));
assert_eq!(e["file"], json!(alchemy::EXPR_NAME));
assert_eq!(e["format"], json!(null));
assert_eq!(e["code"], json!("DSL_TYPE_ERROR"));
assert_eq!((e["line"].clone(), e["col"].clone()), (json!(1), json!(21)));
assert_eq!(e["output"], json!("none"));
let out = with_stdin(&r, "[{\"a\": 1},\n {\"b\": }]");
assert_eq!(out.status, status::PARSE);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("transduce"));
assert_eq!(e["code"], json!("INPUT_INVALID"));
assert_eq!(e["file"], json!("-"));
assert_eq!(e["format"], json!("json"));
assert_eq!((e["line"].clone(), e["col"].clone()), (json!(2), json!(8)));
let failing = req(Op::Alchemy {
program: ProgramArg::Expr(
"def check [r]\n match (get \"b\" r)\n case \"y\" (fail \"no y\")\n case _ \"ok\"\n\
def export [input]\n join \"\\n\" (map check (select (path \"rows\" each-index) input))"
.into(),
),
render: None,
explain: false,
});
let out = with_stdin(&failing, RECORDS);
assert_eq!(out.status, status::PARSE, "{}", out.stderr);
assert_eq!(
out.stdout, "ok",
"the first item, whole, before the failure"
);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("transduce"));
assert_eq!(e["code"], json!("INPUT_INVALID"));
assert_eq!(e["message"], json!("no y"));
assert_eq!(e["file"], json!(alchemy::EXPR_NAME));
assert_eq!(e["format"], json!(null));
assert_eq!((e["line"].clone(), e["col"].clone()), (json!(3), json!(14)));
assert_eq!(e["input"], json!("-"));
assert_eq!(e["output"], json!("partial"));
let mut lines = req(echo());
lines.kind = Some(Format::Jsonl);
lines.max_size = Some(4);
let out = with_stdin(&lines, "{\"id\": 1}\n{\"id\": 2}\n");
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "[{\"id\":1},{\"id\":2}]\n");
r.max_size = Some(4);
assert_eq!(with_stdin(&r, RECORDS).status, status::TOO_LARGE);
}
#[test]
fn a_renderers_failure_over_the_programs_rows_is_reported_as_the_inputs() {
let table = "def api-binding\n record\n entry :columns (path \"meta\" \"fields\")\n \
entry :rows (path \"records\" each-index)\n entry :column (fn [f] (record \
(entry :label (get \"title\" f)) (entry :source (as-path (get \"path\" f)))))\n\
def export [input]\n table-from-json api-binding input\n";
let mut r = req(Op::Alchemy {
program: ProgramArg::Expr(table.into()),
render: None,
explain: false,
});
r.compact = true;
let doc = r#"{"meta": {"fields": [{"title": "Id", "path": ["id"]}, {"title": "Name", "path": ["name"]}]}, "records": [{"id": 1, "name": "a"}, {"id": 2}]}"#;
let out = with_stdin(&r, doc);
assert_eq!(out.status, status::PARSE, "{}", out.stderr);
assert_eq!(
out.stdout, "\"Id\",\"Name\"\r\n\"1\",\"a\"\r\n",
"the header and the whole row before the failure"
);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("transduce"));
assert_eq!(e["code"], json!("MISSING_VALUE"));
assert_eq!(e["file"], json!("-"));
assert_eq!(e["format"], json!("json"));
assert_eq!(e["output"], json!("partial"));
assert!(e.get("input").is_none(), "{e}");
assert!(e.get("line").is_none() && e.get("col").is_none(), "{e}");
let mut r = req(Op::Alchemy {
program: ProgramArg::Expr(table.into()),
render: Some(Renderer::Json),
explain: false,
});
r.compact = true;
let out = with_stdin(&r, doc);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "[{\"Id\":1,\"Name\":\"a\"},{\"Id\":2}]\n");
}
#[test]
fn an_oversize_program_file_is_too_large_worded_for_the_program() {
let dir = std::env::temp_dir().join(format!("aless-program-size-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("p.alc");
std::fs::write(&path, "def export [input] input\n").unwrap();
let mut r = req(Op::Alchemy {
program: ProgramArg::File(path.clone()),
render: None,
explain: false,
});
r.max_size = Some(10);
let out = with_stdin(&r, RECORDS);
assert_eq!(out.status, status::TOO_LARGE, "{}", out.stderr);
assert_eq!(out.stdout, "");
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("too_large"));
assert_eq!(e["code"], json!("too_large"));
assert_eq!(e["file"], json!(path.display().to_string()));
assert_eq!(e["format"], json!(null));
assert_eq!(e["message"], json!("program is 25 B, over the 10 B limit"));
assert_eq!(e["size"], json!(25));
assert_eq!(e["limit"], json!(10));
let hint = e["hint"].as_str().unwrap();
assert!(!hint.contains("bytes of memory"), "{hint}");
assert!(
hint.starts_with("A program file over --max-size is not read."),
"{hint}"
);
assert!(hint.contains("--max-size 0"), "{hint}");
assert!(
e["report"].as_str().unwrap().contains("program is 25 B"),
"{e}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_grammars_refusal_to_stream_under_a_program_is_the_inputs() {
let mut r = req(Op::Alchemy {
program: ProgramArg::Expr("def export [input] (json input)".into()),
render: None,
explain: false,
});
r.kind = Some(Format::Jsonic);
let long = "x".repeat(2 * tabnas_render::DEFAULT_BUDGET);
let out = with_stdin(&r, &format!("{{a:'{long}'}}\n{{b:2}}\n"));
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, format!("[{{\"a\":\"{long}\"}},{{\"b\":2}}]\n"));
let out = with_stdin(&r, "{a:1}\n{b:2}\n");
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "[{\"a\":1},{\"b\":2}]\n");
let w = req(Op::Alchemy {
program: ProgramArg::Expr(
"def w [f] (f f)\ndef export [input]\n concat-map (fn [row] (let [x (w w)] \".\")) \
(select (path each-index) input)"
.into(),
),
render: None,
explain: false,
});
let out = with_stdin(&w, "[1, 2]");
assert_eq!(out.status, status::USAGE, "{}", out.stderr);
assert_eq!(out.stdout, "");
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("alchemy"));
assert_eq!(e["code"], json!("STREAMABILITY_UNKNOWN"));
assert!(
e["message"].as_str().unwrap().starts_with("recursion: "),
"{e}"
);
assert_eq!(e["file"], json!(alchemy::EXPR_NAME));
assert_eq!(e["format"], json!(null));
assert_eq!((e["line"].clone(), e["col"].clone()), (json!(1), json!(12)));
assert_eq!(e["input"], json!("-"));
assert_eq!(e["output"], json!("none"));
}
#[test]
fn a_program_slow_on_one_item_stops_at_the_deadline_as_a_run_timeout() {
let slow =
"def slow [row]\n let [v (as-vector row)]\n let [w (map (fn [x] (map (fn [y] (map (fn [z] z) v)) \
v)) v)]\n \".\"\ndef export [input]\n concat-map slow (select (path \
each-index) input)\n";
let mut r = req(Op::Alchemy {
program: ProgramArg::Expr(slow.into()),
render: None,
explain: false,
});
r.timeout = Some(Duration::from_millis(1500));
let row: Vec<String> = (0..300).map(|i| i.to_string()).collect();
let doc = format!("[[{}]]", row.join(","));
let started = std::time::Instant::now();
let out = with_stdin(&r, &doc);
assert!(
started.elapsed() < Duration::from_secs(4),
"the run went on {:?} past a 1.5 s deadline",
started.elapsed()
);
assert_eq!(out.status, status::TIMEOUT, "{}", out.stderr);
assert_eq!(out.stdout, "");
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("timeout"));
assert_eq!(e["code"], json!("timeout"));
assert_eq!(e["seconds"], json!(1.5));
assert_eq!(e["output"], json!("none"));
assert_eq!(e["file"], json!("-"));
assert_eq!(e["format"], json!("json"));
assert_eq!(
e["message"],
json!("timeout: the run (the parse and the program) ran longer than 1.5 s")
);
let hint = e["hint"].as_str().unwrap();
assert!(hint.contains("the program's work on an item"), "{e}");
assert!(!hint.contains("got this far"), "{e}");
assert_eq!(e["line"], Value::Null, "{e}");
assert_eq!(e["col"], Value::Null, "{e}");
let report = e["report"].as_str().unwrap();
assert!(
report.contains("--> (stdin)\n") && !report.contains("--> (stdin):"),
"{report}"
);
let mut slow_parse = req(Op::Render(Renderer::Json));
slow_parse.kind = Some(Format::Toml);
slow_parse.timeout = Some(Duration::from_millis(1));
let toml: String = (0..3_000)
.map(|i| format!("[[item]]\nid = {i}\nname = \"item {i}\"\n\n"))
.collect();
let e = json_of(&with_stdin(&slow_parse, &toml).stderr);
assert_eq!(
e["error"]["message"],
json!("timeout: the parse ran longer than 0.001 s")
);
}
#[test]
fn render_failures_keep_the_error_shape() {
let r = req(Op::Render(Renderer::Csv));
let out = with_stdin(&r, "[{\"a\": 1},\n {\"b\": }]");
assert_eq!(out.status, status::PARSE);
assert_eq!(
out.stdout, "\"a\"\r\n\"1\"\r\n",
"the whole row before the failure"
);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("transduce"));
assert_eq!(e["file"], json!("-"));
assert_eq!(e["format"], json!("json"));
assert_eq!(e["code"], json!("INPUT_INVALID"));
assert_eq!((e["line"].clone(), e["col"].clone()), (json!(2), json!(8)));
assert_eq!(e["output"], json!("partial"));
assert!(
e.get("row").is_none(),
"the transducer's row is aless's line"
);
let out = with_stdin(&r, r#"{"a": 1}"#);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "\"a\"\r\n\"1\"\r\n");
let mut deep = r.clone();
deep.start = Start::Path(".rows".into());
let out = with_stdin(&deep, r#"{"rows": [{"a": 1}, 2]}"#);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(out.stdout, "\"a\"\r\n\"1\"\r\n\"\"\r\n");
let wide: String = (0..10_001).map(|i| format!("\"k{i}\":1,")).collect();
let e = json_of(&with_stdin(&r, &format!("[{{{}}}]", wide.trim_end_matches(','))).stderr);
assert_eq!(e["error"]["code"], json!("RESOURCE_LIMIT_EXCEEDED"));
assert_eq!(e["error"]["limit"]["name"], json!("max_columns"));
let out = with_stdin(&r, &format!("[{{{}}}]", wide.trim_end_matches(',')));
assert_eq!(out.status, status::TOO_LARGE);
let mut absent = r.clone();
absent.start = Start::Path(".rows.nope".into());
let out = with_stdin(&absent, RECORDS);
assert_eq!(out.status, status::NOT_FOUND);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("not_found"));
assert_eq!(e["path"], json!(".rows.nope"));
assert_eq!(
e["message"],
json!("no .rows.nope in -: .rows has 2 items, [0] to [1]")
);
assert_eq!(
e["nearest"],
json!({"path": ".rows", "kind": "array", "line": null, "col": null, "length": 2})
);
assert_eq!(e["keys"], Value::Null);
absent.start = Start::Path(".zz".into());
let e = json_of(&with_stdin(&absent, RECORDS).stderr);
assert_eq!(e["error"]["keys"], json!(["rows", "n"]));
assert_eq!(e["error"]["nearest"]["kind"], json!("object"));
let usage = |out: Output| {
assert_eq!(out.status, status::USAGE, "{}", out.stderr);
let e = json_of(&out.stderr);
assert_eq!(e["error"]["kind"], json!("usage"));
e["error"]["message"].as_str().unwrap().to_string()
};
let mut at = r.clone();
at.start = Start::At(1, None);
assert_eq!(usage(with_stdin(&at, RECORDS)), RENDER_TAKES_NO_AT);
let mut last = r.clone();
last.start = Start::Path(".rows[-1]".into());
let message = usage(with_stdin(&last, RECORDS));
assert!(
message.contains(": [-1] in .rows[-1] names an item of the array at .rows;"),
"{message}"
);
let mut text = r.clone();
text.kind = Some(Format::Text);
assert!(usage(with_stdin(&text, "hello\n")).contains("-k"));
assert!(usage(run(&r, None)).starts_with("no input"));
let mut lines = r.clone();
lines.kind = Some(Format::Jsonl);
assert!(usage(with_stdin(&lines, "")).contains("standard input is empty"));
let mut two = r.clone();
two.files = vec!["a.json".into(), "b.json".into()];
assert!(usage(run(&two, None)).contains("--render reads one input"));
let mut slow = req(Op::Render(Renderer::Json));
slow.kind = Some(Format::Toml);
slow.timeout = Some(Duration::from_millis(1));
let toml: String = (0..3_000)
.map(|i| format!("[[item]]\nid = {i}\nname = \"item {i}\"\n\n"))
.collect();
let out = with_stdin(&slow, &toml);
assert_eq!(out.status, status::TIMEOUT, "{}", out.stderr);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("timeout"));
assert_eq!(e["code"], json!("timeout"));
assert_eq!(e["seconds"], json!(0.001));
assert_eq!(e["output"], json!("none"));
assert!(e["line"].as_u64().unwrap() > 0, "{e}");
assert!(e["report"].as_str().unwrap().contains("(stdin):"), "{e}");
let deep = format!("{}1{}", "[".repeat(300), "]".repeat(300));
let out = with_stdin(&req(Op::Render(Renderer::Json)), &deep);
assert_eq!(out.status, status::PARSE);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("parse"));
assert_eq!(e["code"], json!("too_deep"));
}
#[test]
fn loss_is_carried_only_by_failures_met_while_writing() {
let error = |r: &Request, src: Option<&str>| -> Value {
let out = match src {
Some(src) => with_stdin(r, src),
None => run(r, None),
};
assert_ne!(out.status, status::OK, "{}", out.stdout);
json_of(&out.stderr)["error"].clone()
};
let broken = "[{\"a\": 1},\n {\"b\": }]";
for id in ["json", "csv", "yaml"] {
let r = req(Op::Render(Renderer::from_name(id).unwrap()));
let mut last = r.clone();
last.start = Start::Path("[-1]".into());
let e = error(&last, Some("[[1], [2]]"));
assert_eq!(e["kind"], json!("usage"), "{id}: {e}");
assert_eq!(e.as_object().unwrap().len(), 2, "{id}: {e}");
let mut absent = r.clone();
absent.start = Start::Path(".nope".into());
let e = error(&absent, Some(RECORDS));
assert_eq!(e["kind"], json!("not_found"), "{id}: {e}");
assert!(e.get("loss").is_none(), "{id}: {e}");
let mut missing = r.clone();
missing.files = vec!["no/such/file.json".into()];
let e = error(&missing, None);
assert_eq!(e["kind"], json!("io"), "{id}: {e}");
assert!(e.get("loss").is_none(), "{id}: {e}");
let e = error(&r, Some(broken));
assert_eq!(e["kind"], json!("transduce"), "{id}: {e}");
assert_eq!(e["output"], json!("partial"), "{id}: {e}");
let loss = e["loss"].as_array().unwrap_or_else(|| panic!("{id}: {e}"));
assert!(!loss.is_empty(), "{id}: {e}");
let program = |files: Vec<PathBuf>| {
let mut p = req(Op::Alchemy {
program: ProgramArg::Expr("def export [input] input".into()),
render: Renderer::from_name(id),
explain: false,
});
p.files = files;
p
};
let e = error(&program(vec!["no/such/file.json".into()]), None);
assert_eq!(e["kind"], json!("io"), "{id}: {e}");
assert!(e.get("loss").is_none(), "{id}: {e}");
let e = error(&program(Vec::new()), Some(broken));
assert_eq!(e["kind"], json!("transduce"), "{id}: {e}");
assert!(e["loss"].is_array(), "{id}: {e}");
}
}
#[test]
fn whole_says_why_the_source_was_read_whole() {
let note = |r: &Request, src: &str| -> Value {
let out = with_stdin(r, src);
assert_eq!(out.status, status::OK, "{}", out.stderr);
json_of(&out.stderr)["warning"].clone()
};
for (format, doc, below, broken) in [
(Format::Toml, "a = 1\n[t]\nb = 2\n", ".t", "a = \n"),
(Format::Ini, "a = 1\n[t]\nb = 2\n", ".t", "[t\n"),
] {
let sentence = translate::source_part(format)
.and_then(|p| p.whole.clone())
.unwrap_or_else(|| panic!("{format}: its part says why it is read whole"));
for id in ["json", "yaml"] {
let mut r = req(Op::Render(Renderer::from_name(id).unwrap()));
r.kind = Some(format);
let w = note(&r, doc);
assert_eq!(w["kind"], json!("loss"), "{format} {id}: {w}");
assert_eq!(w["whole"], json!(sentence), "{format} {id}: {w}");
let out = with_stdin(&r, broken);
assert_ne!(out.status, status::OK, "{format} {id}: {}", out.stdout);
let e = &json_of(&out.stderr)["error"];
assert!(e["loss"].is_array(), "{format} {id}: {e}");
assert_eq!(e["whole"], json!(sentence), "{format} {id}: {e}");
let mut inside = r.clone();
inside.start = Start::Path(below.into());
let w = note(&inside, doc);
assert!(w.get("whole").is_none(), "{format} {id}: {w}");
let mut program = req(Op::Alchemy {
program: ProgramArg::Expr("def export [input] input".into()),
render: Renderer::from_name(id),
explain: false,
});
program.kind = Some(format);
let w = note(&program, doc);
assert!(w.get("whole").is_none(), "{format} {id}: {w}");
}
}
let w = note(
&req(Op::Render(Renderer::from_name("yaml").unwrap())),
RECORDS,
);
assert_eq!(w["kind"], json!("loss"), "{w}");
assert!(w.get("whole").is_none(), "{w}");
}
#[test]
fn custom_grammars_are_formats_like_any_other() {
use crate::grammar::{self, Definition};
let def = Definition::parse(
"--grammar-expr",
&format!("impl-kv-h={}", crate::grammar::tests::KV),
)
.unwrap();
let custom = Format::Custom(grammar::register(def, Limits::NONE).unwrap());
let src = "a=1\nb = two\n";
let mut r = req(Op::Json);
r.kind = Some(custom);
r.compact = true;
let out = with_stdin(&r, src);
assert_eq!(out.status, status::OK, "{}", out.stderr);
assert_eq!(
out.stdout,
"[{\"key\":\"a\",\"val\":\"1\"},{\"key\":\"b\",\"val\":\"two\"}]\n"
);
let mut r = req(Op::Paths);
r.kind = Some(custom);
r.depth = Some(1);
let v = json_of(&with_stdin(&r, src).stdout);
assert_eq!(v["format"], json!("impl-kv-h"));
let paths: Vec<&str> = v["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["path"].as_str().unwrap())
.collect();
assert_eq!(paths, [".", ".[0]", ".[1]"]);
let mut r = req(Op::Where);
r.kind = Some(custom);
r.start = Start::At(2, Some(5));
let v = json_of(&with_stdin(&r, src).stdout);
assert_eq!(v["path"], json!(".[1].val"));
assert_eq!(v["value"], json!("two"));
assert_eq!((v["line"].clone(), v["col"].clone()), (json!(2), json!(5)));
let mut r = req(Op::Json);
r.kind = Some(custom);
let out = with_stdin(&r, "a=1\nb 2\n");
assert_eq!(out.status, status::PARSE);
let e = &json_of(&out.stderr)["error"];
assert_eq!(e["kind"], json!("parse"));
assert_eq!(e["format"], json!("impl-kv-h"));
assert_eq!(e["code"], json!("unexpected"));
assert_eq!((e["line"].clone(), e["col"].clone()), (json!(2), json!(3)));
let dir = std::env::temp_dir().join(format!("aless-headless-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("settings.impl-kv-h");
std::fs::write(&file, src).unwrap();
let mut r = req(Op::Check);
r.files = vec![file.clone()];
let v = json_of(&run(&r, None).stdout);
assert_eq!(v["files"][0]["format"], json!("impl-kv-h"));
assert_eq!(v["files"][0]["ok"], json!(true));
let mut r = req(Op::Json);
r.files = vec![file];
r.compact = true;
assert_eq!(
run(&r, None).stdout,
"[{\"key\":\"a\",\"val\":\"1\"},{\"key\":\"b\",\"val\":\"two\"}]\n"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn grammar_failures_have_their_shape() {
use crate::grammar::{self, Definition};
let e = grammar::register(
Definition::parse("--grammar-expr", "impl-bad-h=doc = nope\n").unwrap(),
Limits::NONE,
)
.unwrap_err();
let (text, status) = grammar_failure(&e, true);
assert_eq!(status, status::USAGE);
let v = json_of(&text);
let err = &v["error"];
assert_eq!(err["kind"], json!("usage"));
assert!(
err["message"]
.as_str()
.unwrap()
.starts_with("--grammar-expr impl-bad-h: abnf"),
"{err}"
);
assert_eq!(err["grammar"], json!("impl-bad-h"));
assert!(err.get("file").is_none(), "{err}");
assert!(text.ends_with('\n') && text.lines().count() == 1);
let e = grammar::register(
Definition::parse("--grammar", "impl-missing-h=/nonexistent/g.abnf").unwrap(),
Limits::NONE,
)
.unwrap_err();
let (text, status) = grammar_failure(&e, false);
assert_eq!(status, status::IO);
let err = &json_of(&text)["error"];
assert_eq!(err["kind"], json!("io"));
assert_eq!(err["grammar"], json!("impl-missing-h"));
assert_eq!(err["file"], json!("/nonexistent/g.abnf"));
assert_eq!(err["code"], json!("io"));
assert!(
err["message"]
.as_str()
.unwrap()
.starts_with("--grammar impl-missing-h: /nonexistent/g.abnf: "),
"{err}"
);
for key in ["format", "line", "col", "hint", "source_line"] {
assert_eq!(err[key], Value::Null, "{key}: {err}");
}
let report = err["report"].as_str().unwrap();
assert!(
report.starts_with("[aless/io]: ") && report.contains("--> /nonexistent/g.abnf"),
"{report}"
);
let dir = std::env::temp_dir().join(format!("aless-grammar-h-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("big.abnf");
std::fs::write(&file, crate::grammar::tests::KV).unwrap();
let def =
Definition::parse("--grammar", &format!("impl-big-h={}", file.display())).unwrap();
let limits = Limits {
max_size: Some(8),
timeout: None,
started: None,
};
let e = grammar::register(def, limits).unwrap_err();
let (text, status) = grammar_failure(&e, true);
assert_eq!(status, status::TOO_LARGE);
let err = &json_of(&text)["error"];
assert_eq!(err["kind"], json!("too_large"));
assert_eq!(err["code"], json!("too_large"));
assert_eq!(err["grammar"], json!("impl-big-h"));
assert_eq!(err["file"], json!(file.display().to_string()));
assert_eq!(err["format"], Value::Null);
assert_eq!(err["size"], json!(crate::grammar::tests::KV.len()));
assert_eq!(err["limit"], json!(8));
assert!(
err["hint"]
.as_str()
.unwrap()
.starts_with("A grammar file over --max-size is not read. Pass --max-size "),
"{err}"
);
assert!(
err["report"]
.as_str()
.unwrap()
.starts_with("[aless/too_large]: the grammar file is "),
"{err}"
);
std::fs::remove_dir_all(&dir).unwrap();
let def = Definition::parse("--grammar-expr", "impl-slow-h=doc = 1*300\"a\"\n").unwrap();
let limits = Limits {
max_size: None,
timeout: Some(std::time::Duration::from_millis(1)),
started: None,
};
let e = grammar::register(def, limits).unwrap_err();
let (text, status) = grammar_failure(&e, true);
assert_eq!(status, status::TIMEOUT);
let err = &json_of(&text)["error"];
assert_eq!(err["kind"], json!("timeout"));
assert_eq!(err["code"], json!("timeout"));
assert_eq!(err["grammar"], json!("impl-slow-h"));
assert_eq!(err["file"], Value::Null);
assert_eq!(err["format"], Value::Null);
assert_eq!(err["seconds"], json!(0.001));
assert_eq!(err["line"], Value::Null);
assert!(
err["message"]
.as_str()
.unwrap()
.ends_with("took longer than 0.001 s to compile"),
"{err}"
);
}
#[test]
fn command_line_usage_errors_have_the_usage_shape() {
assert_eq!(
usage_failure("unknown option: --x (see aless --help)", true),
"{\"error\":{\"kind\":\"usage\",\"message\":\"unknown option: --x (see aless \
--help)\"}}\n"
);
assert_eq!(
usage_failure("no", false),
"{\n \"error\": {\n \"kind\": \"usage\",\n \"message\": \"no\"\n }\n}\n"
);
}
#[test]
fn rendering_keeps_entries_on_one_line_each() {
let v =
json!({"a": 1, "list": [{"x": [1, 2]}, {"y": {}}], "empty": [], "o": {"p": {"q": 1}}});
assert_eq!(
render(&v, false),
"{\n \"a\": 1,\n \"list\": [\n {\"x\":[1,2]},\n {\"y\":{}}\n ],\n \"empty\": [],\n \"o\": {\n \"p\": {\"q\":1}\n }\n}\n"
);
assert_eq!(
render(&v, true),
"{\"a\":1,\"list\":[{\"x\":[1,2]},{\"y\":{}}],\"empty\":[],\"o\":{\"p\":{\"q\":1}}}\n"
);
assert_eq!(json_of(&render(&v, false)), v);
}
}