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.inifile.internal; 019 020import static java.lang.String.format; 021import static java.nio.file.Files.createDirectories; 022import static java.nio.file.Files.exists; 023import static java.nio.file.Files.notExists; 024import static java.nio.file.Files.readAllLines; 025import static java.nio.file.Files.writeString; 026import static java.nio.file.StandardOpenOption.CREATE; 027import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; 028import static java.nio.file.StandardOpenOption.WRITE; 029import static java.util.Comparator.naturalOrder; 030import static java.util.regex.Pattern.compile; 031import static java.util.stream.Collectors.joining; 032import static org.apiguardian.api.API.Status.INTERNAL; 033import static org.apiguardian.api.API.Status.STABLE; 034import static org.tquadrat.foundation.inifile.internal.Group.checkGroupNameCandidate; 035import static org.tquadrat.foundation.inifile.internal.Value.checkKeyCandidate; 036import static org.tquadrat.foundation.lang.CommonConstants.EMPTY_STRING; 037import static org.tquadrat.foundation.lang.CommonConstants.UTF8; 038import static org.tquadrat.foundation.lang.Objects.isNull; 039import static org.tquadrat.foundation.lang.Objects.nonNull; 040import static org.tquadrat.foundation.lang.Objects.requireNonNullArgument; 041import static org.tquadrat.foundation.lang.Objects.requireValidArgument; 042import static org.tquadrat.foundation.util.StringUtils.breakText; 043import static org.tquadrat.foundation.util.StringUtils.isNotEmptyOrBlank; 044 045import java.io.IOException; 046import java.nio.file.Path; 047import java.time.Clock; 048import java.time.Instant; 049import java.util.ArrayList; 050import java.util.Collection; 051import java.util.LinkedHashMap; 052import java.util.List; 053import java.util.Map; 054import java.util.Optional; 055import java.util.StringJoiner; 056import java.util.regex.Pattern; 057import java.util.regex.PatternSyntaxException; 058import java.util.stream.Stream; 059 060import org.apiguardian.api.API; 061import org.tquadrat.foundation.annotation.ClassVersion; 062import org.tquadrat.foundation.inifile.INIFile; 063import org.tquadrat.foundation.lang.Lazy; 064import org.tquadrat.foundation.lang.StringConverter; 065import org.tquadrat.foundation.util.stringconverter.PathStringConverter; 066 067/** 068 * The implementation for 069 * {@link INIFile}. 070 * 071 * @extauthor Thomas Thrien - thomas.thrien@tquadrat.org 072 * @version $Id: INIFileImpl.java 1258 2026-06-04 18:33:06Z tquadrat $ 073 * 074 * @UMLGraph.link 075 * @since 0.1.0 076 */ 077@ClassVersion( sourceVersion = "$Id: INIFileImpl.java 1258 2026-06-04 18:33:06Z tquadrat $" ) 078@API( status = INTERNAL, since = "0.1.0" ) 079public final class INIFileImpl implements INIFile 080{ 081 /*------------*\ 082 ====** Attributes **======================================================= 083 \*------------*/ 084 /** 085 * The clock that is used to determine the last update. This is changed 086 * only for testing purposes, the default is 087 * {@link Clock#systemDefaultZone()}. 088 */ 089 private Clock m_Clock = Clock.systemDefaultZone(); 090 091 /** 092 * The comment for this file. 093 */ 094 @SuppressWarnings( "StringBufferField" ) 095 private final StringBuilder m_Comment = new StringBuilder(); 096 097 /** 098 * The reference to file that is used to persist the contents. 099 */ 100 private final Path m_File; 101 102 /** 103 * The groups. 104 */ 105 private final Map<String,Group> m_Groups = new LinkedHashMap<>(); 106 107 /** 108 * The time when the file was last updated. 109 */ 110 private Instant m_LastUpdated; 111 112 /*------------------------*\ 113 ====** Static Initialisations **=========================================== 114 \*------------------------*/ 115 /** 116 * The format String that is used for the last updated comment. 117 */ 118 private static final String LAST_UPDATED_FORMAT; 119 120 /** 121 * The pattern that is used for the last updated comment. 122 */ 123 private static final Pattern LAST_UPDATED_PATTERN; 124 125 static 126 { 127 final var s = "# Last Update: "; 128 LAST_UPDATED_FORMAT = format( "%s%%s", s ); 129 130 try 131 { 132 LAST_UPDATED_PATTERN = compile( format( "%s(.*)$", s ) ); 133 } 134 catch( final PatternSyntaxException e ) 135 { 136 throw new ExceptionInInitializerError( e ); 137 } 138 } 139 140 /*--------------*\ 141 ====** Constructors **===================================================== 142 \*--------------*/ 143 /** 144 * <p>{@summary Creates a new instance of {@code INIFileImpl}.}</p> 145 * <p>The constructor does not check whether the <i>file</i> argument is 146 * {@null}; this has to be done by the factory methods 147 * {@link #create(Path)} 148 * and 149 * {@link #open(Path)}.</p> 150 * <p>But that this constructor is public allows simpler tests.</p> 151 * 152 * @param file The file that holds the contents. 153 */ 154 private INIFileImpl( final Path file ) 155 { 156 m_File = file; 157 m_LastUpdated = Instant.now( m_Clock ); 158 } // INIFileImpl() 159 160 /*---------*\ 161 ====** Methods **========================================================== 162 \*---------*/ 163 /** 164 * {@inheritDoc} 165 */ 166 @Override 167 public final void addComment( final String comment ) 168 { 169 if( isNotEmptyOrBlank( comment ) ) m_Comment.append( comment ); 170 } // addComment() 171 172 /** 173 * {@inheritDoc} 174 */ 175 @Override 176 public final void addComment( final String group, final String comment ) 177 { 178 requireValidArgument( group, "group", Group::checkGroupNameCandidate ); 179 if( isNotEmptyOrBlank( comment ) ) 180 { 181 final var g = m_Groups.computeIfAbsent( group, this::createGroup ); 182 g.addComment( comment ); 183 } 184 } // addComment() 185 186 /** 187 * {@inheritDoc} 188 */ 189 @Override 190 public final void addComment( final String group, final String key, final String comment ) 191 { 192 requireValidArgument( group, "group", Group::checkGroupNameCandidate ); 193 requireValidArgument( key, "key", Value::checkKeyCandidate ); 194 if( isNotEmptyOrBlank( comment ) ) 195 { 196 final var g = m_Groups.computeIfAbsent( group, this::createGroup ); 197 g.addComment( key, comment ); 198 } 199 } // addComment() 200 201 /** 202 * <p>{@summary Breaks the given String into chunks of 203 * {@value #LINE_LENGTH} 204 * characters.} All but the last chunk will end with a backslash 205 * ("\").</p> 206 * 207 * @param s The String to split. 208 * @return The stream with the chunks. 209 */ 210 public static final Stream<String> breakString( final String s ) 211 { 212 var remainder = requireNonNullArgument( s, "s" ).replaceAll( "\n", "\\\\n" ); 213 final var builder = Stream.<String>builder(); 214 while( remainder.length() > LINE_LENGTH ) 215 { 216 builder.add( format( "%s\\", remainder.substring( 0, LINE_LENGTH ) ) ); 217 remainder = remainder.substring( LINE_LENGTH ); 218 } 219 builder.add( remainder ); 220 221 final var retValue = builder.build(); 222 223 //---* Done *---------------------------------------------------------- 224 return retValue; 225 } // breakString() 226 227 /** 228 * <p>{@summary Creates an empty INI file.} If the file already exists, it 229 * will be overwritten without notice.</p> 230 * <p>The given file is used to store the value on a call to 231 * {@link #save()}.</p> 232 * 233 * @param file The file. 234 * @return The new instance. 235 * @throws IOException The file cannot be created. 236 */ 237 public static final INIFile create( final Path file ) throws IOException 238 { 239 final var retValue = new INIFileImpl( requireNonNullArgument( file, "file" ) ); 240 retValue.save(); 241 242 //---* Done *---------------------------------------------------------- 243 return retValue; 244 } // create() 245 246 /** 247 * Creates a new instance of 248 * {@link Group} 249 * for the given name. 250 * 251 * @param group The name of the group. 252 * @return The new instance. 253 */ 254 private final Group createGroup( final String group ) 255 { 256 /* 257 * The argument check will be done by the constructor itself. 258 */ 259 return new Group( group ); 260 } // createGroup() 261 262 /** 263 * Dumps the contents to a String. 264 * 265 * @return The contents in a String. 266 */ 267 @SuppressWarnings( "PublicMethodNotExposedInInterface" ) 268 public final String dumpContents() 269 { 270 final var joiner = new StringJoiner( "\n", EMPTY_STRING, "\n" ); 271 if( !m_Comment.isEmpty() ) 272 { 273 splitComment( m_Comment ).forEach( joiner::add ); 274 } 275 joiner.add( format( LAST_UPDATED_FORMAT, m_LastUpdated.toString() ) ); 276 final var retValue = m_Groups.values() 277 .stream() 278 .map( Group::toString ) 279 .collect( joining( EMPTY_STRING, joiner.toString(), EMPTY_STRING ) ); 280 281 //---* Done *---------------------------------------------------------- 282 return retValue; 283 } // dumpContents() 284 285 /** 286 * {@inheritDoc} 287 */ 288 @Override 289 public final Optional<String> getValue( final String group, final String key ) 290 { 291 Optional<String> retValue = Optional.empty(); 292 final var g = m_Groups.get( requireValidArgument( group, "group", Group::checkGroupNameCandidate ) ); 293 if( nonNull( g ) ) 294 { 295 retValue = g.getValue( key ) 296 .map( Value::getValue ); 297 } 298 299 //---* Done *---------------------------------------------------------- 300 return retValue; 301 } // getValue() 302 303 /** 304 * {@inheritDoc} 305 */ 306 @Override 307 public final <T> Optional<T> getValue( final String group, final String key, final StringConverter<? extends T> stringConverter ) 308 { 309 requireNonNullArgument( stringConverter, "stringConverter" ); 310 final Optional<T> retValue = getValue( group, key ) 311 .map( stringConverter::fromString ); 312 313 //---* Done *---------------------------------------------------------- 314 return retValue; 315 } // getValue() 316 317 /** 318 * {@inheritDoc} 319 */ 320 @Override 321 public final boolean hasGroup( final String group ) 322 { 323 return isNotEmptyOrBlank( group ) && checkGroupNameCandidate( group ) && m_Groups.containsKey( group ); 324 } // hasGroup() 325 326 /** 327 * {@inheritDoc} 328 */ 329 @Override 330 public final boolean hasValue( final String group, final String key ) 331 { 332 final var retValue = isNotEmptyOrBlank( group ) 333 && isNotEmptyOrBlank( key ) 334 && checkGroupNameCandidate( group ) 335 && checkKeyCandidate( key ) 336 && getValue( group, key ).isPresent(); 337 338 //---* Done *---------------------------------------------------------- 339 return retValue; 340 } // hasValue() 341 342 /** 343 * Join all lines that end with a backslash with the next line on the 344 * list. 345 * 346 * @param lines The input lines. 347 * @return The output. 348 */ 349 private final List<String> joinLines( final Iterable<String> lines ) 350 { 351 final List<String> retValue = new ArrayList<>(); 352 353 final var buffer = new StringBuilder(); 354 for( final var line : lines ) 355 { 356 if( line.endsWith( "\\" ) ) 357 { 358 buffer.append( line ); 359 buffer.setLength( buffer.length() - 1 ); 360 } 361 else if( buffer.isEmpty() ) 362 { 363 retValue.add( line ); 364 } 365 else 366 { 367 buffer.append( line ); 368 retValue.add( buffer.toString() ); 369 buffer.setLength( 0 ); 370 } 371 } 372 if( !buffer.isEmpty() ) retValue.add( buffer.toString() ); 373 374 //---* Done *---------------------------------------------------------- 375 return retValue; 376 } // joinLines() 377 378 /** 379 * {@inheritDoc} 380 */ 381 @Override 382 public final Collection<Entry> listEntries() 383 { 384 final List<Entry> buffer = new ArrayList<>(); 385 for( final var group : m_Groups.values() ) 386 { 387 for( final var key : group.getKeys() ) 388 { 389 final var value = group.getValue( key ); 390 buffer.add( new Entry( group.getName(), key, value.map( Value::getValue ).orElse( null ) ) ); 391 } 392 } 393 buffer.sort( naturalOrder() ); 394 final var retValue = List.copyOf( buffer ); 395 396 //---* Done *---------------------------------------------------------- 397 return retValue; 398 } // listEntries() 399 400 /** 401 * Opens the given INI file and reads its contents. If the file does not 402 * exist yet, a new, empty file will be created. 403 * 404 * @param file The file. 405 * @return The new instance. 406 * @throws IOException A problem occurred when reading the file. 407 */ 408 public static final INIFile open( final Path file ) throws IOException 409 { 410 final var retValue = new INIFileImpl( file ); 411 retValue.parse(); 412 413 //---* Done *---------------------------------------------------------- 414 return retValue; 415 } // open() 416 417 /** 418 * Loads the content from the file into memory. 419 * 420 * @throws IOException A problem occurred when reading the file. 421 */ 422 private final void parse() throws IOException 423 { 424 if( exists( m_File ) ) 425 { 426 final var lines = joinLines( readAllLines( m_File, UTF8 ) ); 427 parse( lines ); 428 } 429 } // parse() 430 431 /** 432 * Parses the given lines to the INI file contents. 433 * 434 * @param lines The joined lines; the list does not contain any 435 * multi-line constructs. 436 * @throws IOException The structure is invalid. 437 */ 438 @SuppressWarnings( {"PublicMethodNotExposedInInterface", "OverlyComplexMethod"} ) 439 public final void parse( final List<String> lines ) throws IOException 440 { 441 Group currentGroup = null; 442 final Collection<String> commentBuffer = new ArrayList<>(); 443 m_Comment.setLength( 0 ); 444 var commentsToBuffer = false; 445 446 final var errorMessage = Lazy.use( () -> "'%s' has invalid structure".formatted( PathStringConverter.INSTANCE.toString( m_File ) ) ); 447 448 final var groupPattern = compile( "\\[(.*)]" ); 449 ScanLoop: for( final var line : requireNonNullArgument( lines, "lines" ) ) 450 { 451 var matcher = LAST_UPDATED_PATTERN.matcher( line ); 452 if( matcher.matches() ) 453 { 454 m_LastUpdated = Instant.parse( matcher.group( 1 ) ); 455 continue ScanLoop; 456 } 457 458 if( line.startsWith( "#" ) ) 459 { 460 //---* Comment line found *------------------------------------ 461 final var s = (line.length() > 1 ? line.substring( 1 ).trim() : EMPTY_STRING); 462 if( commentsToBuffer ) 463 { 464 commentBuffer.add( s ); 465 } 466 else 467 { 468 m_Comment.append( s ); 469 } 470 continue ScanLoop; 471 } 472 473 matcher = groupPattern.matcher( line ); 474 if( matcher.matches() ) 475 { 476 final var name = matcher.group( 1 ); 477 currentGroup = createGroup( name ); 478 m_Groups.put( name, currentGroup ); 479 commentBuffer.forEach( currentGroup::addComment ); 480 commentBuffer.clear(); 481 continue ScanLoop; 482 } 483 484 if( line.isBlank() ) 485 { 486 commentsToBuffer = true; 487 continue ScanLoop; 488 } 489 490 if( isNull( currentGroup) ) throw new IOException( errorMessage.get() ); 491 final var pos = line.indexOf( '=' ); 492 if( pos < 1 ) throw new IOException( errorMessage.get() ); 493 final var key = line.substring( 0, pos ).trim(); 494 final var data = line.trim().length() > pos + 1 495 ? line.substring( pos + 1 ).trim().translateEscapes() 496 : null; 497 final var value = currentGroup.setValue( key, data ); 498 commentBuffer.forEach( value::addComment ); 499 commentBuffer.clear(); 500 } // ScanLoop: 501 } // parse() 502 503 /** 504 * {@inheritDoc} 505 */ 506 @Override 507 public final void refresh() throws IOException { parse(); } 508 509 /** 510 * Saves the contents of the INI file to the file that was provided to 511 * {@link #create(Path)} 512 * or 513 * {@link #open(Path)}. 514 * 515 * @throws IOException A problem occurred when writing the contents to 516 * the file. 517 */ 518 @Override 519 public final void save() throws IOException 520 { 521 if( notExists( m_File ) ) 522 { 523 final var folder = m_File.getParent(); 524 if( notExists( folder ) ) createDirectories( folder ); 525 } 526 m_LastUpdated = Instant.now( m_Clock ); 527 writeString( m_File, dumpContents(), UTF8, CREATE, TRUNCATE_EXISTING, WRITE ); 528 } // save() 529 530 /** 531 * <p>{@summary Sets the clock that is used to determine the last 532 * update.}</p> 533 * <p>This is used only for testing purposes.</p> 534 * 535 * @param clock The clock. 536 */ 537 @SuppressWarnings( "PublicMethodNotExposedInInterface" ) 538 public final void setClock( final Clock clock ) 539 { 540 m_Clock = requireNonNullArgument( clock, "clock" ); 541 m_LastUpdated = Instant.now( clock ); 542 } // setClock() 543 544 /** 545 * {@inheritDoc} 546 * 547 * @since 0.4.3 548 */ 549 @API( status = STABLE, since = "0.4.3" ) 550 @Override 551 public final void setComment( final String comment ) 552 { 553 m_Comment.setLength( 0 ); 554 addComment( comment ); 555 } // setComment() 556 557 /** 558 * {@inheritDoc} 559 * 560 * @since 0.4.3 561 */ 562 @API( status = STABLE, since = "0.4.3" ) 563 @Override 564 public final void setComment( final String group, final String comment ) 565 { 566 final var g = m_Groups.computeIfAbsent( requireValidArgument( group, "group", Group::checkGroupNameCandidate ), this::createGroup ); 567 g.setComment( comment ); 568 } // setComment() 569 570 /** 571 * {@inheritDoc} 572 * 573 * @since 0.4.3 574 */ 575 @API( status = STABLE, since = "0.4.3" ) 576 @Override 577 public final void setComment( final String group, final String key, final String comment ) 578 { 579 final var g = m_Groups.computeIfAbsent( requireValidArgument( group, "group", Group::checkGroupNameCandidate ), this::createGroup ); 580 g.setComment( requireValidArgument( key, "key", Value::checkKeyCandidate ), comment ); 581 } // setComment() 582 583 /** 584 * {@inheritDoc} 585 */ 586 @Override 587 public final void setValue( final String group, final String key, final String value ) 588 { 589 final var g = m_Groups.computeIfAbsent( requireValidArgument( group, "group", Group::checkGroupNameCandidate ), this::createGroup ); 590 g.setValue( key, value ); 591 } // setValue() 592 593 /** 594 * Splits a comment to the proper line length. 595 * 596 * @param comment The comment to split. 597 * @return The lines for the comment. 598 */ 599 public static final Collection<String> splitComment( final CharSequence comment ) 600 { 601 final Collection<String> retValue = new ArrayList<>(); 602 603 if( !requireNonNullArgument( comment, "comment" ).isEmpty() ) 604 { 605 breakText( comment, LINE_LENGTH - 2 ) 606 .map( "# "::concat ) 607 .map( String::trim ) 608 .forEach( retValue::add ); 609 } 610 611 //---* Done *---------------------------------------------------------- 612 return retValue; 613 } // splitComment() 614 615 /** 616 * {@inheritDoc} 617 */ 618 @Override 619 public final String toString() { return dumpContents(); } 620} 621// class INIFileImpl 622 623/* 624 * End of File 625 */