3030import io .github .namiuni .kotonoha .annotations .ResourceBundle ;
3131import java .io .IOException ;
3232import java .io .Writer ;
33- import java .util .Comparator ;
34- import java .util .HashMap ;
35- import java .util .LinkedHashSet ;
33+ import java .util .LinkedHashMap ;
3634import java .util .Map ;
37- import java .util .Properties ;
35+ import java .util .Objects ;
3836import java .util .Set ;
39- import java .util .function .Supplier ;
40- import java .util .stream .Collectors ;
4137import javax .annotation .processing .AbstractProcessor ;
4238import javax .annotation .processing .Filer ;
4339import javax .annotation .processing .Messager ;
5349import javax .lang .model .element .TypeElement ;
5450import javax .tools .Diagnostic ;
5551import 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 )
7676public 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}
0 commit comments