LogfmtFormatter.java

package io.jstach.rainbowgum.format;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.KeyValues.KeyValuesConsumer;
import io.jstach.rainbowgum.LogEvent;
import io.jstach.rainbowgum.LogFormatter;
import io.jstach.rainbowgum.LogFormatter.LevelFormatter;
import io.jstach.rainbowgum.LogFormatter.ThrowableFormatter;
import io.jstach.rainbowgum.LogFormatter.TimestampFormatter;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.LogProperty;
import io.jstach.rainbowgum.ServiceRegistry;
import io.jstach.rainbowgum.annotation.LogConfigurable;

/*
 * logfmt (https://brandur.org/logfmt). Package private: the public API is the generated
 * LogfmtFormatterBuilder and the logfmt encoder scheme; logfmt key values on their own
 * come from KeyValuesFormatterBuilder. Quoting and escaping are shared with
 * LogfmtKeyValuesFormatter.
 */
final class LogfmtFormatter implements LogFormatter.EventFormatter {

	/** {@link io.jstach.rainbowgum.LogFormatter.LevelFormatter#of() level_formatter} */
	static final LevelFormatter DEFAULT_LEVEL_FORMATTER = LevelFormatter.of();

	/*
	 * Fixed width UTC milliseconds, cached per millisecond.
	 */
	private static final TimestampFormatter TIME = TimestampFormatter.ofISO();

	private final LevelFormatter levelFormatter;

	private final Palette palette;

	private LogfmtFormatter(LevelFormatter levelFormatter, Palette palette) {
		this.levelFormatter = levelFormatter;
		this.palette = palette;
	}

	/**
	 * Formats events as logfmt, one line of key=value pairs per event like Go's log/slog
	 * text handler, selected with the logfmt encoder scheme.
	 * <p>
	 * Each event is written as one line, for example:
	 * <code>time=2026-10-05T15:04:05.123Z level=INFO logger=com.example.App thread=main msg="hello world" requestId=42</code>
	 * <ul>
	 * <li><code>time</code> is the UTC instant with milliseconds.</li>
	 * <li><code>level</code> is written by the level formatter, by default
	 * <code>TRACE</code>, <code>DEBUG</code>, <code>INFO</code>, <code>WARN</code>, or
	 * <code>ERROR</code>. logfmt does not define level names, and tools differ (Go's slog
	 * is upper case, logrus lower case).</li>
	 * <li>Every key value follows <code>msg</code>, in order. A key value named like one
	 * of these fields is prefixed with <code>keyValues.</code>.</li>
	 * <li>A throwable adds <code>error</code> (its <code>toString()</code>) and
	 * <code>stacktrace</code>.</li>
	 * </ul>
	 * A value is quoted when it is empty or contains a space, <code>=</code>,
	 * <code>"</code>, or a control character. Inside quotes <code>"</code>,
	 * <code>\</code>, newline, carriage return, and tab are backslash escaped and other
	 * control characters are written as <code>\</code><code>uXXXX</code>, so every event
	 * stays on one line. 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.
	 * <p>
	 * Values can be colored like the TTLL formatter; keys, <code>msg</code>,
	 * <code>error</code>, and <code>stacktrace</code> are not. Padding from a padding
	 * level formatter is written after the level's color.
	 * @param name encoder name, used for property lookup.
	 * @param levelFormatter level formatter; as a property, a level formatter constant
	 * name.
	 * @param color whether to color values, see {@link TTLL.ColorMode}.
	 * @param theme which colors, see {@link TTLL.ColorTheme}.
	 * @param serviceRegistry where themes chosen by name in properties are looked up.
	 * @return formatter.
	 */
	@LogConfigurable(name = "LogfmtFormatterBuilder", prefix = LogProperties.ENCODER_PREFIX + "logfmt.")
	static LogFormatter of(@LogConfigurable.KeyParameter String name,
			@LogConfigurable.DefaultParameter("DEFAULT_LEVEL_FORMATTER") @LogConfigurable.ConvertParameter("convertLevelFormatter") LevelFormatter levelFormatter,
			@LogConfigurable.ConvertParameter("convertColor") TTLL.@Nullable ColorMode color,
			@LogConfigurable.ConvertParameter("convertTheme") TTLL.@Nullable ColorTheme theme,
			@LogConfigurable.PassThroughParameter @Nullable ServiceRegistry serviceRegistry) {
		return new LogfmtFormatter(levelFormatter, Palette.of(color == null ? TTLL.ColorMode.DEFAULT : color,
				PaletteColorTheme.resolve(theme, serviceRegistry), AnsiSupport::isAnsiSupported));
	}

	static TTLL.ColorMode convertColor(String value) {
		return TTLLFormatter.convertColor(value);
	}

	static TTLL.ColorTheme convertTheme(String value) {
		return TTLLFormatter.convertTheme(value);
	}

	static LevelFormatter convertLevelFormatter(String value) {
		return LogProperty.enumValue(LevelFormatterChoice.class, value).levelFormatter();
	}

	/*
	 * The level formatter property's values, named after core's level formatter constants
	 * so the property values stayed the same when this moved out of core.
	 */
	enum LevelFormatterChoice {

		LEVEL_FORMATTER, RIGHT_PAD_LEVEL_FORMATTER;

		LevelFormatter levelFormatter() {
			return switch (this) {
				case LEVEL_FORMATTER -> LevelFormatter.of();
				case RIGHT_PAD_LEVEL_FORMATTER -> LevelFormatter.ofRightPadded();
			};
		}

	}

	/*
	 * A key value named like a field written here gets a keyValues. prefix, as in the
	 * JSON encoders, so no key is written twice.
	 */
	private static final KeyValuesConsumer<StringBuilder> KEY_VALUES = (kvs, key, value, count,
			output) -> LogfmtKeyValuesFormatter.ALL.accept(kvs,
					collides(key) ? Json5Formatter.KEY_VALUES_FIELD + "." + key : key, value, count, output);

	private static boolean collides(String key) {
		return Json5Formatter.FIELDS.contains(key) || key.startsWith(Json5Formatter.KEY_VALUES_FIELD + ".");
	}

	@Override
	public void format(StringBuilder output, LogEvent event) {
		var p = palette;
		output.append("time=");
		Palette.start(output, p.timestamp());
		TIME.formatTimestamp(output, event.timestamp());
		Palette.end(output, p.timestamp());
		output.append(" level=");
		String levelColor = p.levelColor(event.level());
		Palette.start(output, levelColor);
		int levelStart = output.length();
		levelFormatter.formatLevel(output, event.level());
		if (!levelColor.isEmpty()) {
			int padding = Palette.removeTrailingSpaces(output, levelStart);
			Palette.end(output, levelColor);
			Palette.spaces(output, padding);
		}
		output.append(" logger=");
		Palette.start(output, p.logger());
		LogfmtKeyValuesFormatter.appendValue(output, event.loggerName());
		Palette.end(output, p.logger());
		output.append(" thread=");
		Palette.start(output, p.thread());
		LogfmtKeyValuesFormatter.appendValue(output, event.threadName());
		Palette.end(output, p.thread());
		output.append(" msg=");
		int start = output.length();
		event.formattedMessage(output);
		LogfmtKeyValuesFormatter.quoteInPlace(output, start);
		var keyValues = event.keyValues();
		if (!keyValues.isEmpty()) {
			output.append(' ');
			Palette.start(output, p.keyValues());
			keyValues.forEach(KEY_VALUES, 0, output);
			Palette.end(output, p.keyValues());
		}
		var throwable = event.throwableOrNull();
		if (throwable != null) {
			output.append(" error=");
			LogfmtKeyValuesFormatter.appendValue(output, throwable.toString());
			output.append(" stacktrace=");
			start = output.length();
			ThrowableFormatter.appendThrowable(output, throwable);
			trimTrailingLineBreaks(output, start);
			LogfmtKeyValuesFormatter.quoteInPlace(output, start);
		}
		output.append('\n');
	}

	private static void trimTrailingLineBreaks(StringBuilder sb, int start) {
		int end = sb.length();
		while (end > start && (sb.charAt(end - 1) == '\n' || sb.charAt(end - 1) == '\r')) {
			end--;
		}
		sb.setLength(end);
	}

}