Skip to content
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

feat: Add chezmoi:template:format-indent template directive #4163

Merged
merged 2 commits into from
Dec 29, 2024
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
27 changes: 27 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,33 @@ 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=$STRING

to set the indent to be the literal `$STRING`, or

chezmoi:template:format-indent-width=$WIDTH

to set the indent to be `$WIDTH` spaces.

!!! example

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

!!! example

```
{{/* chezmoi:template:format-indent-width=4 */}}
{{ dict "key" "value" | toYaml }}
```

## 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
18 changes: 8 additions & 10 deletions internal/chezmoi/sourcestate.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,9 +798,10 @@ type ExecuteTemplateDataOptions struct {
// ExecuteTemplateData returns the result of executing template data.
func (s *SourceState) ExecuteTemplateData(options ExecuteTemplateDataOptions) ([]byte, error) {
templateOptions := options.TemplateOptions
templateOptions.Funcs = s.templateFuncs
templateOptions.Options = slices.Clone(s.templateOptions)

tmpl, err := ParseTemplate(options.Name, options.Data, s.templateFuncs, templateOptions)
tmpl, err := ParseTemplate(options.Name, options.Data, templateOptions)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1529,7 +1530,8 @@ func (s *SourceState) addTemplatesDir(ctx context.Context, templatesDirAbsPath A
templateRelPath := templateAbsPath.MustTrimDirPrefix(templatesDirAbsPath)
name := templateRelPath.String()

tmpl, err := ParseTemplate(name, contents, s.templateFuncs, TemplateOptions{
tmpl, err := ParseTemplate(name, contents, TemplateOptions{
Funcs: s.templateFuncs,
Options: slices.Clone(s.templateOptions),
})
if err != nil {
Expand Down Expand Up @@ -1955,14 +1957,10 @@ func (s *SourceState) newModifyTargetStateEntryFunc(
sourceFile := sourceRelPath.String()
templateContents := removeMatches(modifierContents, matches)
var tmpl *Template
tmpl, err = ParseTemplate(
sourceFile,
templateContents,
s.templateFuncs,
TemplateOptions{
Options: slices.Clone(s.templateOptions),
},
)
tmpl, err = ParseTemplate(sourceFile, templateContents, TemplateOptions{
Funcs: s.templateFuncs,
Options: slices.Clone(s.templateOptions),
})
if err != nil {
return
}
Expand Down
31 changes: 28 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,36 @@ func TestTemplateOptionsParseDirectives(t *testing.T) {
LineEnding: "\n",
},
},
{
name: "format_indent_string",
dataStr: `chezmoi:template:format-indent="\t"`,
expected: TemplateOptions{
FormatIndent: "\t",
},
},
{
name: "format_indent_width_number",
dataStr: `chezmoi:template:format-indent-width=2`,
expected: TemplateOptions{
FormatIndent: " ",
},
},
{
name: "format_indent_width_number_error",
dataStr: `chezmoi:template:format-indent-width=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
60 changes: 55 additions & 5 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 @@ -17,6 +23,8 @@ 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 @@ -25,8 +33,42 @@ type TemplateOptions struct {

// ParseTemplate parses a template named name from data with the given funcs and
// templateOptions.
func ParseTemplate(name string, data []byte, funcs template.FuncMap, options TemplateOptions) (*Template, error) {
contents := options.parseAndRemoveDirectives(data)
func ParseTemplate(name string, data []byte, options TemplateOptions) (*Template, error) {
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).
Expand Down Expand Up @@ -70,10 +112,10 @@ 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.
Expand All @@ -83,6 +125,14 @@ func (o *TemplateOptions) parseAndRemoveDirectives(data []byte) []byte {
key := string(keyValuePairMatch[1])
value := maybeUnquote(string(keyValuePairMatch[2]))
switch key {
case "format-indent":
o.FormatIndent = value
case "format-indent-width":
width, err := strconv.Atoi(value)
if err != nil {
return nil, err
}
o.FormatIndent = strings.Repeat(" ", width)
case "left-delimiter":
o.LeftDelimiter = value
case "line-ending", "line-endings":
Expand All @@ -104,7 +154,7 @@ func (o *TemplateOptions) parseAndRemoveDirectives(data []byte) []byte {
}
}

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

// removeMatches returns data with matchesIndexes removed.
Expand Down
2 changes: 1 addition & 1 deletion internal/chezmoi/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestTemplateParseAndExecute(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
tmpl, err := ParseTemplate(tc.name, []byte(tc.dataStr), nil, TemplateOptions{})
tmpl, err := ParseTemplate(tc.name, []byte(tc.dataStr), TemplateOptions{})
assert.NoError(t, err)
actual, err := tmpl.Execute(nil)
assert.NoError(t, err)
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-width=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-width=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
10 changes: 6 additions & 4 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,8 @@ func (c *Config) createConfigFile(filename chezmoi.RelPath, data []byte, cmd *co
}
chezmoi.RecursiveMerge(c.templateFuncs, initTemplateFuncs)

tmpl, err := chezmoi.ParseTemplate(filename.String(), data, c.templateFuncs, chezmoi.TemplateOptions{
tmpl, err := chezmoi.ParseTemplate(filename.String(), data, chezmoi.TemplateOptions{
Funcs: c.templateFuncs,
Options: slices.Clone(c.Template.Options),
})
if err != nil {
Expand Down Expand Up @@ -1594,8 +1595,8 @@ func (c *Config) gitAutoPush(status *chezmoigit.Status) error {

// gitCommitMessage returns the git commit message for the given status.
func (c *Config) gitCommitMessage(cmd *cobra.Command, status *chezmoigit.Status) ([]byte, error) {
funcMap := maps.Clone(c.templateFuncs)
maps.Copy(funcMap, map[string]any{
templateFuncs := maps.Clone(c.templateFuncs)
maps.Copy(templateFuncs, map[string]any{
"promptBool": c.promptBoolInteractiveTemplateFunc,
"promptChoice": c.promptChoiceInteractiveTemplateFunc,
"promptInt": c.promptIntInteractiveTemplateFunc,
Expand Down Expand Up @@ -1625,7 +1626,8 @@ func (c *Config) gitCommitMessage(cmd *cobra.Command, status *chezmoigit.Status)
name = "COMMIT_MESSAGE"
commitMessageTemplateData = []byte(templates.CommitMessageTmpl)
}
commitMessageTmpl, err := chezmoi.ParseTemplate(name, commitMessageTemplateData, funcMap, chezmoi.TemplateOptions{
commitMessageTmpl, err := chezmoi.ParseTemplate(name, commitMessageTemplateData, chezmoi.TemplateOptions{
Funcs: templateFuncs,
Options: slices.Clone(c.Template.Options),
})
if err != nil {
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/templatefuncs.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,10 @@ func (c *Config) includeTemplateTemplateFunc(filename string, args ...any) strin
}
contents := mustValue(c.readFile(filename, searchDirAbsPaths))

templateOptions := chezmoi.TemplateOptions{
tmpl := mustValue(chezmoi.ParseTemplate(filename, contents, chezmoi.TemplateOptions{
Funcs: c.templateFuncs,
Options: slices.Clone(c.Template.Options),
}
tmpl := mustValue(chezmoi.ParseTemplate(filename, contents, c.templateFuncs, templateOptions))
}))

return string(mustValue(tmpl.Execute(data)))
}
Expand Down
Loading