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