Add helper template functions for rendering tables (#3519)

Co-authored-by: Mislav Marohnić <mislav@github.com>
This commit is contained in:
Heath Stewart 2021-08-23 12:00:25 -07:00 committed by GitHub
parent a53ea0c655
commit e2973453b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 517 additions and 65 deletions

View file

@ -294,6 +294,8 @@ func apiRun(opts *ApiOptions) error {
host = opts.Hostname
}
template := export.NewTemplate(opts.IO, opts.Template)
hasNextPage := true
for hasNextPage {
resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
@ -301,7 +303,7 @@ func apiRun(opts *ApiOptions) error {
return err
}
endCursor, err := processResponse(resp, opts, headersOutputStream)
endCursor, err := processResponse(resp, opts, headersOutputStream, &template)
if err != nil {
return err
}
@ -324,10 +326,10 @@ func apiRun(opts *ApiOptions) error {
}
}
return nil
return template.End()
}
func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer) (endCursor string, err error) {
func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream io.Writer, template *export.Template) (endCursor string, err error) {
if opts.ShowResponseHeaders {
fmt.Fprintln(headersOutputStream, resp.Proto, resp.Status)
printHeaders(headersOutputStream, resp.Header, opts.IO.ColorEnabled())
@ -365,7 +367,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
}
} else if opts.Template != "" {
// TODO: reuse parsed template across pagination invocations
err = export.ExecuteTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled())
err = template.Execute(responseBody)
if err != nil {
return
}

View file

@ -17,6 +17,7 @@ import (
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/export"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
@ -671,6 +672,101 @@ func Test_apiRun_paginationGraphQL(t *testing.T) {
assert.Equal(t, "PAGE1_END", endCursor)
}
func Test_apiRun_paginated_template(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(true)
requestCount := 0
responses := []*http.Response{
{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{`application/json`}},
Body: ioutil.NopCloser(bytes.NewBufferString(`{
"data": {
"nodes": [
{
"page": 1,
"caption": "page one"
}
],
"pageInfo": {
"endCursor": "PAGE1_END",
"hasNextPage": true
}
}
}`)),
},
{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{`application/json`}},
Body: ioutil.NopCloser(bytes.NewBufferString(`{
"data": {
"nodes": [
{
"page": 20,
"caption": "page twenty"
}
],
"pageInfo": {
"endCursor": "PAGE20_END",
"hasNextPage": false
}
}
}`)),
},
}
options := ApiOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
resp := responses[requestCount]
resp.Request = req
requestCount++
return resp, nil
}
return &http.Client{Transport: tr}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
RequestMethod: "POST",
RequestPath: "graphql",
Paginate: true,
// test that templates executed per page properly render a table.
Template: `{{range .data.nodes}}{{tablerow .page .caption}}{{end}}`,
}
err := apiRun(&options)
require.NoError(t, err)
assert.Equal(t, heredoc.Doc(`
1 page one
20 page twenty
`), stdout.String(), "stdout")
assert.Equal(t, "", stderr.String(), "stderr")
var requestData struct {
Variables map[string]interface{}
}
bb, err := ioutil.ReadAll(responses[0].Request.Body)
require.NoError(t, err)
err = json.Unmarshal(bb, &requestData)
require.NoError(t, err)
_, hasCursor := requestData.Variables["endCursor"].(string)
assert.Equal(t, false, hasCursor)
bb, err = ioutil.ReadAll(responses[1].Request.Body)
require.NoError(t, err)
err = json.Unmarshal(bb, &requestData)
require.NoError(t, err)
endCursor, hasCursor := requestData.Variables["endCursor"].(string)
assert.Equal(t, true, hasCursor)
assert.Equal(t, "PAGE1_END", endCursor)
}
func Test_apiRun_inputFile(t *testing.T) {
tests := []struct {
name string
@ -1167,10 +1263,15 @@ func Test_processResponse_template(t *testing.T) {
]`)),
}
_, err := processResponse(&resp, &ApiOptions{
opts := ApiOptions{
IO: io,
Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`,
}, ioutil.Discard)
}
template := export.NewTemplate(io, opts.Template)
_, err := processResponse(&resp, &opts, ioutil.Discard, &template)
require.NoError(t, err)
err = template.End()
require.NoError(t, err)
assert.Equal(t, heredoc.Doc(`

View file

@ -163,7 +163,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.Issues, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, listResult.Issues)
}
if isTerminal {

View file

@ -100,7 +100,7 @@ func statusRun(opts *StatusOptions) error {
"assigned": issuePayload.Assigned.Issues,
"mentioned": issuePayload.Mentioned.Issues,
}
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, data)
}
out := opts.IO.Out

View file

@ -112,7 +112,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, issue, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, issue)
}
if opts.IO.IsStdoutTTY() {

View file

@ -163,7 +163,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.PullRequests, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, listResult.PullRequests)
}
if opts.IO.IsStdoutTTY() {

View file

@ -119,7 +119,7 @@ func statusRun(opts *StatusOptions) error {
if prPayload.CurrentPR != nil {
data["currentBranch"] = prPayload.CurrentPR
}
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, data)
}
out := opts.IO.Out

View file

@ -118,7 +118,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, pr, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, pr)
}
if connectedToTerminal {

View file

@ -109,7 +109,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, release, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, release)
}
if opts.IO.IsStdoutTTY() {

View file

@ -141,7 +141,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.Repositories, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, listResult.Repositories)
}
cs := opts.IO.ColorScheme()

View file

@ -138,7 +138,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, repo, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO, repo)
}
fullName := ghrepo.FullName(toView)

View file

@ -3,7 +3,6 @@ package view
import (
"bytes"
"fmt"
"io"
"net/http"
"testing"
@ -669,9 +668,9 @@ func (e *testExporter) Fields() []string {
return e.fields
}
func (e *testExporter) Write(w io.Writer, data interface{}, colorize bool) error {
func (e *testExporter) Write(io *iostreams.IOStreams, data interface{}) error {
r := data.(*api.Repository)
fmt.Fprintf(w, "name: %s\n", r.Name)
fmt.Fprintf(w, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
fmt.Fprintf(io.Out, "name: %s\n", r.Name)
fmt.Fprintf(io.Out, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
return nil
}

View file

@ -95,12 +95,30 @@ var HelpTopics = map[string]map[string]string{
For the syntax of Go templates, see: https://golang.org/pkg/text/template/
The following functions are available in templates:
- %[1]scolor <style>, <input>%[1]s: colorize input using https://github.com/mgutz/ansi
- %[1]sautocolor%[1]s: like %[1]scolor%[1]s, but only emits color to terminals
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
- %[1]scolor <style> <input>%[1]s: colorize input using https://github.com/mgutz/ansi
- %[1]sjoin <sep> <list>%[1]s: joins values in the list using a separator
- %[1]spluck <field> <list>%[1]s: collects values of a field from all items in the input
- %[1]stablerow <fields>...%[1]s: aligns fields in output vertically as a table
- %[1]stablerender%[1]s: renders fields added by tablerow in place
- %[1]stimeago <time>%[1]s: renders a timestamp as relative to now
- %[1]stimefmt <format> <time>%[1]s: formats a timestamp using Go's Time.Format function
- %[1]struncate <length> <input>%[1]s: ensures input fits within length
EXAMPLES
# format issues as table
$ gh issue list --json number,title --template \
'{{range .}}{{tablerow (printf "#%%v" .number | autocolor "green") .title}}{{end}}'
# format a pull request using multiple tables with headers
$ gh pr view 3519 --json number,title,body,reviews,assignees --template \
'{{printf "#%%v" .number}} {{.title}}
{{.body}}
{{tablerow "ASSIGNEE" "NAME"}}{{range .assignees}}{{tablerow .login .name}}{{end}}{{tablerender}}
{{tablerow "REVIEWER" "STATE" "COMMENT"}}{{range .reviews}}{{tablerow .author.login .state .body}}{{end}}
'
`, "`"),
},
}

View file

@ -11,6 +11,7 @@ import (
"strings"
"github.com/cli/cli/pkg/export"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/jsoncolor"
"github.com/cli/cli/pkg/set"
"github.com/spf13/cobra"
@ -107,7 +108,7 @@ func checkJSONFlags(cmd *cobra.Command) (*exportFormat, error) {
type Exporter interface {
Fields() []string
Write(w io.Writer, data interface{}, colorEnabled bool) error
Write(io *iostreams.IOStreams, data interface{}) error
}
type exportFormat struct {
@ -123,7 +124,7 @@ func (e *exportFormat) Fields() []string {
// Write serializes data into JSON output written to w. If the object passed as data implements exportable,
// or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain
// raw data for serialization.
func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error {
func (e *exportFormat) Write(ios *iostreams.IOStreams, data interface{}) error {
buf := bytes.Buffer{}
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
@ -131,11 +132,12 @@ func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) e
return err
}
w := ios.Out
if e.filter != "" {
return export.FilterJSON(w, &buf, e.filter)
} else if e.template != "" {
return export.ExecuteTemplate(w, &buf, e.template, colorEnabled)
} else if colorEnabled {
return export.ExecuteTemplate(ios, &buf, e.template)
} else if ios.ColorEnabled() {
return jsoncolor.Write(w, &buf, " ")
}

View file

@ -6,6 +6,7 @@ import (
"io/ioutil"
"testing"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -118,8 +119,7 @@ func TestAddJSONFlags(t *testing.T) {
func Test_exportFormat_Write(t *testing.T) {
type args struct {
data interface{}
colorEnabled bool
data interface{}
}
tests := []struct {
name string
@ -132,8 +132,7 @@ func Test_exportFormat_Write(t *testing.T) {
name: "regular JSON output",
exporter: exportFormat{},
args: args{
data: map[string]string{"name": "hubot"},
colorEnabled: false,
data: map[string]string{"name": "hubot"},
},
wantW: "{\"name\":\"hubot\"}\n",
wantErr: false,
@ -142,8 +141,7 @@ func Test_exportFormat_Write(t *testing.T) {
name: "call ExportData",
exporter: exportFormat{fields: []string{"field1", "field2"}},
args: args{
data: &exportableItem{"item1"},
colorEnabled: false,
data: &exportableItem{"item1"},
},
wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n",
wantErr: false,
@ -156,7 +154,6 @@ func Test_exportFormat_Write(t *testing.T) {
"s1": []exportableItem{{"i1"}, {"i2"}},
"s2": []exportableItem{{"i3"}},
},
colorEnabled: false,
},
wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n",
wantErr: false,
@ -165,8 +162,7 @@ func Test_exportFormat_Write(t *testing.T) {
name: "with jq filter",
exporter: exportFormat{filter: ".name"},
args: args{
data: map[string]string{"name": "hubot"},
colorEnabled: false,
data: map[string]string{"name": "hubot"},
},
wantW: "hubot\n",
wantErr: false,
@ -175,8 +171,7 @@ func Test_exportFormat_Write(t *testing.T) {
name: "with Go template",
exporter: exportFormat{template: "{{.name}}"},
args: args{
data: map[string]string{"name": "hubot"},
colorEnabled: false,
data: map[string]string{"name": "hubot"},
},
wantW: "hubot",
wantErr: false,
@ -185,7 +180,10 @@ func Test_exportFormat_Write(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
if err := tt.exporter.Write(w, tt.args.data, tt.args.colorEnabled); (err != nil) != tt.wantErr {
io := &iostreams.IOStreams{
Out: w,
}
if err := tt.exporter.Write(io, tt.args.data); (err != nil) != tt.wantErr {
t.Errorf("exportFormat.Write() error = %v, wantErr %v", err, tt.wantErr)
return
}

View file

@ -11,16 +11,32 @@ import (
"text/template"
"time"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
"github.com/mgutz/ansi"
)
func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
type Template struct {
io *iostreams.IOStreams
tablePrinter utils.TablePrinter
template *template.Template
templateStr string
}
func NewTemplate(io *iostreams.IOStreams, template string) Template {
return Template{
io: io,
templateStr: template,
}
}
func (t *Template) parseTemplate(tpl string) (*template.Template, error) {
now := time.Now()
templateFuncs := map[string]interface{}{
"color": templateColor,
"autocolor": templateColor,
"color": t.color,
"autocolor": t.color,
"timefmt": func(format, input string) (string, error) {
t, err := time.Parse(time.RFC3339, input)
@ -37,11 +53,14 @@ func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
return timeAgo(now.Sub(t)), nil
},
"pluck": templatePluck,
"join": templateJoin,
"pluck": templatePluck,
"join": templateJoin,
"tablerow": t.tableRow,
"tablerender": t.tableRender,
"truncate": text.Truncate,
}
if !colorEnabled {
if !t.io.ColorEnabled() {
templateFuncs["autocolor"] = func(colorName string, input interface{}) (string, error) {
return jsonScalarToString(input)
}
@ -50,10 +69,16 @@ func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
return template.New("").Funcs(templateFuncs).Parse(tpl)
}
func ExecuteTemplate(w io.Writer, input io.Reader, templateStr string, colorEnabled bool) error {
t, err := parseTemplate(templateStr, colorEnabled)
if err != nil {
return err
func (t *Template) Execute(input io.Reader) error {
w := t.io.Out
if t.template == nil {
template, err := t.parseTemplate(t.templateStr)
if err != nil {
return err
}
t.template = template
}
jsonData, err := ioutil.ReadAll(input)
@ -66,7 +91,15 @@ func ExecuteTemplate(w io.Writer, input io.Reader, templateStr string, colorEnab
return err
}
return t.Execute(w, data)
return t.template.Execute(w, data)
}
func ExecuteTemplate(io *iostreams.IOStreams, input io.Reader, template string) error {
t := NewTemplate(io, template)
if err := t.Execute(input); err != nil {
return err
}
return t.End()
}
func jsonScalarToString(input interface{}) (string, error) {
@ -88,7 +121,7 @@ func jsonScalarToString(input interface{}) (string, error) {
}
}
func templateColor(colorName string, input interface{}) (string, error) {
func (t *Template) color(colorName string, input interface{}) (string, error) {
text, err := jsonScalarToString(input)
if err != nil {
return "", err
@ -117,6 +150,40 @@ func templateJoin(sep string, input []interface{}) (string, error) {
return strings.Join(results, sep), nil
}
func (t *Template) tableRow(fields ...interface{}) (string, error) {
if t.tablePrinter == nil {
t.tablePrinter = utils.NewTablePrinterWithOptions(t.io, utils.TablePrinterOptions{IsTTY: true})
}
for _, e := range fields {
s, err := jsonScalarToString(e)
if err != nil {
return "", fmt.Errorf("failed to write table row: %v", err)
}
t.tablePrinter.AddField(s, text.TruncateColumn, nil)
}
t.tablePrinter.EndRow()
return "", nil
}
func (t *Template) tableRender() (string, error) {
if t.tablePrinter != nil {
err := t.tablePrinter.Render()
t.tablePrinter = nil
if err != nil {
return "", fmt.Errorf("failed to render table: %v", err)
}
}
return "", nil
}
func (t *Template) End() error {
// Finalize any template actions.
if _, err := t.tableRender(); err != nil {
return err
}
return nil
}
func timeAgo(ago time.Duration) string {
if ago < time.Minute {
return "just now"

View file

@ -1,7 +1,6 @@
package export
import (
"bytes"
"fmt"
"io"
"strings"
@ -9,6 +8,7 @@ import (
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/iostreams"
)
func Test_jsonScalarToString(t *testing.T) {
@ -85,7 +85,6 @@ func Test_executeTemplate(t *testing.T) {
args: args{
json: strings.NewReader(`{}`),
template: `{{color "blue+h" "songs are like tattoos"}}`,
colorize: false,
},
wantW: "\x1b[0;94msongs are like tattoos\x1b[0m",
},
@ -103,7 +102,6 @@ func Test_executeTemplate(t *testing.T) {
args: args{
json: strings.NewReader(`{}`),
template: `{{autocolor "red" "go"}}`,
colorize: false,
},
wantW: "go",
},
@ -112,7 +110,6 @@ func Test_executeTemplate(t *testing.T) {
args: args{
json: strings.NewReader(`{"created_at":"2008-02-25T20:18:33Z"}`),
template: `{{.created_at | timefmt "Mon Jan 2, 2006"}}`,
colorize: false,
},
wantW: "Mon Feb 25, 2008",
},
@ -121,7 +118,6 @@ func Test_executeTemplate(t *testing.T) {
args: args{
json: strings.NewReader(fmt.Sprintf(`{"created_at":"%s"}`, time.Now().Add(-5*time.Minute).Format(time.RFC3339))),
template: `{{.created_at | timeago}}`,
colorize: false,
},
wantW: "5 minutes ago",
},
@ -134,7 +130,6 @@ func Test_executeTemplate(t *testing.T) {
{"name": "chore"}
]`)),
template: `{{range(pluck "name" .)}}{{. | printf "%s\n"}}{{end}}`,
colorize: false,
},
wantW: "bug\nfeature request\nchore\n",
},
@ -143,15 +138,137 @@ func Test_executeTemplate(t *testing.T) {
args: args{
json: strings.NewReader(`[ "bug", "feature request", "chore" ]`),
template: `{{join "\t" .}}`,
colorize: false,
},
wantW: "bug\tfeature request\tchore",
},
{
name: "table",
args: args{
json: strings.NewReader(heredoc.Doc(`[
{"number": 1, "title": "One"},
{"number": 20, "title": "Twenty"},
{"number": 3000, "title": "Three thousand"}
]`)),
template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`,
},
wantW: heredoc.Doc(`#1 One
#20 Twenty
#3000 Three thousand
`),
},
{
name: "table with multiline text",
args: args{
json: strings.NewReader(heredoc.Doc(`[
{"number": 1, "title": "One\ranother line of text"},
{"number": 20, "title": "Twenty\nanother line of text"},
{"number": 3000, "title": "Three thousand\r\nanother line of text"}
]`)),
template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`,
},
wantW: heredoc.Doc(`#1 One...
#20 Twenty...
#3000 Three thousand...
`),
},
{
name: "table with mixed value types",
args: args{
json: strings.NewReader(heredoc.Doc(`[
{"number": 1, "title": null, "float": false},
{"number": 20.1, "title": "Twenty-ish", "float": true},
{"number": 3000, "title": "Three thousand", "float": false}
]`)),
template: `{{range .}}{{tablerow .number .title .float}}{{end}}`,
},
wantW: heredoc.Doc(`1 false
20.10 Twenty-ish true
3000 Three thousand false
`),
},
{
name: "table with color",
args: args{
json: strings.NewReader(heredoc.Doc(`[
{"number": 1, "title": "One"}
]`)),
template: `{{range .}}{{tablerow (.number | color "green") .title}}{{end}}`,
},
wantW: "\x1b[0;32m1\x1b[0m One\n",
},
{
name: "table with header and footer",
args: args{
json: strings.NewReader(heredoc.Doc(`[
{"number": 1, "title": "One"},
{"number": 2, "title": "Two"}
]`)),
template: heredoc.Doc(`HEADER
{{range .}}{{tablerow .number .title}}{{end}}FOOTER
`),
},
wantW: heredoc.Doc(`HEADER
FOOTER
1 One
2 Two
`),
},
{
name: "table with header and footer using endtable",
args: args{
json: strings.NewReader(heredoc.Doc(`[
{"number": 1, "title": "One"},
{"number": 2, "title": "Two"}
]`)),
template: heredoc.Doc(`HEADER
{{range .}}{{tablerow .number .title}}{{end}}{{tablerender}}FOOTER
`),
},
wantW: heredoc.Doc(`HEADER
1 One
2 Two
FOOTER
`),
},
{
name: "multiple tables with different columns",
args: args{
json: strings.NewReader(heredoc.Doc(`{
"issues": [
{"number": 1, "title": "One"},
{"number": 2, "title": "Two"}
],
"prs": [
{"number": 3, "title": "Three", "reviewDecision": "REVIEW_REQUESTED"},
{"number": 4, "title": "Four", "reviewDecision": "CHANGES_REQUESTED"}
]
}`)),
template: heredoc.Doc(`{{tablerow "ISSUE" "TITLE"}}{{range .issues}}{{tablerow .number .title}}{{end}}{{tablerender}}
{{tablerow "PR" "TITLE" "DECISION"}}{{range .prs}}{{tablerow .number .title .reviewDecision}}{{end}}`),
},
wantW: heredoc.Docf(`ISSUE TITLE
1 One
2 Two
PR TITLE DECISION
3 Three REVIEW_REQUESTED
4 Four CHANGES_REQUESTED
`),
},
{
name: "truncate",
args: args{
json: strings.NewReader(`{"title": "This is a long title"}`),
template: `{{truncate 13 .title}}`,
},
wantW: "This is a ...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
if err := ExecuteTemplate(w, tt.args.json, tt.args.template, tt.args.colorize); (err != nil) != tt.wantErr {
io, _, w, _ := iostreams.Test()
io.SetColorEnabled(tt.args.colorize)
if err := ExecuteTemplate(io, tt.args.json, tt.args.template); (err != nil) != tt.wantErr {
t.Errorf("executeTemplate() error = %v, wantErr %v", err, tt.wantErr)
return
}

View file

@ -21,6 +21,8 @@ import (
"golang.org/x/term"
)
const DefaultWidth = 80
type IOStreams struct {
In io.ReadCloser
Out io.Writer
@ -99,6 +101,10 @@ func (s *IOStreams) TerminalTheme() string {
return s.terminalTheme
}
func (s *IOStreams) SetColorEnabled(colorEnabled bool) {
s.colorEnabled = colorEnabled
}
func (s *IOStreams) SetStdinTTY(isTTY bool) {
s.stdinTTYOverride = true
s.stdinIsTTY = isTTY
@ -239,12 +245,14 @@ func (s *IOStreams) StopProgressIndicator() {
s.progressIndicator = nil
}
// TerminalWidth returns the width of the terminal that stdout is attached to.
// TODO: investigate whether ProcessTerminalWidth could replace all this.
func (s *IOStreams) TerminalWidth() int {
if s.termWidthOverride > 0 {
return s.termWidthOverride
}
defaultWidth := 80
defaultWidth := DefaultWidth
out := s.Out
if s.originalOut != nil {
out = s.originalOut
@ -271,6 +279,15 @@ func (s *IOStreams) TerminalWidth() int {
return defaultWidth
}
// ProcessTerminalWidth returns the width of the terminal that the process is attached to.
func (s *IOStreams) ProcessTerminalWidth() int {
w, _, err := s.ttySize()
if err != nil {
return DefaultWidth
}
return w
}
func (s *IOStreams) ForceTerminal(spec string) {
s.colorEnabled = !EnvColorDisabled()
s.SetStdoutTTY(true)

View file

@ -1,13 +1,15 @@
package text
import (
"strings"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
)
const (
ellipsis = "..."
minWidthForEllipsis = 5
minWidthForEllipsis = len(ellipsis) + 2
)
// DisplayWidth calculates what the rendered width of a string may be
@ -34,3 +36,12 @@ func Truncate(maxWidth int, s string) string {
return r
}
// 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] + ellipsis
}
return Truncate(maxWidth, s)
}

View file

@ -14,6 +14,22 @@ func TestTruncate(t *testing.T) {
args args
want string
}{
{
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{
@ -78,6 +94,14 @@ func TestTruncate(t *testing.T) {
},
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) {
@ -88,6 +112,82 @@ func TestTruncate(t *testing.T) {
}
}
func TestTruncateColumn(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) {
if got := TruncateColumn(tt.args.max, tt.args.s); got != tt.want {
t.Errorf("TruncateColumn() = %q, want %q", got, tt.want)
}
})
}
}
func TestDisplayWidth(t *testing.T) {
tests := []struct {
name string
@ -149,6 +249,11 @@ func TestDisplayWidth(t *testing.T) {
text: `é́́`,
want: 1,
},
{
name: "color codes",
text: "\x1b[0;31mred\x1b[0m",
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -17,11 +17,27 @@ type TablePrinter interface {
Render() error
}
type TablePrinterOptions struct {
IsTTY bool
}
func NewTablePrinter(io *iostreams.IOStreams) TablePrinter {
if io.IsStdoutTTY() {
return NewTablePrinterWithOptions(io, TablePrinterOptions{
IsTTY: io.IsStdoutTTY(),
})
}
func NewTablePrinterWithOptions(io *iostreams.IOStreams, opts TablePrinterOptions) TablePrinter {
if opts.IsTTY {
var maxWidth int
if io.IsStdoutTTY() {
maxWidth = io.TerminalWidth()
} else {
maxWidth = io.ProcessTerminalWidth()
}
return &ttyTablePrinter{
out: io.Out,
maxWidth: io.TerminalWidth(),
maxWidth: maxWidth,
}
}
return &tsvTablePrinter{
@ -49,7 +65,6 @@ func (t ttyTablePrinter) IsTTY() bool {
return true
}
// Never pass pre-colorized text to AddField; always specify colorFunc. Otherwise, the table printer can't correctly compute the width of its columns.
func (t *ttyTablePrinter) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) {
if truncateFunc == nil {
truncateFunc = text.Truncate