Sam Vente 2025-06-16 14:29:36 +00:00 committed by GitHub
commit 0773531942
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 338 additions and 7 deletions

1
Cargo.lock generated
View File

@ -1552,6 +1552,7 @@ dependencies = [
"helix-event",
"helix-loader",
"helix-lsp",
"helix-parsec",
"helix-stdx",
"helix-tui",
"helix-vcs",

View File

@ -16,6 +16,8 @@
| `:write-buffer-close`, `:wbc` | Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt) |
| `:write-buffer-close!`, `:wbc!` | Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt) |
| `:new`, `:n` | Create a new scratch buffer. |
| `:goto-mark` | Go to the selection saved in a register. Register can be provided as argument or selected register else ^ will be used |
| `:register-mark` | Save current selection into a register. Register can be provided as argument or selected register else ^ will be used |
| `:format`, `:fmt` | Format the file using an external formatter or language server. |
| `:indent-style` | Set the indentation style for editing. ('t' for tabs or 1-16 for number of spaces.) |
| `:line-ending` | Set the document's default line ending. Options: crlf, lf. |

View File

@ -12,10 +12,11 @@ use crate::{
tree_sitter::Node,
Assoc, ChangeSet, RopeSlice,
};
use helix_parsec::{seq, take_until, Parser};
use helix_stdx::range::is_subset;
use helix_stdx::rope::{self, RopeSliceExt};
use smallvec::{smallvec, SmallVec};
use std::{borrow::Cow, iter, slice};
use std::{borrow::Cow, fmt::Display, iter, slice};
/// A single selection range.
///
@ -392,6 +393,34 @@ impl Range {
}
}
impl Display for Range {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({},{})", self.anchor, self.head)
}
}
impl TryFrom<&str> for Range {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let parser = seq!(
"(",
take_until(|c| c == ','),
",",
take_until(|c| c == ')'),
")"
);
match parser.parse(value) {
Ok((_tail, (_, anchor, _, head, _))) => Ok(Self {
anchor: anchor.parse::<usize>().map_err(|e| e.to_string())?,
head: head.parse::<usize>().map_err(|e| e.to_string())?,
old_visual_position: None,
}),
Err(e) => Err(e.to_string()),
}
}
}
impl From<(usize, usize)> for Range {
fn from((anchor, head): (usize, usize)) -> Self {
Self {
@ -884,6 +913,54 @@ mod test {
use super::*;
use crate::Rope;
#[test]
fn parse_range() -> Result<(), String> {
// sometimes we want Ok, sometimes we want Err, but we never want a panic
assert_eq!(
Range::try_from("(0,28)"),
Ok(Range {
anchor: 0,
head: 28,
old_visual_position: None
})
);
assert_eq!(
Range::try_from("(3456789,123456789)"),
Ok(Range {
anchor: 3456789,
head: 123456789,
old_visual_position: None
})
);
assert_eq!(Range::try_from("(,)"), Err("(,)".to_string()));
assert_eq!(
Range::try_from("(asdf,asdf)"),
Err("invalid digit found in string".to_string())
);
assert_eq!(Range::try_from("()"), Err("()".to_string()));
assert_eq!(
Range::try_from("(-4,ALSK)"),
Err("invalid digit found in string".to_string())
);
assert_eq!(
Range::try_from("(⦡⓼␀⍆ⴉ├⺶⍄⾨,⦡⓼␀⍆ⴉ├⺶⍄⾨)"),
Err("invalid digit found in string".to_string())
);
Ok(())
}
#[test]
fn display_range() {
assert_eq!(
Range {
anchor: 72,
head: 28,
old_visual_position: None,
}
.to_string(),
"(72,28)".to_string(),
);
}
#[test]
#[should_panic]
fn test_new_empty() {

View File

@ -49,16 +49,32 @@ helix-lsp = { path = "../helix-lsp" }
helix-dap = { path = "../helix-dap" }
helix-vcs = { path = "../helix-vcs" }
helix-loader = { path = "../helix-loader" }
helix-parsec = { path = "../helix-parsec" }
anyhow = "1"
once_cell = "1.21"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] }
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] }
tokio = { version = "1", features = [
"rt",
"rt-multi-thread",
"io-util",
"io-std",
"time",
"process",
"macros",
"fs",
"parking_lot",
] }
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = [
"crossterm",
] }
crossterm = { version = "0.28", features = ["event-stream"] }
signal-hook = "0.3"
tokio-stream = "0.1"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
futures-util = { version = "0.3", features = [
"std",
"async-await",
], default-features = false }
arc-swap = { version = "1.7.1" }
termini = "1"
indexmap = "2.9"
@ -96,7 +112,11 @@ signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
libc = "0.2.172"
[target.'cfg(target_os = "macos")'.dependencies]
crossterm = { version = "0.28", features = ["event-stream", "use-dev-tty", "libc"] }
crossterm = { version = "0.28", features = [
"event-stream",
"use-dev-tty",
"libc",
] }
[build-dependencies]
helix-loader = { path = "../helix-loader" }

View File

@ -5,6 +5,7 @@ pub(crate) mod typed;
pub use dap::*;
use futures_util::FutureExt;
use helix_event::status;
use helix_parsec::{seq, take_until, Parser};
use helix_stdx::{
path::{self, find_paths},
rope::{self, RopeSliceExt},
@ -47,6 +48,7 @@ use helix_view::{
info::Info,
input::KeyEvent,
keyboard::KeyCode,
register::RegisterValues,
theme::Style,
tree,
view::View,
@ -74,6 +76,7 @@ use std::{
future::Future,
io::Read,
num::NonZeroUsize,
str::FromStr,
};
use std::{
@ -6635,6 +6638,10 @@ fn extend_to_word(cx: &mut Context) {
jump_to_word(cx, Movement::Extend)
}
fn read_from_register(editor: &mut Editor, reg: char) -> Option<RegisterValues> {
editor.registers.read(reg, &*editor)
}
fn jump_to_label(cx: &mut Context, labels: Vec<Range>, behaviour: Movement) {
let doc = doc!(cx.editor);
let alphabet = &cx.editor.config().jump_label_alphabet;

View File

@ -479,6 +479,155 @@ fn new_file(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> an
Ok(())
}
fn register_mark(
cx: &mut compositor::Context,
args: Args,
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
};
let register_name: char = args
.first()
.map_or_else(|| cx.editor.selected_register, |s| s.chars().next())
.unwrap_or('^');
let (view, doc) = current!(cx.editor);
let ranges_str = doc
.selection(view.id)
.ranges()
.iter()
.map(|r| r.to_string())
.collect::<Vec<String>>();
// we have to take because of cell
let history = doc.history.take();
let current_history_point = history.current_revision();
doc.history.replace(history);
// doc_id so we know which doc to switch to
// current_history_point so we can apply changes
// to our selection when we restore it.
// the rest of the elements are just the stringified ranges
let mut register_val = vec![
format!("{}", doc.id()),
format!("{}", current_history_point),
];
register_val.extend(ranges_str);
cx.editor.registers.write(register_name, register_val)?;
cx.editor
.set_status(format!("Saved selection bookmark to [{}]", register_name));
Ok(())
}
fn parse_mark_register_contents(
registers_vals: Option<RegisterValues>,
) -> anyhow::Result<(DocumentId, usize, Selection)> {
match registers_vals {
Some(rv) => {
let mut rv_iter = rv.into_iter();
let Some(doc_id) = rv_iter
.next()
.map(|c| c.into_owned())
.and_then(|s| s.try_into().ok())
else {
return Err(anyhow!("Register did not contain valid document id"));
};
let Some(history_rev) = rv_iter
.next()
.map(|c| c.into_owned())
.and_then(|s| s.parse().ok())
else {
return Err(anyhow!("Register did not contain valid revision number"));
};
let Ok(ranges) = rv_iter
.map(|tup| {
let s = tup.into_owned();
let range_parser = seq!(
"(",
take_until(|c| c == ','),
",",
take_until(|c| c == ')'),
")"
);
let Ok((_tail, (_lparen, anchor_str, _comma, head_str, _rparen))) =
range_parser.parse(&s)
else {
return Err(format!("Could not parse range from string: {}", s));
};
let Ok(anchor) = <usize as FromStr>::from_str(anchor_str) else {
return Err(format!("Could not parse range from string: {}", s));
};
let Ok(head) = <usize as FromStr>::from_str(head_str) else {
return Err(format!("Could not parse range from string: {}", s));
};
Ok(Range {
anchor,
head,
old_visual_position: None,
})
})
// reverse the iterators so the first range will end up as the primary when we push them
.rev()
.collect::<Result<Vec<Range>, String>>()
else {
return Err(anyhow!("Some ranges in the register failed to parse!"));
};
let mut ranges_iter = ranges.into_iter();
let last_range = ranges_iter.next().unwrap(); // safe since there is always at least one range
let mut selection = Selection::from(last_range);
for r in ranges_iter {
selection = selection.push(r);
}
Ok((doc_id, history_rev, selection))
}
None => Err(anyhow!("Register was empty")),
}
}
fn goto_mark(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
};
let register_name: char = args
.first()
.map_or_else(|| cx.editor.selected_register, |s| s.chars().next())
.unwrap_or('^');
let scrolloff = cx.editor.config().scrolloff;
// use some helper functions to avoid making the borrow checker angry
let registers_vals = read_from_register(cx.editor, register_name);
let (doc_id, history_rev, mut selection) = parse_mark_register_contents(registers_vals)?;
cx.editor.switch(doc_id, Action::Replace);
let (view, doc) = current!(cx.editor);
let history = doc.history.take();
let revisions_to_apply = history.changes_since(history_rev);
doc.history.replace(history);
selection = match revisions_to_apply {
Some(t) => selection.map(t.changes()),
None => selection,
};
doc.set_selection(view.id, selection);
view.ensure_cursor_in_view(doc, scrolloff);
Ok(())
}
fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
@ -2758,6 +2907,28 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
..Signature::DEFAULT
},
},
TypableCommand {
name: "goto-mark",
aliases: &[],
doc: "Go to the selection saved in a register. Register can be provided as argument or selected register else ^ will be used",
fun: goto_mark,
completer: CommandCompleter::positional(&[completers::register]),
signature: Signature {
positionals: (0, Some(1)),
..Signature::DEFAULT
},
},
TypableCommand {
name: "register-mark",
aliases: &[],
doc: "Save current selection into a register. Register can be provided as argument or selected register else ^ will be used",
fun: register_mark,
completer: CommandCompleter::positional(&[completers::register]),
signature: Signature {
positionals: (0, Some(1)),
..Signature::DEFAULT
},
},
TypableCommand {
name: "format",
aliases: &["fmt"],

View File

@ -336,6 +336,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"C-a" => increment,
"C-x" => decrement,
});
let mut select = normal.clone();
select.merge_nodes(keymap!({ "Select mode"

View File

@ -65,6 +65,43 @@ async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn bookmark() -> anyhow::Result<()> {
// add a mark and then immediately paste it out
test((
indoc! {"\
#[|Lorem]#
ipsum
#(|Lorem)#
ipsum
#(|Lorem)#
ipsum
#(|Lorem)#
ipsum
#(|Lorem)#
ipsum"
},
// make a mark, make changes to the doc, colapse selection by going to end of doc
// then resore mark and see the selection is still good
":register-mark<space>1<ret>casdf<esc>ge:goto-mark<space>1<ret>",
indoc! {"\
#[|asdf]#
ipsum
#(|asdf)#
ipsum
#(|asdf)#
ipsum
#(|asdf)#
ipsum
#(|asdf)#
ipsum"
},
))
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn surround_by_character() -> anyhow::Result<()> {
// Only pairs matching the passed character count

View File

@ -19,7 +19,7 @@ pub mod theme;
pub mod tree;
pub mod view;
use std::num::NonZeroUsize;
use std::num::{NonZeroUsize, ParseIntError};
// uses NonZeroUsize so Option<DocumentId> use a byte rather than two
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
@ -32,6 +32,21 @@ impl Default for DocumentId {
}
}
impl TryFrom<&str> for DocumentId {
type Error = ParseIntError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Ok(Self(value.parse::<NonZeroUsize>()?))
}
}
impl TryFrom<String> for DocumentId {
type Error = ParseIntError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Ok(Self(value.parse::<NonZeroUsize>()?))
}
}
impl std::fmt::Display for DocumentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{}", self.0))