aboutsummaryrefslogtreecommitdiff
path: root/azalea-inventory/azalea-inventory-macros/src
diff options
context:
space:
mode:
authormat <27899617+mat-1@users.noreply.github.com>2023-05-03 20:57:27 -0500
committerGitHub <noreply@github.com>2023-05-03 20:57:27 -0500
commit634cb8d72c6608512aedba19e5cd669104bc35ea (patch)
treef8e76ce9eb43403d29cc0cbcf9a4f51522419dc2 /azalea-inventory/azalea-inventory-macros/src
parent1fb4418f2c9cbd004c64c2f23d2d0352ee12c0e5 (diff)
downloadazalea-drasl-634cb8d72c6608512aedba19e5cd669104bc35ea.tar.xz
Inventory (#48)
* start adding azalea-inventory * design more of how inventories are defined * start working on az-inv-macros * inventory macro works * start adding inventory codegen * update some deps * add inventory codegen * manually write inventory menus * put the inventories in Client * start on containersetcontent * inventory menu should hopefully work * checks in containersetcontent * format a comment * move some variant matches * inventory.rs * inventory stuff * more inventory stuff * inventory/container tracking works * start adding interact function * sequence number * start adding HitResultComponent * implement traverse_blocks * start adding clip * add clip function * update_hit_result_component * start trying to fix * fix * make some stuff simpler * clippy * lever * chest * container handle * fix ambiguity * fix some doc tests * move some container stuff from az-client to azalea * clicking container * start implementing simulate_click * keep working on simulate click * implement more of simulate_click this is really boring * inventory fixes * start implementing shift clicking * fix panic in azalea-chat i hope * shift clicking implemented * more inventory stuff * fix items not showing in containers sometimes * fix test * fix all warnings * remove a println --------- Co-authored-by: mat <git@matdoes.dev>
Diffstat (limited to 'azalea-inventory/azalea-inventory-macros/src')
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/lib.rs45
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/location_enum.rs59
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/menu_enum.rs70
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/menu_impl.rs448
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/parse_macro.rs69
-rw-r--r--azalea-inventory/azalea-inventory-macros/src/utils.rs54
6 files changed, 745 insertions, 0 deletions
diff --git a/azalea-inventory/azalea-inventory-macros/src/lib.rs b/azalea-inventory/azalea-inventory-macros/src/lib.rs
new file mode 100644
index 00000000..d3faa091
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/lib.rs
@@ -0,0 +1,45 @@
+mod location_enum;
+mod menu_enum;
+mod menu_impl;
+mod parse_macro;
+mod utils;
+
+use parse_macro::{DeclareMenus, Field};
+use proc_macro::TokenStream;
+use proc_macro2::Span;
+use quote::quote;
+use syn::{self, parse_macro_input, Ident};
+
+#[proc_macro]
+pub fn declare_menus(input: TokenStream) -> TokenStream {
+ let mut input = parse_macro_input!(input as DeclareMenus);
+
+ // implicitly add a `player` field at the end unless an `inventory` field
+ // is present
+ for menu in &mut input.menus {
+ let mut inventory_field_missing = true;
+ for field in &menu.fields {
+ if matches!(field.name.to_string().as_str(), "inventory" | "player") {
+ inventory_field_missing = false;
+ }
+ }
+ if inventory_field_missing {
+ menu.fields.push(Field {
+ name: Ident::new("player", Span::call_site()),
+ length: 36,
+ })
+ }
+ }
+
+ let menu_enum = menu_enum::generate(&input);
+ let menu_impl = menu_impl::generate(&input);
+ let location_enum = location_enum::generate(&input);
+
+ quote! {
+ #menu_enum
+ #menu_impl
+
+ #location_enum
+ }
+ .into()
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/location_enum.rs b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs
new file mode 100644
index 00000000..6cec88e6
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/location_enum.rs
@@ -0,0 +1,59 @@
+use crate::{parse_macro::DeclareMenus, utils::to_pascal_case};
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::Ident;
+
+pub fn generate(input: &DeclareMenus) -> TokenStream {
+ // pub enum MenuLocation {
+ // Player(PlayerMenuLocation),
+ // ...
+ // }
+ // pub enum PlayerMenuLocation {
+ // CraftResult,
+ // Craft,
+ // Armor,
+ // Inventory,
+ // Offhand,
+ // }
+ // ...
+
+ let mut menu_location_variants = quote! {};
+ let mut enums = quote! {};
+ for menu in &input.menus {
+ let name_snake_case = &menu.name;
+ let variant_name = Ident::new(
+ &to_pascal_case(&name_snake_case.to_string()),
+ name_snake_case.span(),
+ );
+ let enum_name = Ident::new(
+ &format!("{}MenuLocation", variant_name),
+ variant_name.span(),
+ );
+ menu_location_variants.extend(quote! {
+ #variant_name(#enum_name),
+ });
+ let mut individual_menu_location_variants = quote! {};
+ for field in &menu.fields {
+ let field_name = &field.name;
+ let variant_name =
+ Ident::new(&to_pascal_case(&field_name.to_string()), field_name.span());
+ individual_menu_location_variants.extend(quote! {
+ #variant_name,
+ });
+ }
+ enums.extend(quote! {
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+ pub enum #enum_name {
+ #individual_menu_location_variants
+ }
+ });
+ }
+
+ quote! {
+ pub enum MenuLocation {
+ #menu_location_variants
+ }
+
+ #enums
+ }
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs b/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs
new file mode 100644
index 00000000..a9e4f430
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/menu_enum.rs
@@ -0,0 +1,70 @@
+//! Generate the `enum menu` and nothing else. Implementations are in
+//! impl_menu.rs
+
+use crate::parse_macro::{DeclareMenus, Field, Menu};
+use proc_macro2::TokenStream;
+use quote::quote;
+
+pub fn generate(input: &DeclareMenus) -> TokenStream {
+ let mut variants = quote! {};
+ let mut player_fields = None;
+ for menu in &input.menus {
+ if menu.name == "Player" {
+ player_fields = Some(generate_fields(&menu.fields, true));
+ } else {
+ variants.extend(generate_variant_for_menu(menu));
+ }
+ }
+ let player_fields = player_fields.expect("Player variant must be present");
+
+ quote! {
+ #[derive(Clone, Debug, Default)]
+ pub struct Player {
+ #player_fields
+ }
+
+ /// A menu, which is a fixed collection of slots.
+ #[derive(Clone, Debug)]
+ pub enum Menu {
+ Player(Player),
+ #variants
+ }
+ }
+}
+
+/// Player {
+/// craft_result: ItemSlot,
+/// craft: [ItemSlot; 4],
+/// armor: [ItemSlot; 4],
+/// inventory: [ItemSlot; 36],
+/// offhand: ItemSlot,
+/// },
+fn generate_variant_for_menu(menu: &Menu) -> TokenStream {
+ let name = &menu.name;
+ let fields = generate_fields(&menu.fields, false);
+
+ quote! {
+ #name {
+ #fields
+ },
+ }
+}
+
+fn generate_fields(fields: &[Field], public: bool) -> TokenStream {
+ let mut generated_fields = quote! {};
+ for field in fields {
+ let field_length = field.length;
+ let field_type = if field.length == 1 {
+ quote! { ItemSlot }
+ } else {
+ quote! { SlotList<#field_length> }
+ };
+ let field_name = &field.name;
+ if public {
+ generated_fields.extend(quote! { pub #field_name: #field_type, })
+ } else {
+ generated_fields.extend(quote! { #field_name: #field_type, })
+ }
+ }
+ generated_fields
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs
new file mode 100644
index 00000000..804f69f2
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs
@@ -0,0 +1,448 @@
+use crate::{
+ parse_macro::{DeclareMenus, Menu},
+ utils::{to_pascal_case, to_snake_case},
+};
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::Ident;
+
+pub fn generate(input: &DeclareMenus) -> TokenStream {
+ let mut slot_mut_match_variants = quote! {};
+ let mut slot_match_variants = quote! {};
+ let mut len_match_variants = quote! {};
+ let mut kind_match_variants = quote! {};
+ let mut slots_match_variants = quote! {};
+ let mut contents_match_variants = quote! {};
+ let mut location_match_variants = quote! {};
+ let mut player_slots_range_match_variants = quote! {};
+
+ let mut player_consts = quote! {};
+ let mut menu_consts = quote! {};
+
+ let mut hotbar_slots_start = 0;
+ let mut hotbar_slots_end = 0;
+ let mut inventory_without_hotbar_slots_start = 0;
+ let mut inventory_without_hotbar_slots_end = 0;
+
+ for menu in &input.menus {
+ slot_mut_match_variants.extend(generate_match_variant_for_slot_mut(menu, true));
+ slot_match_variants.extend(generate_match_variant_for_slot_mut(menu, false));
+ len_match_variants.extend(generate_match_variant_for_len(menu));
+ kind_match_variants.extend(generate_match_variant_for_kind(menu));
+ slots_match_variants.extend(generate_match_variant_for_slots(menu));
+ contents_match_variants.extend(generate_match_variant_for_contents(menu));
+ location_match_variants.extend(generate_match_variant_for_location(menu));
+ player_slots_range_match_variants
+ .extend(generate_match_variant_for_player_slots_range(menu));
+
+ // this part is only used to generate `Player::is_hotbar_slot`
+ if menu.name == "Player" {
+ let mut i = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ let start = i;
+ i += field.length;
+ let end = i - 1;
+
+ if field_name == "inventory" {
+ // it only subtracts 8 here since it's inclusive (there's 9 total hotbar slots)
+ hotbar_slots_start = end - 8;
+ hotbar_slots_end = end;
+
+ inventory_without_hotbar_slots_start = start;
+ inventory_without_hotbar_slots_end = end - 9;
+ }
+
+ if start == end {
+ let const_name = Ident::new(
+ &format!("{}_SLOT", field_name.to_string().to_uppercase()),
+ field_name.span(),
+ );
+ player_consts.extend(quote! {
+ pub const #const_name: usize = #start;
+ });
+ } else {
+ let const_name = Ident::new(
+ &format!("{}_SLOTS", field_name.to_string().to_uppercase()),
+ field_name.span(),
+ );
+ player_consts.extend(quote! {
+ pub const #const_name: RangeInclusive<usize> = #start..=#end;
+ });
+ }
+ }
+ } else {
+ menu_consts.extend(generate_menu_consts(menu));
+ }
+ }
+
+ assert!(hotbar_slots_start != 0 && hotbar_slots_end != 0);
+ quote! {
+ impl Player {
+ pub const HOTBAR_SLOTS: RangeInclusive<usize> = #hotbar_slots_start..=#hotbar_slots_end;
+ pub const INVENTORY_WITHOUT_HOTBAR_SLOTS: RangeInclusive<usize> = #inventory_without_hotbar_slots_start..=#inventory_without_hotbar_slots_end;
+ #player_consts
+
+ /// Returns whether the given protocol index is in the player's hotbar.
+ ///
+ /// Equivalent to `Player::HOTBAR_SLOTS.contains(&i)`.
+ pub fn is_hotbar_slot(i: usize) -> bool {
+ Self::HOTBAR_SLOTS.contains(&i)
+ }
+ }
+
+ impl Menu {
+ #menu_consts
+
+ /// Get a mutable reference to the [`ItemSlot`] at the given protocol index.
+ ///
+ /// If you're trying to get an item in a menu without caring about
+ /// protocol indexes, you should just `match` it and index the
+ /// [`ItemSlot`] you get.
+ ///
+ /// Use [`Menu::slot`] if you don't need a mutable reference to the slot.
+ ///
+ /// # Errors
+ ///
+ /// Returns `None` if the index is out of bounds.
+ #[inline]
+ pub fn slot_mut(&mut self, i: usize) -> Option<&mut ItemSlot> {
+ Some(match self {
+ #slot_mut_match_variants
+ })
+ }
+
+ /// Get a reference to the [`ItemSlot`] at the given protocol index.
+ ///
+ /// If you're trying to get an item in a menu without caring about
+ /// protocol indexes, you should just `match` it and index the
+ /// [`ItemSlot`] you get.
+ ///
+ /// Use [`Menu::slot_mut`] if you need a mutable reference to the slot.
+ ///
+ /// # Errors
+ ///
+ /// Returns `None` if the index is out of bounds.
+ pub fn slot(&self, i: usize) -> Option<&ItemSlot> {
+ Some(match self {
+ #slot_match_variants
+ })
+ }
+
+ /// Returns the number of slots in the menu.
+ #[allow(clippy::len_without_is_empty)]
+ pub const fn len(&self) -> usize {
+ match self {
+ #len_match_variants
+ }
+ }
+
+ pub fn from_kind(kind: azalea_registry::MenuKind) -> Self {
+ match kind {
+ #kind_match_variants
+ }
+ }
+
+ /// Return the contents of the menu, including the player's inventory.
+ ///
+ /// The indexes in this will match up with [`Menu::slot_mut`].
+ ///
+ /// If you don't want to include the player's inventory, use [`Menu::contents`] instead.
+ pub fn slots(&self) -> Vec<ItemSlot> {
+ match self {
+ #slots_match_variants
+ }
+ }
+
+ /// Return the contents of the menu, not including the player's inventory.
+ ///
+ /// If you want to include the player's inventory, use [`Menu::slots`] instead.
+ pub fn contents(&self) -> Vec<ItemSlot> {
+ match self {
+ #contents_match_variants
+ }
+ }
+
+ pub fn location_for_slot(&self, i: usize) -> Option<MenuLocation> {
+ Some(match self {
+ #location_match_variants
+ })
+ }
+
+ /// Get the range of slot indexes that contain the player's inventory. This may be different for each menu.
+ pub fn player_slots_range(&self) -> RangeInclusive<usize> {
+ match self {
+ #player_slots_range_match_variants
+ }
+ }
+
+ /// Get the range of slot indexes that contain the player's hotbar. This may be different for each menu.
+ pub fn hotbar_slots_range(&self) -> RangeInclusive<usize> {
+ // hotbar is always last 9 slots in the player's inventory
+ ((*self.player_slots_range().end() - 8)..=*self.player_slots_range().end())
+ }
+
+ /// Get the range of slot indexes that contain the player's inventory, not including the hotbar. This may be different for each menu.
+ pub fn player_slots_without_hotbar_range(&self) -> RangeInclusive<usize> {
+ (*self.player_slots_range().start()..=*self.player_slots_range().end() - 9)
+ }
+
+ /// Returns whether the given index would be in the player's hotbar.
+ ///
+ /// Equivalent to `self.hotbar_slots_range().contains(&i)`.
+ pub fn is_hotbar_slot(&self, i: usize) -> bool {
+ self.hotbar_slots_range().contains(&i)
+ }
+ }
+ }
+}
+
+/// Menu::Player {
+/// craft_result,
+/// craft,
+/// armor,
+/// inventory,
+/// offhand,
+/// } => {
+/// match i {
+/// 0 => craft_result,
+/// 1..=4 => craft,
+/// 5..=8 => armor,
+/// // ...
+/// _ => return None,
+/// }
+/// } // ...
+pub fn generate_match_variant_for_slot_mut(menu: &Menu, mutable: bool) -> TokenStream {
+ let mut match_arms = quote! {};
+ let mut i = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ let start = i;
+ i += field.length;
+ let end = i - 1;
+ match_arms.extend(if start == end {
+ quote! { #start => #field_name, }
+ } else if start == 0 {
+ if mutable {
+ quote! { #start..=#end => &mut #field_name[i], }
+ } else {
+ quote! { #start..=#end => &#field_name[i], }
+ }
+ } else if mutable {
+ quote! { #start..=#end => &mut #field_name[i - #start], }
+ } else {
+ quote! { #start..=#end => &#field_name[i - #start], }
+ });
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ match i {
+ #match_arms
+ _ => return None
+ }
+ },
+ true,
+ )
+}
+
+pub fn generate_match_variant_for_len(menu: &Menu) -> TokenStream {
+ let length = menu.fields.iter().map(|f| f.length).sum::<usize>();
+ generate_matcher(
+ menu,
+ &quote! {
+ #length
+ },
+ false,
+ )
+}
+
+pub fn generate_match_variant_for_kind(menu: &Menu) -> TokenStream {
+ // azalea_registry::MenuKind::Generic9x3 => Menu::Generic9x3 { contents:
+ // Default::default(), player: Default::default() },
+
+ let menu_name = &menu.name;
+ let menu_field_names = if menu.name == "Player" {
+ // player isn't in MenuKind
+ return quote! {};
+ } else {
+ let mut menu_field_names = quote! {};
+ for field in &menu.fields {
+ let field_name = &field.name;
+ menu_field_names.extend(quote! { #field_name: Default::default(), })
+ }
+ quote! { { #menu_field_names } }
+ };
+
+ quote! {
+ azalea_registry::MenuKind::#menu_name => Menu::#menu_name #menu_field_names,
+ }
+}
+
+pub fn generate_match_variant_for_slots(menu: &Menu) -> TokenStream {
+ let mut instructions = quote! {};
+ let mut length = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ instructions.extend(if field.length == 1 {
+ quote! { items.push(#field_name.clone()); }
+ } else {
+ quote! { items.extend(#field_name.iter().cloned()); }
+ });
+ length += field.length;
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ let mut items = Vec::with_capacity(#length);
+ #instructions
+ items
+ },
+ true,
+ )
+}
+
+pub fn generate_match_variant_for_contents(menu: &Menu) -> TokenStream {
+ let mut instructions = quote! {};
+ let mut length = 0;
+ for field in &menu.fields {
+ let field_name = &field.name;
+ if field_name == "player" {
+ continue;
+ }
+ instructions.extend(if field.length == 1 {
+ quote! { items.push(#field_name.clone()); }
+ } else {
+ quote! { items.extend(#field_name.iter().cloned()); }
+ });
+ length += field.length;
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ let mut items = Vec::with_capacity(#length);
+ #instructions
+ items
+ },
+ true,
+ )
+}
+
+pub fn generate_match_variant_for_location(menu: &Menu) -> TokenStream {
+ let mut match_arms = quote! {};
+ let mut i = 0;
+
+ let menu_name = Ident::new(&to_pascal_case(&menu.name.to_string()), menu.name.span());
+ let menu_enum_name = Ident::new(&format!("{menu_name}MenuLocation"), menu_name.span());
+
+ for field in &menu.fields {
+ let field_name = Ident::new(&to_pascal_case(&field.name.to_string()), field.name.span());
+ let start = i;
+ i += field.length;
+ let end = i - 1;
+ match_arms.extend(if start == end {
+ quote! { #start => #menu_enum_name::#field_name, }
+ } else {
+ quote! { #start..=#end => #menu_enum_name::#field_name, }
+ });
+ }
+
+ generate_matcher(
+ menu,
+ &quote! {
+ MenuLocation::#menu_name(match i {
+ #match_arms
+ _ => return None
+ })
+ },
+ false,
+ )
+}
+
+pub fn generate_match_variant_for_player_slots_range(menu: &Menu) -> TokenStream {
+ // Menu::Player(Player { .. }) => Player::INVENTORY_SLOTS_RANGE,,
+ // Menu::Generic9x3 { .. } => Menu::GENERIC9X3_SLOTS_RANGE,
+ // ..
+
+ match menu.name.to_string().as_str() {
+ "Player" => {
+ quote! {
+ Menu::Player(Player { .. }) => Player::INVENTORY_SLOTS,
+ }
+ }
+ _ => {
+ let menu_name = &menu.name;
+ let menu_slots_range_name = Ident::new(
+ &format!(
+ "{}_PLAYER_SLOTS",
+ to_snake_case(&menu.name.to_string()).to_uppercase()
+ ),
+ menu.name.span(),
+ );
+ quote! {
+ Menu::#menu_name { .. } => Menu::#menu_slots_range_name,
+ }
+ }
+ }
+}
+
+fn generate_menu_consts(menu: &Menu) -> TokenStream {
+ let mut menu_consts = quote! {};
+
+ let mut i = 0;
+
+ for field in &menu.fields {
+ let field_name_start = format!(
+ "{}_{}",
+ to_snake_case(&menu.name.to_string()).to_uppercase(),
+ to_snake_case(&field.name.to_string()).to_uppercase()
+ );
+ let field_index_start = i;
+ i += field.length;
+ let field_index_end = i - 1;
+
+ if field.length == 1 {
+ let field_name = Ident::new(
+ format!("{}_SLOT", field_name_start).as_str(),
+ field.name.span(),
+ );
+ menu_consts.extend(quote! { pub const #field_name: usize = #field_index_start; });
+ } else {
+ let field_name = Ident::new(
+ format!("{}_SLOTS", field_name_start).as_str(),
+ field.name.span(),
+ );
+ menu_consts.extend(quote! { pub const #field_name: RangeInclusive<usize> = #field_index_start..=#field_index_end; });
+ }
+ }
+
+ menu_consts
+}
+
+pub fn generate_matcher(menu: &Menu, match_arms: &TokenStream, needs_fields: bool) -> TokenStream {
+ let menu_name = &menu.name;
+ let menu_field_names = if needs_fields {
+ let mut menu_field_names = quote! {};
+ for field in &menu.fields {
+ let field_name = &field.name;
+ menu_field_names.extend(quote! { #field_name, })
+ }
+ menu_field_names
+ } else {
+ quote! { .. }
+ };
+
+ let matcher = if menu.name == "Player" {
+ quote! { (Player { #menu_field_names }) }
+ } else {
+ quote! { { #menu_field_names } }
+ };
+ quote! {
+ Menu::#menu_name #matcher => {
+ #match_arms
+ },
+ }
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs b/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs
new file mode 100644
index 00000000..8eada4ec
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/parse_macro.rs
@@ -0,0 +1,69 @@
+use syn::{
+ self, braced,
+ parse::{Parse, ParseStream, Result},
+ Ident, LitInt, Token,
+};
+
+/// An identifier, colon, and number
+/// `craft_result: 1`
+pub struct Field {
+ pub name: Ident,
+ pub length: usize,
+}
+impl Parse for Field {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let name = input.parse::<Ident>()?;
+ let _ = input.parse::<Token![:]>()?;
+ let length = input.parse::<LitInt>()?.base10_parse()?;
+ Ok(Self { name, length })
+ }
+}
+
+/// An identifier and a list of `Field` in curly brackets
+/// ```rust,ignore
+/// Player {
+/// craft_result: 1,
+/// ...
+/// }
+/// ```
+pub struct Menu {
+ /// The menu name, e.g. `Player`
+ pub name: Ident,
+ pub fields: Vec<Field>,
+}
+
+impl Parse for Menu {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let name = input.parse::<Ident>()?;
+
+ let content;
+ braced!(content in input);
+ let fields = content
+ .parse_terminated::<Field, Token![,]>(Field::parse)?
+ .into_iter()
+ .collect();
+
+ Ok(Self { name, fields })
+ }
+}
+
+/// A list of `Menu`s
+/// ```rust,ignore
+/// Player {
+/// craft_result: 1,
+/// ...
+/// },
+/// ...
+/// ```
+pub struct DeclareMenus {
+ pub menus: Vec<Menu>,
+}
+impl Parse for DeclareMenus {
+ fn parse(input: ParseStream) -> Result<Self> {
+ let menus = input
+ .parse_terminated::<Menu, Token![,]>(Menu::parse)?
+ .into_iter()
+ .collect();
+ Ok(Self { menus })
+ }
+}
diff --git a/azalea-inventory/azalea-inventory-macros/src/utils.rs b/azalea-inventory/azalea-inventory-macros/src/utils.rs
new file mode 100644
index 00000000..568c9f71
--- /dev/null
+++ b/azalea-inventory/azalea-inventory-macros/src/utils.rs
@@ -0,0 +1,54 @@
+pub fn to_pascal_case(s: &str) -> String {
+ // we get the first item later so this is to make it impossible for that
+ // to error
+ if s.is_empty() {
+ return String::new();
+ }
+
+ let mut result = String::new();
+ let mut prev_was_underscore = true; // set to true by default so the first character is capitalized
+ if s.chars().next().unwrap().is_numeric() {
+ result.push('_');
+ }
+ for c in s.chars() {
+ if c == '_' {
+ prev_was_underscore = true;
+ } else if prev_was_underscore {
+ result.push(c.to_ascii_uppercase());
+ prev_was_underscore = false;
+ } else {
+ result.push(c);
+ }
+ }
+ result
+}
+
+pub fn to_snake_case(s: &str) -> String {
+ let mut result = String::new();
+ let mut prev_was_uppercase = true;
+ for c in s.chars() {
+ if c.is_ascii_uppercase() {
+ if !prev_was_uppercase {
+ result.push('_');
+ }
+ result.push(c.to_ascii_lowercase());
+ prev_was_uppercase = true;
+ } else {
+ result.push(c);
+ prev_was_uppercase = false;
+ }
+ }
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_snake_case() {
+ assert_eq!(to_snake_case("HelloWorld"), "hello_world");
+ assert_eq!(to_snake_case("helloWorld"), "hello_world");
+ assert_eq!(to_snake_case("hello_world"), "hello_world");
+ }
+}