Skip to content

Commit e0c74c0

Browse files
authored
Merge pull request #100 from ynqa/v0.16.0/dev
v0.16.0
2 parents 56550ed + 8161d6e commit e0c74c0

11 files changed

Lines changed: 1174 additions & 174 deletions

File tree

‎Cargo.toml‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ members = [
1010
]
1111

1212
[workspace.dependencies]
13-
anyhow = "1.0.102"
14-
async-trait = "0.1.89"
15-
bitflags = "2.9.0"
13+
anyhow = "1.0.104"
14+
async-trait = "0.1.92"
15+
bitflags = "2.13.1"
1616
crossbeam-skiplist = "0.1.3"
1717
crossterm = { version = "0.29.0", features = ["use-dev-tty", "event-stream", "serde"] }
1818
csv = "1.3.1"
19-
futures = "0.3.32"
19+
futures = "0.3.34"
2020
radix_trie = "0.3.0"
21-
rayon = "1.11.0"
22-
serde = "1.0.228"
23-
serde_json = { version = "1.0.149", features = ["preserve_order"] }
21+
rayon = "1.12.0"
22+
serde = "1.0.229"
23+
serde_json = { version = "1.0.151", features = ["preserve_order"] }
2424
serde_yaml = "0.9.34"
2525
termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] }
26-
tokio = { version = "1.49.0", features = ["full"] }
27-
thiserror = "2.0.18"
26+
tokio = { version = "1.53.1", features = ["full"] }
27+
thiserror = "2.0.20"
2828
unicode-width = "0.2.2"

‎promkit-widgets/benches/structured/json.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ fn benchmark_fixture(c: &mut Criterion, path: &Path) {
6464
b.iter(|| black_box(state.create_graphemes_in_viewport(VIEWPORT_WIDTH, VIEWPORT_HEIGHT)));
6565
});
6666

67+
state.document.tail();
68+
state.document.up();
69+
group.bench_function("selected_path/tail", |b| {
70+
b.iter(|| black_box(state.document.selected_path()));
71+
});
72+
73+
state.document.head();
6774
let mut forward = true;
6875
group.bench_function("cursor_only", |b| {
6976
b.iter(|| {

‎promkit-widgets/benches/structured/yaml.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ fn benchmark_fixture(c: &mut Criterion, path: &Path) {
6565
b.iter(|| black_box(state.create_graphemes_in_viewport(VIEWPORT_WIDTH, VIEWPORT_HEIGHT)));
6666
});
6767

68+
state.document.tail();
69+
group.bench_function("selected_path/tail", |b| {
70+
b.iter(|| black_box(state.document.selected_path()));
71+
});
72+
73+
state.document.head();
6874
let mut forward = true;
6975
group.bench_function("cursor_only", |b| {
7076
b.iter(|| {

‎promkit-widgets/src/structured/json/document.rs‎

Lines changed: 236 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ use std::{cell::Cell, io::Read};
22

33
use super::{
44
deserializer,
5-
jsonz::{self, Row, RowOperation},
5+
jsonz::{self, ContainerNode, ContainerType, JsonNode, Row, RowOperation},
6+
};
7+
use crate::structured::{
8+
PathIndex, PathRow, ProjectionViewport, create_path_indices,
9+
path::{append_bracket, append_string_key},
10+
projection_viewport,
611
};
7-
use crate::structured::{ProjectionViewport, projection_viewport};
812

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

3338
fn from_rows(rows: Vec<Row>) -> Self {
39+
let path_indices = json_path_indices(&rows);
3440
Self {
3541
rows,
42+
path_indices,
3643
position: 0,
3744
viewport: Cell::default(),
3845
}
@@ -107,6 +114,11 @@ impl Document {
107114
visible_position(&self.rows, self.position)
108115
}
109116

117+
/// Returns the zero-based document index and jq-style path of the selected row.
118+
pub fn selected_path(&self) -> Option<(usize, String)> {
119+
path_at_row(&self.rows, &self.path_indices, self.position)
120+
}
121+
110122
/// Toggles the visibility of a node at the cursor's current position.
111123
pub fn toggle(&mut self) {
112124
let index = self.rows.toggle(self.position);
@@ -130,6 +142,40 @@ impl Document {
130142
row_index_at_visible_position(&self.rows, self.position, visible_offset)
131143
}
132144

145+
/// Resolves a jq-style path in a zero-based document to its underlying row index.
146+
///
147+
/// Each top-level JSON value, including each JSON Lines value, increments the document index.
148+
///
149+
/// Paths use dot notation for identifier keys and bracket notation for array indices and
150+
/// other string keys, for example `.items[0].name` and `["first name"]`.
151+
pub fn row_index_for_path(&self, document_index: usize, path: &str) -> Option<usize> {
152+
locate_path(&self.rows, document_index, path).map(|located| located.row_index)
153+
}
154+
155+
/// Moves the cursor to the value at a jq-style path in a zero-based document.
156+
///
157+
/// Each top-level JSON value, including each JSON Lines value, increments the document index.
158+
///
159+
/// Folded ancestors are expanded while unrelated folding state is preserved.
160+
pub fn move_to_path(&mut self, document_index: usize, path: &str) -> bool {
161+
let Some(located) = locate_path(&self.rows, document_index, path) else {
162+
return false;
163+
};
164+
for open_index in located.ancestors {
165+
if matches!(
166+
self.rows[open_index].node,
167+
JsonNode::Container(ContainerNode::Open {
168+
collapsed: true,
169+
..
170+
})
171+
) {
172+
self.rows.toggle(open_index);
173+
}
174+
}
175+
self.position = located.row_index;
176+
true
177+
}
178+
133179
pub(super) fn row_index_at_viewport_position(&self, visible_position: usize) -> Option<usize> {
134180
let viewport = self.viewport.get();
135181
viewport
@@ -173,6 +219,120 @@ impl Document {
173219
}
174220
}
175221

222+
struct PathFrame {
223+
path: String,
224+
typ: ContainerType,
225+
next_index: usize,
226+
open_index: usize,
227+
}
228+
229+
struct LocatedRow {
230+
row_index: usize,
231+
ancestors: Vec<usize>,
232+
}
233+
234+
fn locate_path(rows: &[Row], document_index: usize, target: &str) -> Option<LocatedRow> {
235+
let mut stack: Vec<PathFrame> = Vec::new();
236+
let mut current_document_index = None;
237+
let mut next_document_index = 0;
238+
239+
for (row_index, row) in rows.iter().enumerate() {
240+
if matches!(row.node, JsonNode::Container(ContainerNode::Close { .. })) {
241+
stack.truncate(row.depth);
242+
continue;
243+
}
244+
if row.depth == 0 {
245+
if next_document_index > document_index {
246+
return None;
247+
}
248+
current_document_index = Some(next_document_index);
249+
next_document_index += 1;
250+
stack.clear();
251+
}
252+
if current_document_index != Some(document_index) {
253+
continue;
254+
}
255+
stack.truncate(row.depth);
256+
257+
let path = if row.depth == 0 {
258+
".".to_owned()
259+
} else {
260+
let parent = stack.get_mut(row.depth - 1)?;
261+
match parent.typ {
262+
ContainerType::Object => append_string_key(&parent.path, row.key.as_deref()?),
263+
ContainerType::Array => {
264+
let path = append_bracket(&parent.path, &parent.next_index.to_string());
265+
parent.next_index += 1;
266+
path
267+
}
268+
}
269+
};
270+
271+
if path == target {
272+
return Some(LocatedRow {
273+
row_index,
274+
ancestors: stack.iter().map(|frame| frame.open_index).collect(),
275+
});
276+
}
277+
278+
if let JsonNode::Container(ContainerNode::Open { typ, .. }) = &row.node {
279+
stack.push(PathFrame {
280+
path,
281+
typ: typ.clone(),
282+
next_index: 0,
283+
open_index: row_index,
284+
});
285+
}
286+
}
287+
288+
None
289+
}
290+
291+
fn json_path_indices(rows: &[Row]) -> Box<[PathIndex]> {
292+
create_path_indices(rows.iter().map(|row| match &row.node {
293+
JsonNode::Container(ContainerNode::Close { .. }) => PathRow::Close { depth: row.depth },
294+
JsonNode::Container(ContainerNode::Open { typ, .. }) => PathRow::Value {
295+
depth: row.depth,
296+
open_type: Some(typ.clone()),
297+
},
298+
_ => PathRow::Value {
299+
depth: row.depth,
300+
open_type: None,
301+
},
302+
}))
303+
}
304+
305+
fn path_at_row(
306+
rows: &[Row],
307+
path_indices: &[PathIndex],
308+
target_row_index: usize,
309+
) -> Option<(usize, String)> {
310+
let target_row_index = match &rows.get(target_row_index)?.node {
311+
JsonNode::Container(ContainerNode::Close { open_index, .. }) => *open_index,
312+
_ => target_row_index,
313+
};
314+
let mut chain = vec![target_row_index];
315+
while rows[*chain.last()?].depth > 0 {
316+
chain.push(path_indices[*chain.last()?].parent()?);
317+
}
318+
319+
let root_index = *chain.last()?;
320+
let document_index = path_indices[root_index].document_index()?;
321+
let mut path = ".".to_owned();
322+
for &row_index in chain.iter().rev().skip(1) {
323+
let index = path_indices[row_index];
324+
let parent_index = index.parent()?;
325+
let JsonNode::Container(ContainerNode::Open { typ, .. }) = &rows[parent_index].node else {
326+
return None;
327+
};
328+
path = match typ {
329+
ContainerType::Object => append_string_key(&path, rows[row_index].key.as_deref()?),
330+
ContainerType::Array => append_bracket(&path, &index.array_index()?.to_string()),
331+
};
332+
}
333+
Some((document_index, path))
334+
}
335+
176336
fn visible_position(rows: &Vec<Row>, target: usize) -> usize {
177337
let mut position = rows.head();
178338
let mut visible = 0;
@@ -276,4 +436,78 @@ mod tests {
276436
assert!(Document::from_reader(Cursor::new(b"[1,")).is_err());
277437
}
278438
}
439+
440+
mod row_index_for_path {
441+
use super::*;
442+
443+
#[test]
444+
fn resolves_nested_values_arrays_and_quoted_keys() {
445+
let document = Document::from_str(
446+
r#"{"items":[null,{"first name":true}],"true":false,"a\"b\n":0}"#,
447+
)
448+
.unwrap();
449+
450+
assert_eq!(document.row_index_for_path(0, "."), Some(0));
451+
assert_eq!(document.row_index_for_path(0, ".items"), Some(1));
452+
assert_eq!(document.row_index_for_path(0, ".items[1]"), Some(3));
453+
assert_eq!(
454+
document.row_index_for_path(0, r#".items[1]["first name"]"#),
455+
Some(4)
456+
);
457+
assert_eq!(document.row_index_for_path(0, r#"["true"]"#), Some(7));
458+
assert_eq!(document.row_index_for_path(0, r#"["a\"b\n"]"#), Some(8));
459+
assert_eq!(document.row_index_for_path(0, ".missing"), None);
460+
}
461+
462+
#[test]
463+
fn distinguishes_json_lines_documents() {
464+
let document = Document::from_str(concat!(
465+
"{\"name\":\"first\"}\n",
466+
"{\"name\":\"second\",\"second_only\":true}\n",
467+
))
468+
.unwrap();
469+
470+
let first = document.row_index_for_path(0, ".name").unwrap();
471+
let second = document.row_index_for_path(1, ".name").unwrap();
472+
assert_ne!(first, second);
473+
assert_eq!(document.row_index_for_path(0, ".second_only"), None);
474+
assert!(document.row_index_for_path(1, ".second_only").is_some());
475+
assert_eq!(document.row_index_for_path(2, "."), None);
476+
}
477+
}
478+
479+
mod move_to_path {
480+
use super::*;
481+
482+
#[test]
483+
fn expands_ancestors_and_selects_the_target() {
484+
let mut document =
485+
Document::from_str(r#"{"items":[{"nested":true}],"other":{"value":1}}"#).unwrap();
486+
document.toggle_at(1);
487+
document.toggle_at(6);
488+
489+
assert!(document.move_to_path(0, ".items[0].nested"));
490+
assert_eq!(document.visible_position(), 3);
491+
assert_eq!(
492+
document.selected_path(),
493+
Some((0, ".items[0].nested".to_owned()))
494+
);
495+
assert_eq!(document.visible_rows().len(), 8);
496+
assert!(!document.move_to_path(0, ".missing"));
497+
}
498+
499+
#[test]
500+
fn selects_a_json_lines_document() {
501+
let mut document =
502+
Document::from_str("{\"first_only\":true}\n{\"second_only\":true}\n").unwrap();
503+
504+
assert!(document.move_to_path(1, ".second_only"));
505+
assert_eq!(
506+
document.selected_path(),
507+
Some((1, ".second_only".to_owned()))
508+
);
509+
assert!(!document.move_to_path(0, ".second_only"));
510+
assert!(!document.move_to_path(2, "."));
511+
}
512+
}
279513
}

0 commit comments

Comments
 (0)