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}