Skip to content

Commit 73b5ec4

Browse files
Merge remote-tracking branch 'origin/main' into 44-bug-unsafe-byte-indexing-in-parse_value-can-panic-instead-of-returning-error
# Conflicts: # src/document.rs
2 parents 74cdff7 + 9bcf237 commit 73b5ec4

2 files changed

Lines changed: 55 additions & 40 deletions

File tree

src/document.rs

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -378,39 +378,52 @@ fn apply_complex_replace(
378378
let start_byte = feature.location.byte_span.0 - ws_len;
379379
let end_byte = feature.location.byte_span.1;
380380

381-
// Find the colon separating key from value
382-
let colon_pos = find_key_colon(content_with_ws);
381+
// Use query_exact to locate the value's byte span independently.
382+
// This avoids string-searching for the colon separator, which breaks
383+
// on quoted keys containing colons (e.g. "http://example.com": 8080).
384+
let value_feature = doc
385+
.query_exact(route)
386+
.map_err(|e| format!("Query failed: {e}"))?;
383387

384-
let key_part = match colon_pos {
385-
Some(pos) => {
386-
let key = &content_with_ws[..pos + 1]; // through the colon
387-
key.to_string()
388+
let key_part = match value_feature {
389+
Some(vf) => {
390+
let vf_start = vf.location.byte_span.0;
391+
// Note: no reversed-span check needed; tree-sitter nodes guarantee start <= end.
392+
if vf_start > source.len() || !source.is_char_boundary(vf_start) {
393+
return Err("Value feature span is not valid in source".to_string());
394+
}
395+
let prefix = source[start_byte..vf_start].trim_end();
396+
if prefix.is_empty() {
397+
// Bare value (e.g. sequence item) — no key prefix
398+
let serialized = serde_yaml::to_string(value)
399+
.map_err(|e| format!("Failed to serialize YAML: {e}"))?;
400+
let trimmed = serialized.trim_end_matches('\n');
401+
402+
let line_start = source[..feature.location.byte_span.0]
403+
.rfind('\n')
404+
.map(|nl| nl + 1)
405+
.unwrap_or(0);
406+
let base_indent = feature.location.byte_span.0 - line_start;
407+
let indent_str = " ".repeat(base_indent);
408+
409+
let indented = indent_block(trimmed, &indent_str);
410+
411+
let mut result = source.to_string();
412+
result.replace_range(
413+
feature.location.byte_span.0..feature.location.byte_span.1,
414+
&indented,
415+
);
416+
if !result.ends_with('\n') {
417+
result.push('\n');
418+
}
419+
return yamlpath::Document::new(result)
420+
.map_err(|e| format!("Failed to re-parse YAML: {e}"));
421+
}
422+
prefix.to_string()
388423
}
389424
None => {
390-
// No colon found — bare value (e.g. sequence item)
391-
let serialized = serde_yaml::to_string(value)
392-
.map_err(|e| format!("Failed to serialize YAML: {e}"))?;
393-
let trimmed = serialized.trim_end_matches('\n');
394-
395-
let line_start = source[..feature.location.byte_span.0]
396-
.rfind('\n')
397-
.map(|nl| nl + 1)
398-
.unwrap_or(0);
399-
let base_indent = feature.location.byte_span.0 - line_start;
400-
let indent_str = " ".repeat(base_indent);
401-
402-
let indented = indent_block(trimmed, &indent_str);
403-
404-
let mut result = source.to_string();
405-
result.replace_range(
406-
feature.location.byte_span.0..feature.location.byte_span.1,
407-
&indented,
408-
);
409-
if !result.ends_with('\n') {
410-
result.push('\n');
411-
}
412-
return yamlpath::Document::new(result)
413-
.map_err(|e| format!("Failed to re-parse YAML: {e}"));
425+
// Absent value (e.g. `key:\n`) — content is just key+colon
426+
content_with_ws.trim_end().to_string()
414427
}
415428
};
416429

@@ -449,16 +462,6 @@ fn apply_complex_replace(
449462
yamlpath::Document::new(result).map_err(|e| format!("Failed to re-parse YAML: {e}"))
450463
}
451464

452-
/// Find the first colon (key-value separator) in a YAML fragment.
453-
///
454-
/// Uses a naive `find(':')`, consistent with yamlpatch's own Replace
455-
/// implementation. This means colons inside quoted keys will be
456-
/// misidentified — a known yamlpatch limitation that will be fixed
457-
/// uniformly when yamlpatch addresses it.
458-
fn find_key_colon(content: &str) -> Option<usize> {
459-
content.find(':')
460-
}
461-
462465
fn indent_block(content: &str, indent: &str) -> String {
463466
let mut result = String::new();
464467
for (i, line) in content.lines().enumerate() {

tests/test_document.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,18 @@ def test_replace_flow_mapping_with_dict(self):
349349
doc2 = doc.replace("config", value={"x": 10})
350350
assert doc2["config"] == {"x": 10}
351351

352+
def test_replace_quoted_key_with_colon_complex_value(self):
353+
"""Regression: colon inside quoted key must not corrupt complex replace."""
354+
doc = Document('"http://example.com": 8080\n')
355+
doc2 = doc.replace("http://example.com", value={"port": 9090})
356+
assert doc2["http://example.com"] == {"port": 9090}
357+
358+
def test_replace_quoted_key_with_multiple_colons_complex_value(self):
359+
"""Regression: multiple colons inside quoted key must not corrupt complex replace."""
360+
doc = Document('"a:b:c": val\n')
361+
doc2 = doc.replace("a:b:c", value={"x": 1})
362+
assert doc2["a:b:c"] == {"x": 1}
363+
352364
def test_replace_key_with_hash_in_value(self):
353365
doc = Document("color: '#ff0000'\n")
354366
doc2 = doc.replace("color", value={"r": 255, "g": 0, "b": 0})

0 commit comments

Comments
 (0)