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.
This commit is contained in:
Harvey Tindall
2025-08-03 17:36:33 +01:00
parent 492d5715fe
commit 5d49a56d94
2 changed files with 193 additions and 34 deletions
+65 -34
View File
@@ -1,6 +1,9 @@
package main package main
import "fmt" import (
"fmt"
"slices"
)
func truthy(val interface{}) bool { func truthy(val interface{}) bool {
switch v := val.(type) { switch v := val.(type) {
@@ -18,43 +21,72 @@ func truthy(val interface{}) bool {
// Variables should be written as {varName}. // Variables should be written as {varName}.
// If statements should be written as {if (!)varName}...{endif}. // If statements should be written as {if (!)varName}...{endif}.
// Strings are true if != "", ints are true if != 0. // 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 ifStart, ifEnd := -1, -1
ifTrue := false ifTrue := false
invalidIf := false invalidIf := false
previousEnd := -2 previousEnd := -2
cStart, cEnd := -1, -1 blockRawStart := -1
blockContentStart, blockContentEnd := -1, -1
varStart, varEnd := -1, -1 varStart, varEnd := -1, -1
varName := "" varName := ""
out := "" out := ""
var err error = nil
oob := func(i int) bool { return i < 0 || i >= len(content) }
for i, c := range content { for i, c := range content {
if c == '{' { if c == '{' {
cStart = i + 1 blockContentStart = i + 1
for content[cStart] == ' ' { blockRawStart = i
cStart++ if content[i+1] == '{' {
err = fmt.Errorf(`double braces ("{{") at position %d, use single brace only`, i)
blockContentStart++
} }
if content[cStart:cStart+3] == "if " { for !oob(blockContentStart) && content[blockContentStart] == ' ' {
varStart = cStart + 3 blockContentStart++
}
if oob(blockContentStart) {
continue
}
if !oob(blockContentStart+3) && content[blockContentStart:blockContentStart+3] == "if " {
varStart = blockContentStart + 3
for content[varStart] == ' ' { for content[varStart] == ' ' {
varStart++ varStart++
} }
} }
if ifStart == -1 { if ifStart == -1 && (oob(i-1) || content[i-1] != '{') {
out += content[previousEnd+2 : i] out += content[previousEnd+2 : i]
} }
if content[cStart:cStart+5] != "endif" || invalidIf { if invalidIf || oob(blockContentStart+5) || content[blockContentStart:blockContentStart+5] != "endif" {
continue continue
} }
ifEnd = i - 1 ifEnd = i - 1
if ifTrue { 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 ifTrue = false
} }
} else if c == '}' { } 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 { if varStart != -1 {
ifStart = i + 1 ifStart = i + 1
varEnd = i - 1 varEnd = i - 1
for content[varEnd] == ' ' { for !oob(varEnd) && content[varEnd] == ' ' {
varEnd-- varEnd--
} }
varName = content[varStart : varEnd+1] varName = content[varStart : varEnd+1]
@@ -63,14 +95,8 @@ func templateEmail(content string, variables []string, conditionals []string, va
positive = false positive = false
varName = varName[1:] varName = varName[1:]
} }
validVar := false
wrappedVarName := "{" + varName + "}" wrappedVarName := "{" + varName + "}"
for _, v := range conditionals { validVar := slices.Contains(conditionals, wrappedVarName)
if v == wrappedVarName {
validVar = true
break
}
}
if validVar { if validVar {
ifTrue = positive == truthy(values[varName]) ifTrue = positive == truthy(values[varName])
} else { } else {
@@ -79,27 +105,26 @@ func templateEmail(content string, variables []string, conditionals []string, va
} }
varStart, varEnd = -1, -1 varStart, varEnd = -1, -1
} }
cEnd = i - 1 blockContentEnd = i - 1
for content[cEnd] == ' ' { for content[blockContentEnd] == ' ' {
cEnd-- blockContentEnd--
} }
previousEnd = i - 1 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 continue
} }
validVar := false varName = content[blockContentStart : blockContentEnd+1]
varName = content[cStart : cEnd+1] blockContentStart, blockContentEnd = -1, -1
cStart, cEnd = -1, -1 blockRawStart = -1
if ifStart != -1 { if ifStart != -1 {
continue continue
} }
wrappedVarName := "{" + varName + "}" wrappedVarName := "{" + varName + "}"
for _, v := range variables { validVar := slices.Contains(variables, wrappedVarName)
if v == wrappedVarName {
validVar = true
break
}
}
if !validVar { if !validVar {
out += wrappedVarName out += wrappedVarName
continue continue
@@ -107,11 +132,17 @@ func templateEmail(content string, variables []string, conditionals []string, va
out += fmt.Sprint(values[varName]) 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 { if previousEnd+1 != len(content)-1 {
out += content[previousEnd+2:] out += content[previousEnd+2:]
} }
if out == "" { if out == "" {
return content return content, err
} }
return out return out, err
} }
+128
View File
@@ -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))
}
}
}