Skip to main content

snapper_fmt/
reflow.rs

1use std::collections::HashMap;
2
3use crate::config::CodeLang;
4use crate::parser::Region;
5use crate::sentence::SentenceSplitter;
6
7/// Configuration for the reflow engine.
8#[derive(Default)]
9pub struct ReflowConfig<'a> {
10    /// Maximum line width. 0 means unlimited.
11    pub max_width: usize,
12    /// Per-language code-block configuration (borrowed from `FormatConfig`).
13    pub code: Option<&'a HashMap<String, CodeLang>>,
14    /// When `true`, the per-language `formatter` runs after comment reflow.
15    pub format_code: bool,
16}
17
18/// Reflow a sequence of regions, applying sentence breaks to Prose regions.
19pub fn reflow(
20    regions: &[Region],
21    splitter: &dyn SentenceSplitter,
22    config: &ReflowConfig,
23) -> String {
24    let mut output = String::new();
25
26    for (idx, region) in regions.iter().enumerate() {
27        match region {
28            Region::Structure(s) => output.push_str(s),
29            Region::BlankLines(s) => output.push_str(s),
30            Region::Code {
31                lang,
32                header,
33                body,
34                footer,
35            } => {
36                output.push_str(header);
37                // Look up the language config; absent entries (or `lang=None`)
38                // mean the body passes through unchanged.
39                let code_cfg = lang
40                    .as_deref()
41                    .and_then(|l| config.code.and_then(|m| m.get(l)));
42                let reflowed = if let Some(cfg) = code_cfg {
43                    crate::code_block::reflow_code_body(body, cfg, splitter, config.format_code)
44                } else {
45                    body.clone()
46                };
47                output.push_str(&reflowed);
48                output.push_str(footer);
49            }
50            Region::Prose(text) => {
51                let sentences = splitter.split(text);
52                for (i, sentence) in sentences.iter().enumerate() {
53                    if config.max_width > 0 {
54                        let wrapped = textwrap::fill(sentence, config.max_width);
55                        output.push_str(&wrapped);
56                    } else {
57                        output.push_str(sentence);
58                    }
59                    if i < sentences.len() - 1 {
60                        output.push('\n');
61                    }
62                }
63                // Add trailing newline when followed by BlankLines or
64                // another Prose region, so paragraph breaks are preserved.
65                // Skip when followed by Structure (e.g. the "\n" after
66                // headlines/list items) to avoid double newlines.
67                if !sentences.is_empty() {
68                    // Add trailing newline after prose. Suppress when the next
69                    // region continues the same line: a bare "\n" (headline /
70                    // list item terminator) or a closing brace/bracket suffix
71                    // from LaTeX sectioning commands (`}\n`).
72                    let suppress = matches!(
73                        regions.get(idx + 1),
74                        Some(Region::Structure(s))
75                            if s == "\n"
76                                || s.starts_with('}')
77                                || s.starts_with(']')
78                                || s.starts_with(')')
79                    );
80                    if !suppress {
81                        output.push('\n');
82                    }
83                }
84            }
85        }
86    }
87
88    output
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::sentence::unicode::UnicodeSentenceSplitter;
95
96    fn reflow_text(input: &str) -> String {
97        let regions = vec![Region::Prose(input.to_string())];
98        let config = ReflowConfig::default();
99        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
100    }
101
102    #[test]
103    fn simple_reflow() {
104        let result = reflow_text("Hello world. This is a test. Another sentence.");
105        assert_eq!(result, "Hello world.\nThis is a test.\nAnother sentence.\n");
106    }
107
108    #[test]
109    fn idempotent() {
110        let input = "Hello world.\nThis is a test.\nAnother sentence.";
111        let first = reflow_text(input);
112        let second = reflow_text(&first);
113        assert_eq!(first, second, "reflow must be idempotent");
114    }
115
116    #[test]
117    fn preserves_structure() {
118        let regions = vec![
119            Region::Structure("#+TITLE: Test\n".to_string()),
120            Region::BlankLines("\n".to_string()),
121            Region::Prose("First sentence. Second sentence.".to_string()),
122        ];
123        let config = ReflowConfig::default();
124        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
125        assert_eq!(
126            result,
127            "#+TITLE: Test\n\nFirst sentence.\nSecond sentence.\n"
128        );
129    }
130
131    #[test]
132    fn max_width_wrapping() {
133        let regions = vec![Region::Prose(
134            "This is a very long sentence that should be wrapped at a reasonable width for readability in narrow terminals.".to_string(),
135        )];
136        let config = ReflowConfig {
137            max_width: 40,
138            ..Default::default()
139        };
140        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
141        // Every line should be <= 40 chars
142        for line in result.lines() {
143            assert!(
144                line.len() <= 40,
145                "Line too long: {} chars: {:?}",
146                line.len(),
147                line
148            );
149        }
150    }
151}