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/// Minimum region count before parallelizing reflow (large multi-MB org/md files).
19#[cfg(feature = "cli")]
20const PARALLEL_REGION_THRESHOLD: usize = 32;
21
22/// Reflow a sequence of regions, applying sentence breaks to Prose regions.
23///
24/// With the `cli` feature, files that parse into many regions (typical large
25/// Org/Markdown trees) reflow independent regions in parallel via rayon, then
26/// concatenate in order.
27pub fn reflow(
28    regions: &[Region],
29    splitter: &dyn SentenceSplitter,
30    config: &ReflowConfig,
31) -> String {
32    #[cfg(feature = "cli")]
33    {
34        if regions.len() >= PARALLEL_REGION_THRESHOLD {
35            return reflow_parallel(regions, splitter, config);
36        }
37    }
38    reflow_sequential(regions, splitter, config)
39}
40
41fn reflow_sequential(
42    regions: &[Region],
43    splitter: &dyn SentenceSplitter,
44    config: &ReflowConfig,
45) -> String {
46    let mut output = String::new();
47    for (idx, region) in regions.iter().enumerate() {
48        output.push_str(&reflow_one(region, idx, regions, splitter, config));
49    }
50    output
51}
52
53#[cfg(feature = "cli")]
54fn reflow_parallel(
55    regions: &[Region],
56    splitter: &dyn SentenceSplitter,
57    config: &ReflowConfig,
58) -> String {
59    use rayon::prelude::*;
60    // Indexed parallel map preserves order on collect.
61    let parts: Vec<String> = regions
62        .par_iter()
63        .enumerate()
64        .map(|(idx, region)| reflow_one(region, idx, regions, splitter, config))
65        .collect();
66    let mut output = String::new();
67    for p in parts {
68        output.push_str(&p);
69    }
70    output
71}
72
73fn reflow_one(
74    region: &Region,
75    idx: usize,
76    regions: &[Region],
77    splitter: &dyn SentenceSplitter,
78    config: &ReflowConfig,
79) -> String {
80    let mut output = String::new();
81    match region {
82        Region::Structure(s) => output.push_str(s),
83        Region::BlankLines(s) => output.push_str(s),
84        Region::Code {
85            lang,
86            header,
87            body,
88            footer,
89        } => {
90            output.push_str(header);
91            let code_cfg = lang
92                .as_deref()
93                .and_then(|l| config.code.and_then(|m| m.get(l)));
94            let reflowed = if let Some(cfg) = code_cfg {
95                crate::code_block::reflow_code_body(body, cfg, splitter, config.format_code)
96            } else {
97                body.clone()
98            };
99            output.push_str(&reflowed);
100            output.push_str(footer);
101        }
102        Region::Prose(text) => {
103            let sentences = splitter.split(text);
104            for (i, sentence) in sentences.iter().enumerate() {
105                if config.max_width > 0 {
106                    let wrapped = textwrap::fill(sentence, config.max_width);
107                    output.push_str(&wrapped);
108                } else {
109                    output.push_str(sentence);
110                }
111                if i < sentences.len() - 1 {
112                    output.push('\n');
113                }
114            }
115            if !sentences.is_empty() {
116                let suppress = matches!(
117                    regions.get(idx + 1),
118                    Some(Region::Structure(s))
119                        if s == "\n"
120                            || s.starts_with('}')
121                            || s.starts_with(']')
122                            || s.starts_with(')')
123                );
124                if !suppress {
125                    output.push('\n');
126                }
127            }
128        }
129    }
130    output
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::sentence::unicode::UnicodeSentenceSplitter;
137
138    fn reflow_text(input: &str) -> String {
139        let regions = vec![Region::Prose(input.to_string())];
140        let config = ReflowConfig::default();
141        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
142    }
143
144    #[test]
145    fn simple_reflow() {
146        let result = reflow_text("Hello world. This is a test. Another sentence.");
147        assert_eq!(result, "Hello world.\nThis is a test.\nAnother sentence.\n");
148    }
149
150    #[test]
151    fn idempotent() {
152        let input = "Hello world.\nThis is a test.\nAnother sentence.";
153        let first = reflow_text(input);
154        let second = reflow_text(&first);
155        assert_eq!(first, second, "reflow must be idempotent");
156    }
157
158    #[test]
159    fn preserves_structure() {
160        let regions = vec![
161            Region::Structure("#+TITLE: Test\n".to_string()),
162            Region::BlankLines("\n".to_string()),
163            Region::Prose("First sentence. Second sentence.".to_string()),
164        ];
165        let config = ReflowConfig::default();
166        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
167        assert_eq!(
168            result,
169            "#+TITLE: Test\n\nFirst sentence.\nSecond sentence.\n"
170        );
171    }
172
173    #[test]
174    fn max_width_wrapping() {
175        let regions = vec![Region::Prose(
176            "This is a very long sentence that should be wrapped at a reasonable width for readability in narrow terminals.".to_string(),
177        )];
178        let config = ReflowConfig {
179            max_width: 40,
180            ..Default::default()
181        };
182        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
183        // Every line should be <= 40 chars
184        for line in result.lines() {
185            assert!(
186                line.len() <= 40,
187                "Line too long: {} chars: {:?}",
188                line.len(),
189                line
190            );
191        }
192    }
193}