Skip to content

LocaleFileChecker

Basti edited this page Sep 27, 2026 · 1 revision

LocaleFileChecker checks your plugin's bundled lang files in a unit test: every locale against the base locale, every MiniMessage tag, and, if you like, the keys your code uses against the keys the base locale defines. It doesn't assert anything itself; it returns a list of readable findings, and every finding should fail your test.

Why Use It? 🎯

  • A missing key, a renamed placeholder or an unclosed tag in one of many translations fails the build instead of showing up in-game.
  • Keys left over after a refactoring, and keys the code asks for but no lang file defines, are found without anyone reading the files side by side.
  • Two plugins share one checker instead of two copies of the same test.

Highlights ✨

  • Every locale must have exactly the keys of the base locale, no more and no fewer. By default that's the one LangLoader falls back to: en_US if the folder has it, else the alphabetically first locale file.
  • Every key must use the same set of tags as in the base locale, so a translator can't drop or rename <count>.
  • Every tag must be a standard MiniMessage tag or one of your placeholders; anything else would be printed literally. That includes misspelled names with - or . such as <dark-red> or <red.bold>; hex colors (<#FF8800>) and negations (<!italic>) are valid.
  • Every value is parsed by MiniMessage in strict mode, so unclosed or mismatched tags (<green>Bye, <red>…</green>) are found.
  • A value identical to the base locale counts as untranslated, unless its key is on the identicalAllowed list (shared terms, lines that are only placeholders). ui.prefix is allowed by default.
  • Optional usage check in both directions: keys used in code but missing in the base locale, and base keys the code never uses.
  • Broken or missing files become findings too, so one bad locale doesn't hide the checks for the others. Without a usable base locale, the others are still checked for unknown tags and valid MiniMessage.

Limitations ⚠️

  • Bundled files, at test time: lang files that admins edit on a server are never checked.
  • Which files count: every *.yml directly in the folder must be named like LangLoader's locales (xx_YY, e.g. de_DE.yml); any other (de_de.yml, README.yml) is an INVALID_FILE finding, since LangLoader never loads it. Lang files must be UTF-8, although LangLoader also reads Windows-1252 at runtime.
  • Base locale: chosen like LangLoader's (en_US, else the alphabetically first file named like a locale) unless you pass baseLocale(...). A missing, broken or empty base locale is one finding; the other locales then get only the tag and MiniMessage checks, no comparison and no usage check.
  • Usage scan, sources: only .java files, read line by line as UTF-8. One source file that isn't UTF-8 stops the scan of that folder, and every key then shows up as UNUSED_KEY.
  • Usage scan, literals: only complete double-quoted literals on one line are found. A top-level key (greeting: Hello) counts as used wherever its name appears as a literal of its own ("greeting"), even when that literal means something else. Keys built with String.format, from constants or in text blocks are missed (list them in dynamicKeys). Literals in comments and commented-out code count as used, and one composed literal (see below) marks everything under it as used. Other literals that start with a lang section name ("log.txt") show up as KEY_NOT_IN_BASE; list them in ignoreLiterals.
  • Usage scan, sections: only top-level sections of the base locale are scanned, so code that uses a section the base file lacks entirely isn't reported. Renames are recognized per line: a rename map spread over several lines counts its new keys as used.
  • Tags: compared as a set of names per key. Order, count and tag arguments (click commands, hover texts, gradient colors) aren't compared.
  • Placeholders: one list for all keys, so it can't tell that a key uses a placeholder the calling code never passes. A placeholder name Adventure doesn't accept (e.g. playerName) makes check() throw.
  • Strict parse: tag names are compared in any case (<RED> = <red>), as MiniMessage reads them. In a value with <reset>, only the text after the last <reset> gets the strict parse, so a mismatched closing tag before it isn't found. A value with an unknown tag skips the strict parse, so its other errors show up once the tag is fixed.
  • Untranslated check: plain text equality, so regional variants such as en_GB next to en_US need long identicalAllowed lists.

Quick Start πŸš€

  1. Add a test in your plugin (src/test/java), with Paper API and JUnit on the test classpath as usual.
  2. Build a checker for src/main/resources/lang, pass your placeholders and, for the usage check, your source folder.
  3. Assert that check().hasProblems() is false and use describeProblems() as the failure message.

References πŸ“š

Method Purpose
LocaleFileChecker.builder(Path langDir) Starts a checker for the *.yml files in langDir, e.g. Path.of("src/main/resources/lang") (Gradle runs tests in the project folder).
baseLocale(String) The locale all others are compared with. Default: like LangLoader, en_US if the folder has it, else the alphabetically first locale file.
placeholders(String...) / (Collection) Placeholder tags your plugin resolves. prefix and prefix_label (LIBRARY_PLACEHOLDERS) are always included.
scanSources(Path sourceRoot) Collects used keys from string literals in the .java files below sourceRoot and turns on the usage check.
usedKeys(Collection) Adds used keys you collected yourself and turns on the usage check.
dynamicKeys(String...) / (Collection) Base keys your code builds at runtime, counted as used. * matches any text, e.g. toggle.*.
ignoreLiterals(String...) / (Collection) Literals that look like lang keys but aren't, e.g. the old keys of your rename map. * matches any text.
renames(Map<String, String> oldToNew) Your rename map. Its old keys count as ignored literals, and a new key only named next to its old key (as in the map's own Map.entry line) doesn't count as used, so a renamed key the code no longer reads shows up as UNUSED_KEY.
identicalAllowed(String...) / (Collection) Keys whose value may equal the base locale, e.g. command.version. * matches any text. ui.prefix (DEFAULT_IDENTICAL_ALLOWED) is always included.
check() Runs all checks and returns a Result. It never throws for broken or missing files; an invalid placeholder name throws.
collectKeyLiterals(Path, Collection sections) Static: string literals that start with one of sections plus a dot, mapped to the first file that contains them. Unlike scanSources, it doesn't count literals that are a whole top-level key and doesn't apply the renames rule.
Result member Contents
findings() All findings in a stable order.
hasProblems() true if there is at least one finding.
ofKind(Kind) Findings of one kind, e.g. to allow some of them.
describeProblems() One finding per line, e.g. de_DE.yml: 'log.loaded' is missing (present in en_US.yml).

Each Finding(kind, file, key, detail) names the lang file (or the source folder for a scan error), the key (empty for a whole file) and what is wrong. The kinds:

Kind Meaning
INVALID_FILE A lang file isn't valid UTF-8 YAML or isn't named like a locale (xx_YY), or the lang folder, base locale or source folder is missing.
MISSING_KEY A locale lacks a key of the base locale.
UNKNOWN_KEY A locale has a key the base locale doesn't have.
TAG_MISMATCH A key uses a different set of tags than in the base locale (hex colors and negations may differ).
UNKNOWN_TAG A tag is neither a standard MiniMessage tag nor a known placeholder.
INVALID_MINIMESSAGE The strict MiniMessage parse failed, e.g. an unclosed or mismatched tag.
KEY_NOT_IN_BASE The code uses a key the base locale doesn't define (usage check).
UNUSED_KEY A base key isn't used anywhere in the code (usage check).
UNTRANSLATED A value is identical to the base locale and its key isn't on the identicalAllowed list.

How the Usage Check Finds Keys πŸ”

scanSources looks for string literals that start with one of the base locale's top-level sections plus a dot, e.g. "log.update_found" when en_US.yml has a log: section, and for literals that are exactly a top-level key ("greeting"). Config keys from other sections ("categories.logs") are ignored that way.

  • A literal that ends in ., _ or - ("log.step_" + step), or that names a whole section of the base locale, counts as the start of a composed key: every base key starting with it counts as used.
  • Keys built entirely at runtime go into dynamicKeys.
  • Pass your rename map (Map.entry("log.old-key", "log.new_key")) to renames(...): its old keys don't count as missing, and its new keys only count as used where the code reads them outside the map.
  • LIBRARY_KEYS holds the lang keys TurtleLib reads itself (currently ui.prefix, read by MessageService); they always count as used.

Examples πŸ§ͺ

import net.kroet.turtlelib.helper.LocaleFileChecker;

class LocaleConsistencyTest {

    @Test
    void langFilesAreConsistent() {
        LocaleFileChecker.Result result = LocaleFileChecker.builder(Path.of("src/main/resources/lang"))
            .placeholders("count", "file", "player", "version")
            .scanSources(Path.of("src/main/java"))
            .renames(MyPlugin.LEGACY_KEY_MIGRATIONS)
            .identicalAllowed("command.version", "log.*_line")
            .build()
            .check();

        assertFalse(result.hasProblems(), "Locale problems:\n" + result.describeProblems());
    }
}

Tips & Notes πŸ’‘

  • The checker ships in the TurtleLib jar but only needs Paper API and Adventure, which your test classpath already has; no JUnit dependency. Keep it out of your shaded plugin jar with exclude 'net/kroet/turtlelib/helper/LocaleFileChecker*' in the shadowJar block.
  • Strict mode requires tags to be closed explicitly (<green>Bye</green>) or by a <reset>. Self-closing tags such as <newline> and your placeholders need no closing tag.

Relationships to Other Helpers πŸ”—

  • MessageService renders the messages this helper checks and resolves prefix and prefix_label in all of them.
  • LangLoader ships the same lang files into the plugin's data folder.

Clone this wiki locally