Skip to content

Commit 4a52e5c

Browse files
arran4halostatue
authored andcommittedSep 22, 2023
feat: find[One]Executable in user-supplied paths
Implemented `findExecutable` with strong caching, extended from #3162. Also implemented `findOneExecutable` to search for any one executable from a list. Resolves: #3141 Closes: #3162 Co-authored-by: Arran Ubels <arran4@gmail.com>
1 parent bb6f952 commit 4a52e5c

14 files changed

+510
-1
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# `findExecutable` *file* *path-list*
2+
3+
`findExecutable` searches for an executable named *file* in directories
4+
identified by *path-list*. The result will be the executable file concatenated
5+
with the matching path. If an executable *file* cannot be found in *path-list*,
6+
`findExecutable` returns an empty string.
7+
8+
`findExecutable` is provided as an alternative to
9+
[`lookPath`](/reference/templates/functions/lookPath) so that you can
10+
interrogate the system PATH as it would be configured after `chezmoi apply`.
11+
Like `lookPath`, `findExecutable` is not hermetic: its return value depends on
12+
the state of the filesystem at the moment the template is executed. Exercise
13+
caution when using it in your templates.
14+
15+
The return value of the first successful call to `findExecutable` is cached, and
16+
future calls to `findExecutable` with the same parameters will return this path.
17+
18+
!!! info
19+
20+
On Windows, the resulting path will contain the first found executable
21+
extension as identified by the environment variable `%PathExt%`.
22+
23+
!!! example
24+
25+
```
26+
{{ if findExecutable "rtx" (list "bin" "go/bin" ".cargo/bin" ".local/bin") }}
27+
# $HOME/.cargo/bin/rtx exists and will probably be in $PATH after apply
28+
{{ end }}
29+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# `findOneExecutable` *file-list* *path-list*
2+
3+
`findOneExecutable` searches for an executable from *file-list* in directories
4+
identified by *path-list*, finding the first matching executable in the first
5+
matching directory (each directory is searched for matching executables in
6+
turn). The result will be the executable file concatenated with the matching
7+
path. If an executable from *file-list* cannot be found in *path-list*,
8+
`findOneExecutable` returns an empty string.
9+
10+
`findOneExecutable` is provided as an alternative to
11+
[`lookPath`](/reference/templates/functions/lookPath) so that you can
12+
interrogate the system PATH as it would be configured after `chezmoi apply`.
13+
Like `lookPath`, `findOneExecutable` is not hermetic: its return value depends
14+
on the state of the filesystem at the moment the template is executed. Exercise
15+
caution when using it in your templates.
16+
17+
The return value of the first successful call to `findOneExecutable` is cached,
18+
and future calls to `findOneExecutable` with the same parameters will return
19+
this path.
20+
21+
!!! info
22+
23+
On Windows, the resulting path will contain the first found executable
24+
extension as identified by the environment variable `%PathExt%`.
25+
26+
!!! example
27+
28+
```
29+
{{ if findOneExecutable (list "eza" "exa") (list "bin" "go/bin" ".cargo/bin" ".local/bin") }}
30+
# $HOME/.cargo/bin/exa exists and will probably be in $PATH after apply
31+
{{ end }}
32+
```

‎assets/chezmoi.io/mkdocs.yml

+2
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ nav:
187187
- deleteValueAtPath: reference/templates/functions/deleteValueAtPath.md
188188
- encrypt: reference/templates/functions/encrypt.md
189189
- eqFold: reference/templates/functions/eqFold.md
190+
- findExecutable: reference/templates/functions/findExecutable.md
191+
- findOneExecutable: reference/templates/functions/findOneExecutable.md
190192
- fromIni: reference/templates/functions/fromIni.md
191193
- fromJsonc: reference/templates/functions/fromJsonc.md
192194
- fromToml: reference/templates/functions/fromToml.md

‎internal/chezmoi/chezmoi_unix.go

+6
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ func init() {
1515
unix.Umask(int(Umask))
1616
}
1717

18+
// findExecutableExtensions returns valid OS executable extensions, on unix it
19+
// can be anything.
20+
func findExecutableExtensions(path string) []string {
21+
return []string{path}
22+
}
23+
1824
// IsExecutable returns if fileInfo is executable.
1925
func IsExecutable(fileInfo fs.FileInfo) bool {
2026
return fileInfo.Mode().Perm()&0o111 != 0

‎internal/chezmoi/chezmoi_windows.go

+16
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ const nativeLineEnding = "\r\n"
1313

1414
var pathExts = strings.Split(os.Getenv("PATHEXT"), string(filepath.ListSeparator))
1515

16+
// findExecutableExtensions returns valid OS executable extensions for the
17+
// provided file if it does not already have an extension. The executable
18+
// extensions are derived from %PathExt%.
19+
func findExecutableExtensions(path string) []string {
20+
cmdExt := filepath.Ext(path)
21+
if cmdExt != "" {
22+
return []string{path}
23+
}
24+
result := make([]string, len(pathExts))
25+
withoutSuffix := strings.TrimSuffix(path, cmdExt)
26+
for i, ext := range pathExts {
27+
result[i] = withoutSuffix + ext
28+
}
29+
return result
30+
}
31+
1632
// IsExecutable checks if the file is a regular file and has an extension listed
1733
// in the PATHEXT environment variable as per
1834
// https://www.nextofwindows.com/what-is-pathext-environment-variable-in-windows.

‎internal/chezmoi/findexecutable.go

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package chezmoi
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"sync"
8+
)
9+
10+
var (
11+
foundExecutableCacheMutex sync.Mutex
12+
foundExecutableCache = make(map[string]string)
13+
)
14+
15+
// FindExecutable is like LookPath except that:
16+
//
17+
// - You can specify the needle as `string`, `[]string`, or `[]interface{}`
18+
// (that converts to `[]string`).
19+
// - You specify the haystack instead of relying on `$PATH`/`%PATH%`.
20+
//
21+
// This makes it useful for the resulting path of shell configurations
22+
// managed by chezmoi.
23+
func FindExecutable(files, paths []string) (string, error) {
24+
foundExecutableCacheMutex.Lock()
25+
defer foundExecutableCacheMutex.Unlock()
26+
27+
key := strings.Join(files, "\x00") + "\x01" + strings.Join(paths, "\x00")
28+
29+
if path, ok := foundExecutableCache[key]; ok {
30+
return path, nil
31+
}
32+
33+
var candidates []string
34+
35+
for _, file := range files {
36+
candidates = append(candidates, findExecutableExtensions(file)...)
37+
}
38+
39+
// based on /usr/lib/go-1.20/src/os/exec/lp_unix.go:52
40+
for _, candidatePath := range paths {
41+
if candidatePath == "" {
42+
continue
43+
}
44+
45+
for _, candidate := range candidates {
46+
path := filepath.Join(candidatePath, candidate)
47+
48+
info, err := os.Stat(path)
49+
if err != nil {
50+
continue
51+
}
52+
53+
// isExecutable doesn't care if it's a directory
54+
if info.Mode().IsDir() {
55+
continue
56+
}
57+
58+
if IsExecutable(info) {
59+
foundExecutableCache[key] = path
60+
return path, nil
61+
}
62+
}
63+
}
64+
65+
return "", nil
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package chezmoi
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/alecthomas/assert/v2"
8+
)
9+
10+
func TestFindExecutable(t *testing.T) {
11+
tests := []struct {
12+
files []string
13+
paths []string
14+
expected string
15+
}{
16+
{
17+
files: []string{"sh"},
18+
paths: []string{"/usr/bin", "/bin"},
19+
expected: "/bin/sh",
20+
},
21+
{
22+
files: []string{"sh"},
23+
paths: []string{"/bin", "/usr/bin"},
24+
expected: "/bin/sh",
25+
},
26+
{
27+
files: []string{"chezmoish"},
28+
paths: []string{"/bin", "/usr/bin"},
29+
expected: "",
30+
},
31+
32+
{
33+
files: []string{"chezmoish", "sh"},
34+
paths: []string{"/usr/bin", "/bin"},
35+
expected: "/bin/sh",
36+
},
37+
{
38+
files: []string{"chezmoish", "sh"},
39+
paths: []string{"/bin", "/usr/bin"},
40+
expected: "/bin/sh",
41+
},
42+
{
43+
files: []string{"chezmoish", "chezvoush"},
44+
paths: []string{"/bin", "/usr/bin"},
45+
expected: "",
46+
},
47+
}
48+
49+
for _, test := range tests {
50+
name := fmt.Sprintf(
51+
"FindExecutable %#v in %#v as %#v",
52+
test.files,
53+
test.paths,
54+
test.expected,
55+
)
56+
t.Run(name, func(t *testing.T) {
57+
actual, err := FindExecutable(test.files, test.paths)
58+
assert.NoError(t, err)
59+
assert.Equal(t, test.expected, actual)
60+
})
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//go:build !windows && !darwin
2+
3+
package chezmoi
4+
5+
import (
6+
"fmt"
7+
"testing"
8+
9+
"github.com/alecthomas/assert/v2"
10+
)
11+
12+
func TestFindExecutable(t *testing.T) {
13+
tests := []struct {
14+
files []string
15+
paths []string
16+
expected string
17+
}{
18+
{
19+
files: []string{"yes"},
20+
paths: []string{"/usr/bin", "/bin"},
21+
expected: "/usr/bin/yes",
22+
},
23+
{
24+
files: []string{"sh"},
25+
paths: []string{"/bin", "/usr/bin"},
26+
expected: "/bin/sh",
27+
},
28+
{
29+
files: []string{"chezmoish"},
30+
paths: []string{"/bin", "/usr/bin"},
31+
expected: "",
32+
},
33+
{
34+
files: []string{"chezmoish", "yes"},
35+
paths: []string{"/usr/bin", "/bin"},
36+
expected: "/usr/bin/yes",
37+
},
38+
{
39+
files: []string{"chezmoish", "sh"},
40+
paths: []string{"/bin", "/usr/bin"},
41+
expected: "/bin/sh",
42+
},
43+
{
44+
files: []string{"chezmoish", "chezvoush"},
45+
paths: []string{"/bin", "/usr/bin"},
46+
expected: "",
47+
},
48+
}
49+
50+
for _, test := range tests {
51+
name := fmt.Sprintf(
52+
"FindExecutable %#v in %#v as %#v",
53+
test.files,
54+
test.paths,
55+
test.expected,
56+
)
57+
t.Run(name, func(t *testing.T) {
58+
actual, err := FindExecutable(test.files, test.paths)
59+
assert.NoError(t, err)
60+
assert.Equal(t, test.expected, actual)
61+
})
62+
}
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//go:build windows
2+
3+
package chezmoi
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
"testing"
9+
10+
"github.com/alecthomas/assert/v2"
11+
)
12+
13+
func TestFindExecutable(t *testing.T) {
14+
tests := []struct {
15+
files []string
16+
paths []string
17+
expected string
18+
}{
19+
{
20+
files: []string{"powershell.exe"},
21+
paths: []string{
22+
"c:\\windows\\system32",
23+
"c:\\windows\\system64",
24+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
25+
},
26+
expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
27+
},
28+
{
29+
files: []string{"powershell"},
30+
paths: []string{
31+
"c:\\windows\\system32",
32+
"c:\\windows\\system64",
33+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
34+
},
35+
expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
36+
},
37+
{
38+
files: []string{"weakshell.exe"},
39+
paths: []string{
40+
"c:\\windows\\system32",
41+
"c:\\windows\\system64",
42+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
43+
},
44+
expected: "",
45+
},
46+
{
47+
files: []string{"weakshell"},
48+
paths: []string{
49+
"c:\\windows\\system32",
50+
"c:\\windows\\system64",
51+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
52+
},
53+
expected: "",
54+
},
55+
{
56+
files: []string{"weakshell.exe", "powershell.exe"},
57+
paths: []string{
58+
"c:\\windows\\system32",
59+
"c:\\windows\\system64",
60+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
61+
},
62+
expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
63+
},
64+
{
65+
files: []string{"weakshell", "powershell"},
66+
paths: []string{
67+
"c:\\windows\\system32",
68+
"c:\\windows\\system64",
69+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
70+
},
71+
expected: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
72+
},
73+
{
74+
files: []string{"weakshell.exe", "chezmoishell.exe"},
75+
paths: []string{
76+
"c:\\windows\\system32",
77+
"c:\\windows\\system64",
78+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
79+
},
80+
expected: "",
81+
},
82+
{
83+
files: []string{"weakshell", "chezmoishell"},
84+
paths: []string{
85+
"c:\\windows\\system32",
86+
"c:\\windows\\system64",
87+
"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
88+
},
89+
expected: "",
90+
},
91+
}
92+
93+
for _, test := range tests {
94+
name := fmt.Sprintf("FindExecutable %v in %#v as %v", test.files, test.paths, test.expected)
95+
t.Run(name, func(t *testing.T) {
96+
actual, err := FindExecutable(test.files, test.paths)
97+
assert.NoError(t, err)
98+
assert.Equal(t, strings.ToLower(test.expected), strings.ToLower(actual))
99+
})
100+
}
101+
}

‎internal/cmd/config.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,8 @@ func newConfig(options ...configOption) (*Config, error) {
408408
"ejsonDecryptWithKey": c.ejsonDecryptWithKeyTemplateFunc,
409409
"encrypt": c.encryptTemplateFunc,
410410
"eqFold": c.eqFoldTemplateFunc,
411+
"findExecutable": c.findExecutableTemplateFunc,
412+
"findOneExecutable": c.findOneExecutableTemplateFunc,
411413
"fromIni": c.fromIniTemplateFunc,
412414
"fromJsonc": c.fromJsoncTemplateFunc,
413415
"fromToml": c.fromTomlTemplateFunc,
@@ -450,8 +452,8 @@ func newConfig(options ...configOption) (*Config, error) {
450452
"output": c.outputTemplateFunc,
451453
"pass": c.passTemplateFunc,
452454
"passFields": c.passFieldsTemplateFunc,
453-
"passRaw": c.passRawTemplateFunc,
454455
"passhole": c.passholeTemplateFunc,
456+
"passRaw": c.passRawTemplateFunc,
455457
"pruneEmptyDicts": c.pruneEmptyDictsTemplateFunc,
456458
"quoteList": c.quoteListTemplateFunc,
457459
"rbw": c.rbwTemplateFunc,

‎internal/cmd/templatefuncs.go

+34
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,40 @@ func (c *Config) eqFoldTemplateFunc(first, second string, more ...string) bool {
133133
return false
134134
}
135135

136+
func (c *Config) findExecutableTemplateFunc(file string, pathList []any) string {
137+
files := []string{file}
138+
paths, err := anySliceToStringSlice(pathList)
139+
if err != nil {
140+
panic(fmt.Errorf("path list: %w", err))
141+
}
142+
143+
switch path, err := chezmoi.FindExecutable(files, paths); {
144+
case err == nil:
145+
return path
146+
default:
147+
panic(err)
148+
}
149+
}
150+
151+
func (c *Config) findOneExecutableTemplateFunc(fileList, pathList []any) string {
152+
files, err := anySliceToStringSlice(fileList)
153+
if err != nil {
154+
panic(fmt.Errorf("file list: %w", err))
155+
}
156+
157+
paths, err := anySliceToStringSlice(pathList)
158+
if err != nil {
159+
panic(fmt.Errorf("path list: %w", err))
160+
}
161+
162+
switch path, err := chezmoi.FindExecutable(files, paths); {
163+
case err == nil:
164+
return path
165+
default:
166+
panic(err)
167+
}
168+
}
169+
136170
func (c *Config) fromIniTemplateFunc(s string) map[string]any {
137171
file, err := ini.Load([]byte(s))
138172
if err != nil {

‎internal/cmd/testdata/scripts/templatefuncs.txtar

+32
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,38 @@ stdout ^true$
7878
exec chezmoi execute-template '{{ isExecutable "bin/not-executable" }}'
7979
stdout ^false$
8080

81+
# test findExecutable template function to find in specified script varargs - success
82+
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib" "/bin" "/usr/bin") }}'
83+
[!windows] stdout ^/bin/echo$
84+
85+
# test findOneExecutable template function to find in specified script varargs - success
86+
[!windows] exec chezmoi execute-template '{{ findOneExecutable (list "chezmoish" "echo") (list "/lib" "/bin" "/usr/bin") }}'
87+
[!windows] stdout ^/bin/echo$
88+
89+
# test findExecutable template function to find in specified script varargs - failure
90+
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib") }}'
91+
[!windows] stdout ^$
92+
93+
# test findExecutable template function to find in specified script - success
94+
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib" "/bin" "/usr/bin") }}'
95+
[!windows] stdout ^/bin/echo$
96+
97+
# test findExecutable template function to find in specified script - failure
98+
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib") }}'
99+
[!windows] stdout ^$
100+
101+
# test findExecutable template function to find in specified script - success with extension
102+
[windows] exec chezmoi execute-template '{{ findExecutable "git.exe" (list "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd") }}'
103+
[windows] stdout 'git'
104+
105+
# test findExecutable template function to find in specified script - success without extension
106+
[windows] exec chezmoi execute-template '{{ findExecutable "git" (list "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd") }}'
107+
[windows] stdout 'git'
108+
109+
# test findExecutable template function to find in specified script - failure
110+
[windows] exec chezmoi execute-template '{{ findExecutable "asdf" (list "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd") }}'
111+
[windows] stdout '^$'
112+
81113
# test lookPath template function to find in PATH
82114
exec chezmoi execute-template '{{ lookPath "go" }}'
83115
stdout go$exe

‎internal/cmd/util.go

+18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"reflect"
45
"strings"
56
"unicode"
67
)
@@ -143,3 +144,20 @@ func upperSnakeCaseToCamelCaseMap[V any](m map[string]V) map[string]V {
143144
}
144145
return result
145146
}
147+
148+
func flattenStringList(vpaths []any) []string {
149+
var paths []string
150+
for i := range vpaths {
151+
switch path := vpaths[i].(type) {
152+
case []string:
153+
paths = append(paths, path...)
154+
case string:
155+
paths = append(paths, path)
156+
case []any:
157+
paths = append(paths, flattenStringList(path)...)
158+
default:
159+
panic("unknown type: " + reflect.TypeOf(path).String())
160+
}
161+
}
162+
return paths
163+
}

‎internal/cmd/util_test.go

+46
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cmd
22

33
import (
4+
"reflect"
5+
"strings"
46
"testing"
57

68
"github.com/alecthomas/assert/v2"
@@ -148,3 +150,47 @@ func TestUpperSnakeCaseToCamelCaseMap(t *testing.T) {
148150
"id": "",
149151
}, actual)
150152
}
153+
154+
func Test_flattenStringList(t *testing.T) {
155+
tests := []struct {
156+
name string
157+
vpaths []any
158+
want []string
159+
}{
160+
{
161+
name: "Nothing",
162+
},
163+
{
164+
name: "Just a string",
165+
vpaths: []any{"1"},
166+
want: []string{"1"},
167+
},
168+
{
169+
name: "Just a array of string",
170+
vpaths: []any{[]string{"1", "2"}},
171+
want: []string{"1", "2"},
172+
},
173+
{
174+
name: "Just a array of any containing string",
175+
vpaths: []any{[]any{"1", "2"}},
176+
want: []string{"1", "2"},
177+
},
178+
{
179+
name: "Just a array of any containing string",
180+
vpaths: []any{[]any{"1", "2"}},
181+
want: []string{"1", "2"},
182+
},
183+
{
184+
name: "Hybrid",
185+
vpaths: []any{"0", []any{"1", "2"}, []any{[]string{"3", "4"}}, []any{[]any{"5", "6"}}},
186+
want: strings.Split("0123456", ""),
187+
},
188+
}
189+
for _, tt := range tests {
190+
t.Run(tt.name, func(t *testing.T) {
191+
if got := flattenStringList(tt.vpaths); !reflect.DeepEqual(got, tt.want) {
192+
t.Errorf("flattenStringList() = %v, want %v", got, tt.want)
193+
}
194+
})
195+
}
196+
}

0 commit comments

Comments
 (0)
Please sign in to comment.