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