diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 4b737893d4656..ec5f7095386f5 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -85,3 +85,4 @@ | `:reset-diff-change`, `:diffget`, `:diffg` | Reset the diff change at the cursor position. | | `:clear-register` | Clear given register. If no argument is provided, clear all registers. | | `:redraw` | Clear and re-render the whole UI | +| `:trim` | Trim whitespace | diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index abe6dd97ec895..a071314597804 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2408,6 +2408,104 @@ fn redraw( Ok(()) } +fn trim_whitespace_impl(doc: &Document, selection: &Selection) -> Transaction { + /// Maps reverse index to non-reverse index + fn map_reverse(len: usize, rev: usize) -> usize { + // This function won't be correct if len is 0 + debug_assert_ne!(len, 0); + (len - 1) - rev + } + + /// Find the last non-whitespace char of a line + fn find_trailing(l: RopeSlice) -> Option { + // Handle empty docs + if l.len_chars() == 0 { + return None; + } + + // Returns the left-wise beginning of the trailing whitespace + // It is +1 the index of that char so that char is not deleted + l.chars_at(l.len_chars()) + .reversed() + .position(|ch| !ch.is_whitespace()) + .map(|n| l.len_chars() - n) + .or(Some(0)) + } + + let mut deletions: Vec = Vec::new(); + let mut delete = |start, end| { + // Don't push empty changes + if start != end { + deletions.push((start, end)); + } + }; + + // Assume ranges are in order and not overlapping + for range in selection.ranges().iter().rev() { + let slice = range.slice(doc.text().slice(..)); + let lines = slice.lines_at(slice.len_lines()).reversed(); + + // Cap the `end` to not delete the line ending + let end_account_le = |line: RopeSlice, n: usize| { + let le_len = helix_core::line_ending::get_line_ending(&line) + .map(|le| le.len_chars()) + .unwrap_or(0); + // Map `end` with respect to the whole doc + range.from() + n - le_len + }; + + // Ignore empty lines if `trailing` is true. + // If not `trailing`, delete trailing whitespace on lines. + let mut trailing = true; + for (idx, line) in lines + .enumerate() + .map(|(idx, line)| (map_reverse(slice.len_lines(), idx), line)) + { + if trailing { + // `n @ 1..` will ignore `Some(0)` from empty lines + if let Some(n @ 1..) = find_trailing(line) { + let start = range.from() + slice.line_to_char(idx) + n; + // Needed to retain the last EOL of the selection, which would be selected. e.g. in `%:trim` + let end = end_account_le(slice, slice.len_chars()); + delete(start, end); + trailing = false; + } + } else if let Some(n) = find_trailing(line) { + let start = range.from() + slice.line_to_char(idx) + n; + // TODO: prove this is safe + let end = end_account_le(line, slice.line_to_char(idx + 1)); + delete(start, end); + } + } + + // Delete empty selections + if trailing { + let start = range.from(); + let end = end_account_le(slice, slice.len_chars()); + delete(start, end); + } + } + Transaction::delete(doc.text(), deletions.into_iter().rev()) +} + +fn trim_whitespace( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let tx = trim_whitespace_impl(doc, selection); + doc.apply(&tx, view.id); + doc.append_changes_to_history(view); + + Ok(()) +} + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -3008,6 +3106,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: redraw, signature: CommandSignature::none(), }, + TypableCommand { + name: "trim", + aliases: &[], + doc: "Trim whitespace", + fun: trim_whitespace, + signature: CommandSignature::none(), + }, ]; pub static TYPABLE_COMMAND_MAP: Lazy> =