Skip to content

Commit

Permalink
feat: find[One]Executable in user-supplied paths
Browse files Browse the repository at this point in the history
Implemented `findExecutable` with strong caching, extended from twpayne#3162.
Also implemented `findOneExecutable` to search for any one executable
from a list.

Resolves: twpayne#3141
Closes: twpayne#3162
Co-authored-by: Arran Ubels <arran4@gmail.com>
  • Loading branch information
arran4 authored and halostatue committed Sep 22, 2023
1 parent c96a4b7 commit 8cb3715
Show file tree
Hide file tree
Showing 14 changed files with 510 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# `findExecutable` *file* *path-list*

`findExecutable` searches for an executable named *file* in directories
identified by *path-list*. 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 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 }}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# `findOneExecutable` *file-list* *path-list*

`findOneExecutable` searches for an executable from *file-list* in directories
identified by *path-list*, finding the first matching executable in the first
matching directory (each directory is searched for matching executables in
turn). The result will be the executable file concatenated with the matching
path. If an executable from *file-list* cannot be found in *path-list*,
`findOneExecutable` returns an empty string.

`findOneExecutable` 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`, `findOneExecutable` 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 `findOneExecutable` is cached,
and future calls to `findOneExecutable` with the same parameters 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 findOneExecutable (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 }}
```
2 changes: 2 additions & 0 deletions assets/chezmoi.io/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ 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
- findOneExecutable: reference/templates/functions/findOneExecutable.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
66 changes: 66 additions & 0 deletions internal/chezmoi/findexecutable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package chezmoi

import (
"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(files, paths []string) (string, error) {
foundExecutableCacheMutex.Lock()
defer foundExecutableCacheMutex.Unlock()

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
}
62 changes: 62 additions & 0 deletions internal/chezmoi/findexecutable_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package chezmoi

import (
"fmt"
"testing"

"github.com/alecthomas/assert/v2"
)

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

{
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)
assert.NoError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}
63 changes: 63 additions & 0 deletions internal/chezmoi/findexecutable_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//go:build !windows && !darwin

package chezmoi

import (
"fmt"
"testing"

"github.com/alecthomas/assert/v2"
)

func TestFindExecutable(t *testing.T) {
tests := []struct {
files []string
paths []string
expected string
}{
{
files: []string{"yes"},
paths: []string{"/usr/bin", "/bin"},
expected: "/usr/bin/yes",
},
{
files: []string{"sh"},
paths: []string{"/bin", "/usr/bin"},
expected: "/bin/sh",
},
{
files: []string{"chezmoish"},
paths: []string{"/bin", "/usr/bin"},
expected: "",
},
{
files: []string{"chezmoish", "yes"},
paths: []string{"/usr/bin", "/bin"},
expected: "/usr/bin/yes",
},
{
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)
assert.NoError(t, err)
assert.Equal(t, test.expected, actual)
})
}
}
Loading

0 comments on commit 8cb3715

Please sign in to comment.