From e84f8a41d4fff6ca0311f5add6acd5fe000e7c18 Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Mon, 28 Nov 2016 02:56:04 +0000 Subject: [PATCH] feat(if): Support `and`, `or` and `not` during if expressions Closes #73 --- CHANGELOG.md | 6 + Sources/IfTag.swift | 373 ++++++++++++++++++++++++---- Tests/StencilTests/IfNodeSpec.swift | 247 ++++++++++++------ docs/builtins.rst | 45 ++++ 4 files changed, 547 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa8dc4..29e5716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ ### Enhancements +- If tags can now use prefix and infix operators such as `not`, `and` and `or`. + + ```html+django + {% if one or two and not three %} + ``` + - You may now register custom template filters which make use of arguments. - There is now a `default` filter. diff --git a/Sources/IfTag.swift b/Sources/IfTag.swift index 1084936..91b8753 100644 --- a/Sources/IfTag.swift +++ b/Sources/IfTag.swift @@ -1,61 +1,48 @@ -class IfNode : NodeType { - let variable:Variable - let trueNodes:[NodeType] - let falseNodes:[NodeType] +protocol Expression: CustomStringConvertible { + func evaluate(context: Context) throws -> Bool +} - class func parse(_ parser:TokenParser, token:Token) throws -> NodeType { - let components = token.components() - guard components.count == 2 else { - throw TemplateSyntaxError("'if' statements should use the following 'if condition' `\(token.contents)`.") - } - let variable = components[1] - var trueNodes = [NodeType]() - var falseNodes = [NodeType]() - trueNodes = try parser.parse(until(["endif", "else"])) +protocol InfixOperator: Expression { + init(lhs: Expression, rhs: Expression) +} - guard let token = parser.nextToken() else { - throw TemplateSyntaxError("`endif` was not found.") - } - if token.contents == "else" { - falseNodes = try parser.parse(until(["endif"])) - _ = parser.nextToken() - } +protocol PrefixOperator: Expression { + init(expression: Expression) +} - return IfNode(variable: variable, trueNodes: trueNodes, falseNodes: falseNodes) + +final class StaticExpression: Expression, CustomStringConvertible { + let value: Bool + + init(value: Bool) { + self.value = value } - class func parse_ifnot(_ parser:TokenParser, token:Token) throws -> NodeType { - let components = token.components() - guard components.count == 2 else { - throw TemplateSyntaxError("'ifnot' statements should use the following 'ifnot condition' `\(token.contents)`.") - } - let variable = components[1] - var trueNodes = [NodeType]() - var falseNodes = [NodeType]() - - falseNodes = try parser.parse(until(["endif", "else"])) - - guard let token = parser.nextToken() else { - throw TemplateSyntaxError("`endif` was not found.") - } - - if token.contents == "else" { - trueNodes = try parser.parse(until(["endif"])) - _ = parser.nextToken() - } - - return IfNode(variable: variable, trueNodes: trueNodes, falseNodes: falseNodes) + func evaluate(context: Context) throws -> Bool { + return value } - init(variable:String, trueNodes:[NodeType], falseNodes:[NodeType]) { - self.variable = Variable(variable) - self.trueNodes = trueNodes - self.falseNodes = falseNodes + var description: String { + return "\(value)" + } +} + + +final class VariableExpression: Expression, CustomStringConvertible { + let variable: Variable + + init(variable: Variable) { + self.variable = variable } - func render(_ context: Context) throws -> String { + var description: String { + return "(variable: \(variable.variable))" + } + + /// Resolves a variable in the given context as boolean + func resolve(context: Context, variable: Variable) throws -> Bool { let result = try variable.resolve(context) var truthy = false @@ -75,6 +62,298 @@ class IfNode : NodeType { truthy = true } + return truthy + } + + func evaluate(context: Context) throws -> Bool { + return try resolve(context: context, variable: variable) + } +} + + +final class NotExpression: Expression, PrefixOperator, CustomStringConvertible { + let expression: Expression + + init(expression: Expression) { + self.expression = expression + } + + var description: String { + return "not \(expression)" + } + + func evaluate(context: Context) throws -> Bool { + return try !expression.evaluate(context: context) + } +} + + +final class OrExpression: Expression, InfixOperator, CustomStringConvertible { + let lhs: Expression + let rhs: Expression + + init(lhs: Expression, rhs: Expression) { + self.lhs = lhs + self.rhs = rhs + } + + var description: String { + return "(\(lhs) or \(rhs))" + } + + func evaluate(context: Context) throws -> Bool { + let lhs = try self.lhs.evaluate(context: context) + if lhs { + return lhs + } + + return try rhs.evaluate(context: context) + } +} + + +final class AndExpression: Expression, InfixOperator, CustomStringConvertible { + let lhs: Expression + let rhs: Expression + + init(lhs: Expression, rhs: Expression) { + self.lhs = lhs + self.rhs = rhs + } + + var description: String { + return "(\(lhs) and \(rhs))" + } + + func evaluate(context: Context) throws -> Bool { + let lhs = try self.lhs.evaluate(context: context) + if !lhs { + return lhs + } + + return try rhs.evaluate(context: context) + } +} + + +enum Operator { + case infix(String, Int, InfixOperator.Type) + case prefix(String, Int, PrefixOperator.Type) + + var name: String { + switch self { + case .infix(let name, _, _): + return name + case .prefix(let name, _, _): + return name + } + } +} + + +let operators: [Operator] = [ + .infix("or", 6, OrExpression.self), + .infix("and", 7, AndExpression.self), + .prefix("not", 8, NotExpression.self), +] + + +func findOperator(name: String) -> Operator? { + for op in operators { + if op.name == name { + return op + } + } + + return nil +} + + +enum IfToken { + case infix(name: String, bindingPower: Int, op: InfixOperator.Type) + case prefix(name: String, bindingPower: Int, op: PrefixOperator.Type) + case variable(Variable) + case end + + var bindingPower: Int { + switch self { + case .infix(_, let bindingPower, _): + return bindingPower + case .prefix(_, let bindingPower, _): + return bindingPower + case .variable(_): + return 0 + case .end: + return 0 + } + } + + func nullDenotation(parser: IfExpressionParser) throws -> Expression { + switch self { + case .infix(let name, _, _): + throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side") + case .prefix(_, let bindingPower, let op): + let expression = try parser.expression(bindingPower: bindingPower) + return op.init(expression: expression) + case .variable(let variable): + return VariableExpression(variable: variable) + case .end: + throw TemplateSyntaxError("'if' expression error: end") + } + } + + func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression { + switch self { + case .infix(_, let bindingPower, let op): + let right = try parser.expression(bindingPower: bindingPower) + return op.init(lhs: left, rhs: right) + case .prefix(let name, _, _): + throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side") + case .variable(let variable): + throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side") + case .end: + throw TemplateSyntaxError("'if' expression error: end") + } + } + + var isEnd: Bool { + switch self { + case .end: + return true + default: + return false + } + } +} + + +final class IfExpressionParser { + let tokens: [IfToken] + var position: Int = 0 + + init(components: [String]) { + self.tokens = components.map { component in + if let op = findOperator(name: component) { + switch op { + case .infix(let name, let bindingPower, let cls): + return .infix(name: name, bindingPower: bindingPower, op: cls) + case .prefix(let name, let bindingPower, let cls): + return .prefix(name: name, bindingPower: bindingPower, op: cls) + } + } + + return .variable(Variable(component)) + } + } + + var currentToken: IfToken { + if tokens.count > position { + return tokens[position] + } + + return .end + } + + var nextToken: IfToken { + position += 1 + return currentToken + } + + func parse() throws -> Expression { + let expression = try self.expression() + + if !currentToken.isEnd { + throw TemplateSyntaxError("'if' expression error: dangling token") + } + + return expression + } + + func expression(bindingPower: Int = 0) throws -> Expression { + var token = currentToken + position += 1 + + var left = try token.nullDenotation(parser: self) + + while bindingPower < currentToken.bindingPower { + token = currentToken + position += 1 + left = try token.leftDenotation(left: left, parser: self) + } + + return left + } +} + + +func parseExpression(components: [String]) throws -> Expression { + let parser = IfExpressionParser(components: components) + return try parser.parse() +} + + +class IfNode : NodeType { + let expression: Expression + let trueNodes: [NodeType] + let falseNodes: [NodeType] + + class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { + var components = token.components() + guard components.count == 2 else { + throw TemplateSyntaxError("'if' statements should use the following 'if condition' `\(token.contents)`.") + } + components.removeFirst() + var trueNodes = [NodeType]() + var falseNodes = [NodeType]() + + trueNodes = try parser.parse(until(["endif", "else"])) + + guard let token = parser.nextToken() else { + throw TemplateSyntaxError("`endif` was not found.") + } + + if token.contents == "else" { + falseNodes = try parser.parse(until(["endif"])) + _ = parser.nextToken() + } + + let expression = try parseExpression(components: components) + return IfNode(expression: expression, trueNodes: trueNodes, falseNodes: falseNodes) + } + + class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType { + var components = token.components() + guard components.count == 2 else { + throw TemplateSyntaxError("'ifnot' statements should use the following 'ifnot condition' `\(token.contents)`.") + } + components.removeFirst() + var trueNodes = [NodeType]() + var falseNodes = [NodeType]() + + falseNodes = try parser.parse(until(["endif", "else"])) + + guard let token = parser.nextToken() else { + throw TemplateSyntaxError("`endif` was not found.") + } + + if token.contents == "else" { + trueNodes = try parser.parse(until(["endif"])) + _ = parser.nextToken() + } + + let expression = try parseExpression(components: components) + return IfNode(expression: expression, trueNodes: trueNodes, falseNodes: falseNodes) + } + + init(expression: Expression, trueNodes: [NodeType], falseNodes: [NodeType]) { + self.expression = expression + self.trueNodes = trueNodes + self.falseNodes = falseNodes + } + + func render(_ context: Context) throws -> String { + let truthy = try expression.evaluate(context: context) + return try context.push { if truthy { return try renderNodes(trueNodes, context) diff --git a/Tests/StencilTests/IfNodeSpec.swift b/Tests/StencilTests/IfNodeSpec.swift index c048864..ca3dc45 100644 --- a/Tests/StencilTests/IfNodeSpec.swift +++ b/Tests/StencilTests/IfNodeSpec.swift @@ -3,6 +3,172 @@ import Spectre func testIfNode() { + describe("Expression") { + $0.describe("VariableExpression") { + let expression = VariableExpression(variable: Variable("value")) + + $0.it("evaluates to true when value is not nil") { + let context = Context(dictionary: ["value": "known"]) + try expect(try expression.evaluate(context: context)).to.beTrue() + } + + $0.it("evaluates to false when value is unset") { + let context = Context() + try expect(try expression.evaluate(context: context)).to.beFalse() + } + + $0.it("evaluates to true when array variable is not empty") { + let items: [[String: Any]] = [["key":"key1","value":42],["key":"key2","value":1337]] + let context = Context(dictionary: ["value": [items]]) + try expect(try expression.evaluate(context: context)).to.beTrue() + } + + $0.it("evaluates to false when array value is empty") { + let emptyItems = [[String: Any]]() + let context = Context(dictionary: ["value": emptyItems]) + try expect(try expression.evaluate(context: context)).to.beFalse() + } + + $0.it("evaluates to false when dictionary value is empty") { + let emptyItems = [String:Any]() + let context = Context(dictionary: ["value": emptyItems]) + try expect(try expression.evaluate(context: context)).to.beFalse() + } + + $0.it("evaluates to false when Array value is empty") { + let context = Context(dictionary: ["value": ([] as [Any])]) + try expect(try expression.evaluate(context: context)).to.beFalse() + } + + $0.it("evaluates to true when integer value is above 0") { + let context = Context(dictionary: ["value": 1]) + try expect(try expression.evaluate(context: context)).to.beTrue() + } + + $0.it("evaluates to false when integer value is below 0 or below") { + let context = Context(dictionary: ["value": 0]) + try expect(try expression.evaluate(context: context)).to.beFalse() + + let negativeContext = Context(dictionary: ["value": 0]) + try expect(try expression.evaluate(context: negativeContext)).to.beFalse() + } + + $0.it("evaluates to true when float value is above 0") { + let context = Context(dictionary: ["value": Float(0.5)]) + try expect(try expression.evaluate(context: context)).to.beTrue() + } + + $0.it("evaluates to false when float is 0 or below") { + let context = Context(dictionary: ["value": Float(0)]) + try expect(try expression.evaluate(context: context)).to.beFalse() + } + + $0.it("evaluates to true when double value is above 0") { + let context = Context(dictionary: ["value": Double(0.5)]) + try expect(try expression.evaluate(context: context)).to.beTrue() + } + + $0.it("evaluates to false when double is 0 or below") { + let context = Context(dictionary: ["value": Double(0)]) + try expect(try expression.evaluate(context: context)).to.beFalse() + } + } + + $0.describe("NotExpression") { + $0.it("returns truthy for positive expressions") { + let expression = NotExpression(expression: StaticExpression(value: true)) + try expect(expression.evaluate(context: Context())).to.beFalse() + } + + $0.it("returns falsy for negative expressions") { + let expression = NotExpression(expression: StaticExpression(value: false)) + try expect(expression.evaluate(context: Context())).to.beTrue() + } + } + + $0.describe("expression parsing") { + $0.it("can parse a variable expression") { + let expression = try parseExpression(components: ["value"]) + try expect(expression.evaluate(context: Context())).to.beFalse() + try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue() + } + + $0.it("can parse a not expression") { + let expression = try parseExpression(components: ["not", "value"]) + try expect(expression.evaluate(context: Context())).to.beTrue() + try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse() + } + + $0.describe("and expression") { + let expression = try! parseExpression(components: ["lhs", "and", "rhs"]) + + $0.it("evaluates to false with lhs false") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse() + } + + $0.it("evaluates to false with rhs false") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse() + } + + $0.it("evaluates to false with lhs and rhs false") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse() + } + + $0.it("evaluates to true with lhs and rhs true") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue() + } + } + + $0.describe("or expression") { + let expression = try! parseExpression(components: ["lhs", "or", "rhs"]) + + $0.it("evaluates to true with lhs true") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue() + } + + $0.it("evaluates to true with rhs true") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beTrue() + } + + $0.it("evaluates to true with lhs and rhs true") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue() + } + + $0.it("evaluates to false with lhs and rhs false") { + try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse() + } + } + + $0.describe("multiple expression") { + let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"]) + + $0.it("evaluates to true with one") { + try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue() + } + + $0.it("evaluates to true with one and three") { + try expect(expression.evaluate(context: Context(dictionary: ["one": true, "three": true]))).to.beTrue() + } + + $0.it("evaluates to true with two") { + try expect(expression.evaluate(context: Context(dictionary: ["two": true]))).to.beTrue() + } + + $0.it("evaluates to false with two and three") { + try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse() + } + + $0.it("evaluates to false with two and three") { + try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse() + } + + $0.it("evaluates to false with nothing") { + try expect(expression.evaluate(context: Context())).to.beFalse() + } + } + } + } + describe("IfNode") { $0.describe("parsing") { $0.it("can parse an if block") { @@ -21,7 +187,6 @@ func testIfNode() { let falseNode = node?.falseNodes.first as? TextNode try expect(nodes.count) == 1 - try expect(node?.variable.variable) == "value" try expect(node?.trueNodes.count) == 1 try expect(trueNode?.text) == "true" try expect(node?.falseNodes.count) == 1 @@ -44,7 +209,6 @@ func testIfNode() { let falseNode = node?.falseNodes.first as? TextNode try expect(nodes.count) == 1 - try expect(node?.variable.variable) == "value" try expect(node?.trueNodes.count) == 1 try expect(trueNode?.text) == "true" try expect(node?.falseNodes.count) == 1 @@ -74,84 +238,13 @@ func testIfNode() { $0.describe("rendering") { $0.it("renders the truth when expression evaluates to true") { - let context = Context(dictionary: ["items": true]) - let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(context)) == "true" + let node = IfNode(expression: StaticExpression(value: true), trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) + try expect(try node.render(Context())) == "true" } $0.it("renders the false when expression evaluates to false") { - let context = Context(dictionary: ["items": false]) - let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(context)) == "false" - } - - $0.it("renders the truth when expression is not nil") { - let context = Context(dictionary: ["known": "known"]) - let node = IfNode(variable: "known", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(context)) == "true" - } - - $0.it("renders the false when expression is nil") { - let context = Context(dictionary: [:]) - let node = IfNode(variable: "unknown", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(context)) == "false" - } - - $0.it("renders the truth when array expression is not empty") { - let items: [[String: Any]] = [["key":"key1","value":42],["key":"key2","value":1337]] - let arrayContext = Context(dictionary: ["items": [items]]) - let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(arrayContext)) == "true" - } - - $0.it("renders the false when array expression is empty") { - let emptyItems = [[String: Any]]() - let arrayContext = Context(dictionary: ["items": emptyItems]) - let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(arrayContext)) == "false" - } - - $0.it("renders the false when dictionary expression is empty") { - let emptyItems = [String:Any]() - let arrayContext = Context(dictionary: ["items": emptyItems]) - let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(arrayContext)) == "false" - } - - $0.it("renders the false when Array variable is empty") { - let arrayContext = Context(dictionary: ["items": ([] as [Any])]) - let node = IfNode(variable: "items", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(arrayContext)) == "false" - } - - $0.it("renders the false when integer is below 1") { - let context = Context(dictionary: ["value": 0]) - let node = IfNode(variable: "value", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(context)) == "false" - - let negativeContext = Context(dictionary: ["value": -5]) - let negativeNode = IfNode(variable: "value", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try negativeNode.render(negativeContext)) == "false" - } - - $0.it("renders the false when float is below 1") { - let context = Context(dictionary: ["value": Float(0)]) - let node = IfNode(variable: "value", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(context)) == "false" - - let negativeContext = Context(dictionary: ["value": Float(-5)]) - let negativeNode = IfNode(variable: "value", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try negativeNode.render(negativeContext)) == "false" - } - - $0.it("renders the false when double is below 1") { - let context = Context(dictionary: ["value": Double(0)]) - let node = IfNode(variable: "value", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try node.render(context)) == "false" - - let negativeContext = Context(dictionary: ["value": Double(-5)]) - let negativeNode = IfNode(variable: "value", trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) - try expect(try negativeNode.render(negativeContext)) == "false" + let node = IfNode(expression: StaticExpression(value: false), trueNodes: [TextNode(text: "true")], falseNodes: [TextNode(text: "false")]) + try expect(try node.render(Context())) == "false" } } } diff --git a/docs/builtins.rst b/docs/builtins.rst index dab8a41..f2afb3b 100644 --- a/docs/builtins.rst +++ b/docs/builtins.rst @@ -41,6 +41,14 @@ The for block sets a few variables available within the loop: ``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: + +* Present in the context +* Being non-empty (dictionaries or arrays) +* Not being a false boolean value +* Not being a numerical value of 0 or below + .. code-block:: html+django {% if variable %} @@ -49,6 +57,43 @@ The for block sets a few variables available within the loop: The variable was not found. {% endif %} +Operators +^^^^^^^^^ + +``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables +or to negate a variable. + +.. code-block:: html+django + + {% if one and two %} + Both one and two evaluate to true. + {% endif %} + + {% if not one %} + One evaluates to false + {% endif %} + + {% if one or two %} + Either one or two evaluates to true. + {% endif %} + + {% if not one or two %} + One does not evaluate to false or two evaluates to true. + {% endif %} + +You may use ``and``, ``or`` and ``not`` multiple times together. ``not`` has +higest prescidence followed by ``and``. For example: + +.. code-block:: html+django + + {% if one or two and three %} + +Will be treated as: + +.. code-block:: text + + one or (two and three) + ``ifnot`` ~~~~~~~~~