Merge pull request #175 from stencilproject/break-continue

Break, continue and loops' labels
This commit is contained in:
David Jennes
2022-07-29 00:02:10 +02:00
committed by GitHub
8 changed files with 479 additions and 48 deletions

View File

@@ -30,6 +30,12 @@
[Ilya Puchka](https://github.com/ilyapuchka)
[#158](https://github.com/stencilproject/Stencil/issues/158)
[#182](https://github.com/stencilproject/Stencil/pull/182)
- Added `break` and `continue` tags to break or continue current loop.
[Ilya Puchka](https://github.com/ilyapuchka)
[#175](https://github.com/stencilproject/Stencil/pull/175)
- You can now access outer loop's scope by labeling it: `{% outer: for ... %}... {% for ... %} {{ outer.counter }} {% endfor %}{% endfor %}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#175](https://github.com/stencilproject/Stencil/pull/175)
### Deprecations

View File

@@ -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)

View File

@@ -6,10 +6,16 @@ class ForNode: NodeType {
let nodes: [NodeType]
let emptyNodes: [NodeType]
let `where`: Expression?
let label: String?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let components = token.components
var components = token.components
var label: String?
if components.first?.hasSuffix(":") == true {
label = String(components.removeFirst().dropLast())
}
func hasToken(_ token: String, at index: Int) -> Bool {
components.count > (index + 1) && components[index] == token
@@ -52,6 +58,7 @@ class ForNode: NodeType {
nodes: forNodes,
emptyNodes: emptyNodes,
where: `where`,
label: label,
token: token
)
}
@@ -62,6 +69,7 @@ class ForNode: NodeType {
nodes: [NodeType],
emptyNodes: [NodeType],
where: Expression? = nil,
label: String? = nil,
token: Token? = nil
) {
self.resolvable = resolvable
@@ -69,6 +77,7 @@ class ForNode: NodeType {
self.nodes = nodes
self.emptyNodes = emptyNodes
self.where = `where`
self.label = label
self.token = token
}
@@ -85,28 +94,51 @@ 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
]
// collect parent loop contexts
let parentLoopContexts = (context["forloop"] as? [String: Any])?
.filter { ($1 as? [String: Any])?["label"] != nil } ?? [:]
return try context.push(dictionary: ["forloop": forContext]) {
try push(value: item, context: context) {
try renderNodes(nodes, context)
for (index, item) in zip(0..., values) {
var forContext: [String: Any] = [
"first": index == 0,
"last": index == (count - 1),
"counter": index + 1,
"counter0": index,
"length": count
]
if let label = label {
forContext["label"] = label
forContext[label] = forContext
}
forContext.merge(parentLoopContexts) { lhs, _ in lhs }
var shouldBreak = false
result += try context.push(dictionary: ["forloop": forContext]) {
defer {
// if outer loop should be continued we should break from current loop
if let shouldContinueLabel = context[LoopTerminationNode.continueContextKey] as? String {
shouldBreak = shouldContinueLabel != label || label == nil
} else {
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 +206,69 @@ class ForNode: NodeType {
return values
}
}
struct LoopTerminationNode: NodeType {
static let breakContextKey = "_internal_forloop_break"
static let continueContextKey = "_internal_forloop_continue"
let name: String
let label: String?
let token: Token?
var contextKey: String {
"_internal_forloop_\(name)"
}
private init(name: String, label: String? = nil, token: Token? = nil) {
self.name = name
self.label = label
self.token = token
}
static func parse(_ parser: TokenParser, token: Token) throws -> LoopTerminationNode {
let components = token.components
guard components.count <= 2 else {
throw TemplateSyntaxError("'\(token.contents)' can accept only one parameter")
}
guard parser.hasOpenedForTag() else {
throw TemplateSyntaxError("'\(token.contents)' can be used only inside loop body")
}
return LoopTerminationNode(name: components[0], label: components.count == 2 ? components[1] : nil, token: token)
}
func render(_ context: Context) throws -> String {
let offset = zip(0..., context.dictionaries).reversed().first { _, dictionary in
guard let forContext = dictionary["forloop"] as? [String: Any],
dictionary["forloop"] != nil else { return false }
if let label = label {
return label == forContext["label"] as? String
} else {
return true
}
}?.0
if let offset = offset {
context.dictionaries[offset][contextKey] = label ?? true
} else if let label = label {
throw TemplateSyntaxError("No loop labeled '\(label)' is currently running")
} else {
throw TemplateSyntaxError("No loop is currently running")
}
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
}
}

View File

@@ -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

View File

@@ -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?
@@ -53,8 +54,13 @@ public class TokenParser {
return nodes
}
if let tag = token.components.first {
if var tag = token.components.first {
do {
// special case for labeled tags (such as for loops)
if tag.hasSuffix(":") && token.components.count >= 2 {
tag = token.components[1]
}
let parser = try environment.findTag(name: tag)
let node = try parser(self, token)
nodes.append(node)
@@ -74,7 +80,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 +95,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

View File

@@ -45,7 +45,12 @@ extension String {
if !components.isEmpty {
if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
components[components.count - 1] += word
// special case for labeled for-loops
if components.count == 1 && word == "for" {
components.append(word)
} else {
components[components.count - 1] += word
}
} else if specialCharacters.contains(word) {
components[components.count - 1] += word
} else if word != "(" && word.first == "(" || word != ")" && word.first == ")" {

View File

@@ -312,6 +312,253 @@ 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 testBreakLabeled() {
it("breaks labeled loop") {
let template = Template(templateString: """
{% outer: for item in items %}\
outer: {{ item }}
{% for item in items %}\
{% break outer %}\
inner: {{ item }}
{% endfor %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
"""
}
it("throws when breaking with unknown label") {
let template = Template(templateString: """
{% outer: for item in items %}
{% break inner %}
{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
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
"""
}
}
func testContinueLabeled() {
it("continues labeled loop") {
let template = Template(templateString: """
{% outer: for item in items %}\
{% for item in items %}\
inner: {{ item }}
{% continue outer %}\
{% endfor %}\
outer: {{ item }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
inner: 1
inner: 1
inner: 1
"""
}
it("throws when continuing with unknown label") {
let template = Template(templateString: """
{% outer: for item in items %}
{% continue inner %}
{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
func testAccessLabeled() {
it("can access labeled outer loop context from inner loop") {
let template = Template(templateString: """
{% outer: for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer.counter }},\
{% endfor %}---\
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1,2-1,3-1,---1-2,2-2,3-2,---
"""
}
it("can access labeled outer loop from double inner loop") {
let template = Template(templateString: """
{% outer: for item in 1...2 %}{% for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer.counter }},\
{% endfor %}---{% endfor %}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1,2-1,3-1,---1-1,2-1,3-1,---
1-2,2-2,3-2,---1-2,2-2,3-2,---
"""
}
it("can access two labeled outer loop contexts from inner loop") {
let template = Template(templateString: """
{% outer1: for item in 1...2 %}{% outer2: for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer2.counter }}-{{ forloop.outer1.counter }},\
{% endfor %}---{% endfor %}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1-1,2-1-1,3-1-1,---1-2-1,2-2-1,3-2-1,---
1-1-2,2-1-2,3-1-2,---1-2-2,2-2-2,3-2-2,---
"""
}
}
}
// MARK: - Helpers

View File

@@ -41,8 +41,7 @@ You can iterate over range literals created using ``N...M`` syntax, both in asce
{% endfor %}
</ul>
The ``for`` tag can contain optional ``where`` expression to filter out
elements on which this expression evaluates to false.
The ``for`` tag can contain optional ``where`` expression to filter out elements on which this expression evaluates to false.
.. code-block:: html+django
@@ -52,8 +51,7 @@ elements on which this expression evaluates to false.
{% endfor %}
</ul>
The ``for`` tag can take an optional ``{% empty %}`` block that will be
displayed if the given list is empty or could not be found.
The ``for`` tag can take an optional ``{% empty %}`` block that will be displayed if the given list is empty or could not be found.
.. code-block:: html+django
@@ -89,12 +87,74 @@ For example:
This is user number {{ forloop.counter }} user.
{% endfor %}
The ``for`` tag accepts an optional label, so that it may later be referred to by name. The contexts of parent labeled loops can be accessed via the `forloop` property:
.. code-block:: html+django
{% outer: for item in users %}
{% for item in 1..3 %}
{% if forloop.outer.first %}
This is the first user.
{% endif %}
{% endfor %}
{% endfor %}
``break``
~~~~~~~~~
The ``break`` tag lets you jump out of a for loop, for example if a certain condition is met:
.. code-block:: html+django
{% for user in users %}
{% if user.inaccessible %}
{% break %}
{% endif %}
This is user {{ user.name }}.
{% endfor %}
Break tags accept an optional label parameter, so that you may break out of multiple loops:
.. code-block:: html+django
{% outer: for user in users %}
{% for address in user.addresses %}
{% if address.isInvalid %}
{% break outer %}
{% endif %}
{% endfor %}
{% endfor %}
``continue``
~~~~~~~~~
The ``continue`` tag lets you skip the rest of the blocks in a loop, for example if a certain condition is met:
.. code-block:: html+django
{% for user in users %}
{% if user.inaccessible %}
{% continue %}
{% endif %}
This is user {{ user.name }}.
{% endfor %}
Continue tags accept an optional label parameter, so that you may skip the execution of multiple loops:
.. code-block:: html+django
{% outer: for user in users %}
{% for address in user.addresses %}
{% if address.isInvalid %}
{% continue outer %}
{% endif %}
{% endfor %}
{% endfor %}
``if``
~~~~~~
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to
true the contents of the block are processed. Being true is defined as:
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to true the contents of the block are processed. Being true is defined as:
* Present in the context
* Being non-empty (dictionaries or arrays)
@@ -115,8 +175,7 @@ true the contents of the block are processed. Being true is defined as:
Operators
^^^^^^^^^
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables
or to negate a variable.
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables or to negate a variable.
.. code-block:: html+django
@@ -279,8 +338,7 @@ By default the included file gets passed the current context. You can pass a sub
{% include "comment.html" comment %}
The `include` tag requires you to provide a loader which will be used to lookup
the template.
The `include` tag requires you to provide a loader which will be used to lookup the template.
.. code-block:: swift
@@ -301,8 +359,7 @@ See :ref:`template-inheritance` for more information.
``block``
~~~~~~~~~
Defines a block that can be overridden by child templates. See
:ref:`template-inheritance` for more information.
Defines a block that can be overridden by child templates. See :ref:`template-inheritance` for more information.
.. _built-in-filters:
@@ -312,8 +369,7 @@ Built-in Filters
``capitalize``
~~~~~~~~~~~~~~
The capitalize filter allows you to capitalize a string.
For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
The capitalize filter allows you to capitalize a string. For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -322,8 +378,7 @@ For example, `stencil` to `Stencil`. Can be applied to array of strings to chang
``uppercase``
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to uppercase.
For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
The uppercase filter allows you to transform a string to uppercase. For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -332,8 +387,7 @@ For example, `Stencil` to `STENCIL`. Can be applied to array of strings to chang
``lowercase``
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to lowercase.
For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
The uppercase filter allows you to transform a string to lowercase. For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -342,8 +396,7 @@ For example, `Stencil` to `stencil`. Can be applied to array of strings to chang
``default``
~~~~~~~~~~~
If a variable not present in the context, use given default. Otherwise, use the
value of the variable. For example:
If a variable not present in the context, use given default. Otherwise, use the value of the variable. For example:
.. code-block:: html+django