asdf/internal/shims/shims.go
Trevor Brown 162cb8ecee feat(golang-rewrite): get remaining shim_exec.bats tests passing
* Disable custom shim template tests
* Document another breaking change
* Enable `shim_exec.bats` tests
* Fix bug in `shims.getCustomExecutablePath` function
* Pass default relative executable path as third argument to `exec-path` callback
* Get remaining `shim_exec.bats` tests passing
2024-12-18 11:32:03 -05:00

439 lines
12 KiB
Go

// Package shims manages writing and parsing of asdf shim scripts.
package shims
import (
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strings"
"asdf/internal/config"
"asdf/internal/hook"
"asdf/internal/installs"
"asdf/internal/paths"
"asdf/internal/plugins"
"asdf/internal/resolve"
"asdf/internal/toolversions"
"golang.org/x/sys/unix"
)
const shimDirName = "shims"
// UnknownCommandError is an error returned when a shim is not found
type UnknownCommandError struct {
shim string
}
func (e UnknownCommandError) Error() string {
return fmt.Sprintf("unknown command: %s", e.shim)
}
// NoVersionSetError is returned when shim is found but no version matches
type NoVersionSetError struct {
shim string
}
func (e NoVersionSetError) Error() string {
return fmt.Sprintf("no versions set for %s", e.shim)
}
// NoExecutableForPluginError is returned when a compatible version is found
// but no executable matching the name is located.
type NoExecutableForPluginError struct {
shim string
tools []string
versions []string
}
func (e NoExecutableForPluginError) Error() string {
return fmt.Sprintf("No %s executable found for %s %s", e.shim, strings.Join(e.tools, ", "), strings.Join(e.versions, ", "))
}
// FindExecutable takes a shim name and a current directory and returns the path
// to the executable that the shim resolves to.
func FindExecutable(conf config.Config, shimName, currentDirectory string) (string, plugins.Plugin, string, bool, error) {
shimPath := Path(conf, shimName)
if _, err := os.Stat(shimPath); err != nil {
return "", plugins.Plugin{}, "", false, UnknownCommandError{shim: shimName}
}
toolVersions, err := GetToolsAndVersionsFromShimFile(shimPath)
if err != nil {
return "", plugins.Plugin{}, "", false, err
}
existingPluginToolVersions := make(map[plugins.Plugin]resolve.ToolVersions)
// loop over tools and check if the plugin for them still exists
for _, shimToolVersion := range toolVersions {
plugin := plugins.New(conf, shimToolVersion.Name)
if plugin.Exists() == nil {
versions, found, err := resolve.Version(conf, plugin, currentDirectory)
if err != nil {
return "", plugins.Plugin{}, "", false, nil
}
if found {
tempVersions := toolversions.Intersect(shimToolVersion.Versions, versions.Versions)
if slices.Contains(versions.Versions, "system") {
tempVersions = append(tempVersions, "system")
}
parsedVersions := toolversions.ParseSlice(versions.Versions)
for _, parsedVersion := range parsedVersions {
if parsedVersion.Type == "path" {
tempVersions = append(tempVersions, toolversions.Format(parsedVersion))
}
}
versions.Versions = tempVersions
existingPluginToolVersions[plugin] = versions
}
}
}
if len(existingPluginToolVersions) == 0 {
return "", plugins.Plugin{}, "", false, NoVersionSetError{shim: shimName}
}
for plugin, toolVersions := range existingPluginToolVersions {
for _, version := range toolVersions.Versions {
parsedVersion := toolversions.Parse(version)
if parsedVersion.Type == "system" {
if executablePath, found := SystemExecutableOnPath(conf, shimName); found {
return executablePath, plugin, version, true, nil
}
break
}
if parsedVersion.Type == "path" {
executablePath, err := GetExecutablePath(conf, plugin, shimName, parsedVersion)
if err == nil {
return executablePath, plugin, version, true, nil
}
break
}
executablePath, err := GetExecutablePath(conf, plugin, shimName, parsedVersion)
if err == nil {
return executablePath, plugin, version, true, nil
}
}
}
tools := []string{}
versions := []string{}
for plugin := range existingPluginToolVersions {
tools = append(tools, plugin.Name)
versions = append(versions, existingPluginToolVersions[plugin].Versions...)
}
return "", plugins.Plugin{}, "", false, NoExecutableForPluginError{shim: shimName, tools: tools, versions: versions}
}
// SystemExecutableOnPath returns the path to the system executable if found,
// removes asdf shim directory from search
func SystemExecutableOnPath(conf config.Config, executableName string) (string, bool) {
currentPath := os.Getenv("PATH")
executablePath, err := ExecutableOnPath(paths.RemoveFromPath(currentPath, Directory(conf)), executableName)
return executablePath, err == nil
}
// ExecutableOnPath returns the path to an executable if one is found on the
// provided paths. `path` must be in the same format as the `PATH` environment
// variable.
func ExecutableOnPath(path, command string) (string, error) {
currentPath := os.Getenv("PATH")
defer os.Setenv("PATH", currentPath)
os.Setenv("PATH", path)
return exec.LookPath(command)
}
// GetExecutablePath returns the path of the executable
func GetExecutablePath(conf config.Config, plugin plugins.Plugin, shimName string, version toolversions.Version) (string, error) {
executables, err := ToolExecutables(conf, plugin, version)
if err != nil {
return "", err
}
executable := ""
for _, executablePath := range executables {
if filepath.Base(executablePath) == shimName {
executable = executablePath
}
}
path, err := getCustomExecutablePath(conf, plugin, shimName, version, executable)
if err == nil {
return path, err
}
if executable != "" {
return executable, nil
}
return "", fmt.Errorf("executable not found")
}
// GetToolsAndVersionsFromShimFile takes a file path and parses out the tools
// and versions present in it and returns them as a slice containing info in
// ToolVersions structs.
func GetToolsAndVersionsFromShimFile(shimPath string) (versions []toolversions.ToolVersions, err error) {
contents, err := os.ReadFile(shimPath)
if err != nil {
return versions, err
}
versions = parse(string(contents))
versions = toolversions.Unique(versions)
return versions, err
}
func getCustomExecutablePath(conf config.Config, plugin plugins.Plugin, shimName string, version toolversions.Version, executablePath string) (string, error) {
var stdOut strings.Builder
var stdErr strings.Builder
installPath := installs.InstallPath(conf, plugin, version)
env := map[string]string{"ASDF_INSTALL_TYPE": "version"}
relativePath, err := filepath.Rel(installPath, executablePath)
if err != nil {
return "", err
}
err = plugin.RunCallback("exec-path", []string{installPath, shimName, relativePath}, env, &stdOut, &stdErr)
if err != nil {
return "", err
}
return filepath.Join(installPath, strings.TrimSpace(stdOut.String())), err
}
// RemoveAll removes all shim scripts
func RemoveAll(conf config.Config) error {
shimDir := filepath.Join(conf.DataDir, shimDirName)
entries, err := os.ReadDir(shimDir)
if err != nil {
return err
}
for _, entry := range entries {
os.RemoveAll(path.Join(shimDir, entry.Name()))
}
return nil
}
// GenerateAll generates shims for all executables of every version of every
// plugin.
func GenerateAll(conf config.Config, stdOut io.Writer, stdErr io.Writer) error {
plugins, err := plugins.List(conf, false, false)
if err != nil {
return err
}
for _, plugin := range plugins {
err := GenerateForPluginVersions(conf, plugin, stdOut, stdErr)
if err != nil {
return err
}
}
return nil
}
// GenerateForPluginVersions generates all shims for all installed versions of
// a tool.
func GenerateForPluginVersions(conf config.Config, plugin plugins.Plugin, stdOut io.Writer, stdErr io.Writer) error {
installedVersions, err := installs.Installed(conf, plugin)
if err != nil {
return err
}
for _, version := range installedVersions {
parsedVersion := toolversions.Parse(version)
GenerateForVersion(conf, plugin, parsedVersion, stdOut, stdErr)
}
return nil
}
// 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 toolversions.Version, stdOut io.Writer, stdErr io.Writer) error {
err := hook.RunWithOutput(conf, fmt.Sprintf("pre_asdf_reshim_%s", plugin.Name), []string{toolversions.Format(version)}, stdOut, stdErr)
if err != nil {
return err
}
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
}
}
err = hook.RunWithOutput(conf, fmt.Sprintf("post_asdf_reshim_%s", plugin.Name), []string{toolversions.Format(version)}, stdOut, stdErr)
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 toolversions.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{toolversions.Format(version)}}}
if _, err := os.Stat(shimPath); err == nil {
oldVersions, err := GetToolsAndVersionsFromShimFile(shimPath)
if err != nil {
return err
}
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, shimDirName, shimName)
}
// Directory returns the path to the shims directory for the current
// configuration.
func Directory(conf config.Config) string {
return filepath.Join(conf.DataDir, shimDirName)
}
func ensureShimDirExists(conf config.Config) error {
return os.MkdirAll(filepath.Join(conf.DataDir, shimDirName), 0o777)
}
// ToolExecutables returns a slice of executables for a given tool version
func ToolExecutables(conf config.Config, plugin plugins.Plugin, version toolversions.Version) (executables []string, err error) {
paths, err := ExecutablePaths(conf, plugin, version)
if err != nil {
return []string{}, err
}
for _, path := range paths {
entries, err := os.ReadDir(path)
if _, ok := err.(*os.PathError); err != nil && !ok {
return executables, err
}
for _, entry := range entries {
// If entry is dir or cannot be executed by the current user ignore it
filePath := filepath.Join(path, entry.Name())
if entry.IsDir() || unix.Access(filePath, unix.X_OK) != nil {
continue
}
executables = append(executables, filePath)
}
}
return executables, err
}
// ExecutablePaths returns a slice of absolute directory paths that tool
// executables are contained in.
func ExecutablePaths(conf config.Config, plugin plugins.Plugin, version toolversions.Version) ([]string, error) {
dirs, err := ExecutableDirs(plugin)
if err != nil {
return []string{}, err
}
installPath := installs.InstallPath(conf, plugin, version)
return dirsToPaths(dirs, installPath), nil
}
// 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
}