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 rendered: String = text
.split('\n')
.map(|line| {
if line.is_empty() {
format!("{indent}#\n")
} else {
format!("{indent}# {line}\n")
}
})
.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: Document,
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
}
#[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");
}
}