001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
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,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.config.internal;
018
019import static org.apache.juneau.commons.lang.StateEnum.*;
020import static org.apache.juneau.commons.utils.CollectionUtils.*;
021import static org.apache.juneau.commons.utils.Shorts.*;
022import static org.apache.juneau.commons.utils.StringUtils.*;
023import static org.apache.juneau.commons.utils.StringUtils.isEmpty;
024import static org.apache.juneau.commons.utils.ThrowableUtils.*;
025import java.io.*;
026import java.util.*;
027import java.util.concurrent.*;
028import java.util.stream.*;
029
030import org.apache.juneau.commons.concurrent.*;
031import org.apache.juneau.commons.utils.*;
032import org.apache.juneau.config.event.*;
033import org.apache.juneau.config.format.*;
034import org.apache.juneau.config.store.*;
035import org.apache.juneau.marshall.*;
036import org.apache.juneau.marshall.collections.*;
037
038/**
039 * Represents the parsed contents of a configuration.
040 *
041 * <h2 class='section'>Concurrency:</h2>
042 * <p>
043 * The parsed contents are held in a single immutable {@link State} snapshot published through a {@code volatile}
044 * reference.  Reads ({@link #getEntry(String,String)}, {@link #getKeys(String)}, {@link #getSections()},
045 * {@link #asMap()}, {@link #toString()}, etc.) are <b>lock-free</b>: they take a single snapshot of the current state
046 * and operate entirely on immutable collections, so there is no live-view leak and no
047 * {@link ConcurrentModificationException} hazard.  Writes ({@link #setEntry(String,String,String,String,List)},
048 * {@link #commit()}, {@link #onChange(String)}, etc.) build a fresh immutable state and swap it under a write lock,
049 * which is still required to make read-modify-write sequences atomic.
050 */
051@SuppressWarnings({
052   "java:S1206", // equals/hashCode not overridden; value equality not practical for this class
053   "java:S3776", // applyChange(), findDiffs() and loadIni() walk sections and entries with nested comparisons that read best as one pass
054   "resource" // Complex nested structure; value equality not practical
055})
056public class ConfigMap implements ConfigStoreListener {
057
058   static final class ConfigSection {
059
060      final String name;   // The config section name, or blank if the default section.  Never null.
061
062      final List<String> preLines;
063      final String rawLine;
064
065      final Map<String,ConfigMapEntry> oentries;
066      final Map<String,ConfigMapEntry> entries;
067
068      private ConfigSection(String name, String rawLine, List<String> preLines, Map<String,ConfigMapEntry> oentries, Map<String,ConfigMapEntry> entries) {
069         this.name = name;
070         this.rawLine = rawLine;
071         this.preLines = preLines;
072         this.oentries = oentries;
073         this.entries = entries;
074      }
075
076      /**
077       * Constructor for an empty (new) section.
078       */
079      ConfigSection(String name) {
080         this(name, "[" + name + "]", List.of(), Map.of(), Map.of());
081      }
082
083      /**
084       * Parses a section from a block of raw lines.
085       */
086      static ConfigSection parse(List<String> lines) {
087
088         String name2 = null;
089         String rawLine2 = null;
090
091         var preLines = new ArrayList<String>();
092         var entries = new LinkedHashMap<String,ConfigMapEntry>();
093
094         // S1: Looking for section.
095         // S2: Found section, looking for end.
096
097         var state = S1;
098         var start = 0;
099
100         for (var i = 0; i < lines.size(); i++) {
101            var l = lines.get(i);
102            var c = StringUtils.firstNonWhitespaceChar(l);
103            if (state == S1) {
104               if (c == '[') {
105                  var i1 = l.indexOf('[');
106                  var i2 = l.indexOf(']');
107                  name2 = l.substring(i1 + 1, i2).trim();
108                  rawLine2 = l;
109                  state = S2;
110                  start = i + 1;
111               } else {
112                  preLines.add(l);
113               }
114            } else {
115               if (c != '#' && l.indexOf('=') != -1) {
116                  var e = ConfigMapEntry.parse(l, lines.subList(start, i));
117                  if (entries.containsKey(e.key()))
118                     throw new ConfigException("Duplicate entry found in section [%s] of configuration:  %s", name2, e.key());
119                  entries.put(e.key(), e);
120                  start = i + 1;
121               }
122            }
123         }
124
125         // oentries and entries start identical; the immutable snapshot is shared safely.
126         var entriesU = u(entries);
127         return new ConfigSection(name2, rawLine2, u(preLines), entriesU, entriesU);
128      }
129
130      /**
131       * Returns a copy of this section with the specified entry added or replaced.
132       */
133      ConfigSection withEntry(ConfigMapEntry e) {
134         var m = new LinkedHashMap<>(entries);
135         m.put(e.key(), e);
136         return new ConfigSection(name, rawLine, preLines, oentries, u(m));
137      }
138
139      /**
140       * Returns a copy of this section with the specified entry removed.
141       */
142      ConfigSection withoutEntry(String key) {
143         if (! entries.containsKey(key))
144            return this;
145         var m = new LinkedHashMap<>(entries);
146         m.remove(key);
147         return new ConfigSection(name, rawLine, preLines, oentries, u(m));
148      }
149
150      /**
151       * Returns a copy of this section with the specified pre-lines.
152       */
153      ConfigSection withPreLines(List<String> preLines) {
154         return new ConfigSection(name, rawLine, u(cp(preLines)), oentries, entries);
155      }
156
157      Writer writeTo(Writer w) throws IOException {
158         for (var s : preLines)
159            w.append(s).append('\n');
160
161         if (! name.isEmpty())
162            w.append(rawLine).append('\n');
163         else {
164            // Need separation between default prelines and first-entry prelines.
165            if (! preLines.isEmpty())
166               w.append('\n');
167         }
168
169         for (var e : entries.values())
170            e.writeTo(w);
171
172         return w;
173      }
174   }
175
176   class Import {
177
178      private final ConfigMap configMap;
179      private final Map<ConfigEventListener,ConfigEventListener> listenerMap = synced(map());
180
181      Import(ConfigMap configMap) {
182         this.configMap = configMap;
183      }
184
185      @Override
186      public boolean equals(Object o) {
187         return o instanceof Import ir && ir.getConfigName().equals(getConfigName()); // HTT - Import.equals() only called via imports.contains() in findDiffs, which requires active import listeners
188      }
189
190      @Override
191      public int hashCode() {
192         return getConfigName().hashCode(); // HTT - Import.hashCode() only called when Import is used as map key, which requires active import functionality
193      }
194
195      ConfigMap getConfigMap() { return configMap; } // HTT - only called from import-related paths requiring active import listeners
196
197      String getConfigName() { return configMap.name; } // HTT - only called from import-related paths requiring active import listeners
198
199      synchronized Import register(Collection<ConfigEventListener> listeners) {
200         listeners.forEach(this::register);
201         return this;
202      }
203
204      synchronized Import register(ConfigEventListener listener) { // HTT - only called when registering with non-empty listener collection
205         var l2 = (ConfigEventListener)events -> {
206            var events2 = events.stream().filter(x -> ! hasEntry(x.getSection(), x.getKey())).collect(Collectors.toCollection(ConfigEvents::new));
207            if (! events2.isEmpty())
208               listener.onConfigChange(events2);
209         };
210         listenerMap.put(listener, l2);
211         configMap.register(l2);
212         return this;
213      }
214
215      synchronized Import unregister(ConfigEventListener listener) { // HTT - only called when import listener unregistration is triggered via unsupported import functionality
216         configMap.unregister(listenerMap.remove(listener));
217         return this;
218      }
219
220      synchronized Import unregisterAll() {
221         listenerMap.values().forEach(configMap::unregister);
222         listenerMap.clear();
223         return this;
224      }
225   }
226
227   /**
228    * Immutable snapshot of the parsed contents of a config.
229    *
230    * <p>
231    * All collections held here are unmodifiable.  A new instance is built for every mutation and published atomically
232    * through the {@code volatile} {@link ConfigMap#state} reference.
233    */
234   private record State(
235      String contents,
236      List<ConfigEvent> changes,
237      Map<String,ConfigSection> entries,
238      Map<String,ConfigSection> oentries,
239      List<Import> imports
240   ) {
241      State withChanges(List<ConfigEvent> changes) {
242         return new State(contents, changes, entries, oentries, imports);
243      }
244
245      State withImports(List<Import> imports) {
246         return new State(contents, changes, entries, oentries, imports);
247      }
248   }
249
250   private static void checkKeyName(String s) {
251      if (! isValidKeyName(s))
252         throw iaex("Invalid key name: '%s'", s);
253   }
254
255   private static void checkSectionName(String s) {
256      if (! (eq(s, "") || isValidNewSectionName(s)))
257         throw iaex("Invalid section name: '%s'", s);
258   }
259
260   private static boolean isValidConfigName(String s) {
261      s = s.trim();
262      if (s.isEmpty())
263         return false;
264      for (var i = 0; i < s.length(); i++) {
265         var c = s.charAt(i);
266         if (i == 0) {
267            if (! Character.isJavaIdentifierStart(c))
268               return false;
269         } else {
270            if (! Character.isJavaIdentifierPart(c))
271               return false;
272         }
273      }
274      return true;
275   }
276
277   private static boolean isValidKeyName(String s) {
278      if (s == null)
279         return false;
280      s = s.trim();
281      if (s.isEmpty())
282         return false;
283      for (var i = 0; i < s.length(); i++) {
284         var c = s.charAt(i);
285         if (c == '/' || c == '\\' || c == '[' || c == ']' || c == '=' || c == '#')
286            return false;
287      }
288      return true;
289   }
290
291   private static boolean isValidNewSectionName(String s) {
292      if (s == null)
293         return false;
294      s = s.trim();
295      if (s.isEmpty())
296         return false;
297      if (s.startsWith("/") || s.endsWith("/") || s.contains("//"))
298         return false;
299      for (var i = 0; i < s.length(); i++) {
300         var c = s.charAt(i);
301         if (c == '\\' || c == '[' || c == ']')
302            return false;
303      }
304      return true;
305   }
306
307   private final ConfigStore store;         // The store that created this object.
308   private final ConfigFormat format;       // Persistence format strategy.
309
310   final String name;                       // The name  of this object.
311
312   // Registered listeners listening for changes during saves or reloads.
313   private final Set<ConfigEventListener> listeners = new CopyOnWriteArraySet<>();
314
315   // The immutable copy-on-write snapshot of this map's contents.  Published atomically; read lock-free.
316   @SuppressWarnings({
317      "java:S3077" // Copy-on-write snapshot: State is a deeply-immutable graph (unmodifiable collections of immutable ConfigSection/ConfigMapEntry records) replaced wholesale under writeLock and never compound-mutated, so volatile safe-publication is sufficient for lock-free reads.
318   })
319   private volatile State state = new State("", List.of(), Map.of(), Map.of(), List.of());
320
321   private final SimpleReadWriteLock lock = new SimpleReadWriteLock();
322
323   /**
324    * Constructor.
325    *
326    * @param store The config store.
327    * @param name The config name.
328    * @throws IOException Thrown by underlying stream.
329    */
330   public ConfigMap(ConfigStore store, String name) throws IOException {
331      this(store, name, IniConfigFormat.INSTANCE);
332   }
333
334   public ConfigMap(ConfigStore store, String name, ConfigFormat format) throws IOException {
335      this.store = store;
336      this.name = name;
337      this.format = format == null ? IniConfigFormat.INSTANCE : format;
338      load(store.read(name));
339   }
340
341   ConfigMap(ConfigStore store, String name, String contents) throws IOException {
342      this(store, name, contents, IniConfigFormat.INSTANCE);
343   }
344
345   ConfigMap(ConfigStore store, String name, String contents, ConfigFormat format) throws IOException {
346      this.store = store;
347      this.name = name;
348      this.format = format == null ? IniConfigFormat.INSTANCE : format;
349      load(contents);
350   }
351
352   /**
353    * Returns the values in this config map as a map of maps.
354    *
355    * <p>
356    * This is considered a snapshot copy of the config map.
357    *
358    * <p>
359    * The returned map is modifiable, but modifications to the returned map are not reflected in the config map.
360    *
361    * @return A copy of this config as a map of maps.
362    */
363   public JsonMap asMap() {
364      var st = state;
365      var m = new JsonMap();
366      st.imports().forEach(y -> m.putAll(y.getConfigMap().asMap()));
367      st.entries().values().forEach(z -> {
368         var m2 = mapOfType(String.class, String.class);
369         z.entries.values().forEach(y -> m2.put(y.key(), y.value()));
370         m.put(z.name, m2);
371      });
372      return m;
373   }
374
375   /**
376    * Persist any changes made to this map and signal all listeners.
377    *
378    * <p>
379    * If the underlying contents of the file have changed, this will reload it and apply the changes
380    * on top of the modified file.
381    *
382    * <p>
383    * Subsequent changes made to the underlying file will also be signaled to all listeners.
384    *
385    * <p>
386    * We try saving the file up to 10 times.
387    * <br>If the file keeps changing on the file system, we throw an exception.
388    *
389    * @return This object.
390    * @throws IOException Thrown by underlying stream.
391    */
392   public ConfigMap commit() throws IOException {
393      try (var x = lock.write()) {
394         var newContents = asString();
395         for (var i = 0; i <= 10; i++) {
396            if (i == 10)
397               throw new ConfigException("Unable to store contents of config to store.");
398         var currentContents = store.write(name, state.contents(), newContents);
399         if (currentContents == null)
400            break;
401         onChange(currentContents); // HTT - retry loop requires concurrent write conflict causing store.write() to return non-null
402         }
403         state = state.withChanges(List.of());
404      }
405      return this;
406   }
407
408   /**
409    * Reads an entry from this map.
410    *
411    * @param section
412    *    The section name.
413    *    <br>Must not be <jk>null</jk>.
414    *    <br>Use blank to refer to the default section.
415    * @param key
416    *    The entry key.
417    *    <br>Must not be <jk>null</jk>.
418    * @return The entry, or <jk>null</jk> if the entry doesn't exist.
419    */
420   public ConfigMapEntry getEntry(String section, String key) {
421      checkSectionName(section);
422      checkKeyName(key);
423      var st = state;
424      var cs = st.entries().get(section);
425      var ce = cs == null ? null : cs.entries.get(key);
426
427      if (ce == null && ! st.imports().isEmpty())
428         ce = st.imports().stream().map(y -> y.getConfigMap().getEntry(section, key)).filter(Shorts::nn).findFirst().orElse(null);
429
430      return ce;
431   }
432
433   /**
434    * Returns the keys of the entries in the specified section.
435    *
436    * @param section
437    *    The section name.
438    *    <br>Must not be <jk>null</jk>.
439    *    <br>Use blank to refer to the default section.
440    * @return
441    *    An unmodifiable set of keys, or an empty set if the section doesn't exist.
442    */
443   public Set<String> getKeys(String section) {
444      checkSectionName(section);
445      var st = state;
446      var cs = st.entries().get(section);
447      Set<String> s = st.imports().isEmpty() && nn(cs) ? cs.entries.keySet() : set();
448      if (! st.imports().isEmpty()) {
449         st.imports().forEach(y -> s.addAll(y.getConfigMap().getKeys(section)));
450         if (nn(cs)) // HTT - requires imports where the section doesn't exist locally (cs==null), which needs active import listeners
451            s.addAll(cs.entries.keySet());
452      }
453      // Order-preserving immutable snapshot merging local + imported keys.
454      return u(cp(s));
455   }
456
457   /**
458    * Returns the listeners currently associated with this config map.
459    *
460    * @return The listeners currently associated with this config map.
461    */
462   public Set<ConfigEventListener> getListeners() { return u(cp(listeners)); }
463
464   /**
465    * Returns the pre-lines on the specified section.
466    *
467    * <p>
468    * The pre-lines are all lines such as blank lines and comments that preceed a section.
469    *
470    * @param section
471    *    The section name.
472    *    <br>Must not be <jk>null</jk>.
473    *    <br>Use blank to refer to the default section.
474    * @return
475    *    An unmodifiable list of lines, or <jk>null</jk> if the section doesn't exist.
476    */
477   public List<String> getPreLines(String section) {
478      checkSectionName(section);
479      var cs = state.entries().get(section);
480      return cs == null ? null : cs.preLines;
481   }
482
483   /**
484    * Returns the keys of the entries in the specified section.
485    *
486    * @return
487    *    An unmodifiable set of keys.
488    */
489   public Set<String> getSections() {
490      var st = state;
491      if (st.imports().isEmpty())
492         return u(cp(st.entries().keySet()));
493      // Order-preserving immutable snapshot merging local + imported sections.
494      var s = setOfType(String.class);
495      st.imports().forEach(y -> s.addAll(y.getConfigMap().getSections()));
496      s.addAll(st.entries().keySet());
497      return u(cp(s));
498   }
499
500   /**
501    * Returns <jk>true</jk> if this config has the specified section.
502    *
503    * @param section
504    *    The section name.
505    *    <br>Must not be <jk>null</jk>.
506    *    <br>Use blank to refer to the default section.
507    * @return <jk>true</jk> if this config has the specified section.
508    */
509   public boolean hasSection(String section) {
510      checkSectionName(section);
511      var st = state;
512      return nn(st.entries().get(section)) || st.imports().stream().anyMatch(x -> x.getConfigMap().hasSection(section)); // HTT - anyMatch() on imports requires active import listeners
513   }
514
515   /**
516    * Overwrites the contents of the config file.
517    *
518    * @param contents The new contents of the config file.
519    * @param synchronous Wait until the change has been persisted before returning this map.
520    * @return This object.
521    * @throws IOException Thrown by underlying stream.
522    * @throws InterruptedException Thread was interrupted.
523    */
524   public ConfigMap load(String contents, boolean synchronous) throws IOException, InterruptedException {
525
526      if (synchronous) {
527         final var latch = new CountDownLatch(1);
528         // The store may notify with text that differs from what was written (e.g. a profile store notifies with the
529         // merged text), so wait for the notification the store says this write will cause.  A late event for an
530         // earlier change never equals it, so it can't release us.
531         var expected = store.getNotifiedContents(name, contents);
532         var listener = (ConfigStoreListener)x -> {
533            if (eq(x, expected))
534               latch.countDown();
535         };
536         store.register(name, listener);
537         try {
538            store.write(name, null, contents);
539            if (! latch.await(30, TimeUnit.SECONDS))
540               throw new ConfigException("Unable to store contents of config to store."); // HTT - timeout requires a store that doesn't fire change events within 30 seconds
541            // Listeners fire in no particular order, so this map may not have seen the event yet.
542            onChange(expected);
543         } finally {
544            store.unregister(name, listener);
545         }
546      } else {
547         store.write(name, null, contents);
548      }
549      return this;
550   }
551
552   @Override /* Overridden from ConfigStoreListener */
553   public void onChange(String newContents) {
554      ConfigEvents changes2 = null;
555      try (var x = lock.write()) {
556         if (neq(state.contents(), newContents)) {
557            changes2 = findDiffs(newContents);
558            load(newContents);
559
560            // Reapply our changes on top of the modifications.
561            state.changes().forEach(y -> applyChange(false, y));
562         }
563      } catch (IOException e) {
564         throw toRex(e); // HTT - IOException from findDiffs/load requires a failing store operation during onChange
565      }
566      if (changes2 != null && ! changes2.isEmpty())
567         signal(changes2);
568   }
569
570   /**
571    * Registers an event listener on this map.
572    *
573    * @param listener The new listener.
574    * @return This object.
575    */
576   public ConfigMap register(ConfigEventListener listener) {
577      listeners.add(listener);
578      state.imports().forEach(x -> x.register(listener));
579      return this;
580   }
581
582   /**
583    * Removes an entry.
584    *
585    * @param section
586    *    The section name.
587    *    <br>Must not be <jk>null</jk>.
588    *    <br>Use blank to refer to the default section.
589    * @param key
590    *    The entry key.
591    *    <br>Must not be <jk>null</jk>.
592    * @return This object.
593    */
594   public ConfigMap removeEntry(String section, String key) {
595      checkSectionName(section);
596      checkKeyName(key);
597      return applyChange(true, ConfigEvent.removeEntry(name, section, key));
598   }
599
600   /**
601    * Removes the import statement with the specified name.
602    *
603    * <p>
604    * The imported config is detached from this map: its entries are no longer visible through this map and its
605    * change-propagation listeners are unregistered.  No-op if no matching import exists.
606    *
607    * @param section
608    *    The section name where the import statement was placed.
609    *    <br>Must not be <jk>null</jk>.
610    *    <br>Use blank for the default section.
611    * @param importName
612    *    The import name.
613    *    <br>Must not be <jk>null</jk>.
614    * @return This object.
615    */
616   public ConfigMap removeImport(String section, String importName) {
617      checkSectionName(section);
618      try (var x = lock.write()) {
619         var st = state;
620         st.imports().stream().filter(y -> y.getConfigName().equals(importName)).findFirst().ifPresent(y -> {
621            y.unregisterAll();
622            var newImports = new ArrayList<>(st.imports());
623            newImports.remove(y);
624            state = st.withImports(u(newImports));
625         });
626      }
627      return this;
628   }
629
630   /**
631    * Removes a section.
632    *
633    * <p>
634    * This eliminates all entries in the section as well.
635    *
636    * @param section
637    *    The section name.
638    *    <br>Must not be <jk>null</jk>.
639    *    <br>Use blank to refer to the default section.
640    * @return This object.
641    */
642   public ConfigMap removeSection(String section) {
643      checkSectionName(section);
644      return applyChange(true, ConfigEvent.removeSection(name, section));
645   }
646
647   /**
648    * Does a rollback of any changes on this map currently in memory.
649    *
650    * @return This object.
651    */
652   public ConfigMap rollback() {
653      if (! state.changes().isEmpty()) {
654         try (var x = lock.write()) {
655            state = state.withChanges(List.of());
656            load(state.contents());
657      } catch (IOException e) {
658         throw toRex(e); // HTT - IOException from private load() is not possible in normal operation (scanner/section parsing doesn't throw)
659      }
660   }
661   return this;
662}
663
664/**
665 * Adds or overwrites an existing entry.
666    *
667    * @param section
668    *    The section name.
669    *    <br>Must not be <jk>null</jk>.
670    *    <br>Use blank to refer to the default section.
671    * @param key
672    *    The entry key.
673    *    <br>Must not be <jk>null</jk>.
674    * @param value
675    *    The entry value.
676    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
677    * @param comment
678    *    Optional comment.
679    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
680    * @param preLines
681    *    Optional pre-lines.
682    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
683    * @return This object.
684    */
685   public ConfigMap setEntry(String section, String key, String value, String comment, List<String> preLines) {
686      checkSectionName(section);
687      checkKeyName(key);
688      return applyChange(true, ConfigEvent.setEntry(name, section, key, value, comment, preLines));
689   }
690
691   /**
692    * Creates the specified import statement if it doesn't already exist.
693    *
694    * <p>
695    * The named config is resolved through this map's {@link ConfigStore store} and attached to this map so that its
696    * entries become visible through this map and subsequent changes to it are propagated to this map's listeners.
697    * No-op if an import with the same name already exists.
698    *
699    * @param section
700    *    The section name where to place the import statement.
701    *    <br>Must not be <jk>null</jk>.
702    *    <br>Use blank for the default section.
703    * @param importName
704    *    The import name.
705    *    <br>Must not be <jk>null</jk>.
706    * @param preLines
707    *    Optional comment and blank lines to add immediately before the import statement.
708    *    <br>If <jk>null</jk>, previous pre-lines will not be replaced.
709    * @return This object.
710    */
711   public ConfigMap setImport(String section, String importName, List<String> preLines) {
712      checkSectionName(section);
713      if (! isValidConfigName(importName))
714         throw iaex("Invalid import config name: '%s'", importName);
715      try (var x = lock.write()) {
716         var st = state;
717         if (st.imports().stream().noneMatch(y -> y.getConfigName().equals(importName))) {
718            var newImports = new ArrayList<>(st.imports());
719            newImports.add(new Import(store.getMap(importName, format)).register(listeners));
720            state = st.withImports(u(newImports));
721         }
722      } catch (IOException e) {
723         throw toRex(e);
724      }
725      return this;
726   }
727
728   /**
729    * Adds a new section or replaces the pre-lines on an existing section.
730    *
731    * @param section
732    *    The section name.
733    *    <br>Must not be <jk>null</jk>.
734    *    <br>Use blank to refer to the default section.
735    * @param preLines
736    *    The pre-lines on the section.
737    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
738    * @return This object.
739    */
740   public ConfigMap setSection(String section, List<String> preLines) {
741      checkSectionName(section);
742      return applyChange(true, ConfigEvent.setSection(name, section, preLines));
743   }
744
745   @Override /* Overridden from Object */
746   public String toString() {
747      return asString();
748   }
749
750   /**
751    * Unregisters an event listener from this map.
752    *
753    * @param listener The listener to remove.
754    * @return This object.
755    */
756   public ConfigMap unregister(ConfigEventListener listener) {
757      listeners.remove(listener);
758      state.imports().forEach(x -> x.unregister(listener));
759      return this;
760   }
761
762   /**
763    * Serializes this map to the specified writer.
764    *
765    * @param w
766    *    The writer to serialize to.
767    *    <br>Must not be <jk>null</jk>.
768    * @return The same writer passed in.
769    * @throws IOException Thrown by underlying stream.
770    */
771   public Writer writeTo(Writer w) throws IOException {
772      w.append(toString());
773      return w;
774   }
775
776   private ConfigMap applyChange(boolean addToChangeList, ConfigEvent ce) {
777      try (var x = lock.write()) {
778         var st = state;
779         var section = ce.getSection();
780         var cs = st.entries().get(section);
781         var newEntries = new LinkedHashMap<>(st.entries());
782         switch (ce.getType()) {
783            case SET_ENTRY -> {
784               var cs2 = cs == null ? new ConfigSection(section) : cs;
785               var oe = cs2.entries.get(ce.getKey());
786               if (oe == null)
787                  oe = ConfigMapEntry.NULL;
788               // @formatter:off
789               cs2 = cs2.withEntry(new ConfigMapEntry(
790                  ce.getKey(),
791                  ce.getValue() == null ? oe.value() : ce.getValue(),
792                  ce.getComment() == null ? oe.comment() : ce.getComment(),
793                  ce.getPreLines() == null ? oe.preLines() : ce.getPreLines()
794               ));
795               // @formatter:on
796               newEntries.put(section, cs2);
797            }
798            case SET_SECTION -> {
799               var cs2 = cs == null ? new ConfigSection(section) : cs;
800               if (nn(ce.getPreLines()))
801                  cs2 = cs2.withPreLines(ce.getPreLines());
802               newEntries.put(section, cs2);
803            }
804            case REMOVE_ENTRY -> {
805               if (nn(cs))
806                  newEntries.put(section, cs.withoutEntry(ce.getKey()));
807            }
808            case REMOVE_SECTION -> {
809               if (nn(cs))
810                  newEntries.remove(section);
811            }
812         }
813         List<ConfigEvent> newChanges = st.changes();
814         if (addToChangeList) {
815            var c = new ArrayList<>(st.changes());
816            c.add(ce);
817            newChanges = u(c);
818         }
819         state = new State(st.contents(), newChanges, u(newEntries), st.oentries(), st.imports());
820      }
821      return this;
822   }
823
824   /**
825    * Serializes the current entries snapshot to INI form.
826    *
827    * @return The INI-formatted string.
828    */
829   public String asIniString() {
830      try {
831         var st = state;
832         var sw = new StringWriter();
833         for (var cs : st.entries().values())
834            cs.writeTo(sw);
835         return sw.toString();
836      } catch (IOException e) {
837         throw toRex(e);  // HTT - StringWriter.append() never throws IOException
838      }
839   }
840
841   // Serializes the current snapshot using the configured format.
842   private String asString() {
843      try {
844         return format.fromInternal(this);
845      } catch (IOException e) {
846         throw toRex(e); // HTT - in-memory format conversion failures are unexpected in normal operation
847      }
848   }
849
850   private ConfigEvents findDiffs(String updatedContents) throws IOException {
851      var changes2 = new ConfigEvents();
852      var newMap = new ConfigMap(store, name, updatedContents, format);
853
854      // Imports added.
855      for (var i : newMap.state.imports()) { // HTT - requires active import listeners with actual ConfigMap imports registered
856         if (! state.imports().contains(i)) {
857            for (var s : i.getConfigMap().state.entries().values()) {
858               for (var e : s.oentries.values()) {
859                  if (! newMap.hasEntry(s.name, e.key())) {
860                     changes2.add(ConfigEvent.setEntry(name, s.name, e.key(), e.value(), e.comment(), e.preLines()));
861                  }
862               }
863            }
864         }
865      }
866
867      // Imports removed.
868      for (var i : state.imports()) { // HTT - requires active import listeners with actual ConfigMap imports registered
869         if (! newMap.state.imports().contains(i)) {
870            for (var s : i.getConfigMap().state.entries().values()) {
871               for (var e : s.oentries.values()) {
872                  if (! newMap.hasEntry(s.name, e.key())) {
873                     changes2.add(ConfigEvent.removeEntry(name, s.name, e.key()));
874                  }
875               }
876            }
877         }
878      }
879
880      for (var ns : newMap.state.oentries().values()) {
881         var s = state.oentries().get(ns.name);
882         if (s == null) {
883            for (var ne : ns.entries.values()) {
884               changes2.add(ConfigEvent.setEntry(name, ns.name, ne.key(), ne.value(), ne.comment(), ne.preLines()));
885            }
886         } else {
887            for (var ne : ns.oentries.values()) {
888               var e = s.oentries.get(ne.key());
889               if (e == null || neq(e.value(), ne.value())) {
890                  changes2.add(ConfigEvent.setEntry(name, s.name, ne.key(), ne.value(), ne.comment(), ne.preLines()));
891               }
892            }
893            for (var e : s.oentries.values()) {
894               var ne = ns.oentries.get(e.key());
895               if (ne == null) {
896                  changes2.add(ConfigEvent.removeEntry(name, s.name, e.key()));
897               }
898            }
899         }
900      }
901
902      for (var s : state.oentries().values()) {
903         var ns = newMap.state.oentries().get(s.name);
904         if (ns == null) {
905            for (var e : s.oentries.values())
906               changes2.add(ConfigEvent.removeEntry(name, s.name, e.key()));
907         }
908      }
909
910      return changes2;
911   }
912
913   @SuppressWarnings({
914      "javabugs:S2259" // False positive: 'format' is a final field assigned a non-null value in every constructor (defaults to IniConfigFormat.INSTANCE); Sonar's symbolic execution can't follow the ternary default across the constructor-to-load() call chain.
915   })
916   private ConfigMap load(String contents) throws IOException {
917      var internalContents = format.toInternal(contents);
918      return loadIni(internalContents, contents);
919   }
920
921   @SuppressWarnings({
922      "java:S6541" // Single-threaded context; synchronization unnecessary
923   })
924   private ConfigMap loadIni(String contents, String originalContents) throws IOException {
925      if (contents == null)
926         contents = "";
927      var newContents = originalContents == null ? "" : originalContents;
928
929      // Detach any imports carried by the previous snapshot before rebuilding.
930      state.imports().forEach(Import::unregisterAll);
931
932      var imports2 = mapOfType(String.class, ConfigMap.class);
933
934      List<String> lines = ll();
935      try (var scanner = new Scanner(contents)) {
936         while (scanner.hasNextLine()) {
937            var line = scanner.nextLine();
938            var c = firstChar(line);
939            var c2 = StringUtils.lastNonWhitespaceChar(line);
940            if (c == '[') {
941               var l = line.trim();
942               if (c2 != ']' || ! isValidNewSectionName(l.substring(1, l.length() - 1)))
943                  throw new ConfigException("Invalid section name found in configuration:  %s", line);
944            } else if (c == '<') {
945               var l = line.trim();
946               var i = l.indexOf('>');
947               if (i != -1) {
948                  var l2 = l.substring(1, i);
949                  if (! isValidConfigName(l2))
950                     throw new ConfigException("Invalid import config name found in configuration:  %s", line);
951                  var l3 = l.substring(i + 1);
952                  if (! (isEmpty(l3) || firstChar(l3) == '#'))
953                     throw new ConfigException("Invalid import config name found in configuration:  %s", line);
954                  var importName = l2.trim();
955                  // Circular imports are detected in ConfigStore.getMap() which throws a clean ConfigException.
956                  if (! imports2.containsKey(importName))
957                     imports2.put(importName, store.getMap(importName, format));
958               }
959            }
960            lines.add(line);
961         }
962      }
963
964      List<Import> irl = listOfSize(imports2.size());
965      forEachReverse(toList(imports2.values()), x -> irl.add(new Import(x).register(listeners)));
966
967      // Add [blank] section.
968      var inserted = false;
969      var foundComment = false;
970      for (var li = lines.listIterator(); li.hasNext();) {
971         var l = li.next();
972         var c = firstNonWhitespaceChar(l);
973         if (c != '#') {
974            if (c == 0 && foundComment) {
975               li.set("[]");
976               inserted = true;
977            }
978            break;
979         }
980         foundComment = true;
981      }
982      if (! inserted)
983         lines.add(0, "[]");
984
985      // Collapse any multi-lines.
986      var li = lines.listIterator(lines.size());
987      StringBuilder accumulator = null;
988      while (li.hasPrevious()) {
989         var l = li.previous();
990         var c = firstChar(l);
991         if (c == '\t') {
992            c = firstNonWhitespaceChar(l);
993            if (c != '#') {
994               if (accumulator == null)
995                  accumulator = new StringBuilder(l.substring(1));
996               else
997                  accumulator.insert(0, l.substring(1) + "\n");
998               li.remove();
999            }
1000         } else if (accumulator != null) {
1001            li.set(l + "\n" + accumulator.toString());
1002            accumulator = null;
1003         }
1004      }
1005
1006      lines = cp(lines);
1007      var last = lines.size() - 1;
1008
1009      // S1: Looking for section.
1010      // S2: Found section, looking for start.
1011
1012      var state2 = S1;
1013
1014      var sections = listOfType(ConfigSection.class);
1015
1016      for (var i = last; i >= 0; i--) {
1017         var l = lines.get(i);
1018         var c = firstChar(l);
1019
1020         if (state2 == S1) {
1021            if (c == '[') {
1022               state2 = S2;
1023            }
1024         } else {
1025            if (c != '#' && (c == '[' || l.indexOf('=') != -1)) {
1026               sections.add(ConfigSection.parse(lines.subList(i + 1, last + 1)));
1027               last = i + 1;
1028               state2 = (c == '[' ? S2 : S1);
1029            }
1030         }
1031      }
1032
1033      sections.add(ConfigSection.parse(lines.subList(0, last + 1)));
1034
1035      var newEntries = new LinkedHashMap<String,ConfigSection>();
1036      for (var i = sections.size() - 1; i >= 0; i--) {
1037         var cs = sections.get(i);
1038         if (newEntries.containsKey(cs.name))
1039            throw new ConfigException("Duplicate section found in configuration:  [%s]", cs.name);
1040         newEntries.put(cs.name, cs);
1041      }
1042
1043      // entries and oentries start identical; the immutable snapshot is shared safely.  Changes carry over a reload.
1044      var entriesU = u(newEntries);
1045      state = new State(newContents, state.changes(), entriesU, entriesU, u(irl));
1046      return this;
1047   }
1048
1049   private void signal(ConfigEvents changes) {
1050      listeners.forEach(x -> x.onConfigChange(changes));
1051   }
1052
1053   boolean hasEntry(String section, String key) {
1054      var cs = state.entries().get(section);
1055      var ce = cs == null ? null : cs.entries.get(key);
1056      return nn(ce);
1057   }
1058}