resolver.go 4.5 KB

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