sq/cli/output.go
2023-11-19 18:06:36 -07:00

448 lines
13 KiB
Go

package cli
import (
"fmt"
"io"
"os"
"strings"
"github.com/fatih/color"
colorable "github.com/mattn/go-colorable"
wordwrap "github.com/mitchellh/go-wordwrap"
"github.com/spf13/cobra"
"github.com/neilotoole/sq/cli/flag"
"github.com/neilotoole/sq/cli/output"
"github.com/neilotoole/sq/cli/output/csvw"
"github.com/neilotoole/sq/cli/output/format"
"github.com/neilotoole/sq/cli/output/htmlw"
"github.com/neilotoole/sq/cli/output/jsonw"
"github.com/neilotoole/sq/cli/output/markdownw"
"github.com/neilotoole/sq/cli/output/raww"
"github.com/neilotoole/sq/cli/output/tablew"
"github.com/neilotoole/sq/cli/output/xlsxw"
"github.com/neilotoole/sq/cli/output/xmlw"
"github.com/neilotoole/sq/cli/output/yamlw"
"github.com/neilotoole/sq/libsq/core/errz"
"github.com/neilotoole/sq/libsq/core/lg/lga"
"github.com/neilotoole/sq/libsq/core/options"
"github.com/neilotoole/sq/libsq/core/stringz"
"github.com/neilotoole/sq/libsq/core/timez"
)
var (
OptPrintHeader = options.NewBool(
"header",
"",
0,
true,
"Print header row",
`Controls whether a header row is printed. This applies only
to certain formats, such as "text" or "csv".`,
options.TagOutput,
)
OptFormat = format.NewOpt(
"format",
"format",
'f',
format.Text,
nil,
"Specify output format",
`Specify the output format. Some formats are only implemented for a subset of
sq's commands. If the specified format is not available for a particular
command, sq falls back to "text". Available formats:
text, csv, tsv, xlsx,
json, jsona, jsonl,
markdown, html, xlsx, xml, yaml, raw`,
)
OptErrorFormat = format.NewOpt(
"error.format",
"",
0,
format.Text,
func(f format.Format) error {
if f == format.Text || f == format.JSON {
return nil
}
return errz.Errorf("option {error.format} allows only %q or %q", format.Text, format.JSON)
},
"Error output format",
fmt.Sprintf(`The format to output errors in. Allowed formats are %q or %q.`, format.Text, format.JSON),
)
OptVerbose = options.NewBool(
"verbose",
"",
'v',
false,
"Print verbose output",
`Print verbose output.`,
options.TagOutput,
)
OptMonochrome = options.NewBool(
"monochrome",
"",
'M',
false,
"Don't print color output",
`Don't print color output.`,
options.TagOutput,
)
OptCompact = options.NewBool(
"compact",
"",
'c',
false,
"Compact instead of pretty-printed output",
`Compact instead of pretty-printed output.`,
options.TagOutput,
)
OptTuningFlushThreshold = options.NewInt(
"tuning.flush-threshold",
"",
0,
1000,
"Output writer buffer flush threshold in bytes",
`Size in bytes after which output writers should flush any internal buffer.
Generally, it is not necessary to fiddle this knob.`,
options.TagTuning,
)
timeLayoutsList = "Predefined values:\n" + stringz.IndentLines(
wordwrap.WrapString(strings.Join(timez.NamedLayouts(), ", "), 64),
" ")
OptDatetimeFormat = options.NewString(
"format.datetime",
"",
0,
"RFC3339",
nil,
"Timestamp format: constant such as RFC3339 or a strftime format",
`Timestamp format. This can be one of several predefined constants such
as "RFC3339" or "Unix", or a strftime format such as "%Y-%m-%d %H:%M:%S".
`+timeLayoutsList,
options.TagOutput,
)
OptDatetimeFormatAsNumber = options.NewBool(
"format.datetime.number",
"",
0,
true,
"Render numeric datetime value as number instead of string",
`Render numeric datetime value as number instead of string, if possible.
If format.datetime renders a numeric value (e.g. a Unix timestamp such
as "1591843854"), that value is typically rendered as a string. For some output
formats, such as JSON, it can be useful to instead render the value as a naked
number instead of a string. Note that this option is no-op if the rendered value
is not an integer.
format.datetime.number=false
[{"first_name":"PENELOPE","last_update":"1591843854"}]
format.datetime.number=true
[{"first_name":"PENELOPE","last_update":1591843854}]
`,
options.TagOutput,
)
OptDateFormat = options.NewString(
"format.date",
"",
0,
"DateOnly",
nil,
"Date format: constant such as DateOnly or a strftime format",
`Date format. This can be one of several predefined constants such
as "DateOnly" or "Unix", or a strftime format such as "%Y-%m-%d".
Note that date values are sometimes programmatically indistinguishable
from datetime values. In that situation, use format.datetime instead.
`+timeLayoutsList,
options.TagOutput,
)
OptDateFormatAsNumber = options.NewBool(
"format.date.number",
"",
0,
true,
"Render numeric date value as number instead of string",
`Render numeric date value as number instead of string, if possible.
If format.date renders a numeric value (e.g. a year such as "1979"), that value
is typically rendered as a string. For some output formats, such as JSON, it can
be useful to instead render the value as a naked number instead of a string.
Note that this option is no-op if the rendered value is not an integer.
format.date.number=false
[{"first_name":"PENELOPE","birth_year":"1979"}]
format.date.number=true
[{"first_name":"PENELOPE","birth_year":1979}]
`,
options.TagOutput,
)
OptTimeFormat = options.NewString(
"format.time",
"",
0,
"TimeOnly",
nil,
"Time format: constant such as TimeOnly or a strftime format",
`Time format. This can be one of several predefined constants such
as "TimeOnly" or "Unix", or a strftime format such as "%Y-%m-%d".
Note that time values are sometimes programmatically indistinguishable
from datetime values. In that situation, use format.datetime instead.
`+timeLayoutsList,
options.TagOutput,
)
OptTimeFormatAsNumber = options.NewBool(
"format.time.number",
"",
0,
true,
"Render numeric time value as number instead of string",
`Render numeric time value as number instead of string, if possible.
If format.time renders a numeric value (e.g. "59"), that value
is typically rendered as a string. For some output formats, such as JSON, it can
be useful to instead render the value as a naked number instead of a string.
Note that this option is no-op if the rendered value is not an integer.
format.time.number=false
[{"first_name":"PENELOPE","favorite_minute":"59"}]
format.time.number=true
[{"first_name":"PENELOPE","favorite_minute":59}]
`,
options.TagOutput,
)
)
// newWriters returns an output.Writers instance configured per defaults and/or
// flags from cmd. The returned out2/errOut2 values may differ
// from the out/errOut args (e.g. decorated to support colorization).
func newWriters(cmd *cobra.Command, o options.Options, out, errOut io.Writer,
) (w *output.Writers, out2, errOut2 io.Writer) {
var pr *output.Printing
pr, out2, errOut2 = getPrinting(cmd, o, out, errOut)
log := logFrom(cmd)
// Package tablew has writer impls for each of the writer interfaces,
// so we use its Writers as the baseline. Later we check the format
// flags and set the various writer fields depending upon which
// writers the format implements.
w = &output.Writers{
Printing: pr,
Record: tablew.NewRecordWriter(out2, pr),
Metadata: tablew.NewMetadataWriter(out2, pr),
Source: tablew.NewSourceWriter(out2, pr),
Ping: tablew.NewPingWriter(out2, pr),
Error: tablew.NewErrorWriter(errOut2, pr),
Version: tablew.NewVersionWriter(out2, pr),
Config: tablew.NewConfigWriter(out2, pr),
}
if OptErrorFormat.Get(o) == format.JSON {
// This logic works because the only supported values are text and json.
w.Error = jsonw.NewErrorWriter(log, errOut2, pr)
}
// Invoke getFormat to see if the format was specified
// via config or flag.
fm := getFormat(cmd, o)
//nolint:exhaustive
switch fm {
case format.JSON:
// No format specified, use JSON
w.Metadata = jsonw.NewMetadataWriter(out2, pr)
w.Source = jsonw.NewSourceWriter(out2, pr)
w.Version = jsonw.NewVersionWriter(out2, pr)
w.Ping = jsonw.NewPingWriter(out2, pr)
w.Config = jsonw.NewConfigWriter(out2, pr)
case format.Text:
// Don't delete this case, it's actually needed due to
// the slightly odd logic that determines format.
case format.TSV:
w.Ping = csvw.NewPingWriter(out2, csvw.Tab)
case format.CSV:
w.Ping = csvw.NewPingWriter(out2, csvw.Comma)
case format.YAML:
w.Config = yamlw.NewConfigWriter(out2, pr)
w.Metadata = yamlw.NewMetadataWriter(out2, pr)
w.Source = yamlw.NewSourceWriter(out2, pr)
w.Version = yamlw.NewVersionWriter(out2, pr)
default:
}
recwFn := getRecordWriterFunc(fm)
if recwFn == nil {
// We can still continue, because w.Record was already set above.
log.Warn("No record writer impl for format", "format", fm)
} else {
w.Record = recwFn(out2, pr)
}
return w, out2, errOut2
}
// getRecordWriterFunc returns a func that creates a new output.RecordWriter
// for format f. If no matching format, nil is returned.
func getRecordWriterFunc(f format.Format) output.NewRecordWriterFunc {
switch f {
case format.Text:
return tablew.NewRecordWriter
case format.CSV:
return csvw.NewCommaRecordWriter
case format.TSV:
return csvw.NewTabRecordWriter
case format.JSON:
return jsonw.NewStdRecordWriter
case format.JSONA:
return jsonw.NewArrayRecordWriter
case format.JSONL:
return jsonw.NewObjectRecordWriter
case format.HTML:
return htmlw.NewRecordWriter
case format.Markdown:
return markdownw.NewRecordWriter
case format.XML:
return xmlw.NewRecordWriter
case format.XLSX:
return xlsxw.NewRecordWriter
case format.YAML:
return yamlw.NewRecordWriter
case format.Raw:
return raww.NewRecordWriter
default:
return nil
}
}
// getPrinting returns a Printing instance and
// colorable or non-colorable writers. It is permissible
// for the cmd arg to be nil. The caller should use the returned
// io.Writer instances instead of the supplied writers, as they
// may be decorated for dealing with color, etc.
// The supplied opts must already have flags merged into it
// via getOptionsFromCmd.
func getPrinting(cmd *cobra.Command, opts options.Options, out, errOut io.Writer,
) (pr *output.Printing, out2, errOut2 io.Writer) {
pr = output.NewPrinting()
pr.FormatDatetime = timez.FormatFunc(OptDatetimeFormat.Get(opts))
pr.FormatDatetimeAsNumber = OptDatetimeFormatAsNumber.Get(opts)
pr.FormatTime = timez.FormatFunc(OptTimeFormat.Get(opts))
pr.FormatTimeAsNumber = OptTimeFormatAsNumber.Get(opts)
pr.FormatDate = timez.FormatFunc(OptDateFormat.Get(opts))
pr.FormatDateAsNumber = OptDateFormatAsNumber.Get(opts)
pr.ExcelDatetimeFormat = xlsxw.OptDatetimeFormat.Get(opts)
pr.ExcelDateFormat = xlsxw.OptDateFormat.Get(opts)
pr.ExcelTimeFormat = xlsxw.OptTimeFormat.Get(opts)
pr.Verbose = OptVerbose.Get(opts)
pr.FlushThreshold = OptTuningFlushThreshold.Get(opts)
pr.Compact = OptCompact.Get(opts)
switch {
case cmdFlagChanged(cmd, flag.Header):
pr.ShowHeader, _ = cmd.Flags().GetBool(flag.Header)
case cmdFlagChanged(cmd, flag.NoHeader):
b, _ := cmd.Flags().GetBool(flag.NoHeader)
pr.ShowHeader = !b
case opts != nil:
pr.ShowHeader = OptPrintHeader.Get(opts)
}
colorize := !OptMonochrome.Get(opts)
if cmdFlagChanged(cmd, flag.Output) {
// We're outputting to a file, thus no color.
colorize = false
}
if !colorize {
color.NoColor = true
pr.EnableColor(false)
out2 = out
errOut2 = errOut
return pr, out2, errOut2
}
// We do want to colorize
if !isColorTerminal(out) {
// But out can't be colorized.
color.NoColor = true
pr.EnableColor(false)
out2, errOut2 = out, errOut
return pr, out2, errOut2
}
// out can be colorized.
color.NoColor = false
pr.EnableColor(true)
out2 = colorable.NewColorable(out.(*os.File))
// Check if we can colorize errOut
if isColorTerminal(errOut) {
errOut2 = colorable.NewColorable(errOut.(*os.File))
} else {
// errOut2 can't be colorized, but since we're colorizing
// out, we'll apply the non-colorable filter to errOut.
errOut2 = colorable.NewNonColorable(errOut)
}
logFrom(cmd).Debug("Constructed output.Printing", lga.Val, pr)
return pr, out2, errOut2
}
func getFormat(cmd *cobra.Command, o options.Options) format.Format {
var fm format.Format
switch {
case cmdFlagChanged(cmd, flag.TSV):
fm = format.TSV
case cmdFlagChanged(cmd, flag.CSV):
fm = format.CSV
case cmdFlagChanged(cmd, flag.XLSX):
fm = format.XLSX
case cmdFlagChanged(cmd, flag.XML):
fm = format.XML
case cmdFlagChanged(cmd, flag.Raw):
fm = format.Raw
case cmdFlagChanged(cmd, flag.HTML):
fm = format.HTML
case cmdFlagChanged(cmd, flag.Markdown):
fm = format.Markdown
case cmdFlagChanged(cmd, flag.Text):
fm = format.Text
case cmdFlagChanged(cmd, flag.JSONL):
fm = format.JSONL
case cmdFlagChanged(cmd, flag.JSONA):
fm = format.JSONA
case cmdFlagChanged(cmd, flag.JSON):
fm = format.JSON
case cmdFlagChanged(cmd, flag.YAML):
fm = format.YAML
default:
// no format flag, use the config value
fm = OptFormat.Get(o)
}
return fm
}