veloren_voxygen/
cmd.rs

1use std::str::FromStr;
2
3use crate::{
4    GlobalState,
5    render::ExperimentalShader,
6    session::{SessionState, settings_change::change_render_mode},
7};
8use client::Client;
9use common::{
10    cmd::*,
11    comp::Admin,
12    link::Is,
13    mounting::{Mount, Rider, VolumeRider},
14    parse_cmd_args,
15    resources::PlayerEntity,
16    uid::Uid,
17};
18use common_i18n::{Content, LocalizationArg};
19use common_net::sync::WorldSyncExt;
20use i18n::Localization;
21use itertools::Itertools;
22use levenshtein::levenshtein;
23use specs::{Join, WorldExt};
24use strum::{EnumIter, IntoEnumIterator};
25
26// Please keep this sorted alphabetically, same as with server commands :-)
27#[derive(Clone, Copy, strum::EnumIter)]
28pub enum ClientChatCommand {
29    Clear,
30    ExperimentalShader,
31    Help,
32    Mute,
33    Unmute,
34    Waypoint,
35    Wiki,
36}
37
38impl ClientChatCommand {
39    pub fn data(&self) -> ChatCommandData {
40        use ArgumentSpec::*;
41        use Requirement::*;
42        let cmd = ChatCommandData::new;
43        match self {
44            ClientChatCommand::Clear => {
45                cmd(Vec::new(), Content::localized("command-clear-desc"), None)
46            },
47            ClientChatCommand::ExperimentalShader => cmd(
48                vec![Enum(
49                    "Shader",
50                    ExperimentalShader::iter()
51                        .map(|item| item.to_string())
52                        .collect(),
53                    Optional,
54                )],
55                Content::localized("command-experimental_shader-desc"),
56                None,
57            ),
58            ClientChatCommand::Help => cmd(
59                vec![Command(Optional)],
60                Content::localized("command-help-desc"),
61                None,
62            ),
63            ClientChatCommand::Mute => cmd(
64                vec![PlayerName(Required)],
65                Content::localized("command-mute-desc"),
66                None,
67            ),
68            ClientChatCommand::Unmute => cmd(
69                vec![PlayerName(Required)],
70                Content::localized("command-unmute-desc"),
71                None,
72            ),
73            ClientChatCommand::Waypoint => {
74                cmd(vec![], Content::localized("command-waypoint-desc"), None)
75            },
76            ClientChatCommand::Wiki => cmd(
77                vec![Any("topic", Optional)],
78                Content::localized("command-wiki-desc"),
79                None,
80            ),
81        }
82    }
83
84    pub fn keyword(&self) -> &'static str {
85        match self {
86            ClientChatCommand::Clear => "clear",
87            ClientChatCommand::ExperimentalShader => "experimental_shader",
88            ClientChatCommand::Help => "help",
89            ClientChatCommand::Mute => "mute",
90            ClientChatCommand::Unmute => "unmute",
91            ClientChatCommand::Waypoint => "waypoint",
92            ClientChatCommand::Wiki => "wiki",
93        }
94    }
95
96    /// A message that explains what the command does
97    pub fn help_content(&self) -> Content {
98        let data = self.data();
99
100        let usage = std::iter::once(format!("/{}", self.keyword()))
101            .chain(data.args.iter().map(|arg| arg.usage_string()))
102            .collect::<Vec<_>>()
103            .join(" ");
104
105        Content::localized_with_args("command-help-template", [
106            ("usage", Content::Plain(usage)),
107            ("description", data.description),
108        ])
109    }
110
111    /// Returns a format string for parsing arguments with scan_fmt
112    pub fn arg_fmt(&self) -> String {
113        self.data()
114            .args
115            .iter()
116            .map(|arg| match arg {
117                ArgumentSpec::PlayerName(_) => "{}",
118                ArgumentSpec::EntityTarget(_) => "{}",
119                ArgumentSpec::SiteName(_) => "{/.*/}",
120                ArgumentSpec::Float(_, _, _) => "{}",
121                ArgumentSpec::Integer(_, _, _) => "{d}",
122                ArgumentSpec::Any(_, _) => "{}",
123                ArgumentSpec::Command(_) => "{}",
124                ArgumentSpec::Message(_) => "{/.*/}",
125                ArgumentSpec::SubCommand => "{} {/.*/}",
126                ArgumentSpec::Enum(_, _, _) => "{}",
127                ArgumentSpec::AssetPath(_, _, _, _) => "{}",
128                ArgumentSpec::Boolean(_, _, _) => "{}",
129                ArgumentSpec::Flag(_) => "{}",
130            })
131            .collect::<Vec<_>>()
132            .join(" ")
133    }
134
135    /// Produce an iterator over all the available commands
136    pub fn iter() -> impl Iterator<Item = Self> + Clone {
137        <Self as strum::IntoEnumIterator>::iter()
138    }
139
140    /// Produce an iterator that first goes over all the short keywords
141    /// and their associated commands and then iterates over all the normal
142    /// keywords with their associated commands
143    pub fn iter_with_keywords() -> impl Iterator<Item = (&'static str, Self)> {
144        Self::iter().map(|c| (c.keyword(), c))
145    }
146}
147
148impl FromStr for ClientChatCommand {
149    type Err = ();
150
151    fn from_str(keyword: &str) -> Result<ClientChatCommand, ()> {
152        Self::iter()
153            .map(|c| (c.keyword(), c))
154            .find_map(|(kwd, command)| (kwd == keyword).then_some(command))
155            .ok_or(())
156    }
157}
158
159#[derive(Clone, Copy)]
160pub enum ChatCommandKind {
161    Client(ClientChatCommand),
162    Server(ServerChatCommand),
163}
164
165impl FromStr for ChatCommandKind {
166    type Err = ();
167
168    fn from_str(s: &str) -> Result<Self, ()> {
169        if let Ok(cmd) = s.parse::<ClientChatCommand>() {
170            Ok(ChatCommandKind::Client(cmd))
171        } else if let Ok(cmd) = s.parse::<ServerChatCommand>() {
172            Ok(ChatCommandKind::Server(cmd))
173        } else {
174            Err(())
175        }
176    }
177}
178
179/// Represents the feedback shown to the user of a command, if any. Server
180/// commands give their feedback as an event, so in those cases this will always
181/// be Ok(None). An Err variant will be be displayed with the error icon and
182/// text color
183type CommandResult = Result<Option<Content>, Content>;
184
185#[derive(EnumIter)]
186enum ClientEntityTarget {
187    Target,
188    Selected,
189    Viewpoint,
190    Mount,
191    Rider,
192    TargetSelf,
193}
194
195impl ClientEntityTarget {
196    const PREFIX: char = '@';
197
198    fn keyword(&self) -> &'static str {
199        match self {
200            ClientEntityTarget::Target => "target",
201            ClientEntityTarget::Selected => "selected",
202            ClientEntityTarget::Viewpoint => "viewpoint",
203            ClientEntityTarget::Mount => "mount",
204            ClientEntityTarget::Rider => "rider",
205            ClientEntityTarget::TargetSelf => "self",
206        }
207    }
208}
209
210fn preproccess_command(
211    session_state: &mut SessionState,
212    command: &ChatCommandKind,
213    args: &mut [String],
214) -> CommandResult {
215    let mut cmd_args = match command {
216        ChatCommandKind::Client(cmd) => cmd.data().args,
217        ChatCommandKind::Server(cmd) => cmd.data().args,
218    };
219    let client = &mut session_state.client.borrow_mut();
220    let ecs = client.state().ecs();
221    let player = ecs.read_resource::<PlayerEntity>().0;
222    let mut command_start = 0;
223    for (i, arg) in args.iter_mut().enumerate() {
224        let mut could_be_entity_target = false;
225        if let Some(post_cmd_args) = cmd_args.get(i - command_start..) {
226            for (j, arg_spec) in post_cmd_args.iter().enumerate() {
227                match arg_spec {
228                    ArgumentSpec::EntityTarget(_) => could_be_entity_target = true,
229                    ArgumentSpec::SubCommand => {
230                        if let Some(sub_command) =
231                            ServerChatCommand::iter().find(|cmd| cmd.keyword() == arg)
232                        {
233                            cmd_args = sub_command.data().args;
234                            command_start = i + j + 1;
235                            break;
236                        }
237                    },
238                    ArgumentSpec::AssetPath(_, prefix, _, _) => {
239                        *arg = prefix.to_string() + arg;
240                    },
241                    _ => {},
242                }
243                if matches!(arg_spec.requirement(), Requirement::Required) {
244                    break;
245                }
246            }
247        } else if matches!(cmd_args.last(), Some(ArgumentSpec::SubCommand)) {
248            could_be_entity_target = true;
249        }
250        if could_be_entity_target && arg.starts_with(ClientEntityTarget::PREFIX) {
251            let target_str = arg.trim_start_matches(ClientEntityTarget::PREFIX);
252            let target = ClientEntityTarget::iter()
253                .find(|t| t.keyword() == target_str)
254                .ok_or_else(|| {
255                    let expected_list = ClientEntityTarget::iter()
256                        .map(|t| t.keyword().to_string())
257                        .collect::<Vec<String>>()
258                        .join("/");
259                    Content::localized_with_args("command-preprocess-target-error", [
260                        ("expected_list", LocalizationArg::from(expected_list)),
261                        ("target", LocalizationArg::from(target_str)),
262                    ])
263                })?;
264            let uid = match target {
265                ClientEntityTarget::Target => session_state
266                    .target_entity
267                    .and_then(|e| ecs.uid_from_entity(e))
268                    .ok_or(Content::localized(
269                        "command-preprocess-not-looking-at-valid-target",
270                    ))?,
271                ClientEntityTarget::Selected => session_state
272                    .selected_entity
273                    .and_then(|(e, _)| ecs.uid_from_entity(e))
274                    .ok_or(Content::localized(
275                        "command-preprocess-not-selected-valid-target",
276                    ))?,
277                ClientEntityTarget::Viewpoint => session_state
278                    .viewpoint_entity
279                    .and_then(|e| ecs.uid_from_entity(e))
280                    .ok_or(Content::localized(
281                        "command-preprocess-not-valid-viewpoint-entity",
282                    ))?,
283                ClientEntityTarget::Mount => {
284                    if let Some(player) = player {
285                        ecs.read_storage::<Is<Rider>>()
286                            .get(player)
287                            .map(|is_rider| is_rider.mount)
288                            .or(ecs.read_storage::<Is<VolumeRider>>().get(player).and_then(
289                                |is_rider| match is_rider.pos.kind {
290                                    common::mounting::Volume::Terrain => None,
291                                    common::mounting::Volume::Entity(uid) => Some(uid),
292                                },
293                            ))
294                            .ok_or(Content::localized(
295                                "command-preprocess-not-riding-valid-entity",
296                            ))?
297                    } else {
298                        return Err(Content::localized("command-preprocess-no-player-entity"));
299                    }
300                },
301                ClientEntityTarget::Rider => {
302                    if let Some(player) = player {
303                        ecs.read_storage::<Is<Mount>>()
304                            .get(player)
305                            .map(|is_mount| is_mount.rider)
306                            .ok_or(Content::localized("command-preprocess-not-valid-rider"))?
307                    } else {
308                        return Err(Content::localized("command-preprocess-no-player-entity"));
309                    }
310                },
311                ClientEntityTarget::TargetSelf => player
312                    .and_then(|e| ecs.uid_from_entity(e))
313                    .ok_or(Content::localized("command-preprocess-no-player-entity"))?,
314            };
315            let uid = u64::from(uid);
316            *arg = format!("uid@{uid}");
317        }
318    }
319
320    Ok(None)
321}
322
323/// Runs a command by either sending it to the server or processing it
324/// locally. Returns a String to be output to the chat.
325// Note: it's not clear what data future commands will need access to, so the
326// signature of this function might change
327pub fn run_command(
328    session_state: &mut SessionState,
329    global_state: &mut GlobalState,
330    cmd: &str,
331    mut args: Vec<String>,
332) -> CommandResult {
333    let command = ChatCommandKind::from_str(cmd)
334        .map_err(|_| invalid_command_message(&session_state.client.borrow(), cmd.to_string()))?;
335
336    preproccess_command(session_state, &command, &mut args)?;
337
338    match command {
339        ChatCommandKind::Server(cmd) => {
340            session_state
341                .client
342                .borrow_mut()
343                .send_command(cmd.keyword().into(), args);
344            Ok(None) // The server will provide a response when the command is
345            // run
346        },
347        ChatCommandKind::Client(cmd) => run_client_command(session_state, global_state, cmd, args),
348    }
349}
350
351fn invalid_command_message(client: &Client, user_entered_invalid_command: String) -> Content {
352    let entity_role = client
353        .state()
354        .read_storage::<Admin>()
355        .get(client.entity())
356        .map(|admin| admin.0);
357
358    let usable_commands = ServerChatCommand::iter()
359        .filter(|cmd| cmd.needs_role() <= entity_role)
360        .map(|cmd| cmd.keyword())
361        .chain(ClientChatCommand::iter().map(|cmd| cmd.keyword()));
362
363    let most_similar_cmd = usable_commands
364        .clone()
365        .min_by_key(|cmd| levenshtein(&user_entered_invalid_command, cmd))
366        .expect("At least one command exists.");
367
368    let commands_with_same_prefix = usable_commands
369        .filter(|cmd| cmd.starts_with(&user_entered_invalid_command) && cmd != &most_similar_cmd);
370
371    Content::localized_with_args("command-invalid-command-message", [
372        (
373            "invalid-command",
374            LocalizationArg::from(user_entered_invalid_command.clone()),
375        ),
376        (
377            "most-similar-command",
378            LocalizationArg::from(String::from("/") + most_similar_cmd),
379        ),
380        (
381            "commands-with-same-prefix",
382            LocalizationArg::from(
383                commands_with_same_prefix
384                    .map(|cmd| format!("/{cmd}"))
385                    .collect::<String>(),
386            ),
387        ),
388    ])
389}
390
391fn run_client_command(
392    session_state: &mut SessionState,
393    global_state: &mut GlobalState,
394    command: ClientChatCommand,
395    args: Vec<String>,
396) -> CommandResult {
397    let command = match command {
398        ClientChatCommand::Clear => handle_clear,
399        ClientChatCommand::ExperimentalShader => handle_experimental_shader,
400        ClientChatCommand::Help => handle_help,
401        ClientChatCommand::Mute => handle_mute,
402        ClientChatCommand::Unmute => handle_unmute,
403        ClientChatCommand::Waypoint => handle_waypoint,
404        ClientChatCommand::Wiki => handle_wiki,
405    };
406
407    command(session_state, global_state, args)
408}
409
410fn handle_clear(
411    session_state: &mut SessionState,
412    _global_state: &mut GlobalState,
413    _args: Vec<String>,
414) -> CommandResult {
415    session_state.hud.clear_chat();
416    Ok(None)
417}
418
419fn handle_help(
420    session_state: &mut SessionState,
421    global_state: &mut GlobalState,
422    args: Vec<String>,
423) -> CommandResult {
424    let i18n = global_state.i18n.read();
425
426    if let Some(cmd) = parse_cmd_args!(&args, ServerChatCommand) {
427        Ok(Some(cmd.help_content()))
428    } else if let Some(cmd) = parse_cmd_args!(&args, ClientChatCommand) {
429        Ok(Some(cmd.help_content()))
430    } else {
431        let client = &mut session_state.client.borrow_mut();
432
433        let entity_role = client
434            .state()
435            .read_storage::<Admin>()
436            .get(client.entity())
437            .map(|admin| admin.0);
438
439        let client_commands = ClientChatCommand::iter()
440            .map(|cmd| i18n.get_content(&cmd.help_content()))
441            .join("\n");
442
443        // Iterate through all ServerChatCommands you have permission to use.
444        let server_commands = ServerChatCommand::iter()
445            .filter(|cmd| cmd.needs_role() <= entity_role)
446            .map(|cmd| i18n.get_content(&cmd.help_content()))
447            .join("\n");
448
449        let additional_shortcuts = ServerChatCommand::iter()
450            .filter(|cmd| cmd.needs_role() <= entity_role)
451            .filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd)))
452            .map(|(k, cmd)| format!("/{} => /{}", k, cmd.keyword()))
453            .join("\n");
454
455        Ok(Some(Content::localized_with_args("command-help-list", [
456            ("client-commands", LocalizationArg::from(client_commands)),
457            ("server-commands", LocalizationArg::from(server_commands)),
458            (
459                "additional-shortcuts",
460                LocalizationArg::from(additional_shortcuts),
461            ),
462        ])))
463    }
464}
465
466fn handle_mute(
467    session_state: &mut SessionState,
468    global_state: &mut GlobalState,
469    args: Vec<String>,
470) -> CommandResult {
471    if let Some(alias) = parse_cmd_args!(args, String) {
472        let client = &mut session_state.client.borrow_mut();
473
474        let target = client
475            .player_list()
476            .values()
477            .find(|p| p.player_alias == alias)
478            .ok_or_else(|| {
479                Content::localized_with_args("command-mute-no-player-found", [(
480                    "player",
481                    LocalizationArg::from(alias.clone()),
482                )])
483            })?;
484
485        if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) {
486            if target.uuid == me.uuid {
487                return Err(Content::localized("command-mute-cannot-mute-self"));
488            }
489        }
490
491        if global_state
492            .profile
493            .mutelist
494            .insert(target.uuid, alias.clone())
495            .is_none()
496        {
497            Ok(Some(Content::localized_with_args(
498                "command-mute-success",
499                [("player", LocalizationArg::from(alias))],
500            )))
501        } else {
502            Err(Content::localized_with_args(
503                "command-mute-already-muted",
504                [("player", LocalizationArg::from(alias))],
505            ))
506        }
507    } else {
508        Err(Content::localized("command-mute-no-player-specified"))
509    }
510}
511
512fn handle_unmute(
513    session_state: &mut SessionState,
514    global_state: &mut GlobalState,
515    args: Vec<String>,
516) -> CommandResult {
517    // Note that we don't care if this is a real player, so that it's possible
518    // to unmute someone when they're offline
519    if let Some(alias) = parse_cmd_args!(args, String) {
520        if let Some(uuid) = global_state
521            .profile
522            .mutelist
523            .iter()
524            .find(|(_, v)| **v == alias)
525            .map(|(k, _)| *k)
526        {
527            let client = &mut session_state.client.borrow_mut();
528
529            if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) {
530                if uuid == me.uuid {
531                    return Err(Content::localized("command-unmute-cannot-unmute-self"));
532                }
533            }
534
535            global_state.profile.mutelist.remove(&uuid);
536            Ok(Some(Content::localized_with_args(
537                "command-unmute-success",
538                [("player", LocalizationArg::from(alias))],
539            )))
540        } else {
541            Err(Content::localized_with_args(
542                "command-unmute-no-muted-player-found",
543                [("player", LocalizationArg::from(alias))],
544            ))
545        }
546    } else {
547        Err(Content::localized("command-unmute-no-player-specified"))
548    }
549}
550
551fn handle_experimental_shader(
552    _session_state: &mut SessionState,
553    global_state: &mut GlobalState,
554    args: Vec<String>,
555) -> CommandResult {
556    if args.is_empty() {
557        Ok(Some(Content::localized_with_args(
558            "command-experimental-shaders-list",
559            [(
560                "shader-list",
561                LocalizationArg::from(
562                    ExperimentalShader::iter()
563                        .map(|s| {
564                            let is_active = global_state
565                                .settings
566                                .graphics
567                                .render_mode
568                                .experimental_shaders
569                                .contains(&s);
570                            format!("[{}] {}", if is_active { "x" } else { "  " }, s)
571                        })
572                        .collect::<Vec<String>>()
573                        .join("/"),
574                ),
575            )],
576        )))
577    } else if let Some(item) = parse_cmd_args!(args, String) {
578        if let Ok(shader) = ExperimentalShader::from_str(&item) {
579            let mut new_render_mode = global_state.settings.graphics.render_mode.clone();
580            let res = if new_render_mode.experimental_shaders.remove(&shader) {
581                Ok(Some(Content::localized_with_args(
582                    "command-experimental-shaders-disabled",
583                    [("shader", LocalizationArg::from(item))],
584                )))
585            } else {
586                new_render_mode.experimental_shaders.insert(shader);
587                Ok(Some(Content::localized_with_args(
588                    "command-experimental-shaders-enabled",
589                    [("shader", LocalizationArg::from(item))],
590                )))
591            };
592
593            change_render_mode(
594                new_render_mode,
595                &mut global_state.window,
596                &mut global_state.settings,
597            );
598
599            res
600        } else {
601            Err(Content::localized_with_args(
602                "command-experimental-shaders-not-a-shader",
603                [("shader", LocalizationArg::from(item))],
604            ))
605        }
606    } else {
607        Err(Content::localized("command-experimental-shaders-not-valid"))
608    }
609}
610
611fn handle_waypoint(
612    session_state: &mut SessionState,
613    _global_state: &mut GlobalState,
614    _args: Vec<String>,
615) -> CommandResult {
616    let client = &mut session_state.client.borrow();
617
618    if let Some(waypoint) = client.waypoint() {
619        Ok(Some(Content::localized_with_args(
620            "command-waypoint-result",
621            [("waypoint", LocalizationArg::from(waypoint.clone()))],
622        )))
623    } else {
624        Err(Content::localized("command-waypoint-error"))
625    }
626}
627
628fn handle_wiki(
629    _session_state: &mut SessionState,
630    _global_state: &mut GlobalState,
631    args: Vec<String>,
632) -> CommandResult {
633    let url = if args.is_empty() {
634        "https://wiki.veloren.net/".to_string()
635    } else {
636        let query_string = args.join("+");
637
638        format!("https://wiki.veloren.net/w/index.php?search={query_string}")
639    };
640
641    open::that_detached(url)
642        .map(|_| Some(Content::localized("command-wiki-success")))
643        .map_err(|e| {
644            Content::localized_with_args("command-wiki-fail", [(
645                "error",
646                LocalizationArg::from(e.to_string()),
647            )])
648        })
649}
650
651trait TabComplete {
652    fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String>;
653}
654
655impl TabComplete for ArgumentSpec {
656    fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
657        match self {
658            ArgumentSpec::PlayerName(_) => complete_player(part, client),
659            ArgumentSpec::EntityTarget(_) => {
660                if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) {
661                    match spec {
662                        "" => ClientEntityTarget::iter()
663                            .filter_map(|target| {
664                                let ident = target.keyword();
665                                if ident.starts_with(end) {
666                                    Some(format!("@{ident}"))
667                                } else {
668                                    None
669                                }
670                            })
671                            .collect(),
672                        "uid" => {
673                            if let Some(end) =
674                                u64::from_str(end).ok().or(end.is_empty().then_some(0))
675                            {
676                                client
677                                    .state()
678                                    .ecs()
679                                    .read_storage::<Uid>()
680                                    .join()
681                                    .filter_map(|uid| {
682                                        let uid = u64::from(*uid);
683                                        if end < uid {
684                                            Some(format!("uid@{uid}"))
685                                        } else {
686                                            None
687                                        }
688                                    })
689                                    .collect()
690                            } else {
691                                vec![]
692                            }
693                        },
694                        _ => vec![],
695                    }
696                } else {
697                    complete_player(part, client)
698                }
699            },
700            ArgumentSpec::SiteName(_) => complete_site(part, client, i18n),
701            ArgumentSpec::Float(_, x, _) => {
702                if part.is_empty() {
703                    vec![format!("{:.1}", x)]
704                } else {
705                    vec![]
706                }
707            },
708            ArgumentSpec::Integer(_, x, _) => {
709                if part.is_empty() {
710                    vec![format!("{}", x)]
711                } else {
712                    vec![]
713                }
714            },
715            ArgumentSpec::Any(_, _) => vec![],
716            ArgumentSpec::Command(_) => complete_command(part, ""),
717            ArgumentSpec::Message(_) => complete_player(part, client),
718            ArgumentSpec::SubCommand => complete_command(part, ""),
719            ArgumentSpec::Enum(_, strings, _) => strings
720                .iter()
721                .filter(|string| string.starts_with(part))
722                .map(|c| c.to_string())
723                .collect(),
724            ArgumentSpec::AssetPath(_, prefix, paths, _) => {
725                if let Some(part_stripped) = part.strip_prefix('#') {
726                    paths
727                        .iter()
728                        .filter(|string| string.contains(part_stripped))
729                        .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
730                        .collect()
731                } else {
732                    let part_with_prefix = prefix.to_string() + part;
733                    let depth = part_with_prefix.split('.').count();
734                    paths
735                        .iter()
736                        .map(|path| path.as_str().split('.').take(depth).join("."))
737                        .dedup()
738                        .filter(|string| string.starts_with(&part_with_prefix))
739                        .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
740                        .collect()
741                }
742            },
743            ArgumentSpec::Boolean(_, part, _) => ["true", "false"]
744                .iter()
745                .filter(|string| string.starts_with(part))
746                .map(|c| c.to_string())
747                .collect(),
748            ArgumentSpec::Flag(part) => vec![part.to_string()],
749        }
750    }
751}
752
753fn complete_player(part: &str, client: &Client) -> Vec<String> {
754    client
755        .player_list()
756        .values()
757        .map(|player_info| &player_info.player_alias)
758        .filter(|alias| alias.starts_with(part))
759        .cloned()
760        .collect()
761}
762
763fn complete_site(mut part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
764    if let Some(p) = part.strip_prefix('"') {
765        part = p;
766    }
767    client
768        .sites()
769        .values()
770        .filter_map(|site| match site.marker.kind {
771            common_net::msg::world_msg::MarkerKind::Cave => None,
772            _ => Some(i18n.get_content(site.marker.name.as_ref()?)),
773        })
774        .filter(|name| name.starts_with(part))
775        .map(|name| {
776            if name.contains(' ') {
777                format!("\"{}\"", name)
778            } else {
779                name.clone()
780            }
781        })
782        .collect()
783}
784
785// Get the byte index of the nth word. Used in completing "/sudo p subcmd"
786fn nth_word(line: &str, n: usize) -> Option<usize> {
787    let mut is_space = false;
788    let mut j = 0;
789    for (i, c) in line.char_indices() {
790        match (is_space, c.is_whitespace()) {
791            (true, true) => {},
792            (true, false) => {
793                is_space = false;
794                j += 1;
795            },
796            (false, true) => {
797                is_space = true;
798            },
799            (false, false) => {},
800        }
801        if j == n {
802            return Some(i);
803        }
804    }
805    None
806}
807
808fn complete_command(part: &str, prefix: &str) -> Vec<String> {
809    ServerChatCommand::iter_with_keywords()
810        .map(|(kwd, _)| kwd)
811        .chain(ClientChatCommand::iter_with_keywords().map(|(kwd, _)| kwd))
812        .filter(|kwd| kwd.starts_with(part))
813        .map(|kwd| format!("{}{}", prefix, kwd))
814        .collect()
815}
816
817pub fn complete(line: &str, client: &Client, i18n: &Localization, cmd_prefix: &str) -> Vec<String> {
818    let word = if line.chars().last().is_none_or(char::is_whitespace) {
819        ""
820    } else {
821        line.split_whitespace().last().unwrap_or("")
822    };
823
824    if line.starts_with(cmd_prefix) {
825        let line = line.strip_prefix(cmd_prefix).unwrap_or(line);
826        let mut iter = line.split_whitespace();
827        let cmd = iter.next().unwrap_or("");
828        let i = iter.count() + usize::from(word.is_empty());
829        if i == 0 {
830            // Completing chat command name. This is the start of the line so the prefix
831            // will be part of it
832            let word = word.strip_prefix(cmd_prefix).unwrap_or(word);
833            return complete_command(word, cmd_prefix);
834        }
835
836        let args = {
837            if let Ok(cmd) = cmd.parse::<ServerChatCommand>() {
838                Some(cmd.data().args)
839            } else if let Ok(cmd) = cmd.parse::<ClientChatCommand>() {
840                Some(cmd.data().args)
841            } else {
842                None
843            }
844        };
845
846        if let Some(args) = args {
847            if let Some(arg) = args.get(i - 1) {
848                // Complete ith argument
849                arg.complete(word, client, i18n)
850            } else {
851                // Complete past the last argument
852                match args.last() {
853                    Some(ArgumentSpec::SubCommand) => {
854                        if let Some(index) = nth_word(line, args.len()) {
855                            complete(&line[index..], client, i18n, "")
856                        } else {
857                            vec![]
858                        }
859                    },
860                    Some(ArgumentSpec::Message(_)) => complete_player(word, client),
861                    _ => vec![], // End of command. Nothing to complete
862                }
863            }
864        } else {
865            // Completing for unknown chat command
866            complete_player(word, client)
867        }
868    } else {
869        // Not completing a command
870        complete_player(word, client)
871    }
872}
873
874#[test]
875fn verify_cmd_list_sorted() {
876    let mut list = ClientChatCommand::iter()
877        .map(|c| c.keyword())
878        .collect::<Vec<_>>();
879
880    // Vec::is_sorted is unstable, so we do it the hard way
881    let list2 = list.clone();
882    list.sort_unstable();
883    assert_eq!(list, list2);
884}
885
886#[test]
887fn test_complete_command() {
888    assert_eq!(complete_command("mu", "/"), vec!["/mute".to_string()]);
889    assert_eq!(complete_command("unba", "/"), vec![
890        "/unban".to_string(),
891        "/unban_ip".to_string()
892    ]);
893    assert_eq!(complete_command("make_", "/"), vec![
894        "/make_block".to_string(),
895        "/make_npc".to_string(),
896        "/make_sprite".to_string(),
897        "/make_volume".to_string()
898    ]);
899}