├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── asdf.go ├── config.go ├── config_test.go ├── fixtures ├── asdf │ ├── installs │ │ ├── go │ │ │ └── 1.9.1 │ │ │ │ └── go │ │ │ │ └── bin │ │ │ │ └── go │ │ ├── python │ │ │ ├── 2.7.11 │ │ │ │ └── bin │ │ │ │ │ └── 2to3 │ │ │ └── 3.6.7 │ │ │ │ └── bin │ │ │ │ └── flask │ │ └── ruby │ │ │ └── 2.0.0 │ │ │ └── bin │ │ │ └── gem │ ├── plugins │ │ ├── go │ │ │ └── bin │ │ │ │ └── list-bin-paths │ │ ├── python │ │ │ └── bin │ │ │ │ └── list-legacy-filenames │ │ └── ruby │ │ │ └── bin │ │ │ ├── list-legacy-filenames │ │ │ └── parse-legacy-file │ └── shims │ │ └── flask └── some-dir │ ├── .tool-versions │ ├── nested-dir │ └── .keep │ └── with-legacy-version │ ├── .python-version │ └── Gemfile ├── go.mod ├── go.sum ├── main.go ├── shim.go ├── shim_test.go ├── utils.go ├── utils_test.go ├── version_manager.go └── version_manager_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/golang:1.9 6 | 7 | working_directory: /go/src/github.com/danhper/asdf-exec 8 | steps: 9 | - checkout 10 | 11 | - run: go get -v -t -d ./... 12 | - run: go test -v ./... 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | asdf-exec 2 | build/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Perez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: linux macos 2 | 3 | 4 | build: 5 | mkdir -p build 6 | 7 | linux: build 8 | GOOS=linux go build -o build/asdf-exec-linux 9 | 10 | macos: build 11 | GOOS=darwin go build -o build/asdf-exec-darwin 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # asdf-exec 2 | 3 | [![CircleCI](https://circleci.com/gh/danhper/asdf-exec.svg?style=svg)](https://circleci.com/gh/danhper/asdf-exec) 4 | 5 | Experimental command to find and run executable used by [asdf][asdf] shims. 6 | 7 | NOTE: This is not officially supported by asdf and requires to slightly 8 | patch the asdf code 9 | 10 | ## Installation 11 | 12 | I assume `asdf` is installed in `~/.asdf`. If not, please change the commands accordingly. 13 | 14 | First, grab the `asdf-exec` executable from [the releases](https://github.com/danhper/asdf-exec/releases/) 15 | and put it in `~/.asdf/bin/private` as `asdf-exec` 16 | 17 | ``` 18 | # for linux 19 | wget https://github.com/danhper/asdf-exec/releases/download/v0.1.2/asdf-exec-linux-x64 -O ~/.asdf/bin/private/asdf-exec 20 | # for macos 21 | wget https://github.com/danhper/asdf-exec/releases/download/v0.1.2/asdf-exec-darwin-x64 -O ~/.asdf/bin/private/asdf-exec 22 | 23 | # for both: 24 | chmod +x ~/.asdf/bin/private/asdf-exec 25 | ``` 26 | 27 | Then, patch asdf reshim command code and regenerate all shims. 28 | 29 | ``` 30 | sed -i.bak -e 's|exec $(asdf_dir)/bin/asdf exec|exec $(asdf_dir)/bin/private/asdf-exec|' ~/.asdf/lib/commands/command-reshim.bash 31 | rm ~/.asdf/shims/* 32 | asdf reshim 33 | ``` 34 | 35 | ## Rationale 36 | 37 | As asdf is growing in features and complexity, the logic to run a single 38 | command has become fairly involved and unfortunately quite slow. 39 | For example 40 | 41 | ``` 42 | $ time python --version 43 | Python 3.7.2 44 | 0.19user 0.04system 0:00.16elapsed 142%CPU (0avgtext+0avgdata 4344maxresident)k 45 | 0inputs+0outputs (0major+25175minor)pagefaults 0swaps 46 | ``` 47 | 48 | While there are surely many things we could do better with bash to improve 49 | performance, tuning bash is quite tedious and error-prone. 50 | 51 | I decided to give a native command a try. This would be called from shims 52 | and would take care of locating the correct version. 53 | Although this does results in quite a bit of duplication between the bash 54 | code and this one, it does give a speed improvement which might be worth it. 55 | 56 | ``` 57 | $ time python --version 58 | Python 3.7.2 59 | 0.00user 0.00system 0:00.01elapsed 100%CPU (0avgtext+0avgdata 4096maxresident)k 60 | 0inputs+0outputs (0major+691minor)pagefaults 0swaps 61 | ``` 62 | 63 | This is still totally experimental and there are no plan to merge or use this into 64 | asdf for now, but please feel free to give it a try and let me know what you think. 65 | 66 | 67 | 68 | 69 | [asdf]: https://github.com/asdf-vm/asdf 70 | -------------------------------------------------------------------------------- /asdf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | // GetAsdfDataPath returns the path for asdf 9 | func GetAsdfDataPath() string { 10 | dir := os.Getenv("ASDF_DATA_DIR") 11 | if dir != "" { 12 | return dir 13 | } 14 | return path.Join(os.Getenv("HOME"), ".asdf") 15 | } 16 | 17 | // GetPluginPath returns the path of the plugin 18 | func GetPluginPath(plugin string) string { 19 | return path.Join(GetAsdfDataPath(), "plugins", plugin) 20 | } 21 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "strings" 9 | ) 10 | 11 | // Config contains asdf configuration 12 | type Config struct { 13 | // Whether to use legacy version files such as .python-version 14 | LegacyVersionFile bool 15 | } 16 | 17 | // ParseBool parses a bool from a string 18 | func ParseBool(boolStr string) (bool, error) { 19 | boolStr = strings.ToLower(boolStr) 20 | if boolStr == "yes" || boolStr == "1" || boolStr == "true" { 21 | return true, nil 22 | } else if boolStr == "no" || boolStr == "0" || boolStr == "false" { 23 | return false, nil 24 | } else { 25 | return false, fmt.Errorf("unexepected boolean value: %s", boolStr) 26 | } 27 | } 28 | 29 | // ConfigFromString returns a config from a string 30 | func ConfigFromString(content string) (config Config, err error) { 31 | for _, line := range ReadLines(content) { 32 | tokens := strings.Split(line, "=") 33 | if len(tokens) != 2 { 34 | return config, fmt.Errorf("invalid line in configuration file: ") 35 | } 36 | key := strings.TrimSpace(tokens[0]) 37 | rawValue := strings.TrimSpace(tokens[1]) 38 | value, err := ParseBool(rawValue) 39 | if err != nil { 40 | return config, err 41 | } 42 | if key == "legacy_version_file" { 43 | config.LegacyVersionFile = value 44 | } 45 | } 46 | return 47 | } 48 | 49 | // ConfigFromFile returns a config from the given configuration file 50 | func ConfigFromFile(filepath string) (Config, error) { 51 | defaultConfig := Config{LegacyVersionFile: false} 52 | if _, err := os.Stat(filepath); err != nil { 53 | return defaultConfig, nil 54 | } 55 | content, err := ioutil.ReadFile(filepath) 56 | if err != nil { 57 | return defaultConfig, err 58 | } 59 | return ConfigFromString(string(content)) 60 | } 61 | 62 | // ConfigFromDefaultFile returns a config from the default configuration file 63 | func ConfigFromDefaultFile() (Config, error) { 64 | filepath := path.Join(os.Getenv("HOME"), ".asdfrc") 65 | return ConfigFromFile(filepath) 66 | } 67 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseBool(t *testing.T) { 10 | for _, value := range []string{"yes", "1", "true", "True", "YES"} { 11 | res, err := ParseBool(value) 12 | assert.Nil(t, err) 13 | assert.Equal(t, true, res) 14 | } 15 | for _, value := range []string{"no", "0", "false", "False", "NO"} { 16 | res, err := ParseBool(value) 17 | assert.Nil(t, err) 18 | assert.Equal(t, false, res) 19 | } 20 | for _, value := range []string{"aaq", "something crazy", "123"} { 21 | _, err := ParseBool(value) 22 | assert.NotNil(t, err) 23 | } 24 | } 25 | 26 | func TestConfigFromString(t *testing.T) { 27 | config, err := ConfigFromString(` 28 | # single line comment 29 | legacy_version_file = yes # end of line comment 30 | # empty line 31 | 32 | use_release_candidates = true 33 | unknown_key = false 34 | `) 35 | assert.Nil(t, err) 36 | assert.True(t, config.LegacyVersionFile) 37 | 38 | _, err = ConfigFromString(` 39 | # bad format 40 | this is a bad line 41 | `) 42 | assert.NotNil(t, err) 43 | } 44 | -------------------------------------------------------------------------------- /fixtures/asdf/installs/go/1.9.1/go/bin/go: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/asdf-exec/b71340b830717b487ff6166f5479881596a38ef0/fixtures/asdf/installs/go/1.9.1/go/bin/go -------------------------------------------------------------------------------- /fixtures/asdf/installs/python/2.7.11/bin/2to3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/asdf-exec/b71340b830717b487ff6166f5479881596a38ef0/fixtures/asdf/installs/python/2.7.11/bin/2to3 -------------------------------------------------------------------------------- /fixtures/asdf/installs/python/3.6.7/bin/flask: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/asdf-exec/b71340b830717b487ff6166f5479881596a38ef0/fixtures/asdf/installs/python/3.6.7/bin/flask -------------------------------------------------------------------------------- /fixtures/asdf/installs/ruby/2.0.0/bin/gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/asdf-exec/b71340b830717b487ff6166f5479881596a38ef0/fixtures/asdf/installs/ruby/2.0.0/bin/gem -------------------------------------------------------------------------------- /fixtures/asdf/plugins/go/bin/list-bin-paths: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo -n "go/bin packages/bin" 4 | -------------------------------------------------------------------------------- /fixtures/asdf/plugins/python/bin/list-legacy-filenames: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo ".python-version" 4 | -------------------------------------------------------------------------------- /fixtures/asdf/plugins/ruby/bin/list-legacy-filenames: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo ".ruby-version Gemfile" 4 | -------------------------------------------------------------------------------- /fixtures/asdf/plugins/ruby/bin/parse-legacy-file: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | get_legacy_version() { 4 | current_file="$1" 5 | basename=$(basename -- "$current_file") 6 | 7 | if [ "$basename" == "Gemfile" ]; then 8 | RUBY_VERSION=$(grep '^\s*ruby' "$current_file" | 9 | sed -e 's/[[:space:]]/ /g' -e 's/#.*//' \ 10 | -e 's/engine:/:engine =>/' -e 's/engine_version:/:engine_version =>/' \ 11 | -e "s/.*:engine *=> *['\"]\([^'\"]*\).*:engine_version *=> *['\"]\([^'\"]*\).*/\1-\2/" \ 12 | -e "s/.*:engine_version *=> *['\"]\([^'\"]*\).*:engine *=> *['\"]\([^'\"]*\).*/\2-\1/" \ 13 | -e "s/ *ruby *['\"]\([^'\"]*\).*/\1/" | 14 | head -1) 15 | elif [ "$basename" == ".ruby-version" ]; then 16 | # Get version from .ruby-version file (filters out 'ruby-' prefix if it exists). 17 | # The .ruby-version is used by rbenv and now rvm. 18 | ruby_version=$(cat "$current_file") 19 | ruby_prefix="ruby-" 20 | RUBY_VERSION=${ruby_version/#$ruby_prefix} 21 | fi 22 | 23 | echo "$RUBY_VERSION" 24 | } 25 | 26 | get_legacy_version "$1" -------------------------------------------------------------------------------- /fixtures/asdf/shims/flask: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # asdf-plugin: python 3.5.2 3 | # asdf-plugin: python 2.7.11 4 | # asdf-plugin: python 3.6.7 5 | exec /home/daniel/.asdf/bin/asdf exec "flask" "$@" 6 | -------------------------------------------------------------------------------- /fixtures/some-dir/.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.6.7 2.7.11 2 | -------------------------------------------------------------------------------- /fixtures/some-dir/nested-dir/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhper/asdf-exec/b71340b830717b487ff6166f5479881596a38ef0/fixtures/some-dir/nested-dir/.keep -------------------------------------------------------------------------------- /fixtures/some-dir/with-legacy-version/.python-version: -------------------------------------------------------------------------------- 1 | 2.7.11 2 | -------------------------------------------------------------------------------- /fixtures/some-dir/with-legacy-version/Gemfile: -------------------------------------------------------------------------------- 1 | ruby '2.0.0' 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/danhper/asdf-exec 2 | 3 | go 1.17 4 | 5 | require github.com/stretchr/testify v1.7.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.0 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 7 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 11 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "syscall" 7 | ) 8 | 9 | func findExecutablePath() (string, error) { 10 | config, err := ConfigFromDefaultFile() 11 | if err != nil { 12 | return "", err 13 | } 14 | 15 | shim := os.Args[1] 16 | executablePath, found, err := FindExecutable(shim, config) 17 | if err != nil { 18 | return "", err 19 | } 20 | if !found { 21 | return "", fmt.Errorf("%s not found", shim) 22 | } 23 | return executablePath, nil 24 | } 25 | 26 | func main() { 27 | executable, err := findExecutablePath() 28 | if err != nil { 29 | fmt.Fprintln(os.Stderr, "error: "+err.Error()) 30 | os.Exit(1) 31 | } 32 | 33 | args := []string{executable} 34 | args = append(args, os.Args[2:]...) 35 | syscall.Exec(executable, args, os.Environ()) 36 | } 37 | -------------------------------------------------------------------------------- /shim.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | const ( 13 | asdfPluginPrefix string = "# asdf-plugin: " 14 | listBinPathScript string = "list-bin-paths" 15 | ) 16 | 17 | // Executable is an instance of a single executable 18 | type Executable struct { 19 | Name string 20 | PluginName string 21 | PluginVersion string 22 | } 23 | 24 | // ParseExecutableLine returns an executable from a shim plugin line 25 | func ParseExecutableLine(name string, fullLine string) (Executable, error) { 26 | line := strings.Replace(fullLine, asdfPluginPrefix, "", -1) 27 | tokens := strings.Split(line, " ") 28 | if len(tokens) != 2 { 29 | return Executable{}, fmt.Errorf("bad line %s", fullLine) 30 | } 31 | return Executable{ 32 | Name: name, 33 | PluginName: strings.TrimSpace(tokens[0]), 34 | PluginVersion: strings.TrimSpace(tokens[1]), 35 | }, nil 36 | } 37 | 38 | // GetExecutablesFromShim retrieves all the executable for a shim 39 | func GetExecutablesFromShim(name string, content string) (executables []Executable, err error) { 40 | for _, line := range strings.Split(content, "\n") { 41 | line = strings.TrimSpace(line) 42 | if strings.HasPrefix(line, asdfPluginPrefix) { 43 | executable, err := ParseExecutableLine(name, line) 44 | if err != nil { 45 | return executables, err 46 | } 47 | executables = append(executables, executable) 48 | } 49 | } 50 | return 51 | } 52 | 53 | // GetExecutablesFromShimFile retrieves all the executable for a shim file 54 | func GetExecutablesFromShimFile(filepath string) ([]Executable, error) { 55 | name := path.Base(filepath) 56 | content, err := ioutil.ReadFile(filepath) 57 | if err != nil { 58 | return []Executable{}, err 59 | } 60 | return GetExecutablesFromShim(name, string(content)) 61 | } 62 | 63 | // FindSystemExecutable returns the path to the system 64 | // executable if found 65 | func FindSystemExecutable(executableName string) (string, bool) { 66 | currentPath := os.Getenv("PATH") 67 | defer os.Setenv("PATH", currentPath) 68 | os.Setenv("PATH", RemoveAsdfPath(currentPath)) 69 | executablePath, err := exec.LookPath(executableName) 70 | return executablePath, err == nil 71 | } 72 | 73 | // FindExecutable returns the path to the executable to be executed 74 | func FindExecutable(executableName string, config Config) (string, bool, error) { 75 | shimPath := GetShimPath(executableName) 76 | executables, err := GetExecutablesFromShimFile(shimPath) 77 | if err != nil { 78 | return "", false, err 79 | } 80 | 81 | var pluginNames []string 82 | plugins := make(map[string][]Executable) 83 | 84 | for _, executable := range executables { 85 | pluginExecutables, ok := plugins[executable.PluginName] 86 | if !ok { 87 | pluginExecutables = []Executable{} 88 | } 89 | pluginExecutables = append(pluginExecutables, executable) 90 | plugins[executable.PluginName] = pluginExecutables 91 | pluginNames = append(pluginNames, executable.PluginName) 92 | } 93 | 94 | formattedPluginNames := strings.Join(pluginNames, ", ") 95 | 96 | type versionsExecutables struct { 97 | versions []string 98 | executables []Executable 99 | } 100 | 101 | var versionsWithExecutables []versionsExecutables 102 | for plugin, pluginExecutables := range plugins { 103 | toolVersions, found, err := FindVersions(plugin, config) 104 | if err != nil { 105 | return "", false, err 106 | } 107 | if found { 108 | newEntry := versionsExecutables{toolVersions, pluginExecutables} 109 | versionsWithExecutables = append(versionsWithExecutables, newEntry) 110 | 111 | } 112 | } 113 | if len(versionsWithExecutables) == 0 { 114 | return "", false, fmt.Errorf("no versions set for %s", formattedPluginNames) 115 | } 116 | 117 | for _, entry := range versionsWithExecutables { 118 | for _, toolVersion := range entry.versions { 119 | if toolVersion == "system" { 120 | if executablePath, found := FindSystemExecutable(executableName); found { 121 | return executablePath, true, nil 122 | } 123 | } 124 | for _, executable := range entry.executables { 125 | if toolVersion == executable.PluginVersion { 126 | if executablePath, err := GetExecutablePath(executable); err == nil { 127 | return executablePath, true, nil 128 | } 129 | } 130 | } 131 | } 132 | } 133 | 134 | return "", false, fmt.Errorf("no %s executable for plugin %s", executableName, formattedPluginNames) 135 | } 136 | 137 | // GetShimPath returns the path of the shim 138 | func GetShimPath(shimName string) string { 139 | return path.Join(GetAsdfDataPath(), "shims", shimName) 140 | } 141 | 142 | // GetExecutablePath returns the path of the executable 143 | func GetExecutablePath(executable Executable) (string, error) { 144 | pluginPath := GetPluginPath(executable.PluginName) 145 | installPath := path.Join( 146 | GetAsdfDataPath(), 147 | "installs", 148 | executable.PluginName, 149 | executable.PluginVersion, 150 | ) 151 | 152 | listBinPath := path.Join(pluginPath, "bin", listBinPathScript) 153 | if _, err := os.Stat(listBinPath); err != nil { 154 | exePath := path.Join(installPath, "bin", executable.Name) 155 | if _, err := os.Stat(exePath); err != nil { 156 | return "", fmt.Errorf("executable not found") 157 | } 158 | return exePath, nil 159 | } 160 | 161 | bashCommand := exec.Command("bash", listBinPath) 162 | bashCommand.Env = os.Environ() 163 | bashCommand.Env = append(bashCommand.Env, fmt.Sprintf("ASDF_INSTALL_VERSION=%s", executable.PluginVersion)) 164 | rawBinPaths, err := bashCommand.Output() 165 | if err != nil { 166 | return "", err 167 | } 168 | paths := strings.Split(string(rawBinPaths), " ") 169 | for _, binPath := range paths { 170 | binPath = strings.TrimSpace(binPath) 171 | exePath := path.Join(installPath, binPath, executable.Name) 172 | if _, err := os.Stat(exePath); err == nil { 173 | return exePath, nil 174 | } 175 | } 176 | return "", fmt.Errorf("executable not found") 177 | } 178 | -------------------------------------------------------------------------------- /shim_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestParseExecutableLine(t *testing.T) { 12 | executable, err := ParseExecutableLine("python", "# asdf-plugin: python 3.6.7") 13 | assert.Nil(t, err) 14 | assert.Equal(t, executable.PluginName, "python") 15 | assert.Equal(t, executable.PluginVersion, "3.6.7") 16 | } 17 | 18 | func TestGetExecutablesFromShim(t *testing.T) { 19 | shimContent := ` 20 | #!/usr/bin/env bash 21 | # asdf-plugin: python 3.6.7 22 | # asdf-plugin: python 2.7.11 23 | exec /home/daniel/.asdf/bin/asdf exec "python" "$@" 24 | ` 25 | executables, err := GetExecutablesFromShim("python", shimContent) 26 | assert.Nil(t, err) 27 | assert.Len(t, executables, 2) 28 | } 29 | 30 | func TestFindExecutable(t *testing.T) { 31 | config := Config{LegacyVersionFile: false} 32 | cwd, err := os.Getwd() 33 | assert.Nil(t, err) 34 | currentHome := os.Getenv("HOME") 35 | os.Setenv("HOME", "/tmp") 36 | os.Setenv("ASDF_DATA_DIR", path.Join(cwd, "fixtures", "asdf")) 37 | 38 | defer os.Setenv("HOME", currentHome) 39 | defer os.Chdir(cwd) 40 | defer os.Unsetenv("ASDF_DATA_DIR") 41 | 42 | assert.Nil(t, os.Chdir("/tmp")) 43 | 44 | _, found, err := FindExecutable("flask", config) 45 | assert.Nil(t, err) 46 | assert.False(t, found) 47 | 48 | assert.Nil(t, os.Chdir(path.Join(cwd, "fixtures", "some-dir", "nested-dir"))) 49 | executablePath, found, err := FindExecutable("flask", config) 50 | assert.Nil(t, err) 51 | assert.True(t, found) 52 | expectedPath := path.Join(GetAsdfDataPath(), "installs", "python", "3.6.7", "bin", "flask") 53 | assert.Equal(t, expectedPath, executablePath) 54 | } 55 | 56 | func TestGetExecutablePath(t *testing.T) { 57 | cwd, err := os.Getwd() 58 | assert.Nil(t, err) 59 | os.Setenv("ASDF_DATA_DIR", path.Join(cwd, "fixtures", "asdf")) 60 | defer os.Unsetenv("ASDF_DATA_DIR") 61 | 62 | executable := Executable{Name: "2to3", PluginName: "python", PluginVersion: "2.7.11"} 63 | executablePath, err := GetExecutablePath(executable) 64 | assert.Nil(t, err) 65 | expected := path.Join(os.Getenv("ASDF_DATA_DIR"), "installs", "python", "2.7.11", "bin", "2to3") 66 | assert.Equal(t, expected, executablePath) 67 | 68 | // check it works with list-bin-paths 69 | executable = Executable{Name: "go", PluginName: "go", PluginVersion: "1.9.1"} 70 | executablePath, err = GetExecutablePath(executable) 71 | assert.Nil(t, err) 72 | expected = path.Join(os.Getenv("ASDF_DATA_DIR"), "installs", "go", "1.9.1", "go", "bin", "go") 73 | assert.Equal(t, expected, executablePath) 74 | } 75 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "path" 5 | "strings" 6 | ) 7 | 8 | // ReadLines reads all the lines in a given file 9 | // removing spaces and comments which are marked by '#' 10 | func ReadLines(content string) (lines []string) { 11 | for _, line := range strings.Split(content, "\n") { 12 | line = strings.SplitN(line, "#", 2)[0] 13 | line = strings.TrimSpace(line) 14 | if len(line) > 0 { 15 | lines = append(lines, line) 16 | } 17 | } 18 | return 19 | } 20 | 21 | // RemoveAsdfPath returns the PATH without asdf shims path 22 | func RemoveAsdfPath(currentPath string) string { 23 | paths := strings.Split(currentPath, ":") 24 | asdfShimPath := path.Join(GetAsdfDataPath(), "shims") 25 | var newPaths []string 26 | for _, fspath := range paths { 27 | if fspath != asdfShimPath { 28 | newPaths = append(newPaths, fspath) 29 | } 30 | } 31 | return strings.Join(newPaths, ":") 32 | } 33 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestReadLines(t *testing.T) { 13 | assert.Equal(t, []string{"foo", "bar"}, ReadLines("foo\nbar")) 14 | assert.Equal(t, []string{"foo", "bar"}, ReadLines("# hello\nfoo #bar \n bar ")) 15 | } 16 | 17 | func TestRemoveAsdfPath(t *testing.T) { 18 | asdfShimPath := path.Join(GetAsdfDataPath(), "shims") 19 | home := os.Getenv("HOME") 20 | homeBin := path.Join(home, "bin") 21 | 22 | currentPath := []string{homeBin, asdfShimPath, "/usr/bin", "/bin"} 23 | actualPath := RemoveAsdfPath(strings.Join(currentPath, ":")) 24 | expectedPath := strings.Join([]string{homeBin, "/usr/bin", "/bin"}, ":") 25 | assert.Equal(t, expectedPath, actualPath) 26 | } 27 | -------------------------------------------------------------------------------- /version_manager.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "os/exec" 7 | "path" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | toolVersionsFile string = ".tool-versions" 13 | legacyFileNamesScript string = "list-legacy-filenames" 14 | parseLegacyFileScript string = "parse-legacy-file" 15 | ) 16 | 17 | var legacyFileNamesCache = make(map[string][]string) 18 | 19 | // ParseVersion parses the raw version 20 | func ParseVersion(rawVersions string) []string { 21 | var versions []string 22 | for _, version := range strings.Split(rawVersions, " ") { 23 | version = strings.TrimSpace(version) 24 | if len(version) > 0 { 25 | versions = append(versions, version) 26 | } 27 | } 28 | return versions 29 | } 30 | 31 | // FindVersionsInEnv returns the version from the environment if present 32 | func FindVersionsInEnv(plugin string) ([]string, bool) { 33 | envVariableName := "ASDF_" + strings.ToUpper(plugin) + "_VERSION" 34 | versionString := os.Getenv(envVariableName) 35 | if versionString == "" { 36 | return nil, false 37 | } 38 | return ParseVersion(versionString), true 39 | } 40 | 41 | // GetLegacyFilenames retrieves the legacy filenames of the plugin 42 | func GetLegacyFilenames(plugin string) ([]string, error) { 43 | pluginPath := GetPluginPath(plugin) 44 | listFilenames := path.Join(pluginPath, "bin", legacyFileNamesScript) 45 | if _, err := os.Stat(listFilenames); err != nil { 46 | return []string{}, nil 47 | } 48 | result, err := exec.Command("bash", listFilenames).Output() 49 | if err != nil { 50 | return nil, err 51 | } 52 | return strings.Split(string(result), " "), nil 53 | } 54 | 55 | // GetVersionsFromLegacyfile returns the versions in the legacy file 56 | func GetVersionsFromLegacyfile(plugin string, legacyFilepath string) (versions []string, err error) { 57 | pluginPath := GetPluginPath(plugin) 58 | parseScriptPath := path.Join(pluginPath, "bin", parseLegacyFileScript) 59 | 60 | var rawVersions []byte 61 | if _, err := os.Stat(parseScriptPath); err == nil { 62 | rawVersions, err = exec.Command("bash", parseScriptPath, legacyFilepath).Output() 63 | } else { 64 | rawVersions, err = ioutil.ReadFile(legacyFilepath) 65 | } 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | for _, version := range strings.Split(string(rawVersions), " ") { 71 | versions = append(versions, strings.TrimSpace(version)) 72 | } 73 | return 74 | } 75 | 76 | // FindVersionsInLegacyFile returns the version from the legacy file if found 77 | func FindVersionsInLegacyFile(dir string, plugin string) (versions []string, found bool, err error) { 78 | var legacyFileNames []string 79 | if names, ok := legacyFileNamesCache[plugin]; ok { 80 | legacyFileNames = names 81 | } else { 82 | legacyFileNames, err = GetLegacyFilenames(plugin) 83 | if err != nil { 84 | return 85 | } 86 | legacyFileNamesCache[plugin] = legacyFileNames 87 | } 88 | for _, filename := range legacyFileNames { 89 | filename = strings.TrimSpace(filename) 90 | filepath := path.Join(dir, filename) 91 | if _, err := os.Stat(filepath); err == nil { 92 | versions, err := GetVersionsFromLegacyfile(plugin, filepath) 93 | if len(versions) == 0 || (len(versions) == 1 && versions[0] == "") { 94 | return nil, false, nil 95 | } 96 | return versions, err == nil, err 97 | } 98 | } 99 | return nil, false, nil 100 | } 101 | 102 | // FindVersionsInDir returns the version from the current directory 103 | func FindVersionsInDir(dir string, plugin string, config Config) ([]string, bool, error) { 104 | filepath := path.Join(dir, toolVersionsFile) 105 | if _, err := os.Stat(filepath); err == nil { 106 | versions, found, err := FindVersionsInToolFile(filepath, plugin) 107 | if found || err != nil { 108 | return versions, found, err 109 | } 110 | } 111 | if config.LegacyVersionFile { 112 | return FindVersionsInLegacyFile(dir, plugin) 113 | 114 | } 115 | return nil, false, nil 116 | } 117 | 118 | // FindVersionsInToolFileContent returns the version of a plugin from the toolsfile content 119 | func FindVersionsInToolFileContent(plugin string, content string) ([]string, bool) { 120 | for _, line := range ReadLines(content) { 121 | tokens := strings.SplitN(line, " ", 2) 122 | if strings.TrimSpace(tokens[0]) == plugin { 123 | return ParseVersion(tokens[1]), true 124 | } 125 | } 126 | return nil, false 127 | } 128 | 129 | // FindVersionsInToolFile returns the version of a plugin from the toolsfile at the given path 130 | func FindVersionsInToolFile(filepath string, plugin string) ([]string, bool, error) { 131 | content, err := ioutil.ReadFile(filepath) 132 | if err != nil { 133 | return nil, false, err 134 | } 135 | versions, found := FindVersionsInToolFileContent(plugin, string(content)) 136 | return versions, found, nil 137 | } 138 | 139 | // FindVersions returns the current versions for the plugin 140 | func FindVersions(plugin string, config Config) ([]string, bool, error) { 141 | version, found := FindVersionsInEnv(plugin) 142 | dir, err := os.Getwd() 143 | if err != nil { 144 | return nil, false, err 145 | } 146 | for !found { 147 | version, found, err = FindVersionsInDir(dir, plugin, config) 148 | if err != nil { 149 | return nil, false, err 150 | } 151 | nextDir := path.Dir(dir) 152 | if nextDir == dir { 153 | break 154 | } 155 | dir = nextDir 156 | } 157 | if !found { 158 | homeToolsFile := path.Join(os.Getenv("HOME"), toolVersionsFile) 159 | if _, err := os.Stat(homeToolsFile); err == nil { 160 | version, found, err = FindVersionsInToolFile(homeToolsFile, plugin) 161 | } 162 | } 163 | return version, found, err 164 | } 165 | -------------------------------------------------------------------------------- /version_manager_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestParseVersion(t *testing.T) { 12 | assert.Equal(t, []string{"3.7.2"}, ParseVersion(" 3.7.2")) 13 | assert.Equal(t, []string{"3.7.2", "2.7.16", "system"}, ParseVersion(" 3.7.2 2.7.16 system")) 14 | } 15 | 16 | func TestFindVersionsInEnv(t *testing.T) { 17 | _, found := FindVersionsInEnv("python") 18 | assert.False(t, found) 19 | os.Setenv("ASDF_PYTHON_VERSION", "3.7.2 2.7.16") 20 | versions, found := FindVersionsInEnv("python") 21 | assert.True(t, found) 22 | assert.Len(t, versions, 2) 23 | assert.Equal(t, []string{"3.7.2", "2.7.16"}, versions) 24 | os.Unsetenv("ASDF_PYTHON_VERSION") 25 | } 26 | 27 | func TestFindVersionsInToolFileContent(t *testing.T) { 28 | content := ` 29 | # some comments 30 | python 3.6.7 2.7.11 system # fallback to system 31 | 32 | ruby 2.6.2 33 | ` 34 | versions, found := FindVersionsInToolFileContent("python", content) 35 | assert.True(t, found) 36 | assert.Len(t, versions, 3) 37 | assert.Equal(t, []string{"3.6.7", "2.7.11", "system"}, versions) 38 | 39 | versions, found = FindVersionsInToolFileContent("ruby", content) 40 | assert.True(t, found) 41 | assert.Len(t, versions, 1) 42 | assert.Equal(t, []string{"2.6.2"}, versions) 43 | 44 | _, found = FindVersionsInToolFileContent("nodejs", content) 45 | assert.False(t, found) 46 | } 47 | 48 | func TestFindVersions(t *testing.T) { 49 | cwd, err := os.Getwd() 50 | assert.Nil(t, err) 51 | 52 | defer os.Chdir(cwd) 53 | defer os.Unsetenv("ASDF_DATA_DIR") 54 | 55 | config := Config{LegacyVersionFile: true} 56 | 57 | os.Setenv("HOME", "/tmp") 58 | assert.Nil(t, os.Chdir("/tmp")) 59 | _, found, err := FindVersions("python", config) 60 | assert.Nil(t, err) 61 | assert.False(t, found) 62 | 63 | assert.Nil(t, os.Chdir(path.Join(cwd, "fixtures", "some-dir", "nested-dir"))) 64 | versions, found, err := FindVersions("python", config) 65 | assert.Nil(t, err) 66 | assert.True(t, found) 67 | assert.Equal(t, []string{"3.6.7", "2.7.11"}, versions) 68 | 69 | os.Setenv("HOME", path.Join(cwd, "fixtures", "some-dir")) 70 | assert.Nil(t, os.Chdir("/tmp")) 71 | versions, found, err = FindVersions("python", config) 72 | assert.Nil(t, err) 73 | assert.True(t, found) 74 | assert.Equal(t, []string{"3.6.7", "2.7.11"}, versions) 75 | 76 | delete(legacyFileNamesCache, "python") 77 | os.Setenv("ASDF_DATA_DIR", path.Join(cwd, "fixtures", "asdf")) 78 | assert.Nil(t, os.Chdir(path.Join(cwd, "fixtures", "some-dir", "with-legacy-version"))) 79 | versions, found, err = FindVersions("python", config) 80 | assert.Nil(t, err) 81 | assert.True(t, found) 82 | assert.Equal(t, []string{"2.7.11"}, versions) 83 | 84 | assert.Nil(t, os.Chdir(path.Join(cwd, "fixtures", "some-dir", "with-legacy-version"))) 85 | versions, found, err = FindVersions("ruby", config) 86 | assert.Nil(t, err) 87 | assert.True(t, found) 88 | assert.Equal(t, []string{"2.0.0"}, versions) 89 | } 90 | --------------------------------------------------------------------------------