endbasic_std/console/
format.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License.  You may obtain a copy
6// of the License at:
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
13// License for the specific language governing permissions and limitations
14// under the License.
15
16//! Utilities to format text.
17
18use super::Pager;
19use crate::console::Console;
20use std::io;
21
22/// Refills a paragraph to fit within a maximum width, returning the formatted lines.
23///
24/// This does not cut words half-way, which means that it may be impossible to fit certain words in
25/// the specified width.  If that happens, lines will overflow.
26fn refill(paragraph: &str, width: usize) -> Vec<String> {
27    if paragraph.is_empty() {
28        return vec!["".to_owned()];
29    }
30
31    let mut lines = vec![];
32
33    let mut line = String::new();
34    for word in paragraph.split_whitespace() {
35        if !line.is_empty() {
36            // Determine how many spaces to inject after a period.  We want 2 spaces to separate
37            // different sentences and 1 otherwise.  The heuristic here isn't great and it'd be
38            // better to respect the original spacing of the paragraph.
39            let spaces = if line.ends_with('.') {
40                let first = word.chars().next().expect("Words cannot be empty");
41                if first == first.to_ascii_uppercase() {
42                    2
43                } else {
44                    1
45                }
46            } else {
47                1
48            };
49
50            if (line.len() + word.len() + spaces) >= width {
51                lines.push(line);
52                line = String::new();
53            } else {
54                for _ in 0..spaces {
55                    line.push(' ');
56                }
57            }
58        }
59        line.push_str(word);
60    }
61    if !line.is_empty() {
62        lines.push(line);
63    }
64
65    lines
66}
67
68/// Refills a collection of paragraphs, prefixing them with an optional `indent`.
69fn refill_many<S: AsRef<str>, P: IntoIterator<Item = S>>(
70    paragraphs: P,
71    indent: &str,
72    width: usize,
73) -> Vec<String> {
74    let mut formatted = vec![];
75
76    let mut first = true;
77    for paragraph in paragraphs {
78        let paragraph = paragraph.as_ref();
79
80        if !first {
81            formatted.push(String::new());
82        }
83        first = false;
84
85        let mut extra_indent = String::new();
86        for ch in paragraph.chars() {
87            // TODO(jmmv): It'd be nice to recognize '*' prefixes so that continuation lines in
88            // lists look nicer.
89            if ch.is_whitespace() {
90                extra_indent.push(' ');
91            } else {
92                break;
93            }
94        }
95
96        let lines = refill(paragraph, width - 4 - indent.len() - extra_indent.len());
97        for line in lines {
98            if line.is_empty() {
99                formatted.push(String::new());
100            } else {
101                formatted.push(format!("{}{}{}", indent, extra_indent, line));
102            }
103        }
104    }
105
106    formatted
107}
108
109/// Same as `refill` but prints the lines of each paragraph to the console instead of returning
110/// them and prefixes them with an optional `indent`.
111///
112/// The width is automatically determined from the console's size.
113pub fn refill_and_print<S: AsRef<str>, P: IntoIterator<Item = S>>(
114    console: &mut dyn Console,
115    paragraphs: P,
116    indent: &str,
117) -> io::Result<()> {
118    // TODO(jmmv): This queries the size on every print, which is not very efficient.  Should
119    // reuse this across calls, maybe by having a wrapper over Console and using it throughout.
120    let size = console.size_chars()?;
121    for line in refill_many(paragraphs, indent, usize::from(size.x)) {
122        console.print(&line)?;
123    }
124    Ok(())
125}
126
127/// Same as `refill` but prints the lines of each paragraph to a pager instead of returning
128/// them and prefixes them with an optional `indent`.
129///
130/// The width is automatically determined from the console's size.
131pub(crate) async fn refill_and_page<S: AsRef<str>, P: IntoIterator<Item = S>>(
132    pager: &mut Pager<'_>,
133    paragraphs: P,
134    indent: &str,
135) -> io::Result<()> {
136    for line in refill_many(paragraphs, indent, usize::from(pager.columns())) {
137        pager.print(&line).await?;
138    }
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::console::CharsXY;
146    use crate::testutils::{CapturedOut, MockConsole};
147
148    #[test]
149    fn test_refill_empty() {
150        assert_eq!(&[""], refill("", 0).as_slice());
151        assert_eq!(&[""], refill("", 10).as_slice());
152    }
153
154    #[test]
155    fn test_refill_nothing_fits() {
156        assert_eq!(&["this", "is", "some", "text"], refill("this is some text", 0).as_slice());
157        assert_eq!(&["this", "is", "some", "text"], refill("this is some text", 1).as_slice());
158    }
159
160    #[test]
161    fn test_refill_some_lines() {
162        assert_eq!(
163            &["this is a piece", "of text with", "a-fictitious-very-long-word", "within it"],
164            refill("this is a piece of text with a-fictitious-very-long-word within it", 16)
165                .as_slice()
166        );
167    }
168
169    #[test]
170    fn test_refill_reformats_periods() {
171        assert_eq!(&["foo. bar. baz."], refill("foo. bar.    baz.", 100).as_slice());
172        assert_eq!(&["foo.  Bar. baz."], refill("foo. Bar.    baz.", 100).as_slice());
173        assert_eq!(&["[some .. range]"], refill("[some .. range]", 100).as_slice());
174    }
175
176    #[test]
177    fn test_refill_and_print_empty() {
178        let mut console = MockConsole::default();
179        let paragraphs: &[&str] = &[];
180        refill_and_print(&mut console, paragraphs, "    ").unwrap();
181        assert!(console.captured_out().is_empty());
182    }
183
184    #[test]
185    fn test_refill_and_print_one() {
186        let mut console = MockConsole::default();
187        let paragraphs = &["First    paragraph"];
188        refill_and_print(&mut console, paragraphs, "    ").unwrap();
189        assert_eq!(&[CapturedOut::Print("    First paragraph".to_owned())], console.captured_out());
190    }
191
192    #[test]
193    fn test_refill_and_print_multiple() {
194        let mut console = MockConsole::default();
195        let paragraphs = &["First    paragraph", "Second", "Third. The. end."];
196        refill_and_print(&mut console, paragraphs, "    ").unwrap();
197        assert_eq!(
198            &[
199                CapturedOut::Print("    First paragraph".to_owned()),
200                CapturedOut::Print("".to_owned()),
201                CapturedOut::Print("    Second".to_owned()),
202                CapturedOut::Print("".to_owned()),
203                CapturedOut::Print("    Third.  The. end.".to_owned()),
204            ],
205            console.captured_out()
206        );
207    }
208
209    #[test]
210    fn test_refill_and_print_multiple_with_extra_indent() {
211        let mut console = MockConsole::default();
212        console.set_size_chars(CharsXY { x: 30, y: 30 });
213        let paragraphs = &[
214            "First    paragraph that is somewhat long",
215            "  The second paragraph contains an extra indent",
216            "Third. The. end.",
217        ];
218        refill_and_print(&mut console, paragraphs, "    ").unwrap();
219        assert_eq!(
220            &[
221                CapturedOut::Print("    First paragraph that".to_owned()),
222                CapturedOut::Print("    is somewhat long".to_owned()),
223                CapturedOut::Print("".to_owned()),
224                CapturedOut::Print("      The second".to_owned()),
225                CapturedOut::Print("      paragraph contains".to_owned()),
226                CapturedOut::Print("      an extra indent".to_owned()),
227                CapturedOut::Print("".to_owned()),
228                CapturedOut::Print("    Third.  The. end.".to_owned()),
229            ],
230            console.captured_out()
231        );
232    }
233}