diff --git a/Cargo.toml b/Cargo.toml index 08532095d..34cdcbd15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ members = [ "src/blocks/crates/build", "src/blocks/crates/data", "src/bin", - "src/commands", + "src/command-infra", "src/components", "src/core", "src/dashboard", @@ -129,7 +129,6 @@ temper-block-data = { path = "src/blocks/crates/data" } temper-config = { path = "src/config" } temper-core = { path = "src/core" } temper-default-commands = { path = "src/default_commands" } -temper-commands = { path = "src/commands" } temper-general-purpose = { path = "src/base/general_purpose" } temper-logging = { path = "src/base/logging" } temper-macros = { path = "src/base/macros" } @@ -137,6 +136,7 @@ temper-nbt = { path = "src/adapters/nbt" } temper-net-runtime = { path = "src/net/runtime" } temper-performance = { path = "src/performance" } temper-codec = { path = "src/net/codec" } +temper-command-infra = { path = "src/command-infra" } temper-protocol = { path = "src/net/protocol" } temper-encryption = { path = "src/net/encryption" } temper-profiling = { path = "src/base/profiling" } @@ -180,7 +180,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-appender = "0.2.5" tracing-tracy = { version = "0.11.4", features = ["timer-fallback", "ondemand", "fibers", "context-switch-tracing", "delayed-init"] } -log = "0.4.32" +log = "0.4.33" tracy-client = "0.18.4" # Concurrency/Parallelism @@ -195,7 +195,7 @@ reqwest = { version = "0.13.4", features = ["json", "native-tls", "blocking", "h # Error handling thiserror = "2.0.18" -anyhow = "1.0.102" +anyhow = "1.0.103" # Cryptography rand = "0.10.1" @@ -228,16 +228,16 @@ byteorder = "1.5.0" # Data types dashmap = { version = "7.0.0-rc2", features = ["serde"] } -uuid = { version = "1.23.3", features = ["v4", "v3", "serde"] } +uuid = { version = "1.23.4", features = ["v4", "v3", "serde"] } indexmap = { version = "2.14.0", features = ["serde"] } bimap = "0.6.3" -arrayvec = "0.7.6" +arrayvec = "0.7.7" fastcache = "0.1.7" # Macros lazy_static = "1.5.0" -quote = "1.0.45" -syn = { version = "2.0.117", features = ["extra-traits"] } +quote = "1.0.46" +syn = { version = "2.0.118", features = ["extra-traits", "full"] } proc-macro2 = "1.0.106" paste = "1.0.15" maplit = "1.0.2" @@ -260,7 +260,7 @@ heed = "0.22.1" # Misc deepsize = "0.2.0" page_size = "0.6.0" -enum-ordinalize = "4.3.2" +enum-ordinalize = "4.4.1" regex = "1.12.4" noise = "0.9.0" ctrlc = "3.5.2" @@ -274,9 +274,9 @@ mime_guess = "2.0.5" ## TUI/CLI crossterm = "0.29.0" -ratatui-core = "0.1.1" +ratatui-core = "0.1.2" tui-input = "0.15.3" -ratatui = "0.30.1" +ratatui = "0.30.2" tui-logger = { version = "0.18.2", features = ["tracing-support", "crossterm"] } clap = { version = "4.6.1", features = ["derive", "env"] } indicatif = "0.18.4" @@ -285,7 +285,7 @@ unicode-width = "0.2.2" heck = "0.5.0" # I/O -memmap2 = "0.9.10" +memmap2 = "0.9.11" tempfile = "3.27.0" walkdir = "2.5.0" include_dir = "0.7.4" @@ -293,14 +293,14 @@ include_dir = "0.7.4" # Benchmarking criterion = { version = "0.8.2", features = ["html_reports"] } -phf = { version = "0.13.1", features = ["macros"] } -phf_codegen = { version = "0.13.1" } +phf = { version = "0.14.0", features = ["macros"] } +phf_codegen = { version = "0.14.0" } # Web server axum = { version = "0.8.9", features = ["tokio", "ws"] } # Stats -sysinfo = { version = "0.39.3", default-features = false, features = ["system"] } +sysinfo = { version = "0.39.5", default-features = false, features = ["system"] } dir-size = "0.1.1" # Compression is a real bottleneck that we can do little about, so compiling it with optimizations is needed in dev mode. diff --git a/src/base/macros/src/command_derive/attrs.rs b/src/base/macros/src/command_derive/attrs.rs new file mode 100644 index 000000000..cac56daef --- /dev/null +++ b/src/base/macros/src/command_derive/attrs.rs @@ -0,0 +1,201 @@ +use syn::parse::{Parse, ParseStream}; +use syn::{ + Attribute, Expr, ExprArray, ExprLit, Ident, Lit, LitStr, Path, Result as SynResult, Token, +}; + +pub enum CommandKind { + Root(CommandAttrs), + Subcommand(SubcommandAttrs), +} + +pub struct CommandAttrs { + pub name: LitStr, + pub aliases: Vec, + pub permission: Option, +} + +pub struct SubcommandAttrs { + pub permission: Option, +} + +pub enum VariantPrefix { + Literal(PrefixAttrs), + Subcommand(PrefixAttrs), +} + +pub struct PrefixAttrs { + pub name: LitStr, + pub aliases: Vec, +} + +pub struct VariantAttrs { + pub prefix: Option, + pub permission: Option, +} + +pub fn command_kind(attrs: &[Attribute]) -> SynResult { + for attr in attrs { + if !attr.path().is_ident("command") { + continue; + } + + if let Ok(name) = attr.parse_args::() { + return Ok(CommandKind::Root(CommandAttrs { + name, + aliases: Vec::new(), + permission: None, + })); + } + + let mut name = None; + let mut aliases = Vec::new(); + let mut permission = None; + let mut subcommand = false; + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + name = Some(meta.value()?.parse::()?); + Ok(()) + } else if meta.path.is_ident("aliases") { + aliases = parse_aliases(meta.value()?.parse::()?)?; + Ok(()) + } else if meta.path.is_ident("permission") { + permission = Some(meta.value()?.parse::()?); + Ok(()) + } else if meta.path.is_ident("subcommand") { + subcommand = true; + Ok(()) + } else { + Err(meta.error("unsupported command option")) + } + })?; + + return match (name, subcommand) { + (Some(name), false) => Ok(CommandKind::Root(CommandAttrs { + name, + aliases, + permission, + })), + (None, true) => Ok(CommandKind::Subcommand(SubcommandAttrs { permission })), + (Some(_), true) => Err(syn::Error::new_spanned( + attr, + "command cannot be both named and a subcommand", + )), + (None, false) => Err(syn::Error::new_spanned( + attr, + "expected #[command(\"name\")], #[command(name = \"name\")], or #[command(subcommand)]", + )), + }; + } + + Err(syn::Error::new( + proc_macro2::Span::call_site(), + "missing #[command(...)] attribute", + )) +} + +pub fn variant_attrs(attrs: &[Attribute]) -> SynResult { + let mut prefix = None; + let mut permission = None; + + for attr in attrs { + let next = if attr.path().is_ident("literal") { + Some(VariantPrefix::Literal(attr.parse_args::()?)) + } else if attr.path().is_ident("subcommand") { + Some(VariantPrefix::Subcommand(attr.parse_args::()?)) + } else { + None + }; + + if let Some(next) = next { + if prefix.is_some() { + return Err(syn::Error::new_spanned( + attr, + "command variants can only have one #[literal(...)] or #[subcommand(...)] attribute", + )); + } + + prefix = Some(next); + continue; + } + + if attr.path().is_ident("permission") { + if permission.is_some() { + return Err(syn::Error::new_spanned( + attr, + "command variants can only have one #[permission(...)] attribute", + )); + } + + permission = Some(attr.parse_args::()?); + } + } + + Ok(VariantAttrs { prefix, permission }) +} + +pub fn permission_attr(attrs: &[Attribute]) -> SynResult> { + let mut permission = None; + + for attr in attrs { + if !attr.path().is_ident("permission") { + continue; + } + + if permission.is_some() { + return Err(syn::Error::new_spanned( + attr, + "fields can only have one #[permission(...)] attribute", + )); + } + + permission = Some(attr.parse_args::()?); + } + + Ok(permission) +} + +fn parse_aliases(aliases: ExprArray) -> SynResult> { + aliases + .elems + .into_iter() + .map(|expr| match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(alias), + .. + }) => Ok(alias), + _ => Err(syn::Error::new_spanned( + expr, + "aliases must be string literals", + )), + }) + .collect() +} + +impl Parse for PrefixAttrs { + fn parse(input: ParseStream<'_>) -> SynResult { + let name = input.parse::()?; + let mut aliases = Vec::new(); + + while input.peek(Token![,]) { + input.parse::()?; + + if input.is_empty() { + break; + } + + let option = input.parse::()?; + if option != "aliases" { + return Err(syn::Error::new_spanned( + option, + "unsupported literal/subcommand option", + )); + } + + input.parse::()?; + aliases = parse_aliases(input.parse::()?)?; + } + + Ok(Self { name, aliases }) + } +} diff --git a/src/base/macros/src/command_derive/expand.rs b/src/base/macros/src/command_derive/expand.rs new file mode 100644 index 000000000..d2b8897fd --- /dev/null +++ b/src/base/macros/src/command_derive/expand.rs @@ -0,0 +1,506 @@ +use quote::{format_ident, quote}; +use syn::{Data, DataEnum, DataStruct, DeriveInput, Ident, LitStr, Result as SynResult}; + +use super::attrs::{command_kind, variant_attrs, CommandKind, PrefixAttrs, VariantPrefix}; +use super::fields::{CommandFields, FieldParse}; + +pub fn expand(input: DeriveInput) -> SynResult { + let ident = input.ident; + let command_kind = command_kind(&input.attrs)?; + + match input.data { + Data::Enum(data_enum) => expand_enum(&ident, command_kind, data_enum), + Data::Struct(data_struct) => expand_struct(&ident, command_kind, data_struct), + Data::Union(_) => Err(syn::Error::new( + ident.span(), + "Command can only be derived for enums or structs", + )), + } +} + +fn expand_enum( + ident: &Ident, + command_kind: CommandKind, + data_enum: DataEnum, +) -> SynResult { + let mut parse_arms = Vec::new(); + let mut segment_entries = Vec::new(); + let mut greedy_assertions = Vec::new(); + let mut suggestion_registrations = Vec::new(); + + for variant in data_enum.variants { + let variant_ident = variant.ident; + let variant_attrs = variant_attrs(&variant.attrs)?; + let fields = CommandFields::from_fields(variant.fields)?; + + match variant_attrs.prefix.as_ref() { + Some(VariantPrefix::Subcommand(prefix)) => { + let ty = fields.single_unnamed_type()?; + let permission_parse = permission_parse(variant_attrs.permission.as_ref()); + let literal_parse = literal_parse(prefix); + parse_arms.push(quote! { + { + let __checkpoint = __reader.checkpoint(); + let __result = (|| -> Result { + #permission_parse + #literal_parse + let __subcommand = <#ty as ::temper_command_infra::SubcommandSpec>::parse_reader_with_permissions(__reader, __can_use)?; + Ok(Self::#variant_ident(__subcommand)) + })(); + + match __result { + Ok(__command) => return Ok(__command), + Err(__err) => { + __best_error = Some(match __best_error.take() { + Some(__best) => __best.farthest(__err), + None => __err, + }); + __reader.rewind(__checkpoint); + } + } + } + }); + + segment_entries.push(subcommand_segment_entries( + prefix, + variant_attrs.permission.as_ref(), + ty, + )); + } + _ => { + let field_parse = FieldParse::new(fields.fields())?; + greedy_assertions.extend(field_parse.greedy_assertions.clone()); + suggestion_registrations.extend(field_parse.suggestion_registrations.clone()); + let constructor = constructor(ident, &variant_ident, &fields, &field_parse); + let raw_bindings = &field_parse.raw_bindings; + let permission_parse = permission_parse(variant_attrs.permission.as_ref()); + let prefix_parse = prefix_parse(variant_attrs.prefix.as_ref()); + let variant_segment_entries = variant_segment_entries( + variant_attrs.prefix.as_ref(), + variant_attrs.permission.as_ref(), + &field_parse.segments, + ); + + parse_arms.push(quote! { + { + let __checkpoint = __reader.checkpoint(); + let __result = (|| -> Result { + #permission_parse + #prefix_parse + #(#raw_bindings)* + __reader.expect_end()?; + Ok(#constructor) + })(); + + match __result { + Ok(__command) => return Ok(__command), + Err(__err) => { + __best_error = Some(match __best_error.take() { + Some(__best) => __best.farthest(__err), + None => __err, + }); + __reader.rewind(__checkpoint); + } + } + } + }); + + segment_entries.push(variant_segment_entries); + } + } + } + + let parse_body = quote! { + let mut __best_error: Option<::temper_command_infra::ParseError> = None; + + #(#parse_arms)* + + Err(__best_error.unwrap_or_else(|| { + ::temper_command_infra::ParseError::expected(__reader.cursor(), "command variant") + })) + }; + + Ok(expand_impl( + ident, + command_kind, + parse_body, + segment_entries, + greedy_assertions, + suggestion_registrations, + )) +} + +fn expand_struct( + ident: &Ident, + command_kind: CommandKind, + data_struct: DataStruct, +) -> SynResult { + let fields = CommandFields::from_fields(data_struct.fields)?; + let field_parse = FieldParse::new(fields.fields())?; + let raw_bindings = &field_parse.raw_bindings; + let constructor = struct_constructor(&fields, &field_parse); + + let parse_body = quote! { + #(#raw_bindings)* + __reader.expect_end()?; + Ok(#constructor) + }; + + let segments = &field_parse.segments; + let segment_entries = vec![quote! { + vec![vec![#(#segments),*]] + }]; + + Ok(expand_impl( + ident, + command_kind, + parse_body, + segment_entries, + field_parse.greedy_assertions, + field_parse.suggestion_registrations, + )) +} + +fn expand_impl( + ident: &Ident, + command_kind: CommandKind, + parse_body: proc_macro2::TokenStream, + segment_entries: Vec, + greedy_assertions: Vec, + suggestion_registrations: Vec, +) -> proc_macro2::TokenStream { + let segment_builder = quote! { + let mut __segments = Vec::new(); + #( + __segments.extend(#segment_entries); + )* + __segments + }; + + match command_kind { + CommandKind::Root(command_attrs) => { + let command_name = command_attrs.name; + let aliases = command_attrs.aliases; + let command_permission_parse = permission_parse(command_attrs.permission.as_ref()); + let permission_fn = permission_fn(command_attrs.permission.as_ref()); + let registration = expand_registration(ident, &suggestion_registrations); + + quote! { + #(#greedy_assertions)* + + impl ::temper_command_infra::CommandSpec for #ident { + const NAME: &'static str = #command_name; + + fn parse_reader( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + ) -> Result { + let __can_use = |_| true; + Self::parse_reader_with_permissions(__reader, &__can_use) + } + + fn parse_reader_with_permissions( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + __can_use: &dyn Fn(::temper_command_infra::Permissions) -> bool, + ) -> Result { + #command_permission_parse + #parse_body + } + + fn aliases() -> &'static [&'static str] { + &[#(#aliases),*] + } + + #permission_fn + + fn paths() -> Vec<::temper_command_infra::CommandPath> { + #segment_builder + .into_iter() + .map(|__segments| { + ::temper_command_infra::CommandPath::new(#command_name, __segments) + }) + .collect() + } + } + + #registration + } + } + CommandKind::Subcommand(subcommand_attrs) => { + let permission_parse = permission_parse(subcommand_attrs.permission.as_ref()); + let subcommand_segments = subcommand_segments_with_permission( + subcommand_attrs.permission.as_ref(), + segment_builder, + ); + let suggestion_registration = + expand_suggestion_registration(ident, &suggestion_registrations); + + quote! { + #(#greedy_assertions)* + + impl ::temper_command_infra::SubcommandSpec for #ident { + fn parse_reader( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + ) -> Result { + let __can_use = |_| true; + Self::parse_reader_with_permissions(__reader, &__can_use) + } + + fn parse_reader_with_permissions( + __reader: &mut ::temper_command_infra::CommandReader<'_>, + __can_use: &dyn Fn(::temper_command_infra::Permissions) -> bool, + ) -> Result { + #permission_parse + #parse_body + } + + fn segments() -> Vec> { + #subcommand_segments + } + } + + #suggestion_registration + } + } + } +} + +fn expand_registration( + ident: &Ident, + suggestion_registrations: &[proc_macro2::TokenStream], +) -> proc_macro2::TokenStream { + let register_fn = format_ident!("__{}_register_command", ident); + let register_system_fn = format_ident!("__{}_register_command_system", ident); + let suggestion_registration = expand_suggestion_registration(ident, suggestion_registrations); + + quote! { + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_fn() { + ::temper_command_infra::register_static_command( + ::temper_command_infra::RegisteredCommand::of::<#ident>(), + ); + } + + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_system_fn() { + ::temper_command_infra::add_system( + ::temper_command_infra::dispatch_command::<#ident>, + ); + } + + #suggestion_registration + } +} + +fn expand_suggestion_registration( + ident: &Ident, + suggestion_registrations: &[proc_macro2::TokenStream], +) -> proc_macro2::TokenStream { + let register_suggestions_fn = format_ident!("__{}_register_command_arg_suggestions", ident); + + quote! { + #[::temper_command_infra::ctor::ctor(unsafe)] + #[allow(non_snake_case)] + #[doc(hidden)] + fn #register_suggestions_fn() { + #(#suggestion_registrations)* + } + } +} + +fn constructor( + ident: &Ident, + variant_ident: &Ident, + fields: &CommandFields, + field_parse: &FieldParse, +) -> proc_macro2::TokenStream { + match fields { + CommandFields::Unnamed(_) => { + let values = &field_parse.tuple_values; + quote! { + #ident::#variant_ident(#(#values),*) + } + } + CommandFields::Named(_) => { + let values = &field_parse.named_values; + quote! { + #ident::#variant_ident { #(#values),* } + } + } + CommandFields::Unit => quote! { + #ident::#variant_ident + }, + } +} + +fn struct_constructor( + fields: &CommandFields, + field_parse: &FieldParse, +) -> proc_macro2::TokenStream { + match fields { + CommandFields::Unnamed(_) => { + let values = &field_parse.tuple_values; + quote! { + Self(#(#values),*) + } + } + CommandFields::Named(_) => { + let values = &field_parse.named_values; + quote! { + Self { #(#values),* } + } + } + CommandFields::Unit => quote! { + Self + }, + } +} + +fn prefix_parse(prefix: Option<&VariantPrefix>) -> proc_macro2::TokenStream { + match prefix { + Some(VariantPrefix::Literal(prefix)) => literal_parse(prefix), + Some(VariantPrefix::Subcommand(_)) | None => quote! {}, + } +} + +fn variant_segment_entries( + prefix: Option<&VariantPrefix>, + permission: Option<&syn::Path>, + segments: &[proc_macro2::TokenStream], +) -> proc_macro2::TokenStream { + match prefix { + Some(VariantPrefix::Literal(prefix)) => { + let literal_segments = literal_segments(prefix, permission); + let trailing_segments = quote! { + vec![#(#segments),*] + }; + + quote! { + vec![ + #({ + let mut __segments = vec![#literal_segments]; + __segments.extend(#trailing_segments); + __segments + }),* + ] + } + } + Some(VariantPrefix::Subcommand(_)) | None => { + quote! { + vec![vec![#(#segments),*]] + } + } + } +} + +fn subcommand_segment_entries( + prefix: &PrefixAttrs, + permission: Option<&syn::Path>, + ty: &syn::Type, +) -> proc_macro2::TokenStream { + let literal_segments = literal_segments(prefix, permission); + + quote! { + { + let __subcommand_paths = <#ty as ::temper_command_infra::SubcommandSpec>::segments(); + let mut __paths = Vec::new(); + + #( + __paths.extend(__subcommand_paths.iter().cloned().map(|mut __segments| { + let mut __path = vec![#literal_segments]; + __path.append(&mut __segments); + __path + })); + )* + + __paths + } + } +} + +fn literal_segments( + prefix: &PrefixAttrs, + permission: Option<&syn::Path>, +) -> Vec { + std::iter::once(&prefix.name) + .chain(prefix.aliases.iter()) + .map(|literal| literal_segment(literal, permission)) + .collect() +} + +fn literal_segment(literal: &LitStr, permission: Option<&syn::Path>) -> proc_macro2::TokenStream { + let segment = quote! { + ::temper_command_infra::CommandPathSegment::literal(#literal) + }; + + match permission { + Some(permission) => quote! { + #segment.with_permission(#permission) + }, + None => segment, + } +} + +fn permission_parse(permission: Option<&syn::Path>) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + if !__can_use(#permission) { + return Err(::temper_command_infra::ParseError::new( + __reader.cursor(), + "permission", + "you do not have permission to use this command path", + )); + } + }, + None => quote! {}, + } +} + +fn permission_fn(permission: Option<&syn::Path>) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + fn permission() -> Option<::temper_command_infra::Permissions> { + Some(#permission) + } + }, + None => quote! {}, + } +} + +fn subcommand_segments_with_permission( + permission: Option<&syn::Path>, + segment_builder: proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + let mut __segments = #segment_builder; + for __path in &mut __segments { + if let Some(__first) = __path.first_mut() { + *__first = __first.clone().with_permission(#permission); + } + } + __segments + }, + None => segment_builder, + } +} + +fn literal_parse(prefix: &PrefixAttrs) -> proc_macro2::TokenStream { + let literal = &prefix.name; + let aliases = &prefix.aliases; + + quote! { + let __literal_cursor = __reader.cursor(); + let __actual_literal = __reader.read_word_span()?; + if __actual_literal != #literal #(&& __actual_literal != #aliases)* { + return Err(::temper_command_infra::ParseError::new( + __literal_cursor, + #literal, + format!("expected literal {}", #literal), + )); + } + } +} diff --git a/src/base/macros/src/command_derive/fields.rs b/src/base/macros/src/command_derive/fields.rs new file mode 100644 index 000000000..d9761ac76 --- /dev/null +++ b/src/base/macros/src/command_derive/fields.rs @@ -0,0 +1,219 @@ +use quote::{format_ident, quote, quote_spanned}; +use syn::{spanned::Spanned, Field, Fields, Ident, LitStr, Path, Result as SynResult, Type}; + +use super::attrs::permission_attr; + +pub enum CommandFields { + Unnamed(Vec), + Named(Vec), + Unit, +} + +impl CommandFields { + pub fn from_fields(fields: Fields) -> SynResult { + match fields { + Fields::Unnamed(fields) => Ok(Self::Unnamed( + fields + .unnamed + .into_iter() + .map(CommandField::unnamed) + .collect::>>()?, + )), + Fields::Named(fields) => Ok(Self::Named( + fields + .named + .into_iter() + .map(|field| { + let ident = field.ident.clone().ok_or_else(|| { + syn::Error::new(field.span(), "named command fields must have names") + })?; + Ok(CommandField { + ident: Some(ident), + permission: permission_attr(&field.attrs)?, + field, + }) + }) + .collect::>>()?, + )), + Fields::Unit => Ok(Self::Unit), + } + } + + pub fn fields(&self) -> &[CommandField] { + match self { + CommandFields::Unnamed(fields) | CommandFields::Named(fields) => fields, + CommandFields::Unit => &[], + } + } + + pub fn single_unnamed_type(&self) -> SynResult<&Type> { + let CommandFields::Unnamed(fields) = self else { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "subcommand variants must contain exactly one unnamed field", + )); + }; + + if fields.len() != 1 { + return Err(syn::Error::new( + fields + .first() + .map(|field| field.field.span()) + .unwrap_or_else(proc_macro2::Span::call_site), + "subcommand variants must contain exactly one unnamed field", + )); + } + + Ok(&fields[0].field.ty) + } +} + +pub struct FieldParse { + pub raw_bindings: Vec, + pub tuple_values: Vec, + pub named_values: Vec, + pub segments: Vec, + pub greedy_assertions: Vec, + pub suggestion_registrations: Vec, +} + +impl FieldParse { + pub fn new(fields: &[CommandField]) -> SynResult { + let last_field_idx = fields.len().saturating_sub(1); + let mut raw_bindings = Vec::new(); + let mut tuple_values = Vec::new(); + let mut named_values = Vec::new(); + let mut segments = Vec::new(); + let mut greedy_assertions = Vec::new(); + let mut suggestion_registrations = Vec::new(); + + for (idx, command_field) in fields.iter().enumerate() { + let arg_name = arg_name(command_field)?; + let field = &command_field.field; + let ty = &field.ty; + let raw_ident = format_ident!("__raw_{idx}"); + let permission_check = permission_check(command_field.permission.as_ref()); + + raw_bindings.push(quote! { + #permission_check + let #raw_ident = <#ty as ::temper_command_infra::CommandArg>::recognize(__reader)?; + }); + + tuple_values.push(quote! { + <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + + if let Some(field_ident) = &command_field.ident { + named_values.push(quote! { + #field_ident: <#ty as ::temper_command_infra::CommandArg>::parse(#raw_ident)? + }); + } + + let mut segment = quote! { + { + let mut __spec = <#ty as ::temper_command_infra::CommandArg>::argument_spec(); + match <#ty as ::temper_command_infra::CommandArg>::SUGGESTIONS { + ::temper_command_infra::SuggestionProviderKind::None => {} + ::temper_command_infra::SuggestionProviderKind::Protocol(__provider) => { + __spec = __spec.with_protocol_suggestions(__provider); + } + ::temper_command_infra::SuggestionProviderKind::Server => { + __spec = __spec + .with_protocol_suggestions("minecraft:ask_server") + .with_server_suggestions( + ::temper_command_infra::command_arg_suggestion_id::<#ty>(), + ); + } + } + + ::temper_command_infra::CommandPathSegment::argument( + #arg_name, + __spec, + ) + } + }; + + if let Some(permission) = &command_field.permission { + segment = quote! { + #segment.with_permission(#permission) + }; + } + + segments.push(segment); + suggestion_registrations.push(quote! { + ::temper_command_infra::register_command_arg_suggestions::<#ty>(); + }); + + if idx != last_field_idx { + greedy_assertions.push(quote_spanned! { ty.span() => + const _: () = assert!( + !matches!( + <#ty as ::temper_command_infra::CommandArg>::KIND, + ::temper_command_infra::ArgKind::GreedyTail + ), + "greedy-tail command args must be the final field in a command variant" + ); + }); + } + } + + Ok(Self { + raw_bindings, + tuple_values, + named_values, + segments, + greedy_assertions, + suggestion_registrations, + }) + } +} + +pub struct CommandField { + ident: Option, + permission: Option, + field: Field, +} + +impl CommandField { + fn unnamed(field: Field) -> SynResult { + let permission = permission_attr(&field.attrs)?; + + Ok(Self { + ident: None, + permission, + field, + }) + } +} + +fn arg_name(command_field: &CommandField) -> SynResult { + for attr in &command_field.field.attrs { + if attr.path().is_ident("arg") { + return attr.parse_args::(); + } + } + + if let Some(ident) = &command_field.ident { + return Ok(LitStr::new(&ident.to_string(), ident.span())); + } + + Err(syn::Error::new( + command_field.field.span(), + "command tuple fields must have #[arg(\"name\")]", + )) +} + +fn permission_check(permission: Option<&Path>) -> proc_macro2::TokenStream { + match permission { + Some(permission) => quote! { + if !__can_use(#permission) { + return Err(::temper_command_infra::ParseError::new( + __reader.cursor(), + "permission", + "you do not have permission to use this command argument", + )); + } + }, + None => quote! {}, + } +} diff --git a/src/base/macros/src/command_derive/mod.rs b/src/base/macros/src/command_derive/mod.rs new file mode 100644 index 000000000..1d1e83008 --- /dev/null +++ b/src/base/macros/src/command_derive/mod.rs @@ -0,0 +1,15 @@ +use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput}; + +mod attrs; +mod expand; +mod fields; + +pub fn derive(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + match expand::expand(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} diff --git a/src/base/macros/src/commands/mod.rs b/src/base/macros/src/commands/mod.rs deleted file mode 100644 index 5d899688b..000000000 --- a/src/base/macros/src/commands/mod.rs +++ /dev/null @@ -1,259 +0,0 @@ -use proc_macro::TokenStream; -use quote::{format_ident, quote, ToTokens}; -use syn::{ - parse::{Parse, ParseStream}, - parse_macro_input, FnArg, ItemFn, LitStr, Pat, Result as SynResult, Type, -}; - -#[derive(Clone, Debug)] -struct Arg { - name: String, - required: bool, - ty: String, -} - -struct CommandAttr { - name: String, -} - -impl Parse for CommandAttr { - fn parse(input: ParseStream) -> SynResult { - let name = input.parse::()?.value(); - Ok(CommandAttr { name }) - } -} - -pub fn command(attr: TokenStream, item: TokenStream) -> TokenStream { - let mut input_fn = parse_macro_input!(item as ItemFn); - let fn_name = input_fn.clone().sig.ident; - - let command_attr = parse_macro_input!(attr as CommandAttr); - - let mut args = Vec::new(); - let mut bevy_args = Vec::<(Box, Type)>::new(); - let mut has_sender_arg = false; - let mut sender_arg_before_cmd_args = false; - let mut sender_arg_index: Option = None; - let mut first_arg_index: Option = None; - - for (idx, fn_arg) in input_fn.sig.inputs.iter_mut().enumerate() { - let FnArg::Typed(fn_arg) = fn_arg else { - return TokenStream::from(quote! { - compiler_error!("command handler cannot have receiver"); - }); - }; - - if fn_arg.attrs.is_empty() { - bevy_args.push((fn_arg.pat.clone(), *fn_arg.ty.clone())); - } - - let mut is_arg_attr = false; - let mut is_sender_attr = false; - let mut sender_arg_mismatched_ty = false; - - fn_arg.attrs.retain(|arg| { - let is_arg = arg.path().is_ident("arg"); - let is_sender = arg.path().is_ident("sender"); - - if is_arg { - is_arg_attr = true; - - let required = match *fn_arg.ty { - Type::Path(ref path) => { - !path.path.segments.iter().any(|seg| seg.ident == "Option") - } - _ => true, - }; - - args.push(Arg { - name: fn_arg.pat.to_token_stream().to_string(), - required, - ty: fn_arg.ty.to_token_stream().to_string(), - }); - } - - if is_sender { - is_sender_attr = true; - - match *fn_arg.ty { - Type::Path(ref path) => { - if path.path.segments.iter().next_back().unwrap().ident != "Sender" { - sender_arg_mismatched_ty = true; - return false; - } - } - _ => { - sender_arg_mismatched_ty = true; - return false; - } - } - - has_sender_arg = true; - } - - !is_arg && !is_sender - }); - - if sender_arg_mismatched_ty { - return TokenStream::from(quote! { - compile_error!("invalid type for sender arg - should be Sender"); - }); - } - - if is_sender_attr && sender_arg_index.is_none() { - sender_arg_index = Some(idx); - } - - if is_arg_attr && first_arg_index.is_none() { - first_arg_index = Some(idx); - } - } - - if let (Some(sender_idx), Some(arg_idx)) = (sender_arg_index, first_arg_index) { - if sender_idx < arg_idx { - sender_arg_before_cmd_args = true; - } - } - - if bevy_args.iter().any(|(_, ty)| { - if let Type::Reference(refr) = ty { - if let Type::Path(path) = *refr.clone().elem { - println!("path reference: {:?}", path.path.segments.clone()); - let is_bevy = path.path.segments.iter().any(|seg| { - println!("{}", seg.ident); - &seg.ident.to_string() == "bevy_ecs" - }); - println!("is bevy? {is_bevy}"); - println!("{}", path.path.is_ident("World")); - - path.path.is_ident("World") && (is_bevy || path.path.segments.len() == 1) - } else { - false - } - } else { - false - } - }) { - return TokenStream::from(quote! { - compile_error!("commands cannot accept bevy world arguments due to bevy restrictions") - }); - } - - let system_name = format_ident!("__{}_handler", fn_name); - let system_args = bevy_args - .clone() - .iter() - .map(|(pat, ty)| { - quote! { #pat: #ty, } - }) - .collect::>(); - let system_arg_pats = bevy_args - .clone() - .iter() - .map(|(pat, _)| match pat.as_ref() { - syn::Pat::Ident(pat_ident) => { - let ident = &pat_ident.ident; - quote!(#ident) - } - _ => quote!(#pat), - }) - .collect::>(); - - let arg_extractors = args - .clone() - .iter() - .map(|arg| { - let name = &arg.name; - let ty = syn::parse_str::(&arg.ty).expect("invalid arg type"); - - quote! { - match __ctx.arg::<#ty>(#name) { - Ok(a) => a, - Err(err) => { - sender.send_message(temper_text::TextComponentBuilder::new(format!("failed parsing {}: ", #name)) - .extra(*err) - .color(temper_text::NamedColor::Red) - .build(), false); - return; - } - }, - } - }) - .collect::>(); - - let sender_param = if has_sender_arg { - quote! { sender.clone(), } - } else { - quote! {} - }; - - let ctor_fn_name = format_ident!("__{}_register", fn_name); - let command_name = command_attr.name; - let tracing_name = format!("command:{}", command_name); - let system_tracing_name = format!("{} handler", tracing_name); - - let command_args = args - .iter() - .map(|arg| { - let name = arg.name.clone(); - let required = arg.required; - let ty = format_ident!("{}", &arg.ty); - - quote! { - temper_commands::arg::CommandArgumentNode { - name: #name.to_string(), - required: #required, - primitive: <#ty as temper_commands::arg::CommandArgument>::primitive(), - suggester: <#ty as temper_commands::arg::CommandArgument>::suggest, - }, - } - }) - .collect::>(); - - let call = if has_sender_arg && sender_arg_before_cmd_args { - quote! { - #fn_name(#sender_param #(#arg_extractors)* #(#system_arg_pats)*); - } - } else if has_sender_arg { - quote! { - #fn_name(#(#arg_extractors)* #sender_param #(#system_arg_pats)*); - } - } else { - quote! { - #fn_name(#(#arg_extractors)* #(#system_arg_pats)*); - } - }; - - TokenStream::from(quote! { - #[allow(non_snake_case)] - #[allow(dead_code)] - #[doc(hidden)] - #[tracing::instrument(name = #tracing_name, skip_all)] - #input_fn - - #[allow(unused_mut)] // required to use mutable queries without clippy screaming bloody murder - #[allow(non_snake_case)] - #[allow(unused_variables)] - #[doc(hidden)] - #[tracing::instrument(name = #system_tracing_name, skip_all)] - fn #system_name(mut messages: bevy_ecs::prelude::MessageMutator, #(#system_args)*) { - for temper_commands::messages::ResolvedCommandDispatched { command: __command, ctx: __ctx, sender } in messages.read() { - if __command.name == #command_name { - #call - return // this is due to ownership issues - } - } - } - - #[ctor::ctor(unsafe)] - #[doc(hidden)] - fn #ctor_fn_name() { - temper_commands::infrastructure::add_system(#system_name); - - temper_commands::infrastructure::register_command(std::sync::Arc::new(temper_commands::Command { - name: #command_name, - args: vec![#(#command_args)*], - })); - } - }) -} diff --git a/src/base/macros/src/lib.rs b/src/base/macros/src/lib.rs index 1fc13b791..da70538c0 100644 --- a/src/base/macros/src/lib.rs +++ b/src/base/macros/src/lib.rs @@ -4,7 +4,7 @@ use block::matches; use proc_macro::TokenStream; mod block; -mod commands; +mod command_derive; mod helpers; mod item; mod misc; @@ -59,31 +59,11 @@ pub fn lookup_packet(input: TokenStream) -> TokenStream { } // #=================== PACKETS ===================# -/// Creates a command. -/// -/// A command function can take a sender argument, multiple command arguments and bevy system arguments. -/// -/// The optional sender argument is marked with `#[sender]` attribute and command arguments are marked with -/// the `#[arg]` attribute. Any other argument is treated as a bevy system arg. -/// -/// Usage example: -/// -/// ```ignore -/// #[command("hello")] -/// fn command(#[sender] sender: Sender) { -/// sender.send_message(TextComponent::from("Hello, world!"), false); -/// } -/// ``` -#[proc_macro_attribute] -pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream { - commands::command(attr, input) +#[proc_macro_derive(Command, attributes(command, arg, literal, subcommand, permission))] +pub fn command_derive(input: TokenStream) -> TokenStream { + command_derive::derive(input) } -// #[proc_macro_attribute] -// pub fn arg(attr: TokenStream, input: TokenStream) -> TokenStream { -// commands::arg(attr, input) -// } - /// Get a registry entry from the registries.json file. /// returns protocol_id (as u64) of the specified entry. #[proc_macro] diff --git a/src/commands/Cargo.toml b/src/command-infra/Cargo.toml similarity index 58% rename from src/commands/Cargo.toml rename to src/command-infra/Cargo.toml index 2ca337e7b..60f5ac65a 100644 --- a/src/commands/Cargo.toml +++ b/src/command-infra/Cargo.toml @@ -1,27 +1,23 @@ [package] -name = "temper-commands" +name = "temper-command-infra" version = "0.1.0" edition = "2024" [dependencies] -thiserror = { workspace = true } -tracing = { workspace = true } -dashmap = { workspace = true } -temper-text = { workspace = true } -temper-core = { workspace = true } -enum-ordinalize = { workspace = true } -temper-macros = { workspace = true } bevy_ecs = { workspace = true } -temper-codec = { workspace = true } -regex = { workspace = true } +ctor = { workspace = true } +temper-core = { workspace = true } temper-components = { workspace = true } -temper-nbt = { workspace = true } +temper-permissions = { workspace = true } temper-state = { workspace = true } +temper-text = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } uuid = { workspace = true } rand = { workspace = true } -[dev-dependencies] # Needed for the ServerState mock... :concern: -temper-world = { workspace = true } +[dev-dependencies] +temper-macros = { workspace = true } [lints] workspace = true diff --git a/src/command-infra/src/args/entity.rs b/src/command-infra/src/args/entity.rs new file mode 100644 index 000000000..bdf270f47 --- /dev/null +++ b/src/command-infra/src/args/entity.rs @@ -0,0 +1,107 @@ +use rand::prelude::IteratorRandom; +use std::ops::Deref; + +use bevy_ecs::entity::Entity; +use temper_components::entity_identity::Identity; +use temper_components::player::player_marker::PlayerMarker; +use uuid::Uuid; + +use crate::{ArgumentSpec, CommandArg, CommandReader, ParseError, SuggestionProviderKind}; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct EntitySelector(String); + +macro_rules! entity_arg { + ($name:ident, single: $single:literal, players_only: $players_only:literal) => { + #[derive(Clone, Debug, Eq, PartialEq)] + pub struct $name(EntitySelector); + + impl $name { + pub fn resolve<'a>( + &self, + iter: impl Iterator)>, + ) -> Vec { + self.0.resolve(iter) + } + } + + impl Deref for $name { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl CommandArg for $name { + type Raw<'a> = &'a str; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + recognize_entity(reader) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(EntitySelector(raw.to_string()))) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::entity($single, $players_only) + } + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + } + }; +} + +entity_arg!(EntityArg, single: true, players_only: false); +entity_arg!(EntitiesArg, single: false, players_only: false); +entity_arg!(PlayerArg, single: true, players_only: true); +entity_arg!(PlayersArg, single: false, players_only: true); + +impl Deref for EntitySelector { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl EntitySelector { + fn resolve<'a>( + &self, + iter: impl Iterator)>, + ) -> Vec { + match &**self { + "@e" => iter.map(|(entity, _, _)| entity).collect(), + "@a" => iter + .filter_map(|(entity, _, marker)| marker.map(|_| entity)) + .collect(), + "@r" => iter + .filter_map(|(entity, _, marker)| marker.map(|_| entity)) + .sample(&mut rand::rng(), 1), + raw => { + let uuid = Uuid::parse_str(raw).ok(); + + iter.filter_map(|(entity, identity, _)| { + if identity.name.as_deref() == Some(raw) || Some(identity.uuid) == uuid { + Some(entity) + } else { + None + } + }) + .collect() + } + } + } +} + +fn recognize_entity<'a>(reader: &mut CommandReader<'a>) -> Result<&'a str, ParseError> { + let cursor = reader.cursor(); + let span = reader.read_word_span()?; + + if span.is_empty() { + Err(ParseError::expected(cursor, "entity")) + } else { + Ok(span) + } +} diff --git a/src/command-infra/src/args/integer.rs b/src/command-infra/src/args/integer.rs new file mode 100644 index 000000000..5cce5bef0 --- /dev/null +++ b/src/command-infra/src/args/integer.rs @@ -0,0 +1,63 @@ +use std::ops::Deref; + +use crate::{ + ArgumentSpec, CommandArg, CommandReader, IntegerProperties, ParseError, ParserKind, + ParserProperties, SuggestionProviderKind, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IntegerArg(i32); + +impl Deref for IntegerArg { + type Target = i32; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for IntegerArg { + type Raw<'a> = i32; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let cursor = reader.cursor(); + let raw = reader.read_word_span()?; + let value = raw + .parse::() + .map_err(|_| ParseError::new(cursor, "integer", "invalid integer"))?; + + if value < MIN { + return Err(ParseError::new( + cursor, + "integer", + format!("integer too small: {value}, expected at least {MIN}"), + )); + } + + if value > MAX { + return Err(ParseError::new( + cursor, + "integer", + format!("integer too large: {value}, expected at most {MAX}"), + )); + } + + Ok(value) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw)) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::Integer, + ParserProperties::Integer(IntegerProperties { + min: Some(MIN), + max: Some(MAX), + }), + ) + } +} diff --git a/src/command-infra/src/args/mod.rs b/src/command-infra/src/args/mod.rs new file mode 100644 index 000000000..4c0b16afb --- /dev/null +++ b/src/command-infra/src/args/mod.rs @@ -0,0 +1,70 @@ +//! # Command args +//! +//! For simpler commands you can just reuse existing argument types defined in this module, but +//! there's a solid chance you'll need to make your own. +//! +//! Similarly to commands, args are defined with a struct and a trait (but no macro this time). +//! The general gist is that you define a struct that stores the value your command handler wants, +//! a method to recognize the next bit of command input, a method to turn that recognized input into +//! the final value, and some graph/suggestion metadata. +//! +//! ```rust +//! # use bevy_ecs::world::World; +//! # use temper_command_infra::{ +//! # ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind, ParserProperties, +//! # StringMode, SuggestionInput, SuggestionProviderKind, +//! # }; +//! +//! struct ExampleArg(String); +//! +//! impl CommandArg for ExampleArg { +//! // The borrowed or cheap intermediate type returned by recognize(). +//! type Raw<'a> = &'a str; +//! +//! // Whether the command graph should use no suggestions, a vanilla protocol provider, +//! // or server/ECS-backed suggestions. +//! const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; +//! +//! // Consume exactly the input that belongs to this argument and return the raw value. +//! // Keep this cheap, because the parser may try several command variants and rewind. +//! // It is fine to do cheap syntax checks here when they decide whether this arg shape fits. +//! fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { +//! reader.read_word_span() +//! } +//! +//! // Build the final arg value from the raw value returned by recognize(). +//! // This is the right place for conversions, allocation, and semantic validation. +//! fn parse(raw: Self::Raw<'_>) -> Result { +//! Ok(Self(raw.to_string())) +//! } +//! +//! // Describe the parser/properties that should be sent to the client command graph. +//! // Check out the ArgumentSpec docs to see what you should use. +//! fn argument_spec() -> ArgumentSpec { +//! ArgumentSpec::with_properties( +//! ParserKind::String, +//! ParserProperties::String(StringMode::Word), +//! ) +//! } +//! +//! // When SuggestionProviderKind::Server is used, this method is called to generate suggestions +//! // for the client. It has ECS access so can be useful for stuff like searching for entities. +//! // This method is optional so you don't need to implement it if you don't need it. +//! fn suggest(_input: SuggestionInput<'_>, _world: &mut World) -> Vec { +//! vec!["example".to_string()] +//! } +//! } +//! ``` +//! +//! See [SuggestionProviderKind](crate::SuggestionProviderKind) for which suggestion mode to use. +//! Most args should use [SuggestionProviderKind::None](crate::SuggestionProviderKind::None). + +mod entity; +mod integer; +mod position; +mod string; + +pub use entity::{EntitiesArg, EntityArg, PlayerArg, PlayersArg}; +pub use integer::IntegerArg; +pub use position::PositionArg; +pub use string::{GreedyStringArg, QuotableStringArg, SingleWordArg}; diff --git a/src/command-infra/src/args/position.rs b/src/command-infra/src/args/position.rs new file mode 100644 index 000000000..80f4ec061 --- /dev/null +++ b/src/command-infra/src/args/position.rs @@ -0,0 +1,82 @@ +use temper_components::player::position::Position; + +use crate::{ + ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind, SuggestionProviderKind, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PositionArg { + pub x: String, + pub y: String, + pub z: String, +} + +impl PositionArg { + pub fn resolve(&self, base: &Position) -> Position { + Position::new( + resolve_coord(&self.x, base.x), + resolve_coord(&self.y, base.y), + resolve_coord(&self.z, base.z), + ) + } +} + +impl CommandArg for PositionArg { + type Raw<'a> = (&'a str, &'a str, &'a str); + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + let x = read_coord(reader, "x coordinate")?; + let y = read_coord(reader, "y coordinate")?; + let z = read_coord(reader, "z coordinate")?; + + Ok((x, y, z)) + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self { + x: raw.0.to_string(), + y: raw.1.to_string(), + z: raw.2.to_string(), + }) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::new(ParserKind::Position) + } +} + +fn read_coord<'a>( + reader: &mut CommandReader<'a>, + expected: &'static str, +) -> Result<&'a str, ParseError> { + let cursor = reader.cursor(); + let span = reader.read_word_span()?; + + if let Some(relative) = span.strip_prefix('~') + && (relative.is_empty() || relative.parse::().is_ok()) + { + Ok(span) + } else if span.parse::().is_ok() { + Ok(span) + } else { + Err(ParseError::new( + cursor, + expected, + format!("invalid {expected}: {span}"), + )) + } +} + +fn resolve_coord(coord: &str, base: f64) -> f64 { + if let Some(relative) = coord.strip_prefix('~') { + if relative.is_empty() { + base + } else { + base + relative.parse::().unwrap_or(0.0) + } + } else { + coord.parse::().unwrap_or(base) + } +} diff --git a/src/command-infra/src/args/string.rs b/src/command-infra/src/args/string.rs new file mode 100644 index 000000000..0594a9665 --- /dev/null +++ b/src/command-infra/src/args/string.rs @@ -0,0 +1,132 @@ +use std::ops::Deref; + +use crate::{ + ArgKind, ArgumentSpec, CommandArg, CommandReader, ParseError, ParserKind, ParserProperties, + StringMode, SuggestionProviderKind, reader::StringSpan, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SingleWordArg(String); + +impl Deref for SingleWordArg { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for SingleWordArg { + type Raw<'a> = &'a str; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Word), + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuotableStringArg(String); + +impl Deref for QuotableStringArg { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for QuotableStringArg { + type Raw<'a> = StringSpan<'a>; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_string_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + let parsed = match raw { + StringSpan::Bare(span) => span.to_string(), + StringSpan::Quoted(span) => { + let mut result = String::new(); + let mut escaped = false; + for c in span.chars() { + if escaped { + if matches!(c, '"' | '\\') { + result.push(c); + } else { + result.push('\\'); + result.push(c); + } + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + } else { + result.push(c); + } + } + if escaped { + result.push('\\'); + } + result + } + }; + + Ok(Self(parsed)) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Quotable), + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GreedyStringArg(String); + +impl Deref for GreedyStringArg { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CommandArg for GreedyStringArg { + type Raw<'a> = &'a str; + + const KIND: ArgKind = ArgKind::GreedyTail; + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_remaining_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::with_properties( + ParserKind::String, + ParserProperties::String(StringMode::Greedy), + ) + } +} diff --git a/src/command-infra/src/ecs.rs b/src/command-infra/src/ecs.rs new file mode 100644 index 000000000..792acb439 --- /dev/null +++ b/src/command-infra/src/ecs.rs @@ -0,0 +1,332 @@ +use std::sync::{LazyLock, RwLock}; +use std::{cell::RefCell, sync::Arc}; + +use bevy_ecs::prelude::{ + Component, Entity, IntoScheduleConfigs, Message, MessageReader, Query, Resource, Schedule, +}; +use bevy_ecs::schedule::ScheduleConfigs; +use bevy_ecs::system::{ParamSet, ScheduleSystem, SystemParam, SystemParamItem}; +use temper_core::mq; +use temper_permissions::Permissions; +use temper_permissions::player::PlayerPermission; +use temper_text::{NamedColor, TextComponent, TextComponentBuilder}; +use tracing::info; + +use crate::{CommandGraph, CommandPath, CommandSpec, ParseError}; + +static STATIC_COMMANDS: LazyLock>> = + LazyLock::new(|| RwLock::new(Vec::new())); + +thread_local! { + static SYSTEMS_TO_BE_REGISTERED: RefCell>> = RefCell::new(Vec::new()); +} + +#[derive(Clone, Debug)] +pub struct RegisteredCommand { + pub name: &'static str, + pub aliases: &'static [&'static str], + pub permission: Option, + pub paths: Vec, +} + +impl RegisteredCommand { + pub fn of() -> Self { + let primary_paths = C::paths() + .into_iter() + .map(|path| path.with_permission(C::permission())); + let alias_paths = C::aliases().iter().flat_map(|alias| { + let alias = *alias; + C::paths() + .into_iter() + .map(move |path| path.with_root(alias).with_permission(C::permission())) + }); + + Self { + name: C::NAME, + aliases: C::aliases(), + permission: C::permission(), + paths: primary_paths.chain(alias_paths).collect(), + } + } + + pub fn matches_root(&self, root: &str) -> bool { + self.name == root || self.aliases.contains(&root) + } +} + +pub fn register_static_command(command: RegisteredCommand) { + if let Ok(mut commands) = STATIC_COMMANDS.write() { + commands.push(command); + } +} + +pub fn static_commands() -> Vec { + STATIC_COMMANDS + .read() + .map(|commands| commands.clone()) + .unwrap_or_default() +} + +pub fn add_system(system: impl IntoScheduleConfigs) { + SYSTEMS_TO_BE_REGISTERED.with(|systems| { + systems.borrow_mut().push(system.into_configs()); + }); +} + +pub fn register_command_systems(schedule: &mut Schedule) { + SYSTEMS_TO_BE_REGISTERED.with(|systems| { + let mut systems = systems.borrow_mut(); + while let Some(system) = systems.pop() { + schedule.add_systems(system); + } + }); +} + +pub trait CommandHandler: CommandSpec + Sized + Send + Sync + 'static { + type SystemParam<'w, 's>: SystemParam; + + /// Execute a parsed command. + /// + /// Returning an error sends that error to the command source automatically. + fn handle( + self, + source: CommandSource, + params: &mut SystemParamItem<'_, '_, Self::SystemParam<'_, '_>>, + ) -> CommandResult; + + fn handle_parse_error( + source: CommandSource, + error: ParseError, + _params: &mut SystemParamItem<'_, '_, Self::SystemParam<'_, '_>>, + ) { + send_parse_error(source, &error); + } +} + +/// Result returned by command handlers. +/// +/// `Ok(())` means the command handled its own success output. `Err(error)` means the dispatcher +/// should send the error message to the command source. +pub type CommandResult = Result<(), CommandError>; + +/// User-facing command failure. +#[derive(Clone, Debug)] +pub struct CommandError { + message: Box, +} + +impl CommandError { + /// Create a command error from a chat component or plain string. + pub fn new(message: impl Into) -> Self { + Self { + message: Box::new(message.into()), + } + } + + /// Borrow the message that will be sent to the command source. + pub fn message(&self) -> &TextComponent { + &self.message + } + + /// Consume the error and return the message to send. + pub fn into_message(self) -> TextComponent { + *self.message + } +} + +impl From for CommandError { + fn from(message: TextComponent) -> Self { + Self::new(message) + } +} + +impl From for CommandError { + fn from(message: String) -> Self { + Self::new(message) + } +} + +impl From<&str> for CommandError { + fn from(message: &str) -> Self { + Self::new(message) + } +} + +pub fn send_parse_error(source: CommandSource, error: &ParseError) { + let message = TextComponentBuilder::new(format!("failed parsing command: {}", error.message)) + .color(NamedColor::Red) + .build(); + + source.send_message(message); +} + +pub fn send_command_error(source: CommandSource, error: CommandError) { + source.send_message(error.into_message()); +} + +pub fn dispatch_command( + mut commands: MessageReader, + mut params: ParamSet<(Query<&PlayerPermission>, C::SystemParam<'_, '_>)>, +) { + for event in commands.read() { + let input = &event.input; + let Some(root) = input.split_whitespace().next() else { + continue; + }; + + if root != C::NAME && !C::aliases().contains(&root) { + continue; + } + + let parse_result = { + let permissions = params.p0(); + let can_use = |permission| { + let source = event.source; + match source { + CommandSource::Server => true, + CommandSource::Player(entity) => permissions + .get(entity) + .is_ok_and(|player_permissions| player_permissions.can(permission)), + } + }; + if let Some(permission) = C::permission() + && !can_use(permission) + { + let source = event.source; + let message = + TextComponentBuilder::new("You don't have permission to use this command.") + .color(NamedColor::Red) + .build(); + source.send_message(message); + continue; + } + + let input1 = &event.input; + let input = input1.strip_prefix(root).unwrap_or(input1).trim_start(); + let mut reader = crate::CommandReader::new(input); + C::parse_reader_with_permissions(&mut reader, &can_use) + }; + + match parse_result { + Ok(command) => { + let mut command_params = params.p1(); + if let Err(error) = command.handle(event.source, &mut command_params) { + send_command_error(event.source, error); + } + } + Err(error) => { + let mut command_params = params.p1(); + C::handle_parse_error(event.source, error, &mut command_params); + } + } + } +} + +#[derive(Default, Resource)] +pub struct CommandRegistry { + commands: Vec, +} + +impl CommandRegistry { + pub fn from_static_commands() -> Self { + Self { + commands: static_commands(), + } + } + + pub fn register(&mut self) { + self.commands.push(RegisteredCommand::of::()); + } + + pub fn register_command(&mut self, command: RegisteredCommand) { + self.commands.push(command); + } + + pub fn commands(&self) -> &[RegisteredCommand] { + &self.commands + } + + pub fn owns_input(&self, input: &str) -> bool { + input.split_whitespace().next().is_some_and(|input_root| { + self.commands + .iter() + .any(|command| command.matches_root(input_root)) + }) + } + + pub fn paths_for_player(&self, _player: Entity) -> Vec { + self.paths_for_permissions(|_| true) + } + + pub fn paths_for_player_permissions( + &self, + _player: Entity, + permissions: Option<&PlayerPermission>, + ) -> Vec { + self.paths_for_permissions(|permission| permissions.is_some_and(|p| p.can(permission))) + } + + pub fn paths_for_permissions(&self, can_use: impl Fn(Permissions) -> bool) -> Vec { + self.commands + .iter() + .flat_map(|command| { + command + .paths + .iter() + .filter(|path| path.is_allowed_by(&can_use)) + .cloned() + }) + .collect() + } + + pub fn build_graph_for_player(&self, player: Entity) -> CommandGraph { + CommandGraph::from_paths(&self.paths_for_player(player)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandSource { + Player(Entity), + Server, +} + +impl CommandSource { + pub fn send_message(self, message: TextComponent) { + match self { + CommandSource::Player(entity) => mq::queue(message, false, entity), + CommandSource::Server => { + info!("{}", message.to_plain_text()) + } + } + } +} + +#[derive(Message, Clone, Debug)] +pub struct CommandDispatched { + pub input: Arc, + pub source: CommandSource, +} + +#[derive(Component, Clone, Debug, Eq, PartialEq)] +pub struct PlayerCommandGraph { + pub graph: CommandGraph, + pub version: u64, +} + +impl PlayerCommandGraph { + pub fn new(graph: CommandGraph) -> Self { + Self { graph, version: 0 } + } + + pub fn next(graph: CommandGraph, previous: Option<&PlayerCommandGraph>) -> Self { + Self { + graph, + version: previous.map(|graph| graph.version + 1).unwrap_or(0), + } + } +} + +#[derive(Message, Clone, Copy, Debug, Eq, PartialEq)] +pub struct RebuildCommandGraph { + pub player: Entity, +} diff --git a/src/command-infra/src/error.rs b/src/command-infra/src/error.rs new file mode 100644 index 000000000..99a908477 --- /dev/null +++ b/src/command-infra/src/error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +#[error("{message} at cursor {cursor}")] +pub struct ParseError { + pub cursor: usize, + pub expected: &'static str, + pub message: String, +} + +impl ParseError { + pub fn new(cursor: usize, expected: &'static str, message: impl Into) -> Self { + Self { + cursor, + expected, + message: message.into(), + } + } + + pub fn expected(cursor: usize, expected: &'static str) -> Self { + Self::new(cursor, expected, format!("expected {expected}")) + } + + pub fn farthest(self, other: Self) -> Self { + if other.cursor > self.cursor { + other + } else { + self + } + } +} diff --git a/src/command-infra/src/graph.rs b/src/command-infra/src/graph.rs new file mode 100644 index 000000000..df73017a5 --- /dev/null +++ b/src/command-infra/src/graph.rs @@ -0,0 +1,151 @@ +use crate::{ArgumentSpec, CommandPath, CommandPathSegment, EntityProperties, ParserProperties}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandNodeKind { + Root, + Literal, + Argument, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandNode { + pub kind: CommandNodeKind, + pub name: Option, + pub argument: Option, + pub children: Vec, + pub executable: bool, +} + +impl CommandNode { + fn root() -> Self { + Self { + kind: CommandNodeKind::Root, + name: None, + argument: None, + children: Vec::new(), + executable: false, + } + } + + fn literal(name: &str) -> Self { + Self { + kind: CommandNodeKind::Literal, + name: Some(name.to_string()), + argument: None, + children: Vec::new(), + executable: false, + } + } + + fn argument(name: &str, spec: ArgumentSpec) -> Self { + Self { + kind: CommandNodeKind::Argument, + name: Some(name.to_string()), + argument: Some(spec), + children: Vec::new(), + executable: false, + } + } + + fn matches_segment(&self, segment: &CommandPathSegment) -> bool { + match segment { + CommandPathSegment::Literal { name, .. } => { + self.kind == CommandNodeKind::Literal && self.name.as_deref() == Some(*name) + } + CommandPathSegment::Argument { spec, .. } => { + self.kind == CommandNodeKind::Argument && self.argument == Some(*spec) + } + } + } + + fn child_priority(&self) -> u8 { + match self.kind { + CommandNodeKind::Literal => 0, + CommandNodeKind::Argument + if matches!( + self.argument.and_then(|arg| arg.properties), + Some(ParserProperties::Entity(EntityProperties { + single: false, + .. + })) + ) => + { + 1 + } + CommandNodeKind::Argument + if self + .argument + .and_then(|arg| arg.protocol_suggestions) + .is_some() => + { + 2 + } + CommandNodeKind::Argument => 3, + CommandNodeKind::Root => 4, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandGraph { + pub nodes: Vec, + pub root_idx: usize, +} + +impl Default for CommandGraph { + fn default() -> Self { + Self { + nodes: vec![CommandNode::root()], + root_idx: 0, + } + } +} + +impl CommandGraph { + pub fn from_paths(paths: &[CommandPath]) -> Self { + let mut graph = Self::default(); + for path in paths { + graph.push_path(path); + } + graph + } + + pub fn push_path(&mut self, path: &CommandPath) { + let mut current = self.root_idx; + + for literal in path.root.split_whitespace() { + current = self.push_or_reuse(current, CommandPathSegment::literal(literal)); + } + + for segment in &path.segments { + current = self.push_or_reuse(current, segment.clone()); + } + + self.nodes[current].executable = true; + } + + fn push_or_reuse(&mut self, parent: usize, segment: CommandPathSegment) -> usize { + for child_idx in self.nodes[parent].children.clone() { + if self.nodes[child_idx].matches_segment(&segment) { + return child_idx; + } + } + + let node = match segment { + CommandPathSegment::Literal { name, .. } => CommandNode::literal(name), + CommandPathSegment::Argument { name, spec, .. } => CommandNode::argument(name, spec), + }; + + let priority = node.child_priority(); + let insert_at = self.nodes[parent] + .children + .iter() + .position(|child_idx| self.nodes[*child_idx].child_priority() > priority) + .unwrap_or(self.nodes[parent].children.len()); + + let idx = self.nodes.len(); + self.nodes.push(node); + self.nodes[parent].children.insert(insert_at, idx); + idx + } +} diff --git a/src/command-infra/src/lib.rs b/src/command-infra/src/lib.rs new file mode 100644 index 000000000..4ffe60bd1 --- /dev/null +++ b/src/command-infra/src/lib.rs @@ -0,0 +1,194 @@ +//! # Commands in Temper +//! +//! Defining commands is pretty simple: define an enum or struct, derive `Command`, and give it a +//! root command name. +//! +//! ```rust +//! # use temper_command_infra::args::{EntityArg, PositionArg}; +//! # use temper_command_infra::{CommandHandler, CommandResult, CommandSource}; +//! # use temper_macros::Command; +//! +//! #[derive(Command)] +//! #[command("example")] +//! enum ExampleCommand { +//! WithEntity { entity: EntityArg }, +//! WithoutEntity, +//! #[subcommand("sub")] +//! Subcommand(ExampleSingleSubcommand), +//! } +//! +//! #[derive(Command)] +//! #[command(subcommand)] +//! struct ExampleSingleSubcommand { +//! location: PositionArg, +//! } +//! # impl CommandHandler for ExampleCommand { +//! # type SystemParam<'w, 's> = (); +//! # +//! # fn handle(self, _source: CommandSource, _params: &mut Self::SystemParam<'_, '_>) -> CommandResult { +//! # Ok(()) +//! # } +//! # } +//! ``` +//! +//! Then implement [CommandHandler] for the command: +//! +//! ```rust +//! # use bevy_ecs::prelude::{Query, Res}; +//! # use temper_command_infra::{CommandHandler, CommandResult, CommandSource, ParseError}; +//! # use temper_command_infra::args::{EntityArg, PositionArg}; +//! # use temper_components::player::position::Position; +//! # use temper_components::player::rotation::Rotation; +//! # use temper_macros::Command; +//! # use temper_state::GlobalStateResource; +//! # #[derive(Command)] +//! # #[command(subcommand)] +//! # struct ExampleSingleSubcommand { location: PositionArg } +//! # #[derive(Command)] +//! # #[command("example")] +//! # enum ExampleCommand { +//! # WithEntity { entity: EntityArg }, +//! # WithoutEntity, +//! # #[subcommand("sub")] +//! # Subcommand(ExampleSingleSubcommand), +//! # } +//! +//! impl CommandHandler for ExampleCommand { +//! // These can be whatever ECS params you need +//! type SystemParam<'w, 's> = ( +//! Res<'w, GlobalStateResource>, +//! Query<'w, 's, (&'static Position, &'static Rotation)>, +//! ); +//! +//! fn handle(self, source: CommandSource, params: &mut Self::SystemParam<'_, '_>) -> CommandResult { +//! let (_state, _positions) = params; +//! +//! match self { +//! ExampleCommand::WithEntity { entity } => { +//! // do something with the entity name/uuid/selector the player gave in the first argument +//! }, +//! ExampleCommand::WithoutEntity => { +//! // do something without an entity +//! }, +//! ExampleCommand::Subcommand(subcommand) => { +//! // do something with the subcommand +//! let location = subcommand.location; +//! // do something with the position the player gave in the first argument of the subcommand +//! } +//! } +//! +//! Ok(()) +//! } +//! +//! // Optional error handler method +//! fn handle_parse_error( +//! source: CommandSource, +//! error: ParseError, +//! _params: &mut Self::SystemParam<'_, '_>, +//! ) {} +//! } +//! ``` +//! The entire system revolves around the [CommandHandler] trait, which is implemented on the +//! command enum/struct. Under the hood the derive macro will generate all the code needed to have +//! the command wired into the ECS, provide suggestions and parse the arguments. All you need to do +//! is define what arguments a command needs and what it does with those args. If a handler returns +//! an error, the dispatcher sends that error to the command source automatically. +//! +//! There are several attributes available including literal args: +//! +//! ```rust +//! # use temper_command_infra::{CommandHandler, CommandResult, CommandSource}; +//! # use temper_macros::Command; +//! #[derive(Command)] +//! #[command("example")] +//! enum ExampleCommand { +//! #[literal("literal")] +//! LiteralCommand, +//! } +//! # impl CommandHandler for ExampleCommand { +//! # type SystemParam<'w, 's> = (); +//! # +//! # fn handle(self, _source: CommandSource, _params: &mut Self::SystemParam<'_, '_>) -> CommandResult { +//! # Ok(()) +//! # } +//! # } +//! ``` +//! that skip the hassle of parsing and verifying an argument when you only allow a specific set of +//! options, aliases on both commands and literals: +//! +//! ```rust +//! # use temper_command_infra::{CommandHandler, CommandResult, CommandSource}; +//! # use temper_macros::Command; +//! #[derive(Command)] +//! #[command(name = "example", aliases = ["ex", "exmpl"])] +//! enum ExampleCommand { +//! #[literal("literal", aliases = ["lit", "l"])] +//! LiteralCommand, +//! } +//! # impl CommandHandler for ExampleCommand { +//! # type SystemParam<'w, 's> = (); +//! # +//! # fn handle(self, _source: CommandSource, _params: &mut Self::SystemParam<'_, '_>) -> CommandResult { +//! # Ok(()) +//! # } +//! # } +//! ``` +//! and permissions: +//! +//! ```rust +//! # use temper_command_infra::{CommandHandler, CommandResult, CommandSource}; +//! # use temper_command_infra::Permissions; +//! # use temper_macros::Command; +//! #[derive(Command)] +//! #[command(name = "example", permission = Permissions::Op)] +//! enum ExampleCommand { +//! #[literal("literal")] +//! #[permission(Permissions::Kill)] +//! LiteralCommand, +//! } +//! # impl CommandHandler for ExampleCommand { +//! # type SystemParam<'w, 's> = (); +//! # +//! # fn handle(self, _source: CommandSource, _params: &mut Self::SystemParam<'_, '_>) -> CommandResult { +//! # Ok(()) +//! # } +//! # } +//! ``` +//! Permissions are used when building the command graph and when parsing/dispatching commands, so +//! players should not be able to use a command path they do not have permission for. Handlers should +//! still validate any game-specific assumptions, such as whether a resolved entity actually exists. +//! +//! This is the general gist of using commands with existing argument types, check out [args] +//! for how to make your own argument types. + +pub mod args; +pub mod ecs; +pub mod error; +pub mod graph; +pub mod metadata; +pub mod reader; +pub mod suggestions; + +pub use ctor; +pub use ecs::{ + CommandDispatched, CommandError, CommandHandler, CommandRegistry, CommandResult, CommandSource, + PlayerCommandGraph, RebuildCommandGraph, RegisteredCommand, +}; +pub use ecs::{ + add_system, dispatch_command, register_command_systems, register_static_command, + send_command_error, send_parse_error, static_commands, +}; +pub use error::ParseError; +pub use graph::{CommandGraph, CommandNode, CommandNodeKind}; +pub use metadata::SubcommandSpec; +pub use metadata::{ + ArgKind, ArgumentSpec, CommandArg, CommandPath, CommandPathSegment, CommandSpec, + EntityProperties, IntegerProperties, ParserKind, ParserProperties, ResourceProperties, + StringMode, SuggestionProviderKind, +}; +pub use reader::{Checkpoint, CommandReader}; +pub use suggestions::{ + SuggestionInput, command_arg_suggestion_id, register_command_arg_suggestions, + suggest_command_arg, +}; +pub use temper_permissions::Permissions; diff --git a/src/command-infra/src/metadata.rs b/src/command-infra/src/metadata.rs new file mode 100644 index 000000000..d181a7857 --- /dev/null +++ b/src/command-infra/src/metadata.rs @@ -0,0 +1,383 @@ +use bevy_ecs::world::World; + +use crate::SuggestionInput; +use crate::{CommandReader, ParseError}; +use temper_permissions::Permissions; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ArgKind { + /// A normal argument that consumes one logical argument slot. + Normal, + + /// An argument that consumes the rest of the input. + /// + /// Greedy tail args must be the final field in a command variant. + GreedyTail, +} + +/// Extra mode information for string parsers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StringMode { + /// A single unquoted word. + Word, + + /// A single word or quoted string. + Quotable, + + /// The rest of the command input. + Greedy, +} + +/// Min/max bounds for integer arguments. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IntegerProperties { + /// Minimum accepted value, if one should be sent to the client. + pub min: Option, + + /// Maximum accepted value, if one should be sent to the client. + pub max: Option, +} + +/// Selector flags for entity arguments. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EntityProperties { + /// Whether the argument should accept only a single selected entity. + pub single: bool, + + /// Whether the argument should accept only players. + pub players_only: bool, +} + +/// Registry metadata for resource arguments. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResourceProperties { + /// The vanilla registry id, such as `minecraft:entity_type`. + pub registry: &'static str, +} + +/// Extra parser metadata sent to the client command graph. +/// +/// Use this when the parser needs flags beyond the basic [ParserKind]. For example, string +/// arguments need to say whether they are word, quotable, or greedy strings, and resource arguments +/// need to say which registry they refer to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ParserProperties { + /// Additional mode data for [ParserKind::String]. + String(StringMode), + + /// Bounds for [ParserKind::Integer]. + Integer(IntegerProperties), + + /// Selector flags for [ParserKind::Entity]. + Entity(EntityProperties), + + /// Registry id for [ParserKind::Resource]. + Resource(ResourceProperties), +} + +/// The client-side parser type for an argument. +/// +/// This does not parse commands on the server. It describes the argument to the client command graph +/// so the client can highlight input correctly and provide built-in completions where vanilla +/// supports them. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ParserKind { + /// A single word string. + Word, + + /// A 32-bit integer. + Integer, + + /// A string parser configured by [StringMode]. + String, + + /// A three-coordinate position parser. + Position, + + /// An entity selector/name/uuid parser. + Entity, + + /// A resource from a specific vanilla registry. + Resource, +} + +/// Controls how an argument participates in tab completion. +/// +/// This is only for Brigadier suggestion providers. Parser metadata still belongs in +/// [ArgumentSpec]. For example, registry/resource completion should normally use +/// [ArgumentSpec::resource] instead of a suggestion provider. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SuggestionProviderKind { + /// Do not attach a suggestion provider to this argument. + /// + /// Use this for most arguments, including arguments whose completion is already described by + /// their parser metadata. + None, + + /// Attach a raw vanilla suggestion provider id to the protocol command graph. + /// + /// The client handles these providers itself. The server will not receive suggestion requests + /// for them. This is for vanilla providers such as `minecraft:available_sounds`, not for + /// server/ECS-backed suggestions. + Protocol(&'static str), + + /// Ask this server for dynamic suggestions. + /// + /// The derive macro sends `minecraft:ask_server` in the command graph and registers this arg + /// type's [CommandArg::suggest] method (that has ECS access) as the handler. Use this if you + /// want to send specific suggestions from the server, such as a list of online players or + /// entities. + Server, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// Metadata generated for one command argument in the client command graph. +/// +/// Each [CommandArg](CommandArg) returns an `ArgumentSpec` from +/// [CommandArg::argument_spec](CommandArg::argument_spec). The derive macro then uses that +/// spec when building command paths and protocol graph nodes. +/// +/// In most custom args you will use one of: +/// +/// ```rust +/// # use temper_command_infra::{ArgumentSpec, ParserKind}; +/// ArgumentSpec::new(ParserKind::Word); +/// ArgumentSpec::entity(false, false); +/// ArgumentSpec::resource("minecraft:entity_type"); +/// ``` +/// +/// Suggestions are usually filled in by the derive macro from +/// [CommandArg::SUGGESTIONS](CommandArg::SUGGESTIONS), so custom args should not normally +/// set `protocol_suggestions` or `server_suggestions` by hand. +pub struct ArgumentSpec { + /// The basic parser type the client should use for this argument. + pub parser: ParserKind, + + /// Optional parser-specific flags or metadata. + pub properties: Option, + + /// Optional vanilla suggestion provider id sent to the client. + /// + /// This is what the protocol graph uses. For server-backed suggestions this will usually be + /// `minecraft:ask_server`. + pub protocol_suggestions: Option<&'static str>, + + /// Optional internal provider id used to route server-backed suggestions. + /// + /// This is not sent to the client. + pub server_suggestions: Option<&'static str>, +} + +impl ArgumentSpec { + /// Create an argument spec with only a basic parser type. + pub const fn new(parser: ParserKind) -> Self { + Self { + parser, + properties: None, + protocol_suggestions: None, + server_suggestions: None, + } + } + + /// Create an argument spec with parser-specific properties. + pub const fn with_properties(parser: ParserKind, properties: ParserProperties) -> ArgumentSpec { + Self { + parser, + properties: Some(properties), + protocol_suggestions: None, + server_suggestions: None, + } + } + + /// Set the vanilla protocol suggestion provider. + /// + /// Prefer using [SuggestionProviderKind::Protocol] on the arg type unless manually constructing + /// command paths. + pub const fn with_suggestions(mut self, suggestions: &'static str) -> ArgumentSpec { + self.protocol_suggestions = Some(suggestions); + self + } + + /// Set the vanilla protocol suggestion provider. + /// + /// Prefer using [SuggestionProviderKind::Protocol] on the arg type unless manually constructing + /// command paths. + pub const fn with_protocol_suggestions(mut self, suggestions: &'static str) -> ArgumentSpec { + self.protocol_suggestions = Some(suggestions); + self + } + + /// Set the internal server suggestion provider id. + /// + /// Prefer using [SuggestionProviderKind::Server] on the arg type unless manually constructing + /// command paths. + pub const fn with_server_suggestions(mut self, suggestions: &'static str) -> ArgumentSpec { + self.server_suggestions = Some(suggestions); + self + } + + /// Create an entity argument spec with selector flags. + pub const fn entity(single: bool, players_only: bool) -> ArgumentSpec { + Self::with_properties( + ParserKind::Entity, + ParserProperties::Entity(EntityProperties { + single, + players_only, + }), + ) + } + + /// Create a resource argument spec for a vanilla registry. + /// + /// This is the normal way to get registry-backed completion, such as entity type suggestions + /// for `minecraft:entity_type`. + pub const fn resource(registry: &'static str) -> ArgumentSpec { + Self::with_properties( + ParserKind::Resource, + ParserProperties::Resource(ResourceProperties { registry }), + ) + } +} + +pub trait CommandArg: Sized { + type Raw<'a>; + + const KIND: ArgKind = ArgKind::Normal; + const SUGGESTIONS: SuggestionProviderKind; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError>; + + fn parse(raw: Self::Raw<'_>) -> Result; + + fn argument_spec() -> ArgumentSpec; + + fn suggest(_input: SuggestionInput<'_>, _world: &mut World) -> Vec { + Vec::new() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommandPathSegment { + Literal { + name: &'static str, + permission: Option, + }, + Argument { + name: &'static str, + spec: ArgumentSpec, + permission: Option, + }, +} + +impl CommandPathSegment { + pub const fn literal(name: &'static str) -> Self { + Self::Literal { + name, + permission: None, + } + } + + pub const fn argument(name: &'static str, spec: ArgumentSpec) -> Self { + Self::Argument { + name, + spec, + permission: None, + } + } + + pub const fn with_permission(mut self, permission: Permissions) -> Self { + match &mut self { + Self::Literal { + permission: segment_permission, + .. + } + | Self::Argument { + permission: segment_permission, + .. + } => *segment_permission = Some(permission), + } + self + } + + pub const fn permission(&self) -> Option { + match self { + Self::Literal { permission, .. } | Self::Argument { permission, .. } => *permission, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandPath { + pub root: &'static str, + pub permission: Option, + pub segments: Vec, +} + +impl CommandPath { + pub fn new(root: &'static str, segments: Vec) -> Self { + Self { + root, + permission: None, + segments, + } + } + + pub fn with_permission(mut self, permission: Option) -> Self { + self.permission = permission; + self + } + + pub fn with_root(mut self, root: &'static str) -> Self { + self.root = root; + self + } + + pub fn is_allowed_by(&self, can_use: impl Fn(Permissions) -> bool) -> bool { + self.permission.is_none_or(&can_use) + && self + .segments + .iter() + .all(|segment| segment.permission().is_none_or(&can_use)) + } +} + +pub trait CommandSpec: Sized { + const NAME: &'static str; + + fn aliases() -> &'static [&'static str] { + &[] + } + + fn permission() -> Option { + None + } + + fn parse_reader(reader: &mut CommandReader<'_>) -> Result; + + fn parse_reader_with_permissions( + reader: &mut CommandReader<'_>, + _can_use: &dyn Fn(Permissions) -> bool, + ) -> Result { + Self::parse_reader(reader) + } + + fn paths() -> Vec; + + fn parse(input: &str) -> Result { + let mut reader = CommandReader::new(input); + Self::parse_reader(&mut reader) + } +} + +pub trait SubcommandSpec: Sized { + fn parse_reader(reader: &mut CommandReader<'_>) -> Result; + + fn parse_reader_with_permissions( + reader: &mut CommandReader<'_>, + _can_use: &dyn Fn(Permissions) -> bool, + ) -> Result { + Self::parse_reader(reader) + } + + fn segments() -> Vec>; +} diff --git a/src/command-infra/src/reader.rs b/src/command-infra/src/reader.rs new file mode 100644 index 000000000..f87ada665 --- /dev/null +++ b/src/command-infra/src/reader.rs @@ -0,0 +1,148 @@ +use crate::ParseError; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Checkpoint { + cursor: usize, +} + +#[derive(Clone, Copy, Debug)] +pub enum StringSpan<'a> { + Bare(&'a str), + Quoted(&'a str), +} + +pub struct CommandReader<'a> { + input: &'a str, + cursor: usize, +} + +impl<'a> CommandReader<'a> { + pub fn new(input: &'a str) -> Self { + Self { input, cursor: 0 } + } + + pub fn input(&self) -> &'a str { + self.input + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn remaining(&self) -> &'a str { + &self.input[self.cursor..] + } + + pub fn checkpoint(&self) -> Checkpoint { + Checkpoint { + cursor: self.cursor, + } + } + + pub fn rewind(&mut self, checkpoint: Checkpoint) { + self.cursor = checkpoint.cursor; + } + + pub fn has_remaining(&self) -> bool { + self.cursor < self.input.len() + } + + pub fn peek(&self) -> Option { + self.remaining().chars().next() + } + + pub fn read_char(&mut self) -> Option { + let c = self.peek()?; + self.cursor += c.len_utf8(); + Some(c) + } + + pub fn skip_whitespace(&mut self) { + while self.peek().is_some_and(char::is_whitespace) { + self.read_char(); + } + } + + pub fn read_word_span(&mut self) -> Result<&'a str, ParseError> { + self.skip_whitespace(); + let start = self.cursor; + + while self.peek().is_some_and(|c| !c.is_whitespace()) { + self.read_char(); + } + + if start == self.cursor { + return Err(ParseError::expected(start, "word")); + } + + Ok(&self.input[start..self.cursor]) + } + + pub fn read_quoted_string_span(&mut self) -> Result<&'a str, ParseError> { + self.skip_whitespace(); + let quote_cursor = self.cursor; + + if self.read_char() != Some('"') { + return Err(ParseError::expected(quote_cursor, "quoted string")); + } + + let start = self.cursor; + let mut escaped = false; + + while let Some(c) = self.read_char() { + if escaped { + escaped = false; + continue; + } + + match c { + '\\' => escaped = true, + '"' => { + let end = self.cursor - c.len_utf8(); + return Ok(&self.input[start..end]); + } + _ => {} + } + } + + Err(ParseError::new( + quote_cursor, + "closing quote", + "unterminated quoted string", + )) + } + + pub fn read_string_span(&mut self) -> Result, ParseError> { + self.skip_whitespace(); + if self.peek() == Some('"') { + self.read_quoted_string_span().map(StringSpan::Quoted) + } else { + self.read_word_span().map(StringSpan::Bare) + } + } + + pub fn read_remaining_span(&mut self) -> Result<&'a str, ParseError> { + self.skip_whitespace(); + let start = self.cursor; + self.cursor = self.input.len(); + + if start == self.cursor { + return Err(ParseError::expected(start, "remaining input")); + } + + Ok(&self.input[start..]) + } + + pub fn expect_end(&mut self) -> Result<(), ParseError> { + self.skip_whitespace(); + if self.has_remaining() { + Err(ParseError::new( + self.cursor, + "end of command", + "unexpected trailing input", + )) + } else { + Ok(()) + } + } +} diff --git a/src/command-infra/src/suggestions.rs b/src/command-infra/src/suggestions.rs new file mode 100644 index 000000000..aa61e32b7 --- /dev/null +++ b/src/command-infra/src/suggestions.rs @@ -0,0 +1,72 @@ +use std::any::type_name; +use std::sync::{LazyLock, RwLock}; + +use bevy_ecs::entity::Entity; +use bevy_ecs::world::World; + +use crate::{CommandArg, SuggestionProviderKind}; + +static ARG_SUGGESTIONS: LazyLock>> = + LazyLock::new(|| RwLock::new(Vec::new())); + +#[derive(Clone, Copy, Debug)] +pub struct SuggestionInput<'a> { + pub full_input: &'a str, + pub current_token: &'a str, + pub source: Entity, +} + +#[derive(Clone, Copy)] +struct RegisteredArgSuggestions { + id: &'static str, + suggest: for<'a> fn(&mut World, SuggestionInput<'a>) -> Vec, +} + +pub fn command_arg_suggestion_id() -> &'static str { + type_name::() +} + +pub fn register_command_arg_suggestions() { + if !matches!(A::SUGGESTIONS, SuggestionProviderKind::Server) { + return; + } + + let id = command_arg_suggestion_id::(); + + if let Ok(mut suggestions) = ARG_SUGGESTIONS.write() { + if suggestions.iter().any(|suggestion| suggestion.id == id) { + return; + } + + suggestions.push(RegisteredArgSuggestions { + id, + suggest: suggest_for_arg::, + }); + } +} + +pub fn suggest_command_arg( + id: &str, + world: &mut World, + input: SuggestionInput<'_>, +) -> Option> { + let suggest = ARG_SUGGESTIONS + .read() + .ok() + .and_then(|suggestions| { + suggestions + .iter() + .find(|suggestion| suggestion.id == id) + .copied() + }) + .map(|suggestion| suggestion.suggest)?; + + Some(suggest(world, input)) +} + +fn suggest_for_arg( + world: &mut World, + input: SuggestionInput<'_>, +) -> Vec { + A::suggest(input, world) +} diff --git a/src/command-infra/tests/derive_command.rs b/src/command-infra/tests/derive_command.rs new file mode 100644 index 000000000..be27889f1 --- /dev/null +++ b/src/command-infra/tests/derive_command.rs @@ -0,0 +1,726 @@ +use bevy_ecs::prelude::{Entity, Resource, World}; +use temper_command_infra::args::{ + EntitiesArg, EntityArg, GreedyStringArg, IntegerArg, PlayerArg, PlayersArg, PositionArg, + SingleWordArg, +}; +use temper_command_infra::{ + ArgumentSpec, CommandArg, CommandGraph, CommandHandler, CommandNodeKind, CommandReader, + CommandResult, CommandSource, CommandSpec, ParseError, ParserKind, ParserProperties, + Permissions, SuggestionInput, SuggestionProviderKind, +}; +use temper_macros::Command; + +#[derive(Debug, PartialEq, Command)] +#[command("tp")] +enum TpCommand { + ToPos { + location: PositionArg, + }, + ToEntity { + destination: EntityArg, + }, + EntityToPos { + target: EntitiesArg, + location: PositionArg, + }, + EntityToEntity { + target: EntitiesArg, + destination: EntityArg, + }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("entity-flags")] +enum EntityFlagsCommand { + Entity { target: EntityArg }, + Entities { targets: EntitiesArg }, + Player { player: PlayerArg }, + Players { players: PlayersArg }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("overlap")] +enum OverlapCommand { + Word { value: SingleWordArg }, + Entity { target: EntityArg }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("say")] +enum SayCommand { + Say { message: GreedyStringArg }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("number")] +enum NumberCommand { + Number { value: IntegerArg<0, 10> }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("rename")] +enum RenameCommand { + Rename { + #[arg("display_name")] + name: SingleWordArg, + }, +} + +#[derive(Debug, PartialEq, Command)] +#[command("stop")] +struct StopCommand; + +#[derive(Debug, PartialEq, Command)] +#[command("me")] +struct MeCommand { + action: GreedyStringArg, +} + +#[derive(Debug, PartialEq, Command)] +#[command(name = "time")] +enum TimeCommand { + #[subcommand("set", aliases = ["s"])] + #[permission(Permissions::Op)] + Set(SetTimeCommand), + #[literal("add")] + Add { + #[permission(Permissions::Kill)] + amount: IntegerArg<0, 24000>, + }, +} + +#[derive(Debug, PartialEq, Command)] +#[command(subcommand)] +enum SetTimeCommand { + #[literal("day", aliases = ["d"])] + #[permission(Permissions::DeOp)] + Day, + #[literal("night")] + Night, + Ticks { + value: IntegerArg<0, 24000>, + }, +} + +#[derive(Debug, PartialEq, Command)] +#[command( + name = "alias-demo", + aliases = ["ad", "demoalias"], + permission = Permissions::Teleport +)] +struct AliasCommand; + +#[derive(Debug, PartialEq, Command)] +#[command("suggested")] +struct SuggestedCommand { + value: SuggestedWordArg, +} + +#[derive(Debug, PartialEq, Command)] +#[command("client-suggested")] +struct ClientSuggestedCommand { + value: ClientSuggestedArg, +} + +#[derive(Debug, PartialEq, Command)] +#[command("summon")] +struct SummonCommand { + entity: ResourceArg, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SuggestedWordArg(String); + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ClientSuggestedArg(String); + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResourceArg(String); + +#[derive(Resource)] +struct SuggestedWords(Vec); + +impl CommandArg for SuggestedWordArg { + type Raw<'a> = &'a str; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::Server; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::new(ParserKind::Word) + } + + fn suggest(_input: SuggestionInput<'_>, world: &mut World) -> Vec { + world.resource::().0.clone() + } +} + +impl CommandArg for ClientSuggestedArg { + type Raw<'a> = &'a str; + + const SUGGESTIONS: SuggestionProviderKind = + SuggestionProviderKind::Protocol("minecraft:available_sounds"); + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::new(ParserKind::Word) + } +} + +impl CommandArg for ResourceArg { + type Raw<'a> = &'a str; + + const SUGGESTIONS: SuggestionProviderKind = SuggestionProviderKind::None; + + fn recognize<'a>(reader: &mut CommandReader<'a>) -> Result, ParseError> { + reader.read_word_span() + } + + fn parse(raw: Self::Raw<'_>) -> Result { + Ok(Self(raw.to_string())) + } + + fn argument_spec() -> ArgumentSpec { + ArgumentSpec::resource("minecraft:entity_type") + } +} + +macro_rules! impl_noop_handler { + ($($command:ty),* $(,)?) => { + $( + impl CommandHandler for $command { + type SystemParam<'w, 's> = (); + + fn handle<'w, 's>( + self, + _source: CommandSource, + _params: &mut Self::SystemParam<'w, 's>, + ) -> CommandResult { + Ok(()) + } + } + )* + }; +} + +impl_noop_handler!( + TpCommand, + OverlapCommand, + SayCommand, + NumberCommand, + RenameCommand, + StopCommand, + MeCommand, + TimeCommand, + AliasCommand, + EntityFlagsCommand, + SuggestedCommand, + ClientSuggestedCommand, + SummonCommand, +); + +#[test] +fn tp_to_position_parses() { + let command = TpCommand::parse("~ ~ ~").unwrap(); + + assert!(matches!(command, TpCommand::ToPos { .. })); +} + +#[test] +fn tp_to_entity_parses() { + let command = TpCommand::parse("Steve").unwrap(); + + match command { + TpCommand::ToEntity { destination } => assert_eq!(&*destination, "Steve"), + _ => panic!("expected entity destination"), + } +} + +#[test] +fn tp_entity_to_position_parses() { + let command = TpCommand::parse("Steve ~ ~ ~").unwrap(); + + match command { + TpCommand::EntityToPos { target, location } => { + assert_eq!(&*target, "Steve"); + assert_eq!(location.x, "~"); + assert_eq!(location.y, "~"); + assert_eq!(location.z, "~"); + } + _ => panic!("expected entity to position"), + } +} + +#[test] +fn tp_entity_to_entity_parses() { + let command = TpCommand::parse("Steve Alex").unwrap(); + + match command { + TpCommand::EntityToEntity { + target, + destination, + } => { + assert_eq!(&*target, "Steve"); + assert_eq!(&*destination, "Alex"); + } + _ => panic!("expected entity to entity"), + } +} + +#[test] +fn failed_variants_rewind_cleanly() { + let command = TpCommand::parse("Steve 1 2 3").unwrap(); + + assert!(matches!(command, TpCommand::EntityToPos { .. })); +} + +#[test] +fn variant_order_breaks_ties() { + let command = OverlapCommand::parse("Steve").unwrap(); + + assert!(matches!(command, OverlapCommand::Word { .. })); +} + +#[test] +fn greedy_tail_variant_parses() { + let command = SayCommand::parse("hello there").unwrap(); + + match command { + SayCommand::Say { message } => assert_eq!(&*message, "hello there"), + } +} + +#[test] +fn parse_errors_report_farthest_failure() { + let err = NumberCommand::parse("20").unwrap_err(); + + assert_eq!(err.cursor, 0); + assert!(err.message.contains("too large")); +} + +#[test] +fn graph_generation_merges_shared_prefixes() { + let graph = CommandGraph::from_paths(&TpCommand::paths()); + + let root = &graph.nodes[graph.root_idx]; + assert_eq!(root.children.len(), 1); + + let tp_idx = root.children[0]; + let tp = &graph.nodes[tp_idx]; + assert_eq!(tp.kind, CommandNodeKind::Literal); + assert_eq!(tp.name.as_deref(), Some("tp")); + + let child_names = tp + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(child_names, vec!["target", "location", "destination"]); + + let destination_idx = tp + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("destination")) + .unwrap(); + let destination = &graph.nodes[destination_idx]; + + assert!(destination.children.is_empty()); + assert!(destination.executable); + + let target_idx = tp + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("target")) + .unwrap(); + let target = &graph.nodes[target_idx]; + + assert!(!target.executable); + assert_eq!(target.children.len(), 2); + assert!( + target + .children + .iter() + .all(|idx| graph.nodes[*idx].executable) + ); +} + +#[test] +fn graph_uses_arg_attribute_names() { + let graph = CommandGraph::from_paths(&NumberCommand::paths()); + let number_idx = graph.nodes[graph.root_idx].children[0]; + let value_idx = graph.nodes[number_idx].children[0]; + + assert_eq!(graph.nodes[number_idx].name.as_deref(), Some("number")); + assert_eq!(graph.nodes[value_idx].name.as_deref(), Some("value")); + assert!(graph.nodes[value_idx].executable); +} + +#[test] +fn entity_arg_types_generate_entity_flags() { + let paths = EntityFlagsCommand::paths(); + + assert_entity_flags(&paths, "target", true, false); + assert_entity_flags(&paths, "targets", false, false); + assert_entity_flags(&paths, "player", true, true); + assert_entity_flags(&paths, "players", false, true); +} + +#[test] +fn arg_suggestions_are_registered_from_field_types() { + let paths = SuggestedCommand::paths(); + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + let provider = temper_command_infra::command_arg_suggestion_id::(); + + assert_eq!(spec.protocol_suggestions, Some("minecraft:ask_server")); + assert_eq!(spec.server_suggestions, Some(provider)); + + let mut world = World::new(); + world.insert_resource(SuggestedWords(vec!["alpha".into(), "beta".into()])); + temper_command_infra::register_command_arg_suggestions::(); + + let suggestions = temper_command_infra::suggest_command_arg( + provider, + &mut world, + SuggestionInput { + full_input: "/suggested a", + current_token: "a", + source: Entity::PLACEHOLDER, + }, + ) + .unwrap(); + + assert_eq!(suggestions, vec!["alpha", "beta"]); +} + +#[test] +fn position_args_use_client_parser_suggestions() { + let paths = TpCommand::paths(); + let spec = paths + .iter() + .flat_map(|path| path.segments.iter()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { + name: "location", + spec, + .. + } => Some(*spec), + _ => None, + }) + .unwrap(); + + assert_eq!(spec.parser, ParserKind::Position); + assert_eq!(spec.protocol_suggestions, None); + assert_eq!(spec.server_suggestions, None); +} + +#[test] +fn resource_args_generate_registry_parser_metadata() { + let paths = SummonCommand::paths(); + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + + assert_eq!(spec.parser, ParserKind::Resource); + assert_eq!( + spec.properties, + Some(ParserProperties::Resource( + temper_command_infra::ResourceProperties { + registry: "minecraft:entity_type", + } + )) + ); + assert_eq!(spec.protocol_suggestions, None); + assert_eq!(spec.server_suggestions, None); +} + +#[test] +fn client_suggestions_only_set_protocol_provider() { + let paths = ClientSuggestedCommand::paths(); + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { spec, .. } => Some(*spec), + _ => None, + }) + .unwrap(); + + assert_eq!( + spec.protocol_suggestions, + Some("minecraft:available_sounds") + ); + assert_eq!(spec.server_suggestions, None); +} + +#[test] +fn arg_attribute_overrides_named_field_name() { + let graph = CommandGraph::from_paths(&RenameCommand::paths()); + let rename_idx = graph.nodes[graph.root_idx].children[0]; + let name_idx = graph.nodes[rename_idx].children[0]; + + assert_eq!(graph.nodes[name_idx].name.as_deref(), Some("display_name")); +} + +#[test] +fn unit_struct_command_parses_without_args() { + let command = StopCommand::parse("").unwrap(); + + assert_eq!(command, StopCommand); + assert!(StopCommand::parse("extra").is_err()); +} + +#[test] +fn unit_struct_command_graph_executable_at_root_literal() { + let graph = CommandGraph::from_paths(&StopCommand::paths()); + let stop_idx = graph.nodes[graph.root_idx].children[0]; + let stop = &graph.nodes[stop_idx]; + + assert_eq!(stop.name.as_deref(), Some("stop")); + assert!(stop.children.is_empty()); + assert!(stop.executable); +} + +#[test] +fn named_struct_command_parses_single_arg_set() { + let command = MeCommand::parse("waves hello").unwrap(); + + assert_eq!(&*command.action, "waves hello"); +} + +#[test] +fn named_struct_command_uses_field_names_in_graph() { + let graph = CommandGraph::from_paths(&MeCommand::paths()); + let me_idx = graph.nodes[graph.root_idx].children[0]; + let action_idx = graph.nodes[me_idx].children[0]; + let action = &graph.nodes[action_idx]; + + assert_eq!(graph.nodes[me_idx].name.as_deref(), Some("me")); + assert_eq!(action.name.as_deref(), Some("action")); + assert!(action.executable); +} + +#[test] +fn nested_subcommand_literal_parses() { + let command = TimeCommand::parse("set day").unwrap(); + + assert!(matches!(command, TimeCommand::Set(SetTimeCommand::Day))); +} + +#[test] +fn nested_subcommand_alias_parses() { + let command = TimeCommand::parse("s night").unwrap(); + + assert!(matches!(command, TimeCommand::Set(SetTimeCommand::Night))); +} + +#[test] +fn nested_literal_alias_parses() { + let command = TimeCommand::parse("set d").unwrap(); + + assert!(matches!(command, TimeCommand::Set(SetTimeCommand::Day))); +} + +#[test] +fn nested_subcommand_arg_parses() { + let command = TimeCommand::parse("set 1200").unwrap(); + + assert!(matches!( + command, + TimeCommand::Set(SetTimeCommand::Ticks { .. }) + )); +} + +#[test] +fn literal_variant_with_args_parses() { + let command = TimeCommand::parse("add 20").unwrap(); + + assert!(matches!(command, TimeCommand::Add { .. })); +} + +#[test] +fn nested_subcommands_generate_literal_graph_paths() { + let graph = CommandGraph::from_paths(&TimeCommand::paths()); + let time_idx = graph.nodes[graph.root_idx].children[0]; + let time = &graph.nodes[time_idx]; + + let time_children = time + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(time_children, vec!["set", "s", "add"]); + + let set_idx = time + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("set")) + .unwrap(); + let set = &graph.nodes[set_idx]; + let set_children = set + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(set_children, vec!["day", "d", "night", "value"]); + assert!(set.children.iter().all(|idx| graph.nodes[*idx].executable)); + + let s_idx = time + .children + .iter() + .copied() + .find(|idx| graph.nodes[*idx].name.as_deref() == Some("s")) + .unwrap(); + let s = &graph.nodes[s_idx]; + let s_children = s + .children + .iter() + .map(|idx| graph.nodes[*idx].name.as_deref().unwrap()) + .collect::>(); + + assert_eq!(s_children, vec!["day", "d", "night", "value"]); + assert!(s.children.iter().all(|idx| graph.nodes[*idx].executable)); +} + +#[test] +fn command_aliases_generate_extra_roots() { + let roots = AliasCommand::paths() + .iter() + .map(|path| path.root) + .collect::>(); + + assert_eq!(AliasCommand::aliases(), &["ad", "demoalias"]); + assert_eq!(AliasCommand::permission(), Some(Permissions::Teleport)); + assert_eq!(roots, vec!["alias-demo"]); + + let registry = temper_command_infra::RegisteredCommand::of::(); + let registry_roots = registry + .paths + .iter() + .map(|path| path.root) + .collect::>(); + + assert_eq!(registry_roots, vec!["alias-demo", "ad", "demoalias"]); +} + +#[test] +fn command_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new(""); + let err = AliasCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::Teleport + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn subcommand_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new("set night"); + let err = TimeCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::Op + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn literal_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new("set day"); + let err = TimeCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::DeOp + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn arg_permission_blocks_permission_aware_parse() { + let mut reader = CommandReader::new("add 20"); + let err = TimeCommand::parse_reader_with_permissions(&mut reader, &|permission| { + permission != Permissions::Kill + }) + .unwrap_err(); + + assert_eq!(err.expected, "permission"); +} + +#[test] +fn permission_filter_removes_disallowed_paths() { + let registry = temper_command_infra::RegisteredCommand::of::(); + let allowed_paths = registry + .paths + .iter() + .filter(|path| path.is_allowed_by(|permission| permission != Permissions::Op)) + .map(|path| path.segments.first().unwrap()) + .collect::>(); + + assert_eq!(allowed_paths.len(), 1); + assert!(matches!( + allowed_paths[0], + temper_command_infra::CommandPathSegment::Literal { name: "add", .. } + )); +} + +fn assert_entity_flags( + paths: &[temper_command_infra::CommandPath], + arg_name: &str, + single: bool, + players_only: bool, +) { + let spec = paths + .iter() + .filter_map(|path| path.segments.first()) + .find_map(|segment| match segment { + temper_command_infra::CommandPathSegment::Argument { name, spec, .. } + if *name == arg_name => + { + Some(*spec) + } + _ => None, + }) + .unwrap(); + + assert_eq!( + spec.properties, + Some(ParserProperties::Entity( + temper_command_infra::EntityProperties { + single, + players_only + } + )) + ); +} diff --git a/src/command-infra/tests/ecs_registry.rs b/src/command-infra/tests/ecs_registry.rs new file mode 100644 index 000000000..bcbc938bc --- /dev/null +++ b/src/command-infra/tests/ecs_registry.rs @@ -0,0 +1,137 @@ +use bevy_ecs::entity::Entity; +use bevy_ecs::message::MessageRegistry; +use bevy_ecs::prelude::{IntoScheduleConfigs, MessageWriter, ResMut, Resource, Schedule, World}; +use std::sync::Arc; +use temper_command_infra::args::{EntityArg, PositionArg, SingleWordArg}; +use temper_command_infra::{ + CommandDispatched, CommandHandler, CommandRegistry, CommandResult, CommandSource, CommandSpec, + ParseError, dispatch_command, static_commands, +}; +use temper_macros::Command; + +#[derive(Debug, PartialEq, Command)] +#[command("tp")] +enum TpCommand { + TpToPos { location: PositionArg }, + TpToEntity { destination: EntityArg }, +} + +impl CommandHandler for TpCommand { + type SystemParam<'w, 's> = (); + + fn handle<'w, 's>( + self, + _source: CommandSource, + _params: &mut Self::SystemParam<'w, 's>, + ) -> CommandResult { + Ok(()) + } +} + +#[derive(Debug, PartialEq, Command)] +#[command("demo")] +enum DemoCommand { + Word { value: SingleWordArg }, +} + +#[derive(Default, Resource)] +struct HandledCommands { + handled: usize, + parse_errors: usize, + last_source: Option, + last_value: Option, +} + +impl CommandHandler for DemoCommand { + type SystemParam<'w, 's> = ResMut<'w, HandledCommands>; + + fn handle<'w, 's>( + self, + source: CommandSource, + params: &mut Self::SystemParam<'w, 's>, + ) -> CommandResult { + let DemoCommand::Word { value } = self; + + params.handled += 1; + params.last_source = Some(source); + params.last_value = Some(value.to_string()); + + Ok(()) + } + + fn handle_parse_error<'w, 's>( + _source: CommandSource, + _error: ParseError, + params: &mut Self::SystemParam<'w, 's>, + ) { + params.parse_errors += 1; + } +} + +fn emit_demo_commands(mut writer: MessageWriter) { + writer.write(CommandDispatched { + input: Arc::from("demo hello"), + source: CommandSource::Player(Entity::PLACEHOLDER), + }); + writer.write(CommandDispatched { + input: Arc::from("demo"), + source: CommandSource::Player(Entity::PLACEHOLDER), + }); + writer.write(CommandDispatched { + input: Arc::from("other hello"), + source: CommandSource::Player(Entity::PLACEHOLDER), + }); +} + +#[test] +fn registry_builds_graph_from_registered_commands() { + let mut registry = CommandRegistry::default(); + registry.register::(); + + let graph = registry.build_graph_for_player(Entity::PLACEHOLDER); + let root = &graph.nodes[graph.root_idx]; + let tp_idx = root.children[0]; + let tp = &graph.nodes[tp_idx]; + + assert_eq!(registry.commands().len(), 1); + assert_eq!(tp.name.as_deref(), Some("tp")); + assert_eq!(tp.children.len(), 2); + assert!( + tp.children + .iter() + .all(|child| graph.nodes[*child].executable) + ); +} + +#[test] +fn dispatch_command_calls_handler_trait_methods() { + let mut world = World::new(); + MessageRegistry::register_message::(&mut world); + world.init_resource::(); + + let mut schedule = Schedule::default(); + schedule.add_systems((emit_demo_commands, dispatch_command::).chain()); + schedule.run(&mut world); + + let handled = world.resource::(); + + assert_eq!(handled.handled, 1); + assert_eq!(handled.parse_errors, 1); + assert_eq!( + handled.last_source, + Some(CommandSource::Player(Entity::PLACEHOLDER)) + ); + assert_eq!(handled.last_value.as_deref(), Some("hello")); +} + +#[test] +fn derived_commands_register_static_metadata() { + let commands = static_commands(); + + assert!( + commands + .iter() + .any(|command| command.name == TpCommand::NAME), + "derived command was not registered in static command metadata" + ); +} diff --git a/src/command-infra/tests/reader_and_args.rs b/src/command-infra/tests/reader_and_args.rs new file mode 100644 index 000000000..20b2c3f5c --- /dev/null +++ b/src/command-infra/tests/reader_and_args.rs @@ -0,0 +1,80 @@ +use temper_command_infra::args::{ + GreedyStringArg, IntegerArg, PositionArg, QuotableStringArg, SingleWordArg, +}; +use temper_command_infra::{CommandArg, CommandReader}; + +#[test] +fn reader_supports_checkpoint_and_rewind() { + let mut reader = CommandReader::new("alpha beta"); + let checkpoint = reader.checkpoint(); + + assert_eq!(reader.read_word_span().unwrap(), "alpha"); + assert_eq!(reader.cursor(), 5); + + reader.rewind(checkpoint); + + assert_eq!(reader.cursor(), 0); + assert_eq!(reader.read_word_span().unwrap(), "alpha"); +} + +#[test] +fn reader_reads_quoted_strings() { + let mut reader = CommandReader::new("\"hello \\\"there\\\"\" tail"); + let span = reader.read_quoted_string_span().unwrap(); + + assert_eq!(span, "hello \\\"there\\\""); + assert_eq!(reader.read_word_span().unwrap(), "tail"); +} + +#[test] +fn reader_rejects_unterminated_quoted_strings() { + let mut reader = CommandReader::new("\"hello"); + let err = reader.read_quoted_string_span().unwrap_err(); + + assert_eq!(err.expected, "closing quote"); +} + +#[test] +fn word_arg_consumes_one_token() { + let mut reader = CommandReader::new("hello there"); + let raw = SingleWordArg::recognize(&mut reader).unwrap(); + let parsed = SingleWordArg::parse(raw).unwrap(); + + assert_eq!(&*parsed, "hello"); + assert_eq!(reader.read_word_span().unwrap(), "there"); +} + +#[test] +fn quoted_string_arg_can_appear_before_later_args() { + let mut reader = CommandReader::new("\"hello there\" 5"); + let raw_string = QuotableStringArg::recognize(&mut reader).unwrap(); + let raw_int = IntegerArg::<0, 10>::recognize(&mut reader).unwrap(); + + let string = QuotableStringArg::parse(raw_string).unwrap(); + let int = IntegerArg::<0, 10>::parse(raw_int).unwrap(); + + assert_eq!(&*string, "hello there"); + assert_eq!(*int, 5); +} + +#[test] +fn greedy_arg_consumes_remainder() { + let mut reader = CommandReader::new("hello there friend"); + let raw = GreedyStringArg::recognize(&mut reader).unwrap(); + let parsed = GreedyStringArg::parse(raw).unwrap(); + + assert_eq!(&*parsed, "hello there friend"); + assert!(reader.expect_end().is_ok()); +} + +#[test] +fn position_arg_consumes_exactly_three_tokens() { + let mut reader = CommandReader::new("~ ~1 3 Steve"); + let raw = PositionArg::recognize(&mut reader).unwrap(); + let position = PositionArg::parse(raw).unwrap(); + + assert_eq!(position.x, "~"); + assert_eq!(position.y, "~1"); + assert_eq!(position.z, "3"); + assert_eq!(reader.read_word_span().unwrap(), "Steve"); +} diff --git a/src/commands/src/arg/bossbar_set.rs b/src/commands/src/arg/bossbar_set.rs deleted file mode 100644 index bdafb0a39..000000000 --- a/src/commands/src/arg/bossbar_set.rs +++ /dev/null @@ -1,90 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; - -pub enum BossbarSetOptions { - Color(String), - Name(String), - Players(Vec), - Style((String, String)), - Value(f32), - Max(f32), -} - -impl CommandArgument for BossbarSetOptions { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - let value = match &*str.to_lowercase() { - "color" => { - let color = ctx.input.read_string(); - BossbarSetOptions::Color(color) - } - "name" => BossbarSetOptions::Name(ctx.input.read_string()), - "players" => { - let players = ctx.input.read_string(); - BossbarSetOptions::Players(vec![players]) - } - "style" => { - let style = ctx.input.read_string(); - let divider = ctx.input.read_string(); - BossbarSetOptions::Style((style, divider)) - } - "value" => { - let v = ctx - .input - .read_string() - .parse::() - .map_err(|_| parser_error("invalid float for value"))?; - BossbarSetOptions::Value(v) - } - "max" => { - let v = ctx - .input - .read_string() - .parse::() - .map_err(|_| parser_error("invalid float for max"))?; - BossbarSetOptions::Max(v) - } - _ => return Err(parser_error(&format!("invalid option: {str}"))), - }; - - Ok(value) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::greedy() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - let str = ctx.input.read_string(); - - let players: Vec = ctx - .state - .players - .player_list - .iter() - .map(|e| e.value().1.clone()) - .collect(); - let mut player_refs: Vec<&str> = players.iter().map(|s| s.as_str()).collect(); - player_refs.append(&mut vec!["@e", "@a", "@r"]); - - let suggestions: &[&str] = match str.to_lowercase().as_str() { - "color" => &["blue", "green", "pink", "purple", "red", "white", "yellow"], - "style" => &[ - "notched_6", - "notched_10", - "notched_12", - "notched_20", - "progress", - ], - "players" => &player_refs, - _ => &["color", "name", "players", "style", "value", "max"], - }; - - suggestions.iter().map(Suggestion::of).collect() - } -} diff --git a/src/commands/src/arg/duration.rs b/src/commands/src/arg/duration.rs deleted file mode 100644 index 7297838fd..000000000 --- a/src/commands/src/arg/duration.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{sync::LazyLock, time::Duration}; - -use regex::Regex; - -use crate::{CommandContext, Suggestion}; - -use super::{CommandArgument, ParserResult, primitive::PrimitiveArgument, utils::parser_error}; - -static PATTERN: LazyLock = - LazyLock::new(|| Regex::new("(([1-9][0-9]+|[1-9])[dhms])").unwrap()); - -impl CommandArgument for Duration { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let mut duration = Duration::ZERO; - - for (_, [line, value]) in PATTERN - .captures_iter(&ctx.input.read_string()) - .map(|c| c.extract()) - { - let Some(unit) = line.chars().nth(line.len() - 1) else { - return Err(parser_error("missing time unit")); - }; - let Ok(value) = value.parse::() else { - return Err(parser_error("invalid number")); - }; - - match unit { - 'd' => duration += Duration::from_hours(value * 24), - 'h' => duration += Duration::from_hours(value), - 'm' => duration += Duration::from_mins(value), - 's' => duration += Duration::from_secs(value), - _ => return Err(parser_error("invalid unit: expected d/h/m/s")), - } - } - - if duration.is_zero() { - return Err(parser_error("invalid input")); - } - - Ok(duration) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.skip_whitespace(u32::MAX, false); - if !ctx.input.has_remaining_input() { - return (0..9).map(|i| Suggestion::of(i.to_string())).collect(); - }; - - let input = ctx.input.read_string(); - - if input.chars().last().unwrap().is_ascii_alphabetic() { - return vec![]; - } - - ['d', 'h', 'm', 's'] - .into_iter() - .filter(|unit| !input.contains(*unit)) - .map(|unit| Suggestion::of(input.clone() + &unit.to_string())) - .collect() - } -} diff --git a/src/commands/src/arg/entities/any_entity.rs b/src/commands/src/arg/entities/any_entity.rs deleted file mode 100644 index e39f441c3..000000000 --- a/src/commands/src/arg/entities/any_entity.rs +++ /dev/null @@ -1,9 +0,0 @@ -use bevy_ecs::prelude::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_any_entity<'a>( - iter: impl Iterator)>, -) -> Vec { - iter.map(|(entity, _, _)| entity).collect() -} diff --git a/src/commands/src/arg/entities/any_player.rs b/src/commands/src/arg/entities/any_player.rs deleted file mode 100644 index c61117b12..000000000 --- a/src/commands/src/arg/entities/any_player.rs +++ /dev/null @@ -1,16 +0,0 @@ -use bevy_ecs::entity::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_any_player<'a>( - iter: impl Iterator)>, -) -> Vec { - let mut players = Vec::new(); - for (entity, _, player_marker) in iter { - if player_marker.is_none() { - continue; - } - players.push(entity); - } - players -} diff --git a/src/commands/src/arg/entities/entity_uuid.rs b/src/commands/src/arg/entities/entity_uuid.rs deleted file mode 100644 index c5a3e4591..000000000 --- a/src/commands/src/arg/entities/entity_uuid.rs +++ /dev/null @@ -1,16 +0,0 @@ -use bevy_ecs::entity::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; -use uuid::Uuid; - -pub(crate) fn resolve_uuid<'a>( - uuid: Uuid, - iter: impl Iterator)>, -) -> Option { - for (entity, entity_id_opt, _) in iter { - if entity_id_opt.uuid == uuid { - return Some(entity); - } - } - None -} diff --git a/src/commands/src/arg/entities/mod.rs b/src/commands/src/arg/entities/mod.rs deleted file mode 100644 index cbe25ed98..000000000 --- a/src/commands/src/arg/entities/mod.rs +++ /dev/null @@ -1,430 +0,0 @@ -mod any_entity; -mod any_player; -mod entity_uuid; -mod player; -mod random_player; - -use crate::arg::primitive::PrimitiveArgument; -use crate::arg::{CommandArgument, ParserResult}; -use crate::{CommandContext, Suggestion}; -use ::uuid::Uuid; -use bevy_ecs::prelude::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -/// Represents an entity argument in a command. -/// It can be a player name, UUID, or special selectors like @e, @p, @r, @a. -/// This won't get you an entity directly, use `resolve()` to get the entities. -/// -/// # Example -/// ```ignore -/// # use temper_commands::arg::entities::EntityArgument; -/// # use temper_core::identity::entity_identity::EntityIdentity; -/// # use temper_core::identity::player_identity::Identity; -/// # use bevy_ecs::prelude::World; -/// -/// fn my_command(query: Query<(Entity, Option<&EntityIdentity>, Option<&Identity>)>) { -/// let arg = EntityArgument::PlayerName("Steve".to_string()); -/// let result = arg.resolve(query.iter()); -/// assert_eq!(result, vec![entity]); -/// } -/// ``` -#[derive(Clone, Debug, PartialEq)] -pub enum EntityArgument { - PlayerName(String), - Uuid(Uuid), - AnyEntity, - AnyPlayer, - // NearestPlayer, - RandomPlayer, -} - -impl CommandArgument for EntityArgument { - fn parse(ctx: &mut CommandContext) -> ParserResult { - const PREFIXES: &[(&str, EntityArgument)] = &[ - ("@e", EntityArgument::AnyEntity), - // ("@p", EntityArgument::NearestPlayer), - ("@r", EntityArgument::RandomPlayer), - ("@a", EntityArgument::AnyPlayer), - ]; - let input = ctx.input.read_string(); - for (prefix, entity_type) in PREFIXES { - if input == *prefix { - return Ok(entity_type.clone()); - } - } - if input.len() == 36 && input.chars().all(|c| c.is_ascii_hexdigit() || c == '-') { - let uuid = Uuid::parse_str(&input) - .map_err(|_| crate::arg::utils::parser_error("invalid UUID format"))?; - Ok(EntityArgument::Uuid(uuid)) - } else { - Ok(EntityArgument::PlayerName(input)) - } - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - let mut suggestions = vec![ - Suggestion { - content: "@e".to_string(), - tooltip: Some(temper_nbt::NBT::new("Any Entity".into())), - }, - // Suggestion { - // content: "@p".to_string(), - // tooltip: Some(temper_nbt::NBT::new("Nearest Player".into())), - // }, - Suggestion { - content: "@r".to_string(), - tooltip: Some(temper_nbt::NBT::new("Random Player".into())), - }, - Suggestion { - content: "@a".to_string(), - tooltip: Some(temper_nbt::NBT::new("All Players".into())), - }, - ]; - let state = ctx.state.clone(); - for kv in &state.clone().players.player_list { - let (_, (uuid, name)) = kv.pair(); - suggestions.push(Suggestion { - content: name.clone(), - tooltip: Some(temper_nbt::NBT::new( - Uuid::from_u128(*uuid) - .as_hyphenated() - .to_string() - .to_uppercase() - .into(), - )), - }); - } - suggestions - } -} - -impl EntityArgument { - pub fn resolve<'a>( - &self, - iter: impl Iterator)>, - ) -> Vec { - match self { - EntityArgument::PlayerName(name) => player::resolve_player_name(name.clone(), iter) - .map(|e| vec![e]) - .unwrap_or_default(), - EntityArgument::Uuid(uuid) => entity_uuid::resolve_uuid(*uuid, iter) - .map(|e| vec![e]) - .unwrap_or_default(), - EntityArgument::AnyEntity => any_entity::resolve_any_entity(iter), - EntityArgument::AnyPlayer => any_player::resolve_any_player(iter), - // EntityArgument::NearestPlayer => { - // // TODO: Figure this out - // vec![] - // } - EntityArgument::RandomPlayer => random_player::resolve_random_player(iter) - .map(|e| vec![e]) - .unwrap_or_default(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Command, CommandInput, Sender}; - use bevy_ecs::prelude::World; - use std::sync::Arc; - use temper_components::entity_identity::Identity; - use temper_state::create_test_state; - - #[test] - fn test_parse_entity_argument() { - let mut ctx = CommandContext { - input: CommandInput { - input: "Steve".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::PlayerName("Steve".to_string())); - - let mut ctx = CommandContext { - input: CommandInput { - input: "@e".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::AnyEntity); - - // let mut ctx = CommandContext { - // input: CommandInput { - // input: "@p".to_string(), - // cursor: 0, - // }, - // command: Arc::new(Command { - // name: "", - // args: vec![], - // }), - // sender: Sender::Server, - // }; - // let arg = EntityArgument::parse(&mut ctx).unwrap(); - // assert_eq!(arg, EntityArgument::NearestPlayer); - - let mut ctx = CommandContext { - input: CommandInput { - input: "@r".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::RandomPlayer); - - let mut ctx = CommandContext { - input: CommandInput { - input: "@a".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!(arg, EntityArgument::AnyPlayer); - - let uuid_str = "123e4567-e89b-12d3-a456-426614174000"; - let mut ctx = CommandContext { - input: CommandInput { - input: uuid_str.to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let arg = EntityArgument::parse(&mut ctx).unwrap(); - assert_eq!( - arg, - EntityArgument::Uuid(Uuid::parse_str(uuid_str).unwrap()) - ); - } - - #[test] - fn test_suggest_entity_argument() { - let mut ctx = CommandContext { - input: CommandInput { - input: "a".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let suggestions = EntityArgument::suggest(&mut ctx); - assert!(!ctx.input.has_remaining_input()); - assert!(suggestions.iter().any(|s| s.content == "@a")); - assert!(suggestions.iter().any(|s| s.content == "@e")); - assert!(suggestions.iter().any(|s| s.content == "@r")); - } - - #[test] - fn test_resolves_name() { - let mut world = World::new(); - let entity = world - .spawn(( - Identity { - name: Some("Steve".to_string()), - uuid: Default::default(), - entity_id: 0, - }, - PlayerMarker, - )) - .id(); - - let arg = EntityArgument::PlayerName("Steve".to_string()); - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result, vec![entity]); - } - - #[test] - fn test_resolves_uuid() { - let mut world = World::new(); - let test_uuid = Uuid::new_v4(); - let entity = world - .spawn((Identity { - entity_id: 0, - uuid: test_uuid, - name: None, - },)) - .id(); - let arg = EntityArgument::Uuid(test_uuid); - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result, vec![entity]); - } - - #[test] - fn test_resolves_any_entity() { - let mut world = World::new(); - let entity1 = world - .spawn(Identity { - entity_id: 0, - uuid: Uuid::new_v4(), - name: None, - }) - .id(); - let entity2 = world - .spawn(Identity { - entity_id: 1, - uuid: Uuid::new_v4(), - name: None, - }) - .id(); - let arg = EntityArgument::AnyEntity; - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 2); - assert!(result.contains(&entity1)); - assert!(result.contains(&entity2)); - } - - #[test] - fn test_resolves_any_player() { - let mut world = World::new(); - let entity1 = world - .spawn(( - Identity { - name: Some("Steve".to_string()), - uuid: Uuid::new_v4(), - entity_id: 0, - }, - PlayerMarker, - )) - .id(); - let entity2 = world - .spawn(( - Identity { - name: Some("Alex".to_string()), - entity_id: 1, - uuid: Uuid::new_v4(), - }, - PlayerMarker, - )) - .id(); - let non_player_entity = world - .spawn((Identity { - entity_id: 2, - uuid: Uuid::new_v4(), - name: None, - },)) - .id(); - let arg = EntityArgument::AnyPlayer; - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 2); - assert!(result.contains(&entity1)); - assert!(result.contains(&entity2)); - assert!(!result.contains(&non_player_entity)); - } - - #[test] - fn resolves_random_player() { - let mut world = World::new(); - let entity1 = world - .spawn(( - Identity { - name: Some("Steve".to_string()), - uuid: Uuid::new_v4(), - entity_id: 0, - }, - PlayerMarker, - )) - .id(); - let entity2 = world - .spawn(( - Identity { - name: Some("Alex".to_string()), - entity_id: 1, - uuid: Uuid::new_v4(), - }, - PlayerMarker, - )) - .id(); - let non_player_entity = world - .spawn((Identity { - entity_id: 2, - uuid: Uuid::new_v4(), - name: None, - },)) - .id(); - let arg = EntityArgument::RandomPlayer; - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 1); - assert!(result.contains(&entity1) || result.contains(&entity2)); - assert!(!result.contains(&non_player_entity)); - - // Run the test 500 times to ensure randomness - // Technically this could actually result in 500 identical results, but the odds of that are astronomically low (about 1 in 3.27e150) - let mut results = vec![]; - for _ in 0..500 { - let result = arg.resolve( - world - .query::<(Entity, &Identity, Option<&PlayerMarker>)>() - .iter(&world), - ); - assert_eq!(result.len(), 1); - results.push(result[0]); - } - let unique_results: std::collections::HashSet<_> = results.into_iter().collect(); - assert_eq!( - unique_results.len(), - 2, - "Random player selection is not random enough" - ); - } -} diff --git a/src/commands/src/arg/entities/player.rs b/src/commands/src/arg/entities/player.rs deleted file mode 100644 index da3be1f8e..000000000 --- a/src/commands/src/arg/entities/player.rs +++ /dev/null @@ -1,21 +0,0 @@ -use bevy_ecs::prelude::Entity; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_player_name<'a>( - name: String, - iter: impl Iterator)>, -) -> Option { - for (entity, id, player_marker) in iter { - if player_marker.is_some() - && id - .name - .as_ref() - .map(|n| n.eq_ignore_ascii_case(&name)) - .unwrap_or(false) - { - return Some(entity); - } - } - None -} diff --git a/src/commands/src/arg/entities/random_player.rs b/src/commands/src/arg/entities/random_player.rs deleted file mode 100644 index 54fb4875c..000000000 --- a/src/commands/src/arg/entities/random_player.rs +++ /dev/null @@ -1,18 +0,0 @@ -use bevy_ecs::entity::Entity; -use rand::prelude::IteratorRandom; -use temper_components::entity_identity::Identity; -use temper_components::player::player_marker::PlayerMarker; - -pub(crate) fn resolve_random_player<'a>( - iter: impl Iterator)>, -) -> Option { - let mut rng = rand::rng(); - iter.filter_map(|(entity, _, player_id)| { - if player_id.is_some() { - Some(entity) - } else { - None - } - }) - .choose(&mut rng) -} diff --git a/src/commands/src/arg/gamemode.rs b/src/commands/src/arg/gamemode.rs deleted file mode 100644 index 2513a76f6..000000000 --- a/src/commands/src/arg/gamemode.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; -use temper_components::player::gamemode::GameMode; - -// Implement the trait directly for the enum -impl CommandArgument for GameMode { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - let value = match &*str.to_lowercase() { - "survival" | "0" => GameMode::Survival, - "creative" | "1" => GameMode::Creative, - "adventure" | "2" => GameMode::Adventure, - "spectator" | "3" => GameMode::Spectator, - _ => return Err(parser_error(&format!("invalid gamemode: {str}"))), - }; - - Ok(value) - } - - fn primitive() -> PrimitiveArgument { - // We're parsing a single word - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - - ["survival", "creative", "adventure", "spectator"] - .into_iter() - .map(Suggestion::of) - .collect() - } -} diff --git a/src/commands/src/arg/mod.rs b/src/commands/src/arg/mod.rs deleted file mode 100644 index 6b17028bf..000000000 --- a/src/commands/src/arg/mod.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Command arguments. - -use primitive::PrimitiveArgument; -use temper_text::TextComponent; - -use crate::{Suggestion, ctx::CommandContext}; - -pub mod bossbar_set; -pub mod duration; -pub mod entities; -pub mod gamemode; -pub mod position; -pub mod primitive; - -pub type ParserResult = Result>; - -/// [`CommandArgument`] represents an argument that can be added to a command. -/// This is generally done by having a wrapper type around the inner value, with -/// (const) type arguments for options and implementing [`CommandArgument`] for -/// the wrapper type. -pub trait CommandArgument -where - Self: Sized, -{ - /// Parses the argument from a command context and returns the value or a text error. - fn parse(ctx: &mut CommandContext) -> ParserResult; - - /// Returns the primitive argument type of this argument. This represents the - /// vanilla parser sent to the client for client-side validation. - fn primitive() -> PrimitiveArgument; - - /// Returns the completion strings sent to the client when typing something. - /// This is called every time the client enters or removes a character. - /// - /// **Make sure to consume the input in here, even if you are not suggesting anything**. - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - vec![] - } -} - -impl CommandArgument for Option -where - T: CommandArgument + Sized, -{ - fn parse(ctx: &mut CommandContext) -> ParserResult { - if ctx.input.has_remaining_input() { - T::parse(ctx).map(|t| Some(t)) - } else { - Ok(None) - } - } - - fn primitive() -> PrimitiveArgument { - T::primitive() - } -} - -/// An instance of a command argument node consisting of a name, optionality and the -/// underlying [`PrimitiveArgument`] of this argument. -// The reason we don't implement Eq is because of float argument flags, since -// floats are not Eq. -#[derive(Clone, Debug)] -pub struct CommandArgumentNode { - /// The name of the argument. - pub name: String, - - /// Whether this argument is required or not. - pub required: bool, - - /// The [`PrimitiveArgument`] of this argument node. - pub primitive: PrimitiveArgument, - - /// Suggests autocomplete options for this argument. - pub suggester: fn(&mut CommandContext) -> Vec, -} - -impl PartialEq for CommandArgumentNode { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - && self.required == other.required - && self.primitive == other.primitive - } -} - -pub mod utils { - //! Utilities related to argument parsing errors. - - use std::error::Error; - - use temper_text::{NamedColor, TextComponent, TextComponentBuilder}; - - use crate::errors::CommandError; - - /// Creates a [`CommandError::ParserError`] parser error from the given `message`. - pub fn parser_error(message: &str) -> Box { - error(CommandError::ParserError(message.to_string())) - } - - /// Creates a parser error from the given `err`. - pub fn error(err: impl Error) -> Box { - Box::new( - TextComponentBuilder::new(err.to_string()) - .color(NamedColor::Red) - .build(), - ) - } -} diff --git a/src/commands/src/arg/position.rs b/src/commands/src/arg/position.rs deleted file mode 100644 index 249863cd0..000000000 --- a/src/commands/src/arg/position.rs +++ /dev/null @@ -1,305 +0,0 @@ -use crate::arg::primitive::PrimitiveArgument; -use crate::arg::utils::parser_error; -use crate::arg::{CommandArgument, ParserResult}; -use crate::{CommandContext, Suggestion}; -use temper_components::player::position::Position; - -/// Represents a position argument in a command, which can be absolute or relative. -/// For example: "100 64 -200" (absolute) or "~10 ~ ~-5" (relative). -/// -/// The coordinates are initially opaque, in order to get an actual world position you must pass in -/// a base position to resolve against, usually the player calling the command. You can pass in a -/// 0,0,0 position if you want absolute coordinates only. -/// -/// # Example -/// ```ignore -/// use temper_commands::arg::position::CommandPosition; -/// use temper_core::transform::position::Position; -/// -/// let cmd_pos = CommandPosition::parse("~10 64 ~-5").unwrap; -/// let base_position = Position::new(100.0, 64.0, 100.0); -/// let resolved_position = cmd_pos.resolve(&base_position); -/// assert_eq!(resolved_position, Position::new(110.0, 64.0, 95.0)); -/// ``` -pub struct CommandPosition { - x: PositionType, - y: PositionType, - z: PositionType, -} - -enum PositionType { - Absolute(f64), - Relative(f64), -} - -impl CommandArgument for CommandPosition { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let mut string_input = String::new(); - while ctx.input.has_remaining_input() { - if !string_input.is_empty() { - string_input.push(' '); - } - string_input.push_str(&ctx.input.read_string()); - } - let mut parts = string_input.split_whitespace(); - let x_str = parts - .next() - .ok_or_else(|| parser_error("missing x coordinate"))?; - let y_str = parts - .next() - .ok_or_else(|| parser_error("missing y coordinate"))?; - let z_str = parts - .next() - .ok_or_else(|| parser_error("missing z coordinate"))?; - let x = if let Some(x_str) = x_str.strip_prefix('~') { - if x_str.is_empty() { - PositionType::Relative(0.0) - } else { - let offset = x_str - .parse::() - .map_err(|_| parser_error("invalid x coordinate"))?; - PositionType::Relative(offset) - } - } else { - let value = x_str - .parse::() - .map_err(|_| parser_error("invalid x coordinate"))?; - PositionType::Absolute(value) - }; - let y = if let Some(y_str) = y_str.strip_prefix('~') { - if y_str.is_empty() { - PositionType::Relative(0.0) - } else { - let offset = y_str - .parse::() - .map_err(|_| parser_error("invalid y coordinate"))?; - PositionType::Relative(offset) - } - } else { - let value = y_str - .parse::() - .map_err(|_| parser_error("invalid y coordinate"))?; - PositionType::Absolute(value) - }; - let z = if let Some(z_str) = z_str.strip_prefix('~') { - if z_str.is_empty() { - PositionType::Relative(0.0) - } else { - let offset = z_str - .parse::() - .map_err(|_| parser_error("invalid z coordinate"))?; - PositionType::Relative(offset) - } - } else { - let value = z_str - .parse::() - .map_err(|_| parser_error("invalid z coordinate"))?; - PositionType::Absolute(value) - }; - if parts.next().is_some() { - return Err(parser_error("too many coordinates provided")); - } - Ok(CommandPosition { x, y, z }) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::greedy() - } - - fn suggest(_ctx: &mut CommandContext) -> Vec { - vec![Suggestion::of("~ ~ ~")] - } -} - -impl CommandPosition { - pub fn resolve(&self, position: &Position) -> Position { - let x = match self.x { - PositionType::Absolute(val) => val, - PositionType::Relative(offset) => position.x + offset, - }; - let y = match self.y { - PositionType::Absolute(val) => val, - PositionType::Relative(offset) => position.y + offset, - }; - let z = match self.z { - PositionType::Absolute(val) => val, - PositionType::Relative(offset) => position.z + offset, - }; - Position::new(x, y, z) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{Command, CommandInput, Sender}; - use std::sync::Arc; - use temper_state::create_test_state; - - #[test] - fn test_parse() { - let mut ctx = CommandContext { - input: CommandInput { - input: "~10 5 ~-10".to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let cmd_pos = CommandPosition::parse(&mut ctx).unwrap(); - match cmd_pos.x { - PositionType::Relative(offset) => assert_eq!(offset, 10.0), - _ => panic!("Expected relative x"), - } - match cmd_pos.y { - PositionType::Absolute(value) => assert_eq!(value, 5.0), - _ => panic!("Expected absolute y"), - } - match cmd_pos.z { - PositionType::Relative(offset) => assert_eq!(offset, -10.0), - _ => panic!("Expected relative z"), - } - } - - #[test] - fn test_resolve() { - let cmd_pos = CommandPosition { - x: PositionType::Relative(10.0), - y: PositionType::Absolute(5.0), - z: PositionType::Relative(-10.0), - }; - let base_position = Position::new(100.0, 64.0, 100.0); - let resolved = cmd_pos.resolve(&base_position); - assert_eq!(resolved.x, 110.0); - assert_eq!(resolved.y, 5.0); - assert_eq!(resolved.z, 90.0); - } - - #[test] - fn parse_valid_inputs() { - let cases = vec![ - ( - "100 64 -200", - ( - PositionType::Absolute(100.0), - PositionType::Absolute(64.0), - PositionType::Absolute(-200.0), - ), - ), - ( - "~10 ~ ~-5", - ( - PositionType::Relative(10.0), - PositionType::Relative(0.0), - PositionType::Relative(-5.0), - ), - ), - ( - "50 ~20 30", - ( - PositionType::Absolute(50.0), - PositionType::Relative(20.0), - PositionType::Absolute(30.0), - ), - ), - ( - "~ ~ ~", - ( - PositionType::Relative(0.0), - PositionType::Relative(0.0), - PositionType::Relative(0.0), - ), - ), - ( - "1 2 ~", - ( - PositionType::Absolute(1.0), - PositionType::Absolute(2.0), - PositionType::Relative(0.0), - ), - ), - ( - "~-0 ~0 ~+0", - ( - PositionType::Relative(-0.0), - PositionType::Relative(0.0), - PositionType::Relative(0.0), - ), - ), - ]; - for (input, expected) in cases { - let mut ctx = CommandContext { - input: CommandInput { - input: input.to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - let cmd_pos = CommandPosition::parse(&mut ctx) - .unwrap_or_else(|_| panic!("input `{}` should be valid", input)); - match cmd_pos.x { - PositionType::Absolute(val) => match expected.0 { - PositionType::Absolute(exp_val) => assert_eq!(val, exp_val), - _ => panic!("Expected relative x for input `{}`", input), - }, - PositionType::Relative(offset) => match expected.0 { - PositionType::Relative(exp_offset) => assert_eq!(offset, exp_offset), - _ => panic!("Expected absolute x for input `{}`", input), - }, - } - match cmd_pos.y { - PositionType::Absolute(val) => match expected.1 { - PositionType::Absolute(exp_val) => assert_eq!(val, exp_val), - _ => panic!("Expected relative y for input `{}`", input), - }, - PositionType::Relative(offset) => match expected.1 { - PositionType::Relative(exp_offset) => assert_eq!(offset, exp_offset), - _ => panic!("Expected absolute y for input `{}`", input), - }, - } - match cmd_pos.z { - PositionType::Absolute(val) => match expected.2 { - PositionType::Absolute(exp_val) => assert_eq!(val, exp_val), - _ => panic!("Expected relative z for input `{}`", input), - }, - PositionType::Relative(offset) => match expected.2 { - PositionType::Relative(exp_offset) => assert_eq!(offset, exp_offset), - _ => panic!("Expected absolute z for input `{}`", input), - }, - } - } - } - - #[test] - fn test_parse_invalid_inputs() { - let cases = vec!["", "1 2", "1 two 3", "not_a_number 5 6", "~ ~", "1 2 3 4"]; - for input in cases { - let mut ctx = CommandContext { - input: CommandInput { - input: input.to_string(), - cursor: 0, - }, - command: Arc::new(Command { - name: "", - args: vec![], - }), - sender: Sender::Server, - state: create_test_state().0.0, - }; - assert!( - CommandPosition::parse(&mut ctx).is_err(), - "input `{}` should be invalid", - input - ); - } - } -} diff --git a/src/commands/src/arg/primitive/bool.rs b/src/commands/src/arg/primitive/bool.rs deleted file mode 100644 index 7a1694bab..000000000 --- a/src/commands/src/arg/primitive/bool.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; - -impl CommandArgument for bool { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - let value = match &*str.to_lowercase() { - "true" | "yes" | "on" | "y" => true, - "false" | "no" | "off" | "n" => false, - _ => return Err(parser_error(&format!("invalid variant: {str}"))), - }; - - Ok(value) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::bool() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - - vec![Suggestion::of("true"), Suggestion::of("false")] - } -} diff --git a/src/commands/src/arg/primitive/char.rs b/src/commands/src/arg/primitive/char.rs deleted file mode 100644 index 6d8dac9b4..000000000 --- a/src/commands/src/arg/primitive/char.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::{ - CommandContext, Suggestion, - arg::{CommandArgument, ParserResult, utils::parser_error}, -}; - -use super::PrimitiveArgument; - -impl CommandArgument for char { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let str = ctx.input.read_string(); - - if str.len() > 1 || str.is_empty() { - return Err(parser_error("expected single character")); - } - - Ok(str.chars().nth(0).unwrap()) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } - - fn suggest(ctx: &mut CommandContext) -> Vec { - ctx.input.read_string(); - - ('a'..'Z').map(|c| Suggestion::of(c.to_string())).collect() - } -} diff --git a/src/commands/src/arg/primitive/float.rs b/src/commands/src/arg/primitive/float.rs deleted file mode 100644 index 98f969ab0..000000000 --- a/src/commands/src/arg/primitive/float.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::io::Write; - -use temper_codec::encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}; - -use crate::{ - arg::{CommandArgument, ParserResult, utils::error}, - ctx::CommandContext, - wrapper, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Default)] -pub struct FloatArgumentFlags { - pub min: Option, - pub max: Option, -} - -impl NetEncode for FloatArgumentFlags { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - let mut flags = 0u8; - if self.min.is_some() { - flags |= 0x01; - } - if self.max.is_some() { - flags |= 0x02; - } - flags.encode(writer, opts)?; - self.min.encode(writer, opts)?; - self.max.encode(writer, opts) - } -} - -wrapper! { - /// A 32-bit floating point number. This can sadly not be bound in size since - /// floats are not valid const type parameters. The workaround for this is to - /// check it manually. - struct Float(f32); -} - -impl CommandArgument for Float { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let token = ctx.input.read_string(); - let float = match token.parse::() { - Ok(int) => int, - Err(err) => return Err(error(err)), - }; - - Ok(Float(float)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::float(None, None) - } -} diff --git a/src/commands/src/arg/primitive/int.rs b/src/commands/src/arg/primitive/int.rs deleted file mode 100644 index 53ec0cd54..000000000 --- a/src/commands/src/arg/primitive/int.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{io::Write, ops::Deref}; - -use temper_codec::encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}; - -use crate::{ - arg::{ - CommandArgument, ParserResult, - utils::{error, parser_error}, - }, - ctx::CommandContext, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Default)] -pub struct IntArgumentFlags { - pub min: Option, - pub max: Option, -} - -impl NetEncode for IntArgumentFlags { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - let mut flags = 0u8; - if self.min.is_some() { - flags |= 0x01; - } - if self.max.is_some() { - flags |= 0x02; - } - flags.encode(writer, opts)?; - self.min.encode(writer, opts)?; - self.max.encode(writer, opts) - } -} - -// Not using wrapper! here because of the complex generics -/// An integer, limited in size by the type arguments. -pub struct Integer(i32); - -impl Deref for Integer { - type Target = i32; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl CommandArgument for Integer { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let token = ctx.input.read_string(); - let int = match token.parse::() { - Ok(int) => int, - Err(err) => return Err(error(err)), - }; - - if int < MIN { - return Err(parser_error(&format!( - "integer too small: {int}, expected at least {MIN}" - ))); - } - - if int > MAX { - return Err(parser_error(&format!( - "integer too large: {int}, expected at most {MAX}" - ))); - } - - Ok(Integer(int)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::int(Some(MIN), Some(MAX)) - } -} diff --git a/src/commands/src/arg/primitive/long.rs b/src/commands/src/arg/primitive/long.rs deleted file mode 100644 index b41f69837..000000000 --- a/src/commands/src/arg/primitive/long.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{io::Write, ops::Deref}; - -use temper_codec::encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}; - -use crate::{ - arg::{ - CommandArgument, ParserResult, - utils::{error, parser_error}, - }, - ctx::CommandContext, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Default)] -pub struct LongArgumentFlags { - pub min: Option, - pub max: Option, -} - -impl NetEncode for LongArgumentFlags { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - let mut flags = 0u8; - if self.min.is_some() { - flags |= 0x01; - } - if self.max.is_some() { - flags |= 0x02; - } - flags.encode(writer, opts)?; - self.min.encode(writer, opts)?; - self.max.encode(writer, opts) - } -} - -// Not using wrapper! here because of the complex generics -/// A 64-bit integer, limited in size by the type arguments. -pub struct Long(i64); - -impl Deref for Long { - type Target = i64; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl CommandArgument for Long { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let token = ctx.input.read_string(); - let long = match token.parse::() { - Ok(int) => int, - Err(err) => return Err(error(err)), - }; - - if long < MIN { - return Err(parser_error(&format!( - "integer too small: {long}, expected at least {MIN}" - ))); - } - - if long > MIN { - return Err(parser_error(&format!( - "integer too large: {long}, expected at most {MIN}" - ))); - } - - Ok(Long(long)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::long(Some(MIN), Some(MIN)) - } -} diff --git a/src/commands/src/arg/primitive/mod.rs b/src/commands/src/arg/primitive/mod.rs deleted file mode 100644 index 995f08e83..000000000 --- a/src/commands/src/arg/primitive/mod.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! Primitive command argument types. - -// TODO: -// * Entity -// * Score Holder -// * Resource or Tag -// * Resource or Tag Key -// * Resource -// * Resource Key - -use std::io::Write; - -use enum_ordinalize::Ordinalize; -use float::FloatArgumentFlags; -use int::IntArgumentFlags; -use long::LongArgumentFlags; -use string::StringArgumentType; -use temper_codec::{ - encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}, - net_types::var_int::VarInt, -}; -use temper_macros::NetEncode; - -pub mod bool; -pub mod char; -pub mod float; -pub mod int; -pub mod long; -pub mod string; - -#[derive(Clone, Debug, PartialEq)] -pub struct PrimitiveArgument { - pub argument_type: PrimitiveArgumentType, - pub flags: Option, -} - -impl PrimitiveArgument { - pub fn word() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::String, - flags: Some(PrimitiveArgumentFlags::String(StringArgumentType::Word)), - } - } - - pub fn quotable() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::String, - flags: Some(PrimitiveArgumentFlags::String(StringArgumentType::Quotable)), - } - } - - pub fn greedy() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::String, - flags: Some(PrimitiveArgumentFlags::String(StringArgumentType::Greedy)), - } - } - - pub fn int(min: Option, max: Option) -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Int, - flags: Some(PrimitiveArgumentFlags::Int(IntArgumentFlags { min, max })), - } - } - - pub fn long(min: Option, max: Option) -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Long, - flags: Some(PrimitiveArgumentFlags::Long(LongArgumentFlags { min, max })), - } - } - - pub fn float(min: Option, max: Option) -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Float, - flags: Some(PrimitiveArgumentFlags::Float(FloatArgumentFlags { - min, - max, - })), - } - } - - pub fn bool() -> PrimitiveArgument { - PrimitiveArgument { - argument_type: PrimitiveArgumentType::Bool, - flags: None, - } - } -} - -#[derive(Clone, Debug, PartialEq, NetEncode)] -pub enum PrimitiveArgumentFlags { - Float(FloatArgumentFlags), - Int(IntArgumentFlags), - Long(LongArgumentFlags), - String(StringArgumentType), -} - -#[derive(Clone, Debug, PartialEq, Ordinalize)] -pub enum PrimitiveArgumentType { - Bool, - Float, - Double, - Int, - Long, - String, - Entity, - GameProfile, - BlockPos, - ColumnPos, - Vec3, - Vec2, - BlockState, - BlockPredicate, - ItemStack, - ItemPredicate, - Color, - Component, - Style, - Message, - Nbt, - NbtTag, - NbtPath, - Objective, - ObjectiveCriteria, - Operator, - Particle, - Angle, - Rotation, - ScoreboardDisplaySlot, - ScoreHolder, - UpTo3Axes, - Team, - ItemSlot, - ResourceLocation, - Function, - EntityAnchor, - IntRange, - FloatRange, - Dimension, - GameMode, - Time, - ResourceOrTag, - ResourceOrTagKey, - Resource, - ResourceKey, - TemplateMirror, - TemplateRotation, - Heightmap, - UUID, - Position, -} - -impl NetEncode for PrimitiveArgumentType { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - VarInt::new(i32::from(self.ordinal())).encode(writer, opts) - } -} - -#[doc(hidden)] -mod utils { - //! Internal utilities related to command arguments. - - /// Macro that creates a wrapper struct around an inner type and implements Deref for the inner type. - #[macro_export] - macro_rules! wrapper { - ( - $( - $(#[$meta:meta])* - struct $name:ident $(<$($generics:tt),*>)? ($inner:ty); - )* - ) => { - $( - $(#[$meta])* - pub struct $name $(<$($generics),*>)? ($inner); - - impl std::ops::Deref for $name { - type Target = $inner; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - )* - }; - } -} diff --git a/src/commands/src/arg/primitive/string.rs b/src/commands/src/arg/primitive/string.rs deleted file mode 100644 index 2a762452f..000000000 --- a/src/commands/src/arg/primitive/string.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::io::Write; - -use enum_ordinalize::Ordinalize; -use temper_codec::{ - encode::{NetEncode, NetEncodeOpts, errors::NetEncodeError}, - net_types::var_int::VarInt, -}; - -use crate::{ - arg::{CommandArgument, ParserResult, utils::parser_error}, - ctx::CommandContext, - wrapper, -}; - -use super::PrimitiveArgument; - -#[derive(Clone, Debug, PartialEq, Ordinalize, Default)] -pub enum StringArgumentType { - #[default] - Word, - Quotable, - Greedy, -} - -impl NetEncode for StringArgumentType { - fn encode(&self, writer: &mut W, opts: &NetEncodeOpts) -> Result<(), NetEncodeError> { - VarInt::new(i32::from(self.ordinal())).encode(writer, opts) - } -} - -wrapper! { - /// A single-word string. - struct SingleWord(String); - - /// A quotable string, accepting either a single-word string or a quoted multi-word string. - struct QuotableString(String); - - /// A greedy string, consuming the rest of the command input. - struct GreedyString(String); -} - -impl CommandArgument for SingleWord { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let word = ctx.input.read_string(); - - if word.is_empty() { - return Err(parser_error("string must not be empty")); - } - - Ok(SingleWord(word)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::word() - } -} - -impl CommandArgument for QuotableString { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let input = &mut ctx.input; - - input.skip_whitespace(u32::MAX, false); - - // If it starts with a single or double quote, then parse a quotable string - if input.peek() == Some('"') || input.peek() == Some('\'') { - input.read(1); // Consume opening quote - - let mut result = String::new(); - let mut escaped = false; - - while input.has_remaining_input() { - let current = input.peek(); - - match current { - None => return Err(parser_error("unterminated quoted string")), - Some(c) => { - input.read(1); - - if escaped { - match c { - '"' | '\\' => result.push(c), - _ => { - result.push('\\'); - result.push(c); - } - } - escaped = false; - } else { - match c { - '"' | '\'' => return Ok(QuotableString(result)), - '\\' => escaped = true, - _ => result.push(c), - } - } - } - } - } - - Err(parser_error("unterminated quoted string")) - } else { - let word = input.read_string(); - - if word.is_empty() { - return Err(parser_error("string must not be empty")); - } - - Ok(QuotableString(word)) - } - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::quotable() - } -} - -impl CommandArgument for GreedyString { - fn parse(ctx: &mut CommandContext) -> ParserResult { - let input = &mut ctx.input; - let mut result_parts = Vec::new(); - - while input.has_remaining_input() { - let string = input.read_string(); - - result_parts.push(string); - } - - if result_parts.is_empty() { - return Err(parser_error("string cannot be empty")); - } - - let result = result_parts.join(" "); - - Ok(GreedyString(result)) - } - - fn primitive() -> PrimitiveArgument { - PrimitiveArgument::greedy() - } -} diff --git a/src/commands/src/ctx.rs b/src/commands/src/ctx.rs deleted file mode 100644 index 267de378a..000000000 --- a/src/commands/src/ctx.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Command contexts. - -use std::sync::Arc; - -use crate::{ - Command, - arg::{CommandArgument, ParserResult, utils::parser_error}, - input::CommandInput, - sender::Sender, -}; -use temper_state::GlobalState; -use tracing::error; - -/// Context of the execution of a command. -pub struct CommandContext { - /// The command input. - pub input: CommandInput, - - /// The command. - pub command: Arc, - - /// The sender of the command. - pub sender: Sender, - - pub state: GlobalState, -} - -impl CommandContext { - /// Attempts to retrieve and parse an argument of the given `name` and parses it with the given parser. - pub fn arg(&mut self, name: &str) -> ParserResult { - if self.command.args.iter().any(|a| a.name == name) { - T::parse(self) - } else { - error!("attempted to fetch non-existant command argument"); - Err(parser_error(&format!("arg {name} does not exist"))) - } - } -} diff --git a/src/commands/src/errors.rs b/src/commands/src/errors.rs deleted file mode 100644 index 3aee4e2e3..000000000 --- a/src/commands/src/errors.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Command errors. - -use thiserror::Error; - -/// Errors related to commands. -#[derive(Debug, Clone, Error)] -pub enum CommandError { - /// An argument parser failed - #[error("{0}")] - ParserError(String), - - /// A given argument could not be found. - #[error("argument not found: {0}")] - ArgumentNotFound(String), -} diff --git a/src/commands/src/graph/mod.rs b/src/commands/src/graph/mod.rs deleted file mode 100644 index 728a92154..000000000 --- a/src/commands/src/graph/mod.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! The command graph. - -use std::collections::HashMap; -use std::sync::Arc; - -use node::{CommandNode, CommandNodeFlag, CommandNodeType}; -use temper_codec::net_types::length_prefixed_vec::LengthPrefixedVec; -use temper_codec::net_types::var_int::VarInt; - -use crate::Command; - -pub mod node; - -/// The command graph of the server or an individual player. -/// As of now, only one instance of this exists on the entire -/// server and it is shared between all players. The command -/// graph holds references to all command nodes and maps them -/// to their indices, and is later sent to the client on join. -#[derive(Clone, Debug)] -pub struct CommandGraph { - /// The root node. - pub root_node: CommandNode, - - /// The root node with all its child nodes. - pub nodes: Vec, - - /// A map of command node parts to indices. - pub node_to_indices: HashMap, -} - -impl Default for CommandGraph { - fn default() -> Self { - let root_node = CommandNode { - flags: CommandNodeFlag::NodeType(CommandNodeType::Root).bitmask(), - children: LengthPrefixedVec::new(Vec::new()), - redirect_node: None, - name: None, - parser_id: None, - properties: None, - suggestions_type: None, - }; - - Self { - root_node: root_node.clone(), - nodes: vec![root_node], - node_to_indices: HashMap::new(), - } - } -} - -impl CommandGraph { - /// Adds the given `command` onto this command graph. - pub fn push(&mut self, command: Arc) { - let mut current_node_idx = 0; - - for (idx, part) in command.name.split_whitespace().enumerate() { - let is_last = idx == command.name.split_whitespace().count() - 1; - - let mut node = CommandNode { - flags: CommandNodeFlag::NodeType(CommandNodeType::Literal).bitmask(), - children: LengthPrefixedVec::new(Vec::new()), - redirect_node: None, - name: Some(part.to_string()), - parser_id: None, - properties: None, - suggestions_type: None, - }; - - if is_last - && (command.args.is_empty() - || command.args.first().is_some_and(|arg| !arg.required)) - { - node.flags |= CommandNodeFlag::Executable.bitmask(); - } - - let node_idx = self.nodes.len() as u32; - self.nodes.push(node); - self.node_to_indices.insert(part.to_string(), node_idx); - - if idx == 0 { - self.nodes[0].children.push(VarInt::new(node_idx as i32)); - } else { - let parent_node = self.nodes.get_mut(current_node_idx as usize).unwrap(); - parent_node.children.push(VarInt::new(node_idx as i32)); - } - - current_node_idx = node_idx; - } - - let mut prev_node_idx = current_node_idx; - - for (idx, arg) in command.args.iter().enumerate() { - let primitive = arg.primitive.clone(); - let is_last = idx == command.args.len() - 1; - - let mut arg_node = CommandNode { - flags: CommandNodeFlag::NodeType(CommandNodeType::Argument).bitmask() - | CommandNodeFlag::HasSuggestionsType.bitmask(), - children: LengthPrefixedVec::new(Vec::new()), - redirect_node: None, - name: Some(arg.name.clone()), - parser_id: Some(primitive.argument_type), - properties: primitive.flags, - suggestions_type: Some("ask_server".to_string()), - }; - - if is_last { - arg_node.flags |= CommandNodeFlag::Executable.bitmask(); - } - - let arg_node_idx = self.nodes.len() as u32; - self.nodes.push(arg_node); - - self.nodes[prev_node_idx as usize] - .children - .push(VarInt::new(arg_node_idx as i32)); - - prev_node_idx = arg_node_idx; - } - } - - /// Traverses the command graph with a given function. - pub fn traverse(&self, mut f: F) - where - F: FnMut(&CommandNode, u32, usize, Option), - { - self.traverse_node(0, 0, None, &mut f); - } - - fn traverse_node(&self, node_idx: u32, depth: usize, parent: Option, f: &mut F) - where - F: FnMut(&CommandNode, u32, usize, Option), - { - let current_node = &self.nodes[node_idx as usize]; - - f(current_node, node_idx, depth, parent); - - for child_idx in current_node.children.data.iter() { - self.traverse_node(child_idx.0 as u32, depth + 1, Some(node_idx), f); - } - } - - /// Attempts to find the matches to a given `input` string and returns - /// a vector of the node index and command name. - pub fn find_command<'a>(&'a self, input: &'a str) -> Vec<(u32, &'a str)> { - let mut matches = Vec::new(); - let input = input.trim(); - - self.find_command_recursive(0, input, &mut matches); - matches - } - - fn find_command_recursive<'a>( - &'a self, - node_idx: u32, - remaining_input: &'a str, - matches: &mut Vec<(u32, &'a str)>, - ) { - let current_node = &self.nodes[node_idx as usize]; - let input_words: Vec<&str> = remaining_input.split_whitespace().collect(); - - // once the input is empty and the currently selected node is executable, we've found it. - if remaining_input.is_empty() && current_node.is_executable() { - matches.push((node_idx, remaining_input)); - return; - } - - // once the input is empty but the currently selected node is not executable, we check the children. - if remaining_input.is_empty() { - return; - } - - match current_node.node_type() { - CommandNodeType::Root => { - // the root node is the root of all evil. - for child_idx in current_node.children.data.iter() { - self.find_command_recursive(child_idx.0 as u32, remaining_input, matches); - } - } - CommandNodeType::Literal => { - // for literal nodes, everything must match exactly. - if let Some(name) = ¤t_node.name - && !input_words.is_empty() - && input_words[0] == name - { - // we found a match, we continue with the remaining input. - let remaining = if input_words.len() > 1 { - remaining_input[name.len()..].trim_start() - } else { - "" - }; - - // once we found a node that is executable and the remaining input is empty, we've found something. - if remaining.is_empty() && current_node.is_executable() { - matches.push((node_idx, remaining)); - } - - // we continue checking the other children. - for child_idx in current_node.children.data.iter() { - self.find_command_recursive(child_idx.0 as u32, remaining, matches); - } - } - } - CommandNodeType::Argument => { - // for argument nodes, we consume one argument and then continue. - if !input_words.is_empty() { - let remaining = if input_words.len() > 1 { - remaining_input[input_words[0].len()..].trim_start() - } else { - "" - }; - - // if this node is executable, we add it. - matches.push((node_idx, remaining)); - - // continue checking anyway. - for child_idx in current_node.children.data.iter() { - self.find_command_recursive(child_idx.0 as u32, remaining, matches); - } - } - } - } - } - - fn collect_command_parts(&self, node_idx: u32, parts: &mut Vec) { - let node = &self.nodes[node_idx as usize]; - - if let Some(name) = &node.name - && node.node_type() == CommandNodeType::Literal - { - parts.push(name.clone()); - } - - // find the parent - for (parent_idx, parent_node) in self.nodes.iter().enumerate() { - if parent_node - .children - .data - .iter() - .any(|child| child.0 as u32 == node_idx) - { - self.collect_command_parts(parent_idx as u32, parts); - break; - } - } - } - - /// Gets the name of a command based off the `node_idx`. - pub fn get_command_name(&self, node_idx: u32) -> String { - let mut parts = Vec::new(); - self.collect_command_parts(node_idx, &mut parts); - parts.reverse(); // reverse since we want the command name in proper order - parts.join(" ") - } - - /// Attempts to find a command from the given `input` and returns the command name. - pub fn find_command_by_input(&self, input: &str) -> Option { - let matches = self.find_command(input); - - matches - .first() - .map(|(node_idx, _remaining)| self.get_command_name(*node_idx)) - } -} diff --git a/src/commands/src/graph/node.rs b/src/commands/src/graph/node.rs deleted file mode 100644 index 511be1951..000000000 --- a/src/commands/src/graph/node.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Command graph nodes. - -use std::fmt; - -use enum_ordinalize::Ordinalize; -use temper_codec::net_types::{length_prefixed_vec::LengthPrefixedVec, var_int::VarInt}; -use temper_macros::NetEncode; - -use crate::arg::primitive::{PrimitiveArgumentFlags, PrimitiveArgumentType}; - -/// The type of command node. -#[derive(Clone, Debug, PartialEq, Ordinalize)] -pub enum CommandNodeType { - Root, - Literal, - Argument, -} - -impl CommandNodeType { - /// Gets the protocol ID (ordinal) of this type. - pub fn id(&self) -> u8 { - self.ordinal() as u8 - } -} - -/// Flags related to command nodes. -#[derive(Clone, Debug, PartialEq)] -pub enum CommandNodeFlag { - /// The node type. - NodeType(CommandNodeType), - - /// The node is executable. - Executable, - - /// The node has a redirect. - HasRedirect, - - /// The node has a suggestion type ([`CommandNodeType::Argument`] only). - HasSuggestionsType, -} - -impl CommandNodeFlag { - /// Gets the bitmask of this flag. - pub const fn bitmask(&self) -> u8 { - match self { - CommandNodeFlag::NodeType(CommandNodeType::Root) => 0x00, - CommandNodeFlag::NodeType(CommandNodeType::Literal) => 0x01, - CommandNodeFlag::NodeType(CommandNodeType::Argument) => 0x02, - CommandNodeFlag::Executable => 0x04, - CommandNodeFlag::HasRedirect => 0x08, - CommandNodeFlag::HasSuggestionsType => 0x10, - } - } -} - -/// An instance of a command node in a command graph. -#[derive(Clone, NetEncode)] -pub struct CommandNode { - /// The encoded [`CommandNodeFlag`] of this node. - pub flags: u8, - - /// Node indices of this node's children. - pub children: LengthPrefixedVec, - - /// Node index of the redirected node. Only [`Some`] if `flags` is [`CommandNodeFlag::HasRedirect`]. - pub redirect_node: Option, - - /// The name of this node. Only [`None`] for the root node. - pub name: Option, - - /// The [`PrimitiveArgumentType`] of this node. Only [`Some`] for argument nodes. - pub parser_id: Option, - - /// The [`PrimitiveArgumentFlags`] of this node. Only [`Some`] for argument nodes. - pub properties: Option, - - /// The type of suggestions used for this node. Only [`Some`] for argument nodes. - pub suggestions_type: Option, -} - -// We want to display the actual flags and not the encoded value -impl fmt::Debug for CommandNode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let node_type = match self.flags & 0x03 { - 0 => CommandNodeType::Root, - 1 => CommandNodeType::Literal, - 2 => CommandNodeType::Argument, - _ => panic!("Invalid node type"), - }; - - let executable = self.flags & 0x04 != 0; - let has_redirect = self.flags & 0x08 != 0; - let has_suggestions_type = self.flags & 0x10 != 0; - - f.debug_struct("CommandNode") - .field("node_type", &node_type) - .field("executable", &executable) - .field("has_redirect", &has_redirect) - .field("has_suggestions_type", &has_suggestions_type) - .field("flags", &self.flags) - .field("children", &self.children) - .field("redirect_node", &self.redirect_node) - .field("name", &self.name) - .field("parser_id", &self.parser_id) - .field("properties", &self.properties) - .field("suggestions_type", &self.suggestions_type) - .finish() - } -} - -impl CommandNode { - /// Gets the [`CommandNodeType`] from the flags. - pub fn node_type(&self) -> CommandNodeType { - match self.flags & 0x03 { - 1 => CommandNodeType::Literal, - 2 => CommandNodeType::Argument, - _ => CommandNodeType::Root, - } - } - - /// Whether this node is executable. - pub fn is_executable(&self) -> bool { - self.flags & 0x04 != 0 - } - - /// Whether this node has a redirect. - pub fn has_redirect(&self) -> bool { - self.flags & 0x08 != 0 - } - - /// Whether this node has a suggestion type. - pub fn has_suggestions_type(&self) -> bool { - self.flags & 0x10 != 0 - } -} diff --git a/src/commands/src/infrastructure.rs b/src/commands/src/infrastructure.rs deleted file mode 100644 index 66cc84ef0..000000000 --- a/src/commands/src/infrastructure.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Command infrastructure - -use bevy_ecs::{prelude::*, schedule::ScheduleConfigs, system::ScheduleSystem}; -use dashmap::DashMap; -use std::{ - cell::RefCell, - sync::{Arc, LazyLock, RwLock}, -}; - -use crate::{Command, graph::CommandGraph}; - -static COMMANDS: LazyLock>> = LazyLock::new(DashMap::new); -static COMMAND_GRAPH: LazyLock> = - LazyLock::new(|| RwLock::new(CommandGraph::default())); - -thread_local! { - static SYSTEMS_TO_BE_REGISTERED: RefCell>> = RefCell::new(Vec::new()); - static EXCLUSIVE_SYSTEMS_TO_BE_REGISTERED: RefCell>> = RefCell::new(Vec::new()); -} - -/// Internal function. Adds a command system. -#[doc(hidden)] -pub fn add_system(system: impl IntoScheduleConfigs) { - SYSTEMS_TO_BE_REGISTERED.with(|systems| { - systems.borrow_mut().push(system.into_configs()); - }); -} - -/// Internal function. Registers all command systems. -#[doc(hidden)] -pub fn register_command_systems(schedule: &mut Schedule) { - SYSTEMS_TO_BE_REGISTERED.with(|systems| { - let mut systems = systems.borrow_mut(); - while let Some(sys) = systems.pop() { - schedule.add_systems(sys); - } - }); -} - -/// Registers a command. -pub fn register_command(command: Arc) { - COMMANDS.insert(command.name, command.clone()); - if let Ok(mut graph) = COMMAND_GRAPH.write() { - graph.push(command); - } -} - -/// Gets the server's command graph. -pub fn get_graph() -> CommandGraph { - if let Ok(graph) = COMMAND_GRAPH.read() { - graph.clone() - } else { - CommandGraph::default() - } -} - -/// Attempts to find a command by its `name`. -pub fn get_command_by_name(name: &str) -> Option> { - COMMANDS.get(name).map(|cmd_ref| Arc::clone(&cmd_ref)) -} - -/// Attempts to find a command by an `input` string. -pub fn find_command(input: &str) -> Option> { - let graph = get_graph(); - let name = graph.find_command_by_input(input); - if let Some(name) = name { - get_command_by_name(&name) - } else { - None - } -} diff --git a/src/commands/src/input.rs b/src/commands/src/input.rs deleted file mode 100644 index b8abd2d0b..000000000 --- a/src/commands/src/input.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Command input. - -/// Input of a command. -/// This struct is mainly used for parsing arguments. -#[derive(Clone)] -pub struct CommandInput { - /// The entire input. - pub input: String, - - /// The current cursor position. - pub cursor: u32, -} - -impl CommandInput { - /// Creates a new [`CommandInput`] of a given `input` string. - pub fn of(input: String) -> Self { - Self { input, cursor: 0 } - } - - /// Appends a given `string` to this input. - pub fn append_string(&mut self, string: String) { - self.input += &*string; - } - - /// Moves the cursor by `chars`. - pub fn move_cursor(&mut self, chars: u32) { - if self.cursor + chars > self.input.len() as u32 { - return; - } - - self.cursor += chars; - } - - /// Gets the remaining length of the input. - pub fn remaining_length(&self) -> u32 { - self.input.len() as u32 - self.cursor - } - - /// Peeks one character ahead. - pub fn peek(&self) -> Option { - self.input.chars().nth(self.cursor as usize) - } - - /// Whether there is any input remaining unconsumed. - pub fn has_remaining_input(&self) -> bool { - self.cursor < self.input.len() as u32 - } - - /// Skips a max of `max_spaces` whitespace characters, whilst preserving single whitespace characters - /// if `preserve_single` is true. - pub fn skip_whitespace(&mut self, max_spaces: u32, preserve_single: bool) { - if preserve_single && self.remaining_length() == 1 && self.peek() == Some(' ') { - return; - } - - let mut i = 0; - while i < max_spaces - && self.has_remaining_input() - && self.peek().is_some_and(|c| c.is_whitespace()) - { - self.read(1); - i += 1; - } - } - - /// Gets the remaining input. - pub fn remaining_input(&self) -> String { - self.input[self.cursor as usize..].to_string() - } - - /// Peeks `chars` and returns a string of all found characters. - pub fn peek_string_chars(&self, chars: u32) -> String { - let remaining = self.remaining_input(); - if chars > remaining.len() as u32 { - return "".to_string(); - } - - remaining[0..chars as usize].to_string() - } - - /// Reads `chars` and returns a string of all found characters. - pub fn read(&mut self, chars: u32) -> String { - let read_string = self.peek_string_chars(chars); - self.move_cursor(chars); - read_string - } - - /// Counts all remaining tokens. - pub fn remaining_tokens(&self) -> u32 { - let count = self.remaining_input().split(' ').count() as u32; - if self.remaining_input().ends_with(' ') { - return count + 1; - } - count - } - - /// Reads an entire word and returns it. - pub fn read_string(&mut self) -> String { - self.skip_whitespace(u32::MAX, false); - let mut result = String::new(); - while let Some(c) = self.peek() { - if c.is_whitespace() { - break; - } - result.push(c); - self.move_cursor(1); - } - result - } - - /// Peeks an entire word and returns it. - pub fn peek_string(&self) -> String { - let remaining = self.remaining_input(); - remaining - .split_whitespace() - .next() - .unwrap_or("") - .to_string() - } - - /// Reads until `separator` is found and returns all found characters as a string. - pub fn read_until(&mut self, separator: char) -> String { - self.skip_whitespace(u32::MAX, false); - let mut result = String::new(); - while let Some(c) = self.peek() { - if c == separator { - self.move_cursor(1); - break; - } - result.push(c); - self.move_cursor(1); - } - result - } - - /// Reads an entire word whilst skipping whitespace and preserving single whitespace - /// characters if `preserve_single` is true. - pub fn read_string_skip_whitespace(&mut self, preserve_single: bool) -> String { - let read_string = self.read_string(); - self.skip_whitespace(u32::MAX, preserve_single); - read_string - } -} diff --git a/src/commands/src/lib.rs b/src/commands/src/lib.rs deleted file mode 100644 index bc156ec27..000000000 --- a/src/commands/src/lib.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Temper's Command API. - -use std::sync::{Arc, LazyLock}; - -use arg::CommandArgumentNode; - -pub mod arg; -mod ctx; -pub mod errors; -pub mod graph; -pub mod infrastructure; -mod input; -pub mod messages; -pub mod resolve; -mod sender; - -// Re-export under main module to avoid clutter. -pub use ctx::*; -pub use input::*; -pub use sender::*; -use temper_macros::NetEncode; -use temper_nbt::NBT; -use temper_text::TextComponent; - -/// An instance of a command. -#[derive(Debug, Clone, PartialEq)] -pub struct Command { - /// The name of the command. - pub name: &'static str, - - /// All possible arguments this command can take. - pub args: Vec, -} - -/// A command suggestion. -#[derive(NetEncode, Clone, Debug, PartialEq)] -pub struct Suggestion { - /// The content of the suggestion. - pub content: String, - - /// An optional tooltip that gets displayed when hovering over the suggestion. - pub tooltip: Option>, -} - -impl Suggestion { - pub fn of(content: impl AsRef) -> Suggestion { - Suggestion { - content: content.as_ref().to_string(), - tooltip: None, - } - } -} - -/// The root command. This is only for internal use and you should never ever have to rely on using this. -/// Only used in command suggestion cases when we don't know the command a player is entering yet. -pub static ROOT_COMMAND: LazyLock> = LazyLock::new(|| { - Arc::new(Command { - name: "", - args: Vec::new(), - }) -}); diff --git a/src/commands/src/messages.rs b/src/commands/src/messages.rs deleted file mode 100644 index 8dc331448..000000000 --- a/src/commands/src/messages.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Messages related to commmands. - -use std::sync::Arc; - -use bevy_ecs::message::Message; - -use crate::{Command, ctx::CommandContext, sender::Sender}; - -/// A command has been dispatched -#[derive(Message)] -pub struct CommandDispatched { - /// The command string. - pub command: String, - - /// The sender of the command. - pub sender: Sender, -} - -/// A command has been dispatched and resolved. -/// At this point in time, the command has not been executed -/// yet. This is up to the server or plugins to handle. -#[derive(Message)] -pub struct ResolvedCommandDispatched { - /// The command. - pub command: Arc, - - /// The created command context. - pub ctx: CommandContext, - - /// The sender of the command. - pub sender: Sender, -} diff --git a/src/commands/src/resolve.rs b/src/commands/src/resolve.rs deleted file mode 100644 index c453cd45f..000000000 --- a/src/commands/src/resolve.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::{Command, CommandContext, CommandInput, Sender, infrastructure}; -use bevy_ecs::error; -use std::sync::Arc; -use temper_state::GlobalState; -use temper_text::{NamedColor, TextComponent, TextComponentBuilder}; - -pub fn resolve( - input: String, - sender: Sender, - state: GlobalState, -) -> error::Result<(Arc, CommandContext), Box> { - let command = infrastructure::find_command(&input); - if command.is_none() { - return Err(Box::new( - TextComponentBuilder::new("Unknown command") - .color(NamedColor::Red) - .build(), - )); - } - - let command = command.unwrap(); - let input = input - .strip_prefix(command.name) - .unwrap_or(&input) - .trim_start(); - let input = CommandInput::of(input.to_string()); - let ctx = CommandContext { - input: input.clone(), - command: command.clone(), - sender, - state, - }; - - Ok((command, ctx)) -} diff --git a/src/commands/src/sender.rs b/src/commands/src/sender.rs deleted file mode 100644 index dc67e0be0..000000000 --- a/src/commands/src/sender.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Command senders. - -use bevy_ecs::prelude::*; -use temper_core::mq; -use temper_text::TextComponent; -use tracing::info; - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] -/// A possible command sender. -pub enum Sender { - /// A player has sent a command. - Player(Entity), - - /// The server console has sent a command. - Server, -} - -impl Sender { - /// Sends the given `message` to this sender, and to the action bar - /// if `actionbar` is true. - pub fn send_message(&self, message: TextComponent, actionbar: bool) { - match self { - Sender::Player(entity) => mq::queue(message, actionbar, *entity), - Sender::Server => { - info!("{}", message.to_plain_text()); // TODO: serialize into ANSI? - } - } - } -} diff --git a/src/components/src/player/bossbar_sender.rs b/src/components/src/player/bossbar_sender.rs index 6251e93cc..5d5f10061 100644 --- a/src/components/src/player/bossbar_sender.rs +++ b/src/components/src/player/bossbar_sender.rs @@ -23,7 +23,12 @@ impl BossbarSender { } pub fn update(&mut self, uuid: Uuid) { - self.0.insert(uuid, BossbarSenderState::Update); + if matches!( + self.0.get(&uuid), + Some(BossbarSenderState::Informed | BossbarSenderState::Update) + ) { + self.0.insert(uuid, BossbarSenderState::Update); + } } pub fn remove(&mut self, uuid: Uuid) { @@ -46,3 +51,30 @@ impl BossbarSender { self.0.get(&uuid).cloned() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_does_not_start_tracking_unknown_bossbar() { + let uuid = Uuid::new_v4(); + let mut sender = BossbarSender::default(); + + sender.update(uuid); + + assert_eq!(sender.get_state(uuid), None); + } + + #[test] + fn update_marks_informed_bossbar_for_update() { + let uuid = Uuid::new_v4(); + let mut sender = BossbarSender::default(); + + sender.add(uuid); + sender.informed(uuid); + sender.update(uuid); + + assert_eq!(sender.get_state(uuid), Some(BossbarSenderState::Update)); + } +} diff --git a/src/default_commands/Cargo.toml b/src/default_commands/Cargo.toml index fb4ec9061..df1cee1b2 100644 --- a/src/default_commands/Cargo.toml +++ b/src/default_commands/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "temper-default-commands" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] temper-messages = { workspace = true } temper-components = { workspace = true } -temper-commands = { workspace = true } +temper-command-infra = { workspace = true } temper-macros = { workspace = true } temper-text = { workspace = true } temper-core = { workspace = true } diff --git a/src/default_commands/src/bossbar.rs b/src/default_commands/src/bossbar.rs index daf4bd9a8..bb00505ec 100644 --- a/src/default_commands/src/bossbar.rs +++ b/src/default_commands/src/bossbar.rs @@ -1,307 +1,502 @@ -//! Bossbar Command System -//! -//! This module provides command handlers for creating, managing, and assigning bossbars -//! to players. Bossbars are stored in `BossBarResource` and synced to clients via -//! `BossbarSender`. -//! -//! --- -//! -//! ## Commands -//! -//! ### `bossbar add ` -//! Creates a new bossbar with the given display name. -//! Returns the generated UUID of the bossbar. -//! -//! --- -//! -//! ### `bossbar get ` -//! Retrieves information about a specific bossbar. -//! Prints its current state if it exists. -//! -//! --- -//! -//! ### `bossbar list` -//! Lists all currently existing bossbars by UUID. -//! -//! --- -//! -//! ### `bossbar remove ` -//! Deletes a bossbar from the system and stops tracking it. -//! -//! --- -//! -//! ### `bossbar set