1use 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#[derive(Clone, Copy, strum::EnumIter)]
46pub enum ClientChatCommand {
47 Clear,
49 ExperimentalShader,
51 Help,
53 Mute,
55 Naga,
57 Unmute,
59 Waypoint,
62 Wiki,
64}
65
66impl ClientChatCommand {
67 pub fn data(&self) -> ChatCommandData {
73 use ArgumentSpec::*;
74 use Requirement::*;
75 let cmd = ChatCommandData::new;
76 match self {
77 ClientChatCommand::Clear => {
78 cmd(Vec::new(), Content::localized("command-clear-desc"), None)
79 },
80 ClientChatCommand::ExperimentalShader => cmd(
81 vec![Enum(
82 "Shader",
83 ExperimentalShader::iter()
84 .map(|item| item.to_string())
85 .collect(),
86 Optional,
87 )],
88 Content::localized("command-experimental_shader-desc"),
89 None,
90 ),
91 ClientChatCommand::Help => cmd(
92 vec![Command(Optional)],
93 Content::localized("command-help-desc"),
94 None,
95 ),
96 ClientChatCommand::Naga => cmd(vec![], Content::localized("command-naga-desc"), None),
97 ClientChatCommand::Mute => cmd(
98 vec![PlayerName(Required)],
99 Content::localized("command-mute-desc"),
100 None,
101 ),
102 ClientChatCommand::Unmute => cmd(
103 vec![PlayerName(Required)],
104 Content::localized("command-unmute-desc"),
105 None,
106 ),
107 ClientChatCommand::Waypoint => {
108 cmd(vec![], Content::localized("command-waypoint-desc"), None)
109 },
110 ClientChatCommand::Wiki => cmd(
111 vec![Any("topic", Optional)],
112 Content::localized("command-wiki-desc"),
113 None,
114 ),
115 }
116 }
117
118 pub fn keyword(&self) -> &'static str {
122 match self {
123 ClientChatCommand::Clear => "clear",
124 ClientChatCommand::ExperimentalShader => "experimental_shader",
125 ClientChatCommand::Help => "help",
126 ClientChatCommand::Naga => "naga",
127 ClientChatCommand::Mute => "mute",
128 ClientChatCommand::Unmute => "unmute",
129 ClientChatCommand::Waypoint => "waypoint",
130 ClientChatCommand::Wiki => "wiki",
131 }
132 }
133
134 pub fn help_content(&self) -> Content {
136 let data = self.data();
137
138 let usage = std::iter::once(format!("/{}", self.keyword()))
139 .chain(data.args.iter().map(|arg| arg.usage_string()))
140 .collect::<Vec<_>>()
141 .join(" ");
142
143 Content::localized_with_args("command-help-template", [
144 ("usage", Content::Plain(usage)),
145 ("description", data.description),
146 ])
147 }
148
149 pub fn iter() -> impl Iterator<Item = Self> + Clone {
151 <Self as strum::IntoEnumIterator>::iter()
152 }
153
154 pub fn iter_with_keywords() -> impl Iterator<Item = (&'static str, Self)> {
158 Self::iter().map(|c| (c.keyword(), c))
159 }
160}
161
162impl FromStr for ClientChatCommand {
163 type Err = ();
164
165 fn from_str(keyword: &str) -> Result<ClientChatCommand, ()> {
166 Self::iter()
167 .map(|c| (c.keyword(), c))
168 .find_map(|(kwd, command)| (kwd == keyword).then_some(command))
169 .ok_or(())
170 }
171}
172
173#[derive(Clone, Copy)]
179pub enum ChatCommandKind {
180 Client(ClientChatCommand),
181 Server(ServerChatCommand),
182}
183
184impl FromStr for ChatCommandKind {
185 type Err = ();
186
187 fn from_str(s: &str) -> Result<Self, ()> {
188 if let Ok(cmd) = s.parse::<ClientChatCommand>() {
189 Ok(ChatCommandKind::Client(cmd))
190 } else if let Ok(cmd) = s.parse::<ServerChatCommand>() {
191 Ok(ChatCommandKind::Server(cmd))
192 } else {
193 Err(())
194 }
195 }
196}
197
198type CommandResult = Result<Option<Content>, Content>;
207
208#[derive(EnumIter)]
213enum ClientEntityTarget {
214 Target,
216 Selected,
218 Viewpoint,
220 Mount,
222 Rider,
224 TargetSelf,
226}
227
228impl ClientEntityTarget {
229 const PREFIX: char = '@';
230
231 fn keyword(&self) -> &'static str {
232 match self {
233 ClientEntityTarget::Target => "target",
234 ClientEntityTarget::Selected => "selected",
235 ClientEntityTarget::Viewpoint => "viewpoint",
236 ClientEntityTarget::Mount => "mount",
237 ClientEntityTarget::Rider => "rider",
238 ClientEntityTarget::TargetSelf => "self",
239 }
240 }
241}
242
243fn preproccess_command(
249 session_state: &mut SessionState,
250 command: &ChatCommandKind,
251 args: &mut [String],
252) -> CommandResult {
253 let mut cmd_args = match command {
255 ChatCommandKind::Client(cmd) => cmd.data().args,
256 ChatCommandKind::Server(cmd) => cmd.data().args,
257 };
258 let client = &mut session_state.client.borrow_mut();
259 let ecs = client.state().ecs();
260 let player = ecs.read_resource::<PlayerEntity>().0;
261
262 let mut command_start = 0;
263
264 for (i, arg) in args.iter_mut().enumerate() {
265 let mut could_be_entity_target = false;
266
267 if let Some(post_cmd_args) = cmd_args.get(i - command_start..) {
268 for (j, arg_spec) in post_cmd_args.iter().enumerate() {
269 match arg_spec {
270 ArgumentSpec::EntityTarget(_) => could_be_entity_target = true,
271
272 ArgumentSpec::SubCommand => {
273 if let Some(sub_command) =
274 ServerChatCommand::iter().find(|cmd| cmd.keyword() == arg)
275 {
276 cmd_args = sub_command.data().args;
277 command_start = i + j + 1;
278 break;
279 }
280 },
281
282 ArgumentSpec::AssetPath(_, prefix, _, _) => {
283 *arg = prefix.to_string() + arg;
284 },
285 _ => {},
286 }
287
288 if matches!(arg_spec.requirement(), Requirement::Required) {
289 break;
290 }
291 }
292 } else if matches!(cmd_args.last(), Some(ArgumentSpec::SubCommand)) {
293 could_be_entity_target = true;
296 }
297 if could_be_entity_target && arg.starts_with(ClientEntityTarget::PREFIX) {
299 let target_str = arg.trim_start_matches(ClientEntityTarget::PREFIX);
301
302 let target = ClientEntityTarget::iter()
304 .find(|t| t.keyword() == target_str)
305 .ok_or_else(|| {
306 let expected_list = ClientEntityTarget::iter()
308 .map(|t| t.keyword().to_string())
309 .collect::<Vec<String>>()
310 .join("/");
311 Content::localized_with_args("command-preprocess-target-error", [
312 ("expected_list", LocalizationArg::from(expected_list)),
313 ("target", LocalizationArg::from(target_str)),
314 ])
315 })?;
316 let uid = match target {
317 ClientEntityTarget::Target => session_state
318 .target_entity
319 .and_then(|e| ecs.uid_from_entity(e))
320 .ok_or(Content::localized(
321 "command-preprocess-not-looking-at-valid-target",
322 ))?,
323 ClientEntityTarget::Selected => session_state
324 .selected_entity
325 .and_then(|(e, _)| ecs.uid_from_entity(e))
326 .ok_or(Content::localized(
327 "command-preprocess-not-selected-valid-target",
328 ))?,
329 ClientEntityTarget::Viewpoint => session_state
330 .viewpoint_entity
331 .and_then(|e| ecs.uid_from_entity(e))
332 .ok_or(Content::localized(
333 "command-preprocess-not-valid-viewpoint-entity",
334 ))?,
335 ClientEntityTarget::Mount => {
336 if let Some(player) = player {
337 ecs.read_storage::<Is<Rider>>()
338 .get(player)
339 .map(|is_rider| is_rider.mount)
340 .or(ecs.read_storage::<Is<VolumeRider>>().get(player).and_then(
341 |is_rider| match is_rider.pos.kind {
342 common::mounting::Volume::Terrain => None,
343 common::mounting::Volume::Entity(uid) => Some(uid),
344 },
345 ))
346 .ok_or(Content::localized(
347 "command-preprocess-not-riding-valid-entity",
348 ))?
349 } else {
350 return Err(Content::localized("command-preprocess-no-player-entity"));
351 }
352 },
353 ClientEntityTarget::Rider => {
354 if let Some(player) = player {
355 ecs.read_storage::<Is<Mount>>()
356 .get(player)
357 .map(|is_mount| is_mount.rider)
358 .ok_or(Content::localized("command-preprocess-not-valid-rider"))?
359 } else {
360 return Err(Content::localized("command-preprocess-no-player-entity"));
361 }
362 },
363 ClientEntityTarget::TargetSelf => player
364 .and_then(|e| ecs.uid_from_entity(e))
365 .ok_or(Content::localized("command-preprocess-no-player-entity"))?,
366 };
367
368 let uid = u64::from(uid);
370 *arg = format!("uid@{uid}");
371 }
372 }
373
374 Ok(None)
375}
376
377pub fn run_command(
384 session_state: &mut SessionState,
385 global_state: &mut GlobalState,
386 cmd: &str,
387 mut args: Vec<String>,
388) -> CommandResult {
389 let command = ChatCommandKind::from_str(cmd)
390 .map_err(|_| invalid_command_message(&session_state.client.borrow(), cmd.to_string()))?;
391
392 preproccess_command(session_state, &command, &mut args)?;
393
394 match command {
395 ChatCommandKind::Server(cmd) => {
396 session_state
397 .client
398 .borrow_mut()
399 .send_command(cmd.keyword().into(), args);
400 Ok(None) },
403 ChatCommandKind::Client(cmd) => run_client_command(session_state, global_state, cmd, args),
404 }
405}
406
407fn invalid_command_message(client: &Client, user_entered_invalid_command: String) -> Content {
409 let entity_role = client
410 .state()
411 .read_storage::<Admin>()
412 .get(client.entity())
413 .map(|admin| admin.0);
414
415 let usable_commands = ServerChatCommand::iter()
416 .filter(|cmd| cmd.needs_role() <= entity_role)
417 .map(|cmd| cmd.keyword())
418 .chain(ClientChatCommand::iter().map(|cmd| cmd.keyword()));
419
420 let most_similar_cmd = usable_commands
421 .clone()
422 .min_by_key(|cmd| levenshtein(&user_entered_invalid_command, cmd))
423 .expect("At least one command exists.");
424
425 let commands_with_same_prefix = usable_commands
426 .filter(|cmd| cmd.starts_with(&user_entered_invalid_command) && cmd != &most_similar_cmd);
427
428 Content::localized_with_args("command-invalid-command-message", [
429 (
430 "invalid-command",
431 LocalizationArg::from(user_entered_invalid_command.clone()),
432 ),
433 (
434 "most-similar-command",
435 LocalizationArg::from(String::from("/") + most_similar_cmd),
436 ),
437 (
438 "commands-with-same-prefix",
439 LocalizationArg::from(
440 commands_with_same_prefix
441 .map(|cmd| format!("/{cmd}"))
442 .collect::<String>(),
443 ),
444 ),
445 ])
446}
447
448fn run_client_command(
453 session_state: &mut SessionState,
454 global_state: &mut GlobalState,
455 command: ClientChatCommand,
456 args: Vec<String>,
457) -> CommandResult {
458 let command = match command {
459 ClientChatCommand::Clear => handle_clear,
460 ClientChatCommand::ExperimentalShader => handle_experimental_shader,
461 ClientChatCommand::Help => handle_help,
462 ClientChatCommand::Naga => handle_naga,
463 ClientChatCommand::Mute => handle_mute,
464 ClientChatCommand::Unmute => handle_unmute,
465 ClientChatCommand::Waypoint => handle_waypoint,
466 ClientChatCommand::Wiki => handle_wiki,
467 };
468
469 command(session_state, global_state, args)
470}
471
472fn handle_clear(
474 session_state: &mut SessionState,
475 _global_state: &mut GlobalState,
476 _args: Vec<String>,
477) -> CommandResult {
478 session_state.hud.clear_chat();
479 Ok(None)
480}
481
482fn handle_experimental_shader(
484 _session_state: &mut SessionState,
485 global_state: &mut GlobalState,
486 args: Vec<String>,
487) -> CommandResult {
488 if args.is_empty() {
489 Ok(Some(Content::localized_with_args(
490 "command-experimental-shaders-list",
491 [(
492 "shader-list",
493 LocalizationArg::from(
494 ExperimentalShader::iter()
495 .map(|s| {
496 let is_active = global_state
497 .settings
498 .graphics
499 .render_mode
500 .experimental_shaders
501 .contains(&s);
502 format!("[{}] {}", if is_active { "x" } else { " " }, s)
503 })
504 .collect::<Vec<String>>()
505 .join("/"),
506 ),
507 )],
508 )))
509 } else if let Some(item) = parse_cmd_args!(args, String) {
510 if let Ok(shader) = ExperimentalShader::from_str(&item) {
511 let mut new_render_mode = global_state.settings.graphics.render_mode.clone();
512 let res = if new_render_mode.experimental_shaders.remove(&shader) {
513 Ok(Some(Content::localized_with_args(
514 "command-experimental-shaders-disabled",
515 [("shader", LocalizationArg::from(item))],
516 )))
517 } else {
518 new_render_mode.experimental_shaders.insert(shader);
519 Ok(Some(Content::localized_with_args(
520 "command-experimental-shaders-enabled",
521 [("shader", LocalizationArg::from(item))],
522 )))
523 };
524
525 change_render_mode(
526 new_render_mode,
527 &mut global_state.window,
528 &mut global_state.settings,
529 );
530
531 res
532 } else {
533 Err(Content::localized_with_args(
534 "command-experimental-shaders-not-a-shader",
535 [("shader", LocalizationArg::from(item))],
536 ))
537 }
538 } else {
539 Err(Content::localized("command-experimental-shaders-not-valid"))
540 }
541}
542
543fn handle_help(
549 session_state: &mut SessionState,
550 global_state: &mut GlobalState,
551 args: Vec<String>,
552) -> CommandResult {
553 let i18n = global_state.i18n.read();
554
555 if let Some(cmd) = parse_cmd_args!(&args, ServerChatCommand) {
556 Ok(Some(cmd.help_content()))
557 } else if let Some(cmd) = parse_cmd_args!(&args, ClientChatCommand) {
558 Ok(Some(cmd.help_content()))
559 } else {
560 let client = &mut session_state.client.borrow_mut();
561
562 let entity_role = client
563 .state()
564 .read_storage::<Admin>()
565 .get(client.entity())
566 .map(|admin| admin.0);
567
568 let client_commands = ClientChatCommand::iter()
569 .map(|cmd| i18n.get_content(&cmd.help_content()))
570 .join("\n");
571
572 let server_commands = ServerChatCommand::iter()
574 .filter(|cmd| cmd.needs_role() <= entity_role)
575 .map(|cmd| i18n.get_content(&cmd.help_content()))
576 .join("\n");
577
578 let additional_shortcuts = ServerChatCommand::iter()
579 .filter(|cmd| cmd.needs_role() <= entity_role)
580 .filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd)))
581 .map(|(k, cmd)| format!("/{} => /{}", k, cmd.keyword()))
582 .join("\n");
583
584 Ok(Some(Content::localized_with_args("command-help-list", [
585 ("client-commands", LocalizationArg::from(client_commands)),
586 ("server-commands", LocalizationArg::from(server_commands)),
587 (
588 "additional-shortcuts",
589 LocalizationArg::from(additional_shortcuts),
590 ),
591 ])))
592 }
593}
594
595fn handle_naga(
599 _session_state: &mut SessionState,
600 global_state: &mut GlobalState,
601 _args: Vec<String>,
602) -> CommandResult {
603 let mut new_render_mode = global_state.settings.graphics.render_mode.clone();
604 new_render_mode.enable_naga ^= true;
605 let naga_enabled = new_render_mode.enable_naga;
606 change_render_mode(
607 new_render_mode,
608 &mut global_state.window,
609 &mut global_state.settings,
610 );
611
612 Ok(Some(Content::localized_with_args(
613 "command-shader-backend",
614 [(
615 "shader-backend",
616 if naga_enabled {
617 LocalizationArg::from("naga")
618 } else {
619 LocalizationArg::from("shaderc")
620 },
621 )],
622 )))
623}
624
625fn handle_mute(
627 session_state: &mut SessionState,
628 global_state: &mut GlobalState,
629 args: Vec<String>,
630) -> CommandResult {
631 if let Some(alias) = parse_cmd_args!(args, String) {
632 let client = &mut session_state.client.borrow_mut();
633
634 let target = client
635 .player_list()
636 .values()
637 .find(|p| p.player_alias == alias)
638 .ok_or_else(|| {
639 Content::localized_with_args("command-mute-no-player-found", [(
640 "player",
641 LocalizationArg::from(alias.clone()),
642 )])
643 })?;
644
645 if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid))
646 && target.uuid == me.uuid
647 {
648 return Err(Content::localized("command-mute-cannot-mute-self"));
649 }
650
651 if global_state
652 .profile
653 .mutelist
654 .insert(target.uuid, alias.clone())
655 .is_none()
656 {
657 Ok(Some(Content::localized_with_args(
658 "command-mute-success",
659 [("player", LocalizationArg::from(alias))],
660 )))
661 } else {
662 Err(Content::localized_with_args(
663 "command-mute-already-muted",
664 [("player", LocalizationArg::from(alias))],
665 ))
666 }
667 } else {
668 Err(Content::localized("command-mute-no-player-specified"))
669 }
670}
671
672fn handle_unmute(
674 session_state: &mut SessionState,
675 global_state: &mut GlobalState,
676 args: Vec<String>,
677) -> CommandResult {
678 if let Some(alias) = parse_cmd_args!(args, String) {
681 if let Some(uuid) = global_state
682 .profile
683 .mutelist
684 .iter()
685 .find(|(_, v)| **v == alias)
686 .map(|(k, _)| *k)
687 {
688 let client = &mut session_state.client.borrow_mut();
689
690 if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid))
691 && uuid == me.uuid
692 {
693 return Err(Content::localized("command-unmute-cannot-unmute-self"));
694 }
695
696 global_state.profile.mutelist.remove(&uuid);
697
698 Ok(Some(Content::localized_with_args(
699 "command-unmute-success",
700 [("player", LocalizationArg::from(alias))],
701 )))
702 } else {
703 Err(Content::localized_with_args(
704 "command-unmute-no-muted-player-found",
705 [("player", LocalizationArg::from(alias))],
706 ))
707 }
708 } else {
709 Err(Content::localized("command-unmute-no-player-specified"))
710 }
711}
712
713fn handle_waypoint(
715 session_state: &mut SessionState,
716 _global_state: &mut GlobalState,
717 _args: Vec<String>,
718) -> CommandResult {
719 let client = &mut session_state.client.borrow();
720
721 if let Some(waypoint) = client.waypoint() {
722 Ok(Some(Content::localized_with_args(
723 "command-waypoint-result",
724 [("waypoint", LocalizationArg::from(waypoint.clone()))],
725 )))
726 } else {
727 Err(Content::localized("command-waypoint-error"))
728 }
729}
730
731fn handle_wiki(
737 _session_state: &mut SessionState,
738 _global_state: &mut GlobalState,
739 args: Vec<String>,
740) -> CommandResult {
741 let url = if args.is_empty() {
742 "https://wiki.veloren.net/".to_string()
743 } else {
744 let query_string = args.join("+");
745
746 format!("https://wiki.veloren.net/w/index.php?search={query_string}")
747 };
748
749 open::that_detached(url)
750 .map(|_| Some(Content::localized("command-wiki-success")))
751 .map_err(|e| {
752 Content::localized_with_args("command-wiki-fail", [(
753 "error",
754 LocalizationArg::from(e.to_string()),
755 )])
756 })
757}
758
759trait TabComplete {
764 fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String>;
765}
766
767impl TabComplete for ArgumentSpec {
768 fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
769 match self {
770 ArgumentSpec::PlayerName(_) => complete_player(part, client),
771 ArgumentSpec::EntityTarget(_) => {
772 if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) {
774 match spec {
775 "" => ClientEntityTarget::iter()
777 .filter_map(|target| {
778 let ident = target.keyword();
779 if ident.starts_with(end) {
780 Some(format!("@{ident}"))
781 } else {
782 None
783 }
784 })
785 .collect(),
786 "uid" => {
788 if let Some(end) =
790 u64::from_str(end).ok().or(end.is_empty().then_some(0))
791 {
792 client
794 .state()
795 .ecs()
796 .read_storage::<Uid>()
797 .join()
798 .filter_map(|uid| {
799 let uid = u64::from(*uid);
800 if end < uid {
801 Some(format!("uid@{uid}"))
802 } else {
803 None
804 }
805 })
806 .collect()
807 } else {
808 vec![]
809 }
810 },
811 _ => vec![],
812 }
813 } else {
814 complete_player(part, client)
815 }
816 },
817 ArgumentSpec::SiteName(_) => complete_site(part, client, i18n),
818 ArgumentSpec::Float(_, x, _) => {
819 if part.is_empty() {
820 vec![format!("{:.1}", x)] } else {
822 vec![] }
824 },
825 ArgumentSpec::Integer(_, x, _) => {
826 if part.is_empty() {
827 vec![format!("{}", x)]
828 } else {
829 vec![]
830 }
831 },
832 ArgumentSpec::Any(_, _) => vec![],
834 ArgumentSpec::Command(_) => complete_command(part, ""),
835 ArgumentSpec::Message(_) => complete_player(part, client),
836 ArgumentSpec::SubCommand => complete_command(part, ""),
837 ArgumentSpec::Enum(_, strings, _) => strings
838 .iter()
839 .filter(|string| string.starts_with(part)) .map(|c| c.to_string())
841 .collect(),
842 ArgumentSpec::AssetPath(_, prefix, paths, _) => {
844 if let Some(part_stripped) = part.strip_prefix('#') {
846 paths
847 .iter()
848 .filter(|string| string.contains(part_stripped))
849 .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
850 .collect()
851 } else {
852 let part_with_prefix = prefix.to_string() + part;
854 let depth = part_with_prefix.split('.').count();
855 paths
856 .iter()
857 .map(|path| path.as_str().split('.').take(depth).join("."))
858 .dedup()
859 .filter(|string| string.starts_with(&part_with_prefix))
860 .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
861 .collect()
862 }
863 },
864 ArgumentSpec::Boolean(_, part, _) => ["true", "false"]
865 .iter()
866 .filter(|string| string.starts_with(part))
867 .map(|c| c.to_string())
868 .collect(),
869 ArgumentSpec::Flag(part) => vec![part.to_string()],
870 }
871 }
872}
873
874fn complete_player(part: &str, client: &Client) -> Vec<String> {
876 client
877 .player_list()
878 .values()
879 .map(|player_info| &player_info.player_alias)
880 .filter(|alias| alias.starts_with(part))
881 .cloned()
882 .collect()
883}
884
885fn complete_site(mut part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
887 if let Some(p) = part.strip_prefix('"') {
888 part = p;
889 }
890 client
891 .sites()
892 .values()
893 .filter_map(|site| match site.marker.kind {
894 common::map::MarkerKind::Cave => None,
895 _ => Some(i18n.get_content(site.marker.label.as_ref()?)),
897 })
898 .filter(|name| name.starts_with(part))
899 .map(|name| {
900 if name.contains(' ') {
901 format!("\"{}\"", name)
902 } else {
903 name.clone()
904 }
905 })
906 .collect()
907}
908
909fn nth_word(line: &str, n: usize) -> Option<usize> {
911 let mut is_space = false;
912 let mut word_counter = 0;
913
914 for (i, c) in line.char_indices() {
915 match (is_space, c.is_whitespace()) {
916 (true, true) => {},
917 (true, false) => {
919 is_space = false;
920 word_counter += 1;
921 },
922 (false, true) => {
924 is_space = true;
925 },
926 (false, false) => {},
927 }
928
929 if word_counter == n {
930 return Some(i);
931 }
932 }
933
934 None
935}
936
937fn complete_command(part: &str, prefix: &str) -> Vec<String> {
940 ServerChatCommand::iter_with_keywords()
941 .map(|(kwd, _)| kwd)
942 .chain(ClientChatCommand::iter_with_keywords().map(|(kwd, _)| kwd))
943 .filter(|kwd| kwd.starts_with(part))
944 .map(|kwd| format!("{}{}", prefix, kwd))
945 .collect()
946}
947
948pub fn complete(line: &str, client: &Client, i18n: &Localization, cmd_prefix: &str) -> Vec<String> {
954 let word = if line.chars().last().is_none_or(char::is_whitespace) {
957 ""
958 } else {
959 line.split_whitespace().last().unwrap_or("")
960 };
961
962 if line.starts_with(cmd_prefix) {
964 let line = line.strip_prefix(cmd_prefix).unwrap_or(line);
966 let mut iter = line.split_whitespace();
967
968 let cmd = iter.next().unwrap_or("");
970
971 let argument_position = iter.count() + usize::from(word.is_empty());
973
974 if argument_position == 0 {
976 let word = word.strip_prefix(cmd_prefix).unwrap_or(word);
979 return complete_command(word, cmd_prefix);
980 }
981
982 let args = {
984 if let Ok(cmd) = cmd.parse::<ServerChatCommand>() {
985 Some(cmd.data().args)
986 } else if let Ok(cmd) = cmd.parse::<ClientChatCommand>() {
987 Some(cmd.data().args)
988 } else {
989 None
990 }
991 };
992
993 if let Some(args) = args {
994 if let Some(arg) = args.get(argument_position - 1) {
996 arg.complete(word, client, i18n)
998 } else {
999 match args.last() {
1001 Some(ArgumentSpec::SubCommand) => {
1003 if let Some(index) = nth_word(line, args.len()) {
1005 complete(&line[index..], client, i18n, "")
1007 } else {
1008 vec![]
1009 }
1010 },
1011 Some(ArgumentSpec::Message(_)) => complete_player(word, client),
1013 _ => vec![],
1014 }
1015 }
1016 } else {
1017 complete_player(word, client)
1018 }
1019 } else {
1020 complete_player(word, client)
1021 }
1022}
1023
1024#[test]
1025fn verify_cmd_list_sorted() {
1026 let mut list = ClientChatCommand::iter()
1027 .map(|c| c.keyword())
1028 .collect::<Vec<_>>();
1029
1030 let list2 = list.clone();
1032 list.sort_unstable();
1033 assert_eq!(list, list2);
1034}
1035
1036#[test]
1037fn test_complete_command() {
1038 assert_eq!(complete_command("mu", "/"), vec!["/mute".to_string()]);
1039 assert_eq!(complete_command("unba", "/"), vec![
1040 "/unban".to_string(),
1041 "/unban_ip".to_string()
1042 ]);
1043 assert_eq!(complete_command("make_", "/"), vec![
1044 "/make_block".to_string(),
1045 "/make_npc".to_string(),
1046 "/make_sprite".to_string(),
1047 "/make_volume".to_string()
1048 ]);
1049}