use ratatui::text::{Line, Span};
use serde_json::{Map, Value};
use super::super::{
render::{display_width, wrap_line_at_whitespace},
theme::Theme,
};
const NEST_INDENT: usize = 2;
const MAX_KEY_COLUMN: usize = 20;
const BULLET: &str = "• ";
pub(super) fn outline_lines(value: &Value, width: usize) -> Vec<Line<'static>> {
let mut out = Vec::new();
push_value(&mut out, value, 0, width.max(1));
out
}
fn push_value(out: &mut Vec<Line<'static>>, value: &Value, indent: usize, width: usize) {
match value {
Value::Object(map) if !map.is_empty() => push_object(out, map, indent, width),
Value::Array(items) if !items.is_empty() => push_items(out, items, indent, width),
scalar => push_paragraph(
out,
&scalar_text(scalar),
scalar_style(scalar),
indent,
width,
),
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum EntryGroup {
Inline,
Paragraph,
Nested,
}
fn push_object(
out: &mut Vec<Line<'static>>,
map: &Map<String, Value>,
indent: usize,
width: usize,
) {
let key_column = map
.iter()
.filter(|(_, value)| is_scalar_like(value))
.map(|(key, _)| display_width(key))
.max()
.unwrap_or(0)
.min(MAX_KEY_COLUMN);
let mut entries = map
.iter()
.map(|(key, value)| {
let group = entry_group(key, value, key_column, indent, width);
(group, key, value)
})
.collect::<Vec<_>>();
entries.sort_by_key(|(group, _, _)| *group);
for (group, key, value) in entries {
match group {
EntryGroup::Inline => {
let pad = key_column.saturating_sub(display_width(key));
out.push(Line::from(vec![
indent_span(indent),
Span::styled(key.clone(), Theme::accent()),
Span::raw(" ".repeat(pad + 2)),
Span::styled(scalar_text(value), scalar_style(value)),
]));
}
EntryGroup::Paragraph => {
out.push(key_line(key, None, indent));
let text = value.as_str().unwrap_or_default();
push_paragraph(out, text, Theme::text(), indent + NEST_INDENT, width);
}
EntryGroup::Nested => {
let count = value.as_array().map(Vec::len);
out.push(key_line(key, count, indent));
push_value(out, value, indent + NEST_INDENT, width);
}
}
}
}
fn entry_group(
key: &str,
value: &Value,
key_column: usize,
indent: usize,
width: usize,
) -> EntryGroup {
if !is_scalar_like(value) {
return EntryGroup::Nested;
}
let text = scalar_text(value);
let row = indent + key_column.max(display_width(key)) + 2 + display_width(&text);
if value.is_string() && (row > width || text.contains('\n')) {
EntryGroup::Paragraph
} else {
EntryGroup::Inline
}
}
fn push_items(out: &mut Vec<Line<'static>>, items: &[Value], indent: usize, width: usize) {
let numbered = items.iter().any(|item| !is_scalar_like(item));
let marker_width = if numbered {
format!("{}. ", items.len()).len()
} else {
display_width(BULLET)
};
for (index, item) in items.iter().enumerate() {
if numbered && index > 0 {
out.push(Line::from(indent_span(indent)));
}
let first = out.len();
push_value(out, item, indent + marker_width, width);
let marker = if numbered {
format!("{:<marker_width$}", format!("{}.", index + 1))
} else {
BULLET.to_owned()
};
if let Some(line) = out.get_mut(first) {
let inner = line.spans[0]
.content
.get(indent + marker_width..)
.unwrap_or_default()
.to_owned();
line.spans[0] = Span::styled(
format!("{}{marker}{inner}", " ".repeat(indent)),
Theme::dim(),
);
}
}
}
fn push_paragraph(
out: &mut Vec<Line<'static>>,
text: &str,
style: ratatui::style::Style,
indent: usize,
width: usize,
) {
let available = width.saturating_sub(indent).max(1);
let mut emitted = false;
for source_line in text.lines() {
for chunk in wrap_line_at_whitespace(source_line, available) {
out.push(Line::from(vec![
indent_span(indent),
Span::styled(chunk.to_owned(), style),
]));
emitted = true;
}
}
if !emitted {
out.push(Line::from(vec![indent_span(indent), Span::raw("")]));
}
}
fn key_line(key: &str, count: Option<usize>, indent: usize) -> Line<'static> {
let mut spans = vec![
indent_span(indent),
Span::styled(key.to_owned(), Theme::accent()),
];
if let Some(count) = count {
spans.push(Span::styled(format!(" · {count}"), Theme::dim()));
}
Line::from(spans)
}
fn indent_span(indent: usize) -> Span<'static> {
Span::raw(" ".repeat(indent))
}
fn is_scalar_like(value: &Value) -> bool {
match value {
Value::Array(items) => items.is_empty(),
Value::Object(map) => map.is_empty(),
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => true,
}
}
fn scalar_text(value: &Value) -> String {
match value {
Value::String(text) if text.is_empty() => "(empty)".to_owned(),
Value::String(text) => text.clone(),
Value::Array(_) | Value::Object(_) => "none".to_owned(),
Value::Null => "null".to_owned(),
Value::Bool(_) | Value::Number(_) => value.to_string(),
}
}
fn scalar_style(value: &Value) -> ratatui::style::Style {
match value {
Value::String(text) if !text.is_empty() => Theme::text(),
Value::Bool(_) | Value::Number(_) => Theme::text_strong(),
Value::String(_) | Value::Array(_) | Value::Object(_) | Value::Null => Theme::dim(),
}
}
#[cfg(test)]
#[path = "json_outline_tests.rs"]
mod tests;