]> Cypherpunks.ru repositories - gostls13.git/commitdiff
html/template, text/template: implement break and continue for range loops
authorRuss Cox <rsc@golang.org>
Thu, 20 May 2021 16:46:33 +0000 (12:46 -0400)
committerRuss Cox <rsc@golang.org>
Thu, 23 Sep 2021 02:52:10 +0000 (02:52 +0000)
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 #20531.

Change-Id: I05330482a976f1c078b4b49c2287bd9031bb7616
Reviewed-on: https://go-review.googlesource.com/c/go/+/321491
Trust: Russ Cox <rsc@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Rob Pike <r@golang.org>
12 files changed:
src/html/template/context.go
src/html/template/escape.go
src/html/template/escape_test.go
src/html/template/exec_test.go
src/text/template/doc.go
src/text/template/exec.go
src/text/template/exec_test.go
src/text/template/parse/lex.go
src/text/template/parse/lex_test.go
src/text/template/parse/node.go
src/text/template/parse/parse.go
src/text/template/parse/parse_test.go

index f7d4849928d98603a1db9162496362ef45378a26..aaa7d08359397c8fcb3b115561332553384f05b2 100644 (file)
@@ -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
@@ -22,6 +23,7 @@ type context struct {
        jsCtx   jsCtx
        attr    attr
        element element
+       n       parse.Node // for range break/continue
        err     *Error
 }
 
@@ -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
index 8739735cb7bc0cdfe6fa4507cd28df47b73919f9..6dea79c7b55fa676cd83673ceffa0d2047c4f88b 100644 (file)
@@ -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.
@@ -109,6 +118,7 @@ func makeEscaper(n *nameSpace) escaper {
                map[*parse.ActionNode][]string{},
                map[*parse.TemplateNode]string{},
                map[*parse.TextNode][]byte{},
+               nil,
        }
 }
 
@@ -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:
@@ -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
        }
@@ -466,14 +490,27 @@ 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.
@@ -481,11 +518,39 @@ func (e *escaper) escapeBranch(c context, n *parse.BranchNode, nodeName string)
                        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 {
@@ -493,6 +558,9 @@ func (e *escaper) escapeList(c context, n *parse.ListNode) context {
        }
        for _, m := range n.Nodes {
                c = e.escape(c, m)
+               if c.state == stateDead {
+                       break
+               }
        }
        return c
 }
@@ -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
index fbc84a75928b23077a2925598f472839c23b0fdd..3b0aa8c8466065bcc2972ed6ebe1aa516d7e5cd7 100644 (file)
@@ -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}}",
@@ -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",
index 888587335de112cd5637f0bfe4e94d3213126c43..523340bac959151871943fb8091f6edeeca87efb 100644 (file)
@@ -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},
index 0ea132e8e64a0d048eb7791d46a4e69acba9648f..10093881fbef66440d3f9cf46018220049886a19 100644 (file)
@@ -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.
 
index 6e005b57d7770dc7041099ba1aa11d05a9cd7f79..e03920964e607a99914292c4ea70dd1ab9e9f6b5 100644 (file)
@@ -5,6 +5,7 @@
 package template
 
 import (
+       "errors"
        "fmt"
        "internal/fmtsort"
        "io"
@@ -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) {
@@ -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:
@@ -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.
@@ -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:
index ae67b9334f3d5ea009a4603a09430ef91ac97f90..93fd54e84dae7fe86c83ff660dfa2f972377c25f 100644 (file)
@@ -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},
index 6784071b1118d16f752d5b39e8467af013881f7e..95e33771c09732747140500c2e0a5c97104ad46f 100644 (file)
@@ -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
@@ -76,6 +78,8 @@ const (
 var key = map[string]itemType{
        ".":        itemDot,
        "block":    itemBlock,
+       "break":    itemBreak,
+       "continue": itemContinue,
        "define":   itemDefine,
        "else":     itemElse,
        "end":      itemEnd,
@@ -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.
@@ -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":
index 6510eed674dd9fecdae833ff8ae710e1522c17cb..df6aabffb20e13c9010faf779a0b190bf8d43ac4 100644 (file)
@@ -35,6 +35,8 @@ var itemName = map[itemType]string{
        // keywords
        itemDot:      ".",
        itemBlock:    "block",
+       itemBreak:    "break",
+       itemContinue: "continue",
        itemDefine:   "define",
        itemElse:     "else",
        itemIf:       "if",
index 177482f9b26059b5183e29b0146985b796dc134d..47268225c8ca1cd4aef1f4cc5e4bd4c9d0543904 100644 (file)
@@ -71,6 +71,8 @@ const (
        NodeVariable                   // A $ variable.
        NodeWith                       // A with action.
        NodeComment                    // A comment.
+       NodeBreak                      // A break action.
+       NodeContinue                   // A continue action.
 )
 
 // Nodes.
@@ -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
index 1a63961c13ecce1c892584698d26e54836a1eed4..d92bed5d3d4bfc79fe5d1400f7552c36be3b3ddb 100644 (file)
@@ -31,6 +31,7 @@ type Tree struct {
        vars       []string // variables defined at the moment.
        treeSet    map[string]*Tree
        actionLine int // line of left delim starting action
+       rangeDepth int
        mode       Mode
 }
 
@@ -224,6 +225,8 @@ func (t *Tree) startParse(funcs []map[string]interface{}, lex *lexer, treeSet ma
        t.vars = []string{"$"}
        t.funcs = funcs
        t.treeSet = treeSet
+       lex.breakOK = !t.hasFunction("break")
+       lex.continueOK = !t.hasFunction("continue")
 }
 
 // stopParse terminates parsing.
@@ -386,6 +389,10 @@ func (t *Tree) action() (n Node) {
        switch token := t.nextNonSpace(); token.typ {
        case itemBlock:
                return t.blockControl()
+       case itemBreak:
+               return t.breakControl(token.pos, token.line)
+       case itemContinue:
+               return t.continueControl(token.pos, token.line)
        case itemElse:
                return t.elseControl()
        case itemEnd:
@@ -405,6 +412,32 @@ func (t *Tree) action() (n Node) {
        return t.newAction(token.pos, token.line, t.pipeline("command", itemRightDelim))
 }
 
+// Break:
+//     {{break}}
+// Break keyword is past.
+func (t *Tree) breakControl(pos Pos, line int) Node {
+       if token := t.next(); token.typ != itemRightDelim {
+               t.unexpected(token, "in {{break}}")
+       }
+       if t.rangeDepth == 0 {
+               t.errorf("{{break}} outside {{range}}")
+       }
+       return t.newBreak(pos, line)
+}
+
+// Continue:
+//     {{continue}}
+// Continue keyword is past.
+func (t *Tree) continueControl(pos Pos, line int) Node {
+       if token := t.next(); token.typ != itemRightDelim {
+               t.unexpected(token, "in {{continue}}")
+       }
+       if t.rangeDepth == 0 {
+               t.errorf("{{continue}} outside {{range}}")
+       }
+       return t.newContinue(pos, line)
+}
+
 // Pipeline:
 //     declarations? command ('|' command)*
 func (t *Tree) pipeline(context string, end itemType) (pipe *PipeNode) {
@@ -480,8 +513,14 @@ func (t *Tree) checkPipeline(pipe *PipeNode, context string) {
 func (t *Tree) parseControl(allowElseIf bool, context string) (pos Pos, line int, pipe *PipeNode, list, elseList *ListNode) {
        defer t.popVars(len(t.vars))
        pipe = t.pipeline(context, itemRightDelim)
+       if context == "range" {
+               t.rangeDepth++
+       }
        var next Node
        list, next = t.itemList()
+       if context == "range" {
+               t.rangeDepth--
+       }
        switch next.Type() {
        case nodeEnd: //done
        case nodeElse:
@@ -523,7 +562,8 @@ func (t *Tree) ifControl() Node {
 //     {{range pipeline}} itemList {{else}} itemList {{end}}
 // Range keyword is past.
 func (t *Tree) rangeControl() Node {
-       return t.newRange(t.parseControl(false, "range"))
+       r := t.newRange(t.parseControl(false, "range"))
+       return r
 }
 
 // With:
index 9b1be272e573d98e38c3da477f50660fdeddd3d0..c3679a08decb4d73246622d6e6d12a133444d8ef 100644 (file)
@@ -230,6 +230,10 @@ var parseTests = []parseTest{
                `{{range $x := .SI}}{{.}}{{end}}`},
        {"range 2 vars", "{{range $x, $y := .SI}}{{.}}{{end}}", noError,
                `{{range $x, $y := .SI}}{{.}}{{end}}`},
+       {"range with break", "{{range .SI}}{{.}}{{break}}{{end}}", noError,
+               `{{range .SI}}{{.}}{{break}}{{end}}`},
+       {"range with continue", "{{range .SI}}{{.}}{{continue}}{{end}}", noError,
+               `{{range .SI}}{{.}}{{continue}}{{end}}`},
        {"constants", "{{range .SI 1 -3.2i true false 'a' nil}}{{end}}", noError,
                `{{range .SI 1 -3.2i true false 'a' nil}}{{end}}`},
        {"template", "{{template `x`}}", noError,
@@ -279,6 +283,10 @@ var parseTests = []parseTest{
        {"adjacent args", "{{printf 3`x`}}", hasError, ""},
        {"adjacent args with .", "{{printf `x`.}}", hasError, ""},
        {"extra end after if", "{{if .X}}a{{else if .Y}}b{{end}}{{end}}", hasError, ""},
+       {"break outside range", "{{range .}}{{end}} {{break}}", hasError, ""},
+       {"continue outside range", "{{range .}}{{end}} {{continue}}", hasError, ""},
+       {"break in range else", "{{range .}}{{else}}{{break}}{{end}}", hasError, ""},
+       {"continue in range else", "{{range .}}{{else}}{{continue}}{{end}}", hasError, ""},
        // Other kinds of assignments and operators aren't available yet.
        {"bug0a", "{{$x := 0}}{{$x}}", noError, "{{$x := 0}}{{$x}}"},
        {"bug0b", "{{$x += 1}}{{$x}}", hasError, ""},