diff --git a/Sources/Mustache/Lambda.swift b/Sources/Mustache/Lambda.swift index 7c9e64e..78b30be 100644 --- a/Sources/Mustache/Lambda.swift +++ b/Sources/Mustache/Lambda.swift @@ -34,7 +34,7 @@ /// public struct MustacheLambda { /// lambda callback - public typealias Callback = (Any, MustacheTemplate) -> String + public typealias Callback = (String) -> Any? let callback: Callback @@ -44,7 +44,13 @@ public struct MustacheLambda { self.callback = cb } - internal func run(_ object: Any, _ template: MustacheTemplate) -> String { - return self.callback(object, template) + /// Initialize `MustacheLambda` + /// - Parameter cb: function to be called by lambda + public init(_ cb: @escaping () -> Any?) { + self.callback = { _ in cb() } + } + + internal func callAsFunction(_ s: String) -> Any? { + return self.callback(s) } } diff --git a/Sources/Mustache/Parser.swift b/Sources/Mustache/Parser.swift index cb55f6f..a2c110e 100644 --- a/Sources/Mustache/Parser.swift +++ b/Sources/Mustache/Parser.swift @@ -100,6 +100,15 @@ extension Parser { return subString } + /// Read until we hit string index + /// - Parameter until: Read until position + /// - Returns: The string read from the buffer + mutating func read(until: String.Index) -> Substring { + let string = self.buffer[self.position.. [Token] { + static func parse(_ string: String) throws -> MustacheTemplate { var parser = Parser(string) do { return try self.parse(&parser, state: .init()) @@ -117,10 +117,11 @@ extension MustacheTemplate { } /// parse section in mustache text - static func parse(_ parser: inout Parser, state: ParserState) throws -> [Token] { + static func parse(_ parser: inout Parser, state: ParserState) throws -> MustacheTemplate { var tokens: [Token] = [] var state = state var whiteSpaceBefore: Substring = "" + var origParser = parser while !parser.reachedEnd() { // if new line read whitespace if state.newLine { @@ -169,8 +170,8 @@ extension MustacheTemplate { tokens.append(.text(String(whiteSpaceBefore))) whiteSpaceBefore = "" } - let sectionTokens = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms)) - tokens.append(.section(name: name, transforms: transforms, template: MustacheTemplate(sectionTokens))) + let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms)) + tokens.append(.section(name: name, transforms: transforms, template: sectionTemplate)) case "^": // inverted section @@ -182,11 +183,17 @@ extension MustacheTemplate { tokens.append(.text(String(whiteSpaceBefore))) whiteSpaceBefore = "" } - let sectionTokens = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms)) - tokens.append(.invertedSection(name: name, transforms: transforms, template: MustacheTemplate(sectionTokens))) + let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms)) + tokens.append(.invertedSection(name: name, transforms: transforms, template: sectionTemplate)) case "/": // end of section + + // record end of section text + var sectionParser = parser + sectionParser.unsafeRetreat() + sectionParser.unsafeRetreat() + parser.unsafeAdvance() let position = parser.position let (name, transforms) = try parseName(&parser, state: state) @@ -200,7 +207,7 @@ extension MustacheTemplate { tokens.append(.text(String(whiteSpaceBefore))) whiteSpaceBefore = "" } - return tokens + return .init(tokens, text: String(origParser.read(until: sectionParser.position))) case "!": // comment @@ -280,10 +287,10 @@ extension MustacheTemplate { if self.isStandalone(&parser, state: state) { setNewLine = true } - let sectionTokens = try parse(&parser, state: state.withInheritancePartial(sectionName)) + let sectionTemplate = try parse(&parser, state: state.withInheritancePartial(sectionName)) var inherit: [String: MustacheTemplate] = [:] // parse tokens in section to extract inherited sections - for token in sectionTokens { + for token in sectionTemplate.tokens { switch token { case .blockDefinition(let name, let template): inherit[name] = template @@ -311,8 +318,8 @@ extension MustacheTemplate { if standAlone { setNewLine = true } - let sectionTokens = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine)) - tokens.append(.blockDefinition(name: name, template: MustacheTemplate(sectionTokens))) + let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine)) + tokens.append(.blockDefinition(name: name, template: sectionTemplate)) } else { if whiteSpaceBefore.count > 0 { @@ -321,8 +328,8 @@ extension MustacheTemplate { if self.isStandalone(&parser, state: state) { setNewLine = true } else if whiteSpaceBefore.count > 0 {} - let sectionTokens = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine)) - tokens.append(.blockExpansion(name: name, default: MustacheTemplate(sectionTokens), indentation: String(whiteSpaceBefore))) + let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine)) + tokens.append(.blockExpansion(name: name, default: sectionTemplate, indentation: String(whiteSpaceBefore))) whiteSpaceBefore = "" } @@ -355,7 +362,7 @@ extension MustacheTemplate { guard state.sectionName == nil else { throw Error.expectedSectionEnd } - return tokens + return .init(tokens, text: String(origParser.read(until: parser.position))) } /// read until we hit either the start delimiter of a tag or a newline diff --git a/Sources/Mustache/Template+Render.swift b/Sources/Mustache/Template+Render.swift index 0a358a9..4dd5c7b 100644 --- a/Sources/Mustache/Template+Render.swift +++ b/Sources/Mustache/Template+Render.swift @@ -54,6 +54,8 @@ extension MustacheTemplate { return template.render(context: context) } else if let renderable = child as? MustacheCustomRenderable { return context.contentType.escapeText(renderable.renderText) + } else if let lambda = child as? MustacheLambda { + return self.renderLambda(lambda, parameter: "", context: context) } else { return context.contentType.escapeText(String(describing: child)) } @@ -63,6 +65,8 @@ extension MustacheTemplate { if let child = getChild(named: variable, transforms: transforms, context: context) { if let renderable = child as? MustacheCustomRenderable { return renderable.renderText + } else if let lambda = child as? MustacheLambda { + return self.renderUnescapedLambda(lambda, parameter: "", context: context) } else { return String(describing: child) } @@ -70,6 +74,9 @@ extension MustacheTemplate { case .section(let variable, let transforms, let template): let child = self.getChild(named: variable, transforms: transforms, context: context) + if let lambda = child as? MustacheLambda { + return self.renderUnescapedLambda(lambda, parameter: template.text, context: context) + } return self.renderSection(child, with: template, context: context) case .invertedSection(let variable, let transforms, let template): @@ -144,8 +151,6 @@ extension MustacheTemplate { return array.renderSection(with: template, context: context) case let bool as Bool: return bool ? template.render(context: context) : "" - case let lambda as MustacheLambda: - return lambda.run(context.stack.last!, template) case let null as MustacheCustomRenderable where null.isNull == true: return "" case .some(let value): @@ -176,19 +181,67 @@ extension MustacheTemplate { } } + func renderLambda(_ lambda: MustacheLambda, parameter: String, context: MustacheContext) -> String { + var lambda = lambda + while true { + guard let result = lambda(parameter) else { return "" } + if let string = result as? String { + do { + let newTemplate = try MustacheTemplate(string: context.contentType.escapeText(string)) + return self.renderSection(context.stack.last, with: newTemplate, context: context) + } catch { + return "" + } + } else if let lambda2 = result as? MustacheLambda { + lambda = lambda2 + continue + } else { + return context.contentType.escapeText(String(describing: result)) + } + } + } + + func renderUnescapedLambda(_ lambda: MustacheLambda, parameter: String, context: MustacheContext) -> String { + var lambda = lambda + while true { + guard let result = lambda(parameter) else { return "" } + if let string = result as? String { + do { + let newTemplate = try MustacheTemplate(string: string) + return self.renderSection(context.stack.last, with: newTemplate, context: context) + } catch { + return "" + } + } else if let lambda2 = result as? MustacheLambda { + lambda = lambda2 + continue + } else { + return String(describing: result) + } + } + } + /// Get child object from variable name func getChild(named name: String, transforms: [String], context: MustacheContext) -> Any? { func _getImmediateChild(named name: String, from object: Any) -> Any? { - if let customBox = object as? MustacheParent { - return customBox.child(named: name) - } else { - let mirror = Mirror(reflecting: object) - return mirror.getValue(forKey: name) - } + let object = { + if let customBox = object as? MustacheParent { + return customBox.child(named: name) + } else { + let mirror = Mirror(reflecting: object) + return mirror.getValue(forKey: name) + } + }() + return object } func _getChild(named names: ArraySlice, from object: Any) -> Any? { guard let name = names.first else { return object } + var object = object + if let lambda = object as? MustacheLambda { + guard let result = lambda("") else { return nil } + object = result + } guard let childObject = _getImmediateChild(named: name, from: object) else { return nil } let names2 = names.dropFirst() return _getChild(named: names2, from: childObject) diff --git a/Sources/Mustache/Template.swift b/Sources/Mustache/Template.swift index 72574a2..5a72e54 100644 --- a/Sources/Mustache/Template.swift +++ b/Sources/Mustache/Template.swift @@ -13,12 +13,14 @@ //===----------------------------------------------------------------------===// /// Class holding Mustache template -public struct MustacheTemplate: Sendable { +public struct MustacheTemplate: Sendable, CustomStringConvertible { /// Initialize template /// - Parameter string: Template text /// - Throws: MustacheTemplate.Error public init(string: String) throws { - self.tokens = try Self.parse(string) + let template = try Self.parse(string) + self.tokens = template.tokens + self.text = string self.filename = nil } @@ -54,12 +56,15 @@ public struct MustacheTemplate: Sendable { return self.render(context: .init(object, library: library)) } - internal init(_ tokens: [Token]) { + internal init(_ tokens: [Token], text: String) { self.tokens = tokens self.filename = nil + self.text = text } - enum Token: Sendable { + public var description: String { self.text } + + enum Token: Sendable /* , CustomStringConvertible */ { case text(String) case variable(name: String, transforms: [String] = []) case unescapedVariable(name: String, transforms: [String] = []) @@ -73,5 +78,6 @@ public struct MustacheTemplate: Sendable { } var tokens: [Token] + let text: String let filename: String? } diff --git a/Tests/MustacheTests/SpecTests.swift b/Tests/MustacheTests/SpecTests.swift index de2e33d..122a463 100644 --- a/Tests/MustacheTests/SpecTests.swift +++ b/Tests/MustacheTests/SpecTests.swift @@ -63,7 +63,7 @@ final class MustacheSpecTests: XCTestCase { struct Test: Decodable { let name: String let desc: String - let data: AnyDecodable + var data: AnyDecodable let partials: [String: String]? let template: String let expected: String @@ -155,6 +155,49 @@ final class MustacheSpecTests: XCTestCase { print(-date.timeIntervalSinceNow) } + func testLambdaSpec() async throws { + var g = 0 + let lambdaMap = [ + "Interpolation": MustacheLambda { "world" }, + "Interpolation - Expansion": MustacheLambda { "{{planet}}" }, + "Interpolation - Alternate Delimiters": MustacheLambda { "|planet| => {{planet}}" }, + "Interpolation - Multiple Calls": MustacheLambda { return MustacheLambda { g += 1; return g }}, + "Escaping": MustacheLambda { ">" }, + "Section": MustacheLambda { text in text == "{{x}}" ? "yes" : "no" }, + "Section - Expansion": MustacheLambda { text in text + "{{planet}}" + text }, + // Not going to bother implementing this requires pushing alternate delimiters through the context + // "Section - Alternate Delimiters": MustacheLambda { text in return text + "{{planet}} => |planet|" + text }, + "Section - Multiple Calls": MustacheLambda { text in "__" + text + "__" }, + "Inverted Section": MustacheLambda { false }, + ] + let url = URL(string: "https://raw.githubusercontent.com/mustache/spec/master/specs/~lambdas.json")! + #if compiler(>=6.0) + let (data, _) = try await URLSession.shared.data(from: url) + #else + let data = try Data(contentsOf: url) + #endif + let spec = try JSONDecoder().decode(Spec.self, from: data) + // edit spec and replace lambda with Swift lambda + let editedSpecTests = spec.tests.compactMap { test -> Spec.Test? in + var test = test + var newTestData: [String: Any] = [:] + guard let dictionary = test.data.value as? [String: Any] else { return nil } + for values in dictionary { + newTestData[values.key] = values.value + } + guard let lambda = lambdaMap[test.name] else { return nil } + newTestData["lambda"] = lambda + test.data = .init(newTestData) + return test + } + + let date = Date() + for test in editedSpecTests { + XCTAssertNoThrow(try test.run()) + } + print(-date.timeIntervalSinceNow) + } + func testCommentsSpec() async throws { try await self.testSpec(name: "comments") } diff --git a/Tests/MustacheTests/TemplateParserTests.swift b/Tests/MustacheTests/TemplateParserTests.swift index 8312535..1561fce 100644 --- a/Tests/MustacheTests/TemplateParserTests.swift +++ b/Tests/MustacheTests/TemplateParserTests.swift @@ -28,12 +28,12 @@ final class TemplateParserTests: XCTestCase { func testSection() throws { let template = try MustacheTemplate(string: "test {{#section}}text{{/section}}") - XCTAssertEqual(template.tokens, [.text("test "), .section(name: "section", template: .init([.text("text")]))]) + XCTAssertEqual(template.tokens, [.text("test "), .section(name: "section", template: .init([.text("text")], text: "text"))]) } func testInvertedSection() throws { let template = try MustacheTemplate(string: "test {{^section}}text{{/section}}") - XCTAssertEqual(template.tokens, [.text("test "), .invertedSection(name: "section", template: .init([.text("text")]))]) + XCTAssertEqual(template.tokens, [.text("test "), .invertedSection(name: "section", template: .init([.text("text")], text: "text"))]) } func testComment() throws { diff --git a/Tests/MustacheTests/TemplateRendererTests.swift b/Tests/MustacheTests/TemplateRendererTests.swift index 8e85d58..eb233b7 100644 --- a/Tests/MustacheTests/TemplateRendererTests.swift +++ b/Tests/MustacheTests/TemplateRendererTests.swift @@ -140,7 +140,7 @@ final class TemplateRendererTests: XCTestCase { } /// variables - func testMustacheManualExample1() throws { + func testMustacheManualVariables() throws { let template = try MustacheTemplate(string: """ Hello {{name}} You have just won {{value}} dollars! @@ -157,8 +157,8 @@ final class TemplateRendererTests: XCTestCase { """) } - /// test esacped and unescaped text - func testMustacheManualExample2() throws { + /// test escaped and unescaped text + func testMustacheManualEscapedText() throws { let template = try MustacheTemplate(string: """ *{{name}} *{{age}} @@ -174,8 +174,71 @@ final class TemplateRendererTests: XCTestCase { """) } + /// test dotted names + func test_MustacheManualDottedNames() throws { + let template = try MustacheTemplate(string: """ + * {{client.name}} + * {{age}} + * {{client.company.name}} + * {{{company.name}}} + """) + let object: [String: Any] = [ + "client": ( + name: "Chris & Friends", + age: 50 + ), + "company": [ + "name": "GitHub", + ], + ] + XCTAssertEqual(template.render(object), """ + * Chris & Friends + * + * + * GitHub + """) + } + + /// test implicit operator + func testMustacheManualImplicitOperator() throws { + let template = try MustacheTemplate(string: """ + * {{.}} + """) + let object = "Hello!" + XCTAssertEqual(template.render(object), """ + * Hello! + """) + } + + /// test lambda + func test_MustacheManualLambda() throws { + let template = try MustacheTemplate(string: """ + * {{time.hour}} + * {{today}} + """) + let object: [String: Any] = [ + "year": 1970, + "month": 1, + "day": 1, + "time": MustacheLambda { _ in + return ( + hour: 0, + minute: 0, + second: 0 + ) + }, + "today": MustacheLambda { _ in + return "{{year}}-{{month}}-{{day}}" + }, + ] + XCTAssertEqual(template.render(object), """ + * 0 + * 1970-1-1 + """) + } + /// test boolean - func testMustacheManualExample3() throws { + func testMustacheManualSectionFalse() throws { let template = try MustacheTemplate(string: """ Shown. {{#person}} @@ -190,7 +253,7 @@ final class TemplateRendererTests: XCTestCase { } /// test non-empty lists - func testMustacheManualExample4() throws { + func testMustacheManualSectionList() throws { let template = try MustacheTemplate(string: """ {{#repo}} {{name}} @@ -205,13 +268,29 @@ final class TemplateRendererTests: XCTestCase { """) } + /// test non-empty lists + func testMustacheManualSectionList2() throws { + let template = try MustacheTemplate(string: """ + {{#repo}} + {{.}} + {{/repo}} + """) + let object: [String: Any] = ["repo": ["resque", "hub", "rip"]] + XCTAssertEqual(template.render(object), """ + resque + hub + rip + + """) + } + /// test lambdas - func testMustacheManualExample5() throws { + func testMustacheManualSectionLambda() throws { let template = try MustacheTemplate(string: """ {{#wrapped}}{{name}} is awesome.{{/wrapped}} """) - func wrapped(object: Any, template: MustacheTemplate) -> String { - return "\(template.render(object))" + func wrapped(_ s: String) -> Any? { + return "\(s)" } let object: [String: Any] = ["name": "Willy", "wrapped": MustacheLambda(wrapped)] XCTAssertEqual(template.render(object), """ @@ -220,7 +299,7 @@ final class TemplateRendererTests: XCTestCase { } /// test setting context object - func testMustacheManualExample6() throws { + func testMustacheManualContextObject() throws { let template = try MustacheTemplate(string: """ {{#person?}} Hi {{name}}! @@ -234,7 +313,7 @@ final class TemplateRendererTests: XCTestCase { } /// test inverted sections - func testMustacheManualExample7() throws { + func testMustacheManualInvertedSection() throws { let template = try MustacheTemplate(string: """ {{#repo}} {{name}} @@ -251,7 +330,7 @@ final class TemplateRendererTests: XCTestCase { } /// test comments - func testMustacheManualExample8() throws { + func testMustacheManualComment() throws { let template = try MustacheTemplate(string: """

Today{{! ignore me }}.

""")