@@ -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
158395fn convert_feature ( feature : & yamlpath:: Feature < ' _ > ) -> PyFeature {
0 commit comments