Implement Recursive Transforms (#37)

* Implement Recursive Transforms

* Correct test names

* apply suggestions

* format

* add comments

* move the parse function

* refine `parseTransforms()` function

* refinements

* format

* Swift Format again

---------

Co-authored-by: Adam Fowler <adamfowler71@gmail.com>
This commit is contained in:
Mahdi Bahrami
2024-07-14 15:19:16 +03:30
committed by GitHub
parent a66a7d139c
commit 58b9c3b00c
6 changed files with 203 additions and 51 deletions

View File

@@ -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(),

View File

@@ -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 }
func parseTransforms(existing: [Substring]) throws -> (Substring, [Substring]) {
let name = nameParser.read(while: self.sectionNameCharsWithoutBrackets)
switch nameParser.current() {
case ")":
// Transforms are ending
nameParser.unsafeAdvance()
guard nameParser.reachedEnd() else { throw Error.unfinishedName }
return (String(string2), String(string))
// 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))
}
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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
///

View File

@@ -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}}