use crate::comments::Comment;
use crate::cst::Document;
use crate::error::{Error, Result};
use crate::prelude::*;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommentBundle {
pub before: Vec<Comment>,
pub inline: Option<Comment>,
}
impl CommentBundle {
#[must_use]
pub fn is_empty(&self) -> bool {
self.before.is_empty() && self.inline.is_none()
}
}
impl Document {
#[must_use]
pub fn comments_at(&self, path: &str) -> CommentBundle {
let Some((start, end)) = self.span_at(path) else {
return CommentBundle::default();
};
let comments = match crate::comments::load_comments(self.source()) {
Ok(c) => c,
Err(_) => return CommentBundle::default(),
};
let src = self.source();
let line_start_idx = line_start(src, start);
let line_end_idx = line_end(src, end.saturating_sub(1).max(start));
let mut bundle = CommentBundle::default();
let is_single_line = !src[start..end].contains('\n');
if is_single_line {
for c in &comments {
if c.start >= end && c.start <= line_end_idx {
bundle.inline = Some(c.clone());
break;
}
}
}
let mut cursor = line_start_idx;
let mut acc: Vec<Comment> = Vec::new();
while cursor > 0 {
let prev_line_end = cursor - 1; let prev_line_start = line_start(src, prev_line_end.saturating_sub(1));
let line_text = &src[prev_line_start..prev_line_end];
let trimmed = line_text.trim_start_matches([' ', '\t']);
if trimmed.is_empty() {
cursor = prev_line_start;
continue;
}
if trimmed.starts_with('#') {
if let Some(c) = comments
.iter()
.find(|c| c.start >= prev_line_start && c.start < prev_line_end)
{
acc.push(c.clone());
}
cursor = prev_line_start;
continue;
}
break;
}
acc.reverse();
bundle.before = acc;
bundle
}
pub fn set_inline_comment(&mut self, path: &str, text: &str) -> Result<()> {
if text.contains('\n') {
return Err(Error::Parse(format!(
"set_inline_comment: comment text for `{path}` contains a newline; \
an inline comment is a single line"
)));
}
let Some((start, end)) = self.span_at(path) else {
return Err(Error::Parse(format!(
"set_inline_comment: path `{path}` did not resolve to a node"
)));
};
if self.source()[start..end].contains('\n') {
return Err(Error::Parse(format!(
"set_inline_comment: `{path}` is a multi-line node and has no inline \
comment of its own; comment its individual entries instead"
)));
}
let rendered = if text.is_empty() {
"#".to_string()
} else {
format!("# {text}")
};
let existing = self.comments_at(path).inline;
let snapshot = self.clone();
let expected = self.as_value().clone();
let splice = match existing {
Some(c) => self.replace_span(c.start, c.end, &rendered),
None => self.replace_span(end, end, &format!(" {rendered}")),
};
self.finish_comment_edit("set_inline_comment", path, splice, snapshot, &expected)
}
pub fn remove_inline_comment(&mut self, path: &str) -> Result<()> {
let Some((_start, end)) = self.span_at(path) else {
return Ok(());
};
let Some(c) = self.comments_at(path).inline else {
return Ok(());
};
let snapshot = self.clone();
let expected = self.as_value().clone();
let splice = self.replace_span(end, c.end, "");
self.finish_comment_edit("remove_inline_comment", path, splice, snapshot, &expected)
}
pub fn set_leading_comment(&mut self, path: &str, text: &str) -> Result<()> {
let (key_start, entry_line_start, indent) = self.leading_comment_site(path)?;
let _ = key_start;
let nl = comment_line_break(self.source());
let rendered: String = text
.split('\n')
.map(|line| {
if line.is_empty() {
format!("{indent}#{nl}")
} else {
format!("{indent}# {line}{nl}")
}
})
.collect();
let before = self.comments_at(path).before;
let snapshot = self.clone();
let expected = self.as_value().clone();
let splice = match (before.first(), before.last()) {
(Some(first), Some(last)) => {
let block_start = line_start(self.source(), first.start);
let block_end = line_end(self.source(), last.start) + 1;
self.replace_span(block_start, block_end, &rendered)
}
_ => self.replace_span(entry_line_start, entry_line_start, &rendered),
};
self.finish_comment_edit("set_leading_comment", path, splice, snapshot, &expected)
}
pub fn remove_leading_comment(&mut self, path: &str) -> Result<()> {
if self.leading_comment_site(path).is_err() {
return Ok(());
}
let before = self.comments_at(path).before;
let (Some(first), Some(last)) = (before.first(), before.last()) else {
return Ok(());
};
let block_start = line_start(self.source(), first.start);
let block_end = line_end(self.source(), last.start) + 1;
let snapshot = self.clone();
let expected = self.as_value().clone();
let splice = self.replace_span(block_start, block_end, "");
self.finish_comment_edit("remove_leading_comment", path, splice, snapshot, &expected)
}
fn leading_comment_site(&self, path: &str) -> Result<(usize, usize, String)> {
let Some((key_start, _key_end)) = self.key_span(path) else {
return Err(Error::Parse(format!(
"leading comment: `{path}` does not address a block-mapping key"
)));
};
let Some((vstart, vend)) = self.span_at(path) else {
return Err(Error::Parse(format!(
"leading comment: `{path}` did not resolve to a value"
)));
};
if self.source()[vstart..vend].contains('\n') {
return Err(Error::Parse(format!(
"leading comment: `{path}` is a multi-line entry; leading-comment \
mutation is limited to single-line mapping keys in this phase"
)));
}
let ls = line_start(self.source(), key_start);
let indent = self.source()[ls..key_start].to_string();
Ok((key_start, ls, indent))
}
fn finish_comment_edit(
&mut self,
op: &str,
path: &str,
splice: Result<()>,
snapshot: Self,
expected: &crate::Value,
) -> Result<()> {
if let Err(e) = splice {
*self = snapshot;
return Err(Error::Parse(format!(
"{op}: editing the comment on `{path}` could not be spliced ({e}); \
the document was left unchanged"
)));
}
if let Err(e) = self.validate() {
*self = snapshot;
return Err(Error::Parse(format!(
"{op}: editing the comment on `{path}` left the document unable to \
re-parse ({e}); the document was left unchanged"
)));
}
if *self.as_value() != *expected {
*self = snapshot;
return Err(Error::Parse(format!(
"{op}: editing the comment on `{path}` changed the document's data; \
the document was left unchanged"
)));
}
Ok(())
}
}
#[inline]
fn line_start(src: &str, byte: usize) -> usize {
let bytes = src.as_bytes();
let mut i = byte.min(bytes.len());
while i > 0 && bytes[i - 1] != b'\n' {
i -= 1;
}
i
}
#[inline]
fn line_end(src: &str, byte: usize) -> usize {
let bytes = src.as_bytes();
let mut i = byte.min(bytes.len().saturating_sub(1));
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
i
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommentPosition {
Inline,
Before,
}
impl Document {
pub fn set_comment(&mut self, path: &str, position: CommentPosition, text: &str) -> Result<()> {
let p = path.to_owned();
let txt = text.to_owned();
self.guarded_comment_edit("set_comment", move |d| {
d.set_comment_inner(&p, position, &txt)
})
}
fn set_comment_inner(
&mut self,
path: &str,
position: CommentPosition,
text: &str,
) -> Result<()> {
let Some((start, end)) = self.span_at(path) else {
return Err(Error::Parse(format!(
"set_comment: path `{path}` does not resolve"
)));
};
let bundle = self.comments_at(path);
match position {
CommentPosition::Inline => {
let body = normalise_body(text);
if let Some(existing) = bundle.inline {
self.replace_span(existing.start, existing.end, &format!("#{body}"))
} else {
let line_end = line_break_start(self.source(), end);
self.replace_span(line_end, line_end, &format!(" #{body}"))
}
}
CommentPosition::Before => {
let indent = indent_of_line_containing(self.source(), start);
let nl = comment_line_break(self.source());
let block: String = text
.split('\n')
.map(|l| format!("{indent}#{}{nl}", normalise_body(l)))
.collect();
if let (Some(first), Some(last)) = (bundle.before.first(), bundle.before.last()) {
let end_of_run = line_end_from(self.source(), last.end) + 1;
let start_of_run = line_start_from(self.source(), first.start);
self.replace_span(start_of_run, end_of_run.min(self.source().len()), &block)
} else {
let line_start = line_start_from(self.source(), start);
self.replace_span(line_start, line_start, &block)
}
}
}
}
pub fn remove_comment(&mut self, path: &str, position: CommentPosition) -> Result<()> {
let p = path.to_owned();
self.guarded_comment_edit("remove_comment", move |doc| {
doc.remove_comment_inner(&p, position)
})
}
fn remove_comment_inner(&mut self, path: &str, position: CommentPosition) -> Result<()> {
if self.span_at(path).is_none() {
return Err(Error::Parse(format!(
"remove_comment: path `{path}` does not resolve"
)));
}
let bundle = self.comments_at(path);
match position {
CommentPosition::Inline => {
let Some(c) = bundle.inline else {
return Ok(());
};
let from = trim_back_whitespace(self.source(), c.start);
self.replace_span(from, c.end, "")
}
CommentPosition::Before => {
let (Some(first), Some(last)) = (bundle.before.first(), bundle.before.last())
else {
return Ok(());
};
let start_of_run = line_start_from(self.source(), first.start);
let end_of_run =
(line_end_from(self.source(), last.end) + 1).min(self.source().len());
self.replace_span(start_of_run, end_of_run, "")
}
}
}
}
impl Document {
fn guarded_comment_edit<F>(&mut self, what: &str, edit: F) -> Result<()>
where
F: FnOnce(&mut Self) -> Result<()>,
{
let before = crate::from_str::<crate::Value>(self.source()).ok();
let snapshot = self.clone();
edit(self)?;
if let Some(before) = before {
let unchanged =
matches!(crate::from_str::<crate::Value>(self.source()), Ok(a) if a == before);
if !unchanged {
*self = snapshot;
return Err(Error::Parse(format!(
"{what}: the edit would change the document's value, not just its \
comments — it was left unchanged. This happens where a `#` is not \
a comment, such as inside a block scalar."
)));
}
}
Ok(())
}
}
fn normalise_body(text: &str) -> String {
if text.starts_with(char::is_whitespace) || text.is_empty() {
text.to_owned()
} else {
format!(" {text}")
}
}
fn line_end_from(src: &str, idx: usize) -> usize {
src[idx..].find('\n').map_or(src.len(), |n| idx + n)
}
fn line_break_start(src: &str, idx: usize) -> usize {
let end = line_end_from(src, idx);
if end > idx && src.as_bytes()[end - 1] == b'\r' {
end - 1
} else {
end
}
}
fn comment_line_break(src: &str) -> &'static str {
let bytes = src.as_bytes();
let mut saw = false;
for (i, &b) in bytes.iter().enumerate() {
if b == b'\n' {
if i == 0 || bytes[i - 1] != b'\r' {
return "\n";
}
saw = true;
}
}
if saw { "\r\n" } else { "\n" }
}
fn line_start_from(src: &str, idx: usize) -> usize {
src[..idx].rfind('\n').map_or(0, |n| n + 1)
}
fn trim_back_whitespace(src: &str, idx: usize) -> usize {
let bytes = src.as_bytes();
let mut i = idx;
while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
i -= 1;
}
i
}
fn indent_of_line_containing(src: &str, idx: usize) -> String {
let start = line_start_from(src, idx);
src[start..]
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cst::parse_document;
#[test]
fn inline_comment_on_simple_value() {
let doc = parse_document("port: 8080 # the listen port\n").unwrap();
let b = doc.comments_at("port");
assert!(b.before.is_empty());
assert_eq!(b.inline.as_ref().unwrap().text, " the listen port");
}
#[test]
fn leading_single_comment() {
let doc = parse_document("# pre\nkey: val\n").unwrap();
let b = doc.comments_at("key");
assert_eq!(b.before.len(), 1);
assert_eq!(b.before[0].text, " pre");
assert!(b.inline.is_none());
}
#[test]
fn leading_multi_with_blank_lines_preserves_run() {
let doc = parse_document(
"# first\n\
\n\
# second\n\
key: val\n",
)
.unwrap();
let b = doc.comments_at("key");
assert_eq!(b.before.len(), 2);
assert_eq!(b.before[0].text, " first");
assert_eq!(b.before[1].text, " second");
}
#[test]
fn content_line_breaks_leading_run() {
let doc = parse_document(
"name: noyalib\n\
# this comment belongs to version, not name\n\
version: 0.0.1\n",
)
.unwrap();
let name = doc.comments_at("name");
assert!(name.before.is_empty());
let version = doc.comments_at("version");
assert_eq!(version.before.len(), 1);
assert!(version.before[0].text.contains("belongs to version"));
}
#[test]
fn nested_path_inline_comment() {
let doc = parse_document(
"server:\n\
\x20 host: localhost # bind address\n\
\x20 port: 8080\n",
)
.unwrap();
let host = doc.comments_at("server.host");
assert_eq!(host.inline.as_ref().unwrap().text, " bind address");
let port = doc.comments_at("server.port");
assert!(port.inline.is_none());
}
#[test]
fn unknown_path_returns_empty_bundle() {
let doc = parse_document("a: 1\n").unwrap();
let b = doc.comments_at("nonexistent");
assert!(b.is_empty());
}
#[test]
fn comments_survive_lossless_edit() {
let mut doc = parse_document(
"# version is bumped by Renovate\n\
version: 0.0.1 # do not edit by hand\n",
)
.unwrap();
doc.set("version", "0.0.2").unwrap();
let b = doc.comments_at("version");
assert_eq!(b.before.len(), 1);
assert_eq!(b.before[0].text, " version is bumped by Renovate");
assert_eq!(b.inline.as_ref().unwrap().text, " do not edit by hand");
assert!(doc.to_string().contains("version: 0.0.2"));
assert!(doc.to_string().contains("# version is bumped by Renovate"));
assert!(doc.to_string().contains("# do not edit by hand"));
}
#[test]
fn multiline_block_does_not_inherit_child_inline() {
let doc =
parse_document("server:\n host: localhost\n port: 8080 # main HTTP port\n").unwrap();
let server = doc.comments_at("server");
assert!(
server.inline.is_none(),
"block must not inherit child inline"
);
let port = doc.comments_at("server.port");
assert_eq!(port.inline.as_ref().unwrap().text, " main HTTP port");
}
#[test]
fn sequence_item_inline_comment() {
let doc = parse_document(
"items:\n\
\x20 - one # the first\n\
\x20 - two # the second\n",
)
.unwrap();
let first = doc.comments_at("items[0]");
assert_eq!(first.inline.as_ref().unwrap().text, " the first");
let second = doc.comments_at("items[1]");
assert_eq!(second.inline.as_ref().unwrap().text, " the second");
}
}