From 65b21e3348c9141b26a8f00c67d88ae84b9083d1 Mon Sep 17 00:00:00 2001 From: Michael Eischer Date: Sun, 14 Sep 2025 17:27:36 +0200 Subject: [PATCH] ui: collect Quote and Truncate helpers Collect ui formatting helpers in the ui package --- internal/ui/backup/text.go | 3 +- internal/ui/format.go | 65 ++++++++++++++++-- internal/ui/format_test.go | 98 +++++++++++++++++++++++++-- internal/ui/table/table.go | 8 +-- internal/ui/termstatus/status.go | 64 +---------------- internal/ui/termstatus/status_test.go | 86 ----------------------- 6 files changed, 160 insertions(+), 164 deletions(-) diff --git a/internal/ui/backup/text.go b/internal/ui/backup/text.go index efd7ffdfe..359331b27 100644 --- a/internal/ui/backup/text.go +++ b/internal/ui/backup/text.go @@ -8,7 +8,6 @@ import ( "github.com/restic/restic/internal/archiver" "github.com/restic/restic/internal/restic" "github.com/restic/restic/internal/ui" - "github.com/restic/restic/internal/ui/termstatus" ) // TextProgress reports progress for the `backup` command. @@ -90,7 +89,7 @@ func (b *TextProgress) Error(_ string, err error) error { // CompleteItem is the status callback function for the archiver when a // file/dir has been saved successfully. func (b *TextProgress) CompleteItem(messageType, item string, s archiver.ItemStats, d time.Duration) { - item = termstatus.Quote(item) + item = ui.Quote(item) switch messageType { case "dir new": diff --git a/internal/ui/format.go b/internal/ui/format.go index fa50fb682..36dfa8147 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -8,6 +8,7 @@ import ( "math/bits" "strconv" "time" + "unicode" "golang.org/x/text/width" ) @@ -108,17 +109,17 @@ func ToJSONString(status interface{}) string { return buf.String() } -// TerminalDisplayWidth returns the number of terminal cells needed to display s -func TerminalDisplayWidth(s string) int { +// DisplayWidth returns the number of terminal cells needed to display s +func DisplayWidth(s string) int { width := 0 for _, r := range s { - width += terminalDisplayRuneWidth(r) + width += displayRuneWidth(r) } return width } -func terminalDisplayRuneWidth(r rune) int { +func displayRuneWidth(r rune) int { switch width.LookupRune(r).Kind() { case width.EastAsianWide, width.EastAsianFullwidth: return 2 @@ -128,3 +129,59 @@ func terminalDisplayRuneWidth(r rune) int { return 0 } } + +// Quote lines with funny characters in them, meaning control chars, newlines, +// tabs, anything else non-printable and invalid UTF-8. +// +// This is intended to produce a string that does not mess up the terminal +// rather than produce an unambiguous quoted string. +func Quote(line string) string { + for _, r := range line { + // The replacement character usually means the input is not UTF-8. + if r == unicode.ReplacementChar || !unicode.IsPrint(r) { + return strconv.Quote(line) + } + } + return line +} + +// Truncate s to fit in width (number of terminal cells) w. +// If w is negative, returns the empty string. +func Truncate(s string, w int) string { + if len(s) < w { + // Since the display width of a character is at most 2 + // and all of ASCII (single byte per rune) has width 1, + // no character takes more bytes to encode than its width. + return s + } + + for i := uint(0); i < uint(len(s)); { + utfsize := uint(1) // UTF-8 encoding size of first rune in s. + w-- + + if s[i] > unicode.MaxASCII { + var wide bool + if wide, utfsize = wideRune(s[i:]); wide { + w-- + } + } + + if w < 0 { + return s[:i] + } + i += utfsize + } + + return s +} + +// Guess whether the first rune in s would occupy two terminal cells +// instead of one. This cannot be determined exactly without knowing +// the terminal font, so we treat all ambiguous runes as full-width, +// i.e., two cells. +func wideRune(s string) (wide bool, utfsize uint) { + prop, size := width.LookupString(s) + kind := prop.Kind() + wide = kind != width.Neutral && kind != width.EastAsianNarrow + return wide, uint(size) +} diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go index d595026c4..d3c4bc277 100644 --- a/internal/ui/format_test.go +++ b/internal/ui/format_test.go @@ -1,9 +1,10 @@ package ui import ( + "strconv" "testing" - "github.com/restic/restic/internal/test" + rtest "github.com/restic/restic/internal/test" ) func TestFormatBytes(t *testing.T) { @@ -61,8 +62,8 @@ func TestParseBytes(t *testing.T) { {"9223372036854775807", 1<<63 - 1}, } { actual, err := ParseBytes(tt.in) - test.OK(t, err) - test.Equals(t, tt.expected, actual) + rtest.OK(t, err) + rtest.Equals(t, tt.expected, actual) } } @@ -81,11 +82,11 @@ func TestParseBytesInvalid(t *testing.T) { if err == nil { t.Errorf("wanted error for invalid value %q, got nil", s) } - test.Equals(t, int64(0), v) + rtest.Equals(t, int64(0), v) } } -func TestTerminalDisplayWidth(t *testing.T) { +func TestDisplayWidth(t *testing.T) { for _, c := range []struct { input string want int @@ -96,9 +97,94 @@ func TestTerminalDisplayWidth(t *testing.T) { {"a’b", 3}, {"aあb", 4}, } { - if got := TerminalDisplayWidth(c.input); got != c.want { + if got := DisplayWidth(c.input); got != c.want { t.Errorf("wrong display width for '%s', want %d, got %d", c.input, c.want, got) } } } + +func TestQuote(t *testing.T) { + for _, c := range []struct { + in string + needQuote bool + }{ + {"foo.bar/baz", false}, + {"föó_bàŕ-bãẑ", false}, + {" foo ", false}, + {"foo bar", false}, + {"foo\nbar", true}, + {"foo\rbar", true}, + {"foo\abar", true}, + {"\xff", true}, + {`c:\foo\bar`, false}, + // Issue #2260: terminal control characters. + {"\x1bm_red_is_beautiful", true}, + } { + if c.needQuote { + rtest.Equals(t, strconv.Quote(c.in), Quote(c.in)) + } else { + rtest.Equals(t, c.in, Quote(c.in)) + } + } +} + +func TestTruncate(t *testing.T) { + var tests = []struct { + input string + width int + output string + }{ + {"", 80, ""}, + {"", 0, ""}, + {"", -1, ""}, + {"foo", 80, "foo"}, + {"foo", 4, "foo"}, + {"foo", 3, "foo"}, + {"foo", 2, "fo"}, + {"foo", 1, "f"}, + {"foo", 0, ""}, + {"foo", -1, ""}, + {"Löwen", 4, "Löwe"}, + {"あああああ/data", 7, "あああ"}, + {"あああああ/data", 10, "あああああ"}, + {"あああああ/data", 11, "あああああ/"}, + } + + for _, test := range tests { + t.Run("", func(t *testing.T) { + out := Truncate(test.input, test.width) + if out != test.output { + t.Fatalf("wrong output for input %v, width %d: want %q, got %q", + test.input, test.width, test.output, out) + } + }) + } +} + +func benchmarkTruncate(b *testing.B, s string, w int) { + for i := 0; i < b.N; i++ { + Truncate(s, w) + } +} + +func BenchmarkTruncateASCII(b *testing.B) { + s := "This is an ASCII-only status message...\r\n" + benchmarkTruncate(b, s, len(s)-1) +} + +func BenchmarkTruncateUnicode(b *testing.B) { + s := "Hello World or Καλημέρα κόσμε or こんにちは 世界" + w := 0 + for i := 0; i < len(s); { + w++ + wide, utfsize := wideRune(s[i:]) + if wide { + w++ + } + i += int(utfsize) + } + b.ResetTimer() + + benchmarkTruncate(b, s, w-1) +} diff --git a/internal/ui/table/table.go b/internal/ui/table/table.go index 264a302a2..2aff8cbb1 100644 --- a/internal/ui/table/table.go +++ b/internal/ui/table/table.go @@ -90,7 +90,7 @@ func printLine(w io.Writer, print func(io.Writer, string) error, sep string, dat } // apply padding - pad := widths[fieldNum] - ui.TerminalDisplayWidth(v) + pad := widths[fieldNum] - ui.DisplayWidth(v) if pad > 0 { v += strings.Repeat(" ", pad) } @@ -140,7 +140,7 @@ func (t *Table) Write(w io.Writer) error { columnWidths := make([]int, columns) for i, desc := range t.columns { for _, line := range strings.Split(desc, "\n") { - width := ui.TerminalDisplayWidth(line) + width := ui.DisplayWidth(line) if columnWidths[i] < width { columnWidths[i] = width } @@ -149,7 +149,7 @@ func (t *Table) Write(w io.Writer) error { for _, line := range lines { for i, content := range line { for _, l := range strings.Split(content, "\n") { - width := ui.TerminalDisplayWidth(l) + width := ui.DisplayWidth(l) if columnWidths[i] < width { columnWidths[i] = width } @@ -162,7 +162,7 @@ func (t *Table) Write(w io.Writer) error { for _, width := range columnWidths { totalWidth += width } - totalWidth += (columns - 1) * ui.TerminalDisplayWidth(t.CellSeparator) + totalWidth += (columns - 1) * ui.DisplayWidth(t.CellSeparator) // write header if len(t.columns) > 0 { diff --git a/internal/ui/termstatus/status.go b/internal/ui/termstatus/status.go index a5ce24205..b54ee6d80 100644 --- a/internal/ui/termstatus/status.go +++ b/internal/ui/termstatus/status.go @@ -5,11 +5,7 @@ import ( "fmt" "io" "os" - "strconv" "strings" - "unicode" - - "golang.org/x/text/width" "github.com/restic/restic/internal/terminal" "github.com/restic/restic/internal/ui" @@ -230,53 +226,12 @@ func (t *Terminal) Error(line string) { t.print(line, true) } -// Truncate s to fit in width (number of terminal cells) w. -// If w is negative, returns the empty string. -func Truncate(s string, w int) string { - if len(s) < w { - // Since the display width of a character is at most 2 - // and all of ASCII (single byte per rune) has width 1, - // no character takes more bytes to encode than its width. - return s - } - - for i := uint(0); i < uint(len(s)); { - utfsize := uint(1) // UTF-8 encoding size of first rune in s. - w-- - - if s[i] > unicode.MaxASCII { - var wide bool - if wide, utfsize = wideRune(s[i:]); wide { - w-- - } - } - - if w < 0 { - return s[:i] - } - i += utfsize - } - - return s -} - -// Guess whether the first rune in s would occupy two terminal cells -// instead of one. This cannot be determined exactly without knowing -// the terminal font, so we treat all ambiguous runes as full-width, -// i.e., two cells. -func wideRune(s string) (wide bool, utfsize uint) { - prop, size := width.LookupString(s) - kind := prop.Kind() - wide = kind != width.Neutral && kind != width.EastAsianNarrow - return wide, uint(size) -} - func sanitizeLines(lines []string, width int) []string { // Sanitize lines and truncate them if they're too long. for i, line := range lines { - line = Quote(line) + line = ui.Quote(line) if width > 0 { - line = Truncate(line, width-2) + line = ui.Truncate(line, width-2) } if i < len(lines)-1 { // Last line gets no line break. line += "\n" @@ -307,18 +262,3 @@ func (t *Terminal) SetStatus(lines []string) { case <-t.closed: } } - -// Quote lines with funny characters in them, meaning control chars, newlines, -// tabs, anything else non-printable and invalid UTF-8. -// -// This is intended to produce a string that does not mess up the terminal -// rather than produce an unambiguous quoted string. -func Quote(line string) string { - for _, r := range line { - // The replacement character usually means the input is not UTF-8. - if r == unicode.ReplacementChar || !unicode.IsPrint(r) { - return strconv.Quote(line) - } - } - return line -} diff --git a/internal/ui/termstatus/status_test.go b/internal/ui/termstatus/status_test.go index 8e5414686..b12928931 100644 --- a/internal/ui/termstatus/status_test.go +++ b/internal/ui/termstatus/status_test.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "io" - "strconv" "testing" "github.com/restic/restic/internal/terminal" @@ -58,91 +57,6 @@ func TestSetStatus(t *testing.T) { rtest.Equals(t, exp, buf.String()) } -func TestQuote(t *testing.T) { - for _, c := range []struct { - in string - needQuote bool - }{ - {"foo.bar/baz", false}, - {"föó_bàŕ-bãẑ", false}, - {" foo ", false}, - {"foo bar", false}, - {"foo\nbar", true}, - {"foo\rbar", true}, - {"foo\abar", true}, - {"\xff", true}, - {`c:\foo\bar`, false}, - // Issue #2260: terminal control characters. - {"\x1bm_red_is_beautiful", true}, - } { - if c.needQuote { - rtest.Equals(t, strconv.Quote(c.in), Quote(c.in)) - } else { - rtest.Equals(t, c.in, Quote(c.in)) - } - } -} - -func TestTruncate(t *testing.T) { - var tests = []struct { - input string - width int - output string - }{ - {"", 80, ""}, - {"", 0, ""}, - {"", -1, ""}, - {"foo", 80, "foo"}, - {"foo", 4, "foo"}, - {"foo", 3, "foo"}, - {"foo", 2, "fo"}, - {"foo", 1, "f"}, - {"foo", 0, ""}, - {"foo", -1, ""}, - {"Löwen", 4, "Löwe"}, - {"あああああ/data", 7, "あああ"}, - {"あああああ/data", 10, "あああああ"}, - {"あああああ/data", 11, "あああああ/"}, - } - - for _, test := range tests { - t.Run("", func(t *testing.T) { - out := Truncate(test.input, test.width) - if out != test.output { - t.Fatalf("wrong output for input %v, width %d: want %q, got %q", - test.input, test.width, test.output, out) - } - }) - } -} - -func benchmarkTruncate(b *testing.B, s string, w int) { - for i := 0; i < b.N; i++ { - Truncate(s, w) - } -} - -func BenchmarkTruncateASCII(b *testing.B) { - s := "This is an ASCII-only status message...\r\n" - benchmarkTruncate(b, s, len(s)-1) -} - -func BenchmarkTruncateUnicode(b *testing.B) { - s := "Hello World or Καλημέρα κόσμε or こんにちは 世界" - w := 0 - for i := 0; i < len(s); { - w++ - wide, utfsize := wideRune(s[i:]) - if wide { - w++ - } - i += int(utfsize) - } - b.ResetTimer() - - benchmarkTruncate(b, s, w-1) -} - func TestSanitizeLines(t *testing.T) { var tests = []struct { input []string