resolve.go

  1package config
  2
  3import (
  4	"context"
  5	"fmt"
  6	"strings"
  7	"time"
  8
  9	"github.com/charmbracelet/crush/internal/shell"
 10)
 11
 12type VariableResolver interface {
 13	ResolveValue(value string) (string, error)
 14}
 15
 16type Shell interface {
 17	Exec(ctx context.Context, command string) (stdout, stderr string, err error)
 18}
 19
 20type shellVariableResolver struct {
 21	shell Shell
 22	env   []string
 23}
 24
 25func NewShellVariableResolver(env []string) VariableResolver {
 26	return &shellVariableResolver{
 27		env: env,
 28		shell: shell.NewShell(
 29			&shell.Options{
 30				Env: env,
 31			},
 32		),
 33	}
 34}
 35
 36// ResolveValue is a method for resolving values, such as environment variables.
 37// it will resolve shell-like variable substitution anywhere in the string, including:
 38// - $(command) for command substitution
 39// - $VAR or ${VAR} for environment variables
 40// TODO: can we replace this with [os.Expand](https://pkg.go.dev/os#Expand) somehow?
 41func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
 42	// Special case: lone $ is an error (backward compatibility)
 43	if value == "$" {
 44		return "", fmt.Errorf("invalid value format: %s", value)
 45	}
 46
 47	// If no $ found, return as-is
 48	if !strings.Contains(value, "$") {
 49		return value, nil
 50	}
 51
 52	result := value
 53
 54	// Handle command substitution: $(command)
 55	for {
 56		start := strings.Index(result, "$(")
 57		if start == -1 {
 58			break
 59		}
 60
 61		// Find matching closing parenthesis
 62		depth := 0
 63		end := -1
 64		for i := start + 2; i < len(result); i++ {
 65			if result[i] == '(' {
 66				depth++
 67			} else if result[i] == ')' {
 68				if depth == 0 {
 69					end = i
 70					break
 71				}
 72				depth--
 73			}
 74		}
 75
 76		if end == -1 {
 77			return "", fmt.Errorf("unmatched $( in value: %s", value)
 78		}
 79
 80		command := result[start+2 : end]
 81		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
 82
 83		stdout, _, err := r.shell.Exec(ctx, command)
 84		cancel()
 85		if err != nil {
 86			return "", fmt.Errorf("command execution failed for '%s': %w", command, err)
 87		}
 88
 89		// Replace the $(command) with the output
 90		replacement := strings.TrimSpace(stdout)
 91		result = result[:start] + replacement + result[end+1:]
 92	}
 93
 94	// Handle environment variables: $VAR and ${VAR}
 95	searchStart := 0
 96	for {
 97		start := strings.Index(result[searchStart:], "$")
 98		if start == -1 {
 99			break
100		}
101		start += searchStart // Adjust for the offset
102
103		// Skip if this is part of $( which we already handled
104		if start+1 < len(result) && result[start+1] == '(' {
105			// Skip past this $(...)
106			searchStart = start + 1
107			continue
108		}
109		var varName string
110		var end int
111
112		if start+1 < len(result) && result[start+1] == '{' {
113			// Handle ${VAR} format
114			closeIdx := strings.Index(result[start+2:], "}")
115			if closeIdx == -1 {
116				return "", fmt.Errorf("unmatched ${ in value: %s", value)
117			}
118			varName = result[start+2 : start+2+closeIdx]
119			end = start + 2 + closeIdx + 1
120		} else {
121			// Handle $VAR format - variable names must start with letter or underscore
122			if start+1 >= len(result) {
123				return "", fmt.Errorf("incomplete variable reference at end of string: %s", value)
124			}
125
126			if result[start+1] != '_' &&
127				(result[start+1] < 'a' || result[start+1] > 'z') &&
128				(result[start+1] < 'A' || result[start+1] > 'Z') {
129				return "", fmt.Errorf("invalid variable name starting with '%c' in: %s", result[start+1], value)
130			}
131
132			end = start + 1
133			for end < len(result) && (result[end] == '_' ||
134				(result[end] >= 'a' && result[end] <= 'z') ||
135				(result[end] >= 'A' && result[end] <= 'Z') ||
136				(result[end] >= '0' && result[end] <= '9')) {
137				end++
138			}
139			varName = result[start+1 : end]
140		}
141
142		envValue := environ(r.env).Getenv(varName)
143		if envValue == "" {
144			return "", fmt.Errorf("environment variable %q not set", varName)
145		}
146
147		result = result[:start] + envValue + result[end:]
148		searchStart = start + len(envValue) // Continue searching after the replacement
149	}
150
151	return result, nil
152}
153
154type environmentVariableResolver struct {
155	env []string
156}
157
158func NewEnvironmentVariableResolver(env []string) VariableResolver {
159	return &environmentVariableResolver{
160		env: env,
161	}
162}
163
164// ResolveValue resolves environment variables from the provided env.Env.
165func (r *environmentVariableResolver) ResolveValue(value string) (string, error) {
166	if !strings.HasPrefix(value, "$") {
167		return value, nil
168	}
169
170	varName := strings.TrimPrefix(value, "$")
171	resolvedValue := environ(r.env).Getenv(varName)
172	if resolvedValue == "" {
173		return "", fmt.Errorf("environment variable %q not set", varName)
174	}
175	return resolvedValue, nil
176}