Skip to main content

ephemeris_documents/yaml/
literal.rs

1//! Literal block scalars.
2
3use std::iter;
4
5use crate::text::is_unsafe_text_char;
6
7/// Text for a `|` block scalar.
8pub(crate) struct LiteralBlock<'a>(&'a str);
9
10impl<'a> LiteralBlock<'a> {
11    pub(crate) const fn new(text: &'a str) -> Self {
12        Self(text)
13    }
14
15    /// Whether a block scalar reproduces the text exactly.
16    ///
17    /// Control characters other than line feeds, and line separators, have
18    /// no faithful block form. A tab is fine inside a line but not at its
19    /// start. Text of only line feeds has none either. Empty text is `|-`
20    /// with no lines.
21    pub(crate) fn is_representable(&self) -> bool {
22        (self.0.is_empty() || !self.0.trim_matches('\n').is_empty())
23            && !self.0.split('\n').any(|line| line.starts_with('\t'))
24            && !self
25                .0
26                .chars()
27                .any(|c| is_unsafe_text_char(c) || c == '\u{feff}')
28    }
29
30    /// The header, such as `|-`, `|+`, or `|2`.
31    ///
32    /// The indicator is needed when the first line is empty or starts with
33    /// a space.
34    ///
35    /// Content is written two columns deeper than the node that owns the
36    /// scalar, so the indicator is always 2.
37    pub(super) fn header(&self) -> String {
38        let mut header = String::from("|");
39        if self.0.starts_with('\n') || self.0.starts_with(' ') {
40            header.push('2');
41        }
42        match self.trailing_newlines() {
43            0 => header.push('-'),
44            1 => {}
45            _ => header.push('+'),
46        }
47        header
48    }
49
50    /// The content lines, followed by the empty lines that keep chomping
51    /// needs.
52    pub(super) fn lines(&self) -> impl Iterator<Item = &str> {
53        let body = self.0.trim_end_matches('\n');
54        let extra = self.trailing_newlines().saturating_sub(1);
55        (!self.0.is_empty())
56            .then(|| body.split('\n'))
57            .into_iter()
58            .flatten()
59            .chain(iter::repeat_n("", extra))
60    }
61
62    fn trailing_newlines(&self) -> usize {
63        self.0
64            .len()
65            .saturating_sub(self.0.trim_end_matches('\n').len())
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::LiteralBlock;
72
73    #[test]
74    fn chomping_follows_trailing_newlines() {
75        assert_eq!(LiteralBlock("a").header(), "|-");
76        assert_eq!(LiteralBlock("a\n").header(), "|");
77        assert_eq!(LiteralBlock("a\n\n").header(), "|+");
78    }
79
80    #[test]
81    fn leading_space_needs_an_indentation_indicator() {
82        assert_eq!(LiteralBlock("  a\n").header(), "|2");
83        assert_eq!(LiteralBlock("\n  a").header(), "|2-");
84        assert_eq!(LiteralBlock("\nb").header(), "|2-");
85    }
86
87    #[test]
88    fn unrepresentable_text_is_detected() {
89        for text in ["\n", "a\rb", "\ta", "a\n\tb", "a\u{1}", "a\u{2028}b"] {
90            assert!(!LiteralBlock(text).is_representable(), "{text:?}");
91        }
92        for text in ["a\nb\n", "", "a\tb", "a \tb\n"] {
93            assert!(LiteralBlock(text).is_representable(), "{text:?}");
94        }
95    }
96
97    #[test]
98    fn empty_text_has_no_lines() {
99        assert_eq!(LiteralBlock("").header(), "|-");
100        assert_eq!(LiteralBlock("").lines().count(), 0);
101    }
102
103    #[test]
104    fn keep_chomping_adds_empty_lines() {
105        let lines: Vec<_> = LiteralBlock("a\nb\n\n\n").lines().collect();
106        assert_eq!(lines, ["a", "b", "", ""]);
107    }
108}