1use std::collections::HashMap;
2
3use crate::config::CodeLang;
4use crate::parser::Region;
5use crate::sentence::SentenceSplitter;
6
7#[derive(Default)]
9pub struct ReflowConfig<'a> {
10 pub max_width: usize,
12 pub code: Option<&'a HashMap<String, CodeLang>>,
14 pub format_code: bool,
16}
17
18pub 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 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 if !sentences.is_empty() {
68 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(®ions, &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(®ions, &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(®ions, &UnicodeSentenceSplitter::new(), &config);
141 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}