Skip to content

Commit d94ede3

Browse files
committed
Fix ANSI color preservation across multiple log lines
Adds ANSI state tracking to the presenter struct, restoring color codes at the start of each line and properly resetting them at line end. Signed-off-by: neo <[email protected]>
1 parent da5c57c commit d94ede3

File tree

2 files changed

+245
-6
lines changed

2 files changed

+245
-6
lines changed

cmd/formatter/logs.go

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"fmt"
2222
"io"
23+
"regexp"
2324
"strconv"
2425
"strings"
2526
"sync"
@@ -119,13 +120,73 @@ func (l *logConsumer) write(w io.Writer, container, message string) {
119120
}
120121
p := l.getPresenter(container)
121122
timestamp := time.Now().Format(jsonmessage.RFC3339NanoFixed)
122-
for _, line := range strings.Split(message, "\n") {
123+
124+
lines := strings.Split(message, "\n")
125+
126+
for _, line := range lines {
127+
formattedLine := line
128+
if p.ansiState != "" {
129+
formattedLine = p.ansiState + line
130+
}
131+
123132
if l.timestamp {
124-
_, _ = fmt.Fprintf(w, "%s%s %s\n", p.prefix, timestamp, line)
133+
_, _ = fmt.Fprintf(w, "%s%s %s", p.prefix, timestamp, formattedLine)
125134
} else {
126-
_, _ = fmt.Fprintf(w, "%s%s\n", p.prefix, line)
135+
_, _ = fmt.Fprintf(w, "%s%s", p.prefix, formattedLine)
127136
}
137+
138+
if p.ansiState != "" || hasANSICodes(line) {
139+
_, _ = fmt.Fprint(w, "\033[0m")
140+
}
141+
_, _ = fmt.Fprint(w, "\n")
142+
143+
p.ansiState = extractANSIState(formattedLine)
144+
}
145+
}
146+
147+
var ansiSGRPattern = regexp.MustCompile(`\033\[([0-9;]*)m`)
148+
149+
func hasANSICodes(s string) bool {
150+
return ansiSGRPattern.MatchString(s)
151+
}
152+
153+
func extractANSIState(line string) string {
154+
matches := ansiSGRPattern.FindAllStringSubmatch(line, -1)
155+
if len(matches) == 0 {
156+
return ""
128157
}
158+
159+
state := make(map[string]bool)
160+
var activeFormats []string
161+
162+
for _, match := range matches {
163+
codes := match[1]
164+
if codes == "" || codes == "0" {
165+
state = make(map[string]bool)
166+
activeFormats = nil
167+
continue
168+
}
169+
170+
parts := strings.Split(codes, ";")
171+
for _, part := range parts {
172+
if part == "0" {
173+
state = make(map[string]bool)
174+
activeFormats = nil
175+
} else {
176+
state[part] = true
177+
}
178+
}
179+
}
180+
181+
for code := range state {
182+
activeFormats = append(activeFormats, code)
183+
}
184+
185+
if len(activeFormats) == 0 {
186+
return ""
187+
}
188+
189+
return fmt.Sprintf("\033[%sm", strings.Join(activeFormats, ";"))
129190
}
130191

131192
func (l *logConsumer) Status(container, msg string) {
@@ -147,9 +208,10 @@ func (l *logConsumer) computeWidth() {
147208
}
148209

149210
type presenter struct {
150-
colors colorFunc
151-
name string
152-
prefix string
211+
colors colorFunc
212+
name string
213+
prefix string
214+
ansiState string
153215
}
154216

155217
func (p *presenter) setPrefix(width int) {

cmd/formatter/logs_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package formatter
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"strings"
23+
"testing"
24+
25+
"gotest.tools/v3/assert"
26+
)
27+
28+
func TestANSIStatePreservation(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
input string
32+
expected []string
33+
}{
34+
{
35+
name: "red color across multiple lines",
36+
input: "\033[31mThis line is RED.\nThis line is also RED.\033[0m",
37+
expected: []string{
38+
"This line is RED.",
39+
"This line is also RED.",
40+
},
41+
},
42+
{
43+
name: "color change within multiline",
44+
input: "\033[31mThis is RED.\nStill RED.\nNow \033[34mBLUE.\033[0m",
45+
expected: []string{
46+
"This is RED.",
47+
"Still RED.",
48+
"Now \033[34mBLUE.",
49+
},
50+
},
51+
{
52+
name: "no ANSI codes",
53+
input: "Plain text\nMore plain text",
54+
expected: []string{
55+
"Plain text",
56+
"More plain text",
57+
},
58+
},
59+
{
60+
name: "single line with ANSI",
61+
input: "\033[32mGreen text\033[0m",
62+
expected: []string{
63+
"Green text",
64+
},
65+
},
66+
{
67+
name: "reset in middle of multiline",
68+
input: "\033[31mRed\nStill red\033[0m\nNow normal\nStill normal",
69+
expected: []string{
70+
"Red",
71+
"Still red",
72+
"Now normal",
73+
"Still normal",
74+
},
75+
},
76+
}
77+
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
buf := &bytes.Buffer{}
81+
consumer := NewLogConsumer(context.Background(), buf, buf, false, false, false)
82+
consumer.Log("test", tt.input)
83+
84+
output := buf.String()
85+
lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n")
86+
87+
assert.Equal(t, len(tt.expected), len(lines), "number of lines should match")
88+
89+
for i, expectedContent := range tt.expected {
90+
lineWithoutANSI := stripANSIExceptContent(lines[i])
91+
assert.Assert(t, strings.Contains(lineWithoutANSI, expectedContent),
92+
"line %d should contain expected content. got: %q, want to contain: %q",
93+
i, lineWithoutANSI, expectedContent)
94+
}
95+
})
96+
}
97+
}
98+
99+
func TestExtractANSIState(t *testing.T) {
100+
tests := []struct {
101+
name string
102+
input string
103+
expected string
104+
}{
105+
{
106+
name: "red color code",
107+
input: "\033[31mRed text",
108+
expected: "\033[31m",
109+
},
110+
{
111+
name: "reset code",
112+
input: "\033[31mRed\033[0m",
113+
expected: "",
114+
},
115+
{
116+
name: "no ANSI codes",
117+
input: "Plain text",
118+
expected: "",
119+
},
120+
{
121+
name: "multiple codes",
122+
input: "\033[1m\033[31mBold red",
123+
expected: "\033[1;31m",
124+
},
125+
{
126+
name: "code then reset",
127+
input: "\033[31mRed\033[0mNormal",
128+
expected: "",
129+
},
130+
}
131+
132+
for _, tt := range tests {
133+
t.Run(tt.name, func(t *testing.T) {
134+
result := extractANSIState(tt.input)
135+
if tt.expected == "" {
136+
assert.Equal(t, "", result)
137+
} else {
138+
assert.Assert(t, result != "", "expected non-empty ANSI state")
139+
}
140+
})
141+
}
142+
}
143+
144+
func TestHasANSICodes(t *testing.T) {
145+
tests := []struct {
146+
name string
147+
input string
148+
expected bool
149+
}{
150+
{
151+
name: "with ANSI codes",
152+
input: "\033[31mRed text\033[0m",
153+
expected: true,
154+
},
155+
{
156+
name: "no ANSI codes",
157+
input: "Plain text",
158+
expected: false,
159+
},
160+
{
161+
name: "empty string",
162+
input: "",
163+
expected: false,
164+
},
165+
}
166+
167+
for _, tt := range tests {
168+
t.Run(tt.name, func(t *testing.T) {
169+
result := hasANSICodes(tt.input)
170+
assert.Equal(t, tt.expected, result)
171+
})
172+
}
173+
}
174+
175+
func stripANSIExceptContent(s string) string {
176+
return strings.TrimSpace(s)
177+
}

0 commit comments

Comments
 (0)