From 2e18892f4c02406a90cb96b2ebb1a2438c6655da Mon Sep 17 00:00:00 2001 From: David Jennes Date: Sat, 19 May 2018 22:03:51 +0200 Subject: [PATCH 1/3] 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 5f009cbb..0bd445b2 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 00000000..445ef291 --- /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 b3570219..262ccb55 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 3ca28cbc..7b386bc1 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 1934abe8..147be457 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 ~~~~~~~ From 3995ff9acfb25812ca4ad9b835d457e2e1cbe75e Mon Sep 17 00:00:00 2001 From: Theophane RUPIN Date: Sun, 20 May 2018 16:52:22 -0700 Subject: [PATCH 2/3] Added Weaver to the list of projects using Stencil --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 290cccea..75ce68b2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ Resources to help you integrate Stencil into a Swift project: [Sourcery](https://github.com/krzysztofzablocki/Sourcery), [SwiftGen](https://github.com/SwiftGen/SwiftGen), -[Kitura](https://github.com/IBM-Swift/Kitura) +[Kitura](https://github.com/IBM-Swift/Kitura), +[Weaver](https://github.com/scribd/Weaver) ## License From b66abc3112cc14833f1d265e6663b85fd1accb69 Mon Sep 17 00:00:00 2001 From: David Jennes Date: Wed, 11 Jul 2018 23:11:13 +0200 Subject: [PATCH 3/3] Update CHANGELOG.md --- CHANGELOG.md | 79 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd445b2..d84c9b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,7 @@ 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 +- Adds support for using spaces in filter expression. [Ilya Puchka](https://github.com/yonaskolb) [#178](https://github.com/stencilproject/Stencil/pull/178) @@ -27,28 +26,64 @@ ### Enhancements -- Added support for resolving superclass properties for not-NSObject subclasses +- Added support for resolving superclass properties for not-NSObject subclasses. + [Ilya Puchka](https://github.com/ilyapuchka) + [#152](https://github.com/stencilproject/Stencil/pull/152) - The `{% for %}` tag can now iterate over tuples, structures and classes via - their stored properties. -- Added `split` filter -- Allow default string filters to be applied to arrays -- Similar filters are suggested when unknown filter is used -- Added `indent` filter -- Allow using new lines inside tags -- Added support for iterating arrays of tuples -- Added support for ranges in if-in expression -- Added property `forloop.length` to get number of items in the loop -- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count` + their stored properties. + [Ilya Puchka](https://github.com/ilyapuchka) + [#172](https://github.com/stencilproject/Stencil/pull/173) +- Added `split` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#187](https://github.com/stencilproject/Stencil/pull/187) +- Allow default string filters to be applied to arrays. + [Ilya Puchka](https://github.com/ilyapuchka) + [#190](https://github.com/stencilproject/Stencil/pull/190) +- Similar filters are suggested when unknown filter is used. + [Ilya Puchka](https://github.com/ilyapuchka) + [#186](https://github.com/stencilproject/Stencil/pull/186) +- Added `indent` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#188](https://github.com/stencilproject/Stencil/pull/188) +- Allow using new lines inside tags. + [Ilya Puchka](https://github.com/ilyapuchka) + [#202](https://github.com/stencilproject/Stencil/pull/202) +- Added support for iterating arrays of tuples. + [Ilya Puchka](https://github.com/ilyapuchka) + [#177](https://github.com/stencilproject/Stencil/pull/177) +- Added support for ranges in if-in expression. + [Ilya Puchka](https://github.com/ilyapuchka) + [#193](https://github.com/stencilproject/Stencil/pull/193) +- Added property `forloop.length` to get number of items in the loop. + [Ilya Puchka](https://github.com/ilyapuchka) + [#171](https://github.com/stencilproject/Stencil/pull/171) +- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#192](https://github.com/stencilproject/Stencil/pull/192) ### Bug Fixes -- Fixed rendering `{{ block.super }}` with several levels of inheritance -- Fixed checking dictionary values for nil in `default` filter -- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. -- Integer literals now resolve into Int values, not Float -- Fixed accessing properties of optional properties via reflection -- No longer render optional values in arrays as `Optional(..)` -- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}` +- Fixed rendering `{{ block.super }}` with several levels of inheritance. + [Ilya Puchka](https://github.com/ilyapuchka) + [#154](https://github.com/stencilproject/Stencil/pull/154) +- Fixed checking dictionary values for nil in `default` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#162](https://github.com/stencilproject/Stencil/pull/162) +- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. + [Ilya Puchka](https://github.com/ilyapuchka) + [#168](https://github.com/stencilproject/Stencil/pull/168) +- Integer literals now resolve into Int values, not Float. + [Ilya Puchka](https://github.com/ilyapuchka) + [#181](https://github.com/stencilproject/Stencil/pull/181) +- Fixed accessing properties of optional properties via reflection. + [Ilya Puchka](https://github.com/ilyapuchka) + [#204](https://github.com/stencilproject/Stencil/pull/204) +- No longer render optional values in arrays as `Optional(..)`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#205](https://github.com/stencilproject/Stencil/pull/205) +- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#172](https://github.com/stencilproject/Stencil/pull/172) ## 0.10.1 @@ -249,10 +284,10 @@ ### Bug Fixes - Variables (`{{ variable.5 }}`) that reference an array index at an unknown - index will now resolve to `nil` instead of causing a crash. + index will now resolve to `nil` instead of causing a crash. [#72](https://github.com/kylef/Stencil/issues/72) -- Templates can now extend templates that extend other templates. +- Templates can now extend templates that extend other templates. [#60](https://github.com/kylef/Stencil/issues/60) - If comparisons will now treat 0 and below numbers as negative.