use std::io::{self, IsTerminal, Read, Write};
use std::path::PathBuf;
use std::time::Duration;
use std::{env, fs, panic, process};
use anyhow::{Context, Result};
use crossterm::{
event::{self, Event},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::prelude::*;
const POLL_TIMEOUT: Duration = Duration::from_millis(50);
mod app;
mod error;
mod filter;
mod loader;
mod text_buffer;
mod ui;
mod worker;
use app::{App, AppAction};
use loader::Loader;
fn main() {
if let Err(e) = run() {
eprintln!("Error: {e:#}");
process::exit(1);
}
}
fn run() -> Result<()> {
let args: Vec<String> = env::args().skip(1).collect();
let (filter_arg, file_arg) = parse_args(&args);
if let Some(filter_text) = filter_arg {
let json_input = read_input(file_arg)?;
let values = parse_json(&json_input)?;
return run_noninteractive(filter_text, &values);
}
let loader = match file_arg {
Some(path) => Loader::spawn_file(PathBuf::from(path)),
None if !io::stdin().is_terminal() => Loader::spawn_stdin(),
None => {
eprintln!("Usage: jarq [FILTER] [FILE]");
eprintln!(" cat file.json | jarq [FILTER]");
eprintln!();
eprintln!("If FILTER is provided, runs non-interactively.");
eprintln!("Otherwise, opens an interactive TUI.");
return Ok(());
}
};
run_interactive_async(loader)
}
fn parse_args(args: &[String]) -> (Option<&str>, Option<&str>) {
match args.len() {
0 => (None, None),
1 => {
if looks_like_filter(&args[0]) {
(Some(&args[0]), None)
} else {
(None, Some(&args[0]))
}
}
_ => {
(Some(&args[0]), Some(&args[1]))
}
}
}
fn looks_like_filter(s: &str) -> bool {
let s = s.trim();
if s.contains('/') || s.ends_with(".json") {
return false;
}
if s.contains('|') {
return true;
}
s.starts_with('.')
|| s.starts_with('[')
|| s.starts_with('{')
|| filter::builtins::Builtin::from_name(s).is_some()
}
fn run_noninteractive(filter_text: &str, values: &[serde_json::Value]) -> Result<()> {
let results = filter::evaluate_all(filter_text, values)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let stdout = io::stdout();
let mut handle = stdout.lock();
for value in results {
writeln!(handle, "{}", serde_json::to_string_pretty(&value)?)?;
}
Ok(())
}
fn run_interactive_async(loader: Loader) -> Result<()> {
let mut app = App::new_loading(loader);
run_tui_loop(&mut app)
}
#[allow(dead_code)]
fn run_interactive(values: Vec<serde_json::Value>) -> Result<()> {
let mut app = App::new(values);
run_tui_loop(&mut app)
}
fn run_tui_loop(app: &mut App) -> Result<()> {
let mut stdout = io::stdout();
enable_raw_mode().context("Failed to enable raw mode")?;
stdout
.execute(EnterAlternateScreen)
.context("Failed to enter alternate screen")?;
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = io::stdout().execute(LeaveAlternateScreen);
original_hook(panic_info);
}));
let mut terminal = Terminal::new(CrosstermBackend::new(stdout))
.context("Failed to create terminal")?;
loop {
terminal.draw(|frame| ui::render(frame, app))?;
if event::poll(POLL_TIMEOUT).context("Failed to poll event")? {
match event::read().context("Failed to read event")? {
Event::Key(key) => match app.handle_key(key) {
AppAction::Quit => break,
AppAction::Continue => {}
},
Event::Resize(_, _) => {}
_ => {}
}
}
app.tick();
}
disable_raw_mode().context("Failed to disable raw mode")?;
io::stdout()
.execute(LeaveAlternateScreen)
.context("Failed to leave alternate screen")?;
Ok(())
}
fn parse_json(input: &str) -> Result<Vec<serde_json::Value>> {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(input) {
return Ok(vec![value]);
}
let mut values = Vec::new();
for (line_num, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value = serde_json::from_str(trimmed)
.with_context(|| format!("Invalid JSON on line {}", line_num + 1))?;
values.push(value);
}
if values.is_empty() {
anyhow::bail!("No valid JSON found in input");
}
Ok(values)
}
fn read_input(file_arg: Option<&str>) -> Result<String> {
if let Some(path) = file_arg {
fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))
} else if !io::stdin().is_terminal() {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.context("Failed to read from stdin")?;
if input.is_empty() {
anyhow::bail!("Empty input is not valid JSON");
}
Ok(input)
} else {
eprintln!("Usage: jarq [FILTER] [FILE]");
eprintln!(" cat file.json | jarq [FILTER]");
eprintln!();
eprintln!("If FILTER is provided, runs non-interactively.");
eprintln!("Otherwise, opens an interactive TUI.");
process::exit(0);
}
}