From 500cdf96c58e3e735cd582ce187e31e42ccad5ae Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 28 Aug 2026 09:32:01 +0900 Subject: [PATCH 1/6] Add path navigation to structured documents --- .../src/structured/json/document.rs | 134 ++++++- promkit-widgets/src/structured/json/jsonz.rs | 57 +-- promkit-widgets/src/structured/mod.rs | 2 + promkit-widgets/src/structured/path.rs | 110 ++++++ .../src/structured/yaml/deserializer.rs | 66 +++- .../src/structured/yaml/document.rs | 225 ++++++++++- promkit-widgets/src/structured/yaml/yamlz.rs | 354 ++++++++++++------ promkit-widgets/tests/jsonz/get_all_paths.rs | 2 +- 8 files changed, 785 insertions(+), 165 deletions(-) create mode 100644 promkit-widgets/src/structured/path.rs diff --git a/promkit-widgets/src/structured/json/document.rs b/promkit-widgets/src/structured/json/document.rs index f89cda8f..5a222b30 100644 --- a/promkit-widgets/src/structured/json/document.rs +++ b/promkit-widgets/src/structured/json/document.rs @@ -2,9 +2,13 @@ use std::{cell::Cell, io::Read}; use super::{ deserializer, - jsonz::{self, Row, RowOperation}, + jsonz::{self, ContainerNode, ContainerType, JsonNode, Row, RowOperation}, +}; +use crate::structured::{ + ProjectionViewport, + 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)] @@ -130,6 +134,36 @@ impl Document { row_index_at_visible_position(&self.rows, self.position, visible_offset) } + /// Resolves a jq-style path to its underlying document row 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, path: &str) -> Option { + locate_path(&self.rows, path).map(|located| located.row_index) + } + + /// Moves the cursor to the value at a jq-style path. + /// + /// Folded ancestors are expanded while unrelated folding state is preserved. + pub fn move_to_path(&mut self, path: &str) -> bool { + let Some(located) = locate_path(&self.rows, 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 { let viewport = self.viewport.get(); viewport @@ -173,6 +207,62 @@ impl Document { } } +struct PathFrame { + path: String, + typ: ContainerType, + next_index: usize, + open_index: usize, +} + +struct LocatedRow { + row_index: usize, + ancestors: Vec, +} + +fn locate_path(rows: &[Row], target: &str) -> Option { + let mut stack: Vec = Vec::new(); + + for (row_index, row) in rows.iter().enumerate() { + if matches!(row.node, JsonNode::Container(ContainerNode::Close { .. })) { + stack.truncate(row.depth); + 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 visible_position(rows: &Vec, target: usize) -> usize { let mut position = rows.head(); let mut visible = 0; @@ -276,4 +366,44 @@ 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("."), Some(0)); + assert_eq!(document.row_index_for_path(".items"), Some(1)); + assert_eq!(document.row_index_for_path(".items[1]"), Some(3)); + assert_eq!( + document.row_index_for_path(r#".items[1]["first name"]"#), + Some(4) + ); + assert_eq!(document.row_index_for_path(r#"["true"]"#), Some(7)); + assert_eq!(document.row_index_for_path(r#"["a\"b\n"]"#), Some(8)); + assert_eq!(document.row_index_for_path(".missing"), 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(".items[0].nested")); + assert_eq!(document.visible_position(), 3); + assert_eq!(document.visible_rows().len(), 8); + assert!(!document.move_to_path(".missing")); + } + } } diff --git a/promkit-widgets/src/structured/json/jsonz.rs b/promkit-widgets/src/structured/json/jsonz.rs index 5ba75f01..30f14b25 100644 --- a/promkit-widgets/src/structured/json/jsonz.rs +++ b/promkit-widgets/src/structured/json/jsonz.rs @@ -1,5 +1,7 @@ use rayon::prelude::*; +use crate::structured::path::{append_bracket, append_string_key}; + pub use crate::structured::{ContainerNode, ContainerType, PrettyRender, RowOperation}; #[derive(Clone, Debug, PartialEq)] @@ -63,22 +65,18 @@ impl PrettyRender for [Row] { }, } - if i + 1 < self.len() { - if matches!( + if i + 1 < self.len() + && !matches!( &self[i + 1].node, JsonNode::Container(ContainerNode::Close { .. }) - ) { - } else if matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. })) { - } else { - result.push(','); - } + ) + && !matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. })) + { + result.push(','); } - if matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. })) { - first_in_container = true; - } else { - first_in_container = false; - } + first_in_container = + matches!(&row.node, JsonNode::Container(ContainerNode::Open { .. })); } result @@ -263,7 +261,7 @@ fn process_value( serde_json::Value::Null => { rows.push(Row { depth, - key: key, + key, node: JsonNode::Null, }); rows.len() - 1 @@ -271,7 +269,7 @@ fn process_value( serde_json::Value::Bool(b) => { rows.push(Row { depth, - key: key, + key, node: JsonNode::Boolean(*b), }); rows.len() - 1 @@ -279,7 +277,7 @@ fn process_value( serde_json::Value::Number(n) => { rows.push(Row { depth, - key: key, + key, node: JsonNode::Number(n.clone()), }); rows.len() - 1 @@ -287,7 +285,7 @@ fn process_value( serde_json::Value::String(s) => { rows.push(Row { depth, - key: key, + key, node: JsonNode::String(s.clone()), }); rows.len() - 1 @@ -296,7 +294,7 @@ fn process_value( if arr.is_empty() { rows.push(Row { depth, - key: key, + key, node: JsonNode::Container(ContainerNode::Empty { typ: ContainerType::Array, }), @@ -308,7 +306,7 @@ fn process_value( rows.push(Row { depth, - key: key, + key, node: JsonNode::Container(ContainerNode::Open { typ: ContainerType::Array, collapsed: false, @@ -343,7 +341,7 @@ fn process_value( if obj.is_empty() { rows.push(Row { depth, - key: key, + key, node: JsonNode::Container(ContainerNode::Empty { typ: ContainerType::Object, }), @@ -355,7 +353,7 @@ fn process_value( rows.push(Row { depth, - key: key, + key, node: JsonNode::Container(ContainerNode::Open { typ: ContainerType::Object, collapsed: false, @@ -402,16 +400,6 @@ pub struct PathIterator<'a> { stack: Vec<(String, &'a serde_json::Value)>, } -impl PathIterator<'_> { - fn escape_json_path_key(key: &str) -> String { - if key.contains('.') || key.contains('-') || key.contains('@') { - format!("\"{}\"", key) - } else { - key.to_string() - } - } -} - impl Iterator for PathIterator<'_> { type Item = String; @@ -420,18 +408,13 @@ impl Iterator for PathIterator<'_> { match value { serde_json::Value::Object(obj) => { for (key, val) in obj.iter() { - let escaped = Self::escape_json_path_key(key); - let new_path = if current_path == "." { - format!(".{}", escaped) - } else { - format!("{}.{}", current_path, escaped) - }; + let new_path = append_string_key(¤t_path, key); self.stack.push((new_path, val)); } } serde_json::Value::Array(arr) => { for (i, val) in arr.iter().enumerate() { - let new_path = format!("{}[{}]", current_path, i); + let new_path = append_bracket(¤t_path, &i.to_string()); self.stack.push((new_path, val)); } } diff --git a/promkit-widgets/src/structured/mod.rs b/promkit-widgets/src/structured/mod.rs index 1f1b7b1b..32a985c2 100644 --- a/promkit-widgets/src/structured/mod.rs +++ b/promkit-widgets/src/structured/mod.rs @@ -10,6 +10,8 @@ pub mod yaml; #[cfg_attr(docsrs, doc(cfg(feature = "tree")))] pub mod tree; +mod path; + use std::cell::Cell; use promkit_core::grapheme::StyledGraphemes; diff --git a/promkit-widgets/src/structured/path.rs b/promkit-widgets/src/structured/path.rs new file mode 100644 index 00000000..07f9cd78 --- /dev/null +++ b/promkit-widgets/src/structured/path.rs @@ -0,0 +1,110 @@ +pub(super) fn append_string_key(parent: &str, key: &str) -> String { + if is_identifier(key) { + if parent == "." { + format!(".{key}") + } else { + format!("{parent}.{key}") + } + } else { + append_bracket(parent, "e_string(key)) + } +} + +pub(super) fn append_bracket(parent: &str, value: &str) -> String { + let prefix = if parent == "." { "" } else { parent }; + format!("{prefix}[{value}]") +} + +fn quote_string(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('"'); + for character in value.chars() { + match character { + '"' => quoted.push_str("\\\""), + '\\' => quoted.push_str("\\\\"), + '\u{08}' => quoted.push_str("\\b"), + '\u{0c}' => quoted.push_str("\\f"), + '\n' => quoted.push_str("\\n"), + '\r' => quoted.push_str("\\r"), + '\t' => quoted.push_str("\\t"), + character if character <= '\u{1f}' => { + use std::fmt::Write as _; + write!(quoted, "\\u{:04x}", u32::from(character)) + .expect("writing to a String cannot fail"); + } + character => quoted.push(character), + } + } + quoted.push('"'); + quoted +} + +fn is_identifier(key: &str) -> bool { + let mut chars = key.chars(); + chars + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || matches!(character, '_' | '$')) + && chars + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$')) + && !matches!( + key, + "break" + | "case" + | "catch" + | "class" + | "const" + | "continue" + | "debugger" + | "default" + | "delete" + | "do" + | "else" + | "export" + | "extends" + | "false" + | "finally" + | "for" + | "function" + | "if" + | "import" + | "in" + | "instanceof" + | "new" + | "null" + | "return" + | "super" + | "switch" + | "this" + | "throw" + | "true" + | "try" + | "typeof" + | "var" + | "void" + | "while" + | "with" + | "yield" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod append_string_key { + use super::*; + + #[test] + fn uses_dot_notation_only_for_identifiers() { + assert_eq!(append_string_key(".", "name"), ".name"); + assert_eq!(append_string_key(".item", "value"), ".item.value"); + assert_eq!(append_string_key(".", "true"), r#"["true"]"#); + assert_eq!(append_string_key(".", "first name"), r#"["first name"]"#); + } + + #[test] + fn quotes_json_control_characters() { + assert_eq!(append_string_key(".", "a\"b\nc"), r#"["a\"b\nc"]"#); + } + } +} diff --git a/promkit-widgets/src/structured/yaml/deserializer.rs b/promkit-widgets/src/structured/yaml/deserializer.rs index af5caf51..ba1d1b98 100644 --- a/promkit-widgets/src/structured/yaml/deserializer.rs +++ b/promkit-widgets/src/structured/yaml/deserializer.rs @@ -6,10 +6,11 @@ use serde::{ }; use super::yamlz::{ - ContainerNode, ContainerType, Row, YamlNode, normalize_mapping_key_for_display, + ContainerNode, ContainerType, IndexedRows, PathKeyKind, Row, YamlNode, + normalize_mapping_key_for_display, }; -struct ParsedRows(Vec); +struct ParsedRows(IndexedRows); impl<'de> Deserialize<'de> for ParsedRows { fn deserialize(deserializer: D) -> Result @@ -17,21 +18,29 @@ impl<'de> Deserialize<'de> for ParsedRows { D: serde::Deserializer<'de>, { let mut rows = Vec::new(); + let mut path_key_kinds = Vec::new(); RowsSeed { rows: &mut rows, + path_key_kinds: &mut path_key_kinds, depth: 0, key: None, + path_key_kind: PathKeyKind::None, is_sequence_item: false, } .deserialize(deserializer)?; - Ok(Self(rows)) + Ok(Self(IndexedRows { + rows, + path_key_kinds, + })) } } struct RowsSeed<'a> { rows: &'a mut Vec, + path_key_kinds: &'a mut Vec, depth: usize, key: Option, + path_key_kind: PathKeyKind, is_sequence_item: bool, } @@ -44,8 +53,10 @@ impl<'de> DeserializeSeed<'de> for RowsSeed<'_> { { deserializer.deserialize_any(RowsVisitor { rows: self.rows, + path_key_kinds: self.path_key_kinds, depth: self.depth, key: self.key, + path_key_kind: self.path_key_kind, is_sequence_item: self.is_sequence_item, }) } @@ -53,8 +64,10 @@ impl<'de> DeserializeSeed<'de> for RowsSeed<'_> { struct RowsVisitor<'a> { rows: &'a mut Vec, + path_key_kinds: &'a mut Vec, depth: usize, key: Option, + path_key_kind: PathKeyKind, is_sequence_item: bool, } @@ -66,6 +79,8 @@ impl RowsVisitor<'_> { node, is_sequence_item: self.is_sequence_item, }); + self.path_key_kinds.push(self.path_key_kind); + debug_assert_eq!(self.rows.len(), self.path_key_kinds.len()); self.rows.len() - 1 } } @@ -125,8 +140,10 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { { let Self { rows, + path_key_kinds, depth, key, + path_key_kind, is_sequence_item, } = self; let open_index = rows.len(); @@ -140,13 +157,16 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { close_index: 0, }), }); + path_key_kinds.push(path_key_kind); let mut is_empty = true; while sequence .next_element_seed(RowsSeed { rows: &mut *rows, + path_key_kinds: &mut *path_key_kinds, depth: depth + 1, key: None, + path_key_kind: PathKeyKind::None, is_sequence_item: true, })? .is_some() @@ -172,6 +192,7 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { open_index, }), }); + path_key_kinds.push(PathKeyKind::None); rows[open_index].node = YamlNode::Container(ContainerNode::Open { typ: ContainerType::Array, collapsed: false, @@ -186,8 +207,10 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { { let Self { rows, + path_key_kinds, depth, key, + path_key_kind, is_sequence_item, } = self; let open_index = rows.len(); @@ -201,17 +224,21 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { close_index: 0, }), }); + path_key_kinds.push(path_key_kind); let mut keys = HashSet::new(); while let Some(mapping_key) = mapping.next_key::()? { let key = normalize_mapping_key_for_display(&mapping_key); + let path_key_kind = PathKeyKind::from_mapping_key(&mapping_key); if !keys.insert(mapping_key) { return Err(de::Error::custom("duplicate entry in YAML map")); } mapping.next_value_seed(RowsSeed { rows: &mut *rows, + path_key_kinds: &mut *path_key_kinds, depth: depth + 1, key, + path_key_kind, is_sequence_item: false, })?; } @@ -234,6 +261,7 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { open_index, }), }); + path_key_kinds.push(PathKeyKind::None); rows[open_index].node = YamlNode::Container(ContainerNode::Open { typ: ContainerType::Object, collapsed: false, @@ -249,14 +277,18 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { let (tag, contents) = data.variant::()?; let Self { rows, + path_key_kinds, depth, key, + path_key_kind, is_sequence_item, } = self; let index = contents.newtype_variant_seed(RowsSeed { rows: &mut *rows, + path_key_kinds: &mut *path_key_kinds, depth, key, + path_key_kind, is_sequence_item, })?; rows[index].node = YamlNode::Tagged { @@ -267,23 +299,29 @@ impl<'de> Visitor<'de> for RowsVisitor<'_> { } } -pub fn from_str(input: &str) -> Result, serde_yaml::Error> { +pub fn from_str(input: &str) -> Result { collect(serde_yaml::Deserializer::from_str(input).map(ParsedRows::deserialize)) } -pub fn from_reader(reader: R) -> Result, serde_yaml::Error> { +pub fn from_reader(reader: R) -> Result { collect(serde_yaml::Deserializer::from_reader(reader).map(ParsedRows::deserialize)) } -fn collect(documents: I) -> Result, serde_yaml::Error> +fn collect(documents: I) -> Result where I: IntoIterator>, { let mut documents = documents.into_iter(); let Some(first) = documents.next() else { - return Ok(Vec::new()); + return Ok(IndexedRows { + rows: Vec::new(), + path_key_kinds: Vec::new(), + }); }; - let mut rows = first?.0; + let IndexedRows { + mut rows, + mut path_key_kinds, + } = first?.0; for document in documents { rows.push(Row { @@ -292,14 +330,22 @@ where node: YamlNode::DocumentSeparator, is_sequence_item: false, }); - let mut document_rows = document?.0; + path_key_kinds.push(PathKeyKind::None); + let IndexedRows { + rows: mut document_rows, + path_key_kinds: document_path_key_kinds, + } = document?.0; let offset = rows.len(); for row in &mut document_rows { rebase_container_indices(&mut row.node, offset); } rows.extend(document_rows); + path_key_kinds.extend(document_path_key_kinds); } - Ok(rows) + Ok(IndexedRows { + rows, + path_key_kinds, + }) } fn rebase_container_indices(node: &mut YamlNode, offset: usize) { diff --git a/promkit-widgets/src/structured/yaml/document.rs b/promkit-widgets/src/structured/yaml/document.rs index 7b0f27ae..2ed8621e 100644 --- a/promkit-widgets/src/structured/yaml/document.rs +++ b/promkit-widgets/src/structured/yaml/document.rs @@ -2,14 +2,23 @@ use std::{cell::Cell, io::Read}; use crate::structured::yaml::{ deserializer, - yamlz::{self, Row, RowOperation}, + yamlz::{ + self, ContainerNode, ContainerType, IndexedRows, PathKeyKind, Row, RowOperation, + TagAwareContainer, YamlNode, is_invisible_root_container, + sequence_mapping_line_start_for_path, + }, +}; +use crate::structured::{ + ProjectionViewport, + path::{append_bracket, append_string_key}, + projection_viewport, }; -use crate::structured::{ProjectionViewport, projection_viewport}; /// Represents a navigable YAML document, allowing for efficient row navigation and folding. #[derive(Clone)] pub struct Document { rows: Vec, + path_key_kinds: Vec, position: usize, line_numbers: Vec>, line_count: usize, @@ -18,7 +27,7 @@ pub struct Document { impl Document { pub fn new<'a, I: IntoIterator>(iter: I) -> Self { - Self::from_rows(yamlz::create_rows(iter)) + Self::from_rows(yamlz::create_indexed_rows(iter)) } /// Parses one or more YAML documents directly into a navigable document. @@ -32,7 +41,12 @@ impl Document { deserializer::from_reader(reader).map(Self::from_rows) } - fn from_rows(rows: Vec) -> Self { + fn from_rows(indexed_rows: IndexedRows) -> Self { + let IndexedRows { + rows, + path_key_kinds, + } = indexed_rows; + debug_assert_eq!(rows.len(), path_key_kinds.len()); let position = rows.head(); let mut line_numbers = vec![None; rows.len()]; let mut line_count = 0; @@ -53,6 +67,7 @@ impl Document { Self { rows, + path_key_kinds, position, line_numbers, line_count, @@ -168,6 +183,38 @@ impl Document { row_index_at_visible_position(&self.rows, self.position, visible_offset) } + /// Resolves a jq-style path to its navigable underlying document row index. + /// + /// Paths use dot notation for identifier keys and bracket notation for sequence indices, + /// non-identifier strings, and non-string scalar keys. + pub fn row_index_for_path(&self, path: &str) -> Option { + locate_path(&self.rows, &self.path_key_kinds, path) + .map(|located| navigable_row(&self.rows, located.row_index)) + } + + /// Moves the cursor to the value at a jq-style path. + /// + /// Folded ancestors are expanded while unrelated folding state is preserved. Mapping keys + /// that cannot be represented by a jq-style path are not addressable. + pub fn move_to_path(&mut self, path: &str) -> bool { + let Some(located) = locate_path(&self.rows, &self.path_key_kinds, path) else { + return false; + }; + for open_index in located.ancestors { + if matches!( + TagAwareContainer::get(&self.rows[open_index].node), + Some(ContainerNode::Open { + collapsed: true, + .. + }) + ) { + self.rows.toggle(open_index); + } + } + self.position = navigable_row(&self.rows, located.row_index); + true + } + pub(super) fn row_index_at_viewport_position(&self, visible_position: usize) -> Option { let viewport = self.viewport.get(); viewport @@ -211,6 +258,91 @@ impl Document { } } +struct PathFrame { + path: Option, + typ: ContainerType, + next_index: usize, + open_index: usize, +} + +struct LocatedRow { + row_index: usize, + ancestors: Vec, +} + +fn locate_path(rows: &[Row], path_key_kinds: &[PathKeyKind], target: &str) -> Option { + let mut stack: Vec = Vec::new(); + + for (row_index, row) in rows.iter().enumerate() { + if matches!(row.node, YamlNode::DocumentSeparator) { + stack.clear(); + continue; + } + if matches!( + TagAwareContainer::get(&row.node), + Some(ContainerNode::Close { .. }) + ) { + stack.truncate(row.depth); + continue; + } + stack.truncate(row.depth); + + let path = if row.depth == 0 { + Some(".".to_owned()) + } else { + let parent = stack.get_mut(row.depth - 1)?; + match parent.typ { + ContainerType::Object => parent.path.as_ref().and_then(|parent_path| { + mapping_path(parent_path, row.key.as_deref()?, path_key_kinds[row_index]) + }), + ContainerType::Array => { + let path = parent.path.as_ref().map(|parent_path| { + append_bracket(parent_path, &parent.next_index.to_string()) + }); + parent.next_index += 1; + path + } + } + }; + + if path.as_deref() == Some(target) { + return Some(LocatedRow { + row_index, + ancestors: stack.iter().map(|frame| frame.open_index).collect(), + }); + } + + if let Some(ContainerNode::Open { typ, .. }) = TagAwareContainer::get(&row.node) { + stack.push(PathFrame { + path, + typ: typ.clone(), + next_index: 0, + open_index: row_index, + }); + } + } + + None +} + +fn mapping_path(parent: &str, key: &str, kind: PathKeyKind) -> Option { + match kind { + PathKeyKind::String => Some(append_string_key(parent, key)), + PathKeyKind::Number | PathKeyKind::Bool | PathKeyKind::Null => { + Some(append_bracket(parent, key)) + } + PathKeyKind::None | PathKeyKind::Unsupported => None, + } +} + +fn navigable_row(rows: &Vec, row_index: usize) -> usize { + if is_invisible_root_container(&rows[row_index]) { + rows.head() + } else { + sequence_mapping_line_start_for_path(rows, row_index).unwrap_or(row_index) + } +} + fn visible_position(rows: &Vec, target: usize) -> usize { let mut position = rows.head(); let mut visible = 0; @@ -329,6 +461,21 @@ second: [1, 2] let actual = Document::from_reader(Cursor::new(INPUT.as_bytes())).unwrap(); assert_eq!(actual.rows(), expected.rows()); + for path in [ + ".name", + "[1]", + "[true]", + "[null]", + ".tagged.nested", + ".items[2].aliased", + ".second[1]", + ] { + assert_eq!( + actual.row_index_for_path(path), + expected.row_index_for_path(path), + "path: {path}" + ); + } } #[test] @@ -336,4 +483,74 @@ second: [1, 2] assert!(Document::from_reader(Cursor::new(b"key: {")).is_err()); } } + + mod row_index_for_path { + use super::*; + + #[test] + fn distinguishes_scalar_mapping_keys_and_sequence_indices() { + let document = Document::from_str(concat!( + "name: Alice\n", + "\"true\": string\n", + "true: boolean\n", + "1: number\n", + "null: null-key\n", + "items:\n", + " - first\n", + " - name: Bob\n", + )) + .unwrap(); + + assert_eq!(document.row_index_for_path(".name"), Some(1)); + assert_eq!(document.row_index_for_path(r#"["true"]"#), Some(2)); + assert_eq!(document.row_index_for_path("[true]"), Some(3)); + assert_eq!(document.row_index_for_path("[1]"), Some(4)); + assert_eq!(document.row_index_for_path("[null]"), Some(5)); + assert_eq!(document.row_index_for_path(".items[0]"), Some(7)); + assert_eq!(document.row_index_for_path(".items[1]"), Some(8)); + assert_eq!(document.row_index_for_path(".items[1].name"), Some(8)); + assert_eq!(document.row_index_for_path(".missing"), None); + } + + #[test] + fn excludes_descendants_of_unrepresentable_mapping_keys() { + let document = Document::from_str(concat!( + "? [complex, key]\n", + ": { hidden: value }\n", + "visible: true\n", + )) + .unwrap(); + + assert_eq!(document.row_index_for_path(".hidden"), None); + assert!(document.row_index_for_path(".visible").is_some()); + } + } + + mod move_to_path { + use super::*; + + #[test] + fn expands_ancestors_and_preserves_unrelated_folding() { + let mut document = Document::from_str(concat!( + "items:\n", + " - name:\n", + " nested: true\n", + "other:\n", + " value: 1\n", + )) + .unwrap(); + document.toggle_at(1); + document.toggle_at(8); + + assert!(document.move_to_path(".items[0].name.nested")); + assert_eq!(document.visible_position(), 2); + assert!( + !document + .visible_rows() + .iter() + .any(|row| row.key.as_deref() == Some("value")) + ); + assert!(!document.move_to_path(".missing")); + } + } } diff --git a/promkit-widgets/src/structured/yaml/yamlz.rs b/promkit-widgets/src/structured/yaml/yamlz.rs index 8fabb0f8..0c82c9cc 100644 --- a/promkit-widgets/src/structured/yaml/yamlz.rs +++ b/promkit-widgets/src/structured/yaml/yamlz.rs @@ -1,5 +1,7 @@ use rayon::prelude::*; +use crate::structured::path::{append_bracket, append_string_key}; + pub use crate::structured::{ContainerNode, ContainerType, RowOperation}; #[derive(Clone, Debug, PartialEq)] @@ -23,10 +25,10 @@ pub struct Row { /// YAML tags can wrap container nodes (`Tagged(Container(...))`). /// This helper centralizes "unwrap/rewrap while preserving tag" behavior. -struct TagAwareContainer; +pub(super) struct TagAwareContainer; impl TagAwareContainer { - fn get(node: &YamlNode) -> Option<&ContainerNode> { + pub(super) fn get(node: &YamlNode) -> Option<&ContainerNode> { match node { YamlNode::Container(container) => Some(container), YamlNode::Tagged { node, .. } => Self::get(node), @@ -34,7 +36,7 @@ impl TagAwareContainer { } } - fn replace(node: &YamlNode, new_container: ContainerNode) -> Option { + pub(super) fn replace(node: &YamlNode, new_container: ContainerNode) -> Option { match node { YamlNode::Container(_) => Some(YamlNode::Container(new_container)), YamlNode::Tagged { tag, node } => Some(YamlNode::Tagged { @@ -65,6 +67,23 @@ fn sequence_mapping_line_start(rows: &[Row], index: usize) -> Option { renders_as_sequence_mapping_line(&rows[previous], &rows[index]).then_some(previous) } +pub(super) fn sequence_mapping_line_start_for_path(rows: &[Row], index: usize) -> Option { + let previous = index.checked_sub(1)?; + let row = &rows[previous]; + let next_row = &rows[index]; + (matches!( + TagAwareContainer::get(&row.node), + Some(ContainerNode::Open { + typ: ContainerType::Object, + .. + }) + ) && row.is_sequence_item + && next_row.depth == row.depth + 1 + && !next_row.is_sequence_item + && next_row.key.is_some()) + .then_some(previous) +} + fn sequence_mapping_inline_row(rows: &[Row], index: usize) -> Option { let next = index + 1; let next_row = rows.get(next)?; @@ -80,7 +99,7 @@ fn sequence_mapping_inline_container(rows: &[Row], index: usize) -> Option bool { +pub(super) fn is_invisible_root_container(row: &Row) -> bool { row.depth == 0 && row.key.is_none() && !row.is_sequence_item @@ -389,98 +408,178 @@ pub(super) fn normalize_mapping_key_for_display(mapping_key: &serde_yaml::Value) } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum PathKeyKind { + #[default] + None, + String, + Number, + Bool, + Null, + Unsupported, +} + +impl PathKeyKind { + pub(super) fn from_mapping_key(key: &serde_yaml::Value) -> Self { + match key { + serde_yaml::Value::String(_) => Self::String, + serde_yaml::Value::Number(_) => Self::Number, + serde_yaml::Value::Bool(_) => Self::Bool, + serde_yaml::Value::Null => Self::Null, + serde_yaml::Value::Tagged(_) + | serde_yaml::Value::Sequence(_) + | serde_yaml::Value::Mapping(_) => Self::Unsupported, + } + } +} + +pub(super) struct IndexedRows { + pub(super) rows: Vec, + pub(super) path_key_kinds: Vec, +} + +fn push_indexed_row( + rows: &mut Vec, + path_key_kinds: &mut Vec, + row: Row, + path_key_kind: PathKeyKind, +) -> usize { + rows.push(row); + path_key_kinds.push(path_key_kind); + debug_assert_eq!(rows.len(), path_key_kinds.len()); + rows.len() - 1 +} + fn process_value( value: &serde_yaml::Value, rows: &mut Vec, + path_key_kinds: &mut Vec, depth: usize, key: Option, + path_key_kind: PathKeyKind, is_sequence_item: bool, ) -> usize { match value { serde_yaml::Value::Tagged(tagged) => { - let index = process_value(&tagged.value, rows, depth, key, is_sequence_item); + let index = process_value( + &tagged.value, + rows, + path_key_kinds, + depth, + key, + path_key_kind, + is_sequence_item, + ); rows[index].node = YamlNode::Tagged { tag: tagged.tag.to_string(), node: Box::new(rows[index].node.clone()), }; index } - serde_yaml::Value::Null => { - rows.push(Row { + serde_yaml::Value::Null => push_indexed_row( + rows, + path_key_kinds, + Row { depth, key, is_sequence_item, node: YamlNode::Null, - }); - rows.len() - 1 - } - serde_yaml::Value::Bool(b) => { - rows.push(Row { + }, + path_key_kind, + ), + serde_yaml::Value::Bool(b) => push_indexed_row( + rows, + path_key_kinds, + Row { depth, key, is_sequence_item, node: YamlNode::Boolean(*b), - }); - rows.len() - 1 - } - serde_yaml::Value::Number(n) => { - rows.push(Row { + }, + path_key_kind, + ), + serde_yaml::Value::Number(n) => push_indexed_row( + rows, + path_key_kinds, + Row { depth, key, is_sequence_item, node: YamlNode::Number(n.clone()), - }); - rows.len() - 1 - } - serde_yaml::Value::String(s) => { - rows.push(Row { + }, + path_key_kind, + ), + serde_yaml::Value::String(s) => push_indexed_row( + rows, + path_key_kinds, + Row { depth, key, is_sequence_item, node: YamlNode::String(s.clone()), - }); - rows.len() - 1 - } + }, + path_key_kind, + ), serde_yaml::Value::Sequence(seq) => { if seq.is_empty() { - rows.push(Row { + return push_indexed_row( + rows, + path_key_kinds, + Row { + depth, + key, + is_sequence_item, + node: YamlNode::Container(ContainerNode::Empty { + typ: ContainerType::Array, + }), + }, + path_key_kind, + ); + } + + let open_index = push_indexed_row( + rows, + path_key_kinds, + Row { depth, key, is_sequence_item, - node: YamlNode::Container(ContainerNode::Empty { + node: YamlNode::Container(ContainerNode::Open { typ: ContainerType::Array, + collapsed: false, + close_index: 0, }), - }); - return rows.len() - 1; - } - - let open_index = rows.len(); - rows.push(Row { - depth, - key, - is_sequence_item, - node: YamlNode::Container(ContainerNode::Open { - typ: ContainerType::Array, - collapsed: false, - close_index: 0, - }), - }); + }, + path_key_kind, + ); for item in seq { - process_value(item, rows, depth + 1, None, true); + process_value( + item, + rows, + path_key_kinds, + depth + 1, + None, + PathKeyKind::None, + true, + ); } - let close_index = rows.len(); - rows.push(Row { - depth, - key: None, - is_sequence_item: false, - node: YamlNode::Container(ContainerNode::Close { - typ: ContainerType::Array, - collapsed: false, - open_index, - }), - }); + let close_index = push_indexed_row( + rows, + path_key_kinds, + Row { + depth, + key: None, + is_sequence_item: false, + node: YamlNode::Container(ContainerNode::Close { + typ: ContainerType::Array, + collapsed: false, + open_index, + }), + }, + PathKeyKind::None, + ); rows[open_index].node = YamlNode::Container(ContainerNode::Open { typ: ContainerType::Array, @@ -492,45 +591,66 @@ fn process_value( } serde_yaml::Value::Mapping(map) => { if map.is_empty() { - rows.push(Row { + return push_indexed_row( + rows, + path_key_kinds, + Row { + depth, + key, + is_sequence_item, + node: YamlNode::Container(ContainerNode::Empty { + typ: ContainerType::Object, + }), + }, + path_key_kind, + ); + } + + let open_index = push_indexed_row( + rows, + path_key_kinds, + Row { depth, key, is_sequence_item, - node: YamlNode::Container(ContainerNode::Empty { + node: YamlNode::Container(ContainerNode::Open { typ: ContainerType::Object, + collapsed: false, + close_index: 0, }), - }); - return rows.len() - 1; - } - - let open_index = rows.len(); - rows.push(Row { - depth, - key, - is_sequence_item, - node: YamlNode::Container(ContainerNode::Open { - typ: ContainerType::Object, - collapsed: false, - close_index: 0, - }), - }); + }, + path_key_kind, + ); for (mapping_key, map_value) in map { let key = normalize_mapping_key_for_display(mapping_key); - process_value(map_value, rows, depth + 1, key, false); + let path_key_kind = PathKeyKind::from_mapping_key(mapping_key); + process_value( + map_value, + rows, + path_key_kinds, + depth + 1, + key, + path_key_kind, + false, + ); } - let close_index = rows.len(); - rows.push(Row { - depth, - key: None, - is_sequence_item: false, - node: YamlNode::Container(ContainerNode::Close { - typ: ContainerType::Object, - collapsed: false, - open_index, - }), - }); + let close_index = push_indexed_row( + rows, + path_key_kinds, + Row { + depth, + key: None, + is_sequence_item: false, + node: YamlNode::Container(ContainerNode::Close { + typ: ContainerType::Object, + collapsed: false, + open_index, + }), + }, + PathKeyKind::None, + ); rows[open_index].node = YamlNode::Container(ContainerNode::Open { typ: ContainerType::Object, @@ -544,19 +664,42 @@ fn process_value( } pub fn create_rows<'a, T: IntoIterator>(iter: T) -> Vec { + create_indexed_rows(iter).rows +} + +pub(super) fn create_indexed_rows<'a, T: IntoIterator>( + iter: T, +) -> IndexedRows { let mut rows = Vec::new(); + let mut path_key_kinds = Vec::new(); for (index, value) in iter.into_iter().enumerate() { if index > 0 { - rows.push(Row { - depth: 0, - key: None, - node: YamlNode::DocumentSeparator, - is_sequence_item: false, - }); + push_indexed_row( + &mut rows, + &mut path_key_kinds, + Row { + depth: 0, + key: None, + node: YamlNode::DocumentSeparator, + is_sequence_item: false, + }, + PathKeyKind::None, + ); } - process_value(value, &mut rows, 0, None, false); + process_value( + value, + &mut rows, + &mut path_key_kinds, + 0, + None, + PathKeyKind::None, + false, + ); + } + IndexedRows { + rows, + path_key_kinds, } - rows } #[derive(Debug)] @@ -564,16 +707,6 @@ pub struct PathIterator<'a> { stack: Vec<(String, &'a serde_yaml::Value)>, } -impl PathIterator<'_> { - fn escape_path_key(key: &str) -> String { - if key.contains('.') || key.contains('-') || key.contains('@') { - format!("\"{}\"", key) - } else { - key.to_string() - } - } -} - impl Iterator for PathIterator<'_> { type Item = String; @@ -587,22 +720,20 @@ impl Iterator for PathIterator<'_> { for (key, val) in map { match key { serde_yaml::Value::String(key) => { - let escaped = Self::escape_path_key(key); - let new_path = if current_path == "." { - format!(".{}", escaped) - } else { - format!("{}.{}", current_path, escaped) - }; + let new_path = append_string_key(¤t_path, key); self.stack.push((new_path, val)); } serde_yaml::Value::Number(n) => { - self.stack.push((format!("{}[{}]", current_path, n), val)); + self.stack + .push((append_bracket(¤t_path, &n.to_string()), val)); } serde_yaml::Value::Bool(b) => { - self.stack.push((format!("{}[{}]", current_path, b), val)); + self.stack + .push((append_bracket(¤t_path, &b.to_string()), val)); } serde_yaml::Value::Null => { - self.stack.push((format!("{}[null]", current_path), val)); + self.stack + .push((append_bracket(¤t_path, "null"), val)); } _ => {} } @@ -610,7 +741,8 @@ impl Iterator for PathIterator<'_> { } serde_yaml::Value::Sequence(seq) => { for (i, val) in seq.iter().enumerate() { - self.stack.push((format!("{}[{}]", current_path, i), val)); + self.stack + .push((append_bracket(¤t_path, &i.to_string()), val)); } } _ => {} diff --git a/promkit-widgets/tests/jsonz/get_all_paths.rs b/promkit-widgets/tests/jsonz/get_all_paths.rs index 0938124e..db28b86c 100644 --- a/promkit-widgets/tests/jsonz/get_all_paths.rs +++ b/promkit-widgets/tests/jsonz/get_all_paths.rs @@ -89,7 +89,7 @@ fn returns_all_paths() { ".nested.field1", ".nested.field2", ".nested.field2.inner", - ".null", + r#"["null"]"#, ".number", ".string", ] From 6193f65752bb452162df8c2bade6c277c118f074 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 28 Aug 2026 11:05:58 +0900 Subject: [PATCH 2/6] Support document indexes in path navigation --- .../src/structured/json/document.rs | 75 ++++++++--- .../src/structured/yaml/document.rs | 123 +++++++++++++----- 2 files changed, 151 insertions(+), 47 deletions(-) diff --git a/promkit-widgets/src/structured/json/document.rs b/promkit-widgets/src/structured/json/document.rs index 5a222b30..88f7b672 100644 --- a/promkit-widgets/src/structured/json/document.rs +++ b/promkit-widgets/src/structured/json/document.rs @@ -134,19 +134,23 @@ impl Document { row_index_at_visible_position(&self.rows, self.position, visible_offset) } - /// Resolves a jq-style path to its underlying document row index. + /// 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, path: &str) -> Option { - locate_path(&self.rows, path).map(|located| located.row_index) + pub fn row_index_for_path(&self, document_index: usize, path: &str) -> Option { + locate_path(&self.rows, document_index, path).map(|located| located.row_index) } - /// Moves the cursor to the value at a jq-style path. + /// 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, path: &str) -> bool { - let Some(located) = locate_path(&self.rows, path) else { + 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 { @@ -219,14 +223,27 @@ struct LocatedRow { ancestors: Vec, } -fn locate_path(rows: &[Row], target: &str) -> Option { +fn locate_path(rows: &[Row], document_index: usize, target: &str) -> Option { let mut stack: Vec = 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 { @@ -377,16 +394,32 @@ mod tests { ) .unwrap(); - assert_eq!(document.row_index_for_path("."), Some(0)); - assert_eq!(document.row_index_for_path(".items"), Some(1)); - assert_eq!(document.row_index_for_path(".items[1]"), Some(3)); + 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(r#".items[1]["first name"]"#), + document.row_index_for_path(0, r#".items[1]["first name"]"#), Some(4) ); - assert_eq!(document.row_index_for_path(r#"["true"]"#), Some(7)); - assert_eq!(document.row_index_for_path(r#"["a\"b\n"]"#), Some(8)); - assert_eq!(document.row_index_for_path(".missing"), None); + 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); } } @@ -400,10 +433,20 @@ mod tests { document.toggle_at(1); document.toggle_at(6); - assert!(document.move_to_path(".items[0].nested")); + assert!(document.move_to_path(0, ".items[0].nested")); assert_eq!(document.visible_position(), 3); assert_eq!(document.visible_rows().len(), 8); - assert!(!document.move_to_path(".missing")); + 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!(!document.move_to_path(0, ".second_only")); + assert!(!document.move_to_path(2, ".")); } } } diff --git a/promkit-widgets/src/structured/yaml/document.rs b/promkit-widgets/src/structured/yaml/document.rs index 2ed8621e..a2597332 100644 --- a/promkit-widgets/src/structured/yaml/document.rs +++ b/promkit-widgets/src/structured/yaml/document.rs @@ -183,21 +183,26 @@ impl Document { row_index_at_visible_position(&self.rows, self.position, visible_offset) } - /// Resolves a jq-style path to its navigable underlying document row index. + /// Resolves a jq-style path in a zero-based document to its navigable underlying row index. + /// + /// Each YAML document in the stream increments the document index. /// /// Paths use dot notation for identifier keys and bracket notation for sequence indices, /// non-identifier strings, and non-string scalar keys. - pub fn row_index_for_path(&self, path: &str) -> Option { - locate_path(&self.rows, &self.path_key_kinds, path) + pub fn row_index_for_path(&self, document_index: usize, path: &str) -> Option { + locate_path(&self.rows, &self.path_key_kinds, document_index, path) .map(|located| navigable_row(&self.rows, located.row_index)) } - /// Moves the cursor to the value at a jq-style path. + /// Moves the cursor to the value at a jq-style path in a zero-based document. + /// + /// Each YAML document in the stream increments the document index. /// /// Folded ancestors are expanded while unrelated folding state is preserved. Mapping keys /// that cannot be represented by a jq-style path are not addressable. - pub fn move_to_path(&mut self, path: &str) -> bool { - let Some(located) = locate_path(&self.rows, &self.path_key_kinds, path) else { + pub fn move_to_path(&mut self, document_index: usize, path: &str) -> bool { + let Some(located) = locate_path(&self.rows, &self.path_key_kinds, document_index, path) + else { return false; }; for open_index in located.ancestors { @@ -270,8 +275,15 @@ struct LocatedRow { ancestors: Vec, } -fn locate_path(rows: &[Row], path_key_kinds: &[PathKeyKind], target: &str) -> Option { +fn locate_path( + rows: &[Row], + path_key_kinds: &[PathKeyKind], + document_index: usize, + target: &str, +) -> Option { let mut stack: Vec = 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, YamlNode::DocumentSeparator) { @@ -285,6 +297,17 @@ fn locate_path(rows: &[Row], path_key_kinds: &[PathKeyKind], target: &str) -> Op 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 { @@ -337,7 +360,7 @@ fn mapping_path(parent: &str, key: &str, kind: PathKeyKind) -> Option { fn navigable_row(rows: &Vec, row_index: usize) -> usize { if is_invisible_root_container(&rows[row_index]) { - rows.head() + rows.down(row_index) } else { sequence_mapping_line_start_for_path(rows, row_index).unwrap_or(row_index) } @@ -461,18 +484,18 @@ second: [1, 2] let actual = Document::from_reader(Cursor::new(INPUT.as_bytes())).unwrap(); assert_eq!(actual.rows(), expected.rows()); - for path in [ - ".name", - "[1]", - "[true]", - "[null]", - ".tagged.nested", - ".items[2].aliased", - ".second[1]", + for (document_index, path) in [ + (0, ".name"), + (0, "[1]"), + (0, "[true]"), + (0, "[null]"), + (0, ".tagged.nested"), + (0, ".items[2].aliased"), + (1, ".second[1]"), ] { assert_eq!( - actual.row_index_for_path(path), - expected.row_index_for_path(path), + actual.row_index_for_path(document_index, path), + expected.row_index_for_path(document_index, path), "path: {path}" ); } @@ -501,15 +524,15 @@ second: [1, 2] )) .unwrap(); - assert_eq!(document.row_index_for_path(".name"), Some(1)); - assert_eq!(document.row_index_for_path(r#"["true"]"#), Some(2)); - assert_eq!(document.row_index_for_path("[true]"), Some(3)); - assert_eq!(document.row_index_for_path("[1]"), Some(4)); - assert_eq!(document.row_index_for_path("[null]"), Some(5)); - assert_eq!(document.row_index_for_path(".items[0]"), Some(7)); - assert_eq!(document.row_index_for_path(".items[1]"), Some(8)); - assert_eq!(document.row_index_for_path(".items[1].name"), Some(8)); - assert_eq!(document.row_index_for_path(".missing"), None); + assert_eq!(document.row_index_for_path(0, ".name"), Some(1)); + assert_eq!(document.row_index_for_path(0, r#"["true"]"#), Some(2)); + assert_eq!(document.row_index_for_path(0, "[true]"), Some(3)); + assert_eq!(document.row_index_for_path(0, "[1]"), Some(4)); + assert_eq!(document.row_index_for_path(0, "[null]"), Some(5)); + assert_eq!(document.row_index_for_path(0, ".items[0]"), Some(7)); + assert_eq!(document.row_index_for_path(0, ".items[1]"), Some(8)); + assert_eq!(document.row_index_for_path(0, ".items[1].name"), Some(8)); + assert_eq!(document.row_index_for_path(0, ".missing"), None); } #[test] @@ -521,8 +544,27 @@ second: [1, 2] )) .unwrap(); - assert_eq!(document.row_index_for_path(".hidden"), None); - assert!(document.row_index_for_path(".visible").is_some()); + assert_eq!(document.row_index_for_path(0, ".hidden"), None); + assert!(document.row_index_for_path(0, ".visible").is_some()); + } + + #[test] + fn distinguishes_yaml_documents() { + let document = Document::from_str(concat!( + "name: first\n", + "---\n", + "name: second\n", + "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(1, "."), Some(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); } } @@ -542,7 +584,7 @@ second: [1, 2] document.toggle_at(1); document.toggle_at(8); - assert!(document.move_to_path(".items[0].name.nested")); + assert!(document.move_to_path(0, ".items[0].name.nested")); assert_eq!(document.visible_position(), 2); assert!( !document @@ -550,7 +592,26 @@ second: [1, 2] .iter() .any(|row| row.key.as_deref() == Some("value")) ); - assert!(!document.move_to_path(".missing")); + assert!(!document.move_to_path(0, ".missing")); + } + + #[test] + fn selects_a_yaml_document() { + let mut document = Document::from_str(concat!( + "first_only: true\n", + "---\n", + "second_only: true\n", + )) + .unwrap(); + + assert!(document.move_to_path(1, ".second_only")); + assert!(!document.move_to_path(0, ".second_only")); + assert!(!document.move_to_path(2, ".")); + + assert!(document.move_to_path(1, ".")); + let root_position = document.visible_position(); + assert!(document.move_to_path(1, ".second_only")); + assert_eq!(document.visible_position(), root_position); } } } From 060173124f08f68e0f5681a35072f65fc8929621 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 1 Sep 2026 15:19:49 +0900 Subject: [PATCH 3/6] Expose selected structured document paths --- .../src/structured/json/document.rs | 65 ++++++++++++++++ .../src/structured/yaml/document.rs | 78 +++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/promkit-widgets/src/structured/json/document.rs b/promkit-widgets/src/structured/json/document.rs index 88f7b672..fc45e7ff 100644 --- a/promkit-widgets/src/structured/json/document.rs +++ b/promkit-widgets/src/structured/json/document.rs @@ -111,6 +111,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.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); @@ -280,6 +285,58 @@ fn locate_path(rows: &[Row], document_index: usize, target: &str) -> Option 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 stack: Vec = Vec::new(); + let mut 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 row_index > 0 { + document_index += 1; + } + stack.clear(); + } + 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 row_index == target_row_index { + return Some((document_index, path)); + } + + 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 visible_position(rows: &Vec, target: usize) -> usize { let mut position = rows.head(); let mut visible = 0; @@ -435,6 +492,10 @@ mod tests { 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")); } @@ -445,6 +506,10 @@ mod tests { 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, ".")); } diff --git a/promkit-widgets/src/structured/yaml/document.rs b/promkit-widgets/src/structured/yaml/document.rs index a2597332..d47032df 100644 --- a/promkit-widgets/src/structured/yaml/document.rs +++ b/promkit-widgets/src/structured/yaml/document.rs @@ -150,6 +150,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_key_kinds, self.position) + } + /// Toggles the container value associated with the YAML key at the cursor. /// /// The displayed key determines the toggle target: @@ -348,6 +353,71 @@ fn locate_path( None } +fn path_at_row( + rows: &[Row], + path_key_kinds: &[PathKeyKind], + target_row_index: usize, +) -> Option<(usize, String)> { + let mut stack: Vec = Vec::new(); + let mut document_index = 0; + let mut found_document = false; + + for (row_index, row) in rows.iter().enumerate() { + if matches!(row.node, YamlNode::DocumentSeparator) { + stack.clear(); + continue; + } + if matches!( + TagAwareContainer::get(&row.node), + Some(ContainerNode::Close { .. }) + ) { + stack.truncate(row.depth); + continue; + } + if row.depth == 0 { + if found_document { + document_index += 1; + } + found_document = true; + stack.clear(); + } + stack.truncate(row.depth); + + let path = if row.depth == 0 { + Some(".".to_owned()) + } else { + let parent = stack.get_mut(row.depth - 1)?; + match parent.typ { + ContainerType::Object => parent.path.as_ref().and_then(|parent_path| { + mapping_path(parent_path, row.key.as_deref()?, path_key_kinds[row_index]) + }), + ContainerType::Array => { + let path = parent.path.as_ref().map(|parent_path| { + append_bracket(parent_path, &parent.next_index.to_string()) + }); + parent.next_index += 1; + path + } + } + }; + + if row_index == target_row_index { + return path.map(|path| (document_index, path)); + } + + if let Some(ContainerNode::Open { typ, .. }) = TagAwareContainer::get(&row.node) { + stack.push(PathFrame { + path, + typ: typ.clone(), + next_index: 0, + open_index: row_index, + }); + } + } + + None +} + fn mapping_path(parent: &str, key: &str, kind: PathKeyKind) -> Option { match kind { PathKeyKind::String => Some(append_string_key(parent, key)), @@ -586,6 +656,10 @@ second: [1, 2] assert!(document.move_to_path(0, ".items[0].name.nested")); assert_eq!(document.visible_position(), 2); + assert_eq!( + document.selected_path(), + Some((0, ".items[0].name.nested".to_owned())) + ); assert!( !document .visible_rows() @@ -605,6 +679,10 @@ second: [1, 2] .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, ".")); From 2a53c07f43b4aa7934f391c6c1ed5952a0f2d315 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 1 Sep 2026 18:56:18 +0900 Subject: [PATCH 4/6] Revert "Expose selected structured document paths" This reverts commit 060173124f08f68e0f5681a35072f65fc8929621. --- .../src/structured/json/document.rs | 65 ---------------- .../src/structured/yaml/document.rs | 78 ------------------- 2 files changed, 143 deletions(-) diff --git a/promkit-widgets/src/structured/json/document.rs b/promkit-widgets/src/structured/json/document.rs index fc45e7ff..88f7b672 100644 --- a/promkit-widgets/src/structured/json/document.rs +++ b/promkit-widgets/src/structured/json/document.rs @@ -111,11 +111,6 @@ 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.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); @@ -285,58 +280,6 @@ fn locate_path(rows: &[Row], document_index: usize, target: &str) -> Option 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 stack: Vec = Vec::new(); - let mut 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 row_index > 0 { - document_index += 1; - } - stack.clear(); - } - 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 row_index == target_row_index { - return Some((document_index, path)); - } - - 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 visible_position(rows: &Vec, target: usize) -> usize { let mut position = rows.head(); let mut visible = 0; @@ -492,10 +435,6 @@ mod tests { 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")); } @@ -506,10 +445,6 @@ mod tests { 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, ".")); } diff --git a/promkit-widgets/src/structured/yaml/document.rs b/promkit-widgets/src/structured/yaml/document.rs index d47032df..a2597332 100644 --- a/promkit-widgets/src/structured/yaml/document.rs +++ b/promkit-widgets/src/structured/yaml/document.rs @@ -150,11 +150,6 @@ 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_key_kinds, self.position) - } - /// Toggles the container value associated with the YAML key at the cursor. /// /// The displayed key determines the toggle target: @@ -353,71 +348,6 @@ fn locate_path( None } -fn path_at_row( - rows: &[Row], - path_key_kinds: &[PathKeyKind], - target_row_index: usize, -) -> Option<(usize, String)> { - let mut stack: Vec = Vec::new(); - let mut document_index = 0; - let mut found_document = false; - - for (row_index, row) in rows.iter().enumerate() { - if matches!(row.node, YamlNode::DocumentSeparator) { - stack.clear(); - continue; - } - if matches!( - TagAwareContainer::get(&row.node), - Some(ContainerNode::Close { .. }) - ) { - stack.truncate(row.depth); - continue; - } - if row.depth == 0 { - if found_document { - document_index += 1; - } - found_document = true; - stack.clear(); - } - stack.truncate(row.depth); - - let path = if row.depth == 0 { - Some(".".to_owned()) - } else { - let parent = stack.get_mut(row.depth - 1)?; - match parent.typ { - ContainerType::Object => parent.path.as_ref().and_then(|parent_path| { - mapping_path(parent_path, row.key.as_deref()?, path_key_kinds[row_index]) - }), - ContainerType::Array => { - let path = parent.path.as_ref().map(|parent_path| { - append_bracket(parent_path, &parent.next_index.to_string()) - }); - parent.next_index += 1; - path - } - } - }; - - if row_index == target_row_index { - return path.map(|path| (document_index, path)); - } - - if let Some(ContainerNode::Open { typ, .. }) = TagAwareContainer::get(&row.node) { - stack.push(PathFrame { - path, - typ: typ.clone(), - next_index: 0, - open_index: row_index, - }); - } - } - - None -} - fn mapping_path(parent: &str, key: &str, kind: PathKeyKind) -> Option { match kind { PathKeyKind::String => Some(append_string_key(parent, key)), @@ -656,10 +586,6 @@ second: [1, 2] assert!(document.move_to_path(0, ".items[0].name.nested")); assert_eq!(document.visible_position(), 2); - assert_eq!( - document.selected_path(), - Some((0, ".items[0].name.nested".to_owned())) - ); assert!( !document .visible_rows() @@ -679,10 +605,6 @@ second: [1, 2] .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, ".")); From 66427fdecebc484164aadbee80ebfb737e965a03 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 1 Sep 2026 19:21:48 +0900 Subject: [PATCH 5/6] Speed up selected structured paths --- promkit-widgets/benches/structured/json.rs | 7 + promkit-widgets/benches/structured/yaml.rs | 6 + .../src/structured/json/document.rs | 63 ++++++++- promkit-widgets/src/structured/mod.rs | 120 ++++++++++++++++++ .../src/structured/yaml/document.rs | 84 +++++++++++- 5 files changed, 278 insertions(+), 2 deletions(-) diff --git a/promkit-widgets/benches/structured/json.rs b/promkit-widgets/benches/structured/json.rs index fe39a38f..59c0e67a 100644 --- a/promkit-widgets/benches/structured/json.rs +++ b/promkit-widgets/benches/structured/json.rs @@ -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(|| { diff --git a/promkit-widgets/benches/structured/yaml.rs b/promkit-widgets/benches/structured/yaml.rs index cf5b34c8..24d82f7d 100644 --- a/promkit-widgets/benches/structured/yaml.rs +++ b/promkit-widgets/benches/structured/yaml.rs @@ -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(|| { diff --git a/promkit-widgets/src/structured/json/document.rs b/promkit-widgets/src/structured/json/document.rs index 88f7b672..c72cb307 100644 --- a/promkit-widgets/src/structured/json/document.rs +++ b/promkit-widgets/src/structured/json/document.rs @@ -5,7 +5,7 @@ use super::{ jsonz::{self, ContainerNode, ContainerType, JsonNode, Row, RowOperation}, }; use crate::structured::{ - ProjectionViewport, + PathIndex, PathRow, ProjectionViewport, create_path_indices, path::{append_bracket, append_string_key}, projection_viewport, }; @@ -14,6 +14,7 @@ use crate::structured::{ #[derive(Clone)] pub struct Document { rows: Vec, + path_indices: Box<[PathIndex]>, position: usize, viewport: Cell, } @@ -35,8 +36,10 @@ impl Document { } fn from_rows(rows: Vec) -> Self { + let path_indices = json_path_indices(&rows); Self { rows, + path_indices, position: 0, viewport: Cell::default(), } @@ -111,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); @@ -280,6 +288,51 @@ fn locate_path(rows: &[Row], document_index: usize, target: &str) -> Option 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, target: usize) -> usize { let mut position = rows.head(); let mut visible = 0; @@ -435,6 +488,10 @@ mod tests { 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")); } @@ -445,6 +502,10 @@ mod tests { 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, ".")); } diff --git a/promkit-widgets/src/structured/mod.rs b/promkit-widgets/src/structured/mod.rs index 32a985c2..d7dfe883 100644 --- a/promkit-widgets/src/structured/mod.rs +++ b/promkit-widgets/src/structured/mod.rs @@ -133,6 +133,126 @@ pub trait RowOperation { fn extract(&self, current: usize, n: usize) -> Vec; } +const NO_PATH_INDEX: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct PathIndex { + parent_or_document: u32, + array_index: u32, +} + +impl PathIndex { + fn invalid() -> Self { + Self { + parent_or_document: NO_PATH_INDEX, + array_index: NO_PATH_INDEX, + } + } + + fn root(document_index: usize) -> Self { + Self { + parent_or_document: path_index(document_index), + array_index: NO_PATH_INDEX, + } + } + + fn child(parent: usize, array_index: Option) -> Self { + Self { + parent_or_document: path_index(parent), + array_index: array_index.map_or(NO_PATH_INDEX, path_index), + } + } + + pub(crate) fn parent(self) -> Option { + (self.parent_or_document != NO_PATH_INDEX).then_some(self.parent_or_document as usize) + } + + pub(crate) fn document_index(self) -> Option { + self.parent() + } + + pub(crate) fn array_index(self) -> Option { + (self.array_index != NO_PATH_INDEX).then_some(self.array_index as usize) + } +} + +fn path_index(index: usize) -> u32 { + assert!( + index < u32::MAX as usize, + "structured documents support fewer than u32::MAX rows" + ); + index as u32 +} + +pub(crate) enum PathRow { + Separator, + Close { + depth: usize, + }, + Value { + depth: usize, + open_type: Option, + }, +} + +struct PathIndexFrame { + row_index: usize, + typ: ContainerType, + next_index: usize, +} + +pub(crate) fn create_path_indices(rows: impl IntoIterator) -> Box<[PathIndex]> { + let rows = rows.into_iter(); + let mut indices = Vec::with_capacity(rows.size_hint().0); + let mut stack: Vec = Vec::new(); + let mut document_index = 0; + + for (row_index, row) in rows.enumerate() { + let (depth, open_type) = match row { + PathRow::Separator => { + stack.clear(); + indices.push(PathIndex::invalid()); + continue; + } + PathRow::Close { depth } => { + stack.truncate(depth); + indices.push(PathIndex::invalid()); + continue; + } + PathRow::Value { depth, open_type } => (depth, open_type), + }; + + stack.truncate(depth); + let index = if depth == 0 { + let index = PathIndex::root(document_index); + document_index += 1; + stack.clear(); + index + } else { + let parent = stack + .get_mut(depth - 1) + .expect("a child row must have a parent"); + let array_index = matches!(parent.typ, ContainerType::Array).then(|| { + let index = parent.next_index; + parent.next_index += 1; + index + }); + PathIndex::child(parent.row_index, array_index) + }; + indices.push(index); + + if let Some(typ) = open_type { + stack.push(PathIndexFrame { + row_index, + typ, + next_index: 0, + }); + } + } + + indices.into_boxed_slice() +} + #[derive(Clone, Copy, Debug, Default)] pub(crate) struct ProjectionViewport { start: usize, diff --git a/promkit-widgets/src/structured/yaml/document.rs b/promkit-widgets/src/structured/yaml/document.rs index a2597332..45090d30 100644 --- a/promkit-widgets/src/structured/yaml/document.rs +++ b/promkit-widgets/src/structured/yaml/document.rs @@ -9,7 +9,7 @@ use crate::structured::yaml::{ }, }; use crate::structured::{ - ProjectionViewport, + PathIndex, PathRow, ProjectionViewport, create_path_indices, path::{append_bracket, append_string_key}, projection_viewport, }; @@ -19,6 +19,7 @@ use crate::structured::{ pub struct Document { rows: Vec, path_key_kinds: Vec, + path_indices: Box<[PathIndex]>, position: usize, line_numbers: Vec>, line_count: usize, @@ -47,6 +48,7 @@ impl Document { path_key_kinds, } = indexed_rows; debug_assert_eq!(rows.len(), path_key_kinds.len()); + let path_indices = yaml_path_indices(&rows); let position = rows.head(); let mut line_numbers = vec![None; rows.len()]; let mut line_count = 0; @@ -68,6 +70,7 @@ impl Document { Self { rows, path_key_kinds, + path_indices, position, line_numbers, line_count, @@ -150,6 +153,16 @@ 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_key_kinds, + &self.path_indices, + self.position, + ) + } + /// Toggles the container value associated with the YAML key at the cursor. /// /// The displayed key determines the toggle target: @@ -348,6 +361,67 @@ fn locate_path( None } +fn yaml_path_indices(rows: &[Row]) -> Box<[PathIndex]> { + create_path_indices(rows.iter().map(|row| { + if matches!(row.node, YamlNode::DocumentSeparator) { + return PathRow::Separator; + } + match TagAwareContainer::get(&row.node) { + Some(ContainerNode::Close { .. }) => PathRow::Close { depth: row.depth }, + Some(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_key_kinds: &[PathKeyKind], + path_indices: &[PathIndex], + target_row_index: usize, +) -> Option<(usize, String)> { + let target = rows.get(target_row_index)?; + if matches!(target.node, YamlNode::DocumentSeparator) { + return None; + } + let target_row_index = match TagAwareContainer::get(&target.node) { + Some(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 Some(ContainerNode::Open { typ, .. }) = + TagAwareContainer::get(&rows[parent_index].node) + else { + return None; + }; + path = match typ { + ContainerType::Object => mapping_path( + &path, + rows[row_index].key.as_deref()?, + path_key_kinds[row_index], + )?, + ContainerType::Array => append_bracket(&path, &index.array_index()?.to_string()), + }; + } + Some((document_index, path)) +} + fn mapping_path(parent: &str, key: &str, kind: PathKeyKind) -> Option { match kind { PathKeyKind::String => Some(append_string_key(parent, key)), @@ -586,6 +660,10 @@ second: [1, 2] assert!(document.move_to_path(0, ".items[0].name.nested")); assert_eq!(document.visible_position(), 2); + assert_eq!( + document.selected_path(), + Some((0, ".items[0].name.nested".to_owned())) + ); assert!( !document .visible_rows() @@ -605,6 +683,10 @@ second: [1, 2] .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, ".")); From 8161d6efbc1173574c6c6a01ed69a075403ed7f8 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 7 Sep 2026 18:23:20 +0900 Subject: [PATCH 6/6] Update workspace dependencies --- Cargo.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c93d2a68..a7195a2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"