diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 7566b67..44bb669 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -104,7 +104,8 @@ func local() *time.Location { } // External returns an io.Writer that logs a script's output, one log line per -// text line, at externalLevel. +// text line, at externalLevel. A carriage return redraws a line in place, as a +// progress bar does, so a line is logged as it was last drawn. func External() io.Writer { return &lineWriter{level: externalLevel} } type lineWriter struct { @@ -119,10 +120,19 @@ func (w *lineWriter) Write(p []byte) (int, error) { if i < 0 { break } - if line := bytes.TrimRight(w.buf[:i], "\r"); len(line) > 0 { + line := bytes.TrimRight(w.buf[:i], "\r") + if j := bytes.LastIndexByte(line, '\r'); j >= 0 { + line = line[j+1:] + } + if len(line) > 0 { write(w.level, string(line)) } w.buf = w.buf[i+1:] } + // Earlier drawings of the unfinished line go now, so a long bar is not held + // in memory. Carriage returns at the very end draw nothing yet. + if j := bytes.LastIndexByte(bytes.TrimRight(w.buf, "\r"), '\r'); j >= 0 { + w.buf = w.buf[j+1:] + } return len(p), nil } diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index b12653b..504efd6 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -80,6 +80,30 @@ func TestScriptOutputIsSplitIntoLines(t *testing.T) { } } +// A progress bar redraws its line with a carriage return: the log keeps what it +// last showed, not every frame, and a "\r\n" split across two writes still ends +// one line. +func TestARedrawnLineIsLoggedAsItEnds(t *testing.T) { + p := open(t) + w := External() + w.Write([]byte("fetching\n\r## 10.0%")) + w.Write([]byte("\r#### 20.0%\r")) + w.Write([]byte("\r######100.0%\r")) + w.Write([]byte("\nnext\r\r")) + w.Write([]byte("\n")) + + got := read(t, p) + want := []string{"fetching", "######100.0%", "next"} + if len(got) != len(want) { + t.Fatalf("got %d lines, want %d: %q", len(got), len(want), got) + } + for i, w := range want { + if m := line.FindStringSubmatch(got[i]); m == nil || m[2] != w { + t.Errorf("line %d is %q, want %s", i, got[i], w) + } + } +} + // A line with no newline behind it yet is not a line. func TestAPartialLineIsHeldBack(t *testing.T) { p := open(t)