Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds lookPathIn #3148

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 '^$'
Loading