Skip to main content

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}