diff --git a/executor.go b/executor.go index d88f309..21cd650 100644 --- a/executor.go +++ b/executor.go @@ -2959,11 +2959,23 @@ func (e *Executor) templateString(s string, host string, task *Task) string { // resolveExpr resolves a template expression. func (e *Executor) resolveExpr(expr string, host string, task *Task) string { - // Handle filters - if contains(expr, " | ") { - parts := splitN(expr, " | ", 2) - value := e.resolveExpr(parts[0], host, task) - return e.applyFilter(value, parts[1]) + parts := splitTemplatePipeline(expr) + if len(parts) == 0 { + return "" + } + + value := e.resolveExprBase(parts[0], host, task) + for _, filter := range parts[1:] { + value = e.applyFilter(value, filter) + } + return value +} + +// resolveExprBase resolves a single templating expression without applying filters. +func (e *Executor) resolveExprBase(expr string, host string, task *Task) string { + expr = corexTrimSpace(expr) + if expr == "" { + return "" } // Handle lookups @@ -3062,6 +3074,70 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string { return "{{ " + expr + " }}" // Return as-is if unresolved } +// splitTemplatePipeline splits a template expression into a base expression +// and any chained filters, preserving quoted or parenthesised filter arguments. +func splitTemplatePipeline(expr string) []string { + expr = corexTrimSpace(expr) + if expr == "" { + return nil + } + + parts := make([]string, 0, 4) + var ( + current strings.Builder + depth int + inSingle bool + inDouble bool + escaped bool + ) + + flush := func() { + part := corexTrimSpace(current.String()) + if part != "" { + parts = append(parts, part) + } + current.Reset() + } + + for i := 0; i < len(expr); i++ { + ch := expr[i] + switch { + case escaped: + current.WriteByte(ch) + escaped = false + case ch == '\\' && (inSingle || inDouble): + current.WriteByte(ch) + escaped = true + case ch == '\'' && !inDouble: + current.WriteByte(ch) + inSingle = !inSingle + case ch == '"' && !inSingle: + current.WriteByte(ch) + inDouble = !inDouble + case inSingle || inDouble: + current.WriteByte(ch) + case ch == '(': + depth++ + current.WriteByte(ch) + case ch == ')': + if depth > 0 { + depth-- + } + current.WriteByte(ch) + case ch == '|' && depth == 0: + flush() + default: + current.WriteByte(ch) + } + } + + flush() + if len(parts) == 0 { + return nil + } + return parts +} + // buildEnvironmentPrefix renders merged play/task environment variables as // shell export statements. func (e *Executor) buildEnvironmentPrefix(host string, task *Task, play *Play) string { diff --git a/executor_test.go b/executor_test.go index c073c94..5bc7ee9 100644 --- a/executor_test.go +++ b/executor_test.go @@ -2478,6 +2478,15 @@ func TestExecutor_ApplyFilter_Good_RegexReplace(t *testing.T) { assert.Equal(t, "123", e.applyFilter("abc123", `regex_replace("\D+", "")`)) } +func TestExecutor_TemplateString_Good_ChainedFilters(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["padded"] = " web01 " + + result := e.templateString("{{ missing_var | default('fallback') | trim }} {{ padded | trim }}", "", nil) + + assert.Equal(t, "fallback web01", result) +} + // --- resolveLoop --- func TestExecutor_ResolveLoop_Good_SliceAny(t *testing.T) {