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/
.build/
Packages/
Package.resolved
Package.pins
*.xcodeproj

View File

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

View File

@@ -2,6 +2,100 @@
## 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
- 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.
[Ilya Puchka](https://github.com/ilyapuchka)
[#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
, i.e. `myfilter = "uppercase"` and then use it to invoke this filter with `{{ string|filter:myfilter }}`.
- Improvements in error reporting.
[Ilya Puchka](https://github.com/ilyapuchka)
[#203](https://github.com/stencilproject/Stencil/pull/203)
[#167](https://github.com/stencilproject/Stencil/pull/167)
### Bug Fixes

View File

@@ -1,4 +1,4 @@
Copyright (c) 2014, Kyle Fuller
Copyright (c) 2018, Kyle Fuller
All rights reserved.
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,
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.

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
let package = Package(
name: "Stencil",
products: [
.library(name: "Stencil", targets: ["Stencil"])
],
dependencies: [
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 9),
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 8),
.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"
])
]
)

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 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.loader = loader
self.extensions = (extensions ?? []) + [DefaultExtension()]
@@ -28,11 +31,18 @@ public struct Environment {
public func renderTemplate(name: String, context: [String: Any]? = nil) throws -> String {
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 {
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)
}
}

View File

@@ -17,3 +17,67 @@ public class TemplateDoesNotExist: Error, CustomStringConvertible {
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
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
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
public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
filters[name] = .simple(filter)
@@ -46,9 +55,9 @@ class DefaultExtension: Extension {
registerTag("for", parser: ForNode.parse)
registerTag("if", parser: IfNode.parse)
registerTag("ifnot", parser: IfNode.parse_ifnot)
#if !os(Linux)
#if !os(Linux)
registerTag("now", parser: NowNode.parse)
#endif
#endif
registerTag("include", parser: IncludeNode.parse)
registerTag("extends", parser: ExtendsNode.parse)
registerTag("block", parser: BlockNode.parse)

View File

@@ -1,6 +1,7 @@
class FilterNode : NodeType {
let resolvable: Resolvable
let nodes: [NodeType]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components()
@@ -15,20 +16,21 @@ class FilterNode : NodeType {
throw TemplateSyntaxError("`endfilter` was not found.")
}
let resolvable = try parser.compileFilter("filter_value|\(bits[1])")
return FilterNode(nodes: blocks, resolvable: resolvable)
let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
}
init(nodes: [NodeType], resolvable: Resolvable) {
init(nodes: [NodeType], resolvable: Resolvable, token: Token) {
self.nodes = nodes
self.resolvable = resolvable
self.token = token
}
func render(_ context: Context) throws -> String {
let value = try renderNodes(nodes, context)
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
if arguments.count > 0 {
guard let value = arguments[0] as? Int else {
throw TemplateSyntaxError("'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))")
throw TemplateSyntaxError("""
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
""")
}
indentWidth = value
}
@@ -82,7 +84,9 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
var indentationChar = " "
if arguments.count > 1 {
guard let value = arguments[1] as? String else {
throw TemplateSyntaxError("'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))")
throw TemplateSyntaxError("""
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
""")
}
indentationChar = value
}

View File

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

View File

@@ -38,10 +38,11 @@ func findOperator(name: String) -> Operator? {
}
enum IfToken {
case infix(name: String, bindingPower: Int, op: InfixOperator.Type)
case prefix(name: String, bindingPower: Int, op: PrefixOperator.Type)
indirect enum IfToken {
case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
case variable(Resolvable)
case subExpression(Expression)
case end
var bindingPower: Int {
@@ -52,6 +53,8 @@ enum IfToken {
return bindingPower
case .variable(_):
return 0
case .subExpression(_):
return 0
case .end:
return 0
}
@@ -66,6 +69,8 @@ enum IfToken {
return op.init(expression: expression)
case .variable(let variable):
return VariableExpression(variable: variable)
case .subExpression(let expression):
return expression
case .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")
case .variable(let variable):
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:
throw TemplateSyntaxError("'if' expression error: end")
}
@@ -100,19 +107,69 @@ final class IfExpressionParser {
let tokens: [IfToken]
var position: Int = 0
init(components: [String], environment: Environment) throws {
self.tokens = try components.map { component in
private init(tokens: [IfToken]) {
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) {
switch op {
case .infix(let name, let bindingPower, let cls):
return .infix(name: name, bindingPower: bindingPower, op: cls)
case .prefix(let name, let bindingPower, let cls):
return .prefix(name: name, bindingPower: bindingPower, op: cls)
case .infix(let name, let bindingPower, let operatorType):
return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
case .prefix(let name, let bindingPower, let operatorType):
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 {
@@ -176,49 +233,51 @@ final class IfCondition {
class IfNode : NodeType {
let conditions: [IfCondition]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components()
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"]))
var conditions: [IfCondition] = [
IfCondition(expression: expression, nodes: nodes)
]
var token = parser.nextToken()
while let current = token, current.contents.hasPrefix("elif") {
var nextToken = parser.nextToken()
while let current = nextToken, current.contents.hasPrefix("elif") {
var components = current.components()
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"]))
token = parser.nextToken()
nextToken = parser.nextToken()
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"]))))
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.")
}
return IfNode(conditions: conditions)
return IfNode(conditions: conditions, token: token)
}
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components()
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()
var trueNodes = [NodeType]()
var falseNodes = [NodeType]()
let expression = try parseExpression(components: components, tokenParser: parser, token: token)
falseNodes = try parser.parse(until(["endif", "else"]))
guard let token = parser.nextToken() else {
@@ -230,15 +289,15 @@ class IfNode : NodeType {
_ = parser.nextToken()
}
let expression = try parser.compileExpression(components: components)
return IfNode(conditions: [
IfCondition(expression: expression, nodes: trueNodes),
IfCondition(expression: nil, nodes: falseNodes),
])
], token: token)
}
init(conditions: [IfCondition]) {
init(conditions: [IfCondition], token: Token? = nil) {
self.conditions = conditions
self.token = token
}
func render(_ context: Context) throws -> String {

View File

@@ -4,20 +4,26 @@ import PathKit
class IncludeNode : NodeType {
let templateName: Variable
let includeContext: String?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components()
guard bits.count == 2 || 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.includeContext = includeContext
self.token = token
}
func render(_ context: Context) throws -> String {
@@ -27,10 +33,18 @@ class IncludeNode : NodeType {
let template = try context.environment.loadTemplate(name: templateName)
do {
let subContext = includeContext.flatMap { context[$0] as? [String: Any] }
return try context.push(dictionary: subContext) {
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 var contextKey: String { return "block_context" }
// contains mapping of block names to their nodes and templates where they are defined
var blocks: [String: [BlockNode]]
init(blocks: [String: BlockNode]) {
self.blocks = [:]
blocks.forEach { (key, value) in
self.blocks[key] = [value]
}
blocks.forEach { self.blocks[$0.key] = [$0.value] }
}
func push(_ block: BlockNode, forKey blockName: String) {
@@ -51,6 +50,7 @@ extension Collection {
class ExtendsNode : NodeType {
let templateName: Variable
let blocks: [String:BlockNode]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components()
@@ -64,7 +64,7 @@ class ExtendsNode : NodeType {
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
var dict = accumulator
@@ -72,12 +72,13 @@ class ExtendsNode : NodeType {
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.blocks = blocks
self.token = token
}
func render(_ context: Context) throws -> String {
@@ -85,21 +86,33 @@ class ExtendsNode : NodeType {
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
if let context = context[BlockContext.contextKey] as? BlockContext {
blockContext = context
for (key, value) in blocks {
blockContext.push(value, forKey: key)
if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext {
blockContext = currentBlockContext
for (name, block) in blocks {
blockContext.push(block, forKey: name)
}
} else {
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 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 {
let name: String
let nodes: [NodeType]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components()
@@ -119,25 +133,57 @@ class BlockNode : NodeType {
let blockName = bits[1]
let nodes = try parser.parse(until(["endblock"]))
_ = 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.nodes = nodes
self.token = token
}
func render(_ context: Context) throws -> String {
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let node = blockContext.pop(name) {
let newContext: [String: Any] = [
BlockContext.contextKey: blockContext,
"block": ["super": try self.render(context)]
]
return try context.push(dictionary: newContext) {
return try node.render(context)
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
let childContext = try self.childContext(child, blockContext: blockContext, context: context)
// render extension node
do {
return try context.push(dictionary: childContext) {
return try child.render(context)
}
} catch {
throw error.withToken(child.token)
}
}
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
}
for c in variable.characters {
for c in variable {
switch c {
case "." where subscriptLevel == 0:
try foundSeparator()

View File

@@ -1,16 +1,47 @@
struct Lexer {
let templateString: String
import Foundation
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.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 {
guard string.characters.count > 4 else { return "" }
let start = string.index(string.startIndex, offsetBy: 2)
let end = string.index(string.endIndex, offsetBy: -2)
let trimmed = String(string[start..<end])
guard string.count > 4 else { return "" }
let trimmed = String(string.dropFirst(2).dropLast(2))
.components(separatedBy: "\n")
.filter({ !$0.isEmpty })
.map({ $0.trim(character: " ") })
@@ -18,112 +49,150 @@ struct Lexer {
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("{{") {
return .variable(value: strip())
return .variable(value: value, at: sourceMap)
} else if string.hasPrefix("{%") {
return .block(value: strip())
return .block(value: value, at: sourceMap)
} 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] {
var tokens: [Token] = []
let scanner = Scanner(templateString)
let map = [
"{{": "}}",
"{%": "%}",
"{#": "#}",
]
while !scanner.isEmpty {
if let text = scanner.scan(until: ["{{", "{%", "{#"]) {
if !text.1.isEmpty {
tokens.append(createToken(string: text.1))
if let (char, text) = scanner.scanForTokenStart(Lexer.tokenChars) {
if !text.isEmpty {
tokens.append(createToken(string: text, at: scanner.range))
}
let end = map[text.0]!
let result = scanner.scan(until: end, returnUntil: true)
tokens.append(createToken(string: result))
guard let end = Lexer.tokenCharMap[char] else { continue }
let result = scanner.scanForTokenEnd(end)
tokens.append(createToken(string: result, at: scanner.range))
} else {
tokens.append(createToken(string: scanner.content))
tokens.append(createToken(string: scanner.content, at: scanner.range))
scanner.content = ""
}
}
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 {
let originalContent: 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) {
self.originalContent = content
self.content = content
range = content.startIndex..<content.startIndex
}
var isEmpty: Bool {
return content.isEmpty
}
func scan(until: String, returnUntil: Bool = false) -> String {
if until.isEmpty {
return ""
}
var index = content.startIndex
while index != content.endIndex {
let substring = content.substring(from: index)
if substring.hasPrefix(until) {
let result = content.substring(to: index)
content = substring
if returnUntil {
content = content.substring(from: until.endIndex)
return result + until
}
/// Scans for the end of a token, with a specific ending character. If we're
/// searching for the end of a block token `%}`, this method receives a `%`.
/// The scanner will search for that `%` followed by a `}`.
///
/// Note: if the end 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 after the token. `range` will be set to the range
/// of the token within the template.
///
/// - Parameter tokenChar: The token end character to search for.
/// - Returns: The content of a token, or "" if no token end was found.
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
var foundChar = false
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
} else {
foundChar = (char == tokenChar)
}
index = content.index(after: index)
}
content = ""
return ""
}
func scan(until: [String]) -> (String, String)? {
if until.isEmpty {
return nil
}
/// Scans for the start of a token, with a list of potential starting
/// characters. To scan for the start of variables (`{{`), blocks (`{%`) and
/// 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
while index != content.endIndex {
let substring = content.substring(from: index)
for string in until {
if substring.hasPrefix(string) {
let result = content.substring(to: index)
content = substring
return (string, result)
range = range.upperBound..<range.upperBound
for (index, char) in content.unicodeScalars.enumerated() {
if foundBrace && tokenChars.contains(char) {
let result = String(content.prefix(index - 1))
content = String(content.dropFirst(index - 1))
range = range.upperBound..<originalContent.index(range.upperBound, offsetBy: index - 1)
return (char, result)
} else {
foundBrace = (char == Scanner.tokenStartDelimiter)
}
}
index = content.index(after: index)
}
return nil
}
}
extension String {
func findFirstNot(character: Character) -> String.Index? {
var index = startIndex
@@ -157,3 +226,5 @@ extension String {
return String(self[first..<last])
}
}
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)

View File

@@ -1,35 +1,31 @@
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 {
/// Render the node in the given context
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
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 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
}
@@ -41,9 +37,11 @@ public class SimpleNode : NodeType {
public class TextNode : NodeType {
public let text:String
public let token: Token?
public init(text:String) {
self.text = text
self.token = nil
}
public func render(_ context:Context) throws -> String {
@@ -59,16 +57,65 @@ public protocol Resolvable {
public class VariableNode : NodeType {
public let variable: Resolvable
public var token: Token?
let condition: Expression?
let elseExpression: Resolvable?
public init(variable: Resolvable) {
self.variable = variable
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
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.token = token
self.condition = nil
self.elseExpression = nil
}
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)
return stringify(result)
}

View File

@@ -4,23 +4,25 @@ import Foundation
class NowNode : NodeType {
let format:Variable
let token: Token?
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
var format:Variable?
let components = token.components()
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 {
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.token = token
}
func render(_ context: Context) throws -> String {

View File

@@ -37,10 +37,10 @@ public class TokenParser {
let token = nextToken()!
switch token {
case .text(let text):
case .text(let text, _):
nodes.append(TextNode(text: text))
case .variable:
nodes.append(VariableNode(variable: try compileResolvable(token.contents)))
try nodes.append(VariableNode.parse(self, token: token))
case .block:
if let parse_until = parse_until , parse_until(self, token) {
prependToken(token)
@@ -48,8 +48,13 @@ public class TokenParser {
}
if let tag = token.components().first {
let parser = try environment.findTag(name: tag)
nodes.append(try parser(self, token))
do {
let parser = try findTag(name: tag)
let node = try parser(self, token)
nodes.append(node)
} catch {
throw error.withToken(token)
}
}
case .comment:
continue
@@ -108,7 +113,10 @@ extension Environment {
if suggestedFilters.isEmpty {
throw TemplateSyntaxError("Unknown filter '\(name)'.")
} 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
.map({ (filterName: $0, distance: $0.levenshteinDistance(name)) })
// do not suggest filters which names are shorter than the distance
.filter({ $0.filterName.characters.count > $0.distance })
.filter({ $0.filterName.count > $0.distance })
guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else {
return []
}
@@ -126,6 +134,26 @@ extension Environment {
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 {
return try FilterExpression(token: token, environment: self)
}
@@ -134,11 +162,17 @@ extension Environment {
return try IfExpressionParser(components: components, environment: self).parse()
}
@available(*, deprecated, message: "Use compileResolvable(_:containedIn:)")
public func compileResolvable(_ token: String) throws -> Resolvable {
return try RangeVariable(token, environment: self)
?? 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
@@ -155,10 +189,10 @@ extension String {
// initialize v0 (the previous row of distances)
// this row is A[0][i]: edit distance for an empty s
// the distance is just the number of characters to delete from t
last = [Int](0...target.characters.count)
current = [Int](repeating: 0, count: target.characters.count + 1)
last = [Int](0...target.count)
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
// first element of v1 is A[i+1][0]
@@ -166,7 +200,7 @@ extension String {
current[0] = i + 1
// 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(
last[j+1] + 1,
current[j] + 1,
@@ -178,7 +212,7 @@ extension String {
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
open class Template: ExpressibleByStringLiteral {
let environment: Environment
let templateString: String
internal(set) var environment: Environment
let tokens: [Token]
/// 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) {
self.environment = environment ?? Environment()
self.name = name
self.templateString = templateString
let lexer = Lexer(templateString: templateString)
let lexer = Lexer(templateName: name, templateString: templateString)
tokens = lexer.tokenize()
}

View File

@@ -13,7 +13,7 @@ extension String {
let specialCharacters = ",|:"
func appendWord(_ word: String) {
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
} else if specialCharacters.contains(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 }
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 {
/// A token representing a piece of text.
case text(value: String)
case text(value: String, at: SourceMap)
/// A token representing a variable.
case variable(value: String)
case variable(value: String, at: SourceMap)
/// A token representing a comment.
case comment(value: String)
case comment(value: String, at: SourceMap)
/// 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
public func components() -> [String] {
switch self {
case .block(let value):
return value.smartSplit()
case .variable(let value):
return value.smartSplit()
case .text(let value):
return value.smartSplit()
case .comment(let value):
case .block(let value, _),
.variable(let value, _),
.text(let value, _),
.comment(let value, _):
return value.smartSplit()
}
}
public var contents: String {
switch self {
case .block(let value):
return value
case .variable(let value):
return value
case .text(let value):
return value
case .comment(let value):
case .block(let value, _),
.variable(let value, _),
.text(let value, _),
.comment(let 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 variable: Variable
init(token: String, environment: Environment) throws {
let bits = token.smartSplit(separator: "|").map({ String($0).trim(character: " ") })
init(token: String, parser: TokenParser) throws {
let bits = token.split(separator: "|").map({ String($0).trim(character: " ") })
if bits.isEmpty {
filters = []
variable = Variable("")
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
fileprivate func lookup(_ context: Context) throws -> [String] {
var keyPath = KeyPath(variable, in: context)
let keyPath = KeyPath(variable, in: context)
return try keyPath.parse()
}
@@ -62,7 +60,7 @@ public struct Variable : Equatable, Resolvable {
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
// 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
@@ -89,25 +87,17 @@ public struct Variable : Equatable, Resolvable {
current = dictionary[bit]
}
} else if let array = current as? [Any] {
if let index = Int(bit) {
if index >= 0 && index < array.count {
current = array[index]
} else {
current = nil
}
} else if bit == "first" {
current = array.first
} else if bit == "last" {
current = array.last
} else if bit == "count" {
current = array.count
}
current = resolveCollection(array, bit: bit)
} else if let string = current as? String {
current = resolveCollection(string, bit: bit)
} else if let object = current as? NSObject { // NSKeyValueCoding
#if os(Linux)
#if os(Linux)
return nil
#else
#else
if object.responds(to: Selector(bit)) {
current = object.value(forKey: bit)
#endif
}
#endif
} else if let value = current {
current = Mirror(reflecting: value).getValue(for: bit)
if current == nil {
@@ -128,8 +118,22 @@ public struct Variable : Equatable, Resolvable {
}
}
public func ==(lhs: Variable, rhs: Variable) -> Bool {
return lhs.variable == rhs.variable
private func resolveCollection<T: Collection>(_ collection: T, bit: String) -> Any? {
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`.
@@ -140,7 +144,8 @@ public struct RangeVariable: Resolvable {
public let from: 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: "...")
guard components.count == 2 else {
return nil
@@ -150,6 +155,16 @@ public struct RangeVariable: Resolvable {
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? {
let fromResolved = try from.resolve(context)
let toResolved = try to.resolve(context)

View File

@@ -1,6 +1,6 @@
{
"name": "Stencil",
"version": "0.11.0",
"version": "0.13.1",
"summary": "Stencil is a simple and powerful template language for Swift.",
"homepage": "https://stencil.fuller.li",
"license": {
@@ -13,7 +13,7 @@
"social_media_url": "https://twitter.com/kylefuller",
"source": {
"git": "https://github.com/stencilproject/Stencil.git",
"tag": "0.11.0"
"tag": "0.13.1"
},
"source_files": [
"Sources/*.swift"
@@ -23,10 +23,12 @@
"osx": "10.9",
"tvos": "9.0"
},
"cocoapods_version": ">= 1.4.0",
"swift_version": "4.2",
"requires_arc": true,
"dependencies": {
"PathKit": [
"~> 0.8.0"
"~> 0.9.0"
]
}
}

View File

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

View File

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

View File

@@ -1,10 +1,18 @@
import XCTest
import Spectre
import Stencil
import PathKit
@testable import Stencil
func testEnvironment() {
class EnvironmentTests: XCTestCase {
func testEnvironment() {
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") {
let template = try environment.loadTemplate(name: "example.html")
@@ -32,9 +40,321 @@ func testEnvironment() {
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 {
func loadTemplate(name: String, environment: Environment) throws -> Template {

View File

@@ -1,11 +1,17 @@
import XCTest
import Spectre
@testable import Stencil
func testExpressions() {
class ExpressionsTests: XCTestCase {
func testExpressions() {
describe("Expression") {
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") {
let expression = VariableExpression(variable: Variable("value"))
@@ -105,19 +111,19 @@ func testExpressions() {
$0.describe("expression parsing") {
$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(dictionary: ["value": true]))).to.beTrue()
}
$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(dictionary: ["value": true]))).to.beFalse()
}
$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") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
@@ -137,7 +143,7 @@ func testExpressions() {
}
$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") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
@@ -157,7 +163,7 @@ func testExpressions() {
}
$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") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
@@ -193,7 +199,7 @@ func testExpressions() {
}
$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") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
@@ -205,7 +211,7 @@ func testExpressions() {
}
$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") {
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") {
let expression = try! parser.compileExpression(components: ["lhs", ">=", "rhs"])
let expression = try! parseExpression(components: ["lhs", ">=", "rhs"])
$0.it("evaluates to true with lhs == rhs") {
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") {
let expression = try! parser.compileExpression(components: ["lhs", "<", "rhs"])
let expression = try! parseExpression(components: ["lhs", "<", "rhs"])
$0.it("evaluates to true with lhs < rhs") {
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") {
let expression = try! parser.compileExpression(components: ["lhs", "<=", "rhs"])
let expression = try! parseExpression(components: ["lhs", "<=", "rhs"])
$0.it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
@@ -253,7 +259,7 @@ func testExpressions() {
}
$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") {
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
@@ -281,7 +287,7 @@ func testExpressions() {
}
$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") {
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()
}
}
$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
@testable import Stencil
func testFilter() {
func environmentWithFilter(_ name: String, closure: @escaping (Any?) throws -> Any?) -> Environment {
let filterExtension = Extension()
filterExtension.registerFilter(name, filter: closure)
return Environment(extensions: [filterExtension])
}
class FilterTests: XCTestCase {
func testFilter() {
describe("template filters") {
let context: [String: Any] = ["name": "Kyle"]
$0.it("allows you to register a custom filter") {
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 {
return "\(value) \(value)"
}
@@ -23,12 +19,32 @@ func testFilter() {
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"
}
$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") {
let template = Template(templateString: "{{ name|repeat:'value1, \"value2\"' }}")
let template = Template(templateString: """
{{ name|repeat:'value1, "value2"' }}
""")
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in
@@ -40,11 +56,15 @@ func testFilter() {
}
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") {
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()
repeatExtension.registerFilter("repeat") { value, arguments in
@@ -56,7 +76,9 @@ func testFilter() {
}
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") {
@@ -67,21 +89,25 @@ func testFilter() {
}
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") {
let template = Template(templateString: "{{ name|join }}")
let env = environmentWithFilter("join") { (value: Any?) in
let repeatExtension = Extension()
repeatExtension.registerFilter("join") { (value: Any?) in
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"
}
$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"]]))
try expect(result) == "One, Two"
}
@@ -117,25 +143,33 @@ func testFilter() {
$0.it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ names|capitalize }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == "[\"Kyle\", \"Kyle\"]"
try expect(result) == """
["Kyle", "Kyle"]
"""
}
$0.it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ names|uppercase }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == "[\"KYLE\", \"KYLE\"]"
try expect(result) == """
["KYLE", "KYLE"]
"""
}
$0.it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ names|lowercase }}")
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
try expect(result) == "[\"kyle\", \"kyle\"]"
try expect(result) == """
["kyle", "kyle"]
"""
}
}
}
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") {
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
@@ -148,7 +182,9 @@ func testFilter() {
}
$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: [:]))
try expect(result) == "Hello World"
}
@@ -166,7 +202,9 @@ func testFilter() {
}
$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 user: [String: Any?] = ["name": nilName]
let result = try template.render(Context(dictionary: ["user": user]))
@@ -175,7 +213,9 @@ func testFilter() {
}
describe("join filter") {
let template = Template(templateString: "{{ value|join:\", \" }}")
let template = Template(templateString: """
{{ value|join:", " }}
""")
$0.it("joins a collection of strings") {
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
@@ -188,87 +228,163 @@ func testFilter() {
}
$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]))
try expect(result) == "OnetrueTwo"
}
$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"]]))
try expect(result) == "OneTwo"
}
}
describe("split filter") {
let template = Template(templateString: "{{ value|split:\", \" }}")
let template = Template(templateString: """
{{ value|split:", " }}
""")
$0.it("split a string into array") {
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == "[\"One\", \"Two\"]"
try expect(result) == """
["One", "Two"]
"""
}
$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"]))
try expect(result) == "[\"One,\", \"Two\"]"
try expect(result) == """
["One,", "Two"]
"""
}
}
describe("filter suggestion") {
var template: Template!
var filterExtension: Extension!
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
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") {
let template = Template(templateString: "{{ value|unknownFilter }}")
let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'")
template = Template(templateString: "{{ value|unknownFilter }}")
let filterExtension = Extension()
filterExtension = Extension()
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") {
let template = Template(templateString: "{{ value|lowerFirst }}")
let expectedError = TemplateSyntaxError("Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'")
template = Template(templateString: "{{ value|lowerFirst }}")
let filterExtension = Extension()
filterExtension = Extension()
filterExtension.registerFilter("lowerFirstWord") { 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") {
let template = Template(templateString: "{{ value|unknownFilter }}")
let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'.")
try expect(template.render(Context(dictionary: [:]))).toThrow(expectedError)
template = Template(templateString: "{{ value|unknownFilter }}")
try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.", token: "value|unknownFilter")
}
}
describe("indent filter") {
$0.it("indents content") {
let template = Template(templateString: "{{ value|indent:2 }}")
let result = try template.render(Context(dictionary: ["value": "One\nTwo"]))
try expect(result) == "One\n Two"
let template = Template(templateString: """
{{ value|indent:2 }}
""")
let result = try template.render(Context(dictionary: ["value": """
One
Two
"""]))
try expect(result) == """
One
Two
"""
}
$0.it("can indent with arbitrary character") {
let template = Template(templateString: "{{ value|indent:2,\"\t\" }}")
let result = try template.render(Context(dictionary: ["value": "One\nTwo"]))
try expect(result) == "One\n\t\tTwo"
let template = Template(templateString: """
{{ value|indent:2,"\t" }}
""")
let result = try template.render(Context(dictionary: ["value": """
One
Two
"""]))
try expect(result) == """
One
\t\tTwo
"""
}
$0.it("can indent first line") {
let template = Template(templateString: "{{ value|indent:2,\" \",true }}")
let result = try template.render(Context(dictionary: ["value": "One\nTwo"]))
try expect(result) == " One\n Two"
let template = Template(templateString: """
{{ value|indent:2," ",true }}
""")
let result = try template.render(Context(dictionary: ["value": """
One
Two
"""]))
try expect(result) == """
One
Two
"""
}
$0.it("does not indent empty lines") {
let template = Template(templateString: "{{ value|indent }}")
let result = try template.render(Context(dictionary: ["value": "One\n\n\nTwo\n\n"]))
try expect(result) == "One\n\n\n Two\n\n"
let template = Template(templateString: """
{{ value|indent }}
""")
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 Stencil
func testFilterTag() {
class FilterTagTests: XCTestCase {
func testFilterTag() {
describe("Filter Tag") {
$0.it("allows you to use a filter") {
let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
@@ -17,7 +18,7 @@ func testFilterTag() {
}
$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()
}
@@ -27,7 +28,9 @@ func testFilterTag() {
return ($0 as! String).components(separatedBy: $1[0] as! String)
})
let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: "{% filter split:\",\"|join:\";\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": [1, 2]])
let result = try env.renderTemplate(string: """
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": [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)
})
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"
}
}
}
}

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import XCTest
import Spectre
@testable import Stencil
import PathKit
func testInclude() {
class IncludeTests: XCTestCase {
func testInclude() {
describe("Include") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
@@ -11,15 +12,15 @@ func testInclude() {
$0.describe("parsing") {
$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 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)
}
$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 nodes = try parser.parse()
@@ -31,7 +32,7 @@ func testInclude() {
$0.describe("rendering") {
$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 {
_ = try node.render(Context())
@@ -41,7 +42,7 @@ func testInclude() {
}
$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 {
_ = try node.render(Context(environment: environment))
@@ -51,18 +52,21 @@ func testInclude() {
}
$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 value = try node.render(context)
try expect(value) == "Hello World!"
}
$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 value = try template.render(context)
try expect(value) == "Hello World!"
}
}
}
}
}

View File

@@ -1,9 +1,10 @@
import XCTest
import Spectre
import Stencil
import PathKit
func testInheritence() {
class InheritenceTests: XCTestCase {
func testInheritence() {
describe("Inheritence") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
@@ -11,17 +12,27 @@ func testInheritence() {
$0.it("can inherit from another template") {
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") {
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") {
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
@testable import Stencil
import XCTest
func testLexer() {
class LexerTests: XCTestCase {
func testLexer() {
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") {
let lexer = Lexer(templateString: "Hello World")
let tokens = lexer.tokenize()
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") {
@@ -17,7 +24,7 @@ func testLexer() {
let tokens = lexer.tokenize()
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") {
@@ -25,66 +32,106 @@ func testLexer() {
let tokens = lexer.tokenize()
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") {
let lexer = Lexer(templateString: "{{ thing")
$0.it("can tokenize a token without spaces") {
let lexer = Lexer(templateString: "{{Variable}}")
let tokens = lexer.tokenize()
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") {
let lexer = Lexer(templateString: "My name is {{ name }}.")
let templateString = "My name is {{ myname }}."
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 3
try expect(tokens[0]) == Token.text(value: "My name is ")
try expect(tokens[1]) == Token.variable(value: "name")
try expect(tokens[2]) == Token.text(value: ".")
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
try expect(tokens[1]) == Token.variable(value: "myname", at: makeSourceMap("myname", for: lexer))
try expect(tokens[2]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
}
$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()
try expect(tokens.count) == 2
try expect(tokens[0]) == Token.variable(value: "thing")
try expect(tokens[1]) == Token.variable(value: "name")
try expect(tokens[0]) == Token.variable(value: "thing", at: makeSourceMap("thing", for: lexer))
try expect(tokens[1]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer))
}
$0.it("can tokenize an unclosed block") {
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") {
let lexer = Lexer(templateString: "{{}}")
let _ = lexer.tokenize()
_ = lexer.tokenize()
}
$0.it("can tokenize with new lines") {
let lexer = Lexer(templateString:
"My name is {%\n" +
" if name\n" +
" and\n" +
" name\n" +
"%}{{\n" +
"name\n" +
"}}{%\n" +
"endif %}.")
let templateString = """
My name is {%
if name
and
name
%}{{
name
}}{%
endif %}.
"""
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "My name is ")
try expect(tokens[1]) == Token.block(value: "if name and name")
try expect(tokens[2]) == Token.variable(value: "name")
try expect(tokens[3]) == Token.block(value: "endif")
try expect(tokens[4]) == Token.text(value: ".")
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", at: makeSourceMap("{%", for: lexer))
try expect(tokens[2]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
try expect(tokens[3]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
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 Stencil
import PathKit
func testTemplateLoader() {
class TemplateLoaderTests: XCTestCase {
func testTemplateLoader() {
describe("FileSystemLoader") {
let path = Path(#file) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
@@ -52,4 +53,5 @@ func testTemplateLoader() {
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
}
}
}
}

View File

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

View File

@@ -1,14 +1,16 @@
import XCTest
import Foundation
import Spectre
@testable import Stencil
func testNowNode() {
#if !os(Linux)
class NowNodeTests: XCTestCase {
func testNowNode() {
#if !os(Linux)
describe("NowNode") {
$0.describe("parsing") {
$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 nodes = try parser.parse()
@@ -18,7 +20,7 @@ func testNowNode() {
}
$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 nodes = try parser.parse()
let node = nodes.first as? NowNode
@@ -39,5 +41,6 @@ func testNowNode() {
}
}
}
#endif
#endif
}
}

View File

@@ -1,12 +1,13 @@
import XCTest
import Spectre
@testable import Stencil
func testTokenParser() {
class TokenParserTests: XCTestCase {
func testTokenParser() {
describe("TokenParser") {
$0.it("can parse a text token") {
let parser = TokenParser(tokens: [
.text(value: "Hello World")
.text(value: "Hello World", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
@@ -18,7 +19,7 @@ func testTokenParser() {
$0.it("can parse a variable token") {
let parser = TokenParser(tokens: [
.variable(value: "'name'")
.variable(value: "'name'", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
@@ -30,7 +31,7 @@ func testTokenParser() {
$0.it("can parse a comment token") {
let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!")
.comment(value: "Secret stuff!", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
@@ -44,7 +45,7 @@ func testTokenParser() {
}
let parser = TokenParser(tokens: [
.block(value: "known"),
.block(value: "known", at: .unknown),
], environment: Environment(extensions: [simpleExtension]))
let nodes = try parser.parse()
@@ -52,11 +53,11 @@ func testTokenParser() {
}
$0.it("errors when parsing an unknown tag") {
let parser = TokenParser(tokens: [
.block(value: "unknown"),
], environment: Environment())
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
let parser = TokenParser(tokens: tokens, 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 Stencil
fileprivate class CustomNode : NodeType {
fileprivate struct CustomNode : NodeType {
let token: Token?
func render(_ context:Context) throws -> String {
return "Hello World"
}
}
fileprivate struct Article {
let title: String
let author: String
}
func testStencil() {
class StencilTests: XCTestCase {
func testStencil() {
describe("Stencil") {
let exampleExtension = Extension()
@@ -24,18 +24,20 @@ func testStencil() {
}
exampleExtension.registerTag("customtag") { parser, token in
return CustomNode()
return CustomNode(token: token)
}
let environment = Environment(extensions: [exampleExtension])
$0.it("can render the README example") {
let templateString = "There are {{ articles.count }} articles.\n" +
"\n" +
"{% for article in articles %}" +
" - {{ article.title }} by {{ article.author }}.\n" +
"{% endfor %}\n"
let templateString = """
There are {{ articles.count }} articles.
{% for article in articles %}\
- {{ article.title }} by {{ article.author }}.
{% endfor %}
"""
let context = [
"articles": [
@@ -47,13 +49,13 @@ func testStencil() {
let template = Template(templateString: templateString)
let result = try template.render(context)
let fixture = "There are 2 articles.\n" +
"\n" +
" - Migrating from OCUnit to XCTest by Kyle Fuller.\n" +
" - Memory Management with ARC by Kyle Fuller.\n" +
"\n"
try expect(result) == """
There are 2 articles.
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") {
@@ -66,4 +68,5 @@ func testStencil() {
try expect(result) == "Hello World"
}
}
}
}

View File

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

View File

@@ -1,11 +1,12 @@
import XCTest
import Spectre
import Stencil
@testable import Stencil
func testToken() {
class TokenTests: XCTestCase {
func testToken() {
describe("Token") {
$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()
try expect(components.count) == 2
@@ -14,7 +15,7 @@ func testToken() {
}
$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()
try expect(components.count) == 2
@@ -23,7 +24,7 @@ func testToken() {
}
$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()
try expect(components.count) == 2
@@ -31,4 +32,5 @@ func testToken() {
try expect(components[1]) == "\"kyle fuller\""
}
}
}
}

View File

@@ -1,3 +1,4 @@
import XCTest
import Foundation
import Spectre
@testable import Stencil
@@ -29,7 +30,8 @@ fileprivate class Blog: WebSite {
let featuring: Article? = Article(author: Person(name: "Jhon"))
}
func testVariable() {
class VariableTests: XCTestCase {
func testVariable() {
describe("Variable") {
let context = Context(dictionary: [
"name": "Kyle",
@@ -44,9 +46,9 @@ func testVariable() {
"tuple": (one: 1, two: 2)
])
#if os(OSX)
#if os(OSX)
context["object"] = Object()
#endif
#endif
context["blog"] = Blog()
$0.it("can resolve a string literal with double quotes") {
@@ -86,13 +88,62 @@ func testVariable() {
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 result = try variable.resolve(context) as? String
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 result = try variable.resolve(context) as? String
try expect(result) == "Katie"
@@ -102,7 +153,7 @@ func testVariable() {
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 result = try variable.resolve(context) as? String
try expect(result).to.beNil()
@@ -112,31 +163,32 @@ func testVariable() {
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 result = try variable.resolve(context) as? String
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 result = try variable.resolve(context) as? String
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") {
let variable = Variable("article.author.name")
let result = try variable.resolve(context) as? String
try expect(result) == "Kyle"
}
$0.it("can get the count of a dictionary") {
let variable = Variable("profiles.count")
let result = try variable.resolve(context) as? Int
try expect(result) == 1
}
#if os(OSX)
#if os(OSX)
$0.it("can resolve a value via KVO") {
let variable = Variable("object.title")
let result = try variable.resolve(context) as? String
@@ -148,7 +200,13 @@ func testVariable() {
let result = try variable.resolve(context) as? String
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") {
let variable = Variable("blog.articles.0.author.name")
@@ -189,7 +247,7 @@ func testVariable() {
try expect(result) == 2
}
$0.describe("Subrscripting") {
$0.describe("Subscripting") {
$0.it("can resolve a property subscript via reflection") {
try context.push(dictionary: ["property": "name"]) {
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") {
try context.push(dictionary: ["property": "name"]) {
let variable = Variable("object[property]")
@@ -222,7 +280,7 @@ func testVariable() {
try expect(result) == "Foo"
}
}
#endif
#endif
$0.it("can resolve an optional subscript via reflection") {
try context.push(dictionary: ["property": "featuring"]) {
@@ -292,7 +350,9 @@ func testVariable() {
}()
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") {
@@ -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>
<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">
</iframe>
</p>

View File

@@ -149,6 +149,19 @@ Will be treated as:
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
"""""""""""""""

View File

@@ -58,9 +58,9 @@ author = 'Kyle Fuller'
# built documents.
#
# The short X.Y version.
version = '0.7.0'
version = '0.13.1'
# 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
# for a list of supported languages.

View File

@@ -48,6 +48,17 @@ Registering custom filters with arguments:
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
-----------

View File

@@ -14,7 +14,7 @@ dependencies inside ``Package.swift``.
let package = Package(
name: "MyApplication",
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
pod 'Stencil', '~> 0.8.0'
pod 'Stencil', '~> 0.13.1'
Carthage
--------
@@ -37,7 +37,7 @@ Carthage
.. 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:

View File

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