use jsonc_parser::ast::{Array, Object, ObjectPropName, Value};
use jsonc_parser::common::Ranged;
use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
use serde_json::json;
use yaml_edit::path::YamlPath;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Seg {
Key(String),
Index(usize),
Select { key: String, value: String },
}
pub fn parse_path(spec: &str) -> Result<Vec<Seg>, String> {
let body = spec.strip_prefix('.').unwrap_or(spec);
if body.is_empty() {
return Err(format!("empty path '{spec}'"));
}
let mut segs = Vec::new();
for part in body.split('.') {
let mut rest = part;
match rest.find('[') {
Some(br) => {
let key = &rest[..br];
if !key.is_empty() {
segs.push(Seg::Key(key.to_string()));
}
rest = &rest[br..];
while let Some(stripped) = rest.strip_prefix('[') {
let close = stripped
.find(']')
.ok_or_else(|| format!("unclosed '[' in path '{spec}'"))?;
let inner = &stripped[..close];
if let Some((k, v)) = inner.split_once('=') {
segs.push(Seg::Select {
key: k.to_string(),
value: v.to_string(),
});
} else {
let idx: usize = inner
.parse()
.map_err(|_| format!("invalid index '[{inner}]' in path '{spec}'"))?;
segs.push(Seg::Index(idx));
}
rest = &stripped[close + 1..];
}
if !rest.is_empty() {
return Err(format!("trailing characters '{rest}' in path '{spec}'"));
}
}
None => {
if rest.is_empty() {
return Err(format!("empty segment in path '{spec}'"));
}
segs.push(Seg::Key(rest.to_string()));
}
}
}
Ok(segs)
}
pub fn split_assign(spec: &str) -> Option<(&str, &str)> {
let mut depth = 0i32;
for (i, c) in spec.char_indices() {
match c {
'[' => depth += 1,
']' => depth = (depth - 1).max(0),
'=' if depth == 0 => return Some((&spec[..i], &spec[i + 1..])),
_ => {}
}
}
None
}
pub fn normalize_value(v: &str) -> String {
match serde_json::from_str::<serde_json::Value>(v) {
Ok(parsed) => parsed.to_string(),
Err(_) => serde_json::Value::String(v.to_string()).to_string(),
}
}
#[derive(Debug, Clone, Copy)]
pub enum MoveTo {
First,
Last,
Up,
Down,
}
pub enum Op {
Set {
path: Vec<Seg>,
raw: String,
value: String,
},
Add {
path: Vec<Seg>,
raw: String,
value: String,
},
Delete {
path: Vec<Seg>,
raw: String,
},
Move {
path: Vec<Seg>,
raw: String,
to: MoveTo,
},
}
fn prop_key(name: &ObjectPropName) -> String {
match name {
ObjectPropName::String(s) => s.value.to_string(),
ObjectPropName::Word(w) => w.value.to_string(),
}
}
fn scalar_eq(node: &Value, value: &str) -> bool {
match node {
Value::StringLit(s) => s.value.as_ref() == value,
Value::NumberLit(n) => n.value == value,
Value::BooleanLit(b) => (if b.value { "true" } else { "false" }) == value,
Value::NullKeyword(_) => value == "null",
_ => false,
}
}
fn select_index(arr: &Array, key: &str, value: &str) -> Option<usize> {
arr.elements.iter().position(|e| match e {
Value::Object(o) => o
.properties
.iter()
.any(|p| prop_key(&p.name) == key && scalar_eq(&p.value, value)),
_ => false,
})
}
fn final_array_index(parent: &Value, last: &Seg, raw: &str) -> Result<usize, String> {
let arr = as_array(parent, raw)?;
match last {
Seg::Index(i) => Ok(*i),
Seg::Select { key, value } => select_index(arr, key, value)
.ok_or_else(|| format!("path '{raw}': no array element where {key}={value}")),
Seg::Key(_) => Err(format!(
"path '{raw}': expected an array element, got an object key"
)),
}
}
fn line_indent(text: &str, pos: usize) -> String {
let line_start = text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
text[line_start..pos]
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect()
}
fn splice(text: &str, start: usize, end: usize, repl: &str) -> (String, bool) {
let out = format!("{}{}{}", &text[..start], repl, &text[end..]);
let changed = out != text;
(out, changed)
}
fn navigate<'a>(root: &'a Value<'a>, segs: &[Seg]) -> Option<&'a Value<'a>> {
let mut cur = root;
for seg in segs {
cur = match (seg, cur) {
(Seg::Key(k), Value::Object(o)) => {
&o.properties.iter().find(|p| prop_key(&p.name) == *k)?.value
}
(Seg::Index(i), Value::Array(a)) => a.elements.get(*i)?,
(Seg::Select { key, value }, Value::Array(a)) => {
a.elements.get(select_index(a, key, value)?)?
}
_ => return None,
};
}
Some(cur)
}
fn append_into(
text: &str,
open: usize,
first_child: usize,
last_end: usize,
entry: &str,
) -> (String, bool) {
let multiline = text[open + 1..first_child].contains('\n');
let insert = if multiline {
format!(",\n{}{entry}", line_indent(text, first_child))
} else {
format!(", {entry}")
};
splice(text, last_end, last_end, &insert)
}
fn set_in(
text: &str,
root: &Value,
path: &[Seg],
value: &str,
raw: &str,
) -> Result<(String, bool), String> {
let (last, parents) = path.split_last().expect("path is non-empty");
let parent = navigate(root, parents)
.ok_or_else(|| format!("path '{raw}' not found (a parent segment is missing)"))?;
match last {
Seg::Key(k) => {
let obj = as_object(parent, raw)?;
if let Some(p) = obj.properties.iter().find(|p| prop_key(&p.name) == *k) {
let r = p.value.range();
Ok(splice(text, r.start, r.end, value))
} else {
Ok(add_key(text, obj, k, value))
}
}
Seg::Index(i) => {
let arr = as_array(parent, raw)?;
let len = arr.elements.len();
if *i < len {
let r = arr.elements[*i].range();
Ok(splice(text, r.start, r.end, value))
} else if *i == len {
Ok(append_elem(text, arr, value))
} else {
Err(format!(
"path '{raw}': index {i} is out of range (length {len})"
))
}
}
Seg::Select { key, value: want } => {
let arr = as_array(parent, raw)?;
let i = select_index(arr, key, want)
.ok_or_else(|| format!("path '{raw}': no array element where {key}={want}"))?;
let r = arr.elements[i].range();
Ok(splice(text, r.start, r.end, value))
}
}
}
fn add_key(text: &str, obj: &Object, key: &str, value: &str) -> (String, bool) {
let entry = format!("{}: {value}", json!(key));
if obj.properties.is_empty() {
let pos = obj.range().start + 1;
return splice(text, pos, pos, &entry);
}
let first = obj.properties[0].range().start;
let last_end = obj.properties.last().unwrap().range().end;
append_into(text, obj.range().start, first, last_end, &entry)
}
fn append_elem(text: &str, arr: &Array, value: &str) -> (String, bool) {
if arr.elements.is_empty() {
let pos = arr.range().start + 1;
return splice(text, pos, pos, value);
}
let first = arr.elements[0].range().start;
let last_end = arr.elements.last().unwrap().range().end;
append_into(text, arr.range().start, first, last_end, value)
}
fn delete_in(text: &str, root: &Value, path: &[Seg], raw: &str) -> Result<(String, bool), String> {
let (last, parents) = path.split_last().expect("path is non-empty");
let parent = match navigate(root, parents) {
Some(p) => p,
None => return Ok((text.to_string(), false)),
};
match last {
Seg::Key(k) => {
let obj = as_object(parent, raw)?;
match obj.properties.iter().position(|p| prop_key(&p.name) == *k) {
Some(idx) => Ok(delete_member(
text,
obj.range().start,
obj.range().end,
&obj.properties
.iter()
.map(|p| (p.range().start, p.range().end))
.collect::<Vec<_>>(),
idx,
"{}",
)),
None => Ok((text.to_string(), false)),
}
}
Seg::Index(i) => {
let arr = as_array(parent, raw)?;
if *i < arr.elements.len() {
Ok(delete_member(
text,
arr.range().start,
arr.range().end,
&arr.elements
.iter()
.map(|e| (e.range().start, e.range().end))
.collect::<Vec<_>>(),
*i,
"[]",
))
} else {
Ok((text.to_string(), false))
}
}
Seg::Select { key, value: want } => {
let arr = as_array(parent, raw)?;
match select_index(arr, key, want) {
Some(i) => Ok(delete_member(
text,
arr.range().start,
arr.range().end,
&arr.elements
.iter()
.map(|e| (e.range().start, e.range().end))
.collect::<Vec<_>>(),
i,
"[]",
)),
None => Ok((text.to_string(), false)),
}
}
}
}
fn delete_member(
text: &str,
open: usize,
close: usize,
members: &[(usize, usize)],
idx: usize,
empty: &str,
) -> (String, bool) {
if members.len() == 1 {
return splice(text, open, close, empty);
}
let (start, end) = if idx + 1 < members.len() {
(members[idx].0, members[idx + 1].0)
} else {
(members[idx - 1].1, members[idx].1)
};
splice(text, start, end, "")
}
fn as_object<'a>(node: &'a Value<'a>, raw: &str) -> Result<&'a Object<'a>, String> {
match node {
Value::Object(o) => Ok(o),
_ => Err(format!("path '{raw}': expected an object")),
}
}
fn as_array<'a>(node: &'a Value<'a>, raw: &str) -> Result<&'a Array<'a>, String> {
match node {
Value::Array(a) => Ok(a),
_ => Err(format!("path '{raw}': expected an array")),
}
}
fn add_in(
text: &str,
root: &Value,
path: &[Seg],
value: &str,
raw: &str,
) -> Result<(String, bool), String> {
let node = navigate(root, path).ok_or_else(|| format!("path '{raw}' not found"))?;
let arr = as_array(node, raw)?;
Ok(append_elem(text, arr, value))
}
fn move_in(
text: &str,
root: &Value,
path: &[Seg],
to: MoveTo,
raw: &str,
) -> Result<(String, bool), String> {
let (last, parents) = path
.split_last()
.ok_or_else(|| format!("empty path '{raw}'"))?;
let parent = navigate(root, parents).ok_or_else(|| format!("path '{raw}' not found"))?;
let arr = as_array(parent, raw)?;
let i = final_array_index(parent, last, raw)?;
let len = arr.elements.len();
if i >= len {
return Err(format!(
"path '{raw}': index {i} is out of range (length {len})"
));
}
if len < 2 {
return Ok((text.to_string(), false));
}
let j = match to {
MoveTo::First => 0,
MoveTo::Last => len - 1,
MoveTo::Up => i.saturating_sub(1),
MoveTo::Down => (i + 1).min(len - 1),
};
if i == j {
return Ok((text.to_string(), false));
}
let spans: Vec<(usize, usize)> = arr
.elements
.iter()
.map(|e| {
let r = e.range();
(r.start, r.end)
})
.collect();
let items: Vec<&str> = spans.iter().map(|&(s, e)| &text[s..e]).collect();
let mut order: Vec<usize> = (0..len).collect();
let moved = order.remove(i);
order.insert(j, moved);
let sep = text[spans[0].1..spans[1].0].to_string();
let reordered: Vec<&str> = order.iter().map(|&k| items[k]).collect();
Ok(splice(
text,
spans[0].0,
spans[len - 1].1,
&reordered.join(&sep),
))
}
pub fn apply_op(text: &str, op: &Op) -> Result<(String, bool), String> {
let parsed = parse_to_ast(text, &CollectOptions::default(), &ParseOptions::default())
.map_err(|e| format!("parse error: {e}"))?;
let root = parsed.value.as_ref().ok_or("document is empty")?;
match op {
Op::Set { path, value, raw } => set_in(text, root, path, value, raw),
Op::Add { path, value, raw } => add_in(text, root, path, value, raw),
Op::Delete { path, raw } => delete_in(text, root, path, raw),
Op::Move { path, to, raw } => move_in(text, root, path, *to, raw),
}
}
pub fn apply_doc(text: &str, ops: &[Op]) -> Result<(String, usize), String> {
let mut cur = text.to_string();
let mut changes = 0usize;
for op in ops {
let (next, changed) = apply_op(&cur, op)?;
if changed {
changes += 1;
}
cur = next;
}
Ok((cur, changes))
}
pub fn apply_jsonl(text: &str, ops: &[Op]) -> Result<(String, usize), String> {
let mut out = String::with_capacity(text.len());
let mut changes = 0usize;
for segment in text.split_inclusive('\n') {
let (body, nl) = match segment.strip_suffix('\n') {
Some(b) => (b, "\n"),
None => (segment, ""),
};
if body.trim().is_empty() {
out.push_str(segment);
continue;
}
let (patched, n) = apply_doc(body, ops)?;
changes += n;
out.push_str(&patched);
out.push_str(nl);
}
Ok((out, changes))
}
fn key_path(path: &[Seg], raw: &str) -> Result<Vec<String>, String> {
path.iter()
.map(|s| match s {
Seg::Key(k) => Ok(k.clone()),
Seg::Index(_) => Err(format!(
"array-index paths are not yet supported for YAML ('{raw}')"
)),
Seg::Select { .. } => Err(format!(
"predicate paths are not yet supported for YAML ('{raw}')"
)),
})
.collect()
}
fn leading_trivia(text: &str) -> String {
let mut end = 0;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
end += line.len();
} else {
break;
}
}
text[..end].to_string()
}
fn yaml_set(doc: &yaml_edit::Document, dotted: &str, value: &str, raw: &str) -> Result<(), String> {
let parsed: serde_json::Value = serde_json::from_str(value)
.unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
match parsed {
serde_json::Value::Bool(b) => doc.set_path(dotted, b),
serde_json::Value::String(s) => doc.set_path(dotted, s.as_str()),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
doc.set_path(dotted, i);
} else if let Some(u) = n.as_u64() {
doc.set_path(dotted, u);
} else {
doc.set_path(dotted, n.as_f64().unwrap());
}
}
serde_json::Value::Null => {
return Err(format!(
"path '{raw}': null values are not yet supported for YAML"
));
}
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
return Err(format!(
"path '{raw}': array/object values are not yet supported for YAML"
));
}
}
Ok(())
}
pub fn apply_yaml(text: &str, ops: &[Op]) -> Result<(String, usize), String> {
use std::str::FromStr;
let doc = yaml_edit::Document::from_str(text).map_err(|e| format!("yaml parse error: {e}"))?;
let mut changes = 0usize;
for op in ops {
let before = doc.to_string();
match op {
Op::Set { path, value, raw } => {
let keys = key_path(path, raw)?;
let dotted = keys.join(".");
if doc.get_path(&dotted).is_none() {
return Err(format!(
"path '{raw}': key does not exist (adding new YAML keys is handled by the insert verbs)"
));
}
yaml_set(&doc, &dotted, value, raw)?;
}
Op::Delete { path, raw } => {
let keys = key_path(path, raw)?;
let (last, parents) = keys
.split_last()
.ok_or_else(|| format!("empty path '{raw}'"))?;
let mut map = doc
.as_mapping()
.ok_or_else(|| format!("path '{raw}': document root is not a mapping"))?;
for k in parents {
map = map
.get_mapping(k)
.ok_or_else(|| format!("path '{raw}': '{k}' is not a mapping"))?;
}
map.remove(last);
}
Op::Add { raw, .. } => {
return Err(format!("--add is not yet supported for YAML ('{raw}')"));
}
Op::Move { raw, .. } => {
return Err(format!("--move-* is not yet supported for YAML ('{raw}')"));
}
}
if doc.to_string() != before {
changes += 1;
}
}
let leading = leading_trivia(text);
let mut out = doc.to_string();
if !leading.is_empty() && !out.starts_with(&leading) {
out = format!("{leading}{out}");
}
Ok((out, changes))
}
#[cfg(test)]
mod tests {
use super::*;
fn set(text: &str, path: &str, value: &str) -> (String, bool) {
apply_op(
text,
&Op::Set {
path: parse_path(path).unwrap(),
raw: path.to_string(),
value: normalize_value(value),
},
)
.unwrap()
}
fn delete(text: &str, path: &str) -> (String, bool) {
apply_op(
text,
&Op::Delete {
path: parse_path(path).unwrap(),
raw: path.to_string(),
},
)
.unwrap()
}
fn add(text: &str, path: &str, value: &str) -> (String, bool) {
apply_op(
text,
&Op::Add {
path: parse_path(path).unwrap(),
raw: path.to_string(),
value: normalize_value(value),
},
)
.unwrap()
}
fn move_el(text: &str, path: &str, to: MoveTo) -> (String, bool) {
apply_op(
text,
&Op::Move {
path: parse_path(path).unwrap(),
raw: path.to_string(),
to,
},
)
.unwrap()
}
#[test]
fn path_parsing() {
assert_eq!(
parse_path(".a.b").unwrap(),
vec![Seg::Key("a".into()), Seg::Key("b".into())]
);
assert_eq!(parse_path("[0]").unwrap(), vec![Seg::Index(0)]);
assert_eq!(
parse_path("a[name=web].port").unwrap(),
vec![
Seg::Key("a".into()),
Seg::Select {
key: "name".into(),
value: "web".into()
},
Seg::Key("port".into()),
]
);
assert!(parse_path("").is_err());
assert!(parse_path("a[x]").is_err());
}
#[test]
fn assign_splits_outside_brackets() {
assert_eq!(split_assign(".a[n=b].c=1"), Some((".a[n=b].c", "1")));
assert_eq!(split_assign(".x=hi"), Some((".x", "hi")));
assert_eq!(split_assign(".x"), None);
}
#[test]
fn value_normalisation() {
assert_eq!(normalize_value("42"), "42");
assert_eq!(normalize_value("name"), "\"name\"");
}
#[test]
fn set_replaces_value_preserving_comments_and_layout() {
let t = "{\n \"a\": 1, // keep me\n \"b\": 2\n}\n";
let (out, changed) = set(t, ".a", "42");
assert!(changed);
assert_eq!(out, "{\n \"a\": 42, // keep me\n \"b\": 2\n}\n");
}
#[test]
fn set_adds_missing_key_multiline_with_indent() {
let (out, _) = set("{\n \"a\": 1\n}\n", ".b", "true");
assert_eq!(out, "{\n \"a\": 1,\n \"b\": true\n}\n");
}
#[test]
fn set_string_fallback_quotes_value() {
let (out, _) = set("{\"a\":1}", ".a", "hello");
assert_eq!(out, "{\"a\":\"hello\"}");
}
#[test]
fn nested_set_and_array_index() {
let t = "{\n \"x\": { \"y\": [10, 20, 30] }\n}\n";
let (out, changed) = set(t, ".x.y[1]", "99");
assert!(changed);
assert_eq!(out, "{\n \"x\": { \"y\": [10, 99, 30] }\n}\n");
}
#[test]
fn append_array_element_via_index() {
let (out, _) = set("[1, 2]", "[2]", "3"); assert_eq!(out, "[1, 2, 3]");
}
#[test]
fn predicate_selects_object_in_array() {
let t = "{ \"xs\": [ {\"n\":\"a\",\"v\":1}, {\"n\":\"b\",\"v\":2} ] }";
let (out, changed) = set(t, ".xs[n=b].v", "9");
assert!(changed);
assert_eq!(
out,
"{ \"xs\": [ {\"n\":\"a\",\"v\":1}, {\"n\":\"b\",\"v\":9} ] }"
);
let (del, _) = delete(t, ".xs[n=a]");
assert_eq!(del, "{ \"xs\": [ {\"n\":\"b\",\"v\":2} ] }");
}
#[test]
fn delete_takes_its_comma() {
assert_eq!(
delete("{\"a\":1,\"b\":2,\"c\":3}", ".b").0,
"{\"a\":1,\"c\":3}"
);
assert_eq!(delete("{\"a\":1,\"b\":2}", ".b").0, "{\"a\":1}");
assert_eq!(delete("{ \"a\": 1 }", ".a").0, "{}");
assert!(!delete("{\"a\":1}", ".z").1); }
#[test]
fn add_appends_without_index() {
let (out, changed) = add("{\"xs\": [1, 2]}", ".xs", "3");
assert!(changed);
assert_eq!(out, "{\"xs\": [1, 2, 3]}");
}
#[test]
fn move_reorders_preserving_separator() {
assert_eq!(
move_el("{\"xs\": [1, 2, 3]}", ".xs[0]", MoveTo::Last).0,
"{\"xs\": [2, 3, 1]}"
);
assert_eq!(
move_el("{\"xs\": [1, 2, 3]}", ".xs[2]", MoveTo::Up).0,
"{\"xs\": [1, 3, 2]}"
);
assert!(!move_el("{\"xs\": [1]}", ".xs[0]", MoveTo::Last).1); }
fn yaml_op_set(path: &str, value: &str) -> Op {
Op::Set {
path: parse_path(path).unwrap(),
raw: path.to_string(),
value: normalize_value(value),
}
}
#[test]
fn yaml_set_replace_and_delete_preserve_comments() {
let yaml = "# top\nserver:\n host: localhost # inline\n port: 8080\n debug: true\n";
let del = Op::Delete {
path: parse_path(".server.debug").unwrap(),
raw: ".server.debug".to_string(),
};
let (out, changes) = apply_yaml(yaml, &[yaml_op_set(".server.port", "9090"), del]).unwrap();
assert_eq!(changes, 2);
assert!(out.contains("# top"), "leading comment kept: {out:?}");
assert!(out.contains("port: 9090"), "number not quoted: {out:?}");
assert!(out.contains("# inline"), "inline comment kept: {out:?}");
assert!(!out.contains("debug:"), "debug deleted: {out:?}");
}
#[test]
fn yaml_add_and_predicate_paths_error() {
let add = Op::Add {
path: parse_path(".server.tags").unwrap(),
raw: ".server.tags".to_string(),
value: normalize_value("x"),
};
let e = apply_yaml("server:\n tags:\n - a\n", &[add]).unwrap_err();
assert!(e.contains("not yet supported for YAML"), "{e}");
let pred = apply_yaml("xs: []\n", &[yaml_op_set(".xs[n=a].v", "1")]).unwrap_err();
assert!(
pred.contains("predicate paths are not yet supported"),
"{pred}"
);
}
}