From a76a02a8dc80a78fed0389b8691b6cb35289fe17 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Mon, 15 Mar 2021 18:57:55 +0000 Subject: [PATCH 01/10] Add Comment spec --- .../HummingbirdMustache/Template+Parser.swift | 6 + .../HummingbirdMustacheTests/SpecTests.swift | 114 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 Tests/HummingbirdMustacheTests/SpecTests.swift diff --git a/Sources/HummingbirdMustache/Template+Parser.swift b/Sources/HummingbirdMustache/Template+Parser.swift index 2c29c07..7a034e7 100644 --- a/Sources/HummingbirdMustache/Template+Parser.swift +++ b/Sources/HummingbirdMustache/Template+Parser.swift @@ -62,11 +62,17 @@ extension HBMustacheTemplate { case "!": parser.unsafeAdvance() _ = try parseComment(&parser) + if parser.current() == "\n" { + parser.unsafeAdvance() + } case ">": parser.unsafeAdvance() let (name, _) = try parseName(&parser) tokens.append(.partial(name)) + if parser.current() == "\n" { + parser.unsafeAdvance() + } default: let (name, method) = try parseName(&parser) diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift new file mode 100644 index 0000000..68d8784 --- /dev/null +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -0,0 +1,114 @@ +import HummingbirdMustache +import XCTest + +/// Mustache spec tests. These are the formal standard for Mustache. More details +/// can be found at https://github.com/mustache/spec + +func test(_ object: Any, _ template: String, _ expected: String) throws { + let template = try HBMustacheTemplate(string: template) + let result = template.render(object) + XCTAssertEqual(result, expected) +} + +//MARK: Comments + +final class SpecCommentsTests: XCTestCase { + + func testInline() throws { + let object = {} + let template = "12345{{! Comment Block! }}67890" + let expected = "1234567890" + try test(object, template, expected) + } + + func testMultiline() throws { + let object = {} + let template = """ + 12345{{! + This is a + multi-line comment... + }}67890 + """ + let expected = "1234567890" + try test(object, template, expected) + } + + func testStandalone() throws { + let object = {} + let template = """ + Begin. + {{! Comment Block! }} + End. + """ + let expected = """ + Begin. + End. + """ + try test(object, template, expected) + } + + func testIndentedStandalone() throws { + let object = {} + let template = """ + Begin. + {{! Comment Block! }} + End. + """ + let expected = """ + Begin. + End. + """ + try test(object, template, expected) + } + + func testStandaloneLineEndings() throws { + let object = {} + let template = "\r\n{{! Standalone Comment }}\r\n" + let expected = "\r\n" + try test(object, template, expected) + } + + func testStandaloneWithoutPreviousLine() throws { + let object = {} + let template = " {{! I'm Still Standalone }}\n!" + let expected = "!" + try test(object, template, expected) + } + + func testStandaloneWithoutNewLine() throws { + let object = {} + let template = "!\n {{! I'm Still Standalone }}" + let expected = "!\n" + try test(object, template, expected) + } + + func testStandaloneMultiLine() throws { + let object = {} + let template = """ + Begin. + {{! + Something's going on here... + }} + End. + """ + let expected = """ + Begin. + End. + """ + try test(object, template, expected) + } + + func testIndentedInline() throws { + let object = {} + let template = " 12 {{! 34 }}\n" + let expected = " 12 \n" + try test(object, template, expected) + } + + func testSurroundingWhitespace() throws { + let object = {} + let template = "12345 {{! Comment Block! }} 67890" + let expected = "12345 67890" + try test(object, template, expected) + } +} From 2d96ca34d8d128be4de5330dbe5ece45df5d88aa Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Mon, 15 Mar 2021 20:34:48 +0000 Subject: [PATCH 02/10] Add fix for ampersand --- Sources/HummingbirdMustache/Template+Parser.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/HummingbirdMustache/Template+Parser.swift b/Sources/HummingbirdMustache/Template+Parser.swift index 7a034e7..7e73237 100644 --- a/Sources/HummingbirdMustache/Template+Parser.swift +++ b/Sources/HummingbirdMustache/Template+Parser.swift @@ -59,6 +59,11 @@ extension HBMustacheTemplate { guard try parser.read("}") else { throw Error.unfinishedName } tokens.append(.unescapedVariable(name: name, method: method)) + case "&": + parser.unsafeAdvance() + let (name, method) = try parseName(&parser) + tokens.append(.unescapedVariable(name: name, method: method)) + case "!": parser.unsafeAdvance() _ = try parseComment(&parser) From 9ddc49b7bc0ae8ac461c17295c580a164dac9b73 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Mon, 15 Mar 2021 20:35:05 +0000 Subject: [PATCH 03/10] Add interpolation tests, start inverted --- .../HummingbirdMustacheTests/SpecTests.swift | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index 68d8784..a8bf802 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -112,3 +112,285 @@ final class SpecCommentsTests: XCTestCase { try test(object, template, expected) } } + +//MARK: Interpolation + +final class SpecInterpolationTests: XCTestCase { + func testNoInterpolation() throws { + let object = {} + let template = "Hello from {Mustache}!" + let expected = "Hello from {Mustache}!" + try test(object, template, expected) + + } + + func testBasicInterpolation() throws { + let object = [ "subject": "world" ] + let template = "Hello, {{subject}}!" + let expected = "Hello, world!" + try test(object, template, expected) + + } + + func testHTMLEscaping() throws { + let object = [ "forbidden": #"& " < >"# ] + let template = "These characters should be HTML escaped: {{forbidden}}" + let expected = #"These characters should be HTML escaped: & " < >"# + try test(object, template, expected) + + } + + func testTripleMustache() throws { + let object = [ "forbidden": #"& " < >"# ] + let template = "These characters should not be HTML escaped: {{{forbidden}}}" + let expected = #"These characters should not be HTML escaped: & " < >"# + try test(object, template, expected) + + } + + func testAmpersand() throws { + let object = [ "forbidden": #"& " < >"# ] + let template = "These characters should not be HTML escaped: {{&forbidden}}" + let expected = #"These characters should not be HTML escaped: & " < >"# + try test(object, template, expected) + + } + + func testBasicInteger() throws { + let object = [ "mph": 85 ] + let template = #""{{mph}} miles an hour!""# + let expected = #""85 miles an hour!""# + try test(object, template, expected) + } + + func testTripleMustacheInteger() throws { + let object = [ "mph": 85 ] + let template = #""{{{mph}}} miles an hour!""# + let expected = #""85 miles an hour!""# + try test(object, template, expected) + } + + func testBasicDecimal() throws { + let object = [ "power": 1.210 ] + let template = #""{{power}} jiggawatts!""# + let expected = #""1.21 jiggawatts!""# + try test(object, template, expected) + } + + func testTripleMustacheDecimal() throws { + let object = [ "power": 1.210 ] + let template = #""{{{power}}} jiggawatts!""# + let expected = #""1.21 jiggawatts!""# + try test(object, template, expected) + } + + func testAmpersandDecimal() throws { + let object = [ "power": 1.210 ] + let template = #""{{&power}} jiggawatts!""# + let expected = #""1.21 jiggawatts!""# + try test(object, template, expected) + } + + func testContextMiss() throws { + let object = {} + let template = #"I ({{cannot}}) be seen!"# + let expected = #"I () be seen!"# + try test(object, template, expected) + } + + func testTripleMustacheContextMiss() throws { + let object = {} + let template = #"I ({{{cannot}}}) be seen!"# + let expected = #"I () be seen!"# + try test(object, template, expected) + } + + func testAmpersandContextMiss() throws { + let object = {} + let template = #"I ({{&cannot}}) be seen!"# + let expected = #"I () be seen!"# + try test(object, template, expected) + } + + func testDottedName() throws { + let object = ["person": ["name": "Joe"]] + let template = #""{{person.name}}" == "{{#person}}{{name}}{{/person}}""# + let expected = #""Joe" == "Joe""# + try test(object, template, expected) + } + + func testTripleMustacheDottedName() throws { + let object = ["person": ["name": "Joe"]] + let template = #""{{{person.name}}}" == "{{#person}}{{name}}{{/person}}""# + let expected = #""Joe" == "Joe""# + try test(object, template, expected) + } + + func testAmpersandDottedName() throws { + let object = ["person": ["name": "Joe"]] + let template = #""{{&person.name}}" == "{{#person}}{{name}}{{/person}}""# + let expected = #""Joe" == "Joe""# + try test(object, template, expected) + } + + func testArbituaryDepthDottedName() throws { + let object = ["a": ["b": ["c": ["d": ["e": ["name": "Phil"]]]]]] + let template = #""{{a.b.c.d.e.name}}" == "Phil""# + let expected = #""Phil" == "Phil""# + try test(object, template, expected) + } + + func testBrokenChainDottedName() throws { + let object = ["a": ["b": []], "c": ["name": "Jim"]] + let template = #""{{a.b.c.name}}" == """# + let expected = "\"\" == \"\"" + try test(object, template, expected) + } + + func testInitialResolutionDottedName() throws { + let object = [ + "a": ["b": ["c": ["d": ["e": ["name": "Phil"]]]]], + "b": ["c": ["d": ["e": ["name": "Wrong"]]]] + ] + let template = #""{{#a}}{{b.c.d.e.name}}{{/a}}" == "Phil""# + let expected = #""Phil" == "Phil""# + try test(object, template, expected) + } + + func testContextPrecedenceDottedName() throws { + let object = [ + "a": ["b": []], + "b": ["c": "Error"] + ] + let template = #"{{#a}}{{b.c}}{{/a}}"# + let expected = "" + try test(object, template, expected) + } + + func testSurroundingWhitespace() throws { + let object = ["string": "---"] + let template = "| {{string}} |" + let expected = "| --- |" + try test(object, template, expected) + } + + func testTripleMustacheSurroundingWhitespace() throws { + let object = ["string": "---"] + let template = "| {{{string}}} |" + let expected = "| --- |" + try test(object, template, expected) + } + + func testAmpersandSurroundingWhitespace() throws { + let object = ["string": "---"] + let template = "| {{&string}} |" + let expected = "| --- |" + try test(object, template, expected) + } + + func testInterpolationStandalone() throws { + let object = ["string": "---"] + let template = " {{string}}\n" + let expected = " ---\n" + try test(object, template, expected) + } + + func testTripleMustacheStandalone() throws { + let object = ["string": "---"] + let template = " {{{string}}}\n" + let expected = " ---\n" + try test(object, template, expected) + } + + func testAmpersandStandalone() throws { + let object = ["string": "---"] + let template = " {{&string}}\n" + let expected = " ---\n" + try test(object, template, expected) + } + + func testInterpolationWithPadding() throws { + let object = ["string": "---"] + let template = "|{{ string }}|" + let expected = "|---|" + try test(object, template, expected) + + } + + func testTripleMustacheWithPadding() throws { + let object = ["string": "---"] + let template = "|{{{ string }}}|" + let expected = "|---|" + try test(object, template, expected) + + } + + func testAmpersandWithPadding() throws { + let object = ["string": "---"] + let template = "|{{& string }}|" + let expected = "|---|" + try test(object, template, expected) + } +} + +// MARK: Inverted + +final class SpecInvertedTests: XCTestCase { + func testFalse() throws { + let object = ["boolean": false] + let template = #""{{^boolean}}This should be rendered.{{/boolean}}""# + let expected = #""This should be rendered.""# + try test(object, template, expected) + + } + + func testTrue() throws { + let object = ["boolean": true] + let template = #""{{^boolean}}This should not be rendered.{{/boolean}}""# + let expected = "\"\"" + try test(object, template, expected) + + } + + func testContext() throws { + let object = ["context": ["name": "Joe"]] + let template = #""{{^context}}Hi {{name}}.{{/context}}""# + let expected = "\"\"" + try test(object, template, expected) + + } + + func testList() throws { + let object = ["list": [["n": 1], ["n": 2], ["n": 3]]] + let template = #""{{^list}}{{n}}{{/list}}""# + let expected = "\"\"" + try test(object, template, expected) + + } + + func testEmptyList() throws { + let object = ["list": []] + let template = #""{{^list}}Yay lists!{{/list}}""# + let expected = #""Yay lists!""# + try test(object, template, expected) + } + + func testDoubled() throws { + let object: [String: Any] = ["bool": false, "two": "second"] + let template = """ + {{^bool}} + * first + {{/bool}} + * {{two}} + {{^bool}} + * third + {{/bool}} + """ + let expected = """ + * first + * second + * third + """ + try test(object, template, expected) + } +} From 753079fa9da2ab13c30e1fe98f2a1cafea078373 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Tue, 16 Mar 2021 17:16:51 +0000 Subject: [PATCH 04/10] Add inverted tests --- .../HummingbirdMustacheTests/SpecTests.swift | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index a8bf802..fd090c8 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -393,4 +393,134 @@ final class SpecInvertedTests: XCTestCase { """ try test(object, template, expected) } + + func testNestedFalse() throws { + let object = ["bool": false] + let template = #"| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |"# + let expected = #"| A B C D E |"# + try test(object, template, expected) + } + + func testNestedTrue() throws { + let object = ["bool": true] + let template = #"| A {{^bool}}B {{^bool}}C{{/bool}} D{{/bool}} E |"# + let expected = #"| A E |"# + try test(object, template, expected) + } + + func testContextMiss() throws { + let object = {} + let template = #"[{{^missing}}Cannot find key 'missing'!{{/missing}}]"# + let expected = #"[Cannot find key 'missing'!]"# + try test(object, template, expected) + } + + func testDottedNamesTrue() throws { + let object = ["a": ["b": ["c": true]]] + let template = #""{{^a.b.c}}Not Here{{/a.b.c}}" == """# + let expected = "\"\" == \"\"" + try test(object, template, expected) + } + + func testDottedNamesFalse() throws { + let object = ["a": ["b": ["c": false]]] + let template = #""{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here""# + let expected = #""Not Here" == "Not Here""# + try test(object, template, expected) + } + + func testDottedNamesBrokenChain() throws { + let object = ["a": {}] + let template = #""{{^a.b.c}}Not Here{{/a.b.c}}" == "Not Here""# + let expected = #""Not Here" == "Not Here""# + try test(object, template, expected) + } + + func testSurroundingWhitespace() throws { + let object = ["boolean": false] + let template = " | {{^boolean}}\t|\t{{/boolean}} | \n" + let expected = " | \t|\t | \n" + try test(object, template, expected) + + } + + func testInternalWhitespace() throws { + let object = ["boolean": false] + let template = " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n" + let expected = " | \n | \n" + try test(object, template, expected) + + } + + func testIndentedInline() throws { + let object = ["boolean": false] + let template = " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n" + let expected = " NO\n WAY\n" + try test(object, template, expected) + + } + + func testStandaloneLines() throws { + let object = ["boolean": false] + let template = """ + | This Is + {{^boolean}} + | + {{/boolean}} + | A Line + """ + let expected = """ + | This Is + | + | A Line + """ + try test(object, template, expected) + + } + + func testStandaloneIndentedLines() throws { + let object = ["boolean": false] + let template = """ + | This Is + {{^boolean}} + | + {{/boolean}} + | A Line + """ + let expected = """ + | This Is + | + | A Line + """ + try test(object, template, expected) + } + + func testStandaloneLineEndings() throws { + let object = ["boolean": false] + let template = "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|" + let expected = "|\r\n|" + try test(object, template, expected) + + } + + func testStandaloneWithoutPreviousLine() throws { + let object = ["boolean": false] + let template = " {{^boolean}}\n^{{/boolean}}\n/" + let expected = "^\n/" + try test(object, template, expected) + } + + func testStandaloneWithoutNewLine() throws { + let object = ["boolean": false] + let template = "^{{^boolean}}\n/\n {{/boolean}}" + let expected = "^\n/\n" + try test(object, template, expected) + } + + func testPadding() throws { + let object = ["boolean": false] + let template = "|{{^ boolean }}={{/ boolean }}|" + let expected = "|=|" + try test(object, template, expected) + } } From b4d6a518c7a5c69a2db5327b1561a9a9e0fad6fd Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Wed, 17 Mar 2021 17:02:59 +0000 Subject: [PATCH 05/10] Fixed all issues found in spec so far parse has got quite complex might look to simplify --- Sources/HummingbirdMustache/Parser.swift | 13 ++ .../HummingbirdMustache/Template+Parser.swift | 116 ++++++++++++++---- .../MethodTests.swift | 20 +++ .../HummingbirdMustacheTests/SpecTests.swift | 1 + 4 files changed, 129 insertions(+), 21 deletions(-) diff --git a/Sources/HummingbirdMustache/Parser.swift b/Sources/HummingbirdMustache/Parser.swift index 2f7f8c9..87463c0 100644 --- a/Sources/HummingbirdMustache/Parser.swift +++ b/Sources/HummingbirdMustache/Parser.swift @@ -15,6 +15,7 @@ struct HBParser { case unexpected case emptyString case invalidUTF8 + case invalidPosition } /// Create a Parser object @@ -282,6 +283,9 @@ extension HBParser { { unsafeAdvance() } + if startIndex == index { + return subParser(startIndex.. Int { + return index + } + mutating func setPosition(_ index: Int) throws { + guard range.contains(index) else { throw Error.invalidPosition } + guard validateUTF8Character(at: index).0 != nil else { throw Error.invalidPosition } + _setPosition(index) + } } /// extend Parser to conform to Sequence diff --git a/Sources/HummingbirdMustache/Template+Parser.swift b/Sources/HummingbirdMustache/Template+Parser.swift index 7e73237..d15b68d 100644 --- a/Sources/HummingbirdMustache/Template+Parser.swift +++ b/Sources/HummingbirdMustache/Template+Parser.swift @@ -13,73 +13,136 @@ extension HBMustacheTemplate { } /// parse section in mustache text - static func parse(_ parser: inout HBParser, sectionName: String?) throws -> [Token] { + static func parse(_ parser: inout HBParser, sectionName: String?, newLine: Bool = true) throws -> [Token] { var tokens: [Token] = [] + var newLine = newLine + var whiteSpaceBefore: String = "" while !parser.reachedEnd() { - let text = try parser.read(untilString: "{{", throwOnOverflow: false, skipToEnd: true) + // if new line read whitespace + if newLine { + whiteSpaceBefore = parser.read(while: Set(" \t")).string + } + // read until we hit either a newline or "{" + let text = try parser.read(until: Set("{\n"), throwOnOverflow: false) + // if new line append all text read plus newline + if parser.current() == "\n" { + tokens.append(.text(whiteSpaceBefore + text.string + "\n")) + newLine = true + parser.unsafeAdvance() + continue + } else if parser.current() == "{" { + parser.unsafeAdvance() + if parser.current() != "{" { + if text.count > 0 { + tokens.append(.text(whiteSpaceBefore + text.string + "{")) + whiteSpaceBefore = "" + } + continue + } else { + parser.unsafeAdvance() + } + } + if text.count > 0 { - tokens.append(.text(text.string)) + tokens.append(.text(whiteSpaceBefore + text.string)) + whiteSpaceBefore = "" + newLine = false } if parser.reachedEnd() { break } switch parser.current() { case "#": + // section parser.unsafeAdvance() let (name, method) = try parseName(&parser) - if parser.current() == "\n" { - parser.unsafeAdvance() + if newLine && hasLineFinished(&parser) { + newLine = true + if parser.current() == "\n" { + parser.unsafeAdvance() + } + } else if whiteSpaceBefore.count > 0 { + tokens.append(.text(whiteSpaceBefore)) } - let sectionTokens = try parse(&parser, sectionName: name) + let sectionTokens = try parse(&parser, sectionName: name, newLine: newLine) tokens.append(.section(name: name, method: method, template: HBMustacheTemplate(sectionTokens))) case "^": + // inverted section parser.unsafeAdvance() let (name, method) = try parseName(&parser) - if parser.current() == "\n" { - parser.unsafeAdvance() + if newLine && hasLineFinished(&parser) { + newLine = true + if parser.current() == "\n" { + parser.unsafeAdvance() + } + } else if whiteSpaceBefore.count > 0 { + tokens.append(.text(whiteSpaceBefore)) } - let sectionTokens = try parse(&parser, sectionName: name) + let sectionTokens = try parse(&parser, sectionName: name, newLine: newLine) tokens.append(.invertedSection(name: name, method: method, template: HBMustacheTemplate(sectionTokens))) case "/": + // end of section parser.unsafeAdvance() let (name, _) = try parseName(&parser) guard name == sectionName else { throw Error.sectionCloseNameIncorrect } - if parser.current() == "\n" { - parser.unsafeAdvance() + if newLine && hasLineFinished(&parser) { + newLine = true + if parser.current() == "\n" { + parser.unsafeAdvance() + } + } else if whiteSpaceBefore.count > 0 { + tokens.append(.text(whiteSpaceBefore)) } return tokens + case "!": + // comment + parser.unsafeAdvance() + _ = try parseComment(&parser) + if newLine && hasLineFinished(&parser) { + newLine = true + if !parser.reachedEnd() { + parser.unsafeAdvance() + } + } + case "{": + // unescaped variable + if whiteSpaceBefore.count > 0 { + tokens.append(.text(whiteSpaceBefore)) + } parser.unsafeAdvance() let (name, method) = try parseName(&parser) guard try parser.read("}") else { throw Error.unfinishedName } tokens.append(.unescapedVariable(name: name, method: method)) case "&": + // unescaped variable + if whiteSpaceBefore.count > 0 { + tokens.append(.text(whiteSpaceBefore)) + } parser.unsafeAdvance() let (name, method) = try parseName(&parser) tokens.append(.unescapedVariable(name: name, method: method)) - case "!": - parser.unsafeAdvance() - _ = try parseComment(&parser) - if parser.current() == "\n" { - parser.unsafeAdvance() - } - case ">": + // partial + if whiteSpaceBefore.count > 0 { + tokens.append(.text(whiteSpaceBefore)) + } parser.unsafeAdvance() let (name, _) = try parseName(&parser) tokens.append(.partial(name)) - if parser.current() == "\n" { - parser.unsafeAdvance() - } default: + // variable + if whiteSpaceBefore.count > 0 { + tokens.append(.text(whiteSpaceBefore)) + } let (name, method) = try parseName(&parser) tokens.append(.variable(name: name, method: method)) } @@ -118,6 +181,17 @@ extension HBMustacheTemplate { return text.string } + static func hasLineFinished(_ parser: inout HBParser) -> Bool { + var parser2 = parser + if parser.reachedEnd() { return true } + parser2.read(while: Set(" \t\r")) + if parser2.current() == "\n" { + try! parser.setPosition(parser2.getPosition()) + return true + } + return false + } + private static let sectionNameCharsWithoutBrackets = Set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._?") private static let sectionNameChars = Set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._?()") } diff --git a/Tests/HummingbirdMustacheTests/MethodTests.swift b/Tests/HummingbirdMustacheTests/MethodTests.swift index a6e9cd0..5fd7fb6 100644 --- a/Tests/HummingbirdMustacheTests/MethodTests.swift +++ b/Tests/HummingbirdMustacheTests/MethodTests.swift @@ -18,11 +18,28 @@ final class MethodTests: XCTestCase { XCTAssertEqual(template.render(object), "TEST") } + func testNewline() throws { + let template = try HBMustacheTemplate(string: """ + {{#repo}} + {{name}} + {{/repo}} + + """) + let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] + XCTAssertEqual(template.render(object), """ + resque + hub + rip + + """) + } + func testFirstLast() throws { let template = try HBMustacheTemplate(string: """ {{#repo}} {{#first()}}first: {{/}}{{#last()}}last: {{/}}{{ name }} {{/repo}} + """) let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] XCTAssertEqual(template.render(object), """ @@ -38,6 +55,7 @@ final class MethodTests: XCTestCase { {{#repo}} {{#index()}}{{plusone(.)}}{{/}}) {{ name }} {{/repo}} + """) let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] XCTAssertEqual(template.render(object), """ @@ -53,6 +71,7 @@ final class MethodTests: XCTestCase { {{#repo}} {{index()}}) {{#even()}}even {{/}}{{#odd()}}odd {{/}}{{ name }} {{/repo}} + """) let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] XCTAssertEqual(template.render(object), """ @@ -68,6 +87,7 @@ final class MethodTests: XCTestCase { {{#reversed(repo)}} {{ name }} {{/repo}} + """) let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] XCTAssertEqual(template.render(object), """ diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index fd090c8..8a2f0b9 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -390,6 +390,7 @@ final class SpecInvertedTests: XCTestCase { * first * second * third + """ try test(object, template, expected) } From 169a7bbbf4956030b01a1a0a75ec2d781428f91f Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Wed, 17 Mar 2021 18:30:55 +0000 Subject: [PATCH 06/10] Added SpecPartialsTests and fixed issues --- .../HummingbirdMustache/Template+Parser.swift | 41 ++++-- .../HummingbirdMustache/Template+Render.swift | 11 +- Sources/HummingbirdMustache/Template.swift | 2 +- .../PartialTests.swift | 1 + .../HummingbirdMustacheTests/SpecTests.swift | 119 ++++++++++++++++++ .../TemplateParserTests.swift | 4 +- 6 files changed, 164 insertions(+), 14 deletions(-) diff --git a/Sources/HummingbirdMustache/Template+Parser.swift b/Sources/HummingbirdMustache/Template+Parser.swift index d15b68d..a4f6f73 100644 --- a/Sources/HummingbirdMustache/Template+Parser.swift +++ b/Sources/HummingbirdMustache/Template+Parser.swift @@ -32,10 +32,12 @@ extension HBMustacheTemplate { continue } else if parser.current() == "{" { parser.unsafeAdvance() + // if next character is not "{" then is normal text if parser.current() != "{" { if text.count > 0 { tokens.append(.text(whiteSpaceBefore + text.string + "{")) whiteSpaceBefore = "" + newLine = false } continue } else { @@ -43,26 +45,30 @@ extension HBMustacheTemplate { } } + // whatever text we found before the "{{" should be added if text.count > 0 { tokens.append(.text(whiteSpaceBefore + text.string)) whiteSpaceBefore = "" newLine = false } + // have we reached the end of the text if parser.reachedEnd() { break } + var setNewLine = false switch parser.current() { case "#": // section parser.unsafeAdvance() let (name, method) = try parseName(&parser) if newLine && hasLineFinished(&parser) { - newLine = true + setNewLine = true if parser.current() == "\n" { parser.unsafeAdvance() } } else if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) + whiteSpaceBefore = "" } let sectionTokens = try parse(&parser, sectionName: name, newLine: newLine) tokens.append(.section(name: name, method: method, template: HBMustacheTemplate(sectionTokens))) @@ -72,12 +78,13 @@ extension HBMustacheTemplate { parser.unsafeAdvance() let (name, method) = try parseName(&parser) if newLine && hasLineFinished(&parser) { - newLine = true + setNewLine = true if parser.current() == "\n" { parser.unsafeAdvance() } } else if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) + whiteSpaceBefore = "" } let sectionTokens = try parse(&parser, sectionName: name, newLine: newLine) tokens.append(.invertedSection(name: name, method: method, template: HBMustacheTemplate(sectionTokens))) @@ -90,12 +97,13 @@ extension HBMustacheTemplate { throw Error.sectionCloseNameIncorrect } if newLine && hasLineFinished(&parser) { - newLine = true + setNewLine = true if parser.current() == "\n" { parser.unsafeAdvance() } } else if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) + whiteSpaceBefore = "" } return tokens @@ -104,7 +112,7 @@ extension HBMustacheTemplate { parser.unsafeAdvance() _ = try parseComment(&parser) if newLine && hasLineFinished(&parser) { - newLine = true + setNewLine = true if !parser.reachedEnd() { parser.unsafeAdvance() } @@ -114,6 +122,7 @@ extension HBMustacheTemplate { // unescaped variable if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) + whiteSpaceBefore = "" } parser.unsafeAdvance() let (name, method) = try parseName(&parser) @@ -124,6 +133,7 @@ extension HBMustacheTemplate { // unescaped variable if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) + whiteSpaceBefore = "" } parser.unsafeAdvance() let (name, method) = try parseName(&parser) @@ -131,21 +141,38 @@ extension HBMustacheTemplate { case ">": // partial + parser.unsafeAdvance() + let (name, _) = try parseName(&parser) + /*if newLine && hasLineFinished(&parser) { + setNewLine = true + if parser.current() == "\n" { + parser.unsafeAdvance() + } + }*/ if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) } - parser.unsafeAdvance() - let (name, _) = try parseName(&parser) - tokens.append(.partial(name)) + if newLine && hasLineFinished(&parser) { + setNewLine = true + if parser.current() == "\n" { + parser.unsafeAdvance() + } + tokens.append(.partial(name, indentation: whiteSpaceBefore)) + } else { + tokens.append(.partial(name, indentation: nil)) + } + whiteSpaceBefore = "" default: // variable if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) + whiteSpaceBefore = "" } let (name, method) = try parseName(&parser) tokens.append(.variable(name: name, method: method)) } + newLine = setNewLine } // should never get here if reading section guard sectionName == nil else { diff --git a/Sources/HummingbirdMustache/Template+Render.swift b/Sources/HummingbirdMustache/Template+Render.swift index cb54ce3..870182a 100644 --- a/Sources/HummingbirdMustache/Template+Render.swift +++ b/Sources/HummingbirdMustache/Template+Render.swift @@ -5,9 +5,12 @@ extension HBMustacheTemplate { /// - object: Object /// - context: Context that render is occurring in. Contains information about position in sequence /// - Returns: Rendered text - func render(_ object: Any, context: HBMustacheContext? = nil) -> String { + func render(_ object: Any, context: HBMustacheContext? = nil, indentation: String? = nil) -> String { var string = "" for token in tokens { + if let indentation = indentation, string.last == "\n" { + string += indentation + } switch token { case let .text(text): string += text @@ -31,9 +34,9 @@ extension HBMustacheTemplate { let child = getChild(named: variable, from: object, method: method, context: context) string += renderInvertedSection(child, parent: object, with: template) - case let .partial(name): - if let text = library?.render(object, withTemplate: name) { - string += text + case let .partial(name, indentation): + if let template = library?.getTemplate(named: name) { + string += template.render(object, indentation: indentation) } } } diff --git a/Sources/HummingbirdMustache/Template.swift b/Sources/HummingbirdMustache/Template.swift index d0f23e8..2a63ca0 100644 --- a/Sources/HummingbirdMustache/Template.swift +++ b/Sources/HummingbirdMustache/Template.swift @@ -36,7 +36,7 @@ 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) + case partial(String, indentation: String?) } let tokens: [Token] diff --git a/Tests/HummingbirdMustacheTests/PartialTests.swift b/Tests/HummingbirdMustacheTests/PartialTests.swift index 73fa726..2ec0f90 100644 --- a/Tests/HummingbirdMustacheTests/PartialTests.swift +++ b/Tests/HummingbirdMustacheTests/PartialTests.swift @@ -13,6 +13,7 @@ final class PartialTests: XCTestCase { """) let template2 = try HBMustacheTemplate(string: """ {{.}} + """) library.register(template, named: "base") library.register(template2, named: "user") diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index 8a2f0b9..9157b68 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -525,3 +525,122 @@ final class SpecInvertedTests: XCTestCase { try test(object, template, expected) } } + +// MARK: Partials + +final class SpecPartialsTests: XCTestCase { + func testPartial(_ object: Any, _ template: String, _ partials: [String: String], _ expected: String) throws { + let library = HBMustacheLibrary() + let template = try HBMustacheTemplate(string: template) + library.register(template, named: "template") + for (key, value) in partials { + let template = try HBMustacheTemplate(string: value) + library.register(template, named: key) + } + let result = library.render(object, withTemplate: "template") + XCTAssertEqual(result, expected) + } + + func testBasic() throws { + let object = {} + let template = #""{{>text}}""# + let partial = "from partial" + let expected = #""from partial""# + try testPartial(object, template, ["text": partial], expected) + } + + func testFailedLookup() throws { + let object = {} + let template = #""{{>text}}""# + let expected = "\"\"" + try testPartial(object, template, [:], expected) + } + + func testContext() throws { + let object = ["text": "content"] + let template = #""{{>partial}}""# + let partial = "*{{text}}*" + let expected = #""*content*""# + try testPartial(object, template, ["partial": partial], expected) + } + + func testRecursion() throws { + let object: [String: Any] = ["content": "X", "nodes": [["content": "Y", "nodes": []]]] + let template = #"{{>node}}"# + let partial = "{{content}}<{{#nodes}}{{>node}}{{/nodes}}>" + let expected = #"X>"# + try testPartial(object, template, ["node": partial], expected) + } + + func testSurroundingWhitespace() throws { + let object = {} + let template = "| {{>partial}} |" + let partial = "\t|\t" + let expected = "| \t|\t |" + try testPartial(object, template, ["partial": partial], expected) + } + + func testInlineIdention() throws { + let object = ["data": "|"] + let template = " {{data}} {{> partial}}\n" + let partial = ">\n>" + let expected = " | >\n>\n" + try testPartial(object, template, ["partial": partial], expected) + } + + func testStandaloneLineEndings() throws { + let object = {} + let template = "|\r\n{{>partial}}\r\n|" + let partial = ">" + let expected = "|\r\n>|" + try testPartial(object, template, ["partial": partial], expected) + } + + func testStandaloneWithoutPreviousLine() throws { + let object = {} + let template = " {{>partial}}\n>" + let partial = ">\n>" + let expected = " >\n >>" + try testPartial(object, template, ["partial": partial], expected) + } + + func testStandaloneWithoutNewLine() throws { + let object = {} + let template = ">\n {{>partial}}" + let partial = ">\n>" + let expected = ">\n >\n >" + try testPartial(object, template, ["partial": partial], expected) + } + + func testStandaloneIndentation() throws { + let object = ["content": "<\n->"] + let template = """ + \ + {{>partial}} + / + """ + let partial = """ + | + {{{content}}} + | + + """ + let expected = """ + \ + | + < + -> + | + / + """ + try testPartial(object, template, ["partial": partial], expected) + } + + func testPaddingWhitespace() throws { + let object = ["boolean": true ] + let template = "|{{> partial }}|" + let partial = "[]" + let expected = "|[]|" + try testPartial(object, template, ["partial": partial], expected) + } +} diff --git a/Tests/HummingbirdMustacheTests/TemplateParserTests.swift b/Tests/HummingbirdMustacheTests/TemplateParserTests.swift index 5e84d32..e122399 100644 --- a/Tests/HummingbirdMustacheTests/TemplateParserTests.swift +++ b/Tests/HummingbirdMustacheTests/TemplateParserTests.swift @@ -83,8 +83,8 @@ extension HBMustacheTemplate.Token: Equatable { return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 case let (.invertedSection(lhs1, lhs2, lhs3), .invertedSection(rhs1, rhs2, rhs3)): return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 - case let (.partial(name1), .partial(name2)): - return name1 == name2 + case let (.partial(name1, indent1), .partial(name2, indent2)): + return name1 == name2 && indent1 == indent2 default: return false } From b07ad42b49c0e3a24387af170d8ab2a4a0ed8226 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Wed, 17 Mar 2021 22:33:30 +0000 Subject: [PATCH 07/10] Started section spec tests --- .../HummingbirdMustacheTests/SpecTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index 9157b68..9343e68 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -644,3 +644,31 @@ final class SpecPartialsTests: XCTestCase { try testPartial(object, template, ["partial": partial], expected) } } + +// MARK: Sections + +final class SpecSectionTests: XCTestCase { + func testTrue() throws { + let object = ["boolean": true] + let template = #""{{#boolean}}This should be rendered.{{/boolean}}""# + let expected = #""This should be rendered.""# + try test(object, template, expected) + + } + + func testFalse() throws { + let object = ["boolean": false] + let template = #""{{#boolean}}This should not be rendered.{{/boolean}}""# + let expected = "\"\"" + try test(object, template, expected) + + } + + func testContext() throws { + let object = ["context": ["name": "Joe"]] + let template = #""{{#context}}Hi {{name}}.{{/context}}""# + let expected = #""Hi Joe.""# + try test(object, template, expected) + + } +} From 16a2c54be6e39cea674884915bf6ae75cf341f0a Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Thu, 18 Mar 2021 10:32:42 +0000 Subject: [PATCH 08/10] Added sections tests. Allow for searching of context stack --- Sources/HummingbirdMustache/Sequence.swift | 22 +- .../HummingbirdMustache/Template+Render.swift | 57 ++-- Sources/HummingbirdMustache/Template.swift | 2 +- .../HummingbirdMustacheTests/SpecTests.swift | 252 ++++++++++++++++++ 4 files changed, 303 insertions(+), 30 deletions(-) diff --git a/Sources/HummingbirdMustache/Sequence.swift b/Sources/HummingbirdMustache/Sequence.swift index 6105d7d..498fd8b 100644 --- a/Sources/HummingbirdMustache/Sequence.swift +++ b/Sources/HummingbirdMustache/Sequence.swift @@ -2,37 +2,45 @@ /// Protocol for objects that can be rendered as a sequence in Mustache public protocol HBMustacheSequence { /// Render section using template - func renderSection(with template: HBMustacheTemplate) -> String + func renderSection(with template: HBMustacheTemplate, stack: [Any]) -> String /// Render inverted section using template - func renderInvertedSection(with template: HBMustacheTemplate) -> String + func renderInvertedSection(with template: HBMustacheTemplate, stack: [Any]) -> String } public extension Sequence { /// Render section using template - func renderSection(with template: HBMustacheTemplate) -> String { + func renderSection(with template: HBMustacheTemplate, stack: [Any]) -> String { var string = "" var context = HBMustacheContext(first: true) + var iterator = makeIterator() guard var currentObject = iterator.next() else { return "" } while let object = iterator.next() { - string += template.render(currentObject, context: context) + var stack = stack + stack.append(currentObject) + string += template.render(stack, context: context) currentObject = object context.first = false context.index += 1 } context.last = true - string += template.render(currentObject, context: context) + var stack = stack + stack.append(currentObject) + string += template.render(stack, context: context) return string } /// Render inverted section using template - func renderInvertedSection(with template: HBMustacheTemplate) -> String { + func renderInvertedSection(with template: HBMustacheTemplate, stack: [Any]) -> String { + var stack = stack + stack.append(self) + var iterator = makeIterator() if iterator.next() == nil { - return template.render(self) + return template.render(stack) } return "" } diff --git a/Sources/HummingbirdMustache/Template+Render.swift b/Sources/HummingbirdMustache/Template+Render.swift index 870182a..2bbe212 100644 --- a/Sources/HummingbirdMustache/Template+Render.swift +++ b/Sources/HummingbirdMustache/Template+Render.swift @@ -5,7 +5,7 @@ extension HBMustacheTemplate { /// - object: Object /// - context: Context that render is occurring in. Contains information about position in sequence /// - Returns: Rendered text - func render(_ object: Any, context: HBMustacheContext? = nil, indentation: String? = nil) -> String { + func render(_ object: [Any], context: HBMustacheContext? = nil, indentation: String? = nil) -> String { var string = "" for token in tokens { if let indentation = indentation, string.last == "\n" { @@ -28,11 +28,11 @@ extension HBMustacheTemplate { } case let .section(variable, method, template): let child = getChild(named: variable, from: object, method: method, context: context) - string += renderSection(child, parent: object, with: template) + string += renderSection(child, stack: object, with: template) case let .invertedSection(variable, method, template): let child = getChild(named: variable, from: object, method: method, context: context) - string += renderInvertedSection(child, parent: object, with: template) + string += renderInvertedSection(child, stack: object, with: template) case let .partial(name, indentation): if let template = library?.getTemplate(named: name) { @@ -49,16 +49,16 @@ extension HBMustacheTemplate { /// - parent: Current object being rendered /// - template: Template to render with /// - Returns: Rendered text - func renderSection(_ child: Any?, parent: Any, with template: HBMustacheTemplate) -> String { + func renderSection(_ child: Any?, stack: [Any], with template: HBMustacheTemplate) -> String { switch child { case let array as HBMustacheSequence: - return array.renderSection(with: template) + return array.renderSection(with: template, stack: stack + [array]) case let bool as Bool: - return bool ? template.render(parent) : "" + return bool ? template.render(stack) : "" case let lambda as HBMustacheLambda: - return lambda.run(parent, template) + return lambda.run(stack.last!, template) case let .some(value): - return template.render(value) + return template.render(stack + [value]) case .none: return "" } @@ -70,33 +70,46 @@ extension HBMustacheTemplate { /// - parent: Current object being rendered /// - template: Template to render with /// - Returns: Rendered text - func renderInvertedSection(_ child: Any?, parent: Any, with template: HBMustacheTemplate) -> String { + func renderInvertedSection(_ child: Any?, stack: [Any], with template: HBMustacheTemplate) -> String { switch child { case let array as HBMustacheSequence: - return array.renderInvertedSection(with: template) + return array.renderInvertedSection(with: template, stack: stack) case let bool as Bool: - return bool ? "" : template.render(parent) + return bool ? "" : template.render(stack) case .some: return "" case .none: - return template.render(parent) + return template.render(stack) } } /// Get child object from variable name - func getChild(named name: String, from object: Any, method: String?, context: HBMustacheContext?) -> Any? { - func _getChild(named names: ArraySlice, from object: Any) -> Any? { - guard let name = names.first else { return object } - let childObject: Any? + func getChild(named name: String, from stack: [Any], method: String?, context: HBMustacheContext?) -> Any? { + func _getImmediateChild(named name: String, from object: Any) -> Any? { if let customBox = object as? HBMustacheParent { - childObject = customBox.child(named: name) + return customBox.child(named: name) } else { let mirror = Mirror(reflecting: object) - childObject = mirror.getValue(forKey: name) + return mirror.getValue(forKey: name) } - guard childObject != nil else { return nil } + } + + func _getChild(named names: ArraySlice, from object: Any) -> Any? { + guard let name = names.first else { return object } + guard let childObject = _getImmediateChild(named: name, from: object) else { return nil } let names2 = names.dropFirst() - return _getChild(named: names2, from: childObject!) + return _getChild(named: names2, from: childObject) + } + + func _getChildInStack(named names: ArraySlice, from stack: [Any]) -> Any? { + guard let name = names.first else { return stack.last } + for object in stack.reversed() { + if let childObject = _getImmediateChild(named: name, from: object) { + let names2 = names.dropFirst() + return _getChild(named: names2, from: childObject) + } + } + return nil } // work out which object to access. "." means the current object, if the variable name is "" @@ -104,12 +117,12 @@ extension HBMustacheTemplate { // the name is split by "." and we use mirror to get the correct child object let child: Any? if name == "." { - child = object + child = stack.last! } else if name == "", method != nil { child = context } else { let nameSplit = name.split(separator: ".").map { String($0) } - child = _getChild(named: nameSplit[...], from: object) + child = _getChildInStack(named: nameSplit[...], from: 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 2a63ca0..c2d69e8 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 { - render(object, context: nil) + render([object], context: nil) } internal init(_ tokens: [Token]) { diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index 9343e68..584c32a 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -669,6 +669,258 @@ final class SpecSectionTests: XCTestCase { let template = #""{{#context}}Hi {{name}}.{{/context}}""# let expected = #""Hi Joe.""# try test(object, template, expected) + } + func testParentContext() throws { + let object: [String: Any] = ["a": "foo", "b": "wrong", "sec": ["b": "bar"], "c": ["d": "baz"]] + let template = #""{{#sec}}{{a}}, {{b}}, {{c.d}}{{/sec}}""# + let expected = #""foo, bar, baz""# + try test(object, template, expected) + } + + func testVariables() throws { + let object: [String: Any] = ["foo": "bar"] + let template = #""{{#foo}} {{.}} is {{foo}} {{/foo}}""# + let expected = #"" bar is bar ""# + try test(object, template, expected) + } + + func testListContexts() throws { + let object: [String: Any] = ["tops": ["tname": ["upper": "A", "lower": "a"], "middles": ["mname": "1", "bottoms": [["bname": "x"], ["bname": "y"]]]]] + let template = #"{{#tops}}{{#middles}}{{tname.lower}}{{mname}}.{{#bottoms}}{{tname.upper}}{{mname}}{{bname}}.{{/bottoms}}{{/middles}}{{/tops}}"# + let expected = #"a1.A1x.A1y."# + try test(object, template, expected) + } + + func testDeeplyNestedContexts() throws { + let object: [String: Any] = ["a": ["one": 1], "b": ["two": 2], "c": ["three": 3, "d": ["four": 4, "five": 5]]] + let template = """ + {{#a}} + {{one}} + {{#b}} + {{one}}{{two}}{{one}} + {{#c}} + {{one}}{{two}}{{three}}{{two}}{{one}} + {{#d}} + {{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}} + {{#five}} + {{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}} + {{one}}{{two}}{{three}}{{four}}{{.}}6{{.}}{{four}}{{three}}{{two}}{{one}} + {{one}}{{two}}{{three}}{{four}}{{five}}{{four}}{{three}}{{two}}{{one}} + {{/five}} + {{one}}{{two}}{{three}}{{four}}{{three}}{{two}}{{one}} + {{/d}} + {{one}}{{two}}{{three}}{{two}}{{one}} + {{/c}} + {{one}}{{two}}{{one}} + {{/b}} + {{one}} + {{/a}} + + """ + let expected = """ + 1 + 121 + 12321 + 1234321 + 123454321 + 12345654321 + 123454321 + 1234321 + 12321 + 121 + 1 + + """ + try test(object, template, expected) + } + + func testList() throws { + let object: [String: Any] = ["list": [["item": 1], ["item": 2], ["item": 3]]] + let template = #""{{#list}}{{item}}{{/list}}""# + let expected = #""123""# + try test(object, template, expected) + } + + func testEmptyList() throws { + let object: [Any] = [] + let template = #""{{#list}}Yay lists!{{/list}}""# + let expected = "\"\"" + try test(object, template, expected) + } + + func testDoubled() throws { + let object: [String: Any] = ["bool": true, "two": "second"] + let template = """ + {{#bool}} + * first + {{/bool}} + * {{two}} + {{#bool}} + * third + {{/bool}} + + """ + let expected = """ + * first + * second + * third + + """ + try test(object, template, expected) + } + + func testNestedTrue() throws { + let object = ["bool": true] + let template = "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |" + let expected = "| A B C D E |" + try test(object, template, expected) + } + + func testNestedFalse() throws { + let object = ["bool": false] + let template = "| A {{#bool}}B {{#bool}}C{{/bool}} D{{/bool}} E |" + let expected = "| A E |" + try test(object, template, expected) + } + + func testContextMiss() throws { + let object = {} + let template = "[{{#missing}}Found key 'missing'!{{/missing}}]" + let expected = "[]" + try test(object, template, expected) + } + + func testImplicitIteratorString() throws { + let object = ["list": [ "a", "b", "c", "d", "e" ]] + let template = #""{{#list}}({{.}}){{/list}}""# + let expected = #""(a)(b)(c)(d)(e)""# + try test(object, template, expected) + } + + func testImplicitIteratorInteger() throws { + let object = ["list": [ 1, 2, 3, 4, 5 ]] + let template = #""{{#list}}({{.}}){{/list}}""# + let expected = #""(1)(2)(3)(4)(5)""# + try test(object, template, expected) + } + + func testImplicitIteratorDecimal() throws { + let object = ["list": [ 1.1, 2.2, 3.3, 4.4, 5.5 ]] + let template = #""{{#list}}({{.}}){{/list}}""# + let expected = #""(1.1)(2.2)(3.3)(4.4)(5.5)""# + try test(object, template, expected) + } + + func testImplicitIteratorArray() throws { + let object: [String: Any] = ["list": [[ 1, 2, 3], [ "a", "b", "c"]]] + let template = #""{{#list}}({{#.}}{{.}}{{/.}}){{/list}}""# + let expected = #""(123)(abc)""# + try test(object, template, expected) + } + + + func testDottedNameTrue() throws { + let object = ["a": ["b": ["c": true]]] + let template = #""{{#a.b.c}}Here{{/a.b.c}}" == "Here""# + let expected = #""Here" == "Here""# + try test(object, template, expected) + } + + func testDottedNameFalse() throws { + let object = ["a": ["b": ["c": false]]] + let template = #""{{#a.b.c}}Here{{/a.b.c}}" == """# + let expected = "\"\" == \"\"" + try test(object, template, expected) + } + + func testDottedNameBrokenChain() throws { + let object = ["a": []] + let template = #""{{#a.b.c}}Here{{/a.b.c}}" == """# + let expected = "\"\" == \"\"" + try test(object, template, expected) + } + + func testSurroundingWhitespace() throws { + let object = ["boolean": true] + let template = " | {{#boolean}}\t|\t{{/boolean}} | \n" + let expected = " | \t|\t | \n" + try test(object, template, expected) + } + + func testInternalWhitespace() throws { + let object = ["boolean": true] + let template = " | {{#boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n" + let expected = " | \n | \n" + try test(object, template, expected) + } + + func testIndentedInline() throws { + let object = ["boolean": true] + let template = " {{#boolean}}YES{{/boolean}}\n {{#boolean}}GOOD{{/boolean}}\n" + let expected = " YES\n GOOD\n" + try test(object, template, expected) + } + + func testStandaloneLines() throws { + let object = ["boolean": true] + let template = """ + | This Is + {{#boolean}} + | + {{/boolean}} + | A Line + """ + let expected = """ + | This Is + | + | A Line + """ + try test(object, template, expected) + } + + func testIndentedStandaloneLines() throws { + let object = ["boolean": true] + let template = """ + | This Is + {{#boolean}} + | + {{/boolean}} + | A Line + """ + let expected = """ + | This Is + | + | A Line + """ + try test(object, template, expected) + } + + func testStandaloneLineEndings() throws { + let object = ["boolean": true] + let template = "|\r\n{{#boolean}}\r\n{{/boolean}}\r\n|" + let expected = "|\r\n|" + try test(object, template, expected) + } + + func testStandaloneWithoutPreviousLine() throws { + let object = ["boolean": true] + let template = " {{#boolean}}\n#{{/boolean}}\n/" + let expected = "#\n/" + try test(object, template, expected) + } + + func testStandaloneWithoutNewLine() throws { + let object = ["boolean": true] + let template = "#{{#boolean}}\n/\n {{/boolean}}" + let expected = "#\n/\n" + try test(object, template, expected) + } + + func testPadding() throws { + let object = ["boolean": true] + let template = "|{{# boolean }}={{/ boolean }}|" + let expected = "|=|" + try test(object, template, expected) } } From c560bd0fd936dc88ab60d23c05b06913c1906354 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Thu, 18 Mar 2021 10:34:52 +0000 Subject: [PATCH 09/10] swift format --- Sources/HummingbirdMustache/Parser.swift | 5 +- .../HummingbirdMustache/Template+Parser.swift | 22 +++---- .../HummingbirdMustacheTests/SpecTests.swift | 60 +++++++------------ 3 files changed, 34 insertions(+), 53 deletions(-) diff --git a/Sources/HummingbirdMustache/Parser.swift b/Sources/HummingbirdMustache/Parser.swift index 87463c0..33e2509 100644 --- a/Sources/HummingbirdMustache/Parser.swift +++ b/Sources/HummingbirdMustache/Parser.swift @@ -284,7 +284,7 @@ extension HBParser { unsafeAdvance() } if startIndex == index { - return subParser(startIndex.. Int { return index } + mutating func setPosition(_ index: Int) throws { - guard range.contains(index) else { throw Error.invalidPosition } + guard range.contains(index) else { throw Error.invalidPosition } guard validateUTF8Character(at: index).0 != nil else { throw Error.invalidPosition } _setPosition(index) } diff --git a/Sources/HummingbirdMustache/Template+Parser.swift b/Sources/HummingbirdMustache/Template+Parser.swift index a4f6f73..0910d1f 100644 --- a/Sources/HummingbirdMustache/Template+Parser.swift +++ b/Sources/HummingbirdMustache/Template+Parser.swift @@ -61,7 +61,7 @@ extension HBMustacheTemplate { // section parser.unsafeAdvance() let (name, method) = try parseName(&parser) - if newLine && hasLineFinished(&parser) { + if newLine, hasLineFinished(&parser) { setNewLine = true if parser.current() == "\n" { parser.unsafeAdvance() @@ -77,7 +77,7 @@ extension HBMustacheTemplate { // inverted section parser.unsafeAdvance() let (name, method) = try parseName(&parser) - if newLine && hasLineFinished(&parser) { + if newLine, hasLineFinished(&parser) { setNewLine = true if parser.current() == "\n" { parser.unsafeAdvance() @@ -96,7 +96,7 @@ extension HBMustacheTemplate { guard name == sectionName else { throw Error.sectionCloseNameIncorrect } - if newLine && hasLineFinished(&parser) { + if newLine, hasLineFinished(&parser) { setNewLine = true if parser.current() == "\n" { parser.unsafeAdvance() @@ -111,7 +111,7 @@ extension HBMustacheTemplate { // comment parser.unsafeAdvance() _ = try parseComment(&parser) - if newLine && hasLineFinished(&parser) { + if newLine, hasLineFinished(&parser) { setNewLine = true if !parser.reachedEnd() { parser.unsafeAdvance() @@ -143,16 +143,16 @@ extension HBMustacheTemplate { // partial parser.unsafeAdvance() let (name, _) = try parseName(&parser) - /*if newLine && hasLineFinished(&parser) { - setNewLine = true - if parser.current() == "\n" { - parser.unsafeAdvance() - } - }*/ + /* if newLine && hasLineFinished(&parser) { + setNewLine = true + if parser.current() == "\n" { + parser.unsafeAdvance() + } + } */ if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) } - if newLine && hasLineFinished(&parser) { + if newLine, hasLineFinished(&parser) { setNewLine = true if parser.current() == "\n" { parser.unsafeAdvance() diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index 584c32a..c487390 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -10,10 +10,9 @@ func test(_ object: Any, _ template: String, _ expected: String) throws { XCTAssertEqual(result, expected) } -//MARK: Comments +// MARK: Comments final class SpecCommentsTests: XCTestCase { - func testInline() throws { let object = {} let template = "12345{{! Comment Block! }}67890" @@ -98,14 +97,14 @@ final class SpecCommentsTests: XCTestCase { try test(object, template, expected) } - func testIndentedInline() throws { + func testIndentedInline() throws { let object = {} let template = " 12 {{! 34 }}\n" let expected = " 12 \n" try test(object, template, expected) } - func testSurroundingWhitespace() throws { + func testSurroundingWhitespace() throws { let object = {} let template = "12345 {{! Comment Block! }} 67890" let expected = "12345 67890" @@ -113,7 +112,7 @@ final class SpecCommentsTests: XCTestCase { } } -//MARK: Interpolation +// MARK: Interpolation final class SpecInterpolationTests: XCTestCase { func testNoInterpolation() throws { @@ -121,71 +120,66 @@ final class SpecInterpolationTests: XCTestCase { let template = "Hello from {Mustache}!" let expected = "Hello from {Mustache}!" try test(object, template, expected) - } func testBasicInterpolation() throws { - let object = [ "subject": "world" ] + let object = ["subject": "world"] let template = "Hello, {{subject}}!" let expected = "Hello, world!" try test(object, template, expected) - } func testHTMLEscaping() throws { - let object = [ "forbidden": #"& " < >"# ] + let object = ["forbidden": #"& " < >"#] let template = "These characters should be HTML escaped: {{forbidden}}" let expected = #"These characters should be HTML escaped: & " < >"# try test(object, template, expected) - } func testTripleMustache() throws { - let object = [ "forbidden": #"& " < >"# ] + let object = ["forbidden": #"& " < >"#] let template = "These characters should not be HTML escaped: {{{forbidden}}}" let expected = #"These characters should not be HTML escaped: & " < >"# try test(object, template, expected) - } func testAmpersand() throws { - let object = [ "forbidden": #"& " < >"# ] + let object = ["forbidden": #"& " < >"#] let template = "These characters should not be HTML escaped: {{&forbidden}}" let expected = #"These characters should not be HTML escaped: & " < >"# try test(object, template, expected) - } func testBasicInteger() throws { - let object = [ "mph": 85 ] + let object = ["mph": 85] let template = #""{{mph}} miles an hour!""# let expected = #""85 miles an hour!""# try test(object, template, expected) } func testTripleMustacheInteger() throws { - let object = [ "mph": 85 ] + let object = ["mph": 85] let template = #""{{{mph}}} miles an hour!""# let expected = #""85 miles an hour!""# try test(object, template, expected) } func testBasicDecimal() throws { - let object = [ "power": 1.210 ] + let object = ["power": 1.210] let template = #""{{power}} jiggawatts!""# let expected = #""1.21 jiggawatts!""# try test(object, template, expected) } func testTripleMustacheDecimal() throws { - let object = [ "power": 1.210 ] + let object = ["power": 1.210] let template = #""{{{power}}} jiggawatts!""# let expected = #""1.21 jiggawatts!""# try test(object, template, expected) } func testAmpersandDecimal() throws { - let object = [ "power": 1.210 ] + let object = ["power": 1.210] let template = #""{{&power}} jiggawatts!""# let expected = #""1.21 jiggawatts!""# try test(object, template, expected) @@ -250,7 +244,7 @@ final class SpecInterpolationTests: XCTestCase { func testInitialResolutionDottedName() throws { let object = [ "a": ["b": ["c": ["d": ["e": ["name": "Phil"]]]]], - "b": ["c": ["d": ["e": ["name": "Wrong"]]]] + "b": ["c": ["d": ["e": ["name": "Wrong"]]]], ] let template = #""{{#a}}{{b.c.d.e.name}}{{/a}}" == "Phil""# let expected = #""Phil" == "Phil""# @@ -260,7 +254,7 @@ final class SpecInterpolationTests: XCTestCase { func testContextPrecedenceDottedName() throws { let object = [ "a": ["b": []], - "b": ["c": "Error"] + "b": ["c": "Error"], ] let template = #"{{#a}}{{b.c}}{{/a}}"# let expected = "" @@ -314,7 +308,6 @@ final class SpecInterpolationTests: XCTestCase { let template = "|{{ string }}|" let expected = "|---|" try test(object, template, expected) - } func testTripleMustacheWithPadding() throws { @@ -322,7 +315,6 @@ final class SpecInterpolationTests: XCTestCase { let template = "|{{{ string }}}|" let expected = "|---|" try test(object, template, expected) - } func testAmpersandWithPadding() throws { @@ -341,7 +333,6 @@ final class SpecInvertedTests: XCTestCase { let template = #""{{^boolean}}This should be rendered.{{/boolean}}""# let expected = #""This should be rendered.""# try test(object, template, expected) - } func testTrue() throws { @@ -349,7 +340,6 @@ final class SpecInvertedTests: XCTestCase { let template = #""{{^boolean}}This should not be rendered.{{/boolean}}""# let expected = "\"\"" try test(object, template, expected) - } func testContext() throws { @@ -357,7 +347,6 @@ final class SpecInvertedTests: XCTestCase { let template = #""{{^context}}Hi {{name}}.{{/context}}""# let expected = "\"\"" try test(object, template, expected) - } func testList() throws { @@ -365,7 +354,6 @@ final class SpecInvertedTests: XCTestCase { let template = #""{{^list}}{{n}}{{/list}}""# let expected = "\"\"" try test(object, template, expected) - } func testEmptyList() throws { @@ -442,7 +430,6 @@ final class SpecInvertedTests: XCTestCase { let template = " | {{^boolean}}\t|\t{{/boolean}} | \n" let expected = " | \t|\t | \n" try test(object, template, expected) - } func testInternalWhitespace() throws { @@ -450,7 +437,6 @@ final class SpecInvertedTests: XCTestCase { let template = " | {{^boolean}} {{! Important Whitespace }}\n {{/boolean}} | \n" let expected = " | \n | \n" try test(object, template, expected) - } func testIndentedInline() throws { @@ -458,7 +444,6 @@ final class SpecInvertedTests: XCTestCase { let template = " {{^boolean}}NO{{/boolean}}\n {{^boolean}}WAY{{/boolean}}\n" let expected = " NO\n WAY\n" try test(object, template, expected) - } func testStandaloneLines() throws { @@ -476,7 +461,6 @@ final class SpecInvertedTests: XCTestCase { | A Line """ try test(object, template, expected) - } func testStandaloneIndentedLines() throws { @@ -501,7 +485,6 @@ final class SpecInvertedTests: XCTestCase { let template = "|\r\n{{^boolean}}\r\n{{/boolean}}\r\n|" let expected = "|\r\n|" try test(object, template, expected) - } func testStandaloneWithoutPreviousLine() throws { @@ -637,7 +620,7 @@ final class SpecPartialsTests: XCTestCase { } func testPaddingWhitespace() throws { - let object = ["boolean": true ] + let object = ["boolean": true] let template = "|{{> partial }}|" let partial = "[]" let expected = "|[]|" @@ -653,7 +636,6 @@ final class SpecSectionTests: XCTestCase { let template = #""{{#boolean}}This should be rendered.{{/boolean}}""# let expected = #""This should be rendered.""# try test(object, template, expected) - } func testFalse() throws { @@ -661,7 +643,6 @@ final class SpecSectionTests: XCTestCase { let template = #""{{#boolean}}This should not be rendered.{{/boolean}}""# let expected = "\"\"" try test(object, template, expected) - } func testContext() throws { @@ -792,34 +773,33 @@ final class SpecSectionTests: XCTestCase { } func testImplicitIteratorString() throws { - let object = ["list": [ "a", "b", "c", "d", "e" ]] + let object = ["list": ["a", "b", "c", "d", "e"]] let template = #""{{#list}}({{.}}){{/list}}""# let expected = #""(a)(b)(c)(d)(e)""# try test(object, template, expected) } func testImplicitIteratorInteger() throws { - let object = ["list": [ 1, 2, 3, 4, 5 ]] + let object = ["list": [1, 2, 3, 4, 5]] let template = #""{{#list}}({{.}}){{/list}}""# let expected = #""(1)(2)(3)(4)(5)""# try test(object, template, expected) } func testImplicitIteratorDecimal() throws { - let object = ["list": [ 1.1, 2.2, 3.3, 4.4, 5.5 ]] + let object = ["list": [1.1, 2.2, 3.3, 4.4, 5.5]] let template = #""{{#list}}({{.}}){{/list}}""# let expected = #""(1.1)(2.2)(3.3)(4.4)(5.5)""# try test(object, template, expected) } func testImplicitIteratorArray() throws { - let object: [String: Any] = ["list": [[ 1, 2, 3], [ "a", "b", "c"]]] + let object: [String: Any] = ["list": [[1, 2, 3], ["a", "b", "c"]]] let template = #""{{#list}}({{#.}}{{.}}{{/.}}){{/list}}""# let expected = #""(123)(abc)""# try test(object, template, expected) } - func testDottedNameTrue() throws { let object = ["a": ["b": ["c": true]]] let template = #""{{#a.b.c}}Here{{/a.b.c}}" == "Here""# From 9b17bd6827dd85ffb6867ae419cfc525f335063e Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Thu, 18 Mar 2021 10:55:55 +0000 Subject: [PATCH 10/10] Render optimisation --- .../HummingbirdMustache/Template+Parser.swift | 6 -- .../HummingbirdMustache/Template+Render.swift | 76 +++++++++++-------- .../HummingbirdMustacheTests/SpecTests.swift | 4 +- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/Sources/HummingbirdMustache/Template+Parser.swift b/Sources/HummingbirdMustache/Template+Parser.swift index 0910d1f..acdf964 100644 --- a/Sources/HummingbirdMustache/Template+Parser.swift +++ b/Sources/HummingbirdMustache/Template+Parser.swift @@ -143,12 +143,6 @@ extension HBMustacheTemplate { // partial parser.unsafeAdvance() let (name, _) = try parseName(&parser) - /* if newLine && hasLineFinished(&parser) { - setNewLine = true - if parser.current() == "\n" { - parser.unsafeAdvance() - } - } */ if whiteSpaceBefore.count > 0 { tokens.append(.text(whiteSpaceBefore)) } diff --git a/Sources/HummingbirdMustache/Template+Render.swift b/Sources/HummingbirdMustache/Template+Render.swift index 2bbe212..6f5b006 100644 --- a/Sources/HummingbirdMustache/Template+Render.swift +++ b/Sources/HummingbirdMustache/Template+Render.swift @@ -2,47 +2,59 @@ extension HBMustacheTemplate { /// Render template using object /// - Parameters: - /// - object: Object + /// - stack: Object /// - context: Context that render is occurring in. Contains information about position in sequence + /// - indentation: indentation of partial /// - Returns: Rendered text - func render(_ object: [Any], context: HBMustacheContext? = nil, indentation: String? = nil) -> String { + func render(_ stack: [Any], context: HBMustacheContext? = nil, indentation: String? = nil) -> String { var string = "" - for token in tokens { - if let indentation = indentation, string.last == "\n" { - string += indentation + if let indentation = indentation { + for token in tokens { + if string.last == "\n" { + string += indentation + } + string += renderToken(token, stack: stack, context: context) } - switch token { - case let .text(text): - string += text - case let .variable(variable, method): - if let child = getChild(named: variable, from: object, method: method, context: context) { - if let template = child as? HBMustacheTemplate { - string += template.render(object) - } else { - string += String(describing: child).htmlEscape() - } - } - case let .unescapedVariable(variable, method): - if let child = getChild(named: variable, from: object, method: method, context: context) { - string += String(describing: child) - } - case let .section(variable, method, template): - let child = getChild(named: variable, from: object, method: method, context: context) - string += renderSection(child, stack: object, with: template) - - case let .invertedSection(variable, method, template): - let child = getChild(named: variable, from: object, method: method, context: context) - string += renderInvertedSection(child, stack: object, with: template) - - case let .partial(name, indentation): - if let template = library?.getTemplate(named: name) { - string += template.render(object, indentation: indentation) - } + } else { + for token in tokens { + string += renderToken(token, stack: stack, context: context) } } return string } + func renderToken(_ token: Token, stack: [Any], context: HBMustacheContext? = nil) -> String { + switch token { + case let .text(text): + return text + case let .variable(variable, method): + if let child = getChild(named: variable, from: stack, method: method, context: context) { + if let template = child as? HBMustacheTemplate { + return template.render(stack) + } else { + return String(describing: child).htmlEscape() + } + } + case let .unescapedVariable(variable, method): + if let child = getChild(named: variable, from: stack, method: method, context: context) { + return String(describing: child) + } + case let .section(variable, method, template): + let child = getChild(named: variable, from: stack, method: method, context: context) + return renderSection(child, stack: stack, with: template) + + case let .invertedSection(variable, method, template): + let child = getChild(named: variable, from: stack, method: method, context: context) + return renderInvertedSection(child, stack: stack, with: template) + + case let .partial(name, indentation): + if let template = library?.getTemplate(named: name) { + return template.render(stack, indentation: indentation) + } + } + return "" + } + /// Render a section /// - Parameters: /// - child: Object to render section for diff --git a/Tests/HummingbirdMustacheTests/SpecTests.swift b/Tests/HummingbirdMustacheTests/SpecTests.swift index c487390..dcf847c 100644 --- a/Tests/HummingbirdMustacheTests/SpecTests.swift +++ b/Tests/HummingbirdMustacheTests/SpecTests.swift @@ -661,8 +661,8 @@ final class SpecSectionTests: XCTestCase { func testVariables() throws { let object: [String: Any] = ["foo": "bar"] - let template = #""{{#foo}} {{.}} is {{foo}} {{/foo}}""# - let expected = #"" bar is bar ""# + let template = #""{{#foo}}{{.}} is {{foo}}{{/foo}}""# + let expected = #""bar is bar""# try test(object, template, expected) }