Skip to main content

flyleaf_core/
comment.rs

1//! The comments a document carries, read and written where they live.
2//!
3//! TOML has four places a comment can be, and the tree draws all four: above
4//! a key or a section header, beside a value, before an array element, and
5//! after the last item of the document. `toml_edit` keeps each in a decor,
6//! which is the whitespace around a thing: the prefix holds the blank lines,
7//! the comment lines and the indentation before it, the suffix the space and
8//! the comment after it. Writing a comment means replacing the comment lines
9//! and leaving the whitespace as it was, or an edit to a comment would eat
10//! the blank line above a section.
11//!
12//! One thing is not kept: a blank line between two comment lines of the same
13//! block. The block is rewritten as one run, so `# a`, a blank, `# b` above
14//! a key comes back without the blank once either line is edited. Nothing
15//! else about the file moves.
16
17use toml_edit::{Decor, DocumentMut, RawString};
18
19/// The comment lines in a decor's prefix, without their `#` and the
20/// whitespace around each.
21#[must_use]
22pub fn comments_before(decor: &Decor) -> Vec<String> {
23    lines_of(decor.prefix())
24}
25
26/// The comment in a decor's suffix, which is the one beside a value.
27#[must_use]
28pub fn comment_beside(decor: &Decor) -> Option<String> {
29    lines_of(decor.suffix()).into_iter().next()
30}
31
32/// The comment lines after the last item of a document.
33#[must_use]
34pub fn trailing_comments(doc: &DocumentMut) -> Vec<String> {
35    lines_of(Some(doc.trailing()))
36}
37
38/// Replace the comment lines in a decor's prefix, keeping the blank lines
39/// before them and the indentation after them. No lines removes the comment
40/// and leaves the whitespace.
41pub fn set_comments_before(decor: &mut Decor, lines: &[String]) {
42    let raw = text_of(decor.prefix());
43    decor.set_prefix(rewritten(raw, lines));
44}
45
46/// Replace the comment in a decor's suffix, keeping the space before it, or
47/// remove it.
48pub fn set_comment_beside(decor: &mut Decor, comment: Option<&str>) {
49    let raw = text_of(decor.suffix());
50    let suffix = match comment {
51        None => String::new(),
52        Some(text) => {
53            let before = raw.split('#').next().unwrap_or("");
54            // Three spaces where there were none, which is the width the
55            // tree's own rows use and enough to read a comment as one.
56            let before = if before.trim().is_empty() && !before.is_empty() {
57                before
58            } else {
59                "   "
60            };
61            format!("{before}{}", hashed(text))
62        }
63    };
64    decor.set_suffix(suffix);
65}
66
67/// Replace the comment lines after the last item, keeping the blank lines
68/// before them.
69pub fn set_trailing_comments(doc: &mut DocumentMut, lines: &[String]) {
70    let raw = text_of(Some(doc.trailing()));
71    doc.set_trailing(rewritten(raw, lines));
72}
73
74fn text_of(raw: Option<&RawString>) -> &str {
75    raw.and_then(RawString::as_str).unwrap_or("")
76}
77
78fn lines_of(raw: Option<&RawString>) -> Vec<String> {
79    text_of(raw)
80        .lines()
81        .filter_map(|l| l.trim().strip_prefix('#').map(|c| c.trim().to_owned()))
82        .collect()
83}
84
85/// A comment line as written: `# text`, or a bare `#` for an empty one,
86/// since `# ` with nothing after it is a trailing space nobody meant.
87fn hashed(text: &str) -> String {
88    if text.is_empty() {
89        "#".to_owned()
90    } else {
91        format!("# {text}")
92    }
93}
94
95/// The prefix with its comment lines replaced.
96///
97/// A prefix is blank lines, then comment lines, then blank lines again,
98/// then the indentation of the thing it precedes, which is whatever follows
99/// the last newline. A comment block is as often set off from its key by a
100/// blank line as not, so the blank lines on both sides are kept; each new
101/// comment line takes the indentation, since a comment above an indented
102/// key is indented with it. Where there were no comment lines, every blank
103/// line counts as before, so a new comment lands on the line above its key.
104fn rewritten(raw: &str, lines: &[String]) -> String {
105    let (body, indent) = match raw.rfind('\n') {
106        Some(i) => (&raw[..=i], &raw[i + 1..]),
107        None => ("", raw),
108    };
109    let body: Vec<&str> = body.split_inclusive('\n').collect();
110    let first_comment = body.iter().position(|l| !l.trim().is_empty());
111    let last_comment = body.iter().rposition(|l| !l.trim().is_empty());
112    let (before, after) = match (first_comment, last_comment) {
113        (Some(first), Some(last)) => (&body[..first], &body[last + 1..]),
114        _ => (&body[..], &body[..0]),
115    };
116    let mut out: String = before.concat();
117    for line in lines {
118        out.push_str(indent);
119        out.push_str(&hashed(line));
120        out.push('\n');
121    }
122    out.push_str(&after.concat());
123    out.push_str(indent);
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::{
130        comment_beside, comments_before, set_comment_beside, set_comments_before,
131        set_trailing_comments, trailing_comments,
132    };
133    use toml_edit::DocumentMut;
134
135    const DOC: &str = "\
136# above first
137
138first = 1   # beside first
139
140  # above second, indented
141  second = 2
142
143
144[table]
145# in the table
146third = 3
147
148# at the end
149";
150
151    fn doc() -> DocumentMut {
152        DOC.parse().expect("valid TOML")
153    }
154
155    /// Every slot reads back what the file has, and nothing else: no blank
156    /// lines, no indentation, no `#`.
157    #[test]
158    fn every_slot_reads_its_comment() {
159        let d = doc();
160        let t = d.as_table();
161        assert_eq!(
162            comments_before(t.key("first").unwrap().leaf_decor()),
163            ["above first"]
164        );
165        assert_eq!(
166            comment_beside(t["first"].as_value().unwrap().decor()),
167            Some("beside first".to_owned())
168        );
169        assert_eq!(
170            comments_before(t.key("second").unwrap().leaf_decor()),
171            ["above second, indented"]
172        );
173        assert_eq!(
174            comment_beside(t["second"].as_value().unwrap().decor()),
175            None
176        );
177        assert_eq!(trailing_comments(&d), ["at the end"]);
178    }
179
180    /// An edited comment keeps the blank lines above it and the indentation
181    /// of the key below it; a removed one leaves both; an added one takes
182    /// the indentation of its key. The whole file is compared, so anything
183    /// that moved would show.
184    #[test]
185    fn a_comment_above_is_replaced_and_the_whitespace_stays() {
186        let mut d = doc();
187        let t = d.as_table_mut();
188        set_comments_before(
189            t.key_mut("first").unwrap().leaf_decor_mut(),
190            &["changed".to_owned(), "and a second line".to_owned()],
191        );
192        set_comments_before(t.key_mut("second").unwrap().leaf_decor_mut(), &[]);
193        set_comments_before(
194            t["table"]
195                .as_table_mut()
196                .unwrap()
197                .key_mut("third")
198                .unwrap()
199                .leaf_decor_mut(),
200            &[String::new()],
201        );
202        assert_eq!(
203            d.to_string(),
204            "\
205# changed
206# and a second line
207
208first = 1   # beside first
209
210  second = 2
211
212
213[table]
214#
215third = 3
216
217# at the end
218"
219        );
220        assert_eq!(
221            comments_before(d.as_table().key("first").unwrap().leaf_decor()),
222            ["changed", "and a second line"]
223        );
224    }
225
226    /// A comment added above a key that had none goes on the line above it,
227    /// with its indentation, and the blank line before the key stays where
228    /// it was, above the new comment.
229    #[test]
230    fn a_comment_added_above_takes_the_key_s_indentation() {
231        let mut d: DocumentMut = "a = 1\n\n  b = 2\n".parse().unwrap();
232        set_comments_before(
233            d.as_table_mut().key_mut("b").unwrap().leaf_decor_mut(),
234            &["new".to_owned()],
235        );
236        assert_eq!(d.to_string(), "a = 1\n\n  # new\n  b = 2\n");
237    }
238
239    /// A comment beside a value keeps the spacing it had, gets a standard
240    /// gap where there was none, and is removed cleanly.
241    #[test]
242    fn a_comment_beside_is_replaced_added_and_removed() {
243        let mut d = doc();
244        let t = d.as_table_mut();
245        set_comment_beside(
246            t["first"].as_value_mut().unwrap().decor_mut(),
247            Some("changed"),
248        );
249        set_comment_beside(t["second"].as_value_mut().unwrap().decor_mut(), Some("new"));
250        let written = d.to_string();
251        assert!(written.contains("first = 1   # changed\n"), "{written}");
252        assert!(written.contains("second = 2   # new\n"), "{written}");
253
254        set_comment_beside(
255            d.as_table_mut()["first"]
256                .as_value_mut()
257                .unwrap()
258                .decor_mut(),
259            None,
260        );
261        assert!(d.to_string().contains("first = 1\n"), "{d}");
262    }
263
264    /// The comment after the last item is replaced with its blank line
265    /// kept, and removed with the blank line kept.
266    #[test]
267    fn the_trailing_comment_is_replaced_and_removed() {
268        let mut d = doc();
269        set_trailing_comments(&mut d, &["the end, changed".to_owned()]);
270        assert!(
271            d.to_string().ends_with("third = 3\n\n# the end, changed\n"),
272            "{d}"
273        );
274        set_trailing_comments(&mut d, &[]);
275        assert!(d.to_string().ends_with("third = 3\n\n"), "{d}");
276    }
277
278    /// A table header's comment lives in the table's own decor, and is
279    /// written there.
280    #[test]
281    fn a_comment_above_a_header_is_the_table_s() {
282        let mut d: DocumentMut = "a = 1\n\n# about t\n[t]\nb = 2\n".parse().unwrap();
283        let t = d.as_table_mut()["t"].as_table_mut().unwrap();
284        assert_eq!(comments_before(t.decor()), ["about t"]);
285        set_comments_before(t.decor_mut(), &["about t, changed".to_owned()]);
286        assert_eq!(d.to_string(), "a = 1\n\n# about t, changed\n[t]\nb = 2\n");
287    }
288}