Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions internal/cmn/eval/envscope.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,38 @@ func (e *EnvScope) ToMap() map[string]string {
// expandWithLookup expands $VAR and ${VAR} using the provided lookup function.
// Single-quoted variables ('$VAR' or '${VAR}') and unknown variables are preserved.
func expandWithLookup(s string, lookup func(key string) (string, bool)) string {
return reVarSubstitution.ReplaceAllStringFunc(s, func(match string) string {
key, ok := extractVarKey(match)
if !ok {
return match // Single-quoted - preserve
matches := reVarSubstitution.FindAllStringSubmatchIndex(s, -1)
if len(matches) == 0 {
return s
}

var b strings.Builder
last := 0
for _, loc := range matches {
b.WriteString(s[last:loc[0]])
last = loc[1]

match := s[loc[0]:loc[1]]
if isSingleQuotedVar(s, loc[0], loc[1]) {
b.WriteString(match)
continue
}

var key string
if loc[2] >= 0 { // Group 1: ${...}
key = s[loc[2]:loc[3]]
} else { // Group 2: $VAR
key = s[loc[4]:loc[5]]
}

if val, found := lookup(key); found {
return val
b.WriteString(val)
continue
}
return match // Not found - preserve original
})
b.WriteString(match)
}
b.WriteString(s[last:])
return b.String()
}

// Expand expands ${VAR} and $VAR in s using this scope.
Expand Down
58 changes: 58 additions & 0 deletions internal/cmn/eval/envscope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,64 @@ func TestExpandWithLookup_SingleQuoted(t *testing.T) {
assert.Equal(t, "'$FOO' stays", result)
}

func TestExpandWithLookup_QuoteAdjacentCases(t *testing.T) {
lookup := func(key string) (string, bool) {
switch key {
case "FOO":
return "bar", true
case "BAR":
return "baz", true
default:
return "", false
}
}

tests := []struct {
name string
input string
want string
}{
{
name: "BracedVarFollowedBySingleQuote",
input: "${FOO}'",
want: "bar'",
},
{
name: "SimpleVarFollowedBySingleQuote",
input: "$FOO'",
want: "bar'",
},
{
name: "SingleQuotedBracedPreserved",
input: "'${FOO}'",
want: "'${FOO}'",
},
{
name: "SingleQuotedSimplePreserved",
input: "'$FOO'",
want: "'$FOO'",
},
{
name: "MissingBracedVarFollowedBySingleQuote",
input: "${MISSING}'",
want: "${MISSING}'",
},
{
name: "MultipleVarsWithQuoteAdjacency",
input: "${FOO}' + $BAR'",
want: "bar' + baz'",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := expandWithLookup(tt.input, lookup)
assert.Equal(t, tt.want, got)
})
}
}

func TestEnvScope_Debug_NoParent(t *testing.T) {
scope := NewEnvScope(nil, true)
debug := scope.Debug()
Expand Down
2 changes: 1 addition & 1 deletion internal/cmn/eval/expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func expandWithShellContext(ctx context.Context, input string, opts *Options) (s
match := input[loc[0]:loc[1]]

// Single-quoted: preserve as-is.
if match[0] == '\'' {
if isSingleQuotedVar(input, loc[0], loc[1]) {
b.WriteString(match)
continue
}
Expand Down
36 changes: 36 additions & 0 deletions internal/cmn/eval/expand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,42 @@ func TestExpandWithShellContext(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "'${VAR}'", result)
})

t.Run("VarFollowedBySingleQuote", func(t *testing.T) {
opts := NewOptions()
opts.Variables = []map[string]string{{"VAR": "value"}}

result, err := expandWithShellContext(context.Background(), "${VAR}'", opts)
require.NoError(t, err)
assert.Equal(t, "value'", result)
})

t.Run("SimpleVarFollowedBySingleQuote", func(t *testing.T) {
opts := NewOptions()
opts.Variables = []map[string]string{{"VAR": "value"}}

result, err := expandWithShellContext(context.Background(), "$VAR'", opts)
require.NoError(t, err)
assert.Equal(t, "value'", result)
})

t.Run("MissingVarFollowedBySingleQuoteWithoutExpandOS", func(t *testing.T) {
opts := NewOptions()
opts.ExpandOS = false

result, err := expandWithShellContext(context.Background(), "${MISSING}'", opts)
require.NoError(t, err)
assert.Equal(t, "${MISSING}'", result)
})

t.Run("SingleQuotedSimplePreserved", func(t *testing.T) {
opts := NewOptions()
opts.Variables = []map[string]string{{"VAR": "value"}}

result, err := expandWithShellContext(context.Background(), "'$VAR'", opts)
require.NoError(t, err)
assert.Equal(t, "'$VAR'", result)
})
}

func TestShellEnviron(t *testing.T) {
Expand Down
47 changes: 47 additions & 0 deletions internal/cmn/eval/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,53 @@ func TestObject_ExplicitOSImportStillWorks(t *testing.T) {
assert.Equal(t, "echo /home/testuser", result.Command, "Explicitly imported OS var should be expanded")
}

func TestString_CommandLikeStringWithSingleQuoteAfterVar(t *testing.T) {
t.Parallel()

scope := NewEnvScope(nil, false).WithEntry("MY_VALUE", "hello", EnvSourceDAGEnv)

tests := []struct {
name string
input string
want string
}{
{
name: "BracedVar",
input: `nu -c "print $'got: ${MY_VALUE}'"`,
want: `nu -c "print $'got: hello'"`,
},
{
name: "SimpleVar",
input: `nu -c "print $'got: $MY_VALUE'"`,
want: `nu -c "print $'got: hello'"`,
},
{
name: "MultipleVars",
input: `nu -c "print $'bucket: ${BUCKET_PREFIX}${PROJECT_BUCKET}'"`,
want: `nu -c "print $'bucket: gs://my-bucket'"`,
},
{
name: "MissingVarPreserved",
input: `nu -c "print $'got: ${MISSING}'"`,
want: `nu -c "print $'got: ${MISSING}'"`,
},
}

scope = scope.
WithEntry("BUCKET_PREFIX", "gs://", EnvSourceDAGEnv).
WithEntry("PROJECT_BUCKET", "my-bucket", EnvSourceDAGEnv)
ctx := WithEnvScope(context.Background(), scope)

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := String(ctx, tt.input, WithoutExpandEnv(), WithoutDollarEscape())
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}

func TestString_MultipleVariablesWithStepMapOnLast(t *testing.T) {
ctx := context.Background()

Expand Down
51 changes: 41 additions & 10 deletions internal/cmn/eval/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import (
"strings"
)

// reVarSubstitution matches $VAR, ${VAR}, '$VAR', '${VAR}' patterns for variable substitution.
var reVarSubstitution = regexp.MustCompile(`[']{0,1}\$\{([^}]+)\}[']{0,1}|[']{0,1}\$([a-zA-Z0-9_][a-zA-Z0-9_]*)[']{0,1}`)
// reVarSubstitution matches ${...} and $VAR patterns for variable substitution.
// Quote handling is done by callers based on surrounding characters.
var reVarSubstitution = regexp.MustCompile(`\$\{([^}]+)\}|\$([a-zA-Z0-9_][a-zA-Z0-9_]*)`)

// reQuotedJSONRef matches quoted JSON references like "${FOO.bar}" and simple variables like "${VAR}"
var reQuotedJSONRef = regexp.MustCompile(`"\$\{([A-Za-z0-9_]\w*(?:\.[^}]+)?)\}"`)
Expand Down Expand Up @@ -122,22 +123,52 @@ func extractVarKey(match string) (string, bool) {
return match[1:], true
}

// isSingleQuotedVar reports whether the matched variable token is enclosed
// in single quotes in the original input (e.g., '${VAR}' or '$VAR').
func isSingleQuotedVar(input string, start, end int) bool {
return start > 0 && end < len(input) && input[start-1] == '\'' && input[end] == '\''
}

// replaceVars substitutes $VAR and ${VAR} patterns using all resolver sources.
// JSON path references (containing dots) are skipped; those are handled by expandReferences.
func (r *resolver) replaceVars(template string) string {
return reVarSubstitution.ReplaceAllStringFunc(template, func(match string) string {
key, ok := extractVarKey(match)
if !ok {
return match
matches := reVarSubstitution.FindAllStringSubmatchIndex(template, -1)
if len(matches) == 0 {
return template
}

var b strings.Builder
last := 0
for _, loc := range matches {
b.WriteString(template[last:loc[0]])
last = loc[1]

match := template[loc[0]:loc[1]]
if isSingleQuotedVar(template, loc[0], loc[1]) {
b.WriteString(match)
continue
}

var key string
if loc[2] >= 0 { // Group 1: ${...}
key = template[loc[2]:loc[3]]
} else { // Group 2: $VAR
key = template[loc[4]:loc[5]]
}

if strings.Contains(key, ".") {
return match
b.WriteString(match)
continue
}
if val, found := r.resolve(key); found {
return val
b.WriteString(val)
continue
}
return match
})
b.WriteString(match)
}

b.WriteString(template[last:])
return b.String()
}

// expandReferences resolves JSON path and step property references in the input.
Expand Down
12 changes: 12 additions & 0 deletions internal/cmn/eval/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,18 @@ func TestReplaceVars_EdgeCases(t *testing.T) {
vars: map[string]string{"FOO123": "value"},
want: "value",
},
{
name: "VarFollowedBySingleQuote",
template: "${FOO}'",
vars: map[string]string{"FOO": "bar"},
want: "bar'",
},
{
name: "SimpleVarFollowedBySingleQuote",
template: "$FOO'",
vars: map[string]string{"FOO": "bar"},
want: "bar'",
},
}

for _, tt := range tests {
Expand Down
28 changes: 18 additions & 10 deletions internal/runtime/builtin/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,19 +351,27 @@ func init() {
Shell: true,
GetEvalOptions: func(ctx context.Context, step core.Step) []eval.Option {
env := runtime.GetEnv(ctx)
shell := env.Shell(ctx)
if len(shell) > 0 && shell[0] != "direct" {
// Shell will handle env expansion
return []eval.Option{
eval.WithoutExpandEnv(),
eval.WithoutDollarEscape(),
}
}
// No shell — Dagu must expand OS variables since no shell will do it.
return []eval.Option{eval.WithOSExpansion()}
return commandEvalOptions(env.Shell(ctx))
},
}
executor.RegisterExecutor("", NewCommand, validateCommandStep, caps)
executor.RegisterExecutor("shell", NewCommand, validateCommandStep, caps)
executor.RegisterExecutor("command", NewCommand, validateCommandStep, caps)
}

func commandEvalOptions(shell []string) []eval.Option {
if len(shell) == 0 || shell[0] == "direct" {
// No shell (or direct mode): Dagu must expand OS variables itself.
return []eval.Option{eval.WithOSExpansion()}
}

opts := []eval.Option{eval.WithoutDollarEscape()}

// Unix-like shells support ${VAR} natively, so avoid double expansion.
// Non-Unix shells (e.g., PowerShell/cmd) need Dagu-side ${VAR} expansion.
if cmdutil.IsUnixLikeShell(shell[0]) || cmdutil.IsNixShell(shell[0]) {
opts = append(opts, eval.WithoutExpandEnv())
}

return opts
}
Loading
Loading