// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using EpicGames.Core; namespace EpicGames.Horde { /// /// Normalized string identifier for a resource /// [JsonSchemaString] [JsonConverter(typeof(StringIdJsonConverter))] [TypeConverter(typeof(StringIdTypeConverter))] public readonly struct StringId : IEquatable, IEquatable, IEquatable> { /// /// Enum used to disable validation on string arguments /// public enum Validate { /// /// No validation required /// None, }; /// /// Maximum length for a string id /// public const int MaxLength = 64; /// /// The text representing this id /// public Utf8String Text { get; } /// /// Accessor for the string bytes /// public ReadOnlySpan Span => Text.Span; /// /// Accessor for the string bytes /// public ReadOnlyMemory Memory => Text.Memory; /// /// Constructor /// /// Unique id for the string public StringId(string text) : this(new Utf8String(text)) { } /// /// Constructor /// /// Unique id for the string public StringId(Utf8String text) { StringId id; if (!TryParse(text, out id, out string? errorMessage)) { throw new ArgumentException(errorMessage, nameof(text)); } Text = id.Text; } /// /// Constructor /// /// Unique id for the string /// Argument used for overload resolution for pre-validated strings [SuppressMessage("Style", "IDE0060:Remove unused parameter")] public StringId(Utf8String text, Validate validate) { Text = text; } /// /// Checks whether this StringId is set /// public bool IsEmpty => Text.IsEmpty; /// /// Generates a new string id from the given text /// /// Text to generate from /// New string id public static StringId Sanitize(string text) { StringBuilder result = new StringBuilder(); for (int idx = 0; idx < text.Length && result.Length < MaxLength; idx++) { char character = (char)text[idx]; if (character >= 'A' && character <= 'Z') { result.Append((char)('a' + (character - 'A'))); } else if (character == '.') { if (result.Length > 0) { if (result[^1] == '-') { result[^1] = character; } else { result.Append(character); } } } else if (IsValidCharacter(character)) { result.Append(character); } else if (result.Length > 0 && result[^1] != '-' && result[^1] != '.') { result.Append('-'); } } while (result.Length > 0 && (result[^1] == '-' || result[^1] == '.')) { result.Remove(result.Length - 1, 1); } return new StringId(new Utf8String(result.ToString()), Validate.None); } /// /// Validates the given string as a StringId, normalizing it if necessary. /// /// Text to validate as a StringId /// Parsed identifier /// True if the identifier was parsed public static bool TryParse(string text, out StringId id) => TryParse(new Utf8String(text), out id, out _); /// /// Validates the given string as a StringId, normalizing it if necessary. /// /// Text to validate as a StringId /// Parsed identifier /// Error message if the id cannot be parsed /// True if the identifier was parsed public static bool TryParse(string text, out StringId id, [NotNullWhen(false)] out string? errorMessage) => TryParse(new Utf8String(text), out id, out errorMessage); /// /// Validates the given string as a StringId, normalizing it if necessary. /// /// Text to validate as a StringId /// Parsed identifier /// True if the identifier was parsed public static bool TryParse(Utf8String text, out StringId id) => TryParse(text, out id, out _); /// /// Validates the given string as a StringId, normalizing it if necessary. /// /// Text to validate as a StringId /// Parsed identifier /// Error message if the id cannot be parsed /// True if the identifier was parsed public static bool TryParse(Utf8String text, out StringId id, [NotNullWhen(false)] out string? errorMessage) { if (text.Length > MaxLength) { id = default; errorMessage = $"Id '{text}' is longer than {MaxLength} characters"; return false; } if (text.Length > 0 && (text[0] == '.' || text[^1] == '.')) { id = default; errorMessage = $"'{text}' is not a valid id (cannot start or end with a period)"; return false; } for (int idx = 0; idx < text.Length; idx++) { byte character = text[idx]; if (!IsValidCharacter(character)) { if (character >= 'A' && character <= 'Z') { text = ToLower(text); } else { id = default; errorMessage = $"'{text}' is not a valid id (character '{(char)character}' is not allowed)"; return false; } } } id = new StringId(text, Validate.None); errorMessage = null; return true; } /// /// Converts a utf8 string to lowercase /// /// /// static Utf8String ToLower(Utf8String text) { byte[] output = new byte[text.Length]; for (int idx = 0; idx < text.Length; idx++) { byte character = text[idx]; if (character >= 'A' && character <= 'Z') { character = (byte)((character - 'A') + 'a'); } output[idx] = character; } return new Utf8String(output); } /// /// Checks whether the given character is valid within a string id /// /// The character to check /// True if the character is valid public static bool IsValidCharacter(char character) => character <= 0x7f && IsValidCharacter((byte)character); /// /// Checks whether the given character is valid within a string id /// /// The character to check /// True if the character is valid public static bool IsValidCharacter(byte character) { if (character >= 'a' && character <= 'z') { return true; } if (character >= '0' && character <= '9') { return true; } if (character == '-' || character == '_' || character == '.') { return true; } return false; } /// public override bool Equals(object? obj) => obj is StringId id && Equals(id); /// public override int GetHashCode() => Text.GetHashCode(); /// public bool Equals(StringId other) => Text.Equals(other.Text); /// public bool Equals(string? other) => other != null && Equals(other.AsMemory()); /// public bool Equals(ReadOnlyMemory other) { ReadOnlySpan span = other.Span; if (span.Length != Text.Length) { return false; } for (int idx = 0; idx < Text.Length; idx++) { if (span[idx] != Text[idx]) { return false; } } return true; } /// public override string ToString() => Text.ToString(); /// /// Compares two string ids for equality /// /// The first string id /// Second string id /// True if the two string ids are equal public static bool operator ==(StringId left, StringId right) => left.Equals(right); /// /// Compares two string ids for inequality /// /// The first string id /// Second string id /// True if the two string ids are not equal public static bool operator !=(StringId left, StringId right) => !left.Equals(right); } /// /// Class which serializes types /// sealed class StringIdJsonConverter : JsonConverter { /// public override StringId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => new StringId(new Utf8String(reader.GetUtf8String().ToArray())); /// public override void Write(Utf8JsonWriter writer, StringId value, JsonSerializerOptions options) => writer.WriteStringValue(value.Span); } /// /// Class which serializes types /// sealed class StringIdTypeConverter : TypeConverter { /// public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => sourceType == typeof(string); /// public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) => new StringId((string)value); /// public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => destinationType == typeof(string); /// public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => ((StringId)value!).Text.ToString(); } }