diff --git a/src/Hypercube.Core/IO/Parsers/Yaml/YamlParser.cs b/src/Hypercube.Core/IO/Parsers/Yaml/YamlParser.cs new file mode 100644 index 0000000..b820fa4 --- /dev/null +++ b/src/Hypercube.Core/IO/Parsers/Yaml/YamlParser.cs @@ -0,0 +1,155 @@ +using System.Text; + +namespace Hypercube.Core.IO.Parsers.Yaml; + +public static class YamlParser +{ + // Private constants for quotes, escape characters, and comment symbol + private const char DoubleQuote = '"'; + private const char SingleQuote = '\''; + private const char EscapeChar = '\\'; + private const char CommentChar = '#'; + + /// + /// Parses the provided YAML string into a dictionary of prototypes. + /// + /// The YAML string to be parsed. + /// A dictionary with prototype IDs as keys and field dictionaries as values. + public static Dictionary> ParseYaml(string yamlData) + { + var result = new Dictionary>(); + var lines = yamlData.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + string? currentId = null; + + var currentFields = new Dictionary(); + var inString = false; + + foreach (var line in lines) + { + var cleanedLine = ProcessLine(line, ref inString); + + // Ignore empty or commented-out lines + if (string.IsNullOrWhiteSpace(cleanedLine)) + continue; + + var parts = cleanedLine.Split(':', 2, StringSplitOptions.TrimEntries); + + // Process a new prototype section (ID and fields) + if (!line.StartsWith(" ") && cleanedLine.Contains(":")) + { + if (currentId != null) + result[currentId] = currentFields; + + currentId = parts[0]; + currentFields = new Dictionary(); + continue; + } + + if (!line.StartsWith(" ")) + continue; + + if (parts.Length != 2) + continue; + + currentFields[parts[0]] = RemoveQuotesAndEscape(parts[1]); + } + + // Add the last prototype if present + if (currentId != null) + result[currentId] = currentFields; + + return result; + } + + /// + /// Processes a line by stripping out comments and handling quote and escape characters. + /// + /// The line to process. + /// Indicates whether we are currently inside a string. + /// A cleaned-up line with comments removed and quotes handled. + private static string ProcessLine(string line, ref bool inString) + { + var cleanedLine = string.Empty; + foreach (var ch in line) + { + // Toggle string state on encountering a quote + if (ch is DoubleQuote or SingleQuote) + inString = !inString; + + // Ignore comments if not inside a string + // And stop processing the line at the comment symbol + if (ch == CommentChar && !inString) + break; + + cleanedLine += ch; + } + + return cleanedLine.Trim(); + } + + /// + /// Removes surrounding quotes and handles escape sequences inside the string. + /// + /// The value to process. + /// The value with quotes removed and escape sequences properly handled. + private static string RemoveQuotesAndEscape(string value) + { + // Check for open/close quote mismatch + var startsWithQuote = value.StartsWith(DoubleQuote) || value.StartsWith(SingleQuote); + var endsWithQuote = value.EndsWith(DoubleQuote) || value.EndsWith(SingleQuote); + + if (startsWithQuote && !endsWithQuote) + throw new FormatException("String starts with a quote but does not end with one."); + + if (!startsWithQuote && endsWithQuote) + throw new FormatException("String ends with a quote but does not start with one."); + + // Remove surrounding quotes if they exist + if ((value.StartsWith(DoubleQuote) && value.EndsWith(DoubleQuote)) || + (value.StartsWith(SingleQuote) && value.EndsWith(SingleQuote))) + { + value = value.Substring(1, value.Length - 2); // Remove quotes + } + + var result = string.Empty; + var isEscaped = false; + + // Process each character in the string + foreach (var currentChar in value) + { + // If we're escaping the next character + if (isEscaped) + { + result += currentChar switch + { + 'n' => '\n', // Handle newline escape + 't' => '\t', // Handle tab escape + 'r' => '\r', // Handle carriage return escape + '\\' => '\\', // Handle escaped backslash + '"' => '\"', // Handle escaped double quote + '\'' => '\'', // Handle escaped single quote + _ => throw new FormatException($"Invalid escape sequence: \\{currentChar}") + }; + + isEscaped = false; + continue; + } + + // Detect escape sequence + if (currentChar == EscapeChar) + { + isEscaped = true; + continue; + } + + result += currentChar; + } + + // If we're still escaping after the loop, it's an invalid escape sequence + if (isEscaped) + throw new FormatException("String ends with an incomplete escape sequence."); + + return result; + } +} \ No newline at end of file diff --git a/src/Hypercube.Core/IO/Prototypes/IPrototype.cs b/src/Hypercube.Core/IO/Prototypes/IPrototype.cs new file mode 100644 index 0000000..24cb97f --- /dev/null +++ b/src/Hypercube.Core/IO/Prototypes/IPrototype.cs @@ -0,0 +1,6 @@ +namespace Hypercube.Core.IO.Prototypes; + +public interface IPrototype +{ + string Id { get; set; } +} \ No newline at end of file diff --git a/src/Hypercube.Core/IO/Prototypes/PrototypeAttribute.cs b/src/Hypercube.Core/IO/Prototypes/PrototypeAttribute.cs new file mode 100644 index 0000000..23f5c22 --- /dev/null +++ b/src/Hypercube.Core/IO/Prototypes/PrototypeAttribute.cs @@ -0,0 +1,12 @@ +namespace Hypercube.Core.IO.Prototypes; + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class PrototypeAttribute : Attribute +{ + public readonly string Id; + + public PrototypeAttribute(string id) + { + Id = id; + } +} \ No newline at end of file diff --git a/src/Hypercube.Core/IO/Prototypes/PrototypeId.cs b/src/Hypercube.Core/IO/Prototypes/PrototypeId.cs new file mode 100644 index 0000000..02c170f --- /dev/null +++ b/src/Hypercube.Core/IO/Prototypes/PrototypeId.cs @@ -0,0 +1,50 @@ +namespace Hypercube.Core.IO.Prototypes; + +public readonly struct PrototypeId where T : IPrototype +{ + public readonly string Id; + + public PrototypeId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("Prototype ID cannot be null or empty.", nameof(id)); + + Id = id; + } + + public override string ToString() + { + return Id; + } + + public override bool Equals(object? obj) + { + return obj is PrototypeId other && string.Equals(Id, other.Id, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public static implicit operator string(PrototypeId prototypeId) + { + return prototypeId.Id; + } + + public static explicit operator PrototypeId(string id) + { + return new PrototypeId(id); + } + + + public static bool operator ==(PrototypeId left, PrototypeId right) + { + return left.Equals(right); + } + + public static bool operator !=(PrototypeId left, PrototypeId right) + { + return !(left == right); + } +} \ No newline at end of file diff --git a/src/Hypercube.Core/IO/Prototypes/Storage/IPrototypeStorage.cs b/src/Hypercube.Core/IO/Prototypes/Storage/IPrototypeStorage.cs new file mode 100644 index 0000000..7709072 --- /dev/null +++ b/src/Hypercube.Core/IO/Prototypes/Storage/IPrototypeStorage.cs @@ -0,0 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Hypercube.Core.IO.Prototypes.Storage; + +public interface IPrototypeStorage +{ + T GetPrototype(PrototypeId id) where T : class, IPrototype; + bool TryGetPrototype(PrototypeId id, [NotNullWhen(true)] out T? prototype) where T : class, IPrototype; + bool HasPrototype(PrototypeId id) where T : class, IPrototype; + IEnumerable EnumeratePrototypes() where T : class, IPrototype; + void LoadPrototypes(string yamlData); +} \ No newline at end of file diff --git a/src/Hypercube.Core/IO/Prototypes/Storage/PrototypeStorage.cs b/src/Hypercube.Core/IO/Prototypes/Storage/PrototypeStorage.cs new file mode 100644 index 0000000..abd34f6 --- /dev/null +++ b/src/Hypercube.Core/IO/Prototypes/Storage/PrototypeStorage.cs @@ -0,0 +1,93 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using Hypercube.Core.IO.Parsers.Yaml; +using Hypercube.Utilities.Helpers; + +namespace Hypercube.Core.IO.Prototypes.Storage; + +public class PrototypeStorage : IPrototypeStorage +{ + private readonly FrozenDictionary _types; + private readonly Dictionary _prototypes = new(); + + public PrototypeStorage() + { + var types = new Dictionary(); + foreach (var (type, attribute) in ReflectionHelper.GetAllTypesWithAttribute()) + { + types[attribute.Id] = type; + } + + _types = types.ToFrozenDictionary(); + } + + public T GetPrototype(PrototypeId id) where T : class, IPrototype + { + if (_prototypes.TryGetValue(id.Id, out var prototype) && prototype is T typedPrototype) + return typedPrototype; + + throw new KeyNotFoundException($"Prototype with ID '{id.Id}' not found or is of the wrong type."); + } + + public bool TryGetPrototype(PrototypeId id, [NotNullWhen(true)] out T? prototype) where T : class, IPrototype + { + if (_prototypes.TryGetValue(id.Id, out var proto) && proto is T typedPrototype) + { + prototype = typedPrototype; + return true; + } + + prototype = null; + return false; + } + + public bool HasPrototype(PrototypeId id) where T : class, IPrototype + { + return _prototypes.TryGetValue(id.Id, out var proto) && proto is T; + } + + public IEnumerable EnumeratePrototypes() where T : class, IPrototype + { + return _prototypes.Values.OfType(); + } + + public void LoadPrototypes(string yamlData) + { + var rawPrototypes = YamlParser.ParseYaml(yamlData); + + foreach (var (id, fields) in rawPrototypes) + { + if (!fields.TryGetValue("type", out var value)) + throw new InvalidOperationException($"Prototype '{id}' is missing a 'type' field."); + + if (!_types.TryGetValue(value, out var prototypeType)) + throw new InvalidOperationException($"Unknown or incompatible type '{value}' for prototype '{id}'."); + + if (Activator.CreateInstance(prototypeType) is not IPrototype prototype) + throw new InvalidOperationException($"Failed to create prototype '{id}' of type '{value}'."); + + PopulateFields(prototype, fields); + var property = prototypeType.GetProperty(nameof(IPrototype.Id)); + + if (property is null) + throw new InvalidOperationException($"Prototype '{id}' does not have a valid '{nameof(IPrototype.Id)}' property."); + + property.SetValue(prototype, id); + _prototypes[id] = prototype; + } + } + + private void PopulateFields(IPrototype prototype, Dictionary fields) + { + var type = prototype.GetType(); + foreach (var (fieldName, rawValue) in fields) + { + var property = type.GetProperty(fieldName); + if (property is null || !property.CanWrite) + continue; + + var convertedValue = Convert.ChangeType(rawValue, property.PropertyType); + property.SetValue(prototype, convertedValue); + } + } +} \ No newline at end of file diff --git a/src/Hypercube.UnitTests/Core/IO/Prototypes/PrototypeStorageTests.cs b/src/Hypercube.UnitTests/Core/IO/Prototypes/PrototypeStorageTests.cs new file mode 100644 index 0000000..dc30691 --- /dev/null +++ b/src/Hypercube.UnitTests/Core/IO/Prototypes/PrototypeStorageTests.cs @@ -0,0 +1,200 @@ +using Hypercube.Core.IO.Prototypes; +using Hypercube.Core.IO.Prototypes.Storage; + +namespace Hypercube.UnitTests.Core.IO.Prototypes; + +[TestFixture] +public sealed class PrototypeStorageTests +{ + private readonly string _yamlData = + """ + # Also parser test + example1: + type: example + Name: 'Prototype One' # Parser test + Value: 100 + + example2: + type: example + Name: 'Prototype Two' + Value: 200 + """; + + [Test] + public void LoadPrototypes_ShouldLoadPrototypesCorrectly() + { + // Arrange + var storage = new PrototypeStorage(); + + // Act + storage.LoadPrototypes(_yamlData); + + // Assert + Assert.That(storage.HasPrototype(new PrototypeId("example1")), Is.True); + Assert.That(storage.HasPrototype(new PrototypeId("example2")), Is.True); + } + + [Test] + public void GetPrototype_ShouldReturnCorrectPrototype() + { + // Arrange + var storage = new PrototypeStorage(); + storage.LoadPrototypes(_yamlData); + + // Act + var prototype = storage.GetPrototype(new PrototypeId("example1")); + + // Assert + Assert.That(prototype, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(prototype.Id, Is.EqualTo("example1")); + Assert.That(prototype.Name, Is.EqualTo("Prototype One")); + Assert.That(prototype.Value, Is.EqualTo(100)); + }); + } + + [Test] + public void TryGetPrototype_ShouldReturnTrue_WhenPrototypeExists() + { + // Arrange + var storage = new PrototypeStorage(); + storage.LoadPrototypes(_yamlData); + + // Act + var result = storage.TryGetPrototype(new PrototypeId("example2"), out var prototype); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(prototype, Is.Not.Null); + }); + + Assert.Multiple(() => + { + Assert.That(prototype.Id, Is.EqualTo("example2")); + Assert.That(prototype.Name, Is.EqualTo("Prototype Two")); + Assert.That(prototype.Value, Is.EqualTo(200)); + }); + } + + [Test] + public void TryGetPrototype_ShouldReturnFalse_WhenPrototypeDoesNotExist() + { + // Arrange + var storage = new PrototypeStorage(); + storage.LoadPrototypes(_yamlData); + + // Act + var result = storage.TryGetPrototype(new PrototypeId("nonexistent"), out var prototype); + + // Assert + Assert.Multiple(() => + { + Assert.That(result, Is.False); + Assert.That(prototype, Is.Null); + }); + } + + [Test] + public void HasPrototype_ShouldReturnTrue_WhenPrototypeExists() + { + // Arrange + var storage = new PrototypeStorage(); + storage.LoadPrototypes(_yamlData); + + // Act + var result = storage.HasPrototype(new PrototypeId("example1")); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void HasPrototype_ShouldReturnFalse_WhenPrototypeDoesNotExist() + { + // Arrange + var storage = new PrototypeStorage(); + storage.LoadPrototypes(_yamlData); + + // Act + var result = storage.HasPrototype(new PrototypeId("nonexistent")); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void EnumeratePrototypes_ShouldReturnAllPrototypesOfType() + { + // Arrange + var storage = new PrototypeStorage(); + storage.LoadPrototypes(_yamlData); + + // Act + var prototypes = storage.EnumeratePrototypes().ToList(); + + // Assert + Assert.Multiple(() => + { + Assert.That(prototypes.Count, Is.EqualTo(2)); + Assert.That(prototypes.Any(p => p.Id == "example1"), Is.True); + Assert.That(prototypes.Any(p => p.Id == "example2"), Is.True); + }); + } + + [Test] + public void GetPrototype_ShouldThrowException_WhenPrototypeDoesNotExist() + { + // Arrange + var storage = new PrototypeStorage(); + storage.LoadPrototypes(_yamlData); + + // Act & Assert + Assert.Throws(() => + storage.GetPrototype(new PrototypeId("nonexistent")) + ); + } + + [Test] + public void LoadPrototypes_ShouldThrowException_WhenTypeIsMissing() + { + // Arrange + var invalidYamlData = @" +example1: + Name: 'Invalid Prototype' + Value: 50 +"; + + var storage = new PrototypeStorage(); + + // Act & Assert + Assert.Throws(() => storage.LoadPrototypes(invalidYamlData)); + } + + [Test] + public void LoadPrototypes_ShouldThrowException_WhenTypeIsUnknown() + { + // Arrange + var invalidYamlData = @" +example1: + type: UnknownPrototype + Name: 'Invalid Prototype' + Value: 50 +"; + + var storage = new PrototypeStorage(); + + // Act & Assert + Assert.Throws(() => storage.LoadPrototypes(invalidYamlData)); + } + + [Prototype("example")] + private class ExamplePrototype : IPrototype + { + public string Id { get; set; } + public string Name { get; set; } + public int Value { get; set; } + } +} \ No newline at end of file diff --git a/src/Hypercube.UnitTests/Core/IO/Yaml/YamlParserTests.cs b/src/Hypercube.UnitTests/Core/IO/Yaml/YamlParserTests.cs new file mode 100644 index 0000000..54082f9 --- /dev/null +++ b/src/Hypercube.UnitTests/Core/IO/Yaml/YamlParserTests.cs @@ -0,0 +1,184 @@ +using Hypercube.Core.IO.Parsers.Yaml; + +namespace Hypercube.UnitTests.Core.IO.Yaml; + +[TestFixture] +public class YamlParserTests +{ + [Test] + public void ParseYaml_ValidYaml_ShouldParseCorrectly() + { + // Arrange + const string yamlData = + """ + # This is a comment + prototype1: + type: SomeType + field1: 'Value with # and \" quotes' + field2: "Escaped value\\nNew line" + prototype2: + type: AnotherType + field1: 'Another field' + """; + + var expected = new Dictionary> + { + { + "prototype1", new Dictionary + { + { "type", "SomeType" }, + { "field1", "Value with # and \" quotes" }, + { "field2", "Escaped value\\nNew line" } + } + }, + { + "prototype2", new Dictionary + { + { "type", "AnotherType" }, + { "field1", "Another field" } + } + } + }; + + // Act + var result = YamlParser.ParseYaml(yamlData); + + // Assert + CollectionAssert.AreEqual(expected, result); + } + + [Test] + public void ParseYaml_YamlWithComments_ShouldIgnoreComments() + { + // Arrange + const string yamlData = + """ + # This is a comment + prototype1: + type: SomeType + field1: 'Field1 value' + # Another comment + prototype2: + type: AnotherType + field2: 'Field2 value' + """; + + var expected = new Dictionary> + { + { + "prototype1", new Dictionary + { + { "type", "SomeType" }, + { "field1", "Field1 value" } + } + }, + { + "prototype2", new Dictionary + { + { "type", "AnotherType" }, + { "field2", "Field2 value" } + } + } + }; + + // Act + var result = YamlParser.ParseYaml(yamlData); + + // Assert + CollectionAssert.AreEqual(expected, result); + } + + [Test] + public void ParseYaml_EmptyString_ShouldReturnEmptyDictionary() + { + // Arrange + var yamlData = string.Empty; + + // Act + var result = YamlParser.ParseYaml(yamlData); + + // Assert + Assert.IsEmpty(result); + } + + [Test] + public void ParseYaml_SinglePrototype_ShouldParseCorrectly() + { + // Arrange + const string yamlData = + """ + prototype1: + type: MyType + field1: 'Some value' + """; + + var expected = new Dictionary> + { + { + "prototype1", new Dictionary + { + { "type", "MyType" }, + { "field1", "Some value" } + } + } + }; + + // Act + var result = YamlParser.ParseYaml(yamlData); + + // Assert + CollectionAssert.AreEqual(expected, result); + } + + // It's fucking test be damned, + // fucking parsing after parsing doesn't want to move the string stub + // I don't fucking know why + [Test] + public void ParseYaml_ValidYamlWithEscapedCharacters_ShouldHandleEscapeSequences() + { + // Arrange + const string yamlData = + """ + prototype1: + type: SomeType + field1: 'Value with escaped \\n new line' + field2: 'Another value with \\t tab' + """; + + var expected = new Dictionary> + { + { + "prototype1", new Dictionary + { + { "type", "SomeType" }, + { "field1", "Value with escaped \\n new line" }, + { "field2", "Another value with \\t tab" } + } + } + }; + + // Act + var result = YamlParser.ParseYaml(yamlData); + + // Assert + CollectionAssert.AreEqual(expected, result); + } + + [Test] + public void ParseYaml_InvalidYaml_ShouldThrowException() + { + // Arrange + const string yamlData = + """ + prototype1: + type: SomeType + field1: 'Unmatched quote + prototype2: + type: AnotherType + field2: 'Valid value' + """; + + // Act & Assert + Assert.Throws(() => YamlParser.ParseYaml(yamlData)); + } +} \ No newline at end of file diff --git a/src/Hypercube.UnitTests/GlobalUsings.cs b/src/Hypercube.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/src/Hypercube.UnitTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/src/Hypercube.UnitTests/Hypercube.UnitTests.csproj b/src/Hypercube.UnitTests/Hypercube.UnitTests.csproj new file mode 100644 index 0000000..23732ac --- /dev/null +++ b/src/Hypercube.UnitTests/Hypercube.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + diff --git a/src/Hypercube.sln b/src/Hypercube.sln index 710137c..f5bbe56 100644 --- a/src/Hypercube.sln +++ b/src/Hypercube.sln @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hypercube.Utilities", "Hype EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hypercube.Graphics", "Hypercube.Graphics\Hypercube.Graphics.csproj", "{35E973E0-51C3-4398-906A-17D5D08E3589}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hypercube.UnitTests", "Hypercube.UnitTests\Hypercube.UnitTests.csproj", "{39A315E6-21A0-4308-8A68-525C6DD1A8F5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,5 +44,9 @@ Global {35E973E0-51C3-4398-906A-17D5D08E3589}.Debug|Any CPU.Build.0 = Debug|Any CPU {35E973E0-51C3-4398-906A-17D5D08E3589}.Release|Any CPU.ActiveCfg = Release|Any CPU {35E973E0-51C3-4398-906A-17D5D08E3589}.Release|Any CPU.Build.0 = Release|Any CPU + {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39A315E6-21A0-4308-8A68-525C6DD1A8F5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal