Skip to content

Commit afd6994

Browse files
authored
Merge pull request #96 from twpayne/autotemplate-fixes
Autotemplate fixes
2 parents 76ff3be + 555ea41 commit afd6994

File tree

3 files changed

+160
-25
lines changed

3 files changed

+160
-25
lines changed

lib/chezmoi/autotemplate.go

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
package chezmoi
22

33
import (
4-
"regexp"
54
"sort"
65
"strings"
76
)
87

98
type templateVariable struct {
10-
name string
11-
value string
12-
valueRegexp *regexp.Regexp
9+
name string
10+
value string
1311
}
1412

1513
type byValueLength []templateVariable
@@ -33,9 +31,8 @@ func extractVariables(variables []templateVariable, parent []string, data map[st
3331
switch value := value.(type) {
3432
case string:
3533
variables = append(variables, templateVariable{
36-
name: strings.Join(append(parent, name), "."),
37-
value: value,
38-
valueRegexp: regexp.MustCompile(`\b` + regexp.QuoteMeta(value) + `\b`),
34+
name: strings.Join(append(parent, name), "."),
35+
value: value,
3936
})
4037
case map[string]interface{}:
4138
variables = extractVariables(variables, append(parent, name), value)
@@ -44,14 +41,47 @@ func extractVariables(variables []templateVariable, parent []string, data map[st
4441
return variables
4542
}
4643

47-
func autoTemplate(contents []byte, data map[string]interface{}) []byte {
44+
func autoTemplate(contents []byte, data map[string]interface{}) ([]byte, error) {
4845
// FIXME this naive approach will generate incorrect templates if the
4946
// variable names match variable values
47+
// FIXME the algorithm here is probably O(N^2), we can do better
5048
variables := extractVariables(nil, nil, data)
5149
sort.Sort(sort.Reverse(byValueLength(variables)))
5250
contentsStr := string(contents)
5351
for _, variable := range variables {
54-
contentsStr = variable.valueRegexp.ReplaceAllString(contentsStr, "{{ ."+variable.name+" }}")
52+
index := strings.Index(contentsStr, variable.value)
53+
for index != -1 && index != len(contentsStr) {
54+
if !inWord(contentsStr, index) && !inWord(contentsStr, index+len(variable.value)) {
55+
// Replace variable.value which is on word boundaries at both
56+
// ends.
57+
replacement := "{{ ." + variable.name + " }}"
58+
contentsStr = contentsStr[:index] + replacement + contentsStr[index+len(variable.value):]
59+
index += len(replacement)
60+
} else {
61+
// Otherwise, keep looking. Consume at least one byte so we
62+
// make progress.
63+
index++
64+
}
65+
// Look for the next occurrence of variable.value.
66+
j := strings.Index(contentsStr[index:], variable.value)
67+
if j == -1 {
68+
// No more occurrences found, so terminate the loop.
69+
break
70+
} else {
71+
// Advance to the next occurrence.
72+
index += j
73+
}
74+
}
5575
}
56-
return []byte(contentsStr)
76+
return []byte(contentsStr), nil
77+
}
78+
79+
// isWord returns true if b is a word byte.
80+
func isWord(b byte) bool {
81+
return '0' <= b && b <= '9' || 'A' <= b && b <= 'Z' || 'a' <= b && b <= 'z'
82+
}
83+
84+
// inWord returns true if splitting s at position i would split a word.
85+
func inWord(s string, i int) bool {
86+
return i > 0 && i < len(s) && isWord(s[i-1]) && isWord(s[i])
5787
}

lib/chezmoi/autotemplate_test.go

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ import "testing"
44

55
func TestAutoTemplate(t *testing.T) {
66
for _, tc := range []struct {
7+
name string
78
contentsStr string
89
data map[string]interface{}
910
wantStr string
1011
}{
1112
{
13+
name: "simple",
1214
contentsStr: "email = hello@example.com\n",
1315
data: map[string]interface{}{
1416
"email": "hello@example.com",
1517
},
1618
wantStr: "email = {{ .email }}\n",
1719
},
1820
{
21+
name: "longest_first",
1922
contentsStr: "name = John Smith\nfirstName = John\n",
2023
data: map[string]interface{}{
2124
"name": "John Smith",
@@ -24,6 +27,17 @@ func TestAutoTemplate(t *testing.T) {
2427
wantStr: "name = {{ .name }}\nfirstName = {{ .firstName }}\n",
2528
},
2629
{
30+
name: "alphabetical_first",
31+
contentsStr: "name = John Smith\n",
32+
data: map[string]interface{}{
33+
"alpha": "John Smith",
34+
"beta": "John Smith",
35+
"gamma": "John Smith",
36+
},
37+
wantStr: "name = {{ .alpha }}\n",
38+
},
39+
{
40+
name: "nested_values",
2741
contentsStr: "email = hello@example.com\n",
2842
data: map[string]interface{}{
2943
"personal": map[string]interface{}{
@@ -33,28 +47,116 @@ func TestAutoTemplate(t *testing.T) {
3347
wantStr: "email = {{ .personal.email }}\n",
3448
},
3549
{
50+
name: "only_replace_words",
3651
contentsStr: "darwinian evolution",
3752
data: map[string]interface{}{
3853
"os": "darwin",
3954
},
4055
wantStr: "darwinian evolution", // not "{{ .os }}ian evolution"
4156
},
42-
/*
43-
// FIXME this test currently fails because we match on word
44-
// boundaries and ^/ is not a word boundary.
45-
{
46-
contentsStr: "/home/user",
47-
data: map[string]interface{}{
48-
"homedir": "/home/user",
49-
},
50-
wantStr: "{{ .homedir }}",
57+
{
58+
name: "longest_match_first",
59+
contentsStr: "/home/user",
60+
data: map[string]interface{}{
61+
"homedir": "/home/user",
62+
},
63+
wantStr: "{{ .homedir }}",
64+
},
65+
{
66+
name: "longest_match_first_prefix",
67+
contentsStr: "HOME=/home/user",
68+
data: map[string]interface{}{
69+
"homedir": "/home/user",
70+
},
71+
wantStr: "HOME={{ .homedir }}",
72+
},
73+
{
74+
name: "longest_match_first_suffix",
75+
contentsStr: "/home/user/something",
76+
data: map[string]interface{}{
77+
"homedir": "/home/user",
78+
},
79+
wantStr: "{{ .homedir }}/something",
80+
},
81+
{
82+
name: "longest_match_first_prefix_and_suffix",
83+
contentsStr: "HOME=/home/user/something",
84+
data: map[string]interface{}{
85+
"homedir": "/home/user",
5186
},
52-
*/
87+
wantStr: "HOME={{ .homedir }}/something",
88+
},
89+
{
90+
name: "words_only",
91+
contentsStr: "aaa aa a aa aaa aa a aa aaa",
92+
data: map[string]interface{}{
93+
"alpha": "a",
94+
},
95+
wantStr: "aaa aa {{ .alpha }} aa aaa aa {{ .alpha }} aa aaa",
96+
},
97+
{
98+
name: "words_only_2",
99+
contentsStr: "aaa aa a aa aaa aa a aa aaa",
100+
data: map[string]interface{}{
101+
"alpha": "aa",
102+
},
103+
wantStr: "aaa {{ .alpha }} a {{ .alpha }} aaa {{ .alpha }} a {{ .alpha }} aaa",
104+
},
105+
{
106+
name: "words_only_3",
107+
contentsStr: "aaa aa a aa aaa aa a aa aaa",
108+
data: map[string]interface{}{
109+
"alpha": "aaa",
110+
},
111+
wantStr: "{{ .alpha }} aa a aa {{ .alpha }} aa a aa {{ .alpha }}",
112+
},
113+
} {
114+
t.Run(tc.name, func(t *testing.T) {
115+
got, gotErr := autoTemplate([]byte(tc.contentsStr), tc.data)
116+
gotStr := string(got)
117+
if gotErr != nil || gotStr != tc.wantStr {
118+
t.Errorf("autoTemplate([]byte(%q), %v) == %q, %v, want %q, <nil>", tc.contentsStr, tc.data, gotStr, gotErr, tc.wantStr)
119+
}
120+
})
121+
}
122+
}
123+
124+
func TestInWord(t *testing.T) {
125+
for _, tc := range []struct {
126+
s string
127+
i int
128+
want bool
129+
}{
130+
{s: "", i: 0, want: false},
131+
{s: "a", i: 0, want: false},
132+
{s: "a", i: 1, want: false},
133+
{s: "ab", i: 0, want: false},
134+
{s: "ab", i: 1, want: true},
135+
{s: "ab", i: 2, want: false},
136+
{s: "abc", i: 0, want: false},
137+
{s: "abc", i: 1, want: true},
138+
{s: "abc", i: 2, want: true},
139+
{s: "abc", i: 3, want: false},
140+
{s: " abc ", i: 0, want: false},
141+
{s: " abc ", i: 1, want: false},
142+
{s: " abc ", i: 2, want: true},
143+
{s: " abc ", i: 3, want: true},
144+
{s: " abc ", i: 4, want: false},
145+
{s: " abc ", i: 5, want: false},
146+
{s: "/home/user", i: 0, want: false},
147+
{s: "/home/user", i: 1, want: false},
148+
{s: "/home/user", i: 2, want: true},
149+
{s: "/home/user", i: 3, want: true},
150+
{s: "/home/user", i: 4, want: true},
151+
{s: "/home/user", i: 5, want: false},
152+
{s: "/home/user", i: 6, want: false},
153+
{s: "/home/user", i: 7, want: true},
154+
{s: "/home/user", i: 8, want: true},
155+
{s: "/home/user", i: 9, want: true},
156+
{s: "/home/user", i: 10, want: false},
53157
} {
54-
got := autoTemplate([]byte(tc.contentsStr), tc.data)
55-
gotStr := string(got)
56-
if gotStr != tc.wantStr {
57-
t.Errorf("autoTemplate([]byte(%q), %v) == %q, want %q", tc.contentsStr, tc.data, gotStr, tc.wantStr)
158+
if got := inWord(tc.s, tc.i); got != tc.want {
159+
t.Errorf("inWord(%q, %d) == %v, want %v", tc.s, tc.i, got, tc.want)
58160
}
59161
}
60162
}

lib/chezmoi/target_state.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ func (ts *TargetState) Add(fs vfs.FS, addOptions AddOptions, targetPath string,
112112
return err
113113
}
114114
if addOptions.Template {
115-
contents = autoTemplate(contents, ts.Data)
115+
contents, err = autoTemplate(contents, ts.Data)
116+
if err != nil {
117+
return err
118+
}
116119
}
117120
return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Template, contents, mutator)
118121
case info.Mode()&os.ModeType == os.ModeSymlink:

0 commit comments

Comments
 (0)