@@ -7,6 +7,7 @@ import (
11 "mvdan.cc/sh/v3/expand"
12 "mvdan.cc/sh/v3/expand"
12 "mvdan.cc/sh/v3/interp"
13 "mvdan.cc/sh/v3/interp"
@@ -18,11 +19,28 @@ import (
18// to be embedded in a failing inner command.
19// to be embedded in a failing inner command.
19const maxInnerStderrBytes = 512
20const maxInnerStderrBytes = 512
22+// NoUnset controls whether ExpandValue treats unset variables as an
23+// error. Default false matches bash: $UNSET expands to "". Store true
24+// to re-enable strict mode globally. Not exposed in crush.json; this is
25+// an internal escape hatch in case the lenient default turns out to be
28+// Declared atomic because ExpandValue is invoked concurrently (multiple
29+// MCP / LSP / provider loads in flight at startup, hook execution, etc.)
30+// and an unsynchronised read/write pair is a data race under the Go
31+// memory model regardless of test-level happens-before reasoning. The
32+// atomic load on the hot path is negligible against the cost of parsing
33+// and running through mvdan.
35+// See PLAN.md Phase 2 design decisions #11 and #12 for the full
37+var NoUnset atomic.Bool
21// ExpandValue expands shell-style substitutions in a single config value.
39// ExpandValue expands shell-style substitutions in a single config value.
23// Supported constructs match the bash tool:
41// Supported constructs match the bash tool:
25−// - $VAR and ${VAR} (unset is an error; see nounset below).
43+// - $VAR and ${VAR}.
26// - ${VAR:-default} / ${VAR:+alt} / ${VAR:?msg}.
44// - ${VAR:-default} / ${VAR:+alt} / ${VAR:?msg}.
27// - $(command) with full quoting and nesting.
45// - $(command) with full quoting and nesting.
28// - escaped and quoted strings ("...", '...').
46// - escaped and quoted strings ("...", '...').
@@ -32,9 +50,11 @@ const maxInnerStderrBytes = 512
32// - Returns exactly one string. No field splitting, no globbing, no
50// - Returns exactly one string. No field splitting, no globbing, no
33// pathname generation. Multi-word command output is preserved
51// pathname generation. Multi-word command output is preserved
34// verbatim; it is never split into multiple values.
52// verbatim; it is never split into multiple values.
35−// - Nounset is on: unset variables produce an error instead of
53+// - Nounset is off by default, matching bash: unset variables expand
36−// expanding to the empty string. Use ${VAR:-default} to opt in to
54+// to "". Opt in to strict behaviour per-reference with
37−// an empty fallback.
55+// ${VAR:?msg}, which errors loudly when VAR is unset regardless of
56+// the global toggle. Flip the global default via
57+// shell.NoUnset.Store(true) as an internal escape hatch.
38// - Embedded whitespace and newlines in the input are preserved
58// - Embedded whitespace and newlines in the input are preserved
39// verbatim. Command substitution strips trailing newlines only
59// verbatim. Command substitution strips trailing newlines only
40// (POSIX), never leading or internal whitespace.
60// (POSIX), never leading or internal whitespace.
@@ -63,22 +83,27 @@ func ExpandValue(ctx context.Context, value string, env []string) (string, error
63 logger: noopLogger{},
83 logger: noopLogger{},
86+ strict := NoUnset.Load()
66 var stderrBuf bytes.Buffer
88 var stderrBuf bytes.Buffer
67 cfg := &expand.Config{
89 cfg := &expand.Config{
68 Env: expand.ListEnviron(env...),
90 Env: expand.ListEnviron(env...),
69− NoUnset: true,
91+ NoUnset: strict,
70 CmdSubst: func(w io.Writer, cs *syntax.CmdSubst) error {
92 CmdSubst: func(w io.Writer, cs *syntax.CmdSubst) error {
71 stderrBuf.Reset()
93 stderrBuf.Reset()
72− runner, rerr := interp.New(
94+ runnerOpts := []interp.RunnerOption{
73 interp.StdIO(nil, w, &stderrBuf),
95 interp.StdIO(nil, w, &stderrBuf),
74 interp.Interactive(false),
96 interp.Interactive(false),
75 interp.Env(expand.ListEnviron(env...)),
97 interp.Env(expand.ListEnviron(env...)),
76 interp.Dir(s.cwd),
98 interp.Dir(s.cwd),
77 interp.ExecHandlers(s.execHandlers()...),
99 interp.ExecHandlers(s.execHandlers()...),
78 // Match the outer NoUnset: an unset $VAR inside
102 // Match the outer NoUnset: an unset $VAR inside
79 // $(...) is also an error, not a silent empty.
103 // $(...) is also an error, not a silent empty.
80− interp.Params("-u"),
104+ runnerOpts = append(runnerOpts, interp.Params("-u"))
106+ runner, rerr := interp.New(runnerOpts...)
82 if rerr != nil {
107 if rerr != nil {
83 return rerr
108 return rerr