From f61d038d5e1525b00bc877598d81647f542db883 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Tue, 8 Aug 2023 11:31:26 +1000 Subject: [PATCH] feat: Create `findExecutable` which searches multiple paths for an executable of a particular name and returns the found path. As per: https://github.com/twpayne/chezmoi/issues/3141 --- .../templates/functions/findExecutable.md | 20 +++++++ assets/chezmoi.io/mkdocs.yml | 1 + internal/chezmoi/chezmoi_unix.go | 5 ++ internal/chezmoi/chezmoi_windows.go | 15 +++++ internal/chezmoi/findexecutable.go | 47 +++++++++++++++ .../chezmoi/findexecutable_darwin_test.go | 35 +++++++++++ internal/chezmoi/findexecutable_unix_test.go | 50 ++++++++++++++++ .../chezmoi/findexecutable_windows_test.go | 59 +++++++++++++++++++ internal/cmd/config.go | 3 +- internal/cmd/templatefuncs.go | 9 +++ .../cmd/testdata/scripts/templatefuncs.txtar | 21 +++++++ 11 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 assets/chezmoi.io/docs/reference/templates/functions/findExecutable.md create mode 100644 internal/chezmoi/findexecutable.go create mode 100644 internal/chezmoi/findexecutable_darwin_test.go create mode 100644 internal/chezmoi/findexecutable_unix_test.go create mode 100644 internal/chezmoi/findexecutable_windows_test.go diff --git a/assets/chezmoi.io/docs/reference/templates/functions/findExecutable.md b/assets/chezmoi.io/docs/reference/templates/functions/findExecutable.md new file mode 100644 index 000000000000..caa1eebae688 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/findExecutable.md @@ -0,0 +1,20 @@ +# `findExecutable` *file* *...paths* + +`findExecutable` searches for an executable named *file* in the directories provided by +the varargs `paths` parameter. In the case of Windows it will return the correct extension +if none was provided based on `PathExt`. + +`findExecutable` is provided as an alternative to `lookPath` so that you interrogate the +paths as you would have them after deployment of the RC script. + +Each successful lookup is cached based on the full path, and evaluated in the correct +order each time to reduce `File` `Stat` operations. + +!!! example + + ``` + {{ if findExecutable "less" "bin" "go/bin" ".cargo/bin" ".local/bin" }} + echo "Good news we have found 'less' on system at '{{ findExecutable "less" "bin" "go/bin" ".cargo/bin" ".local/bin" }}'!" + export DIFFTOOL=less + {{ end }} + ``` diff --git a/assets/chezmoi.io/mkdocs.yml b/assets/chezmoi.io/mkdocs.yml index 5d9bd59d49a5..9adb84119a40 100644 --- a/assets/chezmoi.io/mkdocs.yml +++ b/assets/chezmoi.io/mkdocs.yml @@ -185,6 +185,7 @@ 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 - fromIni: reference/templates/functions/fromIni.md - fromJsonc: reference/templates/functions/fromJsonc.md - fromToml: reference/templates/functions/fromToml.md diff --git a/internal/chezmoi/chezmoi_unix.go b/internal/chezmoi/chezmoi_unix.go index 2aa7df61bd6f..e50f104e9b2f 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 6d0cd141e09e..d7b104bf6bb3 100644 --- a/internal/chezmoi/chezmoi_windows.go +++ b/internal/chezmoi/chezmoi_windows.go @@ -13,6 +13,21 @@ const nativeLineEnding = "\r\n" var pathExts = strings.Split(os.Getenv("PATHEXT"), string(filepath.ListSeparator)) +// findExecutableExtensions returns valid OS executable extensions for a given executable basename - does not check the +// existence. +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. diff --git a/internal/chezmoi/findexecutable.go b/internal/chezmoi/findexecutable.go new file mode 100644 index 000000000000..1d5ef1d4b3f9 --- /dev/null +++ b/internal/chezmoi/findexecutable.go @@ -0,0 +1,47 @@ +package chezmoi + +import ( + "os" + "path/filepath" + "sync" +) + +var ( + foundExecutableCacheMutex sync.Mutex + foundExecutableCache = make(map[string]struct{}) +) + +// FindExecutable 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 FindExecutable(file string, 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 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/findexecutable_darwin_test.go b/internal/chezmoi/findexecutable_darwin_test.go new file mode 100644 index 000000000000..475627fa74fd --- /dev/null +++ b/internal/chezmoi/findexecutable_darwin_test.go @@ -0,0 +1,35 @@ +//go:build darwin + +package chezmoi + +import "testing" + +func TestFindExecutable(t *testing.T) { + tests := []struct { + name string + file string + paths []string + want string + wantErr bool + }{ + { + name: "Finds first", + file: "sh", + paths: []string{"/usr/bin", "/bin"}, + want: "/bin/sh", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FindExecutable(tt.file, tt.paths) + if (err != nil) != tt.wantErr { + t.Errorf("FindExecutable() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("FindExecutable() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/chezmoi/findexecutable_unix_test.go b/internal/chezmoi/findexecutable_unix_test.go new file mode 100644 index 000000000000..caf5ae0e34b2 --- /dev/null +++ b/internal/chezmoi/findexecutable_unix_test.go @@ -0,0 +1,50 @@ +//go:build !windows && !darwin + +package chezmoi + +import ( + "os" + "testing" +) + +func TestFindExecutable(t *testing.T) { + tests := []struct { + name string + file string + paths []string + want string + wantErr bool + }{ + { + name: "Finds first", + file: "sh", + paths: []string{"/usr/bin", "/bin"}, + want: "/usr/bin/sh", + wantErr: false, + }, + { + name: "Finds first 2", + file: "sh", + paths: []string{"/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 := FindExecutable(tt.file, tt.paths...) + if (err != nil) != tt.wantErr { + t.Errorf("FindExecutable() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("FindExecutable() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/chezmoi/findexecutable_windows_test.go b/internal/chezmoi/findexecutable_windows_test.go new file mode 100644 index 000000000000..8fd933abfb8a --- /dev/null +++ b/internal/chezmoi/findexecutable_windows_test.go @@ -0,0 +1,59 @@ +//go:build windows + +package chezmoi + +import ( + "strings" + "testing" +) + +func TestFindExecutable(t *testing.T) { + tests := []struct { + name string + file string + paths []string + want string + wantErr bool + }{ + { + name: "Finds with extension", + file: "powershell.exe", + paths: []string{"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: []string{"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: []string{"c:\\windows\\system32", "c:\\windows\\system64\",\"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0"}, + want: "", + wantErr: false, + }, + { + name: "Fails to find without extension", + file: "weakshell", + paths: []string{"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 := FindExecutable(tt.file, tt.paths...) + if (err != nil) != tt.wantErr { + t.Errorf("FindExecutable() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !strings.EqualFold(got, tt.want) { + t.Errorf("FindExecutable() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 782d364449f5..38ff4c16bb0a 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -403,6 +403,7 @@ func newConfig(options ...configOption) (*Config, error) { "ejsonDecryptWithKey": c.ejsonDecryptWithKeyTemplateFunc, "encrypt": c.encryptTemplateFunc, "eqFold": c.eqFoldTemplateFunc, + "findExecutable": c.findExecutableTemplateFunc, "fromIni": c.fromIniTemplateFunc, "fromJsonc": c.fromJsoncTemplateFunc, "fromToml": c.fromTomlTemplateFunc, @@ -443,8 +444,8 @@ func newConfig(options ...configOption) (*Config, error) { "output": c.outputTemplateFunc, "pass": c.passTemplateFunc, "passFields": c.passFieldsTemplateFunc, - "passRaw": c.passRawTemplateFunc, "passhole": c.passholeTemplateFunc, + "passRaw": c.passRawTemplateFunc, "pruneEmptyDicts": c.pruneEmptyDictsTemplateFunc, "quoteList": c.quoteListTemplateFunc, "rbw": c.rbwTemplateFunc, diff --git a/internal/cmd/templatefuncs.go b/internal/cmd/templatefuncs.go index 9537bd2e75c0..15d5dc5dd91a 100644 --- a/internal/cmd/templatefuncs.go +++ b/internal/cmd/templatefuncs.go @@ -133,6 +133,15 @@ func (c *Config) eqFoldTemplateFunc(first, second string, more ...string) bool { return false } +func (c *Config) findExecutableTemplateFunc(file string, paths ...string) string { + switch path, err := chezmoi.FindExecutable(file, paths...); { + case err == nil: + return path + default: + panic(err) + } +} + func (c *Config) fromIniTemplateFunc(s string) map[string]any { file, err := ini.Load([]byte(s)) if err != nil { diff --git a/internal/cmd/testdata/scripts/templatefuncs.txtar b/internal/cmd/testdata/scripts/templatefuncs.txtar index 19abb399ee03..6a568ce1eb75 100644 --- a/internal/cmd/testdata/scripts/templatefuncs.txtar +++ b/internal/cmd/testdata/scripts/templatefuncs.txtar @@ -78,6 +78,27 @@ stdout ^true$ exec chezmoi execute-template '{{ isExecutable "bin/not-executable" }}' stdout ^false$ +# test findExecutable template function to find in specified script - success +[!windows] exec chezmoi execute-template '{{ findExecutable "echo" "/lib" "/bin" "/usr/bin" }}' +[!windows] stdout ^/bin/echo$ + +# test findExecutable template function to find in specified script - failure +[!windows] exec chezmoi execute-template '{{ findExecutable "echo" "/lib" }}' +[!windows] stdout ^$ + +# test findExecutable template function to find in specified script - success with extension +[windows] exec chezmoi execute-template '{{ findExecutable "git.exe" "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd" }}' +[windows] stdout '^C:\\Program Files\\Git\\cmd\\git.exe$' + +# test findExecutable template function to find in specified script - success without extension +[windows] exec chezmoi execute-template '{{ findExecutable "git" "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd" }}' +[windows] stdout '^C:\\Program Files\\Git\\cmd\\git.exe$' + +# test findExecutable template function to find in specified script - failure +[windows] exec chezmoi execute-template '{{ findExecutable "asdf" "c:\\windows\\system32" "c:\\windows\\system64" "C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0" "C:\\Program Files\\Git\\cmd" }}' +[windows] stdout '^$' + + # test lookPath template function to find in PATH exec chezmoi execute-template '{{ lookPath "go" }}' stdout go$exe