TTLLFormatter.java

package io.jstach.rainbowgum.format;

import java.io.IOException;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.function.BooleanSupplier;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.LogEvent;
import io.jstach.rainbowgum.LogFormatter;
import io.jstach.rainbowgum.LogFormatter.TimestampFormatter;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.ServiceRegistry;
import io.jstach.rainbowgum.LogProperty;
import io.jstach.rainbowgum.LogReporter;
import io.jstach.rainbowgum.annotation.LogConfigurable;

/*
 * The TTLL format assembled from its parts. Package private: the public API is the
 * generated TTLLFormatterBuilder, the ttll encoder scheme, and the TTLL choice enums.
 */
final class TTLLFormatter implements TTLL, LogFormatter.EventFormatter, LogReporter.Reportable {

	/** {@link io.jstach.rainbowgum.format.TTLL.TimestampFormat#TTLL ttll} */
	static final LogFormatter DEFAULT_TIMESTAMP = TTLL.TimestampFormat.TTLL.formatter();

	/** {@link io.jstach.rainbowgum.format.TTLL.ThreadFormat#NAME name} */
	static final LogFormatter DEFAULT_THREAD = TTLL.ThreadFormat.NAME.formatter();

	/** {@link io.jstach.rainbowgum.format.TTLL.LevelFormat#PADDED padded} */
	static final LogFormatter DEFAULT_LEVEL = TTLL.LevelFormat.PADDED.formatter();

	/** {@link io.jstach.rainbowgum.format.TTLL.LoggerFormat#FULL full} */
	static final LogFormatter DEFAULT_LOGGER = TTLL.LoggerFormat.FULL.formatter();

	/** {@link io.jstach.rainbowgum.format.TTLL.KeyValuesFormat#PERCENT percent} */
	static final LogFormatter DEFAULT_KEY_VALUES = TTLL.KeyValuesFormat.PERCENT.formatter();

	private final LogFormatter formatter;

	private final String description;

	private TTLLFormatter(LogFormatter formatter, String description) {
		this.formatter = formatter;
		this.description = description;
	}

	/**
	 * The TTLL (Time, Thread, Level, Logger) text format, the default encoder, with each
	 * part configurable, see {@link TTLL}.
	 * <p>
	 * The defaults produce <code>12:00:00.123 [main] INFO  com.example.App - hello</code>
	 * followed by any stack trace. Parts set to none are left out along with their
	 * separating space. As properties of the encoder, each part is one of the lowercase
	 * names of its {@link TTLL} enum, for example
	 * <code>logging.appender.console.encoder.ttll.keyValues=json5</code>; keyValues
	 * accepts <code>json</code> and <code>json5</code> as well as <code>true</code>
	 * (percent encoding) and <code>false</code> (none). The timestamp property also
	 * accepts a DateTimeFormatter pattern in UTC. The color property decides whether to
	 * color and the theme property which colors; when neither is set the rainbowgum theme
	 * is used if the console supports ANSI.
	 * @param name encoder name, used for property lookup.
	 * @param timestamp time formatter, see {@link TTLL.TimestampFormat}.
	 * @param thread thread formatter in square brackets, see {@link TTLL.ThreadFormat}.
	 * @param level level formatter, see {@link TTLL.LevelFormat}.
	 * @param logger logger name formatter, see {@link TTLL.LoggerFormat}.
	 * @param keyValues key values formatter in braces, see {@link TTLL.KeyValuesFormat}.
	 * @param color whether to color, see {@link TTLL.ColorMode}.
	 * @param theme ANSI color theme, see {@link TTLL.ColorTheme}.
	 * @param keyValuesWhenEmpty braces for no key values, see
	 * {@link TTLL.KeyValuesWhenEmpty}.
	 * @param serviceRegistry where themes chosen by name in properties are looked up.
	 * @return formatter.
	 */
	@LogConfigurable(name = "TTLLFormatterBuilder", prefix = LogProperties.ENCODER_PREFIX + "ttll.")
	static LogFormatter of(@LogConfigurable.KeyParameter String name,
			@LogConfigurable.DefaultParameter("DEFAULT_TIMESTAMP") @LogConfigurable.ConvertParameter("convertTimestamp") LogFormatter timestamp,
			@LogConfigurable.DefaultParameter("DEFAULT_THREAD") @LogConfigurable.ConvertParameter("convertThread") LogFormatter thread,
			@LogConfigurable.DefaultParameter("DEFAULT_LEVEL") @LogConfigurable.ConvertParameter("convertLevel") LogFormatter level,
			@LogConfigurable.DefaultParameter("DEFAULT_LOGGER") @LogConfigurable.ConvertParameter("convertLogger") LogFormatter logger,
			@LogConfigurable.DefaultParameter("DEFAULT_KEY_VALUES") @LogConfigurable.ConvertParameter("convertKeyValues") LogFormatter keyValues,
			@LogConfigurable.ConvertParameter("convertColor") TTLL.@Nullable ColorMode color,
			@LogConfigurable.ConvertParameter("convertTheme") TTLL.@Nullable ColorTheme theme,
			@LogConfigurable.ConvertParameter("convertKeyValuesWhenEmpty") TTLL.@Nullable KeyValuesWhenEmpty keyValuesWhenEmpty,
			@LogConfigurable.PassThroughParameter @Nullable ServiceRegistry serviceRegistry) {
		var mode = color == null ? TTLL.ColorMode.DEFAULT : color;
		var resolvedTheme = PaletteColorTheme.resolve(theme, serviceRegistry);
		var palette = Palette.of(mode, resolvedTheme, AnsiSupport::isAnsiSupported);
		var b = LogFormatter.builder();
		boolean empty = true;
		empty = part(b, palette.color(palette.timestamp(), timestamp), empty);
		if (!thread.isNoop()) {
			var bracketed = LogFormatter.builder().text("[").add(thread).text("]").build();
			empty = part(b, palette.color(palette.thread(), bracketed), empty);
		}
		empty = part(b, palette.level(level), empty);
		empty = part(b, palette.color(palette.logger(), logger), empty);
		if (!keyValues.isNoop()) {
			boolean json = keyValues instanceof JsonKeyValuesFormatter j
					&& j.syntax() == KeyValuesFormatterBuilder.Format.JSON;
			var whenEmpty = keyValuesWhenEmpty == null ? TTLL.KeyValuesWhenEmpty.AUTO : keyValuesWhenEmpty;
			if (json && whenEmpty == TTLL.KeyValuesWhenEmpty.OMIT) {
				throw new IllegalArgumentException("keyValuesWhenEmpty=omit cannot be used with keyValues=json, "
						+ "since JSON readers expect an object on every line. Use keyValues=json5 or keyValues=percent, "
						+ "or keyValuesWhenEmpty=auto or show.");
			}
			boolean writeEmpty = whenEmpty == TTLL.KeyValuesWhenEmpty.SHOW
					|| (whenEmpty == TTLL.KeyValuesWhenEmpty.AUTO && json);
			/*
			 * Key values can write nothing, so they carry their own leading space rather
			 * than being a part, and the " - " below only follows text actually written.
			 */
			b.add(palette.keyValues(keyValues, writeEmpty, !empty));
		}
		var line = LogFormatter.builder().add(new LinePrefixFormatter(b.build())).message().newline().throwable();
		return new TTLLFormatter(line.build(), describe(mode, resolvedTheme, palette));
	}

	/*
	 * What the reporter shows: the color settings and whether they resulted in color,
	 * since "why is my console not colored" is the question it answers. The parts are
	 * already formatters here and their choices are not tracked.
	 */
	static String describe(TTLL.ColorMode mode, TTLL.@Nullable ColorTheme theme, Palette palette) {
		String themeName = theme != null ? theme.name() : mode == TTLL.ColorMode.OFF || mode == TTLL.ColorMode.DETECT
				? "none" : TTLL.ColorTheme.RAINBOWGUM.name();
		return TTLL.SCHEMA + " color=" + mode.name().toLowerCase(Locale.ROOT) + " theme="
				+ themeName.toLowerCase(Locale.ROOT) + " colored=" + palette.colored();
	}

	@Override
	public void report(Appendable out) throws IOException {
		out.append(description);
	}

	private static boolean part(LogFormatter.Builder b, LogFormatter part, boolean empty) {
		if (part.isNoop()) {
			return empty;
		}
		if (!empty) {
			b.text(" ");
		}
		b.add(part);
		return false;
	}

	static LogFormatter convertTimestamp(String value) {
		try {
			return TTLL.TimestampFormat.parse(value).formatter();
		}
		catch (IllegalArgumentException notAChoice) {
			// not a choice name or alias, so a DateTimeFormatter pattern
		}
		try {
			return TimestampFormatter.of(DateTimeFormatter.ofPattern(value).withZone(ZoneOffset.UTC));
		}
		catch (IllegalArgumentException e) {
			throw new IllegalArgumentException("'" + value
					+ "' is neither a timestamp format (ttll, iso, none, true, false, default) nor a valid DateTimeFormatter pattern: "
					+ e.getMessage(), e);
		}
	}

	static LogFormatter convertThread(String value) {
		return TTLL.ThreadFormat.parse(value).formatter();
	}

	static LogFormatter convertLevel(String value) {
		return TTLL.LevelFormat.parse(value).formatter();
	}

	static LogFormatter convertLogger(String value) {
		return TTLL.LoggerFormat.parse(value).formatter();
	}

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

	static TTLL.KeyValuesWhenEmpty convertKeyValuesWhenEmpty(String value) {
		return TTLL.KeyValuesWhenEmpty.parse(value);
	}

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

	static LogFormatter convertKeyValues(String value) {
		return TTLL.KeyValuesFormat.parse(value).formatter();
	}

	@Override
	public void format(StringBuilder output, LogEvent event) {
		formatter.format(output, event);
	}

}

/*
 * The last dot separated component of the logger name.
 */
enum ShortLoggerNameFormatter implements LogFormatter.EventFormatter {

	INSTANCE;

	@Override
	public void format(StringBuilder output, LogEvent event) {
		String name = event.loggerName();
		output.append(name, name.lastIndexOf('.') + 1, name.length());
	}

}

/*
 * Everything before the message, followed by " - " only if it wrote something.
 */
record LinePrefixFormatter(LogFormatter prefix) implements LogFormatter.EventFormatter {

	@Override
	public void format(StringBuilder output, LogEvent event) {
		int start = output.length();
		prefix.format(output, event);
		if (output.length() > start) {
			output.append(" - ");
		}
	}

}

/*
 * Writes key values between an open and close text, and when there are none writes the
 * empty braces or leaves them out; the standard event formatter always leaves them out,
 * with their leading space.
 */
record BracedKeyValuesFormatter(LogFormatter keyValuesFormatter, String open, String close,
		boolean writeEmpty) implements LogFormatter.EventFormatter {

	/*
	 * TTLL: "{...}", with a leading space when it follows another part.
	 */
	static BracedKeyValuesFormatter ofTTLL(LogFormatter keyValuesFormatter, String open, String close,
			boolean writeEmpty) {
		return new BracedKeyValuesFormatter(keyValuesFormatter, open, close, writeEmpty);
	}

	/*
	 * Standard event formatter: " {...}", left out entirely when there are no key values.
	 */
	static BracedKeyValuesFormatter ofStandard(LogFormatter keyValuesFormatter) {
		return new BracedKeyValuesFormatter(keyValuesFormatter, " {", "}", false);
	}

	@Override
	public void format(StringBuilder output, LogEvent event) {
		int start = output.length();
		output.append(open);
		keyValuesFormatter.format(output, event);
		if (!writeEmpty && output.length() == start + open.length()) {
			output.setLength(start);
		}
		else {
			output.append(close);
		}
	}

}

/*
 * ANSI escape codes, the same sequences the pattern encoder writes.
 */
final class Ansi {

	static final String RESET = "\033[0;39m";

	private Ansi() {
	}

	static String start(String code) {
		return "\033[" + code + "m";
	}

}

/*
 * A theme with its colors: the built in themes and those made with
 * TTLL.ColorTheme.builder.
 */
record PaletteColorTheme(String name, Palette palette) implements TTLL.ColorTheme {

	static final PaletteColorTheme RAINBOWGUM = new PaletteColorTheme("rainbowgum", Palette.RAINBOWGUM);

	static final PaletteColorTheme SPRING = new PaletteColorTheme("spring", Palette.SPRING);

	static final PaletteColorTheme ONE_DARK = new PaletteColorTheme("one_dark", Palette.ONE_DARK);

	static final PaletteColorTheme DARCULA = new PaletteColorTheme("darcula", Palette.DARCULA);

	static final List<PaletteColorTheme> BUILT_IN = List.of(RAINBOWGUM, SPRING, ONE_DARK, DARCULA);

	private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9_]*");

	/*
	 * A built in theme, or a name to find among registered themes when the formatter is
	 * created, since converting the property cannot see the service registry.
	 */
	static TTLL.ColorTheme parse(String value) {
		String name = value.strip().toLowerCase(Locale.ROOT);
		if (name.equals("default")) {
			return RAINBOWGUM;
		}
		for (var theme : BUILT_IN) {
			if (theme.name().equals(name)) {
				return theme;
			}
		}
		if (!NAME.matcher(name).matches()) {
			throw invalid(value, List.of());
		}
		return new NamedColorTheme(name);
	}

	/*
	 * The theme for a name from properties, looked up among themes registered under their
	 * name.
	 */
	static TTLL.@Nullable ColorTheme resolve(TTLL.@Nullable ColorTheme theme, @Nullable ServiceRegistry registry) {
		if (!(theme instanceof NamedColorTheme named)) {
			return theme;
		}
		var registered = new ArrayList<String>();
		if (registry != null) {
			var found = registry.findOrNull(TTLL.ColorTheme.class, named.name());
			if (found instanceof PaletteColorTheme) {
				return found;
			}
			registry.forEach(TTLL.ColorTheme.class, (n, t) -> registered.add(n));
		}
		throw invalid(named.name(), registered);
	}

	static String requireThemeName(String name) {
		if (!NAME.matcher(name).matches()) {
			throw new IllegalArgumentException("Color theme name '" + name
					+ "' should be lowercase letters, digits, and underscores, starting with a letter.");
		}
		if (name.equals("default") || BUILT_IN.stream().anyMatch(t -> t.name().equals(name))) {
			throw new IllegalArgumentException("Color theme name '" + name + "' is taken by a built in theme.");
		}
		return name;
	}

	static Palette paletteOf(TTLL.ColorTheme theme) {
		return switch (theme) {
			case PaletteColorTheme t -> t.palette();
			case NamedColorTheme t ->
				throw new IllegalArgumentException("Color theme '" + t.name() + "' has not been looked up yet.");
		};
	}

	/*
	 * Same message as when this was an enum, with registered theme names after the built
	 * in ones.
	 */
	private static IllegalArgumentException invalid(String value, List<String> registered) {
		var valid = new ArrayList<String>();
		for (var theme : BUILT_IN) {
			valid.add("'" + theme.name() + "'");
		}
		valid.add("'default'");
		registered.stream().sorted().forEach(n -> valid.add("'" + n + "'"));
		return new IllegalArgumentException("'" + value + "' is not a valid value for "
				+ TTLL.ColorTheme.class.getCanonicalName() + ". Valid values: " + String.join(", ", valid));
	}

}

/*
 * A theme name from properties that is not built in, looked up when the formatter is
 * created.
 */
record NamedColorTheme(String name) implements TTLL.ColorTheme {
}

/*
 * The ANSI codes a color theme uses for each part of the TTLL layout and for each level.
 * Adding a theme is adding a palette. The codes are the same sequences the pattern
 * encoder writes for the equivalent pattern. An empty code means no color.
 */
record Palette(String timestamp, String thread, String logger, String keyValues, String error, String warn, String info,
		String debug, String trace) {

	static final Palette OFF = new Palette("", "", "", "", "", "", "", "", "");

	/*
	 * The pattern encoder's default: cyan time, faint thread, %highlight level with the
	 * default pattern's options and magenta logger name.
	 */
	static final Palette RAINBOWGUM = new Palette("36", "2;39", "35", "2;39", "1;31", "1;31", "1;34", "39", "39");

	/*
	 * Spring Boot's console colors: faint time and thread, %clr level colors (error red,
	 * warn yellow, the rest green) and cyan logger name.
	 */
	static final Palette SPRING = new Palette("2;39", "2;39", "36", "2;39", "31", "33", "32", "32", "32");

	/*
	 * Atom One Dark in 24 bit color, from the One Dark IntelliJ and Eclipse themes: cyan
	 * time, comment gray thread and key values, keyword magenta logger name, and levels
	 * bold red, yellow and blue (error, warn, info) or foreground gray.
	 */
	static final Palette ONE_DARK = new Palette(rgb("56b6c2"), rgb("969896"), rgb("c678dd"), rgb("969896"),
			"1;" + rgb("e06c75"), "1;" + rgb("e5c07b"), "1;" + rgb("61afef"), rgb("abb2bf"), rgb("abb2bf"));

	/*
	 * IntelliJ Darcula in 24 bit color: number blue time, comment gray thread and key
	 * values, keyword orange logger name, and levels bold in Darcula's log console red,
	 * yellow and string green (error, warn, info) or foreground gray.
	 */
	static final Palette DARCULA = new Palette(rgb("6897bb"), rgb("808080"), rgb("cc7832"), rgb("808080"),
			"1;" + rgb("ff6b68"), "1;" + rgb("bbb529"), "1;" + rgb("6a8759"), rgb("a9b7c6"), rgb("a9b7c6"));

	/*
	 * The 24 bit foreground color code for a hex RGB value like c678dd.
	 */
	static String rgb(String hex) {
		int value = Integer.parseInt(hex, 16);
		return "38;2;" + (value >> 16 & 0xff) + ";" + (value >> 8 & 0xff) + ";" + (value & 0xff);
	}

	/*
	 * The palette for the color mode and theme. ANSI detection is only asked for when the
	 * mode needs it.
	 */
	static Palette of(TTLL.ColorMode mode, TTLL.@Nullable ColorTheme theme, BooleanSupplier ansi) {
		var selected = switch (mode) {
			case OFF -> null;
			case DEFAULT -> ansi.getAsBoolean() ? theme == null ? TTLL.ColorTheme.RAINBOWGUM : theme : null;
			case DETECT -> theme != null && ansi.getAsBoolean() ? theme : null;
			case FORCE -> theme == null ? TTLL.ColorTheme.RAINBOWGUM : theme;
		};
		return selected == null ? OFF : of(selected);
	}

	static Palette of(TTLL.ColorTheme theme) {
		return PaletteColorTheme.paletteOf(theme);
	}

	boolean colored() {
		return !timestamp.isEmpty() || !thread.isEmpty() || !logger.isEmpty() || !keyValues.isEmpty()
				|| levelsColored();
	}

	private boolean levelsColored() {
		return !error.isEmpty() || !warn.isEmpty() || !info.isEmpty() || !debug.isEmpty() || !trace.isEmpty();
	}

	LogFormatter color(String code, LogFormatter formatter) {
		if (code.isEmpty() || formatter.isNoop()) {
			return formatter;
		}
		return LogFormatter.builder().text(Ansi.start(code)).add(formatter).text(Ansi.RESET).build();
	}

	LogFormatter level(LogFormatter level) {
		if (!levelsColored() || level.isNoop()) {
			return level;
		}
		return new LevelColorFormatter(level, this);
	}

	LogFormatter keyValues(LogFormatter keyValues, boolean writeEmpty, boolean afterPart) {
		String space = afterPart ? " " : "";
		if (keyValues().isEmpty()) {
			return BracedKeyValuesFormatter.ofTTLL(keyValues, space + "{", "}", writeEmpty);
		}
		return BracedKeyValuesFormatter.ofTTLL(keyValues, space + Ansi.start(keyValues()) + "{", "}" + Ansi.RESET,
				writeEmpty);
	}

	/*
	 * The level's color, or none when this palette does not color.
	 */
	String levelColor(java.lang.System.Logger.Level level) {
		return switch (level) {
			case ERROR -> error;
			case WARNING -> warn;
			case INFO -> info;
			case DEBUG -> debug;
			case TRACE -> trace;
			default -> levelsColored() ? "39" : "";
		};
	}

	/*
	 * Starts and ends a value colored with code, writing nothing for an empty code. Used
	 * by formatters that color values rather than whole parts.
	 */
	static void start(StringBuilder output, String code) {
		if (!code.isEmpty()) {
			output.append(Ansi.start(code));
		}
	}

	static void end(StringBuilder output, String code) {
		if (!code.isEmpty()) {
			output.append(Ansi.RESET);
		}
	}

	/*
	 * Removes the spaces a padding level formatter wrote after start and returns how
	 * many, so they can be written after a closing quote or color instead.
	 */
	static int removeTrailingSpaces(StringBuilder output, int start) {
		int end = output.length();
		int i = end;
		while (i > start && output.charAt(i - 1) == ' ') {
			i--;
		}
		output.setLength(i);
		return end - i;
	}

	static void spaces(StringBuilder output, int count) {
		for (int i = 0; i < count; i++) {
			output.append(' ');
		}
	}

}

/*
 * Colors the level with the palette's color for the event's level.
 */
record LevelColorFormatter(LogFormatter level, Palette palette) implements LogFormatter.EventFormatter {

	@Override
	public void format(StringBuilder output, LogEvent event) {
		String code = palette.levelColor(event.level());
		Palette.start(output, code);
		level.format(output, event);
		Palette.end(output, code);
	}

}