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
18#[cfg(feature = "cli")]
20const PARALLEL_REGION_THRESHOLD: usize = 32;
21
22pub 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 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!(
119 regions.get(idx + 1),
120 Some(Region::Structure(s)) if suppress_prose_trailing_newline(s)
121 );
122 if !suppress {
123 output.push('\n');
124 }
125 }
126 }
127 }
128 output
129}
130
131fn suppress_prose_trailing_newline(s: &str) -> bool {
134 if s == "\n" || s.starts_with('}') || s.starts_with(']') || s.starts_with(')') {
135 return true;
136 }
137 let t = s.trim();
139 if t.starts_with('$') && !t.starts_with("$$") && !t.contains('\n') {
141 return true;
142 }
143 let code = t.trim_end_matches(' ');
145 if code.starts_with('`') && code.ends_with('`') && code.len() >= 2 && !code.contains('\n') {
146 return true;
147 }
148 false
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::sentence::unicode::UnicodeSentenceSplitter;
155
156 fn reflow_text(input: &str) -> String {
157 let regions = vec![Region::Prose(input.to_string())];
158 let config = ReflowConfig::default();
159 reflow(®ions, &UnicodeSentenceSplitter::new(), &config)
160 }
161
162 #[test]
163 fn simple_reflow() {
164 let result = reflow_text("Hello world. This is a test. Another sentence.");
165 assert_eq!(result, "Hello world.\nThis is a test.\nAnother sentence.\n");
166 }
167
168 #[test]
169 fn idempotent() {
170 let input = "Hello world.\nThis is a test.\nAnother sentence.";
171 let first = reflow_text(input);
172 let second = reflow_text(&first);
173 assert_eq!(first, second, "reflow must be idempotent");
174 }
175
176 #[test]
177 fn preserves_structure() {
178 let regions = vec![
179 Region::Structure("#+TITLE: Test\n".to_string()),
180 Region::BlankLines("\n".to_string()),
181 Region::Prose("First sentence. Second sentence.".to_string()),
182 ];
183 let config = ReflowConfig::default();
184 let result = reflow(®ions, &UnicodeSentenceSplitter::new(), &config);
185 assert_eq!(
186 result,
187 "#+TITLE: Test\n\nFirst sentence.\nSecond sentence.\n"
188 );
189 }
190
191 #[test]
192 fn max_width_wrapping() {
193 let regions = vec![Region::Prose(
194 "This is a very long sentence that should be wrapped at a reasonable width for readability in narrow terminals.".to_string(),
195 )];
196 let config = ReflowConfig {
197 max_width: 40,
198 ..Default::default()
199 };
200 let result = reflow(®ions, &UnicodeSentenceSplitter::new(), &config);
201 for line in result.lines() {
203 assert!(
204 line.len() <= 40,
205 "Line too long: {} chars: {:?}",
206 line.len(),
207 line
208 );
209 }
210 }
211}