Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 120 additions & 2 deletions src/shell.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,130 @@
use clap::Parser as _;
use clap::{CommandFactory, Parser as _};
use rustyline::completion::{Completer, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Editor, Helper};

/// Tab-completion helper for the interactive shell.
///
/// Builds the completion tree from clap's `Command` definition so it stays
/// in sync with the CLI automatically — no manual list to maintain.
struct ShellCompleter {
/// `(command_name, [subcommand_names])` pairs derived from `Cli::command()`.
commands: Vec<(String, Vec<String>)>,
}

impl ShellCompleter {
fn new() -> Self {
let cmd = crate::Cli::command();
let commands = cmd
.get_subcommands()
.filter(|sub| {
let name = sub.get_name();
// "shell" and "setup" are blocked inside the REPL, skip them.
name != "shell" && name != "setup"
})
.map(|sub| {
let name = sub.get_name().to_string();
let children: Vec<String> = sub
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect();
(name, children)
})
.collect();

Self { commands }
}
}

impl Completer for ShellCompleter {
type Candidate = Pair;

fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let input = &line[..pos];
let words: Vec<&str> = input.split_whitespace().collect();
let trailing_space = input.ends_with(' ');

match (words.len(), trailing_space) {
// Nothing typed yet, or still typing the first word.
(0, _) | (1, false) => {
let prefix = words.first().copied().unwrap_or("");
let start = pos - prefix.len();

let matches: Vec<Pair> = self
.commands
.iter()
.map(|(name, _)| name.as_str())
.chain(["help", "exit", "quit"])
.filter(|c| c.starts_with(prefix))
.map(|c| Pair {
display: c.to_string(),
replacement: c.to_string(),
})
.collect();

Ok((start, matches))
}

// First word complete, typing (or about to type) the subcommand.
(1, true) | (2, false) => {
let cmd = words[0];
let prefix = if words.len() == 2 && !trailing_space {
words[1]
} else {
""
};
let start = pos - prefix.len();

if let Some((_, subs)) = self.commands.iter().find(|(name, _)| name == cmd) {
let matches: Vec<Pair> = subs
.iter()
.filter(|s| s.starts_with(prefix))
.map(|s| Pair {
display: s.to_string(),
replacement: s.to_string(),
})
.collect();
Ok((start, matches))
} else {
Ok((pos, vec![]))
}
}

// Beyond the subcommand — no further completion for now.
_ => Ok((pos, vec![])),
}
}
}

impl Hinter for ShellCompleter {
type Hint = String;

fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<Self::Hint> {
None
}
}

impl Highlighter for ShellCompleter {}
impl Validator for ShellCompleter {}
impl Helper for ShellCompleter {}

pub async fn run_shell() -> anyhow::Result<()> {
println!();
println!(" Polymarket CLI · Interactive Shell");
println!(" Type 'help' for commands, 'exit' to quit.");
println!(" Tab completion is available for commands.");
println!();

let mut rl = rustyline::DefaultEditor::new()?;
let helper = ShellCompleter::new();
let mut rl = Editor::new()?;
rl.set_helper(Some(helper));

loop {
match rl.readline("polymarket> ") {
Expand Down