Merge branch 'master' into errors-logs-improvements

This commit is contained in:
Ilya Puchka
2018-08-12 22:08:13 +01:00
28 changed files with 1145 additions and 134 deletions

View File

@@ -4,17 +4,89 @@
### Enhancements
- Added support for resolving superclass properties for not-NSObject subclasses
- The `{% for %}` tag can now iterate over tuples, structures and classes via
their stored properties.
- Drastic improvements in error reporting
- Added an optional second parameter to the `include` tag for passing a sub context to the included file.
[Yonas Kolb](https://github.com/yonaskolb)
[#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/ilyapuchka)
[#178](https://github.com/stencilproject/Stencil/pull/178)
- Improvements in error reporting.
[Ilya Puchka](https://github.com/ilyapuchka)
[#167](https://github.com/stencilproject/Stencil/pull/167)
### Bug Fixes
- Fixed rendering `{{ block.super }}` with several levels of inheritance
- Fixed checking dictionary values for nil in `default` filter
- Fixed using quote as a filter parameter.
[Ilya Puchka](https://github.com/ilyapuchka)
[#210](https://github.com/stencilproject/Stencil/pull/210)
## 0.11.0 (2018-04-04)
### Enhancements
- 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.
[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.
[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.
- Integer literals now resolve into Int values, not Float
[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

View File

@@ -1,11 +1,10 @@
// swift-tools-version:3.1
import PackageDescription
let package = Package(
name: "Stencil",
dependencies: [
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8),
// https://github.com/apple/swift-package-manager/pull/597
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7),
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 9),
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 8),
]
)

10
Package@swift-3.swift Normal file
View File

@@ -0,0 +1,10 @@
// swift-tools-version:3.1
import PackageDescription
let package = Package(
name: "Stencil",
dependencies: [
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8),
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7),
]
)

View File

@@ -1,6 +1,6 @@
# Stencil
[![Build Status](https://travis-ci.org/kylef/Stencil.svg?branch=master)](https://travis-ci.org/kylef/Stencil)
[![Build Status](https://travis-ci.org/stencilproject/Stencil.svg?branch=master)](https://travis-ci.org/stencilproject/Stencil)
Stencil is a simple and powerful template language for Swift. It provides a
syntax similar to Django and Mustache. If you're familiar with these, you will
@@ -63,6 +63,13 @@ Resources to help you integrate Stencil into a Swift project:
- [API Reference](http://stencil.fuller.li/en/latest/api.html)
- [Custom Template Tags and Filters](http://stencil.fuller.li/en/latest/custom-template-tags-and-filters.html)
## Projects that use Stencil
[Sourcery](https://github.com/krzysztofzablocki/Sourcery),
[SwiftGen](https://github.com/SwiftGen/SwiftGen),
[Kitura](https://github.com/IBM-Swift/Kitura),
[Weaver](https://github.com/scribd/Weaver)
## License
Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more

View File

@@ -105,6 +105,10 @@ final class InExpression: Expression, InfixOperator, CustomStringConvertible {
if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange<Int> {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange<Int> {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
return rhs.contains(lhs)
} else if lhsValue == nil && rhsValue == nil {

View File

@@ -57,6 +57,8 @@ class DefaultExtension: Extension {
registerFilter("uppercase", filter: uppercase)
registerFilter("lowercase", filter: lowercase)
registerFilter("join", filter: joinFilter)
registerFilter("split", filter: splitFilter)
registerFilter("indent", filter: indentFilter)
}
}

View File

@@ -1,13 +1,25 @@
func capitalise(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).capitalized }
} else {
return stringify(value).capitalized
}
}
func uppercase(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).uppercased() }
} else {
return stringify(value).uppercased()
}
}
func lowercase(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).lowercased() }
} else {
return stringify(value).lowercased()
}
}
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
@@ -40,3 +52,62 @@ func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
return value
}
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count < 2 else {
throw TemplateSyntaxError("'split' filter takes a single argument")
}
let separator = stringify(arguments.first ?? " ")
if let value = value as? String {
return value.components(separatedBy: separator)
}
return value
}
func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count <= 3 else {
throw TemplateSyntaxError("'indent' filter can take at most 3 arguments")
}
var indentWidth = 4
if arguments.count > 0 {
guard let value = arguments[0] as? Int else {
throw TemplateSyntaxError("'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))")
}
indentWidth = value
}
var indentationChar = " "
if arguments.count > 1 {
guard let value = arguments[1] as? String else {
throw TemplateSyntaxError("'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))")
}
indentationChar = value
}
var indentFirst = false
if arguments.count > 2 {
guard let value = arguments[2] as? Bool else {
throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool")
}
indentFirst = value
}
let indentation = [String](repeating: indentationChar, count: indentWidth).joined(separator: "")
return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
}
func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
guard !indentation.isEmpty else { return content }
var lines = content.components(separatedBy: .newlines)
let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
let result = lines.reduce([firstLine]) { (result, line) in
return result + [(line.isEmpty ? "" : "\(indentation)\(line)")]
}
return result.joined(separator: "\n")
}

View File

@@ -11,25 +11,28 @@ class ForNode : NodeType {
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
let components = token.components()
guard components.count >= 3 && components[2] == "in" &&
(components.count == 4 || (components.count >= 6 && components[4] == "where")) else {
throw TemplateSyntaxError("'for' statements should use the following syntax 'for x in y where condition'.")
func hasToken(_ token: String, at index: Int) -> Bool {
return components.count > (index + 1) && components[index] == token
}
func endsOrHasToken(_ token: String, at index: Int) -> Bool {
return components.count == index || hasToken(token, at: index)
}
guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
throw TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.")
}
let loopVariables = components[1].characters
.split(separator: ",")
.map(String.init)
.map { $0.trimmingCharacters(in: CharacterSet.whitespaces) }
.map { $0.trim(character: " ") }
let variable = components[3]
let filter = try parser.compileFilter(variable, containedIn: token)
let resolvable = try parser.compileResolvable(components[3], containedIn: token)
let `where`: Expression?
if components.count >= 6 {
`where` = try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token)
} else {
`where` = nil
}
let `where` = hasToken("where", at: 4)
? try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token)
: nil
let forNodes = try parser.parse(until(["endfor", "empty"]))
@@ -43,7 +46,7 @@ class ForNode : NodeType {
_ = parser.nextToken()
}
return ForNode(resolvable: filter, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`, token: token)
return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`, token: token)
}
init(resolvable: Resolvable, loopVariables: [String], nodes:[NodeType], emptyNodes:[NodeType], where: Expression? = nil, token: Token? = nil) {
@@ -55,25 +58,26 @@ class ForNode : NodeType {
self.token = token
}
func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) rethrows -> Result {
func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
if loopVariables.isEmpty {
return try context.push() {
return try closure()
}
}
if let value = value as? (Any, Any) {
let first = loopVariables[0]
if loopVariables.count == 2 {
let second = loopVariables[1]
return try context.push(dictionary: [first: value.0, second: value.1]) {
return try closure()
let valueMirror = Mirror(reflecting: value)
if case .tuple? = valueMirror.displayStyle {
if loopVariables.count > Int(valueMirror.children.count) {
throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables")
}
var variablesContext = [String: Any]()
valueMirror.children.prefix(loopVariables.count).enumerated().forEach({ (offset, element) in
if loopVariables[offset] != "_" {
variablesContext[loopVariables[offset]] = element.value
}
})
return try context.push(dictionary: [first: value.0]) {
return try context.push(dictionary: variablesContext) {
return try closure()
}
}
@@ -133,6 +137,7 @@ class ForNode : NodeType {
"last": index == (count - 1),
"counter": index + 1,
"counter0": index,
"length": count
]
return try context.push(dictionary: ["forloop": forContext]) {

View File

@@ -111,7 +111,7 @@ final class IfExpressionParser {
}
}
return .variable(try tokenParser.compileFilter(component, containedIn: token))
return .variable(try tokenParser.compileResolvable(component, containedIn: token))
}
}

View File

@@ -3,20 +3,22 @@ import PathKit
class IncludeNode : NodeType {
let templateName: Variable
let includeContext: String?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components()
guard bits.count == 2 else {
throw TemplateSyntaxError("'include' tag takes one argument, the template file to be included")
guard bits.count == 2 || bits.count == 3 else {
throw TemplateSyntaxError("'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file")
}
return IncludeNode(templateName: Variable(bits[1]), token: token)
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
}
init(templateName: Variable, token: Token) {
init(templateName: Variable, includeContext: String? = nil, token: Token) {
self.templateName = templateName
self.includeContext = includeContext
self.token = token
}
@@ -28,7 +30,8 @@ class IncludeNode : NodeType {
let template = try context.environment.loadTemplate(name: templateName)
do {
return try context.push {
let subContext = includeContext.flatMap { context[$0] as? [String: Any] }
return try context.push(dictionary: subContext) {
return try template.render(context)
}
} catch {

112
Sources/KeyPath.swift Normal file
View 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)'")
}
}
}

View File

@@ -14,7 +14,12 @@ struct Lexer {
guard string.characters.count > 4 else { return "" }
let start = string.index(string.startIndex, offsetBy: 2)
let end = string.index(string.endIndex, offsetBy: -2)
return String(string[start..<end]).trim(character: " ")
let trimmed = String(string[start..<end])
.components(separatedBy: "\n")
.filter({ !$0.isEmpty })
.map({ $0.trim(character: " ") })
.joined(separator: " ")
return trimmed
}
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {

View File

@@ -79,6 +79,8 @@ public class VariableNode : NodeType {
func stringify(_ result: Any?) -> String {
if let result = result as? String {
return result
} else if let array = result as? [Any?] {
return unwrap(array).description
} else if let result = result as? CustomStringConvertible {
return result.description
} else if let result = result as? NSObject {
@@ -87,3 +89,16 @@ func stringify(_ result: Any?) -> String {
return ""
}
func unwrap(_ array: [Any?]) -> [Any] {
return array.map { (item: Any?) -> Any in
if let item = item {
if let items = item as? [Any?] {
return unwrap(items)
} else {
return item
}
}
else { return item as Any }
}
}

View File

@@ -40,7 +40,7 @@ public class TokenParser {
case .text(let text, _):
nodes.append(TextNode(text: text))
case .variable:
let filter = try compileFilter(token.contents, containedIn: token)
let filter = try compileResolvable(token.contents, containedIn: token)
nodes.append(VariableNode(variable: filter, token: token))
case .block:
if let parse_until = parse_until , parse_until(self, token) {
@@ -94,7 +94,26 @@ public class TokenParser {
}
}
throw TemplateSyntaxError("Unknown filter '\(name)'")
let suggestedFilters = self.suggestedFilters(for: name)
if suggestedFilters.isEmpty {
throw TemplateSyntaxError("Unknown filter '\(name)'.")
} else {
throw TemplateSyntaxError("Unknown filter '\(name)'. Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", ")).")
}
}
private func suggestedFilters(for name: String) -> [String] {
let allFilters = environment.extensions.flatMap({ $0.filters.keys })
let filtersWithDistance = allFilters
.map({ (filterName: $0, distance: $0.levenshteinDistance(name)) })
// do not suggest filters which names are shorter than the distance
.filter({ $0.filterName.characters.count > $0.distance })
guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else {
return []
}
// suggest all filters with the same distance
return filtersWithDistance.filter({ $0.distance == minDistance }).map({ $0.filterName })
}
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
@@ -122,4 +141,57 @@ public class TokenParser {
return try FilterExpression(token: token, parser: self)
}
@available(*, deprecated, message: "Use compileResolvable(_:containedIn:)")
public func compileResolvable(_ token: String) throws -> Resolvable {
return try RangeVariable(token, parser: self)
?? compileFilter(token)
}
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
return try RangeVariable(token, parser: self)
?? compileFilter(token, containedIn: containingToken)
}
}
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
extension String {
subscript(_ i: Int) -> Character {
return self[self.index(self.startIndex, offsetBy: i)]
}
func levenshteinDistance(_ target: String) -> Int {
// create two work vectors of integer distances
var last, current: [Int]
// initialize v0 (the previous row of distances)
// this row is A[0][i]: edit distance for an empty s
// the distance is just the number of characters to delete from t
last = [Int](0...target.characters.count)
current = [Int](repeating: 0, count: target.characters.count + 1)
for i in 0..<self.characters.count {
// calculate v1 (current row distances) from the previous row v0
// first element of v1 is A[i+1][0]
// edit distance is delete (i+1) chars from s to match empty t
current[0] = i + 1
// use formula to fill in the rest of the row
for j in 0..<target.characters.count {
current[j+1] = Swift.min(
last[j+1] + 1,
current[j] + 1,
last[j] + (self[i] == target[j] ? 0 : 1)
)
}
// copy v1 (current row) to v0 (previous row) for next iteration
last = current
}
return current[target.characters.count]
}
}

View File

@@ -10,6 +10,21 @@ extension String {
var singleQuoteCount = 0
var doubleQuoteCount = 0
let specialCharacters = ",|:"
func appendWord(_ word: String) {
if components.count > 0 {
if let precedingChar = components.last?.characters.last, specialCharacters.characters.contains(precedingChar) {
components[components.count-1] += word
} else if specialCharacters.contains(word) {
components[components.count-1] += word
} else {
components.append(word)
}
} else {
components.append(word)
}
}
for character in self.characters {
if character == "'" { singleQuoteCount += 1 }
else if character == "\"" { doubleQuoteCount += 1 }
@@ -18,8 +33,8 @@ extension String {
if separate != separator {
word.append(separate)
} else if singleQuoteCount % 2 == 0 && doubleQuoteCount % 2 == 0 && !word.isEmpty {
components.append(word)
} else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty {
appendWord(word)
word = ""
}
@@ -33,7 +48,7 @@ extension String {
}
if !word.isEmpty {
components.append(word)
appendWord(word)
}
return components

View File

@@ -48,8 +48,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
@@ -68,8 +70,12 @@ public struct Variable : Equatable, Resolvable {
if let number = Number(variable) {
return number
}
// Boolean literal
if let bool = Bool(variable) {
return bool
}
for bit in lookup() {
for bit in try lookup(context) {
current = normalize(current)
if let context = current as? Context {
@@ -124,6 +130,42 @@ public func ==(lhs: Variable, rhs: Variable) -> Bool {
return lhs.variable == rhs.variable
}
/// A structure used to represet range of two integer values expressed as `from...to`.
/// Values should be numbers (they will be converted to integers).
/// Rendering this variable produces array from range `from...to`.
/// If `from` is more than `to` array will contain values of reversed range.
public struct RangeVariable: Resolvable {
public let from: Resolvable
public let to: Resolvable
public init?(_ token: String, parser: TokenParser) throws {
let components = token.components(separatedBy: "...")
guard components.count == 2 else {
return nil
}
self.from = try parser.compileFilter(components[0])
self.to = try parser.compileFilter(components[1])
}
public func resolve(_ context: Context) throws -> Any? {
let fromResolved = try from.resolve(context)
let toResolved = try to.resolve(context)
guard let from = fromResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
throw TemplateSyntaxError("'from' value is not an Integer (\(fromResolved ?? "nil"))")
}
guard let to = toResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
throw TemplateSyntaxError("'to' value is not an Integer (\(toResolved ?? "nil") )")
}
let range = min(from, to)...max(from, to)
return from > to ? Array(range.reversed()) : Array(range)
}
}
func normalize(_ current: Any?) -> Any? {
if let current = current as? Normalizable {
@@ -167,25 +209,47 @@ extension Dictionary : Normalizable {
func parseFilterComponents(token: String) -> (String, [Variable]) {
var components = token.smartSplit(separator: ":")
let name = components.removeFirst()
let name = components.removeFirst().trim(character: " ")
let variables = components
.joined(separator: ":")
.smartSplit(separator: ",")
.map { Variable($0) }
.map { Variable($0.trim(character: " ")) }
return (name, variables)
}
extension Mirror {
func getValue(for key: String) -> Any? {
let result = descendant(key)
let result = descendant(key) ?? Int(key).flatMap({ descendant($0) })
if result == nil {
// go through inheritance chain to reach superclass properties
return superclassMirror?.getValue(for: key)
} else if let result = result, String(describing: result) == "nil" {
} else if let result = result {
guard String(describing: result) != "nil" else {
// mirror returns non-nil value even for nil-containing properties
// so we have to check if its value is actually nil or not
return nil
}
if let result = (result as? AnyOptional)?.wrapped {
return result
} else {
return result
}
}
return result
}
}
protocol AnyOptional {
var wrapped: Any? { get }
}
extension Optional: AnyOptional {
var wrapped: Any? {
switch self {
case let .some(value): return value
case .none: return nil
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "Stencil",
"version": "0.10.1",
"version": "0.11.0",
"summary": "Stencil is a simple and powerful template language for Swift.",
"homepage": "https://stencil.fuller.li",
"license": {
@@ -12,8 +12,8 @@
},
"social_media_url": "https://twitter.com/kylefuller",
"source": {
"git": "https://github.com/kylef/Stencil.git",
"tag": "0.10.1"
"git": "https://github.com/stencilproject/Stencil.git",
"tag": "0.11.0"
},
"source_files": [
"Sources/*.swift"
@@ -25,6 +25,8 @@
},
"requires_arc": true,
"dependencies": {
"PathKit": [ "~> 0.8.0" ]
"PathKit": [
"~> 0.8.0"
]
}
}

View File

@@ -48,18 +48,20 @@ func testEnvironment() {
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
}
func expectError(reason: String, token: String) throws {
func expectError(reason: String, token: String,
file: String = #file, line: Int = #line, function: String = #function) throws {
let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"])).toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError)
let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError)
}
$0.context("given syntax error") {
$0.it("reports syntax error on invalid for tag syntax") {
template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
try expectError(reason: "'for' statements should use the following syntax 'for x in y where condition'.", token: "for name in")
try expectError(reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.", token: "for name in")
}
$0.it("reports syntax error on missing endfor") {
@@ -78,37 +80,37 @@ func testEnvironment() {
$0.it("reports syntax error in for tag") {
template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
try expectError(reason: "Unknown filter 'unknown'", token: "names|unknown")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "names|unknown")
}
$0.it("reports syntax error in for-where tag") {
template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}"
try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
}
$0.it("reports syntax error in if tag") {
template = "{% if name|unknown %}{{ name }}{% endif %}"
try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
}
$0.it("reports syntax error in elif tag") {
template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}"
try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
}
$0.it("reports syntax error in ifnot tag") {
template = "{% ifnot name|unknown %}{{ name }}{% endif %}"
try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
}
$0.it("reports syntax error in filter tag") {
template = "{% filter unknown %}Text{% endfilter %}"
try expectError(reason: "Unknown filter 'unknown'", token: "filter unknown")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "filter unknown")
}
$0.it("reports syntax error in variable tag") {
template = "{{ name|unknown }}"
try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
}
}
@@ -200,20 +202,21 @@ func testEnvironment() {
includedTemplate = nil
}
func expectError(reason: String, token: String, includedToken: String) throws {
func expectError(reason: String, token: String, includedToken: String,
file: String = #file, line: Int = #line, function: String = #function) throws {
var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
expectedError.stackTrace = [expectedSyntaxError(token: includedToken, template: includedTemplate, description: reason).token!]
let error = try expect(environment.render(template: template, context: ["target": "World"]))
.toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError)
let error = try expect(environment.render(template: template, context: ["target": "World"]),
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError)
}
$0.it("reports syntax error in included template") {
template = Template(templateString: "{% include \"invalid-include.html\" %}", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(reason: "Unknown filter 'unknown'",
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "include \"invalid-include.html\"",
includedToken: "target|unknown")
}
@@ -248,21 +251,22 @@ func testEnvironment() {
baseTemplate = nil
}
func expectError(reason: String, childToken: String, baseToken: String?) throws {
func expectError(reason: String, childToken: String, baseToken: String?,
file: String = #file, line: Int = #line, function: String = #function) throws {
var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
if let baseToken = baseToken {
expectedError.stackTrace = [expectedSyntaxError(token: baseToken, template: baseTemplate, description: reason).token!]
}
let error = try expect(environment.render(template: childTemplate, context: ["target": "World"]))
.toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError)
let error = try expect(environment.render(template: childTemplate, context: ["target": "World"]),
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError)
}
$0.it("reports syntax error in base template") {
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(reason: "Unknown filter 'unknown'",
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown")
}
@@ -286,7 +290,7 @@ func testEnvironment() {
childTemplate = Template(templateString: "{% extends \"base.html\" %}\n" +
"{% block body %}Child {{ target|unknown }}{% endblock %}", environment: environment, name: nil)
try expectError(reason: "Unknown filter 'unknown'",
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "target|unknown",
baseToken: nil)
}
@@ -311,7 +315,7 @@ func testEnvironment() {
}
}
private extension Expectation {
extension Expectation {
@discardableResult
func toThrow<T: Error>() throws -> T {
var thrownError: Error? = nil

View File

@@ -287,12 +287,16 @@ func testExpressions() {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [1, 2, 3]]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["a", "b", "c"]]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "abc"]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1...3]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1..<3]))).to.beTrue()
}
$0.it("evaluates to false when rhs does not contain lhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [2, 3, 4]]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["b", "c", "d"]]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "bcd"]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 1...3]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 3, "rhs": 1..<3]))).to.beFalse()
}
}
}

View File

@@ -78,9 +78,9 @@ func testFilter() {
}
$0.it("allows whitespace in expression") {
let template = Template(templateString: "{{ name | uppercase }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "KYLE"
let template = Template(templateString: "{{ value | join : \", \" }}")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "One, Two"
}
$0.it("throws when you pass arguments to simple filter") {
@@ -89,35 +89,48 @@ func testFilter() {
}
}
describe("capitalize filter") {
describe("string filters") {
$0.context("given string") {
$0.it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ name|capitalize }}")
$0.it("capitalizes a string") {
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "Kyle"
}
}
describe("uppercase filter") {
let template = Template(templateString: "{{ name|uppercase }}")
$0.it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ name|uppercase }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "KYLE"
}
}
describe("lowercase filter") {
let template = Template(templateString: "{{ name|lowercase }}")
$0.it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ name|lowercase }}")
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
try expect(result) == "kyle"
}
}
$0.context("given array of strings") {
$0.it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ names|capitalize }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == "[\"Kyle\", \"Kyle\"]"
}
$0.it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ names|uppercase }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == "[\"KYLE\", \"KYLE\"]"
}
$0.it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ names|lowercase }}")
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
try expect(result) == "[\"kyle\", \"kyle\"]"
}
}
}
describe("default filter") {
let template = Template(templateString: "Hello {{ name|default:\"World\" }}")
@@ -183,4 +196,96 @@ func testFilter() {
try expect(result) == "OneTwo"
}
}
describe("split filter") {
let template = Template(templateString: "{{ value|split:\", \" }}")
$0.it("split a string into array") {
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == "[\"One\", \"Two\"]"
}
$0.it("can split without arguments") {
let template = Template(templateString: "{{ value|split }}")
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == "[\"One,\", \"Two\"]"
}
}
describe("filter suggestion") {
var template: Template!
var filterExtension: Extension!
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
let range = template.templateString.range(of: token)!
let rangeLine = template.templateString.rangeLine(range)
let sourceMap = SourceMap(filename: template.name, line: rangeLine)
let token = Token.block(value: token, at: sourceMap)
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
}
func expectError(reason: String, token: String,
file: String = #file, line: Int = #line, function: String = #function) throws {
let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
let environment = Environment(extensions: [filterExtension])
let error = try expect(environment.render(template: template, context: [:]),
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError)
}
$0.it("made for unknown filter") {
template = Template(templateString: "{{ value|unknownFilter }}")
filterExtension = Extension()
filterExtension.registerFilter("knownFilter") { value, _ in value }
try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.", token: "value|unknownFilter")
}
$0.it("made for multiple similar filters") {
template = Template(templateString: "{{ value|lowerFirst }}")
filterExtension = Extension()
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
try expectError(reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.", token: "value|lowerFirst")
}
$0.it("not made when can't find similar filter") {
template = Template(templateString: "{{ value|unknownFilter }}")
try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.", token: "value|unknownFilter")
}
}
describe("indent filter") {
$0.it("indents content") {
let template = Template(templateString: "{{ value|indent:2 }}")
let result = try template.render(Context(dictionary: ["value": "One\nTwo"]))
try expect(result) == "One\n Two"
}
$0.it("can indent with arbitrary character") {
let template = Template(templateString: "{{ value|indent:2,\"\t\" }}")
let result = try template.render(Context(dictionary: ["value": "One\nTwo"]))
try expect(result) == "One\n\t\tTwo"
}
$0.it("can indent first line") {
let template = Template(templateString: "{{ value|indent:2,\" \",true }}")
let result = try template.render(Context(dictionary: ["value": "One\nTwo"]))
try expect(result) == " One\n Two"
}
$0.it("does not indent empty lines") {
let template = Template(templateString: "{{ value|indent }}")
let result = try template.render(Context(dictionary: ["value": "One\n\n\nTwo\n\n"]))
try expect(result) == "One\n\n\n Two\n\n"
}
}
}

View File

@@ -21,5 +21,25 @@ func testFilterTag() {
try expect(try template.render()).toThrow()
}
$0.it("can render filters with arguments") {
let ext = Extension()
ext.registerFilter("split", filter: {
return ($0 as! String).components(separatedBy: $1[0] as! String)
})
let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: "{% filter split:\",\"|join:\";\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": [1, 2]])
try expect(result) == "1;2"
}
$0.it("can render filters with quote as an argument") {
let ext = Extension()
ext.registerFilter("replace", filter: {
print($1[0] as! String)
return ($0 as! String).replacingOccurrences(of: $1[0] as! String, with: $1[1] as! String)
})
let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: "{% filter replace:'\"',\"\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": ["\"1\"", "\"2\""]])
try expect(result) == "1,2"
}
}
}

View File

@@ -11,7 +11,8 @@ func testForNode() {
"dict": [
"one": "I",
"two": "II",
]
],
"tuples": [(1, 2, 3), (4, 5, 6)]
])
$0.it("renders the given nodes for each item") {
@@ -89,6 +90,12 @@ func testForNode() {
try expect(try node.render(context)) == "102132"
}
$0.it("renders the given nodes while providing loop length") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.length")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(context)) == "132333"
}
$0.it("renders the given nodes while filtering items using where expression") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown))
@@ -104,8 +111,8 @@ func testForNode() {
try expect(try node.render(context)) == "empty"
}
$0.it("can render a filter") {
let templateString = "{% for article in ars|default:articles %}" +
$0.it("can render a filter with spaces") {
let templateString = "{% for article in ars | default: a, b , articles %}" +
"- {{ article.title }} by {{ article.author }}.\n" +
"{% endfor %}\n"
@@ -127,8 +134,55 @@ func testForNode() {
try expect(result) == fixture
}
$0.context("given array of tuples") {
$0.it("can iterate over all tuple values") {
let templateString = "{% for first,second,third in tuples %}" +
"{{ first }}, {{ second }}, {{ third }}\n" +
"{% endfor %}\n"
let template = Template(templateString: templateString)
let result = try template.render(context)
let fixture = "1, 2, 3\n4, 5, 6\n\n"
try expect(result) == fixture
}
$0.it("can iterate with less number of variables") {
let templateString = "{% for first,second in tuples %}" +
"{{ first }}, {{ second }}\n" +
"{% endfor %}\n"
let template = Template(templateString: templateString)
let result = try template.render(context)
let fixture = "1, 2\n4, 5\n\n"
try expect(result) == fixture
}
$0.it("can use _ to skip variables") {
let templateString = "{% for first,_,third in tuples %}" +
"{{ first }}, {{ third }}\n" +
"{% endfor %}\n"
let template = Template(templateString: templateString)
let result = try template.render(context)
let fixture = "1, 3\n4, 6\n\n"
try expect(result) == fixture
}
$0.it("throws when number of variables is more than number of tuple values") {
let templateString = "{% for key,value,smth in dict %}" +
"{% endfor %}\n"
let template = Template(templateString: templateString)
try expect(template.render(context)).toThrow()
}
}
$0.it("can iterate over dictionary") {
let templateString = "{% for key,value in dict %}" +
let templateString = "{% for key, value in dict %}" +
"{{ key }}: {{ value }}," +
"{% endfor %}"
@@ -169,12 +223,92 @@ func testForNode() {
}
$0.it("handles invalid input") {
let tokens: [Token] = [.block(value: "for i", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError(reason: "'for' statements should use the following syntax 'for x in y where condition'.", token: tokens.first)
let token = Token.block(value: "for i", at: .unknown)
let parser = TokenParser(tokens: [token], environment: Environment())
let error = TemplateSyntaxError(reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.", token: token)
try expect(try parser.parse()).toThrow(error)
}
$0.it("can iterate over struct properties") {
struct MyStruct {
let string: String
let number: Int
}
let context = Context(dictionary: [
"struct": MyStruct(string: "abc", number: 123)
])
let nodes: [NodeType] = [
VariableNode(variable: "property"),
TextNode(text: "="),
VariableNode(variable: "value"),
TextNode(text: "\n"),
]
let node = ForNode(resolvable: Variable("struct"), loopVariables: ["property", "value"], nodes: nodes, emptyNodes: [])
let result = try node.render(context)
try expect(result) == "string=abc\nnumber=123\n"
}
$0.it("can iterate tuple items") {
let context = Context(dictionary: [
"tuple": (one: 1, two: "dva"),
])
let nodes: [NodeType] = [
VariableNode(variable: "label"),
TextNode(text: "="),
VariableNode(variable: "value"),
TextNode(text: "\n"),
]
let node = ForNode(resolvable: Variable("tuple"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
let result = try node.render(context)
try expect(result) == "one=1\ntwo=dva\n"
}
$0.it("can iterate over class properties") {
class MyClass {
var baseString: String
var baseInt: Int
init(_ string: String, _ int: Int) {
baseString = string
baseInt = int
}
}
class MySubclass: MyClass {
var childString: String
init(_ childString: String, _ string: String, _ int: Int) {
self.childString = childString
super.init(string, int)
}
}
let context = Context(dictionary: [
"class": MySubclass("child", "base", 1)
])
let nodes: [NodeType] = [
VariableNode(variable: "label"),
TextNode(text: "="),
VariableNode(variable: "value"),
TextNode(text: "\n"),
]
let node = ForNode(resolvable: Variable("class"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
let result = try node.render(context)
try expect(result) == "childString=child\nbaseString=base\nbaseInt=1\n"
}
$0.it("can iterate in range of variables") {
let template: Template = "{% for i in 1...j %}{{ i }}{% endfor %}"
try expect(try template.render(Context(dictionary: ["j": 3]))) == "123"
}
}
}

View File

@@ -266,5 +266,22 @@ func testIfNode() {
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
try expect(result) == ""
}
$0.it("supports closed range variables") {
let tokens: [Token] = [
.block(value: "if value in 1...3", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true"
try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false"
}
}
}

View File

@@ -14,7 +14,7 @@ func testInclude() {
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError(reason: "'include' tag takes one argument, the template file to be included", token: tokens.first)
let error = TemplateSyntaxError(reason: "'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file", token: tokens.first)
try expect(try parser.parse()).toThrow(error)
}
@@ -56,6 +56,13 @@ func testInclude() {
let value = try node.render(context)
try expect(value) == "Hello World!"
}
$0.it("successfully passes context") {
let template = Template(templateString: "{% include \"test.html\" child %}")
let context = Context(dictionary: ["child": ["target": "World"]], environment: environment)
let value = try template.render(context)
try expect(value) == "Hello World!"
}
}
}
}

View File

@@ -67,5 +67,28 @@ func testLexer() {
let lexer = Lexer(templateString: "{{}}")
let _ = lexer.tokenize()
}
$0.it("can tokenize with new lines") {
let templateString =
"My name is {%\n" +
" if name\n" +
" and\n" +
" name\n" +
"%}{{\n" +
"name\n" +
"}}{%\n" +
"endif %}."
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "My name is ", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "My name is")!)))
try expect(tokens[1]) == Token.block(value: "if name and name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "{%")!)))
try expect(tokens[2]) == Token.variable(value: "name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "name", options: [.backwards])!)))
try expect(tokens[3]) == Token.block(value: "endif", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "endif")!)))
try expect(tokens[4]) == Token.text(value: ".", at: SourceMap(line: templateString.rangeLine(templateString.range(of: ".")!)))
}
}
}

View File

@@ -5,10 +5,10 @@ import Spectre
#if os(OSX)
@objc class Superclass: NSObject {
let name = "Foo"
@objc let name = "Foo"
}
@objc class Object : Superclass {
let title = "Hello World"
@objc let title = "Hello World"
}
#endif
@@ -26,6 +26,7 @@ fileprivate class WebSite {
fileprivate class Blog: WebSite {
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
let featuring: Article? = Article(author: Person(name: "Jhon"))
}
func testVariable() {
@@ -39,7 +40,8 @@ func testVariable() {
"counter": [
"count": "kylef",
],
"article": Article(author: Person(name: "Kyle"))
"article": Article(author: Person(name: "Kyle")),
"tuple": (one: 1, two: 2)
])
#if os(OSX)
@@ -71,6 +73,13 @@ func testVariable() {
try expect(result) == 3.14
}
$0.it("can resolve boolean literal") {
try expect(Variable("true").resolve(context) as? Bool) == true
try expect(Variable("false").resolve(context) as? Bool) == false
try expect(Variable("0").resolve(context) as? Int) == 0
try expect(Variable("1").resolve(context) as? Int) == 1
}
$0.it("can resolve a string variable") {
let variable = Variable("name")
let result = try variable.resolve(context) as? String
@@ -153,5 +162,171 @@ func testVariable() {
try expect(result) == "blog.com"
}
$0.it("can resolve optional variable property using reflection") {
let variable = Variable("blog.featuring.author.name")
let result = try variable.resolve(context) as? String
try expect(result) == "Jhon"
}
$0.it("does not render Optional") {
var array: [Any?] = [1, nil]
array.append(array)
let context = Context(dictionary: ["values": array])
try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]"
try expect(VariableNode(variable: "values.1").render(context)) == ""
}
$0.it("can subscript tuple by index") {
let variable = Variable("tuple.0")
let result = try variable.resolve(context) as? Int
try expect(result) == 1
}
$0.it("can subscript tuple by label") {
let variable = Variable("tuple.two")
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") {
let context: Context = {
let ext = Extension()
ext.registerFilter("incr", filter: { (arg: Any?) in toNumber(value: arg!)! + 1 })
let environment = Environment(extensions: [ext])
return Context(dictionary: [:], environment: environment)
}()
func makeVariable(_ token: String) throws -> RangeVariable? {
return try RangeVariable(token, parser: TokenParser(tokens: [], environment: context.environment))
}
$0.it("can resolve closed range as array") {
let result = try makeVariable("1...3")?.resolve(context) as? [Int]
try expect(result) == [1, 2, 3]
}
$0.it("can resolve decreasing closed range as reversed array") {
let result = try makeVariable("3...1")?.resolve(context) as? [Int]
try expect(result) == [3, 2, 1]
}
$0.it("can use filter on range variables") {
let result = try makeVariable("1|incr...3|incr")?.resolve(context) as? [Int]
try expect(result) == [2, 3, 4]
}
$0.it("throws when left value is not int") {
let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}"
try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow()
}
$0.it("throws when right value is not int") {
let variable = try makeVariable("k...j")
try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow()
}
$0.it("throws is left range value is missing") {
try expect(makeVariable("...1")).toThrow()
}
$0.it("throws is right range value is missing") {
try expect(makeVariable("1...")).toThrow()
}
}
}

View File

@@ -29,6 +29,18 @@ The ``for`` tag can iterate over dictionaries.
{% endfor %}
</ul>
It can also iterate over ranges, tuple elements, structs' and classes' stored properties (using ``Mirror``).
You can iterate over range literals created using ``N...M`` syntax, both in ascending and descending order:
.. code-block:: html+django
<ul>
{% for i in 1...array.count %}
<li>{{ i }}</li>
{% endfor %}
</ul>
The ``for`` tag can contain optional ``where`` expression to filter out
elements on which this expression evaluates to false.
@@ -59,6 +71,7 @@ The for block sets a few variables available within the loop:
- ``last`` - True if this is the last time through the loop
- ``counter`` - The current iteration of the loop (1 indexed)
- ``counter0`` - The current iteration of the loop (0 indexed)
- ``length`` - The total length of the loop
For example:
@@ -124,7 +137,7 @@ or to negate a variable.
{% endif %}
You may use ``and``, ``or`` and ``not`` multiple times together. ``not`` has
higest prescidence followed by ``and``. For example:
higest precedence followed by ``and``. For example:
.. code-block:: html+django
@@ -247,6 +260,12 @@ You can include another template using the `include` tag.
{% include "comment.html" %}
By default the included file gets passed the current context. You can pass a sub context by using an optional 2nd parameter as a lookup in the current context.
.. code-block:: html+django
{% include "comment.html" comment %}
The `include` tag requires you to provide a loader which will be used to lookup
the template.
@@ -281,7 +300,7 @@ Built-in Filters
~~~~~~~~~~~~~~
The capitalize filter allows you to capitalize a string.
For example, `stencil` to `Stencil`.
For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -291,7 +310,7 @@ For example, `stencil` to `Stencil`.
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to uppercase.
For example, `Stencil` to `STENCIL`.
For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -301,7 +320,7 @@ For example, `Stencil` to `STENCIL`.
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to lowercase.
For example, `Stencil` to `stencil`.
For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -326,4 +345,31 @@ Join an array of items.
{{ value|join:", " }}
.. note:: The value MUST be an array.
.. note:: The value MUST be an array. Default argument value is empty string.
``split``
~~~~~~~~~
Split string into substrings by separator.
.. code-block:: html+django
{{ value|split:", " }}
.. note:: The value MUST be a String. Default argument value is a single-space string.
``indent``
~~~~~~~~~
Indents lines of rendered value or block.
.. code-block:: html+django
{{ value|indent:2," ",true }}
Filter accepts several arguments:
* indentation width: number of indentation characters to indent lines with. Default is ``4``.
* indentation character: character to be used for indentation. Default is a space.
* indent first line: whether first line of output should be indented or not. Default is ``false``.

View File

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