Skip to content

Commit 3defb03

Browse files
author
Vince Mutolo
committed
add reflow command
Users need to be able to hard-wrap text for many applications, including comments in code, git commit messages, plaintext documentation, etc. It often falls to the user to manually insert line breaks where appropriate in order to hard-wrap text. This commit introduces the "reflow" command (both in the TUI and core library) to automatically hard-wrap selected text to a given number of characters (defined by Unicode "extended grapheme clusters"). It handles lines with a repeated prefix, such as comments ("//") and indentation.
1 parent dc8fef5 commit 3defb03

File tree

3 files changed

+392
-0
lines changed

3 files changed

+392
-0
lines changed

helix-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub mod syntax;
2727
pub mod test;
2828
pub mod textobject;
2929
mod transaction;
30+
pub mod wrap;
3031

3132
pub mod unicode {
3233
pub use unicode_general_category as category;

helix-core/src/wrap.rs

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
use ropey::RopeSlice;
2+
3+
/// Given a slice of text, return the text re-wrapped to fit
4+
/// it within the given width.
5+
pub fn reflow_hard_wrap(text: RopeSlice, max_line_len: usize) -> String {
6+
// TODO: We should handle CRLF line endings.
7+
8+
let text = String::from(text);
9+
let prefix = detect_prefix(&text);
10+
11+
// The current algorithm eats a single trailing newline character. This
12+
// is a hacky way to put it back at the end. Ideally there would be a
13+
// cleaner way to do this. It also happens to matter a lot because Helix
14+
// currently selects lines *including* the trailing newline.
15+
let ends_with_newline = text.chars().rev().next() == Some('\n');
16+
17+
let flow_pieces = separate_flow_pieces(&text, &prefix).into_iter().peekable();
18+
19+
let mut new_flow = String::new();
20+
let mut current_line_len: usize = 0;
21+
22+
for piece in flow_pieces {
23+
match piece {
24+
Piece::NoReflow(piece) => {
25+
new_flow.push('\n');
26+
new_flow.push_str(piece);
27+
current_line_len = 0;
28+
continue;
29+
}
30+
Piece::Reflow(piece) => {
31+
use unicode_segmentation::UnicodeSegmentation;
32+
let piece_len = UnicodeSegmentation::graphemes(piece, true).count();
33+
34+
let piece_will_fit = current_line_len + piece_len <= max_line_len;
35+
if !piece_will_fit && !new_flow.is_empty() {
36+
new_flow.push('\n');
37+
current_line_len = 0;
38+
}
39+
40+
if current_line_len == 0 {
41+
new_flow.push_str(&prefix);
42+
}
43+
44+
new_flow.push_str(piece);
45+
46+
current_line_len += piece_len;
47+
}
48+
}
49+
}
50+
51+
if ends_with_newline {
52+
new_flow.push('\n');
53+
}
54+
55+
new_flow
56+
}
57+
58+
#[cfg_attr(test, derive(Debug, PartialEq))]
59+
enum Piece<'a> {
60+
Reflow(&'a str),
61+
NoReflow(&'a str),
62+
}
63+
64+
fn detect_prefix(text: &str) -> String {
65+
// TODO: We should detect the configured comment prefixes here.
66+
67+
if text.is_empty() {
68+
return String::new();
69+
}
70+
71+
// For now, we'll only detect the prefix by the string (possibly empty)
72+
// that is present at the beginning of every selected line, excluding
73+
// lines that are entirely blank.
74+
75+
// UNWRAP: This is OK because we already checked to see if the string is empty.
76+
let first_line = text.lines().next().unwrap();
77+
text.lines()
78+
// TODO(decide): Should `is_whitespace` be `is_ascii_whitespace` in this next line?
79+
.filter(|line| !line.chars().all(|ch| ch.is_whitespace()))
80+
.fold(first_line, |prefix, line| {
81+
// If the prefix is already empty, we won't bother checking what
82+
// portion of it matches with the current line.
83+
if prefix.is_empty() {
84+
return prefix;
85+
}
86+
87+
let matched_until_excl: usize = prefix
88+
.chars()
89+
.enumerate()
90+
.zip(line.chars())
91+
.take_while(|((_prefix_char_idx, prefix_char), line_char)| {
92+
// TODO(decide): Do we want to relax the restriction that the "prefix"
93+
// for these lines must be entirely non-ascii-alphanumeric?
94+
prefix_char == line_char && !prefix_char.is_ascii_alphanumeric()
95+
})
96+
// add 1 here to be "one past the end" of the prefix
97+
.map(|((prefix_char_idx, _), _)| prefix_char_idx + 1)
98+
.last()
99+
.unwrap_or(0);
100+
101+
prefix.get(..matched_until_excl).unwrap()
102+
})
103+
.into()
104+
}
105+
106+
// PANIC: This function may panic if the `prefix` does not begin every non-
107+
// blank line, where "blank" here means a line that has non-whitespace
108+
// characters *after* the prefix. A line with only the prefix and whitespace
109+
// characters is considered blank.
110+
fn separate_flow_pieces<'a>(text: &'a str, prefix: &str) -> Vec<Piece<'a>> {
111+
use separated::Separated;
112+
113+
let mut flow_pieces = Vec::<Piece>::new();
114+
let lines = Separated::new(text, "\n");
115+
for line in lines {
116+
match line.get(..prefix.len()) {
117+
Some(possible_prefix) if possible_prefix == prefix => {
118+
// If the rest of the line is whitespace, we count it as an
119+
// effectively "blank" line, and we don't want to reflow it.
120+
if line[prefix.len()..].chars().all(char::is_whitespace) {
121+
flow_pieces.push(Piece::NoReflow(line));
122+
} else {
123+
// UNWRAP: The `detect_prefix` function should ensure that this
124+
// unwrap is valid. The "prefix" should begin every line except
125+
// for entirely blank lines.
126+
let line_no_prefix = line.get(prefix.len()..).unwrap();
127+
let new_pieces = Separated::new(line_no_prefix, " ")
128+
.map(|s| Piece::Reflow(s.trim_end_matches('\n').trim_end_matches('\r')));
129+
flow_pieces.extend(new_pieces);
130+
}
131+
}
132+
_ => {
133+
assert!(
134+
line.chars().all(char::is_whitespace),
135+
"lines not matching the prefix should be blank"
136+
);
137+
138+
flow_pieces.push(Piece::NoReflow(line));
139+
}
140+
}
141+
}
142+
143+
flow_pieces
144+
}
145+
146+
mod separated {
147+
// Like the std::str::Lines iterator except it doesn't eat the newline characters.
148+
pub(crate) struct Separated<'a, 'b> {
149+
s: &'a str,
150+
pattern: &'b str,
151+
beg: usize,
152+
}
153+
154+
impl<'a, 'b> Separated<'a, 'b> {
155+
pub(crate) fn new(s: &'a str, pattern: &'b str) -> Self {
156+
Self { s, pattern, beg: 0 }
157+
}
158+
}
159+
160+
impl<'a, 'b> Iterator for Separated<'a, 'b> {
161+
type Item = &'a str;
162+
163+
fn next(&mut self) -> Option<Self::Item> {
164+
let rest = self.s.get(self.beg..)?;
165+
166+
if rest.is_empty() {
167+
return None;
168+
}
169+
170+
let end = rest
171+
.find(self.pattern)
172+
.unwrap_or_else(|| rest.len().saturating_sub(1));
173+
174+
self.beg += end + 1;
175+
Some(&rest[..=end])
176+
}
177+
}
178+
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
use ropey::Rope;
184+
185+
#[test]
186+
fn reflow_basic_to_one_line() {
187+
let text = "hello my name \nis helix";
188+
let text = Rope::from(text);
189+
let reflow = reflow_hard_wrap(text.slice(..), 100);
190+
assert_eq!(reflow, "hello my name is helix");
191+
}
192+
193+
#[test]
194+
fn reflow_basic_to_many_lines() {
195+
let text = "hello my name is helix";
196+
let text = Rope::from(text);
197+
let reflow = reflow_hard_wrap(text.slice(..), 10);
198+
assert_eq!(reflow, "hello my \nname is \nhelix");
199+
}
200+
201+
#[test]
202+
fn reflow_with_blank_empty_line() {
203+
let text = "hello\n\nmy name is helix";
204+
let text = Rope::from(text);
205+
let reflow = reflow_hard_wrap(text.slice(..), 10);
206+
assert_eq!(reflow, "hello\n\nmy name \nis helix");
207+
}
208+
209+
#[test]
210+
fn reflow_with_blank_whitespace_line() {
211+
let text = "hello\n \nmy name is helix";
212+
let text = Rope::from(text);
213+
let reflow = reflow_hard_wrap(text.slice(..), 10);
214+
assert_eq!(reflow, "hello\n \nmy name \nis helix");
215+
}
216+
217+
#[test]
218+
fn reflow_end_with_blank_line() {
219+
let text = "hello my name is helix\n";
220+
let text = Rope::from(text);
221+
let reflow = reflow_hard_wrap(text.slice(..), 10);
222+
assert_eq!(reflow, "hello my \nname is \nhelix\n");
223+
}
224+
225+
#[test]
226+
fn reflow_with_blank_lines_and_prefix() {
227+
let text = " hello\n\nmy name is helix";
228+
let text = Rope::from(text);
229+
let reflow = reflow_hard_wrap(text.slice(..), 10);
230+
assert_eq!(reflow, " hello\n\nmy name \nis helix");
231+
}
232+
233+
#[test]
234+
fn reflow_to_many_lines_with_whitespace_prefix() {
235+
let text = Rope::from("\t Text indented. \n\t Still indented.");
236+
let expected_reflow = "\t Text indented. Still indented.";
237+
let reflow = reflow_hard_wrap(text.slice(..), 80);
238+
assert_eq!(reflow, expected_reflow);
239+
}
240+
241+
#[test]
242+
fn reflow_to_many_lines_with_whitespace_and_comment_prefix() {
243+
let text = Rope::from("// Text indented. \n// Still indented.");
244+
let expected_reflow = "// Text indented. Still indented.";
245+
let text = Rope::from(text);
246+
let reflow = reflow_hard_wrap(text.slice(..), 80);
247+
assert_eq!(reflow, expected_reflow);
248+
}
249+
250+
#[test]
251+
fn reflow_empty() {
252+
let text = Rope::from("");
253+
let reflow = reflow_hard_wrap(text.slice(..), 10);
254+
assert_eq!(reflow, "");
255+
}
256+
257+
#[test]
258+
fn reflow_max_line_length_zero() {
259+
let text = "hello my name is helix";
260+
let text = Rope::from(text);
261+
let reflow = reflow_hard_wrap(text.slice(..), 0);
262+
assert_eq!(reflow, "hello \nmy \nname \nis \nhelix");
263+
}
264+
265+
#[test]
266+
fn reflow_comment_after_blank_line() {
267+
let text = Rope::from("// Text indented. \n\n// Still indented.");
268+
let expected_reflow = "// Text indented. \n\n// Still indented.";
269+
let text = Rope::from(text);
270+
let reflow = reflow_hard_wrap(text.slice(..), 80);
271+
assert_eq!(reflow, expected_reflow);
272+
}
273+
274+
#[test]
275+
fn detect_prefix_no_indent() {
276+
let text = Rope::from("hello my name is helix");
277+
let prefix = detect_prefix(text.to_string().as_str());
278+
assert_eq!(prefix, "");
279+
}
280+
281+
#[test]
282+
fn detect_prefix_spaces_indent() {
283+
let text = Rope::from(" hello my name is helix");
284+
let prefix = detect_prefix(text.to_string().as_str());
285+
assert_eq!(prefix, " ");
286+
}
287+
288+
#[test]
289+
fn detect_prefix_tabs_indent() {
290+
let text = Rope::from("\t\t\thello my name is helix");
291+
let prefix = detect_prefix(text.to_string().as_str());
292+
assert_eq!(prefix, "\t\t\t");
293+
}
294+
295+
#[test]
296+
fn detect_prefix_spaces_with_tabs() {
297+
let text = Rope::from(" \t\thello my name is helix");
298+
let prefix = detect_prefix(text.to_string().as_str());
299+
assert_eq!(prefix, " \t\t");
300+
}
301+
302+
#[test]
303+
fn detect_prefix_tabs_with_spaces_indent() {
304+
let text = Rope::from("\t\t hello my name is helix");
305+
let prefix = detect_prefix(text.to_string().as_str());
306+
assert_eq!(prefix, "\t\t ");
307+
}
308+
309+
#[test]
310+
fn detect_prefix_many_lines_with_comment_then_space() {
311+
let text = Rope::from("// Text indented.\n// Still indented.");
312+
let prefix = detect_prefix(text.to_string().as_str());
313+
assert_eq!(prefix, "// ");
314+
}
315+
316+
#[test]
317+
fn detect_prefix_unfinished_final_line() {
318+
let text = Rope::from("// Text indented.\n// Still indented.\n");
319+
let prefix = detect_prefix(text.to_string().as_str());
320+
assert_eq!(prefix, "// ");
321+
}
322+
323+
#[test]
324+
fn flow_pieces_basic() {
325+
let text = "one two three";
326+
let pieces = separate_flow_pieces(text, "");
327+
assert_eq!(
328+
pieces,
329+
Vec::from([
330+
Piece::Reflow("one "),
331+
Piece::Reflow("two "),
332+
Piece::Reflow("three")
333+
])
334+
);
335+
}
336+
337+
#[test]
338+
fn flow_pieces_with_blank_line() {
339+
let text = "one\n\ntwo";
340+
let pieces = separate_flow_pieces(text, "");
341+
assert_eq!(
342+
pieces,
343+
Vec::from([
344+
Piece::Reflow("one"),
345+
Piece::NoReflow("\n"),
346+
Piece::Reflow("two"),
347+
])
348+
);
349+
}
350+
}

0 commit comments

Comments
 (0)