Merge branch 'master' into errors-logs-improvements

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

View File

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

View File

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

View File

@@ -1,13 +1,25 @@
func capitalise(_ value: Any?) -> Any? {
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")
}

View File

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

View File

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

View File

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

112
Sources/KeyPath.swift Normal file
View File

@@ -0,0 +1,112 @@
import Foundation
/// A structure used to represent a template variable, and to resolve it in a given context.
final class KeyPath {
private var components = [String]()
private var current = ""
private var partialComponents = [String]()
private var subscriptLevel = 0
let variable: String
let context: Context
// Split the keypath string and resolve references if possible
init(_ variable: String, in context: Context) {
self.variable = variable
self.context = context
}
func parse() throws -> [String] {
defer {
components = []
current = ""
partialComponents = []
subscriptLevel = 0
}
for c in variable.characters {
switch c {
case "." where subscriptLevel == 0:
try foundSeparator()
case "[":
try openBracket()
case "]":
try closeBracket()
default:
try addCharacter(c)
}
}
try finish()
return components
}
private func foundSeparator() throws {
if !current.isEmpty {
partialComponents.append(current)
}
guard !partialComponents.isEmpty else {
throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'")
}
components += partialComponents
current = ""
partialComponents = []
}
// when opening the first bracket, we must have a partial component
private func openBracket() throws {
guard !partialComponents.isEmpty || !current.isEmpty else {
throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'")
}
if subscriptLevel > 0 {
current.append("[")
} else if !current.isEmpty {
partialComponents.append(current)
current = ""
}
subscriptLevel += 1
}
// for a closing bracket at root level, try to resolve the reference
private func closeBracket() throws {
guard subscriptLevel > 0 else {
throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'")
}
if subscriptLevel > 1 {
current.append("]")
} else if !current.isEmpty,
let value = try Variable(current).resolve(context) {
partialComponents.append("\(value)")
current = ""
} else {
throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'")
}
subscriptLevel -= 1
}
private func addCharacter(_ c: Character) throws {
guard partialComponents.isEmpty || subscriptLevel > 0 else {
throw TemplateSyntaxError("Unexpected character '\(c)' in variable '\(variable)'")
}
current.append(c)
}
private func finish() throws {
// check if we have a last piece
if !current.isEmpty {
partialComponents.append(current)
}
components += partialComponents
guard subscriptLevel == 0 else {
throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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