diff --git a/Sources/Liquid/Parser.swift b/Sources/Liquid/Parser.swift index 2ed4108..b92cc03 100644 --- a/Sources/Liquid/Parser.swift +++ b/Sources/Liquid/Parser.swift @@ -8,7 +8,7 @@ import Foundation final class Parser { - private let tokens: [Lexer.Token] + let tokens: [Lexer.Token] private var index: Array.Index init(tokens: [Lexer.Token]) { self.tokens = tokens diff --git a/Sources/Liquid/Tags/Include.swift b/Sources/Liquid/Tags/Include.swift new file mode 100644 index 0000000..841754a --- /dev/null +++ b/Sources/Liquid/Tags/Include.swift @@ -0,0 +1,91 @@ +// +// Include.swift +// +// +// Created by Geoffrey Foster on 2019-09-27. +// + +import Foundation + +final class Include: Tag { + private let templateName: Expression + private let variable: Expression? + private let attributes: [String: Expression] + init(name: String, markup: String?, context: ParseContext) throws { + guard let markup = markup else { throw SyntaxError.missingMarkup } + let parser = try Parser(string: markup) + + self.templateName = Expression.parse(parser) + + if parser.consumeId("for") || parser.consumeId("with") { + self.variable = Expression.parse(parser) + } else { + self.variable = nil + } + + var attributes: [String: Expression] = [:] + while let attribute = parser.consume(.id) { + parser.consume(.colon) + let value = Expression.parse(parser) + attributes[attribute] = value + parser.consume(.comma) + } + + parser.consume(.endOfString) + + self.attributes = attributes + } + + func parse(_ tokenizer: Tokenizer, context: ParseContext) throws {} + + func render(context: Context) throws -> [String] { + var result: [String] = [] + let templateName = self.templateName.evaluate(context: context).toString() + let template = try loadTemplate(path: templateName, context: context) + let variableName = templateName.components(separatedBy: "/").last! + + let value = variable?.evaluate(context: context) ?? context.value(named: variableName) + + try context.withScope { + attributes.forEach { (key, expression) in + let value = expression.evaluate(context: context) + context.setValue(value, named: key) + } + + if let value = value, value.isArray { + for vv in value.toArray() { + context.setValue(vv, named: variableName) + result.append(try template.render(context: context)) + } + } else { + if let value = value { + context.setValue(value, named: variableName) + } + result.append(try template.render(context: context)) + } + } + + return result + } + + private func loadTemplate(path: String, context: Context) throws -> Template { + guard !path.isEmpty else { + throw FileSystemError(reason: "") + } + let cacheKey = RegisterKey("CachedPartials") + var cached = (context[cacheKey] as? [String: Template]) ?? [:] + + if let template = cached[path] { + return template + } + + let source = try context.fileSystem.read(path: path) + let template = Template(source: source, context: context) + try template.parse() + + cached[path] = template + context[cacheKey] = cached + + return template + } +} diff --git a/Sources/Liquid/Template.swift b/Sources/Liquid/Template.swift index f822d0b..9a43c31 100644 --- a/Sources/Liquid/Template.swift +++ b/Sources/Liquid/Template.swift @@ -62,6 +62,7 @@ public final class Template { tags["decrement"] = Decrement.init tags["for"] = For.init tags["if"] = If.init + tags["include"] = Include.init tags["increment"] = Increment.init tags["unless"] = If.unless diff --git a/Tests/LiquidTests/IncludeTests.swift b/Tests/LiquidTests/IncludeTests.swift new file mode 100644 index 0000000..1833fcc --- /dev/null +++ b/Tests/LiquidTests/IncludeTests.swift @@ -0,0 +1,151 @@ +// +// IncludeTests.swift +// +// +// Created by Geoffrey Foster on 2019-09-28. +// + +import XCTest +@testable import Liquid + +final class IncludeTests: XCTestCase { + private final class TestFileSystem: FileSystem { + func read(path: String) throws -> String { + switch path { + case "product": + return "Product: {{ product.title }} " + case "locale_variables": + return "Locale: {{echo1}} {{echo2}}" + case "variant": + return "Variant: {{ variant.title }}" + case "nested_template": + return "{% include 'header' %} {% include 'body' %} {% include 'footer' %}" + case "body": + return "body {% include 'body_detail' %}" + case "nested_product_template": + return "Product: {{ nested_product_template.title }} {%include 'details'%} " + case "recursively_nested_template": + return "-{% include 'recursively_nested_template' %}" + case "pick_a_source": + return "from TestFileSystem" + case "assignments": + return "{% assign foo = 'bar' %}" + case "break": + return "{% break %}" + default: + return path + } + } + } + + private final class CountingFileSystem: FileSystem { + var count: Int = 0 + func read(path: String) throws -> String { + defer { count += 1 } + return "from CountingFileSystem" + } + } + + func test_include_tag_with() throws { + XCTAssertTemplate("{% include 'product' with products[0] %}", "Product: Draft 151cm ", ["products": [["title": "Draft 151cm"], ["title": "Element 155cm"]]], fileSystem: TestFileSystem()) + } + + func test_include_tag_with_default_name() throws { + XCTAssertTemplate("{% include 'product' %}", "Product: Draft 151cm ", ["product": ["title": "Draft 151cm"]], fileSystem: TestFileSystem()) + } + + func test_include_tag_for() throws { + XCTAssertTemplate("{% include 'product' for products %}", "Product: Draft 151cm Product: Element 155cm ", ["products": [["title": "Draft 151cm"], ["title": "Element 155cm"]]], fileSystem: TestFileSystem()) + } + + func test_include_tag_with_local_variables() throws { + XCTAssertTemplate("{% include 'locale_variables' echo1: 'test123' %}", "Locale: test123 ", fileSystem: TestFileSystem()) + } + + func test_include_tag_with_multiple_local_variables() throws { + XCTAssertTemplate("{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}", "Locale: test123 test321", fileSystem: TestFileSystem()) + } + + func test_include_tag_with_multiple_local_variables_from_context() throws { + XCTAssertTemplate( + "{% include 'locale_variables' echo1: echo1, echo2: more_echos.echo2 %}", + "Locale: test123 test321", + ["echo1": "test123", "more_echos": ["echo2": "test321"]], + fileSystem: TestFileSystem() + ) + } + + func test_included_templates_assigns_variables() throws { + XCTAssertTemplate("{% include 'assignments' %}{{ foo }}", "bar", fileSystem: TestFileSystem()) + } + + func test_nested_include_tag() throws { + XCTAssertTemplate("{% include 'body' %}", "body body_detail", fileSystem: TestFileSystem()) + XCTAssertTemplate("{% include 'nested_template' %}", "header body body_detail footer", fileSystem: TestFileSystem()) + } + + func test_nested_include_with_variable() throws { + XCTAssertTemplate("{% include 'nested_product_template' with product %}", "Product: Draft 151cm details ", ["product": ["title": "Draft 151cm"]], fileSystem: TestFileSystem()) + XCTAssertTemplate("{% include 'nested_product_template' for products %}", "Product: Draft 151cm details Product: Element 155cm details ", ["products": [["title": "Draft 151cm"], ["title": "Element 155cm"]]], fileSystem: TestFileSystem()) + } + +// func test_recursively_included_template_does_not_produce_endless_loop() throws { +// infinite_file_system = Class.new do +// func read_template_file(template_path)() throws { +// "-{% include 'loop' %}" +// } +// end +// +// Liquid::Template.file_system = infinite_file_system.new +// +// assert_raises(Liquid::StackLevelError) do +// Template.parse("{% include 'loop' %}").render! +// end +// } + + func test_dynamically_choosen_template() throws { + XCTAssertTemplate("{% include template %}", "Test123", ["template": "Test123"], fileSystem: TestFileSystem()) + XCTAssertTemplate("{% include template %}", "Test321", ["template": "Test321"], fileSystem: TestFileSystem()) + XCTAssertTemplate("{% include template for product %}", "Product: Draft 151cm ", ["template": "product", "product": ["title": "Draft 151cm"]], fileSystem: TestFileSystem()) + } + + func test_include_tag_caches_second_read_of_same_partial() throws { + let fileSystem = CountingFileSystem() + XCTAssertTemplate("{% include 'pick_a_source' %}{% include 'pick_a_source' %}", "from CountingFileSystemfrom CountingFileSystem", fileSystem: fileSystem) + XCTAssertEqual(fileSystem.count, 1) + } + + func test_include_tag_doesnt_cache_partials_across_renders() throws { + let fileSystem = CountingFileSystem() + XCTAssertTemplate("{% include 'pick_a_source' %}", "from CountingFileSystem", fileSystem: fileSystem) + XCTAssertEqual(fileSystem.count, 1) + + XCTAssertTemplate("{% include 'pick_a_source' %}", "from CountingFileSystem", fileSystem: fileSystem) + XCTAssertEqual(fileSystem.count, 2) + } + + func test_include_tag_within_if_statement() throws { + XCTAssertTemplate("{% if true %}{% include 'foo_if_true' %}{% endif %}", "foo_if_true", fileSystem: TestFileSystem()) + } + + func test_render_raise_argument_error_when_template_is_undefined() throws { + let undefinedVariableTemplate = Template(source: "{% include undefined_variable %}", fileSystem: TestFileSystem()) + XCTAssertNoThrow(try undefinedVariableTemplate.parse()) + XCTAssertThrowsError(try undefinedVariableTemplate.render()) + + let nilTemplate = Template(source: "{% include nil %}", fileSystem: TestFileSystem()) + XCTAssertNoThrow(try nilTemplate.parse()) + XCTAssertThrowsError(try nilTemplate.render()) + } + + func test_including_via_variable_value() throws { + XCTAssertTemplate("{% assign page = 'pick_a_source' %}{% include page %}", "from TestFileSystem", fileSystem: TestFileSystem()) + XCTAssertTemplate("{% assign page = 'product' %}{% include page %}", "Product: Draft 151cm ", ["product": ["title": "Draft 151cm"]], fileSystem: TestFileSystem()) + XCTAssertTemplate("{% assign page = 'product' %}{% include page for foo %}", "Product: Draft 151cm ", ["foo": ["title": "Draft 151cm"]], fileSystem: TestFileSystem()) + } + + func test_break_through_include() throws { + XCTAssertTemplate("{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}", "1", fileSystem: TestFileSystem()) + XCTAssertTemplate("{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}", "1", fileSystem: TestFileSystem()) + } +}