diff --git a/Cargo.lock b/Cargo.lock index 070453729f..2a2f22a550 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8059,6 +8059,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "rustyline-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb35a55ab810b5c0fe31606fe9b47d1354e4dc519bec0a102655f78ea2b38057" +dependencies = [ + "quote 1.0.17", + "syn 1.0.90", +] + [[package]] name = "rw-stream-sink" version = "0.2.1" @@ -8182,6 +8192,7 @@ dependencies = [ "rand 0.8.5", "rust-flatten-json", "rustyline", + "rustyline-derive", "serde 1.0.136", "serde_json", "thiserror", diff --git a/commons/scmd/Cargo.toml b/commons/scmd/Cargo.toml index 3651448f45..49c4d9c96b 100644 --- a/commons/scmd/Cargo.toml +++ b/commons/scmd/Cargo.toml @@ -10,7 +10,8 @@ edition = "2021" anyhow = "1.0.41" thiserror = "1.0" serde = { version = "1.0.130", features = ["derive"] } -rustyline = "9.0.0" +rustyline = "9.1.2" +rustyline-derive = "0.6.0" clap = { version = "3", features = ["derive"] } serde_json = { version="1.0", features = ["arbitrary_precision"]} rust-flatten-json = "0.2.0" diff --git a/commons/scmd/src/console.rs b/commons/scmd/src/console.rs new file mode 100644 index 0000000000..1628f713f5 --- /dev/null +++ b/commons/scmd/src/console.rs @@ -0,0 +1,175 @@ +// Copyright (c) The Starcoin Core Contributors +// SPDX-License-Identifier: Apache-2.0 + +use std::borrow::Cow::{self, Borrowed, Owned}; + +use once_cell::sync::Lazy; +use rustyline::completion::{extract_word, Completer, FilenameCompleter, Pair}; +use rustyline::highlight::{Highlighter, MatchingBracketHighlighter}; +use rustyline::hint::Hinter; +use rustyline::validate::{self, MatchingBracketValidator, Validator}; +use rustyline::Context; +use rustyline_derive::Helper; +use std::collections::HashSet; + +use rustyline::{ + config::CompletionType, error::ReadlineError, ColorMode, Config as ConsoleConfig, EditMode, + OutputStreamType, +}; + +pub static G_DEFAULT_CONSOLE_CONFIG: Lazy = Lazy::new(|| { + ConsoleConfig::builder() + .max_history_size(1000) + .history_ignore_space(true) + .history_ignore_dups(true) + .completion_type(CompletionType::List) + .auto_add_history(false) + .edit_mode(EditMode::Emacs) + .color_mode(ColorMode::Enabled) + .output_stream(OutputStreamType::Stdout) + .build() +}); + +const DEFAULT_BREAK_CHARS: [u8; 3] = [b' ', b'\t', b'\n']; + +#[derive(Hash, Debug, PartialEq, Eq)] +pub(crate) struct CommandName { + cmd: String, + pre_cmd: String, +} + +impl CommandName { + pub(crate) fn new(cmd: String, pre_cmd: String) -> Self { + Self { cmd, pre_cmd } + } +} +struct CommandCompleter { + cmds: HashSet, +} + +impl CommandCompleter { + pub fn find_matches(&self, line: &str, pos: usize) -> rustyline::Result<(usize, Vec)> { + let (start, word) = extract_word(line, pos, None, &DEFAULT_BREAK_CHARS); + let pre_cmd = line[..start].trim(); + + let matches = self + .cmds + .iter() + .filter_map(|hint| { + if hint.cmd.starts_with(word) && pre_cmd == hint.pre_cmd { + let mut replacement = hint.cmd.clone(); + replacement += " "; + Some(Pair { + display: hint.cmd.to_string(), + replacement: replacement.to_string(), + }) + } else { + None + } + }) + .collect(); + Ok((start, matches)) + } +} + +impl Completer for CommandCompleter { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + self.find_matches(line, pos) + } +} +impl Hinter for CommandCompleter { + type Hint = String; + + fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + None + } +} + +#[derive(Helper)] +pub(crate) struct RLHelper { + file_completer: FilenameCompleter, + cmd_completer: CommandCompleter, + highlighter: MatchingBracketHighlighter, + validator: MatchingBracketValidator, +} + +impl Completer for RLHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + ctx: &Context<'_>, + ) -> Result<(usize, Vec), ReadlineError> { + match self.cmd_completer.complete(line, pos, ctx) { + Ok((start, matches)) => { + if matches.is_empty() { + self.file_completer.complete(line, pos, ctx) + } else { + Ok((start, matches)) + } + } + Err(e) => Err(e), + } + } +} + +impl Hinter for RLHelper { + type Hint = String; + + fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + None + } +} + +impl Highlighter for RLHelper { + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + _default: bool, + ) -> Cow<'b, str> { + Borrowed(prompt) + } + + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Owned("\x1b[1m".to_owned() + hint + "\x1b[m") + } + + fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { + self.highlighter.highlight(line, pos) + } + + fn highlight_char(&self, line: &str, pos: usize) -> bool { + self.highlighter.highlight_char(line, pos) + } +} + +impl Validator for RLHelper { + fn validate( + &self, + ctx: &mut validate::ValidationContext, + ) -> rustyline::Result { + self.validator.validate(ctx) + } + + fn validate_while_typing(&self) -> bool { + self.validator.validate_while_typing() + } +} + +pub(crate) fn init_helper(cmds: HashSet) -> RLHelper { + RLHelper { + file_completer: FilenameCompleter::new(), + cmd_completer: CommandCompleter { cmds }, + highlighter: MatchingBracketHighlighter::new(), + validator: MatchingBracketValidator::new(), + } +} diff --git a/commons/scmd/src/context.rs b/commons/scmd/src/context.rs index fa90e9a769..5b046c0453 100644 --- a/commons/scmd/src/context.rs +++ b/commons/scmd/src/context.rs @@ -1,6 +1,8 @@ // Copyright (c) The Starcoin Core Contributors // SPDX-License-Identifier: Apache-2.0 +pub use crate::console::G_DEFAULT_CONSOLE_CONFIG; +use crate::console::{init_helper, CommandName, RLHelper}; use crate::error::CmdError; use crate::{ print_action_result, CommandAction, CommandExec, CustomCommand, HistoryOp, OutputFormat, @@ -8,9 +10,10 @@ use crate::{ use anyhow::Result; use clap::{Arg, Command}; use clap::{ErrorKind, Parser}; -use once_cell::sync::Lazy; +use rustyline::{error::ReadlineError, Config as ConsoleConfig, Editor}; use serde_json::Value; use std::collections::HashMap; +use std::collections::HashSet; use std::ffi::OsString; use std::fs::File; use std::io::prelude::*; @@ -18,23 +21,6 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -pub use rustyline::{ - config::CompletionType, error::ReadlineError, ColorMode, Config as ConsoleConfig, EditMode, - Editor, -}; - -pub static G_DEFAULT_CONSOLE_CONFIG: Lazy = Lazy::new(|| { - ConsoleConfig::builder() - .max_history_size(1000) - .history_ignore_space(true) - .history_ignore_dups(true) - .completion_type(CompletionType::List) - .auto_add_history(false) - .edit_mode(EditMode::Emacs) - .color_mode(ColorMode::Enabled) - .build() -}); - static G_OUTPUT_FORMAT_ARG: &str = "output-format"; pub struct CmdContext @@ -306,7 +292,18 @@ where let global_opt = Arc::new(global_opt); let state = Arc::new(state); let (config, history_file) = init_action(&app, global_opt.clone(), state.clone()); - let mut rl = Editor::<()>::with_config(config); + let mut rl = Editor::::with_config(config); + let cmd_sets = Self::get_command_names_recursively(&app, "".to_string(), 3) + .iter() + .map(|(a, b)| { + CommandName::new( + a.to_string(), + b.replace(&app_name[..], "").trim().to_string(), + ) + }) + .collect(); + + rl.set_helper(Some(init_helper(cmd_sets))); if let Some(history_file) = history_file.as_ref() { if !history_file.exists() { if let Err(e) = File::create(history_file.as_path()) { @@ -464,7 +461,7 @@ where global_opt: Arc, state: Arc, quit_action: Box, - mut rl: Editor<()>, + mut rl: Editor, history_file: Option, ) { let global_opt = Arc::try_unwrap(global_opt) @@ -480,4 +477,28 @@ where } quit_action(app, global_opt, state); } + + fn get_command_names_recursively( + app: &Command, + prepositive: String, + max_depth: u32, + ) -> HashSet<(String, String)> { + if max_depth == 0 { + return HashSet::<(String, String)>::new(); + } + let name = app.get_name(); + let mut pre = prepositive; + if !pre.is_empty() { + pre.push(' '); + } + pre.push_str(name); + + let mut set = HashSet::new(); + for sub_app in app.get_subcommands() { + set.insert((sub_app.get_name().to_owned(), pre.clone())); + let sub_set = Self::get_command_names_recursively(sub_app, pre.clone(), max_depth - 1); + set.extend(sub_set); + } + set + } } diff --git a/commons/scmd/src/lib.rs b/commons/scmd/src/lib.rs index 280a41e9d0..ddfe1f7667 100644 --- a/commons/scmd/src/lib.rs +++ b/commons/scmd/src/lib.rs @@ -3,6 +3,7 @@ mod action; mod command; +mod console; mod context; pub mod error; mod result;