diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2a8f462d..45ab7659 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,8 +1,10 @@ pub(crate) mod dap; pub(crate) mod lsp; +pub(crate) mod typed; pub use dap::*; pub use lsp::*; +pub use typed::*; use helix_core::{ comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, @@ -179,7 +181,7 @@ impl MappableCommand { match &self { Self::Typable { name, args, doc: _ } => { let args: Vec> = args.iter().map(Cow::from).collect(); - if let Some(command) = cmd::TYPABLE_COMMAND_MAP.get(name.as_str()) { + if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { editor: cx.editor, jobs: cx.jobs, @@ -459,7 +461,7 @@ impl std::str::FromStr for MappableCommand { let args = typable_command .map(|s| s.to_owned()) .collect::>(); - cmd::TYPABLE_COMMAND_MAP + typed::TYPABLE_COMMAND_MAP .get(name) .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), @@ -2017,1473 +2019,6 @@ fn append_mode(cx: &mut Context) { doc.set_selection(view.id, selection); } -pub mod cmd { - use super::*; - - use helix_view::editor::Action; - 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<()>, - pub completer: Option, - } - - fn quit( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - // last view and we have unsaved changes - if cx.editor.tree.views().count() == 1 { - buffers_remaining_impl(cx.editor)? - } - - cx.editor.close(view!(cx.editor).id); - - Ok(()) - } - - fn force_quit( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - cx.editor.close(view!(cx.editor).id); - - Ok(()) - } - - fn open( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - ensure!(!args.is_empty(), "wrong argument count"); - for arg in args { - let (path, pos) = args::parse_file(arg); - 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( - editor: &mut Editor, - doc_ids: &[DocumentId], - force: bool, - ) -> anyhow::Result<()> { - for &doc_id in doc_ids { - editor.close_document(doc_id, force)?; - } - - 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<()> { - let document_ids = buffer_gather_paths_impl(cx.editor, args); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) - } - - fn force_buffer_close( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let document_ids = buffer_gather_paths_impl(cx.editor, args); - buffer_close_by_ids_impl(cx.editor, &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<()> { - let document_ids = buffer_gather_others_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) - } - - fn force_buffer_close_others( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let document_ids = buffer_gather_others_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &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<()> { - let document_ids = buffer_gather_all_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, false) - } - - fn force_buffer_close_all( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let document_ids = buffer_gather_all_impl(cx.editor); - buffer_close_by_ids_impl(cx.editor, &document_ids, true) - } - - fn write_impl(cx: &mut compositor::Context, path: Option<&Cow>) -> anyhow::Result<()> { - let jobs = &mut cx.jobs; - let doc = doc_mut!(cx.editor); - - if let Some(ref path) = path { - doc.set_path(Some(path.as_ref().as_ref())) - .context("invalid filepath")?; - } - if doc.path().is_none() { - bail!("cannot write a buffer without a filename"); - } - let fmt = doc.auto_format().map(|fmt| { - let shared = fmt.shared(); - let callback = make_format_callback( - doc.id(), - doc.version(), - Modified::SetUnmodified, - shared.clone(), - ); - jobs.callback(callback); - shared - }); - let future = doc.format_and_save(fmt); - cx.jobs.add(Job::new(future).wait_before_exiting()); - - if path.is_some() { - let id = doc.id(); - let _ = cx.editor.refresh_language_server(id); - } - Ok(()) - } - - fn write( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - write_impl(cx, args.first()) - } - - fn new_file( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - cx.editor.new_file(Action::Replace); - - Ok(()) - } - - fn format( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let doc = doc!(cx.editor); - if let Some(format) = doc.format() { - let callback = - make_format_callback(doc.id(), doc.version(), Modified::LeaveModified, format); - cx.jobs.callback(callback); - } - - Ok(()) - } - fn set_indent_style( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - 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<()> { - 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", - FF => "form feed", - CR => "carriage return", - Nel => "next line", - - // These should never be a document's default line ending. - 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 { - // We check for CR first because it shares a common prefix with CRLF. - arg if arg.starts_with("cr") => CR, - arg if arg.starts_with("crlf") => Crlf, - arg if arg.starts_with("lf") => LF, - arg if arg.starts_with("ff") => FF, - arg if arg.starts_with("nel") => Nel, - _ => bail!("invalid line ending"), - }; - - doc_mut!(cx.editor).line_ending = line_ending; - Ok(()) - } - - fn earlier( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; - - let (view, doc) = current!(cx.editor); - let success = doc.earlier(view.id, uk); - if !success { - cx.editor.set_status("Already at oldest change"); - } - - Ok(()) - } - - fn later( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; - let (view, doc) = current!(cx.editor); - let success = doc.later(view.id, 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<()> { - write_impl(cx, args.first())?; - quit(cx, &[], event) - } - - fn force_write_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_impl(cx, args.first())?; - force_quit(cx, &[], event) - } - - /// Results an error if there are modified buffers remaining and sets editor error, - /// otherwise returns `Ok(())` - pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> { - let modified: Vec<_> = editor - .documents() - .filter(|doc| doc.is_modified()) - .map(|doc| { - doc.relative_path() - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) - }) - .collect(); - if !modified.is_empty() { - bail!( - "{} unsaved buffer(s) remaining: {:?}", - modified.len(), - modified - ); - } - Ok(()) - } - - fn write_all_impl( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - quit: bool, - force: bool, - ) -> anyhow::Result<()> { - let mut errors = String::new(); - let jobs = &mut cx.jobs; - // save all documents - for doc in &mut cx.editor.documents.values_mut() { - if doc.path().is_none() { - errors.push_str("cannot write a buffer without a filename\n"); - continue; - } - - if !doc.is_modified() { - continue; - } - - let fmt = doc.auto_format().map(|fmt| { - let shared = fmt.shared(); - let callback = make_format_callback( - doc.id(), - doc.version(), - Modified::SetUnmodified, - shared.clone(), - ); - jobs.callback(callback); - shared - }); - let future = doc.format_and_save(fmt); - jobs.add(Job::new(future).wait_before_exiting()); - } - - if quit { - 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); - } - } - - bail!(errors) - } - - fn write_all( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_all_impl(cx, args, event, false, false) - } - - fn write_all_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_all_impl(cx, args, event, true, false) - } - - fn force_write_all_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_all_impl(cx, args, event, true, true) - } - - fn quit_all_impl(editor: &mut Editor, force: bool) -> anyhow::Result<()> { - if !force { - buffers_remaining_impl(editor)?; - } - - // close all views - let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); - for view_id in views { - editor.close(view_id); - } - - Ok(()) - } - - fn quit_all( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - quit_all_impl(cx.editor, false) - } - - fn force_quit_all( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - quit_all_impl(cx.editor, true) - } - - fn cquit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let exit_code = args - .first() - .and_then(|code| code.parse::().ok()) - .unwrap_or(1); - cx.editor.exit_code = exit_code; - - quit_all_impl(cx.editor, false) - } - - fn force_cquit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let exit_code = args - .first() - .and_then(|code| code.parse::().ok()) - .unwrap_or(1); - cx.editor.exit_code = exit_code; - - quit_all_impl(cx.editor, true) - } - - fn theme( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let theme = args.first().context("Theme not provided")?; - let theme = cx - .editor - .theme_loader - .load(theme) - .with_context(|| format!("Failed setting theme {}", theme))?; - let true_color = cx.editor.config.true_color || crate::true_color(); - if !(true_color || theme.is_16_color()) { - bail!("Unsupported theme: theme requires true color support"); - } - cx.editor.set_theme(theme); - Ok(()) - } - - fn yank_main_selection_to_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) - } - - fn yank_joined_to_clipboard( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - 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_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) - } - - fn yank_main_selection_to_primary_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) - } - - fn yank_joined_to_primary_clipboard( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - 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_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) - } - - fn paste_clipboard_after( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) - } - - fn paste_clipboard_before( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) - } - - fn paste_primary_clipboard_after( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) - } - - fn paste_primary_clipboard_before( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) - } - - fn replace_selections_with_clipboard_impl( - cx: &mut compositor::Context, - clipboard_type: ClipboardType, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - - match cx.editor.clipboard_provider.get_contents(clipboard_type) { - Ok(contents) => { - let selection = doc.selection(view.id); - let transaction = - Transaction::change_by_selection(doc.text(), selection, |range| { - (range.from(), range.to(), Some(contents.as_str().into())) - }); - - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); - Ok(()) - } - Err(e) => Err(e.context("Couldn't get system clipboard contents")), - } - } - - fn replace_selections_with_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) - } - - fn replace_selections_with_primary_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) - } - - fn show_clipboard_provider( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - cx.editor - .set_status(cx.editor.clipboard_provider.name().to_string()); - Ok(()) - } - - fn change_current_directory( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let dir = helix_core::path::expand_tilde( - args.first() - .context("target directory not provided")? - .as_ref() - .as_ref(), - ); - - if let Err(e) = std::env::set_current_dir(dir) { - bail!("Couldn't change the current working directory: {}", e); - } - - let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; - cx.editor.set_status(format!( - "Current working directory is now {}", - cwd.display() - )); - Ok(()) - } - - fn show_current_directory( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; - cx.editor - .set_status(format!("Current working directory is {}", cwd.display())); - Ok(()) - } - - /// Sets the [`Document`]'s encoding.. - fn set_encoding( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - 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(()) - } - } - - /// Reload the [`Document`] from its source file. - fn reload( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - doc.reload(view.id) - } - - fn tree_sitter_scopes( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - 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); - cx.editor.set_status(format!("scopes: {:?}", &scopes)); - Ok(()) - } - - fn vsplit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let id = view!(cx.editor).doc; - - 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(()) - } - - fn hsplit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let id = view!(cx.editor).doc; - - 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(()) - } - - fn debug_eval( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - 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<()> { - 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<()> { - 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<()> { - let path = helix_core::runtime_dir().join("tutor.txt"); - 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(()) - } - - pub(super) fn goto_line_number( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - ensure!(!args.is_empty(), "Line number required"); - - let line = args[0].parse::()?; - - goto_line_impl(cx.editor, NonZeroUsize::new(line)); - - let (view, doc) = current!(cx.editor); - - view.ensure_cursor_in_view(doc, line); - Ok(()) - } - - fn setting( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let runtime_config = &mut cx.editor.config; - - if args.len() != 2 { - anyhow::bail!("Bad arguments. Usage: `:set key field`"); - } - - let (key, arg) = (&args[0].to_lowercase(), &args[1]); - - match key.as_ref() { - "scrolloff" => runtime_config.scrolloff = arg.parse()?, - "scroll-lines" => runtime_config.scroll_lines = arg.parse()?, - "mouse" => runtime_config.mouse = arg.parse()?, - "line-number" => runtime_config.line_number = arg.parse()?, - "middle-click_paste" => runtime_config.middle_click_paste = arg.parse()?, - "auto-pairs" => runtime_config.auto_pairs = arg.parse()?, - "auto-completion" => runtime_config.auto_completion = arg.parse()?, - "completion-trigger-len" => runtime_config.completion_trigger_len = arg.parse()?, - "auto-info" => runtime_config.auto_info = arg.parse()?, - "true-color" => runtime_config.true_color = arg.parse()?, - "search.smart-case" => runtime_config.search.smart_case = arg.parse()?, - "search.wrap-around" => runtime_config.search.wrap_around = arg.parse()?, - _ => anyhow::bail!("Unknown key `{}`.", args[0]), - } - - Ok(()) - } - - fn sort( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - sort_impl(cx, args, false) - } - - fn sort_reverse( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - sort_impl(cx, args, true) - } - - fn sort_impl( - cx: &mut compositor::Context, - _args: &[Cow], - reverse: bool, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - - let selection = doc.selection(view.id); - - let mut fragments: Vec<_> = selection - .fragments(text) - .map(|fragment| Tendril::from(fragment.as_ref())) - .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.id); - - Ok(()) - } - - fn tree_sitter_subtree( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - 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 contents = format!("```tsq\n{}\n```", selected_node.to_sexp()); - - let callback = async move { - let call: job::Callback = - 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(()) - } - - pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ - TypableCommand { - name: "quit", - aliases: &["q"], - doc: "Close the current view.", - fun: quit, - completer: None, - }, - TypableCommand { - name: "quit!", - aliases: &["q!"], - doc: "Close the current view forcefully (ignoring unsaved changes).", - fun: force_quit, - completer: None, - }, - TypableCommand { - name: "open", - aliases: &["o"], - doc: "Open a file from disk into the current view.", - fun: open, - completer: Some(completers::filename), - }, - TypableCommand { - name: "buffer-close", - aliases: &["bc", "bclose"], - doc: "Close the current buffer.", - fun: buffer_close, - completer: Some(completers::buffer), - }, - TypableCommand { - name: "buffer-close!", - aliases: &["bc!", "bclose!"], - doc: "Close the current buffer forcefully (ignoring unsaved changes).", - fun: force_buffer_close, - completer: Some(completers::buffer), - }, - TypableCommand { - name: "buffer-close-others", - aliases: &["bco", "bcloseother"], - doc: "Close all buffers but the currently focused one.", - fun: buffer_close_others, - completer: None, - }, - TypableCommand { - name: "buffer-close-others!", - aliases: &["bco!", "bcloseother!"], - doc: "Close all buffers but the currently focused one.", - fun: force_buffer_close_others, - completer: None, - }, - TypableCommand { - name: "buffer-close-all", - aliases: &["bca", "bcloseall"], - doc: "Close all buffers, without quiting.", - fun: buffer_close_all, - completer: None, - }, - TypableCommand { - name: "buffer-close-all!", - aliases: &["bca!", "bcloseall!"], - doc: "Close all buffers forcefully (ignoring unsaved changes), without quiting.", - fun: force_buffer_close_all, - completer: None, - }, - TypableCommand { - name: "write", - aliases: &["w"], - doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)", - fun: write, - completer: Some(completers::filename), - }, - TypableCommand { - name: "new", - aliases: &["n"], - doc: "Create a new scratch buffer.", - fun: new_file, - completer: Some(completers::filename), - }, - TypableCommand { - name: "format", - aliases: &["fmt"], - doc: "Format the file using the LSP formatter.", - fun: format, - completer: 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, - completer: None, - }, - TypableCommand { - name: "line-ending", - aliases: &[], - doc: "Set the document's default line ending. Options: crlf, lf, cr, ff, nel.", - fun: set_line_ending, - completer: 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, - completer: 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, - completer: 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, - completer: Some(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, - completer: Some(completers::filename), - }, - TypableCommand { - name: "write-all", - aliases: &["wa"], - doc: "Write changes from all views to disk.", - fun: write_all, - completer: None, - }, - TypableCommand { - name: "write-quit-all", - aliases: &["wqa", "xa"], - doc: "Write changes from all views to disk and close all views.", - fun: write_all_quit, - completer: None, - }, - TypableCommand { - name: "write-quit-all!", - aliases: &["wqa!", "xa!"], - doc: "Write changes from all views to disk and close all views forcefully (ignoring unsaved changes).", - fun: force_write_all_quit, - completer: None, - }, - TypableCommand { - name: "quit-all", - aliases: &["qa"], - doc: "Close all views.", - fun: quit_all, - completer: None, - }, - TypableCommand { - name: "quit-all!", - aliases: &["qa!"], - doc: "Close all views forcefully (ignoring unsaved changes).", - fun: force_quit_all, - completer: None, - }, - TypableCommand { - name: "cquit", - aliases: &["cq"], - doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", - fun: cquit, - completer: None, - }, - TypableCommand { - name: "cquit!", - aliases: &["cq!"], - doc: "Quit with exit code (default 1) forcefully (ignoring unsaved changes). Accepts an optional integer exit code (:cq! 2).", - fun: force_cquit, - completer: None, - }, - TypableCommand { - name: "theme", - aliases: &[], - doc: "Change the editor theme.", - fun: theme, - completer: Some(completers::theme), - }, - TypableCommand { - name: "clipboard-yank", - aliases: &[], - doc: "Yank main selection into system clipboard.", - fun: yank_main_selection_to_clipboard, - completer: 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, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-yank", - aliases: &[], - doc: "Yank main selection into system primary clipboard.", - fun: yank_main_selection_to_primary_clipboard, - completer: 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, - completer: None, - }, - TypableCommand { - name: "clipboard-paste-after", - aliases: &[], - doc: "Paste system clipboard after selections.", - fun: paste_clipboard_after, - completer: None, - }, - TypableCommand { - name: "clipboard-paste-before", - aliases: &[], - doc: "Paste system clipboard before selections.", - fun: paste_clipboard_before, - completer: None, - }, - TypableCommand { - name: "clipboard-paste-replace", - aliases: &[], - doc: "Replace selections with content of system clipboard.", - fun: replace_selections_with_clipboard, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-paste-after", - aliases: &[], - doc: "Paste primary clipboard after selections.", - fun: paste_primary_clipboard_after, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-paste-before", - aliases: &[], - doc: "Paste primary clipboard before selections.", - fun: paste_primary_clipboard_before, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-paste-replace", - aliases: &[], - doc: "Replace selections with content of system primary clipboard.", - fun: replace_selections_with_primary_clipboard, - completer: None, - }, - TypableCommand { - name: "show-clipboard-provider", - aliases: &[], - doc: "Show clipboard provider name in status bar.", - fun: show_clipboard_provider, - completer: None, - }, - TypableCommand { - name: "change-current-directory", - aliases: &["cd"], - doc: "Change the current working directory.", - fun: change_current_directory, - completer: Some(completers::directory), - }, - TypableCommand { - name: "show-directory", - aliases: &["pwd"], - doc: "Show the current working directory.", - fun: show_current_directory, - completer: None, - }, - TypableCommand { - name: "encoding", - aliases: &[], - doc: "Set encoding based on `https://encoding.spec.whatwg.org`", - fun: set_encoding, - completer: None, - }, - TypableCommand { - name: "reload", - aliases: &[], - doc: "Discard changes and reload from the source file.", - fun: reload, - completer: None, - }, - TypableCommand { - name: "tree-sitter-scopes", - aliases: &[], - doc: "Display tree sitter scopes, primarily for theming and development.", - fun: tree_sitter_scopes, - completer: None, - }, - TypableCommand { - name: "debug-start", - aliases: &["dbg"], - doc: "Start a debug session from a given template with given parameters.", - fun: debug_start, - completer: 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, - completer: None, - }, - TypableCommand { - name: "debug-eval", - aliases: &[], - doc: "Evaluate expression in current debug context.", - fun: debug_eval, - completer: None, - }, - TypableCommand { - name: "vsplit", - aliases: &["vs"], - doc: "Open the file in a vertical split.", - fun: vsplit, - completer: Some(completers::filename), - }, - TypableCommand { - name: "hsplit", - aliases: &["hs", "sp"], - doc: "Open the file in a horizontal split.", - fun: hsplit, - completer: Some(completers::filename), - }, - TypableCommand { - name: "tutor", - aliases: &[], - doc: "Open the tutorial.", - fun: tutor, - completer: None, - }, - TypableCommand { - name: "goto", - aliases: &["g"], - doc: "Go to line number.", - fun: goto_line_number, - completer: None, - }, - TypableCommand { - name: "set-option", - aliases: &["set"], - doc: "Set a config option at runtime", - fun: setting, - completer: Some(completers::setting), - }, - TypableCommand { - name: "sort", - aliases: &[], - doc: "Sort ranges in selection.", - fun: sort, - completer: None, - }, - TypableCommand { - name: "rsort", - aliases: &[], - doc: "Sort ranges in selection in reverse order.", - fun: sort_reverse, - completer: None, - }, - TypableCommand { - name: "tree-sitter-subtree", - aliases: &["ts-subtree"], - doc: "Display tree sitter subtree under cursor, primarily for debugging queries.", - fun: tree_sitter_subtree, - completer: 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() - }); -} - -fn command_mode(cx: &mut Context) { - let mut prompt = Prompt::new( - ":".into(), - Some(':'), - |editor: &Editor, input: &str| { - static FUZZY_MATCHER: Lazy = - Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); - - // we use .this over split_whitespace() because we care about empty segments - let parts = input.split(' ').collect::>(); - - // simple heuristic: if there's no just one part, complete command name. - // if there's a space, per command completion kicks in. - if parts.len() <= 1 { - let mut matches: Vec<_> = cmd::TYPABLE_COMMAND_LIST - .iter() - .filter_map(|command| { - FUZZY_MATCHER - .fuzzy_match(command.name, input) - .map(|score| (command.name, score)) - }) - .collect(); - - matches.sort_unstable_by_key(|(_file, score)| std::cmp::Reverse(*score)); - matches - .into_iter() - .map(|(name, _)| (0.., name.into())) - .collect() - } else { - let part = parts.last().unwrap(); - - if let Some(cmd::TypableCommand { - completer: Some(completer), - .. - }) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) - { - completer(editor, part) - .into_iter() - .map(|(range, file)| { - // offset ranges to input - let offset = input.len() - part.len(); - let range = (range.start + offset)..; - (range, file) - }) - .collect() - } else { - Vec::new() - } - } - }, // completion - move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - if event != PromptEvent::Validate { - return; - } - - 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) = cmd::goto_line_number(cx, &[Cow::from(parts[0])], event) { - cx.editor.set_error(format!("{}", e)); - } - return; - } - - // Handle typable commands - if let Some(cmd) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { - let args = if cfg!(unix) { - shellwords::shellwords(input) - } else { - // Windows doesn't support POSIX, so fallback for now - parts - .into_iter() - .map(|part| part.into()) - .collect::>() - }; - - if let Err(e) = (cmd.fun)(cx, &args[1..], event) { - cx.editor.set_error(format!("{}", e)); - } - } else { - 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(cmd::TypableCommand { doc, aliases, .. }) = cmd::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 file_picker(cx: &mut Context) { // We don't specify language markers, root will be the root of the current git repo let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./")); @@ -3567,15 +2102,13 @@ pub fn command_palette(cx: &mut Context) { compositor.find::().unwrap().keymaps[&doc.mode].reverse_map(); let mut commands: Vec = MappableCommand::STATIC_COMMAND_LIST.into(); - commands.extend( - cmd::TYPABLE_COMMAND_LIST - .iter() - .map(|cmd| MappableCommand::Typable { - name: cmd.name.to_owned(), - doc: cmd.doc.to_owned(), - args: Vec::new(), - }), - ); + commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { + MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: cmd.doc.to_owned(), + args: Vec::new(), + } + })); // formats key bindings, multiple bindings are comma separated, // individual key presses are joined with `+` @@ -5260,7 +3793,7 @@ fn vsplit(cx: &mut Context) { fn wclose(cx: &mut Context) { if cx.editor.tree.views().count() == 1 { - if let Err(err) = cmd::buffers_remaining_impl(cx.editor) { + if let Err(err) = typed::buffers_remaining_impl(cx.editor) { cx.editor.set_error(err.to_string()); return; } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs new file mode 100644 index 00000000..4cc996d6 --- /dev/null +++ b/helix-term/src/commands/typed.rs @@ -0,0 +1,1465 @@ +use super::*; + +use helix_view::editor::Action; +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<()>, + pub completer: Option, +} + +fn quit( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + // last view and we have unsaved changes + if cx.editor.tree.views().count() == 1 { + buffers_remaining_impl(cx.editor)? + } + + cx.editor.close(view!(cx.editor).id); + + Ok(()) +} + +fn force_quit( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor.close(view!(cx.editor).id); + + Ok(()) +} + +fn open( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + ensure!(!args.is_empty(), "wrong argument count"); + for arg in args { + let (path, pos) = args::parse_file(arg); + 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( + editor: &mut Editor, + doc_ids: &[DocumentId], + force: bool, +) -> anyhow::Result<()> { + for &doc_id in doc_ids { + editor.close_document(doc_id, force)?; + } + + 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<()> { + let document_ids = buffer_gather_paths_impl(cx.editor, args); + buffer_close_by_ids_impl(cx.editor, &document_ids, false) +} + +fn force_buffer_close( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_paths_impl(cx.editor, args); + buffer_close_by_ids_impl(cx.editor, &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<()> { + let document_ids = buffer_gather_others_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &document_ids, false) +} + +fn force_buffer_close_others( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_others_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &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<()> { + let document_ids = buffer_gather_all_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &document_ids, false) +} + +fn force_buffer_close_all( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_all_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &document_ids, true) +} + +fn write_impl(cx: &mut compositor::Context, path: Option<&Cow>) -> anyhow::Result<()> { + let jobs = &mut cx.jobs; + let doc = doc_mut!(cx.editor); + + if let Some(ref path) = path { + doc.set_path(Some(path.as_ref().as_ref())) + .context("invalid filepath")?; + } + if doc.path().is_none() { + bail!("cannot write a buffer without a filename"); + } + let fmt = doc.auto_format().map(|fmt| { + let shared = fmt.shared(); + let callback = make_format_callback( + doc.id(), + doc.version(), + Modified::SetUnmodified, + shared.clone(), + ); + jobs.callback(callback); + shared + }); + let future = doc.format_and_save(fmt); + cx.jobs.add(Job::new(future).wait_before_exiting()); + + if path.is_some() { + let id = doc.id(); + let _ = cx.editor.refresh_language_server(id); + } + Ok(()) +} + +fn write( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + write_impl(cx, args.first()) +} + +fn new_file( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor.new_file(Action::Replace); + + Ok(()) +} + +fn format( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let doc = doc!(cx.editor); + if let Some(format) = doc.format() { + let callback = + make_format_callback(doc.id(), doc.version(), Modified::LeaveModified, format); + cx.jobs.callback(callback); + } + + Ok(()) +} +fn set_indent_style( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + 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<()> { + 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", + FF => "form feed", + CR => "carriage return", + Nel => "next line", + + // These should never be a document's default line ending. + 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 { + // We check for CR first because it shares a common prefix with CRLF. + arg if arg.starts_with("cr") => CR, + arg if arg.starts_with("crlf") => Crlf, + arg if arg.starts_with("lf") => LF, + arg if arg.starts_with("ff") => FF, + arg if arg.starts_with("nel") => Nel, + _ => bail!("invalid line ending"), + }; + + doc_mut!(cx.editor).line_ending = line_ending; + Ok(()) +} + +fn earlier( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; + + let (view, doc) = current!(cx.editor); + let success = doc.earlier(view.id, uk); + if !success { + cx.editor.set_status("Already at oldest change"); + } + + Ok(()) +} + +fn later( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; + let (view, doc) = current!(cx.editor); + let success = doc.later(view.id, 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<()> { + write_impl(cx, args.first())?; + quit(cx, &[], event) +} + +fn force_write_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_impl(cx, args.first())?; + force_quit(cx, &[], event) +} + +/// Results an error if there are modified buffers remaining and sets editor error, +/// otherwise returns `Ok(())` +pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> { + let modified: Vec<_> = editor + .documents() + .filter(|doc| doc.is_modified()) + .map(|doc| { + doc.relative_path() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) + }) + .collect(); + if !modified.is_empty() { + bail!( + "{} unsaved buffer(s) remaining: {:?}", + modified.len(), + modified + ); + } + Ok(()) +} + +fn write_all_impl( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, + quit: bool, + force: bool, +) -> anyhow::Result<()> { + let mut errors = String::new(); + let jobs = &mut cx.jobs; + // save all documents + for doc in &mut cx.editor.documents.values_mut() { + if doc.path().is_none() { + errors.push_str("cannot write a buffer without a filename\n"); + continue; + } + + if !doc.is_modified() { + continue; + } + + let fmt = doc.auto_format().map(|fmt| { + let shared = fmt.shared(); + let callback = make_format_callback( + doc.id(), + doc.version(), + Modified::SetUnmodified, + shared.clone(), + ); + jobs.callback(callback); + shared + }); + let future = doc.format_and_save(fmt); + jobs.add(Job::new(future).wait_before_exiting()); + } + + if quit { + 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); + } + } + + bail!(errors) +} + +fn write_all( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_all_impl(cx, args, event, false, false) +} + +fn write_all_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_all_impl(cx, args, event, true, false) +} + +fn force_write_all_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_all_impl(cx, args, event, true, true) +} + +fn quit_all_impl(editor: &mut Editor, force: bool) -> anyhow::Result<()> { + if !force { + buffers_remaining_impl(editor)?; + } + + // close all views + let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); + for view_id in views { + editor.close(view_id); + } + + Ok(()) +} + +fn quit_all( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + quit_all_impl(cx.editor, false) +} + +fn force_quit_all( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + quit_all_impl(cx.editor, true) +} + +fn cquit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let exit_code = args + .first() + .and_then(|code| code.parse::().ok()) + .unwrap_or(1); + cx.editor.exit_code = exit_code; + + quit_all_impl(cx.editor, false) +} + +fn force_cquit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let exit_code = args + .first() + .and_then(|code| code.parse::().ok()) + .unwrap_or(1); + cx.editor.exit_code = exit_code; + + quit_all_impl(cx.editor, true) +} + +fn theme( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let theme = args.first().context("Theme not provided")?; + let theme = cx + .editor + .theme_loader + .load(theme) + .with_context(|| format!("Failed setting theme {}", theme))?; + let true_color = cx.editor.config.true_color || crate::true_color(); + if !(true_color || theme.is_16_color()) { + bail!("Unsupported theme: theme requires true color support"); + } + cx.editor.set_theme(theme); + Ok(()) +} + +fn yank_main_selection_to_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) +} + +fn yank_joined_to_clipboard( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + 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_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) +} + +fn yank_main_selection_to_primary_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) +} + +fn yank_joined_to_primary_clipboard( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + 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_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) +} + +fn paste_clipboard_after( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) +} + +fn paste_clipboard_before( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) +} + +fn paste_primary_clipboard_after( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) +} + +fn paste_primary_clipboard_before( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) +} + +fn replace_selections_with_clipboard_impl( + cx: &mut compositor::Context, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + + match cx.editor.clipboard_provider.get_contents(clipboard_type) { + Ok(contents) => { + let selection = doc.selection(view.id); + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + (range.from(), range.to(), Some(contents.as_str().into())) + }); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + Ok(()) + } + Err(e) => Err(e.context("Couldn't get system clipboard contents")), + } +} + +fn replace_selections_with_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) +} + +fn replace_selections_with_primary_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) +} + +fn show_clipboard_provider( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor + .set_status(cx.editor.clipboard_provider.name().to_string()); + Ok(()) +} + +fn change_current_directory( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let dir = helix_core::path::expand_tilde( + args.first() + .context("target directory not provided")? + .as_ref() + .as_ref(), + ); + + if let Err(e) = std::env::set_current_dir(dir) { + bail!("Couldn't change the current working directory: {}", e); + } + + let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; + cx.editor.set_status(format!( + "Current working directory is now {}", + cwd.display() + )); + Ok(()) +} + +fn show_current_directory( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; + cx.editor + .set_status(format!("Current working directory is {}", cwd.display())); + Ok(()) +} + +/// Sets the [`Document`]'s encoding.. +fn set_encoding( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + 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(()) + } +} + +/// Reload the [`Document`] from its source file. +fn reload( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + doc.reload(view.id) +} + +fn tree_sitter_scopes( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + 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); + cx.editor.set_status(format!("scopes: {:?}", &scopes)); + Ok(()) +} + +fn vsplit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let id = view!(cx.editor).doc; + + 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(()) +} + +fn hsplit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let id = view!(cx.editor).doc; + + 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(()) +} + +fn debug_eval( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + 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<()> { + 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<()> { + 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<()> { + let path = helix_core::runtime_dir().join("tutor.txt"); + 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(()) +} + +pub(super) fn goto_line_number( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + ensure!(!args.is_empty(), "Line number required"); + + let line = args[0].parse::()?; + + goto_line_impl(cx.editor, NonZeroUsize::new(line)); + + let (view, doc) = current!(cx.editor); + + view.ensure_cursor_in_view(doc, line); + Ok(()) +} + +fn setting( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let runtime_config = &mut cx.editor.config; + + if args.len() != 2 { + anyhow::bail!("Bad arguments. Usage: `:set key field`"); + } + + let (key, arg) = (&args[0].to_lowercase(), &args[1]); + + match key.as_ref() { + "scrolloff" => runtime_config.scrolloff = arg.parse()?, + "scroll-lines" => runtime_config.scroll_lines = arg.parse()?, + "mouse" => runtime_config.mouse = arg.parse()?, + "line-number" => runtime_config.line_number = arg.parse()?, + "middle-click_paste" => runtime_config.middle_click_paste = arg.parse()?, + "auto-pairs" => runtime_config.auto_pairs = arg.parse()?, + "auto-completion" => runtime_config.auto_completion = arg.parse()?, + "completion-trigger-len" => runtime_config.completion_trigger_len = arg.parse()?, + "auto-info" => runtime_config.auto_info = arg.parse()?, + "true-color" => runtime_config.true_color = arg.parse()?, + "search.smart-case" => runtime_config.search.smart_case = arg.parse()?, + "search.wrap-around" => runtime_config.search.wrap_around = arg.parse()?, + _ => anyhow::bail!("Unknown key `{}`.", args[0]), + } + + Ok(()) +} + +fn sort( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + sort_impl(cx, args, false) +} + +fn sort_reverse( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + sort_impl(cx, args, true) +} + +fn sort_impl( + cx: &mut compositor::Context, + _args: &[Cow], + reverse: bool, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id); + + let mut fragments: Vec<_> = selection + .fragments(text) + .map(|fragment| Tendril::from(fragment.as_ref())) + .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.id); + + Ok(()) +} + +fn tree_sitter_subtree( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + 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 contents = format!("```tsq\n{}\n```", selected_node.to_sexp()); + + let callback = async move { + let call: job::Callback = + 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(()) +} + +pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ + TypableCommand { + name: "quit", + aliases: &["q"], + doc: "Close the current view.", + fun: quit, + completer: None, + }, + TypableCommand { + name: "quit!", + aliases: &["q!"], + doc: "Close the current view forcefully (ignoring unsaved changes).", + fun: force_quit, + completer: None, + }, + TypableCommand { + name: "open", + aliases: &["o"], + doc: "Open a file from disk into the current view.", + fun: open, + completer: Some(completers::filename), + }, + TypableCommand { + name: "buffer-close", + aliases: &["bc", "bclose"], + doc: "Close the current buffer.", + fun: buffer_close, + completer: Some(completers::buffer), + }, + TypableCommand { + name: "buffer-close!", + aliases: &["bc!", "bclose!"], + doc: "Close the current buffer forcefully (ignoring unsaved changes).", + fun: force_buffer_close, + completer: Some(completers::buffer), + }, + TypableCommand { + name: "buffer-close-others", + aliases: &["bco", "bcloseother"], + doc: "Close all buffers but the currently focused one.", + fun: buffer_close_others, + completer: None, + }, + TypableCommand { + name: "buffer-close-others!", + aliases: &["bco!", "bcloseother!"], + doc: "Close all buffers but the currently focused one.", + fun: force_buffer_close_others, + completer: None, + }, + TypableCommand { + name: "buffer-close-all", + aliases: &["bca", "bcloseall"], + doc: "Close all buffers, without quiting.", + fun: buffer_close_all, + completer: None, + }, + TypableCommand { + name: "buffer-close-all!", + aliases: &["bca!", "bcloseall!"], + doc: "Close all buffers forcefully (ignoring unsaved changes), without quiting.", + fun: force_buffer_close_all, + completer: None, + }, + TypableCommand { + name: "write", + aliases: &["w"], + doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)", + fun: write, + completer: Some(completers::filename), + }, + TypableCommand { + name: "new", + aliases: &["n"], + doc: "Create a new scratch buffer.", + fun: new_file, + completer: Some(completers::filename), + }, + TypableCommand { + name: "format", + aliases: &["fmt"], + doc: "Format the file using the LSP formatter.", + fun: format, + completer: 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, + completer: None, + }, + TypableCommand { + name: "line-ending", + aliases: &[], + doc: "Set the document's default line ending. Options: crlf, lf, cr, ff, nel.", + fun: set_line_ending, + completer: 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, + completer: 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, + completer: 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, + completer: Some(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, + completer: Some(completers::filename), + }, + TypableCommand { + name: "write-all", + aliases: &["wa"], + doc: "Write changes from all views to disk.", + fun: write_all, + completer: None, + }, + TypableCommand { + name: "write-quit-all", + aliases: &["wqa", "xa"], + doc: "Write changes from all views to disk and close all views.", + fun: write_all_quit, + completer: None, + }, + TypableCommand { + name: "write-quit-all!", + aliases: &["wqa!", "xa!"], + doc: "Write changes from all views to disk and close all views forcefully (ignoring unsaved changes).", + fun: force_write_all_quit, + completer: None, + }, + TypableCommand { + name: "quit-all", + aliases: &["qa"], + doc: "Close all views.", + fun: quit_all, + completer: None, + }, + TypableCommand { + name: "quit-all!", + aliases: &["qa!"], + doc: "Close all views forcefully (ignoring unsaved changes).", + fun: force_quit_all, + completer: None, + }, + TypableCommand { + name: "cquit", + aliases: &["cq"], + doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", + fun: cquit, + completer: None, + }, + TypableCommand { + name: "cquit!", + aliases: &["cq!"], + doc: "Quit with exit code (default 1) forcefully (ignoring unsaved changes). Accepts an optional integer exit code (:cq! 2).", + fun: force_cquit, + completer: None, + }, + TypableCommand { + name: "theme", + aliases: &[], + doc: "Change the editor theme.", + fun: theme, + completer: Some(completers::theme), + }, + TypableCommand { + name: "clipboard-yank", + aliases: &[], + doc: "Yank main selection into system clipboard.", + fun: yank_main_selection_to_clipboard, + completer: 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, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-yank", + aliases: &[], + doc: "Yank main selection into system primary clipboard.", + fun: yank_main_selection_to_primary_clipboard, + completer: 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, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-after", + aliases: &[], + doc: "Paste system clipboard after selections.", + fun: paste_clipboard_after, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-before", + aliases: &[], + doc: "Paste system clipboard before selections.", + fun: paste_clipboard_before, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-replace", + aliases: &[], + doc: "Replace selections with content of system clipboard.", + fun: replace_selections_with_clipboard, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-after", + aliases: &[], + doc: "Paste primary clipboard after selections.", + fun: paste_primary_clipboard_after, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-before", + aliases: &[], + doc: "Paste primary clipboard before selections.", + fun: paste_primary_clipboard_before, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-replace", + aliases: &[], + doc: "Replace selections with content of system primary clipboard.", + fun: replace_selections_with_primary_clipboard, + completer: None, + }, + TypableCommand { + name: "show-clipboard-provider", + aliases: &[], + doc: "Show clipboard provider name in status bar.", + fun: show_clipboard_provider, + completer: None, + }, + TypableCommand { + name: "change-current-directory", + aliases: &["cd"], + doc: "Change the current working directory.", + fun: change_current_directory, + completer: Some(completers::directory), + }, + TypableCommand { + name: "show-directory", + aliases: &["pwd"], + doc: "Show the current working directory.", + fun: show_current_directory, + completer: None, + }, + TypableCommand { + name: "encoding", + aliases: &[], + doc: "Set encoding based on `https://encoding.spec.whatwg.org`", + fun: set_encoding, + completer: None, + }, + TypableCommand { + name: "reload", + aliases: &[], + doc: "Discard changes and reload from the source file.", + fun: reload, + completer: None, + }, + TypableCommand { + name: "tree-sitter-scopes", + aliases: &[], + doc: "Display tree sitter scopes, primarily for theming and development.", + fun: tree_sitter_scopes, + completer: None, + }, + TypableCommand { + name: "debug-start", + aliases: &["dbg"], + doc: "Start a debug session from a given template with given parameters.", + fun: debug_start, + completer: 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, + completer: None, + }, + TypableCommand { + name: "debug-eval", + aliases: &[], + doc: "Evaluate expression in current debug context.", + fun: debug_eval, + completer: None, + }, + TypableCommand { + name: "vsplit", + aliases: &["vs"], + doc: "Open the file in a vertical split.", + fun: vsplit, + completer: Some(completers::filename), + }, + TypableCommand { + name: "hsplit", + aliases: &["hs", "sp"], + doc: "Open the file in a horizontal split.", + fun: hsplit, + completer: Some(completers::filename), + }, + TypableCommand { + name: "tutor", + aliases: &[], + doc: "Open the tutorial.", + fun: tutor, + completer: None, + }, + TypableCommand { + name: "goto", + aliases: &["g"], + doc: "Go to line number.", + fun: goto_line_number, + completer: None, + }, + TypableCommand { + name: "set-option", + aliases: &["set"], + doc: "Set a config option at runtime", + fun: setting, + completer: Some(completers::setting), + }, + TypableCommand { + name: "sort", + aliases: &[], + doc: "Sort ranges in selection.", + fun: sort, + completer: None, + }, + TypableCommand { + name: "rsort", + aliases: &[], + doc: "Sort ranges in selection in reverse order.", + fun: sort_reverse, + completer: None, + }, + TypableCommand { + name: "tree-sitter-subtree", + aliases: &["ts-subtree"], + doc: "Display tree sitter subtree under cursor, primarily for debugging queries.", + fun: tree_sitter_subtree, + completer: 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() + }); + +pub fn command_mode(cx: &mut Context) { + let mut prompt = Prompt::new( + ":".into(), + Some(':'), + |editor: &Editor, input: &str| { + static FUZZY_MATCHER: Lazy = + Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); + + // we use .this over split_whitespace() because we care about empty segments + let parts = input.split(' ').collect::>(); + + // simple heuristic: if there's no just one part, complete command name. + // if there's a space, per command completion kicks in. + if parts.len() <= 1 { + let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST + .iter() + .filter_map(|command| { + FUZZY_MATCHER + .fuzzy_match(command.name, input) + .map(|score| (command.name, score)) + }) + .collect(); + + matches.sort_unstable_by_key(|(_file, score)| std::cmp::Reverse(*score)); + matches + .into_iter() + .map(|(name, _)| (0.., name.into())) + .collect() + } else { + let part = parts.last().unwrap(); + + if let Some(typed::TypableCommand { + completer: Some(completer), + .. + }) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) + { + completer(editor, part) + .into_iter() + .map(|(range, file)| { + // offset ranges to input + let offset = input.len() - part.len(); + let range = (range.start + offset)..; + (range, file) + }) + .collect() + } else { + Vec::new() + } + } + }, // completion + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + 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 args = if cfg!(unix) { + shellwords::shellwords(input) + } else { + // Windows doesn't support POSIX, so fallback for now + parts + .into_iter() + .map(|part| part.into()) + .collect::>() + }; + + if let Err(e) = (cmd.fun)(cx, &args[1..], event) { + cx.editor.set_error(format!("{}", e)); + } + } else { + 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)); +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d24a29cc..ad0eb16f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -107,7 +107,7 @@ pub mod md_gen { use crate::path; use std::fs; - use helix_term::commands::cmd::TYPABLE_COMMAND_LIST; + use helix_term::commands::TYPABLE_COMMAND_LIST; pub const TYPABLE_COMMANDS_MD_OUTPUT: &str = "typable-cmd.md"; pub const LANG_SUPPORT_MD_OUTPUT: &str = "lang-support.md";