pub mod blocks;
pub mod verify;
#[cfg(test)]
mod tests_anchor;
#[cfg(test)]
mod tests_blocks;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnchorError {
NotFound,
FlowStyle,
BlockScalar,
Alias,
Duplicate,
Multiline,
NotAMapping,
Unterminated,
}
impl AnchorError {
pub fn reason(self) -> &'static str {
match self {
Self::NotFound => {
"no such key in the document text — the value may come from an \
include, a recipe, or a {{template}} expansion"
}
Self::FlowStyle => {
"the node is written in flow style ({...} / [...]); editing it by \
byte range is not sound, so forjar refuses rather than guesses"
}
Self::BlockScalar => "the value is a block scalar (| or >)",
Self::Alias => "the value is a YAML anchor, alias or tag",
Self::Duplicate => "the key appears more than once at that path",
Self::Multiline => "the value spans more than one line",
Self::NotAMapping => "the path does not name a block mapping",
Self::Unterminated => "the last line of the region has no terminating newline",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScalarSpan {
pub line: usize,
pub byte_start: usize,
pub byte_end: usize,
}
pub(crate) struct Line<'a> {
pub(crate) text: &'a str,
pub(crate) start: usize,
pub(crate) next: usize,
pub(crate) indent: usize,
}
pub(crate) const TRANSPARENT: usize = usize::MAX;
pub(crate) fn scan_lines(text: &str) -> Vec<Line<'_>> {
let mut out = Vec::new();
let mut start = 0usize;
for raw in text.split_inclusive('\n') {
let body = raw.strip_suffix('\n').unwrap_or(raw);
let body = body.strip_suffix('\r').unwrap_or(body);
out.push(Line {
text: body,
start,
next: start + raw.len(),
indent: indent_of(body),
});
start += raw.len();
}
out
}
fn indent_of(body: &str) -> usize {
let n = body.len() - body.trim_start_matches(' ').len();
let rest = &body[n..];
if rest.is_empty() || rest.starts_with('#') || rest.starts_with('\t') {
TRANSPARENT
} else {
n
}
}
pub(crate) fn is_comment(line: &Line<'_>) -> bool {
line.text.trim_start().starts_with('#')
}
fn is_key_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '+' | '@')
}
fn parse_key(rest: &str) -> Option<(String, usize)> {
let (raw, end) = match rest.chars().next()? {
'"' => quoted_key(rest, '"')?,
'\'' => quoted_key(rest, '\'')?,
_ => {
let n = rest.find(|c| !is_key_char(c))?;
if n == 0 {
return None;
}
(rest[..n].to_string(), n)
}
};
let after = &rest[end..];
let tail = after.strip_prefix(':')?;
if tail.is_empty() || tail.starts_with(' ') {
Some((raw, end + 1))
} else {
None
}
}
fn quoted_key(rest: &str, quote: char) -> Option<(String, usize)> {
let close = rest[1..].find(quote)? + 1;
Some((rest[1..close].to_string(), close + 1))
}
pub(crate) fn key_at(line: &Line<'_>, indent: usize) -> Option<(String, usize)> {
if line.indent != indent {
return None;
}
let rest = &line.text[indent..];
if rest.starts_with('-') {
return None;
}
parse_key(rest).map(|(k, consumed)| (k, indent + consumed))
}
pub(crate) fn value_of<'a>(line: &Line<'a>, key_end: usize) -> (usize, &'a str) {
let rest = &line.text[key_end..];
let ws = rest.len() - rest.trim_start_matches(' ').len();
let val = rest[ws..].trim_end();
if val.starts_with('#') {
return (line.start + line.text.len(), "");
}
(line.start + key_end + ws, val)
}
pub(crate) fn classify_inline(val: &str) -> AnchorError {
classify_prefix(val).unwrap_or(AnchorError::NotAMapping)
}
fn classify_prefix(val: &str) -> Option<AnchorError> {
match val.chars().next()? {
'{' | '[' => Some(AnchorError::FlowStyle),
'|' | '>' => Some(AnchorError::BlockScalar),
'&' | '*' | '!' => Some(AnchorError::Alias),
_ => None,
}
}
pub(crate) struct Located {
pub(crate) line: usize,
pub(crate) indent: usize,
pub(crate) range_end: usize,
pub(crate) key_end: usize,
}
pub(crate) fn locate_key(lines: &[Line<'_>], path: &[&str]) -> Result<Located, AnchorError> {
if path.is_empty() {
return Err(AnchorError::NotFound);
}
let (mut lo, mut hi) = (0usize, lines.len());
let mut indent = first_key_indent(lines, lo, hi).ok_or(AnchorError::NotAMapping)?;
for (i, seg) in path.iter().enumerate() {
let (idx, key_end) = find_key(lines, lo, hi, indent, seg)?;
if i + 1 == path.len() {
return Ok(Located {
line: idx,
indent,
range_end: hi,
key_end,
});
}
let (_, val) = value_of(&lines[idx], key_end);
if !val.is_empty() {
return Err(classify_inline(val));
}
let (clo, chi) = child_range(lines, idx, indent, hi);
indent = first_key_indent(lines, clo, chi).ok_or(AnchorError::NotAMapping)?;
lo = clo;
hi = chi;
}
Err(AnchorError::NotFound)
}
fn find_key(
lines: &[Line<'_>],
lo: usize,
hi: usize,
indent: usize,
key: &str,
) -> Result<(usize, usize), AnchorError> {
let mut found: Option<(usize, usize)> = None;
for (j, line) in lines.iter().enumerate().take(hi).skip(lo) {
if let Some((k, key_end)) = key_at(line, indent) {
if k == key {
if found.is_some() {
return Err(AnchorError::Duplicate);
}
found = Some((j, key_end));
}
}
}
found.ok_or(AnchorError::NotFound)
}
pub(crate) fn child_range(
lines: &[Line<'_>],
idx: usize,
indent: usize,
hi: usize,
) -> (usize, usize) {
let lo = (idx + 1).min(hi);
let mut end = lo;
for (j, line) in lines.iter().enumerate().take(hi).skip(lo) {
if line.indent != TRANSPARENT && line.indent <= indent {
break;
}
end = j + 1;
}
(lo, end)
}
pub(crate) fn first_key_indent(lines: &[Line<'_>], lo: usize, hi: usize) -> Option<usize> {
for line in lines.iter().take(hi).skip(lo) {
if line.indent == TRANSPARENT {
continue;
}
return parse_key(&line.text[line.indent..]).map(|_| line.indent);
}
None
}
pub fn find_scalar(text: &str, path: &[&str]) -> Result<ScalarSpan, AnchorError> {
let lines = scan_lines(text);
let at = locate_key(&lines, path)?;
let (vs, val) = value_of(&lines[at.line], at.key_end);
if val.is_empty() {
return Err(AnchorError::NotFound);
}
if let Some(e) = classify_prefix(val) {
return Err(e);
}
if next_line_is_deeper(&lines, at.line, at.indent) {
return Err(AnchorError::Multiline);
}
let len = scalar_len(val)?;
Ok(ScalarSpan {
line: at.line + 1,
byte_start: vs,
byte_end: vs + len,
})
}
fn next_line_is_deeper(lines: &[Line<'_>], idx: usize, indent: usize) -> bool {
lines
.get(idx + 1)
.is_some_and(|n| n.indent != TRANSPARENT && n.indent > indent)
}
fn scalar_len(val: &str) -> Result<usize, AnchorError> {
match val.chars().next() {
Some('"') => quoted_len(val, '"', true),
Some('\'') => quoted_len(val, '\'', false),
_ => Ok(val.find(" #").unwrap_or(val.len())),
}
.map(|n| val[..n].trim_end().len())
}
fn quoted_len(val: &str, quote: char, escapes: bool) -> Result<usize, AnchorError> {
let bytes = val.as_bytes();
let mut i = 1;
while i < bytes.len() {
let c = bytes[i] as char;
if escapes && c == '\\' {
i += 2;
continue;
}
if c == quote {
return Ok(i + 1);
}
i += 1;
}
Err(AnchorError::Multiline)
}
pub fn scalar_text<'a>(text: &'a str, span: &ScalarSpan) -> &'a str {
&text[span.byte_start..span.byte_end]
}
pub fn unquote(s: &str) -> String {
for q in ['"', '\''] {
if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
return s[1..s.len() - 1].to_string();
}
}
s.to_string()
}
pub fn splice(text: &str, span: &ScalarSpan, new_value: &str) -> String {
let mut out = String::with_capacity(text.len() + new_value.len());
out.push_str(&text[..span.byte_start]);
out.push_str(new_value);
out.push_str(&text[span.byte_end..]);
out
}
pub fn emit_scalar(value: &str) -> Result<String, AnchorError> {
let rendered = serde_yaml_ng::to_string(&serde_yaml_ng::Value::String(value.to_string()))
.map_err(|_| AnchorError::Multiline)?;
let one_line = rendered.trim_end_matches('\n');
if one_line.contains('\n') {
return Err(AnchorError::Multiline);
}
Ok(one_line.to_string())
}