92 Commits
0.14.0 ... main

Author SHA1 Message Date
T. R. Bernstein
02f61d55a4 chore: release 0.15.2
Some checks failed
Danger / Danger Check (push) Has been cancelled
Lint Cocoapods / Pod Lint (push) Has been cancelled
SwiftLint / SwiftLint (push) Has been cancelled
Test SPM / Test SPM Linux (push) Has been cancelled
Test SPM / Test SPM macOS (push) Has been cancelled
Publish on Tag / GitHub Release (push) Has been cancelled
2025-09-30 23:14:57 +02:00
T. R. Bernstein
c25b7a52e7 feat: Allow tokens to be escaped 2025-09-30 23:13:05 +02:00
T. R. Bernstein
25d1507159 refactor: Use tabs for indent 2025-09-30 23:13:04 +02:00
T. R. Bernstein
6811c71bd6 refactor: Adapt to new repository 2025-09-30 23:12:59 +02:00
twodayslate
17af3bace1 docs: fix code example syntax (#348) 2024-12-22 12:54:30 +00:00
David Jennes
1aeeced65d Merge pull request #342 from art-divin/master
Prefer DynamicMemberLookup over KVC
2023-08-28 02:34:58 +02:00
Ruslan Alikhamov
ea58733eb6 added new version in CHANGELOG 2023-08-26 05:10:17 +00:00
Ruslan Alikhamov
003341d94c Merge branch 'master' of https://github.com/art-divin/Stencil 2023-08-26 05:08:36 +00:00
Ruslan Alikhamov
930db33028 updated changelog 2023-08-26 05:07:29 +00:00
Ruslan Alikhamov
6b6d6c2730 changed order of condition to make positive case first 2023-08-26 01:03:49 -04:00
Ruslan Alikhamov
973609e141 Update Variable.swift - Fixed a typo for objc runtime check 2023-08-20 15:05:35 +04:00
Ruslan Alikhamov
644687b885 prefer DynamicMemberLookup over KVC 2023-08-20 10:28:27 +00:00
David Jennes
4f222ac85d Merge pull request #329 from stencilproject/release/0.15.1
Some checks failed
Publish on Tag / Push To CocoaPods (push) Has been cancelled
Publish on Tag / GitHub Release (push) Has been cancelled
Release 0.15.1
2022-07-31 23:07:25 +02:00
David Jennes
3a98d1ef7d Bump version to 0.15.1 2022-07-31 23:05:06 +02:00
David Jennes
95a24b950f Small docs fix 2022-07-31 23:03:40 +02:00
David Jennes
a3df900bd2 Merge pull request #328 from stencilproject/feature/fix-lazy
Fix `LazyValueWrapper`
2022-07-31 22:58:06 +02:00
David Jennes
59b0c176c7 Changelog entry 2022-07-31 22:54:47 +02:00
David Jennes
bc5051ffe3 Fix implementation of lazy value wrapper 2022-07-31 22:53:24 +02:00
David Jennes
9444ee5c86 Reset changelog 2022-07-30 00:30:18 +02:00
David Jennes
8989f8a189 Merge pull request #327 from stencilproject/release/0.15.0
Some checks failed
Publish on Tag / Push To CocoaPods (push) Has been cancelled
Publish on Tag / GitHub Release (push) Has been cancelled
Release 0.15.0
2022-07-30 00:23:04 +02:00
David Jennes
6bafcffd2b Tweak changelog a bit 2022-07-29 18:32:22 +02:00
David Jennes
c690f3e613 Bump version to 0.15.0 2022-07-29 18:32:22 +02:00
David Jennes
2ddc039129 Rake tweaks 2022-07-29 18:32:22 +02:00
David Jennes
078c7a84e6 Merge pull request #324 from stencilproject/feature/lazy-data
Support lazy context data
2022-07-29 17:53:52 +02:00
David Jennes
5f0c01809d Docs & changelog entry 2022-07-29 03:07:12 +02:00
David Jennes
07d36651bf Tests 2022-07-29 03:07:12 +02:00
David Jennes
1072e919a3 Create mechanism for providing lazily evaluated context data 2022-07-29 03:07:12 +02:00
David Jennes
0d8fdbc3aa Merge pull request #326 from stencilproject/feature/attribution
Add attribution #trivial
2022-07-29 03:05:52 +02:00
David Jennes
5828770138 Bump year 2022-07-29 02:43:18 +02:00
David Jennes
71879ecdc9 Add attribution to all source code 2022-07-29 02:43:12 +02:00
David Jennes
6481534f6c Merge pull request #325 from stencilproject/feature/resolvable-expressions
Resolvable boolean expressions
2022-07-29 02:01:08 +02:00
David Jennes
0fa830c5cb Changelog entry 2022-07-29 01:54:28 +02:00
Ilya Puchka
479fdad30b Update docs 2022-07-29 01:47:57 +02:00
Ilya Puchka
6649b7e716 Parse variables as expressions
removed static boolean expressions
added test for rendering template with boolean expression
2022-07-29 01:47:55 +02:00
David Jennes
242bea54c3 Merge pull request #175 from stencilproject/break-continue
Break, continue and loops' labels
2022-07-29 00:02:10 +02:00
David Jennes
14f4c2a131 Changelog entry 2022-07-28 23:59:31 +02:00
David Jennes
f12d6ed7f3 Update documentation 2022-07-28 23:59:31 +02:00
Ilya Puchka
dd7ea1e097 Access outer loop context via its label 2022-07-28 23:59:31 +02:00
Ilya Puchka
91df84b1a5 Loop labels 2022-07-28 23:59:31 +02:00
Ilya Puchka
a7448b74cf Added break and continue nodes 2022-07-28 23:59:31 +02:00
David Jennes
248d664d4a Merge pull request #182 from stencilproject/blocks-cache
Caching rendered blocks content to reuse them in further calls
2022-07-28 19:07:15 +02:00
Ilya Puchka
41e0c9c9e0 Changelog entry 2022-07-28 19:05:28 +02:00
Ilya Puchka
67f94aa9f0 Update docs 2022-07-28 19:05:28 +02:00
Ilya Puchka
8c379296ca Cache rendered blocks content to reuse them in further calls 2022-07-28 19:01:13 +02:00
David Jennes
4d3f911f5d Merge pull request #287 from stencilproject/trim_whitespace
Add whitespace control mechanisms
2022-07-28 18:25:45 +02:00
David Jennes
b95b18ff60 Try to document this (afaik) 2022-07-28 18:24:10 +02:00
yonaskolb
27a543d748 Changelog entry 2022-07-28 16:43:24 +02:00
yonaskolb
ef97973e85 Implement trim whitespace 2022-07-28 16:43:24 +02:00
David Jennes
d4dc631752 Merge pull request #323 from stencilproject/feature/drop-swift4
Drop Swift 4 support
2022-07-28 16:39:36 +02:00
David Jennes
20b41782a1 Changelog entry 2022-07-28 03:15:54 +02:00
David Jennes
888797b27e Move source files to where SPM expectes them to be 2022-07-28 03:15:53 +02:00
David Jennes
f32c772b99 Warnings-- 2022-07-28 03:13:01 +02:00
David Jennes
e6ee27f64e Drop swift 4.2 support 2022-07-28 03:10:38 +02:00
David Jennes
256388ddc8 Merge pull request #246 from stencilproject/dynamic-member-lookup
Dynamic member lookup (via marker protocol)
2022-07-28 03:07:01 +02:00
David Jennes
099b8414d2 Changelog entry & docs 2022-07-28 03:05:17 +02:00
Ilya Puchka
7247d0a83d Dynamic member lookup (via marker protocol) 2022-07-28 03:05:17 +02:00
David Jennes
203510175f Merge pull request #267 from stencilproject/fix-block-super
Always evaluate `block.super` even if it's not directly used
2022-07-28 02:45:22 +02:00
David Jennes
8e890db688 Changelog entry 2022-07-27 18:53:01 +02:00
Ilya Puchka
701221c0fb always evaluate block.super even if it is not used 2022-07-27 18:51:52 +02:00
David Jennes
a6d0428036 Merge pull request #322 from stencilproject/feature/public-render
Make `render` method public
2022-07-27 05:05:57 +02:00
David Jennes
ee8b4bc4bc Changelog entry 2022-07-27 03:07:32 +02:00
David Jennes
99cc1cac4a Make render with context public 2022-07-27 03:01:37 +02:00
David Jennes
779820ed99 Merge pull request #321 from stencilproject/feature/pr-automation
PR checks automation
2022-07-27 03:01:17 +02:00
David Jennes
828a9b6fc4 Changelog entry 2022-07-27 02:56:21 +02:00
David Jennes
1b72ef27a4 Forgot ruby version 2022-07-27 02:56:21 +02:00
David Jennes
bf6c7ce456 Switch from travis to github actions 2022-07-27 02:56:21 +02:00
David Jennes
0bbb8005bb Fix bunch of warnings 2022-07-27 02:56:21 +02:00
David Jennes
d9a48fbda6 Copy & adapt rake infrastructure from swiftgen projects 2022-07-27 02:56:21 +02:00
David Jennes
d18e27d6e4 Tweak gitignore 2022-07-27 02:09:52 +02:00
David Jennes
ec031f9c7f Merge pull request #292 from stefanomondino/master
Make tokens public
2022-07-26 15:20:17 +02:00
David Jennes
7dbccf9686 Changelog entry 2022-07-26 15:03:55 +02:00
Stefano Mondino
c444fb959d Make tokens public 2022-07-26 15:02:39 +02:00
David Jennes
c7e1c890f8 Reset CHANGELOG 2021-11-03 18:08:03 +01:00
David Jennes
ccd9402682 Merge pull request #319 from stencilproject/release/0.14.2
Release 0.14.2
2021-11-03 18:04:47 +01:00
David Jennes
9f0b9388d2 Bump gems 2021-11-03 17:59:55 +01:00
David Jennes
38f5faec78 Version 0.14.2 2021-11-03 17:59:46 +01:00
David Jennes
a724419474 Merge pull request #314 from astromonkee/update_pathkit_to_1.0.1
Update PathKit & Spectre to support Xcode 13
2021-10-07 23:09:00 +02:00
David Jennes
12b3a2e9bd Add changelog entry 2021-10-07 23:08:06 +02:00
astromonkee
47a44889ae Update Spectre to 0.10.1 2021-09-29 18:43:28 +09:00
astromonkee
01740c61d3 Update pathkit to 1.0.1
Pathkit 1.0.1 adds support for Xcode 13
2021-09-23 20:36:29 +09:00
David Jennes
c729a7d58f Reset CHANGELOG 2021-04-11 16:30:50 +02:00
David Jennes
973e190edf Merge pull request #307 from stencilproject/release/0.14.1
Release 0.14.1
2021-04-11 16:26:15 +02:00
David Jennes
e134aafe7f Version 0.14.1 2021-04-11 00:17:27 +02:00
David Jennes
88fd776a02 Merge pull request #306 from lkuczborski/variable-crash-fix
Fix for crashing range indexes when variable length is 1
2021-04-10 18:08:27 +02:00
Łukasz Kuczborski
8480648bd3 Fixed changelog entry 2021-04-10 05:57:40 +02:00
Łukasz Kuczborski
521a599a60 Fixed logic and tests 2021-04-09 23:37:54 +02:00
Łukasz Kuczborski
371a4737d9 Fixed missing braces 2021-04-09 23:27:00 +02:00
Łukasz Kuczborski
61919c5e8e PR fixes 2021-04-09 23:21:46 +02:00
Łukasz Kuczborski
7c635975d1 Fix for crashing range indexes when variable length is 1 2021-04-09 22:51:55 +02:00
David Jennes
fd107355c2 Merge pull request #305 from danpalmer/patch-1
Fix build warning
2021-02-07 21:09:47 +01:00
Dan Palmer
f5f85d95a9 Fix build warning
`components` is not mutated, so it can be a `let`. This fixes the build warning that otherwise shows up in build logs.
2021-01-18 17:33:13 +00:00
Olivier Halligon
22440c5369 Reset CHANGELOG 2020-08-17 20:45:21 +02:00
109 changed files with 8063 additions and 6147 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

26
.github/workflows/danger.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Danger
on:
push:
branches:
- main
pull_request:
jobs:
check:
name: Danger Check
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Run Danger
run: bundle exec danger --verbose --dangerfile=rakelib/Dangerfile
env:
DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}

24
.github/workflows/lint-cocoapods.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Lint Cocoapods
on:
push:
branches:
- main
pull_request:
jobs:
lint:
name: Pod Lint
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Lint podspec
run: bundle exec rake pod:lint

View File

@@ -0,0 +1,23 @@
name: Check Versions
on:
push:
branches:
- 'release/**'
jobs:
check_versions:
name: Check Versions
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Check versions
run: bundle exec rake release:check_versions

27
.github/workflows/swiftlint.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: SwiftLint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
name: SwiftLint
runs-on: macos-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Lint source code
run: bundle exec rake lint:code
-
name: Lint tests source code
run: bundle exec rake lint:tests

26
.github/workflows/tag-publish.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Publish on Tag
on:
push:
tags:
- '*'
workflow_dispatch:
jobs:
github:
name: GitHub Release
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Create release on GitHub
run: bundle exec rake release:github
env:
DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}

67
.github/workflows/test-spm.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Test SPM
on:
push:
branches:
- main
pull_request:
jobs:
linux:
name: Test SPM Linux
runs-on: ubuntu-latest
container: swiftgen/swift:5.6
steps:
-
name: Checkout
uses: actions/checkout@v3
-
# Note: we can't use `ruby/setup-ruby` on custom docker images, so we
# have to do our own caching
name: Cache gems
uses: actions/cache@v3
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-
-
name: Cache SPM
uses: actions/cache@v3
with:
path: .build
key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
-
name: Bundle install
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
-
name: Run tests
run: bundle exec rake spm:test
macos:
name: Test SPM macOS
runs-on: macos-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Cache SPM
uses: actions/cache@v3
with:
path: .build
key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-
-
name: Run tests
run: bundle exec rake spm:test

77
.gitignore vendored
View File

@@ -1,6 +1,75 @@
.conche/
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## Build generated
build/
DerivedData/
Fixtures/stub-env/**/*.swiftmodule
## Various settings
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata/
## Other
*.moved-aside
*.xcuserstate
*.xcscmblueprint
## Obj-C/Swift specific
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
Packages/
.build/
.swiftpm/
Packages/
Package.pins
*.xcodeproj
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
Carthage/Checkouts
Carthage/Build
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
# Other stuff
.apitoken
.DS_Store
.idea/
bin/
Frameworks/
Rome/

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
3.0.4

View File

@@ -1,90 +1,112 @@
swiftlint_version: 0.39.2
disabled_rules:
# Remove this once we remove old swift support
- implicit_return
swiftlint_version: 0.61.0
opt_in_rules:
- anyobject_protocol
- array_init
- attributes
- closure_body_length
- closure_end_indentation
- closure_spacing
- collection_alignment
- contains_over_filter_count
- contains_over_filter_is_empty
- contains_over_first_not_nil
- contains_over_range_nil_comparison
- convenience_type
- discouraged_optional_boolean
- discouraged_optional_collection
- duplicate_enum_cases
- duplicate_imports
- empty_collection_literal
- empty_count
- empty_string
- fallthrough
- fatal_error_message
- first_where
- flatmap_over_map_reduce
- force_unwrapping
- identical_operands
- inert_defer
- joined_default_parameter
- last_where
- legacy_hashing
- legacy_random
- literal_expression_end_indentation
- lower_acl_than_parent
- modifier_order
- multiline_arguments
- multiline_function_chains
- multiline_literal_brackets
- multiline_parameters
- multiline_parameters_brackets
- nslocalizedstring_key
- nsobject_prefer_isequal
- number_separator
- object_literal
- operator_usage_whitespace
- overridden_super_call
- override_in_extension
- prefer_self_type_over_type_of_self
- private_action
- private_outlet
- prohibited_super_call
- raw_value_for_camel_cased_codable_enum
- reduce_boolean
- reduce_into
- redundant_nil_coalescing
- redundant_objc_attribute
- sorted_first_last
- sorted_imports
- static_operator
- strong_iboutlet
- toggle_bool
- trailing_closure
- unavailable_function
- unneeded_parentheses_in_closure_argument
- unowned_variable_capture
- unused_capture_list
- unused_control_flow_label
- unused_declaration
- unused_setter_value
- vertical_parameter_alignment_on_call
- vertical_whitespace_closing_braces
- vertical_whitespace_opening_braces
- xct_specific_matcher
- yoda_condition
# Enable this again once we remove old swift support
# - optional_enum_case_matching
# - legacy_multiple
- accessibility_label_for_image
- anonymous_argument_in_multiline_closure
- anyobject_protocol
- array_init
- attributes
- balanced_xctest_lifecycle
- closure_body_length
- closure_end_indentation
- closure_spacing
- collection_alignment
- comment_spacing
- conditional_returns_on_newline
- contains_over_filter_count
- contains_over_filter_is_empty
- contains_over_first_not_nil
- contains_over_range_nil_comparison
- convenience_type
- discarded_notification_center_observer
- discouraged_assert
- discouraged_none_name
- discouraged_optional_boolean
- discouraged_optional_collection
- empty_collection_literal
- empty_count
- empty_string
- empty_xctest_method
- enum_case_associated_values_count
- fallthrough
- fatal_error_message
- file_header
- first_where
- flatmap_over_map_reduce
- force_unwrapping
- ibinspectable_in_extension
- identical_operands
- implicit_return
- implicitly_unwrapped_optional
- inclusive_language
- indentation_width
- joined_default_parameter
- last_where
- legacy_multiple
- legacy_objc_type
- legacy_random
- literal_expression_end_indentation
- lower_acl_than_parent
- missing_docs
- modifier_order
- multiline_arguments
- multiline_arguments_brackets
- multiline_function_chains
- multiline_literal_brackets
- multiline_parameters
- multiline_parameters_brackets
- nslocalizedstring_key
- nslocalizedstring_require_bundle
- number_separator
- operator_usage_whitespace
- optional_enum_case_matching
- overridden_super_call
- override_in_extension
- prefer_self_in_static_references
- prefer_self_type_over_type_of_self
- prefer_zero_over_explicit_init
- prefixed_toplevel_constant
- private_action
- private_outlet
- private_subject
- prohibited_super_call
- raw_value_for_camel_cased_codable_enum
- reduce_into
- redundant_nil_coalescing
- redundant_type_annotation
- required_enum_case
- return_value_from_void_function
- single_test_class
- sorted_first_last
- sorted_imports
- static_operator
- strong_iboutlet
- switch_case_on_newline
- test_case_accessibility
- toggle_bool
- trailing_closure
- unavailable_function
- unneeded_parentheses_in_closure_argument
- unowned_variable_capture
- unused_closure_parameter
- vertical_parameter_alignment_on_call
- vertical_whitespace_closing_braces
- vertical_whitespace_opening_braces
- void_function_in_ternary
- weak_delegate
- xct_specific_matcher
- yoda_condition
# Rules customization
closure_body_length:
warning: 25
conditional_returns_on_newline:
if_only: true
indentation_width:
indentation_width: 2
line_length:
warning: 120
error: 200
@@ -92,8 +114,3 @@ line_length:
nesting:
type_level:
warning: 2
# Exclude generated files
excluded:
- .build
- Tests/StencilTests/XCTestManifests.swift

View File

@@ -1,22 +0,0 @@
matrix:
include:
- os: osx
osx_image: xcode11.4
env: SWIFT_VERSION=4.2
- os: osx
osx_image: xcode11.4
env: SWIFT_VERSION=5.0
- os: linux
env: SWIFT_VERSION=4.2
- os: linux
env: SWIFT_VERSION=5.0
language: generic
sudo: required
dist: trusty
install:
- if [ "$TRAVIS_OS_NAME" == "linux" ]; then eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"; fi
- if [ "$TRAVIS_OS_NAME" == "osx" ]; then wget --output-document /tmp/SwiftLint.pkg https://github.com/realm/SwiftLint/releases/download/0.39.2/SwiftLint.pkg &&
sudo installer -pkg /tmp/SwiftLint.pkg -target /; fi
script:
- swift test
- if [ "$TRAVIS_OS_NAME" == "osx" ]; then swiftlint; fi

View File

@@ -1,42 +1,131 @@
# Stencil Changelog
## 0.15.2
### Enhancements
- Prefer `DynamicMemberLookup` over KVC.
[##342](https://github.com/stencilproject/Stencil/pull/342)
[@art-divin](https://github.com/art-divin)
- Allow tokens to be escaped by a backslash, i.e. `\{{ something }}` would render to `{{ something }}`.
## 0.15.1
### Bug Fixes
- Fix bug in `LazyValueWrapper`, causing it to never resolve.
[David Jennes](https://github.com/djbe)
[#328](https://github.com/stencilproject/Stencil/pull/328)
## 0.15.0
### Breaking
- Drop support for Swift < 5. For Swift 4.2 support, you should use Stencil 0.14.2.
[David Jennes](https://github.com/djbe)
[#323](https://github.com/stencilproject/Stencil/pull/323)
### Enhancements
- Added support for trimming whitespace around blocks with Jinja2 whitespace control symbols. eg `{%- if value +%}`.
[Miguel Bejar](https://github.com/bejar37)
[Yonas Kolb](https://github.com/yonaskolb)
[#92](https://github.com/stencilproject/Stencil/pull/92)
[#287](https://github.com/stencilproject/Stencil/pull/287)
- Added support for adding default whitespace trimming behaviour to an environment.
[Yonas Kolb](https://github.com/yonaskolb)
[#287](https://github.com/stencilproject/Stencil/pull/287)
- Blocks now can be used repeatedly in the template. When block is rendered for the first time its content will be cached and it can be rendered again later using `{{ block.block_name }}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#158](https://github.com/stencilproject/Stencil/issues/158)
[#182](https://github.com/stencilproject/Stencil/pull/182)
- Added `break` and `continue` tags to break or continue current loop.
[Ilya Puchka](https://github.com/ilyapuchka)
[#175](https://github.com/stencilproject/Stencil/pull/175)
- You can now access outer loop's scope by labeling it: `{% outer: for ... %}... {% for ... %} {{ outer.counter }} {% endfor %}{% endfor %}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#175](https://github.com/stencilproject/Stencil/pull/175)
- Boolean expressions can now be rendered, i.e `{{ name == "John" }}` will render `true` or `false` depending on the evaluation result.
[Ilya Puchka](https://github.com/ilyapuchka)
[David Jennes](https://github.com/djbe)
[#164](https://github.com/stencilproject/Stencil/pull/164)
[#325](https://github.com/stencilproject/Stencil/pull/325)
- Enable dynamic member lookup using a new `DynamicMemberLookup` marker protocol. Conform your own types to this protocol to support dynamic member from with contexts.
[Ilya Puchka](https://github.com/ilyapuchka)
[#219](https://github.com/stencilproject/Stencil/issues/219)
[#246](https://github.com/stencilproject/Stencil/pull/246)
- Allow providing lazily evaluated context data, using the `LazyValueWrapper` structure.
[David Jennes](https://github.com/djbe)
[#324](https://github.com/stencilproject/Stencil/pull/324)
### Bug Fixes
- Fixed using `{{ block.super }}` inside nodes other than `block`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#266](https://github.com/stencilproject/Stencil/issues/266)
[#267](https://github.com/stencilproject/Stencil/pull/267)
### Internal Changes
- Updated internal maintenance scripts, and switched to GitHub actions.
[David Jennes](https://github.com/djbe)
[#321](https://github.com/stencilproject/Stencil/pull/321)
- Made the `tokens` property on a `Template` public.
[Stefanomondino](https://github.com/stefanomondino)
[#292](https://github.com/stencilproject/Stencil/pull/292)
- Made the `Template.render(_:)` method (that accepts a `Context`) public.
[David Jennes](https://github.com/djbe)
[#322](https://github.com/stencilproject/Stencil/pull/322)
## 0.14.2
### Internal Changes
- Update Spectre (0.10) and PathKit to support Xcode 13.
[Astromonkee](https://github.com/astromonkee)
[#314](https://github.com/stencilproject/Stencil/pull/314)
## 0.14.1
### Bug Fixes
- Fix for crashing range indexes when variable length is 1.
[Łukasz Kuczborski](https://github.com/lkuczborski)
[#306](https://github.com/stencilproject/Stencil/pull/306)
## 0.14.0
### Breaking
- Drop support for Swift < 4.2. For Swift 4 support, you should use Stencil 0.13.1.
- Drop support for Swift < 4.2. For Swift 4 support, you should use Stencil 0.13.1.
[David Jennes](https://github.com/djbe)
[#294](https://github.com/stencilproject/Stencil/pull/294)
### Enhancements
- Added support for dynamic filter using `filter` filter. With that you can define a variable with a name of filter
, i.e. `myfilter = "uppercase"` and then use it to invoke this filter with `{{ string|filter:myfilter }}`.
, i.e. `myfilter = "uppercase"` and then use it to invoke this filter with `{{ string|filter:myfilter }}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#203](https://github.com/stencilproject/Stencil/pull/203)
### Deprecations
_None_
### Bug Fixes
- Fixed using parenthesis in boolean expressions, they now can be used without spaces around them.
- Fixed using parenthesis in boolean expressions, they now can be used without spaces around them.
[Ilya Puchka](https://github.com/ilyapuchka)
[#254](https://github.com/stencilproject/Stencil/pull/254)
- Throw syntax error on empty variable tags (`{{ }}`) instead `fatalError`.
- Throw syntax error on empty variable tags (`{{ }}`) instead `fatalError`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#263](https://github.com/stencilproject/Stencil/pull/263)
### Internal Changes
- `Token` type converted to struct to allow computing token components only once.
- `Token` type converted to struct to allow computing token components only once.
[Ilya Puchka](https://github.com/ilyapuchka)
[#256](https://github.com/stencilproject/Stencil/pull/256)
- Added SwiftLint to the project.
- Added SwiftLint to the project.
[David Jennes](https://github.com/djbe)
[#249](https://github.com/stencilproject/Stencil/pull/249)
- Updated to Swift 5.
- Updated to Swift 5.
[Jungwon An](https://github.com/kawoou)
[#268](https://github.com/stencilproject/Stencil/pull/268)
@@ -45,7 +134,7 @@ _None_
### Bug Fixes
- Fixed a bug in Stencil 0.13 where tags without spaces were incorrectly parsed.
- Fixed a bug in Stencil 0.13 where tags without spaces were incorrectly parsed.
[David Jennes](https://github.com/djbe)
[#252](https://github.com/stencilproject/Stencil/pull/252)
@@ -54,46 +143,46 @@ _None_
### Breaking
- Now requires Swift 4.1 or newer.
- Now requires Swift 4.1 or newer.
[Yonas Kolb](https://github.com/yonaskolb)
[#228](https://github.com/stencilproject/Stencil/pull/228)
### Enhancements
- You can now use parentheses in boolean expressions to change operator precedence.
- You can now use parentheses in boolean expressions to change operator precedence.
[Ilya Puchka](https://github.com/ilyapuchka)
[#165](https://github.com/stencilproject/Stencil/pull/165)
- Added method to add boolean filters with their negative counterparts.
- Added method to add boolean filters with their negative counterparts.
[Ilya Puchka](https://github.com/ilyapuchka)
[#160](https://github.com/stencilproject/Stencil/pull/160)
- Now you can conditionally render variables with `{{ variable if condition }}`, which is a shorthand for `{% if condition %}{{ variable }}{% endif %}`. You can also use `else` like `{{ variable1 if condition else variable2 }}`, which is a shorthand for `{% if condition %}{{ variable1 }}{% else %}{{ variable2 }}{% endif %}`
- Now you can conditionally render variables with `{{ variable if condition }}`, which is a shorthand for `{% if condition %}{{ variable }}{% endif %}`. You can also use `else` like `{{ variable1 if condition else variable2 }}`, which is a shorthand for `{% if condition %}{{ variable1 }}{% else %}{{ variable2 }}{% endif %}`
[Ilya Puchka](https://github.com/ilyapuchka)
[#243](https://github.com/stencilproject/Stencil/pull/243)
- Now you can access string characters by index or get string length the same was as if it was an array, i.e. `{{ 'string'.first }}`, `{{ 'string'.last }}`, `{{ 'string'.1 }}`, `{{ 'string'.count }}`.
- Now you can access string characters by index or get string length the same was as if it was an array, i.e. `{{ 'string'.first }}`, `{{ 'string'.last }}`, `{{ 'string'.1 }}`, `{{ 'string'.count }}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#245](https://github.com/stencilproject/Stencil/pull/245)
### Bug Fixes
- Fixed the performance issues introduced in Stencil 0.12 with the error log improvements.
- Fixed the performance issues introduced in Stencil 0.12 with the error log improvements.
[Ilya Puchka](https://github.com/ilyapuchka)
[#230](https://github.com/stencilproject/Stencil/pull/230)
- Now accessing undefined keys in NSObject does not cause runtime crash and instead renders empty string.
- Now accessing undefined keys in NSObject does not cause runtime crash and instead renders empty string.
[Ilya Puchka](https://github.com/ilyapuchka)
[#234](https://github.com/stencilproject/Stencil/pull/234)
- `for` tag: When iterating over a dictionary the keys will now always be sorted (in an ascending order) to ensure consistent output generation.
- `for` tag: When iterating over a dictionary the keys will now always be sorted (in an ascending order) to ensure consistent output generation.
[David Jennes](https://github.com/djbe)
[#240](https://github.com/stencilproject/Stencil/pull/240)
### Internal Changes
- Updated the codebase to use Swift 4 features.
- Updated the codebase to use Swift 4 features.
[David Jennes](https://github.com/djbe)
[#239](https://github.com/stencilproject/Stencil/pull/239)
- Update to Spectre 0.9.0.
- Update to Spectre 0.9.0.
[Ilya Puchka](https://github.com/ilyapuchka)
[#247](https://github.com/stencilproject/Stencil/pull/247)
- Optimise Scanner performance.
- Optimise Scanner performance.
[Eric Thorpe](https://github.com/trametheka)
[Sébastien Duperron](https://github.com/Liquidsoul)
[David Jennes](https://github.com/djbe)
@@ -104,7 +193,7 @@ _None_
### Internal Changes
- Updated the PathKit dependency to 0.9.0 in CocoaPods, to be in line with SPM.
- Updated the PathKit dependency to 0.9.0 in CocoaPods, to be in line with SPM.
[David Jennes](https://github.com/djbe)
[#227](https://github.com/stencilproject/Stencil/pull/227)
@@ -113,23 +202,23 @@ _None_
### Enhancements
- Added an optional second parameter to the `include` tag for passing a sub context to the included file.
- Added an optional second parameter to the `include` tag for passing a sub context to the included file.
[Yonas Kolb](https://github.com/yonaskolb)
[#214](https://github.com/stencilproject/Stencil/pull/214)
- Variables now support the subscript notation. For example, if you have a variable `key = "name"`, and an
object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John".
object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John".
[David Jennes](https://github.com/djbe)
[#215](https://github.com/stencilproject/Stencil/pull/215)
- Adds support for using spaces in filter expression.
- Adds support for using spaces in filter expression.
[Ilya Puchka](https://github.com/ilyapuchka)
[#178](https://github.com/stencilproject/Stencil/pull/178)
- Improvements in error reporting.
- Improvements in error reporting.
[Ilya Puchka](https://github.com/ilyapuchka)
[#167](https://github.com/stencilproject/Stencil/pull/167)
### Bug Fixes
- Fixed using quote as a filter parameter.
- Fixed using quote as a filter parameter.
[Ilya Puchka](https://github.com/ilyapuchka)
[#210](https://github.com/stencilproject/Stencil/pull/210)
@@ -138,62 +227,62 @@ _None_
### Enhancements
- Added support for resolving superclass properties for not-NSObject subclasses.
- Added support for resolving superclass properties for not-NSObject subclasses.
[Ilya Puchka](https://github.com/ilyapuchka)
[#152](https://github.com/stencilproject/Stencil/pull/152)
- The `{% for %}` tag can now iterate over tuples, structures and classes via
their stored properties.
their stored properties.
[Ilya Puchka](https://github.com/ilyapuchka)
[#172](https://github.com/stencilproject/Stencil/pull/173)
- Added `split` filter.
[#173](https://github.com/stencilproject/Stencil/pull/173)
- Added `split` filter.
[Ilya Puchka](https://github.com/ilyapuchka)
[#187](https://github.com/stencilproject/Stencil/pull/187)
- Allow default string filters to be applied to arrays.
- Allow default string filters to be applied to arrays.
[Ilya Puchka](https://github.com/ilyapuchka)
[#190](https://github.com/stencilproject/Stencil/pull/190)
- Similar filters are suggested when unknown filter is used.
- Similar filters are suggested when unknown filter is used.
[Ilya Puchka](https://github.com/ilyapuchka)
[#186](https://github.com/stencilproject/Stencil/pull/186)
- Added `indent` filter.
- Added `indent` filter.
[Ilya Puchka](https://github.com/ilyapuchka)
[#188](https://github.com/stencilproject/Stencil/pull/188)
- Allow using new lines inside tags.
- Allow using new lines inside tags.
[Ilya Puchka](https://github.com/ilyapuchka)
[#202](https://github.com/stencilproject/Stencil/pull/202)
- Added support for iterating arrays of tuples.
- Added support for iterating arrays of tuples.
[Ilya Puchka](https://github.com/ilyapuchka)
[#177](https://github.com/stencilproject/Stencil/pull/177)
- Added support for ranges in if-in expression.
- Added support for ranges in if-in expression.
[Ilya Puchka](https://github.com/ilyapuchka)
[#193](https://github.com/stencilproject/Stencil/pull/193)
- Added property `forloop.length` to get number of items in the loop.
- Added property `forloop.length` to get number of items in the loop.
[Ilya Puchka](https://github.com/ilyapuchka)
[#171](https://github.com/stencilproject/Stencil/pull/171)
- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count`.
- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#192](https://github.com/stencilproject/Stencil/pull/192)
### Bug Fixes
- Fixed rendering `{{ block.super }}` with several levels of inheritance.
- Fixed rendering `{{ block.super }}` with several levels of inheritance.
[Ilya Puchka](https://github.com/ilyapuchka)
[#154](https://github.com/stencilproject/Stencil/pull/154)
- Fixed checking dictionary values for nil in `default` filter.
- Fixed checking dictionary values for nil in `default` filter.
[Ilya Puchka](https://github.com/ilyapuchka)
[#162](https://github.com/stencilproject/Stencil/pull/162)
- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings.
- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings.
[Ilya Puchka](https://github.com/ilyapuchka)
[#168](https://github.com/stencilproject/Stencil/pull/168)
- Integer literals now resolve into Int values, not Float.
- Integer literals now resolve into Int values, not Float.
[Ilya Puchka](https://github.com/ilyapuchka)
[#181](https://github.com/stencilproject/Stencil/pull/181)
- Fixed accessing properties of optional properties via reflection.
- Fixed accessing properties of optional properties via reflection.
[Ilya Puchka](https://github.com/ilyapuchka)
[#204](https://github.com/stencilproject/Stencil/pull/204)
- No longer render optional values in arrays as `Optional(..)`.
- No longer render optional values in arrays as `Optional(..)`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#205](https://github.com/stencilproject/Stencil/pull/205)
- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}`.
- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}`.
[Ilya Puchka](https://github.com/ilyapuchka)
[#172](https://github.com/stencilproject/Stencil/pull/172)
@@ -253,7 +342,7 @@ _None_
### Bug Fixes
- You can now use literal filter arguments which contain quotes.
[#98](https://github.com/kylef/Stencil/pull/98)
[#98](https://github.com/stencilproject/Stencil/pull/98)
## 0.8.0
@@ -396,11 +485,11 @@ _None_
### Bug Fixes
- Variables (`{{ variable.5 }}`) that reference an array index at an unknown
index will now resolve to `nil` instead of causing a crash.
[#72](https://github.com/kylef/Stencil/issues/72)
index will now resolve to `nil` instead of causing a crash.
[#72](https://github.com/stencilproject/Stencil/issues/72)
- Templates can now extend templates that extend other templates.
[#60](https://github.com/kylef/Stencil/issues/60)
- Templates can now extend templates that extend other templates.
[#60](https://github.com/stencilproject/Stencil/issues/60)
- If comparisons will now treat 0 and below numbers as negative.

22
Gemfile
View File

@@ -1,7 +1,21 @@
# frozen_string_literal: true
source "https://rubygems.org"
source 'https://rubygems.org'
gem "octokit"
gem "cocoapods"
gem "rake"
# The bare minimum for building, e.g. in Homebrew
group :build do
gem 'base64', '~> 0.3'
gem 'rake', '~> 13.0'
gem 'xcpretty', '~> 0.3'
end
# In addition to :build, for contributing
group :development do
gem 'danger', '~> 8.4'
gem 'rubocop', '~> 1.22'
end
# For releasing to GitHub
group :release do
gem 'octokit', '~> 4.7'
end

View File

@@ -1,105 +1,132 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
activesupport (4.2.11.3)
i18n (~> 0.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
algoliasearch (1.27.3)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
claide (1.0.3)
cocoapods (1.9.3)
activesupport (>= 4.0.2, < 5)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.9.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.2.2, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-stats (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.6.6)
nap (~> 1.0)
ruby-macho (~> 1.4)
xcodeproj (>= 1.14.0, < 2.0)
cocoapods-core (1.9.3)
activesupport (>= 4.0.2, < 6)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.4)
cocoapods-downloader (1.4.0)
cocoapods-plugins (1.0.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
base64 (0.3.0)
claide (1.1.0)
claide-plugins (0.9.2)
cork
nap
cocoapods-search (1.0.0)
cocoapods-stats (1.1.0)
cocoapods-trunk (1.5.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
open4 (~> 1.3)
colored2 (3.1.2)
concurrent-ruby (1.1.6)
escape (0.0.4)
ethon (0.12.0)
ffi (>= 1.3.0)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
ffi (1.13.1)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
json (2.3.1)
minitest (5.14.1)
molinillo (0.6.6)
multipart-post (2.1.1)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
octokit (4.18.0)
faraday (>= 0.9)
sawyer (~> 0.8.0, >= 0.5.3)
public_suffix (4.0.5)
rake (13.0.1)
ruby-macho (1.4.0)
sawyer (0.8.2)
addressable (>= 2.3.5)
faraday (> 0.8, < 2.0)
thread_safe (0.3.6)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (1.2.7)
thread_safe (~> 0.1)
xcodeproj (1.17.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
cork (0.3.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
danger (8.6.1)
claide (~> 1.0)
claide-plugins (>= 0.9.2)
colored2 (~> 3.1)
cork (~> 0.1)
faraday (>= 0.9.0, < 2.0)
faraday-http-cache (~> 2.0)
git (~> 1.7)
kramdown (~> 2.3)
kramdown-parser-gfm (~> 1.0)
no_proxy_fix
octokit (~> 4.7)
terminal-table (>= 1, < 4)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-http-cache (2.5.1)
faraday (>= 0.8)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
git (1.19.1)
addressable (~> 2.8)
rchardet (~> 1.8)
json (2.15.0)
kramdown (2.5.1)
rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
multipart-post (2.4.1)
nap (1.1.0)
no_proxy_fix (0.1.2)
octokit (4.25.1)
faraday (>= 1, < 3)
sawyer (~> 0.9)
open4 (1.3.4)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
prism (1.5.1)
public_suffix (4.0.7)
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.0)
rchardet (1.10.0)
regexp_parser (2.11.3)
rexml (3.4.4)
rouge (3.28.0)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
terminal-table (1.6.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
ruby
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
cocoapods
octokit
rake
base64 (~> 0.3)
danger (~> 8.4)
octokit (~> 4.7)
rake (~> 13.0)
rubocop (~> 1.22)
xcpretty (~> 0.3)
BUNDLED WITH
2.1.4
2.7.2

View File

@@ -1,4 +1,5 @@
Copyright (c) 2018, Kyle Fuller
Copyright (c) 2022, Kyle Fuller as the original author
Copyright (c) 2025, Astzweig GmbH & Co. KG
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@@ -1,22 +1,22 @@
{
"object": {
"pins": [
{
"package": "PathKit",
"repositoryURL": "https://github.com/kylef/PathKit.git",
"state": {
"branch": null,
"revision": "73f8e9dca9b7a3078cb79128217dc8f2e585a511",
"version": "1.0.0"
}
},
{
"package": "Spectre",
"repositoryURL": "https://github.com/kylef/Spectre.git",
"state": {
"branch": null,
"revision": "f14ff47f45642aa5703900980b014c2e9394b6e5",
"version": "0.9.0"
"revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
"version": "0.10.1"
}
},
{
"package": "swiftpm-pathkit",
"repositoryURL": "https://github.com/astzweig/swiftpm-pathkit.git",
"state": {
"branch": null,
"revision": "5d164c34f35fa7241d77357e965acde80efcb6d9",
"version": "1.5.0"
}
}
]

View File

@@ -1,4 +1,4 @@
// swift-tools-version:4.2
// swift-tools-version:5.0
import PackageDescription
let package = Package(
@@ -7,17 +7,17 @@ let package = Package(
.library(name: "Stencil", targets: ["Stencil"])
],
dependencies: [
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.0"),
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
.package(url: "https://github.com/astzweig/swiftpm-pathkit.git", from: "1.5.0"),
.package(url: "https://github.com/kylef/Spectre.git", from: "0.10.1")
],
targets: [
.target(name: "Stencil", dependencies: [
"PathKit"
], path: "Sources"),
]),
.testTarget(name: "StencilTests", dependencies: [
"Stencil",
"Spectre"
])
],
swiftLanguageVersions: [.v4_2]
swiftLanguageVersions: [.v5]
)

View File

@@ -1,23 +0,0 @@
// swift-tools-version:5.0
import PackageDescription
let package = Package(
name: "Stencil",
products: [
.library(name: "Stencil", targets: ["Stencil"])
],
dependencies: [
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.0"),
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
],
targets: [
.target(name: "Stencil", dependencies: [
"PathKit"
], path: "Sources"),
.testTarget(name: "StencilTests", dependencies: [
"Stencil",
"Spectre"
])
],
swiftLanguageVersions: [.v4_2, .v5]
)

View File

@@ -1,7 +1,5 @@
# Stencil
[![Build Status](https://travis-ci.org/stencilproject/Stencil.svg?branch=master)](https://travis-ci.org/stencilproject/Stencil)
Stencil is a simple and powerful template language for Swift. It provides a
syntax similar to Django and Mustache. If you're familiar with these, you will
feel right at home with Stencil.

View File

@@ -1,10 +1,50 @@
#!/usr/bin/rake
PODSPEC_FILE = 'Stencil.podspec.json'
CHANGELOG_FILE = 'CHANGELOG.md'
if ENV['BUNDLE_GEMFILE'].nil?
puts "\u{274C} Please use bundle exec"
unless defined?(Bundler)
puts 'Please use bundle exec to run the rake command'
exit 1
end
require 'English'
## [ Constants ] ##############################################################
POD_NAME = 'Stencil'
MIN_XCODE_VERSION = 13.0
BUILD_DIR = File.absolute_path('./.build')
## [ Build Tasks ] ############################################################
namespace :files do
desc 'Update all files containing a version'
task :update, [:version] do |_, args|
version = args[:version]
Utils.print_header "Updating files for version #{version}"
podspec = Utils.podspec(POD_NAME)
podspec['version'] = version
podspec['source']['tag'] = version
File.write("#{POD_NAME}.podspec.json", JSON.pretty_generate(podspec) + "\n")
replace('CHANGELOG.md', '## Master' => "\#\# #{version}")
replace("docs/conf.py",
/^version = .*/ => %Q(version = '#{version}'),
/^release = .*/ => %Q(release = '#{version}')
)
docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
replace("docs/installation.rst",
/\.package\(url: .+, from: "(.+)"/ => %Q(.package\(url: "https://github.com/swiftstencil/swiftpm-stencil.git", from: "#{version}")
)
end
def replace(file, replacements)
content = File.read(file)
replacements.each do |match, replacement|
content.gsub!(match, replacement)
end
File.write(file, content)
end
end
task :default => 'release:new'

View File

@@ -1,68 +0,0 @@
/// A container for template variables.
public class Context {
var dictionaries: [[String: Any?]]
public let environment: Environment
public init(dictionary: [String: Any] = [:], environment: Environment? = nil) {
if !dictionary.isEmpty {
dictionaries = [dictionary]
} else {
dictionaries = []
}
self.environment = environment ?? Environment()
}
public subscript(key: String) -> Any? {
/// Retrieves a variable's value, starting at the current context and going upwards
get {
for dictionary in Array(dictionaries.reversed()) {
if let value = dictionary[key] {
return value
}
}
return nil
}
/// Set a variable in the current context, deleting the variable if it's nil
set(value) {
if var dictionary = dictionaries.popLast() {
dictionary[key] = value
dictionaries.append(dictionary)
}
}
}
/// Push a new level into the Context
fileprivate func push(_ dictionary: [String: Any] = [:]) {
dictionaries.append(dictionary)
}
/// Pop the last level off of the Context
fileprivate func pop() -> [String: Any?]? {
return dictionaries.popLast()
}
/// Push a new level onto the context for the duration of the execution of the given closure
public func push<Result>(dictionary: [String: Any] = [:], closure: (() throws -> Result)) rethrows -> Result {
push(dictionary)
defer { _ = pop() }
return try closure()
}
public func flatten() -> [String: Any] {
var accumulator: [String: Any] = [:]
for dictionary in dictionaries {
for (key, value) in dictionary {
if let value = value {
accumulator.updateValue(value, forKey: key)
}
}
}
return accumulator
}
}

View File

@@ -1,47 +0,0 @@
public struct Environment {
public let templateClass: Template.Type
public var extensions: [Extension]
public var loader: Loader?
public init(
loader: Loader? = nil,
extensions: [Extension] = [],
templateClass: Template.Type = Template.self
) {
self.templateClass = templateClass
self.loader = loader
self.extensions = extensions + [DefaultExtension()]
}
public func loadTemplate(name: String) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(name: name, environment: self)
} else {
throw TemplateDoesNotExist(templateNames: [name], loader: nil)
}
}
public func loadTemplate(names: [String]) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(names: names, environment: self)
} else {
throw TemplateDoesNotExist(templateNames: names, loader: nil)
}
}
public func renderTemplate(name: String, context: [String: Any] = [:]) throws -> String {
let template = try loadTemplate(name: name)
return try render(template: template, context: context)
}
public func renderTemplate(string: String, context: [String: Any] = [:]) throws -> String {
let template = templateClass.init(templateString: string, environment: self)
return try render(template: template, context: context)
}
func render(template: Template, context: [String: Any]) throws -> String {
// update template environment as it can be created from string literal with default environment
template.environment = self
return try template.render(context)
}
}

View File

@@ -1,81 +0,0 @@
public class TemplateDoesNotExist: Error, CustomStringConvertible {
let templateNames: [String]
let loader: Loader?
public init(templateNames: [String], loader: Loader? = nil) {
self.templateNames = templateNames
self.loader = loader
}
public var description: String {
let templates = templateNames.joined(separator: ", ")
if let loader = loader {
return "Template named `\(templates)` does not exist in loader \(loader)"
}
return "Template named `\(templates)` does not exist. No loaders found"
}
}
public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
public let reason: String
public var description: String { return reason }
public internal(set) var token: Token?
public internal(set) var stackTrace: [Token]
public var templateName: String? { return token?.sourceMap.filename }
var allTokens: [Token] {
return stackTrace + (token.map { [$0] } ?? [])
}
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
self.reason = reason
self.stackTrace = stackTrace
self.token = token
}
public init(_ description: String) {
self.init(reason: description)
}
}
extension Error {
func withToken(_ token: Token?) -> Error {
if var error = self as? TemplateSyntaxError {
error.token = error.token ?? token
return error
} else {
return TemplateSyntaxError(reason: "\(self)", token: token)
}
}
}
public protocol ErrorReporter: AnyObject {
func renderError(_ error: Error) -> String
}
open class SimpleErrorReporter: ErrorReporter {
open func renderError(_ error: Error) -> String {
guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
func describe(token: Token) -> String {
let templateName = token.sourceMap.filename ?? ""
let location = token.sourceMap.location
let highlight = """
\(String(Array(repeating: " ", count: location.lineOffset)))\
^\(String(Array(repeating: "~", count: max(token.contents.count - 1, 0))))
"""
return """
\(templateName)\(location.lineNumber):\(location.lineOffset): error: \(templateError.reason)
\(location.content)
\(highlight)
"""
}
var descriptions = templateError.stackTrace.reduce(into: []) { $0.append(describe(token: $1)) }
let description = templateError.token.map(describe(token:)) ?? templateError.reason
descriptions.append(description)
return descriptions.joined(separator: "\n")
}
}

View File

@@ -1,321 +0,0 @@
public protocol Expression: CustomStringConvertible {
func evaluate(context: Context) throws -> Bool
}
protocol InfixOperator: Expression {
init(lhs: Expression, rhs: Expression)
}
protocol PrefixOperator: Expression {
init(expression: Expression)
}
final class StaticExpression: Expression, CustomStringConvertible {
let value: Bool
init(value: Bool) {
self.value = value
}
func evaluate(context: Context) throws -> Bool {
return value
}
var description: String {
return "\(value)"
}
}
final class VariableExpression: Expression, CustomStringConvertible {
let variable: Resolvable
init(variable: Resolvable) {
self.variable = variable
}
var description: String {
return "(variable: \(variable))"
}
/// Resolves a variable in the given context as boolean
func resolve(context: Context, variable: Resolvable) throws -> Bool {
let result = try variable.resolve(context)
var truthy = false
if let result = result as? [Any] {
truthy = !result.isEmpty
} else if let result = result as? [String: Any] {
truthy = !result.isEmpty
} else if let result = result as? Bool {
truthy = result
} else if let result = result as? String {
truthy = !result.isEmpty
} else if let value = result, let result = toNumber(value: value) {
truthy = result > 0
} else if result != nil {
truthy = true
}
return truthy
}
func evaluate(context: Context) throws -> Bool {
return try resolve(context: context, variable: variable)
}
}
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
let expression: Expression
init(expression: Expression) {
self.expression = expression
}
var description: String {
return "not \(expression)"
}
func evaluate(context: Context) throws -> Bool {
return try !expression.evaluate(context: context)
}
}
final class InExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
return "(\(lhs) in \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
let lhsValue = try lhs.variable.resolve(context)
let rhsValue = try rhs.variable.resolve(context)
if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange<Int> {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange<Int> {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
return rhs.contains(lhs)
} else if lhsValue == nil && rhsValue == nil {
return true
}
}
return false
}
}
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
return "(\(lhs) or \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
let lhs = try self.lhs.evaluate(context: context)
if lhs {
return lhs
}
return try rhs.evaluate(context: context)
}
}
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
return "(\(lhs) and \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
let lhs = try self.lhs.evaluate(context: context)
if !lhs {
return lhs
}
return try rhs.evaluate(context: context)
}
}
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
required init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
return "(\(lhs) == \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
let lhsValue = try lhs.variable.resolve(context)
let rhsValue = try rhs.variable.resolve(context)
if let lhs = lhsValue, let rhs = rhsValue {
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
return lhs == rhs
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
return lhs == rhs
} else if let lhs = lhsValue as? Bool, let rhs = rhsValue as? Bool {
return lhs == rhs
}
} else if lhsValue == nil && rhsValue == nil {
return true
}
}
return false
}
}
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
required init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
return "(\(lhs) \(symbol) \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
let lhsValue = try lhs.variable.resolve(context)
let rhsValue = try rhs.variable.resolve(context)
if let lhs = lhsValue, let rhs = rhsValue {
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
return compare(lhs: lhs, rhs: rhs)
}
}
}
return false
}
var symbol: String {
return ""
}
func compare(lhs: Number, rhs: Number) -> Bool {
return false
}
}
class MoreThanExpression: NumericExpression {
override var symbol: String {
return ">"
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs > rhs
}
}
class MoreThanEqualExpression: NumericExpression {
override var symbol: String {
return ">="
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs >= rhs
}
}
class LessThanExpression: NumericExpression {
override var symbol: String {
return "<"
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs < rhs
}
}
class LessThanEqualExpression: NumericExpression {
override var symbol: String {
return "<="
}
override func compare(lhs: Number, rhs: Number) -> Bool {
return lhs <= rhs
}
}
class InequalityExpression: EqualityExpression {
override var description: String {
return "(\(lhs) != \(rhs))"
}
override func evaluate(context: Context) throws -> Bool {
return try !super.evaluate(context: context)
}
}
// swiftlint:disable:next cyclomatic_complexity
func toNumber(value: Any) -> Number? {
if let value = value as? Float {
return Number(value)
} else if let value = value as? Double {
return Number(value)
} else if let value = value as? UInt {
return Number(value)
} else if let value = value as? Int {
return Number(value)
} else if let value = value as? Int8 {
return Number(value)
} else if let value = value as? Int16 {
return Number(value)
} else if let value = value as? Int32 {
return Number(value)
} else if let value = value as? Int64 {
return Number(value)
} else if let value = value as? UInt8 {
return Number(value)
} else if let value = value as? UInt16 {
return Number(value)
} else if let value = value as? UInt32 {
return Number(value)
} else if let value = value as? UInt64 {
return Number(value)
} else if let value = value as? Number {
return value
} else if let value = value as? Float64 {
return Number(value)
} else if let value = value as? Float32 {
return Number(value)
}
return nil
}

View File

@@ -1,99 +0,0 @@
open class Extension {
typealias TagParser = (TokenParser, Token) throws -> NodeType
var tags = [String: TagParser]()
var filters = [String: Filter]()
public init() {
}
/// Registers a new template tag
public func registerTag(_ name: String, parser: @escaping (TokenParser, Token) throws -> NodeType) {
tags[name] = parser
}
/// Registers a simple template tag with a name and a handler
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
registerTag(name) { _, token in
SimpleNode(token: token, handler: handler)
}
}
/// Registers boolean filter with it's negative counterpart
// swiftlint:disable:next discouraged_optional_boolean
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
filters[name] = .simple(filter)
filters[negativeFilterName] = .simple {
guard let result = try filter($0) else { return nil }
return !result
}
}
/// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
filters[name] = .simple(filter)
}
/// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
filters[name] = .arguments({ value, args, _ in try filter(value, args) })
}
/// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?], Context) throws -> Any?) {
filters[name] = .arguments(filter)
}
}
class DefaultExtension: Extension {
override init() {
super.init()
registerDefaultTags()
registerDefaultFilters()
}
fileprivate func registerDefaultTags() {
registerTag("for", parser: ForNode.parse)
registerTag("if", parser: IfNode.parse)
registerTag("ifnot", parser: IfNode.parse_ifnot)
#if !os(Linux)
registerTag("now", parser: NowNode.parse)
#endif
registerTag("include", parser: IncludeNode.parse)
registerTag("extends", parser: ExtendsNode.parse)
registerTag("block", parser: BlockNode.parse)
registerTag("filter", parser: FilterNode.parse)
}
fileprivate func registerDefaultFilters() {
registerFilter("default", filter: defaultFilter)
registerFilter("capitalize", filter: capitalise)
registerFilter("uppercase", filter: uppercase)
registerFilter("lowercase", filter: lowercase)
registerFilter("join", filter: joinFilter)
registerFilter("split", filter: splitFilter)
registerFilter("indent", filter: indentFilter)
registerFilter("filter", filter: filterFilter)
}
}
protocol FilterType {
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any?
}
enum Filter: FilterType {
case simple(((Any?) throws -> Any?))
case arguments(((Any?, [Any?], Context) throws -> Any?))
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
switch self {
case let .simple(filter):
if !arguments.isEmpty {
throw TemplateSyntaxError("Can't invoke filter with an argument")
}
return try filter(value)
case let .arguments(filter):
return try filter(value, arguments, context)
}
}
}

View File

@@ -1,36 +0,0 @@
class FilterNode: NodeType {
let resolvable: Resolvable
let nodes: [NodeType]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 else {
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
}
let blocks = try parser.parse(until(["endfilter"]))
guard parser.nextToken() != nil else {
throw TemplateSyntaxError("`endfilter` was not found.")
}
let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
}
init(nodes: [NodeType], resolvable: Resolvable, token: Token) {
self.nodes = nodes
self.resolvable = resolvable
self.token = token
}
func render(_ context: Context) throws -> String {
let value = try renderNodes(nodes, context)
return try context.push(dictionary: ["filter_value": value]) {
try VariableNode(variable: resolvable, token: token).render(context)
}
}
}

View File

@@ -1,129 +0,0 @@
func capitalise(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).capitalized }
} else {
return stringify(value).capitalized
}
}
func uppercase(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).uppercased() }
} else {
return stringify(value).uppercased()
}
}
func lowercase(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).lowercased() }
} else {
return stringify(value).lowercased()
}
}
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
// value can be optional wrapping nil, so this way we check for underlying value
if let value = value, String(describing: value) != "nil" {
return value
}
for argument in arguments {
if let argument = argument {
return argument
}
}
return nil
}
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count < 2 else {
throw TemplateSyntaxError("'join' filter takes at most one argument")
}
let separator = stringify(arguments.first ?? "")
if let value = value as? [Any] {
return value
.map(stringify)
.joined(separator: separator)
}
return value
}
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count < 2 else {
throw TemplateSyntaxError("'split' filter takes at most one argument")
}
let separator = stringify(arguments.first ?? " ")
if let value = value as? String {
return value.components(separatedBy: separator)
}
return value
}
func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count <= 3 else {
throw TemplateSyntaxError("'indent' filter can take at most 3 arguments")
}
var indentWidth = 4
if !arguments.isEmpty {
guard let value = arguments[0] as? Int else {
throw TemplateSyntaxError("""
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
""")
}
indentWidth = value
}
var indentationChar = " "
if arguments.count > 1 {
guard let value = arguments[1] as? String else {
throw TemplateSyntaxError("""
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
""")
}
indentationChar = value
}
var indentFirst = false
if arguments.count > 2 {
guard let value = arguments[2] as? Bool else {
throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool")
}
indentFirst = value
}
let indentation = [String](repeating: indentationChar, count: indentWidth).joined()
return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
}
func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
guard !indentation.isEmpty else { return content }
var lines = content.components(separatedBy: .newlines)
let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
let result = lines.reduce(into: [firstLine]) { result, line in
result.append(line.isEmpty ? "" : "\(indentation)\(line)")
}
return result.joined(separator: "\n")
}
func filterFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
guard let value = value else { return nil }
guard arguments.count == 1 else {
throw TemplateSyntaxError("'filter' filter takes one argument")
}
let attribute = stringify(arguments[0])
let expr = try context.environment.compileFilter("$0|\(attribute)")
return try context.push(dictionary: ["$0": value]) {
try expr.resolve(context)
}
}

View File

@@ -1,176 +0,0 @@
import Foundation
class ForNode: NodeType {
let resolvable: Resolvable
let loopVariables: [String]
let nodes: [NodeType]
let emptyNodes: [NodeType]
let `where`: Expression?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let components = token.components
func hasToken(_ token: String, at index: Int) -> Bool {
return components.count > (index + 1) && components[index] == token
}
func endsOrHasToken(_ token: String, at index: Int) -> Bool {
return components.count == index || hasToken(token, at: index)
}
guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
throw TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.")
}
let loopVariables = components[1]
.split(separator: ",")
.map(String.init)
.map { $0.trim(character: " ") }
let resolvable = try parser.compileResolvable(components[3], containedIn: token)
let `where` = hasToken("where", at: 4)
? try parser.compileExpression(components: Array(components.suffix(from: 5)), token: token)
: nil
let forNodes = try parser.parse(until(["endfor", "empty"]))
guard let token = parser.nextToken() else {
throw TemplateSyntaxError("`endfor` was not found.")
}
var emptyNodes = [NodeType]()
if token.contents == "empty" {
emptyNodes = try parser.parse(until(["endfor"]))
_ = parser.nextToken()
}
return ForNode(
resolvable: resolvable,
loopVariables: loopVariables,
nodes: forNodes,
emptyNodes: emptyNodes,
where: `where`,
token: token
)
}
init(
resolvable: Resolvable,
loopVariables: [String],
nodes: [NodeType],
emptyNodes: [NodeType],
where: Expression? = nil,
token: Token? = nil
) {
self.resolvable = resolvable
self.loopVariables = loopVariables
self.nodes = nodes
self.emptyNodes = emptyNodes
self.where = `where`
self.token = token
}
func render(_ context: Context) throws -> String {
var values = try resolve(context)
if let `where` = self.where {
values = try values.filter { item -> Bool in
try push(value: item, context: context) {
try `where`.evaluate(context: context)
}
}
}
if !values.isEmpty {
let count = values.count
return try zip(0..., values)
.map { index, item in
let forContext: [String: Any] = [
"first": index == 0,
"last": index == (count - 1),
"counter": index + 1,
"counter0": index,
"length": count
]
return try context.push(dictionary: ["forloop": forContext]) {
try push(value: item, context: context) {
try renderNodes(nodes, context)
}
}
}
.joined()
}
return try context.push {
try renderNodes(emptyNodes, context)
}
}
private func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
if loopVariables.isEmpty {
return try context.push {
try closure()
}
}
let valueMirror = Mirror(reflecting: value)
if case .tuple? = valueMirror.displayStyle {
if loopVariables.count > Int(valueMirror.children.count) {
throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables")
}
var variablesContext = [String: Any]()
valueMirror.children.prefix(loopVariables.count).enumerated().forEach { offset, element in
if loopVariables[offset] != "_" {
variablesContext[loopVariables[offset]] = element.value
}
}
return try context.push(dictionary: variablesContext) {
try closure()
}
}
return try context.push(dictionary: [loopVariables.first ?? "": value]) {
try closure()
}
}
private func resolve(_ context: Context) throws -> [Any] {
let resolved = try resolvable.resolve(context)
var values: [Any]
if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
values = dictionary.sorted { $0.key < $1.key }
} else if let array = resolved as? [Any] {
values = array
} else if let range = resolved as? CountableClosedRange<Int> {
values = Array(range)
} else if let range = resolved as? CountableRange<Int> {
values = Array(range)
} else if let resolved = resolved {
let mirror = Mirror(reflecting: resolved)
switch mirror.displayStyle {
case .struct?, .tuple?:
values = Array(mirror.children)
case .class?:
var children = Array(mirror.children)
var currentMirror: Mirror? = mirror
while let superclassMirror = currentMirror?.superclassMirror {
children.append(contentsOf: superclassMirror.children)
currentMirror = superclassMirror
}
values = Array(children)
default:
values = []
}
} else {
values = []
}
return values
}
}

View File

@@ -1,313 +0,0 @@
enum Operator {
case infix(String, Int, InfixOperator.Type)
case prefix(String, Int, PrefixOperator.Type)
var name: String {
switch self {
case .infix(let name, _, _):
return name
case .prefix(let name, _, _):
return name
}
}
}
let operators: [Operator] = [
.infix("in", 5, InExpression.self),
.infix("or", 6, OrExpression.self),
.infix("and", 7, AndExpression.self),
.prefix("not", 8, NotExpression.self),
.infix("==", 10, EqualityExpression.self),
.infix("!=", 10, InequalityExpression.self),
.infix(">", 10, MoreThanExpression.self),
.infix(">=", 10, MoreThanEqualExpression.self),
.infix("<", 10, LessThanExpression.self),
.infix("<=", 10, LessThanEqualExpression.self)
]
func findOperator(name: String) -> Operator? {
for `operator` in operators where `operator`.name == name {
return `operator`
}
return nil
}
indirect enum IfToken {
case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
case variable(Resolvable)
case subExpression(Expression)
case end
var bindingPower: Int {
switch self {
case .infix(_, let bindingPower, _):
return bindingPower
case .prefix(_, let bindingPower, _):
return bindingPower
case .variable:
return 0
case .subExpression:
return 0
case .end:
return 0
}
}
func nullDenotation(parser: IfExpressionParser) throws -> Expression {
switch self {
case .infix(let name, _, _):
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
case .prefix(_, let bindingPower, let operatorType):
let expression = try parser.expression(bindingPower: bindingPower)
return operatorType.init(expression: expression)
case .variable(let variable):
return VariableExpression(variable: variable)
case .subExpression(let expression):
return expression
case .end:
throw TemplateSyntaxError("'if' expression error: end")
}
}
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
switch self {
case .infix(_, let bindingPower, let operatorType):
let right = try parser.expression(bindingPower: bindingPower)
return operatorType.init(lhs: left, rhs: right)
case .prefix(let name, _, _):
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
case .variable(let variable):
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
case .subExpression:
throw TemplateSyntaxError("'if' expression error: sub expression was called with a left hand side")
case .end:
throw TemplateSyntaxError("'if' expression error: end")
}
}
var isEnd: Bool {
switch self {
case .end:
return true
default:
return false
}
}
}
final class IfExpressionParser {
let tokens: [IfToken]
var position: Int = 0
private init(tokens: [IfToken]) {
self.tokens = tokens
}
static func parser(components: [String], environment: Environment, token: Token) throws -> IfExpressionParser {
return try IfExpressionParser(components: ArraySlice(components), environment: environment, token: token)
}
private init(components: ArraySlice<String>, environment: Environment, token: Token) throws {
var parsedComponents = Set<Int>()
var bracketsBalance = 0
self.tokens = try zip(components.indices, components).compactMap { index, component in
guard !parsedComponents.contains(index) else { return nil }
if component == "(" {
bracketsBalance += 1
let (expression, parsedCount) = try IfExpressionParser.subExpression(
from: components.suffix(from: index + 1),
environment: environment,
token: token
)
parsedComponents.formUnion(Set(index...(index + parsedCount)))
return .subExpression(expression)
} else if component == ")" {
bracketsBalance -= 1
if bracketsBalance < 0 {
throw TemplateSyntaxError("'if' expression error: missing opening bracket")
}
parsedComponents.insert(index)
return nil
} else {
parsedComponents.insert(index)
if let `operator` = findOperator(name: component) {
switch `operator` {
case .infix(let name, let bindingPower, let operatorType):
return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
case .prefix(let name, let bindingPower, let operatorType):
return .prefix(name: name, bindingPower: bindingPower, operatorType: operatorType)
}
}
return .variable(try environment.compileResolvable(component, containedIn: token))
}
}
}
private static func subExpression(
from components: ArraySlice<String>,
environment: Environment,
token: Token
) throws -> (Expression, Int) {
var bracketsBalance = 1
let subComponents = components.prefix {
if $0 == "(" {
bracketsBalance += 1
} else if $0 == ")" {
bracketsBalance -= 1
}
return bracketsBalance != 0
}
if bracketsBalance > 0 {
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
}
let expressionParser = try IfExpressionParser(components: subComponents, environment: environment, token: token)
let expression = try expressionParser.parse()
return (expression, subComponents.count)
}
var currentToken: IfToken {
if tokens.count > position {
return tokens[position]
}
return .end
}
var nextToken: IfToken {
position += 1
return currentToken
}
func parse() throws -> Expression {
let expression = try self.expression()
if !currentToken.isEnd {
throw TemplateSyntaxError("'if' expression error: dangling token")
}
return expression
}
func expression(bindingPower: Int = 0) throws -> Expression {
var token = currentToken
position += 1
var left = try token.nullDenotation(parser: self)
while bindingPower < currentToken.bindingPower {
token = currentToken
position += 1
left = try token.leftDenotation(left: left, parser: self)
}
return left
}
}
/// Represents an if condition and the associated nodes when the condition
/// evaluates
final class IfCondition {
let expression: Expression?
let nodes: [NodeType]
init(expression: Expression?, nodes: [NodeType]) {
self.expression = expression
self.nodes = nodes
}
func render(_ context: Context) throws -> String {
return try context.push {
try renderNodes(nodes, context)
}
}
}
class IfNode: NodeType {
let conditions: [IfCondition]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components
components.removeFirst()
let expression = try parser.compileExpression(components: components, token: token)
let nodes = try parser.parse(until(["endif", "elif", "else"]))
var conditions: [IfCondition] = [
IfCondition(expression: expression, nodes: nodes)
]
var nextToken = parser.nextToken()
while let current = nextToken, current.contents.hasPrefix("elif") {
var components = current.components
components.removeFirst()
let expression = try parser.compileExpression(components: components, token: current)
let nodes = try parser.parse(until(["endif", "elif", "else"]))
nextToken = parser.nextToken()
conditions.append(IfCondition(expression: expression, nodes: nodes))
}
if let current = nextToken, current.contents == "else" {
conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"]))))
nextToken = parser.nextToken()
}
guard let current = nextToken, current.contents == "endif" else {
throw TemplateSyntaxError("`endif` was not found.")
}
return IfNode(conditions: conditions, token: token)
}
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components
guard components.count == 2 else {
throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.")
}
components.removeFirst()
var trueNodes = [NodeType]()
var falseNodes = [NodeType]()
let expression = try parser.compileExpression(components: components, token: token)
falseNodes = try parser.parse(until(["endif", "else"]))
guard let token = parser.nextToken() else {
throw TemplateSyntaxError("`endif` was not found.")
}
if token.contents == "else" {
trueNodes = try parser.parse(until(["endif"]))
_ = parser.nextToken()
}
return IfNode(conditions: [
IfCondition(expression: expression, nodes: trueNodes),
IfCondition(expression: nil, nodes: falseNodes)
], token: token)
}
init(conditions: [IfCondition], token: Token? = nil) {
self.conditions = conditions
self.token = token
}
func render(_ context: Context) throws -> String {
for condition in conditions {
if let expression = condition.expression {
let truthy = try expression.evaluate(context: context)
if truthy {
return try condition.render(context)
}
} else {
return try condition.render(context)
}
}
return ""
}
}

View File

@@ -1,48 +0,0 @@
import PathKit
class IncludeNode: NodeType {
let templateName: Variable
let includeContext: String?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 || bits.count == 3 else {
throw TemplateSyntaxError("""
'include' tag requires one argument, the template file to be included. \
A second optional argument can be used to specify the context that will \
be passed to the included file
""")
}
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
}
init(templateName: Variable, includeContext: String? = nil, token: Token) {
self.templateName = templateName
self.includeContext = includeContext
self.token = token
}
func render(_ context: Context) throws -> String {
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
let template = try context.environment.loadTemplate(name: templateName)
do {
let subContext = includeContext.flatMap { context[$0] as? [String: Any] } ?? [:]
return try context.push(dictionary: subContext) {
try template.render(context)
}
} catch {
if let error = error as? TemplateSyntaxError {
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
} else {
throw error
}
}
}
}

View File

@@ -1,185 +0,0 @@
class BlockContext {
class var contextKey: String { return "block_context" }
// contains mapping of block names to their nodes and templates where they are defined
var blocks: [String: [BlockNode]]
init(blocks: [String: BlockNode]) {
self.blocks = [:]
blocks.forEach { self.blocks[$0.key] = [$0.value] }
}
func push(_ block: BlockNode, forKey blockName: String) {
if var blocks = blocks[blockName] {
blocks.append(block)
self.blocks[blockName] = blocks
} else {
self.blocks[blockName] = [block]
}
}
func pop(_ blockName: String) -> BlockNode? {
if var blocks = blocks[blockName] {
let block = blocks.removeFirst()
if blocks.isEmpty {
self.blocks.removeValue(forKey: blockName)
} else {
self.blocks[blockName] = blocks
}
return block
} else {
return nil
}
}
}
extension Collection {
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
for element in self {
if closure(element) {
return element
}
}
return nil
}
}
class ExtendsNode: NodeType {
let templateName: Variable
let blocks: [String: BlockNode]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 else {
throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
}
let parsedNodes = try parser.parse()
guard (parsedNodes.any { $0 is ExtendsNode }) == nil else {
throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
}
let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
let nodes = blockNodes.reduce(into: [String: BlockNode]()) { accumulator, node in
accumulator[node.name] = node
}
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
}
init(templateName: Variable, blocks: [String: BlockNode], token: Token) {
self.templateName = templateName
self.blocks = blocks
self.token = token
}
func render(_ context: Context) throws -> String {
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
let baseTemplate = try context.environment.loadTemplate(name: templateName)
let blockContext: BlockContext
if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext {
blockContext = currentBlockContext
for (name, block) in blocks {
blockContext.push(block, forKey: name)
}
} else {
blockContext = BlockContext(blocks: blocks)
}
do {
// pushes base template and renders it's content
// block_context contains all blocks from child templates
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
try baseTemplate.render(context)
}
} catch {
// if error template is already set (see catch in BlockNode)
// and it happend in the same template as current template
// there is no need to wrap it in another error
if let error = error as? TemplateSyntaxError, error.templateName != token?.sourceMap.filename {
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
} else {
throw error
}
}
}
}
class BlockNode: NodeType {
let name: String
let nodes: [NodeType]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 else {
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
}
let blockName = bits[1]
let nodes = try parser.parse(until(["endblock"]))
_ = parser.nextToken()
return BlockNode(name: blockName, nodes: nodes, token: token)
}
init(name: String, nodes: [NodeType], token: Token) {
self.name = name
self.nodes = nodes
self.token = token
}
func render(_ context: Context) throws -> String {
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
let childContext = try self.childContext(child, blockContext: blockContext, context: context)
// render extension node
do {
return try context.push(dictionary: childContext) {
try child.render(context)
}
} catch {
throw error.withToken(child.token)
}
}
return try renderNodes(nodes, context)
}
// child node is a block node from child template that extends this node (has the same name)
func childContext(_ child: BlockNode, blockContext: BlockContext, context: Context) throws -> [String: Any] {
var childContext: [String: Any] = [BlockContext.contextKey: blockContext]
if let blockSuperNode = child.nodes.first(where: {
if let token = $0.token, case .variable = token.kind, token.contents == "block.super" {
return true
} else {
return false
}
}) {
do {
// render base node so that its content can be used as part of child node that extends it
childContext["block"] = ["super": try self.render(context)]
} catch {
if let error = error as? TemplateSyntaxError {
throw TemplateSyntaxError(
reason: error.reason,
token: blockSuperNode.token,
stackTrace: error.allTokens)
} else {
throw TemplateSyntaxError(
reason: "\(error)",
token: blockSuperNode.token,
stackTrace: [])
}
}
}
return childContext
}
}

View File

@@ -1,112 +0,0 @@
import Foundation
/// A structure used to represent a template variable, and to resolve it in a given context.
final class KeyPath {
private var components = [String]()
private var current = ""
private var partialComponents = [String]()
private var subscriptLevel = 0
let variable: String
let context: Context
// Split the keypath string and resolve references if possible
init(_ variable: String, in context: Context) {
self.variable = variable
self.context = context
}
func parse() throws -> [String] {
defer {
components = []
current = ""
partialComponents = []
subscriptLevel = 0
}
for character in variable {
switch character {
case "." where subscriptLevel == 0:
try foundSeparator()
case "[":
try openBracket()
case "]":
try closeBracket()
default:
try addCharacter(character)
}
}
try finish()
return components
}
private func foundSeparator() throws {
if !current.isEmpty {
partialComponents.append(current)
}
guard !partialComponents.isEmpty else {
throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'")
}
components += partialComponents
current = ""
partialComponents = []
}
// when opening the first bracket, we must have a partial component
private func openBracket() throws {
guard !partialComponents.isEmpty || !current.isEmpty else {
throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'")
}
if subscriptLevel > 0 {
current.append("[")
} else if !current.isEmpty {
partialComponents.append(current)
current = ""
}
subscriptLevel += 1
}
// for a closing bracket at root level, try to resolve the reference
private func closeBracket() throws {
guard subscriptLevel > 0 else {
throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'")
}
if subscriptLevel > 1 {
current.append("]")
} else if !current.isEmpty,
let value = try Variable(current).resolve(context) {
partialComponents.append("\(value)")
current = ""
} else {
throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'")
}
subscriptLevel -= 1
}
private func addCharacter(_ character: Character) throws {
guard partialComponents.isEmpty || subscriptLevel > 0 else {
throw TemplateSyntaxError("Unexpected character '\(character)' in variable '\(variable)'")
}
current.append(character)
}
private func finish() throws {
// check if we have a last piece
if !current.isEmpty {
partialComponents.append(current)
}
components += partialComponents
guard subscriptLevel == 0 else {
throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'")
}
}
}

View File

@@ -1,230 +0,0 @@
import Foundation
typealias Line = (content: String, number: UInt, range: Range<String.Index>)
struct Lexer {
let templateName: String?
let templateString: String
let lines: [Line]
/// The potential token start characters. In a template these appear after a
/// `{` character, for example `{{`, `{%`, `{#`, ...
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
/// The token end characters, corresponding to their token start characters.
/// For example, a variable token starts with `{{` and ends with `}}`
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
"{": "}",
"%": "%",
"#": "#"
]
init(templateName: String? = nil, templateString: String) {
self.templateName = templateName
self.templateString = templateString
self.lines = templateString.components(separatedBy: .newlines).enumerated().compactMap {
guard !$0.element.isEmpty,
let range = templateString.range(of: $0.element) else { return nil }
return (content: $0.element, number: UInt($0.offset + 1), range)
}
}
/// Create a token that will be passed on to the parser, with the given
/// content and a range. The content will be tested to see if it's a
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
/// `text` token.
///
/// - Parameters:
/// - string: The content string of the token
/// - range: The range within the template content, used for smart
/// error reporting
func createToken(string: String, at range: Range<String.Index>) -> Token {
func strip() -> String {
guard string.count > 4 else { return "" }
let trimmed = String(string.dropFirst(2).dropLast(2))
.components(separatedBy: "\n")
.filter { !$0.isEmpty }
.map { $0.trim(character: " ") }
.joined(separator: " ")
return trimmed
}
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
let value = strip()
let range = templateString.range(of: value, range: range) ?? range
let location = rangeLocation(range)
let sourceMap = SourceMap(filename: templateName, location: location)
if string.hasPrefix("{{") {
return .variable(value: value, at: sourceMap)
} else if string.hasPrefix("{%") {
return .block(value: value, at: sourceMap)
} else if string.hasPrefix("{#") {
return .comment(value: value, at: sourceMap)
}
}
let location = rangeLocation(range)
let sourceMap = SourceMap(filename: templateName, location: location)
return .text(value: string, at: sourceMap)
}
/// Transforms the template into a list of tokens, that will eventually be
/// passed on to the parser.
///
/// - Returns: The list of tokens (see `createToken(string: at:)`).
func tokenize() -> [Token] {
var tokens: [Token] = []
let scanner = Scanner(templateString)
while !scanner.isEmpty {
if let (char, text) = scanner.scanForTokenStart(Lexer.tokenChars) {
if !text.isEmpty {
tokens.append(createToken(string: text, at: scanner.range))
}
guard let end = Lexer.tokenCharMap[char] else { continue }
let result = scanner.scanForTokenEnd(end)
tokens.append(createToken(string: result, at: scanner.range))
} else {
tokens.append(createToken(string: scanner.content, at: scanner.range))
scanner.content = ""
}
}
return tokens
}
/// Finds the line matching the given range (for a token)
///
/// - Parameter range: The range to search for.
/// - Returns: The content for that line, the line number and offset within
/// the line.
func rangeLocation(_ range: Range<String.Index>) -> ContentLocation {
guard let line = self.lines.first(where: { $0.range.contains(range.lowerBound) }) else {
return ("", 0, 0)
}
let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound)
return (line.content, line.number, offset)
}
}
class Scanner {
let originalContent: String
var content: String
var range: Range<String.UnicodeScalarView.Index>
/// The start delimiter for a token.
private static let tokenStartDelimiter: Unicode.Scalar = "{"
/// And the corresponding end delimiter for a token.
private static let tokenEndDelimiter: Unicode.Scalar = "}"
init(_ content: String) {
self.originalContent = content
self.content = content
range = content.unicodeScalars.startIndex..<content.unicodeScalars.startIndex
}
var isEmpty: Bool {
return content.isEmpty
}
/// Scans for the end of a token, with a specific ending character. If we're
/// searching for the end of a block token `%}`, this method receives a `%`.
/// The scanner will search for that `%` followed by a `}`.
///
/// Note: if the end of a token is found, the `content` and `range`
/// properties are updated to reflect this. `content` will be set to what
/// remains of the template after the token. `range` will be set to the range
/// of the token within the template.
///
/// - Parameter tokenChar: The token end character to search for.
/// - Returns: The content of a token, or "" if no token end was found.
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
var foundChar = false
for (index, char) in content.unicodeScalars.enumerated() {
if foundChar && char == Scanner.tokenEndDelimiter {
let result = String(content.unicodeScalars.prefix(index + 1))
content = String(content.unicodeScalars.dropFirst(index + 1))
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index + 1)
return result
} else {
foundChar = (char == tokenChar)
}
}
content = ""
return ""
}
/// Scans for the start of a token, with a list of potential starting
/// characters. To scan for the start of variables (`{{`), blocks (`{%`) and
/// comments (`{#`), this method receives the characters `{`, `%` and `#`.
/// The scanner will search for a `{`, followed by one of the search
/// characters. It will give the found character, and the content that came
/// before the token.
///
/// Note: if the start of a token is found, the `content` and `range`
/// properties are updated to reflect this. `content` will be set to what
/// remains of the template starting with the token. `range` will be set to
/// the start of the token within the template.
///
/// - Parameter tokenChars: List of token start characters to search for.
/// - Returns: The found token start character, together with the content
/// before the token, or nil of no token start was found.
func scanForTokenStart(_ tokenChars: [Unicode.Scalar]) -> (Unicode.Scalar, String)? {
var foundBrace = false
range = range.upperBound..<range.upperBound
for (index, char) in content.unicodeScalars.enumerated() {
if foundBrace && tokenChars.contains(char) {
let result = String(content.unicodeScalars.prefix(index - 1))
content = String(content.unicodeScalars.dropFirst(index - 1))
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index - 1)
return (char, result)
} else {
foundBrace = (char == Scanner.tokenStartDelimiter)
}
}
return nil
}
}
extension String {
func findFirstNot(character: Character) -> String.Index? {
var index = startIndex
while index != endIndex {
if character != self[index] {
return index
}
index = self.index(after: index)
}
return nil
}
func findLastNot(character: Character) -> String.Index? {
var index = self.index(before: endIndex)
while index != startIndex {
if character != self[index] {
return self.index(after: index)
}
index = self.index(before: index)
}
return nil
}
func trim(character: Character) -> String {
let first = findFirstNot(character: character) ?? startIndex
let last = findLastNot(character: character) ?? endIndex
return String(self[first..<last])
}
}
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)

View File

@@ -1,124 +0,0 @@
import Foundation
import PathKit
public protocol Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template
func loadTemplate(names: [String], environment: Environment) throws -> Template
}
extension Loader {
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for name in names {
do {
return try loadTemplate(name: name, environment: environment)
} catch is TemplateDoesNotExist {
continue
} catch {
throw error
}
}
throw TemplateDoesNotExist(templateNames: names, loader: self)
}
}
// A class for loading a template from disk
public class FileSystemLoader: Loader, CustomStringConvertible {
public let paths: [Path]
public init(paths: [Path]) {
self.paths = paths
}
public init(bundle: [Bundle]) {
self.paths = bundle.map {
Path($0.bundlePath)
}
}
public var description: String {
return "FileSystemLoader(\(paths))"
}
public func loadTemplate(name: String, environment: Environment) throws -> Template {
for path in paths {
let templatePath = try path.safeJoin(path: Path(name))
if !templatePath.exists {
continue
}
let content: String = try templatePath.read()
return environment.templateClass.init(templateString: content, environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for path in paths {
for templateName in names {
let templatePath = try path.safeJoin(path: Path(templateName))
if templatePath.exists {
let content: String = try templatePath.read()
return environment.templateClass.init(templateString: content, environment: environment, name: templateName)
}
}
}
throw TemplateDoesNotExist(templateNames: names, loader: self)
}
}
public class DictionaryLoader: Loader {
public let templates: [String: String]
public init(templates: [String: String]) {
self.templates = templates
}
public func loadTemplate(name: String, environment: Environment) throws -> Template {
if let content = templates[name] {
return environment.templateClass.init(templateString: content, environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for name in names {
if let content = templates[name] {
return environment.templateClass.init(templateString: content, environment: environment, name: name)
}
}
throw TemplateDoesNotExist(templateNames: names, loader: self)
}
}
extension Path {
func safeJoin(path: Path) throws -> Path {
let newPath = self + path
if !newPath.absolute().description.hasPrefix(absolute().description) {
throw SuspiciousFileOperation(basePath: self, path: newPath)
}
return newPath
}
}
class SuspiciousFileOperation: Error {
let basePath: Path
let path: Path
init(basePath: Path, path: Path) {
self.basePath = basePath
self.path = path
}
var description: String {
return "Path `\(path)` is located outside of base path `\(basePath)`"
}
}

View File

@@ -1,149 +0,0 @@
import Foundation
public protocol NodeType {
/// Render the node in the given context
func render(_ context: Context) throws -> String
/// Reference to this node's token
var token: Token? { get }
}
/// Render the collection of nodes in the given context
public func renderNodes(_ nodes: [NodeType], _ context: Context) throws -> String {
return try nodes
.map {
do {
return try $0.render(context)
} catch {
throw error.withToken($0.token)
}
}
.joined()
}
public class SimpleNode: NodeType {
public let handler: (Context) throws -> String
public let token: Token?
public init(token: Token, handler: @escaping (Context) throws -> String) {
self.token = token
self.handler = handler
}
public func render(_ context: Context) throws -> String {
return try handler(context)
}
}
public class TextNode: NodeType {
public let text: String
public let token: Token?
public init(text: String) {
self.text = text
self.token = nil
}
public func render(_ context: Context) throws -> String {
return self.text
}
}
public protocol Resolvable {
func resolve(_ context: Context) throws -> Any?
}
public class VariableNode: NodeType {
public let variable: Resolvable
public var token: Token?
let condition: Expression?
let elseExpression: Resolvable?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components
func hasToken(_ token: String, at index: Int) -> Bool {
return components.count > (index + 1) && components[index] == token
}
let condition: Expression?
let elseExpression: Resolvable?
if hasToken("if", at: 1) {
let components = components.suffix(from: 2)
if let elseIndex = components.firstIndex(of: "else") {
condition = try parser.compileExpression(components: Array(components.prefix(upTo: elseIndex)), token: token)
let elseToken = components.suffix(from: elseIndex.advanced(by: 1)).joined(separator: " ")
elseExpression = try parser.compileResolvable(elseToken, containedIn: token)
} else {
condition = try parser.compileExpression(components: Array(components), token: token)
elseExpression = nil
}
} else {
condition = nil
elseExpression = nil
}
guard let resolvable = components.first else {
throw TemplateSyntaxError(reason: "Missing variable name", token: token)
}
let filter = try parser.compileResolvable(resolvable, containedIn: token)
return VariableNode(variable: filter, token: token, condition: condition, elseExpression: elseExpression)
}
public init(variable: Resolvable, token: Token? = nil) {
self.variable = variable
self.token = token
self.condition = nil
self.elseExpression = nil
}
init(variable: Resolvable, token: Token? = nil, condition: Expression?, elseExpression: Resolvable?) {
self.variable = variable
self.token = token
self.condition = condition
self.elseExpression = elseExpression
}
public init(variable: String, token: Token? = nil) {
self.variable = Variable(variable)
self.token = token
self.condition = nil
self.elseExpression = nil
}
public func render(_ context: Context) throws -> String {
if let condition = self.condition, try condition.evaluate(context: context) == false {
return try elseExpression?.resolve(context).map(stringify) ?? ""
}
let result = try variable.resolve(context)
return stringify(result)
}
}
func stringify(_ result: Any?) -> String {
if let result = result as? String {
return result
} else if let array = result as? [Any?] {
return unwrap(array).description
} else if let result = result as? CustomStringConvertible {
return result.description
} else if let result = result as? NSObject {
return result.description
}
return ""
}
func unwrap(_ array: [Any?]) -> [Any] {
return array.map { (item: Any?) -> Any in
if let item = item {
if let items = item as? [Any?] {
return unwrap(items)
} else {
return item
}
} else { return item as Any }
}
}

View File

@@ -1,44 +0,0 @@
#if !os(Linux)
import Foundation
class NowNode: NodeType {
let format: Variable
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var format: Variable?
let components = token.components
guard components.count <= 2 else {
throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
}
if components.count == 2 {
format = Variable(components[1])
}
return NowNode(format: format, token: token)
}
init(format: Variable?, token: Token? = nil) {
self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
self.token = token
}
func render(_ context: Context) throws -> String {
let date = Date()
let format = try self.format.resolve(context)
var formatter: DateFormatter
if let format = format as? DateFormatter {
formatter = format
} else if let format = format as? String {
formatter = DateFormatter()
formatter.dateFormat = format
} else {
return ""
}
return formatter.string(from: date)
}
}
#endif

View File

@@ -1,222 +0,0 @@
public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
return { parser, token in
if let name = token.components.first {
for tag in tags where name == tag {
return true
}
}
return false
}
}
/// A class for parsing an array of tokens and converts them into a collection of Node's
public class TokenParser {
public typealias TagParser = (TokenParser, Token) throws -> NodeType
fileprivate var tokens: [Token]
fileprivate let environment: Environment
public init(tokens: [Token], environment: Environment) {
self.tokens = tokens
self.environment = environment
}
/// Parse the given tokens into nodes
public func parse() throws -> [NodeType] {
return try parse(nil)
}
public func parse(_ parseUntil: ((_ parser: TokenParser, _ token: Token) -> (Bool))?) throws -> [NodeType] {
var nodes = [NodeType]()
while !tokens.isEmpty {
guard let token = nextToken() else { break }
switch token.kind {
case .text:
nodes.append(TextNode(text: token.contents))
case .variable:
try nodes.append(VariableNode.parse(self, token: token))
case .block:
if let parseUntil = parseUntil, parseUntil(self, token) {
prependToken(token)
return nodes
}
if let tag = token.components.first {
do {
let parser = try environment.findTag(name: tag)
let node = try parser(self, token)
nodes.append(node)
} catch {
throw error.withToken(token)
}
}
case .comment:
continue
}
}
return nodes
}
public func nextToken() -> Token? {
if !tokens.isEmpty {
return tokens.remove(at: 0)
}
return nil
}
public func prependToken(_ token: Token) {
tokens.insert(token, at: 0)
}
/// Create filter expression from a string contained in provided token
public func compileFilter(_ filterToken: String, containedIn token: Token) throws -> Resolvable {
return try environment.compileFilter(filterToken, containedIn: token)
}
/// Create boolean expression from components contained in provided token
public func compileExpression(components: [String], token: Token) throws -> Expression {
return try environment.compileExpression(components: components, containedIn: token)
}
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
return try environment.compileResolvable(token, containedIn: containingToken)
}
}
extension Environment {
func findTag(name: String) throws -> Extension.TagParser {
for ext in extensions {
if let filter = ext.tags[name] {
return filter
}
}
throw TemplateSyntaxError("Unknown template tag '\(name)'")
}
func findFilter(_ name: String) throws -> FilterType {
for ext in extensions {
if let filter = ext.filters[name] {
return filter
}
}
let suggestedFilters = self.suggestedFilters(for: name)
if suggestedFilters.isEmpty {
throw TemplateSyntaxError("Unknown filter '\(name)'.")
} else {
throw TemplateSyntaxError("""
Unknown filter '\(name)'. \
Found similar filters: \(suggestedFilters.map { "'\($0)'" }.joined(separator: ", ")).
""")
}
}
private func suggestedFilters(for name: String) -> [String] {
let allFilters = extensions.flatMap { $0.filters.keys }
let filtersWithDistance = allFilters
.map { (filterName: $0, distance: $0.levenshteinDistance(name)) }
// do not suggest filters which names are shorter than the distance
.filter { $0.filterName.count > $0.distance }
guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else {
return []
}
// suggest all filters with the same distance
return filtersWithDistance.filter { $0.distance == minDistance }.map { $0.filterName }
}
/// Create filter expression from a string
public func compileFilter(_ token: String) throws -> Resolvable {
return try FilterExpression(token: token, environment: self)
}
/// Create filter expression from a string contained in provided token
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
do {
return try FilterExpression(token: filterToken, environment: self)
} catch {
guard var syntaxError = error as? TemplateSyntaxError, syntaxError.token == nil else {
throw error
}
// find offset of filter in the containing token so that only filter is highligted, not the whole token
if let filterTokenRange = containingToken.contents.range(of: filterToken) {
var location = containingToken.sourceMap.location
location.lineOffset += containingToken.contents.distance(
from: containingToken.contents.startIndex,
to: filterTokenRange.lowerBound
)
syntaxError.token = .variable(
value: filterToken,
at: SourceMap(filename: containingToken.sourceMap.filename, location: location)
)
} else {
syntaxError.token = containingToken
}
throw syntaxError
}
}
/// Create resolvable (i.e. range variable or filter expression) from a string
public func compileResolvable(_ token: String) throws -> Resolvable {
return try RangeVariable(token, environment: self)
?? compileFilter(token)
}
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
return try RangeVariable(token, environment: self, containedIn: containingToken)
?? compileFilter(token, containedIn: containingToken)
}
/// Create boolean expression from components contained in provided token
public func compileExpression(components: [String], containedIn token: Token) throws -> Expression {
return try IfExpressionParser.parser(components: components, environment: self, token: token).parse()
}
}
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
extension String {
subscript(_ index: Int) -> Character {
return self[self.index(self.startIndex, offsetBy: index)]
}
func levenshteinDistance(_ target: String) -> Int {
// create two work vectors of integer distances
var last, current: [Int]
// initialize v0 (the previous row of distances)
// this row is A[0][i]: edit distance for an empty s
// the distance is just the number of characters to delete from t
last = [Int](0...target.count)
current = [Int](repeating: 0, count: target.count + 1)
for selfIndex in 0..<self.count {
// calculate v1 (current row distances) from the previous row v0
// first element of v1 is A[i+1][0]
// edit distance is delete (i+1) chars from s to match empty t
current[0] = selfIndex + 1
// use formula to fill in the rest of the row
for targetIndex in 0..<target.count {
current[targetIndex + 1] = Swift.min(
last[targetIndex + 1] + 1,
current[targetIndex] + 1,
last[targetIndex] + (self[selfIndex] == target[targetIndex] ? 0 : 1)
)
}
// copy v1 (current row) to v0 (previous row) for next iteration
last = current
}
return current[target.count]
}
}

View File

@@ -0,0 +1,106 @@
/// A container for template variables.
public class Context {
var dictionaries: [[String: Any?]]
/// The context's environment, such as registered extensions, classes,
public let environment: Environment
init(dictionaries: [[String: Any?]], environment: Environment) {
self.dictionaries = dictionaries
self.environment = environment
}
/// Create a context from a dictionary (and an env.)
///
/// - Parameters:
/// - dictionary: The context's data
/// - environment: Environment such as extensions,
public convenience init(dictionary: [String: Any] = [:], environment: Environment? = nil) {
self.init(
dictionaries: dictionary.isEmpty ? [] : [dictionary],
environment: environment ?? Environment()
)
}
/// Access variables in this context by name
public subscript(key: String) -> Any? {
/// Retrieves a variable's value, starting at the current context and going upwards
get {
for dictionary in Array(dictionaries.reversed()) {
if let value = dictionary[key] {
return value
}
}
return nil
}
/// Set a variable in the current context, deleting the variable if it's nil
set(value) {
if var dictionary = dictionaries.popLast() {
dictionary[key] = value
dictionaries.append(dictionary)
}
}
}
/// Push a new level into the Context
///
/// - Parameters:
/// - dictionary: The new level data
fileprivate func push(_ dictionary: [String: Any] = [:]) {
dictionaries.append(dictionary)
}
/// Pop the last level off of the Context
///
/// - returns: The popped level
// swiftlint:disable:next discouraged_optional_collection
fileprivate func pop() -> [String: Any?]? {
dictionaries.popLast()
}
/// Push a new level onto the context for the duration of the execution of the given closure
///
/// - Parameters:
/// - dictionary: The new level data
/// - closure: The closure to execute
/// - returns: Return value of the closure
public func push<Result>(dictionary: [String: Any] = [:], closure: (() throws -> Result)) rethrows -> Result {
push(dictionary)
defer { _ = pop() }
return try closure()
}
/// Flatten all levels of context data into 1, merging duplicate variables
///
/// - returns: All collected variables
public func flatten() -> [String: Any] {
var accumulator: [String: Any] = [:]
for dictionary in dictionaries {
for (key, value) in dictionary {
if let value = value {
accumulator.updateValue(value, forKey: key)
}
}
}
return accumulator
}
/// Cache result of block by its name in the context top-level, so that it can be later rendered
/// via `{{ block.name }}`
///
/// - Parameters:
/// - name: The name of the stored block
/// - content: The block's rendered content
public func cacheBlock(_ name: String, content: String) {
if var block = dictionaries.first?["block"] as? [String: String] {
block[name] = content
dictionaries[0]["block"] = block
} else {
dictionaries.insert(["block": [name: content]], at: 0)
}
}
}

View File

@@ -0,0 +1,18 @@
/// Marker protocol so we can know which types support `@dynamicMemberLookup`. Add this to your own types that support
/// lookup by String.
public protocol DynamicMemberLookup {
/// Get a value for a given `String` key
subscript(dynamicMember member: String) -> Any? { get }
}
public extension DynamicMemberLookup where Self: RawRepresentable {
/// Get a value for a given `String` key
subscript(dynamicMember member: String) -> Any? {
switch member {
case "rawValue":
return rawValue
default:
return nil
}
}
}

View File

@@ -0,0 +1,84 @@
/// Container for environment data, such as registered extensions
public struct Environment {
/// The class for loading new templates
public let templateClass: Template.Type
/// List of registered extensions
public var extensions: [Extension]
/// How to handle whitespace
public var trimBehaviour: TrimBehaviour
/// Mechanism for loading new files
public var loader: Loader?
/// Basic initializer
///
/// - Parameters:
/// - loader: Mechanism for loading new files
/// - extensions: List of extension containers
/// - templateClass: Class for newly loaded templates
/// - trimBehaviour: How to handle whitespace
public init(
loader: Loader? = nil,
extensions: [Extension] = [],
templateClass: Template.Type = Template.self,
trimBehaviour: TrimBehaviour = .nothing
) {
self.templateClass = templateClass
self.loader = loader
self.extensions = extensions + [DefaultExtension()]
self.trimBehaviour = trimBehaviour
}
/// Load a template with the given name
///
/// - Parameters:
/// - name: Name of the template
/// - returns: Loaded template instance
public func loadTemplate(name: String) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(name: name, environment: self)
} else {
throw TemplateDoesNotExist(templateNames: [name], loader: nil)
}
}
/// Load a template with the given names
///
/// - Parameters:
/// - names: Names of the template
/// - returns: Loaded template instance
public func loadTemplate(names: [String]) throws -> Template {
if let loader = loader {
return try loader.loadTemplate(names: names, environment: self)
} else {
throw TemplateDoesNotExist(templateNames: names, loader: nil)
}
}
/// Render a template with the given name, providing some data
///
/// - Parameters:
/// - name: Name of the template
/// - context: Data for rendering
/// - returns: Rendered output
public func renderTemplate(name: String, context: [String: Any] = [:]) throws -> String {
let template = try loadTemplate(name: name)
return try render(template: template, context: context)
}
/// Render the given template string, providing some data
///
/// - Parameters:
/// - string: Template string
/// - context: Data for rendering
/// - returns: Rendered output
public func renderTemplate(string: String, context: [String: Any] = [:]) throws -> String {
let template = templateClass.init(templateString: string, environment: self)
return try render(template: template, context: context)
}
func render(template: Template, context: [String: Any]) throws -> String {
// update template environment as it can be created from string literal with default environment
template.environment = self
return try template.render(context)
}
}

View File

@@ -0,0 +1,81 @@
public class TemplateDoesNotExist: Error, CustomStringConvertible {
let templateNames: [String]
let loader: Loader?
public init(templateNames: [String], loader: Loader? = nil) {
self.templateNames = templateNames
self.loader = loader
}
public var description: String {
let templates = templateNames.joined(separator: ", ")
if let loader = loader {
return "Template named `\(templates)` does not exist in loader \(loader)"
}
return "Template named `\(templates)` does not exist. No loaders found"
}
}
public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
public let reason: String
public var description: String { reason }
public internal(set) var token: Token?
public internal(set) var stackTrace: [Token]
public var templateName: String? { token?.sourceMap.filename }
var allTokens: [Token] {
stackTrace + (token.map { [$0] } ?? [])
}
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
self.reason = reason
self.stackTrace = stackTrace
self.token = token
}
public init(_ description: String) {
self.init(reason: description)
}
}
extension Error {
func withToken(_ token: Token?) -> Error {
if var error = self as? TemplateSyntaxError {
error.token = error.token ?? token
return error
} else {
return TemplateSyntaxError(reason: "\(self)", token: token)
}
}
}
public protocol ErrorReporter: AnyObject {
func renderError(_ error: Error) -> String
}
open class SimpleErrorReporter: ErrorReporter {
open func renderError(_ error: Error) -> String {
guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
func describe(token: Token) -> String {
let templateName = token.sourceMap.filename ?? ""
let location = token.sourceMap.location
let highlight = """
\(String(Array(repeating: " ", count: location.lineOffset)))\
^\(String(Array(repeating: "~", count: max(token.contents.count - 1, 0))))
"""
return """
\(templateName)\(location.lineNumber):\(location.lineOffset): error: \(templateError.reason)
\(location.content)
\(highlight)
"""
}
var descriptions = templateError.stackTrace.reduce(into: []) { $0.append(describe(token: $1)) }
let description = templateError.token.map(describe(token:)) ?? templateError.reason
descriptions.append(description)
return descriptions.joined(separator: "\n")
}
}

View File

@@ -0,0 +1,327 @@
public protocol Expression: CustomStringConvertible, Resolvable {
func evaluate(context: Context) throws -> Bool
}
extension Expression {
func resolve(_ context: Context) throws -> Any? {
try "\(evaluate(context: context))"
}
}
protocol InfixOperator: Expression {
init(lhs: Expression, rhs: Expression)
}
protocol PrefixOperator: Expression {
init(expression: Expression)
}
final class StaticExpression: Expression, CustomStringConvertible {
let value: Bool
init(value: Bool) {
self.value = value
}
func evaluate(context: Context) throws -> Bool {
value
}
var description: String {
"\(value)"
}
}
final class VariableExpression: Expression, CustomStringConvertible {
let variable: Resolvable
init(variable: Resolvable) {
self.variable = variable
}
var description: String {
"(variable: \(variable))"
}
func resolve(_ context: Context) throws -> Any? {
try variable.resolve(context)
}
/// Resolves a variable in the given context as boolean
func evaluate(context: Context) throws -> Bool {
let result = try variable.resolve(context)
var truthy = false
if let result = result as? [Any] {
truthy = !result.isEmpty
} else if let result = result as? [String: Any] {
truthy = !result.isEmpty
} else if let result = result as? Bool {
truthy = result
} else if let result = result as? String {
truthy = !result.isEmpty
} else if let value = result, let result = toNumber(value: value) {
truthy = result > 0
} else if result != nil {
truthy = true
}
return truthy
}
}
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
let expression: Expression
init(expression: Expression) {
self.expression = expression
}
var description: String {
"not \(expression)"
}
func evaluate(context: Context) throws -> Bool {
try !expression.evaluate(context: context)
}
}
final class InExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
"(\(lhs) in \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
let lhsValue = try lhs.variable.resolve(context)
let rhsValue = try rhs.variable.resolve(context)
if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange<Int> {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange<Int> {
return rhs.contains(lhs)
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
return rhs.contains(lhs)
} else if lhsValue == nil && rhsValue == nil {
return true
}
}
return false
}
}
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
"(\(lhs) or \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
let lhs = try self.lhs.evaluate(context: context)
if lhs {
return lhs
}
return try rhs.evaluate(context: context)
}
}
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
"(\(lhs) and \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
let lhs = try self.lhs.evaluate(context: context)
if !lhs {
return lhs
}
return try rhs.evaluate(context: context)
}
}
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
required init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
"(\(lhs) == \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
let lhsValue = try lhs.variable.resolve(context)
let rhsValue = try rhs.variable.resolve(context)
if let lhs = lhsValue, let rhs = rhsValue {
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
return lhs == rhs
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
return lhs == rhs
} else if let lhs = lhsValue as? Bool, let rhs = rhsValue as? Bool {
return lhs == rhs
}
} else if lhsValue == nil && rhsValue == nil {
return true
}
}
return false
}
}
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
let lhs: Expression
let rhs: Expression
required init(lhs: Expression, rhs: Expression) {
self.lhs = lhs
self.rhs = rhs
}
var description: String {
"(\(lhs) \(symbol) \(rhs))"
}
func evaluate(context: Context) throws -> Bool {
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
let lhsValue = try lhs.variable.resolve(context)
let rhsValue = try rhs.variable.resolve(context)
if let lhs = lhsValue, let rhs = rhsValue {
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
return compare(lhs: lhs, rhs: rhs)
}
}
}
return false
}
var symbol: String {
""
}
func compare(lhs: Number, rhs: Number) -> Bool {
false
}
}
class MoreThanExpression: NumericExpression {
override var symbol: String {
">"
}
override func compare(lhs: Number, rhs: Number) -> Bool {
lhs > rhs
}
}
class MoreThanEqualExpression: NumericExpression {
override var symbol: String {
">="
}
override func compare(lhs: Number, rhs: Number) -> Bool {
lhs >= rhs
}
}
class LessThanExpression: NumericExpression {
override var symbol: String {
"<"
}
override func compare(lhs: Number, rhs: Number) -> Bool {
lhs < rhs
}
}
class LessThanEqualExpression: NumericExpression {
override var symbol: String {
"<="
}
override func compare(lhs: Number, rhs: Number) -> Bool {
lhs <= rhs
}
}
class InequalityExpression: EqualityExpression {
override var description: String {
"(\(lhs) != \(rhs))"
}
override func evaluate(context: Context) throws -> Bool {
try !super.evaluate(context: context)
}
}
// swiftlint:disable:next cyclomatic_complexity
func toNumber(value: Any) -> Number? {
if let value = value as? Float {
return Number(value)
} else if let value = value as? Double {
return Number(value)
} else if let value = value as? UInt {
return Number(value)
} else if let value = value as? Int {
return Number(value)
} else if let value = value as? Int8 {
return Number(value)
} else if let value = value as? Int16 {
return Number(value)
} else if let value = value as? Int32 {
return Number(value)
} else if let value = value as? Int64 {
return Number(value)
} else if let value = value as? UInt8 {
return Number(value)
} else if let value = value as? UInt16 {
return Number(value)
} else if let value = value as? UInt32 {
return Number(value)
} else if let value = value as? UInt64 {
return Number(value)
} else if let value = value as? Number {
return value
} else if let value = value as? Float64 {
return Number(value)
} else if let value = value as? Float32 {
return Number(value)
}
return nil
}

View File

@@ -0,0 +1,103 @@
/// Container for registered tags and filters
open class Extension {
typealias TagParser = (TokenParser, Token) throws -> NodeType
var tags = [String: TagParser]()
var filters = [String: Filter]()
/// Simple initializer
public init() {
}
/// Registers a new template tag
public func registerTag(_ name: String, parser: @escaping (TokenParser, Token) throws -> NodeType) {
tags[name] = parser
}
/// Registers a simple template tag with a name and a handler
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
registerTag(name) { _, token in
SimpleNode(token: token, handler: handler)
}
}
/// Registers boolean filter with it's negative counterpart
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
// swiftlint:disable:previous discouraged_optional_boolean
filters[name] = .simple(filter)
filters[negativeFilterName] = .simple { value in
guard let result = try filter(value) else { return nil }
return !result
}
}
/// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
filters[name] = .simple(filter)
}
/// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
filters[name] = .arguments { value, args, _ in try filter(value, args) }
}
/// Registers a template filter with the given name
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?], Context) throws -> Any?) {
filters[name] = .arguments(filter)
}
}
class DefaultExtension: Extension {
override init() {
super.init()
registerDefaultTags()
registerDefaultFilters()
}
fileprivate func registerDefaultTags() {
registerTag("for", parser: ForNode.parse)
registerTag("break", parser: LoopTerminationNode.parse)
registerTag("continue", parser: LoopTerminationNode.parse)
registerTag("if", parser: IfNode.parse)
registerTag("ifnot", parser: IfNode.parse_ifnot)
#if !os(Linux)
registerTag("now", parser: NowNode.parse)
#endif
registerTag("include", parser: IncludeNode.parse)
registerTag("extends", parser: ExtendsNode.parse)
registerTag("block", parser: BlockNode.parse)
registerTag("filter", parser: FilterNode.parse)
}
fileprivate func registerDefaultFilters() {
registerFilter("default", filter: defaultFilter)
registerFilter("capitalize", filter: capitalise)
registerFilter("uppercase", filter: uppercase)
registerFilter("lowercase", filter: lowercase)
registerFilter("join", filter: joinFilter)
registerFilter("split", filter: splitFilter)
registerFilter("indent", filter: indentFilter)
registerFilter("filter", filter: filterFilter)
}
}
protocol FilterType {
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any?
}
enum Filter: FilterType {
case simple(((Any?) throws -> Any?))
case arguments(((Any?, [Any?], Context) throws -> Any?))
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
switch self {
case let .simple(filter):
if !arguments.isEmpty {
throw TemplateSyntaxError("Can't invoke filter with an argument")
}
return try filter(value)
case let .arguments(filter):
return try filter(value, arguments, context)
}
}
}

View File

@@ -0,0 +1,36 @@
class FilterNode: NodeType {
let resolvable: Resolvable
let nodes: [NodeType]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 else {
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
}
let blocks = try parser.parse(until(["endfilter"]))
guard parser.nextToken() != nil else {
throw TemplateSyntaxError("`endfilter` was not found.")
}
let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
}
init(nodes: [NodeType], resolvable: Resolvable, token: Token) {
self.nodes = nodes
self.resolvable = resolvable
self.token = token
}
func render(_ context: Context) throws -> String {
let value = try renderNodes(nodes, context)
return try context.push(dictionary: ["filter_value": value]) {
try VariableNode(variable: resolvable, token: token).render(context)
}
}
}

View File

@@ -0,0 +1,133 @@
func capitalise(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).capitalized }
} else {
return stringify(value).capitalized
}
}
func uppercase(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).uppercased() }
} else {
return stringify(value).uppercased()
}
}
func lowercase(_ value: Any?) -> Any? {
if let array = value as? [Any?] {
return array.map { stringify($0).lowercased() }
} else {
return stringify(value).lowercased()
}
}
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
// value can be optional wrapping nil, so this way we check for underlying value
if let value = value, String(describing: value) != "nil" {
return value
}
for argument in arguments {
if let argument = argument {
return argument
}
}
return nil
}
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count < 2 else {
throw TemplateSyntaxError("'join' filter takes at most one argument")
}
let separator = stringify(arguments.first ?? "")
if let value = value as? [Any] {
return value
.map(stringify)
.joined(separator: separator)
}
return value
}
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count < 2 else {
throw TemplateSyntaxError("'split' filter takes at most one argument")
}
let separator = stringify(arguments.first ?? " ")
if let value = value as? String {
return value.components(separatedBy: separator)
}
return value
}
func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
guard arguments.count <= 3 else {
throw TemplateSyntaxError("'indent' filter can take at most 3 arguments")
}
var indentWidth = 4
if !arguments.isEmpty {
guard let value = arguments[0] as? Int else {
throw TemplateSyntaxError(
"""
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
"""
)
}
indentWidth = value
}
var indentationChar = " "
if arguments.count > 1 {
guard let value = arguments[1] as? String else {
throw TemplateSyntaxError(
"""
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
"""
)
}
indentationChar = value
}
var indentFirst = false
if arguments.count > 2 {
guard let value = arguments[2] as? Bool else {
throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool")
}
indentFirst = value
}
let indentation = [String](repeating: indentationChar, count: indentWidth).joined()
return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
}
func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
guard !indentation.isEmpty else { return content }
var lines = content.components(separatedBy: .newlines)
let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
let result = lines.reduce(into: [firstLine]) { result, line in
result.append(line.isEmpty ? "" : "\(indentation)\(line)")
}
return result.joined(separator: "\n")
}
func filterFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
guard let value = value else { return nil }
guard arguments.count == 1 else {
throw TemplateSyntaxError("'filter' filter takes one argument")
}
let attribute = stringify(arguments[0])
let expr = try context.environment.compileFilter("$0|\(attribute)")
return try context.push(dictionary: ["$0": value]) {
try expr.resolve(context)
}
}

View File

@@ -0,0 +1,274 @@
import Foundation
class ForNode: NodeType {
let resolvable: Resolvable
let loopVariables: [String]
let nodes: [NodeType]
let emptyNodes: [NodeType]
let `where`: Expression?
let label: String?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components
var label: String?
if components.first?.hasSuffix(":") == true {
label = String(components.removeFirst().dropLast())
}
func hasToken(_ token: String, at index: Int) -> Bool {
components.count > (index + 1) && components[index] == token
}
func endsOrHasToken(_ token: String, at index: Int) -> Bool {
components.count == index || hasToken(token, at: index)
}
guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
throw TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.")
}
let loopVariables = components[1]
.split(separator: ",")
.map(String.init)
.map { $0.trim(character: " ") }
let resolvable = try parser.compileResolvable(components[3], containedIn: token)
let `where` = hasToken("where", at: 4)
? try parser.compileExpression(components: Array(components.suffix(from: 5)), token: token)
: nil
let forNodes = try parser.parse(until(["endfor", "empty"]))
guard let token = parser.nextToken() else {
throw TemplateSyntaxError("`endfor` was not found.")
}
var emptyNodes = [NodeType]()
if token.contents == "empty" {
emptyNodes = try parser.parse(until(["endfor"]))
_ = parser.nextToken()
}
return ForNode(
resolvable: resolvable,
loopVariables: loopVariables,
nodes: forNodes,
emptyNodes: emptyNodes,
where: `where`,
label: label,
token: token
)
}
init(
resolvable: Resolvable,
loopVariables: [String],
nodes: [NodeType],
emptyNodes: [NodeType],
where: Expression? = nil,
label: String? = nil,
token: Token? = nil
) {
self.resolvable = resolvable
self.loopVariables = loopVariables
self.nodes = nodes
self.emptyNodes = emptyNodes
self.where = `where`
self.label = label
self.token = token
}
func render(_ context: Context) throws -> String {
var values = try resolve(context)
if let `where` = self.where {
values = try values.filter { item -> Bool in
try push(value: item, context: context) {
try `where`.evaluate(context: context)
}
}
}
if !values.isEmpty {
let count = values.count
var result = ""
// collect parent loop contexts
let parentLoopContexts = (context["forloop"] as? [String: Any])?
.filter { ($1 as? [String: Any])?["label"] != nil } ?? [:]
for (index, item) in zip(0..., values) {
var forContext: [String: Any] = [
"first": index == 0,
"last": index == (count - 1),
"counter": index + 1,
"counter0": index,
"length": count
]
if let label = label {
forContext["label"] = label
forContext[label] = forContext
}
forContext.merge(parentLoopContexts) { lhs, _ in lhs }
var shouldBreak = false
result += try context.push(dictionary: ["forloop": forContext]) {
defer {
// if outer loop should be continued we should break from current loop
if let shouldContinueLabel = context[LoopTerminationNode.continueContextKey] as? String {
shouldBreak = shouldContinueLabel != label || label == nil
} else {
shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
}
}
return try push(value: item, context: context) {
try renderNodes(nodes, context)
}
}
if shouldBreak {
break
}
}
return result
} else {
return try context.push {
try renderNodes(emptyNodes, context)
}
}
}
private func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
if loopVariables.isEmpty {
return try context.push {
try closure()
}
}
let valueMirror = Mirror(reflecting: value)
if case .tuple? = valueMirror.displayStyle {
if loopVariables.count > Int(valueMirror.children.count) {
throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables")
}
var variablesContext = [String: Any]()
valueMirror.children.prefix(loopVariables.count).enumerated().forEach { offset, element in
if loopVariables[offset] != "_" {
variablesContext[loopVariables[offset]] = element.value
}
}
return try context.push(dictionary: variablesContext) {
try closure()
}
}
return try context.push(dictionary: [loopVariables.first ?? "": value]) {
try closure()
}
}
private func resolve(_ context: Context) throws -> [Any] {
let resolved = try resolvable.resolve(context)
var values: [Any]
if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
values = dictionary.sorted { $0.key < $1.key }
} else if let array = resolved as? [Any] {
values = array
} else if let range = resolved as? CountableClosedRange<Int> {
values = Array(range)
} else if let range = resolved as? CountableRange<Int> {
values = Array(range)
} else if let resolved = resolved {
let mirror = Mirror(reflecting: resolved)
switch mirror.displayStyle {
case .struct, .tuple:
values = Array(mirror.children)
case .class:
var children = Array(mirror.children)
var currentMirror: Mirror? = mirror
while let superclassMirror = currentMirror?.superclassMirror {
children.append(contentsOf: superclassMirror.children)
currentMirror = superclassMirror
}
values = Array(children)
default:
values = []
}
} else {
values = []
}
return values
}
}
struct LoopTerminationNode: NodeType {
static let breakContextKey = "_internal_forloop_break"
static let continueContextKey = "_internal_forloop_continue"
let name: String
let label: String?
let token: Token?
var contextKey: String {
"_internal_forloop_\(name)"
}
private init(name: String, label: String? = nil, token: Token? = nil) {
self.name = name
self.label = label
self.token = token
}
static func parse(_ parser: TokenParser, token: Token) throws -> Self {
let components = token.components
guard components.count <= 2 else {
throw TemplateSyntaxError("'\(token.contents)' can accept only one parameter")
}
guard parser.hasOpenedForTag() else {
throw TemplateSyntaxError("'\(token.contents)' can be used only inside loop body")
}
return Self(name: components[0], label: components.count == 2 ? components[1] : nil, token: token)
}
func render(_ context: Context) throws -> String {
let offset = zip(0..., context.dictionaries).reversed().first { _, dictionary in
guard let forContext = dictionary["forloop"] as? [String: Any],
dictionary["forloop"] != nil else { return false }
if let label = label {
return label == forContext["label"] as? String
} else {
return true
}
}?.0
if let offset = offset {
context.dictionaries[offset][contextKey] = label ?? true
} else if let label = label {
throw TemplateSyntaxError("No loop labeled '\(label)' is currently running")
} else {
throw TemplateSyntaxError("No loop is currently running")
}
return ""
}
}
private extension TokenParser {
func hasOpenedForTag() -> Bool {
var openForCount = 0
for parsedToken in parsedTokens.reversed() where parsedToken.kind == .block {
if parsedToken.components.first == "endfor" { openForCount -= 1 }
if parsedToken.components.first == "for" { openForCount += 1 }
}
return openForCount > 0
}
}

314
Sources/Stencil/IfTag.swift Normal file
View File

@@ -0,0 +1,314 @@
enum Operator {
case infix(String, Int, InfixOperator.Type)
case prefix(String, Int, PrefixOperator.Type)
var name: String {
switch self {
case .infix(let name, _, _):
return name
case .prefix(let name, _, _):
return name
}
}
static let all: [Self] = [
.infix("in", 5, InExpression.self),
.infix("or", 6, OrExpression.self),
.infix("and", 7, AndExpression.self),
.prefix("not", 8, NotExpression.self),
.infix("==", 10, EqualityExpression.self),
.infix("!=", 10, InequalityExpression.self),
.infix(">", 10, MoreThanExpression.self),
.infix(">=", 10, MoreThanEqualExpression.self),
.infix("<", 10, LessThanExpression.self),
.infix("<=", 10, LessThanEqualExpression.self)
]
}
func findOperator(name: String) -> Operator? {
for `operator` in Operator.all where `operator`.name == name {
return `operator`
}
return nil
}
indirect enum IfToken {
case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
case variable(Resolvable)
case subExpression(Expression)
case end
var bindingPower: Int {
switch self {
case .infix(_, let bindingPower, _):
return bindingPower
case .prefix(_, let bindingPower, _):
return bindingPower
case .variable:
return 0
case .subExpression:
return 0
case .end:
return 0
}
}
func nullDenotation(parser: IfExpressionParser) throws -> Expression {
switch self {
case .infix(let name, _, _):
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
case .prefix(_, let bindingPower, let operatorType):
let expression = try parser.expression(bindingPower: bindingPower)
return operatorType.init(expression: expression)
case .variable(let variable):
return VariableExpression(variable: variable)
case .subExpression(let expression):
return expression
case .end:
throw TemplateSyntaxError("'if' expression error: end")
}
}
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
switch self {
case .infix(_, let bindingPower, let operatorType):
let right = try parser.expression(bindingPower: bindingPower)
return operatorType.init(lhs: left, rhs: right)
case .prefix(let name, _, _):
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
case .variable(let variable):
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
case .subExpression:
throw TemplateSyntaxError("'if' expression error: sub expression was called with a left hand side")
case .end:
throw TemplateSyntaxError("'if' expression error: end")
}
}
var isEnd: Bool {
switch self {
case .end:
return true
default:
return false
}
}
}
final class IfExpressionParser {
let tokens: [IfToken]
var position: Int = 0
private init(tokens: [IfToken]) {
self.tokens = tokens
}
static func parser(components: [String], environment: Environment, token: Token) throws -> IfExpressionParser {
try IfExpressionParser(components: ArraySlice(components), environment: environment, token: token)
}
private init(components: ArraySlice<String>, environment: Environment, token: Token) throws {
var parsedComponents = Set<Int>()
var bracketsBalance = 0
// swiftlint:disable:next closure_body_length
self.tokens = try zip(components.indices, components).compactMap { index, component in
guard !parsedComponents.contains(index) else { return nil }
if component == "(" {
bracketsBalance += 1
let (expression, parsedCount) = try Self.subExpression(
from: components.suffix(from: index + 1),
environment: environment,
token: token
)
parsedComponents.formUnion(Set(index...(index + parsedCount)))
return .subExpression(expression)
} else if component == ")" {
bracketsBalance -= 1
if bracketsBalance < 0 {
throw TemplateSyntaxError("'if' expression error: missing opening bracket")
}
parsedComponents.insert(index)
return nil
} else {
parsedComponents.insert(index)
if let `operator` = findOperator(name: component) {
switch `operator` {
case .infix(let name, let bindingPower, let operatorType):
return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
case .prefix(let name, let bindingPower, let operatorType):
return .prefix(name: name, bindingPower: bindingPower, operatorType: operatorType)
}
}
return .variable(try environment.compileResolvable(component, containedIn: token))
}
}
}
private static func subExpression(
from components: ArraySlice<String>,
environment: Environment,
token: Token
) throws -> (Expression, Int) {
var bracketsBalance = 1
let subComponents = components.prefix { component in
if component == "(" {
bracketsBalance += 1
} else if component == ")" {
bracketsBalance -= 1
}
return bracketsBalance != 0
}
if bracketsBalance > 0 {
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
}
let expressionParser = try IfExpressionParser(components: subComponents, environment: environment, token: token)
let expression = try expressionParser.parse()
return (expression, subComponents.count)
}
var currentToken: IfToken {
if tokens.count > position {
return tokens[position]
}
return .end
}
var nextToken: IfToken {
position += 1
return currentToken
}
func parse() throws -> Expression {
let expression = try self.expression()
if !currentToken.isEnd {
throw TemplateSyntaxError("'if' expression error: dangling token")
}
return expression
}
func expression(bindingPower: Int = 0) throws -> Expression {
var token = currentToken
position += 1
var left = try token.nullDenotation(parser: self)
while bindingPower < currentToken.bindingPower {
token = currentToken
position += 1
left = try token.leftDenotation(left: left, parser: self)
}
return left
}
}
/// Represents an if condition and the associated nodes when the condition
/// evaluates
final class IfCondition {
let expression: Expression?
let nodes: [NodeType]
init(expression: Expression?, nodes: [NodeType]) {
self.expression = expression
self.nodes = nodes
}
func render(_ context: Context) throws -> String {
try context.push {
try renderNodes(nodes, context)
}
}
}
class IfNode: NodeType {
let conditions: [IfCondition]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components
components.removeFirst()
let expression = try parser.compileExpression(components: components, token: token)
let nodes = try parser.parse(until(["endif", "elif", "else"]))
var conditions: [IfCondition] = [
IfCondition(expression: expression, nodes: nodes)
]
var nextToken = parser.nextToken()
while let current = nextToken, current.contents.hasPrefix("elif") {
var components = current.components
components.removeFirst()
let expression = try parser.compileExpression(components: components, token: current)
let nodes = try parser.parse(until(["endif", "elif", "else"]))
nextToken = parser.nextToken()
conditions.append(IfCondition(expression: expression, nodes: nodes))
}
if let current = nextToken, current.contents == "else" {
conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"]))))
nextToken = parser.nextToken()
}
guard let current = nextToken, current.contents == "endif" else {
throw TemplateSyntaxError("`endif` was not found.")
}
return IfNode(conditions: conditions, token: token)
}
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
var components = token.components
guard components.count == 2 else {
throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.")
}
components.removeFirst()
var trueNodes = [NodeType]()
var falseNodes = [NodeType]()
let expression = try parser.compileExpression(components: components, token: token)
falseNodes = try parser.parse(until(["endif", "else"]))
guard let token = parser.nextToken() else {
throw TemplateSyntaxError("`endif` was not found.")
}
if token.contents == "else" {
trueNodes = try parser.parse(until(["endif"]))
_ = parser.nextToken()
}
return IfNode(conditions: [
IfCondition(expression: expression, nodes: trueNodes),
IfCondition(expression: nil, nodes: falseNodes)
], token: token)
}
init(conditions: [IfCondition], token: Token? = nil) {
self.conditions = conditions
self.token = token
}
func render(_ context: Context) throws -> String {
for condition in conditions {
if let expression = condition.expression {
let truthy = try expression.evaluate(context: context)
if truthy {
return try condition.render(context)
}
} else {
return try condition.render(context)
}
}
return ""
}
}

View File

@@ -0,0 +1,48 @@
class IncludeNode: NodeType {
let templateName: Variable
let includeContext: String?
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 || bits.count == 3 else {
throw TemplateSyntaxError(
"""
'include' tag requires one argument, the template file to be included. \
A second optional argument can be used to specify the context that will \
be passed to the included file
"""
)
}
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
}
init(templateName: Variable, includeContext: String? = nil, token: Token) {
self.templateName = templateName
self.includeContext = includeContext
self.token = token
}
func render(_ context: Context) throws -> String {
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
let template = try context.environment.loadTemplate(name: templateName)
do {
let subContext = includeContext.flatMap { context[$0] as? [String: Any] } ?? [:]
return try context.push(dictionary: subContext) {
try template.render(context)
}
} catch {
if let error = error as? TemplateSyntaxError {
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
} else {
throw error
}
}
}
}

View File

@@ -0,0 +1,158 @@
class BlockContext {
class var contextKey: String { "block_context" }
// contains mapping of block names to their nodes and templates where they are defined
var blocks: [String: [BlockNode]]
init(blocks: [String: BlockNode]) {
self.blocks = [:]
blocks.forEach { self.blocks[$0.key] = [$0.value] }
}
func push(_ block: BlockNode, forKey blockName: String) {
if var blocks = blocks[blockName] {
blocks.append(block)
self.blocks[blockName] = blocks
} else {
self.blocks[blockName] = [block]
}
}
func pop(_ blockName: String) -> BlockNode? {
if var blocks = blocks[blockName] {
let block = blocks.removeFirst()
if blocks.isEmpty {
self.blocks.removeValue(forKey: blockName)
} else {
self.blocks[blockName] = blocks
}
return block
} else {
return nil
}
}
}
extension Collection {
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
for element in self where closure(element) {
return element
}
return nil
}
}
class ExtendsNode: NodeType {
let templateName: Variable
let blocks: [String: BlockNode]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 else {
throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
}
let parsedNodes = try parser.parse()
guard (parsedNodes.any { $0 is Self }) == nil else {
throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
}
let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
let nodes = blockNodes.reduce(into: [String: BlockNode]()) { accumulator, node in
accumulator[node.name] = node
}
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
}
init(templateName: Variable, blocks: [String: BlockNode], token: Token) {
self.templateName = templateName
self.blocks = blocks
self.token = token
}
func render(_ context: Context) throws -> String {
guard let templateName = try self.templateName.resolve(context) as? String else {
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
}
let baseTemplate = try context.environment.loadTemplate(name: templateName)
let blockContext: BlockContext
if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext {
blockContext = currentBlockContext
for (name, block) in blocks {
blockContext.push(block, forKey: name)
}
} else {
blockContext = BlockContext(blocks: blocks)
}
do {
// pushes base template and renders it's content
// block_context contains all blocks from child templates
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
try baseTemplate.render(context)
}
} catch {
// if error template is already set (see catch in BlockNode)
// and it happend in the same template as current template
// there is no need to wrap it in another error
if let error = error as? TemplateSyntaxError, error.templateName != token?.sourceMap.filename {
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
} else {
throw error
}
}
}
}
class BlockNode: NodeType {
let name: String
let nodes: [NodeType]
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let bits = token.components
guard bits.count == 2 else {
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
}
let blockName = bits[1]
let nodes = try parser.parse(until(["endblock"]))
_ = parser.nextToken()
return BlockNode(name: blockName, nodes: nodes, token: token)
}
init(name: String, nodes: [NodeType], token: Token) {
self.name = name
self.nodes = nodes
self.token = token
}
func render(_ context: Context) throws -> String {
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
let childContext: [String: Any] = [
BlockContext.contextKey: blockContext,
"block": ["super": try self.render(context)]
]
// render extension node
do {
return try context.push(dictionary: childContext) {
try child.render(context)
}
} catch {
throw error.withToken(child.token)
}
}
let result = try renderNodes(nodes, context)
context.cacheBlock(name, content: result)
return result
}
}

View File

@@ -0,0 +1,112 @@
import Foundation
/// A structure used to represent a template variable, and to resolve it in a given context.
final class KeyPath {
private var components = [String]()
private var current = ""
private var partialComponents = [String]()
private var subscriptLevel = 0
let variable: String
let context: Context
// Split the keypath string and resolve references if possible
init(_ variable: String, in context: Context) {
self.variable = variable
self.context = context
}
func parse() throws -> [String] {
defer {
components = []
current = ""
partialComponents = []
subscriptLevel = 0
}
for character in variable {
switch character {
case "." where subscriptLevel == 0:
try foundSeparator()
case "[":
try openBracket()
case "]":
try closeBracket()
default:
try addCharacter(character)
}
}
try finish()
return components
}
private func foundSeparator() throws {
if !current.isEmpty {
partialComponents.append(current)
}
guard !partialComponents.isEmpty else {
throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'")
}
components += partialComponents
current = ""
partialComponents = []
}
// when opening the first bracket, we must have a partial component
private func openBracket() throws {
guard !partialComponents.isEmpty || !current.isEmpty else {
throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'")
}
if subscriptLevel > 0 {
current.append("[")
} else if !current.isEmpty {
partialComponents.append(current)
current = ""
}
subscriptLevel += 1
}
// for a closing bracket at root level, try to resolve the reference
private func closeBracket() throws {
guard subscriptLevel > 0 else {
throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'")
}
if subscriptLevel > 1 {
current.append("]")
} else if !current.isEmpty,
let value = try Variable(current).resolve(context) {
partialComponents.append("\(value)")
current = ""
} else {
throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'")
}
subscriptLevel -= 1
}
private func addCharacter(_ character: Character) throws {
guard partialComponents.isEmpty || subscriptLevel > 0 else {
throw TemplateSyntaxError("Unexpected character '\(character)' in variable '\(variable)'")
}
current.append(character)
}
private func finish() throws {
// check if we have a last piece
if !current.isEmpty {
partialComponents.append(current)
}
components += partialComponents
guard subscriptLevel == 0 else {
throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'")
}
}
}

View File

@@ -0,0 +1,57 @@
/// Used to lazily set context data. Useful for example if you have some data that requires heavy calculations, and may
/// not be used in every render possiblity.
public final class LazyValueWrapper {
private let closure: (Context) throws -> Any
private let context: Context?
private var cachedValue: Any?
/// Create a wrapper that'll use a **reference** to the current context.
/// This means when the closure is evaluated, it'll use the **active** context at that moment.
///
/// - Parameters:
/// - closure: The closure to lazily evaluate
public init(closure: @escaping (Context) throws -> Any) {
self.context = nil
self.closure = closure
}
/// Create a wrapper that'll create a **copy** of the current context.
/// This means when the closure is evaluated, it'll use the context **as it was** when this wrapper was created.
///
/// - Parameters:
/// - context: The context to use during evaluation
/// - closure: The closure to lazily evaluate
/// - Note: This will use more memory than the other `init` as it needs to keep a copy of the full context around.
public init(copying context: Context, closure: @escaping (Context) throws -> Any) {
self.context = Context(dictionaries: context.dictionaries, environment: context.environment)
self.closure = closure
}
/// Shortcut for creating a lazy wrapper when you don't need access to the Stencil context.
///
/// - Parameters:
/// - closure: The closure to lazily evaluate
public init(_ closure: @autoclosure @escaping () throws -> Any) {
self.context = nil
self.closure = { _ in try closure() }
}
}
extension LazyValueWrapper {
func value(context: Context) throws -> Any {
if let value = cachedValue {
return value
} else {
let value = try closure(self.context ?? context)
cachedValue = value
return value
}
}
}
extension LazyValueWrapper: Resolvable {
public func resolve(_ context: Context) throws -> Any? {
let value = try self.value(context: context)
return try (value as? Resolvable)?.resolve(context) ?? value
}
}

265
Sources/Stencil/Lexer.swift Normal file
View File

@@ -0,0 +1,265 @@
import Foundation
// swiftlint:disable large_tuple
typealias Line = (content: String, number: UInt, range: Range<String.Index>)
/// Location in some content (text)
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)
// swiftlint:enable large_tuple
struct Lexer {
let templateName: String?
let templateString: String
let lines: [Line]
/// The potential token start characters. In a template these appear after a
/// `{` character, for example `{{`, `{%`, `{#`, ...
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
/// The minimum length of a tag
private static let tagLength = 2
/// The token end characters, corresponding to their token start characters.
/// For example, a variable token starts with `{{` and ends with `}}`
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
"{": "}",
"%": "%",
"#": "#"
]
/// Characters controlling whitespace trimming behaviour
private static let behaviourMap: [Character: WhitespaceBehaviour.Behaviour] = [
"+": .keep,
"-": .trim
]
init(templateName: String? = nil, templateString: String) {
self.templateName = templateName
self.templateString = templateString
self.lines = zip(1..., templateString.components(separatedBy: .newlines)).compactMap { index, line in
guard !line.isEmpty,
let range = templateString.range(of: line) else { return nil }
return (content: line, number: UInt(index), range)
}
}
private func behaviour(string: String, tagLength: Int) -> WhitespaceBehaviour {
let leftIndex = string.index(string.startIndex, offsetBy: tagLength, limitedBy: string.endIndex)
let rightIndex = string.index(string.endIndex, offsetBy: -(tagLength + 1), limitedBy: string.startIndex)
return WhitespaceBehaviour(
leading: Self.behaviourMap[leftIndex.map { string[$0] } ?? " "] ?? .unspecified,
trailing: Self.behaviourMap[rightIndex.map { string[$0] } ?? " "] ?? .unspecified
)
}
/// Create a token that will be passed on to the parser, with the given
/// content and a range. The content will be tested to see if it's a
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
/// `text` token.
///
/// - Parameters:
/// - string: The content string of the token
/// - range: The range within the template content, used for smart
/// error reporting
func createToken(string: String, at range: Range<String.Index>, _ isInEscapeMode: Bool = false) -> Token {
func strip(length: (Int, Int) = (Self.tagLength, Self.tagLength)) -> String {
guard string.count > (length.0 + length.1) else { return "" }
let trimmed = String(string.dropFirst(length.0).dropLast(length.1))
.components(separatedBy: "\n")
.filter { !$0.isEmpty }
.map { $0.trim(character: " ") }
.joined(separator: " ")
return trimmed
}
if !isInEscapeMode && (string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#")) {
let behaviour = string.hasPrefix("{%") ? behaviour(string: string, tagLength: Self.tagLength) : .unspecified
let stripLengths = (
Self.tagLength + (behaviour.leading != .unspecified ? 1 : 0),
Self.tagLength + (behaviour.trailing != .unspecified ? 1 : 0)
)
let value = strip(length: stripLengths)
let range = templateString.range(of: value, range: range) ?? range
let location = rangeLocation(range)
let sourceMap = SourceMap(filename: templateName, location: location)
if string.hasPrefix("{{") {
return .variable(value: value, at: sourceMap)
} else if string.hasPrefix("{%") {
return .block(value: strip(length: stripLengths), at: sourceMap, whitespace: behaviour)
} else if string.hasPrefix("{#") {
return .comment(value: value, at: sourceMap)
}
}
let location = rangeLocation(range)
let sourceMap = SourceMap(filename: templateName, location: location)
return .text(value: string, at: sourceMap)
}
/// Transforms the template into a list of tokens, that will eventually be
/// passed on to the parser.
///
/// - Returns: The list of tokens (see `createToken(string: at:)`).
func tokenize() -> [Token] {
var tokens: [Token] = []
let scanner = Scanner(templateString)
while !scanner.isEmpty {
if let (char, text, isInEscapeMode) = scanner.scanForTokenStart(Self.tokenChars) {
if !text.isEmpty {
tokens.append(createToken(string: text, at: scanner.range))
}
guard let end = Self.tokenCharMap[char] else { continue }
let result = scanner.scanForTokenEnd(end)
tokens.append(createToken(string: result, at: scanner.range, isInEscapeMode))
} else {
tokens.append(createToken(string: scanner.content, at: scanner.range))
scanner.content = ""
}
}
return tokens
}
/// Finds the line matching the given range (for a token)
///
/// - Parameter range: The range to search for.
/// - Returns: The content for that line, the line number and offset within
/// the line.
func rangeLocation(_ range: Range<String.Index>) -> ContentLocation {
guard let line = self.lines.first(where: { $0.range.contains(range.lowerBound) }) else {
return ("", 0, 0)
}
let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound)
return (line.content, line.number, offset)
}
}
class Scanner {
let originalContent: String
var content: String
var range: Range<String.UnicodeScalarView.Index>
/// The start delimiter for a token.
private static let tokenStartDelimiter: Unicode.Scalar = "{"
/// And the corresponding end delimiter for a token.
private static let tokenEndDelimiter: Unicode.Scalar = "}"
private static let tokenDelimiterEscape: Unicode.Scalar = "\\"
init(_ content: String) {
self.originalContent = content
self.content = content
range = content.unicodeScalars.startIndex..<content.unicodeScalars.startIndex
}
var isEmpty: Bool {
content.isEmpty
}
/// Scans for the end of a token, with a specific ending character. If we're
/// searching for the end of a block token `%}`, this method receives a `%`.
/// The scanner will search for that `%` followed by a `}`.
///
/// Note: if the end of a token is found, the `content` and `range`
/// properties are updated to reflect this. `content` will be set to what
/// remains of the template after the token. `range` will be set to the range
/// of the token within the template.
///
/// - Parameter tokenChar: The token end character to search for.
/// - Returns: The content of a token, or "" if no token end was found.
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
var foundChar = false
for (index, char) in zip(0..., content.unicodeScalars) {
if foundChar && char == Self.tokenEndDelimiter {
let result = String(content.unicodeScalars.prefix(index + 1))
content = String(content.unicodeScalars.dropFirst(index + 1))
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index + 1)
return result
} else {
foundChar = (char == tokenChar)
}
}
content = ""
return ""
}
/// Scans for the start of a token, with a list of potential starting
/// characters. To scan for the start of variables (`{{`), blocks (`{%`) and
/// comments (`{#`), this method receives the characters `{`, `%` and `#`.
/// The scanner will search for a `{`, followed by one of the search
/// characters. It will give the found character, and the content that came
/// before the token.
///
/// Note: if the start of a token is found, the `content` and `range`
/// properties are updated to reflect this. `content` will be set to what
/// remains of the template starting with the token. `range` will be set to
/// the start of the token within the template.
///
/// - Parameter tokenChars: List of token start characters to search for.
/// - Returns: The found token start character, together with the content
/// before the token, or nil of no token start was found.
// swiftlint:disable:next large_tuple
func scanForTokenStart(_ tokenChars: [Unicode.Scalar]) -> (Unicode.Scalar, String, Bool)? {
var foundBrace = false
var isInEscapeMode = false
var lastChar: Unicode.Scalar = " "
range = range.upperBound..<range.upperBound
for (index, char) in zip(0..., content.unicodeScalars) {
if foundBrace && tokenChars.contains(char) {
let prefixOffset = isInEscapeMode ? 1 : 0
let prefix = String(content.unicodeScalars.prefix(index - 1 - prefixOffset))
content = String(content.unicodeScalars.dropFirst(index - 1))
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index - 1)
return (char, prefix, isInEscapeMode)
} else {
foundBrace = (char == Self.tokenStartDelimiter)
isInEscapeMode = (lastChar == Self.tokenDelimiterEscape)
lastChar = char
}
}
return nil
}
}
extension String {
func findFirstNot(character: Character) -> String.Index? {
var index = startIndex
while index != endIndex {
if character != self[index] {
return index
}
index = self.index(after: index)
}
return nil
}
func findLastNot(character: Character) -> String.Index? {
var index = self.index(before: endIndex)
while index != startIndex {
if character != self[index] {
return self.index(after: index)
}
index = self.index(before: index)
}
return nil
}
func trim(character: Character) -> String {
let first = findFirstNot(character: character) ?? startIndex
let last = findLastNot(character: character) ?? endIndex
return String(self[first..<last])
}
}

View File

@@ -0,0 +1,128 @@
import Foundation
import PathKit
/// Type used for loading a template
public protocol Loader {
/// Load a template with the given name
func loadTemplate(name: String, environment: Environment) throws -> Template
/// Load a template with the given list of names
func loadTemplate(names: [String], environment: Environment) throws -> Template
}
extension Loader {
/// Default implementation, tries to load the first template that exists from the list of given names
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for name in names {
do {
return try loadTemplate(name: name, environment: environment)
} catch is TemplateDoesNotExist {
continue
} catch {
throw error
}
}
throw TemplateDoesNotExist(templateNames: names, loader: self)
}
}
// A class for loading a template from disk
public class FileSystemLoader: Loader, CustomStringConvertible {
public let paths: [Path]
public init(paths: [Path]) {
self.paths = paths
}
public init(bundle: [Bundle]) {
self.paths = bundle.compactMap { bundle in
Path(bundle.path)
}
}
public var description: String {
"FileSystemLoader(\(paths))"
}
public func loadTemplate(name: String, environment: Environment) throws -> Template {
for path in paths {
let templatePath = try path.safeJoin(path: name)
if !templatePath.exists {
continue
}
let content: String = try String(contentsOf: templatePath)
return environment.templateClass.init(templateString: content, environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for path in paths {
for templateName in names {
let templatePath = try path.safeJoin(path: templateName)
if templatePath.exists {
let content: String = try String(contentsOf: templatePath)
return environment.templateClass.init(templateString: content, environment: environment, name: templateName)
}
}
}
throw TemplateDoesNotExist(templateNames: names, loader: self)
}
}
public class DictionaryLoader: Loader {
public let templates: [String: String]
public init(templates: [String: String]) {
self.templates = templates
}
public func loadTemplate(name: String, environment: Environment) throws -> Template {
if let content = templates[name] {
return environment.templateClass.init(templateString: content, environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
for name in names {
if let content = templates[name] {
return environment.templateClass.init(templateString: content, environment: environment, name: name)
}
}
throw TemplateDoesNotExist(templateNames: names, loader: self)
}
}
extension Path {
func safeJoin(path: String) throws -> Path {
let newPath = self / path
if !newPath.string.hasPrefix(self.string) {
throw SuspiciousFileOperation(basePath: self, path: newPath)
}
return newPath
}
}
class SuspiciousFileOperation: Error {
let basePath: Path
let path: Path
init(basePath: Path, path: Path) {
self.basePath = basePath
self.path = path
}
var description: String {
"Path `\(path)` is located outside of base path `\(basePath)`"
}
}

185
Sources/Stencil/Node.swift Normal file
View File

@@ -0,0 +1,185 @@
import Foundation
/// Represents a parsed node
public protocol NodeType {
/// Render the node in the given context
func render(_ context: Context) throws -> String
/// Reference to this node's token
var token: Token? { get }
}
/// Render the collection of nodes in the given context
public func renderNodes(_ nodes: [NodeType], _ context: Context) throws -> String {
var result = ""
for node in nodes {
do {
result += try node.render(context)
} catch {
throw error.withToken(node.token)
}
let shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
let shouldContinue = context[LoopTerminationNode.continueContextKey] != nil
if shouldBreak || shouldContinue {
break
}
}
return result
}
/// Simple node, used for triggering a closure during rendering
public class SimpleNode: NodeType {
public let handler: (Context) throws -> String
public let token: Token?
public init(token: Token, handler: @escaping (Context) throws -> String) {
self.token = token
self.handler = handler
}
public func render(_ context: Context) throws -> String {
try handler(context)
}
}
/// Represents a block of text, renders the text
public class TextNode: NodeType {
public let text: String
public let token: Token?
public let trimBehaviour: TrimBehaviour
public init(text: String, trimBehaviour: TrimBehaviour = .nothing) {
self.text = text
self.token = nil
self.trimBehaviour = trimBehaviour
}
public func render(_ context: Context) throws -> String {
var string = self.text
if trimBehaviour.leading != .nothing, !string.isEmpty {
let range = NSRange(..<string.endIndex, in: string)
string = TrimBehaviour.leadingRegex(trim: trimBehaviour.leading)
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
}
if trimBehaviour.trailing != .nothing, !string.isEmpty {
let range = NSRange(..<string.endIndex, in: string)
string = TrimBehaviour.trailingRegex(trim: trimBehaviour.trailing)
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
}
return string
}
}
/// Representing something that can be resolved in a context
public protocol Resolvable {
/// Try to resolve this with the given context
func resolve(_ context: Context) throws -> Any?
}
/// Represents a variable, renders the variable, may have conditional expressions.
public class VariableNode: NodeType {
public let variable: Resolvable
public var token: Token?
let condition: Expression?
let elseExpression: Resolvable?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
let components = token.components
func hasToken(_ token: String, at index: Int) -> Bool {
components.count > (index + 1) && components[index] == token
}
func compileResolvable(_ components: [String], containedIn token: Token) throws -> Resolvable {
try (try? parser.compileExpression(components: components, token: token)) ??
parser.compileFilter(components.joined(separator: " "), containedIn: token)
}
let variable: Resolvable
let condition: Expression?
let elseExpression: Resolvable?
if hasToken("if", at: 1) {
variable = try compileResolvable([components[0]], containedIn: token)
let components = components.suffix(from: 2)
if let elseIndex = components.firstIndex(of: "else") {
condition = try parser.compileExpression(components: Array(components.prefix(upTo: elseIndex)), token: token)
let elseToken = Array(components.suffix(from: elseIndex.advanced(by: 1)))
elseExpression = try compileResolvable(elseToken, containedIn: token)
} else {
condition = try parser.compileExpression(components: Array(components), token: token)
elseExpression = nil
}
} else if !components.isEmpty {
variable = try compileResolvable(components, containedIn: token)
condition = nil
elseExpression = nil
} else {
throw TemplateSyntaxError(reason: "Missing variable name", token: token)
}
return VariableNode(variable: variable, token: token, condition: condition, elseExpression: elseExpression)
}
public init(variable: Resolvable, token: Token? = nil) {
self.variable = variable
self.token = token
self.condition = nil
self.elseExpression = nil
}
init(variable: Resolvable, token: Token? = nil, condition: Expression?, elseExpression: Resolvable?) {
self.variable = variable
self.token = token
self.condition = condition
self.elseExpression = elseExpression
}
public init(variable: String, token: Token? = nil) {
self.variable = Variable(variable)
self.token = token
self.condition = nil
self.elseExpression = nil
}
public func render(_ context: Context) throws -> String {
if let condition = self.condition, try condition.evaluate(context: context) == false {
return try elseExpression?.resolve(context).map(stringify) ?? ""
}
let result = try variable.resolve(context)
return stringify(result)
}
}
func stringify(_ result: Any?) -> String {
if let result = result as? String {
return result
} else if let array = result as? [Any?] {
return unwrap(array).description
} else if let result = result as? CustomStringConvertible {
return result.description
} else if let result = result as? NSObject {
return result.description
}
return ""
}
func unwrap(_ array: [Any?]) -> [Any] {
array.map { (item: Any?) -> Any in
if let item = item {
if let items = item as? [Any?] {
return unwrap(items)
} else {
return item
}
} else {
return item as Any
}
}
}

View File

@@ -0,0 +1,44 @@
#if !os(Linux)
import Foundation
class NowNode: NodeType {
let format: Variable
let token: Token?
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
var format: Variable?
let components = token.components
guard components.count <= 2 else {
throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
}
if components.count == 2 {
format = Variable(components[1])
}
return NowNode(format: format, token: token)
}
init(format: Variable?, token: Token? = nil) {
self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
self.token = token
}
func render(_ context: Context) throws -> String {
let date = Date()
let format = try self.format.resolve(context)
var formatter: DateFormatter
if let format = format as? DateFormatter {
formatter = format
} else if let format = format as? String {
formatter = DateFormatter()
formatter.dateFormat = format
} else {
return ""
}
return formatter.string(from: date)
}
}
#endif

View File

@@ -0,0 +1,272 @@
/// Creates a checker that will stop parsing if it encounters a list of tags.
/// Useful for example for scanning until a given "end"-node.
public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
{ _, token in
if let name = token.components.first {
for tag in tags where name == tag {
return true
}
}
return false
}
}
/// A class for parsing an array of tokens and converts them into a collection of Node's
public class TokenParser {
/// Parser for finding a kind of node
public typealias TagParser = (TokenParser, Token) throws -> NodeType
fileprivate var tokens: [Token]
fileprivate(set) var parsedTokens: [Token] = []
fileprivate let environment: Environment
fileprivate var previousWhiteSpace: WhitespaceBehaviour.Behaviour?
/// Simple initializer
public init(tokens: [Token], environment: Environment) {
self.tokens = tokens
self.environment = environment
}
/// Parse the given tokens into nodes
public func parse() throws -> [NodeType] {
try parse(nil)
}
/// Parse nodes until a specific "something" is detected, determined by the provided closure.
/// Combine this with the `until(:)` function above to scan nodes until a given token.
public func parse(_ parseUntil: ((_ parser: TokenParser, _ token: Token) -> (Bool))?) throws -> [NodeType] {
var nodes = [NodeType]()
while !tokens.isEmpty {
guard let token = nextToken() else { break }
switch token.kind {
case .text:
nodes.append(TextNode(text: token.contents, trimBehaviour: trimBehaviour))
case .variable:
previousWhiteSpace = nil
try nodes.append(VariableNode.parse(self, token: token))
case .block:
previousWhiteSpace = token.whitespace?.trailing
if let parseUntil = parseUntil, parseUntil(self, token) {
prependToken(token)
return nodes
}
if var tag = token.components.first {
do {
// special case for labeled tags (such as for loops)
if tag.hasSuffix(":") && token.components.count >= 2 {
tag = token.components[1]
}
let parser = try environment.findTag(name: tag)
let node = try parser(self, token)
nodes.append(node)
} catch {
throw error.withToken(token)
}
}
case .comment:
previousWhiteSpace = nil
continue
}
}
return nodes
}
/// Pop the next token (returning it)
public func nextToken() -> Token? {
if !tokens.isEmpty {
let nextToken = tokens.remove(at: 0)
parsedTokens.append(nextToken)
return nextToken
}
return nil
}
func peekWhitespace() -> WhitespaceBehaviour.Behaviour? {
tokens.first?.whitespace?.leading
}
/// Insert a token
public func prependToken(_ token: Token) {
tokens.insert(token, at: 0)
if parsedTokens.last == token {
parsedTokens.removeLast()
}
}
/// Create filter expression from a string contained in provided token
public func compileFilter(_ filterToken: String, containedIn token: Token) throws -> Resolvable {
try environment.compileFilter(filterToken, containedIn: token)
}
/// Create boolean expression from components contained in provided token
public func compileExpression(components: [String], token: Token) throws -> Expression {
try environment.compileExpression(components: components, containedIn: token)
}
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
try environment.compileResolvable(token, containedIn: containingToken)
}
private var trimBehaviour: TrimBehaviour {
var behaviour: TrimBehaviour = .nothing
if let leading = previousWhiteSpace {
if leading == .unspecified {
behaviour.leading = environment.trimBehaviour.trailing
} else {
behaviour.leading = leading == .trim ? .whitespaceAndNewLines : .nothing
}
}
if let trailing = peekWhitespace() {
if trailing == .unspecified {
behaviour.trailing = environment.trimBehaviour.leading
} else {
behaviour.trailing = trailing == .trim ? .whitespaceAndNewLines : .nothing
}
}
return behaviour
}
}
extension Environment {
func findTag(name: String) throws -> Extension.TagParser {
for ext in extensions {
if let filter = ext.tags[name] {
return filter
}
}
throw TemplateSyntaxError("Unknown template tag '\(name)'")
}
func findFilter(_ name: String) throws -> FilterType {
for ext in extensions {
if let filter = ext.filters[name] {
return filter
}
}
let suggestedFilters = self.suggestedFilters(for: name)
if suggestedFilters.isEmpty {
throw TemplateSyntaxError("Unknown filter '\(name)'.")
} else {
throw TemplateSyntaxError(
"""
Unknown filter '\(name)'. \
Found similar filters: \(suggestedFilters.map { "'\($0)'" }.joined(separator: ", ")).
"""
)
}
}
private func suggestedFilters(for name: String) -> [String] {
let allFilters = extensions.flatMap { $0.filters.keys }
let filtersWithDistance = allFilters
.map { (filterName: $0, distance: $0.levenshteinDistance(name)) }
// do not suggest filters which names are shorter than the distance
.filter { $0.filterName.count > $0.distance }
guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else {
return []
}
// suggest all filters with the same distance
return filtersWithDistance.filter { $0.distance == minDistance }.map { $0.filterName }
}
/// Create filter expression from a string
public func compileFilter(_ token: String) throws -> Resolvable {
try FilterExpression(token: token, environment: self)
}
/// Create filter expression from a string contained in provided token
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
do {
return try FilterExpression(token: filterToken, environment: self)
} catch {
guard var syntaxError = error as? TemplateSyntaxError, syntaxError.token == nil else {
throw error
}
// find offset of filter in the containing token so that only filter is highligted, not the whole token
if let filterTokenRange = containingToken.contents.range(of: filterToken) {
var location = containingToken.sourceMap.location
location.lineOffset += containingToken.contents.distance(
from: containingToken.contents.startIndex,
to: filterTokenRange.lowerBound
)
syntaxError.token = .variable(
value: filterToken,
at: SourceMap(filename: containingToken.sourceMap.filename, location: location)
)
} else {
syntaxError.token = containingToken
}
throw syntaxError
}
}
/// Create resolvable (i.e. range variable or filter expression) from a string
public func compileResolvable(_ token: String) throws -> Resolvable {
try RangeVariable(token, environment: self)
?? compileFilter(token)
}
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
try RangeVariable(token, environment: self, containedIn: containingToken)
?? compileFilter(token, containedIn: containingToken)
}
/// Create boolean expression from components contained in provided token
public func compileExpression(components: [String], containedIn token: Token) throws -> Expression {
try IfExpressionParser.parser(components: components, environment: self, token: token).parse()
}
}
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
extension String {
subscript(_ index: Int) -> Character {
self[self.index(self.startIndex, offsetBy: index)]
}
func levenshteinDistance(_ target: String) -> Int {
// create two work vectors of integer distances
var last, current: [Int]
// initialize v0 (the previous row of distances)
// this row is A[0][i]: edit distance for an empty s
// the distance is just the number of characters to delete from t
last = [Int](0...target.count)
current = [Int](repeating: 0, count: target.count + 1)
for selfIndex in 0..<self.count {
// calculate v1 (current row distances) from the previous row v0
// first element of v1 is A[i+1][0]
// edit distance is delete (i+1) chars from s to match empty t
current[0] = selfIndex + 1
// use formula to fill in the rest of the row
for targetIndex in 0..<target.count {
current[targetIndex + 1] = Swift.min(
last[targetIndex + 1] + 1,
current[targetIndex] + 1,
last[targetIndex] + (self[selfIndex] == target[targetIndex] ? 0 : 1)
)
}
// copy v1 (current row) to v0 (previous row) for next iteration
last = current
}
return current[target.count]
}
}

View File

@@ -0,0 +1,87 @@
import Foundation
import PathKit
#if os(Linux)
// swiftlint:disable:next prefixed_toplevel_constant
let NSFileNoSuchFileError = 4
#endif
/// A class representing a template
open class Template: ExpressibleByStringLiteral {
let templateString: String
var environment: Environment
/// The list of parsed (lexed) tokens
public let tokens: [Token]
/// The name of the loaded Template if the Template was loaded from a Loader
public let name: String?
/// Create a template with a template string
public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
self.environment = environment ?? Environment()
self.name = name
self.templateString = templateString
let lexer = Lexer(templateName: name, templateString: templateString)
tokens = lexer.tokenize()
}
/// Create a template with the given name inside the given bundle
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
public convenience init(named: String, inBundle bundle: Bundle? = nil) throws {
let useBundle = bundle ?? Bundle.main
guard let url = useBundle.url(forResource: named, withExtension: nil) else {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
}
try self.init(URL: url)
}
/// Create a template with a file found at the given URL
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
public convenience init(URL: Foundation.URL) throws {
guard let path = Path(url: URL) else {
throw TemplateDoesNotExist(templateNames: [URL.lastPathComponent])
}
try self.init(path: path)
}
/// Create a template with a file found at the given path
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
public convenience init(path: Path, environment: Environment? = nil, name: String? = nil) throws {
let value = try String(contentsOf: path)
self.init(templateString: value, environment: environment, name: name)
}
// MARK: ExpressibleByStringLiteral
// Create a templaVte with a template string literal
public required convenience init(stringLiteral value: String) {
self.init(templateString: value)
}
// Create a template with a template string literal
public required convenience init(extendedGraphemeClusterLiteral value: StringLiteralType) {
self.init(stringLiteral: value)
}
// Create a template with a template string literal
public required convenience init(unicodeScalarLiteral value: StringLiteralType) {
self.init(stringLiteral: value)
}
/// Render the given template with a context
public func render(_ context: Context) throws -> String {
let context = context
let parser = TokenParser(tokens: tokens, environment: context.environment)
let nodes = try parser.parse()
return try renderNodes(nodes, context)
}
/// Render the given template
// swiftlint:disable:next discouraged_optional_collection
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
try render(Context(dictionary: dictionary ?? [:], environment: environment))
}
}

View File

@@ -0,0 +1,154 @@
import Foundation
extension String {
/// Split a string by a separator leaving quoted phrases together
func smartSplit(separator: Character = " ") -> [String] {
var word = ""
var components: [String] = []
var separate: Character = separator
var singleQuoteCount = 0
var doubleQuoteCount = 0
for character in self {
if character == "'" {
singleQuoteCount += 1
} else if character == "\"" {
doubleQuoteCount += 1
}
if character == separate {
if separate != separator {
word.append(separate)
} else if (singleQuoteCount.isMultiple(of: 2) || doubleQuoteCount.isMultiple(of: 2)) && !word.isEmpty {
appendWord(word, to: &components)
word = ""
}
separate = separator
} else {
if separate == separator && (character == "'" || character == "\"") {
separate = character
}
word.append(character)
}
}
if !word.isEmpty {
appendWord(word, to: &components)
}
return components
}
private func appendWord(_ word: String, to components: inout [String]) {
let specialCharacters = ",|:"
if !components.isEmpty {
if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
// special case for labeled for-loops
if components.count == 1 && word == "for" {
components.append(word)
} else {
components[components.count - 1] += word
}
} else if specialCharacters.contains(word) {
components[components.count - 1] += word
} else if word != "(" && word.first == "(" || word != ")" && word.first == ")" {
components.append(String(word.prefix(1)))
appendWord(String(word.dropFirst()), to: &components)
} else if word != "(" && word.last == "(" || word != ")" && word.last == ")" {
appendWord(String(word.dropLast()), to: &components)
components.append(String(word.suffix(1)))
} else {
components.append(word)
}
} else {
components.append(word)
}
}
}
public struct SourceMap: Equatable {
public let filename: String?
public let location: ContentLocation
init(filename: String? = nil, location: ContentLocation = ("", 0, 0)) {
self.filename = filename
self.location = location
}
static let unknown = Self()
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.filename == rhs.filename && lhs.location == rhs.location
}
}
public struct WhitespaceBehaviour: Equatable {
public enum Behaviour {
case unspecified
case trim
case keep
}
let leading: Behaviour
let trailing: Behaviour
public static let unspecified = Self(leading: .unspecified, trailing: .unspecified)
}
public class Token: Equatable {
public enum Kind: Equatable {
/// A token representing a piece of text.
case text
/// A token representing a variable.
case variable
/// A token representing a comment.
case comment
/// A token representing a template block.
case block
}
public let contents: String
public let kind: Kind
public let sourceMap: SourceMap
public var whitespace: WhitespaceBehaviour?
/// Returns the underlying value as an array seperated by spaces
public private(set) lazy var components: [String] = self.contents.smartSplit()
init(contents: String, kind: Kind, sourceMap: SourceMap, whitespace: WhitespaceBehaviour? = nil) {
self.contents = contents
self.kind = kind
self.sourceMap = sourceMap
self.whitespace = whitespace
}
/// A token representing a piece of text.
public static func text(value: String, at sourceMap: SourceMap) -> Token {
Token(contents: value, kind: .text, sourceMap: sourceMap)
}
/// A token representing a variable.
public static func variable(value: String, at sourceMap: SourceMap) -> Token {
Token(contents: value, kind: .variable, sourceMap: sourceMap)
}
/// A token representing a comment.
public static func comment(value: String, at sourceMap: SourceMap) -> Token {
Token(contents: value, kind: .comment, sourceMap: sourceMap)
}
/// A token representing a template block.
public static func block(
value: String,
at sourceMap: SourceMap,
whitespace: WhitespaceBehaviour = .unspecified
) -> Token {
Token(contents: value, kind: .block, sourceMap: sourceMap, whitespace: whitespace)
}
public static func == (lhs: Token, rhs: Token) -> Bool {
lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap
}
}

View File

@@ -0,0 +1,75 @@
import Foundation
public struct TrimBehaviour: Equatable {
var leading: Trim
var trailing: Trim
public enum Trim {
/// nothing
case nothing
/// tabs and spaces
case whitespace
/// tabs and spaces and a single new line
case whitespaceAndOneNewLine
/// all tabs spaces and newlines
case whitespaceAndNewLines
}
public init(leading: Trim, trailing: Trim) {
self.leading = leading
self.trailing = trailing
}
/// doesn't touch newlines
public static let nothing = Self(leading: .nothing, trailing: .nothing)
/// removes whitespace before a block and whitespace and a single newline after a block
public static let smart = Self(leading: .whitespace, trailing: .whitespaceAndOneNewLine)
/// removes all whitespace and newlines before and after a block
public static let all = Self(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
static func leadingRegex(trim: Trim) -> NSRegularExpression {
switch trim {
case .nothing:
fatalError("No RegularExpression for none")
case .whitespace:
return Self.leadingWhitespace
case .whitespaceAndOneNewLine:
return Self.leadingWhitespaceAndOneNewLine
case .whitespaceAndNewLines:
return Self.leadingWhitespaceAndNewlines
}
}
static func trailingRegex(trim: Trim) -> NSRegularExpression {
switch trim {
case .nothing:
fatalError("No RegularExpression for none")
case .whitespace:
return Self.trailingWhitespace
case .whitespaceAndOneNewLine:
return Self.trailingWhitespaceAndOneNewLine
case .whitespaceAndNewLines:
return Self.trailingWhitespaceAndNewLines
}
}
// swiftlint:disable:next force_try
private static let leadingWhitespaceAndNewlines = try! NSRegularExpression(pattern: "^\\s+")
// swiftlint:disable:next force_try
private static let trailingWhitespaceAndNewLines = try! NSRegularExpression(pattern: "\\s+$")
// swiftlint:disable:next force_try
private static let leadingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "^[ \t]*\n")
// swiftlint:disable:next force_try
private static let trailingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "\n[ \t]*$")
// swiftlint:disable:next force_try
private static let leadingWhitespace = try! NSRegularExpression(pattern: "^[ \t]*")
// swiftlint:disable:next force_try
private static let trailingWhitespace = try! NSRegularExpression(pattern: "[ \t]*$")
}

View File

@@ -0,0 +1,285 @@
import Foundation
typealias Number = Float
class FilterExpression: Resolvable {
let filters: [(FilterType, [Variable])]
let variable: Variable
init(token: String, environment: Environment) throws {
let bits = token.smartSplit(separator: "|").map { String($0).trim(character: " ") }
if bits.isEmpty {
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
}
variable = Variable(bits[0])
let filterBits = bits[bits.indices.suffix(from: 1)]
do {
filters = try filterBits.map { bit in
let (name, arguments) = parseFilterComponents(token: bit)
let filter = try environment.findFilter(name)
return (filter, arguments)
}
} catch {
filters = []
throw error
}
}
func resolve(_ context: Context) throws -> Any? {
let result = try variable.resolve(context)
return try filters.reduce(result) { value, filter in
let arguments = try filter.1.map { try $0.resolve(context) }
return try filter.0.invoke(value: value, arguments: arguments, context: context)
}
}
}
/// A structure used to represent a template variable, and to resolve it in a given context.
public struct Variable: Equatable, Resolvable {
public let variable: String
/// Create a variable with a string representing the variable
public init(_ variable: String) {
self.variable = variable
}
/// Resolve the variable in the given context
public func resolve(_ context: Context) throws -> Any? {
if variable.count > 1 &&
((variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\""))) {
// String literal
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
}
// Number literal
if let int = Int(variable) {
return int
}
if let number = Number(variable) {
return number
}
// Boolean literal
if let bool = Bool(variable) {
return bool
}
var current: Any? = context
for bit in try lookup(context) {
current = resolve(bit: bit, context: current)
if current == nil {
return nil
} else if let lazyCurrent = current as? LazyValueWrapper {
current = try lazyCurrent.value(context: context)
}
}
if let resolvable = current as? Resolvable {
current = try resolvable.resolve(context)
} else if let node = current as? NodeType {
current = try node.render(context)
}
return normalize(current)
}
// Split the lookup string and resolve references if possible
private func lookup(_ context: Context) throws -> [String] {
let keyPath = KeyPath(variable, in: context)
return try keyPath.parse()
}
// Try to resolve a partial keypath for the given context
private func resolve(bit: String, context: Any?) -> Any? {
let context = normalize(context)
if let context = context as? Context {
return context[bit]
} else if let dictionary = context as? [String: Any] {
return resolve(bit: bit, dictionary: dictionary)
} else if let array = context as? [Any] {
return resolve(bit: bit, collection: array)
} else if let string = context as? String {
return resolve(bit: bit, collection: string)
} else if let value = context as? DynamicMemberLookup {
return value[dynamicMember: bit]
} else if let object = context as? NSObject { // NSKeyValueCoding
#if canImport(ObjectiveC)
if object.responds(to: Selector(bit)) {
return object.value(forKey: bit)
}
#else
return nil
#endif
} else if let value = context {
return Mirror(reflecting: value).getValue(for: bit)
}
return nil
}
// Try to resolve a partial keypath for the given dictionary
private func resolve(bit: String, dictionary: [String: Any]) -> Any? {
if bit == "count" {
return dictionary.count
} else {
return dictionary[bit]
}
}
// Try to resolve a partial keypath for the given collection
private func resolve<T: Collection>(bit: String, collection: T) -> Any? {
if let index = Int(bit) {
if index >= 0 && index < collection.count {
return collection[collection.index(collection.startIndex, offsetBy: index)]
} else {
return nil
}
} else if bit == "first" {
return collection.first
} else if bit == "last" {
return collection[collection.index(collection.endIndex, offsetBy: -1)]
} else if bit == "count" {
return collection.count
} else {
return nil
}
}
}
/// A structure used to represet range of two integer values expressed as `from...to`.
/// Values should be numbers (they will be converted to integers).
/// Rendering this variable produces array from range `from...to`.
/// If `from` is more than `to` array will contain values of reversed range.
public struct RangeVariable: Resolvable {
public let from: Resolvable
// swiftlint:disable:next identifier_name
public let to: Resolvable
public init?(_ token: String, environment: Environment) throws {
let components = token.components(separatedBy: "...")
guard components.count == 2 else {
return nil
}
self.from = try environment.compileFilter(components[0])
self.to = try environment.compileFilter(components[1])
}
public init?(_ token: String, environment: Environment, containedIn containingToken: Token) throws {
let components = token.components(separatedBy: "...")
guard components.count == 2 else {
return nil
}
self.from = try environment.compileFilter(components[0], containedIn: containingToken)
self.to = try environment.compileFilter(components[1], containedIn: containingToken)
}
public func resolve(_ context: Context) throws -> Any? {
let lowerResolved = try from.resolve(context)
let upperResolved = try to.resolve(context)
guard let lower = lowerResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
throw TemplateSyntaxError("'from' value is not an Integer (\(lowerResolved ?? "nil"))")
}
guard let upper = upperResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
throw TemplateSyntaxError("'to' value is not an Integer (\(upperResolved ?? "nil") )")
}
let range = min(lower, upper)...max(lower, upper)
return lower > upper ? Array(range.reversed()) : Array(range)
}
}
func normalize(_ current: Any?) -> Any? {
if let current = current as? Normalizable {
return current.normalize()
}
return current
}
protocol Normalizable {
func normalize() -> Any?
}
extension Array: Normalizable {
func normalize() -> Any? {
map { $0 as Any }
}
}
// swiftlint:disable:next legacy_objc_type
extension NSArray: Normalizable {
func normalize() -> Any? {
map { $0 as Any }
}
}
extension Dictionary: Normalizable {
func normalize() -> Any? {
var dictionary: [String: Any] = [:]
for (key, value) in self {
if let key = key as? String {
dictionary[key] = Stencil.normalize(value)
} else if let key = key as? CustomStringConvertible {
dictionary[key.description] = Stencil.normalize(value)
}
}
return dictionary
}
}
func parseFilterComponents(token: String) -> (String, [Variable]) {
var components = token.smartSplit(separator: ":")
let name = components.removeFirst().trim(character: " ")
let variables = components
.joined(separator: ":")
.smartSplit(separator: ",")
.map { Variable($0.trim(character: " ")) }
return (name, variables)
}
extension Mirror {
func getValue(for key: String) -> Any? {
let result = descendant(key) ?? Int(key).flatMap { descendant($0) }
if result == nil {
// go through inheritance chain to reach superclass properties
return superclassMirror?.getValue(for: key)
} else if let result = result {
guard String(describing: result) != "nil" else {
// mirror returns non-nil value even for nil-containing properties
// so we have to check if its value is actually nil or not
return nil
}
if let result = (result as? AnyOptional)?.wrapped {
return result
} else {
return result
}
}
return result
}
}
protocol AnyOptional {
var wrapped: Any? { get }
}
extension Optional: AnyOptional {
var wrapped: Any? {
switch self {
case let .some(value):
return value
case .none:
return nil
}
}
}

View File

@@ -1,80 +0,0 @@
import Foundation
import PathKit
#if os(Linux)
let NSFileNoSuchFileError = 4
#endif
/// A class representing a template
open class Template: ExpressibleByStringLiteral {
let templateString: String
var environment: Environment
let tokens: [Token]
/// The name of the loaded Template if the Template was loaded from a Loader
public let name: String?
/// Create a template with a template string
public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
self.environment = environment ?? Environment()
self.name = name
self.templateString = templateString
let lexer = Lexer(templateName: name, templateString: templateString)
tokens = lexer.tokenize()
}
/// Create a template with the given name inside the given bundle
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
public convenience init(named: String, inBundle bundle: Bundle? = nil) throws {
let useBundle = bundle ?? Bundle.main
guard let url = useBundle.url(forResource: named, withExtension: nil) else {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
}
try self.init(URL: url)
}
/// Create a template with a file found at the given URL
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
public convenience init(URL: Foundation.URL) throws {
try self.init(path: Path(URL.path))
}
/// Create a template with a file found at the given path
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
public convenience init(path: Path, environment: Environment? = nil, name: String? = nil) throws {
self.init(templateString: try path.read(), environment: environment, name: name)
}
// MARK: ExpressibleByStringLiteral
// Create a templaVte with a template string literal
public required convenience init(stringLiteral value: String) {
self.init(templateString: value)
}
// Create a template with a template string literal
public required convenience init(extendedGraphemeClusterLiteral value: StringLiteralType) {
self.init(stringLiteral: value)
}
// Create a template with a template string literal
public required convenience init(unicodeScalarLiteral value: StringLiteralType) {
self.init(stringLiteral: value)
}
/// Render the given template with a context
func render(_ context: Context) throws -> String {
let context = context
let parser = TokenParser(tokens: tokens, environment: context.environment)
let nodes = try parser.parse()
return try renderNodes(nodes, context)
}
// swiftlint:disable discouraged_optional_collection
/// Render the given template
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
return try render(Context(dictionary: dictionary ?? [:], environment: environment))
}
}

View File

@@ -1,130 +0,0 @@
import Foundation
extension String {
/// Split a string by a separator leaving quoted phrases together
func smartSplit(separator: Character = " ") -> [String] {
var word = ""
var components: [String] = []
var separate: Character = separator
var singleQuoteCount = 0
var doubleQuoteCount = 0
for character in self {
if character == "'" {
singleQuoteCount += 1
} else if character == "\"" {
doubleQuoteCount += 1
}
if character == separate {
if separate != separator {
word.append(separate)
} else if (singleQuoteCount % 2 == 0 || doubleQuoteCount % 2 == 0) && !word.isEmpty {
appendWord(word, to: &components)
word = ""
}
separate = separator
} else {
if separate == separator && (character == "'" || character == "\"") {
separate = character
}
word.append(character)
}
}
if !word.isEmpty {
appendWord(word, to: &components)
}
return components
}
private func appendWord(_ word: String, to components: inout [String]) {
let specialCharacters = ",|:"
if !components.isEmpty {
if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
components[components.count - 1] += word
} else if specialCharacters.contains(word) {
components[components.count - 1] += word
} else if word != "(" && word.first == "(" || word != ")" && word.first == ")" {
components.append(String(word.prefix(1)))
appendWord(String(word.dropFirst()), to: &components)
} else if word != "(" && word.last == "(" || word != ")" && word.last == ")" {
appendWord(String(word.dropLast()), to: &components)
components.append(String(word.suffix(1)))
} else {
components.append(word)
}
} else {
components.append(word)
}
}
}
public struct SourceMap: Equatable {
public let filename: String?
public let location: ContentLocation
init(filename: String? = nil, location: ContentLocation = ("", 0, 0)) {
self.filename = filename
self.location = location
}
static let unknown = SourceMap()
public static func == (lhs: SourceMap, rhs: SourceMap) -> Bool {
return lhs.filename == rhs.filename && lhs.location == rhs.location
}
}
public class Token: Equatable {
public enum Kind: Equatable {
/// A token representing a piece of text.
case text
/// A token representing a variable.
case variable
/// A token representing a comment.
case comment
/// A token representing a template block.
case block
}
public let contents: String
public let kind: Kind
public let sourceMap: SourceMap
/// Returns the underlying value as an array seperated by spaces
public private(set) lazy var components: [String] = self.contents.smartSplit()
init(contents: String, kind: Kind, sourceMap: SourceMap) {
self.contents = contents
self.kind = kind
self.sourceMap = sourceMap
}
/// A token representing a piece of text.
public static func text(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .text, sourceMap: sourceMap)
}
/// A token representing a variable.
public static func variable(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .variable, sourceMap: sourceMap)
}
/// A token representing a comment.
public static func comment(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .comment, sourceMap: sourceMap)
}
/// A token representing a template block.
public static func block(value: String, at sourceMap: SourceMap) -> Token {
return Token(contents: value, kind: .block, sourceMap: sourceMap)
}
public static func == (lhs: Token, rhs: Token) -> Bool {
return lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap
}
}

View File

@@ -1,277 +0,0 @@
import Foundation
typealias Number = Float
class FilterExpression: Resolvable {
let filters: [(FilterType, [Variable])]
let variable: Variable
init(token: String, environment: Environment) throws {
let bits = token.smartSplit(separator: "|").map { String($0).trim(character: " ") }
if bits.isEmpty {
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
}
variable = Variable(bits[0])
let filterBits = bits[bits.indices.suffix(from: 1)]
do {
filters = try filterBits.map {
let (name, arguments) = parseFilterComponents(token: $0)
let filter = try environment.findFilter(name)
return (filter, arguments)
}
} catch {
filters = []
throw error
}
}
func resolve(_ context: Context) throws -> Any? {
let result = try variable.resolve(context)
return try filters.reduce(result) { value, filter in
let arguments = try filter.1.map { try $0.resolve(context) }
return try filter.0.invoke(value: value, arguments: arguments, context: context)
}
}
}
/// A structure used to represent a template variable, and to resolve it in a given context.
public struct Variable: Equatable, Resolvable {
public let variable: String
/// Create a variable with a string representing the variable
public init(_ variable: String) {
self.variable = variable
}
/// Resolve the variable in the given context
public func resolve(_ context: Context) throws -> Any? {
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
// String literal
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
}
// Number literal
if let int = Int(variable) {
return int
}
if let number = Number(variable) {
return number
}
// Boolean literal
if let bool = Bool(variable) {
return bool
}
var current: Any? = context
for bit in try lookup(context) {
current = resolve(bit: bit, context: current)
if current == nil {
return nil
}
}
if let resolvable = current as? Resolvable {
current = try resolvable.resolve(context)
} else if let node = current as? NodeType {
current = try node.render(context)
}
return normalize(current)
}
// Split the lookup string and resolve references if possible
private func lookup(_ context: Context) throws -> [String] {
let keyPath = KeyPath(variable, in: context)
return try keyPath.parse()
}
// Try to resolve a partial keypath for the given context
private func resolve(bit: String, context: Any?) -> Any? {
let context = normalize(context)
if let context = context as? Context {
return context[bit]
} else if let dictionary = context as? [String: Any] {
return resolve(bit: bit, dictionary: dictionary)
} else if let array = context as? [Any] {
return resolve(bit: bit, collection: array)
} else if let string = context as? String {
return resolve(bit: bit, collection: string)
} else if let object = context as? NSObject { // NSKeyValueCoding
#if os(Linux)
return nil
#else
if object.responds(to: Selector(bit)) {
return object.value(forKey: bit)
}
#endif
} else if let value = context {
return Mirror(reflecting: value).getValue(for: bit)
}
return nil
}
// Try to resolve a partial keypath for the given dictionary
private func resolve(bit: String, dictionary: [String: Any]) -> Any? {
if bit == "count" {
return dictionary.count
} else {
return dictionary[bit]
}
}
// Try to resolve a partial keypath for the given collection
private func resolve<T: Collection>(bit: String, collection: T) -> Any? {
if let index = Int(bit) {
if index >= 0 && index < collection.count {
return collection[collection.index(collection.startIndex, offsetBy: index)]
} else {
return nil
}
} else if bit == "first" {
return collection.first
} else if bit == "last" {
return collection[collection.index(collection.endIndex, offsetBy: -1)]
} else if bit == "count" {
return collection.count
} else {
return nil
}
}
}
/// A structure used to represet range of two integer values expressed as `from...to`.
/// Values should be numbers (they will be converted to integers).
/// Rendering this variable produces array from range `from...to`.
/// If `from` is more than `to` array will contain values of reversed range.
public struct RangeVariable: Resolvable {
public let from: Resolvable
// swiftlint:disable:next identifier_name
public let to: Resolvable
public init?(_ token: String, environment: Environment) throws {
let components = token.components(separatedBy: "...")
guard components.count == 2 else {
return nil
}
self.from = try environment.compileFilter(components[0])
self.to = try environment.compileFilter(components[1])
}
public init?(_ token: String, environment: Environment, containedIn containingToken: Token) throws {
let components = token.components(separatedBy: "...")
guard components.count == 2 else {
return nil
}
self.from = try environment.compileFilter(components[0], containedIn: containingToken)
self.to = try environment.compileFilter(components[1], containedIn: containingToken)
}
public func resolve(_ context: Context) throws -> Any? {
let lowerResolved = try from.resolve(context)
let upperResolved = try to.resolve(context)
guard let lower = lowerResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
throw TemplateSyntaxError("'from' value is not an Integer (\(lowerResolved ?? "nil"))")
}
guard let upper = upperResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
throw TemplateSyntaxError("'to' value is not an Integer (\(upperResolved ?? "nil") )")
}
let range = min(lower, upper)...max(lower, upper)
return lower > upper ? Array(range.reversed()) : Array(range)
}
}
func normalize(_ current: Any?) -> Any? {
if let current = current as? Normalizable {
return current.normalize()
}
return current
}
protocol Normalizable {
func normalize() -> Any?
}
extension Array: Normalizable {
func normalize() -> Any? {
return map { $0 as Any }
}
}
extension NSArray: Normalizable {
func normalize() -> Any? {
return map { $0 as Any }
}
}
extension Dictionary: Normalizable {
func normalize() -> Any? {
var dictionary: [String: Any] = [:]
for (key, value) in self {
if let key = key as? String {
dictionary[key] = Stencil.normalize(value)
} else if let key = key as? CustomStringConvertible {
dictionary[key.description] = Stencil.normalize(value)
}
}
return dictionary
}
}
func parseFilterComponents(token: String) -> (String, [Variable]) {
var components = token.smartSplit(separator: ":")
let name = components.removeFirst().trim(character: " ")
let variables = components
.joined(separator: ":")
.smartSplit(separator: ",")
.map { Variable($0.trim(character: " ")) }
return (name, variables)
}
extension Mirror {
func getValue(for key: String) -> Any? {
let result = descendant(key) ?? Int(key).flatMap { descendant($0) }
if result == nil {
// go through inheritance chain to reach superclass properties
return superclassMirror?.getValue(for: key)
} else if let result = result {
guard String(describing: result) != "nil" else {
// mirror returns non-nil value even for nil-containing properties
// so we have to check if its value is actually nil or not
return nil
}
if let result = (result as? AnyOptional)?.wrapped {
return result
} else {
return result
}
}
return result
}
}
protocol AnyOptional {
var wrapped: Any? { get }
}
extension Optional: AnyOptional {
var wrapped: Any? {
switch self {
case let .some(value): return value
case .none: return nil
}
}
}

View File

@@ -1,9 +0,0 @@
import Foundation
#if !swift(>=4.2)
extension ArraySlice where Element: Equatable {
func firstIndex(of element: Element) -> Int? {
return index(of: element)
}
}
#endif

View File

@@ -1,37 +1,34 @@
{
"name": "Stencil",
"version": "0.14.0",
"version": "0.15.2",
"summary": "Stencil is a simple and powerful template language for Swift.",
"homepage": "https://stencil.fuller.li",
"license": {
"type": "BSD",
"file": "LICENSE"
},
"authors": {
"Kyle Fuller": "kyle@fuller.li"
"Thomas Bernstein": "developer@astzweig.kg"
},
"social_media_url": "https://twitter.com/kylefuller",
"social_media_url": "https://twitter.com/trbernstein",
"source": {
"git": "https://github.com/stencilproject/Stencil.git",
"tag": "0.14.0"
"git": "https://github.com/swiftstencil/swiftpm-stencil.git",
"tag": "0.15.2"
},
"source_files": [
"Sources/*.swift"
"Sources/Stencil/*.swift"
],
"platforms": {
"ios": "8.0",
"osx": "10.9",
"tvos": "9.0"
},
"cocoapods_version": ">= 1.7.0",
"swift_versions": [
"4.2",
"5.0"
],
"requires_arc": true,
"dependencies": {
"PathKit": [
"~> 1.0.0"
"~> 1.5.0"
]
}
}

View File

@@ -1,8 +0,0 @@
import XCTest
import StencilTests
var tests = [XCTestCaseEntry]()
tests += StencilTests.__allTests()
XCTMain(tests)

View File

@@ -1,3 +1,5 @@
parent_config: ../../.swiftlint.yml
disabled_rules: # rule identifiers to exclude from running
- type_body_length
- file_length

View File

@@ -3,89 +3,158 @@ import Spectre
import XCTest
final class ContextTests: XCTestCase {
func testContextSubscripting() {
describe("Context Subscripting") {
var context = Context()
$0.before {
context = Context(dictionary: ["name": "Kyle"])
}
func testContextSubscripting() {
describe("Context Subscripting") { test in
var context = Context()
test.before {
context = Context(dictionary: ["name": "Kyle"])
}
$0.it("allows you to get a value via subscripting") {
try expect(context["name"] as? String) == "Kyle"
}
test.it("allows you to get a value via subscripting") {
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to set a value via subscripting") {
context["name"] = "Katie"
test.it("allows you to set a value via subscripting") {
context["name"] = "Katie"
try expect(context["name"] as? String) == "Katie"
}
try expect(context["name"] as? String) == "Katie"
}
$0.it("allows you to remove a value via subscripting") {
context["name"] = nil
test.it("allows you to remove a value via subscripting") {
context["name"] = nil
try expect(context["name"]).to.beNil()
}
try expect(context["name"]).to.beNil()
}
$0.it("allows you to retrieve a value from a parent") {
try context.push {
try expect(context["name"] as? String) == "Kyle"
}
}
test.it("allows you to retrieve a value from a parent") {
try context.push {
try expect(context["name"] as? String) == "Kyle"
}
}
$0.it("allows you to override a parent's value") {
try context.push {
context["name"] = "Katie"
try expect(context["name"] as? String) == "Katie"
}
}
}
}
test.it("allows you to override a parent's value") {
try context.push {
context["name"] = "Katie"
try expect(context["name"] as? String) == "Katie"
}
}
}
}
func testContextRestoration() {
describe("Context Restoration") {
var context = Context()
$0.before {
context = Context(dictionary: ["name": "Kyle"])
}
func testContextRestoration() {
describe("Context Restoration") { test in
var context = Context()
test.before {
context = Context(dictionary: ["name": "Kyle"])
}
$0.it("allows you to pop to restore previous state") {
context.push {
context["name"] = "Katie"
}
test.it("allows you to pop to restore previous state") {
context.push {
context["name"] = "Katie"
}
try expect(context["name"] as? String) == "Kyle"
}
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to remove a parent's value in a level") {
try context.push {
context["name"] = nil
try expect(context["name"]).to.beNil()
}
test.it("allows you to remove a parent's value in a level") {
try context.push {
context["name"] = nil
try expect(context["name"]).to.beNil()
}
try expect(context["name"] as? String) == "Kyle"
}
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to push a dictionary and run a closure then restoring previous state") {
var didRun = false
test.it("allows you to push a dictionary and run a closure then restoring previous state") {
var didRun = false
try context.push(dictionary: ["name": "Katie"]) {
didRun = true
try expect(context["name"] as? String) == "Katie"
}
try context.push(dictionary: ["name": "Katie"]) {
didRun = true
try expect(context["name"] as? String) == "Katie"
}
try expect(didRun).to.beTrue()
try expect(context["name"] as? String) == "Kyle"
}
try expect(didRun).to.beTrue()
try expect(context["name"] as? String) == "Kyle"
}
$0.it("allows you to flatten the context contents") {
try context.push(dictionary: ["test": "abc"]) {
let flattened = context.flatten()
test.it("allows you to flatten the context contents") {
try context.push(dictionary: ["test": "abc"]) {
let flattened = context.flatten()
try expect(flattened.count) == 2
try expect(flattened["name"] as? String) == "Kyle"
try expect(flattened["test"] as? String) == "abc"
}
}
}
}
try expect(flattened.count) == 2
try expect(flattened["name"] as? String) == "Kyle"
try expect(flattened["test"] as? String) == "abc"
}
}
}
}
func testContextLazyEvaluation() {
let ticker = Ticker()
var context = Context()
var wrapper = LazyValueWrapper("")
describe("Lazy evaluation") { test in
test.before {
ticker.count = 0
wrapper = LazyValueWrapper(ticker.tick())
context = Context(dictionary: ["name": wrapper])
}
test.it("Evaluates lazy data") {
let template = Template(templateString: "{{ name }}")
let result = try template.render(context)
try expect(result) == "Kyle"
try expect(ticker.count) == 1
}
test.it("Evaluates lazy only once") {
let template = Template(templateString: "{{ name }}{{ name }}")
let result = try template.render(context)
try expect(result) == "KyleKyle"
try expect(ticker.count) == 1
}
test.it("Does not evaluate lazy data when not used") {
let template = Template(templateString: "{{ 'Katie' }}")
let result = try template.render(context)
try expect(result) == "Katie"
try expect(ticker.count) == 0
}
}
}
func testContextLazyAccessTypes() {
it("Supports evaluation via context reference") {
let context = Context(dictionary: ["name": "Kyle"])
context["alias"] = LazyValueWrapper { $0["name"] ?? "" }
let template = Template(templateString: "{{ alias }}")
try context.push(dictionary: ["name": "Katie"]) {
let result = try template.render(context)
try expect(result) == "Katie"
}
}
it("Supports evaluation via context copy") {
let context = Context(dictionary: ["name": "Kyle"])
context["alias"] = LazyValueWrapper(copying: context) { $0["name"] ?? "" }
let template = Template(templateString: "{{ alias }}")
try context.push(dictionary: ["name": "Katie"]) {
let result = try template.render(context)
try expect(result) == "Kyle"
}
}
}
}
// MARK: - Helpers
private final class Ticker {
var count: Int = 0
func tick() -> String {
count += 1
return "Kyle"
}
}

View File

@@ -0,0 +1,125 @@
import PathKit
import Spectre
@testable import Stencil
import XCTest
final class EnvironmentBaseAndChildTemplateTests: XCTestCase {
private var environment = Environment(loader: ExampleLoader())
private var childTemplate: Template = ""
private var baseTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String)! / ".." / "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
childTemplate = ""
baseTemplate = ""
}
override func tearDown() {
super.tearDown()
}
func testSyntaxErrorInBaseTemplate() throws {
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown"
)
}
func testRuntimeErrorInBaseTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(
reason: "filter error",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown"
)
}
func testSyntaxErrorInChildTemplate() throws {
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "target|unknown",
baseToken: nil
)
}
func testRuntimeErrorInChildTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(
reason: "filter error",
childToken: "target|unknown",
baseToken: nil
)
}
private func expectError(
reason: String,
childToken: String,
baseToken: String?,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
if let baseToken = baseToken {
expectedError.stackTrace = [
expectedSyntaxError(
token: baseToken,
template: baseTemplate,
description: reason
).token
].compactMap { $0 }
}
let error = try expect(
self.environment.render(template: self.childTemplate, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}

View File

@@ -0,0 +1,88 @@
import PathKit
import Spectre
@testable import Stencil
import XCTest
final class EnvironmentIncludeTemplateTests: XCTestCase {
private var environment = Environment(loader: ExampleLoader())
private var template: Template = ""
private var includedTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String)! / ".." / "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
template = ""
includedTemplate = ""
}
override func tearDown() {
super.tearDown()
}
func testSyntaxError() throws {
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: #"include "invalid-include.html""#,
includedToken: "target|unknown"
)
}
func testRuntimeError() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(
reason: "filter error",
token: "include \"invalid-include.html\"",
includedToken: "target|unknown"
)
}
private func expectError(
reason: String,
token: String,
includedToken: String,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
expectedError.stackTrace = [
expectedSyntaxError(
token: includedToken,
template: includedTemplate,
description: reason
).token
].compactMap { $0 }
let error = try expect(
self.environment.render(template: self.template, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}

View File

@@ -1,448 +1,220 @@
import PathKit
import Spectre
@testable import Stencil
import XCTest
final class EnvironmentTests: XCTestCase {
var environment = Environment(loader: ExampleLoader())
var template: Template = ""
private var environment = Environment(loader: ExampleLoader())
private var template: Template = ""
override func setUp() {
super.setUp()
override func setUp() {
super.setUp()
let errorExtension = Extension()
errorExtension.registerFilter("throw") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
errorExtension.registerSimpleTag("simpletag") { _ in
throw TemplateSyntaxError("simpletag error")
}
errorExtension.registerTag("customtag") { _, token in
ErrorNode(token: token)
}
let errorExtension = Extension()
errorExtension.registerFilter("throw") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
errorExtension.registerSimpleTag("simpletag") { _ in
throw TemplateSyntaxError("simpletag error")
}
errorExtension.registerTag("customtag") { _, token in
ErrorNode(token: token)
}
environment = Environment(loader: ExampleLoader())
environment.extensions += [errorExtension]
template = ""
}
environment = Environment(loader: ExampleLoader())
environment.extensions += [errorExtension]
template = ""
}
func testLoading() {
it("can load a template from a name") {
let template = try self.environment.loadTemplate(name: "example.html")
try expect(template.name) == "example.html"
}
override func tearDown() {
super.tearDown()
}
it("can load a template from a names") {
let template = try self.environment.loadTemplate(names: ["first.html", "example.html"])
try expect(template.name) == "example.html"
}
}
func testLoading() {
it("can load a template from a name") {
let template = try self.environment.loadTemplate(name: "example.html")
try expect(template.name) == "example.html"
}
func testRendering() {
it("can render a template from a string") {
let result = try self.environment.renderTemplate(string: "Hello World")
try expect(result) == "Hello World"
}
it("can load a template from a names") {
let template = try self.environment.loadTemplate(names: ["first.html", "example.html"])
try expect(template.name) == "example.html"
}
}
it("can render a template from a file") {
let result = try self.environment.renderTemplate(name: "example.html")
try expect(result) == "Hello World!"
}
func testRendering() {
it("can render a template from a string") {
let result = try self.environment.renderTemplate(string: "Hello World")
try expect(result) == "Hello World"
}
it("allows you to provide a custom template class") {
let environment = Environment(loader: ExampleLoader(), templateClass: CustomTemplate.self)
let result = try environment.renderTemplate(string: "Hello World")
it("can render a template from a file") {
let result = try self.environment.renderTemplate(name: "example.html")
try expect(result) == "Hello World!"
}
try expect(result) == "here"
}
}
it("allows you to provide a custom template class") {
let environment = Environment(loader: ExampleLoader(), templateClass: CustomTemplate.self)
let result = try environment.renderTemplate(string: "Hello World")
func testSyntaxError() {
it("reports syntax error on invalid for tag syntax") {
self.template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
try self.expectError(
reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.",
token: "for name in"
)
}
try expect(result) == "here"
}
}
it("reports syntax error on missing endfor") {
self.template = "{% for name in names %}{{ name }}"
try self.expectError(reason: "`endfor` was not found.", token: "for name in names")
}
func testSyntaxError() {
it("reports syntax error on invalid for tag syntax") {
self.template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
try self.expectError(
reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.",
token: "for name in"
)
}
it("reports syntax error on unknown tag") {
self.template = "{% for name in names %}{{ name }}{% end %}"
try self.expectError(reason: "Unknown template tag 'end'", token: "end")
}
}
it("reports syntax error on missing endfor") {
self.template = "{% for name in names %}{{ name }}"
try self.expectError(reason: "`endfor` was not found.", token: "for name in names")
}
func testUnknownFilter() {
it("reports syntax error in for tag") {
self.template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "names|unknown"
)
}
it("reports syntax error on unknown tag") {
self.template = "{% for name in names %}{{ name }}{% end %}"
try self.expectError(reason: "Unknown template tag 'end'", token: "end")
}
}
it("reports syntax error in for-where tag") {
self.template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
func testUnknownFilter() {
it("reports syntax error in for tag") {
self.template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "names|unknown"
)
}
it("reports syntax error in if tag") {
self.template = "{% if name|unknown %}{{ name }}{% endif %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in for-where tag") {
self.template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in elif tag") {
self.template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in if tag") {
self.template = "{% if name|unknown %}{{ name }}{% endif %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in ifnot tag") {
self.template = "{% ifnot name|unknown %}{{ name }}{% endif %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in elif tag") {
self.template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in filter tag") {
self.template = "{% filter unknown %}Text{% endfilter %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "filter unknown"
)
}
it("reports syntax error in ifnot tag") {
self.template = "{% ifnot name|unknown %}{{ name }}{% endif %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in variable tag") {
self.template = "{{ name|unknown }}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
it("reports syntax error in filter tag") {
self.template = "{% filter unknown %}Text{% endfilter %}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "filter unknown"
)
}
it("reports error in variable tag") {
self.template = "{{ }}"
try self.expectError(reason: "Missing variable name", token: " ")
}
}
it("reports syntax error in variable tag") {
self.template = "{{ name|unknown }}"
try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown"
)
}
func testRenderingError() {
it("reports rendering error in variable filter") {
self.template = Template(templateString: "{{ name|throw }}", environment: self.environment)
try self.expectError(reason: "filter error", token: "name|throw")
}
it("reports error in variable tag") {
self.template = "{{ }}"
try self.expectError(reason: "Missing variable name", token: " ")
}
}
it("reports rendering error in filter tag") {
self.template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: self.environment)
try self.expectError(reason: "filter error", token: "filter throw")
}
func testRenderingError() {
it("reports rendering error in variable filter") {
self.template = Template(templateString: "{{ name|throw }}", environment: self.environment)
try self.expectError(reason: "filter error", token: "name|throw")
}
it("reports rendering error in simple tag") {
self.template = Template(templateString: "{% simpletag %}", environment: self.environment)
try self.expectError(reason: "simpletag error", token: "simpletag")
}
it("reports rendering error in filter tag") {
self.template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: self.environment)
try self.expectError(reason: "filter error", token: "filter throw")
}
it("reports passing argument to simple filter") {
self.template = "{{ name|uppercase:5 }}"
try self.expectError(reason: "Can't invoke filter with an argument", token: "name|uppercase:5")
}
it("reports rendering error in simple tag") {
self.template = Template(templateString: "{% simpletag %}", environment: self.environment)
try self.expectError(reason: "simpletag error", token: "simpletag")
}
it("reports rendering error in custom tag") {
self.template = Template(templateString: "{% customtag %}", environment: self.environment)
try self.expectError(reason: "Custom Error", token: "customtag")
}
it("reports passing argument to simple filter") {
self.template = "{{ name|uppercase:5 }}"
try self.expectError(reason: "Can't invoke filter with an argument", token: "name|uppercase:5")
}
it("reports rendering error in for body") {
self.template = Template(templateString: """
{% for name in names %}{% customtag %}{% endfor %}
""", environment: self.environment)
try self.expectError(reason: "Custom Error", token: "customtag")
}
it("reports rendering error in custom tag") {
self.template = Template(templateString: "{% customtag %}", environment: self.environment)
try self.expectError(reason: "Custom Error", token: "customtag")
}
it("reports rendering error in block") {
self.template = Template(
templateString: "{% block some %}{% customtag %}{% endblock %}",
environment: self.environment
)
try self.expectError(reason: "Custom Error", token: "customtag")
}
}
it("reports rendering error in for body") {
self.template = Template(templateString: """
{% for name in names %}{% customtag %}{% endfor %}
""", environment: self.environment)
try self.expectError(reason: "Custom Error", token: "customtag")
}
private func expectError(
reason: String,
token: String,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
it("reports rendering error in block") {
self.template = Template(
templateString: "{% block some %}{% customtag %}{% endblock %}",
environment: self.environment
)
try self.expectError(reason: "Custom Error", token: "customtag")
}
}
let error = try expect(
self.environment.render(template: self.template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
private func expectError(
reason: String,
token: String,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
let error = try expect(
self.environment.render(template: self.template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}
final class EnvironmentIncludeTemplateTests: XCTestCase {
var environment = Environment(loader: ExampleLoader())
var template: Template = ""
var includedTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
template = ""
includedTemplate = ""
}
func testSyntaxError() throws {
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: """
include "invalid-include.html"
""",
includedToken: "target|unknown")
}
func testRuntimeError() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
template = Template(templateString: """
{% include "invalid-include.html" %}
""", environment: environment)
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
try expectError(reason: "filter error",
token: "include \"invalid-include.html\"",
includedToken: "target|unknown")
}
private func expectError(
reason: String,
token: String,
includedToken: String,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
expectedError.stackTrace = [
expectedSyntaxError(
token: includedToken,
template: includedTemplate,
description: reason
).token
].compactMap { $0 }
let error = try expect(
self.environment.render(template: self.template, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}
final class EnvironmentBaseAndChildTemplateTests: XCTestCase {
var environment = Environment(loader: ExampleLoader())
var childTemplate: Template = ""
var baseTemplate: Template = ""
override func setUp() {
super.setUp()
let path = Path(#file as String) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
environment = Environment(loader: loader)
childTemplate = ""
baseTemplate = ""
}
func testSyntaxErrorInBaseTemplate() throws {
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "extends \"invalid-base.html\"",
baseToken: "target|unknown")
}
func testRuntimeErrorInBaseTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
try expectError(reason: "filter error",
childToken: "block.super",
baseToken: "target|unknown")
}
func testSyntaxErrorInChildTemplate() throws {
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
childToken: "target|unknown",
baseToken: nil)
}
func testRuntimeErrorInChildTemplate() throws {
let filterExtension = Extension()
filterExtension.registerFilter("unknown") { (_: Any?) in
throw TemplateSyntaxError("filter error")
}
environment.extensions += [filterExtension]
childTemplate = Template(
templateString: """
{% extends "base.html" %}
{% block body %}Child {{ target|unknown }}{% endblock %}
""",
environment: environment,
name: nil
)
try expectError(reason: "filter error",
childToken: "target|unknown",
baseToken: nil)
}
private func expectError(
reason: String,
childToken: String,
baseToken: String?,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
if let baseToken = baseToken {
expectedError.stackTrace = [
expectedSyntaxError(
token: baseToken,
template: baseTemplate,
description: reason
).token
].compactMap { $0 }
}
let error = try expect(
self.environment.render(template: self.childTemplate, context: ["target": "World"]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}
extension Expectation {
@discardableResult
func toThrow<T: Error>() throws -> T {
var thrownError: Error?
do {
_ = try expression()
} catch {
thrownError = error
}
if let thrownError = thrownError {
if let thrownError = thrownError as? T {
return thrownError
} else {
throw failure("\(thrownError) is not \(T.self)")
}
} else {
throw failure("expression did not throw an error")
}
}
}
extension XCTestCase {
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
let token = Token.block(value: token, at: sourceMap)
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
}
}
private class ExampleLoader: Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template {
if name == "example.html" {
return Template(templateString: "Hello World!", environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
}
// MARK: - Helpers
private class CustomTemplate: Template {
// swiftlint:disable discouraged_optional_collection
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
return "here"
}
// swiftlint:disable discouraged_optional_collection
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
"here"
}
}

View File

@@ -3,353 +3,353 @@ import Spectre
import XCTest
final class ExpressionsTests: XCTestCase {
let parser = TokenParser(tokens: [], environment: Environment())
private func makeExpression(_ components: [String]) -> Expression {
do {
let parser = try IfExpressionParser.parser(
components: components,
environment: Environment(),
token: .text(value: "", at: .unknown)
)
return try parser.parse()
} catch {
fatalError(error.localizedDescription)
}
}
func testTrueExpressions() {
let expression = VariableExpression(variable: Variable("value"))
it("evaluates to true when value is not nil") {
let context = Context(dictionary: ["value": "known"])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true when array variable is not empty") {
let items: [[String: Any]] = [["key": "key1", "value": 42], ["key": "key2", "value": 1_337]]
let context = Context(dictionary: ["value": [items]])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to false when dictionary value is empty") {
let emptyItems = [String: Any]()
let context = Context(dictionary: ["value": emptyItems])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to true when integer value is above 0") {
let context = Context(dictionary: ["value": 1])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true with string") {
let context = Context(dictionary: ["value": "test"])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true when float value is above 0") {
let context = Context(dictionary: ["value": Float(0.5)])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true when double value is above 0") {
let context = Context(dictionary: ["value": Double(0.5)])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
}
func testFalseExpressions() {
let expression = VariableExpression(variable: Variable("value"))
it("evaluates to false when value is unset") {
let context = Context()
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when array value is empty") {
let emptyItems = [[String: Any]]()
let context = Context(dictionary: ["value": emptyItems])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when dictionary value is empty") {
let emptyItems = [String: Any]()
let context = Context(dictionary: ["value": emptyItems])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when Array<Any> value is empty") {
let context = Context(dictionary: ["value": ([] as [Any])])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when empty string") {
let context = Context(dictionary: ["value": ""])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when integer value is below 0 or below") {
let context = Context(dictionary: ["value": 0])
try expect(try expression.evaluate(context: context)).to.beFalse()
let negativeContext = Context(dictionary: ["value": -1])
try expect(try expression.evaluate(context: negativeContext)).to.beFalse()
}
it("evaluates to false when float is 0 or below") {
let context = Context(dictionary: ["value": Float(0)])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when double is 0 or below") {
let context = Context(dictionary: ["value": Double(0)])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when uint is 0") {
let context = Context(dictionary: ["value": UInt(0)])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
}
func testNotExpression() {
it("returns truthy for positive expressions") {
let expression = NotExpression(expression: StaticExpression(value: true))
try expect(expression.evaluate(context: Context())).to.beFalse()
}
it("returns falsy for negative expressions") {
let expression = NotExpression(expression: StaticExpression(value: false))
try expect(expression.evaluate(context: Context())).to.beTrue()
}
}
func testExpressionParsing() {
it("can parse a variable expression") {
let expression = self.makeExpression(["value"])
try expect(expression.evaluate(context: Context())).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue()
}
it("can parse a not expression") {
let expression = self.makeExpression(["not", "value"])
try expect(expression.evaluate(context: Context())).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse()
}
}
func testAndExpression() {
let expression = makeExpression(["lhs", "and", "rhs"])
it("evaluates to false with lhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
}
it("evaluates to false with rhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
}
it("evaluates to false with lhs and rhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
}
it("evaluates to true with lhs and rhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
}
}
func testOrExpression() {
let expression = makeExpression(["lhs", "or", "rhs"])
it("evaluates to true with lhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
}
it("evaluates to true with rhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beTrue()
}
it("evaluates to true with lhs and rhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
}
it("evaluates to false with lhs and rhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
}
}
func testEqualityExpression() {
let expression = makeExpression(["lhs", "==", "rhs"])
it("evaluates to true with equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
}
it("evaluates to false with non equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beFalse()
}
it("evaluates to true with nils") {
try expect(expression.evaluate(context: Context(dictionary: [:]))).to.beTrue()
}
it("evaluates to true with numbers") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.0]))).to.beTrue()
}
it("evaluates to false with non equal numbers") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.1]))).to.beFalse()
}
it("evaluates to true with booleans") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
}
it("evaluates to false with falsy booleans") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
}
it("evaluates to false with different types") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": 1]))).to.beFalse()
}
}
func testInequalityExpression() {
let expression = makeExpression(["lhs", "!=", "rhs"])
it("evaluates to true with inequal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
}
it("evaluates to false with equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "b", "rhs": "b"]))).to.beFalse()
}
}
func testMoreThanExpression() {
let expression = makeExpression(["lhs", ">", "rhs"])
it("evaluates to true with lhs > rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue()
}
it("evaluates to false with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
}
}
func testMoreThanEqualExpression() {
let expression = makeExpression(["lhs", ">=", "rhs"])
it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
}
it("evaluates to false with lhs < rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.1]))).to.beFalse()
}
}
func testLessThanExpression() {
let expression = makeExpression(["lhs", "<", "rhs"])
it("evaluates to true with lhs < rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue()
}
it("evaluates to false with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
}
}
func testLessThanEqualExpression() {
let expression = makeExpression(["lhs", "<=", "rhs"])
it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
}
it("evaluates to false with lhs > rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.1, "rhs": 5.0]))).to.beFalse()
}
}
func testMultipleExpressions() {
let expression = makeExpression(["one", "or", "two", "and", "not", "three"])
it("evaluates to true with one") {
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
}
it("evaluates to true with one and three") {
try expect(expression.evaluate(context: Context(dictionary: ["one": true, "three": true]))).to.beTrue()
}
it("evaluates to true with two") {
try expect(expression.evaluate(context: Context(dictionary: ["two": true]))).to.beTrue()
}
it("evaluates to false with two and three") {
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
}
it("evaluates to false with two and three") {
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
}
it("evaluates to false with nothing") {
try expect(expression.evaluate(context: Context())).to.beFalse()
}
}
func testTrueInExpression() throws {
let expression = makeExpression(["lhs", "in", "rhs"])
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": [1, 2, 3]
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": ["a", "b", "c"]
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": "abc"
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": 1...3
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": 1..<3
]))).to.beTrue()
}
func testFalseInExpression() throws {
let expression = makeExpression(["lhs", "in", "rhs"])
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": [2, 3, 4]
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": ["b", "c", "d"]
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": "bcd"
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 4,
"rhs": 1...3
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 3,
"rhs": 1..<3
]))).to.beFalse()
}
private let parser = TokenParser(tokens: [], environment: Environment())
private func makeExpression(_ components: [String]) -> Stencil.Expression {
do {
let parser = try IfExpressionParser.parser(
components: components,
environment: Environment(),
token: .text(value: "", at: .unknown)
)
return try parser.parse()
} catch {
fatalError(error.localizedDescription)
}
}
func testTrueExpressions() {
let expression = VariableExpression(variable: Variable("value"))
it("evaluates to true when value is not nil") {
let context = Context(dictionary: ["value": "known"])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true when array variable is not empty") {
let items: [[String: Any]] = [["key": "key1", "value": 42], ["key": "key2", "value": 1_337]]
let context = Context(dictionary: ["value": [items]])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to false when dictionary value is empty") {
let emptyItems = [String: Any]()
let context = Context(dictionary: ["value": emptyItems])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to true when integer value is above 0") {
let context = Context(dictionary: ["value": 1])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true with string") {
let context = Context(dictionary: ["value": "test"])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true when float value is above 0") {
let context = Context(dictionary: ["value": Float(0.5)])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
it("evaluates to true when double value is above 0") {
let context = Context(dictionary: ["value": Double(0.5)])
try expect(try expression.evaluate(context: context)).to.beTrue()
}
}
func testFalseExpressions() {
let expression = VariableExpression(variable: Variable("value"))
it("evaluates to false when value is unset") {
let context = Context()
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when array value is empty") {
let emptyItems = [[String: Any]]()
let context = Context(dictionary: ["value": emptyItems])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when dictionary value is empty") {
let emptyItems = [String: Any]()
let context = Context(dictionary: ["value": emptyItems])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when Array<Any> value is empty") {
let context = Context(dictionary: ["value": ([] as [Any])])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when empty string") {
let context = Context(dictionary: ["value": ""])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when integer value is below 0 or below") {
let context = Context(dictionary: ["value": 0])
try expect(try expression.evaluate(context: context)).to.beFalse()
let negativeContext = Context(dictionary: ["value": -1])
try expect(try expression.evaluate(context: negativeContext)).to.beFalse()
}
it("evaluates to false when float is 0 or below") {
let context = Context(dictionary: ["value": Float(0)])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when double is 0 or below") {
let context = Context(dictionary: ["value": Double(0)])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
it("evaluates to false when uint is 0") {
let context = Context(dictionary: ["value": UInt(0)])
try expect(try expression.evaluate(context: context)).to.beFalse()
}
}
func testNotExpression() {
it("returns truthy for positive expressions") {
let expression = NotExpression(expression: VariableExpression(variable: Variable("true")))
try expect(expression.evaluate(context: Context())).to.beFalse()
}
it("returns falsy for negative expressions") {
let expression = NotExpression(expression: VariableExpression(variable: Variable("false")))
try expect(expression.evaluate(context: Context())).to.beTrue()
}
}
func testExpressionParsing() {
it("can parse a variable expression") {
let expression = self.makeExpression(["value"])
try expect(expression.evaluate(context: Context())).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue()
}
it("can parse a not expression") {
let expression = self.makeExpression(["not", "value"])
try expect(expression.evaluate(context: Context())).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse()
}
}
func testAndExpression() {
let expression = makeExpression(["lhs", "and", "rhs"])
it("evaluates to false with lhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
}
it("evaluates to false with rhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
}
it("evaluates to false with lhs and rhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
}
it("evaluates to true with lhs and rhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
}
}
func testOrExpression() {
let expression = makeExpression(["lhs", "or", "rhs"])
it("evaluates to true with lhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
}
it("evaluates to true with rhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beTrue()
}
it("evaluates to true with lhs and rhs true") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
}
it("evaluates to false with lhs and rhs false") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
}
}
func testEqualityExpression() {
let expression = makeExpression(["lhs", "==", "rhs"])
it("evaluates to true with equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
}
it("evaluates to false with non equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beFalse()
}
it("evaluates to true with nils") {
try expect(expression.evaluate(context: Context(dictionary: [:]))).to.beTrue()
}
it("evaluates to true with numbers") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.0]))).to.beTrue()
}
it("evaluates to false with non equal numbers") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.1]))).to.beFalse()
}
it("evaluates to true with booleans") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
}
it("evaluates to false with falsy booleans") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
}
it("evaluates to false with different types") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": 1]))).to.beFalse()
}
}
func testInequalityExpression() {
let expression = makeExpression(["lhs", "!=", "rhs"])
it("evaluates to true with inequal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
}
it("evaluates to false with equal lhs/rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "b", "rhs": "b"]))).to.beFalse()
}
}
func testMoreThanExpression() {
let expression = makeExpression(["lhs", ">", "rhs"])
it("evaluates to true with lhs > rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue()
}
it("evaluates to false with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
}
}
func testMoreThanEqualExpression() {
let expression = makeExpression(["lhs", ">=", "rhs"])
it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
}
it("evaluates to false with lhs < rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.1]))).to.beFalse()
}
}
func testLessThanExpression() {
let expression = makeExpression(["lhs", "<", "rhs"])
it("evaluates to true with lhs < rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue()
}
it("evaluates to false with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
}
}
func testLessThanEqualExpression() {
let expression = makeExpression(["lhs", "<=", "rhs"])
it("evaluates to true with lhs == rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
}
it("evaluates to false with lhs > rhs") {
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.1, "rhs": 5.0]))).to.beFalse()
}
}
func testMultipleExpressions() {
let expression = makeExpression(["one", "or", "two", "and", "not", "three"])
it("evaluates to true with one") {
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
}
it("evaluates to true with one and three") {
try expect(expression.evaluate(context: Context(dictionary: ["one": true, "three": true]))).to.beTrue()
}
it("evaluates to true with two") {
try expect(expression.evaluate(context: Context(dictionary: ["two": true]))).to.beTrue()
}
it("evaluates to false with two and three") {
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
}
it("evaluates to false with two and three") {
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
}
it("evaluates to false with nothing") {
try expect(expression.evaluate(context: Context())).to.beFalse()
}
}
func testTrueInExpression() throws {
let expression = makeExpression(["lhs", "in", "rhs"])
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": [1, 2, 3]
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": ["a", "b", "c"]
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": "abc"
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": 1...3
]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": 1..<3
]))).to.beTrue()
}
func testFalseInExpression() throws {
let expression = makeExpression(["lhs", "in", "rhs"])
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1,
"rhs": [2, 3, 4]
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": ["b", "c", "d"]
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a",
"rhs": "bcd"
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 4,
"rhs": 1...3
]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 3,
"rhs": 1..<3
]))).to.beFalse()
}
}

View File

@@ -3,378 +3,380 @@ import Spectre
import XCTest
final class FilterTests: XCTestCase {
func testRegistration() {
let context: [String: Any] = ["name": "Kyle"]
func testRegistration() {
let context: [String: Any] = ["name": "Kyle"]
it("allows you to register a custom filter") {
let template = Template(templateString: "{{ name|repeat }}")
it("allows you to register a custom filter") {
let template = Template(templateString: "{{ name|repeat }}")
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { (value: Any?) in
if let value = value as? String {
return "\(value) \(value)"
}
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { (value: Any?) in
if let value = value as? String {
return "\(value) \(value)"
}
return nil
}
return nil
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == "Kyle Kyle"
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == "Kyle Kyle"
}
it("allows you to register boolean filters") {
let repeatExtension = Extension()
repeatExtension.registerFilter(name: "isPositive", negativeFilterName: "isNotPositive") { (value: Any?) in
if let value = value as? Int {
return value > 0
}
return nil
}
it("allows you to register boolean filters") {
let repeatExtension = Extension()
repeatExtension.registerFilter(name: "isPositive", negativeFilterName: "isNotPositive") { (value: Any?) in
if let value = value as? Int {
return value > 0
}
return nil
}
let result = try Template(templateString: "{{ value|isPositive }}")
.render(Context(dictionary: ["value": 1], environment: Environment(extensions: [repeatExtension])))
try expect(result) == "true"
let result = try Template(templateString: "{{ value|isPositive }}")
.render(Context(dictionary: ["value": 1], environment: Environment(extensions: [repeatExtension])))
try expect(result) == "true"
let negativeResult = try Template(templateString: "{{ value|isNotPositive }}")
.render(Context(dictionary: ["value": -1], environment: Environment(extensions: [repeatExtension])))
try expect(negativeResult) == "true"
}
let negativeResult = try Template(templateString: "{{ value|isNotPositive }}")
.render(Context(dictionary: ["value": -1], environment: Environment(extensions: [repeatExtension])))
try expect(negativeResult) == "true"
}
it("allows you to register a custom which throws") {
let template = Template(templateString: "{{ name|repeat }}")
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { (_: Any?) in
throw TemplateSyntaxError("No Repeat")
}
it("allows you to register a custom which throws") {
let template = Template(templateString: "{{ name|repeat }}")
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { (_: Any?) in
throw TemplateSyntaxError("No Repeat")
}
let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))
try expect(try template.render(context))
.toThrow(TemplateSyntaxError(reason: "No Repeat", token: template.tokens.first))
}
let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))
try expect(try template.render(context))
.toThrow(TemplateSyntaxError(reason: "No Repeat", token: template.tokens.first))
}
it("throws when you pass arguments to simple filter") {
let template = Template(templateString: "{{ name|uppercase:5 }}")
try expect(try template.render(Context(dictionary: ["name": "kyle"]))).toThrow()
}
}
it("throws when you pass arguments to simple filter") {
let template = Template(templateString: "{{ name|uppercase:5 }}")
try expect(try template.render(Context(dictionary: ["name": "kyle"]))).toThrow()
}
}
func testRegistrationOverrideDefault() throws {
let template = Template(templateString: "{{ name|join }}")
let context: [String: Any] = ["name": "Kyle"]
func testRegistrationOverrideDefault() throws {
let template = Template(templateString: "{{ name|join }}")
let context: [String: Any] = ["name": "Kyle"]
let repeatExtension = Extension()
repeatExtension.registerFilter("join") { (_: Any?) in
"joined"
}
let repeatExtension = Extension()
repeatExtension.registerFilter("join") { (_: Any?) in
"joined"
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == "joined"
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == "joined"
}
func testRegistrationWithArguments() {
let context: [String: Any] = ["name": "Kyle"]
func testRegistrationWithArguments() {
let context: [String: Any] = ["name": "Kyle"]
it("allows you to register a custom filter which accepts single argument") {
let template = Template(templateString: """
{{ name|repeat:'value1, "value2"' }}
""")
it("allows you to register a custom filter which accepts single argument") {
let template = Template(templateString: """
{{ name|repeat:'value1, "value2"' }}
""")
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in
guard let value = value,
let argument = arguments.first else { return nil }
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in
guard let value = value,
let argument = arguments.first else { return nil }
return "\(value) \(value) with args \(argument ?? "")"
}
return "\(value) \(value) with args \(argument ?? "")"
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == """
Kyle Kyle with args value1, "value2"
"""
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == """
Kyle Kyle with args value1, "value2"
"""
}
it("allows you to register a custom filter which accepts several arguments") {
let template = Template(templateString: """
{{ name|repeat:'value"1"',"value'2'",'(key, value)' }}
""")
it("allows you to register a custom filter which accepts several arguments") {
let template = Template(templateString: """
{{ name|repeat:'value"1"',"value'2'",'(key, value)' }}
""")
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in
guard let value = value else { return nil }
let args = arguments.compactMap { $0 }
return "\(value) \(value) with args 0: \(args[0]), 1: \(args[1]), 2: \(args[2])"
}
let repeatExtension = Extension()
repeatExtension.registerFilter("repeat") { value, arguments in
guard let value = value else { return nil }
let args = arguments.compactMap { $0 }
return "\(value) \(value) with args 0: \(args[0]), 1: \(args[1]), 2: \(args[2])"
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == """
Kyle Kyle with args 0: value"1", 1: value'2', 2: (key, value)
"""
}
let result = try template.render(Context(
dictionary: context,
environment: Environment(extensions: [repeatExtension])
))
try expect(result) == """
Kyle Kyle with args 0: value"1", 1: value'2', 2: (key, value)
"""
}
it("allows whitespace in expression") {
let template = Template(templateString: """
{{ value | join : ", " }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "One, Two"
}
}
it("allows whitespace in expression") {
let template = Template(templateString: """
{{ value | join : ", " }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "One, Two"
}
}
func testStringFilters() {
it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ name|capitalize }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "Kyle"
}
func testStringFilters() {
it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ name|capitalize }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "Kyle"
}
it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ name|uppercase }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "KYLE"
}
it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ name|uppercase }}")
let result = try template.render(Context(dictionary: ["name": "kyle"]))
try expect(result) == "KYLE"
}
it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ name|lowercase }}")
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
try expect(result) == "kyle"
}
}
it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ name|lowercase }}")
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
try expect(result) == "kyle"
}
}
func testStringFiltersWithArrays() {
it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ names|capitalize }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == """
["Kyle", "Kyle"]
"""
}
func testStringFiltersWithArrays() {
it("transforms a string to be capitalized") {
let template = Template(templateString: "{{ names|capitalize }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == """
["Kyle", "Kyle"]
"""
}
it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ names|uppercase }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == """
["KYLE", "KYLE"]
"""
}
it("transforms a string to be uppercase") {
let template = Template(templateString: "{{ names|uppercase }}")
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
try expect(result) == """
["KYLE", "KYLE"]
"""
}
it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ names|lowercase }}")
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
try expect(result) == """
["kyle", "kyle"]
"""
}
}
it("transforms a string to be lowercase") {
let template = Template(templateString: "{{ names|lowercase }}")
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
try expect(result) == """
["kyle", "kyle"]
"""
}
}
func testDefaultFilter() {
let template = Template(templateString: """
Hello {{ name|default:"World" }}
""")
func testDefaultFilter() {
let template = Template(templateString: """
Hello {{ name|default:"World" }}
""")
it("shows the variable value") {
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
try expect(result) == "Hello Kyle"
}
it("shows the variable value") {
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
try expect(result) == "Hello Kyle"
}
it("shows the default value") {
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "Hello World"
}
it("shows the default value") {
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "Hello World"
}
it("supports multiple defaults") {
let template = Template(templateString: """
Hello {{ name|default:a,b,c,"World" }}
""")
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "Hello World"
}
it("supports multiple defaults") {
let template = Template(templateString: """
Hello {{ name|default:a,b,c,"World" }}
""")
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "Hello World"
}
it("can use int as default") {
let template = Template(templateString: "{{ value|default:1 }}")
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "1"
}
it("can use int as default") {
let template = Template(templateString: "{{ value|default:1 }}")
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "1"
}
it("can use float as default") {
let template = Template(templateString: "{{ value|default:1.5 }}")
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "1.5"
}
it("can use float as default") {
let template = Template(templateString: "{{ value|default:1.5 }}")
let result = try template.render(Context(dictionary: [:]))
try expect(result) == "1.5"
}
it("checks for underlying nil value correctly") {
let template = Template(templateString: """
Hello {{ user.name|default:"anonymous" }}
""")
let nilName: String? = nil
let user: [String: Any?] = ["name": nilName]
let result = try template.render(Context(dictionary: ["user": user]))
try expect(result) == "Hello anonymous"
}
}
it("checks for underlying nil value correctly") {
let template = Template(templateString: """
Hello {{ user.name|default:"anonymous" }}
""")
let nilName: String? = nil
let user: [String: Any?] = ["name": nilName]
let result = try template.render(Context(dictionary: ["user": user]))
try expect(result) == "Hello anonymous"
}
}
func testJoinFilter() {
let template = Template(templateString: """
{{ value|join:", " }}
""")
func testJoinFilter() {
let template = Template(templateString: """
{{ value|join:", " }}
""")
it("joins a collection of strings") {
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "One, Two"
}
it("joins a collection of strings") {
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "One, Two"
}
it("joins a mixed-type collection") {
let result = try template.render(Context(dictionary: ["value": ["One", 2, true, 10.5, "Five"]]))
try expect(result) == "One, 2, true, 10.5, Five"
}
it("joins a mixed-type collection") {
let result = try template.render(Context(dictionary: ["value": ["One", 2, true, 10.5, "Five"]]))
try expect(result) == "One, 2, true, 10.5, Five"
}
it("can join by non string") {
let template = Template(templateString: """
{{ value|join:separator }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"], "separator": true]))
try expect(result) == "OnetrueTwo"
}
it("can join by non string") {
let template = Template(templateString: """
{{ value|join:separator }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"], "separator": true]))
try expect(result) == "OnetrueTwo"
}
it("can join without arguments") {
let template = Template(templateString: """
{{ value|join }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "OneTwo"
}
}
it("can join without arguments") {
let template = Template(templateString: """
{{ value|join }}
""")
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
try expect(result) == "OneTwo"
}
}
func testSplitFilter() {
let template = Template(templateString: """
{{ value|split:", " }}
""")
func testSplitFilter() {
let template = Template(templateString: """
{{ value|split:", " }}
""")
it("split a string into array") {
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == """
["One", "Two"]
"""
}
it("split a string into array") {
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == """
["One", "Two"]
"""
}
it("can split without arguments") {
let template = Template(templateString: """
{{ value|split }}
""")
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == """
["One,", "Two"]
"""
}
}
it("can split without arguments") {
let template = Template(templateString: """
{{ value|split }}
""")
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
try expect(result) == """
["One,", "Two"]
"""
}
}
func testFilterSuggestion() {
it("made for unknown filter") {
let template = Template(templateString: "{{ value|unknownFilter }}")
let filterExtension = Extension()
filterExtension.registerFilter("knownFilter") { value, _ in value }
func testFilterSuggestion() {
it("made for unknown filter") {
let template = Template(templateString: "{{ value|unknownFilter }}")
let filterExtension = Extension()
filterExtension.registerFilter("knownFilter") { value, _ in value }
try self.expectError(
reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.",
token: "value|unknownFilter",
template: template,
extension: filterExtension
)
}
try self.expectError(
reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.",
token: "value|unknownFilter",
template: template,
extension: filterExtension
)
}
it("made for multiple similar filters") {
let template = Template(templateString: "{{ value|lowerFirst }}")
let filterExtension = Extension()
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
it("made for multiple similar filters") {
let template = Template(templateString: "{{ value|lowerFirst }}")
let filterExtension = Extension()
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
try self.expectError(
reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.",
token: "value|lowerFirst",
template: template,
extension: filterExtension
)
}
try self.expectError(
reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.",
token: "value|lowerFirst",
template: template,
extension: filterExtension
)
}
it("not made when can't find similar filter") {
let template = Template(templateString: "{{ value|unknownFilter }}")
let filterExtension = Extension()
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
it("not made when can't find similar filter") {
let template = Template(templateString: "{{ value|unknownFilter }}")
let filterExtension = Extension()
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
try self.expectError(
reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.",
token: "value|unknownFilter",
template: template,
extension: filterExtension
)
}
}
try self.expectError(
reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.",
token: "value|unknownFilter",
template: template,
extension: filterExtension
)
}
}
func testIndentContent() throws {
let template = Template(templateString: """
func testIndentContent() throws {
let template = Template(templateString: """
{{ value|indent:2 }}
""")
let result = try template.render(Context(dictionary: [
"value": """
let result = try template.render(Context(dictionary: [
"value": """
One
Two
"""
]))
try expect(result) == """
]))
try expect(result) == """
One
Two
"""
}
}
func testIndentWithArbitraryCharacter() throws {
let template = Template(templateString: """
func testIndentWithArbitraryCharacter() throws {
let template = Template(templateString: """
{{ value|indent:2,"\t" }}
""")
let result = try template.render(Context(dictionary: [
"value": """
let result = try template.render(Context(dictionary: [
"value": """
One
Two
"""
]))
try expect(result) == """
One
\t\tTwo
"""
}
]))
try expect(result) == """
One
\t\tTwo
"""
}
func testIndentFirstLine() throws {
let template = Template(templateString: """
{{ value|indent:2," ",true }}
""")
let result = try template.render(Context(dictionary: [
"value": """
func testIndentFirstLine() throws {
let template = Template(templateString: """
{{ value|indent:2," ",true }}
""")
let result = try template.render(Context(dictionary: [
"value": """
One
Two
"""
]))
try expect(result) == """
]))
// swiftlint:disable indentation_width
try expect(result) == """
One
Two
"""
}
// swiftlint:enable indentation_width
}
func testIndentNotEmptyLines() throws {
let template = Template(templateString: """
{{ value|indent }}
""")
let result = try template.render(Context(dictionary: [
"value": """
func testIndentNotEmptyLines() throws {
let template = Template(templateString: """
{{ value|indent }}
""")
let result = try template.render(Context(dictionary: [
"value": """
One
@@ -382,8 +384,9 @@ final class FilterTests: XCTestCase {
"""
]))
try expect(result) == """
]))
// swiftlint:disable indentation_width
try expect(result) == """
One
@@ -391,63 +394,64 @@ final class FilterTests: XCTestCase {
"""
}
// swiftlint:enable indentation_width
}
func testDynamicFilters() throws {
it("can apply dynamic filter") {
let template = Template(templateString: "{{ name|filter:somefilter }}")
let result = try template.render(Context(dictionary: ["name": "Jhon", "somefilter": "uppercase"]))
try expect(result) == "JHON"
}
func testDynamicFilters() throws {
it("can apply dynamic filter") {
let template = Template(templateString: "{{ name|filter:somefilter }}")
let result = try template.render(Context(dictionary: ["name": "Jhon", "somefilter": "uppercase"]))
try expect(result) == "JHON"
}
it("can apply dynamic filter on array") {
let template = Template(templateString: "{{ values|filter:joinfilter }}")
let result = try template.render(Context(dictionary: ["values": [1, 2, 3], "joinfilter": "join:\", \""]))
try expect(result) == "1, 2, 3"
}
it("can apply dynamic filter on array") {
let template = Template(templateString: "{{ values|filter:joinfilter }}")
let result = try template.render(Context(dictionary: ["values": [1, 2, 3], "joinfilter": "join:\", \""]))
try expect(result) == "1, 2, 3"
}
it("throws on unknown dynamic filter") {
let template = Template(templateString: "{{ values|filter:unknown }}")
let context = Context(dictionary: ["values": [1, 2, 3], "unknown": "absurd"])
try expect(try template.render(context)).toThrow()
}
}
it("throws on unknown dynamic filter") {
let template = Template(templateString: "{{ values|filter:unknown }}")
let context = Context(dictionary: ["values": [1, 2, 3], "unknown": "absurd"])
try expect(try template.render(context)).toThrow()
}
}
private func expectError(
reason: String,
token: String,
template: Template,
extension: Extension,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
private func expectError(
reason: String,
token: String,
template: Template,
extension: Extension,
file: String = #file,
line: Int = #line,
function: String = #function
) throws {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
let environment = Environment(extensions: [`extension`])
let expectedError: Error = {
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
let token = Token.block(value: token, at: sourceMap)
return TemplateSyntaxError(reason: reason, token: token, stackTrace: [])
}()
let environment = Environment(extensions: [`extension`])
let expectedError: Error = {
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
let token = Token.block(value: token, at: sourceMap)
return TemplateSyntaxError(reason: reason, token: token, stackTrace: [])
}()
let error = try expect(
environment.render(template: template, context: [:]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
let error = try expect(
environment.render(template: template, context: [:]),
file: file,
line: line,
function: function
).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter()
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
try expect(
reporter.renderError(error),
file: file,
line: line,
function: function
) == reporter.renderError(expectedError)
}
}

View File

@@ -3,52 +3,52 @@ import Stencil
import XCTest
final class FilterTagTests: XCTestCase {
func testFilterTag() {
it("allows you to use a filter") {
let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
let result = try template.render()
try expect(result) == "TEST"
}
func testFilterTag() {
it("allows you to use a filter") {
let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
let result = try template.render()
try expect(result) == "TEST"
}
it("allows you to chain filters") {
let template = Template(templateString: "{% filter lowercase|capitalize %}TEST{% endfilter %}")
let result = try template.render()
try expect(result) == "Test"
}
it("allows you to chain filters") {
let template = Template(templateString: "{% filter lowercase|capitalize %}TEST{% endfilter %}")
let result = try template.render()
try expect(result) == "Test"
}
it("errors without a filter") {
let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
try expect(try template.render()).toThrow()
}
it("errors without a filter") {
let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
try expect(try template.render()).toThrow()
}
it("can render filters with arguments") {
let ext = Extension()
ext.registerFilter("split") {
guard let value = $0 as? String,
let argument = $1.first as? String else { return $0 }
return value.components(separatedBy: argument)
}
let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: """
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": [1, 2]])
try expect(result) == "1;2"
}
it("can render filters with arguments") {
let ext = Extension()
ext.registerFilter("split") { value, args in
guard let value = value as? String,
let argument = args.first as? String else { return value }
return value.components(separatedBy: argument)
}
let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: """
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": [1, 2]])
try expect(result) == "1;2"
}
it("can render filters with quote as an argument") {
let ext = Extension()
ext.registerFilter("replace") {
guard let value = $0 as? String,
$1.count == 2,
let search = $1.first as? String,
let replacement = $1.last as? String else { return $0 }
return value.replacingOccurrences(of: search, with: replacement)
}
let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: """
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": ["\"1\"", "\"2\""]])
try expect(result) == "1,2"
}
}
it("can render filters with quote as an argument") {
let ext = Extension()
ext.registerFilter("replace") { value, args in
guard let value = value as? String,
args.count == 2,
let search = args.first as? String,
let replacement = args.last as? String else { return value }
return value.replacingOccurrences(of: search, with: replacement)
}
let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: """
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": ["\"1\"", "\"2\""]])
try expect(result) == "1,2"
}
}
}

View File

@@ -3,339 +3,589 @@ import Spectre
import XCTest
final class ForNodeTests: XCTestCase {
let context = Context(dictionary: [
"items": [1, 2, 3],
"anyItems": [1, 2, 3] as [Any],
"nsItems": NSArray(array: [1, 2, 3]),
"emptyItems": [Int](),
"dict": [
"one": "I",
"two": "II"
],
"tuples": [(1, 2, 3), (4, 5, 6)]
])
private let context = Context(dictionary: [
"items": [1, 2, 3],
"anyItems": [1, 2, 3] as [Any],
// swiftlint:disable:next legacy_objc_type
"nsItems": NSArray(array: [1, 2, 3]),
"emptyItems": [Int](),
"dict": [
"one": "I",
"two": "II"
],
"tuples": [(1, 2, 3), (4, 5, 6)]
])
func testForNode() {
it("renders the given nodes for each item") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "123"
}
func testForNode() {
it("renders the given nodes for each item") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "123"
}
it("renders the given empty nodes when no items found item") {
let node = ForNode(
resolvable: Variable("emptyItems"),
loopVariables: ["item"],
nodes: [VariableNode(variable: "item")],
emptyNodes: [TextNode(text: "empty")]
)
try expect(try node.render(self.context)) == "empty"
}
it("renders the given empty nodes when no items found item") {
let node = ForNode(
resolvable: Variable("emptyItems"),
loopVariables: ["item"],
nodes: [VariableNode(variable: "item")],
emptyNodes: [TextNode(text: "empty")]
)
try expect(try node.render(self.context)) == "empty"
}
it("renders a context variable of type Array<Any>") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("anyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "123"
}
it("renders a context variable of type Array<Any>") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("anyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "123"
}
#if os(OSX)
it("renders a context variable of type NSArray") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("nsItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "123"
}
#endif
#if os(OSX)
it("renders a context variable of type NSArray") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("nsItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "123"
}
#endif
it("can render a filter with spaces") {
let template = Template(templateString: """
{% for article in ars | default: a, b , articles %}\
- {{ article.title }} by {{ article.author }}.
{% endfor %}
""")
let context = Context(dictionary: [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller")
]
])
let result = try template.render(context)
it("can render a filter with spaces") {
let template = Template(templateString: """
{% for article in ars | default: a, b , articles %}\
- {{ article.title }} by {{ article.author }}.
{% endfor %}
""")
let context = Context(dictionary: [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller")
]
])
let result = try template.render(context)
try expect(result) == """
- Migrating from OCUnit to XCTest by Kyle Fuller.
- Memory Management with ARC by Kyle Fuller.
try expect(result) == """
- Migrating from OCUnit to XCTest by Kyle Fuller.
- Memory Management with ARC by Kyle Fuller.
"""
}
}
"""
}
}
func testLoopMetadata() {
it("renders the given nodes while providing if the item is first in the context") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "1true2false3false"
}
func testLoopMetadata() {
it("renders the given nodes while providing if the item is first in the context") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "1true2false3false"
}
it("renders the given nodes while providing if the item is last in the context") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.last")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "1false2false3true"
}
it("renders the given nodes while providing if the item is last in the context") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.last")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "1false2false3true"
}
it("renders the given nodes while providing item counter") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "112233"
}
it("renders the given nodes while providing item counter") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "112233"
}
it("renders the given nodes while providing item counter") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter0")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "102132"
}
it("renders the given nodes while providing item counter") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter0")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "102132"
}
it("renders the given nodes while providing loop length") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.length")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "132333"
}
}
it("renders the given nodes while providing loop length") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.length")]
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(self.context)) == "132333"
}
}
func testWhereExpression() {
it("renders the given nodes while filtering items using where expression") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
let parser = TokenParser(tokens: [], environment: Environment())
let `where` = try parser.compileExpression(components: ["item", ">", "1"], token: .text(value: "", at: .unknown))
let node = ForNode(
resolvable: Variable("items"),
loopVariables: ["item"],
nodes: nodes,
emptyNodes: [],
where: `where`
)
try expect(try node.render(self.context)) == "2132"
}
func testWhereExpression() {
it("renders the given nodes while filtering items using where expression") {
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
let parser = TokenParser(tokens: [], environment: Environment())
let `where` = try parser.compileExpression(components: ["item", ">", "1"], token: .text(value: "", at: .unknown))
let node = ForNode(
resolvable: Variable("items"),
loopVariables: ["item"],
nodes: nodes,
emptyNodes: [],
where: `where`
)
try expect(try node.render(self.context)) == "2132"
}
it("renders the given empty nodes when all items filtered out with where expression") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let parser = TokenParser(tokens: [], environment: Environment())
let `where` = try parser.compileExpression(components: ["item", "==", "0"], token: .text(value: "", at: .unknown))
let node = ForNode(
resolvable: Variable("emptyItems"),
loopVariables: ["item"],
nodes: nodes,
emptyNodes: emptyNodes,
where: `where`
)
try expect(try node.render(self.context)) == "empty"
}
}
it("renders the given empty nodes when all items filtered out with where expression") {
let nodes: [NodeType] = [VariableNode(variable: "item")]
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let parser = TokenParser(tokens: [], environment: Environment())
let `where` = try parser.compileExpression(components: ["item", "==", "0"], token: .text(value: "", at: .unknown))
let node = ForNode(
resolvable: Variable("emptyItems"),
loopVariables: ["item"],
nodes: nodes,
emptyNodes: emptyNodes,
where: `where`
)
try expect(try node.render(self.context)) == "empty"
}
}
func testArrayOfTuples() {
it("can iterate over all tuple values") {
let template = Template(templateString: """
{% for first,second,third in tuples %}\
{{ first }}, {{ second }}, {{ third }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1, 2, 3
4, 5, 6
func testArrayOfTuples() {
it("can iterate over all tuple values") {
let template = Template(templateString: """
{% for first,second,third in tuples %}\
{{ first }}, {{ second }}, {{ third }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1, 2, 3
4, 5, 6
"""
}
"""
}
it("can iterate with less number of variables") {
let template = Template(templateString: """
{% for first,second in tuples %}\
{{ first }}, {{ second }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1, 2
4, 5
it("can iterate with less number of variables") {
let template = Template(templateString: """
{% for first,second in tuples %}\
{{ first }}, {{ second }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1, 2
4, 5
"""
}
"""
}
it("can use _ to skip variables") {
let template = Template(templateString: """
{% for first,_,third in tuples %}\
{{ first }}, {{ third }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1, 3
4, 6
it("can use _ to skip variables") {
let template = Template(templateString: """
{% for first,_,third in tuples %}\
{{ first }}, {{ third }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1, 3
4, 6
"""
}
"""
}
it("throws when number of variables is more than number of tuple values") {
let template = Template(templateString: """
{% for key,value,smth in dict %}{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
it("throws when number of variables is more than number of tuple values") {
let template = Template(templateString: """
{% for key,value,smth in dict %}{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
func testIterateDictionary() {
it("can iterate over dictionary") {
let template = Template(templateString: """
{% for key, value in dict %}\
{{ key }}: {{ value }},\
{% endfor %}
""")
try expect(template.render(self.context)) == """
one: I,two: II,
"""
}
func testIterateDictionary() {
it("can iterate over dictionary") {
let template = Template(templateString: """
{% for key, value in dict %}\
{{ key }}: {{ value }},\
{% endfor %}
""")
try expect(template.render(self.context)) == """
one: I,two: II,
"""
}
it("renders supports iterating over dictionary") {
let nodes: [NodeType] = [
VariableNode(variable: "key"),
TextNode(text: ",")
]
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let node = ForNode(
resolvable: Variable("dict"),
loopVariables: ["key"],
nodes: nodes,
emptyNodes: emptyNodes
)
it("renders supports iterating over dictionary") {
let nodes: [NodeType] = [
VariableNode(variable: "key"),
TextNode(text: ",")
]
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let node = ForNode(
resolvable: Variable("dict"),
loopVariables: ["key"],
nodes: nodes,
emptyNodes: emptyNodes
)
try expect(node.render(self.context)) == """
one,two,
"""
}
try expect(node.render(self.context)) == """
one,two,
"""
}
it("renders supports iterating over dictionary with values") {
let nodes: [NodeType] = [
VariableNode(variable: "key"),
TextNode(text: "="),
VariableNode(variable: "value"),
TextNode(text: ",")
]
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let node = ForNode(
resolvable: Variable("dict"),
loopVariables: ["key", "value"],
nodes: nodes,
emptyNodes: emptyNodes
)
it("renders supports iterating over dictionary with values") {
let nodes: [NodeType] = [
VariableNode(variable: "key"),
TextNode(text: "="),
VariableNode(variable: "value"),
TextNode(text: ",")
]
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
let node = ForNode(
resolvable: Variable("dict"),
loopVariables: ["key", "value"],
nodes: nodes,
emptyNodes: emptyNodes
)
try expect(node.render(self.context)) == """
one=I,two=II,
"""
}
}
try expect(node.render(self.context)) == """
one=I,two=II,
"""
}
}
func testIterateUsingMirroring() {
let nodes: [NodeType] = [
VariableNode(variable: "label"),
TextNode(text: "="),
VariableNode(variable: "value"),
TextNode(text: "\n")
]
let node = ForNode(
resolvable: Variable("item"),
loopVariables: ["label", "value"],
nodes: nodes,
emptyNodes: []
)
func testIterateUsingMirroring() {
let nodes: [NodeType] = [
VariableNode(variable: "label"),
TextNode(text: "="),
VariableNode(variable: "value"),
TextNode(text: "\n")
]
let node = ForNode(
resolvable: Variable("item"),
loopVariables: ["label", "value"],
nodes: nodes,
emptyNodes: []
)
it("can iterate over struct properties") {
let context = Context(dictionary: [
"item": MyStruct(string: "abc", number: 123)
])
try expect(node.render(context)) == """
string=abc
number=123
it("can iterate over struct properties") {
let context = Context(dictionary: [
"item": MyStruct(string: "abc", number: 123)
])
try expect(node.render(context)) == """
string=abc
number=123
"""
}
"""
}
it("can iterate tuple items") {
let context = Context(dictionary: [
"item": (one: 1, two: "dva")
])
try expect(node.render(context)) == """
one=1
two=dva
it("can iterate tuple items") {
let context = Context(dictionary: [
"item": (one: 1, two: "dva")
])
try expect(node.render(context)) == """
one=1
two=dva
"""
}
"""
}
it("can iterate over class properties") {
let context = Context(dictionary: [
"item": MySubclass("child", "base", 1)
])
try expect(node.render(context)) == """
childString=child
baseString=base
baseInt=1
it("can iterate over class properties") {
let context = Context(dictionary: [
"item": MySubclass("child", "base", 1)
])
try expect(node.render(context)) == """
childString=child
baseString=base
baseInt=1
"""
}
}
"""
}
}
func testIterateRange() {
it("renders a context variable of type CountableClosedRange<Int>") {
let context = Context(dictionary: ["range": 1...3])
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("range"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
func testIterateRange() {
it("renders a context variable of type CountableClosedRange<Int>") {
let context = Context(dictionary: ["range": 1...3])
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("range"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(context)) == "123"
}
try expect(try node.render(context)) == "123"
}
it("renders a context variable of type CountableRange<Int>") {
let context = Context(dictionary: ["range": 1..<4])
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("range"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
it("renders a context variable of type CountableRange<Int>") {
let context = Context(dictionary: ["range": 1..<4])
let nodes: [NodeType] = [VariableNode(variable: "item")]
let node = ForNode(resolvable: Variable("range"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
try expect(try node.render(context)) == "123"
}
try expect(try node.render(context)) == "123"
}
it("can iterate in range of variables") {
let template: Template = "{% for i in 1...j %}{{ i }}{% endfor %}"
try expect(try template.render(Context(dictionary: ["j": 3]))) == "123"
}
}
it("can iterate in range of variables") {
let template: Template = "{% for i in 1...j %}{{ i }}{% endfor %}"
try expect(try template.render(Context(dictionary: ["j": 3]))) == "123"
}
}
func testHandleInvalidInput() throws {
let token = Token.block(value: "for i", at: .unknown)
let parser = TokenParser(tokens: [token], environment: Environment())
let error = TemplateSyntaxError(
reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.",
token: token
)
try expect(try parser.parse()).toThrow(error)
}
func testHandleInvalidInput() throws {
let token = Token.block(value: "for i", at: .unknown)
let parser = TokenParser(tokens: [token], environment: Environment())
let error = TemplateSyntaxError(
reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.",
token: token
)
try expect(try parser.parse()).toThrow(error)
}
func testBreak() {
it("can break from loop") {
let template = Template(templateString: """
{% for item in items %}\
{{ item }}{% break %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
1
"""
}
it("can break from inner node") {
let template = Template(templateString: """
{% for item in items %}\
{{ item }}\
{% if forloop.first %}<{% break %}>{% endif %}!\
{% endfor %}
""")
try expect(template.render(self.context)) == """
1<
"""
}
it("does not allow break outside loop") {
let template = Template(templateString: "{% for item in items %}{% endfor %}{% break %}")
let error = self.expectedSyntaxError(
token: "break",
template: template,
description: "'break' can be used only inside loop body"
)
try expect(template.render(self.context)).toThrow(error)
}
}
func testBreakNested() {
it("breaks outer loop") {
let template = Template(templateString: """
{% for item in items %}\
outer: {{ item }}
{% for item in items %}\
inner: {{ item }}
{% endfor %}\
{% break %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
inner: 1
inner: 2
inner: 3
"""
}
it("breaks inner loop") {
let template = Template(templateString: """
{% for item in items %}\
outer: {{ item }}
{% for item in items %}\
inner: {{ item }}
{% break %}\
{% endfor %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
inner: 1
outer: 2
inner: 1
outer: 3
inner: 1
"""
}
}
func testBreakLabeled() {
it("breaks labeled loop") {
let template = Template(templateString: """
{% outer: for item in items %}\
outer: {{ item }}
{% for item in items %}\
{% break outer %}\
inner: {{ item }}
{% endfor %}\
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
"""
}
it("throws when breaking with unknown label") {
let template = Template(templateString: """
{% outer: for item in items %}
{% break inner %}
{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
func testContinue() {
it("can continue loop") {
let template = Template(templateString: """
{% for item in items %}\
{{ item }}{% continue %}!\
{% endfor %}
""")
try expect(template.render(self.context)) == "123"
}
it("can continue from inner node") {
let template = Template(templateString: """
{% for item in items %}\
{% if forloop.last %}<{% continue %}>{% endif %}!\
{{ item }}\
{% endfor %}
""")
try expect(template.render(self.context)) == "!1!2<"
}
it("does not allow continue outside loop") {
let template = Template(templateString: "{% for item in items %}{% endfor %}{% continue %}")
let error = self.expectedSyntaxError(
token: "continue",
template: template,
description: "'continue' can be used only inside loop body"
)
try expect(template.render(self.context)).toThrow(error)
}
}
func testContinueNested() {
it("breaks outer loop") {
let template = Template(templateString: """
{% for item in items %}\
{% for item in items %}\
inner: {{ item }}\
{% endfor %}
{% continue %}
outer: {{ item }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
inner: 1inner: 2inner: 3
inner: 1inner: 2inner: 3
inner: 1inner: 2inner: 3
"""
}
it("breaks inner loop") {
let template = Template(templateString: """
{% for item in items %}\
{% for item in items %}\
{% continue %}\
inner: {{ item }}
{% endfor %}\
outer: {{ item }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
outer: 1
outer: 2
outer: 3
"""
}
}
func testContinueLabeled() {
it("continues labeled loop") {
let template = Template(templateString: """
{% outer: for item in items %}\
{% for item in items %}\
inner: {{ item }}
{% continue outer %}\
{% endfor %}\
outer: {{ item }}
{% endfor %}
""")
try expect(template.render(self.context)) == """
inner: 1
inner: 1
inner: 1
"""
}
it("throws when continuing with unknown label") {
let template = Template(templateString: """
{% outer: for item in items %}
{% continue inner %}
{% endfor %}
""")
try expect(template.render(self.context)).toThrow()
}
}
func testAccessLabeled() {
it("can access labeled outer loop context from inner loop") {
let template = Template(templateString: """
{% outer: for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer.counter }},\
{% endfor %}---\
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1,2-1,3-1,---1-2,2-2,3-2,---
"""
}
it("can access labeled outer loop from double inner loop") {
let template = Template(templateString: """
{% outer: for item in 1...2 %}{% for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer.counter }},\
{% endfor %}---{% endfor %}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1,2-1,3-1,---1-1,2-1,3-1,---
1-2,2-2,3-2,---1-2,2-2,3-2,---
"""
}
it("can access two labeled outer loop contexts from inner loop") {
let template = Template(templateString: """
{% outer1: for item in 1...2 %}{% outer2: for item in 1...2 %}\
{% for item in items %}\
{{ forloop.counter }}-{{ forloop.outer2.counter }}-{{ forloop.outer1.counter }},\
{% endfor %}---{% endfor %}
{% endfor %}
""")
try expect(template.render(self.context)) == """
1-1-1,2-1-1,3-1-1,---1-2-1,2-2-1,3-2-1,---
1-1-2,2-1-2,3-1-2,---1-2-2,2-2-2,3-2-2,---
"""
}
}
}
// MARK: - Helpers
private struct MyStruct {
let string: String
let number: Int
let string: String
let number: Int
}
private struct Article {
let title: String
let author: String
let title: String
let author: String
}
private class MyClass {
var baseString: String
var baseInt: Int
init(_ string: String, _ int: Int) {
baseString = string
baseInt = int
}
var baseString: String
var baseInt: Int
init(_ string: String, _ int: Int) {
baseString = string
baseInt = int
}
}
private class MySubclass: MyClass {
var childString: String
init(_ childString: String, _ string: String, _ int: Int) {
self.childString = childString
super.init(string, int)
}
var childString: String
init(_ childString: String, _ string: String, _ int: Int) {
self.childString = childString
super.init(string, int)
}
}

View File

@@ -0,0 +1,63 @@
import PathKit
import Spectre
@testable import Stencil
import XCTest
extension Expectation {
@discardableResult
func toThrow<E: Error>() throws -> E {
var thrownError: Error?
do {
_ = try expression()
} catch {
thrownError = error
}
if let thrownError = thrownError {
if let thrownError = thrownError as? E {
return thrownError
} else {
throw failure("\(thrownError) is not \(T.self)")
}
} else {
throw failure("expression did not throw an error")
}
}
}
extension XCTestCase {
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
guard let range = template.templateString.range(of: token) else {
fatalError("Can't find '\(token)' in '\(template)'")
}
let lexer = Lexer(templateString: template.templateString)
let location = lexer.rangeLocation(range)
let sourceMap = SourceMap(filename: template.name, location: location)
let token = Token.block(value: token, at: sourceMap)
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
}
}
// MARK: - Test Types
class ExampleLoader: Loader {
func loadTemplate(name: String, environment: Environment) throws -> Template {
if name == "example.html" {
return Template(templateString: "Hello World!", environment: environment, name: name)
}
throw TemplateDoesNotExist(templateNames: [name], loader: self)
}
}
class ErrorNode: NodeType {
let token: Token?
init(token: Token? = nil) {
self.token = token
}
func render(_ context: Context) throws -> String {
throw TemplateSyntaxError("Custom Error")
}
}

View File

@@ -2,287 +2,289 @@ import Spectre
@testable import Stencil
import XCTest
private struct SomeType {
let value: String? = nil
}
final class IfNodeTests: XCTestCase {
func testParseIf() {
it("can parse an if block") {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testParseIf() {
it("can parse an if block") {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let conditions = node?.conditions
try expect(conditions?.count) == 1
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
}
let conditions = node?.conditions
try expect(conditions?.count) == 1
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
}
it("can parse an if with complex expression") {
let tokens: [Token] = [
.block(value: """
if value == \"test\" and (not name or not (name and surname) or( some )and other )
""", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
it("can parse an if with complex expression") {
let tokens: [Token] = [
.block(value: """
if value == \"test\" and (not name or not (name and surname) or( some )and other )
""", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
try expect(nodes.first is IfNode).beTrue()
}
}
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
try expect(nodes.first is IfNode).beTrue()
}
}
func testParseIfWithElse() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testParseIfWithElse() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let conditions = node?.conditions
try expect(conditions?.count) == 2
let conditions = node?.conditions
try expect(conditions?.count) == 2
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[1].nodes.count) == 1
let falseNode = conditions?[1].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
try expect(conditions?[1].nodes.count) == 1
let falseNode = conditions?[1].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
func testParseIfWithElif() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "elif something", at: .unknown),
.text(value: "some", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testParseIfWithElif() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "elif something", at: .unknown),
.text(value: "some", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let conditions = node?.conditions
try expect(conditions?.count) == 3
let conditions = node?.conditions
try expect(conditions?.count) == 3
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[1].nodes.count) == 1
let elifNode = conditions?[1].nodes.first as? TextNode
try expect(elifNode?.text) == "some"
try expect(conditions?[1].nodes.count) == 1
let elifNode = conditions?[1].nodes.first as? TextNode
try expect(elifNode?.text) == "some"
try expect(conditions?[2].nodes.count) == 1
let falseNode = conditions?[2].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
try expect(conditions?[2].nodes.count) == 1
let falseNode = conditions?[2].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
func testParseIfWithElifWithoutElse() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "elif something", at: .unknown),
.text(value: "some", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testParseIfWithElifWithoutElse() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "elif something", at: .unknown),
.text(value: "some", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let conditions = node?.conditions
try expect(conditions?.count) == 2
let conditions = node?.conditions
try expect(conditions?.count) == 2
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[1].nodes.count) == 1
let elifNode = conditions?[1].nodes.first as? TextNode
try expect(elifNode?.text) == "some"
}
try expect(conditions?[1].nodes.count) == 1
let elifNode = conditions?[1].nodes.first as? TextNode
try expect(elifNode?.text) == "some"
}
func testParseMultipleElif() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "elif something1", at: .unknown),
.text(value: "some1", at: .unknown),
.block(value: "elif something2", at: .unknown),
.text(value: "some2", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testParseMultipleElif() throws {
let tokens: [Token] = [
.block(value: "if value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "elif something1", at: .unknown),
.text(value: "some1", at: .unknown),
.block(value: "elif something2", at: .unknown),
.text(value: "some2", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let conditions = node?.conditions
try expect(conditions?.count) == 4
let conditions = node?.conditions
try expect(conditions?.count) == 4
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[1].nodes.count) == 1
let elifNode = conditions?[1].nodes.first as? TextNode
try expect(elifNode?.text) == "some1"
try expect(conditions?[1].nodes.count) == 1
let elifNode = conditions?[1].nodes.first as? TextNode
try expect(elifNode?.text) == "some1"
try expect(conditions?[2].nodes.count) == 1
let elif2Node = conditions?[2].nodes.first as? TextNode
try expect(elif2Node?.text) == "some2"
try expect(conditions?[2].nodes.count) == 1
let elif2Node = conditions?[2].nodes.first as? TextNode
try expect(elif2Node?.text) == "some2"
try expect(conditions?[3].nodes.count) == 1
let falseNode = conditions?[3].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
try expect(conditions?[3].nodes.count) == 1
let falseNode = conditions?[3].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
func testParseIfnot() throws {
let tokens: [Token] = [
.block(value: "ifnot value", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testParseIfnot() throws {
let tokens: [Token] = [
.block(value: "ifnot value", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let conditions = node?.conditions
try expect(conditions?.count) == 2
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IfNode
let conditions = node?.conditions
try expect(conditions?.count) == 2
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[0].nodes.count) == 1
let trueNode = conditions?[0].nodes.first as? TextNode
try expect(trueNode?.text) == "true"
try expect(conditions?[1].nodes.count) == 1
let falseNode = conditions?[1].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
try expect(conditions?[1].nodes.count) == 1
let falseNode = conditions?[1].nodes.first as? TextNode
try expect(falseNode?.text) == "false"
}
func testParsingErrors() {
it("throws an error when parsing an if block without an endif") {
let tokens: [Token] = [.block(value: "if value", at: .unknown)]
func testParsingErrors() {
it("throws an error when parsing an if block without an endif") {
let tokens: [Token] = [.block(value: "if value", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
try expect(try parser.parse()).toThrow(error)
}
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
try expect(try parser.parse()).toThrow(error)
}
it("throws an error when parsing an ifnot without an endif") {
let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
it("throws an error when parsing an ifnot without an endif") {
let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
try expect(try parser.parse()).toThrow(error)
}
}
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
try expect(try parser.parse()).toThrow(error)
}
}
func testRendering() {
it("renders a true expression") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
func testRendering() {
it("renders a true expression") {
let node = IfNode(conditions: [
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
try expect(try node.render(Context())) == "1"
}
try expect(try node.render(Context())) == "1"
}
it("renders the first true expression") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
it("renders the first true expression") {
let node = IfNode(conditions: [
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
try expect(try node.render(Context())) == "2"
}
try expect(try node.render(Context())) == "2"
}
it("renders the empty expression when other conditions are falsy") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
it("renders the empty expression when other conditions are falsy") {
let node = IfNode(conditions: [
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "2")]),
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
])
try expect(try node.render(Context())) == "3"
}
try expect(try node.render(Context())) == "3"
}
it("renders empty when no truthy conditions") {
let node = IfNode(conditions: [
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")])
])
it("renders empty when no truthy conditions") {
let node = IfNode(conditions: [
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "2")])
])
try expect(try node.render(Context())) == ""
}
}
try expect(try node.render(Context())) == ""
}
}
func testSupportVariableFilters() throws {
let tokens: [Token] = [
.block(value: "if value|uppercase == \"TEST\"", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testSupportVariableFilters() throws {
let tokens: [Token] = [
.block(value: "if value|uppercase == \"TEST\"", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let result = try renderNodes(nodes, Context(dictionary: ["value": "test"]))
try expect(result) == "true"
}
let result = try renderNodes(nodes, Context(dictionary: ["value": "test"]))
try expect(result) == "true"
}
func testEvaluatesNilAsFalse() throws {
let tokens: [Token] = [
.block(value: "if instance.value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testEvaluatesNilAsFalse() throws {
let tokens: [Token] = [
.block(value: "if instance.value", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
try expect(result) == ""
}
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
try expect(result) == ""
}
func testSupportsRangeVariables() throws {
let tokens: [Token] = [
.block(value: "if value in 1...3", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
func testSupportsRangeVariables() throws {
let tokens: [Token] = [
.block(value: "if value in 1...3", at: .unknown),
.text(value: "true", at: .unknown),
.block(value: "else", at: .unknown),
.text(value: "false", at: .unknown),
.block(value: "endif", at: .unknown)
]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true"
try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false"
}
try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true"
try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false"
}
}
// MARK: - Helpers
private struct SomeType {
let value: String? = nil
}

View File

@@ -4,69 +4,69 @@ import Spectre
import XCTest
final class IncludeTests: XCTestCase {
let path = Path(#file as String) + ".." + "fixtures"
lazy var loader = FileSystemLoader(paths: [path])
lazy var environment = Environment(loader: loader)
private let path = Path(#file as String)! / ".." / "fixtures"
private lazy var loader = FileSystemLoader(paths: [path])
private lazy var environment = Environment(loader: loader)
func testParsing() {
it("throws an error when no template is given") {
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
func testParsing() {
it("throws an error when no template is given") {
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let error = TemplateSyntaxError(reason: """
'include' tag requires one argument, the template file to be included. \
A second optional argument can be used to specify the context that will \
be passed to the included file
""", token: tokens.first)
try expect(try parser.parse()).toThrow(error)
}
let error = TemplateSyntaxError(reason: """
'include' tag requires one argument, the template file to be included. \
A second optional argument can be used to specify the context that will \
be passed to the included file
""", token: tokens.first)
try expect(try parser.parse()).toThrow(error)
}
it("can parse a valid include block") {
let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
it("can parse a valid include block") {
let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? IncludeNode
try expect(nodes.count) == 1
try expect(node?.templateName) == Variable("\"test.html\"")
}
}
let nodes = try parser.parse()
let node = nodes.first as? IncludeNode
try expect(nodes.count) == 1
try expect(node?.templateName) == Variable("\"test.html\"")
}
}
func testRendering() {
it("throws an error when rendering without a loader") {
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
func testRendering() {
it("throws an error when rendering without a loader") {
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
do {
_ = try node.render(Context())
} catch {
try expect("\(error)") == "Template named `test.html` does not exist. No loaders found"
}
}
do {
_ = try node.render(Context())
} catch {
try expect("\(error)") == "Template named `test.html` does not exist. No loaders found"
}
}
it("throws an error when it cannot find the included template") {
let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
it("throws an error when it cannot find the included template") {
let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
do {
_ = try node.render(Context(environment: self.environment))
} catch {
try expect("\(error)".hasPrefix("Template named `unknown.html` does not exist in loader")).to.beTrue()
}
}
do {
_ = try node.render(Context(environment: self.environment))
} catch {
try expect("\(error)".hasPrefix("Template named `unknown.html` does not exist in loader")).to.beTrue()
}
}
it("successfully renders a found included template") {
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
let context = Context(dictionary: ["target": "World"], environment: self.environment)
let value = try node.render(context)
try expect(value) == "Hello World!"
}
it("successfully renders a found included template") {
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
let context = Context(dictionary: ["target": "World"], environment: self.environment)
let value = try node.render(context)
try expect(value) == "Hello World!"
}
it("successfully passes context") {
let template = Template(templateString: """
{% include "test.html" child %}
""")
let context = Context(dictionary: ["child": ["target": "World"]], environment: self.environment)
let value = try template.render(context)
try expect(value) == "Hello World!"
}
}
it("successfully passes context") {
let template = Template(templateString: """
{% include "test.html" child %}
""")
let context = Context(dictionary: ["child": ["target": "World"]], environment: self.environment)
let value = try template.render(context)
try expect(value) == "Hello World!"
}
}
}

View File

@@ -4,33 +4,70 @@ import Stencil
import XCTest
final class InheritanceTests: XCTestCase {
let path = Path(#file as String) + ".." + "fixtures"
lazy var loader = FileSystemLoader(paths: [path])
lazy var environment = Environment(loader: loader)
private let path = Path(#file as String)! / ".." / "fixtures"
private lazy var loader = FileSystemLoader(paths: [path])
private lazy var environment = Environment(loader: loader)
func testInheritance() {
it("can inherit from another template") {
let template = try self.environment.loadTemplate(name: "child.html")
try expect(try template.render()) == """
Super_Header Child_Header
Child_Body
"""
}
func testInheritance() {
it("can inherit from another template") {
let template = try self.environment.loadTemplate(name: "child.html")
try expect(try template.render()) == """
Super_Header Child_Header
Child_Body
"""
}
it("can inherit from another template inheriting from another template") {
let template = try self.environment.loadTemplate(name: "child-child.html")
try expect(try template.render()) == """
Super_Header Child_Header Child_Child_Header
Child_Body
"""
}
it("can inherit from another template inheriting from another template") {
let template = try self.environment.loadTemplate(name: "child-child.html")
try expect(try template.render()) == """
Super_Header Child_Header Child_Child_Header
Child_Body
"""
}
it("can inherit from a template that calls a super block") {
let template = try self.environment.loadTemplate(name: "child-super.html")
try expect(try template.render()) == """
Header
Child_Body
"""
}
}
it("can inherit from a template that calls a super block") {
let template = try self.environment.loadTemplate(name: "child-super.html")
try expect(try template.render()) == """
Header
Child_Body
"""
}
it("can render block.super in if tag") {
let template = try self.environment.loadTemplate(name: "if-block-child.html")
try expect(try template.render(["sort": "new"])) == """
Title - Nieuwste spellen
"""
try expect(try template.render(["sort": "upcoming"])) == """
Title - Binnenkort op de agenda
"""
try expect(try template.render(["sort": "near-me"])) == """
Title - In mijn buurt
"""
}
}
func testInheritanceCache() {
it("can call block twice") {
let template: Template = "{% block repeat %}Block{% endblock %}{{ block.repeat }}"
try expect(try template.render()) == "BlockBlock"
}
it("renders child content when calling block twice in base template") {
let template = try self.environment.loadTemplate(name: "child-repeat.html")
try expect(try template.render()) == """
Super_Header Child_Header
Child_Body
Repeat
Super_Header Child_Header
Child_Body
"""
}
}
}

View File

@@ -4,85 +4,102 @@ import Spectre
import XCTest
final class LexerTests: XCTestCase {
func testText() throws {
let lexer = Lexer(templateString: "Hello World")
let tokens = lexer.tokenize()
func testText() throws {
let lexer = Lexer(templateString: "Hello World")
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "Hello World", at: makeSourceMap("Hello World", for: lexer))
}
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "Hello World", at: makeSourceMap("Hello World", for: lexer))
}
func testComment() throws {
let lexer = Lexer(templateString: "{# Comment #}")
let tokens = lexer.tokenize()
func testComment() throws {
let lexer = Lexer(templateString: "{# Comment #}")
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .comment(value: "Comment", at: makeSourceMap("Comment", for: lexer))
}
try expect(tokens.count) == 1
try expect(tokens.first) == .comment(value: "Comment", at: makeSourceMap("Comment", for: lexer))
}
func testVariable() throws {
let lexer = Lexer(templateString: "{{ Variable }}")
let tokens = lexer.tokenize()
func testEscapedVariableToken() throws {
let lexer = Lexer(templateString: "\\{{ Variable }}")
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
}
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "{{ Variable }}", at: makeSourceMap("{{ Variable }}", for: lexer))
}
func testTokenWithoutSpaces() throws {
let lexer = Lexer(templateString: "{{Variable}}")
let tokens = lexer.tokenize()
func testEscapedBehaviourToken() throws {
let lexer = Lexer(templateString: "\\{% Variable %}")
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
}
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "{% Variable %}", at: makeSourceMap("{% Variable %}", for: lexer))
}
func testUnclosedTag() throws {
let templateString = "{{ thing"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
func testVariable() throws {
let lexer = Lexer(templateString: "{{ Variable }}")
let tokens = lexer.tokenize()
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "", at: makeSourceMap("{{ thing", for: lexer))
}
try expect(tokens.count) == 1
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
}
func testContentMixture() throws {
let templateString = "My name is {{ myname }}."
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
func testTokenWithoutSpaces() throws {
let lexer = Lexer(templateString: "{{Variable}}")
let tokens = lexer.tokenize()
try expect(tokens.count) == 3
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
try expect(tokens[1]) == Token.variable(value: "myname", at: makeSourceMap("myname", for: lexer))
try expect(tokens[2]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
}
try expect(tokens.count) == 1
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
}
func testVariablesWithoutBeingGreedy() throws {
let templateString = "{{ thing }}{{ name }}"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
func testUnclosedTag() throws {
let templateString = "{{ thing"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 2
try expect(tokens[0]) == Token.variable(value: "thing", at: makeSourceMap("thing", for: lexer))
try expect(tokens[1]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer))
}
try expect(tokens.count) == 1
try expect(tokens.first) == .text(value: "", at: makeSourceMap("{{ thing", for: lexer))
}
func testUnclosedBlock() throws {
let lexer = Lexer(templateString: "{%}")
_ = lexer.tokenize()
}
func testContentMixture() throws {
let templateString = "My name is {{ myname }}."
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
func testTokenizeIncorrectSyntaxWithoutCrashing() throws {
let lexer = Lexer(templateString: "func some() {{% if %}")
_ = lexer.tokenize()
}
try expect(tokens.count) == 3
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
try expect(tokens[1]) == .variable(value: "myname", at: makeSourceMap("myname", for: lexer))
try expect(tokens[2]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
}
func testEmptyVariable() throws {
let lexer = Lexer(templateString: "{{}}")
_ = lexer.tokenize()
}
func testVariablesWithoutBeingGreedy() throws {
let templateString = "{{ thing }}{{ name }}"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
func testNewlines() throws {
let templateString = """
try expect(tokens.count) == 2
try expect(tokens[0]) == .variable(value: "thing", at: makeSourceMap("thing", for: lexer))
try expect(tokens[1]) == .variable(value: "name", at: makeSourceMap("name", for: lexer))
}
func testUnclosedBlock() throws {
let lexer = Lexer(templateString: "{%}")
_ = lexer.tokenize()
}
func testTokenizeIncorrectSyntaxWithoutCrashing() throws {
let lexer = Lexer(templateString: "func some() {{% if %}")
_ = lexer.tokenize()
}
func testEmptyVariable() throws {
let lexer = Lexer(templateString: "{{}}")
_ = lexer.tokenize()
}
func testNewlines() throws {
// swiftlint:disable indentation_width
let templateString = """
My name is {%
if name
and
@@ -92,53 +109,69 @@ final class LexerTests: XCTestCase {
}}{%
endif %}.
"""
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
// swiftlint:enable indentation_width
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
try expect(tokens[1]) == Token.block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
try expect(tokens[2]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
try expect(tokens[3]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
try expect(tokens[4]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
}
try expect(tokens.count) == 5
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
try expect(tokens[1]) == .block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
try expect(tokens[2]) == .variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
try expect(tokens[3]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
try expect(tokens[4]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
}
func testEscapeSequence() throws {
let templateString = "class Some {{ '{' }}{% if true %}{{ stuff }}{% endif %}"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
func testTrimSymbols() throws {
let fBlock = "if hello"
let sBlock = "ta da"
let lexer = Lexer(templateString: "{%+ \(fBlock) -%}{% \(sBlock) -%}")
let tokens = lexer.tokenize()
let behaviours = (
WhitespaceBehaviour(leading: .keep, trailing: .trim),
WhitespaceBehaviour(leading: .unspecified, trailing: .trim)
)
try expect(tokens.count) == 5
try expect(tokens[0]) == Token.text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
try expect(tokens[1]) == Token.variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
try expect(tokens[2]) == Token.block(value: "if true", at: makeSourceMap("if true", for: lexer))
try expect(tokens[3]) == Token.variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
try expect(tokens[4]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
}
try expect(tokens.count) == 2
try expect(tokens[0]) == .block(value: fBlock, at: makeSourceMap(fBlock, for: lexer), whitespace: behaviours.0)
try expect(tokens[1]) == .block(value: sBlock, at: makeSourceMap(sBlock, for: lexer), whitespace: behaviours.1)
}
func testPerformance() throws {
let path = Path(#file as String) + ".." + "fixtures" + "huge.html"
let content: String = try path.read()
func testEscapeSequence() throws {
let templateString = "class Some {{ '{' }}{% if true %}{{ stuff }}{% endif %}"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
measure {
let lexer = Lexer(templateString: content)
_ = lexer.tokenize()
}
}
try expect(tokens.count) == 5
try expect(tokens[0]) == .text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
try expect(tokens[1]) == .variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
try expect(tokens[2]) == .block(value: "if true", at: makeSourceMap("if true", for: lexer))
try expect(tokens[3]) == .variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
try expect(tokens[4]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
}
func testCombiningDiaeresis() throws {
// the symbol "ü" in the `templateString` is unusually encoded as 0x75 0xCC 0x88 (LATIN SMALL LETTER U + COMBINING
// DIAERESIS) instead of 0xC3 0xBC (LATIN SMALL LETTER U WITH DIAERESIS)
let templateString = "\n{% if test %}ü{% endif %}\n{% if ü %}ü{% endif %}\n"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
func testPerformance() throws {
let path = Path(#file as String)! / ".." / "fixtures" / "huge.html"
let content: String = try NSString(contentsOfFile: path.string, encoding: String.Encoding.utf8.rawValue).substring(from: 0) as String
try expect(tokens.count) == 9
assert(tokens[1].contents == "if test")
}
measure {
let lexer = Lexer(templateString: content)
_ = lexer.tokenize()
}
}
private func makeSourceMap(_ token: String, for lexer: Lexer, options: String.CompareOptions = []) -> SourceMap {
guard let range = lexer.templateString.range(of: token, options: options) else { fatalError("Token not found") }
return SourceMap(location: lexer.rangeLocation(range))
}
func testCombiningDiaeresis() throws {
// the symbol "ü" in the `templateString` is unusually encoded as 0x75 0xCC 0x88 (LATIN SMALL LETTER U + COMBINING
// DIAERESIS) instead of 0xC3 0xBC (LATIN SMALL LETTER U WITH DIAERESIS)
let templateString = "\n{% if test %}ü{% endif %}\n{% if ü %}ü{% endif %}\n"
let lexer = Lexer(templateString: templateString)
let tokens = lexer.tokenize()
try expect(tokens.count) == 9
assert(tokens[1].contents == "if test")
}
private func makeSourceMap(_ token: String, for lexer: Lexer, options: String.CompareOptions = []) -> SourceMap {
guard let range = lexer.templateString.range(of: token, options: options) else { fatalError("Token not found") }
return SourceMap(location: lexer.rangeLocation(range))
}
}

View File

@@ -4,52 +4,52 @@ import Stencil
import XCTest
final class TemplateLoaderTests: XCTestCase {
func testFileSystemLoader() {
let path = Path(#file as String) + ".." + "fixtures"
let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader)
func testFileSystemLoader() {
let path = Path(#file as String)! / ".." / "fixtures"
let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader)
it("errors when a template cannot be found") {
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
}
it("errors when a template cannot be found") {
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
}
it("errors when an array of templates cannot be found") {
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
}
it("errors when an array of templates cannot be found") {
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
}
it("can load a template from a file") {
_ = try environment.loadTemplate(name: "test.html")
}
it("can load a template from a file") {
_ = try environment.loadTemplate(name: "test.html")
}
it("errors when loading absolute file outside of the selected path") {
try expect(try environment.loadTemplate(name: "/etc/hosts")).toThrow()
}
it("errors when loading absolute file outside of the selected path") {
try expect(try environment.loadTemplate(name: "/etc/hosts")).toThrow()
}
it("errors when loading relative file outside of the selected path") {
try expect(try environment.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
}
}
it("errors when loading relative file outside of the selected path") {
try expect(try environment.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
}
}
func testDictionaryLoader() {
let loader = DictionaryLoader(templates: [
"index.html": "Hello World"
])
let environment = Environment(loader: loader)
func testDictionaryLoader() {
let loader = DictionaryLoader(templates: [
"index.html": "Hello World"
])
let environment = Environment(loader: loader)
it("errors when a template cannot be found") {
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
}
it("errors when a template cannot be found") {
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
}
it("errors when an array of templates cannot be found") {
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
}
it("errors when an array of templates cannot be found") {
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
}
it("can load a template from a known templates") {
_ = try environment.loadTemplate(name: "index.html")
}
it("can load a template from a known templates") {
_ = try environment.loadTemplate(name: "index.html")
}
it("can load a known template from a collection of templates") {
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
}
}
it("can load a known template from a collection of templates") {
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
}
}
}

View File

@@ -2,61 +2,110 @@ import Spectre
@testable import Stencil
import XCTest
class ErrorNode: NodeType {
let token: Token?
init(token: Token? = nil) {
self.token = token
}
func render(_ context: Context) throws -> String {
throw TemplateSyntaxError("Custom Error")
}
}
final class NodeTests: XCTestCase {
let context = Context(dictionary: [
"name": "Kyle",
"age": 27,
"items": [1, 2, 3]
])
private let context = Context(dictionary: [
"name": "Kyle",
"age": 27,
"items": [1, 2, 3]
])
func testTextNode() {
it("renders the given text") {
let node = TextNode(text: "Hello World")
try expect(try node.render(self.context)) == "Hello World"
}
}
func testTextNode() {
it("renders the given text") {
let node = TextNode(text: "Hello World")
try expect(try node.render(self.context)) == "Hello World"
}
it("Trims leading whitespace") {
let text = " \n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespace, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "\n Some text "
}
it("Trims leading whitespace and one newline") {
let text = "\n\n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndOneNewLine, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "\n Some text "
}
it("Trims leading whitespace and one newline") {
let text = "\n\n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "Some text "
}
it("Trims trailing whitespace") {
let text = " Some text \n"
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespace)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text\n"
}
it("Trims trailing whitespace and one newline") {
let text = " Some text \n \n "
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndOneNewLine)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text \n "
}
it("Trims trailing whitespace and newlines") {
let text = " Some text \n \n "
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndNewLines)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text"
}
it("Trims all whitespace") {
let text = " \n \nSome text \n "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "Some text"
}
}
func testVariableNode() {
it("resolves and renders the variable") {
let node = VariableNode(variable: Variable("name"))
try expect(try node.render(self.context)) == "Kyle"
}
func testVariableNode() {
it("resolves and renders the variable") {
let node = VariableNode(variable: Variable("name"))
try expect(try node.render(self.context)) == "Kyle"
}
it("resolves and renders a non string variable") {
let node = VariableNode(variable: Variable("age"))
try expect(try node.render(self.context)) == "27"
}
}
it("resolves and renders a non string variable") {
let node = VariableNode(variable: Variable("age"))
try expect(try node.render(self.context)) == "27"
}
}
func testRendering() {
it("renders the nodes") {
let nodes: [NodeType] = [
TextNode(text: "Hello "),
VariableNode(variable: "name")
]
func testRendering() {
it("renders the nodes") {
let nodes: [NodeType] = [
TextNode(text: "Hello "),
VariableNode(variable: "name")
]
try expect(try renderNodes(nodes, self.context)) == "Hello Kyle"
}
try expect(try renderNodes(nodes, self.context)) == "Hello Kyle"
}
it("correctly throws a nodes failure") {
let nodes: [NodeType] = [
TextNode(text: "Hello "),
VariableNode(variable: "name"),
ErrorNode()
]
it("correctly throws a nodes failure") {
let nodes: [NodeType] = [
TextNode(text: "Hello "),
VariableNode(variable: "name"),
ErrorNode()
]
try expect(try renderNodes(nodes, self.context)).toThrow(TemplateSyntaxError("Custom Error"))
}
}
try expect(try renderNodes(nodes, self.context)).toThrow(TemplateSyntaxError("Custom Error"))
}
}
func testRenderingBooleans() {
it("can render true & false") {
try expect(Template(templateString: "{{ true }}").render()) == "true"
try expect(Template(templateString: "{{ false }}").render()) == "false"
}
it("can resolve variable") {
let template = Template(templateString: "{{ value == \"known\" }}")
try expect(template.render(["value": "known"])) == "true"
try expect(template.render(["value": "unknown"])) == "false"
}
it("can render a boolean expression") {
try expect(Template(templateString: "{{ 1 > 0 }}").render()) == "true"
try expect(Template(templateString: "{{ 1 == 2 }}").render()) == "false"
}
}
}

View File

@@ -3,48 +3,48 @@ import Spectre
import XCTest
final class NowNodeTests: XCTestCase {
func testParsing() {
it("parses default format without any now arguments") {
#if os(Linux)
throw skip()
#else
let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
func testParsing() {
it("parses default format without any now arguments") {
#if os(Linux)
throw skip()
#else
let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? NowNode
try expect(nodes.count) == 1
try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
#endif
}
let nodes = try parser.parse()
let node = nodes.first as? NowNode
try expect(nodes.count) == 1
try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
#endif
}
it("parses now with a format") {
#if os(Linux)
throw skip()
#else
let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? NowNode
try expect(nodes.count) == 1
try expect(node?.format.variable) == "\"HH:mm\""
#endif
}
}
it("parses now with a format") {
#if os(Linux)
throw skip()
#else
let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? NowNode
try expect(nodes.count) == 1
try expect(node?.format.variable) == "\"HH:mm\""
#endif
}
}
func testRendering() {
it("renders the date") {
#if os(Linux)
throw skip()
#else
let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
func testRendering() {
it("renders the date") {
#if os(Linux)
throw skip()
#else
let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let date = formatter.string(from: NSDate() as Date)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let date = formatter.string(from: Date())
try expect(try node.render(Context())) == date
#endif
}
}
try expect(try node.render(Context())) == date
#endif
}
}
}

View File

@@ -3,62 +3,77 @@ import Spectre
import XCTest
final class TokenParserTests: XCTestCase {
func testTokenParser() {
it("can parse a text token") {
let parser = TokenParser(tokens: [
.text(value: "Hello World", at: .unknown)
], environment: Environment())
func testTextToken() throws {
let parser = TokenParser(tokens: [
.text(value: "Hello World", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? TextNode
let nodes = try parser.parse()
let node = nodes.first as? TextNode
try expect(nodes.count) == 1
try expect(node?.text) == "Hello World"
}
try expect(nodes.count) == 1
try expect(node?.text) == "Hello World"
}
it("can parse a variable token") {
let parser = TokenParser(tokens: [
.variable(value: "'name'", at: .unknown)
], environment: Environment())
func testVariableToken() throws {
let parser = TokenParser(tokens: [
.variable(value: "'name'", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
let node = nodes.first as? VariableNode
try expect(nodes.count) == 1
let result = try node?.render(Context())
try expect(result) == "name"
}
let nodes = try parser.parse()
let node = nodes.first as? VariableNode
try expect(nodes.count) == 1
let result = try node?.render(Context())
try expect(result) == "name"
}
it("can parse a comment token") {
let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!", at: .unknown)
], environment: Environment())
func testCommentToken() throws {
let parser = TokenParser(tokens: [
.comment(value: "Secret stuff!", at: .unknown)
], environment: Environment())
let nodes = try parser.parse()
try expect(nodes.count) == 0
}
let nodes = try parser.parse()
try expect(nodes.count) == 0
}
it("can parse a tag token") {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in
""
}
func testTagToken() throws {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in
""
}
let parser = TokenParser(tokens: [
.block(value: "known", at: .unknown)
], environment: Environment(extensions: [simpleExtension]))
let parser = TokenParser(tokens: [
.block(value: "known", at: .unknown)
], environment: Environment(extensions: [simpleExtension]))
let nodes = try parser.parse()
try expect(nodes.count) == 1
}
let nodes = try parser.parse()
try expect(nodes.count) == 1
}
it("errors when parsing an unknown tag") {
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
func testErrorUnknownTag() throws {
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
let parser = TokenParser(tokens: tokens, environment: Environment())
try expect(try parser.parse()).toThrow(TemplateSyntaxError(
reason: "Unknown template tag 'unknown'",
token: tokens.first)
)
}
}
try expect(try parser.parse()).toThrow(TemplateSyntaxError(
reason: "Unknown template tag 'unknown'",
token: tokens.first
))
}
func testTransformWhitespaceBehaviourToTrimBehaviour() throws {
let simpleExtension = Extension()
simpleExtension.registerSimpleTag("known") { _ in "" }
let parser = TokenParser(tokens: [
.block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .unspecified, trailing: .trim)),
.text(value: " \nSome text ", at: .unknown),
.block(value: "known", at: .unknown, whitespace: WhitespaceBehaviour(leading: .keep, trailing: .trim))
], environment: Environment(extensions: [simpleExtension]))
let nodes = try parser.parse()
try expect(nodes.count) == 3
let textNode = nodes[1] as? TextNode
try expect(textNode?.text) == " \nSome text "
try expect(textNode?.trimBehaviour) == TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
}
}

View File

@@ -2,67 +2,69 @@ import Spectre
import Stencil
import XCTest
final class StencilTests: XCTestCase {
private lazy var environment: Environment = {
let exampleExtension = Extension()
exampleExtension.registerSimpleTag("simpletag") { _ in
"Hello World"
}
exampleExtension.registerTag("customtag") { _, token in
CustomNode(token: token)
}
return Environment(extensions: [exampleExtension])
}()
func testStencil() {
it("can render the README example") {
let templateString = """
There are {{ articles.count }} articles.
{% for article in articles %}\
- {{ article.title }} by {{ article.author }}.
{% endfor %}
"""
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller")
]
]
let template = Template(templateString: templateString)
let result = try template.render(context)
try expect(result) == """
There are 2 articles.
- Migrating from OCUnit to XCTest by Kyle Fuller.
- Memory Management with ARC by Kyle Fuller.
"""
}
it("can render a custom template tag") {
let result = try self.environment.renderTemplate(string: "{% customtag %}")
try expect(result) == "Hello World"
}
it("can render a simple custom tag") {
let result = try self.environment.renderTemplate(string: "{% simpletag %}")
try expect(result) == "Hello World"
}
}
}
// MARK: - Helpers
private struct CustomNode: NodeType {
let token: Token?
func render(_ context: Context) throws -> String {
return "Hello World"
}
let token: Token?
func render(_ context: Context) throws -> String {
"Hello World"
}
}
private struct Article {
let title: String
let author: String
}
final class StencilTests: XCTestCase {
lazy var environment: Environment = {
let exampleExtension = Extension()
exampleExtension.registerSimpleTag("simpletag") { _ in
"Hello World"
}
exampleExtension.registerTag("customtag") { _, token in
CustomNode(token: token)
}
return Environment(extensions: [exampleExtension])
}()
func testStencil() {
it("can render the README example") {
let templateString = """
There are {{ articles.count }} articles.
{% for article in articles %}\
- {{ article.title }} by {{ article.author }}.
{% endfor %}
"""
let context = [
"articles": [
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
Article(title: "Memory Management with ARC", author: "Kyle Fuller")
]
]
let template = Template(templateString: templateString)
let result = try template.render(context)
try expect(result) == """
There are 2 articles.
- Migrating from OCUnit to XCTest by Kyle Fuller.
- Memory Management with ARC by Kyle Fuller.
"""
}
it("can render a custom template tag") {
let result = try self.environment.renderTemplate(string: "{% customtag %}")
try expect(result) == "Hello World"
}
it("can render a simple custom tag") {
let result = try self.environment.renderTemplate(string: "{% simpletag %}")
try expect(result) == "Hello World"
}
}
let title: String
let author: String
}

View File

@@ -3,17 +3,23 @@ import Spectre
import XCTest
final class TemplateTests: XCTestCase {
func testTemplate() {
it("can render a template from a string") {
let template = Template(templateString: "Hello World")
let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello World"
}
func testTemplate() {
it("can render a template from a string") {
let template = Template(templateString: "Hello World")
let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello World"
}
it("can render a template from a string literal") {
let template: Template = "Hello World"
let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello World"
}
}
it("can render a template from a string literal") {
let template: Template = "Hello World"
let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello World"
}
it("can render a template with escaped token") {
let template: Template = "Hello \\{{ name }}"
let result = try template.render([ "name": "Kyle" ])
try expect(result) == "Hello {{ name }}"
}
}
}

View File

@@ -3,32 +3,32 @@ import Spectre
import XCTest
final class TokenTests: XCTestCase {
func testToken() {
it("can split the contents into components") {
let token = Token.text(value: "hello world", at: .unknown)
let components = token.components
func testToken() {
it("can split the contents into components") {
let token = Token.text(value: "hello world", at: .unknown)
let components = token.components
try expect(components.count) == 2
try expect(components[0]) == "hello"
try expect(components[1]) == "world"
}
try expect(components.count) == 2
try expect(components[0]) == "hello"
try expect(components[1]) == "world"
}
it("can split the contents into components with single quoted strings") {
let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
let components = token.components
it("can split the contents into components with single quoted strings") {
let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
let components = token.components
try expect(components.count) == 2
try expect(components[0]) == "hello"
try expect(components[1]) == "'kyle fuller'"
}
try expect(components.count) == 2
try expect(components[0]) == "hello"
try expect(components[1]) == "'kyle fuller'"
}
it("can split the contents into components with double quoted strings") {
let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
let components = token.components
it("can split the contents into components with double quoted strings") {
let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
let components = token.components
try expect(components.count) == 2
try expect(components[0]) == "hello"
try expect(components[1]) == "\"kyle fuller\""
}
}
try expect(components.count) == 2
try expect(components[0]) == "hello"
try expect(components[1]) == "\"kyle fuller\""
}
}
}

View File

@@ -0,0 +1,137 @@
import Spectre
import Stencil
import XCTest
final class TrimBehaviourTests: XCTestCase {
func testSmartTrimCanRemoveNewlines() throws {
let templateString = """
{% for item in items %}
- {{item}}
{% endfor %}
text
"""
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
- item 1
- item 2
text
"""
// swiftlint:enable indentation_width
}
func testSmartTrimOnlyRemoveSingleNewlines() throws {
let templateString = """
{% for item in items %}
- {{item}}
{% endfor %}
text
"""
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
- item 1
- item 2
text
"""
// swiftlint:enable indentation_width
}
func testSmartTrimCanRemoveNewlinesWhileKeepingWhitespace() throws {
// swiftlint:disable indentation_width
let templateString = """
Items:
{% for item in items %}
- {{item}}
{% endfor %}
"""
// swiftlint:enable indentation_width
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
Items:
- item 1
- item 2
"""
// swiftlint:enable indentation_width
}
func testTrimSymbols() {
it("Respects whitespace control symbols in for tags") {
// swiftlint:disable indentation_width
let template: Template = """
{% for num in numbers -%}
{{num}}
{%- endfor %}
"""
// swiftlint:enable indentation_width
let result = try template.render([ "numbers": Array(1...9) ])
try expect(result) == "123456789"
}
it("Respects whitespace control symbols in if tags") {
let template: Template = """
{% if value -%}
{{text}}
{%- endif %}
"""
let result = try template.render([ "text": "hello", "value": true ])
try expect(result) == "hello"
}
}
func testTrimSymbolsOverridingEnvironment() {
let environment = Environment(trimBehaviour: .all)
it("respects whitespace control symbols in if tags") {
// swiftlint:disable indentation_width
let templateString = """
{% if value +%}
{{text}}
{%+ endif %}
"""
// swiftlint:enable indentation_width
let template = Template(templateString: templateString, environment: environment)
let result = try template.render([ "text": "hello", "value": true ])
try expect(result) == "\n hello\n"
}
it("can customize blocks on same line as text") {
// swiftlint:disable indentation_width
let templateString = """
Items:{% for item in items +%}
- {{item}}
{%- endfor %}
"""
// swiftlint:enable indentation_width
let context = ["items": ["item 1", "item 2"]]
let template = Template(templateString: templateString, environment: environment)
let result = try template.render(context)
// swiftlint:disable indentation_width
try expect(result) == """
Items:
- item 1
- item 2
"""
// swiftlint:enable indentation_width
}
}
}

View File

@@ -2,354 +2,397 @@ import Spectre
@testable import Stencil
import XCTest
final class VariableTests: XCTestCase {
private let context: Context = {
let ext = Extension()
ext.registerFilter("incr") { arg in
(arg.flatMap { toNumber(value: $0) } ?? 0) + 1
}
let environment = Environment(extensions: [ext])
var context = Context(dictionary: [
"name": "Kyle",
"contacts": ["Katie", "Carlton"],
"profiles": [
"github": "kylef"
],
"counter": [
"count": "kylef"
],
"article": Article(author: Person(name: "Kyle")),
"blog": Blog(),
"tuple": (one: 1, two: 2),
"dynamic": [
"enum": DynamicEnum.someValue,
"struct": DynamicStruct()
]
], environment: environment)
#if os(OSX)
context["object"] = Object()
#endif
return context
}()
func testLiterals() {
it("can resolve a string literal with double quotes") {
let variable = Variable("\"name\"")
let result = try variable.resolve(self.context) as? String
try expect(result) == "name"
}
it("can resolve a string literal with one double quote") {
let variable = Variable("\"")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
it("can resolve a string literal with single quotes") {
let variable = Variable("'name'")
let result = try variable.resolve(self.context) as? String
try expect(result) == "name"
}
it("can resolve a string literal with one single quote") {
let variable = Variable("'")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
it("can resolve an integer literal") {
let variable = Variable("5")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 5
}
it("can resolve an float literal") {
let variable = Variable("3.14")
let result = try variable.resolve(self.context) as? Number
try expect(result) == 3.14
}
it("can resolve boolean literal") {
try expect(Variable("true").resolve(self.context) as? Bool) == true
try expect(Variable("false").resolve(self.context) as? Bool) == false
try expect(Variable("0").resolve(self.context) as? Int) == 0
try expect(Variable("1").resolve(self.context) as? Int) == 1
}
}
func testVariable() {
it("can resolve a string variable") {
let variable = Variable("name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
func testDictionary() {
it("can resolve an item from a dictionary") {
let variable = Variable("profiles.github")
let result = try variable.resolve(self.context) as? String
try expect(result) == "kylef"
}
it("can get the count of a dictionary") {
let variable = Variable("profiles.count")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 1
}
}
func testArray() {
it("can resolve an item from an array via it's index") {
let variable = Variable("contacts.0")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Katie"
let variable1 = Variable("contacts.1")
let result1 = try variable1.resolve(self.context) as? String
try expect(result1) == "Carlton"
}
it("can resolve an item from an array via unknown index") {
let variable = Variable("contacts.5")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
let variable1 = Variable("contacts.-5")
let result1 = try variable1.resolve(self.context) as? String
try expect(result1).to.beNil()
}
it("can resolve the first item from an array") {
let variable = Variable("contacts.first")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Katie"
}
it("can resolve the last item from an array") {
let variable = Variable("contacts.last")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Carlton"
}
}
func testDynamicMemberLookup() {
it("can resolve dynamic member lookup") {
let variable = Variable("dynamic.struct.test")
let result = try variable.resolve(self.context) as? String
try expect(result) == "this is a dynamic response"
}
it("can resolve dynamic enum rawValue") {
let variable = Variable("dynamic.enum.rawValue")
let result = try variable.resolve(self.context) as? String
try expect(result) == "this is raw value"
}
}
func testReflection() {
it("can resolve a property with reflection") {
let variable = Variable("article.author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
it("can resolve a value via reflection") {
let variable = Variable("blog.articles.0.author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
it("can resolve a superclass value via reflection") {
let variable = Variable("blog.url")
let result = try variable.resolve(self.context) as? String
try expect(result) == "blog.com"
}
it("can resolve optional variable property using reflection") {
let variable = Variable("blog.featuring.author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Jhon"
}
}
func testKVO() {
#if os(OSX)
it("can resolve a value via KVO") {
let variable = Variable("object.title")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Hello World"
}
it("can resolve a superclass value via KVO") {
let variable = Variable("object.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Foo"
}
it("does not crash on KVO") {
let variable = Variable("object.fullname")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
#endif
}
func testTuple() {
it("can resolve tuple by index") {
let variable = Variable("tuple.0")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 1
}
it("can resolve tuple by label") {
let variable = Variable("tuple.two")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 2
}
}
func testOptional() {
it("does not render Optional") {
var array: [Any?] = [1, nil]
array.append(array)
let context = Context(dictionary: ["values": array])
try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]"
try expect(VariableNode(variable: "values.1").render(context)) == ""
}
}
func testSubscripting() {
it("can resolve a property subscript via reflection") {
try self.context.push(dictionary: ["property": "name"]) {
let variable = Variable("article.author[property]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
it("can subscript an array with a valid index") {
try self.context.push(dictionary: ["property": 0]) {
let variable = Variable("contacts[property]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Katie"
}
}
it("can subscript an array with an unknown index") {
try self.context.push(dictionary: ["property": 5]) {
let variable = Variable("contacts[property]")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
}
#if os(OSX)
it("can resolve a subscript via KVO") {
try self.context.push(dictionary: ["property": "name"]) {
let variable = Variable("object[property]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Foo"
}
}
#endif
it("can resolve an optional subscript via reflection") {
try self.context.push(dictionary: ["property": "featuring"]) {
let variable = Variable("blog[property].author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Jhon"
}
}
}
func testMultipleSubscripting() {
it("can resolve multiple subscripts") {
try self.context.push(dictionary: [
"prop1": "articles",
"prop2": 0,
"prop3": "name"
]) {
let variable = Variable("blog[prop1][prop2].author[prop3]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
it("can resolve nested subscripts") {
try self.context.push(dictionary: [
"prop1": "prop2",
"ref": ["prop2": "name"]
]) {
let variable = Variable("article.author[ref[prop1]]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
it("throws for invalid keypath syntax") {
try self.context.push(dictionary: ["prop": "name"]) {
let samples = [
".",
"..",
".test",
"test..test",
"[prop]",
"article.author[prop",
"article.author[[prop]",
"article.author[prop]]",
"article.author[]",
"article.author[[]]",
"article.author[prop][]",
"article.author[prop]comments",
"article.author[.]"
]
for lookup in samples {
let variable = Variable(lookup)
try expect(variable.resolve(self.context)).toThrow()
}
}
}
}
func testRangeVariable() {
func makeVariable(_ token: String) throws -> RangeVariable? {
let token = Token.variable(value: token, at: .unknown)
return try RangeVariable(token.contents, environment: context.environment, containedIn: token)
}
it("can resolve closed range as array") {
let result = try makeVariable("1...3")?.resolve(self.context) as? [Int]
try expect(result) == [1, 2, 3]
}
it("can resolve decreasing closed range as reversed array") {
let result = try makeVariable("3...1")?.resolve(self.context) as? [Int]
try expect(result) == [3, 2, 1]
}
it("can use filter on range variables") {
let result = try makeVariable("1|incr...3|incr")?.resolve(self.context) as? [Int]
try expect(result) == [2, 3, 4]
}
it("throws when left value is not int") {
let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}"
try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow()
}
it("throws when right value is not int") {
let variable = try makeVariable("k...j")
try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow()
}
it("throws is left range value is missing") {
try expect(makeVariable("...1")).toThrow()
}
it("throws is right range value is missing") {
try expect(makeVariable("1...")).toThrow()
}
}
}
// MARK: - Helpers
#if os(OSX)
@objc
class Superclass: NSObject {
@objc let name = "Foo"
@objc let name = "Foo"
}
@objc
class Object: Superclass {
@objc let title = "Hello World"
@objc let title = "Hello World"
}
#endif
private struct Person {
let name: String
let name: String
}
private struct Article {
let author: Person
let author: Person
}
private class WebSite {
let url: String = "blog.com"
let url: String = "blog.com"
}
private class Blog: WebSite {
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
let featuring: Article? = Article(author: Person(name: "Jhon"))
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
let featuring: Article? = Article(author: Person(name: "Jhon"))
}
final class VariableTests: XCTestCase {
let context: Context = {
let ext = Extension()
ext.registerFilter("incr") { arg in
(arg.flatMap { toNumber(value: $0) } ?? 0) + 1
}
let environment = Environment(extensions: [ext])
var context = Context(dictionary: [
"name": "Kyle",
"contacts": ["Katie", "Carlton"],
"profiles": [
"github": "kylef"
],
"counter": [
"count": "kylef"
],
"article": Article(author: Person(name: "Kyle")),
"blog": Blog(),
"tuple": (one: 1, two: 2)
], environment: environment)
#if os(OSX)
context["object"] = Object()
#endif
return context
}()
func testLiterals() {
it("can resolve a string literal with double quotes") {
let variable = Variable("\"name\"")
let result = try variable.resolve(self.context) as? String
try expect(result) == "name"
}
it("can resolve a string literal with single quotes") {
let variable = Variable("'name'")
let result = try variable.resolve(self.context) as? String
try expect(result) == "name"
}
it("can resolve an integer literal") {
let variable = Variable("5")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 5
}
it("can resolve an float literal") {
let variable = Variable("3.14")
let result = try variable.resolve(self.context) as? Number
try expect(result) == 3.14
}
it("can resolve boolean literal") {
try expect(Variable("true").resolve(self.context) as? Bool) == true
try expect(Variable("false").resolve(self.context) as? Bool) == false
try expect(Variable("0").resolve(self.context) as? Int) == 0
try expect(Variable("1").resolve(self.context) as? Int) == 1
}
}
func testVariable() {
it("can resolve a string variable") {
let variable = Variable("name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
func testDictionary() {
it("can resolve an item from a dictionary") {
let variable = Variable("profiles.github")
let result = try variable.resolve(self.context) as? String
try expect(result) == "kylef"
}
it("can get the count of a dictionary") {
let variable = Variable("profiles.count")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 1
}
}
func testArray() {
it("can resolve an item from an array via it's index") {
let variable = Variable("contacts.0")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Katie"
let variable1 = Variable("contacts.1")
let result1 = try variable1.resolve(self.context) as? String
try expect(result1) == "Carlton"
}
it("can resolve an item from an array via unknown index") {
let variable = Variable("contacts.5")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
let variable1 = Variable("contacts.-5")
let result1 = try variable1.resolve(self.context) as? String
try expect(result1).to.beNil()
}
it("can resolve the first item from an array") {
let variable = Variable("contacts.first")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Katie"
}
it("can resolve the last item from an array") {
let variable = Variable("contacts.last")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Carlton"
}
}
func testReflection() {
it("can resolve a property with reflection") {
let variable = Variable("article.author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
it("can resolve a value via reflection") {
let variable = Variable("blog.articles.0.author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
it("can resolve a superclass value via reflection") {
let variable = Variable("blog.url")
let result = try variable.resolve(self.context) as? String
try expect(result) == "blog.com"
}
it("can resolve optional variable property using reflection") {
let variable = Variable("blog.featuring.author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Jhon"
}
}
func testKVO() {
#if os(OSX)
it("can resolve a value via KVO") {
let variable = Variable("object.title")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Hello World"
}
it("can resolve a superclass value via KVO") {
let variable = Variable("object.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Foo"
}
it("does not crash on KVO") {
let variable = Variable("object.fullname")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
#endif
}
func testTuple() {
it("can resolve tuple by index") {
let variable = Variable("tuple.0")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 1
}
it("can resolve tuple by label") {
let variable = Variable("tuple.two")
let result = try variable.resolve(self.context) as? Int
try expect(result) == 2
}
}
func testOptional() {
it("does not render Optional") {
var array: [Any?] = [1, nil]
array.append(array)
let context = Context(dictionary: ["values": array])
try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]"
try expect(VariableNode(variable: "values.1").render(context)) == ""
}
}
func testSubscripting() {
it("can resolve a property subscript via reflection") {
try self.context.push(dictionary: ["property": "name"]) {
let variable = Variable("article.author[property]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
it("can subscript an array with a valid index") {
try self.context.push(dictionary: ["property": 0]) {
let variable = Variable("contacts[property]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Katie"
}
}
it("can subscript an array with an unknown index") {
try self.context.push(dictionary: ["property": 5]) {
let variable = Variable("contacts[property]")
let result = try variable.resolve(self.context) as? String
try expect(result).to.beNil()
}
}
#if os(OSX)
it("can resolve a subscript via KVO") {
try self.context.push(dictionary: ["property": "name"]) {
let variable = Variable("object[property]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Foo"
}
}
#endif
it("can resolve an optional subscript via reflection") {
try self.context.push(dictionary: ["property": "featuring"]) {
let variable = Variable("blog[property].author.name")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Jhon"
}
}
}
func testMultipleSubscripting() {
it("can resolve multiple subscripts") {
try self.context.push(dictionary: [
"prop1": "articles",
"prop2": 0,
"prop3": "name"
]) {
let variable = Variable("blog[prop1][prop2].author[prop3]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
it("can resolve nested subscripts") {
try self.context.push(dictionary: [
"prop1": "prop2",
"ref": ["prop2": "name"]
]) {
let variable = Variable("article.author[ref[prop1]]")
let result = try variable.resolve(self.context) as? String
try expect(result) == "Kyle"
}
}
it("throws for invalid keypath syntax") {
try self.context.push(dictionary: ["prop": "name"]) {
let samples = [
".",
"..",
".test",
"test..test",
"[prop]",
"article.author[prop",
"article.author[[prop]",
"article.author[prop]]",
"article.author[]",
"article.author[[]]",
"article.author[prop][]",
"article.author[prop]comments",
"article.author[.]"
]
for lookup in samples {
let variable = Variable(lookup)
try expect(variable.resolve(self.context)).toThrow()
}
}
}
}
func testRangeVariable() {
func makeVariable(_ token: String) throws -> RangeVariable? {
let token = Token.variable(value: token, at: .unknown)
return try RangeVariable(token.contents, environment: context.environment, containedIn: token)
}
it("can resolve closed range as array") {
let result = try makeVariable("1...3")?.resolve(self.context) as? [Int]
try expect(result) == [1, 2, 3]
}
it("can resolve decreasing closed range as reversed array") {
let result = try makeVariable("3...1")?.resolve(self.context) as? [Int]
try expect(result) == [3, 2, 1]
}
it("can use filter on range variables") {
let result = try makeVariable("1|incr...3|incr")?.resolve(self.context) as? [Int]
try expect(result) == [2, 3, 4]
}
it("throws when left value is not int") {
let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}"
try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow()
}
it("throws when right value is not int") {
let variable = try makeVariable("k...j")
try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow()
}
it("throws is left range value is missing") {
try expect(makeVariable("...1")).toThrow()
}
it("throws is right range value is missing") {
try expect(makeVariable("1...")).toThrow()
}
}
@dynamicMemberLookup
private struct DynamicStruct: DynamicMemberLookup {
subscript(dynamicMember member: String) -> Any? {
member == "test" ? "this is a dynamic response" : nil
}
}
private enum DynamicEnum: String, DynamicMemberLookup {
case someValue = "this is raw value"
}

View File

@@ -1,228 +0,0 @@
import XCTest
extension ContextTests {
static let __allTests = [
("testContextRestoration", testContextRestoration),
("testContextSubscripting", testContextSubscripting),
]
}
extension EnvironmentBaseAndChildTemplateTests {
static let __allTests = [
("testRuntimeErrorInBaseTemplate", testRuntimeErrorInBaseTemplate),
("testRuntimeErrorInChildTemplate", testRuntimeErrorInChildTemplate),
("testSyntaxErrorInBaseTemplate", testSyntaxErrorInBaseTemplate),
("testSyntaxErrorInChildTemplate", testSyntaxErrorInChildTemplate),
]
}
extension EnvironmentIncludeTemplateTests {
static let __allTests = [
("testRuntimeError", testRuntimeError),
("testSyntaxError", testSyntaxError),
]
}
extension EnvironmentTests {
static let __allTests = [
("testLoading", testLoading),
("testRendering", testRendering),
("testRenderingError", testRenderingError),
("testSyntaxError", testSyntaxError),
("testUnknownFilter", testUnknownFilter),
]
}
extension ExpressionsTests {
static let __allTests = [
("testAndExpression", testAndExpression),
("testEqualityExpression", testEqualityExpression),
("testExpressionParsing", testExpressionParsing),
("testFalseExpressions", testFalseExpressions),
("testFalseInExpression", testFalseInExpression),
("testInequalityExpression", testInequalityExpression),
("testLessThanEqualExpression", testLessThanEqualExpression),
("testLessThanExpression", testLessThanExpression),
("testMoreThanEqualExpression", testMoreThanEqualExpression),
("testMoreThanExpression", testMoreThanExpression),
("testMultipleExpressions", testMultipleExpressions),
("testNotExpression", testNotExpression),
("testOrExpression", testOrExpression),
("testTrueExpressions", testTrueExpressions),
("testTrueInExpression", testTrueInExpression),
]
}
extension FilterTagTests {
static let __allTests = [
("testFilterTag", testFilterTag),
]
}
extension FilterTests {
static let __allTests = [
("testDefaultFilter", testDefaultFilter),
("testDynamicFilters", testDynamicFilters),
("testFilterSuggestion", testFilterSuggestion),
("testIndentContent", testIndentContent),
("testIndentFirstLine", testIndentFirstLine),
("testIndentNotEmptyLines", testIndentNotEmptyLines),
("testIndentWithArbitraryCharacter", testIndentWithArbitraryCharacter),
("testJoinFilter", testJoinFilter),
("testRegistration", testRegistration),
("testRegistrationOverrideDefault", testRegistrationOverrideDefault),
("testRegistrationWithArguments", testRegistrationWithArguments),
("testSplitFilter", testSplitFilter),
("testStringFilters", testStringFilters),
("testStringFiltersWithArrays", testStringFiltersWithArrays),
]
}
extension ForNodeTests {
static let __allTests = [
("testArrayOfTuples", testArrayOfTuples),
("testForNode", testForNode),
("testHandleInvalidInput", testHandleInvalidInput),
("testIterateDictionary", testIterateDictionary),
("testIterateRange", testIterateRange),
("testIterateUsingMirroring", testIterateUsingMirroring),
("testLoopMetadata", testLoopMetadata),
("testWhereExpression", testWhereExpression),
]
}
extension IfNodeTests {
static let __allTests = [
("testEvaluatesNilAsFalse", testEvaluatesNilAsFalse),
("testParseIf", testParseIf),
("testParseIfnot", testParseIfnot),
("testParseIfWithElif", testParseIfWithElif),
("testParseIfWithElifWithoutElse", testParseIfWithElifWithoutElse),
("testParseIfWithElse", testParseIfWithElse),
("testParseMultipleElif", testParseMultipleElif),
("testParsingErrors", testParsingErrors),
("testRendering", testRendering),
("testSupportsRangeVariables", testSupportsRangeVariables),
("testSupportVariableFilters", testSupportVariableFilters),
]
}
extension IncludeTests {
static let __allTests = [
("testParsing", testParsing),
("testRendering", testRendering),
]
}
extension InheritanceTests {
static let __allTests = [
("testInheritance", testInheritance),
]
}
extension LexerTests {
static let __allTests = [
("testComment", testComment),
("testContentMixture", testContentMixture),
("testEmptyVariable", testEmptyVariable),
("testEscapeSequence", testEscapeSequence),
("testNewlines", testNewlines),
("testPerformance", testPerformance),
("testText", testText),
("testTokenizeIncorrectSyntaxWithoutCrashing", testTokenizeIncorrectSyntaxWithoutCrashing),
("testTokenWithoutSpaces", testTokenWithoutSpaces),
("testUnclosedBlock", testUnclosedBlock),
("testUnclosedTag", testUnclosedTag),
("testVariable", testVariable),
("testVariablesWithoutBeingGreedy", testVariablesWithoutBeingGreedy),
]
}
extension NodeTests {
static let __allTests = [
("testRendering", testRendering),
("testTextNode", testTextNode),
("testVariableNode", testVariableNode),
]
}
extension NowNodeTests {
static let __allTests = [
("testParsing", testParsing),
("testRendering", testRendering),
]
}
extension StencilTests {
static let __allTests = [
("testStencil", testStencil),
]
}
extension TemplateLoaderTests {
static let __allTests = [
("testDictionaryLoader", testDictionaryLoader),
("testFileSystemLoader", testFileSystemLoader),
]
}
extension TemplateTests {
static let __allTests = [
("testTemplate", testTemplate),
]
}
extension TokenParserTests {
static let __allTests = [
("testTokenParser", testTokenParser),
]
}
extension TokenTests {
static let __allTests = [
("testToken", testToken),
]
}
extension VariableTests {
static let __allTests = [
("testArray", testArray),
("testDictionary", testDictionary),
("testKVO", testKVO),
("testLiterals", testLiterals),
("testMultipleSubscripting", testMultipleSubscripting),
("testOptional", testOptional),
("testRangeVariable", testRangeVariable),
("testReflection", testReflection),
("testSubscripting", testSubscripting),
("testTuple", testTuple),
("testVariable", testVariable),
]
}
#if !os(macOS)
public func __allTests() -> [XCTestCaseEntry] {
return [
testCase(ContextTests.__allTests),
testCase(EnvironmentBaseAndChildTemplateTests.__allTests),
testCase(EnvironmentIncludeTemplateTests.__allTests),
testCase(EnvironmentTests.__allTests),
testCase(ExpressionsTests.__allTests),
testCase(FilterTagTests.__allTests),
testCase(FilterTests.__allTests),
testCase(ForNodeTests.__allTests),
testCase(IfNodeTests.__allTests),
testCase(IncludeTests.__allTests),
testCase(InheritanceTests.__allTests),
testCase(LexerTests.__allTests),
testCase(NodeTests.__allTests),
testCase(NowNodeTests.__allTests),
testCase(StencilTests.__allTests),
testCase(TemplateLoaderTests.__allTests),
testCase(TemplateTests.__allTests),
testCase(TokenParserTests.__allTests),
testCase(TokenTests.__allTests),
testCase(VariableTests.__allTests),
]
}
#endif

View File

@@ -0,0 +1,5 @@
{% block header %}Header{% endblock %}
{% block body %}Body{% endblock %}
Repeat
{{ block.header }}
{{ block.body }}

View File

@@ -0,0 +1,3 @@
{% extends "base-repeat.html" %}
{% block header %}Super_{{ block.super }} Child_Header{% endblock %}
{% block body %}Child_Body{% endblock %}

View File

@@ -0,0 +1,2 @@
{% extends "if-block.html" %}
{% block title %}{% if sort == "new" %}{{ block.super }} - Nieuwste spellen{% elif sort == "upcoming" %}{{ block.super }} - Binnenkort op de agenda{% elif sort == "near-me" %}{{ block.super }} - In mijn buurt{% endif %}{% endblock %}

View File

@@ -0,0 +1 @@
{% block title %}Title{% endblock %}

View File

@@ -2,7 +2,7 @@
<p>
<iframe
src="https://ghbtns.com/github-btn.html?user=stencilproject&repo=Stencil&type=watch&count=true&size=large"
src="https://ghbtns.com/github-btn.html?user=swiftstencil&repo=swiftpm-stencil&type=watch&count=true&size=large"
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px">
</iframe>
</p>
@@ -12,22 +12,8 @@
<div class="social">
<p>
<iframe
src="https://ghbtns.com/github-btn.html?user=kylef&type=follow&count=false"
src="https://ghbtns.com/github-btn.html?user=swiftstencil&type=follow&count=false"
allowtransparency="true" frameborder="0" scrolling="0" width="200" height="20">
</iframe>
</p>
<p>
<a href="https://twitter.com/kylefuller" class="twitter-follow-button" data-show-count="false">Follow @kylefuller</a> <script>!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https';if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src=p+'://platform.twitter.com/widgets.js';fjs.parentNode.insertBefore(js,fjs);}}(document, 'script', 'twitter-wjs');</script>
</p>
</div>
<h3>Other Projects</h3>
<p>More <a href="https://fuller.li/">Kyle Fuller</a> projects:</p>
<ul>
<li><a href="https://github.com/kylef/Commander">Commander</a></li>
<li><a href="https://curassow.fuller.li/">Curassow</a></li>
<li><a href="https://github.com/kylef/Spectre">Spectre</a></li>
<li><a href="https://github.com/kylef/heroku-buildpack-swift">Heroku Swift buildpack</a></li>
</ul>

View File

@@ -41,8 +41,7 @@ You can iterate over range literals created using ``N...M`` syntax, both in asce
{% endfor %}
</ul>
The ``for`` tag can contain optional ``where`` expression to filter out
elements on which this expression evaluates to false.
The ``for`` tag can contain optional ``where`` expression to filter out elements on which this expression evaluates to false.
.. code-block:: html+django
@@ -52,8 +51,7 @@ elements on which this expression evaluates to false.
{% endfor %}
</ul>
The ``for`` tag can take an optional ``{% empty %}`` block that will be
displayed if the given list is empty or could not be found.
The ``for`` tag can take an optional ``{% empty %}`` block that will be displayed if the given list is empty or could not be found.
.. code-block:: html+django
@@ -89,12 +87,74 @@ For example:
This is user number {{ forloop.counter }} user.
{% endfor %}
The ``for`` tag accepts an optional label, so that it may later be referred to by name. The contexts of parent labeled loops can be accessed via the `forloop` property:
.. code-block:: html+django
{% outer: for item in users %}
{% for item in 1..3 %}
{% if forloop.outer.first %}
This is the first user.
{% endif %}
{% endfor %}
{% endfor %}
``break``
~~~~~~~~~
The ``break`` tag lets you jump out of a for loop, for example if a certain condition is met:
.. code-block:: html+django
{% for user in users %}
{% if user.inaccessible %}
{% break %}
{% endif %}
This is user {{ user.name }}.
{% endfor %}
Break tags accept an optional label parameter, so that you may break out of multiple loops:
.. code-block:: html+django
{% outer: for user in users %}
{% for address in user.addresses %}
{% if address.isInvalid %}
{% break outer %}
{% endif %}
{% endfor %}
{% endfor %}
``continue``
~~~~~~~~~
The ``continue`` tag lets you skip the rest of the blocks in a loop, for example if a certain condition is met:
.. code-block:: html+django
{% for user in users %}
{% if user.inaccessible %}
{% continue %}
{% endif %}
This is user {{ user.name }}.
{% endfor %}
Continue tags accept an optional label parameter, so that you may skip the execution of multiple loops:
.. code-block:: html+django
{% outer: for user in users %}
{% for address in user.addresses %}
{% if address.isInvalid %}
{% continue outer %}
{% endif %}
{% endfor %}
{% endfor %}
``if``
~~~~~~
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to
true the contents of the block are processed. Being true is defined as:
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to true the contents of the block are processed. Being true is defined as:
* Present in the context
* Being non-empty (dictionaries or arrays)
@@ -115,8 +175,7 @@ true the contents of the block are processed. Being true is defined as:
Operators
^^^^^^^^^
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables
or to negate a variable.
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables or to negate a variable.
.. code-block:: html+django
@@ -279,8 +338,7 @@ By default the included file gets passed the current context. You can pass a sub
{% include "comment.html" comment %}
The `include` tag requires you to provide a loader which will be used to lookup
the template.
The `include` tag requires you to provide a loader which will be used to lookup the template.
.. code-block:: swift
@@ -301,8 +359,7 @@ See :ref:`template-inheritance` for more information.
``block``
~~~~~~~~~
Defines a block that can be overridden by child templates. See
:ref:`template-inheritance` for more information.
Defines a block that can be overridden by child templates. See :ref:`template-inheritance` for more information.
.. _built-in-filters:
@@ -312,8 +369,7 @@ Built-in Filters
``capitalize``
~~~~~~~~~~~~~~
The capitalize filter allows you to capitalize a string.
For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
The capitalize filter allows you to capitalize a string. For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -322,8 +378,7 @@ For example, `stencil` to `Stencil`. Can be applied to array of strings to chang
``uppercase``
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to uppercase.
For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
The uppercase filter allows you to transform a string to uppercase. For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -332,8 +387,7 @@ For example, `Stencil` to `STENCIL`. Can be applied to array of strings to chang
``lowercase``
~~~~~~~~~~~~~
The uppercase filter allows you to transform a string to lowercase.
For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
The uppercase filter allows you to transform a string to lowercase. For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
.. code-block:: html+django
@@ -342,8 +396,7 @@ For example, `Stencil` to `stencil`. Can be applied to array of strings to chang
``default``
~~~~~~~~~~~
If a variable not present in the context, use given default. Otherwise, use the
value of the variable. For example:
If a variable not present in the context, use given default. Otherwise, use the value of the variable. For example:
.. code-block:: html+django

View File

@@ -50,7 +50,7 @@ master_doc = 'index'
# General information about the project.
project = 'Stencil'
copyright = '2016, Kyle Fuller'
copyright = [ '2022, Kyle Fuller', '2025, Astzweig GmbH & Co. KG' ]
author = 'Kyle Fuller'
# The version info for the project you're documenting, acts as replacement for
@@ -58,9 +58,9 @@ author = 'Kyle Fuller'
# built documents.
#
# The short X.Y version.
version = '0.14.0'
version = '0.15.2'
# The full version, including alpha/beta/rc tags.
release = '0.14.0'
release = '0.15.2'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View File

@@ -31,7 +31,7 @@ feel right at home with Stencil.
]
]
let environment = Environment(loader: FileSystemLoader(paths: ["templates/"])
let environment = Environment(loader: FileSystemLoader(paths: ["templates/"]))
let rendered = try environment.renderTemplate(name: "articles.html", context: context)
print(rendered)

View File

@@ -14,39 +14,6 @@ dependencies inside ``Package.swift``.
let package = Package(
name: "MyApplication",
dependencies: [
.Package(url: "https://github.com/stencilproject/Stencil.git", majorVersion: 0, minor: 13),
.package(url: "https://github.com/swiftstencil/swiftpm-stencil.git", from: "0.15.2"),
]
)
CocoaPods
---------
If you're using CocoaPods, you can add Stencil to your ``Podfile`` and then run
``pod install``.
.. code-block:: ruby
pod 'Stencil', '~> 0.14.0'
Carthage
--------
.. note:: Use at your own risk. We don't offer support for Carthage and instead recommend you use Swift Package Manager.
1) Add ``Stencil`` to your ``Cartfile``:
.. code-block:: text
github "stencilproject/Stencil" ~> 0.14.0
2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil:
.. code-block:: shell
$ carthage update
$ (cd Carthage/Checkouts/Stencil && swift package generate-xcodeproj)
$ carthage build
3) Follow the Carthage steps to add the built frameworks to your project.
To learn more about this approach see `Using Swift Package Manager with Carthage <https://fuller.li/posts/using-swift-package-manager-with-carthage/>`_.

View File

@@ -22,6 +22,7 @@ following lookup:
- Dictionary lookup
- Array and string lookup (first, last, count, by index)
- Key value coding lookup
- @dynamicMemberLookup when conforming to our `DynamicMemberLookup` marker protocol
- Type introspection (via ``Mirror``)
For example, if `people` was an array:
@@ -49,6 +50,24 @@ For example, if you have the following context:
The result of {{ item[key] }} will be the same as {{ item.name }}. It will first evaluate the result of {{ key }}, and only then evaluate the lookup expression.
You can use the `LazyValueWrapper` type to have values in your context that will be lazily evaluated. The provided value will only be evaluated when it's first accessed in your template, and will be cached afterwards. For example:
.. code-block:: swift
[
"magic": LazyValueWrapper(myHeavyCalculations())
]
Boolean expressions
-------------------
Boolean expressions can be rendered using ``{{ ... }}`` tag.
For example, this will output string `true` if variable is equal to 1 and `false` otherwise:
.. code-block:: html+django
{{ variable == 1 }}
Filters
~~~~~~~
@@ -94,6 +113,17 @@ To comment out part of your template, you can use the following syntax:
{# My comment is completely hidden #}
Whitespace Control
------------------
Stencil supports the same syntax as Jinja for whitespace control, see `their docs for more information <https://jinja.palletsprojects.com/en/3.1.x/templates/#whitespace-control>`_.
Essentially, Stencil will **not** trim whitespace by default. However you can:
- Control how this is handled for the whole template by setting the trim behaviour. We provide a few pre-made combinations such as `nothing` (default), `smart` and `all`. More granular combinations are possible.
- You can disable this per-block using the `+` control character. For example `{{+ if … }}` to preserve whitespace before.
- You can force trimming per-block by using the `-` control character. For example `{{ if … -}}` to trim whitespace after.
.. _template-inheritance:
Template inheritance
@@ -129,7 +159,7 @@ Let's take a look at an example. Here is our base template (``base.html``):
</html>
This example declares three blocks, ``title``, ``sidebar`` and ``content``. We
can use the ``{% extends %}`` template tag to inherit from out base template
can use the ``{% extends %}`` template tag to inherit from our base template
and then use ``{% block %}`` to override any blocks from our base template.
A child template might look like the following:
@@ -183,3 +213,5 @@ inheritance is the following three-level approach:
extend ``base.html`` and include section-specific styles/design.
* Create individual templates for each type of page, such as a news article or
blog entry. These templates extend the appropriate section template.
You can render block's content more than once by using ``{{ block.name }}`` **after** a block is defined.

Some files were not shown because too many files have changed in this diff Show More