From 7ff0acae94a4074454495020ba90dd0c69b2ca87 Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Tue, 7 Feb 2023 23:42:44 +0100 Subject: [PATCH] feat: Add pruneEmptyDicts template function --- .../templates/functions/pruneEmptyDicts.md | 11 +++++++ assets/chezmoi.io/mkdocs.yml | 1 + pkg/cmd/config.go | 1 + pkg/cmd/templatefuncs.go | 20 +++++++++++ pkg/cmd/templatefuncs_test.go | 33 +++++++++++++++++++ pkg/cmd/testdata/scripts/templatefuncs.txtar | 7 ++++ 6 files changed, 73 insertions(+) create mode 100644 assets/chezmoi.io/docs/reference/templates/functions/pruneEmptyDicts.md diff --git a/assets/chezmoi.io/docs/reference/templates/functions/pruneEmptyDicts.md b/assets/chezmoi.io/docs/reference/templates/functions/pruneEmptyDicts.md new file mode 100644 index 00000000000..e1ebbbb005a --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/pruneEmptyDicts.md @@ -0,0 +1,11 @@ +# `pruneEmptyDicts` *dict* + +`pruneEmptyDicts` modifies *dict* to remove nested empty dicts. Properties are +pruned from the bottom up, so any nested dicts that themselves only contain +empty dicts are pruned. + +!!! example + + ``` + {{ dict "key" "value" inner (dict) | pruneEmptyDicts | toJson }} + ``` diff --git a/assets/chezmoi.io/mkdocs.yml b/assets/chezmoi.io/mkdocs.yml index bf9d816bcf4..2c9da43f4d9 100644 --- a/assets/chezmoi.io/mkdocs.yml +++ b/assets/chezmoi.io/mkdocs.yml @@ -193,6 +193,7 @@ nav: - lstat: reference/templates/functions/lstat.md - mozillaInstallHash: reference/templates/functions/mozillaInstallHash.md - output: reference/templates/functions/output.md + - pruneEmptyDicts: reference/templates/functions/pruneEmptyDicts.md - quoteList: reference/templates/functions/quoteList.md - replaceAllRegex: reference/templates/functions/replaceAllRegex.md - setValueAtPath: reference/templates/functions/setValueAtPath.md diff --git a/pkg/cmd/config.go b/pkg/cmd/config.go index cbd9a7f534e..4b82584581c 100644 --- a/pkg/cmd/config.go +++ b/pkg/cmd/config.go @@ -396,6 +396,7 @@ func newConfig(options ...configOption) (*Config, error) { "passFields": c.passFieldsTemplateFunc, "passRaw": c.passRawTemplateFunc, "passhole": c.passholeTemplateFunc, + "pruneEmptyDicts": c.pruneEmptyDictsTemplateFunc, "quoteList": c.quoteListTemplateFunc, "replaceAllRegex": c.replaceAllRegexTemplateFunc, "secret": c.secretTemplateFunc, diff --git a/pkg/cmd/templatefuncs.go b/pkg/cmd/templatefuncs.go index d07afc7eb1c..b5e14978ac2 100644 --- a/pkg/cmd/templatefuncs.go +++ b/pkg/cmd/templatefuncs.go @@ -312,6 +312,11 @@ func (c *Config) outputTemplateFunc(name string, args ...string) string { return string(output) } +func (c *Config) pruneEmptyDictsTemplateFunc(dict map[string]any) map[string]any { + pruneEmptyMaps(dict) + return dict +} + func (c *Config) quoteListTemplateFunc(list []any) []string { result := make([]string, 0, len(list)) for _, elem := range list { @@ -587,6 +592,21 @@ func needsQuote(s string) bool { return false } +// pruneEmptyMaps prunes all empty maps from m and returns if m is now empty +// itself. +func pruneEmptyMaps(m map[string]any) bool { + for key, value := range m { + nestedMap, ok := value.(map[string]any) + if !ok { + continue + } + if pruneEmptyMaps(nestedMap) { + delete(m, key) + } + } + return len(m) == 0 +} + func sortedKeys[K constraints.Ordered, V any](m map[K]V) []K { keys := maps.Keys(m) slices.Sort(keys) diff --git a/pkg/cmd/templatefuncs_test.go b/pkg/cmd/templatefuncs_test.go index ca1aa7dd1da..f18887ece40 100644 --- a/pkg/cmd/templatefuncs_test.go +++ b/pkg/cmd/templatefuncs_test.go @@ -160,6 +160,39 @@ func TestDeleteValueAtPathTemplateFunc(t *testing.T) { } } +func TestPruneEmptyDicts(t *testing.T) { + for _, tc := range []struct { + name string + dict map[string]any + expected map[string]any + }{ + { + name: "nil", + dict: nil, + expected: nil, + }, + { + name: "empty", + dict: map[string]any{}, + expected: map[string]any{}, + }, + { + name: "nested_empty", + dict: map[string]any{ + "key1": map[string]any{}, + "key2": 0, + }, + expected: map[string]any{ + "key2": 0, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, (&Config{}).pruneEmptyDictsTemplateFunc(tc.dict)) + }) + } +} + func TestSetValueAtPathTemplateFunc(t *testing.T) { for _, tc := range []struct { name string diff --git a/pkg/cmd/testdata/scripts/templatefuncs.txtar b/pkg/cmd/testdata/scripts/templatefuncs.txtar index 22f5d1672dd..5fb0dc5d56d 100644 --- a/pkg/cmd/testdata/scripts/templatefuncs.txtar +++ b/pkg/cmd/testdata/scripts/templatefuncs.txtar @@ -84,6 +84,11 @@ stdout 2656FF1E876E9973 [!(windows||illumos)] stderr 'error calling output: .*/false: exit status 1' [illumos] stderr 'error calling output: .*/false: exit status 255' +# test pruneEmptyDicts template function +exec chezmoi execute-template '{{ dict "key1" "value1" "key2" (dict) | pruneEmptyDicts | toJson }}' +rmfinalnewline golden/pruneEmptyDicts +cmp stdout golden/pruneEmptyDicts + # test replaceAllRegex template function exec chezmoi execute-template '{{ "foo bar baz" | replaceAllRegex "ba" "BA" }}' stdout 'foo BAr BAz' @@ -191,6 +196,8 @@ file2.txt # contents of .include -- golden/include-relpath -- # contents of .local/share/chezmoi/.include +-- golden/pruneEmptyDicts -- +{"key1":"value1"} -- golden/setValueAtPath -- {"key1":{"key2":"value2"}} -- golden/toIni --