Punycode Converter — C# source
Convert internationalized domain names (IDN) between Unicode and Punycode (xn--) ACE form. RFC 3492 compliant, runs entirely in your browser, with a shareable link to your exact input.
This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// punycode - RFC 3492 Punycode encode/decode + IDNA2003 toASCII/toUnicode.
//
// Language: C# (.NET 6+, standard library only)
// Source: CosmoDev polyglot showcase port of the Punycode tool, ported from
// src/lib/punycode.ts (the canonical TypeScript implementation) and
// cli/punycode/punycode.go (the live Go CLI twin).
// License: display source - part of CosmoDev's polyglot tool pages.
//
// Design goals:
// - Pure + deterministic; encode never throws, decode returns null on
// malformed input.
// - Functionally equivalent to the TS/Go reference: same inputs -> same
// outputs.
// - Self-contained: stdlib only. String.EnumerateRunes() yields the code
// points, so astral characters (emoji, CJK extensions) are single
// elements, matching Go runes and the TS code-point iteration; long
// carries the RFC 3492 arithmetic with the 2^53-1
// (Number.MAX_SAFE_INTEGER) overflow guard; code points beyond U+10FFFF
// reject the label.
#nullable enable
using System;
using System.Collections.Generic;
using System.Text;
namespace Punycode;
public static class Punycode
{
private const int Base = 36;
private const int TMin = 1;
private const int TMax = 26;
private const int Skew = 38;
private const int Damp = 700;
private const int InitialBias = 72;
private const int InitialN = 128;
private const string AcePrefix = "xn--";
private const long MaxInt = 9007199254740991; // 2^53-1 overflow guard
/// <summary>Bias adaptation (RFC 3492 section 6.1).</summary>
private static long Adapt(long delta, long numpoints, bool firsttime)
{
long d = firsttime ? delta / Damp : delta / 2;
d += d / numpoints;
long k = 0;
while (d > (Base - TMin) * TMax / 2)
{
d /= Base - TMin;
k += Base;
}
return k + (Base - TMin + 1) * d / (d + Skew);
}
/// <summary>Map a digit value (0-35) to its base-36 character (lowercase).</summary>
private static char DigitToChar(long d) => d < 26 ? (char)('a' + d) : (char)('0' + (d - 26));
/// <summary>Map a character to its digit value (0-35), or -1 when invalid.</summary>
private static long CharToDigit(char c) => c switch
{
>= 'a' and <= 'z' => c - 'a',
>= 'A' and <= 'Z' => c - 'A',
>= '0' and <= '9' => c - '0' + 26,
_ => -1,
};
/// <summary>True if the string contains any non-ASCII code point (>= 128).</summary>
private static bool HasNonAscii(string s)
{
foreach (char c in s)
if (c >= 128) return true; // surrogates (astral chars) are >= 128
return false;
}
/// <summary>
/// Punycode-encode a single label (RFC 3492), no ACE prefix. Basic code
/// points are emitted first, then a '-' delimiter (only if there was at
/// least one), then the generalized-base-36 deltas.
/// </summary>
public static string EncodeLabel(string input)
{
List<int> codePoints = new();
foreach (int cp in input.EnumerateCodePoints()) codePoints.Add(cp);
long length = codePoints.Count;
StringBuilder output = new();
long b = 0;
foreach (int cp in codePoints)
if (cp < 128)
{
output.Append((char)cp);
b++;
}
if (b > 0) output.Append('-');
long n = InitialN, delta = 0, bias = InitialBias, h = b;
while (h < length)
{
long m = long.MaxValue; // smallest code point in the input that is >= n
foreach (int cp in codePoints)
if (cp >= n && cp < m) m = cp;
delta += (m - n) * (h + 1);
n = m;
foreach (int cp in codePoints)
{
if (cp < n)
{
delta += 1;
}
else if (cp == n)
{
long q = delta;
for (long k = Base; ; k += Base)
{
long t = Math.Max(TMin, Math.Min(TMax, k - bias));
if (q < t) break;
output.Append(DigitToChar(t + (q - t) % (Base - t)));
q = (q - t) / (Base - t);
}
output.Append(DigitToChar(q));
bias = Adapt(delta, h + 1, h == b);
delta = 0;
h += 1;
}
}
delta += 1;
n += 1;
}
return output.ToString();
}
/// <summary>
/// Punycode-decode a single label (RFC 3492). Returns null when the input
/// is malformed (invalid digit, truncated generalized number, non-ASCII in
/// the basic portion, code point beyond U+10FFFF, or overflow).
/// </summary>
public static string? DecodeLabel(string input)
{
int lastDash = input.LastIndexOf('-');
List<int> output = new();
if (lastDash >= 0)
{
for (int i = 0; i < lastDash; i++)
{
if (input[i] >= 128) return null; // basic portion must be ASCII
output.Add(input[i]);
}
}
string ext = lastDash >= 0 ? input[(lastDash + 1)..] : input;
long n = InitialN, i = 0, bias = InitialBias, pos = 0;
while (pos < ext.Length)
{
long oldi = i, w = 1;
for (long k = Base; ; k += Base)
{
if (pos >= ext.Length) return null; // truncated generalized number
long digit = CharToDigit(ext[(int)pos]);
if (digit < 0) return null; // invalid digit
pos += 1;
if (digit >= MaxInt / w) return null; // overflow guard
i += digit * w;
long t = Math.Max(TMin, Math.Min(TMax, k - bias));
if (digit < t) break;
w *= Base - t;
}
bias = Adapt(i - oldi, output.Count + 1, oldi == 0);
long outLen = output.Count + 1;
n += i / outLen;
i %= outLen;
if (n > 0x10FFFF) return null;
output.Insert((int)i, (int)n);
i += 1;
}
StringBuilder sb = new();
foreach (int cp in output) sb.Append(char.ConvertFromUtf32(cp));
return sb.ToString();
}
/// <summary>
/// IDNA toASCII: lowercase the domain, ACE-encode ("xn--" + Punycode) any
/// label containing a non-ASCII code point, leave ASCII-only labels
/// untouched. Empty input returns empty.
/// </summary>
public static string Encode(string domain)
{
if (domain.Length == 0) return "";
string lower = domain.ToLowerInvariant();
string[] labels = lower.Split('.'); // keeps empty labels, like the TS split('.')
for (int idx = 0; idx < labels.Length; idx++)
if (HasNonAscii(labels[idx]))
labels[idx] = AcePrefix + EncodeLabel(labels[idx]);
return string.Join('.', labels);
}
/// <summary>
/// IDNA toUnicode: decode any "xn--" label (case-insensitive, prefix
/// detected on the lowercased label), leave every other label untouched.
/// Returns null when any "xn--" label is invalid - the whole domain is
/// rejected, matching IDNA semantics. Empty input returns empty.
/// </summary>
public static string? Decode(string domain)
{
if (domain.Length == 0) return "";
string[] labels = domain.Split('.');
string[] decoded = new string[labels.Length];
for (int idx = 0; idx < labels.Length; idx++)
{
string label = labels[idx];
if (label.ToLowerInvariant().StartsWith(AcePrefix, StringComparison.Ordinal) &&
label.Length > AcePrefix.Length)
{
string? result = DecodeLabel(label[AcePrefix.Length..]);
if (result is null) return null;
decoded[idx] = result;
}
else
{
decoded[idx] = label;
}
}
return string.Join('.', decoded);
}
/// <summary>Enumerate a string by Unicode code point (surrogate pairs joined).</summary>
private static IEnumerable<int> EnumerateCodePoints(this string s)
{
for (int i = 0; i < s.Length; i++)
{
if (char.IsHighSurrogate(s[i]) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1]))
{
yield return char.ConvertToUtf32(s[i], s[i + 1]);
i++;
}
else
{
yield return s[i];
}
}
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →