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         * <p>
150         * The supplied map is shallow-copied once before the first locale candidate is attempted. Alternative predicates,
151         * language-form selectors, failure reporting, and every fallback candidate observe that same snapshot. Generated
152         * fragments are resolved separately and cannot change expression operands or selector inputs.
153         *
154         * @param key          localization key, not null
155         * @param placeholders caller-supplied placeholders, may be null
156         * @param options      per-invocation options, not null
157         * @return translation result, not null
158         * @since 3.0.0
159         */
160        @NonNull
161        TranslationResult getResult(@NonNull String key,
162                                                                                                @Nullable Map<@NonNull String, @Nullable Object> placeholders,
163                                                                                                @NonNull TranslationOptions options);
164
165        /**
166         * Gets the locales for which localized strings were supplied.
167         *
168         * @return the supported locales, not null
169         * @since 3.0.0
170         */
171        @NonNull
172        Set<@NonNull Locale> getSupportedLocales();
173
174        /**
175         * Gets the localized string keys supplied for the given locale.
176         *
177         * @param locale locale to inspect, not null
178         * @return the localized string keys for the locale, not null
179         * @throws IllegalArgumentException if the locale is not supported
180         * @since 3.0.0
181         */
182        @NonNull
183        Set<@NonNull String> getKeysForLocale(@NonNull Locale locale);
184
185        /**
186         * Gets the keys supplied by {@code sourceLocale} but missing from {@code targetLocale}.
187         *
188         * @param sourceLocale locale whose keys are used as the source set, not null
189         * @param targetLocale locale whose keys are compared against the source set, not null
190         * @return keys present in {@code sourceLocale} and missing from {@code targetLocale}, not null
191         * @throws IllegalArgumentException if either locale is not supported
192         * @since 3.0.0
193         */
194        @NonNull
195        Set<@NonNull String> getMissingKeys(@NonNull Locale sourceLocale, @NonNull Locale targetLocale);
196
197        /**
198         * Vends a {@link Strings} instance builder for the specified fallback locale.
199         * <pre>{@code
200         * Strings strings = Strings.withFallbackLocale(Locale.forLanguageTag("en"))
201         *     .localizedStringSupplier(() -> LocalizedStringLoader.loadFromClasspath("strings"))
202         *     .localeSupplier((matcher) -> matcher.bestMatchFor(Locale.forLanguageTag("en-GB")))
203         *     .build();
204         * }</pre>
205         *
206         * @param fallbackLocale the fallback locale, not null
207         * @return a builder for a {@link Strings} instance, not null
208         * @throws IllegalArgumentException if the fallback locale is not a well-formed IETF BCP 47 locale
209         */
210        static @NonNull Builder withFallbackLocale(@NonNull Locale fallbackLocale) {
211                LocaleUtils.requireWellFormed(fallbackLocale, "Fallback locale");
212                return new Builder(fallbackLocale);
213        }
214
215        /**
216         * Builder used to construct {@link Strings} instances.
217         * <p>
218         * This class is intended for use by a single thread.
219         *
220         * @author <a href="https://revetkn.com">Mark Allen</a>
221         * @since 3.0.0
222         */
223        @NotThreadSafe
224        class Builder {
225                @NonNull
226                private final Locale fallbackLocale;
227                @Nullable
228                private Supplier<@NonNull Map<@NonNull Locale,
229                                ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier;
230                @Nullable
231                private Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier;
232                @Nullable
233                private Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier;
234                @Nullable
235                private Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode;
236                @Nullable
237                private TranslationFailureHandler translationFailureHandler;
238                @Nullable
239                private TranslationFallbackPolicy translationFallbackPolicy;
240                @Nullable
241                private TranslationRuntimeLimits runtimeLimits;
242                @Nullable
243                private PhoneticResolver phoneticResolver;
244                @Nullable
245                private BidiIsolation bidiIsolation;
246
247                /**
248                 * Constructs a strings builder with a default locale.
249                 *
250                 * @param fallbackLocale fallback locale, not null
251                 */
252                Builder(@NonNull Locale fallbackLocale) {
253                        requireNonNull(fallbackLocale);
254                        this.fallbackLocale = fallbackLocale;
255                }
256
257                /**
258                 * Applies a localized string supplier to this builder.
259                 * <p>
260                 * Locale keys returned by the supplier must be well-formed and must render to distinct IETF BCP 47 language tags.
261                 * The returned map, its keys, each per-locale iterable, and every localized string must be non-null. Each
262                 * per-locale iterable must contain unique translation keys.
263                 *
264                 * @param localizedStringSupplier localized string supplier, may be null
265                 * @return this builder instance, useful for chaining. not null
266                 */
267                @NonNull
268                public Builder localizedStringSupplier(@Nullable Supplier<@NonNull Map<@NonNull Locale,
269                                ? extends @NonNull Iterable<@NonNull LocalizedString>>> localizedStringSupplier) {
270                        this.localizedStringSupplier = localizedStringSupplier;
271                        return this;
272                }
273
274                /**
275                 * Applies a locale supplier to this builder.
276                 * <p>
277                 * The supplier may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe. It
278                 * must return a well-formed IETF BCP 47 locale.
279                 *
280                 * @param localeSupplier locale supplier, may be null
281                 * @return this builder instance, useful for chaining. not null
282                 */
283                @NonNull
284                public Builder localeSupplier(
285                                @Nullable Function<@NonNull LocaleMatcher, @NonNull Locale> localeSupplier) {
286                        this.localeSupplier = localeSupplier;
287
288                        if (localeSupplier != null)
289                                this.localeMatchSupplier = null;
290
291                        return this;
292                }
293
294                /**
295                 * Applies a locale-negotiation-result supplier to this builder.
296                 * <p>
297                 * Prefer this over {@link #localeSupplier(Function)} when callers of {@link #getResult(String)} should receive the
298                 * original negotiation diagnostics. The supplier may be invoked concurrently and must be thread-safe. Supplying a
299                 * non-null value replaces any locale supplier.
300                 *
301                 * @param localeMatchSupplier locale-match supplier, may be null
302                 * @return this builder, not null
303                 * @since 3.0.0
304                 */
305                @NonNull
306                public Builder localeMatchSupplier(
307                                @Nullable Function<@NonNull LocaleMatcher, @NonNull LocaleMatchResult> localeMatchSupplier) {
308                        this.localeMatchSupplier = localeMatchSupplier;
309
310                        if (localeMatchSupplier != null)
311                                this.localeSupplier = null;
312
313                        return this;
314                }
315
316                /**
317                 * Applies a mapping of a well-formed IETF BCP 47 primary language subtag to its ordered "tiebreaker" fallback
318                 * locales to this builder.
319                 * <p>
320                 * Deprecated language aliases are canonicalized, so aliases such as {@code he} and {@code iw} must not both be
321                 * supplied. Each list must be a duplicate-free exact permutation of the loaded {@link Locale}s whose normalized
322                 * primary language matches its key. This configuration is validated by {@link #build()}.
323                 *
324                 * @param tiebreakerLocalesByLanguageCode "tiebreaker" fallback locales, may be null; keys, lists, and locales
325                 *                                         must not be null
326                 * @return this builder instance, useful for chaining. not null
327                 */
328                @NonNull
329                public Builder tiebreakerLocalesByLanguageCode(@Nullable Map<@NonNull String, @NonNull List<@NonNull Locale>> tiebreakerLocalesByLanguageCode) {
330                        this.tiebreakerLocalesByLanguageCode = tiebreakerLocalesByLanguageCode;
331                        return this;
332                }
333
334                /**
335                 * Applies a phonetic resolver to this builder.
336                 * <p>
337                 * The resolver may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe.
338                 *
339                 * @param phoneticResolver phonetic resolver, may be null (defaults to fail-fast resolver)
340                 * @return this builder instance, useful for chaining. not null
341                 */
342                @NonNull
343                public Builder phoneticResolver(@Nullable PhoneticResolver phoneticResolver) {
344                        this.phoneticResolver = phoneticResolver;
345                        return this;
346                }
347
348                /**
349                 * Applies a translation failure handler to this builder.
350                 * <p>
351                 * The handler may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe.
352                 *
353                 * @param translationFailureHandler handler for failed lookups, may be null (defaults to returning the key)
354                 * @return this builder instance, useful for chaining. not null
355                 */
356                @NonNull
357                public Builder translationFailureHandler(@Nullable TranslationFailureHandler translationFailureHandler) {
358                        this.translationFailureHandler = translationFailureHandler;
359                        return this;
360                }
361
362                /**
363                 * Applies the policy that decides whether failed locale attempts continue to fallback candidates.
364                 * <p>
365                 * The policy may be invoked concurrently after the {@link Strings} instance is built and must be thread-safe.
366                 * The default {@link TranslationFallbackPolicy#fallbackOnMissingTranslationOrNoMatchingAlternative()} policy stops
367                 * on {@link TranslationFailureReason#RESOLUTION_FAILURE}, including failures while evaluating an expression-fragment
368                 * predicate or interpolating its selected/default fragment.
369                 *
370                 * @param translationFallbackPolicy locale fallback policy, may be null to use the safe default
371                 * @return this builder, not null
372                 */
373                @NonNull
374                public Builder translationFallbackPolicy(@Nullable TranslationFallbackPolicy translationFallbackPolicy) {
375                        this.translationFallbackPolicy = translationFallbackPolicy;
376                        return this;
377                }
378
379                /**
380                 * Applies safety limits to localized strings construction and translation evaluation.
381                 * <p>
382                 * Expression limits apply to both whole-message alternatives and
383                 * {@link LocalizedString.ExpressionAlternative expression-fragment alternatives}. Generated-placeholder depth and
384                 * expansion limits are shared by {@link LocalizedString.LanguageFormTranslation language-form} and
385                 * {@link LocalizedString.ExpressionTranslation expression-selected} fragments. The interpolated-output limit also
386                 * bounds caller-supplied {@link CharSequence} values materialized for phonetic resolution.
387                 *
388                 * @param runtimeLimits runtime limits, may be null to use the library defaults
389                 * @return this builder, not null
390                 * @since 3.0.0
391                 */
392                @NonNull
393                public Builder runtimeLimits(@Nullable TranslationRuntimeLimits runtimeLimits) {
394                        this.runtimeLimits = runtimeLimits;
395                        return this;
396                }
397
398                /**
399                 * Applies bidirectional isolation behavior for caller-supplied placeholder values.
400                 * <p>
401                 * Translation-owned text in generated fragments is not isolated; caller-supplied values interpolated into those
402                 * fragments use this policy.
403                 *
404                 * @param bidiIsolation bidi isolation behavior, may be null (defaults to isolating caller-supplied values in RTL locales)
405                 * @return this builder instance, useful for chaining. not null
406                 */
407                @NonNull
408                public Builder bidiIsolation(@Nullable BidiIsolation bidiIsolation) {
409                        this.bidiIsolation = bidiIsolation;
410                        return this;
411                }
412
413                /**
414                 * Constructs a {@link Strings} instance.
415                 *
416                 * @return a {@link Strings} instance, not null
417                 * @throws IllegalArgumentException if required localized-string or locale suppliers are missing, a configured locale
418                 *                                  is malformed, localized-string locale keys render to duplicate language tags,
419                 *                                  tiebreaker keys are invalid or collide after canonicalization, a tiebreaker list is
420                 *                                  not an exact permutation of its language's loaded locales, or supplied localized
421                 *                                  strings are invalid, including an alternative graph nested more than 128 levels deep
422                 * @throws ExpressionEvaluationException if an expression cannot be compiled, including when it exceeds the
423                 *                                       configured runtime limits
424                 */
425                @NonNull
426                public Strings build() {
427                        return new DefaultStrings(fallbackLocale, localizedStringSupplier, localeSupplier, localeMatchSupplier,
428                                        tiebreakerLocalesByLanguageCode,
429                                        translationFailureHandler, phoneticResolver, bidiIsolation, translationFallbackPolicy, runtimeLimits);
430                }
431        }
432}