Skip to main content

cli_justify/
wrap.rs

1use crate::text_utils::{
2  char_len, drop_one_leading_whitespace, leading_whitespace, split_at_char,
3  split_at_last_whitespace_before,
4};
5
6pub(crate) fn wrap_line_preserving_whitespace(
7  line: &str,
8  line_width: usize,
9) -> Vec<String> {
10  if line_width == 0 {
11    return vec![String::new()];
12  }
13
14  if char_len(line) <= line_width {
15    return vec![line.to_string()];
16  }
17  if line.trim_matches([' ', '\t']).is_empty() {
18    return vec![String::new()];
19  }
20
21  let trimmed_start = line.trim_start_matches([' ', '\t']);
22  let original_indent_chars = char_len(line) - char_len(trimmed_start);
23  if !trimmed_start.is_empty() && char_len(trimmed_start) <= line_width {
24    let clamped_indent = line_width.saturating_sub(char_len(trimmed_start));
25    // Only collapse the leading whitespace when the original indent looks
26    // truly excessive (e.g. an over-indented TOC label) and clamping is
27    // strictly less than the original. Otherwise preserve the indent and
28    // let the wrapping loop split the line at word boundaries.
29    if original_indent_chars > 20 && clamped_indent < original_indent_chars {
30      return vec![format!("{}{}", " ".repeat(clamped_indent), trimmed_start)];
31    }
32  }
33
34  let indent = leading_whitespace(line).to_string();
35  let indent_chars = char_len(&indent);
36  let max_continuation_indent =
37    line_width.saturating_sub(8).min(32).min(indent_chars);
38  let continuation_indent = " ".repeat(max_continuation_indent);
39  let continuation_indent_chars = max_continuation_indent;
40  let mut out = Vec::new();
41
42  let mut remainder = line;
43  let mut is_first = true;
44
45  loop {
46    let available = if is_first {
47      line_width
48    } else {
49      line_width.saturating_sub(continuation_indent_chars)
50    };
51
52    if available == 0 {
53      let (w1, w2) = split_at_char(remainder, 1);
54      out.push(if is_first {
55        w1.to_string()
56      } else {
57        format!("{continuation_indent}{w1}")
58      });
59      remainder = w2.unwrap_or("");
60      if remainder.is_empty() {
61        break;
62      }
63      is_first = false;
64      continue;
65    }
66
67    if char_len(remainder) <= available {
68      out.push(if is_first {
69        remainder.to_string()
70      } else {
71        format!("{continuation_indent}{remainder}")
72      });
73      break;
74    }
75
76    let split_byte_idx = split_at_last_whitespace_before(remainder, available)
77      .unwrap_or_else(|| {
78        let (w1, _) = split_at_char(remainder, available);
79        w1.len()
80      });
81
82    let (chunk, rest) = remainder.split_at(split_byte_idx);
83    let chunk_without_trailing_ws = chunk.trim_end_matches([' ', '\t']);
84    if chunk_without_trailing_ws.is_empty() {
85      if is_first {
86        let trimmed_remainder = remainder.trim_start_matches([' ', '\t']);
87        if !trimmed_remainder.is_empty()
88          && char_len(trimmed_remainder) <= line_width
89        {
90          let right_aligned_indent =
91            line_width.saturating_sub(char_len(trimmed_remainder));
92          out.push(format!(
93            "{}{}",
94            " ".repeat(right_aligned_indent),
95            trimmed_remainder
96          ));
97          break;
98        }
99      }
100
101      remainder = rest.trim_start_matches([' ', '\t']);
102      is_first = false;
103      if remainder.is_empty() {
104        out.push(String::new());
105        break;
106      }
107      continue;
108    }
109
110    out.push(if is_first {
111      chunk_without_trailing_ws.to_string()
112    } else {
113      format!("{continuation_indent}{chunk_without_trailing_ws}")
114    });
115
116    remainder = drop_one_leading_whitespace(rest);
117    is_first = false;
118
119    if remainder.is_empty() {
120      break;
121    }
122  }
123
124  out
125}
126
127pub fn wrap_preserve_whitespace(text: &str, line_width: usize) -> Vec<String> {
128  let mut out = Vec::new();
129  for line in text.split('\n') {
130    if line.is_empty() {
131      out.push(String::new());
132      continue;
133    }
134    out.extend(wrap_line_preserving_whitespace(line, line_width));
135  }
136  out
137}
138
139#[cfg(test)]
140mod tests {
141  use super::wrap_preserve_whitespace;
142  use crate::text_utils::char_len;
143
144  #[test]
145  fn preserves_indentation_and_spacing() {
146    let input = "a    b    c";
147    let out = wrap_preserve_whitespace(input, 80);
148    assert_eq!(out, vec![input.to_string()]);
149
150    let input =
151      "    fn main() {    println!(\"hi\");    println!(\"there\"); }";
152    let out = wrap_preserve_whitespace(input, 25);
153    assert!(out.len() > 1);
154    assert!(out[0].starts_with("    "));
155    assert!(out[1].starts_with("    "));
156  }
157
158  #[test]
159  fn avoids_vertical_splitting_for_extreme_indentation() {
160    let input = format!("{}Contents", " ".repeat(105));
161    let out = wrap_preserve_whitespace(&input, 80);
162    assert!(out.iter().any(|line| line.contains("Contents")));
163    assert!(out.len() <= 3, "expected compact output, got: {out:?}");
164    assert!(
165      !out.iter().any(|line| line.trim() == "C"),
166      "expected no single-letter vertical split, got: {out:?}"
167    );
168    assert!(
169      out
170        .iter()
171        .filter(|line| !line.is_empty())
172        .all(|line| char_len(line) <= 80),
173      "expected wrapped lines to respect width, got: {out:?}"
174    );
175  }
176
177  #[test]
178  fn preserves_right_position_for_overindented_short_labels() {
179    let input = format!("{}Content", " ".repeat(86));
180    let out = wrap_preserve_whitespace(&input, 80);
181
182    assert_eq!(out.len(), 1, "expected single wrapped line, got: {out:?}");
183    assert_eq!(
184      char_len(&out[0]),
185      80,
186      "expected width-clamped output, got: {out:?}"
187    );
188    assert!(
189      out[0].ends_with("Content"),
190      "expected label text to be preserved, got: {out:?}"
191    );
192    let leading = out[0].chars().take_while(|&ch| ch == ' ').count();
193    assert!(
194      leading >= 70,
195      "expected right-positioned label instead of collapsed continuation indent, got: {out:?}"
196    );
197  }
198}