Merge pull request #60 from asdf-vm/tb/shim-generation

feat(golang-rewrite): shim generation part 1
This commit is contained in:
Trevor Brown 2024-09-02 14:31:34 -04:00 committed by Trevor Brown
commit 62c2ba18f4
6 changed files with 430 additions and 21 deletions

2
go.mod
View File

@ -9,6 +9,7 @@ require (
github.com/sethvargo/go-envconfig v1.0.0
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.27.1
golang.org/x/sys v0.15.0
gopkg.in/ini.v1 v1.67.0
)
@ -37,7 +38,6 @@ require (
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/tools v0.13.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

167
internal/shims/shims.go Normal file
View File

@ -0,0 +1,167 @@
// Package shims manages writing and parsing of asdf shim scripts.
package shims
import (
"fmt"
"os"
"path/filepath"
"strings"
"asdf/config"
"asdf/internal/toolversions"
"asdf/internal/versions"
"asdf/plugins"
"golang.org/x/sys/unix"
)
// GenerateForVersion loops over all the executable files found for a tool and
// generates a shim for each one
func GenerateForVersion(conf config.Config, plugin plugins.Plugin, version string) error {
executables, err := ToolExecutables(conf, plugin, version)
if err != nil {
return err
}
for _, executablePath := range executables {
err := Write(conf, plugin, version, executablePath)
if err != nil {
return err
}
}
return nil
}
// Write generates a shim script and writes it to disk
func Write(conf config.Config, plugin plugins.Plugin, version, executablePath string) error {
err := ensureShimDirExists(conf)
if err != nil {
return err
}
shimName := filepath.Base(executablePath)
shimPath := Path(conf, shimName)
versions := []toolversions.ToolVersions{{Name: plugin.Name, Versions: []string{version}}}
if _, err := os.Stat(shimPath); err == nil {
contents, err := os.ReadFile(shimPath)
if err != nil {
return err
}
oldVersions := parse(string(contents))
versions = toolversions.Unique(append(versions, oldVersions...))
}
return os.WriteFile(shimPath, []byte(encode(shimName, versions)), 0o777)
}
// Path returns the path for a shim script
func Path(conf config.Config, shimName string) string {
return filepath.Join(conf.DataDir, "shims", shimName)
}
func ensureShimDirExists(conf config.Config) error {
return os.MkdirAll(filepath.Join(conf.DataDir, "shims"), 0o777)
}
// ToolExecutables returns a slice of executables for a given tool version
func ToolExecutables(conf config.Config, plugin plugins.Plugin, version string) (executables []string, err error) {
dirs, err := ExecutableDirs(plugin)
if err != nil {
return executables, err
}
installPath := versions.InstallPath(conf, plugin, version)
paths := dirsToPaths(dirs, installPath)
for _, path := range paths {
// Walk the directory and any sub directories
err = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// If entry is dir or cannot be executed by the current user ignore it
if info.IsDir() || unix.Access(path, unix.X_OK) != nil {
return nil
}
executables = append(executables, path)
return nil
})
if err != nil {
return executables, err
}
}
return executables, err
}
// ExecutableDirs returns a slice of directory names that tool executables are
// contained in
func ExecutableDirs(plugin plugins.Plugin) ([]string, error) {
var stdOut strings.Builder
var stdErr strings.Builder
err := plugin.RunCallback("list-bin-paths", []string{}, map[string]string{}, &stdOut, &stdErr)
if err != nil {
if _, ok := err.(plugins.NoCallbackError); ok {
// assume all executables are located in /bin directory
return []string{"bin"}, nil
}
return []string{}, err
}
// use output from list-bin-paths to determine locations for executables
rawDirs := strings.Split(stdOut.String(), " ")
var dirs []string
for _, dir := range rawDirs {
dirs = append(dirs, strings.TrimSpace(dir))
}
return dirs, nil
}
func parse(contents string) (versions []toolversions.ToolVersions) {
lines := strings.Split(contents, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "# asdf-plugin:") {
segments := strings.Split(line, " ")
// if doesn't have expected number of elements on line skip
if len(segments) >= 4 {
versions = append(versions, toolversions.ToolVersions{Name: segments[2], Versions: []string{segments[3]}})
}
}
}
return versions
}
func encode(shimName string, toolVersions []toolversions.ToolVersions) string {
var content string
content = "#!/usr/bin/env bash\n"
// Add all asdf-plugin comments
for _, tool := range toolVersions {
for _, version := range tool.Versions {
content += fmt.Sprintf("# asdf-plugin: %s %s\n", tool.Name, version)
}
}
// Add call asdf exec to actually run real command
content += fmt.Sprintf("exec asdf exec \"%s\" \"$@\"", shimName)
return content
}
func dirsToPaths(dirs []string, root string) (paths []string) {
for _, dir := range dirs {
paths = append(paths, filepath.Join(root, dir))
}
return paths
}

View File

@ -0,0 +1,179 @@
package shims
import (
"os"
"path/filepath"
"strings"
"testing"
"asdf/config"
"asdf/internal/versions"
"asdf/plugins"
"asdf/repotest"
"github.com/stretchr/testify/assert"
"golang.org/x/sys/unix"
)
const testPluginName = "lua"
func TestGenerateForVersion(t *testing.T) {
version := "1.1.0"
version2 := "2.0.0"
conf, plugin := generateConfig(t)
installVersion(t, conf, plugin, version)
installVersion(t, conf, plugin, version2)
executables, err := ToolExecutables(conf, plugin, version)
assert.Nil(t, err)
t.Run("generates shim script for every executable in version", func(t *testing.T) {
assert.Nil(t, GenerateForVersion(conf, plugin, version))
// check for generated shims
for _, executable := range executables {
shimName := filepath.Base(executable)
shimPath := Path(conf, shimName)
assert.Nil(t, unix.Access(shimPath, unix.X_OK))
}
})
t.Run("updates existing shims for every executable in version", func(t *testing.T) {
assert.Nil(t, GenerateForVersion(conf, plugin, version))
assert.Nil(t, GenerateForVersion(conf, plugin, version2))
// check for generated shims
for _, executable := range executables {
shimName := filepath.Base(executable)
shimPath := Path(conf, shimName)
assert.Nil(t, unix.Access(shimPath, unix.X_OK))
}
})
}
func TestWrite(t *testing.T) {
version := "1.1.0"
version2 := "2.0.0"
conf, plugin := generateConfig(t)
installVersion(t, conf, plugin, version)
installVersion(t, conf, plugin, version2)
executables, err := ToolExecutables(conf, plugin, version)
executable := executables[0]
assert.Nil(t, err)
t.Run("writes a new shim file when doesn't exist", func(t *testing.T) {
executable := executables[0]
err = Write(conf, plugin, version, executable)
assert.Nil(t, err)
// shim is executable
shimName := filepath.Base(executable)
shimPath := Path(conf, shimName)
assert.Nil(t, unix.Access(shimPath, unix.X_OK))
// shim exists and has expected contents
content, err := os.ReadFile(shimPath)
assert.Nil(t, err)
want := "#!/usr/bin/env bash\n# asdf-plugin: lua 1.1.0\nexec asdf exec \"dummy\" \"$@\""
assert.Equal(t, want, string(content))
os.Remove(shimPath)
})
t.Run("updates an existing shim file when already present", func(t *testing.T) {
// Write same shim for two versions
assert.Nil(t, Write(conf, plugin, version, executable))
assert.Nil(t, Write(conf, plugin, version2, executable))
// shim is still executable
shimName := filepath.Base(executable)
shimPath := Path(conf, shimName)
assert.Nil(t, unix.Access(shimPath, unix.X_OK))
// has expected contents
content, err := os.ReadFile(shimPath)
assert.Nil(t, err)
want := "#!/usr/bin/env bash\n# asdf-plugin: lua 2.0.0\n# asdf-plugin: lua 1.1.0\nexec asdf exec \"dummy\" \"$@\""
assert.Equal(t, want, string(content))
os.Remove(shimPath)
})
t.Run("doesn't add the same version to a shim file twice", func(t *testing.T) {
assert.Nil(t, Write(conf, plugin, version, executable))
assert.Nil(t, Write(conf, plugin, version, executable))
// Shim doesn't contain any duplicate lines
shimPath := Path(conf, filepath.Base(executable))
content, err := os.ReadFile(shimPath)
assert.Nil(t, err)
want := "#!/usr/bin/env bash\n# asdf-plugin: lua 1.1.0\nexec asdf exec \"dummy\" \"$@\""
assert.Equal(t, want, string(content))
os.Remove(shimPath)
})
}
func TestToolExecutables(t *testing.T) {
version := "1.1.0"
conf, plugin := generateConfig(t)
installVersion(t, conf, plugin, version)
t.Run("returns list of executables for plugin", func(t *testing.T) {
executables, err := ToolExecutables(conf, plugin, version)
assert.Nil(t, err)
var filenames []string
for _, executablePath := range executables {
assert.True(t, strings.HasPrefix(executablePath, conf.DataDir))
filenames = append(filenames, filepath.Base(executablePath))
}
assert.Equal(t, filenames, []string{"dummy", "other_bin"})
})
}
func TestExecutableDirs(t *testing.T) {
conf, plugin := generateConfig(t)
installVersion(t, conf, plugin, "1.2.3")
t.Run("returns list only containing 'bin' when list-bin-paths callback missing", func(t *testing.T) {
executables, err := ExecutableDirs(plugin)
assert.Nil(t, err)
assert.Equal(t, executables, []string{"bin"})
})
t.Run("returns list of executable paths for tool version", func(t *testing.T) {
data := []byte("echo 'foo bar'")
err := os.WriteFile(filepath.Join(plugin.Dir, "bin", "list-bin-paths"), data, 0o777)
assert.Nil(t, err)
executables, err := ExecutableDirs(plugin)
assert.Nil(t, err)
assert.Equal(t, executables, []string{"foo", "bar"})
})
}
// Helper functions
func buildOutputs() (strings.Builder, strings.Builder) {
var stdout strings.Builder
var stderr strings.Builder
return stdout, stderr
}
func generateConfig(t *testing.T) (config.Config, plugins.Plugin) {
t.Helper()
testDataDir := t.TempDir()
conf, err := config.LoadConfig()
assert.Nil(t, err)
conf.DataDir = testDataDir
_, err = repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName)
assert.Nil(t, err)
return conf, plugins.New(conf, testPluginName)
}
func installVersion(t *testing.T, conf config.Config, plugin plugins.Plugin, version string) {
t.Helper()
stdout, stderr := buildOutputs()
err := versions.InstallOneVersion(conf, plugin, version, &stdout, &stderr)
assert.Nil(t, err)
}

View File

@ -4,6 +4,7 @@ package toolversions
import (
"os"
"slices"
"strings"
)
@ -25,17 +26,6 @@ func FindToolVersions(filepath, toolName string) (versions []string, found bool,
return versions, found, nil
}
func findToolVersionsInContent(content, toolName string) (versions []string, found bool) {
toolVersions := getAllToolsAndVersionsInContent(content)
for _, tool := range toolVersions {
if tool.Name == toolName {
return tool.Versions, true
}
}
return versions, found
}
// GetAllToolsAndVersions returns a list of all tools and associated versions
// contained in a .tool-versions file
func GetAllToolsAndVersions(filepath string) (toolVersions []ToolVersions, err error) {
@ -48,14 +38,34 @@ func GetAllToolsAndVersions(filepath string) (toolVersions []ToolVersions, err e
return toolVersions, nil
}
func getAllToolsAndVersionsInContent(content string) (toolVersions []ToolVersions) {
for _, line := range readLines(content) {
tokens := parseLine(line)
newTool := ToolVersions{Name: tokens[0], Versions: tokens[1:]}
toolVersions = append(toolVersions, newTool)
// Unique takes a slice of ToolVersions and returns a slice of unique tools and
// versions.
func Unique(versions []ToolVersions) (uniques []ToolVersions) {
for _, version := range versions {
var found bool
for index, unique := range uniques {
if unique.Name == version.Name {
// Duplicate name, check versions
for _, versionNumber := range version.Versions {
if !slices.Contains(unique.Versions, versionNumber) {
unique.Versions = append(unique.Versions, versionNumber)
}
}
uniques[index] = unique
found = true
break
}
}
// None with name found, add
if !found {
uniques = append(uniques, version)
}
}
return toolVersions
return uniques
}
// readLines reads all the lines in a given file
@ -71,6 +81,27 @@ func readLines(content string) (lines []string) {
return
}
func findToolVersionsInContent(content, toolName string) (versions []string, found bool) {
toolVersions := getAllToolsAndVersionsInContent(content)
for _, tool := range toolVersions {
if tool.Name == toolName {
return tool.Versions, true
}
}
return versions, found
}
func getAllToolsAndVersionsInContent(content string) (toolVersions []ToolVersions) {
for _, line := range readLines(content) {
tokens := parseLine(line)
newTool := ToolVersions{Name: tokens[0], Versions: tokens[1:]}
toolVersions = append(toolVersions, newTool)
}
return toolVersions
}
func parseLine(line string) (tokens []string) {
for _, token := range strings.Split(line, " ") {
token = strings.TrimSpace(token)

View File

@ -51,6 +51,37 @@ func TestFindToolVersions(t *testing.T) {
})
}
func TestUnique(t *testing.T) {
t.Run("returns unique slice of tool versions when tool appears multiple times in slice", func(t *testing.T) {
got := Unique([]ToolVersions{
{Name: "foo", Versions: []string{"1"}},
{Name: "foo", Versions: []string{"2"}},
})
want := []ToolVersions{
{Name: "foo", Versions: []string{"1", "2"}},
}
assert.Equal(t, got, want)
})
t.Run("returns unique slice of tool versions when given slice with multiple tools", func(t *testing.T) {
got := Unique([]ToolVersions{
{Name: "foo", Versions: []string{"1"}},
{Name: "bar", Versions: []string{"2"}},
{Name: "foo", Versions: []string{"2"}},
{Name: "bar", Versions: []string{"2"}},
})
want := []ToolVersions{
{Name: "foo", Versions: []string{"1", "2"}},
{Name: "bar", Versions: []string{"2"}},
}
assert.Equal(t, got, want)
})
}
func TestfindToolVersionsInContent(t *testing.T) {
t.Run("returns empty list with found false when empty content", func(t *testing.T) {
versions, found := findToolVersionsInContent("", "ruby")

View File

@ -131,7 +131,7 @@ func InstallOneVersion(conf config.Config, plugin plugins.Plugin, version string
}
downloadDir := downloadPath(conf, plugin, version)
installDir := installPath(conf, plugin, version)
installDir := InstallPath(conf, plugin, version)
versionType, version := ParseString(version)
if Installed(conf, plugin, version) {
@ -200,7 +200,7 @@ func asdfConcurrency(conf config.Config) string {
// Installed checks if a specific version of a tool is installed
func Installed(conf config.Config, plugin plugins.Plugin, version string) bool {
installDir := installPath(conf, plugin, version)
installDir := InstallPath(conf, plugin, version)
// Check if version already installed
_, err := os.Stat(installDir)
@ -319,6 +319,7 @@ func downloadPath(conf config.Config, plugin plugins.Plugin, version string) str
return filepath.Join(conf.DataDir, dataDirDownloads, plugin.Name, version)
}
func installPath(conf config.Config, plugin plugins.Plugin, version string) string {
// InstallPath returns the path to a tool installation
func InstallPath(conf config.Config, plugin plugins.Plugin, version string) string {
return filepath.Join(conf.DataDir, dataDirInstalls, plugin.Name, version)
}