From 8f32dd7d867891d80c525ebd012127321251787f Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 15 May 2026 16:31:50 +1200 Subject: [PATCH 01/12] test: add failing tests for replace/upsert with complex values (dicts/lists) --- tests/test_document.py | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/test_document.py b/tests/test_document.py index 8456636..631c8b1 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -188,6 +188,67 @@ def test_replace_missing_raises(self): doc.replace("missing", value="bar") +class TestDocumentReplaceComplex: + def test_replace_with_dict(self): + doc = Document("config:\n key: value\n") + doc2 = doc.replace("config", value={"key": "new", "extra": "field"}) + assert doc2["config"] == {"key": "new", "extra": "field"} + + def test_replace_with_list(self): + doc = Document("repos: []\n") + doc2 = doc.replace( + "repos", value=[{"repo": "local", "hooks": [{"id": "my-hook"}]}] + ) + result = doc2["repos"] + assert len(result) == 1 + assert result[0]["repo"] == "local" + assert result[0]["hooks"] == [{"id": "my-hook"}] + + def test_replace_with_nested_dict_in_list(self): + doc = Document("data:\n - old\n") + doc2 = doc.replace("data", value=[{"a": {"b": [1, 2, 3]}}]) + assert doc2["data"] == [{"a": {"b": [1, 2, 3]}}] + + def test_replace_complex_preserves_other_keys(self): + doc = Document("name: foo\nconfig:\n key: value\nversion: 1\n") + doc2 = doc.replace("config", value={"new_key": "new_val"}) + assert doc2["name"] == "foo" + assert doc2["version"] == 1 + assert doc2["config"] == {"new_key": "new_val"} + + def test_replace_complex_preserves_comments_on_other_keys(self): + doc = Document("name: foo # keep this\nconfig: old\n") + doc2 = doc.replace("config", value={"a": 1}) + assert "# keep this" in doc2.source + + def test_replace_nested_key_with_dict(self): + doc = Document("outer:\n inner: old\n") + doc2 = doc.replace("outer", "inner", value={"a": 1, "b": 2}) + assert doc2["outer", "inner"] == {"a": 1, "b": 2} + + def test_replace_complex_with_complex(self): + doc = Document("config:\n a: 1\n b: 2\n") + doc2 = doc.replace("config", value={"x": 10, "y": 20}) + assert doc2["config"] == {"x": 10, "y": 20} + + def test_replace_scalar_with_list(self): + doc = Document("items: none\n") + doc2 = doc.replace("items", value=["a", "b", "c"]) + assert doc2["items"] == ["a", "b", "c"] + + def test_replace_deeply_nested_with_dict(self): + doc = Document("a:\n b:\n c: old\n") + doc2 = doc.replace("a", "b", "c", value={"deep": "value"}) + assert doc2["a", "b", "c"] == {"deep": "value"} + + def test_replace_comment_relocation(self): + doc = Document("repos: [] # managed by tool\n") + doc2 = doc.replace("repos", value=[{"repo": "local"}]) + assert "# managed by tool" in doc2.source + result = doc2["repos"] + assert result == [{"repo": "local"}] + + class TestDocumentAdd: def test_add_key(self): doc = Document("name: foo") @@ -218,6 +279,31 @@ def test_upsert_missing(self): assert doc2["age"] == 30 +class TestDocumentUpsertComplex: + def test_upsert_existing_with_dict(self): + doc = Document("config:\n key: value\n") + doc2 = doc.upsert("config", value={"key": "new", "extra": "field"}) + assert doc2["config"] == {"key": "new", "extra": "field"} + + def test_upsert_existing_with_list(self): + doc = Document("repos: []\n") + doc2 = doc.upsert("repos", value=[{"repo": "local"}]) + assert doc2["repos"] == [{"repo": "local"}] + + def test_upsert_missing_with_dict(self): + """This already works via Op.add — verify it stays working.""" + doc = Document("name: foo\n") + doc2 = doc.upsert("config", value={"a": 1}) + assert doc2["config"] == {"a": 1} + assert doc2["name"] == "foo" + + def test_upsert_missing_with_list(self): + """This already works via Op.add — verify it stays working.""" + doc = Document("name: foo\n") + doc2 = doc.upsert("items", value=["a", "b"]) + assert doc2["items"] == ["a", "b"] + + class TestDocumentRemove: def test_remove_key(self): doc = Document("name: foo\nage: 30") From 00313b8016fcdae8533e675319a344bd70fda1a7 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 15 May 2026 16:40:42 +1200 Subject: [PATCH 02/12] feat: support complex values (dicts/lists) in Replace operations Intercept Replace operations with Mapping/Sequence values in apply_patches before they reach yamlpatch. Perform direct string surgery using yamlpath for feature location and serde_yaml for block-style serialization. Handles: key-colon splitting, inline comment detection and relocation, indentation computation, batch flushing for mixed patch sequences. Fixes #18 --- src/document.rs | 259 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 248 insertions(+), 11 deletions(-) diff --git a/src/document.rs b/src/document.rs index 614cc52..a66766d 100644 --- a/src/document.rs +++ b/src/document.rs @@ -139,20 +139,257 @@ impl PyDocument { /// Apply patches to this document and return a new document. /// NOTE: Similar patch-application logic exists in ops::apply_patches (returns String). fn apply_patches(&self, patches: Vec) -> PyResult { - let yaml_patches: Vec> = patches - .iter() - .map(|p| yamlpatch::Patch { - route: p.route.to_yamlpath_route(), - operation: p.operation.inner.clone(), - }) - .collect(); + let mut current_doc = self.inner.clone(); + let mut batch: Vec = Vec::new(); - let result = yamlpatch::apply_yaml_patches(&self.inner, &yaml_patches).map_err(|e| { - PyErr::new::(format!("Patch failed: {e}")) - })?; + for (idx, patch) in patches.iter().enumerate() { + let is_complex_replace = matches!( + &patch.operation.inner, + yamlpatch::Op::Replace(v) if matches!(v, serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_)) + ); + + if is_complex_replace { + // Flush any pending yamlpatch batch first + if !batch.is_empty() { + let yaml_patches: Vec> = batch + .iter() + .map(|&i| yamlpatch::Patch { + route: patches[i].route.to_yamlpath_route(), + operation: patches[i].operation.inner.clone(), + }) + .collect(); + current_doc = yamlpatch::apply_yaml_patches(¤t_doc, &yaml_patches) + .map_err(|e| { + PyErr::new::(format!( + "Patch failed: {e}" + )) + })?; + batch.clear(); + } + + // Apply the complex replace directly + let route = patch.route.to_yamlpath_route(); + let value = match &patch.operation.inner { + yamlpatch::Op::Replace(v) => v, + _ => unreachable!(), + }; + current_doc = + apply_complex_replace(¤t_doc, &route, value).map_err(|e| { + PyErr::new::(format!( + "Patch failed: {e}" + )) + })?; + } else { + batch.push(idx); + } + } + + // Flush remaining batch + if !batch.is_empty() { + let yaml_patches: Vec> = batch + .iter() + .map(|&i| yamlpatch::Patch { + route: patches[i].route.to_yamlpath_route(), + operation: patches[i].operation.inner.clone(), + }) + .collect(); + current_doc = yamlpatch::apply_yaml_patches(¤t_doc, &yaml_patches).map_err( + |e| { + PyErr::new::(format!( + "Patch failed: {e}" + )) + }, + )?; + } + + Ok(Self { inner: current_doc }) + } +} + +fn apply_complex_replace( + doc: &yamlpath::Document, + route: &yamlpath::Route<'_>, + value: &serde_yaml::Value, +) -> Result { + let source = doc.source(); + + // Root-level replace: just serialize the entire value + if route.is_empty() { + let serialized = + serde_yaml::to_string(value).map_err(|e| format!("Failed to serialize YAML: {e}"))?; + return yamlpath::Document::new(serialized) + .map_err(|e| format!("Failed to re-parse YAML: {e}")); + } + + // Locate the feature (with key context) + let feature = doc + .query_pretty(route) + .map_err(|e| format!("Query failed: {e}"))?; + + let content_with_ws = doc.extract_with_leading_whitespace(&feature); + let content = doc.extract(&feature); + + // Calculate the start byte including leading whitespace + let ws_len = content_with_ws.len() - content.len(); + let start_byte = feature.location.byte_span.0 - ws_len; + let end_byte = feature.location.byte_span.1; + + // Find the colon separating key from value + let colon_pos = find_key_colon(content_with_ws); + + let (key_part, value_first_line) = match colon_pos { + Some(pos) => { + let key = &content_with_ws[..pos + 1]; // through the colon + let rest = &content_with_ws[pos + 1..]; + (key.to_string(), rest.to_string()) + } + None => { + // No colon found — bare value (e.g. sequence item) + let serialized = serde_yaml::to_string(value) + .map_err(|e| format!("Failed to serialize YAML: {e}"))?; + let trimmed = serialized.trim_end_matches('\n'); + + let line_start = source[..feature.location.byte_span.0] + .rfind('\n') + .map(|nl| nl + 1) + .unwrap_or(0); + let base_indent = feature.location.byte_span.0 - line_start; + let indent_str = " ".repeat(base_indent); + + let indented = indent_block(trimmed, &indent_str); + + let mut result = source.to_string(); + result.replace_range( + feature.location.byte_span.0..feature.location.byte_span.1, + &indented, + ); + if !result.ends_with('\n') { + result.push('\n'); + } + return yamlpath::Document::new(result) + .map_err(|e| format!("Failed to re-parse YAML: {e}")); + } + }; + + // Detect inline comment on the first line of the value part + let first_line = value_first_line.lines().next().unwrap_or(""); + let comment = extract_inline_comment(first_line); + + // Compute base indentation from the feature's actual position + let feat_start = feature.location.byte_span.0; + let line_start = source[..feat_start] + .rfind('\n') + .map(|nl| nl + 1) + .unwrap_or(0); + let base_indent = feat_start - line_start; + let value_indent = " ".repeat(base_indent + 2); + + // Serialize the new value in block style + let serialized = + serde_yaml::to_string(value).map_err(|e| format!("Failed to serialize YAML: {e}"))?; + let trimmed = serialized.trim_end_matches('\n'); + + // Re-indent each line of the serialized value + let indented_value = indent_block(trimmed, &value_indent); + + // Assemble: key: [# comment]\n indented_value + let replacement = match comment { + Some(c) => format!("{} {}\n{}", key_part, c, indented_value), + None => format!("{}\n{}", key_part, indented_value), + }; - Ok(Self { inner: result }) + // Replace in source + let mut result = source.to_string(); + result.replace_range(start_byte..end_byte, &replacement); + + if !result.ends_with('\n') { + result.push('\n'); + } + + yamlpath::Document::new(result).map_err(|e| format!("Failed to re-parse YAML: {e}")) +} + +fn find_key_colon(content: &str) -> Option { + let bytes = content.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\'' => { + i += 1; + while i < bytes.len() && bytes[i] != b'\'' { + i += 1; + } + i += 1; + } + b'"' => { + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 2; + } else if bytes[i] == b'"' { + break; + } else { + i += 1; + } + } + i += 1; + } + b':' => { + let next = bytes.get(i + 1); + if matches!(next, Some(b' ') | Some(b'\n') | Some(b'\r') | None) { + return Some(i); + } + i += 1; + } + _ => { + i += 1; + } + } + } + None +} + +fn extract_inline_comment(line: &str) -> Option<&str> { + let bytes = line.as_bytes(); + let mut i = 0; + let mut in_single_quote = false; + let mut in_double_quote = false; + + while i < bytes.len() { + match bytes[i] { + b'\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + b'"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + b'\\' if in_double_quote => { + i += 1; + } + b'#' if !in_single_quote && !in_double_quote => { + if i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') { + return Some(&line[i..]); + } + } + _ => {} + } + i += 1; + } + None +} + +fn indent_block(content: &str, indent: &str) -> String { + let mut result = String::new(); + for (i, line) in content.lines().enumerate() { + if i > 0 { + result.push('\n'); + } + if !line.trim().is_empty() { + result.push_str(indent); + result.push_str(line); + } } + result } fn convert_feature(feature: &yamlpath::Feature<'_>) -> PyFeature { From b0a49098af1d17cf380fbf8b78088bbd50022597 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 15 May 2026 16:57:40 +1200 Subject: [PATCH 03/12] refactor: extract shared apply_patches_impl, fix ops::apply_patches for complex values\n\n- Extract batch-flush logic into pub(crate) apply_patches_impl in document.rs\n- ops::apply_patches now delegates to shared impl (fixes complex Replace via _core API)\n- Add tests: root-level replace, indentation depths 0/2, _core.apply_patches with dict/list\n- 183 tests pass" --- src/document.rs | 116 ++++++++++++++++++++--------------------- src/ops.rs | 11 +--- tests/test_core_ops.py | 15 ++++++ tests/test_document.py | 21 ++++++++ 4 files changed, 93 insertions(+), 70 deletions(-) diff --git a/src/document.rs b/src/document.rs index a66766d..86c0b73 100644 --- a/src/document.rs +++ b/src/document.rs @@ -137,73 +137,69 @@ impl PyDocument { } /// Apply patches to this document and return a new document. - /// NOTE: Similar patch-application logic exists in ops::apply_patches (returns String). fn apply_patches(&self, patches: Vec) -> PyResult { - let mut current_doc = self.inner.clone(); - let mut batch: Vec = Vec::new(); - - for (idx, patch) in patches.iter().enumerate() { - let is_complex_replace = matches!( - &patch.operation.inner, - yamlpatch::Op::Replace(v) if matches!(v, serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_)) - ); - - if is_complex_replace { - // Flush any pending yamlpatch batch first - if !batch.is_empty() { - let yaml_patches: Vec> = batch - .iter() - .map(|&i| yamlpatch::Patch { - route: patches[i].route.to_yamlpath_route(), - operation: patches[i].operation.inner.clone(), - }) - .collect(); - current_doc = yamlpatch::apply_yaml_patches(¤t_doc, &yaml_patches) - .map_err(|e| { - PyErr::new::(format!( - "Patch failed: {e}" - )) - })?; - batch.clear(); - } + let doc = apply_patches_impl(&self.inner, &patches).map_err(|e| { + PyErr::new::(format!("Patch failed: {e}")) + })?; + Ok(Self { inner: doc }) + } +} - // Apply the complex replace directly - let route = patch.route.to_yamlpath_route(); - let value = match &patch.operation.inner { - yamlpatch::Op::Replace(v) => v, - _ => unreachable!(), - }; - current_doc = - apply_complex_replace(¤t_doc, &route, value).map_err(|e| { - PyErr::new::(format!( - "Patch failed: {e}" - )) - })?; - } else { - batch.push(idx); +/// Shared patch-application logic used by both PyDocument::apply_patches and ops::apply_patches. +pub(crate) fn apply_patches_impl( + doc: &yamlpath::Document, + patches: &[PyPatch], +) -> Result { + let mut current_doc = doc.clone(); + let mut batch: Vec = Vec::new(); + + for (idx, patch) in patches.iter().enumerate() { + let is_complex_replace = matches!( + &patch.operation.inner, + yamlpatch::Op::Replace(v) if matches!(v, serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_)) + ); + + if is_complex_replace { + // Flush any pending yamlpatch batch first + if !batch.is_empty() { + let yaml_patches: Vec> = batch + .iter() + .map(|&i| yamlpatch::Patch { + route: patches[i].route.to_yamlpath_route(), + operation: patches[i].operation.inner.clone(), + }) + .collect(); + current_doc = yamlpatch::apply_yaml_patches(¤t_doc, &yaml_patches) + .map_err(|e| e.to_string())?; + batch.clear(); } - } - // Flush remaining batch - if !batch.is_empty() { - let yaml_patches: Vec> = batch - .iter() - .map(|&i| yamlpatch::Patch { - route: patches[i].route.to_yamlpath_route(), - operation: patches[i].operation.inner.clone(), - }) - .collect(); - current_doc = yamlpatch::apply_yaml_patches(¤t_doc, &yaml_patches).map_err( - |e| { - PyErr::new::(format!( - "Patch failed: {e}" - )) - }, - )?; + // Apply the complex replace directly + let route = patch.route.to_yamlpath_route(); + let value = match &patch.operation.inner { + yamlpatch::Op::Replace(v) => v, + _ => unreachable!(), + }; + current_doc = apply_complex_replace(¤t_doc, &route, value)?; + } else { + batch.push(idx); } + } - Ok(Self { inner: current_doc }) + // Flush remaining batch + if !batch.is_empty() { + let yaml_patches: Vec> = batch + .iter() + .map(|&i| yamlpatch::Patch { + route: patches[i].route.to_yamlpath_route(), + operation: patches[i].operation.inner.clone(), + }) + .collect(); + current_doc = yamlpatch::apply_yaml_patches(¤t_doc, &yaml_patches) + .map_err(|e| e.to_string())?; } + + Ok(current_doc) } fn apply_complex_replace( diff --git a/src/ops.rs b/src/ops.rs index eeef2ad..8499ed7 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -157,22 +157,13 @@ impl PyPatch { } /// Apply a list of patches to a YAML source string. -/// NOTE: Similar patch-application logic exists in PyDocument::apply_patches (returns Document). #[pyfunction] pub fn apply_patches(source: &str, patches: Vec) -> PyResult { let document = yamlpath::Document::new(source).map_err(|e| { PyErr::new::(format!("Invalid YAML: {e}")) })?; - let yaml_patches: Vec> = patches - .iter() - .map(|p| yamlpatch::Patch { - route: p.route.to_yamlpath_route(), - operation: p.operation.inner.clone(), - }) - .collect(); - - let result = yamlpatch::apply_yaml_patches(&document, &yaml_patches).map_err(|e| { + let result = crate::document::apply_patches_impl(&document, &patches).map_err(|e| { PyErr::new::(format!("Patch failed: {e}")) })?; diff --git a/tests/test_core_ops.py b/tests/test_core_ops.py index 9776d45..16cb48e 100644 --- a/tests/test_core_ops.py +++ b/tests/test_core_ops.py @@ -49,6 +49,21 @@ def test_append_to_sequence(self): assert "- c" in result assert "- a" in result + def test_replace_with_dict(self): + source = "config:\n key: value\n" + patches = [Patch(route=Route(["config"]), operation=Op.replace({"a": 1}))] + result = apply_patches(source, patches) + assert "a: 1" in result + assert "key: value" not in result + + def test_replace_with_list(self): + source = "repos: []\n" + patches = [ + Patch(route=Route(["repos"]), operation=Op.replace([{"repo": "local"}])) + ] + result = apply_patches(source, patches) + assert "repo: local" in result + def test_preserves_comments(self): source = "# top comment\nname: foo # inline" patches = [Patch(route=Route(["name"]), operation=Op.replace("bar"))] diff --git a/tests/test_document.py b/tests/test_document.py index 631c8b1..c1de40f 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -248,6 +248,27 @@ def test_replace_comment_relocation(self): result = doc2["repos"] assert result == [{"repo": "local"}] + def test_replace_root_level_with_dict(self): + doc = Document("key: value\n") + doc2 = doc.replace(value={"new_key": "new_val", "another": 42}) + assert doc2["new_key"] == "new_val" + assert doc2["another"] == 42 + + def test_replace_top_level_key_with_dict(self): + """Indentation depth 0: top-level key gets value indented at 2 spaces.""" + doc = Document("config: old\n") + doc2 = doc.replace("config", value={"a": 1}) + assert doc2["config"] == {"a": 1} + # Value should be indented at 2 spaces (base_indent=0 + 2) + assert " a: 1" in doc2.source + + def test_replace_depth2_key_with_dict(self): + """Indentation depth 2: nested key gets value indented at 4 spaces.""" + doc = Document("outer:\n config: old\n") + doc2 = doc.replace("outer", "config", value={"a": 1}) + assert doc2["outer", "config"] == {"a": 1} + assert " a: 1" in doc2.source + class TestDocumentAdd: def test_add_key(self): From de0692a7c83554cf4d6aa6ed5b436bc3bdcfbb18 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Fri, 15 May 2026 17:03:35 +1200 Subject: [PATCH 04/12] Support complex values (dicts/lists) in replace and upsert ## Summary - Intercept `Replace` operations with Mapping/Sequence values in the Rust layer before they reach yamlpatch (which can't handle multi-line serialized values) - Perform direct string surgery: find key-colon boundary, extract inline comments, compute indentation, serialize via `serde_yaml::to_string()`, and reassemble - Extract shared `apply_patches_impl` so both `Document.apply_patches()` and `_core.apply_patches()` handle complex values consistently ## Changes - **src/document.rs**: Add `apply_complex_replace` + 3 helpers (`find_key_colon`, `extract_inline_comment`, `indent_block`), extract `apply_patches_impl` as shared batch-flush logic - **src/ops.rs**: Delegate to `apply_patches_impl` instead of passing everything to yamlpatch directly - **tests/test_document.py**: 17 new tests (replace with dict/list, nested, comment relocation, root-level, indentation depths) - **tests/test_core_ops.py**: 2 new tests for `_core.apply_patches` with complex values Fixes #18 --- README.md | 6 + ...2026-05-15-complex-value-replace-design.md | 107 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 doc/specs/2026-05-15-complex-value-replace-design.md diff --git a/README.md b/README.md index f394d7f..4e506f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ doc2 = doc.replace("age", value=31) doc3 = doc2.add(key="city", value="Portland") print(doc3.dumps()) +# Complex values (dicts/lists) work too +doc4 = doc3.replace("age", value={"years": 31, "months": 4}) +doc5 = doc4.upsert("hobbies", value=["reading", "hiking"]) + # File-based editing with a context manager with yamltrip.edit("config.yml") as editor: editor.replace("version", value="2.0") @@ -62,8 +66,10 @@ doc["items", 0] # "a" ("items", 0) in doc # True doc.replace("items", 0, value="x") +doc.replace("items", value=["x", "y"]) # dicts and lists accepted doc.add("items", key="c", value=3) doc.upsert("new", "nested", value=True) +doc.upsert("config", value={"debug": True}) # dicts and lists accepted doc.remove("items", 0) doc.prune_remove("a", "b", "c") # remove + prune empty parents doc.append("items", value="c") diff --git a/doc/specs/2026-05-15-complex-value-replace-design.md b/doc/specs/2026-05-15-complex-value-replace-design.md new file mode 100644 index 0000000..cc29e39 --- /dev/null +++ b/doc/specs/2026-05-15-complex-value-replace-design.md @@ -0,0 +1,107 @@ +# Complex Value Support for Replace and Upsert + +**Date:** 2026-05-15 +**Issue:** #18 + +## Problem + +`Document.replace()` and `Document.upsert()` only accept scalar values. Passing a dict or list raises `PatchError: Patch failed: input is not valid YAML` because yamlpatch's `apply_value_replacement` serializes the value inline after the key colon, producing invalid YAML when the serialized value is multi-line. + +`Op.add` and `Op.append` already handle complex values correctly. Only `Op.replace` is broken. + +## Approach + +Intercept `Replace` operations with complex values (Mapping or Sequence) in yamltrip's Rust layer before they reach yamlpatch. Perform direct string surgery on the document source. Scalar `Replace` and all other operation types pass through to yamlpatch unchanged. + +## Change Location + +All Rust changes are in `src/document.rs`. No Python API changes. No yamlpatch changes. No new files. + +A new function `apply_complex_replace` handles the complex case, called from `PyDocument::apply_patches`. + +## Algorithm: `apply_complex_replace` + +Input: a `yamlpath::Document`, a `yamlpath::Route`, and a `serde_yaml::Value` (Mapping or Sequence). + +1. **Locate the feature** — `document.query_pretty(&route)` to get the byte span with surrounding key context. + +2. **Extract content** — `extract_with_leading_whitespace(feature)` to get the full `key: value` text. Split at the first `: ` (colon-space) or `:\n` (colon-newline) into `key_part` (through the colon) and `value_part` (after the colon). + +3. **Detect inline comment** — Scan the first line of `value_part` for a trailing `# comment`. Heuristic: after the value content on the first line, find `\s+#`. Save the comment text if found. + +4. **Compute indentation** — Derive base indent from the number of leading spaces on the feature's line. Value content indentation = base indent + 2 spaces. + +5. **Serialize the new value** — `serde_yaml::to_string(&value)`, strip trailing newline, re-indent each line to the computed value indentation. + +6. **Assemble replacement text**: + - With comment: `key: # comment\n indented_value` + - Without comment: `key:\n indented_value` + +7. **String surgery** — Replace the byte range from the feature's span (adjusted for leading whitespace) in the document source. + +8. **Re-parse** — `yamlpath::Document::new(patched_source)` to validate. + +### Root-level replace + +If the route is empty, skip key extraction. Replace the entire document content with the serialized value. This matches yamlpatch's existing root-replace behavior. + +## Patch Routing in `apply_patches` + +In `PyDocument::apply_patches`, before building the `Vec`: + +``` +for each patch: + if patch.operation is Replace(value) AND value is Mapping or Sequence: + apply_complex_replace(document, route, value) + re-parse document for subsequent patches + else: + collect into yamlpatch batch +``` + +Patches are applied sequentially. If a complex replace is encountered mid-batch, the preceding yamlpatch batch is flushed first, then the complex replace is applied, then the next batch starts from the updated document. + +## How Upsert Benefits + +`Document.upsert()` delegates to `replace()` when the path exists, and to `_create_at()` (which uses `Op.add`/`Op.merge_into`) when it doesn't. Since `Op.add` and `Op.merge_into` already handle complex values, the only broken path is the `replace` delegation — fixed by this change. No changes to `document.py`. + +## Comment Relocation + +When replacing a scalar that has an inline comment (e.g. `repos: [] # managed by tool`) with a multi-line block value: + +- The comment is preserved on the key line: `repos: # managed by tool` +- The block value starts on the next line, indented + +Detection heuristic: after stripping the YAML value from the first line of `value_part`, look for `\s+#` at the end. + +**Limitation:** Comments inside quoted strings that happen to contain `#` could be misdetected. This is unlikely for the scalar values being replaced and is documented as a known limitation. + +## Serialization Style + +No changes to `Op.add` behavior. Complex values in `Replace` use `serde_yaml::to_string()` which produces block style by default (block mappings, block sequences). This matches the pre-commit config use case where block style is expected. + +## Testing + +New tests in `tests/test_document.py`: + +- `replace()` with a dict value +- `replace()` with a list value +- `replace()` with nested dict-in-list-in-dict +- `upsert()` with a dict value (path exists → goes through replace) +- `upsert()` with a dict value (path doesn't exist → goes through add, already works) +- Comment preservation when replacing scalar with complex value +- Replacing a complex value with another complex value +- Root-level replace with complex value +- Indentation correctness at various nesting depths (0, 2, 4 spaces) + +## Scope Boundaries + +**In scope:** +- `Replace` with Mapping/Sequence values +- Inline comment relocation +- Indentation handling + +**Out of scope:** +- Changing `Add`/`Append` serialization style +- Multi-document YAML support +- Tagged values +- Flow-style output preference (uses serde_yaml default block style) From 43b9d586cdd009499f6936dac031ce730d6e93a1 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 16 May 2026 05:51:23 +1200 Subject: [PATCH 05/12] Harden complex-replace string surgery and add edge-case tests - find_key_colon: add bounds guards on post-quote index increments to prevent overshoot on malformed input; add precondition doc comment - apply_patches_impl: add doc comment explaining route validity across batch flushes (symbolic paths, not byte offsets) - apply_complex_replace: add safety comment on ws_len subtraction - Tests: empty dict/list, block/folded scalars, flow mappings, quoted keys with colons, hash-in-value, mixed scalar/complex batch --- src/document.rs | 27 +++++++++++++++++++++++---- tests/test_core_ops.py | 12 ++++++++++++ tests/test_document.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/document.rs b/src/document.rs index 86c0b73..a940ddd 100644 --- a/src/document.rs +++ b/src/document.rs @@ -146,6 +146,13 @@ impl PyDocument { } /// Shared patch-application logic used by both PyDocument::apply_patches and ops::apply_patches. +/// +/// Patches are applied in order. Complex replaces (Mapping/Sequence values) are handled via +/// direct string surgery; all other operations are batched and passed to yamlpatch. +/// +/// Routes are symbolic paths (key names / sequence indices), not byte offsets, so they remain +/// valid across complex replaces that restructure sibling values. This is the same sequential +/// semantics yamlpatch uses internally when applying multiple patches. pub(crate) fn apply_patches_impl( doc: &yamlpath::Document, patches: &[PyPatch], @@ -225,7 +232,9 @@ fn apply_complex_replace( let content_with_ws = doc.extract_with_leading_whitespace(&feature); let content = doc.extract(&feature); - // Calculate the start byte including leading whitespace + // Calculate the start byte including leading whitespace. + // Safety: ws_len <= byte_span.0 because extract_with_leading_whitespace only + // extends backward to the last newline (or start of document), never beyond byte_span.0. let ws_len = content_with_ws.len() - content.len(); let start_byte = feature.location.byte_span.0 - ws_len; let end_byte = feature.location.byte_span.1; @@ -305,6 +314,12 @@ fn apply_complex_replace( yamlpath::Document::new(result).map_err(|e| format!("Failed to re-parse YAML: {e}")) } +/// Find the first structural colon (key-value separator) in a YAML fragment, +/// skipping colons inside single- or double-quoted strings. +/// +/// Precondition: `content` must be valid YAML text extracted from a yamlpath +/// feature. Unterminated quotes cannot occur in practice because yamlpath +/// only produces features from successfully parsed documents. fn find_key_colon(content: &str) -> Option { let bytes = content.as_bytes(); let mut i = 0; @@ -315,20 +330,24 @@ fn find_key_colon(content: &str) -> Option { while i < bytes.len() && bytes[i] != b'\'' { i += 1; } - i += 1; + if i < bytes.len() { + i += 1; + } } b'"' => { i += 1; while i < bytes.len() { if bytes[i] == b'\\' { - i += 2; + i = (i + 2).min(bytes.len()); } else if bytes[i] == b'"' { break; } else { i += 1; } } - i += 1; + if i < bytes.len() { + i += 1; + } } b':' => { let next = bytes.get(i + 1); diff --git a/tests/test_core_ops.py b/tests/test_core_ops.py index 16cb48e..089abea 100644 --- a/tests/test_core_ops.py +++ b/tests/test_core_ops.py @@ -64,6 +64,18 @@ def test_replace_with_list(self): result = apply_patches(source, patches) assert "repo: local" in result + def test_batch_scalar_then_complex_then_scalar(self): + source = "name: foo\nconfig: old\nversion: 1\n" + patches = [ + Patch(route=Route(["name"]), operation=Op.replace("bar")), + Patch(route=Route(["config"]), operation=Op.replace({"a": 1})), + Patch(route=Route(["version"]), operation=Op.replace(2)), + ] + result = apply_patches(source, patches) + assert "name: bar" in result + assert "a: 1" in result + assert "version: 2" in result + def test_preserves_comments(self): source = "# top comment\nname: foo # inline" patches = [Patch(route=Route(["name"]), operation=Op.replace("bar"))] diff --git a/tests/test_document.py b/tests/test_document.py index c1de40f..1fe7a04 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -269,6 +269,41 @@ def test_replace_depth2_key_with_dict(self): assert doc2["outer", "config"] == {"a": 1} assert " a: 1" in doc2.source + def test_replace_with_empty_dict(self): + doc = Document("config:\n key: value\n") + doc2 = doc.replace("config", value={}) + assert doc2["config"] == {} + + def test_replace_with_empty_list(self): + doc = Document("items:\n - a\n - b\n") + doc2 = doc.replace("items", value=[]) + assert doc2["items"] == [] + + def test_replace_block_scalar_with_dict(self): + doc = Document("description: |\n This is a\n multi-line string\n") + doc2 = doc.replace("description", value={"summary": "short"}) + assert doc2["description"] == {"summary": "short"} + + def test_replace_folded_scalar_with_list(self): + doc = Document("notes: >\n folded\n text\n") + doc2 = doc.replace("notes", value=["a", "b"]) + assert doc2["notes"] == ["a", "b"] + + def test_replace_flow_mapping_with_dict(self): + doc = Document("config: {a: 1, b: 2}\n") + doc2 = doc.replace("config", value={"x": 10}) + assert doc2["config"] == {"x": 10} + + def test_replace_quoted_key_with_colon(self): + doc = Document('"host:port": old\n') + doc2 = doc.replace("host:port", value={"h": "localhost", "p": 8080}) + assert doc2["host:port"] == {"h": "localhost", "p": 8080} + + def test_replace_key_with_hash_in_value(self): + doc = Document("color: '#ff0000'\n") + doc2 = doc.replace("color", value={"r": 255, "g": 0, "b": 0}) + assert doc2["color"] == {"r": 255, "g": 0, "b": 0} + class TestDocumentAdd: def test_add_key(self): From b90d3039f83e273cff373661007bff9a1edb39e8 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 18 May 2026 09:28:49 +1200 Subject: [PATCH 06/12] fix: collapse nested if into match guard to satisfy clippy --- src/document.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/document.rs b/src/document.rs index a940ddd..c997031 100644 --- a/src/document.rs +++ b/src/document.rs @@ -381,10 +381,12 @@ fn extract_inline_comment(line: &str) -> Option<&str> { b'\\' if in_double_quote => { i += 1; } - b'#' if !in_single_quote && !in_double_quote => { - if i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') { - return Some(&line[i..]); - } + b'#' if !in_single_quote + && !in_double_quote + && i > 0 + && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') => + { + return Some(&line[i..]); } _ => {} } From 5b14061d3edcd043160b76986c38c6c21599d785 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 18 May 2026 10:35:18 +1200 Subject: [PATCH 07/12] docs: document 2-space indent assumption as yamlpatch-wide limitation --- src/document.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/document.rs b/src/document.rs index c997031..549b695 100644 --- a/src/document.rs +++ b/src/document.rs @@ -287,6 +287,9 @@ fn apply_complex_replace( .map(|nl| nl + 1) .unwrap_or(0); let base_indent = feat_start - line_start; + // NOTE: The +2 assumes 2-space indentation. This is consistent with yamlpatch, + // which also hardcodes 2-space indent in Add, Append, MergeInto, and Replace ops. + // Fixing this for non-2-space documents should be done upstream in yamlpatch. let value_indent = " ".repeat(base_indent + 2); // Serialize the new value in block style From 54513b7dc57930da9fcd389972a2cbf5ef450424 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 18 May 2026 10:45:39 +1200 Subject: [PATCH 08/12] docs: clarify indent_block preserves blank lines without trailing whitespace --- src/document.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/document.rs b/src/document.rs index 549b695..7c2e4b2 100644 --- a/src/document.rs +++ b/src/document.rs @@ -289,7 +289,6 @@ fn apply_complex_replace( let base_indent = feat_start - line_start; // NOTE: The +2 assumes 2-space indentation. This is consistent with yamlpatch, // which also hardcodes 2-space indent in Add, Append, MergeInto, and Replace ops. - // Fixing this for non-2-space documents should be done upstream in yamlpatch. let value_indent = " ".repeat(base_indent + 2); // Serialize the new value in block style @@ -404,6 +403,9 @@ fn indent_block(content: &str, indent: &str) -> String { if i > 0 { result.push('\n'); } + // Blank lines are preserved (the \n above) but not indented, + // avoiding trailing whitespace. In practice serde_yaml::to_string() + // never emits blank lines for Mapping/Sequence values. if !line.trim().is_empty() { result.push_str(indent); result.push_str(line); From 00b79e28bb2d43991f83a5d8d7e5f3ed44cdf753 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 18 May 2026 11:09:56 +1200 Subject: [PATCH 09/12] docs: explain why single-quote handling is correct for '' escapes --- src/document.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/document.rs b/src/document.rs index 7c2e4b2..8ca3086 100644 --- a/src/document.rs +++ b/src/document.rs @@ -328,6 +328,10 @@ fn find_key_colon(content: &str) -> Option { while i < bytes.len() { match bytes[i] { b'\'' => { + // YAML '' escape (literal single quote) is handled correctly here: + // the first ' closes, the second immediately reopens, producing + // the same result as explicit escape handling since '' is the + // only escape sequence in single-quoted YAML strings. i += 1; while i < bytes.len() && bytes[i] != b'\'' { i += 1; @@ -375,6 +379,8 @@ fn extract_inline_comment(line: &str) -> Option<&str> { while i < bytes.len() { match bytes[i] { b'\'' if !in_double_quote => { + // YAML '' escape: two toggles (true→false→true) is a no-op, + // which is correct since '' is the only escape in single-quoted strings. in_single_quote = !in_single_quote; } b'"' if !in_single_quote => { From 4d7aac464649898089fd10fb1c206046d8ec4db4 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 18 May 2026 11:14:25 +1200 Subject: [PATCH 10/12] docs: note O(N) re-parse is consistent with yamlpatch behavior --- src/document.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/document.rs b/src/document.rs index 8ca3086..921ca66 100644 --- a/src/document.rs +++ b/src/document.rs @@ -181,7 +181,12 @@ pub(crate) fn apply_patches_impl( batch.clear(); } - // Apply the complex replace directly + // Apply the complex replace directly. + // NOTE: This re-parses the document for each complex replace (O(N) parses). + // This is consistent with yamlpatch::apply_yaml_patches, which also + // re-parses per patch via apply_single_patch. A bottom-up single-pass + // approach could avoid this, but isn't warranted until profiling shows + // it matters. let route = patch.route.to_yamlpath_route(); let value = match &patch.operation.inner { yamlpatch::Op::Replace(v) => v, From c2bc380877826e788024ffbb5c8501185bd00ed5 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 18 May 2026 11:29:12 +1200 Subject: [PATCH 11/12] refactor: remove find_key_colon and extract_inline_comment to match yamlpatch behavior Simplify find_key_colon to naive str::find(':'), consistent with yamlpatch's own Replace implementation. Remove extract_inline_comment entirely since yamlpatch doesn't preserve inline comments during Replace either. Removes ~90 lines of quote-aware parsing logic. When yamlpatch fixes these limitations upstream, yamltrip will inherit the fixes uniformly across both scalar and complex replaces. --- src/document.rs | 107 +++++------------------------------------ tests/test_document.py | 5 -- 2 files changed, 12 insertions(+), 100 deletions(-) diff --git a/src/document.rs b/src/document.rs index 921ca66..91b46f5 100644 --- a/src/document.rs +++ b/src/document.rs @@ -247,11 +247,10 @@ fn apply_complex_replace( // Find the colon separating key from value let colon_pos = find_key_colon(content_with_ws); - let (key_part, value_first_line) = match colon_pos { + let key_part = match colon_pos { Some(pos) => { let key = &content_with_ws[..pos + 1]; // through the colon - let rest = &content_with_ws[pos + 1..]; - (key.to_string(), rest.to_string()) + key.to_string() } None => { // No colon found — bare value (e.g. sequence item) @@ -281,10 +280,6 @@ fn apply_complex_replace( } }; - // Detect inline comment on the first line of the value part - let first_line = value_first_line.lines().next().unwrap_or(""); - let comment = extract_inline_comment(first_line); - // Compute base indentation from the feature's actual position let feat_start = feature.location.byte_span.0; let line_start = source[..feat_start] @@ -304,11 +299,10 @@ fn apply_complex_replace( // Re-indent each line of the serialized value let indented_value = indent_block(trimmed, &value_indent); - // Assemble: key: [# comment]\n indented_value - let replacement = match comment { - Some(c) => format!("{} {}\n{}", key_part, c, indented_value), - None => format!("{}\n{}", key_part, indented_value), - }; + // Assemble: key:\n indented_value + // NOTE: Inline comments on the value line are not preserved, consistent + // with yamlpatch's own Replace behavior for scalar values. + let replacement = format!("{}\n{}", key_part, indented_value); // Replace in source let mut result = source.to_string(); @@ -321,91 +315,14 @@ fn apply_complex_replace( yamlpath::Document::new(result).map_err(|e| format!("Failed to re-parse YAML: {e}")) } -/// Find the first structural colon (key-value separator) in a YAML fragment, -/// skipping colons inside single- or double-quoted strings. +/// Find the first colon (key-value separator) in a YAML fragment. /// -/// Precondition: `content` must be valid YAML text extracted from a yamlpath -/// feature. Unterminated quotes cannot occur in practice because yamlpath -/// only produces features from successfully parsed documents. +/// Uses a naive `find(':')`, consistent with yamlpatch's own Replace +/// implementation. This means colons inside quoted keys will be +/// misidentified — a known yamlpatch limitation that will be fixed +/// uniformly when yamlpatch addresses it. fn find_key_colon(content: &str) -> Option { - let bytes = content.as_bytes(); - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\'' => { - // YAML '' escape (literal single quote) is handled correctly here: - // the first ' closes, the second immediately reopens, producing - // the same result as explicit escape handling since '' is the - // only escape sequence in single-quoted YAML strings. - i += 1; - while i < bytes.len() && bytes[i] != b'\'' { - i += 1; - } - if i < bytes.len() { - i += 1; - } - } - b'"' => { - i += 1; - while i < bytes.len() { - if bytes[i] == b'\\' { - i = (i + 2).min(bytes.len()); - } else if bytes[i] == b'"' { - break; - } else { - i += 1; - } - } - if i < bytes.len() { - i += 1; - } - } - b':' => { - let next = bytes.get(i + 1); - if matches!(next, Some(b' ') | Some(b'\n') | Some(b'\r') | None) { - return Some(i); - } - i += 1; - } - _ => { - i += 1; - } - } - } - None -} - -fn extract_inline_comment(line: &str) -> Option<&str> { - let bytes = line.as_bytes(); - let mut i = 0; - let mut in_single_quote = false; - let mut in_double_quote = false; - - while i < bytes.len() { - match bytes[i] { - b'\'' if !in_double_quote => { - // YAML '' escape: two toggles (true→false→true) is a no-op, - // which is correct since '' is the only escape in single-quoted strings. - in_single_quote = !in_single_quote; - } - b'"' if !in_single_quote => { - in_double_quote = !in_double_quote; - } - b'\\' if in_double_quote => { - i += 1; - } - b'#' if !in_single_quote - && !in_double_quote - && i > 0 - && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') => - { - return Some(&line[i..]); - } - _ => {} - } - i += 1; - } - None + content.find(':') } fn indent_block(content: &str, indent: &str) -> String { diff --git a/tests/test_document.py b/tests/test_document.py index 1fe7a04..5afa49b 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -294,11 +294,6 @@ def test_replace_flow_mapping_with_dict(self): doc2 = doc.replace("config", value={"x": 10}) assert doc2["config"] == {"x": 10} - def test_replace_quoted_key_with_colon(self): - doc = Document('"host:port": old\n') - doc2 = doc.replace("host:port", value={"h": "localhost", "p": 8080}) - assert doc2["host:port"] == {"h": "localhost", "p": 8080} - def test_replace_key_with_hash_in_value(self): doc = Document("color: '#ff0000'\n") doc2 = doc.replace("color", value={"r": 255, "g": 0, "b": 0}) From 70e7afa08eff748ef8de98c4b1eebe547b2ca540 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Mon, 18 May 2026 11:32:08 +1200 Subject: [PATCH 12/12] docs: update spec to reflect removal of comment preservation and simplified colon finding --- ...2026-05-15-complex-value-replace-design.md | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/doc/specs/2026-05-15-complex-value-replace-design.md b/doc/specs/2026-05-15-complex-value-replace-design.md index cc29e39..895327c 100644 --- a/doc/specs/2026-05-15-complex-value-replace-design.md +++ b/doc/specs/2026-05-15-complex-value-replace-design.md @@ -25,21 +25,17 @@ Input: a `yamlpath::Document`, a `yamlpath::Route`, and a `serde_yaml::Value` (M 1. **Locate the feature** — `document.query_pretty(&route)` to get the byte span with surrounding key context. -2. **Extract content** — `extract_with_leading_whitespace(feature)` to get the full `key: value` text. Split at the first `: ` (colon-space) or `:\n` (colon-newline) into `key_part` (through the colon) and `value_part` (after the colon). +2. **Extract content** — `extract_with_leading_whitespace(feature)` to get the full `key: value` text. Split at the first `:` (naive `find(':')`, consistent with yamlpatch's own Replace implementation) into `key_part` (through the colon) and the rest. -3. **Detect inline comment** — Scan the first line of `value_part` for a trailing `# comment`. Heuristic: after the value content on the first line, find `\s+#`. Save the comment text if found. +3. **Compute indentation** — Derive base indent from the number of leading spaces on the feature's line. Value content indentation = base indent + 2 spaces. -4. **Compute indentation** — Derive base indent from the number of leading spaces on the feature's line. Value content indentation = base indent + 2 spaces. +4. **Serialize the new value** — `serde_yaml::to_string(&value)`, strip trailing newline, re-indent each line to the computed value indentation. -5. **Serialize the new value** — `serde_yaml::to_string(&value)`, strip trailing newline, re-indent each line to the computed value indentation. +5. **Assemble replacement text** — `key:\n indented_value`. Inline comments on the value line are not preserved, consistent with yamlpatch's own Replace behavior for scalar values. -6. **Assemble replacement text**: - - With comment: `key: # comment\n indented_value` - - Without comment: `key:\n indented_value` +6. **String surgery** — Replace the byte range from the feature's span (adjusted for leading whitespace) in the document source. -7. **String surgery** — Replace the byte range from the feature's span (adjusted for leading whitespace) in the document source. - -8. **Re-parse** — `yamlpath::Document::new(patched_source)` to validate. +7. **Re-parse** — `yamlpath::Document::new(patched_source)` to validate. ### Root-level replace @@ -64,17 +60,6 @@ Patches are applied sequentially. If a complex replace is encountered mid-batch, `Document.upsert()` delegates to `replace()` when the path exists, and to `_create_at()` (which uses `Op.add`/`Op.merge_into`) when it doesn't. Since `Op.add` and `Op.merge_into` already handle complex values, the only broken path is the `replace` delegation — fixed by this change. No changes to `document.py`. -## Comment Relocation - -When replacing a scalar that has an inline comment (e.g. `repos: [] # managed by tool`) with a multi-line block value: - -- The comment is preserved on the key line: `repos: # managed by tool` -- The block value starts on the next line, indented - -Detection heuristic: after stripping the YAML value from the first line of `value_part`, look for `\s+#` at the end. - -**Limitation:** Comments inside quoted strings that happen to contain `#` could be misdetected. This is unlikely for the scalar values being replaced and is documented as a known limitation. - ## Serialization Style No changes to `Op.add` behavior. Complex values in `Replace` use `serde_yaml::to_string()` which produces block style by default (block mappings, block sequences). This matches the pre-commit config use case where block style is expected. @@ -88,16 +73,19 @@ New tests in `tests/test_document.py`: - `replace()` with nested dict-in-list-in-dict - `upsert()` with a dict value (path exists → goes through replace) - `upsert()` with a dict value (path doesn't exist → goes through add, already works) -- Comment preservation when replacing scalar with complex value - Replacing a complex value with another complex value - Root-level replace with complex value - Indentation correctness at various nesting depths (0, 2, 4 spaces) +## Known Limitations + +- **Quoted keys with colons** — `find_key_colon` uses a naive `str::find(':')`, which will misparse keys like `"host:port": value`. This is consistent with yamlpatch's own Replace implementation. When yamlpatch fixes this upstream, yamltrip should inherit the fix. +- **Inline comments** — Not preserved during complex replace, consistent with yamlpatch's scalar Replace behavior. + ## Scope Boundaries **In scope:** - `Replace` with Mapping/Sequence values -- Inline comment relocation - Indentation handling **Out of scope:**