Skip to content

Commit

Permalink
html/template, text/template: implement break and continue for range …
Browse files Browse the repository at this point in the history
…loops

Break and continue for range loops was accepted as a proposal in June 2017.
It was implemented in CL 66410 (Oct 2017)
but then rolled back in CL 92155 (Feb 2018)
because html/template changes had not been implemented.

This CL reimplements break and continue in text/template
and then adds support for them in html/template as well.

Fixes golang#20531.

Change-Id: I05330482a976f1c078b4b49c2287bd9031bb7616
Reviewed-on: https://go-review.googlesource.com/c/go/+/321491
Trust: Russ Cox <[email protected]>
Run-TryBot: Russ Cox <[email protected]>
TryBot-Result: Go Bot <[email protected]>
Reviewed-by: Rob Pike <[email protected]>
  • Loading branch information
rsc committed Sep 23, 2021
1 parent 9345323 commit d0dd26a
Show file tree
Hide file tree
Showing 12 changed files with 232 additions and 4 deletions.
4 changes: 4 additions & 0 deletions src/html/template/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package template

import (
"fmt"
"text/template/parse"
)

// context describes the state an HTML parser must be in when it reaches the
Expand All @@ -22,6 +23,7 @@ type context struct {
jsCtx jsCtx
attr attr
element element
n parse.Node // for range break/continue
err *Error
}

Expand Down Expand Up @@ -141,6 +143,8 @@ const (
// stateError is an infectious error state outside any valid
// HTML/CSS/JS construct.
stateError
// stateDead marks unreachable code after a {{break}} or {{continue}}.
stateDead
)

// isComment is true for any state that contains content meant for template
Expand Down
71 changes: 70 additions & 1 deletion src/html/template/escape.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ type escaper struct {
actionNodeEdits map[*parse.ActionNode][]string
templateNodeEdits map[*parse.TemplateNode]string
textNodeEdits map[*parse.TextNode][]byte
// rangeContext holds context about the current range loop.
rangeContext *rangeContext
}

// rangeContext holds information about the current range loop.
type rangeContext struct {
outer *rangeContext // outer loop
breaks []context // context at each break action
continues []context // context at each continue action
}

// makeEscaper creates a blank escaper for the given set.
Expand All @@ -109,6 +118,7 @@ func makeEscaper(n *nameSpace) escaper {
map[*parse.ActionNode][]string{},
map[*parse.TemplateNode]string{},
map[*parse.TextNode][]byte{},
nil,
}
}

Expand All @@ -124,8 +134,16 @@ func (e *escaper) escape(c context, n parse.Node) context {
switch n := n.(type) {
case *parse.ActionNode:
return e.escapeAction(c, n)
case *parse.BreakNode:
c.n = n
e.rangeContext.breaks = append(e.rangeContext.breaks, c)
return context{state: stateDead}
case *parse.CommentNode:
return c
case *parse.ContinueNode:
c.n = n
e.rangeContext.continues = append(e.rangeContext.breaks, c)
return context{state: stateDead}
case *parse.IfNode:
return e.escapeBranch(c, &n.BranchNode, "if")
case *parse.ListNode:
Expand Down Expand Up @@ -427,6 +445,12 @@ func join(a, b context, node parse.Node, nodeName string) context {
if b.state == stateError {
return b
}
if a.state == stateDead {
return b
}
if b.state == stateDead {
return a
}
if a.eq(b) {
return a
}
Expand Down Expand Up @@ -466,33 +490,77 @@ func join(a, b context, node parse.Node, nodeName string) context {

// escapeBranch escapes a branch template node: "if", "range" and "with".
func (e *escaper) escapeBranch(c context, n *parse.BranchNode, nodeName string) context {
if nodeName == "range" {
e.rangeContext = &rangeContext{outer: e.rangeContext}
}
c0 := e.escapeList(c, n.List)
if nodeName == "range" && c0.state != stateError {
if nodeName == "range" {
if c0.state != stateError {
c0 = joinRange(c0, e.rangeContext)
}
e.rangeContext = e.rangeContext.outer
if c0.state == stateError {
return c0
}

// The "true" branch of a "range" node can execute multiple times.
// We check that executing n.List once results in the same context
// as executing n.List twice.
e.rangeContext = &rangeContext{outer: e.rangeContext}
c1, _ := e.escapeListConditionally(c0, n.List, nil)
c0 = join(c0, c1, n, nodeName)
if c0.state == stateError {
e.rangeContext = e.rangeContext.outer
// Make clear that this is a problem on loop re-entry
// since developers tend to overlook that branch when
// debugging templates.
c0.err.Line = n.Line
c0.err.Description = "on range loop re-entry: " + c0.err.Description
return c0
}
c0 = joinRange(c0, e.rangeContext)
e.rangeContext = e.rangeContext.outer
if c0.state == stateError {
return c0
}
}
c1 := e.escapeList(c, n.ElseList)
return join(c0, c1, n, nodeName)
}

func joinRange(c0 context, rc *rangeContext) context {
// Merge contexts at break and continue statements into overall body context.
// In theory we could treat breaks differently from continues, but for now it is
// enough to treat them both as going back to the start of the loop (which may then stop).
for _, c := range rc.breaks {
c0 = join(c0, c, c.n, "range")
if c0.state == stateError {
c0.err.Line = c.n.(*parse.BreakNode).Line
c0.err.Description = "at range loop break: " + c0.err.Description
return c0
}
}
for _, c := range rc.continues {
c0 = join(c0, c, c.n, "range")
if c0.state == stateError {
c0.err.Line = c.n.(*parse.ContinueNode).Line
c0.err.Description = "at range loop continue: " + c0.err.Description
return c0
}
}
return c0
}

// escapeList escapes a list template node.
func (e *escaper) escapeList(c context, n *parse.ListNode) context {
if n == nil {
return c
}
for _, m := range n.Nodes {
c = e.escape(c, m)
if c.state == stateDead {
break
}
}
return c
}
Expand All @@ -503,6 +571,7 @@ func (e *escaper) escapeList(c context, n *parse.ListNode) context {
// which is the same as whether e was updated.
func (e *escaper) escapeListConditionally(c context, n *parse.ListNode, filter func(*escaper, context) bool) (context, bool) {
e1 := makeEscaper(e.ns)
e1.rangeContext = e.rangeContext
// Make type inferences available to f.
for k, v := range e.output {
e1.output[k] = v
Expand Down
24 changes: 24 additions & 0 deletions src/html/template/escape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,22 @@ func TestErrors(t *testing.T) {
"<a href='/foo?{{range .Items}}&{{.K}}={{.V}}{{end}}'>",
"",
},
{
"{{range .Items}}<a{{if .X}}{{end}}>{{end}}",
"",
},
{
"{{range .Items}}<a{{if .X}}{{end}}>{{continue}}{{end}}",
"",
},
{
"{{range .Items}}<a{{if .X}}{{end}}>{{break}}{{end}}",
"",
},
{
"{{range .Items}}<a{{if .X}}{{end}}>{{if .X}}{{break}}{{end}}{{end}}",
"",
},
// Error cases.
{
"{{if .Cond}}<a{{end}}",
Expand Down Expand Up @@ -955,6 +971,14 @@ func TestErrors(t *testing.T) {
"\n{{range .Items}} x='<a{{end}}",
"z:2:8: on range loop re-entry: {{range}} branches",
},
{
"{{range .Items}}<a{{if .X}}{{break}}{{end}}>{{end}}",
"z:1:29: at range loop break: {{range}} branches end in different contexts",
},
{
"{{range .Items}}<a{{if .X}}{{continue}}{{end}}>{{end}}",
"z:1:29: at range loop continue: {{range}} branches end in different contexts",
},
{
"<a b=1 c={{.H}}",
"z: ends in a non-text context: {stateAttr delimSpaceOrTagEnd",
Expand Down
2 changes: 2 additions & 0 deletions src/html/template/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,8 @@ var execTests = []execTest{
{"range empty no else", "{{range .SIEmpty}}-{{.}}-{{end}}", "", tVal, true},
{"range []int else", "{{range .SI}}-{{.}}-{{else}}EMPTY{{end}}", "-3--4--5-", tVal, true},
{"range empty else", "{{range .SIEmpty}}-{{.}}-{{else}}EMPTY{{end}}", "EMPTY", tVal, true},
{"range []int break else", "{{range .SI}}-{{.}}-{{break}}NOTREACHED{{else}}EMPTY{{end}}", "-3-", tVal, true},
{"range []int continue else", "{{range .SI}}-{{.}}-{{continue}}NOTREACHED{{else}}EMPTY{{end}}", "-3--4--5-", tVal, true},
{"range []bool", "{{range .SB}}-{{.}}-{{end}}", "-true--false-", tVal, true},
{"range []int method", "{{range .SI | .MAdd .I}}-{{.}}-{{end}}", "-20--21--22-", tVal, true},
{"range map", "{{range .MSI}}-{{.}}-{{end}}", "-1--3--2-", tVal, true},
Expand Down
8 changes: 8 additions & 0 deletions src/text/template/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ data, defined in detail in the corresponding sections that follow.
T0 is executed; otherwise, dot is set to the successive elements
of the array, slice, or map and T1 is executed.
{{break}}
The innermost {{range pipeline}} loop is ended early, stopping the
current iteration and bypassing all remaining iterations.
{{continue}}
The current iteration of the innermost {{range pipeline}} loop is
stopped, and the loop starts the next iteration.
{{template "name"}}
The template with the specified name is executed with nil data.
Expand Down
24 changes: 23 additions & 1 deletion src/text/template/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package template

import (
"errors"
"fmt"
"internal/fmtsort"
"io"
Expand Down Expand Up @@ -243,6 +244,12 @@ func (t *Template) DefinedTemplates() string {
return b.String()
}

// Sentinel errors for use with panic to signal early exits from range loops.
var (
walkBreak = errors.New("break")
walkContinue = errors.New("continue")
)

// Walk functions step through the major pieces of the template structure,
// generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) {
Expand All @@ -255,7 +262,11 @@ func (s *state) walk(dot reflect.Value, node parse.Node) {
if len(node.Pipe.Decl) == 0 {
s.printValue(node, val)
}
case *parse.BreakNode:
panic(walkBreak)
case *parse.CommentNode:
case *parse.ContinueNode:
panic(walkContinue)
case *parse.IfNode:
s.walkIfOrWith(parse.NodeIf, dot, node.Pipe, node.List, node.ElseList)
case *parse.ListNode:
Expand Down Expand Up @@ -334,6 +345,11 @@ func isTrue(val reflect.Value) (truth, ok bool) {

func (s *state) walkRange(dot reflect.Value, r *parse.RangeNode) {
s.at(r)
defer func() {
if r := recover(); r != nil && r != walkBreak {
panic(r)
}
}()
defer s.pop(s.mark())
val, _ := indirect(s.evalPipeline(dot, r.Pipe))
// mark top of stack before any variables in the body are pushed.
Expand All @@ -347,8 +363,14 @@ func (s *state) walkRange(dot reflect.Value, r *parse.RangeNode) {
if len(r.Pipe.Decl) > 1 {
s.setTopVar(2, index)
}
defer s.pop(mark)
defer func() {
// Consume panic(walkContinue)
if r := recover(); r != nil && r != walkContinue {
panic(r)
}
}()
s.walk(elem, r.List)
s.pop(mark)
}
switch val.Kind() {
case reflect.Array, reflect.Slice:
Expand Down
2 changes: 2 additions & 0 deletions src/text/template/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,8 @@ var execTests = []execTest{
{"range empty no else", "{{range .SIEmpty}}-{{.}}-{{end}}", "", tVal, true},
{"range []int else", "{{range .SI}}-{{.}}-{{else}}EMPTY{{end}}", "-3--4--5-", tVal, true},
{"range empty else", "{{range .SIEmpty}}-{{.}}-{{else}}EMPTY{{end}}", "EMPTY", tVal, true},
{"range []int break else", "{{range .SI}}-{{.}}-{{break}}NOTREACHED{{else}}EMPTY{{end}}", "-3-", tVal, true},
{"range []int continue else", "{{range .SI}}-{{.}}-{{continue}}NOTREACHED{{else}}EMPTY{{end}}", "-3--4--5-", tVal, true},
{"range []bool", "{{range .SB}}-{{.}}-{{end}}", "-true--false-", tVal, true},
{"range []int method", "{{range .SI | .MAdd .I}}-{{.}}-{{end}}", "-20--21--22-", tVal, true},
{"range map", "{{range .MSI}}-{{.}}-{{end}}", "-1--3--2-", tVal, true},
Expand Down
13 changes: 12 additions & 1 deletion src/text/template/parse/lex.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ const (
// Keywords appear after all the rest.
itemKeyword // used only to delimit the keywords
itemBlock // block keyword
itemBreak // break keyword
itemContinue // continue keyword
itemDot // the cursor, spelled '.'
itemDefine // define keyword
itemElse // else keyword
Expand All @@ -76,6 +78,8 @@ const (
var key = map[string]itemType{
".": itemDot,
"block": itemBlock,
"break": itemBreak,
"continue": itemContinue,
"define": itemDefine,
"else": itemElse,
"end": itemEnd,
Expand Down Expand Up @@ -119,6 +123,8 @@ type lexer struct {
parenDepth int // nesting depth of ( ) exprs
line int // 1+number of newlines seen
startLine int // start line of this item
breakOK bool // break keyword allowed
continueOK bool // continue keyword allowed
}

// next returns the next rune in the input.
Expand Down Expand Up @@ -461,7 +467,12 @@ Loop:
}
switch {
case key[word] > itemKeyword:
l.emit(key[word])
item := key[word]
if item == itemBreak && !l.breakOK || item == itemContinue && !l.continueOK {
l.emit(itemIdentifier)
} else {
l.emit(item)
}
case word[0] == '.':
l.emit(itemField)
case word == "true", word == "false":
Expand Down
2 changes: 2 additions & 0 deletions src/text/template/parse/lex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ var itemName = map[itemType]string{
// keywords
itemDot: ".",
itemBlock: "block",
itemBreak: "break",
itemContinue: "continue",
itemDefine: "define",
itemElse: "else",
itemIf: "if",
Expand Down
36 changes: 36 additions & 0 deletions src/text/template/parse/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ const (
NodeVariable // A $ variable.
NodeWith // A with action.
NodeComment // A comment.
NodeBreak // A break action.
NodeContinue // A continue action.
)

// Nodes.
Expand Down Expand Up @@ -907,6 +909,40 @@ func (i *IfNode) Copy() Node {
return i.tr.newIf(i.Pos, i.Line, i.Pipe.CopyPipe(), i.List.CopyList(), i.ElseList.CopyList())
}

// BreakNode represents a {{break}} action.
type BreakNode struct {
tr *Tree
NodeType
Pos
Line int
}

func (t *Tree) newBreak(pos Pos, line int) *BreakNode {
return &BreakNode{tr: t, NodeType: NodeBreak, Pos: pos, Line: line}
}

func (b *BreakNode) Copy() Node { return b.tr.newBreak(b.Pos, b.Line) }
func (b *BreakNode) String() string { return "{{break}}" }
func (b *BreakNode) tree() *Tree { return b.tr }
func (b *BreakNode) writeTo(sb *strings.Builder) { sb.WriteString("{{break}}") }

// ContinueNode represents a {{continue}} action.
type ContinueNode struct {
tr *Tree
NodeType
Pos
Line int
}

func (t *Tree) newContinue(pos Pos, line int) *ContinueNode {
return &ContinueNode{tr: t, NodeType: NodeContinue, Pos: pos, Line: line}
}

func (c *ContinueNode) Copy() Node { return c.tr.newContinue(c.Pos, c.Line) }
func (c *ContinueNode) String() string { return "{{continue}}" }
func (c *ContinueNode) tree() *Tree { return c.tr }
func (c *ContinueNode) writeTo(sb *strings.Builder) { sb.WriteString("{{continue}}") }

// RangeNode represents a {{range}} action and its commands.
type RangeNode struct {
BranchNode
Expand Down
Loading

0 comments on commit d0dd26a

Please sign in to comment.