Skip to content

Commit

Permalink
feat: Restore --autotemplate flag to add command
Browse files Browse the repository at this point in the history
This reverts commit ed0c798.
  • Loading branch information
twpayne committed Aug 13, 2023
1 parent 4e67850 commit a028598
Show file tree
Hide file tree
Showing 7 changed files with 436 additions and 0 deletions.
13 changes: 13 additions & 0 deletions assets/chezmoi.io/docs/reference/commands/add.md
Expand Up @@ -4,6 +4,19 @@ Add *target*s to the source state. If any target is already in the source
state, then its source state is replaced with its current state in the
destination directory.

## `--autotemplate` (deprecated)

Automatically generate a template by replacing strings that match variable
values from the `data` section of the config file with their respective config
names as a template string. Longer substitutions occur before shorter ones.
This implies the `--template` option.

!!! warning

`--autotemplate` uses a greedy algorithm which occasionally generates
templates with unwanted variable substitutions. Carefully review any
templates it generates.

## `--encrypt`

Encrypt files using the defined encryption method.
Expand Down
139 changes: 139 additions & 0 deletions internal/chezmoi/autotemplate.go
@@ -0,0 +1,139 @@
package chezmoi

import (
"regexp"
"sort"
"strings"

"golang.org/x/exp/slices"
)

// A templateVariable is a template variable. It is used instead of a
// map[string]string so that we can control order.
type templateVariable struct {
components []string
value string
}

var templateMarkerRx = regexp.MustCompile(`\{{2,}|\}{2,}`)

// autoTemplate converts contents into a template by escaping template markers
// and replacing values in data with their keys. It returns the template and if
// any replacements were made.
func autoTemplate(contents []byte, data map[string]any) ([]byte, bool) {
contentsStr := string(contents)
replacements := false

// Replace template markers.
replacedTemplateMarkersStr := templateMarkerRx.ReplaceAllString(contentsStr, `{{ "$0" }}`)
if replacedTemplateMarkersStr != contentsStr {
contentsStr = replacedTemplateMarkersStr
replacements = true
}

// Determine the priority order of replacements.
//
// Replace longest values first. If there are multiple matches for the same
// length of value, then choose the shallowest first so that .variable is
// preferred over .chezmoi.config.data.variable. If there are multiple
// matches at the same depth, chose the variable that comes first
// alphabetically.
variables := extractVariables(data)
sort.Slice(variables, func(i, j int) bool {
// First sort by value length, longest first.
valueI := variables[i].value
valueJ := variables[j].value
switch {
case len(valueI) > len(valueJ):
return true
case len(valueI) == len(valueJ):
// Second sort by value name depth, shallowest first.
componentsI := variables[i].components
componentsJ := variables[j].components
switch {
case len(componentsI) < len(componentsJ):
return true
case len(componentsI) == len(componentsJ):
// Thirdly, sort by component names in alphabetical order.
return slices.Compare(componentsI, componentsJ) < 0
default:
return false
}
default:
return false
}
})

// Replace variables in order.
//
// This naive approach will generate incorrect templates if the variable
// names match variable values. The algorithm here is probably O(N^2), we
// can do better.
for _, variable := range variables {
if variable.value == "" {
continue
}

index := strings.Index(contentsStr, variable.value)
for index != -1 && index != len(contentsStr) {
if !inWord(contentsStr, index) && !inWord(contentsStr, index+len(variable.value)) {
// Replace variable.value which is on word boundaries at both
// ends.
replacement := "{{ ." + strings.Join(variable.components, ".") + " }}"
contentsStr = contentsStr[:index] + replacement + contentsStr[index+len(variable.value):]
index += len(replacement)
replacements = true
} else {
// Otherwise, keep looking. Consume at least one byte so we make
// progress.
index++
}

// Look for the next occurrence of variable.value.
j := strings.Index(contentsStr[index:], variable.value)
if j == -1 {
// No more occurrences found, so terminate the loop.
break
}
// Advance to the next occurrence.
index += j
}
}

return []byte(contentsStr), replacements
}

// appendVariables appends all template variables in data to variables
// and returns variables. data is assumed to be rooted at parent.
func appendVariables(
variables []templateVariable, parent []string, data map[string]any,
) []templateVariable {
for name, value := range data {
switch value := value.(type) {
case string:
variable := templateVariable{
components: append(slices.Clone(parent), name),
value: value,
}
variables = append(variables, variable)
case map[string]any:
variables = appendVariables(variables, append(parent, name), value)
}
}
return variables
}

// extractVariables extracts all template variables from data.
func extractVariables(data map[string]any) []templateVariable {
return appendVariables(nil, nil, data)
}

// inWord returns true if splitting s at position i would split a word.
func inWord(s string, i int) bool {
return i > 0 && i < len(s) && isWord(s[i-1]) && isWord(s[i])
}

// isWord returns true if b is a word byte.
func isWord(b byte) bool {
return '0' <= b && b <= '9' || 'A' <= b && b <= 'Z' || 'a' <= b && b <= 'z'
}
214 changes: 214 additions & 0 deletions internal/chezmoi/autotemplate_test.go
@@ -0,0 +1,214 @@
package chezmoi

import (
"testing"

"github.com/alecthomas/assert/v2"
)

func TestAutoTemplate(t *testing.T) {
for _, tc := range []struct {
name string
contentsStr string
data map[string]any
expected string
expectedReplacements bool
}{
{
name: "simple",
contentsStr: "email = you@example.com\n",
data: map[string]any{
"email": "you@example.com",
},
expected: "email = {{ .email }}\n",
expectedReplacements: true,
},
{
name: "longest_first",
contentsStr: "name = John Smith\nfirstName = John\n",
data: map[string]any{
"name": "John Smith",
"firstName": "John",
},
expected: "" +
"name = {{ .name }}\n" +
"firstName = {{ .firstName }}\n",
expectedReplacements: true,
},
{
name: "alphabetical_first",
contentsStr: "name = John Smith\n",
data: map[string]any{
"alpha": "John Smith",
"beta": "John Smith",
"gamma": "John Smith",
},
expected: "name = {{ .alpha }}\n",
expectedReplacements: true,
},
{
name: "nested_values",
contentsStr: "email = you@example.com\n",
data: map[string]any{
"personal": map[string]any{
"email": "you@example.com",
},
},
expected: "email = {{ .personal.email }}\n",
expectedReplacements: true,
},
{
name: "only_replace_words",
contentsStr: "darwinian evolution",
data: map[string]any{
"os": "darwin",
},
expected: "darwinian evolution", // not "{{ .os }}ian evolution"
},
{
name: "longest_match_first",
contentsStr: "/home/user",
data: map[string]any{
"homeDir": "/home/user",
},
expected: "{{ .homeDir }}",
expectedReplacements: true,
},
{
name: "longest_match_first_prefix",
contentsStr: "HOME=/home/user",
data: map[string]any{
"homeDir": "/home/user",
},
expected: "HOME={{ .homeDir }}",
expectedReplacements: true,
},
{
name: "longest_match_first_suffix",
contentsStr: "/home/user/something",
data: map[string]any{
"homeDir": "/home/user",
},
expected: "{{ .homeDir }}/something",
expectedReplacements: true,
},
{
name: "longest_match_first_prefix_and_suffix",
contentsStr: "HOME=/home/user/something",
data: map[string]any{
"homeDir": "/home/user",
},
expected: "HOME={{ .homeDir }}/something",
expectedReplacements: true,
},
{
name: "depth_first",
contentsStr: "a",
data: map[string]any{
"deep": map[string]any{
"deeper": "a",
},
"shallow": "a",
},
expected: "{{ .shallow }}",
expectedReplacements: true,
},
{
name: "alphabetical_first",
contentsStr: "a",
data: map[string]any{
"parent": map[string]any{
"alpha": "a",
"beta": "a",
},
},
expected: "{{ .parent.alpha }}",
expectedReplacements: true,
},
{
name: "words_only",
contentsStr: "aaa aa a aa aaa aa a aa aaa",
data: map[string]any{
"alpha": "a",
},
expected: "aaa aa {{ .alpha }} aa aaa aa {{ .alpha }} aa aaa",
expectedReplacements: true,
},
{
name: "words_only_2",
contentsStr: "aaa aa a aa aaa aa a aa aaa",
data: map[string]any{
"alpha": "aa",
},
expected: "aaa {{ .alpha }} a {{ .alpha }} aaa {{ .alpha }} a {{ .alpha }} aaa",
expectedReplacements: true,
},
{
name: "words_only_3",
contentsStr: "aaa aa a aa aaa aa a aa aaa",
data: map[string]any{
"alpha": "aaa",
},
expected: "{{ .alpha }} aa a aa {{ .alpha }} aa a aa {{ .alpha }}",
expectedReplacements: true,
},
{
name: "skip_empty",
contentsStr: "a",
data: map[string]any{
"empty": "",
},
expected: "a",
},
{
name: "markers",
contentsStr: "{{}}",
expected: `{{ "{{" }}{{ "}}" }}`,
expectedReplacements: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
actualTemplate, actualReplacements := autoTemplate([]byte(tc.contentsStr), tc.data)
assert.Equal(t, tc.expected, string(actualTemplate))
assert.Equal(t, tc.expectedReplacements, actualReplacements)
})
}
}

func TestInWord(t *testing.T) {
for _, tc := range []struct {
s string
i int
expected bool
}{
{s: "", i: 0, expected: false},
{s: "a", i: 0, expected: false},
{s: "a", i: 1, expected: false},
{s: "ab", i: 0, expected: false},
{s: "ab", i: 1, expected: true},
{s: "ab", i: 2, expected: false},
{s: "abc", i: 0, expected: false},
{s: "abc", i: 1, expected: true},
{s: "abc", i: 2, expected: true},
{s: "abc", i: 3, expected: false},
{s: " abc ", i: 0, expected: false},
{s: " abc ", i: 1, expected: false},
{s: " abc ", i: 2, expected: true},
{s: " abc ", i: 3, expected: true},
{s: " abc ", i: 4, expected: false},
{s: " abc ", i: 5, expected: false},
{s: "/home/user", i: 0, expected: false},
{s: "/home/user", i: 1, expected: false},
{s: "/home/user", i: 2, expected: true},
{s: "/home/user", i: 3, expected: true},
{s: "/home/user", i: 4, expected: true},
{s: "/home/user", i: 5, expected: false},
{s: "/home/user", i: 6, expected: false},
{s: "/home/user", i: 7, expected: true},
{s: "/home/user", i: 8, expected: true},
{s: "/home/user", i: 9, expected: true},
{s: "/home/user", i: 10, expected: false},
} {
assert.Equal(t, tc.expected, inWord(tc.s, tc.i))
}
}

0 comments on commit a028598

Please sign in to comment.