Skip to content

Commit

Permalink
feat: Create lookPathIn As per: #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 3, 2023
1 parent c5f30c8 commit cad963e
Show file tree
Hide file tree
Showing 8 changed files with 149 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
40 changes: 38 additions & 2 deletions internal/chezmoi/chezmoi_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,49 @@ 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}
}
result := make([]string, len(cmdExt))
for i, e := range getPathExt() {
result[i] = path + e
}
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
}
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
8 changes: 8 additions & 0 deletions internal/cmd/testdata/scripts/templatefuncs.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ stdout ^value$
exec chezmoi execute-template '{{ lookPath "go" }}'
stdout go$exe

# 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 ^$

# test lookPath template function to check if file exists
exec chezmoi execute-template '{{ lookPath "/non-existing-file" }}'
! stdout .
Expand Down

0 comments on commit cad963e

Please sign in to comment.