Skip to content

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 →