Json5Formatter.java

package io.jstach.rainbowgum.format;

import java.util.Set;

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.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;
import io.jstach.rainbowgum.format.KeyValuesFormatterBuilder.Format;

/*
 * Each event as one JSON5 (or JSON) object, with logfmt's field names. Named json5 rather
 * than json so it is not mistaken for the way to log JSON, which is the rainbowgum-json
 * encoders. Package private: the public API is the generated Json5FormatterBuilder and the
 * json5 encoder scheme.
 */
final class Json5Formatter implements LogFormatter.EventFormatter {

	static final String KEY_VALUES_FIELD = "keyValues";

	/*
	 * The event's own fields, which merged key values must not repeat.
	 */
	static final Set<String> FIELDS = Set.of("time", "level", "logger", "thread", "msg", "error", "stacktrace",
			KEY_VALUES_FIELD);

	/*
	 * One space, the same as the rainbowgum-json encoders' pretty print.
	 */
	private static final String INDENT = " ";

	private static final TimestampFormatter TIME = TimestampFormatter.ofISO();

	private final boolean json5;

	private final KeyValuesPlacement keyValues;

	private final LevelFormatter levelFormatter;

	/*
	 * Written between the event object's members: a comma, followed by a space when
	 * spacing or by a newline and indent when full.
	 */
	private final String separator;

	/*
	 * Written between the nested key values object's members, which stay on one line.
	 */
	private final String nestedSeparator;

	/*
	 * After the opening brace and before the closing one: a newline when full.
	 */
	private final String open;

	private final String close;

	private final Palette palette;

	/*
	 * Built once so writing key values allocates nothing per event.
	 */
	private final Members flat;

	private final Members nestedAll;

	private final Members nestedColliding;

	private Json5Formatter(boolean json5, KeyValuesPlacement keyValues, Json5PrettyPrint prettyPrint, Palette palette) {
		this.json5 = json5;
		this.keyValues = keyValues;
		this.levelFormatter = switch (prettyPrint) {
			case OFF, FULL -> LevelFormatter.of();
			case LEVEL_PADDING, SPACING -> LevelFormatter.ofRightPadded();
		};
		this.separator = switch (prettyPrint) {
			case OFF, LEVEL_PADDING -> ",";
			case SPACING -> ", ";
			case FULL -> ",\n" + INDENT;
		};
		this.nestedSeparator = prettyPrint == Json5PrettyPrint.SPACING ? ", " : ",";
		boolean full = prettyPrint == Json5PrettyPrint.FULL;
		this.open = full ? "{\n" + INDENT : "{";
		this.close = full ? "\n}\n" : "}\n";
		this.palette = palette;
		this.flat = new Members(json5, separator, Members.Mode.FLAT);
		this.nestedAll = new Members(json5, nestedSeparator, Members.Mode.NESTED_ALL);
		this.nestedColliding = new Members(json5, nestedSeparator, Members.Mode.NESTED_COLLIDING);
	}

	/**
	 * Formats each event as a JSON5 (or JSON) object, one line unless pretty printed in
	 * full, for switching an application to structured logging without adding a module,
	 * for example:
	 * <code>{time:"2026-10-05T15:04:05.123Z",level:"INFO" ,logger:"com.example.App",thread:"main",msg:"hello world",requestId:"42"}</code>.
	 * <p>
	 * The fields use logfmt's names: <code>time</code> (UTC instant with milliseconds),
	 * <code>level</code>, <code>logger</code>, <code>thread</code>, <code>msg</code>, and
	 * for a throwable <code>error</code> (its <code>toString()</code>) and
	 * <code>stacktrace</code>. Every value, key values included, is a string, or
	 * <code>null</code> for a key value without one. For a specific schema (ECS, GELF,
	 * Logstash) and more options use the <code>rainbowgum-json</code> module.
	 * <p>
	 * With JSON5, values can be colored like the TTLL formatter, and by default the level
	 * is padded after its closing quote so the value stays the level name:
	 * <code>level:"INFO" ,logger:...</code>. With JSON the color, theme, and pretty print
	 * are ignored, so the output stays plain JSON.
	 * @param name encoder name, used for property lookup.
	 * @param format JSON5 (default), which leaves identifier keys unquoted, or JSON.
	 * @param keyValues where key values go, see {@link KeyValuesPlacement}.
	 * @param prettyPrint JSON5 only, added whitespace, see {@link Json5PrettyPrint}.
	 * @param color JSON5 only, whether to color values, see {@link TTLL.ColorMode}.
	 * @param theme JSON5 only, which colors, see {@link TTLL.ColorTheme}.
	 * @param serviceRegistry where themes chosen by name in properties are looked up.
	 * @return formatter.
	 */
	@LogConfigurable(name = "Json5FormatterBuilder", prefix = LogProperties.ENCODER_PREFIX + "json5.")
	static LogFormatter of(@LogConfigurable.KeyParameter String name,
			@LogConfigurable.DefaultParameter(
					constant = "JSON5") @LogConfigurable.ConvertParameter("convertFormat") Format format,
			@LogConfigurable.DefaultParameter(
					constant = "MERGED") @LogConfigurable.ConvertParameter("convertKeyValues") KeyValuesPlacement keyValues,
			@LogConfigurable.DefaultParameter(
					constant = "LEVEL_PADDING") @LogConfigurable.ConvertParameter("convertPrettyPrint") Json5PrettyPrint prettyPrint,
			@LogConfigurable.ConvertParameter("convertColor") TTLL.@Nullable ColorMode color,
			@LogConfigurable.ConvertParameter("convertTheme") TTLL.@Nullable ColorTheme theme,
			@LogConfigurable.PassThroughParameter @Nullable ServiceRegistry serviceRegistry) {
		return switch (format) {
			case JSON -> new Json5Formatter(false, keyValues, Json5PrettyPrint.OFF, Palette.OFF);
			case JSON5 -> new Json5Formatter(true, keyValues, prettyPrint,
					Palette.of(color == null ? TTLL.ColorMode.DEFAULT : color,
							PaletteColorTheme.resolve(theme, serviceRegistry), AnsiSupport::isAnsiSupported));
			case PERCENT, LOGFMT -> throw new IllegalArgumentException(
					"format=" + format.name().toLowerCase(java.util.Locale.ROOT) + " is not JSON. Use json or json5.");
		};
	}

	static Format convertFormat(String value) {
		return LogProperty.enumValue(Format.class, value);
	}

	static KeyValuesPlacement convertKeyValues(String value) {
		return KeyValuesPlacement.parse(value);
	}

	static Json5PrettyPrint convertPrettyPrint(String value) {
		return Json5PrettyPrint.parse(value);
	}

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

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

	@Override
	public void format(StringBuilder output, LogEvent event) {
		var p = palette;
		output.append(open);
		appendKey(output, "time", json5);
		output.append(':');
		Palette.start(output, p.timestamp());
		output.append('"');
		TIME.formatTimestamp(output, event.timestamp());
		output.append('"');
		Palette.end(output, p.timestamp());
		output.append(separator);
		appendKey(output, "level", json5);
		output.append(':');
		String levelColor = p.levelColor(event.level());
		Palette.start(output, levelColor);
		output.append('"');
		int levelStart = output.length();
		levelFormatter.formatLevel(output, event.level());
		int padding = Palette.removeTrailingSpaces(output, levelStart);
		output.append('"');
		Palette.end(output, levelColor);
		Palette.spaces(output, padding);
		output.append(separator);
		appendKey(output, "logger", json5);
		output.append(':');
		Palette.start(output, p.logger());
		JsonKeyValuesFormatter.appendString(output, event.loggerName());
		Palette.end(output, p.logger());
		output.append(separator);
		appendKey(output, "thread", json5);
		output.append(':');
		Palette.start(output, p.thread());
		JsonKeyValuesFormatter.appendString(output, event.threadName());
		Palette.end(output, p.thread());
		output.append(separator);
		appendKey(output, "msg", json5);
		output.append(':');
		int start = output.length();
		event.formattedMessage(output);
		JsonKeyValuesFormatter.quoteInPlace(output, start);
		var kvs = event.keyValues();
		int keyValuesStart = output.length();
		switch (keyValues) {
			case MERGED -> {
				int colliding = kvs.forEach(flat, 0, output);
				if (colliding > 0) {
					appendNested(output, kvs, nestedColliding);
				}
			}
			case NESTED -> appendNested(output, kvs, nestedAll);
			case NONE -> {
			}
		}
		if (output.length() > keyValuesStart && !p.keyValues().isEmpty()) {
			// Key values start with the separator from msg, left uncolored.
			int colorStart = output.indexOf(separator, keyValuesStart) == keyValuesStart
					? keyValuesStart + separator.length() : keyValuesStart;
			output.insert(colorStart, Ansi.start(p.keyValues()));
			output.append(Ansi.RESET);
		}
		var throwable = event.throwableOrNull();
		if (throwable != null) {
			output.append(separator);
			appendKey(output, "error", json5);
			output.append(':');
			JsonKeyValuesFormatter.appendString(output, throwable.toString());
			output.append(separator);
			appendKey(output, "stacktrace", json5);
			output.append(':');
			start = output.length();
			ThrowableFormatter.appendThrowable(output, throwable);
			int end = output.length();
			while (end > start && (output.charAt(end - 1) == '\n' || output.charAt(end - 1) == '\r')) {
				end--;
			}
			output.setLength(end);
			JsonKeyValuesFormatter.quoteInPlace(output, start);
		}
		output.append(close);
	}

	private void appendNested(StringBuilder output, KeyValues kvs, Members members) {
		output.append(separator);
		appendKey(output, KEY_VALUES_FIELD, json5);
		output.append(":{");
		kvs.forEach(members, 0, output);
		output.append('}');
	}

	static void appendKey(StringBuilder output, String key, boolean json5) {
		if (json5 && JsonKeyValuesFormatter.isIdentifier(key)) {
			output.append(key);
		}
		else {
			JsonKeyValuesFormatter.appendString(output, key);
		}
	}

	/*
	 * Writes key values as object members. FLAT writes those not named like an event
	 * field, each after a comma since the event's own fields come first, and returns how
	 * many it skipped; the nested modes write comma separated members and return how many
	 * they wrote.
	 */
	record Members(boolean json5, String separator, Mode mode) implements KeyValuesConsumer<StringBuilder> {

		enum Mode {

			FLAT, NESTED_ALL, NESTED_COLLIDING

		}

		@Override
		public int accept(KeyValues values, String key, @Nullable String value, int count, StringBuilder output) {
			boolean field = FIELDS.contains(key);
			switch (mode) {
				case FLAT -> {
					if (field) {
						return count + 1;
					}
					output.append(separator);
					appendMember(output, key, value);
					return count;
				}
				case NESTED_COLLIDING -> {
					if (!field) {
						return count;
					}
				}
				case NESTED_ALL -> {
				}
			}
			if (count > 0) {
				output.append(separator);
			}
			appendMember(output, key, value);
			return count + 1;
		}

		private void appendMember(StringBuilder output, String key, @Nullable String value) {
			appendKey(output, key, json5);
			output.append(':');
			if (value == null) {
				output.append("null");
			}
			else {
				JsonKeyValuesFormatter.appendString(output, value);
			}
		}

	}

}