#![warn(clippy::pedantic)]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Json,
Yaml,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Consumed,
Close,
Save,
}
#[derive(Clone, PartialEq)]
enum Val {
Null,
Bool(bool),
Num(String),
Str(String),
Arr {
collapsed: bool,
items: Vec<Val>,
},
Obj {
collapsed: bool,
entries: Vec<(String, Val)>,
},
}
#[derive(Clone, PartialEq)]
enum Seg {
Key(String),
Index(usize),
}
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Scalar,
Container(bool),
}
struct Row {
depth: usize,
label: String,
value: String,
kind: Kind,
path: Vec<Seg>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Normal,
Edit,
}
const HISTORY_CAP: usize = 200;
pub struct Tree {
root: Val,
format: Format,
rows: Vec<Row>,
sel: usize,
scroll: usize,
dirty: bool,
mode: Mode,
edit_buf: String,
undo: Vec<Val>,
redo: Vec<Val>,
}
impl Tree {
pub fn from_text(text: &str, format: Format) -> Result<Self, String> {
let parsed: serde_norway::Value =
serde_norway::from_str(text).map_err(|e| e.to_string())?;
let mut tree = Tree {
root: from_yaml(&parsed),
format,
rows: Vec::new(),
sel: 0,
scroll: 0,
dirty: false,
mode: Mode::Normal,
edit_buf: String::new(),
undo: Vec::new(),
redo: Vec::new(),
};
tree.rebuild();
Ok(tree)
}
#[must_use]
pub fn format(&self) -> Format {
self.format
}
#[must_use]
pub fn row_count(&self) -> usize {
self.rows.len()
}
#[must_use]
pub fn sel(&self) -> usize {
self.sel
}
#[must_use]
pub fn scroll(&self) -> usize {
self.scroll
}
#[must_use]
pub fn is_dirty(&self) -> bool {
self.dirty
}
#[must_use]
pub fn is_editing(&self) -> bool {
matches!(self.mode, Mode::Edit)
}
#[must_use]
pub fn edit_buffer(&self) -> &str {
&self.edit_buf
}
#[must_use]
pub fn depth(&self, i: usize) -> usize {
self.rows.get(i).map_or(0, |r| r.depth)
}
#[must_use]
pub fn label(&self, i: usize) -> &str {
self.rows.get(i).map_or("", |r| r.label.as_str())
}
#[must_use]
pub fn value(&self, i: usize) -> &str {
self.rows.get(i).map_or("", |r| r.value.as_str())
}
#[must_use]
pub fn is_container(&self, i: usize) -> bool {
matches!(self.rows.get(i).map(|r| r.kind), Some(Kind::Container(_)))
}
#[must_use]
pub fn is_collapsed(&self, i: usize) -> bool {
matches!(
self.rows.get(i).map(|r| r.kind),
Some(Kind::Container(true))
)
}
#[must_use]
pub fn to_text(&self) -> String {
match self.format {
Format::Json => {
let mut out = String::new();
json_push(&self.root, 0, &mut out);
out.push('\n');
out
}
Format::Yaml => serde_norway::to_string(&to_yaml(&self.root)).unwrap_or_default(),
}
}
pub fn mark_saved(&mut self) {
self.dirty = false;
}
pub fn ensure_visible(&mut self, height: usize) {
self.scroll =
vix_list_state::ensure_visible(self.sel, self.scroll, height, self.rows.len());
}
pub fn handle_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && key.code == KeyCode::Char('s') {
if self.mode == Mode::Edit {
self.commit_edit();
}
return Outcome::Save;
}
if self.mode == Mode::Edit {
self.edit_key(key);
return Outcome::Consumed;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => self.sel = self.sel.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => self.step_down(1),
KeyCode::PageUp => self.sel = self.sel.saturating_sub(page.max(1)),
KeyCode::PageDown => self.step_down(page.max(1)),
KeyCode::Home => self.sel = 0,
KeyCode::End => self.sel = self.rows.len().saturating_sub(1),
KeyCode::Left | KeyCode::Char('h') => self.collapse_or_parent(),
KeyCode::Right | KeyCode::Char('l') => self.expand_or_child(),
KeyCode::Char(' ') => self.toggle(),
KeyCode::Enter | KeyCode::F(2) => self.activate(),
KeyCode::Char('u') => self.undo(),
KeyCode::Char('r') if ctrl => self.redo(),
KeyCode::Esc | KeyCode::Char('q') => return Outcome::Close,
_ => {}
}
Outcome::Consumed
}
fn edit_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => self.commit_edit(),
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Backspace => {
self.edit_buf.pop();
}
KeyCode::Char(c) => self.edit_buf.push(c),
_ => {}
}
}
fn step_down(&mut self, n: usize) {
self.sel = (self.sel + n).min(self.rows.len().saturating_sub(1));
}
fn collapse_or_parent(&mut self) {
let Some(row) = self.rows.get(self.sel) else {
return;
};
if matches!(row.kind, Kind::Container(false)) {
let path = row.path.clone();
self.set_collapsed(&path, true);
self.rebuild();
self.select_path(&path);
} else {
let path = row.path.clone();
if path.is_empty() {
return;
}
self.select_path(&path[..path.len() - 1]);
}
}
fn expand_or_child(&mut self) {
let Some(row) = self.rows.get(self.sel) else {
return;
};
match row.kind {
Kind::Container(true) => {
let path = row.path.clone();
self.set_collapsed(&path, false);
self.rebuild();
self.select_path(&path);
}
Kind::Container(false) => {
if self.sel + 1 < self.rows.len() {
self.sel += 1;
}
}
Kind::Scalar => {}
}
}
fn toggle(&mut self) {
let Some(row) = self.rows.get(self.sel) else {
return;
};
if let Kind::Container(c) = row.kind {
let path = row.path.clone();
self.set_collapsed(&path, !c);
self.rebuild();
self.select_path(&path);
}
}
fn activate(&mut self) {
match self.rows.get(self.sel).map(|r| r.kind) {
Some(Kind::Scalar) => {
self.edit_buf = self.rows[self.sel].value.clone();
self.mode = Mode::Edit;
}
Some(Kind::Container(_)) => self.toggle(),
None => {}
}
}
fn commit_edit(&mut self) {
self.mode = Mode::Normal;
let Some(row) = self.rows.get(self.sel) else {
return;
};
let path = row.path.clone();
let new = parse_scalar(&self.edit_buf);
if let Some(node) = at_mut(&mut self.root, &path) {
if *node == new {
return;
}
let snap = self.root.clone();
vix_capped_stack::push_capped(&mut self.undo, snap, HISTORY_CAP);
self.redo.clear();
if let Some(node) = at_mut(&mut self.root, &path) {
*node = new;
}
self.dirty = true;
self.rebuild();
self.select_path(&path);
}
}
fn set_collapsed(&mut self, path: &[Seg], collapsed: bool) {
if let Some(Val::Arr { collapsed: c, .. } | Val::Obj { collapsed: c, .. }) =
at_mut(&mut self.root, path)
{
*c = collapsed;
}
}
fn rebuild(&mut self) {
self.rows.clear();
flatten(&self.root, 0, String::new(), Vec::new(), &mut self.rows);
if self.sel >= self.rows.len() {
self.sel = self.rows.len().saturating_sub(1);
}
}
fn select_path(&mut self, path: &[Seg]) {
if let Some(i) = self.rows.iter().position(|r| r.path == path) {
self.sel = i;
}
}
fn undo(&mut self) {
if let Some(prev) = self.undo.pop() {
self.redo.push(std::mem::replace(&mut self.root, prev));
self.dirty = true;
self.rebuild();
}
}
fn redo(&mut self) {
if let Some(next) = self.redo.pop() {
self.undo.push(std::mem::replace(&mut self.root, next));
self.dirty = true;
self.rebuild();
}
}
}
fn at_mut<'a>(mut node: &'a mut Val, path: &[Seg]) -> Option<&'a mut Val> {
for seg in path {
node = match (node, seg) {
(Val::Arr { items, .. }, Seg::Index(i)) => items.get_mut(*i)?,
(Val::Obj { entries, .. }, Seg::Key(k)) => {
entries.iter_mut().find(|(ek, _)| ek == k).map(|(_, v)| v)?
}
_ => return None,
};
}
Some(node)
}
fn scalar_text(val: &Val) -> String {
match val {
Val::Null => "null".to_string(),
Val::Bool(b) => b.to_string(),
Val::Num(s) | Val::Str(s) => s.clone(),
_ => String::new(),
}
}
fn flatten(val: &Val, depth: usize, label: String, path: Vec<Seg>, out: &mut Vec<Row>) {
match val {
Val::Arr { collapsed, items } => {
out.push(Row {
depth,
label,
value: format!("[{}]", items.len()),
kind: Kind::Container(*collapsed),
path: path.clone(),
});
if !collapsed {
for (i, item) in items.iter().enumerate() {
let mut p = path.clone();
p.push(Seg::Index(i));
flatten(item, depth + 1, format!("[{i}]"), p, out);
}
}
}
Val::Obj { collapsed, entries } => {
out.push(Row {
depth,
label,
value: format!("{{{}}}", entries.len()),
kind: Kind::Container(*collapsed),
path: path.clone(),
});
if !collapsed {
for (k, v) in entries {
let mut p = path.clone();
p.push(Seg::Key(k.clone()));
flatten(v, depth + 1, k.clone(), p, out);
}
}
}
scalar => out.push(Row {
depth,
label,
value: scalar_text(scalar),
kind: Kind::Scalar,
path,
}),
}
}
fn parse_scalar(s: &str) -> Val {
match s {
"true" => Val::Bool(true),
"false" => Val::Bool(false),
"null" => Val::Null,
_ if s.parse::<f64>().is_ok() => Val::Num(s.to_string()),
_ => Val::Str(s.to_string()),
}
}
fn from_yaml(v: &serde_norway::Value) -> Val {
use serde_norway::Value as Y;
match v {
Y::Null => Val::Null,
Y::Bool(b) => Val::Bool(*b),
Y::Number(n) => Val::Num(n.to_string()),
Y::String(s) => Val::Str(s.clone()),
Y::Sequence(items) => Val::Arr {
collapsed: false,
items: items.iter().map(from_yaml).collect(),
},
Y::Mapping(m) => Val::Obj {
collapsed: false,
entries: m.iter().map(|(k, v)| (yaml_key(k), from_yaml(v))).collect(),
},
Y::Tagged(t) => from_yaml(&t.value),
}
}
fn yaml_key(k: &serde_norway::Value) -> String {
use serde_norway::Value as Y;
match k {
Y::String(s) => s.clone(),
Y::Bool(b) => b.to_string(),
Y::Number(n) => n.to_string(),
Y::Null => "null".to_string(),
_ => "?".to_string(),
}
}
fn to_yaml(val: &Val) -> serde_norway::Value {
use serde_norway::Value as Y;
match val {
Val::Null => Y::Null,
Val::Bool(b) => Y::Bool(*b),
Val::Num(s) => s
.parse::<i64>()
.map(Y::from)
.or_else(|_| s.parse::<f64>().map(Y::from))
.unwrap_or_else(|_| Y::String(s.clone())),
Val::Str(s) => Y::String(s.clone()),
Val::Arr { items, .. } => Y::Sequence(items.iter().map(to_yaml).collect()),
Val::Obj { entries, .. } => {
let mut m = serde_norway::Mapping::new();
for (k, v) in entries {
m.insert(Y::String(k.clone()), to_yaml(v));
}
Y::Mapping(m)
}
}
}
fn indent(out: &mut String, depth: usize) {
for _ in 0..depth {
out.push_str(" ");
}
}
fn json_quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
fn json_push(val: &Val, depth: usize, out: &mut String) {
match val {
Val::Null => out.push_str("null"),
Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Val::Num(s) => out.push_str(s),
Val::Str(s) => out.push_str(&json_quote(s)),
Val::Arr { items, .. } => {
if items.is_empty() {
out.push_str("[]");
return;
}
out.push_str("[\n");
for (i, item) in items.iter().enumerate() {
indent(out, depth + 1);
json_push(item, depth + 1, out);
if i + 1 < items.len() {
out.push(',');
}
out.push('\n');
}
indent(out, depth);
out.push(']');
}
Val::Obj { entries, .. } => {
if entries.is_empty() {
out.push_str("{}");
return;
}
out.push_str("{\n");
for (i, (k, v)) in entries.iter().enumerate() {
indent(out, depth + 1);
out.push_str(&json_quote(k));
out.push_str(": ");
json_push(v, depth + 1, out);
if i + 1 < entries.len() {
out.push(',');
}
out.push('\n');
}
indent(out, depth);
out.push('}');
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn code(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn json() -> Tree {
Tree::from_text(r#"{"a": 1, "b": [10, 20], "c": "hi"}"#, Format::Json).unwrap()
}
#[test]
fn parses_json_into_rows_in_order() {
let t = json();
assert_eq!(t.row_count(), 6);
assert_eq!(t.label(1), "a");
assert_eq!(t.value(1), "1");
assert_eq!(t.label(2), "b");
assert!(t.is_container(2));
assert_eq!(t.label(5), "c");
assert_eq!(t.value(5), "hi");
}
#[test]
fn invalid_input_is_an_error() {
assert!(Tree::from_text("{ not valid", Format::Json).is_err());
}
#[test]
fn parse_error_names_the_real_problem() {
let Err(err) = Tree::from_text("a: [1, 2\nb: 3", Format::Yaml) else {
panic!("expected a parse error");
};
assert!(
err.to_lowercase().contains("line"),
"the parser's own diagnostic names a location: {err:?}"
);
}
#[test]
fn collapsing_hides_children() {
let mut t = json();
t.handle_key(code(KeyCode::Down), 8); t.handle_key(code(KeyCode::Down), 8); assert!(t.is_container(t.sel()));
t.handle_key(code(KeyCode::Left), 8); assert!(t.is_collapsed(t.sel()));
assert_eq!(t.row_count(), 4);
}
#[test]
fn edits_a_scalar_value() {
let mut t = json();
t.handle_key(code(KeyCode::Down), 8); t.handle_key(code(KeyCode::Enter), 8); assert!(t.is_editing());
t.handle_key(code(KeyCode::Backspace), 8);
t.handle_key(key('9'), 8);
t.handle_key(code(KeyCode::Enter), 8); assert_eq!(t.value(1), "9");
assert!(t.is_dirty());
assert!(t.to_text().contains("\"a\": 9"));
}
#[test]
fn undo_and_redo_a_value_edit() {
let mut t = json();
t.handle_key(code(KeyCode::Down), 8); t.handle_key(code(KeyCode::Enter), 8);
t.handle_key(code(KeyCode::Backspace), 8);
t.handle_key(key('7'), 8);
t.handle_key(code(KeyCode::Enter), 8);
assert_eq!(t.value(1), "7");
t.handle_key(key('u'), 8);
assert_eq!(t.value(1), "1", "undo restores");
t.handle_key(ctrl('r'), 8);
assert_eq!(t.value(1), "7", "redo reapplies");
}
#[test]
fn json_round_trips_and_preserves_key_order() {
let t = Tree::from_text(r#"{"b": 1, "a": 2}"#, Format::Json).unwrap();
assert_eq!(
t.to_text(),
"{\n \"b\": 1,\n \"a\": 2\n}\n",
"keys stay in source order"
);
}
#[test]
fn yaml_parses_and_serializes() {
let t = Tree::from_text("name: vix\ntags:\n - tui\n - editor\n", Format::Yaml).unwrap();
assert_eq!(t.label(1), "name");
assert_eq!(t.value(1), "vix");
let out = t.to_text();
assert!(out.contains("name: vix"), "got: {out:?}");
assert!(out.contains("- tui"), "got: {out:?}");
}
#[test]
fn save_and_close_outcomes() {
let mut t = json();
assert_eq!(t.handle_key(ctrl('s'), 8), Outcome::Save);
assert_eq!(t.handle_key(key('q'), 8), Outcome::Close);
assert_eq!(t.handle_key(code(KeyCode::Esc), 8), Outcome::Close);
}
proptest::proptest! {
#[test]
fn from_text_never_panics(s in ".*") {
let _ = Tree::from_text(&s, Format::Json);
let _ = Tree::from_text(&s, Format::Yaml);
}
}
#[test]
fn deeply_nested_value_does_not_overflow() {
let deep = format!("{}{}", "[".repeat(100_000), "]".repeat(100_000));
assert!(Tree::from_text(&deep, Format::Json).is_err());
}
}