jarq 0.7.2

An interactive jq-like JSON query tool with a TUI
Documentation
use std::io::{self, IsTerminal, Read, Write};
use std::path::PathBuf;
use std::time::Duration;
use std::{fs, panic, process};

use clap::Parser;
use mimalloc::MiMalloc;

#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

use anyhow::{Context, Result};
use crossterm::{
    ExecutableCommand,
    event::{self, Event},
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::prelude::*;

const POLL_TIMEOUT: Duration = Duration::from_millis(50);

mod app;
mod error;
mod filter;
mod json;
mod loader;
mod text_buffer;
mod ui;
mod worker;

use app::{App, AppAction};
use loader::Loader;

/// An interactive jq-like JSON query tool with a TUI
#[derive(Parser)]
#[command(version, about)]
struct Args {
    /// Compact output (no pretty-printing)
    #[arg(short = 'c')]
    compact: bool,

    /// Raw output (strings without quotes)
    #[arg(short = 'r')]
    raw: bool,

    /// Slurp (read all inputs into array)
    #[arg(short = 's')]
    slurp: bool,

    /// Filter expression (if provided, runs non-interactively)
    filter: Option<String>,

    /// Input file (reads from stdin if not provided)
    file: Option<PathBuf>,
}

fn main() {
    if let Err(e) = run() {
        eprintln!("Error: {e:#}");
        process::exit(1);
    }
}

fn run() -> Result<()> {
    let args = Args::parse();
    let stdin_is_pipe = !io::stdin().is_terminal();
    let has_flags = args.compact || args.raw || args.slurp;

    match (&args.filter, &args.file) {
        (None, None) => {
            // No args: interactive mode from stdin, or show help
            if stdin_is_pipe {
                if has_flags {
                    let json_input = read_stdin()?;
                    let values = parse_json(&json_input, args.slurp)?;
                    run_noninteractive(".", &values, &args)
                } else {
                    run_interactive_async(Loader::spawn_stdin())
                }
            } else {
                // No input - clap will show help via --help, just show usage hint
                eprintln!("Usage: jarq [OPTIONS] [FILTER] [FILE]");
                eprintln!("       cat file.json | jarq [OPTIONS] [FILTER]");
                eprintln!();
                eprintln!("Run 'jarq --help' for more information.");
                Ok(())
            }
        }
        (Some(filter), None) => {
            // Filter provided, no file
            if stdin_is_pipe {
                let json_input = read_stdin()?;
                let values = parse_json(&json_input, args.slurp)?;
                run_noninteractive(filter, &values, &args)
            } else {
                // Treat filter as file path for interactive mode
                run_interactive_async(Loader::spawn_file(PathBuf::from(filter)))
            }
        }
        (None, Some(file)) => {
            // File provided, no filter - interactive or non-interactive with flags
            if has_flags {
                let json_input = fs::read_to_string(file)
                    .with_context(|| format!("Failed to read: {:?}", file))?;
                let values = parse_json(&json_input, args.slurp)?;
                run_noninteractive(".", &values, &args)
            } else {
                run_interactive_async(Loader::spawn_file(file.clone()))
            }
        }
        (Some(filter), Some(file)) => {
            // Both filter and file: non-interactive
            let json_input =
                fs::read_to_string(file).with_context(|| format!("Failed to read: {:?}", file))?;
            let values = parse_json(&json_input, args.slurp)?;
            run_noninteractive(filter, &values, &args)
        }
    }
}

fn run_noninteractive(
    filter_text: &str,
    values: &[simd_json::OwnedValue],
    args: &Args,
) -> Result<()> {
    let results =
        filter::evaluate_all(filter_text, values).map_err(|e| anyhow::anyhow!("{}", e))?;

    let stdout = io::stdout();
    let use_color = stdout.is_terminal() && !args.compact && !args.raw;
    let mut handle = stdout.lock();

    for value in results {
        if args.raw {
            // Raw mode: output strings without quotes, other types as JSON
            if let Some(s) = json::to_string_raw(&value) {
                writeln!(handle, "{}", s)?;
            } else if args.compact {
                writeln!(handle, "{}", json::to_string_compact(&value))?;
            } else {
                writeln!(handle, "{}", json::to_string_pretty(&value))?;
            }
        } else if args.compact {
            writeln!(handle, "{}", json::to_string_compact(&value))?;
        } else if use_color {
            writeln!(handle, "{}", json::to_string_pretty_colored(&value))?;
        } else {
            writeln!(handle, "{}", 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<simd_json::OwnedValue>) -> 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, slurp: bool) -> Result<Vec<simd_json::OwnedValue>> {
    // First, try parsing as a single JSON value
    let mut input_bytes = input.as_bytes().to_vec();
    if let Ok(value) = simd_json::to_owned_value(&mut input_bytes) {
        if slurp {
            // Wrap single value in array
            return Ok(vec![simd_json::OwnedValue::Array(Box::new(vec![value]))]);
        }
        return Ok(vec![value]);
    }

    // Fall back to JSONL: parse each non-empty line
    let mut values = Vec::new();
    for (line_num, line) in input.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let mut line_bytes = trimmed.as_bytes().to_vec();
        let value = simd_json::to_owned_value(&mut line_bytes)
            .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");
    }

    if slurp {
        // Wrap all values in a single array
        return Ok(vec![simd_json::OwnedValue::Array(Box::new(values))]);
    }

    Ok(values)
}

fn read_stdin() -> Result<String> {
    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)
}