From 64601100c75f9d46748faea45e58224d42fbde5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Mendelski?= Date: Mon, 2 Jan 2023 18:00:36 +0100 Subject: [PATCH] Add tests and documentation. Improve edge case handling. --- README.md | 406 ++++++++++++++++-- .../quark/i18n/AggregatedI18nLoader.java | 35 +- .../quark/i18n/ArgumentIndexExtractor.java | 60 +++ .../coditory/quark/i18n/ArgumentResolver.java | 52 ++- .../com/coditory/quark/i18n/Currencies.java | 4 + .../quark/i18n/I18nArgTransformer.java | 12 +- .../java/com/coditory/quark/i18n/I18nKey.java | 3 +- .../coditory/quark/i18n/I18nKeyGenerator.java | 10 +- .../coditory/quark/i18n/I18nMessagePack.java | 34 +- .../quark/i18n/I18nMessagePackBuilder.java | 133 ++++-- .../com/coditory/quark/i18n/I18nMessages.java | 2 +- .../quark/i18n/I18nMissingMessageHandler.java | 59 +++ .../i18n/I18nMissingMessagesDetector.java | 170 ++++++++ .../quark/i18n/I18nSystemDefaults.java | 4 +- .../i18n/I18nUnresolvedMessageHandler.java | 27 -- .../quark/i18n/ImmutableI18nMessagePack.java | 73 +++- .../coditory/quark/i18n/LocaleResolver.java | 5 +- .../java/com/coditory/quark/i18n/Locales.java | 17 + .../coditory/quark/i18n/MessageTemplate.java | 22 +- .../quark/i18n/MessageTemplateNormalizer.java | 3 + .../quark/i18n/MessageTemplateParser.java | 11 +- .../java/com/coditory/quark/i18n/Money.java | 9 + .../quark/i18n/ReferenceResolver.java | 29 +- .../quark/i18n/Reloadable18nMessagePack.java | 16 +- .../quark/i18n/TemplatesBundlePrefixes.java | 11 +- .../quark/i18n/loader/FileWatcher.java | 2 +- .../quark/i18n/loader/I18nFileLoader.java | 30 +- .../i18n/loader/I18nFileLoaderBuilder.java | 2 +- .../quark/i18n/loader/I18nLoadException.java | 2 +- .../quark/i18n/loader/I18nLoader.java | 2 +- ...atesBundle.java => I18nMessageBundle.java} | 7 +- .../coditory/quark/i18n/loader/Resource.java | 10 +- .../quark/i18n/loader/ResourceScanner.java | 4 + .../coditory/quark/i18n/loader/Runner.java | 42 -- .../i18n/loader/WatchableI18nLoader.java | 5 +- .../quark/i18n/parser/EntriesI18nParser.java | 3 + .../quark/i18n/parser/I18nParseException.java | 2 +- .../quark/i18n/parser/JsonI18nParser.java | 3 + .../i18n/parser/PropertiesI18nParser.java | 2 + .../quark/i18n/parser/YamlI18nParser.java | 4 +- .../i18n/ArgumentIndexExtractorSpec.groovy | 45 ++ .../coditory/quark/i18n/I18nKeySpec.groovy | 7 +- .../quark/i18n/MessageHierarchySpec.groovy | 101 +++++ .../quark/i18n/MessageResolutionSpec.groovy | 50 +++ .../MessageWhitespaceNormalizationSpec.groovy | 32 ++ .../i18n/MissingMessageDetectionSpec.groovy | 178 ++++++++ .../quark/i18n/MissingMessageSpec.groovy | 64 +++ .../quark/i18n/ReferenceHierarchySpec.groovy | 143 ++++++ .../quark/i18n/ReferenceResolutionSpec.groovy | 102 +++++ .../i18n/ResolveMessageReferenceSpec.groovy | 159 ------- .../quark/i18n/ResolveMessageSpec.groovy | 100 ----- .../i18n/TypeBasedArgFormattingSpec.groovy | 113 +++++ .../quark/i18n/base/CapturingAppender.groovy | 102 +++++ .../quark/i18n/base/InMemI18nLoader.groovy | 10 +- .../i18n/formats/IcuDateFormatSpec.groovy | 2 +- .../formats/IcuRuleBasedFormatSpec.groovy | 4 +- .../i18n/formats/IcuSelectFormatSpec.groovy | 4 +- .../i18n/formats/IcuTimeFormatSpec.groovy | 2 +- 58 files changed, 2006 insertions(+), 539 deletions(-) create mode 100644 src/main/java/com/coditory/quark/i18n/ArgumentIndexExtractor.java create mode 100755 src/main/java/com/coditory/quark/i18n/I18nMissingMessageHandler.java create mode 100644 src/main/java/com/coditory/quark/i18n/I18nMissingMessagesDetector.java delete mode 100755 src/main/java/com/coditory/quark/i18n/I18nUnresolvedMessageHandler.java rename src/main/java/com/coditory/quark/i18n/loader/{I18nTemplatesBundle.java => I18nMessageBundle.java} (56%) delete mode 100644 src/main/java/com/coditory/quark/i18n/loader/Runner.java create mode 100644 src/test/groovy/com/coditory/quark/i18n/ArgumentIndexExtractorSpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/MessageHierarchySpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/MessageResolutionSpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/MessageWhitespaceNormalizationSpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/MissingMessageDetectionSpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/MissingMessageSpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/ReferenceHierarchySpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/ReferenceResolutionSpec.groovy delete mode 100755 src/test/groovy/com/coditory/quark/i18n/ResolveMessageReferenceSpec.groovy delete mode 100755 src/test/groovy/com/coditory/quark/i18n/ResolveMessageSpec.groovy create mode 100755 src/test/groovy/com/coditory/quark/i18n/TypeBasedArgFormattingSpec.groovy create mode 100644 src/test/groovy/com/coditory/quark/i18n/base/CapturingAppender.groovy diff --git a/README.md b/README.md index 89ce2e5..0c59b59 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ [![Coverage Status](https://coveralls.io/repos/github/coditory/quark-i18n/badge.svg)](https://coveralls.io/github/coditory/quark-i18n) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.coditory.quark/quark-i18n/badge.svg)](https://mvnrepository.com/artifact/com.coditory.quark/quark-i18n) -## Additional features +**🚧 This library as under heavy development until release of version `1.x.x` 🚧** -- [ICU message formatting](#message-formatting) +> Advanced i18n message resolution java library. Provides missing capabilities of +> java [ResourceBundle](https://www.baeldung.com/java-resourcebundle). Uses [icu4j](https://github.com/unicode-org/icu) +> for standardized message formatting. + +- [ICU Message formatting](#message-formatting) +- [Message resolution with fallbacks and prefixed queries](#message-resolution) - [Message references](#message-references) -- Messages resolution with [indexed](./README_FORMAT.md#indexed-argument) - and [named](./README_FORMAT.md##named-argument) arguments -- [Type based formatters](#type-based-formatters) -- [Missing message detection](#missing-message-detection) -- [Whitespace normalization](#whitespace-normalization) -- Splitting big file into multiple smaller ones -- ...or use single file to define messages for multiple locales -- Supports multiple file formats: yaml, properties, json -- [Auto-reloadable dev mode](#devmode) +- [Missing message handling and detection](#missing-messages) +- [Flexible message loading](#message-loading) +- [DevMode](#devmode) +- [Other features](#other-features) ## Installation @@ -28,15 +28,24 @@ dependencies { } ``` -## Usage +## Basic usage + +```java +I18nMessagePack messagePack=I18nMessagePack.builder() + .scanClassPath("/i18n/messages-{locale}.yml") + .setDefaultLocale(Locales.EN_US) + .build(); -Build +// ...when request arrives + I18nMessages messages=messagePack.localize(req.getLocale()); + print(messages.getMessage("greeting",userName)); +``` ## Message formatting Message formatting is fully handled by [ICU4J](https://github.com/unicode-org/icu). ICU is a mature, widely used set of libraries providing Unicode and Globalization support for software applications. -It's a standard handled by multiple translation focused systems. +It's a standard handled by multiple translation centric systems. Some examples: @@ -55,28 +64,371 @@ Message was sent on {0,date}. You have {0, plural, zero {no new messages}, one {one new message} other {# new messages}} ``` -For more examples go to [advanced message formatting examples](./README-FORMAT.md) +> For more examples go to [advanced message formatting examples](./README-FORMAT.md) + +## Message loading + +Messages can be created in 3 ways: + +- Created manually using a builder +- Loaded from classpath +- Loaded from filesystem + +### Manual messages creation + +```java +I18nMessagePack messages=I18nMessagePack.builder() + .addMessage(Loacles.EN_US,"hello","Hello {0}") + .addMessage(Loacles.PL_PL,"hello","Cześć {0}") + .setDefaultLocale(PL_PL) + .build(); + + messages.getMessage(Loacles.EN_US,"hello",userName); +``` + +If you want to quickly load messages from a nested map (for example fetched from a document storage) +you can use `I18nParsers.parseEntries(map, locale)` to translate nested the map keys into localized message paths. + +```java +I18nMessagePack messages=I18nMessagePack.builder() + .addMessages(I18nParsers.parseEntries(Map.of("hello","Hello {0}"),EN_US)) + .build(); +``` + +### Loading messages from classpath or file system + +```java +I18nMessagePack messages=I18nMessagePack.builder() + .scanClassPath("/i18n/messages-{locale}.yml") + .scanFileSystem("./overriddes/messages-{locale}.yml") + .setDefaultLocale(PL_PL) + .build(); +``` + +Localization based path placeholders are used to assign all messages in the file to file's locale. +Available placeholders: + +- `{locale}` - matches language code or language code with country code +- `{lang}` - matches language code (cannot be used with `{locale}`) +- `{country}` - matches country code (requires `{lang}`) + +Prefixed based path placeholders are used to prefix all message paths (and [references](#message-references)) in the +file to file's prefix. +Available placeholders: + +- `{prefix}` - matches single directory name or part of a file +- `{prefixes}` - matches multiple directory names + +Supported formats: + +- `YAML` - thanks to [SnakeYAML](https://mvnrepository.com/artifact/org.yaml/snakeyaml) library +- `JSON` - thanks to [Gson](https://mvnrepository.com/artifact/com.google.code.gson/gson) library +- `properties` - with UTF-8 encoding only + +There is [dev mode](#devmode) that auto-reloads files during development. + +### Watching for file changes + +To reload messages in file change use: + +``` +I18nMessagePack.builder() + .scanFileSystem("i18n/*") + .buildAndWatchForChanges(); +``` + +ATM, it works for messages loaded from filesystem only, but for add your own implementation of `WatchableI18nLoader`. + +## Message resolution + +Messages are resolved with locale and message path. +If there is no match for message path and locale then less strict locale is used. +If there is still no match then the default locale (followed by a less strict default locale) is used. + +```java +I18nMessagePack messages=I18nMessagePack.builder() + .scanClassPath("/i18n/messages-{locale}.yml") + .setDefaultLocale(PL_PL) + .addFallbackKeyPrefix("glossary") + .build(); + + String message=messages.getMessage(Locales.en_US,"hello"); +``` + +Locations used to find the message: + +``` +1. (en_US, en) hello +2. (pl_PL, pl) hello +``` + +### Message fallbacks + +Sometimes it is useful to specify a common path prefix for all unmatched queries: + +```java +I18nMessagePack messages=I18nMessagePack.builder() + .scanClassPath("/i18n/messages-{locale}.yml") + .setDefaultLocale(PL_PL) + .addMessageFallbackKeyPrefix("common") + .build(); + + String message=messages.getMessage(Locales.en_US,"hello"); +``` + +Locations used to find the message: + +``` +1. (en_US, en) hello +2. (en_US, en) common.hello +3. (pl_PL, pl) hello +4. (pl_PL, pl) common.hello +``` + +### Prefixed queries + +Sometimes it's useful to prefix all queries with some path, like in the example: + +```java +I18nMessagePack messages=I18nMessagePack.builder() + .scanClassPath("/i18n/messages-{locale}.yml") + .setDefaultLocale(PL_PL) + .build(); + I18nMessagePack homepageMessages=messages.prefixQueries("pages.homepage"); + String homepageTitle=homepageMessages.getMessage(en_US,"title"); + String homepageSubtitle=homepageMessages.getMessage(en_US,"title"); +``` + +Locations used to find the message: + +``` +1. (en_US, en) pages.homepage.title +2. (en_US, en) title +3. (pl_PL, pl) pages.homepage.title +4. (pl_PL, pl) title +``` + +### Localized queries + +Sometimes it's useful to apply common locale to all queries: + +```java +I18nMessagePack messagePack=I18nMessagePack.builder() + .scanClassPath("/i18n/messages-{locale}.yml") + .setDefaultLocale(PL_PL) + .build(); + +// ...when request arrives + I18nMessages messages=messagePack.localize(req.getLocale()); + String title=messages.getMessage("title"); + String subtitle=messages.getMessage("subtitle"); +``` + +Query localization mechanism can be used together with query prefixes: + +```java +I18nMessages messages=messagePack + .prefixQueries("pages.homepage") + .localize(req.getLocale()) +``` ## Message references +Message references are the way to reuse text across multiple messages. + +```yml +# Common entries +company: + name: ACME + established: 1988 +# Message +about-company: "${company.name} was established on ${company.established}" +``` + +```java +messages.getMessage("about-company")=="ACME was established on 1988" +``` + +- It's not a part of ICU standard +- Reference resolution mechanism can be disabled with: `i18nMessagePackBuilder.disableReferences()` +- You can add fallback path prefixes + with `i18nMessagePackBuilder.addReferenceFallbackKeyPrefix()` ([example](#sample-reference-resolution)) +- References in prefixed files are prefixed as + well `${foo} -> ${.foo}` ([example](#sample-reference-resolution-from-a-prefixed-file)) +- References have short notation `$common.reference` and long one `${common.reference}`. The long one is useful when + there reference is placed next to `[a-zA-Z0-9-_]`, like in `abc${common.reference}abc$`. + +### Reference resolution order + +Let's configure messages: + +```java +I18nMessagePack messagePack=I18nMessagePack.builder() + .addMessage(EN_US,"msg","${company.name} was established on 1988") + .scanClassPath("/i18n/messages-{locale}.yml") + .setDefaultLocale(PL_PL) + .addFallbackKeyPrefix("fallback") + .build(); +``` + +Locations used to find the message: + +``` +1. (en_US, en) company.name +2. (en_US, en) fallback.company.name +3. (pl_PL, pl) company.name +4. (pl_PL, pl) fallback.company.name +``` + +### References in a prefixed file + +If the reference is defined in a message stored in a prefixed file it will be automatically prefixed: + +```java +I18nMessagePack messagePack=I18nMessagePack.builder() + .scanClassPathLocation("i18n/{prefix}/message_{locale}.yml") + .setDefaultLocale(PL_PL) + .addFallbackKeyPrefix("fallback") + .build(); +``` + +and file `i18n/company/message_en-US.yml` contains + +```yml +msg: ${name} was established on 1988 +``` + +Locations used to find the message: + +``` +1. (en_US, en) company.name +2. (en_US, en) name +3. (en_US, en) fallback.company.name +4. (pl_PL, pl) company.name +5. (pl_PL, pl) name +6. (pl_PL, pl) fallback.company.name +``` + ## Type based formatters +You can map arguments by their type using argument transformers: +- transformation is located in the definition order +- only the arguments used in the message are transformed +- transformation is transitive - one value can be transformed multiple times + +Example: + +```java +I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "{0,number,00000.00000}") + .addArgumentTransformer(Foo, (foo) -> foo.getSomeNumber()) + .buildLocalized(EN); + +messages.getMessage("msg", new Foo(123.456)) == "00123.45600" +``` + +## Missing message handler + +When message is missing, exception is thrown. This mechanism can be changed with: + +``` +// add custom missing message handler +i18nMessagePackBuilder.setMissingMessageHandler(customHandler); + +// ...or simply return message path when message is missing +i18nMessagePackBuilder.usePathOnMissingMessage() +``` + ## Missing message detection +It's important to find about missing messages as quickly as possible +and avoid finding them on production. + +That's why there is an option to detect them during build phase: +``` +i18nMessagePackBuilder.validateNoMissingMessages() - throws exception on missing message +i18nMessagePackBuilder.logMissingMessages() - simply logs a report about missing messages +``` + +### Missing message sample report + +```java +I18nMessagePack.builder() + .addMessage(EN_US, "hello", "Hello") + .addMessage(PL_PL, "hello", "Cześć") + .addMessage(DE_DE, "bye", "Tschüss") + .logMissingMessages() + .build() +``` + +Will generate following report: +``` +Missing Messages +================ + Path: bye +Missing: en_US, pl_PL +Sources: de_DE + + Path: hello +Missing: de_DE +Sources: en_US, pl_PL + +Total: 2 +``` + +### Skipping paths during missing message detection + +It's common to store glossary type of messages in the default language. +Those kind of values are deliberately defined in a single place and should not be detected as missing in other locales. +You can skip them using a custom missing message detector: + +```java +I18nMissingMessagesDetector detector = I18nMissingMessagesDetector.builder() + .skipPath(skipPath) + .logMissingMessages() + .build() + +I18nMessagePack.builder() + .addMessage(EN_US, "a.b.c.d", "MISSING") + .addMessage(EN_US, "x", "X") + .addMessage(EN_GB, "x", "X") + .addMessage(PL_PL, "x", "X") + .detectMissingMessages(detector) + .build() + +// to skip a.b.c.d use one of sample path patterns as a skipPath: +// - "a.b.c.d", +// - "a.b.c.*", +// - "a.b.**", +// - "a.**", +// - "a.**.d", +// - "**.d", +// - "*.*.*.*" +``` + ## Whitespace normalization -## Files structure +You can normalize whitespaces by trimming texts them and compressing all consecutive whitespace characters to a +single `' '`. +It makes working with complicated messages easier. + +White space normalization +`' \n\tsome text with, \nspaces\t' -> 'some text with, spaces'` + +This mechanism is disabled by default and can be enabled with: `i18nMessagePackBuilder.normalizeWhitespaces()`. ## DevMode -## TODO - -- Fix TODOs in code -- Test -- Unify exceptions -- Unify public and final modifiers -- Update readme -- Release -- Trim new lines? -- add option to disable references -- entries -> bundles +You can use file watching capabilities to speed up the development cycle: + +``` +I18nMessagePackBuidler messagesBuilder = I18nMessagePack.builder() + .setDefaultLocale(EN_US); + // ... other common settings + +I18nMessagePack messages = devMode + ? messagesBuilder.scanFileSystem("src/main/resources/i18n/*").buildAndWatchForChanges() + : messagesBuilder.scanClassPath("i18n/*").build() +``` + +Following setup will load messages directly from project structure and watch for changes. diff --git a/src/main/java/com/coditory/quark/i18n/AggregatedI18nLoader.java b/src/main/java/com/coditory/quark/i18n/AggregatedI18nLoader.java index 1d2eafc..984ee92 100644 --- a/src/main/java/com/coditory/quark/i18n/AggregatedI18nLoader.java +++ b/src/main/java/com/coditory/quark/i18n/AggregatedI18nLoader.java @@ -1,7 +1,7 @@ package com.coditory.quark.i18n; import com.coditory.quark.i18n.loader.I18nLoader; -import com.coditory.quark.i18n.loader.I18nTemplatesBundle; +import com.coditory.quark.i18n.loader.I18nMessageBundle; import com.coditory.quark.i18n.loader.WatchableI18nLoader; import org.jetbrains.annotations.NotNull; @@ -13,29 +13,29 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import static java.util.Objects.requireNonNull; +import static com.coditory.quark.i18n.Preconditions.expectNonNull; final class AggregatedI18nLoader implements WatchableI18nLoader { private final List loaders = new ArrayList<>(); private final Map currentEntries = new LinkedHashMap<>(); - private final ConcurrentHashMap> cachedResults = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> cachedResults = new ConcurrentHashMap<>(); private final Set listeners = new LinkedHashSet<>(); private boolean watching = false; public synchronized void addLoader(I18nLoader loader) { - requireNonNull(loader); + expectNonNull(loader, "loader"); appendCurrentEntries(); loaders.add(loader); } public synchronized void addMessage(I18nKey key, String value) { - requireNonNull(key); - requireNonNull(value); + expectNonNull(key, "key"); + expectNonNull(value, "value"); currentEntries.put(key, value); } public synchronized void addMessages(Map messages) { - requireNonNull(messages); + expectNonNull(messages, "messages"); currentEntries.putAll(messages); } @@ -48,6 +48,7 @@ public synchronized AggregatedI18nLoader copy() { @Override public synchronized void addChangeListener(I18nLoaderChangeListener listener) { + expectNonNull(listener, "listener"); listeners.add(listener); } @@ -60,14 +61,14 @@ public synchronized void startWatching() { for (I18nLoader loader : loaders) { if (loader instanceof WatchableI18nLoader watchableLoader) { watchableLoader.startWatching(); - watchableLoader.addChangeListener(entries -> onBundlesChange(loader, entries)); + watchableLoader.addChangeListener(bundles -> onBundlesChange(loader, bundles)); } } } - private synchronized void onBundlesChange(I18nLoader loader, List bundles) { + private synchronized void onBundlesChange(I18nLoader loader, List bundles) { cachedResults.put(loader, bundles); - List result = loaders.stream() + List result = loaders.stream() .map(l -> cachedResults.getOrDefault(l, List.of())) .reduce(new ArrayList<>(), (m, e) -> { m.addAll(e); @@ -92,7 +93,7 @@ public synchronized void stopWatching() { @Override @NotNull - public synchronized List load() { + public synchronized List load() { appendCurrentEntries(); return loaders.stream() .map(this::load) @@ -102,17 +103,17 @@ public synchronized List load() { }); } - private List load(I18nLoader loader) { - List entries = loader.load(); - cachedResults.put(loader, entries); - return entries; + private List load(I18nLoader loader) { + List bundles = loader.load(); + cachedResults.put(loader, bundles); + return bundles; } private void appendCurrentEntries() { if (!currentEntries.isEmpty()) { Map copy = new LinkedHashMap<>(currentEntries); - I18nTemplatesBundle templates = new I18nTemplatesBundle(copy); - List result = List.of(templates); + I18nMessageBundle templates = new I18nMessageBundle(copy); + List result = List.of(templates); I18nLoader loader = () -> result; loaders.add(loader); cachedResults.put(loader, result); diff --git a/src/main/java/com/coditory/quark/i18n/ArgumentIndexExtractor.java b/src/main/java/com/coditory/quark/i18n/ArgumentIndexExtractor.java new file mode 100644 index 0000000..172956b --- /dev/null +++ b/src/main/java/com/coditory/quark/i18n/ArgumentIndexExtractor.java @@ -0,0 +1,60 @@ +package com.coditory.quark.i18n; + +import java.util.HashSet; +import java.util.PrimitiveIterator; +import java.util.Set; + +import static com.coditory.quark.i18n.Preconditions.expectNonNull; +import static java.util.Collections.unmodifiableSet; + +final class ArgumentIndexExtractor { + static Set extractArgumentIndexes(String template) { + expectNonNull(template, "template"); + Set result = new HashSet<>(); + boolean escaped = false; + int stack = 0; + int prev = -1; + for (PrimitiveIterator.OfInt it = template.codePoints().iterator(); it.hasNext(); ) { + int c = it.next(); + if (c == '\'') { + escaped = true; + } else if (c == '{' && it.hasNext() && (!escaped || stack > 0 && prev == '\'')) { + if (prev == '\'') { + stack = 0; + escaped = false; + } + int number = extractIndex(it); + if (number >= 0) { + result.add(number); + } + } else if (escaped && c == '{') { + stack += 1; + } else if (escaped && c == '}') { + stack = Math.max(0, stack - 1); + if (stack == 0) { + escaped = false; + } + } else if (escaped && stack == 0) { + escaped = false; + } + prev = c; + } + return unmodifiableSet(result); + } + + private static int extractIndex(PrimitiveIterator.OfInt iterator) { + int c = iterator.next(); + while (Character.isWhitespace(c)) { + c = iterator.next(); + } + int number = 0; + while (Character.isDigit(c)) { + number = number * 10 + Character.digit(c, 10); + c = iterator.next(); + } + while (Character.isWhitespace(c)) { + c = iterator.next(); + } + return c == '}' || c == ',' ? number : -1; + } +} diff --git a/src/main/java/com/coditory/quark/i18n/ArgumentResolver.java b/src/main/java/com/coditory/quark/i18n/ArgumentResolver.java index f440840..5fb1003 100644 --- a/src/main/java/com/coditory/quark/i18n/ArgumentResolver.java +++ b/src/main/java/com/coditory/quark/i18n/ArgumentResolver.java @@ -3,25 +3,32 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import static com.coditory.quark.i18n.Preconditions.expectNonNull; import static java.util.stream.Collectors.toMap; final class ArgumentResolver { private final Map, I18nArgTransformer> transformers; static ArgumentResolver of(List> transformers) { + expectNonNull(transformers, "transformers"); Map, I18nArgTransformer> map = transformers.stream().collect(toMap(I18nArgTransformer::getArgType, it -> it)); return new ArgumentResolver(map); } private ArgumentResolver(Map, I18nArgTransformer> transformers) { + expectNonNull(transformers, "transformers"); this.transformers = Map.copyOf(transformers); } - Object[] resolveArguments(Object[] args) { + Object[] resolveArguments(Object[] args, Set usedIndexes) { + expectNonNull(args, "args"); + expectNonNull(usedIndexes, "usedIndexes"); boolean transformable = false; - for (Object arg : args) { - if (arg != null && transformers.containsKey(arg.getClass())) { + for (int i = 0; i < args.length; ++i) { + Object arg = args[i]; + if (arg != null && usedIndexes.contains(i) && transformers.containsKey(arg.getClass())) { transformable = true; break; } @@ -31,28 +38,42 @@ Object[] resolveArguments(Object[] args) { } Object[] result = new Object[args.length]; for (int i = 0; i < args.length; ++i) { - result[i] = transformArgument(args[i]); + Object arg = args[i]; + if (arg != null && usedIndexes.contains(i)) { + result[i] = transformArgument(args[i], i); + } } return result; } - Map resolveArguments(Map args) { - boolean transformable = args.values().stream() - .anyMatch(a -> a != null && transformers.containsKey(a.getClass())); - if (!transformable) { - return args; - } + Map resolveArguments(Map args, Set usedArgumentNames) { + expectNonNull(args, "args"); + expectNonNull(usedArgumentNames, "usedArgumentNames"); Map result = new HashMap<>(); for (Map.Entry entry : args.entrySet()) { String key = entry.getKey(); - Object value = transformArgument(entry.getValue()); + Object value = entry.getValue(); + if (value != null && usedArgumentNames.contains(key)) { + value = transformArgument(value, key); + } result.put(key, value); } return result; } + private Object transformArgument(Object argument, Object nameOrIndex) { + try { + return transformArgumentWithLimit(argument, 0); + } catch (Throwable e) { + throw new IllegalArgumentException("Could not transform argument: " + nameOrIndex + "=" + argument, e); + } + } + @SuppressWarnings("unchecked") - private Object transformArgument(Object argument) { + private Object transformArgumentWithLimit(Object argument, int count) { + if (count > 10) { + throw new IllegalArgumentException("Too many argument transformations"); + } if (argument == null) { return null; } @@ -60,10 +81,7 @@ private Object transformArgument(Object argument) { if (transformer == null) { return argument; } - try { - return transformer.transform(argument); - } catch (Throwable e) { - throw new IllegalArgumentException("Could not transform argument: " + argument, e); - } + Object transformed = transformer.transform(argument); + return transformArgumentWithLimit(transformed, count + 1); } } diff --git a/src/main/java/com/coditory/quark/i18n/Currencies.java b/src/main/java/com/coditory/quark/i18n/Currencies.java index b705bc5..364ba99 100644 --- a/src/main/java/com/coditory/quark/i18n/Currencies.java +++ b/src/main/java/com/coditory/quark/i18n/Currencies.java @@ -69,6 +69,8 @@ private Currencies() { @NotNull public static String formatByCurrency(@NotNull BigDecimal amount, @NotNull Currency currency) { + expectNonNull(amount, "amount"); + expectNonNull(currency, "currency"); Locale currencyLocale = LOCALE_BY_CURRENCY.get(currency); if (currencyLocale == null) { throw new IllegalArgumentException("Unrecognized currency: " + currency); @@ -78,6 +80,7 @@ public static String formatByCurrency(@NotNull BigDecimal amount, @NotNull Curre @NotNull public static String formatByCurrency(long amount, @NotNull Currency currency) { + expectNonNull(currency, "currency"); Locale currencyLocale = LOCALE_BY_CURRENCY.get(currency); if (currencyLocale == null) { throw new IllegalArgumentException("Missing locale for currency: " + currency); @@ -87,6 +90,7 @@ public static String formatByCurrency(long amount, @NotNull Currency currency) { @NotNull public static String formatByCurrency(double amount, @NotNull Currency currency) { + expectNonNull(currency, "currency"); Locale currencyLocale = LOCALE_BY_CURRENCY.get(currency); if (currencyLocale == null) { throw new IllegalArgumentException("Missing locale for currency: " + currency); diff --git a/src/main/java/com/coditory/quark/i18n/I18nArgTransformer.java b/src/main/java/com/coditory/quark/i18n/I18nArgTransformer.java index 5741b41..5026a8e 100644 --- a/src/main/java/com/coditory/quark/i18n/I18nArgTransformer.java +++ b/src/main/java/com/coditory/quark/i18n/I18nArgTransformer.java @@ -4,7 +4,7 @@ import java.util.function.Function; -import static java.util.Objects.requireNonNull; +import static com.coditory.quark.i18n.Preconditions.expectNonNull; interface I18nArgTransformer { static I18nArgTransformer of(Class type, Function transform) { @@ -17,13 +17,13 @@ static I18nArgTransformer of(Class type, Function transform Object transform(@NotNull T value); } -class SimpleI18nArgTransformer implements I18nArgTransformer { +final class SimpleI18nArgTransformer implements I18nArgTransformer { private final Class type; private final Function transform; public SimpleI18nArgTransformer(Class type, Function transform) { - this.type = requireNonNull(type); - this.transform = requireNonNull(transform); + this.type = expectNonNull(type, "type"); + this.transform = expectNonNull(transform, "transform"); } @Override @@ -32,7 +32,9 @@ public Class getArgType() { } @Override - public @NotNull Object transform(@NotNull T value) { + @NotNull + public Object transform(@NotNull T value) { + expectNonNull(value, "value"); return transform.apply(value); } } \ No newline at end of file diff --git a/src/main/java/com/coditory/quark/i18n/I18nKey.java b/src/main/java/com/coditory/quark/i18n/I18nKey.java index 115949b..3484ee4 100755 --- a/src/main/java/com/coditory/quark/i18n/I18nKey.java +++ b/src/main/java/com/coditory/quark/i18n/I18nKey.java @@ -80,7 +80,8 @@ public String pathValue() { } public String toShortString() { - return "" + locale + ':' + path; + String localeString = locale.toString().replaceAll("_", "-"); + return localeString + ':' + path; } @Override diff --git a/src/main/java/com/coditory/quark/i18n/I18nKeyGenerator.java b/src/main/java/com/coditory/quark/i18n/I18nKeyGenerator.java index b98b36e..50b5b7d 100644 --- a/src/main/java/com/coditory/quark/i18n/I18nKeyGenerator.java +++ b/src/main/java/com/coditory/quark/i18n/I18nKeyGenerator.java @@ -4,30 +4,38 @@ import java.util.List; import java.util.Locale; +import static com.coditory.quark.i18n.Preconditions.expectNonNull; + final class I18nKeyGenerator { private final List defaultLocales; private final List globalPrefixes; private final LocaleResolver localeResolver; public I18nKeyGenerator(Locale defaultLocale, List globalPrefixes, LocaleResolver localeResolver) { + expectNonNull(localeResolver, "localeResolver"); + expectNonNull(globalPrefixes, "globalPrefixes"); this.defaultLocales = defaultLocale != null ? localeResolver.getLocaleHierarchy(defaultLocale) : List.of(); - this.globalPrefixes = globalPrefixes; + this.globalPrefixes = List.copyOf(globalPrefixes); this.localeResolver = localeResolver; } List keys(I18nKey key) { + expectNonNull(key, "key"); return keys(key, List.of()); } List keys(I18nKey key, I18nPath prefix) { + expectNonNull(key, "key"); return prefix == null || prefix.isRoot() ? keys(key) : keys(key, List.of(prefix)); } List keys(I18nKey key, List prefixes) { + expectNonNull(key, "key"); + expectNonNull(prefixes, "prefixes"); List locales = localeResolver.getLocaleHierarchy(key.locale()); I18nPath path = key.path(); List keys = new ArrayList<>(6 * (1 + prefixes.size() + globalPrefixes.size())); diff --git a/src/main/java/com/coditory/quark/i18n/I18nMessagePack.java b/src/main/java/com/coditory/quark/i18n/I18nMessagePack.java index e674e2a..f3e5b0f 100755 --- a/src/main/java/com/coditory/quark/i18n/I18nMessagePack.java +++ b/src/main/java/com/coditory/quark/i18n/I18nMessagePack.java @@ -15,18 +15,6 @@ static I18nMessagePackBuilder builder() { return new I18nMessagePackBuilder(); } - @NotNull - I18nMessages localize(@NotNull Locale locale); - - @NotNull - default I18nMessages localize(@NotNull String localeTag) { - Locale locale = Locales.parseLocale(localeTag); - return localize(locale); - } - - @NotNull - I18nMessagePack withQueryPrefix(@NotNull String prefix); - @NotNull String getMessage(@NotNull I18nKey key, Object... args); @@ -105,6 +93,8 @@ default String getMessageOrNull(@NotNull Locale locale, @NotNull I18nPath path, @Nullable default String getMessageOrNull(@NotNull Locale locale, @NotNull String key) { + expectNonNull(locale, "locale"); + expectNonNull(key, "key"); return getMessageOrNull(locale, key, EMPTY_ARGS); } @@ -112,5 +102,23 @@ default String getMessageOrNull(@NotNull Locale locale, @NotNull String key) { String format(@NotNull Locale locale, @NotNull String template, Object... args); @NotNull - String format(@NotNull Locale locale, @NotNull String expression, @NotNull Map args); + String format(@NotNull Locale locale, @NotNull String template, @NotNull Map args); + + @NotNull + I18nMessages localize(@NotNull Locale locale); + + @NotNull + default I18nMessages localize(@NotNull String locale) { + expectNonNull(locale, "locale"); + return localize(Locales.parseLocale(locale)); + } + + @NotNull + I18nMessagePack prefixQueries(I18nPath prefix); + + @NotNull + default I18nMessagePack prefixQueries(@NotNull String prefix) { + expectNonNull(prefix, "prefix"); + return prefixQueries(I18nPath.of(prefix)); + } } diff --git a/src/main/java/com/coditory/quark/i18n/I18nMessagePackBuilder.java b/src/main/java/com/coditory/quark/i18n/I18nMessagePackBuilder.java index acca589..ebd7529 100755 --- a/src/main/java/com/coditory/quark/i18n/I18nMessagePackBuilder.java +++ b/src/main/java/com/coditory/quark/i18n/I18nMessagePackBuilder.java @@ -2,16 +2,17 @@ import com.coditory.quark.i18n.loader.I18nFileLoaderFactory; import com.coditory.quark.i18n.loader.I18nLoader; -import com.coditory.quark.i18n.loader.I18nTemplatesBundle; +import com.coditory.quark.i18n.loader.I18nMessageBundle; import org.jetbrains.annotations.NotNull; import java.nio.file.FileSystem; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.function.Function; import static com.coditory.quark.i18n.I18nArgTransformers.javaTimeI18nArgTransformers; @@ -23,19 +24,29 @@ public final class I18nMessagePackBuilder { private final List referenceFallbackPaths = new ArrayList<>(); private final List messageFallbackPaths = new ArrayList<>(); private final List> argTransformers = new ArrayList<>(); - private boolean transformJava8TimeTypes = true; - private boolean normalizeWhitespaces = true; - private I18nUnresolvedMessageHandler unresolvedMessageHandler = I18nUnresolvedMessageHandler.throwError(); + private I18nMissingMessageHandler missingMessageHandler = I18nMissingMessageHandler.errorThrowingHandler(); private Locale defaultLocale; + private boolean transformJava8TimeTypes = true; + private boolean normalizeWhitespaces = false; + private boolean resolveReferences = true; + private I18nMissingMessagesDetector missingMessagesDetector; + + I18nMessagePackBuilder() { + // package protected constructor + } private I18nMessagePackBuilder copy() { I18nMessagePackBuilder builder = new I18nMessagePackBuilder(); builder.loader.addLoader(loader.copy()); - builder.referenceFallbackPaths.clear(); builder.referenceFallbackPaths.addAll(referenceFallbackPaths); - builder.messageFallbackPaths.clear(); builder.messageFallbackPaths.addAll(messageFallbackPaths); - builder.unresolvedMessageHandler = unresolvedMessageHandler; + builder.argTransformers.addAll(argTransformers); + builder.missingMessageHandler = missingMessageHandler; + builder.defaultLocale = defaultLocale; + builder.transformJava8TimeTypes = transformJava8TimeTypes; + builder.normalizeWhitespaces = normalizeWhitespaces; + builder.resolveReferences = resolveReferences; + builder.missingMessagesDetector = missingMessagesDetector; return builder; } @@ -86,8 +97,44 @@ public I18nMessagePackBuilder disableJava8ArgumentTransformers() { } @NotNull - public I18nMessagePackBuilder disableWhiteSpaceNormalization() { - this.normalizeWhitespaces = false; + public I18nMessagePackBuilder normalizeWhitespaces() { + this.normalizeWhitespaces = true; + return this; + } + + @NotNull + public I18nMessagePackBuilder disableReferenceResolution() { + this.resolveReferences = false; + return this; + } + + @NotNull + public I18nMessagePackBuilder usePathOnMissingMessage() { + this.missingMessageHandler = I18nMissingMessageHandler.pathPrintingHandler(); + return this; + } + + @NotNull + public I18nMessagePackBuilder validateNoMissingMessages() { + return detectMissingMessages(I18nMissingMessagesDetector.builder() + .throwErrorOnMissingMessages() + .build()); + } + + @NotNull + public I18nMessagePackBuilder logMissingMessages() { + return detectMissingMessages(I18nMissingMessagesDetector.builder() + .logMissingMessages() + .build()); + } + + @NotNull + public I18nMessagePackBuilder detectMissingMessages(I18nMissingMessagesDetector missingMessagesDetector) { + expectNonNull(missingMessagesDetector, "missingMessagesDetector"); + if (this.missingMessagesDetector != null) { + throw new IllegalArgumentException("Missing messages detector was already defined"); + } + this.missingMessagesDetector = missingMessagesDetector; return this; } @@ -130,9 +177,16 @@ public I18nMessagePackBuilder addMessages(@NotNull Map messages } @NotNull - public I18nMessagePackBuilder setUnresolvedMessageHandler(@NotNull I18nUnresolvedMessageHandler unresolvedMessageHandler) { - expectNonNull(unresolvedMessageHandler, "unresolvedMessageHandler"); - this.unresolvedMessageHandler = unresolvedMessageHandler; + public I18nMessagePackBuilder addMessages(@NotNull I18nMessageBundle messageBundle) { + expectNonNull(messageBundle, "messageBundle"); + this.loader.addLoader(() -> List.of(messageBundle)); + return this; + } + + @NotNull + public I18nMessagePackBuilder setMissingMessageHandler(@NotNull I18nMissingMessageHandler missingMessageHandler) { + expectNonNull(missingMessageHandler, "missingMessageHandler"); + this.missingMessageHandler = missingMessageHandler; return this; } @@ -144,17 +198,15 @@ public I18nMessagePackBuilder setDefaultLocale(@NotNull Locale defaultLocale) { } @NotNull - public I18nMessagePackBuilder setReferenceFallbackKeyPrefixes(@NotNull List keyPrefixes) { + public I18nMessagePackBuilder addReferenceFallbackKeyPrefixes(@NotNull List keyPrefixes) { expectNonNull(keyPrefixes, "keyPrefixes"); - this.referenceFallbackPaths.clear(); keyPrefixes.forEach(this::addReferenceFallbackKeyPrefix); return this; } @NotNull - public I18nMessagePackBuilder setReferenceFallbackKeyPrefixes(@NotNull String... keyPrefixes) { + public I18nMessagePackBuilder addReferenceFallbackKeyPrefixes(@NotNull String... keyPrefixes) { expectNonNull(keyPrefixes, "keyPrefixes"); - this.referenceFallbackPaths.clear(); Arrays.stream(keyPrefixes).forEach(this::addReferenceFallbackKeyPrefix); return this; } @@ -168,17 +220,15 @@ public I18nMessagePackBuilder addReferenceFallbackKeyPrefix(@NotNull String keyP } @NotNull - public I18nMessagePackBuilder setMessageFallbackKeyPrefixes(@NotNull List keyPrefixes) { + public I18nMessagePackBuilder addMessageFallbackKeyPrefixes(@NotNull List keyPrefixes) { expectNonNull(keyPrefixes, "keyPrefixes"); - this.messageFallbackPaths.clear(); keyPrefixes.forEach(this::addMessageFallbackKeyPrefix); return this; } @NotNull - public I18nMessagePackBuilder setMessageFallbackKeyPrefixes(@NotNull String... keyPrefixes) { + public I18nMessagePackBuilder addMessageFallbackKeyPrefixes(@NotNull String... keyPrefixes) { expectNonNull(keyPrefixes, "keyPrefixes"); - this.messageFallbackPaths.clear(); Arrays.stream(keyPrefixes).forEach(this::addMessageFallbackKeyPrefix); return this; } @@ -192,18 +242,18 @@ public I18nMessagePackBuilder addMessageFallbackKeyPrefix(@NotNull String keyPre } @NotNull - public I18nMessagePackBuilder setFallbackKeyPrefixes(@NotNull List keyPrefixes) { + public I18nMessagePackBuilder addFallbackKeyPrefixes(@NotNull List keyPrefixes) { expectNonNull(keyPrefixes, "keyPrefixes"); - setMessageFallbackKeyPrefixes(keyPrefixes); - setReferenceFallbackKeyPrefixes(keyPrefixes); + addMessageFallbackKeyPrefixes(keyPrefixes); + addReferenceFallbackKeyPrefixes(keyPrefixes); return this; } @NotNull - public I18nMessagePackBuilder setFallbackKeyPrefixes(@NotNull String... keyPrefixes) { + public I18nMessagePackBuilder addFallbackKeyPrefixes(@NotNull String... keyPrefixes) { expectNonNull(keyPrefixes, "keyPrefixes"); - setMessageFallbackKeyPrefixes(keyPrefixes); - setReferenceFallbackKeyPrefixes(keyPrefixes); + addMessageFallbackKeyPrefixes(keyPrefixes); + addReferenceFallbackKeyPrefixes(keyPrefixes); return this; } @@ -217,10 +267,17 @@ public I18nMessagePackBuilder addFallbackKeyPrefix(@NotNull String keyPrefix) { @NotNull public I18nMessagePack build() { - List bundles = loader.load(); + List bundles = loader.load(); return build(bundles); } + @NotNull + public I18nMessages buildLocalized(Locale locale) { + expectNonNull(locale, "locale"); + I18nMessagePack pack = build(); + return pack.localize(locale); + } + @NotNull public Reloadable18nMessagePack buildReloadable() { I18nMessagePackBuilder copy = this.copy(); @@ -235,18 +292,19 @@ public Reloadable18nMessagePack buildAndWatchForChanges() { return messagePack; } - private I18nMessagePack build(List bundles) { + private I18nMessagePack build(List bundles) { bundles = TemplatesBundlePrefixes.prefix(bundles); + detectMissingMessages(bundles); LocaleResolver localeResolver = LocaleResolver.of(defaultLocale, bundles); I18nKeyGenerator messageKeyGenerator = new I18nKeyGenerator(defaultLocale, messageFallbackPaths, localeResolver); MessageTemplateParser parser = buildMessageTemplateParser(bundles, localeResolver); Map templates = parser.parseTemplates(bundles); - return new ImmutableI18nMessagePack(templates, parser, unresolvedMessageHandler, messageKeyGenerator); + return new ImmutableI18nMessagePack(templates, parser, missingMessageHandler, messageKeyGenerator); } - private MessageTemplateParser buildMessageTemplateParser(List bundles, LocaleResolver localeResolver) { + private MessageTemplateParser buildMessageTemplateParser(List bundles, LocaleResolver localeResolver) { I18nKeyGenerator referenceKeyGenerator = new I18nKeyGenerator(defaultLocale, referenceFallbackPaths, localeResolver); - ReferenceResolver referenceResolver = new ReferenceResolver(bundles, referenceKeyGenerator); + ReferenceResolver referenceResolver = new ReferenceResolver(bundles, referenceKeyGenerator, resolveReferences); ArgumentResolver argumentResolver = buildArgumentResolver(); MessageTemplateNormalizer messageTemplateNormalizer = new MessageTemplateNormalizer(normalizeWhitespaces); return new MessageTemplateParser(referenceResolver, argumentResolver, messageTemplateNormalizer); @@ -260,4 +318,15 @@ private ArgumentResolver buildArgumentResolver() { result.addAll(argTransformers); return ArgumentResolver.of(result); } + + private void detectMissingMessages(List bundles) { + if (missingMessagesDetector == null) { + return; + } + Set keys = new HashSet<>(); + for (I18nMessageBundle bundle : bundles) { + keys.addAll(bundle.templates().keySet()); + } + missingMessagesDetector.detect(keys); + } } diff --git a/src/main/java/com/coditory/quark/i18n/I18nMessages.java b/src/main/java/com/coditory/quark/i18n/I18nMessages.java index 3f5e85e..5df5364 100755 --- a/src/main/java/com/coditory/quark/i18n/I18nMessages.java +++ b/src/main/java/com/coditory/quark/i18n/I18nMessages.java @@ -53,7 +53,7 @@ public String getMessage(@NotNull String key) { @NotNull public I18nMessages addPrefix(@NotNull String prefix) { - return messagePack.withQueryPrefix(prefix).localize(locale); + return messagePack.prefixQueries(prefix).localize(locale); } @NotNull diff --git a/src/main/java/com/coditory/quark/i18n/I18nMissingMessageHandler.java b/src/main/java/com/coditory/quark/i18n/I18nMissingMessageHandler.java new file mode 100755 index 0000000..612a9a7 --- /dev/null +++ b/src/main/java/com/coditory/quark/i18n/I18nMissingMessageHandler.java @@ -0,0 +1,59 @@ +package com.coditory.quark.i18n; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.Map; + +import static com.coditory.quark.i18n.Preconditions.expectNonNull; +import static java.lang.String.format; + +public interface I18nMissingMessageHandler { + @NotNull + static I18nMissingMessageHandler errorThrowingHandler() { + return new ErrorThrowingI18NMissingMessageHandler(); + } + + @NotNull + static I18nMissingMessageHandler pathPrintingHandler() { + return new PathPrintingI18NMissingMessageHandler(); + } + + @NotNull + String onUnresolvedMessage(@NotNull I18nKey key, @NotNull Object... args); + + @NotNull + String onUnresolvedMessageWithNamedArguments(@NotNull I18nKey key, @NotNull Map args); +} + +final class PathPrintingI18NMissingMessageHandler implements I18nMissingMessageHandler { + @Override + public @NotNull String onUnresolvedMessage(@NotNull I18nKey key, @NotNull Object... args) { + return key.path().getValue(); + } + + @Override + public @NotNull String onUnresolvedMessageWithNamedArguments(@NotNull I18nKey key, @NotNull Map args) { + return key.path().getValue(); + } +} + +final class ErrorThrowingI18NMissingMessageHandler implements I18nMissingMessageHandler { + @Override + public @NotNull String onUnresolvedMessage(@NotNull I18nKey key, @NotNull Object... args) { + expectNonNull(key, "key"); + expectNonNull(args, "args"); + String argsString = args.length == 0 ? "" : Arrays.toString(args); + String argsStringInParenthesis = argsString.isEmpty() ? "" : "(" + argsString.substring(1, argsString.length() - 1) + ')'; + throw new I18nMessagesException(format("Missing message %s%s", key.toShortString(), argsStringInParenthesis)); + } + + @Override + public @NotNull String onUnresolvedMessageWithNamedArguments(@NotNull I18nKey key, @NotNull Map args) { + expectNonNull(key, "key"); + expectNonNull(args, "args"); + String argsString = args.toString(); + String argsStringInParenthesis = argsString.isEmpty() ? "" : "(" + argsString.substring(1, argsString.length() - 1) + ')'; + throw new I18nMessagesException(format("Missing message %s%s", key.toShortString(), argsStringInParenthesis)); + } +} \ No newline at end of file diff --git a/src/main/java/com/coditory/quark/i18n/I18nMissingMessagesDetector.java b/src/main/java/com/coditory/quark/i18n/I18nMissingMessagesDetector.java new file mode 100644 index 0000000..a529ef9 --- /dev/null +++ b/src/main/java/com/coditory/quark/i18n/I18nMissingMessagesDetector.java @@ -0,0 +1,170 @@ +package com.coditory.quark.i18n; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +import static com.coditory.quark.i18n.Locales.isSubLocale; +import static com.coditory.quark.i18n.Preconditions.expectNonBlank; +import static com.coditory.quark.i18n.Preconditions.expectNonNull; +import static java.util.stream.Collectors.toSet; + +final public class I18nMissingMessagesDetector { + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + private final Set pathsToSkip; + private final boolean logMissingMessages; + private final Function, RuntimeException> errorCreator; + + private I18nMissingMessagesDetector(boolean logMissingMessages, Function, RuntimeException> errorCreator, Set pathsToSkip) { + this.logMissingMessages = logMissingMessages; + this.errorCreator = errorCreator; + this.pathsToSkip = Set.copyOf(pathsToSkip); + } + + void detect(Set keys) { + List missingMessages = findMissingMessages(keys); + if (missingMessages.isEmpty()) { + return; + } + if (logMissingMessages) { + String report = toReport(missingMessages); + logger.warn(report); + } + if (errorCreator != null) { + throw errorCreator.apply(missingMessages); + } + } + + private String toReport(List missingMessages) { + StringBuilder report = new StringBuilder(); + report.append("\nMissing Messages"); + report.append("\n================"); + for (MissingMessages missing : missingMessages) { + report.append("\n Path: ").append(missing.path.getValue()); + report.append("\nMissing: ").append(toString(missing.missingLanguages, 10)); + report.append("\nSources: ").append(toString(missing.sourceLocales, 3)); + report.append("\n"); + } + report.append("\nTotal: ").append(missingMessages.size()); + return report.toString(); + } + + private List findMissingMessages(Set keys) { + List skipPatterns = pathsToSkip.stream() + .map(s -> s.replaceAll("\\.", "\\\\.").replaceAll("\\*\\*", ".+").replaceAll("\\*", "[^.]+")) + .map(Pattern::compile) + .toList(); + Map> sourceLocalesByPath = new HashMap<>(); + Set allLocales = new HashSet<>(); + Set skippedPaths = new HashSet<>(); + for (I18nKey key : keys) { + boolean skipped = skippedPaths.contains(key.path()) + || skipPatterns.stream().anyMatch(s -> s.matcher(key.pathValue()).matches()); + if (skipped) { + skippedPaths.add(key.path()); + continue; + } + allLocales.add(key.locale()); + sourceLocalesByPath.compute(key.path(), (path, value) -> { + Set locales = value == null ? new HashSet<>() : value; + locales.add(key.locale()); + return locales; + }); + } + List result = new ArrayList<>(); + for (I18nPath path : sourceLocalesByPath.keySet()) { + Set missingLocales = new HashSet<>(allLocales); + Set sourceLocales = sourceLocalesByPath.get(path); + for (Locale sourceLocale : sourceLocales) { + missingLocales = missingLocales.stream() + .filter(missing -> !isSubLocale(sourceLocale, missing)) + .collect(toSet()); + } + if (!missingLocales.isEmpty()) { + result.add(new MissingMessages(path, sourceLocales, missingLocales)); + } + } + result.sort(Comparator.comparing(it -> it.path.getValue())); + return result; + } + + private String toString(Set set, int limit) { + if (set.isEmpty()) { + return ""; + } + List items = set.stream() + .map(Object::toString) + .sorted() + .limit(limit) + .toList(); + String result = items.toString(); + result = result.substring(1, result.length() - 1); + if (set.size() > items.size()) { + result += "... (total: " + set.size() + ")"; + } + return result; + } + + public static I18nMissingMessagesDetectorBuilder builder() { + return new I18nMissingMessagesDetectorBuilder(); + } + + public static final class I18nMissingMessagesDetectorBuilder { + private final Set pathsToSkip = new HashSet<>(); + private boolean logMissingMessages = false; + private Function, RuntimeException> errorCreator; + + public I18nMissingMessagesDetectorBuilder logMissingMessages() { + this.logMissingMessages = true; + return this; + } + + public I18nMissingMessagesDetectorBuilder throwErrorOnMissingMessages() { + return throwErrorOnMissingMessages((missing) -> new IllegalStateException("Detected missing messages: " + missing.size())); + } + + public I18nMissingMessagesDetectorBuilder throwErrorOnMissingMessages(Function, RuntimeException> errorCreator) { + expectNonNull(errorCreator, "errorCreator"); + this.errorCreator = errorCreator; + return this; + } + + public I18nMissingMessagesDetectorBuilder skipPath(String pathPattern) { + expectNonBlank(pathPattern, "pathPattern"); + pathsToSkip.add(pathPattern); + return this; + } + + public I18nMissingMessagesDetectorBuilder skipPaths(String pathPattern, String... others) { + skipPath(pathPattern); + for (String path : others) { + skipPath(path); + } + return this; + } + + public I18nMissingMessagesDetectorBuilder skipPaths(Collection pathPatterns) { + expectNonNull(pathPatterns, "pathPatterns"); + pathPatterns.forEach(this::skipPath); + return this; + } + + public I18nMissingMessagesDetector build() { + return new I18nMissingMessagesDetector(logMissingMessages, errorCreator, pathsToSkip); + } + } + + private record MissingMessages(I18nPath path, Set sourceLocales, Set missingLanguages) { + } +} diff --git a/src/main/java/com/coditory/quark/i18n/I18nSystemDefaults.java b/src/main/java/com/coditory/quark/i18n/I18nSystemDefaults.java index 82a7809..cf92c56 100644 --- a/src/main/java/com/coditory/quark/i18n/I18nSystemDefaults.java +++ b/src/main/java/com/coditory/quark/i18n/I18nSystemDefaults.java @@ -5,10 +5,10 @@ public class I18nSystemDefaults { private I18nSystemDefaults() { - throw new RuntimeException("Utility class constructor"); + throw new UnsupportedOperationException("Do not instantiate utility class"); } - public static void setupNormalized() { + public static void setupGmtAndEnUsAsDefaults() { System.setProperty("user.timezone", "GMT"); TimeZone.setDefault(TimeZone.getTimeZone("GMT")); System.setProperty("user.language", "en"); diff --git a/src/main/java/com/coditory/quark/i18n/I18nUnresolvedMessageHandler.java b/src/main/java/com/coditory/quark/i18n/I18nUnresolvedMessageHandler.java deleted file mode 100755 index 133c7a4..0000000 --- a/src/main/java/com/coditory/quark/i18n/I18nUnresolvedMessageHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.coditory.quark.i18n; - -import org.jetbrains.annotations.NotNull; - -import java.util.Arrays; - -import static java.lang.String.format; - -@FunctionalInterface -public interface I18nUnresolvedMessageHandler { - @NotNull - static I18nUnresolvedMessageHandler throwError() { - return (key, args) -> { - String argsString = args == null || args.length == 0 ? "" : Arrays.toString(args); - String argsStringInParenthesis = argsString.isEmpty() ? "" : "(" + argsString.substring(1, argsString.length() - 1) + ')'; - throw new I18nMessagesException(format("Missing message %s%s", key.toShortString(), argsStringInParenthesis)); - }; - } - - @NotNull - static I18nUnresolvedMessageHandler generateErrorMessage() { - return (key, args) -> key.pathValue(); - } - - @NotNull - String onUnresolvedMessage(@NotNull I18nKey key, @NotNull Object... args); -} diff --git a/src/main/java/com/coditory/quark/i18n/ImmutableI18nMessagePack.java b/src/main/java/com/coditory/quark/i18n/ImmutableI18nMessagePack.java index affd697..05261fe 100755 --- a/src/main/java/com/coditory/quark/i18n/ImmutableI18nMessagePack.java +++ b/src/main/java/com/coditory/quark/i18n/ImmutableI18nMessagePack.java @@ -3,9 +3,9 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Arrays; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.Optional; import static com.coditory.quark.i18n.Preconditions.expectNonNull; @@ -13,14 +13,14 @@ final class ImmutableI18nMessagePack implements I18nMessagePack { private final Map templates; private final MessageTemplateParser parser; - private final I18nUnresolvedMessageHandler unresolvedMessageHandler; + private final I18nMissingMessageHandler unresolvedMessageHandler; private final I18nPath queryPrefix; private final I18nKeyGenerator keyGenerator; ImmutableI18nMessagePack( Map templates, MessageTemplateParser parser, - I18nUnresolvedMessageHandler unresolvedMessageHandler, + I18nMissingMessageHandler unresolvedMessageHandler, I18nKeyGenerator keyGenerator ) { this(templates, parser, unresolvedMessageHandler, keyGenerator, null); @@ -29,7 +29,7 @@ final class ImmutableI18nMessagePack implements I18nMessagePack { private ImmutableI18nMessagePack( Map templates, MessageTemplateParser parser, - I18nUnresolvedMessageHandler unresolvedMessageHandler, + I18nMissingMessageHandler unresolvedMessageHandler, I18nKeyGenerator keyGenerator, I18nPath queryPrefix ) { @@ -66,7 +66,7 @@ public String getMessage(@NotNull I18nKey key, @NotNull Map args expectNonNull(args, "args"); String result = getMessageOrNull(key, args); return result == null - ? unresolvedMessageHandler.onUnresolvedMessage(key, args) + ? unresolvedMessageHandler.onUnresolvedMessageWithNamedArguments(key, args) : result; } @@ -90,39 +90,70 @@ public String getMessageOrNull(@NotNull I18nKey key, @NotNull Map getTemplate(I18nKey key) { + private Optional getTemplate(I18nKey key) { return keyGenerator.keys(key, queryPrefix).stream() - .map(templates::get) - .filter(Objects::nonNull) + .filter(templates::containsKey) + .map(matched -> new MessageTemplateWithKey(matched, templates.get(matched))) .findFirst(); } @NotNull @Override - public String format(@NotNull Locale locale, @NotNull String expression, Object... args) { + public String format(@NotNull Locale locale, @NotNull String template, Object... args) { expectNonNull(locale, "locale"); - expectNonNull(expression, "expression"); + expectNonNull(template, "template"); expectNonNull(args, "args"); - Object value = parser.parseTemplate(locale, expression) - .resolve(locale, args); - return Objects.toString(value); + try { + return parser.parseTemplate(locale, template) + .resolve(locale, args); + } catch (Throwable e) { + throw new IllegalArgumentException("Could not format message " + template + + "\" with indexed arguments " + Arrays.toString(args) + " and locale: " + locale); + } } @NotNull @Override - public String format(@NotNull Locale locale, @NotNull String expression, @NotNull Map args) { + public String format(@NotNull Locale locale, @NotNull String template, @NotNull Map args) { expectNonNull(locale, "locale"); - expectNonNull(expression, "expression"); + expectNonNull(template, "template"); expectNonNull(args, "args"); - Object value = parser.parseTemplate(locale, expression) - .resolve(locale, args); - return Objects.toString(value); + try { + return parser.parseTemplate(locale, template) + .resolve(locale, args); + } catch (Throwable e) { + throw new IllegalArgumentException("Could not format message " + + template + + "\" with named arguments " + args + " and locale: " + locale); + } } @Override @NotNull - public I18nMessagePack withQueryPrefix(@NotNull String prefix) { - I18nPath path = I18nPath.of(prefix); - return new ImmutableI18nMessagePack(templates, parser, unresolvedMessageHandler, keyGenerator, path); + public I18nMessagePack prefixQueries(@NotNull I18nPath prefix) { + expectNonNull(prefix, "prefix"); + return new ImmutableI18nMessagePack(templates, parser, unresolvedMessageHandler, keyGenerator, prefix); + } + + private record MessageTemplateWithKey(I18nKey key, MessageTemplate template) { + String resolve(Locale locale, @NotNull Map args) { + try { + return template.resolve(locale, args); + } catch (Throwable e) { + throw new IllegalArgumentException("Could not resolve message " + + key.toShortString() + "=\"" + template.getValue() + + "\" with named arguments " + args + " and locale: " + locale, e); + } + } + + String resolve(Locale locale, @NotNull Object[] args) { + try { + return template.resolve(locale, args); + } catch (Throwable e) { + throw new IllegalArgumentException("Could not resolve message " + + key.toShortString() + "=\"" + template.getValue() + + "\" with indexed arguments " + Arrays.toString(args) + " and locale: " + locale, e); + } + } } } diff --git a/src/main/java/com/coditory/quark/i18n/LocaleResolver.java b/src/main/java/com/coditory/quark/i18n/LocaleResolver.java index 9c688aa..abc91f7 100644 --- a/src/main/java/com/coditory/quark/i18n/LocaleResolver.java +++ b/src/main/java/com/coditory/quark/i18n/LocaleResolver.java @@ -1,6 +1,6 @@ package com.coditory.quark.i18n; -import com.coditory.quark.i18n.loader.I18nTemplatesBundle; +import com.coditory.quark.i18n.loader.I18nMessageBundle; import java.util.ArrayList; import java.util.Collections; @@ -13,7 +13,8 @@ import static java.util.stream.Collectors.toSet; final class LocaleResolver { - static LocaleResolver of(Locale defaultLocale, List templates) { + static LocaleResolver of(Locale defaultLocale, List templates) { + expectNonNull(templates, "templates"); Set availableLocales = templates.stream() .flatMap(t -> t.templates().keySet().stream()) .map(I18nKey::locale) diff --git a/src/main/java/com/coditory/quark/i18n/Locales.java b/src/main/java/com/coditory/quark/i18n/Locales.java index d3a86fc..4aeb311 100644 --- a/src/main/java/com/coditory/quark/i18n/Locales.java +++ b/src/main/java/com/coditory/quark/i18n/Locales.java @@ -4,6 +4,7 @@ import org.jetbrains.annotations.Nullable; import java.util.Locale; +import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -146,4 +147,20 @@ public static Locale parseLocaleOrDefault(@NotNull String value, @NotNull Locale public static Optional parseLocaleOrEmpty(@NotNull String value) { return Optional.ofNullable(parseLocaleOrNull(value)); } + + public static boolean isSubLocale(@NotNull Locale parent, @NotNull Locale child) { + expectNonNull(parent, "parent"); + expectNonNull(child, "child"); + if (!Objects.equals(parent.getLanguage(), child.getLanguage())) { + return false; + } + if (!isNullOrEmpty(parent.getCountry()) && !Objects.equals(parent.getCountry(), child.getCountry())) { + return false; + } + return isNullOrEmpty(parent.getVariant()) || Objects.equals(parent.getVariant(), child.getVariant()); + } + + private static boolean isNullOrEmpty(String text) { + return text == null || text.isEmpty(); + } } diff --git a/src/main/java/com/coditory/quark/i18n/MessageTemplate.java b/src/main/java/com/coditory/quark/i18n/MessageTemplate.java index fe694a7..808e9a1 100755 --- a/src/main/java/com/coditory/quark/i18n/MessageTemplate.java +++ b/src/main/java/com/coditory/quark/i18n/MessageTemplate.java @@ -5,12 +5,16 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import static com.coditory.quark.i18n.ArgumentIndexExtractor.extractArgumentIndexes; import static com.coditory.quark.i18n.Preconditions.expectNonNull; final class MessageTemplate { static MessageTemplate parse(String template, ArgumentResolver argumentResolver) { + expectNonNull(template, "template"); + expectNonNull(argumentResolver, "argumentResolver"); try { MessageFormat messageFormat = new MessageFormat(template); return new MessageTemplate(template, messageFormat, argumentResolver); @@ -24,19 +28,27 @@ static MessageTemplate parse(String template, ArgumentResolver argumentResolver) private final String template; private final MessageFormat messageFormat; private final boolean dynamic; + private final Set usedArgumentNames; + private final Set usedArgumentIndexes; private MessageTemplate(String template, MessageFormat messageFormat, ArgumentResolver argumentResolver) { this.template = expectNonNull(template, "template"); this.messageFormat = expectNonNull(messageFormat, "messageFormat"); - this.dynamic = messageFormat.getFormats().length > 0; this.argumentResolver = expectNonNull(argumentResolver, "argumentResolver"); + this.usedArgumentNames = messageFormat.usesNamedArguments() + ? Set.copyOf(messageFormat.getArgumentNames()) + : Set.of(); + this.usedArgumentIndexes = messageFormat.usesNamedArguments() + ? Set.of() + : extractArgumentIndexes(template); + this.dynamic = !usedArgumentNames.isEmpty() || !usedArgumentIndexes.isEmpty(); } public String resolve(Locale locale, Object[] args) { expectNonNull(locale, "locale"); expectNonNull(args, "args"); MessageFormat messageFormat = getMessageFormat(locale); - Object[] resolvedArgs = argumentResolver.resolveArguments(args); + Object[] resolvedArgs = argumentResolver.resolveArguments(args, usedArgumentIndexes); return messageFormat.format(resolvedArgs); } @@ -44,7 +56,7 @@ public String resolve(Locale locale, Map args) { expectNonNull(locale, "locale"); expectNonNull(args, "args"); MessageFormat messageFormat = getMessageFormat(locale); - Map resolvedArgs = argumentResolver.resolveArguments(args); + Map resolvedArgs = argumentResolver.resolveArguments(args, usedArgumentNames); return messageFormat.format(resolvedArgs); } @@ -60,6 +72,10 @@ private MessageFormat createMessageFormat(Locale locale) { return copy; } + public String getValue() { + return template; + } + @Override public String toString() { return "MessageTemplate{" + template + '}'; diff --git a/src/main/java/com/coditory/quark/i18n/MessageTemplateNormalizer.java b/src/main/java/com/coditory/quark/i18n/MessageTemplateNormalizer.java index ac8ca04..dc6b040 100644 --- a/src/main/java/com/coditory/quark/i18n/MessageTemplateNormalizer.java +++ b/src/main/java/com/coditory/quark/i18n/MessageTemplateNormalizer.java @@ -1,5 +1,7 @@ package com.coditory.quark.i18n; +import static com.coditory.quark.i18n.Preconditions.expectNonNull; + final class MessageTemplateNormalizer { private final boolean normalizeWhiteSpaces; @@ -8,6 +10,7 @@ final class MessageTemplateNormalizer { } String normalize(String template) { + expectNonNull(template, "template"); if (!normalizeWhiteSpaces) { return template; } diff --git a/src/main/java/com/coditory/quark/i18n/MessageTemplateParser.java b/src/main/java/com/coditory/quark/i18n/MessageTemplateParser.java index 220d55e..e94283c 100644 --- a/src/main/java/com/coditory/quark/i18n/MessageTemplateParser.java +++ b/src/main/java/com/coditory/quark/i18n/MessageTemplateParser.java @@ -1,6 +1,6 @@ package com.coditory.quark.i18n; -import com.coditory.quark.i18n.loader.I18nTemplatesBundle; +import com.coditory.quark.i18n.loader.I18nMessageBundle; import java.util.HashMap; import java.util.List; @@ -20,9 +20,10 @@ public MessageTemplateParser(ReferenceResolver referenceResolver, ArgumentResolv this.messageTemplateNormalizer = expectNonNull(messageTemplateNormalizer, "messageTemplateNormalizer"); } - Map parseTemplates(List bundles) { + Map parseTemplates(List bundles) { + expectNonNull(bundles, "bundles"); Map result = new HashMap<>(); - for (I18nTemplatesBundle bundle : bundles) { + for (I18nMessageBundle bundle : bundles) { for (Map.Entry entry : bundle.templates().entrySet()) { I18nKey key = entry.getKey(); String value = entry.getValue(); @@ -34,12 +35,16 @@ Map parseTemplates(List bundles) } MessageTemplate parseTemplate(I18nKey key, String template) { + expectNonNull(key, "key"); + expectNonNull(template, "template"); template = messageTemplateNormalizer.normalize(template); template = referenceResolver.resolveReferences(key, template); return MessageTemplate.parse(template, argumentResolver); } MessageTemplate parseTemplate(Locale locale, String template) { + expectNonNull(locale, "locale"); + expectNonNull(template, "template"); template = messageTemplateNormalizer.normalize(template); template = referenceResolver.resolveReferences(locale, template); return MessageTemplate.parse(template, argumentResolver); diff --git a/src/main/java/com/coditory/quark/i18n/Money.java b/src/main/java/com/coditory/quark/i18n/Money.java index 1cc25bc..0acfe90 100644 --- a/src/main/java/com/coditory/quark/i18n/Money.java +++ b/src/main/java/com/coditory/quark/i18n/Money.java @@ -8,6 +8,11 @@ import static com.coditory.quark.i18n.Preconditions.expectNonNull; public record Money(@NotNull BigDecimal amount, @NotNull Currency currency) { + public Money(BigDecimal amount, Currency currency) { + this.amount = expectNonNull(amount, "amount"); + this.currency = expectNonNull(currency, "currency"); + } + @NotNull static Money of(double amount, @NotNull Currency currency) { return new Money(BigDecimal.valueOf(amount), currency); @@ -25,6 +30,7 @@ static Money of(@NotNull String amount, @NotNull Currency currency) { @NotNull public Money add(@NotNull Money other) { + expectNonNull(other, "other"); expectSameCurrencies(other); BigDecimal result = amount.add(other.amount); return new Money(result, currency); @@ -32,6 +38,7 @@ public Money add(@NotNull Money other) { @NotNull public Money subtract(@NotNull Money other) { + expectNonNull(other, "other"); expectSameCurrencies(other); BigDecimal result = amount.subtract(other.amount); return new Money(result, currency); @@ -39,6 +46,7 @@ public Money subtract(@NotNull Money other) { @NotNull public Money multiply(@NotNull Money other) { + expectNonNull(other, "other"); expectSameCurrencies(other); BigDecimal result = amount.multiply(other.amount); return new Money(result, currency); @@ -46,6 +54,7 @@ public Money multiply(@NotNull Money other) { @NotNull public Money divide(@NotNull Money other) { + expectNonNull(other, "other"); expectSameCurrencies(other); BigDecimal result = amount.divide(other.amount); return new Money(result, currency); diff --git a/src/main/java/com/coditory/quark/i18n/ReferenceResolver.java b/src/main/java/com/coditory/quark/i18n/ReferenceResolver.java index 5286e4b..ad76cac 100644 --- a/src/main/java/com/coditory/quark/i18n/ReferenceResolver.java +++ b/src/main/java/com/coditory/quark/i18n/ReferenceResolver.java @@ -1,6 +1,6 @@ package com.coditory.quark.i18n; -import com.coditory.quark.i18n.loader.I18nTemplatesBundle; +import com.coditory.quark.i18n.loader.I18nMessageBundle; import java.util.HashMap; import java.util.List; @@ -13,32 +13,43 @@ import static java.util.Collections.unmodifiableMap; final class ReferenceResolver { - private final Map bundles; + private final Map bundles; private final Map templates; private final I18nKeyGenerator keyGenerator; + private final boolean resolveReferences; - ReferenceResolver(List entries, I18nKeyGenerator keyGenerator) { + ReferenceResolver(List bundles, I18nKeyGenerator keyGenerator, boolean resolveReferences) { + expectNonNull(bundles, "bundles"); + expectNonNull(keyGenerator, "keyGenerator"); Map templates = new HashMap<>(); - Map bundles = new HashMap<>(); - for (I18nTemplatesBundle templateEntry : entries) { + Map bundlesByKey = new HashMap<>(); + for (I18nMessageBundle templateEntry : bundles) { templates.putAll(templateEntry.templates()); templateEntry.templates().keySet() - .forEach(key -> bundles.put(key, templateEntry)); + .forEach(key -> bundlesByKey.put(key, templateEntry)); } this.keyGenerator = expectNonNull(keyGenerator, "keyGenerator"); this.templates = unmodifiableMap(templates); - this.bundles = unmodifiableMap(bundles); + this.bundles = unmodifiableMap(bundlesByKey); + this.resolveReferences = resolveReferences; } String resolveReferences(I18nKey key, String template) { + expectNonNull(key, "key"); + expectNonNull(template, "template"); return resolveReferences(key.locale(), key.path(), template, 0); } String resolveReferences(Locale locale, String template) { + expectNonNull(locale, "locale"); + expectNonNull(template, "template"); return resolveReferences(locale, null, template, 0); } private String resolveReferences(Locale locale, I18nPath path, String template, int iteration) { + if (!resolveReferences) { + return template; + } if (!template.contains("$")) { return template; } @@ -110,7 +121,7 @@ private boolean isReferenceChar(int codePoint) { private String resolveReference(Locale locale, I18nPath sourcePath, I18nPath referencePath) { I18nKey referenceKey = I18nKey.of(locale, referencePath); - I18nTemplatesBundle bundle = sourcePath != null + I18nMessageBundle bundle = sourcePath != null ? bundles.get(I18nKey.of(locale, sourcePath)) : null; List keys = bundle != null && bundle.prefix() != null @@ -120,6 +131,6 @@ private String resolveReference(Locale locale, I18nPath sourcePath, I18nPath ref .map(templates::get) .filter(Objects::nonNull) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Reference not found: " + referencePath)); + .orElseThrow(() -> new I18nMessagesException("Missing reference: " + referencePath)); } } diff --git a/src/main/java/com/coditory/quark/i18n/Reloadable18nMessagePack.java b/src/main/java/com/coditory/quark/i18n/Reloadable18nMessagePack.java index f1692f6..d3ee6ad 100755 --- a/src/main/java/com/coditory/quark/i18n/Reloadable18nMessagePack.java +++ b/src/main/java/com/coditory/quark/i18n/Reloadable18nMessagePack.java @@ -1,6 +1,6 @@ package com.coditory.quark.i18n; -import com.coditory.quark.i18n.loader.I18nTemplatesBundle; +import com.coditory.quark.i18n.loader.I18nMessageBundle; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,11 +12,11 @@ import static com.coditory.quark.i18n.Preconditions.expectNonNull; public final class Reloadable18nMessagePack implements I18nMessagePack { - private final Function, I18nMessagePack> i18nMessagePackCreator; + private final Function, I18nMessagePack> i18nMessagePackCreator; private final AggregatedI18nLoader loader; private volatile I18nMessagePack i18nMessagePack; - Reloadable18nMessagePack(AggregatedI18nLoader loader, Function, I18nMessagePack> i18nMessagePackCreator) { + Reloadable18nMessagePack(AggregatedI18nLoader loader, Function, I18nMessagePack> i18nMessagePackCreator) { expectNonNull(i18nMessagePackCreator, "i18nMessagePackCreator"); expectNonNull(loader, "loader"); this.i18nMessagePackCreator = i18nMessagePackCreator; @@ -28,7 +28,7 @@ public void reload() { reload(loader.load()); } - private void reload(List bundles) { + private void reload(List bundles) { this.i18nMessagePack = i18nMessagePackCreator.apply(bundles); } @@ -46,8 +46,8 @@ public synchronized void stopWatching() { } @Override - public @NotNull I18nMessagePack withQueryPrefix(@NotNull String prefix) { - return i18nMessagePack.withQueryPrefix(prefix); + public @NotNull I18nMessagePack prefixQueries(I18nPath prefix) { + return i18nMessagePack.prefixQueries(prefix); } @Override @@ -85,7 +85,7 @@ public String format(@NotNull Locale locale, @NotNull String template, Object... @Override @NotNull - public String format(@NotNull Locale locale, @NotNull String expression, @NotNull Map args) { - return i18nMessagePack.format(locale, expression, args); + public String format(@NotNull Locale locale, @NotNull String template, @NotNull Map args) { + return i18nMessagePack.format(locale, template, args); } } diff --git a/src/main/java/com/coditory/quark/i18n/TemplatesBundlePrefixes.java b/src/main/java/com/coditory/quark/i18n/TemplatesBundlePrefixes.java index b535e0d..2e2d524 100644 --- a/src/main/java/com/coditory/quark/i18n/TemplatesBundlePrefixes.java +++ b/src/main/java/com/coditory/quark/i18n/TemplatesBundlePrefixes.java @@ -1,19 +1,22 @@ package com.coditory.quark.i18n; -import com.coditory.quark.i18n.loader.I18nTemplatesBundle; +import com.coditory.quark.i18n.loader.I18nMessageBundle; import java.util.HashMap; import java.util.List; import java.util.Map; +import static com.coditory.quark.i18n.Preconditions.expectNonNull; + final class TemplatesBundlePrefixes { - static List prefix(List bundles) { + static List prefix(List bundles) { + expectNonNull(bundles, "bundles"); return bundles.stream() .map(TemplatesBundlePrefixes::prefix) .toList(); } - static private I18nTemplatesBundle prefix(I18nTemplatesBundle bundle) { + static private I18nMessageBundle prefix(I18nMessageBundle bundle) { I18nPath prefix = bundle.prefix(); if (prefix == null || prefix.isRoot()) { return bundle; @@ -23,6 +26,6 @@ static private I18nTemplatesBundle prefix(I18nTemplatesBundle bundle) { I18nKey prefixed = entry.getKey().prefixPath(prefix); mapped.put(prefixed, entry.getValue()); } - return new I18nTemplatesBundle(mapped, prefix); + return new I18nMessageBundle(mapped, prefix); } } diff --git a/src/main/java/com/coditory/quark/i18n/loader/FileWatcher.java b/src/main/java/com/coditory/quark/i18n/loader/FileWatcher.java index 0afbb24..72f349f 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/FileWatcher.java +++ b/src/main/java/com/coditory/quark/i18n/loader/FileWatcher.java @@ -30,7 +30,7 @@ import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; import static java.util.Objects.requireNonNull; -public class FileWatcher implements Runnable { +public final class FileWatcher implements Runnable { private static final int MAX_DIRS_TO_WATCH = 1_000; private final Logger logger = LoggerFactory.getLogger(this.getClass()); private final Map watchedDirKeys = new HashMap<>(); diff --git a/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoader.java b/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoader.java index af020e5..b86dba8 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoader.java +++ b/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoader.java @@ -23,7 +23,7 @@ import static java.util.Collections.unmodifiableList; import static java.util.Objects.requireNonNull; -public class I18nFileLoader implements WatchableI18nLoader { +public final class I18nFileLoader implements WatchableI18nLoader { private final List listeners = new ArrayList<>(); private final Set pathPatterns; private final ClassLoader classLoader; @@ -32,7 +32,7 @@ public class I18nFileLoader implements WatchableI18nLoader { private final I18nPath staticPrefix; private final FileSystem fileSystem; private final Map cachedResources = new LinkedHashMap<>(); - private final Map cachedEntries = new LinkedHashMap<>(); + private final Map cachedBundles = new LinkedHashMap<>(); private Thread watchThread; I18nFileLoader( @@ -53,32 +53,32 @@ public class I18nFileLoader implements WatchableI18nLoader { @NotNull @Override - public synchronized List load() { - List result = new ArrayList<>(); + public synchronized List load() { + List result = new ArrayList<>(); for (I18nPathPattern pathPattern : pathPatterns) { List resources = scanFiles(pathPattern); for (Resource resource : resources) { - I18nTemplatesBundle templates = load(pathPattern, resource); + I18nMessageBundle templates = load(pathPattern, resource); result.add(templates); } } return unmodifiableList(result); } - private I18nTemplatesBundle load(I18nPathPattern pathPattern, Resource resource) { + private I18nMessageBundle load(I18nPathPattern pathPattern, Resource resource) { I18nPathGroups matchedGroups = pathPattern.matchGroups(resource.name()); return load(resource, matchedGroups); } - private I18nTemplatesBundle load(Resource resource, I18nPathGroups matchedGroups) { + private I18nMessageBundle load(Resource resource, I18nPathGroups matchedGroups) { Locale locale = matchedGroups.locale(); I18nPath prefix = matchedGroups.path() != null ? staticPrefix.child(matchedGroups.path()) : I18nPath.root(); Map parsed = parseFile(locale, resource); String urlString = resource.url().toString(); - I18nTemplatesBundle result = new I18nTemplatesBundle(parsed, prefix); - cachedEntries.put(urlString, result); + I18nMessageBundle result = new I18nMessageBundle(parsed, prefix); + cachedBundles.put(urlString, result); cachedResources.put(urlString, new CachedResource(resource, matchedGroups)); return result; } @@ -128,9 +128,9 @@ private String getExtension(String resourceName) { @Override public synchronized void startWatching() { if (watchThread != null) { - throw new IllegalStateException("Loader is already watching for changes"); + throw new I18nLoadException("Loader is already watching for changes"); } - if (cachedEntries.isEmpty()) { + if (cachedBundles.isEmpty()) { load(); } watchThread = FileWatcher.builder() @@ -164,18 +164,18 @@ private synchronized void onFileChange(FileChangedEvent event) { String urlString = url.toString(); Resource resource = new Resource(path.toString(), url); switch (event.changeType()) { - case DELETE -> cachedEntries.remove(urlString); + case DELETE -> cachedBundles.remove(urlString); case MODIFY -> { - cachedEntries.remove(urlString); + cachedBundles.remove(urlString); loadToCache(resource); } case CREATE -> loadToCache(resource); } - List entries = cachedEntries.values() + List bundles = cachedBundles.values() .stream() .toList(); for (I18nLoaderChangeListener listener : listeners) { - listener.onChange(entries); + listener.onChange(bundles); } } diff --git a/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoaderBuilder.java b/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoaderBuilder.java index a02f710..70eaced 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoaderBuilder.java +++ b/src/main/java/com/coditory/quark/i18n/loader/I18nFileLoaderBuilder.java @@ -17,7 +17,7 @@ import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toCollection; -public class I18nFileLoaderBuilder { +public final class I18nFileLoaderBuilder { private final List pathPatterns = new ArrayList<>(); private final Map fileParsersByExtension = new HashMap<>(I18N_PARSERS_BY_EXT); private ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); diff --git a/src/main/java/com/coditory/quark/i18n/loader/I18nLoadException.java b/src/main/java/com/coditory/quark/i18n/loader/I18nLoadException.java index 7a97c01..d6b44ec 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/I18nLoadException.java +++ b/src/main/java/com/coditory/quark/i18n/loader/I18nLoadException.java @@ -2,7 +2,7 @@ import com.coditory.quark.i18n.I18nMessagesException; -public class I18nLoadException extends I18nMessagesException { +public final class I18nLoadException extends I18nMessagesException { public I18nLoadException(String message) { super(message); } diff --git a/src/main/java/com/coditory/quark/i18n/loader/I18nLoader.java b/src/main/java/com/coditory/quark/i18n/loader/I18nLoader.java index 9cebe3c..1b6f723 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/I18nLoader.java +++ b/src/main/java/com/coditory/quark/i18n/loader/I18nLoader.java @@ -7,5 +7,5 @@ @FunctionalInterface public interface I18nLoader { @NotNull - List load(); + List load(); } diff --git a/src/main/java/com/coditory/quark/i18n/loader/I18nTemplatesBundle.java b/src/main/java/com/coditory/quark/i18n/loader/I18nMessageBundle.java similarity index 56% rename from src/main/java/com/coditory/quark/i18n/loader/I18nTemplatesBundle.java rename to src/main/java/com/coditory/quark/i18n/loader/I18nMessageBundle.java index 7940700..335228c 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/I18nTemplatesBundle.java +++ b/src/main/java/com/coditory/quark/i18n/loader/I18nMessageBundle.java @@ -3,18 +3,17 @@ import com.coditory.quark.i18n.I18nKey; import com.coditory.quark.i18n.I18nPath; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.Map; import static java.util.Objects.requireNonNull; -public record I18nTemplatesBundle(Map templates, I18nPath prefix) { - public I18nTemplatesBundle(@NotNull Map templates) { +public record I18nMessageBundle(Map templates, I18nPath prefix) { + public I18nMessageBundle(@NotNull Map templates) { this(templates, I18nPath.root()); } - public I18nTemplatesBundle(@NotNull Map templates, @Nullable I18nPath prefix) { + public I18nMessageBundle(@NotNull Map templates, @NotNull I18nPath prefix) { this.templates = Map.copyOf(templates); this.prefix = requireNonNull(prefix); } diff --git a/src/main/java/com/coditory/quark/i18n/loader/Resource.java b/src/main/java/com/coditory/quark/i18n/loader/Resource.java index c2d63c9..975b46b 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/Resource.java +++ b/src/main/java/com/coditory/quark/i18n/loader/Resource.java @@ -1,6 +1,14 @@ package com.coditory.quark.i18n.loader; +import org.jetbrains.annotations.NotNull; + import java.net.URL; -record Resource(String name, URL url) { +import static java.util.Objects.requireNonNull; + +record Resource(@NotNull String name, @NotNull URL url) { + Resource(String name, URL url) { + this.name = requireNonNull(name); + this.url = requireNonNull(url); + } } diff --git a/src/main/java/com/coditory/quark/i18n/loader/ResourceScanner.java b/src/main/java/com/coditory/quark/i18n/loader/ResourceScanner.java index 3a6a152..2212ea8 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/ResourceScanner.java +++ b/src/main/java/com/coditory/quark/i18n/loader/ResourceScanner.java @@ -11,8 +11,12 @@ import java.util.Queue; import java.util.function.Predicate; +import static java.util.Objects.requireNonNull; + final class ResourceScanner { static List scanFiles(FileSystem fs, I18nPathPattern pathPattern) { + requireNonNull(fs); + requireNonNull(pathPattern); Path basePath = fs.getPath(pathPattern.getBaseDirectory()); try { return scanFiles(basePath, pathPattern.getPattern().asMatchPredicate()); diff --git a/src/main/java/com/coditory/quark/i18n/loader/Runner.java b/src/main/java/com/coditory/quark/i18n/loader/Runner.java deleted file mode 100644 index ef9a977..0000000 --- a/src/main/java/com/coditory/quark/i18n/loader/Runner.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.coditory.quark.i18n.loader; - -import com.coditory.quark.i18n.I18nMessagePack; -import com.coditory.quark.i18n.I18nMessages; -import com.coditory.quark.i18n.Reloadable18nMessagePack; - -import java.io.IOException; -import java.util.Set; - -public class Runner { - public static void main(String[] args) throws InterruptedException { - Reloadable18nMessagePack i18nMessagePack = I18nMessagePack.builder() - .scanFileSystem("src/main/resources/**/{prefix}/i18n.yml") - .scanFileSystem("src/main/resources/i18n/*") - .buildAndWatchForChanges(); - while (true) { - I18nMessages messages = i18nMessagePack.localize("pl"); - try { - String message = messages.getMessage("homepage.hello"); - System.out.println("Message: " + message); - } catch (RuntimeException e) { - System.out.println("Error: " + e.getMessage()); - } - Thread.sleep(5000); - } - } - - public static void main2(String[] args) throws IOException { - ClassPath cp = ClassPath.from(Thread.currentThread().getContextClassLoader()); - Set resources = cp.getResources("/a/b"); - System.out.println("Resources: " + resources); - - Reloadable18nMessagePack i18nMessagePack = I18nMessagePack.builder() - .scanClassPath("**/{prefix}/i18n.yml") - .scanClassPath("i18n/*") - .buildReloadable(); - I18nMessages messages = i18nMessagePack.localize("pl"); - - String message = messages.getMessage("homepage.hello"); - System.out.println("Message: " + message); - } -} diff --git a/src/main/java/com/coditory/quark/i18n/loader/WatchableI18nLoader.java b/src/main/java/com/coditory/quark/i18n/loader/WatchableI18nLoader.java index fa2115b..f9dccc4 100644 --- a/src/main/java/com/coditory/quark/i18n/loader/WatchableI18nLoader.java +++ b/src/main/java/com/coditory/quark/i18n/loader/WatchableI18nLoader.java @@ -2,12 +2,15 @@ import java.util.List; +import static java.util.Objects.requireNonNull; + public interface WatchableI18nLoader extends I18nLoader { void addChangeListener(I18nLoaderChangeListener listener); void startWatching(); default void startWatching(I18nLoaderChangeListener listener) { + requireNonNull(listener); addChangeListener(listener); startWatching(); } @@ -15,6 +18,6 @@ default void startWatching(I18nLoaderChangeListener listener) { void stopWatching(); interface I18nLoaderChangeListener { - void onChange(List entries); + void onChange(List bundles); } } diff --git a/src/main/java/com/coditory/quark/i18n/parser/EntriesI18nParser.java b/src/main/java/com/coditory/quark/i18n/parser/EntriesI18nParser.java index 42d52d5..3f56f97 100644 --- a/src/main/java/com/coditory/quark/i18n/parser/EntriesI18nParser.java +++ b/src/main/java/com/coditory/quark/i18n/parser/EntriesI18nParser.java @@ -12,8 +12,11 @@ import java.util.Locale; import java.util.Map; +import static java.util.Objects.requireNonNull; + final class EntriesI18nParser { public Map parseEntries(@NotNull Map values, @Nullable Locale locale) { + requireNonNull(values); return parseEntries(values, I18nPath.root(), locale); } diff --git a/src/main/java/com/coditory/quark/i18n/parser/I18nParseException.java b/src/main/java/com/coditory/quark/i18n/parser/I18nParseException.java index 86b7650..9f8a1b5 100644 --- a/src/main/java/com/coditory/quark/i18n/parser/I18nParseException.java +++ b/src/main/java/com/coditory/quark/i18n/parser/I18nParseException.java @@ -2,7 +2,7 @@ import com.coditory.quark.i18n.I18nMessagesException; -public class I18nParseException extends I18nMessagesException { +public final class I18nParseException extends I18nMessagesException { I18nParseException(String message) { super(message); } diff --git a/src/main/java/com/coditory/quark/i18n/parser/JsonI18nParser.java b/src/main/java/com/coditory/quark/i18n/parser/JsonI18nParser.java index d9634b3..3e65cd1 100644 --- a/src/main/java/com/coditory/quark/i18n/parser/JsonI18nParser.java +++ b/src/main/java/com/coditory/quark/i18n/parser/JsonI18nParser.java @@ -11,6 +11,8 @@ import java.util.Locale; import java.util.Map; +import static java.util.Objects.requireNonNull; + final class JsonI18nParser implements I18nParser { private final Gson gson = new GsonBuilder() .setPrettyPrinting() @@ -19,6 +21,7 @@ final class JsonI18nParser implements I18nParser { @Override @NotNull public Map parse(@NotNull String content, @Nullable Locale locale) { + requireNonNull(content); Map entries = parseJson(content); return I18nParsers.parseEntries(entries, locale); } diff --git a/src/main/java/com/coditory/quark/i18n/parser/PropertiesI18nParser.java b/src/main/java/com/coditory/quark/i18n/parser/PropertiesI18nParser.java index 6a27ecb..2cccc6e 100644 --- a/src/main/java/com/coditory/quark/i18n/parser/PropertiesI18nParser.java +++ b/src/main/java/com/coditory/quark/i18n/parser/PropertiesI18nParser.java @@ -12,12 +12,14 @@ import java.util.Objects; import java.util.Properties; +import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toMap; final class PropertiesI18nParser implements I18nParser { @Override @NotNull public Map parse(@NotNull String content, @Nullable Locale locale) { + requireNonNull(content); Map entries = parseProperties(content); return I18nParsers.parseEntries(entries, locale); } diff --git a/src/main/java/com/coditory/quark/i18n/parser/YamlI18nParser.java b/src/main/java/com/coditory/quark/i18n/parser/YamlI18nParser.java index 5dd63e0..e2b7742 100644 --- a/src/main/java/com/coditory/quark/i18n/parser/YamlI18nParser.java +++ b/src/main/java/com/coditory/quark/i18n/parser/YamlI18nParser.java @@ -1,7 +1,6 @@ package com.coditory.quark.i18n.parser; import com.coditory.quark.i18n.I18nKey; -import com.coditory.quark.i18n.I18nPath; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.yaml.snakeyaml.Yaml; @@ -9,10 +8,13 @@ import java.util.Locale; import java.util.Map; +import static java.util.Objects.requireNonNull; + final class YamlI18nParser implements I18nParser { @Override @NotNull public Map parse(@NotNull String content, @Nullable Locale locale) { + requireNonNull(content); Map entries = parseYaml(content); return I18nParsers.parseEntries(entries, locale); } diff --git a/src/test/groovy/com/coditory/quark/i18n/ArgumentIndexExtractorSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/ArgumentIndexExtractorSpec.groovy new file mode 100644 index 0000000..d88b737 --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/ArgumentIndexExtractorSpec.groovy @@ -0,0 +1,45 @@ +package com.coditory.quark.i18n + +import spock.lang.Specification +import spock.lang.Unroll + +import static com.coditory.quark.i18n.ArgumentIndexExtractor.extractArgumentIndexes + +class ArgumentIndexExtractorSpec extends Specification { + @Unroll + def "should extract argument indexes from: #template"() { + when: + List indexes = extractArgumentIndexes(template).toList() + indexes.sort() + then: + indexes == expected + where: + template || expected + "{0}" || [0] + "{0,number,integer}" || [0] + "{ 0 , number }" || [0] + "{ 10 , number }" || [10] + "{ 0 } {10}" || [0, 10] + "'{abc '{0}}" || [0] + "'{abc {'{0}} }" || [0] + } + + @Unroll + def "should extract no argument indexes from: #template"() { + when: + List indexes = extractArgumentIndexes(template).toList() + then: + indexes == [] + where: + template << [ + "", + "0", + "{named}", + "{0named}", + "{0 named}", + "'{0}", + "'{abc {0}}", + "'{abc {} { {0}} {}}" + ] + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/I18nKeySpec.groovy b/src/test/groovy/com/coditory/quark/i18n/I18nKeySpec.groovy index fec5035..903a0ec 100644 --- a/src/test/groovy/com/coditory/quark/i18n/I18nKeySpec.groovy +++ b/src/test/groovy/com/coditory/quark/i18n/I18nKeySpec.groovy @@ -4,18 +4,16 @@ import spock.lang.Specification import spock.lang.Unroll class I18nKeySpec extends Specification { - @Unroll def "should expose i18nKey values"() { when: I18nKey key = I18nKey.of(Locales.EN_US, "a.b.c") then: - key.toShortString() == "en_US:a.b.c" + key.toShortString() == "en-US:a.b.c" key.pathValue() == "a.b.c" key.path() == I18nPath.of("a", "b", "c") key.locale() == Locales.EN_US } - @Unroll def "should create i18nKey with different locale"() { given: I18nKey key = I18nKey.of(Locales.EN_US, "a.b.c") @@ -23,7 +21,6 @@ class I18nKeySpec extends Specification { key.withLocale(Locales.PL) == I18nKey.of(Locales.PL, "a.b.c") } - @Unroll def "should create i18nKey with child path"() { given: I18nKey key = I18nKey.of(Locales.EN_US, "a.b.c") @@ -31,7 +28,6 @@ class I18nKeySpec extends Specification { key.child("x.y") == I18nKey.of(Locales.EN_US, "a.b.c.x.y") } - @Unroll def "should create i18nKey with prefixed path"() { given: I18nKey key = I18nKey.of(Locales.EN_US, "a.b.c") @@ -39,7 +35,6 @@ class I18nKeySpec extends Specification { key.prefixPath("x.y") == I18nKey.of(Locales.EN_US, "x.y.a.b.c") } - @Unroll def "should create i18nKey with different path"() { given: I18nKey key = I18nKey.of(Locales.EN_US, "a.b.c") diff --git a/src/test/groovy/com/coditory/quark/i18n/MessageHierarchySpec.groovy b/src/test/groovy/com/coditory/quark/i18n/MessageHierarchySpec.groovy new file mode 100755 index 0000000..e90462b --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/MessageHierarchySpec.groovy @@ -0,0 +1,101 @@ +package com.coditory.quark.i18n + +import spock.lang.Specification +import spock.lang.Unroll + +import static com.coditory.quark.i18n.Locales.EN +import static com.coditory.quark.i18n.Locales.EN_GB +import static com.coditory.quark.i18n.Locales.EN_US +import static com.coditory.quark.i18n.Locales.PL +import static com.coditory.quark.i18n.Locales.PL_PL + +class MessageHierarchySpec extends Specification { + @Unroll + def "should resolve message using fallback locales (#locale, #path)"() { + given: + I18nMessagePack messagePack = I18nMessagePack.builder() + .addMessage(EN_US, "a", "en-US:a") + // EN + .addMessage(EN, "a", "en:a") + .addMessage(EN, "b", "en:b") + // PL_PL - default + .setDefaultLocale(PL_PL) + .addMessage(PL_PL, "a", "pl-PL:a") + .addMessage(PL_PL, "b", "pl-PL:b") + .addMessage(PL_PL, "c", "pl-PL:c") + // PL - default's parent + .addMessage(PL, "a", "pl:a") + .addMessage(PL, "b", "pl:b") + .addMessage(PL, "c", "pl:c") + .addMessage(PL, "d", "pl:d") + // EN_GB - control group + .addMessage(EN_GB, "a", "en-GB:a") + .addMessage(EN_GB, "b", "en-GB:b") + .addMessage(EN_GB, "c", "en-GB:c") + .build() + when: + String result = messagePack.getMessage(locale, path) + then: + result == expected + where: + locale | path || expected + EN_US | "a" || "en-US:a" + EN_US | "b" || "en:b" + EN_US | "c" || "pl-PL:c" + EN_US | "d" || "pl:d" + EN | "a" || "en:a" + Locale.forLanguageTag("en-XX") | "a" || "en:a" + } + + @Unroll + def "should resolve message using fallback prefix (#locale, #path)"() { + given: + I18nMessagePack messagePack = I18nMessagePack.builder() + // EN_US + .addMessage(EN_US, "a", "en-US:a") + .addMessage(EN_US, "fallback.a", "en-US:fallback.a") + .addMessage(EN_US, "fallback.b", "en-US:fallback.b") + .addMessage(EN_US, "fallback.c", "en-US:fallback.c") + // EN + .addMessage(EN, "a", "en:a") + .addMessage(EN, "b", "en:b") + .addMessage(EN, "fallback.a", "en:fallback.a") + .addMessage(EN, "fallback.b", "en:fallback.b") + .addMessage(EN, "fallback.c", "en:fallback.c") + .addMessage(EN, "fallback.d", "en:fallback.d") + // PL - default's parent + .addMessage(PL, "a", "pl:a") + .addMessage(PL, "b", "pl:b") + .addMessage(PL, "c", "pl:c") + .addMessage(PL, "d", "pl:d") + .addMessage(PL, "e", "pl:e") + .addMessage(PL, "fallback.a", "pl:fallback.a") + .addMessage(PL, "fallback.b", "pl:fallback.b") + .addMessage(PL, "fallback.c", "pl:fallback.c") + .addMessage(PL, "fallback.d", "pl:fallback.d") + .addMessage(PL, "fallback.e", "pl:fallback.e") + .addMessage(PL, "fallback.f", "pl:fallback.f") + // EN_GB - control group + .addMessage(EN_GB, "a", "en-GB:a") + .addMessage(EN_GB, "b", "en-GB:b") + .addMessage(EN_GB, "c", "en-GB:c") + // common settings + .addMessageFallbackKeyPrefix("fallback") + .setDefaultLocale(PL_PL) + .build() + when: + String result = messagePack.getMessage(locale, path) + then: + result == expected + where: + locale | path || expected + EN_US | "a" || "en-US:a" + EN_US | "b" || "en:b" + EN_US | "c" || "en-US:fallback.c" + EN_US | "d" || "en:fallback.d" + EN_US | "e" || "pl:e" + EN_US | "f" || "pl:fallback.f" + EN | "a" || "en:a" + Locale.forLanguageTag("en-XX") | "a" || "en:a" + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/MessageResolutionSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/MessageResolutionSpec.groovy new file mode 100755 index 0000000..655fa93 --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/MessageResolutionSpec.groovy @@ -0,0 +1,50 @@ +package com.coditory.quark.i18n + +import spock.lang.Specification +import spock.lang.Unroll + +import static com.coditory.quark.i18n.Locales.EN_US +import static com.coditory.quark.i18n.Locales.PL +import static com.coditory.quark.i18n.Locales.PL_PL + +class MessageResolutionSpec extends Specification { + def "should return message with two indexed arguments"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(PL, "hello", "Witaj {0} {1}") + .buildLocalized(PL) + when: + String result = messages.getMessage("hello", "Jan", "Kowalski") + then: + result == "Witaj Jan Kowalski" + } + + def "should return message with two named arguments"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(PL, "hello", "Witaj {firstName} {lastName}") + .buildLocalized(PL) + when: + String result = messages.getMessage("hello", [firstName: "Jan", lastName: "Kowalski"]) + then: + result == "Witaj Jan Kowalski" + } + + @Unroll + def "should prefix queries (#locale, #path)"() { + given: + I18nMessagePack messagePack = I18nMessagePack.builder() + .addMessage(EN_US, "a", "en-US:a") + .addMessage(EN_US, "x.y.a", "en-US:x.y.a") + .addMessage(EN_US, "b", "en-US:b") + .addMessage(PL, "c", "pl:c") + .addMessage(PL, "x.y.c", "pl:x.y.c") + .setDefaultLocale(PL_PL) + .build() + .prefixQueries("x.y") + expect: + messagePack.getMessage(EN_US, "a") == "en-US:x.y.a" + messagePack.getMessage(EN_US, "b") == "en-US:b" + messagePack.getMessage(EN_US, "c") == "pl:x.y.c" + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/MessageWhitespaceNormalizationSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/MessageWhitespaceNormalizationSpec.groovy new file mode 100755 index 0000000..8e6b242 --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/MessageWhitespaceNormalizationSpec.groovy @@ -0,0 +1,32 @@ +package com.coditory.quark.i18n + +import spock.lang.Specification +import spock.lang.Unroll + +import static com.coditory.quark.i18n.Locales.EN + +class MessageWhitespaceNormalizationSpec extends Specification { + def "should replace multiple whitespaces with a single space"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", " \n\tsome text\n with\t\tspaces ") + .normalizeWhitespaces() + .buildLocalized(EN) + when: + String result = messages.getMessage("msg") + then: + result == "some text with spaces" + } + + @Unroll + def "should skip whitespace normalization by default"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", " \n\tsome text\n with\t\tspaces ") + .buildLocalized(EN) + when: + String result = messages.getMessage("msg") + then: + result == " \n\tsome text\n with\t\tspaces " + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/MissingMessageDetectionSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/MissingMessageDetectionSpec.groovy new file mode 100755 index 0000000..91638b5 --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/MissingMessageDetectionSpec.groovy @@ -0,0 +1,178 @@ +package com.coditory.quark.i18n + +import ch.qos.logback.classic.Logger +import com.coditory.quark.i18n.base.CapturingAppender +import org.slf4j.LoggerFactory +import spock.lang.Specification + +import static com.coditory.quark.i18n.Locales.DE_DE +import static com.coditory.quark.i18n.Locales.EN +import static com.coditory.quark.i18n.Locales.EN_GB +import static com.coditory.quark.i18n.Locales.EN_US +import static com.coditory.quark.i18n.Locales.PL_PL + +class MissingMessageDetectionSpec extends Specification { + CapturingAppender appender = new CapturingAppender() + + void setup() { + Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) + rootLogger.addAppender(appender) + } + + void cleanup() { + Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) + rootLogger.detachAppender(appender) + } + + def "should detect missing message"() { + when: + I18nMessagePack.builder() + .addMessage(EN_US, "hello", "Hello") + .addMessage(PL_PL, "hello", "Cześć") + .addMessage(DE_DE, "bye", "Tschüss") + .logMissingMessages() + .build() + then: + missingMessageReport() == """ + Missing Messages + ================ + Path: bye + Missing: en_US, pl_PL + Sources: de_DE + + Path: hello + Missing: de_DE + Sources: en_US, pl_PL + + Total: 2""".stripIndent().trim() + } + + def "should detect missing message from the same language and different countries"() { + when: + I18nMessagePack.builder() + .addMessage(EN_US, "hello", "hello") + .addMessage(EN_GB, "bye", "bye") + .addMessage(EN, "other", "other") + .logMissingMessages() + .build() + then: + missingMessageReport() == """ + Missing Messages + ================ + Path: bye + Missing: en, en_US + Sources: en_GB + + Path: hello + Missing: en, en_GB + Sources: en_US + + Total: 2""".stripIndent().trim() + } + + def "should print report and throw error on missing messages"() { + given: + I18nMissingMessagesDetector detector = I18nMissingMessagesDetector.builder() + .throwErrorOnMissingMessages() + .logMissingMessages() + .build() + when: + I18nMessagePack.builder() + .addMessage(EN_US, "hello", "Hello US") + .addMessage(PL_PL, "bye", "Narka") + .detectMissingMessages(detector) + .build() + then: + IllegalStateException e = thrown(IllegalStateException) + e.getMessage() == "Detected missing messages: 2" + + and: + missingMessageReport() == """ + Missing Messages + ================ + Path: bye + Missing: en_US + Sources: pl_PL + + Path: hello + Missing: pl_PL + Sources: en_US + + Total: 2""".stripIndent().trim() + } + + def "should detect missing message in lang-only locale when all country based locales are valid"() { + when: + I18nMessagePack.builder() + .addMessage(EN, "bye", "Bye") + .addMessage(EN_US, "hello", "Hello US") + .addMessage(EN_GB, "hello", "Hello GB") + .addMessage(PL_PL, "hello", "Cześć") + .logMissingMessages() + .build() + then: + missingMessageReport() == """ + Missing Messages + ================ + Path: bye + Missing: pl_PL + Sources: en + + Path: hello + Missing: en + Sources: en_GB, en_US, pl_PL + + Total: 2""".stripIndent().trim() + } + + def "should detect no missing message"() { + when: + I18nMessagePack.builder() + .addMessage(EN_US, "hello", "Hello US") + .addMessage(EN_GB, "hello", "Hello GB") + .addMessage(PL_PL, "hello", "Cześć") + .logMissingMessages() + .build() + then: + missingMessageReport() == "" + } + + def "should skip validating messages by path prefix: #skipPath"() { + given: + I18nMissingMessagesDetector detector = I18nMissingMessagesDetector.builder() + .skipPath(skipPath) + .logMissingMessages() + .build() + when: + I18nMessagePack.builder() + .addMessage(EN_US, "a.b.c.d", "MISSING") + .addMessage(EN_US, "x", "X") + .addMessage(EN_GB, "x", "X") + .addMessage(PL_PL, "x", "X") + .detectMissingMessages(detector) + .build() + then: + missingMessageReport() == "" + + where: + skipPath << [ + "a.b.c.d", + "a.b.c.*", + "a.b.**", + "a.**", + "a.**.d", + "**.d", + "*.*.*.*" + ] + } + + String missingMessageReport() { + List logs = appender.getLogsByMessagePrefix("\nMissing Messages") + if (logs.isEmpty()) { + return "" + } + return logs.get(0) + .replaceAll("\\[WARN\\]", "") + .trim() + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/MissingMessageSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/MissingMessageSpec.groovy new file mode 100755 index 0000000..ae35b35 --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/MissingMessageSpec.groovy @@ -0,0 +1,64 @@ +package com.coditory.quark.i18n + +import spock.lang.Specification + +import static com.coditory.quark.i18n.I18nMessagePackFactory.emptyMessagePack +import static com.coditory.quark.i18n.Locales.EN +import static com.coditory.quark.i18n.Locales.EN_US +import static com.coditory.quark.i18n.Locales.PL + +class MissingMessageSpec extends Specification { + def "should throw error for missing message"() { + given: + I18nMessagePack messages = emptyMessagePack() + when: + messages.getMessage(EN_US, "home.xxx") + then: + I18nMessagesException e = thrown(I18nMessagesException) + e.message == "Missing message en-US:home.xxx" + } + + def "should throw error for missing message with indexed arguments"() { + given: + I18nMessagePack messages = emptyMessagePack() + when: + messages.getMessage(EN_US, "home.xxx", 123, "xyz") + then: + I18nMessagesException e = thrown(I18nMessagesException) + e.message == "Missing message en-US:home.xxx(123, xyz)" + } + + def "should throw error for missing message with named arguments"() { + given: + I18nMessagePack messages = emptyMessagePack() + when: + messages.getMessage(EN_US, "home.xxx", [someNumber: 123, someText: "xyz"]) + then: + I18nMessagesException e = thrown(I18nMessagesException) + e.message == "Missing message en-US:home.xxx(someNumber=123, someText=xyz)" + } + + def "should throw error for missing message and no default locale"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(PL, "home.hello", "Witamy") + .addMessage(EN, "home.bye", "Bye") + .buildLocalized(PL) + when: + messages.getMessage("home.bye") + then: + I18nMessagesException e = thrown(I18nMessagesException) + e.message == "Missing message pl:home.bye" + } + + def "should return missing message path when using printing unresolved message handler"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .usePathOnMissingMessage() + .buildLocalized(EN_US) + when: + String result = messages.getMessage("home.bye") + then: + result == "home.bye" + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/ReferenceHierarchySpec.groovy b/src/test/groovy/com/coditory/quark/i18n/ReferenceHierarchySpec.groovy new file mode 100755 index 0000000..6957db0 --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/ReferenceHierarchySpec.groovy @@ -0,0 +1,143 @@ +package com.coditory.quark.i18n + +import com.coditory.quark.i18n.base.InMemI18nLoader +import spock.lang.Specification +import spock.lang.Unroll + +import static com.coditory.quark.i18n.Locales.EN +import static com.coditory.quark.i18n.Locales.EN_GB +import static com.coditory.quark.i18n.Locales.EN_US +import static com.coditory.quark.i18n.Locales.PL +import static com.coditory.quark.i18n.Locales.PL_PL + +class ReferenceHierarchySpec extends Specification { + def "should resolve reference using source message locale (not user locale)"() { + given: + I18nMessagePack messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "\$ref") + .addMessage(EN, "ref", "en:ref") + .addMessage(EN_US, "ref", "en-US:ref") + .addMessage(PL_PL, "ref", "pl-PL:ref") + .setDefaultLocale(PL_PL) + .build() + when: + String message = messages.getMessage(EN_US, "msg") + then: + message == "en:ref" + } + + def "should resolve references using fallback locales (#locale, #reference)"() { + when: + String message = I18nMessagePack.builder() + .addMessage(locale, "msg", "\${$reference}") + // EN_US + .addMessage(EN_US, "a", "en-US:a") + // EN + .addMessage(EN, "a", "en:a") + .addMessage(EN, "b", "en:b") + // PL_PL - default + .setDefaultLocale(PL_PL) + .addMessage(PL_PL, "a", "pl-PL:a") + .addMessage(PL_PL, "b", "pl-PL:b") + .addMessage(PL_PL, "c", "pl-PL:c") + // PL - default's parent + .addMessage(PL, "a", "pl:a") + .addMessage(PL, "b", "pl:b") + .addMessage(PL, "c", "pl:c") + .addMessage(PL, "d", "pl:d") + // EN_GB - control group + .addMessage(EN_GB, "a", "en-GB:a") + .addMessage(EN_GB, "b", "en-GB:b") + .addMessage(EN_GB, "c", "en-GB:c") + .build() + .getMessage(locale, "msg") + then: + message == expected + where: + locale | reference || expected + EN_US | "a" || "en-US:a" + EN_US | "b" || "en:b" + EN_US | "c" || "pl-PL:c" + EN_US | "d" || "pl:d" + EN | "a" || "en:a" + } + + @Unroll + def "should resolve references using fallback key prefix (#locale, #reference)"() { + when: + String message = I18nMessagePack.builder() + .addMessage(locale, "msg", "\${$reference}") + // EN_US + .addMessage(EN_US, "a", "en-US:a") + .addMessage(EN_US, "fallback.b", "en-US:fallback.b") + .addMessage(EN_US, "fallback.c", "en-US:fallback.c") + // EN + .addMessage(EN, "a", "en:a") + .addMessage(EN, "b", "en:b") + .addMessage(EN, "fallback.c", "en:fallback.c") + .addMessage(EN, "fallback.d", "en:fallback.d") + // PL - default's parent + .addMessage(PL, "a", "pl:a") + .addMessage(PL, "b", "pl:b") + .addMessage(PL, "c", "pl:c") + .addMessage(PL, "d", "pl:d") + .addMessage(PL, "e", "pl:e") + .addMessage(PL, "fallback.f", "pl:fallback.f") + // common settings + .setDefaultLocale(PL_PL) + .addFallbackKeyPrefix("fallback") + .build() + .getMessage(locale, "msg") + then: + message == expected + + where: + locale | reference || expected + EN_US | "a" || "en-US:a" + EN_US | "b" || "en:b" + EN_US | "c" || "en-US:fallback.c" + EN_US | "d" || "en:fallback.d" + EN_US | "e" || "pl:e" + EN_US | "f" || "pl:fallback.f" + } + + @Unroll + def "should prefix references from a loaded bundle (#locale, #reference)"() { + when: + String message = I18nMessagePack.builder() + .addLoader(InMemI18nLoader.of([ + (I18nKey.of(locale, "msg")): "\$" + reference, + ], I18nPath.of("common"))) + // EN_US + .addMessage(EN_US, "common.a", "en-US:common.a") + .addMessage(EN_US, "b", "en-US:b") + .addMessage(EN_US, "c", "en-US:c") + // EN + .addMessage(EN, "common.a", "en:common.a") + .addMessage(EN, "common.b", "en:common.b") + .addMessage(EN, "c", "en:c") + .addMessage(EN, "d", "en:d") + // PL - default's parent + .addMessage(PL, "common.a", "pl:common.a") + .addMessage(PL, "common.b", "pl:common.b") + .addMessage(PL, "common.c", "pl:common.c") + .addMessage(PL, "common.d", "pl:common.d") + .addMessage(PL, "common.e", "pl:common.e") + .addMessage(PL, "f", "pl:f") + // settings + .setDefaultLocale(PL_PL) + .build() + .getMessage(locale, "common.msg") + then: + message == expected + + where: + locale | reference || expected + EN_US | "a" || "en-US:common.a" + EN_US | "b" || "en:common.b" + EN_US | "c" || "en-US:c" + EN_US | "d" || "en:d" + EN_US | "e" || "pl:common.e" + EN_US | "f" || "pl:f" + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/ReferenceResolutionSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/ReferenceResolutionSpec.groovy new file mode 100755 index 0000000..468d55d --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/ReferenceResolutionSpec.groovy @@ -0,0 +1,102 @@ +package com.coditory.quark.i18n + +import spock.lang.Specification + +import static com.coditory.quark.i18n.Locales.EN + +class ReferenceResolutionSpec extends Specification { + def "should resolve message reference"() { + when: + String message = I18nMessagePack.builder() + .addMessage(EN, "msg", "\$company.name was established on 1988") + .addMessage(EN, "company.name", "ACME") + .build() + .getMessage(EN, "msg") + then: + message == "ACME was established on 1988" + } + + def "should skip reference resolution when requested"() { + when: + String message = I18nMessagePack.builder() + .addMessage(EN, "msg", "\$company.name was established on 1988") + .addMessage(EN, "company.name", "ACME") + .disableReferenceResolution() + .build() + .getMessage(EN, "msg") + then: + message == "\$company.name was established on 1988" + } + + def "should resolve a message with a transitive reference"() { + given: + I18nMessagePack messagePack = I18nMessagePack.builder() + .addMessage(EN, "msg", "\$company.name1 was established on 1988") + .addMessage(EN, "company.name1", ">\${company.name2}<") + .addMessage(EN, "company.name2", ">\${company.name3}<") + .addMessage(EN, "company.name3", "ACME") + .build() + when: + String message = messagePack.getMessage(EN, "msg") + then: + message == ">>ACME<< was established on 1988" + } + + def "should resolve a message with a reference in braces and without them"() { + given: + I18nMessagePack messagePack = I18nMessagePack.builder() + .addMessage(EN, "msg", "a: \${a}, b: \${ b }, c: \$c") + .addMessage(EN, "a", "A") + .addMessage(EN, "b", "B") + .addMessage(EN, "c", "C") + .build() + when: + String message = messagePack.getMessage(EN, "msg") + then: + message == "a: A, b: B, c: C" + } + + def "should throw error on unresolvable reference"() { + when: + I18nMessagePack.builder() + .addMessage(EN, "msg", "\$company.name was established on 1988") + .build() + then: + I18nMessagesException e = thrown(I18nMessagesException) + e.getMessage() == "Missing reference: company.name" + } + + def "should throw error on cyclic reference"() { + when: + I18nMessagePack.builder() + .addMessage(EN, "msg", "\$company.name1 was established on 1988") + .addMessage(EN, "company.name1", "\${company.name2}") + .addMessage(EN, "company.name2", "\${company.name3}") + .addMessage(EN, "company.name3", "\${company.name1}") + .build() + then: + IllegalArgumentException e = thrown(IllegalArgumentException) + e.getMessage() == "Detected potential cyclic reference" + } + + def "should throw error on a reference to itself"() { + when: + I18nMessagePack.builder() + .addMessage(EN, "msg", "\$msg") + .build() + then: + IllegalArgumentException e = thrown(IllegalArgumentException) + e.getMessage() == "Detected potential cyclic reference" + } + + def "should throw error on missing reference even when using printing unresolved message handler"() { + when: + I18nMessagePack.builder() + .addMessage(EN, "msg", ">> \${missing.value} <<") + .setMissingMessageHandler(I18nMissingMessageHandler.pathPrintingHandler()) + .build() + then: + I18nMessagesException e = thrown(I18nMessagesException) + e.getMessage() == "Missing reference: missing.value" + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/ResolveMessageReferenceSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/ResolveMessageReferenceSpec.groovy deleted file mode 100755 index 028eb4d..0000000 --- a/src/test/groovy/com/coditory/quark/i18n/ResolveMessageReferenceSpec.groovy +++ /dev/null @@ -1,159 +0,0 @@ -package com.coditory.quark.i18n - -import com.coditory.quark.i18n.base.InMemI18nLoader -import spock.lang.Specification - -import static com.coditory.quark.i18n.Locales.EN -import static com.coditory.quark.i18n.Locales.EN_GB -import static com.coditory.quark.i18n.Locales.EN_US -import static com.coditory.quark.i18n.Locales.PL -import static com.coditory.quark.i18n.Locales.PL_PL - -class ResolveMessageReferenceSpec extends Specification { - def "should resolve message reference"() { - when: - String message = I18nMessagePack.builder() - .addMessage(EN, "msg", "\$company.name was established on 1988") - .addMessage(EN, "company.name", "ACME") - .build() - .getMessage(EN, "msg") - then: - message == "ACME was established on 1988" - } - - def "should resolve a message with a transitive reference"() { - when: - String message = I18nMessagePack.builder() - .addMessage(EN, "msg", "\$company.name1 was established on 1988") - .addMessage(EN, "company.name1", ">\${company.name2}<") - .addMessage(EN, "company.name2", ">\${company.name3}<") - .addMessage(EN, "company.name3", "ACME") - .build() - .getMessage(EN, "msg") - then: - message == ">>ACME<< was established on 1988" - } - - def "should resolve a message with a reference in braces and without them"() { - when: - String message = I18nMessagePack.builder() - .addMessage(EN, "msg", "a: \${a}, b: \${ b }, c: \$c") - .addMessage(EN, "a", "A") - .addMessage(EN, "b", "B") - .addMessage(EN, "c", "C") - .build() - .getMessage(EN, "msg") - then: - message == "a: A, b: B, c: C" - } - - def "should throw error on unresolvable reference"() { - when: - I18nMessagePack.builder() - .addMessage(EN, "msg", "\$company.name was established on 1988") - .build() - then: - IllegalArgumentException e = thrown(IllegalArgumentException) - e.getMessage() == "Reference not found: company.name" - } - - def "should throw error on cyclic reference"() { - when: - I18nMessagePack.builder() - .addMessage(EN, "msg", "\$company.name1 was established on 1988") - .addMessage(EN, "company.name1", "\${company.name2}") - .addMessage(EN, "company.name2", "\${company.name3}") - .addMessage(EN, "company.name3", "\${company.name1}") - .build() - then: - IllegalArgumentException e = thrown(IllegalArgumentException) - e.getMessage() == "Detected potential cyclic reference" - } - - def "should throw error on a reference to itself"() { - when: - I18nMessagePack.builder() - .addMessage(EN, "msg", "\$msg") - .build() - then: - IllegalArgumentException e = thrown(IllegalArgumentException) - e.getMessage() == "Detected potential cyclic reference" - } - - def "should resolve references using fallback locales"() { - when: - String message = I18nMessagePack.builder() - .addMessage(EN_US, "msg", "a: \${a}, b: \${b}, c: \${c}, d: \${d}") - .addMessage(EN_US, "a", "a-en_US") - // EN - .addMessage(EN, "a", "a-en") - .addMessage(EN, "b", "b-en") - // PL_PL - default - .setDefaultLocale(PL_PL) - .addMessage(PL_PL, "a", "a-pl_PL") - .addMessage(PL_PL, "b", "b-pl_PL") - .addMessage(PL_PL, "c", "c-pl_PL") - // PL - default's parent - .addMessage(PL, "a", "a-pl") - .addMessage(PL, "b", "b-pl") - .addMessage(PL, "c", "c-pl") - .addMessage(PL, "d", "d-pl") - // EN_GB - control group - should not be used - .addMessage(EN_GB, "a", "a-en_GB") - .addMessage(EN_GB, "b", "b-en_GB") - .addMessage(EN_GB, "c", "c-en_GB") - .build() - .getMessage(EN_US, "msg") - then: - message == "a: a-en_US, b: b-en, c: c-pl_PL, d: d-pl" - } - - def "should resolve references using fallback key prefix"() { - when: - String message = I18nMessagePack.builder() - .addMessage(EN_US, "msg", "a: \${a}, b: \${b}, c: \${c}, d: \${d}, e: \${e}") - .addMessage(EN_US, "common.a", "common.a-en_US") - .addMessage(EN_US, "common.b", "common.b-en_US") - // EN - .addMessage(EN, "a", "a-en") - .addMessage(EN, "common.a", "common.a-en") - .addMessage(EN, "common.b", "common.b-en") - .addMessage(EN, "common.c", "common.c-en") - // PL - default's parent - .addMessage(PL, "a", "a-pl") - .addMessage(PL, "b", "b-pl") - .addMessage(PL, "c", "c-pl") - .addMessage(PL, "d", "d-pl") - .addMessage(PL, "common.a", "common.a-pl") - .addMessage(PL, "common.b", "common.b-pl") - .addMessage(PL, "common.c", "common.c-pl") - .addMessage(PL, "common.d", "common.d-pl") - .addMessage(PL, "common.e", "common.e-pl") - // common settings - .setDefaultLocale(PL_PL) - .addFallbackKeyPrefix("common") - .build() - .getMessage(EN_US, "msg") - then: - message == "a: a-en, b: common.b-en_US, c: common.c-en, d: d-pl, e: common.e-pl" - } - - def "should prefix references from a loaded bundle"() { - when: - String message = I18nMessagePack.builder() - .addMessage(EN_US, "a", "a-root-en_US") - .addMessage(EN_US, "b", "b-root-en_US") - .addMessage(EN_US, "c", "c-root-en_US") - .addMessage(EN_US, "d", "d-en_US") - .addMessage(EN_US, "loaded.d", "d2-en_US") - .addLoader(InMemI18nLoader.of([ - (I18nKey.of(EN_US, "msg")): "a: \$a, b: \$b, c: \$c, d: \$d", - (I18nKey.of(EN_US, "a")) : "a-loaded-en_US", - (I18nKey.of(EN, "b")) : "b-loaded-en" - ], I18nPath.of("loaded"))) - .build() - .getMessage(EN_US, "loaded.msg") - then: - message == "a: a-loaded-en_US, b: b-loaded-en, c: c-root-en_US, d: d2-en_US" - } -} diff --git a/src/test/groovy/com/coditory/quark/i18n/ResolveMessageSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/ResolveMessageSpec.groovy deleted file mode 100755 index 678a3c2..0000000 --- a/src/test/groovy/com/coditory/quark/i18n/ResolveMessageSpec.groovy +++ /dev/null @@ -1,100 +0,0 @@ -package com.coditory.quark.i18n - -import spock.lang.Specification -import spock.lang.Unroll - -import static com.coditory.quark.i18n.Locales.DE -import static com.coditory.quark.i18n.Locales.EN -import static com.coditory.quark.i18n.Locales.EN_GB -import static com.coditory.quark.i18n.Locales.EN_US -import static com.coditory.quark.i18n.Locales.PL -import static com.coditory.quark.i18n.Locales.PL_PL - -class ResolveMessageSpec extends Specification { - @Unroll - def "should resolve message for locale: #locale"() { - given: - I18nMessages messages = I18nMessagePack.builder() - .setDefaultLocale(EN) - .addMessage(PL, "hello", "Cześć") - .addMessage(EN, "hello", "Hello") - .addMessage(EN_US, "hello", "Hello - US") - .addMessage(EN_GB, "hello", "Hello - GB") - .build() - .localize(locale) - when: - String result = messages.getMessage("hello") - then: - result == expected - - where: - locale | expected - PL | "Cześć" - PL_PL | "Cześć" - DE | "Hello" - EN | "Hello" - EN_US | "Hello - US" - EN_GB | "Hello - GB" - Locale.forLanguageTag("en-XX") | "Hello" - } - - def "should throw error for missing translation"() { - given: - I18nMessages messages = I18nMessagePack.builder() - .setDefaultLocale(EN) - .addMessage(PL, "home.hello", "Witamy") - .addMessage(EN, "home.bye", "Bye") - .build() - .localize(PL) - when: - messages.getMessage("home.xxx") - then: - I18nMessagesException e = thrown(I18nMessagesException) - e.message == "Missing message pl:home.xxx" - - when: - messages.getMessage("home.xxx", 123, "xyz") - then: - e = thrown(I18nMessagesException) - e.message == "Missing message pl:home.xxx(123, xyz)" - } - - def "should throw error for missing translation and no default locale"() { - given: - I18nMessages messages = I18nMessagePack.builder() - .addMessage(PL, "home.hello", "Witamy") - .addMessage(EN, "home.bye", "Bye") - .build() - .localize(PL) - when: - messages.getMessage("home.bye") - then: - I18nMessagesException e = thrown(I18nMessagesException) - e.message == "Missing message pl:home.bye" - - when: - messages.getMessage("home.bye", 123, "xyz") - then: - e = thrown(I18nMessagesException) - e.message == "Missing message pl:home.bye(123, xyz)" - } - - def "should return message with two string arguments"() { - given: - I18nMessages messages = I18nMessagePack.builder() - .setDefaultLocale(EN) - .addMessage(PL, "hello", "Witaj {0} {1}") - .addMessage(EN, "bye", "Bye {0} {1}") - .build() - .localize(PL) - when: - String result = messages.getMessage("hello", "Jan", "Kowalski") - then: - result == "Witaj Jan Kowalski" - - when: - result = messages.getMessage("bye", "John", "Doe") - then: - result == "Bye John Doe" - } -} diff --git a/src/test/groovy/com/coditory/quark/i18n/TypeBasedArgFormattingSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/TypeBasedArgFormattingSpec.groovy new file mode 100755 index 0000000..0e24bb6 --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/TypeBasedArgFormattingSpec.groovy @@ -0,0 +1,113 @@ +package com.coditory.quark.i18n + +import spock.lang.Specification + +import static com.coditory.quark.i18n.Locales.EN + +class TypeBasedArgFormattingSpec extends Specification { + def "should transform message argument by type"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "{0}") + .addArgumentTransformer(Foo, { ">>${it.value}<<" }) + .buildLocalized(EN) + when: + String result = messages.getMessage("msg", new Foo("abc")) + then: + result == ">>abc<<" + } + + def "should transform argument transitively"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "{0} {1}") + .addArgumentTransformer(Foo, { new Bar(it.value) }) + .addArgumentTransformer(Bar, { ">>${it.value}<<" }) + .buildLocalized(EN) + when: + String result = messages.getMessage("msg", new Foo("abc"), new Bar("def")) + then: + result == ">>abc<< >>def<<" + } + + def "should format transformed argument"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "{0,number,00000.00000}") + .addArgumentTransformer(Foo, { Double.parseDouble(it.value) }) + .buildLocalized(EN) + when: + String result = messages.getMessage("msg", new Foo("123.456")) + then: + result == "00123.45600" + } + + def "should propagate error on message transformation"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "{0}") + .addArgumentTransformer(Foo, { throw new RuntimeException("Simulated") }) + .buildLocalized(EN) + when: + messages.getMessage("msg", new Foo("123.456")) + then: + IllegalArgumentException e = thrown(IllegalArgumentException) + e.getMessage().startsWith("Could not resolve message en:msg=\"{0}\"") + e.getCause().getMessage() == "Could not transform argument: 0=Foo(123.456)" + e.getCause().getCause().getMessage() == "Simulated" + } + + def "should not transform unused argument"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "{0}") + .addArgumentTransformer(Foo, { throw new RuntimeException("should not be executed") }) + .buildLocalized(EN) + when: + String message = messages.getMessage("msg", "used", new Foo("unused")) + then: + message == "used" + } + + def "should throw error on cyclic transformation"() { + given: + I18nMessages messages = I18nMessagePack.builder() + .addMessage(EN, "msg", "{0}") + .addArgumentTransformer(Foo, { new Bar(it.value) }) + .addArgumentTransformer(Bar, { new Foo(it.value) }) + .buildLocalized(EN) + when: + messages.getMessage("msg", new Foo("unused")) + then: + IllegalArgumentException e = thrown(IllegalArgumentException) + e.getMessage().startsWith("Could not resolve message en:msg=\"{0}\"") + e.getCause().getMessage().startsWith("Could not transform argument: 0=Foo(unused)") + e.getCause().getCause().getMessage() == "Too many argument transformations" + } + + class Foo { + final String value + + Foo(String value) { + this.value = value + } + + @Override + String toString() { + return "Foo($value)" + } + } + + class Bar { + final String value + + Bar(String value) { + this.value = value + } + + @Override + String toString() { + return "Bar($value)" + } + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/base/CapturingAppender.groovy b/src/test/groovy/com/coditory/quark/i18n/base/CapturingAppender.groovy new file mode 100644 index 0000000..5c68ccd --- /dev/null +++ b/src/test/groovy/com/coditory/quark/i18n/base/CapturingAppender.groovy @@ -0,0 +1,102 @@ +package com.coditory.quark.i18n.base + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.spi.LoggingEvent +import ch.qos.logback.core.AppenderBase +import org.slf4j.Marker +import spock.util.concurrent.BlockingVariable + +import java.util.function.Predicate + +class CapturingAppender extends AppenderBase { + private int loggerCalls = 0 + private final List capturedEvents = new ArrayList<>() + private final BlockingVariable called = new BlockingVariable(0.2) // 200ms + + CapturingAppender() { + start() + } + + @Override + protected void append(LoggingEvent event) { + capturedEvents.add(event) + called.set(true) + } + + void waitForFirstLog() { + called.get() + } + + synchronized int getLoggerCalls() { + return loggerCalls + } + + synchronized void reset() { + capturedEvents.clear() + } + + synchronized List getLogsByMessagePrefix(String prefix) { + return capturedEvents + .findAll { it.message.startsWith(prefix) } + .collect { it.toString() } + } + + synchronized int capturedEvents() { + return capturedEvents.size() + } + + synchronized int countCapturedEvents(Predicate predicate) { + return capturedEvents.count { predicate.test(it) } + } + + synchronized int countCapturedEvents(String message) { + return capturedEvents.count { it.message == message } + } + + synchronized int countCapturedEvents(String message, List params) { + return capturedEvents.count { + it.message == message && + it.argumentArray == params.toArray() + } + } + + synchronized int countCapturedEvents(Level level) { + return capturedEvents.count { it.level == level } + } + + synchronized int countCapturedEvents(String message, List params, String exceptionMessage) { + return capturedEvents.count { + it.message == message && + it.argumentArray == params.toArray() && + it.throwableProxy.message == exceptionMessage + } + } + + synchronized int countCapturedEvents(Marker marker) { + return capturedEvents.count { it.marker == marker } + } + + synchronized int countCapturedEvents(Level level, String message, List params, String exceptionMessage) { + return capturedEvents.count { + it.level == level && + it.message == message && + it.argumentArray == params?.toArray() && + it.throwableProxy.message == exceptionMessage + } + } + + synchronized int countCapturedEvents(Level level, String message, List params) { + return capturedEvents.count { + it.level == level && + it.message == message && + it.argumentArray == params.toArray() + } + } + + synchronized int countCapturedEvents(Level level, String message) { + return capturedEvents.count { + it.level == level && + it.message == message + } + } +} diff --git a/src/test/groovy/com/coditory/quark/i18n/base/InMemI18nLoader.groovy b/src/test/groovy/com/coditory/quark/i18n/base/InMemI18nLoader.groovy index 2a3464b..62bd9d1 100644 --- a/src/test/groovy/com/coditory/quark/i18n/base/InMemI18nLoader.groovy +++ b/src/test/groovy/com/coditory/quark/i18n/base/InMemI18nLoader.groovy @@ -3,23 +3,23 @@ package com.coditory.quark.i18n.base import com.coditory.quark.i18n.I18nKey import com.coditory.quark.i18n.I18nPath import com.coditory.quark.i18n.loader.I18nLoader -import com.coditory.quark.i18n.loader.I18nTemplatesBundle +import com.coditory.quark.i18n.loader.I18nMessageBundle class InMemI18nLoader implements I18nLoader { static final InMemI18nLoader of(Map entries, I18nPath prefix) { - List templates = [new I18nTemplatesBundle(entries, prefix)] + List templates = [new I18nMessageBundle(entries, prefix)] return new InMemI18nLoader(templates) } - private final List result + private final List result private int executed = 0 - InMemI18nLoader(List result) { + InMemI18nLoader(List result) { this.result = result } @Override - List load() { + List load() { return result } diff --git a/src/test/groovy/com/coditory/quark/i18n/formats/IcuDateFormatSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/formats/IcuDateFormatSpec.groovy index 96017bd..601a665 100644 --- a/src/test/groovy/com/coditory/quark/i18n/formats/IcuDateFormatSpec.groovy +++ b/src/test/groovy/com/coditory/quark/i18n/formats/IcuDateFormatSpec.groovy @@ -25,7 +25,7 @@ class IcuDateFormatSpec extends Specification { static final LocalDate localDate = LocalDate.ofInstant(instant, ZoneOffset.UTC) void setup() { - I18nSystemDefaults.setupNormalized() + I18nSystemDefaults.setupGmtAndEnUsAsDefaults() } @Unroll diff --git a/src/test/groovy/com/coditory/quark/i18n/formats/IcuRuleBasedFormatSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/formats/IcuRuleBasedFormatSpec.groovy index b500b4a..692d07d 100644 --- a/src/test/groovy/com/coditory/quark/i18n/formats/IcuRuleBasedFormatSpec.groovy +++ b/src/test/groovy/com/coditory/quark/i18n/formats/IcuRuleBasedFormatSpec.groovy @@ -6,8 +6,6 @@ import com.coditory.quark.i18n.I18nSystemDefaults import spock.lang.Specification import spock.lang.Unroll -import java.time.Instant - import static com.coditory.quark.i18n.Locales.EN_US import static com.coditory.quark.i18n.Locales.PL_PL @@ -15,7 +13,7 @@ class IcuRuleBasedFormatSpec extends Specification { static final I18nMessagePack messages = I18nMessagePackFactory.emptyMessagePack() void setup() { - I18nSystemDefaults.setupNormalized() + I18nSystemDefaults.setupGmtAndEnUsAsDefaults() } @Unroll diff --git a/src/test/groovy/com/coditory/quark/i18n/formats/IcuSelectFormatSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/formats/IcuSelectFormatSpec.groovy index 41fe9e1..409c019 100644 --- a/src/test/groovy/com/coditory/quark/i18n/formats/IcuSelectFormatSpec.groovy +++ b/src/test/groovy/com/coditory/quark/i18n/formats/IcuSelectFormatSpec.groovy @@ -18,14 +18,14 @@ class IcuSelectFormatSpec extends Specification { female {She} other {They} } sent you a message - """.stripIndent()) + """.stripIndent().trim()) .addMessage(PL_PL, key, """ {0, select, male {On wysłał} female {Ona wysłała} other {Oni wysłali} } ci wiadomość - """.stripIndent()) + """.stripIndent().trim()) .build() .localize(locale) when: diff --git a/src/test/groovy/com/coditory/quark/i18n/formats/IcuTimeFormatSpec.groovy b/src/test/groovy/com/coditory/quark/i18n/formats/IcuTimeFormatSpec.groovy index f24d1cf..ca667da 100644 --- a/src/test/groovy/com/coditory/quark/i18n/formats/IcuTimeFormatSpec.groovy +++ b/src/test/groovy/com/coditory/quark/i18n/formats/IcuTimeFormatSpec.groovy @@ -16,7 +16,7 @@ class IcuTimeFormatSpec extends Specification { static final Instant instant = Instant.parse("2007-12-03T10:15:30.00Z") void setup() { - I18nSystemDefaults.setupNormalized() + I18nSystemDefaults.setupGmtAndEnUsAsDefaults() } @Unroll