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}