Skip to content

Commit c44f290

Browse files
authored
improve flags (#6)
Signed-off-by: Nico Braun <[email protected]>
1 parent bc84bcf commit c44f290

File tree

7 files changed

+282
-246
lines changed

7 files changed

+282
-246
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/bin
22
tpl-*-amd64*
33
*.tar.gz
4+
assets/ideas.md

README.md

+13-8
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Render json, yaml, & toml with go templates from the command line.
44

5-
The templates are executed with the [text/template](https://pkg.go.dev/text/template) package. This means they come with the additional risks and benefits the text templates provide.
5+
The templates are executed with the [text/template](https://pkg.go.dev/text/template) package. This means they come with the additional risks and benefits of the text template engine.
66

77
## Synopsis
88

@@ -28,7 +28,6 @@ The input data is read from stdin via pipe or redirection. It is actually not re
2828
tpl '{{ . }}' < path/to/input.json
2929
# Pipe
3030
curl localhost | tpl '{{ . }}'
31-
3231
# nil data
3332
tpl '{{ . }}'
3433
```
@@ -37,31 +36,37 @@ tpl '{{ . }}'
3736

3837
The default templates name is `_gotpl_default` and positional arguments are parsed into this root template. That means while its possible to specify multiple arguments, they will overwrite each other unless they use the `define` keyword to define a named template that can be referenced later when executing the template. If a named template is parsed multiple times, the last one will override the previous ones.
3938

40-
Templates from the flags --file and --glob are parsed in the order they are specified. So the override rules of the text/template package apply. If a file with the same name is specified multiple times, the last one wins. Even if they are in different directories.
39+
Templates from the flags `--file` and `--glob` are parsed in the order they are specified. So the override rules of the text/template package apply. If a file with the same name is specified multiple times, the last one wins. Even if they are in different directories.
4140

4241
The behavior of the cli tries to stay consistent with the actual behavior of the go template engine.
4342

44-
If the default template exists it will be used unless the --name flag is specified. If no default template exists because no positional argument has been provided, the template with the given file name is used, as long as only one file has been parsed. If multiple files have been parsed, the --name flag is required to avoid ambiguity.
43+
If the default template exists it will be used unless the `--name` flag is specified. If no default template exists because no positional argument has been provided, the template with the given file name is used, as long as only one file has been parsed. If multiple files have been parsed, the `--name` flag is required to avoid ambiguity.
4544

4645
```bash
47-
tpl '{{ . }}' --file foo.tpl --glob templates/*.tpl # default will be used
48-
tpl --file foo.tpl # foo.tpl will be used
49-
tpl --file foo.tpl --glob templates/*.tpl --name foo.tpl # the --name flag is required to select a template by name
46+
tpl '{{ . }}' --file foo.tpl '--glob templates/*.tpl' # default will be used
47+
tpl --file foo.tpl # foo.tpl will be used
48+
tpl --file foo.tpl --glob 'templates/*.tpl' --name foo.tpl # the --name flag is required to select a template by name
5049
```
5150

5251
The ability to parse multiple templates makes sense when defining helper snippets and other named templates to reference using the builtin `template` keyword or the custom `include` function which can be used in pipelines.
5352

53+
note globs need to quotes to avoid shell expansion.
54+
5455
## Decoders
5556

5657
By default input data is decoded as json and passed to the template to execute. It is possible to use an alternative decoder. The supported decoders are:
5758

5859
- json
5960
- yaml
6061
- toml
61-
- xml
6262

6363
While json could technically be decoded using the yaml decoder, this is not done by default for performance reasons.
6464

65+
## Options
66+
67+
The `--options` flag is passed to the template engine. Possible options can be found in the [documentation of the template engine](https://pkg.go.dev/text/template#Template.Option).
68+
The only option currently known is `missingkey`. Since the input data is decoded into `interface{}`, setting `missingkey=zero` will show `<no value>`, if the key does not exist, which is the same as the default. However, `missingkey=error` has some actual use cases.
69+
6570
## Functions
6671

6772
Next to the builtin functions, sSprig functions](http://masterminds.github.io/sprig/) and [treasure-map functions](https://github.com/bluebrown/treasure-map) are available.

assets/examples/README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ curl -s https://jsonplaceholder.typicode.com/todos | tpl '{{ table . }}'
1515
## Convert YAML to JSON
1616

1717
```bash
18-
echo 'foo: [bar, baz]' | tpl '{{ toPrettyJson . }}'
18+
echo 'foo: [bar, baz]' | tpl '{{ toPrettyJson . }}' -d yaml
1919
```
2020

2121
## Create a Certificate
2222

2323
```bash
24-
tpl -t assets/examples/cert.yaml.tpl
24+
tpl -f assets/examples/cert.yaml.tpl
2525
```

cmd/tpl/main.go

+263
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"os"
9+
"text/template"
10+
11+
"github.com/BurntSushi/toml"
12+
"github.com/icza/dyno"
13+
"github.com/spf13/pflag"
14+
"gopkg.in/yaml.v3"
15+
16+
"github.com/Masterminds/sprig/v3"
17+
"github.com/bluebrown/treasure-map/textfunc"
18+
)
19+
20+
var (
21+
version = "0.2.0"
22+
commit = "unknown"
23+
)
24+
25+
var (
26+
files []string
27+
globs []string
28+
templateName string
29+
options []string
30+
decoder decoderkind = decoderkindJSON
31+
noNewline bool
32+
)
33+
34+
const defaultTemplateName = "_gotpl_default"
35+
36+
var decoderMap = map[decoderkind]decode{
37+
decoderkindJSON: decodeJson,
38+
decoderkindYAML: decodeYaml,
39+
decoderkindTOML: decodeToml,
40+
}
41+
42+
func setFlagUsage() {
43+
pflag.CommandLine.SortFlags = false
44+
pflag.Usage = func() {
45+
fmt.Fprintln(os.Stderr, `Usage: tpl [--file PATH]... [--glob PATTERN]... [--name TEMPLATE_NAME]
46+
[--decoder DECODER_NAME] [--option KEY=VALUE]... [--no-newline] [TEMPLATE...] [-]
47+
[--help] [--usage] [--version]`)
48+
}
49+
}
50+
51+
func helptext() {
52+
fmt.Fprintln(os.Stderr, "Usage: tpl [options] [templates]")
53+
fmt.Fprintln(os.Stderr, "Options:")
54+
pflag.PrintDefaults()
55+
fmt.Fprintln(os.Stderr, "Examples")
56+
fmt.Fprintln(os.Stderr, " tpl '{{ . }}' < data.json")
57+
fmt.Fprintln(os.Stderr, " tpl --file my-template.tpl < data.json")
58+
fmt.Fprintln(os.Stderr, " tpl --glob 'templates/*' --name foo.tpl < data.json")
59+
}
60+
61+
func parseFlags() {
62+
var showHelp bool
63+
var showUsage bool
64+
var showVersion bool
65+
pflag.StringArrayVarP(&files, "file", "f", []string{}, "template file path. Can be specified multiple times")
66+
pflag.StringArrayVarP(&globs, "glob", "g", []string{}, "template file glob. Can be specified multiple times")
67+
pflag.StringVarP(&templateName, "name", "n", "", "if specified, execute the template with the given name")
68+
pflag.VarP(&decoder, "decoder", "d", "decoder to use for input data. Supported values: json, yaml, toml")
69+
pflag.StringArrayVar(&options, "option", []string{}, "option to pass to the template engine. Can be specified multiple times")
70+
pflag.BoolVar(&noNewline, "no-newline", false, "do not print newline at the end of the output")
71+
pflag.BoolVarP(&showHelp, "help", "h", false, "show the help text")
72+
pflag.BoolVar(&showUsage, "usage", false, "show the short usage text")
73+
pflag.BoolVarP(&showVersion, "version", "v", false, "show the version")
74+
pflag.Parse()
75+
if showHelp {
76+
helptext()
77+
os.Exit(0)
78+
}
79+
if showUsage {
80+
pflag.Usage()
81+
os.Exit(0)
82+
}
83+
if showVersion {
84+
fmt.Fprintf(os.Stderr, "version %s - commit %s\n", version, commit)
85+
os.Exit(0)
86+
}
87+
}
88+
89+
func main() {
90+
setFlagUsage()
91+
parseFlags()
92+
93+
err := run()
94+
if err != nil {
95+
fmt.Fprintf(os.Stderr, "%v\n", err)
96+
pflag.Usage()
97+
fmt.Fprintf(os.Stdout, "%v\n", err)
98+
os.Exit(2)
99+
}
100+
101+
// add a newline if the --no-newline flag was not set
102+
if !noNewline {
103+
fmt.Println()
104+
}
105+
}
106+
107+
func run() (err error) {
108+
// create the root template
109+
tpl := template.New(defaultTemplateName)
110+
tpl.Option(options...)
111+
tpl.Funcs(textfunc.MapClosure(sprig.TxtFuncMap(), tpl))
112+
113+
// parse the arguments
114+
for _, arg := range pflag.Args() {
115+
tpl, err = tpl.Parse(arg)
116+
if err != nil {
117+
return fmt.Errorf("error to parsing template: %v", err)
118+
}
119+
}
120+
121+
// parse files and globs in the order they were specified
122+
// to align with go's template package
123+
fileIndex := 0
124+
globIndex := 0
125+
for _, arg := range os.Args[1:] {
126+
if arg == "-f" || arg == "--file" {
127+
// parse next file
128+
file := files[fileIndex]
129+
tpl, err = tpl.ParseFiles(file)
130+
if err != nil {
131+
return fmt.Errorf("error parsing file %s: %v", file, err)
132+
}
133+
fileIndex++
134+
continue
135+
}
136+
if arg == "-g" || arg == "--glob" {
137+
// parse next glob
138+
glob := globs[globIndex]
139+
tpl, err = tpl.ParseGlob(glob)
140+
if err != nil {
141+
return fmt.Errorf("error parsing glob %s: %v", glob, err)
142+
}
143+
globIndex++
144+
continue
145+
}
146+
}
147+
148+
// defined templates
149+
templates := tpl.Templates()
150+
151+
// if there are no templates, return an error
152+
if len(templates) == 0 {
153+
return errors.New("no templates found")
154+
}
155+
156+
// determine the template to use
157+
if templateName == "" {
158+
if len(pflag.Args()) > 0 {
159+
templateName = defaultTemplateName
160+
} else if len(templates) == 1 {
161+
templateName = templates[0].Name()
162+
} else {
163+
return errors.New(fmt.Sprintf(
164+
"the --name flag is required when multiple templates are defined and no default template exists%s",
165+
tpl.DefinedTemplates(),
166+
))
167+
}
168+
}
169+
170+
// execute the template
171+
// read the input from stdin
172+
info, err := os.Stdin.Stat()
173+
if err != nil {
174+
return fmt.Errorf("error reading stdin: %v", err)
175+
}
176+
177+
// data is used to store the decoded input
178+
var data any
179+
180+
// if we are reading from stdin, decode the input
181+
if info.Mode()&os.ModeCharDevice == 0 {
182+
if err := decoderMap[decoder](os.Stdin, &data); err != nil {
183+
return fmt.Errorf("error decoding input: %v", err)
184+
}
185+
}
186+
187+
// execute the template with the given name
188+
// and optional data from stdin
189+
if err := tpl.ExecuteTemplate(os.Stdout, templateName, data); err != nil {
190+
return fmt.Errorf("error executing template: %v", err)
191+
}
192+
193+
return nil
194+
}
195+
196+
type decoderkind string // json, yaml, toml
197+
198+
const (
199+
decoderkindJSON decoderkind = "json"
200+
decoderkindYAML decoderkind = "yaml"
201+
decoderkindTOML decoderkind = "toml"
202+
)
203+
204+
func (d *decoderkind) Set(s string) error {
205+
switch s {
206+
case "json", "yaml", "toml":
207+
*d = decoderkind(s)
208+
return nil
209+
default:
210+
return fmt.Errorf(
211+
"invalid decoder kind: %s, supported value are: %s, %s, %s",
212+
s,
213+
decoderkindJSON,
214+
decoderkindYAML,
215+
decoderkindTOML,
216+
)
217+
}
218+
}
219+
220+
func (d *decoderkind) String() string {
221+
return string(*d)
222+
}
223+
224+
func (d *decoderkind) Type() string {
225+
return "string"
226+
}
227+
228+
type decode func(io.Reader, *any) error
229+
230+
func decodeYaml(in io.Reader, out *any) error {
231+
dec := yaml.NewDecoder(in)
232+
for {
233+
err := dec.Decode(out)
234+
if err != nil {
235+
if err == io.EOF {
236+
break
237+
}
238+
return err
239+
}
240+
}
241+
*out = dyno.ConvertMapI2MapS(*out)
242+
return nil
243+
}
244+
245+
func decodeToml(in io.Reader, out *any) error {
246+
dec := toml.NewDecoder(in)
247+
_, err := dec.Decode(out)
248+
return err
249+
}
250+
251+
func decodeJson(in io.Reader, out *any) error {
252+
dec := json.NewDecoder(in)
253+
for {
254+
err := dec.Decode(out)
255+
if err != nil {
256+
if err == io.EOF {
257+
break
258+
}
259+
return err
260+
}
261+
}
262+
return nil
263+
}

0 commit comments

Comments
 (0)