diff --git a/src/Moniker/Chars.cs b/src/Moniker/Chars.cs new file mode 100644 index 0000000..36644aa --- /dev/null +++ b/src/Moniker/Chars.cs @@ -0,0 +1,106 @@ +using System; +using System.Text; + +namespace Moniker; + +/// +/// Represents a reference to a string of characters. +/// +public readonly ref struct Chars +{ + private readonly ReadOnlySpan _u8str; + + internal Chars(ReadOnlySpan u8str, int charCount) + { + _u8str = u8str; + Length = charCount; + } + + /// + /// The length in characters. + /// + public int Length { get; } + + /// + /// The length when encoded in UTF-8 bytes. + /// + public int Utf8Length => _u8str.Length; + + /// + /// Writes characters to the specified buffer. + /// + /// The buffer to write to. + /// The number of characters written. + public int Write(Span buffer) + { + var count = Length; + if (count > buffer.Length) + return 0; + + Encoding.UTF8.GetChars(_u8str, buffer); + return count; + } + + /// + /// Writes character in UTF-8 encoding to the specified buffer. + /// + /// The buffer to write to. + /// The number of characters written. + public int WriteUtf8(Span buffer) => + _u8str.TryCopyTo(buffer) ? Utf8Length : 0; + + /// + /// Returns a string with the same characters. + /// + public override string ToString() + { + var chars = Length <= 64 ? stackalloc char[Length] : new char[Length]; + _ = Write(chars); + return new(chars); + } + +#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member + + /// + /// This method is unsupported and throws + /// because instances cannot be boxed. Use + /// instead. + /// + /// Always thrown. + [Obsolete("This method is unsupported and will always throw an exception. Use the equality operator instead.")] + public override bool Equals(object? obj) => throw new NotSupportedException(); + + /// + /// This method is unsupported and throws . + /// + /// Always thrown. + [Obsolete("This method is unsupported and will always throw an exception.")] + public override int GetHashCode() => throw new NotSupportedException(); + +#pragma warning restore CS0809 // Obsolete member overrides non-obsolete member + + /// + /// Compares to another for equality. + /// + /// The other to compare to. + /// if this instance compares equals to . + public bool Equals(Chars other) => _u8str.SequenceEqual(other._u8str); + + internal bool Equals(ReadOnlySpan other) => _u8str.SequenceEqual(other); + + /// + /// Compares two for equality. + /// + /// First operand to compare. + /// Second operand to compare. + /// if equals . + public static bool operator ==(Chars left, Chars right) => left.Equals(right); + + /// + /// Compares two for inequality. + /// + /// First operand to compare. + /// Second operand to compare. + /// if does not equal . + public static bool operator !=(Chars left, Chars right) => !left.Equals(right); +} diff --git a/src/Moniker/NameGenerator.cs b/src/Moniker/NameGenerator.cs index 0f4e986..42824c8 100644 --- a/src/Moniker/NameGenerator.cs +++ b/src/Moniker/NameGenerator.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; namespace Moniker { @@ -21,12 +23,27 @@ public static class NameGenerator /// public static string Generate(MonikerStyle monikerStyle, string delimiter = DefaultDelimiter) { - return monikerStyle switch + ValidateDelimiterArgument(delimiter); + Generate(monikerStyle, out var adjective, out var noun); + return Join(adjective, delimiter, noun); + } + + /// + /// Generate a random name in the specified style. + /// + /// The style of random name. + /// The adjective part of the random name. + /// The adjective part of the random name. + /// The generated random name. + /// + public static void Generate(MonikerStyle monikerStyle, out Chars adjective, out Chars noun) + { + switch (monikerStyle) { - MonikerStyle.Moby => GenerateMoby(delimiter), - MonikerStyle.Moniker => GenerateMoniker(delimiter), - _ => throw new ArgumentOutOfRangeException(nameof(monikerStyle)) - }; + case MonikerStyle.Moby: GenerateMoby(out adjective, out noun); break; + case MonikerStyle.Moniker: GenerateMoniker(out adjective, out noun); break; + default: throw new ArgumentOutOfRangeException(nameof(monikerStyle)); + } } /// @@ -35,7 +52,18 @@ public static string Generate(MonikerStyle monikerStyle, string delimiter = Defa /// An optional delimiter to use between adjective and noun. /// The generated random name. public static string GenerateMoniker(string delimiter = DefaultDelimiter) - => BuildNamePair(MonikerDescriptors.Strings, MonikerAnimals.Strings, delimiter); + { + ValidateDelimiterArgument(delimiter); + GenerateMoniker(out var adjective, out var noun); + return Join(adjective, delimiter, noun); + } + + /// + /// Generate a random name in the 'moniker' project style. + /// + /// The generated random name. + public static void GenerateMoniker(out Chars adjective, out Chars noun) + => BuildNamePair(MonikerDescriptors.Strings, out adjective, MonikerAnimals.Strings, out noun); /// /// Generate a random name in the 'moby' project style. @@ -43,42 +71,56 @@ public static string GenerateMoniker(string delimiter = DefaultDelimiter) /// An optional delimiter to use between adjective and noun. /// The generated random name. public static string GenerateMoby(string delimiter = DefaultDelimiter) - => BuildNamePair(MobyAdjectives.Strings, MobySurnames.Strings, delimiter); - - private static string BuildNamePair( - Utf8Strings adjectives, - Utf8Strings nouns, - string delimiter) { - if (string.IsNullOrEmpty(delimiter)) - throw new ArgumentException("The delimiter must not be null or empty.", nameof(delimiter)); + ValidateDelimiterArgument(delimiter); + GenerateMoby(out var adjective, out var noun); + return Join(adjective, delimiter, noun); + } - BuildNamePair(adjectives, out var adjective, nouns, out var noun); + private static string Join(Chars adjective, ReadOnlySpan delimiter, Chars noun) + { + var length = adjective.Length + delimiter.Length + noun.Length; + var chars = length <= 64 ? stackalloc char[length] : new char[length]; - var length = adjective.CharCount + delimiter.Length + noun.CharCount; + var writeCount = adjective.Write(chars); + Debug.Assert(writeCount == adjective.Length); - var chars = length <= 64 ? stackalloc char[length] : new char[length]; + delimiter.CopyTo(chars[adjective.Length..]); - _ = adjective.GetChars(chars); - delimiter.CopyTo(chars[adjective.CharCount..]); - noun.GetChars(chars[(adjective.CharCount + delimiter.Length)..]); + writeCount = noun.Write(chars[(adjective.Length + delimiter.Length)..]); + Debug.Assert(writeCount == noun.Length); return new(chars); } + /// + /// Generate a random name in the 'moby' project style. + /// + /// The generated random name. + public static void GenerateMoby(out Chars adjective, out Chars noun) + => BuildNamePair(MobyAdjectives.Strings, out adjective, MobySurnames.Strings, out noun); + + private static void ValidateDelimiterArgument( + string delimiter, + [CallerArgumentExpression(nameof(delimiter))] string? paramName = null) + { + if (string.IsNullOrEmpty(delimiter)) + throw new ArgumentException("The delimiter must not be null or empty.", paramName); + } + private static void BuildNamePair( - Utf8Strings adjectives, out Utf8String adjective, - Utf8Strings nouns, out Utf8String noun) + Utf8Strings adjectives, out Chars adjective, + Utf8Strings nouns, out Chars noun) { do { adjective = GetRandomEntry(adjectives); noun = GetRandomEntry(nouns); } - while (adjective == "boring"u8 && noun == "wozniak"u8); // Steve Wozniak is not boring + while (adjective.Equals("boring"u8) && noun.Equals("wozniak"u8)); // Steve Wozniak is not boring } - private static Utf8String GetRandomEntry(Utf8Strings entries) + private static Chars GetRandomEntry(Utf8Strings entries) { var index = Random.Shared.Next(entries.Count); return entries[index]; diff --git a/src/Moniker/Utf8Strings.cs b/src/Moniker/Utf8Strings.cs index 4ef2256..e93c563 100644 --- a/src/Moniker/Utf8Strings.cs +++ b/src/Moniker/Utf8Strings.cs @@ -6,32 +6,6 @@ namespace Moniker; -internal readonly ref struct Utf8String(ReadOnlySpan bytes, int charCount) -{ - public readonly ReadOnlySpan Bytes = bytes; - public readonly int CharCount = charCount; - - public int GetChars(Span chars) => Encoding.UTF8.GetChars(Bytes, chars); - - public override string ToString() => Encoding.UTF8.GetString(Bytes); - - // Equality members - - public static bool operator ==(Utf8String left, ReadOnlySpan right) => left.Equals(right); - public static bool operator !=(Utf8String left, ReadOnlySpan right) => !left.Equals(right); - - private bool Equals(ReadOnlySpan other) => Bytes.SequenceEqual(other); - - // Unsupported equality members because this type cannot be boxed - - public override int GetHashCode() => throw new NotSupportedException(); - public override bool Equals(object? obj) => throw new NotSupportedException(); - - // Implicit conversions - - public static implicit operator ReadOnlySpan(Utf8String data) => data.Bytes; -} - internal readonly ref struct Utf8Strings { private readonly ReadOnlySpan _data; @@ -52,7 +26,7 @@ public Utf8Strings(int count, ReadOnlySpan data, ReadOnlySpan offsets _charCounts = charCounts; } - public Utf8String this[int index] => new(_data[_offsets[index].._offsets[index + 1]], _charCounts[index]); + public Chars this[int index] => new(_data[_offsets[index].._offsets[index + 1]], _charCounts[index]); public override string ToString() => $"{{ Count = {Count}, Size = {_data.Length} }}"; @@ -67,7 +41,7 @@ public ref struct Enumerator(Utf8Strings strings) /// Behaviour is undefined if has never been called or returned /// . /// - public Utf8String Current => _strings[_index]; + public Chars Current => _strings[_index]; public bool MoveNext() { diff --git a/test/Moniker.ApprovalTests/Moniker.approved.txt b/test/Moniker.ApprovalTests/Moniker.approved.txt index c1af879..e86feb8 100644 --- a/test/Moniker.ApprovalTests/Moniker.approved.txt +++ b/test/Moniker.ApprovalTests/Moniker.approved.txt @@ -1,5 +1,25 @@ namespace Moniker { + [System.Obsolete("Types with embedded references are not supported in this version of your compiler" + + ".", true)] + [System.Runtime.CompilerServices.CompilerFeatureRequired("RefStructs")] + [System.Runtime.CompilerServices.IsByRefLike] + public readonly struct Chars + { + public int Length { get; } + public int Utf8Length { get; } + public bool Equals(Moniker.Chars other) { } + [System.Obsolete("This method is unsupported and will always throw an exception. Use the equality o" + + "perator instead.")] + public override bool Equals(object? obj) { } + [System.Obsolete("This method is unsupported and will always throw an exception.")] + public override int GetHashCode() { } + public override string ToString() { } + public int Write(System.Span buffer) { } + public int WriteUtf8(System.Span buffer) { } + public static bool operator !=(Moniker.Chars left, Moniker.Chars right) { } + public static bool operator ==(Moniker.Chars left, Moniker.Chars right) { } + } public enum MonikerStyle { Moniker = 0, @@ -9,7 +29,10 @@ namespace Moniker { public const string DefaultDelimiter = "-"; public static string Generate(Moniker.MonikerStyle monikerStyle, string delimiter = "-") { } + public static void Generate(Moniker.MonikerStyle monikerStyle, out Moniker.Chars adjective, out Moniker.Chars noun) { } public static string GenerateMoby(string delimiter = "-") { } + public static void GenerateMoby(out Moniker.Chars adjective, out Moniker.Chars noun) { } public static string GenerateMoniker(string delimiter = "-") { } + public static void GenerateMoniker(out Moniker.Chars adjective, out Moniker.Chars noun) { } } } \ No newline at end of file diff --git a/test/Moniker.PerformanceTests/NameGeneratorBenchmark.cs b/test/Moniker.PerformanceTests/NameGeneratorBenchmark.cs index d4d790e..f3e662b 100644 --- a/test/Moniker.PerformanceTests/NameGeneratorBenchmark.cs +++ b/test/Moniker.PerformanceTests/NameGeneratorBenchmark.cs @@ -9,4 +9,10 @@ public class NameGeneratorBenchmark [Benchmark] public string GenerateMoniker() => NameGenerator.Generate(MonikerStyle.Moniker); + + [Benchmark] + public void GenerateMobyPair() => NameGenerator.Generate(MonikerStyle.Moby, out _, out _); + + [Benchmark] + public void GenerateMonikerPair() => NameGenerator.Generate(MonikerStyle.Moniker, out _, out _); } \ No newline at end of file diff --git a/test/Moniker.Tests/CharsTests.cs b/test/Moniker.Tests/CharsTests.cs new file mode 100644 index 0000000..b9edc6c --- /dev/null +++ b/test/Moniker.Tests/CharsTests.cs @@ -0,0 +1,155 @@ +using System.Text; +using System; +using System.Linq; +using FluentAssertions; +using Xunit; + +namespace Moniker.Tests; + +public class CharsTests +{ + private static Chars Chars(string str) => + new(Encoding.UTF8.GetBytes(str), str.Length); + + [Theory] + [InlineData("foobar", 6, 6)] + [InlineData("☀️", 2, 6)] + [InlineData("🐟", 2, 4)] + [InlineData("⭐", 1, 3)] + [InlineData("🐕", 2, 4)] + public void Lengths(string str, int length, int utf8Length) + { + var chars = Chars(str); + chars.Length.Should().Be(length); + chars.Utf8Length.Should().Be(utf8Length); + } + + [Theory] + [InlineData(6, "foobar")] + [InlineData(7, "foobar-")] + [InlineData(8, "foobar--")] + public void Write(int length, string expected) + { + var chars = Chars("foobar"); + var buffer = new char[length]; + buffer.AsSpan().Fill('-'); + + var result = chars.Write(buffer); + + result.Should().Be(6); + new string(buffer).Should().Be(expected); + } + + [Theory] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public void WriteWhenBufferIsTooSmall(int length) + { + var chars = Chars("foobar"); + var buffer = new char[length]; + buffer.AsSpan().Fill('-'); + + var result = chars.Write(buffer); + + result.Should().Be(0); + new string('-', length).Should().Be(new string(buffer)); + } + + [Theory] + [InlineData(6, "foobar")] + [InlineData(7, "foobar-")] + [InlineData(8, "foobar--")] + public void WriteUtf8(int length, string expected) + { + var chars = Chars("foobar"); + var buffer = new byte[length]; + buffer.AsSpan().Fill((byte)'-'); + + var result = chars.WriteUtf8(buffer); + + result.Should().Be(6); + Encoding.UTF8.GetString(buffer).Should().Be(expected); + } + + [Theory] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public void WriteUtf8WhenBufferIsTooSmall(int length) + { + var chars = Chars("foobar"); + var buffer = new byte[length]; + buffer.AsSpan().Fill((byte)'-'); + + var result = chars.WriteUtf8(buffer); + + result.Should().Be(0); + Encoding.UTF8.GetString(buffer).Should().Be(new string('-', length)); + } + + [Fact] + public void ToStringReturnsStringWithAllCharacters() + { + var samples = Enumerable.Range(0, 100) + .Select(n => string.Join(string.Empty, Enumerable.Repeat("foobar", n))) + .TakeWhile(s => s.Length <= 256); + + foreach (var str in samples) + Chars(str).ToString().Should().Be(str); + } + + [Fact] + public void EqualsComparesValues() + { + var chars1 = Chars("foobar"); + var chars2 = Chars("foobar"); + var chars3 = Chars("FOOBAR"); + chars1.Equals(chars2).Should().BeTrue(); + chars1.Equals(chars3).Should().BeFalse(); + } + + [Fact] + public void EqualityOperatorComparesValues() + { + var chars1 = Chars("foobar"); + var chars2 = Chars("foobar"); + var chars3 = Chars("FOOBAR"); + (chars1 == chars2).Should().BeTrue(); + (chars1 == chars3).Should().BeFalse(); + } + + [Fact] + public void InequalityOperatorComparesValues() + { + var chars1 = Chars("foobar"); + var chars2 = Chars("foobar"); + var chars3 = Chars("FOOBAR"); + (chars1 != chars2).Should().BeFalse(); + (chars1 != chars3).Should().BeTrue(); + } + + [Fact] + public void GetHashCodeIsUnsupported() + { + var act = () => + { + var chars = Chars("foobar"); + _ = chars.GetHashCode(); + }; + + act.Should().Throw(); + } + + [Fact] + public void EqualsIsUnsupported() + { + var act = () => + { + var chars = Chars("foobar"); + _ = chars.Equals(42); + }; + + act.Should().Throw(); + } +} diff --git a/test/Moniker.Tests/NameGeneratorTests.cs b/test/Moniker.Tests/NameGeneratorTests.cs index 2f3bae4..6517352 100644 --- a/test/Moniker.Tests/NameGeneratorTests.cs +++ b/test/Moniker.Tests/NameGeneratorTests.cs @@ -52,5 +52,34 @@ public void GenerateWithMonikerStyleParameter( moniker.Should().MatchRegex(expected); } + + [Theory] + [InlineData(MonikerStyle.Moby)] + [InlineData(MonikerStyle.Moniker)] + public void GeneratePairWithSpecificMonikerStyleMethods(MonikerStyle monikerStyle) + { + Chars adjective, noun; + + switch (monikerStyle) + { + case MonikerStyle.Moniker: NameGenerator.GenerateMoniker(out adjective, out noun); break; + case MonikerStyle.Moby: NameGenerator.GenerateMoby(out adjective, out noun); break; + default: throw new ArgumentOutOfRangeException(nameof(monikerStyle), monikerStyle, null); + }; + const string expected = /* lang=regex */ "^[a-zA-Z]+$"; + adjective.ToString().Should().MatchRegex(expected); + noun.ToString().Should().MatchRegex(expected); + } + + [Theory] + [InlineData(MonikerStyle.Moby)] + [InlineData(MonikerStyle.Moniker)] + public void GeneratePairWithMonikerStyleParameter(MonikerStyle monikerStyle) + { + NameGenerator.Generate(monikerStyle, out var adjective, out var noun); + const string expected = /* lang=regex */ "^[a-zA-Z]+$"; + adjective.ToString().Should().MatchRegex(expected); + noun.ToString().Should().MatchRegex(expected); + } } } diff --git a/test/Moniker.Tests/StringDataTests.cs b/test/Moniker.Tests/StringDataTests.cs index 66ca3be..25887d2 100644 --- a/test/Moniker.Tests/StringDataTests.cs +++ b/test/Moniker.Tests/StringDataTests.cs @@ -66,15 +66,12 @@ into e var index = 0; using var line = lines.GetEnumerator(); - foreach (var u8Str in strings) + foreach (var chars in strings) { line.MoveNext().Should().BeTrue(); - var str = Encoding.UTF8.GetString(u8Str); - str.Should().Be(line.Current); - u8Str.ToString().Should().Be(line.Current); - - strings[index].Bytes.SequenceEqual(u8Str).Should().BeTrue(); - (u8Str == strings[index]).Should().BeTrue(); + var str = chars.ToString(); + chars.ToString().Should().Be(line.Current); + (chars == strings[index]).Should().BeTrue(); // Except for some coincidental cases, ensure string isn't interned. // This list is not exhaustive and even may be brittle (subject to