Skip to content

improve flags #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/bin
tpl-*-amd64*
*.tar.gz
assets/ideas.md
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

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.
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.

## Synopsis

Expand All @@ -28,7 +28,6 @@ The input data is read from stdin via pipe or redirection. It is actually not re
tpl '{{ . }}' < path/to/input.json
# Pipe
curl localhost | tpl '{{ . }}'

# nil data
tpl '{{ . }}'
```
Expand All @@ -37,31 +36,37 @@ tpl '{{ . }}'

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.

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.
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.

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

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.
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.

```bash
tpl '{{ . }}' --file foo.tpl --glob templates/*.tpl # default will be used
tpl --file foo.tpl # foo.tpl will be used
tpl --file foo.tpl --glob templates/*.tpl --name foo.tpl # the --name flag is required to select a template by name
tpl '{{ . }}' --file foo.tpl '--glob templates/*.tpl' # default will be used
tpl --file foo.tpl # foo.tpl will be used
tpl --file foo.tpl --glob 'templates/*.tpl' --name foo.tpl # the --name flag is required to select a template by name
```

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.

note globs need to quotes to avoid shell expansion.

## Decoders

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:

- json
- yaml
- toml
- xml

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

## Options

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).
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.

## Functions

Next to the builtin functions, sSprig functions](http://masterminds.github.io/sprig/) and [treasure-map functions](https://github.com/bluebrown/treasure-map) are available.
Expand Down
4 changes: 2 additions & 2 deletions assets/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ curl -s https://jsonplaceholder.typicode.com/todos | tpl '{{ table . }}'
## Convert YAML to JSON

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

## Create a Certificate

```bash
tpl -t assets/examples/cert.yaml.tpl
tpl -f assets/examples/cert.yaml.tpl
```
263 changes: 263 additions & 0 deletions cmd/tpl/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"text/template"

"github.com/BurntSushi/toml"
"github.com/icza/dyno"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"

"github.com/Masterminds/sprig/v3"
"github.com/bluebrown/treasure-map/textfunc"
)

var (
version = "0.2.0"
commit = "unknown"
)

var (
files []string
globs []string
templateName string
options []string
decoder decoderkind = decoderkindJSON
noNewline bool
)

const defaultTemplateName = "_gotpl_default"

var decoderMap = map[decoderkind]decode{
decoderkindJSON: decodeJson,
decoderkindYAML: decodeYaml,
decoderkindTOML: decodeToml,
}

func setFlagUsage() {
pflag.CommandLine.SortFlags = false
pflag.Usage = func() {
fmt.Fprintln(os.Stderr, `Usage: tpl [--file PATH]... [--glob PATTERN]... [--name TEMPLATE_NAME]
[--decoder DECODER_NAME] [--option KEY=VALUE]... [--no-newline] [TEMPLATE...] [-]
[--help] [--usage] [--version]`)
}
}

func helptext() {
fmt.Fprintln(os.Stderr, "Usage: tpl [options] [templates]")
fmt.Fprintln(os.Stderr, "Options:")
pflag.PrintDefaults()
fmt.Fprintln(os.Stderr, "Examples")
fmt.Fprintln(os.Stderr, " tpl '{{ . }}' < data.json")
fmt.Fprintln(os.Stderr, " tpl --file my-template.tpl < data.json")
fmt.Fprintln(os.Stderr, " tpl --glob 'templates/*' --name foo.tpl < data.json")
}

func parseFlags() {
var showHelp bool
var showUsage bool
var showVersion bool
pflag.StringArrayVarP(&files, "file", "f", []string{}, "template file path. Can be specified multiple times")
pflag.StringArrayVarP(&globs, "glob", "g", []string{}, "template file glob. Can be specified multiple times")
pflag.StringVarP(&templateName, "name", "n", "", "if specified, execute the template with the given name")
pflag.VarP(&decoder, "decoder", "d", "decoder to use for input data. Supported values: json, yaml, toml")
pflag.StringArrayVar(&options, "option", []string{}, "option to pass to the template engine. Can be specified multiple times")
pflag.BoolVar(&noNewline, "no-newline", false, "do not print newline at the end of the output")
pflag.BoolVarP(&showHelp, "help", "h", false, "show the help text")
pflag.BoolVar(&showUsage, "usage", false, "show the short usage text")
pflag.BoolVarP(&showVersion, "version", "v", false, "show the version")
pflag.Parse()
if showHelp {
helptext()
os.Exit(0)
}
if showUsage {
pflag.Usage()
os.Exit(0)
}
if showVersion {
fmt.Fprintf(os.Stderr, "version %s - commit %s\n", version, commit)
os.Exit(0)
}
}

func main() {
setFlagUsage()
parseFlags()

err := run()
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
pflag.Usage()
fmt.Fprintf(os.Stdout, "%v\n", err)
os.Exit(2)
}

// add a newline if the --no-newline flag was not set
if !noNewline {
fmt.Println()
}
}

func run() (err error) {
// create the root template
tpl := template.New(defaultTemplateName)
tpl.Option(options...)
tpl.Funcs(textfunc.MapClosure(sprig.TxtFuncMap(), tpl))

// parse the arguments
for _, arg := range pflag.Args() {
tpl, err = tpl.Parse(arg)
if err != nil {
return fmt.Errorf("error to parsing template: %v", err)
}
}

// parse files and globs in the order they were specified
// to align with go's template package
fileIndex := 0
globIndex := 0
for _, arg := range os.Args[1:] {
if arg == "-f" || arg == "--file" {
// parse next file
file := files[fileIndex]
tpl, err = tpl.ParseFiles(file)
if err != nil {
return fmt.Errorf("error parsing file %s: %v", file, err)
}
fileIndex++
continue
}
if arg == "-g" || arg == "--glob" {
// parse next glob
glob := globs[globIndex]
tpl, err = tpl.ParseGlob(glob)
if err != nil {
return fmt.Errorf("error parsing glob %s: %v", glob, err)
}
globIndex++
continue
}
}

// defined templates
templates := tpl.Templates()

// if there are no templates, return an error
if len(templates) == 0 {
return errors.New("no templates found")
}

// determine the template to use
if templateName == "" {
if len(pflag.Args()) > 0 {
templateName = defaultTemplateName
} else if len(templates) == 1 {
templateName = templates[0].Name()
} else {
return errors.New(fmt.Sprintf(
"the --name flag is required when multiple templates are defined and no default template exists%s",
tpl.DefinedTemplates(),
))
}
}

// execute the template
// read the input from stdin
info, err := os.Stdin.Stat()
if err != nil {
return fmt.Errorf("error reading stdin: %v", err)
}

// data is used to store the decoded input
var data any

// if we are reading from stdin, decode the input
if info.Mode()&os.ModeCharDevice == 0 {
if err := decoderMap[decoder](os.Stdin, &data); err != nil {
return fmt.Errorf("error decoding input: %v", err)
}
}

// execute the template with the given name
// and optional data from stdin
if err := tpl.ExecuteTemplate(os.Stdout, templateName, data); err != nil {
return fmt.Errorf("error executing template: %v", err)
}

return nil
}

type decoderkind string // json, yaml, toml

const (
decoderkindJSON decoderkind = "json"
decoderkindYAML decoderkind = "yaml"
decoderkindTOML decoderkind = "toml"
)

func (d *decoderkind) Set(s string) error {
switch s {
case "json", "yaml", "toml":
*d = decoderkind(s)
return nil
default:
return fmt.Errorf(
"invalid decoder kind: %s, supported value are: %s, %s, %s",
s,
decoderkindJSON,
decoderkindYAML,
decoderkindTOML,
)
}
}

func (d *decoderkind) String() string {
return string(*d)
}

func (d *decoderkind) Type() string {
return "string"
}

type decode func(io.Reader, *any) error

func decodeYaml(in io.Reader, out *any) error {
dec := yaml.NewDecoder(in)
for {
err := dec.Decode(out)
if err != nil {
if err == io.EOF {
break
}
return err
}
}
*out = dyno.ConvertMapI2MapS(*out)
return nil
}

func decodeToml(in io.Reader, out *any) error {
dec := toml.NewDecoder(in)
_, err := dec.Decode(out)
return err
}

func decodeJson(in io.Reader, out *any) error {
dec := json.NewDecoder(in)
for {
err := dec.Decode(out)
if err != nil {
if err == io.EOF {
break
}
return err
}
}
return nil
}
Loading