TransliteratingASCII.java

package com.maybeitssquid.safeascii;

import static com.maybeitssquid.safeascii.internal.Chainable.ASCII;

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.*;
import java.util.function.IntFunction;

/**
 * A custom charset implementation that transliterates Unicode code points to ASCII characters using
 * a configurable transliteration function.
 *
 * <p>This charset provides bidirectional encoding and decoding between Unicode text and a
 * restricted ASCII-based byte representation. It uses a supplied transliterator function to map
 * Unicode code points to their ASCII equivalents (which may be zero or more characters).
 */
public class TransliteratingASCII extends Charset {

  /**
   * Default upper bound on the number of ASCII bytes a single input character may transliterate to,
   * used by the convenience constructor. The aggressive pipeline can expand one character into
   * several bytes; the longest known case is U+33AF SQUARE RAD OVER S SQUARED, which maps to {@code
   * "rad/s2"} (6 bytes). A transliterator that can expand further must be paired with a larger
   * bound via {@link #TransliteratingASCII(IntFunction, float, String...)}.
   */
  public static final float DEFAULT_MAX_BYTES_PER_CHAR = 6F;

  private final IntFunction<CharSequence> transliterator;

  private final float maxBytesPerChar;

  private static String[] aliases(final String[] names) {
    if (names.length > 1) {
      final String[] aliases = new String[names.length - 1];
      System.arraycopy(names, 1, aliases, 0, aliases.length);
      return aliases;
    } else {
      return new String[0];
    }
  }

  /**
   * Initializes a new charset with the given canonical name and alias set, declaring the maximum
   * number of ASCII bytes any single input character may transliterate to.
   *
   * <p>The bound is what {@link java.nio.charset.CharsetEncoder#maxBytesPerChar()} reports, so it
   * must not be underestimated: {@link String#getBytes(Charset)} sizes its output buffer from it
   * and would otherwise overflow. It must be at least {@code 1} (the encoder's average bytes per
   * char).
   *
   * @param transliterator The function to convert a code point into zero or more characters
   * @param maxBytesPerChar The largest number of ASCII bytes the transliterator emits for one input
   *     character; must be {@code >= 1}
   * @param names The canonical name of this charset followed by any aliases
   */
  protected TransliteratingASCII(
      final IntFunction<CharSequence> transliterator,
      final float maxBytesPerChar,
      final String... names) {
    super(names[0], aliases(names));
    this.transliterator = transliterator;
    this.maxBytesPerChar = maxBytesPerChar;
  }

  /**
   * Initializes a new charset with the given canonical name and alias set, using {@link
   * #DEFAULT_MAX_BYTES_PER_CHAR} as the maximum bytes-per-character bound.
   *
   * @param transliterator The function to convert a code point into zero or more characters
   * @param names The canonical name of this charset followed by any aliases
   * @see #TransliteratingASCII(IntFunction, float, String...)
   */
  protected TransliteratingASCII(
      final IntFunction<CharSequence> transliterator, final String... names) {
    this(transliterator, DEFAULT_MAX_BYTES_PER_CHAR, names);
  }

  /**
   * Determines whether this charset provides identity mapping for all ASCII characters.
   *
   * <p>This method verifies that the transliterator preserves every character in the ASCII range
   * (0x00-0x7F) without modification. For each ASCII code point, it checks that:
   *
   * <ul>
   *   <li>The transliterator returns a non-null result
   *   <li>The result contains exactly one character
   *   <li>That character is identical to the input character
   * </ul>
   *
   * <p>This method is used internally by {@link #contains(Charset)} to determine whether this
   * charset can be considered to contain {@link StandardCharsets#US_ASCII}. According to the {@code
   * Charset} specification, a charset <em>C</em> contains charset <em>D</em> if every character
   * representable in <em>D</em> is also representable in <em>C</em> with the same byte sequence.
   *
   * @return {@code true} if all ASCII characters (0x00-0x7F) are mapped to themselves by the
   *     transliterator; {@code false} otherwise
   * @see #contains(Charset)
   * @see StandardCharsets#US_ASCII
   */
  public boolean containsASCII() {
    for (char ch = 0; ch < ASCII; ch++) {
      CharSequence encoding = transliterator.apply(ch);
      if (encoding == null || encoding.length() != 1 || encoding.charAt(0) != ch) {
        return false;
      }
    }
    return true;
  }

  /**
   * Tests whether this charset contains the given charset.
   *
   * <p>Behavior: returns {@code true} if {@code cs} is this instance; {@code false} if {@code cs}
   * is {@code null}; delegates to {@link #containsASCII()} when {@link
   * java.nio.charset.StandardCharsets#US_ASCII} is supplied; otherwise {@code false}.
   *
   * @param cs the charset to test, may be {@code null}
   * @return {@code true} if this charset contains {@code cs} per {@link
   *     java.nio.charset.Charset#contains(Charset)}
   * @see java.nio.charset.Charset#contains(Charset)
   * @see #containsASCII()
   */
  @Override
  public boolean contains(final Charset cs) {
    if (this.equals(cs)) {
      return true;
    } else if (cs == null) {
      return false;
    } else if (StandardCharsets.US_ASCII.equals(cs)) {
      return containsASCII();
    } else {
      return false;
    }
  }

  /**
   * Creates a decoder that transliterates single bytes (0x00–0x7F) to characters. Only characters
   * that are allowed by the configured transliteration function are returned.
   *
   * <p>Recommendation: prefer standard decoders (e.g., UTF-8, US-ASCII, ISO-8859-1, or
   * windows-1252) for general-purpose ASCII decoding; they handle a wider range of inputs. Use this
   * decoder only when input processing must be extremely rigid and only exactly compliant input is
   * allowed.
   *
   * @return a {@link java.nio.charset.CharsetDecoder} that applies the transliterator to single
   *     bytes
   * @see java.nio.charset.CharsetDecoder
   * @see java.nio.charset.StandardCharsets
   */
  @Override
  public CharsetDecoder newDecoder() {
    return new CharsetDecoder(this, 1F, 1F) {
      @Override
      protected CoderResult decodeLoop(final ByteBuffer in, final CharBuffer out) {
        while (in.hasRemaining()) {
          final byte b = in.get(in.position());
          if (b >= 0) {
            final CharSequence transliterated = transliterator.apply(b);
            if (transliterated == null || transliterated.isEmpty()) {
              return CoderResult.unmappableForLength(1);
            } else if (out.remaining() >= 1) {
              in.position(in.position() + 1);
              out.put((char) b);
            } else {
              return CoderResult.OVERFLOW;
            }
          } else {
            return CoderResult.malformedForLength(1);
          }
        }
        return CoderResult.UNDERFLOW;
      }
    };
  }

  /**
   * Creates an encoder that maps Unicode code points to ASCII bytes using the transliterator.
   *
   * <p>Behavior: the transliterator is applied per code point. If the result is empty, the input is
   * reported unmappable for its length (1 or 2 for supplementary code points). If any resulting
   * character is > 0x7F the input is unmappable. If the output buffer lacks space the encoder
   * returns {@link java.nio.charset.CoderResult#OVERFLOW}. Valid transliterations are written as
   * their low-7-bit byte values.
   *
   * <p>The encoder consumes the correct input length for supplementary code points and uses {@code
   * '?'} as the replacement byte for unmappable input.
   *
   * @return a {@link java.nio.charset.CharsetEncoder} that emits ASCII bytes per the transliterator
   */
  @Override
  public CharsetEncoder newEncoder() {
    return new CharsetEncoder(this, 1F, maxBytesPerChar, new byte[] {(byte) '?'}) {
      @Override
      protected CoderResult encodeLoop(final CharBuffer in, final ByteBuffer out) {
        while (in.hasRemaining()) {
          final int codepoint = Character.codePointAt(in, 0);
          final int length = Character.isSupplementaryCodePoint(codepoint) ? 2 : 1;

          final CharSequence transliterated = transliterator.apply(codepoint);
          if (transliterated == null || transliterated.isEmpty()) {
            return CoderResult.unmappableForLength(length);
          } else if (transliterated.length() > out.remaining()) {
            return CoderResult.OVERFLOW;
          } else {
            final int mark = out.position();
            final int len = transliterated.length();
            for (int i = 0; i < len; i++) {
              final char c = transliterated.charAt(i);
              if (c >= ASCII) {
                out.position(mark);
                return CoderResult.unmappableForLength(length);
              } else {
                out.put((byte) c);
              }
            }
            in.position(in.position() + length);
          }
        }
        return CoderResult.UNDERFLOW;
      }
    };
  }
}