diff --git a/CHANGELOG.md b/CHANGELOG.md index 752d7c95..45bbe5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ _None_ , i.e. `myfilter = "uppercase"` and then use it to invoke this filter with `{{ string|filter:myfilter }}`. [Ilya Puchka](https://github.com/ilyapuchka) [#203](https://github.com/stencilproject/Stencil/pull/203) + +- Added `map`, `compact` and `select` filters. + [Ilya Puchka](https://github.com/ilyapuchka) + [#189](https://github.com/stencilproject/Stencil/pull/189) ### Deprecations @@ -126,6 +130,7 @@ _None_ [Ilya Puchka](https://github.com/ilyapuchka) [#167](https://github.com/stencilproject/Stencil/pull/167) + ### Bug Fixes - Fixed using quote as a filter parameter. diff --git a/Sources/Extension.swift b/Sources/Extension.swift index a91b4abc..620dc95c 100644 --- a/Sources/Extension.swift +++ b/Sources/Extension.swift @@ -74,6 +74,9 @@ class DefaultExtension: Extension { registerFilter("split", filter: splitFilter) registerFilter("indent", filter: indentFilter) registerFilter("filter", filter: filterFilter) + registerFilter("map", filter: mapFilter) + registerFilter("compact", filter: compactFilter) + registerFilter("select", filter: selectFilter) } } diff --git a/Sources/Filters.swift b/Sources/Filters.swift index a4562994..9b180e0f 100644 --- a/Sources/Filters.swift +++ b/Sources/Filters.swift @@ -127,3 +127,71 @@ func filterFilter(value: Any?, arguments: [Any?], context: Context) throws -> An try expr.resolve(context) } } + +func mapFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? { + guard arguments.count >= 1 && arguments.count <= 2 else { + throw TemplateSyntaxError("'map' filter takes one or two arguments") + } + + let attribute = stringify(arguments[0]) + let variable = Variable("map_item.\(attribute)") + let defaultValue = arguments.count == 2 ? arguments[1] : nil + + let resolveVariable = { (item: Any) throws -> Any in + let result = try context.push(dictionary: ["map_item": item]) { + try variable.resolve(context) + } + if let result = result { return result } + else if let defaultValue = defaultValue { return defaultValue } + else { return result as Any } + } + + + if let array = value as? [Any] { + return try array.map(resolveVariable) + } else { + return try resolveVariable(value as Any) + } +} + +func compactFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? { + guard arguments.count <= 1 else { + throw TemplateSyntaxError("'compact' filter takes at most one argument") + } + + guard var array = value as? [Any?] else { return value } + + if arguments.count == 1 { + guard let mapped = try mapFilter(value: array, arguments: arguments, context: context) as? [Any?] else { + return value + } + array = mapped + } + + return array.compactMap { item -> Any? in + guard let unwrapped = item, String(describing: unwrapped) != "nil" else { return nil } + return unwrapped + } +} + +func selectFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? { + guard arguments.count == 1 else { + throw TemplateSyntaxError("'select' filter takes one argument") + } + + guard let token = Lexer(templateString: stringify(arguments[0])).tokenize().first else { + throw TemplateSyntaxError("Can't parse filter expression") + } + + let expr = try context.environment.compileExpression(components: token.components, containedIn: token) + + if let array = value as? [Any] { + return try array.filter { + try context.push(dictionary: ["$0": $0]) { + try expr.evaluate(context: context) + } + } + } + + return value +} diff --git a/Sources/Lexer.swift b/Sources/Lexer.swift index 47465f50..83f2b1db 100644 --- a/Sources/Lexer.swift +++ b/Sources/Lexer.swift @@ -2,7 +2,7 @@ import Foundation typealias Line = (content: String, number: UInt, range: Range) -struct Lexer { +public struct Lexer { let templateName: String? let templateString: String let lines: [Line] @@ -19,7 +19,7 @@ struct Lexer { "#": "#" ] - init(templateName: String? = nil, templateString: String) { + public init(templateName: String? = nil, templateString: String) { self.templateName = templateName self.templateString = templateString @@ -74,7 +74,7 @@ struct Lexer { /// passed on to the parser. /// /// - Returns: The list of tokens (see `createToken(string: at:)`). - func tokenize() -> [Token] { + public func tokenize() -> [Token] { var tokens: [Token] = [] let scanner = Scanner(templateString) diff --git a/Sources/Parser.swift b/Sources/Parser.swift index 404b8e2a..e985274b 100644 --- a/Sources/Parser.swift +++ b/Sources/Parser.swift @@ -91,6 +91,7 @@ public class TokenParser { } extension Environment { + func findTag(name: String) throws -> Extension.TagParser { for ext in extensions { if let filter = ext.tags[name] { diff --git a/Tests/StencilTests/EnvironmentSpec.swift b/Tests/StencilTests/EnvironmentSpec.swift index f5b829ff..3599f9b0 100644 --- a/Tests/StencilTests/EnvironmentSpec.swift +++ b/Tests/StencilTests/EnvironmentSpec.swift @@ -75,6 +75,17 @@ final class EnvironmentTests: XCTestCase { self.template = "{% for name in names %}{{ name }}{% end %}" try self.expectError(reason: "Unknown template tag 'end'", token: "end") } + + it("reports syntax error on invalid filter expression") { + self.template = "{{ array|select: $0|isPositive }}" + let ext = Extension() + ext.registerFilter("isPositive") { (value) -> Any? in + if let number = toNumber(value: value as Any) { return number > 0 } + else { return nil } + } + self.environment = Environment(extensions: [ext]) + try self.expectError(reason: "Can't parse filter expression", token: "array|select: $0|isPositive") + } } func testUnknownFilter() { diff --git a/Tests/StencilTests/FilterSpec.swift b/Tests/StencilTests/FilterSpec.swift index d2ac102d..25996006 100644 --- a/Tests/StencilTests/FilterSpec.swift +++ b/Tests/StencilTests/FilterSpec.swift @@ -3,6 +3,7 @@ import Spectre import XCTest final class FilterTests: XCTestCase { + func testRegistration() { let context: [String: Any] = ["name": "Kyle"] @@ -385,6 +386,73 @@ final class FilterTests: XCTestCase { """ } + func testMapfilter() throws { + it("can map over attribute") { + let template = Template(templateString: "{{ array|map:\"name\"}}") + let result = try template.render(Context(dictionary: ["array": [["name": "One"], ["name": "Two"], [:]]])) + try expect(result) == "[\"One\", \"Two\", nil]" + } + + it("can map over runtime attribute") { + let template = Template(templateString: "{{ array|map:key}}") + let result = try template.render(Context(dictionary: ["key": "name", "array": [["name": "One"], ["name": "Two"]]])) + try expect(result) == "[\"One\", \"Two\"]" + } + + it("can use default value") { + let template = Template(templateString: "{{ array|map:\"name\",\"anonymous\"}}") + let result = try template.render(Context(dictionary: ["array": [[:], ["name": "Two"]]])) + try expect(result) == "[\"anonymous\", \"Two\"]" + } + + it("can map single value") { + let template = Template(templateString: "{{ value|map:\"user.name\"}}") + let result = try template.render(Context(dictionary: ["value": ["user": ["name": "Two"]]])) + try expect(result) == "Two" + } + } + + func testCompactFilter() throws { + it("can filter nil values") { + let template = Template(templateString: "{{ array|compact}}") + let result = try template.render(Context(dictionary: ["array": [nil, "Two"]])) + try expect(result) == "[\"Two\"]" + } + + it("can map and filter nil values") { + let template = Template(templateString: "{{ array|compact:\"name\"}}") + let result = try template.render(Context(dictionary: ["array": [["name": "One"], ["name": "Two"], [:]]])) + try expect(result) == "[\"One\", \"Two\"]" + } + } + + func testSelectFilter() throws { + it("can filter using filter") { + let ext = Extension() + ext.registerFilter("isPositive") { (value) -> Any? in + if let number = toNumber(value: value as Any) { return number > 0 } + else { return nil } + } + let env = Environment(extensions: [ext]) + + let template = Template(templateString: "{{ array|select:\"$0|isPositive\" }}") + let result = try template.render(Context(dictionary: ["array": [1, -1, 2, -2, 3, -3]], environment: env)) + try expect(result) == "[1, 2, 3]" + } + + it("can filter using boolean expression") { + let template = Template(templateString: "{{ array|select:\"$0 > 0\" }}") + let result = try template.render(Context(dictionary: ["array": [1, -1, 2, -2, 3, -3]])) + try expect(result) == "[1, 2, 3]" + } + + it("can filter using variable expression") { + let template = Template(templateString: "{{ array|select:\"$0\" }}") + let result = try template.render(Context(dictionary: ["array": [1, -1, nil, 2, -2, 3, -3]])) + try expect(result) == "[1, 2, 3]" + } + } + func testDynamicFilters() throws { it("can apply dynamic filter") { let template = Template(templateString: "{{ name|filter:somefilter }}")