diff --git a/Sources/Common.swift b/Sources/Common.swift index 6931a6af..1a79886d 100644 --- a/Sources/Common.swift +++ b/Sources/Common.swift @@ -149,6 +149,12 @@ extension MustacheError : CustomStringConvertible { } } +// Make the `localizedDescription` property of an `Error` be the same as the `description` +extension MustacheError : LocalizedError { + public var errorDescription: String? { + return description + } +} /// A pair of tag delimiters, such as `("{{", "}}")`. /// diff --git a/Sources/Configuration.swift b/Sources/Configuration.swift index 18b83f7f..4d6474c0 100644 --- a/Sources/Configuration.swift +++ b/Sources/Configuration.swift @@ -93,6 +93,7 @@ public struct Configuration { contentType = .html baseContext = Context() tagDelimiterPair = ("{{", "}}") + throwWhenMissing = false } @@ -295,6 +296,14 @@ public struct Configuration { /// You can also change the delimiters right in your templates using a "Set /// Delimiter tag": `{{=[[ ]]=}}` changes delimiters to `[[` and `]]`. public var tagDelimiterPair: TagDelimiterPair + + // ========================================================================= + // MARK: - Render configuration + + /// Throw an error during rendering when a tag found in the template + /// is missing from the context, or the tag is in the context but its + /// value is nil. + public var throwWhenMissing: Bool } diff --git a/Sources/ExpressionInvocation.swift b/Sources/ExpressionInvocation.swift index a06da269..cf003839 100644 --- a/Sources/ExpressionInvocation.swift +++ b/Sources/ExpressionInvocation.swift @@ -39,14 +39,26 @@ struct ExpressionInvocation { case .identifier(let identifier): // {{ identifier }} - - return context.mustacheBox(forKey: identifier) + + let identifierBox = context.mustacheBox(forKey: identifier) + + if DefaultConfiguration.throwWhenMissing, identifierBox.isEmpty { + throw MustacheError(kind: .renderError, message: "Missing identifier") + } + + return identifierBox case .scoped(let baseExpression, let identifier): // {{ <expression>.identifier }} - - return try evaluate(context: context, expression: baseExpression).mustacheBox(forKey: identifier) - + + let identifierBox = try evaluate(context: context, expression: baseExpression).mustacheBox(forKey: identifier) + + if DefaultConfiguration.throwWhenMissing, identifierBox.isEmpty { + throw MustacheError(kind: .renderError, message: "Missing identifier") + } + + return identifierBox + case .filter(let filterExpression, let argumentExpression, let partialApplication): // {{ <expression>(<expression>) }} diff --git a/Tests/Public/BoxTestsWithMissingIndentifiers.swift b/Tests/Public/BoxTestsWithMissingIndentifiers.swift new file mode 100644 index 00000000..e61e2add --- /dev/null +++ b/Tests/Public/BoxTestsWithMissingIndentifiers.swift @@ -0,0 +1,95 @@ +import XCTest +import Mustache + +class BoxTestsWithMissingIndentifiers: XCTestCase { + override func setUp() { + super.setUp() + + DefaultConfiguration.throwWhenMissing = true + } + + override func tearDown() { + super.tearDown() + + DefaultConfiguration.throwWhenMissing = false + } + + func testIdentifier() { + do { + // Key exists, but value is nil + let value: [String: Any?] = ["string": "foo", "missing": nil] + let template = try! Template(string: "{{string}}, {{missing}}") + assert(try template.render(value), throws: missingErrorDescription("missing", 1)) + } + + do { + // Key does not exist + let value: [String: Any?] = ["string": "foo"] + let template = try! Template(string: "{{string}}, {{missing}}") + assert(try template.render(value), throws: missingErrorDescription("missing", 1)) + } + } + + func testIdentifierWithSubscript() { + do { + // Key exists, but value is nil + let value: [String: Any?] = ["string": "foo", "subscript": ["int": 1, "missing": nil]] + let template = try! Template(string: "{{string}}, {{subscript.int}}, {{subscript.missing}}") + assert(try template.render(value), throws: missingErrorDescription("subscript.missing", 1)) + } + + do { + // Key does not exist + let value: [String: Any?] = ["string": "foo", "subscript": ["int": 1]] + let template = try! Template(string: "{{string}}, {{subscript.int}}, {{subscript.missing}}") + assert(try template.render(value), throws: missingErrorDescription("subscript.missing", 1)) + } + } + + func testSection() { + do { + // Key exists, but value is nil + let value: [String: Any?] = ["section": ["int": 1, "missing": nil]] + let template = try! Template(string: "{{#section}}{{int}}, {{missing}}{{/section}}") + assert(try template.render(value), throws: missingErrorDescription("missing", 1)) + } + + do { + // Key does not exist + let value: [String: Any?] = ["section": ["int": 1]] + let template = try! Template(string: "{{#section}}{{int}}, {{missing}}{{/section}}") + assert(try template.render(value), throws: missingErrorDescription("missing", 1)) + } + + do { + // Section does not exist + let value: [String: Any?] = [:] + let template = try! Template(string: "{{#section}}{{int}}, {{missing}}{{/section}}") + assert(try template.render(value), throws: missingErrorDescription("#section", 1)) + } + } +} + +private func missingErrorDescription(_ label: String, _ lineNumber: Int) -> String { + return "Rendering error at line \(lineNumber): Could not evaluate {{\(label)}} at line \(lineNumber): Missing identifier" +} + +// Inspired by https://www.swiftbysundell.com/articles/testing-error-code-paths-in-swift/ +private extension XCTestCase { + func assert<T>( + _ expression: @autoclosure () throws -> T, + throws errorDescription: String, + in file: StaticString = #file, + line: UInt = #line + ) { + var thrownError: Error? + + XCTAssertThrowsError(try expression(), file: file, line: line) { + thrownError = $0 + } + + if let thrownError = thrownError { + XCTAssertEqual(thrownError.localizedDescription, errorDescription, file: file, line: line) + } + } +} diff --git a/Xcode/Mustache.xcodeproj/project.pbxproj b/Xcode/Mustache.xcodeproj/project.pbxproj index 40779ece..2b3d147f 100644 --- a/Xcode/Mustache.xcodeproj/project.pbxproj +++ b/Xcode/Mustache.xcodeproj/project.pbxproj @@ -66,6 +66,9 @@ 09A89E201BF58720003A695E /* FilterTests.mustache in Resources */ = {isa = PBXBuildFile; fileRef = 56CD9F651A275980001BABA4 /* FilterTests.mustache */; }; 09A89E251BF62345003A695E /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 09A89E231BF58903003A695E /* UIKit.framework */; }; 09A89E261BF62355003A695E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 09A89E211BF588EC003A695E /* Foundation.framework */; }; + 4176BD98237C15CD00250C0B /* BoxTestsWithMissingIndentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4176BD96237C15C700250C0B /* BoxTestsWithMissingIndentifiers.swift */; }; + 4176BD99237C15CE00250C0B /* BoxTestsWithMissingIndentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4176BD96237C15C700250C0B /* BoxTestsWithMissingIndentifiers.swift */; }; + 4176BD9A237C15CE00250C0B /* BoxTestsWithMissingIndentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4176BD96237C15C700250C0B /* BoxTestsWithMissingIndentifiers.swift */; }; 5607A33C1C160216002364C1 /* GRMustacheKeyAccess.h in Headers */ = {isa = PBXBuildFile; fileRef = 5607A33A1C160216002364C1 /* GRMustacheKeyAccess.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5607A33D1C160216002364C1 /* GRMustacheKeyAccess.h in Headers */ = {isa = PBXBuildFile; fileRef = 5607A33A1C160216002364C1 /* GRMustacheKeyAccess.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5607A33E1C160216002364C1 /* GRMustacheKeyAccess.h in Headers */ = {isa = PBXBuildFile; fileRef = 5607A33A1C160216002364C1 /* GRMustacheKeyAccess.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -313,6 +316,7 @@ 09A89DDA1BF584EE003A695E /* MustacheTVOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MustacheTVOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 09A89E211BF588EC003A695E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS9.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 09A89E231BF58903003A695E /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS9.0.sdk/System/Library/Frameworks/UIKit.framework; sourceTree = DEVELOPER_DIR; }; + 4176BD96237C15C700250C0B /* BoxTestsWithMissingIndentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxTestsWithMissingIndentifiers.swift; sourceTree = "<group>"; }; 56056AA51A84CDBD0076BBAA /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = TODO.md; path = ../TODO.md; sourceTree = "<group>"; }; 5607A33A1C160216002364C1 /* GRMustacheKeyAccess.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GRMustacheKeyAccess.h; sourceTree = "<group>"; }; 5607A33B1C160216002364C1 /* GRMustacheKeyAccess.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GRMustacheKeyAccess.m; sourceTree = "<group>"; }; @@ -798,6 +802,7 @@ isa = PBXGroup; children = ( 56C8D6E01A1F1A4700F106F8 /* BoxTests.swift */, + 4176BD96237C15C700250C0B /* BoxTestsWithMissingIndentifiers.swift */, 5698AC1A1D9D31C30056AF8C /* BoxValueTests.swift */, 56FC9C831A17CD0C0020AAF8 /* ConfigurationTests */, 56FC9C881A17CD0C0020AAF8 /* ContextTests */, @@ -1213,6 +1218,7 @@ 09A89E0C1BF58702003A695E /* LambdaTests.swift in Sources */, 09A89E0F1BF58702003A695E /* RenderFunctionTests.swift in Sources */, 09A89E0A1BF58702003A695E /* HookFunctionTests.swift in Sources */, + 4176BD9A237C15CE00250C0B /* BoxTestsWithMissingIndentifiers.swift in Sources */, 09A89E1E1BF58720003A695E /* FilterTests.swift in Sources */, 09A89E091BF58702003A695E /* FoundationCollectionTests.swift in Sources */, 09A89E101BF58709003A695E /* ConfigurationBaseContextTests.swift in Sources */, @@ -1304,6 +1310,7 @@ 561E72B51A8BDC6A004ED48B /* TemplateRepositoryDictionaryTests.swift in Sources */, 561E72B61A8BDC6A004ED48B /* TemplateRepositoryURLTests.swift in Sources */, 561E72A21A8BDC6A004ED48B /* MustacheBoxDocumentationTests.swift in Sources */, + 4176BD99237C15CE00250C0B /* BoxTestsWithMissingIndentifiers.swift in Sources */, 561E72AF1A8BDC6A004ED48B /* StandardLibraryTests.swift in Sources */, 561E72B11A8BDC6A004ED48B /* TagTests.swift in Sources */, 566244A21AF1645F008BAD41 /* HoganSuite.swift in Sources */, @@ -1393,6 +1400,7 @@ 569C423B1A87D03800748E98 /* ContextRegisteredKeyTests.swift in Sources */, 5674F3751A1CC7B8000CE8CB /* FormatterTests.swift in Sources */, 56FC9C8E1A17CD0C0020AAF8 /* ContextValueForMustacheExpressionTests.swift in Sources */, + 4176BD98237C15CD00250C0B /* BoxTestsWithMissingIndentifiers.swift in Sources */, 56FC9C8B1A17CD0C0020AAF8 /* ConfigurationContentTypeTests.swift in Sources */, 56FC9C8D1A17CD0C0020AAF8 /* ConfigurationTagDelimitersTests.swift in Sources */, 566244A11AF1645F008BAD41 /* HoganSuite.swift in Sources */,