From b4dc8dbb761bd69d3d79eb3df37cd9118fcab561 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Thu, 28 Dec 2017 16:14:52 +0100 Subject: [PATCH 01/30] support for iterating array of tuples with more than two values --- CHANGELOG.md | 1 + Sources/ForTag.swift | 23 +++++++------ Tests/StencilTests/ForNodeSpec.swift | 50 +++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce60106..59cb9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added support for resolving superclass properties for not-NSObject subclasses - The `{% for %}` tag can now iterate over tuples, structures and classes via their stored properties. +- Added support for iterating arrays of tuples ### Bug Fixes diff --git a/Sources/ForTag.swift b/Sources/ForTag.swift index 25005e6..b3e5f84 100644 --- a/Sources/ForTag.swift +++ b/Sources/ForTag.swift @@ -53,25 +53,26 @@ class ForNode : NodeType { self.where = `where` } - func push(value: Any, context: Context, closure: () throws -> (Result)) rethrows -> Result { + func push(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result { if loopVariables.isEmpty { return try context.push() { return try closure() } } - if let value = value as? (Any, Any) { - let first = loopVariables[0] - - if loopVariables.count == 2 { - let second = loopVariables[1] - - return try context.push(dictionary: [first: value.0, second: value.1]) { - return try closure() - } + let valueMirror = Mirror(reflecting: value) + if case .tuple? = valueMirror.displayStyle { + if loopVariables.count > Int(valueMirror.children.count) { + throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables") } + var variablesContext = [String: Any]() + valueMirror.children.prefix(loopVariables.count).enumerated().forEach({ (offset, element) in + if loopVariables[offset] != "_" { + variablesContext[loopVariables[offset]] = element.value + } + }) - return try context.push(dictionary: [first: value.0]) { + return try context.push(dictionary: variablesContext) { return try closure() } } diff --git a/Tests/StencilTests/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index 9cc98cb..925cb14 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -11,7 +11,8 @@ func testForNode() { "dict": [ "one": "I", "two": "II", - ] + ], + "tuples": [(1, 2, 3), (4, 5, 6)] ]) $0.it("renders the given nodes for each item") { @@ -127,6 +128,53 @@ func testForNode() { try expect(result) == fixture } + $0.context("given array of tuples") { + $0.it("can iterate over all tuple values") { + let templateString = "{% for first,second,third in tuples %}" + + "{{ first }}, {{ second }}, {{ third }}\n" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + let result = try template.render(context) + + let fixture = "1, 2, 3\n4, 5, 6\n\n" + try expect(result) == fixture + } + + $0.it("can iterate with less number of variables") { + let templateString = "{% for first,second in tuples %}" + + "{{ first }}, {{ second }}\n" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + let result = try template.render(context) + + let fixture = "1, 2\n4, 5\n\n" + try expect(result) == fixture + } + + $0.it("can use _ to skip variables") { + let templateString = "{% for first,_,third in tuples %}" + + "{{ first }}, {{ third }}\n" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + let result = try template.render(context) + + let fixture = "1, 3\n4, 6\n\n" + try expect(result) == fixture + } + + $0.it("throws when number of variables is more than number of tuple values") { + let templateString = "{% for key,value,smth in dict %}" + + "{% endfor %}\n" + + let template = Template(templateString: templateString) + try expect(template.render(context)).toThrow() + } + + } + $0.it("can iterate over dictionary") { let templateString = "{% for key,value in dict %}" + "{{ key }}: {{ value }}," + From c30597457fafa0cd77783862653fdf85c9b16e6a Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Mon, 22 Jan 2018 01:49:32 +0100 Subject: [PATCH 02/30] feat: added split fitler (#187) --- CHANGELOG.md | 1 + Sources/Extension.swift | 1 + Sources/Filters.swift | 13 +++++++++++++ Tests/StencilTests/FilterSpec.swift | 16 ++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce60106..bd80299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added support for resolving superclass properties for not-NSObject subclasses - The `{% for %}` tag can now iterate over tuples, structures and classes via their stored properties. +- Added `split` filter ### Bug Fixes diff --git a/Sources/Extension.swift b/Sources/Extension.swift index 9dfa879..a389276 100644 --- a/Sources/Extension.swift +++ b/Sources/Extension.swift @@ -57,6 +57,7 @@ class DefaultExtension: Extension { registerFilter("uppercase", filter: uppercase) registerFilter("lowercase", filter: lowercase) registerFilter("join", filter: joinFilter) + registerFilter("split", filter: splitFilter) } } diff --git a/Sources/Filters.swift b/Sources/Filters.swift index f84a534..cf8f0fc 100644 --- a/Sources/Filters.swift +++ b/Sources/Filters.swift @@ -40,3 +40,16 @@ func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? { return value } + +func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? { + guard arguments.count < 2 else { + throw TemplateSyntaxError("'split' filter takes a single argument") + } + + let separator = stringify(arguments.first ?? " ") + if let value = value as? String { + return value.components(separatedBy: separator) + } + + return value +} diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index 2501610..bb24e60 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -183,4 +183,20 @@ func testFilter() { try expect(result) == "OneTwo" } } + + describe("split filter") { + let template = Template(templateString: "{{ value|split:\", \" }}") + + $0.it("split a string into array") { + let result = try template.render(Context(dictionary: ["value": "One, Two"])) + try expect(result) == "[\"One\", \"Two\"]" + } + + $0.it("can split without arguments") { + let template = Template(templateString: "{{ value|split }}") + let result = try template.render(Context(dictionary: ["value": "One, Two"])) + try expect(result) == "[\"One,\", \"Two\"]" + } + } + } From c4a84a637555f29f4ff8a70c23729ce93b9bce42 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Mon, 22 Jan 2018 18:20:42 +0100 Subject: [PATCH 03/30] feat: apply string filters to arrays (#190) --- CHANGELOG.md | 1 + Sources/Filters.swift | 18 ++++++++-- Tests/StencilTests/FilterSpec.swift | 53 ++++++++++++++++++----------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd80299..47ccabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - The `{% for %}` tag can now iterate over tuples, structures and classes via their stored properties. - Added `split` filter +- Allow default string filters to be applied to arrays ### Bug Fixes diff --git a/Sources/Filters.swift b/Sources/Filters.swift index cf8f0fc..aa54443 100644 --- a/Sources/Filters.swift +++ b/Sources/Filters.swift @@ -1,13 +1,25 @@ func capitalise(_ value: Any?) -> Any? { - return stringify(value).capitalized + if let array = value as? [Any?] { + return array.map { stringify($0).capitalized } + } else { + return stringify(value).capitalized + } } func uppercase(_ value: Any?) -> Any? { - return stringify(value).uppercased() + if let array = value as? [Any?] { + return array.map { stringify($0).uppercased() } + } else { + return stringify(value).uppercased() + } } func lowercase(_ value: Any?) -> Any? { - return stringify(value).lowercased() + if let array = value as? [Any?] { + return array.map { stringify($0).lowercased() } + } else { + return stringify(value).lowercased() + } } func defaultFilter(value: Any?, arguments: [Any?]) -> Any? { diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index bb24e60..660cf18 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -89,32 +89,45 @@ func testFilter() { } } + describe("string filters") { + $0.context("given string") { + $0.it("transforms a string to be capitalized") { + let template = Template(templateString: "{{ name|capitalize }}") + let result = try template.render(Context(dictionary: ["name": "kyle"])) + try expect(result) == "Kyle" + } - describe("capitalize filter") { - let template = Template(templateString: "{{ name|capitalize }}") + $0.it("transforms a string to be uppercase") { + let template = Template(templateString: "{{ name|uppercase }}") + let result = try template.render(Context(dictionary: ["name": "kyle"])) + try expect(result) == "KYLE" + } - $0.it("capitalizes a string") { - let result = try template.render(Context(dictionary: ["name": "kyle"])) - try expect(result) == "Kyle" + $0.it("transforms a string to be lowercase") { + let template = Template(templateString: "{{ name|lowercase }}") + let result = try template.render(Context(dictionary: ["name": "Kyle"])) + try expect(result) == "kyle" + } } - } + $0.context("given array of strings") { + $0.it("transforms a string to be capitalized") { + let template = Template(templateString: "{{ names|capitalize }}") + let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]])) + try expect(result) == "[\"Kyle\", \"Kyle\"]" + } - describe("uppercase filter") { - let template = Template(templateString: "{{ name|uppercase }}") + $0.it("transforms a string to be uppercase") { + let template = Template(templateString: "{{ names|uppercase }}") + let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]])) + try expect(result) == "[\"KYLE\", \"KYLE\"]" + } - $0.it("transforms a string to be uppercase") { - let result = try template.render(Context(dictionary: ["name": "kyle"])) - try expect(result) == "KYLE" - } - } - - describe("lowercase filter") { - let template = Template(templateString: "{{ name|lowercase }}") - - $0.it("transforms a string to be lowercase") { - let result = try template.render(Context(dictionary: ["name": "Kyle"])) - try expect(result) == "kyle" + $0.it("transforms a string to be lowercase") { + let template = Template(templateString: "{{ names|lowercase }}") + let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]])) + try expect(result) == "[\"kyle\", \"kyle\"]" + } } } From 24c974668930e88a1eb8faf1518109b5dc4b1b5d Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Mon, 22 Jan 2018 19:24:32 +0100 Subject: [PATCH 04/30] fix: updated package bumping PathKit version and created package maifest for swift 3 (#184) --- Package.swift | 7 +++---- Package@swift-3.swift | 10 ++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 Package@swift-3.swift diff --git a/Package.swift b/Package.swift index e366cf9..abda948 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,10 @@ +// swift-tools-version:3.1 import PackageDescription let package = Package( name: "Stencil", dependencies: [ - .Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8), - - // https://github.com/apple/swift-package-manager/pull/597 - .Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7), + .Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 9), + .Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 8), ] ) diff --git a/Package@swift-3.swift b/Package@swift-3.swift new file mode 100644 index 0000000..704b083 --- /dev/null +++ b/Package@swift-3.swift @@ -0,0 +1,10 @@ +// swift-tools-version:3.1 +import PackageDescription + +let package = Package( + name: "Stencil", + dependencies: [ + .Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8), + .Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7), + ] +) From 359d086c0259480a05c1d5754a480539b345a029 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Mon, 22 Jan 2018 19:27:42 +0100 Subject: [PATCH 05/30] feat(filters): Show similar filter names when missing filter(#186) --- CHANGELOG.md | 1 + Sources/Parser.swift | 63 ++++++++++++++++++++++++++++- Tests/StencilTests/FilterSpec.swift | 32 +++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ccabb..fbc1c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ their stored properties. - Added `split` filter - Allow default string filters to be applied to arrays +- Similar filters are suggested when unknown filter is used ### Bug Fixes diff --git a/Sources/Parser.swift b/Sources/Parser.swift index 1a59edb..5d9a1ec 100644 --- a/Sources/Parser.swift +++ b/Sources/Parser.swift @@ -88,7 +88,26 @@ public class TokenParser { } } - throw TemplateSyntaxError("Unknown filter '\(name)'") + let suggestedFilters = self.suggestedFilters(for: name) + if suggestedFilters.isEmpty { + throw TemplateSyntaxError("Unknown filter '\(name)'.") + } else { + throw TemplateSyntaxError("Unknown filter '\(name)'. Found similar filters: \(suggestedFilters.map({ "'\($0)'" }).joined(separator: ", "))") + } + } + + private func suggestedFilters(for name: String) -> [String] { + let allFilters = environment.extensions.flatMap({ $0.filters.keys }) + + let filtersWithDistance = allFilters + .map({ (filterName: $0, distance: $0.levenshteinDistance(name)) }) + // do not suggest filters which names are shorter than the distance + .filter({ $0.filterName.characters.count > $0.distance }) + guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else { + return [] + } + // suggest all filters with the same distance + return filtersWithDistance.filter({ $0.distance == minDistance }).map({ $0.filterName }) } public func compileFilter(_ token: String) throws -> Resolvable { @@ -96,3 +115,45 @@ public class TokenParser { } } + +// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows +extension String { + + subscript(_ i: Int) -> Character { + return self[self.index(self.startIndex, offsetBy: i)] + } + + func levenshteinDistance(_ target: String) -> Int { + // create two work vectors of integer distances + var last, current: [Int] + + // initialize v0 (the previous row of distances) + // this row is A[0][i]: edit distance for an empty s + // the distance is just the number of characters to delete from t + last = [Int](0...target.characters.count) + current = [Int](repeating: 0, count: target.characters.count + 1) + + for i in 0.. Date: Mon, 22 Jan 2018 19:30:53 +0100 Subject: [PATCH 06/30] docs: Added the mention of projects that use Stencil (#176) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ded2ca1..668c7cd 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ Resources to help you integrate Stencil into a Swift project: - [API Reference](http://stencil.fuller.li/en/latest/api.html) - [Custom Template Tags and Filters](http://stencil.fuller.li/en/latest/custom-template-tags-and-filters.html) +## Projects that use Stencil + +[Sourcery](https://github.com/krzysztofzablocki/Sourcery), +[SwiftGen](https://github.com/SwiftGen/SwiftGen), +[Kitura](https://github.com/IBM-Swift/Kitura) + ## License Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more From fa68ba9df84dcd7b21febcdb0ab7ef067f839ea1 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Sun, 28 Jan 2018 17:17:23 +0100 Subject: [PATCH 07/30] feat: Added indent filter (#188) --- CHANGELOG.md | 1 + Sources/Extension.swift | 1 + Sources/Filters.swift | 46 ++++++++++++++++++++++++++ Sources/Variable.swift | 4 +++ Tests/StencilTests/FilterSpec.swift | 26 +++++++++++++++ Tests/StencilTests/FilterTagSpec.swift | 9 +++++ Tests/StencilTests/VariableSpec.swift | 7 ++++ 7 files changed, 94 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc1c1a..e1a5af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Added `split` filter - Allow default string filters to be applied to arrays - Similar filters are suggested when unknown filter is used +- Added `indent` filter ### Bug Fixes diff --git a/Sources/Extension.swift b/Sources/Extension.swift index a389276..33a9925 100644 --- a/Sources/Extension.swift +++ b/Sources/Extension.swift @@ -58,6 +58,7 @@ class DefaultExtension: Extension { registerFilter("lowercase", filter: lowercase) registerFilter("join", filter: joinFilter) registerFilter("split", filter: splitFilter) + registerFilter("indent", filter: indentFilter) } } diff --git a/Sources/Filters.swift b/Sources/Filters.swift index aa54443..fece6eb 100644 --- a/Sources/Filters.swift +++ b/Sources/Filters.swift @@ -65,3 +65,49 @@ func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? { return value } + +func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? { + guard arguments.count <= 3 else { + throw TemplateSyntaxError("'indent' filter can take at most 3 arguments") + } + + var indentWidth = 4 + if arguments.count > 0 { + guard let value = arguments[0] as? Int else { + throw TemplateSyntaxError("'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))") + } + indentWidth = value + } + + var indentationChar = " " + if arguments.count > 1 { + guard let value = arguments[1] as? String else { + throw TemplateSyntaxError("'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))") + } + indentationChar = value + } + + var indentFirst = false + if arguments.count > 2 { + guard let value = arguments[2] as? Bool else { + throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool") + } + indentFirst = value + } + + let indentation = [String](repeating: indentationChar, count: indentWidth).joined(separator: "") + return indent(stringify(value), indentation: indentation, indentFirst: indentFirst) +} + + +func indent(_ content: String, indentation: String, indentFirst: Bool) -> String { + guard !indentation.isEmpty else { return content } + + var lines = content.components(separatedBy: .newlines) + let firstLine = (indentFirst ? indentation : "") + lines.removeFirst() + let result = lines.reduce([firstLine]) { (result, line) in + return result + [(line.isEmpty ? "" : "\(indentation)\(line)")] + } + return result.joined(separator: "\n") +} + diff --git a/Sources/Variable.swift b/Sources/Variable.swift index c17b966..baa5594 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -70,6 +70,10 @@ public struct Variable : Equatable, Resolvable { if let number = Number(variable) { return number } + // Boolean literal + if let bool = Bool(variable) { + return bool + } for bit in lookup() { current = normalize(current) diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index b072477..5224c12 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -244,4 +244,30 @@ func testFilter() { } + + describe("indent filter") { + $0.it("indents content") { + let template = Template(templateString: "{{ value|indent:2 }}") + let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) + try expect(result) == "One\n Two" + } + + $0.it("can indent with arbitrary character") { + let template = Template(templateString: "{{ value|indent:2,\"\t\" }}") + let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) + try expect(result) == "One\n\t\tTwo" + } + + $0.it("can indent first line") { + let template = Template(templateString: "{{ value|indent:2,\" \",true }}") + let result = try template.render(Context(dictionary: ["value": "One\nTwo"])) + try expect(result) == " One\n Two" + } + + $0.it("does not indent empty lines") { + let template = Template(templateString: "{{ value|indent }}") + let result = try template.render(Context(dictionary: ["value": "One\n\n\nTwo\n\n"])) + try expect(result) == "One\n\n\n Two\n\n" + } + } } diff --git a/Tests/StencilTests/FilterTagSpec.swift b/Tests/StencilTests/FilterTagSpec.swift index 2470450..e280668 100644 --- a/Tests/StencilTests/FilterTagSpec.swift +++ b/Tests/StencilTests/FilterTagSpec.swift @@ -21,5 +21,14 @@ func testFilterTag() { try expect(try template.render()).toThrow() } + $0.it("can render filters with arguments") { + let ext = Extension() + ext.registerFilter("split", filter: { + return ($0 as! String).components(separatedBy: $1[0] as! String) + }) + let env = Environment(extensions: [ext]) + let result = try env.renderTemplate(string: "{% filter split:\",\"|join:\";\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": [1, 2]]) + try expect(result) == "1;2" + } } } diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 1a567d8..6937e35 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -71,6 +71,13 @@ func testVariable() { try expect(result) == 3.14 } + $0.it("can resolve boolean literal") { + try expect(Variable("true").resolve(context) as? Bool) == true + try expect(Variable("false").resolve(context) as? Bool) == false + try expect(Variable("0").resolve(context) as? Int) == 0 + try expect(Variable("1").resolve(context) as? Int) == 1 + } + $0.it("can resolve a string variable") { let variable = Variable("name") let result = try variable.resolve(context) as? String From 0bc6bd974e955ce44b2da7ec491520df34fc44f3 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Tue, 13 Mar 2018 09:07:56 +0000 Subject: [PATCH 08/30] feat: allow using new lines inside tags (#202) --- CHANGELOG.md | 1 + Sources/Lexer.swift | 7 ++++++- Tests/StencilTests/LexerSpec.swift | 22 ++++++++++++++++++++++ Tests/StencilTests/VariableSpec.swift | 4 ++-- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a5af3..90e6087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Allow default string filters to be applied to arrays - Similar filters are suggested when unknown filter is used - Added `indent` filter +- Allow using new lines inside tags ### Bug Fixes diff --git a/Sources/Lexer.swift b/Sources/Lexer.swift index 5bd590d..b221775 100644 --- a/Sources/Lexer.swift +++ b/Sources/Lexer.swift @@ -10,7 +10,12 @@ struct Lexer { guard string.characters.count > 4 else { return "" } let start = string.index(string.startIndex, offsetBy: 2) let end = string.index(string.endIndex, offsetBy: -2) - return String(string[start.. Date: Thu, 5 Apr 2018 01:03:19 +0100 Subject: [PATCH 09/30] fix: accessing optional properties through reflection (#204) --- CHANGELOG.md | 1 + Sources/Variable.swift | 30 +++++++++++++++++++++++---- Tests/StencilTests/VariableSpec.swift | 7 +++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 981fb6b..e77b189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Fixed checking dictionary values for nil in `default` filter - Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. - Integer literals now resolve into Int values, not Float +- Fixed accessing properties of optional properties via reflection ## 0.10.1 diff --git a/Sources/Variable.swift b/Sources/Variable.swift index baa5594..5af97ef 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -187,11 +187,33 @@ extension Mirror { if result == nil { // go through inheritance chain to reach superclass properties return superclassMirror?.getValue(for: key) - } else if let result = result, String(describing: result) == "nil" { - // mirror returns non-nil value even for nil-containing properties - // so we have to check if its value is actually nil or not - return nil + } else if let result = result { + guard String(describing: result) != "nil" else { + // mirror returns non-nil value even for nil-containing properties + // so we have to check if its value is actually nil or not + return nil + } + if let result = (result as? AnyOptional)?.wrapped { + return result + } else { + return result + } } return result } } + +protocol AnyOptional { + var wrapped: Any? { get } +} + +extension Optional: AnyOptional { + var wrapped: Any? { + switch self { + case let .some(value): return value + case .none: return nil + } + } +} + + diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 6dd78ee..6dad505 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -26,6 +26,7 @@ fileprivate class WebSite { fileprivate class Blog: WebSite { let articles: [Article] = [Article(author: Person(name: "Kyle"))] + let featuring: Article? = Article(author: Person(name: "Jhon")) } func testVariable() { @@ -160,5 +161,11 @@ func testVariable() { try expect(result) == "blog.com" } + $0.it("can resolve optional variable property using reflection") { + let variable = Variable("blog.featuring.author.name") + let result = try variable.resolve(context) as? String + try expect(result) == "Jhon" + } + } } From 29e859f1aab20235d5a4564948ad442e12cecfe1 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Thu, 5 Apr 2018 01:13:34 +0100 Subject: [PATCH 10/30] fix: Do not print Optional(...) when rendering arrays (#205) --- CHANGELOG.md | 1 + Sources/Node.swift | 15 +++++++++++++++ Tests/StencilTests/VariableSpec.swift | 10 +++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e77b189..df370d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. - Integer literals now resolve into Int values, not Float - Fixed accessing properties of optional properties via reflection +- No longer render optional values in arrays as `Optional(..)` ## 0.10.1 diff --git a/Sources/Node.swift b/Sources/Node.swift index 5b47177..36208a3 100644 --- a/Sources/Node.swift +++ b/Sources/Node.swift @@ -78,6 +78,8 @@ public class VariableNode : NodeType { func stringify(_ result: Any?) -> String { if let result = result as? String { return result + } else if let array = result as? [Any?] { + return unwrap(array).description } else if let result = result as? CustomStringConvertible { return result.description } else if let result = result as? NSObject { @@ -86,3 +88,16 @@ func stringify(_ result: Any?) -> String { return "" } + +func unwrap(_ array: [Any?]) -> [Any] { + return array.map { (item: Any?) -> Any in + if let item = item { + if let items = item as? [Any?] { + return unwrap(items) + } else { + return item + } + } + else { return item as Any } + } +} diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 6dad505..5d3e884 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -148,7 +148,7 @@ func testVariable() { try expect(result) == "Foo" } #endif - + $0.it("can resolve a value via reflection") { let variable = Variable("blog.articles.0.author.name") let result = try variable.resolve(context) as? String @@ -167,5 +167,13 @@ func testVariable() { try expect(result) == "Jhon" } + $0.it("does not render Optional") { + var array: [Any?] = [1, nil] + array.append(array) + let context = Context(dictionary: ["values": array]) + + try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]" + try expect(VariableNode(variable: "values.1").render(context)) == "" + } } } From 6b02fccf84a0adb561dc1f51eb39c5f1b03cd5f5 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Thu, 5 Apr 2018 01:22:05 +0100 Subject: [PATCH 11/30] feat: added support for ranges in if-in expression (#193) --- CHANGELOG.md | 1 + Sources/Expression.swift | 4 ++++ Tests/StencilTests/ExpressionSpec.swift | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df370d5..d55be11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Added `indent` filter - Allow using new lines inside tags - Added support for iterating arrays of tuples +- Added support for ranges in if-in expression ### Bug Fixes diff --git a/Sources/Expression.swift b/Sources/Expression.swift index 1f41afe..c7199fc 100644 --- a/Sources/Expression.swift +++ b/Sources/Expression.swift @@ -105,6 +105,10 @@ final class InExpression: Expression, InfixOperator, CustomStringConvertible { if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] { return rhs.contains(lhs) + } else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange { + return rhs.contains(lhs) + } else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange { + return rhs.contains(lhs) } else if let lhs = lhsValue as? String, let rhs = rhsValue as? String { return rhs.contains(lhs) } else if lhsValue == nil && rhsValue == nil { diff --git a/Tests/StencilTests/ExpressionSpec.swift b/Tests/StencilTests/ExpressionSpec.swift index 4b7958d..c41575f 100644 --- a/Tests/StencilTests/ExpressionSpec.swift +++ b/Tests/StencilTests/ExpressionSpec.swift @@ -287,12 +287,16 @@ func testExpressions() { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [1, 2, 3]]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["a", "b", "c"]]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "abc"]))).to.beTrue() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1...3]))).to.beTrue() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1..<3]))).to.beTrue() } $0.it("evaluates to false when rhs does not contain lhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [2, 3, 4]]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["b", "c", "d"]]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "bcd"]))).to.beFalse() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 1...3]))).to.beFalse() + try expect(expression.evaluate(context: Context(dictionary: ["lhs": 3, "rhs": 1..<3]))).to.beFalse() } } } From f457cddd3f85dea234afc4d3ef8024f6b17f7425 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Thu, 5 Apr 2018 01:23:02 +0100 Subject: [PATCH 12/30] feat(for loop): added property for loop length (#171) --- CHANGELOG.md | 1 + Sources/ForTag.swift | 1 + Tests/StencilTests/ForNodeSpec.swift | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d55be11..7864ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Allow using new lines inside tags - Added support for iterating arrays of tuples - Added support for ranges in if-in expression +- Added property `forloop.length` to get number of items in the loop ### Bug Fixes diff --git a/Sources/ForTag.swift b/Sources/ForTag.swift index b3e5f84..9a7d7ec 100644 --- a/Sources/ForTag.swift +++ b/Sources/ForTag.swift @@ -132,6 +132,7 @@ class ForNode : NodeType { "last": index == (count - 1), "counter": index + 1, "counter0": index, + "length": count ] return try context.push(dictionary: ["forloop": forContext]) { diff --git a/Tests/StencilTests/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index 925cb14..b3d717d 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -90,6 +90,12 @@ func testForNode() { try expect(try node.render(context)) == "102132" } + $0.it("renders the given nodes while providing loop length") { + let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.length")] + let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: []) + try expect(try node.render(context)) == "132333" + } + $0.it("renders the given nodes while filtering items using where expression") { let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")] let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment())) From 2e6a7215c591728b0f864a81f806c3c1d3c82ddf Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Thu, 5 Apr 2018 01:25:13 +0100 Subject: [PATCH 13/30] fix: subscripting tuple by value index (#172) --- CHANGELOG.md | 1 + Sources/Variable.swift | 2 +- Tests/StencilTests/VariableSpec.swift | 15 ++++++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7864ea2..4c8e68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Integer literals now resolve into Int values, not Float - Fixed accessing properties of optional properties via reflection - No longer render optional values in arrays as `Optional(..)` +- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}` ## 0.10.1 diff --git a/Sources/Variable.swift b/Sources/Variable.swift index 5af97ef..74839b7 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -183,7 +183,7 @@ func parseFilterComponents(token: String) -> (String, [Variable]) { extension Mirror { func getValue(for key: String) -> Any? { - let result = descendant(key) + let result = descendant(key) ?? Int(key).flatMap({ descendant($0) }) if result == nil { // go through inheritance chain to reach superclass properties return superclassMirror?.getValue(for: key) diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 5d3e884..d860433 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -40,7 +40,8 @@ func testVariable() { "counter": [ "count": "kylef", ], - "article": Article(author: Person(name: "Kyle")) + "article": Article(author: Person(name: "Kyle")), + "tuple": (one: 1, two: 2) ]) #if os(OSX) @@ -175,5 +176,17 @@ func testVariable() { try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]" try expect(VariableNode(variable: "values.1").render(context)) == "" } + + $0.it("can subscript tuple by index") { + let variable = Variable("tuple.0") + let result = try variable.resolve(context) as? Int + try expect(result) == 1 + } + + $0.it("can subscript tuple by label") { + let variable = Variable("tuple.two") + let result = try variable.resolve(context) as? Int + try expect(result) == 2 + } } } From fe01beb4bb42b6715d4a646ece5dc1bf0d46fd71 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Thu, 5 Apr 2018 01:56:58 +0100 Subject: [PATCH 14/30] feat(for loop): Support range literals (#192) --- CHANGELOG.md | 1 + Sources/ForTag.swift | 29 ++++++++-------- Sources/IfTag.swift | 2 +- Sources/Parser.swift | 7 +++- Sources/Variable.swift | 36 ++++++++++++++++++++ Tests/StencilTests/ForNodeSpec.swift | 7 +++- Tests/StencilTests/IfNodeSpec.swift | 17 ++++++++++ Tests/StencilTests/VariableSpec.swift | 48 +++++++++++++++++++++++++++ 8 files changed, 131 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8e68d..5d10e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Added support for iterating arrays of tuples - Added support for ranges in if-in expression - Added property `forloop.length` to get number of items in the loop +- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count` ### Bug Fixes diff --git a/Sources/ForTag.swift b/Sources/ForTag.swift index 9a7d7ec..cb96657 100644 --- a/Sources/ForTag.swift +++ b/Sources/ForTag.swift @@ -10,9 +10,15 @@ class ForNode : NodeType { class func parse(_ parser:TokenParser, token:Token) throws -> NodeType { let components = token.components() - guard components.count >= 3 && components[2] == "in" && - (components.count == 4 || (components.count >= 6 && components[4] == "where")) else { - throw TemplateSyntaxError("'for' statements should use the following 'for x in y where condition' `\(token.contents)`.") + func hasToken(_ token: String, at index: Int) -> Bool { + return components.count > (index + 1) && components[index] == token + } + func endsOrHasToken(_ token: String, at index: Int) -> Bool { + return components.count == index || hasToken(token, at: index) + } + + guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else { + throw TemplateSyntaxError("'for' statements should use the syntax: `for in [where ]") } let loopVariables = components[1].characters @@ -20,8 +26,6 @@ class ForNode : NodeType { .map(String.init) .map { $0.trimmingCharacters(in: CharacterSet.whitespaces) } - let variable = components[3] - var emptyNodes = [NodeType]() let forNodes = try parser.parse(until(["endfor", "empty"])) @@ -35,14 +39,13 @@ class ForNode : NodeType { _ = parser.nextToken() } - let filter = try parser.compileFilter(variable) - let `where`: Expression? - if components.count >= 6 { - `where` = try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser) - } else { - `where` = nil - } - return ForNode(resolvable: filter, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`) + let resolvable = try parser.compileResolvable(components[3]) + + let `where` = hasToken("where", at: 4) + ? try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser) + : nil + + return ForNode(resolvable: resolvable, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`) } init(resolvable: Resolvable, loopVariables: [String], nodes:[NodeType], emptyNodes:[NodeType], where: Expression? = nil) { diff --git a/Sources/IfTag.swift b/Sources/IfTag.swift index 8f3b0fd..a857d3e 100644 --- a/Sources/IfTag.swift +++ b/Sources/IfTag.swift @@ -111,7 +111,7 @@ final class IfExpressionParser { } } - return .variable(try tokenParser.compileFilter(component)) + return .variable(try tokenParser.compileResolvable(component)) } } diff --git a/Sources/Parser.swift b/Sources/Parser.swift index 5d9a1ec..81a44e1 100644 --- a/Sources/Parser.swift +++ b/Sources/Parser.swift @@ -40,7 +40,7 @@ public class TokenParser { case .text(let text): nodes.append(TextNode(text: text)) case .variable: - nodes.append(VariableNode(variable: try compileFilter(token.contents))) + nodes.append(VariableNode(variable: try compileResolvable(token.contents))) case .block: if let parse_until = parse_until , parse_until(self, token) { prependToken(token) @@ -114,6 +114,11 @@ public class TokenParser { return try FilterExpression(token: token, parser: self) } + public func compileResolvable(_ token: String) throws -> Resolvable { + return try RangeVariable(token, parser: self) + ?? compileFilter(token) + } + } // https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows diff --git a/Sources/Variable.swift b/Sources/Variable.swift index 74839b7..9563c89 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -130,6 +130,42 @@ public func ==(lhs: Variable, rhs: Variable) -> Bool { return lhs.variable == rhs.variable } +/// A structure used to represet range of two integer values expressed as `from...to`. +/// Values should be numbers (they will be converted to integers). +/// Rendering this variable produces array from range `from...to`. +/// If `from` is more than `to` array will contain values of reversed range. +public struct RangeVariable: Resolvable { + public let from: Resolvable + public let to: Resolvable + + public init?(_ token: String, parser: TokenParser) throws { + let components = token.components(separatedBy: "...") + guard components.count == 2 else { + return nil + } + + self.from = try parser.compileFilter(components[0]) + self.to = try parser.compileFilter(components[1]) + } + + public func resolve(_ context: Context) throws -> Any? { + let fromResolved = try from.resolve(context) + let toResolved = try to.resolve(context) + + guard let from = fromResolved.flatMap(toNumber(value:)).flatMap(Int.init) else { + throw TemplateSyntaxError("'from' value is not an Integer (\(fromResolved ?? "nil"))") + } + + guard let to = toResolved.flatMap(toNumber(value:)).flatMap(Int.init) else { + throw TemplateSyntaxError("'to' value is not an Integer (\(toResolved ?? "nil") )") + } + + let range = min(from, to)...max(from, to) + return from > to ? Array(range.reversed()) : Array(range) + } + +} + func normalize(_ current: Any?) -> Any? { if let current = current as? Normalizable { diff --git a/Tests/StencilTests/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index b3d717d..a8b90df 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -227,7 +227,7 @@ func testForNode() { .block(value: "for i"), ] let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError("'for' statements should use the following 'for x in y where condition' `for i`.") + let error = TemplateSyntaxError("'for' statements should use the syntax: `for in [where ]") try expect(try parser.parse()).toThrow(error) } @@ -306,6 +306,11 @@ func testForNode() { try expect(result) == "childString=child\nbaseString=base\nbaseInt=1\n" } + $0.it("can iterate in range of variables") { + let template: Template = "{% for i in 1...j %}{{ i }}{% endfor %}" + try expect(try template.render(Context(dictionary: ["j": 3]))) == "123" + } + } } diff --git a/Tests/StencilTests/IfNodeSpec.swift b/Tests/StencilTests/IfNodeSpec.swift index c77122f..a2d6815 100644 --- a/Tests/StencilTests/IfNodeSpec.swift +++ b/Tests/StencilTests/IfNodeSpec.swift @@ -270,5 +270,22 @@ func testIfNode() { let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()])) try expect(result) == "" } + + $0.it("supports closed range variables") { + let tokens: [Token] = [ + .block(value: "if value in 1...3"), + .text(value: "true"), + .block(value: "else"), + .text(value: "false"), + .block(value: "endif") + ] + + let parser = TokenParser(tokens: tokens, environment: Environment()) + let nodes = try parser.parse() + + try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true" + try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false" + } + } } diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index d860433..3ca28cb 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -189,4 +189,52 @@ func testVariable() { try expect(result) == 2 } } + + describe("RangeVariable") { + + let context: Context = { + let ext = Extension() + ext.registerFilter("incr", filter: { (arg: Any?) in toNumber(value: arg!)! + 1 }) + let environment = Environment(extensions: [ext]) + return Context(dictionary: [:], environment: environment) + }() + + func makeVariable(_ token: String) throws -> RangeVariable? { + return try RangeVariable(token, parser: TokenParser(tokens: [], environment: context.environment)) + } + + $0.it("can resolve closed range as array") { + let result = try makeVariable("1...3")?.resolve(context) as? [Int] + try expect(result) == [1, 2, 3] + } + + $0.it("can resolve decreasing closed range as reversed array") { + let result = try makeVariable("3...1")?.resolve(context) as? [Int] + try expect(result) == [3, 2, 1] + } + + $0.it("can use filter on range variables") { + let result = try makeVariable("1|incr...3|incr")?.resolve(context) as? [Int] + try expect(result) == [2, 3, 4] + } + + $0.it("throws when left value is not int") { + let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}" + try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow() + } + + $0.it("throws when right value is not int") { + let variable = try makeVariable("k...j") + try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow() + } + + $0.it("throws is left range value is missing") { + try expect(makeVariable("...1")).toThrow() + } + + $0.it("throws is right range value is missing") { + try expect(makeVariable("1...")).toThrow() + } + + } } From c2e25f25acfbe24442809c055141d8323af8f6cc Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Wed, 4 Apr 2018 18:30:05 -0700 Subject: [PATCH 15/30] Release 0.11.0 --- CHANGELOG.md | 2 +- Stencil.podspec.json | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d10e2b..2ed61aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Stencil Changelog -## Master +## 0.11.0 (2018-04-04) ### Enhancements diff --git a/Stencil.podspec.json b/Stencil.podspec.json index 4215d5a..5980553 100644 --- a/Stencil.podspec.json +++ b/Stencil.podspec.json @@ -1,6 +1,6 @@ { "name": "Stencil", - "version": "0.10.1", + "version": "0.11.0", "summary": "Stencil is a simple and powerful template language for Swift.", "homepage": "https://stencil.fuller.li", "license": { @@ -13,7 +13,7 @@ "social_media_url": "https://twitter.com/kylefuller", "source": { "git": "https://github.com/kylef/Stencil.git", - "tag": "0.10.1" + "tag": "0.11.0" }, "source_files": [ "Sources/*.swift" @@ -25,6 +25,8 @@ }, "requires_arc": true, "dependencies": { - "PathKit": [ "~> 0.8.0" ] + "PathKit": [ + "~> 0.8.0" + ] } } From 88e54ab4baed125b728e98845c28ac55abdd496b Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Tue, 17 Apr 2018 20:25:15 +0200 Subject: [PATCH 16/30] docs: Fix typo of precedence (#212) --- docs/builtins.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/builtins.rst b/docs/builtins.rst index d0590bb..c1f46ad 100644 --- a/docs/builtins.rst +++ b/docs/builtins.rst @@ -124,7 +124,7 @@ or to negate a variable. {% endif %} You may use ``and``, ``or`` and ``not`` multiple times together. ``not`` has -higest prescidence followed by ``and``. For example: +higest precedence followed by ``and``. For example: .. code-block:: html+django From 7c499cc077ebc52613f8a94cde39559de23aac2b Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Tue, 17 Apr 2018 19:28:28 +0100 Subject: [PATCH 17/30] fix(tokeniser): Tokenising a quote inside quoted parameter (#210) --- CHANGELOG.md | 7 +++++++ Sources/Tokenizer.swift | 2 +- Tests/StencilTests/FilterTagSpec.swift | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed61aa..8bba374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Stencil Changelog +## Master + +### Bug Fixes + +- Fixed using quote as a filter parameter + + ## 0.11.0 (2018-04-04) ### Enhancements diff --git a/Sources/Tokenizer.swift b/Sources/Tokenizer.swift index 0e4bf1e..81680f2 100644 --- a/Sources/Tokenizer.swift +++ b/Sources/Tokenizer.swift @@ -18,7 +18,7 @@ extension String { if separate != separator { word.append(separate) - } else if singleQuoteCount % 2 == 0 && doubleQuoteCount % 2 == 0 && !word.isEmpty { + } else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty { components.append(word) word = "" } diff --git a/Tests/StencilTests/FilterTagSpec.swift b/Tests/StencilTests/FilterTagSpec.swift index e280668..c286449 100644 --- a/Tests/StencilTests/FilterTagSpec.swift +++ b/Tests/StencilTests/FilterTagSpec.swift @@ -30,5 +30,16 @@ func testFilterTag() { let result = try env.renderTemplate(string: "{% filter split:\",\"|join:\";\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": [1, 2]]) try expect(result) == "1;2" } + + $0.it("can render filters with quote as an argument") { + let ext = Extension() + ext.registerFilter("replace", filter: { + print($1[0] as! String) + return ($0 as! String).replacingOccurrences(of: $1[0] as! String, with: $1[1] as! String) + }) + let env = Environment(extensions: [ext]) + let result = try env.renderTemplate(string: "{% filter replace:'\"',\"\" %}{{ items|join:\",\" }}{% endfilter %}", context: ["items": ["\"1\"", "\"2\""]]) + try expect(result) == "1,2" + } } } From 7679b48164331a54eb20e3a85fd2ac2b426a7390 Mon Sep 17 00:00:00 2001 From: Yonas Kolb Date: Mon, 30 Apr 2018 11:45:22 +1000 Subject: [PATCH 18/30] add contex to include --- Sources/Include.swift | 13 ++++++++----- Tests/StencilTests/IncludeSpec.swift | 9 ++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Sources/Include.swift b/Sources/Include.swift index cd9cc5c..3fc9051 100644 --- a/Sources/Include.swift +++ b/Sources/Include.swift @@ -3,19 +3,21 @@ import PathKit class IncludeNode : NodeType { let templateName: Variable + let includeContext: String? class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { let bits = token.components() - guard bits.count == 2 else { - throw TemplateSyntaxError("'include' tag takes one argument, the template file to be included") + guard bits.count == 2 || (bits.count == 4 && bits[2] == "using") else { + throw TemplateSyntaxError("'include' tag requires one argument, the template file to be included. Another optional argument can be used to specify the context that will be passed to the included file, using the format \"using myContext\"") } - return IncludeNode(templateName: Variable(bits[1])) + return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 4 ? bits[3] : nil) } - init(templateName: Variable) { + init(templateName: Variable, includeContext: String? = nil) { self.templateName = templateName + self.includeContext = includeContext } func render(_ context: Context) throws -> String { @@ -25,7 +27,8 @@ class IncludeNode : NodeType { let template = try context.environment.loadTemplate(name: templateName) - return try context.push { + let subContext = includeContext.flatMap{ context[$0] as? [String: Any] } + return try context.push(dictionary: subContext) { return try template.render(context) } } diff --git a/Tests/StencilTests/IncludeSpec.swift b/Tests/StencilTests/IncludeSpec.swift index 0297896..cf785e2 100644 --- a/Tests/StencilTests/IncludeSpec.swift +++ b/Tests/StencilTests/IncludeSpec.swift @@ -14,7 +14,7 @@ func testInclude() { let tokens: [Token] = [ .block(value: "include") ] let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError("'include' tag takes one argument, the template file to be included") + let error = TemplateSyntaxError("'include' tag requires one argument, the template file to be included. Another optional argument can be used to specify the context that will be passed to the included file, using the format \"using myContext\"") try expect(try parser.parse()).toThrow(error) } @@ -56,6 +56,13 @@ func testInclude() { let value = try node.render(context) try expect(value) == "Hello World!" } + + $0.it("successfully passes context") { + let template = Template(templateString: "{% include \"test.html\" using child %}") + let context = Context(dictionary: ["child": ["target": "World"]], environment: environment) + let value = try template.render(context) + try expect(value) == "Hello World!" + } } } } From 098af2a7b68ffa9ee7e40e0318a31d997d96409d Mon Sep 17 00:00:00 2001 From: Yonas Kolb Date: Mon, 30 Apr 2018 12:00:54 +1000 Subject: [PATCH 19/30] remove "using" param name --- Sources/Include.swift | 6 +++--- Tests/StencilTests/IncludeSpec.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Include.swift b/Sources/Include.swift index 3fc9051..1bccb22 100644 --- a/Sources/Include.swift +++ b/Sources/Include.swift @@ -8,11 +8,11 @@ class IncludeNode : NodeType { class func parse(_ parser: TokenParser, token: Token) throws -> NodeType { let bits = token.components() - guard bits.count == 2 || (bits.count == 4 && bits[2] == "using") else { - throw TemplateSyntaxError("'include' tag requires one argument, the template file to be included. Another optional argument can be used to specify the context that will be passed to the included file, using the format \"using myContext\"") + guard bits.count == 2 || bits.count == 3 else { + throw TemplateSyntaxError("'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file") } - return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 4 ? bits[3] : nil) + return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil) } init(templateName: Variable, includeContext: String? = nil) { diff --git a/Tests/StencilTests/IncludeSpec.swift b/Tests/StencilTests/IncludeSpec.swift index cf785e2..1ad004f 100644 --- a/Tests/StencilTests/IncludeSpec.swift +++ b/Tests/StencilTests/IncludeSpec.swift @@ -14,7 +14,7 @@ func testInclude() { let tokens: [Token] = [ .block(value: "include") ] let parser = TokenParser(tokens: tokens, environment: Environment()) - let error = TemplateSyntaxError("'include' tag requires one argument, the template file to be included. Another optional argument can be used to specify the context that will be passed to the included file, using the format \"using myContext\"") + let error = TemplateSyntaxError("'include' tag requires one argument, the template file to be included. A second optional argument can be used to specify the context that will be passed to the included file") try expect(try parser.parse()).toThrow(error) } @@ -58,7 +58,7 @@ func testInclude() { } $0.it("successfully passes context") { - let template = Template(templateString: "{% include \"test.html\" using child %}") + let template = Template(templateString: "{% include \"test.html\" child %}") let context = Context(dictionary: ["child": ["target": "World"]], environment: environment) let value = try template.render(context) try expect(value) == "Hello World!" From eb8c875853b70f5da45c52b7e0b2a38015142013 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Sat, 5 May 2018 23:33:11 +0100 Subject: [PATCH 20/30] docs: Update documentation (#213) * Update documentation * Update builtins.rst --- docs/builtins.rst | 48 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/builtins.rst b/docs/builtins.rst index c1f46ad..9d61eca 100644 --- a/docs/builtins.rst +++ b/docs/builtins.rst @@ -28,6 +28,18 @@ The ``for`` tag can iterate over dictionaries.
  • {{ key }}: {{ value }}
  • {% endfor %} + +It can also iterate over ranges, tuple elements, structs' and classes' stored properties (using ``Mirror``). + +You can iterate over range literals created using ``N...M`` syntax, both in ascending and descending order: + +.. code-block:: html+django + +
      + {% for i in 1...array.count %} +
    • {{ i }}
    • + {% endfor %} +
    The ``for`` tag can contain optional ``where`` expression to filter out elements on which this expression evaluates to false. @@ -59,6 +71,7 @@ The for block sets a few variables available within the loop: - ``last`` - True if this is the last time through the loop - ``counter`` - The current iteration of the loop (1 indexed) - ``counter0`` - The current iteration of the loop (0 indexed) +- ``length`` - The total length of the loop For example: @@ -281,7 +294,7 @@ Built-in Filters ~~~~~~~~~~~~~~ The capitalize filter allows you to capitalize a string. -For example, `stencil` to `Stencil`. +For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string. .. code-block:: html+django @@ -291,7 +304,7 @@ For example, `stencil` to `Stencil`. ~~~~~~~~~~~~~ The uppercase filter allows you to transform a string to uppercase. -For example, `Stencil` to `STENCIL`. +For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string. .. code-block:: html+django @@ -301,7 +314,7 @@ For example, `Stencil` to `STENCIL`. ~~~~~~~~~~~~~ The uppercase filter allows you to transform a string to lowercase. -For example, `Stencil` to `stencil`. +For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string. .. code-block:: html+django @@ -326,4 +339,31 @@ Join an array of items. {{ value|join:", " }} -.. note:: The value MUST be an array. +.. note:: The value MUST be an array. Default argument value is empty string. + +``split`` +~~~~~~~~~ + +Split string into substrings by separator. + +.. code-block:: html+django + + {{ value|split:", " }} + +.. note:: The value MUST be a String. Default argument value is a single-space string. + +``indent`` +~~~~~~~~~ + +Indents lines of rendered value or block. + +.. code-block:: html+django + + {{ value|indent:2," ",true }} + +Filter accepts several arguments: + +* indentation width: number of indentation characters to indent lines with. Default is ``4``. +* indentation character: character to be used for indentation. Default is a space. +* indent first line: whether first line of output should be indented or not. Default is ``false``. + From 34dbafa78933572f13e86b03244f7d631fbdac55 Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Sat, 5 May 2018 17:55:14 -0700 Subject: [PATCH 21/30] docs: Update link to Travis CI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 668c7cd..290ccce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stencil -[![Build Status](https://travis-ci.org/kylef/Stencil.svg?branch=master)](https://travis-ci.org/kylef/Stencil) +[![Build Status](https://travis-ci.org/stencilproject/Stencil.svg?branch=master)](https://travis-ci.org/stencilproject/Stencil) Stencil is a simple and powerful template language for Swift. It provides a syntax similar to Django and Mustache. If you're familiar with these, you will From fc6c0208b29fcc9de2b0851bdd783493bb43ac70 Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Sat, 5 May 2018 17:56:03 -0700 Subject: [PATCH 22/30] fix(cocoapods): Update link to source repository --- Stencil.podspec.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Stencil.podspec.json b/Stencil.podspec.json index 5980553..a173c05 100644 --- a/Stencil.podspec.json +++ b/Stencil.podspec.json @@ -12,7 +12,7 @@ }, "social_media_url": "https://twitter.com/kylefuller", "source": { - "git": "https://github.com/kylef/Stencil.git", + "git": "https://github.com/stencilproject/Stencil.git", "tag": "0.11.0" }, "source_files": [ From 1427e10698f5ac500f0b9c9263984621fd13ec6e Mon Sep 17 00:00:00 2001 From: Yonas Kolb Date: Mon, 7 May 2018 18:45:02 +1000 Subject: [PATCH 23/30] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bba374..ba13737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Master +### Enhancements + +- added an optional second parameter to the `include` tag for passing a sub context to the included file + ### Bug Fixes - Fixed using quote as a filter parameter From 47f2b33d80e41de8988ca700f62c6f1beffc126d Mon Sep 17 00:00:00 2001 From: Yonas Kolb Date: Mon, 7 May 2018 18:45:17 +1000 Subject: [PATCH 24/30] code formatting --- Sources/Include.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Include.swift b/Sources/Include.swift index 1bccb22..560bb74 100644 --- a/Sources/Include.swift +++ b/Sources/Include.swift @@ -27,7 +27,7 @@ class IncludeNode : NodeType { let template = try context.environment.loadTemplate(name: templateName) - let subContext = includeContext.flatMap{ context[$0] as? [String: Any] } + let subContext = includeContext.flatMap { context[$0] as? [String: Any] } return try context.push(dictionary: subContext) { return try template.render(context) } From 1e77f1e85f5a1226f2d5e82104a6ef23713073ae Mon Sep 17 00:00:00 2001 From: Yonas Kolb Date: Mon, 7 May 2018 18:46:20 +1000 Subject: [PATCH 25/30] document new include param --- docs/builtins.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/builtins.rst b/docs/builtins.rst index 9d61eca..d4cc99a 100644 --- a/docs/builtins.rst +++ b/docs/builtins.rst @@ -260,6 +260,12 @@ You can include another template using the `include` tag. {% include "comment.html" %} +By default the included file gets passed the current context. You can pass a sub context by using an optional 2nd parameter as a lookup in the current context. + +.. code-block:: html+django + + {% include "comment.html" comment %} + The `include` tag requires you to provide a loader which will be used to lookup the template. From 2627d3e0d12850cbb9a9d626a5594781ba24fb01 Mon Sep 17 00:00:00 2001 From: Yonas Kolb Date: Thu, 10 May 2018 17:50:59 +1000 Subject: [PATCH 26/30] update changelog formatting --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba13737..6c87fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ ### Enhancements -- added an optional second parameter to the `include` tag for passing a sub context to the included file +- Added an optional second parameter to the `include` tag for passing a sub context to the included file. + [Yonas Kolb](https://github.com/yonaskolb) + [#394](https://github.com/stencilproject/Stencil/pull/214) ### Bug Fixes From 39ed9aa7536d71d28d78313a12e0df7a0d8f3777 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Sun, 13 May 2018 01:06:38 +0100 Subject: [PATCH 27/30] Fixed using spaces in filter expressions and variables lists (#178) * fixed using spaces in filter expression * fixed breaking variables lists and filters by spaces * simplified smartJoin * avoid force unwrap --- CHANGELOG.md | 6 ++++++ Sources/ForTag.swift | 2 +- Sources/Tokenizer.swift | 19 +++++++++++++++++-- Sources/Variable.swift | 4 ++-- Tests/StencilTests/FilterSpec.swift | 6 +++--- Tests/StencilTests/ForNodeSpec.swift | 6 +++--- 6 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c87fe7..5f009cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,15 @@ [Yonas Kolb](https://github.com/yonaskolb) [#394](https://github.com/stencilproject/Stencil/pull/214) +- Adds support for using spaces in filter expression + [Ilya Puchka](https://github.com/yonaskolb) + [#178](https://github.com/stencilproject/Stencil/pull/178) + ### Bug Fixes - Fixed using quote as a filter parameter + [Ilya Puchka](https://github.com/yonaskolb) + [#210](https://github.com/stencilproject/Stencil/pull/210) ## 0.11.0 (2018-04-04) diff --git a/Sources/ForTag.swift b/Sources/ForTag.swift index cb96657..4d7eff2 100644 --- a/Sources/ForTag.swift +++ b/Sources/ForTag.swift @@ -24,7 +24,7 @@ class ForNode : NodeType { let loopVariables = components[1].characters .split(separator: ",") .map(String.init) - .map { $0.trimmingCharacters(in: CharacterSet.whitespaces) } + .map { $0.trim(character: " ") } var emptyNodes = [NodeType]() diff --git a/Sources/Tokenizer.swift b/Sources/Tokenizer.swift index 81680f2..bb3320b 100644 --- a/Sources/Tokenizer.swift +++ b/Sources/Tokenizer.swift @@ -10,6 +10,21 @@ extension String { var singleQuoteCount = 0 var doubleQuoteCount = 0 + let specialCharacters = ",|:" + func appendWord(_ word: String) { + if components.count > 0 { + if let precedingChar = components.last?.characters.last, specialCharacters.characters.contains(precedingChar) { + components[components.count-1] += word + } else if specialCharacters.contains(word) { + components[components.count-1] += word + } else { + components.append(word) + } + } else { + components.append(word) + } + } + for character in self.characters { if character == "'" { singleQuoteCount += 1 } else if character == "\"" { doubleQuoteCount += 1 } @@ -19,7 +34,7 @@ extension String { if separate != separator { word.append(separate) } else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty { - components.append(word) + appendWord(word) word = "" } @@ -33,7 +48,7 @@ extension String { } if !word.isEmpty { - components.append(word) + appendWord(word) } return components diff --git a/Sources/Variable.swift b/Sources/Variable.swift index 9563c89..b357021 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -209,11 +209,11 @@ extension Dictionary : Normalizable { func parseFilterComponents(token: String) -> (String, [Variable]) { var components = token.smartSplit(separator: ":") - let name = components.removeFirst() + let name = components.removeFirst().trim(character: " ") let variables = components .joined(separator: ":") .smartSplit(separator: ",") - .map { Variable($0) } + .map { Variable($0.trim(character: " ")) } return (name, variables) } diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index 5224c12..f8de1b8 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -78,9 +78,9 @@ func testFilter() { } $0.it("allows whitespace in expression") { - let template = Template(templateString: "{{ name | uppercase }}") - let result = try template.render(Context(dictionary: ["name": "kyle"])) - try expect(result) == "KYLE" + let template = Template(templateString: "{{ value | join : \", \" }}") + let result = try template.render(Context(dictionary: ["value": ["One", "Two"]])) + try expect(result) == "One, Two" } $0.it("throws when you pass arguments to simple filter") { diff --git a/Tests/StencilTests/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index a8b90df..4bb3fca 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -111,8 +111,8 @@ func testForNode() { try expect(try node.render(context)) == "empty" } - $0.it("can render a filter") { - let templateString = "{% for article in ars|default:articles %}" + + $0.it("can render a filter with spaces") { + let templateString = "{% for article in ars | default: a, b , articles %}" + "- {{ article.title }} by {{ article.author }}.\n" + "{% endfor %}\n" @@ -182,7 +182,7 @@ func testForNode() { } $0.it("can iterate over dictionary") { - let templateString = "{% for key,value in dict %}" + + let templateString = "{% for key, value in dict %}" + "{{ key }}: {{ value }}," + "{% endfor %}" From 2e18892f4c02406a90cb96b2ebb1a2438c6655da Mon Sep 17 00:00:00 2001 From: David Jennes Date: Sat, 19 May 2018 22:03:51 +0200 Subject: [PATCH 28/30] Subscript syntax for Variables (#215) * Implement variable indirect resolution * Add some tests * Changelog entry * Update documentation * Rework the syntax to use brackets instead of a $ * Move the lookup parser into it's own file * Add invalid syntax tests * Swift 3 support * Rename some things + extra test --- CHANGELOG.md | 10 ++- Sources/KeyPath.swift | 112 ++++++++++++++++++++++++++ Sources/Variable.swift | 8 +- Tests/StencilTests/VariableSpec.swift | 92 +++++++++++++++++++++ docs/templates.rst | 18 +++++ 5 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 Sources/KeyPath.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f009cb..0bd445b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ - Added an optional second parameter to the `include` tag for passing a sub context to the included file. [Yonas Kolb](https://github.com/yonaskolb) - [#394](https://github.com/stencilproject/Stencil/pull/214) + [#214](https://github.com/stencilproject/Stencil/pull/214) +- Variables now support the subscript notation. For example, if you have a variable `key = "name"`, and an + object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John". + [David Jennes](https://github.com/djbe) + [#215](https://github.com/stencilproject/Stencil/pull/215) - Adds support for using spaces in filter expression [Ilya Puchka](https://github.com/yonaskolb) @@ -14,8 +18,8 @@ ### Bug Fixes -- Fixed using quote as a filter parameter - [Ilya Puchka](https://github.com/yonaskolb) +- Fixed using quote as a filter parameter. + [Ilya Puchka](https://github.com/ilyapuchka) [#210](https://github.com/stencilproject/Stencil/pull/210) diff --git a/Sources/KeyPath.swift b/Sources/KeyPath.swift new file mode 100644 index 0000000..445ef29 --- /dev/null +++ b/Sources/KeyPath.swift @@ -0,0 +1,112 @@ +import Foundation + +/// A structure used to represent a template variable, and to resolve it in a given context. +final class KeyPath { + private var components = [String]() + private var current = "" + private var partialComponents = [String]() + private var subscriptLevel = 0 + + let variable: String + let context: Context + + // Split the keypath string and resolve references if possible + init(_ variable: String, in context: Context) { + self.variable = variable + self.context = context + } + + func parse() throws -> [String] { + defer { + components = [] + current = "" + partialComponents = [] + subscriptLevel = 0 + } + + for c in variable.characters { + switch c { + case "." where subscriptLevel == 0: + try foundSeparator() + case "[": + try openBracket() + case "]": + try closeBracket() + default: + try addCharacter(c) + } + } + try finish() + + return components + } + + private func foundSeparator() throws { + if !current.isEmpty { + partialComponents.append(current) + } + + guard !partialComponents.isEmpty else { + throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'") + } + + components += partialComponents + current = "" + partialComponents = [] + } + + // when opening the first bracket, we must have a partial component + private func openBracket() throws { + guard !partialComponents.isEmpty || !current.isEmpty else { + throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'") + } + + if subscriptLevel > 0 { + current.append("[") + } else if !current.isEmpty { + partialComponents.append(current) + current = "" + } + + subscriptLevel += 1 + } + + // for a closing bracket at root level, try to resolve the reference + private func closeBracket() throws { + guard subscriptLevel > 0 else { + throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'") + } + + if subscriptLevel > 1 { + current.append("]") + } else if !current.isEmpty, + let value = try Variable(current).resolve(context) { + partialComponents.append("\(value)") + current = "" + } else { + throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'") + } + + subscriptLevel -= 1 + } + + private func addCharacter(_ c: Character) throws { + guard partialComponents.isEmpty || subscriptLevel > 0 else { + throw TemplateSyntaxError("Unexpected character '\(c)' in variable '\(variable)'") + } + + current.append(c) + } + + private func finish() throws { + // check if we have a last piece + if !current.isEmpty { + partialComponents.append(current) + } + components += partialComponents + + guard subscriptLevel == 0 else { + throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'") + } + } +} diff --git a/Sources/Variable.swift b/Sources/Variable.swift index b357021..262ccb5 100644 --- a/Sources/Variable.swift +++ b/Sources/Variable.swift @@ -50,8 +50,10 @@ public struct Variable : Equatable, Resolvable { self.variable = variable } - fileprivate func lookup() -> [String] { - return variable.characters.split(separator: ".").map(String.init) + // Split the lookup string and resolve references if possible + fileprivate func lookup(_ context: Context) throws -> [String] { + var keyPath = KeyPath(variable, in: context) + return try keyPath.parse() } /// Resolve the variable in the given context @@ -75,7 +77,7 @@ public struct Variable : Equatable, Resolvable { return bool } - for bit in lookup() { + for bit in try lookup(context) { current = normalize(current) if let context = current as? Context { diff --git a/Tests/StencilTests/VariableSpec.swift b/Tests/StencilTests/VariableSpec.swift index 3ca28cb..7b386bc 100644 --- a/Tests/StencilTests/VariableSpec.swift +++ b/Tests/StencilTests/VariableSpec.swift @@ -188,6 +188,98 @@ func testVariable() { let result = try variable.resolve(context) as? Int try expect(result) == 2 } + + $0.describe("Subrscripting") { + $0.it("can resolve a property subscript via reflection") { + try context.push(dictionary: ["property": "name"]) { + let variable = Variable("article.author[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("can subscript an array with a valid index") { + try context.push(dictionary: ["property": 0]) { + let variable = Variable("contacts[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Katie" + } + } + + $0.it("can subscript an array with an unknown index") { + try context.push(dictionary: ["property": 5]) { + let variable = Variable("contacts[property]") + let result = try variable.resolve(context) as? String + try expect(result).to.beNil() + } + } + +#if os(OSX) + $0.it("can resolve a subscript via KVO") { + try context.push(dictionary: ["property": "name"]) { + let variable = Variable("object[property]") + let result = try variable.resolve(context) as? String + try expect(result) == "Foo" + } + } +#endif + + $0.it("can resolve an optional subscript via reflection") { + try context.push(dictionary: ["property": "featuring"]) { + let variable = Variable("blog[property].author.name") + let result = try variable.resolve(context) as? String + try expect(result) == "Jhon" + } + } + + $0.it("can resolve multiple subscripts") { + try context.push(dictionary: [ + "prop1": "articles", + "prop2": 0, + "prop3": "name" + ]) { + let variable = Variable("blog[prop1][prop2].author[prop3]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("can resolve nested subscripts") { + try context.push(dictionary: [ + "prop1": "prop2", + "ref": ["prop2": "name"] + ]) { + let variable = Variable("article.author[ref[prop1]]") + let result = try variable.resolve(context) as? String + try expect(result) == "Kyle" + } + } + + $0.it("throws for invalid keypath syntax") { + try context.push(dictionary: ["prop": "name"]) { + let samples = [ + ".", + "..", + ".test", + "test..test", + "[prop]", + "article.author[prop", + "article.author[[prop]", + "article.author[prop]]", + "article.author[]", + "article.author[[]]", + "article.author[prop][]", + "article.author[prop]comments", + "article.author[.]" + ] + + for lookup in samples { + let variable = Variable(lookup) + try expect(variable.resolve(context)).toThrow() + } + } + } + } } describe("RangeVariable") { diff --git a/docs/templates.rst b/docs/templates.rst index 1934abe..147be45 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -31,6 +31,24 @@ For example, if `people` was an array: There are {{ people.count }} people. {{ people.first }} is the first person, followed by {{ people.1 }}. +You can also use the subscript operator for indirect evaluation. The expression +between brackets will be evaluated first, before the actual lookup will happen. + +For example, if you have the following context: + +.. code-block:: swift + + [ + "item": [ + "name": "John" + ], + "key": "name" + ] + +.. code-block:: html+django + + The result of {{ item[key] }} will be the same as {{ item.name }}. It will first evaluate the result of {{ key }}, and only then evaluate the lookup expression. + Filters ~~~~~~~ From 3995ff9acfb25812ca4ad9b835d457e2e1cbe75e Mon Sep 17 00:00:00 2001 From: Theophane RUPIN Date: Sun, 20 May 2018 16:52:22 -0700 Subject: [PATCH 29/30] Added Weaver to the list of projects using Stencil --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 290ccce..75ce68b 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ Resources to help you integrate Stencil into a Swift project: [Sourcery](https://github.com/krzysztofzablocki/Sourcery), [SwiftGen](https://github.com/SwiftGen/SwiftGen), -[Kitura](https://github.com/IBM-Swift/Kitura) +[Kitura](https://github.com/IBM-Swift/Kitura), +[Weaver](https://github.com/scribd/Weaver) ## License From b66abc3112cc14833f1d265e6663b85fd1accb69 Mon Sep 17 00:00:00 2001 From: David Jennes Date: Wed, 11 Jul 2018 23:11:13 +0200 Subject: [PATCH 30/30] Update CHANGELOG.md --- CHANGELOG.md | 79 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd445b..d84c9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,7 @@ object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John". [David Jennes](https://github.com/djbe) [#215](https://github.com/stencilproject/Stencil/pull/215) - -- Adds support for using spaces in filter expression +- Adds support for using spaces in filter expression. [Ilya Puchka](https://github.com/yonaskolb) [#178](https://github.com/stencilproject/Stencil/pull/178) @@ -27,28 +26,64 @@ ### Enhancements -- Added support for resolving superclass properties for not-NSObject subclasses +- Added support for resolving superclass properties for not-NSObject subclasses. + [Ilya Puchka](https://github.com/ilyapuchka) + [#152](https://github.com/stencilproject/Stencil/pull/152) - The `{% for %}` tag can now iterate over tuples, structures and classes via - their stored properties. -- Added `split` filter -- Allow default string filters to be applied to arrays -- Similar filters are suggested when unknown filter is used -- Added `indent` filter -- Allow using new lines inside tags -- Added support for iterating arrays of tuples -- Added support for ranges in if-in expression -- Added property `forloop.length` to get number of items in the loop -- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count` + their stored properties. + [Ilya Puchka](https://github.com/ilyapuchka) + [#172](https://github.com/stencilproject/Stencil/pull/173) +- Added `split` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#187](https://github.com/stencilproject/Stencil/pull/187) +- Allow default string filters to be applied to arrays. + [Ilya Puchka](https://github.com/ilyapuchka) + [#190](https://github.com/stencilproject/Stencil/pull/190) +- Similar filters are suggested when unknown filter is used. + [Ilya Puchka](https://github.com/ilyapuchka) + [#186](https://github.com/stencilproject/Stencil/pull/186) +- Added `indent` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#188](https://github.com/stencilproject/Stencil/pull/188) +- Allow using new lines inside tags. + [Ilya Puchka](https://github.com/ilyapuchka) + [#202](https://github.com/stencilproject/Stencil/pull/202) +- Added support for iterating arrays of tuples. + [Ilya Puchka](https://github.com/ilyapuchka) + [#177](https://github.com/stencilproject/Stencil/pull/177) +- Added support for ranges in if-in expression. + [Ilya Puchka](https://github.com/ilyapuchka) + [#193](https://github.com/stencilproject/Stencil/pull/193) +- Added property `forloop.length` to get number of items in the loop. + [Ilya Puchka](https://github.com/ilyapuchka) + [#171](https://github.com/stencilproject/Stencil/pull/171) +- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#192](https://github.com/stencilproject/Stencil/pull/192) ### Bug Fixes -- Fixed rendering `{{ block.super }}` with several levels of inheritance -- Fixed checking dictionary values for nil in `default` filter -- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. -- Integer literals now resolve into Int values, not Float -- Fixed accessing properties of optional properties via reflection -- No longer render optional values in arrays as `Optional(..)` -- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}` +- Fixed rendering `{{ block.super }}` with several levels of inheritance. + [Ilya Puchka](https://github.com/ilyapuchka) + [#154](https://github.com/stencilproject/Stencil/pull/154) +- Fixed checking dictionary values for nil in `default` filter. + [Ilya Puchka](https://github.com/ilyapuchka) + [#162](https://github.com/stencilproject/Stencil/pull/162) +- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings. + [Ilya Puchka](https://github.com/ilyapuchka) + [#168](https://github.com/stencilproject/Stencil/pull/168) +- Integer literals now resolve into Int values, not Float. + [Ilya Puchka](https://github.com/ilyapuchka) + [#181](https://github.com/stencilproject/Stencil/pull/181) +- Fixed accessing properties of optional properties via reflection. + [Ilya Puchka](https://github.com/ilyapuchka) + [#204](https://github.com/stencilproject/Stencil/pull/204) +- No longer render optional values in arrays as `Optional(..)`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#205](https://github.com/stencilproject/Stencil/pull/205) +- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}`. + [Ilya Puchka](https://github.com/ilyapuchka) + [#172](https://github.com/stencilproject/Stencil/pull/172) ## 0.10.1 @@ -249,10 +284,10 @@ ### Bug Fixes - Variables (`{{ variable.5 }}`) that reference an array index at an unknown - index will now resolve to `nil` instead of causing a crash. + index will now resolve to `nil` instead of causing a crash. [#72](https://github.com/kylef/Stencil/issues/72) -- Templates can now extend templates that extend other templates. +- Templates can now extend templates that extend other templates. [#60](https://github.com/kylef/Stencil/issues/60) - If comparisons will now treat 0 and below numbers as negative.