noyalib/comments.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Comment capture on the parse path.
5//!
6//! YAML comments are outside the data model — the standard parser
7//! drops them silently. This module exposes a *capture* API so tools
8//! that need to preserve human-authored context (migration scripts,
9//! config linters, documentation generators) can read comments out
10//! of a source document alongside the parsed `Value`.
11//!
12//! # Scope
13//!
14//! This is the MVP: capture-only. The functions here return a
15//! read-only `Vec<Comment>` with source spans and a crude classifier
16//! ([`CommentKind::Line`] vs [`CommentKind::Inline`]) you can cross-
17//! reference against [`Spanned<T>`](crate::Spanned) to associate
18//! comments with specific fields.
19//!
20//! # Not (yet) in scope
21//!
22//! Emit-time round-tripping — i.e. parsing a YAML document with
23//! comments into `Value`, then re-serialising it with the comments
24//! back in the right places — is a separate, larger undertaking and
25//! is tracked as a follow-up. The building blocks are here: the
26//! scanner now preserves comment spans, so a future commit can layer
27//! an AST side-table on top without re-plumbing the parser.
28//!
29//! # Examples
30//!
31//! ```
32//! use noyalib::{load_comments, CommentKind};
33//!
34//! let yaml = "# top-of-file header\nname: noyalib # inline\nversion: 0.0.1\n";
35//! let comments = load_comments(yaml).unwrap();
36//!
37//! assert_eq!(comments.len(), 2);
38//! assert_eq!(comments[0].kind, CommentKind::Line);
39//! assert!(comments[0].text.contains("top-of-file"));
40//! assert_eq!(comments[1].kind, CommentKind::Inline);
41//! assert!(comments[1].text.contains("inline"));
42//! ```
43
44use crate::error::{Error, Result};
45use crate::parser::Parser;
46use crate::prelude::*;
47
48/// Classification of a scanned comment.
49///
50/// # Examples
51///
52/// ```
53/// use noyalib::CommentKind;
54/// let k = CommentKind::Inline;
55/// assert_eq!(k, CommentKind::Inline);
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum CommentKind {
59 /// A standalone comment occupying its own line (possibly indented).
60 ///
61 /// # Examples
62 ///
63 /// ```
64 /// use noyalib::CommentKind;
65 /// let _ = CommentKind::Line;
66 /// ```
67 Line,
68
69 /// A comment trailing content on the same line, e.g.
70 /// `key: value # note`.
71 ///
72 /// # Examples
73 ///
74 /// ```
75 /// use noyalib::CommentKind;
76 /// let _ = CommentKind::Inline;
77 /// ```
78 Inline,
79}
80
81/// A scanned YAML comment with its source span and classification.
82///
83/// `start` and `end` are byte offsets into the original input. The
84/// range points at the `#` through the character before the line
85/// break (or end of input).
86///
87/// # Examples
88///
89/// ```
90/// use noyalib::load_comments;
91/// let comments = load_comments("# hi\n").unwrap();
92/// assert_eq!(comments[0].text, " hi");
93/// assert_eq!(comments[0].start, 0);
94/// ```
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Comment {
97 /// Text of the comment excluding the leading `#`.
98 ///
99 /// Leading whitespace between the `#` and the comment body is
100 /// preserved so callers can reconstruct exact formatting.
101 ///
102 /// # Examples
103 ///
104 /// ```
105 /// use noyalib::load_comments;
106 /// let c = &load_comments("# hello\n").unwrap()[0];
107 /// assert_eq!(c.text, " hello");
108 /// ```
109 pub text: String,
110
111 /// Byte offset of the leading `#` in the source.
112 ///
113 /// # Examples
114 ///
115 /// ```
116 /// use noyalib::load_comments;
117 /// let c = &load_comments("k: 1 # x\n").unwrap()[0];
118 /// assert_eq!(c.start, 6);
119 /// ```
120 pub start: usize,
121
122 /// Byte offset one past the last byte of the comment text.
123 ///
124 /// # Examples
125 ///
126 /// ```
127 /// use noyalib::load_comments;
128 /// let c = &load_comments("# x\n").unwrap()[0];
129 /// assert_eq!(c.end, 3);
130 /// ```
131 pub end: usize,
132
133 /// Classification: line-level or inline-trailing.
134 ///
135 /// # Examples
136 ///
137 /// ```
138 /// use noyalib::{load_comments, CommentKind};
139 /// let cs = load_comments("# hi\nk: 1 # inl\n").unwrap();
140 /// assert_eq!(cs[0].kind, CommentKind::Line);
141 /// assert_eq!(cs[1].kind, CommentKind::Inline);
142 /// ```
143 pub kind: CommentKind,
144}
145
146/// Scan a YAML document and return the list of comments it contains.
147///
148/// The source is driven through the event parser so the returned
149/// positions stay consistent with the spans on [`Spanned<T>`](crate::Spanned).
150/// Comments are returned in source order.
151///
152/// # Errors
153///
154/// Returns an error if the input is not a lexically valid YAML
155/// document — e.g. an unclosed flow collection. Comments inside an
156/// otherwise-well-formed document are captured even if deeper parse
157/// stages (e.g. merge resolution) would later fail.
158///
159/// # Examples
160///
161/// ```
162/// use noyalib::{load_comments, CommentKind};
163/// let yaml = "name: noyalib # the library\n# trailing\n";
164/// let comments = load_comments(yaml).unwrap();
165/// assert_eq!(comments.len(), 2);
166/// assert_eq!(comments[0].kind, CommentKind::Inline);
167/// assert_eq!(comments[1].kind, CommentKind::Line);
168/// ```
169pub fn load_comments(input: &str) -> Result<Vec<Comment>> {
170 let mut parser = Parser::new(input);
171 // Drain events — we don't care about the tree, just need the
172 // scanner to walk the whole document so every comment gets
173 // captured.
174 loop {
175 match parser.next_event() {
176 Ok(ev) => {
177 if matches!(ev, crate::parser::Event::StreamEnd) {
178 break;
179 }
180 }
181 Err(e) => {
182 return Err(Error::parse_at(&*e.message, input, e.index));
183 }
184 }
185 }
186 Ok(parser.take_comments())
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn empty_document_has_no_comments() {
195 assert_eq!(load_comments("").unwrap(), vec![]);
196 assert_eq!(load_comments("k: 1\n").unwrap(), vec![]);
197 }
198
199 #[test]
200 fn single_line_comment() {
201 let cs = load_comments("# hello\n").unwrap();
202 assert_eq!(cs.len(), 1);
203 assert_eq!(cs[0].text, " hello");
204 assert_eq!(cs[0].kind, CommentKind::Line);
205 assert_eq!(cs[0].start, 0);
206 assert_eq!(cs[0].end, 7);
207 }
208
209 #[test]
210 fn inline_comment_classification() {
211 let cs = load_comments("k: 1 # trailing\n").unwrap();
212 assert_eq!(cs.len(), 1);
213 assert_eq!(cs[0].kind, CommentKind::Inline);
214 assert!(cs[0].text.contains("trailing"));
215 }
216
217 #[test]
218 fn mixed_leading_and_inline() {
219 let yaml = "# top\nk: 1 # tail\n# bottom\n";
220 let cs = load_comments(yaml).unwrap();
221 assert_eq!(cs.len(), 3);
222 assert_eq!(cs[0].kind, CommentKind::Line);
223 assert_eq!(cs[1].kind, CommentKind::Inline);
224 assert_eq!(cs[2].kind, CommentKind::Line);
225 }
226
227 #[test]
228 fn indented_line_comment_is_still_line() {
229 // A comment indented by spaces on an otherwise-empty line
230 // should classify as Line (no preceding content on this line).
231 let cs = load_comments("k:\n # indented\n v: 1\n").unwrap();
232 assert!(!cs.is_empty());
233 assert_eq!(cs[0].kind, CommentKind::Line);
234 }
235
236 #[test]
237 fn source_order_preserved() {
238 let yaml = "# a\n# b\n# c\n";
239 let cs = load_comments(yaml).unwrap();
240 assert_eq!(cs.len(), 3);
241 assert!(cs[0].start < cs[1].start);
242 assert!(cs[1].start < cs[2].start);
243 }
244}