SimpleSymbolCensor 0.4.0

dotnet add package SimpleSymbolCensor --version 0.4.0
                    
NuGet\Install-Package SimpleSymbolCensor -Version 0.4.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SimpleSymbolCensor" Version="0.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SimpleSymbolCensor" Version="0.4.0" />
                    
Directory.Packages.props
<PackageReference Include="SimpleSymbolCensor" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SimpleSymbolCensor --version 0.4.0
                    
#r "nuget: SimpleSymbolCensor, 0.4.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SimpleSymbolCensor@0.4.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SimpleSymbolCensor&version=0.4.0
                    
Install as a Cake Addin
#tool nuget:?package=SimpleSymbolCensor&version=0.4.0
                    
Install as a Cake Tool

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.0 and netstandard2.0.
  • Zero third-party runtime dependencies (just the .NET BCL plus System.Text.Json on netstandard2.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 (duckfuck) — 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 / AdditionalWords to bring your own.
  • Streaming / chunked input (Stream, IAsyncEnumerable<string>) — Apply works on whole strings.
  • Severity tiers — one flat list, no mild/strong/slur partitioning.
  • A CLI, web UI, or HTTP service wrapper.

License

MIT.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.4.0 75 5/26/2026
0.3.0 77 5/26/2026
0.2.0 67 5/26/2026
0.1.0 70 5/26/2026