using System; using System.Buffers; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using gaemstone.ECS.Internal; using gaemstone.ECS.Utility; using static flecs_hub.flecs; namespace gaemstone.ECS; // TODO: Redo this with a single UTF8 byte array. public class EntityPath { private readonly byte[][] _parts; public bool IsAbsolute { get; } public bool IsRelative => !IsAbsolute; public int Count => _parts.Length; public UTF8View Name => this[^1]; public EntityPath? Parent => (Count > 1) ? new(IsAbsolute, _parts[..^1]) : null; public UTF8View this[int index] => (index >= 0 && index < Count) ? new(_parts[index].AsSpan()[..^1]) : throw new ArgumentOutOfRangeException(nameof(index)); public EntityPath this[Range range] => new(IsAbsolute && (range.GetOffsetAndLength(Count).Offset == 0), _parts[range]); internal EntityPath(bool absolute, params byte[][] parts) { if (parts.Length == 0) throw new ArgumentException( "Must have at least one part", nameof(parts)); IsAbsolute = absolute; _parts = parts; } public EntityPath(params string[] parts) : this(false, parts) { } public EntityPath(bool absolute, params string[] parts) : this(absolute, parts.Select(part => { if (GetNameValidationError(part) is string error) throw new ArgumentException(error); var byteCount = Encoding.UTF8.GetByteCount(part); // Includes NUL character at the end of bytes. var bytes = new byte[byteCount + 1]; Encoding.UTF8.GetBytes(part, bytes); return bytes; }).ToArray()) { } public static unsafe EntityPath From(World world, Entity entity) { if (entity.IsNone) throw new ArgumentException( "entity is Entity.None", nameof(entity)); var parts = new List(32); do { var name = ecs_get_name(world, entity).FlecsToBytes(); if (name != null) parts.Add(name); else { // If name is not set, use the numeric Id, instead. var id = entity.NumericId.ToString(); var bytes = new byte[Encoding.UTF8.GetByteCount(id) + 1]; Encoding.UTF8.GetBytes(id, bytes); parts.Add(bytes); } } while ((entity = new(ecs_get_target(world, entity, EcsChildOf, 0))).IsSome); parts.Reverse(); return new(true, parts.ToArray()); } public static bool TryParse(string str, [NotNullWhen(true)] out EntityPath? result) { result = null; if (str.Length == 0) return false; var strSpan = str.AsSpan(); var isAbsolute = (str[0] == '/'); if (isAbsolute) strSpan = strSpan[1..]; var numSeparators = 0; foreach (var chr in strSpan) if (chr == '/') numSeparators++; var index = 0; var parts = new byte[numSeparators + 1][]; foreach (var part in strSpan.Split('/')) { if (GetNameValidationError(part) != null) return false; var byteCount = Encoding.UTF8.GetByteCount(part); // Includes NUL character at the end of bytes. var bytes = new byte[byteCount + 1]; Encoding.UTF8.GetBytes(part, bytes); parts[index++] = bytes; } result = new(isAbsolute, parts); return true; } public static EntityPath Parse(string str) { if (str.Length == 0) throw new ArgumentException( "String must not be empty", nameof(str)); var parts = str.Split('/'); // If string starts with a slash, first part will be empty, so create an absolute path. return (parts[0].Length == 0) ? new(true, parts[1..]) : new(parts); } public static string? GetNameValidationError(ReadOnlySpan name) { if (name.Length == 0) return "Must not be empty"; // NOTE: This is a hopefully straightforward way to also prevent "." // and ".." to be part of paths which may access the file system. if (name[0] == '.') return "Must not begin with a dot"; foreach (var chr in name) if (char.IsControl(chr)) return "Must not contain contol characters"; return null; } // private static readonly Rune[] _validRunes = { (Rune)'-', (Rune)'.', (Rune)'_' }; // private static readonly UnicodeCategory[] _validCategories = { // UnicodeCategory.LowercaseLetter, UnicodeCategory.UppercaseLetter, // UnicodeCategory.OtherLetter, UnicodeCategory.DecimalDigitNumber }; // private static void ValidateRune(Rune rune) // { // if (!_validRunes.Contains(rune) && !_validCategories.Contains(Rune.GetUnicodeCategory(rune))) // throw new ArgumentException($"Must not contain {Rune.GetUnicodeCategory(rune)} character"); // } public EntityPath ThrowIfAbsolute() => IsRelative ? this : throw new InvalidOperationException( $"Path '{this}' must not be absolute"); public string[] GetParts() { var result = new string[Count]; for (var i = 0; i < Count; i++) result[i] = this[i]; return result; } public override string ToString() { var builder = new StringBuilder(); if (IsAbsolute) builder.Append('/'); foreach (var part in this) builder.Append(part).Append('/'); return builder.ToString(0, builder.Length - 1); } public static implicit operator EntityPath(string path) => Parse(path); public Enumerator GetEnumerator() => new(this); public ref struct Enumerator { private readonly EntityPath _path; private int index = -1; public UTF8View Current => _path[index]; internal Enumerator(EntityPath path) => _path = path; public bool MoveNext() => (++index < _path.Count); } internal static unsafe Entity Lookup( World world, EntityPath path, Entity parent, bool throwOnNotFound) { var start = path.IsAbsolute ? Entity.None // If path is absolute, ignore parent and use root. : parent.IsNone ? new(ecs_get_scope(world)) // If no parent is specified, use the current scope. : parent; // Otherwise just use the specified parent. var current = start; foreach (var part in path) fixed (byte* ptr = part.AsSpan()) { current = new(ecs_lookup_child(world, current, ptr)); if (current.IsNone || !ecs_is_alive(world, current)) { if (!throwOnNotFound) return Entity.None; else throw new EntityNotFoundException( start.IsNone ? $"Entity at '{path}' not found" : (start == parent) ? $"Child entity of '{From(world, start)}' at '{path}' not found" : $"Entity at scope '{From(world, start)}' at '{path}' not found"); } } return current; } /// Used by . internal static unsafe Entity EnsureEntityExists( World world, EntityPath path, Entity parent) { // If no parent is specified and path is relative, use the current scope. if (parent.IsNone && path.IsRelative) parent = new(ecs_get_scope(world)); var skipLookup = parent.IsNone; foreach (var part in path) fixed (byte* ptr = part.AsSpan()) if (skipLookup || (parent = new(ecs_lookup_child(world, parent, ptr))).IsNone) { var desc = new ecs_entity_desc_t { _name = ptr, _sep = CStringExtensions.Empty }; if (parent.IsSome) desc.add[0] = Id.Pair(FlecsBuiltIn.ChildOf, parent); parent = new(ecs_entity_init(world, &desc)); skipLookup = true; } return parent; } } public readonly ref struct UTF8View { private readonly ReadOnlySpan _bytes; public UTF8View(ReadOnlySpan bytes) => _bytes = bytes; public int Length => _bytes.Length; public byte this[int index] => _bytes[index]; public ReadOnlySpan AsSpan() => _bytes; public override string ToString() => Encoding.UTF8.GetString(_bytes); public static implicit operator string(UTF8View view) => view.ToString(); public Enumerator GetEnumerator() => new(_bytes); public ref struct Enumerator { private readonly ReadOnlySpan _bytes; private int index = 0; private Rune _current = default; public Rune Current => _current; internal Enumerator(ReadOnlySpan bytes) => _bytes = bytes; public bool MoveNext() { if (index >= _bytes.Length) return false; if (Rune.DecodeFromUtf8(_bytes[index..], out _current, out var consumed) != OperationStatus.Done) throw new InvalidOperationException("Contains invalid UTF8"); index += consumed; return true; } } }