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 @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 */