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}