From 35d52603e246e5c18fc9c9eafc47634f6e205255 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Mon, 22 Mar 2021 12:02:22 +0000 Subject: [PATCH] Template inheritance (#9) * Move all context variables into HBMustacheContext * Add support for reading inherited sections * Render inherited tokens * Test inheritance spec, fix two minor issues * fix warning * swift format --- Sources/HummingbirdMustache/Context.swift | 57 +++++++++++++++++ Sources/HummingbirdMustache/Library.swift | 10 +++ Sources/HummingbirdMustache/Sequence.swift | 33 ++++------ .../HummingbirdMustache/Template+Parser.swift | 53 +++++++++++++++- .../HummingbirdMustache/Template+Render.swift | 61 +++++++++++-------- Sources/HummingbirdMustache/Template.swift | 9 ++- .../PartialTests.swift | 47 ++++++++++++++ .../HummingbirdMustacheTests/SpecTests.swift | 19 ++++-- .../TemplateParserTests.swift | 2 +- 9 files changed, 232 insertions(+), 59 deletions(-) create mode 100644 Sources/HummingbirdMustache/Context.swift diff --git a/Sources/HummingbirdMustache/Context.swift b/Sources/HummingbirdMustache/Context.swift new file mode 100644 index 0000000..ed19d57 --- /dev/null +++ b/Sources/HummingbirdMustache/Context.swift @@ -0,0 +1,57 @@ +struct HBMustacheContext { + let stack: [Any] + let sequenceContext: HBMustacheSequenceContext? + let indentation: String? + let inherited: [String: HBMustacheTemplate]? + + init(_ object: Any) { + self.stack = [object] + self.sequenceContext = nil + self.indentation = nil + self.inherited = nil + } + + private init( + stack: [Any], + sequenceContext: HBMustacheSequenceContext?, + indentation: String?, + inherited: [String: HBMustacheTemplate]? + ) { + self.stack = stack + self.sequenceContext = sequenceContext + self.indentation = indentation + self.inherited = inherited + } + + func withObject(_ object: Any) -> HBMustacheContext { + var stack = self.stack + stack.append(object) + return .init(stack: stack, sequenceContext: nil, indentation: self.indentation, inherited: self.inherited) + } + + func withPartial(indented: String?, inheriting: [String: HBMustacheTemplate]?) -> HBMustacheContext { + let indentation: String? + if let indented = indented { + indentation = (self.indentation ?? "") + indented + } else { + indentation = self.indentation + } + let inherits: [String: HBMustacheTemplate]? + if let inheriting = inheriting { + if let originalInherits = self.inherited { + inherits = originalInherits.merging(inheriting) { value, _ in value } + } else { + inherits = inheriting + } + } else { + inherits = self.inherited + } + return .init(stack: self.stack, sequenceContext: nil, indentation: indentation, inherited: inherits) + } + + func withSequence(_ object: Any, sequenceContext: HBMustacheSequenceContext) -> HBMustacheContext { + var stack = self.stack + stack.append(object) + return .init(stack: stack, sequenceContext: sequenceContext, indentation: self.indentation, inherited: self.inherited) + } +} diff --git a/Sources/HummingbirdMustache/Library.swift b/Sources/HummingbirdMustache/Library.swift index 07e00e8..b5334ea 100644 --- a/Sources/HummingbirdMustache/Library.swift +++ b/Sources/HummingbirdMustache/Library.swift @@ -30,6 +30,16 @@ public final class HBMustacheLibrary { self.templates[name] = template } + /// Register template under name + /// - Parameters: + /// - mustache: Mustache text + /// - name: Name of template + public func register(_ mustache: String, named name: String) throws { + let template = try HBMustacheTemplate(string: mustache) + template.setLibrary(self) + self.templates[name] = template + } + /// Return template registed with name /// - Parameter name: name to search for /// - Returns: Template diff --git a/Sources/HummingbirdMustache/Sequence.swift b/Sources/HummingbirdMustache/Sequence.swift index 207f06a..1e0b78d 100644 --- a/Sources/HummingbirdMustache/Sequence.swift +++ b/Sources/HummingbirdMustache/Sequence.swift @@ -1,46 +1,39 @@ /// Protocol for objects that can be rendered as a sequence in Mustache -public protocol HBMustacheSequence { +protocol HBMustacheSequence { /// Render section using template - func renderSection(with template: HBMustacheTemplate, stack: [Any]) -> String + func renderSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String /// Render inverted section using template - func renderInvertedSection(with template: HBMustacheTemplate, stack: [Any]) -> String + func renderInvertedSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String } -public extension Sequence { +extension Sequence { /// Render section using template - func renderSection(with template: HBMustacheTemplate, stack: [Any]) -> String { + func renderSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String { var string = "" - var context = HBMustacheSequenceContext(first: true) + var sequenceContext = HBMustacheSequenceContext(first: true) var iterator = makeIterator() guard var currentObject = iterator.next() else { return "" } while let object = iterator.next() { - var stack = stack - stack.append(currentObject) - string += template.render(stack, context: context) + string += template.render(context: context.withSequence(currentObject, sequenceContext: sequenceContext)) currentObject = object - context.first = false - context.index += 1 + sequenceContext.first = false + sequenceContext.index += 1 } - context.last = true - var stack = stack - stack.append(currentObject) - string += template.render(stack, context: context) + sequenceContext.last = true + string += template.render(context: context.withSequence(currentObject, sequenceContext: sequenceContext)) return string } /// Render inverted section using template - func renderInvertedSection(with template: HBMustacheTemplate, stack: [Any]) -> String { - var stack = stack - stack.append(self) - + func renderInvertedSection(with template: HBMustacheTemplate, context: HBMustacheContext) -> String { var iterator = makeIterator() if iterator.next() == nil { - return template.render(stack) + return template.render(context: context.withObject(self)) } return "" } diff --git a/Sources/HummingbirdMustache/Template+Parser.swift b/Sources/HummingbirdMustache/Template+Parser.swift index 60c3fac..c4b5586 100644 --- a/Sources/HummingbirdMustache/Template+Parser.swift +++ b/Sources/HummingbirdMustache/Template+Parser.swift @@ -16,6 +16,12 @@ extension HBMustacheTemplate { case expectedSectionEnd /// set delimiter tag badly formatted case invalidSetDelimiter + /// cannot apply transform to inherited section + case transformAppliedToInheritanceSection + /// illegal token inside inherit section of partial + case illegalTokenInsideInheritSection + /// text found inside inherit section of partial + case textInsideInheritSection } struct ParserState { @@ -121,6 +127,21 @@ extension HBMustacheTemplate { let sectionTokens = try parse(&parser, state: state.withSectionName(name, method: method)) tokens.append(.invertedSection(name: name, method: method, template: HBMustacheTemplate(sectionTokens))) + case "$": + // inherited section + parser.unsafeAdvance() + let (name, method) = try parseName(&parser, state: state) + // ERROR: can't have methods applied to inherited sections + guard method == nil 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, method: method)) + tokens.append(.inheritedSection(name: name, template: HBMustacheTemplate(sectionTokens))) + case "/": // end of section parser.unsafeAdvance() @@ -174,12 +195,40 @@ extension HBMustacheTemplate { } if self.isStandalone(&parser, state: state) { setNewLine = true - tokens.append(.partial(name, indentation: String(whiteSpaceBefore))) + tokens.append(.partial(name, indentation: String(whiteSpaceBefore), inherits: nil)) } else { - tokens.append(.partial(name, indentation: nil)) + tokens.append(.partial(name, indentation: nil, inherits: nil)) } whiteSpaceBefore = "" + case "<": + // partial with inheritance + parser.unsafeAdvance() + let (name, method) = try parseName(&parser, state: state) + // ERROR: can't have methods applied to inherited sections + guard method == nil else { throw Error.transformAppliedToInheritanceSection } + var indent: String? + if self.isStandalone(&parser, state: state) { + setNewLine = true + } else if whiteSpaceBefore.count > 0 { + indent = String(whiteSpaceBefore) + tokens.append(.text(indent!)) + whiteSpaceBefore = "" + } + let sectionTokens = try parse(&parser, state: state.withSectionName(name, method: method)) + var inherit: [String: HBMustacheTemplate] = [:] + for token in sectionTokens { + switch token { + case .inheritedSection(let name, let template): + inherit[name] = template + case .text: + break + default: + throw Error.illegalTokenInsideInheritSection + } + } + tokens.append(.partial(name, indentation: indent, inherits: inherit)) + case "=": // set delimiter parser.unsafeAdvance() diff --git a/Sources/HummingbirdMustache/Template+Render.swift b/Sources/HummingbirdMustache/Template+Render.swift index 4a62d2d..0b02ce4 100644 --- a/Sources/HummingbirdMustache/Template+Render.swift +++ b/Sources/HummingbirdMustache/Template+Render.swift @@ -6,50 +6,57 @@ extension HBMustacheTemplate { /// - context: Context that render is occurring in. Contains information about position in sequence /// - indentation: indentation of partial /// - Returns: Rendered text - func render(_ stack: [Any], context: HBMustacheSequenceContext? = nil, indentation: String? = nil) -> String { + func render(context: HBMustacheContext) -> String { var string = "" - if let indentation = indentation, indentation != "" { + if let indentation = context.indentation, indentation != "" { for token in tokens { if string.last == "\n" { string += indentation } - string += self.renderToken(token, stack: stack, context: context) + string += self.renderToken(token, context: context) } } else { for token in tokens { - string += self.renderToken(token, stack: stack, context: context) + string += self.renderToken(token, context: context) } } return string } - func renderToken(_ token: Token, stack: [Any], context: HBMustacheSequenceContext? = nil) -> String { + func renderToken(_ token: Token, context: HBMustacheContext) -> String { switch token { case .text(let text): return text case .variable(let variable, let method): - if let child = getChild(named: variable, from: stack, method: method, context: context) { + if let child = getChild(named: variable, method: method, context: context) { if let template = child as? HBMustacheTemplate { - return template.render(stack) + return template.render(context: context) } else { return String(describing: child).htmlEscape() } } case .unescapedVariable(let variable, let method): - if let child = getChild(named: variable, from: stack, method: method, context: context) { + if let child = getChild(named: variable, method: method, context: context) { return String(describing: child) } case .section(let variable, let method, let template): - let child = self.getChild(named: variable, from: stack, method: method, context: context) - return self.renderSection(child, stack: stack, with: template) + let child = self.getChild(named: variable, method: method, context: context) + return self.renderSection(child, with: template, context: context) case .invertedSection(let variable, let method, let template): - let child = self.getChild(named: variable, from: stack, method: method, context: context) - return self.renderInvertedSection(child, stack: stack, with: template) + let child = self.getChild(named: variable, method: method, context: context) + return self.renderInvertedSection(child, with: template, context: context) - case .partial(let name, let indentation): + case .inheritedSection(let name, let template): + if let override = context.inherited?[name] { + return override.render(context: context) + } else { + return template.render(context: context) + } + + case .partial(let name, let indentation, let overrides): if let template = library?.getTemplate(named: name) { - return template.render(stack, indentation: indentation) + return template.render(context: context.withPartial(indented: indentation, inheriting: overrides)) } } return "" @@ -61,16 +68,16 @@ extension HBMustacheTemplate { /// - parent: Current object being rendered /// - template: Template to render with /// - Returns: Rendered text - func renderSection(_ child: Any?, stack: [Any], with template: HBMustacheTemplate) -> String { + func renderSection(_ child: Any?, with template: HBMustacheTemplate, context: HBMustacheContext) -> String { switch child { case let array as HBMustacheSequence: - return array.renderSection(with: template, stack: stack + [array]) + return array.renderSection(with: template, context: context) case let bool as Bool: - return bool ? template.render(stack) : "" + return bool ? template.render(context: context) : "" case let lambda as HBMustacheLambda: - return lambda.run(stack.last!, template) + return lambda.run(context.stack.last!, template) case .some(let value): - return template.render(stack + [value]) + return template.render(context: context.withObject(value)) case .none: return "" } @@ -82,21 +89,21 @@ extension HBMustacheTemplate { /// - parent: Current object being rendered /// - template: Template to render with /// - Returns: Rendered text - func renderInvertedSection(_ child: Any?, stack: [Any], with template: HBMustacheTemplate) -> String { + func renderInvertedSection(_ child: Any?, with template: HBMustacheTemplate, context: HBMustacheContext) -> String { switch child { case let array as HBMustacheSequence: - return array.renderInvertedSection(with: template, stack: stack) + return array.renderInvertedSection(with: template, context: context) case let bool as Bool: - return bool ? "" : template.render(stack) + return bool ? "" : template.render(context: context) case .some: return "" case .none: - return template.render(stack) + return template.render(context: context) } } /// Get child object from variable name - func getChild(named name: String, from stack: [Any], method: String?, context: HBMustacheSequenceContext?) -> Any? { + func getChild(named name: String, method: String?, context: HBMustacheContext) -> Any? { func _getImmediateChild(named name: String, from object: Any) -> Any? { if let customBox = object as? HBMustacheParent { return customBox.child(named: name) @@ -129,12 +136,12 @@ extension HBMustacheTemplate { // the name is split by "." and we use mirror to get the correct child object let child: Any? if name == "." { - child = stack.last! + child = context.stack.last! } else if name == "", method != nil { - child = context + child = context.sequenceContext } else { let nameSplit = name.split(separator: ".").map { String($0) } - child = _getChildInStack(named: nameSplit[...], from: stack) + child = _getChildInStack(named: nameSplit[...], from: context.stack) } // if we want to run a method and the current child can have methods applied to it then // run method on the current child diff --git a/Sources/HummingbirdMustache/Template.swift b/Sources/HummingbirdMustache/Template.swift index f6e36a1..65bc8a9 100644 --- a/Sources/HummingbirdMustache/Template.swift +++ b/Sources/HummingbirdMustache/Template.swift @@ -11,7 +11,7 @@ public final class HBMustacheTemplate { /// - Parameter object: Object to render /// - Returns: Rendered text public func render(_ object: Any) -> String { - self.render([object], context: nil) + self.render(context: .init(object)) } internal init(_ tokens: [Token]) { @@ -22,8 +22,10 @@ public final class HBMustacheTemplate { self.library = library for token in self.tokens { switch token { - case .section(_, _, let template), .invertedSection(_, _, let template): + case .section(_, _, let template), .invertedSection(_, _, let template), .inheritedSection(_, let template): template.setLibrary(library) + case .partial(_, _, let templates): + templates?.forEach { $1.setLibrary(library) } default: break } @@ -36,7 +38,8 @@ public final class HBMustacheTemplate { case unescapedVariable(name: String, method: String? = nil) case section(name: String, method: String? = nil, template: HBMustacheTemplate) case invertedSection(name: String, method: String? = nil, template: HBMustacheTemplate) - case partial(String, indentation: String?) + case inheritedSection(name: String, template: HBMustacheTemplate) + case partial(String, indentation: String?, inherits: [String: HBMustacheTemplate]?) } let tokens: [Token] diff --git a/Tests/HummingbirdMustacheTests/PartialTests.swift b/Tests/HummingbirdMustacheTests/PartialTests.swift index 2ec0f90..4190ada 100644 --- a/Tests/HummingbirdMustacheTests/PartialTests.swift +++ b/Tests/HummingbirdMustacheTests/PartialTests.swift @@ -51,4 +51,51 @@ final class PartialTests: XCTestCase { """) } + + /// test inheritance + func testInheritance() throws { + let library = HBMustacheLibrary() + try library.register( + """ + + {{$title}}Default title{{/title}} + + + """, + named: "header" + ) + try library.register( + """ + + {{$header}}{{/header}} + {{$content}}{{/content}} + + + """, + named: "base" + ) + try library.register( + """ + {{Hello world{{/content}} + {{/base}} + + """, + named: "mypage" + ) + XCTAssertEqual(library.render({}, withTemplate: "mypage")!, """ + + + My page title + +

Hello world

+ + + """) + } } diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index e10e3b4..8fe2b15 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -46,11 +46,6 @@ public extension AnyDecodable { /// Verify implementation against formal standard for Mustache. /// https://github.com/mustache/spec final class MustacheSpecTests: XCTestCase { - func loadSpec(name: String) throws -> Data { - let url = URL(string: "https://raw.githubusercontent.com/mustache/spec/master/specs/\(name).json")! - return try Data(contentsOf: url) - } - struct Spec: Decodable { struct Test: Decodable { let name: String @@ -91,7 +86,12 @@ final class MustacheSpecTests: XCTestCase { } func testSpec(name: String, ignoring: [String] = []) throws { - let data = try loadSpec(name: name) + let url = URL(string: "https://raw.githubusercontent.com/mustache/spec/master/specs/\(name).json")! + try testSpec(url: url, ignoring: ignoring) + } + + func testSpec(url: URL, ignoring: [String] = []) throws { + let data = try Data(contentsOf: url) let spec = try JSONDecoder().decode(Spec.self, from: data) print(spec.overview) @@ -124,4 +124,11 @@ final class MustacheSpecTests: XCTestCase { func testSectionsSpec() throws { try self.testSpec(name: "sections", ignoring: ["Variable test"]) } + + func testInheritanceSpec() throws { + let url = URL( + string: "https://raw.githubusercontent.com/mustache/spec/ab227509e64961943ca374c09c08b63f59da014a/specs/inheritance.json" + )! + try self.testSpec(url: url) + } } diff --git a/Tests/HummingbirdMustacheTests/TemplateParserTests.swift b/Tests/HummingbirdMustacheTests/TemplateParserTests.swift index 43b271c..826a09c 100644 --- a/Tests/HummingbirdMustacheTests/TemplateParserTests.swift +++ b/Tests/HummingbirdMustacheTests/TemplateParserTests.swift @@ -50,7 +50,7 @@ extension HBMustacheTemplate.Token: Equatable { return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 case (.invertedSection(let lhs1, let lhs2, let lhs3), .invertedSection(let rhs1, let rhs2, let rhs3)): return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 - case (.partial(let name1, let indent1), .partial(let name2, let indent2)): + case (.partial(let name1, let indent1, _), .partial(let name2, let indent2, _)): return name1 == name2 && indent1 == indent2 default: return false