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    /// Prefer soft breaks after independent-clause punctuation (`,`, `;`,
17    /// `:`, em dash, `--`) when wrapping under `max_width`.
18    pub clause_breaks: bool,
19}
20
21/// Minimum region count before parallelizing reflow (large multi-MB org/md files).
22#[cfg(feature = "cli")]
23const PARALLEL_REGION_THRESHOLD: usize = 32;
24
25/// Reflow a sequence of regions, applying sentence breaks to Prose regions.
26///
27/// With the `cli` feature, files that parse into many regions (typical large
28/// Org/Markdown trees) reflow independent regions in parallel via rayon, then
29/// concatenate in order.
30pub fn reflow(
31    regions: &[Region],
32    splitter: &dyn SentenceSplitter,
33    config: &ReflowConfig,
34) -> String {
35    #[cfg(feature = "cli")]
36    {
37        if regions.len() >= PARALLEL_REGION_THRESHOLD {
38            return reflow_parallel(regions, splitter, config);
39        }
40    }
41    reflow_sequential(regions, splitter, config)
42}
43
44fn reflow_sequential(
45    regions: &[Region],
46    splitter: &dyn SentenceSplitter,
47    config: &ReflowConfig,
48) -> String {
49    let mut output = String::new();
50    for (idx, region) in regions.iter().enumerate() {
51        output.push_str(&reflow_one(region, idx, regions, splitter, config));
52    }
53    output
54}
55
56#[cfg(feature = "cli")]
57fn reflow_parallel(
58    regions: &[Region],
59    splitter: &dyn SentenceSplitter,
60    config: &ReflowConfig,
61) -> String {
62    use rayon::prelude::*;
63    // Indexed parallel map preserves order on collect.
64    let parts: Vec<String> = regions
65        .par_iter()
66        .enumerate()
67        .map(|(idx, region)| reflow_one(region, idx, regions, splitter, config))
68        .collect();
69    let mut output = String::new();
70    for p in parts {
71        output.push_str(&p);
72    }
73    output
74}
75
76fn reflow_one(
77    region: &Region,
78    idx: usize,
79    regions: &[Region],
80    splitter: &dyn SentenceSplitter,
81    config: &ReflowConfig,
82) -> String {
83    let mut output = String::new();
84    match region {
85        Region::Structure(s) => output.push_str(s),
86        Region::BlankLines(s) => output.push_str(s),
87        Region::Code {
88            lang,
89            header,
90            body,
91            footer,
92        } => {
93            output.push_str(header);
94            let code_cfg = lang
95                .as_deref()
96                .and_then(|l| config.code.and_then(|m| m.get(l)));
97            let reflowed = if let Some(cfg) = code_cfg {
98                crate::code_block::reflow_code_body(body, cfg, splitter, config.format_code)
99            } else {
100                body.clone()
101            };
102            output.push_str(&reflowed);
103            output.push_str(footer);
104        }
105        Region::Prose(text) => {
106            let sentences = splitter.split(text);
107            for (i, sentence) in sentences.iter().enumerate() {
108                if config.max_width > 0 {
109                    let wrapped = if config.clause_breaks {
110                        wrap_with_clause_breaks(sentence, config.max_width)
111                    } else {
112                        textwrap::fill(sentence, config.max_width)
113                    };
114                    output.push_str(&wrapped);
115                } else {
116                    output.push_str(sentence);
117                }
118                if i < sentences.len() - 1 {
119                    output.push('\n');
120                }
121            }
122            if !sentences.is_empty() {
123                // No forced paragraph break before inline islands (math/code) or
124                // tight punctuation structures — those continue the same line.
125                let suppress = matches!(
126                    regions.get(idx + 1),
127                    Some(Region::Structure(s)) if suppress_prose_trailing_newline(s)
128                );
129                if !suppress {
130                    output.push('\n');
131                }
132            }
133        }
134    }
135    output
136}
137
138/// True when `word` ends with independent-clause punctuation (sembr rule 5),
139/// ignoring trailing closing quotes and brackets. Words come from whitespace
140/// splitting, so a match always marks a lossless break site: the punctuation
141/// is followed by real whitespace in the source.
142fn ends_with_clause_punct(word: &str) -> bool {
143    let core = word.trim_end_matches(['"', '\'', ')', ']', '}']);
144    core.ends_with(',')
145        || core.ends_with(';')
146        || core.ends_with(':')
147        || core.ends_with('\u{2014}') // em dash —
148        || core.ends_with("--")
149}
150
151/// Wrap `sentence` under `max_width`, preferring breaks after clause
152/// punctuation (sembr rule 5). A sentence that already fits stays on one
153/// line. Breaks only ever land at whitespace, so tokens like `1,000`,
154/// `10:30`, URLs, and `--flags` are never split apart.
155pub fn wrap_with_clause_breaks(sentence: &str, max_width: usize) -> String {
156    if max_width == 0 {
157        return sentence.to_string();
158    }
159    wrap_words_preferring_clause(sentence, max_width).join("\n")
160}
161
162/// Greedy word wrap that, when forced to break, prefers the last word on the
163/// line that ends with clause punctuation; otherwise breaks at the last word
164/// boundary that fits.
165fn wrap_words_preferring_clause(text: &str, max_width: usize) -> Vec<String> {
166    let words: Vec<&str> = text.split_whitespace().collect();
167    if words.is_empty() {
168        return Vec::new();
169    }
170    let mut lines = Vec::new();
171    let mut start = 0;
172    while start < words.len() {
173        let mut end = start;
174        let mut line_len = 0usize;
175        while end < words.len() {
176            let wlen = words[end].chars().count();
177            let next_len = if end == start {
178                wlen
179            } else {
180                line_len + 1 + wlen
181            };
182            if next_len > max_width && end > start {
183                break;
184            }
185            line_len = next_len;
186            end += 1;
187            // Single overlong word: take it alone
188            if end == start + 1 && line_len > max_width {
189                break;
190            }
191        }
192        // Only a forced break gets pulled back to a clause boundary; the
193        // final line of a sentence keeps its remaining words together.
194        let mut break_at = end;
195        if end < words.len() {
196            for j in (start..end).rev() {
197                if ends_with_clause_punct(words[j]) {
198                    break_at = j + 1;
199                    break;
200                }
201            }
202        }
203        lines.push(words[start..break_at].join(" "));
204        start = break_at;
205    }
206    lines
207}
208
209/// When the next region is an inline structure island (pandoc `Math`/`Code` as
210/// Structure), do not end the preceding prose with a hard line break.
211fn suppress_prose_trailing_newline(s: &str) -> bool {
212    if s == "\n" || s.starts_with('}') || s.starts_with(']') || s.starts_with(')') {
213        return true;
214    }
215    // Islands may carry a leading space for glue after reflow trims prose.
216    let t = s.trim();
217    // Inline math: single-line `$...$` (not display `$$...$$`).
218    if t.starts_with('$') && !t.starts_with("$$") && !t.contains('\n') {
219        return true;
220    }
221    // Inline code island: single-line `...` (optional trailing space already trimmed).
222    let code = t.trim_end_matches(' ');
223    if code.starts_with('`') && code.ends_with('`') && code.len() >= 2 && !code.contains('\n') {
224        return true;
225    }
226    false
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::sentence::unicode::UnicodeSentenceSplitter;
233
234    fn reflow_text(input: &str) -> String {
235        let regions = vec![Region::Prose(input.to_string())];
236        let config = ReflowConfig::default();
237        reflow(&regions, &UnicodeSentenceSplitter::new(), &config)
238    }
239
240    #[test]
241    fn simple_reflow() {
242        let result = reflow_text("Hello world. This is a test. Another sentence.");
243        assert_eq!(result, "Hello world.\nThis is a test.\nAnother sentence.\n");
244    }
245
246    #[test]
247    fn idempotent() {
248        let input = "Hello world.\nThis is a test.\nAnother sentence.";
249        let first = reflow_text(input);
250        let second = reflow_text(&first);
251        assert_eq!(first, second, "reflow must be idempotent");
252    }
253
254    #[test]
255    fn preserves_structure() {
256        let regions = vec![
257            Region::Structure("#+TITLE: Test\n".to_string()),
258            Region::BlankLines("\n".to_string()),
259            Region::Prose("First sentence. Second sentence.".to_string()),
260        ];
261        let config = ReflowConfig::default();
262        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
263        assert_eq!(
264            result,
265            "#+TITLE: Test\n\nFirst sentence.\nSecond sentence.\n"
266        );
267    }
268
269    #[test]
270    fn max_width_wrapping() {
271        let regions = vec![Region::Prose(
272            "This is a very long sentence that should be wrapped at a reasonable width for readability in narrow terminals.".to_string(),
273        )];
274        let config = ReflowConfig {
275            max_width: 40,
276            ..Default::default()
277        };
278        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
279        // Every line should be <= 40 chars
280        for line in result.lines() {
281            assert!(
282                line.len() <= 40,
283                "Line too long: {} chars: {:?}",
284                line.len(),
285                line
286            );
287        }
288    }
289
290    #[test]
291    fn clause_breaks_prefer_commas_under_max_width() {
292        // Issue #7 sample: max_width=80 with clause breaks should land soft
293        // breaks after the independent-clause commas rather than packing
294        // mid-phrase as plain textwrap::fill does.
295        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
296        let wrapped = wrap_with_clause_breaks(sentence, 80);
297        let expected = "\
298It contains rules which govern how the Objectives are orchestrated,
299along with rules which can automatically activate the Objectives in the plan,
300without additional human intervention.";
301        assert_eq!(
302            wrapped, expected,
303            "clause-first wrap:\n--- got ---\n{wrapped}\n--- expected ---\n{expected}"
304        );
305        for line in wrapped.lines() {
306            assert!(
307                line.chars().count() <= 80,
308                "line exceeds max_width: {line:?}"
309            );
310        }
311    }
312
313    #[test]
314    fn clause_breaks_off_matches_textwrap_fill() {
315        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
316        let regions = vec![Region::Prose(sentence.to_string())];
317        let config = ReflowConfig {
318            max_width: 80,
319            clause_breaks: false,
320            ..Default::default()
321        };
322        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
323        let plain = format!("{}\n", textwrap::fill(sentence, 80));
324        assert_eq!(result, plain);
325        // And that plain fill is *not* the clause-first shape
326        assert!(
327            result.contains("orchestrated, along with\n"),
328            "control path still packs past the first comma: {result:?}"
329        );
330    }
331
332    #[test]
333    fn clause_breaks_via_reflow_config() {
334        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
335        let regions = vec![Region::Prose(sentence.to_string())];
336        let config = ReflowConfig {
337            max_width: 80,
338            clause_breaks: true,
339            ..Default::default()
340        };
341        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
342        assert!(
343            result.contains("orchestrated,\nalong with"),
344            "reflow with clause_breaks must break after first comma: {result:?}"
345        );
346        assert!(
347            result.contains("plan,\nwithout"),
348            "reflow with clause_breaks must break after second comma: {result:?}"
349        );
350    }
351
352    #[test]
353    fn clause_breaks_handles_semicolon_colon_emdash() {
354        let s = "First clause; second clause: third clause — fourth clause.";
355        // Fits under the limit: no break is forced, the sentence stays whole.
356        assert_eq!(wrap_with_clause_breaks(s, 80), s);
357        // Forced under a tight limit: every break lands after clause punctuation.
358        assert_eq!(
359            wrap_with_clause_breaks(s, 20),
360            "First clause;\nsecond clause:\nthird clause —\nfourth clause."
361        );
362    }
363
364    #[test]
365    fn clause_breaks_leave_fitting_sentences_alone() {
366        let regions = vec![Region::Prose(
367            "Hello, world. Short, sweet, and done.".to_string(),
368        )];
369        let config = ReflowConfig {
370            max_width: 80,
371            clause_breaks: true,
372            ..Default::default()
373        };
374        let result = reflow(&regions, &UnicodeSentenceSplitter::new(), &config);
375        assert_eq!(result, "Hello, world.\nShort, sweet, and done.\n");
376    }
377
378    #[test]
379    fn clause_breaks_never_split_inside_tokens() {
380        // Clause punctuation not followed by whitespace stays inside its
381        // token; a break there would render as an inserted space.
382        let s = "Totals reached 1,000,000 by 10:30 via https://example.com/a,b using --clause-breaks and rock—paper logic in a sentence long enough to need wrapping.";
383        let wrapped = wrap_with_clause_breaks(s, 30);
384        let rejoined: Vec<&str> = wrapped.split_whitespace().collect();
385        let original: Vec<&str> = s.split_whitespace().collect();
386        assert_eq!(rejoined, original, "wrapping must be lossless: {wrapped:?}");
387        for token in [
388            "1,000,000",
389            "10:30",
390            "https://example.com/a,b",
391            "--clause-breaks",
392            "rock—paper",
393        ] {
394            assert!(
395                wrapped.lines().any(|l| l.contains(token)),
396                "{token:?} must stay on a single line: {wrapped:?}"
397            );
398        }
399    }
400
401    #[test]
402    fn clause_breaks_idempotent() {
403        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
404        let config = ReflowConfig {
405            max_width: 80,
406            clause_breaks: true,
407            ..Default::default()
408        };
409        let splitter = UnicodeSentenceSplitter::new();
410        let first = reflow(&[Region::Prose(sentence.to_string())], &splitter, &config);
411        let second = reflow(
412            &[Region::Prose(first.trim_end().to_string())],
413            &splitter,
414            &config,
415        );
416        assert_eq!(first, second, "clause-break reflow must be idempotent");
417    }
418
419    #[test]
420    fn long_clause_still_word_wraps() {
421        let long = "This is a deliberately long independent clause without internal punctuation that must still wrap under a tight max width constraint for the test.";
422        let wrapped = wrap_with_clause_breaks(long, 40);
423        for line in wrapped.lines() {
424            assert!(
425                line.chars().count() <= 40,
426                "overlong clause must still wrap: {line:?}"
427            );
428        }
429        assert!(wrapped.contains('\n'));
430    }
431}