├── .ruby-version
├── .github
├── dependabot.yml
└── workflows
│ ├── lint-cocoapods.yml
│ ├── release-check-versions.yml
│ ├── swiftlint.yml
│ ├── danger.yml
│ ├── tag-publish.yml
│ └── test-spm.yml
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ └── StencilSwiftKit.xcscheme
├── rakelib
├── pod.rake
├── spm.rake
├── lint.sh
├── changelog.rake
├── lint.rake
├── check_changelog.rb
├── release.rake
├── Dangerfile
└── utils.rake
├── Rakefile
├── Gemfile
├── Documentation
├── MigrationGuide.md
├── filters-numbers.md
├── tag-import.md
├── tag-macro.md
├── tag-call.md
├── tag-map.md
├── tag-set.md
└── filters-strings.md
├── LICENCE
├── Package.swift
├── StencilSwiftKit.podspec
├── Sources
└── StencilSwiftKit
│ ├── ImportNode.swift
│ ├── Filters+Numbers.swift
│ ├── StencilSwiftTemplate.swift
│ ├── SetNode.swift
│ ├── Context.swift
│ ├── MapNode.swift
│ ├── CallMacroNodes.swift
│ ├── Filters.swift
│ ├── SwiftIdentifier.swift
│ ├── Environment.swift
│ ├── Parameters.swift
│ └── Filters+Strings.swift
├── Tests
└── StencilSwiftKitTests
│ ├── ParseEnumTests.swift
│ ├── ImportNodeTests.swift
│ ├── ParseBoolTests.swift
│ ├── ContextTests.swift
│ ├── ParseStringTests.swift
│ ├── SwiftIdentifierTests.swift
│ ├── CallNodeTests.swift
│ ├── MacroNodeTests.swift
│ ├── MapNodeTests.swift
│ ├── ParametersTests.swift
│ ├── SetNodeTests.swift
│ └── StringFiltersTests.swift
├── .gitignore
├── Package.resolved
├── .swiftlint.yml
├── README.md
├── Gemfile.lock
└── CHANGELOG.md
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.0.4
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Maintain dependencies for GitHub Actions
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "daily"
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/rakelib/pod.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - POD_NAME
5 |
6 | if defined?(POD_NAME) && File.file?("#{POD_NAME}.podspec")
7 | namespace :pod do
8 | desc 'Lint the Pod'
9 | task :lint do |task|
10 | Utils.print_header 'Linting the pod spec'
11 | Utils.run(%(bundle exec pod lib lint "#{POD_NAME}.podspec" --quick), task)
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/rake
2 |
3 | require 'English'
4 |
5 | unless defined?(Bundler)
6 | puts 'Please use bundle exec to run the rake command'
7 | exit 1
8 | end
9 |
10 | ## [ Constants ] ##############################################################
11 |
12 | POD_NAME = 'StencilSwiftKit'.freeze
13 | MIN_XCODE_VERSION = 13.0
14 | BUILD_DIR = File.absolute_path('./.build')
15 |
16 | task :default => 'spm:test'
17 |
--------------------------------------------------------------------------------
/rakelib/spm.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # _none_
5 |
6 | namespace :spm do
7 | desc 'Build using SPM'
8 | task :build do |task|
9 | Utils.print_header 'Compile using SPM'
10 | Utils.run('swift build', task, xcrun: true)
11 | end
12 |
13 | desc 'Run SPM Unit Tests'
14 | task :test => :build do |task|
15 | Utils.print_header 'Run the unit tests using SPM'
16 | Utils.run('swift test --parallel', task, xcrun: true)
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | # The bare minimum for building, e.g. in Homebrew
6 | group :build do
7 | gem 'rake', '~> 13.0'
8 | gem 'xcpretty', '~> 0.3'
9 | end
10 |
11 | # In addition to :build, for contributing
12 | group :development do
13 | gem 'cocoapods', '~> 1.11'
14 | gem 'danger', '~> 8.4'
15 | gem 'rubocop', '~> 1.22'
16 | end
17 |
18 | # For releasing to GitHub
19 | group :release do
20 | gem 'octokit', '~> 4.7'
21 | end
22 |
--------------------------------------------------------------------------------
/.github/workflows/lint-cocoapods.yml:
--------------------------------------------------------------------------------
1 | name: Lint Cocoapods
2 |
3 | on:
4 | push:
5 | branches: stable
6 | pull_request:
7 |
8 | jobs:
9 | lint:
10 | name: Pod Lint
11 | runs-on: ubuntu-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Lint podspec
23 | run: bundle exec rake pod:lint
24 |
--------------------------------------------------------------------------------
/.github/workflows/release-check-versions.yml:
--------------------------------------------------------------------------------
1 | name: Check Versions
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'release/**'
7 |
8 | jobs:
9 | check_versions:
10 | name: Check Versions
11 | runs-on: ubuntu-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Check versions
23 | run: bundle exec rake release:check_versions
24 |
--------------------------------------------------------------------------------
/.github/workflows/swiftlint.yml:
--------------------------------------------------------------------------------
1 | name: SwiftLint
2 |
3 | on:
4 | push:
5 | branches: stable
6 | pull_request:
7 |
8 | jobs:
9 | lint:
10 | name: SwiftLint
11 | runs-on: macos-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Lint source code
23 | run: bundle exec rake lint:code
24 | -
25 | name: Lint tests source code
26 | run: bundle exec rake lint:tests
27 |
--------------------------------------------------------------------------------
/.github/workflows/danger.yml:
--------------------------------------------------------------------------------
1 | name: Danger
2 |
3 | on:
4 | push:
5 | branches: stable
6 | pull_request:
7 |
8 | jobs:
9 | check:
10 | name: Danger Check
11 | runs-on: ubuntu-latest
12 | steps:
13 | -
14 | name: Checkout
15 | uses: actions/checkout@v3
16 | -
17 | name: Setup Ruby
18 | uses: ruby/setup-ruby@v1
19 | with:
20 | bundler-cache: true
21 | -
22 | name: Run Danger
23 | run: bundle exec danger --verbose --dangerfile=rakelib/Dangerfile
24 | env:
25 | DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}
26 |
--------------------------------------------------------------------------------
/rakelib/lint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | PROJECT_DIR="${PROJECT_DIR:-`cd "$(dirname $0)/..";pwd`}"
4 | SWIFTLINT="${PROJECT_DIR}/.build/swiftlint/swiftlint"
5 | CONFIG="${PROJECT_DIR}/.swiftlint.yml"
6 | if [ $CI ]; then
7 | REPORTER="--reporter github-actions-logging"
8 | else
9 | REPORTER=
10 | fi
11 |
12 | # possible paths
13 | paths_sources="Sources/StencilSwiftKit"
14 | paths_tests="Tests/StencilSwiftKitTests"
15 |
16 | # load selected group
17 | if [ $# -gt 0 ]; then
18 | key="$1"
19 | else
20 | echo "error: need group to lint."
21 | exit 1
22 | fi
23 |
24 | selected_path=`eval echo '$'paths_$key`
25 | if [ -z "$selected_path" ]; then
26 | echo "error: need a valid group to lint."
27 | exit 1
28 | fi
29 |
30 | "$SWIFTLINT" lint --strict --config "$CONFIG" $REPORTER "${PROJECT_DIR}/${selected_path}"
31 |
--------------------------------------------------------------------------------
/Documentation/MigrationGuide.md:
--------------------------------------------------------------------------------
1 | # StencilSwiftKit 2.0 (SwiftGen 5.0) ##
2 |
3 | ## For template writers:
4 |
5 | * We've removed our `join` array filter as Stencil provides it's own version that accepts a parameter. If you were using StencilSwiftKit's version, replace instances of:
6 | ```{{ myArray|join }}```
7 | with:
8 | ```{{ myArray|join:", " }}```
9 | * We've refactored our `snakeToCamelCase` to accept arguments, thus removing the need for `snakeToCamelCaseNoPrefix`. If you were using the latter, replace instances of:
10 | ```{{ myValue|snakeToCamelCaseNoPrefix }}```
11 | with:
12 | ```{{ myValue|snakeToCamelCase:true }}```
13 |
14 | ## For developers using StencilSwiftKit as a dependency:
15 |
16 | We've removed the following deprecated `typealias`es:
17 |
18 | * `FilterError`: use `Filters.Error` instead.
19 | * `ParametersError`: use `Parameters.Error` instead.
20 | * `NumFilters`: use `Filters.Numbers` instead.
21 | * `StringFilters`: use `Filters.Strings` instead.
22 |
23 | The following functions have been renamed:
24 |
25 | * `stringToSwiftIdentifier` renamed to `swiftIdentifier`.
26 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | MIT Licence
2 |
3 | Copyright (c) 2022 SwiftGen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/tag-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish on Tag
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | cocoapods:
11 | name: Push To CocoaPods
12 | runs-on: ubuntu-latest
13 | steps:
14 | -
15 | name: Checkout
16 | uses: actions/checkout@v3
17 | -
18 | name: Set up Ruby
19 | uses: ruby/setup-ruby@v1
20 | with:
21 | bundler-cache: true
22 | -
23 | name: Push to CocoaPods
24 | run: bundle exec rake release:cocoapods
25 | env:
26 | COCOAPODS_TRUNK_TOKEN: ${{secrets.COCOAPODS_TRUNK_TOKEN}}
27 |
28 | github:
29 | name: GitHub Release
30 | runs-on: ubuntu-latest
31 | steps:
32 | -
33 | name: Checkout
34 | uses: actions/checkout@v3
35 | -
36 | name: Set up Ruby
37 | uses: ruby/setup-ruby@v1
38 | with:
39 | bundler-cache: true
40 | -
41 | name: Create release on GitHub
42 | run: bundle exec rake release:github
43 | env:
44 | DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}
45 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.0
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "StencilSwiftKit",
6 | products: [
7 | .library(name: "StencilSwiftKit", targets: ["StencilSwiftKit"])
8 | ],
9 | dependencies: [
10 | .package(url: "https://github.com/shibapm/Komondor.git", .exact("1.1.3")),
11 | .package(url: "https://github.com/stencilproject/Stencil.git", .upToNextMajor(from: "0.15.0"))
12 | ],
13 | targets: [
14 | .target(name: "StencilSwiftKit", dependencies: [
15 | "Stencil"
16 | ]),
17 | .testTarget(name: "StencilSwiftKitTests", dependencies: [
18 | "StencilSwiftKit"
19 | ])
20 | ],
21 | swiftLanguageVersions: [.v5]
22 | )
23 |
24 | #if canImport(PackageConfig)
25 | import PackageConfig
26 |
27 | let config = PackageConfiguration([
28 | "komondor": [
29 | "pre-commit": [
30 | "PATH=\"~/.rbenv/shims:$PATH\" bundler exec rake lint:code",
31 | "PATH=\"~/.rbenv/shims:$PATH\" bundler exec rake lint:tests"
32 | ],
33 | "pre-push": [
34 | "PATH=\"~/.rbenv/shims:$PATH\" bundle exec rake spm:test"
35 | ]
36 | ],
37 | ]).write()
38 | #endif
39 |
--------------------------------------------------------------------------------
/Documentation/filters-numbers.md:
--------------------------------------------------------------------------------
1 | # Filters
2 |
3 | This is a list of filters that are added by StencilSwiftKit on top of the filters already provided by Stencil (which you can [find here](http://stencil.fuller.li/en/latest/builtins.html#built-in-filters)).
4 |
5 | ## Filter: `int255toFloat`
6 |
7 | Accepts an integer and divides it by 255, resulting in a floating point number (usually) between 0.0 and 1.0.
8 |
9 | | Input | Output |
10 | |-------|---------|
11 | | 240 | 0.9412 |
12 | | 128 | 0.5019 |
13 |
14 | ## Filter: "hexToInt"
15 |
16 | Accepts a string with a number in hexadecimal format, and converts it into an integer number. Note that the string should NOT be prefixed with `0x`.
17 |
18 | | Input | Output |
19 | |----------|-----------|
20 | | FC | 252 |
21 | | fcFf | 64767 |
22 | | 01020304 | 16909060 |
23 | | 0x1234 | nil / "" |
24 |
25 | ## Filter: "percent"
26 |
27 | Accepts a floating point number and multiplies it by 100. The result is truncated into an integer, converted into a string and appended with the `%` character.
28 |
29 | | Input | Output |
30 | |--------|---------|
31 | | 0.23 | 23% |
32 | | 0.779 | 77% |
33 | | 1.234 | 123% |
34 |
--------------------------------------------------------------------------------
/StencilSwiftKit.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'StencilSwiftKit'
3 | s.version = '2.10.1'
4 | s.summary = 'Stencil additions dedicated for Swift code generation'
5 |
6 | s.description = <<-DESC
7 | This pod contains some additional nodes and filters for
8 | [Stencil](https://github.com/stencilproject/Stencil).
9 | These additional nodes & filters are mainly dedicated
10 | for writing Stencil templates generating *Swift* code.
11 | DESC
12 |
13 | s.homepage = 'https://github.com/SwiftGen/StencilSwiftKit'
14 | s.license = 'MIT'
15 | s.author = {
16 | 'Olivier Halligon' => 'olivier@halligon.net',
17 | 'David Jennes' => 'david.jennes@gmail.com'
18 | }
19 | s.social_media_url = 'https://twitter.com/aligatr'
20 |
21 | s.platform = :osx, '10.9'
22 | s.swift_versions = ['5.0']
23 | s.cocoapods_version = '>= 1.9.0'
24 |
25 | s.source = {
26 | git: 'https://github.com/SwiftGen/StencilSwiftKit.git',
27 | tag: s.version.to_s
28 | }
29 | s.source_files = 'Sources/**/*.swift'
30 |
31 | s.dependency 'Stencil', '~> 0.14.0'
32 | s.framework = 'Foundation'
33 | end
34 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/ImportNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Stencil
8 |
9 | internal final class ImportNode: NodeType {
10 | let templateName: Variable
11 | let token: Token?
12 |
13 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
14 | let components = token.components
15 | guard components.count == 2 else {
16 | throw TemplateSyntaxError("'import' tag requires one argument, the template file to be imported.")
17 | }
18 |
19 | return ImportNode(templateName: Variable(components[1]), token: token)
20 | }
21 |
22 | init(templateName: Variable, token: Token? = nil) {
23 | self.templateName = templateName
24 | self.token = token
25 | }
26 |
27 | func render(_ context: Context) throws -> String {
28 | guard let templateName = try self.templateName.resolve(context) as? String else {
29 | throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
30 | }
31 |
32 | let template = try context.environment.loadTemplate(name: templateName)
33 | _ = try template.render(context)
34 |
35 | // Import should never render anything
36 | return ""
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/rakelib/changelog.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # _none_
5 |
6 | require_relative 'check_changelog'
7 |
8 | namespace :changelog do
9 | desc 'Add the empty CHANGELOG entries after a new release'
10 | task :reset do
11 | changelog = File.read('CHANGELOG.md')
12 | abort('A Stable entry already exists') if changelog =~ /^##\s*Stable Branch$/
13 | changelog.sub!(/^##[^#]/, "#{header}\\0")
14 | File.write('CHANGELOG.md', changelog)
15 | end
16 |
17 | def header
18 | <<-HEADER.gsub(/^\s*\|/, '')
19 | |## Stable Branch
20 | |
21 | |### Breaking Changes
22 | |
23 | |_None_
24 | |
25 | |### New Features
26 | |
27 | |_None_
28 | |
29 | |### Bug Fixes
30 | |
31 | |_None_
32 | |
33 | |### Internal Changes
34 | |
35 | |_None_
36 | |
37 | HEADER
38 | end
39 |
40 | desc 'Check if links to issues and PRs use matching numbers between text & link'
41 | task :check do
42 | warnings = check_changelog
43 | if warnings.empty?
44 | puts "\u{2705} All entries seems OK (end with period + 2 spaces, correct links)"
45 | else
46 | puts "\u{274C} Some warnings were found:\n" + Array(warnings.map do |warning|
47 | " - Line #{warning[:line]}: #{warning[:message]}"
48 | end).join("\n")
49 | exit 1
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/rakelib/lint.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - BUILD_DIR
5 |
6 | namespace :lint do
7 | SWIFTLINT = 'rakelib/lint.sh'
8 | SWIFTLINT_VERSION = '0.48.0'
9 |
10 | task :install do |task|
11 | next if check_version
12 |
13 | if OS.mac?
14 | url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/portable_swiftlint.zip"
15 | else
16 | url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/swiftlint_linux.zip"
17 | end
18 | tmppath = '/tmp/swiftlint.zip'
19 | destination = "#{BUILD_DIR}/swiftlint"
20 |
21 | Utils.run([
22 | %(curl -Lo #{tmppath} #{url}),
23 | %(rm -rf #{destination}),
24 | %(mkdir -p #{destination}),
25 | %(unzip #{tmppath} -d #{destination})
26 | ], task)
27 | end
28 |
29 | desc 'Lint the code'
30 | task :code => :install do |task|
31 | Utils.print_header 'Linting the code'
32 | Utils.run(%(#{SWIFTLINT} sources), task)
33 | end
34 |
35 | desc 'Lint the tests'
36 | task :tests => :install do |task|
37 | Utils.print_header 'Linting the unit test code'
38 | Utils.run(%(#{SWIFTLINT} tests), task)
39 | end
40 |
41 | def check_version
42 | swiftlint = "#{BUILD_DIR}/swiftlint/swiftlint"
43 | return false unless File.executable?(swiftlint)
44 |
45 | current = `#{swiftlint} version`.chomp
46 | required = SWIFTLINT_VERSION.chomp
47 |
48 | current == required
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/ParseEnumTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import StencilSwiftKit
8 | import XCTest
9 |
10 | final class ParseEnumTests: XCTestCase {
11 | private enum Test: String {
12 | case foo
13 | case bar
14 | case baz
15 | }
16 |
17 | func testParseEnum_WithFooString() throws {
18 | let value = try Filters.parseEnum(from: ["foo"], default: Test.baz)
19 | XCTAssertEqual(value, Test.foo)
20 | }
21 |
22 | func testParseEnum_WithBarString() throws {
23 | let value = try Filters.parseEnum(from: ["bar"], default: Test.baz)
24 | XCTAssertEqual(value, Test.bar)
25 | }
26 |
27 | func testParseEnum_WithBazString() throws {
28 | let value = try Filters.parseEnum(from: ["baz"], default: Test.baz)
29 | XCTAssertEqual(value, Test.baz)
30 | }
31 |
32 | func testParseEnum_WithEmptyArray() throws {
33 | let value = try Filters.parseEnum(from: [], default: Test.baz)
34 | XCTAssertEqual(value, Test.baz)
35 | }
36 |
37 | func testParseEnum_WithNonZeroIndex() throws {
38 | let value = try Filters.parseEnum(from: [42, "bar"], at: 1, default: Test.baz)
39 | XCTAssertEqual(value, Test.bar)
40 | }
41 |
42 | func testParseEnum_WithUnknownArgument() throws {
43 | XCTAssertThrowsError(try Filters.parseEnum(from: ["test"], default: Test.baz))
44 | XCTAssertThrowsError(try Filters.parseEnum(from: [42], default: Test.baz))
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Documentation/tag-import.md:
--------------------------------------------------------------------------------
1 | # Tag: "Import"
2 |
3 | This tag loads and executes another template, but will **never** render any output. It's only purpose is for loading [macros](tag-macro.md) and [setting some variables](tag-set.md).
4 |
5 | ## Node Information
6 |
7 | | Name | Description |
8 | |-----------|------------------------|
9 | | Tag Name | `import` |
10 | | End Tag | N/A |
11 | | Rendering | Immediately; no output |
12 |
13 | | Parameter | Description |
14 | |------------|--------------------------------------------|
15 | | Template | The name of the template you want to load. |
16 |
17 | The template name will depend on how your environment's loader is configured:
18 |
19 | - If you're using a file loader, template should be a valid file name.
20 | - If you're using a dictionary loader, template should be a key in that dictionary.
21 |
22 | ## When to use it
23 |
24 | Should be used when you have some [macro](tag-macro.md)s or [set](tag-set.md)s that you want to reuse in multiple templates.
25 |
26 | Note that this tag may appear similar to the existing [include](https://stencil.fuller.li/en/latest/builtins.html#include) tag, but the purposes are opposite of each other:
27 |
28 | - `include` will render the included template, but never store changes to the context.
29 | - `import` will never render the imported template, but will store changes to the context.
30 |
31 | ## Usage example
32 |
33 | ```stencil
34 | {% import "common.stencil" %}
35 |
36 | {% call test "a" "b" "c" %}
37 | ```
38 |
39 | `common.stencil` file:
40 |
41 | ```stencil
42 | {% macro test a b c %}
43 | Received parameters in test:
44 | - a = "{{a}}"
45 | - b = "{{b}}"
46 | - c = "{{c}}"
47 | {% endmacro %}
48 | ```
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## Build generated
6 | build/
7 | DerivedData/
8 | Fixtures/stub-env/**/*.swiftmodule
9 |
10 | ## Various settings
11 | *.pbxuser
12 | !default.pbxuser
13 | *.mode1v3
14 | !default.mode1v3
15 | *.mode2v3
16 | !default.mode2v3
17 | *.perspectivev3
18 | !default.perspectivev3
19 | xcuserdata/
20 |
21 | ## Other
22 | *.moved-aside
23 | *.xcuserstate
24 | *.xcscmblueprint
25 |
26 | ## Obj-C/Swift specific
27 | *.hmap
28 | *.ipa
29 | *.dSYM.zip
30 | *.dSYM
31 |
32 | ## Playgrounds
33 | timeline.xctimeline
34 | playground.xcworkspace
35 |
36 | # Swift Package Manager
37 | #
38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
39 | Packages/
40 | .build/
41 |
42 | # CocoaPods
43 | #
44 | # We recommend against adding the Pods directory to your .gitignore. However
45 | # you should judge for yourself, the pros and cons are mentioned at:
46 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
47 | #
48 | # Pods/
49 |
50 | # Carthage
51 | #
52 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
53 | Carthage/Checkouts
54 | Carthage/Build
55 |
56 | # fastlane
57 | #
58 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
59 | # screenshots whenever they are needed.
60 | # For more information about the recommended setup visit:
61 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md
62 |
63 | fastlane/report.xml
64 | fastlane/Preview.html
65 | fastlane/screenshots
66 | fastlane/test_output
67 |
68 | # Other stuff
69 | .apitoken
70 | .DS_Store
71 | .idea/
72 | bin/
73 | Frameworks/
74 | Rome/
75 |
--------------------------------------------------------------------------------
/.github/workflows/test-spm.yml:
--------------------------------------------------------------------------------
1 | name: Test SPM
2 |
3 | on:
4 | push:
5 | branches: stable
6 | pull_request:
7 |
8 | jobs:
9 | linux:
10 | name: Test SPM Linux
11 | runs-on: ubuntu-latest
12 | container: swiftgen/swift:5.6
13 | steps:
14 | -
15 | name: Checkout
16 | uses: actions/checkout@v3
17 | -
18 | # Note: we can't use `ruby/setup-ruby` on custom docker images, so we
19 | # have to do our own caching
20 | name: Cache gems
21 | uses: actions/cache@v3
22 | with:
23 | path: vendor/bundle
24 | key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }}
25 | restore-keys: |
26 | ${{ runner.os }}-gems-
27 | -
28 | name: Cache SPM
29 | uses: actions/cache@v3
30 | with:
31 | path: .build
32 | key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
33 | restore-keys: |
34 | ${{ runner.os }}-spm-
35 | -
36 | name: Bundle install
37 | run: |
38 | bundle config path vendor/bundle
39 | bundle install --jobs 4 --retry 3
40 | -
41 | name: Run tests
42 | run: bundle exec rake spm:test
43 |
44 | macos:
45 | name: Test SPM macOS
46 | runs-on: macos-latest
47 | steps:
48 | -
49 | name: Checkout
50 | uses: actions/checkout@v3
51 | -
52 | name: Setup Ruby
53 | uses: ruby/setup-ruby@v1
54 | with:
55 | bundler-cache: true
56 | -
57 | name: Cache SPM
58 | uses: actions/cache@v3
59 | with:
60 | path: .build
61 | key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
62 | restore-keys: |
63 | ${{ runner.os }}-spm-
64 | -
65 | name: Run tests
66 | run: bundle exec rake spm:test
67 |
--------------------------------------------------------------------------------
/Documentation/tag-macro.md:
--------------------------------------------------------------------------------
1 | # Tag: "Macro"
2 |
3 | This tag stores an entire content tree into a variable to be evaluated and rendered later (possibly multiple times).
4 |
5 | This can be thought like defining a function or macro.
6 |
7 | ## Node Information
8 |
9 | | Name | Description |
10 | |-----------|-----------------------------------------------------------------|
11 | | Tag Name | `macro` |
12 | | End Tag | `endmacro` |
13 | | Rendering | None; content is stored unrendered in variable with block name |
14 |
15 | | Parameter | Description |
16 | |------------|-------------------------------------------|
17 | | Block Name | The name of the block you want to define. |
18 | | ... | A variable list of parameters (optional). |
19 |
20 | _Example:_ `{% macro myBlock name %}Hello {{name}}!{% endmacro %}`
21 |
22 |
23 | ## When to use it
24 |
25 | This node only works together with the `call` tag. The `macro` tag on itself renders nothing as its output, it only stores it's unrendered template contents in a variable on the stack to be called later.
26 |
27 | The parameters in the definition will be available as variables in the context during invocation. Do note that a `macro` block's execution is scoped, thus any changes to the context inside of it will not be available once execution leaves the block's scope.
28 |
29 | ## Usage example
30 |
31 | ```stencil
32 | {% macro hi name %}
33 | Hello, {{name}}! How are you?
34 | {% endmacro %}
35 |
36 | {% call hi Alice %}
37 | {% call hi Bob %}
38 | ```
39 |
40 | ```text
41 | Hello, Alice! How are you?
42 | Hello, Bob! How are you?
43 | ```
44 |
45 | See the documentation for the [call tag](tag-call.md) for a full and more complex usage example.
46 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/Filters+Numbers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 | import Stencil
9 |
10 | public extension Filters {
11 | /// Filters for operations related to numbers
12 | enum Numbers {
13 | /// Tries to parse the given `String` into an `Int` using radix 16.
14 | ///
15 | /// - Parameters:
16 | /// - value: the string value to parse
17 | /// - Returns: the parsed `Int`
18 | /// - Throws: Filters.Error.invalidInputType if the value parameter isn't a string
19 | public static func hexToInt(_ value: Any?) throws -> Any? {
20 | guard let value = value as? String else { throw Filters.Error.invalidInputType }
21 | return Int(value, radix: 16)
22 | }
23 |
24 | /// Tries to convert the given `Int` to a `Float` by dividing it by 255.
25 | ///
26 | /// - Parameters:
27 | /// - value: the `Int` value to convert
28 | /// - Returns: the convert `Float`
29 | /// - Throws: Filters.Error.invalidInputType if the value parameter isn't an integer
30 | public static func int255toFloat(_ value: Any?) throws -> Any? {
31 | guard let value = value as? Int else { throw Filters.Error.invalidInputType }
32 | return Float(value) / Float(255.0)
33 | }
34 |
35 | /// Tries to convert the given `Float` to a percentage string `…%`, after multiplying by 100.
36 | ///
37 | /// - Parameters:
38 | /// - value: the `Float` value to convert
39 | /// - Returns: the rendered `String`
40 | /// - Throws: Filters.Error.invalidInputType if the value parameter isn't a float
41 | public static func percent(_ value: Any?) throws -> Any? {
42 | guard let value = value as? Float else { throw Filters.Error.invalidInputType }
43 |
44 | let percent = Int(value * 100.0)
45 | return "\(percent)%"
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "Komondor",
6 | "repositoryURL": "https://github.com/shibapm/Komondor.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "90b087b1e39069684b1ff4bf915c2aae594f2d60",
10 | "version": "1.1.3"
11 | }
12 | },
13 | {
14 | "package": "PackageConfig",
15 | "repositoryURL": "https://github.com/shibapm/PackageConfig.git",
16 | "state": {
17 | "branch": null,
18 | "revision": "58523193c26fb821ed1720dcd8a21009055c7cdb",
19 | "version": "1.1.3"
20 | }
21 | },
22 | {
23 | "package": "PathKit",
24 | "repositoryURL": "https://github.com/kylef/PathKit.git",
25 | "state": {
26 | "branch": null,
27 | "revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
28 | "version": "1.0.1"
29 | }
30 | },
31 | {
32 | "package": "ShellOut",
33 | "repositoryURL": "https://github.com/JohnSundell/ShellOut.git",
34 | "state": {
35 | "branch": null,
36 | "revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568",
37 | "version": "2.3.0"
38 | }
39 | },
40 | {
41 | "package": "Spectre",
42 | "repositoryURL": "https://github.com/kylef/Spectre.git",
43 | "state": {
44 | "branch": null,
45 | "revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
46 | "version": "0.10.1"
47 | }
48 | },
49 | {
50 | "package": "Stencil",
51 | "repositoryURL": "https://github.com/stencilproject/Stencil.git",
52 | "state": {
53 | "branch": null,
54 | "revision": "8989f8a18998bd67b1727c2c0798b7c0436aa481",
55 | "version": "0.15.0"
56 | }
57 | }
58 | ]
59 | },
60 | "version": 1
61 | }
62 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/ImportNodeTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import Stencil
8 | @testable import StencilSwiftKit
9 | import XCTest
10 |
11 | final class ImportNodeTests: XCTestCase {
12 | func testParser() {
13 | let tokens: [Token] = [.block(value: "import \"Common.stencil\"", at: .unknown)]
14 |
15 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
16 | guard let nodes = try? parser.parse(),
17 | let node = nodes.first as? ImportNode else {
18 | XCTFail("Unable to parse tokens")
19 | return
20 | }
21 |
22 | XCTAssertEqual(node.templateName, Variable("\"Common.stencil\""))
23 | }
24 |
25 | func testParserFail() {
26 | do {
27 | let tokens: [Token] = [.block(value: "import", at: .unknown)]
28 |
29 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
30 | XCTAssertThrowsError(try parser.parse())
31 | }
32 | }
33 |
34 | func testRenderIsEmpty() throws {
35 | let node = ImportNode(templateName: Variable("\"Common.stencil\""))
36 | let env = stencilSwiftEnvironment(templates: ["Common.stencil": "Hello world!"])
37 | let context = Context(dictionary: ["": ""], environment: env)
38 | let output = try node.render(context)
39 |
40 | XCTAssertEqual(output, "")
41 | }
42 |
43 | func testContextModification() throws {
44 | let node = ImportNode(templateName: Variable("\"Common.stencil\""))
45 | let env = stencilSwiftEnvironment(templates: ["Common.stencil": "{% set x %}hello{% endset %}"])
46 | let context = Context(dictionary: ["": ""], environment: env)
47 | _ = try node.render(context)
48 |
49 | guard let string = context["x"] as? String else {
50 | XCTFail("Unable to render import token")
51 | return
52 | }
53 | XCTAssertEqual(string, "hello")
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/StencilSwiftTemplate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 | import Stencil
9 |
10 | #if os(Linux) && !swift(>=3.1)
11 | typealias NSRegularExpression = RegularExpression
12 | #endif
13 |
14 | // Workaround until Stencil fixes https://github.com/stencilproject/Stencil/issues/22
15 | @available(*, deprecated, message: "No longer needed with Stencil whitespace control features")
16 | open class StencilSwiftTemplate: Template {
17 | public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
18 | let templateStringWithMarkedNewlines = templateString
19 | .replacingOccurrences(of: "\n\n", with: "\n\u{000b}\n")
20 | .replacingOccurrences(of: "\n\n", with: "\n\u{000b}\n")
21 | super.init(templateString: templateStringWithMarkedNewlines, environment: environment, name: name)
22 | }
23 |
24 | // swiftlint:disable:next discouraged_optional_collection
25 | override open func render(_ dictionary: [String: Any]? = nil) throws -> String {
26 | try removeExtraLines(from: super.render(dictionary))
27 | }
28 |
29 | // Workaround until Stencil fixes https://github.com/stencilproject/Stencil/issues/22
30 | private func removeExtraLines(from str: String) -> String {
31 | let extraLinesRE: NSRegularExpression = {
32 | do {
33 | return try NSRegularExpression(pattern: "\\n([ \\t]*\\n)+", options: [])
34 | } catch {
35 | fatalError("Regular Expression pattern error: \(error)")
36 | }
37 | }()
38 | let compact = extraLinesRE.stringByReplacingMatches(
39 | in: str,
40 | options: [],
41 | range: NSRange(location: 0, length: str.utf16.count),
42 | withTemplate: "\n"
43 | )
44 | let unmarkedNewlines = compact
45 | .replacingOccurrences(of: "\n\u{000b}\n", with: "\n\n")
46 | .replacingOccurrences(of: "\n\u{000b}\n", with: "\n\n")
47 | return unmarkedNewlines
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/SetNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Stencil
8 |
9 | internal final class SetNode: NodeType {
10 | enum Content {
11 | case nodes([NodeType])
12 | case reference(resolvable: Resolvable)
13 | }
14 |
15 | let variableName: String
16 | let content: Content
17 | let token: Token?
18 |
19 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
20 | let components = token.components
21 | guard components.count <= 3 else {
22 | throw TemplateSyntaxError(
23 | """
24 | 'set' tag takes at least one argument (the name of the variable to set) \
25 | and optionally the value expression.
26 | """
27 | )
28 | }
29 |
30 | let variable = components[1]
31 | if components.count == 3 {
32 | // we have a value expression, no nodes
33 | let resolvable = try parser.compileResolvable(components[2], containedIn: token)
34 | return SetNode(variableName: variable, content: .reference(resolvable: resolvable))
35 | } else {
36 | // no value expression, parse until an `endset` node
37 | let setNodes = try parser.parse(until(["endset"]))
38 |
39 | guard parser.nextToken() != nil else {
40 | throw TemplateSyntaxError("`endset` was not found.")
41 | }
42 |
43 | return SetNode(variableName: variable, content: .nodes(setNodes), token: token)
44 | }
45 | }
46 |
47 | init(variableName: String, content: Content, token: Token? = nil) {
48 | self.variableName = variableName
49 | self.content = content
50 | self.token = token
51 | }
52 |
53 | func render(_ context: Context) throws -> String {
54 | switch content {
55 | case .nodes(let nodes):
56 | let result = try renderNodes(nodes, context)
57 | context[variableName] = result
58 | case .reference(let value):
59 | context[variableName] = try value.resolve(context)
60 | }
61 |
62 | return ""
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/ParseBoolTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import StencilSwiftKit
8 | import XCTest
9 |
10 | final class ParseBoolTests: XCTestCase {
11 | func testParseBool_TrueWithString() throws {
12 | XCTAssertEqual(try Filters.parseBool(from: ["true"]), .some(true))
13 | XCTAssertEqual(try Filters.parseBool(from: ["yes"]), .some(true))
14 | XCTAssertEqual(try Filters.parseBool(from: ["1"]), .some(true))
15 | }
16 |
17 | func testParseBool_FalseWithString() throws {
18 | XCTAssertEqual(try Filters.parseBool(from: ["false"]), .some(false))
19 | XCTAssertEqual(try Filters.parseBool(from: ["no"]), .some(false))
20 | XCTAssertEqual(try Filters.parseBool(from: ["0"]), .some(false))
21 | }
22 |
23 | func testParseBool_WithOptionalInt() throws {
24 | let value = try Filters.parseBool(from: [1], required: false)
25 | XCTAssertNil(value)
26 | }
27 |
28 | func testParseBool_WithRequiredInt() throws {
29 | XCTAssertThrowsError(try Filters.parseBool(from: [1], required: true))
30 | }
31 |
32 | func testParseBool_WithOptionalDouble() throws {
33 | let value = try Filters.parseBool(from: [1.0], required: false)
34 | XCTAssertNil(value)
35 | }
36 |
37 | func testParseBool_WithRequiredDouble() throws {
38 | XCTAssertThrowsError(try Filters.parseBool(from: [1.0], required: true))
39 | }
40 |
41 | func testParseBool_WithEmptyString() throws {
42 | XCTAssertThrowsError(try Filters.parseBool(from: [""], required: false))
43 | }
44 |
45 | func testParseBool_WithEmptyStringAndRequiredArg() throws {
46 | XCTAssertThrowsError(try Filters.parseBool(from: [""], required: true))
47 | }
48 |
49 | func testParseBool_WithEmptyArray() throws {
50 | let value = try Filters.parseBool(from: [], required: false)
51 | XCTAssertNil(value)
52 | }
53 |
54 | func testParseBool_WithEmptyArrayAndRequiredArg() throws {
55 | XCTAssertThrowsError(try Filters.parseBool(from: [], required: true))
56 | }
57 |
58 | func testParseBool_WithNonZeroIndex() throws {
59 | XCTAssertEqual(try Filters.parseBool(from: ["test", "true"], at: 1), .some(true))
60 | XCTAssertEqual(try Filters.parseBool(from: ["test", "false"], at: 1), .some(false))
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/Context.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | /// Helper for enriching a Stencil context with environment or parameters
10 | public enum StencilContext {
11 | /// Stencil context key where environment data will be set
12 | public static let environmentKey = "env"
13 | /// Stencil context key where parameter data will be set
14 | public static let parametersKey = "param"
15 |
16 | /// Enriches a stencil context with parsed parameters and environment variables
17 | ///
18 | /// - Parameters:
19 | /// - context: The stencil context to enrich
20 | /// - parameters: List of strings, will be parsed using the `Parameters.parse(items:)` method
21 | /// - environment: Environment variables, defaults to `ProcessInfo().environment`
22 | /// - Returns: The new Stencil context enriched with the parameters and env variables
23 | /// - Throws: `Parameters.Error`
24 | public static func enrich(
25 | context: [String: Any],
26 | parameters: [String],
27 | environment: [String: String] = ProcessInfo.processInfo.environment
28 | ) throws -> [String: Any] {
29 | let params = try Parameters.parse(items: parameters)
30 | return try enrich(context: context, parameters: params, environment: environment)
31 | }
32 |
33 | /// Enriches a stencil context with parsed parameters and environment variables
34 | ///
35 | /// - Parameters:
36 | /// - context: The stencil context to enrich
37 | /// - parameters: Dictionary of parameters. Can be structured in sub-dictionaries.
38 | /// - environment: Environment variables, defaults to `ProcessInfo().environment`
39 | /// - Returns: The new Stencil context enriched with the parameters and env variables
40 | /// - Throws: `Parameters.Error`
41 | public static func enrich(
42 | context: [String: Any],
43 | parameters: [String: Any],
44 | environment: [String: String] = ProcessInfo.processInfo.environment
45 | ) throws -> [String: Any] {
46 | var context = context
47 |
48 | context[environmentKey] = merge(context[environmentKey], with: environment)
49 | context[parametersKey] = merge(context[parametersKey], with: parameters)
50 |
51 | return context
52 | }
53 |
54 | private static func merge(_ lhs: Any?, with rhs: [String: Any]) -> [String: Any] {
55 | var result = lhs as? [String: Any] ?? [:]
56 |
57 | for (key, value) in rhs {
58 | result[key] = value
59 | }
60 |
61 | return result
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Documentation/tag-call.md:
--------------------------------------------------------------------------------
1 | # Tag: "Call"
2 |
3 | This tag _calls_ a macro previously defined using the [macro tag](tag-macro.md).
4 |
5 | ## Node Information
6 |
7 | | Name | Description |
8 | |-----------|-----------------------------------------------------------------|
9 | | Tag Name | `call` |
10 | | End Tag | N/A |
11 | | Rendering | Immediately; output is the rendering of the called macro block |
12 |
13 | | Parameter | Description |
14 | |------------|-----------------------------------------------------------|
15 | | Block Name | The name of the block you want to invoke. |
16 | | ... | A variable list of arguments, must match block definition |
17 |
18 | _Example:_ `{% call myBlock "Dave" %}`
19 |
20 | ## When to use it
21 |
22 | This node only works together with the `macro` tag. You must define a macro block first, using the [macro tag](tag-macro.md), before you can call the define block using this `call` tag.
23 |
24 | The number of arguments in a `call` invocation must match the number of parameters in a `macro` definition.
25 |
26 | _Note: In contrast to the `set` tag, the `call` and `macro` tags can be used for delayed execution of blocks. When the renderer encounters a `macro` block, it won't render it immediately, but instead it's contents are stored as a body of the block. When the renderer encounters a `call` tag, it will look up any matching block (by name), and will then invoke it using the context from the invocation point._
27 |
28 | Do note that, due to the delayed invocation, a `macro` block can contain `call` tags that invoke the `macro` block again, thus allowing for scenarios such as recursion.
29 |
30 | See the documentation for the [macro tag](tag-macro.md) for more information.
31 |
32 | ## Usage example
33 |
34 | ```stencil
35 | {# define test1 #}
36 | {% macro test1 %}
37 | Hello world! (inside test)
38 | {% endmacro %}
39 |
40 | {# define test2 #}
41 | {% macro test2 a b c %}
42 | Received parameters in test2:
43 | - a = "{{a}}"
44 | - b = "{{b}}"
45 | - c = "{{c}}"
46 |
47 | // calling test1
48 | {% call test1 %}
49 | {% endmacro %}
50 |
51 | {# calling test2 #}
52 | {% call test2 "hey" 123 "world"|capitalise %}
53 | ```
54 |
55 | Will output:
56 |
57 | ```text
58 | Received parameters in test2:
59 | - a = "hey"
60 | - b = "123"
61 | - c = "World"
62 |
63 | // calling test1
64 | Hello world! (inside test)
65 | ```
66 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/ContextTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import StencilSwiftKit
8 | import XCTest
9 |
10 | final class ContextTests: XCTestCase {
11 | func testEmpty() throws {
12 | let context = [String: Any]()
13 |
14 | let result = try StencilContext.enrich(
15 | context: context,
16 | parameters: [],
17 | environment: ["PATH": "foo:bar:baz"]
18 | )
19 | XCTAssertEqual(result.count, 2, "2 items have been added")
20 |
21 | guard let env = result[StencilContext.environmentKey] as? [String: Any] else {
22 | XCTFail("`env` should be a dictionary")
23 | return
24 | }
25 | XCTAssertEqual(env["PATH"] as? String, "foo:bar:baz")
26 |
27 | guard let params = result[StencilContext.parametersKey] as? [String: Any] else {
28 | XCTFail("`param` should be a dictionary")
29 | return
30 | }
31 | XCTAssertEqual(params.count, 0)
32 | }
33 |
34 | func testWithContext() throws {
35 | let context: [String: Any] = ["foo": "bar", "hello": true]
36 |
37 | let result = try StencilContext.enrich(
38 | context: context,
39 | parameters: [],
40 | environment: ["PATH": "foo:bar:baz"]
41 | )
42 | XCTAssertEqual(result.count, 4, "4 items have been added")
43 | XCTAssertEqual(result["foo"] as? String, "bar")
44 | XCTAssertEqual(result["hello"] as? Bool, true)
45 |
46 | guard let env = result[StencilContext.environmentKey] as? [String: Any] else {
47 | XCTFail("`env` should be a dictionary")
48 | return
49 | }
50 | XCTAssertEqual(env["PATH"] as? String, "foo:bar:baz")
51 |
52 | guard let params = result[StencilContext.parametersKey] as? [String: Any] else {
53 | XCTFail("`param` should be a dictionary")
54 | return
55 | }
56 | XCTAssertEqual(params.count, 0)
57 | }
58 |
59 | func testWithParameters() throws {
60 | let context = [String: Any]()
61 |
62 | let result = try StencilContext.enrich(
63 | context: context,
64 | parameters: ["foo=bar", "hello"],
65 | environment: ["PATH": "foo:bar:baz"]
66 | )
67 | XCTAssertEqual(result.count, 2, "2 items have been added")
68 |
69 | guard let env = result[StencilContext.environmentKey] as? [String: Any] else {
70 | XCTFail("`env` should be a dictionary")
71 | return
72 | }
73 | XCTAssertEqual(env["PATH"] as? String, "foo:bar:baz")
74 |
75 | guard let params = result[StencilContext.parametersKey] as? [String: Any] else {
76 | XCTFail("`param` should be a dictionary")
77 | return
78 | }
79 | XCTAssertEqual(params["foo"] as? String, "bar")
80 | XCTAssertEqual(params["hello"] as? Bool, true)
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/rakelib/check_changelog.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This analyze the CHANGELOG.md file and report warnings on its content
4 | #
5 | # It checks:
6 | # - if the description part of each entry ends with a period and two spaces
7 | # - that all links to PRs & issues with format [#nn](repo_url/nn) are consistent
8 | # (use the same number in the link title and URL)
9 | #
10 | # @return Array of Hashes with keys `:line` & `:message` for each element
11 | #
12 | def check_changelog
13 | current_repo = File.basename(`git remote get-url origin`.chomp, '.git').freeze
14 | slug_re = '([a-zA-Z]*/[a-zA-Z]*)'
15 | links = %r{\[#{slug_re}?\#([0-9]+)\]\(https://github.com/#{slug_re}/(issues|pull)/([0-9]+)\)}
16 | links_typos = %r{https://github.com/#{slug_re}/(issue|pulls)/([0-9]+)}
17 |
18 | all_warnings = []
19 | inside_entry = false
20 | last_line_has_correct_ending = false
21 |
22 | File.readlines('CHANGELOG.md').each_with_index do |line, idx|
23 | line.chomp! # Remove \n the end, it's easier for checks below
24 | was_inside_entry = inside_entry
25 | just_started_new_entry = line.start_with?('* ')
26 | inside_entry = true if just_started_new_entry
27 | inside_entry = false if /^ \[.*\]\(.*\)$/ =~ line # link-only line
28 |
29 | if was_inside_entry && !inside_entry && !last_line_has_correct_ending
30 | # We just ended an entry's description by starting the links, but description didn't end with '. '
31 | # Note: entry descriptions can be on multiple lines, hence the need to wait for the next line
32 | # to not be inside an entry to be able to consider the previous line as the end of entry description.
33 | all_warnings.concat [
34 | { line: idx, message: 'Line describing your entry should end with a period and 2 spaces.' }
35 | ]
36 | end
37 | # Store if current line has correct ending, for next iteration, so that if the next line isn't
38 | # part of the entry description, we can check if previous line ends description correctly.
39 | # Also, lines just linking to CHANGELOG to other repositories (StencilSwiftKit & Stencil mainly)
40 | # should be considered as not needing the '. ' ending.
41 | last_line_has_correct_ending = line.end_with?('. ') || line.end_with?('/CHANGELOG.md)')
42 |
43 | # Now, check that links [#nn](.../nn) have matching numbers in link title & URL
44 | wrong_links = line.scan(links).reject do |m|
45 | slug = m[0] || "SwiftGen/#{current_repo}"
46 | (slug == m[2]) && (m[1] == m[4])
47 | end
48 | all_warnings.concat Array(wrong_links.map do |m|
49 | link_text = "#{m[0]}##{m[1]}"
50 | link_url = "#{m[2]}##{m[4]}"
51 | { line: idx + 1, message: "Link text is #{link_text} but links points to #{link_url}." }
52 | end)
53 |
54 | # Flag common typos in GitHub issue/PR URLs
55 | typo_links = line.scan(links_typos)
56 | all_warnings.concat Array(typo_links.map do |_|
57 | { line: idx + 1, message: 'This looks like a GitHub link URL with a typo. Issue links should use `/issues/123` (plural) and PR links should use `/pull/123` (singular).' }
58 | end)
59 | end
60 | all_warnings
61 | end
62 |
--------------------------------------------------------------------------------
/Documentation/tag-map.md:
--------------------------------------------------------------------------------
1 | # Tag: "Map"
2 |
3 | This tag iterates over an array, transforming each element, and storing the resulting array into a variable for later use.
4 |
5 | ## Node Information
6 |
7 | | Name | Description |
8 | |-----------|-----------------------------------------------------------------|
9 | | Tag Name | `map` |
10 | | End Tag | `endmap` |
11 | | Rendering | Immediately; no output |
12 |
13 | | Parameter | Description |
14 | |-------------|--------------------------------------------------------------------|
15 | | Array Name | The name of the array you want to transform. |
16 | | Result Name | The name of the variable you want to store into. |
17 | | Item Name | Optional; name of the variable for accessing the iteration's value |
18 |
19 | _Example:_ `{% map myArray into myNewArray using myItem %}...{% endmap %}`
20 |
21 | ## When to use it
22 |
23 | Handy when you have an array of items, but want to trasform them before applying other operations on the whole collection. For example, you can easily use this node to map an array of strings so that they're all uppercase and preprended with their index number in the collection. You can then join the resulting array into a string using the `join` filter.
24 |
25 | You must at least provide the name of the variable you're going to transform, and the name of the variable to store into. The block between the map/endmap tags will be executed once for each array item. Optionally you can provide a name for the variable of the iteration's element, that will be available during the block's execution. If you don't provide an item name, you can always access it using the `maploop` context variable.
26 |
27 | The `maploop` context variable is available during each iteration, similar to the `forloop` variable when using the `for` node. It contains the following properties:
28 | - `counter`: the current iteration of the loop.
29 | - `first`: true if this is the first time through the loop.
30 | - `last`: true if this is the last time through the loop.
31 | - `item`: the array item for this iteration.
32 |
33 | Keep in mind that, similar to the [set tag](tag-set.md), the result variable is scoped, meaning that if you set a variable while (for example) inside a `macro` call, the set variable will not exist outside the scope of that call.
34 |
35 | ## Usage example
36 |
37 | ```stencil
38 | // we start with 'list' with as value ['a', 'b', 'c']
39 |
40 | // map the list without item name
41 | {% map list into result1 %}{{maploop.item|uppercase}}{% endmap %}
42 | // result1 = ['A', 'B', 'C']
43 |
44 | // map with item name
45 | {% map list into result2 using item %}{{item}}{{item|uppercase}}{% endmap %}
46 | // result2 = ['aA', 'bB', 'cC']
47 |
48 | // map using the counter variable
49 | {% map list into result3 using item %}{{maploop.counter}} - {{item}}{% endmap %}
50 | // result3 = ['0 - a', '1 - b', '2 - c']
51 | ```
52 |
--------------------------------------------------------------------------------
/rakelib/release.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - BUILD_DIR
5 |
6 | def first_match_in_file(file, regexp)
7 | File.foreach(file) do |line|
8 | m = regexp.match(line)
9 | return m if m
10 | end
11 | end
12 |
13 | ## [ Release a new version ] ##################################################
14 |
15 | namespace :release do
16 | desc 'Create a new release on CocoaPods'
17 | task :new => [:check_versions, :check_tag_and_ask_to_release, 'spm:test', :github, :cocoapods]
18 |
19 | desc 'Check if all versions from the podspecs and CHANGELOG match'
20 | task :check_versions do
21 | results = []
22 |
23 | Utils.table_header('Check', 'Status')
24 |
25 | # Check if bundler is installed first, as we'll need it for the cocoapods task (and we prefer to fail early)
26 | `which bundler`
27 | results << Utils.table_result(
28 | $CHILD_STATUS.success?,
29 | 'Bundler installed',
30 | 'Please install bundler using `gem install bundler` and run `bundle install` first.'
31 | )
32 |
33 | # Extract version from podspec
34 | podspec_version = Utils.podspec_version(POD_NAME)
35 | Utils.table_info("#{POD_NAME}.podspec", podspec_version)
36 |
37 | # Check if entry present in CHANGELOG
38 | changelog_entry = first_match_in_file('CHANGELOG.md', /^## #{Regexp.quote(podspec_version)}$/)
39 | results << Utils.table_result(
40 | changelog_entry,
41 | 'CHANGELOG, Entry added',
42 | "Please add an entry for #{podspec_version} in CHANGELOG.md"
43 | )
44 |
45 | changelog_has_stable = system("grep -qi '^## Stable Branch' CHANGELOG.md")
46 | results << Utils.table_result(
47 | !changelog_has_stable,
48 | 'CHANGELOG, No stable',
49 | 'Please remove section for stable branch in CHANGELOG'
50 | )
51 |
52 | exit 1 unless results.all?
53 | end
54 |
55 | desc "Check tag and ask to release"
56 | task :check_tag_and_ask_to_release do
57 | results = []
58 | podspec_version = Utils.podspec_version(POD_NAME)
59 |
60 | tag_set = !`git ls-remote --tags . refs/tags/#{podspec_version}`.empty?
61 | results << Utils.table_result(
62 | tag_set,
63 | 'Tag pushed',
64 | 'Please create a tag and push it'
65 | )
66 |
67 | exit 1 unless results.all?
68 |
69 | print "Release version #{podspec_version} [Y/n]? "
70 | exit 2 unless STDIN.gets.chomp == 'Y'
71 | end
72 |
73 | desc "Create a new GitHub release"
74 | task :github do
75 | require 'octokit'
76 |
77 | client = Utils.octokit_client
78 | tag = Utils.top_changelog_version
79 | body = Utils.top_changelog_entry
80 |
81 | raise 'Must be a valid version' if tag == 'Stable Branch'
82 |
83 | repo_name = File.basename(`git remote get-url origin`.chomp, '.git').freeze
84 | puts "Pushing release notes for tag #{tag}"
85 | client.create_release("SwiftGen/#{repo_name}", tag, name: tag, body: body)
86 | end
87 |
88 | desc "pod trunk push #{POD_NAME} to CocoaPods"
89 | task :cocoapods do
90 | Utils.print_header 'Pushing pod to CocoaPods Trunk'
91 | sh "bundle exec pod trunk push #{POD_NAME}.podspec"
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/MapNode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Stencil
8 |
9 | internal final class MapNode: NodeType {
10 | let resolvable: Resolvable
11 | let resultName: String
12 | let mapVariable: String?
13 | let nodes: [NodeType]
14 | let token: Token?
15 |
16 | class func parse(parser: TokenParser, token: Token) throws -> NodeType {
17 | let components = token.components
18 |
19 | func hasToken(_ token: String, at index: Int) -> Bool {
20 | components.indices ~= index + 1 && components[index] == token
21 | }
22 |
23 | func endsOrHasToken(_ token: String, at index: Int) -> Bool {
24 | components.count == index || hasToken(token, at: index)
25 | }
26 |
27 | guard hasToken("into", at: 2) && endsOrHasToken("using", at: 4) else {
28 | throw TemplateSyntaxError(
29 | """
30 | 'map' statements should use the following 'map {array} into \
31 | {varname} [using {element}]'.
32 | """
33 | )
34 | }
35 |
36 | let resolvable = try parser.compileResolvable(components[1], containedIn: token)
37 | let resultName = components[3]
38 | let mapVariable = hasToken("using", at: 4) ? components[5] : nil
39 |
40 | let mapNodes = try parser.parse(until(["endmap", "empty"]))
41 |
42 | guard let token = parser.nextToken() else {
43 | throw TemplateSyntaxError("`endmap` was not found.")
44 | }
45 |
46 | if token.contents == "empty" {
47 | _ = parser.nextToken()
48 | }
49 |
50 | return MapNode(
51 | resolvable: resolvable,
52 | resultName: resultName,
53 | mapVariable: mapVariable,
54 | nodes: mapNodes,
55 | token: token
56 | )
57 | }
58 |
59 | init(resolvable: Resolvable, resultName: String, mapVariable: String?, nodes: [NodeType], token: Token? = nil) {
60 | self.resolvable = resolvable
61 | self.resultName = resultName
62 | self.mapVariable = mapVariable
63 | self.nodes = nodes
64 | self.token = token
65 | }
66 |
67 | func render(_ context: Context) throws -> String {
68 | let values = try resolvable.resolve(context)
69 |
70 | if let values = values as? [Any], !values.isEmpty {
71 | let mappedValues: [String] = try values.enumerated().map { index, item in
72 | let mapContext = self.context(values: values, index: index, item: item)
73 |
74 | return try context.push(dictionary: mapContext) {
75 | try renderNodes(nodes, context)
76 | }
77 | }
78 | context[resultName] = mappedValues
79 | }
80 |
81 | // Map should never render anything
82 | return ""
83 | }
84 |
85 | func context(values: [Any], index: Int, item: Any) -> [String: Any] {
86 | var result: [String: Any] = [
87 | "maploop": [
88 | "counter": index,
89 | "first": index == 0,
90 | "last": index == (values.count - 1),
91 | "item": item
92 | ]
93 | ]
94 |
95 | if let mapVariable = mapVariable {
96 | result[mapVariable] = item
97 | }
98 |
99 | return result
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/ParseStringTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import StencilSwiftKit
8 | import XCTest
9 |
10 | final class ParseStringTests: XCTestCase {
11 | private struct TestLosslessConvertible: LosslessStringConvertible {
12 | static let stringRepresentation = "TestLosslessConvertibleStringRepresentation"
13 |
14 | var description: String {
15 | Self.stringRepresentation
16 | }
17 |
18 | init() {}
19 | init?(_ description: String) {}
20 | }
21 |
22 | private struct TestConvertible: CustomStringConvertible {
23 | static let stringRepresentation = "TestConvertibleStringRepresentation"
24 |
25 | var description: String {
26 | Self.stringRepresentation
27 | }
28 | }
29 |
30 | private struct TestNotConvertible {}
31 |
32 | // swiftlint:disable legacy_objc_type
33 | func testParseString_FromValue_WithNSStringValue() throws {
34 | let value = try Filters.parseString(from: NSString(string: "foo"))
35 | XCTAssertEqual(value, "foo")
36 | }
37 | // swiftlint:enable legacy_objc_type
38 |
39 | func testParseString_FromValue_WithStringValue() throws {
40 | let value = try Filters.parseString(from: "foo")
41 | XCTAssertEqual(value, "foo")
42 | }
43 |
44 | func testParseString_FromValue_WithNil() throws {
45 | XCTAssertThrowsError(try Filters.parseString(from: nil))
46 | }
47 |
48 | func testParseString_FromValue_WithStringLosslessConvertableArgument() throws {
49 | let value = try Filters.parseString(from: TestLosslessConvertible())
50 | XCTAssertEqual(value, TestLosslessConvertible.stringRepresentation)
51 | }
52 |
53 | func testParseString_FromValue_WithStringConvertableArgument() throws {
54 | XCTAssertThrowsError(try Filters.parseString(from: TestConvertible()))
55 | }
56 |
57 | func testParseString_FromValue_WithNonStringConvertableArgument() throws {
58 | XCTAssertThrowsError(try Filters.parseString(from: TestNotConvertible()))
59 | }
60 |
61 | func testParseStringArgument_WithStringArgument() throws {
62 | let value = try Filters.parseStringArgument(from: ["foo"])
63 | XCTAssertEqual(value, "foo")
64 | }
65 |
66 | func testParseStringArgument_WithStringLosslessConvertableArgument() throws {
67 | let value = try Filters.parseStringArgument(from: [TestLosslessConvertible()])
68 | XCTAssertEqual(value, TestLosslessConvertible.stringRepresentation)
69 | }
70 |
71 | func testParseStringArgument_WithStringConvertableArgument() throws {
72 | XCTAssertThrowsError(try Filters.parseStringArgument(from: [TestConvertible()]))
73 | }
74 |
75 | func testParseStringArgument_WithNonStringConvertableArgument() throws {
76 | XCTAssertThrowsError(try Filters.parseStringArgument(from: [TestNotConvertible()]))
77 | }
78 |
79 | func testParseStringArgument_WithEmptyArray() throws {
80 | XCTAssertThrowsError(try Filters.parseStringArgument(from: []))
81 | }
82 |
83 | func testParseStringArgument_WithNonZeroIndex() throws {
84 | let arguments = [TestNotConvertible(), TestLosslessConvertible(), TestConvertible()] as [Any]
85 | let value = try Filters.parseStringArgument(from: arguments, at: 1)
86 | XCTAssertEqual(value, TestLosslessConvertible.stringRepresentation)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/rakelib/Dangerfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative 'check_changelog'
4 |
5 | is_release = github.branch_for_head.start_with?('release/')
6 | is_hotfix = github.branch_for_head.start_with?('hotfix/')
7 |
8 | ################################################
9 | # Welcome message
10 | markdown [
11 | "Hey 👋 I'm Eve, the friendly bot watching over SwiftGen 🤖",
12 | 'Thanks a lot for your contribution!',
13 | '', '---', ''
14 | ]
15 |
16 | need_fixes = []
17 |
18 | ################################################
19 | # Make it more obvious that a PR is a work in progress and shouldn't be merged yet
20 | warn('PR is classed as Work in Progress') if github.pr_title.include? '[WIP]'
21 |
22 | # Warn when there is a big PR
23 | warn('Big PR') if git.lines_of_code > 500 && !is_release
24 |
25 | ################################################
26 | # Check for correct base branch
27 | if is_release
28 | message('This is a Release PR')
29 |
30 | require 'open3'
31 |
32 | stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'changelog:check')
33 | markdown [
34 | '',
35 | '### ChangeLog check',
36 | '',
37 | stdout
38 | ]
39 | need_fixes << fail('Please fix the CHANGELOG errors') unless status.success?
40 |
41 | stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'release:check_versions')
42 | markdown [
43 | '',
44 | '### Release version check',
45 | '',
46 | stdout
47 | ]
48 | need_fixes << fail('Please fix the versions inconsistencies') unless status.success?
49 | elsif is_hotfix
50 | message('This is a Hotfix PR')
51 | end
52 |
53 | ################################################
54 | # Check for a CHANGELOG entry
55 | declared_trivial = github.pr_title.include? '#trivial'
56 | has_changelog = git.modified_files.include?('CHANGELOG.md')
57 | changelog_msg = ''
58 | unless has_changelog || declared_trivial
59 | repo_url = github.pr_json['head']['repo']['html_url']
60 | pr_title = github.pr_title
61 | pr_title += '.' unless pr_title.end_with?('.')
62 | pr_number = github.pr_json['number']
63 | pr_url = github.pr_json['html_url']
64 | pr_author = github.pr_author
65 | pr_author_url = "https://github.com/#{pr_author}"
66 |
67 | need_fixes = fail("Please include a CHANGELOG entry to credit your work. \nYou can find it at [CHANGELOG.md](#{repo_url}/blob/#{github.branch_for_head}/CHANGELOG.md).")
68 |
69 | changelog_msg = <<-CHANGELOG_FORMAT.gsub(/^ *\|/, '')
70 | |📝 We use the following format for CHANGELOG entries:
71 | |```
72 | |* #{pr_title}
73 | | [##{pr_number}](#{pr_url})
74 | | [@#{pr_author}](#{pr_author_url})
75 | |```
76 | |:bulb: Don't forget to end the line describing your changes by a period and two spaces.
77 | CHANGELOG_FORMAT
78 | # changelog_msg is printed during the "Encouragement message" section, see below
79 | end
80 |
81 | changelog_warnings = check_changelog
82 | unless changelog_warnings.empty?
83 | need_fixes << warn('Found some warnings in CHANGELOG.md')
84 | changelog_warnings.each do |warning|
85 | warn(warning[:message], file: 'CHANGELOG.md', line: warning[:line])
86 | end
87 | end
88 |
89 | ################################################
90 | # Encouragement message
91 | if need_fixes.empty?
92 | markdown('Seems like everything is in order 👍 You did a good job here! 🤝')
93 | else
94 | markdown('Once you fix those tiny nitpickings above, we should be good to go! 🙌')
95 | markdown(changelog_msg) unless changelog_msg.empty?
96 | markdown('ℹ️ _I will update this comment as you add new commits_')
97 | end
98 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | swiftlint_version: 0.48.0
2 |
3 | opt_in_rules:
4 | - accessibility_label_for_image
5 | - anonymous_argument_in_multiline_closure
6 | - anyobject_protocol
7 | - array_init
8 | - attributes
9 | - balanced_xctest_lifecycle
10 | - closure_body_length
11 | - closure_end_indentation
12 | - closure_spacing
13 | - collection_alignment
14 | - comment_spacing
15 | - conditional_returns_on_newline
16 | - contains_over_filter_count
17 | - contains_over_filter_is_empty
18 | - contains_over_first_not_nil
19 | - contains_over_range_nil_comparison
20 | - convenience_type
21 | - discarded_notification_center_observer
22 | - discouraged_assert
23 | - discouraged_none_name
24 | - discouraged_optional_boolean
25 | - discouraged_optional_collection
26 | - empty_collection_literal
27 | - empty_count
28 | - empty_string
29 | - empty_xctest_method
30 | - enum_case_associated_values_count
31 | - fallthrough
32 | - fatal_error_message
33 | - file_header
34 | - first_where
35 | - flatmap_over_map_reduce
36 | - force_unwrapping
37 | - ibinspectable_in_extension
38 | - identical_operands
39 | - implicit_return
40 | - implicitly_unwrapped_optional
41 | - inclusive_language
42 | - indentation_width
43 | - joined_default_parameter
44 | - last_where
45 | - legacy_multiple
46 | - legacy_objc_type
47 | - legacy_random
48 | - literal_expression_end_indentation
49 | - lower_acl_than_parent
50 | - missing_docs
51 | - modifier_order
52 | - multiline_arguments
53 | - multiline_arguments_brackets
54 | - multiline_function_chains
55 | - multiline_literal_brackets
56 | - multiline_parameters
57 | - multiline_parameters_brackets
58 | - nslocalizedstring_key
59 | - nslocalizedstring_require_bundle
60 | - number_separator
61 | - operator_usage_whitespace
62 | - optional_enum_case_matching
63 | - overridden_super_call
64 | - override_in_extension
65 | - prefer_self_in_static_references
66 | - prefer_self_type_over_type_of_self
67 | - prefer_zero_over_explicit_init
68 | - prefixed_toplevel_constant
69 | - private_action
70 | - private_outlet
71 | - private_subject
72 | - prohibited_super_call
73 | - raw_value_for_camel_cased_codable_enum
74 | - reduce_into
75 | - redundant_nil_coalescing
76 | - redundant_type_annotation
77 | - required_enum_case
78 | - return_value_from_void_function
79 | - single_test_class
80 | - sorted_first_last
81 | - sorted_imports
82 | - static_operator
83 | - strict_fileprivate
84 | - strong_iboutlet
85 | - switch_case_on_newline
86 | - test_case_accessibility
87 | - toggle_bool
88 | - trailing_closure
89 | - unavailable_function
90 | - unneeded_parentheses_in_closure_argument
91 | - unowned_variable_capture
92 | - unused_closure_parameter
93 | - vertical_parameter_alignment_on_call
94 | - vertical_whitespace_closing_braces
95 | - vertical_whitespace_opening_braces
96 | - void_function_in_ternary
97 | - weak_delegate
98 | - xct_specific_matcher
99 | - yoda_condition
100 |
101 | # Rules customization
102 | conditional_returns_on_newline:
103 | if_only: true
104 |
105 | file_header:
106 | required_pattern: |
107 | \/\/
108 | \/\/ StencilSwiftKit( UnitTests)?
109 | \/\/ Copyright © 2022 SwiftGen
110 | \/\/ MIT Licence
111 | \/\/
112 |
113 | indentation_width:
114 | indentation_width: 2
115 |
116 | line_length:
117 | warning: 120
118 | error: 200
119 |
120 | nesting:
121 | type_level:
122 | warning: 2
123 |
--------------------------------------------------------------------------------
/Documentation/tag-set.md:
--------------------------------------------------------------------------------
1 | # Tag: "Set"
2 |
3 | This tag stores a value into a variable for later use.
4 |
5 | ## Node Information
6 |
7 | | Name | Description |
8 | |-----------|------------------------------------------------------------------------|
9 | | Tag Name | `set` |
10 | | End Tag | `endset` or N/A (if you're creating an alias of an existing variable) |
11 | | Rendering | Immediately; no output |
12 |
13 | | Parameter | Description |
14 | |------------|---------------------------------------------|
15 | | Name | The name of the variable you want to store. |
16 | | Expression | (Optional) The value to be stored. |
17 |
18 | The parameters and tags of this node depend on which mode you want to use:
19 |
20 | - Use `{% set myVar %}...{% endset %}` to render and store everything between the start and end tag into the variable.
21 | - Use `{% set myVar someOtherVar.prop1.prop2 %}` to evaluate and store an expression's result into the variable.
22 |
23 | _Example of render:_ `{% set myVar %}hello{% endset %}`
24 |
25 | _Example of evaluate:_ `{% set myVar2 myVar|uppercase %}`
26 |
27 | ## When to use it
28 |
29 | Useful when you have a certain calculation you want to re-use in multiple places without repeating yourself. For example you can compute only once the result of multiple filters applied in sequence to a variable, store that result and reuse it later.
30 |
31 | This tag can be used in 2 ways:
32 |
33 | - **Render**: the content between the the `set` and `endset` tags is rendered immediately using the available context, and stored on the stack into a variable with the provided name.
34 | - **Evaluate**: the provided expression is evaluated and stored into a variable with the provided name. This is especially useful if you want to avoid the conversion of contents to a String value (which *render* mode always does).
35 |
36 | Keep in mind that the variable is scoped, meaning that if you set a variable while (for example) inside a for loop, the set variable will not exist outside the scope of that for loop.
37 |
38 | ## Usage example
39 |
40 | ```stencil
41 | // we start with 'x' and 'y' as empty variables
42 | // 'items' is an array of integers in the context: [1, 3, 7]
43 |
44 | // set value
45 | {% set x %}hello{% endset %}
46 | {% set y %}world{% endset %}
47 | // x = "hello", y = "world"
48 |
49 | // Compute some complex expression once, and reuse it multiple times later
50 | {% set greetings %}{{ x|uppercase }}, {{ y|titlecase }}{% endset %}
51 | // greetings = "HELLO, World"
52 |
53 | // set inside for loop
54 | {% for item in items %}
55 | {% set x %}item #{{item}}{% endset %}
56 | // x = "item #...", y = "world"
57 | // greetings is still = "HELLO, World" (it isn't recomputed with new x)
58 | {% endfor %}
59 |
60 | // after for loop
61 | // x = "hello", y = "world"
62 |
63 | {{ greetings }}, {{ greetings }}, {{ greetings }}!
64 | // HELLO World, HELLO World, HELLO World!
65 |
66 | // Difference between render and evaluate:
67 |
68 | {% set a %}{{items}}{% endset %}
69 | {% set b items %}
70 | // a = "[1, 3, 7]", b = [1, 3, 7]
71 | // a is contains a string (the description of 'items')
72 | // b contains an array, the same value as 'items'
73 |
74 | // This will print every character in the string "[1, 3, 7]"
75 | {% for item in a %}
76 | item = {{item}}
77 | {% endfor %}
78 |
79 | // This will print every item of the array [1, 3, 7]
80 | {% for item in b %}
81 | item = {{item}}
82 | {% endfor %}
83 | ```
84 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/CallMacroNodes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Stencil
8 |
9 | internal struct CallableBlock {
10 | let parameters: [String]
11 | let nodes: [NodeType]
12 |
13 | init(parameters: [String], nodes: [NodeType], token: Token? = nil) {
14 | self.parameters = parameters
15 | self.nodes = nodes
16 | }
17 |
18 | func context(_ context: Context, arguments: [Resolvable], variable: Variable) throws -> [String: Any] {
19 | guard parameters.count == arguments.count else {
20 | throw TemplateSyntaxError(
21 | """
22 | Block '\(variable.variable)' accepts \(parameters.count) parameters, \
23 | \(arguments.count) given.
24 | """
25 | )
26 | }
27 |
28 | var result = [String: Any]()
29 | for (parameter, argument) in zip(parameters, arguments) {
30 | result[parameter] = try argument.resolve(context)
31 | }
32 |
33 | return result
34 | }
35 | }
36 |
37 | internal final class MacroNode: NodeType {
38 | let variableName: String
39 | let parameters: [String]
40 | let nodes: [NodeType]
41 | let token: Token?
42 |
43 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
44 | let components = token.components
45 | guard components.count >= 2 else {
46 | throw TemplateSyntaxError("'macro' tag takes at least one argument, the name of the variable to set.")
47 | }
48 | let variable = components[1]
49 | let parameters = Array(components.dropFirst(2))
50 |
51 | let setNodes = try parser.parse(until(["endmacro"]))
52 | guard parser.nextToken() != nil else {
53 | throw TemplateSyntaxError("`endmacro` was not found.")
54 | }
55 |
56 | return MacroNode(variableName: variable, parameters: parameters, nodes: setNodes, token: token)
57 | }
58 |
59 | init(variableName: String, parameters: [String], nodes: [NodeType], token: Token? = nil) {
60 | self.variableName = variableName
61 | self.parameters = parameters
62 | self.nodes = nodes
63 | self.token = token
64 | }
65 |
66 | func render(_ context: Context) throws -> String {
67 | let result = CallableBlock(parameters: parameters, nodes: nodes, token: token)
68 | context[variableName] = result
69 | return ""
70 | }
71 | }
72 |
73 | internal final class CallNode: NodeType {
74 | let variable: Variable
75 | let arguments: [Resolvable]
76 | let token: Token?
77 |
78 | class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
79 | let components = token.components
80 | guard components.count >= 2 else {
81 | throw TemplateSyntaxError("'call' tag takes at least one argument, the name of the block to call.")
82 | }
83 |
84 | let variable = Variable(components[1])
85 | let arguments = try Array(components.dropFirst(2)).map { part in
86 | try parser.compileResolvable(part, containedIn: token)
87 | }
88 |
89 | return CallNode(variable: variable, arguments: arguments, token: token)
90 | }
91 |
92 | init(variable: Variable, arguments: [Resolvable], token: Token? = nil) {
93 | self.variable = variable
94 | self.arguments = arguments
95 | self.token = token
96 | }
97 |
98 | func render(_ context: Context) throws -> String {
99 | guard let block = try variable.resolve(context) as? CallableBlock else {
100 | throw TemplateSyntaxError("Call to undefined block '\(variable.variable)'.")
101 | }
102 | let blockContext = try block.context(context, arguments: arguments, variable: variable)
103 |
104 | return try context.push(dictionary: blockContext) {
105 | try renderNodes(block.nodes, context)
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/StencilSwiftKit.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
49 |
55 |
56 |
57 |
58 |
59 |
69 |
70 |
76 |
77 |
83 |
84 |
85 |
86 |
88 |
89 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/Filters.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 | import Stencil
9 |
10 | /// Namespace for filters
11 | public enum Filters {
12 | typealias BooleanWithArguments = (Any?, [Any?]) throws -> Bool
13 |
14 | /// Possible filter errors
15 | public enum Error: Swift.Error {
16 | case invalidInputType
17 | case invalidOption(option: String)
18 | }
19 |
20 | /// Parses filter input value for a string value, where accepted objects must conform to
21 | /// `CustomStringConvertible`
22 | ///
23 | /// - Parameters:
24 | /// - value: an input value, may be nil
25 | /// - Throws: Filters.Error.invalidInputType
26 | public static func parseString(from value: Any?) throws -> String {
27 | if let losslessString = value as? LosslessStringConvertible {
28 | return String(describing: losslessString)
29 | }
30 | if let string = value as? String {
31 | return string
32 | }
33 | #if os(Linux)
34 | // swiftlint:disable:next legacy_objc_type
35 | if let string = value as? NSString {
36 | return String(describing: string)
37 | }
38 | #endif
39 |
40 | throw Error.invalidInputType
41 | }
42 |
43 | /// Parses filter arguments for a string value, where accepted objects must conform to
44 | /// `CustomStringConvertible`
45 | ///
46 | /// - Parameters:
47 | /// - arguments: an array of argument values, may be empty
48 | /// - index: the index in the arguments array
49 | /// - Throws: Filters.Error.invalidInputType
50 | public static func parseStringArgument(from arguments: [Any?], at index: Int = 0) throws -> String {
51 | guard index < arguments.count else {
52 | throw Error.invalidInputType
53 | }
54 | if let losslessString = arguments[index] as? LosslessStringConvertible {
55 | return String(describing: losslessString)
56 | }
57 | if let string = arguments[index] as? String {
58 | return string
59 | }
60 | throw Error.invalidInputType
61 | }
62 |
63 | // swiftlint:disable discouraged_optional_boolean
64 | /// Parses filter arguments for a boolean value, where true can by any one of: "true", "yes", "1", and
65 | /// false can be any one of: "false", "no", "0". If optional is true it means that the argument on the filter is
66 | /// optional and it's not an error condition if the argument is missing or not the right type
67 | ///
68 | /// - Parameters:
69 | /// - arguments: an array of argument values, may be empty
70 | /// - index: the index in the arguments array
71 | /// - required: If true, the argument is required and function throws if missing.
72 | /// If false, returns nil on missing args.
73 | /// - Throws: Filters.Error.invalidInputType
74 | public static func parseBool(from arguments: [Any?], at index: Int = 0, required: Bool = true) throws -> Bool? {
75 | guard index < arguments.count, let boolArg = arguments[index] as? String else {
76 | if required {
77 | throw Error.invalidInputType
78 | } else {
79 | return nil
80 | }
81 | }
82 |
83 | switch boolArg.lowercased() {
84 | case "false", "no", "0":
85 | return false
86 | case "true", "yes", "1":
87 | return true
88 | default:
89 | throw Error.invalidInputType
90 | }
91 | }
92 | // swiftlint:enable discouraged_optional_boolean
93 |
94 | /// Parses filter arguments for an enum value (with a String rawvalue).
95 | ///
96 | /// - Parameters:
97 | /// - arguments: an array of argument values, may be empty
98 | /// - index: the index in the arguments array
99 | /// - default: The default value should no argument be provided
100 | /// - Throws: Filters.Error.invalidInputType
101 | public static func parseEnum(
102 | from arguments: [Any?],
103 | at index: Int = 0,
104 | default: T
105 | ) throws -> T where T: RawRepresentable, T.RawValue == String {
106 | guard index < arguments.count else { return `default` }
107 | let arg = arguments[index].map(String.init(describing:)) ?? `default`.rawValue
108 |
109 | guard let result = T(rawValue: arg) else {
110 | throw Self.Error.invalidOption(option: arg)
111 | }
112 |
113 | return result
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/SwiftIdentifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | typealias CharRange = CountableClosedRange
10 |
11 | // Official list of valid identifier characters
12 | // swiftlint:disable:next line_length
13 | // from: https://developer.apple.com/library/ios/documentation/Swift/Conceptual/Swift_Programming_Language/LexicalStructure.html#//apple_ref/doc/uid/TP40014097-CH30-ID410
14 | private extension CharRange {
15 | static func mr(_ char: Int) -> CharRange {
16 | char...char
17 | }
18 |
19 | static let headRanges: [CharRange] = [
20 | 0x61...0x7a as CharRange,
21 | 0x41...0x5a as CharRange,
22 | mr(0x5f), mr(0xa8), mr(0xaa), mr(0xad), mr(0xaf),
23 | 0xb2...0xb5 as CharRange,
24 | 0xb7...0xba as CharRange,
25 | 0xbc...0xbe as CharRange,
26 | 0xc0...0xd6 as CharRange,
27 | 0xd8...0xf6 as CharRange,
28 | 0xf8...0xff as CharRange,
29 | 0x100...0x2ff as CharRange,
30 | 0x370...0x167f as CharRange,
31 | 0x1681...0x180d as CharRange,
32 | 0x180f...0x1dbf as CharRange,
33 | 0x1e00...0x1fff as CharRange,
34 | 0x200b...0x200d as CharRange,
35 | 0x202a...0x202e as CharRange,
36 | mr(0x203F), mr(0x2040), mr(0x2054),
37 | 0x2060...0x206f as CharRange,
38 | 0x2070...0x20cf as CharRange,
39 | 0x2100...0x218f as CharRange,
40 | 0x2460...0x24ff as CharRange,
41 | 0x2776...0x2793 as CharRange,
42 | 0x2c00...0x2dff as CharRange,
43 | 0x2e80...0x2fff as CharRange,
44 | 0x3004...0x3007 as CharRange,
45 | 0x3021...0x302f as CharRange,
46 | 0x3031...0x303f as CharRange,
47 | 0x3040...0xd7ff as CharRange,
48 | 0xf900...0xfd3d as CharRange,
49 | 0xfd40...0xfdcf as CharRange,
50 | 0xfdf0...0xfe1f as CharRange,
51 | 0xfe30...0xfe44 as CharRange,
52 | 0xfe47...0xfffd as CharRange,
53 | 0x10000...0x1fffd as CharRange,
54 | 0x20000...0x2fffd as CharRange,
55 | 0x30000...0x3fffd as CharRange,
56 | 0x40000...0x4fffd as CharRange,
57 | 0x50000...0x5fffd as CharRange,
58 | 0x60000...0x6fffd as CharRange,
59 | 0x70000...0x7fffd as CharRange,
60 | 0x80000...0x8fffd as CharRange,
61 | 0x90000...0x9fffd as CharRange,
62 | 0xa0000...0xafffd as CharRange,
63 | 0xb0000...0xbfffd as CharRange,
64 | 0xc0000...0xcfffd as CharRange,
65 | 0xd0000...0xdfffd as CharRange,
66 | 0xe0000...0xefffd as CharRange
67 | ]
68 |
69 | static let tailRanges: [CharRange] = [
70 | 0x30...0x39, 0x300...0x36F, 0x1dc0...0x1dff, 0x20d0...0x20ff, 0xfe20...0xfe2f
71 | ]
72 | }
73 |
74 | private extension CharacterSet {
75 | static let illegalIdentifierHead = setFromRanges(CharRange.headRanges)
76 | static let illegalIdentifierTail = setFromRanges(CharRange.headRanges + CharRange.tailRanges)
77 |
78 | static func setFromRanges(_ ranges: [CharRange]) -> CharacterSet {
79 | var result = CharacterSet()
80 | for range in ranges {
81 | guard let lower = Unicode.Scalar(range.lowerBound), let upper = Unicode.Scalar(range.upperBound) else { continue }
82 | result.insert(charactersIn: lower...upper)
83 | }
84 | return result
85 | }
86 | }
87 |
88 | enum SwiftIdentifier {
89 | static func identifier(
90 | from string: String,
91 | capitalizeComponents: Bool = true,
92 | replaceWithUnderscores underscores: Bool = false
93 | ) -> String {
94 | let parts = string.components(separatedBy: CharacterSet.illegalIdentifierTail.inverted)
95 | let replacement = underscores ? "_" : ""
96 | let mappedParts = !capitalizeComponents ? parts : parts.map { part in
97 | // Can't use capitalizedString here because it will lowercase all letters after the first
98 | // e.g. "SomeNiceIdentifier".capitalizedString will because "Someniceidentifier" which is not what we want
99 | guard let first = part.unicodeScalars.first else { return part }
100 | return String(first).uppercased() + String(part.unicodeScalars.dropFirst())
101 | }
102 |
103 | let result = mappedParts.joined(separator: replacement)
104 | return prefixWithUnderscoreIfNeeded(string: result)
105 | }
106 |
107 | static func prefixWithUnderscoreIfNeeded(string: String) -> String {
108 | guard let firstChar = string.unicodeScalars.first else { return "" }
109 | let prefix = !CharacterSet.illegalIdentifierHead.contains(firstChar) ? "_" : ""
110 |
111 | return prefix + string
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/Environment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import PathKit
8 | import Stencil
9 |
10 | public extension Extension {
11 | /// Registers this package's tags and filters
12 | func registerStencilSwiftExtensions() {
13 | registerTags()
14 | registerStringsFilters()
15 | registerNumbersFilters()
16 | }
17 | }
18 |
19 | private extension Extension {
20 | func registerFilter(_ name: String, filter: @escaping Filters.BooleanWithArguments) {
21 | typealias GenericFilter = (Any?, [Any?]) throws -> Any?
22 | let inverseFilter: GenericFilter = { value, arguments in
23 | try !filter(value, arguments)
24 | }
25 | registerFilter(name, filter: filter as GenericFilter)
26 | registerFilter("!\(name)", filter: inverseFilter)
27 | }
28 |
29 | func registerNumbersFilters() {
30 | registerFilter("hexToInt", filter: Filters.Numbers.hexToInt)
31 | registerFilter("int255toFloat", filter: Filters.Numbers.int255toFloat)
32 | registerFilter("percent", filter: Filters.Numbers.percent)
33 | }
34 |
35 | func registerStringsFilters() {
36 | registerFilter("basename", filter: Filters.Strings.basename)
37 | registerFilter("camelToSnakeCase", filter: Filters.Strings.camelToSnakeCase)
38 | registerFilter("dirname", filter: Filters.Strings.dirname)
39 | registerFilter("escapeReservedKeywords", filter: Filters.Strings.escapeReservedKeywords)
40 | registerFilter("lowerFirstLetter", filter: Filters.Strings.lowerFirstLetter)
41 | registerFilter("lowerFirstWord", filter: Filters.Strings.lowerFirstWord)
42 | registerFilter("removeNewlines", filter: Filters.Strings.removeNewlines)
43 | registerFilter("replace", filter: Filters.Strings.replace)
44 | registerFilter("snakeToCamelCase", filter: Filters.Strings.snakeToCamelCase)
45 | registerFilter("swiftIdentifier", filter: Filters.Strings.swiftIdentifier)
46 | registerFilter("titlecase", filter: Filters.Strings.upperFirstLetter)
47 | registerFilter("upperFirstLetter", filter: Filters.Strings.upperFirstLetter)
48 | registerFilter("contains", filter: Filters.Strings.contains)
49 | registerFilter("hasPrefix", filter: Filters.Strings.hasPrefix)
50 | registerFilter("hasSuffix", filter: Filters.Strings.hasSuffix)
51 | }
52 |
53 | func registerTags() {
54 | registerTag("set", parser: SetNode.parse)
55 | registerTag("macro", parser: MacroNode.parse)
56 | registerTag("call", parser: CallNode.parse)
57 | registerTag("map", parser: MapNode.parse)
58 | registerTag("import", parser: ImportNode.parse)
59 | }
60 | }
61 |
62 | /// Creates an Environment for Stencil to load & render templates
63 | ///
64 | /// - Parameters:
65 | /// - templatePaths: Paths where Stencil can search for templates, used for example for `include` tags
66 | /// - extensions: Additional extensions with filters/tags/… you want to provide to Stencil
67 | /// - templateClass: Custom template class to process templates
68 | /// - trimBehaviour: Globally control how whitespace is handled, defaults to `smart`.
69 | /// - Returns: A fully configured `Environment`
70 | public func stencilSwiftEnvironment(
71 | templatePaths: [Path] = [],
72 | extensions: [Extension] = [],
73 | templateClass: Template.Type = Template.self,
74 | trimBehaviour: TrimBehaviour = .smart
75 | ) -> Environment {
76 | let ext = Extension()
77 | ext.registerStencilSwiftExtensions()
78 |
79 | return Environment(
80 | loader: FileSystemLoader(paths: templatePaths),
81 | extensions: extensions + [ext],
82 | templateClass: templateClass,
83 | trimBehaviour: trimBehaviour
84 | )
85 | }
86 |
87 | /// Creates an Environment for Stencil to load & render templates
88 | ///
89 | /// - Parameters:
90 | /// - templates: Templates that can be included, imported, etc…
91 | /// - extensions: Additional extensions with filters/tags/… you want to provide to Stencil
92 | /// - templateClass: Custom template class to process templates
93 | /// - trimBehaviour: Globally control how whitespace is handled, defaults to `smart`.
94 | /// - Returns: A fully configured `Environment`
95 | public func stencilSwiftEnvironment(
96 | templates: [String: String],
97 | extensions: [Extension] = [],
98 | templateClass: Template.Type = Template.self,
99 | trimBehaviour: TrimBehaviour = .smart
100 | ) -> Environment {
101 | let ext = Extension()
102 | ext.registerStencilSwiftExtensions()
103 |
104 | return Environment(
105 | loader: DictionaryLoader(templates: templates),
106 | extensions: extensions + [ext],
107 | templateClass: templateClass,
108 | trimBehaviour: trimBehaviour
109 | )
110 | }
111 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/SwiftIdentifierTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import StencilSwiftKit
8 | import XCTest
9 |
10 | final class SwiftIdentifierTests: XCTestCase {
11 | func testBasicString() {
12 | XCTAssertEqual(SwiftIdentifier.identifier(from: "Hello"), "Hello")
13 | }
14 |
15 | func testSpecialChars() {
16 | XCTAssertEqual(SwiftIdentifier.identifier(from: "This-is-42$hello@world"), "ThisIs42HelloWorld")
17 | }
18 |
19 | func testKeepUppercaseAcronyms() {
20 | XCTAssertEqual(SwiftIdentifier.identifier(from: "some$URLDecoder"), "SomeURLDecoder")
21 | }
22 |
23 | func testEmojis() {
24 | XCTAssertEqual(SwiftIdentifier.identifier(from: "some😎🎉emoji"), "Some😎🎉emoji")
25 | }
26 |
27 | func testEmojis2() {
28 | XCTAssertEqual(SwiftIdentifier.identifier(from: "😎🎉"), "😎🎉")
29 | }
30 |
31 | func testNumbersFirst() {
32 | XCTAssertEqual(SwiftIdentifier.identifier(from: "42hello"), "_42hello")
33 | }
34 |
35 | func testForbiddenChars() {
36 | XCTAssertEqual(
37 | SwiftIdentifier.identifier(from: "hello$world^this*contains%a=lot@ofchars!does#it/still:work.anyway?"),
38 | "HelloWorldThisContainsALotOfForbiddenCharsDoesItStillWorkAnyway"
39 | )
40 | }
41 |
42 | func testEmptyString() {
43 | XCTAssertEqual(SwiftIdentifier.identifier(from: ""), "")
44 | }
45 | }
46 |
47 | extension SwiftIdentifierTests {
48 | func testSwiftIdentifier_WithNoArgsDefaultsToNormal() throws {
49 | let result = try Filters.Strings.swiftIdentifier("some_test", arguments: []) as? String
50 | XCTAssertEqual(result, "Some_test")
51 | }
52 |
53 | func testSwiftIdentifier_WithWrongArgWillThrow() throws {
54 | do {
55 | _ = try Filters.Strings.swiftIdentifier("", arguments: ["wrong"])
56 | XCTFail("Code did succeed while it was expected to fail for wrong option")
57 | } catch Filters.Error.invalidOption {
58 | // That's the expected exception we want to happen
59 | } catch let error {
60 | XCTFail("Unexpected error occured: \(error)")
61 | }
62 | }
63 |
64 | func testSwiftIdentifier_WithValid() throws {
65 | let expectations = [
66 | "hello": "hello",
67 | "42hello": "_42hello",
68 | "some$URL": "some_URL",
69 | "with space": "with_space",
70 | "apples.count": "apples_count",
71 | ".SFNSDisplay": "_SFNSDisplay",
72 | "Show-NavCtrl": "Show_NavCtrl",
73 | "HEADER_TITLE": "HEADER_TITLE",
74 | "multiLine\nKey": "multiLine_Key",
75 | "foo_bar.baz.qux-yay": "foo_bar_baz_qux_yay",
76 | "25 Ultra Light": "_25_Ultra_Light",
77 | "26_extra_ultra_light": "_26_extra_ultra_light",
78 | "12 @ 34 % 56 + 78 Hello world": "_12___34___56___78_Hello_world"
79 | ]
80 |
81 | for (input, expected) in expectations {
82 | let result = try Filters.Strings.swiftIdentifier(input, arguments: ["valid"]) as? String
83 | XCTAssertEqual(result, expected)
84 | }
85 | }
86 |
87 | func testSwiftIdentifier_WithNormal() throws {
88 | let expectations = [
89 | "hello": "Hello",
90 | "42hello": "_42hello",
91 | "some$URL": "Some_URL",
92 | "with space": "With_Space",
93 | "apples.count": "Apples_Count",
94 | ".SFNSDisplay": "_SFNSDisplay",
95 | "Show-NavCtrl": "Show_NavCtrl",
96 | "HEADER_TITLE": "HEADER_TITLE",
97 | "multiLine\nKey": "MultiLine_Key",
98 | "foo_bar.baz.qux-yay": "Foo_bar_Baz_Qux_Yay",
99 | "25 Ultra Light": "_25_Ultra_Light",
100 | "26_extra_ultra_light": "_26_extra_ultra_light",
101 | "12 @ 34 % 56 + 78 Hello world": "_12___34___56___78_Hello_World"
102 | ]
103 |
104 | for (input, expected) in expectations {
105 | let result = try Filters.Strings.swiftIdentifier(input, arguments: ["normal"]) as? String
106 | XCTAssertEqual(result, expected)
107 | }
108 | }
109 |
110 | func testSwiftIdentifier_WithPretty() throws {
111 | let expectations = [
112 | "hello": "Hello",
113 | "42hello": "_42hello",
114 | "some$URL": "SomeURL",
115 | "with space": "WithSpace",
116 | "apples.count": "ApplesCount",
117 | ".SFNSDisplay": "SFNSDisplay",
118 | "Show-NavCtrl": "ShowNavCtrl",
119 | "HEADER_TITLE": "HeaderTitle",
120 | "multiLine\nKey": "MultiLineKey",
121 | "foo_bar.baz.qux-yay": "FooBarBazQuxYay",
122 | "25 Ultra Light": "_25UltraLight",
123 | "26_extra_ultra_light": "_26ExtraUltraLight",
124 | "12 @ 34 % 56 + 78 Hello world": "_12345678HelloWorld"
125 | ]
126 |
127 | for (input, expected) in expectations {
128 | let result = try Filters.Strings.swiftIdentifier(input, arguments: ["pretty"]) as? String
129 | XCTAssertEqual(result, expected)
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # StencilSwiftKit
2 |
3 | [](https://circleci.com/gh/SwiftGen/StencilSwiftKit/tree/stable)
4 | [](https://img.shields.io/cocoapods/v/StencilSwiftKit.svg)
5 | [](http://cocoadocs.org/docsets/StencilSwiftKit)
6 | 
7 |
8 | `StencilSwiftKit` is a framework bringing additional [Stencil](https://github.com/stencilproject/Stencil) nodes & filters dedicated to Swift code generation.
9 |
10 | ## Tags
11 |
12 | * [Macro](Documentation/tag-macro.md) & [Call](Documentation/tag-call.md)
13 | * `{% macro %}…{% endmacro %}`
14 | * Defines a macro that will be replaced by the nodes inside of this block later when called
15 | * `{% call %}`
16 | * Calls a previously defined macro, passing it some arguments
17 | * [Set](Documentation/tag-set.md)
18 | * `{% set %}…{% endset %}`
19 | * Renders the nodes inside this block immediately, and stores the result in the `` variable of the current context.
20 | * [Import](Documentation/tag-import.md)
21 | * `{% import "common.stencil" %}`
22 | * Imports any macro & set definitions from `common.stencil` into the current context.
23 | * [Map](Documentation/tag-map.md)
24 | * `{% map into using %}…{% endmap %}`
25 | * Apply a `map` operator to an array, and store the result into a new array variable `` in the current context.
26 | * Inside the map loop, a `maploop` special variable is available (akin to the `forloop` variable in `for` nodes). It exposes `maploop.counter`, `maploop.first`, `maploop.last` and `maploop.item`.
27 |
28 | ## Filters
29 |
30 | * [String filters](Documentation/filters-strings.md):
31 | * `basename`: Get the filename from a path.
32 | * `camelToSnakeCase`: Transforms text from camelCase to snake_case. By default it converts to lower case, unless a single optional argument is set to "false", "no" or "0".
33 | * `contains`: Check if a string contains a specific substring.
34 | * `dirname`: Get the path to the parent folder from a path.
35 | * `escapeReservedKeywords`: Escape keywords reserved in the Swift language, by wrapping them inside backticks so that the can be used as regular escape keywords in Swift code.
36 | * `hasPrefix` / `hasSuffix`: Check if a string starts/ends with a specific substring.
37 | * `lowerFirstLetter`: Lowercases only the first letter of a string.
38 | * `lowerFirstWord`: Lowercases only the first word of a string.
39 | * `replace`: Replaces instances of a substring with a new string.
40 | * `snakeToCamelCase`: Transforms text from snake_case to camelCase. By default it keeps leading underscores, unless a single optional argument is set to "true", "yes" or "1".
41 | * `swiftIdentifier`: Transforms an arbitrary string into a valid Swift identifier (using only valid characters for a Swift identifier as defined in the Swift language reference). In "pretty" mode, it will also apply the snakeToCamelCase filter afterwards, and other manipulations if needed for a "prettier" but still valid identifier.
42 | * `upperFirstLetter`: Uppercases only the first character
43 | * [Number filters](Documentation/filters-numbers.md):
44 | * `int255toFloat`
45 | * `hexToInt`
46 | * `percent`
47 |
48 | ## Stencil.Extension & swiftStencilEnvironment
49 |
50 | This framework also contains [helper methods for `Stencil.Extension` and `Stencil.Environment`](https://github.com/SwiftGen/StencilSwiftKit/blob/stable/Sources/StencilSwiftKit/Environment.swift), to easily register all the tags and filters listed above on an existing `Stencil.Extension`, as well as to easily get a `Stencil.Environment` preconfigured with both those tags & filters `Extension`.
51 |
52 | ## Parameters
53 |
54 | This framework contains an additional parser, meant to parse a list of parameters from the CLI. For example, using [Commander](https://github.com/kylef/Commander), if you receive a `[String]` from a `VariadicOption`, you can use the parser to convert it into a structured dictionary. For example:
55 |
56 | ```swift
57 | ["foo=1", "bar=2", "baz.qux=hello", "baz.items=a", "baz.items=b", "something"]
58 | ```
59 |
60 | will become
61 |
62 | ```swift
63 | [
64 | "foo": "1",
65 | "bar": "2",
66 | "baz": [
67 | "qux": "hello",
68 | "items": [
69 | "a",
70 | "b"
71 | ]
72 | ],
73 | something: true
74 | ]
75 | ```
76 |
77 | For easier use, you can use the `StencilContext.enrich(context:parameters:environment:)` function to add the following variables to a context:
78 |
79 | - `param`: the parsed parameters using the parser mentioned above.
80 | - `env`: a dictionary with all available environment variables (such as `PATH`).
81 |
82 | ---
83 |
84 | # Licence
85 |
86 | This code and tool is under the MIT Licence. See the `LICENCE` file in this repository.
87 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/CallNodeTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import Stencil
8 | @testable import StencilSwiftKit
9 | import XCTest
10 |
11 | final class CallNodeTests: XCTestCase {
12 | func testParser() {
13 | let tokens: [Token] = [
14 | .block(value: "call myFunc", at: .unknown)
15 | ]
16 |
17 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
18 | guard let nodes = try? parser.parse(),
19 | let node = nodes.first as? CallNode else {
20 | XCTFail("Unable to parse tokens")
21 | return
22 | }
23 |
24 | XCTAssertEqual(node.variable.variable, "myFunc")
25 | XCTAssertEqual(node.arguments.count, 0)
26 | }
27 |
28 | func testParserWithArgumentsVariable() {
29 | let tokens: [Token] = [
30 | .block(value: "call myFunc a b c", at: .unknown)
31 | ]
32 |
33 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
34 | guard let nodes = try? parser.parse(),
35 | let node = nodes.first as? CallNode else {
36 | XCTFail("Unable to parse tokens")
37 | return
38 | }
39 |
40 | XCTAssertEqual(node.variable.variable, "myFunc")
41 | let variables = node.arguments.compactMap { $0 as? FilterExpression }.compactMap { $0.variable }
42 | XCTAssertEqual(variables, [Variable("a"), Variable("b"), Variable("c")])
43 | }
44 |
45 | func testParserWithArgumentsRange() throws {
46 | let token: Token = .block(value: "call myFunc 1...3 5...7 9...a", at: .unknown)
47 | let tokens = [token]
48 |
49 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
50 | guard let nodes = try? parser.parse(),
51 | let node = nodes.first as? CallNode else {
52 | XCTFail("Unable to parse tokens")
53 | return
54 | }
55 |
56 | XCTAssertEqual(node.variable.variable, "myFunc")
57 | let variables = node.arguments.compactMap { $0 as? RangeVariable }
58 | XCTAssertEqual(variables.count, 3) // RangeVariable isn't equatable
59 | }
60 |
61 | func testParserFail() {
62 | do {
63 | let tokens: [Token] = [
64 | .block(value: "call", at: .unknown)
65 | ]
66 |
67 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
68 | XCTAssertThrowsError(try parser.parse())
69 | }
70 | }
71 |
72 | func testRender() throws {
73 | let block = CallableBlock(parameters: [], nodes: [TextNode(text: "hello")])
74 | let context = Context(dictionary: ["myFunc": block])
75 | let node = CallNode(variable: Variable("myFunc"), arguments: [])
76 | let output = try node.render(context)
77 |
78 | XCTAssertEqual(output, "hello")
79 | }
80 |
81 | func testRenderFail() {
82 | let context = Context(dictionary: [:])
83 | let node = CallNode(variable: Variable("myFunc"), arguments: [])
84 |
85 | XCTAssertThrowsError(try node.render(context))
86 | }
87 |
88 | func testRenderWithParameters() throws {
89 | let block = CallableBlock(
90 | parameters: ["a", "b", "c"],
91 | nodes: [
92 | TextNode(text: "variables: "),
93 | VariableNode(variable: "a"),
94 | VariableNode(variable: "b"),
95 | VariableNode(variable: "c")
96 | ]
97 | )
98 | let context = Context(dictionary: ["myFunc": block])
99 | let node = CallNode(
100 | variable: Variable("myFunc"),
101 | arguments: [
102 | Variable("\"hello\""),
103 | Variable("\"world\""),
104 | Variable("\"test\"")
105 | ]
106 | )
107 | let output = try node.render(context)
108 |
109 | XCTAssertEqual(output, "variables: helloworldtest")
110 | }
111 |
112 | func testRenderWithParametersFail() {
113 | let block = CallableBlock(
114 | parameters: ["a", "b", "c"],
115 | nodes: [
116 | TextNode(text: "variables: "),
117 | VariableNode(variable: "a"),
118 | VariableNode(variable: "b"),
119 | VariableNode(variable: "c")
120 | ]
121 | )
122 | let context = Context(dictionary: ["myFunc": block])
123 |
124 | // must pass arguments
125 | do {
126 | let node = CallNode(variable: Variable("myFunc"), arguments: [])
127 | XCTAssertThrowsError(try node.render(context))
128 | }
129 |
130 | // not enough arguments
131 | do {
132 | let node = CallNode(
133 | variable: Variable("myFunc"),
134 | arguments: [
135 | Variable("\"hello\"")
136 | ]
137 | )
138 | XCTAssertThrowsError(try node.render(context))
139 | }
140 |
141 | // too many arguments
142 | do {
143 | let node = CallNode(
144 | variable: Variable("myFunc"),
145 | arguments: [
146 | Variable("\"hello\""),
147 | Variable("\"world\""),
148 | Variable("\"test\""),
149 | Variable("\"test\"")
150 | ]
151 | )
152 | XCTAssertThrowsError(try node.render(context))
153 | }
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/MacroNodeTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import Stencil
8 | @testable import StencilSwiftKit
9 | import XCTest
10 |
11 | final class MacroNodeTests: XCTestCase {
12 | func testParser() {
13 | let tokens: [Token] = [
14 | .block(value: "macro myFunc", at: .unknown),
15 | .text(value: "hello", at: .unknown),
16 | .block(value: "endmacro", at: .unknown)
17 | ]
18 |
19 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
20 | guard let nodes = try? parser.parse(),
21 | let node = nodes.first as? MacroNode else {
22 | XCTFail("Unable to parse tokens")
23 | return
24 | }
25 |
26 | XCTAssertEqual(node.variableName, "myFunc")
27 | XCTAssertEqual(node.parameters, [])
28 | XCTAssertEqual(node.nodes.count, 1)
29 | XCTAssert(node.nodes.first is TextNode)
30 | }
31 |
32 | func testParserWithParameters() {
33 | let tokens: [Token] = [
34 | .block(value: "macro myFunc a b c", at: .unknown),
35 | .text(value: "hello", at: .unknown),
36 | .block(value: "endmacro", at: .unknown)
37 | ]
38 |
39 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
40 | guard let nodes = try? parser.parse(),
41 | let node = nodes.first as? MacroNode else {
42 | XCTFail("Unable to parse tokens")
43 | return
44 | }
45 |
46 | XCTAssertEqual(node.variableName, "myFunc")
47 | XCTAssertEqual(node.parameters, ["a", "b", "c"])
48 | XCTAssertEqual(node.nodes.count, 1)
49 | XCTAssert(node.nodes.first is TextNode)
50 | }
51 |
52 | func testParserFail() {
53 | do {
54 | let tokens: [Token] = [
55 | .block(value: "macro myFunc", at: .unknown),
56 | .text(value: "hello", at: .unknown)
57 | ]
58 |
59 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
60 | XCTAssertThrowsError(try parser.parse())
61 | }
62 |
63 | do {
64 | let tokens: [Token] = [
65 | .block(value: "macro", at: .unknown),
66 | .text(value: "hello", at: .unknown),
67 | .block(value: "endmacro", at: .unknown)
68 | ]
69 |
70 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
71 | XCTAssertThrowsError(try parser.parse())
72 | }
73 | }
74 |
75 | func testRender() throws {
76 | let node = MacroNode(variableName: "myFunc", parameters: [], nodes: [TextNode(text: "hello")])
77 | let context = Context(dictionary: [:])
78 | let output = try node.render(context)
79 |
80 | XCTAssertEqual(output, "")
81 | }
82 |
83 | func testRenderWithParameters() throws {
84 | let node = MacroNode(variableName: "myFunc", parameters: ["a", "b", "c"], nodes: [TextNode(text: "hello")])
85 | let context = Context(dictionary: [:])
86 | let output = try node.render(context)
87 |
88 | XCTAssertEqual(output, "")
89 | }
90 |
91 | func testContextModification() throws {
92 | let node = MacroNode(variableName: "myFunc", parameters: [], nodes: [TextNode(text: "hello")])
93 | let context = Context(dictionary: ["": ""])
94 | _ = try node.render(context)
95 |
96 | guard let block = context["myFunc"] as? CallableBlock else {
97 | XCTFail("Unable to render macro token")
98 | return
99 | }
100 | XCTAssertEqual(block.parameters, [])
101 | XCTAssertEqual(block.nodes.count, 1)
102 | XCTAssert(block.nodes.first is TextNode)
103 | }
104 |
105 | func testContextModificationWithParameters() throws {
106 | let node = MacroNode(variableName: "myFunc", parameters: ["a", "b", "c"], nodes: [TextNode(text: "hello")])
107 | let context = Context(dictionary: ["": ""])
108 | _ = try node.render(context)
109 |
110 | guard let block = context["myFunc"] as? CallableBlock else {
111 | XCTFail("Unable to render macro token")
112 | return
113 | }
114 | XCTAssertEqual(block.parameters, ["a", "b", "c"])
115 | XCTAssertEqual(block.nodes.count, 1)
116 | XCTAssert(block.nodes.first is TextNode)
117 | }
118 |
119 | func testCallableBlockContext() throws {
120 | let block = CallableBlock(parameters: ["p1", "p2", "p3"], nodes: [TextNode(text: "hello")])
121 | let arguments = [Variable("a"), Variable("b"), Variable("\"hello\"")]
122 | let context = Context(dictionary: ["a": 1, "b": 2])
123 |
124 | let result = try block.context(context, arguments: arguments, variable: Variable("myFunc"))
125 | XCTAssertEqual(result["p1"] as? Int, 1)
126 | XCTAssertEqual(result["p2"] as? Int, 2)
127 | XCTAssertEqual(result["p3"] as? String, "hello")
128 | }
129 |
130 | func testCallableBlockWithFilterExpressionParameter() throws {
131 | let block = CallableBlock(parameters: ["greeting"], nodes: [])
132 |
133 | let environment = stencilSwiftEnvironment()
134 | let arguments = try [FilterExpression(token: "greet|uppercase", environment: environment)]
135 | let context = Context(dictionary: ["greet": "hello"])
136 |
137 | let result = try block.context(context, arguments: arguments, variable: Variable("myFunc"))
138 | XCTAssertEqual(result["greeting"] as? String, "HELLO")
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.5)
5 | rexml
6 | activesupport (6.1.6.1)
7 | concurrent-ruby (~> 1.0, >= 1.0.2)
8 | i18n (>= 1.6, < 2)
9 | minitest (>= 5.1)
10 | tzinfo (~> 2.0)
11 | zeitwerk (~> 2.3)
12 | addressable (2.8.0)
13 | public_suffix (>= 2.0.2, < 5.0)
14 | algoliasearch (1.27.5)
15 | httpclient (~> 2.8, >= 2.8.3)
16 | json (>= 1.5.1)
17 | ast (2.4.2)
18 | atomos (0.1.3)
19 | claide (1.1.0)
20 | claide-plugins (0.9.2)
21 | cork
22 | nap
23 | open4 (~> 1.3)
24 | cocoapods (1.11.3)
25 | addressable (~> 2.8)
26 | claide (>= 1.0.2, < 2.0)
27 | cocoapods-core (= 1.11.3)
28 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
29 | cocoapods-downloader (>= 1.4.0, < 2.0)
30 | cocoapods-plugins (>= 1.0.0, < 2.0)
31 | cocoapods-search (>= 1.0.0, < 2.0)
32 | cocoapods-trunk (>= 1.4.0, < 2.0)
33 | cocoapods-try (>= 1.1.0, < 2.0)
34 | colored2 (~> 3.1)
35 | escape (~> 0.0.4)
36 | fourflusher (>= 2.3.0, < 3.0)
37 | gh_inspector (~> 1.0)
38 | molinillo (~> 0.8.0)
39 | nap (~> 1.0)
40 | ruby-macho (>= 1.0, < 3.0)
41 | xcodeproj (>= 1.21.0, < 2.0)
42 | cocoapods-core (1.11.3)
43 | activesupport (>= 5.0, < 7)
44 | addressable (~> 2.8)
45 | algoliasearch (~> 1.0)
46 | concurrent-ruby (~> 1.1)
47 | fuzzy_match (~> 2.0.4)
48 | nap (~> 1.0)
49 | netrc (~> 0.11)
50 | public_suffix (~> 4.0)
51 | typhoeus (~> 1.0)
52 | cocoapods-deintegrate (1.0.5)
53 | cocoapods-downloader (1.6.3)
54 | cocoapods-plugins (1.0.0)
55 | nap
56 | cocoapods-search (1.0.1)
57 | cocoapods-trunk (1.6.0)
58 | nap (>= 0.8, < 2.0)
59 | netrc (~> 0.11)
60 | cocoapods-try (1.2.0)
61 | colored2 (3.1.2)
62 | concurrent-ruby (1.1.10)
63 | cork (0.3.0)
64 | colored2 (~> 3.1)
65 | danger (8.6.1)
66 | claide (~> 1.0)
67 | claide-plugins (>= 0.9.2)
68 | colored2 (~> 3.1)
69 | cork (~> 0.1)
70 | faraday (>= 0.9.0, < 2.0)
71 | faraday-http-cache (~> 2.0)
72 | git (~> 1.7)
73 | kramdown (~> 2.3)
74 | kramdown-parser-gfm (~> 1.0)
75 | no_proxy_fix
76 | octokit (~> 4.7)
77 | terminal-table (>= 1, < 4)
78 | escape (0.0.4)
79 | ethon (0.15.0)
80 | ffi (>= 1.15.0)
81 | faraday (1.10.0)
82 | faraday-em_http (~> 1.0)
83 | faraday-em_synchrony (~> 1.0)
84 | faraday-excon (~> 1.1)
85 | faraday-httpclient (~> 1.0)
86 | faraday-multipart (~> 1.0)
87 | faraday-net_http (~> 1.0)
88 | faraday-net_http_persistent (~> 1.0)
89 | faraday-patron (~> 1.0)
90 | faraday-rack (~> 1.0)
91 | faraday-retry (~> 1.0)
92 | ruby2_keywords (>= 0.0.4)
93 | faraday-em_http (1.0.0)
94 | faraday-em_synchrony (1.0.0)
95 | faraday-excon (1.1.0)
96 | faraday-http-cache (2.4.0)
97 | faraday (>= 0.8)
98 | faraday-httpclient (1.0.1)
99 | faraday-multipart (1.0.4)
100 | multipart-post (~> 2)
101 | faraday-net_http (1.0.1)
102 | faraday-net_http_persistent (1.2.0)
103 | faraday-patron (1.0.0)
104 | faraday-rack (1.0.0)
105 | faraday-retry (1.0.3)
106 | ffi (1.15.5)
107 | fourflusher (2.3.1)
108 | fuzzy_match (2.0.4)
109 | gh_inspector (1.1.3)
110 | git (1.11.0)
111 | rchardet (~> 1.8)
112 | httpclient (2.8.3)
113 | i18n (1.12.0)
114 | concurrent-ruby (~> 1.0)
115 | json (2.6.2)
116 | kramdown (2.4.0)
117 | rexml
118 | kramdown-parser-gfm (1.1.0)
119 | kramdown (~> 2.0)
120 | minitest (5.16.2)
121 | molinillo (0.8.0)
122 | multipart-post (2.2.3)
123 | nanaimo (0.3.0)
124 | nap (1.1.0)
125 | netrc (0.11.0)
126 | no_proxy_fix (0.1.2)
127 | octokit (4.25.1)
128 | faraday (>= 1, < 3)
129 | sawyer (~> 0.9)
130 | open4 (1.3.4)
131 | parallel (1.22.1)
132 | parser (3.1.2.0)
133 | ast (~> 2.4.1)
134 | public_suffix (4.0.7)
135 | rainbow (3.1.1)
136 | rake (13.0.6)
137 | rchardet (1.8.0)
138 | regexp_parser (2.5.0)
139 | rexml (3.2.5)
140 | rouge (2.0.7)
141 | rubocop (1.32.0)
142 | json (~> 2.3)
143 | parallel (~> 1.10)
144 | parser (>= 3.1.0.0)
145 | rainbow (>= 2.2.2, < 4.0)
146 | regexp_parser (>= 1.8, < 3.0)
147 | rexml (>= 3.2.5, < 4.0)
148 | rubocop-ast (>= 1.19.1, < 2.0)
149 | ruby-progressbar (~> 1.7)
150 | unicode-display_width (>= 1.4.0, < 3.0)
151 | rubocop-ast (1.19.1)
152 | parser (>= 3.1.1.0)
153 | ruby-macho (2.5.1)
154 | ruby-progressbar (1.11.0)
155 | ruby2_keywords (0.0.5)
156 | sawyer (0.9.2)
157 | addressable (>= 2.3.5)
158 | faraday (>= 0.17.3, < 3)
159 | terminal-table (3.0.2)
160 | unicode-display_width (>= 1.1.1, < 3)
161 | typhoeus (1.4.0)
162 | ethon (>= 0.9.0)
163 | tzinfo (2.0.5)
164 | concurrent-ruby (~> 1.0)
165 | unicode-display_width (2.2.0)
166 | xcodeproj (1.22.0)
167 | CFPropertyList (>= 2.3.3, < 4.0)
168 | atomos (~> 0.1.3)
169 | claide (>= 1.0.2, < 2.0)
170 | colored2 (~> 3.1)
171 | nanaimo (~> 0.3.0)
172 | rexml (~> 3.2.4)
173 | xcpretty (0.3.0)
174 | rouge (~> 2.0.7)
175 | zeitwerk (2.6.0)
176 |
177 | PLATFORMS
178 | ruby
179 |
180 | DEPENDENCIES
181 | cocoapods (~> 1.11)
182 | danger (~> 8.4)
183 | octokit (~> 4.7)
184 | rake (~> 13.0)
185 | rubocop (~> 1.22)
186 | xcpretty (~> 0.3)
187 |
188 | BUNDLED WITH
189 | 2.2.33
190 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/MapNodeTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import Stencil
8 | @testable import StencilSwiftKit
9 | import XCTest
10 |
11 | final class MapNodeTests: XCTestCase {
12 | private static let context = [
13 | "items": ["one", "two", "three"]
14 | ]
15 |
16 | func testParserFilterExpression() {
17 | let tokens: [Token] = [
18 | .block(value: "map items into result", at: .unknown),
19 | .text(value: "hello", at: .unknown),
20 | .block(value: "endmap", at: .unknown)
21 | ]
22 |
23 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
24 | guard let nodes = try? parser.parse(),
25 | let node = nodes.first as? MapNode else {
26 | XCTFail("Unable to parse tokens")
27 | return
28 | }
29 |
30 | switch node.resolvable {
31 | case let reference as FilterExpression:
32 | XCTAssertEqual(reference.variable.variable, "items")
33 | default:
34 | XCTFail("Unexpected resolvable type")
35 | }
36 | XCTAssertNil(node.mapVariable)
37 | XCTAssertEqual(node.nodes.count, 1)
38 | XCTAssert(node.nodes.first is TextNode)
39 | }
40 |
41 | func testParserRange() {
42 | let tokens: [Token] = [
43 | .block(value: "map 1...3 into result", at: .unknown),
44 | .text(value: "hello", at: .unknown),
45 | .block(value: "endmap", at: .unknown)
46 | ]
47 |
48 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
49 | guard let nodes = try? parser.parse(),
50 | let node = nodes.first as? MapNode else {
51 | XCTFail("Unable to parse tokens")
52 | return
53 | }
54 |
55 | switch node.resolvable {
56 | case is RangeVariable:
57 | break
58 | default:
59 | XCTFail("Unexpected resolvable type")
60 | }
61 | XCTAssertNil(node.mapVariable)
62 | XCTAssertEqual(node.nodes.count, 1)
63 | XCTAssert(node.nodes.first is TextNode)
64 | }
65 |
66 | func testParserWithMapVariable() {
67 | let tokens: [Token] = [
68 | .block(value: "map items into result using item", at: .unknown),
69 | .text(value: "hello", at: .unknown),
70 | .block(value: "endmap", at: .unknown)
71 | ]
72 |
73 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
74 | guard let nodes = try? parser.parse(),
75 | let node = nodes.first as? MapNode else {
76 | XCTFail("Unable to parse tokens")
77 | return
78 | }
79 |
80 | XCTAssertEqual(node.resultName, "result")
81 | XCTAssertEqual(node.mapVariable, "item")
82 | XCTAssertEqual(node.nodes.count, 1)
83 | XCTAssert(node.nodes.first is TextNode)
84 | }
85 |
86 | func testParserFail() {
87 | // no closing tag
88 | do {
89 | let tokens: [Token] = [
90 | .block(value: "map items into result", at: .unknown),
91 | .text(value: "hello", at: .unknown)
92 | ]
93 |
94 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
95 | XCTAssertThrowsError(try parser.parse())
96 | }
97 |
98 | // no parameters
99 | do {
100 | let tokens: [Token] = [
101 | .block(value: "map", at: .unknown),
102 | .text(value: "hello", at: .unknown),
103 | .block(value: "endmap", at: .unknown)
104 | ]
105 |
106 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
107 | XCTAssertThrowsError(try parser.parse())
108 | }
109 |
110 | // no result parameters
111 | do {
112 | let tokens: [Token] = [
113 | .block(value: "map items", at: .unknown),
114 | .text(value: "hello", at: .unknown),
115 | .block(value: "endmap", at: .unknown)
116 | ]
117 |
118 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
119 | XCTAssertThrowsError(try parser.parse())
120 | }
121 |
122 | // no result variable name
123 | do {
124 | let tokens: [Token] = [
125 | .block(value: "map items into", at: .unknown),
126 | .text(value: "hello", at: .unknown),
127 | .block(value: "endmap", at: .unknown)
128 | ]
129 |
130 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
131 | XCTAssertThrowsError(try parser.parse())
132 | }
133 |
134 | // no map variable name
135 | do {
136 | let tokens: [Token] = [
137 | .block(value: "map items into result using", at: .unknown),
138 | .text(value: "hello", at: .unknown),
139 | .block(value: "endmap", at: .unknown)
140 | ]
141 |
142 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
143 | XCTAssertThrowsError(try parser.parse())
144 | }
145 | }
146 |
147 | func testRender() throws {
148 | let context = Context(dictionary: Self.context)
149 | let node = MapNode(
150 | resolvable: Variable("items"),
151 | resultName: "result",
152 | mapVariable: nil,
153 | nodes: [TextNode(text: "hello")]
154 | )
155 | let output = try node.render(context)
156 |
157 | XCTAssertEqual(output, "")
158 | }
159 |
160 | func testContext() throws {
161 | let context = Context(dictionary: Self.context)
162 | let node = MapNode(
163 | resolvable: Variable("items"),
164 | resultName: "result",
165 | mapVariable: "item",
166 | nodes: [TextNode(text: "hello")]
167 | )
168 | _ = try node.render(context)
169 |
170 | guard let items = context["items"] as? [String], let result = context["result"] as? [String] else {
171 | XCTFail("Unable to render map")
172 | return
173 | }
174 | XCTAssertEqual(items, Self.context["items"] ?? [])
175 | XCTAssertEqual(result, ["hello", "hello", "hello"])
176 | }
177 |
178 | func testMapLoopContext() throws {
179 | let context = Context(dictionary: Self.context)
180 | let node = MapNode(
181 | resolvable: Variable("items"),
182 | resultName: "result",
183 | mapVariable: nil,
184 | nodes: [
185 | VariableNode(variable: "maploop.counter"),
186 | VariableNode(variable: "maploop.first"),
187 | VariableNode(variable: "maploop.last"),
188 | VariableNode(variable: "maploop.item")
189 | ]
190 | )
191 | _ = try node.render(context)
192 |
193 | guard let items = context["items"] as? [String], let result = context["result"] as? [String] else {
194 | XCTFail("Unable to render map")
195 | return
196 | }
197 | XCTAssertEqual(items, Self.context["items"] ?? [])
198 | XCTAssertEqual(result, ["0truefalseone", "1falsefalsetwo", "2falsetruethree"])
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/Parameters.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 |
9 | /// Namespace to handle extra context parameters passed as a list of `foo=bar` strings.
10 | /// Typically used when parsing command-line arguments one by one
11 | /// (like `foo=bar pt.x=1 pt.y=2 values=1 values=2 values=3 flag`)
12 | /// to turn them into a dictionary structure
13 | public enum Parameters {
14 | public enum Error: Swift.Error {
15 | case invalidSyntax(value: Any)
16 | case invalidKey(key: String, value: Any)
17 | case invalidStructure(key: String, oldValue: Any, newValue: Any)
18 | }
19 |
20 | typealias Parameter = (key: String, value: Any)
21 | /// A string-keyed dictionary
22 | public typealias StringDict = [String: Any]
23 |
24 | /// Transforms a list of strings representing structured-key/value pairs, like
25 | /// `["pt.x=1", "pt.y=2", "values=1", "values=2", "values=3", "flag"]`
26 | /// into a structured dictionary.
27 | ///
28 | /// - Parameter items: The list of `k=v`-style Strings, each string
29 | /// representing either a `key=value` pair or a
30 | /// single `flag` key with no `=` (which will then
31 | /// be interpreted as a `true` value)
32 | /// - Returns: A structured dictionary matching the list of keys
33 | /// - Throws: `Parameters.Error`
34 | public static func parse(items: [String]) throws -> StringDict {
35 | let parameters: [Parameter] = try items.map(createParameter)
36 | return try parameters.reduce(StringDict()) { result, parameter in
37 | try parse(parameter: parameter, result: result)
38 | }
39 | }
40 |
41 | /// Flatten a dictionary into a list of "key.path=value" pairs.
42 | /// This method recursively visits the object to build its flat representation.
43 | ///
44 | /// - Parameters:
45 | /// - dictionary: The dictionary to recursively flatten into key pairs
46 | /// - Returns: The list of flatten "key.path=value" pair representations of the object
47 | ///
48 | /// - Note: flatten is the counterpart of parse. flatten(parse(x)) == parse(flatten(x)) == x
49 | ///
50 | /// - Example:
51 | ///
52 | /// flatten(["a":["b":1,"c":[2,3]]])
53 | /// // ["a.b=1","a.c=2","a.c=3"]
54 | public static func flatten(dictionary: StringDict) -> [String] {
55 | flatten(object: dictionary, keyPrefix: "")
56 | }
57 |
58 | // MARK: - Private methods
59 |
60 | /// Parse a single `key=value` (or `key`) string and inserts it into
61 | /// an existing StringDict dictionary being built.
62 | ///
63 | /// - Parameters:
64 | /// - parameter: The parameter/string (key/value pair) to parse
65 | /// - result: The dictionary currently being built and to which to add the value
66 | /// - Returns: The new content of the dictionary being built after inserting the new parsed value
67 | /// - Throws: `Parameters.Error`
68 | private static func parse(parameter: Parameter, result: StringDict) throws -> StringDict {
69 | let parts = parameter.key.components(separatedBy: ".")
70 | let key = parts.first ?? ""
71 |
72 | // validate key
73 | guard validate(key: key) else { throw Error.invalidKey(key: parameter.key, value: parameter.value) }
74 |
75 | // no sub keys, may need to convert to array if repeat key if possible
76 | if parts.count == 1 {
77 | return try parse(key: key, parameter: parameter, result: result)
78 | }
79 |
80 | guard result[key] is StringDict || result[key] == nil else {
81 | throw Error.invalidStructure(key: key, oldValue: result[key] ?? "", newValue: parameter.value)
82 | }
83 |
84 | // recurse into sub keys
85 | var result = result
86 | let current = result[key] as? StringDict ?? StringDict()
87 | let sub = (key: parts.suffix(from: 1).joined(separator: "."), value: parameter.value)
88 | result[key] = try parse(parameter: sub, result: current)
89 | return result
90 | }
91 |
92 | /// Parse a single `key=value` (or `key`) string and inserts it into
93 | /// an existing StringDict dictionary being built.
94 | ///
95 | /// - Parameters:
96 | /// - parameter: The parameter/string (key/value pair) to parse, where key doesn't have sub keys
97 | /// - result: The dictionary currently being built and to which to add the value
98 | /// - Returns: The new content of the dictionary being built after inserting the new parsed value
99 | /// - Throws: `Parameters.Error`
100 | private static func parse(key: String, parameter: Parameter, result: StringDict) throws -> StringDict {
101 | var result = result
102 | if let current = result[key] as? [Any] {
103 | result[key] = current + [parameter.value]
104 | } else if let current = result[key] as? String {
105 | result[key] = [current, parameter.value]
106 | } else if let current = result[key] {
107 | throw Error.invalidStructure(key: key, oldValue: current, newValue: parameter.value)
108 | } else {
109 | result[key] = parameter.value
110 | }
111 | return result
112 | }
113 |
114 | // a valid key is not empty and only alphanumerical or dot
115 | private static func validate(key: String) -> Bool {
116 | !key.isEmpty && key.rangeOfCharacter(from: notAlphanumericsAndDot) == nil
117 | }
118 |
119 | private static let notAlphanumericsAndDot: CharacterSet = {
120 | var result = CharacterSet.alphanumerics
121 | result.insert(".")
122 | return result.inverted
123 | }()
124 |
125 | private static func createParameter(from string: String) throws -> Parameter {
126 | let parts = string.components(separatedBy: "=")
127 | if parts.count >= 2 {
128 | return (key: parts[0], value: parts.dropFirst().joined(separator: "="))
129 | } else if let part = parts.first, parts.count == 1 && validate(key: part) {
130 | return (key: part, value: true)
131 | } else {
132 | throw Error.invalidSyntax(value: string)
133 | }
134 | }
135 |
136 | /// Flatten an object (dictionary, array or single object) into a list of keypath-type k=v pairs.
137 | /// This method recursively visits the object to build the flat representation.
138 | ///
139 | /// - Parameters:
140 | /// - object: The object to recursively flatten
141 | /// - keyPrefix: The prefix to use when creating keys.
142 | /// This is used to build the keyPath via recusrive calls of this function.
143 | /// You should start the root call of this recursive function with an empty keyPrefix.
144 | /// - Returns: The list of flatten "key.path=value" pair representations of the object
145 | private static func flatten(object: Any, keyPrefix: String = "") -> [String] {
146 | var values: [String] = []
147 | switch object {
148 | case is String, is Int, is Double:
149 | values.append("\(keyPrefix)=\(object)")
150 | case let bool as Bool where bool:
151 | values.append(keyPrefix)
152 | case let dict as [String: Any]:
153 | for (key, value) in dict {
154 | let fullKey = keyPrefix.isEmpty ? key : "\(keyPrefix).\(key)"
155 | values += flatten(object: value, keyPrefix: fullKey)
156 | }
157 | case let array as [Any]:
158 | values += array.flatMap { flatten(object: $0, keyPrefix: keyPrefix) }
159 | default:
160 | break
161 | }
162 | return values
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/ParametersTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import StencilSwiftKit
8 | import XCTest
9 |
10 | final class ParametersTests: XCTestCase {
11 | func testBasic() throws {
12 | let items = ["a=1", "b=hello", "c=x=y", "d"]
13 | let result = try Parameters.parse(items: items)
14 |
15 | XCTAssertEqual(result.count, 4, "4 parameters should have been parsed")
16 | XCTAssertEqual(result["a"] as? String, "1")
17 | XCTAssertEqual(result["b"] as? String, "hello")
18 | XCTAssertEqual(result["c"] as? String, "x=y")
19 | XCTAssertEqual(result["d"] as? Bool, true)
20 |
21 | // Test the opposite operation (flatten) as well
22 | let reverse = Parameters.flatten(dictionary: result)
23 | XCTAssertEqual(reverse.count, items.count, "Flattening dictionary != original list")
24 | XCTAssertEqual(Set(reverse), Set(items), "Flattening dictionary != original list")
25 | }
26 |
27 | func testStructured() throws {
28 | let items = ["foo.baz=1", "foo.bar=2", "foo.test"]
29 | let result = try Parameters.parse(items: items)
30 |
31 | XCTAssertEqual(result.count, 1, "1 parameter should have been parsed")
32 | guard let sub = result["foo"] as? [String: Any] else { XCTFail("Parsed parameter should be a dictionary"); return }
33 | XCTAssertEqual(sub["baz"] as? String, "1")
34 | XCTAssertEqual(sub["bar"] as? String, "2")
35 | XCTAssertEqual(sub["test"] as? Bool, true)
36 |
37 | // Test the opposite operation (flatten) as well
38 | let reverse = Parameters.flatten(dictionary: result)
39 | XCTAssertEqual(reverse.count, items.count, "Flattening dictionary != original list")
40 | XCTAssertEqual(Set(reverse), Set(items), "Flattening dictionary != original list")
41 | }
42 |
43 | func testDeepStructured() throws {
44 | let items = ["foo.bar.baz.qux=1"]
45 | let result = try Parameters.parse(items: items)
46 |
47 | XCTAssertEqual(result.count, 1, "1 parameter should have been parsed")
48 | guard let foo = result["foo"] as? [String: Any] else { XCTFail("Parsed parameter should be a dictionary"); return }
49 | guard let bar = foo["bar"] as? [String: Any] else { XCTFail("Parsed parameter should be a dictionary"); return }
50 | guard let baz = bar["baz"] as? [String: Any] else { XCTFail("Parsed parameter should be a dictionary"); return }
51 | guard let qux = baz["qux"] as? String else { XCTFail("Parsed parameter should be a string"); return }
52 | XCTAssertEqual(qux, "1")
53 |
54 | // Test the opposite operation (flatten) as well
55 | let reverse = Parameters.flatten(dictionary: result)
56 | XCTAssertEqual(reverse.count, items.count, "Flattening dictionary != original list")
57 | XCTAssertEqual(Set(reverse), Set(items), "Flattening dictionary != original list")
58 | }
59 |
60 | func testRepeated() throws {
61 | let items = ["foo=1", "foo=2", "foo=hello"]
62 | let result = try Parameters.parse(items: items)
63 |
64 | XCTAssertEqual(result.count, 1, "1 parameter should have been parsed")
65 | guard let sub = result["foo"] as? [String] else { XCTFail("Parsed parameter should be an array"); return }
66 | XCTAssertEqual(sub.count, 3, "Array has 3 elements")
67 | XCTAssertEqual(sub[0], "1")
68 | XCTAssertEqual(sub[1], "2")
69 | XCTAssertEqual(sub[2], "hello")
70 |
71 | // Test the opposite operation (flatten) as well
72 | let reverse = Parameters.flatten(dictionary: result)
73 | XCTAssertEqual(reverse.count, items.count, "Flattening dictionary != original list")
74 | XCTAssertEqual(Set(reverse), Set(items), "Flattening dictionary != original list")
75 | XCTAssertEqual(reverse, items, "The order of arrays are properly preserved when flattened")
76 | }
77 |
78 | func testFlattenBool() {
79 | let trueFlag = Parameters.flatten(dictionary: ["test": true])
80 | XCTAssertEqual(trueFlag, ["test"], "True flag is flattened to a param without value")
81 |
82 | let falseFlag = Parameters.flatten(dictionary: ["test": false])
83 | XCTAssertEqual(falseFlag, [], "False flag is flattened to nothing")
84 |
85 | let stringFlag = Parameters.flatten(dictionary: ["test": "a"])
86 | XCTAssertEqual(stringFlag, ["test=a"], "Non-boolean flag is flattened to a parameter with value")
87 |
88 | let falseStringFlag = Parameters.flatten(dictionary: ["test": "false"])
89 | XCTAssertEqual(falseStringFlag, ["test=false"], "Non-boolean flag is flattened to a parameter with value")
90 | }
91 |
92 | func testParseInvalidSyntax() {
93 | // invalid character
94 | do {
95 | let items = ["foo:1"]
96 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
97 | guard case Parameters.Error.invalidSyntax = error else {
98 | XCTFail("Unexpected error occured while parsing: \(error)")
99 | return
100 | }
101 | }
102 | }
103 |
104 | // invalid character
105 | do {
106 | let items = ["foo!1"]
107 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
108 | guard case Parameters.Error.invalidSyntax = error else {
109 | XCTFail("Unexpected error occured while parsing: \(error)")
110 | return
111 | }
112 | }
113 | }
114 |
115 | // cannot be empty
116 | do {
117 | let items = [""]
118 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
119 | guard case Parameters.Error.invalidSyntax = error else {
120 | XCTFail("Unexpected error occured while parsing: \(error)")
121 | return
122 | }
123 | }
124 | }
125 | }
126 |
127 | func testParseInvalidKey() {
128 | // key may only be alphanumeric or '.'
129 | do {
130 | let items = ["foo:bar=1"]
131 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
132 | guard case Parameters.Error.invalidKey = error else {
133 | XCTFail("Unexpected error occured while parsing: \(error)")
134 | return
135 | }
136 | }
137 | }
138 |
139 | // can't have empty key or sub-key
140 | do {
141 | let items = [".=1"]
142 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
143 | guard case Parameters.Error.invalidKey = error else {
144 | XCTFail("Unexpected error occured while parsing: \(error)")
145 | return
146 | }
147 | }
148 | }
149 |
150 | // can't have empty sub-key
151 | do {
152 | let items = ["foo.=1"]
153 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
154 | guard case Parameters.Error.invalidKey = error else {
155 | XCTFail("Unexpected error occured while parsing: \(error)")
156 | return
157 | }
158 | }
159 | }
160 | }
161 |
162 | func testParseInvalidStructure() {
163 | // can't switch from string to dictionary
164 | do {
165 | let items = ["foo=1", "foo.bar=1"]
166 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
167 | guard case Parameters.Error.invalidStructure = error else {
168 | XCTFail("Unexpected error occured while parsing: \(error)")
169 | return
170 | }
171 | }
172 | }
173 |
174 | // can't switch from dictionary to array
175 | do {
176 | let items = ["foo.bar=1", "foo=1"]
177 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
178 | guard case Parameters.Error.invalidStructure = error else {
179 | XCTFail("Unexpected error occured while parsing: \(error)")
180 | return
181 | }
182 | }
183 | }
184 |
185 | // can't switch from array to dictionary
186 | do {
187 | let items = ["foo=1", "foo=2", "foo.bar=1"]
188 | XCTAssertThrowsError(try Parameters.parse(items: items)) { error in
189 | guard case Parameters.Error.invalidStructure = error else {
190 | XCTFail("Unexpected error occured while parsing: \(error)")
191 | return
192 | }
193 | }
194 | }
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/Documentation/filters-strings.md:
--------------------------------------------------------------------------------
1 | # Filters
2 |
3 | This is a list of filters that are added by StencilSwiftKit on top of the filters already provided by Stencil (which you can [find here](http://stencil.fuller.li/en/latest/builtins.html#built-in-filters)).
4 |
5 | ## Filter: `basename`
6 |
7 | Get the last component of a path, essentially the filename (and extension).
8 |
9 | | Input | Output |
10 | |------------------------|----------------|
11 | | `test.jpg` | `test.jpg` |
12 | | `some/folder/test.jpg` | `test.jpg` |
13 | | `file.txt.png` | `file.txt.png` |
14 |
15 | ## Filter: `camelToSnakeCase`
16 |
17 | Transforms text from camelCase to snake_case. The filter accepts an optional boolean parameter:
18 |
19 | - **true** (default): Lowercase each component.
20 | - **false**: Keep the casing for each component.
21 |
22 | | Input | Output (`true`) | Output (`false`) |
23 | |-------------------------|-------------------------|-------------------------|
24 | | `SomeCapString` | `some_cap_string` | `Some_Cap_String` |
25 | | `someCapString` | `some_cap_string` | `some_Cap_String` |
26 | | `String_With_WoRds` | `string_with_words` | `String_With_Wo_Rds` |
27 | | `string_wiTH_WOrds` | `st_ring_with_words` | `string_wi_TH_W_Ords` |
28 | | `URLChooser` | `url_chooser` | `URL_Chooser` |
29 | | `PLEASE_STOP_SCREAMING` | `please_stop_screaming` | `PLEASE_STOP_SCREAMING` |
30 |
31 | ## Filter: `contains`
32 |
33 | Checks if the string contains given substring - works the same as Swift's `String.contains`.
34 |
35 | | Input | Output |
36 | |-------------------|---------|
37 | | `Hello` `el` | `true` |
38 | | `Hi mates!` `yo` | `false` |
39 |
40 | ## Filter: `dirname`
41 |
42 | Remove the last component of a path, essentially returning the path without the filename (and extension).
43 |
44 | | Input | Output |
45 | |------------------------|---------------|
46 | | `test.jpg` | `` |
47 | | `some/folder/test.jpg` | `some/folder` |
48 | | `file.txt.png` | `` |
49 |
50 | ## Filter: `escapeReservedKeywords`
51 |
52 | Checks if the given string matches a reserved Swift keyword. If it does, wrap the string in escape characters (backticks).
53 |
54 | | Input | Output |
55 | |---------|--------------|
56 | | `hello` | `hello` |
57 | | `self` | `` `self` `` |
58 | | `Any` | `` `Any` `` |
59 |
60 | ## Filter: `hasPrefix`
61 |
62 | Checks if the string has the given prefix - works the same as Swift's `String.hasPrefix`.
63 |
64 | | Input | Output |
65 | |-------------------|---------|
66 | | `Hello` `Hi` | `false` |
67 | | `Hi mates!` `H` | `true` |
68 |
69 | ## Filter: `hasSuffix`
70 |
71 | Checks if the string has the given suffix - works the same as Swift's `String.hasSuffix`.
72 |
73 | | Input | Output |
74 | |-------------------|---------|
75 | | `Hello` `llo` | `true` |
76 | | `Hi mates!` `?` | `false` |
77 |
78 | ## Filter: `lowerFirstLetter`
79 |
80 | Simply lowercases the first character, leaving the other characters untouched.
81 |
82 | | Input | Output |
83 | |-----------------|-----------------|
84 | | `Hello` | `hello` |
85 | | `PeopleChooser` | `peopleChooser` |
86 | | `Hi There!` | `hi There!` |
87 |
88 | ## Filter: `lowerFirstWord`
89 |
90 | Transforms an arbitrary string so that only the first "word" is lowercased.
91 |
92 | - If the string starts with only one uppercase character, lowercase that first character.
93 | - If the string starts with multiple uppercase character, lowercase those first characters up to the one before the last uppercase one, but only if the last one is followed by a lowercase character. This allows to support strings beginnng with an acronym, like `URL`.
94 |
95 | | Input | Output |
96 | |----------------|----------------|
97 | | `PeoplePicker` | `peoplePicker` |
98 | | `URLChooser` | `urlChooser` |
99 |
100 | ## Filter: `replace`
101 |
102 | This filter receives at least 2 parameters: a search parameter and a replacement.
103 | This filter has a couple of modes that you can specify using an optional mode argument:
104 |
105 | - **normal** (default): Simple find and replace.
106 | - **regex**: Enables the use of regular expressions in the search parameter.
107 |
108 | | Input (search, replacement) | Output (`normal`) | Output (`regex`) |
109 | |-----------------------------|-------------------|------------------|
110 | | `Hello` (`l`, `k`) | `Hekko` | `Hekko` |
111 | | `Europe` (`e`, `a`) | `Europa` | `Europa` |
112 | | `Hey1World2!` (`\d`, ` `) | `Hey1World2!` | `Hey World !` |
113 |
114 | ## Filter: `snakeToCamelCase`
115 |
116 | Transforms a string in "snake_case" format into one in "camelCase" format, following the steps below:
117 |
118 | - Separate the string in components using the `_` as separator.
119 | - For each component, uppercase the first character and do not touch the other characters in the component.
120 | - Join the components again into one string.
121 |
122 | If the whole starting "snake_case" string only contained uppercase characters, then each component will be capitalized: uppercase the first character and lowercase the other characters.
123 |
124 | This filter accepts an optional boolean parameter that controls the prefixing behaviour:
125 |
126 | - **false** (default): don't remove any empty components.
127 | - **true**: trim empty components from the beginning of the string
128 |
129 | | Input | Output (`false`) | Output (`true`) |
130 | |----------------|------------------|-----------------|
131 | | `snake_case` | `SnakeCase` | `SnakeCase` |
132 | | `snAke_case` | `SnAkeCase` | `SnAkeCase` |
133 | | `SNAKE_CASE` | `SnakeCase` | `SnakeCase` |
134 | | `__snake_case` | `__SnakeCase` | `SnakeCase` |
135 |
136 | ## Filter: `swiftIdentifier`
137 |
138 | Transforms an arbitrary string into a valid Swift identifier (using only valid characters for a Swift identifier as defined in the Swift language reference). It will apply the following rules:
139 |
140 | - Uppercase the first character.
141 | - Prefix with an underscore if the first character is a number.
142 | - Replace invalid characters by an underscore (`_`).
143 |
144 | The list of allowed characters can be found here:
145 | https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/LexicalStructure.html
146 |
147 | This filter has a couple of modes that you can specify using an optional mode argument:
148 |
149 | - **valid**: Only do the bare minimum for a valid identifier, i.e. the steps mentioned above **except** uppercasing characters.
150 | - **normal** (default): apply the steps mentioned above (uppercase first, prefix if needed, replace invalid characters with `_`).
151 | - **pretty**: Same as normal, but afterwards it will apply the `snakeToCamelCase` filter, and other manipulations, for a prettier (but still valid) identifier.
152 |
153 | | Input | Output (`valid`) | Output (`normal`) | Output (`pretty`) |
154 | |------------------------|-------------------------|-------------------------|----------------------|
155 | | `hello` | `hello` | `Hello` | `Hello` |
156 | | `42hello` | `_42hello` | `_42hello` | `_42hello` |
157 | | `some$URL` | `some_URL` | `Some_URL` | `SomeURL` |
158 | | `25 Ultra Light` | `_25_Ultra_Light` | `_25_Ultra_Light` | `_25UltraLight` |
159 | | `26_extra_ultra_light` | `_26_extra_ultra_light` | `_26_extra_ultra_light` | `_26ExtraUltraLight` |
160 | | `apples.count` | `apples_count` | `Apples_Count` | `ApplesCount` |
161 | | `foo_bar.baz.qux-yay` | `foo_bar_baz_qux_yay` | `Foo_bar_Baz_Qux_Yay` | `FooBarBazQuxYay` |
162 |
163 |
164 | ## Filter: `titlecase`
165 |
166 | Deprecated in favor of `upperFirstLetter`.
167 |
168 | ## Filter: `upperFirstLetter`
169 |
170 | Simply uppercases the first character, leaving the other characters untouched.
171 |
172 | Note that even if very similar, this filter differs from the `capitalized` filter, which uppercases the first character but also lowercases the remaining characters.
173 |
174 | | Input | Output |
175 | |-----------------|-----------------|
176 | | `hello` | `Hello` |
177 | | `peopleChooser` | `PeopleChooser` |
178 |
--------------------------------------------------------------------------------
/rakelib/utils.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Used constants:
4 | # - MIN_XCODE_VERSION
5 |
6 | require 'json'
7 | require 'open3'
8 | require 'pathname'
9 |
10 | # Utility functions to run Xcode commands, extract versionning info and logs messages
11 | #
12 | class Utils
13 | COLUMN_WIDTHS = [45, 12].freeze
14 |
15 | ## [ Run commands ] #########################################################
16 |
17 | # formatter types
18 | # :xcpretty : through xcpretty and store in artifacts
19 | # :raw : store in artifacts
20 | # :to_string : run using backticks and return output
21 |
22 | # run a command using xcrun and xcpretty if applicable
23 | def self.run(command, task, subtask = '', xcrun: false, formatter: :raw)
24 | commands = if xcrun and OS.mac?
25 | Array(command).map { |cmd| "#{version_select} xcrun #{cmd}" }
26 | else
27 | Array(command)
28 | end
29 | case formatter
30 | when :xcpretty then xcpretty(commands, task, subtask)
31 | when :raw then plain(commands, task, subtask)
32 | when :to_string then `#{commands.join(' && ')}`
33 | else raise "Unknown formatter '#{formatter}'"
34 | end
35 | end
36 |
37 | ## [ Convenience Helpers ] ##################################################
38 |
39 | def self.podspec_as_json(file)
40 | file += '.podspec' unless file.include?('.podspec')
41 | json, _, _ = Open3.capture3('bundle', 'exec', 'pod', 'ipc', 'spec', file)
42 | JSON.parse(json)
43 | end
44 |
45 | def self.podspec_version(file)
46 | podspec_as_json(file)['version']
47 | end
48 |
49 | def self.pod_trunk_last_version(pod)
50 | require 'yaml'
51 | stdout, _, _ = Open3.capture3('bundle', 'exec', 'pod', 'trunk', 'info', pod)
52 | stdout.sub!("\n#{pod}\n", '')
53 | last_version_line = YAML.safe_load(stdout).first['Versions'].last
54 | /^[0-9.]*/.match(last_version_line)[0] # Just the 'x.y.z' part
55 | end
56 |
57 | def self.spm_own_version(dep)
58 | dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
59 | dependencies.find { |d| d['package'] == dep }['state']['version']
60 | end
61 |
62 | def self.spm_resolved_version(dep)
63 | dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
64 | dependencies.find { |d| d['package'] == dep }['state']['version']
65 | end
66 |
67 | def self.last_git_tag_version
68 | `git describe --tags --abbrev=0`.strip
69 | end
70 |
71 | def self.octokit_client
72 | token = ENV['DANGER_GITHUB_API_TOKEN']
73 | token ||= File.exist?('.apitoken') && File.read('.apitoken')
74 | token ||= File.exist?('../.apitoken') && File.read('../.apitoken')
75 | Utils.print_error('No .apitoken file found') unless token
76 | require 'octokit'
77 | Octokit::Client.new(access_token: token)
78 | end
79 |
80 | def self.top_changelog_version(changelog_file = 'CHANGELOG.md')
81 | header, _, _ = Open3.capture3('grep', '-m', '1', '^## ', changelog_file)
82 | header.gsub('## ', '').strip
83 | end
84 |
85 | def self.top_changelog_entry(changelog_file = 'CHANGELOG.md')
86 | tag = top_changelog_version
87 | stdout, _, _ = Open3.capture3('sed', '-n', "/^## #{tag}$/,/^## /p", changelog_file)
88 | stdout.gsub(/^## .*$/, '').strip
89 | end
90 |
91 | ## [ Print info/errors ] ####################################################
92 |
93 | # print an info header
94 | def self.print_header(str)
95 | puts "== #{str.chomp} ==".format(:yellow, :bold)
96 | end
97 |
98 | # print an info message
99 | def self.print_info(str)
100 | puts str.chomp.format(:green)
101 | end
102 |
103 | # print an error message
104 | def self.print_error(str)
105 | puts str.chomp.format(:red)
106 | end
107 |
108 | # format an info message in a 2 column table
109 | def self.table_header(col1, col2)
110 | puts "| #{col1.ljust(COLUMN_WIDTHS[0])} | #{col2.ljust(COLUMN_WIDTHS[1])} |"
111 | puts "| #{'-' * COLUMN_WIDTHS[0]} | #{'-' * COLUMN_WIDTHS[1]} |"
112 | end
113 |
114 | # format an info message in a 2 column table
115 | def self.table_info(label, msg)
116 | puts "| #{label.ljust(COLUMN_WIDTHS[0])} | 👉 #{msg.ljust(COLUMN_WIDTHS[1] - 4)} |"
117 | end
118 |
119 | # format a result message in a 2 column table
120 | def self.table_result(result, label, error_msg)
121 | if result
122 | puts "| #{label.ljust(COLUMN_WIDTHS[0])} | #{'✅'.ljust(COLUMN_WIDTHS[1] - 1)} |"
123 | else
124 | puts "| #{label.ljust(COLUMN_WIDTHS[0])} | ❌ - #{error_msg.ljust(COLUMN_WIDTHS[1] - 6)} |"
125 | end
126 | result
127 | end
128 |
129 | ## [ Private helper functions ] ##################################################
130 |
131 | # run a command, pipe output through 'xcpretty' and store the output in CI artifacts
132 | def self.xcpretty(cmd, task, subtask)
133 | command = Array(cmd).join(' && \\' + "\n")
134 |
135 | if ENV['CI']
136 | Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color --report junit)
137 | elsif system('which xcpretty > /dev/null')
138 | Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color)
139 | else
140 | Rake.sh command
141 | end
142 | end
143 | private_class_method :xcpretty
144 |
145 | # run a command and store the output in CI artifacts
146 | def self.plain(cmd, task, subtask)
147 | command = Array(cmd).join(' && \\' + "\n")
148 |
149 | if ENV['CI']
150 | if OS.mac?
151 | Rake.sh %(set -o pipefail && (#{command}))
152 | else
153 | # dash on linux doesn't support `set -o`
154 | Rake.sh %(/bin/bash -eo pipefail -c "#{command}")
155 | end
156 | else
157 | Rake.sh command
158 | end
159 | end
160 | private_class_method :plain
161 |
162 | # select the xcode version we want/support
163 | def self.version_select
164 | @version_select ||= compute_developer_dir(MIN_XCODE_VERSION)
165 | end
166 | private_class_method :version_select
167 |
168 | # Return the "DEVELOPER_DIR=..." prefix to use in order to point to the best Xcode version
169 | #
170 | # @param [String|Float|Gem::Requirement] version_req
171 | # The Xcode version requirement.
172 | # - If it's a Float, it's converted to a "~> x.y" requirement
173 | # - If it's a String, it's converted to a Gem::Requirement as is
174 | # @note If you pass a String, be sure to use "~> " in the string unless you really want
175 | # to point to an exact, very specific version
176 | #
177 | def self.compute_developer_dir(version_req)
178 | version_req = Gem::Requirement.new("~> #{version_req}") if version_req.is_a?(Float)
179 | version_req = Gem::Requirement.new(version_req) unless version_req.is_a?(Gem::Requirement)
180 | # if current Xcode already fulfills min version don't force DEVELOPER_DIR=...
181 | current_xcode_version = `xcodebuild -version`.split("\n").first.match(/[0-9.]+/).to_s
182 | return '' if version_req.satisfied_by? Gem::Version.new(current_xcode_version)
183 |
184 | supported_versions = all_xcode_versions.select { |app| version_req.satisfied_by?(app[:vers]) }
185 | latest_supported_xcode = supported_versions.sort_by { |app| app[:vers] }.last
186 |
187 | # Check if it's at least the right version
188 | if latest_supported_xcode.nil?
189 | raise "\n[!!!] SwiftGen requires Xcode #{version_req}, but we were not able to find it. " \
190 | "If it's already installed, either `xcode-select -s` to it, or update your Spotlight index " \
191 | "with 'mdimport /Applications/Xcode*'\n\n"
192 | end
193 |
194 | %(DEVELOPER_DIR="#{latest_supported_xcode[:path]}/Contents/Developer")
195 | end
196 | private_class_method :compute_developer_dir
197 |
198 | # @return [Array] A list of { :vers => ... , :path => ... } hashes
199 | # of all Xcodes found on the machine using Spotlight
200 | def self.all_xcode_versions
201 | xcodes = `mdfind "kMDItemCFBundleIdentifier = 'com.apple.dt.Xcode'"`.chomp.split("\n")
202 | xcodes.map do |path|
203 | { vers: Gem::Version.new(`mdls -name kMDItemVersion -raw "#{path}"`), path: path }
204 | end
205 | end
206 | private_class_method :all_xcode_versions
207 | end
208 |
209 | # OS detection
210 | #
211 | module OS
212 | def OS.mac?
213 | (/darwin/ =~ RUBY_PLATFORM) != nil
214 | end
215 |
216 | def OS.linux?
217 | OS.unix? and not OS.mac?
218 | end
219 | end
220 |
221 | # Colorization support for Strings
222 | #
223 | class String
224 | # colorization
225 | FORMATTING = {
226 | # text styling
227 | bold: 1,
228 | faint: 2,
229 | italic: 3,
230 | underline: 4,
231 | # foreground colors
232 | black: 30,
233 | red: 31,
234 | green: 32,
235 | yellow: 33,
236 | blue: 34,
237 | magenta: 35,
238 | cyan: 36,
239 | white: 37,
240 | # background colors
241 | bg_black: 40,
242 | bg_red: 41,
243 | bg_green: 42,
244 | bg_yellow: 43,
245 | bg_blue: 44,
246 | bg_magenta: 45,
247 | bg_cyan: 46,
248 | bg_white: 47
249 | }.freeze
250 |
251 | # only enable formatting if terminal supports it
252 | if `tput colors`.chomp.to_i >= 8
253 | def format(*styles)
254 | styles.map { |s| "\e[#{FORMATTING[s]}m" }.join + self + "\e[0m"
255 | end
256 | else
257 | def format(*_styles)
258 | self
259 | end
260 | end
261 | end
262 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/SetNodeTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | @testable import Stencil
8 | @testable import StencilSwiftKit
9 | import XCTest
10 |
11 | final class SetNodeTests: XCTestCase {
12 | func testParserRenderMode() {
13 | let tokens: [Token] = [
14 | .block(value: "set value", at: .unknown),
15 | .text(value: "true", at: .unknown),
16 | .block(value: "endset", at: .unknown)
17 | ]
18 |
19 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
20 | guard let nodes = try? parser.parse(),
21 | let node = nodes.first as? SetNode else {
22 | XCTFail("Unable to parse tokens")
23 | return
24 | }
25 |
26 | XCTAssertEqual(node.variableName, "value")
27 | switch node.content {
28 | case .nodes(let nodes):
29 | XCTAssertEqual(nodes.count, 1)
30 | XCTAssert(nodes.first is TextNode)
31 | default:
32 | XCTFail("Unexpected node content")
33 | }
34 | }
35 |
36 | func testParserEvaluateModeVariable() {
37 | let tokens: [Token] = [
38 | .block(value: "set value some.variable.somewhere", at: .unknown)
39 | ]
40 |
41 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
42 | guard let nodes = try? parser.parse(),
43 | let node = nodes.first as? SetNode else {
44 | XCTFail("Unable to parse tokens")
45 | return
46 | }
47 |
48 | XCTAssertEqual(node.variableName, "value")
49 | switch node.content {
50 | case .reference(let reference as FilterExpression):
51 | XCTAssertEqual(reference.variable.variable, "some.variable.somewhere")
52 | default:
53 | XCTFail("Unexpected node content")
54 | }
55 | }
56 |
57 | func testParserEvaluateModeRange() {
58 | let tokens: [Token] = [
59 | .block(value: "set value 1...3", at: .unknown)
60 | ]
61 |
62 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
63 | guard let nodes = try? parser.parse(),
64 | let node = nodes.first as? SetNode else {
65 | XCTFail("Unable to parse tokens")
66 | return
67 | }
68 |
69 | XCTAssertEqual(node.variableName, "value")
70 | switch node.content {
71 | case .reference(_ as RangeVariable):
72 | break
73 | default:
74 | XCTFail("Unexpected node content")
75 | }
76 | }
77 |
78 | func testParserFail() {
79 | do {
80 | let tokens: [Token] = [
81 | .block(value: "set value", at: .unknown),
82 | .text(value: "true", at: .unknown)
83 | ]
84 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
85 | XCTAssertThrowsError(try parser.parse())
86 | }
87 |
88 | do {
89 | let tokens: [Token] = [
90 | .block(value: "set value true", at: .unknown),
91 | .text(value: "true", at: .unknown),
92 | .block(value: "endset", at: .unknown)
93 | ]
94 | let parser = TokenParser(tokens: tokens, environment: stencilSwiftEnvironment())
95 | XCTAssertThrowsError(try parser.parse())
96 | }
97 | }
98 |
99 | func testRender() throws {
100 | do {
101 | let node = SetNode(variableName: "value", content: .nodes([TextNode(text: "true")]))
102 | let context = Context(dictionary: [:])
103 | let output = try node.render(context)
104 | XCTAssertEqual(output, "")
105 | }
106 |
107 | do {
108 | let node = SetNode(variableName: "value", content: .reference(resolvable: Variable("test")))
109 | let context = Context(dictionary: [:])
110 | let output = try node.render(context)
111 | XCTAssertEqual(output, "")
112 | }
113 | }
114 |
115 | func testContextModification() throws {
116 | // start empty
117 | let context = Context(dictionary: ["": ""])
118 | XCTAssertNil(context["a"])
119 | XCTAssertNil(context["b"])
120 | XCTAssertNil(context["c"])
121 |
122 | // set a
123 | var node = SetNode(variableName: "a", content: .nodes([TextNode(text: "hello")]))
124 | _ = try node.render(context)
125 | XCTAssertEqual(context["a"] as? String, "hello")
126 | XCTAssertNil(context["b"])
127 | XCTAssertNil(context["c"])
128 |
129 | // set b
130 | node = SetNode(variableName: "b", content: .nodes([TextNode(text: "world")]))
131 | _ = try node.render(context)
132 | XCTAssertEqual(context["a"] as? String, "hello")
133 | XCTAssertEqual(context["b"] as? String, "world")
134 | XCTAssertNil(context["c"])
135 |
136 | // modify a
137 | node = SetNode(variableName: "a", content: .nodes([TextNode(text: "hi")]))
138 | _ = try node.render(context)
139 | XCTAssertEqual(context["a"] as? String, "hi")
140 | XCTAssertEqual(context["b"] as? String, "world")
141 | XCTAssertNil(context["c"])
142 |
143 | // alias a into c
144 | node = SetNode(variableName: "c", content: .reference(resolvable: Variable("a")))
145 | _ = try node.render(context)
146 | XCTAssertEqual(context["a"] as? String, "hi")
147 | XCTAssertEqual(context["b"] as? String, "world")
148 | XCTAssertEqual(context["c"] as? String, "hi")
149 |
150 | // alias non-existing into c
151 | node = SetNode(variableName: "c", content: .reference(resolvable: Variable("foo")))
152 | _ = try node.render(context)
153 | XCTAssertEqual(context["a"] as? String, "hi")
154 | XCTAssertEqual(context["b"] as? String, "world")
155 | XCTAssertNil(context["c"])
156 | }
157 |
158 | func testWithExistingContext() throws {
159 | // start with a=1, b=2
160 | let context = Context(dictionary: ["a": 1, "b": 2, "c": 3])
161 | XCTAssertEqual(context["a"] as? Int, 1)
162 | XCTAssertEqual(context["b"] as? Int, 2)
163 | XCTAssertEqual(context["c"] as? Int, 3)
164 |
165 | // set a
166 | var node = SetNode(variableName: "a", content: .nodes([TextNode(text: "hello")]))
167 | _ = try node.render(context)
168 | XCTAssertEqual(context["a"] as? String, "hello")
169 | XCTAssertEqual(context["b"] as? Int, 2)
170 | XCTAssertEqual(context["c"] as? Int, 3)
171 |
172 | // set b
173 | node = SetNode(variableName: "b", content: .nodes([TextNode(text: "world")]))
174 | _ = try node.render(context)
175 | XCTAssertEqual(context["a"] as? String, "hello")
176 | XCTAssertEqual(context["b"] as? String, "world")
177 | XCTAssertEqual(context["c"] as? Int, 3)
178 |
179 | // alias a into c
180 | node = SetNode(variableName: "c", content: .reference(resolvable: Variable("a")))
181 | _ = try node.render(context)
182 | XCTAssertEqual(context["a"] as? String, "hello")
183 | XCTAssertEqual(context["b"] as? String, "world")
184 | XCTAssertEqual(context["c"] as? String, "hello")
185 |
186 | // alias non-existing into c
187 | node = SetNode(variableName: "c", content: .reference(resolvable: Variable("foo")))
188 | _ = try node.render(context)
189 | XCTAssertEqual(context["a"] as? String, "hello")
190 | XCTAssertEqual(context["b"] as? String, "world")
191 | XCTAssertNil(context["c"])
192 | }
193 |
194 | func testContextPush() throws {
195 | // start with a=1, b=2
196 | let context = Context(dictionary: ["a": 1, "b": 2])
197 | XCTAssertEqual(context["a"] as? Int, 1)
198 | XCTAssertEqual(context["b"] as? Int, 2)
199 | XCTAssertNil(context["c"])
200 |
201 | // set a
202 | var node = SetNode(variableName: "a", content: .nodes([TextNode(text: "hello")]))
203 | _ = try node.render(context)
204 | XCTAssertEqual(context["a"] as? String, "hello")
205 | XCTAssertEqual(context["b"] as? Int, 2)
206 | XCTAssertNil(context["c"])
207 |
208 | // push context level
209 | try context.push {
210 | XCTAssertEqual(context["a"] as? String, "hello")
211 | XCTAssertEqual(context["b"] as? Int, 2)
212 | XCTAssertNil(context["c"])
213 |
214 | // set b
215 | node = SetNode(variableName: "b", content: .nodes([TextNode(text: "world")]))
216 | _ = try node.render(context)
217 | XCTAssertEqual(context["a"] as? String, "hello")
218 | XCTAssertEqual(context["b"] as? String, "world")
219 | XCTAssertNil(context["c"])
220 |
221 | // set c
222 | node = SetNode(variableName: "c", content: .nodes([TextNode(text: "foo")]))
223 | _ = try node.render(context)
224 | XCTAssertEqual(context["a"] as? String, "hello")
225 | XCTAssertEqual(context["b"] as? String, "world")
226 | XCTAssertEqual(context["c"] as? String, "foo")
227 | }
228 |
229 | // after pop
230 | XCTAssertEqual(context["a"] as? String, "hello")
231 | XCTAssertEqual(context["b"] as? Int, 2)
232 | XCTAssertNil(context["c"])
233 |
234 | // push context level
235 | try context.push {
236 | XCTAssertEqual(context["a"] as? String, "hello")
237 | XCTAssertEqual(context["b"] as? Int, 2)
238 | XCTAssertNil(context["c"])
239 |
240 | // alias a into c
241 | node = SetNode(variableName: "c", content: .reference(resolvable: Variable("a")))
242 | _ = try node.render(context)
243 | XCTAssertEqual(context["a"] as? String, "hello")
244 | XCTAssertEqual(context["b"] as? Int, 2)
245 | XCTAssertEqual(context["c"] as? String, "hello")
246 | }
247 |
248 | // after pop
249 | XCTAssertEqual(context["a"] as? String, "hello")
250 | XCTAssertEqual(context["b"] as? Int, 2)
251 | XCTAssertNil(context["c"])
252 | }
253 |
254 | func testDifferenceRenderEvaluate() throws {
255 | // start empty
256 | let context = Context(dictionary: ["items": [1, 3, 7]])
257 | XCTAssertNil(context["a"])
258 | XCTAssertNil(context["b"])
259 |
260 | // set a
261 | var node = SetNode(variableName: "a", content: .nodes([VariableNode(variable: "items")]))
262 | _ = try node.render(context)
263 | XCTAssertEqual(context["a"] as? String, "[1, 3, 7]")
264 | XCTAssertNil(context["b"])
265 |
266 | // set b
267 | node = SetNode(variableName: "b", content: .reference(resolvable: Variable("items")))
268 | _ = try node.render(context)
269 | XCTAssertEqual(context["b"] as? [Int], [1, 3, 7])
270 | }
271 |
272 | func testSetWithFilterExpressionParameter() throws {
273 | let context = Context(dictionary: ["greet": "hello"])
274 |
275 | let environment = stencilSwiftEnvironment()
276 | let argument = try FilterExpression(token: "greet|uppercase", environment: environment)
277 | let node = SetNode(variableName: "a", content: .reference(resolvable: argument))
278 |
279 | _ = try node.render(context)
280 | XCTAssertEqual(context["a"] as? String, "HELLO")
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/Sources/StencilSwiftKit/Filters+Strings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | import Foundation
8 | import PathKit
9 | import Stencil
10 |
11 | // MARK: - Strings Filters
12 |
13 | public extension Filters {
14 | /// Filters for operations related to strings
15 | enum Strings {
16 | }
17 | }
18 |
19 | /// Possible modes for removeNewlines filter
20 | @available(*, deprecated, message: "No longer needed with Stencil whitespace control features")
21 | public enum RemoveNewlinesModes: String {
22 | case all, leading
23 | }
24 |
25 | public enum ReplaceModes: String {
26 | case normal, regex
27 | }
28 |
29 | /// Possible modes for swiftIdentifier filter
30 | public enum SwiftIdentifierModes: String {
31 | case valid, normal, pretty
32 | }
33 |
34 | // MARK: - String Filters: Boolean filters
35 |
36 | public extension Filters.Strings {
37 | /// Checks if the given string contains given substring
38 | ///
39 | /// - Parameters:
40 | /// - value: the string value to check if it contains substring
41 | /// - arguments: the arguments to the function; expecting one string argument - substring
42 | /// - Returns: the result whether true or not
43 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string or
44 | /// if number of arguments is not one or if the given argument isn't a string
45 | static func contains(_ value: Any?, arguments: [Any?]) throws -> Bool {
46 | let string = try Filters.parseString(from: value)
47 | let substring = try Filters.parseStringArgument(from: arguments)
48 | return string.contains(substring)
49 | }
50 |
51 | /// Checks if the given string has given prefix
52 | ///
53 | /// - Parameters:
54 | /// - value: the string value to check if it has prefix
55 | /// - arguments: the arguments to the function; expecting one string argument - prefix
56 | /// - Returns: the result whether true or not
57 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string or
58 | /// if number of arguments is not one or if the given argument isn't a string
59 | static func hasPrefix(_ value: Any?, arguments: [Any?]) throws -> Bool {
60 | let string = try Filters.parseString(from: value)
61 | let prefix = try Filters.parseStringArgument(from: arguments)
62 | return string.hasPrefix(prefix)
63 | }
64 |
65 | /// Checks if the given string has given suffix
66 | ///
67 | /// - Parameters:
68 | /// - value: the string value to check if it has prefix
69 | /// - arguments: the arguments to the function; expecting one string argument - suffix
70 | /// - Returns: the result whether true or not
71 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string or
72 | /// if number of arguments is not one or if the given argument isn't a string
73 | static func hasSuffix(_ value: Any?, arguments: [Any?]) throws -> Bool {
74 | let string = try Filters.parseString(from: value)
75 | let suffix = try Filters.parseStringArgument(from: arguments)
76 | return string.hasSuffix(suffix)
77 | }
78 | }
79 |
80 | // MARK: - String Filters: Lettercase filters
81 |
82 | public extension Filters.Strings {
83 | /// Lowers the first letter of the string
84 | /// e.g. "People picker" gives "people picker", "Sports Stats" gives "sports Stats"
85 | static func lowerFirstLetter(_ value: Any?) throws -> Any? {
86 | let string = try Filters.parseString(from: value)
87 | let first = String(string.prefix(1)).lowercased()
88 | let other = String(string.dropFirst(1))
89 | return first + other
90 | }
91 |
92 | /// If the string starts with only one uppercase letter, lowercase that first letter
93 | /// If the string starts with multiple uppercase letters, lowercase those first letters
94 | /// up to the one before the last uppercase one, but only if the last one is followed by
95 | /// a lowercase character.
96 | /// e.g. "PeoplePicker" gives "peoplePicker" but "URLChooser" gives "urlChooser"
97 | static func lowerFirstWord(_ value: Any?) throws -> Any? {
98 | let string = try Filters.parseString(from: value)
99 | guard !string.isEmpty else { return "" }
100 | let characterSet = CharacterSet.uppercaseLetters
101 | let scalars = string.unicodeScalars
102 | let start = scalars.startIndex
103 | var idx = start
104 | while idx < scalars.endIndex, let scalar = UnicodeScalar(scalars[idx].value), characterSet.contains(scalar) {
105 | idx = scalars.index(after: idx)
106 | }
107 | if idx > scalars.index(after: start) && idx < scalars.endIndex,
108 | let scalar = UnicodeScalar(scalars[idx].value),
109 | CharacterSet.lowercaseLetters.contains(scalar) {
110 | idx = scalars.index(before: idx)
111 | }
112 | let transformed = String(scalars[start.. Any? {
125 | let string = try Filters.parseString(from: value)
126 | return upperFirstLetter(string)
127 | }
128 |
129 | /// Converts snake_case to camelCase. Takes an optional Bool argument for removing any resulting
130 | /// leading '_' characters, which defaults to false
131 | ///
132 | /// - Parameters:
133 | /// - value: the value to be processed
134 | /// - arguments: the arguments to the function; expecting zero or one boolean argument
135 | /// - Returns: the camel case string
136 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string
137 | static func snakeToCamelCase(_ value: Any?, arguments: [Any?]) throws -> Any? {
138 | let stripLeading = try Filters.parseBool(from: arguments, required: false) ?? false
139 | let string = try Filters.parseString(from: value)
140 |
141 | return try snakeToCamelCase(string, stripLeading: stripLeading)
142 | }
143 |
144 | /// Converts camelCase to snake_case. Takes an optional Bool argument for making the string lower case,
145 | /// which defaults to true
146 | ///
147 | /// - Parameters:
148 | /// - value: the value to be processed
149 | /// - arguments: the arguments to the function; expecting zero or one boolean argument
150 | /// - Returns: the snake case string
151 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string
152 | static func camelToSnakeCase(_ value: Any?, arguments: [Any?]) throws -> Any? {
153 | let toLower = try Filters.parseBool(from: arguments, required: false) ?? true
154 | let string = try Filters.parseString(from: value)
155 | let snakeCase = try snakecase(string)
156 | if toLower {
157 | return snakeCase.lowercased()
158 | }
159 | return snakeCase
160 | }
161 |
162 | /// Converts snake_case to camelCase, stripping prefix underscores if needed
163 | ///
164 | /// - Parameters:
165 | /// - string: the value to be processed
166 | /// - stripLeading: if false, will preserve leading underscores
167 | /// - Returns: the camel case string
168 | static func snakeToCamelCase(_ string: String, stripLeading: Bool) throws -> String {
169 | let unprefixed: String
170 | if try containsAnyLowercasedChar(string) {
171 | let comps = string.components(separatedBy: "_")
172 | unprefixed = comps.map { upperFirstLetter($0) }.joined()
173 | } else {
174 | let comps = try snakecase(string).components(separatedBy: "_")
175 | unprefixed = comps.map { $0.capitalized }.joined()
176 | }
177 |
178 | // only if passed true, strip the prefix underscores
179 | var prefixUnderscores = ""
180 | var result: String { prefixUnderscores + unprefixed }
181 | if stripLeading {
182 | return result
183 | }
184 | for scalar in string.unicodeScalars {
185 | guard scalar == "_" else { break }
186 | prefixUnderscores += "_"
187 | }
188 | return result
189 | }
190 | }
191 |
192 | private extension Filters.Strings {
193 | static func containsAnyLowercasedChar(_ string: String) throws -> Bool {
194 | let lowercaseCharRegex = try NSRegularExpression(pattern: "[a-z]", options: .dotMatchesLineSeparators)
195 | let fullRange = NSRange(location: 0, length: string.unicodeScalars.count)
196 | return lowercaseCharRegex.firstMatch(in: string, options: .reportCompletion, range: fullRange) != nil
197 | }
198 |
199 | /// Uppers the first letter of the string
200 | /// e.g. "people picker" gives "People picker", "sports Stats" gives "Sports Stats"
201 | ///
202 | /// - Parameters:
203 | /// - value: the value to uppercase first letter of
204 | /// - arguments: the arguments to the function; expecting zero
205 | /// - Returns: the string with first letter being uppercased
206 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string
207 | static func upperFirstLetter(_ value: String) -> String {
208 | guard let first = value.unicodeScalars.first else { return value }
209 | return String(first).uppercased() + String(value.unicodeScalars.dropFirst())
210 | }
211 |
212 | /// This returns the snake cased variant of the string.
213 | ///
214 | /// - Parameter string: The string to snake_case
215 | /// - Returns: The string snake cased from either snake_cased or camelCased string.
216 | static func snakecase(_ string: String) throws -> String {
217 | let longUpper = try NSRegularExpression(pattern: "([A-Z\\d]+)([A-Z][a-z])", options: .dotMatchesLineSeparators)
218 | let camelCased = try NSRegularExpression(pattern: "([a-z\\d])([A-Z])", options: .dotMatchesLineSeparators)
219 |
220 | let fullRange = NSRange(location: 0, length: string.unicodeScalars.count)
221 | var result = longUpper.stringByReplacingMatches(
222 | in: string,
223 | options: .reportCompletion,
224 | range: fullRange,
225 | withTemplate: "$1_$2"
226 | )
227 | result = camelCased.stringByReplacingMatches(
228 | in: result,
229 | options: .reportCompletion,
230 | range: fullRange,
231 | withTemplate: "$1_$2"
232 | )
233 | return result.replacingOccurrences(of: "-", with: "_")
234 | }
235 | }
236 |
237 | // MARK: - String Filters: Mutation filters
238 |
239 | public extension Filters.Strings {
240 | /// Tries to parse the value as a `String`, and then checks if the string is one of the reserved keywords.
241 | /// If so, escapes it using backticks
242 | ///
243 | /// - Parameter in: the string to possibly escape
244 | /// - Returns: if the string is a reserved keyword, the escaped string, otherwise the original one
245 | /// - Throws: Filters.Error.invalidInputType
246 | static func escapeReservedKeywords(value: Any?) throws -> Any? {
247 | let string = try Filters.parseString(from: value)
248 | return escapeReservedKeywords(in: string)
249 | }
250 |
251 | /// Replaces in the given string the given substring with the replacement
252 | /// "people picker", replacing "picker" with "life" gives "people life"
253 | ///
254 | /// - Parameters:
255 | /// - value: the value to be processed
256 | /// - arguments: the arguments to the function; expecting two arguments: substring, replacement
257 | /// - Returns: the results string
258 | /// - Throws: FilterError.invalidInputType if the value parameter or argunemts aren't string
259 | static func replace(_ value: Any?, arguments: [Any?]) throws -> Any? {
260 | let source = try Filters.parseString(from: value)
261 | let substring = try Filters.parseStringArgument(from: arguments, at: 0)
262 | let replacement = try Filters.parseStringArgument(from: arguments, at: 1)
263 | let mode = try Filters.parseEnum(from: arguments, at: 2, default: ReplaceModes.normal)
264 |
265 | switch mode {
266 | case .normal:
267 | return source.replacingOccurrences(of: substring, with: replacement)
268 | case .regex:
269 | return source.replacingOccurrences(of: substring, with: replacement, options: .regularExpression)
270 | }
271 | }
272 |
273 | /// Converts an arbitrary string to a valid swift identifier. Takes an optional Mode argument:
274 | /// - valid: prefix with an underscore if starting with a number, replace invalid characters by underscores
275 | /// - normal (default): uppercase the first character, prefix with an underscore if starting with a number, replace
276 | /// invalid characters by underscores
277 | /// - leading: same as the above, but apply the snaceToCamelCase filter first for a nicer identifier
278 | ///
279 | /// - Parameters:
280 | /// - value: the value to be processed
281 | /// - arguments: the arguments to the function; expecting zero or one mode argument
282 | /// - Returns: the identifier string
283 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string
284 | static func swiftIdentifier(_ value: Any?, arguments: [Any?]) throws -> Any? {
285 | var string = try Filters.parseString(from: value)
286 | let mode = try Filters.parseEnum(from: arguments, default: SwiftIdentifierModes.normal)
287 |
288 | switch mode {
289 | case .valid:
290 | return SwiftIdentifier.identifier(from: string, capitalizeComponents: false, replaceWithUnderscores: true)
291 | case .normal:
292 | return SwiftIdentifier.identifier(from: string, capitalizeComponents: true, replaceWithUnderscores: true)
293 | case .pretty:
294 | string = SwiftIdentifier.identifier(from: string, capitalizeComponents: true, replaceWithUnderscores: true)
295 | string = try snakeToCamelCase(string, stripLeading: true)
296 | return SwiftIdentifier.prefixWithUnderscoreIfNeeded(string: string)
297 | }
298 | }
299 |
300 | /// Converts a file path to just the filename, stripping any path components before it.
301 | ///
302 | /// - Parameter value: the value to be processed
303 | /// - Returns: the basename of the path
304 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string
305 | static func basename(_ value: Any?) throws -> Any? {
306 | let string = try Filters.parseString(from: value)
307 | return Path(string).lastComponent
308 | }
309 |
310 | /// Converts a file path to just the path without the filename.
311 | ///
312 | /// - Parameter value: the value to be processed
313 | /// - Returns: the dirname of the path
314 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string
315 | static func dirname(_ value: Any?) throws -> Any? {
316 | let string = try Filters.parseString(from: value)
317 | return Path(string).parent().normalize().string
318 | }
319 |
320 | /// Removes newlines and other whitespace from a string. Takes an optional Mode argument:
321 | /// - all (default): remove all newlines and whitespaces
322 | /// - leading: remove newlines and only leading whitespaces
323 | ///
324 | /// - Parameters:
325 | /// - value: the value to be processed
326 | /// - arguments: the arguments to the function; expecting zero or one mode argument
327 | /// - Returns: the trimmed string
328 | /// - Throws: FilterError.invalidInputType if the value parameter isn't a string
329 | @available(*, deprecated, message: "No longer needed with Stencil whitespace control features")
330 | static func removeNewlines(_ value: Any?, arguments: [Any?]) throws -> Any? {
331 | let string = try Filters.parseString(from: value)
332 | let mode = try Filters.parseEnum(from: arguments, default: RemoveNewlinesModes.all)
333 |
334 | switch mode {
335 | case .all:
336 | return string
337 | .components(separatedBy: .whitespacesAndNewlines)
338 | .joined()
339 | case .leading:
340 | return string
341 | .components(separatedBy: .newlines)
342 | .map(removeLeadingWhitespaces(from:))
343 | .joined()
344 | .trimmingCharacters(in: .whitespaces)
345 | }
346 | }
347 | }
348 |
349 | private extension Filters.Strings {
350 | static let reservedKeywords = [
351 | "associatedtype", "class", "deinit", "enum", "extension",
352 | "fileprivate", "func", "import", "init", "inout", "internal",
353 | "let", "open", "operator", "private", "protocol", "public",
354 | "static", "struct", "subscript", "typealias", "var", "break",
355 | "case", "continue", "default", "defer", "do", "else",
356 | "fallthrough", "for", "guard", "if", "in", "repeat", "return",
357 | "switch", "where", "while", "as", "Any", "catch", "false", "is",
358 | "nil", "rethrows", "super", "self", "Self", "throw", "throws",
359 | "true", "try", "_", "#available", "#colorLiteral", "#column",
360 | "#else", "#elseif", "#endif", "#file", "#fileLiteral",
361 | "#function", "#if", "#imageLiteral", "#line", "#selector",
362 | "#sourceLocation", "associativity", "convenience", "dynamic",
363 | "didSet", "final", "get", "infix", "indirect", "lazy", "left",
364 | "mutating", "none", "nonmutating", "optional", "override",
365 | "postfix", "precedence", "prefix", "Protocol", "required",
366 | "right", "set", "Type", "unowned", "weak", "willSet"
367 | ]
368 |
369 | static func removeLeadingWhitespaces(from string: String) -> String {
370 | let chars = string.unicodeScalars.drop { CharacterSet.whitespaces.contains($0) }
371 | return String(chars)
372 | }
373 |
374 | /// Checks if the string is one of the reserved keywords and if so, escapes it using backticks
375 | ///
376 | /// - Parameter in: the string to possibly escape
377 | /// - Returns: if the string is a reserved keyword, the escaped string, otherwise the original one
378 | static func escapeReservedKeywords(in string: String) -> String {
379 | guard reservedKeywords.contains(string) else {
380 | return string
381 | }
382 | return "`\(string)`"
383 | }
384 | }
385 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # StencilSwiftKit CHANGELOG
2 |
3 | ---
4 |
5 | ## Stable Branch
6 |
7 | ### Breaking Changes
8 |
9 | _None_
10 |
11 | ### New Features
12 |
13 | _None_
14 |
15 | ### Bug Fixes
16 |
17 | _None_
18 |
19 | ### Internal Changes
20 |
21 | _None_
22 |
23 | ## 2.10.1
24 |
25 | ### Internal Changes
26 |
27 | * Pin `Komondor` to 1.1.3, to avoid issues with SPM for other packages depending on this package.
28 | [redryerye](https://github.com/redryerye)
29 | [#162](https://github.com/SwiftGen/StencilSwiftKit/pull/162)
30 |
31 | ## 2.10.0
32 |
33 | ### Breaking Changes
34 |
35 | * With the Stencil update, we're deprecating our `StencilSwiftTemplate` template class which contained a workaround that's no longer needed. It will also no longer be set by default by the `stencilSwiftEnvironment` builder.
36 | [David Jennes](https://github.com/djbe)
37 | [#159](https://github.com/SwiftGen/StencilSwiftKit/pull/159)
38 | * With the Stencil update, we're deprecating our `removeNewlines` filter, as this can now be achieved by the built-in Stencil syntax.
39 | [David Jennes](https://github.com/djbe)
40 | [#159](https://github.com/SwiftGen/StencilSwiftKit/pull/159)
41 |
42 | ### New Features
43 |
44 | * Updated to Stencil 0.15.0, which contains many improvements, chief amongst them is whitespace behaviour control.
45 | [David Jennes](https://github.com/djbe)
46 | [#159](https://github.com/SwiftGen/StencilSwiftKit/pull/159)
47 | * Added an `import` tag for reusing macro's in multiple templates from a common imported file.
48 | [David Jennes](https://github.com/djbe)
49 | [#111](https://github.com/SwiftGen/StencilSwiftKit/pull/111)
50 | * The `swiftIdentifier` now supports a `valid` mode, where it will do the bare minimum to get a valid identifier. I.e. it will not change the case of characters at all (compared to `normal` mode).
51 | [David Jennes](https://github.com/djbe)
52 | [#160](https://github.com/SwiftGen/StencilSwiftKit/pull/160)
53 |
54 | ### Internal Changes
55 |
56 | * Add `Danger` to check pull requests.
57 | [David Jennes](https://github.com/djbe)
58 | [#158](https://github.com/SwiftGen/StencilSwiftKit/pull/158)
59 |
60 | ## 2.9.0
61 |
62 | ### New Features
63 |
64 | * `stencilSwiftEnvironment` now accepts a list of paths (for the template loader) & extensions.
65 | [David Jennes](https://github.com/djbe)
66 | [#154](https://github.com/SwiftGen/StencilSwiftKit/pull/154)
67 | [#156](https://github.com/SwiftGen/StencilSwiftKit/pull/156)
68 | * The string filter `replace` can now accept an optional parameter `regex` to enable regular expressions, see the [documentation](Documentation/filters-strings.md) for more information.
69 | [David Jennes](https://github.com/djbe)
70 | [JanGorman](https://github.com/JanGorman)
71 | [#123](https://github.com/SwiftGen/StencilSwiftKit/pull/123)
72 | [#155](https://github.com/SwiftGen/StencilSwiftKit/pull/155)
73 |
74 | ### Internal Changes
75 |
76 | * Update to SwiftLint 0.47.1 and enable some extra SwiftLint rules.
77 | [David Jennes](https://github.com/djbe)
78 | [#140](https://github.com/SwiftGen/StencilSwiftKit/pull/140)
79 | [#153](https://github.com/SwiftGen/StencilSwiftKit/pull/153)
80 |
81 | ## 2.8.0
82 |
83 | ### New Features
84 |
85 | * Added support for Swift 5.
86 | [David Jennes](https://github.com/djbe)
87 | [@fortmarek](https://github.com/fortmarek)
88 | [#119](https://github.com/SwiftGen/StencilSwiftKit/pull/119)
89 | [#122](https://github.com/SwiftGen/StencilSwiftKit/pull/122)
90 | [#125](https://github.com/SwiftGen/StencilSwiftKit/pull/125)
91 | [#127](https://github.com/SwiftGen/StencilSwiftKit/pull/127)
92 | * Updated Stencil to the latest version (0.14).
93 | [@fortmarek](https://github.com/fortmarek)
94 | [#127](https://github.com/SwiftGen/StencilSwiftKit/pull/127)
95 |
96 | ### Bug Fixes
97 |
98 | * Fix crash with the `lowerFirstWord` filter when running on empty strings.
99 | [@fortmarek](https://github.com/fortmarek)
100 | [#127](https://github.com/SwiftGen/StencilSwiftKit/pull/127)
101 |
102 | ### Internal Changes
103 |
104 | * Update to SwiftLint 0.42.0 and enable some extra SwiftLint rules.
105 | [David Jennes](https://github.com/djbe)
106 | [@fortmarek](https://github.com/fortmarek)
107 | [#116](https://github.com/SwiftGen/StencilSwiftKit/pull/116)
108 | [#127](https://github.com/SwiftGen/StencilSwiftKit/pull/127)
109 | [#137](https://github.com/SwiftGen/StencilSwiftKit/pull/137)
110 | * Switch from CircleCI to GitHub Actions.
111 | [David Jennes](https://github.com/djbe)
112 | [#128](https://github.com/SwiftGen/StencilSwiftKit/pull/128)
113 | * Dropped support for Swift 4.2.
114 | [David Jennes](https://github.com/djbe)
115 | [#132](https://github.com/SwiftGen/StencilSwiftKit/pull/132)
116 | * Switched the whole project over to use Swift Package Manager, restructuring some of the internals in the process.
117 | [David Jennes](https://github.com/djbe)
118 | [#130](https://github.com/SwiftGen/StencilSwiftKit/pull/130)
119 | * Made the filter implementations public, so they can be used in other libraries.
120 | [David Jennes](https://github.com/djbe)
121 | [#136](https://github.com/SwiftGen/StencilSwiftKit/pull/136)
122 |
123 | ## 2.7.2
124 |
125 | ### Bug Fixes
126 |
127 | * `Parameters`: ensure the `flatten` function correctly handles a flag with a `false` value.
128 | [David Jennes](https://github.com/djbe)
129 | [#108](https://github.com/SwiftGen/StencilSwiftKit/pull/108)
130 |
131 | ### Internal Changes
132 |
133 | * Update to SwiftLint 0.30.1 and enable some extra SwiftLint rules.
134 | [David Jennes](https://github.com/djbe)
135 | [#112](https://github.com/SwiftGen/StencilSwiftKit/pull/112)
136 | [#114](https://github.com/SwiftGen/StencilSwiftKit/pull/114)
137 |
138 | ## 2.7.1
139 |
140 | ### Bug Fixes
141 |
142 | * `swiftIdentifier`: fix crash on empty string.
143 | [David Jennes](https://github.com/djbe)
144 | [#105](https://github.com/SwiftGen/StencilSwiftKit/pull/105)
145 |
146 | ## 2.7.0
147 |
148 | ### New Features
149 |
150 | * Updated Stencil to the latest version (0.13).
151 | [David Jennes](https://github.com/djbe)
152 | [#103](https://github.com/SwiftGen/StencilSwiftKit/pull/103)
153 |
154 | ### Internal Changes
155 |
156 | * Improved the documentation of string filters a bit for a better overview of the inputs & outputs.
157 | [David Jennes](https://github.com/djbe)
158 | [#102](https://github.com/SwiftGen/StencilSwiftKit/pull/102)
159 | * Updated to latest Xcode (10.0).
160 | [David Jennes](https://github.com/djbe)
161 | [#103](https://github.com/SwiftGen/StencilSwiftKit/pull/103)
162 |
163 | ## 2.6.0
164 |
165 | ### New Features
166 |
167 | * The `set` tag can now directly accept an expression as value, see the [documentation](Documentation/tag-set.md) for an explanation on how this differs with the normal `set`/`endset` pair.
168 | [David Jennes](https://github.com/djbe)
169 | [#87](https://github.com/SwiftGen/StencilSwiftKit/pull/87)
170 | * Updated Stencil to the latest version (0.12.1).
171 | [David Jennes](https://github.com/djbe)
172 | [#95](https://github.com/SwiftGen/StencilSwiftKit/pull/95)
173 | [#99](https://github.com/SwiftGen/StencilSwiftKit/pull/99)
174 |
175 | ### Bug fixes
176 |
177 | * Fixed using filter expression in call node.
178 | [Ilya Puchka](https://github.com/ilyapuchka)
179 | [#85](https://github.com/SwiftGen/StencilSwiftKit/pull/85)
180 | * Fixed compilation issue with Xcode 10 & Swift 4.2 by adding hints to help the compiler.
181 | [Olivier Halligon](https://github.com/AliSoftware)
182 | [#93](https://github.com/SwiftGen/StencilSwiftKit/pull/93)
183 | * Migrated to PathKit for url filters. The dirname will return '.' for a filename without base directory.
184 | [Rahul Katariya](https://github.com/RahulKatariya)
185 | [Philip Jander](https://github.com/janderit)
186 | [#94](https://github.com/SwiftGen/StencilSwiftKit/pull/94)
187 |
188 | ### Internal Changes
189 |
190 | * Updated to latest Xcode (9.3.0).
191 | [David Jennes](https://github.com/djbe)
192 | [#86](https://github.com/SwiftGen/StencilSwiftKit/pull/86)
193 | * Update to SwiftLint 0.27 and enable some extra SwiftLint rules.
194 | [David Jennes](https://github.com/djbe)
195 | [#96](https://github.com/SwiftGen/StencilSwiftKit/pull/96)
196 | * Test Linux SPM support in CI.
197 | [David Jennes](https://github.com/janderit)
198 | [#90](https://github.com/SwiftGen/StencilSwiftKit/pull/90)
199 |
200 | ## 2.5.0
201 |
202 | ### New Features
203 |
204 | * Updated Stencil to the latest version (0.11.0).
205 | [David Jennes](https://github.com/djbe)
206 | [#83](https://github.com/SwiftGen/StencilSwiftKit/pull/83)
207 |
208 | ### Internal Changes
209 |
210 | * Switched to using SwiftLint via CocoaPods instead of our own install scripts.
211 | [David Jennes](https://github.com/djbe)
212 | [#78](https://github.com/SwiftGen/StencilSwiftKit/pull/78)
213 | * Enabled some extra SwiftLint rules for better code consistency.
214 | [David Jennes](https://github.com/djbe)
215 | [#79](https://github.com/SwiftGen/StencilSwiftKit/pull/79)
216 | * Migrated to CircleCI 2.0.
217 | [David Jennes](https://github.com/djbe)
218 | [#81](https://github.com/SwiftGen/StencilSwiftKit/pull/81)
219 | * Migrated to Swift 4, and dropped support for Swift 3.
220 | [David Jennes](https://github.com/djbe)
221 | [#80](https://github.com/SwiftGen/StencilSwiftKit/pull/80)
222 |
223 | ## 2.4.0
224 |
225 | ### New Features
226 |
227 | * Add `!` counterpart for strings boolean filters.
228 | [Antondomashnev](https://github.com/antondomashnev)
229 | [#68](https://github.com/SwiftGen/StencilSwiftKit/pull/68)
230 | * Updated Stencil to the latest version (0.10.1).
231 | [Ilya Puchka](https://github.com/ilyapuchka)
232 | [#73](https://github.com/SwiftGen/StencilSwiftKit/pull/73)
233 |
234 | ## 2.3.0
235 |
236 | ### New Features
237 |
238 | * Added `Parameters.flatten(dictionary:)` method to do the opposite of
239 | `Parameters.parse(items:)` and turn a dictionary into the list of parameters to pass from the command line.
240 | [Olivier Halligon](https://github.com/AliSoftware)
241 | [#70](https://github.com/SwiftGen/StencilSwiftKit/pull/70)
242 |
243 | ### Bug Fixes
244 |
245 | * Workaround for `parseString` to support `NSString`.
246 | [Antondomashnev](https://github.com/antondomashnev)
247 | [#68](https://github.com/SwiftGen/StencilSwiftKit/pull/68)
248 |
249 | ## 2.2.0
250 |
251 | ### New Features
252 |
253 | * Accept `LosslessStringConvertible` input for strings filters.
254 | [Antondomashnev](https://github.com/antondomashnev)
255 | [#65](https://github.com/SwiftGen/StencilSwiftKit/pull/65)
256 | * `StencilContext.enrich` now also accept a Dictionary for specifying parameters
257 | (in preparation for supporting Config files in SwiftGen).
258 | [Olivier Halligon](https://github.com/AliSoftware)
259 | [#66](https://github.com/SwiftGen/StencilSwiftKit/pull/66)
260 |
261 | ### Internal Changes
262 |
263 | * Refactoring of `Filters+Strings`.
264 | [Antondomashnev](https://github.com/antondomashnev)
265 | [#63](https://github.com/SwiftGen/StencilSwiftKit/pull/63)
266 |
267 | ## 2.1.0
268 |
269 | ### New Features
270 |
271 | * Added the `basename` and `dirname` string filters for getting a filename, or parent folder (respectively), out of a path.
272 | [David Jennes](https://github.com/djbe)
273 | [#60](https://github.com/SwiftGen/StencilSwiftKit/pull/60)
274 | * Modify the `swiftIdentifier` string filter to accept an optional "pretty" mode, to also apply the `snakeToCamelCase` filter and other manipulations if needed for a "prettier" but still valid identifier.
275 | [David Jennes](https://github.com/djbe)
276 | [#61](https://github.com/SwiftGen/StencilSwiftKit/pull/61)
277 |
278 | ### Internal Changes
279 |
280 | * Ensure `swiftlint` is run using `bundler`.
281 | [David Jennes](https://github.com/djbe)
282 | [#59](https://github.com/SwiftGen/StencilSwiftKit/pull/59)
283 |
284 | ## 2.0.1
285 |
286 | * Fix compilation on Linux.
287 | [JP Simard](https://github.com/jpsim)
288 | [#56](https://github.com/SwiftGen/StencilSwiftKit/pull/56)
289 |
290 | ## 2.0.0
291 |
292 | Due to the removal of legacy code, there are a few breaking changes in this new version that affect both template writers as well as developers. We've provided a migration guide to help you through these changes, which you can find here:
293 | [Migration Guide for 2.0](Documentation/MigrationGuide.md#stencilswiftkit-20-swiftgen-50)
294 |
295 | ### Breaking Changes
296 |
297 | * The `ParametersError` enum has been replaced by the `Parameters.Error` nested type.
298 | [Olivier Halligon](https://github.com/AliGator)
299 | [#37](https://github.com/SwiftGen/StencilSwiftKit/issues/37)
300 | * The `FilterError` enum has been replaced by the `Filters.Error` nested type.
301 | [Olivier Halligon](https://github.com/AliGator)
302 | [#37](https://github.com/SwiftGen/StencilSwiftKit/issues/37)
303 | * The filters in `StringFilters` and `NumFilters` are now located under `Filters.Strings` and `Filters.Numbers`.
304 | [Olivier Halligon](https://github.com/AliGator)
305 | [#40](https://github.com/SwiftGen/StencilSwiftKit/issues/40)
306 | * Removed the `join` filter, as it's now integrated in `Stencil` proper.
307 | [David Jennes](https://github.com/djbe)
308 | [#10](https://github.com/SwiftGen/StencilSwiftKit/issues/10)
309 | * Refactored the `snakeToCamelCase` filter to now accept an (optional) boolean parameter to control the `noPrefix` behaviour.
310 | [David Jennes](https://github.com/djbe)
311 | [#41](https://github.com/SwiftGen/StencilSwiftKit/issues/41)
312 | * Rename the `stringToSwiftIdentifier` function to `swiftIdentifier` to better match the other method names.
313 | [David Jennes](https://github.com/djbe)
314 | [#46](https://github.com/SwiftGen/StencilSwiftKit/issues/46)
315 |
316 | ### New Features
317 |
318 | * Added the `contains`, `replace`, `hasPrefix`, `hasSuffix`, `lowerFirstLetter` filters for strings.
319 | [Antondomashnev](https://github.com/antondomashnev)
320 | [#54](https://github.com/SwiftGen/StencilSwiftKit/pull/54)
321 | * Added the `removeNewlines` filter to remove newlines (and spaces) from a string.
322 | [David Jennes](https://github.com/djbe)
323 | [#47](https://github.com/SwiftGen/StencilSwiftKit/issues/47)
324 | [#48](https://github.com/SwiftGen/StencilSwiftKit/issues/48)
325 |
326 | ### Bug Fixes
327 |
328 | * Fix `snakeToCamelCase` parameters information in README.
329 | [Liquidsoul](https://github.com/Liquidsoul)
330 | [#45](https://github.com/SwiftGen/StencilSwiftKit/issues/45)
331 |
332 | ## 1.0.2
333 |
334 | ### New Features
335 |
336 | * Added camelToSnakeCase filter.
337 | [Gyuri Grell](https://github.com/ggrell)
338 | [#24](https://github.com/SwiftGen/StencilSwiftKit/pull/24)
339 |
340 | ### Bug Fixes
341 |
342 | * The context enrich function won't overwrite existing values in the `env` and `param` variables.
343 | [David Jennes](https://github.com/djbe)
344 | [#29](https://github.com/SwiftGen/StencilSwiftKit/issues/29)
345 |
346 | ### Internal Changes
347 |
348 | * Further refactor the Rakefile into rakelibs, and add a Gemfile for gem dependencies.
349 | [David Jennes](https://github.com/djbe)
350 | [#28](https://github.com/SwiftGen/StencilSwiftKit/issues/28)
351 | [#31](https://github.com/SwiftGen/StencilSwiftKit/issues/31)
352 | * Update Stencil to 0.9.0 and update project to Xcode 8.3.
353 | [Diogo Tridapalli](https://github.com.diogot)
354 | [#32](https://github.com/SwiftGen/StencilSwiftKit/pull/32)
355 | * Added documentation for tags and filters.
356 | [David Jennes](https://github.com/djbe)
357 | [#12](https://github.com/SwiftGen/StencilSwiftKit/pull/12)
358 |
359 | ### Deprecations
360 |
361 | * The `ParametersError` enum has been replaced by the `Parameters.Error` nested type.
362 | `ParametersError` still works (it is now `typealias`) but will be removed in the
363 | next major release.
364 | [Olivier Halligon](https://github.com/AliGator)
365 | * The `FilterError` enum has been replaced by the `Filters.Error` nested type.
366 | `FilterError` still works (it is now `typealias`) but will be removed in the
367 | next major release.
368 | [Olivier Halligon](https://github.com/AliGator)
369 |
370 | ## 1.0.1
371 |
372 | ### Internal Changes
373 |
374 | * Switch from Travis CI to Circle CI, clean up the Rakefile in the process.
375 | [David Jennes](https://github.com/djbe)
376 | [#20](https://github.com/SwiftGen/StencilSwiftKit/issues/20)
377 | [#25](https://github.com/SwiftGen/StencilSwiftKit/issues/25)
378 | * Fixed SPM dependency in `Package.swift`.
379 | [Krzysztof Zabłocki](https://github.com/krzysztofzablocki)
380 | [#26](https://github.com/SwiftGen/StencilSwiftKit/pull/26/files)
381 |
382 | ## 1.0.0
383 |
384 | ### New Features
385 |
386 | * Added support for Swift Package Manager.
387 | [Krzysztof Zabłocki](https://github.com/krzysztofzablocki)
388 | [#15](https://github.com/SwiftGen/StencilSwiftKit/issues/15)
389 | * Added `MapNode` to apply a `map` operator to an array.
390 | You can now use `{% map someArray into result using item %}`
391 | to do the equivalent of the `result = someArray.map { item in … }` Swift code.
392 | [David Jennes](https://github.com/djbe)
393 | [#11](https://github.com/SwiftGen/StencilSwiftKit/pull/11)
394 | * Add a "parameters parser" able to transform parameters passed as a set of strings
395 | (`a=1 b.x=2 b.y=3 c=4 c=5`) — typically provided as the command line arguments of a CLI
396 | — into a Dictionary suitable for Stencil contexts.
397 | [David Jennes](https://github.com/djbe)
398 | [#8](https://github.com/SwiftGen/StencilSwiftKit/pull/8)
399 | * Add a `StencilContext.enrich` function to enrich Stencil contexts with `param` and `env` dictionaries.
400 | The `param` dictionary typically contains parameters parsed via the parameters parser above.
401 | The `env` dictionary contains all the environment variables. You can thus access them in
402 | your templates using `env.USER`, `env.LANG`, `env.PRODUCT_MODULE_NAME`, etc.
403 | [#19](https://github.com/SwiftGen/StencilSwiftKit/pull/19)
404 |
405 | ### Internal Changes
406 |
407 | * Renamed `SwiftTemplate` to `StencilSwiftTemplate`.
408 | [David Jennes](https://github.com/djbe)
409 | [#14](https://github.com/SwiftGen/StencilSwiftKit/issues/14)
410 | * Refactor stencil swift extensions registration for easier use with an existing `Extension`.
411 | [David Jennes](https://github.com/djbe)
412 | [#16](https://github.com/SwiftGen/StencilSwiftKit/issues/16)
413 | * Refactor stencil node tests to not use templates and output files.
414 | [David Jennes](https://github.com/djbe)
415 | [#17](https://github.com/SwiftGen/StencilSwiftKit/issues/17)
416 |
417 | ## Pre-1.0.0
418 |
419 | _See SwitftGen's own CHANGELOG pre SwiftGen 4.2 version, before the refactoring that led us to split the code in frameworks_
420 |
--------------------------------------------------------------------------------
/Tests/StencilSwiftKitTests/StringFiltersTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StencilSwiftKit UnitTests
3 | // Copyright © 2022 SwiftGen
4 | // MIT Licence
5 | //
6 |
7 | // swiftlint:disable file_length
8 |
9 | @testable import StencilSwiftKit
10 | import XCTest
11 |
12 | final class StringFiltersTests: XCTestCase {
13 | private struct Input: LosslessStringConvertible, Hashable {
14 | let stringRepresentation: String
15 |
16 | init(string: String) {
17 | self.stringRepresentation = string
18 | }
19 |
20 | init?(_ description: String) {
21 | self.stringRepresentation = description
22 | }
23 |
24 | var description: String {
25 | stringRepresentation
26 | }
27 | }
28 |
29 | func testCamelToSnakeCase_WithNoArgsDefaultsToTrue() throws {
30 | let result = try Filters.Strings.camelToSnakeCase(Input(string: "StringWithWords"), arguments: []) as? String
31 | XCTAssertEqual(result, "string_with_words")
32 | }
33 |
34 | func testCamelToSnakeCase_WithTrue() throws {
35 | let expectations: [Input: String] = [
36 | Input(string: "string"): "string",
37 | Input(string: "String"): "string",
38 | Input(string: "strIng"): "str_ing",
39 | Input(string: "strING"): "str_ing",
40 | Input(string: "X"): "x",
41 | Input(string: "x"): "x",
42 | Input(string: "SomeCapString"): "some_cap_string",
43 | Input(string: "someCapString"): "some_cap_string",
44 | Input(string: "string_with_words"): "string_with_words",
45 | Input(string: "String_with_words"): "string_with_words",
46 | Input(string: "String_With_Words"): "string_with_words",
47 | Input(string: "String_With_WoRds"): "string_with_wo_rds",
48 | Input(string: "STRing_with_words"): "st_ring_with_words",
49 | Input(string: "string_wiTH_WOrds"): "string_wi_th_w_ords",
50 | Input(string: ""): "",
51 | Input(string: "URLChooser"): "url_chooser",
52 | Input(string: "UrlChooser"): "url_chooser",
53 | Input(string: "a__b__c"): "a__b__c",
54 | Input(string: "__y_z!"): "__y_z!",
55 | Input(string: "PLEASESTOPSCREAMING"): "pleasestopscreaming",
56 | Input(string: "PLEASESTOPSCREAMING!"): "pleasestopscreaming!",
57 | Input(string: "PLEASE_STOP_SCREAMING"): "please_stop_screaming",
58 | Input(string: "PLEASE_STOP_SCREAMING!"): "please_stop_screaming!"
59 | ]
60 |
61 | for (input, expected) in expectations {
62 | let trueArgResult = try Filters.Strings.camelToSnakeCase(input, arguments: ["true"]) as? String
63 | XCTAssertEqual(trueArgResult, expected)
64 | }
65 | }
66 |
67 | func testCamelToSnakeCase_WithFalse() throws {
68 | let expectations: [Input: String] = [
69 | Input(string: "string"): "string",
70 | Input(string: "String"): "String",
71 | Input(string: "strIng"): "str_Ing",
72 | Input(string: "strING"): "str_ING",
73 | Input(string: "X"): "X",
74 | Input(string: "x"): "x",
75 | Input(string: "SomeCapString"): "Some_Cap_String",
76 | Input(string: "someCapString"): "some_Cap_String",
77 | Input(string: "string_with_words"): "string_with_words",
78 | Input(string: "String_with_words"): "String_with_words",
79 | Input(string: "String_With_Words"): "String_With_Words",
80 | Input(string: "String_With_WoRds"): "String_With_Wo_Rds",
81 | Input(string: "STRing_with_words"): "ST_Ring_with_words",
82 | Input(string: "string_wiTH_WOrds"): "string_wi_TH_W_Ords",
83 | Input(string: ""): "",
84 | Input(string: "URLChooser"): "URL_Chooser",
85 | Input(string: "UrlChooser"): "Url_Chooser",
86 | Input(string: "a__b__c"): "a__b__c",
87 | Input(string: "__y_z!"): "__y_z!",
88 | Input(string: "PLEASESTOPSCREAMING"): "PLEASESTOPSCREAMING",
89 | Input(string: "PLEASESTOPSCREAMING!"): "PLEASESTOPSCREAMING!",
90 | Input(string: "PLEASE_STOP_SCREAMING"): "PLEASE_STOP_SCREAMING",
91 | Input(string: "PLEASE_STOP_SCREAMING!"): "PLEASE_STOP_SCREAMING!"
92 | ]
93 |
94 | for (input, expected) in expectations {
95 | let falseArgResult = try Filters.Strings.camelToSnakeCase(input, arguments: ["false"]) as? String
96 | XCTAssertEqual(falseArgResult, expected)
97 | }
98 | }
99 | }
100 |
101 | extension StringFiltersTests {
102 | func testEscapeReservedKeywords() throws {
103 | let expectations: [Input: String] = [
104 | Input(string: "self"): "`self`",
105 | Input(string: "foo"): "foo",
106 | Input(string: "Type"): "`Type`",
107 | Input(string: ""): "",
108 | Input(string: "x"): "x",
109 | Input(string: "Bar"): "Bar",
110 | Input(string: "#imageLiteral"): "`#imageLiteral`"
111 | ]
112 |
113 | for (input, expected) in expectations {
114 | let result = try Filters.Strings.escapeReservedKeywords(value: input) as? String
115 | XCTAssertEqual(result, expected)
116 | }
117 | }
118 | }
119 |
120 | extension StringFiltersTests {
121 | func testLowerFirstWord() throws {
122 | let expectations: [Input: String] = [
123 | Input(string: "string"): "string",
124 | Input(string: "String"): "string",
125 | Input(string: "strIng"): "strIng",
126 | Input(string: "strING"): "strING",
127 | Input(string: "X"): "x",
128 | Input(string: "x"): "x",
129 | Input(string: "SomeCapString"): "someCapString",
130 | Input(string: "someCapString"): "someCapString",
131 | Input(string: "string_with_words"): "string_with_words",
132 | Input(string: "String_with_words"): "string_with_words",
133 | Input(string: "String_With_Words"): "string_With_Words",
134 | Input(string: "STRing_with_words"): "stRing_with_words",
135 | Input(string: "string_wiTH_WOrds"): "string_wiTH_WOrds",
136 | Input(string: ""): "",
137 | Input(string: "URLChooser"): "urlChooser",
138 | Input(string: "a__b__c"): "a__b__c",
139 | Input(string: "__y_z!"): "__y_z!",
140 | Input(string: "PLEASESTOPSCREAMING"): "pleasestopscreaming",
141 | Input(string: "PLEASESTOPSCREAMING!"): "pleasestopscreaming!",
142 | Input(string: "PLEASE_STOP_SCREAMING"): "please_STOP_SCREAMING",
143 | Input(string: "PLEASE_STOP_SCREAMING!"): "please_STOP_SCREAMING!"
144 | ]
145 |
146 | for (input, expected) in expectations {
147 | let result = try Filters.Strings.lowerFirstWord(input) as? String
148 | XCTAssertEqual(result, expected)
149 | }
150 | }
151 | }
152 |
153 | extension StringFiltersTests {
154 | func testRemoveNewlines_WithNoArgsDefaultsToAll() throws {
155 | let result = try Filters.Strings.removeNewlines(Input(string: "test\n \ntest "), arguments: []) as? String
156 | XCTAssertEqual(result, "testtest")
157 | }
158 |
159 | func testRemoveNewlines_WithWrongArgWillThrow() throws {
160 | do {
161 | _ = try Filters.Strings.removeNewlines(Input(string: ""), arguments: ["wrong"])
162 | XCTFail("Code did succeed while it was expected to fail for wrong option")
163 | } catch Filters.Error.invalidOption {
164 | // That's the expected exception we want to happen
165 | } catch let error {
166 | XCTFail("Unexpected error occured: \(error)")
167 | }
168 | }
169 |
170 | func testRemoveNewlines_WithAll() throws {
171 | let expectations: [Input: String] = [
172 | Input(string: "test1"): "test1",
173 | Input(string: " \n test2"): "test2",
174 | Input(string: "test3 \n "): "test3",
175 | Input(string: "test4, \ntest, \ntest"): "test4,test,test",
176 | Input(string: "\n test5\n \ntest test \n "): "test5testtest",
177 | Input(string: "test6\ntest"): "test6test",
178 | Input(string: "test7 test"): "test7test"
179 | ]
180 |
181 | for (input, expected) in expectations {
182 | let result = try Filters.Strings.removeNewlines(input, arguments: ["all"]) as? String
183 | XCTAssertEqual(result, expected)
184 | }
185 | }
186 |
187 | func testRemoveNewlines_WithLeading() throws {
188 | let expectations: [Input: String] = [
189 | Input(string: "test1"): "test1",
190 | Input(string: " \n test2"): "test2",
191 | Input(string: "test3 \n "): "test3",
192 | Input(string: "test4, \ntest, \ntest"): "test4, test, test",
193 | Input(string: "\n test5\n \ntest test \n "): "test5test test",
194 | Input(string: "test6\ntest"): "test6test",
195 | Input(string: "test7 test"): "test7 test"
196 | ]
197 |
198 | for (input, expected) in expectations {
199 | let result = try Filters.Strings.removeNewlines(input, arguments: ["leading"]) as? String
200 | XCTAssertEqual(result, expected)
201 | }
202 | }
203 | }
204 |
205 | extension StringFiltersTests {
206 | func testSnakeToCamelCase_WithNoArgsDefaultsToFalse() throws {
207 | let result = try Filters.Strings.snakeToCamelCase(Input(string: "__y_z!"), arguments: []) as? String
208 | XCTAssertEqual(result, "__YZ!")
209 | }
210 |
211 | func testSnakeToCamelCase_WithFalse() throws {
212 | let expectations: [Input: String] = [
213 | Input(string: "string"): "String",
214 | Input(string: "String"): "String",
215 | Input(string: "strIng"): "StrIng",
216 | Input(string: "strING"): "StrING",
217 | Input(string: "X"): "X",
218 | Input(string: "x"): "X",
219 | Input(string: "SomeCapString"): "SomeCapString",
220 | Input(string: "someCapString"): "SomeCapString",
221 | Input(string: "string_with_words"): "StringWithWords",
222 | Input(string: "String_with_words"): "StringWithWords",
223 | Input(string: "String_With_Words"): "StringWithWords",
224 | Input(string: "STRing_with_words"): "STRingWithWords",
225 | Input(string: "string_wiTH_WOrds"): "StringWiTHWOrds",
226 | Input(string: ""): "",
227 | Input(string: "URLChooser"): "URLChooser",
228 | Input(string: "a__b__c"): "ABC",
229 | Input(string: "__y_z!"): "__YZ!",
230 | Input(string: "PLEASESTOPSCREAMING"): "Pleasestopscreaming",
231 | Input(string: "PLEASESTOPSCREAMING!"): "Pleasestopscreaming!",
232 | Input(string: "PLEASE_STOP_SCREAMING"): "PleaseStopScreaming",
233 | Input(string: "PLEASE_STOP_SCREAMING!"): "PleaseStopScreaming!"
234 | ]
235 |
236 | for (input, expected) in expectations {
237 | let result = try Filters.Strings.snakeToCamelCase(input, arguments: ["false"]) as? String
238 | XCTAssertEqual(result, expected)
239 | }
240 | }
241 |
242 | func testSnakeToCamelCase_WithTrue() throws {
243 | let expectations: [Input: String] = [
244 | Input(string: "string"): "String",
245 | Input(string: "String"): "String",
246 | Input(string: "strIng"): "StrIng",
247 | Input(string: "strING"): "StrING",
248 | Input(string: "X"): "X",
249 | Input(string: "x"): "X",
250 | Input(string: "SomeCapString"): "SomeCapString",
251 | Input(string: "someCapString"): "SomeCapString",
252 | Input(string: "string_with_words"): "StringWithWords",
253 | Input(string: "String_with_words"): "StringWithWords",
254 | Input(string: "String_With_Words"): "StringWithWords",
255 | Input(string: "STRing_with_words"): "STRingWithWords",
256 | Input(string: "string_wiTH_WOrds"): "StringWiTHWOrds",
257 | Input(string: ""): "",
258 | Input(string: "URLChooser"): "URLChooser",
259 | Input(string: "a__b__c"): "ABC",
260 | Input(string: "__y_z!"): "YZ!",
261 | Input(string: "PLEASESTOPSCREAMING"): "Pleasestopscreaming",
262 | Input(string: "PLEASESTOPSCREAMING!"): "Pleasestopscreaming!",
263 | Input(string: "PLEASE_STOP_SCREAMING"): "PleaseStopScreaming",
264 | Input(string: "PLEASE_STOP_SCREAMING!"): "PleaseStopScreaming!"
265 | ]
266 |
267 | for (input, expected) in expectations {
268 | let result = try Filters.Strings.snakeToCamelCase(input, arguments: ["true"]) as? String
269 | XCTAssertEqual(result, expected)
270 | }
271 | }
272 | }
273 |
274 | extension StringFiltersTests {
275 | func testUpperFirstLetter() throws {
276 | let expectations: [Input: String] = [
277 | Input(string: "string"): "String",
278 | Input(string: "String"): "String",
279 | Input(string: "strIng"): "StrIng",
280 | Input(string: "strING"): "StrING",
281 | Input(string: "X"): "X",
282 | Input(string: "x"): "X",
283 | Input(string: "SomeCapString"): "SomeCapString",
284 | Input(string: "someCapString"): "SomeCapString",
285 | Input(string: "string_with_words"): "String_with_words",
286 | Input(string: "String_with_words"): "String_with_words",
287 | Input(string: "String_With_Words"): "String_With_Words",
288 | Input(string: "STRing_with_words"): "STRing_with_words",
289 | Input(string: "string_wiTH_WOrds"): "String_wiTH_WOrds",
290 | Input(string: ""): "",
291 | Input(string: "URLChooser"): "URLChooser",
292 | Input(string: "a__b__c"): "A__b__c",
293 | Input(string: "__y_z!"): "__y_z!",
294 | Input(string: "PLEASESTOPSCREAMING"): "PLEASESTOPSCREAMING",
295 | Input(string: "PLEASESTOPSCREAMING!"): "PLEASESTOPSCREAMING!",
296 | Input(string: "PLEASE_STOP_SCREAMING"): "PLEASE_STOP_SCREAMING",
297 | Input(string: "PLEASE_STOP_SCREAMING!"): "PLEASE_STOP_SCREAMING!"
298 | ]
299 |
300 | for (input, expected) in expectations {
301 | let result = try Filters.Strings.upperFirstLetter(input) as? String
302 | XCTAssertEqual(result, expected)
303 | }
304 | }
305 | }
306 |
307 | extension StringFiltersTests {
308 | func testLowerFirstLetter() throws {
309 | let expectations: [Input: String] = [
310 | Input(string: "string"): "string",
311 | Input(string: "String"): "string",
312 | Input(string: "strIng"): "strIng",
313 | Input(string: "strING"): "strING",
314 | Input(string: "X"): "x",
315 | Input(string: "x"): "x",
316 | Input(string: "SomeCapString"): "someCapString",
317 | Input(string: "someCapString"): "someCapString",
318 | Input(string: "string with words"): "string with words",
319 | Input(string: "String with words"): "string with words",
320 | Input(string: "String With Words"): "string With Words",
321 | Input(string: "STRing with words"): "sTRing with words",
322 | Input(string: "string wiTH WOrds"): "string wiTH WOrds",
323 | Input(string: ""): "",
324 | Input(string: "A__B__C"): "a__B__C",
325 | Input(string: "__y_z!"): "__y_z!",
326 | Input(string: "PLEASESTOPSCREAMING"): "pLEASESTOPSCREAMING",
327 | Input(string: "PLEASESTOPSCREAMING!"): "pLEASESTOPSCREAMING!",
328 | Input(string: "PLEASE_STOP_SCREAMING"): "pLEASE_STOP_SCREAMING",
329 | Input(string: "PLEASE STOP SCREAMING!"): "pLEASE STOP SCREAMING!"
330 | ]
331 |
332 | for (input, expected) in expectations {
333 | let result = try Filters.Strings.lowerFirstLetter(input) as? String
334 | XCTAssertEqual(result, expected)
335 | }
336 | }
337 | }
338 |
339 | extension StringFiltersTests {
340 | func testContains_WithTrueResult() throws {
341 | let expectations: [Input: String] = [
342 | Input(string: "string"): "s",
343 | Input(string: "String"): "ing",
344 | Input(string: "strIng"): "strIng",
345 | Input(string: "strING"): "rING",
346 | Input(string: "x"): "x",
347 | Input(string: "X"): "X",
348 | Input(string: "SomeCapString"): "Some",
349 | Input(string: "someCapString"): "apSt",
350 | Input(string: "string with words"): "with",
351 | Input(string: "String with words"): "th words",
352 | Input(string: "A__B__C"): "_",
353 | Input(string: "__y_z!"): "!"
354 | ]
355 |
356 | for (input, substring) in expectations {
357 | let result = try Filters.Strings.contains(input, arguments: [substring])
358 | XCTAssertTrue(result)
359 | }
360 | }
361 |
362 | func testContains_WithFalseResult() throws {
363 | let expectations: [Input: String] = [
364 | Input(string: "string"): "a",
365 | Input(string: "String"): "blabla",
366 | Input(string: "strIng"): "foo",
367 | Input(string: "strING"): "ing",
368 | Input(string: "X"): "x",
369 | Input(string: "string with words"): "string with sentences",
370 | Input(string: ""): "y",
371 | Input(string: "A__B__C"): "a__B__C",
372 | Input(string: "__y_z!"): "___"
373 | ]
374 |
375 | for (input, substring) in expectations {
376 | let result = try Filters.Strings.contains(input, arguments: [substring])
377 | XCTAssertFalse(result)
378 | }
379 | }
380 | }
381 |
382 | extension StringFiltersTests {
383 | func testHasPrefix_WithTrueResult() throws {
384 | let expectations: [Input: String] = [
385 | Input(string: "string"): "s",
386 | Input(string: "String"): "Str",
387 | Input(string: "strIng"): "strIng",
388 | Input(string: "strING"): "strI",
389 | Input(string: "x"): "x",
390 | Input(string: "X"): "X",
391 | Input(string: "SomeCapString"): "Some",
392 | Input(string: "someCapString"): "someCap",
393 | Input(string: "string with words"): "string",
394 | Input(string: "String with words"): "String with",
395 | Input(string: "A__B__C"): "A",
396 | Input(string: "__y_z!"): "__",
397 | Input(string: "AnotherString"): ""
398 | ]
399 |
400 | for (input, prefix) in expectations {
401 | let result = try Filters.Strings.hasPrefix(input, arguments: [prefix])
402 | XCTAssertTrue(result)
403 | }
404 | }
405 |
406 | func testHasPrefix_WithFalseResult() throws {
407 | let expectations: [Input: String] = [
408 | Input(string: "string"): "tring",
409 | Input(string: "String"): "str",
410 | Input(string: "strING"): "striNG",
411 | Input(string: "X"): "x",
412 | Input(string: "string with words"): "words with words",
413 | Input(string: ""): "y",
414 | Input(string: "A__B__C"): "a__B__C",
415 | Input(string: "__y_z!"): "!"
416 | ]
417 |
418 | for (input, prefix) in expectations {
419 | let result = try Filters.Strings.hasPrefix(input, arguments: [prefix])
420 | XCTAssertFalse(result)
421 | }
422 | }
423 | }
424 |
425 | extension StringFiltersTests {
426 | func testHasSuffix_WithTrueResult() throws {
427 | let expectations: [Input: String] = [
428 | Input(string: "string"): "g",
429 | Input(string: "String"): "ring",
430 | Input(string: "strIng"): "trIng",
431 | Input(string: "strING"): "strING",
432 | Input(string: "X"): "X",
433 | Input(string: "x"): "x",
434 | Input(string: "SomeCapString"): "CapString",
435 | Input(string: "string with words"): "with words",
436 | Input(string: "String with words"): " words",
437 | Input(string: "string wiTH WOrds"): "",
438 | Input(string: "A__B__C"): "_C",
439 | Input(string: "__y_z!"): "z!"
440 | ]
441 |
442 | for (input, suffix) in expectations {
443 | let result = try Filters.Strings.hasSuffix(input, arguments: [suffix])
444 | XCTAssertTrue(result)
445 | }
446 | }
447 |
448 | func testHasSuffix_WithFalseResult() throws {
449 | let expectations: [Input: String] = [
450 | Input(string: "string"): "gni",
451 | Input(string: "String"): "Ing",
452 | Input(string: "strIng"): "ing",
453 | Input(string: "strING"): "nG",
454 | Input(string: "X"): "x",
455 | Input(string: "x"): "X",
456 | Input(string: "string with words"): "with words",
457 | Input(string: "String with words"): " Words",
458 | Input(string: "String With Words"): "th_Words",
459 | Input(string: ""): "aa",
460 | Input(string: "A__B__C"): "C__B",
461 | Input(string: "__y_z!"): "z?"
462 | ]
463 |
464 | for (input, suffix) in expectations {
465 | let result = try Filters.Strings.hasSuffix(input, arguments: [suffix])
466 | XCTAssertFalse(result)
467 | }
468 | }
469 | }
470 |
471 | extension StringFiltersTests {
472 | func testReplace() throws {
473 | let expectations = [
474 | (Input(string: "string"), "ing", "oke", "stroke"),
475 | (Input(string: "string"), "folks", "mates", "string"),
476 | (Input(string: "hi mates!"), "hi", "Yo", "Yo mates!"),
477 | (Input(string: "string with spaces"), " ", "_", "string_with_spaces")
478 | ]
479 |
480 | for (input, substring, replacement, expected) in expectations {
481 | let result = try Filters.Strings.replace(input, arguments: [substring, replacement]) as? String
482 | XCTAssertEqual(result, expected)
483 | }
484 | }
485 |
486 | func testReplaceRegex() throws {
487 | let expectations = [
488 | (Input(string: "string"), "ing", "oke", "stroke"),
489 | (Input(string: "string with numbers 42"), "\\s\\d+$", "", "string with numbers")
490 | ]
491 |
492 | for (input, substring, replacement, expected) in expectations {
493 | let result = try Filters.Strings.replace(input, arguments: [substring, replacement, "regex"]) as? String
494 | XCTAssertEqual(result, expected)
495 | }
496 | }
497 | }
498 |
499 | extension StringFiltersTests {
500 | func testBasename() throws {
501 | let expectations: [Input: String] = [
502 | Input(string: "/tmp/scratch.tiff"): "scratch.tiff",
503 | Input(string: "/tmp/scratch"): "scratch",
504 | Input(string: "/tmp/"): "tmp",
505 | Input(string: "scratch///"): "scratch",
506 | Input(string: "/"): "/"
507 | ]
508 |
509 | for (input, expected) in expectations {
510 | let result = try Filters.Strings.basename(input) as? String
511 | XCTAssertEqual(result, expected)
512 | }
513 | }
514 | }
515 |
516 | extension StringFiltersTests {
517 | func testDirname() throws {
518 | let expectations: [Input: String] = [
519 | Input(string: "/tmp/scratch.tiff"): "/tmp",
520 | Input(string: "/tmp/lock/"): "/tmp",
521 | Input(string: "/tmp/"): "/",
522 | Input(string: "/tmp"): "/",
523 | Input(string: "/"): "/",
524 | Input(string: "scratch.tiff"): "."
525 | ]
526 |
527 | for (input, expected) in expectations {
528 | let result = try Filters.Strings.dirname(input) as? String
529 | XCTAssertEqual(result, expected)
530 | }
531 | }
532 | }
533 |
--------------------------------------------------------------------------------