Skip to content

Commit

Permalink
feat: Create findExecutable which searches multiple paths for an ex…
Browse files Browse the repository at this point in the history
…ecutable of a particular name and returns the found path.

As per: twpayne#3141
  • Loading branch information
arran4 committed Aug 8, 2023
1 parent 552d255 commit 932560b
Show file tree
Hide file tree
Showing 13 changed files with 345 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# `findExecutable` *file* *...paths*

`findExecutable` searches for an executable named *file* in the directories provided by
the varargs `paths` parameter. In the case of Windows it will return the correct extension
if none was provided based on `PathExt`.

The input to `findExecutable` is flattened recursively. It can be a variable parameter array such as:
```
{{ findExecutable "less" "bin" "go/bin" ".cargo/bin" ".local/bin" }}
```
Or a slice:
```
{{ findExecutable "less" (list "bin" "go/bin" ".cargo/bin" ".local/bin") }}
```

`findExecutable` is provided as an alternative to `lookPath` so that you interrogate the
paths as you would have them after deployment of the RC script.

Each successful lookup is cached based on the full path, and evaluated in the correct
order each time to reduce `File` `Stat` operations.

!!! example

```
{{ if findExecutable "less" "bin" "go/bin" ".cargo/bin" ".local/bin" }}
echo "Good news we have found 'less' on system at '{{ findExecutable "less" "bin" "go/bin" ".cargo/bin" ".local/bin" }}'!"
export DIFFTOOL=less
{{ end }}
```
1 change: 1 addition & 0 deletions assets/chezmoi.io/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ nav:
- deleteValueAtPath: reference/templates/functions/deleteValueAtPath.md
- encrypt: reference/templates/functions/encrypt.md
- eqFold: reference/templates/functions/eqFold.md
- findExecutable: reference/templates/functions/findExecutable.md
- fromIni: reference/templates/functions/fromIni.md
- fromJsonc: reference/templates/functions/fromJsonc.md
- fromToml: reference/templates/functions/fromToml.md
Expand Down
5 changes: 5 additions & 0 deletions internal/chezmoi/chezmoi_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ func init() {
unix.Umask(int(Umask))
}

// findExecutableExtensions returns valid OS executable extensions, on unix it can be anything.
func findExecutableExtensions(path string) []string {
return []string{path}
}

// IsExecutable returns if fileInfo is executable.
func IsExecutable(fileInfo fs.FileInfo) bool {
return fileInfo.Mode().Perm()&0o111 != 0
Expand Down
15 changes: 15 additions & 0 deletions internal/chezmoi/chezmoi_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ const nativeLineEnding = "\r\n"

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

// findExecutableExtensions returns valid OS executable extensions for a given executable basename - does not check the
// existence.
func findExecutableExtensions(path string) []string {
cmdExt := filepath.Ext(path)
if cmdExt != "" {
return []string{path}
}
result := make([]string, len(pathExts))
withoutSuffix := strings.TrimSuffix(path, cmdExt)
for i, ext := range pathExts {
result[i] = withoutSuffix + ext
}
return result
}

// IsExecutable checks if the file is a regular file and has an extension listed
// in the PATHEXT environment variable as per
// https://www.nextofwindows.com/what-is-pathext-environment-variable-in-windows.
Expand Down
47 changes: 47 additions & 0 deletions internal/chezmoi/findexecutable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package chezmoi

import (
"os"
"path/filepath"
"sync"
)

var (
foundExecutableCacheMutex sync.Mutex
foundExecutableCache = make(map[string]struct{})
)

// FindExecutable is like lookPath except that you can specify the paths rather than just using the current `$PATH`. This
// makes it useful for the resulting path of rc/profile files.
func FindExecutable(file string, paths ...string) (string, error) {
foundExecutableCacheMutex.Lock()
defer foundExecutableCacheMutex.Unlock()

// stolen from: /usr/lib/go-1.20/src/os/exec/lp_unix.go:52
for _, dir := range paths {
if dir == "" {
continue
}
p := filepath.Join(dir, file)
for _, path := range findExecutableExtensions(p) {
if _, ok := foundExecutableCache[path]; ok {
return path, nil
}
f, err := os.Stat(path)
if err != nil {
continue
}
m := f.Mode()
// isExecutable doesn't care if it's a directory
if m.IsDir() {
continue
}
if IsExecutable(f) {
foundExecutableCache[path] = struct{}{}
return path, nil
}
}
}

return "", nil
}
35 changes: 35 additions & 0 deletions internal/chezmoi/findexecutable_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//go:build darwin

package chezmoi

import "testing"

func TestFindExecutable(t *testing.T) {
tests := []struct {
name string
file string
paths []string
want string
wantErr bool
}{
{
name: "Finds first",
file: "sh",
paths: []string{"/usr/bin", "/bin"},
want: "/bin/sh",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := FindExecutable(tt.file, tt.paths...)
if (err != nil) != tt.wantErr {
t.Errorf("FindExecutable() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("FindExecutable() got = %v, want %v", got, tt.want)
}
})
}
}
50 changes: 50 additions & 0 deletions internal/chezmoi/findexecutable_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build !windows && !darwin

package chezmoi

import (
"os"
"testing"
)

func TestFindExecutableVararg(t *testing.T) {
tests := []struct {
name string
file string
paths []string
want string
wantErr bool
}{
{
name: "Finds first",
file: "sh",
paths: []string{"/usr/bin", "/bin"},
want: "/usr/bin/sh",
wantErr: false,
},
{
name: "Finds first 2",
file: "sh",
paths: []string{"/bin", "/usr/bin"},
want: "/bin/sh",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.want != "" {
if _, err := os.Stat(tt.want); err != nil {
t.Skip("Alpine doesn't have a symlink for sh")
}
}
got, err := FindExecutable(tt.file, tt.paths...)
if (err != nil) != tt.wantErr {
t.Errorf("FindExecutable() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("FindExecutable() got = %v, want %v", got, tt.want)
}
})
}
}
59 changes: 59 additions & 0 deletions internal/chezmoi/findexecutable_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//go:build windows

package chezmoi

import (
"strings"
"testing"
)

func TestFindExecutable(t *testing.T) {
tests := []struct {
name string
file string
paths []string
want string
wantErr bool
}{
{
name: "Finds with extension",
file: "powershell.exe",
paths: []string{"c:\\windows\\system32", "c:\\windows\\system64", "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0"},
want: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
wantErr: false,
},
{
name: "Finds without extension",
file: "powershell",
paths: []string{"c:\\windows\\system32", "c:\\windows\\system64", "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0"},
want: "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
wantErr: false,
},
{
name: "Fails to find with extension",
file: "weakshell.exe",
paths: []string{"c:\\windows\\system32", "c:\\windows\\system64", "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0"},
want: "",
wantErr: false,
},
{
name: "Fails to find without extension",
file: "weakshell",
paths: []string{"c:\\windows\\system32", "c:\\windows\\system64", "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0"},
want: "",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := FindExecutable(tt.file, tt.paths...)
if (err != nil) != tt.wantErr {
t.Errorf("FindExecutable() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !strings.EqualFold(got, tt.want) {
t.Errorf("FindExecutable() got = %v, want %v", got, tt.want)
}
})
}
}
3 changes: 2 additions & 1 deletion internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ func newConfig(options ...configOption) (*Config, error) {
"ejsonDecryptWithKey": c.ejsonDecryptWithKeyTemplateFunc,
"encrypt": c.encryptTemplateFunc,
"eqFold": c.eqFoldTemplateFunc,
"findExecutable": c.findExecutableTemplateFunc,
"fromIni": c.fromIniTemplateFunc,
"fromJsonc": c.fromJsoncTemplateFunc,
"fromToml": c.fromTomlTemplateFunc,
Expand Down Expand Up @@ -443,8 +444,8 @@ func newConfig(options ...configOption) (*Config, error) {
"output": c.outputTemplateFunc,
"pass": c.passTemplateFunc,
"passFields": c.passFieldsTemplateFunc,
"passRaw": c.passRawTemplateFunc,
"passhole": c.passholeTemplateFunc,
"passRaw": c.passRawTemplateFunc,
"pruneEmptyDicts": c.pruneEmptyDictsTemplateFunc,
"quoteList": c.quoteListTemplateFunc,
"rbw": c.rbwTemplateFunc,
Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/templatefuncs.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ func (c *Config) eqFoldTemplateFunc(first, second string, more ...string) bool {
return false
}

func (c *Config) findExecutableTemplateFunc(file string, vpaths ...any) string {
paths := flattenStringList(vpaths)
switch path, err := chezmoi.FindExecutable(file, paths...); {
case err == nil:
return path
default:
panic(err)
}
}

func (c *Config) fromIniTemplateFunc(s string) map[string]any {
file, err := ini.Load([]byte(s))
if err != nil {
Expand Down
28 changes: 28 additions & 0 deletions internal/cmd/testdata/scripts/templatefuncs.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,34 @@ stdout ^true$
exec chezmoi execute-template '{{ isExecutable "bin/not-executable" }}'
stdout ^false$

# test findExecutable template function to find in specified script varargs - success
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" "/lib" "/bin" "/usr/bin" }}'
[!windows] stdout ^/bin/echo$

# test findExecutable template function to find in specified script varargs - failure
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" "/lib" }}'
[!windows] stdout ^$

# test findExecutable template function to find in specified script - success
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib" "/bin" "/usr/bin") }}'
[!windows] stdout ^/bin/echo$

# test findExecutable template function to find in specified script - failure
[!windows] exec chezmoi execute-template '{{ findExecutable "echo" (list "/lib") }}'
[!windows] stdout ^$

# test findExecutable template function to find in specified script - success with extension
[windows] exec chezmoi execute-template '{{ findExecutable "git.exe" "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd" }}'
[windows] stdout 'git'

# test findExecutable template function to find in specified script - success without extension
[windows] exec chezmoi execute-template '{{ findExecutable "git" "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd" }}'
[windows] stdout 'git'

# test findExecutable template function to find in specified script - failure
[windows] exec chezmoi execute-template '{{ findExecutable "asdf" "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd" }}'
[windows] stdout '^$'

# test lookPath template function to find in PATH
exec chezmoi execute-template '{{ lookPath "go" }}'
stdout go$exe
Expand Down
18 changes: 18 additions & 0 deletions internal/cmd/util.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"reflect"
"strings"
"unicode"
)
Expand Down Expand Up @@ -143,3 +144,20 @@ func upperSnakeCaseToCamelCaseMap[V any](m map[string]V) map[string]V {
}
return result
}

func flattenStringList(vpaths []any) []string {
var paths []string
for i := range vpaths {
switch path := vpaths[i].(type) {
case []string:
paths = append(paths, path...)
case string:
paths = append(paths, path)
case []any:
paths = append(paths, flattenStringList(path)...)
default:
panic("unknown type: " + reflect.TypeOf(path).String())
}
}
return paths
}
Loading

0 comments on commit 932560b

Please sign in to comment.