Cache.java

package com.maybeitssquid.safeascii.internal;

import java.util.HashMap;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReferenceArray;
import java.util.function.IntFunction;

/**
 * A {@link Chainable} implementation that caches the results of character processing to improve
 * performance.
 *
 * <p>This class maintains a cache of processed results to avoid re-computing values for the same
 * inputs. It employs a hybrid storage strategy:
 *
 * <ul>
 *   <li>A direct array is used for fast, thread-safe lookup of ASCII characters.
 *   <li>A bounded {@link HashMap} is used for all other codepoints.
 * </ul>
 *
 * This structure ensures minimal overhead for common ASCII characters while limiting memory used by
 * arbitrary non-ASCII input. Once the non-ASCII cache reaches {@link #MAX_NON_ASCII_ENTRIES}, later
 * values are processed but not retained.
 */
public class Cache extends Chainable {
  /** Maximum number of non-ASCII mappings retained by one pipeline. */
  public static final int MAX_NON_ASCII_ENTRIES = 4_096;

  private final AtomicReferenceArray<CharSequence> ascii = new AtomicReferenceArray<>(ASCII);
  private final HashMap<Integer, CharSequence> cache = new HashMap<>();

  /**
   * Creates a new Cache instance.
   *
   * @param delegate the next function in the chain to compute values for cache misses
   */
  public Cache(final IntFunction<CharSequence> delegate) {
    super(delegate);
  }

  /**
   * Manually adds a value to the cache for a specific codepoint.
   *
   * @param codepoint the Unicode codepoint to cache
   * @param value the processed string value to associate with the codepoint
   */
  public void cache(final int codepoint, final CharSequence value) {
    Objects.requireNonNull(value, "value");
    if (codepoint >= 0 && codepoint < ASCII) {
      ascii.set(codepoint, value);
    } else {
      synchronized (cache) {
        if (cache.containsKey(codepoint) || cache.size() < MAX_NON_ASCII_ENTRIES) {
          cache.put(codepoint, value);
        }
      }
    }
  }

  /**
   * Retrieves a cached value for the given codepoint.
   *
   * @param codepoint the character to look up
   * @return the cached {@link CharSequence} if available, otherwise {@code null}
   */
  @Override
  protected CharSequence process(final int codepoint) {
    if (codepoint >= 0 && codepoint < ASCII) {
      return ascii.get(codepoint);
    }
    synchronized (cache) {
      return cache.get(codepoint);
    }
  }

  /**
   * Returns the processed string for a given codepoint, using the cache if available.
   *
   * <p>If the value is found in the cache, it is returned immediately. If not, the request is
   * delegated to the upstream handler, and the result is cached for future use.
   *
   * @param value the codepoint to process
   * @return the processed {@link CharSequence}
   */
  @Override
  public CharSequence apply(final int value) {
    if (value >= 0 && value < ASCII) {
      final CharSequence cached = ascii.get(value);
      if (cached != null) {
        return cached;
      }
      synchronized (ascii) {
        final CharSequence synchronizedCached = ascii.get(value);
        if (synchronizedCached != null) {
          return synchronizedCached;
        }
        final CharSequence result = delegate(value);
        if (result != null) {
          ascii.set(value, result);
        }
        return result;
      }
    }

    synchronized (cache) {
      final CharSequence cached = cache.get(value);
      if (cached != null) {
        return cached;
      }
      final CharSequence result = delegate(value);
      if (result != null && cache.size() < MAX_NON_ASCII_ENTRIES) {
        cache.put(value, result);
      }
      return result;
    }
  }
}