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/// The input is held to the default [`ParserConfig`](crate::ParserConfig)
153/// limits a value parse would apply: at most `max_document_length`
154/// bytes, and a pending key may hold back no more tokens than
155/// `max_events` allows.
156///
157/// # Errors
158///
159/// Returns an error if the input is longer than the default
160/// `max_document_length`, if a pending key holds back more tokens than
161/// the default `max_events` allows, or if the input is not a lexically
162/// valid YAML document — e.g. an unclosed flow collection. Comments inside an
163/// otherwise-well-formed document are captured even if deeper parse
164/// stages (e.g. merge resolution) would later fail.
165///
166/// # Examples
167///
168/// ```
169/// use noyalib::{load_comments, CommentKind};
170/// let yaml = "name: noyalib # the library\n# trailing\n";
171/// let comments = load_comments(yaml).unwrap();
172/// assert_eq!(comments.len(), 2);
173/// assert_eq!(comments[0].kind, CommentKind::Inline);
174/// assert_eq!(comments[1].kind, CommentKind::Line);
175/// ```
176pub fn load_comments(input: &str) -> Result<Vec<Comment>> {
177 let limits = crate::ParserConfig::default();
178 if input.len() > limits.max_document_length {
179 return Err(Error::Parse(format!(
180 "document exceeds maximum length of {} bytes",
181 limits.max_document_length
182 )));
183 }
184 let mut parser = Parser::with_max_events(input, limits.max_events);
185 parser.enable_comment_capture();
186 // Drain events — we don't care about the tree, just need the
187 // scanner to walk the whole document so every comment gets
188 // captured.
189 loop {
190 match parser.next_event() {
191 Ok(ev) => {
192 if matches!(ev, crate::parser::Event::StreamEnd) {
193 break;
194 }
195 }
196 Err(e) => return Err(e.into_error(input, limits.max_events)),
197 }
198 }
199 Ok(parser.take_comments())
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn an_oversized_input_is_refused_like_a_value_parse() {
208 let limit = crate::ParserConfig::default().max_document_length;
209 let input = format!("# c\n{}", "a".repeat(limit));
210 let err = load_comments(&input).unwrap_err().to_string();
211 assert!(err.contains("maximum length"), "{err}");
212 }
213
214 #[test]
215 fn empty_document_has_no_comments() {
216 assert_eq!(load_comments("").unwrap(), vec![]);
217 assert_eq!(load_comments("k: 1\n").unwrap(), vec![]);
218 }
219
220 #[test]
221 fn single_line_comment() {
222 let cs = load_comments("# hello\n").unwrap();
223 assert_eq!(cs.len(), 1);
224 assert_eq!(cs[0].text, " hello");
225 assert_eq!(cs[0].kind, CommentKind::Line);
226 assert_eq!(cs[0].start, 0);
227 assert_eq!(cs[0].end, 7);
228 }
229
230 #[test]
231 fn inline_comment_classification() {
232 let cs = load_comments("k: 1 # trailing\n").unwrap();
233 assert_eq!(cs.len(), 1);
234 assert_eq!(cs[0].kind, CommentKind::Inline);
235 assert!(cs[0].text.contains("trailing"));
236 }
237
238 #[test]
239 fn mixed_leading_and_inline() {
240 let yaml = "# top\nk: 1 # tail\n# bottom\n";
241 let cs = load_comments(yaml).unwrap();
242 assert_eq!(cs.len(), 3);
243 assert_eq!(cs[0].kind, CommentKind::Line);
244 assert_eq!(cs[1].kind, CommentKind::Inline);
245 assert_eq!(cs[2].kind, CommentKind::Line);
246 }
247
248 #[test]
249 fn indented_line_comment_is_still_line() {
250 // A comment indented by spaces on an otherwise-empty line
251 // should classify as Line (no preceding content on this line).
252 let cs = load_comments("k:\n # indented\n v: 1\n").unwrap();
253 assert!(!cs.is_empty(), "expected a non-empty value");
254 assert_eq!(cs[0].kind, CommentKind::Line);
255 }
256
257 #[test]
258 fn source_order_preserved() {
259 let yaml = "# a\n# b\n# c\n";
260 let cs = load_comments(yaml).unwrap();
261 assert_eq!(cs.len(), 3);
262 assert!(cs[0].start < cs[1].start);
263 assert!(cs[1].start < cs[2].start);
264 }
265}