Skip to content

Commit 99b4999

Browse files
authored
fix(#1661): expand env vars for non-Unix shells (PowerShell/cmd) (#1666)
1 parent 0d3f052 commit 99b4999

File tree

9 files changed

+385
-94
lines changed

9 files changed

+385
-94
lines changed

internal/cmn/eval/envscope.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,42 @@ func (e *EnvScope) ToMap() map[string]string {
152152
// expandWithLookup expands $VAR and ${VAR} using the provided lookup function.
153153
// Single-quoted variables ('$VAR' or '${VAR}') and unknown variables are preserved.
154154
func expandWithLookup(s string, lookup func(key string) (string, bool)) string {
155-
return reVarSubstitution.ReplaceAllStringFunc(s, func(match string) string {
156-
key, ok := extractVarKey(match)
157-
if !ok {
158-
return match // Single-quoted - preserve
155+
matches := reVarSubstitution.FindAllStringSubmatchIndex(s, -1)
156+
if len(matches) == 0 {
157+
return s
158+
}
159+
160+
var b strings.Builder
161+
last := 0
162+
for _, loc := range matches {
163+
b.WriteString(s[last:loc[0]])
164+
last = loc[1]
165+
166+
match := s[loc[0]:loc[1]]
167+
if isSingleQuotedVar(s, loc[0], loc[1]) {
168+
b.WriteString(match)
169+
continue
159170
}
171+
172+
var key string
173+
if loc[2] >= 0 { // Group 1: ${...}
174+
key = s[loc[2]:loc[3]]
175+
} else if loc[4] >= 0 { // Group 2: $VAR
176+
key = s[loc[4]:loc[5]]
177+
} else {
178+
// Neither group captured — preserve original text.
179+
b.WriteString(match)
180+
continue
181+
}
182+
160183
if val, found := lookup(key); found {
161-
return val
184+
b.WriteString(val)
185+
continue
162186
}
163-
return match // Not found - preserve original
164-
})
187+
b.WriteString(match)
188+
}
189+
b.WriteString(s[last:])
190+
return b.String()
165191
}
166192

167193
// Expand expands ${VAR} and $VAR in s using this scope.

internal/cmn/eval/envscope_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,64 @@ func TestExpandWithLookup_SingleQuoted(t *testing.T) {
748748
assert.Equal(t, "'$FOO' stays", result)
749749
}
750750

751+
func TestExpandWithLookup_QuoteAdjacentCases(t *testing.T) {
752+
lookup := func(key string) (string, bool) {
753+
switch key {
754+
case "FOO":
755+
return "bar", true
756+
case "BAR":
757+
return "baz", true
758+
default:
759+
return "", false
760+
}
761+
}
762+
763+
tests := []struct {
764+
name string
765+
input string
766+
want string
767+
}{
768+
{
769+
name: "BracedVarFollowedBySingleQuote",
770+
input: "${FOO}'",
771+
want: "bar'",
772+
},
773+
{
774+
name: "SimpleVarFollowedBySingleQuote",
775+
input: "$FOO'",
776+
want: "bar'",
777+
},
778+
{
779+
name: "SingleQuotedBracedPreserved",
780+
input: "'${FOO}'",
781+
want: "'${FOO}'",
782+
},
783+
{
784+
name: "SingleQuotedSimplePreserved",
785+
input: "'$FOO'",
786+
want: "'$FOO'",
787+
},
788+
{
789+
name: "MissingBracedVarFollowedBySingleQuote",
790+
input: "${MISSING}'",
791+
want: "${MISSING}'",
792+
},
793+
{
794+
name: "MultipleVarsWithQuoteAdjacency",
795+
input: "${FOO}' + $BAR'",
796+
want: "bar' + baz'",
797+
},
798+
}
799+
800+
for _, tt := range tests {
801+
t.Run(tt.name, func(t *testing.T) {
802+
t.Parallel()
803+
got := expandWithLookup(tt.input, lookup)
804+
assert.Equal(t, tt.want, got)
805+
})
806+
}
807+
}
808+
751809
func TestEnvScope_Debug_NoParent(t *testing.T) {
752810
scope := NewEnvScope(nil, true)
753811
debug := scope.Debug()

internal/cmn/eval/expand.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func expandWithShellContext(ctx context.Context, input string, opts *Options) (s
112112
match := input[loc[0]:loc[1]]
113113

114114
// Single-quoted: preserve as-is.
115-
if match[0] == '\'' {
115+
if isSingleQuotedVar(input, loc[0], loc[1]) {
116116
b.WriteString(match)
117117
continue
118118
}

internal/cmn/eval/expand_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,42 @@ func TestExpandWithShellContext(t *testing.T) {
181181
require.NoError(t, err)
182182
assert.Equal(t, "'${VAR}'", result)
183183
})
184+
185+
t.Run("VarFollowedBySingleQuote", func(t *testing.T) {
186+
opts := NewOptions()
187+
opts.Variables = []map[string]string{{"VAR": "value"}}
188+
189+
result, err := expandWithShellContext(context.Background(), "${VAR}'", opts)
190+
require.NoError(t, err)
191+
assert.Equal(t, "value'", result)
192+
})
193+
194+
t.Run("SimpleVarFollowedBySingleQuote", func(t *testing.T) {
195+
opts := NewOptions()
196+
opts.Variables = []map[string]string{{"VAR": "value"}}
197+
198+
result, err := expandWithShellContext(context.Background(), "$VAR'", opts)
199+
require.NoError(t, err)
200+
assert.Equal(t, "value'", result)
201+
})
202+
203+
t.Run("MissingVarFollowedBySingleQuoteWithoutExpandOS", func(t *testing.T) {
204+
opts := NewOptions()
205+
opts.ExpandOS = false
206+
207+
result, err := expandWithShellContext(context.Background(), "${MISSING}'", opts)
208+
require.NoError(t, err)
209+
assert.Equal(t, "${MISSING}'", result)
210+
})
211+
212+
t.Run("SingleQuotedSimplePreserved", func(t *testing.T) {
213+
opts := NewOptions()
214+
opts.Variables = []map[string]string{{"VAR": "value"}}
215+
216+
result, err := expandWithShellContext(context.Background(), "'$VAR'", opts)
217+
require.NoError(t, err)
218+
assert.Equal(t, "'$VAR'", result)
219+
})
184220
}
185221

186222
func TestShellEnviron(t *testing.T) {

internal/cmn/eval/pipeline_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,53 @@ func TestObject_ExplicitOSImportStillWorks(t *testing.T) {
800800
assert.Equal(t, "echo /home/testuser", result.Command, "Explicitly imported OS var should be expanded")
801801
}
802802

803+
func TestString_CommandLikeStringWithSingleQuoteAfterVar(t *testing.T) {
804+
t.Parallel()
805+
806+
scope := NewEnvScope(nil, false).WithEntry("MY_VALUE", "hello", EnvSourceDAGEnv)
807+
808+
tests := []struct {
809+
name string
810+
input string
811+
want string
812+
}{
813+
{
814+
name: "BracedVar",
815+
input: `nu -c "print $'got: ${MY_VALUE}'"`,
816+
want: `nu -c "print $'got: hello'"`,
817+
},
818+
{
819+
name: "SimpleVar",
820+
input: `nu -c "print $'got: $MY_VALUE'"`,
821+
want: `nu -c "print $'got: hello'"`,
822+
},
823+
{
824+
name: "MultipleVars",
825+
input: `nu -c "print $'bucket: ${BUCKET_PREFIX}${PROJECT_BUCKET}'"`,
826+
want: `nu -c "print $'bucket: gs://my-bucket'"`,
827+
},
828+
{
829+
name: "MissingVarPreserved",
830+
input: `nu -c "print $'got: ${MISSING}'"`,
831+
want: `nu -c "print $'got: ${MISSING}'"`,
832+
},
833+
}
834+
835+
scope = scope.
836+
WithEntry("BUCKET_PREFIX", "gs://", EnvSourceDAGEnv).
837+
WithEntry("PROJECT_BUCKET", "my-bucket", EnvSourceDAGEnv)
838+
ctx := WithEnvScope(context.Background(), scope)
839+
840+
for _, tt := range tests {
841+
t.Run(tt.name, func(t *testing.T) {
842+
t.Parallel()
843+
got, err := String(ctx, tt.input, WithoutExpandEnv(), WithoutDollarEscape())
844+
require.NoError(t, err)
845+
require.Equal(t, tt.want, got)
846+
})
847+
}
848+
}
849+
803850
func TestString_MultipleVariablesWithStepMapOnLast(t *testing.T) {
804851
ctx := context.Background()
805852

internal/cmn/eval/resolve.go

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import (
77
"strings"
88
)
99

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

1314
// reQuotedJSONRef matches quoted JSON references like "${FOO.bar}" and simple variables like "${VAR}"
1415
var reQuotedJSONRef = regexp.MustCompile(`"\$\{([A-Za-z0-9_]\w*(?:\.[^}]+)?)\}"`)
@@ -110,34 +111,60 @@ func (r *resolver) resolveJSONSource(name string) (string, bool) {
110111
return r.lookupScopeNonOS(name)
111112
}
112113

113-
// extractVarKey extracts the variable key from a regex match.
114-
// Returns the key and false if the match is single-quoted.
115-
func extractVarKey(match string) (string, bool) {
116-
if match[0] == '\'' && match[len(match)-1] == '\'' {
117-
return "", false
118-
}
119-
if strings.HasPrefix(match, "${") {
120-
return match[2 : len(match)-1], true
121-
}
122-
return match[1:], true
114+
// isSingleQuotedVar reports whether the matched variable token is enclosed
115+
// in single quotes in the original input (e.g., '${VAR}' or '$VAR').
116+
// Note: this is a simple adjacent-character heuristic. It may misidentify
117+
// nested quote contexts such as 'foo'${BAR}'baz' where the quote before
118+
// ${BAR} actually closes a prior segment. This is acceptable for the
119+
// targeted use cases (nu shell $'...' syntax, simple quoting).
120+
func isSingleQuotedVar(input string, start, end int) bool {
121+
return start > 0 && end < len(input) && input[start-1] == '\'' && input[end] == '\''
123122
}
124123

125124
// replaceVars substitutes $VAR and ${VAR} patterns using all resolver sources.
126125
// JSON path references (containing dots) are skipped; those are handled by expandReferences.
127126
func (r *resolver) replaceVars(template string) string {
128-
return reVarSubstitution.ReplaceAllStringFunc(template, func(match string) string {
129-
key, ok := extractVarKey(match)
130-
if !ok {
131-
return match
127+
matches := reVarSubstitution.FindAllStringSubmatchIndex(template, -1)
128+
if len(matches) == 0 {
129+
return template
130+
}
131+
132+
var b strings.Builder
133+
last := 0
134+
for _, loc := range matches {
135+
b.WriteString(template[last:loc[0]])
136+
last = loc[1]
137+
138+
match := template[loc[0]:loc[1]]
139+
if isSingleQuotedVar(template, loc[0], loc[1]) {
140+
b.WriteString(match)
141+
continue
142+
}
143+
144+
var key string
145+
if loc[2] >= 0 { // Group 1: ${...}
146+
key = template[loc[2]:loc[3]]
147+
} else if loc[4] >= 0 { // Group 2: $VAR
148+
key = template[loc[4]:loc[5]]
149+
} else {
150+
// Neither group captured — preserve original text.
151+
b.WriteString(match)
152+
continue
132153
}
154+
133155
if strings.Contains(key, ".") {
134-
return match
156+
b.WriteString(match)
157+
continue
135158
}
136159
if val, found := r.resolve(key); found {
137-
return val
160+
b.WriteString(val)
161+
continue
138162
}
139-
return match
140-
})
163+
b.WriteString(match)
164+
}
165+
166+
b.WriteString(template[last:])
167+
return b.String()
141168
}
142169

143170
// expandReferences resolves JSON path and step property references in the input.

internal/cmn/eval/resolve_test.go

Lines changed: 12 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,18 @@ func TestReplaceVars_EdgeCases(t *testing.T) {
294294
vars: map[string]string{"FOO123": "value"},
295295
want: "value",
296296
},
297+
{
298+
name: "VarFollowedBySingleQuote",
299+
template: "${FOO}'",
300+
vars: map[string]string{"FOO": "bar"},
301+
want: "bar'",
302+
},
303+
{
304+
name: "SimpleVarFollowedBySingleQuote",
305+
template: "$FOO'",
306+
vars: map[string]string{"FOO": "bar"},
307+
want: "bar'",
308+
},
297309
}
298310

299311
for _, tt := range tests {
@@ -394,62 +406,6 @@ func TestReplaceVarsWithScope(t *testing.T) {
394406
}
395407
}
396408

397-
// --- extractVarKey ---
398-
399-
func TestExtractVarKey(t *testing.T) {
400-
tests := []struct {
401-
name string
402-
match string
403-
wantK string
404-
wantOK bool
405-
}{
406-
{
407-
name: "BracedVar",
408-
match: "${FOO}",
409-
wantK: "FOO",
410-
wantOK: true,
411-
},
412-
{
413-
name: "SimpleDollarVar",
414-
match: "$FOO",
415-
wantK: "FOO",
416-
wantOK: true,
417-
},
418-
{
419-
name: "SingleQuotedBraced",
420-
match: "'${FOO}'",
421-
wantK: "",
422-
wantOK: false,
423-
},
424-
{
425-
name: "SingleQuotedSimple",
426-
match: "'$FOO'",
427-
wantK: "",
428-
wantOK: false,
429-
},
430-
{
431-
name: "VarWithUnderscore",
432-
match: "${FOO_BAR}",
433-
wantK: "FOO_BAR",
434-
wantOK: true,
435-
},
436-
{
437-
name: "VarWithNumbers",
438-
match: "$VAR123",
439-
wantK: "VAR123",
440-
wantOK: true,
441-
},
442-
}
443-
444-
for _, tt := range tests {
445-
t.Run(tt.name, func(t *testing.T) {
446-
gotK, gotOK := extractVarKey(tt.match)
447-
require.Equal(t, tt.wantK, gotK)
448-
require.Equal(t, tt.wantOK, gotOK)
449-
})
450-
}
451-
}
452-
453409
// --- ExpandReferences ---
454410

455411
func TestExpandReferences(t *testing.T) {

0 commit comments

Comments
 (0)