├── .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 | [![CircleCI](https://circleci.com/gh/SwiftGen/StencilSwiftKit/tree/stable.svg?style=svg)](https://circleci.com/gh/SwiftGen/StencilSwiftKit/tree/stable) 4 | [![CocoaPods Compatible](https://img.shields.io/cocoapods/v/StencilSwiftKit.svg)](https://img.shields.io/cocoapods/v/StencilSwiftKit.svg) 5 | [![Platform](https://img.shields.io/cocoapods/p/StencilSwiftKit.svg?style=flat)](http://cocoadocs.org/docsets/StencilSwiftKit) 6 | ![Swift 5.0](https://img.shields.io/badge/Swift-5.0-orange.svg) 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 | --------------------------------------------------------------------------------