Skip to main content

yaml_edit/nodes/
comment.rs

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