AbstractStringHolder.java

/*
 * stringhold
 *
 * Copyright 2022-2024 Christian Kohlschütter
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.kohlschutter.stringhold;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.util.Objects;

import com.kohlschutter.annotations.compiletime.ExcludeFromCodeCoverageGeneratedReport;
import com.kohlschutter.annotations.compiletime.SuppressFBWarnings;
import com.kohlschutter.util.ComparisonUtil;

/**
 * Base implementation of a {@link StringHolder}.
 *
 * @author Christian Kohlschütter
 */
@SuppressWarnings({
    "PMD.CyclomaticComplexity", "PMD.ExcessiveClassLength", "PMD.ExcessivePublicCount"})
public abstract class AbstractStringHolder extends CharSequenceReleaseShim implements StringHolder {
  String theString;

  private int minLength;
  private int expectedLength;

  private boolean trouble = false;

  @SuppressFBWarnings("EI_EXPOSE_REP")
  private StringHolderScope scope = null;

  /**
   * Constructs a {@link AbstractStringHolder} with a zero minimum length.
   */
  protected AbstractStringHolder() {
    this(0);
  }

  /**
   * Constructs a {@link AbstractStringHolder} with the given minimum length, use {@code 0} if no
   * minimum length is known.
   *
   * @param minLength The minimum length, which must not be larger than the eventual actual length.
   */
  @SuppressFBWarnings("CT_CONSTRUCTOR_THROW")
  protected AbstractStringHolder(int minLength) {
    this(minLength, minLength);
  }

  /**
   * Constructs a {@link AbstractStringHolder} with the given minimum length (use {@code 0} if no
   * minimum length is known), and expected length.
   *
   * @param minLength The minimum length, which must not be larger than the eventual actual length.
   * @param expectedLength The expected length, which may be larger than the eventual actual length
   */
  @SuppressFBWarnings("CT_CONSTRUCTOR_THROW")
  protected AbstractStringHolder(int minLength, int expectedLength) {
    super();
    if (minLength < 0) {
      throw new IllegalArgumentException("Invalid minLength");
    }
    this.minLength = minLength;
    this.expectedLength = Math.max(minLength, expectedLength);
  }

  @Override
  public final int getMinimumLength() {
    return minLength;
  }

  @Override
  public final int getExpectedLength() {
    return expectedLength;
  }

  @Override
  public void setExpectedLength(int len) {
    resizeTo(getMinimumLength(), len);
  }

  /**
   * Sets the expected lengths (minimum and estimated) to the given values.
   *
   * @param min The new minimum length, must not be smaller than the current minimum (unless
   *          {@link #checkError()} is {@code true})
   * @param expected The new expected length (will be rounded up if less than {@code min}).
   * @return The new expected length (which may be adjusted to the new minimum).
   * @throws IllegalStateException if the value is negative, and {@link #checkError()} is
   *           {@code false}.
   */
  protected final int resizeTo(int min, int expected) {
    return resizeTo(min, expected, false);
  }

  private int resizeTo(int min, int expected, boolean fromToString) {
    int oldMin = this.minLength;
    int oldExpected = this.expectedLength;

    if (!fromToString && isString()) {
      // unchanged
      return oldExpected;
    }

    if (min < oldMin) {
      if (checkError()) {
        // throw away our previous expectations upon error
        expected = (min = Math.max(0, min));
      } else {
        if (fromToString) {
          setError();
          resizeTo(min, expected, false);
          throw new IllegalStateException("Detected mispredicted minLength");
        } else {
          throw new IllegalStateException("New minimum is smaller than current minimum");
        }
      }
    }

    int el = (this.expectedLength = Math.max((this.minLength = min), expected));

    StringHolderScope sc = this.scope;
    if (sc != null) {
      try {
        sc.resizeBy(this.minLength - oldMin, this.expectedLength - oldExpected);
      } catch (RuntimeException | Error e) {
        setError();
        throw e;
      }
    }

    return el;
  }

  /**
   * Increases the expected lengths (minimum and estimated) by the given values.
   *
   * Any value that overflows {@link Integer#MAX_VALUE} will be capped at that limit.
   *
   * @param minBy The minimum length increment, must not be negative (unless {@link #checkError()}
   *          is {@code true})
   * @param expectedBy The expected length increment, may be negative; final length will be
   *          {@code >= 0}.
   * @throws IllegalArgumentException if minBy is negative and {@link #checkError()} is
   *           {@code false}
   */
  protected final void resizeBy(int minBy, int expectedBy) {
    int oldMin = this.minLength;
    int oldExpected = this.expectedLength;

    if (minBy < 0) {
      if (checkError()) {
        this.minLength = Math.max(0, this.minLength + minBy);
      } else {
        throw new IllegalArgumentException("Minimum length increment is negative");
      }
    } else if ((this.minLength += minBy) < 0) {
      // cannot express minimum length that large
      this.minLength = Integer.MAX_VALUE;
    }
    this.expectedLength = Math.max(minLength, this.expectedLength + expectedBy);

    StringHolderScope sc = this.scope;
    if (sc != null) {
      try {
        sc.resizeBy(this.minLength - oldMin, this.expectedLength - oldExpected);
      } catch (RuntimeException | Error e) {
        setError();
        throw e;
      }
    }
  }

  @Override
  public final int length() {
    if (theString != null) {
      return minLength;
    } else {
      return resizeTo(computeLength(), 0);
    }
  }

  /**
   * Computes the actual length of this instance's contents.
   *
   * By default, this is implemented as {@code toString().length()}.
   *
   * When overriding this method, make sure to also override {@link #isLengthKnown()}.
   *
   * @return The actual length.
   */
  @SuppressWarnings("PMD.UseStringBufferLength")
  protected int computeLength() {
    return toString().length();
  }

  @Override
  public final boolean isString() {
    return theString != null;
  }

  @Override
  public final boolean isKnownEmpty() {
    if (minLength > 0) {
      return false;
    } else if (isLengthKnown() && length() == 0) {
      return true;
    } else {
      String s;

      return (s = theString) != null && s.isEmpty();
    }
  }

  @Override
  public final boolean isEmpty() {
    return isKnownEmpty() || super.isEmpty();
  }

  @Override
  public boolean isLengthKnown() {
    return isString();
  }

  @Override
  public int hashCode() {
    return toString().hashCode();
  }

  @SuppressFBWarnings("EQ_CHECK_FOR_OPERAND_NOT_COMPATIBLE_WITH_THIS")
  @Override
  public final boolean equals(Object obj) {
    if (obj == null) {
      return false;
    } else if (obj == this) {
      return true;
    } else if (obj instanceof String) {
      return equalsString((String) obj);
    } else if (obj instanceof StringHolder) {
      return equalsStringHolder((StringHolder) obj);
    } else {
      return false;
    }
  }

  private boolean equalsString(String s) {
    if (!checkError() && s.length() < getMinimumLength()) {
      return false;
    } else if (isString()) {
      return toString().equals(s);
    } else if (isLengthKnown() && length() != s.length()) {
      return false;
    } else {
      return checkEquals(s);
    }
  }

  /**
   * Checks if this {@link StringHolder} instance is equal to the given String (assume that trivial
   * requirements, such as minimum length, were already checked).
   *
   * Subclasses may override this check for a faster operation.
   *
   * @param s The other string.
   * @return {@code true} if this {@link StringHolder} is equal to the given string.
   */
  protected boolean checkEquals(String s) {
    return toString().equals(s);
  }

  /**
   * Checks if this {@link StringHolder} instance is equal to the given {@link StringHolder} (assume
   * that trivial requirements, such as minimum length, were already checked).
   *
   * Subclasses may override this check for a faster operation.
   *
   * @param sh The other {@link StringHolder}.
   * @return {@code true} if this {@link StringHolder} is equal to the given string.
   */
  protected boolean checkEquals(StringHolder sh) {
    return toString().equals(sh.toString());
  }

  @SuppressWarnings("unlikely-arg-type")
  @SuppressFBWarnings("EC_UNRELATED_CLASS_AND_INTERFACE")
  private boolean equalsStringHolder(StringHolder obj) {
    if (isLengthKnown() && obj.isLengthKnown() && length() != obj.length()) {
      return false;
    }

    if (isString()) {
      if (!obj.checkError() && length() < obj.getMinimumLength()) {
        return false;
      }
      if (obj.isString()) {
        return toString().equals(obj.toString());
      } else {
        return obj.equals(toString());
      }
    } else if (obj.isString()) {
      if (!checkError() && obj.length() < getMinimumLength()) {
        return false;
      }
    }

    return checkEquals(obj);
  }

  @Override
  public final void appendTo(Appendable out) throws IOException {
    appendToAndReturnLength(out);
  }

  @Override
  public final void appendTo(StringBuilder out) {
    appendToAndReturnLength(out);
  }

  @Override
  public final void appendTo(StringBuffer out) {
    appendToAndReturnLength(out);
  }

  @Override
  public final void appendTo(Writer out) throws IOException {
    appendToAndReturnLength(out);
  }

  @Override
  public final int appendToAndReturnLength(Appendable out) throws IOException {
    if (out instanceof Writer) {
      return appendToAndReturnLength((Writer) out);
    } else if (out instanceof StringBuilder) {
      return appendToAndReturnLength((StringBuilder) out);
    } else if (out instanceof StringBuffer) {
      return appendToAndReturnLength((StringBuffer) out);
    } else {
      return appendToAndReturnLengthDefault(out);
    }
  }

  @Override
  public final int appendToAndReturnLength(StringBuilder out) {
    int len;
    if (isString()) {
      len = length();
      if (len > 0) {
        out.append(toString());
      }
    } else {
      len = appendToAndReturnLengthImpl(out);
      if (minLength < len) {
        resizeTo(len, 0);
      }
    }
    return len;
  }

  @Override
  public final int appendToAndReturnLength(StringBuffer out) {
    int len;
    if (isString()) {
      len = length();
      out.append(toString());
    } else {
      len = appendToAndReturnLengthImpl(out);
      if (minLength < len) {
        resizeTo(len, 0);
      }
    }
    return len;
  }

  @Override
  public final int appendToAndReturnLength(Writer out) throws IOException {
    int len;
    if (isString()) {
      len = length();
      out.append(toString());
    } else {
      len = appendToAndReturnLengthImpl(out);
      if (minLength < len) {
        resizeTo(len, 0);
      }
    }
    return len;
  }

  /**
   * Append the contents of this {@link StringHolder} to the given {@link Appendable} (which is
   * neither a {@link StringBuilder}, {@link StringBuffer}, nor a {@link Writer}), and returns the
   * number of characters appended. This call may or may not turn the contents of this instance into
   * a String. It won't be called if it's already one.
   *
   * @param out The target.
   * @return The number of characters appended (which is assumed to be the new minimum length).
   * @see #appendToAndReturnLength(StringBuilder)
   * @see #appendToAndReturnLength(StringBuffer)
   * @see #appendToAndReturnLength(StringWriter)
   * @throws IOException on error.
   */
  protected int appendToAndReturnLengthDefaultImpl(Appendable out) throws IOException {
    String s = toString();
    if (!s.isEmpty()) {
      out.append(s);
    }
    return s.length();
  }

  /**
   * Append the contents of this {@link StringHolder} to the given {@link StringBuilder}, and
   * returns the number of characters appended. This call may or may not turn the contents of this
   * instance into a String. It won't be called if it's already one.
   *
   * @param out The target.
   * @return The number of characters appended (which is assumed to be the new minimum length).
   */
  protected int appendToAndReturnLengthImpl(StringBuilder out) {
    String s = toString();
    if (!s.isEmpty()) {
      out.append(s);
    }
    return s.length();
  }

  /**
   * Append the contents of this {@link StringHolder} to the given {@link StringBuffer}, and returns
   * the number of characters appended. This call may or may not turn the contents of this instance
   * into a String. It won't be called if it's already one.
   *
   * @param out The target.
   * @return The number of characters appended (which is assumed to be the new minimum length).
   */
  protected int appendToAndReturnLengthImpl(StringBuffer out) {
    String s = toString();
    if (!s.isEmpty()) {
      out.append(s);
    }
    return s.length();
  }

  /**
   * Append the contents of this {@link StringHolder} to the given {@link Writer}, and returns the
   * number of characters appended. This call may or may not turn the contents of this instance into
   * a String. It won't be called if it's already one.
   *
   * @param out The target.
   * @return The number of characters appended (which is assumed to be the new minimum length).
   * @throws IOException on error.
   */
  protected int appendToAndReturnLengthImpl(Writer out) throws IOException {
    // subclasses may implement a better way for Writers, but we don't
    return appendToAndReturnLengthDefault(out);
  }

  private int appendToAndReturnLengthDefault(Appendable out) throws IOException {
    int len;
    if (isString()) {
      len = length();
      out.append(toString());
    } else {
      len = appendToAndReturnLengthDefaultImpl(out);
      if (minLength < len) {
        resizeTo(len, 0);
      }
    }
    return len;
  }

  @Override
  public final String toString() {
    String s = theString;
    if (s != null) {
      return s;
    }
    synchronized (this) {
      try {
        if (isKnownEmpty()) {
          theString = s = "";
        } else {
          theString = s = CommonStrings.lookupIfPossible(Objects.requireNonNull(getString()));
        }
        resizeTo(s.length(), 0, true);
      } catch (RuntimeException e) {
        s = theString;
        if (s != null) {
          resizeTo(s.length(), 0, true);
        }
        setError();
        throw e;
      }

      stringSanityCheck(s);
      return s;
    }
  }

  /**
   * Called from within {@link #toString()} after updating/assigning the cached string but before
   * returning it. This may be a good opportunity to see if we got what we wanted, call setError,
   * etc.
   *
   * @param s The string.
   */
  protected void stringSanityCheck(String s) {
  }

  /**
   * Retrieves the string.
   *
   * @return The string; must not be {@code null}.
   */
  protected abstract String getString();

  /**
   * Un-caches the already-determined String. This can be used to implement mutable data structures.
   *
   * Important: Subclasses must carefully check {@link #isEffectivelyImmutable()} status.
   */
  protected void uncache() {
    theString = null;
  }

  @Override
  public final Reader toReader() throws IOException {
    String s = theString;
    if (s != null) {
      return new StringReader(s);
    } else {
      return newReader();
    }
  }

  @Override
  public final boolean checkError() {
    return trouble;
  }

  /**
   * Signals that this instance had some kind of unexpected condition.
   *
   * @see #checkError()
   * @see #clearError()
   */
  protected final void setError() {
    trouble = true;
    StringHolderScope sc = scope;
    if (sc != null) {
      sc.setError(this);
    }
  }

  /**
   * Clears the trouble state of this instance.
   *
   * @see #checkError()
   * @see #setError()
   */
  protected final void clearError() {
    trouble = false;
    StringHolderScope sc = scope;
    if (sc != null) {
      sc.clearError(this);
    }
  }

  /**
   * Constructs a new {@link Reader} providing the contents of this {@link StringHolder}.
   *
   * @return The reader.
   * @throws IOException on error.
   */
  protected Reader newReader() throws IOException {
    return LazyInitReader.withSupplier(() -> new StringReader(AbstractStringHolder.this
        .toString()));
  }

  @Override
  public final StringHolderScope getScope() {
    return scope;
  }

  @Override
  public final StringHolderScope updateScope(StringHolderScope newScope) {
    if (newScope == StringHolderScope.NONE) { // NOPMD.CompareObjectsWithEquals
      newScope = null;
    }
    StringHolderScope oldScope = this.scope;
    if (oldScope == newScope) { // NOPMD.CompareObjectsWithEquals
      return oldScope;
    }

    if (oldScope != null) {
      try {
        oldScope.remove(this);
      } catch (RuntimeException | Error e) {
        setError();
        throw e;
      }
    }

    if (newScope != null) {
      try {
        newScope.add(this);
      } catch (RuntimeException | Error e) {
        setError();
        throw e;
      }
    }
    this.scope = newScope;
    return oldScope;
  }

  @Override
  public char charAt(int index) {
    return toString().charAt(index);
  }

  @Override
  public CharSequence subSequence(int start, int end) {
    if (start == 0 && end == length()) {
      return this;
    }
    return toString().subSequence(start, end);
  }

  @Override
  public Object asContent() {
    if (isString()) {
      return toString();
    }
    return this;
  }

  @Override
  public int compareTo(CharSequence o) {
    if (o instanceof StringHolder) {
      return compareTo((StringHolder) o);
    }

    if (isKnownEmpty()) {
      if (CharSequenceReleaseShim.isEmpty(o)) {
        return 0;
      } else {
        return -1;
      }
    }

    return compareToDefault(o);
  }

  @Override
  public int compareTo(StringHolder o) {
    if (o.isKnownEmpty()) {
      if (isKnownEmpty()) {
        return 0;
      }
    } else if (o.isString()) {
      if (isString()) {
        return toString().compareTo(o.toString());
      } else {
        return compareTo(o.toString());
      }
    } else if (isString()) {
      return ComparisonUtil.reverseComparisonResult(o.compareTo(toString()));
    }

    return compareToDefault(o);
  }

  /**
   * Default implementation for comparing this instance with another {@link CharSequence} that is
   * not a {@link StringHolder}. Certain trivial checks were already performed, such as one or both
   * being known empty.
   *
   * @param o The other object.
   * @return The comparison result, as defined by {@link #compareTo(Object)}.
   */
  @SuppressWarnings({"PMD.CognitiveComplexity"})
  protected final int compareToDefault(CharSequence o) {
    int k = 0;

    if (getMinimumLength() > 0 && CharSequenceReleaseShim.isEmpty(o)) {
      // NOTE: we trust the StringHolder claim of minimum length
      return 1;
    }
    int len2 = o.length();

    int lim;
    char c1;
    try {
      c1 = charAt(k);
    } catch (IndexOutOfBoundsException e) {
      if (len2 == 0) {
        return 0;
      } else {
        return -1;
      }
    }

    int len1;
    if (isString()) {
      if (o instanceof String) {
        return toString().compareTo((String) o);
      }
      len1 = length();
      lim = Math.min(len1, len2);
    } else {
      if (isLengthKnown()) {
        len1 = length();
        lim = Math.min(len1, len2);
      } else {
        len1 = Integer.MAX_VALUE;
        lim = len2;
      }
    }

    while (k < lim) {
      char c2 = o.charAt(k);
      if (c1 != c2) {
        return c1 - c2;
      }
      k++;
      if (k < lim) {
        try {
          c1 = charAt(k);
        } catch (IndexOutOfBoundsException e) {
          return -1;
        }
      }
    }

    if (getMinimumLength() > k) {
      return 1;
    } else if (len1 == k) {
      return len1 - len2;
    }
    try {
      charAt(k);
      return 1;
    } catch (IndexOutOfBoundsException e) {
      return 0;
    }
  }

  /**
   * Default implementation for comparing this instance with another {@link StringHolder}. Certain
   * trivial checks were already performed, such as one or both being known empty or known being
   * string.
   *
   * @param o The other object.
   * @return The comparison result, as defined by {@link #compareTo(Object)}.
   */
  @SuppressWarnings({"PMD.NPathComplexity", "PMD.CognitiveComplexity"})
  protected int compareToDefault(StringHolder o) {
    int k = 0;

    char c1;
    char c2;
    try {
      c1 = charAt(k);
    } catch (IndexOutOfBoundsException e) {
      try {
        o.charAt(k);
        return -1;
      } catch (IndexOutOfBoundsException e2) {
        return 0;
      }
    }
    try {
      c2 = o.charAt(k);
    } catch (IndexOutOfBoundsException e) {
      return 1;
    }

    if (c1 != c2) {
      return c1 - c2;
    }

    if (isString()) {
      return ComparisonUtil.reverseComparisonResult(o.compareTo(toString()));
    } else if (o.isString()) {
      return compareTo(o.toString());
    }

    boolean len1Known = isLengthKnown();
    boolean len2Known = o.isLengthKnown();

    if (len1Known && len2Known) {
      return compareBothLengthsKnown(o, k, length(), o.length());
    } else if (len1Known) {
      return compareOurLengthKnown(o, k, length());
    } else if (len2Known) {
      return compareOtherLengthKnown(o, k, o.length());
    } else {
      return compareBothLengthsUnknown(o, k);
    }
  }

  private int compareBothLengthsKnown(StringHolder o, int k, int len1, int len2) {
    int lim = Math.min(len1, len2);

    char c1;
    char c2;

    while (k < lim) {
      c1 = charAt(k);
      c2 = o.charAt(k);
      if (c1 != c2) {
        return c1 - c2;
      }
      k++;
    }

    return len1 - len2;
  }

  private int compareOurLengthKnown(StringHolder o, int k, int len1) {
    char c1;
    char c2;

    while (k < len1) {
      c1 = charAt(k);
      try {
        c2 = o.charAt(k);
      } catch (IndexOutOfBoundsException e) {
        return 1;
      }
      if (c1 != c2) {
        return c1 - c2;
      }
      k++;
    }

    try {
      if (o.getMinimumLength() > k) {
        return -1;
      }
      o.charAt(k);
      return -1;
    } catch (IndexOutOfBoundsException e) {
      return 0;
    }
  }

  private int compareOtherLengthKnown(StringHolder o, int k, int len2) {
    char c1;
    char c2;

    while (k < len2) {
      try {
        c1 = charAt(k);
      } catch (IndexOutOfBoundsException e) {
        return -1;
      }
      c2 = o.charAt(k);
      if (c1 != c2) {
        return c1 - c2;
      }
      k++;
    }

    try {
      if (getMinimumLength() > k) {
        return 1;
      }
      charAt(k);
      return 1;
    } catch (IndexOutOfBoundsException e) {
      return 0;
    }
  }

  private int compareBothLengthsUnknown(StringHolder o, int k) {
    char c1;
    char c2;
    while (true) {
      k++;

      try {
        c1 = charAt(k);
      } catch (IndexOutOfBoundsException e) {
        try {
          o.charAt(k);
          return -1;
        } catch (IndexOutOfBoundsException e2) {
          return 0;
        }
      }
      try {
        c2 = o.charAt(k);
      } catch (IndexOutOfBoundsException e) {
        return 1;
      }

      if (c1 != c2) {
        return c1 - c2;
      }
    }
  }

  /**
   * Computes a partial hash code, using the given value as the seed.
   *
   * @param h The initial value (seed).
   * @return The updated hash code.
   */
  protected int updateHashCode(int h) {
    int length = length();

    if (h == 0 && isString()) {
      return toString().hashCode();
    }

    for (int i = 0; i < length; i++) {
      h = 31 * h + charAt(i);
    }
    return h;
  }

  @Override
  public boolean isEffectivelyImmutable() {
    return isString();
  }

  @Override
  public void markEffectivelyImmutable() {
    if (!isEffectivelyImmutable()) {
      toString();
    }
  }

  @ExcludeFromCodeCoverageGeneratedReport(reason = "exception unreachable")
  private AbstractStringHolder cloneSuper() {
    try {
      return (AbstractStringHolder) super.clone();
    } catch (CloneNotSupportedException e) {
      throw new IllegalStateException(e);
    }
  }

  @Override
  public AbstractStringHolder clone() {
    return cloneSuper();
  }
}