pub(in crate::event_iter) fn is_implicit_mapping_line(trimmed: &str) -> bool {
find_value_indicator_offset(trimmed).is_some()
}
pub(in crate::event_iter) fn is_tab_indented_block_indicator(s: &str) -> bool {
s.strip_prefix(['-', '?']).map_or_else(
|| is_implicit_mapping_line(s),
|after| after.is_empty() || after.starts_with([' ', '\t']),
)
}
pub(in crate::event_iter) fn inline_contains_mapping_key(inline: &str) -> bool {
if find_value_indicator_offset(inline).is_some() {
return true;
}
let mut s = inline;
loop {
let trimmed = s.trim_start_matches([' ', '\t']);
if let Some(after_amp) = trimmed.strip_prefix('&') {
let name_end = after_amp.find([' ', '\t']).unwrap_or(after_amp.len());
s = &after_amp[name_end..];
} else if trimmed.starts_with('!') {
let tag_end = trimmed.find([' ', '\t']).unwrap_or(trimmed.len());
s = &trimmed[tag_end..];
} else {
break;
}
if find_value_indicator_offset(s.trim_start_matches([' ', '\t'])).is_some() {
return true;
}
}
false
}
pub(in crate::event_iter) fn find_value_indicator_offset(trimmed: &str) -> Option<usize> {
if matches!(
trimmed.as_bytes().first().copied(),
Some(
b'\t'
| b'%'
| b'@'
| b'`'
| b','
| b'['
| b']'
| b'{'
| b'}'
| b'#'
| b'&'
| b'*'
| b'!'
| b'|'
| b'>'
)
) {
return None;
}
let bytes = trimmed.as_bytes();
let mut pos = 0;
if bytes.first().copied() == Some(b'"') {
pos = 1; while let Some(&inner) = bytes.get(pos) {
match inner {
b'\\' => pos += 2, b'"' => {
pos += 1; break;
}
_ => pos += 1,
}
}
}
else if bytes.first().copied() == Some(b'\'') {
pos = 1; while let Some(&inner) = bytes.get(pos) {
pos += 1;
if inner == b'\'' {
if bytes.get(pos).copied() == Some(b'\'') {
pos += 1; } else {
break; }
}
}
}
let mut prev_was_space = false;
while pos < bytes.len() {
let rel = memchr::memchr2(b':', b'#', bytes.get(pos..).unwrap_or_default())?;
let hit = pos + rel;
if rel > 0 {
let last = bytes.get(hit - 1).copied().unwrap_or(0);
prev_was_space = last == b' ' || last == b'\t';
}
let Some(&b) = bytes.get(hit) else { break };
if b == b'#' {
if hit == 0 || prev_was_space {
return None;
}
prev_was_space = false;
pos = hit + 1;
} else {
match bytes.get(hit + 1).copied() {
None | Some(b' ' | b'\t' | b'\n' | b'\r') => return Some(hit),
_ => {
prev_was_space = false;
pos = hit + 1;
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::{
find_value_indicator_offset, inline_contains_mapping_key, is_implicit_mapping_line,
};
use rstest::rstest;
#[test]
fn find_value_indicator_agrees_with_is_implicit_mapping_line() {
let accepted = [
"key:",
"key: value",
"key:\t",
"key: multiple spaces",
"\"quoted key\": val",
"'single quoted': val",
"key with spaces: val",
"k:",
"longer-key-with-dashes: v",
"unicode_\u{00e9}: v",
];
for line in accepted {
assert!(
is_implicit_mapping_line(line),
"expected is_implicit_mapping_line to accept: {line:?}"
);
assert!(
find_value_indicator_offset(line).is_some(),
"find_value_indicator_offset must return Some for accepted line: {line:?}"
);
}
let rejected = [
"plain scalar",
"http://example.com",
"no colon here",
"# comment: not a key",
"",
];
for line in rejected {
assert!(
!is_implicit_mapping_line(line),
"expected is_implicit_mapping_line to reject: {line:?}"
);
assert!(
find_value_indicator_offset(line).is_none(),
"find_value_indicator_offset must return None for rejected line: {line:?}"
);
}
}
#[rstest]
#[case::trailing_colon("key:", 3)]
#[case::colon_space("key: value", 3)]
#[case::colon_tab("key:\tv", 3)]
#[case::single_char_key("k:", 1)]
#[case::key_with_spaces("key with spaces: v", 15)]
#[case::dashes_in_key("a-b-c: v", 5)]
#[case::unicode_before_colon("é: v", 2)] #[case::colon_at_start_of_value("a: b: c", 1)] fn find_value_indicator_offset_returns_correct_byte_offset(
#[case] input: &str,
#[case] expected_offset: usize,
) {
assert_eq!(find_value_indicator_offset(input), Some(expected_offset));
}
#[rstest]
#[case::plain_scalar("plain scalar")]
#[case::url("http://example.com")]
#[case::colon_in_middle_of_word("abc:def")]
#[case::comment_at_start("# comment: not a key")]
#[case::comment_after_space("text # comment: x")]
#[case::empty("")]
#[case::starts_with_tab("\tkey: v")]
#[case::starts_with_percent("%TAG")]
#[case::starts_with_at("@node")]
#[case::starts_with_backtick("`raw`")]
#[case::starts_with_comma(",")]
#[case::starts_with_open_bracket("[a: b]")]
#[case::starts_with_close_bracket("]")]
#[case::starts_with_open_brace("{a: b}")]
#[case::starts_with_close_brace("}")]
#[case::starts_with_hash_indicator("#")]
#[case::starts_with_ampersand("&anchor")]
#[case::starts_with_asterisk("*alias")]
#[case::starts_with_bang("!tag")]
#[case::starts_with_pipe("|")]
#[case::starts_with_gt(">")]
fn find_value_indicator_offset_rejects(#[case] input: &str) {
assert!(find_value_indicator_offset(input).is_none());
}
#[rstest]
#[case::double_quoted_key_colon_inside("\"ke:y\": val", 6)]
#[case::single_quoted_key_colon_inside("'ke:y': val", 6)]
#[case::double_quoted_key_escaped_quote("\"ke\\\"y\": val", 7)]
#[case::single_quoted_key_escaped_quote("'ke''y': val", 7)]
#[case::double_quote_not_at_start("a\"b\": val", 4)]
fn find_value_indicator_offset_skips_quoted_colons(
#[case] input: &str,
#[case] expected_offset: usize,
) {
assert_eq!(find_value_indicator_offset(input), Some(expected_offset));
}
#[rstest]
#[case::two_byte_char("é:", 2)] #[case::three_byte_char("中:", 3)] #[case::four_byte_char("\u{1F600}:", 4)] #[case::mixed_multibyte("é中\u{1F600}:", 9)] fn find_value_indicator_offset_multibyte_utf8(
#[case] input: &str,
#[case] expected_offset: usize,
) {
assert_eq!(find_value_indicator_offset(input), Some(expected_offset));
}
#[test]
fn is_implicit_mapping_line_agrees_with_find_value_indicator_offset() {
let accepted = [
"é:",
"中:",
"\u{1F600}:",
"\"ke:y\": val",
"'ke:y': val",
"a\"b\": v", ];
for line in accepted {
assert!(
is_implicit_mapping_line(line),
"expected is_implicit_mapping_line to accept: {line:?}"
);
assert!(
find_value_indicator_offset(line).is_some(),
"find_value_indicator_offset must return Some for: {line:?}"
);
}
let rejected = ["http://example.com", "\tkey: v"];
for line in rejected {
assert!(
!is_implicit_mapping_line(line),
"expected is_implicit_mapping_line to reject: {line:?}"
);
assert!(
find_value_indicator_offset(line).is_none(),
"find_value_indicator_offset must return None for: {line:?}"
);
}
}
#[rstest]
#[case::no_anchor_plain_key("key: v", true)]
#[case::anchor_before_key("&a key: v", true)]
#[case::tag_before_key("!str key: v", true)]
#[case::anchor_and_tag("&a !str key: v", true)]
#[case::anchor_no_key("&a plain", false)]
#[case::no_mapping_at_all("plain scalar", false)]
fn inline_contains_mapping_key_with_anchors_and_tags(
#[case] input: &str,
#[case] expected: bool,
) {
assert_eq!(inline_contains_mapping_key(input), expected);
}
}