Skip to main content

basalt_types/
identifier.rs

1use std::fmt;
2
3use crate::{Decode, Encode, EncodedSize, Error, Result, VarInt};
4
5/// A namespaced identifier in the format `namespace:path`.
6///
7/// Identifiers (also called ResourceLocations) are used throughout the
8/// Minecraft protocol to reference game content: blocks (`minecraft:stone`),
9/// items (`minecraft:diamond`), entities (`minecraft:creeper`), dimensions
10/// (`minecraft:overworld`), registries, and plugin channels. They are
11/// encoded on the wire as a single VarInt-prefixed UTF-8 string in the
12/// format `namespace:path`.
13///
14/// The namespace defaults to `minecraft` when absent. Valid characters are:
15/// - Namespace: `[a-z0-9._-]`
16/// - Path: `[a-z0-9._-/]`
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct Identifier {
19    /// The namespace part (e.g., `minecraft`, `mymod`).
20    pub namespace: String,
21    /// The path part (e.g., `stone`, `textures/block/dirt`).
22    pub path: String,
23}
24
25impl Identifier {
26    /// Creates a new identifier with the given namespace and path.
27    ///
28    /// Validates that both namespace and path contain only allowed characters.
29    /// Returns `Error::InvalidData` if validation fails.
30    pub fn new(namespace: impl Into<String>, path: impl Into<String>) -> Result<Self> {
31        let namespace = namespace.into();
32        let path = path.into();
33
34        if !namespace.chars().all(is_valid_namespace_char) {
35            return Err(Error::InvalidData(format!(
36                "invalid namespace character in '{namespace}'"
37            )));
38        }
39        if !path.chars().all(is_valid_path_char) {
40            return Err(Error::InvalidData(format!(
41                "invalid path character in '{path}'"
42            )));
43        }
44
45        Ok(Self { namespace, path })
46    }
47
48    /// Creates a new identifier under the `minecraft` namespace.
49    ///
50    /// This is a convenience for the most common case, since the majority
51    /// of identifiers in the protocol use the `minecraft` namespace.
52    pub fn minecraft(path: impl Into<String>) -> Result<Self> {
53        Self::new("minecraft", path)
54    }
55
56    /// Returns the full identifier string in `namespace:path` format.
57    ///
58    /// Note: this allocates a new `String`. For zero-cost display, use
59    /// the `Display` impl via `format!("{id}")` or `id.to_string()`.
60    #[deprecated(note = "use Display impl via to_string() instead")]
61    pub fn as_str(&self) -> String {
62        self.to_string()
63    }
64}
65
66/// Returns true if the character is valid in an identifier namespace.
67///
68/// Allowed characters: lowercase ASCII letters, digits, dots, underscores,
69/// and hyphens (`[a-z0-9._-]`).
70fn is_valid_namespace_char(c: char) -> bool {
71    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-'
72}
73
74/// Returns true if the character is valid in an identifier path.
75///
76/// Allowed characters: same as namespace plus forward slashes
77/// (`[a-z0-9._-/]`). Slashes enable hierarchical paths like
78/// `textures/block/dirt`.
79fn is_valid_path_char(c: char) -> bool {
80    is_valid_namespace_char(c) || c == '/'
81}
82
83/// Parses an identifier string in `namespace:path` or bare `path` format.
84///
85/// If no colon is present, the namespace defaults to `minecraft`, matching
86/// the Minecraft protocol convention. Returns `Error::InvalidData` if the
87/// string contains invalid characters or is empty.
88fn parse_identifier(s: &str) -> Result<Identifier> {
89    if s.is_empty() {
90        return Err(Error::InvalidData("empty identifier".into()));
91    }
92
93    let (namespace, path) = match s.find(':') {
94        Some(pos) => (&s[..pos], &s[pos + 1..]),
95        None => ("minecraft", s),
96    };
97
98    Identifier::new(namespace, path)
99}
100
101/// Encodes an Identifier as a VarInt-prefixed UTF-8 string in `namespace:path` format.
102///
103/// The full `namespace:path` string is written using the standard Minecraft
104/// string encoding (VarInt length prefix + UTF-8 bytes). This is the same
105/// wire format used for all string fields in the protocol.
106impl Encode for Identifier {
107    /// Writes the identifier as a VarInt-prefixed `namespace:path` string.
108    fn encode(&self, buf: &mut Vec<u8>) -> Result<()> {
109        self.to_string().encode(buf)
110    }
111}
112
113/// Decodes an Identifier from a VarInt-prefixed UTF-8 string.
114///
115/// Reads the string using the standard Minecraft string decoding, then
116/// parses it as `namespace:path`. If no colon is present, the namespace
117/// defaults to `minecraft`. Validates that all characters are in the
118/// allowed sets for namespace and path.
119impl Decode for Identifier {
120    /// Reads a protocol string and parses it as an identifier.
121    ///
122    /// Fails with `Error::InvalidData` if the identifier contains invalid
123    /// characters or is empty. Also inherits string decoding errors
124    /// (buffer underflow, string too long, invalid UTF-8).
125    fn decode(buf: &mut &[u8]) -> Result<Self> {
126        let s = String::decode(buf)?;
127        parse_identifier(&s)
128    }
129}
130
131/// Computes the wire size of the identifier in `namespace:path` format.
132///
133/// The total size includes the VarInt length prefix and the full
134/// `namespace:path` UTF-8 byte count (including the colon separator).
135impl EncodedSize for Identifier {
136    /// Returns the VarInt prefix size plus the byte length of `namespace:path`.
137    fn encoded_size(&self) -> usize {
138        let str_len = self.namespace.len() + 1 + self.path.len();
139        VarInt(str_len as i32).encoded_size() + str_len
140    }
141}
142
143/// Displays the identifier in `namespace:path` format.
144impl fmt::Display for Identifier {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(f, "{}:{}", self.namespace, self.path)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn roundtrip(namespace: &str, path: &str) {
155        let id = Identifier::new(namespace, path).unwrap();
156        let mut buf = Vec::with_capacity(id.encoded_size());
157        id.encode(&mut buf).unwrap();
158        assert_eq!(buf.len(), id.encoded_size());
159
160        let mut cursor = buf.as_slice();
161        let decoded = Identifier::decode(&mut cursor).unwrap();
162        assert!(cursor.is_empty());
163        assert_eq!(decoded, id);
164    }
165
166    // -- Construction --
167
168    #[test]
169    fn new_valid() {
170        let id = Identifier::new("minecraft", "stone").unwrap();
171        assert_eq!(id.namespace, "minecraft");
172        assert_eq!(id.path, "stone");
173    }
174
175    #[test]
176    fn minecraft_shorthand() {
177        let id = Identifier::minecraft("diamond").unwrap();
178        assert_eq!(id.namespace, "minecraft");
179        assert_eq!(id.path, "diamond");
180    }
181
182    #[test]
183    fn custom_namespace() {
184        let id = Identifier::new("mymod", "custom_block").unwrap();
185        assert_eq!(id.namespace, "mymod");
186        assert_eq!(id.path, "custom_block");
187    }
188
189    #[test]
190    fn path_with_slashes() {
191        let id = Identifier::new("minecraft", "textures/block/dirt").unwrap();
192        assert_eq!(id.path, "textures/block/dirt");
193    }
194
195    #[test]
196    fn invalid_namespace_uppercase() {
197        assert!(Identifier::new("Minecraft", "stone").is_err());
198    }
199
200    #[test]
201    fn invalid_namespace_space() {
202        assert!(Identifier::new("my mod", "stone").is_err());
203    }
204
205    #[test]
206    fn invalid_path_uppercase() {
207        assert!(Identifier::new("minecraft", "Stone").is_err());
208    }
209
210    #[test]
211    fn valid_special_chars() {
212        assert!(Identifier::new("my-mod.v2", "custom_item-v3").is_ok());
213    }
214
215    // -- Parsing --
216
217    #[test]
218    fn parse_with_namespace() {
219        let id = parse_identifier("minecraft:stone").unwrap();
220        assert_eq!(id.namespace, "minecraft");
221        assert_eq!(id.path, "stone");
222    }
223
224    #[test]
225    fn parse_without_namespace() {
226        let id = parse_identifier("stone").unwrap();
227        assert_eq!(id.namespace, "minecraft");
228        assert_eq!(id.path, "stone");
229    }
230
231    #[test]
232    fn parse_empty() {
233        assert!(parse_identifier("").is_err());
234    }
235
236    #[test]
237    fn parse_custom_namespace() {
238        let id = parse_identifier("mymod:custom_block").unwrap();
239        assert_eq!(id.namespace, "mymod");
240        assert_eq!(id.path, "custom_block");
241    }
242
243    // -- Encode/Decode --
244
245    #[test]
246    fn roundtrip_minecraft() {
247        roundtrip("minecraft", "stone");
248    }
249
250    #[test]
251    fn roundtrip_custom() {
252        roundtrip("mymod", "custom_block");
253    }
254
255    #[test]
256    fn roundtrip_with_path_slashes() {
257        roundtrip("minecraft", "textures/block/dirt");
258    }
259
260    #[test]
261    fn decode_bare_path() {
262        // Encode "stone" (no namespace) as a raw string
263        let mut buf = Vec::new();
264        "stone".to_string().encode(&mut buf).unwrap();
265
266        let mut cursor = buf.as_slice();
267        let id = Identifier::decode(&mut cursor).unwrap();
268        assert_eq!(id.namespace, "minecraft");
269        assert_eq!(id.path, "stone");
270    }
271
272    // -- Display --
273
274    #[test]
275    fn display() {
276        let id = Identifier::new("minecraft", "stone").unwrap();
277        assert_eq!(id.to_string(), "minecraft:stone");
278    }
279
280    #[test]
281    fn to_string_format() {
282        let id = Identifier::new("mymod", "item").unwrap();
283        assert_eq!(id.to_string(), "mymod:item");
284    }
285
286    // -- EncodedSize --
287
288    #[test]
289    fn encoded_size_includes_colon() {
290        let id = Identifier::new("minecraft", "stone").unwrap();
291        // "minecraft:stone" = 15 chars, VarInt(15) = 1 byte
292        assert_eq!(id.encoded_size(), 16);
293    }
294
295    mod proptests {
296        use super::*;
297        use proptest::prelude::*;
298
299        fn namespace_strategy() -> impl Strategy<Value = String> {
300            "[a-z0-9._\\-]{1,20}"
301        }
302
303        fn path_strategy() -> impl Strategy<Value = String> {
304            "[a-z0-9._\\-/]{1,50}"
305        }
306
307        proptest! {
308            #[test]
309            fn identifier_roundtrip(
310                namespace in namespace_strategy(),
311                path in path_strategy(),
312            ) {
313                roundtrip(&namespace, &path);
314            }
315        }
316    }
317}