Skip to main content

yaml_edit/nodes/
comment.rs

1//! Comment trivia.
2
3use super::{fresh_token, SyntaxNode, SyntaxToken};
4use crate::lex::SyntaxKind;
5
6/// A single comment token in a YAML document.
7///
8/// Comments are trivia: they are preserved in the syntax tree but do not affect
9/// the parsed value. Use [`YamlFile::comments`](crate::YamlFile::comments) or
10/// [`Document::comments`](crate::Document::comments) to iterate over them.
11#[derive(Clone, PartialEq, Eq, Hash)]
12pub struct Comment(pub(crate) SyntaxToken);
13
14impl Comment {
15    /// Wrap a syntax token as a comment, if it is a `COMMENT` token.
16    pub(crate) fn cast(token: SyntaxToken) -> Option<Self> {
17        (token.kind() == SyntaxKind::COMMENT).then_some(Comment(token))
18    }
19
20    /// The full comment text, including the leading `#`.
21    ///
22    /// For `# hello` this returns `"# hello"`.
23    pub fn text(&self) -> &str {
24        self.0.text()
25    }
26
27    /// The comment content with the leading `#` and a single optional space removed.
28    ///
29    /// For `# hello` this returns `"hello"`; for `#hello` it returns `"hello"`.
30    /// Trailing whitespace is left intact.
31    pub fn content(&self) -> &str {
32        let text = self.0.text();
33        let without_hash = text.strip_prefix('#').unwrap_or(text);
34        without_hash.strip_prefix(' ').unwrap_or(without_hash)
35    }
36
37    crate::nodes::ast_node_spans!("comment");
38
39    /// Get the text range of this comment as a rowan [`TextRange`](rowan::TextRange).
40    ///
41    /// The range spans the full comment text, including the leading `#`.
42    pub fn text_range(&self) -> rowan::TextRange {
43        self.0.text_range()
44    }
45
46    /// Replace the full comment text, including the leading `#`.
47    ///
48    /// The replacement must be a single valid comment token starting with `#`
49    /// and containing no newline; otherwise this panics, since writing it back
50    /// would corrupt the tree.
51    ///
52    /// # Example
53    /// ```
54    /// use yaml_edit::Document;
55    /// use std::str::FromStr;
56    ///
57    /// let doc = Document::from_str("key: value # teh value\n").unwrap();
58    /// let comment = doc.comments().next().unwrap();
59    /// comment.set_text("# the value");
60    /// assert_eq!(doc.to_string(), "key: value # the value\n");
61    /// ```
62    pub fn set_text(&self, text: &str) {
63        assert!(
64            text.starts_with('#'),
65            "comment text must start with '#', got {text:?}"
66        );
67        assert!(
68            !text.contains('\n') && !text.contains('\r'),
69            "comment text must not contain a newline, got {text:?}"
70        );
71        let parent = self
72            .0
73            .parent()
74            .expect("a comment token always has a parent node");
75        let index = self.0.index();
76        let new_token = fresh_token(SyntaxKind::COMMENT, text);
77        parent.splice_children(index..index + 1, vec![new_token.into()]);
78    }
79
80    /// Replace the comment content, preserving the leading `# ` marker.
81    ///
82    /// This is the counterpart to [`content`](Self::content): it sets the text
83    /// after the `#` and a single space. Use [`set_text`](Self::set_text) when
84    /// you need full control over the marker and spacing.
85    ///
86    /// # Example
87    /// ```
88    /// use yaml_edit::Document;
89    /// use std::str::FromStr;
90    ///
91    /// let doc = Document::from_str("key: value # teh value\n").unwrap();
92    /// let comment = doc.comments().next().unwrap();
93    /// comment.set_content("the value");
94    /// assert_eq!(doc.to_string(), "key: value # the value\n");
95    /// ```
96    pub fn set_content(&self, content: &str) {
97        self.set_text(&format!("# {content}"));
98    }
99
100    /// Access the underlying syntax token.
101    pub fn syntax(&self) -> &SyntaxToken {
102        &self.0
103    }
104}
105
106impl std::fmt::Debug for Comment {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("Comment")
109            .field("text", &self.text())
110            .finish()
111    }
112}
113
114impl std::fmt::Display for Comment {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        write!(f, "{}", self.0.text())
117    }
118}
119
120/// Iterate over every comment in `node` and its descendants, in source order.
121pub(crate) fn comments(node: &SyntaxNode) -> impl Iterator<Item = Comment> {
122    node.descendants_with_tokens()
123        .filter_map(|element| element.into_token())
124        .filter_map(Comment::cast)
125}
126
127#[cfg(test)]
128mod tests {
129    use crate::YamlFile;
130    use std::str::FromStr;
131
132    #[test]
133    fn iterates_comments_in_order() {
134        let text = "# header\nkey: value # trailing\n# footer\n";
135        let file = YamlFile::from_str(text).unwrap();
136        let texts: Vec<_> = file.comments().map(|c| c.text().to_string()).collect();
137        assert_eq!(texts, vec!["# header", "# trailing", "# footer"]);
138    }
139
140    #[test]
141    fn content_strips_hash_and_single_space() {
142        let file = YamlFile::from_str("#nospace\n# one space\n#  two spaces\n").unwrap();
143        let contents: Vec<_> = file.comments().map(|c| c.content().to_string()).collect();
144        assert_eq!(contents, vec!["nospace", "one space", " two spaces"]);
145    }
146
147    #[test]
148    fn no_comments_yields_empty() {
149        let file = YamlFile::from_str("key: value\n").unwrap();
150        assert_eq!(file.comments().count(), 0);
151    }
152
153    #[test]
154    fn positions_are_one_indexed() {
155        let text = "key: value # trailing\n";
156        let file = YamlFile::from_str(text).unwrap();
157        let comment = file.comments().next().unwrap();
158        let start = comment.start_position(text);
159        assert_eq!(start.line, 1);
160        assert_eq!(start.column, 12);
161    }
162
163    #[test]
164    fn text_range_covers_full_comment() {
165        let text = "key: value # trailing\n";
166        let file = YamlFile::from_str(text).unwrap();
167        let comment = file.comments().next().unwrap();
168        let range = comment.text_range();
169        assert_eq!(&text[range], "# trailing");
170    }
171
172    #[test]
173    fn set_text_replaces_in_place() {
174        let file = YamlFile::from_str("key: value # teh value\n").unwrap();
175        let comment = file.comments().next().unwrap();
176        comment.set_text("# the value");
177        assert_eq!(file.to_string(), "key: value # the value\n");
178    }
179
180    #[test]
181    fn set_content_preserves_marker() {
182        let file = YamlFile::from_str("key: value #teh value\n").unwrap();
183        let comment = file.comments().next().unwrap();
184        comment.set_content("the value");
185        assert_eq!(file.to_string(), "key: value # the value\n");
186    }
187
188    #[test]
189    fn set_text_leaves_other_comments_intact() {
190        let file = YamlFile::from_str("# header\nkey: value # teh value\n# footer\n").unwrap();
191        let target = file.comments().nth(1).unwrap();
192        target.set_text("# the value");
193        let texts: Vec<_> = file.comments().map(|c| c.text().to_string()).collect();
194        assert_eq!(texts, vec!["# header", "# the value", "# footer"]);
195    }
196
197    #[test]
198    #[should_panic(expected = "must start with '#'")]
199    fn set_text_rejects_missing_hash() {
200        let file = YamlFile::from_str("key: value # comment\n").unwrap();
201        file.comments().next().unwrap().set_text("no hash");
202    }
203
204    #[test]
205    #[should_panic(expected = "must not contain a newline")]
206    fn set_text_rejects_newline() {
207        let file = YamlFile::from_str("key: value # comment\n").unwrap();
208        file.comments().next().unwrap().set_text("# a\n# b");
209    }
210}