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}