resolve.go

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