databricks-cli/libs/cmdio/render.go

468 lines
12 KiB
Go

package cmdio
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"text/tabwriter"
"text/template"
"time"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/flags"
"github.com/databricks/databricks-sdk-go/listing"
"github.com/fatih/color"
"github.com/nwidger/jsoncolor"
)
// Heredoc is the equivalent of compute.TrimLeadingWhitespace
// (command-execution API helper from SDK), except it's more
// friendly to non-printable characters.
func Heredoc(tmpl string) (trimmed string) {
lines := strings.Split(tmpl, "\n")
leadingWhitespace := 1<<31 - 1
for _, line := range lines {
for pos, char := range line {
if char == ' ' || char == '\t' {
continue
}
// first non-whitespace character
if pos < leadingWhitespace {
leadingWhitespace = pos
}
// is not needed further
break
}
}
for i := 0; i < len(lines); i++ {
if lines[i] == "" || strings.TrimSpace(lines[i]) == "" {
continue
}
if len(lines[i]) < leadingWhitespace {
trimmed += lines[i] + "\n" // or not..
} else {
trimmed += lines[i][leadingWhitespace:] + "\n"
}
}
return strings.TrimSpace(trimmed)
}
// writeFlusher represents a buffered writer that can be flushed. This is useful when
// buffering writing a large number of resources (such as during a list API).
type writeFlusher interface {
io.Writer
Flush() error
}
type jsonRenderer interface {
// Render an object as JSON to the provided writeFlusher.
renderJson(context.Context, writeFlusher) error
}
type textRenderer interface {
// Render an object as text to the provided writeFlusher.
renderText(context.Context, io.Writer) error
}
type templateRenderer interface {
// Render an object using the provided template and write to the provided tabwriter.Writer.
renderTemplate(context.Context, *template.Template, *tabwriter.Writer) error
}
type readerRenderer struct {
reader io.Reader
}
func (r readerRenderer) renderText(_ context.Context, w io.Writer) error {
_, err := io.Copy(w, r.reader)
return err
}
type iteratorRenderer[T any] struct {
t listing.Iterator[T]
bufferSize int
}
func (ir iteratorRenderer[T]) getBufferSize() int {
if ir.bufferSize == 0 {
return 20
}
return ir.bufferSize
}
func (ir iteratorRenderer[T]) renderJson(ctx context.Context, w writeFlusher) error {
// Iterators are always rendered as a list of resources in JSON.
_, err := w.Write([]byte("[\n "))
if err != nil {
return err
}
for i := 0; ir.t.HasNext(ctx); i++ {
if i != 0 {
_, err = w.Write([]byte(",\n "))
if err != nil {
return err
}
}
n, err := ir.t.Next(ctx)
if err != nil {
return err
}
res, err := json.MarshalIndent(n, " ", " ")
if err != nil {
return err
}
_, err = w.Write(res)
if err != nil {
return err
}
if (i+1)%ir.getBufferSize() == 0 {
err = w.Flush()
if err != nil {
return err
}
}
}
_, err = w.Write([]byte("\n]\n"))
if err != nil {
return err
}
return w.Flush()
}
func (ir iteratorRenderer[T]) renderTemplate(ctx context.Context, t *template.Template, w *tabwriter.Writer) error {
buf := make([]any, 0, ir.getBufferSize())
for i := 0; ir.t.HasNext(ctx); i++ {
n, err := ir.t.Next(ctx)
if err != nil {
return err
}
buf = append(buf, n)
if len(buf) == cap(buf) {
err = t.Execute(w, buf)
if err != nil {
return err
}
err = w.Flush()
if err != nil {
return err
}
buf = buf[:0]
}
}
if len(buf) > 0 {
err := t.Execute(w, buf)
if err != nil {
return err
}
}
return w.Flush()
}
type defaultRenderer struct {
t any
}
func (d defaultRenderer) renderJson(_ context.Context, w writeFlusher) error {
pretty, err := fancyJSON(d.t)
if err != nil {
return err
}
_, err = w.Write(pretty)
if err != nil {
return err
}
_, err = w.Write([]byte("\n"))
if err != nil {
return err
}
return w.Flush()
}
func (d defaultRenderer) renderTemplate(_ context.Context, t *template.Template, w *tabwriter.Writer) error {
return t.Execute(w, d.t)
}
// Returns something implementing one of the following interfaces:
// - jsonRenderer
// - textRenderer
// - templateRenderer
func newRenderer(t any) any {
if r, ok := t.(io.Reader); ok {
return readerRenderer{reader: r}
}
return defaultRenderer{t: t}
}
func newIteratorRenderer[T any](i listing.Iterator[T]) iteratorRenderer[T] {
return iteratorRenderer[T]{t: i}
}
type bufferedFlusher struct {
w io.Writer
b *bytes.Buffer
}
func (b bufferedFlusher) Write(bs []byte) (int, error) {
return b.b.Write(bs)
}
func (b bufferedFlusher) Flush() error {
_, err := b.w.Write(b.b.Bytes())
if err != nil {
return err
}
b.b.Reset()
return nil
}
func newBufferedFlusher(w io.Writer) writeFlusher {
return bufferedFlusher{
w: w,
b: &bytes.Buffer{},
}
}
func renderWithTemplate(r any, ctx context.Context, outputFormat flags.Output, w io.Writer, headerTemplate, template string) error {
// TODO: add terminal width & white/dark theme detection
switch outputFormat {
case flags.OutputJSON:
if jr, ok := r.(jsonRenderer); ok {
return jr.renderJson(ctx, newBufferedFlusher(w))
}
return errors.New("json output not supported")
case flags.OutputText:
if tr, ok := r.(templateRenderer); ok && template != "" {
return renderUsingTemplate(ctx, tr, w, headerTemplate, template)
}
if tr, ok := r.(textRenderer); ok {
return tr.renderText(ctx, w)
}
if jr, ok := r.(jsonRenderer); ok {
return jr.renderJson(ctx, newBufferedFlusher(w))
}
return errors.New("no renderer defined")
default:
return fmt.Errorf("invalid output format: %s", outputFormat)
}
}
type listingInterface interface {
HasNext(context.Context) bool
}
func Render(ctx context.Context, v any) error {
c := fromContext(ctx)
if _, ok := v.(listingInterface); ok {
panic("use RenderIterator instead")
}
return renderWithTemplate(newRenderer(v), ctx, c.outputFormat, c.out, c.headerTemplate, c.template)
}
func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error {
c := fromContext(ctx)
return renderWithTemplate(newIteratorRenderer(i), ctx, c.outputFormat, c.out, c.headerTemplate, c.template)
}
func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template string) error {
c := fromContext(ctx)
if _, ok := v.(listingInterface); ok {
panic("use RenderIteratorWithTemplate instead")
}
return renderWithTemplate(newRenderer(v), ctx, c.outputFormat, c.out, headerTemplate, template)
}
func RenderIteratorWithTemplate[T any](ctx context.Context, i listing.Iterator[T], headerTemplate, template string) error {
c := fromContext(ctx)
return renderWithTemplate(newIteratorRenderer(i), ctx, c.outputFormat, c.out, headerTemplate, template)
}
func RenderIteratorJson[T any](ctx context.Context, i listing.Iterator[T]) error {
c := fromContext(ctx)
return renderWithTemplate(newIteratorRenderer(i), ctx, c.outputFormat, c.out, c.headerTemplate, c.template)
}
var renderFuncMap = template.FuncMap{
// we render colored output if stdout is TTY, otherwise we render text.
// in the future we'll check if we can explicitly check for stderr being
// a TTY
"header": color.BlueString,
"red": color.RedString,
"green": color.GreenString,
"blue": color.BlueString,
"yellow": color.YellowString,
"magenta": color.MagentaString,
"cyan": color.CyanString,
"bold": func(format string, a ...any) string {
return color.New(color.Bold).Sprintf(format, a...)
},
"italic": func(format string, a ...any) string {
return color.New(color.Italic).Sprintf(format, a...)
},
"replace": strings.ReplaceAll,
"join": strings.Join,
"bool": func(v bool) string {
if v {
return color.GreenString("YES")
}
return color.RedString("NO")
},
"pretty_json": func(in string) (string, error) {
var tmp any
err := json.Unmarshal([]byte(in), &tmp)
if err != nil {
return "", err
}
b, err := fancyJSON(tmp)
if err != nil {
return "", err
}
return string(b), nil
},
"pretty_date": func(t time.Time) string {
return t.Format("2006-01-02T15:04:05Z")
},
"b64_encode": func(in string) (string, error) {
var out bytes.Buffer
enc := base64.NewEncoder(base64.StdEncoding, &out)
_, err := enc.Write([]byte(in))
if err != nil {
return "", err
}
err = enc.Close()
if err != nil {
return "", err
}
return out.String(), nil
},
"b64_decode": func(in string) (string, error) {
dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(in))
out, err := io.ReadAll(dec)
if err != nil {
return "", err
}
return string(out), nil
},
}
func renderUsingTemplate(ctx context.Context, r templateRenderer, w io.Writer, headerTmpl, tmpl string) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
base := template.New("command").Funcs(renderFuncMap)
if headerTmpl != "" {
headerT, err := base.Parse(headerTmpl)
if err != nil {
return err
}
err = headerT.Execute(tw, nil)
if err != nil {
return err
}
tw.Write([]byte("\n"))
// Do not flush here. Instead, allow the first 100 resources to determine the initial spacing of the header columns.
}
t, err := base.Parse(tmpl)
if err != nil {
return err
}
err = r.renderTemplate(ctx, t, tw)
if err != nil {
return err
}
return tw.Flush()
}
func fancyJSON(v any) ([]byte, error) {
// create custom formatter
f := jsoncolor.NewFormatter()
// set custom colors
f.StringColor = color.New(color.FgGreen)
f.TrueColor = color.New(color.FgGreen, color.Bold)
f.FalseColor = color.New(color.FgRed)
f.NumberColor = color.New(color.FgCyan)
f.NullColor = color.New(color.FgMagenta)
f.ObjectColor = color.New(color.Reset)
f.CommaColor = color.New(color.Reset)
f.ColonColor = color.New(color.Reset)
return jsoncolor.MarshalIndentWithFormatter(v, "", " ", f)
}
const errorTemplate = `{{ "Error" | red }}: {{ .Summary }}
{{- range $index, $element := .Paths }}
{{ if eq $index 0 }}at {{else}} {{ end}}{{ $element.String | green }}
{{- end }}
{{- range $index, $element := .Locations }}
{{ if eq $index 0 }}in {{else}} {{ end}}{{ $element.String | cyan }}
{{- end }}
{{- if .Detail }}
{{ .Detail }}
{{- end }}
`
const warningTemplate = `{{ "Warning" | yellow }}: {{ .Summary }}
{{- range $index, $element := .Paths }}
{{ if eq $index 0 }}at {{else}} {{ end}}{{ $element.String | green }}
{{- end }}
{{- range $index, $element := .Locations }}
{{ if eq $index 0 }}in {{else}} {{ end}}{{ $element.String | cyan }}
{{- end }}
{{- if .Detail }}
{{ .Detail }}
{{- end }}
`
const recommendationTemplate = `{{ "Recommendation" | blue }}: {{ .Summary }}
{{- range $index, $element := .Paths }}
{{ if eq $index 0 }}at {{else}} {{ end}}{{ $element.String | green }}
{{- end }}
{{- range $index, $element := .Locations }}
{{ if eq $index 0 }}in {{else}} {{ end}}{{ $element.String | cyan }}
{{- end }}
{{- if .Detail }}
{{ .Detail }}
{{- end }}
`
func RenderDiagnosticsToErrorOut(ctx context.Context, diags diag.Diagnostics) error {
c := fromContext(ctx)
return RenderDiagnostics(c.err, diags)
}
func RenderDiagnostics(out io.Writer, diags diag.Diagnostics) error {
errorT := template.Must(template.New("error").Funcs(renderFuncMap).Parse(errorTemplate))
warningT := template.Must(template.New("warning").Funcs(renderFuncMap).Parse(warningTemplate))
recommendationT := template.Must(template.New("recommendation").Funcs(renderFuncMap).Parse(recommendationTemplate))
// Print errors and warnings.
for _, d := range diags {
var t *template.Template
switch d.Severity {
case diag.Error:
t = errorT
case diag.Warning:
t = warningT
case diag.Recommendation:
t = recommendationT
}
// Render the diagnostic with the appropriate template.
err := t.Execute(out, d)
if err != nil {
return fmt.Errorf("failed to render template: %w", err)
}
}
return nil
}