Merge pull request #287 from stencilproject/trim_whitespace
Add whitespace control mechanisms
This commit is contained in:
@@ -18,6 +18,14 @@
|
|||||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
[#219](https://github.com/stencilproject/Stencil/issues/219)
|
[#219](https://github.com/stencilproject/Stencil/issues/219)
|
||||||
[#246](https://github.com/stencilproject/Stencil/pull/246)
|
[#246](https://github.com/stencilproject/Stencil/pull/246)
|
||||||
|
- Added support for trimming whitespace around blocks with Jinja2 whitespace control symbols. eg `{%- if value +%}`.
|
||||||
|
[Miguel Bejar](https://github.com/bejar37)
|
||||||
|
[Yonas Kolb](https://github.com/yonaskolb)
|
||||||
|
[#92](https://github.com/stencilproject/Stencil/pull/92)
|
||||||
|
[#287](https://github.com/stencilproject/Stencil/pull/287)
|
||||||
|
- Added support for adding default whitespace trimming behaviour to an environment.
|
||||||
|
[Yonas Kolb](https://github.com/yonaskolb)
|
||||||
|
[#287](https://github.com/stencilproject/Stencil/pull/287)
|
||||||
|
|
||||||
### Deprecations
|
### Deprecations
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public struct Environment {
|
|||||||
public let templateClass: Template.Type
|
public let templateClass: Template.Type
|
||||||
/// List of registered extensions
|
/// List of registered extensions
|
||||||
public var extensions: [Extension]
|
public var extensions: [Extension]
|
||||||
|
/// How to handle whitespace
|
||||||
|
public var trimBehaviour: TrimBehaviour
|
||||||
/// Mechanism for loading new files
|
/// Mechanism for loading new files
|
||||||
public var loader: Loader?
|
public var loader: Loader?
|
||||||
|
|
||||||
@@ -13,14 +15,17 @@ public struct Environment {
|
|||||||
/// - loader: Mechanism for loading new files
|
/// - loader: Mechanism for loading new files
|
||||||
/// - extensions: List of extension containers
|
/// - extensions: List of extension containers
|
||||||
/// - templateClass: Class for newly loaded templates
|
/// - templateClass: Class for newly loaded templates
|
||||||
|
/// - trimBehaviour: How to handle whitespace
|
||||||
public init(
|
public init(
|
||||||
loader: Loader? = nil,
|
loader: Loader? = nil,
|
||||||
extensions: [Extension] = [],
|
extensions: [Extension] = [],
|
||||||
templateClass: Template.Type = Template.self
|
templateClass: Template.Type = Template.self,
|
||||||
|
trimBehaviour: TrimBehaviour = .nothing
|
||||||
) {
|
) {
|
||||||
self.templateClass = templateClass
|
self.templateClass = templateClass
|
||||||
self.loader = loader
|
self.loader = loader
|
||||||
self.extensions = extensions + [DefaultExtension()]
|
self.extensions = extensions + [DefaultExtension()]
|
||||||
|
self.trimBehaviour = trimBehaviour
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a template with the given name
|
/// Load a template with the given name
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ struct Lexer {
|
|||||||
/// `{` character, for example `{{`, `{%`, `{#`, ...
|
/// `{` character, for example `{{`, `{%`, `{#`, ...
|
||||||
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
|
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
|
||||||
|
|
||||||
|
/// The minimum length of a tag
|
||||||
|
private static let tagLength = 2
|
||||||
|
|
||||||
/// The token end characters, corresponding to their token start characters.
|
/// The token end characters, corresponding to their token start characters.
|
||||||
/// For example, a variable token starts with `{{` and ends with `}}`
|
/// For example, a variable token starts with `{{` and ends with `}}`
|
||||||
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
|
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
|
||||||
@@ -19,6 +22,12 @@ struct Lexer {
|
|||||||
"#": "#"
|
"#": "#"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Characters controlling whitespace trimming behaviour
|
||||||
|
private static let behaviourMap: [Character: WhitespaceBehaviour.Behaviour] = [
|
||||||
|
"+": .keep,
|
||||||
|
"-": .trim
|
||||||
|
]
|
||||||
|
|
||||||
init(templateName: String? = nil, templateString: String) {
|
init(templateName: String? = nil, templateString: String) {
|
||||||
self.templateName = templateName
|
self.templateName = templateName
|
||||||
self.templateString = templateString
|
self.templateString = templateString
|
||||||
@@ -30,6 +39,16 @@ struct Lexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func behaviour(string: String, tagLength: Int) -> WhitespaceBehaviour {
|
||||||
|
let leftIndex = string.index(string.startIndex, offsetBy: tagLength, limitedBy: string.endIndex)
|
||||||
|
let rightIndex = string.index(string.endIndex, offsetBy: -(tagLength + 1), limitedBy: string.startIndex)
|
||||||
|
|
||||||
|
return WhitespaceBehaviour(
|
||||||
|
leading: Self.behaviourMap[leftIndex.map { string[$0] } ?? " "] ?? .unspecified,
|
||||||
|
trailing: Self.behaviourMap[rightIndex.map { string[$0] } ?? " "] ?? .unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a token that will be passed on to the parser, with the given
|
/// Create a token that will be passed on to the parser, with the given
|
||||||
/// content and a range. The content will be tested to see if it's a
|
/// content and a range. The content will be tested to see if it's a
|
||||||
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
|
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
|
||||||
@@ -40,9 +59,9 @@ struct Lexer {
|
|||||||
/// - range: The range within the template content, used for smart
|
/// - range: The range within the template content, used for smart
|
||||||
/// error reporting
|
/// error reporting
|
||||||
func createToken(string: String, at range: Range<String.Index>) -> Token {
|
func createToken(string: String, at range: Range<String.Index>) -> Token {
|
||||||
func strip() -> String {
|
func strip(length: (Int, Int) = (Self.tagLength, Self.tagLength)) -> String {
|
||||||
guard string.count > 4 else { return "" }
|
guard string.count > (length.0 + length.1) else { return "" }
|
||||||
let trimmed = String(string.dropFirst(2).dropLast(2))
|
let trimmed = String(string.dropFirst(length.0).dropLast(length.1))
|
||||||
.components(separatedBy: "\n")
|
.components(separatedBy: "\n")
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
.map { $0.trim(character: " ") }
|
.map { $0.trim(character: " ") }
|
||||||
@@ -51,7 +70,13 @@ struct Lexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
|
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
|
||||||
let value = strip()
|
let behaviour = string.hasPrefix("{%") ? behaviour(string: string, tagLength: Self.tagLength) : .unspecified
|
||||||
|
let stripLengths = (
|
||||||
|
Self.tagLength + (behaviour.leading != .unspecified ? 1 : 0),
|
||||||
|
Self.tagLength + (behaviour.trailing != .unspecified ? 1 : 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
let value = strip(length: stripLengths)
|
||||||
let range = templateString.range(of: value, range: range) ?? range
|
let range = templateString.range(of: value, range: range) ?? range
|
||||||
let location = rangeLocation(range)
|
let location = rangeLocation(range)
|
||||||
let sourceMap = SourceMap(filename: templateName, location: location)
|
let sourceMap = SourceMap(filename: templateName, location: location)
|
||||||
@@ -59,7 +84,7 @@ struct Lexer {
|
|||||||
if string.hasPrefix("{{") {
|
if string.hasPrefix("{{") {
|
||||||
return .variable(value: value, at: sourceMap)
|
return .variable(value: value, at: sourceMap)
|
||||||
} else if string.hasPrefix("{%") {
|
} else if string.hasPrefix("{%") {
|
||||||
return .block(value: value, at: sourceMap)
|
return .block(value: strip(length: stripLengths), at: sourceMap, whitespace: behaviour)
|
||||||
} else if string.hasPrefix("{#") {
|
} else if string.hasPrefix("{#") {
|
||||||
return .comment(value: value, at: sourceMap)
|
return .comment(value: value, at: sourceMap)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,27 @@ public class SimpleNode: NodeType {
|
|||||||
public class TextNode: NodeType {
|
public class TextNode: NodeType {
|
||||||
public let text: String
|
public let text: String
|
||||||
public let token: Token?
|
public let token: Token?
|
||||||
|
public let trimBehaviour: TrimBehaviour
|
||||||
|
|
||||||
public init(text: String) {
|
public init(text: String, trimBehaviour: TrimBehaviour = .nothing) {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.token = nil
|
self.token = nil
|
||||||
|
self.trimBehaviour = trimBehaviour
|
||||||
}
|
}
|
||||||
|
|
||||||
public func render(_ context: Context) throws -> String {
|
public func render(_ context: Context) throws -> String {
|
||||||
self.text
|
var string = self.text
|
||||||
|
if trimBehaviour.leading != .nothing, !string.isEmpty {
|
||||||
|
let range = NSRange(..<string.endIndex, in: string)
|
||||||
|
string = TrimBehaviour.leadingRegex(trim: trimBehaviour.leading)
|
||||||
|
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||||
|
}
|
||||||
|
if trimBehaviour.trailing != .nothing, !string.isEmpty {
|
||||||
|
let range = NSRange(..<string.endIndex, in: string)
|
||||||
|
string = TrimBehaviour.trailingRegex(trim: trimBehaviour.trailing)
|
||||||
|
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||||
|
}
|
||||||
|
return string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class TokenParser {
|
|||||||
|
|
||||||
fileprivate var tokens: [Token]
|
fileprivate var tokens: [Token]
|
||||||
fileprivate let environment: Environment
|
fileprivate let environment: Environment
|
||||||
|
fileprivate var previousWhiteSpace: WhitespaceBehaviour.Behaviour?
|
||||||
|
|
||||||
/// Simple initializer
|
/// Simple initializer
|
||||||
public init(tokens: [Token], environment: Environment) {
|
public init(tokens: [Token], environment: Environment) {
|
||||||
@@ -41,10 +42,12 @@ public class TokenParser {
|
|||||||
|
|
||||||
switch token.kind {
|
switch token.kind {
|
||||||
case .text:
|
case .text:
|
||||||
nodes.append(TextNode(text: token.contents))
|
nodes.append(TextNode(text: token.contents, trimBehaviour: trimBehaviour))
|
||||||
case .variable:
|
case .variable:
|
||||||
|
previousWhiteSpace = nil
|
||||||
try nodes.append(VariableNode.parse(self, token: token))
|
try nodes.append(VariableNode.parse(self, token: token))
|
||||||
case .block:
|
case .block:
|
||||||
|
previousWhiteSpace = token.whitespace?.trailing
|
||||||
if let parseUntil = parseUntil, parseUntil(self, token) {
|
if let parseUntil = parseUntil, parseUntil(self, token) {
|
||||||
prependToken(token)
|
prependToken(token)
|
||||||
return nodes
|
return nodes
|
||||||
@@ -60,6 +63,7 @@ public class TokenParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .comment:
|
case .comment:
|
||||||
|
previousWhiteSpace = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,10 @@ public class TokenParser {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func peekWhitespace() -> WhitespaceBehaviour.Behaviour? {
|
||||||
|
tokens.first?.whitespace?.leading
|
||||||
|
}
|
||||||
|
|
||||||
/// Insert a token
|
/// Insert a token
|
||||||
public func prependToken(_ token: Token) {
|
public func prependToken(_ token: Token) {
|
||||||
tokens.insert(token, at: 0)
|
tokens.insert(token, at: 0)
|
||||||
@@ -95,6 +103,27 @@ public class TokenParser {
|
|||||||
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||||
try environment.compileResolvable(token, containedIn: containingToken)
|
try environment.compileResolvable(token, containedIn: containingToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var trimBehaviour: TrimBehaviour {
|
||||||
|
var behaviour: TrimBehaviour = .nothing
|
||||||
|
|
||||||
|
if let leading = previousWhiteSpace {
|
||||||
|
if leading == .unspecified {
|
||||||
|
behaviour.leading = environment.trimBehaviour.trailing
|
||||||
|
} else {
|
||||||
|
behaviour.leading = leading == .trim ? .whitespaceAndNewLines : .nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let trailing = peekWhitespace() {
|
||||||
|
if trailing == .unspecified {
|
||||||
|
behaviour.trailing = environment.trimBehaviour.leading
|
||||||
|
} else {
|
||||||
|
behaviour.trailing = trailing == .trim ? .whitespaceAndNewLines : .nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return behaviour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Environment {
|
extension Environment {
|
||||||
|
|||||||
@@ -79,6 +79,19 @@ public struct SourceMap: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct WhitespaceBehaviour: Equatable {
|
||||||
|
public enum Behaviour {
|
||||||
|
case unspecified
|
||||||
|
case trim
|
||||||
|
case keep
|
||||||
|
}
|
||||||
|
|
||||||
|
let leading: Behaviour
|
||||||
|
let trailing: Behaviour
|
||||||
|
|
||||||
|
public static let unspecified = WhitespaceBehaviour(leading: .unspecified, trailing: .unspecified)
|
||||||
|
}
|
||||||
|
|
||||||
public class Token: Equatable {
|
public class Token: Equatable {
|
||||||
public enum Kind: Equatable {
|
public enum Kind: Equatable {
|
||||||
/// A token representing a piece of text.
|
/// A token representing a piece of text.
|
||||||
@@ -94,14 +107,16 @@ public class Token: Equatable {
|
|||||||
public let contents: String
|
public let contents: String
|
||||||
public let kind: Kind
|
public let kind: Kind
|
||||||
public let sourceMap: SourceMap
|
public let sourceMap: SourceMap
|
||||||
|
public var whitespace: WhitespaceBehaviour?
|
||||||
|
|
||||||
/// Returns the underlying value as an array seperated by spaces
|
/// Returns the underlying value as an array seperated by spaces
|
||||||
public private(set) lazy var components: [String] = self.contents.smartSplit()
|
public private(set) lazy var components: [String] = self.contents.smartSplit()
|
||||||
|
|
||||||
init(contents: String, kind: Kind, sourceMap: SourceMap) {
|
init(contents: String, kind: Kind, sourceMap: SourceMap, whitespace: WhitespaceBehaviour? = nil) {
|
||||||
self.contents = contents
|
self.contents = contents
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.sourceMap = sourceMap
|
self.sourceMap = sourceMap
|
||||||
|
self.whitespace = whitespace
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A token representing a piece of text.
|
/// A token representing a piece of text.
|
||||||
@@ -120,8 +135,12 @@ public class Token: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A token representing a template block.
|
/// A token representing a template block.
|
||||||
public static func block(value: String, at sourceMap: SourceMap) -> Token {
|
public static func block(
|
||||||
Token(contents: value, kind: .block, sourceMap: sourceMap)
|
value: String,
|
||||||
|
at sourceMap: SourceMap,
|
||||||
|
whitespace: WhitespaceBehaviour = .unspecified
|
||||||
|
) -> Token {
|
||||||
|
Token(contents: value, kind: .block, sourceMap: sourceMap, whitespace: whitespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func == (lhs: Token, rhs: Token) -> Bool {
|
public static func == (lhs: Token, rhs: Token) -> Bool {
|
||||||
|
|||||||
70
Sources/Stencil/TrimBehaviour.swift
Normal file
70
Sources/Stencil/TrimBehaviour.swift
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct TrimBehaviour: Equatable {
|
||||||
|
var leading: Trim
|
||||||
|
var trailing: Trim
|
||||||
|
|
||||||
|
public enum Trim {
|
||||||
|
/// nothing
|
||||||
|
case nothing
|
||||||
|
|
||||||
|
/// tabs and spaces
|
||||||
|
case whitespace
|
||||||
|
|
||||||
|
/// tabs and spaces and a single new line
|
||||||
|
case whitespaceAndOneNewLine
|
||||||
|
|
||||||
|
/// all tabs spaces and newlines
|
||||||
|
case whitespaceAndNewLines
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(leading: Trim, trailing: Trim) {
|
||||||
|
self.leading = leading
|
||||||
|
self.trailing = trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// doesn't touch newlines
|
||||||
|
public static let nothing = TrimBehaviour(leading: .nothing, trailing: .nothing)
|
||||||
|
|
||||||
|
/// removes whitespace before a block and whitespace and a single newline after a block
|
||||||
|
public static let smart = TrimBehaviour(leading: .whitespace, trailing: .whitespaceAndOneNewLine)
|
||||||
|
|
||||||
|
/// removes all whitespace and newlines before and after a block
|
||||||
|
public static let all = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
|
||||||
|
|
||||||
|
static func leadingRegex(trim: Trim) -> NSRegularExpression {
|
||||||
|
switch trim {
|
||||||
|
case .nothing:
|
||||||
|
fatalError("No RegularExpression for none")
|
||||||
|
case .whitespace:
|
||||||
|
return Self.leadingWhitespace
|
||||||
|
case .whitespaceAndOneNewLine:
|
||||||
|
return Self.leadingWhitespaceAndOneNewLine
|
||||||
|
case .whitespaceAndNewLines:
|
||||||
|
return Self.leadingWhitespaceAndNewlines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func trailingRegex(trim: Trim) -> NSRegularExpression {
|
||||||
|
switch trim {
|
||||||
|
case .nothing:
|
||||||
|
fatalError("No RegularExpression for none")
|
||||||
|
case .whitespace:
|
||||||
|
return Self.trailingWhitespace
|
||||||
|
case .whitespaceAndOneNewLine:
|
||||||
|
return Self.trailingWhitespaceAndOneNewLine
|
||||||
|
case .whitespaceAndNewLines:
|
||||||
|
return Self.trailingWhitespaceAndNewLines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable force_try
|
||||||
|
private static let leadingWhitespaceAndNewlines = try! NSRegularExpression(pattern: "^\\s+")
|
||||||
|
private static let trailingWhitespaceAndNewLines = try! NSRegularExpression(pattern: "\\s+$")
|
||||||
|
|
||||||
|
private static let leadingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "^[ \t]*\n")
|
||||||
|
private static let trailingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "\n[ \t]*$")
|
||||||
|
|
||||||
|
private static let leadingWhitespace = try! NSRegularExpression(pattern: "^[ \t]*")
|
||||||
|
private static let trailingWhitespace = try! NSRegularExpression(pattern: "[ \t]*$")
|
||||||
|
}
|
||||||
@@ -51,9 +51,9 @@ final class LexerTests: XCTestCase {
|
|||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 3
|
try expect(tokens.count) == 3
|
||||||
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
|
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "myname", at: makeSourceMap("myname", for: lexer))
|
try expect(tokens[1]) == .variable(value: "myname", at: makeSourceMap("myname", for: lexer))
|
||||||
try expect(tokens[2]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
|
try expect(tokens[2]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testVariablesWithoutBeingGreedy() throws {
|
func testVariablesWithoutBeingGreedy() throws {
|
||||||
@@ -62,8 +62,8 @@ final class LexerTests: XCTestCase {
|
|||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 2
|
try expect(tokens.count) == 2
|
||||||
try expect(tokens[0]) == Token.variable(value: "thing", at: makeSourceMap("thing", for: lexer))
|
try expect(tokens[0]) == .variable(value: "thing", at: makeSourceMap("thing", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer))
|
try expect(tokens[1]) == .variable(value: "name", at: makeSourceMap("name", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnclosedBlock() throws {
|
func testUnclosedBlock() throws {
|
||||||
@@ -98,11 +98,26 @@ final class LexerTests: XCTestCase {
|
|||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 5
|
try expect(tokens.count) == 5
|
||||||
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
|
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
|
||||||
try expect(tokens[1]) == Token.block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
|
try expect(tokens[1]) == .block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
|
||||||
try expect(tokens[2]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
|
try expect(tokens[2]) == .variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
|
||||||
try expect(tokens[3]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
try expect(tokens[3]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||||
try expect(tokens[4]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
|
try expect(tokens[4]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTrimSymbols() throws {
|
||||||
|
let fBlock = "if hello"
|
||||||
|
let sBlock = "ta da"
|
||||||
|
let lexer = Lexer(templateString: "{%+ \(fBlock) -%}{% \(sBlock) -%}")
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
let behaviours = (
|
||||||
|
WhitespaceBehaviour(leading: .keep, trailing: .trim),
|
||||||
|
WhitespaceBehaviour(leading: .unspecified, trailing: .trim)
|
||||||
|
)
|
||||||
|
|
||||||
|
try expect(tokens.count) == 2
|
||||||
|
try expect(tokens[0]) == .block(value: fBlock, at: makeSourceMap(fBlock, for: lexer), whitespace: behaviours.0)
|
||||||
|
try expect(tokens[1]) == .block(value: sBlock, at: makeSourceMap(sBlock, for: lexer), whitespace: behaviours.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEscapeSequence() throws {
|
func testEscapeSequence() throws {
|
||||||
@@ -111,11 +126,11 @@ final class LexerTests: XCTestCase {
|
|||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 5
|
try expect(tokens.count) == 5
|
||||||
try expect(tokens[0]) == Token.text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
|
try expect(tokens[0]) == .text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
|
try expect(tokens[1]) == .variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
|
||||||
try expect(tokens[2]) == Token.block(value: "if true", at: makeSourceMap("if true", for: lexer))
|
try expect(tokens[2]) == .block(value: "if true", at: makeSourceMap("if true", for: lexer))
|
||||||
try expect(tokens[3]) == Token.variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
|
try expect(tokens[3]) == .variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
|
||||||
try expect(tokens[4]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
try expect(tokens[4]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPerformance() throws {
|
func testPerformance() throws {
|
||||||
|
|||||||
@@ -14,6 +14,48 @@ final class NodeTests: XCTestCase {
|
|||||||
let node = TextNode(text: "Hello World")
|
let node = TextNode(text: "Hello World")
|
||||||
try expect(try node.render(self.context)) == "Hello World"
|
try expect(try node.render(self.context)) == "Hello World"
|
||||||
}
|
}
|
||||||
|
it("Trims leading whitespace") {
|
||||||
|
let text = " \n Some text "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespace, trailing: .nothing)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "\n Some text "
|
||||||
|
}
|
||||||
|
it("Trims leading whitespace and one newline") {
|
||||||
|
let text = "\n\n Some text "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndOneNewLine, trailing: .nothing)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "\n Some text "
|
||||||
|
}
|
||||||
|
it("Trims leading whitespace and one newline") {
|
||||||
|
let text = "\n\n Some text "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "Some text "
|
||||||
|
}
|
||||||
|
it("Trims trailing whitespace") {
|
||||||
|
let text = " Some text \n"
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespace)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == " Some text\n"
|
||||||
|
}
|
||||||
|
it("Trims trailing whitespace and one newline") {
|
||||||
|
let text = " Some text \n \n "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndOneNewLine)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == " Some text \n "
|
||||||
|
}
|
||||||
|
it("Trims trailing whitespace and newlines") {
|
||||||
|
let text = " Some text \n \n "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndNewLines)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == " Some text"
|
||||||
|
}
|
||||||
|
it("Trims all whitespace") {
|
||||||
|
let text = " \n \nSome text \n "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "Some text"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testVariableNode() {
|
func testVariableNode() {
|
||||||
|
|||||||
@@ -3,62 +3,77 @@ import Spectre
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class TokenParserTests: XCTestCase {
|
final class TokenParserTests: XCTestCase {
|
||||||
func testTokenParser() {
|
func testTextToken() throws {
|
||||||
it("can parse a text token") {
|
let parser = TokenParser(tokens: [
|
||||||
let parser = TokenParser(tokens: [
|
.text(value: "Hello World", at: .unknown)
|
||||||
.text(value: "Hello World", at: .unknown)
|
], environment: Environment())
|
||||||
], environment: Environment())
|
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
let node = nodes.first as? TextNode
|
let node = nodes.first as? TextNode
|
||||||
|
|
||||||
try expect(nodes.count) == 1
|
try expect(nodes.count) == 1
|
||||||
try expect(node?.text) == "Hello World"
|
try expect(node?.text) == "Hello World"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVariableToken() throws {
|
||||||
|
let parser = TokenParser(tokens: [
|
||||||
|
.variable(value: "'name'", at: .unknown)
|
||||||
|
], environment: Environment())
|
||||||
|
|
||||||
|
let nodes = try parser.parse()
|
||||||
|
let node = nodes.first as? VariableNode
|
||||||
|
try expect(nodes.count) == 1
|
||||||
|
let result = try node?.render(Context())
|
||||||
|
try expect(result) == "name"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCommentToken() throws {
|
||||||
|
let parser = TokenParser(tokens: [
|
||||||
|
.comment(value: "Secret stuff!", at: .unknown)
|
||||||
|
], environment: Environment())
|
||||||
|
|
||||||
|
let nodes = try parser.parse()
|
||||||
|
try expect(nodes.count) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTagToken() throws {
|
||||||
|
let simpleExtension = Extension()
|
||||||
|
simpleExtension.registerSimpleTag("known") { _ in
|
||||||
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
it("can parse a variable token") {
|
let parser = TokenParser(tokens: [
|
||||||
let parser = TokenParser(tokens: [
|
.block(value: "known", at: .unknown)
|
||||||
.variable(value: "'name'", at: .unknown)
|
], environment: Environment(extensions: [simpleExtension]))
|
||||||
], environment: Environment())
|
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
let node = nodes.first as? VariableNode
|
try expect(nodes.count) == 1
|
||||||
try expect(nodes.count) == 1
|
}
|
||||||
let result = try node?.render(Context())
|
|
||||||
try expect(result) == "name"
|
|
||||||
}
|
|
||||||
|
|
||||||
it("can parse a comment token") {
|
func testErrorUnknownTag() throws {
|
||||||
let parser = TokenParser(tokens: [
|
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
|
||||||
.comment(value: "Secret stuff!", at: .unknown)
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
], environment: Environment())
|
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
try expect(try parser.parse()).toThrow(TemplateSyntaxError(
|
||||||
try expect(nodes.count) == 0
|
reason: "Unknown template tag 'unknown'",
|
||||||
}
|
token: tokens.first
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
it("can parse a tag token") {
|
func testTransformWhitespaceBehaviourToTrimBehaviour() throws {
|
||||||
let simpleExtension = Extension()
|
let simpleExtension = Extension()
|
||||||
simpleExtension.registerSimpleTag("known") { _ in
|
simpleExtension.registerSimpleTag("known") { _ in "" }
|
||||||
""
|
|
||||||
}
|
|
||||||
|
|
||||||
let parser = TokenParser(tokens: [
|
let parser = TokenParser(tokens: [
|
||||||
.block(value: "known", at: .unknown)
|
.block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .unspecified, trailing: .trim)),
|
||||||
], environment: Environment(extensions: [simpleExtension]))
|
.text(value: " \nSome text ", at: .unknown),
|
||||||
|
.block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .keep, trailing: .trim))
|
||||||
|
], environment: Environment(extensions: [simpleExtension]))
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
try expect(nodes.count) == 1
|
try expect(nodes.count) == 3
|
||||||
}
|
let textNode = nodes[1] as? TextNode
|
||||||
|
try expect(textNode?.text) == " \nSome text "
|
||||||
it("errors when parsing an unknown tag") {
|
try expect(textNode?.trimBehaviour) == TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
|
||||||
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
|
||||||
|
|
||||||
try expect(try parser.parse()).toThrow(TemplateSyntaxError(
|
|
||||||
reason: "Unknown template tag 'unknown'",
|
|
||||||
token: tokens.first
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
137
Tests/StencilTests/TrimBehaviourSpec.swift
Normal file
137
Tests/StencilTests/TrimBehaviourSpec.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import Spectre
|
||||||
|
import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class TrimBehaviourTests: XCTestCase {
|
||||||
|
func testSmartTrimCanRemoveNewlines() throws {
|
||||||
|
let templateString = """
|
||||||
|
{% for item in items %}
|
||||||
|
- {{item}}
|
||||||
|
{% endfor %}
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
- item 1
|
||||||
|
- item 2
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSmartTrimOnlyRemoveSingleNewlines() throws {
|
||||||
|
let templateString = """
|
||||||
|
{% for item in items %}
|
||||||
|
|
||||||
|
- {{item}}
|
||||||
|
{% endfor %}
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
|
||||||
|
- item 1
|
||||||
|
|
||||||
|
- item 2
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSmartTrimCanRemoveNewlinesWhileKeepingWhitespace() throws {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let templateString = """
|
||||||
|
Items:
|
||||||
|
{% for item in items %}
|
||||||
|
- {{item}}
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
Items:
|
||||||
|
- item 1
|
||||||
|
- item 2
|
||||||
|
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTrimSymbols() {
|
||||||
|
it("Respects whitespace control symbols in for tags") {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let template: Template = """
|
||||||
|
{% for num in numbers -%}
|
||||||
|
{{num}}
|
||||||
|
{%- endfor %}
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
let result = try template.render([ "numbers": Array(1...9) ])
|
||||||
|
try expect(result) == "123456789"
|
||||||
|
}
|
||||||
|
it("Respects whitespace control symbols in if tags") {
|
||||||
|
let template: Template = """
|
||||||
|
{% if value -%}
|
||||||
|
{{text}}
|
||||||
|
{%- endif %}
|
||||||
|
"""
|
||||||
|
let result = try template.render([ "text": "hello", "value": true ])
|
||||||
|
try expect(result) == "hello"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTrimSymbolsOverridingEnvironment() {
|
||||||
|
let environment = Environment(trimBehaviour: .all)
|
||||||
|
|
||||||
|
it("respects whitespace control symbols in if tags") {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let templateString = """
|
||||||
|
{% if value +%}
|
||||||
|
{{text}}
|
||||||
|
{%+ endif %}
|
||||||
|
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
let template = Template(templateString: templateString, environment: environment)
|
||||||
|
let result = try template.render([ "text": "hello", "value": true ])
|
||||||
|
try expect(result) == "\n hello\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can customize blocks on same line as text") {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let templateString = """
|
||||||
|
Items:{% for item in items +%}
|
||||||
|
- {{item}}
|
||||||
|
{%- endfor %}
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: environment)
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
Items:
|
||||||
|
- item 1
|
||||||
|
- item 2
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,17 @@ To comment out part of your template, you can use the following syntax:
|
|||||||
|
|
||||||
.. _template-inheritance:
|
.. _template-inheritance:
|
||||||
|
|
||||||
|
Whitespace Control
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Stencil supports the same syntax as Jinja for whitespace control, see [their docs for more information](https://jinja.palletsprojects.com/en/3.1.x/templates/#whitespace-control).
|
||||||
|
|
||||||
|
Essentially, Stencil will **not** trim whitespace by default. However you can:
|
||||||
|
|
||||||
|
- Control how this is handled for the whole template by setting the trim behaviour. We provide a few pre-made combinations such as `nothing` (default), `smart` and `all`. More granular combinations are possible.
|
||||||
|
- You can disable this per-block using the `+` control character. For example `{{+ if … }}` to preserve whitespace before.
|
||||||
|
- You can force trimming per-block by using the `-` control character. For example `{{ if … -}}` to trim whitespace after.
|
||||||
|
|
||||||
Template inheritance
|
Template inheritance
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user