UnusedKeyCheck.java
package io.jstach.rainbowgum;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.function.Supplier;
import org.jspecify.annotations.Nullable;
import io.jstach.rainbowgum.properties.ForwardingLogProperties;
/*
* Used by logging.debug=help and all. Records every property key read through the
* layers it wraps, then finds keys that were set in a layer that can list its keys but
* never read, suggesting the read key each was probably meant to be. Package private so
* nothing outside core can depend on it, even from the classpath.
*/
final class UnusedKeyCheck {
static final int MAX_DISTANCE = 2;
/*
* Read before or outside of LogConfig, so the recorder never sees them: core's own
* system property only keys, RainbowGumSystemLoggerFinder's, and the keys
* rainbowgum-simple-props reads while loading its files.
*/
static final Set<String> READ_BEFORE_CONFIG_KEYS = Set.of("logging.debug", "logging.global.queue.level",
"logging.global.queue.error", "logging.systemlogger.initialize", "logging.profiles",
"logging.simpleprops.strict", "logging.simpleprops.levelFile", "logging.simpleprops.levelFilePath");
/*
* System properties other libraries read that happen to start with logging.: Spring
* Boot's logging.config and JBoss LogManager's logging.configuration.
*/
static final Set<String> OTHER_LIBRARY_KEYS = Set.of("logging.config", "logging.configuration");
private final Set<String> reads = ConcurrentHashMap.newKeySet();
private final List<KeySource> sources;
/**
* A layer that can list its keys.
*
* @param properties the layer, used for descriptions.
* @param keys its keys.
*/
record KeySource(LogProperties properties, Supplier<? extends Collection<String>> keys) {
}
/**
* An unused key.
*
* @param key the key as set.
* @param description where it was set.
* @param suggestion the read key it was probably meant to be.
*/
record Unused(String key, String description, @Nullable String suggestion) {
/**
* Message for alerts and errors.
* @return message.
*/
String message() {
String message = "Property key '" + key + "' from " + description + " was set but not read during startup.";
if (suggestion != null) {
message += " Did you mean '" + suggestion + "'?";
}
return message;
}
}
/**
* Creates a check over sources.
* @param sources layers that can list their keys, highest priority first.
*/
UnusedKeyCheck(List<KeySource> sources) {
this.sources = List.copyOf(sources);
}
/**
* Wraps a layer so its reads are recorded.
* @param properties layer.
* @return recording layer.
*/
LogProperties wrap(LogProperties properties) {
if (properties instanceof LogProperties.MutableLogProperties mutable) {
return new MutableRecordingProperties(mutable, reads);
}
return new RecordingProperties(properties, reads);
}
/**
* Keys set in the sources that were not read, each reported once for the highest
* priority source that has it.
* @return unused keys in source order.
*/
List<Unused> unused() {
var candidates = new TreeSet<>(reads);
candidates.addAll(READ_BEFORE_CONFIG_KEYS);
var seen = new LinkedHashSet<String>();
var unused = new ArrayList<Unused>();
for (var source : sources) {
for (String key : source.keys().get()) {
if (isIgnored(key) || reads.contains(key) || !seen.add(key)) {
continue;
}
unused.add(new Unused(key, source.properties().description(key), suggestion(key, candidates)));
}
}
return unused;
}
/*
* Level and change keys are read per logger name as loggers are created, so not
* reading one at startup means nothing.
*/
static boolean isIgnored(String key) {
if (!key.startsWith(LogProperties.ROOT_PREFIX) || READ_BEFORE_CONFIG_KEYS.contains(key)
|| OTHER_LIBRARY_KEYS.contains(key)) {
return true;
}
if (inNamespace(key, LogProperties.LEVEL_PREFIX) || inNamespace(key, LogProperties.CHANGE_PREFIX)) {
return true;
}
String routePrefix = LogProperties.ROOT_PREFIX + "route.";
if (!key.startsWith(routePrefix)) {
return false;
}
int routeEnd = key.indexOf(LogProperties.SEP, routePrefix.length());
if (routeEnd < 0) {
return false;
}
String routeKey = key.substring(routeEnd + LogProperties.SEP.length());
return inNamespace(routeKey, "level");
}
private static boolean inNamespace(String key, String namespace) {
return key.equals(namespace) || key.startsWith(namespace + LogProperties.SEP);
}
/*
* Suggestions come from every key read, not only those read but not set: a system
* property meant to override a key set in a file is close to a key that was found.
*/
static @Nullable String suggestion(String key, Set<String> candidates) {
String normalized = normalize(key);
for (String candidate : candidates) {
if (normalize(candidate).equals(normalized)) {
return candidate;
}
}
var moved = movedKeys(key);
for (String candidate : candidates) {
for (String pattern : moved) {
if (matches(pattern, candidate)) {
return candidate;
}
}
}
String best = null;
int bestDistance = MAX_DISTANCE + 1;
for (String candidate : candidates) {
if (isIgnored(candidate) && !READ_BEFORE_CONFIG_KEYS.contains(candidate)) {
continue;
}
int distance = distance(key, candidate, bestDistance);
if (distance < bestDistance) {
best = candidate;
bestDistance = distance;
}
}
return best;
}
/*
* Where a key from an older layout is now, with ANY_SEGMENT standing for the type's
* URI scheme when the old key does not say it:
*
* logging.encoder.console.level -> logging.appender.console.encoder.*.level
* logging.encoder.ttll.console.level -> logging.appender.console.encoder.ttll.level
* logging.appender.console.encoder.level -> logging.appender.console.encoder.*.level
* logging.appender.console.encoder -> logging.appender.console.encoder.uri
* logging.publisher.default.bufferSize ->
* logging.route.default.publisher.*.bufferSize
* logging.route.default.publisher.bufferSize ->
* logging.route.default.publisher.*.bufferSize logging.route.default.publisher ->
* logging.route.default.publisher.uri
*
* The third and sixth only apply to a single property name segment, since a longer
* key under the encoder or publisher is a typed key, possibly left over from a type
* switched away from.
*/
static List<String> movedKeys(String key) {
var segments = List.of(key.split("\\.", -1));
if (segments.size() < 3 || !segments.get(0).equals("logging")) {
return List.of();
}
String component = segments.get(1);
if (component.equals("encoder") || component.equals("output")) {
var moved = new ArrayList<String>();
if (segments.size() > 3) {
moved.add(movedKey("appender", segments.get(2), component, ANY_SEGMENT,
segments.subList(3, segments.size())));
}
if (segments.size() > 4) {
moved.add(movedKey("appender", segments.get(3), component, segments.get(2),
segments.subList(4, segments.size())));
}
return moved;
}
if (component.equals("publisher") && segments.size() > 3) {
return List
.of(movedKey("route", segments.get(2), component, ANY_SEGMENT, segments.subList(3, segments.size())));
}
boolean typed = switch (component) {
case "appender" ->
segments.size() >= 4 && (segments.get(3).equals("encoder") || segments.get(3).equals("output"));
case "route" -> segments.size() >= 4 && segments.get(3).equals("publisher");
default -> false;
};
if (typed) {
if (segments.size() == 4) {
return List.of(key + ".uri");
}
if (segments.size() == 5 && !segments.get(4).equals("uri")) {
return List
.of(movedKey(component, segments.get(2), segments.get(3), ANY_SEGMENT, segments.subList(4, 5)));
}
}
return List.of();
}
private static final String ANY_SEGMENT = "*";
private static String movedKey(String parent, String name, String component, String scheme, List<String> rest) {
return "logging." + parent + "." + name + "." + component + "." + scheme + "."
+ String.join(LogProperties.SEP, rest);
}
/*
* Whether a candidate is a moved key pattern, compared like normalize with
* ANY_SEGMENT matching any one segment.
*/
static boolean matches(String pattern, String candidate) {
String[] p = normalize(pattern).split("\\.", -1);
String[] c = normalize(candidate).split("\\.", -1);
if (p.length != c.length) {
return false;
}
for (int i = 0; i < p.length; i++) {
if (!p[i].equals(ANY_SEGMENT) && !p[i].equals(c[i])) {
return false;
}
}
return true;
}
/*
* The same key as far as a user typing it is concerned: keyValues, keyvalues,
* KEYVALUES, key-values, and key_values.
*/
static String normalize(String key) {
return key.toLowerCase(Locale.ROOT).replace("-", "").replace("_", "");
}
/*
* Levenshtein distance, giving up once it cannot be under the limit.
*/
static int distance(String a, String b, int limit) {
if (Math.abs(a.length() - b.length()) >= limit) {
return limit;
}
int[] previous = new int[b.length() + 1];
int[] current = new int[b.length() + 1];
for (int j = 0; j <= b.length(); j++) {
previous[j] = j;
}
for (int i = 1; i <= a.length(); i++) {
current[0] = i;
int rowMin = current[0];
for (int j = 1; j <= b.length(); j++) {
int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
current[j] = Math.min(Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
rowMin = Math.min(rowMin, current[j]);
}
if (rowMin >= limit) {
return limit;
}
int[] swap = previous;
previous = current;
current = swap;
}
return previous[b.length()];
}
/*
* Records the key of every lookup. visit records the key asked for, since a layer
* that renames keys may answer through another properties.
*/
private static sealed class RecordingProperties extends ForwardingLogProperties permits MutableRecordingProperties {
private final LogProperties delegate;
private final Set<String> reads;
RecordingProperties(LogProperties delegate, Set<String> reads) {
this.delegate = delegate;
this.reads = reads;
}
@Override
protected LogProperties delegate() {
return delegate;
}
@Override
public @Nullable String valueOrNull(String key) {
reads.add(key);
return super.valueOrNull(key);
}
@Override
public @Nullable List<String> listOrNull(String key) {
reads.add(key);
return super.listOrNull(key);
}
@Override
public @Nullable Map<String, String> mapOrNull(String key) {
reads.add(key);
var map = super.mapOrNull(key);
/*
* Keep the delegate's map parsing, including native map support. When no
* single value supplies the map, its .keys and member properties supply it.
* Those nested reads happen inside the delegate rather than this decorator.
*/
if (map != null && delegate.valueOrNull(key) == null) {
reads.add(key + LogProperties.SEP + LogProperties.MAP_KEYS_SUFFIX);
for (var member : map.keySet()) {
reads.add(key + LogProperties.SEP + member);
}
}
return map;
}
@Override
public <R extends @Nullable Object> @Nullable R visit(String key,
BiFunction<LogProperties, String, @Nullable R> visitor) {
reads.add(key);
return super.visit(key, visitor);
}
}
private static final class MutableRecordingProperties extends RecordingProperties
implements LogProperties.MutableLogProperties {
private final LogProperties.MutableLogProperties mutable;
MutableRecordingProperties(LogProperties.MutableLogProperties mutable, Set<String> reads) {
super(mutable, reads);
this.mutable = mutable;
}
@Override
public LogProperties.MutableLogProperties put(String key, @Nullable String value) {
mutable.put(key, value);
return this;
}
}
}