From 2e18892f4c02406a90cb96b2ebb1a2438c6655da Mon Sep 17 00:00:00 2001 From: David Jennes Date: Sat, 19 May 2018 22:03:51 +0200 Subject: [PATCH] Subscript syntax for Variables (#215) * Implement variable indirect resolution * Add some tests * Changelog entry * Update documentation * Rework the syntax to use brackets instead of a $ * Move the lookup parser into it's own file * Add invalid syntax tests * Swift 3 support * Rename some things + extra test --- CHANGELOG.md | 10 ++- Sources/KeyPath.swift | 112 ++++++++++++++++++++++++++ Sources/Variable.swift | 8 +- Tests/StencilTests/VariableSpec.swift | 92 +++++++++++++++++++++ docs/templates.rst | 18 +++++ 5 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 Sources/KeyPath.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f009cb..0bd445b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ - Added an optional second parameter to the `include` tag for passing a sub context to the included file. [Yonas Kolb](https://github.com/yonaskolb) - [#394](https://github.com/stencilproject/Stencil/pull/214) + [#214](https://github.com/stencilproject/Stencil/pull/214) +- Variables now support the subscript notation. For example, if you have a variable `key = "name"`, and an + object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John". + [David Jennes](https://github.com/djbe) + [#215](https://github.com/stencilproject/Stencil/pull/215) - Adds support for using spaces in filter expression [Ilya Puchka](https://github.com/yonaskolb) @@ -14,8 +18,8 @@ ### Bug Fixes -- Fixed using quote as a filter parameter - [Ilya Puchka](https://github.com/yonaskolb) +- Fixed using quote as a filter parameter. + [Ilya Puchka](https://github.com/ilyapuchka) [#210](https://github.com/stencilproject/Stencil/pull/210) diff --git a/Sources/KeyPath.swift b/Sources/KeyPath.swift new file mode 100644 index 0000000..445ef29 --- /dev/null +++ b/Sources/KeyPath.swift @@ -0,0 +1,112 @@ +import Foundation + +/// A structure used to represent a template variable, and to resolve it in a given context. +final class KeyPath { + private var components = [String]() + private var current = "" + private var partialComponents = [String]() + private var subscriptLevel = 0 + + let variable: String + let context: Context + + // Split the keypath string and resolve references if possible + init(_ variable: String, in context: Context) { + self.variable = variable + self.context = context + } + + func parse() throws -> [String] { + defer { + components = [] + current = "" + partialComponents = [] + subscriptLevel = 0 + } + + for c in variable.characters { + switch c { + case "." where subscriptLevel == 0: + try foundSeparator() + case "[": + try openBracket() + case "]": + try closeBracket() + default: + try addCharacter(c) + } + } + try finish() + + return components + } + + private func foundSeparator() throws { + if !current.isEmpty { + partialComponents.append(current) + } + + guard !partialComponents.isEmpty else { + throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'") + } + + components += partialComponents + current = "" + partialComponents = [] + } + + // when opening the first bracket, we must have a partial component + private func openBracket() throws { + guard !partialComponents.isEmpty || !current.isEmpty else { + throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'") + } + + if subscriptLevel > 0 { + current.append("[") + } else if !current.isEmpty { + partialComponents.append(current) + current = "" + } + + subscriptLevel += 1 + } + + // for a closing bracket at root level, try to resolve the reference + private func closeBracket() throws { + guard subscriptLevel > 0 else { + throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'") + } + + if subscriptLevel > 1 { + current.append("]") + } else if !current.isEmpty, + let value = try Variable(current).resolve(context) { + partialComponents.append("\(value)") + current = "" + } else { + throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'") + } + + subscriptLevel -= 1 + } + + private func addCharacter(_ c: Character) throws { + guard partialComponents.isEmpty || subscriptLevel > 0 else { + throw TemplateSyntaxError("Unexpected character '\(c)' in variable '\(variable)'") + } + + current.append(c) + } + + private func finish() throws { + // check if we have a last piece + if !current.isEmpty { + partialComponents.append(current) + } + components += partialComponents + + guard subscriptLevel == 0 else { + throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'") + } + } +} diff --git a/Sources/Variable.swift b/Sources/Variable.swift index b357021..262ccb5 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -50,8 +50,10 @@ public struct Variable : Equatable, Resolvable { self.variable = variable } - fileprivate func lookup() -> [String] { - return variable.characters.split(separator: ".").map(String.init) + // Split the lookup string and resolve references if possible + fileprivate func lookup(_ context: Context) throws -> [String] { + var keyPath = KeyPath(variable, in: context) + return try keyPath.parse() } /// Resolve the variable in the given context @@ -75,7 +77,7 @@ public struct Variable : Equatable, Resolvable { return bool } - for bit in lookup() { + for bit in try lookup(context) { current = normalize(current) if let context = current as? Context { diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 3ca28cb..7b386bc 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -188,6 +188,98 @@ func testVariable() { let result = try variable.resolve(context) as? Int try expect(result) == 2 } + + $0.describe("Subrscripting") { + $0.it("can resolve a property subscript via reflection") { + try context.push(dictionary: ["property": "name"]) { + let variable = Variable("article.author[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("can subscript an array with a valid index") { + try context.push(dictionary: ["property": 0]) { + let variable = Variable("contacts[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Katie" + } + } + + $0.it("can subscript an array with an unknown index") { + try context.push(dictionary: ["property": 5]) { + let variable = Variable("contacts[property]") + let result = try variable.resolve(context) as? String + try expect(result).to.beNil() + } + } + +#if os(OSX) + $0.it("can resolve a subscript via KVO") { + try context.push(dictionary: ["property": "name"]) { + let variable = Variable("object[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Foo" + } + } +#endif + + $0.it("can resolve an optional subscript via reflection") { + try context.push(dictionary: ["property": "featuring"]) { + let variable = Variable("blog[property].author.name") + let result = try variable.resolve(context) as? String + try expect(result) == "Jhon" + } + } + + $0.it("can resolve multiple subscripts") { + try context.push(dictionary: [ + "prop1": "articles", + "prop2": 0, + "prop3": "name" + ]) { + let variable = Variable("blog[prop1][prop2].author[prop3]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("can resolve nested subscripts") { + try context.push(dictionary: [ + "prop1": "prop2", + "ref": ["prop2": "name"] + ]) { + let variable = Variable("article.author[ref[prop1]]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("throws for invalid keypath syntax") { + try context.push(dictionary: ["prop": "name"]) { + let samples = [ + ".", + "..", + ".test", + "test..test", + "[prop]", + "article.author[prop", + "article.author[[prop]", + "article.author[prop]]", + "article.author[]", + "article.author[[]]", + "article.author[prop][]", + "article.author[prop]comments", + "article.author[.]" + ] + + for lookup in samples { + let variable = Variable(lookup) + try expect(variable.resolve(context)).toThrow() + } + } + } + } } describe("RangeVariable") { diff --git a/docs/templates.rst b/docs/templates.rst index 1934abe..147be45 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -31,6 +31,24 @@ For example, if `people` was an array: There are {{ people.count }} people. {{ people.first }} is the first person, followed by {{ people.1 }}. +You can also use the subscript operator for indirect evaluation. The expression +between brackets will be evaluated first, before the actual lookup will happen. + +For example, if you have the following context: + +.. code-block:: swift + + [ + "item": [ + "name": "John" + ], + "key": "name" + ] + +.. code-block:: html+django + + The result of {{ item[key] }} will be the same as {{ item.name }}. It will first evaluate the result of {{ key }}, and only then evaluate the lookup expression. + Filters ~~~~~~~