mod scanner;
mod search;
mod source;
use scanner::{container_empty, decode_str, skip_value, skip_ws, value_kind, Cursor, Kind, RawChild};
use search::Search;
use source::Source;
use memmap2::Mmap;
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEventKind},
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use std::{
collections::HashSet,
fs::File,
io::{IsTerminal, Read},
sync::{
mpsc::{self, Receiver, TryRecvError},
Arc,
},
thread,
time::{Duration, Instant},
};
const PREVIEW_ITEMS: usize = 5;
const PREVIEW_WIDTH: usize = 64;
const C_KEY: Color = Color::Cyan; const C_INDEX: Color = Color::DarkGray; const C_STR: Color = Color::Green; const C_NUM: Color = Color::Yellow; const C_BOOL: Color = Color::Magenta; const C_PUNCT: Color = Color::DarkGray;
fn value_color(kind: Kind) -> Color {
match kind {
Kind::Str => C_STR,
Kind::Number => C_NUM,
Kind::Bool => C_BOOL,
Kind::Null | Kind::Object | Kind::Array => C_PUNCT,
}
}
struct Node {
label: String,
start: usize,
end: usize,
kind: Kind,
is_index: bool,
jsonl: bool,
has_children: bool,
expanded: bool,
done: bool,
children: Vec<Node>,
cursor: Option<Cursor>,
}
impl Node {
fn is_container(&self) -> bool {
matches!(self.kind, Kind::Object | Kind::Array)
}
fn make_cursor(&self) -> Cursor {
if self.jsonl {
Cursor::lines(self.start, self.end)
} else {
Cursor::new(self.start, self.end, matches!(self.kind, Kind::Array))
}
}
fn from_raw(rc: RawChild, b: &[u8]) -> Node {
let is_cont = matches!(rc.kind, Kind::Object | Kind::Array);
let has = is_cont && !container_empty(b, rc.start, rc.end);
Node {
label: rc.label,
start: rc.start,
end: rc.end,
kind: rc.kind,
is_index: rc.is_index,
jsonl: false,
has_children: has,
expanded: false,
done: false,
children: Vec::new(),
cursor: None,
}
}
fn toggle(&mut self) {
if !self.is_container() || !self.has_children {
return;
}
self.expanded = !self.expanded;
if self.expanded && self.cursor.is_none() && self.children.is_empty() && !self.done {
self.cursor = Some(self.make_cursor());
}
}
fn ensure_child(&mut self, b: &[u8], i: usize) {
while self.children.len() <= i {
let nx = match self.cursor.as_mut() {
Some(c) => c.next(b),
None => None,
};
match nx {
Some(rc) => self.children.push(Node::from_raw(rc, b)),
None => {
self.done = true;
break;
}
}
}
}
fn preview(&self, b: &[u8]) -> String {
match self.kind {
Kind::Object | Kind::Array => {
let arr = matches!(self.kind, Kind::Array);
if !self.has_children {
if arr { "[]".into() } else { "{}".into() }
} else if self.expanded {
if arr { "[".into() } else { "{".into() }
} else {
self.collapsed_preview(b)
}
}
Kind::Str => format!("\"{}\"", truncate(&decode_str(b, self.start, self.end), 70)),
_ => truncate(&String::from_utf8_lossy(&b[self.start..self.end]), 70),
}
}
fn collapsed_preview(&self, b: &[u8]) -> String {
let arr = matches!(self.kind, Kind::Array);
let (open, close) = if arr { ("[", "]") } else { ("{", "}") };
let mut cur = self.make_cursor();
let mut parts: Vec<String> = Vec::new();
let mut more = false;
loop {
if parts.len() == PREVIEW_ITEMS {
more = cur.next(b).is_some(); break;
}
match cur.next(b) {
Some(rc) => {
let v = brief(b, &rc);
parts.push(if arr { v } else { format!("{}: {}", rc.label, v) });
}
None => break,
}
}
if parts.is_empty() {
return format!("{open}{close}");
}
let mut body = parts.join(", ");
if more {
body.push_str(", …");
}
truncate(&format!("{open} {body} {close}"), PREVIEW_WIDTH)
}
}
fn brief(b: &[u8], rc: &RawChild) -> String {
match rc.kind {
Kind::Object => {
if container_empty(b, rc.start, rc.end) { "{}".into() } else { "{…}".into() }
}
Kind::Array => {
if container_empty(b, rc.start, rc.end) { "[]".into() } else { "[…]".into() }
}
Kind::Str => format!("\"{}\"", truncate(&decode_str(b, rc.start, rc.end), 24)),
_ => truncate(&String::from_utf8_lossy(&b[rc.start..rc.end]), 24),
}
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let t: String = s.chars().take(n).collect();
format!("{t}…")
}
}
struct Row {
depth: usize,
label: String,
value: String,
kind: Kind,
is_index: bool,
has_children: bool,
expanded: bool,
path: Vec<usize>,
}
fn flatten(node: &mut Node, b: &[u8], depth: usize, budget: usize, out: &mut Vec<Row>, path: &mut Vec<usize>) {
if out.len() >= budget {
return;
}
out.push(Row {
depth,
label: node.label.clone(),
value: node.preview(b),
kind: node.kind,
is_index: node.is_index,
has_children: node.has_children,
expanded: node.expanded,
path: path.clone(),
});
if node.expanded && node.is_container() {
let mut i = 0;
while out.len() < budget {
node.ensure_child(b, i);
if i >= node.children.len() {
break; }
path.push(i);
flatten(&mut node.children[i], b, depth + 1, budget, out, path);
path.pop();
i += 1;
}
}
}
fn get<'a>(mut n: &'a Node, path: &[usize]) -> &'a Node {
for &i in path {
n = &n.children[i];
}
n
}
fn get_mut<'a>(mut n: &'a mut Node, path: &[usize]) -> &'a mut Node {
for &i in path {
n = &mut n.children[i];
}
n
}
fn expand_to(n: &mut Node, b: &[u8], path: &[usize]) {
if path.is_empty() {
return;
}
if n.is_container() && n.has_children && !n.expanded {
n.toggle();
}
n.ensure_child(b, path[0]);
if path[0] < n.children.len() {
expand_to(&mut n.children[path[0]], b, &path[1..]);
}
}
#[derive(PartialEq)]
enum Mode {
Normal,
Search,
}
enum KeyOutcome {
Continue,
Quit,
Relaunch,
}
struct App {
root: Node,
name: String,
focus: usize,
scroll: usize,
rows: Vec<Row>,
mode: Mode,
query: String,
search: Option<Search>,
match_idx: usize,
match_set: HashSet<Vec<usize>>,
indexed: usize,
want_path: Option<Vec<usize>>,
}
fn make_root(b: &[u8], name: &str, jsonl: bool) -> Node {
let (start, kind, has) = if jsonl {
(0, Kind::Array, skip_ws(b, 0, b.len()) < b.len())
} else {
let rstart = skip_ws(b, 0, b.len());
if rstart >= b.len() {
(rstart, Kind::Null, false)
} else {
let k = value_kind(b, rstart);
let cont = matches!(k, Kind::Object | Kind::Array);
(rstart, k, cont && !container_empty(b, rstart, b.len()))
}
};
let mut root = Node {
label: name.into(),
start,
end: b.len(), kind,
is_index: false,
jsonl,
has_children: has,
expanded: false,
done: false,
children: Vec::new(),
cursor: None,
};
if has {
root.toggle(); }
root
}
fn collect_expanded(node: &Node, path: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
if node.expanded {
out.push(path.clone());
for (i, ch) in node.children.iter().enumerate() {
path.push(i);
collect_expanded(ch, path, out);
path.pop();
}
}
}
fn set_expanded(root: &mut Node, b: &[u8], path: &[usize]) {
let mut n = root;
for &idx in path {
if n.is_container() && n.has_children && !n.expanded {
n.toggle();
}
n.ensure_child(b, idx);
if idx >= n.children.len() {
return; }
n = &mut n.children[idx];
}
if n.is_container() && n.has_children && !n.expanded {
n.toggle();
}
}
impl App {
fn new(b: &[u8], path: &str, jsonl: bool) -> App {
let name = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "ROOT".into());
let root = make_root(b, &name, jsonl);
App {
root,
name,
focus: 0,
scroll: 0,
rows: Vec::new(),
mode: Mode::Normal,
query: String::new(),
search: None,
match_idx: 0,
match_set: HashSet::new(),
indexed: 0,
want_path: None,
}
}
fn rebuild(&mut self, b: &[u8], jsonl: bool) {
let mut expanded = Vec::new();
collect_expanded(&self.root, &mut Vec::new(), &mut expanded);
let focus_path = self.rows.get(self.focus).map(|r| r.path.clone());
self.root = make_root(b, &self.name, jsonl);
for path in &expanded {
set_expanded(&mut self.root, b, path);
}
if self.want_path.is_none() {
self.want_path = focus_path;
}
}
fn toggle_focus(&mut self) {
if self.rows.is_empty() {
return;
}
let path = self.rows[self.focus].path.clone();
get_mut(&mut self.root, &path).toggle();
}
fn relaunch(&mut self, mmap: &Arc<Source>) {
if let Some(old) = self.search.take() {
old.cancel(); }
self.match_set.clear();
self.indexed = 0;
self.match_idx = 0;
self.want_path = None;
if self.query.is_empty() {
return;
}
self.search = Some(Search::spawn(Arc::clone(mmap), self.query.clone(), self.root.jsonl));
}
fn pump_search(&mut self) {
if let Some(s) = self.search.as_mut() {
s.drain();
}
let n = self.search.as_ref().map_or(0, |s| s.matches.len());
while self.indexed < n {
let p = self.search.as_ref().unwrap().matches[self.indexed].clone();
self.match_set.insert(p);
self.indexed += 1;
}
}
fn step_match(&mut self, dir: i32, b: &[u8]) {
let n = self.search.as_ref().map_or(0, |s| s.matches.len());
if n == 0 {
return;
}
self.match_idx = if dir >= 0 {
(self.match_idx + 1) % n
} else {
(self.match_idx + n - 1) % n
};
let path = self.search.as_ref().unwrap().matches[self.match_idx].clone();
self.jump_to(&path, b);
}
fn jump_to(&mut self, path: &[usize], b: &[u8]) {
expand_to(&mut self.root, b, path);
self.want_path = Some(path.to_vec());
}
fn clear_search(&mut self) {
if let Some(s) = self.search.take() {
s.cancel();
}
self.query.clear();
self.match_set.clear();
self.indexed = 0;
self.match_idx = 0;
self.want_path = None;
}
fn collapse_or_parent(&mut self) {
if self.rows.is_empty() {
return;
}
let path = self.rows[self.focus].path.clone();
let (expanded, cont) = {
let n = get(&self.root, &path);
(n.expanded, n.is_container())
};
if expanded && cont {
get_mut(&mut self.root, &path).toggle();
} else if !path.is_empty() {
let pp = &path[..path.len() - 1];
if let Some(idx) = self.rows.iter().position(|r| r.path.as_slice() == pp) {
self.focus = idx;
}
}
}
}
fn ui(f: &mut Frame, app: &App, h: usize, streaming: bool) {
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
let title = if streaming {
format!(" {} {}/{}+ ⟳ streaming", app.name, app.focus + 1, app.rows.len())
} else {
format!(" {} {}/{}+", app.name, app.focus + 1, app.rows.len())
};
f.render_widget(
Paragraph::new(title).style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
chunks[0],
);
let cur_match = app
.search
.as_ref()
.and_then(|s| s.matches.get(app.match_idx));
let mut lines = Vec::new();
let end = (app.scroll + h).min(app.rows.len());
for i in app.scroll..end {
let r = &app.rows[i];
let marker = if r.has_children {
if r.expanded {
"▼"
} else {
"▶"
}
} else {
" "
};
let indent = " ".repeat(r.depth);
let line = if i == app.focus {
let text = format!("{indent}{marker} {}: {}", r.label, r.value);
Line::from(Span::styled(text, Style::default().add_modifier(Modifier::REVERSED)))
} else if cur_match == Some(&r.path) || app.match_set.contains(&r.path) {
let text = format!("{indent}{marker} {}: {}", r.label, r.value);
let mut st = Style::default().fg(Color::Yellow);
if cur_match == Some(&r.path) {
st = st.add_modifier(Modifier::BOLD);
}
Line::from(Span::styled(text, st))
} else {
let key_color = if r.is_index { C_INDEX } else { C_KEY };
Line::from(vec![
Span::raw(indent),
Span::styled(marker, Style::default().fg(C_PUNCT)),
Span::raw(" "),
Span::styled(r.label.clone(), Style::default().fg(key_color)),
Span::styled(": ", Style::default().fg(C_PUNCT)),
Span::styled(r.value.clone(), Style::default().fg(value_color(r.kind))),
])
};
lines.push(line);
}
f.render_widget(Paragraph::new(lines), chunks[1]);
let footer = if app.mode == Mode::Search {
let count = app.search.as_ref().map_or(0, |s| s.matches.len());
let more = match &app.search {
Some(s) if !s.finished => "+",
_ => "",
};
Span::styled(
format!(" /{} {}{} matches · enter go · esc cancel", app.query, count, more),
Style::default().fg(Color::Yellow),
)
} else if app.search.is_some() {
let count = app.search.as_ref().map_or(0, |s| s.matches.len());
Span::styled(
format!(
" /{} {}/{} · n/N next/prev · / search · q quit",
app.query,
app.match_idx + 1,
count
),
Style::default().fg(Color::DarkGray),
)
} else {
Span::styled(
" ↑/↓ move · enter/→ expand · ← collapse · / search · g top · q quit",
Style::default().fg(Color::DarkGray),
)
};
f.render_widget(Paragraph::new(Line::from(footer)), chunks[2]);
}
fn render_frame(
term: &mut ratatui::DefaultTerminal,
app: &mut App,
b: &[u8],
h: usize,
streaming: bool,
) -> std::io::Result<()> {
let budget = match &app.want_path {
Some(p) => p.iter().sum::<usize>() + p.len() + h + 64,
None => (app.scroll + h + 64).max(app.focus + 64),
};
app.rows.clear();
let mut path = Vec::new();
flatten(&mut app.root, b, 0, budget, &mut app.rows, &mut path);
if let Some(p) = app.want_path.take() {
if let Some(idx) = app.rows.iter().position(|r| r.path == p) {
app.focus = idx;
}
}
if app.focus >= app.rows.len() {
app.focus = app.rows.len().saturating_sub(1);
}
if app.focus < app.scroll {
app.scroll = app.focus;
}
if app.focus >= app.scroll + h {
app.scroll = app.focus + 1 - h;
}
term.draw(|f| ui(f, app, h, streaming))?;
Ok(())
}
fn process_key(app: &mut App, k: ratatui::crossterm::event::KeyEvent, b: &[u8], h: usize) -> KeyOutcome {
if app.mode == Mode::Search {
match k.code {
KeyCode::Esc => {
app.mode = Mode::Normal;
app.clear_search();
}
KeyCode::Enter => {
app.mode = Mode::Normal;
if app.search.as_ref().is_some_and(|s| !s.matches.is_empty()) {
let first = app.search.as_ref().unwrap().matches[0].clone();
app.match_idx = 0;
app.jump_to(&first, b);
}
}
KeyCode::Backspace => {
app.query.pop();
return KeyOutcome::Relaunch;
}
KeyCode::Char(c) => {
app.query.push(c);
return KeyOutcome::Relaunch;
}
_ => {}
}
return KeyOutcome::Continue;
}
match k.code {
KeyCode::Char('q') | KeyCode::Esc => return KeyOutcome::Quit,
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.query.clear();
return KeyOutcome::Relaunch;
}
KeyCode::Char('n') => app.step_match(1, b),
KeyCode::Char('N') => app.step_match(-1, b),
KeyCode::Down | KeyCode::Char('j') => app.focus += 1,
KeyCode::Up | KeyCode::Char('k') => app.focus = app.focus.saturating_sub(1),
KeyCode::PageDown => app.focus += h,
KeyCode::PageUp => app.focus = app.focus.saturating_sub(h),
KeyCode::Home | KeyCode::Char('g') => {
app.focus = 0;
app.scroll = 0;
}
KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Right => app.toggle_focus(),
KeyCode::Left => app.collapse_or_parent(),
_ => {}
}
KeyOutcome::Continue
}
fn term_rows() -> std::io::Result<usize> {
let (_, th) = ratatui::crossterm::terminal::size()?;
Ok((th.saturating_sub(2) as usize).max(1))
}
fn pump_input(app: &mut App, b: &[u8], h: usize, poll_ms: u64) -> std::io::Result<KeyOutcome> {
if !event::poll(Duration::from_millis(poll_ms))? {
return Ok(KeyOutcome::Continue);
}
let Event::Key(k) = event::read()? else {
return Ok(KeyOutcome::Continue);
};
if k.kind != KeyEventKind::Press {
return Ok(KeyOutcome::Continue);
}
Ok(process_key(app, k, b, h))
}
fn run(
term: &mut ratatui::DefaultTerminal,
app: &mut App,
b: &[u8],
mmap: &Arc<Source>,
) -> std::io::Result<()> {
loop {
app.pump_search();
let h = term_rows()?;
render_frame(term, app, b, h, false)?;
match pump_input(app, b, h, 100)? {
KeyOutcome::Quit => return Ok(()),
KeyOutcome::Relaunch => app.relaunch(mmap),
KeyOutcome::Continue => {}
}
}
}
const STREAM_REBUILD_MS: u128 = 100;
fn run_stream(
term: &mut ratatui::DefaultTerminal,
app: &mut App,
buf: &mut Vec<u8>,
jsonl: &mut bool,
rx: Receiver<Vec<u8>>,
) -> std::io::Result<()> {
let mut dirty = false; let mut done = false; let mut last_build = Instant::now();
loop {
loop {
match rx.try_recv() {
Ok(chunk) => {
buf.extend_from_slice(&chunk);
dirty = true;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
done = true;
break;
}
}
}
if !*jsonl && sniff_multi(buf) {
*jsonl = true;
}
if dirty && (done || last_build.elapsed().as_millis() >= STREAM_REBUILD_MS) {
app.rebuild(buf, *jsonl);
dirty = false;
last_build = Instant::now();
}
app.pump_search();
let h = term_rows()?;
let outcome = {
let b: &[u8] = buf;
render_frame(term, app, b, h, !done)?;
pump_input(app, b, h, 100)?
};
match outcome {
KeyOutcome::Quit => return Ok(()),
KeyOutcome::Relaunch => {
let snap = Arc::new(Source::Buffered(buf.clone()));
app.relaunch(&snap);
}
KeyOutcome::Continue => {}
}
}
}
fn sniff_multi(b: &[u8]) -> bool {
let i = skip_ws(b, 0, b.len());
if i >= b.len() {
return false;
}
let after = skip_value(b, i, b.len());
skip_ws(b, after, b.len()) < b.len()
}
#[cfg(unix)]
fn reattach_terminal_to_stdin() {
unsafe {
for fd in [libc::STDOUT_FILENO, libc::STDERR_FILENO] {
if libc::isatty(fd) != 1 {
continue;
}
let name = libc::ttyname(fd);
if name.is_null() {
continue;
}
let f = libc::open(name, libc::O_RDWR);
if f >= 0 {
libc::dup2(f, libc::STDIN_FILENO);
if f != libc::STDIN_FILENO {
libc::close(f);
}
return;
}
}
}
}
#[cfg(unix)]
fn take_pipe_reader() -> Box<dyn Read + Send> {
use std::os::fd::FromRawFd;
unsafe {
let dup = libc::dup(libc::STDIN_FILENO);
reattach_terminal_to_stdin();
Box::new(File::from_raw_fd(dup))
}
}
#[cfg(not(unix))]
fn take_pipe_reader() -> Box<dyn Read + Send> {
Box::new(std::io::stdin())
}
fn spawn_reader(mut reader: Box<dyn Read + Send>) -> Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut buf = [0u8; 64 * 1024];
loop {
match reader.read(&mut buf) {
Ok(0) => break, Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break; }
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
rx
}
fn run_file(path: String) -> std::io::Result<()> {
let lower = path.to_ascii_lowercase();
let jsonl = [".jsonl", ".ndjson", ".ldjson", ".jsonlines"]
.iter()
.any(|ext| lower.ends_with(ext));
let file = File::open(&path)?;
let mmap = Arc::new(Source::Mapped(unsafe { Mmap::map(&file)? }));
let b: &[u8] = &mmap;
let mut app = App::new(b, &path, jsonl);
let mut term = ratatui::init();
let res = run(&mut term, &mut app, b, &mmap);
ratatui::restore();
res
}
fn run_stdin() -> std::io::Result<()> {
let rx = spawn_reader(take_pipe_reader());
let mut buf: Vec<u8> = Vec::new();
let mut jsonl = false;
let mut app = App::new(&buf, "stdin", jsonl);
let mut term = ratatui::init();
let res = run_stream(&mut term, &mut app, &mut buf, &mut jsonl, rx);
ratatui::restore();
res
}
fn main() -> std::io::Result<()> {
match std::env::args().nth(1) {
Some(path) => run_file(path),
None => {
if std::io::stdin().is_terminal() {
eprintln!("usage: rsview <file.json> (or pipe JSON: cat file.json | rsview)");
std::process::exit(2);
}
run_stdin()
}
}
}