From 3156577fbf1a97e07e90e11b51c66155f122c3b7 Mon Sep 17 00:00:00 2001 From: ath3 <45574139+ath3@users.noreply.github.com> Date: Sun, 12 Dec 2021 13:13:33 +0100 Subject: [PATCH] Open files with spaces in filename, allow opening multiple files (#1231) --- helix-core/src/lib.rs | 1 + helix-core/src/shellwords.rs | 164 +++++++++++++++++++++++++++++++++++ helix-term/src/commands.rs | 145 ++++++++++++++++--------------- 3 files changed, 239 insertions(+), 71 deletions(-) create mode 100644 helix-core/src/shellwords.rs diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 4ae044ccee1c..92a59f31e6cd 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -17,6 +17,7 @@ mod position; pub mod register; pub mod search; pub mod selection; +pub mod shellwords; mod state; pub mod surround; pub mod syntax; diff --git a/helix-core/src/shellwords.rs b/helix-core/src/shellwords.rs new file mode 100644 index 000000000000..13f6f3e99da4 --- /dev/null +++ b/helix-core/src/shellwords.rs @@ -0,0 +1,164 @@ +use std::borrow::Cow; + +/// Get the vec of escaped / quoted / doublequoted filenames from the input str +pub fn shellwords(input: &str) -> Vec> { + enum State { + Normal, + NormalEscaped, + Quoted, + QuoteEscaped, + Dquoted, + DquoteEscaped, + } + + use State::*; + + let mut state = Normal; + let mut args: Vec> = Vec::new(); + let mut escaped = String::with_capacity(input.len()); + + let mut start = 0; + let mut end = 0; + + for (i, c) in input.char_indices() { + state = match state { + Normal => match c { + '\\' => { + escaped.push_str(&input[start..i]); + start = i + 1; + NormalEscaped + } + '"' => { + end = i; + Dquoted + } + '\'' => { + end = i; + Quoted + } + c if c.is_ascii_whitespace() => { + end = i; + Normal + } + _ => Normal, + }, + NormalEscaped => Normal, + Quoted => match c { + '\\' => { + escaped.push_str(&input[start..i]); + start = i + 1; + QuoteEscaped + } + '\'' => { + end = i; + Normal + } + _ => Quoted, + }, + QuoteEscaped => Quoted, + Dquoted => match c { + '\\' => { + escaped.push_str(&input[start..i]); + start = i + 1; + DquoteEscaped + } + '"' => { + end = i; + Normal + } + _ => Dquoted, + }, + DquoteEscaped => Dquoted, + }; + + if i >= input.len() - 1 && end == 0 { + end = i + 1; + } + + if end > 0 { + let esc_trim = escaped.trim(); + let inp = &input[start..end]; + + if !(esc_trim.is_empty() && inp.trim().is_empty()) { + if esc_trim.is_empty() { + args.push(inp.into()); + } else { + args.push([escaped, inp.into()].concat().into()); + escaped = "".to_string(); + } + } + start = i + 1; + end = 0; + } + } + args +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_normal() { + let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#; + let result = shellwords(input); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó"), + Cow::from("wörds"), + Cow::from(r#"three "with escaping\"#), + ]; + // TODO test is_owned and is_borrowed, once they get stabilized. + assert_eq!(expected, result); + } + + #[test] + fn test_quoted() { + let quoted = + r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#; + let result = shellwords(quoted); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from(r#"three' "with escaping\"#), + Cow::from("quote incomplete"), + ]; + assert_eq!(expected, result); + } + + #[test] + fn test_dquoted() { + let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#; + let result = shellwords(dquoted); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from(r#"three' "with escaping\"#), + Cow::from("dquote incomplete"), + ]; + assert_eq!(expected, result); + } + + #[test] + fn test_mixed() { + let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#; + let result = shellwords(dquoted); + let expected = vec![ + Cow::from(":o"), + Cow::from("single_word"), + Cow::from("twó wörds"), + Cow::from("three' \"with escaping\\"), + Cow::from("no space before"), + Cow::from("and after"), + Cow::from("$#%^@"), + Cow::from("%^&(%^"), + Cow::from(")(*&^%"), + Cow::from(r#"a\\b"#), + //last ' just changes to quoted but since we dont have anything after it, it should be ignored + ]; + assert_eq!(expected, result); + } +} diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 87c5a63f6b79..314cd11fd8b9 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -10,7 +10,7 @@ use helix_core::{ movement::{self, Direction}, object, pos_at_coords, regex::{self, Regex, RegexBuilder}, - search, selection, surround, textobject, + search, selection, shellwords, surround, textobject, unicode::width::UnicodeWidthChar, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, SmallVec, Tendril, Transaction, @@ -173,14 +173,14 @@ impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { MappableCommand::Typable { name, args, doc: _ } => { - let args: Vec<&str> = args.iter().map(|arg| arg.as_str()).collect(); + let args: Vec> = args.iter().map(Cow::from).collect(); if let Some(command) = cmd::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { editor: cx.editor, jobs: cx.jobs, scroll: None, }; - if let Err(e) = (command.fun)(&mut cx, &args, PromptEvent::Validate) { + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { cx.editor.set_error(format!("{}", e)); } } @@ -1963,13 +1963,13 @@ pub mod cmd { pub aliases: &'static [&'static str], pub doc: &'static str, // params, flags, helper, completer - pub fun: fn(&mut compositor::Context, &[&str], PromptEvent) -> anyhow::Result<()>, + pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, pub completer: Option, } fn quit( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { // last view and we have unsaved changes @@ -1984,7 +1984,7 @@ pub mod cmd { fn force_quit( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor.close(view!(cx.editor).id); @@ -1994,17 +1994,19 @@ pub mod cmd { fn open( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { - let path = args.get(0).context("wrong argument count")?; - let _ = cx.editor.open(path.into(), Action::Replace)?; + ensure!(!args.is_empty(), "wrong argument count"); + for arg in args { + let _ = cx.editor.open(arg.as_ref().into(), Action::Replace)?; + } Ok(()) } fn buffer_close( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let view = view!(cx.editor); @@ -2015,7 +2017,7 @@ pub mod cmd { fn force_buffer_close( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let view = view!(cx.editor); @@ -2024,15 +2026,12 @@ pub mod cmd { Ok(()) } - fn write_impl>( - cx: &mut compositor::Context, - path: Option

, - ) -> anyhow::Result<()> { + fn write_impl(cx: &mut compositor::Context, path: Option<&Cow>) -> anyhow::Result<()> { let jobs = &mut cx.jobs; let (_, doc) = current!(cx.editor); if let Some(ref path) = path { - doc.set_path(Some(path.as_ref())) + doc.set_path(Some(path.as_ref().as_ref())) .context("invalid filepath")?; } if doc.path().is_none() { @@ -2061,7 +2060,7 @@ pub mod cmd { fn write( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first()) @@ -2069,7 +2068,7 @@ pub mod cmd { fn new_file( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor.new_file(Action::Replace); @@ -2079,7 +2078,7 @@ pub mod cmd { fn format( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); @@ -2094,7 +2093,7 @@ pub mod cmd { } fn set_indent_style( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { use IndentStyle::*; @@ -2114,7 +2113,7 @@ pub mod cmd { // Attempt to parse argument as an indent style. let style = match args.get(0) { Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), - Some(&"0") => Some(Tabs), + Some(Cow::Borrowed("0")) => Some(Tabs), Some(arg) => arg .parse::() .ok() @@ -2133,7 +2132,7 @@ pub mod cmd { /// Sets or reports the current document's line ending setting. fn set_line_ending( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { use LineEnding::*; @@ -2177,7 +2176,7 @@ pub mod cmd { fn earlier( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; @@ -2193,7 +2192,7 @@ pub mod cmd { fn later( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; @@ -2208,7 +2207,7 @@ pub mod cmd { fn write_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first())?; @@ -2217,7 +2216,7 @@ pub mod cmd { fn force_write_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first())?; @@ -2248,7 +2247,7 @@ pub mod cmd { fn write_all_impl( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, quit: bool, force: bool, @@ -2284,7 +2283,7 @@ pub mod cmd { fn write_all( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, false, false) @@ -2292,7 +2291,7 @@ pub mod cmd { fn write_all_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, true, false) @@ -2300,7 +2299,7 @@ pub mod cmd { fn force_write_all_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, true, true) @@ -2308,7 +2307,7 @@ pub mod cmd { fn quit_all_impl( editor: &mut Editor, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, force: bool, ) -> anyhow::Result<()> { @@ -2327,7 +2326,7 @@ pub mod cmd { fn quit_all( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { quit_all_impl(cx.editor, args, event, false) @@ -2335,7 +2334,7 @@ pub mod cmd { fn force_quit_all( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { quit_all_impl(cx.editor, args, event, true) @@ -2343,7 +2342,7 @@ pub mod cmd { fn cquit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let exit_code = args @@ -2362,7 +2361,7 @@ pub mod cmd { fn theme( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let theme = args.first().context("theme not provided")?; @@ -2371,7 +2370,7 @@ pub mod cmd { fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) @@ -2379,20 +2378,18 @@ pub mod cmd { fn yank_joined_to_clipboard( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); - let separator = args - .first() - .copied() - .unwrap_or_else(|| doc.line_ending.as_str()); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) } fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) @@ -2400,20 +2397,18 @@ pub mod cmd { fn yank_joined_to_primary_clipboard( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); - let separator = args - .first() - .copied() - .unwrap_or_else(|| doc.line_ending.as_str()); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) } fn paste_clipboard_after( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard) @@ -2421,7 +2416,7 @@ pub mod cmd { fn paste_clipboard_before( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard) @@ -2429,7 +2424,7 @@ pub mod cmd { fn paste_primary_clipboard_after( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection) @@ -2437,7 +2432,7 @@ pub mod cmd { fn paste_primary_clipboard_before( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection) @@ -2467,7 +2462,7 @@ pub mod cmd { fn replace_selections_with_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) @@ -2475,7 +2470,7 @@ pub mod cmd { fn replace_selections_with_primary_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) @@ -2483,7 +2478,7 @@ pub mod cmd { fn show_clipboard_provider( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor @@ -2493,12 +2488,13 @@ pub mod cmd { fn change_current_directory( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let dir = helix_core::path::expand_tilde( args.first() .context("target directory not provided")? + .as_ref() .as_ref(), ); @@ -2516,7 +2512,7 @@ pub mod cmd { fn show_current_directory( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; @@ -2528,7 +2524,7 @@ pub mod cmd { /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); @@ -2544,7 +2540,7 @@ pub mod cmd { /// Reload the [`Document`] from its source file. fn reload( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (view, doc) = current!(cx.editor); @@ -2553,7 +2549,7 @@ pub mod cmd { fn tree_sitter_scopes( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let (view, doc) = current!(cx.editor); @@ -2567,15 +2563,18 @@ pub mod cmd { fn vsplit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let id = view!(cx.editor).doc; - if let Some(path) = args.get(0) { - cx.editor.open(path.into(), Action::VerticalSplit)?; - } else { + if args.is_empty() { cx.editor.switch(id, Action::VerticalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; + } } Ok(()) @@ -2583,15 +2582,18 @@ pub mod cmd { fn hsplit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let id = view!(cx.editor).doc; - if let Some(path) = args.get(0) { - cx.editor.open(path.into(), Action::HorizontalSplit)?; - } else { + if args.is_empty() { cx.editor.switch(id, Action::HorizontalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; + } } Ok(()) @@ -2599,7 +2601,7 @@ pub mod cmd { fn tutor( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { let path = helix_core::runtime_dir().join("tutor.txt"); @@ -2611,7 +2613,7 @@ pub mod cmd { pub(super) fn goto_line_number( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow], _event: PromptEvent, ) -> anyhow::Result<()> { ensure!(!args.is_empty(), "Line number required"); @@ -2980,7 +2982,7 @@ fn command_mode(cx: &mut Context) { // If command is numeric, interpret as line number and go there. if parts.len() == 1 && parts[0].parse::().ok().is_some() { - if let Err(e) = cmd::goto_line_number(cx, &parts[0..], event) { + if let Err(e) = cmd::goto_line_number(cx, &[Cow::from(parts[0])], event) { cx.editor.set_error(format!("{}", e)); } return; @@ -2988,7 +2990,8 @@ fn command_mode(cx: &mut Context) { // Handle typable commands if let Some(cmd) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { - if let Err(e) = (cmd.fun)(cx, &parts[1..], event) { + let args = shellwords::shellwords(input); + if let Err(e) = (cmd.fun)(cx, &args[1..], event) { cx.editor.set_error(format!("{}", e)); } } else {