Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Restore --autotemplate flag to add command
This reverts commit ed0c798.
- Loading branch information
Showing
7 changed files
with
436 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) | ||
} | ||
} |
Oops, something went wrong.