SimpleLogProperties.java
package io.jstach.rainbowgum.simple.props;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.util.LinkedHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.function.Consumer;
import org.jspecify.annotations.Nullable;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.LogReporter;
class SimpleLogProperties implements LogProperties.Listable, LogReporter.Reportable {
private final String resource;
private final Map<String, PropertyEntry> entries;
private final List<PropertyEntry> invalidEntries;
record PropertyEntry(String key, String value, long line) {
}
private SimpleLogProperties(String resource, Map<String, PropertyEntry> entries,
List<PropertyEntry> invalidEntries) {
super();
this.resource = resource;
this.entries = entries;
this.invalidEntries = invalidEntries;
}
@Override
public @Nullable String valueOrNull(String key) {
var e = entries.get(key);
if (e == null)
return null;
return e.value();
}
/*
* Every key in file order, with the logging prefix when short keys added it.
*/
@Override
public List<String> keys() {
return List.<String>copyOf(entries.keySet());
}
static SimpleLogProperties read(Reader reader, String resource) throws IOException {
Map<String, PropertyEntry> m = new LinkedHashMap<>();
var invalidEntries = new ArrayList<PropertyEntry>();
readProperties(reader, e -> {
m.put(e.key, e);
if (!e.key().startsWith(LogProperties.ROOT_PREFIX)) {
invalidEntries.add(e);
}
});
return new SimpleLogProperties(resource, m, List.copyOf(invalidEntries));
}
/*
* The same entries with the logging prefix added to keys that lack it, each keeping
* its key as written for descriptions. A key given both ways keeps the later line, as
* putting into the map in file order does for any repeated key.
*/
SimpleLogProperties withShortKeys() {
Map<String, PropertyEntry> m = new LinkedHashMap<>();
for (var e : entries.values()) {
String key = e.key().startsWith(LogProperties.ROOT_PREFIX) ? e.key() : LogProperties.ROOT_PREFIX + e.key();
var previous = m.get(key);
if (previous == null || previous.line() <= e.line()) {
m.put(key, e);
}
}
return new SimpleLogProperties(resource, m, List.of());
}
/*
* An error for every key without the logging prefix, saying so plainly when the key
* is one only java.util.logging uses.
*/
List<String> prefixErrors() {
return invalidEntries.stream().map(e -> isJulKey(e.key()) ? julError(e) : prefixError(e)).toList();
}
/*
* An error for every key without the logging prefix that only java.util.logging uses,
* since its configuration file shares the logging.properties name.
*/
List<String> julErrors() {
return invalidEntries.stream().filter(e -> isJulKey(e.key())).map(this::julError).toList();
}
/*
* Keys no Rainbow Gum property has, even written without the logging prefix: a key
* ending in .level is not one of them because Rainbow Gum has such keys.
*/
static boolean isJulKey(String key) {
return key.equals("handlers") || key.endsWith(".handlers") || key.endsWith(".useParentHandlers")
|| key.startsWith(".") || key.equals("config") || key.startsWith("java.util.logging.");
}
private String prefixError(PropertyEntry e) {
return "Property key should start with: '" + LogProperties.ROOT_PREFIX + "'. key: '" + e.key() + "' from "
+ LogProperties.descriptionForResource(DESCRIPTION, resource, e.key(), e.line());
}
private String julError(PropertyEntry e) {
return "Property key looks like java.util.logging configuration, which Rainbow Gum does not read. key: '"
+ e.key() + "' from " + LogProperties.descriptionForResource(DESCRIPTION, resource, e.key(), e.line());
}
private static final String DESCRIPTION = "SIMPLE_PROPS";
@Override
public String description(String key) {
var v = entries.get(key);
if (v != null) {
return LogProperties.descriptionForResource(DESCRIPTION, this.resource, v.key(), v.line());
}
return LogProperties.descriptionForResource(DESCRIPTION, this.resource, key);
}
@Override
public void report(Appendable out) throws IOException {
out.append(DESCRIPTION).append("[").append(resource).append("]");
}
/*
* Matches the order SimpleProperties.Builder previously passed to
* LogProperties.builder().order(100) for the classpath-file source: lower than
* SYSTEM_PROPERTIES (400) and ENVIRONMENT_VARIABLES (300), so the file stays the
* lowest-precedence, tried-last source when coalesced with those two.
*/
@Override
public int order() {
return 100;
}
static Map<String, PropertyEntry> readProperties(String input) {
Map<String, PropertyEntry> m = new LinkedHashMap<>();
try {
readProperties(new StringReader(input), e -> {
m.put(e.key, e);
});
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
return m;
}
/*
* java.util.Properties#load(Reader) reads its input through its own large internal
* buffer before parsing any entries out of it, so wrapping the Reader in a
* LineNumberReader and reading Properties.load()'s line number off it inside an
* overridden put() does not work: by the time the first entry is parsed, the reader
* has already been drained to (or near) EOF, and every entry ends up reporting the
* same, final line number. Confirmed directly: a three-key input reported line 6 (the
* file's last line) for the first key too, not line 1.
*
* Instead, this pre-splits the input into logical entries (one key=value each,
* possibly spanning multiple physical lines via a trailing-backslash continuation)
* using the same comment (# or !) and continuation rules Properties.load() itself
* uses, so line numbers can be tracked while walking physical lines ourselves - then
* re-parses each isolated entry through a real Properties#load() call, so escaping,
* continuation joining, and key/value separation are still handled by the JDK's own
* parser rather than reimplemented here.
*/
static void readProperties(Reader reader, Consumer<PropertyEntry> consumer) throws IOException {
BufferedReader br = reader instanceof BufferedReader b ? b : new BufferedReader(reader);
StringBuilder chunk = new StringBuilder();
long chunkStartLine = -1;
long lineNumber = 0;
String line;
while ((line = br.readLine()) != null) {
lineNumber++;
if (chunk.length() == 0) {
String trimmed = line.stripLeading();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) {
continue;
}
chunkStartLine = lineNumber;
}
chunk.append(line);
if (endsWithContinuation(line)) {
chunk.append('\n');
continue;
}
parseChunk(chunk.toString(), chunkStartLine, consumer);
chunk.setLength(0);
}
if (chunk.length() > 0) {
parseChunk(chunk.toString(), chunkStartLine, consumer);
}
}
private static void parseChunk(String chunkText, long line, Consumer<PropertyEntry> consumer) throws IOException {
Properties p = new Properties();
p.load(new StringReader(chunkText));
for (var e : p.entrySet()) {
consumer.accept(new PropertyEntry((String) e.getKey(), (String) e.getValue(), line));
}
}
/*
* A logical line continues onto the next physical line when it ends in an odd number
* of backslashes (an even number is that many literal, escaped backslashes with no
* continuation; see java.util.Properties's own documented format).
*/
private static boolean endsWithContinuation(String line) {
int count = 0;
for (int i = line.length() - 1; i >= 0 && line.charAt(i) == '\\'; i--) {
count++;
}
return count % 2 == 1;
}
}