From 6e59115b7a0fb7f130bbb4a92a391caebcdefe9a Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 23 Aug 2022 12:43:09 +0300 Subject: [PATCH 1/2] Extract text package from gh --- go.mod | 1 + go.sum | 2 + pkg/tableprinter/table.go | 24 +- pkg/tableprinter/table_test.go | 92 ------- pkg/template/template.go | 44 +-- pkg/text/text.go | 178 ++++++++++++ pkg/text/text_test.go | 487 +++++++++++++++++++++++++++++++++ 7 files changed, 680 insertions(+), 148 deletions(-) create mode 100644 pkg/text/text.go create mode 100644 pkg/text/text_test.go diff --git a/go.mod b/go.mod index f84309d..d088b79 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 + golang.org/x/text v0.3.7 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 9bd8d7c..c7e6fde 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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..1f75138 100644 --- a/pkg/template/template.go +++ b/pkg/template/template.go @@ -14,9 +14,8 @@ 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" ) // Template is the representation of a template. @@ -148,7 +147,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 +165,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(text.TruncateMultiline)) } tp.EndRow() return "", nil @@ -207,43 +206,16 @@ 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 text.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 fmt.Sprintf("%d %ss", num, thing) -} - -// 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 { - 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)) - } - return truncate.StringWithTail(s, uint(maxWidth), "...") + return text.Pluralize(int(ago.Hours()/24/365), "year") + " ago" } diff --git a/pkg/text/text.go b/pkg/text/text.go new file mode 100644 index 0000000..f68d093 --- /dev/null +++ b/pkg/text/text.go @@ -0,0 +1,178 @@ +// Package text is a set of utility functions for text processing and outputting to the terminal. +package text + +import ( + "fmt" + "net/url" + "regexp" + "strings" + "time" + "unicode" + + "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/truncate" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + ellipsis = "..." + minWidthForEllipsis = len(ellipsis) + 2 +) + +var indentRE = regexp.MustCompile(`(?m)^`) +var whitespaceRE = regexp.MustCompile(`\s+`) + +// Indent returns a copy of the string s with indent prefixed to it, will indent each line +// in the string. +func Indent(s, indent string) string { + if len(strings.TrimSpace(s)) == 0 { + return s + } + return indentRE.ReplaceAllLiteralString(s, indent) +} + +// CamelToKebab returns a copy of the string s that is converted from camel case form to '-' separated form. +func CamelToKebab(s string) string { + var output []rune + var segment []rune + for _, r := range s { + if !unicode.IsLower(r) && string(r) != "-" && !unicode.IsNumber(r) { + output = addSegment(output, segment) + segment = nil + } + segment = append(segment, unicode.ToLower(r)) + } + output = addSegment(output, segment) + return string(output) +} + +func addSegment(inrune, segment []rune) []rune { + if len(segment) == 0 { + return inrune + } + if len(inrune) != 0 { + inrune = append(inrune, '-') + } + inrune = append(inrune, segment...) + return inrune +} + +// Title returns a copy of the string s with all Unicode letters that begin words mapped to their Unicode title case. +func Title(s string) string { + c := cases.Title(language.English) + return c.String(s) +} + +// RemoveExcessiveWhitespace returns a copy of the string s with excessive whitespace removed. +func RemoveExcessiveWhitespace(s string) string { + return whitespaceRE.ReplaceAllString(strings.TrimSpace(s), " ") +} + +// 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 +} + +// 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] + ellipsis + } + return Truncate(maxWidth, s) +} + +// 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)) +} + +// FuzzyAgo returns a human readable string of the time duration between a and b that is estimated +// to the nearest unit of time. +func FuzzyAgo(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") +} + +// FuzzyAgoAbbr is an abbreviated version of FuzzyAgo. It returns a human readable string of the +// time duration between a and b that is estimated to the nearest unit of time. +func FuzzyAgoAbbr(a, b time.Time) string { + ago := a.Sub(b) + + if ago < time.Hour { + return fmt.Sprintf("%d%s", int(ago.Minutes()), "m") + } + if ago < 24*time.Hour { + return fmt.Sprintf("%d%s", int(ago.Hours()), "h") + } + if ago < 30*24*time.Hour { + return fmt.Sprintf("%d%s", int(ago.Hours())/24, "d") + } + + return b.Format("Jan _2, 2006") +} + +// Humanize returns a copy of the string s that replaces all instance of '-' and '_' with spaces. +func Humanize(s string) string { + replace := "_-" + h := func(r rune) rune { + if strings.ContainsRune(replace, r) { + return ' ' + } + return r + } + return strings.Map(h, s) +} + +// DisplayURL returns a copy of the string urlStr removing everything except the hostname and path. +// If there is an error parsing urlStr then urlStr is returned without modification. +func DisplayURL(urlStr string) string { + u, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + return u.Hostname() + u.Path +} diff --git a/pkg/text/text_test.go b/pkg/text/text_test.go new file mode 100644 index 0000000..b5842f6 --- /dev/null +++ b/pkg/text/text_test.go @@ -0,0 +1,487 @@ +package text + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestFuzzyAgo(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) + fuzzy := FuzzyAgo(now, d) + assert.Equal(t, expected, fuzzy) + } +} + +func TestFuzzyAgoAbbr(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": "0m", + "2020-Nov-22 13:59:00": "1m", + "2020-Nov-22 13:30:00": "30m", + "2020-Nov-22 13:00:00": "1h", + "2020-Nov-22 02:00:00": "12h", + "2020-Nov-21 14:00:00": "1d", + "2020-Nov-07 14:00:00": "15d", + "2020-Oct-24 14:00:00": "29d", + "2020-Oct-23 14:00:00": "Oct 23, 2020", + "2019-Nov-22 14:00:00": "Nov 22, 2019", + } + for createdAt, expected := range cases { + d, err := time.Parse(form, createdAt) + assert.NoError(t, err) + fuzzy := FuzzyAgoAbbr(now, d) + assert.Equal(t, expected, fuzzy) + } +} + +func ExampleTruncate() { + +} + +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 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) + }) + } +} + +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 TestRemoveExcessiveWhitespace(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "nothing to remove", + input: "one two three", + want: "one two three", + }, + { + name: "whitespace b-gone", + input: "\n one\n\t two three\r\n ", + want: "one two three", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RemoveExcessiveWhitespace(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCamelToKebab(t *testing.T) { + tests := []struct { + name string + in string + out string + }{ + { + name: "single lowercase word", + in: "test", + out: "test", + }, + { + name: "multiple mixed words", + in: "testTestTest", + out: "test-test-test", + }, + { + name: "multiple uppercase words", + in: "TestTest", + out: "test-test", + }, + { + name: "multiple lowercase words", + in: "testtest", + out: "testtest", + }, + { + name: "multiple mixed words with number", + in: "test2Test", + out: "test2-test", + }, + { + name: "multiple lowercase words with number", + in: "test2test", + out: "test2test", + }, + { + name: "multiple lowercase words with dash", + in: "test-test", + out: "test-test", + }, + { + name: "multiple uppercase words with dash", + in: "Test-Test", + out: "test--test", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, CamelToKebab(tt.in)) + }) + } +} + +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) + }) + } +} From 6748f09832cdae7649156bc6f03df14f2e26859e Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Tue, 13 Sep 2022 12:53:38 +0400 Subject: [PATCH 2/2] Address PR Comments --- go.mod | 1 - go.sum | 2 - pkg/template/template.go | 16 ++- pkg/template/template_test.go | 75 ++++++++++++++ pkg/text/text.go | 100 +----------------- pkg/text/text_test.go | 187 +--------------------------------- 6 files changed, 97 insertions(+), 284 deletions(-) diff --git a/go.mod b/go.mod index d088b79..f84309d 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 - golang.org/x/text v0.3.7 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index c7e6fde..9bd8d7c 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,6 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/template/template.go b/pkg/template/template.go index 1f75138..4151646 100644 --- a/pkg/template/template.go +++ b/pkg/template/template.go @@ -18,6 +18,10 @@ import ( color "github.com/mgutz/ansi" ) +const ( + ellipsis = "..." +) + // Template is the representation of a template. type Template struct { colorEnabled bool @@ -165,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(text.TruncateMultiline)) + tp.AddField(s, tableprinter.WithTruncate(truncateMultiline)) } tp.EndRow() return "", nil @@ -219,3 +223,13 @@ func timeAgo(ago time.Duration) string { } return text.Pluralize(int(ago.Hours()/24/365), "year") + " ago" } + +// 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] + ellipsis + } + 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 index f68d093..586d5b4 100644 --- a/pkg/text/text.go +++ b/pkg/text/text.go @@ -3,16 +3,12 @@ package text import ( "fmt" - "net/url" "regexp" "strings" "time" - "unicode" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) const ( @@ -21,10 +17,9 @@ const ( ) var indentRE = regexp.MustCompile(`(?m)^`) -var whitespaceRE = regexp.MustCompile(`\s+`) -// Indent returns a copy of the string s with indent prefixed to it, will indent each line -// in the string. +// 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 @@ -32,43 +27,6 @@ func Indent(s, indent string) string { return indentRE.ReplaceAllLiteralString(s, indent) } -// CamelToKebab returns a copy of the string s that is converted from camel case form to '-' separated form. -func CamelToKebab(s string) string { - var output []rune - var segment []rune - for _, r := range s { - if !unicode.IsLower(r) && string(r) != "-" && !unicode.IsNumber(r) { - output = addSegment(output, segment) - segment = nil - } - segment = append(segment, unicode.ToLower(r)) - } - output = addSegment(output, segment) - return string(output) -} - -func addSegment(inrune, segment []rune) []rune { - if len(segment) == 0 { - return inrune - } - if len(inrune) != 0 { - inrune = append(inrune, '-') - } - inrune = append(inrune, segment...) - return inrune -} - -// Title returns a copy of the string s with all Unicode letters that begin words mapped to their Unicode title case. -func Title(s string) string { - c := cases.Title(language.English) - return c.String(s) -} - -// RemoveExcessiveWhitespace returns a copy of the string s with excessive whitespace removed. -func RemoveExcessiveWhitespace(s string) string { - return whitespaceRE.ReplaceAllString(strings.TrimSpace(s), " ") -} - // DisplayWidth calculates what the rendered width of string s will be. func DisplayWidth(s string) int { return ansi.PrintableRuneWidth(s) @@ -91,16 +49,6 @@ func Truncate(maxWidth int, s string) string { return r } -// 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] + ellipsis - } - return Truncate(maxWidth, s) -} - // 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 { @@ -113,9 +61,9 @@ func fmtDuration(amount int, unit string) string { return fmt.Sprintf("about %s ago", Pluralize(amount, unit)) } -// FuzzyAgo returns a human readable string of the time duration between a and b that is estimated +// RelativeTimeAgo returns a human readable string of the time duration between a and b that is estimated // to the nearest unit of time. -func FuzzyAgo(a, b time.Time) string { +func RelativeTimeAgo(a, b time.Time) string { ago := a.Sub(b) if ago < time.Minute { @@ -136,43 +84,3 @@ func FuzzyAgo(a, b time.Time) string { return fmtDuration(int(ago.Hours()/24/365), "year") } - -// FuzzyAgoAbbr is an abbreviated version of FuzzyAgo. It returns a human readable string of the -// time duration between a and b that is estimated to the nearest unit of time. -func FuzzyAgoAbbr(a, b time.Time) string { - ago := a.Sub(b) - - if ago < time.Hour { - return fmt.Sprintf("%d%s", int(ago.Minutes()), "m") - } - if ago < 24*time.Hour { - return fmt.Sprintf("%d%s", int(ago.Hours()), "h") - } - if ago < 30*24*time.Hour { - return fmt.Sprintf("%d%s", int(ago.Hours())/24, "d") - } - - return b.Format("Jan _2, 2006") -} - -// Humanize returns a copy of the string s that replaces all instance of '-' and '_' with spaces. -func Humanize(s string) string { - replace := "_-" - h := func(r rune) rune { - if strings.ContainsRune(replace, r) { - return ' ' - } - return r - } - return strings.Map(h, s) -} - -// DisplayURL returns a copy of the string urlStr removing everything except the hostname and path. -// If there is an error parsing urlStr then urlStr is returned without modification. -func DisplayURL(urlStr string) string { - u, err := url.Parse(urlStr) - if err != nil { - return urlStr - } - return u.Hostname() + u.Path -} diff --git a/pkg/text/text_test.go b/pkg/text/text_test.go index b5842f6..d8c4a1d 100644 --- a/pkg/text/text_test.go +++ b/pkg/text/text_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestFuzzyAgo(t *testing.T) { +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{ @@ -28,38 +28,11 @@ func TestFuzzyAgo(t *testing.T) { for createdAt, expected := range cases { d, err := time.Parse(form, createdAt) assert.NoError(t, err) - fuzzy := FuzzyAgo(now, d) - assert.Equal(t, expected, fuzzy) + relative := RelativeTimeAgo(now, d) + assert.Equal(t, expected, relative) } } -func TestFuzzyAgoAbbr(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": "0m", - "2020-Nov-22 13:59:00": "1m", - "2020-Nov-22 13:30:00": "30m", - "2020-Nov-22 13:00:00": "1h", - "2020-Nov-22 02:00:00": "12h", - "2020-Nov-21 14:00:00": "1d", - "2020-Nov-07 14:00:00": "15d", - "2020-Oct-24 14:00:00": "29d", - "2020-Oct-23 14:00:00": "Oct 23, 2020", - "2019-Nov-22 14:00:00": "Nov 22, 2019", - } - for createdAt, expected := range cases { - d, err := time.Parse(form, createdAt) - assert.NoError(t, err) - fuzzy := FuzzyAgoAbbr(now, d) - assert.Equal(t, expected, fuzzy) - } -} - -func ExampleTruncate() { - -} - func TestTruncate(t *testing.T) { type args struct { max int @@ -199,81 +172,6 @@ func TestTruncate(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) - }) - } -} - func TestDisplayWidth(t *testing.T) { tests := []struct { name string @@ -364,85 +262,6 @@ func TestDisplayWidth(t *testing.T) { } } -func TestRemoveExcessiveWhitespace(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "nothing to remove", - input: "one two three", - want: "one two three", - }, - { - name: "whitespace b-gone", - input: "\n one\n\t two three\r\n ", - want: "one two three", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := RemoveExcessiveWhitespace(tt.input) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestCamelToKebab(t *testing.T) { - tests := []struct { - name string - in string - out string - }{ - { - name: "single lowercase word", - in: "test", - out: "test", - }, - { - name: "multiple mixed words", - in: "testTestTest", - out: "test-test-test", - }, - { - name: "multiple uppercase words", - in: "TestTest", - out: "test-test", - }, - { - name: "multiple lowercase words", - in: "testtest", - out: "testtest", - }, - { - name: "multiple mixed words with number", - in: "test2Test", - out: "test2-test", - }, - { - name: "multiple lowercase words with number", - in: "test2test", - out: "test2test", - }, - { - name: "multiple lowercase words with dash", - in: "test-test", - out: "test-test", - }, - { - name: "multiple uppercase words with dash", - in: "Test-Test", - out: "test--test", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.out, CamelToKebab(tt.in)) - }) - } -} - func TestIndent(t *testing.T) { type args struct { s string