Skip to content

Commit 2a57253

Browse files
committed
add pipe/batch mode with mmap fast path | bump to 0.2.3 | jetro 0.5.5
1 parent e4a0795 commit 2a57253

5 files changed

Lines changed: 214 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 12 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[package]
22
name = "jetrocli"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
edition = "2021"
55
description = "Interactive split-pane TUI for jetro: paste JSON, do live query"
66
license = "MIT"
77
homepage = "https://github.com/mitghi/jetrocli"
88
repository = "https://github.com/mitghi/jetrocli"
99

1010
[dependencies]
11-
jetro-core = "0.5.3"
11+
jetro-core = "0.5.5"
1212
serde_json = "1"
1313
indexmap = "2"
1414
ratatui = "0.29"
@@ -17,6 +17,7 @@ tui-textarea = "0.7"
1717
anyhow = "1"
1818
clap = { version = "4", features = ["derive"] }
1919
regex = "1"
20+
memmap2 = "0.9"
2021

2122
[profile.dist]
2223
inherits = "release"

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,34 @@ Binary lands at `target/release/jetrocli`.
4343

4444
## Usage
4545

46+
### Interactive TUI
47+
48+
Default when stdin is a TTY.
49+
4650
```sh
4751
jetrocli # sample document
4852
jetrocli -i data.json # load from file
4953
jetrocli -i data.json -e '$.users' # pre-fill expression
5054
```
5155

56+
### Pipe / batch mode
57+
58+
When stdin is piped or redirected, TUI is skipped — jetrocli evaluates the expression against stdin and prints the result with `jq`-style colorized JSON (ANSI dropped when stdout not a TTY; respects `NO_COLOR` and `JETROCLI_COLOR=never`).
59+
60+
```sh
61+
echo '{"users":[{"name":"a"},{"name":"b"}]}' | jetrocli '$.users.name'
62+
curl -s api.example.com/data | jetrocli '$.items.first()'
63+
```
64+
65+
With no expression (or empty string), jetrocli just pretty-prints stdin as JSON, like `jq` with no filter:
66+
67+
```sh
68+
cat data.json | jetrocli
69+
echo '{"a":1,"b":[2,3]}' | jetrocli ''
70+
```
71+
72+
When stdin is backed by a regular file (`jetrocli EXPR < big.json`), input is `mmap`'d instead of streamed — zero-copy load for large documents. Real pipes fall back to a buffered read.
73+
5274
## License
5375

5476
MIT

src/main.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
mod completion;
44
mod editor;
55
mod eval;
6+
mod pipe;
67
mod shape;
78
mod theme;
89

@@ -24,6 +25,7 @@ use ratatui::{
2425
};
2526
use regex::Regex;
2627
use serde_json::Value;
28+
use std::io::IsTerminal;
2729
use std::{collections::HashMap, fs, io, path::PathBuf, time::Duration};
2830
use tui_textarea::TextArea;
2931

@@ -39,11 +41,17 @@ use theme::{
3941
#[derive(Parser, Debug)]
4042
#[command(name = "jetrocli", version)]
4143
struct Cli {
44+
/// Jetro expression. When stdin is piped/redirected, evaluation runs in
45+
/// non-interactive batch mode (no TUI) using a fast `from_bytes` path.
46+
/// Otherwise, this just pre-fills the expression input.
47+
#[arg(value_name = "EXPR")]
48+
expr_pos: Option<String>,
49+
4250
/// Load this JSON file into the left pane at startup.
4351
#[arg(short, long)]
4452
input: Option<PathBuf>,
4553

46-
/// Pre-fill the expression input.
54+
/// Pre-fill the expression input (alternative to the positional EXPR).
4755
#[arg(short, long)]
4856
expr: Option<String>,
4957

@@ -2086,6 +2094,21 @@ fn popup_move(app: &mut App, delta: i32) {
20862094

20872095
fn main() -> Result<()> {
20882096
let cli = Cli::parse();
2097+
2098+
let expr_seed = cli
2099+
.expr_pos
2100+
.clone()
2101+
.or_else(|| cli.expr.clone())
2102+
.unwrap_or_default();
2103+
2104+
// Pipe / batch mode: stdin is not a TTY (piped or redirected). Skip the
2105+
// TUI entirely and run a single evaluation through the fast `from_bytes`
2106+
// path. mmap'd when stdin is backed by a regular file.
2107+
if !io::stdin().is_terminal() {
2108+
let code = pipe::run(&expr_seed)?;
2109+
std::process::exit(code);
2110+
}
2111+
20892112
init_palette(cli.theme);
20902113

20912114
let json_seed = match cli.input {
@@ -2094,8 +2117,6 @@ fn main() -> Result<()> {
20942117
None => r#"{"store":{"books":[{"title":"Dune","price":12.99},{"title":"Foundation","price":9.99}]}}"#.to_string(),
20952118
};
20962119

2097-
let expr_seed = cli.expr.unwrap_or_default();
2098-
20992120
let mut app = App::new(json_seed, expr_seed);
21002121
run(&mut app)?;
21012122
Ok(())

src/pipe.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//! Non-interactive batch mode: evaluate a Jetro expression against stdin
2+
//! and print the result. Used when stdin is piped/redirected, so no TUI.
3+
//!
4+
//! Fast path: when stdin is backed by a regular file (e.g. `jetrocli EXPR
5+
//! < big.json`) we mmap it; otherwise drain the pipe into a `Vec`. The
6+
//! parsed document goes through `Jetro::from_bytes` so feature-gated
7+
//! lazy/SIMD parsing in jetro-core can kick in.
8+
//!
9+
//! Output is colorized like `jq` when stdout is a TTY (and `NO_COLOR` is
10+
//! unset). Piping into another program drops the ANSI escapes.
11+
12+
use anyhow::{anyhow, Context, Result};
13+
use serde_json::Value;
14+
use std::io::{self, Read, Write};
15+
use std::os::fd::AsRawFd;
16+
17+
pub fn run(expr: &str) -> Result<i32> {
18+
let bytes = read_stdin_bytes()?;
19+
if bytes.is_empty() {
20+
return Err(anyhow!("empty input on stdin"));
21+
}
22+
23+
let stdout = io::stdout();
24+
let mut out = stdout.lock();
25+
let color = use_color();
26+
27+
// No expression → pretty-print stdin as JSON.
28+
if expr.trim().is_empty() {
29+
let val: Value = serde_json::from_slice(&bytes).context("parse JSON from stdin")?;
30+
write_value(&mut out, &val, color)?;
31+
out.write_all(b"\n")?;
32+
return Ok(0);
33+
}
34+
35+
let doc = jetro_core::Jetro::from_bytes(bytes).context("parse JSON from stdin")?;
36+
37+
match doc.collect(expr) {
38+
Ok(v) => {
39+
let val = serde_json::to_value(&v).unwrap_or(Value::Null);
40+
write_value(&mut out, &val, color)?;
41+
out.write_all(b"\n")?;
42+
Ok(0)
43+
}
44+
Err(e) => {
45+
eprintln!("error: {e}");
46+
Ok(1)
47+
}
48+
}
49+
}
50+
51+
fn write_value<W: Write>(w: &mut W, v: &Value, color: bool) -> io::Result<()> {
52+
if color {
53+
write_colored(w, v, 0)
54+
} else {
55+
let s = serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
56+
w.write_all(s.as_bytes())
57+
}
58+
}
59+
60+
fn read_stdin_bytes() -> Result<Vec<u8>> {
61+
let stdin = io::stdin();
62+
let fd = stdin.as_raw_fd();
63+
64+
// mmap when fd 0 is a regular file (`< file.json`). For real pipes /
65+
// FIFOs this fails fast and we fall through to the streaming reader.
66+
if let Ok(mmap) = unsafe { memmap2::Mmap::map(fd) } {
67+
if !mmap.is_empty() {
68+
#[cfg(unix)]
69+
let _ = mmap.advise(memmap2::Advice::Sequential);
70+
return Ok(mmap.to_vec());
71+
}
72+
}
73+
74+
let mut buf = Vec::with_capacity(64 * 1024);
75+
stdin.lock().read_to_end(&mut buf).context("read stdin")?;
76+
Ok(buf)
77+
}
78+
79+
fn use_color() -> bool {
80+
if matches!(std::env::var("JETROCLI_COLOR").ok().as_deref(), Some("never") | Some("0")) {
81+
return false;
82+
}
83+
if std::env::var_os("NO_COLOR").is_some() {
84+
return false;
85+
}
86+
true
87+
}
88+
89+
// jq default palette: null;false;true;number;string;array;object;objectkey
90+
const C_NULL: &str = "\x1b[1;30m";
91+
const C_FALSE: &str = "\x1b[0;39m";
92+
const C_TRUE: &str = "\x1b[0;39m";
93+
const C_NUM: &str = "\x1b[0;39m";
94+
const C_STR: &str = "\x1b[0;32m";
95+
const C_PUNCT: &str = "\x1b[1;39m";
96+
const C_KEY: &str = "\x1b[34;1m";
97+
const C_RESET: &str = "\x1b[0m";
98+
const INDENT: &str = " ";
99+
100+
fn write_indent<W: Write>(w: &mut W, depth: usize) -> io::Result<()> {
101+
for _ in 0..depth {
102+
w.write_all(INDENT.as_bytes())?;
103+
}
104+
Ok(())
105+
}
106+
107+
fn write_colored<W: Write>(w: &mut W, v: &Value, depth: usize) -> io::Result<()> {
108+
match v {
109+
Value::Null => write!(w, "{}null{}", C_NULL, C_RESET),
110+
Value::Bool(true) => write!(w, "{}true{}", C_TRUE, C_RESET),
111+
Value::Bool(false) => write!(w, "{}false{}", C_FALSE, C_RESET),
112+
Value::Number(n) => write!(w, "{}{}{}", C_NUM, n, C_RESET),
113+
Value::String(s) => {
114+
let esc = serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into());
115+
write!(w, "{}{}{}", C_STR, esc, C_RESET)
116+
}
117+
Value::Array(arr) => {
118+
if arr.is_empty() {
119+
return write!(w, "{}[]{}", C_PUNCT, C_RESET);
120+
}
121+
write!(w, "{}[{}\n", C_PUNCT, C_RESET)?;
122+
for (i, item) in arr.iter().enumerate() {
123+
write_indent(w, depth + 1)?;
124+
write_colored(w, item, depth + 1)?;
125+
if i + 1 < arr.len() {
126+
write!(w, "{},{}", C_PUNCT, C_RESET)?;
127+
}
128+
w.write_all(b"\n")?;
129+
}
130+
write_indent(w, depth)?;
131+
write!(w, "{}]{}", C_PUNCT, C_RESET)
132+
}
133+
Value::Object(map) => {
134+
if map.is_empty() {
135+
return write!(w, "{}{{}}{}", C_PUNCT, C_RESET);
136+
}
137+
write!(w, "{}{{{}\n", C_PUNCT, C_RESET)?;
138+
let len = map.len();
139+
for (i, (k, val)) in map.iter().enumerate() {
140+
write_indent(w, depth + 1)?;
141+
let key_esc = serde_json::to_string(k).unwrap_or_else(|_| "\"\"".into());
142+
write!(w, "{}{}{}{}: {}", C_KEY, key_esc, C_RESET, C_PUNCT, C_RESET)?;
143+
write_colored(w, val, depth + 1)?;
144+
if i + 1 < len {
145+
write!(w, "{},{}", C_PUNCT, C_RESET)?;
146+
}
147+
w.write_all(b"\n")?;
148+
}
149+
write_indent(w, depth)?;
150+
write!(w, "{}}}{}", C_PUNCT, C_RESET)
151+
}
152+
}
153+
}

0 commit comments

Comments
 (0)