Skip to content

feat: New command :paste-join #13600

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions book/src/generated/static-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@
| `paste_clipboard_before` | Paste clipboard before selections | normal: `` <space>P ``, select: `` <space>P `` |
| `paste_primary_clipboard_after` | Paste primary clipboard after selections | |
| `paste_primary_clipboard_before` | Paste primary clipboard before selections | |
| `paste_before_joined_with_newline` | Join all selections with a newline and paste before cursor | |
| `paste_after_joined_with_newline` | Join all selections with a newline and paste after cursor | |
| `replace_joined_with_newline` | Replace selection with all selections joined with a newline | |
| `indent` | Indent selection | normal: `` <gt> ``, select: `` <gt> `` |
| `unindent` | Unindent selection | normal: `` <lt> ``, select: `` <lt> `` |
| `format_selections` | Format selection | normal: `` = ``, select: `` = `` |
Expand Down
1 change: 1 addition & 0 deletions book/src/generated/typable-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
| `:cquit`, `:cq` | Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2). |
| `:cquit!`, `:cq!` | Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2). |
| `:theme` | Change the editor theme (show current theme if no name specified). |
| `:paste-join`, `:pj` | Join selections with a separator and paste |
| `:yank-join` | Yank joined selections. A separator can be provided as first argument. Default value is newline. |
| `:clipboard-yank` | Yank main selection into system clipboard. |
| `:clipboard-yank-join` | Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline. |
Expand Down
107 changes: 90 additions & 17 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ use std::{
future::Future,
io::Read,
num::NonZeroUsize,
str::FromStr,
};

use std::{
Expand Down Expand Up @@ -214,6 +215,10 @@ pub enum MappableCommand {
},
Static {
name: &'static str,
// TODO: Change the signature to
// fn(cx: &mut Context) -> anyhow::Result<()>
//
// Then handle the error by using `Editor::set_error` in a single place
fun: fn(cx: &mut Context),
doc: &'static str,
},
Expand Down Expand Up @@ -494,6 +499,9 @@ impl MappableCommand {
paste_clipboard_before, "Paste clipboard before selections",
paste_primary_clipboard_after, "Paste primary clipboard after selections",
paste_primary_clipboard_before, "Paste primary clipboard before selections",
paste_before_joined_with_newline, "Join all selections with a newline and paste before cursor",
paste_after_joined_with_newline, "Join all selections with a newline and paste after cursor",
replace_joined_with_newline, "Replace selection with all selections joined with a newline",
indent, "Indent selection",
unindent, "Unindent selection",
format_selections, "Format selection",
Expand Down Expand Up @@ -4620,6 +4628,35 @@ enum Paste {
Cursor,
}

/// Where to paste joined selections
#[derive(Copy, Clone, Default)]
pub enum PasteJoined {
/// Paste before the cursor
Before,
/// Paste after the cursor
#[default]
After,
/// Replace the selection with cursor
Replace,
}

impl PasteJoined {
const VARIANTS: [&'static str; 3] = ["before", "after", "replace"];
}

impl FromStr for PasteJoined {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"before" => Ok(Self::Before),
"after" => Ok(Self::After),
"replace" => Ok(Self::Replace),
_ => Err(anyhow!("Invalid paste position: {s}")),
}
}
}

static LINE_ENDING_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\r\n|\r|\n").unwrap());

fn paste_impl(
Expand Down Expand Up @@ -4747,32 +4784,38 @@ fn replace_with_yanked(cx: &mut Context) {
}

fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) {
let Some(values) = editor
.registers
.read(register, editor)
.filter(|values| values.len() > 0)
else {
let scrolloff = editor.config().scrolloff;

let Some(values) = editor.registers.read(register, editor) else {
return;
};
let scrolloff = editor.config().scrolloff;
let (view, doc) = current_ref!(editor);
let yanked = values.map(|value| value.to_string()).collect::<Vec<_>>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why collect to a vec here instead of accepting impl Iterator<Item=&str>

Copy link
Contributor Author

@nik-rev nik-rev May 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that here:

    let Some(values) = editor.registers.read(register, editor) else {
        return;
    };

values from borrows from the editor.
We then need to let (view, doc) = current!(editor); which mutably borrows from Editor to create &mut View and &mut Document

To call replace_impl with something like impl Iterator<Item=&str>, each &str would need to borrow from Editor and the View/Document would need to mutably borrow

I also tried accepting impl Iterator<Item = String>, but it did not work because the lifetime of the iterator is still bound to the editor, which causes the issues mentioned above

let (view, doc) = current!(editor);

let map_value = |value: &Cow<str>| {
replace_impl(&yanked, doc, view, count, scrolloff)
}

fn replace_impl(
values: &[String],
doc: &mut Document,
view: &mut View,
count: usize,
scrolloff: usize,
) {
let map_value = |value: &String| {
let value = LINE_ENDING_REGEX.replace_all(value, doc.line_ending.as_str());
let mut out = Tendril::from(value.as_ref());
for _ in 1..count {
out.push_str(&value);
}
out
};
let mut values_rev = values.rev().peekable();
// `values` is asserted to have at least one entry above.
let last = values_rev.peek().unwrap();
let mut values_rev = values.iter().rev().peekable();
let Some(last) = values_rev.peek() else {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you change this from an unwrap? As far as I can see there is still always atleast one value?

And if there isn't I would rather unwrap instead of silently doing nothing (or atleast log an error)

Copy link
Contributor Author

@nik-rev nik-rev May 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously we had registers.read().filter(|values| values.len() > 0) and would return if it's empty. So unwrap was ok. This all happened in the body of the same function

But now, I've moved registers.read().filter(|values| values.len() > 0) outside of replace_impl. Keeping the unwrap means placing an extra constraint on the caller and I think it'd be better to do the validation inside of replace_impl, so I removed call to .filter

The effect is the same as before: Do nothing if it's empty.

It can be empty when the register contains nothing. E.g., "m<C-R> would do nothing if m is empty.

This behaviour (including not logging anything) is consistent with what would happen if you did "mp, "mP, "mR etc if the m register is empty

return;
};
let repeat = std::iter::repeat(map_value(last));
let mut values = values_rev
.rev()
.map(|value| map_value(&value))
.chain(repeat);
let mut values = values_rev.rev().map(map_value).chain(repeat);
let selection = doc.selection(view.id);
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
if !range.is_empty() {
Expand All @@ -4781,9 +4824,7 @@ fn replace_with_yanked_impl(editor: &mut Editor, register: char, count: usize) {
(range.from(), range.to(), None)
}
});
drop(values);

let (view, doc) = current!(editor);
doc.apply(&transaction, view.id);
doc.append_changes_to_history(view);
view.ensure_cursor_in_view(doc, scrolloff);
Expand Down Expand Up @@ -6608,6 +6649,38 @@ fn replay_macro(cx: &mut Context) {
}));
}

fn paste_before_joined_with_newline(cx: &mut Context) {
if let Err(err) = paste_joined_impl(
cx.editor,
cx.count(),
PasteJoined::Before,
cx.register,
None,
) {
cx.editor.set_error(err.to_string());
};
}

fn paste_after_joined_with_newline(cx: &mut Context) {
if let Err(err) =
paste_joined_impl(cx.editor, cx.count(), PasteJoined::After, cx.register, None)
{
cx.editor.set_error(err.to_string());
};
}

fn replace_joined_with_newline(cx: &mut Context) {
if let Err(err) = paste_joined_impl(
cx.editor,
cx.count(),
PasteJoined::Replace,
cx.register,
None,
) {
cx.editor.set_error(err.to_string());
};
}

fn goto_word(cx: &mut Context) {
jump_to_word(cx, Movement::Move)
}
Expand Down
118 changes: 118 additions & 0 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::fmt::Write;
use std::io::BufReader;
use std::num::NonZero;
use std::ops::{self, Deref};

use crate::job::Job;
Expand Down Expand Up @@ -969,6 +970,86 @@ fn yank_main_selection_to_clipboard(
Ok(())
}

pub fn paste_joined_impl(
editor: &mut Editor,
count: usize,
paste_position: PasteJoined,
register: Option<char>,
separator: Option<&str>,
) -> anyhow::Result<()> {
let config = editor.config();
let register = register.unwrap_or(config.default_yank_register);
let scrolloff = config.scrolloff;

let register_values = editor
.registers
.read(register, editor)
.ok_or_else(|| anyhow!("Register {register} is empty"))?;

let doc = doc!(editor);
let separator = separator.unwrap_or_else(|| doc.line_ending.as_str());

// Intersperse register values with a separator
let paste = register_values.fold(String::new(), |mut pasted, value| {
if !pasted.is_empty() {
pasted.push_str(separator);
}
pasted.push_str(&value);
pasted
});

let (view, doc) = current!(editor);

match paste_position {
PasteJoined::Before => paste_impl(&[paste], doc, view, Paste::Before, count, editor.mode),
PasteJoined::After => paste_impl(&[paste], doc, view, Paste::After, count, editor.mode),
PasteJoined::Replace => replace_impl(&[paste], doc, view, count, scrolloff),
}

Ok(())
}

fn paste_joined(
cx: &mut compositor::Context,
args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}

let count = args
.get_flag("count")
.map(|count| count.parse::<NonZero<usize>>())
.transpose()?
.map_or(1, |count| count.get());

let paste_position = args
.get_flag("position")
.map(|pos| pos.parse::<PasteJoined>())
.transpose()?
.unwrap_or_default();

let register = args
.get_flag("register")
.map(|reg| {
reg.parse::<char>()
.map_err(|_| anyhow!("Invalid register: {reg}"))
})
.transpose()?
.or(cx.editor.selected_register);

paste_joined_impl(
cx.editor,
count,
paste_position,
register,
args.get_flag("separator"),
)?;

Ok(())
}

fn yank_joined(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
Expand Down Expand Up @@ -2937,6 +3018,43 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
..Signature::DEFAULT
},
},
TypableCommand {
name: "paste-join",
aliases: &["pj"],
doc: "Join selections with a separator and paste",
fun: paste_joined,
completer: CommandCompleter::none(),
signature: Signature {
positionals: (0, Some(0)),
flags: &[
Flag {
name: "separator",
alias: Some('s'),
doc: "Separator between joined selections (Default: newline)",
completions: Some(&[])
},
Flag {
name: "count",
alias: Some('c'),
doc: "How many times to paste",
completions: Some(&[])
},
Flag {
name: "position",
alias: Some('p'),
doc: "Location of where to paste",
completions: Some(&PasteJoined::VARIANTS)
},
Flag {
name: "register",
alias: Some('r'),
doc: "Paste from this register",
completions: Some(&[])
}
],
..Signature::DEFAULT
},
},
TypableCommand {
name: "yank-join",
aliases: &[],
Expand Down
1 change: 1 addition & 0 deletions helix-term/tests/test/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use super::*;

mod insert;
mod movement;
mod paste_join;
mod write;

#[tokio::test(flavor = "multi_thread")]
Expand Down
Loading