From 798a5d5c296c0b96ab80e1a6831b912845cc1eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:53:49 +0200 Subject: [PATCH] fix: keep tabs in text values and treat CRLF as newlines Pretty-printed errors (for example oops stack fragments) indent with tabs and often use Windows newlines. The text formatter escaped both, and Lip Gloss then turned remaining tabs into spaces, so the source never lined up. Leave tabs intact and normalize CR before wrapping multiline values. --- text.go | 35 +++++++++++++++++++++++++++-------- text_test.go | 21 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/text.go b/text.go index 9187c4c..8a776d7 100644 --- a/text.go +++ b/text.go @@ -8,6 +8,8 @@ import ( "time" "unicode" "unicode/utf8" + + "charm.land/lipgloss/v2" ) const ( @@ -17,6 +19,7 @@ const ( func (l *Logger) writeIndent(w io.Writer, str string, indent string, newline bool, key string) { st := l.styles + str = normalizeLogNewlines(str) // kindly borrowed from hclog for { @@ -26,9 +29,9 @@ func (l *Logger) writeIndent(w io.Writer, str string, indent string, newline boo _, _ = w.Write([]byte(indent)) val := escapeStringForOutput(str, false) if valueStyle, ok := st.Values[key]; ok { - val = valueStyle.Render(val) + val = renderKeepTabs(valueStyle, val) } else { - val = st.Value.Render(val) + val = renderKeepTabs(st.Value, val) } _, _ = w.Write([]byte(val)) if newline { @@ -40,13 +43,27 @@ func (l *Logger) writeIndent(w io.Writer, str string, indent string, newline boo _, _ = w.Write([]byte(indent)) val := escapeStringForOutput(str[:nl], false) - val = st.Value.Render(val) + val = renderKeepTabs(st.Value, val) _, _ = w.Write([]byte(val)) _, _ = w.Write([]byte{'\n'}) str = str[nl+1:] } } +// normalizeLogNewlines turns CRLF and leftover CR into LF so Windows line +// endings don't show up as a literal \r in the text formatter. +func normalizeLogNewlines(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") +} + +// renderKeepTabs styles s without converting tabs to spaces. Lip Gloss +// otherwise replaces tabs with 4 spaces (or removes them when TabWidth is 0), +// which breaks pretty-printed errors that indent with tabs. +func renderKeepTabs(style lipgloss.Style, s string) string { + return style.TabWidth(lipgloss.NoTabConversion).Render(s) +} + func needsEscaping(str string) bool { for _, b := range str { if !unicode.IsPrint(b) || b == '"' { @@ -95,7 +112,9 @@ func escapeStringForOutput(str string, escapeQuotes bool) string { case '\r': bb.WriteString(`\r`) case '\t': - bb.WriteString(`\t`) + // Keep tabs as tabs so multiline values (pretty-printed + // errors, source fragments) retain their indentation. + bb.WriteRune(r) case '\v': bb.WriteString(`\v`) default: @@ -209,8 +228,8 @@ func (l *Logger) textFormatter(keyvals ...any) { } case MessageKey: if msg := keyvals[i+1]; msg != nil { - m := fmt.Sprint(msg) - m = st.Message.Render(m) + m := normalizeLogNewlines(fmt.Sprint(msg)) + m = renderKeepTabs(st.Message, m) writeSpace(&l.b, firstKey) l.b.WriteString(m) } @@ -255,10 +274,10 @@ func (l *Logger) textFormatter(keyvals ...any) { writeSpace(&l.b, firstKey) l.b.WriteString(key) l.b.WriteString(sep) - l.b.WriteString(valueStyle.Render(fmt.Sprintf(`"%s"`, + l.b.WriteString(renderKeepTabs(valueStyle, fmt.Sprintf(`"%s"`, escapeStringForOutput(val, true)))) } else { - val = valueStyle.Render(val) + val = renderKeepTabs(valueStyle, val) writeSpace(&l.b, firstKey) l.b.WriteString(key) l.b.WriteString(sep) diff --git a/text_test.go b/text_test.go index b194ee1..60099e4 100644 --- a/text_test.go +++ b/text_test.go @@ -144,6 +144,27 @@ func TestTextLogger(t *testing.T) { kvs: []any{"key1", "val1\nval2"}, f: logger.Error, }, + { + name: "multiline value preserves tabs", + expected: "ERROR oops\n error=\n │ src:\n │ \tline\n", + msg: "oops", + kvs: []any{"error", "src:\n\tline"}, + f: logger.Error, + }, + { + name: "multiline value treats CRLF as a line break", + expected: "ERROR oops\n error=\n │ src:\n │ \tline\n", + msg: "oops", + kvs: []any{"error", "src:\r\n\tline"}, + f: logger.Error, + }, + { + name: "message preserves tabs", + expected: "ERROR src:\tline\n", + msg: "src:\tline", + kvs: nil, + f: logger.Error, + }, { name: "odd number of keyvals", expected: "ERROR info key1=val1 key2=val2 key3=\"missing value\"\n",