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#[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 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 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 pub fn iter() -> impl Iterator<Item = Self> + Clone {
137 <Self as strum::IntoEnumIterator>::iter()
138 }
139
140 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
179type 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
323pub 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) },
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 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 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
785fn 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 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 arg.complete(word, client, i18n)
850 } else {
851 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![], }
863 }
864 } else {
865 complete_player(word, client)
867 }
868 } else {
869 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 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}