git.lucas.co / cce-data-editor
structured data editor
git clone https://git.lucas.co/cce-data-editor.git

commitf40c64eae4e79633e804176becd1a62b861e6e10
parent1a6a0410a1
authorLucas Galante <[email protected]>
date2026-09-11 13:55
Find the span of an indexed element, not of the n-th node

`find_kdl_span` treated an `Index` token as a node index, but the flat
key's index addresses an element of the flattened VALUE, and the two
are not the same: `kdl_to_json` turns an all-string multi-arg node
(`bevel_apps "a" "b"`) into a list with one element per argument, and
repeated nodes of a name concatenate into the same list.

So for a string list the lookup answered `[0]` with the span of the
whole line and `[1]` — the second app — with nothing at all. Selecting
it in the tree left the raw pane where it was instead of scrolling to
and highlighting the value, and a click anywhere on the line mapped
back to `[0]`, whichever argument was under the cursor. The writer is
unaffected: saves go through unflatten_json, not this span.

Walk the elements the nodes actually contribute (`node_element_spans`,
mirroring kdl_to_json's own rule) when the path ends at the index. A
deeper path still indexes nodes — only a node with properties is an
object, and those contribute one element each.

This is what test_kdl_span_lookup has been failing on: it asserts
against the live config, which grew a `bevel_apps` second entry. The
two tests added here pin the behaviour on a fixture instead, including
the `[0]`-too-wide case that `is_some()` could never catch.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/main.rs | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 92 insertions(+), 9 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 23a2638..5468e6d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -81,6 +81,35 @@ fn char_to_byte_idx(content: &str, char_idx: usize) -> usize {
         .unwrap_or(content.len())
 }
 
+/// The spans of the array elements one node contributes to its name's
+/// flattened value, in order. Mirrors `kdl_to_json`: a multi-argument node
+/// whose arguments are all strings (`bevel_apps "a" "b"`) is a LIST, one
+/// element per argument; everything else — a lone argument, the joined
+/// numeric multi-arg form (`(vec2i)100 200`), a node carrying properties —
+/// is a single element. Repeated nodes of the same name concatenate, so the
+/// caller walks them in document order.
+fn node_element_spans(node: &kdl::KdlNode) -> Vec<(usize, usize)> {
+    let span_of = |s: &kdl::KdlEntry| {
+        let sp = s.span();
+        (sp.offset(), sp.offset() + sp.len())
+    };
+    let entries = node.entries();
+    let has_props = entries.iter().any(|e| e.name().is_some());
+    if !has_props
+        && entries.len() > 1
+        && entries
+            .iter()
+            .all(|e| matches!(e.value(), kdl::KdlValue::String(_) | kdl::KdlValue::RawString(_)))
+    {
+        return entries.iter().map(span_of).collect();
+    }
+    let span = entries.first().map(span_of).unwrap_or_else(|| {
+        let sp = node.span();
+        (sp.offset(), sp.offset() + sp.len())
+    });
+    vec![span]
+}
+
 fn find_kdl_span(content: &str, tokens: &[PathToken]) -> Option<(usize, usize)> {
     let doc = content.parse::<kdl::KdlDocument>().ok()?;
     find_kdl_span_in_doc(&doc, tokens)
@@ -119,17 +148,24 @@ fn find_kdl_span_in_doc(doc: &kdl::KdlDocument, tokens: &[PathToken]) -> Option<
                             }
                         }
                     }
+                } else if tokens.len() == 2 {
+                    // The index addresses an element of the flattened value,
+                    // which is not the same as the n-th node: one node can
+                    // contribute several (a string list), and several nodes
+                    // concatenate. Walking the elements is what finds the
+                    // second entry of `bevel_apps "a" "b"` — indexing nodes
+                    // found nothing past [0], and answered [0] with the whole
+                    // line.
+                    return nodes.iter().flat_map(|n| node_element_spans(n)).nth(idx);
                 } else {
+                    // A deeper path means the element is an object, and only
+                    // a node with properties is one — so here the index IS
+                    // the node index.
                     let node = nodes.get(idx)?;
-                    if tokens.len() == 2 {
-                        let span = node.span();
-                        return Some((span.offset(), span.offset() + span.len()));
-                    } else {
-                        if let PathToken::Key(prop_key) = &tokens[2] {
-                            if let Some(entry) = node.entries().iter().find(|e| e.name().map(|id| id.value()) == Some(prop_key)) {
-                                let span = entry.span();
-                                return Some((span.offset(), span.offset() + span.len()));
-                            }
+                    if let PathToken::Key(prop_key) = &tokens[2] {
+                        if let Some(entry) = node.entries().iter().find(|e| e.name().map(|id| id.value()) == Some(prop_key)) {
+                            let span = entry.span();
+                            return Some((span.offset(), span.offset() + span.len()));
                         }
                     }
                 }
@@ -2922,6 +2958,53 @@ mod tests {
         }
     }
 
+    /// What `find_kdl_span` must return for an indexed path, pinned on a
+    /// fixture rather than the user's config: the earlier version indexed
+    /// NODES, so a string list answered `[0]` with its whole line and `[1]`
+    /// with nothing at all — the tree could not highlight the second app in
+    /// `bevel_apps`, and a click anywhere on that line selected `[0]`.
+    #[test]
+    fn span_of_an_indexed_element() {
+        let content = "\
+window_manager {
+    bevel_apps \"claude-desktop\" \"com.anthropic.Claude\"
+    bevel_apps \"latecomer\"
+    gap 12
+}
+";
+        let slice = |path: &str| -> Option<&str> {
+            let (start, end) = find_kdl_span(content, &parse_path(path))?;
+            Some(content[start..end].trim())
+        };
+        // Each argument of a string list is its own element...
+        assert_eq!(slice("window_manager.bevel_apps[0]"), Some("\"claude-desktop\""));
+        assert_eq!(slice("window_manager.bevel_apps[1]"), Some("\"com.anthropic.Claude\""));
+        // ...and a repeated node continues the same index space, because
+        // kdl_to_json concatenates the two lists.
+        assert_eq!(slice("window_manager.bevel_apps[2]"), Some("\"latecomer\""));
+        assert_eq!(slice("window_manager.bevel_apps[3]"), None);
+        // Plain keys are untouched by the change.
+        assert_eq!(slice("window_manager.gap"), Some("12"));
+    }
+
+    /// The other index shape: `key_bindings` children, where the index does
+    /// address the n-th node and a trailing key addresses one of its props.
+    #[test]
+    fn span_of_a_key_binding() {
+        let content = "\
+key_bindings {
+    bind key=\"q\" action=\"close\"
+    bind key=\"t\" action=\"new_tab\"
+}
+";
+        let span = |path: &str| -> Option<&str> {
+            let (start, end) = find_kdl_span(content, &parse_path(path))?;
+            Some(content[start..end].trim())
+        };
+        assert_eq!(span("key_bindings[1].action"), Some("action=\"new_tab\""));
+        assert_eq!(span("key_bindings[0].key"), Some("key=\"q\""));
+    }
+
     #[test]
     fn test_kdl_span_lookup() {
         let content = std::fs::read_to_string(cce_ui::config::get_config_path()).unwrap();