resolve.go

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