Skip to content

Commit 500cdf9

Browse files
committed
Add path navigation to structured documents
1 parent 56550ed commit 500cdf9

8 files changed

Lines changed: 785 additions & 165 deletions

File tree

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

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ 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+
ProjectionViewport,
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)]
@@ -130,6 +134,36 @@ impl Document {
130134
row_index_at_visible_position(&self.rows, self.position, visible_offset)
131135
}
132136

137+
/// Resolves a jq-style path to its underlying document row index.
138+
///
139+
/// Paths use dot notation for identifier keys and bracket notation for array indices and
140+
/// other string keys, for example `.items[0].name` and `["first name"]`.
141+
pub fn row_index_for_path(&self, path: &str) -> Option<usize> {
142+
locate_path(&self.rows, path).map(|located| located.row_index)
143+
}
144+
145+
/// Moves the cursor to the value at a jq-style path.
146+
///
147+
/// Folded ancestors are expanded while unrelated folding state is preserved.
148+
pub fn move_to_path(&mut self, path: &str) -> bool {
149+
let Some(located) = locate_path(&self.rows, path) else {
150+
return false;
151+
};
152+
for open_index in located.ancestors {
153+
if matches!(
154+
self.rows[open_index].node,
155+
JsonNode::Container(ContainerNode::Open {
156+
collapsed: true,
157+
..
158+
})
159+
) {
160+
self.rows.toggle(open_index);
161+
}
162+
}
163+
self.position = located.row_index;
164+
true
165+
}
166+
133167
pub(super) fn row_index_at_viewport_position(&self, visible_position: usize) -> Option<usize> {
134168
let viewport = self.viewport.get();
135169
viewport
@@ -173,6 +207,62 @@ impl Document {
173207
}
174208
}
175209

210+
struct PathFrame {
211+
path: String,
212+
typ: ContainerType,
213+
next_index: usize,
214+
open_index: usize,
215+
}
216+
217+
struct LocatedRow {
218+
row_index: usize,
219+
ancestors: Vec<usize>,
220+
}
221+
222+
fn locate_path(rows: &[Row], target: &str) -> Option<LocatedRow> {
223+
let mut stack: Vec<PathFrame> = Vec::new();
224+
225+
for (row_index, row) in rows.iter().enumerate() {
226+
if matches!(row.node, JsonNode::Container(ContainerNode::Close { .. })) {
227+
stack.truncate(row.depth);
228+
continue;
229+
}
230+
stack.truncate(row.depth);
231+
232+
let path = if row.depth == 0 {
233+
".".to_owned()
234+
} else {
235+
let parent = stack.get_mut(row.depth - 1)?;
236+
match parent.typ {
237+
ContainerType::Object => append_string_key(&parent.path, row.key.as_deref()?),
238+
ContainerType::Array => {
239+
let path = append_bracket(&parent.path, &parent.next_index.to_string());
240+
parent.next_index += 1;
241+
path
242+
}
243+
}
244+
};
245+
246+
if path == target {
247+
return Some(LocatedRow {
248+
row_index,
249+
ancestors: stack.iter().map(|frame| frame.open_index).collect(),
250+
});
251+
}
252+
253+
if let JsonNode::Container(ContainerNode::Open { typ, .. }) = &row.node {
254+
stack.push(PathFrame {
255+
path,
256+
typ: typ.clone(),
257+
next_index: 0,
258+
open_index: row_index,
259+
});
260+
}
261+
}
262+
263+
None
264+
}
265+
176266
fn visible_position(rows: &Vec<Row>, target: usize) -> usize {
177267
let mut position = rows.head();
178268
let mut visible = 0;
@@ -276,4 +366,44 @@ mod tests {
276366
assert!(Document::from_reader(Cursor::new(b"[1,")).is_err());
277367
}
278368
}
369+
370+
mod row_index_for_path {
371+
use super::*;
372+
373+
#[test]
374+
fn resolves_nested_values_arrays_and_quoted_keys() {
375+
let document = Document::from_str(
376+
r#"{"items":[null,{"first name":true}],"true":false,"a\"b\n":0}"#,
377+
)
378+
.unwrap();
379+
380+
assert_eq!(document.row_index_for_path("."), Some(0));
381+
assert_eq!(document.row_index_for_path(".items"), Some(1));
382+
assert_eq!(document.row_index_for_path(".items[1]"), Some(3));
383+
assert_eq!(
384+
document.row_index_for_path(r#".items[1]["first name"]"#),
385+
Some(4)
386+
);
387+
assert_eq!(document.row_index_for_path(r#"["true"]"#), Some(7));
388+
assert_eq!(document.row_index_for_path(r#"["a\"b\n"]"#), Some(8));
389+
assert_eq!(document.row_index_for_path(".missing"), None);
390+
}
391+
}
392+
393+
mod move_to_path {
394+
use super::*;
395+
396+
#[test]
397+
fn expands_ancestors_and_selects_the_target() {
398+
let mut document =
399+
Document::from_str(r#"{"items":[{"nested":true}],"other":{"value":1}}"#).unwrap();
400+
document.toggle_at(1);
401+
document.toggle_at(6);
402+
403+
assert!(document.move_to_path(".items[0].nested"));
404+
assert_eq!(document.visible_position(), 3);
405+
assert_eq!(document.visible_rows().len(), 8);
406+
assert!(!document.move_to_path(".missing"));
407+
}
408+
}
279409
}

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

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use rayon::prelude::*;
22

3+
use crate::structured::path::{append_bracket, append_string_key};
4+
35
pub use crate::structured::{ContainerNode, ContainerType, PrettyRender, RowOperation};
46

57
#[derive(Clone, Debug, PartialEq)]
@@ -63,22 +65,18 @@ impl PrettyRender for [Row] {
6365
},
6466
}
6567

66-
if i + 1 < self.len() {
67-
if matches!(
68+
if i + 1 < self.len()
69+
&& !matches!(
6870
&self[i + 1].node,
6971
JsonNode::Container(ContainerNode::Close { .. })
70-
) {
71-
} else if matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. })) {
72-
} else {
73-
result.push(',');
74-
}
72+
)
73+
&& !matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. }))
74+
{
75+
result.push(',');
7576
}
7677

77-
if matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. })) {
78-
first_in_container = true;
79-
} else {
80-
first_in_container = false;
81-
}
78+
first_in_container =
79+
matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. }));
8280
}
8381

8482
result
@@ -263,31 +261,31 @@ fn process_value(
263261
serde_json::Value::Null => {
264262
rows.push(Row {
265263
depth,
266-
key: key,
264+
key,
267265
node: JsonNode::Null,
268266
});
269267
rows.len() - 1
270268
}
271269
serde_json::Value::Bool(b) => {
272270
rows.push(Row {
273271
depth,
274-
key: key,
272+
key,
275273
node: JsonNode::Boolean(*b),
276274
});
277275
rows.len() - 1
278276
}
279277
serde_json::Value::Number(n) => {
280278
rows.push(Row {
281279
depth,
282-
key: key,
280+
key,
283281
node: JsonNode::Number(n.clone()),
284282
});
285283
rows.len() - 1
286284
}
287285
serde_json::Value::String(s) => {
288286
rows.push(Row {
289287
depth,
290-
key: key,
288+
key,
291289
node: JsonNode::String(s.clone()),
292290
});
293291
rows.len() - 1
@@ -296,7 +294,7 @@ fn process_value(
296294
if arr.is_empty() {
297295
rows.push(Row {
298296
depth,
299-
key: key,
297+
key,
300298
node: JsonNode::Container(ContainerNode::Empty {
301299
typ: ContainerType::Array,
302300
}),
@@ -308,7 +306,7 @@ fn process_value(
308306

309307
rows.push(Row {
310308
depth,
311-
key: key,
309+
key,
312310
node: JsonNode::Container(ContainerNode::Open {
313311
typ: ContainerType::Array,
314312
collapsed: false,
@@ -343,7 +341,7 @@ fn process_value(
343341
if obj.is_empty() {
344342
rows.push(Row {
345343
depth,
346-
key: key,
344+
key,
347345
node: JsonNode::Container(ContainerNode::Empty {
348346
typ: ContainerType::Object,
349347
}),
@@ -355,7 +353,7 @@ fn process_value(
355353

356354
rows.push(Row {
357355
depth,
358-
key: key,
356+
key,
359357
node: JsonNode::Container(ContainerNode::Open {
360358
typ: ContainerType::Object,
361359
collapsed: false,
@@ -402,16 +400,6 @@ pub struct PathIterator<'a> {
402400
stack: Vec<(String, &'a serde_json::Value)>,
403401
}
404402

405-
impl PathIterator<'_> {
406-
fn escape_json_path_key(key: &str) -> String {
407-
if key.contains('.') || key.contains('-') || key.contains('@') {
408-
format!("\"{}\"", key)
409-
} else {
410-
key.to_string()
411-
}
412-
}
413-
}
414-
415403
impl Iterator for PathIterator<'_> {
416404
type Item = String;
417405

@@ -420,18 +408,13 @@ impl Iterator for PathIterator<'_> {
420408
match value {
421409
serde_json::Value::Object(obj) => {
422410
for (key, val) in obj.iter() {
423-
let escaped = Self::escape_json_path_key(key);
424-
let new_path = if current_path == "." {
425-
format!(".{}", escaped)
426-
} else {
427-
format!("{}.{}", current_path, escaped)
428-
};
411+
let new_path = append_string_key(&current_path, key);
429412
self.stack.push((new_path, val));
430413
}
431414
}
432415
serde_json::Value::Array(arr) => {
433416
for (i, val) in arr.iter().enumerate() {
434-
let new_path = format!("{}[{}]", current_path, i);
417+
let new_path = append_bracket(&current_path, &i.to_string());
435418
self.stack.push((new_path, val));
436419
}
437420
}

‎promkit-widgets/src/structured/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub mod yaml;
1010
#[cfg_attr(docsrs, doc(cfg(feature = "tree")))]
1111
pub mod tree;
1212

13+
mod path;
14+
1315
use std::cell::Cell;
1416

1517
use promkit_core::grapheme::StyledGraphemes;

0 commit comments

Comments
 (0)