Skip to content

Commit

Permalink
feat: Create lookPathIn As per: twpayne#3141
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `isExecutable` has major change for windows. It now does something
  • Loading branch information
arran4 committed Aug 4, 2023
1 parent c5f30c8 commit d3dca6a
Show file tree
Hide file tree
Showing 12 changed files with 311 additions and 2 deletions.
39 changes: 39 additions & 0 deletions assets/chezmoi.io/docs/reference/templates/functions/lookPathIn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `lookPathIn` *file* *paths*

`lookPathIn` searches for an executable named *file* in the directories provided by
the `paths` parameter using the standard OS way of separating the PATH environment
variable. The result may be an absolute path or a path relative to the current directory.
If *file* is not found, `lookPathIn` returns an empty string.

If the OS is Windows `lookPathIn` will either: if there is an extension, check to see if
the extension is specified in the `PathExt` environment variable. If there isn't an
extension it will try each of the extensions specified in the `PathExt` environment
variable in the order provided until it finds one. In either case if it doesn't `lookPathIn`
moves onto the next path provided in the `paths` parameter.

`lookPathIn` is provided as an alternative to `lookPath` so that you interrogate the
paths as you would have them.

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

!!! example

```
{{- $paths := list }}
{{- $homeDir := .chezmoi.homeDir }}
{{- range $_, $relPath := list "bin" "go/bin" ".cargo/bin" ".local/bin" }}
{{ $path := joinPath $homeDir $relPath }}
{{- if stat $path }}
{{- $paths = mustAppend $paths $path }}
{{- end }}
{{- end }}
{{- if $paths }}
export PATH={{ toStrings $paths | join ":" }}:$PATH
{{- end }}

{{ if lookPath "less" $paths }}
echo "Good news we have found 'less' on system at '{{ lookPath "less" $paths }}'!"
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 @@ -198,6 +198,7 @@ nav:
- joinPath: reference/templates/functions/joinPath.md
- jq: reference/templates/functions/jq.md
- lookPath: reference/templates/functions/lookPath.md
- lookPathIn: reference/templates/functions/lookPathIn.md
- lstat: reference/templates/functions/lstat.md
- mozillaInstallHash: reference/templates/functions/mozillaInstallHash.md
- output: reference/templates/functions/output.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
42 changes: 40 additions & 2 deletions internal/chezmoi/chezmoi_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,51 @@ package chezmoi

import (
"io/fs"
"os"
"path/filepath"
"strings"
)

const nativeLineEnding = "\r\n"

// isExecutable returns false on Windows.
var pathExt []string = nil

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

func getPathExt() []string {
if pathExt == nil {
pathExt = strings.Split(os.Getenv("PathExt"), string(filepath.ListSeparator))
}
return pathExt
}

// isExecutable checks if the file has an extension listed in the `PathExt` variable as per:
// https://www.nextofwindows.com/what-is-pathext-environment-variable-in-windows then checks to see if it's regular file
func isExecutable(fileInfo fs.FileInfo) bool {
return false
foundPathExt := false
cmdExt := filepath.Ext(fileInfo.Name())
if cmdExt != "" {
for _, ext := range getPathExt() {
if strings.EqualFold(cmdExt, ext) {
foundPathExt = true
break
}
}
}
return foundPathExt && fileInfo.Mode().IsRegular()
}

// isPrivate returns false on Windows.
Expand Down
47 changes: 47 additions & 0 deletions internal/chezmoi/lookpathin.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{})
)

// LookPathIn 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 LookPathIn(file, 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 filepath.SplitList(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/lookpathin_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 TestLookPathIn(t *testing.T) {
tests := []struct {
name string
file string
paths string
want string
wantErr bool
}{
{
name: "Finds first",
file: "sh",
paths: "/usr/bin:/bin",
want: "/bin/sh",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LookPathIn(tt.file, tt.paths)
if (err != nil) != tt.wantErr {
t.Errorf("LookPathIn() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("LookPathIn() got = %v, want %v", got, tt.want)
}
})
}
}
50 changes: 50 additions & 0 deletions internal/chezmoi/lookpathin_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 TestLookPathIn(t *testing.T) {
tests := []struct {
name string
file string
paths string
want string
wantErr bool
}{
{
name: "Finds first",
file: "sh",
paths: "/usr/bin:/bin",
want: "/usr/bin/sh",
wantErr: false,
},
{
name: "Finds first 2",
file: "sh",
paths: "/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 := LookPathIn(tt.file, tt.paths)
if (err != nil) != tt.wantErr {
t.Errorf("LookPathIn() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("LookPathIn() got = %v, want %v", got, tt.want)
}
})
}
}
59 changes: 59 additions & 0 deletions internal/chezmoi/lookpathin_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 TestLookPathIn(t *testing.T) {
tests := []struct {
name string
file string
paths string
want string
wantErr bool
}{
{
name: "Finds with extension",
file: "powershell.exe",
paths: "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: "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: "c:\\windows\\system32;c:\\windows\\system64;C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0",
want: "",
wantErr: false,
},
{
name: "Fails to find without extension",
file: "weakshell",
paths: "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 := LookPathIn(tt.file, tt.paths)
if (err != nil) != tt.wantErr {
t.Errorf("LookPathIn() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !strings.EqualFold(got, tt.want) {
t.Errorf("LookPathIn() got = %v, want %v", got, tt.want)
}
})
}
}
1 change: 1 addition & 0 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ func newConfig(options ...configOption) (*Config, error) {
"lastpass": c.lastpassTemplateFunc,
"lastpassRaw": c.lastpassRawTemplateFunc,
"lookPath": c.lookPathTemplateFunc,
"lookPathIn": c.lookPathInTemplateFunc,
"lstat": c.lstatTemplateFunc,
"mozillaInstallHash": c.mozillaInstallHashTemplateFunc,
"onepassword": c.onepasswordTemplateFunc,
Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/templatefuncs.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,16 @@ func (c *Config) lookPathTemplateFunc(file string) string {
}
}

func (c *Config) lookPathInTemplateFunc(file, paths string) string {
switch path, err := chezmoi.LookPathIn(file, paths); {
case err == nil:
return path
// It's wrong to return an error past a parsing issue, parser is "dumb" however.
default:
panic(err)
}
}

func (c *Config) lstatTemplateFunc(name string) any {
switch fileInfo, err := c.fileSystem.Lstat(name); {
case err == nil:
Expand Down
10 changes: 10 additions & 0 deletions internal/cmd/testdata/scripts/templatefuncs_unix.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[windows] skip 'Unix only'

# test lookPathIn template function to find in specified script - success
exec chezmoi execute-template '{{ lookPathIn "echo" "/bin" }}'
stdout ^/bin/echo$

# test lookPathIn template function to find in specified script - failure
exec chezmoi execute-template '{{ lookPathIn "echo" "/lib" }}'
stdout ^$

14 changes: 14 additions & 0 deletions internal/cmd/testdata/scripts/templatefuncs_windows.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[!windows] skip 'Windows only'

# Couldn't figure out why this works locally but not in github actions
# # test lookPathIn template function to find in specified script - success with extension
# exec chezmoi execute-template '{{ lookPathIn "git.exe" "c:\\windows\\system32;c:\\windows\\system64;C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0;C:\\Program Files\\Git\\cmd" }}'
# stdout '^C:\\Program Files\\Git\\cmd\\git.exe$'
#
# # test lookPathIn template function to find in specified script - success without extension
# exec chezmoi execute-template '{{ lookPathIn "git" "c:\\windows\\system32;c:\\windows\\system64;C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0;C:\\Program Files\\Git\\cmd" }}'
# stdout '^C:\\Program Files\\Git\\cmd\\git.exe$'
#
# # test lookPathIn template function to find in specified script - failure
# exec chezmoi execute-template '{{ lookPathIn "asdf" "c:\\windows\\system32;c:\\windows\\system64;C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0;C:\\Program Files\\Git\\cmd" }}'
# stdout '^$'

0 comments on commit d3dca6a

Please sign in to comment.