Skip to content

Commit

Permalink
feat: findExecutable in user-supplied paths
Browse files Browse the repository at this point in the history
Implement `findExecutable` with strong caching. The main change from
twpayne#3162 is that it now also accepts either a `file` or `file-list` for
searching, so that one of several different options can be searched
simultaneously for roughly equivalent implementations. See twpayne#3256 for the
inspiration for this particular change.

We *can* switch this to two template functions, but I think that the
core implementation would best remain the way that it is.

Resolves: twpayne#3141
Closes: twpayne#3162
Co-authored-by: Arran Ubels <arran4@gmail.com>
  • Loading branch information
arran4 authored and halostatue committed Sep 21, 2023
1 parent 6a8ca16 commit e650ecb
Show file tree
Hide file tree
Showing 13 changed files with 524 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `findExecutable` *file|file-list* *path-list*

`findExecutable` searches for an executable named *file* in directories
identified by *path-list*. The search may be specified as a *file-list*, to find
the first matching executable in the first matching directory. The result will
be the executable file concatenated with the matching path. If an executable
*file* cannot be found in *path-list*, `findExecutable` returns an empty string.

`findExecutable` is provided as an alternative to
[`lookPath`](/reference/templates/functions/lookPath) so that you can
interrogate the system PATH as it would be configured after `chezmoi apply`.
Like `lookPath`, `findExecutable` is not hermetic: its return value depends on
the state of the filesystem at the moment the template is executed. Exercise
caution when using it in your templates.

The return value of the first successful call to `findExecutable` is cached, and
future calls to `findExecutable` with the same parameters (*file* and
*path-list* or *file-list* and *path-list*) will return this path.

!!! info

On Windows, the resulting path will contain the first found executable
extension as identified by the environment variable `%PathExt%`.

!!! example

```
{{ if findExecutable "rtx" (list "bin" "go/bin" ".cargo/bin" ".local/bin") }}
# $HOME/.cargo/bin/rtx exists and will probably be in $PATH after apply
{{ end }}
```

With a list of possible executables:

```
{{ if findExecutable (list "eza" "exa") (list "bin" "go/bin" ".cargo/bin" ".local/bin") }}
# $HOME/.cargo/bin/exa exists and will probably be in $PATH after apply
{{ end }}
```
1 change: 1 addition & 0 deletions assets/chezmoi.io/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,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
6 changes: 6 additions & 0 deletions internal/chezmoi/chezmoi_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ 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
16 changes: 16 additions & 0 deletions internal/chezmoi/chezmoi_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ const nativeLineEnding = "\r\n"

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

// findExecutableExtensions returns valid OS executable extensions for the
// provided file if it does not already have an extension. The executable
// extensions are derived from %PathExt%.
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
87 changes: 87 additions & 0 deletions internal/chezmoi/findexecutable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package chezmoi

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

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

// FindExecutable is like LookPath except that:
//
// - You can specify the needle as `string`, `[]string`, or `[]interface{}`
// (that converts to `[]string`).
// - You specify the haystack instead of relying on `$PATH`/`%PATH%`.
//
// This makes it useful for the resulting path of shell configurations
// managed by chezmoi.
func FindExecutable(fileOrFiles any, paths []string) (string, error) {
foundExecutableCacheMutex.Lock()
defer foundExecutableCacheMutex.Unlock()

var files []string

switch value := fileOrFiles.(type) {
case string:
files = []string{value}
case []string:
files = value
case []interface{}:
files = make([]string, 0, len(value))
for _, e := range value {
if v, ok := e.(string); ok {
files = append(files, v)
} else {
return "", fmt.Errorf("%v: must be string or []string but is %T", value, value)
}
}
default:
return "", fmt.Errorf("%v: must be string or []string but is %T", value, value)
}

key := strings.Join(files, "\x00") + "\x01" + strings.Join(paths, "\x00")

if path, ok := foundExecutableCache[key]; ok {
return path, nil
}

var candidates []string

for _, file := range files {
candidates = append(candidates, findExecutableExtensions(file)...)
}

// based on /usr/lib/go-1.20/src/os/exec/lp_unix.go:52
for _, candidatePath := range paths {
if candidatePath == "" {
continue
}

for _, candidate := range candidates {
path := filepath.Join(candidatePath, candidate)

info, err := os.Stat(path)
if err != nil {
continue
}

// isExecutable doesn't care if it's a directory
if info.Mode().IsDir() {
continue
}

if IsExecutable(info) {
foundExecutableCache[key] = path
return path, nil
}
}
}

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

package chezmoi

import (
"fmt"
"testing"
)

func TestFindExecutableOneNeedle(t *testing.T) {
tests := []struct {
file string
paths []string
expected string
}{
{
file: "sh",
paths: []string{"/usr/bin", "/bin"},
expected: "/bin/sh",
},
{
file: "sh",
paths: []string{"/bin", "/usr/bin"},
expected: "/bin/sh",
},
{
file: "chezmoish",
paths: []string{"/bin", "/usr/bin"},
expected: "",
},
}

for _, test := range tests {
name := fmt.Sprintf("FindExecutable %v in %#v as %v", test.file, test.paths, test.expected)
t.Run(name, func(t *testing.T) {
actual, err := FindExecutable(test.file, test.paths)
if err != nil {
t.Errorf("FindExecutable() error = %v, expected = nil", err)
}

if actual != test.expected {
t.Errorf("FindExecutable() actual = %v, expected = %v", actual, test.expected)
}
})
}
}

func TestFindExecutableMultipleNeedles(t *testing.T) {
tests := []struct {
files []string
paths []string
expected string
}{
{
files: []string{"chezmoish", "sh"},
paths: []string{"/usr/bin", "/bin"},
expected: "/bin/sh",
},
{
files: []string{"chezmoish", "sh"},
paths: []string{"/bin", "/usr/bin"},
expected: "/bin/sh",
},
{
files: []string{"chezmoish", "chezvoush"},
paths: []string{"/bin", "/usr/bin"},
expected: "",
},
}

for _, test := range tests {
name := fmt.Sprintf(
"FindExecutable %#v in %#v as %v",
test.files,
test.paths,
test.expected,
)
t.Run(name, func(t *testing.T) {
actual, err := FindExecutable(test.files, test.paths)
if err != nil {
t.Errorf("FindExecutable() error = %v, expected = nil", err)
return
}

if actual != test.expected {
t.Errorf("FindExecutable() actual = %v, expected = %v", actual, test.expected)
}
})
}
}
91 changes: 91 additions & 0 deletions internal/chezmoi/findexecutable_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//go:build !windows && !darwin

package chezmoi

import (
"fmt"
"testing"
)

func TestFindExecutableOneNeedle(t *testing.T) {
tests := []struct {
file string
paths []string
expected string
}{
{
file: "sh",
paths: []string{"/usr/bin", "/bin"},
expected: "/usr/bin/sh",
},
{
file: "sh",
paths: []string{"/bin", "/usr/bin"},
expected: "/bin/sh",
},
{
file: "chezmoish",
paths: []string{"/bin", "/usr/bin"},
expected: "",
},
}

for _, test := range tests {
name := fmt.Sprintf("FindExecutable %v in %#v as %v", test.file, test.paths, test.expected)
t.Run(name, func(t *testing.T) {
actual, err := FindExecutable(test.file, test.paths)
if err != nil {
t.Errorf("FindExecutable() error = %v, expected = nil", err)
return
}

if actual != test.expected {
t.Errorf("FindExecutable() actual = %v, expected = %v", actual, test.expected)
}
})
}
}

func TestFindExecutableMultipleNeedles(t *testing.T) {
tests := []struct {
files []string
paths []string
expected string
}{
{
files: []string{"chezmoish", "sh"},
paths: []string{"/usr/bin", "/bin"},
expected: "/usr/bin/sh",
},
{
files: []string{"chezmoish", "sh"},
paths: []string{"/bin", "/usr/bin"},
expected: "/bin/sh",
},
{
files: []string{"chezmoish", "chezvoush"},
paths: []string{"/bin", "/usr/bin"},
expected: "",
},
}

for _, test := range tests {
name := fmt.Sprintf(
"FindExecutable %#v in %#v as %v",
test.files,
test.paths,
test.expected,
)
t.Run(name, func(t *testing.T) {
actual, err := FindExecutable(test.files, test.paths)
if err != nil {
t.Errorf("FindExecutable() error = %v, expected = nil", err)
return
}

if actual != test.expected {
t.Errorf("FindExecutable() actual = %v, expected = %v", actual, test.expected)
}
})
}
}
Loading

0 comments on commit e650ecb

Please sign in to comment.