diff --git a/pkg/executor/resolve.go b/pkg/executor/resolve.go index e2d004f..b5284c0 100644 --- a/pkg/executor/resolve.go +++ b/pkg/executor/resolve.go @@ -172,62 +172,7 @@ func ReplaceVar(cmd, varName, replacement string, syntax string) string { return re.ReplaceAllLiteralString(cmd, replacement) } -// FindAllVars finds ALL variable references in a command, ignoring quoting. +// FindAllVars finds variable references using the parser's syntax and escaping rules. func FindAllVars(cmd string, syntax string) []string { - allowDollar := syntax == "dollar" || syntax == "both" - allowAngle := syntax == "angle" || syntax == "both" - - var vars []string - seen := make(map[string]bool) - add := func(name string) { - if seen[name] { - return - } - seen[name] = true - vars = append(vars, name) - } - - for i := 0; i < len(cmd); i++ { - switch cmd[i] { - case '$': - i = scanDollarVar(cmd, i, allowDollar, add) - case '<': - i = scanAngleVar(cmd, i, allowAngle, add) - } - } - - return vars -} - -func scanDollarVar(cmd string, i int, allowDollar bool, add func(string)) int { - if !allowDollar || i+1 >= len(cmd) || (i > 0 && cmd[i-1] == '\\') { - return i - } - j := i + 1 - for j < len(cmd) && parser.IsVarChar(cmd[j], j == i+1) { - j++ - } - if j > i+1 { - add(cmd[i+1 : j]) - } - return j - 1 -} - -func scanAngleVar(cmd string, i int, allowAngle bool, add func(string)) int { - if !allowAngle { - return i - } - j := i + 1 - if j >= len(cmd) || !parser.IsVarChar(cmd[j], true) { - return i - } - j++ - for j < len(cmd) && parser.IsVarChar(cmd[j], false) { - j++ - } - if j >= len(cmd) || cmd[j] != '>' { - return i - } - add(cmd[i+1 : j]) - return j + return parser.ExtractVars(cmd, syntax == "" || syntax == "dollar" || syntax == "both", syntax == "angle" || syntax == "both") } diff --git a/pkg/executor/var_discovery_test.go b/pkg/executor/var_discovery_test.go new file mode 100644 index 0000000..aa82f5f --- /dev/null +++ b/pkg/executor/var_discovery_test.go @@ -0,0 +1,28 @@ +package executor + +import ( + "reflect" + "testing" +) + +func TestFindAllVarsEscapesAndDefaultSyntax(t *testing.T) { + for _, tc := range []struct { + name, command, syntax string + want []string + }{ + {"default", "echo $value", "", []string{"value"}}, + {"odd backslash", `echo \$literal $value`, "dollar", []string{"value"}}, + {"even backslashes", `echo \\$value`, "dollar", []string{"value"}}, + {"three backslashes", `echo \\\$literal $value`, "dollar", []string{"value"}}, + {"shell braces", "echo ${HOME} $value", "dollar", []string{"value"}}, + {"ordered unique names", "echo $first $first", "both", []string{"first", "second"}}, + {"angle only", "echo $first ", "angle", []string{"second"}}, + {"malformed angle", "echo `) +// WalkVars visits complete variable references in source order. Offsets are byte +// offsets into command. Dollar references escaped by an odd number of preceding +// backslashes and shell ${...} forms are left to the shell. +func WalkVars(command string, allowDollar, allowAngle bool, visit func(start, end int, name string)) { + backslashes := 0 + for i := 0; i < len(command); i++ { + if command[i] == '\\' { + backslashes++ + continue + } + escaped := backslashes%2 != 0 + backslashes = 0 + dollar := command[i] == '$' && allowDollar && !escaped + angle := command[i] == '<' && allowAngle + if !dollar && !angle { + continue + } + start := i + j := i + 1 + if j >= len(command) || !IsVarChar(command[j], true) { + continue + } + j++ + for j < len(command) && IsVarChar(command[j], false) { + j++ + } + name := command[i+1 : j] + if angle { + if j >= len(command) || command[j] != '>' { + continue + } + j++ + } + visit(start, j, name) + i = j - 1 + } +} // ExtractVars finds all variables in a command string. It respects the provided // flags for dollar ($var) and angle bracket () syntaxes. It returns a @@ -14,20 +46,12 @@ func ExtractVars(command string, allowDollar, allowAngle bool) []string { varMap := make(map[string]bool) var vars []string - matches := combinedRegex.FindAllStringSubmatch(command, -1) - for _, match := range matches { - var name string - if match[1] != "" && allowDollar { - name = match[1] - } else if match[2] != "" && allowAngle { - name = match[2] - } - - if name != "" && !varMap[name] { + WalkVars(command, allowDollar, allowAngle, func(_, _ int, name string) { + if !varMap[name] { varMap[name] = true vars = append(vars, name) } - } + }) return vars } diff --git a/pkg/parser/compose_test.go b/pkg/parser/compose_test.go index acd2be6..7aa425b 100644 --- a/pkg/parser/compose_test.go +++ b/pkg/parser/compose_test.go @@ -37,9 +37,9 @@ func TestExtractVars(t *testing.T) { want: nil, }, { - name: "escaped variables", // the parser currently extracts it anyway, which is fine for compose + name: "escaped variables", command: "echo \\$var", - want: []string{"var"}, + want: nil, }, }