use std::fmt::Write; use std::ops::Deref; use crate::job::Job; use super::*; use helix_core::fuzzy::fuzzy_match; use helix_core::{encoding, shellwords::Shellwords}; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::editor::{Action, CloseError, ConfigEvent}; use serde_json::Value; use ui::completers::{self, Completer}; #[derive(Clone)] pub struct TypableCommand { pub name: &'static str, pub aliases: &'static [&'static str], pub doc: &'static str, // params, flags, helper, completer pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, /// What completion methods, if any, does this command have? pub signature: CommandSignature, } impl TypableCommand { fn completer_for_argument_number(&self, n: usize) -> &Completer { match self.signature.positional_args.get(n) { Some(completer) => completer, _ => &self.signature.var_args, } } } #[derive(Clone)] pub struct CommandSignature { // Arguments with specific completion methods based on their position. positional_args: &'static [Completer], // All remaining arguments will use this completion method, if set. var_args: Completer, } impl CommandSignature { const fn none() -> Self { Self { positional_args: &[], var_args: completers::none, } } const fn positional(completers: &'static [Completer]) -> Self { Self { positional_args: completers, var_args: completers::none, } } const fn all(completer: Completer) -> Self { Self { positional_args: &[], var_args: completer, } } } fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { log::debug!("quitting..."); if event != PromptEvent::Validate { return Ok(()); } ensure!(args.is_empty(), ":quit takes no arguments"); // last view and we have unsaved changes if cx.editor.tree.views().count() == 1 { buffers_remaining_impl(cx.editor)? } cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); Ok(()) } fn force_quit( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(args.is_empty(), ":quit! takes no arguments"); cx.block_try_flush_writes()?; cx.editor.close(view!(cx.editor).id); Ok(()) } fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(!args.is_empty(), "wrong argument count"); for arg in args { let (path, pos) = args::parse_file(arg); let path = helix_core::path::expand_tilde(&path); // If the path is a directory, open a file picker on that directory and update the status // message if let Ok(true) = std::fs::canonicalize(&path).map(|p| p.is_dir()) { let callback = async move { let call: job::Callback = job::Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { let picker = ui::file_picker(path, &editor.config()); compositor.push(Box::new(overlaid(picker))); }, )); Ok(call) }; cx.jobs.callback(callback); } else { // Otherwise, just open the file let _ = cx.editor.open(&path, Action::Replace)?; let (view, doc) = current!(cx.editor); let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); doc.set_selection(view.id, pos); // does not affect opening a buffer without pos align_view(doc, view, Align::Center); } } Ok(()) } fn buffer_close_by_ids_impl( cx: &mut compositor::Context, doc_ids: &[DocumentId], force: bool, ) -> anyhow::Result<()> { cx.block_try_flush_writes()?; let (modified_ids, modified_names): (Vec<_>, Vec<_>) = doc_ids .iter() .filter_map(|&doc_id| { if let Err(CloseError::BufferModified(name)) = cx.editor.close_document(doc_id, force) { Some((doc_id, name)) } else { None } }) .unzip(); if let Some(first) = modified_ids.first() { let current = doc!(cx.editor); // If the current document is unmodified, and there are modified // documents, switch focus to the first modified doc. if !modified_ids.contains(¤t.id()) { cx.editor.switch(*first, Action::Replace); } bail!( "{} unsaved buffer(s) remaining: {:?}", modified_names.len(), modified_names ); } Ok(()) } fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec { // No arguments implies current document if args.is_empty() { let doc_id = view!(editor).doc; return vec![doc_id]; } let mut nonexistent_buffers = vec![]; let mut document_ids = vec![]; for arg in args { let doc_id = editor.documents().find_map(|doc| { let arg_path = Some(Path::new(arg.as_ref())); if doc.path().map(|p| p.as_path()) == arg_path || doc.relative_path().as_deref() == arg_path { Some(doc.id()) } else { None } }); match doc_id { Some(doc_id) => document_ids.push(doc_id), None => nonexistent_buffers.push(format!("'{}'", arg)), } } if !nonexistent_buffers.is_empty() { editor.set_error(format!( "cannot close non-existent buffers: {}", nonexistent_buffers.join(", ") )); } document_ids } fn buffer_close( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let document_ids = buffer_gather_paths_impl(cx.editor, args); buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let document_ids = buffer_gather_paths_impl(cx.editor, args); buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_gather_others_impl(editor: &mut Editor) -> Vec { let current_document = &doc!(editor).id(); editor .documents() .map(|doc| doc.id()) .filter(|doc_id| doc_id != current_document) .collect() } fn buffer_close_others( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let document_ids = buffer_gather_others_impl(cx.editor); buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close_others( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let document_ids = buffer_gather_others_impl(cx.editor); buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_gather_all_impl(editor: &mut Editor) -> Vec { editor.documents().map(|doc| doc.id()).collect() } fn buffer_close_all( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let document_ids = buffer_gather_all_impl(cx.editor); buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_buffer_close_all( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let document_ids = buffer_gather_all_impl(cx.editor); buffer_close_by_ids_impl(cx, &document_ids, true) } fn buffer_next( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } goto_buffer(cx.editor, Direction::Forward); Ok(()) } fn buffer_previous( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } goto_buffer(cx.editor, Direction::Backward); Ok(()) } fn write_impl( cx: &mut compositor::Context, path: Option<&Cow>, force: bool, ) -> anyhow::Result<()> { let editor_auto_fmt = cx.editor.config().auto_format; let jobs = &mut cx.jobs; let (view, doc) = current!(cx.editor); let path = path.map(AsRef::as_ref); let fmt = if editor_auto_fmt { doc.auto_format().map(|fmt| { let callback = make_format_callback( doc.id(), doc.version(), view.id, fmt, Some((path.map(Into::into), force)), ); jobs.add(Job::with_callback(callback).wait_before_exiting()); }) } else { None }; if fmt.is_none() { let id = doc.id(); cx.editor.save(id, path, force)?; } Ok(()) } fn write( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), false) } fn force_write( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), true) } fn write_buffer_close( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), false)?; let document_ids = buffer_gather_paths_impl(cx.editor, args); buffer_close_by_ids_impl(cx, &document_ids, false) } fn force_write_buffer_close( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), true)?; let document_ids = buffer_gather_paths_impl(cx.editor, args); buffer_close_by_ids_impl(cx, &document_ids, false) } fn new_file( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor.new_file(Action::Replace); Ok(()) } fn format( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let (view, doc) = current!(cx.editor); if let Some(format) = doc.format() { let callback = make_format_callback(doc.id(), doc.version(), view.id, format, None); cx.jobs.callback(callback); } Ok(()) } fn set_indent_style( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } use IndentStyle::*; // If no argument, report current indent style. if args.is_empty() { let style = doc!(cx.editor).indent_style; cx.editor.set_status(match style { Tabs => "tabs".to_owned(), Spaces(1) => "1 space".to_owned(), Spaces(n) if (2..=8).contains(&n) => format!("{} spaces", n), _ => unreachable!(), // Shouldn't happen. }); return Ok(()); } // 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(Cow::Borrowed("0")) => Some(Tabs), Some(arg) => arg .parse::() .ok() .filter(|n| (1..=8).contains(n)) .map(Spaces), _ => None, }; let style = style.context("invalid indent style")?; let doc = doc_mut!(cx.editor); doc.indent_style = style; Ok(()) } /// Sets or reports the current document's line ending setting. fn set_line_ending( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } use LineEnding::*; // If no argument, report current line ending setting. if args.is_empty() { let line_ending = doc!(cx.editor).line_ending; cx.editor.set_status(match line_ending { Crlf => "crlf", LF => "line feed", #[cfg(feature = "unicode-lines")] FF => "form feed", #[cfg(feature = "unicode-lines")] CR => "carriage return", #[cfg(feature = "unicode-lines")] Nel => "next line", // These should never be a document's default line ending. #[cfg(feature = "unicode-lines")] VT | LS | PS => "error", }); return Ok(()); } let arg = args .get(0) .context("argument missing")? .to_ascii_lowercase(); // Attempt to parse argument as a line ending. let line_ending = match arg { arg if arg.starts_with("crlf") => Crlf, arg if arg.starts_with("lf") => LF, #[cfg(feature = "unicode-lines")] arg if arg.starts_with("cr") => CR, #[cfg(feature = "unicode-lines")] arg if arg.starts_with("ff") => FF, #[cfg(feature = "unicode-lines")] arg if arg.starts_with("nel") => Nel, _ => bail!("invalid line ending"), }; let (view, doc) = current!(cx.editor); doc.line_ending = line_ending; let mut pos = 0; let transaction = Transaction::change( doc.text(), doc.text().lines().filter_map(|line| { pos += line.len_chars(); match helix_core::line_ending::get_line_ending(&line) { Some(ending) if ending != line_ending => { let start = pos - ending.len_chars(); let end = pos; Some((start, end, Some(line_ending.as_str().into()))) } _ => None, } }), ); doc.apply(&transaction, view.id); doc.append_changes_to_history(view); Ok(()) } fn earlier( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); let success = doc.earlier(view, uk); if !success { cx.editor.set_status("Already at oldest change"); } Ok(()) } fn later( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); let success = doc.later(view, uk); if !success { cx.editor.set_status("Already at newest change"); } Ok(()) } fn write_quit( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), false)?; cx.block_try_flush_writes()?; quit(cx, &[], event) } fn force_write_quit( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_impl(cx, args.first(), true)?; cx.block_try_flush_writes()?; force_quit(cx, &[], event) } /// Results in an error if there are modified buffers remaining and sets editor /// error, otherwise returns `Ok(())`. If the current document is unmodified, /// and there are modified documents, switches focus to one of them. pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> { let (modified_ids, modified_names): (Vec<_>, Vec<_>) = editor .documents() .filter(|doc| doc.is_modified()) .map(|doc| (doc.id(), doc.display_name())) .unzip(); if let Some(first) = modified_ids.first() { let current = doc!(editor); // If the current document is unmodified, and there are modified // documents, switch focus to the first modified doc. if !modified_ids.contains(¤t.id()) { editor.switch(*first, Action::Replace); } bail!( "{} unsaved buffer(s) remaining: {:?}", modified_names.len(), modified_names ); } Ok(()) } pub fn write_all_impl( cx: &mut compositor::Context, force: bool, write_scratch: bool, ) -> anyhow::Result<()> { let mut errors: Vec<&'static str> = Vec::new(); let auto_format = cx.editor.config().auto_format; let jobs = &mut cx.jobs; let current_view = view!(cx.editor); // save all documents let saves: Vec<_> = cx .editor .documents .values_mut() .filter_map(|doc| { if !doc.is_modified() { return None; } if doc.path().is_none() { if write_scratch { errors.push("cannot write a buffer without a filename"); } return None; } // Look for a view to apply the formatting change to. If the document // is in the current view, just use that. Otherwise, since we don't // have any other metric available for better selection, just pick // the first view arbitrarily so that we still commit the document // state for undos. If somehow we have a document that has not been // initialized with any view, initialize it with the current view. let target_view = if doc.selections().contains_key(¤t_view.id) { current_view.id } else if let Some(view) = doc.selections().keys().next() { *view } else { doc.ensure_view_init(current_view.id); current_view.id }; let fmt = if auto_format { doc.auto_format().map(|fmt| { let callback = make_format_callback( doc.id(), doc.version(), target_view, fmt, Some((None, force)), ); jobs.add(Job::with_callback(callback).wait_before_exiting()); }) } else { None }; if fmt.is_none() { return Some(doc.id()); } None }) .collect(); // manually call save for the rest of docs that don't have a formatter for id in saves { cx.editor.save::(id, None, force)?; } if !errors.is_empty() && !force { bail!("{:?}", errors); } Ok(()) } fn write_all( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_all_impl(cx, false, true) } fn force_write_all( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_all_impl(cx, true, true) } fn write_all_quit( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } write_all_impl(cx, false, true)?; quit_all_impl(cx, false) } fn force_write_all_quit( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let _ = write_all_impl(cx, true, true); quit_all_impl(cx, true) } fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<()> { cx.block_try_flush_writes()?; if !force { buffers_remaining_impl(cx.editor)?; } // close all views let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); for view_id in views { cx.editor.close(view_id); } Ok(()) } fn quit_all( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } quit_all_impl(cx, false) } fn force_quit_all( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } quit_all_impl(cx, true) } fn cquit( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let exit_code = args .first() .and_then(|code| code.parse::().ok()) .unwrap_or(1); cx.editor.exit_code = exit_code; quit_all_impl(cx, false) } fn force_cquit( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let exit_code = args .first() .and_then(|code| code.parse::().ok()) .unwrap_or(1); cx.editor.exit_code = exit_code; quit_all_impl(cx, true) } fn theme( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { let true_color = cx.editor.config.load().true_color || crate::true_color(); match event { PromptEvent::Abort => { cx.editor.unset_theme_preview(); } PromptEvent::Update => { if args.is_empty() { // Ensures that a preview theme gets cleaned up if the user backspaces until the prompt is empty. cx.editor.unset_theme_preview(); } else if let Some(theme_name) = args.first() { if let Ok(theme) = cx.editor.theme_loader.load(theme_name) { if !(true_color || theme.is_16_color()) { bail!("Unsupported theme: theme requires true color support"); } cx.editor.set_theme_preview(theme); }; }; } PromptEvent::Validate => { if let Some(theme_name) = args.first() { let theme = cx .editor .theme_loader .load(theme_name) .map_err(|err| anyhow::anyhow!("Could not load theme: {}", err))?; if !(true_color || theme.is_16_color()) { bail!("Unsupported theme: theme requires true color support"); } cx.editor.set_theme(theme); } else { let name = cx.editor.theme.name().to_string(); cx.editor.set_status(name); } } }; Ok(()) } fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } yank_primary_selection_impl(cx.editor, '*'); Ok(()) } fn yank_joined( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(args.len() <= 1, ":yank-join takes at most 1 argument"); let doc = doc!(cx.editor); let default_sep = Cow::Borrowed(doc.line_ending.as_str()); let separator = args.first().unwrap_or(&default_sep); let register = cx.editor.selected_register.unwrap_or('"'); yank_joined_impl(cx.editor, separator, register); Ok(()) } fn yank_joined_to_clipboard( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let doc = doc!(cx.editor); let default_sep = Cow::Borrowed(doc.line_ending.as_str()); let separator = args.first().unwrap_or(&default_sep); yank_joined_impl(cx.editor, separator, '*'); Ok(()) } fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } yank_primary_selection_impl(cx.editor, '+'); Ok(()) } fn yank_joined_to_primary_clipboard( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let doc = doc!(cx.editor); let default_sep = Cow::Borrowed(doc.line_ending.as_str()); let separator = args.first().unwrap_or(&default_sep); yank_joined_impl(cx.editor, separator, '+'); Ok(()) } fn paste_clipboard_after( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } paste(cx.editor, '*', Paste::After, 1); Ok(()) } fn paste_clipboard_before( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } paste(cx.editor, '*', Paste::Before, 1); Ok(()) } fn paste_primary_clipboard_after( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } paste(cx.editor, '+', Paste::After, 1); Ok(()) } fn paste_primary_clipboard_before( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } paste(cx.editor, '+', Paste::Before, 1); Ok(()) } fn replace_selections_with_clipboard( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } replace_with_yanked_impl(cx.editor, '*', 1); Ok(()) } fn replace_selections_with_primary_clipboard( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } replace_with_yanked_impl(cx.editor, '+', 1); Ok(()) } fn show_clipboard_provider( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor .set_status(cx.editor.registers.clipboard_provider_name().to_string()); Ok(()) } fn change_current_directory( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let dir = helix_core::path::expand_tilde( args.first() .context("target directory not provided")? .as_ref() .as_ref(), ); helix_loader::set_current_working_dir(dir)?; cx.editor.set_status(format!( "Current working directory is now {}", helix_loader::current_working_dir().display() )); Ok(()) } fn show_current_directory( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let cwd = helix_loader::current_working_dir(); let message = format!("Current working directory is {}", cwd.display()); if cwd.exists() { cx.editor.set_status(message); } else { cx.editor.set_error(format!("{} (deleted)", message)); } Ok(()) } /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let doc = doc_mut!(cx.editor); if let Some(label) = args.first() { doc.set_encoding(label) } else { let encoding = doc.encoding().name().to_owned(); cx.editor.set_status(encoding); Ok(()) } } /// Shows info about the character under the primary cursor. fn get_character_info( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); let grapheme_start = doc.selection(view.id).primary().cursor(text); let grapheme_end = graphemes::next_grapheme_boundary(text, grapheme_start); if grapheme_start == grapheme_end { return Ok(()); } let grapheme = text.slice(grapheme_start..grapheme_end).to_string(); let encoding = doc.encoding(); let printable = grapheme.chars().fold(String::new(), |mut s, c| { match c { '\0' => s.push_str("\\0"), '\t' => s.push_str("\\t"), '\n' => s.push_str("\\n"), '\r' => s.push_str("\\r"), _ => s.push(c), } s }); // Convert to Unicode codepoints if in UTF-8 let unicode = if encoding == encoding::UTF_8 { let mut unicode = " (".to_owned(); for (i, char) in grapheme.chars().enumerate() { if i != 0 { unicode.push(' '); } unicode.push_str("U+"); let codepoint: u32 = if char.is_ascii() { char.into() } else { // Not ascii means it will be multi-byte, so strip out the extra // bits that encode the length & mark continuation bytes let s = String::from(char); let bytes = s.as_bytes(); // First byte starts with 2-4 ones then a zero, so strip those off let first = bytes[0]; let codepoint = first & (0xFF >> (first.leading_ones() + 1)); let mut codepoint = u32::from(codepoint); // Following bytes start with 10 for byte in bytes.iter().skip(1) { codepoint <<= 6; codepoint += u32::from(*byte) & 0x3F; } codepoint }; write!(unicode, "{codepoint:0>4x}").unwrap(); } unicode.push(')'); unicode } else { String::new() }; // Give the decimal value for ascii characters let dec = if encoding.is_ascii_compatible() && grapheme.len() == 1 { format!(" Dec {}", grapheme.as_bytes()[0]) } else { String::new() }; let hex = { let mut encoder = encoding.new_encoder(); let max_encoded_len = encoder .max_buffer_length_from_utf8_without_replacement(grapheme.len()) .unwrap(); let mut bytes = Vec::with_capacity(max_encoded_len); let mut current_byte = 0; let mut hex = String::new(); for (i, char) in grapheme.chars().enumerate() { if i != 0 { hex.push_str(" +"); } let (result, _input_bytes_read) = encoder.encode_from_utf8_to_vec_without_replacement( &char.to_string(), &mut bytes, true, ); if let encoding::EncoderResult::Unmappable(char) = result { bail!("{char:?} cannot be mapped to {}", encoding.name()); } for byte in &bytes[current_byte..] { write!(hex, " {byte:0>2x}").unwrap(); } current_byte = bytes.len(); } hex }; cx.editor .set_status(format!("\"{printable}\"{unicode}{dec} Hex{hex}")); Ok(()) } /// Reload the [`Document`] from its source file. fn reload( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); doc.reload(view, &cx.editor.diff_providers).map(|_| { view.ensure_cursor_in_view(doc, scrolloff); })?; if let Some(path) = doc.path() { cx.editor .language_servers .file_event_handler .file_changed(path.clone()); } Ok(()) } fn reload_all( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let scrolloff = cx.editor.config().scrolloff; let view_id = view!(cx.editor).id; let docs_view_ids: Vec<(DocumentId, Vec)> = cx .editor .documents_mut() .map(|doc| { let mut view_ids: Vec<_> = doc.selections().keys().cloned().collect(); if view_ids.is_empty() { doc.ensure_view_init(view_id); view_ids.push(view_id); }; (doc.id(), view_ids) }) .collect(); for (doc_id, view_ids) in docs_view_ids { let doc = doc_mut!(cx.editor, &doc_id); // Every doc is guaranteed to have at least 1 view at this point. let view = view_mut!(cx.editor, view_ids[0]); // Ensure that the view is synced with the document's history. view.sync_changes(doc); doc.reload(view, &cx.editor.diff_providers)?; if let Some(path) = doc.path() { cx.editor .language_servers .file_event_handler .file_changed(path.clone()); } for view_id in view_ids { let view = view_mut!(cx.editor, view_id); if view.doc.eq(&doc_id) { view.ensure_cursor_in_view(doc, scrolloff); } } } Ok(()) } /// Update the [`Document`] if it has been modified. fn update( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let (_view, doc) = current!(cx.editor); if doc.is_modified() { write(cx, args, event) } else { Ok(()) } } fn lsp_workspace_command( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let doc = doc!(cx.editor); let Some((language_server_id, options)) = doc .language_servers_with_feature(LanguageServerFeature::WorkspaceCommand) .find_map(|ls| { ls.capabilities() .execute_command_provider .as_ref() .map(|options| (ls.id(), options)) }) else { cx.editor .set_status("No active language servers for this document support workspace commands"); return Ok(()); }; if args.is_empty() { let commands = options .commands .iter() .map(|command| helix_lsp::lsp::Command { title: command.clone(), command: command.clone(), arguments: None, }) .collect::>(); let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { let picker = ui::Picker::new(commands, (), move |cx, command, _action| { execute_lsp_command(cx.editor, language_server_id, command.clone()); }); compositor.push(Box::new(overlaid(picker))) }, )); Ok(call) }; cx.jobs.callback(callback); } else { let command = args.join(" "); if options.commands.iter().any(|c| c == &command) { execute_lsp_command( cx.editor, language_server_id, helix_lsp::lsp::Command { title: command.clone(), arguments: None, command, }, ); } else { cx.editor.set_status(format!( "`{command}` is not supported for this language server" )); return Ok(()); } } Ok(()) } fn lsp_restart( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let editor_config = cx.editor.config.load(); let (_view, doc) = current!(cx.editor); let config = doc .language_config() .context("LSP not defined for the current document")?; cx.editor.language_servers.restart( config, doc.path(), &editor_config.workspace_lsp_roots, editor_config.lsp.snippets, )?; // This collect is needed because refresh_language_server would need to re-borrow editor. let document_ids_to_refresh: Vec = cx .editor .documents() .filter_map(|doc| match doc.language_config() { Some(config) if config.language_servers.iter().any(|ls| { config .language_servers .iter() .any(|restarted_ls| restarted_ls.name == ls.name) }) => { Some(doc.id()) } _ => None, }) .collect(); for document_id in document_ids_to_refresh { cx.editor.refresh_language_servers(document_id); } Ok(()) } fn lsp_stop( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let ls_shutdown_names = doc!(cx.editor) .language_servers() .map(|ls| ls.name().to_string()) .collect::>(); for ls_name in &ls_shutdown_names { cx.editor.language_servers.stop(ls_name); for doc in cx.editor.documents_mut() { if let Some(client) = doc.remove_language_server_by_name(ls_name) { doc.clear_diagnostics(client.id()); } } } Ok(()) } fn tree_sitter_scopes( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let pos = doc.selection(view.id).primary().cursor(text); let scopes = indent::get_scopes(doc.syntax(), text, pos); let contents = format!("```json\n{:?}\n````", scopes); let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); }, )); Ok(call) }; cx.jobs.callback(callback); Ok(()) } fn tree_sitter_highlight_name( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { fn find_highlight_at_cursor( cx: &mut compositor::Context<'_>, ) -> Option { use helix_core::syntax::HighlightEvent; let (view, doc) = current!(cx.editor); let syntax = doc.syntax()?; let text = doc.text().slice(..); let cursor = doc.selection(view.id).primary().cursor(text); let byte = text.char_to_byte(cursor); let node = syntax .tree() .root_node() .descendant_for_byte_range(byte, byte)?; // Query the same range as the one used in syntax highlighting. let range = { // Calculate viewport byte ranges: let row = text.char_to_line(view.offset.anchor.min(text.len_chars())); // Saturating subs to make it inclusive zero indexing. let last_line = text.len_lines().saturating_sub(1); let height = view.inner_area(doc).height; let last_visible_line = (row + height as usize).saturating_sub(1).min(last_line); let start = text.line_to_byte(row.min(last_line)); let end = text.line_to_byte(last_visible_line + 1); start..end }; let mut highlight = None; for event in syntax.highlight_iter(text, Some(range), None) { match event.unwrap() { HighlightEvent::Source { start, end } if start == node.start_byte() && end == node.end_byte() => { return highlight; } HighlightEvent::HighlightStart(hl) => { highlight = Some(hl); } _ => (), } } None } if event != PromptEvent::Validate { return Ok(()); } let Some(highlight) = find_highlight_at_cursor(cx) else { return Ok(()); }; let content = cx.editor.theme.scope(highlight.0).to_string(); let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { let content = ui::Markdown::new(content, editor.syn_loader.clone()); let popup = Popup::new("hover", content).auto_close(true); compositor.replace_or_push("hover", popup); }, )); Ok(call) }; cx.jobs.callback(callback); Ok(()) } fn vsplit( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } if args.is_empty() { split(cx.editor, Action::VerticalSplit); } else { for arg in args { cx.editor .open(&PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; } } Ok(()) } fn hsplit( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } if args.is_empty() { split(cx.editor, Action::HorizontalSplit); } else { for arg in args { cx.editor .open(&PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; } } Ok(()) } fn vsplit_new( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor.new_file(Action::VerticalSplit); Ok(()) } fn hsplit_new( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor.new_file(Action::HorizontalSplit); Ok(()) } fn debug_eval( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } if let Some(debugger) = cx.editor.debugger.as_mut() { let (frame, thread_id) = match (debugger.active_frame, debugger.thread_id) { (Some(frame), Some(thread_id)) => (frame, thread_id), _ => { bail!("Cannot find current stack frame to access variables") } }; // TODO: support no frame_id let frame_id = debugger.stack_frames[&thread_id][frame].id; let response = helix_lsp::block_on(debugger.eval(args.join(" "), Some(frame_id)))?; cx.editor.set_status(response.result); } Ok(()) } fn debug_start( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let mut args = args.to_owned(); let name = match args.len() { 0 => None, _ => Some(args.remove(0)), }; dap_start_impl(cx, name.as_deref(), None, Some(args)) } fn debug_remote( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let mut args = args.to_owned(); let address = match args.len() { 0 => None, _ => Some(args.remove(0).parse()?), }; let name = match args.len() { 0 => None, _ => Some(args.remove(0)), }; dap_start_impl(cx, name.as_deref(), address, Some(args)) } fn tutor( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let path = helix_loader::runtime_file(Path::new("tutor")); cx.editor.open(&path, Action::Replace)?; // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(cx.editor).set_path(None); Ok(()) } fn abort_goto_line_number_preview(cx: &mut compositor::Context) { if let Some(last_selection) = cx.editor.last_selection.take() { let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); doc.set_selection(view.id, last_selection); view.ensure_cursor_in_view(doc, scrolloff); } } fn update_goto_line_number_preview( cx: &mut compositor::Context, args: &[Cow], ) -> anyhow::Result<()> { cx.editor.last_selection.get_or_insert_with(|| { let (view, doc) = current!(cx.editor); doc.selection(view.id).clone() }); let scrolloff = cx.editor.config().scrolloff; let line = args[0].parse::()?; goto_line_without_jumplist(cx.editor, NonZeroUsize::new(line)); let (view, doc) = current!(cx.editor); view.ensure_cursor_in_view(doc, scrolloff); Ok(()) } pub(super) fn goto_line_number( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { match event { PromptEvent::Abort => abort_goto_line_number_preview(cx), PromptEvent::Validate => { ensure!(!args.is_empty(), "Line number required"); // If we are invoked directly via a keybinding, Validate is // sent without any prior Update events. Ensure the cursor // is moved to the appropriate location. update_goto_line_number_preview(cx, args)?; let last_selection = cx .editor .last_selection .take() .expect("update_goto_line_number_preview should always set last_selection"); let (view, doc) = current!(cx.editor); view.jumps.push((doc.id(), last_selection)); } // When a user hits backspace and there are no numbers left, // we can bring them back to their original selection. If they // begin typing numbers again, we'll start a new preview session. PromptEvent::Update if args.is_empty() => abort_goto_line_number_preview(cx), PromptEvent::Update => update_goto_line_number_preview(cx, args)?, } Ok(()) } // Fetch the current value of a config option and output as status. fn get_option( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } if args.len() != 1 { anyhow::bail!("Bad arguments. Usage: `:get key`"); } let key = &args[0].to_lowercase(); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let config = serde_json::json!(cx.editor.config().deref()); let pointer = format!("/{}", key.replace('.', "/")); let value = config.pointer(&pointer).ok_or_else(key_error)?; cx.editor.set_status(value.to_string()); Ok(()) } /// Change config at runtime. Access nested values by dot syntax, for /// example to disable smart case search, use `:set search.smart-case false`. fn set_option( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } if args.len() != 2 { anyhow::bail!("Bad arguments. Usage: `:set key field`"); } let (key, arg) = (&args[0].to_lowercase(), &args[1]); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let field_error = |_| anyhow::anyhow!("Could not parse field `{}`", arg); let mut config = serde_json::json!(&cx.editor.config().deref()); let pointer = format!("/{}", key.replace('.', "/")); let value = config.pointer_mut(&pointer).ok_or_else(key_error)?; *value = if value.is_string() { // JSON strings require quotes, so we can't .parse() directly Value::String(arg.to_string()) } else { arg.parse().map_err(field_error)? }; let config = serde_json::from_value(config).map_err(field_error)?; cx.editor .config_events .0 .send(ConfigEvent::Update(config))?; Ok(()) } /// Toggle boolean config option at runtime. Access nested values by dot /// syntax, for example to toggle smart case search, use `:toggle search.smart- /// case`. fn toggle_option( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } if args.is_empty() { anyhow::bail!("Bad arguments. Usage: `:toggle key [values]?`"); } let key = &args[0].to_lowercase(); let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let mut config = serde_json::json!(&cx.editor.config().deref()); let pointer = format!("/{}", key.replace('.', "/")); let value = config.pointer_mut(&pointer).ok_or_else(key_error)?; *value = match value { Value::Bool(ref value) => { ensure!( args.len() == 1, "Bad arguments. For boolean configurations use: `:toggle key`" ); Value::Bool(!value) } Value::String(ref value) => { ensure!( args.len() > 2, "Bad arguments. For string configurations use: `:toggle key val1 val2 ...`", ); Value::String( args[1..] .iter() .skip_while(|e| *e != value) .nth(1) .unwrap_or_else(|| &args[1]) .to_string(), ) } Value::Number(ref value) => { ensure!( args.len() > 2, "Bad arguments. For number configurations use: `:toggle key val1 val2 ...`", ); Value::Number( args[1..] .iter() .skip_while(|&e| value.to_string() != *e.to_string()) .nth(1) .unwrap_or_else(|| &args[1]) .parse()?, ) } Value::Null | Value::Object(_) | Value::Array(_) => { anyhow::bail!("Configuration {key} does not support toggle yet") } }; let status = format!("'{key}' is now set to {value}"); let config = serde_json::from_value(config) .map_err(|err| anyhow::anyhow!("Cannot parse `{:?}`, {}", &args, err))?; cx.editor .config_events .0 .send(ConfigEvent::Update(config))?; cx.editor.set_status(status); Ok(()) } /// Change the language of the current buffer at runtime. fn language( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } if args.is_empty() { let doc = doc!(cx.editor); let language = &doc.language_name().unwrap_or(DEFAULT_LANGUAGE_NAME); cx.editor.set_status(language.to_string()); return Ok(()); } if args.len() != 1 { anyhow::bail!("Bad arguments. Usage: `:set-language language`"); } let doc = doc_mut!(cx.editor); if args[0] == DEFAULT_LANGUAGE_NAME { doc.set_language(None, None) } else { doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?; } doc.detect_indent_and_line_ending(); let id = doc.id(); cx.editor.refresh_language_servers(id); Ok(()) } fn sort(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } sort_impl(cx, args, false) } fn sort_reverse( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } sort_impl(cx, args, true) } fn sort_impl( cx: &mut compositor::Context, _args: &[Cow], reverse: bool, ) -> anyhow::Result<()> { let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let selection = doc.selection(view.id); let mut fragments: Vec<_> = selection .slices(text) .map(|fragment| fragment.chunks().collect()) .collect(); fragments.sort_by(match reverse { true => |a: &Tendril, b: &Tendril| b.cmp(a), false => |a: &Tendril, b: &Tendril| a.cmp(b), }); let transaction = Transaction::change( doc.text(), selection .into_iter() .zip(fragments) .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), ); doc.apply(&transaction, view.id); doc.append_changes_to_history(view); view.ensure_cursor_in_view(doc, scrolloff); Ok(()) } fn reflow( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let scrolloff = cx.editor.config().scrolloff; let cfg_text_width: usize = cx.editor.config().text_width; let (view, doc) = current!(cx.editor); // Find the text_width by checking the following sources in order: // - The passed argument in `args` // - The configured text-width for this language in languages.toml // - The configured text-width in the config.toml let text_width: usize = args .get(0) .map(|num| num.parse::()) .transpose()? .or_else(|| doc.language_config().and_then(|config| config.text_width)) .unwrap_or(cfg_text_width); let rope = doc.text(); 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)) }); doc.apply(&transaction, view.id); doc.append_changes_to_history(view); view.ensure_cursor_in_view(doc, scrolloff); Ok(()) } fn tree_sitter_subtree( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let (view, doc) = current!(cx.editor); if let Some(syntax) = doc.syntax() { let primary_selection = doc.selection(view.id).primary(); let text = doc.text(); let from = text.char_to_byte(primary_selection.from()); let to = text.char_to_byte(primary_selection.to()); if let Some(selected_node) = syntax .tree() .root_node() .descendant_for_byte_range(from, to) { let mut contents = String::from("```tsq\n"); helix_core::syntax::pretty_print_tree(&mut contents, selected_node)?; contents.push_str("\n```"); let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); }, )); Ok(call) }; cx.jobs.callback(callback); } } Ok(()) } fn open_config( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor .open(&helix_loader::config_file(), Action::Replace)?; Ok(()) } fn open_workspace_config( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor .open(&helix_loader::workspace_config_file(), Action::Replace)?; Ok(()) } fn open_log( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor.open(&helix_loader::log_file(), Action::Replace)?; Ok(()) } fn refresh_config( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } cx.editor.config_events.0.send(ConfigEvent::Refresh)?; Ok(()) } fn append_output( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(!args.is_empty(), "Shell command required"); shell(cx, &args.join(" "), &ShellBehavior::Append); Ok(()) } fn insert_output( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(!args.is_empty(), "Shell command required"); shell(cx, &args.join(" "), &ShellBehavior::Insert); Ok(()) } fn pipe_to( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Ignore) } fn pipe(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> anyhow::Result<()> { pipe_impl(cx, args, event, &ShellBehavior::Replace) } fn pipe_impl( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, behavior: &ShellBehavior, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(!args.is_empty(), "Shell command required"); shell(cx, &args.join(" "), behavior); Ok(()) } fn run_shell_command( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let shell = cx.editor.config().shell.clone(); let args = args.join(" "); let callback = async move { let (output, success) = shell_impl_async(&shell, &args, None).await?; let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { if !output.is_empty() { let contents = ui::Markdown::new( format!("```sh\n{}\n```", output), editor.syn_loader.clone(), ); let popup = Popup::new("shell", contents).position(Some( helix_core::Position::new(editor.cursor().0.unwrap_or_default().row, 2), )); compositor.replace_or_push("shell", popup); } if success { editor.set_status("Command succeeded"); } else { editor.set_error("Command failed"); } }, )); Ok(call) }; cx.jobs.callback(callback); Ok(()) } fn reset_diff_change( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(args.is_empty(), ":reset-diff-change takes no arguments"); let editor = &mut cx.editor; let scrolloff = editor.config().scrolloff; let (view, doc) = current!(editor); let Some(handle) = doc.diff_handle() else { bail!("Diff is not available in the current buffer") }; let diff = handle.load(); let doc_text = doc.text().slice(..); let line = doc.selection(view.id).primary().cursor_line(doc_text); let Some(hunk_idx) = diff.hunk_at(line as u32, true) else { bail!("There is no change at the cursor") }; let hunk = diff.nth_hunk(hunk_idx); let diff_base = diff.diff_base(); let before_start = diff_base.line_to_char(hunk.before.start as usize); let before_end = diff_base.line_to_char(hunk.before.end as usize); let text: Tendril = diff .diff_base() .slice(before_start..before_end) .chunks() .collect(); let anchor = doc_text.line_to_char(hunk.after.start as usize); let transaction = Transaction::change( doc.text(), [( anchor, doc_text.line_to_char(hunk.after.end as usize), (!text.is_empty()).then_some(text), )] .into_iter(), ); drop(diff); // make borrow check happy doc.apply(&transaction, view.id); // select inserted text let text_len = before_end - before_start; doc.set_selection(view.id, Selection::single(anchor, anchor + text_len)); doc.append_changes_to_history(view); view.ensure_cursor_in_view(doc, scrolloff); Ok(()) } fn clear_register( cx: &mut compositor::Context, args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } ensure!(args.len() <= 1, ":clear-register takes at most 1 argument"); if args.is_empty() { cx.editor.registers.clear(); cx.editor.set_status("All registers cleared"); return Ok(()); } ensure!( args[0].chars().count() == 1, format!("Invalid register {}", args[0]) ); let register = args[0].chars().next().unwrap_or_default(); if cx.editor.registers.remove(register) { cx.editor .set_status(format!("Register {} cleared", register)); } else { cx.editor .set_error(format!("Register {} not found", register)); } Ok(()) } fn redraw( cx: &mut compositor::Context, _args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { if event != PromptEvent::Validate { return Ok(()); } let callback = Box::pin(async move { let call: job::Callback = job::Callback::EditorCompositor(Box::new(|_editor, compositor| { compositor.need_full_redraw(); })); Ok(call) }); cx.jobs.callback(callback); Ok(()) } pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", aliases: &["q"], doc: "Close the current view.", fun: quit, signature: CommandSignature::none(), }, TypableCommand { name: "quit!", aliases: &["q!"], doc: "Force close the current view, ignoring unsaved changes.", fun: force_quit, signature: CommandSignature::none(), }, TypableCommand { name: "open", aliases: &["o"], doc: "Open a file from disk into the current view.", fun: open, signature: CommandSignature::all(completers::filename), }, TypableCommand { name: "buffer-close", aliases: &["bc", "bclose"], doc: "Close the current buffer.", fun: buffer_close, signature: CommandSignature::all(completers::buffer), }, TypableCommand { name: "buffer-close!", aliases: &["bc!", "bclose!"], doc: "Close the current buffer forcefully, ignoring unsaved changes.", fun: force_buffer_close, signature: CommandSignature::all(completers::buffer) }, TypableCommand { name: "buffer-close-others", aliases: &["bco", "bcloseother"], doc: "Close all buffers but the currently focused one.", fun: buffer_close_others, signature: CommandSignature::none(), }, TypableCommand { name: "buffer-close-others!", aliases: &["bco!", "bcloseother!"], doc: "Force close all buffers but the currently focused one.", fun: force_buffer_close_others, signature: CommandSignature::none(), }, TypableCommand { name: "buffer-close-all", aliases: &["bca", "bcloseall"], doc: "Close all buffers without quitting.", fun: buffer_close_all, signature: CommandSignature::none(), }, TypableCommand { name: "buffer-close-all!", aliases: &["bca!", "bcloseall!"], doc: "Force close all buffers ignoring unsaved changes without quitting.", fun: force_buffer_close_all, signature: CommandSignature::none(), }, TypableCommand { name: "buffer-next", aliases: &["bn", "bnext"], doc: "Goto next buffer.", fun: buffer_next, signature: CommandSignature::none(), }, TypableCommand { name: "buffer-previous", aliases: &["bp", "bprev"], doc: "Goto previous buffer.", fun: buffer_previous, signature: CommandSignature::none(), }, TypableCommand { name: "write", aliases: &["w"], doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)", fun: write, signature: CommandSignature::positional(&[completers::filename]), }, TypableCommand { name: "write!", aliases: &["w!"], doc: "Force write changes to disk creating necessary subdirectories. Accepts an optional path (:write! some/path.txt)", fun: force_write, signature: CommandSignature::positional(&[completers::filename]), }, TypableCommand { name: "write-buffer-close", aliases: &["wbc"], doc: "Write changes to disk and closes the buffer. Accepts an optional path (:write-buffer-close some/path.txt)", fun: write_buffer_close, signature: CommandSignature::positional(&[completers::filename]), }, TypableCommand { name: "write-buffer-close!", aliases: &["wbc!"], doc: "Force write changes to disk creating necessary subdirectories and closes the buffer. Accepts an optional path (:write-buffer-close! some/path.txt)", fun: force_write_buffer_close, signature: CommandSignature::positional(&[completers::filename]), }, TypableCommand { name: "new", aliases: &["n"], doc: "Create a new scratch buffer.", fun: new_file, signature: CommandSignature::none(), }, TypableCommand { name: "format", aliases: &["fmt"], doc: "Format the file using the LSP formatter.", fun: format, signature: CommandSignature::none(), }, TypableCommand { name: "indent-style", aliases: &[], doc: "Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.)", fun: set_indent_style, signature: CommandSignature::none(), }, TypableCommand { name: "line-ending", aliases: &[], #[cfg(not(feature = "unicode-lines"))] doc: "Set the document's default line ending. Options: crlf, lf.", #[cfg(feature = "unicode-lines")] doc: "Set the document's default line ending. Options: crlf, lf, cr, ff, nel.", fun: set_line_ending, signature: CommandSignature::none(), }, TypableCommand { name: "earlier", aliases: &["ear"], doc: "Jump back to an earlier point in edit history. Accepts a number of steps or a time span.", fun: earlier, signature: CommandSignature::none(), }, TypableCommand { name: "later", aliases: &["lat"], doc: "Jump to a later point in edit history. Accepts a number of steps or a time span.", fun: later, signature: CommandSignature::none(), }, TypableCommand { name: "write-quit", aliases: &["wq", "x"], doc: "Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt)", fun: write_quit, signature: CommandSignature::positional(&[completers::filename]), }, TypableCommand { name: "write-quit!", aliases: &["wq!", "x!"], doc: "Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt)", fun: force_write_quit, signature: CommandSignature::positional(&[completers::filename]), }, TypableCommand { name: "write-all", aliases: &["wa"], doc: "Write changes from all buffers to disk.", fun: write_all, signature: CommandSignature::none(), }, TypableCommand { name: "write-all!", aliases: &["wa!"], doc: "Forcefully write changes from all buffers to disk creating necessary subdirectories.", fun: force_write_all, signature: CommandSignature::none(), }, TypableCommand { name: "write-quit-all", aliases: &["wqa", "xa"], doc: "Write changes from all buffers to disk and close all views.", fun: write_all_quit, signature: CommandSignature::none(), }, TypableCommand { name: "write-quit-all!", aliases: &["wqa!", "xa!"], doc: "Write changes from all buffers to disk and close all views forcefully (ignoring unsaved changes).", fun: force_write_all_quit, signature: CommandSignature::none(), }, TypableCommand { name: "quit-all", aliases: &["qa"], doc: "Close all views.", fun: quit_all, signature: CommandSignature::none(), }, TypableCommand { name: "quit-all!", aliases: &["qa!"], doc: "Force close all views ignoring unsaved changes.", fun: force_quit_all, signature: CommandSignature::none(), }, TypableCommand { name: "cquit", aliases: &["cq"], doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", fun: cquit, signature: CommandSignature::none(), }, TypableCommand { name: "cquit!", aliases: &["cq!"], doc: "Force quit with exit code (default 1) ignoring unsaved changes. Accepts an optional integer exit code (:cq! 2).", fun: force_cquit, signature: CommandSignature::none(), }, TypableCommand { name: "theme", aliases: &[], doc: "Change the editor theme (show current theme if no name specified).", fun: theme, signature: CommandSignature::positional(&[completers::theme]), }, TypableCommand { name: "yank-join", aliases: &[], doc: "Yank joined selections. A separator can be provided as first argument. Default value is newline.", fun: yank_joined, signature: CommandSignature::none(), }, TypableCommand { name: "clipboard-yank", aliases: &[], doc: "Yank main selection into system clipboard.", fun: yank_main_selection_to_clipboard, signature: CommandSignature::none(), }, TypableCommand { name: "clipboard-yank-join", aliases: &[], doc: "Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. fun: yank_joined_to_clipboard, signature: CommandSignature::none(), }, TypableCommand { name: "primary-clipboard-yank", aliases: &[], doc: "Yank main selection into system primary clipboard.", fun: yank_main_selection_to_primary_clipboard, signature: CommandSignature::none(), }, TypableCommand { name: "primary-clipboard-yank-join", aliases: &[], doc: "Yank joined selections into system primary clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. fun: yank_joined_to_primary_clipboard, signature: CommandSignature::none(), }, TypableCommand { name: "clipboard-paste-after", aliases: &[], doc: "Paste system clipboard after selections.", fun: paste_clipboard_after, signature: CommandSignature::none(), }, TypableCommand { name: "clipboard-paste-before", aliases: &[], doc: "Paste system clipboard before selections.", fun: paste_clipboard_before, signature: CommandSignature::none(), }, TypableCommand { name: "clipboard-paste-replace", aliases: &[], doc: "Replace selections with content of system clipboard.", fun: replace_selections_with_clipboard, signature: CommandSignature::none(), }, TypableCommand { name: "primary-clipboard-paste-after", aliases: &[], doc: "Paste primary clipboard after selections.", fun: paste_primary_clipboard_after, signature: CommandSignature::none(), }, TypableCommand { name: "primary-clipboard-paste-before", aliases: &[], doc: "Paste primary clipboard before selections.", fun: paste_primary_clipboard_before, signature: CommandSignature::none(), }, TypableCommand { name: "primary-clipboard-paste-replace", aliases: &[], doc: "Replace selections with content of system primary clipboard.", fun: replace_selections_with_primary_clipboard, signature: CommandSignature::none(), }, TypableCommand { name: "show-clipboard-provider", aliases: &[], doc: "Show clipboard provider name in status bar.", fun: show_clipboard_provider, signature: CommandSignature::none(), }, TypableCommand { name: "change-current-directory", aliases: &["cd"], doc: "Change the current working directory.", fun: change_current_directory, signature: CommandSignature::positional(&[completers::directory]), }, TypableCommand { name: "show-directory", aliases: &["pwd"], doc: "Show the current working directory.", fun: show_current_directory, signature: CommandSignature::none(), }, TypableCommand { name: "encoding", aliases: &[], doc: "Set encoding. Based on `https://encoding.spec.whatwg.org`.", fun: set_encoding, signature: CommandSignature::none(), }, TypableCommand { name: "character-info", aliases: &["char"], doc: "Get info about the character under the primary cursor.", fun: get_character_info, signature: CommandSignature::none(), }, TypableCommand { name: "reload", aliases: &["rl"], doc: "Discard changes and reload from the source file.", fun: reload, signature: CommandSignature::none(), }, TypableCommand { name: "reload-all", aliases: &["rla"], doc: "Discard changes and reload all documents from the source files.", fun: reload_all, signature: CommandSignature::none(), }, TypableCommand { name: "update", aliases: &["u"], doc: "Write changes only if the file has been modified.", fun: update, signature: CommandSignature::none(), }, TypableCommand { name: "lsp-workspace-command", aliases: &[], doc: "Open workspace command picker", fun: lsp_workspace_command, signature: CommandSignature::positional(&[completers::lsp_workspace_command]), }, TypableCommand { name: "lsp-restart", aliases: &[], doc: "Restarts the language servers used by the current doc", fun: lsp_restart, signature: CommandSignature::none(), }, TypableCommand { name: "lsp-stop", aliases: &[], doc: "Stops the language servers that are used by the current doc", fun: lsp_stop, signature: CommandSignature::none(), }, TypableCommand { name: "tree-sitter-scopes", aliases: &[], doc: "Display tree sitter scopes, primarily for theming and development.", fun: tree_sitter_scopes, signature: CommandSignature::none(), }, TypableCommand { name: "tree-sitter-highlight-name", aliases: &[], doc: "Display name of tree-sitter highlight scope under the cursor.", fun: tree_sitter_highlight_name, signature: CommandSignature::none(), }, TypableCommand { name: "debug-start", aliases: &["dbg"], doc: "Start a debug session from a given template with given parameters.", fun: debug_start, signature: CommandSignature::none(), }, TypableCommand { name: "debug-remote", aliases: &["dbg-tcp"], doc: "Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters.", fun: debug_remote, signature: CommandSignature::none(), }, TypableCommand { name: "debug-eval", aliases: &[], doc: "Evaluate expression in current debug context.", fun: debug_eval, signature: CommandSignature::none(), }, TypableCommand { name: "vsplit", aliases: &["vs"], doc: "Open the file in a vertical split.", fun: vsplit, signature: CommandSignature::all(completers::filename) }, TypableCommand { name: "vsplit-new", aliases: &["vnew"], doc: "Open a scratch buffer in a vertical split.", fun: vsplit_new, signature: CommandSignature::none(), }, TypableCommand { name: "hsplit", aliases: &["hs", "sp"], doc: "Open the file in a horizontal split.", fun: hsplit, signature: CommandSignature::all(completers::filename) }, TypableCommand { name: "hsplit-new", aliases: &["hnew"], doc: "Open a scratch buffer in a horizontal split.", fun: hsplit_new, signature: CommandSignature::none(), }, TypableCommand { name: "tutor", aliases: &[], doc: "Open the tutorial.", fun: tutor, signature: CommandSignature::none(), }, TypableCommand { name: "goto", aliases: &["g"], doc: "Goto line number.", fun: goto_line_number, signature: CommandSignature::none(), }, TypableCommand { name: "set-language", aliases: &["lang"], doc: "Set the language of current buffer (show current language if no value specified).", fun: language, signature: CommandSignature::positional(&[completers::language]), }, TypableCommand { name: "set-option", aliases: &["set"], doc: "Set a config option at runtime.\nFor example to disable smart case search, use `:set search.smart-case false`.", fun: set_option, // TODO: Add support for completion of the options value(s), when appropriate. signature: CommandSignature::positional(&[completers::setting]), }, TypableCommand { name: "toggle-option", aliases: &["toggle"], doc: "Toggle a boolean config option at runtime.\nFor example to toggle smart case search, use `:toggle search.smart-case`.", fun: toggle_option, signature: CommandSignature::positional(&[completers::setting]), }, TypableCommand { name: "get-option", aliases: &["get"], doc: "Get the current value of a config option.", fun: get_option, signature: CommandSignature::positional(&[completers::setting]), }, TypableCommand { name: "sort", aliases: &[], doc: "Sort ranges in selection.", fun: sort, signature: CommandSignature::none(), }, TypableCommand { name: "rsort", aliases: &[], doc: "Sort ranges in selection in reverse order.", fun: sort_reverse, signature: CommandSignature::none(), }, TypableCommand { name: "reflow", aliases: &[], doc: "Hard-wrap the current selection of lines to a given width.", fun: reflow, signature: CommandSignature::none(), }, TypableCommand { name: "tree-sitter-subtree", aliases: &["ts-subtree"], doc: "Display tree sitter subtree under cursor, primarily for debugging queries.", fun: tree_sitter_subtree, signature: CommandSignature::none(), }, TypableCommand { name: "config-reload", aliases: &[], doc: "Refresh user config.", fun: refresh_config, signature: CommandSignature::none(), }, TypableCommand { name: "config-open", aliases: &[], doc: "Open the user config.toml file.", fun: open_config, signature: CommandSignature::none(), }, TypableCommand { name: "config-open-workspace", aliases: &[], doc: "Open the workspace config.toml file.", fun: open_workspace_config, signature: CommandSignature::none(), }, TypableCommand { name: "log-open", aliases: &[], doc: "Open the helix log file.", fun: open_log, signature: CommandSignature::none(), }, TypableCommand { name: "insert-output", aliases: &[], doc: "Run shell command, inserting output before each selection.", fun: insert_output, signature: CommandSignature::none(), }, TypableCommand { name: "append-output", aliases: &[], doc: "Run shell command, appending output after each selection.", fun: append_output, signature: CommandSignature::none(), }, TypableCommand { name: "pipe", aliases: &[], doc: "Pipe each selection to the shell command.", fun: pipe, signature: CommandSignature::none(), }, TypableCommand { name: "pipe-to", aliases: &[], doc: "Pipe each selection to the shell command, ignoring output.", fun: pipe_to, signature: CommandSignature::none(), }, TypableCommand { name: "run-shell-command", aliases: &["sh"], doc: "Run a shell command", fun: run_shell_command, signature: CommandSignature::all(completers::filename) }, TypableCommand { name: "reset-diff-change", aliases: &["diffget", "diffg"], doc: "Reset the diff change at the cursor position.", fun: reset_diff_change, signature: CommandSignature::none(), }, TypableCommand { name: "clear-register", aliases: &[], doc: "Clear given register. If no argument is provided, clear all registers.", fun: clear_register, signature: CommandSignature::none(), }, TypableCommand { name: "redraw", aliases: &[], doc: "Clear and re-render the whole UI", fun: redraw, signature: CommandSignature::none(), }, ]; pub static TYPABLE_COMMAND_MAP: Lazy> = Lazy::new(|| { TYPABLE_COMMAND_LIST .iter() .flat_map(|cmd| { std::iter::once((cmd.name, cmd)) .chain(cmd.aliases.iter().map(move |&alias| (alias, cmd))) }) .collect() }); #[allow(clippy::unnecessary_unwrap)] pub(super) fn command_mode(cx: &mut Context) { let mut prompt = Prompt::new( ":".into(), Some(':'), |editor: &Editor, input: &str| { let shellwords = Shellwords::from(input); let words = shellwords.words(); if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) { fuzzy_match( input, TYPABLE_COMMAND_LIST.iter().map(|command| command.name), false, ) .into_iter() .map(|(name, _)| (0.., name.into())) .collect() } else { // Otherwise, use the command's completer and the last shellword // as completion input. let (word, word_len) = if words.len() == 1 || shellwords.ends_with_whitespace() { (&Cow::Borrowed(""), 0) } else { (words.last().unwrap(), words.last().unwrap().len()) }; let argument_number = argument_number_of(&shellwords); if let Some(completer) = TYPABLE_COMMAND_MAP .get(&words[0] as &str) .map(|tc| tc.completer_for_argument_number(argument_number)) { completer(editor, word) .into_iter() .map(|(range, file)| { let file = shellwords::escape(file); // offset ranges to input let offset = input.len() - word_len; let range = (range.start + offset)..; (range, file) }) .collect() } else { Vec::new() } } }, // completion move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { let parts = input.split_whitespace().collect::>(); if parts.is_empty() { return; } // 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) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { cx.editor.set_error(format!("{}", e)); } return; } // Handle typable commands if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { let shellwords = Shellwords::from(input); let args = shellwords.words(); if let Err(e) = (cmd.fun)(cx, &args[1..], event) { cx.editor.set_error(format!("{}", e)); } } else if event == PromptEvent::Validate { cx.editor .set_error(format!("no such command: '{}'", parts[0])); } }, ); prompt.doc_fn = Box::new(|input: &str| { let part = input.split(' ').next().unwrap_or_default(); if let Some(typed::TypableCommand { doc, aliases, .. }) = typed::TYPABLE_COMMAND_MAP.get(part) { if aliases.is_empty() { return Some((*doc).into()); } return Some(format!("{}\nAliases: {}", doc, aliases.join(", ")).into()); } None }); // Calculate initial completion prompt.recalculate_completion(cx.editor); cx.push_layer(Box::new(prompt)); } fn argument_number_of(shellwords: &Shellwords) -> usize { if shellwords.ends_with_whitespace() { shellwords.words().len().saturating_sub(1) } else { shellwords.words().len().saturating_sub(2) } } #[test] fn test_argument_number_of() { let cases = vec![ ("set-option", 0), ("set-option ", 0), ("set-option a", 0), ("set-option asdf", 0), ("set-option asdf ", 1), ("set-option asdf xyz", 1), ("set-option asdf xyz abc", 2), ("set-option asdf xyz abc ", 3), ]; for case in cases { assert_eq!(case.1, argument_number_of(&Shellwords::from(case.0))); } }