veloren_voxygen/
cmd.rs

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