001/*
002 * Copyright 2017-2022 Product Mog LLC, 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.lokalized;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.Immutable;
023import javax.annotation.concurrent.NotThreadSafe;
024import java.math.BigDecimal;
025import java.math.BigInteger;
026import java.math.RoundingMode;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.concurrent.atomic.AtomicInteger;
030import java.util.concurrent.atomic.AtomicLong;
031import java.util.concurrent.atomic.DoubleAccumulator;
032import java.util.concurrent.atomic.DoubleAdder;
033import java.util.concurrent.atomic.LongAccumulator;
034import java.util.concurrent.atomic.LongAdder;
035
036import static com.lokalized.Diagnostics.format;
037import static java.util.Objects.requireNonNull;
038
039/**
040 * Unicode CLDR plural operands for cardinality and ordinality evaluation.
041 * <p>
042 * Most applications should use {@link Cardinality#forNumber(Number, java.util.Locale)} or
043 * {@link Ordinality#forNumber(Number, java.util.Locale)}. Use this type when the displayed number has details that
044 * are not fully represented by the Java {@link Number}, such as an explicitly visible decimal count or a compact-decimal exponent.
045 * <p>
046 * CLDR plural-category evaluation uses the absolute numeric value exposed by {@link #getNumber()}. When an instance is
047 * used in an alternative expression's ordinary numeric comparison, Lokalized instead preserves the sign of the source
048 * number supplied to {@link #forNumber(Number)}.
049 * <p>
050 * Supported number implementations are {@link BigDecimal}, {@link BigInteger}, the boxed JDK numeric types
051 * ({@link Byte}, {@link Short}, {@link Integer}, {@link Long}, {@link Float}, and {@link Double}), and the JDK atomic
052 * numeric types ({@link AtomicInteger}, {@link AtomicLong}, {@link LongAdder}, {@link DoubleAdder},
053 * {@link LongAccumulator}, and {@link DoubleAccumulator}). Atomic values are sampled once when {@link Builder#build()}
054 * executes. Integral values are converted exactly. {@code Float} and {@code Double} values use their canonical decimal
055 * representation rather than the exact binary floating-point value. Non-finite floating-point values and unlisted
056 * {@code Number} implementations are rejected; callers with another numeric representation should convert it to a
057 * {@code BigDecimal} explicitly.
058 * Builders use {@link TranslationRuntimeLimits#defaults()} unless
059 * {@link Builder#runtimeLimits(TranslationRuntimeLimits)} supplies different limits. This is also how callers opt up
060 * from the defaults to the hard ceilings exposed below.
061 *
062 * @author <a href="https://revetkn.com">Mark Allen</a>
063 * @since 3.0.0
064 */
065@Immutable
066public final class PluralOperands {
067  /**
068   * Hard precision ceiling for a number used in plural operands or an alternative-expression numeric literal: 4,096.
069   *
070   * @since 3.0.0
071   */
072  @NonNull
073  public static final Integer MAXIMUM_NUMBER_PRECISION = TranslationRuntimeLimits.MAXIMUM_NUMBER_PRECISION;
074  /**
075   * Hard ceiling for the absolute {@link BigDecimal#scale()} accepted for a number used in plural operands or an
076   * alternative-expression numeric literal: 4,096.
077   *
078   * @since 3.0.0
079   */
080  @NonNull
081  public static final Integer MAXIMUM_ABSOLUTE_NUMBER_SCALE = TranslationRuntimeLimits.MAXIMUM_ABSOLUTE_NUMBER_SCALE;
082  /**
083   * Hard ceiling for explicitly visible decimal places accepted by
084   * {@link Builder#visibleDecimalPlaces(Integer)}: 4,096.
085   *
086   * @since 3.0.0
087   */
088  @NonNull
089  public static final Integer MAXIMUM_VISIBLE_DECIMAL_PLACES = TranslationRuntimeLimits.MAXIMUM_VISIBLE_DECIMAL_PLACES;
090  /**
091   * Hard ceiling for compact-decimal exponents accepted by {@link Builder#compactExponent(Integer)}: 4,096.
092   *
093   * @since 3.0.0
094   */
095  @NonNull
096  public static final Integer MAXIMUM_COMPACT_EXPONENT = TranslationRuntimeLimits.MAXIMUM_COMPACT_EXPONENT;
097
098  // Input scale, visible scale, and compact shifting can each add zeroes to the materialized unscaled value.
099  private static final int MAXIMUM_MATERIALIZED_PRECISION = 12_288;
100
101  @NonNull
102  private final BigDecimal n;
103  @NonNull
104  private final BigDecimal i;
105  @NonNull
106  private final BigDecimal v;
107  @NonNull
108  private final BigDecimal w;
109  @NonNull
110  private final BigDecimal f;
111  @NonNull
112  private final BigDecimal t;
113  @NonNull
114  private final BigDecimal c;
115  @NonNull
116  private final BigDecimal e;
117  @NonNull
118  private final BigDecimal sourceNumber;
119  @Nullable
120  private final Integer explicitVisibleDecimalPlaces;
121
122  private PluralOperands(@NonNull BigDecimal sourceNumber, @NonNull BigDecimal number, int compactExponent,
123                         @Nullable Integer explicitVisibleDecimalPlaces) {
124    requireNonNull(sourceNumber);
125    requireNonNull(number);
126
127    @NonNull BigDecimal operandNumber = number.movePointRight(compactExponent);
128    @NonNull BigDecimal strippedNumber = operandNumber.stripTrailingZeros();
129    @NonNull BigDecimal exponent = BigDecimal.valueOf(compactExponent);
130
131    this.n = operandNumber;
132    this.i = new BigDecimal(NumberUtils.integerComponent(operandNumber));
133    this.v = BigDecimal.valueOf(NumberUtils.numberOfDecimalPlaces(operandNumber));
134    this.w = BigDecimal.valueOf(NumberUtils.numberOfDecimalPlaces(strippedNumber));
135    this.f = new BigDecimal(NumberUtils.fractionalComponent(operandNumber));
136    this.t = new BigDecimal(NumberUtils.fractionalComponent(strippedNumber));
137    this.c = exponent;
138    this.e = exponent;
139    this.sourceNumber = sourceNumber;
140    this.explicitVisibleDecimalPlaces = explicitVisibleDecimalPlaces;
141  }
142
143  /**
144   * Validates the non-materializing characteristics of a decimal before code derives integer and fractional operands.
145   */
146  @NonNull
147  static BigDecimal validateNumericValue(@NonNull BigDecimal number, @NonNull String description) {
148    return validateNumericValue(number, description, TranslationRuntimeLimits.defaults());
149  }
150
151  /**
152   * Validates a decimal against the supplied runtime limits without materializing its integer or fraction digits.
153   */
154  @NonNull
155  static BigDecimal validateNumericValue(@NonNull BigDecimal number, @NonNull String description,
156                                         @NonNull TranslationRuntimeLimits runtimeLimits) {
157    requireNonNull(number);
158    requireNonNull(description);
159    requireNonNull(runtimeLimits);
160
161    long absoluteScale = Math.abs((long) number.scale());
162    if (absoluteScale > runtimeLimits.getMaximumAbsoluteNumberScale())
163      throw new IllegalArgumentException(format("%s scale %d exceeds the maximum absolute scale of %d",
164          description, number.scale(), runtimeLimits.getMaximumAbsoluteNumberScale()));
165
166    if (number.precision() > runtimeLimits.getMaximumNumberPrecision())
167      throw new IllegalArgumentException(format("%s precision %d exceeds the maximum of %d",
168          description, number.precision(), runtimeLimits.getMaximumNumberPrecision()));
169
170    return number;
171  }
172
173  private static void validateMaterializedPrecision(long materializedPrecision) {
174    if (materializedPrecision > MAXIMUM_MATERIALIZED_PRECISION)
175      throw new IllegalArgumentException(format("Plural operand materialized precision %d exceeds the maximum of %d",
176          materializedPrecision, MAXIMUM_MATERIALIZED_PRECISION));
177  }
178
179  /**
180   * Creates a builder for CLDR plural operands backed by the given number.
181   * <p>
182   * Negative numbers are evaluated using their absolute value.
183   * The supported {@link Number} implementations and their conversion semantics are documented by this class.
184   *
185   * @param number the number that drives pluralization, not null
186   * @return a plural-operands builder, not null
187   */
188  @NonNull
189  public static Builder forNumber(@NonNull Number number) {
190    requireNonNull(number);
191    return new Builder(number);
192  }
193
194  /**
195   * Gets the absolute numeric value used for the CLDR {@code n} operand.
196   *
197   * @return the absolute numeric value, not null
198   */
199  @NonNull
200  public BigDecimal getNumber() {
201    return n;
202  }
203
204  /**
205   * Gets the compact-decimal exponent used for the CLDR {@code c} and {@code e} operands.
206   *
207   * @return the compact-decimal exponent, not null
208   */
209  @NonNull
210  public Integer getCompactExponent() {
211    return e.intValueExact();
212  }
213
214  @NonNull
215  BigDecimal n() {
216    return n;
217  }
218
219  @NonNull
220  BigDecimal i() {
221    return i;
222  }
223
224  @NonNull
225  BigDecimal v() {
226    return v;
227  }
228
229  @NonNull
230  BigDecimal w() {
231    return w;
232  }
233
234  @NonNull
235  BigDecimal f() {
236    return f;
237  }
238
239  @NonNull
240  BigDecimal t() {
241    return t;
242  }
243
244  @NonNull
245  BigDecimal c() {
246    return c;
247  }
248
249  @NonNull
250  BigDecimal sourceNumber() {
251    return sourceNumber;
252  }
253
254  @NonNull
255  Optional<@NonNull Integer> explicitVisibleDecimalPlaces() {
256    return Optional.ofNullable(explicitVisibleDecimalPlaces);
257  }
258
259  @NonNull
260  BigDecimal e() {
261    return e;
262  }
263
264  /**
265   * Generates a {@code String} representation of this object.
266   *
267   * @return a string representation of this object, not null
268   */
269  @Override
270  @NonNull
271  public String toString() {
272    return format("%s{number=%s, compactExponent=%s}", getClass().getSimpleName(), getNumber(), getCompactExponent());
273  }
274
275  /**
276   * Checks if this object is equal to another one.
277   *
278   * @param other the object to check, null returns false
279   * @return true if this is equal to the other object, false otherwise
280   */
281  @Override
282  public boolean equals(@Nullable Object other) {
283    if (this == other)
284      return true;
285
286    if (other == null || !getClass().equals(other.getClass()))
287      return false;
288
289    PluralOperands pluralOperands = (PluralOperands) other;
290
291    return Objects.equals(n(), pluralOperands.n())
292        && sourceNumber.signum() == pluralOperands.sourceNumber.signum()
293        && Objects.equals(getCompactExponent(), pluralOperands.getCompactExponent());
294  }
295
296  /**
297   * A hash code for this object.
298   *
299   * @return a suitable hash code
300   */
301  @Override
302  public int hashCode() {
303    return Objects.hash(n(), sourceNumber.signum(), getCompactExponent());
304  }
305
306  /**
307   * Builder for {@link PluralOperands}.
308   *
309   * @since 3.0.0
310   */
311  @NotThreadSafe
312  public static final class Builder {
313    @NonNull
314    private final Number number;
315    @Nullable
316    private Integer visibleDecimalPlaces;
317    @Nullable
318    private Integer compactExponent;
319    @NonNull
320    private TranslationRuntimeLimits runtimeLimits;
321
322    private Builder(@NonNull Number number) {
323      requireNonNull(number);
324      this.number = number;
325      this.runtimeLimits = TranslationRuntimeLimits.defaults();
326    }
327
328    /**
329     * Specifies the number of decimal places that will be visible to the user.
330     * <p>
331     * If omitted, {@link BigDecimal} scale is preserved and other {@link Number} types use their normalized decimal places.
332     * Reducing the scale does not round implicitly: callers must supply an already-rounded number, otherwise
333     * {@link #build()} throws {@link ArithmeticException}.
334     * The active {@link TranslationRuntimeLimits} determine the accepted maximum; the library default is
335     * {@link TranslationRuntimeLimits#DEFAULT_MAXIMUM_VISIBLE_DECIMAL_PLACES} and the hard ceiling is
336     * {@link #MAXIMUM_VISIBLE_DECIMAL_PLACES}.
337     *
338     * @param visibleDecimalPlaces the visible decimal places, from zero through the active runtime limit, or null to
339     *                             use the number's natural scale
340     * @return this builder, not null
341     */
342    @NonNull
343    public Builder visibleDecimalPlaces(@Nullable Integer visibleDecimalPlaces) {
344      this.visibleDecimalPlaces = visibleDecimalPlaces;
345      return this;
346    }
347
348    /**
349     * Specifies the compact-decimal exponent used by the CLDR {@code c} and {@code e} operands.
350     * <p>
351     * The number supplied to this builder is the displayed mantissa, not the expanded value. For example, a compact
352     * display such as {@code 1M} is represented by the number {@code 1} and a compact exponent of {@code 6}, not by the
353     * number {@code 1000000} and an exponent of {@code 6}.
354     * The active {@link TranslationRuntimeLimits} determine the accepted maximum; the library default is
355     * {@link TranslationRuntimeLimits#DEFAULT_MAXIMUM_COMPACT_EXPONENT} and the hard ceiling is
356     * {@link #MAXIMUM_COMPACT_EXPONENT}.
357     *
358     * @param compactExponent the compact-decimal exponent, from zero through the active runtime limit, or null to use
359     *                        zero
360     * @return this builder, not null
361     */
362    @NonNull
363    public Builder compactExponent(@Nullable Integer compactExponent) {
364      this.compactExponent = compactExponent;
365      return this;
366    }
367
368    /**
369     * Applies safety limits to operand construction.
370     *
371     * @param runtimeLimits runtime limits, or null to use the library defaults
372     * @return this builder, not null
373     * @since 3.0.0
374     */
375    @NonNull
376    public Builder runtimeLimits(@Nullable TranslationRuntimeLimits runtimeLimits) {
377      this.runtimeLimits = runtimeLimits == null ? TranslationRuntimeLimits.defaults() : runtimeLimits;
378      return this;
379    }
380
381    /**
382     * Builds immutable plural operands.
383     *
384     * @return immutable plural operands, not null
385     * @throws ArithmeticException if the requested visible decimal places would require rounding
386     * @throws IllegalArgumentException if the number implementation is unsupported, the number is non-finite, or the
387     *                                  number, visible decimal places, or compact exponent exceeds a supported safety
388     *                                  limit
389     */
390    @NonNull
391    public PluralOperands build() {
392      int effectiveCompactExponent = compactExponent == null ? 0 : compactExponent;
393
394      if (effectiveCompactExponent < 0)
395        throw new IllegalArgumentException(format("Compact exponent must be non-negative, but was %d", effectiveCompactExponent));
396      if (effectiveCompactExponent > runtimeLimits.getMaximumCompactExponent())
397        throw new IllegalArgumentException(format("Compact exponent %d exceeds the maximum of %d",
398            effectiveCompactExponent, runtimeLimits.getMaximumCompactExponent()));
399
400      if (visibleDecimalPlaces != null) {
401        if (visibleDecimalPlaces < 0)
402          throw new IllegalArgumentException(format("Visible decimal places must be non-negative, but was %d", visibleDecimalPlaces));
403        if (visibleDecimalPlaces > runtimeLimits.getMaximumVisibleDecimalPlaces())
404          throw new IllegalArgumentException(format("Visible decimal places %d exceeds the maximum of %d",
405              visibleDecimalPlaces, runtimeLimits.getMaximumVisibleDecimalPlaces()));
406      }
407
408      boolean numberIsBigDecimal = number instanceof BigDecimal;
409      BigDecimal numberAsBigDecimal = numberIsBigDecimal ? (BigDecimal) number : NumberUtils.toBigDecimal(number);
410      validateNumericValue(numberAsBigDecimal, "Number", runtimeLimits);
411      BigDecimal sourceNumber = numberAsBigDecimal;
412      numberAsBigDecimal = numberAsBigDecimal.abs();
413
414      int effectiveScale = numberAsBigDecimal.scale();
415      long materializedPrecision = numberAsBigDecimal.precision();
416
417      if (visibleDecimalPlaces == null) {
418        if (!numberIsBigDecimal) {
419          effectiveScale = Math.max(0, numberAsBigDecimal.stripTrailingZeros().scale());
420          materializedPrecision += Math.max(0L, (long) effectiveScale - numberAsBigDecimal.scale());
421        }
422      } else {
423        effectiveScale = visibleDecimalPlaces;
424        materializedPrecision += Math.max(0L, (long) effectiveScale - numberAsBigDecimal.scale());
425      }
426
427      materializedPrecision += Math.max(0L, (long) effectiveCompactExponent - effectiveScale);
428      validateMaterializedPrecision(materializedPrecision);
429
430      if (!numberIsBigDecimal || visibleDecimalPlaces != null)
431        numberAsBigDecimal = numberAsBigDecimal.setScale(effectiveScale, RoundingMode.UNNECESSARY);
432
433      return new PluralOperands(sourceNumber, numberAsBigDecimal, effectiveCompactExponent, visibleDecimalPlaces);
434    }
435  }
436}