SimpleSymbolCensor 0.4.0
dotnet add package SimpleSymbolCensor --version 0.4.0
NuGet\Install-Package SimpleSymbolCensor -Version 0.4.0
<PackageReference Include="SimpleSymbolCensor" Version="0.4.0" />
<PackageVersion Include="SimpleSymbolCensor" Version="0.4.0" />
<PackageReference Include="SimpleSymbolCensor" />
paket add SimpleSymbolCensor --version 0.4.0
#r "nuget: SimpleSymbolCensor, 0.4.0"
#:package SimpleSymbolCensor@0.4.0
#addin nuget:?package=SimpleSymbolCensor&version=0.4.0
#tool nuget:?package=SimpleSymbolCensor&version=0.4.0
SimpleSymbolCensor
A lightweight, dependency-free profanity censoring library for .NET. Configurable replacement strategies, per-letter symbol-map matching for evasion resistance, allow-list support, and optional Levenshtein fuzzy matching.
- Multi-targets
net8.0andnetstandard2.0. - Zero third-party runtime dependencies (just the .NET BCL plus
System.Text.Jsononnetstandard2.0, where it isn't in the BCL). - Deterministic build + Source Link.
- MIT licensed.
Install
dotnet add package SimpleSymbolCensor
Quick start
using SimpleSymbolCensor;
var censor = new Censor();
censor.Apply("oh shit"); // "oh ****"
censor.Apply("oh sh!t"); // "oh ****" — symbol substitution caught
censor.Apply("oh s.h.i.t"); // "oh *******" — single-char separators caught
censor.Contains("hello world"); // false
censor.Contains("you suck"); // true
Replacement strategies
Pass a ReplacementStrategy to Apply to control what every match is replaced with. The no-strategy overload uses PerCharacterSymbol('*').
PerCharacterSymbol
Replaces every character of the match with one symbol. Match length is preserved.
censor.Apply("you suck"); // "you ****"
censor.Apply("you suck", new PerCharacterSymbol('#')); // "you ####"
FixedMark
Replaces the entire match with a fixed string, regardless of the original length.
censor.Apply("you suck", new FixedMark()); // "you [censored]"
censor.Apply("you suck", new FixedMark("[redacted]")); // "you [redacted]"
Grawlix
Replaces every character of the match with a random symbol from a configured set. Match length is preserved. Pass a seed for deterministic output.
censor.Apply("you suck", new Grawlix()); // e.g. "you %#@!"
censor.Apply("you suck", new Grawlix("@#$%", seed: 42)); // deterministic 4-char from "@#$%"
Grawlix is thread-safe — a single instance can be shared across calls and threads.
Configuration
Everything goes through CensorOptions at construction time. Censor instances are immutable afterwards.
Word list
// Replace the default list entirely:
var custom = new Censor(new CensorOptions
{
Words = new[] { "frobnicate", "widget" },
});
// Add to the defaults:
var extended = new Censor(new CensorOptions
{
AdditionalWords = new[] { "frobnicate" },
});
// Remove specific entries:
var trimmed = new Censor(new CensorOptions
{
RemovedWords = new[] { "damn" },
});
Allow-list (the Scunthorpe problem)
Words on the allow-list are never censored, even if they contain a profanity substring. Matching is case-insensitive against the surrounding word.
A small built-in default allow-list ships with the library covering common English false positives — Scunthorpe, Hancock, classic, assassin, passport, cockpit, Dickinson, shiitake, Wankel, etc. (the full list is in Resources/default-allow-list.json). It is active by default; you do not need to opt in.
// Default allow-list is on out of the box:
new Censor().Apply("I live in Scunthorpe"); // "I live in Scunthorpe"
new Censor().Apply("the assassin walks in"); // "the assassin walks in"
new Censor().Apply("what a cunt"); // "what a ****" — standalone still censored
// Extend the defaults (recommended):
var extended = new Censor(new CensorOptions
{
AdditionalAllowList = new[] { "Yourtown", "YourCompanyName" },
});
// Replace the defaults outright (advanced — you lose Scunthorpe/classic/cockpit etc.):
var custom = new Censor(new CensorOptions
{
AllowList = new[] { "Yourtown" },
});
Symbol map
The per-letter symbol map controls which symbols count as which letter when matching. Defaults cover all of a–z with common leet-style substitutions, Latin-1 + Latin Extended-A accented forms (shít, fück), multi-character ASCII art (fuc|( → "fuck"), and a small set of emoji homoglyphs (🅱itch, 🅾ne for "o"-substituted words). Substitutes can be single or multi-character strings — anything from "@" to "|\\|" to a supplementary-plane emoji like "🅱". Override or extend:
// Add a substitute ("%" for the letter "i") on top of the defaults:
var censor = new Censor(new CensorOptions
{
AdditionalSymbols = new Dictionary<char, IReadOnlyList<string>>
{
{ 'i', new[] { "%" } },
},
});
// Multi-character substitutes work the same way:
var withAsciiArt = new Censor(new CensorOptions
{
AdditionalSymbols = new Dictionary<char, IReadOnlyList<string>>
{
{ 'k', new[] { "|<" } }, // ASCII art for k
{ 'b', new[] { "\U0001F171" } }, // 🅱 emoji
},
});
// Or replace the map outright (e.g. to disable symbol substitution entirely):
var literalOnly = new Dictionary<char, IReadOnlyList<string>>();
for (var c = 'a'; c <= 'z'; c++) literalOnly[c] = new[] { c.ToString() };
var strict = new Censor(new CensorOptions { SymbolMap = literalOnly });
Fuzzy matching (optional, off by default)
MaxEditDistance opts in to a Levenshtein-distance fuzzy pass on top of the regex / symbol-map pass.
var fuzzy = new Censor(new CensorOptions { MaxEditDistance = 1 });
fuzzy.Apply("oh shet"); // "oh ****" — 1 edit from "shit"
fuzzy.Apply("oh shiet"); // "oh *****" — 1 insert from "shit"
The fuzzy pass only considers words of length ≥ 2 * MaxEditDistance + 2, which keeps shorter profanity (4–5 letters) out of the candidate pool at higher distances where collisions with benign words (quick/prick, that/twat) get uncomfortable. Even at MaxEditDistance = 1, identical-length 4-letter pairs are an inherent collision (duck → fuck) — fuzzy matching is opt-in for callers who can tolerate that trade-off.
Inspection API
Detect without replacing.
censor.Contains("hello world"); // false
censor.Contains("you suck"); // true
foreach (var m in censor.FindMatches("fuck this shit"))
{
Console.WriteLine($"{m.Word} at {m.Index} (length {m.Length}): {m.MatchedText}");
// fuck at 0 (length 4): fuck
// shit at 10 (length 4): shit
}
FindMatches returns hits left-to-right with overlap resolved leftmost-longest, so you asshole produces one match for asshole, not separate matches for ass and asshole.
Evasion tactics handled
- Case variations:
SHIT,Shit,sHiT. - Symbol substitution from the configured per-letter map:
sh!t,f*ck,a$$,$#1t. - Latin-1 + Latin Extended-A accented forms:
shít,fück,wankér. - Multi-character ASCII art:
fuc|((k),nãw |\|o sh\/t(n, v). - Emoji homoglyphs: 🅱itch, 🅾one for "o"-substituted words, 🅰rse.
- Repeated characters:
fuuuuck,shiiit. - Single-character non-word separators between letters:
s.h.i.t,s h i t,s-h-i-t. - Optional Levenshtein fuzzy matching (opt-in via
MaxEditDistance).
Thread safety
A configured Censor is safe to share across threads. All internal state (compiled regexes, resolved word list, allow-list) is constructor-immutable. All three replacement strategies are also thread-safe; Grawlix uses an internal lock around its shared Random.
Dependency injection
Censor implements ICensor (added in v0.4), so it drops straight into any Microsoft.Extensions.DependencyInjection-compatible container. Register it as a singleton — the heavy work (regex compilation) happens once in the constructor and the result is immutable + thread-safe.
// Program.cs / Startup.cs
builder.Services.AddSingleton<ICensor>(_ => new Censor(new CensorOptions
{
AdditionalAllowList = new[] { "Yourtown", "YourCompanyName" },
}));
// Any service / controller / minimal-API handler
public class CommentService(ICensor censor)
{
public string CleanUp(string body) => censor.Apply(body);
}
For unit tests that depend on ICensor, hand-roll a fake or use your usual mocking library — ICensor is small (Apply × 2, Contains, FindMatches) so a fake takes a few lines.
Out of scope (v1)
- Multi-language word lists — only English ships in the defaults. Use
Words/AdditionalWordsto bring your own. - Streaming / chunked input (
Stream,IAsyncEnumerable<string>) —Applyworks on whole strings. - Severity tiers — one flat list, no mild/strong/slur partitioning.
- A CLI, web UI, or HTTP service wrapper.
License
MIT.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- System.Text.Json (>= 8.0.5)
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.