diff --git a/pkg/tableprinter/table.go b/pkg/tableprinter/table.go index df8bd52..bb38cb8 100644 --- a/pkg/tableprinter/table.go +++ b/pkg/tableprinter/table.go @@ -9,8 +9,7 @@ import ( "io" "strings" - "github.com/muesli/reflow/ansi" - "github.com/muesli/reflow/truncate" + "github.com/cli/go-gh/pkg/text" ) type fieldOption func(*tableField) @@ -72,7 +71,7 @@ func (t *ttyTablePrinter) AddField(s string, opts ...fieldOption) { rowI := len(t.rows) - 1 field := tableField{ text: s, - truncateFunc: truncateText, + truncateFunc: text.Truncate, } for _, opt := range opts { opt(&field) @@ -107,7 +106,7 @@ func (t *ttyTablePrinter) Render() error { } if col < numCols-1 { // pad value with spaces on the right - if padWidth := colWidths[col] - displayWidth(field.text); padWidth > 0 { + if padWidth := colWidths[col] - text.DisplayWidth(field.text); padWidth > 0 { truncVal += strings.Repeat(" ", padWidth) } } @@ -136,7 +135,7 @@ func (t *ttyTablePrinter) calculateColumnWidths(delimSize int) []int { for _, row := range t.rows { for col, field := range row { - w := displayWidth(field.text) + w := text.DisplayWidth(field.text) if w > maxColWidths[col] { maxColWidths[col] = w } @@ -230,18 +229,3 @@ func (t *tsvTablePrinter) EndRow() { func (t *tsvTablePrinter) Render() error { return nil } - -func truncateText(maxWidth int, s string) string { - rw := ansi.PrintableRuneWidth(s) - if rw <= maxWidth { - return s - } - if maxWidth < 5 { - return truncate.String(s, uint(maxWidth)) - } - return truncate.StringWithTail(s, uint(maxWidth), "...") -} - -func displayWidth(s string) int { - return ansi.PrintableRuneWidth(s) -} diff --git a/pkg/tableprinter/table_test.go b/pkg/tableprinter/table_test.go index 98f8406..889a1e8 100644 --- a/pkg/tableprinter/table_test.go +++ b/pkg/tableprinter/table_test.go @@ -95,95 +95,3 @@ func Test_tsvTablePrinter(t *testing.T) { t.Errorf("expected: %q, got: %q", expected, buf.String()) } } - -func Test_truncateText(t *testing.T) { - type args struct { - maxWidth int - s string - } - tests := []struct { - name string - args args - want string - }{ - { - name: "empty", - args: args{ - s: "", - maxWidth: 10, - }, - want: "", - }, - { - name: "short", - args: args{ - s: "hello", - maxWidth: 3, - }, - want: "hel", - }, - { - name: "long", - args: args{ - s: "hello world", - maxWidth: 5, - }, - want: "he...", - }, - { - name: "no truncate", - args: args{ - s: "hello world", - maxWidth: 11, - }, - want: "hello world", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := truncateText(tt.args.maxWidth, tt.args.s); got != tt.want { - t.Errorf("truncateText() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_displayWidth(t *testing.T) { - type args struct { - s string - } - tests := []struct { - name string - args args - want int - }{ - { - name: "empty", - args: args{ - s: "", - }, - want: 0, - }, - { - name: "Latin", - args: args{ - s: "hello world 123$#!", - }, - want: 18, - }, - { - name: "Asian", - args: args{ - s: "つのだ☆HIRO", - }, - want: 11, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := displayWidth(tt.args.s); got != tt.want { - t.Errorf("displayWidth() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/pkg/template/template.go b/pkg/template/template.go index dd2d6f9..4151646 100644 --- a/pkg/template/template.go +++ b/pkg/template/template.go @@ -14,9 +14,12 @@ import ( "time" "github.com/cli/go-gh/pkg/tableprinter" + "github.com/cli/go-gh/pkg/text" color "github.com/mgutz/ansi" - "github.com/muesli/reflow/ansi" - "github.com/muesli/reflow/truncate" +) + +const ( + ellipsis = "..." ) // Template is the representation of a template. @@ -148,7 +151,7 @@ func truncateFunc(maxWidth int, v interface{}) (string, error) { return "", nil } if s, ok := v.(string); ok { - return truncateText(maxWidth, s), nil + return text.Truncate(maxWidth, s), nil } return "", fmt.Errorf("invalid value; expected string, got %T", v) } @@ -166,7 +169,7 @@ func tableRowFunc(tp tableprinter.TablePrinter, fields ...interface{}) (string, if err != nil { return "", fmt.Errorf("failed to write table row: %v", err) } - tp.AddField(s, tableprinter.WithTruncate(truncateColumn)) + tp.AddField(s, tableprinter.WithTruncate(truncateMultiline)) } tp.EndRow() return "", nil @@ -207,43 +210,26 @@ func timeAgo(ago time.Duration) string { return "just now" } if ago < time.Hour { - return pluralize(int(ago.Minutes()), "minute") + " ago" + return text.Pluralize(int(ago.Minutes()), "minute") + " ago" } if ago < 24*time.Hour { - return pluralize(int(ago.Hours()), "hour") + " ago" + return text.Pluralize(int(ago.Hours()), "hour") + " ago" } if ago < 30*24*time.Hour { - return pluralize(int(ago.Hours())/24, "day") + " ago" + return text.Pluralize(int(ago.Hours())/24, "day") + " ago" } if ago < 365*24*time.Hour { - return pluralize(int(ago.Hours())/24/30, "month") + " ago" - } - return pluralize(int(ago.Hours()/24/365), "year") + " ago" -} - -func pluralize(num int, thing string) string { - if num == 1 { - return fmt.Sprintf("%d %s", num, thing) + return text.Pluralize(int(ago.Hours())/24/30, "month") + " ago" } - return fmt.Sprintf("%d %ss", num, thing) + return text.Pluralize(int(ago.Hours()/24/365), "year") + " ago" } -// TruncateColumn replaces the first new line character with an ellipsis -// and shortens a string to fit the maximum display width. -func truncateColumn(maxWidth int, s string) string { +// TruncateMultiline returns a copy of the string s that has been shortened to fit the maximum +// display width. If string s has multiple lines the first line will be shortened and all others +// removed. +func truncateMultiline(maxWidth int, s string) string { if i := strings.IndexAny(s, "\r\n"); i >= 0 { - s = s[:i] + "..." - } - return truncateText(maxWidth, s) -} - -func truncateText(maxWidth int, s string) string { - rw := ansi.PrintableRuneWidth(s) - if rw <= maxWidth { - return s - } - if maxWidth < 5 { - return truncate.String(s, uint(maxWidth)) + s = s[:i] + ellipsis } - return truncate.StringWithTail(s, uint(maxWidth), "...") + return text.Truncate(maxWidth, s) } diff --git a/pkg/template/template_test.go b/pkg/template/template_test.go index d8ace1a..0f9e5ae 100644 --- a/pkg/template/template_test.go +++ b/pkg/template/template_test.go @@ -341,3 +341,78 @@ func TestExecute(t *testing.T) { }) } } + +func TestTruncateMultiline(t *testing.T) { + type args struct { + max int + s string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "exactly minimum width", + args: args{ + max: 5, + s: "short", + }, + want: "short", + }, + { + name: "exactly minimum width with new line", + args: args{ + max: 5, + s: "short\n", + }, + want: "sh...", + }, + { + name: "less than minimum width", + args: args{ + max: 4, + s: "short", + }, + want: "shor", + }, + { + name: "less than minimum width with new line", + args: args{ + max: 4, + s: "short\n", + }, + want: "shor", + }, + { + name: "first line of multiple is short enough", + args: args{ + max: 80, + s: "short\n\nthis is a new line", + }, + want: "short...", + }, + { + name: "using Windows line endings", + args: args{ + max: 80, + s: "short\r\n\r\nthis is a new line", + }, + want: "short...", + }, + { + name: "using older MacOS line endings", + args: args{ + max: 80, + s: "short\r\rthis is a new line", + }, + want: "short...", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateMultiline(tt.args.max, tt.args.s) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/text/text.go b/pkg/text/text.go new file mode 100644 index 0000000..586d5b4 --- /dev/null +++ b/pkg/text/text.go @@ -0,0 +1,86 @@ +// Package text is a set of utility functions for text processing and outputting to the terminal. +package text + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/truncate" +) + +const ( + ellipsis = "..." + minWidthForEllipsis = len(ellipsis) + 2 +) + +var indentRE = regexp.MustCompile(`(?m)^`) + +// Indent returns a copy of the string s with indent prefixed to it, will apply indent +// to each line of the string. +func Indent(s, indent string) string { + if len(strings.TrimSpace(s)) == 0 { + return s + } + return indentRE.ReplaceAllLiteralString(s, indent) +} + +// DisplayWidth calculates what the rendered width of string s will be. +func DisplayWidth(s string) int { + return ansi.PrintableRuneWidth(s) +} + +// Truncate returns a copy of the string s that has been shortened to fit the maximum display width. +func Truncate(maxWidth int, s string) string { + w := DisplayWidth(s) + if w <= maxWidth { + return s + } + tail := "" + if maxWidth >= minWidthForEllipsis { + tail = ellipsis + } + r := truncate.StringWithTail(s, uint(maxWidth), tail) + if DisplayWidth(r) < maxWidth { + r += " " + } + return r +} + +// Pluralize returns a concatenated string with num and the plural form of thing if necessary. +func Pluralize(num int, thing string) string { + if num == 1 { + return fmt.Sprintf("%d %s", num, thing) + } + return fmt.Sprintf("%d %ss", num, thing) +} + +func fmtDuration(amount int, unit string) string { + return fmt.Sprintf("about %s ago", Pluralize(amount, unit)) +} + +// RelativeTimeAgo returns a human readable string of the time duration between a and b that is estimated +// to the nearest unit of time. +func RelativeTimeAgo(a, b time.Time) string { + ago := a.Sub(b) + + if ago < time.Minute { + return "less than a minute ago" + } + if ago < time.Hour { + return fmtDuration(int(ago.Minutes()), "minute") + } + if ago < 24*time.Hour { + return fmtDuration(int(ago.Hours()), "hour") + } + if ago < 30*24*time.Hour { + return fmtDuration(int(ago.Hours())/24, "day") + } + if ago < 365*24*time.Hour { + return fmtDuration(int(ago.Hours())/24/30, "month") + } + + return fmtDuration(int(ago.Hours()/24/365), "year") +} diff --git a/pkg/text/text_test.go b/pkg/text/text_test.go new file mode 100644 index 0000000..d8c4a1d --- /dev/null +++ b/pkg/text/text_test.go @@ -0,0 +1,306 @@ +package text + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRelativeTimeAgo(t *testing.T) { + const form = "2006-Jan-02 15:04:05" + now, _ := time.Parse(form, "2020-Nov-22 14:00:00") + cases := map[string]string{ + "2020-Nov-22 14:00:00": "less than a minute ago", + "2020-Nov-22 13:59:30": "less than a minute ago", + "2020-Nov-22 13:59:00": "about 1 minute ago", + "2020-Nov-22 13:30:00": "about 30 minutes ago", + "2020-Nov-22 13:00:00": "about 1 hour ago", + "2020-Nov-22 02:00:00": "about 12 hours ago", + "2020-Nov-21 14:00:00": "about 1 day ago", + "2020-Nov-07 14:00:00": "about 15 days ago", + "2020-Oct-24 14:00:00": "about 29 days ago", + "2020-Oct-23 14:00:00": "about 1 month ago", + "2020-Sep-23 14:00:00": "about 2 months ago", + "2019-Nov-22 14:00:00": "about 1 year ago", + "2018-Nov-22 14:00:00": "about 2 years ago", + } + for createdAt, expected := range cases { + d, err := time.Parse(form, createdAt) + assert.NoError(t, err) + relative := RelativeTimeAgo(now, d) + assert.Equal(t, expected, relative) + } +} + +func TestTruncate(t *testing.T) { + type args struct { + max int + s string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + s: "", + max: 10, + }, + want: "", + }, + { + name: "short", + args: args{ + s: "hello", + max: 3, + }, + want: "hel", + }, + { + name: "long", + args: args{ + s: "hello world", + max: 5, + }, + want: "he...", + }, + { + name: "no truncate", + args: args{ + s: "hello world", + max: 11, + }, + want: "hello world", + }, + { + name: "Short enough", + args: args{ + max: 5, + s: "short", + }, + want: "short", + }, + { + name: "Too short", + args: args{ + max: 4, + s: "short", + }, + want: "shor", + }, + { + name: "Japanese", + args: args{ + max: 11, + s: "テストテストテストテスト", + }, + want: "テストテ...", + }, + { + name: "Japanese filled", + args: args{ + max: 11, + s: "aテストテストテストテスト", + }, + want: "aテスト... ", + }, + { + name: "Chinese", + args: args{ + max: 11, + s: "幫新舉報違章工廠新增編號", + }, + want: "幫新舉報...", + }, + { + name: "Chinese filled", + args: args{ + max: 11, + s: "a幫新舉報違章工廠新增編號", + }, + want: "a幫新舉... ", + }, + { + name: "Korean", + args: args{ + max: 11, + s: "프로젝트 내의", + }, + want: "프로젝트...", + }, + { + name: "Korean filled", + args: args{ + max: 11, + s: "a프로젝트 내의", + }, + want: "a프로젝... ", + }, + { + name: "Emoji", + args: args{ + max: 11, + s: "💡💡💡💡💡💡💡💡💡💡💡💡", + }, + want: "💡💡💡💡...", + }, + { + name: "Accented characters", + args: args{ + max: 11, + s: "é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́", + }, + want: "é́́é́́é́́é́́é́́é́́é́́é́́...", + }, + { + name: "Red accented characters", + args: args{ + max: 11, + s: "\x1b[0;31mé́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́\x1b[0m", + }, + want: "\x1b[0;31mé́́é́́é́́é́́é́́é́́é́́é́́...\x1b[0m", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Truncate(tt.args.max, tt.args.s) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDisplayWidth(t *testing.T) { + tests := []struct { + name string + text string + want int + }{ + { + name: "check mark", + text: `✓`, + want: 1, + }, + { + name: "bullet icon", + text: `•`, + want: 1, + }, + { + name: "middle dot", + text: `·`, + want: 1, + }, + { + name: "ellipsis", + text: `…`, + want: 1, + }, + { + name: "right arrow", + text: `→`, + want: 1, + }, + { + name: "smart double quotes", + text: `“”`, + want: 2, + }, + { + name: "smart single quotes", + text: `‘’`, + want: 2, + }, + { + name: "em dash", + text: `—`, + want: 1, + }, + { + name: "en dash", + text: `–`, + want: 1, + }, + { + name: "emoji", + text: `👍`, + want: 2, + }, + { + name: "accent character", + text: `é́́`, + want: 1, + }, + { + name: "color codes", + text: "\x1b[0;31mred\x1b[0m", + want: 3, + }, + { + name: "empty", + text: "", + want: 0, + }, + { + name: "Latin", + text: "hello world 123$#!", + want: 18, + }, + { + name: "Asian", + text: "つのだ☆HIRO", + want: 11, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DisplayWidth(tt.text) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIndent(t *testing.T) { + type args struct { + s string + indent string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + s: "", + indent: "--", + }, + want: "", + }, + { + name: "blank", + args: args{ + s: "\n", + indent: "--", + }, + want: "\n", + }, + { + name: "indent", + args: args{ + s: "one\ntwo\nthree", + indent: "--", + }, + want: "--one\n--two\n--three", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Indent(tt.args.s, tt.args.indent) + assert.Equal(t, tt.want, got) + }) + } +}