Skip to main content

ephemeris_documents/
text.rs

1//! Plain text documents.
2
3use std::fmt;
4use std::ops::Deref;
5
6use facet::Facet;
7
8use crate::columns::Columns;
9use crate::error::{ColumnsError, TextError};
10use crate::header::GENERATED;
11
12/// Comment delimiter for plain text formats that support comments.
13#[derive(Debug, Clone, Copy, Facet, PartialEq, Eq)]
14#[repr(u8)]
15pub enum CommentSyntax {
16    /// `# comment`.
17    #[facet(rename = "hash")]
18    Hash,
19    /// `// comment`.
20    #[facet(rename = "double-slash")]
21    DoubleSlash,
22    /// `; comment`.
23    #[facet(rename = "semicolon")]
24    Semicolon,
25    /// `<!-- comment -->`.
26    #[facet(rename = "xml")]
27    Xml,
28}
29
30impl CommentSyntax {
31    /// Every syntax.
32    pub(crate) const ALL: [Self; 4] = [Self::Hash, Self::DoubleSlash, Self::Semicolon, Self::Xml];
33
34    /// The text of `line` when it is a comment of one line in this syntax.
35    pub(crate) fn uncomment(self, line: &str) -> Option<&str> {
36        match self.line_delimiter() {
37            Some(delimiter) => line.strip_prefix(delimiter),
38            None => line.strip_prefix("<!--")?.strip_suffix("-->"),
39        }
40    }
41
42    /// The delimiter that starts each line, or `None` for the block syntax.
43    const fn line_delimiter(self) -> Option<&'static str> {
44        match self {
45            Self::Hash => Some("#"),
46            Self::DoubleSlash => Some("//"),
47            Self::Semicolon => Some(";"),
48            Self::Xml => None,
49        }
50    }
51
52    /// Render a comment in this syntax. Every output line ends with `\n`.
53    ///
54    /// Line syntaxes prefix every line. The block syntax wraps the whole text
55    /// once.
56    pub(crate) fn comment(self, comment: &str) -> impl fmt::Display + '_ {
57        Rendered {
58            syntax: self,
59            comment,
60        }
61    }
62
63    /// Write the lines of a comment in this syntax, each after `indent`.
64    pub(crate) fn write_lines(
65        self,
66        out: &mut impl fmt::Write,
67        comment: &str,
68        indent: impl fmt::Display,
69    ) -> fmt::Result {
70        let Some(delimiter) = self.line_delimiter() else {
71            return if comment.is_empty() {
72                writeln!(out, "{indent}<!-- -->")
73            } else if comment.contains('\n') {
74                writeln!(out, "{indent}<!--")?;
75                comment
76                    .split('\n')
77                    .try_for_each(|line| writeln!(out, "{indent}{line}"))?;
78                writeln!(out, "{indent}-->")
79            } else {
80                writeln!(out, "{indent}<!-- {comment} -->")
81            };
82        };
83        comment.split('\n').try_for_each(|line| {
84            if line.is_empty() {
85                writeln!(out, "{indent}{delimiter}")
86            } else {
87                writeln!(out, "{indent}{delimiter} {line}")
88            }
89        })
90    }
91}
92
93struct Rendered<'a> {
94    syntax: CommentSyntax,
95    comment: &'a str,
96}
97
98impl fmt::Display for Rendered<'_> {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        self.syntax.write_lines(f, self.comment, "")
101    }
102}
103
104/// A validated plain text document. Rendering cannot fail.
105#[derive(Debug, Clone)]
106pub(crate) struct TextDocument<'a> {
107    parts: Vec<Part<'a>>,
108}
109
110#[derive(Debug, Clone)]
111enum Part<'a> {
112    Comment {
113        syntax: CommentSyntax,
114        comment: &'a str,
115    },
116    Text {
117        content: &'a str,
118        wrap: Option<Wrap>,
119    },
120}
121
122impl<'a> TextDocument<'a> {
123    /// Validate the nodes against the comment syntax.
124    ///
125    /// # Errors
126    /// Returns an error when a comment node has no syntax to render with,
127    /// contains a carriage return or another character that corrupts output,
128    /// would contain the XML closing delimiter
129    /// (`-->`) under [`CommentSyntax::Xml`], or follows text that does not end
130    /// a line.
131    pub(crate) fn new(
132        syntax: Option<CommentSyntax>,
133        nodes: &'a [DocumentNode],
134    ) -> Result<Self, TextError> {
135        let mut parts = Vec::with_capacity(nodes.len());
136        let mut at_line_start = true;
137        for node in nodes {
138            match node {
139                DocumentNode::Text { content, wrap } => {
140                    if let Some(last) = content.chars().next_back() {
141                        at_line_start = wrap.is_some() || last == '\n';
142                    }
143                    parts.push(Part::Text {
144                        content,
145                        wrap: *wrap,
146                    });
147                }
148                DocumentNode::Comment { content } => {
149                    let syntax = syntax.ok_or(TextError::CommentSyntaxMissing)?;
150                    Comment::check(content)?;
151                    let comment = content.as_str();
152                    if syntax == CommentSyntax::Xml && comment.contains("-->") {
153                        return Err(TextError::XmlCommentContainsCloser);
154                    }
155                    if !at_line_start {
156                        return Err(TextError::CommentMidLine);
157                    }
158                    parts.push(Part::Comment { syntax, comment });
159                }
160            }
161        }
162        Ok(Self { parts })
163    }
164}
165
166impl TextDocument<'_> {
167    /// Put the generated marker and a blank line in front, when the syntax
168    /// can express a comment.
169    pub(crate) fn generated(mut self, syntax: Option<CommentSyntax>) -> Self {
170        if let Some(syntax) = syntax {
171            self.parts.splice(
172                ..0,
173                [
174                    Part::Comment {
175                        syntax,
176                        comment: GENERATED,
177                    },
178                    Part::Text {
179                        content: "\n",
180                        wrap: None,
181                    },
182                ],
183            );
184        }
185        self
186    }
187}
188
189impl fmt::Display for TextDocument<'_> {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        for part in &self.parts {
192            match part {
193                Part::Comment { syntax, comment } => write!(f, "{}", syntax.comment(comment))?,
194                Part::Text {
195                    content,
196                    wrap: None,
197                } => f.write_str(content)?,
198                Part::Text {
199                    content,
200                    wrap: Some(width),
201                } => Wrapped {
202                    content,
203                    width: width.get(),
204                }
205                .fmt(f)?,
206            }
207        }
208        Ok(())
209    }
210}
211
212/// Text with every line broken at word boundaries to fit a column count.
213struct Wrapped<'a> {
214    content: &'a str,
215    width: usize,
216}
217
218impl Wrapped<'_> {
219    fn write_line(&self, line: &str, out: &mut impl fmt::Write) -> fmt::Result {
220        if line.chars().count() <= self.width {
221            return writeln!(out, "{line}");
222        }
223        let indent_len = line.find(|c| c != ' ' && c != '\t').unwrap_or(line.len());
224        let (indent, body) = line.split_at(indent_len);
225        let indent_width = indent.chars().count();
226        let mut column = 0;
227        for word in body.split_whitespace() {
228            let word_width = word.chars().count();
229            if column == 0 {
230                write!(out, "{indent}{word}")?;
231                column = indent_width.saturating_add(word_width);
232            } else if column.saturating_add(1).saturating_add(word_width) <= self.width {
233                write!(out, " {word}")?;
234                column = column.saturating_add(1).saturating_add(word_width);
235            } else {
236                write!(out, "\n{indent}{word}")?;
237                column = indent_width.saturating_add(word_width);
238            }
239        }
240        writeln!(out)
241    }
242}
243
244impl fmt::Display for Wrapped<'_> {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        if self.content.is_empty() {
247            return Ok(());
248        }
249        let content = self.content.strip_suffix('\n').unwrap_or(self.content);
250        content
251            .split('\n')
252            .try_for_each(|line| self.write_line(line, f))
253    }
254}
255
256/// Comment text without delimiters. It holds no carriage return, no control
257/// character other than line feed and tab, and no line or paragraph
258/// separator.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub(crate) struct Comment(String);
261
262impl Comment {
263    /// Validate comment text.
264    ///
265    /// # Errors
266    /// Returns [`TextError::CommentNotLf`] when the text contains a carriage
267    /// return, and [`TextError::CommentCharacter`] for any other control
268    /// character except line feed and tab, and for U+2028 and U+2029.
269    pub(crate) fn new(text: String) -> Result<Self, TextError> {
270        Self::check(&text)?;
271        Ok(Self(text))
272    }
273
274    /// Validate comment text without taking it.
275    ///
276    /// # Errors
277    /// Returns the same errors as [`Self::new`].
278    pub(crate) fn check(text: &str) -> Result<(), TextError> {
279        if text.contains('\r') {
280            return Err(TextError::CommentNotLf);
281        }
282        if let Some(character) = text.chars().find(|c| is_unsafe_text_char(*c)) {
283            return Err(TextError::CommentCharacter { character });
284        }
285        Ok(())
286    }
287}
288
289impl Deref for Comment {
290    type Target = str;
291
292    fn deref(&self) -> &str {
293        &self.0
294    }
295}
296
297/// A positive column count for wrapping text.
298#[derive(Debug, Clone, Copy, Facet, PartialEq, Eq)]
299#[facet(proxy = usize)]
300pub struct Wrap(#[facet(opaque)] Columns);
301
302impl Wrap {
303    /// A wrap width of `columns`, or `None` for zero.
304    #[must_use]
305    pub const fn new(columns: usize) -> Option<Self> {
306        match Columns::new(columns) {
307            Some(columns) => Some(Self(columns)),
308            None => None,
309        }
310    }
311
312    /// The number of columns.
313    #[must_use]
314    pub const fn get(self) -> usize {
315        self.0.get()
316    }
317}
318
319impl TryFrom<usize> for Wrap {
320    type Error = ColumnsError;
321
322    fn try_from(columns: usize) -> Result<Self, Self::Error> {
323        Columns::try_from(columns).map(Self)
324    }
325}
326
327impl From<&Wrap> for usize {
328    fn from(wrap: &Wrap) -> Self {
329        wrap.get()
330    }
331}
332
333/// A comment or literal text node in a plain text document.
334#[derive(Debug, Clone, Facet, PartialEq, Eq)]
335#[facet(tag = "type", deny_unknown_fields)]
336#[repr(u8)]
337pub enum DocumentNode {
338    /// A comment, rendered using the document's [`CommentSyntax`].
339    #[facet(rename = "comment")]
340    Comment {
341        /// The comment text, without delimiters.
342        content: String,
343    },
344    /// Literal text copied into the document, optionally wrapped.
345    #[facet(rename = "text")]
346    Text {
347        /// The literal text content.
348        content: String,
349        /// Column count at which lines longer than it are broken at spaces.
350        #[facet(default)]
351        wrap: Option<Wrap>,
352    },
353}
354
355/// Whether `c` cannot be written as it is in text that holds line feeds and
356/// tabs. That is every control character but those two, and the Unicode line
357/// and paragraph separators.
358pub(crate) const fn is_unsafe_text_char(c: char) -> bool {
359    (c.is_control() && !matches!(c, '\n' | '\t')) || matches!(c, '\u{2028}' | '\u{2029}')
360}
361
362#[cfg(test)]
363mod tests;