mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
772 lines
27 KiB
Rust
772 lines
27 KiB
Rust
use std::str::FromStr;
|
|
|
|
use crate::{
|
|
render::ExperimentalShader,
|
|
session::{settings_change::change_render_mode, SessionState},
|
|
GlobalState,
|
|
};
|
|
use client::Client;
|
|
use common::{
|
|
cmd::*,
|
|
comp::Admin,
|
|
link::Is,
|
|
mounting::{Mount, Rider, VolumeRider},
|
|
parse_cmd_args,
|
|
resources::PlayerEntity,
|
|
uid::Uid,
|
|
uuid::Uuid,
|
|
};
|
|
use common_net::sync::WorldSyncExt;
|
|
use itertools::Itertools;
|
|
use levenshtein::levenshtein;
|
|
use specs::{Join, WorldExt};
|
|
use strum::{EnumIter, IntoEnumIterator};
|
|
|
|
// Please keep this sorted alphabetically, same as with server commands :-)
|
|
#[derive(Clone, Copy, strum::EnumIter)]
|
|
pub enum ClientChatCommand {
|
|
ExperimentalShader,
|
|
Help,
|
|
Mute,
|
|
Unmute,
|
|
}
|
|
|
|
impl ClientChatCommand {
|
|
pub fn data(&self) -> ChatCommandData {
|
|
use ArgumentSpec::*;
|
|
use Requirement::*;
|
|
let cmd = ChatCommandData::new;
|
|
match self {
|
|
ClientChatCommand::ExperimentalShader => cmd(
|
|
vec![Enum(
|
|
"Shader",
|
|
ExperimentalShader::iter()
|
|
.map(|item| item.to_string())
|
|
.collect(),
|
|
Optional,
|
|
)],
|
|
"Toggles an experimental shader.",
|
|
None,
|
|
),
|
|
ClientChatCommand::Help => cmd(
|
|
vec![Command(Optional)],
|
|
"Display information about commands",
|
|
None,
|
|
),
|
|
ClientChatCommand::Mute => cmd(
|
|
vec![PlayerName(Required)],
|
|
"Mutes chat messages from a player.",
|
|
None,
|
|
),
|
|
ClientChatCommand::Unmute => cmd(
|
|
vec![PlayerName(Required)],
|
|
"Unmutes a player muted with the 'mute' command.",
|
|
None,
|
|
),
|
|
}
|
|
}
|
|
|
|
pub fn keyword(&self) -> &'static str {
|
|
match self {
|
|
ClientChatCommand::ExperimentalShader => "experimental_shader",
|
|
ClientChatCommand::Help => "help",
|
|
ClientChatCommand::Mute => "mute",
|
|
ClientChatCommand::Unmute => "unmute",
|
|
}
|
|
}
|
|
|
|
/// A message that explains what the command does
|
|
pub fn help_string(&self) -> String {
|
|
let data = self.data();
|
|
let usage = std::iter::once(format!("/{}", self.keyword()))
|
|
.chain(data.args.iter().map(|arg| arg.usage_string()))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
format!("{}: {}", usage, data.description)
|
|
}
|
|
|
|
/// Returns a format string for parsing arguments with scan_fmt
|
|
pub fn arg_fmt(&self) -> String {
|
|
self.data()
|
|
.args
|
|
.iter()
|
|
.map(|arg| match arg {
|
|
ArgumentSpec::PlayerName(_) => "{}",
|
|
ArgumentSpec::EntityTarget(_) => "{}",
|
|
ArgumentSpec::SiteName(_) => "{/.*/}",
|
|
ArgumentSpec::Float(_, _, _) => "{}",
|
|
ArgumentSpec::Integer(_, _, _) => "{d}",
|
|
ArgumentSpec::Any(_, _) => "{}",
|
|
ArgumentSpec::Command(_) => "{}",
|
|
ArgumentSpec::Message(_) => "{/.*/}",
|
|
ArgumentSpec::SubCommand => "{} {/.*/}",
|
|
ArgumentSpec::Enum(_, _, _) => "{}",
|
|
ArgumentSpec::AssetPath(_, _, _, _) => "{}",
|
|
ArgumentSpec::Boolean(_, _, _) => "{}",
|
|
ArgumentSpec::Flag(_) => "{}",
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// Produce an iterator over all the available commands
|
|
pub fn iter() -> impl Iterator<Item = Self> + Clone {
|
|
<Self as strum::IntoEnumIterator>::iter()
|
|
}
|
|
|
|
/// Produce an iterator that first goes over all the short keywords
|
|
/// and their associated commands and then iterates over all the normal
|
|
/// keywords with their associated commands
|
|
pub fn iter_with_keywords() -> impl Iterator<Item = (&'static str, Self)> {
|
|
Self::iter().map(|c| (c.keyword(), c))
|
|
}
|
|
}
|
|
|
|
impl FromStr for ClientChatCommand {
|
|
type Err = ();
|
|
|
|
fn from_str(keyword: &str) -> Result<ClientChatCommand, ()> {
|
|
Self::iter()
|
|
.map(|c| (c.keyword(), c))
|
|
.find_map(|(kwd, command)| (kwd == keyword).then_some(command))
|
|
.ok_or(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum ChatCommandKind {
|
|
Client(ClientChatCommand),
|
|
Server(ServerChatCommand),
|
|
}
|
|
|
|
impl FromStr for ChatCommandKind {
|
|
type Err = ();
|
|
|
|
fn from_str(s: &str) -> Result<Self, ()> {
|
|
if let Ok(cmd) = s.parse::<ClientChatCommand>() {
|
|
Ok(ChatCommandKind::Client(cmd))
|
|
} else if let Ok(cmd) = s.parse::<ServerChatCommand>() {
|
|
Ok(ChatCommandKind::Server(cmd))
|
|
} else {
|
|
Err(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents the feedback shown to the user of a command, if any. Server
|
|
/// commands give their feedback as an event, so in those cases this will always
|
|
/// be Ok(None). An Err variant will be be displayed with the error icon and
|
|
/// text color
|
|
type CommandResult = Result<Option<String>, String>;
|
|
|
|
#[derive(EnumIter)]
|
|
enum ClientEntityTarget {
|
|
Target,
|
|
Selected,
|
|
Viewpoint,
|
|
Mount,
|
|
Rider,
|
|
TargetSelf,
|
|
}
|
|
|
|
impl ClientEntityTarget {
|
|
const PREFIX: char = '@';
|
|
|
|
fn keyword(&self) -> &'static str {
|
|
match self {
|
|
ClientEntityTarget::Target => "target",
|
|
ClientEntityTarget::Selected => "selected",
|
|
ClientEntityTarget::Viewpoint => "viewpoint",
|
|
ClientEntityTarget::Mount => "mount",
|
|
ClientEntityTarget::Rider => "rider",
|
|
ClientEntityTarget::TargetSelf => "self",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn preproccess_command(
|
|
session_state: &mut SessionState,
|
|
command: &ChatCommandKind,
|
|
args: &mut [String],
|
|
) -> CommandResult {
|
|
let mut cmd_args = match command {
|
|
ChatCommandKind::Client(cmd) => cmd.data().args,
|
|
ChatCommandKind::Server(cmd) => cmd.data().args,
|
|
};
|
|
let client = &mut session_state.client.borrow_mut();
|
|
let ecs = client.state().ecs();
|
|
let player = ecs.read_resource::<PlayerEntity>().0;
|
|
let mut command_start = 0;
|
|
for (i, arg) in args.iter_mut().enumerate() {
|
|
let mut could_be_entity_target = false;
|
|
if let Some(post_cmd_args) = cmd_args.get(i - command_start..) {
|
|
for (j, arg_spec) in post_cmd_args.iter().enumerate() {
|
|
match arg_spec {
|
|
ArgumentSpec::EntityTarget(_) => could_be_entity_target = true,
|
|
ArgumentSpec::SubCommand => {
|
|
if let Some(sub_command) =
|
|
ServerChatCommand::iter().find(|cmd| cmd.keyword() == arg)
|
|
{
|
|
cmd_args = sub_command.data().args;
|
|
command_start = i + j + 1;
|
|
break;
|
|
}
|
|
},
|
|
ArgumentSpec::AssetPath(_, prefix, _, _) => {
|
|
*arg = prefix.to_string() + arg;
|
|
},
|
|
_ => {},
|
|
}
|
|
if matches!(arg_spec.requirement(), Requirement::Required) {
|
|
break;
|
|
}
|
|
}
|
|
} else if matches!(cmd_args.last(), Some(ArgumentSpec::SubCommand)) {
|
|
could_be_entity_target = true;
|
|
}
|
|
if could_be_entity_target && arg.starts_with(ClientEntityTarget::PREFIX) {
|
|
let target_str = arg.trim_start_matches(ClientEntityTarget::PREFIX);
|
|
let target = ClientEntityTarget::iter()
|
|
.find(|t| t.keyword() == target_str)
|
|
.ok_or_else(|| {
|
|
let help_string = ClientEntityTarget::iter()
|
|
.map(|t| t.keyword().to_string())
|
|
.reduce(|a, b| format!("{a}/{b}"))
|
|
.unwrap_or_default();
|
|
format!("Expected {help_string} after '@' found {target_str}")
|
|
})?;
|
|
let uid = match target {
|
|
ClientEntityTarget::Target => session_state
|
|
.target_entity
|
|
.and_then(|e| ecs.uid_from_entity(e))
|
|
.ok_or("Not looking at a valid target".to_string())?,
|
|
ClientEntityTarget::Selected => session_state
|
|
.selected_entity
|
|
.and_then(|(e, _)| ecs.uid_from_entity(e))
|
|
.ok_or("You don't have a valid target selected".to_string())?,
|
|
ClientEntityTarget::Viewpoint => session_state
|
|
.viewpoint_entity
|
|
.and_then(|e| ecs.uid_from_entity(e))
|
|
.ok_or("Not viewing from a valid viewpoint entity".to_string())?,
|
|
ClientEntityTarget::Mount => {
|
|
if let Some(player) = player {
|
|
ecs.read_storage::<Is<Rider>>()
|
|
.get(player)
|
|
.map(|is_rider| is_rider.mount)
|
|
.or(ecs.read_storage::<Is<VolumeRider>>().get(player).and_then(
|
|
|is_rider| match is_rider.pos.kind {
|
|
common::mounting::Volume::Terrain => None,
|
|
common::mounting::Volume::Entity(uid) => Some(uid),
|
|
},
|
|
))
|
|
.ok_or("Not riding a valid entity".to_string())?
|
|
} else {
|
|
return Err("No player entity".to_string());
|
|
}
|
|
},
|
|
ClientEntityTarget::Rider => {
|
|
if let Some(player) = player {
|
|
ecs.read_storage::<Is<Mount>>()
|
|
.get(player)
|
|
.map(|is_mount| is_mount.rider)
|
|
.ok_or("No valid rider".to_string())?
|
|
} else {
|
|
return Err("No player entity".to_string());
|
|
}
|
|
},
|
|
ClientEntityTarget::TargetSelf => player
|
|
.and_then(|e| ecs.uid_from_entity(e))
|
|
.ok_or("No player entity")?,
|
|
};
|
|
let uid = u64::from(uid);
|
|
*arg = format!("uid@{uid}");
|
|
}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
/// Runs a command by either sending it to the server or processing it
|
|
/// locally. Returns a String to be output to the chat.
|
|
// Note: it's not clear what data future commands will need access to, so the
|
|
// signature of this function might change
|
|
pub fn run_command(
|
|
session_state: &mut SessionState,
|
|
global_state: &mut GlobalState,
|
|
cmd: &str,
|
|
mut args: Vec<String>,
|
|
) -> CommandResult {
|
|
let command = ChatCommandKind::from_str(cmd)
|
|
.map_err(|_| invalid_command_message(&session_state.client.borrow(), cmd.to_string()))?;
|
|
|
|
preproccess_command(session_state, &command, &mut args)?;
|
|
|
|
let client = &mut session_state.client.borrow_mut();
|
|
|
|
match command {
|
|
ChatCommandKind::Server(cmd) => {
|
|
client.send_command(cmd.keyword().into(), args);
|
|
Ok(None) // The server will provide a response when the command is run
|
|
},
|
|
ChatCommandKind::Client(cmd) => {
|
|
Ok(Some(run_client_command(client, global_state, cmd, args)?))
|
|
},
|
|
}
|
|
}
|
|
|
|
fn invalid_command_message(client: &Client, user_entered_invalid_command: String) -> String {
|
|
let entity_role = client
|
|
.state()
|
|
.read_storage::<Admin>()
|
|
.get(client.entity())
|
|
.map(|admin| admin.0);
|
|
|
|
let usable_commands = ServerChatCommand::iter()
|
|
.filter(|cmd| cmd.needs_role() <= entity_role)
|
|
.map(|cmd| cmd.keyword())
|
|
.chain(ClientChatCommand::iter().map(|cmd| cmd.keyword()));
|
|
|
|
let most_similar_str = usable_commands
|
|
.clone()
|
|
.min_by_key(|cmd| levenshtein(&user_entered_invalid_command, cmd))
|
|
.expect("At least one command exists.");
|
|
|
|
let commands_with_same_prefix = usable_commands
|
|
.filter(|cmd| cmd.starts_with(&user_entered_invalid_command) && cmd != &most_similar_str);
|
|
|
|
format!(
|
|
"Could not find a command named {}. Did you mean any of the following? \n/{} {} \n\nType \
|
|
/help to see a list of all commands.",
|
|
user_entered_invalid_command,
|
|
most_similar_str,
|
|
commands_with_same_prefix.fold(String::new(), |s, arg| s + "\n/" + arg)
|
|
)
|
|
}
|
|
|
|
fn run_client_command(
|
|
client: &mut Client,
|
|
global_state: &mut GlobalState,
|
|
command: ClientChatCommand,
|
|
args: Vec<String>,
|
|
) -> Result<String, String> {
|
|
let command = match command {
|
|
ClientChatCommand::ExperimentalShader => handle_experimental_shader,
|
|
ClientChatCommand::Help => handle_help,
|
|
ClientChatCommand::Mute => handle_mute,
|
|
ClientChatCommand::Unmute => handle_unmute,
|
|
};
|
|
|
|
command(client, global_state, args)
|
|
}
|
|
|
|
fn handle_help(
|
|
client: &Client,
|
|
_global_state: &mut GlobalState,
|
|
args: Vec<String>,
|
|
) -> Result<String, String> {
|
|
if let Some(cmd) = parse_cmd_args!(args, ServerChatCommand) {
|
|
Ok(cmd.help_string())
|
|
} else {
|
|
let mut message = String::new();
|
|
let entity_role = client
|
|
.state()
|
|
.read_storage::<Admin>()
|
|
.get(client.entity())
|
|
.map(|admin| admin.0);
|
|
|
|
ClientChatCommand::iter().for_each(|cmd| {
|
|
message += &cmd.help_string();
|
|
message += "\n";
|
|
});
|
|
// Iterate through all ServerChatCommands you have permission to use.
|
|
ServerChatCommand::iter()
|
|
.filter(|cmd| cmd.needs_role() <= entity_role)
|
|
.for_each(|cmd| {
|
|
message += &cmd.help_string();
|
|
message += "\n";
|
|
});
|
|
message += "Additionally, you can use the following shortcuts:";
|
|
ServerChatCommand::iter()
|
|
.filter(|cmd| cmd.needs_role() <= entity_role)
|
|
.filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd)))
|
|
.for_each(|(k, cmd)| {
|
|
message += &format!(" /{} => /{}", k, cmd.keyword());
|
|
});
|
|
Ok(message)
|
|
}
|
|
}
|
|
|
|
fn handle_mute(
|
|
client: &Client,
|
|
global_state: &mut GlobalState,
|
|
args: Vec<String>,
|
|
) -> Result<String, String> {
|
|
if let Some(alias) = parse_cmd_args!(args, String) {
|
|
let target = client
|
|
.player_list()
|
|
.values()
|
|
.find(|p| p.player_alias == alias)
|
|
.ok_or_else(|| format!("Could not find a player named {}", alias))?;
|
|
|
|
if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) {
|
|
if target.uuid == me.uuid {
|
|
return Err("You cannot mute yourself.".to_string());
|
|
}
|
|
}
|
|
|
|
if global_state
|
|
.profile
|
|
.mutelist
|
|
.insert(target.uuid, alias.clone())
|
|
.is_none()
|
|
{
|
|
Ok(format!("Successfully muted player {}.", alias))
|
|
} else {
|
|
Err(format!("{} is already muted.", alias))
|
|
}
|
|
} else {
|
|
Err("You must specify a player to mute.".to_string())
|
|
}
|
|
}
|
|
|
|
fn handle_unmute(
|
|
client: &Client,
|
|
global_state: &mut GlobalState,
|
|
args: Vec<String>,
|
|
) -> Result<String, String> {
|
|
// Note that we don't care if this is a real player, so that it's possible
|
|
// to unmute someone when they're offline
|
|
if let Some(alias) = parse_cmd_args!(args, String) {
|
|
if let Some(uuid) = global_state
|
|
.profile
|
|
.mutelist
|
|
.iter()
|
|
.find(|(_, v)| **v == alias)
|
|
.map(|(k, _)| *k)
|
|
{
|
|
if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) {
|
|
if uuid == me.uuid {
|
|
return Err("You cannot unmute yourself.".to_string());
|
|
}
|
|
}
|
|
|
|
global_state.profile.mutelist.remove(&uuid);
|
|
Ok(format!("Successfully unmuted player {}.", alias))
|
|
} else {
|
|
Err(format!("Could not find a muted player named {}.", alias))
|
|
}
|
|
} else {
|
|
Err("You must specify a player to unmute.".to_string())
|
|
}
|
|
}
|
|
|
|
fn handle_experimental_shader(
|
|
_client: &Client,
|
|
global_state: &mut GlobalState,
|
|
args: Vec<String>,
|
|
) -> Result<String, String> {
|
|
if args.is_empty() {
|
|
ExperimentalShader::iter()
|
|
.map(|s| {
|
|
let is_active = global_state
|
|
.settings
|
|
.graphics
|
|
.render_mode
|
|
.experimental_shaders
|
|
.contains(&s);
|
|
format!("[{}] {}", if is_active { "x" } else { " " }, s)
|
|
})
|
|
.reduce(|mut a, b| {
|
|
a.push('\n');
|
|
a.push_str(&b);
|
|
a
|
|
})
|
|
.ok_or("There are no experimental shaders.".to_string())
|
|
} else if let Some(item) = parse_cmd_args!(args, String) {
|
|
if let Ok(shader) = ExperimentalShader::from_str(&item) {
|
|
let mut new_render_mode = global_state.settings.graphics.render_mode.clone();
|
|
let res = if new_render_mode.experimental_shaders.remove(&shader) {
|
|
Ok(format!("Disabled {item}."))
|
|
} else {
|
|
new_render_mode.experimental_shaders.insert(shader);
|
|
Ok(format!("Enabled {item}."))
|
|
};
|
|
|
|
change_render_mode(
|
|
new_render_mode,
|
|
&mut global_state.window,
|
|
&mut global_state.settings,
|
|
);
|
|
|
|
res
|
|
} else {
|
|
Err(format!(
|
|
"{item} is not an expermimental shader, use this command with any arguments to \
|
|
see a complete list."
|
|
))
|
|
}
|
|
} else {
|
|
Err(
|
|
"You must specify a valid experimental shader, to get a list of experimental shaders, \
|
|
use this command without any arguments."
|
|
.to_string(),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// A helper function to get the Uuid of a player with a given alias
|
|
pub fn get_player_uuid(client: &Client, alias: &String) -> Option<Uuid> {
|
|
client
|
|
.player_list()
|
|
.values()
|
|
.find(|p| p.player_alias == *alias)
|
|
.map(|p| p.uuid)
|
|
}
|
|
|
|
trait TabComplete {
|
|
fn complete(&self, part: &str, client: &Client) -> Vec<String>;
|
|
}
|
|
|
|
impl TabComplete for ArgumentSpec {
|
|
fn complete(&self, part: &str, client: &Client) -> Vec<String> {
|
|
match self {
|
|
ArgumentSpec::PlayerName(_) => complete_player(part, client),
|
|
ArgumentSpec::EntityTarget(_) => {
|
|
if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) {
|
|
match spec {
|
|
"" => ClientEntityTarget::iter()
|
|
.filter_map(|target| {
|
|
let ident = target.keyword();
|
|
if ident.starts_with(end) {
|
|
Some(format!("@{ident}"))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect(),
|
|
"uid" => {
|
|
if let Some(end) =
|
|
u64::from_str(end).ok().or(end.is_empty().then_some(0))
|
|
{
|
|
client
|
|
.state()
|
|
.ecs()
|
|
.read_storage::<Uid>()
|
|
.join()
|
|
.filter_map(|uid| {
|
|
let uid = u64::from(*uid);
|
|
if end < uid {
|
|
Some(format!("uid@{uid}"))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
} else {
|
|
vec![]
|
|
}
|
|
},
|
|
_ => vec![],
|
|
}
|
|
} else {
|
|
complete_player(part, client)
|
|
}
|
|
},
|
|
ArgumentSpec::SiteName(_) => complete_site(part, client),
|
|
ArgumentSpec::Float(_, x, _) => {
|
|
if part.is_empty() {
|
|
vec![format!("{:.1}", x)]
|
|
} else {
|
|
vec![]
|
|
}
|
|
},
|
|
ArgumentSpec::Integer(_, x, _) => {
|
|
if part.is_empty() {
|
|
vec![format!("{}", x)]
|
|
} else {
|
|
vec![]
|
|
}
|
|
},
|
|
ArgumentSpec::Any(_, _) => vec![],
|
|
ArgumentSpec::Command(_) => complete_command(part, ' '),
|
|
ArgumentSpec::Message(_) => complete_player(part, client),
|
|
ArgumentSpec::SubCommand => complete_command(part, ' '),
|
|
ArgumentSpec::Enum(_, strings, _) => strings
|
|
.iter()
|
|
.filter(|string| string.starts_with(part))
|
|
.map(|c| c.to_string())
|
|
.collect(),
|
|
ArgumentSpec::AssetPath(_, prefix, paths, _) => {
|
|
if let Some(part_stripped) = part.strip_prefix('#') {
|
|
paths
|
|
.iter()
|
|
.filter(|string| string.contains(part_stripped))
|
|
.filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
|
|
.collect()
|
|
} else {
|
|
let part_with_prefix = prefix.to_string() + part;
|
|
let depth = part_with_prefix.split('.').count();
|
|
paths
|
|
.iter()
|
|
.map(|path| path.as_str().split('.').take(depth).join("."))
|
|
.dedup()
|
|
.filter(|string| string.starts_with(&part_with_prefix))
|
|
.filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
|
|
.collect()
|
|
}
|
|
},
|
|
ArgumentSpec::Boolean(_, part, _) => ["true", "false"]
|
|
.iter()
|
|
.filter(|string| string.starts_with(part))
|
|
.map(|c| c.to_string())
|
|
.collect(),
|
|
ArgumentSpec::Flag(part) => vec![part.to_string()],
|
|
}
|
|
}
|
|
}
|
|
|
|
fn complete_player(part: &str, client: &Client) -> Vec<String> {
|
|
client
|
|
.player_list()
|
|
.values()
|
|
.map(|player_info| &player_info.player_alias)
|
|
.filter(|alias| alias.starts_with(part))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn complete_site(mut part: &str, client: &Client) -> Vec<String> {
|
|
if let Some(p) = part.strip_prefix('"') {
|
|
part = p;
|
|
}
|
|
client
|
|
.sites()
|
|
.values()
|
|
.filter_map(|site| match site.site.kind {
|
|
common_net::msg::world_msg::SiteKind::Cave => None,
|
|
_ => site.site.name.as_ref(),
|
|
})
|
|
.filter(|name| name.starts_with(part))
|
|
.map(|name| {
|
|
if name.contains(' ') {
|
|
format!("\"{}\"", name)
|
|
} else {
|
|
name.clone()
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// Get the byte index of the nth word. Used in completing "/sudo p /subcmd"
|
|
fn nth_word(line: &str, n: usize) -> Option<usize> {
|
|
let mut is_space = false;
|
|
let mut j = 0;
|
|
for (i, c) in line.char_indices() {
|
|
match (is_space, c.is_whitespace()) {
|
|
(true, true) => {},
|
|
(true, false) => {
|
|
is_space = false;
|
|
j += 1;
|
|
},
|
|
(false, true) => {
|
|
is_space = true;
|
|
},
|
|
(false, false) => {},
|
|
}
|
|
if j == n {
|
|
return Some(i);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn complete_command(part: &str, prefix: char) -> Vec<String> {
|
|
ServerChatCommand::iter_with_keywords()
|
|
.map(|(kwd, _)| kwd)
|
|
.chain(ClientChatCommand::iter_with_keywords().map(|(kwd, _)| kwd))
|
|
.filter(|kwd| kwd.starts_with(part))
|
|
.map(|kwd| format!("{}{}", prefix, kwd))
|
|
.collect()
|
|
}
|
|
|
|
pub fn complete(line: &str, client: &Client, cmd_prefix: char) -> Vec<String> {
|
|
let word = if line.chars().last().map_or(true, char::is_whitespace) {
|
|
""
|
|
} else {
|
|
line.split_whitespace().last().unwrap_or("")
|
|
};
|
|
|
|
if line.starts_with(cmd_prefix) {
|
|
let line = line.strip_prefix(cmd_prefix).unwrap_or(line);
|
|
let mut iter = line.split_whitespace();
|
|
let cmd = iter.next().unwrap_or("");
|
|
let i = iter.count() + usize::from(word.is_empty());
|
|
if i == 0 {
|
|
// Completing chat command name. This is the start of the line so the prefix
|
|
// will be part of it
|
|
let word = word.strip_prefix(cmd_prefix).unwrap_or(word);
|
|
return complete_command(word, cmd_prefix);
|
|
}
|
|
|
|
let args = {
|
|
if let Ok(cmd) = cmd.parse::<ServerChatCommand>() {
|
|
Some(cmd.data().args)
|
|
} else if let Ok(cmd) = cmd.parse::<ClientChatCommand>() {
|
|
Some(cmd.data().args)
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(args) = args {
|
|
if let Some(arg) = args.get(i - 1) {
|
|
// Complete ith argument
|
|
arg.complete(word, client)
|
|
} else {
|
|
// Complete past the last argument
|
|
match args.last() {
|
|
Some(ArgumentSpec::SubCommand) => {
|
|
if let Some(index) = nth_word(line, args.len()) {
|
|
complete(&line[index..], client, cmd_prefix)
|
|
} else {
|
|
vec![]
|
|
}
|
|
},
|
|
Some(ArgumentSpec::Message(_)) => complete_player(word, client),
|
|
_ => vec![], // End of command. Nothing to complete
|
|
}
|
|
}
|
|
} else {
|
|
// Completing for unknown chat command
|
|
complete_player(word, client)
|
|
}
|
|
} else {
|
|
// Not completing a command
|
|
complete_player(word, client)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn verify_cmd_list_sorted() {
|
|
let mut list = ClientChatCommand::iter()
|
|
.map(|c| c.keyword())
|
|
.collect::<Vec<_>>();
|
|
|
|
// Vec::is_sorted is unstable, so we do it the hard way
|
|
let list2 = list.clone();
|
|
list.sort_unstable();
|
|
assert_eq!(list, list2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_complete_command() {
|
|
assert_eq!(complete_command("mu", '/'), vec!["/mute".to_string()]);
|
|
assert_eq!(complete_command("unba", '/'), vec!["/unban".to_string()]);
|
|
assert_eq!(complete_command("make_", '/'), vec![
|
|
"/make_block".to_string(),
|
|
"/make_npc".to_string(),
|
|
"/make_sprite".to_string(),
|
|
"/make_volume".to_string()
|
|
]);
|
|
}
|