Skip to content

Commit

Permalink
Add support for proper lambdas (#48)
Browse files Browse the repository at this point in the history
* Add support for proper lambdas

* Get rid of recursion

Remove renderSectionLambda as I can use renderUnescapedLambda for that.
  • Loading branch information
adam-fowler authored Sep 19, 2024
1 parent 8fba85e commit 933fa3d
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 44 deletions.
12 changes: 9 additions & 3 deletions Sources/Mustache/Lambda.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
///
public struct MustacheLambda {
/// lambda callback
public typealias Callback = (Any, MustacheTemplate) -> String
public typealias Callback = (String) -> Any?

let callback: Callback

Expand All @@ -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)
}
}
9 changes: 9 additions & 0 deletions Sources/Mustache/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<until]
self.position = until
return string
}

/// Read from buffer until we hit a character. Position after this is of the character we were checking for
/// - Parameter until: Character to read until
/// - Throws: .overflow if we hit the end of the buffer before reading character
Expand Down
4 changes: 3 additions & 1 deletion Sources/Mustache/Template+FileSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ extension MustacheTemplate {
let fs = FileManager()
guard let data = fs.contents(atPath: filename) else { return nil }
let string = String(decoding: data, as: Unicode.UTF8.self)
self.tokens = try Self.parse(string)
let template = try Self.parse(string)
self.tokens = template.tokens
self.text = string
self.filename = filename
}
}
35 changes: 21 additions & 14 deletions Sources/Mustache/Template+Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ extension MustacheTemplate {
}

/// parse mustache text to generate a list of tokens
static func parse(_ string: String) throws -> [Token] {
static func parse(_ string: String) throws -> MustacheTemplate {
var parser = Parser(string)
do {
return try self.parse(&parser, state: .init())
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 = ""
}

Expand Down Expand Up @@ -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
Expand Down
69 changes: 61 additions & 8 deletions Sources/Mustache/Template+Render.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -63,13 +65,18 @@ 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)
}
}

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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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<String>, 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)
Expand Down
14 changes: 10 additions & 4 deletions Sources/Mustache/Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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] = [])
Expand All @@ -73,5 +78,6 @@ public struct MustacheTemplate: Sendable {
}

var tokens: [Token]
let text: String
let filename: String?
}
45 changes: 44 additions & 1 deletion Tests/MustacheTests/SpecTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
4 changes: 2 additions & 2 deletions Tests/MustacheTests/TemplateParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 933fa3d

Please sign in to comment.