use rustyline to replace readline
This commit is contained in:
parent
41874331ed
commit
ca60ac13d9
@ -30,6 +30,7 @@ pam-sys = "0.5"
|
|||||||
pin-utils = "0.1.0-alpha"
|
pin-utils = "0.1.0-alpha"
|
||||||
proxmox = { git = "ssh://gitolite3@proxdev.maurer-it.com/rust/proxmox", version = "0.1", features = [ "sortable-macro", "api-macro" ] }
|
proxmox = { git = "ssh://gitolite3@proxdev.maurer-it.com/rust/proxmox", version = "0.1", features = [ "sortable-macro", "api-macro" ] }
|
||||||
regex = "1.0"
|
regex = "1.0"
|
||||||
|
rustyline = "5.0.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
shellwords = "1.0"
|
shellwords = "1.0"
|
||||||
|
72
src/bin/completion.rs
Normal file
72
src/bin/completion.rs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
use failure::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use proxmox::{sortable, identity};
|
||||||
|
use proxmox::api::*;
|
||||||
|
use proxmox::api::schema::*;
|
||||||
|
|
||||||
|
use proxmox_backup::cli::*;
|
||||||
|
|
||||||
|
#[sortable]
|
||||||
|
const API_METHOD_TEST_COMMAND: ApiMethod = ApiMethod::new(
|
||||||
|
&ApiHandler::Sync(&test_command),
|
||||||
|
&ObjectSchema::new(
|
||||||
|
"Test command.",
|
||||||
|
&sorted!([
|
||||||
|
( "verbose", true, &BooleanSchema::new("Verbose output.").schema() ),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
fn test_command(
|
||||||
|
_param: Value,
|
||||||
|
_info: &ApiMethod,
|
||||||
|
_rpcenv: &mut dyn RpcEnvironment,
|
||||||
|
) -> Result<Value, Error> {
|
||||||
|
|
||||||
|
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_map() -> CliCommandMap {
|
||||||
|
let cmd_def = CliCommandMap::new()
|
||||||
|
.insert("ls", CliCommand::new(&API_METHOD_TEST_COMMAND).into())
|
||||||
|
.insert("test", CliCommand::new(&API_METHOD_TEST_COMMAND).into())
|
||||||
|
.insert("help", help_command_def().into());
|
||||||
|
|
||||||
|
cmd_def
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn main() -> Result<(), Error> {
|
||||||
|
|
||||||
|
let def = CommandLineInterface::Nested(command_map());
|
||||||
|
|
||||||
|
let helper = CliHelper::new(def);
|
||||||
|
|
||||||
|
let config = rustyline::config::Builder::new()
|
||||||
|
//.completion_type(rustyline::config::CompletionType::List)
|
||||||
|
//.completion_prompt_limit(0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut rl = rustyline::Editor::<CliHelper>::with_config(config);
|
||||||
|
rl.set_helper(Some(helper));
|
||||||
|
|
||||||
|
while let Ok(line) = rl.readline("# prompt: ") {
|
||||||
|
let helper = rl.helper().unwrap();
|
||||||
|
// readline already handles tabs, so here we only split on spaces
|
||||||
|
let args = shellword_split(&line)?;
|
||||||
|
|
||||||
|
let def = helper.cmd_def();
|
||||||
|
let _ = match def {
|
||||||
|
CommandLineInterface::Simple(ref cli_cmd) => {
|
||||||
|
handle_simple_command(def, "", &cli_cmd, args)
|
||||||
|
}
|
||||||
|
CommandLineInterface::Nested(ref map) => {
|
||||||
|
handle_nested_command(def, "", &map, args)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
@ -10,6 +10,9 @@ pub use environment::*;
|
|||||||
mod format;
|
mod format;
|
||||||
pub use format::*;
|
pub use format::*;
|
||||||
|
|
||||||
|
mod completion;
|
||||||
|
pub use completion::*;
|
||||||
|
|
||||||
mod getopts;
|
mod getopts;
|
||||||
pub use getopts::*;
|
pub use getopts::*;
|
||||||
|
|
||||||
|
230
src/cli/completion.rs
Normal file
230
src/cli/completion.rs
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
use failure::*;
|
||||||
|
|
||||||
|
use rustyline::completion::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
enum ParseMode {
|
||||||
|
Space,
|
||||||
|
DoubleQuote,
|
||||||
|
EscapeNormal,
|
||||||
|
EscapeInDoubleQuote,
|
||||||
|
Normal,
|
||||||
|
SingleQuote,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsing strings as they would be interpreted by the UNIX Bourne shell.
|
||||||
|
///
|
||||||
|
/// - ``finalize``: assume this is a complete command line. Set this
|
||||||
|
/// to false for the 'completion' helper, which needs to get
|
||||||
|
/// information about the last unfinished parameter.
|
||||||
|
///
|
||||||
|
/// Returns the list of fully parsed words (unescaped and quotes
|
||||||
|
/// removed). If there are unclosed quotes, the start of that
|
||||||
|
/// parameter, the parameter value (unescaped and quotes removed), and
|
||||||
|
/// the quote type are returned.
|
||||||
|
pub fn shellword_split_unclosed(s: &str, finalize: bool) -> (Vec<String>, Option<(usize, String, Quote)>) {
|
||||||
|
|
||||||
|
let char_indices = s.char_indices();
|
||||||
|
let mut args: Vec<String> = Vec::new();
|
||||||
|
let mut field_start = None;
|
||||||
|
let mut field = String::new();
|
||||||
|
let mut mode = ParseMode::Space;
|
||||||
|
|
||||||
|
let space_chars = [' ', '\t', '\n'];
|
||||||
|
|
||||||
|
for (index, c) in char_indices {
|
||||||
|
match mode {
|
||||||
|
ParseMode::Space => {
|
||||||
|
if c == '"' {
|
||||||
|
mode = ParseMode::DoubleQuote;
|
||||||
|
field_start = Some((index, Quote::Double));
|
||||||
|
} else if c == '\\' {
|
||||||
|
mode = ParseMode::EscapeNormal;
|
||||||
|
field_start = Some((index, Quote::None));
|
||||||
|
} else if c == '\'' {
|
||||||
|
mode = ParseMode::SingleQuote;
|
||||||
|
field_start = Some((index, Quote::Single));
|
||||||
|
} else if space_chars.iter().any(|s| c == *s) {
|
||||||
|
/* skip space */
|
||||||
|
} else {
|
||||||
|
mode = ParseMode::Normal;
|
||||||
|
field_start = Some((index, Quote::None));
|
||||||
|
field.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParseMode::EscapeNormal => {
|
||||||
|
mode = ParseMode::Normal;
|
||||||
|
field.push(c);
|
||||||
|
}
|
||||||
|
ParseMode::EscapeInDoubleQuote => {
|
||||||
|
// Within double quoted strings, backslashes are only
|
||||||
|
// treated as metacharacters when followed by one of
|
||||||
|
// the following characters: $ ' " \ newline
|
||||||
|
if c == '$' || c == '\'' || c == '"' || c == '\\' || c == '\n' {
|
||||||
|
field.push(c);
|
||||||
|
} else {
|
||||||
|
field.push('\\');
|
||||||
|
field.push(c);
|
||||||
|
}
|
||||||
|
mode = ParseMode::DoubleQuote;
|
||||||
|
}
|
||||||
|
ParseMode::Normal => {
|
||||||
|
if c == '"' {
|
||||||
|
mode = ParseMode::DoubleQuote;
|
||||||
|
} else if c == '\'' {
|
||||||
|
mode = ParseMode::SingleQuote;
|
||||||
|
} else if c == '\\' {
|
||||||
|
mode = ParseMode::EscapeNormal;
|
||||||
|
} else if space_chars.iter().any(|s| c == *s) {
|
||||||
|
mode = ParseMode::Space;
|
||||||
|
let (_start, _quote) = field_start.take().unwrap();
|
||||||
|
args.push(field.split_off(0));
|
||||||
|
} else {
|
||||||
|
field.push(c); // continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParseMode::DoubleQuote => {
|
||||||
|
if c == '"' {
|
||||||
|
mode = ParseMode::Normal;
|
||||||
|
} else if c == '\\' {
|
||||||
|
mode = ParseMode::EscapeInDoubleQuote;
|
||||||
|
} else {
|
||||||
|
field.push(c); // continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParseMode::SingleQuote => {
|
||||||
|
// Note: no escape in single quotes
|
||||||
|
if c == '\'' {
|
||||||
|
mode = ParseMode::Normal;
|
||||||
|
} else {
|
||||||
|
field.push(c); // continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalize && mode == ParseMode::Normal {
|
||||||
|
let (_start, _quote) = field_start.take().unwrap();
|
||||||
|
args.push(field.split_off(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
match field_start {
|
||||||
|
Some ((start, quote)) => {
|
||||||
|
(args, Some((start, field, quote)))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
(args, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a string into a vector of words in the same way the UNIX Bourne shell does.
|
||||||
|
///
|
||||||
|
/// Return words unescaped and without quotes.
|
||||||
|
pub fn shellword_split(s: &str) -> Result<Vec<String>, Error> {
|
||||||
|
|
||||||
|
let (args, unclosed_field) = shellword_split_unclosed(s, true);
|
||||||
|
if !unclosed_field.is_none() {
|
||||||
|
bail!("shellword split failed - found unclosed quote.");
|
||||||
|
}
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shellword_split() {
|
||||||
|
|
||||||
|
let expect = [ "ls", "/etc" ];
|
||||||
|
let expect: Vec<String> = expect.iter().map(|v| v.to_string()).collect();
|
||||||
|
|
||||||
|
assert_eq!(expect, shellword_split("ls /etc").unwrap());
|
||||||
|
assert_eq!(expect, shellword_split("ls \"/etc\"").unwrap());
|
||||||
|
assert_eq!(expect, shellword_split("ls '/etc'").unwrap());
|
||||||
|
assert_eq!(expect, shellword_split("ls '/etc'").unwrap());
|
||||||
|
|
||||||
|
assert_eq!(expect, shellword_split("ls /e\"t\"c").unwrap());
|
||||||
|
assert_eq!(expect, shellword_split("ls /e'tc'").unwrap());
|
||||||
|
assert_eq!(expect, shellword_split("ls /e't''c'").unwrap());
|
||||||
|
|
||||||
|
let expect = [ "ls", "/etc 08x" ];
|
||||||
|
let expect: Vec<String> = expect.iter().map(|v| v.to_string()).collect();
|
||||||
|
assert_eq!(expect, shellword_split("ls /etc\\ \\08x").unwrap());
|
||||||
|
|
||||||
|
let expect = [ "ls", "/etc \\08x" ];
|
||||||
|
let expect: Vec<String> = expect.iter().map(|v| v.to_string()).collect();
|
||||||
|
assert_eq!(expect, shellword_split("ls \"/etc \\08x\"").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shellword_split_unclosed() {
|
||||||
|
|
||||||
|
let expect = [ "ls".to_string() ].to_vec();
|
||||||
|
assert_eq!(
|
||||||
|
(expect, Some((3, "./File1 name with spaces".to_string(), Quote::Single))),
|
||||||
|
shellword_split_unclosed("ls './File1 name with spaces", false)
|
||||||
|
);
|
||||||
|
|
||||||
|
let expect = [ "ls".to_string() ].to_vec();
|
||||||
|
assert_eq!(
|
||||||
|
(expect, Some((3, "./File2 name with spaces".to_string(), Quote::Double))),
|
||||||
|
shellword_split_unclosed("ls \"./File2 \"name\" with spaces", false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CliHelper {
|
||||||
|
cmd_def: CommandLineInterface,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliHelper {
|
||||||
|
|
||||||
|
pub fn new(cmd_def: CommandLineInterface) -> Self {
|
||||||
|
Self { cmd_def }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_def(&self) -> &CommandLineInterface {
|
||||||
|
&self.cmd_def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustyline::completion::Completer for CliHelper {
|
||||||
|
type Candidate = String;
|
||||||
|
|
||||||
|
fn complete(
|
||||||
|
&self,
|
||||||
|
line: &str,
|
||||||
|
pos: usize,
|
||||||
|
_ctx: &rustyline::Context<'_>,
|
||||||
|
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
|
||||||
|
|
||||||
|
let line = &line[..pos];
|
||||||
|
|
||||||
|
let (args, start ) = match shellword_split_unclosed(line, false) {
|
||||||
|
(mut args, None) => {
|
||||||
|
args.push("".into());
|
||||||
|
(args, pos)
|
||||||
|
}
|
||||||
|
(mut args, Some((start , arg, _quote))) => {
|
||||||
|
args.push(arg);
|
||||||
|
(args, start)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let def = &self.cmd_def;
|
||||||
|
|
||||||
|
let completions = if !args.is_empty() && args[0] == "help" {
|
||||||
|
get_help_completion(&def, &help_command_def(), &args[1..])
|
||||||
|
} else {
|
||||||
|
get_nested_completion(&def, &args)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok((start, completions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustyline::hint::Hinter for CliHelper {}
|
||||||
|
impl rustyline::highlight::Highlighter for CliHelper {}
|
||||||
|
|
||||||
|
impl rustyline::Helper for CliHelper {
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user