use crate::comments::Comment;
use crate::cst::Document;
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
}
}
#[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");
}
}