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}