Move source files to where SPM expectes them to be
This commit is contained in:
88
Sources/Stencil/Context.swift
Normal file
88
Sources/Stencil/Context.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
/// A container for template variables.
|
||||
public class Context {
|
||||
var dictionaries: [[String: Any?]]
|
||||
|
||||
/// The context's environment, such as registered extensions, classes, …
|
||||
public let environment: Environment
|
||||
|
||||
/// Create a context from a dictionary (and an env.)
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - dictionary: The context's data
|
||||
/// - environment: Environment such as extensions, …
|
||||
public init(dictionary: [String: Any] = [:], environment: Environment? = nil) {
|
||||
if !dictionary.isEmpty {
|
||||
dictionaries = [dictionary]
|
||||
} else {
|
||||
dictionaries = []
|
||||
}
|
||||
|
||||
self.environment = environment ?? Environment()
|
||||
}
|
||||
|
||||
/// Access variables in this context by name
|
||||
public subscript(key: String) -> Any? {
|
||||
/// Retrieves a variable's value, starting at the current context and going upwards
|
||||
get {
|
||||
for dictionary in Array(dictionaries.reversed()) {
|
||||
if let value = dictionary[key] {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Set a variable in the current context, deleting the variable if it's nil
|
||||
set(value) {
|
||||
if var dictionary = dictionaries.popLast() {
|
||||
dictionary[key] = value
|
||||
dictionaries.append(dictionary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new level into the Context
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - dictionary: The new level data
|
||||
fileprivate func push(_ dictionary: [String: Any] = [:]) {
|
||||
dictionaries.append(dictionary)
|
||||
}
|
||||
|
||||
/// Pop the last level off of the Context
|
||||
///
|
||||
/// - returns: The popped level
|
||||
fileprivate func pop() -> [String: Any?]? {
|
||||
dictionaries.popLast()
|
||||
}
|
||||
|
||||
/// Push a new level onto the context for the duration of the execution of the given closure
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - dictionary: The new level data
|
||||
/// - closure: The closure to execute
|
||||
/// - returns: Return value of the closure
|
||||
public func push<Result>(dictionary: [String: Any] = [:], closure: (() throws -> Result)) rethrows -> Result {
|
||||
push(dictionary)
|
||||
defer { _ = pop() }
|
||||
return try closure()
|
||||
}
|
||||
|
||||
/// Flatten all levels of context data into 1, merging duplicate variables
|
||||
///
|
||||
/// - returns: All collected variables
|
||||
public func flatten() -> [String: Any] {
|
||||
var accumulator: [String: Any] = [:]
|
||||
|
||||
for dictionary in dictionaries {
|
||||
for (key, value) in dictionary {
|
||||
if let value = value {
|
||||
accumulator.updateValue(value, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator
|
||||
}
|
||||
}
|
||||
18
Sources/Stencil/DynamicMemberLookup.swift
Normal file
18
Sources/Stencil/DynamicMemberLookup.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
/// Marker protocol so we can know which types support `@dynamicMemberLookup`. Add this to your own types that support
|
||||
/// lookup by String.
|
||||
public protocol DynamicMemberLookup {
|
||||
/// Get a value for a given `String` key
|
||||
subscript(dynamicMember member: String) -> Any? { get }
|
||||
}
|
||||
|
||||
public extension DynamicMemberLookup where Self: RawRepresentable {
|
||||
/// Get a value for a given `String` key
|
||||
subscript(dynamicMember member: String) -> Any? {
|
||||
switch member {
|
||||
case "rawValue":
|
||||
return rawValue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
79
Sources/Stencil/Environment.swift
Normal file
79
Sources/Stencil/Environment.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
/// Container for environment data, such as registered extensions
|
||||
public struct Environment {
|
||||
/// The class for loading new templates
|
||||
public let templateClass: Template.Type
|
||||
/// List of registered extensions
|
||||
public var extensions: [Extension]
|
||||
/// Mechanism for loading new files
|
||||
public var loader: Loader?
|
||||
|
||||
/// Basic initializer
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - loader: Mechanism for loading new files
|
||||
/// - extensions: List of extension containers
|
||||
/// - templateClass: Class for newly loaded templates
|
||||
public init(
|
||||
loader: Loader? = nil,
|
||||
extensions: [Extension] = [],
|
||||
templateClass: Template.Type = Template.self
|
||||
) {
|
||||
self.templateClass = templateClass
|
||||
self.loader = loader
|
||||
self.extensions = extensions + [DefaultExtension()]
|
||||
}
|
||||
|
||||
/// Load a template with the given name
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: Name of the template
|
||||
/// - returns: Loaded template instance
|
||||
public func loadTemplate(name: String) throws -> Template {
|
||||
if let loader = loader {
|
||||
return try loader.loadTemplate(name: name, environment: self)
|
||||
} else {
|
||||
throw TemplateDoesNotExist(templateNames: [name], loader: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a template with the given names
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - names: Names of the template
|
||||
/// - returns: Loaded template instance
|
||||
public func loadTemplate(names: [String]) throws -> Template {
|
||||
if let loader = loader {
|
||||
return try loader.loadTemplate(names: names, environment: self)
|
||||
} else {
|
||||
throw TemplateDoesNotExist(templateNames: names, loader: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a template with the given name, providing some data
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: Name of the template
|
||||
/// - context: Data for rendering
|
||||
/// - returns: Rendered output
|
||||
public func renderTemplate(name: String, context: [String: Any] = [:]) throws -> String {
|
||||
let template = try loadTemplate(name: name)
|
||||
return try render(template: template, context: context)
|
||||
}
|
||||
|
||||
/// Render the given template string, providing some data
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - string: Template string
|
||||
/// - context: Data for rendering
|
||||
/// - returns: Rendered output
|
||||
public func renderTemplate(string: String, context: [String: Any] = [:]) 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)
|
||||
}
|
||||
}
|
||||
81
Sources/Stencil/Errors.swift
Normal file
81
Sources/Stencil/Errors.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
public class TemplateDoesNotExist: Error, CustomStringConvertible {
|
||||
let templateNames: [String]
|
||||
let loader: Loader?
|
||||
|
||||
public init(templateNames: [String], loader: Loader? = nil) {
|
||||
self.templateNames = templateNames
|
||||
self.loader = loader
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
let templates = templateNames.joined(separator: ", ")
|
||||
|
||||
if let loader = loader {
|
||||
return "Template named `\(templates)` does not exist in loader \(loader)"
|
||||
}
|
||||
|
||||
return "Template named `\(templates)` does not exist. No loaders found"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
|
||||
public let reason: String
|
||||
public var description: String { reason }
|
||||
public internal(set) var token: Token?
|
||||
public internal(set) var stackTrace: [Token]
|
||||
public var templateName: String? { token?.sourceMap.filename }
|
||||
var allTokens: [Token] {
|
||||
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: AnyObject {
|
||||
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(into: []) { $0.append(describe(token: $1)) }
|
||||
let description = templateError.token.map(describe(token:)) ?? templateError.reason
|
||||
descriptions.append(description)
|
||||
return descriptions.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
321
Sources/Stencil/Expression.swift
Normal file
321
Sources/Stencil/Expression.swift
Normal file
@@ -0,0 +1,321 @@
|
||||
public protocol Expression: CustomStringConvertible {
|
||||
func evaluate(context: Context) throws -> Bool
|
||||
}
|
||||
|
||||
protocol InfixOperator: Expression {
|
||||
init(lhs: Expression, rhs: Expression)
|
||||
}
|
||||
|
||||
protocol PrefixOperator: Expression {
|
||||
init(expression: Expression)
|
||||
}
|
||||
|
||||
final class StaticExpression: Expression, CustomStringConvertible {
|
||||
let value: Bool
|
||||
|
||||
init(value: Bool) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
value
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(value)"
|
||||
}
|
||||
}
|
||||
|
||||
final class VariableExpression: Expression, CustomStringConvertible {
|
||||
let variable: Resolvable
|
||||
|
||||
init(variable: Resolvable) {
|
||||
self.variable = variable
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"(variable: \(variable))"
|
||||
}
|
||||
|
||||
/// Resolves a variable in the given context as boolean
|
||||
func resolve(context: Context, variable: Resolvable) throws -> Bool {
|
||||
let result = try variable.resolve(context)
|
||||
var truthy = false
|
||||
|
||||
if let result = result as? [Any] {
|
||||
truthy = !result.isEmpty
|
||||
} else if let result = result as? [String: Any] {
|
||||
truthy = !result.isEmpty
|
||||
} else if let result = result as? Bool {
|
||||
truthy = result
|
||||
} else if let result = result as? String {
|
||||
truthy = !result.isEmpty
|
||||
} else if let value = result, let result = toNumber(value: value) {
|
||||
truthy = result > 0
|
||||
} else if result != nil {
|
||||
truthy = true
|
||||
}
|
||||
|
||||
return truthy
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
try resolve(context: context, variable: variable)
|
||||
}
|
||||
}
|
||||
|
||||
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
|
||||
let expression: Expression
|
||||
|
||||
init(expression: Expression) {
|
||||
self.expression = expression
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"not \(expression)"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
try !expression.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
final class InExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"(\(lhs) in \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||
let lhsValue = try lhs.variable.resolve(context)
|
||||
let rhsValue = try rhs.variable.resolve(context)
|
||||
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"(\(lhs) or \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
let lhs = try self.lhs.evaluate(context: context)
|
||||
if lhs {
|
||||
return lhs
|
||||
}
|
||||
|
||||
return try rhs.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"(\(lhs) and \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
let lhs = try self.lhs.evaluate(context: context)
|
||||
if !lhs {
|
||||
return lhs
|
||||
}
|
||||
|
||||
return try rhs.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
required init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"(\(lhs) == \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||
let lhsValue = try lhs.variable.resolve(context)
|
||||
let rhsValue = try rhs.variable.resolve(context)
|
||||
|
||||
if let lhs = lhsValue, let rhs = rhsValue {
|
||||
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
||||
return lhs == rhs
|
||||
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
|
||||
return lhs == rhs
|
||||
} else if let lhs = lhsValue as? Bool, let rhs = rhsValue as? Bool {
|
||||
return lhs == rhs
|
||||
}
|
||||
} else if lhsValue == nil && rhsValue == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||
let lhs: Expression
|
||||
let rhs: Expression
|
||||
|
||||
required init(lhs: Expression, rhs: Expression) {
|
||||
self.lhs = lhs
|
||||
self.rhs = rhs
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"(\(lhs) \(symbol) \(rhs))"
|
||||
}
|
||||
|
||||
func evaluate(context: Context) throws -> Bool {
|
||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||
let lhsValue = try lhs.variable.resolve(context)
|
||||
let rhsValue = try rhs.variable.resolve(context)
|
||||
|
||||
if let lhs = lhsValue, let rhs = rhsValue {
|
||||
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
||||
return compare(lhs: lhs, rhs: rhs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
""
|
||||
}
|
||||
|
||||
func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
class MoreThanExpression: NumericExpression {
|
||||
override var symbol: String {
|
||||
">"
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
lhs > rhs
|
||||
}
|
||||
}
|
||||
|
||||
class MoreThanEqualExpression: NumericExpression {
|
||||
override var symbol: String {
|
||||
">="
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
lhs >= rhs
|
||||
}
|
||||
}
|
||||
|
||||
class LessThanExpression: NumericExpression {
|
||||
override var symbol: String {
|
||||
"<"
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
lhs < rhs
|
||||
}
|
||||
}
|
||||
|
||||
class LessThanEqualExpression: NumericExpression {
|
||||
override var symbol: String {
|
||||
"<="
|
||||
}
|
||||
|
||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||
lhs <= rhs
|
||||
}
|
||||
}
|
||||
|
||||
class InequalityExpression: EqualityExpression {
|
||||
override var description: String {
|
||||
"(\(lhs) != \(rhs))"
|
||||
}
|
||||
|
||||
override func evaluate(context: Context) throws -> Bool {
|
||||
try !super.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func toNumber(value: Any) -> Number? {
|
||||
if let value = value as? Float {
|
||||
return Number(value)
|
||||
} else if let value = value as? Double {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int8 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int16 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int32 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Int64 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt8 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt16 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt32 {
|
||||
return Number(value)
|
||||
} else if let value = value as? UInt64 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Number {
|
||||
return value
|
||||
} else if let value = value as? Float64 {
|
||||
return Number(value)
|
||||
} else if let value = value as? Float32 {
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
101
Sources/Stencil/Extension.swift
Normal file
101
Sources/Stencil/Extension.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
/// Container for registered tags and filters
|
||||
open class Extension {
|
||||
typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||
|
||||
var tags = [String: TagParser]()
|
||||
var filters = [String: Filter]()
|
||||
|
||||
/// Simple initializer
|
||||
public init() {
|
||||
}
|
||||
|
||||
/// Registers a new template tag
|
||||
public func registerTag(_ name: String, parser: @escaping (TokenParser, Token) throws -> NodeType) {
|
||||
tags[name] = parser
|
||||
}
|
||||
|
||||
/// Registers a simple template tag with a name and a handler
|
||||
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
|
||||
registerTag(name) { _, token in
|
||||
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?) {
|
||||
// swiftlint:disable:previous discouraged_optional_boolean
|
||||
filters[name] = .simple(filter)
|
||||
filters[negativeFilterName] = .simple { value in
|
||||
guard let result = try filter(value) 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)
|
||||
}
|
||||
|
||||
/// Registers a template filter with the given name
|
||||
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
|
||||
filters[name] = .arguments { value, args, _ in try filter(value, args) }
|
||||
}
|
||||
|
||||
/// Registers a template filter with the given name
|
||||
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?], Context) throws -> Any?) {
|
||||
filters[name] = .arguments(filter)
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultExtension: Extension {
|
||||
override init() {
|
||||
super.init()
|
||||
registerDefaultTags()
|
||||
registerDefaultFilters()
|
||||
}
|
||||
|
||||
fileprivate func registerDefaultTags() {
|
||||
registerTag("for", parser: ForNode.parse)
|
||||
registerTag("if", parser: IfNode.parse)
|
||||
registerTag("ifnot", parser: IfNode.parse_ifnot)
|
||||
#if !os(Linux)
|
||||
registerTag("now", parser: NowNode.parse)
|
||||
#endif
|
||||
registerTag("include", parser: IncludeNode.parse)
|
||||
registerTag("extends", parser: ExtendsNode.parse)
|
||||
registerTag("block", parser: BlockNode.parse)
|
||||
registerTag("filter", parser: FilterNode.parse)
|
||||
}
|
||||
|
||||
fileprivate func registerDefaultFilters() {
|
||||
registerFilter("default", filter: defaultFilter)
|
||||
registerFilter("capitalize", filter: capitalise)
|
||||
registerFilter("uppercase", filter: uppercase)
|
||||
registerFilter("lowercase", filter: lowercase)
|
||||
registerFilter("join", filter: joinFilter)
|
||||
registerFilter("split", filter: splitFilter)
|
||||
registerFilter("indent", filter: indentFilter)
|
||||
registerFilter("filter", filter: filterFilter)
|
||||
}
|
||||
}
|
||||
|
||||
protocol FilterType {
|
||||
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any?
|
||||
}
|
||||
|
||||
enum Filter: FilterType {
|
||||
case simple(((Any?) throws -> Any?))
|
||||
case arguments(((Any?, [Any?], Context) throws -> Any?))
|
||||
|
||||
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
|
||||
switch self {
|
||||
case let .simple(filter):
|
||||
if !arguments.isEmpty {
|
||||
throw TemplateSyntaxError("Can't invoke filter with an argument")
|
||||
}
|
||||
return try filter(value)
|
||||
case let .arguments(filter):
|
||||
return try filter(value, arguments, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Sources/Stencil/FilterTag.swift
Normal file
36
Sources/Stencil/FilterTag.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
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
|
||||
|
||||
guard bits.count == 2 else {
|
||||
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
|
||||
}
|
||||
|
||||
let blocks = try parser.parse(until(["endfilter"]))
|
||||
|
||||
guard parser.nextToken() != nil else {
|
||||
throw TemplateSyntaxError("`endfilter` was not found.")
|
||||
}
|
||||
|
||||
let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
|
||||
return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
|
||||
}
|
||||
|
||||
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]) {
|
||||
try VariableNode(variable: resolvable, token: token).render(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
133
Sources/Stencil/Filters.swift
Normal file
133
Sources/Stencil/Filters.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
func capitalise(_ value: Any?) -> Any? {
|
||||
if let array = value as? [Any?] {
|
||||
return array.map { stringify($0).capitalized }
|
||||
} else {
|
||||
return stringify(value).capitalized
|
||||
}
|
||||
}
|
||||
|
||||
func uppercase(_ value: Any?) -> Any? {
|
||||
if let array = value as? [Any?] {
|
||||
return array.map { stringify($0).uppercased() }
|
||||
} else {
|
||||
return stringify(value).uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
func lowercase(_ value: Any?) -> Any? {
|
||||
if let array = value as? [Any?] {
|
||||
return array.map { stringify($0).lowercased() }
|
||||
} else {
|
||||
return stringify(value).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
|
||||
// value can be optional wrapping nil, so this way we check for underlying value
|
||||
if let value = value, String(describing: value) != "nil" {
|
||||
return value
|
||||
}
|
||||
|
||||
for argument in arguments {
|
||||
if let argument = argument {
|
||||
return argument
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||
guard arguments.count < 2 else {
|
||||
throw TemplateSyntaxError("'join' filter takes at most one argument")
|
||||
}
|
||||
|
||||
let separator = stringify(arguments.first ?? "")
|
||||
|
||||
if let value = value as? [Any] {
|
||||
return value
|
||||
.map(stringify)
|
||||
.joined(separator: separator)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||
guard arguments.count < 2 else {
|
||||
throw TemplateSyntaxError("'split' filter takes at most one 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.isEmpty {
|
||||
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()
|
||||
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(into: [firstLine]) { result, line in
|
||||
result.append(line.isEmpty ? "" : "\(indentation)\(line)")
|
||||
}
|
||||
return result.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func filterFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
|
||||
guard let value = value else { return nil }
|
||||
guard arguments.count == 1 else {
|
||||
throw TemplateSyntaxError("'filter' filter takes one argument")
|
||||
}
|
||||
|
||||
let attribute = stringify(arguments[0])
|
||||
|
||||
let expr = try context.environment.compileFilter("$0|\(attribute)")
|
||||
return try context.push(dictionary: ["$0": value]) {
|
||||
try expr.resolve(context)
|
||||
}
|
||||
}
|
||||
176
Sources/Stencil/ForTag.swift
Normal file
176
Sources/Stencil/ForTag.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
|
||||
class ForNode: NodeType {
|
||||
let resolvable: Resolvable
|
||||
let loopVariables: [String]
|
||||
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
|
||||
|
||||
func hasToken(_ token: String, at index: Int) -> Bool {
|
||||
components.count > (index + 1) && components[index] == token
|
||||
}
|
||||
|
||||
func endsOrHasToken(_ token: String, at index: Int) -> Bool {
|
||||
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]
|
||||
.split(separator: ",")
|
||||
.map(String.init)
|
||||
.map { $0.trim(character: " ") }
|
||||
|
||||
let resolvable = try parser.compileResolvable(components[3], containedIn: token)
|
||||
|
||||
let `where` = hasToken("where", at: 4)
|
||||
? try parser.compileExpression(components: Array(components.suffix(from: 5)), token: token)
|
||||
: nil
|
||||
|
||||
let forNodes = try parser.parse(until(["endfor", "empty"]))
|
||||
|
||||
guard let token = parser.nextToken() else {
|
||||
throw TemplateSyntaxError("`endfor` was not found.")
|
||||
}
|
||||
|
||||
var emptyNodes = [NodeType]()
|
||||
if token.contents == "empty" {
|
||||
emptyNodes = try parser.parse(until(["endfor"]))
|
||||
_ = parser.nextToken()
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
self.resolvable = resolvable
|
||||
self.loopVariables = loopVariables
|
||||
self.nodes = nodes
|
||||
self.emptyNodes = emptyNodes
|
||||
self.where = `where`
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
var values = try resolve(context)
|
||||
|
||||
if let `where` = self.where {
|
||||
values = try values.filter { item -> Bool in
|
||||
try push(value: item, context: context) {
|
||||
try `where`.evaluate(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !values.isEmpty {
|
||||
let count = values.count
|
||||
|
||||
return try zip(0..., values)
|
||||
.map { index, item in
|
||||
let forContext: [String: Any] = [
|
||||
"first": index == 0,
|
||||
"last": index == (count - 1),
|
||||
"counter": index + 1,
|
||||
"counter0": index,
|
||||
"length": count
|
||||
]
|
||||
|
||||
return try context.push(dictionary: ["forloop": forContext]) {
|
||||
try push(value: item, context: context) {
|
||||
try renderNodes(nodes, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
.joined()
|
||||
}
|
||||
|
||||
return try context.push {
|
||||
try renderNodes(emptyNodes, context)
|
||||
}
|
||||
}
|
||||
|
||||
private func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
|
||||
if loopVariables.isEmpty {
|
||||
return try context.push {
|
||||
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: variablesContext) {
|
||||
try closure()
|
||||
}
|
||||
}
|
||||
|
||||
return try context.push(dictionary: [loopVariables.first ?? "": value]) {
|
||||
try closure()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolve(_ context: Context) throws -> [Any] {
|
||||
let resolved = try resolvable.resolve(context)
|
||||
|
||||
var values: [Any]
|
||||
if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
|
||||
values = dictionary.sorted { $0.key < $1.key }
|
||||
} else if let array = resolved as? [Any] {
|
||||
values = array
|
||||
} else if let range = resolved as? CountableClosedRange<Int> {
|
||||
values = Array(range)
|
||||
} else if let range = resolved as? CountableRange<Int> {
|
||||
values = Array(range)
|
||||
} else if let resolved = resolved {
|
||||
let mirror = Mirror(reflecting: resolved)
|
||||
switch mirror.displayStyle {
|
||||
case .struct, .tuple:
|
||||
values = Array(mirror.children)
|
||||
case .class:
|
||||
var children = Array(mirror.children)
|
||||
var currentMirror: Mirror? = mirror
|
||||
while let superclassMirror = currentMirror?.superclassMirror {
|
||||
children.append(contentsOf: superclassMirror.children)
|
||||
currentMirror = superclassMirror
|
||||
}
|
||||
values = Array(children)
|
||||
default:
|
||||
values = []
|
||||
}
|
||||
} else {
|
||||
values = []
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
}
|
||||
313
Sources/Stencil/IfTag.swift
Normal file
313
Sources/Stencil/IfTag.swift
Normal file
@@ -0,0 +1,313 @@
|
||||
enum Operator {
|
||||
case infix(String, Int, InfixOperator.Type)
|
||||
case prefix(String, Int, PrefixOperator.Type)
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .infix(let name, _, _):
|
||||
return name
|
||||
case .prefix(let name, _, _):
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
static let all: [Operator] = [
|
||||
.infix("in", 5, InExpression.self),
|
||||
.infix("or", 6, OrExpression.self),
|
||||
.infix("and", 7, AndExpression.self),
|
||||
.prefix("not", 8, NotExpression.self),
|
||||
.infix("==", 10, EqualityExpression.self),
|
||||
.infix("!=", 10, InequalityExpression.self),
|
||||
.infix(">", 10, MoreThanExpression.self),
|
||||
.infix(">=", 10, MoreThanEqualExpression.self),
|
||||
.infix("<", 10, LessThanExpression.self),
|
||||
.infix("<=", 10, LessThanEqualExpression.self)
|
||||
]
|
||||
}
|
||||
|
||||
func findOperator(name: String) -> Operator? {
|
||||
for `operator` in Operator.all where `operator`.name == name {
|
||||
return `operator`
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
switch self {
|
||||
case .infix(_, let bindingPower, _):
|
||||
return bindingPower
|
||||
case .prefix(_, let bindingPower, _):
|
||||
return bindingPower
|
||||
case .variable:
|
||||
return 0
|
||||
case .subExpression:
|
||||
return 0
|
||||
case .end:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func nullDenotation(parser: IfExpressionParser) throws -> Expression {
|
||||
switch self {
|
||||
case .infix(let name, _, _):
|
||||
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
|
||||
case .prefix(_, let bindingPower, let operatorType):
|
||||
let expression = try parser.expression(bindingPower: bindingPower)
|
||||
return operatorType.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")
|
||||
}
|
||||
}
|
||||
|
||||
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
|
||||
switch self {
|
||||
case .infix(_, let bindingPower, let operatorType):
|
||||
let right = try parser.expression(bindingPower: bindingPower)
|
||||
return operatorType.init(lhs: left, rhs: right)
|
||||
case .prefix(let name, _, _):
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
var isEnd: Bool {
|
||||
switch self {
|
||||
case .end:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class IfExpressionParser {
|
||||
let tokens: [IfToken]
|
||||
var position: Int = 0
|
||||
|
||||
private init(tokens: [IfToken]) {
|
||||
self.tokens = tokens
|
||||
}
|
||||
|
||||
static func parser(components: [String], environment: Environment, token: Token) throws -> IfExpressionParser {
|
||||
try IfExpressionParser(components: ArraySlice(components), environment: environment, token: token)
|
||||
}
|
||||
|
||||
private init(components: ArraySlice<String>, environment: Environment, 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 Self.subExpression(
|
||||
from: components.suffix(from: index + 1),
|
||||
environment: environment,
|
||||
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 `operator` = findOperator(name: component) {
|
||||
switch `operator` {
|
||||
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 environment.compileResolvable(component, containedIn: token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func subExpression(
|
||||
from components: ArraySlice<String>,
|
||||
environment: Environment,
|
||||
token: Token
|
||||
) throws -> (Expression, Int) {
|
||||
var bracketsBalance = 1
|
||||
let subComponents = components.prefix { component in
|
||||
if component == "(" {
|
||||
bracketsBalance += 1
|
||||
} else if component == ")" {
|
||||
bracketsBalance -= 1
|
||||
}
|
||||
return bracketsBalance != 0
|
||||
}
|
||||
if bracketsBalance > 0 {
|
||||
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
|
||||
}
|
||||
|
||||
let expressionParser = try IfExpressionParser(components: subComponents, environment: environment, token: token)
|
||||
let expression = try expressionParser.parse()
|
||||
return (expression, subComponents.count)
|
||||
}
|
||||
|
||||
var currentToken: IfToken {
|
||||
if tokens.count > position {
|
||||
return tokens[position]
|
||||
}
|
||||
|
||||
return .end
|
||||
}
|
||||
|
||||
var nextToken: IfToken {
|
||||
position += 1
|
||||
return currentToken
|
||||
}
|
||||
|
||||
func parse() throws -> Expression {
|
||||
let expression = try self.expression()
|
||||
|
||||
if !currentToken.isEnd {
|
||||
throw TemplateSyntaxError("'if' expression error: dangling token")
|
||||
}
|
||||
|
||||
return expression
|
||||
}
|
||||
|
||||
func expression(bindingPower: Int = 0) throws -> Expression {
|
||||
var token = currentToken
|
||||
position += 1
|
||||
|
||||
var left = try token.nullDenotation(parser: self)
|
||||
|
||||
while bindingPower < currentToken.bindingPower {
|
||||
token = currentToken
|
||||
position += 1
|
||||
left = try token.leftDenotation(left: left, parser: self)
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an if condition and the associated nodes when the condition
|
||||
/// evaluates
|
||||
final class IfCondition {
|
||||
let expression: Expression?
|
||||
let nodes: [NodeType]
|
||||
|
||||
init(expression: Expression?, nodes: [NodeType]) {
|
||||
self.expression = expression
|
||||
self.nodes = nodes
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
try context.push {
|
||||
try renderNodes(nodes, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, token: token)
|
||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||
var conditions: [IfCondition] = [
|
||||
IfCondition(expression: expression, nodes: nodes)
|
||||
]
|
||||
|
||||
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, token: current)
|
||||
|
||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||
nextToken = parser.nextToken()
|
||||
conditions.append(IfCondition(expression: expression, nodes: nodes))
|
||||
}
|
||||
|
||||
if let current = nextToken, current.contents == "else" {
|
||||
conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"]))))
|
||||
nextToken = parser.nextToken()
|
||||
}
|
||||
|
||||
guard let current = nextToken, current.contents == "endif" else {
|
||||
throw TemplateSyntaxError("`endif` was not found.")
|
||||
}
|
||||
|
||||
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 syntax 'ifnot condition'.")
|
||||
}
|
||||
components.removeFirst()
|
||||
var trueNodes = [NodeType]()
|
||||
var falseNodes = [NodeType]()
|
||||
|
||||
let expression = try parser.compileExpression(components: components, token: token)
|
||||
falseNodes = try parser.parse(until(["endif", "else"]))
|
||||
|
||||
guard let token = parser.nextToken() else {
|
||||
throw TemplateSyntaxError("`endif` was not found.")
|
||||
}
|
||||
|
||||
if token.contents == "else" {
|
||||
trueNodes = try parser.parse(until(["endif"]))
|
||||
_ = parser.nextToken()
|
||||
}
|
||||
|
||||
return IfNode(conditions: [
|
||||
IfCondition(expression: expression, nodes: trueNodes),
|
||||
IfCondition(expression: nil, nodes: falseNodes)
|
||||
], token: token)
|
||||
}
|
||||
|
||||
init(conditions: [IfCondition], token: Token? = nil) {
|
||||
self.conditions = conditions
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
for condition in conditions {
|
||||
if let expression = condition.expression {
|
||||
let truthy = try expression.evaluate(context: context)
|
||||
|
||||
if truthy {
|
||||
return try condition.render(context)
|
||||
}
|
||||
} else {
|
||||
return try condition.render(context)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
50
Sources/Stencil/Include.swift
Normal file
50
Sources/Stencil/Include.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
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
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
|
||||
}
|
||||
|
||||
init(templateName: Variable, includeContext: String? = nil, token: Token) {
|
||||
self.templateName = templateName
|
||||
self.includeContext = includeContext
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
guard let templateName = try self.templateName.resolve(context) as? String else {
|
||||
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
|
||||
}
|
||||
|
||||
let template = try context.environment.loadTemplate(name: templateName)
|
||||
|
||||
do {
|
||||
let subContext = includeContext.flatMap { context[$0] as? [String: Any] } ?? [:]
|
||||
return try context.push(dictionary: subContext) {
|
||||
try template.render(context)
|
||||
}
|
||||
} catch {
|
||||
if let error = error as? TemplateSyntaxError {
|
||||
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
Sources/Stencil/Inheritance.swift
Normal file
156
Sources/Stencil/Inheritance.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
class BlockContext {
|
||||
class var contextKey: String { "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 { self.blocks[$0.key] = [$0.value] }
|
||||
}
|
||||
|
||||
func push(_ block: BlockNode, forKey blockName: String) {
|
||||
if var blocks = blocks[blockName] {
|
||||
blocks.append(block)
|
||||
self.blocks[blockName] = blocks
|
||||
} else {
|
||||
self.blocks[blockName] = [block]
|
||||
}
|
||||
}
|
||||
|
||||
func pop(_ blockName: String) -> BlockNode? {
|
||||
if var blocks = blocks[blockName] {
|
||||
let block = blocks.removeFirst()
|
||||
if blocks.isEmpty {
|
||||
self.blocks.removeValue(forKey: blockName)
|
||||
} else {
|
||||
self.blocks[blockName] = blocks
|
||||
}
|
||||
return block
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
|
||||
for element in self where closure(element) {
|
||||
return element
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
guard bits.count == 2 else {
|
||||
throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
|
||||
}
|
||||
|
||||
let parsedNodes = try parser.parse()
|
||||
guard (parsedNodes.any { $0 is ExtendsNode }) == nil else {
|
||||
throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
|
||||
}
|
||||
|
||||
let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
|
||||
let nodes = blockNodes.reduce(into: [String: BlockNode]()) { accumulator, node in
|
||||
accumulator[node.name] = node
|
||||
}
|
||||
|
||||
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
|
||||
}
|
||||
|
||||
init(templateName: Variable, blocks: [String: BlockNode], token: Token) {
|
||||
self.templateName = templateName
|
||||
self.blocks = blocks
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func render(_ context: Context) throws -> String {
|
||||
guard let templateName = try self.templateName.resolve(context) as? String else {
|
||||
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
|
||||
}
|
||||
|
||||
let baseTemplate = try context.environment.loadTemplate(name: templateName)
|
||||
|
||||
let blockContext: BlockContext
|
||||
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]) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
guard bits.count == 2 else {
|
||||
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
|
||||
}
|
||||
|
||||
let blockName = bits[1]
|
||||
let nodes = try parser.parse(until(["endblock"]))
|
||||
_ = parser.nextToken()
|
||||
return BlockNode(name: blockName, nodes: nodes, token: token)
|
||||
}
|
||||
|
||||
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 child = blockContext.pop(name) {
|
||||
let childContext: [String: Any] = [
|
||||
BlockContext.contextKey: blockContext,
|
||||
"block": ["super": try self.render(context)]
|
||||
]
|
||||
|
||||
// render extension node
|
||||
do {
|
||||
return try context.push(dictionary: childContext) {
|
||||
try child.render(context)
|
||||
}
|
||||
} catch {
|
||||
throw error.withToken(child.token)
|
||||
}
|
||||
}
|
||||
|
||||
return try renderNodes(nodes, context)
|
||||
}
|
||||
}
|
||||
112
Sources/Stencil/KeyPath.swift
Normal file
112
Sources/Stencil/KeyPath.swift
Normal 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 character in variable {
|
||||
switch character {
|
||||
case "." where subscriptLevel == 0:
|
||||
try foundSeparator()
|
||||
case "[":
|
||||
try openBracket()
|
||||
case "]":
|
||||
try closeBracket()
|
||||
default:
|
||||
try addCharacter(character)
|
||||
}
|
||||
}
|
||||
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(_ character: Character) throws {
|
||||
guard partialComponents.isEmpty || subscriptLevel > 0 else {
|
||||
throw TemplateSyntaxError("Unexpected character '\(character)' in variable '\(variable)'")
|
||||
}
|
||||
|
||||
current.append(character)
|
||||
}
|
||||
|
||||
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)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
231
Sources/Stencil/Lexer.swift
Normal file
231
Sources/Stencil/Lexer.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
import Foundation
|
||||
|
||||
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 = zip(1..., templateString.components(separatedBy: .newlines)).compactMap { index, line in
|
||||
guard !line.isEmpty,
|
||||
let range = templateString.range(of: line) else { return nil }
|
||||
return (content: line, number: UInt(index), range)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.count > 4 else { return "" }
|
||||
let trimmed = String(string.dropFirst(2).dropLast(2))
|
||||
.components(separatedBy: "\n")
|
||||
.filter { !$0.isEmpty }
|
||||
.map { $0.trim(character: " ") }
|
||||
.joined(separator: " ")
|
||||
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: value, at: sourceMap)
|
||||
} else if string.hasPrefix("{%") {
|
||||
return .block(value: value, at: sourceMap)
|
||||
} else if string.hasPrefix("{#") {
|
||||
return .comment(value: value, at: sourceMap)
|
||||
}
|
||||
}
|
||||
|
||||
let location = rangeLocation(range)
|
||||
let sourceMap = SourceMap(filename: templateName, location: location)
|
||||
return .text(value: string, at: sourceMap)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
while !scanner.isEmpty {
|
||||
if let (char, text) = scanner.scanForTokenStart(Self.tokenChars) {
|
||||
if !text.isEmpty {
|
||||
tokens.append(createToken(string: text, at: scanner.range))
|
||||
}
|
||||
|
||||
guard let end = Self.tokenCharMap[char] else { continue }
|
||||
let result = scanner.scanForTokenEnd(end)
|
||||
tokens.append(createToken(string: result, at: scanner.range))
|
||||
} else {
|
||||
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.UnicodeScalarView.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.unicodeScalars.startIndex..<content.unicodeScalars.startIndex
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
content.isEmpty
|
||||
}
|
||||
|
||||
/// 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 zip(0..., content.unicodeScalars) {
|
||||
if foundChar && char == Self.tokenEndDelimiter {
|
||||
let result = String(content.unicodeScalars.prefix(index + 1))
|
||||
content = String(content.unicodeScalars.dropFirst(index + 1))
|
||||
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index + 1)
|
||||
return result
|
||||
} else {
|
||||
foundChar = (char == tokenChar)
|
||||
}
|
||||
}
|
||||
|
||||
content = ""
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
range = range.upperBound..<range.upperBound
|
||||
for (index, char) in zip(0..., content.unicodeScalars) {
|
||||
if foundBrace && tokenChars.contains(char) {
|
||||
let result = String(content.unicodeScalars.prefix(index - 1))
|
||||
content = String(content.unicodeScalars.dropFirst(index - 1))
|
||||
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index - 1)
|
||||
return (char, result)
|
||||
} else {
|
||||
foundBrace = (char == Self.tokenStartDelimiter)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func findFirstNot(character: Character) -> String.Index? {
|
||||
var index = startIndex
|
||||
|
||||
while index != endIndex {
|
||||
if character != self[index] {
|
||||
return index
|
||||
}
|
||||
index = self.index(after: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findLastNot(character: Character) -> String.Index? {
|
||||
var index = self.index(before: endIndex)
|
||||
|
||||
while index != startIndex {
|
||||
if character != self[index] {
|
||||
return self.index(after: index)
|
||||
}
|
||||
index = self.index(before: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func trim(character: Character) -> String {
|
||||
let first = findFirstNot(character: character) ?? startIndex
|
||||
let last = findLastNot(character: character) ?? endIndex
|
||||
return String(self[first..<last])
|
||||
}
|
||||
}
|
||||
|
||||
/// Location in some content (text)
|
||||
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)
|
||||
128
Sources/Stencil/Loader.swift
Normal file
128
Sources/Stencil/Loader.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
import PathKit
|
||||
|
||||
/// Type used for loading a template
|
||||
public protocol Loader {
|
||||
/// Load a template with the given name
|
||||
func loadTemplate(name: String, environment: Environment) throws -> Template
|
||||
/// Load a template with the given list of names
|
||||
func loadTemplate(names: [String], environment: Environment) throws -> Template
|
||||
}
|
||||
|
||||
extension Loader {
|
||||
/// Default implementation, tries to load the first template that exists from the list of given names
|
||||
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||
for name in names {
|
||||
do {
|
||||
return try loadTemplate(name: name, environment: environment)
|
||||
} catch is TemplateDoesNotExist {
|
||||
continue
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||
}
|
||||
}
|
||||
|
||||
// A class for loading a template from disk
|
||||
public class FileSystemLoader: Loader, CustomStringConvertible {
|
||||
public let paths: [Path]
|
||||
|
||||
public init(paths: [Path]) {
|
||||
self.paths = paths
|
||||
}
|
||||
|
||||
public init(bundle: [Bundle]) {
|
||||
self.paths = bundle.map { bundle in
|
||||
Path(bundle.bundlePath)
|
||||
}
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
"FileSystemLoader(\(paths))"
|
||||
}
|
||||
|
||||
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||
for path in paths {
|
||||
let templatePath = try path.safeJoin(path: Path(name))
|
||||
|
||||
if !templatePath.exists {
|
||||
continue
|
||||
}
|
||||
|
||||
let content: String = try templatePath.read()
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
||||
}
|
||||
|
||||
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||
for path in paths {
|
||||
for templateName in names {
|
||||
let templatePath = try path.safeJoin(path: Path(templateName))
|
||||
|
||||
if templatePath.exists {
|
||||
let content: String = try templatePath.read()
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: templateName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||
}
|
||||
}
|
||||
|
||||
public class DictionaryLoader: Loader {
|
||||
public let templates: [String: String]
|
||||
|
||||
public init(templates: [String: String]) {
|
||||
self.templates = templates
|
||||
}
|
||||
|
||||
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||
if let content = templates[name] {
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
||||
}
|
||||
|
||||
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||
for name in names {
|
||||
if let content = templates[name] {
|
||||
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Path {
|
||||
func safeJoin(path: Path) throws -> Path {
|
||||
let newPath = self + path
|
||||
|
||||
if !newPath.absolute().description.hasPrefix(absolute().description) {
|
||||
throw SuspiciousFileOperation(basePath: self, path: newPath)
|
||||
}
|
||||
|
||||
return newPath
|
||||
}
|
||||
}
|
||||
|
||||
class SuspiciousFileOperation: Error {
|
||||
let basePath: Path
|
||||
let path: Path
|
||||
|
||||
init(basePath: Path, path: Path) {
|
||||
self.basePath = basePath
|
||||
self.path = path
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"Path `\(path)` is located outside of base path `\(basePath)`"
|
||||
}
|
||||
}
|
||||
155
Sources/Stencil/Node.swift
Normal file
155
Sources/Stencil/Node.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents a parsed node
|
||||
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 {
|
||||
try nodes
|
||||
.map { node in
|
||||
do {
|
||||
return try node.render(context)
|
||||
} catch {
|
||||
throw error.withToken(node.token)
|
||||
}
|
||||
}
|
||||
.joined()
|
||||
}
|
||||
|
||||
/// Simple node, used for triggering a closure during rendering
|
||||
public class SimpleNode: NodeType {
|
||||
public let handler: (Context) throws -> String
|
||||
public let token: Token?
|
||||
|
||||
public init(token: Token, handler: @escaping (Context) throws -> String) {
|
||||
self.token = token
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
public func render(_ context: Context) throws -> String {
|
||||
try handler(context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a block of text, renders the text
|
||||
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 {
|
||||
self.text
|
||||
}
|
||||
}
|
||||
|
||||
/// Representing something that can be resolved in a context
|
||||
public protocol Resolvable {
|
||||
/// Try to resolve this with the given context
|
||||
func resolve(_ context: Context) throws -> Any?
|
||||
}
|
||||
|
||||
/// Represents a variable, renders the variable, may have conditional expressions.
|
||||
public class VariableNode: NodeType {
|
||||
public let variable: Resolvable
|
||||
public var token: Token?
|
||||
let condition: Expression?
|
||||
let elseExpression: Resolvable?
|
||||
|
||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||
let components = token.components
|
||||
|
||||
func hasToken(_ token: String, at index: Int) -> Bool {
|
||||
components.count > (index + 1) && components[index] == token
|
||||
}
|
||||
|
||||
let condition: Expression?
|
||||
let elseExpression: Resolvable?
|
||||
|
||||
if hasToken("if", at: 1) {
|
||||
let components = components.suffix(from: 2)
|
||||
if let elseIndex = components.firstIndex(of: "else") {
|
||||
condition = try parser.compileExpression(components: Array(components.prefix(upTo: elseIndex)), token: token)
|
||||
let elseToken = components.suffix(from: elseIndex.advanced(by: 1)).joined(separator: " ")
|
||||
elseExpression = try parser.compileResolvable(elseToken, containedIn: token)
|
||||
} else {
|
||||
condition = try parser.compileExpression(components: Array(components), token: token)
|
||||
elseExpression = nil
|
||||
}
|
||||
} else {
|
||||
condition = nil
|
||||
elseExpression = nil
|
||||
}
|
||||
|
||||
guard let resolvable = components.first else {
|
||||
throw TemplateSyntaxError(reason: "Missing variable name", token: token)
|
||||
}
|
||||
let filter = try parser.compileResolvable(resolvable, 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return result.description
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func unwrap(_ array: [Any?]) -> [Any] {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
44
Sources/Stencil/NowTag.swift
Normal file
44
Sources/Stencil/NowTag.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
#if !os(Linux)
|
||||
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.")
|
||||
}
|
||||
if components.count == 2 {
|
||||
format = Variable(components[1])
|
||||
}
|
||||
|
||||
return NowNode(format: format, token: token)
|
||||
}
|
||||
|
||||
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 {
|
||||
let date = Date()
|
||||
let format = try self.format.resolve(context)
|
||||
|
||||
var formatter: DateFormatter
|
||||
if let format = format as? DateFormatter {
|
||||
formatter = format
|
||||
} else if let format = format as? String {
|
||||
formatter = DateFormatter()
|
||||
formatter.dateFormat = format
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
232
Sources/Stencil/Parser.swift
Normal file
232
Sources/Stencil/Parser.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
/// Creates a checker that will stop parsing if it encounters a list of tags.
|
||||
/// Useful for example for scanning until a given "end"-node.
|
||||
public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
|
||||
{ _, token in
|
||||
if let name = token.components.first {
|
||||
for tag in tags where name == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// A class for parsing an array of tokens and converts them into a collection of Node's
|
||||
public class TokenParser {
|
||||
/// Parser for finding a kind of node
|
||||
public typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||
|
||||
fileprivate var tokens: [Token]
|
||||
fileprivate let environment: Environment
|
||||
|
||||
/// Simple initializer
|
||||
public init(tokens: [Token], environment: Environment) {
|
||||
self.tokens = tokens
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Parse the given tokens into nodes
|
||||
public func parse() throws -> [NodeType] {
|
||||
try parse(nil)
|
||||
}
|
||||
|
||||
/// Parse nodes until a specific "something" is detected, determined by the provided closure.
|
||||
/// Combine this with the `until(:)` function above to scan nodes until a given token.
|
||||
public func parse(_ parseUntil: ((_ parser: TokenParser, _ token: Token) -> (Bool))?) throws -> [NodeType] {
|
||||
var nodes = [NodeType]()
|
||||
|
||||
while !tokens.isEmpty {
|
||||
guard let token = nextToken() else { break }
|
||||
|
||||
switch token.kind {
|
||||
case .text:
|
||||
nodes.append(TextNode(text: token.contents))
|
||||
case .variable:
|
||||
try nodes.append(VariableNode.parse(self, token: token))
|
||||
case .block:
|
||||
if let parseUntil = parseUntil, parseUntil(self, token) {
|
||||
prependToken(token)
|
||||
return nodes
|
||||
}
|
||||
|
||||
if let tag = token.components.first {
|
||||
do {
|
||||
let parser = try environment.findTag(name: tag)
|
||||
let node = try parser(self, token)
|
||||
nodes.append(node)
|
||||
} catch {
|
||||
throw error.withToken(token)
|
||||
}
|
||||
}
|
||||
case .comment:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
/// Pop the next token (returning it)
|
||||
public func nextToken() -> Token? {
|
||||
if !tokens.isEmpty {
|
||||
return tokens.remove(at: 0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Insert a token
|
||||
public func prependToken(_ token: Token) {
|
||||
tokens.insert(token, at: 0)
|
||||
}
|
||||
|
||||
/// Create filter expression from a string contained in provided token
|
||||
public func compileFilter(_ filterToken: String, containedIn token: Token) throws -> Resolvable {
|
||||
try environment.compileFilter(filterToken, containedIn: token)
|
||||
}
|
||||
|
||||
/// Create boolean expression from components contained in provided token
|
||||
public func compileExpression(components: [String], token: Token) throws -> Expression {
|
||||
try environment.compileExpression(components: components, containedIn: token)
|
||||
}
|
||||
|
||||
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
|
||||
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||
try environment.compileResolvable(token, containedIn: containingToken)
|
||||
}
|
||||
}
|
||||
|
||||
extension Environment {
|
||||
func findTag(name: String) throws -> Extension.TagParser {
|
||||
for ext in extensions {
|
||||
if let filter = ext.tags[name] {
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
throw TemplateSyntaxError("Unknown template tag '\(name)'")
|
||||
}
|
||||
|
||||
func findFilter(_ name: String) throws -> FilterType {
|
||||
for ext in extensions {
|
||||
if let filter = ext.filters[name] {
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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.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 }
|
||||
}
|
||||
|
||||
/// Create filter expression from a string
|
||||
public func compileFilter(_ token: String) throws -> Resolvable {
|
||||
try FilterExpression(token: token, environment: self)
|
||||
}
|
||||
|
||||
/// Create filter expression from a string contained in provided token
|
||||
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||
do {
|
||||
return try FilterExpression(token: filterToken, environment: 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Create resolvable (i.e. range variable or filter expression) from a string
|
||||
public func compileResolvable(_ token: String) throws -> Resolvable {
|
||||
try RangeVariable(token, environment: self)
|
||||
?? compileFilter(token)
|
||||
}
|
||||
|
||||
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
|
||||
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||
try RangeVariable(token, environment: self, containedIn: containingToken)
|
||||
?? compileFilter(token, containedIn: containingToken)
|
||||
}
|
||||
|
||||
/// Create boolean expression from components contained in provided token
|
||||
public func compileExpression(components: [String], containedIn token: Token) throws -> Expression {
|
||||
try IfExpressionParser.parser(components: components, environment: self, token: token).parse()
|
||||
}
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
|
||||
extension String {
|
||||
subscript(_ index: Int) -> Character {
|
||||
self[self.index(self.startIndex, offsetBy: index)]
|
||||
}
|
||||
|
||||
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.count)
|
||||
current = [Int](repeating: 0, count: target.count + 1)
|
||||
|
||||
for selfIndex in 0..<self.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] = selfIndex + 1
|
||||
|
||||
// use formula to fill in the rest of the row
|
||||
for targetIndex in 0..<target.count {
|
||||
current[targetIndex + 1] = Swift.min(
|
||||
last[targetIndex + 1] + 1,
|
||||
current[targetIndex] + 1,
|
||||
last[targetIndex] + (self[selfIndex] == target[targetIndex] ? 0 : 1)
|
||||
)
|
||||
}
|
||||
|
||||
// copy v1 (current row) to v0 (previous row) for next iteration
|
||||
last = current
|
||||
}
|
||||
|
||||
return current[target.count]
|
||||
}
|
||||
}
|
||||
83
Sources/Stencil/Template.swift
Normal file
83
Sources/Stencil/Template.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import PathKit
|
||||
|
||||
#if os(Linux)
|
||||
// swiftlint:disable:next prefixed_toplevel_constant
|
||||
let NSFileNoSuchFileError = 4
|
||||
#endif
|
||||
|
||||
/// A class representing a template
|
||||
open class Template: ExpressibleByStringLiteral {
|
||||
let templateString: String
|
||||
var environment: Environment
|
||||
|
||||
/// The list of parsed (lexed) tokens
|
||||
public let tokens: [Token]
|
||||
|
||||
/// The name of the loaded Template if the Template was loaded from a Loader
|
||||
public let name: String?
|
||||
|
||||
/// Create a template with a template string
|
||||
public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
|
||||
self.environment = environment ?? Environment()
|
||||
self.name = name
|
||||
self.templateString = templateString
|
||||
|
||||
let lexer = Lexer(templateName: name, templateString: templateString)
|
||||
tokens = lexer.tokenize()
|
||||
}
|
||||
|
||||
/// Create a template with the given name inside the given bundle
|
||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||
public convenience init(named: String, inBundle bundle: Bundle? = nil) throws {
|
||||
let useBundle = bundle ?? Bundle.main
|
||||
guard let url = useBundle.url(forResource: named, withExtension: nil) else {
|
||||
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
|
||||
}
|
||||
|
||||
try self.init(URL: url)
|
||||
}
|
||||
|
||||
/// Create a template with a file found at the given URL
|
||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||
public convenience init(URL: Foundation.URL) throws {
|
||||
try self.init(path: Path(URL.path))
|
||||
}
|
||||
|
||||
/// Create a template with a file found at the given path
|
||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||
public convenience init(path: Path, environment: Environment? = nil, name: String? = nil) throws {
|
||||
self.init(templateString: try path.read(), environment: environment, name: name)
|
||||
}
|
||||
|
||||
// MARK: ExpressibleByStringLiteral
|
||||
|
||||
// Create a templaVte with a template string literal
|
||||
public required convenience init(stringLiteral value: String) {
|
||||
self.init(templateString: value)
|
||||
}
|
||||
|
||||
// Create a template with a template string literal
|
||||
public required convenience init(extendedGraphemeClusterLiteral value: StringLiteralType) {
|
||||
self.init(stringLiteral: value)
|
||||
}
|
||||
|
||||
// Create a template with a template string literal
|
||||
public required convenience init(unicodeScalarLiteral value: StringLiteralType) {
|
||||
self.init(stringLiteral: value)
|
||||
}
|
||||
|
||||
/// Render the given template with a context
|
||||
public func render(_ context: Context) throws -> String {
|
||||
let context = context
|
||||
let parser = TokenParser(tokens: tokens, environment: context.environment)
|
||||
let nodes = try parser.parse()
|
||||
return try renderNodes(nodes, context)
|
||||
}
|
||||
|
||||
// swiftlint:disable discouraged_optional_collection
|
||||
/// Render the given template
|
||||
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
||||
try render(Context(dictionary: dictionary ?? [:], environment: environment))
|
||||
}
|
||||
}
|
||||
130
Sources/Stencil/Tokenizer.swift
Normal file
130
Sources/Stencil/Tokenizer.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Split a string by a separator leaving quoted phrases together
|
||||
func smartSplit(separator: Character = " ") -> [String] {
|
||||
var word = ""
|
||||
var components: [String] = []
|
||||
var separate: Character = separator
|
||||
var singleQuoteCount = 0
|
||||
var doubleQuoteCount = 0
|
||||
|
||||
for character in self {
|
||||
if character == "'" {
|
||||
singleQuoteCount += 1
|
||||
} else if character == "\"" {
|
||||
doubleQuoteCount += 1
|
||||
}
|
||||
|
||||
if character == separate {
|
||||
if separate != separator {
|
||||
word.append(separate)
|
||||
} else if (singleQuoteCount.isMultiple(of: 2) || doubleQuoteCount.isMultiple(of: 2)) && !word.isEmpty {
|
||||
appendWord(word, to: &components)
|
||||
word = ""
|
||||
}
|
||||
|
||||
separate = separator
|
||||
} else {
|
||||
if separate == separator && (character == "'" || character == "\"") {
|
||||
separate = character
|
||||
}
|
||||
word.append(character)
|
||||
}
|
||||
}
|
||||
|
||||
if !word.isEmpty {
|
||||
appendWord(word, to: &components)
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
private func appendWord(_ word: String, to components: inout [String]) {
|
||||
let specialCharacters = ",|:"
|
||||
|
||||
if !components.isEmpty {
|
||||
if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
|
||||
components[components.count - 1] += word
|
||||
} else if specialCharacters.contains(word) {
|
||||
components[components.count - 1] += word
|
||||
} else if word != "(" && word.first == "(" || word != ")" && word.first == ")" {
|
||||
components.append(String(word.prefix(1)))
|
||||
appendWord(String(word.dropFirst()), to: &components)
|
||||
} else if word != "(" && word.last == "(" || word != ")" && word.last == ")" {
|
||||
appendWord(String(word.dropLast()), to: &components)
|
||||
components.append(String(word.suffix(1)))
|
||||
} else {
|
||||
components.append(word)
|
||||
}
|
||||
} else {
|
||||
components.append(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
lhs.filename == rhs.filename && lhs.location == rhs.location
|
||||
}
|
||||
}
|
||||
|
||||
public class Token: Equatable {
|
||||
public enum Kind: Equatable {
|
||||
/// A token representing a piece of text.
|
||||
case text
|
||||
/// A token representing a variable.
|
||||
case variable
|
||||
/// A token representing a comment.
|
||||
case comment
|
||||
/// A token representing a template block.
|
||||
case block
|
||||
}
|
||||
|
||||
public let contents: String
|
||||
public let kind: Kind
|
||||
public let sourceMap: SourceMap
|
||||
|
||||
/// Returns the underlying value as an array seperated by spaces
|
||||
public private(set) lazy var components: [String] = self.contents.smartSplit()
|
||||
|
||||
init(contents: String, kind: Kind, sourceMap: SourceMap) {
|
||||
self.contents = contents
|
||||
self.kind = kind
|
||||
self.sourceMap = sourceMap
|
||||
}
|
||||
|
||||
/// A token representing a piece of text.
|
||||
public static func text(value: String, at sourceMap: SourceMap) -> Token {
|
||||
Token(contents: value, kind: .text, sourceMap: sourceMap)
|
||||
}
|
||||
|
||||
/// A token representing a variable.
|
||||
public static func variable(value: String, at sourceMap: SourceMap) -> Token {
|
||||
Token(contents: value, kind: .variable, sourceMap: sourceMap)
|
||||
}
|
||||
|
||||
/// A token representing a comment.
|
||||
public static func comment(value: String, at sourceMap: SourceMap) -> Token {
|
||||
Token(contents: value, kind: .comment, sourceMap: sourceMap)
|
||||
}
|
||||
|
||||
/// A token representing a template block.
|
||||
public static func block(value: String, at sourceMap: SourceMap) -> Token {
|
||||
Token(contents: value, kind: .block, sourceMap: sourceMap)
|
||||
}
|
||||
|
||||
public static func == (lhs: Token, rhs: Token) -> Bool {
|
||||
lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap
|
||||
}
|
||||
}
|
||||
283
Sources/Stencil/Variable.swift
Normal file
283
Sources/Stencil/Variable.swift
Normal file
@@ -0,0 +1,283 @@
|
||||
import Foundation
|
||||
|
||||
typealias Number = Float
|
||||
|
||||
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: " ") }
|
||||
if bits.isEmpty {
|
||||
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
|
||||
}
|
||||
|
||||
variable = Variable(bits[0])
|
||||
let filterBits = bits[bits.indices.suffix(from: 1)]
|
||||
|
||||
do {
|
||||
filters = try filterBits.map { bit in
|
||||
let (name, arguments) = parseFilterComponents(token: bit)
|
||||
let filter = try environment.findFilter(name)
|
||||
return (filter, arguments)
|
||||
}
|
||||
} catch {
|
||||
filters = []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func resolve(_ context: Context) throws -> Any? {
|
||||
let result = try variable.resolve(context)
|
||||
|
||||
return try filters.reduce(result) { value, filter in
|
||||
let arguments = try filter.1.map { try $0.resolve(context) }
|
||||
return try filter.0.invoke(value: value, arguments: arguments, context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure used to represent a template variable, and to resolve it in a given context.
|
||||
public struct Variable: Equatable, Resolvable {
|
||||
public let variable: String
|
||||
|
||||
/// Create a variable with a string representing the variable
|
||||
public init(_ variable: String) {
|
||||
self.variable = variable
|
||||
}
|
||||
|
||||
/// Resolve the variable in the given context
|
||||
public func resolve(_ context: Context) throws -> Any? {
|
||||
if variable.count > 1 &&
|
||||
((variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\""))) {
|
||||
// String literal
|
||||
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
|
||||
}
|
||||
|
||||
// Number literal
|
||||
if let int = Int(variable) {
|
||||
return int
|
||||
}
|
||||
if let number = Number(variable) {
|
||||
return number
|
||||
}
|
||||
// Boolean literal
|
||||
if let bool = Bool(variable) {
|
||||
return bool
|
||||
}
|
||||
|
||||
var current: Any? = context
|
||||
for bit in try lookup(context) {
|
||||
current = resolve(bit: bit, context: current)
|
||||
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if let resolvable = current as? Resolvable {
|
||||
current = try resolvable.resolve(context)
|
||||
} else if let node = current as? NodeType {
|
||||
current = try node.render(context)
|
||||
}
|
||||
|
||||
return normalize(current)
|
||||
}
|
||||
|
||||
// Split the lookup string and resolve references if possible
|
||||
private func lookup(_ context: Context) throws -> [String] {
|
||||
let keyPath = KeyPath(variable, in: context)
|
||||
return try keyPath.parse()
|
||||
}
|
||||
|
||||
// Try to resolve a partial keypath for the given context
|
||||
private func resolve(bit: String, context: Any?) -> Any? {
|
||||
let context = normalize(context)
|
||||
|
||||
if let context = context as? Context {
|
||||
return context[bit]
|
||||
} else if let dictionary = context as? [String: Any] {
|
||||
return resolve(bit: bit, dictionary: dictionary)
|
||||
} else if let array = context as? [Any] {
|
||||
return resolve(bit: bit, collection: array)
|
||||
} else if let string = context as? String {
|
||||
return resolve(bit: bit, collection: string)
|
||||
} else if let object = context as? NSObject { // NSKeyValueCoding
|
||||
#if os(Linux)
|
||||
return nil
|
||||
#else
|
||||
if object.responds(to: Selector(bit)) {
|
||||
return object.value(forKey: bit)
|
||||
}
|
||||
#endif
|
||||
} else if let value = context as? DynamicMemberLookup {
|
||||
return value[dynamicMember: bit]
|
||||
} else if let value = context {
|
||||
return Mirror(reflecting: value).getValue(for: bit)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to resolve a partial keypath for the given dictionary
|
||||
private func resolve(bit: String, dictionary: [String: Any]) -> Any? {
|
||||
if bit == "count" {
|
||||
return dictionary.count
|
||||
} else {
|
||||
return dictionary[bit]
|
||||
}
|
||||
}
|
||||
|
||||
// Try to resolve a partial keypath for the given collection
|
||||
private func resolve<T: Collection>(bit: String, collection: T) -> 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`.
|
||||
/// 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
|
||||
// swiftlint:disable:next identifier_name
|
||||
public let to: Resolvable
|
||||
|
||||
public init?(_ token: String, environment: Environment) throws {
|
||||
let components = token.components(separatedBy: "...")
|
||||
guard components.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.from = try environment.compileFilter(components[0])
|
||||
self.to = try environment.compileFilter(components[1])
|
||||
}
|
||||
|
||||
public init?(_ token: String, environment: Environment, containedIn containingToken: Token) throws {
|
||||
let components = token.components(separatedBy: "...")
|
||||
guard components.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.from = try environment.compileFilter(components[0], containedIn: containingToken)
|
||||
self.to = try environment.compileFilter(components[1], containedIn: containingToken)
|
||||
}
|
||||
|
||||
public func resolve(_ context: Context) throws -> Any? {
|
||||
let lowerResolved = try from.resolve(context)
|
||||
let upperResolved = try to.resolve(context)
|
||||
|
||||
guard let lower = lowerResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||
throw TemplateSyntaxError("'from' value is not an Integer (\(lowerResolved ?? "nil"))")
|
||||
}
|
||||
|
||||
guard let upper = upperResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||
throw TemplateSyntaxError("'to' value is not an Integer (\(upperResolved ?? "nil") )")
|
||||
}
|
||||
|
||||
let range = min(lower, upper)...max(lower, upper)
|
||||
return lower > upper ? Array(range.reversed()) : Array(range)
|
||||
}
|
||||
}
|
||||
|
||||
func normalize(_ current: Any?) -> Any? {
|
||||
if let current = current as? Normalizable {
|
||||
return current.normalize()
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
protocol Normalizable {
|
||||
func normalize() -> Any?
|
||||
}
|
||||
|
||||
extension Array: Normalizable {
|
||||
func normalize() -> Any? {
|
||||
map { $0 as Any }
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next legacy_objc_type
|
||||
extension NSArray: Normalizable {
|
||||
func normalize() -> Any? {
|
||||
map { $0 as Any }
|
||||
}
|
||||
}
|
||||
|
||||
extension Dictionary: Normalizable {
|
||||
func normalize() -> Any? {
|
||||
var dictionary: [String: Any] = [:]
|
||||
|
||||
for (key, value) in self {
|
||||
if let key = key as? String {
|
||||
dictionary[key] = Stencil.normalize(value)
|
||||
} else if let key = key as? CustomStringConvertible {
|
||||
dictionary[key.description] = Stencil.normalize(value)
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary
|
||||
}
|
||||
}
|
||||
|
||||
func parseFilterComponents(token: String) -> (String, [Variable]) {
|
||||
var components = token.smartSplit(separator: ":")
|
||||
let name = components.removeFirst().trim(character: " ")
|
||||
let variables = components
|
||||
.joined(separator: ":")
|
||||
.smartSplit(separator: ",")
|
||||
.map { Variable($0.trim(character: " ")) }
|
||||
return (name, variables)
|
||||
}
|
||||
|
||||
extension Mirror {
|
||||
func getValue(for key: String) -> Any? {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user