Skip to main content

snapper_fmt/
reflow.rs

1use crate::parser::Region;
2use crate::sentence::SentenceSplitter;
3
4/// Configuration for the reflow engine.
5pub struct ReflowConfig {
6    /// Maximum line width. 0 means unlimited.
7    pub max_width: usize,
8}
9
10/// Reflow a sequence of regions, applying sentence breaks to Prose regions.
11pub fn reflow(
12    regions: &[Region],
13    splitter: &dyn SentenceSplitter,
14    config: &ReflowConfig,
15) -> String {
16    let mut output = String::new();
17
18    for (idx, region) in regions.iter().enumerate() {
19        match region {
20            Region::Structure(s) => output.push_str(s),
21            Region::BlankLines(s) => output.push_str(s),
22            Region::Prose(text) => {
23                let sentences = splitter.split(text);
24                for (i, sentence) in sentences.iter().enumerate() {
25                    if config.max_width > 0 {
26                        let wrapped = textwrap::fill(sentence, config.max_width);
27                        output.push_str(&wrapped);
28                    } else {
29                        output.push_str(sentence);
30                    }
31                    if i < sentences.len() - 1 {
32                        output.push('\n');
33                    }
34                }
35                // Add trailing newline when followed by BlankLines or
36                // another Prose region, so paragraph breaks are preserved.
37                // Skip when followed by Structure (e.g. the "\n" after
38                // headlines/list items) to avoid double newlines.
39                if !sentences.is_empty() {
40                    // Add trailing newline after prose. Only suppress when
41                    // the next region is a bare "\n" (inline Structure from
42                    // headlines/list items) to avoid double newlines.
43                    let suppress = matches!(
44                        regions.get(idx + 1),
45                        Some(Region::Structure(s)) if s == "\n"
46                    );
47                    if !suppress {
48                        output.push('\n');
49                    }
50                }
51            }
52        }
53    }
54
55    output
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::sentence::unicode::UnicodeSentenceSplitter;
62
63    fn reflow_text(input: &str) -> String {
64        let regions = vec![Region::Prose(input.to_string())];
65        let config = ReflowConfig { max_width: 0 };
66        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
67    }
68
69    #[test]
70    fn simple_reflow() {
71        let result = reflow_text("Hello world. This is a test. Another sentence.");
72        assert_eq!(result, "Hello world.\nThis is a test.\nAnother sentence.\n");
73    }
74
75    #[test]
76    fn idempotent() {
77        let input = "Hello world.\nThis is a test.\nAnother sentence.";
78        let first = reflow_text(input);
79        let second = reflow_text(&first);
80        assert_eq!(first, second, "reflow must be idempotent");
81    }
82
83    #[test]
84    fn preserves_structure() {
85        let regions = vec![
86            Region::Structure("#+TITLE: Test\n".to_string()),
87            Region::BlankLines("\n".to_string()),
88            Region::Prose("First sentence. Second sentence.".to_string()),
89        ];
90        let config = ReflowConfig { max_width: 0 };
91        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
92        assert_eq!(
93            result,
94            "#+TITLE: Test\n\nFirst sentence.\nSecond sentence.\n"
95        );
96    }
97
98    #[test]
99    fn max_width_wrapping() {
100        let regions = vec![Region::Prose(
101            "This is a very long sentence that should be wrapped at a reasonable width for readability in narrow terminals.".to_string(),
102        )];
103        let config = ReflowConfig { max_width: 40 };
104        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
105        // Every line should be <= 40 chars
106        for line in result.lines() {
107            assert!(
108                line.len() <= 40,
109                "Line too long: {} chars: {:?}",
110                line.len(),
111                line
112            );
113        }
114    }
115}