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
This commit is contained in:
committed by
Ilya Puchka
parent
39ed9aa753
commit
2e18892f4c
10
CHANGELOG.md
10
CHANGELOG.md
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
- Added an optional second parameter to the `include` tag for passing a sub context to the included file.
|
- Added an optional second parameter to the `include` tag for passing a sub context to the included file.
|
||||||
[Yonas Kolb](https://github.com/yonaskolb)
|
[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
|
- Adds support for using spaces in filter expression
|
||||||
[Ilya Puchka](https://github.com/yonaskolb)
|
[Ilya Puchka](https://github.com/yonaskolb)
|
||||||
@@ -14,8 +18,8 @@
|
|||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Fixed using quote as a filter parameter
|
- Fixed using quote as a filter parameter.
|
||||||
[Ilya Puchka](https://github.com/yonaskolb)
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
[#210](https://github.com/stencilproject/Stencil/pull/210)
|
[#210](https://github.com/stencilproject/Stencil/pull/210)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
112
Sources/KeyPath.swift
Normal file
112
Sources/KeyPath.swift
Normal file
@@ -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)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,8 +50,10 @@ public struct Variable : Equatable, Resolvable {
|
|||||||
self.variable = variable
|
self.variable = variable
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate func lookup() -> [String] {
|
// Split the lookup string and resolve references if possible
|
||||||
return variable.characters.split(separator: ".").map(String.init)
|
fileprivate func lookup(_ context: Context) throws -> [String] {
|
||||||
|
var keyPath = KeyPath(variable, in: context)
|
||||||
|
return try keyPath.parse()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the variable in the given context
|
/// Resolve the variable in the given context
|
||||||
@@ -75,7 +77,7 @@ public struct Variable : Equatable, Resolvable {
|
|||||||
return bool
|
return bool
|
||||||
}
|
}
|
||||||
|
|
||||||
for bit in lookup() {
|
for bit in try lookup(context) {
|
||||||
current = normalize(current)
|
current = normalize(current)
|
||||||
|
|
||||||
if let context = current as? Context {
|
if let context = current as? Context {
|
||||||
|
|||||||
@@ -188,6 +188,98 @@ func testVariable() {
|
|||||||
let result = try variable.resolve(context) as? Int
|
let result = try variable.resolve(context) as? Int
|
||||||
try expect(result) == 2
|
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") {
|
describe("RangeVariable") {
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ For example, if `people` was an array:
|
|||||||
There are {{ people.count }} people. {{ people.first }} is the first
|
There are {{ people.count }} people. {{ people.first }} is the first
|
||||||
person, followed by {{ people.1 }}.
|
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
|
Filters
|
||||||
~~~~~~~
|
~~~~~~~
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user