diff --git a/Sources/Mustache/Parser.swift b/Sources/Mustache/Parser.swift index a3581cf..cb55f6f 100644 --- a/Sources/Mustache/Parser.swift +++ b/Sources/Mustache/Parser.swift @@ -223,7 +223,7 @@ extension Parser { /// Read while character at current position is the one supplied /// - Parameter while: Character to check against - /// - Returns: String read from buffer + /// - Returns: The count of string read from buffer @discardableResult mutating func read(while: Character) -> Int { var count = 0 while !self.reachedEnd(), diff --git a/Sources/Mustache/Template+Parser.swift b/Sources/Mustache/Template+Parser.swift index 2d69e5c..566de74 100644 --- a/Sources/Mustache/Template+Parser.swift +++ b/Sources/Mustache/Template+Parser.swift @@ -25,7 +25,7 @@ extension MustacheTemplate { case sectionCloseNameIncorrect /// tag was badly formatted case unfinishedName - /// was not expecting a section end + /// was expecting a section end case expectedSectionEnd /// set delimiter tag badly formatted case invalidSetDelimiter @@ -43,7 +43,7 @@ extension MustacheTemplate { struct ParserState { var sectionName: String? - var sectionTransform: String? + var sectionTransforms: [String] = [] var newLine: Bool var startDelimiter: String var endDelimiter: String @@ -55,10 +55,10 @@ extension MustacheTemplate { self.endDelimiter = "}}" } - func withSectionName(_ name: String, transform: String? = nil) -> ParserState { + func withSectionName(_ name: String, transforms: [String] = []) -> ParserState { var newValue = self newValue.sectionName = name - newValue.sectionTransform = transform + newValue.sectionTransforms = transforms return newValue } @@ -114,50 +114,50 @@ extension MustacheTemplate { case "#": // section parser.unsafeAdvance() - let (name, transform) = try parseName(&parser, state: state) + let (name, transforms) = try parseName(&parser, state: state) if self.isStandalone(&parser, state: state) { setNewLine = true } else if whiteSpaceBefore.count > 0 { tokens.append(.text(String(whiteSpaceBefore))) whiteSpaceBefore = "" } - let sectionTokens = try parse(&parser, state: state.withSectionName(name, transform: transform)) - tokens.append(.section(name: name, transform: transform, template: MustacheTemplate(sectionTokens))) + let sectionTokens = try parse(&parser, state: state.withSectionName(name, transforms: transforms)) + tokens.append(.section(name: name, transforms: transforms, template: MustacheTemplate(sectionTokens))) case "^": // inverted section parser.unsafeAdvance() - let (name, transform) = try parseName(&parser, state: state) + let (name, transforms) = try parseName(&parser, state: state) if self.isStandalone(&parser, state: state) { setNewLine = true } else if whiteSpaceBefore.count > 0 { tokens.append(.text(String(whiteSpaceBefore))) whiteSpaceBefore = "" } - let sectionTokens = try parse(&parser, state: state.withSectionName(name, transform: transform)) - tokens.append(.invertedSection(name: name, transform: transform, template: MustacheTemplate(sectionTokens))) + let sectionTokens = try parse(&parser, state: state.withSectionName(name, transforms: transforms)) + tokens.append(.invertedSection(name: name, transforms: transforms, template: MustacheTemplate(sectionTokens))) case "$": // inherited section parser.unsafeAdvance() - let (name, transform) = try parseName(&parser, state: state) + let (name, transforms) = try parseName(&parser, state: state) // ERROR: can't have transform applied to inherited sections - guard transform == nil else { throw Error.transformAppliedToInheritanceSection } + guard transforms.isEmpty else { throw Error.transformAppliedToInheritanceSection } if self.isStandalone(&parser, state: state) { setNewLine = true } else if whiteSpaceBefore.count > 0 { tokens.append(.text(String(whiteSpaceBefore))) whiteSpaceBefore = "" } - let sectionTokens = try parse(&parser, state: state.withSectionName(name, transform: transform)) + let sectionTokens = try parse(&parser, state: state.withSectionName(name, transforms: transforms)) tokens.append(.inheritedSection(name: name, template: MustacheTemplate(sectionTokens))) case "/": // end of section parser.unsafeAdvance() let position = parser.position - let (name, transform) = try parseName(&parser, state: state) - guard name == state.sectionName, transform == state.sectionTransform else { + let (name, transforms) = try parseName(&parser, state: state) + guard name == state.sectionName, transforms == state.sectionTransforms else { parser.unsafeSetPosition(position) throw Error.sectionCloseNameIncorrect } @@ -182,9 +182,9 @@ extension MustacheTemplate { whiteSpaceBefore = "" } parser.unsafeAdvance() - let (name, transform) = try parseName(&parser, state: state) + let (name, transforms) = try parseName(&parser, state: state) guard try parser.read("}") else { throw Error.unfinishedName } - tokens.append(.unescapedVariable(name: name, transform: transform)) + tokens.append(.unescapedVariable(name: name, transforms: transforms)) case "&": // unescaped variable @@ -193,8 +193,8 @@ extension MustacheTemplate { whiteSpaceBefore = "" } parser.unsafeAdvance() - let (name, transform) = try parseName(&parser, state: state) - tokens.append(.unescapedVariable(name: name, transform: transform)) + let (name, transforms) = try parseName(&parser, state: state) + tokens.append(.unescapedVariable(name: name, transforms: transforms)) case ">": // partial @@ -258,8 +258,8 @@ extension MustacheTemplate { tokens.append(.text(String(whiteSpaceBefore))) whiteSpaceBefore = "" } - let (name, transform) = try parseName(&parser, state: state) - tokens.append(.variable(name: name, transform: transform)) + let (name, transforms) = try parseName(&parser, state: state) + tokens.append(.variable(name: name, transforms: transforms)) } state.newLine = setNewLine } @@ -296,7 +296,7 @@ extension MustacheTemplate { } /// parse variable name - static func parseName(_ parser: inout Parser, state: ParserState) throws -> (String, String?) { + static func parseName(_ parser: inout Parser, state: ParserState) throws -> (String, [String]) { parser.read(while: \.isWhitespace) let text = String(parser.read(while: self.sectionNameChars)) parser.read(while: \.isWhitespace) @@ -306,16 +306,39 @@ extension MustacheTemplate { var nameParser = Parser(String(text)) let string = nameParser.read(while: self.sectionNameCharsWithoutBrackets) if nameParser.reachedEnd() { - return (text, nil) + return (text, []) } else { // parse function parameter, as we have just parsed a function name guard nameParser.current() == "(" else { throw Error.unfinishedName } nameParser.unsafeAdvance() - let string2 = nameParser.read(while: self.sectionNameCharsWithoutBrackets) - guard nameParser.current() == ")" else { throw Error.unfinishedName } - nameParser.unsafeAdvance() - guard nameParser.reachedEnd() else { throw Error.unfinishedName } - return (String(string2), String(string)) + + func parseTransforms(existing: [Substring]) throws -> (Substring, [Substring]) { + let name = nameParser.read(while: self.sectionNameCharsWithoutBrackets) + switch nameParser.current() { + case ")": + // Transforms are ending + nameParser.unsafeAdvance() + // We need to have a `)` for each transform that we've parsed + guard nameParser.read(while: ")") + 1 == existing.count, + nameParser.reachedEnd() + else { + throw Error.unfinishedName + } + return (name, existing) + case "(": + // Parse the next transform + nameParser.unsafeAdvance() + + var transforms = existing + transforms.append(name) + return try parseTransforms(existing: transforms) + default: + throw Error.unfinishedName + } + } + let (parameterName, transforms) = try parseTransforms(existing: [string]) + + return (String(parameterName), transforms.map(String.init)) } } diff --git a/Sources/Mustache/Template+Render.swift b/Sources/Mustache/Template+Render.swift index c3f657c..b95305c 100644 --- a/Sources/Mustache/Template+Render.swift +++ b/Sources/Mustache/Template+Render.swift @@ -46,8 +46,9 @@ extension MustacheTemplate { switch token { case .text(let text): return text - case .variable(let variable, let transform): - if let child = getChild(named: variable, transform: transform, context: context) { + + case .variable(let variable, let transforms): + if let child = getChild(named: variable, transforms: transforms, context: context) { if let template = child as? MustacheTemplate { return template.render(context: context) } else if let renderable = child as? MustacheCustomRenderable { @@ -56,20 +57,22 @@ extension MustacheTemplate { return context.contentType.escapeText(String(describing: child)) } } - case .unescapedVariable(let variable, let transform): - if let child = getChild(named: variable, transform: transform, context: context) { + + case .unescapedVariable(let variable, let transforms): + if let child = getChild(named: variable, transforms: transforms, context: context) { if let renderable = child as? MustacheCustomRenderable { return renderable.renderText } else { return String(describing: child) } } - case .section(let variable, let transform, let template): - let child = self.getChild(named: variable, transform: transform, context: context) + + case .section(let variable, let transforms, let template): + let child = self.getChild(named: variable, transforms: transforms, context: context) return self.renderSection(child, with: template, context: context) - case .invertedSection(let variable, let transform, let template): - let child = self.getChild(named: variable, transform: transform, context: context) + case .invertedSection(let variable, let transforms, let template): + let child = self.getChild(named: variable, transforms: transforms, context: context) return self.renderInvertedSection(child, with: template, context: context) case .inheritedSection(let name, let template): @@ -135,7 +138,7 @@ extension MustacheTemplate { } /// Get child object from variable name - func getChild(named name: String, transform: String?, context: MustacheContext) -> Any? { + 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) @@ -171,7 +174,7 @@ extension MustacheTemplate { let child: Any? if name == "." { child = context.stack.last! - } else if name == "", transform != nil { + } else if name == "", !transforms.isEmpty { child = context.sequenceContext } else if name.first == "." { let nameSplit = name.split(separator: ".").map { String($0) } @@ -180,14 +183,26 @@ extension MustacheTemplate { let nameSplit = name.split(separator: ".").map { String($0) } child = _getChildInStack(named: nameSplit[...], from: context.stack) } - // if we want to run a transform and the current child can have transforms applied to it then - // run transform on the current child - if let transform { - if let runnable = child as? MustacheTransformable { - return runnable.transform(transform) - } + + // skip transforms if child is already nil + guard var child else { return nil } + + // if we want to run a transform and the current child can have transforms applied to it then + // run transform on the current child + for transform in transforms.reversed() { + if let runnable = child as? MustacheTransformable, + let transformed = runnable.transform(transform) + { + child = transformed + continue + } + + // return nil if transform is unsuccessful or has returned nil + return nil + } + return child } } diff --git a/Sources/Mustache/Template.swift b/Sources/Mustache/Template.swift index 2d608c6..e62e0ee 100644 --- a/Sources/Mustache/Template.swift +++ b/Sources/Mustache/Template.swift @@ -34,10 +34,10 @@ public struct MustacheTemplate: Sendable { enum Token: Sendable { case text(String) - case variable(name: String, transform: String? = nil) - case unescapedVariable(name: String, transform: String? = nil) - case section(name: String, transform: String? = nil, template: MustacheTemplate) - case invertedSection(name: String, transform: String? = nil, template: MustacheTemplate) + case variable(name: String, transforms: [String] = []) + case unescapedVariable(name: String, transforms: [String] = []) + case section(name: String, transforms: [String] = [], template: MustacheTemplate) + case invertedSection(name: String, transforms: [String] = [], template: MustacheTemplate) case inheritedSection(name: String, template: MustacheTemplate) case partial(String, indentation: String?, inherits: [String: MustacheTemplate]?) case contentType(MustacheContentType) diff --git a/Sources/Mustache/Transform.swift b/Sources/Mustache/Transform.swift index f0947af..a6f8f85 100644 --- a/Sources/Mustache/Transform.swift +++ b/Sources/Mustache/Transform.swift @@ -48,7 +48,7 @@ public extension StringProtocol { case "uppercased": return uppercased() case "reversed": - return reversed() + return Substring(self.reversed()) default: return nil } @@ -66,7 +66,7 @@ private protocol ComparableSequence { extension Array: MustacheTransformable { /// Transform Array. /// - /// Transforms available are `first`, `last`, `reversed`, `count` and for arrays + /// Transforms available are `first`, `last`, `reversed`, `count`, `empty` and for arrays /// with comparable elements `sorted`. /// - Parameter name: transform name /// - Returns: Result @@ -102,6 +102,78 @@ extension Array: ComparableSequence where Element: Comparable { } } +extension Set: MustacheTransformable { + /// Transform Set. + /// + /// Transforms available are `count`, `empty` and for sets + /// with comparable elements `sorted`. + /// - Parameter name: transform name + /// - Returns: Result + public func transform(_ name: String) -> Any? { + switch name { + case "count": + return count + case "empty": + return isEmpty + default: + if let comparableSeq = self as? ComparableSequence { + return comparableSeq.comparableTransform(name) + } + return nil + } + } +} + +extension Set: ComparableSequence where Element: Comparable { + func comparableTransform(_ name: String) -> Any? { + switch name { + case "sorted": + return sorted() + default: + return nil + } + } +} + +extension ReversedCollection: MustacheTransformable { + /// Transform ReversedCollection. + /// + /// Transforms available are `first`, `last`, `reversed`, `count`, `empty` and for collections + /// with comparable elements `sorted`. + /// - Parameter name: transform name + /// - Returns: Result + public func transform(_ name: String) -> Any? { + switch name { + case "first": + return first + case "last": + return last + case "reversed": + return reversed() + case "count": + return count + case "empty": + return isEmpty + default: + if let comparableSeq = self as? ComparableSequence { + return comparableSeq.comparableTransform(name) + } + return nil + } + } +} + +extension ReversedCollection: ComparableSequence where Element: Comparable { + func comparableTransform(_ name: String) -> Any? { + switch name { + case "sorted": + return sorted() + default: + return nil + } + } +} + extension Dictionary: MustacheTransformable { /// Transform Dictionary /// diff --git a/Tests/MustacheTests/TransformTests.swift b/Tests/MustacheTests/TransformTests.swift index 26668bc..6736cbe 100644 --- a/Tests/MustacheTests/TransformTests.swift +++ b/Tests/MustacheTests/TransformTests.swift @@ -80,6 +80,48 @@ final class TransformTests: XCTestCase { """) } + func testDoubleSequenceTransformWorks() throws { + let template = try MustacheTemplate(string: """ + {{#repo}} + {{count(reversed(numbers))}} + {{/repo}} + + """) + let object: [String: Any] = ["repo": ["numbers": [1, 2, 3]]] + XCTAssertEqual(template.render(object), """ + 3 + + """) + } + + func testMultipleTransformWorks() throws { + let template = try MustacheTemplate(string: """ + {{#repo}} + {{minusone(plusone(last(reversed(numbers))))}} + {{/repo}} + + """) + let object: [String: Any] = ["repo": ["numbers": [5, 4, 3]]] + XCTAssertEqual(template.render(object), """ + 5 + + """) + } + + func testNestedTransformWorks() throws { + let template = try MustacheTemplate(string: """ + {{#repo}} + {{#uppercased(string)}}{{reversed(.)}}{{/uppercased(string)}} + {{/repo}} + + """) + let object: [String: Any] = ["repo": ["string": "a123a"]] + XCTAssertEqual(template.render(object), """ + A321A + + """) + } + func testEvenOdd() throws { let template = try MustacheTemplate(string: """ {{#repo}}