From fa833ba439b26c1b0a72f1ac8a0e32bff5968765 Mon Sep 17 00:00:00 2001 From: Laptop59 <90901102+Laptop59@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:33:16 +0530 Subject: [PATCH] added identifier and its argument type (#2000) --- pumpkin-util/src/identifier.rs | 419 ++++++++++++++++++ pumpkin-util/src/lib.rs | 1 + .../src/command/argument_types/identifier.rs | 29 ++ pumpkin/src/command/argument_types/mod.rs | 26 ++ 4 files changed, 475 insertions(+) create mode 100644 pumpkin-util/src/identifier.rs create mode 100644 pumpkin/src/command/argument_types/identifier.rs diff --git a/pumpkin-util/src/identifier.rs b/pumpkin-util/src/identifier.rs new file mode 100644 index 000000000..c21a5d769 --- /dev/null +++ b/pumpkin-util/src/identifier.rs @@ -0,0 +1,419 @@ +use std::{borrow::Cow, fmt::Display}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const VANILLA_NAMESPACE: &str = "minecraft"; +pub const PUMPKIN_NAMESPACE: &str = "pumpkin"; + +/// An immutable structure that identifies a particular resource, +/// which is possibly heap-allocated. +/// They are expressed in the form `:`. +/// +/// The namespace may only contain: +/// - digits `[0-9]` +/// - lowercase letters `[a-z]` +/// - periods `.` +/// - underscores `_` +/// - hyphens `-` +/// +/// The path allows all the characters that the namespace does, but +/// with the addition of forward slashes `/` (path separator). +/// +/// If an identifier is specified without a colon and namespace, +/// i.e. just ``, then the namespace is assumed +/// to be `minecraft`. +/// +/// # Implementation Note +/// +/// Namespace and path are stored separately, internally +/// as a `Cow<'static, str>`. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +pub struct Identifier { + namespace: Cow<'static, str>, + path: Cow<'static, str>, +} + +/// Represents an error arising from +/// trying to create/parse an identifier. +#[derive(Clone, Debug, Error)] +pub enum IdentifierError { + #[error("Invalid character in namespace of identifier: {0}")] + InvalidNamespace(Identifier), + + #[error("Invalid character in path of identifier: {0}")] + InvalidPath(Identifier), +} + +/// Represents a result of an attempt to create an [`Identifier`]. +pub type IdentifierCreationResult = Result; + +impl Identifier { + /// Tries to create a new [`Identifier`] from both a specified namespace and path. + pub fn new( + namespace: impl Into>, + path: impl Into>, + ) -> IdentifierCreationResult { + let namespace = namespace.into(); + let path = path.into(); + + let identifier = Self { namespace, path }; + + Self::validate_identifier(identifier) + } + + /// Creates a new [`Identifier`] from both a specified namespace and path + /// at compile-time. + /// + /// The identifier returned is guaranteed **NOT to be heap allocated**. + /// + /// # Panics + /// + /// Panics if either the provided namespace or provided path is invalid. + #[must_use] + pub const fn from_static(namespace: &'static str, path: &'static str) -> Self { + assert!( + Self::is_valid_namespace(namespace), + "Invalid namespace provided" + ); + assert!(Self::is_valid_path(path), "Invalid path provided"); + + Self { + namespace: Cow::Borrowed(namespace), + path: Cow::Borrowed(path), + } + } + + /// Attempts to parse an identifier from a given string. + /// + /// The identifier returned is heap allocated. + pub fn parse(identifier: &str) -> IdentifierCreationResult { + identifier.bytes().position(|b| b == b':').map_or_else( + || Self::new(VANILLA_NAMESPACE, identifier.to_string()), + |colon_i| { + // Colon exists. + let path = identifier[colon_i + 1..].to_string(); + + if colon_i == 0 { + Self::new(VANILLA_NAMESPACE, path) + } else { + let namespace = identifier[0..colon_i].to_string(); + Self::new(namespace, path) + } + }, + ) + } + + /// Attempts to parse an identifier from a given string at compile-time. + /// + /// The identifier returned is guaranteed **NOT to be heap allocated**. + #[must_use] + pub const fn parse_static(identifier: &'static str) -> Self { + let bytes = identifier.as_bytes(); + let mut colon_i = 0; + + while colon_i < bytes.len() { + if bytes[colon_i] == b':' { + break; + } + colon_i += 1; + } + + if colon_i < bytes.len() { + // Colon exists. + // We are forced to use unsafe code in a const + // as Index trait is not const. + + let path = unsafe { + // SAFETY: The given start and end is valid. + // `colon_i` is at a ':', which is a one-byte ASCII character (0x3A) + // This means that colon_i + 1 is a valid boundary. + // Rust guarantees that `identifier` is a valid UTF-8 string. + Self::slice_bytes_to_str_unchecked(bytes, colon_i + 1, bytes.len()) + }; + + if colon_i == 0 { + Self::from_static(VANILLA_NAMESPACE, path) + } else { + let namespace = unsafe { + // SAFETY: The given start and end is valid. + // `colon_i` is at a ':', which is a one-byte ASCII character (0x3A) + // This means that colon_i is a valid boundary. + // Rust guarantees that `identifier` is a valid UTF-8 string. + Self::slice_bytes_to_str_unchecked(bytes, 0, colon_i) + }; + Self::from_static(namespace, path) + } + } else { + Self::from_static(VANILLA_NAMESPACE, identifier) + } + } + + /// Unsafe function to slice bytes into a `&str` at valid positions. + /// We do this as `std::ops::Index` is not yet stable as a const trait. + /// + /// `start` and `end` are expressed in bytes, acting as indices for `bytes`. + /// + /// # Safety + /// Undefined behavior occurs if any one of the following conditions are violated: + /// - `start <= end <= bytes.len()` + /// - `start` and `end` both lie on UTF-8 character boundaries. + /// - the subslice `bytes[start..end]` is valid UTF-8. + #[must_use] + const unsafe fn slice_bytes_to_str_unchecked(bytes: &[u8], start: usize, end: usize) -> &str { + unsafe { + core::str::from_utf8_unchecked(core::slice::from_raw_parts( + bytes.as_ptr().add(start), + end - start, + )) + } + } + + /// Tries to create a new [`Identifier`] that has a namespace of `minecraft`. + pub fn vanilla(path: impl Into>) -> IdentifierCreationResult { + Self::new(VANILLA_NAMESPACE, path) + } + + /// Tries to create a new [`Identifier`] that has a namespace of `minecraft` at compile-time. + /// + /// # Panics + /// + /// Panics if the provided path is invalid. + #[must_use] + pub const fn vanilla_static(path: &'static str) -> Self { + Self::from_static(VANILLA_NAMESPACE, path) + } + + /// Creates a new [`Identifier`] that has a namespace of `pumpkin`. + pub fn pumpkin(path: impl Into>) -> IdentifierCreationResult { + Self::new(PUMPKIN_NAMESPACE, path) + } + + /// Creates a new [`Identifier`] that has a namespace of `pumpkin` at compile-time. + /// + /// # Panics + /// + /// Panics if the provided path is invalid. + #[must_use] + pub const fn pumpkin_static(path: &'static str) -> Self { + Self::from_static(PUMPKIN_NAMESPACE, path) + } + + /// Consumes this identifier to replace its current path with the specified path. + pub fn with_path(self, path: impl Into>) -> IdentifierCreationResult { + Self::validate_identifier_path(Self { + namespace: self.namespace, + path: path.into(), + }) + } + + /// Consumes this identifier to add a prefix to the current path. + pub fn prefix_path(self, prefix: &str) -> IdentifierCreationResult { + Self::validate_identifier_path(Self { + namespace: self.namespace, + path: format!("{prefix}{}", self.path).into(), + }) + } + + /// Consumes this identifier to add a suffix to the current path. + pub fn suffix_path(self, suffix: &str) -> IdentifierCreationResult { + Self::validate_identifier_path(Self { + namespace: self.namespace, + path: format!("{}{suffix}", self.path).into(), + }) + } + + /// Consumes this identifier to create a new one by mapping its path through the given function. + pub fn map_path(self, f: F) -> IdentifierCreationResult + where + F: FnOnce(&str) -> String, + { + Self::validate_identifier_path(Self { + namespace: self.namespace, + path: f(&self.path).into(), + }) + } + + /// Gets the namespace of this [`Identifier`]. + #[must_use] + #[inline] + pub fn namespace(&self) -> &str { + &self.namespace + } + + /// Gets the path of this [`Identifier`]. + #[must_use] + #[inline] + pub fn path(&self) -> &str { + &self.path + } + + fn validate_identifier(identifier: Self) -> IdentifierCreationResult { + if !Self::is_valid_namespace(&identifier.namespace) { + return Err(IdentifierError::InvalidNamespace(identifier)); + } + if !Self::is_valid_path(&identifier.path) { + return Err(IdentifierError::InvalidPath(identifier)); + } + Ok(identifier) + } + + fn validate_identifier_path(identifier: Self) -> IdentifierCreationResult { + if !Self::is_valid_path(&identifier.path) { + return Err(IdentifierError::InvalidPath(identifier)); + } + Ok(identifier) + } + + /// Returns whether the given namespace would be valid if + /// used in an identifier. + #[must_use] + pub const fn is_valid_namespace(namespace: &str) -> bool { + // We have to use a manual loop so that the function + // can be marked as a `const` function. + let bytes = namespace.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !matches!(bytes[i], b'0'..=b'9' | b'a'..=b'z' | b'-' | b'_' | b'.') { + return false; + } + i += 1; + } + true + } + + /// Returns whether the given path would be valid if + /// used in an identifier. + #[must_use] + pub const fn is_valid_path(path: &str) -> bool { + // We have to use a manual loop so that the function + // can be marked as a `const` function. + let bytes = path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !matches!(bytes[i], b'0'..=b'9' | b'a'..=b'z' | b'-' | b'_' | b'.' | b'/') { + return false; + } + i += 1; + } + true + } + + /// Returns whether the given character could be valid in an identifier. + #[must_use] + pub const fn is_valid_char(c: char) -> bool { + matches!(c, '0'..='9' | 'a'..='z' | '-' | '_' | '.' | '/' | ':') + } + + /// Gets a tuple of references to the internal strings of the + /// namespace and path. + #[must_use] + pub fn view(&self) -> (&str, &str) { + (&self.namespace, &self.path) + } + + /// Returns whether this identifier is a `minecraft:` prefixed one. + #[must_use] + pub fn is_vanilla(&self) -> bool { + self.namespace() == VANILLA_NAMESPACE + } + + /// Returns whether this identifier is a `pumpkin:` prefixed one. + #[must_use] + pub fn is_pumpkin(&self) -> bool { + self.namespace() == PUMPKIN_NAMESPACE + } + + /// If this identifier is a `minecraft:` prefixed one, it returns + /// a [`Some`] containing the path of this identifier. Otherwise, + /// a [`None`] is returned. + #[must_use] + pub fn is_vanilla_then(&self) -> Option<&str> { + self.is_vanilla().then_some(&self.path) + } + + /// If this identifier is a `pumpkin:` prefixed one, it returns + /// a [`Some`] containing the path of this identifier. Otherwise, + /// a [`None`] is returned. + #[must_use] + pub fn is_pumpkin_then(&self) -> Option<&str> { + self.is_pumpkin().then_some(&self.path) + } +} + +impl TryFrom<&str> for Identifier { + type Error = IdentifierError; + + fn try_from(value: &str) -> IdentifierCreationResult { + Self::parse(value) + } +} + +impl TryFrom<&String> for Identifier { + type Error = IdentifierError; + + fn try_from(value: &String) -> IdentifierCreationResult { + Self::parse(value) + } +} + +impl Display for Identifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.namespace, self.path) + } +} + +impl Serialize for Identifier { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Identifier { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let identifier_string = String::deserialize(deserializer)?; + Self::parse(&identifier_string).map_err(|error| serde::de::Error::custom(error.to_string())) + } +} + +#[cfg(test)] +mod test { + use crate::identifier::{Identifier, IdentifierError}; + + #[test] + fn new() -> Result<(), IdentifierError> { + assert_eq!(Identifier::new("abc", "def")?.to_string(), "abc:def"); + assert_eq!(Identifier::from_static("abc", "def").to_string(), "abc:def"); + + Ok(()) + } + + #[test] + fn parse() -> Result<(), IdentifierError> { + assert_eq!(Identifier::parse("abc")?.to_string(), "minecraft:abc"); + assert_eq!(Identifier::parse("abc:def")?.to_string(), "abc:def"); + assert_eq!(Identifier::parse("abc:")?.to_string(), "abc:"); + assert_eq!(Identifier::parse(":def")?.to_string(), "minecraft:def"); + assert_eq!(Identifier::parse(":")?.to_string(), "minecraft:"); + + assert_eq!(Identifier::parse_static("abc").to_string(), "minecraft:abc"); + assert_eq!(Identifier::parse_static("abc:def").to_string(), "abc:def"); + + let _ = Identifier::parse("")?; + let _ = Identifier::parse("abc:/4/5")?; + let _ = Identifier::parse("a._b-c:/4_-/5.9")?; + + assert!(Identifier::parse("::").is_err()); + assert!(Identifier::parse("a:b:c").is_err()); + assert!(Identifier::parse("he/llo:bye").is_err()); + assert!(Identifier::parse("1234+567:89").is_err()); + + Ok(()) + } +} diff --git a/pumpkin-util/src/lib.rs b/pumpkin-util/src/lib.rs index b7b9e2388..438f02ace 100644 --- a/pumpkin-util/src/lib.rs +++ b/pumpkin-util/src/lib.rs @@ -26,6 +26,7 @@ pub mod version; pub mod world_seed; pub mod y_offset; +pub mod identifier; pub mod jwt; /// Represents the different types of height maps used for terrain generation and collision checks. diff --git a/pumpkin/src/command/argument_types/identifier.rs b/pumpkin/src/command/argument_types/identifier.rs new file mode 100644 index 000000000..5adb34c9a --- /dev/null +++ b/pumpkin/src/command/argument_types/identifier.rs @@ -0,0 +1,29 @@ +use pumpkin_util::identifier::Identifier; + +use crate::command::{ + argument_types::{ + FromStringReader, + argument_type::{ArgumentType, JavaClientArgumentType}, + }, + errors::command_syntax_error::CommandSyntaxError, + string_reader::StringReader, +}; + +/// An argument type that parses a generic [`Identifier`] with a namespace and path. +pub struct IdentifierArgumentType; + +impl ArgumentType for IdentifierArgumentType { + type Item = Identifier; + + fn parse(&self, reader: &mut StringReader) -> Result { + Identifier::from_reader(reader) + } + + fn client_side_parser(&'_ self) -> JavaClientArgumentType<'_> { + JavaClientArgumentType::ResourceLocation + } + + fn examples(&self) -> Vec { + examples!("foo", "foo:bar") + } +} diff --git a/pumpkin/src/command/argument_types/mod.rs b/pumpkin/src/command/argument_types/mod.rs index d0318ded7..923c89eed 100644 --- a/pumpkin/src/command/argument_types/mod.rs +++ b/pumpkin/src/command/argument_types/mod.rs @@ -4,6 +4,7 @@ use crate::command::errors::error_types::{ }; use crate::command::string_reader::StringReader; use pumpkin_data::translation; +use pumpkin_util::identifier::Identifier; use pumpkin_util::math::bounds::{Bounds, DoubleBounds, FloatDegreeBounds, IntBounds}; use pumpkin_util::text::TextComponent; use std::str::FromStr; @@ -67,6 +68,8 @@ const EMPTY_BOUNDS_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new(translation::ARGUMENT_RANGE_EMPTY); const SWAPPED_BOUNDS_ERROR_TYPE: CommandErrorType<0> = CommandErrorType::new(translation::ARGUMENT_RANGE_SWAPPED); +const INVALID_IDENTIFIER_ERROR_TYPE: CommandErrorType<0> = + CommandErrorType::new(translation::ARGUMENT_ID_INVALID); /// A trait to try getting a value from a [`StringReader`]. pub trait FromStringReader: Sized { @@ -185,11 +188,34 @@ impl FromStringReader for FloatDegreeBounds { } } +impl FromStringReader for Identifier { + fn from_reader(reader: &mut StringReader) -> Result { + let start = reader.cursor(); + while let Some(c) = reader.peek() + && Self::is_valid_char(c) + { + reader.skip(); + } + + let raw_identifier = &reader.string()[start..reader.cursor()]; + let identifier_result = Self::parse(raw_identifier); + + identifier_result.map_or_else( + |_| { + reader.set_cursor(start); + Err(INVALID_IDENTIFIER_ERROR_TYPE.create(reader)) + }, + Ok, + ) + } +} + pub mod argument_type; pub mod coordinates; pub mod core; pub mod entity; pub mod entity_selector; pub mod game_profile; +pub mod identifier; pub mod range; pub mod time;