use std::borrow::Cow; use std::marker::PhantomData; use std::path::Path; use bevy::prelude::*; use bevy::asset::{UntypedAssetId, VisitAssetDependencies}; use bevy::platform::collections::HashMap; use derive_where::derive_where; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Reflect, Deref)] #[derive_where(Deserialize, Serialize, Clone, PartialEq, Eq, Hash, Debug)] #[serde(try_from = "String", into = "String")] pub struct Identifier { #[deref] value: Cow<'static, str>, #[reflect(ignore)] identifier_type: PhantomData, } impl Identifier { pub const unsafe fn new_unsafe(value: Cow<'static, str>) -> Self { Self { value, identifier_type: PhantomData, } } pub fn new(value: impl Into) -> Result { let value = value.into(); Self::validate(&value)?; Ok(unsafe { Self::new_unsafe(Cow::Owned(value)) }) } pub const fn new_const(value: &'static str) -> Self { match Self::validate(&value) { Ok(_) => unsafe { Self::new_unsafe(Cow::Borrowed(value)) }, Err(_) => panic!("invalid identifier"), } } const fn validate(value: &str) -> Result<(), IdentifierParseError> { if value.is_empty() { return Err(IdentifierParseError::Empty); } if value.len() > 32 { return Err(IdentifierParseError::Length); } // if value.chars().any(|c| !c.is_ascii_lowercase()) // NOTE: This mess is to allow the function to be called in a const context. let bytes = value.as_bytes(); let mut i = 0; while i < value.len() { if let Some(c) = char::from_u32(bytes[i] as u32) { if c.is_ascii_lowercase() { i += 1; continue; } } return Err(IdentifierParseError::Invalid); } Ok(()) } } #[derive(Error, Debug)] pub enum IdentifierParseError { #[error("String is empty")] Empty, #[error("String is too long")] Length, #[error("String contains invalid characters")] Invalid, } impl std::fmt::Display for Identifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.value) } } impl From> for String { fn from(value: Identifier) -> Self { value.to_string() } } impl TryFrom for Identifier { type Error = IdentifierParseError; fn try_from(value: String) -> std::result::Result { Self::new(value) } } impl TryFrom<&Path> for Identifier { type Error = IdentifierParseError; fn try_from(value: &Path) -> Result { // SAFETY: Since this is an asset path, it should never fail. let file_name = value.file_name().unwrap(); // SAFETY: If the asset path contains invalid UTF-8, it's someone else's fault. let file_name_str = file_name.to_str().expect("invalid utf8"); let id = if let Some((prefix, _)) = file_name_str.split_once('.') { if prefix.is_empty() { panic!("asset file name starts with dot") } prefix } else { file_name_str }; Self::new(id) } } #[derive(Deserialize, Serialize, Reflect, Deref, DerefMut)] #[derive_where(Default)] pub struct IdentifierMap(HashMap, V>); impl Clone for IdentifierMap { fn clone(&self) -> Self { Self(self.0.clone()) } } impl VisitAssetDependencies for IdentifierMap> { fn visit_dependencies(&self, visit: &mut impl FnMut(UntypedAssetId)) { for dependency in self.values() { visit(dependency.id().untyped()); } } }