LogfmtKeyValuesFormatter.java

package io.jstach.rainbowgum.format;

import java.util.List;
import java.util.Locale;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.KeyValues;
import io.jstach.rainbowgum.KeyValues.KeyValuesConsumer;
import io.jstach.rainbowgum.LogEvent;
import io.jstach.rainbowgum.LogFormatter;
import io.jstach.rainbowgum.LogFormatter.KeyValueNullStrategy;

/*
 * logfmt key values for KeyValuesFormatterBuilder, and logfmt's quoting and escaping,
 * which LogfmtFormatter, the logfmt encoder, also uses for its own fields.
 */
record LogfmtKeyValuesFormatter(@Nullable List<String> keys,
		KeyValueNullStrategy nullStrategy) implements LogFormatter.EventFormatter, KeyValuesConsumer<StringBuilder> {

	/*
	 * All key values, a null value written as key=.
	 */
	static final LogfmtKeyValuesFormatter ALL = new LogfmtKeyValuesFormatter(null, KeyValueNullStrategy.KEEP);

	@Override
	public void format(StringBuilder output, LogEvent event) {
		var keyValues = event.keyValues();
		var selected = keys;
		if (selected == null) {
			keyValues.forEach(this, 0, output);
		}
		else {
			int count = 0;
			for (String key : selected) {
				count = accept(keyValues, key, keyValues.getValueOrNull(key), count, output);
			}
		}
	}

	@Override
	public int accept(KeyValues values, String key, @Nullable String value, int count, StringBuilder output) {
		if (value == null) {
			switch (nullStrategy) {
				case SKIP -> {
					return count;
				}
				case EMPTY -> value = "";
				case KEEP -> {
					/* written as key= */
				}
			}
		}
		if (count > 0) {
			output.append(' ');
		}
		appendKey(output, key);
		output.append('=');
		appendValue(output, value);
		return count + 1;
	}

	static void appendValue(StringBuilder output, @Nullable String value) {
		if (value == null) {
			return;
		}
		int start = output.length();
		output.append(value);
		quoteInPlace(output, start);
	}

	/*
	 * Percent-encodes the characters logfmt keys do not allow, and % itself, so two
	 * different keys never write the same name. The empty key is a lone %, which no
	 * encoded non-empty key can be.
	 */
	static void appendKey(StringBuilder output, String key) {
		if (key.isEmpty()) {
			output.append('%');
			return;
		}
		for (int i = 0; i < key.length(); i++) {
			char c = key.charAt(i);
			if (isSpecial(c) || c == '\\' || c == '%') {
				output.append('%').append(HEX[c >> 4]).append(HEX[c & 0xF]);
			}
			else {
				output.append(c);
			}
		}
	}

	private static final char[] HEX = "0123456789ABCDEF".toCharArray();

	/*
	 * Quotes and escapes the text from start to the end of the output if logfmt needs it,
	 * in place so the common unquoted case copies nothing.
	 */
	static void quoteInPlace(StringBuilder output, int start) {
		int end = output.length();
		if (end > start && !needsQuotes(output, start, end)) {
			return;
		}
		output.insert(start, '"');
		for (int i = start + 1; i <= end; i++) {
			String escape = escape(output.charAt(i));
			if (escape != null) {
				output.replace(i, i + 1, escape);
				i += escape.length() - 1;
				end += escape.length() - 1;
			}
		}
		output.append('"');
	}

	private static boolean needsQuotes(StringBuilder output, int start, int end) {
		for (int i = start; i < end; i++) {
			char c = output.charAt(i);
			if (isSpecial(c) || c == '\\') {
				return true;
			}
		}
		return false;
	}

	private static boolean isSpecial(char c) {
		return c <= ' ' || c == '=' || c == '"' || c == 0x7f;
	}

	private static @Nullable String escape(char c) {
		return switch (c) {
			case '"' -> "\\\"";
			case '\\' -> "\\\\";
			case '\n' -> "\\n";
			case '\r' -> "\\r";
			case '\t' -> "\\t";
			default -> c < ' ' || c == 0x7f ? String.format(Locale.ROOT, "\\u%04x", (int) c) : null;
		};
	}

}