yaml_edit/nodes/
comment.rs1use super::{SyntaxNode, SyntaxToken};
4use crate::lex::SyntaxKind;
5use rowan::GreenNodeBuilder;
6
7#[derive(Clone, PartialEq, Eq, Hash)]
13pub struct Comment(pub(crate) SyntaxToken);
14
15impl Comment {
16 pub(crate) fn cast(token: SyntaxToken) -> Option<Self> {
18 (token.kind() == SyntaxKind::COMMENT).then_some(Comment(token))
19 }
20
21 pub fn text(&self) -> &str {
25 self.0.text()
26 }
27
28 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 pub fn byte_range(&self) -> crate::TextPosition {
40 self.0.text_range().into()
41 }
42
43 pub fn text_range(&self) -> rowan::TextRange {
47 self.0.text_range()
48 }
49
50 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 pub fn set_content(&self, content: &str) {
108 self.set_text(&format!("# {content}"));
109 }
110
111 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 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 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
155pub(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}