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",