diff --git a/CHANGELOG.md b/CHANGELOG.md index d84c9b5..6c8662c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,11 @@ [David Jennes](https://github.com/djbe) [#215](https://github.com/stencilproject/Stencil/pull/215) - Adds support for using spaces in filter expression. - [Ilya Puchka](https://github.com/yonaskolb) + [Ilya Puchka](https://github.com/ilyapuchka) [#178](https://github.com/stencilproject/Stencil/pull/178) +- Improvements in error reporting. + [Ilya Puchka](https://github.com/ilyapuchka) + [#167](https://github.com/stencilproject/Stencil/pull/167) ### Bug Fixes diff --git a/Sources/Context.swift b/Sources/Context.swift index 60bec17..157f230 100644 --- a/Sources/Context.swift +++ b/Sources/Context.swift @@ -3,7 +3,7 @@ public class Context { var dictionaries: [[String: Any?]] public let environment: Environment - + init(dictionary: [String: Any]? = nil, environment: Environment? = nil) { if let dictionary = dictionary { dictionaries = [dictionary] diff --git a/Sources/Environment.swift b/Sources/Environment.swift index 6b78fec..2778a5d 100644 --- a/Sources/Environment.swift +++ b/Sources/Environment.swift @@ -4,7 +4,10 @@ public struct Environment { public var loader: Loader? - public init(loader: Loader? = nil, extensions: [Extension]? = nil, templateClass: Template.Type = Template.self) { + public init(loader: Loader? = nil, + extensions: [Extension]? = nil, + templateClass: Template.Type = Template.self) { + self.templateClass = templateClass self.loader = loader self.extensions = (extensions ?? []) + [DefaultExtension()] @@ -28,11 +31,18 @@ public struct Environment { public func renderTemplate(name: String, context: [String: Any]? = nil) throws -> String { let template = try loadTemplate(name: name) - return try template.render(context) + return try render(template: template, context: context) } public func renderTemplate(string: String, context: [String: Any]? = nil) throws -> String { let template = templateClass.init(templateString: string, environment: self) + return try render(template: template, context: context) + } + + func render(template: Template, context: [String: Any]?) throws -> String { + // update template environment as it can be created from string literal with default environment + template.environment = self return try template.render(context) } + } diff --git a/Sources/Errors.swift b/Sources/Errors.swift index 964eeae..407a9e2 100644 --- a/Sources/Errors.swift +++ b/Sources/Errors.swift @@ -17,3 +17,67 @@ public class TemplateDoesNotExist: Error, CustomStringConvertible { return "Template named `\(templates)` does not exist. No loaders found" } } + +public struct TemplateSyntaxError : Error, Equatable, CustomStringConvertible { + public let reason: String + public var description: String { return reason } + public internal(set) var token: Token? + public internal(set) var stackTrace: [Token] + public var templateName: String? { return token?.sourceMap.filename } + var allTokens: [Token] { + return stackTrace + (token.map({ [$0] }) ?? []) + } + + public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) { + self.reason = reason + self.stackTrace = stackTrace + self.token = token + } + + public init(_ description: String) { + self.init(reason: description) + } + + public static func ==(lhs:TemplateSyntaxError, rhs:TemplateSyntaxError) -> Bool { + return lhs.description == rhs.description && lhs.token == rhs.token && lhs.stackTrace == rhs.stackTrace + } + +} + +extension Error { + func withToken(_ token: Token?) -> Error { + if var error = self as? TemplateSyntaxError { + error.token = error.token ?? token + return error + } else { + return TemplateSyntaxError(reason: "\(self)", token: token) + } + } +} + +public protocol ErrorReporter: class { + func renderError(_ error: Error) -> String +} + +open class SimpleErrorReporter: ErrorReporter { + + open func renderError(_ error: Error) -> String { + guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription } + + func describe(token: Token) -> String { + let templateName = token.sourceMap.filename ?? "" + let line = token.sourceMap.line + let highlight = "\(String(Array(repeating: " ", count: line.offset)))^\(String(Array(repeating: "~", count: max(token.contents.characters.count - 1, 0))))" + + return "\(templateName)\(line.number):\(line.offset): error: \(templateError.reason)\n" + + "\(line.content)\n" + + "\(highlight)\n" + } + + var descriptions = templateError.stackTrace.reduce([]) { $0 + [describe(token: $1)] } + let description = templateError.token.map(describe(token:)) ?? templateError.reason + descriptions.append(description) + return descriptions.joined(separator: "\n") + } + +} diff --git a/Sources/Expression.swift b/Sources/Expression.swift index c7199fc..e329e5a 100644 --- a/Sources/Expression.swift +++ b/Sources/Expression.swift @@ -88,21 +88,21 @@ final class NotExpression: Expression, PrefixOperator, CustomStringConvertible { 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 { return "(\(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 { @@ -115,10 +115,10 @@ final class InExpression: Expression, InfixOperator, CustomStringConvertible { return true } } - + return false } - + } final class OrExpression: Expression, InfixOperator, CustomStringConvertible { diff --git a/Sources/Extension.swift b/Sources/Extension.swift index 33a9925..1203378 100644 --- a/Sources/Extension.swift +++ b/Sources/Extension.swift @@ -15,7 +15,7 @@ open class Extension { /// Registers a simple template tag with a name and a handler public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) { registerTag(name, parser: { parser, token in - return SimpleNode(handler: handler) + return SimpleNode(token: token, handler: handler) }) } @@ -42,9 +42,9 @@ class DefaultExtension: Extension { 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 + #if !os(Linux) + registerTag("now", parser: NowNode.parse) + #endif registerTag("include", parser: IncludeNode.parse) registerTag("extends", parser: ExtendsNode.parse) registerTag("block", parser: BlockNode.parse) diff --git a/Sources/FilterTag.swift b/Sources/FilterTag.swift index 63ce321..4cf9746 100644 --- a/Sources/FilterTag.swift +++ b/Sources/FilterTag.swift @@ -1,6 +1,7 @@ class FilterNode : NodeType { let resolvable: Resolvable let nodes: [NodeType] + let token: Token? class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { let bits = token.components() @@ -15,20 +16,21 @@ class FilterNode : NodeType { throw TemplateSyntaxError("`endfilter` was not found.") } - let resolvable = try parser.compileFilter("filter_value|\(bits[1])") - return FilterNode(nodes: blocks, resolvable: resolvable) + let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token) + return FilterNode(nodes: blocks, resolvable: resolvable, token: token) } - init(nodes: [NodeType], resolvable: Resolvable) { + init(nodes: [NodeType], resolvable: Resolvable, token: Token) { self.nodes = nodes self.resolvable = resolvable + self.token = token } func render(_ context: Context) throws -> String { let value = try renderNodes(nodes, context) return try context.push(dictionary: ["filter_value": value]) { - return try VariableNode(variable: resolvable).render(context) + return try VariableNode(variable: resolvable, token: token).render(context) } } } diff --git a/Sources/ForTag.swift b/Sources/ForTag.swift index 4d7eff2..284498f 100644 --- a/Sources/ForTag.swift +++ b/Sources/ForTag.swift @@ -6,6 +6,7 @@ class ForNode : NodeType { let nodes:[NodeType] let emptyNodes: [NodeType] let `where`: Expression? + let token: Token? class func parse(_ parser:TokenParser, token:Token) throws -> NodeType { let components = token.components() @@ -13,12 +14,13 @@ class ForNode : NodeType { func hasToken(_ token: String, at index: Int) -> Bool { return components.count > (index + 1) && components[index] == token } + func endsOrHasToken(_ token: String, at index: Int) -> Bool { return components.count == index || hasToken(token, at: index) } guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else { - throw TemplateSyntaxError("'for' statements should use the syntax: `for in [where ]") + throw TemplateSyntaxError("'for' statements should use the syntax: `for in [where ]`.") } let loopVariables = components[1].characters @@ -26,7 +28,11 @@ class ForNode : NodeType { .map(String.init) .map { $0.trim(character: " ") } - var emptyNodes = [NodeType]() + let resolvable = try parser.compileResolvable(components[3], containedIn: token) + + let `where` = hasToken("where", at: 4) + ? try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, token: token) + : nil let forNodes = try parser.parse(until(["endfor", "empty"])) @@ -34,26 +40,22 @@ class ForNode : NodeType { throw TemplateSyntaxError("`endfor` was not found.") } + var emptyNodes = [NodeType]() if token.contents == "empty" { emptyNodes = try parser.parse(until(["endfor"])) _ = parser.nextToken() } - let resolvable = try parser.compileResolvable(components[3]) - - let `where` = hasToken("where", at: 4) - ? try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser) - : nil - - return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`) + return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes: emptyNodes, where: `where`, token: token) } - init(resolvable: Resolvable, loopVariables: [String], nodes:[NodeType], emptyNodes:[NodeType], where: Expression? = nil) { + init(resolvable: Resolvable, loopVariables: [String], nodes: [NodeType], emptyNodes: [NodeType], where: Expression? = nil, token: Token? = nil) { self.resolvable = resolvable self.loopVariables = loopVariables self.nodes = nodes self.emptyNodes = emptyNodes self.where = `where` + self.token = token } func push(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result { @@ -143,7 +145,7 @@ class ForNode : NodeType { try renderNodes(nodes, context) } } - }.joined(separator: "") + }.joined(separator: "") } return try context.push { diff --git a/Sources/IfTag.swift b/Sources/IfTag.swift index a857d3e..e014812 100644 --- a/Sources/IfTag.swift +++ b/Sources/IfTag.swift @@ -53,7 +53,7 @@ enum IfToken { case .variable(_): return 0 case .end: - return 0 + return 0 } } @@ -100,7 +100,7 @@ final class IfExpressionParser { let tokens: [IfToken] var position: Int = 0 - init(components: [String], tokenParser: TokenParser) throws { + init(components: [String], tokenParser: TokenParser, token: Token) throws { self.tokens = try components.map { component in if let op = findOperator(name: component) { switch op { @@ -111,7 +111,7 @@ final class IfExpressionParser { } } - return .variable(try tokenParser.compileResolvable(component)) + return .variable(try tokenParser.compileResolvable(component, containedIn: token)) } } @@ -155,8 +155,8 @@ final class IfExpressionParser { } -func parseExpression(components: [String], tokenParser: TokenParser) throws -> Expression { - let parser = try IfExpressionParser(components: components, tokenParser: tokenParser) +func parseExpression(components: [String], tokenParser: TokenParser, token: Token) throws -> Expression { + let parser = try IfExpressionParser(components: components, tokenParser: tokenParser, token: token) return try parser.parse() } @@ -182,49 +182,51 @@ final class IfCondition { class IfNode : NodeType { let conditions: [IfCondition] + let token: Token? class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { var components = token.components() components.removeFirst() - let expression = try parseExpression(components: components, tokenParser: parser) + let expression = try parseExpression(components: components, tokenParser: parser, token: token) let nodes = try parser.parse(until(["endif", "elif", "else"])) var conditions: [IfCondition] = [ IfCondition(expression: expression, nodes: nodes) ] - var token = parser.nextToken() - while let current = token, current.contents.hasPrefix("elif") { + var nextToken = parser.nextToken() + while let current = nextToken, current.contents.hasPrefix("elif") { var components = current.components() components.removeFirst() - let expression = try parseExpression(components: components, tokenParser: parser) + let expression = try parseExpression(components: components, tokenParser: parser, token: current) let nodes = try parser.parse(until(["endif", "elif", "else"])) - token = parser.nextToken() + nextToken = parser.nextToken() conditions.append(IfCondition(expression: expression, nodes: nodes)) } - if let current = token, current.contents == "else" { + if let current = nextToken, current.contents == "else" { conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"])))) - token = parser.nextToken() + nextToken = parser.nextToken() } - guard let current = token, current.contents == "endif" else { + guard let current = nextToken, current.contents == "endif" else { throw TemplateSyntaxError("`endif` was not found.") } - return IfNode(conditions: conditions) + return IfNode(conditions: conditions, token: token) } class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType { var components = token.components() guard components.count == 2 else { - throw TemplateSyntaxError("'ifnot' statements should use the following 'ifnot condition' `\(token.contents)`.") + throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.") } components.removeFirst() var trueNodes = [NodeType]() var falseNodes = [NodeType]() + let expression = try parseExpression(components: components, tokenParser: parser, token: token) falseNodes = try parser.parse(until(["endif", "else"])) guard let token = parser.nextToken() else { @@ -236,15 +238,15 @@ class IfNode : NodeType { _ = parser.nextToken() } - let expression = try parseExpression(components: components, tokenParser: parser) return IfNode(conditions: [ IfCondition(expression: expression, nodes: trueNodes), IfCondition(expression: nil, nodes: falseNodes), - ]) + ], token: token) } - init(conditions: [IfCondition]) { + init(conditions: [IfCondition], token: Token? = nil) { self.conditions = conditions + self.token = token } func render(_ context: Context) throws -> String { diff --git a/Sources/Include.swift b/Sources/Include.swift index 560bb74..6dd331c 100644 --- a/Sources/Include.swift +++ b/Sources/Include.swift @@ -4,6 +4,7 @@ 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() @@ -12,12 +13,13 @@ class IncludeNode : NodeType { throw TemplateSyntaxError("'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file") } - return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil) + return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token) } - init(templateName: Variable, includeContext: String? = nil) { + init(templateName: Variable, includeContext: String? = nil, token: Token) { self.templateName = templateName self.includeContext = includeContext + self.token = token } func render(_ context: Context) throws -> String { @@ -27,9 +29,17 @@ class IncludeNode : NodeType { let template = try context.environment.loadTemplate(name: templateName) - let subContext = includeContext.flatMap { context[$0] as? [String: Any] } - return try context.push(dictionary: subContext) { - return try template.render(context) + do { + let subContext = includeContext.flatMap { context[$0] as? [String: Any] } + return try context.push(dictionary: subContext) { + return try template.render(context) + } + } catch { + if let error = error as? TemplateSyntaxError { + throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens) + } else { + throw error + } } } } diff --git a/Sources/Inheritence.swift b/Sources/Inheritence.swift index b9bf87a..db2d67f 100644 --- a/Sources/Inheritence.swift +++ b/Sources/Inheritence.swift @@ -1,13 +1,12 @@ class BlockContext { class var contextKey: String { return "block_context" } + // contains mapping of block names to their nodes and templates where they are defined var blocks: [String: [BlockNode]] init(blocks: [String: BlockNode]) { self.blocks = [:] - blocks.forEach { (key, value) in - self.blocks[key] = [value] - } + blocks.forEach { self.blocks[$0.key] = [$0.value] } } func push(_ block: BlockNode, forKey blockName: String) { @@ -18,7 +17,7 @@ class BlockContext { self.blocks[blockName] = [block] } } - + func pop(_ blockName: String) -> BlockNode? { if var blocks = blocks[blockName] { let block = blocks.removeFirst() @@ -51,6 +50,7 @@ extension Collection { class ExtendsNode : NodeType { let templateName: Variable let blocks: [String:BlockNode] + let token: Token? class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { let bits = token.components() @@ -72,12 +72,13 @@ class ExtendsNode : NodeType { return dict } - return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes) + return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token) } - init(templateName: Variable, blocks: [String: BlockNode]) { + init(templateName: Variable, blocks: [String: BlockNode], token: Token) { self.templateName = templateName self.blocks = blocks + self.token = token } func render(_ context: Context) throws -> String { @@ -85,21 +86,33 @@ class ExtendsNode : NodeType { throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string") } - let template = try context.environment.loadTemplate(name: templateName) + let baseTemplate = try context.environment.loadTemplate(name: templateName) let blockContext: BlockContext - if let context = context[BlockContext.contextKey] as? BlockContext { - blockContext = context - - for (key, value) in blocks { - blockContext.push(value, forKey: key) + if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext { + blockContext = currentBlockContext + for (name, block) in blocks { + blockContext.push(block, forKey: name) } } else { blockContext = BlockContext(blocks: blocks) } - return try context.push(dictionary: [BlockContext.contextKey: blockContext]) { - return try template.render(context) + do { + // pushes base template and renders it's content + // block_context contains all blocks from child templates + return try context.push(dictionary: [BlockContext.contextKey: blockContext]) { + return try baseTemplate.render(context) + } + } catch { + // if error template is already set (see catch in BlockNode) + // and it happend in the same template as current template + // there is no need to wrap it in another error + if let error = error as? TemplateSyntaxError, error.templateName != token?.sourceMap.filename { + throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens) + } else { + throw error + } } } } @@ -108,6 +121,7 @@ class ExtendsNode : NodeType { class BlockNode : NodeType { let name: String let nodes: [NodeType] + let token: Token? class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { let bits = token.components() @@ -119,25 +133,57 @@ class BlockNode : NodeType { let blockName = bits[1] let nodes = try parser.parse(until(["endblock"])) _ = parser.nextToken() - return BlockNode(name:blockName, nodes:nodes) + return BlockNode(name:blockName, nodes:nodes, token: token) } - init(name: String, nodes: [NodeType]) { + init(name: String, nodes: [NodeType], token: Token) { self.name = name self.nodes = nodes + self.token = token } func render(_ context: Context) throws -> String { - if let blockContext = context[BlockContext.contextKey] as? BlockContext, let node = blockContext.pop(name) { - let newContext: [String: Any] = [ - BlockContext.contextKey: blockContext, - "block": ["super": try self.render(context)] - ] - return try context.push(dictionary: newContext) { - return try node.render(context) + if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) { + let childContext = try self.childContext(child, blockContext: blockContext, context: context) + // render extension node + do { + return try context.push(dictionary: childContext) { + return try child.render(context) + } + } catch { + throw error.withToken(child.token) } } return try renderNodes(nodes, context) } + + // child node is a block node from child template that extends this node (has the same name) + func childContext(_ child: BlockNode, blockContext: BlockContext, context: Context) throws -> [String: Any?] { + var childContext: [String: Any?] = [BlockContext.contextKey: blockContext] + + if let blockSuperNode = child.nodes.first(where: { + if case .variable(let variable, _)? = $0.token, variable == "block.super" { return true } + else { return false} + }) { + do { + // render base node so that its content can be used as part of child node that extends it + childContext["block"] = ["super": try self.render(context)] + } catch { + if let error = error as? TemplateSyntaxError { + throw TemplateSyntaxError( + reason: error.reason, + token: blockSuperNode.token, + stackTrace: error.allTokens) + } else { + throw TemplateSyntaxError( + reason: "\(error)", + token: blockSuperNode.token, + stackTrace: []) + } + } + } + return childContext + } + } diff --git a/Sources/Lexer.swift b/Sources/Lexer.swift index b221775..ec833d5 100644 --- a/Sources/Lexer.swift +++ b/Sources/Lexer.swift @@ -1,11 +1,15 @@ +import Foundation + struct Lexer { + let templateName: String? let templateString: String - init(templateString: String) { + init(templateName: String? = nil, templateString: String) { + self.templateName = templateName self.templateString = templateString } - func createToken(string: String) -> Token { + func createToken(string: String, at range: Range) -> Token { func strip() -> String { guard string.characters.count > 4 else { return "" } let start = string.index(string.startIndex, offsetBy: 2) @@ -18,15 +22,24 @@ struct Lexer { return trimmed } - if string.hasPrefix("{{") { - return .variable(value: strip()) - } else if string.hasPrefix("{%") { - return .block(value: strip()) - } else if string.hasPrefix("{#") { - return .comment(value: strip()) + if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") { + let value = strip() + let range = templateString.range(of: value, range: range) ?? range + let line = templateString.rangeLine(range) + let sourceMap = SourceMap(filename: templateName, line: line) + + 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) + } } - return .text(value: string) + let line = templateString.rangeLine(range) + let sourceMap = SourceMap(filename: templateName, line: line) + return .text(value: string, at: sourceMap) } /// Returns an array of tokens from a given template string. @@ -39,33 +52,37 @@ struct Lexer { "{{": "}}", "{%": "%}", "{#": "#}", - ] + ] while !scanner.isEmpty { if let text = scanner.scan(until: ["{{", "{%", "{#"]) { if !text.1.isEmpty { - tokens.append(createToken(string: text.1)) + tokens.append(createToken(string: text.1, at: scanner.range)) } let end = map[text.0]! let result = scanner.scan(until: end, returnUntil: true) - tokens.append(createToken(string: result)) + tokens.append(createToken(string: result, at: scanner.range)) } else { - tokens.append(createToken(string: scanner.content)) + tokens.append(createToken(string: scanner.content, at: scanner.range)) scanner.content = "" } } return tokens } + } - class Scanner { + let originalContent: String var content: String + var range: Range init(_ content: String) { + self.originalContent = content self.content = content + range = content.startIndex.. String { + var index = content.startIndex + if until.isEmpty { return "" } - var index = content.startIndex + range = range.upperBound..) -> RangeLine { + var lineNumber: UInt = 0 + var offset: Int = 0 + var lineContent = "" + + for line in components(separatedBy: CharacterSet.newlines) { + lineNumber += 1 + lineContent = line + if let rangeOfLine = self.range(of: line), rangeOfLine.contains(range.lowerBound) { + offset = distance(from: rangeOfLine.lowerBound, to: range.lowerBound) + break + } + } + + return (lineContent, lineNumber, offset) + } } + +public typealias RangeLine = (content: String, number: UInt, offset: Int) diff --git a/Sources/Node.swift b/Sources/Node.swift index 36208a3..c4bb77a 100644 --- a/Sources/Node.swift +++ b/Sources/Node.swift @@ -1,35 +1,31 @@ import Foundation - -public struct TemplateSyntaxError : Error, Equatable, CustomStringConvertible { - public let description:String - - public init(_ description:String) { - self.description = description - } -} - - -public func ==(lhs:TemplateSyntaxError, rhs:TemplateSyntaxError) -> Bool { - return lhs.description == rhs.description -} - - public protocol NodeType { /// Render the node in the given context func render(_ context:Context) throws -> String + + /// Reference to this node's token + var token: Token? { get } } /// Render the collection of nodes in the given context public func renderNodes(_ nodes:[NodeType], _ context:Context) throws -> String { - return try nodes.map { try $0.render(context) }.joined(separator: "") + return try nodes.map { + do { + return try $0.render(context) + } catch { + throw error.withToken($0.token) + } + }.joined(separator: "") } public class SimpleNode : NodeType { public let handler:(Context) throws -> String + public let token: Token? - public init(handler: @escaping (Context) throws -> String) { + public init(token: Token, handler: @escaping (Context) throws -> String) { + self.token = token self.handler = handler } @@ -41,9 +37,11 @@ public class SimpleNode : NodeType { public class TextNode : NodeType { public let text:String + public let token: Token? public init(text:String) { self.text = text + self.token = nil } public func render(_ context:Context) throws -> String { @@ -59,13 +57,16 @@ public protocol Resolvable { public class VariableNode : NodeType { public let variable: Resolvable + public var token: Token? - public init(variable: Resolvable) { + public init(variable: Resolvable, token: Token? = nil) { self.variable = variable + self.token = token } - public init(variable: String) { + public init(variable: String, token: Token? = nil) { self.variable = Variable(variable) + self.token = token } public func render(_ context: Context) throws -> String { diff --git a/Sources/NowTag.swift b/Sources/NowTag.swift index cd6e4ea..6d354ed 100644 --- a/Sources/NowTag.swift +++ b/Sources/NowTag.swift @@ -4,23 +4,25 @@ import Foundation class NowNode : NodeType { let format:Variable + let token: Token? class func parse(_ parser:TokenParser, token:Token) throws -> NodeType { var format:Variable? let components = token.components() guard components.count <= 2 else { - throw TemplateSyntaxError("'now' tags may only have one argument: the format string `\(token.contents)`.") + throw TemplateSyntaxError("'now' tags may only have one argument: the format string.") } if components.count == 2 { format = Variable(components[1]) } - return NowNode(format:format) + return NowNode(format:format, token: token) } - init(format:Variable?) { + init(format:Variable?, token: Token? = nil) { self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"") + self.token = token } func render(_ context: Context) throws -> String { diff --git a/Sources/Parser.swift b/Sources/Parser.swift index 81a44e1..81d9335 100644 --- a/Sources/Parser.swift +++ b/Sources/Parser.swift @@ -37,10 +37,11 @@ public class TokenParser { let token = nextToken()! switch token { - case .text(let text): + case .text(let text, _): nodes.append(TextNode(text: text)) case .variable: - nodes.append(VariableNode(variable: try compileResolvable(token.contents))) + let filter = try compileResolvable(token.contents, containedIn: token) + nodes.append(VariableNode(variable: filter, token: token)) case .block: if let parse_until = parse_until , parse_until(self, token) { prependToken(token) @@ -48,8 +49,13 @@ public class TokenParser { } if let tag = token.components().first { - let parser = try findTag(name: tag) - nodes.append(try parser(self, token)) + do { + let parser = try findTag(name: tag) + let node = try parser(self, token) + nodes.append(node) + } catch { + throw error.withToken(token) + } } case .comment: continue @@ -92,7 +98,7 @@ public class TokenParser { if suggestedFilters.isEmpty { throw TemplateSyntaxError("Unknown filter '\(name)'.") } else { - throw TemplateSyntaxError("Unknown filter '\(name)'. Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", "))") + throw TemplateSyntaxError("Unknown filter '\(name)'. Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", ")).") } } @@ -110,15 +116,41 @@ public class TokenParser { return filtersWithDistance.filter({ $0.distance == minDistance }).map({ $0.filterName }) } + public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable { + do { + return try FilterExpression(token: filterToken, parser: self) + } catch { + guard var syntaxError = error as? TemplateSyntaxError, syntaxError.token == nil else { + throw error + } + // find offset of filter in the containing token so that only filter is highligted, not the whole token + if let filterTokenRange = containingToken.contents.range(of: filterToken) { + var rangeLine = containingToken.sourceMap.line + rangeLine.offset += containingToken.contents.distance(from: containingToken.contents.startIndex, to: filterTokenRange.lowerBound) + syntaxError.token = .variable(value: filterToken, at: SourceMap(filename: containingToken.sourceMap.filename, line: rangeLine)) + } else { + syntaxError.token = containingToken + } + throw syntaxError + } + } + + @available(*, deprecated, message: "Use compileFilter(_:containedIn:)") public func compileFilter(_ token: String) throws -> Resolvable { return try FilterExpression(token: token, parser: self) } + @available(*, deprecated, message: "Use compileResolvable(_:containedIn:)") public func compileResolvable(_ token: String) throws -> Resolvable { return try RangeVariable(token, parser: self) ?? compileFilter(token) } + public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable { + return try RangeVariable(token, parser: self, containedIn: containingToken) + ?? compileFilter(token, containedIn: containingToken) + } + } // https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows diff --git a/Sources/Template.swift b/Sources/Template.swift index d9b1893..0bf1c78 100644 --- a/Sources/Template.swift +++ b/Sources/Template.swift @@ -7,7 +7,8 @@ let NSFileNoSuchFileError = 4 /// A class representing a template open class Template: ExpressibleByStringLiteral { - let environment: Environment + let templateString: String + internal(set) var environment: Environment let tokens: [Token] /// The name of the loaded Template if the Template was loaded from a Loader @@ -17,8 +18,9 @@ open class Template: ExpressibleByStringLiteral { public required init(templateString: String, environment: Environment? = nil, name: String? = nil) { self.environment = environment ?? Environment() self.name = name + self.templateString = templateString - let lexer = Lexer(templateString: templateString) + let lexer = Lexer(templateName: name, templateString: templateString) tokens = lexer.tokenize() } diff --git a/Sources/Tokenizer.swift b/Sources/Tokenizer.swift index bb3320b..a243f80 100644 --- a/Sources/Tokenizer.swift +++ b/Sources/Tokenizer.swift @@ -55,59 +55,79 @@ extension String { } } +public struct SourceMap: Equatable { + public let filename: String? + public let line: RangeLine + + init(filename: String? = nil, line: RangeLine = ("", 0, 0)) { + self.filename = filename + self.line = line + } + + static let unknown = SourceMap() + + public static func ==(lhs: SourceMap, rhs: SourceMap) -> Bool { + return lhs.filename == rhs.filename && lhs.line == rhs.line + } +} public enum Token : Equatable { /// A token representing a piece of text. - case text(value: String) + case text(value: String, at: SourceMap) /// A token representing a variable. - case variable(value: String) + case variable(value: String, at: SourceMap) /// A token representing a comment. - case comment(value: String) + case comment(value: String, at: SourceMap) /// A token representing a template block. - case block(value: String) + case block(value: String, at: SourceMap) /// Returns the underlying value as an array seperated by spaces public func components() -> [String] { switch self { - case .block(let value): - return value.smartSplit() - case .variable(let value): - return value.smartSplit() - case .text(let value): - return value.smartSplit() - case .comment(let value): + case .block(let value, _), + .variable(let value, _), + .text(let value, _), + .comment(let value, _): return value.smartSplit() } } public var contents: String { switch self { - case .block(let value): - return value - case .variable(let value): - return value - case .text(let value): - return value - case .comment(let value): + case .block(let value, _), + .variable(let value, _), + .text(let value, _), + .comment(let value, _): return value } } + + public var sourceMap: SourceMap { + switch self { + case .block(_, let sourceMap), + .variable(_, let sourceMap), + .text(_, let sourceMap), + .comment(_, let sourceMap): + return sourceMap + } + } + } public func == (lhs: Token, rhs: Token) -> Bool { switch (lhs, rhs) { - case (.text(let lhsValue), .text(let rhsValue)): - return lhsValue == rhsValue - case (.variable(let lhsValue), .variable(let rhsValue)): - return lhsValue == rhsValue - case (.block(let lhsValue), .block(let rhsValue)): - return lhsValue == rhsValue - case (.comment(let lhsValue), .comment(let rhsValue)): - return lhsValue == rhsValue + case let (.text(lhsValue, lhsAt), .text(rhsValue, rhsAt)): + return lhsValue == rhsValue && lhsAt == rhsAt + case let (.variable(lhsValue, lhsAt), .variable(rhsValue, rhsAt)): + return lhsValue == rhsValue && lhsAt == rhsAt + case let (.block(lhsValue, lhsAt), .block(rhsValue, rhsAt)): + return lhsValue == rhsValue && lhsAt == rhsAt + case let (.comment(lhsValue, lhsAt), .comment(rhsValue, rhsAt)): + return lhsValue == rhsValue && lhsAt == rhsAt default: return false } diff --git a/Sources/Variable.swift b/Sources/Variable.swift index 262ccb5..1da7439 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -11,8 +11,6 @@ class FilterExpression : Resolvable { init(token: String, parser: TokenParser) throws { let bits = token.characters.split(separator: "|").map({ String($0).trim(character: " ") }) if bits.isEmpty { - filters = [] - variable = Variable("") throw TemplateSyntaxError("Variable tags must include at least 1 argument") } @@ -52,7 +50,7 @@ public struct Variable : Equatable, Resolvable { // Split the lookup string and resolve references if possible fileprivate func lookup(_ context: Context) throws -> [String] { - var keyPath = KeyPath(variable, in: context) + let keyPath = KeyPath(variable, in: context) return try keyPath.parse() } @@ -103,11 +101,11 @@ public struct Variable : Equatable, Resolvable { current = array.count } } else if let object = current as? NSObject { // NSKeyValueCoding -#if os(Linux) - return nil -#else - current = object.value(forKey: bit) -#endif + #if os(Linux) + return nil + #else + current = object.value(forKey: bit) + #endif } else if let value = current { current = Mirror(reflecting: value).getValue(for: bit) if current == nil { @@ -140,6 +138,7 @@ public struct RangeVariable: Resolvable { public let from: Resolvable public let to: Resolvable + @available(*, deprecated, message: "Use init?(_:parser:containedIn:)") public init?(_ token: String, parser: TokenParser) throws { let components = token.components(separatedBy: "...") guard components.count == 2 else { @@ -150,6 +149,16 @@ public struct RangeVariable: Resolvable { self.to = try parser.compileFilter(components[1]) } + public init?(_ token: String, parser: TokenParser, containedIn containingToken: Token) throws { + let components = token.components(separatedBy: "...") + guard components.count == 2 else { + return nil + } + + self.from = try parser.compileFilter(components[0], containedIn: containingToken) + self.to = try parser.compileFilter(components[1], containedIn: containingToken) + } + public func resolve(_ context: Context) throws -> Any? { let fromResolved = try from.resolve(context) let toResolved = try to.resolve(context) diff --git a/Tests/StencilTests/EnvironmentSpec.swift b/Tests/StencilTests/EnvironmentSpec.swift index 8bb5bfc..aa68c3a 100644 --- a/Tests/StencilTests/EnvironmentSpec.swift +++ b/Tests/StencilTests/EnvironmentSpec.swift @@ -1,10 +1,17 @@ import Spectre -import Stencil +import PathKit +@testable import Stencil func testEnvironment() { describe("Environment") { - let environment = Environment(loader: ExampleLoader()) + var environment: Environment! + var template: Template! + + $0.before { + environment = Environment(loader: ExampleLoader()) + template = nil + } $0.it("can load a template from a name") { let template = try environment.loadTemplate(name: "example.html") @@ -32,9 +39,309 @@ func testEnvironment() { try expect(result) == "here" } + + func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError { + guard let range = template.templateString.range(of: token) else { + fatalError("Can't find '\(token)' in '\(template)'") + } + let rangeLine = template.templateString.rangeLine(range) + let sourceMap = SourceMap(filename: template.name, line: rangeLine) + let token = Token.block(value: token, at: sourceMap) + return TemplateSyntaxError(reason: description, token: token, stackTrace: []) + } + + func expectError(reason: String, token: String, + file: String = #file, line: Int = #line, function: String = #function) throws { + let expectedError = expectedSyntaxError(token: token, template: template, description: reason) + + let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + let reporter = SimpleErrorReporter() + try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError) + } + + $0.context("given syntax error") { + + $0.it("reports syntax error on invalid for tag syntax") { + template = "Hello {% for name in %}{{ name }}, {% endfor %}!" + try expectError(reason: "'for' statements should use the syntax: `for in [where ]`.", token: "for name in") + } + + $0.it("reports syntax error on missing endfor") { + template = "{% for name in names %}{{ name }}" + try expectError(reason: "`endfor` was not found.", token: "for name in names") + } + + $0.it("reports syntax error on unknown tag") { + template = "{% for name in names %}{{ name }}{% end %}" + try expectError(reason: "Unknown template tag 'end'", token: "end") + } + + } + + $0.context("given unknown filter") { + + $0.it("reports syntax error in for tag") { + template = "{% for name in names|unknown %}{{ name }}{% endfor %}" + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "names|unknown") + } + + $0.it("reports syntax error in for-where tag") { + template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}" + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") + } + + $0.it("reports syntax error in if tag") { + template = "{% if name|unknown %}{{ name }}{% endif %}" + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") + } + + $0.it("reports syntax error in elif tag") { + template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}" + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") + } + + $0.it("reports syntax error in ifnot tag") { + template = "{% ifnot name|unknown %}{{ name }}{% endif %}" + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") + } + + $0.it("reports syntax error in filter tag") { + template = "{% filter unknown %}Text{% endfilter %}" + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "filter unknown") + } + + $0.it("reports syntax error in variable tag") { + template = "{{ name|unknown }}" + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown") + } + + } + + $0.context("given rendering error") { + + $0.it("reports rendering error in variable filter") { + let filterExtension = Extension() + filterExtension.registerFilter("throw") { (value: Any?) in + throw TemplateSyntaxError("filter error") + } + environment.extensions += [filterExtension] + + template = Template(templateString: "{{ name|throw }}", environment: environment) + try expectError(reason: "filter error", token: "name|throw") + } + + $0.it("reports rendering error in filter tag") { + let filterExtension = Extension() + filterExtension.registerFilter("throw") { (value: Any?) in + throw TemplateSyntaxError("filter error") + } + environment.extensions += [filterExtension] + + template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: environment) + try expectError(reason: "filter error", token: "filter throw") + } + + $0.it("reports rendering error in simple tag") { + let tagExtension = Extension() + tagExtension.registerSimpleTag("simpletag") { context in + throw TemplateSyntaxError("simpletag error") + } + environment.extensions += [tagExtension] + + template = Template(templateString: "{% simpletag %}", environment: environment) + try expectError(reason: "simpletag error", token: "simpletag") + } + + $0.it("reporsts passing argument to simple filter") { + template = "{{ name|uppercase:5 }}" + try expectError(reason: "cannot invoke filter with an argument", token: "name|uppercase:5") + } + + $0.it("reports rendering error in custom tag") { + let tagExtension = Extension() + tagExtension.registerTag("customtag") { parser, token in + return ErrorNode(token: token) + } + environment.extensions += [tagExtension] + + template = Template(templateString: "{% customtag %}", environment: environment) + try expectError(reason: "Custom Error", token: "customtag") + } + + $0.it("reports rendering error in for body") { + let tagExtension = Extension() + tagExtension.registerTag("customtag") { parser, token in + return ErrorNode(token: token) + } + environment.extensions += [tagExtension] + + template = Template(templateString: "{% for name in names %}{% customtag %}{% endfor %}", environment: environment) + try expectError(reason: "Custom Error", token: "customtag") + } + + $0.it("reports rendering error in block") { + let tagExtension = Extension() + tagExtension.registerTag("customtag") { parser, token in + return ErrorNode(token: token) + } + environment.extensions += [tagExtension] + + template = Template(templateString: "{% block some %}{% customtag %}{% endblock %}", environment: environment) + try expectError(reason: "Custom Error", token: "customtag") + } + } + + $0.context("given included template") { + let path = Path(#file) + ".." + "fixtures" + let loader = FileSystemLoader(paths: [path]) + var environment = Environment(loader: loader) + var template: Template! + var includedTemplate: Template! + + $0.before { + environment = Environment(loader: loader) + template = nil + includedTemplate = nil + } + + func expectError(reason: String, token: String, includedToken: String, + file: String = #file, line: Int = #line, function: String = #function) throws { + var expectedError = expectedSyntaxError(token: token, template: template, description: reason) + expectedError.stackTrace = [expectedSyntaxError(token: includedToken, template: includedTemplate, description: reason).token!] + + let error = try expect(environment.render(template: template, context: ["target": "World"]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + let reporter = SimpleErrorReporter() + try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError) + } + + $0.it("reports syntax error in included template") { + template = Template(templateString: "{% include \"invalid-include.html\" %}", environment: environment) + includedTemplate = try environment.loadTemplate(name: "invalid-include.html") + + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", + token: "include \"invalid-include.html\"", + includedToken: "target|unknown") + } + + $0.it("reports runtime error in included template") { + let filterExtension = Extension() + filterExtension.registerFilter("unknown", filter: { (_: Any?) in + throw TemplateSyntaxError("filter error") + }) + environment.extensions += [filterExtension] + + template = Template(templateString: "{% include \"invalid-include.html\" %}", environment: environment) + includedTemplate = try environment.loadTemplate(name: "invalid-include.html") + + try expectError(reason: "filter error", + token: "include \"invalid-include.html\"", + includedToken: "target|unknown") + } + + } + + $0.context("given base and child templates") { + let path = Path(#file) + ".." + "fixtures" + let loader = FileSystemLoader(paths: [path]) + var environment: Environment! + var childTemplate: Template! + var baseTemplate: Template! + + $0.before { + environment = Environment(loader: loader) + childTemplate = nil + baseTemplate = nil + } + + func expectError(reason: String, childToken: String, baseToken: String?, + file: String = #file, line: Int = #line, function: String = #function) throws { + var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason) + if let baseToken = baseToken { + expectedError.stackTrace = [expectedSyntaxError(token: baseToken, template: baseTemplate, description: reason).token!] + } + let error = try expect(environment.render(template: childTemplate, context: ["target": "World"]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + let reporter = SimpleErrorReporter() + try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError) + } + + $0.it("reports syntax error in base template") { + childTemplate = try environment.loadTemplate(name: "invalid-child-super.html") + baseTemplate = try environment.loadTemplate(name: "invalid-base.html") + + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", + childToken: "extends \"invalid-base.html\"", + baseToken: "target|unknown") + } + + $0.it("reports runtime error in base template") { + let filterExtension = Extension() + filterExtension.registerFilter("unknown", filter: { (_: Any?) in + throw TemplateSyntaxError("filter error") + }) + environment.extensions += [filterExtension] + + childTemplate = try environment.loadTemplate(name: "invalid-child-super.html") + baseTemplate = try environment.loadTemplate(name: "invalid-base.html") + + try expectError(reason: "filter error", + childToken: "block.super", + baseToken: "target|unknown") + } + + $0.it("reports syntax error in child template") { + childTemplate = Template(templateString: "{% extends \"base.html\" %}\n" + + "{% block body %}Child {{ target|unknown }}{% endblock %}", environment: environment, name: nil) + + try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", + childToken: "target|unknown", + baseToken: nil) + } + + $0.it("reports runtime error in child template") { + let filterExtension = Extension() + filterExtension.registerFilter("unknown", filter: { (_: Any?) in + throw TemplateSyntaxError("filter error") + }) + environment.extensions += [filterExtension] + + childTemplate = Template(templateString: "{% extends \"base.html\" %}\n" + + "{% block body %}Child {{ target|unknown }}{% endblock %}", environment: environment, name: nil) + + try expectError(reason: "filter error", + childToken: "target|unknown", + baseToken: nil) + } + + } + } } +extension Expectation { + @discardableResult + func toThrow() throws -> T { + var thrownError: Error? = nil + + do { + _ = try expression() + } catch { + thrownError = error + } + + if let thrownError = thrownError { + if let thrownError = thrownError as? T { + return thrownError + } else { + throw failure("\(thrownError) is not \(T.self)") + } + } else { + throw failure("expression did not throw an error") + } + } +} fileprivate class ExampleLoader: Loader { func loadTemplate(name: String, environment: Environment) throws -> Template { diff --git a/Tests/StencilTests/ExpressionSpec.swift b/Tests/StencilTests/ExpressionSpec.swift index c41575f..e53ffab 100644 --- a/Tests/StencilTests/ExpressionSpec.swift +++ b/Tests/StencilTests/ExpressionSpec.swift @@ -105,19 +105,19 @@ func testExpressions() { $0.describe("expression parsing") { $0.it("can parse a variable expression") { - let expression = try parseExpression(components: ["value"], tokenParser: parser) + let expression = try parseExpression(components: ["value"], tokenParser: parser, token: .text(value: "", at: .unknown)) try expect(expression.evaluate(context: Context())).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue() } $0.it("can parse a not expression") { - let expression = try parseExpression(components: ["not", "value"], tokenParser: parser) + let expression = try parseExpression(components: ["not", "value"], tokenParser: parser, token: .text(value: "", at: .unknown)) try expect(expression.evaluate(context: Context())).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse() } $0.describe("and expression") { - let expression = try! parseExpression(components: ["lhs", "and", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", "and", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to false with lhs false") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse() @@ -137,7 +137,7 @@ func testExpressions() { } $0.describe("or expression") { - let expression = try! parseExpression(components: ["lhs", "or", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", "or", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with lhs true") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue() @@ -157,7 +157,7 @@ func testExpressions() { } $0.describe("equality expression") { - let expression = try! parseExpression(components: ["lhs", "==", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", "==", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with equal lhs/rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue() @@ -193,7 +193,7 @@ func testExpressions() { } $0.describe("inequality expression") { - let expression = try! parseExpression(components: ["lhs", "!=", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", "!=", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with inequal lhs/rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue() @@ -205,7 +205,7 @@ func testExpressions() { } $0.describe("more than expression") { - let expression = try! parseExpression(components: ["lhs", ">", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", ">", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with lhs > rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue() @@ -217,7 +217,7 @@ func testExpressions() { } $0.describe("more than equal expression") { - let expression = try! parseExpression(components: ["lhs", ">=", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", ">=", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with lhs == rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue() @@ -229,7 +229,7 @@ func testExpressions() { } $0.describe("less than expression") { - let expression = try! parseExpression(components: ["lhs", "<", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", "<", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with lhs < rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue() @@ -241,7 +241,7 @@ func testExpressions() { } $0.describe("less than equal expression") { - let expression = try! parseExpression(components: ["lhs", "<=", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", "<=", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with lhs == rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue() @@ -253,7 +253,7 @@ func testExpressions() { } $0.describe("multiple expression") { - let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"], tokenParser: parser) + let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true with one") { try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue() @@ -281,7 +281,7 @@ func testExpressions() { } $0.describe("in expression") { - let expression = try! parseExpression(components: ["lhs", "in", "rhs"], tokenParser: parser) + let expression = try! parseExpression(components: ["lhs", "in", "rhs"], tokenParser: parser, token: .text(value: "", at: .unknown)) $0.it("evaluates to true when rhs contains lhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [1, 2, 3]]))).to.beTrue() diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index f8de1b8..6c9139f 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -62,7 +62,7 @@ func testFilter() { } let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension])) - try expect(try template.render(context)).toThrow(TemplateSyntaxError("No Repeat")) + try expect(try template.render(context)).toThrow(TemplateSyntaxError(reason: "No Repeat", token: template.tokens.first)) } $0.it("allows you to override a default filter") { @@ -214,32 +214,52 @@ func testFilter() { describe("filter suggestion") { + var template: Template! + var filterExtension: Extension! + + func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError { + guard let range = template.templateString.range(of: token) else { + fatalError("Can't find '\(token)' in '\(template)'") + } + let rangeLine = template.templateString.rangeLine(range) + let sourceMap = SourceMap(filename: template.name, line: rangeLine) + let token = Token.block(value: token, at: sourceMap) + return TemplateSyntaxError(reason: description, token: token, stackTrace: []) + } + + func expectError(reason: String, token: String, + file: String = #file, line: Int = #line, function: String = #function) throws { + let expectedError = expectedSyntaxError(token: token, template: template, description: reason) + let environment = Environment(extensions: [filterExtension]) + + let error = try expect(environment.render(template: template, context: [:]), + file: file, line: line, function: function).toThrow() as TemplateSyntaxError + let reporter = SimpleErrorReporter() + try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError) + } $0.it("made for unknown filter") { - let template = Template(templateString: "{{ value|unknownFilter }}") - let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'") + template = Template(templateString: "{{ value|unknownFilter }}") - let filterExtension = Extension() + filterExtension = Extension() filterExtension.registerFilter("knownFilter") { value, _ in value } - try expect(template.render(Context(dictionary: [:], environment: Environment(extensions: [filterExtension])))).toThrow(expectedError) + try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.", token: "value|unknownFilter") } $0.it("made for multiple similar filters") { - let template = Template(templateString: "{{ value|lowerFirst }}") - let expectedError = TemplateSyntaxError("Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'") + template = Template(templateString: "{{ value|lowerFirst }}") - let filterExtension = Extension() + filterExtension = Extension() filterExtension.registerFilter("lowerFirstWord") { value, _ in value } filterExtension.registerFilter("lowerFirstLetter") { value, _ in value } - try expect(template.render(Context(dictionary: [:], environment: Environment(extensions: [filterExtension])))).toThrow(expectedError) + try expectError(reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.", token: "value|lowerFirst") } $0.it("not made when can't find similar filter") { - let template = Template(templateString: "{{ value|unknownFilter }}") - let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'.") - try expect(template.render(Context(dictionary: [:]))).toThrow(expectedError) + template = Template(templateString: "{{ value|unknownFilter }}") + try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.", token: "value|unknownFilter") } } diff --git a/Tests/StencilTests/FilterTagSpec.swift b/Tests/StencilTests/FilterTagSpec.swift index c286449..5d59fdc 100644 --- a/Tests/StencilTests/FilterTagSpec.swift +++ b/Tests/StencilTests/FilterTagSpec.swift @@ -17,7 +17,7 @@ func testFilterTag() { } $0.it("errors without a filter") { - let template = Template(templateString: "{% filter %}Test{% endfilter %}") + let template = Template(templateString: "Some {% filter %}Test{% endfilter %}") try expect(try template.render()).toThrow() } diff --git a/Tests/StencilTests/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index 4bb3fca..ddb7692 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -13,7 +13,7 @@ func testForNode() { "two": "II", ], "tuples": [(1, 2, 3), (4, 5, 6)] - ]) + ]) $0.it("renders the given nodes for each item") { let nodes: [NodeType] = [VariableNode(variable: "item")] @@ -31,7 +31,7 @@ func testForNode() { $0.it("renders a context variable of type Array") { let any_context = Context(dictionary: [ "items": ([1, 2, 3] as [Any]) - ]) + ]) let nodes: [NodeType] = [VariableNode(variable: "item")] let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: []) @@ -54,17 +54,17 @@ func testForNode() { try expect(try node.render(context)) == "123" } -#if os(OSX) + #if os(OSX) $0.it("renders a context variable of type NSArray") { let nsarray_context = Context(dictionary: [ "items": NSArray(array: [1, 2, 3]) - ]) + ]) let nodes: [NodeType] = [VariableNode(variable: "item")] let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: []) try expect(try node.render(nsarray_context)) == "123" } -#endif + #endif $0.it("renders the given nodes while providing if the item is first in the context") { let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")] @@ -97,31 +97,31 @@ func testForNode() { } $0.it("renders the given nodes while filtering items using where expression") { - let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")] - let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment())) - let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [], where: `where`) - try expect(try node.render(context)) == "2132" + let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")] + let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown)) + let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [], where: `where`) + try expect(try node.render(context)) == "2132" } $0.it("renders the given empty nodes when all items filtered out with where expression") { - let nodes: [NodeType] = [VariableNode(variable: "item")] - let emptyNodes: [NodeType] = [TextNode(text: "empty")] - let `where` = try parseExpression(components: ["item", "==", "0"], tokenParser: TokenParser(tokens: [], environment: Environment())) - let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes, where: `where`) - try expect(try node.render(context)) == "empty" + let nodes: [NodeType] = [VariableNode(variable: "item")] + let emptyNodes: [NodeType] = [TextNode(text: "empty")] + let `where` = try parseExpression(components: ["item", "==", "0"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown)) + let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes, where: `where`) + try expect(try node.render(context)) == "empty" } $0.it("can render a filter with spaces") { let templateString = "{% for article in ars | default: a, b , articles %}" + "- {{ article.title }} by {{ article.author }}.\n" + - "{% endfor %}\n" + "{% endfor %}\n" let context = Context(dictionary: [ "articles": [ Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"), Article(title: "Memory Management with ARC", author: "Kyle Fuller"), ] - ]) + ]) let template = Template(templateString: templateString) let result = try template.render(context) @@ -129,7 +129,7 @@ func testForNode() { let fixture = "" + "- Migrating from OCUnit to XCTest by Kyle Fuller.\n" + "- Memory Management with ARC by Kyle Fuller.\n" + - "\n" + "\n" try expect(result) == fixture } @@ -184,7 +184,7 @@ func testForNode() { $0.it("can iterate over dictionary") { let templateString = "{% for key, value in dict %}" + "{{ key }}: {{ value }}," + - "{% endfor %}" + "{% endfor %}" let template = Template(templateString: templateString) let result = try template.render(context) @@ -197,7 +197,7 @@ func testForNode() { let nodes: [NodeType] = [ VariableNode(variable: "key"), TextNode(text: ","), - ] + ] let emptyNodes: [NodeType] = [TextNode(text: "empty")] let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key"], nodes: nodes, emptyNodes: emptyNodes, where: nil) let result = try node.render(context) @@ -212,7 +212,7 @@ func testForNode() { TextNode(text: "="), VariableNode(variable: "value"), TextNode(text: ","), - ] + ] let emptyNodes: [NodeType] = [TextNode(text: "empty")] let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key", "value"], nodes: nodes, emptyNodes: emptyNodes, where: nil) @@ -223,11 +223,9 @@ func testForNode() { } $0.it("handles invalid input") { - let tokens: [Token] = [ - .block(value: "for i"), - ] - let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError("'for' statements should use the syntax: `for in [where ]") + let token = Token.block(value: "for i", at: .unknown) + let parser = TokenParser(tokens: [token], environment: Environment()) + let error = TemplateSyntaxError(reason: "'for' statements should use the syntax: `for in [where ]`.", token: token) try expect(try parser.parse()).toThrow(error) } @@ -239,14 +237,14 @@ func testForNode() { let context = Context(dictionary: [ "struct": MyStruct(string: "abc", number: 123) - ]) + ]) let nodes: [NodeType] = [ VariableNode(variable: "property"), TextNode(text: "="), VariableNode(variable: "value"), TextNode(text: "\n"), - ] + ] let node = ForNode(resolvable: Variable("struct"), loopVariables: ["property", "value"], nodes: nodes, emptyNodes: []) let result = try node.render(context) @@ -256,14 +254,14 @@ func testForNode() { $0.it("can iterate tuple items") { let context = Context(dictionary: [ "tuple": (one: 1, two: "dva"), - ]) + ]) let nodes: [NodeType] = [ VariableNode(variable: "label"), TextNode(text: "="), VariableNode(variable: "value"), TextNode(text: "\n"), - ] + ] let node = ForNode(resolvable: Variable("tuple"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: []) let result = try node.render(context) @@ -291,15 +289,15 @@ func testForNode() { let context = Context(dictionary: [ "class": MySubclass("child", "base", 1) - ]) + ]) let nodes: [NodeType] = [ VariableNode(variable: "label"), TextNode(text: "="), VariableNode(variable: "value"), TextNode(text: "\n"), - ] - + ] + let node = ForNode(resolvable: Variable("class"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: []) let result = try node.render(context) diff --git a/Tests/StencilTests/IfNodeSpec.swift b/Tests/StencilTests/IfNodeSpec.swift index a2d6815..cd662d7 100644 --- a/Tests/StencilTests/IfNodeSpec.swift +++ b/Tests/StencilTests/IfNodeSpec.swift @@ -7,9 +7,9 @@ func testIfNode() { $0.describe("parsing") { $0.it("can parse an if block") { let tokens: [Token] = [ - .block(value: "if value"), - .text(value: "true"), - .block(value: "endif") + .block(value: "if value", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -25,11 +25,11 @@ func testIfNode() { $0.it("can parse an if with else block") { let tokens: [Token] = [ - .block(value: "if value"), - .text(value: "true"), - .block(value: "else"), - .text(value: "false"), - .block(value: "endif") + .block(value: "if value", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "else", at: .unknown), + .text(value: "false", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -50,13 +50,13 @@ func testIfNode() { $0.it("can parse an if with elif block") { let tokens: [Token] = [ - .block(value: "if value"), - .text(value: "true"), - .block(value: "elif something"), - .text(value: "some"), - .block(value: "else"), - .text(value: "false"), - .block(value: "endif") + .block(value: "if value", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "elif something", at: .unknown), + .text(value: "some", at: .unknown), + .block(value: "else", at: .unknown), + .text(value: "false", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -81,11 +81,11 @@ func testIfNode() { $0.it("can parse an if with elif block without else") { let tokens: [Token] = [ - .block(value: "if value"), - .text(value: "true"), - .block(value: "elif something"), - .text(value: "some"), - .block(value: "endif") + .block(value: "if value", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "elif something", at: .unknown), + .text(value: "some", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -106,15 +106,15 @@ func testIfNode() { $0.it("can parse an if with multiple elif block") { let tokens: [Token] = [ - .block(value: "if value"), - .text(value: "true"), - .block(value: "elif something1"), - .text(value: "some1"), - .block(value: "elif something2"), - .text(value: "some2"), - .block(value: "else"), - .text(value: "false"), - .block(value: "endif") + .block(value: "if value", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "elif something1", at: .unknown), + .text(value: "some1", at: .unknown), + .block(value: "elif something2", at: .unknown), + .text(value: "some2", at: .unknown), + .block(value: "else", at: .unknown), + .text(value: "false", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -144,9 +144,9 @@ func testIfNode() { $0.it("can parse an if with complex expression") { let tokens: [Token] = [ - .block(value: "if value == \"test\" and not name"), - .text(value: "true"), - .block(value: "endif") + .block(value: "if value == \"test\" and not name", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -156,11 +156,11 @@ func testIfNode() { $0.it("can parse an ifnot block") { let tokens: [Token] = [ - .block(value: "ifnot value"), - .text(value: "false"), - .block(value: "else"), - .text(value: "true"), - .block(value: "endif") + .block(value: "ifnot value", at: .unknown), + .text(value: "false", at: .unknown), + .block(value: "else", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -179,22 +179,18 @@ func testIfNode() { } $0.it("throws an error when parsing an if block without an endif") { - let tokens: [Token] = [ - .block(value: "if value"), - ] + let tokens: [Token] = [.block(value: "if value", at: .unknown)] let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError("`endif` was not found.") + let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first) try expect(try parser.parse()).toThrow(error) } $0.it("throws an error when parsing an ifnot without an endif") { - let tokens: [Token] = [ - .block(value: "ifnot value"), - ] + let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)] let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError("`endif` was not found.") + let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first) try expect(try parser.parse()).toThrow(error) } } @@ -242,9 +238,9 @@ func testIfNode() { $0.it("supports variable filters in the if expression") { let tokens: [Token] = [ - .block(value: "if value|uppercase == \"TEST\""), - .text(value: "true"), - .block(value: "endif") + .block(value: "if value|uppercase == \"TEST\"", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -256,9 +252,9 @@ func testIfNode() { $0.it("evaluates nil properties as false") { let tokens: [Token] = [ - .block(value: "if instance.value"), - .text(value: "true"), - .block(value: "endif") + .block(value: "if instance.value", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) @@ -273,11 +269,11 @@ func testIfNode() { $0.it("supports closed range variables") { let tokens: [Token] = [ - .block(value: "if value in 1...3"), - .text(value: "true"), - .block(value: "else"), - .text(value: "false"), - .block(value: "endif") + .block(value: "if value in 1...3", at: .unknown), + .text(value: "true", at: .unknown), + .block(value: "else", at: .unknown), + .text(value: "false", at: .unknown), + .block(value: "endif", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) diff --git a/Tests/StencilTests/IncludeSpec.swift b/Tests/StencilTests/IncludeSpec.swift index 1ad004f..a87dc85 100644 --- a/Tests/StencilTests/IncludeSpec.swift +++ b/Tests/StencilTests/IncludeSpec.swift @@ -11,15 +11,15 @@ func testInclude() { $0.describe("parsing") { $0.it("throws an error when no template is given") { - let tokens: [Token] = [ .block(value: "include") ] + let tokens: [Token] = [ .block(value: "include", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError("'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file") + let error = TemplateSyntaxError(reason: "'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file", token: tokens.first) try expect(try parser.parse()).toThrow(error) } $0.it("can parse a valid include block") { - let tokens: [Token] = [ .block(value: "include \"test.html\"") ] + let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) let nodes = try parser.parse() @@ -31,7 +31,7 @@ func testInclude() { $0.describe("rendering") { $0.it("throws an error when rendering without a loader") { - let node = IncludeNode(templateName: Variable("\"test.html\"")) + let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown)) do { _ = try node.render(Context()) @@ -41,7 +41,7 @@ func testInclude() { } $0.it("throws an error when it cannot find the included template") { - let node = IncludeNode(templateName: Variable("\"unknown.html\"")) + let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown)) do { _ = try node.render(Context(environment: environment)) @@ -51,7 +51,7 @@ func testInclude() { } $0.it("successfully renders a found included template") { - let node = IncludeNode(templateName: Variable("\"test.html\"")) + let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown)) let context = Context(dictionary: ["target": "World"], environment: environment) let value = try node.render(context) try expect(value) == "Hello World!" diff --git a/Tests/StencilTests/LexerSpec.swift b/Tests/StencilTests/LexerSpec.swift index e0014c7..2a9f1e1 100644 --- a/Tests/StencilTests/LexerSpec.swift +++ b/Tests/StencilTests/LexerSpec.swift @@ -9,7 +9,7 @@ func testLexer() { let tokens = lexer.tokenize() try expect(tokens.count) == 1 - try expect(tokens.first) == .text(value: "Hello World") + try expect(tokens.first) == .text(value: "Hello World", at: SourceMap(line: ("Hello World", 1, 0))) } $0.it("can tokenize a comment") { @@ -17,7 +17,7 @@ func testLexer() { let tokens = lexer.tokenize() try expect(tokens.count) == 1 - try expect(tokens.first) == .comment(value: "Comment") + try expect(tokens.first) == .comment(value: "Comment", at: SourceMap(line: ("{# Comment #}", 1, 3))) } $0.it("can tokenize a variable") { @@ -25,34 +25,37 @@ func testLexer() { let tokens = lexer.tokenize() try expect(tokens.count) == 1 - try expect(tokens.first) == .variable(value: "Variable") + try expect(tokens.first) == .variable(value: "Variable", at: SourceMap(line: ("{{ Variable }}", 1, 3))) } $0.it("can tokenize unclosed tag by ignoring it") { - let lexer = Lexer(templateString: "{{ thing") + let templateString = "{{ thing" + let lexer = Lexer(templateString: templateString) let tokens = lexer.tokenize() try expect(tokens.count) == 1 - try expect(tokens.first) == .text(value: "") + try expect(tokens.first) == .text(value: "", at: SourceMap(line: ("{{ thing", 1, 0))) } $0.it("can tokenize a mixture of content") { - let lexer = Lexer(templateString: "My name is {{ name }}.") + let templateString = "My name is {{ myname }}." + let lexer = Lexer(templateString: templateString) let tokens = lexer.tokenize() try expect(tokens.count) == 3 - try expect(tokens[0]) == Token.text(value: "My name is ") - try expect(tokens[1]) == Token.variable(value: "name") - try expect(tokens[2]) == Token.text(value: ".") + try expect(tokens[0]) == Token.text(value: "My name is ", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "My name is ")!))) + try expect(tokens[1]) == Token.variable(value: "myname", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "myname")!))) + try expect(tokens[2]) == Token.text(value: ".", at: SourceMap(line: templateString.rangeLine(templateString.range(of: ".")!))) } $0.it("can tokenize two variables without being greedy") { - let lexer = Lexer(templateString: "{{ thing }}{{ name }}") + let templateString = "{{ thing }}{{ name }}" + let lexer = Lexer(templateString: templateString) let tokens = lexer.tokenize() try expect(tokens.count) == 2 - try expect(tokens[0]) == Token.variable(value: "thing") - try expect(tokens[1]) == Token.variable(value: "name") + try expect(tokens[0]) == Token.variable(value: "thing", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "thing")!))) + try expect(tokens[1]) == Token.variable(value: "name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "name")!))) } $0.it("can tokenize an unclosed block") { @@ -66,25 +69,26 @@ func testLexer() { } $0.it("can tokenize with new lines") { - let lexer = Lexer(templateString: + let templateString = "My name is {%\n" + - " if name\n" + - " and\n" + - " name\n" + - "%}{{\n" + - "name\n" + - "}}{%\n" + - "endif %}.") + " if name\n" + + " and\n" + + " name\n" + + "%}{{\n" + + "name\n" + + "}}{%\n" + + "endif %}." - let tokens = lexer.tokenize() + let lexer = Lexer(templateString: templateString) - try expect(tokens.count) == 5 - try expect(tokens[0]) == Token.text(value: "My name is ") - try expect(tokens[1]) == Token.block(value: "if name and name") - try expect(tokens[2]) == Token.variable(value: "name") - try expect(tokens[3]) == Token.block(value: "endif") - try expect(tokens[4]) == Token.text(value: ".") + let tokens = lexer.tokenize() + try expect(tokens.count) == 5 + try expect(tokens[0]) == Token.text(value: "My name is ", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "My name is")!))) + try expect(tokens[1]) == Token.block(value: "if name and name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "{%")!))) + try expect(tokens[2]) == Token.variable(value: "name", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "name", options: [.backwards])!))) + try expect(tokens[3]) == Token.block(value: "endif", at: SourceMap(line: templateString.rangeLine(templateString.range(of: "endif")!))) + try expect(tokens[4]) == Token.text(value: ".", at: SourceMap(line: templateString.rangeLine(templateString.range(of: ".")!))) } } } diff --git a/Tests/StencilTests/NodeSpec.swift b/Tests/StencilTests/NodeSpec.swift index 431d225..1adfa26 100644 --- a/Tests/StencilTests/NodeSpec.swift +++ b/Tests/StencilTests/NodeSpec.swift @@ -3,6 +3,11 @@ import Spectre class ErrorNode : NodeType { + let token: Token? + init(token: Token? = nil) { + self.token = token + } + func render(_ context: Context) throws -> String { throw TemplateSyntaxError("Custom Error") } diff --git a/Tests/StencilTests/NowNodeSpec.swift b/Tests/StencilTests/NowNodeSpec.swift index 4adba36..33a0d3f 100644 --- a/Tests/StencilTests/NowNodeSpec.swift +++ b/Tests/StencilTests/NowNodeSpec.swift @@ -8,7 +8,7 @@ func testNowNode() { describe("NowNode") { $0.describe("parsing") { $0.it("parses default format without any now arguments") { - let tokens: [Token] = [ .block(value: "now") ] + let tokens: [Token] = [ .block(value: "now", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) let nodes = try parser.parse() @@ -18,7 +18,7 @@ func testNowNode() { } $0.it("parses now with a format") { - let tokens: [Token] = [ .block(value: "now \"HH:mm\"") ] + let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ] let parser = TokenParser(tokens: tokens, environment: Environment()) let nodes = try parser.parse() let node = nodes.first as? NowNode diff --git a/Tests/StencilTests/ParserSpec.swift b/Tests/StencilTests/ParserSpec.swift index b5c9bb2..25c485f 100644 --- a/Tests/StencilTests/ParserSpec.swift +++ b/Tests/StencilTests/ParserSpec.swift @@ -6,7 +6,7 @@ func testTokenParser() { describe("TokenParser") { $0.it("can parse a text token") { let parser = TokenParser(tokens: [ - .text(value: "Hello World") + .text(value: "Hello World", at: .unknown) ], environment: Environment()) let nodes = try parser.parse() @@ -18,7 +18,7 @@ func testTokenParser() { $0.it("can parse a variable token") { let parser = TokenParser(tokens: [ - .variable(value: "'name'") + .variable(value: "'name'", at: .unknown) ], environment: Environment()) let nodes = try parser.parse() @@ -30,7 +30,7 @@ func testTokenParser() { $0.it("can parse a comment token") { let parser = TokenParser(tokens: [ - .comment(value: "Secret stuff!") + .comment(value: "Secret stuff!", at: .unknown) ], environment: Environment()) let nodes = try parser.parse() @@ -44,7 +44,7 @@ func testTokenParser() { } let parser = TokenParser(tokens: [ - .block(value: "known"), + .block(value: "known", at: .unknown), ], environment: Environment(extensions: [simpleExtension])) let nodes = try parser.parse() @@ -52,11 +52,10 @@ func testTokenParser() { } $0.it("errors when parsing an unknown tag") { - let parser = TokenParser(tokens: [ - .block(value: "unknown"), - ], environment: Environment()) + let tokens: [Token] = [.block(value: "unknown", at: .unknown)] + let parser = TokenParser(tokens: tokens, environment: Environment()) - try expect(try parser.parse()).toThrow(TemplateSyntaxError("Unknown template tag 'unknown'")) + try expect(try parser.parse()).toThrow(TemplateSyntaxError(reason: "Unknown template tag 'unknown'", token: tokens.first)) } } } diff --git a/Tests/StencilTests/StencilSpec.swift b/Tests/StencilTests/StencilSpec.swift index 427cd98..099a407 100644 --- a/Tests/StencilTests/StencilSpec.swift +++ b/Tests/StencilTests/StencilSpec.swift @@ -2,7 +2,8 @@ import Spectre import Stencil -fileprivate class CustomNode : NodeType { +fileprivate struct CustomNode : NodeType { + let token: Token? func render(_ context:Context) throws -> String { return "Hello World" } @@ -24,7 +25,7 @@ func testStencil() { } exampleExtension.registerTag("customtag") { parser, token in - return CustomNode() + return CustomNode(token: token) } let environment = Environment(extensions: [exampleExtension]) diff --git a/Tests/StencilTests/TemplateSpec.swift b/Tests/StencilTests/TemplateSpec.swift index ad03851..fee0c5e 100644 --- a/Tests/StencilTests/TemplateSpec.swift +++ b/Tests/StencilTests/TemplateSpec.swift @@ -1,5 +1,5 @@ import Spectre -import Stencil +@testable import Stencil func testTemplate() { @@ -15,5 +15,6 @@ func testTemplate() { let result = try template.render([ "name": "Kyle" ]) try expect(result) == "Hello World" } + } } diff --git a/Tests/StencilTests/TokenSpec.swift b/Tests/StencilTests/TokenSpec.swift index c7f9db1..dd73298 100644 --- a/Tests/StencilTests/TokenSpec.swift +++ b/Tests/StencilTests/TokenSpec.swift @@ -1,11 +1,11 @@ import Spectre -import Stencil +@testable import Stencil func testToken() { describe("Token") { $0.it("can split the contents into components") { - let token = Token.text(value: "hello world") + let token = Token.text(value: "hello world", at: .unknown) let components = token.components() try expect(components.count) == 2 @@ -14,7 +14,7 @@ func testToken() { } $0.it("can split the contents into components with single quoted strings") { - let token = Token.text(value: "hello 'kyle fuller'") + let token = Token.text(value: "hello 'kyle fuller'", at: .unknown) let components = token.components() try expect(components.count) == 2 @@ -23,7 +23,7 @@ func testToken() { } $0.it("can split the contents into components with double quoted strings") { - let token = Token.text(value: "hello \"kyle fuller\"") + let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown) let components = token.components() try expect(components.count) == 2 diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 7b386bc..ac85f1b 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -292,7 +292,9 @@ func testVariable() { }() func makeVariable(_ token: String) throws -> RangeVariable? { - return try RangeVariable(token, parser: TokenParser(tokens: [], environment: context.environment)) + let token = Token.variable(value: token, at: .unknown) + let parser = TokenParser(tokens: [token], environment: context.environment) + return try RangeVariable(token.contents, parser: parser, containedIn: token) } $0.it("can resolve closed range as array") { @@ -321,11 +323,11 @@ func testVariable() { } $0.it("throws is left range value is missing") { - try expect(makeVariable("...1")).toThrow() + try expect(makeVariable("...1")).toThrow() } $0.it("throws is right range value is missing") { - try expect(makeVariable("1...")).toThrow() + try expect(makeVariable("1...")).toThrow() } } diff --git a/Tests/StencilTests/fixtures/invalid-base.html b/Tests/StencilTests/fixtures/invalid-base.html new file mode 100644 index 0000000..51c27a0 --- /dev/null +++ b/Tests/StencilTests/fixtures/invalid-base.html @@ -0,0 +1,2 @@ +{% block header %}Header{% endblock %} +{% block body %}Body {{ target|unknown }} {% endblock %} diff --git a/Tests/StencilTests/fixtures/invalid-child-super.html b/Tests/StencilTests/fixtures/invalid-child-super.html new file mode 100644 index 0000000..23aea65 --- /dev/null +++ b/Tests/StencilTests/fixtures/invalid-child-super.html @@ -0,0 +1,3 @@ +{% extends "invalid-base.html" %} +{% block body %}Child {{ block.super }}{% endblock %} + diff --git a/Tests/StencilTests/fixtures/invalid-include.html b/Tests/StencilTests/fixtures/invalid-include.html new file mode 100644 index 0000000..014ba0e --- /dev/null +++ b/Tests/StencilTests/fixtures/invalid-include.html @@ -0,0 +1 @@ +Hello {{ target|unknown }}!