Merge branch 'master' into dynamic-filter

# Conflicts:
#	CHANGELOG.md
#	Sources/ForTag.swift
#	Sources/IfTag.swift
#	Sources/Parser.swift
#	Sources/Variable.swift
#	Tests/StencilTests/ExpressionSpec.swift
#	Tests/StencilTests/FilterSpec.swift
#	Tests/StencilTests/ForNodeSpec.swift
#	Tests/StencilTests/VariableSpec.swift
This commit is contained in:
Ilya Puchka
2018-10-01 21:21:56 +01:00
59 changed files with 4589 additions and 2113 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.conche/ .conche/
.build/ .build/
Packages/ Packages/
Package.resolved
Package.pins Package.pins
*.xcodeproj

View File

@@ -1,18 +1,15 @@
matrix: matrix:
include: include:
- os: osx - os: osx
osx_image: xcode8.3 osx_image: xcode9.4
env: SWIFT_VERSION=3.1.1 env: SWIFT_VERSION=4.1
- os: osx - os: osx
osx_image: xcode9 osx_image: xcode10
env: SWIFT_VERSION=4.0 env: SWIFT_VERSION=4.2
- os: osx
osx_image: xcode9.1
env: SWIFT_VERSION=4.0
- os: linux - os: linux
env: SWIFT_VERSION=3.1.1 env: SWIFT_VERSION=4.1
- os: linux - os: linux
env: SWIFT_VERSION=4.0 env: SWIFT_VERSION=4.2
language: generic language: generic
sudo: required sudo: required
dist: trusty dist: trusty

View File

@@ -2,6 +2,100 @@
## Master ## Master
### Breaking
_None_
### Enhancements
- Added support for dynamic filter using `filter` filter. With that you can define a variable with a name of filter
, i.e. `myfilter = "uppercase"` and then use it to invoke this filter with `{{ string|filter:myfilter }}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#203](https://github.com/stencilproject/Stencil/pull/203)
### Deprecations
_None_
### Bug Fixes
_None_
### Internal Changes
_None_
## 0.13.1
### Bug Fixes
- Fixed a bug in Stencil 0.13 where tags without spaces were incorrectly parsed.
[David Jennes](https://github.com/djbe)
[#252](https://github.com/stencilproject/Stencil/pull/252)
## 0.13.0
### Breaking
- Now requires Swift 4.1 or newer.
[Yonas Kolb](https://github.com/yonaskolb)
[#228](https://github.com/stencilproject/Stencil/pull/228)
### Enhancements
- You can now use parentheses in boolean expressions to change operator precedence.
[Ilya Puchka](https://github.com/ilyapuchka)
[#165](https://github.com/stencilproject/Stencil/pull/165)
- Added method to add boolean filters with their negative counterparts.
[Ilya Puchka](https://github.com/ilyapuchka)
[#160](https://github.com/stencilproject/Stencil/pull/160)
- Now you can conditionally render variables with `{{ variable if condition }}`, which is a shorthand for `{% if condition %}{{ variable }}{% endif %}`. You can also use `else` like `{{ variable1 if condition else variable2 }}`, which is a shorthand for `{% if condition %}{{ variable1 }}{% else %}{{ variable2 }}{% endif %}`
[Ilya Puchka](https://github.com/ilyapuchka)
[#243](https://github.com/stencilproject/Stencil/pull/243)
- Now you can access string characters by index or get string length the same was as if it was an array, i.e. `{{ 'string'.first }}`, `{{ 'string'.last }}`, `{{ 'string'.1 }}`, `{{ 'string'.count }}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#245](https://github.com/stencilproject/Stencil/pull/245)
### Bug Fixes
- Fixed the performance issues introduced in Stencil 0.12 with the error log improvements.
[Ilya Puchka](https://github.com/ilyapuchka)
[#230](https://github.com/stencilproject/Stencil/pull/230)
- Now accessing undefined keys in NSObject does not cause runtime crash and instead renders empty string.
[Ilya Puchka](https://github.com/ilyapuchka)
[#234](https://github.com/stencilproject/Stencil/pull/234)
- `for` tag: When iterating over a dictionary the keys will now always be sorted (in an ascending order) to ensure consistent output generation.
[David Jennes](https://github.com/djbe)
[#240](https://github.com/stencilproject/Stencil/pull/240)
### Internal Changes
- Updated the codebase to use Swift 4 features.
[David Jennes](https://github.com/djbe)
[#239](https://github.com/stencilproject/Stencil/pull/239)
- Update to Spectre 0.9.0.
[Ilya Puchka](https://github.com/ilyapuchka)
[#247](https://github.com/stencilproject/Stencil/pull/247)
- Optimise Scanner performance.
[Eric Thorpe](https://github.com/trametheka)
[Sébastien Duperron](https://github.com/Liquidsoul)
[David Jennes](https://github.com/djbe)
[#226](https://github.com/stencilproject/Stencil/pull/226)
## 0.12.1
### Internal Changes
- Updated the PathKit dependency to 0.9.0 in CocoaPods, to be in line with SPM.
[David Jennes](https://github.com/djbe)
[#227](https://github.com/stencilproject/Stencil/pull/227)
## 0.12.0
### Enhancements ### Enhancements
- Added an optional second parameter to the `include` tag for passing a sub context to the included file. - Added an optional second parameter to the `include` tag for passing a sub context to the included file.
@@ -14,10 +108,9 @@
- Adds support for using spaces in filter expression. - Adds support for using spaces in filter expression.
[Ilya Puchka](https://github.com/ilyapuchka) [Ilya Puchka](https://github.com/ilyapuchka)
[#178](https://github.com/stencilproject/Stencil/pull/178) [#178](https://github.com/stencilproject/Stencil/pull/178)
- Added support for dynamic filter using `filter` filter. With that you can define a variable with a name of filter - Improvements in error reporting.
, i.e. `myfilter = "uppercase"` and then use it to invoke this filter with `{{ string|filter:myfilter }}`.
[Ilya Puchka](https://github.com/ilyapuchka) [Ilya Puchka](https://github.com/ilyapuchka)
[#203](https://github.com/stencilproject/Stencil/pull/203) [#167](https://github.com/stencilproject/Stencil/pull/167)
### Bug Fixes ### Bug Fixes

View File

@@ -1,4 +1,4 @@
Copyright (c) 2014, Kyle Fuller Copyright (c) 2018, Kyle Fuller
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
@@ -21,4 +21,3 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

25
Package.resolved Normal file
View File

@@ -0,0 +1,25 @@
{
"object": {
"pins": [
{
"package": "PathKit",
"repositoryURL": "https://github.com/kylef/PathKit.git",
"state": {
"branch": null,
"revision": "e2f5be30e4c8f531c9c1e8765aa7b71c0a45d7a0",
"version": "0.9.2"
}
},
{
"package": "Spectre",
"repositoryURL": "https://github.com/kylef/Spectre.git",
"state": {
"branch": null,
"revision": "f14ff47f45642aa5703900980b014c2e9394b6e5",
"version": "0.9.0"
}
}
]
},
"version": 1
}

View File

@@ -1,10 +1,22 @@
// swift-tools-version:3.1 // swift-tools-version:4.1
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "Stencil", name: "Stencil",
products: [
.library(name: "Stencil", targets: ["Stencil"])
],
dependencies: [ dependencies: [
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 9), .package(url: "https://github.com/kylef/PathKit.git", from: "0.9.0"),
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 8), .package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
],
targets: [
.target(name: "Stencil", dependencies: [
"PathKit"
], path: "Sources"),
.testTarget(name: "StencilTests", dependencies: [
"Stencil",
"Spectre"
])
] ]
) )

View File

@@ -1,10 +0,0 @@
// 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),
]
)

23
Package@swift-4.2.swift Normal file
View File

@@ -0,0 +1,23 @@
// swift-tools-version:4.2
import PackageDescription
let package = Package(
name: "Stencil",
products: [
.library(name: "Stencil", targets: ["Stencil"])
],
dependencies: [
.package(url: "https://github.com/kylef/PathKit.git", from: "0.9.0"),
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
],
targets: [
.target(name: "Stencil", dependencies: [
"PathKit"
], path: "Sources"),
.testTarget(name: "StencilTests", dependencies: [
"Stencil",
"Spectre"
])
],
swiftLanguageVersions: [.v4, .v4_2]
)

View File

@@ -4,7 +4,10 @@ public struct Environment {
public var loader: Loader? public var loader: Loader?
public init(loader: Loader? = nil, extensions: [Extension]? = nil, templateClass: Template.Type = Template.self) { public init(loader: Loader? = nil,
extensions: [Extension]? = nil,
templateClass: Template.Type = Template.self) {
self.templateClass = templateClass self.templateClass = templateClass
self.loader = loader self.loader = loader
self.extensions = (extensions ?? []) + [DefaultExtension()] self.extensions = (extensions ?? []) + [DefaultExtension()]
@@ -28,11 +31,18 @@ public struct Environment {
public func renderTemplate(name: String, context: [String: Any]? = nil) throws -> String { public func renderTemplate(name: String, context: [String: Any]? = nil) throws -> String {
let template = try loadTemplate(name: name) let template = try loadTemplate(name: name)
return try template.render(context) return try render(template: template, context: context)
} }
public func renderTemplate(string: String, context: [String: Any]? = nil) throws -> String { public func renderTemplate(string: String, context: [String: Any]? = nil) throws -> String {
let template = templateClass.init(templateString: string, environment: self) let template = templateClass.init(templateString: string, environment: self)
return try render(template: template, context: context)
}
func render(template: Template, context: [String: Any]?) throws -> String {
// update template environment as it can be created from string literal with default environment
template.environment = self
return try template.render(context) return try template.render(context)
} }
} }

View File

@@ -17,3 +17,67 @@ public class TemplateDoesNotExist: Error, CustomStringConvertible {
return "Template named `\(templates)` does not exist. No loaders found" return "Template named `\(templates)` does not exist. No loaders found"
} }
} }
public struct TemplateSyntaxError : Error, Equatable, CustomStringConvertible {
public let reason: String
public var description: String { return reason }
public internal(set) var token: Token?
public internal(set) var stackTrace: [Token]
public var templateName: String? { return token?.sourceMap.filename }
var allTokens: [Token] {
return stackTrace + (token.map({ [$0] }) ?? [])
}
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
self.reason = reason
self.stackTrace = stackTrace
self.token = token
}
public init(_ description: String) {
self.init(reason: description)
}
}
extension Error {
func withToken(_ token: Token?) -> Error {
if var error = self as? TemplateSyntaxError {
error.token = error.token ?? token
return error
} else {
return TemplateSyntaxError(reason: "\(self)", token: token)
}
}
}
public protocol ErrorReporter: class {
func renderError(_ error: Error) -> String
}
open class SimpleErrorReporter: ErrorReporter {
open func renderError(_ error: Error) -> String {
guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
func describe(token: Token) -> String {
let templateName = token.sourceMap.filename ?? ""
let location = token.sourceMap.location
let highlight = """
\(String(Array(repeating: " ", count: location.lineOffset)))\
^\(String(Array(repeating: "~", count: max(token.contents.count - 1, 0))))
"""
return """
\(templateName)\(location.lineNumber):\(location.lineOffset): error: \(templateError.reason)
\(location.content)
\(highlight)
"""
}
var descriptions = templateError.stackTrace.reduce([]) { $0 + [describe(token: $1)] }
let description = templateError.token.map(describe(token:)) ?? templateError.reason
descriptions.append(description)
return descriptions.joined(separator: "\n")
}
}

View File

@@ -15,10 +15,19 @@ open class Extension {
/// Registers a simple template tag with a name and a handler /// Registers a simple template tag with a name and a handler
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) { public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
registerTag(name, parser: { parser, token in registerTag(name, parser: { parser, token in
return SimpleNode(handler: handler) return SimpleNode(token: token, handler: handler)
}) })
} }
/// Registers boolean filter with it's negative counterpart
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
filters[name] = .simple(filter)
filters[negativeFilterName] = .simple {
guard let result = try filter($0) else { return nil }
return !result
}
}
/// Registers a template filter with the given name /// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) { public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
filters[name] = .simple(filter) filters[name] = .simple(filter)
@@ -46,9 +55,9 @@ class DefaultExtension: Extension {
registerTag("for", parser: ForNode.parse) registerTag("for", parser: ForNode.parse)
registerTag("if", parser: IfNode.parse) registerTag("if", parser: IfNode.parse)
registerTag("ifnot", parser: IfNode.parse_ifnot) registerTag("ifnot", parser: IfNode.parse_ifnot)
#if !os(Linux) #if !os(Linux)
registerTag("now", parser: NowNode.parse) registerTag("now", parser: NowNode.parse)
#endif #endif
registerTag("include", parser: IncludeNode.parse) registerTag("include", parser: IncludeNode.parse)
registerTag("extends", parser: ExtendsNode.parse) registerTag("extends", parser: ExtendsNode.parse)
registerTag("block", parser: BlockNode.parse) registerTag("block", parser: BlockNode.parse)

View File

@@ -1,6 +1,7 @@
class FilterNode : NodeType { class FilterNode : NodeType {
let resolvable: Resolvable let resolvable: Resolvable
let nodes: [NodeType] let nodes: [NodeType]
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()
@@ -15,20 +16,21 @@ class FilterNode : NodeType {
throw TemplateSyntaxError("`endfilter` was not found.") throw TemplateSyntaxError("`endfilter` was not found.")
} }
let resolvable = try parser.compileFilter("filter_value|\(bits[1])") let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
return FilterNode(nodes: blocks, resolvable: resolvable) return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
} }
init(nodes: [NodeType], resolvable: Resolvable) { init(nodes: [NodeType], resolvable: Resolvable, token: Token) {
self.nodes = nodes self.nodes = nodes
self.resolvable = resolvable self.resolvable = resolvable
self.token = token
} }
func render(_ context: Context) throws -> String { func render(_ context: Context) throws -> String {
let value = try renderNodes(nodes, context) let value = try renderNodes(nodes, context)
return try context.push(dictionary: ["filter_value": value]) { return try context.push(dictionary: ["filter_value": value]) {
return try VariableNode(variable: resolvable).render(context) return try VariableNode(variable: resolvable, token: token).render(context)
} }
} }
} }

View File

@@ -74,7 +74,9 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
var indentWidth = 4 var indentWidth = 4
if arguments.count > 0 { if arguments.count > 0 {
guard let value = arguments[0] as? Int else { guard let value = arguments[0] as? Int else {
throw TemplateSyntaxError("'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))") throw TemplateSyntaxError("""
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
""")
} }
indentWidth = value indentWidth = value
} }
@@ -82,7 +84,9 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
var indentationChar = " " var indentationChar = " "
if arguments.count > 1 { if arguments.count > 1 {
guard let value = arguments[1] as? String else { guard let value = arguments[1] as? String else {
throw TemplateSyntaxError("'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))") throw TemplateSyntaxError("""
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
""")
} }
indentationChar = value indentationChar = value
} }

View File

@@ -6,6 +6,7 @@ class ForNode : NodeType {
let nodes:[NodeType] let nodes:[NodeType]
let emptyNodes: [NodeType] let emptyNodes: [NodeType]
let `where`: Expression? let `where`: Expression?
let token: Token?
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()
@@ -13,20 +14,25 @@ class ForNode : NodeType {
func hasToken(_ token: String, at index: Int) -> Bool { func hasToken(_ token: String, at index: Int) -> Bool {
return components.count > (index + 1) && components[index] == token return components.count > (index + 1) && components[index] == token
} }
func endsOrHasToken(_ token: String, at index: Int) -> Bool { func endsOrHasToken(_ token: String, at index: Int) -> Bool {
return components.count == index || hasToken(token, at: index) return components.count == index || hasToken(token, at: index)
} }
guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else { guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
throw TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]") throw TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.")
} }
let loopVariables = components[1].characters let loopVariables = components[1]
.split(separator: ",") .split(separator: ",")
.map(String.init) .map(String.init)
.map { $0.trim(character: " ") } .map { $0.trim(character: " ") }
var emptyNodes = [NodeType]() let resolvable = try parser.compileResolvable(components[3], containedIn: token)
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"])) let forNodes = try parser.parse(until(["endfor", "empty"]))
@@ -34,26 +40,22 @@ class ForNode : NodeType {
throw TemplateSyntaxError("`endfor` was not found.") throw TemplateSyntaxError("`endfor` was not found.")
} }
var emptyNodes = [NodeType]()
if token.contents == "empty" { if token.contents == "empty" {
emptyNodes = try parser.parse(until(["endfor"])) emptyNodes = try parser.parse(until(["endfor"]))
_ = parser.nextToken() _ = parser.nextToken()
} }
let resolvable = try parser.compileResolvable(components[3]) return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes: emptyNodes, where: `where`, token: token)
let `where` = hasToken("where", at: 4)
? try parser.compileExpression(components: Array(components.suffix(from: 5)))
: nil
return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`)
} }
init(resolvable: Resolvable, loopVariables: [String], nodes:[NodeType], emptyNodes:[NodeType], where: Expression? = nil) { init(resolvable: Resolvable, loopVariables: [String], nodes: [NodeType], emptyNodes: [NodeType], where: Expression? = nil, token: Token? = nil) {
self.resolvable = resolvable self.resolvable = resolvable
self.loopVariables = loopVariables self.loopVariables = loopVariables
self.nodes = nodes self.nodes = nodes
self.emptyNodes = emptyNodes self.emptyNodes = emptyNodes
self.where = `where` self.where = `where`
self.token = token
} }
func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result { func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
@@ -91,7 +93,7 @@ class ForNode : NodeType {
var values: [Any] var values: [Any]
if let dictionary = resolved as? [String: Any], !dictionary.isEmpty { if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
values = dictionary.map { ($0.key, $0.value) } values = dictionary.sorted { $0.key < $1.key }
} else if let array = resolved as? [Any] { } else if let array = resolved as? [Any] {
values = array values = array
} else if let range = resolved as? CountableClosedRange<Int> { } else if let range = resolved as? CountableClosedRange<Int> {

View File

@@ -38,10 +38,11 @@ func findOperator(name: String) -> Operator? {
} }
enum IfToken { indirect enum IfToken {
case infix(name: String, bindingPower: Int, op: InfixOperator.Type) case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
case prefix(name: String, bindingPower: Int, op: PrefixOperator.Type) case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
case variable(Resolvable) case variable(Resolvable)
case subExpression(Expression)
case end case end
var bindingPower: Int { var bindingPower: Int {
@@ -52,6 +53,8 @@ enum IfToken {
return bindingPower return bindingPower
case .variable(_): case .variable(_):
return 0 return 0
case .subExpression(_):
return 0
case .end: case .end:
return 0 return 0
} }
@@ -66,6 +69,8 @@ enum IfToken {
return op.init(expression: expression) return op.init(expression: expression)
case .variable(let variable): case .variable(let variable):
return VariableExpression(variable: variable) return VariableExpression(variable: variable)
case .subExpression(let expression):
return expression
case .end: case .end:
throw TemplateSyntaxError("'if' expression error: end") throw TemplateSyntaxError("'if' expression error: end")
} }
@@ -80,6 +85,8 @@ enum IfToken {
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side") throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
case .variable(let variable): case .variable(let variable):
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side") throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
case .subExpression(_):
throw TemplateSyntaxError("'if' expression error: sub expression was called with a left hand side")
case .end: case .end:
throw TemplateSyntaxError("'if' expression error: end") throw TemplateSyntaxError("'if' expression error: end")
} }
@@ -100,19 +107,69 @@ final class IfExpressionParser {
let tokens: [IfToken] let tokens: [IfToken]
var position: Int = 0 var position: Int = 0
init(components: [String], environment: Environment) throws { private init(tokens: [IfToken]) {
self.tokens = try components.map { component in self.tokens = tokens
}
static func parser(components: [String], tokenParser: TokenParser, token: Token) throws -> IfExpressionParser {
return try IfExpressionParser(components: ArraySlice(components), tokenParser: tokenParser, token: token)
}
private init(components: ArraySlice<String>, tokenParser: TokenParser, token: Token) throws {
var parsedComponents = Set<Int>()
var bracketsBalance = 0
self.tokens = try zip(components.indices, components).compactMap { (index, component) in
guard !parsedComponents.contains(index) else { return nil }
if component == "(" {
bracketsBalance += 1
let (expression, parsedCount) = try IfExpressionParser.subExpression(
from: components.suffix(from: index + 1),
tokenParser: tokenParser,
token: token
)
parsedComponents.formUnion(Set(index...(index + parsedCount)))
return .subExpression(expression)
} else if component == ")" {
bracketsBalance -= 1
if bracketsBalance < 0 {
throw TemplateSyntaxError("'if' expression error: missing opening bracket")
}
parsedComponents.insert(index)
return nil
} else {
parsedComponents.insert(index)
if let op = findOperator(name: component) { if let op = findOperator(name: component) {
switch op { switch op {
case .infix(let name, let bindingPower, let cls): case .infix(let name, let bindingPower, let operatorType):
return .infix(name: name, bindingPower: bindingPower, op: cls) return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
case .prefix(let name, let bindingPower, let cls): case .prefix(let name, let bindingPower, let operatorType):
return .prefix(name: name, bindingPower: bindingPower, op: cls) return .prefix(name: name, bindingPower: bindingPower, operatorType: operatorType)
}
}
return .variable(try tokenParser.compileResolvable(component, containedIn: token))
}
} }
} }
return .variable(try environment.compileResolvable(component)) private static func subExpression(from components: ArraySlice<String>, tokenParser: TokenParser, token: Token) throws -> (Expression, Int) {
var bracketsBalance = 1
let subComponents = components
.prefix(while: {
if $0 == "(" {
bracketsBalance += 1
} else if $0 == ")" {
bracketsBalance -= 1
} }
return bracketsBalance != 0
})
if bracketsBalance > 0 {
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
}
let expressionParser = try IfExpressionParser(components: subComponents, tokenParser: tokenParser, token: token)
let expression = try expressionParser.parse()
return (expression, subComponents.count)
} }
var currentToken: IfToken { var currentToken: IfToken {
@@ -176,49 +233,51 @@ final class IfCondition {
class IfNode : NodeType { class IfNode : NodeType {
let conditions: [IfCondition] let conditions: [IfCondition]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components() var components = token.components()
components.removeFirst() components.removeFirst()
let expression = try parser.compileExpression(components: components) let expression = try parseExpression(components: components, tokenParser: parser, token: token)
let nodes = try parser.parse(until(["endif", "elif", "else"])) let nodes = try parser.parse(until(["endif", "elif", "else"]))
var conditions: [IfCondition] = [ var conditions: [IfCondition] = [
IfCondition(expression: expression, nodes: nodes) IfCondition(expression: expression, nodes: nodes)
] ]
var token = parser.nextToken() var nextToken = parser.nextToken()
while let current = token, current.contents.hasPrefix("elif") { while let current = nextToken, current.contents.hasPrefix("elif") {
var components = current.components() var components = current.components()
components.removeFirst() components.removeFirst()
let expression = try parser.compileExpression(components: components) let expression = try parseExpression(components: components, tokenParser: parser, token: current)
let nodes = try parser.parse(until(["endif", "elif", "else"])) let nodes = try parser.parse(until(["endif", "elif", "else"]))
token = parser.nextToken() nextToken = parser.nextToken()
conditions.append(IfCondition(expression: expression, nodes: nodes)) conditions.append(IfCondition(expression: expression, nodes: nodes))
} }
if let current = token, current.contents == "else" { if let current = nextToken, current.contents == "else" {
conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"])))) conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"]))))
token = parser.nextToken() nextToken = parser.nextToken()
} }
guard let current = token, current.contents == "endif" else { guard let current = nextToken, current.contents == "endif" else {
throw TemplateSyntaxError("`endif` was not found.") throw TemplateSyntaxError("`endif` was not found.")
} }
return IfNode(conditions: conditions) return IfNode(conditions: conditions, token: token)
} }
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType { class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components() var components = token.components()
guard components.count == 2 else { guard components.count == 2 else {
throw TemplateSyntaxError("'ifnot' statements should use the following 'ifnot condition' `\(token.contents)`.") throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.")
} }
components.removeFirst() components.removeFirst()
var trueNodes = [NodeType]() var trueNodes = [NodeType]()
var falseNodes = [NodeType]() var falseNodes = [NodeType]()
let expression = try parseExpression(components: components, tokenParser: parser, token: token)
falseNodes = try parser.parse(until(["endif", "else"])) falseNodes = try parser.parse(until(["endif", "else"]))
guard let token = parser.nextToken() else { guard let token = parser.nextToken() else {
@@ -230,15 +289,15 @@ class IfNode : NodeType {
_ = parser.nextToken() _ = parser.nextToken()
} }
let expression = try parser.compileExpression(components: components)
return IfNode(conditions: [ return IfNode(conditions: [
IfCondition(expression: expression, nodes: trueNodes), IfCondition(expression: expression, nodes: trueNodes),
IfCondition(expression: nil, nodes: falseNodes), IfCondition(expression: nil, nodes: falseNodes),
]) ], token: token)
} }
init(conditions: [IfCondition]) { init(conditions: [IfCondition], token: Token? = nil) {
self.conditions = conditions self.conditions = conditions
self.token = token
} }
func render(_ context: Context) throws -> String { func render(_ context: Context) throws -> String {

View File

@@ -4,20 +4,26 @@ import PathKit
class IncludeNode : NodeType { class IncludeNode : NodeType {
let templateName: Variable let templateName: Variable
let includeContext: String? let includeContext: String?
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 || bits.count == 3 else { 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") 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]), includeContext: bits.count == 3 ? bits[2] : nil) return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
} }
init(templateName: Variable, includeContext: String? = nil) { init(templateName: Variable, includeContext: String? = nil, token: Token) {
self.templateName = templateName self.templateName = templateName
self.includeContext = includeContext self.includeContext = includeContext
self.token = token
} }
func render(_ context: Context) throws -> String { func render(_ context: Context) throws -> String {
@@ -27,10 +33,18 @@ class IncludeNode : NodeType {
let template = try context.environment.loadTemplate(name: templateName) let template = try context.environment.loadTemplate(name: templateName)
do {
let subContext = includeContext.flatMap { context[$0] as? [String: Any] } let subContext = includeContext.flatMap { context[$0] as? [String: Any] }
return try context.push(dictionary: subContext) { return try context.push(dictionary: subContext) {
return try template.render(context) return try template.render(context)
} }
} catch {
if let error = error as? TemplateSyntaxError {
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
} else {
throw error
}
}
} }
} }

View File

@@ -1,13 +1,12 @@
class BlockContext { class BlockContext {
class var contextKey: String { return "block_context" } class var contextKey: String { return "block_context" }
// contains mapping of block names to their nodes and templates where they are defined
var blocks: [String: [BlockNode]] var blocks: [String: [BlockNode]]
init(blocks: [String: BlockNode]) { init(blocks: [String: BlockNode]) {
self.blocks = [:] self.blocks = [:]
blocks.forEach { (key, value) in blocks.forEach { self.blocks[$0.key] = [$0.value] }
self.blocks[key] = [value]
}
} }
func push(_ block: BlockNode, forKey blockName: String) { func push(_ block: BlockNode, forKey blockName: String) {
@@ -51,6 +50,7 @@ extension Collection {
class ExtendsNode : NodeType { class ExtendsNode : NodeType {
let templateName: Variable let templateName: Variable
let blocks: [String:BlockNode] let blocks: [String:BlockNode]
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()
@@ -64,7 +64,7 @@ class ExtendsNode : NodeType {
throw TemplateSyntaxError("'extends' cannot appear more than once in the same template") throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
} }
let blockNodes = parsedNodes.flatMap { $0 as? BlockNode } let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
let nodes = blockNodes.reduce([String: BlockNode]()) { (accumulator, node) -> [String: BlockNode] in let nodes = blockNodes.reduce([String: BlockNode]()) { (accumulator, node) -> [String: BlockNode] in
var dict = accumulator var dict = accumulator
@@ -72,12 +72,13 @@ class ExtendsNode : NodeType {
return dict return dict
} }
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes) return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
} }
init(templateName: Variable, blocks: [String: BlockNode]) { init(templateName: Variable, blocks: [String: BlockNode], token: Token) {
self.templateName = templateName self.templateName = templateName
self.blocks = blocks self.blocks = blocks
self.token = token
} }
func render(_ context: Context) throws -> String { func render(_ context: Context) throws -> String {
@@ -85,21 +86,33 @@ class ExtendsNode : NodeType {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string") throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
} }
let template = try context.environment.loadTemplate(name: templateName) let baseTemplate = try context.environment.loadTemplate(name: templateName)
let blockContext: BlockContext let blockContext: BlockContext
if let context = context[BlockContext.contextKey] as? BlockContext { if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext {
blockContext = context blockContext = currentBlockContext
for (name, block) in blocks {
for (key, value) in blocks { blockContext.push(block, forKey: name)
blockContext.push(value, forKey: key)
} }
} else { } else {
blockContext = BlockContext(blocks: blocks) blockContext = BlockContext(blocks: blocks)
} }
do {
// pushes base template and renders it's content
// block_context contains all blocks from child templates
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) { return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
return try template.render(context) return try baseTemplate.render(context)
}
} catch {
// if error template is already set (see catch in BlockNode)
// and it happend in the same template as current template
// there is no need to wrap it in another error
if let error = error as? TemplateSyntaxError, error.templateName != token?.sourceMap.filename {
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
} else {
throw error
}
} }
} }
} }
@@ -108,6 +121,7 @@ class ExtendsNode : NodeType {
class BlockNode : NodeType { class BlockNode : NodeType {
let name: String let name: String
let nodes: [NodeType] let nodes: [NodeType]
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()
@@ -119,25 +133,57 @@ class BlockNode : NodeType {
let blockName = bits[1] let blockName = bits[1]
let nodes = try parser.parse(until(["endblock"])) let nodes = try parser.parse(until(["endblock"]))
_ = parser.nextToken() _ = parser.nextToken()
return BlockNode(name:blockName, nodes:nodes) return BlockNode(name:blockName, nodes:nodes, token: token)
} }
init(name: String, nodes: [NodeType]) { init(name: String, nodes: [NodeType], token: Token) {
self.name = name self.name = name
self.nodes = nodes self.nodes = nodes
self.token = token
} }
func render(_ context: Context) throws -> String { func render(_ context: Context) throws -> String {
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let node = blockContext.pop(name) { if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
let newContext: [String: Any] = [ let childContext = try self.childContext(child, blockContext: blockContext, context: context)
BlockContext.contextKey: blockContext, // render extension node
"block": ["super": try self.render(context)] do {
] return try context.push(dictionary: childContext) {
return try context.push(dictionary: newContext) { return try child.render(context)
return try node.render(context) }
} catch {
throw error.withToken(child.token)
} }
} }
return try renderNodes(nodes, context) return try renderNodes(nodes, context)
} }
// child node is a block node from child template that extends this node (has the same name)
func childContext(_ child: BlockNode, blockContext: BlockContext, context: Context) throws -> [String: Any] {
var childContext: [String: Any] = [BlockContext.contextKey: blockContext]
if let blockSuperNode = child.nodes.first(where: {
if case .variable(let variable, _)? = $0.token, variable == "block.super" { return true }
else { return false}
}) {
do {
// render base node so that its content can be used as part of child node that extends it
childContext["block"] = ["super": try self.render(context)]
} catch {
if let error = error as? TemplateSyntaxError {
throw TemplateSyntaxError(
reason: error.reason,
token: blockSuperNode.token,
stackTrace: error.allTokens)
} else {
throw TemplateSyntaxError(
reason: "\(error)",
token: blockSuperNode.token,
stackTrace: [])
}
}
}
return childContext
}
} }

View File

@@ -24,7 +24,7 @@ final class KeyPath {
subscriptLevel = 0 subscriptLevel = 0
} }
for c in variable.characters { for c in variable {
switch c { switch c {
case "." where subscriptLevel == 0: case "." where subscriptLevel == 0:
try foundSeparator() try foundSeparator()

View File

@@ -1,16 +1,47 @@
struct Lexer { import Foundation
let templateString: String
init(templateString: String) { typealias Line = (content: String, number: UInt, range: Range<String.Index>)
struct Lexer {
let templateName: String?
let templateString: String
let lines: [Line]
/// The potential token start characters. In a template these appear after a
/// `{` character, for example `{{`, `{%`, `{#`, ...
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
/// The token end characters, corresponding to their token start characters.
/// For example, a variable token starts with `{{` and ends with `}}`
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
"{": "}",
"%": "%",
"#": "#"
]
init(templateName: String? = nil, templateString: String) {
self.templateName = templateName
self.templateString = templateString self.templateString = templateString
self.lines = templateString.components(separatedBy: .newlines).enumerated().compactMap {
guard !$0.element.isEmpty else { return nil }
return (content: $0.element, number: UInt($0.offset + 1), templateString.range(of: $0.element)!)
}
} }
func createToken(string: String) -> Token { /// Create a token that will be passed on to the parser, with the given
/// content and a range. The content will be tested to see if it's a
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
/// `text` token.
///
/// - Parameters:
/// - string: The content string of the token
/// - range: The range within the template content, used for smart
/// error reporting
func createToken(string: String, at range: Range<String.Index>) -> Token {
func strip() -> String { func strip() -> String {
guard string.characters.count > 4 else { return "" } guard string.count > 4 else { return "" }
let start = string.index(string.startIndex, offsetBy: 2) let trimmed = String(string.dropFirst(2).dropLast(2))
let end = string.index(string.endIndex, offsetBy: -2)
let trimmed = String(string[start..<end])
.components(separatedBy: "\n") .components(separatedBy: "\n")
.filter({ !$0.isEmpty }) .filter({ !$0.isEmpty })
.map({ $0.trim(character: " ") }) .map({ $0.trim(character: " ") })
@@ -18,112 +49,150 @@ struct Lexer {
return trimmed return trimmed
} }
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
let value = strip()
let range = templateString.range(of: value, range: range) ?? range
let location = rangeLocation(range)
let sourceMap = SourceMap(filename: templateName, location: location)
if string.hasPrefix("{{") { if string.hasPrefix("{{") {
return .variable(value: strip()) return .variable(value: value, at: sourceMap)
} else if string.hasPrefix("{%") { } else if string.hasPrefix("{%") {
return .block(value: strip()) return .block(value: value, at: sourceMap)
} else if string.hasPrefix("{#") { } else if string.hasPrefix("{#") {
return .comment(value: strip()) return .comment(value: value, at: sourceMap)
}
} }
return .text(value: string) let location = rangeLocation(range)
let sourceMap = SourceMap(filename: templateName, location: location)
return .text(value: string, at: sourceMap)
} }
/// Returns an array of tokens from a given template string. /// Transforms the template into a list of tokens, that will eventually be
/// passed on to the parser.
///
/// - Returns: The list of tokens (see `createToken(string: at:)`).
func tokenize() -> [Token] { func tokenize() -> [Token] {
var tokens: [Token] = [] var tokens: [Token] = []
let scanner = Scanner(templateString) let scanner = Scanner(templateString)
let map = [
"{{": "}}",
"{%": "%}",
"{#": "#}",
]
while !scanner.isEmpty { while !scanner.isEmpty {
if let text = scanner.scan(until: ["{{", "{%", "{#"]) { if let (char, text) = scanner.scanForTokenStart(Lexer.tokenChars) {
if !text.1.isEmpty { if !text.isEmpty {
tokens.append(createToken(string: text.1)) tokens.append(createToken(string: text, at: scanner.range))
} }
let end = map[text.0]! guard let end = Lexer.tokenCharMap[char] else { continue }
let result = scanner.scan(until: end, returnUntil: true) let result = scanner.scanForTokenEnd(end)
tokens.append(createToken(string: result)) tokens.append(createToken(string: result, at: scanner.range))
} else { } else {
tokens.append(createToken(string: scanner.content)) tokens.append(createToken(string: scanner.content, at: scanner.range))
scanner.content = "" scanner.content = ""
} }
} }
return tokens return tokens
} }
/// Finds the line matching the given range (for a token)
///
/// - Parameter range: The range to search for.
/// - Returns: The content for that line, the line number and offset within
/// the line.
func rangeLocation(_ range: Range<String.Index>) -> ContentLocation {
guard let line = self.lines.first(where: { $0.range.contains(range.lowerBound) }) else {
return ("", 0, 0)
}
let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound)
return (line.content, line.number, offset)
}
} }
class Scanner { class Scanner {
let originalContent: String
var content: String var content: String
var range: Range<String.Index>
/// The start delimiter for a token.
private static let tokenStartDelimiter: Unicode.Scalar = "{"
/// And the corresponding end delimiter for a token.
private static let tokenEndDelimiter: Unicode.Scalar = "}"
init(_ content: String) { init(_ content: String) {
self.originalContent = content
self.content = content self.content = content
range = content.startIndex..<content.startIndex
} }
var isEmpty: Bool { var isEmpty: Bool {
return content.isEmpty return content.isEmpty
} }
func scan(until: String, returnUntil: Bool = false) -> String { /// Scans for the end of a token, with a specific ending character. If we're
if until.isEmpty { /// searching for the end of a block token `%}`, this method receives a `%`.
return "" /// The scanner will search for that `%` followed by a `}`.
} ///
/// Note: if the end of a token is found, the `content` and `range`
var index = content.startIndex /// properties are updated to reflect this. `content` will be set to what
while index != content.endIndex { /// remains of the template after the token. `range` will be set to the range
let substring = content.substring(from: index) /// of the token within the template.
///
if substring.hasPrefix(until) { /// - Parameter tokenChar: The token end character to search for.
let result = content.substring(to: index) /// - Returns: The content of a token, or "" if no token end was found.
content = substring func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
var foundChar = false
if returnUntil {
content = content.substring(from: until.endIndex)
return result + until
}
for (index, char) in content.unicodeScalars.enumerated() {
if foundChar && char == Scanner.tokenEndDelimiter {
let result = String(content.prefix(index + 1))
content = String(content.dropFirst(index + 1))
range = range.upperBound..<originalContent.index(range.upperBound, offsetBy: index + 1)
return result return result
} else {
foundChar = (char == tokenChar)
} }
index = content.index(after: index)
} }
content = "" content = ""
return "" return ""
} }
func scan(until: [String]) -> (String, String)? { /// Scans for the start of a token, with a list of potential starting
if until.isEmpty { /// characters. To scan for the start of variables (`{{`), blocks (`{%`) and
return nil /// comments (`{#`), this method receives the characters `{`, `%` and `#`.
} /// The scanner will search for a `{`, followed by one of the search
/// characters. It will give the found character, and the content that came
/// before the token.
///
/// Note: if the start of a token is found, the `content` and `range`
/// properties are updated to reflect this. `content` will be set to what
/// remains of the template starting with the token. `range` will be set to
/// the start of the token within the template.
///
/// - Parameter tokenChars: List of token start characters to search for.
/// - Returns: The found token start character, together with the content
/// before the token, or nil of no token start was found.
func scanForTokenStart(_ tokenChars: [Unicode.Scalar]) -> (Unicode.Scalar, String)? {
var foundBrace = false
var index = content.startIndex range = range.upperBound..<range.upperBound
while index != content.endIndex { for (index, char) in content.unicodeScalars.enumerated() {
let substring = content.substring(from: index) if foundBrace && tokenChars.contains(char) {
for string in until { let result = String(content.prefix(index - 1))
if substring.hasPrefix(string) { content = String(content.dropFirst(index - 1))
let result = content.substring(to: index) range = range.upperBound..<originalContent.index(range.upperBound, offsetBy: index - 1)
content = substring return (char, result)
return (string, result) } else {
foundBrace = (char == Scanner.tokenStartDelimiter)
} }
} }
index = content.index(after: index)
}
return nil return nil
} }
} }
extension String { extension String {
func findFirstNot(character: Character) -> String.Index? { func findFirstNot(character: Character) -> String.Index? {
var index = startIndex var index = startIndex
@@ -157,3 +226,5 @@ extension String {
return String(self[first..<last]) return String(self[first..<last])
} }
} }
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)

View File

@@ -1,35 +1,31 @@
import Foundation import Foundation
public struct TemplateSyntaxError : Error, Equatable, CustomStringConvertible {
public let description:String
public init(_ description:String) {
self.description = description
}
}
public func ==(lhs:TemplateSyntaxError, rhs:TemplateSyntaxError) -> Bool {
return lhs.description == rhs.description
}
public protocol NodeType { public protocol NodeType {
/// Render the node in the given context /// Render the node in the given context
func render(_ context:Context) throws -> String func render(_ context:Context) throws -> String
/// Reference to this node's token
var token: Token? { get }
} }
/// Render the collection of nodes in the given context /// Render the collection of nodes in the given context
public func renderNodes(_ nodes:[NodeType], _ context:Context) throws -> String { public func renderNodes(_ nodes:[NodeType], _ context:Context) throws -> String {
return try nodes.map { try $0.render(context) }.joined(separator: "") return try nodes.map {
do {
return try $0.render(context)
} catch {
throw error.withToken($0.token)
}
}.joined(separator: "")
} }
public class SimpleNode : NodeType { public class SimpleNode : NodeType {
public let handler:(Context) throws -> String public let handler:(Context) throws -> String
public let token: Token?
public init(handler: @escaping (Context) throws -> String) { public init(token: Token, handler: @escaping (Context) throws -> String) {
self.token = token
self.handler = handler self.handler = handler
} }
@@ -41,9 +37,11 @@ public class SimpleNode : NodeType {
public class TextNode : NodeType { public class TextNode : NodeType {
public let text:String public let text:String
public let token: Token?
public init(text:String) { public init(text:String) {
self.text = text self.text = text
self.token = nil
} }
public func render(_ context:Context) throws -> String { public func render(_ context:Context) throws -> String {
@@ -59,16 +57,65 @@ public protocol Resolvable {
public class VariableNode : NodeType { public class VariableNode : NodeType {
public let variable: Resolvable public let variable: Resolvable
public var token: Token?
let condition: Expression?
let elseExpression: Resolvable?
public init(variable: Resolvable) { class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
self.variable = variable var components = token.components()
func hasToken(_ token: String, at index: Int) -> Bool {
return components.count > (index + 1) && components[index] == token
} }
public init(variable: String) { let condition: Expression?
let elseExpression: Resolvable?
if hasToken("if", at: 1) {
let components = components.suffix(from: 2)
if let elseIndex = components.index(of: "else") {
condition = try parseExpression(components: Array(components.prefix(upTo: elseIndex)), tokenParser: parser, token: token)
let elseToken = components.suffix(from: elseIndex.advanced(by: 1)).joined(separator: " ")
elseExpression = try parser.compileResolvable(elseToken, containedIn: token)
} else {
condition = try parseExpression(components: Array(components), tokenParser: parser, token: token)
elseExpression = nil
}
} else {
condition = nil
elseExpression = nil
}
let filter = try parser.compileResolvable(components[0], containedIn: token)
return VariableNode(variable: filter, token: token, condition: condition, elseExpression: elseExpression)
}
public init(variable: Resolvable, token: Token? = nil) {
self.variable = variable
self.token = token
self.condition = nil
self.elseExpression = nil
}
init(variable: Resolvable, token: Token? = nil, condition: Expression?, elseExpression: Resolvable?) {
self.variable = variable
self.token = token
self.condition = condition
self.elseExpression = elseExpression
}
public init(variable: String, token: Token? = nil) {
self.variable = Variable(variable) self.variable = Variable(variable)
self.token = token
self.condition = nil
self.elseExpression = nil
} }
public func render(_ context: Context) throws -> String { public func render(_ context: Context) throws -> String {
if let condition = self.condition, try condition.evaluate(context: context) == false {
return try elseExpression?.resolve(context).map(stringify) ?? ""
}
let result = try variable.resolve(context) let result = try variable.resolve(context)
return stringify(result) return stringify(result)
} }

View File

@@ -4,23 +4,25 @@ import Foundation
class NowNode : NodeType { class NowNode : NodeType {
let format:Variable let format:Variable
let token: Token?
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType { class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
var format:Variable? var format:Variable?
let components = token.components() let components = token.components()
guard components.count <= 2 else { guard components.count <= 2 else {
throw TemplateSyntaxError("'now' tags may only have one argument: the format string `\(token.contents)`.") throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
} }
if components.count == 2 { if components.count == 2 {
format = Variable(components[1]) format = Variable(components[1])
} }
return NowNode(format:format) return NowNode(format:format, token: token)
} }
init(format:Variable?) { init(format:Variable?, token: Token? = nil) {
self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"") self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
self.token = token
} }
func render(_ context: Context) throws -> String { func render(_ context: Context) throws -> String {

View File

@@ -37,10 +37,10 @@ public class TokenParser {
let token = nextToken()! let token = nextToken()!
switch token { switch token {
case .text(let text): case .text(let text, _):
nodes.append(TextNode(text: text)) nodes.append(TextNode(text: text))
case .variable: case .variable:
nodes.append(VariableNode(variable: try compileResolvable(token.contents))) try nodes.append(VariableNode.parse(self, 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) {
prependToken(token) prependToken(token)
@@ -48,8 +48,13 @@ public class TokenParser {
} }
if let tag = token.components().first { if let tag = token.components().first {
let parser = try environment.findTag(name: tag) do {
nodes.append(try parser(self, token)) let parser = try findTag(name: tag)
let node = try parser(self, token)
nodes.append(node)
} catch {
throw error.withToken(token)
}
} }
case .comment: case .comment:
continue continue
@@ -108,7 +113,10 @@ extension Environment {
if suggestedFilters.isEmpty { if suggestedFilters.isEmpty {
throw TemplateSyntaxError("Unknown filter '\(name)'.") throw TemplateSyntaxError("Unknown filter '\(name)'.")
} else { } else {
throw TemplateSyntaxError("Unknown filter '\(name)'. Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", "))") throw TemplateSyntaxError("""
Unknown filter '\(name)'. \
Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", ")).
""")
} }
} }
@@ -118,7 +126,7 @@ extension Environment {
let filtersWithDistance = allFilters let filtersWithDistance = allFilters
.map({ (filterName: $0, distance: $0.levenshteinDistance(name)) }) .map({ (filterName: $0, distance: $0.levenshteinDistance(name)) })
// do not suggest filters which names are shorter than the distance // do not suggest filters which names are shorter than the distance
.filter({ $0.filterName.characters.count > $0.distance }) .filter({ $0.filterName.count > $0.distance })
guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else { guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else {
return [] return []
} }
@@ -126,6 +134,26 @@ extension Environment {
return filtersWithDistance.filter({ $0.distance == minDistance }).map({ $0.filterName }) return filtersWithDistance.filter({ $0.distance == minDistance }).map({ $0.filterName })
} }
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
do {
return try FilterExpression(token: filterToken, parser: self)
} catch {
guard var syntaxError = error as? TemplateSyntaxError, syntaxError.token == nil else {
throw error
}
// find offset of filter in the containing token so that only filter is highligted, not the whole token
if let filterTokenRange = containingToken.contents.range(of: filterToken) {
var location = containingToken.sourceMap.location
location.lineOffset += containingToken.contents.distance(from: containingToken.contents.startIndex, to: filterTokenRange.lowerBound)
syntaxError.token = .variable(value: filterToken, at: SourceMap(filename: containingToken.sourceMap.filename, location: location))
} else {
syntaxError.token = containingToken
}
throw syntaxError
}
}
@available(*, deprecated, message: "Use compileFilter(_:containedIn:)")
public func compileFilter(_ token: String) throws -> Resolvable { public func compileFilter(_ token: String) throws -> Resolvable {
return try FilterExpression(token: token, environment: self) return try FilterExpression(token: token, environment: self)
} }
@@ -134,11 +162,17 @@ extension Environment {
return try IfExpressionParser(components: components, environment: self).parse() return try IfExpressionParser(components: components, environment: self).parse()
} }
@available(*, deprecated, message: "Use compileResolvable(_:containedIn:)")
public func compileResolvable(_ token: String) throws -> Resolvable { public func compileResolvable(_ token: String) throws -> Resolvable {
return try RangeVariable(token, environment: self) return try RangeVariable(token, environment: self)
?? compileFilter(token) ?? compileFilter(token)
} }
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
return try RangeVariable(token, parser: self, containedIn: containingToken)
?? compileFilter(token, containedIn: containingToken)
}
} }
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows // https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
@@ -155,10 +189,10 @@ extension String {
// initialize v0 (the previous row of distances) // initialize v0 (the previous row of distances)
// this row is A[0][i]: edit distance for an empty s // this row is A[0][i]: edit distance for an empty s
// the distance is just the number of characters to delete from t // the distance is just the number of characters to delete from t
last = [Int](0...target.characters.count) last = [Int](0...target.count)
current = [Int](repeating: 0, count: target.characters.count + 1) current = [Int](repeating: 0, count: target.count + 1)
for i in 0..<self.characters.count { for i in 0..<self.count {
// calculate v1 (current row distances) from the previous row v0 // calculate v1 (current row distances) from the previous row v0
// first element of v1 is A[i+1][0] // first element of v1 is A[i+1][0]
@@ -166,7 +200,7 @@ extension String {
current[0] = i + 1 current[0] = i + 1
// use formula to fill in the rest of the row // use formula to fill in the rest of the row
for j in 0..<target.characters.count { for j in 0..<target.count {
current[j+1] = Swift.min( current[j+1] = Swift.min(
last[j+1] + 1, last[j+1] + 1,
current[j] + 1, current[j] + 1,
@@ -178,7 +212,7 @@ extension String {
last = current last = current
} }
return current[target.characters.count] return current[target.count]
} }
} }

View File

@@ -7,7 +7,8 @@ let NSFileNoSuchFileError = 4
/// A class representing a template /// A class representing a template
open class Template: ExpressibleByStringLiteral { open class Template: ExpressibleByStringLiteral {
let environment: Environment let templateString: String
internal(set) var environment: Environment
let tokens: [Token] let tokens: [Token]
/// The name of the loaded Template if the Template was loaded from a Loader /// The name of the loaded Template if the Template was loaded from a Loader
@@ -17,8 +18,9 @@ open class Template: ExpressibleByStringLiteral {
public required init(templateString: String, environment: Environment? = nil, name: String? = nil) { public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
self.environment = environment ?? Environment() self.environment = environment ?? Environment()
self.name = name self.name = name
self.templateString = templateString
let lexer = Lexer(templateString: templateString) let lexer = Lexer(templateName: name, templateString: templateString)
tokens = lexer.tokenize() tokens = lexer.tokenize()
} }

View File

@@ -13,7 +13,7 @@ extension String {
let specialCharacters = ",|:" let specialCharacters = ",|:"
func appendWord(_ word: String) { func appendWord(_ word: String) {
if components.count > 0 { if components.count > 0 {
if let precedingChar = components.last?.characters.last, specialCharacters.characters.contains(precedingChar) { if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
components[components.count-1] += word components[components.count-1] += word
} else if specialCharacters.contains(word) { } else if specialCharacters.contains(word) {
components[components.count-1] += word components[components.count-1] += word
@@ -25,7 +25,7 @@ extension String {
} }
} }
for character in self.characters { for character in self {
if character == "'" { singleQuoteCount += 1 } if character == "'" { singleQuoteCount += 1 }
else if character == "\"" { doubleQuoteCount += 1 } else if character == "\"" { doubleQuoteCount += 1 }
@@ -55,60 +55,63 @@ extension String {
} }
} }
public struct SourceMap: Equatable {
public let filename: String?
public let location: ContentLocation
init(filename: String? = nil, location: ContentLocation = ("", 0, 0)) {
self.filename = filename
self.location = location
}
static let unknown = SourceMap()
public static func ==(lhs: SourceMap, rhs: SourceMap) -> Bool {
return lhs.filename == rhs.filename && lhs.location == rhs.location
}
}
public enum Token : Equatable { public enum Token : Equatable {
/// A token representing a piece of text. /// A token representing a piece of text.
case text(value: String) case text(value: String, at: SourceMap)
/// A token representing a variable. /// A token representing a variable.
case variable(value: String) case variable(value: String, at: SourceMap)
/// A token representing a comment. /// A token representing a comment.
case comment(value: String) case comment(value: String, at: SourceMap)
/// A token representing a template block. /// A token representing a template block.
case block(value: String) case block(value: String, at: SourceMap)
/// Returns the underlying value as an array seperated by spaces /// Returns the underlying value as an array seperated by spaces
public func components() -> [String] { public func components() -> [String] {
switch self { switch self {
case .block(let value): case .block(let value, _),
return value.smartSplit() .variable(let value, _),
case .variable(let value): .text(let value, _),
return value.smartSplit() .comment(let value, _):
case .text(let value):
return value.smartSplit()
case .comment(let value):
return value.smartSplit() return value.smartSplit()
} }
} }
public var contents: String { public var contents: String {
switch self { switch self {
case .block(let value): case .block(let value, _),
return value .variable(let value, _),
case .variable(let value): .text(let value, _),
return value .comment(let value, _):
case .text(let value):
return value
case .comment(let value):
return value return value
} }
} }
public var sourceMap: SourceMap {
switch self {
case .block(_, let sourceMap),
.variable(_, let sourceMap),
.text(_, let sourceMap),
.comment(_, let sourceMap):
return sourceMap
}
}
} }
public func == (lhs: Token, rhs: Token) -> Bool {
switch (lhs, rhs) {
case (.text(let lhsValue), .text(let rhsValue)):
return lhsValue == rhsValue
case (.variable(let lhsValue), .variable(let rhsValue)):
return lhsValue == rhsValue
case (.block(let lhsValue), .block(let rhsValue)):
return lhsValue == rhsValue
case (.comment(let lhsValue), .comment(let rhsValue)):
return lhsValue == rhsValue
default:
return false
}
}

View File

@@ -8,11 +8,9 @@ class FilterExpression : Resolvable {
let filters: [(FilterType, [Variable])] let filters: [(FilterType, [Variable])]
let variable: Variable let variable: Variable
init(token: String, environment: Environment) throws { init(token: String, parser: TokenParser) throws {
let bits = token.smartSplit(separator: "|").map({ String($0).trim(character: " ") }) let bits = token.split(separator: "|").map({ String($0).trim(character: " ") })
if bits.isEmpty { if bits.isEmpty {
filters = []
variable = Variable("")
throw TemplateSyntaxError("Variable tags must include at least 1 argument") throw TemplateSyntaxError("Variable tags must include at least 1 argument")
} }
@@ -52,7 +50,7 @@ public struct Variable : Equatable, Resolvable {
// Split the lookup string and resolve references if possible // Split the lookup string and resolve references if possible
fileprivate func lookup(_ context: Context) throws -> [String] { fileprivate func lookup(_ context: Context) throws -> [String] {
var keyPath = KeyPath(variable, in: context) let keyPath = KeyPath(variable, in: context)
return try keyPath.parse() return try keyPath.parse()
} }
@@ -62,7 +60,7 @@ public struct Variable : Equatable, Resolvable {
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) { if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
// String literal // String literal
return String(variable[variable.characters.index(after: variable.startIndex) ..< variable.characters.index(before: variable.endIndex)]) return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
} }
// Number literal // Number literal
@@ -89,25 +87,17 @@ public struct Variable : Equatable, Resolvable {
current = dictionary[bit] current = dictionary[bit]
} }
} else if let array = current as? [Any] { } else if let array = current as? [Any] {
if let index = Int(bit) { current = resolveCollection(array, bit: bit)
if index >= 0 && index < array.count { } else if let string = current as? String {
current = array[index] current = resolveCollection(string, bit: bit)
} else {
current = nil
}
} else if bit == "first" {
current = array.first
} else if bit == "last" {
current = array.last
} else if bit == "count" {
current = array.count
}
} else if let object = current as? NSObject { // NSKeyValueCoding } else if let object = current as? NSObject { // NSKeyValueCoding
#if os(Linux) #if os(Linux)
return nil return nil
#else #else
if object.responds(to: Selector(bit)) {
current = object.value(forKey: bit) current = object.value(forKey: bit)
#endif }
#endif
} else if let value = current { } else if let value = current {
current = Mirror(reflecting: value).getValue(for: bit) current = Mirror(reflecting: value).getValue(for: bit)
if current == nil { if current == nil {
@@ -128,8 +118,22 @@ public struct Variable : Equatable, Resolvable {
} }
} }
public func ==(lhs: Variable, rhs: Variable) -> Bool { private func resolveCollection<T: Collection>(_ collection: T, bit: String) -> Any? {
return lhs.variable == rhs.variable if let index = Int(bit) {
if index >= 0 && index < collection.count {
return collection[collection.index(collection.startIndex, offsetBy: index)]
} else {
return nil
}
} else if bit == "first" {
return collection.first
} else if bit == "last" {
return collection[collection.index(collection.endIndex, offsetBy: -1)]
} else if bit == "count" {
return collection.count
} else {
return nil
}
} }
/// A structure used to represet range of two integer values expressed as `from...to`. /// A structure used to represet range of two integer values expressed as `from...to`.
@@ -140,7 +144,8 @@ public struct RangeVariable: Resolvable {
public let from: Resolvable public let from: Resolvable
public let to: Resolvable public let to: Resolvable
public init?(_ token: String, environment: Environment) throws { @available(*, deprecated, message: "Use init?(_:parser:containedIn:)")
public init?(_ token: String, parser: TokenParser) throws {
let components = token.components(separatedBy: "...") let components = token.components(separatedBy: "...")
guard components.count == 2 else { guard components.count == 2 else {
return nil return nil
@@ -150,6 +155,16 @@ public struct RangeVariable: Resolvable {
self.to = try environment.compileFilter(components[1]) self.to = try environment.compileFilter(components[1])
} }
public init?(_ token: String, parser: TokenParser, containedIn containingToken: Token) throws {
let components = token.components(separatedBy: "...")
guard components.count == 2 else {
return nil
}
self.from = try parser.compileFilter(components[0], containedIn: containingToken)
self.to = try parser.compileFilter(components[1], containedIn: containingToken)
}
public func resolve(_ context: Context) throws -> Any? { public func resolve(_ context: Context) throws -> Any? {
let fromResolved = try from.resolve(context) let fromResolved = try from.resolve(context)
let toResolved = try to.resolve(context) let toResolved = try to.resolve(context)

View File

@@ -1,6 +1,6 @@
{ {
"name": "Stencil", "name": "Stencil",
"version": "0.11.0", "version": "0.13.1",
"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": {
@@ -13,7 +13,7 @@
"social_media_url": "https://twitter.com/kylefuller", "social_media_url": "https://twitter.com/kylefuller",
"source": { "source": {
"git": "https://github.com/stencilproject/Stencil.git", "git": "https://github.com/stencilproject/Stencil.git",
"tag": "0.11.0" "tag": "0.13.1"
}, },
"source_files": [ "source_files": [
"Sources/*.swift" "Sources/*.swift"
@@ -23,10 +23,12 @@
"osx": "10.9", "osx": "10.9",
"tvos": "9.0" "tvos": "9.0"
}, },
"cocoapods_version": ">= 1.4.0",
"swift_version": "4.2",
"requires_arc": true, "requires_arc": true,
"dependencies": { "dependencies": {
"PathKit": [ "PathKit": [
"~> 0.8.0" "~> 0.9.0"
] ]
} }
} }

View File

@@ -1,3 +1,8 @@
import XCTest
import StencilTests import StencilTests
stencilTests() var tests = [XCTestCaseEntry]()
tests += StencilTests.__allTests()
XCTMain(tests)

View File

@@ -1,8 +1,11 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
func testContext() { class ContextTests: XCTestCase {
func testContext() {
describe("Context") { describe("Context") {
var context: Context! var context: Context!
@@ -78,4 +81,5 @@ func testContext() {
} }
} }
} }
}
} }

View File

@@ -1,10 +1,18 @@
import XCTest
import Spectre import Spectre
import Stencil import PathKit
@testable import Stencil
class EnvironmentTests: XCTestCase {
func testEnvironment() { func testEnvironment() {
describe("Environment") { describe("Environment") {
let environment = Environment(loader: ExampleLoader()) var environment: Environment!
var template: Template!
$0.before {
environment = Environment(loader: ExampleLoader())
template = nil
}
$0.it("can load a template from a name") { $0.it("can load a template from a name") {
let template = try environment.loadTemplate(name: "example.html") let template = try environment.loadTemplate(name: "example.html")
@@ -32,9 +40,321 @@ func testEnvironment() {
try expect(result) == "here" try expect(result) == "here"
} }
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
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 error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.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 syntax: `for <x> in <y> [where <condition>]`.", token: "for name in")
}
$0.it("reports syntax error on missing endfor") {
template = "{% for name in names %}{{ name }}"
try expectError(reason: "`endfor` was not found.", token: "for name in names")
}
$0.it("reports syntax error on unknown tag") {
template = "{% for name in names %}{{ name }}{% end %}"
try expectError(reason: "Unknown template tag 'end'", token: "end")
}
}
$0.context("given unknown filter") {
$0.it("reports syntax error in for tag") {
template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
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'. 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'. 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'. 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'. 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'. Found similar filters: 'uppercase'.", token: "filter unknown")
}
$0.it("reports syntax error in variable tag") {
template = "{{ name|unknown }}"
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
}
}
$0.context("given rendering error") {
$0.it("reports rendering error in variable filter") {
let filterExtension = Extension()
filterExtension.registerFilter("throw") { (value: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
template = Template(templateString: "{{ name|throw }}", environment: environment)
try expectError(reason: "filter error", token: "name|throw")
}
$0.it("reports rendering error in filter tag") {
let filterExtension = Extension()
filterExtension.registerFilter("throw") { (value: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: environment)
try expectError(reason: "filter error", token: "filter throw")
}
$0.it("reports rendering error in simple tag") {
let tagExtension = Extension()
tagExtension.registerSimpleTag("simpletag") { context in
throw TemplateSyntaxError("simpletag error")
}
environment.extensions += [tagExtension]
template = Template(templateString: "{% simpletag %}", environment: environment)
try expectError(reason: "simpletag error", token: "simpletag")
}
$0.it("reporsts passing argument to simple filter") {
template = "{{ name|uppercase:5 }}"
try expectError(reason: "cannot invoke filter with an argument", token: "name|uppercase:5")
}
$0.it("reports rendering error in custom tag") {
let tagExtension = Extension()
tagExtension.registerTag("customtag") { parser, token in
return ErrorNode(token: token)
}
environment.extensions += [tagExtension]
template = Template(templateString: "{% customtag %}", environment: environment)
try expectError(reason: "Custom Error", token: "customtag")
}
$0.it("reports rendering error in for body") {
let tagExtension = Extension()
tagExtension.registerTag("customtag") { parser, token in
return ErrorNode(token: token)
}
environment.extensions += [tagExtension]
template = Template(templateString: "{% for name in names %}{% customtag %}{% endfor %}", environment: environment)
try expectError(reason: "Custom Error", token: "customtag")
}
$0.it("reports rendering error in block") {
let tagExtension = Extension()
tagExtension.registerTag("customtag") { parser, token in
return ErrorNode(token: token)
}
environment.extensions += [tagExtension]
template = Template(templateString: "{% block some %}{% customtag %}{% endblock %}", environment: environment)
try expectError(reason: "Custom Error", token: "customtag")
}
}
$0.context("given included template") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
var environment = Environment(loader: loader)
var template: Template!
var includedTemplate: Template!
$0.before {
environment = Environment(loader: loader)
template = nil
includedTemplate = nil
}
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"]),
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.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'. Found similar filters: 'uppercase'.",
token: """
include "invalid-include.html"
""",
includedToken: "target|unknown")
}
$0.it("reports runtime error in included template") {
let filterExtension = Extension()
filterExtension.registerFilter("unknown", filter: { (_: Any?) in
throw TemplateSyntaxError("filter error")
})
environment.extensions += [filterExtension]
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(reason: "filter error",
token: "include \"invalid-include.html\"",
includedToken: "target|unknown")
}
}
$0.context("given base and child templates") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
var environment: Environment!
var childTemplate: Template!
var baseTemplate: Template!
$0.before {
environment = Environment(loader: loader)
childTemplate = nil
baseTemplate = nil
}
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"]),
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.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'. Found similar filters: 'uppercase'.",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown")
}
$0.it("reports runtime error in base template") {
let filterExtension = Extension()
filterExtension.registerFilter("unknown", filter: { (_: Any?) in
throw TemplateSyntaxError("filter error")
})
environment.extensions += [filterExtension]
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(reason: "filter error",
childToken: "block.super",
baseToken: "target|unknown")
}
$0.it("reports syntax error in child template") {
childTemplate = Template(templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""", environment: environment, name: nil)
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "target|unknown",
baseToken: nil)
}
$0.it("reports runtime error in child template") {
let filterExtension = Extension()
filterExtension.registerFilter("unknown", filter: { (_: Any?) in
throw TemplateSyntaxError("filter error")
})
environment.extensions += [filterExtension]
childTemplate = Template(templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""", environment: environment, name: nil)
try expectError(reason: "filter error",
childToken: "target|unknown",
baseToken: nil)
}
}
}
} }
} }
extension Expectation {
@discardableResult
func toThrow<T: Error>() throws -> T {
var thrownError: Error? = nil
do {
_ = try expression()
} catch {
thrownError = error
}
if let thrownError = thrownError {
if let thrownError = thrownError as? T {
return thrownError
} else {
throw failure("\(thrownError) is not \(T.self)")
}
} else {
throw failure("expression did not throw an error")
}
}
}
fileprivate class ExampleLoader: Loader { fileprivate class ExampleLoader: Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template { func loadTemplate(name: String, environment: Environment) throws -> Template {

View File

@@ -1,11 +1,17 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
class ExpressionsTests: XCTestCase {
func testExpressions() { func testExpressions() {
describe("Expression") { describe("Expression") {
let parser = TokenParser(tokens: [], environment: Environment()) let parser = TokenParser(tokens: [], environment: Environment())
func parseExpression(components: [String]) throws -> Expression {
let parser = try IfExpressionParser.parser(components: components, tokenParser: parser, token: .text(value: "", at: .unknown))
return try parser.parse()
}
$0.describe("VariableExpression") { $0.describe("VariableExpression") {
let expression = VariableExpression(variable: Variable("value")) let expression = VariableExpression(variable: Variable("value"))
@@ -105,19 +111,19 @@ func testExpressions() {
$0.describe("expression parsing") { $0.describe("expression parsing") {
$0.it("can parse a variable expression") { $0.it("can parse a variable expression") {
let expression = try parser.compileExpression(components: ["value"]) let expression = try parseExpression(components: ["value"])
try expect(expression.evaluate(context: Context())).to.beFalse() try expect(expression.evaluate(context: Context())).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue()
} }
$0.it("can parse a not expression") { $0.it("can parse a not expression") {
let expression = try parser.compileExpression(components: ["not", "value"]) let expression = try parseExpression(components: ["not", "value"])
try expect(expression.evaluate(context: Context())).to.beTrue() try expect(expression.evaluate(context: Context())).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse()
} }
$0.describe("and expression") { $0.describe("and expression") {
let expression = try! parser.compileExpression(components: ["lhs", "and", "rhs"]) let expression = try! parseExpression(components: ["lhs", "and", "rhs"])
$0.it("evaluates to false with lhs false") { $0.it("evaluates to false with lhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
@@ -137,7 +143,7 @@ func testExpressions() {
} }
$0.describe("or expression") { $0.describe("or expression") {
let expression = try! parser.compileExpression(components: ["lhs", "or", "rhs"]) let expression = try! parseExpression(components: ["lhs", "or", "rhs"])
$0.it("evaluates to true with lhs true") { $0.it("evaluates to true with lhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
@@ -157,7 +163,7 @@ func testExpressions() {
} }
$0.describe("equality expression") { $0.describe("equality expression") {
let expression = try! parser.compileExpression(components: ["lhs", "==", "rhs"]) let expression = try! parseExpression(components: ["lhs", "==", "rhs"])
$0.it("evaluates to true with equal lhs/rhs") { $0.it("evaluates to true with equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
@@ -193,7 +199,7 @@ func testExpressions() {
} }
$0.describe("inequality expression") { $0.describe("inequality expression") {
let expression = try! parser.compileExpression(components: ["lhs", "!=", "rhs"]) let expression = try! parseExpression(components: ["lhs", "!=", "rhs"])
$0.it("evaluates to true with inequal lhs/rhs") { $0.it("evaluates to true with inequal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
@@ -205,7 +211,7 @@ func testExpressions() {
} }
$0.describe("more than expression") { $0.describe("more than expression") {
let expression = try! parser.compileExpression(components: ["lhs", ">", "rhs"]) let expression = try! parseExpression(components: ["lhs", ">", "rhs"])
$0.it("evaluates to true with lhs > rhs") { $0.it("evaluates to true with lhs > rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue()
@@ -217,7 +223,7 @@ func testExpressions() {
} }
$0.describe("more than equal expression") { $0.describe("more than equal expression") {
let expression = try! parser.compileExpression(components: ["lhs", ">=", "rhs"]) let expression = try! parseExpression(components: ["lhs", ">=", "rhs"])
$0.it("evaluates to true with lhs == rhs") { $0.it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
@@ -229,7 +235,7 @@ func testExpressions() {
} }
$0.describe("less than expression") { $0.describe("less than expression") {
let expression = try! parser.compileExpression(components: ["lhs", "<", "rhs"]) let expression = try! parseExpression(components: ["lhs", "<", "rhs"])
$0.it("evaluates to true with lhs < rhs") { $0.it("evaluates to true with lhs < rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue()
@@ -241,7 +247,7 @@ func testExpressions() {
} }
$0.describe("less than equal expression") { $0.describe("less than equal expression") {
let expression = try! parser.compileExpression(components: ["lhs", "<=", "rhs"]) let expression = try! parseExpression(components: ["lhs", "<=", "rhs"])
$0.it("evaluates to true with lhs == rhs") { $0.it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
@@ -253,7 +259,7 @@ func testExpressions() {
} }
$0.describe("multiple expression") { $0.describe("multiple expression") {
let expression = try! parser.compileExpression(components: ["one", "or", "two", "and", "not", "three"]) let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"])
$0.it("evaluates to true with one") { $0.it("evaluates to true with one") {
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
@@ -281,7 +287,7 @@ func testExpressions() {
} }
$0.describe("in expression") { $0.describe("in expression") {
let expression = try! parser.compileExpression(components: ["lhs", "in", "rhs"]) let expression = try! parseExpression(components: ["lhs", "in", "rhs"])
$0.it("evaluates to true when rhs contains lhs") { $0.it("evaluates to true when rhs contains lhs") {
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()
@@ -299,6 +305,42 @@ func testExpressions() {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 3, "rhs": 1..<3]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["lhs": 3, "rhs": 1..<3]))).to.beFalse()
} }
} }
$0.describe("sub expression") {
$0.it("evaluates correctly") {
let context = Context(dictionary: ["one": false, "two": false, "three": true, "four": true])
let expression = try! parseExpression(components: ["one", "and", "two", "or", "three", "and", "four"])
let expressionWithBrackets = try! parseExpression(components: ["one", "and", "(", "(", "two", ")", "or", "(", "three", "and", "four", ")", ")"])
try expect(expression.evaluate(context: context)).to.beTrue()
try expect(expressionWithBrackets.evaluate(context: context)).to.beFalse()
let notExpression = try! parseExpression(components: ["not", "one", "or", "three"])
let notExpressionWithBrackets = try! parseExpression(components: ["not", "(", "one", "or", "three", ")"])
try expect(notExpression.evaluate(context: context)).to.beTrue()
try expect(notExpressionWithBrackets.evaluate(context: context)).to.beFalse()
}
$0.it("fails when brackets are not balanced") {
try expect(parseExpression(components: ["(", "lhs", "and", "rhs"]))
.toThrow(TemplateSyntaxError("'if' expression error: missing closing bracket"))
try expect(parseExpression(components: [")", "lhs", "and", "rhs"]))
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
try expect(parseExpression(components: ["lhs", "and", "rhs", ")"]))
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
try expect(parseExpression(components: ["(", "lhs", "and", "rhs", ")", "("]))
.toThrow(TemplateSyntaxError("'if' expression error: missing closing bracket"))
try expect(parseExpression(components: ["(", "lhs", "and", "rhs", ")", ")"]))
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
try expect(parseExpression(components: ["(", "lhs", "and", ")"]))
.toThrow(TemplateSyntaxError("'if' expression error: end"))
try expect(parseExpression(components: ["(", "and", "rhs", ")"]))
.toThrow(TemplateSyntaxError("'if' expression error: infix operator 'and' doesn't have a left hand side"))
}
}
}
} }
} }
} }

View File

@@ -1,21 +1,17 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
class FilterTests: XCTestCase {
func testFilter() { func testFilter() {
func environmentWithFilter(_ name: String, closure: @escaping (Any?) throws -> Any?) -> Environment {
let filterExtension = Extension()
filterExtension.registerFilter(name, filter: closure)
return Environment(extensions: [filterExtension])
}
describe("template filters") { describe("template filters") {
let context: [String: Any] = ["name": "Kyle"] let context: [String: Any] = ["name": "Kyle"]
$0.it("allows you to register a custom filter") { $0.it("allows you to register a custom filter") {
let template = Template(templateString: "{{ name|repeat }}") let template = Template(templateString: "{{ name|repeat }}")
let env = environmentWithFilter("repeat") { (value: Any?) in
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { (value: Any?) in
if let value = value as? String { if let value = value as? String {
return "\(value) \(value)" return "\(value) \(value)"
} }
@@ -23,12 +19,32 @@ func testFilter() {
return nil return nil
} }
let result = try template.render(Context(dictionary: context, environment: env)) let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
try expect(result) == "Kyle Kyle" try expect(result) == "Kyle Kyle"
} }
$0.it("allows you to register boolean filters") {
let repeatExtension = Extension()
repeatExtension.registerFilter(name: "isPositive", negativeFilterName: "isNotPositive") { (value: Any?) in
if let value = value as? Int {
return value > 0
}
return nil
}
let result = try Template(templateString: "{{ value|isPositive }}")
.render(Context(dictionary: ["value": 1], environment: Environment(extensions: [repeatExtension])))
try expect(result) == "true"
let negativeResult = try Template(templateString: "{{ value|isNotPositive }}")
.render(Context(dictionary: ["value": -1], environment: Environment(extensions: [repeatExtension])))
try expect(negativeResult) == "true"
}
$0.it("allows you to register a custom filter which accepts single argument") { $0.it("allows you to register a custom filter which accepts single argument") {
let template = Template(templateString: "{{ name|repeat:'value1, \"value2\"' }}") let template = Template(templateString: """
{{ name|repeat:'value1, "value2"' }}
""")
let repeatExtension = Extension() let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in repeatExtension.registerFilter("repeat") { value, arguments in
@@ -40,11 +56,15 @@ func testFilter() {
} }
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))) let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
try expect(result) == "Kyle Kyle with args value1, \"value2\"" try expect(result) == """
Kyle Kyle with args value1, "value2"
"""
} }
$0.it("allows you to register a custom filter which accepts several arguments") { $0.it("allows you to register a custom filter which accepts several arguments") {
let template = Template(templateString: "{{ name|repeat:'value\"1\"',\"value'2'\",'(key, value)' }}") let template = Template(templateString: """
{{ name|repeat:'value"1"',"value'2'",'(key, value)' }}
""")
let repeatExtension = Extension() let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in repeatExtension.registerFilter("repeat") { value, arguments in
@@ -56,7 +76,9 @@ func testFilter() {
} }
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))) let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
try expect(result) == "Kyle Kyle with args 0: value\"1\", 1: value'2', 2: (key, value)" try expect(result) == """
Kyle Kyle with args 0: value"1", 1: value'2', 2: (key, value)
"""
} }
$0.it("allows you to register a custom which throws") { $0.it("allows you to register a custom which throws") {
@@ -67,21 +89,25 @@ func testFilter() {
} }
let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension])) let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))
try expect(try template.render(context)).toThrow(TemplateSyntaxError("No Repeat")) try expect(try template.render(context)).toThrow(TemplateSyntaxError(reason: "No Repeat", token: template.tokens.first))
} }
$0.it("allows you to override a default filter") { $0.it("allows you to override a default filter") {
let template = Template(templateString: "{{ name|join }}") let template = Template(templateString: "{{ name|join }}")
let env = environmentWithFilter("join") { (value: Any?) in
let repeatExtension = Extension()
repeatExtension.registerFilter("join") { (value: Any?) in
return "joined" return "joined"
} }
let result = try template.render(Context(dictionary: context, environment: env)) let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
try expect(result) == "joined" try expect(result) == "joined"
} }
$0.it("allows whitespace in expression") { $0.it("allows whitespace in expression") {
let template = Template(templateString: "{{ value | join : \", \" }}") let template = Template(templateString: """
{{ value | join : ", " }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]])) let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "One, Two" try expect(result) == "One, Two"
} }
@@ -117,25 +143,33 @@ func testFilter() {
$0.it("transforms a string to be capitalized") { $0.it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ names|capitalize }}") let template = Template(templateString: "{{ names|capitalize }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]])) let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == "[\"Kyle\", \"Kyle\"]" try expect(result) == """
["Kyle", "Kyle"]
"""
} }
$0.it("transforms a string to be uppercase") { $0.it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ names|uppercase }}") let template = Template(templateString: "{{ names|uppercase }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]])) let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == "[\"KYLE\", \"KYLE\"]" try expect(result) == """
["KYLE", "KYLE"]
"""
} }
$0.it("transforms a string to be lowercase") { $0.it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ names|lowercase }}") let template = Template(templateString: "{{ names|lowercase }}")
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]])) let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
try expect(result) == "[\"kyle\", \"kyle\"]" try expect(result) == """
["kyle", "kyle"]
"""
} }
} }
} }
describe("default filter") { describe("default filter") {
let template = Template(templateString: "Hello {{ name|default:\"World\" }}") let template = Template(templateString: """
Hello {{ name|default:"World" }}
""")
$0.it("shows the variable value") { $0.it("shows the variable value") {
let result = try template.render(Context(dictionary: ["name": "Kyle"])) let result = try template.render(Context(dictionary: ["name": "Kyle"]))
@@ -148,7 +182,9 @@ func testFilter() {
} }
$0.it("supports multiple defaults") { $0.it("supports multiple defaults") {
let template = Template(templateString: "Hello {{ name|default:a,b,c,\"World\" }}") let template = Template(templateString: """
Hello {{ name|default:a,b,c,"World" }}
""")
let result = try template.render(Context(dictionary: [:])) let result = try template.render(Context(dictionary: [:]))
try expect(result) == "Hello World" try expect(result) == "Hello World"
} }
@@ -166,7 +202,9 @@ func testFilter() {
} }
$0.it("checks for underlying nil value correctly") { $0.it("checks for underlying nil value correctly") {
let template = Template(templateString: "Hello {{ user.name|default:\"anonymous\" }}") let template = Template(templateString: """
Hello {{ user.name|default:"anonymous" }}
""")
let nilName: String? = nil let nilName: String? = nil
let user: [String: Any?] = ["name": nilName] let user: [String: Any?] = ["name": nilName]
let result = try template.render(Context(dictionary: ["user": user])) let result = try template.render(Context(dictionary: ["user": user]))
@@ -175,7 +213,9 @@ func testFilter() {
} }
describe("join filter") { describe("join filter") {
let template = Template(templateString: "{{ value|join:\", \" }}") let template = Template(templateString: """
{{ value|join:", " }}
""")
$0.it("joins a collection of strings") { $0.it("joins a collection of strings") {
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]])) let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
@@ -188,87 +228,163 @@ func testFilter() {
} }
$0.it("can join by non string") { $0.it("can join by non string") {
let template = Template(templateString: "{{ value|join:separator }}") let template = Template(templateString: """
{{ value|join:separator }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"], "separator": true])) let result = try template.render(Context(dictionary: ["value": ["One", "Two"], "separator": true]))
try expect(result) == "OnetrueTwo" try expect(result) == "OnetrueTwo"
} }
$0.it("can join without arguments") { $0.it("can join without arguments") {
let template = Template(templateString: "{{ value|join }}") let template = Template(templateString: """
{{ value|join }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]])) let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "OneTwo" try expect(result) == "OneTwo"
} }
} }
describe("split filter") { describe("split filter") {
let template = Template(templateString: "{{ value|split:\", \" }}") let template = Template(templateString: """
{{ value|split:", " }}
""")
$0.it("split a string into array") { $0.it("split a string into array") {
let result = try template.render(Context(dictionary: ["value": "One, Two"])) let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == "[\"One\", \"Two\"]" try expect(result) == """
["One", "Two"]
"""
} }
$0.it("can split without arguments") { $0.it("can split without arguments") {
let template = Template(templateString: "{{ value|split }}") let template = Template(templateString: """
{{ value|split }}
""")
let result = try template.render(Context(dictionary: ["value": "One, Two"])) let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == "[\"One,\", \"Two\"]" try expect(result) == """
["One,", "Two"]
"""
} }
} }
describe("filter suggestion") { describe("filter suggestion") {
var template: Template!
var filterExtension: Extension!
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
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
let reporter = SimpleErrorReporter()
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError)
}
$0.it("made for unknown filter") { $0.it("made for unknown filter") {
let template = Template(templateString: "{{ value|unknownFilter }}") template = Template(templateString: "{{ value|unknownFilter }}")
let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'")
let filterExtension = Extension() filterExtension = Extension()
filterExtension.registerFilter("knownFilter") { value, _ in value } filterExtension.registerFilter("knownFilter") { value, _ in value }
try expect(template.render(Context(dictionary: [:], environment: Environment(extensions: [filterExtension])))).toThrow(expectedError) try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.", token: "value|unknownFilter")
} }
$0.it("made for multiple similar filters") { $0.it("made for multiple similar filters") {
let template = Template(templateString: "{{ value|lowerFirst }}") template = Template(templateString: "{{ value|lowerFirst }}")
let expectedError = TemplateSyntaxError("Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'")
let filterExtension = Extension() filterExtension = Extension()
filterExtension.registerFilter("lowerFirstWord") { value, _ in value } filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value } filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
try expect(template.render(Context(dictionary: [:], environment: Environment(extensions: [filterExtension])))).toThrow(expectedError) try expectError(reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.", token: "value|lowerFirst")
} }
$0.it("not made when can't find similar filter") { $0.it("not made when can't find similar filter") {
let template = Template(templateString: "{{ value|unknownFilter }}") template = Template(templateString: "{{ value|unknownFilter }}")
let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'.") try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.", token: "value|unknownFilter")
try expect(template.render(Context(dictionary: [:]))).toThrow(expectedError)
} }
} }
describe("indent filter") { describe("indent filter") {
$0.it("indents content") { $0.it("indents content") {
let template = Template(templateString: "{{ value|indent:2 }}") let template = Template(templateString: """
let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) {{ value|indent:2 }}
try expect(result) == "One\n Two" """)
let result = try template.render(Context(dictionary: ["value": """
One
Two
"""]))
try expect(result) == """
One
Two
"""
} }
$0.it("can indent with arbitrary character") { $0.it("can indent with arbitrary character") {
let template = Template(templateString: "{{ value|indent:2,\"\t\" }}") let template = Template(templateString: """
let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) {{ value|indent:2,"\t" }}
try expect(result) == "One\n\t\tTwo" """)
let result = try template.render(Context(dictionary: ["value": """
One
Two
"""]))
try expect(result) == """
One
\t\tTwo
"""
} }
$0.it("can indent first line") { $0.it("can indent first line") {
let template = Template(templateString: "{{ value|indent:2,\" \",true }}") let template = Template(templateString: """
let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) {{ value|indent:2," ",true }}
try expect(result) == " One\n Two" """)
let result = try template.render(Context(dictionary: ["value": """
One
Two
"""]))
try expect(result) == """
One
Two
"""
} }
$0.it("does not indent empty lines") { $0.it("does not indent empty lines") {
let template = Template(templateString: "{{ value|indent }}") let template = Template(templateString: """
let result = try template.render(Context(dictionary: ["value": "One\n\n\nTwo\n\n"])) {{ value|indent }}
try expect(result) == "One\n\n\n Two\n\n" """)
let result = try template.render(Context(dictionary: ["value": """
One
Two
"""]))
try expect(result) == """
One
Two
"""
}
} }
} }

View File

@@ -1,8 +1,9 @@
import XCTest
import Spectre import Spectre
import Stencil import Stencil
class FilterTagTests: XCTestCase {
func testFilterTag() { func testFilterTag() {
describe("Filter Tag") { describe("Filter Tag") {
$0.it("allows you to use a filter") { $0.it("allows you to use a filter") {
let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}") let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
@@ -17,7 +18,7 @@ func testFilterTag() {
} }
$0.it("errors without a filter") { $0.it("errors without a filter") {
let template = Template(templateString: "{% filter %}Test{% endfilter %}") let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
try expect(try template.render()).toThrow() try expect(try template.render()).toThrow()
} }
@@ -27,7 +28,9 @@ func testFilterTag() {
return ($0 as! String).components(separatedBy: $1[0] as! String) return ($0 as! String).components(separatedBy: $1[0] as! String)
}) })
let env = Environment(extensions: [ext]) let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: "{% filter split:\",\"|join:\";\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": [1, 2]]) let result = try env.renderTemplate(string: """
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": [1, 2]])
try expect(result) == "1;2" try expect(result) == "1;2"
} }
@@ -38,8 +41,11 @@ func testFilterTag() {
return ($0 as! String).replacingOccurrences(of: $1[0] as! String, with: $1[1] as! String) return ($0 as! String).replacingOccurrences(of: $1[0] as! String, with: $1[1] as! String)
}) })
let env = Environment(extensions: [ext]) let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: "{% filter replace:'\"',\"\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": ["\"1\"", "\"2\""]]) let result = try env.renderTemplate(string: """
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": ["\"1\"", "\"2\""]])
try expect(result) == "1,2" try expect(result) == "1,2"
} }
} }
}
} }

View File

@@ -1,9 +1,10 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import Foundation import Foundation
class ForNodeTests: XCTestCase {
func testForNode() { func testForNode() {
describe("ForNode") { describe("ForNode") {
let context = Context(dictionary: [ let context = Context(dictionary: [
"items": [1, 2, 3], "items": [1, 2, 3],
@@ -54,7 +55,7 @@ func testForNode() {
try expect(try node.render(context)) == "123" try expect(try node.render(context)) == "123"
} }
#if os(OSX) #if os(OSX)
$0.it("renders a context variable of type NSArray") { $0.it("renders a context variable of type NSArray") {
let nsarray_context = Context(dictionary: [ let nsarray_context = Context(dictionary: [
"items": NSArray(array: [1, 2, 3]) "items": NSArray(array: [1, 2, 3])
@@ -64,7 +65,7 @@ func testForNode() {
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: []) let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(nsarray_context)) == "123" try expect(try node.render(nsarray_context)) == "123"
} }
#endif #endif
$0.it("renders the given nodes while providing if the item is first in the context") { $0.it("renders the given nodes while providing if the item is first in the context") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")] let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")]
@@ -98,8 +99,7 @@ func testForNode() {
$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 parser = TokenParser(tokens: [], environment: Environment()) let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown))
let `where` = try parser.compileExpression(components: ["item", ">", "1"])
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [], where: `where`) let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [], where: `where`)
try expect(try node.render(context)) == "2132" try expect(try node.render(context)) == "2132"
} }
@@ -107,16 +107,17 @@ func testForNode() {
$0.it("renders the given empty nodes when all items filtered out with where expression") { $0.it("renders the given empty nodes when all items filtered out with where expression") {
let nodes: [NodeType] = [VariableNode(variable: "item")] let nodes: [NodeType] = [VariableNode(variable: "item")]
let emptyNodes: [NodeType] = [TextNode(text: "empty")] let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let parser = TokenParser(tokens: [], environment: Environment()) let `where` = try parseExpression(components: ["item", "==", "0"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown))
let `where` = try parser.compileExpression(components: ["item", "==", "0"])
let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes, where: `where`) let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes, where: `where`)
try expect(try node.render(context)) == "empty" try expect(try node.render(context)) == "empty"
} }
$0.it("can render a filter with spaces") { $0.it("can render a filter with spaces") {
let templateString = "{% for article in ars | default: a, b , articles %}" + let templateString = """
"- {{ article.title }} by {{ article.author }}.\n" + {% for article in ars | default: a, b , articles %}\
"{% endfor %}\n" - {{ article.title }} by {{ article.author }}.
{% endfor %}
"""
let context = Context(dictionary: [ let context = Context(dictionary: [
"articles": [ "articles": [
@@ -128,54 +129,70 @@ func testForNode() {
let template = Template(templateString: templateString) let template = Template(templateString: templateString)
let result = try template.render(context) let result = try template.render(context)
let fixture = "" + try expect(result) == """
"- Migrating from OCUnit to XCTest by Kyle Fuller.\n" + - Migrating from OCUnit to XCTest by Kyle Fuller.
"- Memory Management with ARC by Kyle Fuller.\n" + - Memory Management with ARC by Kyle Fuller.
"\n"
try expect(result) == fixture """
} }
$0.context("given array of tuples") { $0.context("given array of tuples") {
$0.it("can iterate over all tuple values") { $0.it("can iterate over all tuple values") {
let templateString = "{% for first,second,third in tuples %}" + let templateString = """
"{{ first }}, {{ second }}, {{ third }}\n" + {% for first,second,third in tuples %}\
"{% endfor %}\n" {{ first }}, {{ second }}, {{ third }}
{% endfor %}
"""
let template = Template(templateString: templateString) let template = Template(templateString: templateString)
let result = try template.render(context) let result = try template.render(context)
let fixture = "1, 2, 3\n4, 5, 6\n\n" try expect(result) == """
try expect(result) == fixture 1, 2, 3
4, 5, 6
"""
} }
$0.it("can iterate with less number of variables") { $0.it("can iterate with less number of variables") {
let templateString = "{% for first,second in tuples %}" + let templateString = """
"{{ first }}, {{ second }}\n" + {% for first,second in tuples %}\
"{% endfor %}\n" {{ first }}, {{ second }}
{% endfor %}
"""
let template = Template(templateString: templateString) let template = Template(templateString: templateString)
let result = try template.render(context) let result = try template.render(context)
let fixture = "1, 2\n4, 5\n\n" try expect(result) == """
try expect(result) == fixture 1, 2
4, 5
"""
} }
$0.it("can use _ to skip variables") { $0.it("can use _ to skip variables") {
let templateString = "{% for first,_,third in tuples %}" + let templateString = """
"{{ first }}, {{ third }}\n" + {% for first,_,third in tuples %}\
"{% endfor %}\n" {{ first }}, {{ third }}
{% endfor %}
"""
let template = Template(templateString: templateString) let template = Template(templateString: templateString)
let result = try template.render(context) let result = try template.render(context)
let fixture = "1, 3\n4, 6\n\n" try expect(result) == """
try expect(result) == fixture 1, 3
4, 6
"""
} }
$0.it("throws when number of variables is more than number of tuple values") { $0.it("throws when number of variables is more than number of tuple values") {
let templateString = "{% for key,value,smth in dict %}" + let templateString = """
"{% endfor %}\n" {% for key,value,smth in dict %}
{% endfor %}
"""
let template = Template(templateString: templateString) let template = Template(templateString: templateString)
try expect(template.render(context)).toThrow() try expect(template.render(context)).toThrow()
@@ -184,15 +201,18 @@ func testForNode() {
} }
$0.it("can iterate over dictionary") { $0.it("can iterate over dictionary") {
let templateString = "{% for key, value in dict %}" + let templateString = """
"{{ key }}: {{ value }}," + {% for key, value in dict %}\
"{% endfor %}" {{ key }}: {{ value }},\
{% endfor %}
"""
let template = Template(templateString: templateString) let template = Template(templateString: templateString)
let result = try template.render(context) let result = try template.render(context)
let sortedResult = result.characters.split(separator: ",").map(String.init).sorted(by: <) try expect(result) == """
try expect(sortedResult) == ["one: I", "two: II"] one: I,two: II,
"""
} }
$0.it("renders supports iterating over dictionary") { $0.it("renders supports iterating over dictionary") {
@@ -204,8 +224,9 @@ func testForNode() {
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key"], nodes: nodes, emptyNodes: emptyNodes, where: nil) let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
let result = try node.render(context) let result = try node.render(context)
let sortedResult = result.characters.split(separator: ",").map(String.init).sorted(by: <) try expect(result) == """
try expect(sortedResult) == ["one", "two"] one,two,
"""
} }
$0.it("renders supports iterating over dictionary") { $0.it("renders supports iterating over dictionary") {
@@ -217,19 +238,17 @@ func testForNode() {
] ]
let emptyNodes: [NodeType] = [TextNode(text: "empty")] let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key", "value"], nodes: nodes, emptyNodes: emptyNodes, where: nil) let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key", "value"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
let result = try node.render(context) let result = try node.render(context)
let sortedResult = result.characters.split(separator: ",").map(String.init).sorted(by: <) try expect(result) == """
try expect(sortedResult) == ["one=I", "two=II"] one=I,two=II,
"""
} }
$0.it("handles invalid input") { $0.it("handles invalid input") {
let tokens: [Token] = [ let token = Token.block(value: "for i", at: .unknown)
.block(value: "for i"), 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)
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]")
try expect(try parser.parse()).toThrow(error) try expect(try parser.parse()).toThrow(error)
} }
@@ -252,7 +271,11 @@ func testForNode() {
let node = ForNode(resolvable: Variable("struct"), loopVariables: ["property", "value"], nodes: nodes, emptyNodes: []) let node = ForNode(resolvable: Variable("struct"), loopVariables: ["property", "value"], nodes: nodes, emptyNodes: [])
let result = try node.render(context) let result = try node.render(context)
try expect(result) == "string=abc\nnumber=123\n" try expect(result) == """
string=abc
number=123
"""
} }
$0.it("can iterate tuple items") { $0.it("can iterate tuple items") {
@@ -270,7 +293,11 @@ func testForNode() {
let node = ForNode(resolvable: Variable("tuple"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: []) let node = ForNode(resolvable: Variable("tuple"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
let result = try node.render(context) let result = try node.render(context)
try expect(result) == "one=1\ntwo=dva\n" try expect(result) == """
one=1
two=dva
"""
} }
$0.it("can iterate over class properties") { $0.it("can iterate over class properties") {
@@ -305,7 +332,12 @@ func testForNode() {
let node = ForNode(resolvable: Variable("class"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: []) let node = ForNode(resolvable: Variable("class"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
let result = try node.render(context) let result = try node.render(context)
try expect(result) == "childString=child\nbaseString=base\nbaseInt=1\n" try expect(result) == """
childString=child
baseString=base
baseInt=1
"""
} }
$0.it("can iterate in range of variables") { $0.it("can iterate in range of variables") {
@@ -315,9 +347,9 @@ func testForNode() {
} }
}
} }
fileprivate struct Article { fileprivate struct Article {
let title: String let title: String
let author: String let author: String

View File

@@ -1,15 +1,16 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
class IfNodeTests: XCTestCase {
func testIfNode() { func testIfNode() {
describe("IfNode") { describe("IfNode") {
$0.describe("parsing") { $0.describe("parsing") {
$0.it("can parse an if block") { $0.it("can parse an if block") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value"), .block(value: "if value", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -25,11 +26,11 @@ func testIfNode() {
$0.it("can parse an if with else block") { $0.it("can parse an if with else block") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value"), .block(value: "if value", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "else"), .block(value: "else", at: .unknown),
.text(value: "false"), .text(value: "false", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -50,13 +51,13 @@ func testIfNode() {
$0.it("can parse an if with elif block") { $0.it("can parse an if with elif block") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value"), .block(value: "if value", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "elif something"), .block(value: "elif something", at: .unknown),
.text(value: "some"), .text(value: "some", at: .unknown),
.block(value: "else"), .block(value: "else", at: .unknown),
.text(value: "false"), .text(value: "false", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -81,11 +82,11 @@ func testIfNode() {
$0.it("can parse an if with elif block without else") { $0.it("can parse an if with elif block without else") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value"), .block(value: "if value", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "elif something"), .block(value: "elif something", at: .unknown),
.text(value: "some"), .text(value: "some", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -106,15 +107,15 @@ func testIfNode() {
$0.it("can parse an if with multiple elif block") { $0.it("can parse an if with multiple elif block") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value"), .block(value: "if value", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "elif something1"), .block(value: "elif something1", at: .unknown),
.text(value: "some1"), .text(value: "some1", at: .unknown),
.block(value: "elif something2"), .block(value: "elif something2", at: .unknown),
.text(value: "some2"), .text(value: "some2", at: .unknown),
.block(value: "else"), .block(value: "else", at: .unknown),
.text(value: "false"), .text(value: "false", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -144,9 +145,9 @@ func testIfNode() {
$0.it("can parse an if with complex expression") { $0.it("can parse an if with complex expression") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value == \"test\" and not name"), .block(value: "if value == \"test\" and not name", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -156,11 +157,11 @@ func testIfNode() {
$0.it("can parse an ifnot block") { $0.it("can parse an ifnot block") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "ifnot value"), .block(value: "ifnot value", at: .unknown),
.text(value: "false"), .text(value: "false", at: .unknown),
.block(value: "else"), .block(value: "else", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -179,22 +180,18 @@ func testIfNode() {
} }
$0.it("throws an error when parsing an if block without an endif") { $0.it("throws an error when parsing an if block without an endif") {
let tokens: [Token] = [ let tokens: [Token] = [.block(value: "if value", at: .unknown)]
.block(value: "if value"),
]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError("`endif` was not found.") let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
try expect(try parser.parse()).toThrow(error) try expect(try parser.parse()).toThrow(error)
} }
$0.it("throws an error when parsing an ifnot without an endif") { $0.it("throws an error when parsing an ifnot without an endif") {
let tokens: [Token] = [ let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
.block(value: "ifnot value"),
]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError("`endif` was not found.") let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
try expect(try parser.parse()).toThrow(error) try expect(try parser.parse()).toThrow(error)
} }
} }
@@ -242,9 +239,9 @@ func testIfNode() {
$0.it("supports variable filters in the if expression") { $0.it("supports variable filters in the if expression") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value|uppercase == \"TEST\""), .block(value: "if value|uppercase == \"TEST\"", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -256,9 +253,9 @@ func testIfNode() {
$0.it("evaluates nil properties as false") { $0.it("evaluates nil properties as false") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if instance.value"), .block(value: "if instance.value", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -273,11 +270,11 @@ func testIfNode() {
$0.it("supports closed range variables") { $0.it("supports closed range variables") {
let tokens: [Token] = [ let tokens: [Token] = [
.block(value: "if value in 1...3"), .block(value: "if value in 1...3", at: .unknown),
.text(value: "true"), .text(value: "true", at: .unknown),
.block(value: "else"), .block(value: "else", at: .unknown),
.text(value: "false"), .text(value: "false", at: .unknown),
.block(value: "endif") .block(value: "endif", at: .unknown)
] ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
@@ -288,4 +285,5 @@ func testIfNode() {
} }
} }
}
} }

View File

@@ -1,9 +1,10 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import PathKit import PathKit
class IncludeTests: XCTestCase {
func testInclude() { func testInclude() {
describe("Include") { describe("Include") {
let path = Path(#file) + ".." + "fixtures" let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path]) let loader = FileSystemLoader(paths: [path])
@@ -11,15 +12,15 @@ func testInclude() {
$0.describe("parsing") { $0.describe("parsing") {
$0.it("throws an error when no template is given") { $0.it("throws an error when no template is given") {
let tokens: [Token] = [ .block(value: "include") ] 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("'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") 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)
} }
$0.it("can parse a valid include block") { $0.it("can parse a valid include block") {
let tokens: [Token] = [ .block(value: "include \"test.html\"") ] let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse() let nodes = try parser.parse()
@@ -31,7 +32,7 @@ func testInclude() {
$0.describe("rendering") { $0.describe("rendering") {
$0.it("throws an error when rendering without a loader") { $0.it("throws an error when rendering without a loader") {
let node = IncludeNode(templateName: Variable("\"test.html\"")) let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
do { do {
_ = try node.render(Context()) _ = try node.render(Context())
@@ -41,7 +42,7 @@ func testInclude() {
} }
$0.it("throws an error when it cannot find the included template") { $0.it("throws an error when it cannot find the included template") {
let node = IncludeNode(templateName: Variable("\"unknown.html\"")) let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
do { do {
_ = try node.render(Context(environment: environment)) _ = try node.render(Context(environment: environment))
@@ -51,18 +52,21 @@ func testInclude() {
} }
$0.it("successfully renders a found included template") { $0.it("successfully renders a found included template") {
let node = IncludeNode(templateName: Variable("\"test.html\"")) let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
let context = Context(dictionary: ["target": "World"], environment: environment) let context = Context(dictionary: ["target": "World"], environment: environment)
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") { $0.it("successfully passes context") {
let template = Template(templateString: "{% include \"test.html\" child %}") let template = Template(templateString: """
{% include "test.html" child %}
""")
let context = Context(dictionary: ["child": ["target": "World"]], environment: environment) let context = Context(dictionary: ["child": ["target": "World"]], environment: environment)
let value = try template.render(context) let value = try template.render(context)
try expect(value) == "Hello World!" try expect(value) == "Hello World!"
} }
} }
} }
}
} }

View File

@@ -1,9 +1,10 @@
import XCTest
import Spectre import Spectre
import Stencil import Stencil
import PathKit import PathKit
class InheritenceTests: XCTestCase {
func testInheritence() { func testInheritence() {
describe("Inheritence") { describe("Inheritence") {
let path = Path(#file) + ".." + "fixtures" let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path]) let loader = FileSystemLoader(paths: [path])
@@ -11,17 +12,27 @@ func testInheritence() {
$0.it("can inherit from another template") { $0.it("can inherit from another template") {
let template = try environment.loadTemplate(name: "child.html") let template = try environment.loadTemplate(name: "child.html")
try expect(try template.render()) == "Super_Header Child_Header\nChild_Body" try expect(try template.render()) == """
Super_Header Child_Header
Child_Body
"""
} }
$0.it("can inherit from another template inheriting from another template") { $0.it("can inherit from another template inheriting from another template") {
let template = try environment.loadTemplate(name: "child-child.html") let template = try environment.loadTemplate(name: "child-child.html")
try expect(try template.render()) == "Super_Header Child_Header Child_Child_Header\nChild_Body" try expect(try template.render()) == """
Super_Header Child_Header Child_Child_Header
Child_Body
"""
} }
$0.it("can inherit from a template that calls a super block") { $0.it("can inherit from a template that calls a super block") {
let template = try environment.loadTemplate(name: "child-super.html") let template = try environment.loadTemplate(name: "child-super.html")
try expect(try template.render()) == "Header\nChild_Body" try expect(try template.render()) == """
Header
Child_Body
"""
}
} }
} }
} }

View File

@@ -1,15 +1,22 @@
import PathKit
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import XCTest
class LexerTests: XCTestCase {
func testLexer() { func testLexer() {
describe("Lexer") { describe("Lexer") {
func makeSourceMap(_ token: String, for lexer: Lexer, options: String.CompareOptions = []) -> SourceMap {
guard let range = lexer.templateString.range(of: token, options: options) else { fatalError("Token not found") }
return SourceMap(location: lexer.rangeLocation(range))
}
$0.it("can tokenize text") { $0.it("can tokenize text") {
let lexer = Lexer(templateString: "Hello World") let lexer = Lexer(templateString: "Hello World")
let tokens = lexer.tokenize() let tokens = lexer.tokenize()
try expect(tokens.count) == 1 try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "Hello World") try expect(tokens.first) == .text(value: "Hello World", at: makeSourceMap("Hello World", for: lexer))
} }
$0.it("can tokenize a comment") { $0.it("can tokenize a comment") {
@@ -17,7 +24,7 @@ func testLexer() {
let tokens = lexer.tokenize() let tokens = lexer.tokenize()
try expect(tokens.count) == 1 try expect(tokens.count) == 1
try expect(tokens.first) == .comment(value: "Comment") try expect(tokens.first) == .comment(value: "Comment", at: makeSourceMap("Comment", for: lexer))
} }
$0.it("can tokenize a variable") { $0.it("can tokenize a variable") {
@@ -25,66 +32,106 @@ func testLexer() {
let tokens = lexer.tokenize() let tokens = lexer.tokenize()
try expect(tokens.count) == 1 try expect(tokens.count) == 1
try expect(tokens.first) == .variable(value: "Variable") try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
} }
$0.it("can tokenize unclosed tag by ignoring it") { $0.it("can tokenize a token without spaces") {
let lexer = Lexer(templateString: "{{ thing") let lexer = Lexer(templateString: "{{Variable}}")
let tokens = lexer.tokenize() let tokens = lexer.tokenize()
try expect(tokens.count) == 1 try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "") try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
}
$0.it("can tokenize unclosed tag by ignoring it") {
let templateString = "{{ thing"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "", at: makeSourceMap("{{ thing", for: lexer))
} }
$0.it("can tokenize a mixture of content") { $0.it("can tokenize a mixture of content") {
let lexer = Lexer(templateString: "My name is {{ name }}.") let templateString = "My name is {{ myname }}."
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize() let tokens = lexer.tokenize()
try expect(tokens.count) == 3 try expect(tokens.count) == 3
try expect(tokens[0]) == Token.text(value: "My name is ") try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
try expect(tokens[1]) == Token.variable(value: "name") try expect(tokens[1]) == Token.variable(value: "myname", at: makeSourceMap("myname", for: lexer))
try expect(tokens[2]) == Token.text(value: ".") try expect(tokens[2]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
} }
$0.it("can tokenize two variables without being greedy") { $0.it("can tokenize two variables without being greedy") {
let lexer = Lexer(templateString: "{{ thing }}{{ name }}") let templateString = "{{ thing }}{{ name }}"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize() let tokens = lexer.tokenize()
try expect(tokens.count) == 2 try expect(tokens.count) == 2
try expect(tokens[0]) == Token.variable(value: "thing") try expect(tokens[0]) == Token.variable(value: "thing", at: makeSourceMap("thing", for: lexer))
try expect(tokens[1]) == Token.variable(value: "name") try expect(tokens[1]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer))
} }
$0.it("can tokenize an unclosed block") { $0.it("can tokenize an unclosed block") {
let lexer = Lexer(templateString: "{%}") let lexer = Lexer(templateString: "{%}")
let _ = lexer.tokenize() _ = lexer.tokenize()
}
$0.it("can tokenize incorrect syntax without crashing") {
let lexer = Lexer(templateString: "func some() {{% if %}")
_ = lexer.tokenize()
} }
$0.it("can tokenize an empty variable") { $0.it("can tokenize an empty variable") {
let lexer = Lexer(templateString: "{{}}") let lexer = Lexer(templateString: "{{}}")
let _ = lexer.tokenize() _ = lexer.tokenize()
} }
$0.it("can tokenize with new lines") { $0.it("can tokenize with new lines") {
let lexer = Lexer(templateString: let templateString = """
"My name is {%\n" + My name is {%
" if name\n" + if name
" and\n" + and
" name\n" + name
"%}{{\n" + %}{{
"name\n" + name
"}}{%\n" + }}{%
"endif %}.") endif %}.
"""
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize() let tokens = lexer.tokenize()
try expect(tokens.count) == 5 try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "My name is ") try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
try expect(tokens[1]) == Token.block(value: "if name and name") try expect(tokens[1]) == Token.block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
try expect(tokens[2]) == Token.variable(value: "name") try expect(tokens[2]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
try expect(tokens[3]) == Token.block(value: "endif") try expect(tokens[3]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
try expect(tokens[4]) == Token.text(value: ".") try expect(tokens[4]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
}
$0.it("can tokenize escape sequences") {
let templateString = "class Some {{ '{' }}{% if true %}{{ stuff }}{% endif %}"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
try expect(tokens[1]) == Token.variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
try expect(tokens[2]) == Token.block(value: "if true", at: makeSourceMap("if true", for: lexer))
try expect(tokens[3]) == Token.variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
try expect(tokens[4]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
}
}
}
func testPerformance() throws {
let path = Path(#file) + ".." + "fixtures" + "huge.html"
let content: String = try path.read()
measure {
let lexer = Lexer(templateString: content)
_ = lexer.tokenize()
} }
} }
} }

View File

@@ -1,9 +1,10 @@
import XCTest
import Spectre import Spectre
import Stencil import Stencil
import PathKit import PathKit
class TemplateLoaderTests: XCTestCase {
func testTemplateLoader() { func testTemplateLoader() {
describe("FileSystemLoader") { describe("FileSystemLoader") {
let path = Path(#file) + ".." + "fixtures" let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path]) let loader = FileSystemLoader(paths: [path])
@@ -52,4 +53,5 @@ func testTemplateLoader() {
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"]) _ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
} }
} }
}
} }

View File

@@ -1,15 +1,20 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
class ErrorNode : NodeType { class ErrorNode : NodeType {
let token: Token?
init(token: Token? = nil) {
self.token = token
}
func render(_ context: Context) throws -> String { func render(_ context: Context) throws -> String {
throw TemplateSyntaxError("Custom Error") throw TemplateSyntaxError("Custom Error")
} }
} }
class NodeTests: XCTestCase {
func testNode() { func testNode() {
describe("Node") { describe("Node") {
let context = Context(dictionary: [ let context = Context(dictionary: [
"name": "Kyle", "name": "Kyle",
@@ -57,4 +62,5 @@ func testNode() {
} }
} }
} }
}
} }

View File

@@ -1,14 +1,16 @@
import XCTest
import Foundation import Foundation
import Spectre import Spectre
@testable import Stencil @testable import Stencil
func testNowNode() { class NowNodeTests: XCTestCase {
#if !os(Linux) func testNowNode() {
#if !os(Linux)
describe("NowNode") { describe("NowNode") {
$0.describe("parsing") { $0.describe("parsing") {
$0.it("parses default format without any now arguments") { $0.it("parses default format without any now arguments") {
let tokens: [Token] = [ .block(value: "now") ] let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse() let nodes = try parser.parse()
@@ -18,7 +20,7 @@ func testNowNode() {
} }
$0.it("parses now with a format") { $0.it("parses now with a format") {
let tokens: [Token] = [ .block(value: "now \"HH:mm\"") ] let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse() let nodes = try parser.parse()
let node = nodes.first as? NowNode let node = nodes.first as? NowNode
@@ -39,5 +41,6 @@ func testNowNode() {
} }
} }
} }
#endif #endif
}
} }

View File

@@ -1,12 +1,13 @@
import XCTest
import Spectre import Spectre
@testable import Stencil @testable import Stencil
class TokenParserTests: XCTestCase {
func testTokenParser() { func testTokenParser() {
describe("TokenParser") { describe("TokenParser") {
$0.it("can parse a text token") { $0.it("can parse a text token") {
let parser = TokenParser(tokens: [ let parser = TokenParser(tokens: [
.text(value: "Hello World") .text(value: "Hello World", at: .unknown)
], environment: Environment()) ], environment: Environment())
let nodes = try parser.parse() let nodes = try parser.parse()
@@ -18,7 +19,7 @@ func testTokenParser() {
$0.it("can parse a variable token") { $0.it("can parse a variable token") {
let parser = TokenParser(tokens: [ let parser = TokenParser(tokens: [
.variable(value: "'name'") .variable(value: "'name'", at: .unknown)
], environment: Environment()) ], environment: Environment())
let nodes = try parser.parse() let nodes = try parser.parse()
@@ -30,7 +31,7 @@ func testTokenParser() {
$0.it("can parse a comment token") { $0.it("can parse a comment token") {
let parser = TokenParser(tokens: [ let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!") .comment(value: "Secret stuff!", at: .unknown)
], environment: Environment()) ], environment: Environment())
let nodes = try parser.parse() let nodes = try parser.parse()
@@ -44,7 +45,7 @@ func testTokenParser() {
} }
let parser = TokenParser(tokens: [ let parser = TokenParser(tokens: [
.block(value: "known"), .block(value: "known", at: .unknown),
], environment: Environment(extensions: [simpleExtension])) ], environment: Environment(extensions: [simpleExtension]))
let nodes = try parser.parse() let nodes = try parser.parse()
@@ -52,11 +53,11 @@ func testTokenParser() {
} }
$0.it("errors when parsing an unknown tag") { $0.it("errors when parsing an unknown tag") {
let parser = TokenParser(tokens: [ let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
.block(value: "unknown"), let parser = TokenParser(tokens: tokens, environment: Environment())
], environment: Environment())
try expect(try parser.parse()).toThrow(TemplateSyntaxError("Unknown template tag 'unknown'")) try expect(try parser.parse()).toThrow(TemplateSyntaxError(reason: "Unknown template tag 'unknown'", token: tokens.first))
}
} }
} }
} }

View File

@@ -1,21 +1,21 @@
import XCTest
import Spectre import Spectre
import Stencil import Stencil
fileprivate struct CustomNode : NodeType {
fileprivate class CustomNode : NodeType { let token: Token?
func render(_ context:Context) throws -> String { func render(_ context:Context) throws -> String {
return "Hello World" return "Hello World"
} }
} }
fileprivate struct Article { fileprivate struct Article {
let title: String let title: String
let author: String let author: String
} }
class StencilTests: XCTestCase {
func testStencil() { func testStencil() {
describe("Stencil") { describe("Stencil") {
let exampleExtension = Extension() let exampleExtension = Extension()
@@ -24,18 +24,20 @@ func testStencil() {
} }
exampleExtension.registerTag("customtag") { parser, token in exampleExtension.registerTag("customtag") { parser, token in
return CustomNode() return CustomNode(token: token)
} }
let environment = Environment(extensions: [exampleExtension]) let environment = Environment(extensions: [exampleExtension])
$0.it("can render the README example") { $0.it("can render the README example") {
let templateString = "There are {{ articles.count }} articles.\n" + let templateString = """
"\n" + There are {{ articles.count }} articles.
"{% for article in articles %}" +
" - {{ article.title }} by {{ article.author }}.\n" + {% for article in articles %}\
"{% endfor %}\n" - {{ article.title }} by {{ article.author }}.
{% endfor %}
"""
let context = [ let context = [
"articles": [ "articles": [
@@ -47,13 +49,13 @@ func testStencil() {
let template = Template(templateString: templateString) let template = Template(templateString: templateString)
let result = try template.render(context) let result = try template.render(context)
let fixture = "There are 2 articles.\n" + try expect(result) == """
"\n" + There are 2 articles.
" - Migrating from OCUnit to XCTest by Kyle Fuller.\n" +
" - Memory Management with ARC by Kyle Fuller.\n" +
"\n"
try expect(result) == fixture - Migrating from OCUnit to XCTest by Kyle Fuller.
- Memory Management with ARC by Kyle Fuller.
"""
} }
$0.it("can render a custom template tag") { $0.it("can render a custom template tag") {
@@ -66,4 +68,5 @@ func testStencil() {
try expect(result) == "Hello World" try expect(result) == "Hello World"
} }
} }
}
} }

View File

@@ -1,8 +1,9 @@
import XCTest
import Spectre import Spectre
import Stencil @testable import Stencil
class TemplateTests: XCTestCase {
func testTemplate() { func testTemplate() {
describe("Template") { describe("Template") {
$0.it("can render a template from a string") { $0.it("can render a template from a string") {
let template = Template(templateString: "Hello World") let template = Template(templateString: "Hello World")
@@ -15,5 +16,7 @@ func testTemplate() {
let result = try template.render([ "name": "Kyle" ]) let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello World" try expect(result) == "Hello World"
} }
}
} }
} }

View File

@@ -1,11 +1,12 @@
import XCTest
import Spectre import Spectre
import Stencil @testable import Stencil
class TokenTests: XCTestCase {
func testToken() { func testToken() {
describe("Token") { describe("Token") {
$0.it("can split the contents into components") { $0.it("can split the contents into components") {
let token = Token.text(value: "hello world") let token = Token.text(value: "hello world", at: .unknown)
let components = token.components() let components = token.components()
try expect(components.count) == 2 try expect(components.count) == 2
@@ -14,7 +15,7 @@ func testToken() {
} }
$0.it("can split the contents into components with single quoted strings") { $0.it("can split the contents into components with single quoted strings") {
let token = Token.text(value: "hello 'kyle fuller'") let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
let components = token.components() let components = token.components()
try expect(components.count) == 2 try expect(components.count) == 2
@@ -23,7 +24,7 @@ func testToken() {
} }
$0.it("can split the contents into components with double quoted strings") { $0.it("can split the contents into components with double quoted strings") {
let token = Token.text(value: "hello \"kyle fuller\"") let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
let components = token.components() let components = token.components()
try expect(components.count) == 2 try expect(components.count) == 2
@@ -31,4 +32,5 @@ func testToken() {
try expect(components[1]) == "\"kyle fuller\"" try expect(components[1]) == "\"kyle fuller\""
} }
} }
}
} }

View File

@@ -1,3 +1,4 @@
import XCTest
import Foundation import Foundation
import Spectre import Spectre
@testable import Stencil @testable import Stencil
@@ -29,7 +30,8 @@ fileprivate class Blog: WebSite {
let featuring: Article? = Article(author: Person(name: "Jhon")) let featuring: Article? = Article(author: Person(name: "Jhon"))
} }
func testVariable() { class VariableTests: XCTestCase {
func testVariable() {
describe("Variable") { describe("Variable") {
let context = Context(dictionary: [ let context = Context(dictionary: [
"name": "Kyle", "name": "Kyle",
@@ -44,9 +46,9 @@ func testVariable() {
"tuple": (one: 1, two: 2) "tuple": (one: 1, two: 2)
]) ])
#if os(OSX) #if os(OSX)
context["object"] = Object() context["object"] = Object()
#endif #endif
context["blog"] = Blog() context["blog"] = Blog()
$0.it("can resolve a string literal with double quotes") { $0.it("can resolve a string literal with double quotes") {
@@ -86,13 +88,62 @@ func testVariable() {
try expect(result) == "Kyle" try expect(result) == "Kyle"
} }
$0.it("can resolve an item from a dictionary") { $0.context("given string") {
$0.it("can resolve an item via it's index") {
let variable = Variable("name.0")
let result = try variable.resolve(context) as? Character
try expect(result) == "K"
let variable1 = Variable("name.1")
let result1 = try variable1.resolve(context) as? Character
try expect(result1) == "y"
}
$0.it("can resolve an item via unknown index") {
let variable = Variable("name.5")
let result = try variable.resolve(context) as? Character
try expect(result).to.beNil()
let variable1 = Variable("name.-5")
let result1 = try variable1.resolve(context) as? Character
try expect(result1).to.beNil()
}
$0.it("can resolve the first item") {
let variable = Variable("name.first")
let result = try variable.resolve(context) as? Character
try expect(result) == "K"
}
$0.it("can resolve the last item") {
let variable = Variable("name.last")
let result = try variable.resolve(context) as? Character
try expect(result) == "e"
}
$0.it("can get the characters count") {
let variable = Variable("name.count")
let result = try variable.resolve(context) as? Int
try expect(result) == 4
}
}
$0.context("given dictionary") {
$0.it("can resolve an item") {
let variable = Variable("profiles.github") let variable = Variable("profiles.github")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
try expect(result) == "kylef" try expect(result) == "kylef"
} }
$0.it("can resolve an item from an array via it's index") { $0.it("can get the count") {
let variable = Variable("profiles.count")
let result = try variable.resolve(context) as? Int
try expect(result) == 1
}
}
$0.context("given array") {
$0.it("can resolve an item via it's index") {
let variable = Variable("contacts.0") let variable = Variable("contacts.0")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
try expect(result) == "Katie" try expect(result) == "Katie"
@@ -102,7 +153,7 @@ func testVariable() {
try expect(result1) == "Carlton" try expect(result1) == "Carlton"
} }
$0.it("can resolve an item from an array via unknown index") { $0.it("can resolve an item via unknown index") {
let variable = Variable("contacts.5") let variable = Variable("contacts.5")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
try expect(result).to.beNil() try expect(result).to.beNil()
@@ -112,31 +163,32 @@ func testVariable() {
try expect(result1).to.beNil() try expect(result1).to.beNil()
} }
$0.it("can resolve the first item from an array") { $0.it("can resolve the first item") {
let variable = Variable("contacts.first") let variable = Variable("contacts.first")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
try expect(result) == "Katie" try expect(result) == "Katie"
} }
$0.it("can resolve the last item from an array") { $0.it("can resolve the last item") {
let variable = Variable("contacts.last") let variable = Variable("contacts.last")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
try expect(result) == "Carlton" try expect(result) == "Carlton"
} }
$0.it("can get the count") {
let variable = Variable("contacts.count")
let result = try variable.resolve(context) as? Int
try expect(result) == 2
}
}
$0.it("can resolve a property with reflection") { $0.it("can resolve a property with reflection") {
let variable = Variable("article.author.name") let variable = Variable("article.author.name")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
try expect(result) == "Kyle" try expect(result) == "Kyle"
} }
$0.it("can get the count of a dictionary") { #if os(OSX)
let variable = Variable("profiles.count")
let result = try variable.resolve(context) as? Int
try expect(result) == 1
}
#if os(OSX)
$0.it("can resolve a value via KVO") { $0.it("can resolve a value via KVO") {
let variable = Variable("object.title") let variable = Variable("object.title")
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
@@ -148,7 +200,13 @@ func testVariable() {
let result = try variable.resolve(context) as? String let result = try variable.resolve(context) as? String
try expect(result) == "Foo" try expect(result) == "Foo"
} }
#endif
$0.it("does not crash on KVO") {
let variable = Variable("object.fullname")
let result = try variable.resolve(context) as? String
try expect(result).to.beNil()
}
#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")
@@ -189,7 +247,7 @@ func testVariable() {
try expect(result) == 2 try expect(result) == 2
} }
$0.describe("Subrscripting") { $0.describe("Subscripting") {
$0.it("can resolve a property subscript via reflection") { $0.it("can resolve a property subscript via reflection") {
try context.push(dictionary: ["property": "name"]) { try context.push(dictionary: ["property": "name"]) {
let variable = Variable("article.author[property]") let variable = Variable("article.author[property]")
@@ -214,7 +272,7 @@ func testVariable() {
} }
} }
#if os(OSX) #if os(OSX)
$0.it("can resolve a subscript via KVO") { $0.it("can resolve a subscript via KVO") {
try context.push(dictionary: ["property": "name"]) { try context.push(dictionary: ["property": "name"]) {
let variable = Variable("object[property]") let variable = Variable("object[property]")
@@ -222,7 +280,7 @@ func testVariable() {
try expect(result) == "Foo" try expect(result) == "Foo"
} }
} }
#endif #endif
$0.it("can resolve an optional subscript via reflection") { $0.it("can resolve an optional subscript via reflection") {
try context.push(dictionary: ["property": "featuring"]) { try context.push(dictionary: ["property": "featuring"]) {
@@ -292,7 +350,9 @@ func testVariable() {
}() }()
func makeVariable(_ token: String) throws -> RangeVariable? { func makeVariable(_ token: String) throws -> RangeVariable? {
return try RangeVariable(token, environment: context.environment) let token = Token.variable(value: token, at: .unknown)
let parser = TokenParser(tokens: [token], environment: context.environment)
return try RangeVariable(token.contents, parser: parser, containedIn: token)
} }
$0.it("can resolve closed range as array") { $0.it("can resolve closed range as array") {
@@ -329,4 +389,24 @@ func testVariable() {
} }
} }
describe("inline if expression") {
$0.it("can conditionally render variable") {
let template: Template = "{{ variable if variable|uppercase == \"A\" }}"
try expect(template.render(Context(dictionary: ["variable": "a"]))) == "a"
try expect(template.render(Context(dictionary: ["variable": "b"]))) == ""
}
$0.it("can render with else expression") {
let template: Template = "{{ variable if variable|uppercase == \"A\" else fallback|uppercase }}"
try expect(template.render(Context(dictionary: ["variable": "b", "fallback": "c"]))) == "C"
}
$0.it("throws when used invalid condition") {
let template: Template = "{{ variable if variable \"A\" }}"
try expect(template.render(Context(dictionary: ["variable": "a"]))).toThrow()
}
}
}
} }

View File

@@ -1,30 +0,0 @@
import XCTest
public func stencilTests() {
testContext()
testFilter()
testLexer()
testToken()
testTokenParser()
testTemplateLoader()
testTemplate()
testVariable()
testNode()
testForNode()
testExpressions()
testIfNode()
testNowNode()
testInclude()
testInheritence()
testFilterTag()
testEnvironment()
testStencil()
}
class StencilTests: XCTestCase {
func testRunStencilTests() {
stencilTests()
}
}

View File

@@ -0,0 +1,135 @@
import XCTest
extension ContextTests {
static let __allTests = [
("testContext", testContext),
]
}
extension EnvironmentTests {
static let __allTests = [
("testEnvironment", testEnvironment),
]
}
extension ExpressionsTests {
static let __allTests = [
("testExpressions", testExpressions),
]
}
extension FilterTagTests {
static let __allTests = [
("testFilterTag", testFilterTag),
]
}
extension FilterTests {
static let __allTests = [
("testFilter", testFilter),
]
}
extension ForNodeTests {
static let __allTests = [
("testForNode", testForNode),
]
}
extension IfNodeTests {
static let __allTests = [
("testIfNode", testIfNode),
]
}
extension IncludeTests {
static let __allTests = [
("testInclude", testInclude),
]
}
extension InheritenceTests {
static let __allTests = [
("testInheritence", testInheritence),
]
}
extension LexerTests {
static let __allTests = [
("testLexer", testLexer),
("testPerformance", testPerformance),
]
}
extension NodeTests {
static let __allTests = [
("testNode", testNode),
]
}
extension NowNodeTests {
static let __allTests = [
("testNowNode", testNowNode),
]
}
extension StencilTests {
static let __allTests = [
("testStencil", testStencil),
]
}
extension TemplateLoaderTests {
static let __allTests = [
("testTemplateLoader", testTemplateLoader),
]
}
extension TemplateTests {
static let __allTests = [
("testTemplate", testTemplate),
]
}
extension TokenParserTests {
static let __allTests = [
("testTokenParser", testTokenParser),
]
}
extension TokenTests {
static let __allTests = [
("testToken", testToken),
]
}
extension VariableTests {
static let __allTests = [
("testVariable", testVariable),
]
}
#if !os(macOS)
public func __allTests() -> [XCTestCaseEntry] {
return [
testCase(ContextTests.__allTests),
testCase(EnvironmentTests.__allTests),
testCase(ExpressionsTests.__allTests),
testCase(FilterTagTests.__allTests),
testCase(FilterTests.__allTests),
testCase(ForNodeTests.__allTests),
testCase(IfNodeTests.__allTests),
testCase(IncludeTests.__allTests),
testCase(InheritenceTests.__allTests),
testCase(LexerTests.__allTests),
testCase(NodeTests.__allTests),
testCase(NowNodeTests.__allTests),
testCase(StencilTests.__allTests),
testCase(TemplateLoaderTests.__allTests),
testCase(TemplateTests.__allTests),
testCase(TokenParserTests.__allTests),
testCase(TokenTests.__allTests),
testCase(VariableTests.__allTests),
]
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
{% block header %}Header{% endblock %}
{% block body %}Body {{ target|unknown }} {% endblock %}

View File

@@ -0,0 +1,3 @@
{% extends "invalid-base.html" %}
{% block body %}Child {{ block.super }}{% endblock %}

View File

@@ -0,0 +1 @@
Hello {{ target|unknown }}!

View File

@@ -2,7 +2,7 @@
<p> <p>
<iframe <iframe
src="https://ghbtns.com/github-btn.html?user=kylef&repo=Stencil&type=watch&count=true&size=large" src="https://ghbtns.com/github-btn.html?user=stencilproject&repo=Stencil&type=watch&count=true&size=large"
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"> allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px">
</iframe> </iframe>
</p> </p>

View File

@@ -149,6 +149,19 @@ Will be treated as:
one or (two and three) one or (two and three)
You can use parentheses to change operator precedence. For example:
.. code-block:: html+django
{% if (one or two) and three %}
Will be treated as:
.. code-block:: text
(one or two) and three
``==`` operator ``==`` operator
""""""""""""""" """""""""""""""

View File

@@ -58,9 +58,9 @@ author = 'Kyle Fuller'
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '0.7.0' version = '0.13.1'
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = '0.7.0' release = '0.13.1'
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

View File

@@ -48,6 +48,17 @@ Registering custom filters with arguments:
return value return value
} }
Registering custom boolean filters:
.. code-block:: swift
ext.registerFilter("ordinary", negativeFilterName: "odd") { (value: Any?) in
if let value = value as? Int {
return myInt % 2 == 0
}
return nil
}
Custom Tags Custom Tags
----------- -----------

View File

@@ -14,7 +14,7 @@ dependencies inside ``Package.swift``.
let package = Package( let package = Package(
name: "MyApplication", name: "MyApplication",
dependencies: [ dependencies: [
.Package(url: "https://github.com/kylef/Stencil.git", majorVersion: 0, minor: 8), .Package(url: "https://github.com/stencilproject/Stencil.git", majorVersion: 0, minor: 13),
] ]
) )
@@ -26,7 +26,7 @@ If you're using CocoaPods, you can add Stencil to your ``Podfile`` and then run
.. code-block:: ruby .. code-block:: ruby
pod 'Stencil', '~> 0.8.0' pod 'Stencil', '~> 0.13.1'
Carthage Carthage
-------- --------
@@ -37,7 +37,7 @@ Carthage
.. code-block:: text .. code-block:: text
github "kylef/Stencil" ~> 0.8.0 github "stencilproject/Stencil" ~> 0.13.1
2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil: 2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil:

View File

@@ -20,9 +20,9 @@ following lookup:
- Context lookup - Context lookup
- Dictionary lookup - Dictionary lookup
- Array lookup (first, last, count, index) - Array and string lookup (first, last, count, by index)
- Key value coding lookup - Key value coding lookup
- Type introspection - Type introspection (via ``Mirror``)
For example, if `people` was an array: For example, if `people` was an array: