diff --git a/LanguageTags/LanguageTag.cs b/LanguageTags/LanguageTag.cs index 55921b8..cc60ac2 100644 --- a/LanguageTags/LanguageTag.cs +++ b/LanguageTags/LanguageTag.cs @@ -320,16 +320,41 @@ public ExtensionTag() public override string ToString() => Tags.IsEmpty ? string.Empty : $"{Prefix}-{string.Join('-', Tags)}"; - /// - /// Creates a new extension tag with sorted and lowercased tags. - /// - /// A normalized copy of this extension tag. internal ExtensionTag Normalize() => this with { Prefix = char.ToLowerInvariant(Prefix), Tags = [.. Tags.Select(t => t.ToLowerInvariant()).OrderBy(t => t)], }; + + /// + /// Determines whether this instance is equal to another . + /// + /// The to compare with. + /// true if the extension tags are equal; otherwise, false. + public bool Equals(ExtensionTag? other) => + ReferenceEquals(this, other) + || ( + other is not null + && char.ToLowerInvariant(Prefix) == char.ToLowerInvariant(other.Prefix) + && Tags.SequenceEqual(other.Tags, StringComparer.OrdinalIgnoreCase) + ); + + /// + /// Returns the hash code for this extension tag. + /// + /// A hash code for the current extension tag. + public override int GetHashCode() + { + HashCode hashCode = new(); + hashCode.Add(char.ToLowerInvariant(Prefix)); + foreach (string tag in Tags) + { + hashCode.Add(tag, StringComparer.OrdinalIgnoreCase); + } + + return hashCode.ToHashCode(); + } } /// @@ -363,13 +388,33 @@ public PrivateUseTag() public override string ToString() => Tags.IsEmpty ? string.Empty : $"{Prefix}-{string.Join('-', Tags)}"; - /// - /// Creates a new private use tag with sorted and lowercased tags. - /// - /// A normalized copy of this private use tag. internal PrivateUseTag Normalize() => this with { Tags = [.. Tags.Select(t => t.ToLowerInvariant()).OrderBy(t => t)], }; + + /// + /// Determines whether this instance is equal to another . + /// + /// The to compare with. + /// true if the private use tags are equal; otherwise, false. + public bool Equals(PrivateUseTag? other) => + ReferenceEquals(this, other) + || (other is not null && Tags.SequenceEqual(other.Tags, StringComparer.OrdinalIgnoreCase)); + + /// + /// Returns the hash code for this private use tag. + /// + /// A hash code for the current private use tag. + public override int GetHashCode() + { + HashCode hashCode = new(); + foreach (string tag in Tags) + { + hashCode.Add(tag, StringComparer.OrdinalIgnoreCase); + } + + return hashCode.ToHashCode(); + } } diff --git a/LanguageTags/LanguageTagBuilder.cs b/LanguageTags/LanguageTagBuilder.cs index f45dc44..8e9fd78 100644 --- a/LanguageTags/LanguageTagBuilder.cs +++ b/LanguageTags/LanguageTagBuilder.cs @@ -82,10 +82,17 @@ public LanguageTagBuilder VariantAddRange(IEnumerable values) /// The extension values. /// The builder instance for method chaining. /// Thrown when is null. + /// Thrown when is empty. public LanguageTagBuilder ExtensionAdd(char prefix, IEnumerable values) { ArgumentNullException.ThrowIfNull(values); - _languageTag._extensions.Add(new ExtensionTag(prefix, values)); + ImmutableArray tags = [.. values]; + if (tags.IsEmpty) + { + throw new ArgumentException("Extension tags cannot be empty.", nameof(values)); + } + + _languageTag._extensions.Add(new ExtensionTag(prefix, tags)); return this; } diff --git a/LanguageTags/LanguageTagParser.cs b/LanguageTags/LanguageTagParser.cs index 931c4c2..e489098 100644 --- a/LanguageTags/LanguageTagParser.cs +++ b/LanguageTags/LanguageTagParser.cs @@ -290,7 +290,9 @@ private static bool ValidateExtensionPrefix(string tag) => private static bool ValidateExtension(string tag) => // 2 - 8 chars - !string.IsNullOrEmpty(tag) && tag.Length is >= 2 and <= 8; + !string.IsNullOrWhiteSpace(tag) + && tag.Length is >= 2 and <= 8 + && !tag.Any(char.IsWhiteSpace); private bool ParseExtension() { @@ -788,7 +790,8 @@ internal static bool Validate(LanguageTag languageTag) } if ( languageTag._extensions.Any(extension => - !ValidateExtensionPrefix(extension.Prefix.ToString()) + extension.Tags.IsEmpty + || !ValidateExtensionPrefix(extension.Prefix.ToString()) || extension.Tags.Any(tag => !ValidateExtension(tag)) ) ) diff --git a/LanguageTagsCreate/HttpClientFactory.cs b/LanguageTagsCreate/HttpClientFactory.cs index c97c65e..db37c50 100644 --- a/LanguageTagsCreate/HttpClientFactory.cs +++ b/LanguageTagsCreate/HttpClientFactory.cs @@ -28,8 +28,11 @@ private static ResilienceHandler CreateResilienceHandler() => MaxDelay = TimeSpan.FromSeconds(30), ShouldHandle = args => ValueTask.FromResult( - args.Outcome.Result != null - && !args.Outcome.Result.IsSuccessStatusCode + args.Outcome.Exception != null + || ( + args.Outcome.Result != null + && !args.Outcome.Result.IsSuccessStatusCode + ) ), } ) @@ -42,8 +45,11 @@ private static ResilienceHandler CreateResilienceHandler() => BreakDuration = TimeSpan.FromSeconds(30), ShouldHandle = args => ValueTask.FromResult( - args.Outcome.Result != null - && !args.Outcome.Result.IsSuccessStatusCode + args.Outcome.Exception != null + || ( + args.Outcome.Result != null + && !args.Outcome.Result.IsSuccessStatusCode + ) ), } ) diff --git a/LanguageTagsCreate/Program.cs b/LanguageTagsCreate/Program.cs index afd8145..c904236 100644 --- a/LanguageTagsCreate/Program.cs +++ b/LanguageTagsCreate/Program.cs @@ -8,10 +8,6 @@ CancellationToken cancellationToken private const string DataDirectory = "LanguageData"; private const string CodeDirectory = "LanguageTags"; - internal CommandLine.Options GetCommandLineOptions() => commandLineOptions; - - internal CancellationToken GetCancellationToken() => cancellationToken; - internal static async Task Main(string[] args) { // Parse commandline diff --git a/LanguageTagsTests/LanguageTagBuilderTests.cs b/LanguageTagsTests/LanguageTagBuilderTests.cs index e7e800e..da91798 100644 --- a/LanguageTagsTests/LanguageTagBuilderTests.cs +++ b/LanguageTagsTests/LanguageTagBuilderTests.cs @@ -125,6 +125,10 @@ public void Build_Fail() // Extension prefix 1 char, not x languageTag = new LanguageTagBuilder().Language("en").ExtensionAdd('x', ["abcd"]).Build(); _ = languageTag.Validate().Should().BeFalse(); + + // Extension tags must not be whitespace + languageTag = new LanguageTagBuilder().Language("en").ExtensionAdd('a', [" "]).Build(); + _ = languageTag.Validate().Should().BeFalse(); } [Fact] @@ -161,6 +165,16 @@ public void ExtensionAdd_ThrowsOnNull() .NotBeNull(); } + [Fact] + public void ExtensionAdd_ThrowsOnEmpty() + { + LanguageTagBuilder builder = new(); + _ = Assert + .Throws(() => builder.ExtensionAdd('u', [])) + .Should() + .NotBeNull(); + } + [Fact] public void PrivateUseAddRange_ThrowsOnNull() { diff --git a/LanguageTagsTests/LanguageTagParserTests.cs b/LanguageTagsTests/LanguageTagParserTests.cs index 227f3ba..eb51c1c 100644 --- a/LanguageTagsTests/LanguageTagParserTests.cs +++ b/LanguageTagsTests/LanguageTagParserTests.cs @@ -95,6 +95,7 @@ public void Normalize_Sort_Pass(string tag, string parsed) [InlineData("en-gb-abcde-abcde")] // Variant repeats [InlineData("en-gb-a-abcd-a-abcde")] // Extension prefix repeats [InlineData("en-gb-a-abcd-abcd")] // Extension tag repeats + [InlineData("en-a- ")] // Extension tag whitespace [InlineData("en-gb-x-abcd-x-abcd")] // Private prefix repeats [InlineData("en-gb-x-abcd-abcd")] // Private tag repeats public void Parse_Fail(string tag) => _ = new LanguageTagParser().Parse(tag).Should().BeNull(); diff --git a/LanguageTagsTests/LanguageTagTests.cs b/LanguageTagsTests/LanguageTagTests.cs index 0228f87..4d59d25 100644 --- a/LanguageTagsTests/LanguageTagTests.cs +++ b/LanguageTagsTests/LanguageTagTests.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using System.Linq; namespace ptr727.LanguageTags.Tests; @@ -564,16 +563,15 @@ public void ExtensionTag_EnumerableConstructor_CreatesTag() public void ExtensionTag_RecordEquality_WorksCorrectly() { ExtensionTag ext1 = new('u', ["ca", "buddhist"]); - ExtensionTag ext2 = new('u', ["ca", "buddhist"]); + ExtensionTag ext2 = new('U', ["CA", "BUDDHIST"]); ExtensionTag ext3 = new('t', ["ca", "buddhist"]); - // Records with ImmutableArray need element-wise comparison - _ = ext1.Prefix.Should().Be(ext2.Prefix); - _ = ext1.Tags.SequenceEqual(ext2.Tags).Should().BeTrue(); - _ = ext1.ToString().Should().Be(ext2.ToString()); + _ = ext1.Equals(ext2).Should().BeTrue(); + _ = (ext1 == ext2).Should().BeTrue(); + _ = ext1.GetHashCode().Should().Be(ext2.GetHashCode()); - _ = ext1.Prefix.Should().NotBe(ext3.Prefix); - _ = ext1.ToString().Should().NotBe(ext3.ToString()); + _ = ext1.Equals(ext3).Should().BeFalse(); + _ = (ext1 != ext3).Should().BeTrue(); } [Fact] @@ -599,15 +597,15 @@ public void PrivateUseTag_EnumerableConstructor_CreatesTag() public void PrivateUseTag_RecordEquality_WorksCorrectly() { PrivateUseTag priv1 = new(["private1", "private2"]); - PrivateUseTag priv2 = new(["private1", "private2"]); + PrivateUseTag priv2 = new(["PRIVATE1", "PRIVATE2"]); PrivateUseTag priv3 = new(["other"]); - // Records with ImmutableArray need element-wise comparison - _ = priv1.Tags.SequenceEqual(priv2.Tags).Should().BeTrue(); - _ = priv1.ToString().Should().Be(priv2.ToString()); + _ = priv1.Equals(priv2).Should().BeTrue(); + _ = (priv1 == priv2).Should().BeTrue(); + _ = priv1.GetHashCode().Should().Be(priv2.GetHashCode()); - _ = priv1.Tags.SequenceEqual(priv3.Tags).Should().BeFalse(); - _ = priv1.ToString().Should().NotBe(priv3.ToString()); + _ = priv1.Equals(priv3).Should().BeFalse(); + _ = (priv1 != priv3).Should().BeTrue(); } [Fact]