1#![warn(clippy::pedantic)]
23#![forbid(unsafe_code)]
24#![deny(missing_docs)]
25
26use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Format {
31 Json,
33 Yaml,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Outcome {
40 Consumed,
42 Close,
44 Save,
46}
47
48#[derive(Clone, PartialEq)]
50enum Val {
51 Null,
52 Bool(bool),
53 Num(String),
55 Str(String),
56 Arr {
57 collapsed: bool,
58 items: Vec<Val>,
59 },
60 Obj {
61 collapsed: bool,
62 entries: Vec<(String, Val)>,
63 },
64}
65
66#[derive(Clone, PartialEq)]
68enum Seg {
69 Key(String),
70 Index(usize),
71}
72
73#[derive(Clone, Copy, PartialEq)]
75enum Kind {
76 Scalar,
77 Container(bool),
78}
79
80struct Row {
82 depth: usize,
83 label: String,
84 value: String,
85 kind: Kind,
86 path: Vec<Seg>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum Mode {
92 Normal,
93 Edit,
94}
95
96const HISTORY_CAP: usize = 200;
98
99pub struct Tree {
101 root: Val,
102 format: Format,
103 rows: Vec<Row>,
104 sel: usize,
105 scroll: usize,
106 dirty: bool,
107 mode: Mode,
108 edit_buf: String,
109 undo: Vec<Val>,
110 redo: Vec<Val>,
111}
112
113impl Tree {
114 #[must_use]
117 pub fn from_text(text: &str, format: Format) -> Option<Self> {
118 let parsed: serde_yaml::Value = serde_yaml::from_str(text).ok()?;
119 let mut tree = Tree {
120 root: from_yaml(&parsed),
121 format,
122 rows: Vec::new(),
123 sel: 0,
124 scroll: 0,
125 dirty: false,
126 mode: Mode::Normal,
127 edit_buf: String::new(),
128 undo: Vec::new(),
129 redo: Vec::new(),
130 };
131 tree.rebuild();
132 Some(tree)
133 }
134
135 #[must_use]
137 pub fn format(&self) -> Format {
138 self.format
139 }
140
141 #[must_use]
143 pub fn row_count(&self) -> usize {
144 self.rows.len()
145 }
146
147 #[must_use]
149 pub fn sel(&self) -> usize {
150 self.sel
151 }
152
153 #[must_use]
155 pub fn scroll(&self) -> usize {
156 self.scroll
157 }
158
159 #[must_use]
161 pub fn is_dirty(&self) -> bool {
162 self.dirty
163 }
164
165 #[must_use]
167 pub fn is_editing(&self) -> bool {
168 matches!(self.mode, Mode::Edit)
169 }
170
171 #[must_use]
173 pub fn edit_buffer(&self) -> &str {
174 &self.edit_buf
175 }
176
177 #[must_use]
179 pub fn depth(&self, i: usize) -> usize {
180 self.rows.get(i).map_or(0, |r| r.depth)
181 }
182
183 #[must_use]
185 pub fn label(&self, i: usize) -> &str {
186 self.rows.get(i).map_or("", |r| r.label.as_str())
187 }
188
189 #[must_use]
191 pub fn value(&self, i: usize) -> &str {
192 self.rows.get(i).map_or("", |r| r.value.as_str())
193 }
194
195 #[must_use]
197 pub fn is_container(&self, i: usize) -> bool {
198 matches!(self.rows.get(i).map(|r| r.kind), Some(Kind::Container(_)))
199 }
200
201 #[must_use]
203 pub fn is_collapsed(&self, i: usize) -> bool {
204 matches!(
205 self.rows.get(i).map(|r| r.kind),
206 Some(Kind::Container(true))
207 )
208 }
209
210 #[must_use]
212 pub fn to_text(&self) -> String {
213 match self.format {
214 Format::Json => {
215 let mut out = String::new();
216 json_push(&self.root, 0, &mut out);
217 out.push('\n');
218 out
219 }
220 Format::Yaml => serde_yaml::to_string(&to_yaml(&self.root)).unwrap_or_default(),
221 }
222 }
223
224 pub fn mark_saved(&mut self) {
226 self.dirty = false;
227 }
228
229 pub fn ensure_visible(&mut self, height: usize) {
231 let height = height.max(1);
232 if self.sel < self.scroll {
233 self.scroll = self.sel;
234 } else if self.sel >= self.scroll + height {
235 self.scroll = self.sel + 1 - height;
236 }
237 let max = self.rows.len().saturating_sub(height);
238 self.scroll = self.scroll.min(max);
239 }
240
241 pub fn handle_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
243 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
244 if ctrl && key.code == KeyCode::Char('s') {
245 if self.mode == Mode::Edit {
246 self.commit_edit();
247 }
248 return Outcome::Save;
249 }
250 if self.mode == Mode::Edit {
251 self.edit_key(key);
252 return Outcome::Consumed;
253 }
254 match key.code {
255 KeyCode::Up | KeyCode::Char('k') => self.sel = self.sel.saturating_sub(1),
256 KeyCode::Down | KeyCode::Char('j') => self.step_down(1),
257 KeyCode::PageUp => self.sel = self.sel.saturating_sub(page.max(1)),
258 KeyCode::PageDown => self.step_down(page.max(1)),
259 KeyCode::Home => self.sel = 0,
260 KeyCode::End => self.sel = self.rows.len().saturating_sub(1),
261 KeyCode::Left | KeyCode::Char('h') => self.collapse_or_parent(),
262 KeyCode::Right | KeyCode::Char('l') => self.expand_or_child(),
263 KeyCode::Char(' ') => self.toggle(),
264 KeyCode::Enter | KeyCode::F(2) => self.activate(),
265 KeyCode::Char('u') => self.undo(),
266 KeyCode::Char('r') if ctrl => self.redo(),
267 KeyCode::Esc | KeyCode::Char('q') => return Outcome::Close,
268 _ => {}
269 }
270 Outcome::Consumed
271 }
272
273 fn edit_key(&mut self, key: KeyEvent) {
275 match key.code {
276 KeyCode::Enter => self.commit_edit(),
277 KeyCode::Esc => self.mode = Mode::Normal,
278 KeyCode::Backspace => {
279 self.edit_buf.pop();
280 }
281 KeyCode::Char(c) => self.edit_buf.push(c),
282 _ => {}
283 }
284 }
285
286 fn step_down(&mut self, n: usize) {
288 self.sel = (self.sel + n).min(self.rows.len().saturating_sub(1));
289 }
290
291 fn collapse_or_parent(&mut self) {
293 let Some(row) = self.rows.get(self.sel) else {
294 return;
295 };
296 if matches!(row.kind, Kind::Container(false)) {
297 let path = row.path.clone();
298 self.set_collapsed(&path, true);
299 self.rebuild();
300 self.select_path(&path);
301 } else {
302 let path = row.path.clone();
303 if path.is_empty() {
304 return;
305 }
306 self.select_path(&path[..path.len() - 1]);
307 }
308 }
309
310 fn expand_or_child(&mut self) {
312 let Some(row) = self.rows.get(self.sel) else {
313 return;
314 };
315 match row.kind {
316 Kind::Container(true) => {
317 let path = row.path.clone();
318 self.set_collapsed(&path, false);
319 self.rebuild();
320 self.select_path(&path);
321 }
322 Kind::Container(false) => {
323 if self.sel + 1 < self.rows.len() {
324 self.sel += 1;
325 }
326 }
327 Kind::Scalar => {}
328 }
329 }
330
331 fn toggle(&mut self) {
333 let Some(row) = self.rows.get(self.sel) else {
334 return;
335 };
336 if let Kind::Container(c) = row.kind {
337 let path = row.path.clone();
338 self.set_collapsed(&path, !c);
339 self.rebuild();
340 self.select_path(&path);
341 }
342 }
343
344 fn activate(&mut self) {
346 match self.rows.get(self.sel).map(|r| r.kind) {
347 Some(Kind::Scalar) => {
348 self.edit_buf = self.rows[self.sel].value.clone();
349 self.mode = Mode::Edit;
350 }
351 Some(Kind::Container(_)) => self.toggle(),
352 None => {}
353 }
354 }
355
356 fn commit_edit(&mut self) {
358 self.mode = Mode::Normal;
359 let Some(row) = self.rows.get(self.sel) else {
360 return;
361 };
362 let path = row.path.clone();
363 let new = parse_scalar(&self.edit_buf);
364 if let Some(node) = at_mut(&mut self.root, &path) {
365 if *node == new {
366 return;
367 }
368 self.undo.push(self.root.clone());
369 if self.undo.len() > HISTORY_CAP {
370 self.undo.remove(0);
371 }
372 self.redo.clear();
373 if let Some(node) = at_mut(&mut self.root, &path) {
374 *node = new;
375 }
376 self.dirty = true;
377 self.rebuild();
378 self.select_path(&path);
379 }
380 }
381
382 fn set_collapsed(&mut self, path: &[Seg], collapsed: bool) {
384 if let Some(Val::Arr { collapsed: c, .. } | Val::Obj { collapsed: c, .. }) =
385 at_mut(&mut self.root, path)
386 {
387 *c = collapsed;
388 }
389 }
390
391 fn rebuild(&mut self) {
393 self.rows.clear();
394 flatten(&self.root, 0, String::new(), Vec::new(), &mut self.rows);
395 if self.sel >= self.rows.len() {
396 self.sel = self.rows.len().saturating_sub(1);
397 }
398 }
399
400 fn select_path(&mut self, path: &[Seg]) {
402 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
403 self.sel = i;
404 }
405 }
406
407 fn undo(&mut self) {
409 if let Some(prev) = self.undo.pop() {
410 self.redo.push(std::mem::replace(&mut self.root, prev));
411 self.dirty = true;
412 self.rebuild();
413 }
414 }
415
416 fn redo(&mut self) {
418 if let Some(next) = self.redo.pop() {
419 self.undo.push(std::mem::replace(&mut self.root, next));
420 self.dirty = true;
421 self.rebuild();
422 }
423 }
424}
425
426fn at_mut<'a>(mut node: &'a mut Val, path: &[Seg]) -> Option<&'a mut Val> {
428 for seg in path {
429 node = match (node, seg) {
430 (Val::Arr { items, .. }, Seg::Index(i)) => items.get_mut(*i)?,
431 (Val::Obj { entries, .. }, Seg::Key(k)) => {
432 entries.iter_mut().find(|(ek, _)| ek == k).map(|(_, v)| v)?
433 }
434 _ => return None,
435 };
436 }
437 Some(node)
438}
439
440fn scalar_text(val: &Val) -> String {
442 match val {
443 Val::Null => "null".to_string(),
444 Val::Bool(b) => b.to_string(),
445 Val::Num(s) | Val::Str(s) => s.clone(),
446 _ => String::new(),
447 }
448}
449
450fn flatten(val: &Val, depth: usize, label: String, path: Vec<Seg>, out: &mut Vec<Row>) {
453 match val {
454 Val::Arr { collapsed, items } => {
455 out.push(Row {
456 depth,
457 label,
458 value: format!("[{}]", items.len()),
459 kind: Kind::Container(*collapsed),
460 path: path.clone(),
461 });
462 if !collapsed {
463 for (i, item) in items.iter().enumerate() {
464 let mut p = path.clone();
465 p.push(Seg::Index(i));
466 flatten(item, depth + 1, format!("[{i}]"), p, out);
467 }
468 }
469 }
470 Val::Obj { collapsed, entries } => {
471 out.push(Row {
472 depth,
473 label,
474 value: format!("{{{}}}", entries.len()),
475 kind: Kind::Container(*collapsed),
476 path: path.clone(),
477 });
478 if !collapsed {
479 for (k, v) in entries {
480 let mut p = path.clone();
481 p.push(Seg::Key(k.clone()));
482 flatten(v, depth + 1, k.clone(), p, out);
483 }
484 }
485 }
486 scalar => out.push(Row {
487 depth,
488 label,
489 value: scalar_text(scalar),
490 kind: Kind::Scalar,
491 path,
492 }),
493 }
494}
495
496fn parse_scalar(s: &str) -> Val {
499 match s {
500 "true" => Val::Bool(true),
501 "false" => Val::Bool(false),
502 "null" => Val::Null,
503 _ if s.parse::<f64>().is_ok() => Val::Num(s.to_string()),
504 _ => Val::Str(s.to_string()),
505 }
506}
507
508fn from_yaml(v: &serde_yaml::Value) -> Val {
510 use serde_yaml::Value as Y;
511 match v {
512 Y::Null => Val::Null,
513 Y::Bool(b) => Val::Bool(*b),
514 Y::Number(n) => Val::Num(n.to_string()),
515 Y::String(s) => Val::Str(s.clone()),
516 Y::Sequence(items) => Val::Arr {
517 collapsed: false,
518 items: items.iter().map(from_yaml).collect(),
519 },
520 Y::Mapping(m) => Val::Obj {
521 collapsed: false,
522 entries: m.iter().map(|(k, v)| (yaml_key(k), from_yaml(v))).collect(),
523 },
524 Y::Tagged(t) => from_yaml(&t.value),
525 }
526}
527
528fn yaml_key(k: &serde_yaml::Value) -> String {
530 use serde_yaml::Value as Y;
531 match k {
532 Y::String(s) => s.clone(),
533 Y::Bool(b) => b.to_string(),
534 Y::Number(n) => n.to_string(),
535 Y::Null => "null".to_string(),
536 _ => "?".to_string(),
537 }
538}
539
540fn to_yaml(val: &Val) -> serde_yaml::Value {
542 use serde_yaml::Value as Y;
543 match val {
544 Val::Null => Y::Null,
545 Val::Bool(b) => Y::Bool(*b),
546 Val::Num(s) => s
547 .parse::<i64>()
548 .map(Y::from)
549 .or_else(|_| s.parse::<f64>().map(Y::from))
550 .unwrap_or_else(|_| Y::String(s.clone())),
551 Val::Str(s) => Y::String(s.clone()),
552 Val::Arr { items, .. } => Y::Sequence(items.iter().map(to_yaml).collect()),
553 Val::Obj { entries, .. } => {
554 let mut m = serde_yaml::Mapping::new();
555 for (k, v) in entries {
556 m.insert(Y::String(k.clone()), to_yaml(v));
557 }
558 Y::Mapping(m)
559 }
560 }
561}
562
563fn indent(out: &mut String, depth: usize) {
565 for _ in 0..depth {
566 out.push_str(" ");
567 }
568}
569
570fn json_quote(s: &str) -> String {
572 serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
573}
574
575fn json_push(val: &Val, depth: usize, out: &mut String) {
577 match val {
578 Val::Null => out.push_str("null"),
579 Val::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
580 Val::Num(s) => out.push_str(s),
581 Val::Str(s) => out.push_str(&json_quote(s)),
582 Val::Arr { items, .. } => {
583 if items.is_empty() {
584 out.push_str("[]");
585 return;
586 }
587 out.push_str("[\n");
588 for (i, item) in items.iter().enumerate() {
589 indent(out, depth + 1);
590 json_push(item, depth + 1, out);
591 if i + 1 < items.len() {
592 out.push(',');
593 }
594 out.push('\n');
595 }
596 indent(out, depth);
597 out.push(']');
598 }
599 Val::Obj { entries, .. } => {
600 if entries.is_empty() {
601 out.push_str("{}");
602 return;
603 }
604 out.push_str("{\n");
605 for (i, (k, v)) in entries.iter().enumerate() {
606 indent(out, depth + 1);
607 out.push_str(&json_quote(k));
608 out.push_str(": ");
609 json_push(v, depth + 1, out);
610 if i + 1 < entries.len() {
611 out.push(',');
612 }
613 out.push('\n');
614 }
615 indent(out, depth);
616 out.push('}');
617 }
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 fn key(c: char) -> KeyEvent {
626 KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
627 }
628
629 fn code(code: KeyCode) -> KeyEvent {
630 KeyEvent::new(code, KeyModifiers::NONE)
631 }
632
633 fn ctrl(c: char) -> KeyEvent {
634 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
635 }
636
637 fn json() -> Tree {
638 Tree::from_text(r#"{"a": 1, "b": [10, 20], "c": "hi"}"#, Format::Json).unwrap()
639 }
640
641 #[test]
642 fn parses_json_into_rows_in_order() {
643 let t = json();
644 assert_eq!(t.row_count(), 6);
646 assert_eq!(t.label(1), "a");
647 assert_eq!(t.value(1), "1");
648 assert_eq!(t.label(2), "b");
649 assert!(t.is_container(2));
650 assert_eq!(t.label(5), "c");
651 assert_eq!(t.value(5), "hi");
652 }
653
654 #[test]
655 fn invalid_input_is_none() {
656 assert!(Tree::from_text("{ not valid", Format::Json).is_none());
657 }
658
659 #[test]
660 fn collapsing_hides_children() {
661 let mut t = json();
662 t.handle_key(code(KeyCode::Down), 8); t.handle_key(code(KeyCode::Down), 8); assert!(t.is_container(t.sel()));
665 t.handle_key(code(KeyCode::Left), 8); assert!(t.is_collapsed(t.sel()));
667 assert_eq!(t.row_count(), 4);
669 }
670
671 #[test]
672 fn edits_a_scalar_value() {
673 let mut t = json();
674 t.handle_key(code(KeyCode::Down), 8); t.handle_key(code(KeyCode::Enter), 8); assert!(t.is_editing());
677 t.handle_key(code(KeyCode::Backspace), 8);
678 t.handle_key(key('9'), 8);
679 t.handle_key(code(KeyCode::Enter), 8); assert_eq!(t.value(1), "9");
681 assert!(t.is_dirty());
682 assert!(t.to_text().contains("\"a\": 9"));
683 }
684
685 #[test]
686 fn undo_and_redo_a_value_edit() {
687 let mut t = json();
688 t.handle_key(code(KeyCode::Down), 8); t.handle_key(code(KeyCode::Enter), 8);
690 t.handle_key(code(KeyCode::Backspace), 8);
691 t.handle_key(key('7'), 8);
692 t.handle_key(code(KeyCode::Enter), 8);
693 assert_eq!(t.value(1), "7");
694 t.handle_key(key('u'), 8);
695 assert_eq!(t.value(1), "1", "undo restores");
696 t.handle_key(ctrl('r'), 8);
697 assert_eq!(t.value(1), "7", "redo reapplies");
698 }
699
700 #[test]
701 fn json_round_trips_and_preserves_key_order() {
702 let t = Tree::from_text(r#"{"b": 1, "a": 2}"#, Format::Json).unwrap();
703 assert_eq!(
704 t.to_text(),
705 "{\n \"b\": 1,\n \"a\": 2\n}\n",
706 "keys stay in source order"
707 );
708 }
709
710 #[test]
711 fn yaml_parses_and_serializes() {
712 let t = Tree::from_text("name: vix\ntags:\n - tui\n - editor\n", Format::Yaml).unwrap();
713 assert_eq!(t.label(1), "name");
714 assert_eq!(t.value(1), "vix");
715 let out = t.to_text();
716 assert!(out.contains("name: vix"), "got: {out:?}");
717 assert!(out.contains("- tui"), "got: {out:?}");
718 }
719
720 #[test]
721 fn save_and_close_outcomes() {
722 let mut t = json();
723 assert_eq!(t.handle_key(ctrl('s'), 8), Outcome::Save);
724 assert_eq!(t.handle_key(key('q'), 8), Outcome::Close);
725 assert_eq!(t.handle_key(code(KeyCode::Esc), 8), Outcome::Close);
726 }
727
728 proptest::proptest! {
729 #[test]
731 fn from_text_never_panics(s in ".*") {
732 let _ = Tree::from_text(&s, Format::Json);
733 let _ = Tree::from_text(&s, Format::Yaml);
734 }
735 }
736
737 #[test]
738 fn deeply_nested_value_does_not_overflow() {
739 let deep = format!("{}{}", "[".repeat(100_000), "]".repeat(100_000));
742 assert!(Tree::from_text(&deep, Format::Json).is_none());
743 }
744}