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