use std::{
fs::File,
io::{self, Read, Write},
path::PathBuf,
sync::Arc,
};
use anyhow::anyhow;
use clap::Parser;
use promkit_widgets::{
core::{
crossterm,
grapheme::StyledGraphemes,
render::{Renderer, SharedRenderer},
},
listbox::{self, Listbox},
spinner::{self, Spinner},
text_editor::{self, TextEditor},
};
use tokio::sync::{mpsc, RwLock};
mod completion;
use completion::{CompletionAction, CompletionNavigator};
mod config;
use config::{Config, DEFAULT_CONFIG};
mod context;
use context::{Index, SharedContext};
mod event_dispatcher;
mod guide;
use guide::GuideAction;
mod json;
mod json_viewer;
mod query_editor;
use query_editor::{QueryEditor, QueryEditorAction};
mod runtime_tasks;
mod stdout_redirect;
use stdout_redirect::StdoutRedirect;
mod utils;
#[derive(Parser)]
#[command(
name = "jnv",
version,
help_template = "
{about}
Usage: {usage}
Examples:
- Read from a file:
{bin} data.json
- Read from standard input:
cat data.json | {bin}
Arguments:
{positionals}
Options:
{options}
"
)]
pub struct Args {
pub input: Option<PathBuf>,
#[arg(short = 'c', long = "config", help = "Path to the configuration file.")]
pub config_file: Option<PathBuf>,
#[arg(
long = "default-filter",
help = "Default jq filter to apply to the input data",
long_help = "
Sets the default jq filter to apply to the input data.
The filter is applied when the interface is first loaded.
"
)]
default_filter: Option<String>,
#[arg(
long = "write-to-stdout",
help = "Write the current JSON result to stdout when exiting"
)]
write_to_stdout: bool,
}
fn parse_input(args: &Args) -> anyhow::Result<String> {
let mut ret = String::new();
match &args.input {
None => {
io::stdin().read_to_string(&mut ret)?;
}
Some(path) => {
if path == &PathBuf::from("-") {
io::stdin().read_to_string(&mut ret)?;
} else {
File::open(path)?.read_to_string(&mut ret)?;
}
}
}
Ok(ret)
}
fn ensure_file_exists(path: &PathBuf) -> anyhow::Result<()> {
if path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| anyhow!("Failed to create directory: {e}"))?;
}
std::fs::File::create(path)?.write_all(DEFAULT_CONFIG.as_bytes())?;
Ok(())
}
fn determine_config_file(config_path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
if let Some(path) = config_path {
ensure_file_exists(&path)?;
return Ok(path);
}
let default_path = dirs::config_dir()
.ok_or_else(|| anyhow!("Failed to determine the configuration directory"))?
.join("jnv")
.join("config.toml");
ensure_file_exists(&default_path)?;
Ok(default_path)
}
struct TerminalCleanupGuard;
impl Drop for TerminalCleanupGuard {
fn drop(&mut self) {
let _ = crossterm::execute!(
io::stdout(),
crossterm::cursor::Show,
crossterm::event::DisableMouseCapture
);
let _ = crossterm::terminal::disable_raw_mode();
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let input = parse_input(&args)?;
let input: &'static str = Box::leak(input.into_boxed_str());
let config = determine_config_file(args.config_file)
.and_then(|config_file| {
std::fs::read_to_string(&config_file)
.map_err(|e| anyhow!("Failed to read configuration file: {e}"))
})
.and_then(|content| Config::load_from(&content))
.unwrap_or_else(|_e| {
Config::load_from(DEFAULT_CONFIG).expect("Failed to load default configuration")
});
crossterm::terminal::enable_raw_mode()?;
let _terminal_cleanup_guard = TerminalCleanupGuard;
crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
let (shared_suggestions, completion_loader_task) = completion::spawn_initialize(
input,
config.json.max_streams,
config.completion.search_load_chunk_size,
);
let completion_navigator = CompletionNavigator::new(
shared_suggestions,
listbox::State {
listbox: Listbox::default(),
config: config.completion.listbox,
},
config.completion.search_result_chunk_size,
);
let query_editor = QueryEditor::new(
text_editor::State {
texteditor: if let Some(ref filter) = args.default_filter {
TextEditor::new(filter)
} else {
Default::default()
},
history: Default::default(),
config: config.editor.on_focus.clone(),
},
config.editor.on_focus,
config.editor.on_defocus,
config.keybinds.on_editor.clone(),
);
let mut stdout_redirect = StdoutRedirect::try_new_for_tui(args.write_to_stdout)?;
let terminal_size = crossterm::terminal::size()?;
let renderer = SharedRenderer::new(
Renderer::try_new_with_graphemes(
[
(
Index::QueryEditor,
query_editor.create_graphemes(terminal_size.0, terminal_size.1),
),
(Index::Guide, StyledGraphemes::default()),
(Index::Completion, StyledGraphemes::default()),
(Index::JsonViewer, StyledGraphemes::default()),
]
.into_iter(),
true,
)
.await?,
);
let ctx = SharedContext::new(terminal_size);
let load_for_json_viewer = json_viewer::initialize(
input,
config.json,
config.keybinds.on_json_viewer.clone(),
ctx.clone(),
renderer.clone(),
);
let spinner_task = tokio::spawn({
let shared_renderer = renderer.clone();
let ctx = ctx.clone();
async move {
let spinner = Spinner::default().duration(config.reactivity_control.spin_duration);
let _ = spinner::run(&spinner, ctx, Index::JsonViewer, shared_renderer).await;
}
});
let (debounce_query_tx, last_query_rx, query_debouncer) =
utils::setup_debouncer::<String>(config.reactivity_control.query_debounce_duration);
if let Some(default_filter) = args.default_filter {
debounce_query_tx.send(default_filter).await?;
}
let (debounce_resize_tx, last_resize_rx, resize_debouncer) =
utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration);
let (editor_action_tx, editor_action_rx) = mpsc::channel::<QueryEditorAction>(1);
let (completion_action_tx, completion_action_rx) = mpsc::channel::<CompletionAction>(1);
let (json_viewer_action_tx, json_viewer_action_rx) =
mpsc::channel::<json_viewer::ViewerAction>(8);
let (guide_action_tx, guide_action_rx) = mpsc::channel::<GuideAction>(8);
let event_dispacher_task = event_dispatcher::spawn_terminal_event_dispatch_task(
ctx.clone(),
config.keybinds.clone(),
debounce_resize_tx,
editor_action_tx.clone(),
completion_action_tx.clone(),
json_viewer_action_tx.clone(),
guide_action_tx.clone(),
);
let query_change_forward_task = runtime_tasks::spawn_query_change_forward_task(
last_query_rx,
json_viewer_action_tx.clone(),
);
let guide_task = guide::start_guide_task(
guide_action_rx,
renderer.clone(),
ctx.clone(),
config.no_hint,
);
let shared_query_editor = Arc::new(RwLock::new(query_editor));
let shared_completion_navigator = Arc::new(RwLock::new(completion_navigator));
let query_editor_task = query_editor::start_query_editor_task(
editor_action_rx,
ctx.clone(),
shared_query_editor.clone(),
renderer.clone(),
completion_action_tx.clone(),
debounce_query_tx.clone(),
guide_action_tx.clone(),
);
let completion_navigator_task = completion::start_completion_task(
completion_action_rx,
ctx.clone(),
shared_completion_navigator.clone(),
renderer.clone(),
editor_action_tx.clone(),
guide_action_tx.clone(),
config.keybinds.on_editor.on_completion.clone(),
);
let shared_json_viewer = load_for_json_viewer.await?;
let json_viewer_task = json_viewer::start_viewer_task(
json_viewer_action_rx,
ctx.clone(),
shared_json_viewer.clone(),
renderer.clone(),
guide_action_tx.clone(),
);
let resize_render_task = runtime_tasks::spawn_resize_render_task(
last_resize_rx,
ctx.clone(),
renderer.clone(),
shared_query_editor.clone(),
shared_completion_navigator.clone(),
shared_json_viewer.clone(),
guide_action_tx.clone(),
);
let maybe_output_result: anyhow::Result<Option<String>> = match event_dispacher_task.await {
Ok(Ok(())) if args.write_to_stdout => {
let runtime = shared_json_viewer.lock().await;
Ok(Some(runtime.formatted_content()))
}
Ok(Ok(())) => Ok(None),
Ok(Err(err)) => Err(err),
Err(err) => Err(err.into()),
};
spinner_task.abort();
query_debouncer.abort();
resize_debouncer.abort();
completion_loader_task.abort();
query_change_forward_task.abort();
resize_render_task.abort();
guide_task.abort();
query_editor_task.abort();
completion_navigator_task.abort();
json_viewer_task.abort();
let _ = spinner_task.await;
let _ = query_debouncer.await;
let _ = resize_debouncer.await;
let _ = completion_loader_task.await;
let _ = query_change_forward_task.await;
let _ = resize_render_task.await;
let _ = guide_task.await;
let _ = query_editor_task.await;
let _ = completion_navigator_task.await;
let _ = json_viewer_task.await;
stdout_redirect.restore()?;
if let Some(output) = maybe_output_result? {
let mut stdout = io::stdout();
stdout.write_all(output.as_bytes())?;
if !output.ends_with('\n') {
stdout.write_all(b"\n")?;
}
}
Ok(())
}