diff --git a/src/admin/handler.rs b/src/admin/handler.rs index 0bd8af9f..1466c040 100644 --- a/src/admin/handler.rs +++ b/src/admin/handler.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use clap::Parser; +use clap::{CommandFactory, Parser}; use conduit::trace; use ruma::events::{ relation::InReplyTo, @@ -9,7 +9,7 @@ use ruma::events::{ extern crate conduit_service as service; -use conduit::Result; +use conduit::{utils::string::common_prefix, Result}; pub(crate) use service::admin::{Command, Service}; use service::admin::{CommandOutput, CommandResult, HandlerResult}; @@ -62,7 +62,10 @@ pub(crate) enum AdminCommand { } #[must_use] -pub fn handle(command: Command) -> HandlerResult { Box::pin(handle_command(command)) } +pub(crate) fn handle(command: Command) -> HandlerResult { Box::pin(handle_command(command)) } + +#[must_use] +pub(crate) fn complete(line: &str) -> String { complete_admin_command(AdminCommand::command(), line) } #[tracing::instrument(skip_all, name = "admin")] async fn handle_command(command: Command) -> CommandResult { @@ -105,54 +108,6 @@ async fn process_admin_message(msg: String) -> CommandOutput { } } -// Parse chat messages from the admin room into an AdminCommand object -fn parse_admin_command(command_line: &str) -> Result { - let mut argv = command_line.split_whitespace().collect::>(); - - // Remove any escapes that came with a server-side escape command - if !argv.is_empty() && argv[0].ends_with("admin") { - argv[0] = argv[0].trim_start_matches('\\'); - } - - // First indice has to be "admin" but for console convenience we add it here - let server_user = services().globals.server_user.as_str(); - if !argv.is_empty() && !argv[0].ends_with("admin") && !argv[0].starts_with(server_user) { - argv.insert(0, "admin"); - } - - // Replace `help command` with `command --help` - // Clap has a help subcommand, but it omits the long help description. - if argv.len() > 1 && argv[1] == "help" { - argv.remove(1); - argv.push("--help"); - } - - // Backwards compatibility with `register_appservice`-style commands - let command_with_dashes_argv1; - if argv.len() > 1 && argv[1].contains('_') { - command_with_dashes_argv1 = argv[1].replace('_', "-"); - argv[1] = &command_with_dashes_argv1; - } - - // Backwards compatibility with `register_appservice`-style commands - let command_with_dashes_argv2; - if argv.len() > 2 && argv[2].contains('_') { - command_with_dashes_argv2 = argv[2].replace('_', "-"); - argv[2] = &command_with_dashes_argv2; - } - - // if the user is using the `query` command (argv[1]), replace the database - // function/table calls with underscores to match the codebase - let command_with_dashes_argv3; - if argv.len() > 3 && argv[1].eq("query") { - command_with_dashes_argv3 = argv[3].replace('_', "-"); - argv[3] = &command_with_dashes_argv3; - } - - trace!(?command_line, ?argv, "parse"); - AdminCommand::try_parse_from(argv).map_err(|error| error.to_string()) -} - #[tracing::instrument(skip_all, name = "command")] async fn process_admin_command(command: AdminCommand, body: Vec<&str>) -> Result { let reply_message_content = match command { @@ -169,3 +124,96 @@ async fn process_admin_command(command: AdminCommand, body: Vec<&str>) -> Result Ok(reply_message_content) } + +// Parse chat messages from the admin room into an AdminCommand object +fn parse_admin_command(command_line: &str) -> Result { + let argv = parse_command_line(command_line); + AdminCommand::try_parse_from(argv).map_err(|error| error.to_string()) +} + +fn complete_admin_command(mut cmd: clap::Command, line: &str) -> String { + let mut ret = Vec::::new(); + let argv = parse_command_line(line); + 'token: for token in argv.into_iter().skip(1) { + let mut choice = Vec::new(); + let cmd_ = cmd.clone(); + for sub in cmd_.get_subcommands() { + let name = sub.get_name(); + if *name == token { + // token already complete; recurse to subcommand + ret.push(token); + cmd.clone_from(sub); + continue 'token; + } + if name.starts_with(&token) { + // partial match; add to choices + choice.push(name); + } + } + + if choice.is_empty() { + // Nothing found, return original string + ret.push(token); + } else if choice.len() == 1 { + // One choice. Add extra space because it's complete + ret.push((*choice.first().expect("only choice")).to_owned()); + ret.push(String::new()); + } else { + // Find the common prefix + ret.push(common_prefix(&choice).into()); + } + + // Return from completion + return ret.join(" "); + } + + // Return from no completion. Needs a space though. + let mut ret = ret.join(" "); + ret.push(' '); + ret +} + +// Parse chat messages from the admin room into an AdminCommand object +fn parse_command_line(command_line: &str) -> Vec { + let mut argv = command_line + .split_whitespace() + .map(str::to_owned) + .collect::>(); + + // Remove any escapes that came with a server-side escape command + if !argv.is_empty() && argv[0].ends_with("admin") { + argv[0] = argv[0].trim_start_matches('\\').into(); + } + + // First indice has to be "admin" but for console convenience we add it here + let server_user = services().globals.server_user.as_str(); + if !argv.is_empty() && !argv[0].ends_with("admin") && !argv[0].starts_with(server_user) { + argv.insert(0, "admin".to_owned()); + } + + // Replace `help command` with `command --help` + // Clap has a help subcommand, but it omits the long help description. + if argv.len() > 1 && argv[1] == "help" { + argv.remove(1); + argv.push("--help".to_owned()); + } + + // Backwards compatibility with `register_appservice`-style commands + if argv.len() > 1 && argv[1].contains('_') { + argv[1] = argv[1].replace('_', "-"); + } + + // Backwards compatibility with `register_appservice`-style commands + if argv.len() > 2 && argv[2].contains('_') { + argv[2] = argv[2].replace('_', "-"); + } + + // if the user is using the `query` command (argv[1]), replace the database + // function/table calls with underscores to match the codebase + if argv.len() > 3 && argv[1].eq("query") { + argv[3] = argv[3].replace('_', "-"); + } + + trace!(?command_line, ?argv, "parse"); + argv +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 7bb22418..2de42420 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -30,6 +30,12 @@ mod_dtor! {} /// Install the admin command handler #[allow(clippy::let_underscore_must_use)] pub async fn init() { + _ = services() + .admin + .complete + .write() + .expect("locked for writing") + .insert(handler::complete); _ = services().admin.handle.lock().await.insert(handler::handle); } @@ -37,6 +43,12 @@ pub async fn init() { #[allow(clippy::let_underscore_must_use)] pub async fn fini() { _ = services().admin.handle.lock().await.take(); + _ = services() + .admin + .complete + .write() + .expect("locked for writing") + .take(); } #[cfg(test)] diff --git a/src/service/admin/console.rs b/src/service/admin/console.rs index 3d96e078..0a200cae 100644 --- a/src/service/admin/console.rs +++ b/src/service/admin/console.rs @@ -118,6 +118,7 @@ impl Console { let _suppression = log::Suppress::new(&services().server); let (mut readline, _writer) = Readline::new(PROMPT.to_owned())?; + readline.set_tab_completer(Self::tab_complete); self.set_history(&mut readline); let future = readline.readline(); @@ -185,6 +186,13 @@ impl Console { history.push_front(line); history.truncate(HISTORY_LIMIT); } + + fn tab_complete(line: &str) -> String { + services() + .admin + .complete_command(line) + .unwrap_or_else(|| line.to_owned()) + } } fn configure_output(mut output: MadSkin) -> MadSkin { diff --git a/src/service/admin/mod.rs b/src/service/admin/mod.rs index ed2ac725..d01a6f55 100644 --- a/src/service/admin/mod.rs +++ b/src/service/admin/mod.rs @@ -2,7 +2,11 @@ pub mod console; mod create; mod grant; -use std::{future::Future, pin::Pin, sync::Arc}; +use std::{ + future::Future, + pin::Pin, + sync::{Arc, RwLock as StdRwLock}, +}; use async_trait::async_trait; use conduit::{error, utils::mutex_map, Error, Result}; @@ -27,12 +31,14 @@ pub type CommandOutput = Option; pub type CommandResult = Result; pub type HandlerResult = Pin + Send>>; pub type Handler = fn(Command) -> HandlerResult; +pub type Completer = fn(&str) -> String; pub struct Service { sender: Sender, receiver: Mutex>, handler_join: Mutex>>, pub handle: Mutex>, + pub complete: StdRwLock>, #[cfg(feature = "console")] pub console: Arc, } @@ -52,6 +58,7 @@ impl crate::Service for Service { receiver: Mutex::new(receiver), handler_join: Mutex::new(None), handle: Mutex::new(None), + complete: StdRwLock::new(None), #[cfg(feature = "console")] console: console::Console::new(), })) @@ -127,6 +134,13 @@ impl Service { .await } + pub fn complete_command(&self, command: &str) -> Option { + self.complete + .read() + .expect("locked for reading") + .map(|complete| complete(command)) + } + async fn send(&self, message: Command) { debug_assert!(!self.sender.is_closed(), "channel closed"); self.sender.send_async(message).await.expect("message sent");