Skip to content

Commit 7e38625

Browse files
authored
Merge pull request #18 from NamiUni/feat/sort-bundle-keys
Ensure that the order of resource bundle keys matches the order of methods
2 parents 25aa563 + ac57920 commit 7e38625

4 files changed

Lines changed: 101 additions & 58 deletions

File tree

build-logic/src/main/kotlin/kotonoha.base.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66

77
java {
88
toolchain {
9-
languageVersion.set(JavaLanguageVersion.of(21))
9+
languageVersion.set(JavaLanguageVersion.of(25))
1010
}
1111
}
1212

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Project Properties
2-
projectVersion=0.2.0
2+
projectVersion=0.2.1
33
group=io.github.namiuni
44
description = An Adventure-focused library for Internationalization (i18n).
55

resourcebundle-generator-processor/src/main/java/io/github/namiuni/kotonoha/resourcebundle/generator/processor/ResourceBundleGeneratorProcessor.java

Lines changed: 98 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,10 @@
3030
import io.github.namiuni.kotonoha.annotations.ResourceBundle;
3131
import java.io.IOException;
3232
import java.io.Writer;
33-
import java.util.Comparator;
34-
import java.util.HashMap;
35-
import java.util.LinkedHashSet;
33+
import java.util.LinkedHashMap;
3634
import java.util.Map;
37-
import java.util.Properties;
35+
import java.util.Objects;
3836
import java.util.Set;
39-
import java.util.function.Supplier;
40-
import java.util.stream.Collectors;
4137
import javax.annotation.processing.AbstractProcessor;
4238
import javax.annotation.processing.Filer;
4339
import javax.annotation.processing.Messager;
@@ -53,41 +49,34 @@
5349
import javax.lang.model.element.TypeElement;
5450
import javax.tools.Diagnostic;
5551
import javax.tools.StandardLocation;
56-
import org.jspecify.annotations.NonNull;
57-
58-
/**
59-
* An annotation processor that generates Java {@link java.util.ResourceBundle} property files
60-
* from interfaces annotated with {@link ResourceBundle}.
61-
*
62-
* <p>This processor scans methods within annotated interfaces for {@link Key}, {@link ResourceBundle}, {@link Message},
63-
* {@link Messages} annotations
64-
* to construct translation keys and messages. It then writes these into standard
65-
* `.properties` files, grouped by locale.</p>
66-
*
67-
* @since 0.1.0
68-
*/
52+
import org.jspecify.annotations.NullMarked;
53+
import org.jspecify.annotations.Nullable;
54+
55+
/// An annotation processor that generates Java [java.util.ResourceBundle] property files
56+
/// from interfaces annotated with [ResourceBundle].
57+
///
58+
/// This processor scans methods within annotated interfaces for [Key], [ResourceBundle], [Message],
59+
/// [Messages] annotations
60+
/// to construct translation keys and messages. It then writes these into standard
61+
/// `.properties` files, grouped by locale.
62+
///
63+
/// Keys in the generated files are written in the same order as the method declarations
64+
/// in the source interface, because [javax.lang.model.element.TypeElement#getEnclosedElements()]
65+
/// preserves source declaration order per the `javax.lang.model` specification.
66+
///
67+
/// @since 0.1.0
68+
@NullMarked
6969
@SupportedAnnotationTypes({
7070
"io.github.namiuni.kotonoha.annotations.Key",
7171
"io.github.namiuni.kotonoha.annotations.ResourceBundle",
7272
"io.github.namiuni.kotonoha.annotations.Message",
7373
"io.github.namiuni.kotonoha.annotations.Messages"
7474
})
75-
@SupportedSourceVersion(SourceVersion.RELEASE_21)
75+
@SupportedSourceVersion(SourceVersion.RELEASE_25)
7676
public final class ResourceBundleGeneratorProcessor extends AbstractProcessor {
7777

78-
private static final Supplier<Properties> SORTED_PROPERTIES = () -> new Properties() {
79-
@Override
80-
public synchronized @NonNull Set<Map.Entry<Object, Object>> entrySet() {
81-
return Set.copyOf(
82-
(Set<? extends Map.Entry<Object, Object>>) super.entrySet()
83-
.stream()
84-
.sorted(Comparator.comparing(entry -> entry.getKey().toString()))
85-
.collect(Collectors.toCollection(LinkedHashSet::new)));
86-
}
87-
};
88-
89-
private Filer filer;
90-
private Messager messager;
78+
private @Nullable Filer filer;
79+
private @Nullable Messager messager;
9180

9281
/**
9382
* Creates a new {@code ResourceBundleGeneratorProcessor} instance.
@@ -114,8 +103,7 @@ public boolean process(final Set<? extends TypeElement> annotations, final Round
114103

115104
for (final Element element : resourceBundleElements) {
116105
if (element.getKind() != ElementKind.INTERFACE) {
117-
final String message = "@ResourceBundle can only be applied to interfaces";
118-
this.messager.printMessage(Diagnostic.Kind.ERROR, message, element);
106+
Objects.requireNonNull(this.messager).printMessage(Diagnostic.Kind.ERROR, "@ResourceBundle can only be applied to interfaces", element);
119107
continue;
120108
}
121109

@@ -132,41 +120,44 @@ private void processResourceBundleInterface(final TypeElement typeElement) {
132120
}
133121

134122
final String baseName = resourceBundleAnnotation.baseName();
135-
final Map<String, Properties> localeProperties = new HashMap<>();
136123

137-
// Process all methods in the interface
124+
// LinkedHashMap preserves locale insertion order.
125+
// The inner LinkedHashMap preserves key insertion order, which corresponds
126+
// to method declaration order because getEnclosedElements() is ordered by
127+
// source position per the javax.lang.model specification.
128+
final Map<String, Map<String, String>> localeEntries = new LinkedHashMap<>();
129+
138130
for (final Element enclosedElement : typeElement.getEnclosedElements()) {
139131
if (enclosedElement.getKind() == ElementKind.METHOD) {
140-
this.processMethod((ExecutableElement) enclosedElement, localeProperties);
132+
this.processMethod((ExecutableElement) enclosedElement, localeEntries);
141133
}
142134
}
143135

144-
// Write properties files for each locale
145-
this.writePropertiesFiles(baseName, localeProperties);
136+
this.writePropertiesFiles(baseName, localeEntries);
146137
}
147138

148-
private void processMethod(final ExecutableElement method, final Map<String, Properties> localeProperties) {
139+
private void processMethod(
140+
final ExecutableElement method,
141+
final Map<String, Map<String, String>> localeEntries
142+
) {
149143
if (method.isDefault() || method.getModifiers().contains(Modifier.STATIC)) {
150144
return;
151145
}
152146

153147
final Key keyAnnotation = method.getAnnotation(Key.class);
154148
if (keyAnnotation == null) {
155149
final String message = "Method missing @Key annotation: %s";
156-
this.messager.printMessage(Diagnostic.Kind.WARNING, message.formatted(method.getSimpleName()), method);
150+
Objects.requireNonNull(this.messager).printMessage(Diagnostic.Kind.WARNING, message.formatted(method.getSimpleName()), method);
157151
return;
158152
}
159153

160154
final String key = keyAnnotation.value();
161-
162-
// Process @Message annotations (both single and repeatable)
163155
final Message[] messageAnnotations = this.getMessageAnnotations(method);
164156

165157
for (final Message messageAnnotation : messageAnnotations) {
166158
final String localeKey = this.getLocaleKey(messageAnnotation.locale());
167-
final String content = messageAnnotation.content();
168-
169-
localeProperties.computeIfAbsent(localeKey, k -> SORTED_PROPERTIES.get()).setProperty(key, content);
159+
localeEntries.computeIfAbsent(localeKey, _ -> new LinkedHashMap<>())
160+
.put(key, messageAnnotation.content());
170161
}
171162
}
172163

@@ -191,20 +182,72 @@ private String getLocaleKey(final Locales locale) {
191182
return "_" + locale.asLocale();
192183
}
193184

194-
private void writePropertiesFiles(final String baseName, final Map<String, Properties> localeProperties) {
195-
for (final Map.Entry<String, Properties> entry : localeProperties.entrySet()) {
185+
private void writePropertiesFiles(
186+
final String baseName,
187+
final Map<String, Map<String, String>> localeEntries
188+
) {
189+
for (final Map.Entry<String, Map<String, String>> entry : localeEntries.entrySet()) {
196190
final String localeKey = entry.getKey();
197-
final Properties properties = entry.getValue();
198-
191+
final Map<String, String> entries = entry.getValue();
199192
final String fileName = baseName + localeKey + ".properties";
200-
try (Writer writer = this.filer.createResource(StandardLocation.CLASS_OUTPUT, "", fileName).openWriter()) {
201-
properties.store(writer, "Generated by ResourceBundleGeneratorProcessor");
202-
this.messager.printMessage(Diagnostic.Kind.NOTE, "Generated resource bundle: " + fileName);
203193

194+
try (Writer writer = Objects.requireNonNull(this.filer).createResource(StandardLocation.CLASS_OUTPUT, "", fileName).openWriter()) {
195+
writer.write("# Generated by ResourceBundleGeneratorProcessor\n");
196+
for (final Map.Entry<String, String> prop : entries.entrySet()) {
197+
writer.write(escapeKey(prop.getKey()));
198+
writer.write('=');
199+
writer.write(escapeValue(prop.getValue()));
200+
writer.write('\n');
201+
}
202+
Objects.requireNonNull(this.messager).printMessage(Diagnostic.Kind.NOTE, "Generated resource bundle: " + fileName);
204203
} catch (final IOException exception) {
205204
final String message = "Failed to write properties file: %s - %s";
206-
this.messager.printMessage(Diagnostic.Kind.ERROR, message.formatted(fileName, exception.getMessage()));
205+
Objects.requireNonNull(this.messager).printMessage(Diagnostic.Kind.ERROR, message.formatted(fileName, exception.getMessage()));
207206
}
208207
}
209208
}
209+
210+
private static String escapeKey(final String key) {
211+
return escape(key, true);
212+
}
213+
214+
private static String escapeValue(final String value) {
215+
return escape(value, false);
216+
}
217+
218+
private static String escape(final String input, final boolean escapeSpace) {
219+
final int length = input.length();
220+
final StringBuilder builder = new StringBuilder(length);
221+
222+
for (int i = 0; i < length; i++) {
223+
final char ch = input.charAt(i);
224+
switch (ch) {
225+
case '\\' -> builder.append("\\\\");
226+
case '\n' -> builder.append("\\n");
227+
case '\r' -> builder.append("\\r");
228+
case '\t' -> builder.append("\\t");
229+
case '\f' -> builder.append("\\f");
230+
case ' ' -> {
231+
if (escapeSpace || i == 0) {
232+
builder.append("\\ ");
233+
} else {
234+
builder.append(ch);
235+
}
236+
}
237+
case '=', ':', '#', '!' -> {
238+
builder.append('\\');
239+
builder.append(ch);
240+
}
241+
default -> {
242+
if (ch < 0x0020 || ch > 0x007e) {
243+
builder.append(String.format("\\u%04x", (int) ch));
244+
} else {
245+
builder.append(ch);
246+
}
247+
}
248+
}
249+
}
250+
251+
return builder.toString();
252+
}
210253
}

resourcebundle-generator-processor/src/test/java/io/github/namiuni/kotonoha/resourcebundle/generator/processor/ResourceBundleGeneratorProcessorTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ void testGetSupportedAnnotationTypes() {
6767
@DisplayName("Verify that supported Java source version is correct")
6868
void testGetSupportedSourceVersion() {
6969
final ResourceBundleGeneratorProcessor processor = new ResourceBundleGeneratorProcessor();
70-
assertEquals(SourceVersion.RELEASE_21, processor.getSupportedSourceVersion());
70+
assertEquals(SourceVersion.RELEASE_25, processor.getSupportedSourceVersion());
7171
}
7272
}
7373

0 commit comments

Comments
 (0)