From 5d49a56d946a630a77800c8a13993d745e542203 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 3 Aug 2025 17:36:33 +0100 Subject: [PATCH] template: add tests, fix up easy holes should cope with double-braced blocks now (treating them the same as single-braced. templateEmail now returns an error, which should not be seen as catastrophic, but reports likely mistakes. --- template.go | 99 +++++++++++++++++++++++------------- template_test.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 34 deletions(-) create mode 100644 template_test.go diff --git a/template.go b/template.go index 8458729..d0fadb8 100644 --- a/template.go +++ b/template.go @@ -1,6 +1,9 @@ package main -import "fmt" +import ( + "fmt" + "slices" +) func truthy(val interface{}) bool { switch v := val.(type) { @@ -18,43 +21,72 @@ func truthy(val interface{}) bool { // Variables should be written as {varName}. // If statements should be written as {if (!)varName}...{endif}. // Strings are true if != "", ints are true if != 0. -func templateEmail(content string, variables []string, conditionals []string, values map[string]interface{}) string { +// Errors returned are likely warnings only. +func templateEmail(content string, variables []string, conditionals []string, values map[string]interface{}) (string, error) { + // minimum length for templatable content (albeit just "{}" -> "") + if len(content) < 2 { + return content, nil + } ifStart, ifEnd := -1, -1 ifTrue := false invalidIf := false previousEnd := -2 - cStart, cEnd := -1, -1 + blockRawStart := -1 + blockContentStart, blockContentEnd := -1, -1 varStart, varEnd := -1, -1 varName := "" out := "" + var err error = nil + + oob := func(i int) bool { return i < 0 || i >= len(content) } + for i, c := range content { if c == '{' { - cStart = i + 1 - for content[cStart] == ' ' { - cStart++ + blockContentStart = i + 1 + blockRawStart = i + if content[i+1] == '{' { + err = fmt.Errorf(`double braces ("{{") at position %d, use single brace only`, i) + blockContentStart++ } - if content[cStart:cStart+3] == "if " { - varStart = cStart + 3 + for !oob(blockContentStart) && content[blockContentStart] == ' ' { + blockContentStart++ + } + if oob(blockContentStart) { + continue + } + if !oob(blockContentStart+3) && content[blockContentStart:blockContentStart+3] == "if " { + varStart = blockContentStart + 3 for content[varStart] == ' ' { varStart++ } } - if ifStart == -1 { + if ifStart == -1 && (oob(i-1) || content[i-1] != '{') { out += content[previousEnd+2 : i] } - if content[cStart:cStart+5] != "endif" || invalidIf { + if invalidIf || oob(blockContentStart+5) || content[blockContentStart:blockContentStart+5] != "endif" { continue } ifEnd = i - 1 if ifTrue { - out += templateEmail(content[ifStart:ifEnd+1], variables, conditionals, values) + toAppend, subErr := templateEmail(content[ifStart:ifEnd+1], variables, conditionals, values) + out += toAppend + if subErr != nil { + err = subErr + } ifTrue = false } } else if c == '}' { + doubleBraced := !oob(i+1) && content[i+1] == '}' + if doubleBraced { + err = fmt.Errorf(`double braces ("}}") at position %d, use single brace only`, i) + } + if !oob(i-1) && content[i-1] == '}' { + continue + } if varStart != -1 { ifStart = i + 1 varEnd = i - 1 - for content[varEnd] == ' ' { + for !oob(varEnd) && content[varEnd] == ' ' { varEnd-- } varName = content[varStart : varEnd+1] @@ -63,14 +95,8 @@ func templateEmail(content string, variables []string, conditionals []string, va positive = false varName = varName[1:] } - validVar := false wrappedVarName := "{" + varName + "}" - for _, v := range conditionals { - if v == wrappedVarName { - validVar = true - break - } - } + validVar := slices.Contains(conditionals, wrappedVarName) if validVar { ifTrue = positive == truthy(values[varName]) } else { @@ -79,27 +105,26 @@ func templateEmail(content string, variables []string, conditionals []string, va } varStart, varEnd = -1, -1 } - cEnd = i - 1 - for content[cEnd] == ' ' { - cEnd-- + blockContentEnd = i - 1 + for content[blockContentEnd] == ' ' { + blockContentEnd-- } previousEnd = i - 1 - if content[cEnd-4:cEnd+1] == "endif" && !invalidIf { + // Skip the extra brace + if doubleBraced { + previousEnd++ + } + if !oob(blockContentEnd-4) && !oob(blockContentEnd+1) && content[blockContentEnd-4:blockContentEnd+1] == "endif" && !invalidIf { continue } - validVar := false - varName = content[cStart : cEnd+1] - cStart, cEnd = -1, -1 + varName = content[blockContentStart : blockContentEnd+1] + blockContentStart, blockContentEnd = -1, -1 + blockRawStart = -1 if ifStart != -1 { continue } wrappedVarName := "{" + varName + "}" - for _, v := range variables { - if v == wrappedVarName { - validVar = true - break - } - } + validVar := slices.Contains(variables, wrappedVarName) if !validVar { out += wrappedVarName continue @@ -107,11 +132,17 @@ func templateEmail(content string, variables []string, conditionals []string, va out += fmt.Sprint(values[varName]) } } + if blockContentStart != -1 && blockContentEnd == -1 { + err = fmt.Errorf(`incomplete block (single "{") near position %d`, blockContentStart) + // Include the brace, maybe the user wants it. + previousEnd = blockRawStart - 2 + } if previousEnd+1 != len(content)-1 { out += content[previousEnd+2:] + } if out == "" { - return content + return content, err } - return out + return out, err } diff --git a/template_test.go b/template_test.go new file mode 100644 index 0000000..465bc2b --- /dev/null +++ b/template_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "strings" + "testing" +) + +// In == Out when nothing is meant to be templated. +func TestBlankTemplate(t *testing.T) { + in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` + + out, err := templateEmail(in, []string{}, []string{}, map[string]any{}) + + if err != nil { + t.Fatalf("error: %+v", err) + } + + if out != in { + t.Fatalf(`returned string doesn't match input: "%+v" != "%+v"`, out, in) + } +} + +func testConditional(isTrue bool, t *testing.T) { + in := `Success, {username}! Your account has been created. {if myCondition}Log in at {myAccountURL} with username {username} to get started.{endif}` + + vars := []string{"{username}", "{myAccountURL}", "{myCondition}"} + conds := vars + vals := map[string]any{ + "username": "TemplateUsername", + "myAccountURL": "TemplateURL", + "myCondition": isTrue, + } + + out, err := templateEmail(in, vars, conds, vals) + + target := "" + if isTrue { + target = `Success, {username}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.` + } else { + target = `Success, {username}! Your account has been created. ` + } + + target = strings.ReplaceAll(target, "{username}", vals["username"].(string)) + target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string)) + + if err != nil { + t.Fatalf("error: %+v", err) + } + + if out != target { + t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) + } +} + +func TestConditionalTrue(t *testing.T) { + testConditional(true, t) +} + +func TestConditionalFalse(t *testing.T) { + testConditional(false, t) +} + +// Template mistakenly double-braced values, but return a warning. +func TestTemplateDoubleBraceGracefulHandling(t *testing.T) { + in := `Success, {{username}}! Your account has been created. Log in at {myAccountURL} with username {username} to get started.` + + vars := []string{"{username}", "{myAccountURL}"} + vals := map[string]any{ + "username": "TemplateUsername", + "myAccountURL": "TemplateURL", + } + + target := strings.ReplaceAll(in, "{{username}}", vals["username"].(string)) + target = strings.ReplaceAll(target, "{username}", vals["username"].(string)) + target = strings.ReplaceAll(target, "{myAccountURL}", vals["myAccountURL"].(string)) + + out, err := templateEmail(in, vars, []string{}, vals) + + if err == nil { + t.Fatal("no error when given double-braced variable") + } + + if out != target { + t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) + } +} + +func TestVarAtAnyPosition(t *testing.T) { + in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` + vars := []string{"{username}", "{myAccountURL}"} + vals := map[string]any{ + "username": "TemplateUsername", + "myAccountURL": "TemplateURL", + } + + for i := range in { + newIn := in[0:i] + vars[0] + in[i:] + + target := strings.ReplaceAll(newIn, vars[0], vals["username"].(string)) + + out, err := templateEmail(newIn, vars, []string{}, vals) + + if err != nil { + t.Fatalf("error: %+v", err) + } + + if out != target { + t.Fatalf(`returned string doesn't match desired output: "%+v" != "%+v"`, out, target) + } + } +} + +func TestIncompleteBlock(t *testing.T) { + in := `Success, user! Your account has been created. Log in at myAccountURL with your username to get started.` + for i := range in { + newIn := in[0:i] + "{" + in[i:] + + out, err := templateEmail(newIn, []string{"a"}, []string{"a"}, map[string]any{"a": "a"}) + + if out != newIn { + t.Fatalf(`returned string for position %d/%d doesn't match desired output: "%+v" != "%+v"`, i+1, len(newIn), out, newIn) + } + if err == nil { + t.Fatalf("no error when given incomplete block with brace at position %d/%d", i+1, len(newIn)) + } + + } +}