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

commit123c7f39b66d68c53a22bf8cbf16701da214defa
parent716f32d562
authorLucas Galante <[email protected]>
date2026-06-29 13:45
Transition from JSON to KDL format configuration

 Cargo.toml  |   1 +
 src/main.rs | 359 ++++++++++++++++++++----------------------------------------
 2 files changed, 119 insertions(+), 241 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index eb42264..32ee34f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,3 +18,4 @@ glyphon = "0.8"
 tokio = { version = "1", features = ["full"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+kdl = "4.6"
diff --git a/src/main.rs b/src/main.rs
index 8b273c5..816fecb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -53,226 +53,60 @@ fn parse_path(path: &str) -> Vec<PathToken> {
     tokens
 }
 
-struct JsonSpanFinder<'a> {
-    input: &'a str,
-    chars: std::iter::Peekable<std::str::CharIndices<'a>>,
+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)
 }
 
-impl<'a> JsonSpanFinder<'a> {
-    fn new(input: &'a str) -> Self {
-        Self {
-            input,
-            chars: input.char_indices().peekable(),
-        }
+fn find_kdl_span_in_doc(doc: &kdl::KdlDocument, tokens: &[PathToken]) -> Option<(usize, usize)> {
+    if tokens.is_empty() {
+        return None;
     }
-
-    fn skip_whitespace(&mut self) {
-        while let Some(&(_, c)) = self.chars.peek() {
-            if c.is_whitespace() {
-                self.chars.next();
+    let next_token = &tokens[0];
+    match next_token {
+        PathToken::Key(target_key) => {
+            let target_idx = if tokens.len() > 1 {
+                if let PathToken::Index(idx) = &tokens[1] {
+                    Some(*idx)
+                } else {
+                    None
+                }
             } else {
-                break;
-            }
-        }
-    }
-
-    fn find(&mut self, tokens: &[PathToken]) -> Option<(usize, usize)> {
-        self.skip_whitespace();
-        if tokens.is_empty() {
-            let start = self.chars.peek()?.0;
-            self.skip_value()?;
-            let end = self.chars.peek().map(|&(i, _)| i).unwrap_or(self.input.len());
-            return Some((start, end));
-        }
+                None
+            };
 
-        let next_token = &tokens[0];
-        match next_token {
-            PathToken::Key(target_key) => {
-                if self.chars.peek()?.1 != '{' {
-                    return None;
-                }
-                self.chars.next(); // consume '{'
-                
-                loop {
-                    self.skip_whitespace();
-                    let next_c = self.chars.peek()?.1;
-                    if next_c == '}' {
-                        self.chars.next();
-                        break;
-                    }
-                    
-                    let _key_start = self.chars.peek()?.0;
-                    let key = self.parse_string()?;
-                    
-                    self.skip_whitespace();
-                    if self.chars.next()?.1 != ':' {
-                        return None;
-                    }
-                    self.skip_whitespace();
-                    
-                    if key == *target_key {
-                        if tokens.len() == 1 {
-                            let val_start = self.chars.peek()?.0;
-                            self.skip_value()?;
-                            let val_end = self.chars.peek().map(|&(i, _)| i).unwrap_or(self.input.len());
-                            return Some((val_start, val_end));
-                        } else {
-                            return self.find(&tokens[1..]);
-                        }
-                    } else {
-                        self.skip_value()?;
-                    }
-                    
-                    self.skip_whitespace();
-                    let comma_c = self.chars.peek()?.1;
-                    if comma_c == ',' {
-                        self.chars.next();
-                    } else if comma_c == '}' {
-                        // next iteration will handle loop break
-                    } else {
-                        return None;
-                    }
-                }
-            }
-            PathToken::Index(target_idx) => {
-                if self.chars.peek()?.1 != '[' {
-                    return None;
-                }
-                self.chars.next(); // consume '['
-                
-                let mut current_idx = 0;
-                loop {
-                    self.skip_whitespace();
-                    let next_c = self.chars.peek()?.1;
-                    if next_c == ']' {
-                        self.chars.next();
-                        break;
-                    }
-                    
-                    if current_idx == *target_idx {
-                        if tokens.len() == 1 {
-                            let val_start = self.chars.peek()?.0;
-                            self.skip_value()?;
-                            let val_end = self.chars.peek().map(|&(i, _)| i).unwrap_or(self.input.len());
-                            return Some((val_start, val_end));
-                        } else {
-                            return self.find(&tokens[1..]);
+            let nodes: Vec<&kdl::KdlNode> = doc.nodes().iter().filter(|n| n.name().value() == target_key).collect();
+            if let Some(idx) = target_idx {
+                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()));
                         }
-                    } else {
-                        self.skip_value()?;
-                    }
-                    
-                    self.skip_whitespace();
-                    let comma_c = self.chars.peek()?.1;
-                    if comma_c == ',' {
-                        self.chars.next();
-                        current_idx += 1;
-                    } else if comma_c == ']' {
-                        // next iteration will handle loop break
-                    } else {
-                        return None;
                     }
                 }
-            }
-        }
-        None
-    }
-
-    fn parse_string(&mut self) -> Option<String> {
-        self.skip_whitespace();
-        if self.chars.next()?.1 != '"' {
-            return None;
-        }
-        let mut s = String::new();
-        while let Some((_, c)) = self.chars.next() {
-            if c == '"' {
-                return Some(s);
-            } else if c == '\\' {
-                let escaped = self.chars.next()?.1;
-                s.push(escaped);
             } else {
-                s.push(c);
-            }
-        }
-        None
-    }
-
-    fn skip_value(&mut self) -> Option<()> {
-        self.skip_whitespace();
-        let &(_, c) = self.chars.peek()?;
-        match c {
-            '{' => {
-                self.chars.next();
-                loop {
-                    self.skip_whitespace();
-                    let next_c = self.chars.peek()?.1;
-                    if next_c == '}' {
-                        self.chars.next();
-                        break;
-                    }
-                    self.parse_string()?;
-                    self.skip_whitespace();
-                    if self.chars.next()?.1 != ':' {
-                        return None;
-                    }
-                    self.skip_value()?;
-                    self.skip_whitespace();
-                    let comma = self.chars.peek()?.1;
-                    if comma == ',' {
-                        self.chars.next();
-                    } else if comma == '}' {
-                        // will break next
-                    } else {
-                        return None;
-                    }
-                }
-            }
-            '[' => {
-                self.chars.next();
-                loop {
-                    self.skip_whitespace();
-                    let next_c = self.chars.peek()?.1;
-                    if next_c == ']' {
-                        self.chars.next();
-                        break;
-                    }
-                    self.skip_value()?;
-                    self.skip_whitespace();
-                    let comma = self.chars.peek()?.1;
-                    if comma == ',' {
-                        self.chars.next();
-                    } else if comma == ']' {
-                        // will break next
-                    } else {
-                        return None;
-                    }
-                }
-            }
-            '"' => {
-                self.parse_string()?;
-            }
-            't' | 'f' | 'n' => {
-                while let Some(&(_, c)) = self.chars.peek() {
-                    if c.is_alphabetic() {
-                        self.chars.next();
+                let node = nodes.first()?;
+                if tokens.len() == 1 {
+                    if let Some(entry) = node.entries().first() {
+                        let span = entry.span();
+                        return Some((span.offset(), span.offset() + span.len()));
                     } else {
-                        break;
+                        let span = node.span();
+                        return Some((span.offset(), span.offset() + span.len()));
                     }
+                } else if let Some(children) = node.children() {
+                    return find_kdl_span_in_doc(children, &tokens[1..]);
                 }
             }
-            '-' | '0'..='9' => {
-                while let Some(&(_, c)) = self.chars.peek() {
-                    if c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E' || c.is_digit(10) {
-                        self.chars.next();
-                    } else {
-                        break;
-                    }
-                }
-            }
-            _ => return None,
         }
-        Some(())
+        PathToken::Index(_) => {}
     }
+    None
 }
 
 
@@ -498,9 +332,47 @@ struct DataEditorApp {
 }
 
 impl DataEditorApp {
+    fn json_to_kdl_string(&self, val: &serde_json::Value) -> String {
+        let mut out = String::new();
+        if let serde_json::Value::Object(map) = val {
+            for (sec_name, sec_val) in map {
+                out.push_str(&format!("{} {{\n", sec_name));
+                if let serde_json::Value::Object(sec_map) = sec_val {
+                    for (key, k_val) in sec_map {
+                        let (val_str, val_ty) = match k_val {
+                            serde_json::Value::Bool(b) => (b.to_string(), Some("bool")),
+                            serde_json::Value::Number(num) => {
+                                if num.is_f64() {
+                                    (num.to_string(), Some("f64"))
+                                } else {
+                                    (num.to_string(), Some("i64"))
+                                }
+                            }
+                            serde_json::Value::String(s) => {
+                                if s.starts_with('#') {
+                                    (format!("\"{}\"", s), Some("color"))
+                                } else {
+                                    (format!("\"{}\"", s), None)
+                                }
+                            }
+                            _ => (k_val.to_string(), None),
+                        };
+                        if let Some(ty) = val_ty {
+                            out.push_str(&format!("    {} ({}){}\n", key, ty, val_str));
+                        } else {
+                            out.push_str(&format!("    {} {}\n", key, val_str));
+                        }
+                    }
+                }
+                out.push_str("}\n");
+            }
+        }
+        out
+    }
+
     fn pick_file_to_open(&self) -> Result<std::path::PathBuf, String> {
         println!("[DEBUG] pick_file_to_open: Executing XDG desktop portal file chooser");
-        match cce_ui::file_dialog::pick_file("Open JSON Document", &[("JSON Documents", &["json"]), ("All Files", &["*"])]) {
+        match cce_ui::file_dialog::pick_file("Open KDL Document", &[("KDL Documents", &["kdl"]), ("All Files", &["*"])]) {
             Some(path) => Ok(path),
             None => Err("No file selected".to_string()),
         }
@@ -508,7 +380,7 @@ impl DataEditorApp {
 
     fn pick_file_to_save(&self) -> Result<std::path::PathBuf, String> {
         println!("[DEBUG] pick_file_to_save: Executing XDG desktop portal file chooser");
-        match cce_ui::file_dialog::save_file("Save JSON Document", &[("JSON Documents", &["json"]), ("All Files", &["*"])]) {
+        match cce_ui::file_dialog::save_file("Save KDL Document", &[("KDL Documents", &["kdl"]), ("All Files", &["*"])]) {
             Some(path) => Ok(path),
             None => Err("No file selected".to_string()),
         }
@@ -516,11 +388,10 @@ impl DataEditorApp {
 
     fn update_raw_from_flat(&mut self) {
         let root = unflatten_json(&self.flat_keys);
-        if let Ok(pretty) = serde_json::to_string_pretty(&root) {
-            self.raw_json_editor.text = pretty;
-            self.raw_json_editor.edit_buffer = self.raw_json_editor.text.clone();
-            self.raw_json_editor.sync_editor_state();
-        }
+        let pretty = self.json_to_kdl_string(&root);
+        self.raw_json_editor.text = pretty;
+        self.raw_json_editor.edit_buffer = self.raw_json_editor.text.clone();
+        self.raw_json_editor.sync_editor_state();
     }
 
     fn sync_preview_selection(&mut self) {
@@ -529,7 +400,7 @@ impl DataEditorApp {
                 let path = &self.flat_keys[idx].0;
                 let tokens = parse_path(path);
                 let content = if self.raw_json_editor.editing { &self.raw_json_editor.edit_buffer } else { &self.raw_json_editor.text };
-                if let Some((start, end)) = JsonSpanFinder::new(content).find(&tokens) {
+                if let Some((start, end)) = find_kdl_span(content, &tokens) {
                     self.raw_json_editor.select_anchor = Some(start);
                     self.raw_json_editor.cursor_idx = end;
                     self.raw_json_editor.sync_editor_state();
@@ -819,9 +690,8 @@ impl Application for DataEditorApp {
                     raw_json_editor.text = content;
                     raw_json_editor.edit_buffer = raw_json_editor.text.clone();
                     current_file_path = Some(path);
-                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw_json_editor.text) {
-                        flatten_json(&val, "", &mut flat_keys);
-                    }
+                    let val = cce_ui::config::parse_kdl_to_json(&raw_json_editor.text);
+                    flatten_json(&val, "", &mut flat_keys);
                 }
             }
         }
@@ -866,7 +736,7 @@ impl Application for DataEditorApp {
 
     fn settings(&self) -> WindowSettings {
         WindowSettings {
-            title: "Clear JSON Data Editor".to_string(),
+            title: "Clear KDL Data Editor".to_string(),
             app_id: "cce-data-editor".to_string(),
             width: 800,
             height: 600,
@@ -894,11 +764,14 @@ impl Application for DataEditorApp {
                                 self.raw_json_editor.editing = false;
                                 
                                 self.flat_keys.clear();
-                                if let Ok(val) = serde_json::from_str::<serde_json::Value>(&self.raw_json_editor.text) {
+                                if self.raw_json_editor.text.parse::<kdl::KdlDocument>().is_ok() {
+                                    let val = cce_ui::config::parse_kdl_to_json(&self.raw_json_editor.text);
                                     flatten_json(&val, "", &mut self.flat_keys);
                                     self.status_message = Some((format!("Opened {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
                                 } else {
-                                    self.status_message = Some(("Loaded, but JSON is syntactically invalid".to_string(), true));
+                                    let val = cce_ui::config::parse_kdl_to_json(&self.raw_json_editor.text);
+                                    flatten_json(&val, "", &mut self.flat_keys);
+                                    self.status_message = Some(("Loaded, but KDL is syntactically invalid".to_string(), true));
                                 }
                                 
                                 self.selected_key_idx = None;
@@ -925,8 +798,8 @@ impl Application for DataEditorApp {
             }
             AppMessage::SaveDocument => {
                 let content = if self.raw_json_editor.editing { &self.raw_json_editor.edit_buffer } else { &self.raw_json_editor.text };
-                if let Err(e) = serde_json::from_str::<serde_json::Value>(content) {
-                    self.status_message = Some((format!("Cannot save: invalid JSON ({})", e), true));
+                if let Err(e) = content.parse::<kdl::KdlDocument>() {
+                    self.status_message = Some((format!("Cannot save: invalid KDL ({})", e), true));
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
                     return;
@@ -952,8 +825,8 @@ impl Application for DataEditorApp {
             }
             AppMessage::SaveDocumentAs => {
                 let content = if self.raw_json_editor.editing { &self.raw_json_editor.edit_buffer } else { &self.raw_json_editor.text };
-                if let Err(e) = serde_json::from_str::<serde_json::Value>(content) {
-                    self.status_message = Some((format!("Cannot save: invalid JSON ({})", e), true));
+                if let Err(e) = content.parse::<kdl::KdlDocument>() {
+                    self.status_message = Some((format!("Cannot save: invalid KDL ({})", e), true));
                     *needs_rebuild = true;
                     self.needs_rebuild = true;
                     return;
@@ -987,22 +860,22 @@ impl Application for DataEditorApp {
             }
             AppMessage::FormatJson => {
                 let content = if self.raw_json_editor.editing { &self.raw_json_editor.edit_buffer } else { &self.raw_json_editor.text };
-                match serde_json::from_str::<serde_json::Value>(content) {
-                    Ok(val) => {
-                        if let Ok(pretty) = serde_json::to_string_pretty(&val) {
-                            self.raw_json_editor.text = pretty;
-                            self.raw_json_editor.edit_buffer = self.raw_json_editor.text.clone();
-                            self.raw_json_editor.editing = false;
-                            self.raw_json_editor.sync_editor_state();
-                            
-                            self.flat_keys.clear();
-                            flatten_json(&val, "", &mut self.flat_keys);
-                            self.status_message = Some(("Formatted successfully".to_string(), false));
-                            self.sync_preview_selection();
-                        }
+                match content.parse::<kdl::KdlDocument>() {
+                    Ok(doc) => {
+                        let pretty = doc.to_string();
+                        self.raw_json_editor.text = pretty;
+                        self.raw_json_editor.edit_buffer = self.raw_json_editor.text.clone();
+                        self.raw_json_editor.editing = false;
+                        self.raw_json_editor.sync_editor_state();
+                        
+                        let val = cce_ui::config::parse_kdl_to_json(&self.raw_json_editor.text);
+                        self.flat_keys.clear();
+                        flatten_json(&val, "", &mut self.flat_keys);
+                        self.status_message = Some(("Formatted successfully".to_string(), false));
+                        self.sync_preview_selection();
                     }
                     Err(e) => {
-                        self.status_message = Some((format!("JSON error: {}", e), true));
+                        self.status_message = Some((format!("KDL error: {}", e), true));
                     }
                 }
                 *needs_rebuild = true;
@@ -1019,11 +892,14 @@ impl Application for DataEditorApp {
                             self.raw_json_editor.editing = false;
                             
                             self.flat_keys.clear();
-                            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&self.raw_json_editor.text) {
+                            if self.raw_json_editor.text.parse::<kdl::KdlDocument>().is_ok() {
+                                let val = cce_ui::config::parse_kdl_to_json(&self.raw_json_editor.text);
                                 flatten_json(&val, "", &mut self.flat_keys);
                                 self.status_message = Some(("Refreshed from disk".to_string(), false));
                             } else {
-                                self.status_message = Some(("Refreshed, but JSON is syntactically invalid".to_string(), true));
+                                let val = cce_ui::config::parse_kdl_to_json(&self.raw_json_editor.text);
+                                flatten_json(&val, "", &mut self.flat_keys);
+                                self.status_message = Some(("Refreshed, but KDL is syntactically invalid".to_string(), true));
                             }
                             
                             self.selected_key_idx = None;
@@ -1131,7 +1007,8 @@ impl Application for DataEditorApp {
     fn tick(&mut self, _dt: f32, needs_rebuild: &mut bool) {
         if self.raw_json_editor.take_change() {
             let content = if self.raw_json_editor.editing { &self.raw_json_editor.edit_buffer } else { &self.raw_json_editor.text };
-            if let Ok(val) = serde_json::from_str::<serde_json::Value>(content) {
+            if content.parse::<kdl::KdlDocument>().is_ok() {
+                let val = cce_ui::config::parse_kdl_to_json(content);
                 let mut new_flat = Vec::new();
                 flatten_json(&val, "", &mut new_flat);
                 
@@ -1156,7 +1033,7 @@ impl Application for DataEditorApp {
                 }
                 self.status_message = None;
             } else {
-                self.status_message = Some(("JSON syntax error in text editor".to_string(), true));
+                self.status_message = Some(("KDL syntax error in text editor".to_string(), true));
             }
             *needs_rebuild = true;
             self.needs_rebuild = true;