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 pub clause_breaks: bool,
19}
20
21#[cfg(feature = "cli")]
23const PARALLEL_REGION_THRESHOLD: usize = 32;
24
25pub 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 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 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
138fn 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}') || core.ends_with("--")
149}
150
151pub 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
162fn 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 if end == start + 1 && line_len > max_width {
189 break;
190 }
191 }
192 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
209fn 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 let t = s.trim();
217 if t.starts_with('$') && !t.starts_with("$$") && !t.contains('\n') {
219 return true;
220 }
221 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(®ions, &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(®ions, &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(®ions, &UnicodeSentenceSplitter::new(), &config);
279 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 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(®ions, &UnicodeSentenceSplitter::new(), &config);
323 let plain = format!("{}\n", textwrap::fill(sentence, 80));
324 assert_eq!(result, plain);
325 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(®ions, &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 assert_eq!(wrap_with_clause_breaks(s, 80), s);
357 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(®ions, &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 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}