Skip to content

Commit 00313b8

Browse files
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
1 parent 8f32dd7 commit 00313b8

1 file changed

Lines changed: 248 additions & 11 deletions

File tree

src/document.rs

Lines changed: 248 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -139,20 +139,257 @@ impl PyDocument {
139139
/// Apply patches to this document and return a new document.
140140
/// NOTE: Similar patch-application logic exists in ops::apply_patches (returns String).
141141
fn apply_patches(&self, patches: Vec<PyPatch>) -> PyResult<Self> {
142-
let yaml_patches: Vec<yamlpatch::Patch<'_>> = patches
143-
.iter()
144-
.map(|p| yamlpatch::Patch {
145-
route: p.route.to_yamlpath_route(),
146-
operation: p.operation.inner.clone(),
147-
})
148-
.collect();
142+
let mut current_doc = self.inner.clone();
143+
let mut batch: Vec<usize> = Vec::new();
149144

150-
let result = yamlpatch::apply_yaml_patches(&self.inner, &yaml_patches).map_err(|e| {
151-
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("Patch failed: {e}"))
152-
})?;
145+
for (idx, patch) in patches.iter().enumerate() {
146+
let is_complex_replace = matches!(
147+
&patch.operation.inner,
148+
yamlpatch::Op::Replace(v) if matches!(v, serde_yaml::Value::Mapping(_) | serde_yaml::Value::Sequence(_))
149+
);
150+
151+
if is_complex_replace {
152+
// Flush any pending yamlpatch batch first
153+
if !batch.is_empty() {
154+
let yaml_patches: Vec<yamlpatch::Patch<'_>> = batch
155+
.iter()
156+
.map(|&i| yamlpatch::Patch {
157+
route: patches[i].route.to_yamlpath_route(),
158+
operation: patches[i].operation.inner.clone(),
159+
})
160+
.collect();
161+
current_doc = yamlpatch::apply_yaml_patches(&current_doc, &yaml_patches)
162+
.map_err(|e| {
163+
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!(
164+
"Patch failed: {e}"
165+
))
166+
})?;
167+
batch.clear();
168+
}
169+
170+
// Apply the complex replace directly
171+
let route = patch.route.to_yamlpath_route();
172+
let value = match &patch.operation.inner {
173+
yamlpatch::Op::Replace(v) => v,
174+
_ => unreachable!(),
175+
};
176+
current_doc =
177+
apply_complex_replace(&current_doc, &route, value).map_err(|e| {
178+
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!(
179+
"Patch failed: {e}"
180+
))
181+
})?;
182+
} else {
183+
batch.push(idx);
184+
}
185+
}
186+
187+
// Flush remaining batch
188+
if !batch.is_empty() {
189+
let yaml_patches: Vec<yamlpatch::Patch<'_>> = batch
190+
.iter()
191+
.map(|&i| yamlpatch::Patch {
192+
route: patches[i].route.to_yamlpath_route(),
193+
operation: patches[i].operation.inner.clone(),
194+
})
195+
.collect();
196+
current_doc = yamlpatch::apply_yaml_patches(&current_doc, &yaml_patches).map_err(
197+
|e| {
198+
PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!(
199+
"Patch failed: {e}"
200+
))
201+
},
202+
)?;
203+
}
204+
205+
Ok(Self { inner: current_doc })
206+
}
207+
}
208+
209+
fn apply_complex_replace(
210+
doc: &yamlpath::Document,
211+
route: &yamlpath::Route<'_>,
212+
value: &serde_yaml::Value,
213+
) -> Result<yamlpath::Document, String> {
214+
let source = doc.source();
215+
216+
// Root-level replace: just serialize the entire value
217+
if route.is_empty() {
218+
let serialized =
219+
serde_yaml::to_string(value).map_err(|e| format!("Failed to serialize YAML: {e}"))?;
220+
return yamlpath::Document::new(serialized)
221+
.map_err(|e| format!("Failed to re-parse YAML: {e}"));
222+
}
223+
224+
// Locate the feature (with key context)
225+
let feature = doc
226+
.query_pretty(route)
227+
.map_err(|e| format!("Query failed: {e}"))?;
228+
229+
let content_with_ws = doc.extract_with_leading_whitespace(&feature);
230+
let content = doc.extract(&feature);
231+
232+
// Calculate the start byte including leading whitespace
233+
let ws_len = content_with_ws.len() - content.len();
234+
let start_byte = feature.location.byte_span.0 - ws_len;
235+
let end_byte = feature.location.byte_span.1;
236+
237+
// Find the colon separating key from value
238+
let colon_pos = find_key_colon(content_with_ws);
239+
240+
let (key_part, value_first_line) = match colon_pos {
241+
Some(pos) => {
242+
let key = &content_with_ws[..pos + 1]; // through the colon
243+
let rest = &content_with_ws[pos + 1..];
244+
(key.to_string(), rest.to_string())
245+
}
246+
None => {
247+
// No colon found — bare value (e.g. sequence item)
248+
let serialized = serde_yaml::to_string(value)
249+
.map_err(|e| format!("Failed to serialize YAML: {e}"))?;
250+
let trimmed = serialized.trim_end_matches('\n');
251+
252+
let line_start = source[..feature.location.byte_span.0]
253+
.rfind('\n')
254+
.map(|nl| nl + 1)
255+
.unwrap_or(0);
256+
let base_indent = feature.location.byte_span.0 - line_start;
257+
let indent_str = " ".repeat(base_indent);
258+
259+
let indented = indent_block(trimmed, &indent_str);
260+
261+
let mut result = source.to_string();
262+
result.replace_range(
263+
feature.location.byte_span.0..feature.location.byte_span.1,
264+
&indented,
265+
);
266+
if !result.ends_with('\n') {
267+
result.push('\n');
268+
}
269+
return yamlpath::Document::new(result)
270+
.map_err(|e| format!("Failed to re-parse YAML: {e}"));
271+
}
272+
};
273+
274+
// Detect inline comment on the first line of the value part
275+
let first_line = value_first_line.lines().next().unwrap_or("");
276+
let comment = extract_inline_comment(first_line);
277+
278+
// Compute base indentation from the feature's actual position
279+
let feat_start = feature.location.byte_span.0;
280+
let line_start = source[..feat_start]
281+
.rfind('\n')
282+
.map(|nl| nl + 1)
283+
.unwrap_or(0);
284+
let base_indent = feat_start - line_start;
285+
let value_indent = " ".repeat(base_indent + 2);
286+
287+
// Serialize the new value in block style
288+
let serialized =
289+
serde_yaml::to_string(value).map_err(|e| format!("Failed to serialize YAML: {e}"))?;
290+
let trimmed = serialized.trim_end_matches('\n');
291+
292+
// Re-indent each line of the serialized value
293+
let indented_value = indent_block(trimmed, &value_indent);
294+
295+
// Assemble: key: [# comment]\n indented_value
296+
let replacement = match comment {
297+
Some(c) => format!("{} {}\n{}", key_part, c, indented_value),
298+
None => format!("{}\n{}", key_part, indented_value),
299+
};
153300

154-
Ok(Self { inner: result })
301+
// Replace in source
302+
let mut result = source.to_string();
303+
result.replace_range(start_byte..end_byte, &replacement);
304+
305+
if !result.ends_with('\n') {
306+
result.push('\n');
307+
}
308+
309+
yamlpath::Document::new(result).map_err(|e| format!("Failed to re-parse YAML: {e}"))
310+
}
311+
312+
fn find_key_colon(content: &str) -> Option<usize> {
313+
let bytes = content.as_bytes();
314+
let mut i = 0;
315+
while i < bytes.len() {
316+
match bytes[i] {
317+
b'\'' => {
318+
i += 1;
319+
while i < bytes.len() && bytes[i] != b'\'' {
320+
i += 1;
321+
}
322+
i += 1;
323+
}
324+
b'"' => {
325+
i += 1;
326+
while i < bytes.len() {
327+
if bytes[i] == b'\\' {
328+
i += 2;
329+
} else if bytes[i] == b'"' {
330+
break;
331+
} else {
332+
i += 1;
333+
}
334+
}
335+
i += 1;
336+
}
337+
b':' => {
338+
let next = bytes.get(i + 1);
339+
if matches!(next, Some(b' ') | Some(b'\n') | Some(b'\r') | None) {
340+
return Some(i);
341+
}
342+
i += 1;
343+
}
344+
_ => {
345+
i += 1;
346+
}
347+
}
348+
}
349+
None
350+
}
351+
352+
fn extract_inline_comment(line: &str) -> Option<&str> {
353+
let bytes = line.as_bytes();
354+
let mut i = 0;
355+
let mut in_single_quote = false;
356+
let mut in_double_quote = false;
357+
358+
while i < bytes.len() {
359+
match bytes[i] {
360+
b'\'' if !in_double_quote => {
361+
in_single_quote = !in_single_quote;
362+
}
363+
b'"' if !in_single_quote => {
364+
in_double_quote = !in_double_quote;
365+
}
366+
b'\\' if in_double_quote => {
367+
i += 1;
368+
}
369+
b'#' if !in_single_quote && !in_double_quote => {
370+
if i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
371+
return Some(&line[i..]);
372+
}
373+
}
374+
_ => {}
375+
}
376+
i += 1;
377+
}
378+
None
379+
}
380+
381+
fn indent_block(content: &str, indent: &str) -> String {
382+
let mut result = String::new();
383+
for (i, line) in content.lines().enumerate() {
384+
if i > 0 {
385+
result.push('\n');
386+
}
387+
if !line.trim().is_empty() {
388+
result.push_str(indent);
389+
result.push_str(line);
390+
}
155391
}
392+
result
156393
}
157394

158395
fn convert_feature(feature: &yamlpath::Feature<'_>) -> PyFeature {

0 commit comments

Comments
 (0)