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     *  (&quot;\&quot;).</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 */