diff --git a/Sources/Stencil/Extension.swift b/Sources/Stencil/Extension.swift index 20f6d5d..42d5431 100644 --- a/Sources/Stencil/Extension.swift +++ b/Sources/Stencil/Extension.swift @@ -56,6 +56,8 @@ class DefaultExtension: Extension { fileprivate func registerDefaultTags() { registerTag("for", parser: ForNode.parse) + registerTag("break", parser: LoopTerminationNode.parse) + registerTag("continue", parser: LoopTerminationNode.parse) registerTag("if", parser: IfNode.parse) registerTag("ifnot", parser: IfNode.parse_ifnot) #if !os(Linux) diff --git a/Sources/Stencil/ForTag.swift b/Sources/Stencil/ForTag.swift index 4c0ad49..3d34eb8 100644 --- a/Sources/Stencil/ForTag.swift +++ b/Sources/Stencil/ForTag.swift @@ -85,28 +85,35 @@ class ForNode: NodeType { if !values.isEmpty { let count = values.count + var result = "" - return try zip(0..., values) - .map { index, item in - let forContext: [String: Any] = [ - "first": index == 0, - "last": index == (count - 1), - "counter": index + 1, - "counter0": index, - "length": count - ] + for (index, item) in zip(0..., values) { + let forContext: [String: Any] = [ + "first": index == 0, + "last": index == (count - 1), + "counter": index + 1, + "counter0": index, + "length": count + ] - return try context.push(dictionary: ["forloop": forContext]) { - try push(value: item, context: context) { - try renderNodes(nodes, context) - } + var shouldBreak = false + result += try context.push(dictionary: ["forloop": forContext]) { + defer { shouldBreak = context[LoopTerminationNode.breakContextKey] != nil } + return try push(value: item, context: context) { + try renderNodes(nodes, context) } } - .joined() - } - return try context.push { - try renderNodes(emptyNodes, context) + if shouldBreak { + break + } + } + + return result + } else { + return try context.push { + try renderNodes(emptyNodes, context) + } } } @@ -174,3 +181,53 @@ class ForNode: NodeType { return values } } + +struct LoopTerminationNode: NodeType { + static let breakContextKey = "_internal_forloop_break" + static let continueContextKey = "_internal_forloop_continue" + + let name: String + let token: Token? + + var contextKey: String { + "_internal_forloop_\(name)" + } + + private init(name: String, token: Token? = nil) { + self.name = name + self.token = token + } + + static func parse(_ parser: TokenParser, token: Token) throws -> LoopTerminationNode { + guard token.components.count == 1 else { + throw TemplateSyntaxError("'\(token.contents)' does not accept parameters") + } + guard parser.hasOpenedForTag() else { + throw TemplateSyntaxError("'\(token.contents)' can be used only inside loop body") + } + return LoopTerminationNode(name: token.contents) + } + + func render(_ context: Context) throws -> String { + let offset = zip(0..., context.dictionaries).reversed().first { _, dictionary in + dictionary["forloop"] != nil + }?.0 + + if let offset = offset { + context.dictionaries[offset][contextKey] = true + } + + return "" + } +} + +private extension TokenParser { + func hasOpenedForTag() -> Bool { + var openForCount = 0 + for parsedToken in parsedTokens.reversed() where parsedToken.kind == .block { + if parsedToken.components.first == "endfor" { openForCount -= 1 } + if parsedToken.components.first == "for" { openForCount += 1 } + } + return openForCount > 0 + } +} diff --git a/Sources/Stencil/Node.swift b/Sources/Stencil/Node.swift index c9205fa..f06f7ed 100644 --- a/Sources/Stencil/Node.swift +++ b/Sources/Stencil/Node.swift @@ -11,15 +11,24 @@ public protocol NodeType { /// Render the collection of nodes in the given context public func renderNodes(_ nodes: [NodeType], _ context: Context) throws -> String { - try nodes - .map { node in - do { - return try node.render(context) - } catch { - throw error.withToken(node.token) - } + var result = "" + + for node in nodes { + do { + result += try node.render(context) + } catch { + throw error.withToken(node.token) } - .joined() + + let shouldBreak = context[LoopTerminationNode.breakContextKey] != nil + let shouldContinue = context[LoopTerminationNode.continueContextKey] != nil + + if shouldBreak || shouldContinue { + break + } + } + + return result } /// Simple node, used for triggering a closure during rendering diff --git a/Sources/Stencil/Parser.swift b/Sources/Stencil/Parser.swift index 32711b0..be786be 100644 --- a/Sources/Stencil/Parser.swift +++ b/Sources/Stencil/Parser.swift @@ -18,6 +18,7 @@ public class TokenParser { public typealias TagParser = (TokenParser, Token) throws -> NodeType fileprivate var tokens: [Token] + fileprivate(set) var parsedTokens: [Token] = [] fileprivate let environment: Environment fileprivate var previousWhiteSpace: WhitespaceBehaviour.Behaviour? @@ -74,7 +75,9 @@ public class TokenParser { /// Pop the next token (returning it) public func nextToken() -> Token? { if !tokens.isEmpty { - return tokens.remove(at: 0) + let nextToken = tokens.remove(at: 0) + parsedTokens.append(nextToken) + return nextToken } return nil @@ -87,6 +90,9 @@ public class TokenParser { /// Insert a token public func prependToken(_ token: Token) { tokens.insert(token, at: 0) + if parsedTokens.last == token { + parsedTokens.removeLast() + } } /// Create filter expression from a string contained in provided token diff --git a/Tests/StencilTests/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index 942385e..c551233 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -312,6 +312,152 @@ final class ForNodeTests: XCTestCase { ) try expect(try parser.parse()).toThrow(error) } + + func testBreak() { + it("can break from loop") { + let template = Template(templateString: """ + {% for item in items %}\ + {{ item }}{% break %}\ + {% endfor %} + """) + try expect(template.render(self.context)) == """ + 1 + """ + } + + it("can break from inner node") { + let template = Template(templateString: """ + {% for item in items %}\ + {{ item }}\ + {% if forloop.first %}<{% break %}>{% endif %}!\ + {% endfor %} + """) + try expect(template.render(self.context)) == """ + 1< + """ + } + + it("does not allow break outside loop") { + let template = Template(templateString: "{% for item in items %}{% endfor %}{% break %}") + let error = self.expectedSyntaxError( + token: "break", + template: template, + description: "'break' can be used only inside loop body" + ) + try expect(template.render(self.context)).toThrow(error) + } + } + + func testBreakNested() { + it("breaks outer loop") { + let template = Template(templateString: """ + {% for item in items %}\ + outer: {{ item }} + {% for item in items %}\ + inner: {{ item }} + {% endfor %}\ + {% break %}\ + {% endfor %} + """) + try expect(template.render(self.context)) == """ + outer: 1 + inner: 1 + inner: 2 + inner: 3 + + """ + } + + it("breaks inner loop") { + let template = Template(templateString: """ + {% for item in items %}\ + outer: {{ item }} + {% for item in items %}\ + inner: {{ item }} + {% break %}\ + {% endfor %}\ + {% endfor %} + """) + try expect(template.render(self.context)) == """ + outer: 1 + inner: 1 + outer: 2 + inner: 1 + outer: 3 + inner: 1 + + """ + } + } + + func testContinue() { + it("can continue loop") { + let template = Template(templateString: """ + {% for item in items %}\ + {{ item }}{% continue %}!\ + {% endfor %} + """) + try expect(template.render(self.context)) == "123" + } + + it("can continue from inner node") { + let template = Template(templateString: """ + {% for item in items %}\ + {% if forloop.last %}<{% continue %}>{% endif %}!\ + {{ item }}\ + {% endfor %} + """) + try expect(template.render(self.context)) == "!1!2<" + } + + it("does not allow continue outside loop") { + let template = Template(templateString: "{% for item in items %}{% endfor %}{% continue %}") + let error = self.expectedSyntaxError( + token: "continue", + template: template, + description: "'continue' can be used only inside loop body" + ) + try expect(template.render(self.context)).toThrow(error) + } + } + + func testContinueNested() { + it("breaks outer loop") { + let template = Template(templateString: """ + {% for item in items %}\ + {% for item in items %}\ + inner: {{ item }}\ + {% endfor %} + {% continue %} + outer: {{ item }} + {% endfor %} + """) + try expect(template.render(self.context)) == """ + inner: 1inner: 2inner: 3 + inner: 1inner: 2inner: 3 + inner: 1inner: 2inner: 3 + + """ + } + + it("breaks inner loop") { + let template = Template(templateString: """ + {% for item in items %}\ + {% for item in items %}\ + {% continue %}\ + inner: {{ item }} + {% endfor %}\ + outer: {{ item }} + {% endfor %} + """) + try expect(template.render(self.context)) == """ + outer: 1 + outer: 2 + outer: 3 + + """ + } + } } // MARK: - Helpers