Skip to main content

rmut_core/
flowed.rs

1//! RFC 3676 `text/plain; format=flowed`.
2//!
3//! Incoming: [`unflow`] puts a paragraph the sender split across
4//! several flowed lines back on one logical line, keeping the quote
5//! depth, so the pager's own wrapper lays it out at the display width
6//! instead of leaving the sender's line breaks in place.
7//!
8//! Outgoing: [`space_stuff`] is the sender's half of the format, the
9//! leading space RFC 3676 puts on a line that would otherwise look
10//! like a quote or a `From ` line.
11
12/// A line ending in a space continues the paragraph, except the
13/// signature separator, which RFC 3676 ยง4.3 keeps fixed.
14const SIG_SEPARATOR: &str = "-- ";
15
16/// Leading `>` run (the quote depth) and the rest of the line. RFC
17/// 3676 quote marks sit at the very start with nothing between them.
18fn split_quote(line: &str) -> (usize, &str) {
19    let depth = line.bytes().take_while(|&b| b == b'>').count();
20    (depth, &line[depth..])
21}
22
23/// Reflow a flowed body into one line per paragraph. `delsp` is the
24/// DelSp parameter: with it the space that marked a line as flowed is
25/// dropped on joining, without it (the default) it is part of the
26/// text. Quote depth survives as `>` marks, so the pager still colours
27/// quoted text and can still fold it.
28pub fn unflow(text: &str, delsp: bool) -> String {
29    let mut out = String::new();
30    // The paragraph being collected: its quote depth and text so far.
31    let mut open: Option<(usize, String)> = None;
32    for raw in text.lines() {
33        let (depth, rest) = split_quote(raw);
34        // Space-stuffing is the sender's, not part of the text.
35        let rest = rest.strip_prefix(' ').unwrap_or(rest);
36        let flowed = rest.ends_with(' ') && rest != SIG_SEPARATOR;
37        let content = match flowed && delsp {
38            true => &rest[..rest.len() - 1],
39            false => rest,
40        };
41        match &mut open {
42            // Same depth: the paragraph goes on.
43            Some((d, buf)) if *d == depth => buf.push_str(content),
44            // A depth change ends the paragraph, whatever the spaces.
45            Some(_) => {
46                flush(&mut out, open.take());
47                open = Some((depth, content.to_string()));
48            }
49            None => open = Some((depth, content.to_string())),
50        }
51        if !flowed {
52            flush(&mut out, open.take());
53        }
54    }
55    flush(&mut out, open.take());
56    out
57}
58
59fn flush(out: &mut String, paragraph: Option<(usize, String)>) {
60    let Some((depth, text)) = paragraph else {
61        return;
62    };
63    for _ in 0..depth {
64        out.push('>');
65    }
66    if depth > 0 && !text.is_empty() {
67        out.push(' ');
68    }
69    out.push_str(&text);
70    out.push('\n');
71}
72
73/// RFC 3676 space-stuffing: a line starting with a space, a `>` or
74/// `From ` gets one more space in front, so a reader can tell the
75/// sender's text from the format's own marks. Undone by [`unflow`] at
76/// the other end.
77pub fn space_stuff(text: &str) -> String {
78    let mut out = String::new();
79    for line in text.lines() {
80        if line.starts_with(' ') || line.starts_with('>') || line.starts_with("From ") {
81            out.push(' ');
82        }
83        out.push_str(line);
84        out.push('\n');
85    }
86    if !text.ends_with('\n') {
87        out.pop();
88    }
89    out
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn paragraphs_join_and_fixed_lines_stay() {
98        let text = "One flowed \nparagraph here.\n\nA fixed line.\n";
99        assert_eq!(
100            unflow(text, false),
101            "One flowed paragraph here.\n\nA fixed line.\n"
102        );
103    }
104
105    #[test]
106    fn delsp_drops_the_marking_space() {
107        assert_eq!(unflow("half \nway\n", true), "halfway\n");
108        assert_eq!(unflow("half \nway\n", false), "half way\n");
109    }
110
111    #[test]
112    fn quote_depth_bounds_a_paragraph() {
113        let text = "> quoted and \n> continued\n>> deeper \n>> line\nmine\n";
114        assert_eq!(
115            unflow(text, false),
116            "> quoted and continued\n>> deeper line\nmine\n"
117        );
118    }
119
120    #[test]
121    fn stuffing_is_undone_and_the_signature_stays_fixed() {
122        // A stuffed line: the leading space is the format's, not text.
123        assert_eq!(unflow("  indented\n", false), " indented\n");
124        assert_eq!(unflow(" >not a quote\n", false), ">not a quote\n");
125        // "-- " ends the paragraph despite the trailing space.
126        assert_eq!(unflow("text\n-- \nJane\n", false), "text\n-- \nJane\n");
127    }
128
129    #[test]
130    fn stuffing_marks_the_lines_that_need_it() {
131        assert_eq!(
132            space_stuff(" lead\n>quote\nFrom here\nplain\n"),
133            "  lead\n >quote\n From here\nplain\n"
134        );
135        assert_eq!(space_stuff("no newline"), "no newline");
136    }
137}