diff --git a/pkg/report/doc.go b/pkg/report/doc.go index 326b315f2..088568173 100644 --- a/pkg/report/doc.go +++ b/pkg/report/doc.go @@ -3,34 +3,44 @@ Package report provides helper structs/methods/funcs for formatting output To format output for an array of structs: - w := report.NewWriterDefault(os.Stdout) - defer w.Flush() - +ExamplePodman: headers := report.Headers(struct { ID string }{}, nil) - t, _ := report.NewTemplate("command name").Parse("{{range .}}{{.ID}}{{end}}") - t.Execute(t, headers) - t.Execute(t, map[string]string{ + + f := report.New(os.Stdout, "Command Name") + f, _ := f.Parse(report.OriginPodman, "{{range .}}{{.ID}}{{end}}") + defer f.Flush() + + if f.RenderHeaders { + f.Execute(headers) + } + f.Execute( map[string]string{ "ID":"fa85da03b40141899f3af3de6d27852b", }) - // t.IsTable() == false - -or - w := report.NewWriterDefault(os.Stdout) - defer w.Flush() + // Output: + // ID + // fa85da03b40141899f3af3de6d27852b +ExampleUser: headers := report.Headers(struct { CID string - }{}, map[string]string{ - "CID":"ID"}) - t, _ := report.NewTemplate("command name").Parse("table {{.CID}}") - t.Execute(t, headers) + }{}, map[string]string{"CID":"ID"}) + + f, _ := report.New(os.Stdout, "Command Name").Parse(report.OriginUser, "table {{.CID}}") + defer f.Flush() + + if f.RenderHeaders { + t.Execute(t, headers) + } t.Execute(t,map[string]string{ "CID":"fa85da03b40141899f3af3de6d27852b", }) - // t.IsTable() == true + + // Output: + // ID + // fa85da03b40141899f3af3de6d27852b Helpers: @@ -38,13 +48,20 @@ Helpers: ... process JSON and output } + if report.HasTable(cmd.Flag("format").Value.String()) { + ... "table" keyword prefix in format text + } + Template Functions: The following template functions are added to the template when parsed: - join strings.Join, {{join .Field separator}} + - json encode field as JSON {{ json .Field }} - lower strings.ToLower {{ .Field | lower }} + - pad add spaces as prefix and suffix {{ pad . 2 2 }} - split strings.Split {{ .Field | split }} - title strings.Title {{ .Field | title }} + - truncate limit field length {{ truncate . 10 }} - upper strings.ToUpper {{ .Field | upper }} report.Funcs() may be used to add additional template functions. diff --git a/pkg/report/formatter.go b/pkg/report/formatter.go new file mode 100644 index 000000000..1772f8765 --- /dev/null +++ b/pkg/report/formatter.go @@ -0,0 +1,151 @@ +package report + +import ( + "io" + "strings" + "text/tabwriter" + "text/template" +) + +// Flusher is the interface that wraps the Flush method. +type Flusher interface { + Flush() error +} + +// NopFlusher represents a type which flush operation is nop. +type NopFlusher struct{} + +// Flush is a nop operation. +func (f *NopFlusher) Flush() (err error) { return } + +type Origin int + +const ( + OriginUnknown Origin = iota + OriginPodman + OriginUser +) + +func (o Origin) String() string { + switch o { + case OriginPodman: + return "OriginPodman" + case OriginUser: + return "OriginUser" + default: + return "OriginUnknown" + } +} + +// Formatter holds the configured Writer and parsed Template, additional state fields are +// maintained to assist in the podman command report writing. +type Formatter struct { + Origin Origin // Source of go template. OriginUser or OriginPodman + RenderHeaders bool // Hint, default behavior for given template is to include headers + RenderTable bool // Does template have "table" keyword + flusher Flusher // Flush any buffered formatted output + template *template.Template // Go text/template for formatting output + text string // value of canonical template after processing + writer io.Writer // Destination for formatted output +} + +// Parse parses golang template returning a formatter +// +// - OriginPodman implies text is a template from podman code. Output will +// be filtered through a tabwriter. +// +// - OriginUser implies text is a template from a user. If template includes +// keyword "table" output will be filtered through a tabwriter. +func (f *Formatter) Parse(origin Origin, text string) (*Formatter, error) { + f.Origin = origin + + switch { + case strings.HasPrefix(text, "table "): + f.RenderTable = true + text = "{{range .}}" + NormalizeFormat(text) + "{{end -}}" + case OriginUser == origin: + text = EnforceRange(NormalizeFormat(text)) + default: + text = NormalizeFormat(text) + } + f.text = text + + if f.RenderTable || origin == OriginPodman { + tw := tabwriter.NewWriter(f.writer, 12, 2, 2, ' ', tabwriter.StripEscape) + f.writer = tw + f.flusher = tw + f.RenderHeaders = true + } + + tmpl, err := f.template.Funcs(template.FuncMap(DefaultFuncs)).Parse(text) + if err != nil { + return f, err + } + f.template = tmpl + return f, nil +} + +// Funcs adds the elements of the argument map to the template's function map. +// A default template function will be replaced if there is a key collision. +func (f *Formatter) Funcs(funcMap template.FuncMap) *Formatter { + m := make(template.FuncMap, len(DefaultFuncs)+len(funcMap)) + for k, v := range DefaultFuncs { + m[k] = v + } + for k, v := range funcMap { + m[k] = v + } + f.template = f.template.Funcs(funcMap) + return f +} + +// Init either resets the given tabwriter with new values or wraps w in tabwriter with given values +func (f *Formatter) Init(w io.Writer, minwidth, tabwidth, padding int, padchar byte, flags uint) *Formatter { + flags |= tabwriter.StripEscape + + if tw, ok := f.writer.(*tabwriter.Writer); ok { + tw = tw.Init(w, minwidth, tabwidth, padding, padchar, flags) + f.writer = tw + f.flusher = tw + } else { + tw = tabwriter.NewWriter(w, minwidth, tabwidth, padding, padchar, flags) + f.writer = tw + f.flusher = tw + } + return f +} + +// Execute applies a parsed template to the specified data object, +// and writes the output to Formatter.Writer. +func (f *Formatter) Execute(data interface{}) error { + return f.template.Execute(f.writer, data) +} + +// Flush should be called after the last call to Write to ensure +// that any data buffered in the Formatter is written to output. Any +// incomplete escape sequence at the end is considered +// complete for formatting purposes. +func (f *Formatter) Flush() error { + // Indirection is required here to prevent caller from having to know when + // value of Flusher may be changed. + return f.flusher.Flush() +} + +// Writer returns the embedded io.Writer from Formatter +func (f *Formatter) Writer() io.Writer { + return f.writer +} + +// New allocates a new, undefined Formatter with the given name and Writer +func New(output io.Writer, name string) *Formatter { + f := new(Formatter) + + f.flusher = new(NopFlusher) + if flusher, ok := output.(Flusher); ok { + f.flusher = flusher + } + + f.template = template.New(name) + f.writer = output + return f +} diff --git a/pkg/report/formatter_test.go b/pkg/report/formatter_test.go new file mode 100644 index 000000000..711267ce0 --- /dev/null +++ b/pkg/report/formatter_test.go @@ -0,0 +1,220 @@ +package report + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + "text/tabwriter" + "text/template" + + "github.com/stretchr/testify/assert" +) + +func TestFormatter_IsTableFalse(t *testing.T) { + fmtr, err := New(os.Stdout, t.Name()).Parse(OriginPodman, "{{.ID}}") + assert.NoError(t, err) + assert.False(t, fmtr.RenderTable) +} + +func TestFormatter_IsTableTrue(t *testing.T) { + fmtr, err := New(os.Stdout, t.Name()).Parse(OriginPodman, "table {{.ID}}") + assert.NoError(t, err) + assert.True(t, fmtr.RenderTable) +} + +func TestFormatter_HasTable(t *testing.T) { + assert.True(t, HasTable("table foobar")) + assert.False(t, HasTable("foobar")) +} + +type testFormatterStruct struct { + FieldA bool // camel case test + Fieldb bool // no camel case + fieldC bool // nolint // private field + fieldd bool // nolint // private field +} + +func TestFormatter_HeadersNoOverrides(t *testing.T) { + expected := []map[string]string{{ + "FieldA": "FIELD A", + "Fieldb": "FIELDB", + "fieldC": "FIELD C", + "fieldd": "FIELDD", + }} + assert.Equal(t, expected, Headers(testFormatterStruct{}, nil)) +} + +func TestFormatter_HeadersOverride(t *testing.T) { + expected := []map[string]string{{ + "FieldA": "FIELD A", + "Fieldb": "FIELD B", + "fieldC": "FIELD C", + "fieldd": "FIELD D", + }} + assert.Equal(t, expected, Headers(testFormatterStruct{}, map[string]string{ + "Fieldb": "field b", + "fieldd": "field d", + })) +} + +func TestFormatter_ParseTable(t *testing.T) { + testCase := []struct { + Type io.Writer + Origin Origin + Format string + Expected string + }{ + {&tabwriter.Writer{}, OriginUser, "table {{ .ID}}", "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, "table {{ .ID}}\n", "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, `table {{ .ID}}\n`, "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, "table {{.ID}}", "Identity\nc061a0839e\nf10fc2e11057\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\n"}, + {&tabwriter.Writer{}, OriginUser, "table {{.ID}}\t{{.Value}}", + "Identity Value\nc061a0839e one\nf10fc2e11057 two\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a three\n"}, + {&bytes.Buffer{}, OriginUser, "{{range .}}{{.ID}}\tID{{end}}", "c061a0839e\tIDf10fc2e11057\tID1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\tID\n"}, + {&tabwriter.Writer{}, OriginPodman, "Value\tIdent\n{{range .}}{{.ID}}\tID{{end}}", + "Value Ident\nIdentity ID\nValue Ident\nc061a0839e IDf10fc2e11057 ID1eb6fab5aa8f4b5cbfd3e66aa35e9b2a ID\n"}, + {&bytes.Buffer{}, OriginUser, "{{range .}}{{.ID}}\tID\n{{end}}", "c061a0839e\tID\nf10fc2e11057\tID\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a\tID\n\n"}, + {&bytes.Buffer{}, OriginUser, `{{range .}}{{.ID}}{{end -}}`, "c061a0839ef10fc2e110571eb6fab5aa8f4b5cbfd3e66aa35e9b2a"}, + } + + for loop, tc := range testCase { + tc := tc + name := fmt.Sprintf("Loop#%d", loop) + t.Run(name, func(t *testing.T) { + buf := new(bytes.Buffer) + + rpt, err := New(buf, name).Parse(tc.Origin, tc.Format) + assert.NoError(t, err) + assert.Equal(t, tc.Origin, rpt.Origin) + assert.IsType(t, tc.Type, rpt.Writer()) + + if rpt.RenderHeaders { + rpt.Execute([]map[string]string{{ + "ID": "Identity", + "Value": "Value", + }}) + } + err = rpt.Execute([...]map[string]string{ + {"ID": "c061a0839e", "Value": "one"}, + {"ID": "f10fc2e11057", "Value": "two"}, + {"ID": "1eb6fab5aa8f4b5cbfd3e66aa35e9b2a", "Value": "three"}, + }) + assert.NoError(t, err, fmt.Sprintf("original %+q, cooked %+q", tc.Format, rpt.text)) + rpt.Flush() + assert.Equal(t, tc.Expected, buf.String(), fmt.Sprintf("original %+q, cooked %+q", tc.Format, rpt.text)) + }) + } +} + +func TestFormatter_Init(t *testing.T) { + data := [...]map[string]string{ + {"ID": "c061a0839e", "Value": "one"}, + {"ID": "f10fc2e11057", "Value": "two"}, + {"ID": "1eb6fab5aa8f4b5cbfd3e66aa35e9b2a", "Value": "three"}, + } + + buf := new(bytes.Buffer) + fmtr, err := New(buf, t.Name()).Parse(OriginPodman, "{{range .}}{{.ID}}\t{{.Value}}\n{{end -}}") + assert.NoError(t, err) + + err = fmtr.Execute([]map[string]string{{ + "ID": "Identity", + "Value": "Value", + }}) + assert.NoError(t, err) + err = fmtr.Execute(data) + assert.NoError(t, err) + fmtr.Flush() + assert.Equal(t, + "Identity Value\nc061a0839e one\nf10fc2e11057 two\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a three\n", buf.String()) + + buf = new(bytes.Buffer) + fmtr = fmtr.Init(buf, 8, 1, 1, ' ', tabwriter.Debug) + + err = fmtr.Execute([]map[string]string{{ + "ID": "Identity", + "Value": "Value", + }}) + assert.NoError(t, err) + err = fmtr.Execute(data) + assert.NoError(t, err) + fmtr.Flush() + assert.Equal(t, "Identity |Value\nc061a0839e |one\nf10fc2e11057 |two\n1eb6fab5aa8f4b5cbfd3e66aa35e9b2a |three\n", buf.String()) +} + +func TestFormatter_FuncsTrim(t *testing.T) { + buf := new(bytes.Buffer) + fmtr := New(buf, t.Name()) + + fmtr, err := fmtr.Funcs(template.FuncMap{"trim": strings.TrimSpace}).Parse(OriginPodman, "{{.ID |trim}}") + assert.NoError(t, err) + + err = fmtr.Execute(map[string]string{ + "ID": "ident ", + }) + assert.NoError(t, err) + assert.Equal(t, "ident\n", buf.String()) +} + +func TestFormatter_FuncsJoin(t *testing.T) { + buf := new(bytes.Buffer) + // Add 'trim' function to ensure default 'join' function is still available + fmtr, e := New(buf, t.Name()).Funcs(template.FuncMap{"trim": strings.TrimSpace}).Parse(OriginPodman, `{{join .ID "-"}}`) + assert.NoError(t, e) + + err := fmtr.Execute(map[string][]string{ + "ID": {"ident1", "ident2", "ident3"}, + }) + assert.NoError(t, err) + assert.Equal(t, "ident1-ident2-ident3\n", buf.String()) +} + +func TestFormatter_FuncsReplace(t *testing.T) { + buf := new(bytes.Buffer) + rpt := New(buf, t.Name()) + + // yes, we're overriding ToUpper with ToLower :-) + tmpl, e := rpt.Funcs(template.FuncMap{"upper": strings.ToLower}).Parse(OriginPodman, `{{.ID | lower}}`) + assert.NoError(t, e) + + err := tmpl.Execute(map[string]string{ + "ID": "IDENT", + }) + assert.NoError(t, err) + assert.Equal(t, "ident\n", buf.String()) +} + +func TestFormatter_FuncsJSON(t *testing.T) { + buf := new(bytes.Buffer) + rpt := New(buf, t.Name()) + + rpt, e := rpt.Parse(OriginUser, `{{json .ID}}`) + assert.NoError(t, e) + + err := rpt.Execute([]struct { + ID []string + }{{ + ID: []string{"ident1", "ident2", "ident3"}, + }}) + assert.NoError(t, err) + assert.Equal(t, `["ident1","ident2","ident3"]`+"\n", buf.String(), fmt.Sprintf("cooked %+q", rpt.text)) +} + +// Verify compatible output +func TestFormatter_Compatible(t *testing.T) { + buf := new(bytes.Buffer) + + rpt, err := New(buf, t.Name()).Parse(OriginUser, "ID\t{{.ID}}") + assert.NoError(t, err) + + err = rpt.Execute([...]map[string]string{ + {"ID": "c061a0839e"}, + }) + assert.NoError(t, err) + rpt.Flush() + + assert.Equal(t, "ID\tc061a0839e\n", buf.String(), fmt.Sprintf("cooked %+q", rpt.text)) +} diff --git a/pkg/report/template.go b/pkg/report/template.go index f86b07034..95c04424d 100644 --- a/pkg/report/template.go +++ b/pkg/report/template.go @@ -25,17 +25,19 @@ var tableReplacer = strings.NewReplacer( "table ", "", `\t`, "\t", " ", "\t", + `\n`, "\n", ) // escapedReplacer will clean up escaped characters from CLI var escapedReplacer = strings.NewReplacer( `\t`, "\t", + `\n`, "\n", ) var DefaultFuncs = FuncMap{ "join": strings.Join, "json": func(v interface{}) string { - buf := &bytes.Buffer{} + buf := new(bytes.Buffer) enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) enc.Encode(v) @@ -157,7 +159,7 @@ func (t *Template) IsTable() bool { return t.isTable } -var rangeRegex = regexp.MustCompile(`{{\s*range\s*\.\s*}}.*{{\s*end\s*-?\s*}}`) +var rangeRegex = regexp.MustCompile(`(?s){{\s*range\s*\.\s*}}.*{{\s*end\s*-?\s*}}`) // EnforceRange ensures that the format string contains a range func EnforceRange(format string) string { diff --git a/pkg/report/template_test.go b/pkg/report/template_test.go index db75a6fab..fb3f23631 100644 --- a/pkg/report/template_test.go +++ b/pkg/report/template_test.go @@ -113,7 +113,7 @@ func TestTemplate_trim(t *testing.T) { func TestTemplate_DefaultFuncs(t *testing.T) { tmpl := NewTemplate("TestTemplate") // Throw in trim function to ensure default 'join' is still available - tmpl, e := tmpl.Funcs(FuncMap{"trim": strings.TrimSpace}).Parse(`{{join .ID "\n"}}`) + tmpl, e := tmpl.Funcs(FuncMap{"trim": strings.TrimSpace}).Parse(`{{join .ID "-"}}`) assert.NoError(t, e) var buf bytes.Buffer @@ -121,7 +121,7 @@ func TestTemplate_DefaultFuncs(t *testing.T) { "ID": {"ident1", "ident2", "ident3"}, }) assert.NoError(t, err) - assert.Equal(t, "ident1\nident2\nident3\n", buf.String()) + assert.Equal(t, "ident1-ident2-ident3\n", buf.String()) } func TestTemplate_ReplaceFuncs(t *testing.T) {