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 * <h5 class='section'>Concurrency:</h5>
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,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 modifiers
678    *    Optional modifiers.
679    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
680    * @param comment
681    *    Optional comment.
682    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
683    * @param preLines
684    *    Optional pre-lines.
685    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
686    * @return This object.
687    */
688   public ConfigMap setEntry(String section, String key, String value, String modifiers, String comment, List<String> preLines) {
689      checkSectionName(section);
690      checkKeyName(key);
691      return applyChange(true, ConfigEvent.setEntry(name, section, key, value, modifiers, comment, preLines));
692   }
693
694   /**
695    * Creates the specified import statement if it doesn't already exist.
696    *
697    * <p>
698    * The named config is resolved through this map's {@link ConfigStore store} and attached to this map so that its
699    * entries become visible through this map and subsequent changes to it are propagated to this map's listeners.
700    * No-op if an import with the same name already exists.
701    *
702    * @param section
703    *    The section name where to place the import statement.
704    *    <br>Must not be <jk>null</jk>.
705    *    <br>Use blank for the default section.
706    * @param importName
707    *    The import name.
708    *    <br>Must not be <jk>null</jk>.
709    * @param preLines
710    *    Optional comment and blank lines to add immediately before the import statement.
711    *    <br>If <jk>null</jk>, previous pre-lines will not be replaced.
712    * @return This object.
713    */
714   public ConfigMap setImport(String section, String importName, List<String> preLines) {
715      checkSectionName(section);
716      if (! isValidConfigName(importName))
717         throw iaex("Invalid import config name: '%s'", importName);
718      try (var x = lock.write()) {
719         var st = state;
720         if (st.imports().stream().noneMatch(y -> y.getConfigName().equals(importName))) {
721            var newImports = new ArrayList<>(st.imports());
722            newImports.add(new Import(store.getMap(importName, format)).register(listeners));
723            state = st.withImports(u(newImports));
724         }
725      } catch (IOException e) {
726         throw toRex(e);
727      }
728      return this;
729   }
730
731   /**
732    * Adds a new section or replaces the pre-lines on an existing section.
733    *
734    * @param section
735    *    The section name.
736    *    <br>Must not be <jk>null</jk>.
737    *    <br>Use blank to refer to the default section.
738    * @param preLines
739    *    The pre-lines on the section.
740    *    <br>If <jk>null</jk>, the previous value will not be overwritten.
741    * @return This object.
742    */
743   public ConfigMap setSection(String section, List<String> preLines) {
744      checkSectionName(section);
745      return applyChange(true, ConfigEvent.setSection(name, section, preLines));
746   }
747
748   @Override /* Overridden from Object */
749   public String toString() {
750      return asString();
751   }
752
753   /**
754    * Unregisters an event listener from this map.
755    *
756    * @param listener The listener to remove.
757    * @return This object.
758    */
759   public ConfigMap unregister(ConfigEventListener listener) {
760      listeners.remove(listener);
761      state.imports().forEach(x -> x.unregister(listener));
762      return this;
763   }
764
765   /**
766    * Serializes this map to the specified writer.
767    *
768    * @param w
769    *    The writer to serialize to.
770    *    <br>Must not be <jk>null</jk>.
771    * @return The same writer passed in.
772    * @throws IOException Thrown by underlying stream.
773    */
774   public Writer writeTo(Writer w) throws IOException {
775      w.append(toString());
776      return w;
777   }
778
779   private ConfigMap applyChange(boolean addToChangeList, ConfigEvent ce) {
780      try (var x = lock.write()) {
781         var st = state;
782         var section = ce.getSection();
783         var cs = st.entries().get(section);
784         var newEntries = new LinkedHashMap<>(st.entries());
785         switch (ce.getType()) {
786            case SET_ENTRY -> {
787               var cs2 = cs == null ? new ConfigSection(section) : cs;
788               var oe = cs2.entries.get(ce.getKey());
789               if (oe == null)
790                  oe = ConfigMapEntry.NULL;
791               // @formatter:off
792               cs2 = cs2.withEntry(new ConfigMapEntry(
793                  ce.getKey(),
794                  ce.getValue() == null ? oe.value() : ce.getValue(),
795                  ce.getModifiers() == null ? oe.modifiers() : ce.getModifiers(),
796                  ce.getComment() == null ? oe.comment() : ce.getComment(),
797                  ce.getPreLines() == null ? oe.preLines() : ce.getPreLines()
798               ));
799               // @formatter:on
800               newEntries.put(section, cs2);
801            }
802            case SET_SECTION -> {
803               var cs2 = cs == null ? new ConfigSection(section) : cs;
804               if (nn(ce.getPreLines()))
805                  cs2 = cs2.withPreLines(ce.getPreLines());
806               newEntries.put(section, cs2);
807            }
808            case REMOVE_ENTRY -> {
809               if (nn(cs))
810                  newEntries.put(section, cs.withoutEntry(ce.getKey()));
811            }
812            case REMOVE_SECTION -> {
813               if (nn(cs))
814                  newEntries.remove(section);
815            }
816         }
817         List<ConfigEvent> newChanges = st.changes();
818         if (addToChangeList) {
819            var c = new ArrayList<>(st.changes());
820            c.add(ce);
821            newChanges = u(c);
822         }
823         state = new State(st.contents(), newChanges, u(newEntries), st.oentries(), st.imports());
824      }
825      return this;
826   }
827
828   /**
829    * Serializes the current entries snapshot to INI form.
830    *
831    * @return The INI-formatted string.
832    */
833   public String asIniString() {
834      try {
835         var st = state;
836         var sw = new StringWriter();
837         for (var cs : st.entries().values())
838            cs.writeTo(sw);
839         return sw.toString();
840      } catch (IOException e) {
841         throw toRex(e);  // HTT - StringWriter.append() never throws IOException
842      }
843   }
844
845   // Serializes the current snapshot using the configured format.
846   private String asString() {
847      try {
848         return format.fromInternal(this);
849      } catch (IOException e) {
850         throw toRex(e); // HTT - in-memory format conversion failures are unexpected in normal operation
851      }
852   }
853
854   private ConfigEvents findDiffs(String updatedContents) throws IOException {
855      var changes2 = new ConfigEvents();
856      var newMap = new ConfigMap(store, name, updatedContents, format);
857
858      // Imports added.
859      for (var i : newMap.state.imports()) { // HTT - requires active import listeners with actual ConfigMap imports registered
860         if (! state.imports().contains(i)) {
861            for (var s : i.getConfigMap().state.entries().values()) {
862               for (var e : s.oentries.values()) {
863                  if (! newMap.hasEntry(s.name, e.key())) {
864                     changes2.add(ConfigEvent.setEntry(name, s.name, e.key(), e.value(), e.modifiers(), e.comment(), e.preLines()));
865                  }
866               }
867            }
868         }
869      }
870
871      // Imports removed.
872      for (var i : state.imports()) { // HTT - requires active import listeners with actual ConfigMap imports registered
873         if (! newMap.state.imports().contains(i)) {
874            for (var s : i.getConfigMap().state.entries().values()) {
875               for (var e : s.oentries.values()) {
876                  if (! newMap.hasEntry(s.name, e.key())) {
877                     changes2.add(ConfigEvent.removeEntry(name, s.name, e.key()));
878                  }
879               }
880            }
881         }
882      }
883
884      for (var ns : newMap.state.oentries().values()) {
885         var s = state.oentries().get(ns.name);
886         if (s == null) {
887            for (var ne : ns.entries.values()) {
888               changes2.add(ConfigEvent.setEntry(name, ns.name, ne.key(), ne.value(), ne.modifiers(), ne.comment(), ne.preLines()));
889            }
890         } else {
891            for (var ne : ns.oentries.values()) {
892               var e = s.oentries.get(ne.key());
893               if (e == null || neq(e.value(), ne.value())) {
894                  changes2.add(ConfigEvent.setEntry(name, s.name, ne.key(), ne.value(), ne.modifiers(), ne.comment(), ne.preLines()));
895               }
896            }
897            for (var e : s.oentries.values()) {
898               var ne = ns.oentries.get(e.key());
899               if (ne == null) {
900                  changes2.add(ConfigEvent.removeEntry(name, s.name, e.key()));
901               }
902            }
903         }
904      }
905
906      for (var s : state.oentries().values()) {
907         var ns = newMap.state.oentries().get(s.name);
908         if (ns == null) {
909            for (var e : s.oentries.values())
910               changes2.add(ConfigEvent.removeEntry(name, s.name, e.key()));
911         }
912      }
913
914      return changes2;
915   }
916
917   @SuppressWarnings({
918      "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.
919   })
920   private ConfigMap load(String contents) throws IOException {
921      var internalContents = format.toInternal(contents);
922      return loadIni(internalContents, contents);
923   }
924
925   @SuppressWarnings({
926      "java:S6541" // Single-threaded context; synchronization unnecessary
927   })
928   private ConfigMap loadIni(String contents, String originalContents) throws IOException {
929      if (contents == null)
930         contents = "";
931      var newContents = originalContents == null ? "" : originalContents;
932
933      // Detach any imports carried by the previous snapshot before rebuilding.
934      state.imports().forEach(Import::unregisterAll);
935
936      var imports2 = mapOfType(String.class, ConfigMap.class);
937
938      List<String> lines = ll();
939      try (var scanner = new Scanner(contents)) {
940         while (scanner.hasNextLine()) {
941            var line = scanner.nextLine();
942            var c = firstChar(line);
943            var c2 = StringUtils.lastNonWhitespaceChar(line);
944            if (c == '[') {
945               var l = line.trim();
946               if (c2 != ']' || ! isValidNewSectionName(l.substring(1, l.length() - 1)))
947                  throw new ConfigException("Invalid section name found in configuration:  %s", line);
948            } else if (c == '<') {
949               var l = line.trim();
950               var i = l.indexOf('>');
951               if (i != -1) {
952                  var l2 = l.substring(1, i);
953                  if (! isValidConfigName(l2))
954                     throw new ConfigException("Invalid import config name found in configuration:  %s", line);
955                  var l3 = l.substring(i + 1);
956                  if (! (isEmpty(l3) || firstChar(l3) == '#'))
957                     throw new ConfigException("Invalid import config name found in configuration:  %s", line);
958                  var importName = l2.trim();
959                  // Circular imports are detected in ConfigStore.getMap() which throws a clean ConfigException.
960                  if (! imports2.containsKey(importName))
961                     imports2.put(importName, store.getMap(importName, format));
962               }
963            }
964            lines.add(line);
965         }
966      }
967
968      List<Import> irl = listOfSize(imports2.size());
969      forEachReverse(toList(imports2.values()), x -> irl.add(new Import(x).register(listeners)));
970
971      // Add [blank] section.
972      var inserted = false;
973      var foundComment = false;
974      for (var li = lines.listIterator(); li.hasNext();) {
975         var l = li.next();
976         var c = firstNonWhitespaceChar(l);
977         if (c != '#') {
978            if (c == 0 && foundComment) {
979               li.set("[]");
980               inserted = true;
981            }
982            break;
983         }
984         foundComment = true;
985      }
986      if (! inserted)
987         lines.add(0, "[]");
988
989      // Collapse any multi-lines.
990      var li = lines.listIterator(lines.size());
991      StringBuilder accumulator = null;
992      while (li.hasPrevious()) {
993         var l = li.previous();
994         var c = firstChar(l);
995         if (c == '\t') {
996            c = firstNonWhitespaceChar(l);
997            if (c != '#') {
998               if (accumulator == null)
999                  accumulator = new StringBuilder(l.substring(1));
1000               else
1001                  accumulator.insert(0, l.substring(1) + "\n");
1002               li.remove();
1003            }
1004         } else if (accumulator != null) {
1005            li.set(l + "\n" + accumulator.toString());
1006            accumulator = null;
1007         }
1008      }
1009
1010      lines = cp(lines);
1011      var last = lines.size() - 1;
1012
1013      // S1: Looking for section.
1014      // S2: Found section, looking for start.
1015
1016      var state2 = S1;
1017
1018      var sections = listOfType(ConfigSection.class);
1019
1020      for (var i = last; i >= 0; i--) {
1021         var l = lines.get(i);
1022         var c = firstChar(l);
1023
1024         if (state2 == S1) {
1025            if (c == '[') {
1026               state2 = S2;
1027            }
1028         } else {
1029            if (c != '#' && (c == '[' || l.indexOf('=') != -1)) {
1030               sections.add(ConfigSection.parse(lines.subList(i + 1, last + 1)));
1031               last = i + 1;
1032               state2 = (c == '[' ? S2 : S1);
1033            }
1034         }
1035      }
1036
1037      sections.add(ConfigSection.parse(lines.subList(0, last + 1)));
1038
1039      var newEntries = new LinkedHashMap<String,ConfigSection>();
1040      for (var i = sections.size() - 1; i >= 0; i--) {
1041         var cs = sections.get(i);
1042         if (newEntries.containsKey(cs.name))
1043            throw new ConfigException("Duplicate section found in configuration:  [%s]", cs.name);
1044         newEntries.put(cs.name, cs);
1045      }
1046
1047      // entries and oentries start identical; the immutable snapshot is shared safely.  Changes carry over a reload.
1048      var entriesU = u(newEntries);
1049      state = new State(newContents, state.changes(), entriesU, entriesU, u(irl));
1050      return this;
1051   }
1052
1053   private void signal(ConfigEvents changes) {
1054      listeners.forEach(x -> x.onConfigChange(changes));
1055   }
1056
1057   boolean hasEntry(String section, String key) {
1058      var cs = state.entries().get(section);
1059      var ce = cs == null ? null : cs.entries.get(key);
1060      return nn(ce);
1061   }
1062}