Skip to content

Commit

Permalink
feat: Add chezmoi:template:format-indent template directive
Browse files Browse the repository at this point in the history
  • Loading branch information
twpayne committed Dec 28, 2024
1 parent 2e8bbb7 commit 60c1bb2
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 12 deletions.
16 changes: 16 additions & 0 deletions assets/chezmoi.io/docs/reference/templates/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ inherited by templates called from the file.
# [[ "true" ]]
```

## Format indent

By default, chezmoi's `toJson`, `toToml`, and `toYaml` template functions use
the default indent of two spaces. The indent can be overidden with:

chezmoi:template:format-indent=$VALUE

`$VALUE` can be an integer number of spaces or a string.

!!! example

```
{{/* chezmoi:template:format-indent="\t" */}}
{{ dict "key" "value" | toJson }}
```

## Line endings

Many of the template functions available in chezmoi primarily use UNIX-style
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
github.com/itchyny/gojq v0.12.17
github.com/klauspost/compress v1.17.11
github.com/mattn/go-runewidth v0.0.16
github.com/mitchellh/copystructure v1.2.0
github.com/mitchellh/mapstructure v1.5.0
github.com/muesli/combinator v0.3.0
Expand Down Expand Up @@ -131,7 +132,6 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
Expand Down
36 changes: 33 additions & 3 deletions internal/chezmoi/sourcestate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1798,6 +1798,7 @@ func TestTemplateOptionsParseDirectives(t *testing.T) {
name string
dataStr string
expected TemplateOptions
expectedErr string
expectedDataStr string
}{
{
Expand Down Expand Up @@ -1957,12 +1958,41 @@ func TestTemplateOptionsParseDirectives(t *testing.T) {
LineEnding: "\n",
},
},
{
name: "format_indent_string",
dataStr: `chezmoi:template:format-indent="\t"`,
expected: TemplateOptions{
FormatIndent: "\t",
},
},
{
name: "format_indent_string_error",
dataStr: `chezmoi:template:format-indent="\t`,
expectedErr: "invalid syntax",
},
{
name: "format_indent_number",
dataStr: `chezmoi:template:format-indent=2`,
expected: TemplateOptions{
FormatIndent: " ",
},
},
{
name: "format_indent_number_error",
dataStr: `chezmoi:template:format-indent=x`,
expectedErr: `strconv.Atoi: parsing "x": invalid syntax`,
},
} {
t.Run(tc.name, func(t *testing.T) {
var actual TemplateOptions
actualData := actual.parseAndRemoveDirectives([]byte(tc.dataStr))
assert.Equal(t, tc.expected, actual)
assert.Equal(t, tc.expectedDataStr, string(actualData))
actualData, err := actual.parseAndRemoveDirectives([]byte(tc.dataStr))
if tc.expectedErr != "" {
assert.EqualError(t, err, tc.expectedErr)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, actual)
assert.Equal(t, tc.expectedDataStr, string(actualData))
}
})
}
}
Expand Down
72 changes: 64 additions & 8 deletions internal/chezmoi/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ package chezmoi

import (
"bytes"
"encoding/json"
"maps"
"strconv"
"strings"
"text/template"

"github.com/mattn/go-runewidth"
"github.com/mitchellh/copystructure"
"github.com/pelletier/go-toml/v2"
"gopkg.in/yaml.v3"
)

// A Template extends text/template.Template with support for directives.
Expand All @@ -18,6 +24,7 @@ type Template struct {
// TemplateOptions are template options that can be set with directives.
type TemplateOptions struct {
Funcs template.FuncMap
FormatIndent string
LeftDelimiter string
LineEnding string
RightDelimiter string
Expand All @@ -27,11 +34,45 @@ type TemplateOptions struct {
// ParseTemplate parses a template named name from data with the given funcs and
// templateOptions.
func ParseTemplate(name string, data []byte, options TemplateOptions) (*Template, error) {
contents := options.parseAndRemoveDirectives(data)
contents, err := options.parseAndRemoveDirectives(data)
if err != nil {
return nil, err
}
funcs := options.Funcs
if options.FormatIndent != "" {
funcs = maps.Clone(funcs)
funcs["toJson"] = func(data any) string {
var builder strings.Builder
encoder := json.NewEncoder(&builder)
encoder.SetIndent("", options.FormatIndent)
if err := encoder.Encode(data); err != nil {
panic(err)
}
return builder.String()
}
funcs["toToml"] = func(data any) string {
var builder strings.Builder
encoder := toml.NewEncoder(&builder)
encoder.SetIndentSymbol(options.FormatIndent)
if err := encoder.Encode(data); err != nil {
panic(err)
}
return builder.String()
}
funcs["toYaml"] = func(data any) string {
var builder strings.Builder
encoder := yaml.NewEncoder(&builder)
encoder.SetIndent(runewidth.StringWidth(options.FormatIndent))
if err := encoder.Encode(data); err != nil {
panic(err)
}
return builder.String()
}
}
tmpl, err := template.New(name).
Option(options.Options...).
Delims(options.LeftDelimiter, options.RightDelimiter).
Funcs(options.Funcs).
Funcs(funcs).
Parse(string(contents))
if err != nil {
return nil, err
Expand Down Expand Up @@ -71,20 +112,34 @@ func (t *Template) Execute(data any) ([]byte, error) {
// parseAndRemoveDirectives updates o by parsing all template directives in data
// and returns data with the lines containing directives removed. The lines are
// removed so that any delimiters do not break template parsing.
func (o *TemplateOptions) parseAndRemoveDirectives(data []byte) []byte {
func (o *TemplateOptions) parseAndRemoveDirectives(data []byte) ([]byte, error) {
directiveMatches := templateDirectiveRx.FindAllSubmatchIndex(data, -1)
if directiveMatches == nil {
return data
return data, nil
}

// Parse options from directives.
for _, directiveMatch := range directiveMatches {
keyValuePairMatches := templateDirectiveKeyValuePairRx.FindAllSubmatch(data[directiveMatch[2]:directiveMatch[3]], -1)
for _, keyValuePairMatch := range keyValuePairMatches {
key := string(keyValuePairMatch[1])
value := maybeUnquote(string(keyValuePairMatch[2]))
switch key {
case "format-indent":
if strings.HasPrefix(string(keyValuePairMatch[2]), `"`) {
var err error
o.FormatIndent, err = strconv.Unquote(string(keyValuePairMatch[2]))
if err != nil {
return nil, err
}
} else {
n, err := strconv.Atoi(string(keyValuePairMatch[2]))
if err != nil {
return nil, err
}
o.FormatIndent = strings.Repeat(" ", n)
}
case "left-delimiter":
value := maybeUnquote(string(keyValuePairMatch[2]))
o.LeftDelimiter = value
case "line-ending", "line-endings":
switch string(keyValuePairMatch[2]) {
Expand All @@ -95,17 +150,18 @@ func (o *TemplateOptions) parseAndRemoveDirectives(data []byte) []byte {
case "native":
o.LineEnding = nativeLineEnding
default:
o.LineEnding = value
o.LineEnding = maybeUnquote(string(keyValuePairMatch[2]))
}
case "right-delimiter":
o.RightDelimiter = value
o.RightDelimiter = maybeUnquote(string(keyValuePairMatch[2]))
case "missing-key":
value := maybeUnquote(string(keyValuePairMatch[2]))
o.Options = append(o.Options, "missingkey="+value)
}
}
}

return removeMatches(data, directiveMatches)
return removeMatches(data, directiveMatches), nil
}

// removeMatches returns data with matchesIndexes removed.
Expand Down
37 changes: 37 additions & 0 deletions internal/cmd/catcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ func TestCatCmd(t *testing.T) {
"ok",
),
},
{
name: "json_indent",
root: map[string]any{
"/home/user/.local/share/chezmoi/dot_template.tmpl": chezmoitest.JoinLines(
`# chezmoi:template:format-indent=3`,
`{{ dict "a" (dict "b" "c") | toJson }}`,
),
},
args: []string{
"/home/user/.template",
},
expectedStr: chezmoitest.JoinLines(
`{`,
` "a": {`,
` "b": "c"`,
` }`,
`}`,
``,
),
},
{
name: "yaml_indent",
root: map[string]any{
"/home/user/.local/share/chezmoi/dot_template.tmpl": chezmoitest.JoinLines(
`# chezmoi:template:format-indent=3`,
`{{ dict "a" (dict "b" "c") | toYaml }}`,
),
},
args: []string{
"/home/user/.template",
},
expectedStr: chezmoitest.JoinLines(
`a:`,
` b: c`,
``,
),
},
} {
t.Run(tc.name, func(t *testing.T) {
chezmoitest.WithTestFS(t, tc.root, func(fileSystem vfs.FS) {
Expand Down

0 comments on commit 60c1bb2

Please sign in to comment.