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 ### Enhancements
- Added support for resolving superclass properties for not-NSObject subclasses - Added an optional second parameter to the `include` tag for passing a sub context to the included file.
- The `{% for %}` tag can now iterate over tuples, structures and classes via [Yonas Kolb](https://github.com/yonaskolb)
their stored properties. [#214](https://github.com/stencilproject/Stencil/pull/214)
- Drastic improvements in error reporting - 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 ### Bug Fixes
- Fixed rendering `{{ block.super }}` with several levels of inheritance - Fixed using quote as a filter parameter.
- Fixed checking dictionary values for nil in `default` filter [Ilya Puchka](https://github.com/ilyapuchka)
- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. [#210](https://github.com/stencilproject/Stencil/pull/210)
- Integer literals now resolve into Int values, not Float
## 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.
[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 ## 0.10.1
@@ -215,10 +287,10 @@
### Bug Fixes ### Bug Fixes
- Variables (`{{ variable.5 }}`) that reference an array index at an unknown - 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) [#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) [#60](https://github.com/kylef/Stencil/issues/60)
- If comparisons will now treat 0 and below numbers as negative. - If comparisons will now treat 0 and below numbers as negative.

View File

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

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 # 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 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 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) - [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) - [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 ## License
Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more 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] { if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] {
return rhs.contains(lhs) 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 { } else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
return rhs.contains(lhs) return rhs.contains(lhs)
} else if lhsValue == nil && rhsValue == nil { } else if lhsValue == nil && rhsValue == nil {

View File

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

View File

@@ -1,13 +1,25 @@
func capitalise(_ value: Any?) -> Any? { func capitalise(_ value: Any?) -> Any? {
return stringify(value).capitalized if let array = value as? [Any?] {
return array.map { stringify($0).capitalized }
} else {
return stringify(value).capitalized
}
} }
func uppercase(_ value: Any?) -> Any? { func uppercase(_ value: Any?) -> Any? {
return stringify(value).uppercased() if let array = value as? [Any?] {
return array.map { stringify($0).uppercased() }
} else {
return stringify(value).uppercased()
}
} }
func lowercase(_ value: Any?) -> Any? { func lowercase(_ value: Any?) -> Any? {
return stringify(value).lowercased() if let array = value as? [Any?] {
return array.map { stringify($0).lowercased() }
} else {
return stringify(value).lowercased()
}
} }
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? { func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
@@ -40,3 +52,62 @@ func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
return value 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 { class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
let components = token.components() let components = token.components()
guard components.count >= 3 && components[2] == "in" && func hasToken(_ token: String, at index: Int) -> Bool {
(components.count == 4 || (components.count >= 6 && components[4] == "where")) else { return components.count > (index + 1) && components[index] == token
throw TemplateSyntaxError("'for' statements should use the following syntax 'for x in y where condition'.") }
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 let loopVariables = components[1].characters
.split(separator: ",") .split(separator: ",")
.map(String.init) .map(String.init)
.map { $0.trimmingCharacters(in: CharacterSet.whitespaces) } .map { $0.trim(character: " ") }
let variable = components[3] let resolvable = try parser.compileResolvable(components[3], containedIn: token)
let filter = try parser.compileFilter(variable, containedIn: token)
let `where`: Expression? let `where` = hasToken("where", at: 4)
if components.count >= 6 { ? try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token)
`where` = try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token) : nil
} else {
`where` = nil
}
let forNodes = try parser.parse(until(["endfor", "empty"])) let forNodes = try parser.parse(until(["endfor", "empty"]))
@@ -43,7 +46,7 @@ class ForNode : NodeType {
_ = parser.nextToken() _ = 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) { 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 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 { if loopVariables.isEmpty {
return try context.push() { return try context.push() {
return try closure() return try closure()
} }
} }
if let value = value as? (Any, Any) { let valueMirror = Mirror(reflecting: value)
let first = loopVariables[0] if case .tuple? = valueMirror.displayStyle {
if loopVariables.count > Int(valueMirror.children.count) {
if loopVariables.count == 2 { throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables")
let second = loopVariables[1]
return try context.push(dictionary: [first: value.0, second: value.1]) {
return try closure()
}
} }
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() return try closure()
} }
} }
@@ -133,14 +137,15 @@ class ForNode : NodeType {
"last": index == (count - 1), "last": index == (count - 1),
"counter": index + 1, "counter": index + 1,
"counter0": index, "counter0": index,
] "length": count
]
return try context.push(dictionary: ["forloop": forContext]) { return try context.push(dictionary: ["forloop": forContext]) {
return try push(value: item, context: context) { return try push(value: item, context: context) {
try renderNodes(nodes, context) try renderNodes(nodes, context)
} }
} }
}.joined(separator: "") }.joined(separator: "")
} }
return try context.push { return try context.push {

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 { class IncludeNode : NodeType {
let templateName: Variable let templateName: Variable
let includeContext: String?
let token: Token? let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components() let bits = token.components()
guard bits.count == 2 else { guard bits.count == 2 || bits.count == 3 else {
throw TemplateSyntaxError("'include' tag takes one argument, the template file to be included") 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.templateName = templateName
self.includeContext = includeContext
self.token = token self.token = token
} }
@@ -28,7 +30,8 @@ class IncludeNode : NodeType {
let template = try context.environment.loadTemplate(name: templateName) let template = try context.environment.loadTemplate(name: templateName)
do { 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) return try template.render(context)
} }
} catch { } 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 "" } guard string.characters.count > 4 else { return "" }
let start = string.index(string.startIndex, offsetBy: 2) let start = string.index(string.startIndex, offsetBy: 2)
let end = string.index(string.endIndex, 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("{#") { if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {

View File

@@ -79,6 +79,8 @@ public class VariableNode : NodeType {
func stringify(_ result: Any?) -> String { func stringify(_ result: Any?) -> String {
if let result = result as? String { if let result = result as? String {
return result return result
} else if let array = result as? [Any?] {
return unwrap(array).description
} else if let result = result as? CustomStringConvertible { } else if let result = result as? CustomStringConvertible {
return result.description return result.description
} else if let result = result as? NSObject { } else if let result = result as? NSObject {
@@ -87,3 +89,16 @@ func stringify(_ result: Any?) -> String {
return "" 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, _): case .text(let text, _):
nodes.append(TextNode(text: text)) nodes.append(TextNode(text: text))
case .variable: 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)) nodes.append(VariableNode(variable: filter, token: token))
case .block: case .block:
if let parse_until = parse_until , parse_until(self, token) { 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 { public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
@@ -122,4 +141,57 @@ public class TokenParser {
return try FilterExpression(token: token, parser: self) 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 singleQuoteCount = 0
var doubleQuoteCount = 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 { for character in self.characters {
if character == "'" { singleQuoteCount += 1 } if character == "'" { singleQuoteCount += 1 }
else if character == "\"" { doubleQuoteCount += 1 } else if character == "\"" { doubleQuoteCount += 1 }
@@ -18,8 +33,8 @@ extension String {
if separate != separator { if separate != separator {
word.append(separate) word.append(separate)
} else if singleQuoteCount % 2 == 0 && doubleQuoteCount % 2 == 0 && !word.isEmpty { } else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty {
components.append(word) appendWord(word)
word = "" word = ""
} }
@@ -33,7 +48,7 @@ extension String {
} }
if !word.isEmpty { if !word.isEmpty {
components.append(word) appendWord(word)
} }
return components return components

View File

@@ -48,8 +48,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
@@ -68,8 +70,12 @@ public struct Variable : Equatable, Resolvable {
if let number = Number(variable) { if let number = Number(variable) {
return number return number
} }
// Boolean literal
if let bool = Bool(variable) {
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 {
@@ -124,6 +130,42 @@ public func ==(lhs: Variable, rhs: Variable) -> Bool {
return lhs.variable == rhs.variable 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? { func normalize(_ current: Any?) -> Any? {
if let current = current as? Normalizable { if let current = current as? Normalizable {
@@ -167,25 +209,47 @@ extension Dictionary : Normalizable {
func parseFilterComponents(token: String) -> (String, [Variable]) { func parseFilterComponents(token: String) -> (String, [Variable]) {
var components = token.smartSplit(separator: ":") var components = token.smartSplit(separator: ":")
let name = components.removeFirst() let name = components.removeFirst().trim(character: " ")
let variables = components let variables = components
.joined(separator: ":") .joined(separator: ":")
.smartSplit(separator: ",") .smartSplit(separator: ",")
.map { Variable($0) } .map { Variable($0.trim(character: " ")) }
return (name, variables) return (name, variables)
} }
extension Mirror { extension Mirror {
func getValue(for key: String) -> Any? { func getValue(for key: String) -> Any? {
let result = descendant(key) let result = descendant(key) ?? Int(key).flatMap({ descendant($0) })
if result == nil { if result == nil {
// go through inheritance chain to reach superclass properties // go through inheritance chain to reach superclass properties
return superclassMirror?.getValue(for: key) return superclassMirror?.getValue(for: key)
} else if let result = result, String(describing: result) == "nil" { } else if let result = result {
// mirror returns non-nil value even for nil-containing properties guard String(describing: result) != "nil" else {
// so we have to check if its value is actually nil or not // mirror returns non-nil value even for nil-containing properties
return nil // 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 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", "name": "Stencil",
"version": "0.10.1", "version": "0.11.0",
"summary": "Stencil is a simple and powerful template language for Swift.", "summary": "Stencil is a simple and powerful template language for Swift.",
"homepage": "https://stencil.fuller.li", "homepage": "https://stencil.fuller.li",
"license": { "license": {
@@ -12,8 +12,8 @@
}, },
"social_media_url": "https://twitter.com/kylefuller", "social_media_url": "https://twitter.com/kylefuller",
"source": { "source": {
"git": "https://github.com/kylef/Stencil.git", "git": "https://github.com/stencilproject/Stencil.git",
"tag": "0.10.1" "tag": "0.11.0"
}, },
"source_files": [ "source_files": [
"Sources/*.swift" "Sources/*.swift"
@@ -25,6 +25,8 @@
}, },
"requires_arc": true, "requires_arc": true,
"dependencies": { "dependencies": {
"PathKit": [ "~> 0.8.0" ] "PathKit": [
"~> 0.8.0"
]
} }
} }

View File

@@ -48,18 +48,20 @@ func testEnvironment() {
return TemplateSyntaxError(reason: description, token: token, stackTrace: []) 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 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 let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError) 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.context("given syntax error") {
$0.it("reports syntax error on invalid for tag syntax") { $0.it("reports syntax error on invalid for tag syntax") {
template = "Hello {% for name in %}{{ name }}, {% endfor %}!" 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") { $0.it("reports syntax error on missing endfor") {
@@ -78,37 +80,37 @@ func testEnvironment() {
$0.it("reports syntax error in for tag") { $0.it("reports syntax error in for tag") {
template = "{% for name in names|unknown %}{{ name }}{% endfor %}" 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") { $0.it("reports syntax error in for-where tag") {
template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}" 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") { $0.it("reports syntax error in if tag") {
template = "{% if name|unknown %}{{ name }}{% endif %}" 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") { $0.it("reports syntax error in elif tag") {
template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}" 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") { $0.it("reports syntax error in ifnot tag") {
template = "{% ifnot name|unknown %}{{ name }}{% endif %}" 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") { $0.it("reports syntax error in filter tag") {
template = "{% filter unknown %}Text{% endfilter %}" 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") { $0.it("reports syntax error in variable tag") {
template = "{{ name|unknown }}" 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 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) var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
expectedError.stackTrace = [expectedSyntaxError(token: includedToken, template: includedTemplate, description: reason).token!] expectedError.stackTrace = [expectedSyntaxError(token: includedToken, template: includedTemplate, description: reason).token!]
let error = try expect(environment.render(template: template, context: ["target": "World"])) let error = try expect(environment.render(template: template, context: ["target": "World"]),
.toThrow() as TemplateSyntaxError file: file, line: line, function: function).toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError) try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError)
} }
$0.it("reports syntax error in included template") { $0.it("reports syntax error in included template") {
template = Template(templateString: "{% include \"invalid-include.html\" %}", environment: environment) template = Template(templateString: "{% include \"invalid-include.html\" %}", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html") 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\"", token: "include \"invalid-include.html\"",
includedToken: "target|unknown") includedToken: "target|unknown")
} }
@@ -248,21 +251,22 @@ func testEnvironment() {
baseTemplate = nil 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) var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
if let baseToken = baseToken { if let baseToken = baseToken {
expectedError.stackTrace = [expectedSyntaxError(token: baseToken, template: baseTemplate, description: reason).token!] expectedError.stackTrace = [expectedSyntaxError(token: baseToken, template: baseTemplate, description: reason).token!]
} }
let error = try expect(environment.render(template: childTemplate, context: ["target": "World"])) let error = try expect(environment.render(template: childTemplate, context: ["target": "World"]),
.toThrow() as TemplateSyntaxError file: file, line: line, function: function).toThrow() as TemplateSyntaxError
try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError) try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError)
} }
$0.it("reports syntax error in base template") { $0.it("reports syntax error in base template") {
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html") childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.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\"", childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown") baseToken: "target|unknown")
} }
@@ -286,7 +290,7 @@ func testEnvironment() {
childTemplate = Template(templateString: "{% extends \"base.html\" %}\n" + childTemplate = Template(templateString: "{% extends \"base.html\" %}\n" +
"{% block body %}Child {{ target|unknown }}{% endblock %}", environment: environment, name: nil) "{% 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", childToken: "target|unknown",
baseToken: nil) baseToken: nil)
} }
@@ -311,7 +315,7 @@ func testEnvironment() {
} }
} }
private extension Expectation { extension Expectation {
@discardableResult @discardableResult
func toThrow<T: Error>() throws -> T { func toThrow<T: Error>() throws -> T {
var thrownError: Error? = nil 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": 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": ["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": "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") { $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": 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": ["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": "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") { $0.it("allows whitespace in expression") {
let template = Template(templateString: "{{ name | uppercase }}") let template = Template(templateString: "{{ value | join : \", \" }}")
let result = try template.render(Context(dictionary: ["name": "kyle"])) let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "KYLE" try expect(result) == "One, Two"
} }
$0.it("throws when you pass arguments to simple filter") { $0.it("throws when you pass arguments to simple filter") {
@@ -89,32 +89,45 @@ func testFilter() {
} }
} }
describe("string filters") {
$0.context("given string") {
$0.it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ name|capitalize }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "Kyle"
}
describe("capitalize filter") { $0.it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ name|capitalize }}") let template = Template(templateString: "{{ name|uppercase }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "KYLE"
}
$0.it("capitalizes a string") { $0.it("transforms a string to be lowercase") {
let result = try template.render(Context(dictionary: ["name": "kyle"])) let template = Template(templateString: "{{ name|lowercase }}")
try expect(result) == "Kyle" 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\"]"
}
describe("uppercase filter") { $0.it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ name|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 uppercase") { $0.it("transforms a string to be lowercase") {
let result = try template.render(Context(dictionary: ["name": "kyle"])) let template = Template(templateString: "{{ names|lowercase }}")
try expect(result) == "KYLE" let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
} try expect(result) == "[\"kyle\", \"kyle\"]"
} }
describe("lowercase filter") {
let template = Template(templateString: "{{ name|lowercase }}")
$0.it("transforms a string to be lowercase") {
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
try expect(result) == "kyle"
} }
} }
@@ -183,4 +196,96 @@ func testFilter() {
try expect(result) == "OneTwo" 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() 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": [ "dict": [
"one": "I", "one": "I",
"two": "II", "two": "II",
] ],
"tuples": [(1, 2, 3), (4, 5, 6)]
]) ])
$0.it("renders the given nodes for each item") { $0.it("renders the given nodes for each item") {
@@ -89,6 +90,12 @@ func testForNode() {
try expect(try node.render(context)) == "102132" 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") { $0.it("renders the given nodes while filtering items using where expression") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")] 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)) 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" try expect(try node.render(context)) == "empty"
} }
$0.it("can render a filter") { $0.it("can render a filter with spaces") {
let templateString = "{% for article in ars|default:articles %}" + let templateString = "{% for article in ars | default: a, b , articles %}" +
"- {{ article.title }} by {{ article.author }}.\n" + "- {{ article.title }} by {{ article.author }}.\n" +
"{% endfor %}\n" "{% endfor %}\n"
@@ -127,8 +134,55 @@ func testForNode() {
try expect(result) == fixture 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") { $0.it("can iterate over dictionary") {
let templateString = "{% for key,value in dict %}" + let templateString = "{% for key, value in dict %}" +
"{{ key }}: {{ value }}," + "{{ key }}: {{ value }}," +
"{% endfor %}" "{% endfor %}"
@@ -169,12 +223,92 @@ func testForNode() {
} }
$0.it("handles invalid input") { $0.it("handles invalid input") {
let tokens: [Token] = [.block(value: "for i", at: .unknown)] let token = Token.block(value: "for i", at: .unknown)
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: [token], environment: Environment())
let error = TemplateSyntaxError(reason: "'for' statements should use the following syntax 'for x in y where condition'.", token: tokens.first) 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) 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()])) let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
try expect(result) == "" 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 tokens: [Token] = [ .block(value: "include", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment()) 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) try expect(try parser.parse()).toThrow(error)
} }
@@ -56,6 +56,13 @@ func testInclude() {
let value = try node.render(context) let value = try node.render(context)
try expect(value) == "Hello World!" 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 = Lexer(templateString: "{{}}")
let _ = lexer.tokenize() 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) #if os(OSX)
@objc class Superclass: NSObject { @objc class Superclass: NSObject {
let name = "Foo" @objc let name = "Foo"
} }
@objc class Object : Superclass { @objc class Object : Superclass {
let title = "Hello World" @objc let title = "Hello World"
} }
#endif #endif
@@ -26,6 +26,7 @@ fileprivate class WebSite {
fileprivate class Blog: WebSite { fileprivate class Blog: WebSite {
let articles: [Article] = [Article(author: Person(name: "Kyle"))] let articles: [Article] = [Article(author: Person(name: "Kyle"))]
let featuring: Article? = Article(author: Person(name: "Jhon"))
} }
func testVariable() { func testVariable() {
@@ -39,7 +40,8 @@ func testVariable() {
"counter": [ "counter": [
"count": "kylef", "count": "kylef",
], ],
"article": Article(author: Person(name: "Kyle")) "article": Article(author: Person(name: "Kyle")),
"tuple": (one: 1, two: 2)
]) ])
#if os(OSX) #if os(OSX)
@@ -71,6 +73,13 @@ func testVariable() {
try expect(result) == 3.14 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") { $0.it("can resolve a string variable") {
let variable = Variable("name") let variable = Variable("name")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
@@ -140,7 +149,7 @@ func testVariable() {
try expect(result) == "Foo" try expect(result) == "Foo"
} }
#endif #endif
$0.it("can resolve a value via reflection") { $0.it("can resolve a value via reflection") {
let variable = Variable("blog.articles.0.author.name") let variable = Variable("blog.articles.0.author.name")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
@@ -153,5 +162,171 @@ func testVariable() {
try expect(result) == "blog.com" 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

@@ -28,6 +28,18 @@ The ``for`` tag can iterate over dictionaries.
<li>{{ key }}: {{ value }}</li> <li>{{ key }}: {{ value }}</li>
{% endfor %} {% endfor %}
</ul> </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 The ``for`` tag can contain optional ``where`` expression to filter out
elements on which this expression evaluates to false. 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 - ``last`` - True if this is the last time through the loop
- ``counter`` - The current iteration of the loop (1 indexed) - ``counter`` - The current iteration of the loop (1 indexed)
- ``counter0`` - The current iteration of the loop (0 indexed) - ``counter0`` - The current iteration of the loop (0 indexed)
- ``length`` - The total length of the loop
For example: For example:
@@ -124,7 +137,7 @@ or to negate a variable.
{% endif %} {% endif %}
You may use ``and``, ``or`` and ``not`` multiple times together. ``not`` has 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 .. code-block:: html+django
@@ -247,6 +260,12 @@ You can include another template using the `include` tag.
{% include "comment.html" %} {% 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 `include` tag requires you to provide a loader which will be used to lookup
the template. the template.
@@ -281,7 +300,7 @@ Built-in Filters
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
The capitalize filter allows you to capitalize a string. 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 .. code-block:: html+django
@@ -291,7 +310,7 @@ For example, `stencil` to `Stencil`.
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to uppercase. 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 .. code-block:: html+django
@@ -301,7 +320,7 @@ For example, `Stencil` to `STENCIL`.
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to lowercase. 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 .. code-block:: html+django
@@ -326,4 +345,31 @@ Join an array of items.
{{ value|join:", " }} {{ 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 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
~~~~~~~ ~~~~~~~