diff --git a/CHANGELOG.md b/CHANGELOG.md index c1873f4a..c023f215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ - 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`, `map`, `compact` and `filter` filters +- Allow default string filters to be applied to arrays +- Similar filters are suggested when unknown filter is used +- Added `indent`, `split`, `map`, `compact` and `filter` filters ### Bug Fixes 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), + ] +) 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 diff --git a/Sources/Extension.swift b/Sources/Extension.swift index 97894676..711d50f1 100644 --- a/Sources/Extension.swift +++ b/Sources/Extension.swift @@ -62,6 +62,7 @@ class DefaultExtension: Extension { registerFilter("lowercase", filter: lowercase) registerFilter("join", filter: joinFilter) registerFilter("split", filter: splitFilter) + registerFilter("indent", filter: indentFilter) registerFilter("map", filter: mapFilter) registerFilter("compact", filter: compactFilter) registerFilter("filter", filter: filterFilter) diff --git a/Sources/Filters.swift b/Sources/Filters.swift index 6df4bedc..7ff33f41 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? { @@ -54,6 +66,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") +} func mapFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? { guard arguments.count >= 1 && arguments.count <= 2 else { diff --git a/Sources/Parser.swift b/Sources/Parser.swift index d01b0518..7ed968c3 100644 --- a/Sources/Parser.swift +++ b/Sources/Parser.swift @@ -101,7 +101,68 @@ extension Environment { } } - 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 = 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 }) + } + +} + +// 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..", "rhs"], tokenParser: parser) + let expression = try! parser.compileExpression(components: ["lhs", ">", "rhs"]) $0.it("evaluates to true with lhs > rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue() @@ -217,7 +217,7 @@ func testExpressions() { } $0.describe("more than equal expression") { - let expression = try! parseExpression(components: ["lhs", ">=", "rhs"], tokenParser: parser) + let expression = try! parser.compileExpression(components: ["lhs", ">=", "rhs"]) $0.it("evaluates to true with lhs == rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue() @@ -229,7 +229,7 @@ func testExpressions() { } $0.describe("less than expression") { - let expression = try! parseExpression(components: ["lhs", "<", "rhs"], tokenParser: parser) + let expression = try! parser.compileExpression(components: ["lhs", "<", "rhs"]) $0.it("evaluates to true with lhs < rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue() @@ -241,7 +241,7 @@ func testExpressions() { } $0.describe("less than equal expression") { - let expression = try! parseExpression(components: ["lhs", "<=", "rhs"], tokenParser: parser) + let expression = try! parser.compileExpression(components: ["lhs", "<=", "rhs"]) $0.it("evaluates to true with lhs == rhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue() @@ -253,7 +253,7 @@ func testExpressions() { } $0.describe("multiple expression") { - let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"], tokenParser: parser) + let expression = try! parser.compileExpression(components: ["one", "or", "two", "and", "not", "three"]) $0.it("evaluates to true with one") { try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue() @@ -281,7 +281,7 @@ func testExpressions() { } $0.describe("in expression") { - let expression = try! parseExpression(components: ["lhs", "in", "rhs"], tokenParser: parser) + let expression = try! parser.compileExpression(components: ["lhs", "in", "rhs"]) $0.it("evaluates to true when rhs contains lhs") { try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [1, 2, 3]]))).to.beTrue() diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index 56ed15a1..4156ded9 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -92,32 +92,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 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 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\"]" + } } } @@ -202,6 +215,63 @@ func testFilter() { } } + describe("filter suggestion") { + + $0.it("made for unknown filter") { + let template = Template(templateString: "{{ value|unknownFilter }}") + let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'") + + let filterExtension = Extension() + filterExtension.registerFilter("knownFilter") { value, _ in value } + + try expect(template.render(Context(dictionary: [:], environment: Environment(extensions: [filterExtension])))).toThrow(expectedError) + } + + $0.it("made for multiple similar filters") { + let template = Template(templateString: "{{ value|lowerFirst }}") + let expectedError = TemplateSyntaxError("Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'") + + let filterExtension = Extension() + filterExtension.registerFilter("lowerFirstWord") { value, _ in value } + filterExtension.registerFilter("lowerFirstLetter") { value, _ in value } + + try expect(template.render(Context(dictionary: [:], environment: Environment(extensions: [filterExtension])))).toThrow(expectedError) + } + + $0.it("not made when can't find similar filter") { + let template = Template(templateString: "{{ value|unknownFilter }}") + let expectedError = TemplateSyntaxError("Unknown filter 'unknownFilter'.") + try expect(template.render(Context(dictionary: [:]))).toThrow(expectedError) + } + + } + + 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" + } + } + describe("map filter") { $0.it("can map over attribute") { 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/ForNodeSpec.swift b/Tests/StencilTests/ForNodeSpec.swift index 9cc98cb7..447cf97c 100644 --- a/Tests/StencilTests/ForNodeSpec.swift +++ b/Tests/StencilTests/ForNodeSpec.swift @@ -91,7 +91,8 @@ func testForNode() { $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())) + let parser = TokenParser(tokens: [], environment: Environment()) + let `where` = try parser.compileExpression(components: ["item", ">", "1"]) let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [], where: `where`) try expect(try node.render(context)) == "2132" } @@ -99,7 +100,8 @@ func testForNode() { $0.it("renders the given empty nodes when all items filtered out with where expression") { let nodes: [NodeType] = [VariableNode(variable: "item")] let emptyNodes: [NodeType] = [TextNode(text: "empty")] - let `where` = try parseExpression(components: ["item", "==", "0"], tokenParser: TokenParser(tokens: [], environment: Environment())) + let parser = TokenParser(tokens: [], environment: Environment()) + let `where` = try parser.compileExpression(components: ["item", "==", "0"]) let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes, where: `where`) try expect(try node.render(context)) == "empty" } 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