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");
+ }
+}