001/*
002 * ============================================================================
003 *  Copyright © 2002-2025 by Thomas Thrien.
004 *  All Rights Reserved.
005 * ============================================================================
006 *  Licensed to the public under the agreements of the GNU Lesser General Public
007 *  License, version 3.0 (the "License"). You may obtain a copy of the License at
008 *
009 *       http://www.gnu.org/licenses/lgpl.html
010 *
011 *  Unless required by applicable law or agreed to in writing, software
012 *  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
013 *  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
014 *  License for the specific language governing permissions and limitations
015 *  under the License.
016 */
017
018package org.tquadrat.foundation.i18n.ap;
019
020import static java.lang.String.format;
021import static java.lang.String.join;
022import static java.text.Normalizer.Form.NFKC;
023import static java.util.Locale.ENGLISH;
024import static java.util.stream.Collectors.joining;
025import static javax.lang.model.element.ElementKind.METHOD;
026import static javax.tools.Diagnostic.Kind.ERROR;
027import static javax.tools.Diagnostic.Kind.NOTE;
028import static javax.tools.StandardLocation.CLASS_OUTPUT;
029import static javax.tools.StandardLocation.SOURCE_OUTPUT;
030import static javax.tools.StandardLocation.SOURCE_PATH;
031import static org.apiguardian.api.API.Status.STABLE;
032import static org.tquadrat.foundation.i18n.I18nUtil.ADDITIONAL_TEXT_FILE;
033import static org.tquadrat.foundation.i18n.I18nUtil.ADDITIONAL_TEXT_LOCATION;
034import static org.tquadrat.foundation.i18n.I18nUtil.DEFAULT_BASEBUNDLENAME;
035import static org.tquadrat.foundation.i18n.I18nUtil.DEFAULT_MESSAGE_PREFIX;
036import static org.tquadrat.foundation.lang.CommonConstants.EMPTY_STRING;
037import static org.tquadrat.foundation.lang.CommonConstants.ISO8859_1;
038import static org.tquadrat.foundation.lang.Objects.nonNull;
039import static org.tquadrat.foundation.lang.Objects.requireNonNullArgument;
040import static org.tquadrat.foundation.util.CharSetUtils.convertUnicodeToASCII;
041import static org.tquadrat.foundation.util.StringUtils.isEmptyOrBlank;
042import static org.tquadrat.foundation.util.StringUtils.isNotEmptyOrBlank;
043import static org.tquadrat.foundation.util.StringUtils.splitString;
044import static org.tquadrat.foundation.util.StringUtils.stream;
045import static org.tquadrat.foundation.util.SystemUtils.retrieveLocale;
046import static org.tquadrat.foundation.util.stringconverter.LocaleStringConverter.MSG_InvalidLocaleFormat;
047
048import javax.annotation.processing.Filer;
049import javax.annotation.processing.RoundEnvironment;
050import javax.annotation.processing.SupportedOptions;
051import javax.annotation.processing.SupportedSourceVersion;
052import javax.lang.model.SourceVersion;
053import javax.lang.model.element.Element;
054import javax.lang.model.element.TypeElement;
055import javax.lang.model.element.VariableElement;
056import javax.tools.FileObject;
057import javax.xml.parsers.ParserConfigurationException;
058import javax.xml.parsers.SAXParserFactory;
059import java.io.File;
060import java.io.FileInputStream;
061import java.io.IOException;
062import java.io.OutputStreamWriter;
063import java.io.Writer;
064import java.lang.annotation.Annotation;
065import java.util.ArrayList;
066import java.util.Collection;
067import java.util.HashMap;
068import java.util.HashSet;
069import java.util.List;
070import java.util.Locale;
071import java.util.Map;
072import java.util.Optional;
073import java.util.SequencedCollection;
074import java.util.Set;
075import java.util.SortedMap;
076
077import org.apiguardian.api.API;
078import org.tquadrat.foundation.annotation.ClassVersion;
079import org.tquadrat.foundation.ap.APBase;
080import org.tquadrat.foundation.ap.AnnotationProcessingError;
081import org.tquadrat.foundation.ap.IllegalAnnotationError;
082import org.tquadrat.foundation.i18n.BaseBundleName;
083import org.tquadrat.foundation.i18n.Message;
084import org.tquadrat.foundation.i18n.MessagePrefix;
085import org.tquadrat.foundation.i18n.Text;
086import org.tquadrat.foundation.i18n.Texts;
087import org.tquadrat.foundation.i18n.UseAdditionalTexts;
088import org.xml.sax.InputSource;
089import org.xml.sax.SAXException;
090
091/**
092 *  The annotation processor for the module {@code org.tquadrat.foundation.i18n}.
093 *
094 *  @extauthor Thomas Thrien - thomas.thrien@tquadrat.org
095 *  @version $Id: I18nAnnotationProcessor.java 1151 2025-10-01 21:32:15Z tquadrat $
096 *  @since 0.0.1
097 *
098 *  @UMLGraph.link
099 */
100@ClassVersion( sourceVersion = "$Id: I18nAnnotationProcessor.java 1151 2025-10-01 21:32:15Z tquadrat $" )
101@API( status = STABLE, since = "0.0.1" )
102@SupportedSourceVersion( SourceVersion.RELEASE_17 )
103@SupportedOptions( { APBase.ADD_DEBUG_OUTPUT, APBase.MAVEN_GOAL, ADDITIONAL_TEXT_LOCATION } )
104public class I18nAnnotationProcessor extends APBase
105{
106        /*------------*\
107    ====** Attributes **=======================================================
108        \*------------*/
109    /**
110     *  The base bundle name.
111     */
112    private String m_BaseBundleName;
113
114    /**
115     *  The default language.
116     */
117    private Locale m_DefaultLanguage = ENGLISH;
118
119    /**
120     *  The message prefix.
121     */
122    private String m_MessagePrefix;
123
124        /*--------------*\
125    ====** Constructors **=====================================================
126        \*--------------*/
127    /**
128     *  Creates a new {@code I18NAnnotationProcessor} instance.
129     */
130    @SuppressWarnings( "RedundantNoArgConstructor" )
131    public I18nAnnotationProcessor() { super(); }
132
133        /*---------*\
134    ====** Methods **==========================================================
135        \*---------*/
136    /**
137     *  Generates the resource bundles from the given texts.
138     *
139     *  @param  textFileLocation    The provided location for
140     *      {@value org.tquadrat.foundation.i18n.I18nUtil#ADDITIONAL_TEXT_FILE}.
141     *  @param  texts    The texts.
142     *  @param  elements    The annotated elements.
143     */
144    @SuppressWarnings( {"NestedTryStatement", "OptionalUsedAsFieldOrParameterType", "OverlyComplexMethod"} )
145    private final void generateResourceBundle( final Optional<String> textFileLocation, final Map<Locale,SortedMap<String,TextEntry>> texts, final Element... elements )
146    {
147        final var filer = getFiler();
148
149        try
150        {
151            Optional<InputSource> searchResult = Optional.empty();
152
153            //---* Load the additional texts *---------------------------------
154            if( textFileLocation.isPresent() ) searchResult = searchAdditionalTextsOnProvidedLocation( textFileLocation.get() );
155            if( searchResult.isEmpty() ) searchResult = searchAdditionalTextsOnConfiguredLocation();
156            if( searchResult.isEmpty() ) searchResult = searchAdditionalTextsOnSourceTree( filer );
157
158            //---* Do the parsing *--------------------------------------------
159            if( searchResult.isPresent() )
160            {
161                final var inputSource = searchResult.get();
162                parseTextsFile( texts, inputSource );
163            }
164        }
165        catch( final IOException e )
166        {
167            printMessage( ERROR, "Unable to read file '%s': %s".formatted( ADDITIONAL_TEXT_FILE, e.getMessage() ) );
168            throw new AnnotationProcessingError( e );
169        }
170        catch( final SAXException e )
171        {
172            printMessage( ERROR, "Unable to parse file '%s': %s".formatted( ADDITIONAL_TEXT_FILE, e.getMessage() ) );
173            throw new AnnotationProcessingError( e );
174        }
175        catch( final ParserConfigurationException e )
176        {
177            printMessage( ERROR, "Unable to create SAXParser: %s".formatted( e.getMessage() ) );
178            throw new AnnotationProcessingError( e );
179        }
180
181        //---* Write the resource bundle properties files *--------------------
182        final var filenameParts = splitString( nonNull( m_BaseBundleName ) ? m_BaseBundleName : DEFAULT_BASEBUNDLENAME, '.' );
183        final var filename = new StringBuilder();
184
185        //---* Loop over the locales *-----------------------------------------
186        LocaleLoop: for( final var localeEntries : texts.entrySet() )
187        {
188            /*
189             * This loop cannot be translated to use Map.forEach() because
190             * some statements may throw checked exceptions (in particular,
191             * IOException). Changing that would require to modify the API for
192             * this method significantly.
193             */
194            final var locale = localeEntries.getKey();
195
196            //---* Create the path name *--------------------------------------
197            filename.setLength( 0 );
198            filename.append( join( "/", filenameParts ) );
199            if( locale != m_DefaultLanguage )
200            {
201                filename.append( '_' ).append( locale.toString() );
202            }
203            filename.append( ".properties" );
204
205            //---* Write to the location for the generated sources *-----------
206            var pathName = filename.toString();
207            try
208            {
209                final var bundleFile = filer.createResource( SOURCE_OUTPUT, EMPTY_STRING, pathName, elements );
210                pathName = bundleFile.toUri().toString();
211                printMessage( NOTE, "Creating Resource File: %s".formatted( pathName ) );
212                try( final var writer = new OutputStreamWriter( bundleFile.openOutputStream(), ISO8859_1 ) )
213                {
214                    writeResourceBundleFile( localeEntries.getValue().values(), writer );
215                }
216            }
217            catch( final IOException e )
218            {
219                printMessage( ERROR, "Unable to write resource bundle file '%s': %s".formatted( pathName, e.getMessage() ) );
220                throw new AnnotationProcessingError( e );
221            }
222
223            //---* Write to the location for the classes *---------------------
224            /*
225             * For some yet unknown reasons, sometimes one, sometimes the other
226             * location works; now we decided to write to both.
227             */
228            pathName = filename.toString();
229            try
230            {
231                final var bundleFile = filer.createResource( CLASS_OUTPUT, EMPTY_STRING, pathName, elements );
232                pathName = bundleFile.toUri().toString();
233                printMessage( NOTE, "Creating Resource File: %s".formatted( pathName ) );
234                try( final var writer = new OutputStreamWriter( bundleFile.openOutputStream(), ISO8859_1 ) )
235                {
236                    writeResourceBundleFile( localeEntries.getValue().values(), writer );
237                }
238            }
239            catch( final IOException e )
240            {
241                printMessage( ERROR, "Unable to write resource bundle file '%s': %s".formatted( pathName, e.getMessage() ) );
242                throw new AnnotationProcessingError( e );
243            }
244        }   //  LocaleLoop:
245    }   //  generateResourceBundle()
246
247    /**
248     *  {@inheritDoc}
249     */
250    @Override
251    protected final Collection<Class<? extends Annotation>> getSupportedAnnotationClasses()
252    {
253        final Collection<Class<? extends Annotation>> retValue = List.of
254        (
255            BaseBundleName.class, Message.class, MessagePrefix.class, Text.class, Texts.class, UseAdditionalTexts.class
256        );
257
258        //---* Done *----------------------------------------------------------
259        return retValue;
260    }   //  getSupportedAnnotationClasses()
261
262    /**
263     *  Parses a texts XML file.
264     *
265     *  @param  texts   The data structure that takes the parsed texts.
266     *  @param  inputSource The XML input stream.
267     *  @throws IOException Reading the input failed.
268     *  @throws ParserConfigurationException    It was not possible to obtain a
269     *      {@link SAXParserFactory}
270     *      or it could not be configured properly.
271     *  @throws SAXException    Parsing the input file failed.
272     */
273    private final void parseTextsFile( final Map<Locale,SortedMap<String,TextEntry>> texts, final InputSource inputSource ) throws IOException, ParserConfigurationException, SAXException
274    {
275        /*
276         * Obtain an instance of an XMLReader implementation from a system
277         * property.
278         */
279        final var saxParserFactory = SAXParserFactory.newInstance();
280        saxParserFactory.setValidating( true );
281        saxParserFactory.setNamespaceAware( true );
282        final var saxParser = saxParserFactory.newSAXParser();
283
284        //---* Create a content handler *--------------------------------------
285        final var contentHandler = new TextFileContentHandler( requireNonNullArgument( texts, "texts" ) );
286
287        //---* Do the parsing *------------------------------------------------
288        saxParser.parse( requireNonNullArgument( inputSource, "inputSource" ), contentHandler );
289    }   //  parseTextsFile()
290
291    /**
292     *  {@inheritDoc}
293     */
294    @SuppressWarnings( "OverlyComplexMethod" )
295    @Override
296    public final boolean process( final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnvironment )
297    {
298        //---* Tell them who we are *------------------------------------------
299        final var message = annotations.isEmpty() ? "No annotations to process" : annotations.stream()
300            .map( TypeElement::getQualifiedName )
301            .collect( joining( "', '", "Processing the annotation" + (annotations.size() > 1 ? "s '" : " '"), "'" ) );
302        printMessage( NOTE, message );
303
304        final var retValue = !roundEnvironment.errorRaised() && !annotations.isEmpty();
305        if( retValue )
306        {
307            final Map<Locale,SortedMap<String,TextEntry>> texts = new HashMap<>();
308            final Collection<Element> processedElements = new HashSet<>();
309
310            //noinspection ConstantExpression
311            if( !annotations.isEmpty() )
312            {
313                //---* Get the message prefix *--------------------------------
314                retrieveAnnotatedField( roundEnvironment, MessagePrefix.class )
315                    .ifPresent( variableElement -> m_MessagePrefix = variableElement.getConstantValue().toString() );
316
317                //---* Get the base bundle name *------------------------------
318                retrieveAnnotatedField( roundEnvironment, BaseBundleName.class )
319                    .ifPresent( variableElement ->
320                    {
321                        m_BaseBundleName = variableElement.getConstantValue().toString();
322                        final var annotation = variableElement.getAnnotation( BaseBundleName.class );
323                        final var defaultLanguageCode = annotation.defaultLanguage();
324                        m_DefaultLanguage = retrieveLocale( defaultLanguageCode ).orElseThrow( () -> new IllegalArgumentException( format( MSG_InvalidLocaleFormat, defaultLanguageCode ) ) );
325                    } );
326
327                /*
328                 *  Collect the elements that are annotated with @Text or
329                 *  @Message.
330                 */
331                if( annotations.stream()
332                    .map( TypeElement::getQualifiedName )
333                    .map( Object::toString )
334                    .anyMatch( name -> name.equals( Texts.class.getName() ) || name.equals( Text.class.getName() ) || name.equals( Message.class.getName() ) ) )
335                {
336                    final var textCollector = new TextCollector( this, nonNull( m_MessagePrefix ) ? m_MessagePrefix : DEFAULT_MESSAGE_PREFIX );
337                    for( final var element : roundEnvironment.getElementsAnnotatedWith( Message.class ) )
338                    {
339                        if( element instanceof VariableElement )
340                        {
341                            element.accept( textCollector, texts );
342                            processedElements.add( element );
343                        }
344                        else
345                        {
346                            printMessage( ERROR, format( MSG_IllegalAnnotationUse, element.getSimpleName().toString(), BaseBundleName.class.getSimpleName() ), element );
347                            throw new IllegalAnnotationError( BaseBundleName.class );
348                        }
349                    }
350
351                    final Collection<Element> textAnnotatedElements = new HashSet<>( roundEnvironment.getElementsAnnotatedWith( Text.class ) );
352                    textAnnotatedElements.addAll( roundEnvironment.getElementsAnnotatedWith( Texts.class ) );
353                    for( final var element : textAnnotatedElements )
354                    {
355                        if( (element instanceof VariableElement) || (element.getKind() == METHOD) )
356                        {
357                            element.accept( textCollector, texts );
358                            processedElements.add( element );
359                        }
360                        else
361                        {
362                            printMessage( ERROR, format( MSG_IllegalAnnotationUse, element.getSimpleName().toString(), BaseBundleName.class.getSimpleName() ), element );
363                            throw new IllegalAnnotationError( Text.class );
364                        }
365                    }
366                }
367            }
368
369            /*
370             * Even when no text or message annotation was found, there could
371             * be still some additional texts.
372             */
373            final SequencedCollection<Element> useAdditionalTextsAnnotatedElements = new ArrayList<>( roundEnvironment.getElementsAnnotatedWith( UseAdditionalTexts.class ) );
374            final Optional<String> textFileLocation = switch( useAdditionalTextsAnnotatedElements.size() )
375            {
376                case 0 -> Optional.empty();
377                case 1 ->
378                    {
379                        final var annotation = useAdditionalTextsAnnotatedElements.getFirst().getAnnotation( UseAdditionalTexts.class );
380                        final var location = annotation.location();
381                        yield isEmptyOrBlank( location ) ? Optional.empty() : Optional.of( location );
382                    }
383                default ->
384                {
385                    final var msg = format( MSG_MultipleElements, EMPTY_STRING, UseAdditionalTexts.class.getSimpleName() );
386                    printMessage( ERROR, msg );
387                    throw new IllegalAnnotationError( msg, UseAdditionalTexts.class );
388                }
389            };
390
391            printMessage( NOTE, "Generate ResourceBundles" );
392            generateResourceBundle( textFileLocation, texts, processedElements.toArray( Element []::new ) );
393        }
394
395        //---* Done *----------------------------------------------------------
396        return retValue;
397    }   //  process()
398
399    /**
400     *  Searches the file with the additional texts
401     *  ({@value org.tquadrat.foundation.i18n.I18nUtil#ADDITIONAL_TEXT_FILE})
402     *  on the location configured with the annotation processor option
403     *  {@value org.tquadrat.foundation.i18n.I18nUtil#ADDITIONAL_TEXT_LOCATION}.
404     *
405     *  @return An instance of
406     *      {@link Optional}
407     *      that holds the
408     *      {@link InputSource}
409     *      for the file.
410     */
411    private final Optional<InputSource> searchAdditionalTextsOnConfiguredLocation()
412    {
413        Optional<InputSource> retValue = Optional.empty();
414
415        final var option = getOption( ADDITIONAL_TEXT_LOCATION );
416        if( option.isPresent() )
417        {
418            final var folder = new File( option.get() );
419            if( folder.exists() && folder.isDirectory() )
420            {
421                var textFile = new File( folder, ADDITIONAL_TEXT_FILE );
422                var textFileName = textFile.getAbsolutePath();
423                try
424                {
425                    textFile = textFile.getCanonicalFile().getAbsoluteFile();
426                    textFileName = textFile.getAbsolutePath();
427                    if( textFile.exists() && textFile.isFile() )
428                    {
429                        final var inputStream = new FileInputStream( textFile );
430
431                        //---* Create the return value *-----------------------
432                        retValue = Optional.of( new InputSource( inputStream ) );
433
434                        textFileName = textFile.toURI().toURL().toExternalForm();
435                        printMessage( NOTE, "Reading additional texts from '%s' (configured location)".formatted( textFileName ) );
436                    }
437                }
438                catch( @SuppressWarnings( "OverlyBroadCatchBlock" ) final IOException ignored )
439                {
440                    printMessage( NOTE, "Cannot open file '%s' with additional texts".formatted( textFileName ) );
441                }
442            }
443        }
444
445        //---* Done *----------------------------------------------------------
446        return retValue;
447    }   //  searchAdditionalTextsOnConfiguredLocation
448
449    /**
450     *  Searches the file with the additional texts
451     *  ({@value org.tquadrat.foundation.i18n.I18nUtil#ADDITIONAL_TEXT_FILE})
452     *  on the location provided by the annotation
453     *  {@link UseAdditionalTexts &#64;UseAdditionalTexts}.
454     *
455     *  @param  location    The location for the file with the additional
456     *      texts.
457     *  @return An instance of
458     *      {@link Optional}
459     *      that holds the
460     *      {@link InputSource}
461     *      for the file.
462     */
463    private final Optional<InputSource> searchAdditionalTextsOnProvidedLocation( final String location )
464    {
465        Optional<InputSource> retValue = Optional.empty();
466
467        final var folder = new File( location );
468        if( folder.exists() && folder.isDirectory() )
469        {
470            var textFile = new File( folder, ADDITIONAL_TEXT_FILE );
471            var textFileName = textFile.getAbsolutePath();
472            try
473            {
474                textFile = textFile.getCanonicalFile().getAbsoluteFile();
475                textFileName = textFile.getAbsolutePath();
476                if( textFile.exists() && textFile.isFile() )
477                {
478                    final var inputStream = new FileInputStream( textFile );
479
480                    //---* Create the return value *---------------------------
481                    retValue = Optional.of( new InputSource( inputStream ) );
482
483                    textFileName = textFile.toURI().toURL().toExternalForm();
484                    printMessage( NOTE, "Reading additional texts from '%s' (provided location)".formatted( textFileName ) );
485                }
486            }
487            catch( @SuppressWarnings( "OverlyBroadCatchBlock" ) final IOException ignored )
488            {
489                printMessage( NOTE, "Cannot open file '%s' with additional texts".formatted( textFileName ) );
490            }
491        }
492
493        //---* Done *----------------------------------------------------------
494        return retValue;
495    }   //  searchAdditionalTextsOnProvidedLocation
496
497    /**
498     *  Searches the file with the additional texts
499     *  ({@value org.tquadrat.foundation.i18n.I18nUtil#ADDITIONAL_TEXT_FILE})
500     *  on the location determined by
501     *  {@link javax.tools.StandardLocation#SOURCE_PATH}.
502     *
503     *  @param filer    The
504     *      {@link Filer}
505     *      instance that provides the {@code SOURCE_PATH} location.
506     *  @return An instance of
507     *      {@link Optional}
508     *      that holds the
509     *      {@link InputSource}
510     *      for the file.
511     */
512    @SuppressWarnings( "AssignmentToNull" )
513    private final Optional<InputSource> searchAdditionalTextsOnSourceTree( final Filer filer )
514    {
515        Optional<InputSource> retValue = Optional.empty();
516
517        FileObject textFile;
518        try
519        {
520            textFile = filer.getResource( SOURCE_PATH, EMPTY_STRING, ADDITIONAL_TEXT_FILE );
521        }
522        catch( @SuppressWarnings( "unused" ) final IOException ignored )
523        {
524            printMessage( NOTE, "Cannot open file '%s' with additional texts".formatted( ADDITIONAL_TEXT_FILE ) );
525            textFile = null;
526        }
527        if( nonNull( textFile ) )
528        {
529            var textFileName = ADDITIONAL_TEXT_FILE;
530            try
531            {
532                textFileName = textFile.toUri().toURL().toExternalForm();
533
534                //---* Create the input stream *-------------------------------
535                final var inputStream = textFile.openInputStream();
536
537                //---* Create the input source *-------------------------------
538                final var inputSource = new InputSource( inputStream );
539
540                //---* Create the return value *-------------------------------
541                retValue = Optional.of( inputSource );
542
543                printMessage( NOTE, "Reading additional texts from '%s' (source tree)".formatted( textFileName ) );
544            }
545            catch( @SuppressWarnings( "OverlyBroadCatchBlock" ) final IllegalArgumentException | IOException e )
546            {
547                printMessage( ERROR, "Unable to read file '%s' with additional texts: %s".formatted( textFileName, e.toString() ) );
548                throw new AnnotationProcessingError( e );
549            }
550        }
551
552        //---* Done *----------------------------------------------------------
553        return retValue;
554    }   //  searchAdditionalTextsOnSourceTree()
555
556    /**
557     *  Write the resource bundle properties to the given
558     *  {@link Writer}.
559     *
560     *  @param  data    The properties.
561     *  @param  writer  The target {@code Writer} instance.
562     *  @throws IOException Writing the resource bundle file failed.
563     */
564    private final void writeResourceBundleFile( final Collection<TextEntry> data, final Writer writer ) throws IOException
565    {
566        requireNonNullArgument( writer, "writer" );
567
568        final var encoder = ISO8859_1.newEncoder();
569
570        writer.append(
571            """
572            # suppress inspection "TrailingSpacesInProperty" for whole file
573            """ );
574
575        //---* Loop over the text entries *------------------------------------
576        TextLoop: for( final var entry : requireNonNullArgument( data, "data" ) )
577        {
578            var text = entry.text();
579            if( !encoder.canEncode( text ) ) text = convertUnicodeToASCII( NFKC, text );
580
581            final var description = stream( entry.description(), '\n' )
582                .collect( joining( "\n# ", "# ", "\n" ) );
583            writer.append( description );
584            if( isNotEmptyOrBlank( entry.className() ) )
585            {
586                writer.append( "# Defined in: " )
587                    .append( entry.className() )
588                    .append( '\n' );
589            }
590            writer.append( entry.key() )
591                .append( '=' )
592                .append( text )
593                .append( '\n' )
594                .append( '\n' );
595        }   //  TextLoop:
596    }   //  writeResourceBundleFile()
597}
598//  class I18nAnnotationProcessor
599
600/*
601 *  End of File
602 */