diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d780b..6c8662c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,89 @@ ### Enhancements -- Added support for resolving superclass properties for not-NSObject subclasses -- The `{% for %}` tag can now iterate over tuples, structures and classes via - their stored properties. -- Drastic improvements in error reporting +- Added an optional second parameter to the `include` tag for passing a sub context to the included file. + [Yonas Kolb](https://github.com/yonaskolb) + [#214](https://github.com/stencilproject/Stencil/pull/214) +- Variables now support the subscript notation. For example, if you have a variable `key = "name"`, and an + object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John". + [David Jennes](https://github.com/djbe) + [#215](https://github.com/stencilproject/Stencil/pull/215) +- Adds support for using spaces in filter expression. + [Ilya Puchka](https://github.com/ilyapuchka) + [#178](https://github.com/stencilproject/Stencil/pull/178) +- Improvements in error reporting. + [Ilya Puchka](https://github.com/ilyapuchka) + [#167](https://github.com/stencilproject/Stencil/pull/167) ### Bug Fixes -- Fixed rendering `{{ block.super }}` with several levels of inheritance -- Fixed checking dictionary values for nil in `default` filter -- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. -- Integer literals now resolve into Int values, not Float +- Fixed using quote as a filter parameter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#210](https://github.com/stencilproject/Stencil/pull/210) + + +## 0.11.0 (2018-04-04) + +### Enhancements + +- Added support for resolving superclass properties for not-NSObject subclasses. + [Ilya Puchka](https://github.com/ilyapuchka) + [#152](https://github.com/stencilproject/Stencil/pull/152) +- The `{% for %}` tag can now iterate over tuples, structures and classes via + their stored properties. + [Ilya Puchka](https://github.com/ilyapuchka) + [#172](https://github.com/stencilproject/Stencil/pull/173) +- Added `split` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#187](https://github.com/stencilproject/Stencil/pull/187) +- Allow default string filters to be applied to arrays. + [Ilya Puchka](https://github.com/ilyapuchka) + [#190](https://github.com/stencilproject/Stencil/pull/190) +- Similar filters are suggested when unknown filter is used. + [Ilya Puchka](https://github.com/ilyapuchka) + [#186](https://github.com/stencilproject/Stencil/pull/186) +- Added `indent` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#188](https://github.com/stencilproject/Stencil/pull/188) +- Allow using new lines inside tags. + [Ilya Puchka](https://github.com/ilyapuchka) + [#202](https://github.com/stencilproject/Stencil/pull/202) +- Added support for iterating arrays of tuples. + [Ilya Puchka](https://github.com/ilyapuchka) + [#177](https://github.com/stencilproject/Stencil/pull/177) +- Added support for ranges in if-in expression. + [Ilya Puchka](https://github.com/ilyapuchka) + [#193](https://github.com/stencilproject/Stencil/pull/193) +- Added property `forloop.length` to get number of items in the loop. + [Ilya Puchka](https://github.com/ilyapuchka) + [#171](https://github.com/stencilproject/Stencil/pull/171) +- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#192](https://github.com/stencilproject/Stencil/pull/192) + +### Bug Fixes + +- Fixed rendering `{{ block.super }}` with several levels of inheritance. + [Ilya Puchka](https://github.com/ilyapuchka) + [#154](https://github.com/stencilproject/Stencil/pull/154) +- Fixed checking dictionary values for nil in `default` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#162](https://github.com/stencilproject/Stencil/pull/162) +- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. + [Ilya Puchka](https://github.com/ilyapuchka) + [#168](https://github.com/stencilproject/Stencil/pull/168) +- Integer literals now resolve into Int values, not Float. + [Ilya Puchka](https://github.com/ilyapuchka) + [#181](https://github.com/stencilproject/Stencil/pull/181) +- Fixed accessing properties of optional properties via reflection. + [Ilya Puchka](https://github.com/ilyapuchka) + [#204](https://github.com/stencilproject/Stencil/pull/204) +- No longer render optional values in arrays as `Optional(..)`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#205](https://github.com/stencilproject/Stencil/pull/205) +- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#172](https://github.com/stencilproject/Stencil/pull/172) ## 0.10.1 @@ -215,10 +287,10 @@ ### Bug Fixes - Variables (`{{ variable.5 }}`) that reference an array index at an unknown - index will now resolve to `nil` instead of causing a crash. + index will now resolve to `nil` instead of causing a crash. [#72](https://github.com/kylef/Stencil/issues/72) -- Templates can now extend templates that extend other templates. +- Templates can now extend templates that extend other templates. [#60](https://github.com/kylef/Stencil/issues/60) - If comparisons will now treat 0 and below numbers as negative. diff --git a/Package.swift b/Package.swift index e366cf9..abda948 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,10 @@ +// swift-tools-version:3.1 import PackageDescription let package = Package( name: "Stencil", dependencies: [ - .Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8), - - // https://github.com/apple/swift-package-manager/pull/597 - .Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7), + .Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 9), + .Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 8), ] ) diff --git a/Package@swift-3.swift b/Package@swift-3.swift new file mode 100644 index 0000000..704b083 --- /dev/null +++ b/Package@swift-3.swift @@ -0,0 +1,10 @@ +// swift-tools-version:3.1 +import PackageDescription + +let package = Package( + name: "Stencil", + dependencies: [ + .Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8), + .Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7), + ] +) diff --git a/README.md b/README.md index ded2ca1..75ce68b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stencil -[![Build Status](https://travis-ci.org/kylef/Stencil.svg?branch=master)](https://travis-ci.org/kylef/Stencil) +[![Build Status](https://travis-ci.org/stencilproject/Stencil.svg?branch=master)](https://travis-ci.org/stencilproject/Stencil) Stencil is a simple and powerful template language for Swift. It provides a syntax similar to Django and Mustache. If you're familiar with these, you will @@ -63,6 +63,13 @@ Resources to help you integrate Stencil into a Swift project: - [API Reference](http://stencil.fuller.li/en/latest/api.html) - [Custom Template Tags and Filters](http://stencil.fuller.li/en/latest/custom-template-tags-and-filters.html) +## Projects that use Stencil + +[Sourcery](https://github.com/krzysztofzablocki/Sourcery), +[SwiftGen](https://github.com/SwiftGen/SwiftGen), +[Kitura](https://github.com/IBM-Swift/Kitura), +[Weaver](https://github.com/scribd/Weaver) + ## License Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more diff --git a/Sources/Expression.swift b/Sources/Expression.swift index afea2b1..e329e5a 100644 --- a/Sources/Expression.swift +++ b/Sources/Expression.swift @@ -105,6 +105,10 @@ final class InExpression: Expression, InfixOperator, CustomStringConvertible { if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] { return rhs.contains(lhs) + } else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange { + return rhs.contains(lhs) + } else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange { + return rhs.contains(lhs) } else if let lhs = lhsValue as? String, let rhs = rhsValue as? String { return rhs.contains(lhs) } else if lhsValue == nil && rhsValue == nil { diff --git a/Sources/Extension.swift b/Sources/Extension.swift index bfa9454..1203378 100644 --- a/Sources/Extension.swift +++ b/Sources/Extension.swift @@ -57,6 +57,8 @@ class DefaultExtension: Extension { registerFilter("uppercase", filter: uppercase) registerFilter("lowercase", filter: lowercase) registerFilter("join", filter: joinFilter) + registerFilter("split", filter: splitFilter) + registerFilter("indent", filter: indentFilter) } } diff --git a/Sources/Filters.swift b/Sources/Filters.swift index f84a534..fece6eb 100644 --- a/Sources/Filters.swift +++ b/Sources/Filters.swift @@ -1,13 +1,25 @@ func capitalise(_ value: Any?) -> Any? { - return stringify(value).capitalized + if let array = value as? [Any?] { + return array.map { stringify($0).capitalized } + } else { + return stringify(value).capitalized + } } func uppercase(_ value: Any?) -> Any? { - return stringify(value).uppercased() + if let array = value as? [Any?] { + return array.map { stringify($0).uppercased() } + } else { + return stringify(value).uppercased() + } } func lowercase(_ value: Any?) -> Any? { - return stringify(value).lowercased() + if let array = value as? [Any?] { + return array.map { stringify($0).lowercased() } + } else { + return stringify(value).lowercased() + } } func defaultFilter(value: Any?, arguments: [Any?]) -> Any? { @@ -40,3 +52,62 @@ func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? { return value } + +func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? { + guard arguments.count < 2 else { + throw TemplateSyntaxError("'split' filter takes a single argument") + } + + let separator = stringify(arguments.first ?? " ") + if let value = value as? String { + return value.components(separatedBy: separator) + } + + return value +} + +func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? { + guard arguments.count <= 3 else { + throw TemplateSyntaxError("'indent' filter can take at most 3 arguments") + } + + var indentWidth = 4 + if arguments.count > 0 { + guard let value = arguments[0] as? Int else { + throw TemplateSyntaxError("'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))") + } + indentWidth = value + } + + var indentationChar = " " + if arguments.count > 1 { + guard let value = arguments[1] as? String else { + throw TemplateSyntaxError("'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))") + } + indentationChar = value + } + + var indentFirst = false + if arguments.count > 2 { + guard let value = arguments[2] as? Bool else { + throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool") + } + indentFirst = value + } + + let indentation = [String](repeating: indentationChar, count: indentWidth).joined(separator: "") + return indent(stringify(value), indentation: indentation, indentFirst: indentFirst) +} + + +func indent(_ content: String, indentation: String, indentFirst: Bool) -> String { + guard !indentation.isEmpty else { return content } + + var lines = content.components(separatedBy: .newlines) + let firstLine = (indentFirst ? indentation : "") + lines.removeFirst() + let result = lines.reduce([firstLine]) { (result, line) in + return result + [(line.isEmpty ? "" : "\(indentation)\(line)")] + } + return result.joined(separator: "\n") +} + diff --git a/Sources/ForTag.swift b/Sources/ForTag.swift index 1b6b1d2..d5bfee7 100644 --- a/Sources/ForTag.swift +++ b/Sources/ForTag.swift @@ -11,25 +11,28 @@ class ForNode : NodeType { class func parse(_ parser:TokenParser, token:Token) throws -> NodeType { let components = token.components() - guard components.count >= 3 && components[2] == "in" && - (components.count == 4 || (components.count >= 6 && components[4] == "where")) else { - throw TemplateSyntaxError("'for' statements should use the following syntax 'for x in y where condition'.") + func hasToken(_ token: String, at index: Int) -> Bool { + return components.count > (index + 1) && components[index] == token + } + + func endsOrHasToken(_ token: String, at index: Int) -> Bool { + return components.count == index || hasToken(token, at: index) + } + + guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else { + throw TemplateSyntaxError("'for' statements should use the syntax: `for in [where ]`.") } let loopVariables = components[1].characters .split(separator: ",") .map(String.init) - .map { $0.trimmingCharacters(in: CharacterSet.whitespaces) } + .map { $0.trim(character: " ") } - let variable = components[3] - let filter = try parser.compileFilter(variable, containedIn: token) + let resolvable = try parser.compileResolvable(components[3], containedIn: token) - let `where`: Expression? - if components.count >= 6 { - `where` = try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token) - } else { - `where` = nil - } + let `where` = hasToken("where", at: 4) + ? try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token) + : nil let forNodes = try parser.parse(until(["endfor", "empty"])) @@ -43,7 +46,7 @@ class ForNode : NodeType { _ = parser.nextToken() } - return ForNode(resolvable: filter, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`, token: token) + return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`, token: token) } init(resolvable: Resolvable, loopVariables: [String], nodes:[NodeType], emptyNodes:[NodeType], where: Expression? = nil, token: Token? = nil) { @@ -55,25 +58,26 @@ class ForNode : NodeType { self.token = token } - func push(value: Any, context: Context, closure: () throws -> (Result)) rethrows -> Result { + func push(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result { if loopVariables.isEmpty { return try context.push() { return try closure() } } - if let value = value as? (Any, Any) { - let first = loopVariables[0] - - if loopVariables.count == 2 { - let second = loopVariables[1] - - return try context.push(dictionary: [first: value.0, second: value.1]) { - return try closure() - } + let valueMirror = Mirror(reflecting: value) + if case .tuple? = valueMirror.displayStyle { + if loopVariables.count > Int(valueMirror.children.count) { + throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables") } + var variablesContext = [String: Any]() + valueMirror.children.prefix(loopVariables.count).enumerated().forEach({ (offset, element) in + if loopVariables[offset] != "_" { + variablesContext[loopVariables[offset]] = element.value + } + }) - return try context.push(dictionary: [first: value.0]) { + return try context.push(dictionary: variablesContext) { return try closure() } } @@ -133,14 +137,15 @@ class ForNode : NodeType { "last": index == (count - 1), "counter": index + 1, "counter0": index, - ] + "length": count + ] return try context.push(dictionary: ["forloop": forContext]) { return try push(value: item, context: context) { try renderNodes(nodes, context) } } - }.joined(separator: "") + }.joined(separator: "") } return try context.push { diff --git a/Sources/IfTag.swift b/Sources/IfTag.swift index ec7952c..e014812 100644 --- a/Sources/IfTag.swift +++ b/Sources/IfTag.swift @@ -111,7 +111,7 @@ final class IfExpressionParser { } } - return .variable(try tokenParser.compileFilter(component, containedIn: token)) + return .variable(try tokenParser.compileResolvable(component, containedIn: token)) } } diff --git a/Sources/Include.swift b/Sources/Include.swift index 8ebec9f..6dd331c 100644 --- a/Sources/Include.swift +++ b/Sources/Include.swift @@ -3,20 +3,22 @@ import PathKit class IncludeNode : NodeType { let templateName: Variable + let includeContext: String? let token: Token? class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { let bits = token.components() - guard bits.count == 2 else { - throw TemplateSyntaxError("'include' tag takes one argument, the template file to be included") + guard bits.count == 2 || bits.count == 3 else { + throw TemplateSyntaxError("'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file") } - return IncludeNode(templateName: Variable(bits[1]), token: token) + return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token) } - init(templateName: Variable, token: Token) { + init(templateName: Variable, includeContext: String? = nil, token: Token) { self.templateName = templateName + self.includeContext = includeContext self.token = token } @@ -28,7 +30,8 @@ class IncludeNode : NodeType { let template = try context.environment.loadTemplate(name: templateName) do { - return try context.push { + let subContext = includeContext.flatMap { context[$0] as? [String: Any] } + return try context.push(dictionary: subContext) { return try template.render(context) } } catch { diff --git a/Sources/KeyPath.swift b/Sources/KeyPath.swift new file mode 100644 index 0000000..445ef29 --- /dev/null +++ b/Sources/KeyPath.swift @@ -0,0 +1,112 @@ +import Foundation + +/// A structure used to represent a template variable, and to resolve it in a given context. +final class KeyPath { + private var components = [String]() + private var current = "" + private var partialComponents = [String]() + private var subscriptLevel = 0 + + let variable: String + let context: Context + + // Split the keypath string and resolve references if possible + init(_ variable: String, in context: Context) { + self.variable = variable + self.context = context + } + + func parse() throws -> [String] { + defer { + components = [] + current = "" + partialComponents = [] + subscriptLevel = 0 + } + + for c in variable.characters { + switch c { + case "." where subscriptLevel == 0: + try foundSeparator() + case "[": + try openBracket() + case "]": + try closeBracket() + default: + try addCharacter(c) + } + } + try finish() + + return components + } + + private func foundSeparator() throws { + if !current.isEmpty { + partialComponents.append(current) + } + + guard !partialComponents.isEmpty else { + throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'") + } + + components += partialComponents + current = "" + partialComponents = [] + } + + // when opening the first bracket, we must have a partial component + private func openBracket() throws { + guard !partialComponents.isEmpty || !current.isEmpty else { + throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'") + } + + if subscriptLevel > 0 { + current.append("[") + } else if !current.isEmpty { + partialComponents.append(current) + current = "" + } + + subscriptLevel += 1 + } + + // for a closing bracket at root level, try to resolve the reference + private func closeBracket() throws { + guard subscriptLevel > 0 else { + throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'") + } + + if subscriptLevel > 1 { + current.append("]") + } else if !current.isEmpty, + let value = try Variable(current).resolve(context) { + partialComponents.append("\(value)") + current = "" + } else { + throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'") + } + + subscriptLevel -= 1 + } + + private func addCharacter(_ c: Character) throws { + guard partialComponents.isEmpty || subscriptLevel > 0 else { + throw TemplateSyntaxError("Unexpected character '\(c)' in variable '\(variable)'") + } + + current.append(c) + } + + private func finish() throws { + // check if we have a last piece + if !current.isEmpty { + partialComponents.append(current) + } + components += partialComponents + + guard subscriptLevel == 0 else { + throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'") + } + } +} diff --git a/Sources/Lexer.swift b/Sources/Lexer.swift index bf4d9f8..4c8f8a8 100644 --- a/Sources/Lexer.swift +++ b/Sources/Lexer.swift @@ -14,7 +14,12 @@ struct Lexer { guard string.characters.count > 4 else { return "" } let start = string.index(string.startIndex, offsetBy: 2) let end = string.index(string.endIndex, offsetBy: -2) - return String(string[start.. String { if let result = result as? String { return result + } else if let array = result as? [Any?] { + return unwrap(array).description } else if let result = result as? CustomStringConvertible { return result.description } else if let result = result as? NSObject { @@ -87,3 +89,16 @@ func stringify(_ result: Any?) -> String { return "" } + +func unwrap(_ array: [Any?]) -> [Any] { + return array.map { (item: Any?) -> Any in + if let item = item { + if let items = item as? [Any?] { + return unwrap(items) + } else { + return item + } + } + else { return item as Any } + } +} diff --git a/Sources/Parser.swift b/Sources/Parser.swift index 4a0b2c6..5dde40c 100644 --- a/Sources/Parser.swift +++ b/Sources/Parser.swift @@ -40,7 +40,7 @@ public class TokenParser { case .text(let text, _): nodes.append(TextNode(text: text)) case .variable: - let filter = try compileFilter(token.contents, containedIn: token) + let filter = try compileResolvable(token.contents, containedIn: token) nodes.append(VariableNode(variable: filter, token: token)) case .block: if let parse_until = parse_until , parse_until(self, token) { @@ -94,7 +94,26 @@ public class TokenParser { } } - throw TemplateSyntaxError("Unknown filter '\(name)'") + let suggestedFilters = self.suggestedFilters(for: name) + if suggestedFilters.isEmpty { + throw TemplateSyntaxError("Unknown filter '\(name)'.") + } else { + throw TemplateSyntaxError("Unknown filter '\(name)'. Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", ")).") + } + } + + private func suggestedFilters(for name: String) -> [String] { + let allFilters = environment.extensions.flatMap({ $0.filters.keys }) + + let filtersWithDistance = allFilters + .map({ (filterName: $0, distance: $0.levenshteinDistance(name)) }) + // do not suggest filters which names are shorter than the distance + .filter({ $0.filterName.characters.count > $0.distance }) + guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else { + return [] + } + // suggest all filters with the same distance + return filtersWithDistance.filter({ $0.distance == minDistance }).map({ $0.filterName }) } public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable { @@ -122,4 +141,57 @@ public class TokenParser { return try FilterExpression(token: token, parser: self) } + @available(*, deprecated, message: "Use compileResolvable(_:containedIn:)") + public func compileResolvable(_ token: String) throws -> Resolvable { + return try RangeVariable(token, parser: self) + ?? compileFilter(token) + } + + public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable { + return try RangeVariable(token, parser: self) + ?? compileFilter(token, containedIn: containingToken) + } + +} + +// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows +extension String { + + subscript(_ i: Int) -> Character { + return self[self.index(self.startIndex, offsetBy: i)] + } + + func levenshteinDistance(_ target: String) -> Int { + // create two work vectors of integer distances + var last, current: [Int] + + // initialize v0 (the previous row of distances) + // this row is A[0][i]: edit distance for an empty s + // the distance is just the number of characters to delete from t + last = [Int](0...target.characters.count) + current = [Int](repeating: 0, count: target.characters.count + 1) + + for i in 0.. 0 { + if let precedingChar = components.last?.characters.last, specialCharacters.characters.contains(precedingChar) { + components[components.count-1] += word + } else if specialCharacters.contains(word) { + components[components.count-1] += word + } else { + components.append(word) + } + } else { + components.append(word) + } + } + for character in self.characters { if character == "'" { singleQuoteCount += 1 } else if character == "\"" { doubleQuoteCount += 1 } @@ -18,8 +33,8 @@ extension String { if separate != separator { word.append(separate) - } else if singleQuoteCount % 2 == 0 && doubleQuoteCount % 2 == 0 && !word.isEmpty { - components.append(word) + } else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty { + appendWord(word) word = "" } @@ -33,7 +48,7 @@ extension String { } if !word.isEmpty { - components.append(word) + appendWord(word) } return components diff --git a/Sources/Variable.swift b/Sources/Variable.swift index ee81467..1c54a55 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -48,8 +48,10 @@ public struct Variable : Equatable, Resolvable { self.variable = variable } - fileprivate func lookup() -> [String] { - return variable.characters.split(separator: ".").map(String.init) + // Split the lookup string and resolve references if possible + fileprivate func lookup(_ context: Context) throws -> [String] { + var keyPath = KeyPath(variable, in: context) + return try keyPath.parse() } /// Resolve the variable in the given context @@ -68,8 +70,12 @@ public struct Variable : Equatable, Resolvable { if let number = Number(variable) { return number } + // Boolean literal + if let bool = Bool(variable) { + return bool + } - for bit in lookup() { + for bit in try lookup(context) { current = normalize(current) if let context = current as? Context { @@ -124,6 +130,42 @@ public func ==(lhs: Variable, rhs: Variable) -> Bool { return lhs.variable == rhs.variable } +/// A structure used to represet range of two integer values expressed as `from...to`. +/// Values should be numbers (they will be converted to integers). +/// Rendering this variable produces array from range `from...to`. +/// If `from` is more than `to` array will contain values of reversed range. +public struct RangeVariable: Resolvable { + public let from: Resolvable + public let to: Resolvable + + public init?(_ token: String, parser: TokenParser) throws { + let components = token.components(separatedBy: "...") + guard components.count == 2 else { + return nil + } + + self.from = try parser.compileFilter(components[0]) + self.to = try parser.compileFilter(components[1]) + } + + public func resolve(_ context: Context) throws -> Any? { + let fromResolved = try from.resolve(context) + let toResolved = try to.resolve(context) + + guard let from = fromResolved.flatMap(toNumber(value:)).flatMap(Int.init) else { + throw TemplateSyntaxError("'from' value is not an Integer (\(fromResolved ?? "nil"))") + } + + guard let to = toResolved.flatMap(toNumber(value:)).flatMap(Int.init) else { + throw TemplateSyntaxError("'to' value is not an Integer (\(toResolved ?? "nil") )") + } + + let range = min(from, to)...max(from, to) + return from > to ? Array(range.reversed()) : Array(range) + } + +} + func normalize(_ current: Any?) -> Any? { if let current = current as? Normalizable { @@ -167,25 +209,47 @@ extension Dictionary : Normalizable { func parseFilterComponents(token: String) -> (String, [Variable]) { var components = token.smartSplit(separator: ":") - let name = components.removeFirst() + let name = components.removeFirst().trim(character: " ") let variables = components .joined(separator: ":") .smartSplit(separator: ",") - .map { Variable($0) } + .map { Variable($0.trim(character: " ")) } return (name, variables) } extension Mirror { func getValue(for key: String) -> Any? { - let result = descendant(key) + let result = descendant(key) ?? Int(key).flatMap({ descendant($0) }) if result == nil { // go through inheritance chain to reach superclass properties return superclassMirror?.getValue(for: key) - } else if let result = result, String(describing: result) == "nil" { - // mirror returns non-nil value even for nil-containing properties - // so we have to check if its value is actually nil or not - return nil + } else if let result = result { + guard String(describing: result) != "nil" else { + // mirror returns non-nil value even for nil-containing properties + // so we have to check if its value is actually nil or not + return nil + } + if let result = (result as? AnyOptional)?.wrapped { + return result + } else { + return result + } } return result } } + +protocol AnyOptional { + var wrapped: Any? { get } +} + +extension Optional: AnyOptional { + var wrapped: Any? { + switch self { + case let .some(value): return value + case .none: return nil + } + } +} + + diff --git a/Stencil.podspec.json b/Stencil.podspec.json index 4215d5a..a173c05 100644 --- a/Stencil.podspec.json +++ b/Stencil.podspec.json @@ -1,6 +1,6 @@ { "name": "Stencil", - "version": "0.10.1", + "version": "0.11.0", "summary": "Stencil is a simple and powerful template language for Swift.", "homepage": "https://stencil.fuller.li", "license": { @@ -12,8 +12,8 @@ }, "social_media_url": "https://twitter.com/kylefuller", "source": { - "git": "https://github.com/kylef/Stencil.git", - "tag": "0.10.1" + "git": "https://github.com/stencilproject/Stencil.git", + "tag": "0.11.0" }, "source_files": [ "Sources/*.swift" @@ -25,6 +25,8 @@ }, "requires_arc": true, "dependencies": { - "PathKit": [ "~> 0.8.0" ] + "PathKit": [ + "~> 0.8.0" + ] } } diff --git a/Tests/StencilTests/EnvironmentSpec.swift b/Tests/StencilTests/EnvironmentSpec.swift index ca097e1..8fd4684 100644 --- a/Tests/StencilTests/EnvironmentSpec.swift +++ b/Tests/StencilTests/EnvironmentSpec.swift @@ -48,18 +48,20 @@ func testEnvironment() { return TemplateSyntaxError(reason: description, token: token, stackTrace: []) } - func expectError(reason: String, token: String) throws { + func expectError(reason: String, token: String, + file: String = #file, line: Int = #line, function: String = #function) throws { let expectedError = expectedSyntaxError(token: token, template: template, description: reason) - let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"])).toThrow() as TemplateSyntaxError - try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError) + let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError) } $0.context("given syntax error") { $0.it("reports syntax error on invalid for tag syntax") { template = "Hello {% for name in %}{{ name }}, {% endfor %}!" - try expectError(reason: "'for' statements should use the following syntax 'for x in y where condition'.", token: "for name in") + try expectError(reason: "'for' statements should use the syntax: `for in [where ]`.", token: "for name in") } $0.it("reports syntax error on missing endfor") { @@ -78,37 +80,37 @@ func testEnvironment() { $0.it("reports syntax error in for tag") { template = "{% for name in names|unknown %}{{ name }}{% endfor %}" - try expectError(reason: "Unknown filter 'unknown'", token: "names|unknown") + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "names|unknown") } $0.it("reports syntax error in for-where tag") { template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}" - try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown") + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") } $0.it("reports syntax error in if tag") { template = "{% if name|unknown %}{{ name }}{% endif %}" - try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown") + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") } $0.it("reports syntax error in elif tag") { template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}" - try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown") + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") } $0.it("reports syntax error in ifnot tag") { template = "{% ifnot name|unknown %}{{ name }}{% endif %}" - try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown") + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") } $0.it("reports syntax error in filter tag") { template = "{% filter unknown %}Text{% endfilter %}" - try expectError(reason: "Unknown filter 'unknown'", token: "filter unknown") + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "filter unknown") } $0.it("reports syntax error in variable tag") { template = "{{ name|unknown }}" - try expectError(reason: "Unknown filter 'unknown'", token: "name|unknown") + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") } } @@ -200,20 +202,21 @@ func testEnvironment() { includedTemplate = nil } - func expectError(reason: String, token: String, includedToken: String) throws { + func expectError(reason: String, token: String, includedToken: String, + file: String = #file, line: Int = #line, function: String = #function) throws { var expectedError = expectedSyntaxError(token: token, template: template, description: reason) expectedError.stackTrace = [expectedSyntaxError(token: includedToken, template: includedTemplate, description: reason).token!] - let error = try expect(environment.render(template: template, context: ["target": "World"])) - .toThrow() as TemplateSyntaxError - try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError) + let error = try expect(environment.render(template: template, context: ["target": "World"]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError) } $0.it("reports syntax error in included template") { template = Template(templateString: "{% include \"invalid-include.html\" %}", environment: environment) includedTemplate = try environment.loadTemplate(name: "invalid-include.html") - try expectError(reason: "Unknown filter 'unknown'", + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "include \"invalid-include.html\"", includedToken: "target|unknown") } @@ -248,21 +251,22 @@ func testEnvironment() { baseTemplate = nil } - func expectError(reason: String, childToken: String, baseToken: String?) throws { + func expectError(reason: String, childToken: String, baseToken: String?, + file: String = #file, line: Int = #line, function: String = #function) throws { var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason) if let baseToken = baseToken { expectedError.stackTrace = [expectedSyntaxError(token: baseToken, template: baseTemplate, description: reason).token!] } - let error = try expect(environment.render(template: childTemplate, context: ["target": "World"])) - .toThrow() as TemplateSyntaxError - try expect(environment.errorReporter.renderError(error)) == environment.errorReporter.renderError(expectedError) + let error = try expect(environment.render(template: childTemplate, context: ["target": "World"]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError) } $0.it("reports syntax error in base template") { childTemplate = try environment.loadTemplate(name: "invalid-child-super.html") baseTemplate = try environment.loadTemplate(name: "invalid-base.html") - try expectError(reason: "Unknown filter 'unknown'", + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", childToken: "extends \"invalid-base.html\"", baseToken: "target|unknown") } @@ -286,7 +290,7 @@ func testEnvironment() { childTemplate = Template(templateString: "{% extends \"base.html\" %}\n" + "{% block body %}Child {{ target|unknown }}{% endblock %}", environment: environment, name: nil) - try expectError(reason: "Unknown filter 'unknown'", + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", childToken: "target|unknown", baseToken: nil) } @@ -311,7 +315,7 @@ func testEnvironment() { } } -private extension Expectation { +extension Expectation { @discardableResult func toThrow() throws -> T { var thrownError: Error? = nil diff --git a/Tests/StencilTests/ExpressionSpec.swift b/Tests/StencilTests/ExpressionSpec.swift index 00bd68a..e53ffab 100644 --- a/Tests/StencilTests/ExpressionSpec.swift +++ b/Tests/StencilTests/ExpressionSpec.swift @@ -287,12 +287,16 @@ func testExpressions() { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [1, 2, 3]]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["a", "b", "c"]]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "abc"]))).to.beTrue() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1...3]))).to.beTrue() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1..<3]))).to.beTrue() } $0.it("evaluates to false when rhs does not contain lhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [2, 3, 4]]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["b", "c", "d"]]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "bcd"]))).to.beFalse() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 1...3]))).to.beFalse() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 3, "rhs": 1..<3]))).to.beFalse() } } } diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index 7898cc0..710d5ae 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -78,9 +78,9 @@ func testFilter() { } $0.it("allows whitespace in expression") { - let template = Template(templateString: "{{ name | uppercase }}") - let result = try template.render(Context(dictionary: ["name": "kyle"])) - try expect(result) == "KYLE" + let template = Template(templateString: "{{ value | join : \", \" }}") + let result = try template.render(Context(dictionary: ["value": ["One", "Two"]])) + try expect(result) == "One, Two" } $0.it("throws when you pass arguments to simple filter") { @@ -89,32 +89,45 @@ func testFilter() { } } + describe("string filters") { + $0.context("given string") { + $0.it("transforms a string to be capitalized") { + let template = Template(templateString: "{{ name|capitalize }}") + let result = try template.render(Context(dictionary: ["name": "kyle"])) + try expect(result) == "Kyle" + } - describe("capitalize filter") { - let template = Template(templateString: "{{ name|capitalize }}") + $0.it("transforms a string to be uppercase") { + let template = Template(templateString: "{{ name|uppercase }}") + let result = try template.render(Context(dictionary: ["name": "kyle"])) + try expect(result) == "KYLE" + } - $0.it("capitalizes a string") { - let result = try template.render(Context(dictionary: ["name": "kyle"])) - try expect(result) == "Kyle" + $0.it("transforms a string to be lowercase") { + let template = Template(templateString: "{{ name|lowercase }}") + let result = try template.render(Context(dictionary: ["name": "Kyle"])) + try expect(result) == "kyle" + } } - } + $0.context("given array of strings") { + $0.it("transforms a string to be capitalized") { + let template = Template(templateString: "{{ names|capitalize }}") + let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]])) + try expect(result) == "[\"Kyle\", \"Kyle\"]" + } - describe("uppercase filter") { - let template = Template(templateString: "{{ name|uppercase }}") + $0.it("transforms a string to be uppercase") { + let template = Template(templateString: "{{ names|uppercase }}") + let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]])) + try expect(result) == "[\"KYLE\", \"KYLE\"]" + } - $0.it("transforms a string to be uppercase") { - let result = try template.render(Context(dictionary: ["name": "kyle"])) - try expect(result) == "KYLE" - } - } - - describe("lowercase filter") { - let template = Template(templateString: "{{ name|lowercase }}") - - $0.it("transforms a string to be lowercase") { - let result = try template.render(Context(dictionary: ["name": "Kyle"])) - try expect(result) == "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\"]" + } } } @@ -183,4 +196,96 @@ func testFilter() { try expect(result) == "OneTwo" } } + + describe("split filter") { + let template = Template(templateString: "{{ value|split:\", \" }}") + + $0.it("split a string into array") { + let result = try template.render(Context(dictionary: ["value": "One, Two"])) + try expect(result) == "[\"One\", \"Two\"]" + } + + $0.it("can split without arguments") { + let template = Template(templateString: "{{ value|split }}") + let result = try template.render(Context(dictionary: ["value": "One, Two"])) + try expect(result) == "[\"One,\", \"Two\"]" + } + } + + + describe("filter suggestion") { + var template: Template! + var filterExtension: Extension! + + func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError { + let range = template.templateString.range(of: token)! + let rangeLine = template.templateString.rangeLine(range) + let sourceMap = SourceMap(filename: template.name, line: rangeLine) + let token = Token.block(value: token, at: sourceMap) + return TemplateSyntaxError(reason: description, token: token, stackTrace: []) + } + + func expectError(reason: String, token: String, + file: String = #file, line: Int = #line, function: String = #function) throws { + let expectedError = expectedSyntaxError(token: token, template: template, description: reason) + let environment = Environment(extensions: [filterExtension]) + + let error = try expect(environment.render(template: template, context: [:]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + + try expect(environment.errorReporter.renderError(error), file: file, line: line, function: function) == environment.errorReporter.renderError(expectedError) + } + + $0.it("made for unknown filter") { + template = Template(templateString: "{{ value|unknownFilter }}") + + filterExtension = Extension() + filterExtension.registerFilter("knownFilter") { value, _ in value } + + try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.", token: "value|unknownFilter") + } + + $0.it("made for multiple similar filters") { + template = Template(templateString: "{{ value|lowerFirst }}") + + filterExtension = Extension() + filterExtension.registerFilter("lowerFirstWord") { value, _ in value } + filterExtension.registerFilter("lowerFirstLetter") { value, _ in value } + + try expectError(reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.", token: "value|lowerFirst") + } + + $0.it("not made when can't find similar filter") { + template = Template(templateString: "{{ value|unknownFilter }}") + try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.", token: "value|unknownFilter") + } + + } + + + describe("indent filter") { + $0.it("indents content") { + let template = Template(templateString: "{{ value|indent:2 }}") + let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) + try expect(result) == "One\n Two" + } + + $0.it("can indent with arbitrary character") { + let template = Template(templateString: "{{ value|indent:2,\"\t\" }}") + let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) + try expect(result) == "One\n\t\tTwo" + } + + $0.it("can indent first line") { + let template = Template(templateString: "{{ value|indent:2,\" \",true }}") + let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) + try expect(result) == " One\n Two" + } + + $0.it("does not indent empty lines") { + let template = Template(templateString: "{{ value|indent }}") + let result = try template.render(Context(dictionary: ["value": "One\n\n\nTwo\n\n"])) + try expect(result) == "One\n\n\n Two\n\n" + } + } } diff --git a/Tests/StencilTests/FilterTagSpec.swift b/Tests/StencilTests/FilterTagSpec.swift index a6e4513..5d59fdc 100644 --- a/Tests/StencilTests/FilterTagSpec.swift +++ b/Tests/StencilTests/FilterTagSpec.swift @@ -21,5 +21,25 @@ func testFilterTag() { try expect(try template.render()).toThrow() } + $0.it("can render filters with arguments") { + let ext = Extension() + ext.registerFilter("split", filter: { + return ($0 as! String).components(separatedBy: $1[0] as! String) + }) + let env = Environment(extensions: [ext]) + let result = try env.renderTemplate(string: "{% filter split:\",\"|join:\";\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": [1, 2]]) + try expect(result) == "1;2" + } + + $0.it("can render filters with quote as an argument") { + let ext = Extension() + ext.registerFilter("replace", filter: { + print($1[0] as! String) + return ($0 as! String).replacingOccurrences(of: $1[0] as! String, with: $1[1] as! String) + }) + let env = Environment(extensions: [ext]) + let result = try env.renderTemplate(string: "{% filter replace:'\"',\"\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": ["\"1\"", "\"2\""]]) + try expect(result) == "1,2" + } } } diff --git a/Tests/StencilTests/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index 1177b35..4fb1a04 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -11,7 +11,8 @@ func testForNode() { "dict": [ "one": "I", "two": "II", - ] + ], + "tuples": [(1, 2, 3), (4, 5, 6)] ]) $0.it("renders the given nodes for each item") { @@ -89,6 +90,12 @@ func testForNode() { try expect(try node.render(context)) == "102132" } + $0.it("renders the given nodes while providing loop length") { + let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.length")] + let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: []) + try expect(try node.render(context)) == "132333" + } + $0.it("renders the given nodes while filtering items using where expression") { let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")] let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown)) @@ -104,8 +111,8 @@ func testForNode() { try expect(try node.render(context)) == "empty" } - $0.it("can render a filter") { - let templateString = "{% for article in ars|default:articles %}" + + $0.it("can render a filter with spaces") { + let templateString = "{% for article in ars | default: a, b , articles %}" + "- {{ article.title }} by {{ article.author }}.\n" + "{% endfor %}\n" @@ -127,8 +134,55 @@ func testForNode() { try expect(result) == fixture } + $0.context("given array of tuples") { + $0.it("can iterate over all tuple values") { + let templateString = "{% for first,second,third in tuples %}" + + "{{ first }}, {{ second }}, {{ third }}\n" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + let result = try template.render(context) + + let fixture = "1, 2, 3\n4, 5, 6\n\n" + try expect(result) == fixture + } + + $0.it("can iterate with less number of variables") { + let templateString = "{% for first,second in tuples %}" + + "{{ first }}, {{ second }}\n" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + let result = try template.render(context) + + let fixture = "1, 2\n4, 5\n\n" + try expect(result) == fixture + } + + $0.it("can use _ to skip variables") { + let templateString = "{% for first,_,third in tuples %}" + + "{{ first }}, {{ third }}\n" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + let result = try template.render(context) + + let fixture = "1, 3\n4, 6\n\n" + try expect(result) == fixture + } + + $0.it("throws when number of variables is more than number of tuple values") { + let templateString = "{% for key,value,smth in dict %}" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + try expect(template.render(context)).toThrow() + } + + } + $0.it("can iterate over dictionary") { - let templateString = "{% for key,value in dict %}" + + let templateString = "{% for key, value in dict %}" + "{{ key }}: {{ value }}," + "{% endfor %}" @@ -169,12 +223,92 @@ func testForNode() { } $0.it("handles invalid input") { - let tokens: [Token] = [.block(value: "for i", at: .unknown)] - let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError(reason: "'for' statements should use the following syntax 'for x in y where condition'.", token: tokens.first) + let token = Token.block(value: "for i", at: .unknown) + let parser = TokenParser(tokens: [token], environment: Environment()) + let error = TemplateSyntaxError(reason: "'for' statements should use the syntax: `for in [where ]`.", token: token) try expect(try parser.parse()).toThrow(error) } - + + $0.it("can iterate over struct properties") { + struct MyStruct { + let string: String + let number: Int + } + + let context = Context(dictionary: [ + "struct": MyStruct(string: "abc", number: 123) + ]) + + let nodes: [NodeType] = [ + VariableNode(variable: "property"), + TextNode(text: "="), + VariableNode(variable: "value"), + TextNode(text: "\n"), + ] + let node = ForNode(resolvable: Variable("struct"), loopVariables: ["property", "value"], nodes: nodes, emptyNodes: []) + let result = try node.render(context) + + try expect(result) == "string=abc\nnumber=123\n" + } + + $0.it("can iterate tuple items") { + let context = Context(dictionary: [ + "tuple": (one: 1, two: "dva"), + ]) + + let nodes: [NodeType] = [ + VariableNode(variable: "label"), + TextNode(text: "="), + VariableNode(variable: "value"), + TextNode(text: "\n"), + ] + + let node = ForNode(resolvable: Variable("tuple"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: []) + let result = try node.render(context) + + try expect(result) == "one=1\ntwo=dva\n" + } + + $0.it("can iterate over class properties") { + class MyClass { + var baseString: String + var baseInt: Int + init(_ string: String, _ int: Int) { + baseString = string + baseInt = int + } + } + + class MySubclass: MyClass { + var childString: String + init(_ childString: String, _ string: String, _ int: Int) { + self.childString = childString + super.init(string, int) + } + } + + let context = Context(dictionary: [ + "class": MySubclass("child", "base", 1) + ]) + + let nodes: [NodeType] = [ + VariableNode(variable: "label"), + TextNode(text: "="), + VariableNode(variable: "value"), + TextNode(text: "\n"), + ] + + let node = ForNode(resolvable: Variable("class"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: []) + let result = try node.render(context) + + try expect(result) == "childString=child\nbaseString=base\nbaseInt=1\n" + } + + $0.it("can iterate in range of variables") { + let template: Template = "{% for i in 1...j %}{{ i }}{% endfor %}" + try expect(try template.render(Context(dictionary: ["j": 3]))) == "123" + } + } } diff --git a/Tests/StencilTests/IfNodeSpec.swift b/Tests/StencilTests/IfNodeSpec.swift index 9fada9f..cd662d7 100644 --- a/Tests/StencilTests/IfNodeSpec.swift +++ b/Tests/StencilTests/IfNodeSpec.swift @@ -266,5 +266,22 @@ func testIfNode() { let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()])) try expect(result) == "" } + + $0.it("supports closed range variables") { + let tokens: [Token] = [ + .block(value: "if value in 1...3", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "else", at: .unknown), + .text(value: "false", at: .unknown), + .block(value: "endif", at: .unknown) + ] + + let parser = TokenParser(tokens: tokens, environment: Environment()) + let nodes = try parser.parse() + + try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true" + try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false" + } + } } diff --git a/Tests/StencilTests/IncludeSpec.swift b/Tests/StencilTests/IncludeSpec.swift index 153d7f6..a87dc85 100644 --- a/Tests/StencilTests/IncludeSpec.swift +++ b/Tests/StencilTests/IncludeSpec.swift @@ -14,7 +14,7 @@ func testInclude() { let tokens: [Token] = [ .block(value: "include", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError(reason: "'include' tag takes one argument, the template file to be included", token: tokens.first) + let error = TemplateSyntaxError(reason: "'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file", token: tokens.first) try expect(try parser.parse()).toThrow(error) } @@ -56,6 +56,13 @@ func testInclude() { let value = try node.render(context) try expect(value) == "Hello World!" } + + $0.it("successfully passes context") { + let template = Template(templateString: "{% include \"test.html\" child %}") + let context = Context(dictionary: ["child": ["target": "World"]], environment: environment) + let value = try template.render(context) + try expect(value) == "Hello World!" + } } } } diff --git a/Tests/StencilTests/LexerSpec.swift b/Tests/StencilTests/LexerSpec.swift index 270cb4b..9fe8df0 100644 --- a/Tests/StencilTests/LexerSpec.swift +++ b/Tests/StencilTests/LexerSpec.swift @@ -67,5 +67,28 @@ func testLexer() { let lexer = Lexer(templateString: "{{}}") let _ = lexer.tokenize() } + + $0.it("can tokenize with new lines") { + let templateString = + "My name is {%\n" + + " if name\n" + + " and\n" + + " name\n" + + "%}{{\n" + + "name\n" + + "}}{%\n" + + "endif %}." + + let lexer = Lexer(templateString: templateString) + + let tokens = lexer.tokenize() + + try expect(tokens.count) == 5 + try expect(tokens[0]) == Token.text(value: "My name is ", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "My name is")!))) + try expect(tokens[1]) == Token.block(value: "if name and name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "{%")!))) + try expect(tokens[2]) == Token.variable(value: "name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "name", options: [.backwards])!))) + try expect(tokens[3]) == Token.block(value: "endif", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "endif")!))) + try expect(tokens[4]) == Token.text(value: ".", at: SourceMap(line: templateString.rangeLine(templateString.range(of: ".")!))) + } } } diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 1a567d8..7b386bc 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -5,10 +5,10 @@ import Spectre #if os(OSX) @objc class Superclass: NSObject { - let name = "Foo" + @objc let name = "Foo" } @objc class Object : Superclass { - let title = "Hello World" + @objc let title = "Hello World" } #endif @@ -26,6 +26,7 @@ fileprivate class WebSite { fileprivate class Blog: WebSite { let articles: [Article] = [Article(author: Person(name: "Kyle"))] + let featuring: Article? = Article(author: Person(name: "Jhon")) } func testVariable() { @@ -39,7 +40,8 @@ func testVariable() { "counter": [ "count": "kylef", ], - "article": Article(author: Person(name: "Kyle")) + "article": Article(author: Person(name: "Kyle")), + "tuple": (one: 1, two: 2) ]) #if os(OSX) @@ -71,6 +73,13 @@ func testVariable() { try expect(result) == 3.14 } + $0.it("can resolve boolean literal") { + try expect(Variable("true").resolve(context) as? Bool) == true + try expect(Variable("false").resolve(context) as? Bool) == false + try expect(Variable("0").resolve(context) as? Int) == 0 + try expect(Variable("1").resolve(context) as? Int) == 1 + } + $0.it("can resolve a string variable") { let variable = Variable("name") let result = try variable.resolve(context) as? String @@ -140,7 +149,7 @@ func testVariable() { try expect(result) == "Foo" } #endif - + $0.it("can resolve a value via reflection") { let variable = Variable("blog.articles.0.author.name") let result = try variable.resolve(context) as? String @@ -153,5 +162,171 @@ func testVariable() { try expect(result) == "blog.com" } + $0.it("can resolve optional variable property using reflection") { + let variable = Variable("blog.featuring.author.name") + let result = try variable.resolve(context) as? String + try expect(result) == "Jhon" + } + + $0.it("does not render Optional") { + var array: [Any?] = [1, nil] + array.append(array) + let context = Context(dictionary: ["values": array]) + + try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]" + try expect(VariableNode(variable: "values.1").render(context)) == "" + } + + $0.it("can subscript tuple by index") { + let variable = Variable("tuple.0") + let result = try variable.resolve(context) as? Int + try expect(result) == 1 + } + + $0.it("can subscript tuple by label") { + let variable = Variable("tuple.two") + let result = try variable.resolve(context) as? Int + try expect(result) == 2 + } + + $0.describe("Subrscripting") { + $0.it("can resolve a property subscript via reflection") { + try context.push(dictionary: ["property": "name"]) { + let variable = Variable("article.author[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("can subscript an array with a valid index") { + try context.push(dictionary: ["property": 0]) { + let variable = Variable("contacts[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Katie" + } + } + + $0.it("can subscript an array with an unknown index") { + try context.push(dictionary: ["property": 5]) { + let variable = Variable("contacts[property]") + let result = try variable.resolve(context) as? String + try expect(result).to.beNil() + } + } + +#if os(OSX) + $0.it("can resolve a subscript via KVO") { + try context.push(dictionary: ["property": "name"]) { + let variable = Variable("object[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Foo" + } + } +#endif + + $0.it("can resolve an optional subscript via reflection") { + try context.push(dictionary: ["property": "featuring"]) { + let variable = Variable("blog[property].author.name") + let result = try variable.resolve(context) as? String + try expect(result) == "Jhon" + } + } + + $0.it("can resolve multiple subscripts") { + try context.push(dictionary: [ + "prop1": "articles", + "prop2": 0, + "prop3": "name" + ]) { + let variable = Variable("blog[prop1][prop2].author[prop3]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("can resolve nested subscripts") { + try context.push(dictionary: [ + "prop1": "prop2", + "ref": ["prop2": "name"] + ]) { + let variable = Variable("article.author[ref[prop1]]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("throws for invalid keypath syntax") { + try context.push(dictionary: ["prop": "name"]) { + let samples = [ + ".", + "..", + ".test", + "test..test", + "[prop]", + "article.author[prop", + "article.author[[prop]", + "article.author[prop]]", + "article.author[]", + "article.author[[]]", + "article.author[prop][]", + "article.author[prop]comments", + "article.author[.]" + ] + + for lookup in samples { + let variable = Variable(lookup) + try expect(variable.resolve(context)).toThrow() + } + } + } + } + } + + describe("RangeVariable") { + + let context: Context = { + let ext = Extension() + ext.registerFilter("incr", filter: { (arg: Any?) in toNumber(value: arg!)! + 1 }) + let environment = Environment(extensions: [ext]) + return Context(dictionary: [:], environment: environment) + }() + + func makeVariable(_ token: String) throws -> RangeVariable? { + return try RangeVariable(token, parser: TokenParser(tokens: [], environment: context.environment)) + } + + $0.it("can resolve closed range as array") { + let result = try makeVariable("1...3")?.resolve(context) as? [Int] + try expect(result) == [1, 2, 3] + } + + $0.it("can resolve decreasing closed range as reversed array") { + let result = try makeVariable("3...1")?.resolve(context) as? [Int] + try expect(result) == [3, 2, 1] + } + + $0.it("can use filter on range variables") { + let result = try makeVariable("1|incr...3|incr")?.resolve(context) as? [Int] + try expect(result) == [2, 3, 4] + } + + $0.it("throws when left value is not int") { + let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}" + try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow() + } + + $0.it("throws when right value is not int") { + let variable = try makeVariable("k...j") + try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow() + } + + $0.it("throws is left range value is missing") { + try expect(makeVariable("...1")).toThrow() + } + + $0.it("throws is right range value is missing") { + try expect(makeVariable("1...")).toThrow() + } + } } diff --git a/docs/builtins.rst b/docs/builtins.rst index d0590bb..d4cc99a 100644 --- a/docs/builtins.rst +++ b/docs/builtins.rst @@ -28,6 +28,18 @@ The ``for`` tag can iterate over dictionaries.
  • {{ key }}: {{ value }}
  • {% endfor %} + +It can also iterate over ranges, tuple elements, structs' and classes' stored properties (using ``Mirror``). + +You can iterate over range literals created using ``N...M`` syntax, both in ascending and descending order: + +.. code-block:: html+django + +
      + {% for i in 1...array.count %} +
    • {{ i }}
    • + {% endfor %} +
    The ``for`` tag can contain optional ``where`` expression to filter out elements on which this expression evaluates to false. @@ -59,6 +71,7 @@ The for block sets a few variables available within the loop: - ``last`` - True if this is the last time through the loop - ``counter`` - The current iteration of the loop (1 indexed) - ``counter0`` - The current iteration of the loop (0 indexed) +- ``length`` - The total length of the loop For example: @@ -124,7 +137,7 @@ or to negate a variable. {% endif %} You may use ``and``, ``or`` and ``not`` multiple times together. ``not`` has -higest prescidence followed by ``and``. For example: +higest precedence followed by ``and``. For example: .. code-block:: html+django @@ -247,6 +260,12 @@ You can include another template using the `include` tag. {% include "comment.html" %} +By default the included file gets passed the current context. You can pass a sub context by using an optional 2nd parameter as a lookup in the current context. + +.. code-block:: html+django + + {% include "comment.html" comment %} + The `include` tag requires you to provide a loader which will be used to lookup the template. @@ -281,7 +300,7 @@ Built-in Filters ~~~~~~~~~~~~~~ The capitalize filter allows you to capitalize a string. -For example, `stencil` to `Stencil`. +For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string. .. code-block:: html+django @@ -291,7 +310,7 @@ For example, `stencil` to `Stencil`. ~~~~~~~~~~~~~ The uppercase filter allows you to transform a string to uppercase. -For example, `Stencil` to `STENCIL`. +For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string. .. code-block:: html+django @@ -301,7 +320,7 @@ For example, `Stencil` to `STENCIL`. ~~~~~~~~~~~~~ The uppercase filter allows you to transform a string to lowercase. -For example, `Stencil` to `stencil`. +For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string. .. code-block:: html+django @@ -326,4 +345,31 @@ Join an array of items. {{ value|join:", " }} -.. note:: The value MUST be an array. +.. note:: The value MUST be an array. Default argument value is empty string. + +``split`` +~~~~~~~~~ + +Split string into substrings by separator. + +.. code-block:: html+django + + {{ value|split:", " }} + +.. note:: The value MUST be a String. Default argument value is a single-space string. + +``indent`` +~~~~~~~~~ + +Indents lines of rendered value or block. + +.. code-block:: html+django + + {{ value|indent:2," ",true }} + +Filter accepts several arguments: + +* indentation width: number of indentation characters to indent lines with. Default is ``4``. +* indentation character: character to be used for indentation. Default is a space. +* indent first line: whether first line of output should be indented or not. Default is ``false``. + diff --git a/docs/templates.rst b/docs/templates.rst index 1934abe..147be45 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -31,6 +31,24 @@ For example, if `people` was an array: There are {{ people.count }} people. {{ people.first }} is the first person, followed by {{ people.1 }}. +You can also use the subscript operator for indirect evaluation. The expression +between brackets will be evaluated first, before the actual lookup will happen. + +For example, if you have the following context: + +.. code-block:: swift + + [ + "item": [ + "name": "John" + ], + "key": "name" + ] + +.. code-block:: html+django + + The result of {{ item[key] }} will be the same as {{ item.name }}. It will first evaluate the result of {{ key }}, and only then evaluate the lookup expression. + Filters ~~~~~~~