use clap::CommandFactory;
use rustyline::completion::{Completer, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::history::FileHistory;
use rustyline::validate::Validator;
use rustyline::{Editor, Helper};
use std::fmt;
#[derive(Debug, PartialEq, Eq)]
pub(super) enum SplitError {
UnterminatedQuote(char),
}
impl fmt::Display for SplitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SplitError::UnterminatedQuote(q) => write!(f, "unterminated {q} quote"),
}
}
}
pub(super) fn split_args(line: &str) -> Result<Vec<String>, SplitError> {
let mut args = Vec::new();
let mut cur = String::new();
let mut has_token = false;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
c if c.is_whitespace() => {
if has_token {
args.push(std::mem::take(&mut cur));
has_token = false;
}
}
'\'' => {
has_token = true;
loop {
match chars.next() {
Some('\'') => break,
Some(ch) => cur.push(ch),
None => return Err(SplitError::UnterminatedQuote('\'')),
}
}
}
'"' => {
has_token = true;
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(next @ ('"' | '\\')) => cur.push(next),
Some(other) => {
cur.push('\\');
cur.push(other);
}
None => return Err(SplitError::UnterminatedQuote('"')),
},
Some(ch) => cur.push(ch),
None => return Err(SplitError::UnterminatedQuote('"')),
}
}
}
'\\' => {
has_token = true;
match chars.next() {
Some(ch) => cur.push(ch),
None => cur.push('\\'),
}
}
other => {
has_token = true;
cur.push(other);
}
}
}
if has_token {
args.push(cur);
}
Ok(args)
}
const BUILTINS: [&str; 2] = ["exit", "quit"];
pub(super) fn complete_line(cmd: &clap::Command, line: &str, pos: usize) -> (usize, Vec<String>) {
let before = &line[..pos];
let start = before
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
let word = &before[start..];
let preceding = before[..start].trim();
let candidates = if preceding.is_empty() {
subcommand_names(cmd)
.into_iter()
.chain(BUILTINS.iter().map(std::string::ToString::to_string))
.filter(|c| c.starts_with(word))
.collect()
} else if word.starts_with('-') {
let sub = preceding.split_whitespace().next().unwrap_or("");
option_names(cmd, sub)
.into_iter()
.filter(|c| c.starts_with(word))
.collect()
} else {
Vec::new()
};
(start, candidates)
}
fn subcommand_names(cmd: &clap::Command) -> Vec<String> {
let mut names = Vec::new();
for sub in cmd.get_subcommands() {
names.push(sub.get_name().to_string());
names.extend(
sub.get_visible_aliases()
.map(std::string::ToString::to_string),
);
}
names
}
fn option_names(cmd: &clap::Command, sub: &str) -> Vec<String> {
let Some(subcmd) = cmd
.get_subcommands()
.find(|c| c.get_name() == sub || c.get_visible_aliases().any(|a| a == sub))
else {
return Vec::new();
};
subcmd
.get_arguments()
.filter_map(|arg| arg.get_long().map(|l| format!("--{l}")))
.collect()
}
pub(super) struct ShellHelper {
cmd: clap::Command,
}
impl Completer for ShellHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let (start, candidates) = complete_line(&self.cmd, line, pos);
let pairs = candidates
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
})
.collect();
Ok((start, pairs))
}
}
impl Hinter for ShellHelper {
type Hint = String;
}
impl Highlighter for ShellHelper {}
impl Validator for ShellHelper {}
impl Helper for ShellHelper {}
pub(super) fn build_editor() -> rustyline::Result<Editor<ShellHelper, FileHistory>> {
let mut editor = Editor::new()?;
editor.set_helper(Some(ShellHelper {
cmd: super::args::Cli::command(),
}));
Ok(editor)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_on_whitespace() {
assert_eq!(split_args("add title").unwrap(), vec!["add", "title"]);
}
#[test]
fn collapses_repeated_whitespace() {
assert_eq!(split_args(" add title ").unwrap(), vec!["add", "title"]);
}
#[test]
fn empty_line_yields_no_tokens() {
assert_eq!(split_args(" ").unwrap(), Vec::<String>::new());
}
#[test]
fn double_quotes_preserve_spaces() {
assert_eq!(
split_args("add \"Hello world\"").unwrap(),
vec!["add", "Hello world"]
);
}
#[test]
fn single_quotes_preserve_spaces_verbatim() {
assert_eq!(split_args("add 'a b\\c'").unwrap(), vec!["add", "a b\\c"]);
}
#[test]
fn double_quote_escapes_quote_and_backslash() {
assert_eq!(
split_args("add \"a\\\"b\\\\c\"").unwrap(),
vec!["add", "a\"b\\c"]
);
}
#[test]
fn backslash_escapes_outside_quotes() {
assert_eq!(split_args("a\\ b").unwrap(), vec!["a b"]);
}
#[test]
fn empty_quotes_become_empty_argument() {
assert_eq!(
split_args("edit T-1 --body \"\"").unwrap(),
vec!["edit", "T-1", "--body", ""]
);
}
#[test]
fn unterminated_double_quote_errors() {
assert_eq!(
split_args("add \"oops"),
Err(SplitError::UnterminatedQuote('"'))
);
}
#[test]
fn unterminated_single_quote_errors() {
assert_eq!(
split_args("add 'oops"),
Err(SplitError::UnterminatedQuote('\''))
);
}
#[test]
fn split_errors_display_and_edge_case_escapes_are_stable() {
assert_eq!(
SplitError::UnterminatedQuote('"').to_string(),
"unterminated \" quote"
);
assert_eq!(split_args("add \"a\\q\"").unwrap(), vec!["add", "a\\q"]);
assert_eq!(split_args("add \\").unwrap(), vec!["add", "\\"]);
assert_eq!(
split_args("add \"a\\").expect_err("quote after a trailing escape"),
SplitError::UnterminatedQuote('"')
);
}
fn cmd() -> clap::Command {
super::super::args::Cli::command()
}
#[test]
fn completes_subcommands_at_start() {
let (start, cands) = complete_line(&cmd(), "ad", 2);
assert_eq!(start, 0);
assert_eq!(cands, vec!["add"]);
}
#[test]
fn empty_first_word_lists_all_subcommands_and_builtins() {
let (start, cands) = complete_line(&cmd(), "", 0);
assert_eq!(start, 0);
for expected in [
"add", "list", "show", "move", "board", "shell", "exit", "quit",
] {
assert!(cands.contains(&expected.to_string()), "missing {expected}");
}
}
#[test]
fn completes_visible_alias_ls_for_list() {
let (_start, cands) = complete_line(&cmd(), "l", 1);
assert!(cands.contains(&"list".to_string()));
assert!(cands.contains(&"ls".to_string()));
}
#[test]
fn completes_long_options_for_subcommand() {
let (start, cands) = complete_line(&cmd(), "list --", 7);
assert_eq!(start, 5);
assert!(cands.contains(&"--json".to_string()));
assert!(cands.contains(&"--status".to_string()));
}
#[test]
fn completes_partial_long_option() {
let (start, cands) = complete_line(&cmd(), "add --po", 8);
assert_eq!(start, 4);
assert_eq!(cands, vec!["--points"]);
}
#[test]
fn no_candidates_for_plain_argument_word() {
let (_start, cands) = complete_line(&cmd(), "show T", 6);
assert!(cands.is_empty());
}
#[test]
fn unknown_subcommand_has_no_option_candidates() {
let (_start, cands) = complete_line(&cmd(), "unknown --", 10);
assert!(cands.is_empty());
}
#[test]
fn rustyline_helper_converts_completion_candidates_to_pairs() {
let helper = ShellHelper { cmd: cmd() };
let history = rustyline::history::DefaultHistory::new();
let context = rustyline::Context::new(&history);
let (start, pairs) = helper
.complete("ad", 2, &context)
.expect("completion succeeds");
assert_eq!(start, 0);
assert_eq!(
pairs
.iter()
.map(|pair| (&pair.display, &pair.replacement))
.collect::<Vec<_>>(),
vec![(&"add".to_string(), &"add".to_string())]
);
}
}