diff --git a/Cargo.lock b/Cargo.lock index 083545be8..8567dae10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1424,12 +1424,11 @@ dependencies = [ "slotmap", "smallvec", "smartstring", - "textwrap", "toml", "tree-house", "unicode-general-category", "unicode-segmentation", - "unicode-width 0.1.12", + "unicode-width", "url", ] @@ -2564,12 +2563,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" - [[package]] name = "socket2" version = "0.5.7" @@ -2642,17 +2635,6 @@ dependencies = [ "home", ] -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", - "unicode-linebreak", - "unicode-width 0.2.0", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2871,12 +2853,6 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - [[package]] name = "unicode-normalization" version = "0.1.23" @@ -2898,12 +2874,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - [[package]] name = "url" version = "2.5.4" diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 4e825364b..96df833f0 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -51,8 +51,6 @@ encoding_rs = "0.8" chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } -textwrap = "0.16.2" - nucleo.workspace = true parking_lot.workspace = true globset = "0.4.16" diff --git a/helix-core/src/doc_formatter.rs b/helix-core/src/doc_formatter.rs index d74709420..bebda0535 100644 --- a/helix-core/src/doc_formatter.rs +++ b/helix-core/src/doc_formatter.rs @@ -24,7 +24,7 @@ use helix_stdx::rope::{RopeGraphemes, RopeSliceExt}; use crate::graphemes::{Grapheme, GraphemeStr}; use crate::syntax::Highlight; use crate::text_annotations::TextAnnotations; -use crate::{Position, RopeSlice}; +use crate::{movement, Change, LineEnding, Position, RopeSlice, Tendril}; /// TODO make Highlight a u32 to reduce the size of this enum to a single word. #[derive(Debug, Clone, Copy)] @@ -478,3 +478,91 @@ impl<'t> Iterator for DocumentFormatter<'t> { Some(grapheme) } } + +pub struct ReflowOpts<'a> { + pub width: usize, + pub line_ending: LineEnding, + pub comment_tokens: &'a [String], +} + +impl ReflowOpts<'_> { + fn find_indent<'a>(&self, line: usize, doc: RopeSlice<'a>) -> RopeSlice<'a> { + let line_start = doc.line_to_char(line); + let mut indent_end = movement::skip_while(doc, line_start, |ch| matches!(ch, ' ' | '\t')) + .unwrap_or(line_start); + let slice = doc.slice(indent_end..); + if let Some(token) = self + .comment_tokens + .iter() + .filter(|token| slice.starts_with(token)) + .max_by_key(|x| x.len()) + { + indent_end += token.chars().count(); + } + let indent_end = movement::skip_while(doc, indent_end, |ch| matches!(ch, ' ' | '\t')) + .unwrap_or(indent_end); + return doc.slice(line_start..indent_end); + } +} + +/// reflow wraps long lines in text to be less than opts.width. +pub fn reflow(text: RopeSlice, char_pos: usize, opts: &ReflowOpts) -> Vec { + // A constant so that reflow behaves consistently across + // different configurations. + const TAB_WIDTH: u16 = 8; + + let line_idx = text.char_to_line(char_pos.min(text.len_chars())); + let mut char_pos = text.line_to_char(line_idx); + + let mut col = 0; + let mut word_width = 0; + let mut last_word_boundary = None; + let mut changes = Vec::new(); + for grapheme in text.slice(char_pos..).graphemes() { + let grapheme_chars = grapheme.len_chars(); + let mut grapheme = Grapheme::new(GraphemeStr::from(Cow::from(grapheme)), col, TAB_WIDTH); + if col + grapheme.width() > opts.width && !grapheme.is_whitespace() { + if let Some(n) = last_word_boundary { + let indent = opts.find_indent(text.char_to_line(n - 1), text); + let mut whitespace_start = n; + let mut whitespace_end = n; + while whitespace_start > 0 && text.char(whitespace_start - 1) == ' ' { + whitespace_start -= 1; + } + while whitespace_end < text.chars().len() && text.char(whitespace_end) == ' ' { + whitespace_end += 1; + } + changes.push(( + whitespace_start, + whitespace_end, + Some(Tendril::from(format!( + "{}{}", + opts.line_ending.as_str(), + &indent + ))), + )); + + col = 0; + for g in indent.graphemes() { + let g = Grapheme::new(GraphemeStr::from(Cow::from(g)), col, TAB_WIDTH); + col += g.width(); + } + col += word_width; + last_word_boundary = None; + grapheme.change_position(col, TAB_WIDTH); + } + } + col += grapheme.width(); + word_width += grapheme.width(); + if grapheme == Grapheme::Newline { + col = 0; + word_width = 0; + last_word_boundary = None; + } else if grapheme.is_whitespace() { + last_word_boundary = Some(char_pos); + word_width = 0; + } + char_pos += grapheme_chars; + } + changes +} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 09865ca40..b7e843062 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -32,7 +32,6 @@ pub mod text_annotations; pub mod textobject; mod transaction; pub mod uri; -pub mod wrap; pub mod unicode { pub use unicode_general_category as category; diff --git a/helix-core/src/wrap.rs b/helix-core/src/wrap.rs deleted file mode 100644 index 337b389ae..000000000 --- a/helix-core/src/wrap.rs +++ /dev/null @@ -1,11 +0,0 @@ -use smartstring::{LazyCompact, SmartString}; -use textwrap::{Options, WordSplitter::NoHyphenation}; - -/// Given a slice of text, return the text re-wrapped to fit it -/// within the given width. -pub fn reflow_hard_wrap(text: &str, text_width: usize) -> SmartString { - let options = Options::new(text_width) - .word_splitter(NoHyphenation) - .word_separator(textwrap::WordSeparator::AsciiSpace); - textwrap::refill(text, options).into() -} diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 2013a9d81..cecc9e2d8 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -7,6 +7,7 @@ use crate::job::Job; use super::*; use helix_core::command_line::{Args, Flag, Signature, Token, TokenKind}; +use helix_core::doc_formatter::ReflowOpts; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; use helix_core::line_ending; @@ -2165,14 +2166,24 @@ fn reflow(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyho .unwrap_or_else(|| doc.text_width()); let rope = doc.text(); + let opts = ReflowOpts { + width: text_width, + line_ending: doc.line_ending, + comment_tokens: doc + .language_config() + .and_then(|config| config.comment_tokens.as_deref()) + .unwrap_or(&[]), + }; - let selection = doc.selection(view.id); - let transaction = Transaction::change_by_selection(rope, selection, |range| { - let fragment = range.fragment(rope.slice(..)); - let reflowed_text = helix_core::wrap::reflow_hard_wrap(&fragment, text_width); - - (range.from(), range.to(), Some(reflowed_text)) - }); + let mut changes = Vec::new(); + for selection in doc.selection(view.id) { + changes.append(&mut helix_core::doc_formatter::reflow( + rope.slice(..selection.to()), + selection.from(), + &opts, + )); + } + let transaction = Transaction::change(rope, changes.into_iter()); doc.apply(&transaction, view.id); doc.append_changes_to_history(view); diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index 29f76cfb8..9cd1d405a 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -820,3 +820,112 @@ async fn macro_play_within_macro_record() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn test_reflow() -> anyhow::Result<()> { + test(( + "#[|This is a long line bla bla bla]#", + ":reflow 5", + "#[|This +is a +long +line +bla +bla +bla]#", + )) + .await?; + + test(( + "// #[|This is a really long comment that we want to break onto multiple lines.]#", + ":lang rust:reflow 13", + "// #[|This is a +// really +// long +// comment +// that we +// want to +// break onto +// multiple +// lines.]#", + )) + .await?; + + test(( + "#[\t// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod +\t// tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim +\t// veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea +\t// commodo consequat. Duis aute irure dolor in reprehenderit in voluptate +\t// velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint +\t// occaecat cupidatat non proident, sunt in culpa qui officia deserunt +\t// mollit anim id est laborum. +|]#", + ":lang go:reflow 50", + "#[\t// Lorem ipsum dolor sit amet, consectetur +\t// adipiscing elit, sed do eiusmod +\t// tempor incididunt ut labore et dolore +\t// magna aliqua. Ut enim ad minim +\t// veniam, quis nostrud exercitation +\t// ullamco laboris nisi ut aliquip ex ea +\t// commodo consequat. Duis aute irure +\t// dolor in reprehenderit in voluptate +\t// velit esse cillum dolore eu fugiat +\t// nulla pariatur. Excepteur sint +\t// occaecat cupidatat non proident, sunt +\t// in culpa qui officia deserunt +\t// mollit anim id est laborum. +|]#", + )) + .await?; + + test(( + " // #[|This document has multiple lines that each need wrapping + + /// currently we wrap each line completely separately in order to preserve existing newlines.]#", + ":lang rust:reflow 40", + " // #[|This document has multiple lines + // that each need wrapping + + /// currently we wrap each line + /// completely separately in order + /// to preserve existing newlines.]#" + )) + .await?; + + test(( + "#[|Very-long-words-should-not-be-broken-by-hard-wrap]#", + ":reflow 2", + "#[|Very-long-words-should-not-be-broken-by-hard-wrap]#", + )) + .await?; + + test(( + "#[|Spaces are removed when wrapping]#", + ":reflow 2", + "#[|Spaces +are +removed +when +wrapping]#", + )) + .await?; + + test(( + "Test wrapping only part of the text +#[|wrapping should only modify the lines that are currently selected +]#", + ":reflow 11", + "Test wrapping only part of the text +#[|wrapping +should only +modify the +lines that +are +currently +selected +]#", + )) + .await?; + + Ok(()) +}