KeyValuesFormatterBuilder.java

package io.jstach.rainbowgum.format;

import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;

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;
import io.jstach.rainbowgum.annotation.CaseChanging;

/**
 * Builds a {@link LogFormatter} that appends event key values percent encoded, or as
 * JSON, JSON5, or logfmt text, directly to a {@link StringBuilder}. The default writes
 * all keys percent encoded, preserving their order, which reads back exactly with
 * {@link io.jstach.rainbowgum.PercentCodec#decode(CharSequence, java.nio.charset.Charset)}.
 * In JSON and JSON5 non-null values are always strings, even when they look like numbers,
 * booleans, or JSON.
 * <p>
 * Compose it with other formatters through
 * {@link LogFormatter.Builder#keyValues(java.util.function.Consumer)}, or
 * {@link LogFormatter.Builder#add(LogFormatter)} with a built formatter:
 * {@snippet :
 * LogFormatter formatter = LogFormatter.builder()
 * 	.message()
 * 	.text(" ")
 * 	.add(new KeyValuesFormatterBuilder().format(KeyValuesFormatterBuilder.Format.JSON5)
 * 		.nullStrategy(LogFormatter.KeyValueNullStrategy.SKIP)
 * 		.build())
 * 	.newline()
 * 	.build();
 * }
 * <p>
 * JSON and JSON5 results include braces, including {@code {}} when no keys are written;
 * percent encoding and logfmt have no braces and write nothing when no keys are written.
 * The builder is mutable; each built formatter is immutable and thread-safe.
 */
public final class KeyValuesFormatterBuilder {

	/**
	 * Syntax of the key values object.
	 */
	@CaseChanging
	public enum Format {

		/**
		 * Percent encoded like a URI query, the default:
		 * <code>requestId=42&amp;user=Ada%20Lovelace</code>. Keys and values are encoded
		 * with {@link io.jstach.rainbowgum.PercentCodec}, so every key value is
		 * reversible and on one line.
		 */
		PERCENT,
		/**
		 * JSON, with every key enclosed in double quotes.
		 */
		JSON,
		/**
		 * JSON5, with identifier keys left unquoted. Other keys and all string values
		 * still use JSON double quotes and escaping. Identifier keys follow the
		 * <a href="https://spec.json5.org/#objects">JSON5 object syntax</a>.
		 */
		JSON5,
		/**
		 * <a href="https://brandur.org/logfmt">logfmt</a>: space separated
		 * <code>key=value</code> pairs, <code>requestId=42 user="Ada Lovelace"</code>. A
		 * value is quoted only when it is empty or contains a space, <code>=</code>,
		 * <code>"</code>, or a control character. Characters logfmt does not allow in
		 * keys, and <code>%</code>, are percent-encoded (<code>a b</code> is
		 * <code>a%20b</code>), and an empty key is <code>%</code>, so different keys stay
		 * different.
		 */
		LOGFMT;

	}

	private @Nullable List<String> keys;

	private KeyValueNullStrategy nullStrategy = KeyValueNullStrategy.KEEP;

	private Format format = Format.PERCENT;

	/**
	 * Creates a builder that writes all event key values percent encoded.
	 */
	public KeyValuesFormatterBuilder() {
	}

	/**
	 * Selects the keys to write, in the supplied order. The list is copied and duplicate
	 * keys are written once at their first position. An empty list writes {@code {}}.
	 * Missing selected keys follow the null strategy, just like keys mapped to null.
	 * @param keys keys to select.
	 * @return this builder.
	 */
	public KeyValuesFormatterBuilder keys(List<String> keys) {
		this.keys = List.copyOf(new LinkedHashSet<>(keys));
		return this;
	}

	/**
	 * Selects all keys, in the order supplied by the event's key values. This is the
	 * default and resets a previous {@link #keys(List)} selection.
	 * @return this builder.
	 */
	public KeyValuesFormatterBuilder allKeys() {
		this.keys = null;
		return this;
	}

	/**
	 * Controls null values, and missing keys when a selection is supplied:
	 * {@link KeyValueNullStrategy#KEEP} writes {@code null} (logfmt: <code>key=</code>,
	 * percent encoding: the key alone), {@link KeyValueNullStrategy#EMPTY} writes an
	 * empty string, and {@link KeyValueNullStrategy#SKIP} omits the key. The default is
	 * KEEP.
	 * @param nullStrategy null value strategy.
	 * @return this builder.
	 */
	public KeyValuesFormatterBuilder nullStrategy(KeyValueNullStrategy nullStrategy) {
		this.nullStrategy = Objects.requireNonNull(nullStrategy);
		return this;
	}

	/**
	 * Selects the syntax. The default is {@link Format#PERCENT}.
	 * @param format syntax to write.
	 * @return this builder.
	 */
	public KeyValuesFormatterBuilder format(Format format) {
		this.format = Objects.requireNonNull(format);
		return this;
	}

	/**
	 * Builds an immutable formatter independent of subsequent builder changes.
	 * @return key values formatter.
	 */
	public LogFormatter build() {
		return build(true);
	}

	/* TTLL supplies the braces and suppresses empty key values. */
	LogFormatter buildUnbraced() {
		return build(false);
	}

	private LogFormatter build(boolean braces) {
		return switch (format) {
			case JSON, JSON5 -> new JsonKeyValuesFormatter(keys, nullStrategy, format, braces);
			case PERCENT -> new PercentKeyValuesFormatter(keys, nullStrategy);
			case LOGFMT -> new LogfmtKeyValuesFormatter(keys, nullStrategy);
		};
	}

}

record JsonKeyValuesFormatter(@Nullable List<String> keys, KeyValueNullStrategy nullStrategy,
		KeyValuesFormatterBuilder.Format syntax,
		boolean braces) implements LogFormatter.EventFormatter, KeyValuesConsumer<StringBuilder> {

	@Override
	public void format(StringBuilder output, LogEvent event) {
		if (braces) {
			output.append('{');
		}
		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);
			}
		}
		if (braces) {
			output.append('}');
		}
	}

	@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 -> {
				}
			}
		}
		if (count > 0) {
			output.append(',');
		}
		if (syntax == KeyValuesFormatterBuilder.Format.JSON5 && isIdentifier(key)) {
			output.append(key);
		}
		else {
			appendString(output, key);
		}
		output.append(':');
		if (value == null) {
			output.append("null");
		}
		else {
			appendString(output, value);
		}
		return count + 1;
	}

	/* JSON5 uses ECMAScript IdentifierName, not Java identifier rules. */
	static boolean isIdentifier(String key) {
		if (key.isEmpty()) {
			return false;
		}
		int first = key.codePointAt(0);
		if (!isIdentifierStart(first)) {
			return false;
		}
		for (int i = Character.charCount(first); i < key.length();) {
			int c = key.codePointAt(i);
			if (!isIdentifierStart(c) && c != 0x200c && c != 0x200d) {
				boolean part = switch (Character.getType(c)) {
					case Character.NON_SPACING_MARK, Character.COMBINING_SPACING_MARK, Character.DECIMAL_DIGIT_NUMBER,
							Character.CONNECTOR_PUNCTUATION ->
						true;
					default -> false;
				};
				if (!part) {
					return false;
				}
			}
			i += Character.charCount(c);
		}
		return true;
	}

	private static boolean isIdentifierStart(int c) {
		return c == '$' || c == '_' || switch (Character.getType(c)) {
			case Character.UPPERCASE_LETTER, Character.LOWERCASE_LETTER, Character.TITLECASE_LETTER,
					Character.MODIFIER_LETTER, Character.OTHER_LETTER, Character.LETTER_NUMBER ->
				true;
			default -> false;
		};
	}

	/*
	 * Quotes and escapes, as a JSON string, the text from start to the end of the output.
	 * Text needing no escapes, the common case, is only wrapped in quotes.
	 */
	static void quoteInPlace(StringBuilder output, int start) {
		int end = output.length();
		for (int i = start; i < end; i++) {
			char c = output.charAt(i);
			if (c == '"' || c == '\\' || c < 0x20 || c == '\u2028' || c == '\u2029' || Character.isSurrogate(c)) {
				String raw = output.substring(start);
				output.setLength(start);
				appendString(output, raw);
				return;
			}
		}
		output.insert(start, '"');
		output.append('"');
	}

	static void appendString(StringBuilder output, String value) {
		output.append('"');
		for (int i = 0; i < value.length(); i++) {
			char c = value.charAt(i);
			switch (c) {
				case '"' -> output.append("\\\"");
				case '\\' -> output.append("\\\\");
				case '\b' -> output.append("\\b");
				case '\f' -> output.append("\\f");
				case '\n' -> output.append("\\n");
				case '\r' -> output.append("\\r");
				case '\t' -> output.append("\\t");
				default -> {
					if (c < 0x20 || c == '\u2028' || c == '\u2029') {
						appendUnicodeEscape(output, c);
					}
					else if (Character.isHighSurrogate(c)) {
						if (i + 1 < value.length() && Character.isLowSurrogate(value.charAt(i + 1))) {
							output.append(c).append(value.charAt(++i));
						}
						else {
							appendUnicodeEscape(output, c);
						}
					}
					else if (Character.isLowSurrogate(c)) {
						appendUnicodeEscape(output, c);
					}
					else {
						output.append(c);
					}
				}
			}
		}
		output.append('"');
	}

	private static void appendUnicodeEscape(StringBuilder output, char c) {
		String hex = "0123456789abcdef";
		output.append("\\u")
			.append(hex.charAt((c >>> 12) & 0xf))
			.append(hex.charAt((c >>> 8) & 0xf))
			.append(hex.charAt((c >>> 4) & 0xf))
			.append(hex.charAt(c & 0xf));
	}

}