From 16da9ac034c2c5934059b20a7f6f90ae8be5a241 Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Thu, 22 Oct 2015 09:47:45 -0700 Subject: [PATCH] Introduce variable filters --- README.md | 52 +++++++++++++++++++++++++++++-- Stencil.xcodeproj/project.pbxproj | 8 +++++ Stencil/Filters.swift | 33 ++++++++++++++++++++ Stencil/Node.swift | 22 ++++++++----- Stencil/Parser.swift | 26 ++++++++++++++-- Stencil/Variable.swift | 39 +++++++++++++++++++++-- StencilTests/FilterTests.swift | 23 ++++++++++++++ StencilTests/ParserTests.swift | 7 +++-- 8 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 Stencil/Filters.swift create mode 100644 StencilTests/FilterTests.swift diff --git a/README.md b/README.md index 5c60113..e88a052 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Stencil -======= +# Stencil [![Build Status](http://img.shields.io/circleci/project/kylef/Stencil/master.svg)](https://circleci.com/gh/kylef/Stencil) @@ -7,7 +6,7 @@ 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 feel right at home with Stencil. -### Example +## Example ```html+django There are {{ articles.count }} articles. @@ -78,6 +77,53 @@ There are {{ people.count }} people, {{ people.first }} is first person. Followed by {{ people.1 }}. ``` +#### Filters + +Filters allow you to transform the values of variables. For example, they look like: + +```html+django +{{ variable|uppercase }} +``` + +##### Capitalize + +The capitalize filter allows you to capitalize a string. +For example, `stencil` to `Stencil`. + +```html+django +{{ "stencil"|capitalize }} +``` + +##### Uppercase + +The uppercase filter allows you to transform a string to uppercase. +For example, `Stencil` to `STENCIL`. + +```html+django +{{ "Stencil"|uppercase }} +``` + +##### Lowercase + +The uppercase filter allows you to transform a string to lowercase. +For example, `Stencil` to `stencil`. + +```html+django +{{ "Stencil"|lowercase }} +``` + +#### Registering custom filters + +``` +template.parser.registerFilter("double") { value in + if let value = value as? Int { + return value * 2 + } + + return value +} +``` + ### Tags Tags are a mechanism to execute a piece of code, allowing you to have diff --git a/Stencil.xcodeproj/project.pbxproj b/Stencil.xcodeproj/project.pbxproj index 218a903..3dcff7b 100644 --- a/Stencil.xcodeproj/project.pbxproj +++ b/Stencil.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 27A848E91B42242C004ACA13 /* base.html in Resources */ = {isa = PBXBuildFile; fileRef = 27A848E71B42242C004ACA13 /* base.html */; }; 27A848EA1B42242C004ACA13 /* child.html in Resources */ = {isa = PBXBuildFile; fileRef = 27A848E81B42242C004ACA13 /* child.html */; }; 27A848EC1B42247D004ACA13 /* InheritenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A848EB1B42247D004ACA13 /* InheritenceTests.swift */; }; + 27BA0A9E1BD9465700B7209B /* Filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BA0A9D1BD9465700B7209B /* Filters.swift */; settings = {ASSET_TAGS = (); }; }; + 27BA0AA01BD946C300B7209B /* FilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BA0A9F1BD946C300B7209B /* FilterTests.swift */; settings = {ASSET_TAGS = (); }; }; 27CE0ADE1A50BEC3004A105B /* TemplateLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CE0ADD1A50BEC3004A105B /* TemplateLoader.swift */; }; 27CE0AE01A50BF05004A105B /* TemplateLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CE0ADF1A50BF05004A105B /* TemplateLoaderTests.swift */; }; 27CE0AFA1A50C963004A105B /* test.html in Resources */ = {isa = PBXBuildFile; fileRef = 27CE0AF91A50C963004A105B /* test.html */; }; @@ -53,6 +55,8 @@ 27A848E71B42242C004ACA13 /* base.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = base.html; sourceTree = ""; }; 27A848E81B42242C004ACA13 /* child.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = child.html; sourceTree = ""; }; 27A848EB1B42247D004ACA13 /* InheritenceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InheritenceTests.swift; sourceTree = ""; }; + 27BA0A9D1BD9465700B7209B /* Filters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Filters.swift; sourceTree = ""; }; + 27BA0A9F1BD946C300B7209B /* FilterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterTests.swift; sourceTree = ""; }; 27CE0ADD1A50BEC3004A105B /* TemplateLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TemplateLoader.swift; sourceTree = ""; }; 27CE0ADF1A50BF05004A105B /* TemplateLoaderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TemplateLoaderTests.swift; sourceTree = ""; }; 27CE0AF91A50C963004A105B /* test.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = test.html; sourceTree = ""; }; @@ -149,6 +153,7 @@ children = ( 77FAAE5719F91E480029DC5E /* Stencil.h */, 77FAAE6E19F920750029DC5E /* Context.swift */, + 27BA0A9D1BD9465700B7209B /* Filters.swift */, 77EB082A19FA8600001870F1 /* Lexer.swift */, 7725B3D419F9438F002CF74B /* Node.swift */, 7725B3D619F94A43002CF74B /* Parser.swift */, @@ -177,6 +182,7 @@ 77FAAE7019F9208C0029DC5E /* ContextTests.swift */, 7725B3CA19F92B4F002CF74B /* VariableTests.swift */, 7725B3D219F9437F002CF74B /* NodeTests.swift */, + 27BA0A9F1BD946C300B7209B /* FilterTests.swift */, 7725B3D819F94A61002CF74B /* ParserTests.swift */, 77EB082819FA85F2001870F1 /* LexerTests.swift */, 77EB082619F96E9C001870F1 /* TemplateTests.swift */, @@ -416,6 +422,7 @@ 77FAAE6F19F920750029DC5E /* Context.swift in Sources */, 77EB082B19FA8600001870F1 /* Lexer.swift in Sources */, 7725B3CF19F94214002CF74B /* Tokenizer.swift in Sources */, + 27BA0A9E1BD9465700B7209B /* Filters.swift in Sources */, 7725B3D719F94A43002CF74B /* Parser.swift in Sources */, 77EB082519F96E88001870F1 /* Template.swift in Sources */, 7725B3CD19F92B61002CF74B /* Variable.swift in Sources */, @@ -437,6 +444,7 @@ 77EB082719F96E9C001870F1 /* TemplateTests.swift in Sources */, 7725B3CB19F92B4F002CF74B /* VariableTests.swift in Sources */, 27CE0B041A50CBEA004A105B /* IncludeTests.swift in Sources */, + 27BA0AA01BD946C300B7209B /* FilterTests.swift in Sources */, 77EB082919FA85F2001870F1 /* LexerTests.swift in Sources */, 77FAAE7119F9208C0029DC5E /* ContextTests.swift in Sources */, ); diff --git a/Stencil/Filters.swift b/Stencil/Filters.swift new file mode 100644 index 0000000..ed31250 --- /dev/null +++ b/Stencil/Filters.swift @@ -0,0 +1,33 @@ +func toString(value: Any?) -> String? { + if let value = value as? String { + return value + } else if let value = value as? CustomStringConvertible { + return value.description + } + + return nil +} + +func capitalise(value: Any?) -> Any? { + if let value = toString(value) { + return value.capitalizedString + } + + return value +} + +func uppercase(value: Any?) -> Any? { + if let value = toString(value) { + return value.uppercaseString + } + + return value +} + +func lowercase(value: Any?) -> Any? { + if let value = toString(value) { + return value.lowercaseString + } + + return value +} \ No newline at end of file diff --git a/Stencil/Node.swift b/Stencil/Node.swift index 68cd694..f4892ae 100644 --- a/Stencil/Node.swift +++ b/Stencil/Node.swift @@ -46,22 +46,28 @@ public class TextNode : NodeType { } } -public class VariableNode : NodeType { - public let variable:Variable +public protocol Resolvable { + func resolve(context: Context) -> Any? +} - public init(variable:Variable) { +public class VariableNode : NodeType { + public let variable: Resolvable + + public init(variable: Resolvable) { self.variable = variable } - public init(variable:String) { + public init(variable: String) { self.variable = Variable(variable) } - public func render(context:Context) throws -> String { - let result:AnyObject? = variable.resolve(context) + public func render(context: Context) throws -> String { + let result = variable.resolve(context) if let result = result as? String { return result + } else if let result = result as? CustomStringConvertible { + return result.description } else if let result = result as? NSObject { return result.description } @@ -94,7 +100,7 @@ public class NowNode : NodeType { public func render(context: Context) throws -> String { let date = NSDate() - let format: AnyObject? = self.format.resolve(context) + let format = self.format.resolve(context) var formatter:NSDateFormatter? if let format = format as? NSDateFormatter { @@ -212,7 +218,7 @@ public class IfNode : NodeType { } public func render(context: Context) throws -> String { - let result: AnyObject? = variable.resolve(context) + let result = variable.resolve(context) var truthy = false if let result = result as? [AnyObject] { diff --git a/Stencil/Parser.swift b/Stencil/Parser.swift index b5e1e11..3bcd599 100644 --- a/Stencil/Parser.swift +++ b/Stencil/Parser.swift @@ -10,12 +10,15 @@ public func until(tags:[String])(parser:TokenParser, token:Token) -> Bool { return false } +public typealias Filter = Any? -> Any? + /// A class for parsing an array of tokens and converts them into a collection of Node's public class TokenParser { public typealias TagParser = (TokenParser, Token) throws -> NodeType private var tokens:[Token] private var tags = [String:TagParser]() + private var filters = [String: Filter]() public init(tokens:[Token]) { self.tokens = tokens @@ -26,6 +29,9 @@ public class TokenParser { registerTag("include", parser: IncludeNode.parse) registerTag("extends", parser: ExtendsNode.parse) registerTag("block", parser: BlockNode.parse) + registerFilter("capitalize", filter: capitalise) + registerFilter("uppercase", filter: uppercase) + registerFilter("lowercase", filter: lowercase) } /// Registers a new template tag @@ -40,6 +46,10 @@ public class TokenParser { }) } + public func registerFilter(name: String, filter: Filter) { + filters[name] = filter + } + /// Parse the given tokens into nodes public func parse() throws -> [NodeType] { return try parse(nil) @@ -54,8 +64,8 @@ public class TokenParser { switch token { case .Text(let text): nodes.append(TextNode(text: text)) - case .Variable(let variable): - nodes.append(VariableNode(variable: variable)) + case .Variable: + nodes.append(VariableNode(variable: try compileFilter(token.contents))) case .Block: let tag = token.components().first @@ -88,4 +98,16 @@ public class TokenParser { public func prependToken(token:Token) { tokens.insert(token, atIndex: 0) } + + public func findFilter(name: String) throws -> Filter { + if let filter = filters[name] { + return filter + } + + throw TemplateSyntaxError("Invalid filter '\(name)'") + } + + func compileFilter(token: String) throws -> Resolvable { + return try FilterExpression(token: token, parser: self) + } } diff --git a/Stencil/Variable.swift b/Stencil/Variable.swift index 578baa7..4d65456 100644 --- a/Stencil/Variable.swift +++ b/Stencil/Variable.swift @@ -1,8 +1,40 @@ import Foundation +class FilterExpression : Resolvable { + let filters: [Filter] + let variable: Variable + + init(token: String, parser: TokenParser) throws { + let bits = token.componentsSeparatedByString("|") + if bits.isEmpty { + filters = [] + variable = Variable("") + throw TemplateSyntaxError("Variable tags must include at least 1 argument") + } + + variable = Variable(bits[0]) + let filterBits = bits[1 ..< bits.endIndex] + + do { + filters = try filterBits.map { try parser.findFilter($0) } + } catch { + filters = [] + throw error + } + } + + func resolve(context: Context) -> Any? { + let result = variable.resolve(context) + + return filters.reduce(result) { x, y in + return y(x) + } + } +} + /// A structure used to represent a template variable, and to resolve it in a given context. -public struct Variable : Equatable { +public struct Variable : Equatable, Resolvable { public let variable:String /// Create a variable with a string representing the variable @@ -15,11 +47,12 @@ public struct Variable : Equatable { } /// Resolve the variable in the given context - public func resolve(context:Context) -> AnyObject? { + public func resolve(context:Context) -> Any? { var current:AnyObject? = context if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) { - return variable.substringWithRange(variable.startIndex.successor() ..< variable.endIndex.predecessor()) + // String literal + return variable[variable.startIndex.successor() ..< variable.endIndex.predecessor()] } for bit in lookup() { diff --git a/StencilTests/FilterTests.swift b/StencilTests/FilterTests.swift new file mode 100644 index 0000000..e1cb918 --- /dev/null +++ b/StencilTests/FilterTests.swift @@ -0,0 +1,23 @@ +import XCTest +import Stencil + + +class FilterTests: XCTestCase { + func testCapitalizeFilter() { + let template = Template(templateString: "{{ name|capitalize }}") + let result = try? template.render(Context(dictionary: ["name": "kyle"])) + XCTAssertEqual(result, "Kyle") + } + + func testUppercaseFilter() { + let template = Template(templateString: "{{ name|uppercase }}") + let result = try? template.render(Context(dictionary: ["name": "kyle"])) + XCTAssertEqual(result, "KYLE") + } + + func testLowercaseFilter() { + let template = Template(templateString: "{{ name|lowercase }}") + let result = try? template.render(Context(dictionary: ["name": "Kyle"])) + XCTAssertEqual(result, "kyle") + } +} \ No newline at end of file diff --git a/StencilTests/ParserTests.swift b/StencilTests/ParserTests.swift index c8f15a9..ff4c83c 100644 --- a/StencilTests/ParserTests.swift +++ b/StencilTests/ParserTests.swift @@ -17,13 +17,14 @@ class TokenParserTests: XCTestCase { func testParsingVariableToken() { let parser = TokenParser(tokens: [ - Token.Variable(value: "name") - ]) + Token.Variable(value: "'name'") + ]) assertSuccess(try parser.parse()) { nodes in let node = nodes.first as! VariableNode XCTAssertEqual(nodes.count, 1) - XCTAssertEqual(node.variable, Variable("name")) + let result = try? node.render(Context()) + XCTAssertEqual(result, "name") } }