...

Source file src/github.com/cli/go-gh/v2/pkg/template/template.go

Documentation: github.com/cli/go-gh/v2/pkg/template

     1  // Package template facilitates processing of JSON strings using Go templates.
     2  // Provides additional functions not available using basic Go templates, such as coloring,
     3  // and table rendering.
     4  package template
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	"io"
    10  	"math"
    11  	"strconv"
    12  	"strings"
    13  	"text/template"
    14  	"time"
    15  
    16  	"github.com/cli/go-gh/v2/pkg/tableprinter"
    17  	"github.com/cli/go-gh/v2/pkg/text"
    18  	color "github.com/mgutz/ansi"
    19  )
    20  
    21  const (
    22  	ellipsis = "..."
    23  )
    24  
    25  // Template is the representation of a template.
    26  type Template struct {
    27  	colorEnabled bool
    28  	output       io.Writer
    29  	tmpl         *template.Template
    30  	tp           tableprinter.TablePrinter
    31  	width        int
    32  	funcs        template.FuncMap
    33  }
    34  
    35  // New initializes a Template.
    36  func New(w io.Writer, width int, colorEnabled bool) *Template {
    37  	return &Template{
    38  		colorEnabled: colorEnabled,
    39  		output:       w,
    40  		tp:           tableprinter.New(w, true, width),
    41  		width:        width,
    42  		funcs:        template.FuncMap{},
    43  	}
    44  }
    45  
    46  // Funcs adds the elements of the argument map to the template's function map.
    47  // It must be called before the template is parsed.
    48  // It is legal to overwrite elements of the map including default functions.
    49  // The return value is the template, so calls can be chained.
    50  func (t *Template) Funcs(funcMap map[string]interface{}) *Template {
    51  	for name, f := range funcMap {
    52  		t.funcs[name] = f
    53  	}
    54  	return t
    55  }
    56  
    57  // Parse the given template string for use with Execute.
    58  func (t *Template) Parse(tmpl string) error {
    59  	now := time.Now()
    60  	templateFuncs := map[string]interface{}{
    61  		"autocolor": colorFunc,
    62  		"color":     colorFunc,
    63  		"hyperlink": hyperlinkFunc,
    64  		"join":      joinFunc,
    65  		"pluck":     pluckFunc,
    66  		"tablerender": func() (string, error) {
    67  			// After rendering a table, prepare a new table printer incase user wants to output
    68  			// another table.
    69  			defer func() {
    70  				t.tp = tableprinter.New(t.output, true, t.width)
    71  			}()
    72  			return tableRenderFunc(t.tp)
    73  		},
    74  		"tablerow": func(fields ...interface{}) (string, error) {
    75  			return tableRowFunc(t.tp, fields...)
    76  		},
    77  		"timeago": func(input string) (string, error) {
    78  			return timeAgoFunc(now, input)
    79  		},
    80  		"timefmt":  timeFormatFunc,
    81  		"truncate": truncateFunc,
    82  	}
    83  	if !t.colorEnabled {
    84  		templateFuncs["autocolor"] = autoColorFunc
    85  	}
    86  	for name, f := range t.funcs {
    87  		templateFuncs[name] = f
    88  	}
    89  	var err error
    90  	t.tmpl, err = template.New("").Funcs(templateFuncs).Parse(tmpl)
    91  	return err
    92  }
    93  
    94  // Execute applies the parsed template to the input and writes result to the writer
    95  // the template was initialized with.
    96  func (t *Template) Execute(input io.Reader) error {
    97  	jsonData, err := io.ReadAll(input)
    98  	if err != nil {
    99  		return err
   100  	}
   101  
   102  	var data interface{}
   103  	if err := json.Unmarshal(jsonData, &data); err != nil {
   104  		return err
   105  	}
   106  
   107  	return t.tmpl.Execute(t.output, data)
   108  }
   109  
   110  // Flush writes any remaining data to the writer. This is mostly useful
   111  // when a templates uses the tablerow function but does not include the
   112  // tablerender function at the end.
   113  // If a template did not use the table functionality this is a noop.
   114  func (t *Template) Flush() error {
   115  	if _, err := tableRenderFunc(t.tp); err != nil {
   116  		return err
   117  	}
   118  	return nil
   119  }
   120  
   121  func colorFunc(colorName string, input interface{}) (string, error) {
   122  	text, err := jsonScalarToString(input)
   123  	if err != nil {
   124  		return "", err
   125  	}
   126  	return color.Color(text, colorName), nil
   127  }
   128  
   129  func pluckFunc(field string, input []interface{}) []interface{} {
   130  	var results []interface{}
   131  	for _, item := range input {
   132  		obj := item.(map[string]interface{})
   133  		results = append(results, obj[field])
   134  	}
   135  	return results
   136  }
   137  
   138  func joinFunc(sep string, input []interface{}) (string, error) {
   139  	var results []string
   140  	for _, item := range input {
   141  		text, err := jsonScalarToString(item)
   142  		if err != nil {
   143  			return "", err
   144  		}
   145  		results = append(results, text)
   146  	}
   147  	return strings.Join(results, sep), nil
   148  }
   149  
   150  func timeFormatFunc(format, input string) (string, error) {
   151  	t, err := time.Parse(time.RFC3339, input)
   152  	if err != nil {
   153  		return "", err
   154  	}
   155  	return t.Format(format), nil
   156  }
   157  
   158  func timeAgoFunc(now time.Time, input string) (string, error) {
   159  	t, err := time.Parse(time.RFC3339, input)
   160  	if err != nil {
   161  		return "", err
   162  	}
   163  	return timeAgo(now.Sub(t)), nil
   164  }
   165  
   166  func truncateFunc(maxWidth int, v interface{}) (string, error) {
   167  	if v == nil {
   168  		return "", nil
   169  	}
   170  	if s, ok := v.(string); ok {
   171  		return text.Truncate(maxWidth, s), nil
   172  	}
   173  	return "", fmt.Errorf("invalid value; expected string, got %T", v)
   174  }
   175  
   176  func autoColorFunc(colorName string, input interface{}) (string, error) {
   177  	return jsonScalarToString(input)
   178  }
   179  
   180  func tableRowFunc(tp tableprinter.TablePrinter, fields ...interface{}) (string, error) {
   181  	if tp == nil {
   182  		return "", fmt.Errorf("failed to write table row: no table printer")
   183  	}
   184  	for _, e := range fields {
   185  		s, err := jsonScalarToString(e)
   186  		if err != nil {
   187  			return "", fmt.Errorf("failed to write table row: %v", err)
   188  		}
   189  		tp.AddField(s, tableprinter.WithTruncate(truncateMultiline))
   190  	}
   191  	tp.EndRow()
   192  	return "", nil
   193  }
   194  
   195  func tableRenderFunc(tp tableprinter.TablePrinter) (string, error) {
   196  	if tp == nil {
   197  		return "", fmt.Errorf("failed to render table: no table printer")
   198  	}
   199  	err := tp.Render()
   200  	if err != nil {
   201  		return "", fmt.Errorf("failed to render table: %v", err)
   202  	}
   203  	return "", nil
   204  }
   205  
   206  func jsonScalarToString(input interface{}) (string, error) {
   207  	switch tt := input.(type) {
   208  	case string:
   209  		return tt, nil
   210  	case float64:
   211  		if math.Trunc(tt) == tt {
   212  			return strconv.FormatFloat(tt, 'f', 0, 64), nil
   213  		} else {
   214  			return strconv.FormatFloat(tt, 'f', 2, 64), nil
   215  		}
   216  	case nil:
   217  		return "", nil
   218  	case bool:
   219  		return fmt.Sprintf("%v", tt), nil
   220  	default:
   221  		return "", fmt.Errorf("cannot convert type to string: %v", tt)
   222  	}
   223  }
   224  
   225  func timeAgo(ago time.Duration) string {
   226  	if ago < time.Minute {
   227  		return "just now"
   228  	}
   229  	if ago < time.Hour {
   230  		return text.Pluralize(int(ago.Minutes()), "minute") + " ago"
   231  	}
   232  	if ago < 24*time.Hour {
   233  		return text.Pluralize(int(ago.Hours()), "hour") + " ago"
   234  	}
   235  	if ago < 30*24*time.Hour {
   236  		return text.Pluralize(int(ago.Hours())/24, "day") + " ago"
   237  	}
   238  	if ago < 365*24*time.Hour {
   239  		return text.Pluralize(int(ago.Hours())/24/30, "month") + " ago"
   240  	}
   241  	return text.Pluralize(int(ago.Hours()/24/365), "year") + " ago"
   242  }
   243  
   244  // TruncateMultiline returns a copy of the string s that has been shortened to fit the maximum
   245  // display width. If string s has multiple lines the first line will be shortened and all others
   246  // removed.
   247  func truncateMultiline(maxWidth int, s string) string {
   248  	if i := strings.IndexAny(s, "\r\n"); i >= 0 {
   249  		s = s[:i] + ellipsis
   250  	}
   251  	return text.Truncate(maxWidth, s)
   252  }
   253  
   254  func hyperlinkFunc(link, text string) string {
   255  	if text == "" {
   256  		text = link
   257  	}
   258  
   259  	// See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
   260  	return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", link, text)
   261  }
   262  

View as plain text