From c4a84a637555f29f4ff8a70c23729ce93b9bce42 Mon Sep 17 00:00:00 2001 From: Ilya Puchka Date: Mon, 22 Jan 2018 18:20:42 +0100 Subject: [PATCH 1/5] 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 bd80299a..47ccabb3 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 cf8f0fcb..aa54443a 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 bb24e602..660cf18d 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" + } } - } - - describe("uppercase filter") { - let template = Template(templateString: "{{ name|uppercase }}") - - $0.it("transforms a string to be uppercase") { - 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("lowercase filter") { - let template = Template(templateString: "{{ name|lowercase }}") + $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 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 2/5] 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 e366cf9b..abda9487 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 00000000..704b083f --- /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 3/5] 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 47ccabb3..fbc1c1a6 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 1a59edba..5d9a1ec5 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 4/5] 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 ded2ca13..668c7cdc 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 5/5] 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 fbc1c1a6..e1a5af3d 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 a3892766..33a9925a 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 aa54443a..fece6ebd 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 c17b9660..baa55948 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 b0724772..5224c121 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 2470450b..e2806681 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 1a567d8a..6937e35e 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