From c30597457fafa0cd77783862653fdf85c9b16e6a Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Mon, 22 Jan 2018 01:49:32 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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..