From 08fd0c6fb44e4ee001bf268f7d7d26533bf10369 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Fri, 26 Jun 2026 11:58:11 +0900 Subject: [PATCH] Fix envsubst parse of empty replace pattern The replace parser rejected an empty pattern such as ${VAR/#/prefix-} or ${VAR/%/-suffix} with ErrParseFuncSubstitution because the arg[1] scan hit the delimiter immediately and produced an illegal token. Peek for the delimiter before scanning the pattern and emit an empty pattern node, mirroring the existing empty-replacement handling. This keeps the two-argument shape the eval side already supports and matches bash, where an empty prefix or suffix pattern prepends or appends the replacement. Signed-off-by: Arpit Jain Assisted-by: Claude Code --- envsubst/eval_test.go | 12 ++++++++++++ envsubst/parse/parse.go | 15 +++++++++++---- envsubst/parse/parse_test.go | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/envsubst/eval_test.go b/envsubst/eval_test.go index 0702c985e..0236f97ba 100644 --- a/envsubst/eval_test.go +++ b/envsubst/eval_test.go @@ -126,6 +126,18 @@ func TestExpand(t *testing.T) { input: "${stringZ/#abc/XYZ}", output: "XYZABC123ABCabc", }, + // replace prefix with empty pattern (prepend) + { + params: map[string]string{"stringZ": "somevalue"}, + input: "${stringZ/#/prefix-}", + output: "prefix-somevalue", + }, + // replace suffix with empty pattern (append) + { + params: map[string]string{"stringZ": "somevalue"}, + input: "${stringZ/%/-suffix}", + output: "somevalue-suffix", + }, // replace all { params: map[string]string{"stringZ": "abcABC123ABCabc"}, diff --git a/envsubst/parse/parse.go b/envsubst/parse/parse.go index 39c72009f..1570cc73f 100644 --- a/envsubst/parse/parse.go +++ b/envsubst/parse/parse.go @@ -283,11 +283,18 @@ func (t *Tree) parseReplaceFunc(name string) (Node, error) { // scan arg[1] { - param, err := t.parseParam(acceptNotSlash, scanIdent|scanEscape) - if err != nil { - return nil, err + // An empty pattern (e.g. ${param/#/string}) means the next rune is the + // delimiter, so there is nothing to scan. Record an empty pattern node + // to keep the two-argument shape the eval side expects. + if t.scanner.peek() == '/' { + node.Args = append(node.Args, newTextNode("")) + } else { + param, err := t.parseParam(acceptNotSlash, scanIdent|scanEscape) + if err != nil { + return nil, err + } + node.Args = append(node.Args, param) } - node.Args = append(node.Args, param) } // expect delimiter diff --git a/envsubst/parse/parse_test.go b/envsubst/parse/parse_test.go index d51e6dbaf..171081d56 100644 --- a/envsubst/parse/parse_test.go +++ b/envsubst/parse/parse_test.go @@ -219,6 +219,28 @@ var tests = []struct { }, }, }, + { + Text: "${string/#/replacement}", + Node: &FuncNode{ + Param: "string", + Name: "/#", + Args: []Node{ + &TextNode{Value: ""}, + &TextNode{Value: "replacement"}, + }, + }, + }, + { + Text: "${string/%/replacement}", + Node: &FuncNode{ + Param: "string", + Name: "/%", + Args: []Node{ + &TextNode{Value: ""}, + &TextNode{Value: "replacement"}, + }, + }, + }, // // default value functions