diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b38e6f8..0772569b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made `OuterClassNamePropagator` configurable - Made Enigma writer always output destination names if visited explicitly, establishing consistency across all writers - Added a simplified `MappingNsCompleter` constructor for completing all destination names with the source names +- Added `MappingFormat#translatableName()` ## [0.7.1] - 2025-01-07 - Restored the ability to read source-namespace-only mapping files, even if not spec-compliant diff --git a/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java b/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java index b281ce36..9b7c0682 100644 --- a/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java +++ b/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java @@ -16,11 +16,14 @@ package net.fabricmc.mappingio.format; +import java.util.Locale; + import org.jetbrains.annotations.Nullable; import net.fabricmc.mappingio.format.FeatureSet.ElementCommentSupport; import net.fabricmc.mappingio.format.FeatureSet.FeaturePresence; import net.fabricmc.mappingio.format.FeatureSet.MetadataSupport; +import net.fabricmc.mappingio.i18n.Translatable; /** * Represents a supported mapping format. Every format can be assumed to have an associated reader available. @@ -34,7 +37,7 @@ public enum MappingFormat { * * @implNote File metadata only has limited support as of now, and is hardcoded to intermediary counters. */ - TINY_FILE("Tiny file", "tiny", true, FeatureSetBuilder.create() + TINY_FILE("tiny", true, FeatureSetBuilder.create() .withNamespaces(true) .withFileMetadata(MetadataSupport.FIXED) // TODO: change this to ARBITRARY once https://github.com/FabricMC/mapping-io/pull/29 is merged .withClasses(c -> c @@ -54,7 +57,7 @@ public enum MappingFormat { /** * The {@code Tiny v2} mapping format, as specified here. */ - TINY_2_FILE("Tiny v2 file", "tiny", true, FeatureSetBuilder.create() + TINY_2_FILE("tiny", true, FeatureSetBuilder.create() .withNamespaces(true) .withFileMetadata(MetadataSupport.ARBITRARY) .withClasses(c -> c @@ -87,7 +90,7 @@ public enum MappingFormat { * * @implNote Access modifiers are currently not supported. */ - ENIGMA_FILE("Enigma file", "mapping", true, FeatureSetBuilder.create() + ENIGMA_FILE("mapping", true, FeatureSetBuilder.create() .withElementMetadata(MetadataSupport.FIXED) // access modifiers .withClasses(c -> c .withSrcNames(FeaturePresence.REQUIRED) @@ -112,14 +115,14 @@ public enum MappingFormat { * * @implNote Access modifiers are currently not supported. */ - ENIGMA_DIR("Enigma directory", null, true, FeatureSetBuilder.createFrom(ENIGMA_FILE.features)), + ENIGMA_DIR(null, true, FeatureSetBuilder.createFrom(ENIGMA_FILE.features)), /** * ProGuard's mapping format, as specified here. * * @implNote Line numbers are currently not supported. */ - PROGUARD_FILE("ProGuard file", "txt", true, FeatureSetBuilder.create() + PROGUARD_FILE("txt", true, FeatureSetBuilder.create() .withElementMetadata(MetadataSupport.FIXED) // line numbers .withClasses(c -> c .withSrcNames(FeaturePresence.REQUIRED) @@ -140,7 +143,7 @@ public enum MappingFormat { * * @implNote Package mappings are currently not supported. */ - SRG_FILE("SRG file", "srg", true, FeatureSetBuilder.create() + SRG_FILE("srg", true, FeatureSetBuilder.create() .withPackages(p -> p .withSrcNames(FeaturePresence.REQUIRED) .withDstNames(FeaturePresence.REQUIRED)) @@ -165,7 +168,7 @@ public enum MappingFormat { * * @implNote Package mappings are currently not supported. */ - XSRG_FILE("XSRG file", "xsrg", true, FeatureSetBuilder.createFrom(SRG_FILE.features) + XSRG_FILE("xsrg", true, FeatureSetBuilder.createFrom(SRG_FILE.features) .withFields(f -> f .withSrcDescs(FeaturePresence.REQUIRED) .withDstDescs(FeaturePresence.REQUIRED))), @@ -173,7 +176,7 @@ public enum MappingFormat { /** * The {@code JAM} ("Java Associated Mapping"; formerly {@code SRGX}) mapping format, as specified here. */ - JAM_FILE("JAM file", "jam", true, FeatureSetBuilder.createFrom(SRG_FILE.features) + JAM_FILE("jam", true, FeatureSetBuilder.createFrom(SRG_FILE.features) .withPackages(p -> p .withSrcNames(FeaturePresence.ABSENT) .withDstNames(FeaturePresence.ABSENT)) @@ -191,7 +194,7 @@ public enum MappingFormat { * * @implNote Package mappings are currently not supported. */ - CSRG_FILE("CSRG file", "csrg", true, FeatureSetBuilder.createFrom(SRG_FILE.features) + CSRG_FILE("csrg", true, FeatureSetBuilder.createFrom(SRG_FILE.features) .withMethods(m -> m .withDstDescs(FeaturePresence.ABSENT))), @@ -202,14 +205,14 @@ public enum MappingFormat { * * @implNote Package mappings are currently not supported. */ - TSRG_FILE("TSRG file", "tsrg", true, FeatureSetBuilder.createFrom(CSRG_FILE.features)), + TSRG_FILE("tsrg", true, FeatureSetBuilder.createFrom(CSRG_FILE.features)), /** * The {@code TSRG v2} mapping format, as specified here. * * @implNote Package mappings and static markers for methods are currently not supported. */ - TSRG_2_FILE("TSRG v2 file", "tsrg", true, FeatureSetBuilder.createFrom(TSRG_FILE.features) + TSRG_2_FILE("tsrg", true, FeatureSetBuilder.createFrom(TSRG_FILE.features) .withNamespaces(true) .withElementMetadata(MetadataSupport.FIXED) // static info for methods .withFields(f -> f @@ -224,7 +227,7 @@ public enum MappingFormat { * * @implNote Package mappings and file metadata are currently not supported. */ - INTELLIJ_MIGRATION_MAP_FILE("IntelliJ migration map file", "xml", true, FeatureSetBuilder.create() + INTELLIJ_MIGRATION_MAP_FILE("xml", true, FeatureSetBuilder.create() .withFileMetadata(MetadataSupport.FIXED) // migration map name and description .withPackages(p -> p .withSrcNames(FeaturePresence.REQUIRED) @@ -238,7 +241,7 @@ public enum MappingFormat { /** * Recaf's {@code Simple} mapping format, as specified here. */ - RECAF_SIMPLE_FILE("Recaf Simple file", "txt", true, FeatureSetBuilder.create() + RECAF_SIMPLE_FILE("txt", true, FeatureSetBuilder.create() .withClasses(c -> c .withSrcNames(FeaturePresence.REQUIRED) .withDstNames(FeaturePresence.REQUIRED) @@ -258,7 +261,7 @@ public enum MappingFormat { * * @implNote Package mappings are currently not supported. */ - JOBF_FILE("JOBF file", "jobf", true, FeatureSetBuilder.create() + JOBF_FILE("jobf", true, FeatureSetBuilder.create() .withPackages(p -> p .withSrcNames(FeaturePresence.REQUIRED) .withDstNames(FeaturePresence.REQUIRED)) @@ -275,8 +278,9 @@ public enum MappingFormat { .withSrcDescs(FeaturePresence.REQUIRED)) .withFileComments(true)); - MappingFormat(String name, @Nullable String fileExt, boolean hasWriter, FeatureSetBuilder featureBuilder) { - this.name = name; + MappingFormat(@Nullable String fileExt, boolean hasWriter, FeatureSetBuilder featureBuilder) { + this.translationKey = "format." + name().toLowerCase(Locale.ROOT); + this.name = translatableName().translate(Locale.US); this.fileExt = fileExt; this.hasWriter = hasWriter; this.features = featureBuilder.build(); @@ -287,6 +291,10 @@ public enum MappingFormat { this.supportsLocals = features.supportsVars(); } + public Translatable translatableName() { + return Translatable.of(translationKey); + } + public FeatureSet features() { return features; } @@ -301,8 +309,15 @@ public String getGlobPattern() { return "*."+fileExt; } + private final String translationKey; private final FeatureSet features; + + /** + * @deprecated Use {@link #translatableName()} instead. + */ + @Deprecated public final String name; + public final boolean hasWriter; @Nullable public final String fileExt; diff --git a/src/main/java/net/fabricmc/mappingio/i18n/I18n.java b/src/main/java/net/fabricmc/mappingio/i18n/I18n.java new file mode 100644 index 00000000..45f137d6 --- /dev/null +++ b/src/main/java/net/fabricmc/mappingio/i18n/I18n.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2023 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.mappingio.i18n; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.Reader; +import java.io.StringWriter; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.PropertyResourceBundle; +import java.util.ResourceBundle; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class I18n { + private I18n() { + } + + public static String translate(String key, Locale locale, Object... args) { + return String.format(translate(key, locale), args); + } + + public static String translate(String key, Locale locale) { + try { + return load(locale).getString(key); + } catch (Exception e) { + System.err.println("Exception while translating key " + key + " to locale " + locale.toLanguageTag() + ": " + getStackTrace(e)); + if (locale == fallbackLocale) return key; + + try { + return load(fallbackLocale).getString(key); + } catch (Exception e2) { + System.err.println("Exception while translating key " + key + " to fallback locale: " + getStackTrace(e2)); + return key; + } + } + } + + private static ResourceBundle load(Locale locale) { + ResourceBundle bundle = messageBundles.get(locale); + + if (bundle != null) { + return bundle; + } + + bundlesLock.lock(); + + try { + if ((bundle = messageBundles.get(locale)) != null) { + return bundle; + } + + return load0(locale); + } finally { + bundlesLock.unlock(); + } + } + + private static ResourceBundle load0(Locale locale) { + ResourceBundle resBundle; + String resName = String.format("/mappingio/lang/%s.properties", locale.toLanguageTag().replace('-', '_').toLowerCase(Locale.ROOT)); + URL resUrl = I18n.class.getResource(resName); + + if (resUrl == null) { + throw new RuntimeException("Locale resource not found: " + resName); + } + + try (Reader reader = new InputStreamReader(resUrl.openStream(), StandardCharsets.UTF_8)) { + resBundle = new PropertyResourceBundle(reader); + messageBundles.put(locale, resBundle); + return resBundle; + } catch (IOException e) { + throw new RuntimeException("Failed to load " + resName, e); + } + } + + private static String getStackTrace(Throwable t) { + if (t == null) return null; + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + t.printStackTrace(pw); + return sw.toString(); + } + + private static final Lock bundlesLock = new ReentrantLock(); + private static final Locale fallbackLocale = Locale.US; + private static final Map messageBundles = new HashMap<>(); +} diff --git a/src/main/java/net/fabricmc/mappingio/i18n/Translatable.java b/src/main/java/net/fabricmc/mappingio/i18n/Translatable.java new file mode 100644 index 00000000..e1029734 --- /dev/null +++ b/src/main/java/net/fabricmc/mappingio/i18n/Translatable.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.mappingio.i18n; + +import java.util.Locale; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.NonExtendable +public interface Translatable { + @ApiStatus.Internal + static Translatable of(String translationKey) { + return new TranslatableImpl(translationKey); + } + + /** + * Translates this translatable to the specified locale, with a fallback to en_US. + */ + String translate(Locale locale); + + /** + * Returns the translation key of this translatable, allowing consumers to provide their own translations + * via custom localization facilities. + */ + String translationKey(); +} diff --git a/src/main/java/net/fabricmc/mappingio/i18n/TranslatableImpl.java b/src/main/java/net/fabricmc/mappingio/i18n/TranslatableImpl.java new file mode 100644 index 00000000..78d0e2e2 --- /dev/null +++ b/src/main/java/net/fabricmc/mappingio/i18n/TranslatableImpl.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.mappingio.i18n; + +import java.util.Locale; + +final class TranslatableImpl implements Translatable { + TranslatableImpl(String translationKey) { + this.translationKey = translationKey; + } + + @Override + public String translate(Locale locale) { + return I18n.translate(translationKey, locale); + } + + @Override + public String translationKey() { + return translationKey; + } + + private final String translationKey; +} diff --git a/src/main/resources/mappingio/lang/en_us.properties b/src/main/resources/mappingio/lang/en_us.properties new file mode 100644 index 00000000..c6b10743 --- /dev/null +++ b/src/main/resources/mappingio/lang/en_us.properties @@ -0,0 +1,14 @@ +format.tiny_file = Tiny File +format.tiny_2_file = Tiny v2 File +format.enigma_file = Enigma File +format.enigma_dir = Enigma Directory +format.proguard_file = ProGuard File +format.srg_file = SRG File +format.xsrg_file = XSRG File +format.jam_file = JAM File +format.csrg_file = CSRG File +format.tsrg_file = TSRG File +format.tsrg_2_file = TSRG v2 File +format.intellij_migration_map_file = IntelliJ Migration Map File +format.recaf_simple_file = Recaf Simple File +format.jobf_file = JOBF File diff --git a/src/test/java/net/fabricmc/mappingio/test/tests/i18n/I18nTest.java b/src/test/java/net/fabricmc/mappingio/test/tests/i18n/I18nTest.java new file mode 100644 index 00000000..9b837532 --- /dev/null +++ b/src/test/java/net/fabricmc/mappingio/test/tests/i18n/I18nTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 FabricMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.fabricmc.mappingio.test.tests.i18n; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import net.fabricmc.mappingio.format.MappingFormat; + +public class I18nTest { + private static List supportedLocales; + + @BeforeAll + public static void init() throws Exception { + URL i18nUrl = I18nTest.class.getResource("/mappingio/lang/"); + Path path = Paths.get(i18nUrl.toURI()); + + supportedLocales = Files.walk(path) + .map(Path::toAbsolutePath) + .map(Path::toString) + .filter(name -> name.endsWith(".properties")) + .map(name -> name.substring(Math.max(0, name.lastIndexOf(File.separatorChar) + 1), name.length() - ".properties".length())) + .map(tag -> Locale.forLanguageTag(tag)) + .collect(Collectors.toList()); + } + + @Test + @SuppressWarnings("deprecation") + public void mappingFormatNames() { + for (MappingFormat format : MappingFormat.values()) { + String enUsName = format.translatableName().translate(Locale.US); + assertEquals(enUsName, format.name); + + for (Locale locale : supportedLocales) { + String translatedName = format.translatableName().translate(locale); + assertFalse(translatedName.startsWith("format."), "Translated name for " + format + " in " + locale + " is missing"); + } + } + } +}