Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ members = [
]

[workspace.dependencies]
anyhow = "1.0.102"
async-trait = "0.1.89"
bitflags = "2.9.0"
anyhow = "1.0.104"
async-trait = "0.1.92"
bitflags = "2.13.1"
crossbeam-skiplist = "0.1.3"
crossterm = { version = "0.29.0", features = ["use-dev-tty", "event-stream", "serde"] }
csv = "1.3.1"
futures = "0.3.32"
futures = "0.3.34"
radix_trie = "0.3.0"
rayon = "1.11.0"
serde = "1.0.228"
serde_json = { version = "1.0.149", features = ["preserve_order"] }
rayon = "1.12.0"
serde = "1.0.229"
serde_json = { version = "1.0.151", features = ["preserve_order"] }
serde_yaml = "0.9.34"
termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] }
tokio = { version = "1.49.0", features = ["full"] }
thiserror = "2.0.18"
tokio = { version = "1.53.1", features = ["full"] }
thiserror = "2.0.20"
unicode-width = "0.2.2"
7 changes: 7 additions & 0 deletions promkit-widgets/benches/structured/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ fn benchmark_fixture(c: &mut Criterion, path: &Path) {
b.iter(|| black_box(state.create_graphemes_in_viewport(VIEWPORT_WIDTH, VIEWPORT_HEIGHT)));
});

state.document.tail();
state.document.up();
group.bench_function("selected_path/tail", |b| {
b.iter(|| black_box(state.document.selected_path()));
});

state.document.head();
let mut forward = true;
group.bench_function("cursor_only", |b| {
b.iter(|| {
Expand Down
6 changes: 6 additions & 0 deletions promkit-widgets/benches/structured/yaml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ fn benchmark_fixture(c: &mut Criterion, path: &Path) {
b.iter(|| black_box(state.create_graphemes_in_viewport(VIEWPORT_WIDTH, VIEWPORT_HEIGHT)));
});

state.document.tail();
group.bench_function("selected_path/tail", |b| {
b.iter(|| black_box(state.document.selected_path()));
});

state.document.head();
let mut forward = true;
group.bench_function("cursor_only", |b| {
b.iter(|| {
Expand Down
238 changes: 236 additions & 2 deletions promkit-widgets/src/structured/json/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ use std::{cell::Cell, io::Read};

use super::{
deserializer,
jsonz::{self, Row, RowOperation},
jsonz::{self, ContainerNode, ContainerType, JsonNode, Row, RowOperation},
};
use crate::structured::{
PathIndex, PathRow, ProjectionViewport, create_path_indices,
path::{append_bracket, append_string_key},
projection_viewport,
};
use crate::structured::{ProjectionViewport, projection_viewport};

/// Represents a navigable JSON document, allowing for efficient row navigation and folding.
#[derive(Clone)]
pub struct Document {
rows: Vec<Row>,
path_indices: Box<[PathIndex]>,
position: usize,
viewport: Cell<ProjectionViewport>,
}
Expand All @@ -31,8 +36,10 @@ impl Document {
}

fn from_rows(rows: Vec<Row>) -> Self {
let path_indices = json_path_indices(&rows);
Self {
rows,
path_indices,
position: 0,
viewport: Cell::default(),
}
Expand Down Expand Up @@ -107,6 +114,11 @@ impl Document {
visible_position(&self.rows, self.position)
}

/// Returns the zero-based document index and jq-style path of the selected row.
pub fn selected_path(&self) -> Option<(usize, String)> {
path_at_row(&self.rows, &self.path_indices, self.position)
}

/// Toggles the visibility of a node at the cursor's current position.
pub fn toggle(&mut self) {
let index = self.rows.toggle(self.position);
Expand All @@ -130,6 +142,40 @@ impl Document {
row_index_at_visible_position(&self.rows, self.position, visible_offset)
}

/// Resolves a jq-style path in a zero-based document to its underlying row index.
///
/// Each top-level JSON value, including each JSON Lines value, increments the document index.
///
/// Paths use dot notation for identifier keys and bracket notation for array indices and
/// other string keys, for example `.items[0].name` and `["first name"]`.
pub fn row_index_for_path(&self, document_index: usize, path: &str) -> Option<usize> {
locate_path(&self.rows, document_index, path).map(|located| located.row_index)
}

/// Moves the cursor to the value at a jq-style path in a zero-based document.
///
/// Each top-level JSON value, including each JSON Lines value, increments the document index.
///
/// Folded ancestors are expanded while unrelated folding state is preserved.
pub fn move_to_path(&mut self, document_index: usize, path: &str) -> bool {
let Some(located) = locate_path(&self.rows, document_index, path) else {
return false;
};
for open_index in located.ancestors {
if matches!(
self.rows[open_index].node,
JsonNode::Container(ContainerNode::Open {
collapsed: true,
..
})
) {
self.rows.toggle(open_index);
}
}
self.position = located.row_index;
true
}

pub(super) fn row_index_at_viewport_position(&self, visible_position: usize) -> Option<usize> {
let viewport = self.viewport.get();
viewport
Expand Down Expand Up @@ -173,6 +219,120 @@ impl Document {
}
}

struct PathFrame {
path: String,
typ: ContainerType,
next_index: usize,
open_index: usize,
}

struct LocatedRow {
row_index: usize,
ancestors: Vec<usize>,
}

fn locate_path(rows: &[Row], document_index: usize, target: &str) -> Option<LocatedRow> {
let mut stack: Vec<PathFrame> = Vec::new();
let mut current_document_index = None;
let mut next_document_index = 0;

for (row_index, row) in rows.iter().enumerate() {
if matches!(row.node, JsonNode::Container(ContainerNode::Close { .. })) {
stack.truncate(row.depth);
continue;
}
if row.depth == 0 {
if next_document_index > document_index {
return None;
}
current_document_index = Some(next_document_index);
next_document_index += 1;
stack.clear();
}
if current_document_index != Some(document_index) {
continue;
}
stack.truncate(row.depth);

let path = if row.depth == 0 {
".".to_owned()
} else {
let parent = stack.get_mut(row.depth - 1)?;
match parent.typ {
ContainerType::Object => append_string_key(&parent.path, row.key.as_deref()?),
ContainerType::Array => {
let path = append_bracket(&parent.path, &parent.next_index.to_string());
parent.next_index += 1;
path
}
}
};

if path == target {
return Some(LocatedRow {
row_index,
ancestors: stack.iter().map(|frame| frame.open_index).collect(),
});
}

if let JsonNode::Container(ContainerNode::Open { typ, .. }) = &row.node {
stack.push(PathFrame {
path,
typ: typ.clone(),
next_index: 0,
open_index: row_index,
});
}
}

None
}

fn json_path_indices(rows: &[Row]) -> Box<[PathIndex]> {
create_path_indices(rows.iter().map(|row| match &row.node {
JsonNode::Container(ContainerNode::Close { .. }) => PathRow::Close { depth: row.depth },
JsonNode::Container(ContainerNode::Open { typ, .. }) => PathRow::Value {
depth: row.depth,
open_type: Some(typ.clone()),
},
_ => PathRow::Value {
depth: row.depth,
open_type: None,
},
}))
}

fn path_at_row(
rows: &[Row],
path_indices: &[PathIndex],
target_row_index: usize,
) -> Option<(usize, String)> {
let target_row_index = match &rows.get(target_row_index)?.node {
JsonNode::Container(ContainerNode::Close { open_index, .. }) => *open_index,
_ => target_row_index,
};
let mut chain = vec![target_row_index];
while rows[*chain.last()?].depth > 0 {
chain.push(path_indices[*chain.last()?].parent()?);
}

let root_index = *chain.last()?;
let document_index = path_indices[root_index].document_index()?;
let mut path = ".".to_owned();
for &row_index in chain.iter().rev().skip(1) {
let index = path_indices[row_index];
let parent_index = index.parent()?;
let JsonNode::Container(ContainerNode::Open { typ, .. }) = &rows[parent_index].node else {
return None;
};
path = match typ {
ContainerType::Object => append_string_key(&path, rows[row_index].key.as_deref()?),
ContainerType::Array => append_bracket(&path, &index.array_index()?.to_string()),
};
}
Some((document_index, path))
}

fn visible_position(rows: &Vec<Row>, target: usize) -> usize {
let mut position = rows.head();
let mut visible = 0;
Expand Down Expand Up @@ -276,4 +436,78 @@ mod tests {
assert!(Document::from_reader(Cursor::new(b"[1,")).is_err());
}
}

mod row_index_for_path {
use super::*;

#[test]
fn resolves_nested_values_arrays_and_quoted_keys() {
let document = Document::from_str(
r#"{"items":[null,{"first name":true}],"true":false,"a\"b\n":0}"#,
)
.unwrap();

assert_eq!(document.row_index_for_path(0, "."), Some(0));
assert_eq!(document.row_index_for_path(0, ".items"), Some(1));
assert_eq!(document.row_index_for_path(0, ".items[1]"), Some(3));
assert_eq!(
document.row_index_for_path(0, r#".items[1]["first name"]"#),
Some(4)
);
assert_eq!(document.row_index_for_path(0, r#"["true"]"#), Some(7));
assert_eq!(document.row_index_for_path(0, r#"["a\"b\n"]"#), Some(8));
assert_eq!(document.row_index_for_path(0, ".missing"), None);
}

#[test]
fn distinguishes_json_lines_documents() {
let document = Document::from_str(concat!(
"{\"name\":\"first\"}\n",
"{\"name\":\"second\",\"second_only\":true}\n",
))
.unwrap();

let first = document.row_index_for_path(0, ".name").unwrap();
let second = document.row_index_for_path(1, ".name").unwrap();
assert_ne!(first, second);
assert_eq!(document.row_index_for_path(0, ".second_only"), None);
assert!(document.row_index_for_path(1, ".second_only").is_some());
assert_eq!(document.row_index_for_path(2, "."), None);
}
}

mod move_to_path {
use super::*;

#[test]
fn expands_ancestors_and_selects_the_target() {
let mut document =
Document::from_str(r#"{"items":[{"nested":true}],"other":{"value":1}}"#).unwrap();
document.toggle_at(1);
document.toggle_at(6);

assert!(document.move_to_path(0, ".items[0].nested"));
assert_eq!(document.visible_position(), 3);
assert_eq!(
document.selected_path(),
Some((0, ".items[0].nested".to_owned()))
);
assert_eq!(document.visible_rows().len(), 8);
assert!(!document.move_to_path(0, ".missing"));
}

#[test]
fn selects_a_json_lines_document() {
let mut document =
Document::from_str("{\"first_only\":true}\n{\"second_only\":true}\n").unwrap();

assert!(document.move_to_path(1, ".second_only"));
assert_eq!(
document.selected_path(),
Some((1, ".second_only".to_owned()))
);
assert!(!document.move_to_path(0, ".second_only"));
assert!(!document.move_to_path(2, "."));
}
}
}
Loading
Loading