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.NotThreadSafe;
023import java.util.List;
024import java.util.Locale;
025import java.util.Map;
026import java.util.Set;
027import java.util.function.Function;
028import java.util.function.Supplier;
029
030import static java.util.Objects.requireNonNull;
031
032/**
033 * Contract for localized string providers - given a key and optional caller-supplied placeholders, return a localized
034 * string.
035 * <p>
036 * Format is {@code "You are missing {{requiredFieldCount}} required fields."}
037 * <p>
038 * Each lookup observes one unmodifiable shallow snapshot of the caller-supplied placeholder map. Whole-message
039 * alternative predicates, {@link LocalizedString.ExpressionTranslation expression-fragment} predicates, and
040 * {@link LocalizedString.LanguageFormTranslation language-form} selectors read from that same snapshot for every
041 * locale candidate. Generated placeholder values do not become expression operands or language-form selector inputs;
042 * in particular, a language-form {@code value} or range endpoint always names raw caller input.
043 * <p>
044 * Generated-placeholder definitions declared by a {@link LocalizedString} are inherited along the selected
045 * whole-message alternative branch. The nearest selected descendant's complete definition replaces a same-named
046 * ancestor definition, unselected sibling definitions are invisible, and the resulting definition takes precedence
047 * over a same-named caller value when rendering output. Definitions are frozen for the selected branch before any
048 * generated fragment is resolved.
049 *
050 * @author <a href="https://revetkn.com">Mark Allen</a>
051 */
052public interface Strings extends LocaleMatcher {
053        /**
054         * Gets a localized string for the given key.
055         * <p>
056         * If no localized string is available, the configured {@link TranslationFailureHandler} decides what happens.
057         *
058         * @param key the localization key, not null
059         * @return a localized string for the key, not null
060         */
061        @NonNull
062        String get(@NonNull String key);
063
064        /**
065         * Gets a localized string for the given key and options.
066         * <p>
067         * If no localized string is available, the configured or per-invocation {@link TranslationFailureHandler} decides what happens.
068         *
069         * @param key     the localization key, not null
070         * @param options per-invocation options, not null
071         * @return a localized string for the key, not null
072         * @since 3.0.0
073         */
074        @NonNull
075        String get(@NonNull String key, @NonNull TranslationOptions options);
076
077        /**
078         * Gets a localized string for the given key.
079         * <p>
080         * If no localized string is available, the configured {@link TranslationFailureHandler} decides what happens.
081         *
082         * @param key          the localization key, not null
083         * @param placeholders the placeholders to insert into the string, may be null
084         * @return a localized string for the key, not null
085         */
086        @NonNull
087        String get(@NonNull String key, @Nullable Map<@NonNull String, @Nullable Object> placeholders);
088
089        /**
090         * Gets a localized string for the given key, placeholders, and options.
091         * <p>
092         * If no localized string is available, the configured or per-invocation {@link TranslationFailureHandler} decides what happens.
093         *
094         * @param key          the localization key, not null
095         * @param placeholders the placeholders to insert into the string, may be null
096         * @param options      per-invocation options, not null
097         * @return a localized string for the key, not null
098         * @since 3.0.0
099         */
100        @NonNull
101        String get(@NonNull String key,
102                                                 @Nullable Map<@NonNull String, @Nullable Object> placeholders,
103                                                 @NonNull TranslationOptions options);
104
105        /**
106         * Resolves a localized string and returns diagnostic information about the lookup.
107         *
108         * @param key localization key, not null
109         * @return translation result, not null
110         * @since 3.0.0
111         */
112        @NonNull
113        default TranslationResult getResult(@NonNull String key) {
114                return getResult(key, null, TranslationOptions.none());
115        }
116
117        /**
118         * Resolves a localized string with per-invocation options and returns diagnostic information.
119         *
120         * @param key     localization key, not null
121         * @param options per-invocation options, not null
122         * @return translation result, not null
123         * @since 3.0.0
124         */
125        @NonNull
126        default TranslationResult getResult(@NonNull String key, @NonNull TranslationOptions options) {
127                return getResult(key, null, options);
128        }
129
130        /**
131         * Resolves a localized string with placeholders and returns diagnostic information.
132         *
133         * @param key          localization key, not null
134         * @param placeholders caller-supplied placeholders, may be null
135         * @return translation result, not null
136         * @since 3.0.0
137         */
138        @NonNull
139        default TranslationResult getResult(@NonNull String key,
140                                                                                                                         @Nullable Map<@NonNull String, @Nullable Object> placeholders) {
141                return getResult(key, placeholders, TranslationOptions.none());
142        }
143
144        /**
145         * Resolves a localized string with placeholders and options and returns diagnostic information.
146         * <p>
147         * A handler response that throws still throws; successful translations and return-key/return-string responses are
148         * represented by the returned result.
149         * A configured {@link TranslationFallbackObserver} runs once before a successful fallback result is returned;
150         * observer exceptions propagate directly to the caller.
151         * <p>
152         * The supplied map is shallow-copied once before the first locale candidate is attempted. Alternative predicates,
153         * language-form selectors, failure reporting, and every fallback candidate observe that same snapshot. Generated
154         * fragments are resolved separately and cannot change expression operands or selector inputs.
155         *
156         * @param key          localization key, not null
157         * @param placeholders caller-supplied placeholders, may be null
158         * @param options      per-invocation options, not null
159         * @return translation result, not null
160         * @since 3.0.0
161         */
162        @NonNull
163        TranslationResult getResult(@NonNull String key,
164                                                                                                @Nullable Map<@NonNull String, @Nullable Object> placeholders,
165                                                                                                @NonNull TranslationOptions options);
166
167        /**
168         * Gets the locales for which localized strings were supplied.
169         *
170         * @return the supported locales, not null
171         * @since 3.0.0
172         */
173        @NonNull
174        Set<@NonNull Locale> getSupportedLocales();
175
176        /**
177         * Gets the localized string keys supplied for the given locale.
178         *
179         * @param locale locale to inspect, not null
180         * @return the localized string keys for the locale, not null
181         * @throws IllegalArgumentException if the locale is not supported
182         * @since 3.0.0
183         */
184        @NonNull
185        Set<@NonNull String> getKeysForLocale(@NonNull Locale locale);
186
187        /**
188         * Gets the keys supplied by {@code sourceLocale} but missing from {@code targetLocale}.
189         *
190         * @param sourceLocale locale whose keys are used as the source set, not null
191         * @param targetLocale locale whose keys are compared against the source set, not null
192         * @return keys present in {@code sourceLocale} and missing from {@code targetLocale}, not null
193         * @throws IllegalArgumentException if either locale is not supported
194         * @since 3.0.0
195         */
196        @NonNull
197        Set<@NonNull String> getMissingKeys(@NonNull Locale sourceLocale, @NonNull Locale targetLocale);
198
199        /**
200         * Vends a {@link Strings} instance builder for the specified fallback locale.
201         * <pre>{@code
202         * Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
203         *     .localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
204         *     .localeSupplier((matcher) -> matcher.bestMatchFor(Locale.forLanguageTag("en-GB")))
205         *     .build();
206         * }</pre>
207         *
208         * @param fallbackLocale the fallback locale, not null
209         * @return a builder for a {@link Strings} instance, not null
210         * @throws IllegalArgumentException if the fallback locale is not a well-formed IETF BCP 47 locale
211         */
212        static @NonNull Builder withFallbackLocale(@NonNull Locale fallbackLocale) {
213                LocaleUtils.requireWellFormed(fallbackLocale, "Fallback locale");
214                return new Builder(fallbackLocale);
215        }
216
217        /**
218         * Builder used to construct {@link Strings} instances.
219         * <p>
220         * This class is intended for use by a single thread.
221         *
222         * @author <a href="https://revetkn.com">Mark Allen</a>
223         * @since 3.0.0
224         */
225        @NotThreadSafe
226        class Builder {
227                @NonNull
228                private final Locale fallbackLocale;
229                @Nullable
230                private Supplier<@NonNull Map<@NonNull Locale,
231                                ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier;
232                @Nullable
233                private Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier;
234                @Nullable
235                private Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier;
236                @Nullable
237                private Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode;
238                @Nullable
239                private TranslationFailureHandler translationFailureHandler;
240                @Nullable
241                private TranslationFallbackPolicy translationFallbackPolicy;
242                @Nullable
243                private TranslationFallbackObserver translationFallbackObserver;
244                @Nullable
245                private TranslationRuntimeLimits runtimeLimits;
246                @Nullable
247                private PhoneticResolver phoneticResolver;
248                @Nullable
249                private BidiIsolation bidiIsolation;
250                @Nullable
251                private LanguageRangeEquivalents languageRangeEquivalents;
252
253                /**
254                 * Constructs a strings builder with a default locale.
255                 *
256                 * @param fallbackLocale fallback locale, not null
257                 */
258                Builder(@NonNull Locale fallbackLocale) {
259                        requireNonNull(fallbackLocale);
260                        this.fallbackLocale = fallbackLocale;
261                }
262
263                /**
264                 * Applies a localized string supplier to this builder.
265                 * <p>
266                 * Locale keys returned by the supplier must be well-formed and must render to distinct IETF BCP 47 language tags.
267                 * The returned map, its keys, each per-locale iterable, and every localized string must be non-null. Each
268                 * per-locale iterable must contain unique translation keys.
269                 *
270                 * @param localizedStringSupplier localized string supplier, may be null
271                 * @return this builder instance, useful for chaining. not null
272                 */
273                @NonNull
274                public Builder localizedStringSupplier(@Nullable Supplier<@NonNull Map<@NonNull Locale,
275                                ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier) {
276                        this.localizedStringSupplier = localizedStringSupplier;
277                        return this;
278                }
279
280                /**
281                 * Applies a locale supplier to this builder.
282                 * <p>
283                 * The supplier may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. It
284                 * must return a well-formed IETF BCP 47 locale.
285                 *
286                 * @param localeSupplier locale supplier, may be null
287                 * @return this builder instance, useful for chaining. not null
288                 */
289                @NonNull
290                public Builder localeSupplier(
291                                @Nullable Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier) {
292                        this.localeSupplier = localeSupplier;
293
294                        if (localeSupplier != null)
295                                this.localeMatchSupplier = null;
296
297                        return this;
298                }
299
300                /**
301                 * Applies a locale-negotiation-result supplier to this builder.
302                 * <p>
303                 * Prefer this over {@link #localeSupplier(Function)} when callers of {@link #getResult(String)} should receive the
304                 * original negotiation diagnostics. The supplier may be invoked concurrently and must be thread-safe. Supplying a
305                 * non-null value replaces any locale supplier.
306                 *
307                 * @param localeMatchSupplier locale-match supplier, may be null
308                 * @return this builder, not null
309                 * @since 3.0.0
310                 */
311                @NonNull
312                public Builder localeMatchSupplier(
313                                @Nullable Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier) {
314                        this.localeMatchSupplier = localeMatchSupplier;
315
316                        if (localeMatchSupplier != null)
317                                this.localeSupplier = null;
318
319                        return this;
320                }
321
322                /**
323                 * Applies a mapping of a well-formed IETF BCP 47 primary language subtag to its ordered "tiebreaker" fallback
324                 * locales to this builder.
325                 * <p>
326                 * Deprecated language aliases are canonicalized, so aliases such as {@code he} and {@code iw} must not both be
327                 * supplied. Each list must be a duplicate-free exact permutation of the loaded {@link Locale}s whose normalized
328                 * primary language matches its key. This configuration is validated by {@link #build()}.
329                 *
330                 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null; keys, lists, and locales
331                 *                                         must not be null
332                 * @return this builder instance, useful for chaining. not null
333                 */
334                @NonNull
335                public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode) {
336                        this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode;
337                        return this;
338                }
339
340                /**
341                 * Applies a phonetic resolver to this builder.
342                 * <p>
343                 * The resolver may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe.
344                 *
345                 * @param phoneticResolver phonetic resolver, may be null (defaults to fail-fast resolver)
346                 * @return this builder instance, useful for chaining. not null
347                 */
348                @NonNull
349                public Builder phoneticResolver(@Nullable PhoneticResolver phoneticResolver) {
350                        this.phoneticResolver = phoneticResolver;
351                        return this;
352                }
353
354                /**
355                 * Applies a translation failure handler to this builder.
356                 * <p>
357                 * The handler may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe.
358                 *
359                 * @param translationFailureHandler handler for failed lookups, may be null (defaults to returning the key)
360                 * @return this builder instance, useful for chaining. not null
361                 */
362                @NonNull
363                public Builder translationFailureHandler(@Nullable TranslationFailureHandler translationFailureHandler) {
364                        this.translationFailureHandler = translationFailureHandler;
365                        return this;
366                }
367
368                /**
369                 * Applies the policy that decides whether failed locale attempts continue to fallback candidates.
370                 * <p>
371                 * The policy may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe.
372                 * The default {@link TranslationFallbackPolicy#fallbackOnMissingTranslationOrNoMatchingAlternative()} policy stops
373                 * on {@link TranslationFailureReason#RESOLUTION_FAILURE}, including failures while evaluating an expression-fragment
374                 * predicate or interpolating its selected/default fragment.
375                 *
376                 * @param translationFallbackPolicy locale fallback policy, may be null to use the safe default
377                 * @return this builder, not null
378                 */
379                @NonNull
380                public Builder translationFallbackPolicy(@Nullable TranslationFallbackPolicy translationFallbackPolicy) {
381                        this.translationFallbackPolicy = translationFallbackPolicy;
382                        return this;
383                }
384
385                /**
386                 * Applies an observer for translations supplied by a later locale candidate after earlier candidates failed.
387                 * <p>
388                 * The observer runs once before a successful lookup returns. It may be invoked concurrently and must be
389                 * thread-safe. Observer exceptions propagate directly to the caller.
390                 *
391                 * @param translationFallbackObserver successful fallback observer, may be null to disable observation
392                 * @return this builder, not null
393                 * @since 3.1.1
394                 */
395                @NonNull
396                public Builder translationFallbackObserver(@Nullable TranslationFallbackObserver translationFallbackObserver) {
397                        this.translationFallbackObserver = translationFallbackObserver;
398                        return this;
399                }
400
401                /**
402                 * Applies safety limits to localized strings construction and translation evaluation.
403                 * <p>
404                 * Expression limits apply to both whole-message alternatives and
405                 * {@link LocalizedString.ExpressionAlternative expression-fragment alternatives}. Generated-placeholder depth and
406                 * expansion limits are shared by {@link LocalizedString.LanguageFormTranslation language-form} and
407                 * {@link LocalizedString.ExpressionTranslation expression-selected} fragments. The interpolated-output limit also
408                 * bounds caller-supplied {@link CharSequence} values materialized for phonetic resolution.
409                 *
410                 * @param runtimeLimits runtime limits, may be null to use the library defaults
411                 * @return this builder, not null
412                 * @since 3.0.0
413                 */
414                @NonNull
415                public Builder runtimeLimits(@Nullable TranslationRuntimeLimits runtimeLimits) {
416                        this.runtimeLimits = runtimeLimits;
417                        return this;
418                }
419
420                /**
421                 * Applies bidirectional isolation behavior for caller-supplied placeholder values.
422                 * <p>
423                 * Translation-owned text in generated fragments is not isolated; caller-supplied values interpolated into those
424                 * fragments use this policy.
425                 *
426                 * @param bidiIsolation bidi isolation behavior, may be null (defaults to isolating caller-supplied values in RTL locales)
427                 * @return this builder instance, useful for chaining. not null
428                 */
429                @NonNull
430                public Builder bidiIsolation(@Nullable BidiIsolation bidiIsolation) {
431                        this.bidiIsolation = bidiIsolation;
432                        return this;
433                }
434
435                /**
436                 * Applies the source of IANA language-range equivalences, such as the {@code he}/{@code iw} pair, for this instance.
437                 * <p>
438                 * The source governs {@link LocaleMatcher#parseLanguageRanges(String)}, {@link LocaleMatcher#bestMatchForAcceptLanguage(String)},
439                 * and the equivalences locale matching recognizes for each requested range, so it can change which loaded locale a
440                 * request selects. {@link LanguageRangeEquivalents#IANA_REGISTRY} uses the IANA Language Subtag Registry snapshot
441                 * bundled in Lokalized and supplies the same equivalents on every JDK; {@link LanguageRangeEquivalents#JDK} uses
442                 * the running JDK's table, as Lokalized 3.0.0 did. A range list parsed by the caller with
443                 * {@link java.util.Locale.LanguageRange#parse(String)} carries the JDK's equivalents whatever this is set to; parse
444                 * it with {@link LocaleMatcher#parseLanguageRanges(String)} to apply this setting.
445                 *
446                 * @param languageRangeEquivalents equivalence source, may be null (defaults to {@link LanguageRangeEquivalents#IANA_REGISTRY})
447                 * @return this builder instance, useful for chaining. not null
448                 * @since 3.1.0
449                 */
450                @NonNull
451                public Builder languageRangeEquivalents(@Nullable LanguageRangeEquivalents languageRangeEquivalents) {
452                        this.languageRangeEquivalents = languageRangeEquivalents;
453                        return this;
454                }
455
456                /**
457                 * Constructs a {@link Strings} instance.
458                 *
459                 * @return a {@link Strings} instance, not null
460                 * @throws IllegalArgumentException if required localized-string or locale suppliers are missing, a configured locale
461                 *                                  is malformed, localized-string locale keys render to duplicate language tags,
462                 *                                  tiebreaker keys are invalid or collide after canonicalization, a tiebreaker list is
463                 *                                  not an exact permutation of its language's loaded locales, or supplied localized
464                 *                                  strings are invalid, including an alternative graph nested more than 128 levels deep
465                 * @throws ExpressionEvaluationException if an expression cannot be compiled, including when it exceeds the
466                 *                                       configured runtime limits
467                 */
468                @NonNull
469                public Strings build() {
470                        return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, localeMatchSupplier,
471                                        tiebreakerLocalesByLanguageCode,
472                                        translationFailureHandler, phoneticResolver, bidiIsolation, translationFallbackPolicy, runtimeLimits,
473                                        languageRangeEquivalents, translationFallbackObserver);
474                }
475        }
476}