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 /// # Errors
49 ///
50 /// Returns a [`CommentTextError`], leaving the tree unchanged, if `text`
51 /// does not start with `#` or contains a line break: either would turn
52 /// the token into something other than a single comment.
53 ///
54 /// Afterwards this handle refers to the new comment token.
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 mut comment = doc.comments().next().unwrap();
63 /// comment.set_text("# the value").unwrap();
64 /// assert_eq!(doc.to_string(), "key: value # the value\n");
65 /// ```
66 pub fn set_text(&mut self, text: &str) -> Result<(), CommentTextError> {
67 if !text.starts_with('#') {
68 return Err(CommentTextError::MissingHash);
69 }
70 if text.contains(['\n', '\r']) {
71 return Err(CommentTextError::LineBreak);
72 }
73 let parent = self
74 .0
75 .parent()
76 .expect("a comment token always has a parent node");
77 let index = self.0.index();
78 let new_token = fresh_token(SyntaxKind::COMMENT, text);
79 super::replace_children(&parent, index..index + 1, vec![new_token.into()]);
80 self.0 = parent
81 .children_with_tokens()
82 .nth(index)
83 .and_then(|element| element.into_token())
84 .expect("the replacement comment sits at the old comment's index");
85 Ok(())
86 }
87
88 /// Replace the comment content, preserving the leading `# ` marker.
89 ///
90 /// This is the counterpart to [`content`](Self::content): it sets the text
91 /// after the `#` and a single space. Use [`set_text`](Self::set_text) when
92 /// you need full control over the marker and spacing.
93 ///
94 /// # Errors
95 ///
96 /// Returns [`CommentTextError::LineBreak`] if `content` contains a line
97 /// break.
98 ///
99 /// # Example
100 /// ```
101 /// use yaml_edit::Document;
102 /// use std::str::FromStr;
103 ///
104 /// let doc = Document::from_str("key: value # teh value\n").unwrap();
105 /// let mut comment = doc.comments().next().unwrap();
106 /// comment.set_content("the value").unwrap();
107 /// assert_eq!(doc.to_string(), "key: value # the value\n");
108 /// ```
109 pub fn set_content(&mut self, content: &str) -> Result<(), CommentTextError> {
110 self.set_text(&format!("# {content}"))
111 }
112
113 /// Access the underlying syntax token.
114 pub fn syntax(&self) -> &SyntaxToken {
115 &self.0
116 }
117}
118
119/// Why [`Comment::set_text`] or [`Comment::set_content`] refused new text.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CommentTextError {
122 /// The text does not start with `#`.
123 MissingHash,
124 /// The text contains a line break.
125 LineBreak,
126}
127
128impl std::fmt::Display for CommentTextError {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 match self {
131 CommentTextError::MissingHash => write!(f, "comment text must start with '#'"),
132 CommentTextError::LineBreak => write!(f, "comment text must not contain a line break"),
133 }
134 }
135}
136
137impl std::error::Error for CommentTextError {}
138
139impl std::fmt::Debug for Comment {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.debug_struct("Comment")
142 .field("text", &self.text())
143 .finish()
144 }
145}
146
147impl std::fmt::Display for Comment {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 write!(f, "{}", self.0.text())
150 }
151}
152
153/// Iterate over every comment in `node` and its descendants, in source order.
154pub(crate) fn comments(node: &SyntaxNode) -> impl Iterator<Item = Comment> {
155 node.descendants_with_tokens()
156 .filter_map(|element| element.into_token())
157 .filter_map(Comment::cast)
158}