jarq 0.2.0

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::{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();

    // Parse arguments
    let (filter_arg, file_arg) = parse_args(&args);

    // Non-interactive mode: still synchronous
    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);
    }

    // Interactive mode: async loading
    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 => {
            // Could be a filter or a file
            if looks_like_filter(&args[0]) {
                (Some(&args[0]), None)
            } else {
                (None, Some(&args[0]))
            }
        }
        _ => {
            // First is filter, second is file
            (Some(&args[0]), Some(&args[1]))
        }
    }
}

fn looks_like_filter(s: &str) -> bool {
    let s = s.trim();

    // If it looks like a file path, it's not a filter
    if s.contains('/') || s.ends_with(".json") {
        return false;
    }

    // Contains pipe - definitely a filter
    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>> {
    // First, try parsing as a single JSON value
    if let Ok(value) = serde_json::from_str::<serde_json::Value>(input) {
        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 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);
    }
}