// 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();
}
}