Fix issues from Inheritance spec (#36)

* Separate inheritance block and expansion

* Catch top level partial definition, and block newlines

* Add testTrailingNewLines to verify output of trailing newlines in partials

* Remove comment

* If block,partial has indentation add indent for first line

* Re-enable full sections spec

* withBlockExpansion

* Get indentation of blocks correct
This commit is contained in:
Adam Fowler
2024-07-15 09:36:15 +01:00
committed by GitHub
parent cc0eaffa06
commit 7689de0a42
6 changed files with 215 additions and 45 deletions

View File

@@ -87,6 +87,23 @@ struct MustacheContext {
)
}
/// return context with indent information for invoking an inheritance block
func withBlockExpansion(indented: String?) -> MustacheContext {
let indentation: String? = if let indented {
(self.indentation ?? "") + indented
} else {
self.indentation
}
return .init(
stack: self.stack,
sequenceContext: nil,
indentation: indentation,
inherited: self.inherited,
contentType: self.contentType,
library: self.library
)
}
/// return context with sequence info and sequence element added to stack
func withSequence(_ object: Any, sequenceContext: MustacheSequenceContext) -> MustacheContext {
var stack = self.stack

View File

@@ -42,23 +42,58 @@ extension MustacheTemplate {
}
struct ParserState {
struct Flags: OptionSet {
let rawValue: Int
init(rawValue: Int) {
self.rawValue = rawValue
}
static var newLine: Self { .init(rawValue: 1 << 0) }
static var isPartialDefinition: Self { .init(rawValue: 1 << 1) }
static var isPartialDefinitionTopLevel: Self { .init(rawValue: 1 << 2) }
}
var sectionName: String?
var sectionTransforms: [String] = []
var newLine: Bool
var flags: Flags
var startDelimiter: String
var endDelimiter: String
var partialDefinitionIndent: Substring?
var newLine: Bool {
get { self.flags.contains(.newLine) }
set {
if newValue {
self.flags.insert(.newLine)
} else {
self.flags.remove(.newLine)
}
}
}
init() {
self.sectionName = nil
self.newLine = true
self.flags = .newLine
self.startDelimiter = "{{"
self.endDelimiter = "}}"
}
func withSectionName(_ name: String, transforms: [String] = []) -> ParserState {
func withSectionName(_ name: String, newLine: Bool, transforms: [String] = []) -> ParserState {
var newValue = self
newValue.sectionName = name
newValue.sectionTransforms = transforms
newValue.flags.remove(.isPartialDefinitionTopLevel)
if !newLine {
newValue.flags.remove(.newLine)
}
return newValue
}
func withInheritancePartial(_ name: String) -> ParserState {
var newValue = self
newValue.sectionName = name
newValue.flags.insert([.newLine, .isPartialDefinition, .isPartialDefinitionTopLevel])
return newValue
}
@@ -66,6 +101,7 @@ extension MustacheTemplate {
var newValue = self
newValue.startDelimiter = start
newValue.endDelimiter = end
newValue.flags.remove(.isPartialDefinitionTopLevel)
return newValue
}
}
@@ -88,7 +124,19 @@ extension MustacheTemplate {
while !parser.reachedEnd() {
// if new line read whitespace
if state.newLine {
whiteSpaceBefore = parser.read(while: Set(" \t"))
let whiteSpace = parser.read(while: Set(" \t"))
// If inside a partial block definition
if state.flags.contains(.isPartialDefinition), !state.flags.contains(.isPartialDefinitionTopLevel) {
// if definition indent has been set then remove it from current whitespace otherwise set the
// indent as this is the first line of the partial definition
if let partialDefinitionIndent = state.partialDefinitionIndent {
whiteSpaceBefore = whiteSpace.dropFirst(partialDefinitionIndent.count)
} else {
state.partialDefinitionIndent = whiteSpace
}
} else {
whiteSpaceBefore = whiteSpace
}
}
let text = try readUntilDelimiterOrNewline(&parser, state: state)
// if we hit a newline add text
@@ -121,7 +169,7 @@ extension MustacheTemplate {
tokens.append(.text(String(whiteSpaceBefore)))
whiteSpaceBefore = ""
}
let sectionTokens = try parse(&parser, state: state.withSectionName(name, transforms: transforms))
let sectionTokens = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms))
tokens.append(.section(name: name, transforms: transforms, template: MustacheTemplate(sectionTokens)))
case "^":
@@ -134,24 +182,9 @@ extension MustacheTemplate {
tokens.append(.text(String(whiteSpaceBefore)))
whiteSpaceBefore = ""
}
let sectionTokens = try parse(&parser, state: state.withSectionName(name, transforms: transforms))
let sectionTokens = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms))
tokens.append(.invertedSection(name: name, transforms: transforms, template: MustacheTemplate(sectionTokens)))
case "$":
// inherited section
parser.unsafeAdvance()
let (name, transforms) = try parseName(&parser, state: state)
// ERROR: can't have transform applied to inherited sections
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, transforms: transforms))
tokens.append(.inheritedSection(name: name, template: MustacheTemplate(sectionTokens)))
case "/":
// end of section
parser.unsafeAdvance()
@@ -215,20 +248,18 @@ extension MustacheTemplate {
// partial with inheritance
parser.unsafeAdvance()
let name = try parsePartialName(&parser, state: state)
var indent: String?
if whiteSpaceBefore.count > 0 {
tokens.append(.text(String(whiteSpaceBefore)))
}
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))
let sectionTokens = try parse(&parser, state: state.withInheritancePartial(name))
var inherit: [String: MustacheTemplate] = [:]
// parse tokens in section to extract inherited sections
for token in sectionTokens {
switch token {
case .inheritedSection(let name, let template):
case .blockDefinition(let name, let template):
inherit[name] = template
case .text:
break
@@ -236,7 +267,34 @@ extension MustacheTemplate {
throw Error.illegalTokenInsideInheritSection
}
}
tokens.append(.partial(name, indentation: indent, inherits: inherit))
tokens.append(.partial(name, indentation: String(whiteSpaceBefore), inherits: inherit))
whiteSpaceBefore = ""
case "$":
// inherited section
parser.unsafeAdvance()
let (name, transforms) = try parseName(&parser, state: state)
// ERROR: can't have transforms applied to inherited sections
guard transforms.isEmpty else { throw Error.transformAppliedToInheritanceSection }
if state.flags.contains(.isPartialDefinitionTopLevel) {
let standAlone = self.isStandalone(&parser, state: state)
if standAlone {
setNewLine = true
}
let sectionTokens = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine))
tokens.append(.blockDefinition(name: name, template: MustacheTemplate(sectionTokens)))
} else {
if whiteSpaceBefore.count > 0 {
tokens.append(.text(String(whiteSpaceBefore)))
}
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)))
whiteSpaceBefore = ""
}
case "=":
// set delimiter

View File

@@ -28,7 +28,8 @@ extension MustacheTemplate {
if let indentation = context.indentation, indentation != "" {
for token in tokens {
let renderedString = self.renderToken(token, context: &context)
if renderedString != "", string.last == "\n" {
// if rendered string is not empty and we are on a new line
if renderedString.count > 0, string.last == "\n" {
string += indentation
}
string += renderedString
@@ -75,11 +76,11 @@ extension MustacheTemplate {
let child = self.getChild(named: variable, transforms: transforms, context: context)
return self.renderInvertedSection(child, with: template, context: context)
case .inheritedSection(let name, let template):
case .blockExpansion(let name, let defaultTemplate, let indented):
if let override = context.inherited?[name] {
return override.render(context: context)
return override.render(context: context.withBlockExpansion(indented: indented))
} else {
return template.render(context: context)
return defaultTemplate.render(context: context.withBlockExpansion(indented: indented))
}
case .partial(let name, let indentation, let overrides):
@@ -89,6 +90,9 @@ extension MustacheTemplate {
case .contentType(let contentType):
context = context.withContentType(contentType)
case .blockDefinition:
fatalError("Should not be rendering block definitions")
}
return ""
}

View File

@@ -38,7 +38,8 @@ public struct MustacheTemplate: Sendable {
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 blockDefinition(name: String, template: MustacheTemplate)
case blockExpansion(name: String, default: MustacheTemplate, indentation: String?)
case partial(String, indentation: String?, inherits: [String: MustacheTemplate]?)
case contentType(MustacheContentType)
}

View File

@@ -21,7 +21,7 @@ final class PartialTests: XCTestCase {
let template = try MustacheTemplate(string: """
<h2>Names</h2>
{{#names}}
{{> user}}
{{> user}}
{{/names}}
""")
let template2 = try MustacheTemplate(string: """
@@ -33,9 +33,9 @@ final class PartialTests: XCTestCase {
let object: [String: Any] = ["names": ["john", "adam", "claire"]]
XCTAssertEqual(library.render(object, withTemplate: "base"), """
<h2>Names</h2>
<strong>john</strong>
<strong>adam</strong>
<strong>claire</strong>
<strong>john</strong>
<strong>adam</strong>
<strong>claire</strong>
""")
}
@@ -62,8 +62,7 @@ final class PartialTests: XCTestCase {
""")
var library = MustacheLibrary()
library.register(template, named: "base")
library.register(template2, named: "user") // , withTemplate: String)// = MustacheLibrary(templates: ["base": template, "user": template2])
library.register(template2, named: "user")
let object: [String: Any] = ["names": ["john", "adam", "claire"]]
XCTAssertEqual(library.render(object, withTemplate: "base"), """
<h2>Names</h2>
@@ -75,6 +74,37 @@ final class PartialTests: XCTestCase {
""")
}
func testTrailingNewLines() throws {
let template1 = try MustacheTemplate(string: """
{{> withNewLine }}
>> {{> withNewLine }}
[ {{> withNewLine }} ]
""")
let template2 = try MustacheTemplate(string: """
{{> withoutNewLine }}
>> {{> withoutNewLine }}
[ {{> withoutNewLine }} ]
""")
let withNewLine = try MustacheTemplate(string: """
{{#things}}{{.}}, {{/things}}
""")
let withoutNewLine = try MustacheTemplate(string: "{{#things}}{{.}}, {{/things}}")
let library = MustacheLibrary(templates: ["base1": template1, "base2": template2, "withNewLine": withNewLine, "withoutNewLine": withoutNewLine])
let object = ["things": [1, 2, 3, 4, 5]]
XCTAssertEqual(library.render(object, withTemplate: "base1"), """
1, 2, 3, 4, 5,
>> 1, 2, 3, 4, 5,
[ 1, 2, 3, 4, 5,
]
""")
XCTAssertEqual(library.render(object, withTemplate: "base2"), """
1, 2, 3, 4, 5, >> 1, 2, 3, 4, 5,
[ 1, 2, 3, 4, 5, ]
""")
}
/// Testing dynamic partials
func testDynamicPartials() throws {
let template = try MustacheTemplate(string: """
@@ -106,7 +136,6 @@ final class PartialTests: XCTestCase {
<head>
<title>{{$title}}Default title{{/title}}</title>
</head>
""",
named: "header"
)
@@ -144,4 +173,32 @@ final class PartialTests: XCTestCase {
""")
}
func testInheritanceIndentation() throws {
var library = MustacheLibrary()
try library.register(
"""
Hi,
{{$block}}{{/block}}
""",
named: "parent"
)
try library.register(
"""
{{<parent}}
{{$block}}
one
two
{{/block}}
{{/parent}}
""",
named: "template"
)
XCTAssertEqual(library.render({}, withTemplate: "template"), """
Hi,
one
two
""")
}
}

View File

@@ -86,7 +86,19 @@ final class MustacheSpecTests: XCTestCase {
func XCTAssertSpecEqual(_ result: String?, _ test: Spec.Test) {
if result != test.expected {
XCTFail("\n\(test.desc)result:\n\(result ?? "nil")\nexpected:\n\(test.expected)")
XCTFail("""
\(test.name)
\(test.desc)
template:
\(test.template)
data:
\(test.data.value)
\(test.partials.map { "partials:\n\($0)" } ?? "")
result:
\(result ?? "nil")
expected:
\(test.expected)
""")
}
}
}
@@ -104,7 +116,6 @@ final class MustacheSpecTests: XCTestCase {
let data = try Data(contentsOf: url)
let spec = try JSONDecoder().decode(Spec.self, from: data)
print(spec.overview)
let date = Date()
for test in spec.tests {
guard !ignoring.contains(test.name) else { continue }
@@ -113,6 +124,23 @@ final class MustacheSpecTests: XCTestCase {
print(-date.timeIntervalSinceNow)
}
func testSpec(name: String, only: [String]) throws {
let url = URL(string: "https://raw.githubusercontent.com/mustache/spec/master/specs/\(name).json")!
try testSpec(url: url, only: only)
}
func testSpec(url: URL, only: [String]) throws {
let data = try Data(contentsOf: url)
let spec = try JSONDecoder().decode(Spec.self, from: data)
let date = Date()
for test in spec.tests {
guard only.contains(test.name) else { continue }
XCTAssertNoThrow(try test.run())
}
print(-date.timeIntervalSinceNow)
}
func testCommentsSpec() throws {
try self.testSpec(name: "comments")
}
@@ -138,7 +166,12 @@ final class MustacheSpecTests: XCTestCase {
}
func testInheritanceSpec() throws {
try XCTSkipIf(true) // inheritance spec has been updated and has added requirements, we don't yet support
try self.testSpec(name: "~inheritance")
try self.testSpec(
name: "~inheritance",
ignoring: [
"Intrinsic indentation",
"Nested block reindentation",
]
)
}
}