use ratatui::{
Frame,
layout::{Alignment, Constraint, Flex, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Wrap},
};
use simd_json::OwnedValue as Value;
use simd_json::StaticNode;
use unicode_width::UnicodeWidthStr;
use crate::app::{App, LoadingState};
use crate::theme::THEME;
const INDENT: &str = " ";
fn expand_tabs(s: &str) -> String {
const TAB_WIDTH: usize = 8;
let mut result = String::with_capacity(s.len());
let mut col = 0;
for c in s.chars() {
if c == '\t' {
let spaces = TAB_WIDTH - (col % TAB_WIDTH);
result.extend(std::iter::repeat_n(' ', spaces));
col += spaces;
} else {
result.push(c);
col += 1;
}
}
result
}
pub fn render(frame: &mut Frame, app: &mut App) {
match app.loading_state() {
LoadingState::Loading => {
render_loading_screen(frame, "Loading...");
return;
}
LoadingState::Failed(err) => {
render_error_screen(frame, &err.to_string());
return;
}
LoadingState::Ready | LoadingState::Streaming { .. } => {} }
let has_message = app.filter_error().is_some() || app.status_message().is_some();
let message_height = if has_message { 1 } else { 0 };
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(message_height),
Constraint::Length(1), Constraint::Length(1),
])
.split(frame.area());
let viewport_height = chunks[0].height as usize;
let viewport_width = chunks[0].width as usize;
app.set_viewport(viewport_width, viewport_height);
let visible_lines = render_viewport(
app.output_values(),
&app.render_options(),
app.scroll_offset(),
viewport_height,
);
let mut json_view = Paragraph::new(visible_lines);
if app.wrap_lines() {
json_view = json_view.wrap(Wrap { trim: false });
} else {
json_view = json_view.scroll((0, app.horizontal_offset() as u16));
}
frame.render_widget(json_view, chunks[0]);
if let Some(error) = app.filter_error() {
let error_line = Line::from(vec![
Span::styled(" Error: ", Style::default().fg(THEME.filter_error)),
Span::styled(
error.to_string(),
Style::default().fg(THEME.filter_error).dim(),
),
]);
frame.render_widget(Paragraph::new(error_line), chunks[1]);
} else if let Some(message) = app.status_message() {
let style = Style::default().fg(THEME.status_message);
let status = Paragraph::new(Span::styled(message, style));
frame.render_widget(status, chunks[1]);
}
render_filter_input(frame, app, chunks[2]);
let total = app.total_lines();
let start = app.scroll_offset() + 1;
let end = (app.scroll_offset() + viewport_height).min(total);
let wrap_indicator = if app.wrap_lines() { " [wrap]" } else { "" };
let input_indicator = if app.is_streaming() {
let count = app.input_count();
let max = app.max_entries();
let state = if app.stream_finished() {
"ended"
} else {
"live"
};
let count_str = if count >= max && max > 0 {
format!("{}/{}", count, max)
} else {
count.to_string()
};
let errors = app.stream_error_count();
let error_str = if errors > 0 {
format!(", {} skipped", errors)
} else {
String::new()
};
let scroll_str = if app.autoscroll() { "" } else { " scroll" };
format!(
" [stream {}: {} entries{}{}]",
state, count_str, error_str, scroll_str
)
} else if app.input_count() > 1 {
format!(" ({} inputs)", app.input_count())
} else {
String::new()
};
let mode_indicators = {
let mut modes = Vec::new();
if app.raw_output() {
modes.push("raw");
}
if app.slurp_mode() {
modes.push("slurp");
}
if app.compact_output() {
modes.push("compact");
}
if modes.is_empty() {
String::new()
} else {
format!(" [{}]", modes.join("+"))
}
};
let eval_indicator = if app.is_evaluating() {
" [eval...]"
} else if app.eval_paused() {
" [paused]"
} else {
""
};
let status = if total == 0 {
format!(
" (empty){}{}{}{} ^h=help",
input_indicator, wrap_indicator, mode_indicators, eval_indicator
)
} else if app.horizontal_offset() > 0 && !app.wrap_lines() {
format!(
" Lines {}-{} of {} | Col {}{}{}{}{} ^h=help",
start,
end,
total,
app.horizontal_offset() + 1,
input_indicator,
wrap_indicator,
mode_indicators,
eval_indicator
)
} else {
format!(
" Lines {}-{} of {}{}{}{}{} ^h=help",
start, end, total, input_indicator, wrap_indicator, mode_indicators, eval_indicator
)
};
let status_line = if app.is_evaluating() {
Paragraph::new(status).style(Style::default().fg(THEME.status_bar_evaluating))
} else {
Paragraph::new(status).style(Style::default().fg(THEME.status_bar_normal))
};
frame.render_widget(status_line, chunks[3]);
if app.show_help() {
render_help_popup(frame, app);
}
}
fn render_filter_input(frame: &mut Frame, app: &App, area: Rect) {
let prompt = if app.partial_eval_mode() {
"Filter[→]> "
} else if app.editing_filter() {
"Filter> "
} else {
"Filter: "
};
let prompt_style = if app.partial_eval_mode() {
Style::default().fg(THEME.prompt_partial_eval)
} else if app.editing_filter() {
Style::default().fg(THEME.prompt_editing)
} else {
Style::default().fg(THEME.prompt_inactive)
};
if let Some(error) = app.filter_error() {
let filter_text = app.filter_text();
let error_pos = match error {
crate::error::FilterError::Parse(e) => e.position,
crate::error::FilterError::Eval(e) => e.position(),
};
let (valid_part, invalid_part) = filter_text.split_at(error_pos.min(filter_text.len()));
let filter_line = Line::from(vec![
Span::styled(prompt, prompt_style),
Span::raw(valid_part),
Span::styled(invalid_part, Style::default().fg(THEME.filter_error)),
]);
frame.render_widget(Paragraph::new(filter_line), area);
} else if let Some(ranges) = app.computed_partial_eval_ranges() {
let filter_text = app.filter_text();
let dim_style = Style::default().fg(THEME.filter_dim);
let eval_style = Style::default().fg(THEME.filter_partial_highlight);
let mut spans = vec![Span::styled(prompt, prompt_style)];
let mut pos = 0;
for (start, end) in &ranges {
if pos < *start {
spans.push(Span::styled(&filter_text[pos..*start], dim_style));
}
spans.push(Span::styled(&filter_text[*start..*end], eval_style));
pos = *end;
}
if pos < filter_text.len() {
spans.push(Span::styled(&filter_text[pos..], dim_style));
}
let filter_line = Line::from(spans);
frame.render_widget(Paragraph::new(filter_line), area);
} else {
let filter_line = Line::from(vec![
Span::styled(prompt, prompt_style),
Span::raw(app.filter_text()),
]);
frame.render_widget(Paragraph::new(filter_line), area);
}
if app.editing_filter() {
let cursor_x = area.x + prompt.width() as u16 + app.filter_cursor_display_col() as u16;
frame.set_cursor_position((cursor_x, area.y));
}
}
fn render_help_popup(frame: &mut Frame, app: &mut App) {
let lines = if app.help_for_filter_mode() {
build_filter_help_lines()
} else {
build_navigation_help_lines()
};
render_scrollable_help(frame, app, &lines);
}
fn help_entry(key: &str, desc: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{:<16}", key), Style::default().fg(THEME.help_key)),
Span::raw(desc.to_string()),
])
}
fn help_section(title: &str) -> Line<'static> {
Line::from(Span::styled(
title.to_string(),
Style::default().fg(THEME.help_title).bold(),
))
}
fn build_filter_help_lines() -> Vec<Line<'static>> {
vec![
help_section("SYNTAX"),
help_entry(".", "Identity (current value)"),
help_entry(".foo", "Access field"),
help_entry(".[0]", "Access array index"),
help_entry(".[]", "Iterate array/object"),
help_entry(".[2:5]", "Array slice"),
help_entry("a | b", "Pipe output to next filter"),
help_entry("[.a, .b]", "Construct array"),
help_entry("{a: .x}", "Construct object"),
help_entry("//", "Alternative (fallback)"),
help_entry("and", "Boolean AND"),
help_entry("or", "Boolean OR"),
help_entry("not", "Boolean NOT"),
help_entry("==", "Equal"),
help_entry("!=", "Not equal"),
help_entry("< >", "Less/greater than"),
help_entry("<= >=", "Less/greater or equal"),
help_entry("+ - * /", "Arithmetic operators"),
help_entry("if-then-else", "Conditional expression"),
Line::from(""),
help_section("FUNCTIONS"),
help_entry("map(f)", "Apply f to each array element"),
help_entry("select(f)", "Keep values where f is truthy"),
help_entry("sort_by(f)", "Sort array by extracted key"),
help_entry("group_by(f)", "Group array elements by key"),
help_entry("unique_by(f)", "Remove duplicates by key"),
help_entry("min_by(f)", "Minimum by extracted key"),
help_entry("max_by(f)", "Maximum by extracted key"),
help_entry("has(k)", "Check if key exists"),
help_entry("contains(v)", "Check if value contains v"),
help_entry("split(s)", "Split string by separator"),
help_entry("join(s)", "Join array with separator"),
help_entry("with_entries(f)", "Transform object entries"),
Line::from(""),
help_section("BUILTINS"),
help_entry("length", "Length of string/array/object"),
help_entry("keys", "Get object keys as array"),
help_entry("values", "Get object values as array"),
help_entry("type", "Get JSON type as string"),
help_entry("sort", "Sort array"),
help_entry("reverse", "Reverse array"),
help_entry("unique", "Remove duplicate values"),
help_entry("flatten", "Flatten nested arrays"),
help_entry("first", "First array element"),
help_entry("last", "Last array element"),
help_entry("min", "Minimum value"),
help_entry("max", "Maximum value"),
help_entry("add", "Sum numbers / concat arrays"),
help_entry("to_entries", "Object to [{key,value}]"),
help_entry("from_entries", "[{key,value}] to object"),
help_entry("empty", "Produce no output"),
help_entry("not", "Boolean negation"),
help_entry("@csv", "Array to CSV string"),
help_entry("@tsv", "Array to TSV string"),
Line::from(""),
help_section("KEYS"),
help_entry("Esc", "Exit filter edit mode"),
help_entry("Tab", "Eval up to cursor"),
help_entry("Ctrl+p", "Pause/resume auto-eval"),
help_entry("Ctrl+c", "Cancel current evaluation"),
help_entry("Ctrl+s", "Toggle slurp mode"),
help_entry("Ctrl+r", "Toggle raw output"),
help_entry("Ctrl+o", "Toggle compact output"),
help_entry("Ctrl+h", "Toggle this help"),
]
}
fn build_navigation_help_lines() -> Vec<Line<'static>> {
vec![
help_section("NAVIGATION"),
help_entry("j / ↓", "Scroll down"),
help_entry("k / ↑", "Scroll up"),
help_entry("h / ←", "Scroll left"),
help_entry("l / →", "Scroll right"),
help_entry("g", "Go to top"),
help_entry("G", "Go to bottom"),
help_entry("0", "Go to line start"),
help_entry("Ctrl+d", "Half page down"),
help_entry("Ctrl+u", "Half page up"),
help_entry("PgDn", "Full page down"),
help_entry("PgUp", "Full page up"),
Line::from(""),
help_section("DISPLAY"),
help_entry("w", "Toggle line wrapping"),
help_entry("r", "Toggle raw output"),
help_entry("o", "Toggle compact output"),
help_entry("s", "Toggle slurp mode"),
Line::from(""),
help_section("FILTER"),
help_entry("i", "Edit filter"),
help_entry("/", "Edit filter (alternative)"),
help_entry("y", "Copy filter as jq command"),
Line::from(""),
help_section("STREAMING"),
help_entry("a", "Toggle autoscroll"),
Line::from(""),
help_section("GENERAL"),
help_entry("Ctrl+h", "Toggle this help"),
help_entry("q/Esc", "Quit"),
help_entry("Ctrl+c/d", "Quit (alternative)"),
]
}
fn render_scrollable_help(frame: &mut Frame, app: &mut App, lines: &[Line<'static>]) {
let area = frame.area();
let content_width = 54u16; let content_height = lines.len();
let max_popup_width = area.width.saturating_sub(4);
let max_popup_height = area.height.saturating_sub(4);
let popup_width = content_width.min(max_popup_width);
let popup_height = (content_height as u16 + 3).min(max_popup_height);
let inner_height = popup_height.saturating_sub(2) as usize;
let max_scroll = content_height.saturating_sub(inner_height);
app.clamp_help_scroll(max_scroll);
let scroll = app.help_scroll();
let needs_scroll = content_height > inner_height;
let popup_area = centered_rect(popup_width, popup_height, area);
let title = if needs_scroll {
format!(
" Help [{}-{}/{}] ",
scroll + 1,
(scroll + inner_height).min(content_height),
content_height
)
} else {
" Help ".to_string()
};
let block = Block::bordered().title(title);
let inner = block.inner(popup_area);
frame.render_widget(Clear, popup_area);
frame.render_widget(block, popup_area);
let visible_lines: Vec<Line> = lines
.iter()
.skip(scroll)
.take(inner_height)
.cloned()
.collect();
frame.render_widget(Paragraph::new(visible_lines), inner);
if needs_scroll {
if scroll > 0 {
let indicator = Span::styled("▲", Style::default().fg(THEME.scroll_indicator));
let indicator_area = Rect {
x: popup_area.x + popup_width - 2,
y: popup_area.y,
width: 1,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(indicator)), indicator_area);
}
if scroll < max_scroll {
let indicator = Span::styled("▼", Style::default().fg(THEME.scroll_indicator));
let indicator_area = Rect {
x: popup_area.x + popup_width - 2,
y: popup_area.y + popup_height - 1,
width: 1,
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(indicator)), indicator_area);
}
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}
fn render_loading_screen(frame: &mut Frame, message: &str) {
let area = frame.area();
let text = Paragraph::new(message)
.style(Style::default().fg(THEME.loading_title))
.alignment(Alignment::Center);
let centered = centered_rect(30, 3, area);
frame.render_widget(text, centered);
}
fn render_error_screen(frame: &mut Frame, message: &str) {
let area = frame.area();
let lines = vec![
Line::from(Span::styled(
"Error loading input",
Style::default().fg(THEME.error_title).bold(),
)),
Line::from(""),
Line::from(Span::styled(
message.to_string(),
Style::default().fg(THEME.error_message),
)),
Line::from(""),
Line::from(Span::styled(
"Press 'q' to quit",
Style::default().fg(THEME.error_message),
)),
];
let text = Paragraph::new(lines).alignment(Alignment::Center);
let height = 5.min(area.height);
let width = (message.len() as u16 + 4).min(area.width).max(25);
let centered = centered_rect(width, height, area);
frame.render_widget(text, centered);
}
#[derive(Default, Clone)]
pub struct RenderOptions {
pub raw_output: bool,
pub compact: bool,
}
pub fn count_total_lines(values: &[Value], options: &RenderOptions) -> usize {
values.iter().map(|v| count_value_lines(v, options)).sum()
}
fn count_value_lines(value: &Value, options: &RenderOptions) -> usize {
if options.compact {
count_lines_compact(value, options.raw_output)
} else {
count_lines_pretty(value, options.raw_output)
}
}
fn count_lines_compact(value: &Value, raw: bool) -> usize {
if raw && let Value::String(s) = value {
return s.lines().count().max(1);
}
1 }
fn count_lines_pretty(value: &Value, raw: bool) -> usize {
if raw && let Value::String(s) = value {
return s.lines().count().max(1);
}
match value {
Value::Static(_) | Value::String(_) => 1,
Value::Array(arr) if arr.is_empty() => 1,
Value::Object(obj) if obj.is_empty() => 1,
Value::Array(arr) => {
2 + arr
.iter()
.map(|v| count_lines_pretty(v, false))
.sum::<usize>()
}
Value::Object(obj) => {
2 + obj
.values()
.map(|v| count_lines_pretty(v, false))
.sum::<usize>()
}
}
}
pub fn render_viewport(
values: &[Value],
options: &RenderOptions,
skip: usize,
take: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::with_capacity(take);
let mut remaining_skip = skip;
for value in values {
if lines.len() >= take {
break;
}
let value_lines = count_value_lines(value, options);
if remaining_skip >= value_lines {
remaining_skip -= value_lines;
continue;
}
if options.compact {
render_value_viewport_compact(
value,
options.raw_output,
&mut lines,
&mut remaining_skip,
take,
);
} else {
render_value_viewport_pretty(
value,
None,
0,
&mut lines,
false,
&mut remaining_skip,
take,
options.raw_output,
);
}
}
lines
}
fn render_value_viewport_compact(
value: &Value,
raw: bool,
lines: &mut Vec<Line<'static>>,
remaining_skip: &mut usize,
take: usize,
) {
if lines.len() >= take {
return;
}
if raw && let Value::String(s) = value {
for line_str in s.lines() {
if *remaining_skip > 0 {
*remaining_skip -= 1;
continue;
}
if lines.len() >= take {
return;
}
lines.push(Line::raw(expand_tabs(line_str)));
}
return;
}
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = Vec::new();
render_value_compact(value, &mut spans);
lines.push(Line::from(spans));
}
#[allow(clippy::too_many_arguments)]
fn render_value_viewport_pretty(
value: &Value,
key: Option<&str>,
depth: usize,
lines: &mut Vec<Line<'static>>,
trailing_comma: bool,
remaining_skip: &mut usize,
take: usize,
raw: bool,
) {
if lines.len() >= take {
return;
}
if raw
&& depth == 0
&& key.is_none()
&& let Value::String(s) = value
{
for line_str in s.lines() {
if *remaining_skip > 0 {
*remaining_skip -= 1;
continue;
}
if lines.len() >= take {
return;
}
lines.push(Line::raw(expand_tabs(line_str)));
}
return;
}
let indent = INDENT.repeat(depth);
let comma_span = if trailing_comma {
Span::styled(",", Style::default().fg(THEME.json_punctuation))
} else {
Span::raw("")
};
match value {
Value::Static(_) | Value::String(_) => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = vec![Span::raw(indent)];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(value_styled(value));
spans.push(comma_span);
lines.push(Line::from(spans));
}
Value::Array(arr) if arr.is_empty() => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = vec![Span::raw(indent)];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled(
"[]",
Style::default().fg(THEME.json_punctuation),
));
spans.push(comma_span);
lines.push(Line::from(spans));
}
Value::Object(obj) if obj.is_empty() => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = vec![Span::raw(indent)];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled(
"{}",
Style::default().fg(THEME.json_punctuation),
));
spans.push(comma_span);
lines.push(Line::from(spans));
}
Value::Array(arr) => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else if lines.len() < take {
let mut spans = vec![Span::raw(indent.clone())];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled(
"[",
Style::default().fg(THEME.json_punctuation),
));
lines.push(Line::from(spans));
}
for (i, item) in arr.iter().enumerate() {
if lines.len() >= take {
return;
}
let is_last = i == arr.len() - 1;
render_value_viewport_pretty(
item,
None,
depth + 1,
lines,
!is_last,
remaining_skip,
take,
false,
);
}
if lines.len() >= take {
return;
}
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else {
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled("]", Style::default().fg(THEME.json_punctuation)),
comma_span,
]));
}
}
Value::Object(obj) => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else if lines.len() < take {
let mut spans = vec![Span::raw(indent.clone())];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled(
"{",
Style::default().fg(THEME.json_punctuation),
));
lines.push(Line::from(spans));
}
let entries: Vec<_> = obj.iter().collect();
for (i, (k, v)) in entries.iter().enumerate() {
if lines.len() >= take {
return;
}
let is_last = i == entries.len() - 1;
render_value_viewport_pretty(
v,
Some(k),
depth + 1,
lines,
!is_last,
remaining_skip,
take,
false,
);
}
if lines.len() >= take {
return;
}
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else {
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled("}", Style::default().fg(THEME.json_punctuation)),
comma_span,
]));
}
}
}
}
fn render_value_compact(value: &Value, spans: &mut Vec<Span<'static>>) {
match value {
Value::Static(StaticNode::Null) => {
spans.push(Span::styled("null", Style::default().fg(THEME.json_null)))
}
Value::Static(StaticNode::Bool(b)) => spans.push(Span::styled(
b.to_string(),
Style::default().fg(THEME.json_bool),
)),
Value::Static(StaticNode::I64(n)) => spans.push(Span::styled(
n.to_string(),
Style::default().fg(THEME.json_number),
)),
Value::Static(StaticNode::U64(n)) => spans.push(Span::styled(
n.to_string(),
Style::default().fg(THEME.json_number),
)),
Value::Static(StaticNode::F64(n)) => spans.push(Span::styled(
n.to_string(),
Style::default().fg(THEME.json_number),
)),
Value::String(s) => spans.push(Span::styled(
format!("\"{}\"", escape_string(s)),
Style::default().fg(THEME.json_string),
)),
Value::Array(arr) => {
spans.push(Span::styled(
"[",
Style::default().fg(THEME.json_punctuation),
));
for (i, v) in arr.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(
",",
Style::default().fg(THEME.json_punctuation),
));
}
render_value_compact(v, spans);
}
spans.push(Span::styled(
"]",
Style::default().fg(THEME.json_punctuation),
));
}
Value::Object(obj) => {
spans.push(Span::styled(
"{",
Style::default().fg(THEME.json_punctuation),
));
for (i, (k, v)) in obj.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(
",",
Style::default().fg(THEME.json_punctuation),
));
}
spans.push(Span::styled(
format!("\"{}\"", escape_string(k)),
Style::default().fg(THEME.json_key),
));
spans.push(Span::styled(
":",
Style::default().fg(THEME.json_punctuation),
));
render_value_compact(v, spans);
}
spans.push(Span::styled(
"}",
Style::default().fg(THEME.json_punctuation),
));
}
}
}
fn value_styled(value: &Value) -> Span<'static> {
match value {
Value::Static(StaticNode::Null) => {
Span::styled("null", Style::default().fg(THEME.json_null))
}
Value::Static(StaticNode::Bool(b)) => {
Span::styled(b.to_string(), Style::default().fg(THEME.json_bool))
}
Value::Static(StaticNode::I64(n)) => {
Span::styled(n.to_string(), Style::default().fg(THEME.json_number))
}
Value::Static(StaticNode::U64(n)) => {
Span::styled(n.to_string(), Style::default().fg(THEME.json_number))
}
Value::Static(StaticNode::F64(n)) => {
Span::styled(n.to_string(), Style::default().fg(THEME.json_number))
}
Value::String(s) => Span::styled(
format!("\"{}\"", escape_string(s)),
Style::default().fg(THEME.json_string),
),
_ => unreachable!("value_styled only handles primitives"),
}
}
fn key_spans(key: &str) -> Vec<Span<'static>> {
vec![
Span::styled(
format!("\"{}\"", escape_string(key)),
Style::default().fg(THEME.json_key),
),
Span::styled(": ", Style::default().fg(THEME.json_punctuation)),
]
}
fn escape_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
c => result.push(c),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use simd_json::json;
fn render_all(values: &[Value], options: &RenderOptions) -> Vec<Line<'static>> {
let total = count_total_lines(values, options);
render_viewport(values, options, 0, total)
}
#[test]
fn test_navigation_help_has_expected_sections() {
let lines = build_navigation_help_lines();
let text: String = lines.iter().map(|l| l.to_string()).collect();
assert!(text.contains("NAVIGATION"));
assert!(text.contains("DISPLAY"));
assert!(text.contains("FILTER"));
assert!(text.contains("GENERAL"));
}
#[test]
fn test_filter_help_has_expected_sections() {
let lines = build_filter_help_lines();
let text: String = lines.iter().map(|l| l.to_string()).collect();
assert!(text.contains("SYNTAX"));
assert!(text.contains("FUNCTIONS"));
assert!(text.contains("BUILTINS"));
assert!(text.contains("KEYS"));
}
#[test]
fn test_help_entry_formatting() {
let line = help_entry("test", "description");
let text = line.to_string();
assert!(text.contains("test"));
assert!(text.contains("description"));
}
#[test]
fn test_centered_rect() {
let area = Rect::new(0, 0, 80, 24);
let centered = centered_rect(40, 10, area);
assert_eq!(centered.width, 40);
assert_eq!(centered.height, 10);
assert_eq!(centered.x, 20); assert_eq!(centered.y, 7); }
#[test]
fn test_centered_rect_larger_than_area() {
let area = Rect::new(0, 0, 30, 10);
let centered = centered_rect(40, 20, area);
assert!(centered.x <= area.width);
assert!(centered.y <= area.height);
}
#[test]
fn test_raw_string_output() {
let value = json!("hello world");
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "hello world");
}
#[test]
fn test_raw_non_string_unchanged() {
let value = json!(42);
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "42");
}
#[test]
fn test_raw_null_unchanged() {
let value = json!(null);
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "null");
}
#[test]
fn test_raw_string_with_newline() {
let value = json!("line1\nline2");
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].to_string(), "line1");
assert_eq!(lines[1].to_string(), "line2");
}
#[test]
fn test_compact_output() {
let value = json!({"a": 1, "b": 2});
let options = RenderOptions {
raw_output: false,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
let text = lines[0].to_string();
assert!(text.contains("\"a\":1") || text.contains("\"a\": 1"));
}
#[test]
fn test_compact_array() {
let value = json!([1, 2, 3]);
let options = RenderOptions {
raw_output: false,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "[1,2,3]");
}
#[test]
fn test_compact_raw_string() {
let value = json!("test");
let options = RenderOptions {
raw_output: true,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "test");
}
#[test]
fn test_compact_raw_non_string() {
let value = json!({"x": 1});
let options = RenderOptions {
raw_output: true,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert!(lines[0].to_string().contains("\"x\""));
}
#[test]
fn test_line_count_simple() {
let value = json!({"name": "alice"});
let options = RenderOptions::default();
assert_eq!(count_total_lines(&[value], &options), 3);
}
#[test]
fn test_viewport_skip_and_take() {
let value = json!([1, 2, 3, 4, 5]);
let options = RenderOptions::default();
assert_eq!(count_total_lines(std::slice::from_ref(&value), &options), 7);
let lines = render_viewport(&[value], &options, 2, 2);
assert_eq!(lines.len(), 2);
assert!(lines[0].to_string().contains("2"));
assert!(lines[1].to_string().contains("3"));
}
#[test]
fn test_expand_tabs_basic() {
assert_eq!(expand_tabs("\tfoo"), " foo");
assert_eq!(expand_tabs("abc\tdef"), "abc def");
assert_eq!(expand_tabs("12345678\tx"), "12345678 x");
}
#[test]
fn test_expand_tabs_multiple() {
assert_eq!(expand_tabs("a\tb\tc"), "a b c");
}
#[test]
fn test_expand_tabs_no_tabs() {
assert_eq!(expand_tabs("no tabs here"), "no tabs here");
}
#[test]
fn test_raw_string_with_tabs() {
let value = json!("Alice\t30\tNYC");
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "Alice 30 NYC");
}
}