mod scanner;
mod search;
use scanner::{container_empty, decode_str, skip_ws, value_kind, Cursor, Kind, RawChild};
use search::Search;
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, sync::Arc, time::Duration};
struct Node {
label: String,
start: usize,
end: usize,
kind: Kind,
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 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,
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(Cursor::new(self.start, self.end, matches!(self.kind, Kind::Array)));
}
}
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 => {
if !self.has_children {
"{}".into()
} else if self.expanded {
"{".into()
} else {
"{…}".into()
}
}
Kind::Array => {
if !self.has_children {
"[]".into()
} else if self.expanded {
"[".into()
} else {
"[…]".into()
}
}
Kind::Str => format!("\"{}\"", truncate(&decode_str(b, self.start, self.end), 70)),
_ => truncate(&String::from_utf8_lossy(&b[self.start..self.end]), 70),
}
}
}
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,
text: String,
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,
text: format!("{}: {}", node.label, node.preview(b)),
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,
}
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>>,
}
impl App {
fn new(b: &[u8], path: &str) -> App {
let rstart = skip_ws(b, 0, b.len());
let kind = value_kind(b, rstart);
let is_cont = matches!(kind, Kind::Object | Kind::Array);
let has = is_cont && !container_empty(b, rstart, b.len());
let name = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "ROOT".into());
let mut root = Node {
label: name.clone(),
start: rstart,
end: b.len(), kind,
has_children: has,
expanded: false,
done: false,
children: Vec::new(),
cursor: None,
};
if has {
root.toggle(); }
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 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<Mmap>) {
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()));
}
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) {
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
let title = 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 text = format!("{}{} {}", " ".repeat(r.depth), marker, r.text);
let st = if i == app.focus {
Style::default().add_modifier(Modifier::REVERSED)
} else if cur_match == Some(&r.path) {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if app.match_set.contains(&r.path) {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(text, st)));
}
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 run(
term: &mut ratatui::DefaultTerminal,
app: &mut App,
b: &[u8],
mmap: &Arc<Mmap>,
) -> std::io::Result<()> {
loop {
app.pump_search();
let (_, th) = ratatui::crossterm::terminal::size()?;
let h = (th.saturating_sub(2) as usize).max(1);
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))?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
let Event::Key(k) = event::read()? else {
continue;
};
if k.kind != KeyEventKind::Press {
continue;
}
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();
app.relaunch(mmap);
}
KeyCode::Char(c) => {
app.query.push(c);
app.relaunch(mmap);
}
_ => {}
}
continue;
}
match k.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('/') => {
app.mode = Mode::Search;
app.query.clear();
app.relaunch(mmap);
}
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(),
_ => {}
}
}
}
fn main() -> std::io::Result<()> {
let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
eprintln!("usage: rsview <file.json>");
std::process::exit(2);
}
};
let file = File::open(&path)?;
let mmap = Arc::new(unsafe { Mmap::map(&file)? });
let b: &[u8] = &mmap;
let mut app = App::new(b, &path);
let mut term = ratatui::init();
let res = run(&mut term, &mut app, b, &mmap);
ratatui::restore();
res
}