diff --git a/src/ProfanityFilter.Services/Extensions/RandomExtensions.cs b/src/ProfanityFilter.Services/Extensions/RandomExtensions.cs new file mode 100644 index 0000000..d121cbe --- /dev/null +++ b/src/ProfanityFilter.Services/Extensions/RandomExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.Services.Extensions; + +internal static class RandomExtensions +{ + /// + /// Gets a random subset of items from the source array, with the + /// specified length, while respecting the limits. + /// + internal static TItem[] RandomItemsWithLimitToOne( + this TItem[] array, + int length, + TItem limit) where TItem : notnull + { + var choices = array.Except([limit]).ToArray(); + + var items = Random.Shared.GetItems(choices, length); + + var index = Random.Shared.Next(0, items.Length); + + items[index] = limit; + + return items; + } +} diff --git a/src/ProfanityFilter.Services/Filters/Symbols.cs b/src/ProfanityFilter.Services/Filters/Symbols.cs index 3abbd89..38bdbf9 100644 --- a/src/ProfanityFilter.Services/Filters/Symbols.cs +++ b/src/ProfanityFilter.Services/Filters/Symbols.cs @@ -5,19 +5,31 @@ namespace ProfanityFilter.Services.Filters; internal static class Symbols { + /// + /// Only permit a single "\$" string within an entire grawlix + /// replacement, to avoid GitHub from rendering it as a Math expression. + /// + internal const string LimitGrawlixToOne = $"\\{UnescapedLimitGrawlixToOne}"; + + /// + /// Only permit a single "$" string within an entire grawlix + /// replacement, to avoid GitHub from rendering it as a Math expression. + /// + internal const string UnescapedLimitGrawlixToOne = "$"; + /// /// An array of hand-selected grawlix replacements for profane words. /// See /// internal static string[] Grawlixes = [ - new Symbol('#', true), + new Symbol('#'), new Symbol('$'), new Symbol('@'), - new Symbol('!', true), + new Symbol('!'), new Symbol('%'), new Symbol('&'), - new Symbol('*', true), + new Symbol('*'), new Symbol('+'), new Symbol('?'), new Symbol('^'), @@ -41,7 +53,7 @@ internal static class Symbols ]; } -file readonly record struct Symbol(char Value, bool RequiresEscaping = false) +file readonly record struct Symbol(char Value, bool Escape = true) { /// /// Implicitly converts a to a . @@ -55,6 +67,6 @@ file readonly record struct Symbol(char Value, bool RequiresEscaping = false) public override string ToString() { - return RequiresEscaping ? $"\\{Value}" : $"{Value}"; + return Escape ? $"\\{Value}" : $"{Value}"; } } diff --git a/src/ProfanityFilter.Services/GlobalUsings.cs b/src/ProfanityFilter.Services/GlobalUsings.cs index d98b8cd..a9877a3 100644 --- a/src/ProfanityFilter.Services/GlobalUsings.cs +++ b/src/ProfanityFilter.Services/GlobalUsings.cs @@ -4,12 +4,16 @@ global using System.Collections.Concurrent; global using System.Collections.Frozen; global using System.Diagnostics.CodeAnalysis; +global using System.Runtime.InteropServices; global using System.Text; global using System.Text.RegularExpressions; + global using Microsoft.Extensions.Caching.Memory; global using Microsoft.Extensions.DependencyInjection; + global using Pathological.Globbing.Extensions; global using Pathological.Globbing.Options; + global using ProfanityFilter.Services.Extensions; global using ProfanityFilter.Services.Filters; global using ProfanityFilter.Services.Results; diff --git a/src/ProfanityFilter.Services/Internals/MatchEvaluators.cs b/src/ProfanityFilter.Services/Internals/MatchEvaluators.cs index 6a1f59f..9281fab 100644 --- a/src/ProfanityFilter.Services/Internals/MatchEvaluators.cs +++ b/src/ProfanityFilter.Services/Internals/MatchEvaluators.cs @@ -20,8 +20,12 @@ internal static MatchEvaluator GrawlixEvaluator(FilterParameters parameters) => var grawlixes = isNotTitle ? Symbols.Grawlixes : Symbols.UnescapedGrawlixes; + var limit = isNotTitle ? Symbols.LimitGrawlixToOne : Symbols.UnescapedLimitGrawlixToOne; + + var randomGrawlix = grawlixes.RandomItemsWithLimitToOne(match.Length, limit); + var result = string.Join( - "", Random.Shared.GetItems(grawlixes, match.Length)); + "", randomGrawlix); return result; }; @@ -38,9 +42,14 @@ internal static MatchEvaluator BoldGrawlixEvaluator(FilterParameters parameters) var grawlixes = isNotTitle ? Symbols.Grawlixes : Symbols.UnescapedGrawlixes; + var limit = isNotTitle ? Symbols.LimitGrawlixToOne : Symbols.UnescapedLimitGrawlixToOne; + + var randomGrawlix = grawlixes.RandomItemsWithLimitToOne(match.Length, limit); + var result = string.Join( - "", Random.Shared.GetItems(grawlixes, match.Length)); + "", randomGrawlix); + // Titles cannot be bolded return isNotTitle ? $"__{result}__" : result; }; diff --git a/tests/ProfanityFilter.Services.Tests/MatchEvaluatorTests.cs b/tests/ProfanityFilter.Services.Tests/MatchEvaluatorTests.cs index 112b16d..b68bdf5 100644 --- a/tests/ProfanityFilter.Services.Tests/MatchEvaluatorTests.cs +++ b/tests/ProfanityFilter.Services.Tests/MatchEvaluatorTests.cs @@ -41,7 +41,7 @@ public void RandomAsteriskEvaluator_Returns_Expected_Result() [Fact] public void GrawlixEvaluator_Returns_Expected_Result() { - var input = "swear"; + var input = "someReallyLongSwearWordOrPhrase"; var regex = TestRegex(); var match = regex.Match(input); var sut = MatchEvaluators.GrawlixEvaluator; @@ -50,6 +50,7 @@ public void GrawlixEvaluator_Returns_Expected_Result() var actual = sut(parameters).Invoke(match); Assert.NotEqual(input, actual); + Assert.Single(actual.Where(@char => @char is '$')); } [Fact] @@ -64,6 +65,7 @@ public void BoldGrawlixEvaluator_Returns_Expected_Result() var actual = sut(parameters).Invoke(match); Assert.NotEqual(input, actual); + Assert.Single(actual.Where(@char => @char is '$')); } [Fact] diff --git a/tests/ProfanityFilter.Services.Tests/RandomExtensionsTests.cs b/tests/ProfanityFilter.Services.Tests/RandomExtensionsTests.cs new file mode 100644 index 0000000..7df20e3 --- /dev/null +++ b/tests/ProfanityFilter.Services.Tests/RandomExtensionsTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.Services.Tests; + +public class RandomExtensionsTests +{ + [Fact] + public void RandomItemsWithLimitsReturnsRandomSubsetOfItems() + { + // Arrange + var source = new[] { "a", "b", "c", "d", "e" }; + + // Act + var result = source.RandomItemsWithLimitToOne(3, ""); + + // Assert + Assert.Equal(3, result.Length); + } + + [Fact] + public void RandomItemsWithLimitsRespectsLimits() + { + // Arrange + var source = new[] { "a", "b", "c", "d", "e" }; + + // Act + var result = source.RandomItemsWithLimitToOne(3, "a"); + + // Assert + Assert.Equal(3, result.Length); + Assert.Single(result.Where(str => str is "a")); + } + + [Fact] + public void RandomItemsWithLimitsReturnsHugeArrayWithOnlyOneLimitedValue() + { + // Arrange + var source = new[] { "a", "b", "c", "d", "e" }; + + // Act + var result = source.RandomItemsWithLimitToOne(100_000, "c"); + + // Assert + Assert.Contains(result, str => str is "a"); + Assert.Contains(result, str => str is "b"); + Assert.Single(result.Where(str => str is "c")); + Assert.Contains(result, str => str is "d"); + Assert.Contains(result, str => str is "e"); + } +}