diff --git a/assets/chezmoi.io/docs/reference/templates/functions/lookPathIn.md b/assets/chezmoi.io/docs/reference/templates/functions/lookPathIn.md new file mode 100644 index 00000000000..b835affe7e8 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/lookPathIn.md @@ -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 }} + ``` diff --git a/assets/chezmoi.io/mkdocs.yml b/assets/chezmoi.io/mkdocs.yml index f3415a88a47..86a2b8bd23f 100644 --- a/assets/chezmoi.io/mkdocs.yml +++ b/assets/chezmoi.io/mkdocs.yml @@ -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 diff --git a/internal/chezmoi/chezmoi_unix.go b/internal/chezmoi/chezmoi_unix.go index dd8a57d2683..c3d27efb227 100644 --- a/internal/chezmoi/chezmoi_unix.go +++ b/internal/chezmoi/chezmoi_unix.go @@ -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 diff --git a/internal/chezmoi/chezmoi_windows.go b/internal/chezmoi/chezmoi_windows.go index a807449fd41..00bfca32a45 100644 --- a/internal/chezmoi/chezmoi_windows.go +++ b/internal/chezmoi/chezmoi_windows.go @@ -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. diff --git a/internal/chezmoi/lookpathin.go b/internal/chezmoi/lookpathin.go new file mode 100644 index 00000000000..765fa31c3ce --- /dev/null +++ b/internal/chezmoi/lookpathin.go @@ -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 +} diff --git a/internal/chezmoi/lookpathin_darwin_test.go b/internal/chezmoi/lookpathin_darwin_test.go new file mode 100644 index 00000000000..7f9e4f85b73 --- /dev/null +++ b/internal/chezmoi/lookpathin_darwin_test.go @@ -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) + } + }) + } +} diff --git a/internal/chezmoi/lookpathin_unix_test.go b/internal/chezmoi/lookpathin_unix_test.go new file mode 100644 index 00000000000..d29ed99e8fe --- /dev/null +++ b/internal/chezmoi/lookpathin_unix_test.go @@ -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) + } + }) + } +} diff --git a/internal/chezmoi/lookpathin_windows_test.go b/internal/chezmoi/lookpathin_windows_test.go new file mode 100644 index 00000000000..2c245650a00 --- /dev/null +++ b/internal/chezmoi/lookpathin_windows_test.go @@ -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) + } + }) + } +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 7fa56d818af..d386694a0e0 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -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, diff --git a/internal/cmd/templatefuncs.go b/internal/cmd/templatefuncs.go index 5231386a0d0..f19cbd6d10d 100644 --- a/internal/cmd/templatefuncs.go +++ b/internal/cmd/templatefuncs.go @@ -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: diff --git a/internal/cmd/testdata/scripts/templatefuncs_unix.txtar b/internal/cmd/testdata/scripts/templatefuncs_unix.txtar new file mode 100644 index 00000000000..78c67fad7a0 --- /dev/null +++ b/internal/cmd/testdata/scripts/templatefuncs_unix.txtar @@ -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 ^$ + diff --git a/internal/cmd/testdata/scripts/templatefuncs_windows.txtar b/internal/cmd/testdata/scripts/templatefuncs_windows.txtar new file mode 100644 index 00000000000..27647c80326 --- /dev/null +++ b/internal/cmd/testdata/scripts/templatefuncs_windows.txtar @@ -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 '^$'