├── .dockerignore ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── workflows │ ├── validate.yml │ ├── nightly.yml │ ├── api-breakage.yml │ ├── verify-documentation.yml │ └── ci.yml └── dependabot.yml ├── .spi.yml ├── .editorconfig ├── .gitignore ├── Dockerfile ├── documentation ├── Pragmas.md ├── Lambdas.md ├── Template Inheritance.md ├── Transforms.md └── Mustache Syntax.md ├── Package.swift ├── Sources └── Mustache │ ├── NSLock+Backport.swift │ ├── AnyOptional.swift │ ├── Parent.swift │ ├── Mirror.swift │ ├── Template+FileSystem.swift │ ├── String.swift │ ├── CustomRenderable.swift │ ├── Lambda.swift │ ├── SequenceContext.swift │ ├── Library+FileSystem.swift │ ├── Sequence.swift │ ├── Deprecations.swift │ ├── ContentType.swift │ ├── Template.swift │ ├── Library.swift │ ├── Context.swift │ ├── Transform.swift │ ├── Template+Render.swift │ ├── Parser.swift │ └── Template+Parser.swift ├── CONTRIBUTING.md ├── README.md ├── .swift-format ├── CODE_OF_CONDUCT.md ├── Tests └── MustacheTests │ ├── ErrorTests.swift │ ├── TemplateParserTests.swift │ ├── LibraryTests.swift │ ├── PartialTests.swift │ ├── TransformTests.swift │ ├── SpecTests.swift │ └── TemplateRendererTests.swift ├── scripts └── validate.sh └── LICENSE /.dockerignore: -------------------------------------------------------------------------------- 1 | .build 2 | .git -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: adam-fowler 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @adam-fowler @Joannis 2 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | external_links: 3 | documentation: "https://docs.hummingbird.codes/2.0/documentation/mustache" 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build/ 3 | .swiftpm/ 4 | .vscode/ 5 | .index-build/ 6 | .devcontainer/ 7 | /Packages 8 | /*.xcodeproj 9 | xcuserdata/ 10 | Package.resolved 11 | /public 12 | /docs 13 | .benchmarkBaselines -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validity Check 2 | 3 | on: 4 | pull_request: 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }}-validate 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | validate: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v6 16 | with: 17 | fetch-depth: 1 18 | - name: run script 19 | run: ./scripts/validate.sh 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:6.0 as build 5 | 6 | WORKDIR /build 7 | 8 | # First just resolve dependencies. 9 | # This creates a cached layer that can be reused 10 | # as long as your Package.swift/Package.resolved 11 | # files do not change. 12 | COPY ./Package.* ./ 13 | RUN swift package resolve 14 | 15 | # Copy entire repo into container 16 | COPY . . 17 | 18 | RUN swift test --sanitize=thread 19 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Swift nightly build 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | linux: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 15 10 | strategy: 11 | matrix: 12 | image: ['nightly-focal', 'nightly-jammy', 'nightly-amazonlinux2'] 13 | container: 14 | image: swiftlang/swift:${{ matrix.image }} 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v6 18 | - name: Test 19 | run: | 20 | swift test 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | ignore: 8 | - dependency-name: "codecov/codecov-action" 9 | update-types: ["version-update:semver-major"] 10 | groups: 11 | dependencies: 12 | patterns: 13 | - "*" 14 | - package-ecosystem: "swift" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | open-pull-requests-limit: 5 19 | allow: 20 | - dependency-type: all 21 | groups: 22 | all-dependencies: 23 | patterns: 24 | - "*" 25 | -------------------------------------------------------------------------------- /documentation/Pragmas.md: -------------------------------------------------------------------------------- 1 | # Pragmas/Configuration variables 2 | 3 | The syntax `{{% var: value}}` can be used to set template rendering configuration variables specific to swift-mustache. The only variable you can set at the moment is `CONTENT_TYPE`. This can be set to either to `HTML` or `TEXT` and defines how variables are escaped. A content type of `TEXT` means no variables are escaped and a content type of `HTML` will do HTML escaping of the rendered text. The content type defaults to `HTML`. 4 | 5 | Given input object "<>", template `{{%CONTENT_TYPE: HTML}}{{.}}` will render as `<>` and `{{%CONTENT_TYPE: TEXT}}{{.}}` will render as `<>`. 6 | 7 | 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-mustache", 8 | platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)], 9 | products: [ 10 | .library(name: "Mustache", targets: ["Mustache"]) 11 | ], 12 | dependencies: [], 13 | targets: [ 14 | .target(name: "Mustache", dependencies: []), 15 | .testTarget(name: "MustacheTests", dependencies: ["Mustache"]), 16 | ], 17 | swiftLanguageVersions: [.v5, .version("6")] 18 | ) 19 | -------------------------------------------------------------------------------- /.github/workflows/api-breakage.yml: -------------------------------------------------------------------------------- 1 | name: API breaking changes 2 | 3 | on: 4 | pull_request: 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }}-apibreakage 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | linux: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | container: 14 | image: swift:latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v6 18 | with: 19 | fetch-depth: 0 20 | # https://github.com/actions/checkout/issues/766 21 | - name: Mark the workspace as safe 22 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 23 | - name: API breaking changes 24 | run: | 25 | swift package diagnose-api-breaking-changes origin/${GITHUB_BASE_REF} 26 | -------------------------------------------------------------------------------- /Sources/Mustache/NSLock+Backport.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | #if compiler(<6.0) 16 | import Foundation 17 | 18 | extension NSLock { 19 | func withLock(_ operation: () throws -> Value) rethrows -> Value { 20 | self.lock() 21 | defer { 22 | self.unlock() 23 | } 24 | return try operation() 25 | } 26 | } 27 | #endif 28 | -------------------------------------------------------------------------------- /Sources/Mustache/AnyOptional.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | /// Internal protocol to allow testing if a variable contains a wrapped value 16 | protocol AnyOptional { 17 | var anyWrapped: Any? { get } 18 | } 19 | 20 | /// Internal extension to allow testing if a variable contains a wrapped value 21 | extension Optional: AnyOptional { 22 | var anyWrapped: Any? { self } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/verify-documentation.yml: -------------------------------------------------------------------------------- 1 | name: Verify Documentation 2 | 3 | on: 4 | pull_request: 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }}-verifydocs 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | linux: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 15 13 | container: 14 | image: swift:latest 15 | steps: 16 | - name: Install rsync 📚 17 | run: | 18 | apt-get update && apt-get install -y rsync bc 19 | - name: Checkout 20 | uses: actions/checkout@v6 21 | with: 22 | fetch-depth: 0 23 | path: "package" 24 | - name: Checkout 25 | uses: actions/checkout@v6 26 | with: 27 | repository: "hummingbird-project/hummingbird-docs" 28 | fetch-depth: 0 29 | path: "documentation" 30 | - name: Verify 31 | run: | 32 | cd documentation 33 | swift package edit ${GITHUB_REPOSITORY#*/} --path ../package 34 | ./scripts/build-docc.sh -e 35 | 36 | -------------------------------------------------------------------------------- /Sources/Mustache/Parent.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | /// Protocol for object that has a custom method for accessing their children, instead 16 | /// of using Mirror 17 | public protocol MustacheParent { 18 | func child(named: String) -> Any? 19 | } 20 | 21 | /// Extend dictionary where the key is a string so that it uses the key values to access 22 | /// it values 23 | extension Dictionary: MustacheParent where Key == String { 24 | public func child(named: String) -> Any? { self[named] } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Mustache/Mirror.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | extension Mirror { 16 | /// Return value from Mirror given name 17 | func getValue(forKey key: String) -> Any? { 18 | guard let matched = children.filter({ $0.label == key }).first else { 19 | return nil 20 | } 21 | return unwrapOptional(matched.value) 22 | } 23 | } 24 | 25 | /// Return object and if it is an Optional return object Optional holds 26 | private func unwrapOptional(_ object: Any) -> Any? { 27 | let mirror = Mirror(reflecting: object) 28 | guard mirror.displayStyle == .optional else { return object } 29 | guard let first = mirror.children.first else { return nil } 30 | return first.value 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Mustache/Template+FileSystem.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | extension MustacheTemplate { 18 | /// Internal function to load a template from a file 19 | /// - Parameters 20 | /// - string: Template text 21 | /// - filename: File template was loaded from 22 | /// - Throws: MustacheTemplate.Error 23 | init?(filename: String) throws { 24 | let fs = FileManager() 25 | guard let data = fs.contents(atPath: filename) else { return nil } 26 | let string = String(decoding: data, as: Unicode.UTF8.self) 27 | let template = try Self.parse(string) 28 | self.tokens = template.tokens 29 | self.text = string 30 | self.filename = filename 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /documentation/Lambdas.md: -------------------------------------------------------------------------------- 1 | # Lambda Implementation 2 | 3 | The library doesn't provide a lambda implementation but it does provide something akin to the lambda feature. 4 | 5 | Add a `MustacheLambda` to the object you want to be rendered and it can be used in a similar way to lambdas are used in Mustache. When you create a section referencing the lambda the contents of the section are passed as a template along with the current object to the lamdba function. This is slightly different from the standard implementation where the unprocessed text is passed to the lambda. 6 | 7 | Given the object `person` defined below 8 | ```swift 9 | struct Person { 10 | let name: String 11 | let wrapped: MustacheLambda 12 | } 13 | let person = Person( 14 | name: "John", 15 | wrapped: MustacheLambda { object, template in 16 | return "\(template.render(object))" 17 | } 18 | ) 19 | 20 | ``` 21 | and the following mustache template 22 | ```swift 23 | let mustache = "{{#wrapped}}{{name}} is awesome.{{/wrapped}}" 24 | let template = try MustacheTemplate(string: mustache) 25 | ``` 26 | Then `template.render(person)` will output 27 | ``` 28 | John is awesome. 29 | ``` 30 | In this example the template constructed from the contents of the `wrapped` section of the mustache is passed to my `wrapped` function inside the `Person` type. 31 | -------------------------------------------------------------------------------- /Sources/Mustache/String.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | extension String { 16 | private static let htmlEscapedCharacters: [Character: String] = [ 17 | "<": "<", 18 | ">": ">", 19 | "&": "&", 20 | "\"": """, 21 | "'": "'", 22 | ] 23 | /// HTML escape string. Replace '<', '>' and '&' with HTML escaped versions 24 | func htmlEscape() -> String { 25 | var newString = "" 26 | newString.reserveCapacity(count) 27 | // currently doing this by going through each character could speed 28 | // this us by treating as an array of UInt8's 29 | for c in self { 30 | if let replacement = Self.htmlEscapedCharacters[c] { 31 | newString += replacement 32 | } else { 33 | newString.append(c) 34 | } 35 | } 36 | return newString 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Mustache/CustomRenderable.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | /// Allow object to override standard hummingbird type rendering which uses 18 | /// `String(describing)`. 19 | public protocol MustacheCustomRenderable { 20 | /// Custom rendered version of object 21 | var renderText: String { get } 22 | /// Whether the object is a null object. Used when scoping sections 23 | var isNull: Bool { get } 24 | } 25 | 26 | extension MustacheCustomRenderable { 27 | /// default version returning the standard rendering 28 | var renderText: String { String(describing: self) } 29 | /// default version returning false 30 | var isNull: Bool { false } 31 | } 32 | 33 | /// Extend NSNull to conform to `MustacheCustomRenderable` to avoid outputting `` and returning 34 | /// a valid response for `isNull` 35 | extension NSNull: MustacheCustomRenderable { 36 | public var renderText: String { "" } 37 | public var isNull: Bool { true } 38 | } 39 | -------------------------------------------------------------------------------- /documentation/Template Inheritance.md: -------------------------------------------------------------------------------- 1 | # Template Inheritance 2 | 3 | Template inheritance is not part of the Mustache spec yet but it is a commonly implemented feature. Template inheritance allows you to override elements of an included partial. It allows you to create a base page template and override elements of it with your page content. A partial that includes overriding elements is indicated with a `{{`. This is a section tag so needs a ending tag as well. Inside the section the tagged sections to override are added using the syntax `{{$tag}}contents{{/tag}}`. If your template and partial were as follows 4 | ``` 5 | {{! mypage.mustache }} 6 | {{My page title{{/head}} 8 | {{$body}}Hello world{{/body}} 9 | {{/base}} 10 | ``` 11 | ``` 12 | {{! base.mustache }} 13 | 14 | 15 | {{$head}}{{/head}} 16 | 17 | 18 | {{$body}}Default text{{/body}} 19 | 20 | 21 | ``` 22 | You would get the following output when rendering `mypage.mustache`. 23 | ``` 24 | 25 | 26 | My page title 27 | 28 | 29 | Hello world 30 | 31 | ``` 32 | Note the `{{$head}}` section in `base.mustache` is replaced with the `{{$head}}` section included inside the `{{\(string)" 27 | /// })) 28 | /// let mustache = "{{#wrapped}}{{name}} is awesome.{{/wrapped}}" 29 | /// let template = try MustacheTemplate(string: mustache) 30 | /// let output = template.render(willy) 31 | /// print(output) // Willy is awesome 32 | /// ``` 33 | /// 34 | public struct MustacheLambda { 35 | /// lambda callback 36 | public typealias Callback = (String) -> Any? 37 | 38 | let callback: Callback 39 | 40 | /// Initialize `MustacheLambda` 41 | /// - Parameter cb: function to be called by lambda 42 | public init(_ cb: @escaping Callback) { 43 | self.callback = cb 44 | } 45 | 46 | /// Initialize `MustacheLambda` 47 | /// - Parameter cb: function to be called by lambda 48 | public init(_ cb: @escaping () -> Any?) { 49 | self.callback = { _ in cb() } 50 | } 51 | 52 | internal func callAsFunction(_ s: String) -> Any? { 53 | self.callback(s) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Mustache/SequenceContext.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | /// Context that current object inside a sequence is being rendered in. Only relevant when rendering a sequence 16 | struct MustacheSequenceContext: MustacheTransformable { 17 | var first: Bool 18 | var last: Bool 19 | var index: Int 20 | 21 | init(first: Bool = false, last: Bool = false) { 22 | self.first = first 23 | self.last = last 24 | self.index = 0 25 | } 26 | 27 | /// Transform `MustacheContext`. These are available when processing elements 28 | /// of a sequence. 29 | /// 30 | /// Format your mustache as follows to accept them. They look like a function without any arguments 31 | /// ``` 32 | /// {{#sequence}}{{index()}}{{/sequence}} 33 | /// ``` 34 | /// 35 | /// Transforms available are `first`, `last`, `index`, `even` and `odd` 36 | /// - Parameter name: transform name 37 | /// - Returns: Result 38 | func transform(_ name: String) -> Any? { 39 | switch name { 40 | case "first": 41 | return self.first 42 | case "last": 43 | return self.last 44 | case "index": 45 | return self.index 46 | case "even": 47 | return (self.index & 1) == 0 48 | case "odd": 49 | return (self.index & 1) == 1 50 | default: 51 | return nil 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Mustache/Library+FileSystem.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | extension MustacheLibrary { 18 | /// Load templates from a folder 19 | static func loadTemplates(from directory: String, withExtension extension: String = "mustache") throws -> [String: MustacheTemplate] { 20 | var directory = directory 21 | if !directory.hasSuffix("/") { 22 | directory += "/" 23 | } 24 | let extWithDot = ".\(`extension`)" 25 | let fs = FileManager() 26 | guard let enumerator = fs.enumerator(atPath: directory) else { return [:] } 27 | var templates: [String: MustacheTemplate] = [:] 28 | for case let path as String in enumerator { 29 | guard path.hasSuffix(extWithDot) else { continue } 30 | do { 31 | guard let template = try MustacheTemplate(filename: directory + path) else { continue } 32 | // drop ".mustache" from path to get name 33 | let name = String(path.dropLast(extWithDot.count)) 34 | #if os(Windows) 35 | templates[name.replacingOccurrences(of: "\\", with: "/")] = template 36 | #else 37 | templates[name] = template 38 | #endif 39 | } catch let error as MustacheTemplate.ParserError { 40 | throw ParserError(filename: path, context: error.context, error: error.error) 41 | } 42 | } 43 | return templates 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift-Mustache 2 | 3 | Package for rendering Mustache templates. Mustache is a "logic-less" templating language commonly used in web and mobile platforms. You can find out more about Mustache [here](http://mustache.github.io/mustache.5.html). 4 | 5 | ## Usage 6 | 7 | Load your templates from the filesystem 8 | ```swift 9 | import Mustache 10 | let library = MustacheLibrary("folder/my/templates/are/in") 11 | ``` 12 | This will look for all the files with the extension ".mustache" in the specified folder and subfolders and attempt to load them. Each file is registed with the name of the file (with subfolder, if inside a subfolder) minus the "mustache" extension. 13 | 14 | Render an object with a template 15 | ```swift 16 | let output = library.render(object, withTemplate: "myTemplate") 17 | ``` 18 | `Swift-Mustache` treats an object as a set of key/value pairs when rendering and will render both dictionaries and objects via `Mirror` reflection. Find out more on how Mustache renders objects [here](https://docs.hummingbird.codes/2.0/documentation/hummingbird/mustachesyntax). 19 | 20 | ## Support 21 | 22 | Swift-Mustache supports all standard Mustache tags and is fully compliant with the Mustache [spec](https://github.com/mustache/spec) with the exception of the Lambda support. 23 | 24 | ## Additional features 25 | 26 | Swift-Mustache includes some features that are specific to its implementation. Please follow the links below to find out more. 27 | 28 | - [Lambda Implementation](https://docs.hummingbird.codes/2.0/documentation/hummingbird/mustachefeatures#Lambdas) 29 | - [Transforms](https://docs.hummingbird.codes/2.0/documentation/hummingbird/mustachefeatures#Transforms) 30 | - [Template Inheritance](https://docs.hummingbird.codes/2.0/documentation/hummingbird/mustachefeatures#Template-inheritance-and-parents) 31 | - [Pragmas](https://docs.hummingbird.codes/2.0/documentation/hummingbird/mustachefeatures#PragmasConfiguration-variables) 32 | 33 | ## Documentation 34 | 35 | Reference documentation for swift-mustache can be found [here](https://docs.hummingbird.codes/2.0/documentation/mustache) 36 | -------------------------------------------------------------------------------- /Sources/Mustache/Sequence.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | /// Protocol for objects that can be rendered as a sequence in Mustache 16 | public protocol MustacheSequence: Sequence {} 17 | 18 | extension MustacheSequence { 19 | /// Render section using template 20 | func renderSection(with template: MustacheTemplate, context: MustacheContext) -> String { 21 | var string = "" 22 | var sequenceContext = MustacheSequenceContext(first: true) 23 | 24 | var iterator = makeIterator() 25 | guard var currentObject = iterator.next() else { return "" } 26 | 27 | while let object = iterator.next() { 28 | string += template.render(context: context.withSequence(currentObject, sequenceContext: sequenceContext)) 29 | currentObject = object 30 | sequenceContext.first = false 31 | sequenceContext.index += 1 32 | } 33 | 34 | sequenceContext.last = true 35 | string += template.render(context: context.withSequence(currentObject, sequenceContext: sequenceContext)) 36 | 37 | return string 38 | } 39 | 40 | /// Render inverted section using template 41 | func renderInvertedSection(with template: MustacheTemplate, context: MustacheContext) -> String { 42 | var iterator = makeIterator() 43 | if iterator.next() == nil { 44 | return template.render(context: context.withObject(self)) 45 | } 46 | return "" 47 | } 48 | } 49 | 50 | extension Array: MustacheSequence {} 51 | extension Set: MustacheSequence {} 52 | extension ReversedCollection: MustacheSequence {} 53 | -------------------------------------------------------------------------------- /Sources/Mustache/Deprecations.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | // Below is a list of unavailable symbols with the "HB" prefix. These are available 16 | // temporarily to ease transition from the old symbols that included the "HB" 17 | // prefix to the new ones. 18 | 19 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheContentType") 20 | public typealias HBMustacheContentType = MustacheContentType 21 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheContentTypes") 22 | public typealias HBMustacheContentTypes = MustacheContentTypes 23 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheCustomRenderable") 24 | public typealias HBMustacheCustomRenderable = MustacheCustomRenderable 25 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheLambda") 26 | public typealias HBMustacheLambda = MustacheLambda 27 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheLibrary") 28 | public typealias HBMustacheLibrary = MustacheLibrary 29 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheParent") 30 | public typealias HBMustacheParent = MustacheParent 31 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheParserContext") 32 | public typealias HBMustacheParserContext = MustacheParserContext 33 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheTemplate") 34 | public typealias HBMustacheTemplate = MustacheTemplate 35 | @_documentation(visibility: internal) @available(*, unavailable, renamed: "MustacheTransformable") 36 | public typealias HBMustacheTransformable = MustacheTransformable 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**.swift' 9 | - '**.yml' 10 | pull_request: 11 | workflow_dispatch: 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }}-ci 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | macOS: 18 | runs-on: macos-14 19 | timeout-minutes: 15 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v6 23 | - name: SPM tests 24 | run: swift test --enable-code-coverage 25 | - name: Convert coverage files 26 | run: | 27 | xcrun llvm-cov export -format "lcov" \ 28 | .build/debug/swift-mustachePackageTests.xctest/Contents/MacOs/swift-mustachePackageTests \ 29 | -ignore-filename-regex="\/Tests\/" \ 30 | -ignore-filename-regex="\/Benchmarks\/" \ 31 | -instr-profile=.build/debug/codecov/default.profdata > info.lcov 32 | - name: Upload to codecov.io 33 | uses: codecov/codecov-action@v4 34 | with: 35 | files: info.lcov 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | linux: 38 | runs-on: ubuntu-latest 39 | timeout-minutes: 15 40 | strategy: 41 | matrix: 42 | image: ["swift:6.0", "swift:6.1", "swift:6.2"] 43 | container: 44 | image: ${{ matrix.image }} 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v6 48 | - name: Test 49 | run: | 50 | swift test --enable-code-coverage 51 | - name: Convert coverage files 52 | run: | 53 | llvm-cov export -format="lcov" \ 54 | .build/debug/swift-mustachePackageTests.xctest \ 55 | -ignore-filename-regex="\/Tests\/" \ 56 | -ignore-filename-regex="\/Benchmarks\/" \ 57 | -instr-profile .build/debug/codecov/default.profdata > info.lcov 58 | - name: Upload to codecov.io 59 | uses: codecov/codecov-action@v4 60 | with: 61 | files: info.lcov 62 | token: ${{ secrets.CODECOV_TOKEN }} 63 | windows: 64 | runs-on: windows-latest 65 | strategy: 66 | matrix: 67 | swift-version: ["6.1"] 68 | steps: 69 | - uses: compnerd/gha-setup-swift@main 70 | with: 71 | branch: swift-${{ matrix.swift-version }}-release 72 | tag: ${{ matrix.swift-version }}-RELEASE 73 | - uses: actions/checkout@v6 74 | - run: swift test 75 | -------------------------------------------------------------------------------- /Sources/Mustache/ContentType.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | /// Protocol for content types 18 | public protocol MustacheContentType: Sendable { 19 | /// escape text for this content type eg for HTML replace "<" with "<" 20 | func escapeText(_ text: String) -> String 21 | } 22 | 23 | /// Text content type where no character is escaped 24 | struct TextContentType: MustacheContentType { 25 | func escapeText(_ text: String) -> String { 26 | text 27 | } 28 | } 29 | 30 | /// HTML content where text is escaped for HTML output 31 | struct HTMLContentType: MustacheContentType { 32 | func escapeText(_ text: String) -> String { 33 | text.htmlEscape() 34 | } 35 | } 36 | 37 | /// Map of strings to content types. 38 | /// 39 | /// The string is read from the "CONTENT_TYPE" pragma `{{% CONTENT_TYPE: type}}`. Replace type with 40 | /// the content type required. The default available types are `TEXT` and `HTML`. You can register your own 41 | /// with `MustacheContentTypes.register`. 42 | public enum MustacheContentTypes { 43 | 44 | private static let lock = NSLock() 45 | 46 | static func get(_ name: String) -> MustacheContentType? { 47 | lock.withLock { 48 | self.types[name] 49 | } 50 | } 51 | 52 | /// Register new content type 53 | /// - Parameters: 54 | /// - contentType: Content type 55 | /// - name: String to identify it 56 | public static func register(_ contentType: MustacheContentType, named name: String) { 57 | lock.withLock { 58 | self.types[name] = contentType 59 | } 60 | } 61 | 62 | private static let _types: [String: MustacheContentType] = [ 63 | "HTML": HTMLContentType(), 64 | "TEXT": TextContentType(), 65 | ] 66 | 67 | #if compiler(>=6.0) 68 | nonisolated(unsafe) static var types: [String: MustacheContentType] = _types 69 | #else 70 | static var types: [String: MustacheContentType] = _types 71 | #endif 72 | } 73 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "version" : 1, 3 | "indentation" : { 4 | "spaces" : 4 5 | }, 6 | "tabWidth" : 4, 7 | "fileScopedDeclarationPrivacy" : { 8 | "accessLevel" : "private" 9 | }, 10 | "spacesAroundRangeFormationOperators" : false, 11 | "indentConditionalCompilationBlocks" : false, 12 | "indentSwitchCaseLabels" : false, 13 | "lineBreakAroundMultilineExpressionChainComponents" : false, 14 | "lineBreakBeforeControlFlowKeywords" : false, 15 | "lineBreakBeforeEachArgument" : true, 16 | "lineBreakBeforeEachGenericRequirement" : true, 17 | "lineLength" : 150, 18 | "maximumBlankLines" : 1, 19 | "respectsExistingLineBreaks" : true, 20 | "prioritizeKeepingFunctionOutputTogether" : true, 21 | "multiElementCollectionTrailingCommas" : true, 22 | "rules" : { 23 | "AllPublicDeclarationsHaveDocumentation" : false, 24 | "AlwaysUseLiteralForEmptyCollectionInit" : false, 25 | "AlwaysUseLowerCamelCase" : false, 26 | "AmbiguousTrailingClosureOverload" : true, 27 | "BeginDocumentationCommentWithOneLineSummary" : false, 28 | "DoNotUseSemicolons" : true, 29 | "DontRepeatTypeInStaticProperties" : true, 30 | "FileScopedDeclarationPrivacy" : true, 31 | "FullyIndirectEnum" : true, 32 | "GroupNumericLiterals" : true, 33 | "IdentifiersMustBeASCII" : true, 34 | "NeverForceUnwrap" : false, 35 | "NeverUseForceTry" : false, 36 | "NeverUseImplicitlyUnwrappedOptionals" : false, 37 | "NoAccessLevelOnExtensionDeclaration" : true, 38 | "NoAssignmentInExpressions" : true, 39 | "NoBlockComments" : true, 40 | "NoCasesWithOnlyFallthrough" : true, 41 | "NoEmptyTrailingClosureParentheses" : true, 42 | "NoLabelsInCasePatterns" : true, 43 | "NoLeadingUnderscores" : false, 44 | "NoParensAroundConditions" : true, 45 | "NoVoidReturnOnFunctionSignature" : true, 46 | "OmitExplicitReturns" : true, 47 | "OneCasePerLine" : true, 48 | "OneVariableDeclarationPerLine" : true, 49 | "OnlyOneTrailingClosureArgument" : true, 50 | "OrderedImports" : true, 51 | "ReplaceForEachWithForLoop" : true, 52 | "ReturnVoidInsteadOfEmptyTuple" : true, 53 | "UseEarlyExits" : false, 54 | "UseExplicitNilCheckInConditions" : false, 55 | "UseLetInEveryBoundCaseVariable" : false, 56 | "UseShorthandTypeNames" : true, 57 | "UseSingleLinePropertyGetter" : false, 58 | "UseSynthesizedInitializer" : false, 59 | "UseTripleSlashForDocumentationComments" : true, 60 | "UseWhereClausesInForLoops" : false, 61 | "ValidateDocumentationComments" : false 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | All developers should feel welcome and encouraged to contribute to Hummingbird. Because of this we have adopted the code of conduct defined by [contributor-covenant.org](https://www.contributor-covenant.org). This document is used across many open source 4 | communities, and we think it articulates our values well. The full text is copied below: 5 | 6 | ## Contributor Code of Conduct v1.3 7 | 8 | As contributors and maintainers of this project, and in the interest of 9 | fostering an open and welcoming community, we pledge to respect all people who 10 | contribute through reporting issues, posting feature requests, updating 11 | documentation, submitting pull requests or patches, and other activities. 12 | 13 | We are committed to making participation in this project a harassment-free 14 | experience for everyone, regardless of level of experience, gender, gender 15 | identity and expression, sexual orientation, disability, personal appearance, 16 | body size, race, ethnicity, age, religion, or nationality. 17 | 18 | Examples of unacceptable behavior by participants include: 19 | 20 | * The use of sexualized language or imagery 21 | * Personal attacks 22 | * Trolling or insulting/derogatory comments 23 | * Public or private harassment 24 | * Publishing other's private information, such as physical or electronic 25 | addresses, without explicit permission 26 | * Other unethical or unprofessional conduct 27 | 28 | Project maintainers have the right and responsibility to remove, edit, or 29 | reject comments, commits, code, wiki edits, issues, and other contributions 30 | that are not aligned to this Code of Conduct, or to ban temporarily or 31 | permanently any contributor for other behaviors that they deem inappropriate, 32 | threatening, offensive, or harmful. 33 | 34 | By adopting this Code of Conduct, project maintainers commit themselves to 35 | fairly and consistently applying these principles to every aspect of managing 36 | this project. Project maintainers who do not follow or enforce the Code of 37 | Conduct may be permanently removed from the project team. 38 | 39 | This Code of Conduct applies both within project spaces and in public spaces 40 | when an individual is representing the project or its community. 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 43 | reported by contacting a project maintainer at [INSERT EMAIL ADDRESS]. All 44 | complaints will be reviewed and investigated and will result in a response that 45 | is deemed necessary and appropriate to the circumstances. Maintainers are 46 | obligated to maintain confidentiality with regard to the reporter of an 47 | incident. 48 | 49 | 50 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 51 | version 1.3.0, available at https://www.contributor-covenant.org/version/1/3/0/code-of-conduct.html 52 | 53 | [homepage]: https://www.contributor-covenant.org 54 | 55 | -------------------------------------------------------------------------------- /documentation/Transforms.md: -------------------------------------------------------------------------------- 1 | # Transforms 2 | 3 | Transforms are specific to this implementation of Mustache. They are similar to Lambdas but instead of generating rendered text they allow you to transform an object into another. Transforms are formatted as a function call inside a tag eg 4 | ``` 5 | {{uppercase(string)}} 6 | ``` 7 | They can be applied to variable, section and inverted section tags. If you apply them to a section or inverted section tag the transform name should be included in the end section tag as well eg 8 | ``` 9 | {{#sorted(array)}}{{.}}{{/sorted(array)}} 10 | ``` 11 | The library comes with a series of transforms for the Swift standard objects. 12 | - String/Substring 13 | - capitalized: Return string with first letter capitalized 14 | - lowercase: Return lowercased version of string 15 | - uppercase: Return uppercased version of string 16 | - reversed: Reverse string 17 | - Int/UInt/Int8/Int16... 18 | - equalzero: Returns if equal to zero 19 | - plusone: Add one to integer 20 | - minusone: Subtract one from integer 21 | - odd: return if integer is odd 22 | - even: return if integer is even 23 | - Array 24 | - first: Return first element of array 25 | - last: Return last element of array 26 | - count: Return number of elements in array 27 | - empty: Returns if array is empty 28 | - reversed: Reverse array 29 | - sorted: If the elements of the array are comparable sort them 30 | - Dictionary 31 | - count: Return number of elements in dictionary 32 | - empty: Returns if dictionary is empty 33 | - enumerated: Return dictionary as array of key, value pairs 34 | - sorted: If the keys are comparable return as array of key, value pairs sorted by key 35 | 36 | If a transform is applied to an object that doesn't recognise it then `nil` is returned. 37 | 38 | ## Sequence context transforms 39 | 40 | Sequence context transforms are transforms applied to the current position in the sequence. They are formatted as a function that takes no parameter eg 41 | ``` 42 | {{#array}}{{.}}{{^last()}}, {{/last()}}{{/array}} 43 | ``` 44 | This will render an array as a comma separated list. The inverted section of the `last()` transform ensures we don't add a comma after the last element. 45 | 46 | The following sequence context transforms are available 47 | - first: Is this the first element of the sequence 48 | - last: Is this the last element of the sequence 49 | - index: Returns the index of the element within the sequence 50 | - odd: Returns if the index of the element is odd 51 | - even: Returns if the index of the element is even 52 | 53 | ## Custom transforms 54 | 55 | You can add transforms to your own objects. Conform the object to `MustacheTransformable` and provide an implementation of the function `transform`. eg 56 | ```swift 57 | struct Object: MustacheTransformable { 58 | let either: Bool 59 | let or: Bool 60 | 61 | func transform(_ name: String) -> Any? { 62 | switch name { 63 | case "eitherOr": 64 | return either || or 65 | default: 66 | break 67 | } 68 | return nil 69 | } 70 | } 71 | ``` 72 | When we render an instance of this object with `either` or `or` set to true using the following template it will render "Success". 73 | ``` 74 | {{#eitherOr(object)}}Success{{/eitherOr(object)}} 75 | ``` 76 | With this we have got around the fact it is not possible to do logical OR statements in Mustache. 77 | -------------------------------------------------------------------------------- /Sources/Mustache/Template.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | /// Class holding Mustache template 16 | public struct MustacheTemplate: Sendable, CustomStringConvertible { 17 | /// Initialize template 18 | /// - Parameter string: Template text 19 | /// - Throws: MustacheTemplate.Error 20 | public init(string: String) throws { 21 | let template = try Self.parse(string) 22 | self.tokens = template.tokens 23 | self.text = string 24 | self.filename = nil 25 | } 26 | 27 | /// Render object using this template 28 | /// - Parameters 29 | /// - object: Object to render 30 | /// - library: library template uses to access partials 31 | /// - Returns: Rendered text 32 | public func render(_ object: Any, library: MustacheLibrary? = nil) -> String { 33 | self.render(context: .init(object, library: library)) 34 | } 35 | 36 | /// Render object using this template 37 | /// - Parameters 38 | /// - object: Object to render 39 | /// - library: library template uses to access partials 40 | /// - reload: Should I reload this template when rendering. This is only available in debug builds 41 | /// - Returns: Rendered text 42 | public func render(_ object: Any, library: MustacheLibrary? = nil, reload: Bool) -> String { 43 | #if DEBUG 44 | if reload { 45 | guard let filename else { 46 | preconditionFailure("Can only use reload if template was generated from a file") 47 | } 48 | do { 49 | guard let template = try MustacheTemplate(filename: filename) else { return "Cannot find template at \(filename)" } 50 | return template.render(context: .init(object, library: library, reloadPartials: reload)) 51 | } catch { 52 | return "\(error)" 53 | } 54 | } 55 | #endif 56 | return self.render(context: .init(object, library: library)) 57 | } 58 | 59 | internal init(_ tokens: [Token], text: String) { 60 | self.tokens = tokens 61 | self.filename = nil 62 | self.text = text 63 | } 64 | 65 | public var description: String { self.text } 66 | 67 | enum Token: Sendable /* , CustomStringConvertible */ { 68 | case text(String) 69 | case variable(name: String, transforms: [String] = []) 70 | case unescapedVariable(name: String, transforms: [String] = []) 71 | case section(name: String, transforms: [String] = [], template: MustacheTemplate) 72 | case invertedSection(name: String, transforms: [String] = [], template: MustacheTemplate) 73 | case blockDefinition(name: String, template: MustacheTemplate) 74 | case blockExpansion(name: String, default: MustacheTemplate, indentation: String?) 75 | case partial(String, indentation: String?, inherits: [String: MustacheTemplate]?) 76 | case dynamicNamePartial(String, indentation: String?, inherits: [String: MustacheTemplate]?) 77 | case contentType(MustacheContentType) 78 | } 79 | 80 | var tokens: [Token] 81 | let text: String 82 | let filename: String? 83 | } 84 | -------------------------------------------------------------------------------- /documentation/Mustache Syntax.md: -------------------------------------------------------------------------------- 1 | # Mustache Syntax 2 | 3 | Mustache is a "logic-less" templating engine. The core language has no flow control statements. Instead it has tags that can be replaced with a value, nothing or a series of values. Below we document all the standard tags 4 | 5 | ## Context 6 | 7 | Mustache renders a template with a context stack. A context is a list of key/value pairs. These can be represented by either a `Dictionary` or the reflection information from `Mirror`. For example the following two objects will render in the same way 8 | ```swift 9 | let object = ["name": "John Smith", "age": 68] 10 | ``` 11 | ```swift 12 | struct Person { 13 | let name: String 14 | let age: Int 15 | } 16 | let object = Person(name: "John Smith", age: 68) 17 | ``` 18 | 19 | Initially the stack will consist of the root context object you want to render. When we enter a section tag we push the associated value onto the context stack and when we leave the section we pop that value back off the stack. 20 | 21 | ## Tags 22 | 23 | All tags are surrounded by a double curly bracket `{{}}`. When a tag has a reference to a key, the key will be searched for from the context at the top of the context stack and the associated value will be output. If the key cannot be found then the next context down will be searched and so on until either a key is found or we have reached the bottom of the stack. If no key is found the output for that value is `nil`. 24 | 25 | A tag can be used to reference a child value from the associated value of a key by using dot notation in a similar manner to Swift. eg in `{{main.sub}}` the first context is searched for the `main` key. If a value is found, that value is used as a context and the key `sub` is used to search within that context and so on. 26 | 27 | If you want to only search for values in the context at the top of the stack then prefix the variable name with a "." eg `{{.key}}` 28 | 29 | ## Tag types 30 | 31 | - `{{key}}`: Render value associated with `key` as text. By default this is HTML escaped. A `nil` value is rendered as an empty string. 32 | - `{{{name}}}`: Acts the same as `{{name}}` except the resultant text is not HTML escaped. You can also use `{{&name}}` to avoid HTML escaping. 33 | - `{{#section}}`: Section render blocks either render text once or multiple times depending on the value of the key in the current context. A section begins with `{{#section}}` and end with `{{/section}}`. If the key represents a `Bool` value it will only render if it is true. If the key represents an `Optional` it will only render if the object is non-nil. If the key represents an `Array` it will then render the internals of the section multiple times, once for each element of the `Array`. Otherwise it will render with the selected value pushed onto the top of the context stack. 34 | - `{{^section}}`: An inverted section does the opposite of a section. If the key represents a `Bool` value it will render if it is false. If the key represents an `Optional` it will render if it is `nil`. If the key represents a `Array` it will render if the `Array` is empty. 35 | - `{{! comment }}`: This is a comment tag and is ignored. 36 | - `{{> partial}}`: A partial tag renders another mustache file, with the current context stack. In swift-mustache partial tags only work for templates that are a part of a library and the tag is the name of the referenced file without the ".mustache" extension. 37 | - `{{=<% %>=}}`: The set delimiter tag allows you to change from using the double curly brackets as tag delimiters. In the example the delimiters have been changed to `<% %>` but you can change them to whatever you like. 38 | 39 | You can find out more about the standard Mustache tags in the [Mustache Manual](https://mustache.github.io/mustache.5.html). 40 | -------------------------------------------------------------------------------- /Tests/MustacheTests/ErrorTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Mustache 16 | import XCTest 17 | 18 | final class ErrorTests: XCTestCase { 19 | func testSectionCloseNameIncorrect() { 20 | XCTAssertThrowsError( 21 | try MustacheTemplate( 22 | string: """ 23 | {{#test}} 24 | {{.}} 25 | {{/test2}} 26 | """ 27 | ) 28 | ) { error in 29 | switch error { 30 | case let error as MustacheTemplate.ParserError: 31 | XCTAssertEqual(error.error as? MustacheTemplate.Error, .sectionCloseNameIncorrect) 32 | XCTAssertEqual(error.context.line, "{{/test2}}") 33 | XCTAssertEqual(error.context.lineNumber, 3) 34 | XCTAssertEqual(error.context.columnNumber, 4) 35 | 36 | default: 37 | XCTFail("\(error)") 38 | } 39 | } 40 | } 41 | 42 | func testUnfinishedName() { 43 | XCTAssertThrowsError( 44 | try MustacheTemplate( 45 | string: """ 46 | {{#test}} 47 | {{name} 48 | {{/test2}} 49 | """ 50 | ) 51 | ) { error in 52 | switch error { 53 | case let error as MustacheTemplate.ParserError: 54 | XCTAssertEqual(error.error as? MustacheTemplate.Error, .unfinishedName) 55 | XCTAssertEqual(error.context.line, "{{name}") 56 | XCTAssertEqual(error.context.lineNumber, 2) 57 | XCTAssertEqual(error.context.columnNumber, 7) 58 | 59 | default: 60 | XCTFail("\(error)") 61 | } 62 | } 63 | } 64 | 65 | func testExpectedSectionEnd() { 66 | XCTAssertThrowsError( 67 | try MustacheTemplate( 68 | string: """ 69 | {{#test}} 70 | {{.}} 71 | """ 72 | ) 73 | ) { error in 74 | switch error { 75 | case let error as MustacheTemplate.ParserError: 76 | XCTAssertEqual(error.error as? MustacheTemplate.Error, .expectedSectionEnd) 77 | XCTAssertEqual(error.context.line, "{{.}}") 78 | XCTAssertEqual(error.context.lineNumber, 2) 79 | XCTAssertEqual(error.context.columnNumber, 6) 80 | 81 | default: 82 | XCTFail("\(error)") 83 | } 84 | } 85 | } 86 | 87 | func testInvalidSetDelimiter() { 88 | XCTAssertThrowsError( 89 | try MustacheTemplate( 90 | string: """ 91 | {{=<% %>=}} 92 | <%.%> 93 | <%={{}}=%> 94 | """ 95 | ) 96 | ) { error in 97 | switch error { 98 | case let error as MustacheTemplate.ParserError: 99 | XCTAssertEqual(error.error as? MustacheTemplate.Error, .invalidSetDelimiter) 100 | XCTAssertEqual(error.context.line, "<%={{}}=%>") 101 | XCTAssertEqual(error.context.lineNumber, 3) 102 | XCTAssertEqual(error.context.columnNumber, 4) 103 | 104 | default: 105 | XCTFail("\(error)") 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Tests/MustacheTests/TemplateParserTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import XCTest 16 | 17 | @testable import Mustache 18 | 19 | final class TemplateParserTests: XCTestCase { 20 | func testText() throws { 21 | let template = try MustacheTemplate(string: "test template") 22 | XCTAssertEqual(template.tokens, [.text("test template")]) 23 | } 24 | 25 | func testVariable() throws { 26 | let template = try MustacheTemplate(string: "test {{variable}}") 27 | XCTAssertEqual(template.tokens, [.text("test "), .variable(name: "variable")]) 28 | } 29 | 30 | func testSection() throws { 31 | let template = try MustacheTemplate(string: "test {{#section}}text{{/section}}") 32 | XCTAssertEqual(template.tokens, [.text("test "), .section(name: "section", template: .init([.text("text")], text: "text"))]) 33 | } 34 | 35 | func testInvertedSection() throws { 36 | let template = try MustacheTemplate(string: "test {{^section}}text{{/section}}") 37 | XCTAssertEqual(template.tokens, [.text("test "), .invertedSection(name: "section", template: .init([.text("text")], text: "text"))]) 38 | } 39 | 40 | func testComment() throws { 41 | let template = try MustacheTemplate(string: "test {{!section}}") 42 | XCTAssertEqual(template.tokens, [.text("test ")]) 43 | } 44 | 45 | func testWhitespace() throws { 46 | let template = try MustacheTemplate(string: "{{ section }}") 47 | XCTAssertEqual(template.tokens, [.variable(name: "section")]) 48 | } 49 | 50 | func testContentType() throws { 51 | let template = try MustacheTemplate(string: "{{% CONTENT_TYPE:TEXT}}") 52 | let template1 = try MustacheTemplate(string: "{{% CONTENT_TYPE:TEXT }}") 53 | let template2 = try MustacheTemplate(string: "{{% CONTENT_TYPE: TEXT}}") 54 | let template3 = try MustacheTemplate(string: "{{%CONTENT_TYPE:TEXT}}") 55 | XCTAssertEqual(template.tokens, [.contentType(TextContentType())]) 56 | XCTAssertEqual(template1.tokens, [.contentType(TextContentType())]) 57 | XCTAssertEqual(template2.tokens, [.contentType(TextContentType())]) 58 | XCTAssertEqual(template3.tokens, [.contentType(TextContentType())]) 59 | } 60 | } 61 | 62 | extension MustacheTemplate { 63 | public static func == (lhs: MustacheTemplate, rhs: MustacheTemplate) -> Bool { 64 | lhs.tokens == rhs.tokens 65 | } 66 | } 67 | 68 | extension MustacheTemplate: Equatable {} 69 | 70 | extension MustacheTemplate.Token { 71 | public static func == (lhs: MustacheTemplate.Token, rhs: MustacheTemplate.Token) -> Bool { 72 | switch (lhs, rhs) { 73 | case (.text(let lhs), .text(let rhs)): 74 | return lhs == rhs 75 | case (.variable(let lhs, let lhs2), .variable(let rhs, let rhs2)): 76 | return lhs == rhs && lhs2 == rhs2 77 | case (.section(let lhs1, let lhs2, let lhs3), .section(let rhs1, let rhs2, let rhs3)): 78 | return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 79 | case (.invertedSection(let lhs1, let lhs2, let lhs3), .invertedSection(let rhs1, let rhs2, let rhs3)): 80 | return lhs1 == rhs1 && lhs2 == rhs2 && lhs3 == rhs3 81 | case (.partial(let name1, let indent1, _), .partial(let name2, let indent2, _)): 82 | return name1 == name2 && indent1 == indent2 83 | case (.contentType(let contentType), .contentType(let contentType2)): 84 | return type(of: contentType) == type(of: contentType2) 85 | default: 86 | return false 87 | } 88 | } 89 | } 90 | 91 | extension MustacheTemplate.Token: Equatable {} 92 | -------------------------------------------------------------------------------- /Sources/Mustache/Library.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | /// Class holding a collection of mustache templates. 16 | /// 17 | /// Each template can reference the others via a partial using the name the template is registered under 18 | /// ``` 19 | /// {{#sequence}}{{>entry}}{{/sequence}} 20 | /// ``` 21 | public struct MustacheLibrary: Sendable { 22 | /// Initialize empty library 23 | public init() { 24 | self.templates = [:] 25 | } 26 | 27 | /// Initialize library with collection of templates. 28 | /// 29 | /// Each template is mapped to a name which can then be used as the partial name. 30 | /// - Parameter templates: Dictionary of mustache templates with a String key 31 | public init(templates: [String: MustacheTemplate]) { 32 | self.templates = templates 33 | } 34 | 35 | /// Initialize library with contents of folder. 36 | /// 37 | /// Each template is registered with the name of the file minus its extension. The search through 38 | /// the folder is recursive and templates in subfolders will be registered with the name `subfolder/template`. 39 | /// - Parameter directory: Directory to look for mustache templates 40 | /// - Parameter extension: Extension of files to look for 41 | public init(directory: String, withExtension extension: String = "mustache") async throws { 42 | self.templates = try Self.loadTemplates(from: directory, withExtension: `extension`) 43 | } 44 | 45 | /// Register template under name 46 | /// - Parameters: 47 | /// - template: Template 48 | /// - name: Name of template 49 | public mutating func register(_ template: MustacheTemplate, named name: String) { 50 | self.templates[name] = template 51 | } 52 | 53 | /// Register template under name 54 | /// - Parameters: 55 | /// - mustache: Mustache text 56 | /// - name: Name of template 57 | public mutating func register(_ mustache: String, named name: String) throws { 58 | let template = try MustacheTemplate(string: mustache) 59 | self.templates[name] = template 60 | } 61 | 62 | /// Return template registed with name 63 | /// - Parameter name: name to search for 64 | /// - Returns: Template 65 | public func getTemplate(named name: String) -> MustacheTemplate? { 66 | self.templates[name] 67 | } 68 | 69 | /// Render object using templated with name 70 | /// - Parameters: 71 | /// - object: Object to render 72 | /// - name: Name of template 73 | /// - Returns: Rendered text 74 | public func render(_ object: Any, withTemplate name: String) -> String? { 75 | guard let template = templates[name] else { return nil } 76 | return template.render(object, library: self) 77 | } 78 | 79 | /// Render object using templated with name 80 | /// - Parameters: 81 | /// - object: Object to render 82 | /// - name: Name of template 83 | /// - reload: Reload templates when rendering. This is only available in debug builds 84 | /// - Returns: Rendered text 85 | public func render(_ object: Any, withTemplate name: String, reload: Bool) -> String? { 86 | guard let template = templates[name] else { return nil } 87 | #if DEBUG 88 | return template.render(object, library: self, reload: reload) 89 | #else 90 | return template.render(object, library: self) 91 | #endif 92 | } 93 | 94 | /// Error returned by init() when parser fails 95 | public struct ParserError: Swift.Error { 96 | /// File error occurred in 97 | public let filename: String 98 | /// Context (line, linenumber and column number) 99 | public let context: MustacheParserContext 100 | /// Actual error that occurred 101 | public let error: Error 102 | } 103 | 104 | private var templates: [String: MustacheTemplate] 105 | } 106 | -------------------------------------------------------------------------------- /scripts/validate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ##===----------------------------------------------------------------------===## 3 | ## 4 | ## This source file is part of the Hummingbird server framework project 5 | ## 6 | ## Copyright (c) 2021-2024 the Hummingbird authors 7 | ## Licensed under Apache License v2.0 8 | ## 9 | ## See LICENSE.txt for license information 10 | ## See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 11 | ## 12 | ## SPDX-License-Identifier: Apache-2.0 13 | ## 14 | ##===----------------------------------------------------------------------===## 15 | ##===----------------------------------------------------------------------===## 16 | ## 17 | ## This source file is part of the SwiftNIO open source project 18 | ## 19 | ## Copyright (c) 2017-2019 Apple Inc. and the SwiftNIO project authors 20 | ## Licensed under Apache License v2.0 21 | ## 22 | ## See LICENSE.txt for license information 23 | ## See CONTRIBUTORS.txt for the list of SwiftNIO project authors 24 | ## 25 | ## SPDX-License-Identifier: Apache-2.0 26 | ## 27 | ##===----------------------------------------------------------------------===## 28 | 29 | SWIFT_FORMAT_VERSION=0.53.10 30 | 31 | set -eu 32 | here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 33 | 34 | function replace_acceptable_years() { 35 | # this needs to replace all acceptable forms with 'YEARS' 36 | sed -e 's/20[12][0-9]-20[12][0-9]/YEARS/' -e 's/20[12][0-9]/YEARS/' -e '/^#!/ d' 37 | } 38 | 39 | printf "=> Checking format... " 40 | FIRST_OUT="$(git status --porcelain)" 41 | git ls-files -z '*.swift' | xargs -0 swift format format --parallel --in-place 42 | git diff --exit-code '*.swift' 43 | 44 | SECOND_OUT="$(git status --porcelain)" 45 | if [[ "$FIRST_OUT" != "$SECOND_OUT" ]]; then 46 | printf "\033[0;31mformatting issues!\033[0m\n" 47 | git --no-pager diff 48 | exit 1 49 | else 50 | printf "\033[0;32mokay.\033[0m\n" 51 | fi 52 | printf "=> Checking license headers... " 53 | tmp=$(mktemp /tmp/.soto-core-sanity_XXXXXX) 54 | 55 | exit 0 56 | 57 | for language in swift-or-c; do 58 | declare -a matching_files 59 | declare -a exceptions 60 | expections=( ) 61 | matching_files=( -name '*' ) 62 | case "$language" in 63 | swift-or-c) 64 | exceptions=( -path '*/Benchmarks/.build/*' -o -name Package.swift) 65 | matching_files=( -name '*.swift' -o -name '*.c' -o -name '*.h' ) 66 | cat > "$tmp" <<"EOF" 67 | //===----------------------------------------------------------------------===// 68 | // 69 | // This source file is part of the Hummingbird server framework project 70 | // 71 | // Copyright (c) YEARS the Hummingbird authors 72 | // Licensed under Apache License v2.0 73 | // 74 | // See LICENSE.txt for license information 75 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 76 | // 77 | // SPDX-License-Identifier: Apache-2.0 78 | // 79 | //===----------------------------------------------------------------------===// 80 | EOF 81 | ;; 82 | bash) 83 | matching_files=( -name '*.sh' ) 84 | cat > "$tmp" <<"EOF" 85 | ##===----------------------------------------------------------------------===## 86 | ## 87 | ## This source file is part of the Hummingbird server framework project 88 | ## 89 | ## Copyright (c) YEARS the Hummingbird authors 90 | ## Licensed under Apache License v2.0 91 | ## 92 | ## See LICENSE.txt for license information 93 | ## See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 94 | ## 95 | ## SPDX-License-Identifier: Apache-2.0 96 | ## 97 | ##===----------------------------------------------------------------------===## 98 | EOF 99 | ;; 100 | *) 101 | echo >&2 "ERROR: unknown language '$language'" 102 | ;; 103 | esac 104 | 105 | lines_to_compare=$(cat "$tmp" | wc -l | tr -d " ") 106 | # need to read one more line as we remove the '#!' line 107 | lines_to_read=$(expr "$lines_to_compare" + 1) 108 | expected_sha=$(cat "$tmp" | shasum) 109 | 110 | ( 111 | cd "$here/.." 112 | find . \ 113 | \( \! -path './.build/*' -a \ 114 | \( "${matching_files[@]}" \) -a \ 115 | \( \! \( "${exceptions[@]}" \) \) \) | while read line; do 116 | if [[ "$(cat "$line" | head -n $lines_to_read | replace_acceptable_years | head -n $lines_to_compare | shasum)" != "$expected_sha" ]]; then 117 | printf "\033[0;31mmissing headers in file '$line'!\033[0m\n" 118 | diff -u <(cat "$line" | head -n $lines_to_read | replace_acceptable_years | head -n $lines_to_compare) "$tmp" 119 | exit 1 120 | fi 121 | done 122 | printf "\033[0;32mokay.\033[0m\n" 123 | ) 124 | done 125 | 126 | rm "$tmp" 127 | -------------------------------------------------------------------------------- /Sources/Mustache/Context.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | /// Context while rendering mustache tokens 16 | struct MustacheContext { 17 | let stack: [Any] 18 | let sequenceContext: MustacheSequenceContext? 19 | let indentation: String? 20 | let inherited: [String: MustacheTemplate]? 21 | let contentType: MustacheContentType 22 | let library: MustacheLibrary? 23 | let reloadPartials: Bool 24 | 25 | /// initialize context with a single objectt 26 | init(_ object: Any, library: MustacheLibrary? = nil, reloadPartials: Bool = false) { 27 | self.stack = [object] 28 | self.sequenceContext = nil 29 | self.indentation = nil 30 | self.inherited = nil 31 | self.contentType = HTMLContentType() 32 | self.library = library 33 | self.reloadPartials = reloadPartials 34 | } 35 | 36 | private init( 37 | stack: [Any], 38 | sequenceContext: MustacheSequenceContext?, 39 | indentation: String?, 40 | inherited: [String: MustacheTemplate]?, 41 | contentType: MustacheContentType, 42 | library: MustacheLibrary? = nil, 43 | reloadPartials: Bool 44 | ) { 45 | self.stack = stack 46 | self.sequenceContext = sequenceContext 47 | self.indentation = indentation 48 | self.inherited = inherited 49 | self.contentType = contentType 50 | self.library = library 51 | self.reloadPartials = reloadPartials 52 | } 53 | 54 | /// return context with object add to stack 55 | func withObject(_ object: Any) -> MustacheContext { 56 | var stack = self.stack 57 | stack.append(object) 58 | return .init( 59 | stack: stack, 60 | sequenceContext: nil, 61 | indentation: self.indentation, 62 | inherited: self.inherited, 63 | contentType: self.contentType, 64 | library: self.library, 65 | reloadPartials: self.reloadPartials 66 | ) 67 | } 68 | 69 | /// return context with indent and parameter information for invoking a partial 70 | func withPartial(indented: String?, inheriting: [String: MustacheTemplate]?) -> MustacheContext { 71 | let indentation: String? 72 | if let indented { 73 | indentation = (self.indentation ?? "") + indented 74 | } else { 75 | indentation = self.indentation 76 | } 77 | let inherits: [String: MustacheTemplate]? 78 | if let inheriting { 79 | if let originalInherits = self.inherited { 80 | inherits = originalInherits.merging(inheriting) { value, _ in value } 81 | } else { 82 | inherits = inheriting 83 | } 84 | } else { 85 | inherits = self.inherited 86 | } 87 | return .init( 88 | stack: self.stack, 89 | sequenceContext: nil, 90 | indentation: indentation, 91 | inherited: inherits, 92 | contentType: HTMLContentType(), 93 | library: self.library, 94 | reloadPartials: self.reloadPartials 95 | ) 96 | } 97 | 98 | /// return context with indent information for invoking an inheritance block 99 | func withBlockExpansion(indented: String?) -> MustacheContext { 100 | let indentation: String? 101 | if let indented { 102 | indentation = (self.indentation ?? "") + indented 103 | } else { 104 | indentation = self.indentation 105 | } 106 | return .init( 107 | stack: self.stack, 108 | sequenceContext: nil, 109 | indentation: indentation, 110 | inherited: self.inherited, 111 | contentType: self.contentType, 112 | library: self.library, 113 | reloadPartials: self.reloadPartials 114 | ) 115 | } 116 | 117 | /// return context with sequence info and sequence element added to stack 118 | func withSequence(_ object: Any, sequenceContext: MustacheSequenceContext) -> MustacheContext { 119 | var stack = self.stack 120 | stack.append(object) 121 | return .init( 122 | stack: stack, 123 | sequenceContext: sequenceContext, 124 | indentation: self.indentation, 125 | inherited: self.inherited, 126 | contentType: self.contentType, 127 | library: self.library, 128 | reloadPartials: self.reloadPartials 129 | ) 130 | } 131 | 132 | /// return context with sequence info and sequence element added to stack 133 | func withContentType(_ contentType: MustacheContentType) -> MustacheContext { 134 | .init( 135 | stack: self.stack, 136 | sequenceContext: self.sequenceContext, 137 | indentation: self.indentation, 138 | inherited: self.inherited, 139 | contentType: contentType, 140 | library: self.library, 141 | reloadPartials: self.reloadPartials 142 | ) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Tests/MustacheTests/LibraryTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import XCTest 16 | 17 | @testable import Mustache 18 | 19 | final class LibraryTests: XCTestCase { 20 | func testDirectoryLoad() async throws { 21 | let fs = FileManager() 22 | try? fs.createDirectory(atPath: "templates", withIntermediateDirectories: false) 23 | defer { XCTAssertNoThrow(try fs.removeItem(atPath: "templates")) } 24 | let mustache = Data("{{#value}}{{.}}{{/value}}".utf8) 25 | try mustache.write(to: URL(fileURLWithPath: "templates/test.mustache")) 26 | defer { XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test.mustache")) } 27 | 28 | let library = try await MustacheLibrary(directory: "./templates") 29 | let object = ["value": ["value1", "value2"]] 30 | XCTAssertEqual(library.render(object, withTemplate: "test"), "value1value2") 31 | } 32 | 33 | func testPartial() async throws { 34 | let fs = FileManager() 35 | try? fs.createDirectory(atPath: "templates", withIntermediateDirectories: false) 36 | let mustache = Data("{{#value}}{{.}}{{/value}}".utf8) 37 | try mustache.write(to: URL(fileURLWithPath: "templates/test-partial.mustache")) 38 | let mustache2 = Data("{{>test-partial}}".utf8) 39 | try mustache2.write(to: URL(fileURLWithPath: "templates/test.mustache")) 40 | defer { 41 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test-partial.mustache")) 42 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test.mustache")) 43 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates")) 44 | } 45 | 46 | let library = try await MustacheLibrary(directory: "./templates") 47 | let object = ["value": ["value1", "value2"]] 48 | XCTAssertEqual(library.render(object, withTemplate: "test"), "value1value2") 49 | } 50 | 51 | func testPartialInSubfolder() async throws { 52 | let fs = FileManager() 53 | try? fs.createDirectory(atPath: "templates/subfolder", withIntermediateDirectories: true) 54 | let mustache = Data("{{#value}}{{.}}{{/value}}".utf8) 55 | try mustache.write(to: URL(fileURLWithPath: "templates/subfolder/test-partial.mustache")) 56 | let mustache2 = Data("{{>subfolder/test-partial}}".utf8) 57 | try mustache2.write(to: URL(fileURLWithPath: "templates/test.mustache")) 58 | defer { 59 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates/subfolder/test-partial.mustache")) 60 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test.mustache")) 61 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates")) 62 | } 63 | 64 | let library = try await MustacheLibrary(directory: "./templates") 65 | let object = ["value": ["value1", "value2"]] 66 | XCTAssertEqual(library.render(object, withTemplate: "test"), "value1value2") 67 | } 68 | 69 | func testLibraryParserError() async throws { 70 | let fs = FileManager() 71 | try? fs.createDirectory(atPath: "templates", withIntermediateDirectories: false) 72 | defer { XCTAssertNoThrow(try fs.removeItem(atPath: "templates")) } 73 | let mustache = Data("{{#value}}{{.}}{{/value}}".utf8) 74 | try mustache.write(to: URL(fileURLWithPath: "templates/test.mustache")) 75 | defer { XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test.mustache")) } 76 | let mustache2 = Data( 77 | """ 78 | {{#test}} 79 | {{{name}} 80 | {{/test2}} 81 | """.utf8 82 | ) 83 | try mustache2.write(to: URL(fileURLWithPath: "templates/error.mustache")) 84 | defer { XCTAssertNoThrow(try fs.removeItem(atPath: "templates/error.mustache")) } 85 | 86 | do { 87 | _ = try await MustacheLibrary(directory: "./templates") 88 | } catch let parserError as MustacheLibrary.ParserError { 89 | XCTAssertEqual(parserError.filename, "error.mustache") 90 | XCTAssertEqual(parserError.context.line, "{{{name}}") 91 | XCTAssertEqual(parserError.context.lineNumber, 2) 92 | XCTAssertEqual(parserError.context.columnNumber, 10) 93 | } 94 | } 95 | 96 | #if DEBUG 97 | func testReload() async throws { 98 | let fs = FileManager() 99 | try? fs.createDirectory(atPath: "templates", withIntermediateDirectories: false) 100 | defer { XCTAssertNoThrow(try fs.removeItem(atPath: "templates")) } 101 | let mustache = Data("{{#value}}{{.}}{{/value}}".utf8) 102 | try mustache.write(to: URL(fileURLWithPath: "templates/test.mustache")) 103 | defer { XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test.mustache")) } 104 | 105 | let library = try await MustacheLibrary(directory: "./templates") 106 | let object = ["value": ["value1", "value2"]] 107 | XCTAssertEqual(library.render(object, withTemplate: "test"), "value1value2") 108 | let mustache2 = Data("{{#value}}{{.}}{{/value}}".utf8) 109 | try mustache2.write(to: URL(fileURLWithPath: "templates/test.mustache")) 110 | XCTAssertEqual(library.render(object, withTemplate: "test", reload: true), "value1value2") 111 | } 112 | 113 | func testReloadPartial() async throws { 114 | let fs = FileManager() 115 | try? fs.createDirectory(atPath: "templates", withIntermediateDirectories: false) 116 | let mustache = Data("{{#value}}{{.}}{{/value}}".utf8) 117 | try mustache.write(to: URL(fileURLWithPath: "templates/test-partial.mustache")) 118 | let mustache2 = Data("{{>test-partial}}".utf8) 119 | try mustache2.write(to: URL(fileURLWithPath: "templates/test.mustache")) 120 | defer { 121 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test-partial.mustache")) 122 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates/test.mustache")) 123 | XCTAssertNoThrow(try fs.removeItem(atPath: "templates")) 124 | } 125 | 126 | let library = try await MustacheLibrary(directory: "./templates") 127 | let object = ["value": ["value1", "value2"]] 128 | XCTAssertEqual(library.render(object, withTemplate: "test"), "value1value2") 129 | let mustache3 = Data("{{#value}}{{.}}{{/value}}".utf8) 130 | try mustache3.write(to: URL(fileURLWithPath: "templates/test-partial.mustache")) 131 | XCTAssertEqual(library.render(object, withTemplate: "test", reload: true), "value1value2") 132 | } 133 | #endif 134 | } 135 | -------------------------------------------------------------------------------- /Tests/MustacheTests/PartialTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import XCTest 16 | 17 | @testable import Mustache 18 | 19 | final class PartialTests: XCTestCase { 20 | /// Testing partials 21 | func testMustacheManualExample9() throws { 22 | let template = try MustacheTemplate( 23 | string: """ 24 |

Names

25 | {{#names}} 26 | {{> us/er}} 27 | {{/names}} 28 | """ 29 | ) 30 | let template2 = try MustacheTemplate( 31 | string: """ 32 | {{.}} 33 | 34 | """ 35 | ) 36 | let library = MustacheLibrary(templates: ["base": template, "us/er": template2]) 37 | 38 | let object: [String: Any] = ["names": ["john", "adam", "claire"]] 39 | XCTAssertEqual( 40 | library.render(object, withTemplate: "base"), 41 | """ 42 |

Names

43 | john 44 | adam 45 | claire 46 | 47 | """ 48 | ) 49 | } 50 | 51 | /// Test where last line of partial generates no content. It should not add a 52 | /// tab either 53 | func testPartialEmptyLineTabbing() throws { 54 | let template = try MustacheTemplate( 55 | string: """ 56 |

Names

57 | {{#names}} 58 | {{> user}} 59 | {{/names}} 60 | Text after 61 | 62 | """ 63 | ) 64 | let template2 = try MustacheTemplate( 65 | string: """ 66 | {{^empty(.)}} 67 | {{.}} 68 | {{/empty(.)}} 69 | {{#empty(.)}} 70 | empty 71 | {{/empty(.)}} 72 | 73 | """ 74 | ) 75 | var library = MustacheLibrary() 76 | library.register(template, named: "base") 77 | library.register(template2, named: "user") 78 | let object: [String: Any] = ["names": ["john", "adam", "claire"]] 79 | XCTAssertEqual( 80 | library.render(object, withTemplate: "base"), 81 | """ 82 |

Names

83 | john 84 | adam 85 | claire 86 | Text after 87 | 88 | """ 89 | ) 90 | } 91 | 92 | func testTrailingNewLines() throws { 93 | let template1 = try MustacheTemplate( 94 | string: """ 95 | {{> withNewLine }} 96 | >> {{> withNewLine }} 97 | [ {{> withNewLine }} ] 98 | """ 99 | ) 100 | let template2 = try MustacheTemplate( 101 | string: """ 102 | {{> withoutNewLine }} 103 | >> {{> withoutNewLine }} 104 | [ {{> withoutNewLine }} ] 105 | """ 106 | ) 107 | let withNewLine = try MustacheTemplate( 108 | string: """ 109 | {{#things}}{{.}}, {{/things}} 110 | 111 | """ 112 | ) 113 | let withoutNewLine = try MustacheTemplate(string: "{{#things}}{{.}}, {{/things}}") 114 | let library = MustacheLibrary(templates: [ 115 | "base1": template1, "base2": template2, "withNewLine": withNewLine, "withoutNewLine": withoutNewLine, 116 | ]) 117 | let object = ["things": [1, 2, 3, 4, 5]] 118 | XCTAssertEqual( 119 | library.render(object, withTemplate: "base1"), 120 | """ 121 | 1, 2, 3, 4, 5, 122 | >> 1, 2, 3, 4, 5, 123 | 124 | [ 1, 2, 3, 4, 5, 125 | ] 126 | """ 127 | ) 128 | XCTAssertEqual( 129 | library.render(object, withTemplate: "base2"), 130 | """ 131 | 1, 2, 3, 4, 5, >> 1, 2, 3, 4, 5, 132 | [ 1, 2, 3, 4, 5, ] 133 | """ 134 | ) 135 | } 136 | 137 | /// Testing dynamic partials 138 | func testDynamicPartials() throws { 139 | let template = try MustacheTemplate( 140 | string: """ 141 |

Names

142 | {{partial}} 143 | """ 144 | ) 145 | let template2 = try MustacheTemplate( 146 | string: """ 147 | {{#names}} 148 | {{.}} 149 | {{/names}} 150 | """ 151 | ) 152 | let library = MustacheLibrary(templates: ["base": template]) 153 | 154 | let object: [String: Any] = ["names": ["john", "adam", "claire"], "partial": template2] 155 | XCTAssertEqual( 156 | library.render(object, withTemplate: "base"), 157 | """ 158 |

Names

159 | john 160 | adam 161 | claire 162 | 163 | """ 164 | ) 165 | } 166 | 167 | /// test inheritance 168 | func testInheritance() throws { 169 | var library = MustacheLibrary() 170 | try library.register( 171 | """ 172 | 173 | {{$title}}Default title{{/title}} 174 | 175 | """, 176 | named: "header" 177 | ) 178 | try library.register( 179 | """ 180 | 181 | {{$header}}{{/header}} 182 | {{$content}}{{/content}} 183 | 184 | 185 | """, 186 | named: "base" 187 | ) 188 | try library.register( 189 | """ 190 | {{Hello world{{/content}} 197 | {{/base}} 198 | 199 | """, 200 | named: "mypage" 201 | ) 202 | XCTAssertEqual( 203 | library.render({}, withTemplate: "mypage")!, 204 | """ 205 | 206 | 207 | My page title 208 | 209 |

Hello world

210 | 211 | 212 | """ 213 | ) 214 | } 215 | 216 | func testInheritanceIndentation() throws { 217 | var library = MustacheLibrary() 218 | try library.register( 219 | """ 220 | Hi, 221 | {{$block}}{{/block}} 222 | """, 223 | named: "parent" 224 | ) 225 | try library.register( 226 | """ 227 | {{ Any? 32 | } 33 | 34 | extension StringProtocol { 35 | /// Transform String/Substring 36 | /// 37 | /// Transforms available are `capitalized`, `lowercased`, `uppercased` and `reversed` 38 | /// - Parameter name: transform name 39 | /// - Returns: Result 40 | public func transform(_ name: String) -> Any? { 41 | switch name { 42 | case "empty": 43 | return isEmpty 44 | case "capitalized": 45 | return capitalized 46 | case "lowercased": 47 | return lowercased() 48 | case "uppercased": 49 | return uppercased() 50 | case "reversed": 51 | return Substring(self.reversed()) 52 | default: 53 | return nil 54 | } 55 | } 56 | } 57 | 58 | extension String: MustacheTransformable {} 59 | extension Substring: MustacheTransformable {} 60 | 61 | /// Protocol for sequence that can be sorted 62 | private protocol ComparableSequence { 63 | func comparableTransform(_ name: String) -> Any? 64 | } 65 | 66 | extension Array: MustacheTransformable { 67 | /// Transform Array. 68 | /// 69 | /// Transforms available are `first`, `last`, `reversed`, `count`, `empty` and for arrays 70 | /// with comparable elements `sorted`. 71 | /// - Parameter name: transform name 72 | /// - Returns: Result 73 | public func transform(_ name: String) -> Any? { 74 | switch name { 75 | case "first": 76 | return first 77 | case "last": 78 | return last 79 | case "reversed": 80 | return reversed() 81 | case "count": 82 | return count 83 | case "empty": 84 | return isEmpty 85 | default: 86 | if let comparableSeq = self as? ComparableSequence { 87 | return comparableSeq.comparableTransform(name) 88 | } 89 | return nil 90 | } 91 | } 92 | } 93 | 94 | extension Array: ComparableSequence where Element: Comparable { 95 | func comparableTransform(_ name: String) -> Any? { 96 | switch name { 97 | case "sorted": 98 | return sorted() 99 | default: 100 | return nil 101 | } 102 | } 103 | } 104 | 105 | extension Set: MustacheTransformable { 106 | /// Transform Set. 107 | /// 108 | /// Transforms available are `count`, `empty` and for sets 109 | /// with comparable elements `sorted`. 110 | /// - Parameter name: transform name 111 | /// - Returns: Result 112 | public func transform(_ name: String) -> Any? { 113 | switch name { 114 | case "count": 115 | return count 116 | case "empty": 117 | return isEmpty 118 | default: 119 | if let comparableSeq = self as? ComparableSequence { 120 | return comparableSeq.comparableTransform(name) 121 | } 122 | return nil 123 | } 124 | } 125 | } 126 | 127 | extension Set: ComparableSequence where Element: Comparable { 128 | func comparableTransform(_ name: String) -> Any? { 129 | switch name { 130 | case "sorted": 131 | return sorted() 132 | default: 133 | return nil 134 | } 135 | } 136 | } 137 | 138 | extension ReversedCollection: MustacheTransformable { 139 | /// Transform ReversedCollection. 140 | /// 141 | /// Transforms available are `first`, `last`, `reversed`, `count`, `empty` and for collections 142 | /// with comparable elements `sorted`. 143 | /// - Parameter name: transform name 144 | /// - Returns: Result 145 | public func transform(_ name: String) -> Any? { 146 | switch name { 147 | case "first": 148 | return first 149 | case "last": 150 | return last 151 | case "reversed": 152 | return reversed() 153 | case "count": 154 | return count 155 | case "empty": 156 | return isEmpty 157 | default: 158 | if let comparableSeq = self as? ComparableSequence { 159 | return comparableSeq.comparableTransform(name) 160 | } 161 | return nil 162 | } 163 | } 164 | } 165 | 166 | extension ReversedCollection: ComparableSequence where Element: Comparable { 167 | func comparableTransform(_ name: String) -> Any? { 168 | switch name { 169 | case "sorted": 170 | return sorted() 171 | default: 172 | return nil 173 | } 174 | } 175 | } 176 | 177 | extension Dictionary: MustacheTransformable { 178 | /// Transform Dictionary 179 | /// 180 | /// Transforms available are `count`, `enumerated` and for dictionaries 181 | /// with comparable keys `sorted`. 182 | /// - Parameter name: transform name 183 | /// - Returns: Result 184 | public func transform(_ name: String) -> Any? { 185 | switch name { 186 | case "count": 187 | return count 188 | case "empty": 189 | return isEmpty 190 | case "enumerated": 191 | return map { (key: $0.key, value: $0.value) } 192 | default: 193 | if let comparableSeq = self as? ComparableSequence { 194 | return comparableSeq.comparableTransform(name) 195 | } 196 | return nil 197 | } 198 | } 199 | } 200 | 201 | extension Dictionary: ComparableSequence where Key: Comparable { 202 | func comparableTransform(_ name: String) -> Any? { 203 | switch name { 204 | case "sorted": 205 | return map { (key: $0.key, value: $0.value) }.sorted { $0.key < $1.key } 206 | default: 207 | return nil 208 | } 209 | } 210 | } 211 | 212 | extension FixedWidthInteger { 213 | /// Transform FixedWidthInteger 214 | /// 215 | /// Transforms available are `plusone`, `minusone`, `odd`, `even` 216 | /// - Parameter name: transform name 217 | /// - Returns: Result 218 | public func transform(_ name: String) -> Any? { 219 | switch name { 220 | case "equalzero": 221 | return self == 0 222 | case "plusone": 223 | return self + 1 224 | case "minusone": 225 | return self - 1 226 | case "even": 227 | return (self & 1) == 0 228 | case "odd": 229 | return (self & 1) == 1 230 | default: 231 | return nil 232 | } 233 | } 234 | } 235 | 236 | extension Int: MustacheTransformable {} 237 | extension Int8: MustacheTransformable {} 238 | extension Int16: MustacheTransformable {} 239 | extension Int32: MustacheTransformable {} 240 | extension Int64: MustacheTransformable {} 241 | extension UInt: MustacheTransformable {} 242 | extension UInt8: MustacheTransformable {} 243 | extension UInt16: MustacheTransformable {} 244 | extension UInt32: MustacheTransformable {} 245 | extension UInt64: MustacheTransformable {} 246 | -------------------------------------------------------------------------------- /Tests/MustacheTests/TransformTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Mustache 16 | import XCTest 17 | 18 | final class TransformTests: XCTestCase { 19 | func testLowercased() throws { 20 | let template = try MustacheTemplate( 21 | string: """ 22 | {{ lowercased(name) }} 23 | """ 24 | ) 25 | let object: [String: Any] = ["name": "Test"] 26 | XCTAssertEqual(template.render(object), "test") 27 | } 28 | 29 | func testUppercased() throws { 30 | let template = try MustacheTemplate( 31 | string: """ 32 | {{ uppercased(name) }} 33 | """ 34 | ) 35 | let object: [String: Any] = ["name": "Test"] 36 | XCTAssertEqual(template.render(object), "TEST") 37 | } 38 | 39 | func testNewline() throws { 40 | let template = try MustacheTemplate( 41 | string: """ 42 | {{#repo}} 43 | {{name}} 44 | {{/repo}} 45 | 46 | """ 47 | ) 48 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 49 | XCTAssertEqual( 50 | template.render(object), 51 | """ 52 | resque 53 | hub 54 | rip 55 | 56 | """ 57 | ) 58 | } 59 | 60 | func testFirstLast() throws { 61 | let template = try MustacheTemplate( 62 | string: """ 63 | {{#repo}} 64 | {{#first()}}first: {{/first()}}{{#last()}}last: {{/last()}}{{ name }} 65 | {{/repo}} 66 | 67 | """ 68 | ) 69 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 70 | XCTAssertEqual( 71 | template.render(object), 72 | """ 73 | first: resque 74 | hub 75 | last: rip 76 | 77 | """ 78 | ) 79 | } 80 | 81 | func testIndex() throws { 82 | let template = try MustacheTemplate( 83 | string: """ 84 | {{#repo}} 85 | {{#index()}}{{plusone(.)}}{{/index()}}) {{ name }} 86 | {{/repo}} 87 | 88 | """ 89 | ) 90 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 91 | XCTAssertEqual( 92 | template.render(object), 93 | """ 94 | 1) resque 95 | 2) hub 96 | 3) rip 97 | 98 | """ 99 | ) 100 | } 101 | 102 | func testDoubleSequenceTransformWorks() throws { 103 | let template = try MustacheTemplate( 104 | string: """ 105 | {{#repo}} 106 | {{count(reversed(numbers))}} 107 | {{/repo}} 108 | 109 | """ 110 | ) 111 | let object: [String: Any] = ["repo": ["numbers": [1, 2, 3]]] 112 | XCTAssertEqual( 113 | template.render(object), 114 | """ 115 | 3 116 | 117 | """ 118 | ) 119 | } 120 | 121 | func testMultipleTransformWorks() throws { 122 | let template = try MustacheTemplate( 123 | string: """ 124 | {{#repo}} 125 | {{minusone(plusone(last(reversed(numbers))))}} 126 | {{/repo}} 127 | 128 | """ 129 | ) 130 | let object: [String: Any] = ["repo": ["numbers": [5, 4, 3]]] 131 | XCTAssertEqual( 132 | template.render(object), 133 | """ 134 | 5 135 | 136 | """ 137 | ) 138 | } 139 | 140 | func testNestedTransformWorks() throws { 141 | let template = try MustacheTemplate( 142 | string: """ 143 | {{#repo}} 144 | {{#uppercased(string)}}{{reversed(.)}}{{/uppercased(string)}} 145 | {{/repo}} 146 | 147 | """ 148 | ) 149 | let object: [String: Any] = ["repo": ["string": "a123a"]] 150 | XCTAssertEqual( 151 | template.render(object), 152 | """ 153 | A321A 154 | 155 | """ 156 | ) 157 | } 158 | 159 | func testEvenOdd() throws { 160 | let template = try MustacheTemplate( 161 | string: """ 162 | {{#repo}} 163 | {{index()}}) {{#even()}}even {{/even()}}{{#odd()}}odd {{/odd()}}{{ name }} 164 | {{/repo}} 165 | 166 | """ 167 | ) 168 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 169 | XCTAssertEqual( 170 | template.render(object), 171 | """ 172 | 0) even resque 173 | 1) odd hub 174 | 2) even rip 175 | 176 | """ 177 | ) 178 | } 179 | 180 | func testReversed() throws { 181 | let template = try MustacheTemplate( 182 | string: """ 183 | {{#reversed(repo)}} 184 | {{ name }} 185 | {{/reversed(repo)}} 186 | 187 | """ 188 | ) 189 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 190 | XCTAssertEqual( 191 | template.render(object), 192 | """ 193 | rip 194 | hub 195 | resque 196 | 197 | """ 198 | ) 199 | } 200 | 201 | func testArrayIndex() throws { 202 | let template = try MustacheTemplate( 203 | string: """ 204 | {{#repo}} 205 | {{ index() }}) {{ name }} 206 | {{/repo}} 207 | """ 208 | ) 209 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 210 | XCTAssertEqual( 211 | template.render(object), 212 | """ 213 | 0) resque 214 | 1) hub 215 | 2) rip 216 | 217 | """ 218 | ) 219 | } 220 | 221 | func testArraySorted() throws { 222 | let template = try MustacheTemplate( 223 | string: """ 224 | {{#sorted(repo)}} 225 | {{ index() }}) {{ . }} 226 | {{/sorted(repo)}} 227 | """ 228 | ) 229 | let object: [String: Any] = ["repo": ["resque", "hub", "rip"]] 230 | XCTAssertEqual( 231 | template.render(object), 232 | """ 233 | 0) hub 234 | 1) resque 235 | 2) rip 236 | 237 | """ 238 | ) 239 | } 240 | 241 | func testDictionaryEmpty() throws { 242 | let template = try MustacheTemplate( 243 | string: """ 244 | {{#empty(array)}}Array{{/empty(array)}}{{#empty(dictionary)}}Dictionary{{/empty(dictionary)}} 245 | """ 246 | ) 247 | let object: [String: Any] = ["array": [], "dictionary": [:]] 248 | XCTAssertEqual(template.render(object), "ArrayDictionary") 249 | } 250 | 251 | func testListOutput() throws { 252 | let object = [1, 2, 3, 4] 253 | let template = try MustacheTemplate(string: "{{#.}}{{.}}{{^last()}}, {{/last()}}{{/.}}") 254 | XCTAssertEqual(template.render(object), "1, 2, 3, 4") 255 | } 256 | 257 | func testDictionaryEnumerated() throws { 258 | let template = try MustacheTemplate( 259 | string: """ 260 | {{#enumerated(.)}}{{ key }} = {{ value }}{{/enumerated(.)}} 261 | """ 262 | ) 263 | let object: [String: Any] = ["one": 1, "two": 2] 264 | let result = template.render(object) 265 | XCTAssertTrue(result == "one = 1two = 2" || result == "two = 2one = 1") 266 | } 267 | 268 | func testDictionarySortedByKey() throws { 269 | let template = try MustacheTemplate( 270 | string: """ 271 | {{#sorted(.)}}{{ key }} = {{ value }}{{/sorted(.)}} 272 | """ 273 | ) 274 | let object: [String: Any] = ["one": 1, "two": 2, "three": 3] 275 | let result = template.render(object) 276 | XCTAssertEqual(result, "one = 1three = 3two = 2") 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /Tests/MustacheTests/SpecTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | import Mustache 17 | import XCTest 18 | 19 | #if os(Linux) || os(Windows) 20 | import FoundationNetworking 21 | #endif 22 | 23 | public struct AnyDecodable: Decodable { 24 | public let value: Any 25 | 26 | public init(_ value: some Any) { 27 | self.value = value 28 | } 29 | } 30 | 31 | extension AnyDecodable { 32 | public init(from decoder: Decoder) throws { 33 | let container = try decoder.singleValueContainer() 34 | 35 | if container.decodeNil() { 36 | self.init(NSNull()) 37 | } else if let bool = try? container.decode(Bool.self) { 38 | self.init(bool) 39 | } else if let int = try? container.decode(Int.self) { 40 | self.init(int) 41 | } else if let uint = try? container.decode(UInt.self) { 42 | self.init(uint) 43 | } else if let double = try? container.decode(Double.self) { 44 | self.init(double) 45 | } else if let string = try? container.decode(String.self) { 46 | self.init(string) 47 | } else if let array = try? container.decode([AnyDecodable].self) { 48 | self.init(array.map(\.value)) 49 | } else if let dictionary = try? container.decode([String: AnyDecodable].self) { 50 | self.init(dictionary.mapValues { $0.value }) 51 | } else { 52 | throw DecodingError.dataCorruptedError( 53 | in: container, 54 | debugDescription: "AnyDecodable value cannot be decoded" 55 | ) 56 | } 57 | } 58 | } 59 | 60 | /// Verify implementation against formal standard for Mustache. 61 | /// https://github.com/mustache/spec 62 | final class MustacheSpecTests: XCTestCase { 63 | struct Spec: Decodable { 64 | struct Test: Decodable { 65 | let name: String 66 | let desc: String 67 | var data: AnyDecodable 68 | let partials: [String: String]? 69 | let template: String 70 | let expected: String 71 | 72 | func run() throws { 73 | print("Test: \(self.name)") 74 | if let partials = self.partials { 75 | let template = try MustacheTemplate(string: self.template) 76 | var templates: [String: MustacheTemplate] = ["__test__": template] 77 | for (key, value) in partials { 78 | let template = try MustacheTemplate(string: value) 79 | templates[key] = template 80 | } 81 | let library = MustacheLibrary(templates: templates) 82 | let result = library.render(self.data.value, withTemplate: "__test__") 83 | self.XCTAssertSpecEqual(result, self) 84 | } else { 85 | let template = try MustacheTemplate(string: self.template) 86 | let result = template.render(self.data.value) 87 | self.XCTAssertSpecEqual(result, self) 88 | } 89 | } 90 | 91 | func XCTAssertSpecEqual(_ result: String?, _ test: Spec.Test) { 92 | if result != test.expected { 93 | XCTFail( 94 | """ 95 | \(test.name) 96 | \(test.desc) 97 | template: 98 | \(test.template) 99 | data: 100 | \(test.data.value) 101 | \(test.partials.map { "partials:\n\($0)" } ?? "") 102 | result: 103 | \(result ?? "nil") 104 | expected: 105 | \(test.expected) 106 | """ 107 | ) 108 | } 109 | } 110 | } 111 | 112 | let overview: String 113 | let tests: [Test] 114 | } 115 | 116 | func testSpec(name: String, ignoring: [String] = []) async throws { 117 | let url = URL( 118 | string: "https://raw.githubusercontent.com/mustache/spec/master/specs/\(name).json" 119 | )! 120 | try await testSpec(url: url, ignoring: ignoring) 121 | } 122 | 123 | func testSpec(url: URL, ignoring: [String] = []) async throws { 124 | #if compiler(>=6.0) 125 | let (data, _) = try await URLSession.shared.data(from: url) 126 | #else 127 | let data = try Data(contentsOf: url) 128 | #endif 129 | let spec = try JSONDecoder().decode(Spec.self, from: data) 130 | 131 | let date = Date() 132 | for test in spec.tests { 133 | guard !ignoring.contains(test.name) else { continue } 134 | XCTAssertNoThrow(try test.run()) 135 | } 136 | print(-date.timeIntervalSinceNow) 137 | } 138 | 139 | func testSpec(name: String, only: [String]) async throws { 140 | let url = URL( 141 | string: "https://raw.githubusercontent.com/mustache/spec/master/specs/\(name).json" 142 | )! 143 | try await testSpec(url: url, only: only) 144 | } 145 | 146 | func testSpec(url: URL, only: [String]) async throws { 147 | #if compiler(>=6.0) 148 | let (data, _) = try await URLSession.shared.data(from: url) 149 | #else 150 | let data = try Data(contentsOf: url) 151 | #endif 152 | let spec = try JSONDecoder().decode(Spec.self, from: data) 153 | 154 | let date = Date() 155 | for test in spec.tests { 156 | guard only.contains(test.name) else { continue } 157 | XCTAssertNoThrow(try test.run()) 158 | } 159 | print(-date.timeIntervalSinceNow) 160 | } 161 | 162 | func testLambdaSpec() async throws { 163 | var g = 0 164 | let lambdaMap = [ 165 | "Interpolation": MustacheLambda { "world" }, 166 | "Interpolation - Expansion": MustacheLambda { "{{planet}}" }, 167 | "Interpolation - Alternate Delimiters": MustacheLambda { "|planet| => {{planet}}" }, 168 | "Interpolation - Multiple Calls": MustacheLambda { 169 | MustacheLambda { 170 | g += 1 171 | return g 172 | } 173 | }, 174 | "Escaping": MustacheLambda { ">" }, 175 | "Section": MustacheLambda { text in text == "{{x}}" ? "yes" : "no" }, 176 | "Section - Expansion": MustacheLambda { text in text + "{{planet}}" + text }, 177 | // Not going to bother implementing this requires pushing alternate delimiters through the context 178 | // "Section - Alternate Delimiters": MustacheLambda { text in return text + "{{planet}} => |planet|" + text }, 179 | "Section - Multiple Calls": MustacheLambda { text in "__" + text + "__" }, 180 | "Inverted Section": MustacheLambda { false }, 181 | ] 182 | let url = URL(string: "https://raw.githubusercontent.com/mustache/spec/master/specs/~lambdas.json")! 183 | #if compiler(>=6.0) 184 | let (data, _) = try await URLSession.shared.data(from: url) 185 | #else 186 | let data = try Data(contentsOf: url) 187 | #endif 188 | let spec = try JSONDecoder().decode(Spec.self, from: data) 189 | // edit spec and replace lambda with Swift lambda 190 | let editedSpecTests = spec.tests.compactMap { test -> Spec.Test? in 191 | var test = test 192 | var newTestData: [String: Any] = [:] 193 | guard let dictionary = test.data.value as? [String: Any] else { return nil } 194 | for values in dictionary { 195 | newTestData[values.key] = values.value 196 | } 197 | guard let lambda = lambdaMap[test.name] else { return nil } 198 | newTestData["lambda"] = lambda 199 | test.data = .init(newTestData) 200 | return test 201 | } 202 | 203 | let date = Date() 204 | for test in editedSpecTests { 205 | XCTAssertNoThrow(try test.run()) 206 | } 207 | print(-date.timeIntervalSinceNow) 208 | } 209 | 210 | func testCommentsSpec() async throws { 211 | try await self.testSpec(name: "comments") 212 | } 213 | 214 | func testDelimitersSpec() async throws { 215 | try await self.testSpec(name: "delimiters") 216 | } 217 | 218 | func testInterpolationSpec() async throws { 219 | try await self.testSpec(name: "interpolation") 220 | } 221 | 222 | func testInvertedSpec() async throws { 223 | try await self.testSpec(name: "inverted") 224 | } 225 | 226 | func testPartialsSpec() async throws { 227 | try await self.testSpec(name: "partials") 228 | } 229 | 230 | func testSectionsSpec() async throws { 231 | try await self.testSpec(name: "sections") 232 | } 233 | 234 | func testInheritanceSpec() async throws { 235 | try await self.testSpec( 236 | name: "~inheritance", 237 | ignoring: [ 238 | "Intrinsic indentation", 239 | "Nested block reindentation", 240 | ] 241 | ) 242 | } 243 | 244 | func testDynamicNamesSpec() async throws { 245 | try await self.testSpec(name: "~dynamic-names") 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Adam Fowler 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Sources/Mustache/Template+Render.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2024 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | extension MustacheTemplate { 18 | /// Render template using object 19 | /// - Parameters: 20 | /// - stack: Object 21 | /// - context: Context that render is occurring in. Contains information about position in sequence 22 | /// - indentation: indentation of partial 23 | /// - Returns: Rendered text 24 | func render(context: MustacheContext) -> String { 25 | var string = "" 26 | var context = context 27 | 28 | if let indentation = context.indentation, indentation != "" { 29 | for token in tokens { 30 | let renderedString = self.renderToken(token, context: &context) 31 | // if rendered string is not empty and we are on a new line 32 | if renderedString.count > 0, string.last == "\n" { 33 | string += indentation 34 | } 35 | string += renderedString 36 | } 37 | } else { 38 | for token in tokens { 39 | let result = self.renderToken(token, context: &context) 40 | string += result 41 | } 42 | } 43 | return string 44 | } 45 | 46 | func renderToken(_ token: Token, context: inout MustacheContext) -> String { 47 | switch token { 48 | case .text(let text): 49 | return text 50 | 51 | case .variable(let variable, let transforms): 52 | if let child = getChild(named: variable, transforms: transforms, context: context) { 53 | if let template = child as? MustacheTemplate { 54 | return template.render(context: context) 55 | } else if let renderable = child as? MustacheCustomRenderable { 56 | return context.contentType.escapeText(renderable.renderText) 57 | } else if let lambda = child as? MustacheLambda { 58 | return self.renderLambda(lambda, parameter: "", context: context) 59 | } else { 60 | return context.contentType.escapeText(String(describing: unwrapIfAnyContainsOptional(child))) 61 | } 62 | } 63 | 64 | case .unescapedVariable(let variable, let transforms): 65 | if let child = getChild(named: variable, transforms: transforms, context: context) { 66 | if let renderable = child as? MustacheCustomRenderable { 67 | return renderable.renderText 68 | } else if let lambda = child as? MustacheLambda { 69 | return self.renderUnescapedLambda(lambda, parameter: "", context: context) 70 | } else { 71 | return String(describing: unwrapIfAnyContainsOptional(child)) 72 | } 73 | } 74 | 75 | case .section(let variable, let transforms, let template): 76 | let child = self.getChild(named: variable, transforms: transforms, context: context) 77 | if let lambda = child as? MustacheLambda { 78 | return self.renderUnescapedLambda(lambda, parameter: template.text, context: context) 79 | } 80 | return self.renderSection(child, with: template, context: context) 81 | 82 | case .invertedSection(let variable, let transforms, let template): 83 | let child = self.getChild(named: variable, transforms: transforms, context: context) 84 | return self.renderInvertedSection(child, with: template, context: context) 85 | 86 | case .blockExpansion(let name, let defaultTemplate, let indented): 87 | if let override = context.inherited?[name] { 88 | return override.render(context: context.withBlockExpansion(indented: indented)) 89 | } else { 90 | return defaultTemplate.render(context: context.withBlockExpansion(indented: indented)) 91 | } 92 | 93 | case .partial(let name, let indentation, let overrides): 94 | if var template = context.library?.getTemplate(named: name) { 95 | #if DEBUG 96 | if context.reloadPartials { 97 | guard let filename = template.filename else { 98 | preconditionFailure("Can only use reload if template was generated from a file") 99 | } 100 | do { 101 | guard let partialTemplate = try MustacheTemplate(filename: filename) else { return "Cannot find template at \(filename)" } 102 | template = partialTemplate 103 | } catch { 104 | return "\(error)" 105 | } 106 | } 107 | #endif 108 | return template.render(context: context.withPartial(indented: indentation, inheriting: overrides)) 109 | } 110 | 111 | case .dynamicNamePartial(let name, let indentation, let overrides): 112 | let child = self.getChild(named: name, transforms: [], context: context) 113 | guard let childName = child as? String else { 114 | return "" 115 | } 116 | if var template = context.library?.getTemplate(named: childName) { 117 | #if DEBUG 118 | if context.reloadPartials { 119 | guard let filename = template.filename else { 120 | preconditionFailure("Can only use reload if template was generated from a file") 121 | } 122 | do { 123 | guard let partialTemplate = try MustacheTemplate(filename: filename) else { return "Cannot find template at \(filename)" } 124 | template = partialTemplate 125 | } catch { 126 | return "\(error)" 127 | } 128 | } 129 | #endif 130 | return template.render(context: context.withPartial(indented: indentation, inheriting: overrides)) 131 | } 132 | 133 | case .contentType(let contentType): 134 | context = context.withContentType(contentType) 135 | 136 | case .blockDefinition: 137 | fatalError("Should not be rendering block definitions") 138 | } 139 | return "" 140 | } 141 | 142 | /// Render a section 143 | /// - Parameters: 144 | /// - child: Object to render section for 145 | /// - parent: Current object being rendered 146 | /// - template: Template to render with 147 | /// - Returns: Rendered text 148 | func renderSection(_ child: Any?, with template: MustacheTemplate, context: MustacheContext) -> String { 149 | switch child { 150 | case let array as any MustacheSequence: 151 | return array.renderSection(with: template, context: context) 152 | case let bool as Bool: 153 | return bool ? template.render(context: context) : "" 154 | case let null as MustacheCustomRenderable where null.isNull == true: 155 | return "" 156 | case .some(let value): 157 | return template.render(context: context.withObject(value)) 158 | case .none: 159 | return "" 160 | } 161 | } 162 | 163 | /// Render an inverted section 164 | /// - Parameters: 165 | /// - child: Object to render section for 166 | /// - parent: Current object being rendered 167 | /// - template: Template to render with 168 | /// - Returns: Rendered text 169 | func renderInvertedSection(_ child: Any?, with template: MustacheTemplate, context: MustacheContext) -> String { 170 | switch child { 171 | case let array as any MustacheSequence: 172 | return array.renderInvertedSection(with: template, context: context) 173 | case let bool as Bool: 174 | return bool ? "" : template.render(context: context) 175 | case let null as MustacheCustomRenderable where null.isNull == true: 176 | return template.render(context: context) 177 | case .some: 178 | return "" 179 | case .none: 180 | return template.render(context: context) 181 | } 182 | } 183 | 184 | func renderLambda(_ lambda: MustacheLambda, parameter: String, context: MustacheContext) -> String { 185 | var lambda = lambda 186 | while true { 187 | guard let result = lambda(parameter) else { return "" } 188 | if let string = result as? String { 189 | do { 190 | let newTemplate = try MustacheTemplate(string: context.contentType.escapeText(string)) 191 | return self.renderSection(context.stack.last, with: newTemplate, context: context) 192 | } catch { 193 | return "" 194 | } 195 | } else if let lambda2 = result as? MustacheLambda { 196 | lambda = lambda2 197 | continue 198 | } else { 199 | return context.contentType.escapeText(String(describing: unwrapIfAnyContainsOptional(result))) 200 | } 201 | } 202 | } 203 | 204 | func renderUnescapedLambda(_ lambda: MustacheLambda, parameter: String, context: MustacheContext) -> String { 205 | var lambda = lambda 206 | while true { 207 | guard let result = lambda(parameter) else { return "" } 208 | if let string = result as? String { 209 | do { 210 | let newTemplate = try MustacheTemplate(string: string) 211 | return self.renderSection(context.stack.last, with: newTemplate, context: context) 212 | } catch { 213 | return "" 214 | } 215 | } else if let lambda2 = result as? MustacheLambda { 216 | lambda = lambda2 217 | continue 218 | } else { 219 | return String(describing: unwrapIfAnyContainsOptional(result)) 220 | } 221 | } 222 | } 223 | 224 | /// Get child object from variable name 225 | func getChild(named name: String, transforms: [String], context: MustacheContext) -> Any? { 226 | func _getImmediateChild(named name: String, from object: Any) -> Any? { 227 | let object = { 228 | if let customBox = object as? MustacheParent { 229 | return customBox.child(named: name) 230 | } else { 231 | let mirror = Mirror(reflecting: object) 232 | return mirror.getValue(forKey: name) 233 | } 234 | }() 235 | return object 236 | } 237 | 238 | func _getChild(named names: ArraySlice, from object: Any) -> Any? { 239 | guard let name = names.first else { return object } 240 | var object = object 241 | if let lambda = object as? MustacheLambda { 242 | guard let result = lambda("") else { return nil } 243 | object = result 244 | } 245 | guard let childObject = _getImmediateChild(named: name, from: object) else { return nil } 246 | let names2 = names.dropFirst() 247 | return _getChild(named: names2, from: childObject) 248 | } 249 | 250 | func _getChildInStack(named names: ArraySlice, from stack: [Any]) -> Any? { 251 | guard let name = names.first else { return stack.last } 252 | for object in stack.reversed() { 253 | if let childObject = _getImmediateChild(named: name, from: object) { 254 | let names2 = names.dropFirst() 255 | return _getChild(named: names2, from: childObject) 256 | } 257 | } 258 | return nil 259 | } 260 | 261 | // work out which object to access. "." means the current object, if the variable name is "" 262 | // and we have a transform to run on the variable then we need the context object, otherwise 263 | // the name is split by "." and we use mirror to get the correct child object. If we cannot find 264 | // the root object we look up the context stack until we can find one with a matching name. The 265 | // stack climbing can be disabled by prefixing the variable name with a "." 266 | let child: Any? 267 | if name == "." { 268 | child = context.stack.last! 269 | } else if name == "", !transforms.isEmpty { 270 | child = context.sequenceContext 271 | } else if name.first == "." { 272 | let nameSplit = name.split(separator: ".").map { String($0) } 273 | child = _getChild(named: nameSplit[...], from: context.stack.last!) 274 | } else { 275 | let nameSplit = name.split(separator: ".").map { String($0) } 276 | child = _getChildInStack(named: nameSplit[...], from: context.stack) 277 | } 278 | 279 | // skip transforms if child is already nil 280 | guard var child else { 281 | return nil 282 | } 283 | 284 | // if we want to run a transform and the current child can have transforms applied to it then 285 | // run transform on the current child 286 | for transform in transforms.reversed() { 287 | if let runnable = child as? MustacheTransformable, 288 | let transformed = runnable.transform(transform) 289 | { 290 | child = transformed 291 | continue 292 | } 293 | 294 | // return nil if transform is unsuccessful or has returned nil 295 | return nil 296 | } 297 | 298 | return child 299 | } 300 | 301 | private func unwrapIfAnyContainsOptional(_ value: Any) -> Any { 302 | guard let opt = value as? AnyOptional else { return value } 303 | return opt.anyWrapped ?? value // nil? keep the original Optional.none 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /Sources/Mustache/Parser.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | /// Reader object for parsing String buffers 18 | struct Parser { 19 | enum Error: Swift.Error { 20 | case overflow 21 | } 22 | 23 | /// internal storage used to store String 24 | private class Storage { 25 | init(_ buffer: String) { 26 | self.buffer = buffer 27 | } 28 | 29 | let buffer: String 30 | } 31 | 32 | private let _storage: Storage 33 | 34 | /// Create a Reader object 35 | /// - Parameter string: String to parse 36 | init(_ string: String) { 37 | self._storage = Storage(string) 38 | self.position = string.startIndex 39 | } 40 | 41 | var buffer: String { self._storage.buffer } 42 | private(set) var position: String.Index 43 | } 44 | 45 | extension Parser { 46 | /// Return current character 47 | /// - Throws: .overflow 48 | /// - Returns: Current character 49 | mutating func character() throws -> Character { 50 | guard !self.reachedEnd() else { throw Parser.Error.overflow } 51 | let c = unsafeCurrent() 52 | unsafeAdvance() 53 | return c 54 | } 55 | 56 | /// Read the current character and return if it is as intended. If character test returns true then move forward 1 57 | /// - Parameter char: character to compare against 58 | /// - Throws: .overflow 59 | /// - Returns: If current character was the one we expected 60 | mutating func read(_ char: Character) throws -> Bool { 61 | let c = try character() 62 | guard c == char else { 63 | unsafeRetreat() 64 | return false 65 | } 66 | return true 67 | } 68 | 69 | /// Read the current character and return if it is as intended. If character test returns true then move forward 1 70 | /// - Parameter char: character to compare against 71 | /// - Throws: .overflow 72 | /// - Returns: If current character was the one we expected 73 | mutating func read(string: String) throws -> Bool { 74 | let initialPosition = self.position 75 | guard string.count > 0 else { return true } 76 | let subString = try read(count: string.count) 77 | guard subString == string else { 78 | self.position = initialPosition 79 | return false 80 | } 81 | return true 82 | } 83 | 84 | /// Read the current character and check if it is in a set of characters If character test returns true then move forward 1 85 | /// - Parameter characterSet: Set of characters to compare against 86 | /// - Throws: .overflow 87 | /// - Returns: If current character is in character set 88 | mutating func read(_ characterSet: Set) throws -> Bool { 89 | let c = try character() 90 | guard characterSet.contains(c) else { 91 | unsafeRetreat() 92 | return false 93 | } 94 | return true 95 | } 96 | 97 | /// Read next so many characters from buffer 98 | /// - Parameter count: Number of characters to read 99 | /// - Throws: .overflow 100 | /// - Returns: The string read from the buffer 101 | mutating func read(count: Int) throws -> Substring { 102 | guard self.buffer.distance(from: self.position, to: self.buffer.endIndex) >= count else { throw Parser.Error.overflow } 103 | let end = self.buffer.index(self.position, offsetBy: count) 104 | let subString = self.buffer[self.position.. Substring { 113 | let string = self.buffer[self.position.. Substring { 123 | let startIndex = self.position 124 | while !self.reachedEnd() { 125 | if unsafeCurrent() == until { 126 | return self.buffer[startIndex.. Substring { 144 | guard untilString.count > 0 else { return "" } 145 | let startIndex = self.position 146 | var foundIndex = self.position 147 | var untilIndex = untilString.startIndex 148 | while !self.reachedEnd() { 149 | if unsafeCurrent() == untilString[untilIndex] { 150 | if untilIndex == untilString.startIndex { 151 | foundIndex = self.position 152 | } 153 | untilIndex = untilString.index(after: untilIndex) 154 | if untilIndex == untilString.endIndex { 155 | unsafeAdvance() 156 | if skipToEnd == false { 157 | self.position = foundIndex 158 | } 159 | let result = self.buffer[startIndex.., throwOnOverflow: Bool = true) throws -> Substring { 179 | let startIndex = self.position 180 | while !self.reachedEnd() { 181 | if characterSet.contains(unsafeCurrent()) { 182 | return self.buffer[startIndex.., throwOnOverflow: Bool = true) throws -> Substring { 198 | let startIndex = self.position 199 | while !self.reachedEnd() { 200 | if current()[keyPath: keyPath] { 201 | return self.buffer[startIndex.. Bool, throwOnOverflow: Bool = true) throws -> Substring { 217 | let startIndex = self.position 218 | while !self.reachedEnd() { 219 | if cb(current()) { 220 | return self.buffer[startIndex.. Substring { 234 | let startIndex = self.position 235 | self.position = self.buffer.endIndex 236 | return self.buffer[startIndex.. Int { 243 | var count = 0 244 | while !self.reachedEnd(), 245 | unsafeCurrent() == `while` 246 | { 247 | unsafeAdvance() 248 | count += 1 249 | } 250 | return count 251 | } 252 | 253 | /// Read while keyPath on character at current position returns true is the one supplied 254 | /// - Parameter while: keyPath to check 255 | /// - Returns: String read from buffer 256 | @discardableResult mutating func read(while keyPath: KeyPath) -> Substring { 257 | let startIndex = self.position 258 | while !self.reachedEnd(), 259 | unsafeCurrent()[keyPath: keyPath] 260 | { 261 | unsafeAdvance() 262 | } 263 | return self.buffer[startIndex.. Bool) -> Substring { 270 | let startIndex = self.position 271 | while !self.reachedEnd(), 272 | cb(unsafeCurrent()) 273 | { 274 | unsafeAdvance() 275 | } 276 | return self.buffer[startIndex..) -> Substring { 283 | let startIndex = self.position 284 | while !self.reachedEnd(), 285 | characterSet.contains(unsafeCurrent()) 286 | { 287 | unsafeAdvance() 288 | } 289 | return self.buffer[startIndex.. Bool { 295 | self.position == self.buffer.endIndex 296 | } 297 | 298 | /// Return whether we are at the start of the buffer 299 | /// - Returns: Are we are the start 300 | func atStart() -> Bool { 301 | self.position == self.buffer.startIndex 302 | } 303 | } 304 | 305 | /// context used in parser error 306 | public struct MustacheParserContext: Sendable { 307 | public let line: String 308 | public let lineNumber: Int 309 | public let columnNumber: Int 310 | } 311 | 312 | extension Parser { 313 | /// Return context of current position (line, lineNumber, columnNumber) 314 | func getContext() -> MustacheParserContext { 315 | var parser = self 316 | var columnNumber = 0 317 | while !parser.atStart() { 318 | try? parser.retreat() 319 | if parser.current() == "\n" { 320 | break 321 | } 322 | columnNumber += 1 323 | } 324 | if parser.current() == "\n" { 325 | try? parser.advance() 326 | } 327 | // read line from parser 328 | let line = try! parser.read(until: Character("\n"), throwOnOverflow: false) 329 | // count new lines up to this current position 330 | let buffer = parser.buffer 331 | let textBefore = buffer[buffer.startIndex.. Character { 344 | guard !self.reachedEnd() else { return "\0" } 345 | return unsafeCurrent() 346 | } 347 | 348 | /// Move forward one character 349 | /// - Throws: .overflow 350 | mutating func advance() throws { 351 | guard !self.reachedEnd() else { throw Parser.Error.overflow } 352 | return unsafeAdvance() 353 | } 354 | 355 | /// Move back one character 356 | /// - Throws: .overflow 357 | mutating func retreat() throws { 358 | guard self.position != self.buffer.startIndex else { throw Parser.Error.overflow } 359 | return unsafeRetreat() 360 | } 361 | 362 | /// Move forward so many character 363 | /// - Parameter amount: number of characters to move forward 364 | /// - Throws: .overflow 365 | mutating func advance(by amount: Int) throws { 366 | guard self.buffer.distance(from: self.position, to: self.buffer.endIndex) >= amount else { throw Parser.Error.overflow } 367 | return unsafeAdvance(by: amount) 368 | } 369 | 370 | /// Move back so many characters 371 | /// - Parameter amount: number of characters to move back 372 | /// - Throws: .overflow 373 | mutating func retreat(by amount: Int) throws { 374 | guard self.buffer.distance(from: self.buffer.startIndex, to: self.position) >= amount else { throw Parser.Error.overflow } 375 | return unsafeRetreat(by: amount) 376 | } 377 | 378 | mutating func setPosition(_ position: String.Index) throws { 379 | guard position <= self.buffer.endIndex else { throw Parser.Error.overflow } 380 | unsafeSetPosition(position) 381 | } 382 | } 383 | 384 | // unsafe versions without checks 385 | extension Parser { 386 | func unsafeCurrent() -> Character { 387 | self.buffer[self.position] 388 | } 389 | 390 | mutating func unsafeAdvance() { 391 | self.position = self.buffer.index(after: self.position) 392 | } 393 | 394 | mutating func unsafeRetreat() { 395 | self.position = self.buffer.index(before: self.position) 396 | } 397 | 398 | mutating func unsafeAdvance(by amount: Int) { 399 | self.position = self.buffer.index(self.position, offsetBy: amount) 400 | } 401 | 402 | mutating func unsafeRetreat(by amount: Int) { 403 | self.position = self.buffer.index(self.position, offsetBy: -amount) 404 | } 405 | 406 | mutating func unsafeSetPosition(_ position: String.Index) { 407 | self.position = position 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /Tests/MustacheTests/TemplateRendererTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Mustache 16 | import XCTest 17 | 18 | final class TemplateRendererTests: XCTestCase { 19 | func testText() throws { 20 | let template = try MustacheTemplate(string: "test text") 21 | XCTAssertEqual(template.render("test"), "test text") 22 | } 23 | 24 | func testStringVariable() throws { 25 | let template = try MustacheTemplate(string: "test {{.}}") 26 | XCTAssertEqual(template.render("text"), "test text") 27 | } 28 | 29 | func testIntegerVariable() throws { 30 | let template = try MustacheTemplate(string: "test {{.}}") 31 | XCTAssertEqual(template.render(101), "test 101") 32 | } 33 | 34 | func testDictionary() throws { 35 | let template = try MustacheTemplate(string: "test {{value}} {{bool}}") 36 | XCTAssertEqual(template.render(["value": "test2", "bool": true]), "test test2 true") 37 | } 38 | 39 | func testArraySection() throws { 40 | let template = try MustacheTemplate(string: "test {{#value}}*{{.}}{{/value}}") 41 | XCTAssertEqual(template.render(["value": ["test2", "bool"]]), "test *test2*bool") 42 | XCTAssertEqual(template.render(["value": ["test2"]]), "test *test2") 43 | XCTAssertEqual(template.render(["value": []]), "test ") 44 | } 45 | 46 | func testBooleanSection() throws { 47 | let template = try MustacheTemplate(string: "test {{#.}}Yep{{/.}}") 48 | XCTAssertEqual(template.render(true), "test Yep") 49 | XCTAssertEqual(template.render(false), "test ") 50 | } 51 | 52 | func testIntegerSection() throws { 53 | let template = try MustacheTemplate(string: "test {{#.}}{{.}}{{/.}}") 54 | XCTAssertEqual(template.render(23), "test 23") 55 | } 56 | 57 | func testStringSection() throws { 58 | let template = try MustacheTemplate(string: "test {{#.}}{{.}}{{/.}}") 59 | XCTAssertEqual(template.render("Hello"), "test Hello") 60 | } 61 | 62 | func testInvertedSection() throws { 63 | let template = try MustacheTemplate(string: "test {{^.}}Inverted{{/.}}") 64 | XCTAssertEqual(template.render(true), "test ") 65 | XCTAssertEqual(template.render(false), "test Inverted") 66 | } 67 | 68 | func testMirror() throws { 69 | struct Test { 70 | let string: String 71 | } 72 | let template = try MustacheTemplate(string: "test {{string}}") 73 | XCTAssertEqual(template.render(Test(string: "string")), "test string") 74 | } 75 | 76 | func testOptionalMirror() throws { 77 | struct Test { 78 | let string: String? 79 | } 80 | let template = try MustacheTemplate(string: "test {{string}}") 81 | XCTAssertEqual(template.render(Test(string: "string")), "test string") 82 | XCTAssertEqual(template.render(Test(string: nil)), "test ") 83 | } 84 | 85 | func testOptionalSection() throws { 86 | struct Test { 87 | let string: String? 88 | } 89 | let template = try MustacheTemplate(string: "test {{#string}}*{{.}}{{/string}}") 90 | XCTAssertEqual(template.render(Test(string: "string")), "test *string") 91 | XCTAssertEqual(template.render(Test(string: nil)), "test ") 92 | let template2 = try MustacheTemplate(string: "test {{^string}}*{{/string}}") 93 | XCTAssertEqual(template2.render(Test(string: "string")), "test ") 94 | XCTAssertEqual(template2.render(Test(string: nil)), "test *") 95 | } 96 | 97 | func testOptionalSequence() throws { 98 | struct Test { 99 | let string: String? 100 | } 101 | let template = try MustacheTemplate(string: "test {{#.}}{{string}}{{/.}}") 102 | XCTAssertEqual(template.render([Test(string: "string")]), "test string") 103 | } 104 | 105 | func testOptionalSequenceSection() throws { 106 | struct Test { 107 | let string: String? 108 | } 109 | let template = try MustacheTemplate(string: "test {{#.}}{{#string}}*{{.}}{{/string}}{{/.}}") 110 | XCTAssertEqual(template.render([Test(string: "string")]), "test *string") 111 | } 112 | 113 | func testStructureInStructure() throws { 114 | struct SubTest { 115 | let string: String? 116 | } 117 | struct Test { 118 | let test: SubTest 119 | } 120 | 121 | let template = try MustacheTemplate(string: "test {{test.string}}") 122 | XCTAssertEqual(template.render(Test(test: .init(string: "sub"))), "test sub") 123 | } 124 | 125 | func testTextEscaping() throws { 126 | let template1 = try MustacheTemplate(string: "{{% CONTENT_TYPE:TEXT}}{{.}}") 127 | XCTAssertEqual(template1.render("<>"), "<>") 128 | let template2 = try MustacheTemplate(string: "{{% CONTENT_TYPE:HTML}}{{.}}") 129 | XCTAssertEqual(template2.render("<>"), "<>") 130 | } 131 | 132 | func testStopClimbingStack() throws { 133 | let template1 = try MustacheTemplate(string: "{{#test}}{{name}}{{/test}}") 134 | let template2 = try MustacheTemplate(string: "{{#test}}{{.name}}{{/test}}") 135 | let object: [String: Any] = ["test": [:], "name": "John"] 136 | let object2: [String: Any] = ["test": ["name": "Jane"], "name": "John"] 137 | XCTAssertEqual(template1.render(object), "John") 138 | XCTAssertEqual(template2.render(object), "") 139 | XCTAssertEqual(template2.render(object2), "Jane") 140 | } 141 | 142 | /// variables 143 | func testMustacheManualVariables() throws { 144 | let template = try MustacheTemplate( 145 | string: """ 146 | Hello {{name}} 147 | You have just won {{value}} dollars! 148 | {{#in_ca}} 149 | Well, {{taxed_value}} dollars, after taxes. 150 | {{/in_ca}} 151 | """ 152 | ) 153 | let object: [String: Any] = ["name": "Chris", "value": 10000, "taxed_value": 10000 - (10000 * 0.4), "in_ca": true] 154 | XCTAssertEqual( 155 | template.render(object), 156 | """ 157 | Hello Chris 158 | You have just won 10000 dollars! 159 | Well, 6000.0 dollars, after taxes. 160 | 161 | """ 162 | ) 163 | } 164 | 165 | /// test escaped and unescaped text 166 | func testMustacheManualEscapedText() throws { 167 | let template = try MustacheTemplate( 168 | string: """ 169 | *{{name}} 170 | *{{age}} 171 | *{{company}} 172 | *{{{company}}} 173 | """ 174 | ) 175 | let object: [String: Any] = ["name": "Chris", "company": "GitHub"] 176 | XCTAssertEqual( 177 | template.render(object), 178 | """ 179 | *Chris 180 | * 181 | *<b>GitHub</b> 182 | *GitHub 183 | """ 184 | ) 185 | } 186 | 187 | /// test dotted names 188 | func test_MustacheManualDottedNames() throws { 189 | let template = try MustacheTemplate( 190 | string: """ 191 | * {{client.name}} 192 | * {{age}} 193 | * {{client.company.name}} 194 | * {{{company.name}}} 195 | """ 196 | ) 197 | let object: [String: Any] = [ 198 | "client": ( 199 | name: "Chris & Friends", 200 | age: 50 201 | ), 202 | "company": [ 203 | "name": "GitHub" 204 | ], 205 | ] 206 | XCTAssertEqual( 207 | template.render(object), 208 | """ 209 | * Chris & Friends 210 | * 211 | * 212 | * GitHub 213 | """ 214 | ) 215 | } 216 | 217 | /// test implicit operator 218 | func testMustacheManualImplicitOperator() throws { 219 | let template = try MustacheTemplate( 220 | string: """ 221 | * {{.}} 222 | """ 223 | ) 224 | let object = "Hello!" 225 | XCTAssertEqual( 226 | template.render(object), 227 | """ 228 | * Hello! 229 | """ 230 | ) 231 | } 232 | 233 | /// test lambda 234 | func test_MustacheManualLambda() throws { 235 | let template = try MustacheTemplate( 236 | string: """ 237 | * {{time.hour}} 238 | * {{today}} 239 | """ 240 | ) 241 | let object: [String: Any] = [ 242 | "year": 1970, 243 | "month": 1, 244 | "day": 1, 245 | "time": MustacheLambda { _ in 246 | ( 247 | hour: 0, 248 | minute: 0, 249 | second: 0 250 | ) 251 | }, 252 | "today": MustacheLambda { _ in 253 | "{{year}}-{{month}}-{{day}}" 254 | }, 255 | ] 256 | XCTAssertEqual( 257 | template.render(object), 258 | """ 259 | * 0 260 | * 1970-1-1 261 | """ 262 | ) 263 | } 264 | 265 | /// test boolean 266 | func testMustacheManualSectionFalse() throws { 267 | let template = try MustacheTemplate( 268 | string: """ 269 | Shown. 270 | {{#person}} 271 | Never shown! 272 | {{/person}} 273 | """ 274 | ) 275 | let object: [String: Any] = ["person": false] 276 | XCTAssertEqual( 277 | template.render(object), 278 | """ 279 | Shown. 280 | 281 | """ 282 | ) 283 | } 284 | 285 | /// test non-empty lists 286 | func testMustacheManualSectionList() throws { 287 | let template = try MustacheTemplate( 288 | string: """ 289 | {{#repo}} 290 | {{name}} 291 | {{/repo}} 292 | """ 293 | ) 294 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 295 | XCTAssertEqual( 296 | template.render(object), 297 | """ 298 | resque 299 | hub 300 | rip 301 | 302 | """ 303 | ) 304 | } 305 | 306 | /// test non-empty lists 307 | func testMustacheManualSectionList2() throws { 308 | let template = try MustacheTemplate( 309 | string: """ 310 | {{#repo}} 311 | {{.}} 312 | {{/repo}} 313 | """ 314 | ) 315 | let object: [String: Any] = ["repo": ["resque", "hub", "rip"]] 316 | XCTAssertEqual( 317 | template.render(object), 318 | """ 319 | resque 320 | hub 321 | rip 322 | 323 | """ 324 | ) 325 | } 326 | 327 | /// test lambdas 328 | func testMustacheManualSectionLambda() throws { 329 | let template = try MustacheTemplate( 330 | string: """ 331 | {{#wrapped}}{{name}} is awesome.{{/wrapped}} 332 | """ 333 | ) 334 | func wrapped(_ s: String) -> Any? { 335 | "\(s)" 336 | } 337 | let object: [String: Any] = ["name": "Willy", "wrapped": MustacheLambda(wrapped)] 338 | XCTAssertEqual( 339 | template.render(object), 340 | """ 341 | Willy is awesome. 342 | """ 343 | ) 344 | } 345 | 346 | /// test setting context object 347 | func testMustacheManualContextObject() throws { 348 | let template = try MustacheTemplate( 349 | string: """ 350 | {{#person?}} 351 | Hi {{name}}! 352 | {{/person?}} 353 | """ 354 | ) 355 | let object: [String: Any] = ["person?": ["name": "Jon"]] 356 | XCTAssertEqual( 357 | template.render(object), 358 | """ 359 | Hi Jon! 360 | 361 | """ 362 | ) 363 | } 364 | 365 | /// test inverted sections 366 | func testMustacheManualInvertedSection() throws { 367 | let template = try MustacheTemplate( 368 | string: """ 369 | {{#repo}} 370 | {{name}} 371 | {{/repo}} 372 | {{^repo}} 373 | No repos :( 374 | {{/repo}} 375 | """ 376 | ) 377 | let object: [String: Any] = ["repo": []] 378 | XCTAssertEqual( 379 | template.render(object), 380 | """ 381 | No repos :( 382 | 383 | """ 384 | ) 385 | } 386 | 387 | /// test comments 388 | func testMustacheManualComment() throws { 389 | let template = try MustacheTemplate( 390 | string: """ 391 |

Today{{! ignore me }}.

392 | """ 393 | ) 394 | let object: [String: Any] = ["repo": []] 395 | XCTAssertEqual( 396 | template.render(object), 397 | """ 398 |

Today.

399 | """ 400 | ) 401 | } 402 | 403 | /// test dynamic names 404 | func testMustacheManualDynamicNames() throws { 405 | var library = MustacheLibrary() 406 | try library.register( 407 | "Hello {{>*dynamic}}", 408 | named: "main" 409 | ) 410 | try library.register( 411 | "everyone!", 412 | named: "world" 413 | ) 414 | let object = ["dynamic": "world"] 415 | XCTAssertEqual(library.render(object, withTemplate: "main"), "Hello everyone!") 416 | } 417 | 418 | /// test block with defaults 419 | func testMustacheManualBlocksWithDefaults() throws { 420 | let template = try MustacheTemplate( 421 | string: """ 422 |

{{$title}}The News of Today{{/title}}

423 | {{$body}} 424 |

Nothing special happened.

425 | {{/body}} 426 | 427 | """ 428 | ) 429 | XCTAssertEqual( 430 | template.render([]), 431 | """ 432 |

The News of Today

433 |

Nothing special happened.

434 | 435 | """ 436 | ) 437 | } 438 | 439 | func testMustacheManualParents() throws { 440 | var library = MustacheLibrary() 441 | try library.register( 442 | """ 443 | {{{{.}}

448 | {{/headlines}} 449 | {{/body}} 450 | {{/article}} 451 | 452 | {{{{$title}}The News of Today{{/title}} 462 | {{$body}} 463 |

Nothing special happened.

464 | {{/body}} 465 | 466 | """, 467 | named: "article" 468 | ) 469 | let object = [ 470 | "headlines": [ 471 | "A pug's handler grew mustaches.", 472 | "What an exciting day!", 473 | ] 474 | ] 475 | XCTAssertEqual( 476 | library.render(object, withTemplate: "main"), 477 | """ 478 |

The News of Today

479 |

A pug's handler grew mustaches.

480 |

What an exciting day!

481 | 482 |

Yesterday

483 |

Nothing special happened.

484 | 485 | """ 486 | ) 487 | } 488 | 489 | func testMustacheManualDynamicNameParents() throws { 490 | var library = MustacheLibrary() 491 | try library.register( 492 | """ 493 | {{<*dynamic}} 494 | {{$text}}Hello World!{{/text}} 495 | {{/*dynamic}} 496 | 497 | """, 498 | named: "dynamic" 499 | ) 500 | try library.register( 501 | """ 502 | {{$text}}Here goes nothing.{{/text}} 503 | """, 504 | named: "normal" 505 | ) 506 | try library.register( 507 | """ 508 | {{$text}}Here also goes nothing but it's bold.{{/text}} 509 | """, 510 | named: "bold" 511 | ) 512 | let object = ["dynamic": "bold"] 513 | XCTAssertEqual( 514 | library.render(object, withTemplate: "dynamic"), 515 | """ 516 | Hello World! 517 | """ 518 | ) 519 | } 520 | 521 | /// test MustacheCustomRenderable 522 | func testCustomRenderable() throws { 523 | let template = try MustacheTemplate(string: "{{.}}") 524 | let template1 = try MustacheTemplate(string: "{{#.}}not null{{/.}}") 525 | let template2 = try MustacheTemplate(string: "{{^.}}null{{/.}}") 526 | struct Object: MustacheCustomRenderable { 527 | let value: String 528 | 529 | var renderText: String { self.value.uppercased() } 530 | var isNull: Bool { self.value == "null" } 531 | } 532 | let testObject = Object(value: "test") 533 | let nullObject = Object(value: "null") 534 | XCTAssertEqual(template.render(testObject), "TEST") 535 | XCTAssertEqual(template1.render(testObject), "not null") 536 | XCTAssertEqual(template1.render(nullObject), "") 537 | XCTAssertEqual(template2.render(testObject), "") 538 | XCTAssertEqual(template2.render(nullObject), "null") 539 | } 540 | 541 | func testTypeErasedOptionalContext() throws { 542 | let object = ["name": "Test" as Any?] 543 | 544 | let template = try MustacheTemplate(string: "{{name}}") 545 | let result = template.render(object) 546 | 547 | XCTAssertEqual(result, "Test") 548 | } 549 | 550 | func testPerformance() throws { 551 | let template = try MustacheTemplate( 552 | string: """ 553 | {{#repo}} 554 | {{name}} 555 | {{/repo}} 556 | """ 557 | ) 558 | let object: [String: Any] = ["repo": [["name": "resque"], ["name": "hub"], ["name": "rip"]]] 559 | let date = Date() 560 | for _ in 1...10000 { 561 | _ = template.render(object) 562 | } 563 | print(-date.timeIntervalSinceNow) 564 | } 565 | } 566 | -------------------------------------------------------------------------------- /Sources/Mustache/Template+Parser.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Hummingbird server framework project 4 | // 5 | // Copyright (c) 2021-2021 the Hummingbird authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | extension MustacheTemplate { 16 | /// Error return by `MustacheTemplate.parse`. Includes information about where error occurred 17 | public struct ParserError: Swift.Error { 18 | public let context: MustacheParserContext 19 | public let error: Swift.Error 20 | } 21 | 22 | /// Error generated by `MustacheTemplate.parse` 23 | public enum Error: Swift.Error { 24 | /// the end section does not match the name of the start section 25 | case sectionCloseNameIncorrect 26 | /// tag was badly formatted 27 | case unfinishedName 28 | /// was expecting a section end 29 | case expectedSectionEnd 30 | /// set delimiter tag badly formatted 31 | case invalidSetDelimiter 32 | /// cannot apply transform to inherited section 33 | case transformAppliedToInheritanceSection 34 | /// illegal token inside inherit section of partial 35 | case illegalTokenInsideInheritSection 36 | /// text found inside inherit section of partial 37 | case textInsideInheritSection 38 | /// config variable syntax is wrong 39 | case invalidConfigVariableSyntax 40 | /// unrecognised config variable 41 | case unrecognisedConfigVariable 42 | } 43 | 44 | struct ParserState { 45 | struct Flags: OptionSet { 46 | let rawValue: Int 47 | 48 | init(rawValue: Int) { 49 | self.rawValue = rawValue 50 | } 51 | 52 | static var newLine: Self { .init(rawValue: 1 << 0) } 53 | static var isPartialDefinition: Self { .init(rawValue: 1 << 1) } 54 | static var isPartialDefinitionTopLevel: Self { .init(rawValue: 1 << 2) } 55 | } 56 | 57 | var sectionName: String? 58 | var sectionTransforms: [String] = [] 59 | var flags: Flags 60 | var startDelimiter: String 61 | var endDelimiter: String 62 | var partialDefinitionIndent: Substring? 63 | 64 | var newLine: Bool { 65 | get { self.flags.contains(.newLine) } 66 | set { 67 | if newValue { 68 | self.flags.insert(.newLine) 69 | } else { 70 | self.flags.remove(.newLine) 71 | } 72 | } 73 | } 74 | 75 | init() { 76 | self.sectionName = nil 77 | self.flags = .newLine 78 | self.startDelimiter = "{{" 79 | self.endDelimiter = "}}" 80 | } 81 | 82 | func withSectionName(_ name: String, newLine: Bool, transforms: [String] = []) -> ParserState { 83 | var newValue = self 84 | newValue.sectionName = name 85 | newValue.sectionTransforms = transforms 86 | newValue.flags.remove(.isPartialDefinitionTopLevel) 87 | if !newLine { 88 | newValue.flags.remove(.newLine) 89 | } 90 | return newValue 91 | } 92 | 93 | func withInheritancePartial(_ name: String) -> ParserState { 94 | var newValue = self 95 | newValue.sectionName = name 96 | newValue.flags.insert([.newLine, .isPartialDefinition, .isPartialDefinitionTopLevel]) 97 | return newValue 98 | } 99 | 100 | func withDelimiters(start: String, end: String) -> ParserState { 101 | var newValue = self 102 | newValue.startDelimiter = start 103 | newValue.endDelimiter = end 104 | newValue.flags.remove(.isPartialDefinitionTopLevel) 105 | return newValue 106 | } 107 | } 108 | 109 | /// parse mustache text to generate a list of tokens 110 | static func parse(_ string: String) throws -> MustacheTemplate { 111 | var parser = Parser(string) 112 | do { 113 | return try self.parse(&parser, state: .init()) 114 | } catch { 115 | throw ParserError(context: parser.getContext(), error: error) 116 | } 117 | } 118 | 119 | /// parse section in mustache text 120 | static func parse(_ parser: inout Parser, state: ParserState) throws -> MustacheTemplate { 121 | var tokens: [Token] = [] 122 | var state = state 123 | var whiteSpaceBefore: Substring = "" 124 | var origParser = parser 125 | while !parser.reachedEnd() { 126 | // if new line read whitespace 127 | if state.newLine { 128 | let whiteSpace = parser.read(while: Set(" \t")) 129 | // If inside a partial block definition 130 | if state.flags.contains(.isPartialDefinition), !state.flags.contains(.isPartialDefinitionTopLevel) { 131 | // if definition indent has been set then remove it from current whitespace otherwise set the 132 | // indent as this is the first line of the partial definition 133 | if let partialDefinitionIndent = state.partialDefinitionIndent { 134 | whiteSpaceBefore = whiteSpace.dropFirst(partialDefinitionIndent.count) 135 | } else { 136 | state.partialDefinitionIndent = whiteSpace 137 | } 138 | } else { 139 | whiteSpaceBefore = whiteSpace 140 | } 141 | } 142 | let text = try readUntilDelimiterOrNewline(&parser, state: state) 143 | // if we hit a newline add text 144 | if parser.current().isNewline { 145 | tokens.append(.text(whiteSpaceBefore + text + String(parser.current()))) 146 | state.newLine = true 147 | parser.unsafeAdvance() 148 | continue 149 | } 150 | // we have found a tag 151 | // whatever text we found before the tag should be added as a token 152 | if text.count > 0 { 153 | tokens.append(.text(whiteSpaceBefore + text)) 154 | whiteSpaceBefore = "" 155 | state.newLine = false 156 | } 157 | // have we reached the end of the text 158 | if parser.reachedEnd() { 159 | break 160 | } 161 | var setNewLine = false 162 | switch parser.current() { 163 | case "#": 164 | // section 165 | parser.unsafeAdvance() 166 | let (name, transforms) = try parseName(&parser, state: state) 167 | if self.isStandalone(&parser, state: state) { 168 | setNewLine = true 169 | } else if whiteSpaceBefore.count > 0 { 170 | tokens.append(.text(String(whiteSpaceBefore))) 171 | whiteSpaceBefore = "" 172 | } 173 | let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms)) 174 | tokens.append(.section(name: name, transforms: transforms, template: sectionTemplate)) 175 | 176 | case "^": 177 | // inverted section 178 | parser.unsafeAdvance() 179 | let (name, transforms) = try parseName(&parser, state: state) 180 | if self.isStandalone(&parser, state: state) { 181 | setNewLine = true 182 | } else if whiteSpaceBefore.count > 0 { 183 | tokens.append(.text(String(whiteSpaceBefore))) 184 | whiteSpaceBefore = "" 185 | } 186 | let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine, transforms: transforms)) 187 | tokens.append(.invertedSection(name: name, transforms: transforms, template: sectionTemplate)) 188 | 189 | case "/": 190 | // end of section 191 | 192 | // record end of section text 193 | var sectionParser = parser 194 | sectionParser.unsafeRetreat() 195 | sectionParser.unsafeRetreat() 196 | 197 | parser.unsafeAdvance() 198 | let position = parser.position 199 | let (name, transforms) = try parseName(&parser, state: state) 200 | guard name == state.sectionName, transforms == state.sectionTransforms else { 201 | parser.unsafeSetPosition(position) 202 | throw Error.sectionCloseNameIncorrect 203 | } 204 | if self.isStandalone(&parser, state: state) { 205 | setNewLine = true 206 | } else if whiteSpaceBefore.count > 0 { 207 | tokens.append(.text(String(whiteSpaceBefore))) 208 | whiteSpaceBefore = "" 209 | } 210 | return .init(tokens, text: String(origParser.read(until: sectionParser.position))) 211 | 212 | case "!": 213 | // comment 214 | parser.unsafeAdvance() 215 | _ = try self.parseComment(&parser, state: state) 216 | setNewLine = self.isStandalone(&parser, state: state) 217 | 218 | case "{": 219 | // unescaped variable 220 | if whiteSpaceBefore.count > 0 { 221 | tokens.append(.text(String(whiteSpaceBefore))) 222 | whiteSpaceBefore = "" 223 | } 224 | parser.unsafeAdvance() 225 | let (name, transforms) = try parseName(&parser, state: state) 226 | guard try parser.read("}") else { throw Error.unfinishedName } 227 | tokens.append(.unescapedVariable(name: name, transforms: transforms)) 228 | 229 | case "&": 230 | // unescaped variable 231 | if whiteSpaceBefore.count > 0 { 232 | tokens.append(.text(String(whiteSpaceBefore))) 233 | whiteSpaceBefore = "" 234 | } 235 | parser.unsafeAdvance() 236 | let (name, transforms) = try parseName(&parser, state: state) 237 | tokens.append(.unescapedVariable(name: name, transforms: transforms)) 238 | 239 | case ">": 240 | // partial 241 | parser.unsafeAdvance() 242 | // skip whitespace 243 | parser.read(while: \.isWhitespace) 244 | var dynamic = false 245 | if parser.current() == "*" { 246 | parser.unsafeAdvance() 247 | dynamic = true 248 | } 249 | let name = try parsePartialName(&parser, state: state) 250 | if whiteSpaceBefore.count > 0 { 251 | tokens.append(.text(String(whiteSpaceBefore))) 252 | } 253 | if self.isStandalone(&parser, state: state) { 254 | setNewLine = true 255 | if dynamic { 256 | tokens.append(.dynamicNamePartial(name, indentation: String(whiteSpaceBefore), inherits: nil)) 257 | } else { 258 | tokens.append(.partial(name, indentation: String(whiteSpaceBefore), inherits: nil)) 259 | } 260 | } else { 261 | if dynamic { 262 | tokens.append(.dynamicNamePartial(name, indentation: nil, inherits: nil)) 263 | } else { 264 | tokens.append(.partial(name, indentation: nil, inherits: nil)) 265 | } 266 | } 267 | whiteSpaceBefore = "" 268 | 269 | case "<": 270 | // partial with inheritance 271 | parser.unsafeAdvance() 272 | // skip whitespace 273 | parser.read(while: \.isWhitespace) 274 | let sectionName = try parsePartialName(&parser, state: state) 275 | let name: String 276 | let dynamic: Bool 277 | if sectionName.first == "*" { 278 | dynamic = true 279 | name = String(sectionName.dropFirst()) 280 | } else { 281 | dynamic = false 282 | name = sectionName 283 | } 284 | if whiteSpaceBefore.count > 0 { 285 | tokens.append(.text(String(whiteSpaceBefore))) 286 | } 287 | if self.isStandalone(&parser, state: state) { 288 | setNewLine = true 289 | } 290 | let sectionTemplate = try parse(&parser, state: state.withInheritancePartial(sectionName)) 291 | var inherit: [String: MustacheTemplate] = [:] 292 | // parse tokens in section to extract inherited sections 293 | for token in sectionTemplate.tokens { 294 | switch token { 295 | case .blockDefinition(let name, let template): 296 | inherit[name] = template 297 | case .text: 298 | break 299 | default: 300 | throw Error.illegalTokenInsideInheritSection 301 | } 302 | } 303 | if dynamic { 304 | tokens.append(.dynamicNamePartial(name, indentation: String(whiteSpaceBefore), inherits: inherit)) 305 | } else { 306 | tokens.append(.partial(name, indentation: String(whiteSpaceBefore), inherits: inherit)) 307 | } 308 | whiteSpaceBefore = "" 309 | 310 | case "$": 311 | // inherited section 312 | parser.unsafeAdvance() 313 | let (name, transforms) = try parseName(&parser, state: state) 314 | // ERROR: can't have transforms applied to inherited sections 315 | guard transforms.isEmpty else { throw Error.transformAppliedToInheritanceSection } 316 | if state.flags.contains(.isPartialDefinitionTopLevel) { 317 | let standAlone = self.isStandalone(&parser, state: state) 318 | if standAlone { 319 | setNewLine = true 320 | } 321 | let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine)) 322 | tokens.append(.blockDefinition(name: name, template: sectionTemplate)) 323 | 324 | } else { 325 | if whiteSpaceBefore.count > 0 { 326 | tokens.append(.text(String(whiteSpaceBefore))) 327 | } 328 | if self.isStandalone(&parser, state: state) { 329 | setNewLine = true 330 | } else if whiteSpaceBefore.count > 0 { 331 | } 332 | let sectionTemplate = try parse(&parser, state: state.withSectionName(name, newLine: setNewLine)) 333 | tokens.append(.blockExpansion(name: name, default: sectionTemplate, indentation: String(whiteSpaceBefore))) 334 | whiteSpaceBefore = "" 335 | } 336 | 337 | case "=": 338 | // set delimiter 339 | parser.unsafeAdvance() 340 | state = try self.parserSetDelimiter(&parser, state: state) 341 | setNewLine = self.isStandalone(&parser, state: state) 342 | 343 | case "%": 344 | // read config variable 345 | parser.unsafeAdvance() 346 | if let token = try self.readConfigVariable(&parser, state: state) { 347 | tokens.append(token) 348 | } 349 | setNewLine = self.isStandalone(&parser, state: state) 350 | 351 | default: 352 | // variable 353 | if whiteSpaceBefore.count > 0 { 354 | tokens.append(.text(String(whiteSpaceBefore))) 355 | whiteSpaceBefore = "" 356 | } 357 | let (name, transforms) = try parseName(&parser, state: state) 358 | tokens.append(.variable(name: name, transforms: transforms)) 359 | } 360 | state.newLine = setNewLine 361 | } 362 | // should never get here if reading section 363 | guard state.sectionName == nil else { 364 | throw Error.expectedSectionEnd 365 | } 366 | return .init(tokens, text: String(origParser.read(until: parser.position))) 367 | } 368 | 369 | /// read until we hit either the start delimiter of a tag or a newline 370 | static func readUntilDelimiterOrNewline(_ parser: inout Parser, state: ParserState) throws -> String { 371 | var untilSet: Set = ["\n", "\r\n"] 372 | guard let delimiterFirstChar = state.startDelimiter.first else { return "" } 373 | var totalText = "" 374 | untilSet.insert(delimiterFirstChar) 375 | 376 | while !parser.reachedEnd() { 377 | // read until we hit either a newline or "{" 378 | let text = try parser.read(until: untilSet, throwOnOverflow: false) 379 | totalText += text 380 | // if new line append all text read plus newline 381 | if parser.current().isNewline { 382 | break 383 | } else if parser.current() == delimiterFirstChar { 384 | if try parser.read(string: state.startDelimiter) { 385 | break 386 | } 387 | totalText += String(delimiterFirstChar) 388 | parser.unsafeAdvance() 389 | } 390 | } 391 | return totalText 392 | } 393 | 394 | /// parse variable name 395 | static func parseName(_ parser: inout Parser, state: ParserState) throws -> (String, [String]) { 396 | parser.read(while: \.isWhitespace) 397 | let text = String(parser.read(while: self.sectionNameChars)) 398 | parser.read(while: \.isWhitespace) 399 | guard try parser.read(string: state.endDelimiter) else { throw Error.unfinishedName } 400 | 401 | // does the name include brackets. If so this is a transform call 402 | var nameParser = Parser(String(text)) 403 | let string = nameParser.read(while: self.sectionNameCharsWithoutBrackets) 404 | if nameParser.reachedEnd() { 405 | return (text, []) 406 | } else { 407 | // parse function parameter, as we have just parsed a function name 408 | guard nameParser.current() == "(" else { throw Error.unfinishedName } 409 | nameParser.unsafeAdvance() 410 | 411 | func parseTransforms(existing: [Substring]) throws -> (Substring, [Substring]) { 412 | let name = nameParser.read(while: self.sectionNameCharsWithoutBrackets) 413 | switch nameParser.current() { 414 | case ")": 415 | // Transforms are ending 416 | nameParser.unsafeAdvance() 417 | // We need to have a `)` for each transform that we've parsed 418 | guard nameParser.read(while: ")") + 1 == existing.count, 419 | nameParser.reachedEnd() 420 | else { 421 | throw Error.unfinishedName 422 | } 423 | return (name, existing) 424 | case "(": 425 | // Parse the next transform 426 | nameParser.unsafeAdvance() 427 | 428 | var transforms = existing 429 | transforms.append(name) 430 | return try parseTransforms(existing: transforms) 431 | default: 432 | throw Error.unfinishedName 433 | } 434 | } 435 | let (parameterName, transforms) = try parseTransforms(existing: [string]) 436 | 437 | return (String(parameterName), transforms.map(String.init)) 438 | } 439 | } 440 | 441 | /// parse partial name 442 | static func parsePartialName(_ parser: inout Parser, state: ParserState) throws -> String { 443 | parser.read(while: \.isWhitespace) 444 | let text = String(parser.read(while: self.partialNameChars)) 445 | parser.read(while: \.isWhitespace) 446 | guard try parser.read(string: state.endDelimiter) else { throw Error.unfinishedName } 447 | return text 448 | } 449 | 450 | static func parseComment(_ parser: inout Parser, state: ParserState) throws -> String { 451 | let text = try parser.read(untilString: state.endDelimiter, throwOnOverflow: true, skipToEnd: true) 452 | return String(text) 453 | } 454 | 455 | static func parserSetDelimiter(_ parser: inout Parser, state: ParserState) throws -> ParserState { 456 | let startDelimiter: Substring 457 | let endDelimiter: Substring 458 | 459 | do { 460 | parser.read(while: \.isWhitespace) 461 | startDelimiter = try parser.read(until: \.isWhitespace) 462 | parser.read(while: \.isWhitespace) 463 | endDelimiter = try parser.read(until: { $0 == "=" || $0.isWhitespace }) 464 | parser.read(while: \.isWhitespace) 465 | } catch { 466 | throw Error.invalidSetDelimiter 467 | } 468 | guard try parser.read("=") else { throw Error.invalidSetDelimiter } 469 | guard try parser.read(string: state.endDelimiter) else { throw Error.invalidSetDelimiter } 470 | guard startDelimiter.count > 0, endDelimiter.count > 0 else { throw Error.invalidSetDelimiter } 471 | return state.withDelimiters(start: String(startDelimiter), end: String(endDelimiter)) 472 | } 473 | 474 | static func readConfigVariable(_ parser: inout Parser, state: ParserState) throws -> Token? { 475 | let variable: Substring 476 | let value: Substring 477 | 478 | do { 479 | parser.read(while: \.isWhitespace) 480 | variable = parser.read(while: self.sectionNameCharsWithoutBrackets) 481 | parser.read(while: \.isWhitespace) 482 | guard try parser.read(":") else { throw Error.invalidConfigVariableSyntax } 483 | parser.read(while: \.isWhitespace) 484 | value = parser.read(while: self.sectionNameCharsWithoutBrackets) 485 | parser.read(while: \.isWhitespace) 486 | guard try parser.read(string: state.endDelimiter) else { throw Error.invalidConfigVariableSyntax } 487 | } catch { 488 | throw Error.invalidConfigVariableSyntax 489 | } 490 | 491 | // do both variable and value have content 492 | guard variable.count > 0, value.count > 0 else { throw Error.invalidConfigVariableSyntax } 493 | 494 | switch variable { 495 | case "CONTENT_TYPE": 496 | guard let contentType = MustacheContentTypes.get(String(value)) else { throw Error.unrecognisedConfigVariable } 497 | return .contentType(contentType) 498 | default: 499 | throw Error.unrecognisedConfigVariable 500 | } 501 | } 502 | 503 | static func hasLineFinished(_ parser: inout Parser) -> Bool { 504 | var parser2 = parser 505 | if parser.reachedEnd() { return true } 506 | parser2.read(while: Set(" \t")) 507 | if parser2.current().isNewline { 508 | parser2.unsafeAdvance() 509 | try! parser.setPosition(parser2.position) 510 | return true 511 | } 512 | return false 513 | } 514 | 515 | static func isStandalone(_ parser: inout Parser, state: ParserState) -> Bool { 516 | state.newLine && self.hasLineFinished(&parser) 517 | } 518 | 519 | private static let sectionNameCharsWithoutBrackets = Set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-_?*") 520 | private static let sectionNameChars = Set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-_?()*") 521 | private static let partialNameChars = Set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-_?()*/") 522 | } 523 | --------------------------------------------------------------------------------