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}