Add more styling support to the Logback and Log4j2 color converters

- Add REVERSE (ANSI code 7) to AnsiStyle enum
- Expand ColorConverter ELEMENTS map to include all AnsiStyle values
  (bold, italic, underline, normal, faint, reverse) and all AnsiBackground
  colors with the bg_ prefix (bg_red, bg_bright_green, etc.)
- Update tests for both logback and log4j2 converters

See gh-49285

Signed-off-by: mvirole <virolemayank@gmail.com>
This commit is contained in:
mvirole
2026-03-11 16:11:38 +00:00
committed by Andy Wilkinson
parent bd663c1551
commit 2e12df8e4f
5 changed files with 256 additions and 29 deletions
@@ -32,7 +32,9 @@ public enum AnsiStyle implements AnsiElement {
ITALIC("3"),
UNDERLINE("4");
UNDERLINE("4"),
REVERSE("7");
private final String code;
@@ -16,6 +16,7 @@
package org.springframework.boot.logging.log4j2;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -35,6 +36,7 @@ import org.apache.logging.log4j.core.pattern.PatternFormatter;
import org.apache.logging.log4j.core.pattern.PatternParser;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.ansi.AnsiBackground;
import org.springframework.boot.ansi.AnsiColor;
import org.springframework.boot.ansi.AnsiElement;
import org.springframework.boot.ansi.AnsiOutput;
@@ -42,8 +44,11 @@ import org.springframework.boot.ansi.AnsiStyle;
/**
* Log4j2 {@link LogEventPatternConverter} to color output using the {@link AnsiOutput}
* class. A single option 'styling' can be provided to the converter, or if not specified
* color styling will be picked based on the logging level.
* class. One or more styling options can be provided to the converter, or if not
* specified color styling will be picked based on the logging level. Supported options
* include foreground colors (e.g. {@code red}, {@code bright_blue}), background colors
* (e.g. {@code bg_red}, {@code bg_bright_green}), and text styles (e.g. {@code bold},
* {@code underline}, {@code reverse}).
*
* @author Vladimir Tsanev
* @since 1.3.0
@@ -56,10 +61,17 @@ public final class ColorConverter extends LogEventPatternConverter {
static {
Map<String, AnsiElement> ansiElements = new HashMap<>();
// Foreground colors (e.g. "red", "bright_blue")
Arrays.stream(AnsiColor.values())
.filter((color) -> color != AnsiColor.DEFAULT)
.forEach((color) -> ansiElements.put(color.name().toLowerCase(Locale.ROOT), color));
ansiElements.put("faint", AnsiStyle.FAINT);
// Text styles (e.g. "bold", "italic", "underline", "reverse", "faint", "normal")
Arrays.stream(AnsiStyle.values())
.forEach((style) -> ansiElements.put(style.name().toLowerCase(Locale.ROOT), style));
// Background colors with "bg_" prefix (e.g. "bg_red", "bg_bright_blue")
Arrays.stream(AnsiBackground.values())
.filter((bg) -> bg != AnsiBackground.DEFAULT)
.forEach((bg) -> ansiElements.put("bg_" + bg.name().toLowerCase(Locale.ROOT), bg));
ELEMENTS = Collections.unmodifiableMap(ansiElements);
}
@@ -75,12 +87,12 @@ public final class ColorConverter extends LogEventPatternConverter {
private final List<PatternFormatter> formatters;
private final @Nullable AnsiElement styling;
private final List<AnsiElement> stylings;
private ColorConverter(List<PatternFormatter> formatters, @Nullable AnsiElement styling) {
private ColorConverter(List<PatternFormatter> formatters, List<AnsiElement> stylings) {
super("style", "style");
this.formatters = formatters;
this.styling = styling;
this.stylings = stylings;
}
@Override
@@ -100,13 +112,15 @@ public final class ColorConverter extends LogEventPatternConverter {
formatter.format(event, buf);
}
if (!buf.isEmpty()) {
AnsiElement element = this.styling;
if (element == null) {
if (this.stylings.isEmpty()) {
// Assume highlighting
element = LEVELS.get(event.getLevel().intLevel());
AnsiElement element = LEVELS.get(event.getLevel().intLevel());
element = (element != null) ? element : AnsiColor.GREEN;
appendAnsiString(toAppendTo, buf.toString(), element);
}
else {
appendAnsiString(toAppendTo, buf.toString(), this.stylings.toArray(new AnsiElement[0]));
}
appendAnsiString(toAppendTo, buf.toString(), element);
}
}
@@ -114,6 +128,13 @@ public final class ColorConverter extends LogEventPatternConverter {
toAppendTo.append(AnsiOutput.toString(element, in));
}
protected void appendAnsiString(StringBuilder toAppendTo, String in, AnsiElement... elements) {
Object[] ansiParams = new Object[elements.length + 1];
System.arraycopy(elements, 0, ansiParams, 0, elements.length);
ansiParams[elements.length] = in;
toAppendTo.append(AnsiOutput.toString(ansiParams));
}
/**
* Creates a new instance of the class. Required by Log4J2.
* @param config the configuration
@@ -131,8 +152,19 @@ public final class ColorConverter extends LogEventPatternConverter {
}
PatternParser parser = PatternLayout.createPatternParser(config);
List<PatternFormatter> formatters = parser.parse(options[0]);
AnsiElement element = (options.length != 1) ? ELEMENTS.get(options[1]) : null;
return new ColorConverter(formatters, element);
List<AnsiElement> stylings = new ArrayList<>();
for (int i = 1; i < options.length; i++) {
if (options[i] != null) {
String[] optionParts = options[i].split(",");
for (String optionPart : optionParts) {
AnsiElement element = ELEMENTS.get(optionPart.trim().toLowerCase(Locale.ROOT));
if (element != null) {
stylings.add(element);
}
}
}
}
return new ColorConverter(formatters, stylings);
}
}
@@ -16,9 +16,11 @@
package org.springframework.boot.logging.logback;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -26,6 +28,7 @@ import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.pattern.CompositeConverter;
import org.springframework.boot.ansi.AnsiBackground;
import org.springframework.boot.ansi.AnsiColor;
import org.springframework.boot.ansi.AnsiElement;
import org.springframework.boot.ansi.AnsiOutput;
@@ -33,8 +36,11 @@ import org.springframework.boot.ansi.AnsiStyle;
/**
* Logback {@link CompositeConverter} to color output using the {@link AnsiOutput} class.
* A single 'color' option can be provided to the converter, or if not specified color
* will be picked based on the logging level.
* One or more styling options can be provided to the converter, or if not specified color
* will be picked based on the logging level. Supported options include foreground colors
* (e.g. {@code red}, {@code bright_blue}), background colors (e.g. {@code bg_red},
* {@code bg_bright_green}), and text styles (e.g. {@code bold}, {@code underline},
* {@code reverse}).
*
* @author Phillip Webb
* @since 1.0.0
@@ -45,10 +51,17 @@ public class ColorConverter extends CompositeConverter<ILoggingEvent> {
static {
Map<String, AnsiElement> ansiElements = new HashMap<>();
// Foreground colors (e.g. "red", "bright_blue")
Arrays.stream(AnsiColor.values())
.filter((color) -> color != AnsiColor.DEFAULT)
.forEach((color) -> ansiElements.put(color.name().toLowerCase(Locale.ROOT), color));
ansiElements.put("faint", AnsiStyle.FAINT);
// Text styles (e.g. "bold", "italic", "underline", "reverse", "faint", "normal")
Arrays.stream(AnsiStyle.values())
.forEach((style) -> ansiElements.put(style.name().toLowerCase(Locale.ROOT), style));
// Background colors with "bg_" prefix (e.g. "bg_red", "bg_bright_blue")
Arrays.stream(AnsiBackground.values())
.filter((bg) -> bg != AnsiBackground.DEFAULT)
.forEach((bg) -> ansiElements.put("bg_" + bg.name().toLowerCase(Locale.ROOT), bg));
ELEMENTS = Collections.unmodifiableMap(ansiElements);
}
@@ -63,19 +76,38 @@ public class ColorConverter extends CompositeConverter<ILoggingEvent> {
@Override
protected String transform(ILoggingEvent event, String in) {
AnsiElement color = ELEMENTS.get(getFirstOption());
if (color == null) {
// Assume highlighting
color = LEVELS.get(event.getLevel().toInteger());
color = (color != null) ? color : AnsiColor.GREEN;
List<String> options = getOptionList();
List<AnsiElement> elements = new ArrayList<>();
if (options != null) {
for (String option : options) {
String[] optionParts = option.split(",");
for (String optionPart : optionParts) {
AnsiElement element = ELEMENTS.get(optionPart.trim().toLowerCase(Locale.ROOT));
if (element != null) {
elements.add(element);
}
}
}
}
return toAnsiString(in, color);
if (elements.isEmpty()) {
// Assume highlighting
AnsiElement element = LEVELS.get(event.getLevel().toInteger());
elements.add((element != null) ? element : AnsiColor.GREEN);
}
return toAnsiString(in, elements.toArray(new AnsiElement[0]));
}
protected String toAnsiString(String in, AnsiElement element) {
return AnsiOutput.toString(element, in);
}
protected String toAnsiString(String in, AnsiElement... elements) {
Object[] ansiParams = new Object[elements.length + 1];
System.arraycopy(elements, 0, ansiParams, 0, elements.length);
ansiParams[elements.length] = in;
return AnsiOutput.toString(ansiParams);
}
static String getName(AnsiElement element) {
return ELEMENTS.entrySet()
.stream()
@@ -173,11 +173,88 @@ class ColorConverterTests {
assertThat(output).hasToString("\033[96min\033[0;39m");
}
@Test
void bold() {
StringBuilder output = new StringBuilder();
newConverter("bold").format(this.event, output);
assertThat(output).hasToString("\033[1min\033[0;39m");
}
@Test
void italic() {
StringBuilder output = new StringBuilder();
newConverter("italic").format(this.event, output);
assertThat(output).hasToString("\033[3min\033[0;39m");
}
@Test
void underline() {
StringBuilder output = new StringBuilder();
newConverter("underline").format(this.event, output);
assertThat(output).hasToString("\033[4min\033[0;39m");
}
@Test
void reverse() {
StringBuilder output = new StringBuilder();
newConverter("reverse").format(this.event, output);
assertThat(output).hasToString("\033[7min\033[0;39m");
}
@Test
void bgRed() {
StringBuilder output = new StringBuilder();
newConverter("bg_red").format(this.event, output);
assertThat(output).hasToString("\033[41min\033[0;39m");
}
@Test
void bgGreen() {
StringBuilder output = new StringBuilder();
newConverter("bg_green").format(this.event, output);
assertThat(output).hasToString("\033[42min\033[0;39m");
}
@Test
void bgYellow() {
StringBuilder output = new StringBuilder();
newConverter("bg_yellow").format(this.event, output);
assertThat(output).hasToString("\033[43min\033[0;39m");
}
@Test
void bgBlue() {
StringBuilder output = new StringBuilder();
newConverter("bg_blue").format(this.event, output);
assertThat(output).hasToString("\033[44min\033[0;39m");
}
@Test
void bgBrightRed() {
StringBuilder output = new StringBuilder();
newConverter("bg_bright_red").format(this.event, output);
assertThat(output).hasToString("\033[101min\033[0;39m");
}
@Test
void multipleStylesCommaSeparated() {
StringBuilder output = new StringBuilder();
newConverter("bold, red").format(this.event, output);
assertThat(output).hasToString("\033[1;31min\033[0;39m");
}
@Test
void multipleStylesMultipleOptions() {
StringBuilder output = new StringBuilder();
newConverter("bold", "red").format(this.event, output);
assertThat(output).hasToString("\033[1;31min\033[0;39m");
}
@Test
void highlightFatal() {
this.event.setLevel(Level.FATAL);
StringBuilder output = new StringBuilder();
newConverter(null).format(this.event, output);
newConverter((String) null).format(this.event, output);
assertThat(output).hasToString("\033[31min\033[0;39m");
}
@@ -185,7 +262,7 @@ class ColorConverterTests {
void highlightError() {
this.event.setLevel(Level.ERROR);
StringBuilder output = new StringBuilder();
newConverter(null).format(this.event, output);
newConverter((String) null).format(this.event, output);
assertThat(output).hasToString("\033[31min\033[0;39m");
}
@@ -193,7 +270,7 @@ class ColorConverterTests {
void highlightWarn() {
this.event.setLevel(Level.WARN);
StringBuilder output = new StringBuilder();
newConverter(null).format(this.event, output);
newConverter((String) null).format(this.event, output);
assertThat(output).hasToString("\033[33min\033[0;39m");
}
@@ -201,7 +278,7 @@ class ColorConverterTests {
void highlightDebug() {
this.event.setLevel(Level.DEBUG);
StringBuilder output = new StringBuilder();
newConverter(null).format(this.event, output);
newConverter((String) null).format(this.event, output);
assertThat(output).hasToString("\033[32min\033[0;39m");
}
@@ -209,12 +286,18 @@ class ColorConverterTests {
void highlightTrace() {
this.event.setLevel(Level.TRACE);
StringBuilder output = new StringBuilder();
newConverter(null).format(this.event, output);
newConverter((String) null).format(this.event, output);
assertThat(output).hasToString("\033[32min\033[0;39m");
}
private ColorConverter newConverter(@Nullable String styling) {
ColorConverter converter = ColorConverter.newInstance(null, new String[] { this.in, styling });
private ColorConverter newConverter(@Nullable String... stylings) {
if (stylings == null) {
stylings = new String[] { null };
}
String[] options = new String[1 + stylings.length];
options[0] = this.in;
System.arraycopy(stylings, 0, options, 1, stylings.length);
ColorConverter converter = ColorConverter.newInstance(null, options);
assertThat(converter).isNotNull();
return converter;
}
@@ -16,6 +16,7 @@
package org.springframework.boot.logging.logback;
import java.util.Arrays;
import java.util.Collections;
import ch.qos.logback.classic.Level;
@@ -170,6 +171,83 @@ class ColorConverterTests {
assertThat(out).isEqualTo("\033[96min\033[0;39m");
}
@Test
void bold() {
this.converter.setOptionList(Collections.singletonList("bold"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[1min\033[0;39m");
}
@Test
void italic() {
this.converter.setOptionList(Collections.singletonList("italic"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[3min\033[0;39m");
}
@Test
void underline() {
this.converter.setOptionList(Collections.singletonList("underline"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[4min\033[0;39m");
}
@Test
void reverse() {
this.converter.setOptionList(Collections.singletonList("reverse"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[7min\033[0;39m");
}
@Test
void bgRed() {
this.converter.setOptionList(Collections.singletonList("bg_red"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[41min\033[0;39m");
}
@Test
void bgGreen() {
this.converter.setOptionList(Collections.singletonList("bg_green"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[42min\033[0;39m");
}
@Test
void bgYellow() {
this.converter.setOptionList(Collections.singletonList("bg_yellow"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[43min\033[0;39m");
}
@Test
void bgBlue() {
this.converter.setOptionList(Collections.singletonList("bg_blue"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[44min\033[0;39m");
}
@Test
void bgBrightRed() {
this.converter.setOptionList(Collections.singletonList("bg_bright_red"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[101min\033[0;39m");
}
@Test
void multipleStylesCommaSeparated() {
this.converter.setOptionList(Collections.singletonList("bold, red"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[1;31min\033[0;39m");
}
@Test
void multipleStyles() {
this.converter.setOptionList(Arrays.asList("bold", "red"));
String out = this.converter.transform(this.event, this.in);
assertThat(out).isEqualTo("\033[1;31min\033[0;39m");
}
@Test
void highlightError() {
this.event.setLevel(Level.ERROR);