Repository: sirupsen/logrus
Pull request #977 (2019-06-05 03:11:16)

Add an pad option log level text
Closes https://github.com/sirupsen/logrus/issues/959

I spent a few hours working on the tests. They're not perfect, but I don't think they can be improved substantially without doing a large refactor (which wouldn't be appropriate for me to do in this PR).

The implementation of the option itself is very straightforward, thanks to `Sprintf`.

I added a bunch of comments that possibly don't belong in the source code? If it's non-ideal having them there, then I can remove them.

The PR should be pretty comprehensive, but totally let me know if there's something else I missed ✨ 


Patch:
diff --git a/README.md b/README.md
index 2040b42..e7450a8 100644
--- a/README.md
+++ b/README.md
@@ -354,6 +354,7 @@ The built-in logging formatters are:
     [github.com/mattn/go-colorable](https://github.com/mattn/go-colorable).
   * When colors are enabled, levels are truncated to 4 characters by default. To disable
     truncation set the `DisableLevelTruncation` field to `true`.
+  * When outputting to a TTY, it's often helpful to visually scan down a column where all the levels are the same width. Setting the `PadLevelText` field to `true` enables this behavior, by adding padding to the level text.
   * All options are listed in the [generated docs](https://godoc.org/github.com/sirupsen/logrus#TextFormatter).
 * `logrus.JSONFormatter`. Logs fields as JSON.
   * All options are listed in the [generated docs](https://godoc.org/github.com/sirupsen/logrus#JSONFormatter).
diff --git a/text_formatter.go b/text_formatter.go
index e01587c..f08563e 100644
--- a/text_formatter.go
+++ b/text_formatter.go
@@ -6,9 +6,11 @@ import (
 	"os"
 	"runtime"
 	"sort"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
+	"unicode/utf8"
 )
 
 const (
@@ -57,6 +59,10 @@ type TextFormatter struct {
 	// Disables the truncation of the level text to 4 characters.
 	DisableLevelTruncation bool
 
+	// PadLevelText Adds padding the level text so that all the levels output at the same length
+	// PadLevelText is a superset of the DisableLevelTruncation option
+	PadLevelText bool
+
 	// QuoteEmptyFields will wrap empty fields in quotes if true
 	QuoteEmptyFields bool
 
@@ -79,12 +85,22 @@ type TextFormatter struct {
 	CallerPrettyfier func(*runtime.Frame) (function string, file string)
 
 	terminalInitOnce sync.Once
+
+	// The max length of the level text, generated dynamically on init
+	levelTextMaxLength int
 }
 
 func (f *TextFormatter) init(entry *Entry) {
 	if entry.Logger != nil {
 		f.isTerminal = checkIfTerminal(entry.Logger.Out)
 	}
+	// Get the max length of the level text
+	for _, level := range AllLevels {
+		levelTextLength := utf8.RuneCount([]byte(level.String()))
+		if levelTextLength > f.levelTextMaxLength {
+			f.levelTextMaxLength = levelTextLength
+		}
+	}
 }
 
 func (f *TextFormatter) isColored() bool {
@@ -217,9 +233,18 @@ func (f *TextFormatter) printColored(b *bytes.Buffer, entry *Entry, keys []strin
 	}
 
 	levelText := strings.ToUpper(entry.Level.String())
-	if !f.DisableLevelTruncation {
+	if !f.DisableLevelTruncation && !f.PadLevelText {
 		levelText = levelText[0:4]
 	}
+	if f.PadLevelText {
+		// Generates the format string used in the next line, for example "%-6s" or "%-7s".
+		// Based on the max level text length.
+		formatString := "%-" + strconv.Itoa(f.levelTextMaxLength) + "s"
+		// Formats the level text by appending spaces up to the max length, for example:
+		// 	- "INFO   "
+		//	- "WARNING"
+		levelText = fmt.Sprintf(formatString, levelText)
+	}
 
 	// Remove a single newline if it already exists in the message to keep
 	// the behavior of logrus text_formatter the same as the stdlib log package


Test patch:
diff --git a/text_formatter_test.go b/text_formatter_test.go
index 9c5e6f0..99b5d8c 100644
--- a/text_formatter_test.go
+++ b/text_formatter_test.go
@@ -172,6 +172,97 @@ func TestDisableLevelTruncation(t *testing.T) {
 	checkDisableTruncation(false, InfoLevel)
 }
 
+func TestPadLevelText(t *testing.T) {
+	// A note for future maintainers / committers:
+	//
+	// This test denormalizes the level text as a part of its assertions.
+	// Because of that, its not really a "unit test" of the PadLevelText functionality.
+	// So! Many apologies to the potential future person who has to rewrite this test
+	// when they are changing some completely unrelated functionality.
+	params := []struct {
+		name            string
+		level           Level
+		paddedLevelText string
+	}{
+		{
+			name:            "PanicLevel",
+			level:           PanicLevel,
+			paddedLevelText: "PANIC  ", // 2 extra spaces
+		},
+		{
+			name:            "FatalLevel",
+			level:           FatalLevel,
+			paddedLevelText: "FATAL  ", // 2 extra spaces
+		},
+		{
+			name:            "ErrorLevel",
+			level:           ErrorLevel,
+			paddedLevelText: "ERROR  ", // 2 extra spaces
+		},
+		{
+			name:  "WarnLevel",
+			level: WarnLevel,
+			// WARNING is already the max length, so we don't need to assert a paddedLevelText
+		},
+		{
+			name:            "DebugLevel",
+			level:           DebugLevel,
+			paddedLevelText: "DEBUG  ", // 2 extra spaces
+		},
+		{
+			name:            "TraceLevel",
+			level:           TraceLevel,
+			paddedLevelText: "TRACE  ", // 2 extra spaces
+		},
+		{
+			name:            "InfoLevel",
+			level:           InfoLevel,
+			paddedLevelText: "INFO   ", // 3 extra spaces
+		},
+	}
+
+	// We create a "default" TextFormatter to do a control test.
+	// We also create a TextFormatter with PadLevelText, which is the parameter we want to do our most relevant assertions against.
+	tfDefault := TextFormatter{}
+	tfWithPadding := TextFormatter{PadLevelText: true}
+
+	for _, val := range params {
+		t.Run(val.name, func(t *testing.T) {
+			// TextFormatter writes into these bytes.Buffers, and we make assertions about their contents later
+			var bytesDefault bytes.Buffer
+			var bytesWithPadding bytes.Buffer
+
+			// The TextFormatter instance and the bytes.Buffer instance are different here
+			// all the other arguments are the same. We also initialize them so that they
+			// fill in the value of levelTextMaxLength.
+			tfDefault.init(&Entry{})
+			tfDefault.printColored(&bytesDefault, &Entry{Level: val.level}, []string{}, nil, "")
+			tfWithPadding.init(&Entry{})
+			tfWithPadding.printColored(&bytesWithPadding, &Entry{Level: val.level}, []string{}, nil, "")
+
+			// turn the bytes back into a string so that we can actually work with the data
+			logLineDefault := (&bytesDefault).String()
+			logLineWithPadding := (&bytesWithPadding).String()
+
+			// Control: the level text should not be padded by default
+			if val.paddedLevelText != "" && strings.Contains(logLineDefault, val.paddedLevelText) {
+				t.Errorf("log line %q should not contain the padded level text %q by default", logLineDefault, val.paddedLevelText)
+			}
+
+			// Assertion: the level text should still contain the string representation of the level
+			if !strings.Contains(strings.ToLower(logLineWithPadding), val.level.String()) {
+				t.Errorf("log line %q should contain the level text %q when padding is enabled", logLineWithPadding, val.level.String())
+			}
+
+			// Assertion: the level text should be in its padded form now
+			if val.paddedLevelText != "" && !strings.Contains(logLineWithPadding, val.paddedLevelText) {
+				t.Errorf("log line %q should contain the padded level text %q when padding is enabled", logLineWithPadding, val.paddedLevelText)
+			}
+
+		})
+	}
+}
+
 func TestDisableTimestampWithColoredOutput(t *testing.T) {
 	tf := &TextFormatter{DisableTimestamp: true, ForceColors: true}