Compare commits
137 Commits
0.13.1
...
973609e141
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973609e141 | ||
|
|
644687b885 | ||
|
|
4f222ac85d | ||
|
|
3a98d1ef7d | ||
|
|
95a24b950f | ||
|
|
a3df900bd2 | ||
|
|
59b0c176c7 | ||
|
|
bc5051ffe3 | ||
|
|
9444ee5c86 | ||
|
|
8989f8a189 | ||
|
|
6bafcffd2b | ||
|
|
c690f3e613 | ||
|
|
2ddc039129 | ||
|
|
078c7a84e6 | ||
|
|
5f0c01809d | ||
|
|
07d36651bf | ||
|
|
1072e919a3 | ||
|
|
0d8fdbc3aa | ||
|
|
5828770138 | ||
|
|
71879ecdc9 | ||
|
|
6481534f6c | ||
|
|
0fa830c5cb | ||
|
|
479fdad30b | ||
|
|
6649b7e716 | ||
|
|
242bea54c3 | ||
|
|
14f4c2a131 | ||
|
|
f12d6ed7f3 | ||
|
|
dd7ea1e097 | ||
|
|
91df84b1a5 | ||
|
|
a7448b74cf | ||
|
|
248d664d4a | ||
|
|
41e0c9c9e0 | ||
|
|
67f94aa9f0 | ||
|
|
8c379296ca | ||
|
|
4d3f911f5d | ||
|
|
b95b18ff60 | ||
|
|
27a543d748 | ||
|
|
ef97973e85 | ||
|
|
d4dc631752 | ||
|
|
20b41782a1 | ||
|
|
888797b27e | ||
|
|
f32c772b99 | ||
|
|
e6ee27f64e | ||
|
|
256388ddc8 | ||
|
|
099b8414d2 | ||
|
|
7247d0a83d | ||
|
|
203510175f | ||
|
|
8e890db688 | ||
|
|
701221c0fb | ||
|
|
a6d0428036 | ||
|
|
ee8b4bc4bc | ||
|
|
99cc1cac4a | ||
|
|
779820ed99 | ||
|
|
828a9b6fc4 | ||
|
|
1b72ef27a4 | ||
|
|
bf6c7ce456 | ||
|
|
0bbb8005bb | ||
|
|
d9a48fbda6 | ||
|
|
d18e27d6e4 | ||
|
|
ec031f9c7f | ||
|
|
7dbccf9686 | ||
|
|
c444fb959d | ||
|
|
c7e1c890f8 | ||
|
|
ccd9402682 | ||
|
|
9f0b9388d2 | ||
|
|
38f5faec78 | ||
|
|
a724419474 | ||
|
|
12b3a2e9bd | ||
|
|
47a44889ae | ||
|
|
01740c61d3 | ||
|
|
c729a7d58f | ||
|
|
973e190edf | ||
|
|
e134aafe7f | ||
|
|
88fd776a02 | ||
|
|
8480648bd3 | ||
|
|
521a599a60 | ||
|
|
371a4737d9 | ||
|
|
61919c5e8e | ||
|
|
7c635975d1 | ||
|
|
fd107355c2 | ||
|
|
f5f85d95a9 | ||
|
|
22440c5369 | ||
|
|
94197b3adb | ||
|
|
e93b33423b | ||
|
|
19646bcddf | ||
|
|
a84cd3d877 | ||
|
|
124df01d3c | ||
|
|
0f1286c032 | ||
|
|
9a61aa48e3 | ||
|
|
520f27be65 | ||
|
|
306d97b638 | ||
|
|
386e9d0234 | ||
|
|
0e116b6202 | ||
|
|
9c3468e300 | ||
|
|
a1718ae350 | ||
|
|
5b2d5dc5e0 | ||
|
|
00fca208a2 | ||
|
|
a229b59d3d | ||
|
|
415c3eaa3d | ||
|
|
e516ca9389 | ||
|
|
4020a9851a | ||
|
|
3c973689a4 | ||
|
|
06ea016fd7 | ||
|
|
c2f18790e3 | ||
|
|
6addc46681 | ||
|
|
782ffdd4c7 | ||
|
|
ebb7ece511 | ||
|
|
305dc31abd | ||
|
|
3394929008 | ||
|
|
693565ddda | ||
|
|
0f18d43d9e | ||
|
|
ee4203a269 | ||
|
|
5220c3791e | ||
|
|
9243bba2b7 | ||
|
|
deec93fbe1 | ||
|
|
8510193d09 | ||
|
|
2d82dcb003 | ||
|
|
3f4622f54f | ||
|
|
799490198f | ||
|
|
6f3ca60e2b | ||
|
|
08fc21d177 | ||
|
|
019d0cca76 | ||
|
|
da6a0ccaca | ||
|
|
dbb5e14e9f | ||
|
|
0269052d6a | ||
|
|
4faf8f5ee6 | ||
|
|
4154cd31ff | ||
|
|
fd79045053 | ||
|
|
9bd86d9fd5 | ||
|
|
66a9bc563a | ||
|
|
01afae9b79 | ||
|
|
d9f6a82f97 | ||
|
|
9a6ba94d7d | ||
|
|
e795f052ea | ||
|
|
2c411ca494 | ||
|
|
f3d5843e78 | ||
|
|
564ccb7af7 |
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
25
.github/workflows/danger.yml
vendored
Normal file
25
.github/workflows/danger.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Danger
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: master
|
||||||
|
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 }}
|
||||||
23
.github/workflows/lint-cocoapods.yml
vendored
Normal file
23
.github/workflows/lint-cocoapods.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Lint Cocoapods
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: master
|
||||||
|
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
|
||||||
23
.github/workflows/release-check-versions.yml
vendored
Normal file
23
.github/workflows/release-check-versions.yml
vendored
Normal 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
|
||||||
26
.github/workflows/swiftlint.yml
vendored
Normal file
26
.github/workflows/swiftlint.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: SwiftLint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: master
|
||||||
|
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
|
||||||
44
.github/workflows/tag-publish.yml
vendored
Normal file
44
.github/workflows/tag-publish.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Publish on Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cocoapods:
|
||||||
|
name: Push To CocoaPods
|
||||||
|
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: Push to CocoaPods
|
||||||
|
run: bundle exec rake release:cocoapods
|
||||||
|
env:
|
||||||
|
COCOAPODS_TRUNK_TOKEN: ${{secrets.COCOAPODS_TRUNK_TOKEN}}
|
||||||
|
|
||||||
|
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 }}
|
||||||
66
.github/workflows/test-spm.yml
vendored
Normal file
66
.github/workflows/test-spm.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Test SPM
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: master
|
||||||
|
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
|
||||||
78
.gitignore
vendored
78
.gitignore
vendored
@@ -1,5 +1,75 @@
|
|||||||
.conche/
|
# Xcode
|
||||||
.build/
|
#
|
||||||
|
# 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/
|
Packages/
|
||||||
Package.pins
|
.build/
|
||||||
*.xcodeproj
|
.swiftpm/
|
||||||
|
|
||||||
|
# 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
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.0.4
|
||||||
124
.swiftlint.yml
Normal file
124
.swiftlint.yml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
swiftlint_version: 0.48.0
|
||||||
|
|
||||||
|
opt_in_rules:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
file_header:
|
||||||
|
required_pattern: |
|
||||||
|
\/\/
|
||||||
|
\/\/ Stencil
|
||||||
|
\/\/ Copyright © 2022 Stencil
|
||||||
|
\/\/ MIT Licence
|
||||||
|
\/\/
|
||||||
|
|
||||||
|
indentation_width:
|
||||||
|
indentation_width: 2
|
||||||
|
|
||||||
|
line_length:
|
||||||
|
warning: 120
|
||||||
|
error: 200
|
||||||
|
|
||||||
|
nesting:
|
||||||
|
type_level:
|
||||||
|
warning: 2
|
||||||
19
.travis.yml
19
.travis.yml
@@ -1,19 +0,0 @@
|
|||||||
matrix:
|
|
||||||
include:
|
|
||||||
- os: osx
|
|
||||||
osx_image: xcode9.4
|
|
||||||
env: SWIFT_VERSION=4.1
|
|
||||||
- os: osx
|
|
||||||
osx_image: xcode10
|
|
||||||
env: SWIFT_VERSION=4.2
|
|
||||||
- os: linux
|
|
||||||
env: SWIFT_VERSION=4.1
|
|
||||||
- os: linux
|
|
||||||
env: SWIFT_VERSION=4.2
|
|
||||||
language: generic
|
|
||||||
sudo: required
|
|
||||||
dist: trusty
|
|
||||||
install:
|
|
||||||
- eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"
|
|
||||||
script:
|
|
||||||
- swift test
|
|
||||||
136
CHANGELOG.md
136
CHANGELOG.md
@@ -1,4 +1,124 @@
|
|||||||
# Stencil Changelog
|
## 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.
|
||||||
|
[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 }}`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#203](https://github.com/stencilproject/Stencil/pull/203)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 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`.
|
||||||
|
[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.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#256](https://github.com/stencilproject/Stencil/pull/256)
|
||||||
|
- Added SwiftLint to the project.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#249](https://github.com/stencilproject/Stencil/pull/249)
|
||||||
|
- Updated to Swift 5.
|
||||||
|
[Jungwon An](https://github.com/kawoou)
|
||||||
|
[#268](https://github.com/stencilproject/Stencil/pull/268)
|
||||||
|
|
||||||
|
|
||||||
## 0.13.1
|
## 0.13.1
|
||||||
|
|
||||||
@@ -16,15 +136,15 @@
|
|||||||
- Now requires Swift 4.1 or newer.
|
- Now requires Swift 4.1 or newer.
|
||||||
[Yonas Kolb](https://github.com/yonaskolb)
|
[Yonas Kolb](https://github.com/yonaskolb)
|
||||||
[#228](https://github.com/stencilproject/Stencil/pull/228)
|
[#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)
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
[#165](https://github.com/stencilproject/Stencil/pull/165)
|
[#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)
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
[#160](https://github.com/stencilproject/Stencil/pull/160)
|
[#160](https://github.com/stencilproject/Stencil/pull/160)
|
||||||
|
|
||||||
### Enhancements
|
|
||||||
|
|
||||||
- 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)
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
[#243](https://github.com/stencilproject/Stencil/pull/243)
|
[#243](https://github.com/stencilproject/Stencil/pull/243)
|
||||||
@@ -103,7 +223,7 @@
|
|||||||
- The `{% for %}` tag can now iterate over tuples, structures and classes via
|
- The `{% for %}` tag can now iterate over tuples, structures and classes via
|
||||||
their stored properties.
|
their stored properties.
|
||||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
[#172](https://github.com/stencilproject/Stencil/pull/173)
|
[#173](https://github.com/stencilproject/Stencil/pull/173)
|
||||||
- Added `split` filter.
|
- Added `split` filter.
|
||||||
[Ilya Puchka](https://github.com/ilyapuchka)
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
[#187](https://github.com/stencilproject/Stencil/pull/187)
|
[#187](https://github.com/stencilproject/Stencil/pull/187)
|
||||||
@@ -212,7 +332,7 @@
|
|||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- You can now use literal filter arguments which contain quotes.
|
- 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
|
## 0.8.0
|
||||||
@@ -356,10 +476,10 @@
|
|||||||
|
|
||||||
- Variables (`{{ variable.5 }}`) that reference an array index at an unknown
|
- Variables (`{{ variable.5 }}`) that reference an array index at an unknown
|
||||||
index will now resolve to `nil` instead of causing a crash.
|
index will now resolve to `nil` instead of causing a crash.
|
||||||
[#72](https://github.com/kylef/Stencil/issues/72)
|
[#72](https://github.com/stencilproject/Stencil/issues/72)
|
||||||
|
|
||||||
- Templates can now extend templates that extend other templates.
|
- Templates can now extend templates that extend other templates.
|
||||||
[#60](https://github.com/kylef/Stencil/issues/60)
|
[#60](https://github.com/stencilproject/Stencil/issues/60)
|
||||||
|
|
||||||
- If comparisons will now treat 0 and below numbers as negative.
|
- If comparisons will now treat 0 and below numbers as negative.
|
||||||
|
|
||||||
|
|||||||
21
Gemfile
Normal file
21
Gemfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
|
# The bare minimum for building, e.g. in Homebrew
|
||||||
|
group :build do
|
||||||
|
gem 'rake', '~> 13.0'
|
||||||
|
gem 'xcpretty', '~> 0.3'
|
||||||
|
end
|
||||||
|
|
||||||
|
# In addition to :build, for contributing
|
||||||
|
group :development do
|
||||||
|
gem 'cocoapods', '~> 1.11'
|
||||||
|
gem 'danger', '~> 8.4'
|
||||||
|
gem 'rubocop', '~> 1.22'
|
||||||
|
end
|
||||||
|
|
||||||
|
# For releasing to GitHub
|
||||||
|
group :release do
|
||||||
|
gem 'octokit', '~> 4.7'
|
||||||
|
end
|
||||||
189
Gemfile.lock
Normal file
189
Gemfile.lock
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.5)
|
||||||
|
rexml
|
||||||
|
activesupport (6.1.6.1)
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
i18n (>= 1.6, < 2)
|
||||||
|
minitest (>= 5.1)
|
||||||
|
tzinfo (~> 2.0)
|
||||||
|
zeitwerk (~> 2.3)
|
||||||
|
addressable (2.8.0)
|
||||||
|
public_suffix (>= 2.0.2, < 5.0)
|
||||||
|
algoliasearch (1.27.5)
|
||||||
|
httpclient (~> 2.8, >= 2.8.3)
|
||||||
|
json (>= 1.5.1)
|
||||||
|
ast (2.4.2)
|
||||||
|
atomos (0.1.3)
|
||||||
|
claide (1.1.0)
|
||||||
|
claide-plugins (0.9.2)
|
||||||
|
cork
|
||||||
|
nap
|
||||||
|
open4 (~> 1.3)
|
||||||
|
cocoapods (1.11.3)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
cocoapods-core (= 1.11.3)
|
||||||
|
cocoapods-deintegrate (>= 1.0.3, < 2.0)
|
||||||
|
cocoapods-downloader (>= 1.4.0, < 2.0)
|
||||||
|
cocoapods-plugins (>= 1.0.0, < 2.0)
|
||||||
|
cocoapods-search (>= 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.8.0)
|
||||||
|
nap (~> 1.0)
|
||||||
|
ruby-macho (>= 1.0, < 3.0)
|
||||||
|
xcodeproj (>= 1.21.0, < 2.0)
|
||||||
|
cocoapods-core (1.11.3)
|
||||||
|
activesupport (>= 5.0, < 7)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
algoliasearch (~> 1.0)
|
||||||
|
concurrent-ruby (~> 1.1)
|
||||||
|
fuzzy_match (~> 2.0.4)
|
||||||
|
nap (~> 1.0)
|
||||||
|
netrc (~> 0.11)
|
||||||
|
public_suffix (~> 4.0)
|
||||||
|
typhoeus (~> 1.0)
|
||||||
|
cocoapods-deintegrate (1.0.5)
|
||||||
|
cocoapods-downloader (1.6.3)
|
||||||
|
cocoapods-plugins (1.0.0)
|
||||||
|
nap
|
||||||
|
cocoapods-search (1.0.1)
|
||||||
|
cocoapods-trunk (1.6.0)
|
||||||
|
nap (>= 0.8, < 2.0)
|
||||||
|
netrc (~> 0.11)
|
||||||
|
cocoapods-try (1.2.0)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
concurrent-ruby (1.1.10)
|
||||||
|
cork (0.3.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
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)
|
||||||
|
escape (0.0.4)
|
||||||
|
ethon (0.15.0)
|
||||||
|
ffi (>= 1.15.0)
|
||||||
|
faraday (1.10.0)
|
||||||
|
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.0)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-http-cache (2.4.0)
|
||||||
|
faraday (>= 0.8)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (1.0.1)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
|
ffi (1.15.5)
|
||||||
|
fourflusher (2.3.1)
|
||||||
|
fuzzy_match (2.0.4)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
git (1.11.0)
|
||||||
|
rchardet (~> 1.8)
|
||||||
|
httpclient (2.8.3)
|
||||||
|
i18n (1.12.0)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
json (2.6.2)
|
||||||
|
kramdown (2.4.0)
|
||||||
|
rexml
|
||||||
|
kramdown-parser-gfm (1.1.0)
|
||||||
|
kramdown (~> 2.0)
|
||||||
|
minitest (5.16.2)
|
||||||
|
molinillo (0.8.0)
|
||||||
|
multipart-post (2.2.3)
|
||||||
|
nanaimo (0.3.0)
|
||||||
|
nap (1.1.0)
|
||||||
|
netrc (0.11.0)
|
||||||
|
no_proxy_fix (0.1.2)
|
||||||
|
octokit (4.25.1)
|
||||||
|
faraday (>= 1, < 3)
|
||||||
|
sawyer (~> 0.9)
|
||||||
|
open4 (1.3.4)
|
||||||
|
parallel (1.22.1)
|
||||||
|
parser (3.1.2.0)
|
||||||
|
ast (~> 2.4.1)
|
||||||
|
public_suffix (4.0.7)
|
||||||
|
rainbow (3.1.1)
|
||||||
|
rake (13.0.6)
|
||||||
|
rchardet (1.8.0)
|
||||||
|
regexp_parser (2.5.0)
|
||||||
|
rexml (3.2.5)
|
||||||
|
rouge (2.0.7)
|
||||||
|
rubocop (1.32.0)
|
||||||
|
json (~> 2.3)
|
||||||
|
parallel (~> 1.10)
|
||||||
|
parser (>= 3.1.0.0)
|
||||||
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
|
regexp_parser (>= 1.8, < 3.0)
|
||||||
|
rexml (>= 3.2.5, < 4.0)
|
||||||
|
rubocop-ast (>= 1.19.1, < 2.0)
|
||||||
|
ruby-progressbar (~> 1.7)
|
||||||
|
unicode-display_width (>= 1.4.0, < 3.0)
|
||||||
|
rubocop-ast (1.19.1)
|
||||||
|
parser (>= 3.1.1.0)
|
||||||
|
ruby-macho (2.5.1)
|
||||||
|
ruby-progressbar (1.11.0)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
sawyer (0.9.2)
|
||||||
|
addressable (>= 2.3.5)
|
||||||
|
faraday (>= 0.17.3, < 3)
|
||||||
|
terminal-table (3.0.2)
|
||||||
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
|
typhoeus (1.4.0)
|
||||||
|
ethon (>= 0.9.0)
|
||||||
|
tzinfo (2.0.5)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
unicode-display_width (2.2.0)
|
||||||
|
xcodeproj (1.22.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.3.0)
|
||||||
|
rexml (~> 3.2.4)
|
||||||
|
xcpretty (0.3.0)
|
||||||
|
rouge (~> 2.0.7)
|
||||||
|
zeitwerk (2.6.0)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
ruby
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
cocoapods (~> 1.11)
|
||||||
|
danger (~> 8.4)
|
||||||
|
octokit (~> 4.7)
|
||||||
|
rake (~> 13.0)
|
||||||
|
rubocop (~> 1.22)
|
||||||
|
xcpretty (~> 0.3)
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.2.33
|
||||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2018, Kyle Fuller
|
Copyright (c) 2022, Kyle Fuller
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
"repositoryURL": "https://github.com/kylef/PathKit.git",
|
"repositoryURL": "https://github.com/kylef/PathKit.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "e2f5be30e4c8f531c9c1e8765aa7b71c0a45d7a0",
|
"revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
|
||||||
"version": "0.9.2"
|
"version": "1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
"repositoryURL": "https://github.com/kylef/Spectre.git",
|
"repositoryURL": "https://github.com/kylef/Spectre.git",
|
||||||
"state": {
|
"state": {
|
||||||
"branch": null,
|
"branch": null,
|
||||||
"revision": "f14ff47f45642aa5703900980b014c2e9394b6e5",
|
"revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
|
||||||
"version": "0.9.0"
|
"version": "0.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version:4.1
|
// swift-tools-version:5.0
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
@@ -7,16 +7,17 @@ let package = Package(
|
|||||||
.library(name: "Stencil", targets: ["Stencil"])
|
.library(name: "Stencil", targets: ["Stencil"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/kylef/PathKit.git", from: "0.9.0"),
|
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.1"),
|
||||||
.package(url: "https://github.com/kylef/Spectre.git", from: "0.9.0")
|
.package(url: "https://github.com/kylef/Spectre.git", from: "0.10.1")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "Stencil", dependencies: [
|
.target(name: "Stencil", dependencies: [
|
||||||
"PathKit"
|
"PathKit"
|
||||||
], path: "Sources"),
|
]),
|
||||||
.testTarget(name: "StencilTests", dependencies: [
|
.testTarget(name: "StencilTests", dependencies: [
|
||||||
"Stencil",
|
"Stencil",
|
||||||
"Spectre"
|
"Spectre"
|
||||||
])
|
])
|
||||||
]
|
],
|
||||||
|
swiftLanguageVersions: [.v5]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
// swift-tools-version:4.2
|
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
let package = Package(
|
|
||||||
name: "Stencil",
|
|
||||||
products: [
|
|
||||||
.library(name: "Stencil", targets: ["Stencil"])
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
.package(url: "https://github.com/kylef/PathKit.git", from: "0.9.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, .v4_2]
|
|
||||||
)
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# Stencil
|
# Stencil
|
||||||
|
|
||||||
[](https://travis-ci.org/stencilproject/Stencil)
|
|
||||||
|
|
||||||
Stencil is a simple and powerful template language for Swift. It provides a
|
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
|
syntax similar to Django and Mustache. If you're familiar with these, you will
|
||||||
feel right at home with Stencil.
|
feel right at home with Stencil.
|
||||||
@@ -68,7 +66,8 @@ Resources to help you integrate Stencil into a Swift project:
|
|||||||
[Sourcery](https://github.com/krzysztofzablocki/Sourcery),
|
[Sourcery](https://github.com/krzysztofzablocki/Sourcery),
|
||||||
[SwiftGen](https://github.com/SwiftGen/SwiftGen),
|
[SwiftGen](https://github.com/SwiftGen/SwiftGen),
|
||||||
[Kitura](https://github.com/IBM-Swift/Kitura),
|
[Kitura](https://github.com/IBM-Swift/Kitura),
|
||||||
[Weaver](https://github.com/scribd/Weaver)
|
[Weaver](https://github.com/scribd/Weaver),
|
||||||
|
[Genesis](https://github.com/yonaskolb/Genesis)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
52
Rakefile
Executable file
52
Rakefile
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/rake
|
||||||
|
|
||||||
|
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/stencilproject/Stencil.git", from: "#{version}"),
|
||||||
|
/pod 'Stencil', '.*'/ => %Q(pod 'Stencil', '~> #{version}'),
|
||||||
|
/github "stencilproject\/Stencil" ~> .*/ => %Q(github "stencilproject/Stencil" ~> #{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'
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/// A container for template variables.
|
|
||||||
public class Context {
|
|
||||||
var dictionaries: [[String: Any?]]
|
|
||||||
|
|
||||||
public let environment: Environment
|
|
||||||
|
|
||||||
init(dictionary: [String: Any]? = nil, environment: Environment? = nil) {
|
|
||||||
if let dictionary = dictionary {
|
|
||||||
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 let dictionary = dictionaries.popLast() {
|
|
||||||
var mutable_dictionary = dictionary
|
|
||||||
mutable_dictionary[key] = value
|
|
||||||
dictionaries.append(mutable_dictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push a new level into the Context
|
|
||||||
fileprivate func push(_ dictionary: [String: Any]? = nil) {
|
|
||||||
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]? = nil, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +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]? = nil,
|
|
||||||
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]? = nil) throws -> String {
|
|
||||||
let template = try loadTemplate(name: name)
|
|
||||||
return try render(template: template, context: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func renderTemplate(string: String, context: [String: Any]? = nil) 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,155 +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 parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser, 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 push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
|
|
||||||
if loopVariables.isEmpty {
|
|
||||||
return try context.push() {
|
|
||||||
return 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) {
|
|
||||||
return try closure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return try context.push(dictionary: [loopVariables.first!: value]) {
|
|
||||||
return try closure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
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 = []
|
|
||||||
}
|
|
||||||
|
|
||||||
if let `where` = self.where {
|
|
||||||
values = try values.filter({ item -> Bool in
|
|
||||||
return try push(value: item, context: context) {
|
|
||||||
try `where`.evaluate(context: context)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if !values.isEmpty {
|
|
||||||
let count = values.count
|
|
||||||
|
|
||||||
return try values.enumerated().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]) {
|
|
||||||
return try push(value: item, context: context) {
|
|
||||||
try renderNodes(nodes, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.joined(separator: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
return try context.push {
|
|
||||||
try renderNodes(emptyNodes, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +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(separator: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
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.index(of: "else") {
|
|
||||||
condition = try parseExpression(components: Array(components.prefix(upTo: elseIndex)), tokenParser: parser, token: token)
|
|
||||||
let elseToken = components.suffix(from: elseIndex.advanced(by: 1)).joined(separator: " ")
|
|
||||||
elseExpression = try parser.compileResolvable(elseToken, containedIn: token)
|
|
||||||
} else {
|
|
||||||
condition = try parseExpression(components: Array(components), tokenParser: parser, token: token)
|
|
||||||
elseExpression = nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
condition = nil
|
|
||||||
elseExpression = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let filter = try parser.compileResolvable(components[0], 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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
|
|
||||||
return { parser, token in
|
|
||||||
if let name = token.components().first {
|
|
||||||
for tag in tags {
|
|
||||||
if 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(_ parse_until:((_ parser:TokenParser, _ token:Token) -> (Bool))?) throws -> [NodeType] {
|
|
||||||
var nodes = [NodeType]()
|
|
||||||
|
|
||||||
while tokens.count > 0 {
|
|
||||||
let token = nextToken()!
|
|
||||||
|
|
||||||
switch token {
|
|
||||||
case .text(let text, _):
|
|
||||||
nodes.append(TextNode(text: text))
|
|
||||||
case .variable:
|
|
||||||
try nodes.append(VariableNode.parse(self, token: token))
|
|
||||||
case .block:
|
|
||||||
if let parse_until = parse_until , parse_until(self, token) {
|
|
||||||
prependToken(token)
|
|
||||||
return nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tag = token.components().first {
|
|
||||||
do {
|
|
||||||
let parser = try 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.count > 0 {
|
|
||||||
return tokens.remove(at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func prependToken(_ token:Token) {
|
|
||||||
tokens.insert(token, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findTag(name: String) throws -> Extension.TagParser {
|
|
||||||
for ext in environment.extensions {
|
|
||||||
if let filter = ext.tags[name] {
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw TemplateSyntaxError("Unknown template tag '\(name)'")
|
|
||||||
}
|
|
||||||
|
|
||||||
func findFilter(_ name: String) throws -> FilterType {
|
|
||||||
for ext in environment.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 = environment.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 })
|
|
||||||
}
|
|
||||||
|
|
||||||
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
|
|
||||||
do {
|
|
||||||
return try FilterExpression(token: filterToken, parser: 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, deprecated, message: "Use compileFilter(_:containedIn:)")
|
|
||||||
public func compileFilter(_ token: String) throws -> Resolvable {
|
|
||||||
return try FilterExpression(token: token, parser: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, deprecated, message: "Use compileResolvable(_:containedIn:)")
|
|
||||||
public func compileResolvable(_ token: String) throws -> Resolvable {
|
|
||||||
return try RangeVariable(token, parser: self)
|
|
||||||
?? compileFilter(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
|
||||||
return try RangeVariable(token, parser: self, containedIn: containingToken)
|
|
||||||
?? compileFilter(token, containedIn: containingToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
|
|
||||||
extension String {
|
|
||||||
|
|
||||||
subscript(_ i: Int) -> Character {
|
|
||||||
return self[self.index(self.startIndex, offsetBy: i)]
|
|
||||||
}
|
|
||||||
|
|
||||||
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 i 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] = i + 1
|
|
||||||
|
|
||||||
// use formula to fill in the rest of the row
|
|
||||||
for j in 0..<target.count {
|
|
||||||
current[j+1] = Swift.min(
|
|
||||||
last[j+1] + 1,
|
|
||||||
current[j] + 1,
|
|
||||||
last[j] + (self[i] == target[j] ? 0 : 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// copy v1 (current row) to v0 (previous row) for next iteration
|
|
||||||
last = current
|
|
||||||
}
|
|
||||||
|
|
||||||
return current[target.count]
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
111
Sources/Stencil/Context.swift
Normal file
111
Sources/Stencil/Context.swift
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Sources/Stencil/DynamicMemberLookup.swift
Normal file
24
Sources/Stencil/DynamicMemberLookup.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Sources/Stencil/Environment.swift
Normal file
90
Sources/Stencil/Environment.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
public class TemplateDoesNotExist: Error, CustomStringConvertible {
|
public class TemplateDoesNotExist: Error, CustomStringConvertible {
|
||||||
let templateNames: [String]
|
let templateNames: [String]
|
||||||
let loader: Loader?
|
let loader: Loader?
|
||||||
@@ -20,12 +26,12 @@ public class TemplateDoesNotExist: Error, CustomStringConvertible {
|
|||||||
|
|
||||||
public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
|
public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
|
||||||
public let reason: String
|
public let reason: String
|
||||||
public var description: String { return reason }
|
public var description: String { reason }
|
||||||
public internal(set) var token: Token?
|
public internal(set) var token: Token?
|
||||||
public internal(set) var stackTrace: [Token]
|
public internal(set) var stackTrace: [Token]
|
||||||
public var templateName: String? { return token?.sourceMap.filename }
|
public var templateName: String? { token?.sourceMap.filename }
|
||||||
var allTokens: [Token] {
|
var allTokens: [Token] {
|
||||||
return stackTrace + (token.map({ [$0] }) ?? [])
|
stackTrace + (token.map { [$0] } ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
|
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
|
||||||
@@ -50,12 +56,11 @@ extension Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol ErrorReporter: class {
|
public protocol ErrorReporter: AnyObject {
|
||||||
func renderError(_ error: Error) -> String
|
func renderError(_ error: Error) -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
open class SimpleErrorReporter: ErrorReporter {
|
open class SimpleErrorReporter: ErrorReporter {
|
||||||
|
|
||||||
open func renderError(_ error: Error) -> String {
|
open func renderError(_ error: Error) -> String {
|
||||||
guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
|
guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
|
||||||
|
|
||||||
@@ -74,10 +79,9 @@ open class SimpleErrorReporter: ErrorReporter {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
var descriptions = templateError.stackTrace.reduce([]) { $0 + [describe(token: $1)] }
|
var descriptions = templateError.stackTrace.reduce(into: []) { $0.append(describe(token: $1)) }
|
||||||
let description = templateError.token.map(describe(token:)) ?? templateError.reason
|
let description = templateError.token.map(describe(token:)) ?? templateError.reason
|
||||||
descriptions.append(description)
|
descriptions.append(description)
|
||||||
return descriptions.joined(separator: "\n")
|
return descriptions.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
protocol Expression: CustomStringConvertible {
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
public protocol Expression: CustomStringConvertible, Resolvable {
|
||||||
func evaluate(context: Context) throws -> Bool
|
func evaluate(context: Context) throws -> Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Expression {
|
||||||
|
func resolve(_ context: Context) throws -> Any? {
|
||||||
|
try "\(evaluate(context: context))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protocol InfixOperator: Expression {
|
protocol InfixOperator: Expression {
|
||||||
init(lhs: Expression, rhs: Expression)
|
init(lhs: Expression, rhs: Expression)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
protocol PrefixOperator: Expression {
|
protocol PrefixOperator: Expression {
|
||||||
init(expression: Expression)
|
init(expression: Expression)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
final class StaticExpression: Expression, CustomStringConvertible {
|
final class StaticExpression: Expression, CustomStringConvertible {
|
||||||
let value: Bool
|
let value: Bool
|
||||||
|
|
||||||
@@ -21,15 +30,14 @@ final class StaticExpression: Expression, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
return value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "\(value)"
|
"\(value)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
final class VariableExpression: Expression, CustomStringConvertible {
|
final class VariableExpression: Expression, CustomStringConvertible {
|
||||||
let variable: Resolvable
|
let variable: Resolvable
|
||||||
|
|
||||||
@@ -38,11 +46,15 @@ final class VariableExpression: Expression, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "(variable: \(variable))"
|
"(variable: \(variable))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(_ context: Context) throws -> Any? {
|
||||||
|
try variable.resolve(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a variable in the given context as boolean
|
/// Resolves a variable in the given context as boolean
|
||||||
func resolve(context: Context, variable: Resolvable) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
let result = try variable.resolve(context)
|
let result = try variable.resolve(context)
|
||||||
var truthy = false
|
var truthy = false
|
||||||
|
|
||||||
@@ -62,12 +74,7 @@ final class VariableExpression: Expression, CustomStringConvertible {
|
|||||||
|
|
||||||
return truthy
|
return truthy
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
return try resolve(context: context, variable: variable)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
|
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
|
||||||
let expression: Expression
|
let expression: Expression
|
||||||
@@ -77,11 +84,11 @@ final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "not \(expression)"
|
"not \(expression)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
return try !expression.evaluate(context: context)
|
try !expression.evaluate(context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +102,7 @@ final class InExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "(\(lhs) in \(rhs))"
|
"(\(lhs) in \(rhs))"
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
@@ -118,7 +125,6 @@ final class InExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
@@ -131,7 +137,7 @@ final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "(\(lhs) or \(rhs))"
|
"(\(lhs) or \(rhs))"
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
@@ -144,7 +150,6 @@ final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
let lhs: Expression
|
let lhs: Expression
|
||||||
let rhs: Expression
|
let rhs: Expression
|
||||||
@@ -155,7 +160,7 @@ final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "(\(lhs) and \(rhs))"
|
"(\(lhs) and \(rhs))"
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
@@ -168,7 +173,6 @@ final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
let lhs: Expression
|
let lhs: Expression
|
||||||
let rhs: Expression
|
let rhs: Expression
|
||||||
@@ -179,7 +183,7 @@ class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "(\(lhs) == \(rhs))"
|
"(\(lhs) == \(rhs))"
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
@@ -204,7 +208,6 @@ class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
let lhs: Expression
|
let lhs: Expression
|
||||||
let rhs: Expression
|
let rhs: Expression
|
||||||
@@ -215,7 +218,7 @@ class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "(\(lhs) \(op) \(rhs))"
|
"(\(lhs) \(symbol) \(rhs))"
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
func evaluate(context: Context) throws -> Bool {
|
||||||
@@ -233,71 +236,66 @@ class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var op: String {
|
var symbol: String {
|
||||||
return ""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
func compare(lhs: Number, rhs: Number) -> Bool {
|
func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MoreThanExpression: NumericExpression {
|
class MoreThanExpression: NumericExpression {
|
||||||
override var op: String {
|
override var symbol: String {
|
||||||
return ">"
|
">"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
return lhs > rhs
|
lhs > rhs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MoreThanEqualExpression: NumericExpression {
|
class MoreThanEqualExpression: NumericExpression {
|
||||||
override var op: String {
|
override var symbol: String {
|
||||||
return ">="
|
">="
|
||||||
}
|
}
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
return lhs >= rhs
|
lhs >= rhs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LessThanExpression: NumericExpression {
|
class LessThanExpression: NumericExpression {
|
||||||
override var op: String {
|
override var symbol: String {
|
||||||
return "<"
|
"<"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
return lhs < rhs
|
lhs < rhs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LessThanEqualExpression: NumericExpression {
|
class LessThanEqualExpression: NumericExpression {
|
||||||
override var op: String {
|
override var symbol: String {
|
||||||
return "<="
|
"<="
|
||||||
}
|
}
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
return lhs <= rhs
|
lhs <= rhs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class InequalityExpression: EqualityExpression {
|
class InequalityExpression: EqualityExpression {
|
||||||
override var description: String {
|
override var description: String {
|
||||||
return "(\(lhs) != \(rhs))"
|
"(\(lhs) != \(rhs))"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func evaluate(context: Context) throws -> Bool {
|
override func evaluate(context: Context) throws -> Bool {
|
||||||
return try !super.evaluate(context: context)
|
try !super.evaluate(context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next cyclomatic_complexity
|
||||||
func toNumber(value: Any) -> Number? {
|
func toNumber(value: Any) -> Number? {
|
||||||
if let value = value as? Float {
|
if let value = value as? Float {
|
||||||
return Number(value)
|
return Number(value)
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
/// Container for registered tags and filters
|
||||||
open class Extension {
|
open class Extension {
|
||||||
typealias TagParser = (TokenParser, Token) throws -> NodeType
|
typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||||
var tags = [String: TagParser]()
|
|
||||||
|
|
||||||
|
var tags = [String: TagParser]()
|
||||||
var filters = [String: Filter]()
|
var filters = [String: Filter]()
|
||||||
|
|
||||||
|
/// Simple initializer
|
||||||
public init() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,16 +22,17 @@ open class Extension {
|
|||||||
|
|
||||||
/// Registers a simple template tag with a name and a handler
|
/// Registers a simple template tag with a name and a handler
|
||||||
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
|
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
|
||||||
registerTag(name, parser: { parser, token in
|
registerTag(name) { _, token in
|
||||||
return SimpleNode(token: token, handler: handler)
|
SimpleNode(token: token, handler: handler)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers boolean filter with it's negative counterpart
|
/// Registers boolean filter with it's negative counterpart
|
||||||
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
|
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
|
||||||
|
// swiftlint:disable:previous discouraged_optional_boolean
|
||||||
filters[name] = .simple(filter)
|
filters[name] = .simple(filter)
|
||||||
filters[negativeFilterName] = .simple {
|
filters[negativeFilterName] = .simple { value in
|
||||||
guard let result = try filter($0) else { return nil }
|
guard let result = try filter(value) else { return nil }
|
||||||
return !result
|
return !result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,11 +44,15 @@ open class Extension {
|
|||||||
|
|
||||||
/// Registers a template filter with the given name
|
/// Registers a template filter with the given name
|
||||||
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
|
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)
|
filters[name] = .arguments(filter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DefaultExtension: Extension {
|
class DefaultExtension: Extension {
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -49,6 +62,8 @@ class DefaultExtension: Extension {
|
|||||||
|
|
||||||
fileprivate func registerDefaultTags() {
|
fileprivate func registerDefaultTags() {
|
||||||
registerTag("for", parser: ForNode.parse)
|
registerTag("for", parser: ForNode.parse)
|
||||||
|
registerTag("break", parser: LoopTerminationNode.parse)
|
||||||
|
registerTag("continue", parser: LoopTerminationNode.parse)
|
||||||
registerTag("if", parser: IfNode.parse)
|
registerTag("if", parser: IfNode.parse)
|
||||||
registerTag("ifnot", parser: IfNode.parse_ifnot)
|
registerTag("ifnot", parser: IfNode.parse_ifnot)
|
||||||
#if !os(Linux)
|
#if !os(Linux)
|
||||||
@@ -68,28 +83,27 @@ class DefaultExtension: Extension {
|
|||||||
registerFilter("join", filter: joinFilter)
|
registerFilter("join", filter: joinFilter)
|
||||||
registerFilter("split", filter: splitFilter)
|
registerFilter("split", filter: splitFilter)
|
||||||
registerFilter("indent", filter: indentFilter)
|
registerFilter("indent", filter: indentFilter)
|
||||||
|
registerFilter("filter", filter: filterFilter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
protocol FilterType {
|
protocol FilterType {
|
||||||
func invoke(value: Any?, arguments: [Any?]) throws -> Any?
|
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Filter: FilterType {
|
enum Filter: FilterType {
|
||||||
case simple(((Any?) throws -> Any?))
|
case simple(((Any?) throws -> Any?))
|
||||||
case arguments(((Any?, [Any?]) throws -> Any?))
|
case arguments(((Any?, [Any?], Context) throws -> Any?))
|
||||||
|
|
||||||
func invoke(value: Any?, arguments: [Any?]) throws -> Any? {
|
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .simple(filter):
|
case let .simple(filter):
|
||||||
if !arguments.isEmpty {
|
if !arguments.isEmpty {
|
||||||
throw TemplateSyntaxError("cannot invoke filter with an argument")
|
throw TemplateSyntaxError("Can't invoke filter with an argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
return try filter(value)
|
return try filter(value)
|
||||||
case let .arguments(filter):
|
case let .arguments(filter):
|
||||||
return try filter(value, arguments)
|
return try filter(value, arguments, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
class FilterNode: NodeType {
|
class FilterNode: NodeType {
|
||||||
let resolvable: Resolvable
|
let resolvable: Resolvable
|
||||||
let nodes: [NodeType]
|
let nodes: [NodeType]
|
||||||
let token: Token?
|
let token: Token?
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
let bits = token.components()
|
let bits = token.components
|
||||||
|
|
||||||
guard bits.count == 2 else {
|
guard bits.count == 2 else {
|
||||||
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
|
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
|
||||||
@@ -30,8 +36,7 @@ class FilterNode : NodeType {
|
|||||||
let value = try renderNodes(nodes, context)
|
let value = try renderNodes(nodes, context)
|
||||||
|
|
||||||
return try context.push(dictionary: ["filter_value": value]) {
|
return try context.push(dictionary: ["filter_value": value]) {
|
||||||
return try VariableNode(variable: resolvable, token: token).render(context)
|
try VariableNode(variable: resolvable, token: token).render(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
func capitalise(_ value: Any?) -> Any? {
|
func capitalise(_ value: Any?) -> Any? {
|
||||||
if let array = value as? [Any?] {
|
if let array = value as? [Any?] {
|
||||||
return array.map { stringify($0).capitalized }
|
return array.map { stringify($0).capitalized }
|
||||||
@@ -39,7 +45,7 @@ func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
|
|||||||
|
|
||||||
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||||
guard arguments.count < 2 else {
|
guard arguments.count < 2 else {
|
||||||
throw TemplateSyntaxError("'join' filter takes a single argument")
|
throw TemplateSyntaxError("'join' filter takes at most one argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
let separator = stringify(arguments.first ?? "")
|
let separator = stringify(arguments.first ?? "")
|
||||||
@@ -55,7 +61,7 @@ func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
|||||||
|
|
||||||
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||||
guard arguments.count < 2 else {
|
guard arguments.count < 2 else {
|
||||||
throw TemplateSyntaxError("'split' filter takes a single argument")
|
throw TemplateSyntaxError("'split' filter takes at most one argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
let separator = stringify(arguments.first ?? " ")
|
let separator = stringify(arguments.first ?? " ")
|
||||||
@@ -72,11 +78,13 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var indentWidth = 4
|
var indentWidth = 4
|
||||||
if arguments.count > 0 {
|
if !arguments.isEmpty {
|
||||||
guard let value = arguments[0] as? Int else {
|
guard let value = arguments[0] as? Int else {
|
||||||
throw TemplateSyntaxError("""
|
throw TemplateSyntaxError(
|
||||||
|
"""
|
||||||
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
|
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
indentWidth = value
|
indentWidth = value
|
||||||
}
|
}
|
||||||
@@ -84,9 +92,11 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
|||||||
var indentationChar = " "
|
var indentationChar = " "
|
||||||
if arguments.count > 1 {
|
if arguments.count > 1 {
|
||||||
guard let value = arguments[1] as? String else {
|
guard let value = arguments[1] as? String else {
|
||||||
throw TemplateSyntaxError("""
|
throw TemplateSyntaxError(
|
||||||
|
"""
|
||||||
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
|
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
indentationChar = value
|
indentationChar = value
|
||||||
}
|
}
|
||||||
@@ -99,19 +109,31 @@ func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
|||||||
indentFirst = value
|
indentFirst = value
|
||||||
}
|
}
|
||||||
|
|
||||||
let indentation = [String](repeating: indentationChar, count: indentWidth).joined(separator: "")
|
let indentation = [String](repeating: indentationChar, count: indentWidth).joined()
|
||||||
return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
|
return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
|
func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
|
||||||
guard !indentation.isEmpty else { return content }
|
guard !indentation.isEmpty else { return content }
|
||||||
|
|
||||||
var lines = content.components(separatedBy: .newlines)
|
var lines = content.components(separatedBy: .newlines)
|
||||||
let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
|
let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
|
||||||
let result = lines.reduce([firstLine]) { (result, line) in
|
let result = lines.reduce(into: [firstLine]) { result, line in
|
||||||
return result + [(line.isEmpty ? "" : "\(indentation)\(line)")]
|
result.append(line.isEmpty ? "" : "\(indentation)\(line)")
|
||||||
}
|
}
|
||||||
return result.joined(separator: "\n")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
280
Sources/Stencil/ForTag.swift
Normal file
280
Sources/Stencil/ForTag.swift
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
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 -> LoopTerminationNode {
|
||||||
|
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 LoopTerminationNode(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
enum Operator {
|
enum Operator {
|
||||||
case infix(String, Int, InfixOperator.Type)
|
case infix(String, Int, InfixOperator.Type)
|
||||||
case prefix(String, Int, PrefixOperator.Type)
|
case prefix(String, Int, PrefixOperator.Type)
|
||||||
@@ -10,10 +16,8 @@ enum Operator {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
static let all: [Operator] = [
|
||||||
let operators: [Operator] = [
|
|
||||||
.infix("in", 5, InExpression.self),
|
.infix("in", 5, InExpression.self),
|
||||||
.infix("or", 6, OrExpression.self),
|
.infix("or", 6, OrExpression.self),
|
||||||
.infix("and", 7, AndExpression.self),
|
.infix("and", 7, AndExpression.self),
|
||||||
@@ -23,21 +27,18 @@ let operators: [Operator] = [
|
|||||||
.infix(">", 10, MoreThanExpression.self),
|
.infix(">", 10, MoreThanExpression.self),
|
||||||
.infix(">=", 10, MoreThanEqualExpression.self),
|
.infix(">=", 10, MoreThanEqualExpression.self),
|
||||||
.infix("<", 10, LessThanExpression.self),
|
.infix("<", 10, LessThanExpression.self),
|
||||||
.infix("<=", 10, LessThanEqualExpression.self),
|
.infix("<=", 10, LessThanEqualExpression.self)
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
|
||||||
func findOperator(name: String) -> Operator? {
|
func findOperator(name: String) -> Operator? {
|
||||||
for op in operators {
|
for `operator` in Operator.all where `operator`.name == name {
|
||||||
if op.name == name {
|
return `operator`
|
||||||
return op
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
indirect enum IfToken {
|
indirect enum IfToken {
|
||||||
case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
|
case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
|
||||||
case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
|
case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
|
||||||
@@ -51,9 +52,9 @@ indirect enum IfToken {
|
|||||||
return bindingPower
|
return bindingPower
|
||||||
case .prefix(_, let bindingPower, _):
|
case .prefix(_, let bindingPower, _):
|
||||||
return bindingPower
|
return bindingPower
|
||||||
case .variable(_):
|
case .variable:
|
||||||
return 0
|
return 0
|
||||||
case .subExpression(_):
|
case .subExpression:
|
||||||
return 0
|
return 0
|
||||||
case .end:
|
case .end:
|
||||||
return 0
|
return 0
|
||||||
@@ -64,9 +65,9 @@ indirect enum IfToken {
|
|||||||
switch self {
|
switch self {
|
||||||
case .infix(let name, _, _):
|
case .infix(let name, _, _):
|
||||||
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
|
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
|
||||||
case .prefix(_, let bindingPower, let op):
|
case .prefix(_, let bindingPower, let operatorType):
|
||||||
let expression = try parser.expression(bindingPower: bindingPower)
|
let expression = try parser.expression(bindingPower: bindingPower)
|
||||||
return op.init(expression: expression)
|
return operatorType.init(expression: expression)
|
||||||
case .variable(let variable):
|
case .variable(let variable):
|
||||||
return VariableExpression(variable: variable)
|
return VariableExpression(variable: variable)
|
||||||
case .subExpression(let expression):
|
case .subExpression(let expression):
|
||||||
@@ -78,14 +79,14 @@ indirect enum IfToken {
|
|||||||
|
|
||||||
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
|
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
|
||||||
switch self {
|
switch self {
|
||||||
case .infix(_, let bindingPower, let op):
|
case .infix(_, let bindingPower, let operatorType):
|
||||||
let right = try parser.expression(bindingPower: bindingPower)
|
let right = try parser.expression(bindingPower: bindingPower)
|
||||||
return op.init(lhs: left, rhs: right)
|
return operatorType.init(lhs: left, rhs: right)
|
||||||
case .prefix(let name, _, _):
|
case .prefix(let name, _, _):
|
||||||
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
|
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
|
||||||
case .variable(let variable):
|
case .variable(let variable):
|
||||||
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
|
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
|
||||||
case .subExpression(_):
|
case .subExpression:
|
||||||
throw TemplateSyntaxError("'if' expression error: sub expression was called with a left hand side")
|
throw TemplateSyntaxError("'if' expression error: sub expression was called with a left hand side")
|
||||||
case .end:
|
case .end:
|
||||||
throw TemplateSyntaxError("'if' expression error: end")
|
throw TemplateSyntaxError("'if' expression error: end")
|
||||||
@@ -102,7 +103,6 @@ indirect enum IfToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
final class IfExpressionParser {
|
final class IfExpressionParser {
|
||||||
let tokens: [IfToken]
|
let tokens: [IfToken]
|
||||||
var position: Int = 0
|
var position: Int = 0
|
||||||
@@ -111,21 +111,21 @@ final class IfExpressionParser {
|
|||||||
self.tokens = tokens
|
self.tokens = tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parser(components: [String], tokenParser: TokenParser, token: Token) throws -> IfExpressionParser {
|
static func parser(components: [String], environment: Environment, token: Token) throws -> IfExpressionParser {
|
||||||
return try IfExpressionParser(components: ArraySlice(components), tokenParser: tokenParser, token: token)
|
try IfExpressionParser(components: ArraySlice(components), environment: environment, token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(components: ArraySlice<String>, tokenParser: TokenParser, token: Token) throws {
|
private init(components: ArraySlice<String>, environment: Environment, token: Token) throws {
|
||||||
var parsedComponents = Set<Int>()
|
var parsedComponents = Set<Int>()
|
||||||
var bracketsBalance = 0
|
var bracketsBalance = 0
|
||||||
self.tokens = try zip(components.indices, components).compactMap { (index, component) in
|
self.tokens = try zip(components.indices, components).compactMap { index, component in
|
||||||
guard !parsedComponents.contains(index) else { return nil }
|
guard !parsedComponents.contains(index) else { return nil }
|
||||||
|
|
||||||
if component == "(" {
|
if component == "(" {
|
||||||
bracketsBalance += 1
|
bracketsBalance += 1
|
||||||
let (expression, parsedCount) = try IfExpressionParser.subExpression(
|
let (expression, parsedCount) = try Self.subExpression(
|
||||||
from: components.suffix(from: index + 1),
|
from: components.suffix(from: index + 1),
|
||||||
tokenParser: tokenParser,
|
environment: environment,
|
||||||
token: token
|
token: token
|
||||||
)
|
)
|
||||||
parsedComponents.formUnion(Set(index...(index + parsedCount)))
|
parsedComponents.formUnion(Set(index...(index + parsedCount)))
|
||||||
@@ -139,35 +139,38 @@ final class IfExpressionParser {
|
|||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
parsedComponents.insert(index)
|
parsedComponents.insert(index)
|
||||||
if let op = findOperator(name: component) {
|
if let `operator` = findOperator(name: component) {
|
||||||
switch op {
|
switch `operator` {
|
||||||
case .infix(let name, let bindingPower, let operatorType):
|
case .infix(let name, let bindingPower, let operatorType):
|
||||||
return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
||||||
case .prefix(let name, let bindingPower, let operatorType):
|
case .prefix(let name, let bindingPower, let operatorType):
|
||||||
return .prefix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
return .prefix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return .variable(try tokenParser.compileResolvable(component, containedIn: token))
|
return .variable(try environment.compileResolvable(component, containedIn: token))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func subExpression(from components: ArraySlice<String>, tokenParser: TokenParser, token: Token) throws -> (Expression, Int) {
|
private static func subExpression(
|
||||||
|
from components: ArraySlice<String>,
|
||||||
|
environment: Environment,
|
||||||
|
token: Token
|
||||||
|
) throws -> (Expression, Int) {
|
||||||
var bracketsBalance = 1
|
var bracketsBalance = 1
|
||||||
let subComponents = components
|
let subComponents = components.prefix { component in
|
||||||
.prefix(while: {
|
if component == "(" {
|
||||||
if $0 == "(" {
|
|
||||||
bracketsBalance += 1
|
bracketsBalance += 1
|
||||||
} else if $0 == ")" {
|
} else if component == ")" {
|
||||||
bracketsBalance -= 1
|
bracketsBalance -= 1
|
||||||
}
|
}
|
||||||
return bracketsBalance != 0
|
return bracketsBalance != 0
|
||||||
})
|
}
|
||||||
if bracketsBalance > 0 {
|
if bracketsBalance > 0 {
|
||||||
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
|
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
|
||||||
}
|
}
|
||||||
|
|
||||||
let expressionParser = try IfExpressionParser(components: subComponents, tokenParser: tokenParser, token: token)
|
let expressionParser = try IfExpressionParser(components: subComponents, environment: environment, token: token)
|
||||||
let expression = try expressionParser.parse()
|
let expression = try expressionParser.parse()
|
||||||
return (expression, subComponents.count)
|
return (expression, subComponents.count)
|
||||||
}
|
}
|
||||||
@@ -211,11 +214,6 @@ final class IfExpressionParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseExpression(components: [String], tokenParser: TokenParser, token: Token) throws -> Expression {
|
|
||||||
let parser = try IfExpressionParser.parser(components: components, tokenParser: tokenParser, token: token)
|
|
||||||
return try parser.parse()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents an if condition and the associated nodes when the condition
|
/// Represents an if condition and the associated nodes when the condition
|
||||||
/// evaluates
|
/// evaluates
|
||||||
final class IfCondition {
|
final class IfCondition {
|
||||||
@@ -228,22 +226,21 @@ final class IfCondition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
func render(_ context: Context) throws -> String {
|
||||||
return try context.push {
|
try context.push {
|
||||||
return try renderNodes(nodes, context)
|
try renderNodes(nodes, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class IfNode: NodeType {
|
class IfNode: NodeType {
|
||||||
let conditions: [IfCondition]
|
let conditions: [IfCondition]
|
||||||
let token: Token?
|
let token: Token?
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
var components = token.components()
|
var components = token.components
|
||||||
components.removeFirst()
|
components.removeFirst()
|
||||||
|
|
||||||
let expression = try parseExpression(components: components, tokenParser: parser, token: token)
|
let expression = try parser.compileExpression(components: components, token: token)
|
||||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||||
var conditions: [IfCondition] = [
|
var conditions: [IfCondition] = [
|
||||||
IfCondition(expression: expression, nodes: nodes)
|
IfCondition(expression: expression, nodes: nodes)
|
||||||
@@ -251,9 +248,9 @@ class IfNode : NodeType {
|
|||||||
|
|
||||||
var nextToken = parser.nextToken()
|
var nextToken = parser.nextToken()
|
||||||
while let current = nextToken, current.contents.hasPrefix("elif") {
|
while let current = nextToken, current.contents.hasPrefix("elif") {
|
||||||
var components = current.components()
|
var components = current.components
|
||||||
components.removeFirst()
|
components.removeFirst()
|
||||||
let expression = try parseExpression(components: components, tokenParser: parser, token: current)
|
let expression = try parser.compileExpression(components: components, token: current)
|
||||||
|
|
||||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||||
nextToken = parser.nextToken()
|
nextToken = parser.nextToken()
|
||||||
@@ -273,7 +270,7 @@ class IfNode : NodeType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
|
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
var components = token.components()
|
var components = token.components
|
||||||
guard components.count == 2 else {
|
guard components.count == 2 else {
|
||||||
throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.")
|
throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.")
|
||||||
}
|
}
|
||||||
@@ -281,7 +278,7 @@ class IfNode : NodeType {
|
|||||||
var trueNodes = [NodeType]()
|
var trueNodes = [NodeType]()
|
||||||
var falseNodes = [NodeType]()
|
var falseNodes = [NodeType]()
|
||||||
|
|
||||||
let expression = try parseExpression(components: components, tokenParser: parser, token: token)
|
let expression = try parser.compileExpression(components: components, token: token)
|
||||||
falseNodes = try parser.parse(until(["endif", "else"]))
|
falseNodes = try parser.parse(until(["endif", "else"]))
|
||||||
|
|
||||||
guard let token = parser.nextToken() else {
|
guard let token = parser.nextToken() else {
|
||||||
@@ -295,7 +292,7 @@ class IfNode : NodeType {
|
|||||||
|
|
||||||
return IfNode(conditions: [
|
return IfNode(conditions: [
|
||||||
IfCondition(expression: expression, nodes: trueNodes),
|
IfCondition(expression: expression, nodes: trueNodes),
|
||||||
IfCondition(expression: nil, nodes: falseNodes),
|
IfCondition(expression: nil, nodes: falseNodes)
|
||||||
], token: token)
|
], token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import PathKit
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
import PathKit
|
||||||
|
|
||||||
class IncludeNode: NodeType {
|
class IncludeNode: NodeType {
|
||||||
let templateName: Variable
|
let templateName: Variable
|
||||||
@@ -7,14 +12,16 @@ class IncludeNode : NodeType {
|
|||||||
let token: Token?
|
let token: Token?
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
let bits = token.components()
|
let bits = token.components
|
||||||
|
|
||||||
guard bits.count == 2 || bits.count == 3 else {
|
guard bits.count == 2 || bits.count == 3 else {
|
||||||
throw TemplateSyntaxError("""
|
throw TemplateSyntaxError(
|
||||||
|
"""
|
||||||
'include' tag requires one argument, the template file to be included. \
|
'include' tag requires one argument, the template file to be included. \
|
||||||
A second optional argument can be used to specify the context that will \
|
A second optional argument can be used to specify the context that will \
|
||||||
be passed to the included file
|
be passed to the included file
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
|
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
|
||||||
@@ -34,9 +41,9 @@ class IncludeNode : NodeType {
|
|||||||
let template = try context.environment.loadTemplate(name: templateName)
|
let template = try context.environment.loadTemplate(name: templateName)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let subContext = includeContext.flatMap { context[$0] as? [String: Any] }
|
let subContext = includeContext.flatMap { context[$0] as? [String: Any] } ?? [:]
|
||||||
return try context.push(dictionary: subContext) {
|
return try context.push(dictionary: subContext) {
|
||||||
return try template.render(context)
|
try template.render(context)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if let error = error as? TemplateSyntaxError {
|
if let error = error as? TemplateSyntaxError {
|
||||||
@@ -47,4 +54,3 @@ class IncludeNode : NodeType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
class BlockContext {
|
class BlockContext {
|
||||||
class var contextKey: String { return "block_context" }
|
class var contextKey: String { "block_context" }
|
||||||
|
|
||||||
// contains mapping of block names to their nodes and templates where they are defined
|
// contains mapping of block names to their nodes and templates where they are defined
|
||||||
var blocks: [String: [BlockNode]]
|
var blocks: [String: [BlockNode]]
|
||||||
@@ -33,27 +39,23 @@ class BlockContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension Collection {
|
extension Collection {
|
||||||
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
|
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
|
||||||
for element in self {
|
for element in self where closure(element) {
|
||||||
if closure(element) {
|
|
||||||
return element
|
return element
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ExtendsNode: NodeType {
|
class ExtendsNode: NodeType {
|
||||||
let templateName: Variable
|
let templateName: Variable
|
||||||
let blocks: [String: BlockNode]
|
let blocks: [String: BlockNode]
|
||||||
let token: Token?
|
let token: Token?
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
let bits = token.components()
|
let bits = token.components
|
||||||
|
|
||||||
guard bits.count == 2 else {
|
guard bits.count == 2 else {
|
||||||
throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
|
throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
|
||||||
@@ -65,11 +67,8 @@ class ExtendsNode : NodeType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
|
let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
|
||||||
|
let nodes = blockNodes.reduce(into: [String: BlockNode]()) { accumulator, node in
|
||||||
let nodes = blockNodes.reduce([String: BlockNode]()) { (accumulator, node) -> [String: BlockNode] in
|
accumulator[node.name] = node
|
||||||
var dict = accumulator
|
|
||||||
dict[node.name] = node
|
|
||||||
return dict
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
|
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
|
||||||
@@ -102,7 +101,7 @@ class ExtendsNode : NodeType {
|
|||||||
// pushes base template and renders it's content
|
// pushes base template and renders it's content
|
||||||
// block_context contains all blocks from child templates
|
// block_context contains all blocks from child templates
|
||||||
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
|
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
|
||||||
return try baseTemplate.render(context)
|
try baseTemplate.render(context)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// if error template is already set (see catch in BlockNode)
|
// if error template is already set (see catch in BlockNode)
|
||||||
@@ -117,14 +116,13 @@ class ExtendsNode : NodeType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class BlockNode: NodeType {
|
class BlockNode: NodeType {
|
||||||
let name: String
|
let name: String
|
||||||
let nodes: [NodeType]
|
let nodes: [NodeType]
|
||||||
let token: Token?
|
let token: Token?
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
let bits = token.components()
|
let bits = token.components
|
||||||
|
|
||||||
guard bits.count == 2 else {
|
guard bits.count == 2 else {
|
||||||
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
|
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
|
||||||
@@ -144,46 +142,23 @@ class BlockNode : NodeType {
|
|||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
func render(_ context: Context) throws -> String {
|
||||||
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
|
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
|
||||||
let childContext = try self.childContext(child, blockContext: blockContext, context: context)
|
let childContext: [String: Any] = [
|
||||||
|
BlockContext.contextKey: blockContext,
|
||||||
|
"block": ["super": try self.render(context)]
|
||||||
|
]
|
||||||
|
|
||||||
// render extension node
|
// render extension node
|
||||||
do {
|
do {
|
||||||
return try context.push(dictionary: childContext) {
|
return try context.push(dictionary: childContext) {
|
||||||
return try child.render(context)
|
try child.render(context)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
throw error.withToken(child.token)
|
throw error.withToken(child.token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return try renderNodes(nodes, context)
|
let result = try renderNodes(nodes, context)
|
||||||
}
|
context.cacheBlock(name, content: result)
|
||||||
|
return result
|
||||||
// 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 case .variable(let variable, _)? = $0.token, variable == "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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A structure used to represent a template variable, and to resolve it in a given context.
|
/// A structure used to represent a template variable, and to resolve it in a given context.
|
||||||
@@ -24,8 +30,8 @@ final class KeyPath {
|
|||||||
subscriptLevel = 0
|
subscriptLevel = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
for c in variable {
|
for character in variable {
|
||||||
switch c {
|
switch character {
|
||||||
case "." where subscriptLevel == 0:
|
case "." where subscriptLevel == 0:
|
||||||
try foundSeparator()
|
try foundSeparator()
|
||||||
case "[":
|
case "[":
|
||||||
@@ -33,7 +39,7 @@ final class KeyPath {
|
|||||||
case "]":
|
case "]":
|
||||||
try closeBracket()
|
try closeBracket()
|
||||||
default:
|
default:
|
||||||
try addCharacter(c)
|
try addCharacter(character)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try finish()
|
try finish()
|
||||||
@@ -90,12 +96,12 @@ final class KeyPath {
|
|||||||
subscriptLevel -= 1
|
subscriptLevel -= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addCharacter(_ c: Character) throws {
|
private func addCharacter(_ character: Character) throws {
|
||||||
guard partialComponents.isEmpty || subscriptLevel > 0 else {
|
guard partialComponents.isEmpty || subscriptLevel > 0 else {
|
||||||
throw TemplateSyntaxError("Unexpected character '\(c)' in variable '\(variable)'")
|
throw TemplateSyntaxError("Unexpected character '\(character)' in variable '\(variable)'")
|
||||||
}
|
}
|
||||||
|
|
||||||
current.append(c)
|
current.append(character)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finish() throws {
|
private func finish() throws {
|
||||||
63
Sources/Stencil/LazyValueWrapper.swift
Normal file
63
Sources/Stencil/LazyValueWrapper.swift
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
typealias Line = (content: String, number: UInt, range: Range<String.Index>)
|
typealias Line = (content: String, number: UInt, range: Range<String.Index>)
|
||||||
@@ -11,6 +17,9 @@ struct Lexer {
|
|||||||
/// `{` character, for example `{{`, `{%`, `{#`, ...
|
/// `{` character, for example `{{`, `{%`, `{#`, ...
|
||||||
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
|
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
|
||||||
|
|
||||||
|
/// The minimum length of a tag
|
||||||
|
private static let tagLength = 2
|
||||||
|
|
||||||
/// The token end characters, corresponding to their token start characters.
|
/// The token end characters, corresponding to their token start characters.
|
||||||
/// For example, a variable token starts with `{{` and ends with `}}`
|
/// For example, a variable token starts with `{{` and ends with `}}`
|
||||||
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
|
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
|
||||||
@@ -19,16 +28,33 @@ struct Lexer {
|
|||||||
"#": "#"
|
"#": "#"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Characters controlling whitespace trimming behaviour
|
||||||
|
private static let behaviourMap: [Character: WhitespaceBehaviour.Behaviour] = [
|
||||||
|
"+": .keep,
|
||||||
|
"-": .trim
|
||||||
|
]
|
||||||
|
|
||||||
init(templateName: String? = nil, templateString: String) {
|
init(templateName: String? = nil, templateString: String) {
|
||||||
self.templateName = templateName
|
self.templateName = templateName
|
||||||
self.templateString = templateString
|
self.templateString = templateString
|
||||||
|
|
||||||
self.lines = templateString.components(separatedBy: .newlines).enumerated().compactMap {
|
self.lines = zip(1..., templateString.components(separatedBy: .newlines)).compactMap { index, line in
|
||||||
guard !$0.element.isEmpty else { return nil }
|
guard !line.isEmpty,
|
||||||
return (content: $0.element, number: UInt($0.offset + 1), templateString.range(of: $0.element)!)
|
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
|
/// Create a token that will be passed on to the parser, with the given
|
||||||
/// content and a range. The content will be tested to see if it's a
|
/// content and a range. The content will be tested to see if it's a
|
||||||
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
|
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
|
||||||
@@ -39,18 +65,24 @@ struct Lexer {
|
|||||||
/// - range: The range within the template content, used for smart
|
/// - range: The range within the template content, used for smart
|
||||||
/// error reporting
|
/// error reporting
|
||||||
func createToken(string: String, at range: Range<String.Index>) -> Token {
|
func createToken(string: String, at range: Range<String.Index>) -> Token {
|
||||||
func strip() -> String {
|
func strip(length: (Int, Int) = (Self.tagLength, Self.tagLength)) -> String {
|
||||||
guard string.count > 4 else { return "" }
|
guard string.count > (length.0 + length.1) else { return "" }
|
||||||
let trimmed = String(string.dropFirst(2).dropLast(2))
|
let trimmed = String(string.dropFirst(length.0).dropLast(length.1))
|
||||||
.components(separatedBy: "\n")
|
.components(separatedBy: "\n")
|
||||||
.filter({ !$0.isEmpty })
|
.filter { !$0.isEmpty }
|
||||||
.map({ $0.trim(character: " ") })
|
.map { $0.trim(character: " ") }
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
|
if string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#") {
|
||||||
let value = strip()
|
let behaviour = string.hasPrefix("{%") ? behaviour(string: string, tagLength: Self.tagLength) : .unspecified
|
||||||
|
let stripLengths = (
|
||||||
|
Self.tagLength + (behaviour.leading != .unspecified ? 1 : 0),
|
||||||
|
Self.tagLength + (behaviour.trailing != .unspecified ? 1 : 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
let value = strip(length: stripLengths)
|
||||||
let range = templateString.range(of: value, range: range) ?? range
|
let range = templateString.range(of: value, range: range) ?? range
|
||||||
let location = rangeLocation(range)
|
let location = rangeLocation(range)
|
||||||
let sourceMap = SourceMap(filename: templateName, location: location)
|
let sourceMap = SourceMap(filename: templateName, location: location)
|
||||||
@@ -58,7 +90,7 @@ struct Lexer {
|
|||||||
if string.hasPrefix("{{") {
|
if string.hasPrefix("{{") {
|
||||||
return .variable(value: value, at: sourceMap)
|
return .variable(value: value, at: sourceMap)
|
||||||
} else if string.hasPrefix("{%") {
|
} else if string.hasPrefix("{%") {
|
||||||
return .block(value: value, at: sourceMap)
|
return .block(value: strip(length: stripLengths), at: sourceMap, whitespace: behaviour)
|
||||||
} else if string.hasPrefix("{#") {
|
} else if string.hasPrefix("{#") {
|
||||||
return .comment(value: value, at: sourceMap)
|
return .comment(value: value, at: sourceMap)
|
||||||
}
|
}
|
||||||
@@ -78,12 +110,12 @@ struct Lexer {
|
|||||||
|
|
||||||
let scanner = Scanner(templateString)
|
let scanner = Scanner(templateString)
|
||||||
while !scanner.isEmpty {
|
while !scanner.isEmpty {
|
||||||
if let (char, text) = scanner.scanForTokenStart(Lexer.tokenChars) {
|
if let (char, text) = scanner.scanForTokenStart(Self.tokenChars) {
|
||||||
if !text.isEmpty {
|
if !text.isEmpty {
|
||||||
tokens.append(createToken(string: text, at: scanner.range))
|
tokens.append(createToken(string: text, at: scanner.range))
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let end = Lexer.tokenCharMap[char] else { continue }
|
guard let end = Self.tokenCharMap[char] else { continue }
|
||||||
let result = scanner.scanForTokenEnd(end)
|
let result = scanner.scanForTokenEnd(end)
|
||||||
tokens.append(createToken(string: result, at: scanner.range))
|
tokens.append(createToken(string: result, at: scanner.range))
|
||||||
} else {
|
} else {
|
||||||
@@ -107,13 +139,12 @@ struct Lexer {
|
|||||||
let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound)
|
let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound)
|
||||||
return (line.content, line.number, offset)
|
return (line.content, line.number, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Scanner {
|
class Scanner {
|
||||||
let originalContent: String
|
let originalContent: String
|
||||||
var content: String
|
var content: String
|
||||||
var range: Range<String.Index>
|
var range: Range<String.UnicodeScalarView.Index>
|
||||||
|
|
||||||
/// The start delimiter for a token.
|
/// The start delimiter for a token.
|
||||||
private static let tokenStartDelimiter: Unicode.Scalar = "{"
|
private static let tokenStartDelimiter: Unicode.Scalar = "{"
|
||||||
@@ -123,11 +154,11 @@ class Scanner {
|
|||||||
init(_ content: String) {
|
init(_ content: String) {
|
||||||
self.originalContent = content
|
self.originalContent = content
|
||||||
self.content = content
|
self.content = content
|
||||||
range = content.startIndex..<content.startIndex
|
range = content.unicodeScalars.startIndex..<content.unicodeScalars.startIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEmpty: Bool {
|
var isEmpty: Bool {
|
||||||
return content.isEmpty
|
content.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scans for the end of a token, with a specific ending character. If we're
|
/// Scans for the end of a token, with a specific ending character. If we're
|
||||||
@@ -144,11 +175,11 @@ class Scanner {
|
|||||||
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
|
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
|
||||||
var foundChar = false
|
var foundChar = false
|
||||||
|
|
||||||
for (index, char) in content.unicodeScalars.enumerated() {
|
for (index, char) in zip(0..., content.unicodeScalars) {
|
||||||
if foundChar && char == Scanner.tokenEndDelimiter {
|
if foundChar && char == Self.tokenEndDelimiter {
|
||||||
let result = String(content.prefix(index + 1))
|
let result = String(content.unicodeScalars.prefix(index + 1))
|
||||||
content = String(content.dropFirst(index + 1))
|
content = String(content.unicodeScalars.dropFirst(index + 1))
|
||||||
range = range.upperBound..<originalContent.index(range.upperBound, offsetBy: index + 1)
|
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index + 1)
|
||||||
return result
|
return result
|
||||||
} else {
|
} else {
|
||||||
foundChar = (char == tokenChar)
|
foundChar = (char == tokenChar)
|
||||||
@@ -178,14 +209,14 @@ class Scanner {
|
|||||||
var foundBrace = false
|
var foundBrace = false
|
||||||
|
|
||||||
range = range.upperBound..<range.upperBound
|
range = range.upperBound..<range.upperBound
|
||||||
for (index, char) in content.unicodeScalars.enumerated() {
|
for (index, char) in zip(0..., content.unicodeScalars) {
|
||||||
if foundBrace && tokenChars.contains(char) {
|
if foundBrace && tokenChars.contains(char) {
|
||||||
let result = String(content.prefix(index - 1))
|
let result = String(content.unicodeScalars.prefix(index - 1))
|
||||||
content = String(content.dropFirst(index - 1))
|
content = String(content.unicodeScalars.dropFirst(index - 1))
|
||||||
range = range.upperBound..<originalContent.index(range.upperBound, offsetBy: index - 1)
|
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index - 1)
|
||||||
return (char, result)
|
return (char, result)
|
||||||
} else {
|
} else {
|
||||||
foundBrace = (char == Scanner.tokenStartDelimiter)
|
foundBrace = (char == Self.tokenStartDelimiter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,4 +258,5 @@ extension String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Location in some content (text)
|
||||||
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)
|
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PathKit
|
import PathKit
|
||||||
|
|
||||||
|
/// Type used for loading a template
|
||||||
public protocol Loader {
|
public protocol Loader {
|
||||||
|
/// Load a template with the given name
|
||||||
func loadTemplate(name: String, environment: Environment) throws -> Template
|
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
|
func loadTemplate(names: [String], environment: Environment) throws -> Template
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension Loader {
|
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 {
|
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||||
for name in names {
|
for name in names {
|
||||||
do {
|
do {
|
||||||
@@ -24,7 +32,6 @@ extension Loader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// A class for loading a template from disk
|
// A class for loading a template from disk
|
||||||
public class FileSystemLoader: Loader, CustomStringConvertible {
|
public class FileSystemLoader: Loader, CustomStringConvertible {
|
||||||
public let paths: [Path]
|
public let paths: [Path]
|
||||||
@@ -34,13 +41,13 @@ public class FileSystemLoader: Loader, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public init(bundle: [Bundle]) {
|
public init(bundle: [Bundle]) {
|
||||||
self.paths = bundle.map {
|
self.paths = bundle.map { bundle in
|
||||||
return Path($0.bundlePath)
|
Path(bundle.bundlePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
return "FileSystemLoader(\(paths))"
|
"FileSystemLoader(\(paths))"
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||||
@@ -74,7 +81,6 @@ public class FileSystemLoader: Loader, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public class DictionaryLoader: Loader {
|
public class DictionaryLoader: Loader {
|
||||||
public let templates: [String: String]
|
public let templates: [String: String]
|
||||||
|
|
||||||
@@ -101,7 +107,6 @@ public class DictionaryLoader: Loader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension Path {
|
extension Path {
|
||||||
func safeJoin(path: Path) throws -> Path {
|
func safeJoin(path: Path) throws -> Path {
|
||||||
let newPath = self + path
|
let newPath = self + path
|
||||||
@@ -114,7 +119,6 @@ extension Path {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SuspiciousFileOperation: Error {
|
class SuspiciousFileOperation: Error {
|
||||||
let basePath: Path
|
let basePath: Path
|
||||||
let path: Path
|
let path: Path
|
||||||
@@ -125,6 +129,6 @@ class SuspiciousFileOperation: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return "Path `\(path)` is located outside of base path `\(basePath)`"
|
"Path `\(path)` is located outside of base path `\(basePath)`"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
189
Sources/Stencil/Node.swift
Normal file
189
Sources/Stencil/Node.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
#if !os(Linux)
|
#if !os(Linux)
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
class NowNode: NodeType {
|
class NowNode: NodeType {
|
||||||
let format: Variable
|
let format: Variable
|
||||||
let token: Token?
|
let token: Token?
|
||||||
@@ -9,7 +14,7 @@ class NowNode : NodeType {
|
|||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
var format: Variable?
|
var format: Variable?
|
||||||
|
|
||||||
let components = token.components()
|
let components = token.components
|
||||||
guard components.count <= 2 else {
|
guard components.count <= 2 else {
|
||||||
throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
|
throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
|
||||||
}
|
}
|
||||||
@@ -28,18 +33,18 @@ class NowNode : NodeType {
|
|||||||
func render(_ context: Context) throws -> String {
|
func render(_ context: Context) throws -> String {
|
||||||
let date = Date()
|
let date = Date()
|
||||||
let format = try self.format.resolve(context)
|
let format = try self.format.resolve(context)
|
||||||
var formatter:DateFormatter?
|
|
||||||
|
|
||||||
|
var formatter: DateFormatter
|
||||||
if let format = format as? DateFormatter {
|
if let format = format as? DateFormatter {
|
||||||
formatter = format
|
formatter = format
|
||||||
} else if let format = format as? String {
|
} else if let format = format as? String {
|
||||||
formatter = DateFormatter()
|
formatter = DateFormatter()
|
||||||
formatter!.dateFormat = format
|
formatter.dateFormat = format
|
||||||
} else {
|
} else {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatter!.string(from: date)
|
return formatter.string(from: date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
278
Sources/Stencil/Parser.swift
Normal file
278
Sources/Stencil/Parser.swift
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
/// 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PathKit
|
import PathKit
|
||||||
|
|
||||||
#if os(Linux)
|
#if os(Linux)
|
||||||
|
// swiftlint:disable:next prefixed_toplevel_constant
|
||||||
let NSFileNoSuchFileError = 4
|
let NSFileNoSuchFileError = 4
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// A class representing a template
|
/// A class representing a template
|
||||||
open class Template: ExpressibleByStringLiteral {
|
open class Template: ExpressibleByStringLiteral {
|
||||||
let templateString: String
|
let templateString: String
|
||||||
internal(set) var environment: Environment
|
var environment: Environment
|
||||||
let tokens: [Token]
|
|
||||||
|
/// The list of parsed (lexed) tokens
|
||||||
|
public let tokens: [Token]
|
||||||
|
|
||||||
/// The name of the loaded Template if the Template was loaded from a Loader
|
/// The name of the loaded Template if the Template was loaded from a Loader
|
||||||
public let name: String?
|
public let name: String?
|
||||||
@@ -50,30 +59,31 @@ open class Template: ExpressibleByStringLiteral {
|
|||||||
// MARK: ExpressibleByStringLiteral
|
// MARK: ExpressibleByStringLiteral
|
||||||
|
|
||||||
// Create a templaVte with a template string literal
|
// Create a templaVte with a template string literal
|
||||||
public convenience required init(stringLiteral value: String) {
|
public required convenience init(stringLiteral value: String) {
|
||||||
self.init(templateString: value)
|
self.init(templateString: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a template with a template string literal
|
// Create a template with a template string literal
|
||||||
public convenience required init(extendedGraphemeClusterLiteral value: StringLiteralType) {
|
public required convenience init(extendedGraphemeClusterLiteral value: StringLiteralType) {
|
||||||
self.init(stringLiteral: value)
|
self.init(stringLiteral: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a template with a template string literal
|
// Create a template with a template string literal
|
||||||
public convenience required init(unicodeScalarLiteral value: StringLiteralType) {
|
public required convenience init(unicodeScalarLiteral value: StringLiteralType) {
|
||||||
self.init(stringLiteral: value)
|
self.init(stringLiteral: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the given template with a context
|
/// Render the given template with a context
|
||||||
func render(_ context: Context) throws -> String {
|
public func render(_ context: Context) throws -> String {
|
||||||
let context = context
|
let context = context
|
||||||
let parser = TokenParser(tokens: tokens, environment: context.environment)
|
let parser = TokenParser(tokens: tokens, environment: context.environment)
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
return try renderNodes(nodes, context)
|
return try renderNodes(nodes, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable discouraged_optional_collection
|
||||||
/// Render the given template
|
/// Render the given template
|
||||||
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
||||||
return try render(Context(dictionary: dictionary, environment: environment))
|
try render(Context(dictionary: dictionary ?? [:], environment: environment))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
160
Sources/Stencil/Tokenizer.swift
Normal file
160
Sources/Stencil/Tokenizer.swift
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
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 = SourceMap()
|
||||||
|
|
||||||
|
public static func == (lhs: SourceMap, rhs: SourceMap) -> 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 = WhitespaceBehaviour(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
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Sources/Stencil/TrimBehaviour.swift
Normal file
76
Sources/Stencil/TrimBehaviour.swift
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct TrimBehaviour: Equatable {
|
||||||
|
var leading: Trim
|
||||||
|
var trailing: Trim
|
||||||
|
|
||||||
|
public enum Trim {
|
||||||
|
/// nothing
|
||||||
|
case nothing
|
||||||
|
|
||||||
|
/// tabs and spaces
|
||||||
|
case whitespace
|
||||||
|
|
||||||
|
/// tabs and spaces and a single new line
|
||||||
|
case whitespaceAndOneNewLine
|
||||||
|
|
||||||
|
/// all tabs spaces and newlines
|
||||||
|
case whitespaceAndNewLines
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(leading: Trim, trailing: Trim) {
|
||||||
|
self.leading = leading
|
||||||
|
self.trailing = trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// doesn't touch newlines
|
||||||
|
public static let nothing = TrimBehaviour(leading: .nothing, trailing: .nothing)
|
||||||
|
|
||||||
|
/// removes whitespace before a block and whitespace and a single newline after a block
|
||||||
|
public static let smart = TrimBehaviour(leading: .whitespace, trailing: .whitespaceAndOneNewLine)
|
||||||
|
|
||||||
|
/// removes all whitespace and newlines before and after a block
|
||||||
|
public static let all = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
|
||||||
|
|
||||||
|
static func leadingRegex(trim: Trim) -> NSRegularExpression {
|
||||||
|
switch trim {
|
||||||
|
case .nothing:
|
||||||
|
fatalError("No RegularExpression for none")
|
||||||
|
case .whitespace:
|
||||||
|
return Self.leadingWhitespace
|
||||||
|
case .whitespaceAndOneNewLine:
|
||||||
|
return Self.leadingWhitespaceAndOneNewLine
|
||||||
|
case .whitespaceAndNewLines:
|
||||||
|
return Self.leadingWhitespaceAndNewlines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func trailingRegex(trim: Trim) -> NSRegularExpression {
|
||||||
|
switch trim {
|
||||||
|
case .nothing:
|
||||||
|
fatalError("No RegularExpression for none")
|
||||||
|
case .whitespace:
|
||||||
|
return Self.trailingWhitespace
|
||||||
|
case .whitespaceAndOneNewLine:
|
||||||
|
return Self.trailingWhitespaceAndOneNewLine
|
||||||
|
case .whitespaceAndNewLines:
|
||||||
|
return Self.trailingWhitespaceAndNewLines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable force_try
|
||||||
|
private static let leadingWhitespaceAndNewlines = try! NSRegularExpression(pattern: "^\\s+")
|
||||||
|
private static let trailingWhitespaceAndNewLines = try! NSRegularExpression(pattern: "\\s+$")
|
||||||
|
|
||||||
|
private static let leadingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "^[ \t]*\n")
|
||||||
|
private static let trailingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "\n[ \t]*$")
|
||||||
|
|
||||||
|
private static let leadingWhitespace = try! NSRegularExpression(pattern: "^[ \t]*")
|
||||||
|
private static let trailingWhitespace = try! NSRegularExpression(pattern: "[ \t]*$")
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
typealias Number = Float
|
typealias Number = Float
|
||||||
|
|
||||||
|
|
||||||
class FilterExpression: Resolvable {
|
class FilterExpression: Resolvable {
|
||||||
let filters: [(FilterType, [Variable])]
|
let filters: [(FilterType, [Variable])]
|
||||||
let variable: Variable
|
let variable: Variable
|
||||||
|
|
||||||
init(token: String, parser: TokenParser) throws {
|
init(token: String, environment: Environment) throws {
|
||||||
let bits = token.split(separator: "|").map({ String($0).trim(character: " ") })
|
let bits = token.smartSplit(separator: "|").map { String($0).trim(character: " ") }
|
||||||
if bits.isEmpty {
|
if bits.isEmpty {
|
||||||
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
|
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
|
||||||
}
|
}
|
||||||
@@ -18,9 +22,9 @@ class FilterExpression : Resolvable {
|
|||||||
let filterBits = bits[bits.indices.suffix(from: 1)]
|
let filterBits = bits[bits.indices.suffix(from: 1)]
|
||||||
|
|
||||||
do {
|
do {
|
||||||
filters = try filterBits.map {
|
filters = try filterBits.map { bit in
|
||||||
let (name, arguments) = parseFilterComponents(token: $0)
|
let (name, arguments) = parseFilterComponents(token: bit)
|
||||||
let filter = try parser.findFilter(name)
|
let filter = try environment.findFilter(name)
|
||||||
return (filter, arguments)
|
return (filter, arguments)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -32,9 +36,9 @@ class FilterExpression : Resolvable {
|
|||||||
func resolve(_ context: Context) throws -> Any? {
|
func resolve(_ context: Context) throws -> Any? {
|
||||||
let result = try variable.resolve(context)
|
let result = try variable.resolve(context)
|
||||||
|
|
||||||
return try filters.reduce(result) { x, y in
|
return try filters.reduce(result) { value, filter in
|
||||||
let arguments = try y.1.map { try $0.resolve(context) }
|
let arguments = try filter.1.map { try $0.resolve(context) }
|
||||||
return try y.0.invoke(value: x, arguments: arguments)
|
return try filter.0.invoke(value: value, arguments: arguments, context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,17 +52,10 @@ public struct Variable : Equatable, Resolvable {
|
|||||||
self.variable = variable
|
self.variable = variable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split the lookup string and resolve references if possible
|
|
||||||
fileprivate func lookup(_ context: Context) throws -> [String] {
|
|
||||||
let keyPath = KeyPath(variable, in: context)
|
|
||||||
return try keyPath.parse()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the variable in the given context
|
/// Resolve the variable in the given context
|
||||||
public func resolve(_ context: Context) throws -> Any? {
|
public func resolve(_ context: Context) throws -> Any? {
|
||||||
var current: Any? = context
|
if variable.count > 1 &&
|
||||||
|
((variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\""))) {
|
||||||
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
|
|
||||||
// String literal
|
// String literal
|
||||||
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
|
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
|
||||||
}
|
}
|
||||||
@@ -75,36 +72,14 @@ public struct Variable : Equatable, Resolvable {
|
|||||||
return bool
|
return bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var current: Any? = context
|
||||||
for bit in try lookup(context) {
|
for bit in try lookup(context) {
|
||||||
current = normalize(current)
|
current = resolve(bit: bit, context: current)
|
||||||
|
|
||||||
if let context = current as? Context {
|
|
||||||
current = context[bit]
|
|
||||||
} else if let dictionary = current as? [String: Any] {
|
|
||||||
if bit == "count" {
|
|
||||||
current = dictionary.count
|
|
||||||
} else {
|
|
||||||
current = dictionary[bit]
|
|
||||||
}
|
|
||||||
} else if let array = current as? [Any] {
|
|
||||||
current = resolveCollection(array, bit: bit)
|
|
||||||
} else if let string = current as? String {
|
|
||||||
current = resolveCollection(string, bit: bit)
|
|
||||||
} else if let object = current as? NSObject { // NSKeyValueCoding
|
|
||||||
#if os(Linux)
|
|
||||||
return nil
|
|
||||||
#else
|
|
||||||
if object.responds(to: Selector(bit)) {
|
|
||||||
current = object.value(forKey: bit)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
} else if let value = current {
|
|
||||||
current = Mirror(reflecting: value).getValue(for: bit)
|
|
||||||
if current == nil {
|
if current == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
} else if let lazyCurrent = current as? LazyValueWrapper {
|
||||||
} else {
|
current = try lazyCurrent.value(context: context)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +91,53 @@ public struct Variable : Equatable, Resolvable {
|
|||||||
|
|
||||||
return normalize(current)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveCollection<T: Collection>(_ collection: T, bit: String) -> Any? {
|
// 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)
|
||||||
|
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 let index = Int(bit) {
|
||||||
if index >= 0 && index < collection.count {
|
if index >= 0 && index < collection.count {
|
||||||
return collection[collection.index(collection.startIndex, offsetBy: index)]
|
return collection[collection.index(collection.startIndex, offsetBy: index)]
|
||||||
@@ -135,6 +154,7 @@ private func resolveCollection<T: Collection>(_ collection: T, bit: String) -> A
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A structure used to represet range of two integer values expressed as `from...to`.
|
/// A structure used to represet range of two integer values expressed as `from...to`.
|
||||||
/// Values should be numbers (they will be converted to integers).
|
/// Values should be numbers (they will be converted to integers).
|
||||||
@@ -142,48 +162,46 @@ private func resolveCollection<T: Collection>(_ collection: T, bit: String) -> A
|
|||||||
/// If `from` is more than `to` array will contain values of reversed range.
|
/// If `from` is more than `to` array will contain values of reversed range.
|
||||||
public struct RangeVariable: Resolvable {
|
public struct RangeVariable: Resolvable {
|
||||||
public let from: Resolvable
|
public let from: Resolvable
|
||||||
|
// swiftlint:disable:next identifier_name
|
||||||
public let to: Resolvable
|
public let to: Resolvable
|
||||||
|
|
||||||
@available(*, deprecated, message: "Use init?(_:parser:containedIn:)")
|
public init?(_ token: String, environment: Environment) throws {
|
||||||
public init?(_ token: String, parser: TokenParser) throws {
|
|
||||||
let components = token.components(separatedBy: "...")
|
let components = token.components(separatedBy: "...")
|
||||||
guard components.count == 2 else {
|
guard components.count == 2 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.from = try parser.compileFilter(components[0])
|
self.from = try environment.compileFilter(components[0])
|
||||||
self.to = try parser.compileFilter(components[1])
|
self.to = try environment.compileFilter(components[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
public init?(_ token: String, parser: TokenParser, containedIn containingToken: Token) throws {
|
public init?(_ token: String, environment: Environment, containedIn containingToken: Token) throws {
|
||||||
let components = token.components(separatedBy: "...")
|
let components = token.components(separatedBy: "...")
|
||||||
guard components.count == 2 else {
|
guard components.count == 2 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
self.from = try parser.compileFilter(components[0], containedIn: containingToken)
|
self.from = try environment.compileFilter(components[0], containedIn: containingToken)
|
||||||
self.to = try parser.compileFilter(components[1], containedIn: containingToken)
|
self.to = try environment.compileFilter(components[1], containedIn: containingToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func resolve(_ context: Context) throws -> Any? {
|
public func resolve(_ context: Context) throws -> Any? {
|
||||||
let fromResolved = try from.resolve(context)
|
let lowerResolved = try from.resolve(context)
|
||||||
let toResolved = try to.resolve(context)
|
let upperResolved = try to.resolve(context)
|
||||||
|
|
||||||
guard let from = fromResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
guard let lower = lowerResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||||
throw TemplateSyntaxError("'from' value is not an Integer (\(fromResolved ?? "nil"))")
|
throw TemplateSyntaxError("'from' value is not an Integer (\(lowerResolved ?? "nil"))")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let to = toResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
guard let upper = upperResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||||
throw TemplateSyntaxError("'to' value is not an Integer (\(toResolved ?? "nil") )")
|
throw TemplateSyntaxError("'to' value is not an Integer (\(upperResolved ?? "nil") )")
|
||||||
}
|
}
|
||||||
|
|
||||||
let range = min(from, to)...max(from, to)
|
let range = min(lower, upper)...max(lower, upper)
|
||||||
return from > to ? Array(range.reversed()) : Array(range)
|
return lower > upper ? Array(range.reversed()) : Array(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func normalize(_ current: Any?) -> Any? {
|
func normalize(_ current: Any?) -> Any? {
|
||||||
if let current = current as? Normalizable {
|
if let current = current as? Normalizable {
|
||||||
return current.normalize()
|
return current.normalize()
|
||||||
@@ -198,13 +216,14 @@ protocol Normalizable {
|
|||||||
|
|
||||||
extension Array: Normalizable {
|
extension Array: Normalizable {
|
||||||
func normalize() -> Any? {
|
func normalize() -> Any? {
|
||||||
return map { $0 as Any }
|
map { $0 as Any }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next legacy_objc_type
|
||||||
extension NSArray: Normalizable {
|
extension NSArray: Normalizable {
|
||||||
func normalize() -> Any? {
|
func normalize() -> Any? {
|
||||||
return map { $0 as Any }
|
map { $0 as Any }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +255,7 @@ func parseFilterComponents(token: String) -> (String, [Variable]) {
|
|||||||
|
|
||||||
extension Mirror {
|
extension Mirror {
|
||||||
func getValue(for key: String) -> Any? {
|
func getValue(for key: String) -> Any? {
|
||||||
let result = descendant(key) ?? Int(key).flatMap({ descendant($0) })
|
let result = descendant(key) ?? Int(key).flatMap { descendant($0) }
|
||||||
if result == nil {
|
if result == nil {
|
||||||
// go through inheritance chain to reach superclass properties
|
// go through inheritance chain to reach superclass properties
|
||||||
return superclassMirror?.getValue(for: key)
|
return superclassMirror?.getValue(for: key)
|
||||||
@@ -263,10 +282,10 @@ protocol AnyOptional {
|
|||||||
extension Optional: AnyOptional {
|
extension Optional: AnyOptional {
|
||||||
var wrapped: Any? {
|
var wrapped: Any? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .some(value): return value
|
case let .some(value):
|
||||||
case .none: return nil
|
return value
|
||||||
|
case .none:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,117 +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
|
|
||||||
|
|
||||||
let specialCharacters = ",|:"
|
|
||||||
func appendWord(_ word: String) {
|
|
||||||
if components.count > 0 {
|
|
||||||
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 {
|
|
||||||
components.append(word)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
components.append(word)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
word = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
separate = separator
|
|
||||||
} else {
|
|
||||||
if separate == separator && (character == "'" || character == "\"") {
|
|
||||||
separate = character
|
|
||||||
}
|
|
||||||
word.append(character)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !word.isEmpty {
|
|
||||||
appendWord(word)
|
|
||||||
}
|
|
||||||
|
|
||||||
return components
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 enum Token : Equatable {
|
|
||||||
/// A token representing a piece of text.
|
|
||||||
case text(value: String, at: SourceMap)
|
|
||||||
|
|
||||||
/// A token representing a variable.
|
|
||||||
case variable(value: String, at: SourceMap)
|
|
||||||
|
|
||||||
/// A token representing a comment.
|
|
||||||
case comment(value: String, at: SourceMap)
|
|
||||||
|
|
||||||
/// A token representing a template block.
|
|
||||||
case block(value: String, at: SourceMap)
|
|
||||||
|
|
||||||
/// Returns the underlying value as an array seperated by spaces
|
|
||||||
public func components() -> [String] {
|
|
||||||
switch self {
|
|
||||||
case .block(let value, _),
|
|
||||||
.variable(let value, _),
|
|
||||||
.text(let value, _),
|
|
||||||
.comment(let value, _):
|
|
||||||
return value.smartSplit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var contents: String {
|
|
||||||
switch self {
|
|
||||||
case .block(let value, _),
|
|
||||||
.variable(let value, _),
|
|
||||||
.text(let value, _),
|
|
||||||
.comment(let value, _):
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var sourceMap: SourceMap {
|
|
||||||
switch self {
|
|
||||||
case .block(_, let sourceMap),
|
|
||||||
.variable(_, let sourceMap),
|
|
||||||
.text(_, let sourceMap),
|
|
||||||
.comment(_, let sourceMap):
|
|
||||||
return sourceMap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Stencil",
|
"name": "Stencil",
|
||||||
"version": "0.13.1",
|
"version": "0.15.1",
|
||||||
"summary": "Stencil is a simple and powerful template language for Swift.",
|
"summary": "Stencil is a simple and powerful template language for Swift.",
|
||||||
"homepage": "https://stencil.fuller.li",
|
"homepage": "https://stencil.fuller.li",
|
||||||
"license": {
|
"license": {
|
||||||
@@ -13,22 +13,24 @@
|
|||||||
"social_media_url": "https://twitter.com/kylefuller",
|
"social_media_url": "https://twitter.com/kylefuller",
|
||||||
"source": {
|
"source": {
|
||||||
"git": "https://github.com/stencilproject/Stencil.git",
|
"git": "https://github.com/stencilproject/Stencil.git",
|
||||||
"tag": "0.13.1"
|
"tag": "0.15.1"
|
||||||
},
|
},
|
||||||
"source_files": [
|
"source_files": [
|
||||||
"Sources/*.swift"
|
"Sources/Stencil/*.swift"
|
||||||
],
|
],
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"ios": "8.0",
|
"ios": "8.0",
|
||||||
"osx": "10.9",
|
"osx": "10.9",
|
||||||
"tvos": "9.0"
|
"tvos": "9.0"
|
||||||
},
|
},
|
||||||
"cocoapods_version": ">= 1.4.0",
|
"cocoapods_version": ">= 1.7.0",
|
||||||
"swift_version": "4.2",
|
"swift_versions": [
|
||||||
|
"5.0"
|
||||||
|
],
|
||||||
"requires_arc": true,
|
"requires_arc": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"PathKit": [
|
"PathKit": [
|
||||||
"~> 0.9.0"
|
"~> 1.0.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
import StencilTests
|
|
||||||
|
|
||||||
var tests = [XCTestCaseEntry]()
|
|
||||||
tests += StencilTests.__allTests()
|
|
||||||
|
|
||||||
XCTMain(tests)
|
|
||||||
5
Tests/StencilTests/.swiftlint.yml
Normal file
5
Tests/StencilTests/.swiftlint.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
parent_config: ../../.swiftlint.yml
|
||||||
|
|
||||||
|
disabled_rules: # rule identifiers to exclude from running
|
||||||
|
- type_body_length
|
||||||
|
- file_length
|
||||||
@@ -1,48 +1,60 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ContextTests: XCTestCase {
|
||||||
class ContextTests: XCTestCase {
|
func testContextSubscripting() {
|
||||||
|
describe("Context Subscripting") { test in
|
||||||
func testContext() {
|
var context = Context()
|
||||||
describe("Context") {
|
test.before {
|
||||||
var context: Context!
|
|
||||||
|
|
||||||
$0.before {
|
|
||||||
context = Context(dictionary: ["name": "Kyle"])
|
context = Context(dictionary: ["name": "Kyle"])
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to get a value via subscripting") {
|
test.it("allows you to get a value via subscripting") {
|
||||||
try expect(context["name"] as? String) == "Kyle"
|
try expect(context["name"] as? String) == "Kyle"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to set a value via subscripting") {
|
test.it("allows you to set a value via subscripting") {
|
||||||
context["name"] = "Katie"
|
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") {
|
test.it("allows you to remove a value via subscripting") {
|
||||||
context["name"] = nil
|
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") {
|
test.it("allows you to retrieve a value from a parent") {
|
||||||
try context.push {
|
try context.push {
|
||||||
try expect(context["name"] as? String) == "Kyle"
|
try expect(context["name"] as? String) == "Kyle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to override a parent's value") {
|
test.it("allows you to override a parent's value") {
|
||||||
try context.push {
|
try context.push {
|
||||||
context["name"] = "Katie"
|
context["name"] = "Katie"
|
||||||
try expect(context["name"] as? String) == "Katie"
|
try expect(context["name"] as? String) == "Katie"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$0.it("allows you to pop to restore previous state") {
|
func testContextRestoration() {
|
||||||
|
describe("Context Restoration") { test in
|
||||||
|
var context = Context()
|
||||||
|
test.before {
|
||||||
|
context = Context(dictionary: ["name": "Kyle"])
|
||||||
|
}
|
||||||
|
|
||||||
|
test.it("allows you to pop to restore previous state") {
|
||||||
context.push {
|
context.push {
|
||||||
context["name"] = "Katie"
|
context["name"] = "Katie"
|
||||||
}
|
}
|
||||||
@@ -50,7 +62,7 @@ class ContextTests: XCTestCase {
|
|||||||
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") {
|
test.it("allows you to remove a parent's value in a level") {
|
||||||
try context.push {
|
try context.push {
|
||||||
context["name"] = nil
|
context["name"] = nil
|
||||||
try expect(context["name"]).to.beNil()
|
try expect(context["name"]).to.beNil()
|
||||||
@@ -59,7 +71,7 @@ class ContextTests: XCTestCase {
|
|||||||
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") {
|
test.it("allows you to push a dictionary and run a closure then restoring previous state") {
|
||||||
var didRun = false
|
var didRun = false
|
||||||
|
|
||||||
try context.push(dictionary: ["name": "Katie"]) {
|
try context.push(dictionary: ["name": "Katie"]) {
|
||||||
@@ -71,7 +83,7 @@ class ContextTests: XCTestCase {
|
|||||||
try expect(context["name"] as? String) == "Kyle"
|
try expect(context["name"] as? String) == "Kyle"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to flatten the context contents") {
|
test.it("allows you to flatten the context contents") {
|
||||||
try context.push(dictionary: ["test": "abc"]) {
|
try context.push(dictionary: ["test": "abc"]) {
|
||||||
let flattened = context.flatten()
|
let flattened = context.flatten()
|
||||||
|
|
||||||
@@ -82,4 +94,73 @@ class ContextTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
Tests/StencilTests/EnvironmentBaseAndChildTemplateSpec.swift
Normal file
131
Tests/StencilTests/EnvironmentBaseAndChildTemplateSpec.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Tests/StencilTests/EnvironmentIncludeTemplateSpec.swift
Normal file
94
Tests/StencilTests/EnvironmentIncludeTemplateSpec.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,374 +1,227 @@
|
|||||||
import XCTest
|
//
|
||||||
import Spectre
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import PathKit
|
import PathKit
|
||||||
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class EnvironmentTests: XCTestCase {
|
final class EnvironmentTests: XCTestCase {
|
||||||
func testEnvironment() {
|
private var environment = Environment(loader: ExampleLoader())
|
||||||
describe("Environment") {
|
private var template: Template = ""
|
||||||
var environment: Environment!
|
|
||||||
var template: Template!
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
$0.before {
|
|
||||||
environment = Environment(loader: ExampleLoader())
|
environment = Environment(loader: ExampleLoader())
|
||||||
template = nil
|
environment.extensions += [errorExtension]
|
||||||
|
template = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can load a template from a name") {
|
override func tearDown() {
|
||||||
let template = try environment.loadTemplate(name: "example.html")
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
try expect(template.name) == "example.html"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can load a template from a names") {
|
it("can load a template from a names") {
|
||||||
let template = try environment.loadTemplate(names: ["first.html", "example.html"])
|
let template = try self.environment.loadTemplate(names: ["first.html", "example.html"])
|
||||||
try expect(template.name) == "example.html"
|
try expect(template.name) == "example.html"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$0.it("can render a template from a string") {
|
func testRendering() {
|
||||||
let result = try environment.renderTemplate(string: "Hello World")
|
it("can render a template from a string") {
|
||||||
|
let result = try self.environment.renderTemplate(string: "Hello World")
|
||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can render a template from a file") {
|
it("can render a template from a file") {
|
||||||
let result = try environment.renderTemplate(name: "example.html")
|
let result = try self.environment.renderTemplate(name: "example.html")
|
||||||
try expect(result) == "Hello World!"
|
try expect(result) == "Hello World!"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to provide a custom template class") {
|
it("allows you to provide a custom template class") {
|
||||||
let environment = Environment(loader: ExampleLoader(), templateClass: CustomTemplate.self)
|
let environment = Environment(loader: ExampleLoader(), templateClass: CustomTemplate.self)
|
||||||
let result = try environment.renderTemplate(string: "Hello World")
|
let result = try environment.renderTemplate(string: "Hello World")
|
||||||
|
|
||||||
try expect(result) == "here"
|
try expect(result) == "here"
|
||||||
}
|
}
|
||||||
|
|
||||||
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: [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func expectError(reason: String, token: String,
|
func testSyntaxError() {
|
||||||
file: String = #file, line: Int = #line, function: String = #function) throws {
|
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 missing endfor") {
|
||||||
|
self.template = "{% for name in names %}{{ name }}"
|
||||||
|
try self.expectError(reason: "`endfor` was not found.", token: "for name in names")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 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 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 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 variable tag") {
|
||||||
|
self.template = "{{ name|unknown }}"
|
||||||
|
try self.expectError(
|
||||||
|
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
|
||||||
|
token: "name|unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports error in variable tag") {
|
||||||
|
self.template = "{{ }}"
|
||||||
|
try self.expectError(reason: "Missing variable name", token: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 filter tag") {
|
||||||
|
self.template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: self.environment)
|
||||||
|
try self.expectError(reason: "filter error", token: "filter 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 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 custom tag") {
|
||||||
|
self.template = Template(templateString: "{% customtag %}", 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 expectedError = expectedSyntaxError(token: token, template: template, description: reason)
|
||||||
|
|
||||||
let error = try expect(environment.render(template: template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
|
let error = try expect(
|
||||||
file: file, line: line, function: function).toThrow() as TemplateSyntaxError
|
self.environment.render(template: self.template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function
|
||||||
|
).toThrow() as TemplateSyntaxError
|
||||||
let reporter = SimpleErrorReporter()
|
let reporter = SimpleErrorReporter()
|
||||||
try expect(reporter.renderError(error), file: file, line: line, function: function) == reporter.renderError(expectedError)
|
try expect(
|
||||||
}
|
reporter.renderError(error),
|
||||||
|
file: file,
|
||||||
$0.context("given syntax error") {
|
line: line,
|
||||||
|
function: function
|
||||||
$0.it("reports syntax error on invalid for tag syntax") {
|
) == reporter.renderError(expectedError)
|
||||||
template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
|
|
||||||
try expectError(reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.", token: "for name in")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error on missing endfor") {
|
|
||||||
template = "{% for name in names %}{{ name }}"
|
|
||||||
try expectError(reason: "`endfor` was not found.", token: "for name in names")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error on unknown tag") {
|
|
||||||
template = "{% for name in names %}{{ name }}{% end %}"
|
|
||||||
try expectError(reason: "Unknown template tag 'end'", token: "end")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.context("given unknown filter") {
|
|
||||||
|
|
||||||
$0.it("reports syntax error in for tag") {
|
|
||||||
template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
|
|
||||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "names|unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in for-where tag") {
|
|
||||||
template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}"
|
|
||||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in if tag") {
|
|
||||||
template = "{% if name|unknown %}{{ name }}{% endif %}"
|
|
||||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in elif tag") {
|
|
||||||
template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}"
|
|
||||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in ifnot tag") {
|
|
||||||
template = "{% ifnot name|unknown %}{{ name }}{% endif %}"
|
|
||||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in filter tag") {
|
|
||||||
template = "{% filter unknown %}Text{% endfilter %}"
|
|
||||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "filter unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in variable tag") {
|
|
||||||
template = "{{ name|unknown }}"
|
|
||||||
try expectError(reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", token: "name|unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.context("given rendering error") {
|
|
||||||
|
|
||||||
$0.it("reports rendering error in variable filter") {
|
|
||||||
let filterExtension = Extension()
|
|
||||||
filterExtension.registerFilter("throw") { (value: Any?) in
|
|
||||||
throw TemplateSyntaxError("filter error")
|
|
||||||
}
|
|
||||||
environment.extensions += [filterExtension]
|
|
||||||
|
|
||||||
template = Template(templateString: "{{ name|throw }}", environment: environment)
|
|
||||||
try expectError(reason: "filter error", token: "name|throw")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports rendering error in filter tag") {
|
|
||||||
let filterExtension = Extension()
|
|
||||||
filterExtension.registerFilter("throw") { (value: Any?) in
|
|
||||||
throw TemplateSyntaxError("filter error")
|
|
||||||
}
|
|
||||||
environment.extensions += [filterExtension]
|
|
||||||
|
|
||||||
template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: environment)
|
|
||||||
try expectError(reason: "filter error", token: "filter throw")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports rendering error in simple tag") {
|
|
||||||
let tagExtension = Extension()
|
|
||||||
tagExtension.registerSimpleTag("simpletag") { context in
|
|
||||||
throw TemplateSyntaxError("simpletag error")
|
|
||||||
}
|
|
||||||
environment.extensions += [tagExtension]
|
|
||||||
|
|
||||||
template = Template(templateString: "{% simpletag %}", environment: environment)
|
|
||||||
try expectError(reason: "simpletag error", token: "simpletag")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reporsts passing argument to simple filter") {
|
|
||||||
template = "{{ name|uppercase:5 }}"
|
|
||||||
try expectError(reason: "cannot invoke filter with an argument", token: "name|uppercase:5")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports rendering error in custom tag") {
|
|
||||||
let tagExtension = Extension()
|
|
||||||
tagExtension.registerTag("customtag") { parser, token in
|
|
||||||
return ErrorNode(token: token)
|
|
||||||
}
|
|
||||||
environment.extensions += [tagExtension]
|
|
||||||
|
|
||||||
template = Template(templateString: "{% customtag %}", environment: environment)
|
|
||||||
try expectError(reason: "Custom Error", token: "customtag")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports rendering error in for body") {
|
|
||||||
let tagExtension = Extension()
|
|
||||||
tagExtension.registerTag("customtag") { parser, token in
|
|
||||||
return ErrorNode(token: token)
|
|
||||||
}
|
|
||||||
environment.extensions += [tagExtension]
|
|
||||||
|
|
||||||
template = Template(templateString: "{% for name in names %}{% customtag %}{% endfor %}", environment: environment)
|
|
||||||
try expectError(reason: "Custom Error", token: "customtag")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports rendering error in block") {
|
|
||||||
let tagExtension = Extension()
|
|
||||||
tagExtension.registerTag("customtag") { parser, token in
|
|
||||||
return ErrorNode(token: token)
|
|
||||||
}
|
|
||||||
environment.extensions += [tagExtension]
|
|
||||||
|
|
||||||
template = Template(templateString: "{% block some %}{% customtag %}{% endblock %}", environment: environment)
|
|
||||||
try expectError(reason: "Custom Error", token: "customtag")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.context("given included template") {
|
// MARK: - Helpers
|
||||||
let path = Path(#file) + ".." + "fixtures"
|
|
||||||
let loader = FileSystemLoader(paths: [path])
|
|
||||||
var environment = Environment(loader: loader)
|
|
||||||
var template: Template!
|
|
||||||
var includedTemplate: Template!
|
|
||||||
|
|
||||||
$0.before {
|
private class CustomTemplate: Template {
|
||||||
environment = Environment(loader: loader)
|
// swiftlint:disable discouraged_optional_collection
|
||||||
template = nil
|
|
||||||
includedTemplate = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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!]
|
|
||||||
|
|
||||||
let error = try expect(environment.render(template: 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in included template") {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports runtime error in included template") {
|
|
||||||
let filterExtension = Extension()
|
|
||||||
filterExtension.registerFilter("unknown", filter: { (_: 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.context("given base and child templates") {
|
|
||||||
let path = Path(#file) + ".." + "fixtures"
|
|
||||||
let loader = FileSystemLoader(paths: [path])
|
|
||||||
var environment: Environment!
|
|
||||||
var childTemplate: Template!
|
|
||||||
var baseTemplate: Template!
|
|
||||||
|
|
||||||
$0.before {
|
|
||||||
environment = Environment(loader: loader)
|
|
||||||
childTemplate = nil
|
|
||||||
baseTemplate = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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!]
|
|
||||||
}
|
|
||||||
let error = try expect(environment.render(template: 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in base template") {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports runtime error in base template") {
|
|
||||||
let filterExtension = Extension()
|
|
||||||
filterExtension.registerFilter("unknown", filter: { (_: 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports syntax error in child template") {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("reports runtime error in child template") {
|
|
||||||
let filterExtension = Extension()
|
|
||||||
filterExtension.registerFilter("unknown", filter: { (_: 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Expectation {
|
|
||||||
@discardableResult
|
|
||||||
func toThrow<T: Error>() throws -> T {
|
|
||||||
var thrownError: Error? = nil
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate 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 CustomTemplate: Template {
|
|
||||||
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
||||||
return "here"
|
"here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,346 +1,361 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class ExpressionsTests: XCTestCase {
|
final class ExpressionsTests: XCTestCase {
|
||||||
func testExpressions() {
|
private let parser = TokenParser(tokens: [], environment: Environment())
|
||||||
describe("Expression") {
|
|
||||||
let parser = TokenParser(tokens: [], environment: Environment())
|
|
||||||
|
|
||||||
func parseExpression(components: [String]) throws -> Expression {
|
private func makeExpression(_ components: [String]) -> Expression {
|
||||||
let parser = try IfExpressionParser.parser(components: components, tokenParser: parser, token: .text(value: "", at: .unknown))
|
do {
|
||||||
|
let parser = try IfExpressionParser.parser(
|
||||||
|
components: components,
|
||||||
|
environment: Environment(),
|
||||||
|
token: .text(value: "", at: .unknown)
|
||||||
|
)
|
||||||
return try parser.parse()
|
return try parser.parse()
|
||||||
|
} catch {
|
||||||
|
fatalError(error.localizedDescription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("VariableExpression") {
|
func testTrueExpressions() {
|
||||||
let expression = VariableExpression(variable: Variable("value"))
|
let expression = VariableExpression(variable: Variable("value"))
|
||||||
|
|
||||||
$0.it("evaluates to true when value is not nil") {
|
it("evaluates to true when value is not nil") {
|
||||||
let context = Context(dictionary: ["value": "known"])
|
let context = Context(dictionary: ["value": "known"])
|
||||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false when value is unset") {
|
it("evaluates to true when array variable is not empty") {
|
||||||
let context = Context()
|
let items: [[String: Any]] = [["key": "key1", "value": 42], ["key": "key2", "value": 1_337]]
|
||||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("evaluates to true when array variable is not empty") {
|
|
||||||
let items: [[String: Any]] = [["key":"key1","value":42],["key":"key2","value":1337]]
|
|
||||||
let context = Context(dictionary: ["value": [items]])
|
let context = Context(dictionary: ["value": [items]])
|
||||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false when array value is empty") {
|
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("evaluates to false when dictionary value is empty") {
|
|
||||||
let emptyItems = [String: Any]()
|
let emptyItems = [String: Any]()
|
||||||
let context = Context(dictionary: ["value": emptyItems])
|
let context = Context(dictionary: ["value": emptyItems])
|
||||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false when Array<Any> value is empty") {
|
it("evaluates to true when integer value is above 0") {
|
||||||
let context = Context(dictionary: ["value": ([] as [Any])])
|
|
||||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("evaluates to true when integer value is above 0") {
|
|
||||||
let context = Context(dictionary: ["value": 1])
|
let context = Context(dictionary: ["value": 1])
|
||||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with string") {
|
it("evaluates to true with string") {
|
||||||
let context = Context(dictionary: ["value": "test"])
|
let context = Context(dictionary: ["value": "test"])
|
||||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false when empty string") {
|
it("evaluates to true when float value is above 0") {
|
||||||
let context = Context(dictionary: ["value": ""])
|
|
||||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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": 0])
|
|
||||||
try expect(try expression.evaluate(context: negativeContext)).to.beFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("evaluates to true when float value is above 0") {
|
|
||||||
let context = Context(dictionary: ["value": Float(0.5)])
|
let context = Context(dictionary: ["value": Float(0.5)])
|
||||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
try expect(try expression.evaluate(context: context)).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false when float is 0 or below") {
|
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)])
|
let context = Context(dictionary: ["value": Float(0)])
|
||||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true when double value is above 0") {
|
it("evaluates to false when double is 0 or below") {
|
||||||
let context = Context(dictionary: ["value": Double(0.5)])
|
|
||||||
try expect(try expression.evaluate(context: context)).to.beTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("evaluates to false when double is 0 or below") {
|
|
||||||
let context = Context(dictionary: ["value": Double(0)])
|
let context = Context(dictionary: ["value": Double(0)])
|
||||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false when uint is 0") {
|
it("evaluates to false when uint is 0") {
|
||||||
let context = Context(dictionary: ["value": UInt(0)])
|
let context = Context(dictionary: ["value": UInt(0)])
|
||||||
try expect(try expression.evaluate(context: context)).to.beFalse()
|
try expect(try expression.evaluate(context: context)).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("NotExpression") {
|
func testNotExpression() {
|
||||||
$0.it("returns truthy for positive expressions") {
|
it("returns truthy for positive expressions") {
|
||||||
let expression = NotExpression(expression: StaticExpression(value: true))
|
let expression = NotExpression(expression: VariableExpression(variable: Variable("true")))
|
||||||
try expect(expression.evaluate(context: Context())).to.beFalse()
|
try expect(expression.evaluate(context: Context())).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("returns falsy for negative expressions") {
|
it("returns falsy for negative expressions") {
|
||||||
let expression = NotExpression(expression: StaticExpression(value: false))
|
let expression = NotExpression(expression: VariableExpression(variable: Variable("false")))
|
||||||
try expect(expression.evaluate(context: Context())).to.beTrue()
|
try expect(expression.evaluate(context: Context())).to.beTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("expression parsing") {
|
func testExpressionParsing() {
|
||||||
$0.it("can parse a variable expression") {
|
it("can parse a variable expression") {
|
||||||
let expression = try parseExpression(components: ["value"])
|
let expression = self.makeExpression(["value"])
|
||||||
try expect(expression.evaluate(context: Context())).to.beFalse()
|
try expect(expression.evaluate(context: Context())).to.beFalse()
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse a not expression") {
|
it("can parse a not expression") {
|
||||||
let expression = try parseExpression(components: ["not", "value"])
|
let expression = self.makeExpression(["not", "value"])
|
||||||
try expect(expression.evaluate(context: Context())).to.beTrue()
|
try expect(expression.evaluate(context: Context())).to.beTrue()
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$0.describe("and expression") {
|
func testAndExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", "and", "rhs"])
|
let expression = makeExpression(["lhs", "and", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to false with lhs false") {
|
it("evaluates to false with lhs false") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with rhs false") {
|
it("evaluates to false with rhs false") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with lhs and rhs false") {
|
it("evaluates to false with lhs and rhs false") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with lhs and rhs true") {
|
it("evaluates to true with lhs and rhs true") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("or expression") {
|
func testOrExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", "or", "rhs"])
|
let expression = makeExpression(["lhs", "or", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true with lhs true") {
|
it("evaluates to true with lhs true") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with rhs true") {
|
it("evaluates to true with rhs true") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with lhs and rhs true") {
|
it("evaluates to true with lhs and rhs true") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with lhs and rhs false") {
|
it("evaluates to false with lhs and rhs false") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": false, "rhs": false]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("equality expression") {
|
func testEqualityExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", "==", "rhs"])
|
let expression = makeExpression(["lhs", "==", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true with equal lhs/rhs") {
|
it("evaluates to true with equal lhs/rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "a"]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with non equal lhs/rhs") {
|
it("evaluates to false with non equal lhs/rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with nils") {
|
it("evaluates to true with nils") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: [:]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: [:]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with numbers") {
|
it("evaluates to true with numbers") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.0]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.0]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with non equal numbers") {
|
it("evaluates to false with non equal numbers") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.1]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1.1]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with booleans") {
|
it("evaluates to true with booleans") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with falsy booleans") {
|
it("evaluates to false with falsy booleans") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": false]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with different types") {
|
it("evaluates to false with different types") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": 1]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": true, "rhs": 1]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("inequality expression") {
|
func testInequalityExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", "!=", "rhs"])
|
let expression = makeExpression(["lhs", "!=", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true with inequal lhs/rhs") {
|
it("evaluates to true with inequal lhs/rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "b"]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with equal lhs/rhs") {
|
it("evaluates to false with equal lhs/rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "b", "rhs": "b"]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "b", "rhs": "b"]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("more than expression") {
|
func testMoreThanExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", ">", "rhs"])
|
let expression = makeExpression(["lhs", ">", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true with lhs > rhs") {
|
it("evaluates to true with lhs > rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 4]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with lhs == rhs") {
|
it("evaluates to false with lhs == rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("more than equal expression") {
|
func testMoreThanEqualExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", ">=", "rhs"])
|
let expression = makeExpression(["lhs", ">=", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true with lhs == rhs") {
|
it("evaluates to true with lhs == rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with lhs < rhs") {
|
it("evaluates to false with lhs < rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.1]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.1]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("less than expression") {
|
func testLessThanExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", "<", "rhs"])
|
let expression = makeExpression(["lhs", "<", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true with lhs < rhs") {
|
it("evaluates to true with lhs < rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 4, "rhs": 4.5]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with lhs == rhs") {
|
it("evaluates to false with lhs == rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5.0]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("less than equal expression") {
|
func testLessThanEqualExpression() {
|
||||||
let expression = try! parseExpression(components: ["lhs", "<=", "rhs"])
|
let expression = makeExpression(["lhs", "<=", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true with lhs == rhs") {
|
it("evaluates to true with lhs == rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.0, "rhs": 5]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with lhs > rhs") {
|
it("evaluates to false with lhs > rhs") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.1, "rhs": 5.0]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 5.1, "rhs": 5.0]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("multiple expression") {
|
func testMultipleExpressions() {
|
||||||
let expression = try! parseExpression(components: ["one", "or", "two", "and", "not", "three"])
|
let expression = makeExpression(["one", "or", "two", "and", "not", "three"])
|
||||||
|
|
||||||
$0.it("evaluates to true with one") {
|
it("evaluates to true with one") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["one": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with one and three") {
|
it("evaluates to true with one and three") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["one": true, "three": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["one": true, "three": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to true with two") {
|
it("evaluates to true with two") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["two": true]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: ["two": true]))).to.beTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with two and three") {
|
it("evaluates to false with two and three") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with two and three") {
|
it("evaluates to false with two and three") {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: ["two": true, "three": true]))).to.beFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false with nothing") {
|
it("evaluates to false with nothing") {
|
||||||
try expect(expression.evaluate(context: Context())).to.beFalse()
|
try expect(expression.evaluate(context: Context())).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("in expression") {
|
func testTrueInExpression() throws {
|
||||||
let expression = try! parseExpression(components: ["lhs", "in", "rhs"])
|
let expression = makeExpression(["lhs", "in", "rhs"])
|
||||||
|
|
||||||
$0.it("evaluates to true when rhs contains lhs") {
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [1, 2, 3]]))).to.beTrue()
|
"lhs": 1,
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": ["a", "b", "c"]]))).to.beTrue()
|
"rhs": [1, 2, 3]
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": "a", "rhs": "abc"]))).to.beTrue()
|
]))).to.beTrue()
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1...3]))).to.beTrue()
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": 1..<3]))).to.beTrue()
|
"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()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates to false when rhs does not contain lhs") {
|
func testFalseInExpression() throws {
|
||||||
try expect(expression.evaluate(context: Context(dictionary: ["lhs": 1, "rhs": [2, 3, 4]]))).to.beFalse()
|
let expression = makeExpression(["lhs", "in", "rhs"])
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.describe("sub expression") {
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
$0.it("evaluates correctly") {
|
"lhs": 1,
|
||||||
let context = Context(dictionary: ["one": false, "two": false, "three": true, "four": true])
|
"rhs": [2, 3, 4]
|
||||||
|
]))).to.beFalse()
|
||||||
let expression = try! parseExpression(components: ["one", "and", "two", "or", "three", "and", "four"])
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
let expressionWithBrackets = try! parseExpression(components: ["one", "and", "(", "(", "two", ")", "or", "(", "three", "and", "four", ")", ")"])
|
"lhs": "a",
|
||||||
|
"rhs": ["b", "c", "d"]
|
||||||
try expect(expression.evaluate(context: context)).to.beTrue()
|
]))).to.beFalse()
|
||||||
try expect(expressionWithBrackets.evaluate(context: context)).to.beFalse()
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": "a",
|
||||||
let notExpression = try! parseExpression(components: ["not", "one", "or", "three"])
|
"rhs": "bcd"
|
||||||
let notExpressionWithBrackets = try! parseExpression(components: ["not", "(", "one", "or", "three", ")"])
|
]))).to.beFalse()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
try expect(notExpression.evaluate(context: context)).to.beTrue()
|
"lhs": 4,
|
||||||
try expect(notExpressionWithBrackets.evaluate(context: context)).to.beFalse()
|
"rhs": 1...3
|
||||||
}
|
]))).to.beFalse()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
$0.it("fails when brackets are not balanced") {
|
"lhs": 3,
|
||||||
try expect(parseExpression(components: ["(", "lhs", "and", "rhs"]))
|
"rhs": 1..<3
|
||||||
.toThrow(TemplateSyntaxError("'if' expression error: missing closing bracket"))
|
]))).to.beFalse()
|
||||||
try expect(parseExpression(components: [")", "lhs", "and", "rhs"]))
|
|
||||||
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
|
|
||||||
try expect(parseExpression(components: ["lhs", "and", "rhs", ")"]))
|
|
||||||
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
|
|
||||||
try expect(parseExpression(components: ["(", "lhs", "and", "rhs", ")", "("]))
|
|
||||||
.toThrow(TemplateSyntaxError("'if' expression error: missing closing bracket"))
|
|
||||||
try expect(parseExpression(components: ["(", "lhs", "and", "rhs", ")", ")"]))
|
|
||||||
.toThrow(TemplateSyntaxError("'if' expression error: missing opening bracket"))
|
|
||||||
try expect(parseExpression(components: ["(", "lhs", "and", ")"]))
|
|
||||||
.toThrow(TemplateSyntaxError("'if' expression error: end"))
|
|
||||||
try expect(parseExpression(components: ["(", "and", "rhs", ")"]))
|
|
||||||
.toThrow(TemplateSyntaxError("'if' expression error: infix operator 'and' doesn't have a left hand side"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class FilterTests: XCTestCase {
|
final class FilterTests: XCTestCase {
|
||||||
func testFilter() {
|
func testRegistration() {
|
||||||
describe("template filters") {
|
|
||||||
let context: [String: Any] = ["name": "Kyle"]
|
let context: [String: Any] = ["name": "Kyle"]
|
||||||
|
|
||||||
$0.it("allows you to register a custom filter") {
|
it("allows you to register a custom filter") {
|
||||||
let template = Template(templateString: "{{ name|repeat }}")
|
let template = Template(templateString: "{{ name|repeat }}")
|
||||||
|
|
||||||
let repeatExtension = Extension()
|
let repeatExtension = Extension()
|
||||||
@@ -19,11 +24,14 @@ class FilterTests: XCTestCase {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
let result = try template.render(Context(
|
||||||
|
dictionary: context,
|
||||||
|
environment: Environment(extensions: [repeatExtension])
|
||||||
|
))
|
||||||
try expect(result) == "Kyle Kyle"
|
try expect(result) == "Kyle Kyle"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to register boolean filters") {
|
it("allows you to register boolean filters") {
|
||||||
let repeatExtension = Extension()
|
let repeatExtension = Extension()
|
||||||
repeatExtension.registerFilter(name: "isPositive", negativeFilterName: "isNotPositive") { (value: Any?) in
|
repeatExtension.registerFilter(name: "isPositive", negativeFilterName: "isNotPositive") { (value: Any?) in
|
||||||
if let value = value as? Int {
|
if let value = value as? Int {
|
||||||
@@ -41,106 +49,117 @@ class FilterTests: XCTestCase {
|
|||||||
try expect(negativeResult) == "true"
|
try expect(negativeResult) == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to register a custom filter which accepts single argument") {
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistrationWithArguments() {
|
||||||
|
let context: [String: Any] = ["name": "Kyle"]
|
||||||
|
|
||||||
|
it("allows you to register a custom filter which accepts single argument") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ name|repeat:'value1, "value2"' }}
|
{{ name|repeat:'value1, "value2"' }}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
let repeatExtension = Extension()
|
let repeatExtension = Extension()
|
||||||
repeatExtension.registerFilter("repeat") { value, arguments in
|
repeatExtension.registerFilter("repeat") { value, arguments in
|
||||||
if !arguments.isEmpty {
|
guard let value = value,
|
||||||
return "\(value!) \(value!) with args \(arguments.first!!)"
|
let argument = arguments.first else { return nil }
|
||||||
|
|
||||||
|
return "\(value) \(value) with args \(argument ?? "")"
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
let result = try template.render(Context(
|
||||||
}
|
dictionary: context,
|
||||||
|
environment: Environment(extensions: [repeatExtension])
|
||||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
Kyle Kyle with args value1, "value2"
|
Kyle Kyle with args value1, "value2"
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to register a custom filter which accepts several arguments") {
|
it("allows you to register a custom filter which accepts several arguments") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ name|repeat:'value"1"',"value'2'",'(key, value)' }}
|
{{ name|repeat:'value"1"',"value'2'",'(key, value)' }}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
let repeatExtension = Extension()
|
let repeatExtension = Extension()
|
||||||
repeatExtension.registerFilter("repeat") { value, arguments in
|
repeatExtension.registerFilter("repeat") { value, arguments in
|
||||||
if !arguments.isEmpty {
|
guard let value = value else { return nil }
|
||||||
return "\(value!) \(value!) with args 0: \(arguments[0]!), 1: \(arguments[1]!), 2: \(arguments[2]!)"
|
let args = arguments.compactMap { $0 }
|
||||||
|
return "\(value) \(value) with args 0: \(args[0]), 1: \(args[1]), 2: \(args[2])"
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
let result = try template.render(Context(
|
||||||
}
|
dictionary: context,
|
||||||
|
environment: Environment(extensions: [repeatExtension])
|
||||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
Kyle Kyle with args 0: value"1", 1: value'2', 2: (key, value)
|
Kyle Kyle with args 0: value"1", 1: value'2', 2: (key, value)
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to register a custom which throws") {
|
it("allows whitespace in expression") {
|
||||||
let template = Template(templateString: "{{ name|repeat }}")
|
|
||||||
let repeatExtension = Extension()
|
|
||||||
repeatExtension.registerFilter("repeat") { (value: 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("allows you to override a default filter") {
|
|
||||||
let template = Template(templateString: "{{ name|join }}")
|
|
||||||
|
|
||||||
let repeatExtension = Extension()
|
|
||||||
repeatExtension.registerFilter("join") { (value: Any?) in
|
|
||||||
return "joined"
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
|
||||||
try expect(result) == "joined"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("allows whitespace in expression") {
|
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value | join : ", " }}
|
{{ value | join : ", " }}
|
||||||
""")
|
""")
|
||||||
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
||||||
try expect(result) == "One, Two"
|
try expect(result) == "One, Two"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("string filters") {
|
func testStringFilters() {
|
||||||
$0.context("given string") {
|
it("transforms a string to be capitalized") {
|
||||||
$0.it("transforms a string to be capitalized") {
|
|
||||||
let template = Template(templateString: "{{ name|capitalize }}")
|
let template = Template(templateString: "{{ name|capitalize }}")
|
||||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
||||||
try expect(result) == "Kyle"
|
try expect(result) == "Kyle"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("transforms a string to be uppercase") {
|
it("transforms a string to be uppercase") {
|
||||||
let template = Template(templateString: "{{ name|uppercase }}")
|
let template = Template(templateString: "{{ name|uppercase }}")
|
||||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
||||||
try expect(result) == "KYLE"
|
try expect(result) == "KYLE"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("transforms a string to be lowercase") {
|
it("transforms a string to be lowercase") {
|
||||||
let template = Template(templateString: "{{ name|lowercase }}")
|
let template = Template(templateString: "{{ name|lowercase }}")
|
||||||
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
|
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
|
||||||
try expect(result) == "kyle"
|
try expect(result) == "kyle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.context("given array of strings") {
|
func testStringFiltersWithArrays() {
|
||||||
$0.it("transforms a string to be capitalized") {
|
it("transforms a string to be capitalized") {
|
||||||
let template = Template(templateString: "{{ names|capitalize }}")
|
let template = Template(templateString: "{{ names|capitalize }}")
|
||||||
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
@@ -148,7 +167,7 @@ class FilterTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("transforms a string to be uppercase") {
|
it("transforms a string to be uppercase") {
|
||||||
let template = Template(templateString: "{{ names|uppercase }}")
|
let template = Template(templateString: "{{ names|uppercase }}")
|
||||||
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
@@ -156,7 +175,7 @@ class FilterTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("transforms a string to be lowercase") {
|
it("transforms a string to be lowercase") {
|
||||||
let template = Template(templateString: "{{ names|lowercase }}")
|
let template = Template(templateString: "{{ names|lowercase }}")
|
||||||
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
|
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
@@ -164,24 +183,23 @@ class FilterTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
describe("default filter") {
|
func testDefaultFilter() {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
Hello {{ name|default:"World" }}
|
Hello {{ name|default:"World" }}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
$0.it("shows the variable value") {
|
it("shows the variable value") {
|
||||||
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
|
let result = try template.render(Context(dictionary: ["name": "Kyle"]))
|
||||||
try expect(result) == "Hello Kyle"
|
try expect(result) == "Hello Kyle"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("shows the default value") {
|
it("shows the default value") {
|
||||||
let result = try template.render(Context(dictionary: [:]))
|
let result = try template.render(Context(dictionary: [:]))
|
||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("supports multiple defaults") {
|
it("supports multiple defaults") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
Hello {{ name|default:a,b,c,"World" }}
|
Hello {{ name|default:a,b,c,"World" }}
|
||||||
""")
|
""")
|
||||||
@@ -189,19 +207,19 @@ class FilterTests: XCTestCase {
|
|||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can use int as default") {
|
it("can use int as default") {
|
||||||
let template = Template(templateString: "{{ value|default:1 }}")
|
let template = Template(templateString: "{{ value|default:1 }}")
|
||||||
let result = try template.render(Context(dictionary: [:]))
|
let result = try template.render(Context(dictionary: [:]))
|
||||||
try expect(result) == "1"
|
try expect(result) == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can use float as default") {
|
it("can use float as default") {
|
||||||
let template = Template(templateString: "{{ value|default:1.5 }}")
|
let template = Template(templateString: "{{ value|default:1.5 }}")
|
||||||
let result = try template.render(Context(dictionary: [:]))
|
let result = try template.render(Context(dictionary: [:]))
|
||||||
try expect(result) == "1.5"
|
try expect(result) == "1.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("checks for underlying nil value correctly") {
|
it("checks for underlying nil value correctly") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
Hello {{ user.name|default:"anonymous" }}
|
Hello {{ user.name|default:"anonymous" }}
|
||||||
""")
|
""")
|
||||||
@@ -212,22 +230,22 @@ class FilterTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("join filter") {
|
func testJoinFilter() {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|join:", " }}
|
{{ value|join:", " }}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
$0.it("joins a collection of strings") {
|
it("joins a collection of strings") {
|
||||||
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
||||||
try expect(result) == "One, Two"
|
try expect(result) == "One, Two"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("joins a mixed-type collection") {
|
it("joins a mixed-type collection") {
|
||||||
let result = try template.render(Context(dictionary: ["value": ["One", 2, true, 10.5, "Five"]]))
|
let result = try template.render(Context(dictionary: ["value": ["One", 2, true, 10.5, "Five"]]))
|
||||||
try expect(result) == "One, 2, true, 10.5, Five"
|
try expect(result) == "One, 2, true, 10.5, Five"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can join by non string") {
|
it("can join by non string") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|join:separator }}
|
{{ value|join:separator }}
|
||||||
""")
|
""")
|
||||||
@@ -235,7 +253,7 @@ class FilterTests: XCTestCase {
|
|||||||
try expect(result) == "OnetrueTwo"
|
try expect(result) == "OnetrueTwo"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can join without arguments") {
|
it("can join without arguments") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|join }}
|
{{ value|join }}
|
||||||
""")
|
""")
|
||||||
@@ -244,19 +262,19 @@ class FilterTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("split filter") {
|
func testSplitFilter() {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|split:", " }}
|
{{ value|split:", " }}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
$0.it("split a string into array") {
|
it("split a string into array") {
|
||||||
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
|
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
["One", "Two"]
|
["One", "Two"]
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can split without arguments") {
|
it("can split without arguments") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|split }}
|
{{ value|split }}
|
||||||
""")
|
""")
|
||||||
@@ -267,115 +285,113 @@ class FilterTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFilterSuggestion() {
|
||||||
describe("filter suggestion") {
|
it("made for unknown filter") {
|
||||||
var template: Template!
|
let template = Template(templateString: "{{ value|unknownFilter }}")
|
||||||
var filterExtension: Extension!
|
let filterExtension = Extension()
|
||||||
|
|
||||||
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: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
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 environment = Environment(extensions: [filterExtension])
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("made for unknown filter") {
|
|
||||||
template = Template(templateString: "{{ value|unknownFilter }}")
|
|
||||||
|
|
||||||
filterExtension = Extension()
|
|
||||||
filterExtension.registerFilter("knownFilter") { value, _ in value }
|
filterExtension.registerFilter("knownFilter") { value, _ in value }
|
||||||
|
|
||||||
try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.", token: "value|unknownFilter")
|
try self.expectError(
|
||||||
|
reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.",
|
||||||
|
token: "value|unknownFilter",
|
||||||
|
template: template,
|
||||||
|
extension: filterExtension
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("made for multiple similar filters") {
|
it("made for multiple similar filters") {
|
||||||
template = Template(templateString: "{{ value|lowerFirst }}")
|
let template = Template(templateString: "{{ value|lowerFirst }}")
|
||||||
|
let filterExtension = Extension()
|
||||||
filterExtension = Extension()
|
|
||||||
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
|
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
|
||||||
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
|
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
|
||||||
|
|
||||||
try expectError(reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.", token: "value|lowerFirst")
|
try self.expectError(
|
||||||
|
reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.",
|
||||||
|
token: "value|lowerFirst",
|
||||||
|
template: template,
|
||||||
|
extension: filterExtension
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("not made when can't find similar filter") {
|
it("not made when can't find similar filter") {
|
||||||
template = Template(templateString: "{{ value|unknownFilter }}")
|
let template = Template(templateString: "{{ value|unknownFilter }}")
|
||||||
try expectError(reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.", token: "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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
func testIndentContent() throws {
|
||||||
|
|
||||||
|
|
||||||
describe("indent filter") {
|
|
||||||
$0.it("indents content") {
|
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|indent:2 }}
|
{{ value|indent:2 }}
|
||||||
""")
|
""")
|
||||||
let result = try template.render(Context(dictionary: ["value": """
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
One
|
One
|
||||||
Two
|
Two
|
||||||
"""]))
|
"""
|
||||||
|
]))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
One
|
One
|
||||||
Two
|
Two
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can indent with arbitrary character") {
|
func testIndentWithArbitraryCharacter() throws {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|indent:2,"\t" }}
|
{{ value|indent:2,"\t" }}
|
||||||
""")
|
""")
|
||||||
let result = try template.render(Context(dictionary: ["value": """
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
One
|
One
|
||||||
Two
|
Two
|
||||||
"""]))
|
"""
|
||||||
|
]))
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
One
|
One
|
||||||
\t\tTwo
|
\t\tTwo
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can indent first line") {
|
func testIndentFirstLine() throws {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|indent:2," ",true }}
|
{{ value|indent:2," ",true }}
|
||||||
""")
|
""")
|
||||||
let result = try template.render(Context(dictionary: ["value": """
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
One
|
One
|
||||||
Two
|
Two
|
||||||
"""]))
|
"""
|
||||||
|
]))
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
One
|
One
|
||||||
Two
|
Two
|
||||||
"""
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("does not indent empty lines") {
|
func testIndentNotEmptyLines() throws {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{{ value|indent }}
|
{{ value|indent }}
|
||||||
""")
|
""")
|
||||||
let result = try template.render(Context(dictionary: ["value": """
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
One
|
One
|
||||||
|
|
||||||
|
|
||||||
Two
|
Two
|
||||||
|
|
||||||
|
|
||||||
"""]))
|
"""
|
||||||
|
]))
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
One
|
One
|
||||||
|
|
||||||
@@ -384,7 +400,64 @@ 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,39 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class FilterTagTests: XCTestCase {
|
final class FilterTagTests: XCTestCase {
|
||||||
func testFilterTag() {
|
func testFilterTag() {
|
||||||
describe("Filter Tag") {
|
it("allows you to use a filter") {
|
||||||
$0.it("allows you to use a filter") {
|
|
||||||
let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
|
let template = Template(templateString: "{% filter uppercase %}Test{% endfilter %}")
|
||||||
let result = try template.render()
|
let result = try template.render()
|
||||||
try expect(result) == "TEST"
|
try expect(result) == "TEST"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to chain filters") {
|
it("allows you to chain filters") {
|
||||||
let template = Template(templateString: "{% filter lowercase|capitalize %}TEST{% endfilter %}")
|
let template = Template(templateString: "{% filter lowercase|capitalize %}TEST{% endfilter %}")
|
||||||
let result = try template.render()
|
let result = try template.render()
|
||||||
try expect(result) == "Test"
|
try expect(result) == "Test"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("errors without a filter") {
|
it("errors without a filter") {
|
||||||
let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
|
let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
|
||||||
try expect(try template.render()).toThrow()
|
try expect(try template.render()).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can render filters with arguments") {
|
it("can render filters with arguments") {
|
||||||
let ext = Extension()
|
let ext = Extension()
|
||||||
ext.registerFilter("split", filter: {
|
ext.registerFilter("split") { value, args in
|
||||||
return ($0 as! String).components(separatedBy: $1[0] as! String)
|
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 env = Environment(extensions: [ext])
|
||||||
let result = try env.renderTemplate(string: """
|
let result = try env.renderTemplate(string: """
|
||||||
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
|
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
|
||||||
@@ -34,12 +41,15 @@ class FilterTagTests: XCTestCase {
|
|||||||
try expect(result) == "1;2"
|
try expect(result) == "1;2"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can render filters with quote as an argument") {
|
it("can render filters with quote as an argument") {
|
||||||
let ext = Extension()
|
let ext = Extension()
|
||||||
ext.registerFilter("replace", filter: {
|
ext.registerFilter("replace") { value, args in
|
||||||
print($1[0] as! String)
|
guard let value = value as? String,
|
||||||
return ($0 as! String).replacingOccurrences(of: $1[0] as! String, with: $1[1] 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 env = Environment(extensions: [ext])
|
||||||
let result = try env.renderTemplate(string: """
|
let result = try env.renderTemplate(string: """
|
||||||
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
|
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
|
||||||
@@ -48,4 +58,3 @@ class FilterTagTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,132 +1,70 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
import Foundation
|
import XCTest
|
||||||
|
|
||||||
class ForNodeTests: XCTestCase {
|
final class ForNodeTests: XCTestCase {
|
||||||
func testForNode() {
|
private let context = Context(dictionary: [
|
||||||
describe("ForNode") {
|
|
||||||
let context = Context(dictionary: [
|
|
||||||
"items": [1, 2, 3],
|
"items": [1, 2, 3],
|
||||||
|
"anyItems": [1, 2, 3] as [Any],
|
||||||
|
// swiftlint:disable:next legacy_objc_type
|
||||||
|
"nsItems": NSArray(array: [1, 2, 3]),
|
||||||
"emptyItems": [Int](),
|
"emptyItems": [Int](),
|
||||||
"dict": [
|
"dict": [
|
||||||
"one": "I",
|
"one": "I",
|
||||||
"two": "II",
|
"two": "II"
|
||||||
],
|
],
|
||||||
"tuples": [(1, 2, 3), (4, 5, 6)]
|
"tuples": [(1, 2, 3), (4, 5, 6)]
|
||||||
])
|
])
|
||||||
|
|
||||||
$0.it("renders the given nodes for each item") {
|
func testForNode() {
|
||||||
|
it("renders the given nodes for each item") {
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||||
try expect(try node.render(context)) == "123"
|
try expect(try node.render(self.context)) == "123"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders the given empty nodes when no items found item") {
|
it("renders the given empty nodes when no items found item") {
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
let node = ForNode(
|
||||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
resolvable: Variable("emptyItems"),
|
||||||
let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes)
|
loopVariables: ["item"],
|
||||||
try expect(try node.render(context)) == "empty"
|
nodes: [VariableNode(variable: "item")],
|
||||||
|
emptyNodes: [TextNode(text: "empty")]
|
||||||
|
)
|
||||||
|
try expect(try node.render(self.context)) == "empty"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders a context variable of type Array<Any>") {
|
it("renders a context variable of type Array<Any>") {
|
||||||
let any_context = Context(dictionary: [
|
|
||||||
"items": ([1, 2, 3] as [Any])
|
|
||||||
])
|
|
||||||
|
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
let node = ForNode(resolvable: Variable("anyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||||
try expect(try node.render(any_context)) == "123"
|
try expect(try node.render(self.context)) == "123"
|
||||||
}
|
|
||||||
|
|
||||||
$0.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"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(OSX)
|
#if os(OSX)
|
||||||
$0.it("renders a context variable of type NSArray") {
|
it("renders a context variable of type NSArray") {
|
||||||
let nsarray_context = Context(dictionary: [
|
|
||||||
"items": NSArray(array: [1, 2, 3])
|
|
||||||
])
|
|
||||||
|
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
let node = ForNode(resolvable: Variable("nsItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||||
try expect(try node.render(nsarray_context)) == "123"
|
try expect(try node.render(self.context)) == "123"
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
$0.it("renders the given nodes while providing if the item is first in the context") {
|
it("can render a filter with spaces") {
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")]
|
let template = Template(templateString: """
|
||||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
|
||||||
try expect(try node.render(context)) == "1true2false3false"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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(context)) == "1false2false3true"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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(context)) == "112233"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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(context)) == "102132"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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(context)) == "132333"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("renders the given nodes while filtering items using where expression") {
|
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.counter")]
|
|
||||||
let `where` = try parseExpression(components: ["item", ">", "1"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown))
|
|
||||||
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [], where: `where`)
|
|
||||||
try expect(try node.render(context)) == "2132"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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 `where` = try parseExpression(components: ["item", "==", "0"], tokenParser: TokenParser(tokens: [], environment: Environment()), token: .text(value: "", at: .unknown))
|
|
||||||
let node = ForNode(resolvable: Variable("emptyItems"), loopVariables: ["item"], nodes: nodes, emptyNodes: emptyNodes, where: `where`)
|
|
||||||
try expect(try node.render(context)) == "empty"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can render a filter with spaces") {
|
|
||||||
let templateString = """
|
|
||||||
{% for article in ars | default: a, b , articles %}\
|
{% for article in ars | default: a, b , articles %}\
|
||||||
- {{ article.title }} by {{ article.author }}.
|
- {{ article.title }} by {{ article.author }}.
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
"""
|
""")
|
||||||
|
|
||||||
let context = Context(dictionary: [
|
let context = Context(dictionary: [
|
||||||
"articles": [
|
"articles": [
|
||||||
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
|
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
|
||||||
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
|
Article(title: "Memory Management with ARC", author: "Kyle Fuller")
|
||||||
]
|
]
|
||||||
])
|
])
|
||||||
|
|
||||||
let template = Template(templateString: templateString)
|
|
||||||
let result = try template.render(context)
|
let result = try template.render(context)
|
||||||
|
|
||||||
try expect(result) == """
|
try expect(result) == """
|
||||||
@@ -135,173 +73,513 @@ class ForNodeTests: XCTestCase {
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$0.context("given array of tuples") {
|
func testLoopMetadata() {
|
||||||
$0.it("can iterate over all tuple values") {
|
it("renders the given nodes while providing if the item is first in the context") {
|
||||||
let templateString = """
|
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 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 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}\
|
{% for first,second,third in tuples %}\
|
||||||
{{ first }}, {{ second }}, {{ third }}
|
{{ first }}, {{ second }}, {{ third }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
"""
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
let template = Template(templateString: templateString)
|
|
||||||
let result = try template.render(context)
|
|
||||||
|
|
||||||
try expect(result) == """
|
|
||||||
1, 2, 3
|
1, 2, 3
|
||||||
4, 5, 6
|
4, 5, 6
|
||||||
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can iterate with less number of variables") {
|
it("can iterate with less number of variables") {
|
||||||
let templateString = """
|
let template = Template(templateString: """
|
||||||
{% for first,second in tuples %}\
|
{% for first,second in tuples %}\
|
||||||
{{ first }}, {{ second }}
|
{{ first }}, {{ second }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
"""
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
let template = Template(templateString: templateString)
|
|
||||||
let result = try template.render(context)
|
|
||||||
|
|
||||||
try expect(result) == """
|
|
||||||
1, 2
|
1, 2
|
||||||
4, 5
|
4, 5
|
||||||
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can use _ to skip variables") {
|
it("can use _ to skip variables") {
|
||||||
let templateString = """
|
let template = Template(templateString: """
|
||||||
{% for first,_,third in tuples %}\
|
{% for first,_,third in tuples %}\
|
||||||
{{ first }}, {{ third }}
|
{{ first }}, {{ third }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
"""
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
let template = Template(templateString: templateString)
|
|
||||||
let result = try template.render(context)
|
|
||||||
|
|
||||||
try expect(result) == """
|
|
||||||
1, 3
|
1, 3
|
||||||
4, 6
|
4, 6
|
||||||
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws when number of variables is more than number of tuple values") {
|
it("throws when number of variables is more than number of tuple values") {
|
||||||
let templateString = """
|
let template = Template(templateString: """
|
||||||
{% for key,value,smth in dict %}
|
{% for key,value,smth in dict %}{% endfor %}
|
||||||
{% endfor %}
|
""")
|
||||||
"""
|
try expect(template.render(self.context)).toThrow()
|
||||||
|
}
|
||||||
let template = Template(templateString: templateString)
|
|
||||||
try expect(template.render(context)).toThrow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
func testIterateDictionary() {
|
||||||
|
it("can iterate over dictionary") {
|
||||||
$0.it("can iterate over dictionary") {
|
let template = Template(templateString: """
|
||||||
let templateString = """
|
|
||||||
{% for key, value in dict %}\
|
{% for key, value in dict %}\
|
||||||
{{ key }}: {{ value }},\
|
{{ key }}: {{ value }},\
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
"""
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
let template = Template(templateString: templateString)
|
|
||||||
let result = try template.render(context)
|
|
||||||
|
|
||||||
try expect(result) == """
|
|
||||||
one: I,two: II,
|
one: I,two: II,
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders supports iterating over dictionary") {
|
it("renders supports iterating over dictionary") {
|
||||||
let nodes: [NodeType] = [
|
let nodes: [NodeType] = [
|
||||||
VariableNode(variable: "key"),
|
VariableNode(variable: "key"),
|
||||||
TextNode(text: ","),
|
TextNode(text: ",")
|
||||||
]
|
]
|
||||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||||
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
|
let node = ForNode(
|
||||||
let result = try node.render(context)
|
resolvable: Variable("dict"),
|
||||||
|
loopVariables: ["key"],
|
||||||
|
nodes: nodes,
|
||||||
|
emptyNodes: emptyNodes
|
||||||
|
)
|
||||||
|
|
||||||
try expect(result) == """
|
try expect(node.render(self.context)) == """
|
||||||
one,two,
|
one,two,
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders supports iterating over dictionary") {
|
it("renders supports iterating over dictionary with values") {
|
||||||
let nodes: [NodeType] = [
|
let nodes: [NodeType] = [
|
||||||
VariableNode(variable: "key"),
|
VariableNode(variable: "key"),
|
||||||
TextNode(text: "="),
|
TextNode(text: "="),
|
||||||
VariableNode(variable: "value"),
|
VariableNode(variable: "value"),
|
||||||
TextNode(text: ","),
|
TextNode(text: ",")
|
||||||
]
|
]
|
||||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||||
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key", "value"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
|
let node = ForNode(
|
||||||
let result = try node.render(context)
|
resolvable: Variable("dict"),
|
||||||
|
loopVariables: ["key", "value"],
|
||||||
|
nodes: nodes,
|
||||||
|
emptyNodes: emptyNodes
|
||||||
|
)
|
||||||
|
|
||||||
try expect(result) == """
|
try expect(node.render(self.context)) == """
|
||||||
one=I,two=II,
|
one=I,two=II,
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("handles invalid input") {
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can iterate over struct properties") {
|
func testIterateUsingMirroring() {
|
||||||
struct MyStruct {
|
|
||||||
let string: String
|
|
||||||
let number: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
let context = Context(dictionary: [
|
|
||||||
"struct": MyStruct(string: "abc", number: 123)
|
|
||||||
])
|
|
||||||
|
|
||||||
let nodes: [NodeType] = [
|
let nodes: [NodeType] = [
|
||||||
VariableNode(variable: "property"),
|
VariableNode(variable: "label"),
|
||||||
TextNode(text: "="),
|
TextNode(text: "="),
|
||||||
VariableNode(variable: "value"),
|
VariableNode(variable: "value"),
|
||||||
TextNode(text: "\n"),
|
TextNode(text: "\n")
|
||||||
]
|
]
|
||||||
let node = ForNode(resolvable: Variable("struct"), loopVariables: ["property", "value"], nodes: nodes, emptyNodes: [])
|
let node = ForNode(
|
||||||
let result = try node.render(context)
|
resolvable: Variable("item"),
|
||||||
|
loopVariables: ["label", "value"],
|
||||||
|
nodes: nodes,
|
||||||
|
emptyNodes: []
|
||||||
|
)
|
||||||
|
|
||||||
try expect(result) == """
|
it("can iterate over struct properties") {
|
||||||
|
let context = Context(dictionary: [
|
||||||
|
"item": MyStruct(string: "abc", number: 123)
|
||||||
|
])
|
||||||
|
try expect(node.render(context)) == """
|
||||||
string=abc
|
string=abc
|
||||||
number=123
|
number=123
|
||||||
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can iterate tuple items") {
|
it("can iterate tuple items") {
|
||||||
let context = Context(dictionary: [
|
let context = Context(dictionary: [
|
||||||
"tuple": (one: 1, two: "dva"),
|
"item": (one: 1, two: "dva")
|
||||||
])
|
])
|
||||||
|
try expect(node.render(context)) == """
|
||||||
let nodes: [NodeType] = [
|
|
||||||
VariableNode(variable: "label"),
|
|
||||||
TextNode(text: "="),
|
|
||||||
VariableNode(variable: "value"),
|
|
||||||
TextNode(text: "\n"),
|
|
||||||
]
|
|
||||||
|
|
||||||
let node = ForNode(resolvable: Variable("tuple"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
|
|
||||||
let result = try node.render(context)
|
|
||||||
|
|
||||||
try expect(result) == """
|
|
||||||
one=1
|
one=1
|
||||||
two=dva
|
two=dva
|
||||||
|
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can iterate over class properties") {
|
it("can iterate over class properties") {
|
||||||
class MyClass {
|
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: [])
|
||||||
|
|
||||||
|
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: [])
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Article {
|
||||||
|
let title: String
|
||||||
|
let author: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MyClass {
|
||||||
var baseString: String
|
var baseString: String
|
||||||
var baseInt: Int
|
var baseInt: Int
|
||||||
init(_ string: String, _ int: Int) {
|
init(_ string: String, _ int: Int) {
|
||||||
@@ -310,47 +588,10 @@ class ForNodeTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MySubclass: MyClass {
|
private class MySubclass: MyClass {
|
||||||
var childString: String
|
var childString: String
|
||||||
init(_ childString: String, _ string: String, _ int: Int) {
|
init(_ childString: String, _ string: String, _ int: Int) {
|
||||||
self.childString = childString
|
self.childString = childString
|
||||||
super.init(string, int)
|
super.init(string, int)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let context = Context(dictionary: [
|
|
||||||
"class": MySubclass("child", "base", 1)
|
|
||||||
])
|
|
||||||
|
|
||||||
let nodes: [NodeType] = [
|
|
||||||
VariableNode(variable: "label"),
|
|
||||||
TextNode(text: "="),
|
|
||||||
VariableNode(variable: "value"),
|
|
||||||
TextNode(text: "\n"),
|
|
||||||
]
|
|
||||||
|
|
||||||
let node = ForNode(resolvable: Variable("class"), loopVariables: ["label", "value"], nodes: nodes, emptyNodes: [])
|
|
||||||
let result = try node.render(context)
|
|
||||||
|
|
||||||
try expect(result) == """
|
|
||||||
childString=child
|
|
||||||
baseString=base
|
|
||||||
baseInt=1
|
|
||||||
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.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"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct Article {
|
|
||||||
let title: String
|
|
||||||
let author: String
|
|
||||||
}
|
|
||||||
|
|||||||
69
Tests/StencilTests/Helpers.swift
Normal file
69
Tests/StencilTests/Helpers.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
import PathKit
|
||||||
|
import Spectre
|
||||||
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
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: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class IfNodeTests: XCTestCase {
|
final class IfNodeTests: XCTestCase {
|
||||||
func testIfNode() {
|
func testParseIf() {
|
||||||
describe("IfNode") {
|
it("can parse an if block") {
|
||||||
$0.describe("parsing") {
|
|
||||||
$0.it("can parse an if block") {
|
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value", at: .unknown),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -24,7 +28,22 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(trueNode?.text) == "true"
|
try expect(trueNode?.text) == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse an if with else block") {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseIfWithElse() throws {
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value", at: .unknown),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -49,7 +68,7 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(falseNode?.text) == "false"
|
try expect(falseNode?.text) == "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse an if with elif block") {
|
func testParseIfWithElif() throws {
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value", at: .unknown),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -80,7 +99,7 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(falseNode?.text) == "false"
|
try expect(falseNode?.text) == "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse an if with elif block without else") {
|
func testParseIfWithElifWithoutElse() throws {
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value", at: .unknown),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -105,7 +124,7 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(elifNode?.text) == "some"
|
try expect(elifNode?.text) == "some"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse an if with multiple elif block") {
|
func testParseMultipleElif() throws {
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value", at: .unknown),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -142,20 +161,7 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(falseNode?.text) == "false"
|
try expect(falseNode?.text) == "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testParseIfnot() throws {
|
||||||
$0.it("can parse an if with complex expression") {
|
|
||||||
let tokens: [Token] = [
|
|
||||||
.block(value: "if value == \"test\" and not name", 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can parse an ifnot block") {
|
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "ifnot value", at: .unknown),
|
.block(value: "ifnot value", at: .unknown),
|
||||||
.text(value: "false", at: .unknown),
|
.text(value: "false", at: .unknown),
|
||||||
@@ -179,7 +185,8 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(falseNode?.text) == "false"
|
try expect(falseNode?.text) == "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws an error when parsing an if block without an endif") {
|
func testParsingErrors() {
|
||||||
|
it("throws an error when parsing an if block without an endif") {
|
||||||
let tokens: [Token] = [.block(value: "if value", at: .unknown)]
|
let tokens: [Token] = [.block(value: "if value", at: .unknown)]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -187,7 +194,7 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(try parser.parse()).toThrow(error)
|
try expect(try parser.parse()).toThrow(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws an error when parsing an ifnot without an endif") {
|
it("throws an error when parsing an ifnot without an endif") {
|
||||||
let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
|
let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -196,48 +203,48 @@ class IfNodeTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("rendering") {
|
func testRendering() {
|
||||||
$0.it("renders a true expression") {
|
it("renders a true expression") {
|
||||||
let node = IfNode(conditions: [
|
let node = IfNode(conditions: [
|
||||||
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "1")]),
|
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "1")]),
|
||||||
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
|
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "2")]),
|
||||||
IfCondition(expression: nil, nodes: [TextNode(text: "3")]),
|
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
|
||||||
])
|
])
|
||||||
|
|
||||||
try expect(try node.render(Context())) == "1"
|
try expect(try node.render(Context())) == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders the first true expression") {
|
it("renders the first true expression") {
|
||||||
let node = IfNode(conditions: [
|
let node = IfNode(conditions: [
|
||||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
|
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
|
||||||
IfCondition(expression: StaticExpression(value: true), nodes: [TextNode(text: "2")]),
|
IfCondition(expression: VariableExpression(variable: Variable("true")), nodes: [TextNode(text: "2")]),
|
||||||
IfCondition(expression: nil, nodes: [TextNode(text: "3")]),
|
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
|
||||||
])
|
])
|
||||||
|
|
||||||
try expect(try node.render(Context())) == "2"
|
try expect(try node.render(Context())) == "2"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders the empty expression when other conditions are falsy") {
|
it("renders the empty expression when other conditions are falsy") {
|
||||||
let node = IfNode(conditions: [
|
let node = IfNode(conditions: [
|
||||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
|
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
|
||||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")]),
|
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "2")]),
|
||||||
IfCondition(expression: nil, nodes: [TextNode(text: "3")]),
|
IfCondition(expression: nil, nodes: [TextNode(text: "3")])
|
||||||
])
|
])
|
||||||
|
|
||||||
try expect(try node.render(Context())) == "3"
|
try expect(try node.render(Context())) == "3"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders empty when no truthy conditions") {
|
it("renders empty when no truthy conditions") {
|
||||||
let node = IfNode(conditions: [
|
let node = IfNode(conditions: [
|
||||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "1")]),
|
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "1")]),
|
||||||
IfCondition(expression: StaticExpression(value: false), nodes: [TextNode(text: "2")]),
|
IfCondition(expression: VariableExpression(variable: Variable("false")), nodes: [TextNode(text: "2")])
|
||||||
])
|
])
|
||||||
|
|
||||||
try expect(try node.render(Context())) == ""
|
try expect(try node.render(Context())) == ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("supports variable filters in the if expression") {
|
func testSupportVariableFilters() throws {
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value|uppercase == \"TEST\"", at: .unknown),
|
.block(value: "if value|uppercase == \"TEST\"", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -251,7 +258,7 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(result) == "true"
|
try expect(result) == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("evaluates nil properties as false") {
|
func testEvaluatesNilAsFalse() throws {
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if instance.value", at: .unknown),
|
.block(value: "if instance.value", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -261,14 +268,11 @@ class IfNodeTests: XCTestCase {
|
|||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
|
|
||||||
struct SomeType {
|
|
||||||
let value: String? = nil
|
|
||||||
}
|
|
||||||
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
|
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
|
||||||
try expect(result) == ""
|
try expect(result) == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("supports closed range variables") {
|
func testSupportsRangeVariables() throws {
|
||||||
let tokens: [Token] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value in 1...3", at: .unknown),
|
.block(value: "if value in 1...3", at: .unknown),
|
||||||
.text(value: "true", at: .unknown),
|
.text(value: "true", at: .unknown),
|
||||||
@@ -283,7 +287,10 @@ class IfNodeTests: XCTestCase {
|
|||||||
try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true"
|
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": 4]))) == "false"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
// MARK: - Helpers
|
||||||
}
|
|
||||||
|
private struct SomeType {
|
||||||
|
let value: String? = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,33 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
import PathKit
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
import PathKit
|
import XCTest
|
||||||
|
|
||||||
class IncludeTests: XCTestCase {
|
final class IncludeTests: XCTestCase {
|
||||||
func testInclude() {
|
private let path = Path(#file as String) + ".." + "fixtures"
|
||||||
describe("Include") {
|
private lazy var loader = FileSystemLoader(paths: [path])
|
||||||
let path = Path(#file) + ".." + "fixtures"
|
private lazy var environment = Environment(loader: loader)
|
||||||
let loader = FileSystemLoader(paths: [path])
|
|
||||||
let environment = Environment(loader: loader)
|
|
||||||
|
|
||||||
$0.describe("parsing") {
|
func testParsing() {
|
||||||
$0.it("throws an error when no template is given") {
|
it("throws an error when no template is given") {
|
||||||
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
|
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
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)
|
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)
|
try expect(try parser.parse()).toThrow(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse a valid include block") {
|
it("can parse a valid include block") {
|
||||||
let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
|
let tokens: [Token] = [ .block(value: "include \"test.html\"", at: .unknown) ]
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
|
|
||||||
@@ -30,8 +38,8 @@ class IncludeTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("rendering") {
|
func testRendering() {
|
||||||
$0.it("throws an error when rendering without a loader") {
|
it("throws an error when rendering without a loader") {
|
||||||
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
|
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -41,32 +49,30 @@ class IncludeTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws an error when it cannot find the included template") {
|
it("throws an error when it cannot find the included template") {
|
||||||
let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
|
let node = IncludeNode(templateName: Variable("\"unknown.html\""), token: .block(value: "", at: .unknown))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try node.render(Context(environment: environment))
|
_ = try node.render(Context(environment: self.environment))
|
||||||
} catch {
|
} catch {
|
||||||
try expect("\(error)".hasPrefix("Template named `unknown.html` does not exist in loader")).to.beTrue()
|
try expect("\(error)".hasPrefix("Template named `unknown.html` does not exist in loader")).to.beTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("successfully renders a found included template") {
|
it("successfully renders a found included template") {
|
||||||
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
|
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
|
||||||
let context = Context(dictionary: ["target": "World"], environment: environment)
|
let context = Context(dictionary: ["target": "World"], environment: self.environment)
|
||||||
let value = try node.render(context)
|
let value = try node.render(context)
|
||||||
try expect(value) == "Hello World!"
|
try expect(value) == "Hello World!"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("successfully passes context") {
|
it("successfully passes context") {
|
||||||
let template = Template(templateString: """
|
let template = Template(templateString: """
|
||||||
{% include "test.html" child %}
|
{% include "test.html" child %}
|
||||||
""")
|
""")
|
||||||
let context = Context(dictionary: ["child": ["target": "World"]], environment: environment)
|
let context = Context(dictionary: ["child": ["target": "World"]], environment: self.environment)
|
||||||
let value = try template.render(context)
|
let value = try template.render(context)
|
||||||
try expect(value) == "Hello World!"
|
try expect(value) == "Hello World!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
79
Tests/StencilTests/InheritanceSpec.swift
Normal file
79
Tests/StencilTests/InheritanceSpec.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
import PathKit
|
||||||
|
import Spectre
|
||||||
|
import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class InheritanceTests: XCTestCase {
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
import Spectre
|
|
||||||
import Stencil
|
|
||||||
import PathKit
|
|
||||||
|
|
||||||
class InheritenceTests: XCTestCase {
|
|
||||||
func testInheritence() {
|
|
||||||
describe("Inheritence") {
|
|
||||||
let path = Path(#file) + ".." + "fixtures"
|
|
||||||
let loader = FileSystemLoader(paths: [path])
|
|
||||||
let environment = Environment(loader: loader)
|
|
||||||
|
|
||||||
$0.it("can inherit from another template") {
|
|
||||||
let template = try environment.loadTemplate(name: "child.html")
|
|
||||||
try expect(try template.render()) == """
|
|
||||||
Super_Header Child_Header
|
|
||||||
Child_Body
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can inherit from another template inheriting from another template") {
|
|
||||||
let template = try environment.loadTemplate(name: "child-child.html")
|
|
||||||
try expect(try template.render()) == """
|
|
||||||
Super_Header Child_Header Child_Child_Header
|
|
||||||
Child_Body
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can inherit from a template that calls a super block") {
|
|
||||||
let template = try environment.loadTemplate(name: "child-super.html")
|
|
||||||
try expect(try template.render()) == """
|
|
||||||
Header
|
|
||||||
Child_Body
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import PathKit
|
import PathKit
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
class LexerTests: XCTestCase {
|
final class LexerTests: XCTestCase {
|
||||||
func testLexer() {
|
func testText() throws {
|
||||||
describe("Lexer") {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can tokenize text") {
|
|
||||||
let lexer = Lexer(templateString: "Hello World")
|
let lexer = Lexer(templateString: "Hello World")
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ class LexerTests: XCTestCase {
|
|||||||
try expect(tokens.first) == .text(value: "Hello World", at: makeSourceMap("Hello World", for: lexer))
|
try expect(tokens.first) == .text(value: "Hello World", at: makeSourceMap("Hello World", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize a comment") {
|
func testComment() throws {
|
||||||
let lexer = Lexer(templateString: "{# Comment #}")
|
let lexer = Lexer(templateString: "{# Comment #}")
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ class LexerTests: XCTestCase {
|
|||||||
try expect(tokens.first) == .comment(value: "Comment", at: makeSourceMap("Comment", for: lexer))
|
try expect(tokens.first) == .comment(value: "Comment", at: makeSourceMap("Comment", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize a variable") {
|
func testVariable() throws {
|
||||||
let lexer = Lexer(templateString: "{{ Variable }}")
|
let lexer = Lexer(templateString: "{{ Variable }}")
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ class LexerTests: XCTestCase {
|
|||||||
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
|
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize a token without spaces") {
|
func testTokenWithoutSpaces() throws {
|
||||||
let lexer = Lexer(templateString: "{{Variable}}")
|
let lexer = Lexer(templateString: "{{Variable}}")
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ class LexerTests: XCTestCase {
|
|||||||
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
|
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize unclosed tag by ignoring it") {
|
func testUnclosedTag() throws {
|
||||||
let templateString = "{{ thing"
|
let templateString = "{{ thing"
|
||||||
let lexer = Lexer(templateString: templateString)
|
let lexer = Lexer(templateString: templateString)
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
@@ -52,43 +51,44 @@ class LexerTests: XCTestCase {
|
|||||||
try expect(tokens.first) == .text(value: "", at: makeSourceMap("{{ thing", for: lexer))
|
try expect(tokens.first) == .text(value: "", at: makeSourceMap("{{ thing", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize a mixture of content") {
|
func testContentMixture() throws {
|
||||||
let templateString = "My name is {{ myname }}."
|
let templateString = "My name is {{ myname }}."
|
||||||
let lexer = Lexer(templateString: templateString)
|
let lexer = Lexer(templateString: templateString)
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 3
|
try expect(tokens.count) == 3
|
||||||
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
|
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "myname", at: makeSourceMap("myname", for: lexer))
|
try expect(tokens[1]) == .variable(value: "myname", at: makeSourceMap("myname", for: lexer))
|
||||||
try expect(tokens[2]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
|
try expect(tokens[2]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize two variables without being greedy") {
|
func testVariablesWithoutBeingGreedy() throws {
|
||||||
let templateString = "{{ thing }}{{ name }}"
|
let templateString = "{{ thing }}{{ name }}"
|
||||||
let lexer = Lexer(templateString: templateString)
|
let lexer = Lexer(templateString: templateString)
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 2
|
try expect(tokens.count) == 2
|
||||||
try expect(tokens[0]) == Token.variable(value: "thing", at: makeSourceMap("thing", for: lexer))
|
try expect(tokens[0]) == .variable(value: "thing", at: makeSourceMap("thing", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer))
|
try expect(tokens[1]) == .variable(value: "name", at: makeSourceMap("name", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize an unclosed block") {
|
func testUnclosedBlock() throws {
|
||||||
let lexer = Lexer(templateString: "{%}")
|
let lexer = Lexer(templateString: "{%}")
|
||||||
_ = lexer.tokenize()
|
_ = lexer.tokenize()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize incorrect syntax without crashing") {
|
func testTokenizeIncorrectSyntaxWithoutCrashing() throws {
|
||||||
let lexer = Lexer(templateString: "func some() {{% if %}")
|
let lexer = Lexer(templateString: "func some() {{% if %}")
|
||||||
_ = lexer.tokenize()
|
_ = lexer.tokenize()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize an empty variable") {
|
func testEmptyVariable() throws {
|
||||||
let lexer = Lexer(templateString: "{{}}")
|
let lexer = Lexer(templateString: "{{}}")
|
||||||
_ = lexer.tokenize()
|
_ = lexer.tokenize()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize with new lines") {
|
func testNewlines() throws {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
let templateString = """
|
let templateString = """
|
||||||
My name is {%
|
My name is {%
|
||||||
if name
|
if name
|
||||||
@@ -99,34 +99,48 @@ class LexerTests: XCTestCase {
|
|||||||
}}{%
|
}}{%
|
||||||
endif %}.
|
endif %}.
|
||||||
"""
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
let lexer = Lexer(templateString: templateString)
|
let lexer = Lexer(templateString: templateString)
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 5
|
try expect(tokens.count) == 5
|
||||||
try expect(tokens[0]) == Token.text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
|
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
|
||||||
try expect(tokens[1]) == Token.block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
|
try expect(tokens[1]) == .block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
|
||||||
try expect(tokens[2]) == Token.variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
|
try expect(tokens[2]) == .variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
|
||||||
try expect(tokens[3]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
try expect(tokens[3]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||||
try expect(tokens[4]) == Token.text(value: ".", at: makeSourceMap(".", for: lexer))
|
try expect(tokens[4]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize escape sequences") {
|
func testTrimSymbols() throws {
|
||||||
|
let fBlock = "if hello"
|
||||||
|
let sBlock = "ta da"
|
||||||
|
let lexer = Lexer(templateString: "{%+ \(fBlock) -%}{% \(sBlock) -%}")
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
let behaviours = (
|
||||||
|
WhitespaceBehaviour(leading: .keep, trailing: .trim),
|
||||||
|
WhitespaceBehaviour(leading: .unspecified, trailing: .trim)
|
||||||
|
)
|
||||||
|
|
||||||
|
try expect(tokens.count) == 2
|
||||||
|
try expect(tokens[0]) == .block(value: fBlock, at: makeSourceMap(fBlock, for: lexer), whitespace: behaviours.0)
|
||||||
|
try expect(tokens[1]) == .block(value: sBlock, at: makeSourceMap(sBlock, for: lexer), whitespace: behaviours.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEscapeSequence() throws {
|
||||||
let templateString = "class Some {{ '{' }}{% if true %}{{ stuff }}{% endif %}"
|
let templateString = "class Some {{ '{' }}{% if true %}{{ stuff }}{% endif %}"
|
||||||
let lexer = Lexer(templateString: templateString)
|
let lexer = Lexer(templateString: templateString)
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 5
|
try expect(tokens.count) == 5
|
||||||
try expect(tokens[0]) == Token.text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
|
try expect(tokens[0]) == .text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
|
try expect(tokens[1]) == .variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
|
||||||
try expect(tokens[2]) == Token.block(value: "if true", at: makeSourceMap("if true", for: lexer))
|
try expect(tokens[2]) == .block(value: "if true", at: makeSourceMap("if true", for: lexer))
|
||||||
try expect(tokens[3]) == Token.variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
|
try expect(tokens[3]) == .variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
|
||||||
try expect(tokens[4]) == Token.block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
try expect(tokens[4]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPerformance() throws {
|
func testPerformance() throws {
|
||||||
let path = Path(#file) + ".." + "fixtures" + "huge.html"
|
let path = Path(#file as String) + ".." + "fixtures" + "huge.html"
|
||||||
let content: String = try path.read()
|
let content: String = try path.read()
|
||||||
|
|
||||||
measure {
|
measure {
|
||||||
@@ -134,4 +148,20 @@ class LexerTests: XCTestCase {
|
|||||||
_ = lexer.tokenize()
|
_ = lexer.tokenize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,61 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
import PathKit
|
||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
import Stencil
|
||||||
import PathKit
|
import XCTest
|
||||||
|
|
||||||
class TemplateLoaderTests: XCTestCase {
|
final class TemplateLoaderTests: XCTestCase {
|
||||||
func testTemplateLoader() {
|
func testFileSystemLoader() {
|
||||||
describe("FileSystemLoader") {
|
let path = Path(#file as String) + ".." + "fixtures"
|
||||||
let path = Path(#file) + ".." + "fixtures"
|
|
||||||
let loader = FileSystemLoader(paths: [path])
|
let loader = FileSystemLoader(paths: [path])
|
||||||
let environment = Environment(loader: loader)
|
let environment = Environment(loader: loader)
|
||||||
|
|
||||||
$0.it("errors when a template cannot be found") {
|
it("errors when a template cannot be found") {
|
||||||
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
|
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("errors when an array of templates cannot be found") {
|
it("errors when an array of templates cannot be found") {
|
||||||
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
|
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can load a template from a file") {
|
it("can load a template from a file") {
|
||||||
_ = try environment.loadTemplate(name: "test.html")
|
_ = try environment.loadTemplate(name: "test.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("errors when loading absolute file outside of the selected path") {
|
it("errors when loading absolute file outside of the selected path") {
|
||||||
try expect(try environment.loadTemplate(name: "/etc/hosts")).toThrow()
|
try expect(try environment.loadTemplate(name: "/etc/hosts")).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("errors when loading relative file outside of the selected path") {
|
it("errors when loading relative file outside of the selected path") {
|
||||||
try expect(try environment.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
|
try expect(try environment.loadTemplate(name: "../LoaderSpec.swift")).toThrow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DictionaryLoader") {
|
func testDictionaryLoader() {
|
||||||
let loader = DictionaryLoader(templates: [
|
let loader = DictionaryLoader(templates: [
|
||||||
"index.html": "Hello World"
|
"index.html": "Hello World"
|
||||||
])
|
])
|
||||||
let environment = Environment(loader: loader)
|
let environment = Environment(loader: loader)
|
||||||
|
|
||||||
$0.it("errors when a template cannot be found") {
|
it("errors when a template cannot be found") {
|
||||||
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
|
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("errors when an array of templates cannot be found") {
|
it("errors when an array of templates cannot be found") {
|
||||||
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
|
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can load a template from a known templates") {
|
it("can load a template from a known templates") {
|
||||||
_ = try environment.loadTemplate(name: "index.html")
|
_ = try environment.loadTemplate(name: "index.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can load a known template from a collection of templates") {
|
it("can load a known template from a collection of templates") {
|
||||||
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
|
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,66 +1,117 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class ErrorNode : NodeType {
|
final class NodeTests: XCTestCase {
|
||||||
let token: Token?
|
private let context = Context(dictionary: [
|
||||||
init(token: Token? = nil) {
|
|
||||||
self.token = token
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
throw TemplateSyntaxError("Custom Error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NodeTests: XCTestCase {
|
|
||||||
func testNode() {
|
|
||||||
describe("Node") {
|
|
||||||
let context = Context(dictionary: [
|
|
||||||
"name": "Kyle",
|
"name": "Kyle",
|
||||||
"age": 27,
|
"age": 27,
|
||||||
"items": [1, 2, 3],
|
"items": [1, 2, 3]
|
||||||
])
|
])
|
||||||
|
|
||||||
$0.describe("TextNode") {
|
func testTextNode() {
|
||||||
$0.it("renders the given text") {
|
it("renders the given text") {
|
||||||
let node = TextNode(text: "Hello World")
|
let node = TextNode(text: "Hello World")
|
||||||
try expect(try node.render(context)) == "Hello World"
|
try expect(try node.render(self.context)) == "Hello World"
|
||||||
|
}
|
||||||
|
it("Trims leading whitespace") {
|
||||||
|
let text = " \n Some text "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespace, trailing: .nothing)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "\n Some text "
|
||||||
|
}
|
||||||
|
it("Trims leading whitespace and one newline") {
|
||||||
|
let text = "\n\n Some text "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndOneNewLine, trailing: .nothing)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "\n Some text "
|
||||||
|
}
|
||||||
|
it("Trims leading whitespace and one newline") {
|
||||||
|
let text = "\n\n Some text "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "Some text "
|
||||||
|
}
|
||||||
|
it("Trims trailing whitespace") {
|
||||||
|
let text = " Some text \n"
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespace)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == " Some text\n"
|
||||||
|
}
|
||||||
|
it("Trims trailing whitespace and one newline") {
|
||||||
|
let text = " Some text \n \n "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndOneNewLine)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == " Some text \n "
|
||||||
|
}
|
||||||
|
it("Trims trailing whitespace and newlines") {
|
||||||
|
let text = " Some text \n \n "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndNewLines)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == " Some text"
|
||||||
|
}
|
||||||
|
it("Trims all whitespace") {
|
||||||
|
let text = " \n \nSome text \n "
|
||||||
|
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
|
||||||
|
let node = TextNode(text: text, trimBehaviour: trimBehaviour)
|
||||||
|
try expect(try node.render(self.context)) == "Some text"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("VariableNode") {
|
func testVariableNode() {
|
||||||
$0.it("resolves and renders the variable") {
|
it("resolves and renders the variable") {
|
||||||
let node = VariableNode(variable: Variable("name"))
|
let node = VariableNode(variable: Variable("name"))
|
||||||
try expect(try node.render(context)) == "Kyle"
|
try expect(try node.render(self.context)) == "Kyle"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("resolves and renders a non string variable") {
|
it("resolves and renders a non string variable") {
|
||||||
let node = VariableNode(variable: Variable("age"))
|
let node = VariableNode(variable: Variable("age"))
|
||||||
try expect(try node.render(context)) == "27"
|
try expect(try node.render(self.context)) == "27"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("rendering nodes") {
|
func testRendering() {
|
||||||
$0.it("renders the nodes") {
|
it("renders the nodes") {
|
||||||
|
let nodes: [NodeType] = [
|
||||||
|
TextNode(text: "Hello "),
|
||||||
|
VariableNode(variable: "name")
|
||||||
|
]
|
||||||
|
|
||||||
|
try expect(try renderNodes(nodes, self.context)) == "Hello Kyle"
|
||||||
|
}
|
||||||
|
|
||||||
|
it("correctly throws a nodes failure") {
|
||||||
let nodes: [NodeType] = [
|
let nodes: [NodeType] = [
|
||||||
TextNode(text: "Hello "),
|
TextNode(text: "Hello "),
|
||||||
VariableNode(variable: "name"),
|
VariableNode(variable: "name"),
|
||||||
|
ErrorNode()
|
||||||
]
|
]
|
||||||
|
|
||||||
try expect(try renderNodes(nodes, context)) == "Hello Kyle"
|
try expect(try renderNodes(nodes, self.context)).toThrow(TemplateSyntaxError("Custom Error"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("correctly throws a nodes failure") {
|
func testRenderingBooleans() {
|
||||||
let nodes: [NodeType] = [
|
it("can render true & false") {
|
||||||
TextNode(text:"Hello "),
|
try expect(Template(templateString: "{{ true }}").render()) == "true"
|
||||||
VariableNode(variable: "name"),
|
try expect(Template(templateString: "{{ false }}").render()) == "false"
|
||||||
ErrorNode(),
|
}
|
||||||
]
|
|
||||||
|
|
||||||
try expect(try renderNodes(nodes, context)).toThrow(TemplateSyntaxError("Custom Error"))
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import XCTest
|
//
|
||||||
import Foundation
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class NowNodeTests: XCTestCase {
|
||||||
class NowNodeTests: XCTestCase {
|
func testParsing() {
|
||||||
func testNowNode() {
|
it("parses default format without any now arguments") {
|
||||||
#if !os(Linux)
|
#if os(Linux)
|
||||||
describe("NowNode") {
|
throw skip()
|
||||||
$0.describe("parsing") {
|
#else
|
||||||
$0.it("parses default format without any now arguments") {
|
|
||||||
let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
|
let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
|
|
||||||
@@ -17,30 +21,36 @@ class NowNodeTests: XCTestCase {
|
|||||||
let node = nodes.first as? NowNode
|
let node = nodes.first as? NowNode
|
||||||
try expect(nodes.count) == 1
|
try expect(nodes.count) == 1
|
||||||
try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
|
try expect(node?.format.variable) == "\"yyyy-MM-dd 'at' HH:mm\""
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("parses now with a format") {
|
it("parses now with a format") {
|
||||||
|
#if os(Linux)
|
||||||
|
throw skip()
|
||||||
|
#else
|
||||||
let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
|
let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
let node = nodes.first as? NowNode
|
let node = nodes.first as? NowNode
|
||||||
try expect(nodes.count) == 1
|
try expect(nodes.count) == 1
|
||||||
try expect(node?.format.variable) == "\"HH:mm\""
|
try expect(node?.format.variable) == "\"HH:mm\""
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.describe("rendering") {
|
func testRendering() {
|
||||||
$0.it("renders the date") {
|
it("renders the date") {
|
||||||
|
#if os(Linux)
|
||||||
|
throw skip()
|
||||||
|
#else
|
||||||
let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
|
let node = NowNode(format: Variable("\"yyyy-MM-dd\""))
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
let date = formatter.string(from: NSDate() as Date)
|
let date = formatter.string(from: Date())
|
||||||
|
|
||||||
try expect(try node.render(Context())) == date
|
try expect(try node.render(Context())) == date
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class TokenParserTests: XCTestCase {
|
final class TokenParserTests: XCTestCase {
|
||||||
func testTokenParser() {
|
func testTextToken() throws {
|
||||||
describe("TokenParser") {
|
|
||||||
$0.it("can parse a text token") {
|
|
||||||
let parser = TokenParser(tokens: [
|
let parser = TokenParser(tokens: [
|
||||||
.text(value: "Hello World", at: .unknown)
|
.text(value: "Hello World", at: .unknown)
|
||||||
], environment: Environment())
|
], environment: Environment())
|
||||||
@@ -17,7 +21,7 @@ class TokenParserTests: XCTestCase {
|
|||||||
try expect(node?.text) == "Hello World"
|
try expect(node?.text) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse a variable token") {
|
func testVariableToken() throws {
|
||||||
let parser = TokenParser(tokens: [
|
let parser = TokenParser(tokens: [
|
||||||
.variable(value: "'name'", at: .unknown)
|
.variable(value: "'name'", at: .unknown)
|
||||||
], environment: Environment())
|
], environment: Environment())
|
||||||
@@ -29,7 +33,7 @@ class TokenParserTests: XCTestCase {
|
|||||||
try expect(result) == "name"
|
try expect(result) == "name"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse a comment token") {
|
func testCommentToken() throws {
|
||||||
let parser = TokenParser(tokens: [
|
let parser = TokenParser(tokens: [
|
||||||
.comment(value: "Secret stuff!", at: .unknown)
|
.comment(value: "Secret stuff!", at: .unknown)
|
||||||
], environment: Environment())
|
], environment: Environment())
|
||||||
@@ -38,26 +42,44 @@ class TokenParserTests: XCTestCase {
|
|||||||
try expect(nodes.count) == 0
|
try expect(nodes.count) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can parse a tag token") {
|
func testTagToken() throws {
|
||||||
let simpleExtension = Extension()
|
let simpleExtension = Extension()
|
||||||
simpleExtension.registerSimpleTag("known") { _ in
|
simpleExtension.registerSimpleTag("known") { _ in
|
||||||
return ""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
let parser = TokenParser(tokens: [
|
let parser = TokenParser(tokens: [
|
||||||
.block(value: "known", at: .unknown),
|
.block(value: "known", at: .unknown)
|
||||||
], environment: Environment(extensions: [simpleExtension]))
|
], environment: Environment(extensions: [simpleExtension]))
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
try expect(nodes.count) == 1
|
try expect(nodes.count) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("errors when parsing an unknown tag") {
|
func testErrorUnknownTag() throws {
|
||||||
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
|
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,27 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
fileprivate struct CustomNode : NodeType {
|
final class StencilTests: XCTestCase {
|
||||||
let token: Token?
|
private lazy var environment: Environment = {
|
||||||
func render(_ context:Context) throws -> String {
|
|
||||||
return "Hello World"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct Article {
|
|
||||||
let title: String
|
|
||||||
let author: String
|
|
||||||
}
|
|
||||||
|
|
||||||
class StencilTests: XCTestCase {
|
|
||||||
func testStencil() {
|
|
||||||
describe("Stencil") {
|
|
||||||
let exampleExtension = Extension()
|
let exampleExtension = Extension()
|
||||||
|
exampleExtension.registerSimpleTag("simpletag") { _ in
|
||||||
exampleExtension.registerSimpleTag("simpletag") { context in
|
"Hello World"
|
||||||
return "Hello World"
|
|
||||||
}
|
}
|
||||||
|
exampleExtension.registerTag("customtag") { _, token in
|
||||||
exampleExtension.registerTag("customtag") { parser, token in
|
CustomNode(token: token)
|
||||||
return CustomNode(token: token)
|
|
||||||
}
|
}
|
||||||
|
return Environment(extensions: [exampleExtension])
|
||||||
|
}()
|
||||||
|
|
||||||
let environment = Environment(extensions: [exampleExtension])
|
func testStencil() {
|
||||||
|
it("can render the README example") {
|
||||||
$0.it("can render the README example") {
|
|
||||||
|
|
||||||
let templateString = """
|
let templateString = """
|
||||||
There are {{ articles.count }} articles.
|
There are {{ articles.count }} articles.
|
||||||
|
|
||||||
@@ -42,7 +33,7 @@ class StencilTests: XCTestCase {
|
|||||||
let context = [
|
let context = [
|
||||||
"articles": [
|
"articles": [
|
||||||
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
|
Article(title: "Migrating from OCUnit to XCTest", author: "Kyle Fuller"),
|
||||||
Article(title: "Memory Management with ARC", author: "Kyle Fuller"),
|
Article(title: "Memory Management with ARC", author: "Kyle Fuller")
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -58,15 +49,28 @@ class StencilTests: XCTestCase {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can render a custom template tag") {
|
it("can render a custom template tag") {
|
||||||
let result = try environment.renderTemplate(string: "{% customtag %}")
|
let result = try self.environment.renderTemplate(string: "{% customtag %}")
|
||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can render a simple custom tag") {
|
it("can render a simple custom tag") {
|
||||||
let result = try environment.renderTemplate(string: "{% simpletag %}")
|
let result = try self.environment.renderTemplate(string: "{% simpletag %}")
|
||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private struct CustomNode: NodeType {
|
||||||
|
let token: Token?
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
"Hello World"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Article {
|
||||||
|
let title: String
|
||||||
|
let author: String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class TemplateTests: XCTestCase {
|
final class TemplateTests: XCTestCase {
|
||||||
func testTemplate() {
|
func testTemplate() {
|
||||||
describe("Template") {
|
it("can render a template from a string") {
|
||||||
$0.it("can render a template from a string") {
|
|
||||||
let template = Template(templateString: "Hello World")
|
let template = Template(templateString: "Hello World")
|
||||||
let result = try template.render([ "name": "Kyle" ])
|
let result = try template.render([ "name": "Kyle" ])
|
||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can render a template from a string literal") {
|
it("can render a template from a string literal") {
|
||||||
let template: Template = "Hello World"
|
let template: Template = "Hello World"
|
||||||
let result = try template.render([ "name": "Kyle" ])
|
let result = try template.render([ "name": "Kyle" ])
|
||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
import XCTest
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
class TokenTests: XCTestCase {
|
final class TokenTests: XCTestCase {
|
||||||
func testToken() {
|
func testToken() {
|
||||||
describe("Token") {
|
it("can split the contents into components") {
|
||||||
$0.it("can split the contents into components") {
|
|
||||||
let token = Token.text(value: "hello world", at: .unknown)
|
let token = Token.text(value: "hello world", at: .unknown)
|
||||||
let components = token.components()
|
let components = token.components
|
||||||
|
|
||||||
try expect(components.count) == 2
|
try expect(components.count) == 2
|
||||||
try expect(components[0]) == "hello"
|
try expect(components[0]) == "hello"
|
||||||
try expect(components[1]) == "world"
|
try expect(components[1]) == "world"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can split the contents into components with single quoted strings") {
|
it("can split the contents into components with single quoted strings") {
|
||||||
let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
|
let token = Token.text(value: "hello 'kyle fuller'", at: .unknown)
|
||||||
let components = token.components()
|
let components = token.components
|
||||||
|
|
||||||
try expect(components.count) == 2
|
try expect(components.count) == 2
|
||||||
try expect(components[0]) == "hello"
|
try expect(components[0]) == "hello"
|
||||||
try expect(components[1]) == "'kyle fuller'"
|
try expect(components[1]) == "'kyle fuller'"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can split the contents into components with double quoted strings") {
|
it("can split the contents into components with double quoted strings") {
|
||||||
let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
|
let token = Token.text(value: "hello \"kyle fuller\"", at: .unknown)
|
||||||
let components = token.components()
|
let components = token.components
|
||||||
|
|
||||||
try expect(components.count) == 2
|
try expect(components.count) == 2
|
||||||
try expect(components[0]) == "hello"
|
try expect(components[0]) == "hello"
|
||||||
@@ -33,4 +38,3 @@ class TokenTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
143
Tests/StencilTests/TrimBehaviourSpec.swift
Normal file
143
Tests/StencilTests/TrimBehaviourSpec.swift
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,232 +1,223 @@
|
|||||||
import XCTest
|
//
|
||||||
import Foundation
|
// Stencil
|
||||||
|
// Copyright © 2022 Stencil
|
||||||
|
// MIT Licence
|
||||||
|
//
|
||||||
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class VariableTests: XCTestCase {
|
||||||
#if os(OSX)
|
private let context: Context = {
|
||||||
@objc class Superclass: NSObject {
|
let ext = Extension()
|
||||||
@objc let name = "Foo"
|
ext.registerFilter("incr") { arg in
|
||||||
|
(arg.flatMap { toNumber(value: $0) } ?? 0) + 1
|
||||||
}
|
}
|
||||||
@objc class Object : Superclass {
|
let environment = Environment(extensions: [ext])
|
||||||
@objc let title = "Hello World"
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
fileprivate struct Person {
|
var context = Context(dictionary: [
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct Article {
|
|
||||||
let author: Person
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate class WebSite {
|
|
||||||
let url: String = "blog.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate class Blog: WebSite {
|
|
||||||
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
|
|
||||||
let featuring: Article? = Article(author: Person(name: "Jhon"))
|
|
||||||
}
|
|
||||||
|
|
||||||
class VariableTests: XCTestCase {
|
|
||||||
func testVariable() {
|
|
||||||
describe("Variable") {
|
|
||||||
let context = Context(dictionary: [
|
|
||||||
"name": "Kyle",
|
"name": "Kyle",
|
||||||
"contacts": ["Katie", "Carlton"],
|
"contacts": ["Katie", "Carlton"],
|
||||||
"profiles": [
|
"profiles": [
|
||||||
"github": "kylef",
|
"github": "kylef"
|
||||||
],
|
],
|
||||||
"counter": [
|
"counter": [
|
||||||
"count": "kylef",
|
"count": "kylef"
|
||||||
],
|
],
|
||||||
"article": Article(author: Person(name: "Kyle")),
|
"article": Article(author: Person(name: "Kyle")),
|
||||||
"tuple": (one: 1, two: 2)
|
"blog": Blog(),
|
||||||
])
|
"tuple": (one: 1, two: 2),
|
||||||
|
"dynamic": [
|
||||||
|
"enum": DynamicEnum.someValue,
|
||||||
|
"struct": DynamicStruct()
|
||||||
|
]
|
||||||
|
], environment: environment)
|
||||||
#if os(OSX)
|
#if os(OSX)
|
||||||
context["object"] = Object()
|
context["object"] = Object()
|
||||||
#endif
|
#endif
|
||||||
context["blog"] = Blog()
|
return context
|
||||||
|
}()
|
||||||
|
|
||||||
$0.it("can resolve a string literal with double quotes") {
|
func testLiterals() {
|
||||||
|
it("can resolve a string literal with double quotes") {
|
||||||
let variable = Variable("\"name\"")
|
let variable = Variable("\"name\"")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "name"
|
try expect(result) == "name"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve a string literal with single quotes") {
|
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 variable = Variable("'name'")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "name"
|
try expect(result) == "name"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve an integer literal") {
|
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 variable = Variable("5")
|
||||||
let result = try variable.resolve(context) as? Int
|
let result = try variable.resolve(self.context) as? Int
|
||||||
try expect(result) == 5
|
try expect(result) == 5
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve an float literal") {
|
it("can resolve an float literal") {
|
||||||
let variable = Variable("3.14")
|
let variable = Variable("3.14")
|
||||||
let result = try variable.resolve(context) as? Number
|
let result = try variable.resolve(self.context) as? Number
|
||||||
try expect(result) == 3.14
|
try expect(result) == 3.14
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve boolean literal") {
|
it("can resolve boolean literal") {
|
||||||
try expect(Variable("true").resolve(context) as? Bool) == true
|
try expect(Variable("true").resolve(self.context) as? Bool) == true
|
||||||
try expect(Variable("false").resolve(context) as? Bool) == false
|
try expect(Variable("false").resolve(self.context) as? Bool) == false
|
||||||
try expect(Variable("0").resolve(context) as? Int) == 0
|
try expect(Variable("0").resolve(self.context) as? Int) == 0
|
||||||
try expect(Variable("1").resolve(context) as? Int) == 1
|
try expect(Variable("1").resolve(self.context) as? Int) == 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve a string variable") {
|
func testVariable() {
|
||||||
|
it("can resolve a string variable") {
|
||||||
let variable = Variable("name")
|
let variable = Variable("name")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Kyle"
|
try expect(result) == "Kyle"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.context("given string") {
|
|
||||||
$0.it("can resolve an item via it's index") {
|
|
||||||
let variable = Variable("name.0")
|
|
||||||
let result = try variable.resolve(context) as? Character
|
|
||||||
try expect(result) == "K"
|
|
||||||
|
|
||||||
let variable1 = Variable("name.1")
|
|
||||||
let result1 = try variable1.resolve(context) as? Character
|
|
||||||
try expect(result1) == "y"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve an item via unknown index") {
|
func testDictionary() {
|
||||||
let variable = Variable("name.5")
|
it("can resolve an item from a dictionary") {
|
||||||
let result = try variable.resolve(context) as? Character
|
|
||||||
try expect(result).to.beNil()
|
|
||||||
|
|
||||||
let variable1 = Variable("name.-5")
|
|
||||||
let result1 = try variable1.resolve(context) as? Character
|
|
||||||
try expect(result1).to.beNil()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can resolve the first item") {
|
|
||||||
let variable = Variable("name.first")
|
|
||||||
let result = try variable.resolve(context) as? Character
|
|
||||||
try expect(result) == "K"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can resolve the last item") {
|
|
||||||
let variable = Variable("name.last")
|
|
||||||
let result = try variable.resolve(context) as? Character
|
|
||||||
try expect(result) == "e"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can get the characters count") {
|
|
||||||
let variable = Variable("name.count")
|
|
||||||
let result = try variable.resolve(context) as? Int
|
|
||||||
try expect(result) == 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.context("given dictionary") {
|
|
||||||
$0.it("can resolve an item") {
|
|
||||||
let variable = Variable("profiles.github")
|
let variable = Variable("profiles.github")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "kylef"
|
try expect(result) == "kylef"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can get the count") {
|
it("can get the count of a dictionary") {
|
||||||
let variable = Variable("profiles.count")
|
let variable = Variable("profiles.count")
|
||||||
let result = try variable.resolve(context) as? Int
|
let result = try variable.resolve(self.context) as? Int
|
||||||
try expect(result) == 1
|
try expect(result) == 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.context("given array") {
|
func testArray() {
|
||||||
$0.it("can resolve an item via it's index") {
|
it("can resolve an item from an array via it's index") {
|
||||||
let variable = Variable("contacts.0")
|
let variable = Variable("contacts.0")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Katie"
|
try expect(result) == "Katie"
|
||||||
|
|
||||||
let variable1 = Variable("contacts.1")
|
let variable1 = Variable("contacts.1")
|
||||||
let result1 = try variable1.resolve(context) as? String
|
let result1 = try variable1.resolve(self.context) as? String
|
||||||
try expect(result1) == "Carlton"
|
try expect(result1) == "Carlton"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve an item via unknown index") {
|
it("can resolve an item from an array via unknown index") {
|
||||||
let variable = Variable("contacts.5")
|
let variable = Variable("contacts.5")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result).to.beNil()
|
try expect(result).to.beNil()
|
||||||
|
|
||||||
let variable1 = Variable("contacts.-5")
|
let variable1 = Variable("contacts.-5")
|
||||||
let result1 = try variable1.resolve(context) as? String
|
let result1 = try variable1.resolve(self.context) as? String
|
||||||
try expect(result1).to.beNil()
|
try expect(result1).to.beNil()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve the first item") {
|
it("can resolve the first item from an array") {
|
||||||
let variable = Variable("contacts.first")
|
let variable = Variable("contacts.first")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Katie"
|
try expect(result) == "Katie"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve the last item") {
|
it("can resolve the last item from an array") {
|
||||||
let variable = Variable("contacts.last")
|
let variable = Variable("contacts.last")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Carlton"
|
try expect(result) == "Carlton"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$0.it("can get the count") {
|
func testDynamicMemberLookup() {
|
||||||
let variable = Variable("contacts.count")
|
it("can resolve dynamic member lookup") {
|
||||||
let result = try variable.resolve(context) as? Int
|
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
|
try expect(result) == 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve a property with reflection") {
|
func testOptional() {
|
||||||
let variable = Variable("article.author.name")
|
it("does not render Optional") {
|
||||||
let result = try variable.resolve(context) as? String
|
|
||||||
try expect(result) == "Kyle"
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(OSX)
|
|
||||||
$0.it("can resolve a value via KVO") {
|
|
||||||
let variable = Variable("object.title")
|
|
||||||
let result = try variable.resolve(context) as? String
|
|
||||||
try expect(result) == "Hello World"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can resolve a superclass value via KVO") {
|
|
||||||
let variable = Variable("object.name")
|
|
||||||
let result = try variable.resolve(context) as? String
|
|
||||||
try expect(result) == "Foo"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("does not crash on KVO") {
|
|
||||||
let variable = Variable("object.fullname")
|
|
||||||
let result = try variable.resolve(context) as? String
|
|
||||||
try expect(result).to.beNil()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
$0.it("can resolve a value via reflection") {
|
|
||||||
let variable = Variable("blog.articles.0.author.name")
|
|
||||||
let result = try variable.resolve(context) as? String
|
|
||||||
try expect(result) == "Kyle"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can resolve a superclass value via reflection") {
|
|
||||||
let variable = Variable("blog.url")
|
|
||||||
let result = try variable.resolve(context) as? String
|
|
||||||
try expect(result) == "blog.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can resolve optional variable property using reflection") {
|
|
||||||
let variable = Variable("blog.featuring.author.name")
|
|
||||||
let result = try variable.resolve(context) as? String
|
|
||||||
try expect(result) == "Jhon"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("does not render Optional") {
|
|
||||||
var array: [Any?] = [1, nil]
|
var array: [Any?] = [1, nil]
|
||||||
array.append(array)
|
array.append(array)
|
||||||
let context = Context(dictionary: ["values": array])
|
let context = Context(dictionary: ["values": array])
|
||||||
@@ -234,87 +225,78 @@ class VariableTests: XCTestCase {
|
|||||||
try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]"
|
try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]"
|
||||||
try expect(VariableNode(variable: "values.1").render(context)) == ""
|
try expect(VariableNode(variable: "values.1").render(context)) == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can subscript tuple by index") {
|
|
||||||
let variable = Variable("tuple.0")
|
|
||||||
let result = try variable.resolve(context) as? Int
|
|
||||||
try expect(result) == 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can subscript tuple by label") {
|
func testSubscripting() {
|
||||||
let variable = Variable("tuple.two")
|
it("can resolve a property subscript via reflection") {
|
||||||
let result = try variable.resolve(context) as? Int
|
try self.context.push(dictionary: ["property": "name"]) {
|
||||||
try expect(result) == 2
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.describe("Subscripting") {
|
|
||||||
$0.it("can resolve a property subscript via reflection") {
|
|
||||||
try context.push(dictionary: ["property": "name"]) {
|
|
||||||
let variable = Variable("article.author[property]")
|
let variable = Variable("article.author[property]")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Kyle"
|
try expect(result) == "Kyle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can subscript an array with a valid index") {
|
it("can subscript an array with a valid index") {
|
||||||
try context.push(dictionary: ["property": 0]) {
|
try self.context.push(dictionary: ["property": 0]) {
|
||||||
let variable = Variable("contacts[property]")
|
let variable = Variable("contacts[property]")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Katie"
|
try expect(result) == "Katie"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can subscript an array with an unknown index") {
|
it("can subscript an array with an unknown index") {
|
||||||
try context.push(dictionary: ["property": 5]) {
|
try self.context.push(dictionary: ["property": 5]) {
|
||||||
let variable = Variable("contacts[property]")
|
let variable = Variable("contacts[property]")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result).to.beNil()
|
try expect(result).to.beNil()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(OSX)
|
#if os(OSX)
|
||||||
$0.it("can resolve a subscript via KVO") {
|
it("can resolve a subscript via KVO") {
|
||||||
try context.push(dictionary: ["property": "name"]) {
|
try self.context.push(dictionary: ["property": "name"]) {
|
||||||
let variable = Variable("object[property]")
|
let variable = Variable("object[property]")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Foo"
|
try expect(result) == "Foo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
$0.it("can resolve an optional subscript via reflection") {
|
it("can resolve an optional subscript via reflection") {
|
||||||
try context.push(dictionary: ["property": "featuring"]) {
|
try self.context.push(dictionary: ["property": "featuring"]) {
|
||||||
let variable = Variable("blog[property].author.name")
|
let variable = Variable("blog[property].author.name")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Jhon"
|
try expect(result) == "Jhon"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$0.it("can resolve multiple subscripts") {
|
func testMultipleSubscripting() {
|
||||||
try context.push(dictionary: [
|
it("can resolve multiple subscripts") {
|
||||||
|
try self.context.push(dictionary: [
|
||||||
"prop1": "articles",
|
"prop1": "articles",
|
||||||
"prop2": 0,
|
"prop2": 0,
|
||||||
"prop3": "name"
|
"prop3": "name"
|
||||||
]) {
|
]) {
|
||||||
let variable = Variable("blog[prop1][prop2].author[prop3]")
|
let variable = Variable("blog[prop1][prop2].author[prop3]")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Kyle"
|
try expect(result) == "Kyle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve nested subscripts") {
|
it("can resolve nested subscripts") {
|
||||||
try context.push(dictionary: [
|
try self.context.push(dictionary: [
|
||||||
"prop1": "prop2",
|
"prop1": "prop2",
|
||||||
"ref": ["prop2": "name"]
|
"ref": ["prop2": "name"]
|
||||||
]) {
|
]) {
|
||||||
let variable = Variable("article.author[ref[prop1]]")
|
let variable = Variable("article.author[ref[prop1]]")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Kyle"
|
try expect(result) == "Kyle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws for invalid keypath syntax") {
|
it("throws for invalid keypath syntax") {
|
||||||
try context.push(dictionary: ["prop": "name"]) {
|
try self.context.push(dictionary: ["prop": "name"]) {
|
||||||
let samples = [
|
let samples = [
|
||||||
".",
|
".",
|
||||||
"..",
|
"..",
|
||||||
@@ -333,80 +315,90 @@ class VariableTests: XCTestCase {
|
|||||||
|
|
||||||
for lookup in samples {
|
for lookup in samples {
|
||||||
let variable = Variable(lookup)
|
let variable = Variable(lookup)
|
||||||
try expect(variable.resolve(context)).toThrow()
|
try expect(variable.resolve(self.context)).toThrow()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("RangeVariable") {
|
func testRangeVariable() {
|
||||||
|
|
||||||
let context: Context = {
|
|
||||||
let ext = Extension()
|
|
||||||
ext.registerFilter("incr", filter: { (arg: Any?) in toNumber(value: arg!)! + 1 })
|
|
||||||
let environment = Environment(extensions: [ext])
|
|
||||||
return Context(dictionary: [:], environment: environment)
|
|
||||||
}()
|
|
||||||
|
|
||||||
func makeVariable(_ token: String) throws -> RangeVariable? {
|
func makeVariable(_ token: String) throws -> RangeVariable? {
|
||||||
let token = Token.variable(value: token, at: .unknown)
|
let token = Token.variable(value: token, at: .unknown)
|
||||||
let parser = TokenParser(tokens: [token], environment: context.environment)
|
return try RangeVariable(token.contents, environment: context.environment, containedIn: token)
|
||||||
return try RangeVariable(token.contents, parser: parser, containedIn: token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve closed range as array") {
|
it("can resolve closed range as array") {
|
||||||
let result = try makeVariable("1...3")?.resolve(context) as? [Int]
|
let result = try makeVariable("1...3")?.resolve(self.context) as? [Int]
|
||||||
try expect(result) == [1, 2, 3]
|
try expect(result) == [1, 2, 3]
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve decreasing closed range as reversed array") {
|
it("can resolve decreasing closed range as reversed array") {
|
||||||
let result = try makeVariable("3...1")?.resolve(context) as? [Int]
|
let result = try makeVariable("3...1")?.resolve(self.context) as? [Int]
|
||||||
try expect(result) == [3, 2, 1]
|
try expect(result) == [3, 2, 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can use filter on range variables") {
|
it("can use filter on range variables") {
|
||||||
let result = try makeVariable("1|incr...3|incr")?.resolve(context) as? [Int]
|
let result = try makeVariable("1|incr...3|incr")?.resolve(self.context) as? [Int]
|
||||||
try expect(result) == [2, 3, 4]
|
try expect(result) == [2, 3, 4]
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws when left value is not int") {
|
it("throws when left value is not int") {
|
||||||
let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}"
|
let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}"
|
||||||
try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow()
|
try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws when right value is not int") {
|
it("throws when right value is not int") {
|
||||||
let variable = try makeVariable("k...j")
|
let variable = try makeVariable("k...j")
|
||||||
try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow()
|
try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws is left range value is missing") {
|
it("throws is left range value is missing") {
|
||||||
try expect(makeVariable("...1")).toThrow()
|
try expect(makeVariable("...1")).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws is right range value is missing") {
|
it("throws is right range value is missing") {
|
||||||
try expect(makeVariable("1...")).toThrow()
|
try expect(makeVariable("1...")).toThrow()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("inline if expression") {
|
// MARK: - Helpers
|
||||||
|
|
||||||
$0.it("can conditionally render variable") {
|
#if os(OSX)
|
||||||
let template: Template = "{{ variable if variable|uppercase == \"A\" }}"
|
@objc
|
||||||
try expect(template.render(Context(dictionary: ["variable": "a"]))) == "a"
|
class Superclass: NSObject {
|
||||||
try expect(template.render(Context(dictionary: ["variable": "b"]))) == ""
|
@objc let name = "Foo"
|
||||||
|
}
|
||||||
|
@objc
|
||||||
|
class Object: Superclass {
|
||||||
|
@objc let title = "Hello World"
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct Person {
|
||||||
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can render with else expression") {
|
private struct Article {
|
||||||
let template: Template = "{{ variable if variable|uppercase == \"A\" else fallback|uppercase }}"
|
let author: Person
|
||||||
try expect(template.render(Context(dictionary: ["variable": "b", "fallback": "c"]))) == "C"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("throws when used invalid condition") {
|
private class WebSite {
|
||||||
let template: Template = "{{ variable if variable \"A\" }}"
|
let url: String = "blog.com"
|
||||||
try expect(template.render(Context(dictionary: ["variable": "a"]))).toThrow()
|
}
|
||||||
}
|
|
||||||
|
private class Blog: WebSite {
|
||||||
|
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
|
||||||
|
let featuring: Article? = Article(author: Person(name: "Jhon"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
extension ContextTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testContext", testContext),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testEnvironment", testEnvironment),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ExpressionsTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testExpressions", testExpressions),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FilterTagTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testFilterTag", testFilterTag),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FilterTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testFilter", testFilter),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ForNodeTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testForNode", testForNode),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension IfNodeTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testIfNode", testIfNode),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension IncludeTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testInclude", testInclude),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InheritenceTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testInheritence", testInheritence),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LexerTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testLexer", testLexer),
|
|
||||||
("testPerformance", testPerformance),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NodeTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testNode", testNode),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NowNodeTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testNowNode", testNowNode),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StencilTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testStencil", testStencil),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TemplateLoaderTests {
|
|
||||||
static let __allTests = [
|
|
||||||
("testTemplateLoader", testTemplateLoader),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
("testVariable", testVariable),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
public func __allTests() -> [XCTestCaseEntry] {
|
|
||||||
return [
|
|
||||||
testCase(ContextTests.__allTests),
|
|
||||||
testCase(EnvironmentTests.__allTests),
|
|
||||||
testCase(ExpressionsTests.__allTests),
|
|
||||||
testCase(FilterTagTests.__allTests),
|
|
||||||
testCase(FilterTests.__allTests),
|
|
||||||
testCase(ForNodeTests.__allTests),
|
|
||||||
testCase(IfNodeTests.__allTests),
|
|
||||||
testCase(IncludeTests.__allTests),
|
|
||||||
testCase(InheritenceTests.__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
|
|
||||||
5
Tests/StencilTests/fixtures/base-repeat.html
Normal file
5
Tests/StencilTests/fixtures/base-repeat.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% block header %}Header{% endblock %}
|
||||||
|
{% block body %}Body{% endblock %}
|
||||||
|
Repeat
|
||||||
|
{{ block.header }}
|
||||||
|
{{ block.body }}
|
||||||
3
Tests/StencilTests/fixtures/child-repeat.html
Normal file
3
Tests/StencilTests/fixtures/child-repeat.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{% extends "base-repeat.html" %}
|
||||||
|
{% block header %}Super_{{ block.super }} Child_Header{% endblock %}
|
||||||
|
{% block body %}Child_Body{% endblock %}
|
||||||
2
Tests/StencilTests/fixtures/if-block-child.html
Normal file
2
Tests/StencilTests/fixtures/if-block-child.html
Normal 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 %}
|
||||||
1
Tests/StencilTests/fixtures/if-block.html
Normal file
1
Tests/StencilTests/fixtures/if-block.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{% block title %}Title{% endblock %}
|
||||||
@@ -41,8 +41,7 @@ You can iterate over range literals created using ``N...M`` syntax, both in asce
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
The ``for`` tag can contain optional ``where`` expression to filter out
|
The ``for`` tag can contain optional ``where`` expression to filter out elements on which this expression evaluates to false.
|
||||||
elements on which this expression evaluates to false.
|
|
||||||
|
|
||||||
.. code-block:: html+django
|
.. code-block:: html+django
|
||||||
|
|
||||||
@@ -52,8 +51,7 @@ elements on which this expression evaluates to false.
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
The ``for`` tag can take an optional ``{% empty %}`` block that will be
|
The ``for`` tag can take an optional ``{% empty %}`` block that will be displayed if the given list is empty or could not be found.
|
||||||
displayed if the given list is empty or could not be found.
|
|
||||||
|
|
||||||
.. code-block:: html+django
|
.. code-block:: html+django
|
||||||
|
|
||||||
@@ -89,12 +87,74 @@ For example:
|
|||||||
This is user number {{ forloop.counter }} user.
|
This is user number {{ forloop.counter }} user.
|
||||||
{% endfor %}
|
{% 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``
|
``if``
|
||||||
~~~~~~
|
~~~~~~
|
||||||
|
|
||||||
The ``{% if %}`` tag evaluates a variable, and if that variable evaluates to
|
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:
|
||||||
true the contents of the block are processed. Being true is defined as:
|
|
||||||
|
|
||||||
* Present in the context
|
* Present in the context
|
||||||
* Being non-empty (dictionaries or arrays)
|
* Being non-empty (dictionaries or arrays)
|
||||||
@@ -115,8 +175,7 @@ true the contents of the block are processed. Being true is defined as:
|
|||||||
Operators
|
Operators
|
||||||
^^^^^^^^^
|
^^^^^^^^^
|
||||||
|
|
||||||
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables
|
``if`` tags may combine ``and``, ``or`` and ``not`` to test multiple variables or to negate a variable.
|
||||||
or to negate a variable.
|
|
||||||
|
|
||||||
.. code-block:: html+django
|
.. 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 %}
|
{% include "comment.html" comment %}
|
||||||
|
|
||||||
The `include` tag requires you to provide a loader which will be used to lookup
|
The `include` tag requires you to provide a loader which will be used to lookup the template.
|
||||||
the template.
|
|
||||||
|
|
||||||
.. code-block:: swift
|
.. code-block:: swift
|
||||||
|
|
||||||
@@ -301,8 +359,7 @@ See :ref:`template-inheritance` for more information.
|
|||||||
``block``
|
``block``
|
||||||
~~~~~~~~~
|
~~~~~~~~~
|
||||||
|
|
||||||
Defines a block that can be overridden by child templates. See
|
Defines a block that can be overridden by child templates. See :ref:`template-inheritance` for more information.
|
||||||
:ref:`template-inheritance` for more information.
|
|
||||||
|
|
||||||
.. _built-in-filters:
|
.. _built-in-filters:
|
||||||
|
|
||||||
@@ -312,8 +369,7 @@ Built-in Filters
|
|||||||
``capitalize``
|
``capitalize``
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
The capitalize filter allows you to capitalize a 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.
|
||||||
For example, `stencil` to `Stencil`. Can be applied to array of strings to change each string.
|
|
||||||
|
|
||||||
.. code-block:: html+django
|
.. code-block:: html+django
|
||||||
|
|
||||||
@@ -322,8 +378,7 @@ For example, `stencil` to `Stencil`. Can be applied to array of strings to chang
|
|||||||
``uppercase``
|
``uppercase``
|
||||||
~~~~~~~~~~~~~
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
The uppercase filter allows you to transform a string to 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.
|
||||||
For example, `Stencil` to `STENCIL`. Can be applied to array of strings to change each string.
|
|
||||||
|
|
||||||
.. code-block:: html+django
|
.. code-block:: html+django
|
||||||
|
|
||||||
@@ -332,8 +387,7 @@ For example, `Stencil` to `STENCIL`. Can be applied to array of strings to chang
|
|||||||
``lowercase``
|
``lowercase``
|
||||||
~~~~~~~~~~~~~
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
The uppercase filter allows you to transform a string to 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.
|
||||||
For example, `Stencil` to `stencil`. Can be applied to array of strings to change each string.
|
|
||||||
|
|
||||||
.. code-block:: html+django
|
.. code-block:: html+django
|
||||||
|
|
||||||
@@ -342,8 +396,7 @@ For example, `Stencil` to `stencil`. Can be applied to array of strings to chang
|
|||||||
``default``
|
``default``
|
||||||
~~~~~~~~~~~
|
~~~~~~~~~~~
|
||||||
|
|
||||||
If a variable not present in the context, use given default. Otherwise, use the
|
If a variable not present in the context, use given default. Otherwise, use the value of the variable. For example:
|
||||||
value of the variable. For example:
|
|
||||||
|
|
||||||
.. code-block:: html+django
|
.. code-block:: html+django
|
||||||
|
|
||||||
@@ -386,3 +439,13 @@ Filter accepts several arguments:
|
|||||||
* indentation character: character to be used for indentation. Default is a space.
|
* indentation character: character to be used for indentation. Default is a space.
|
||||||
* indent first line: whether first line of output should be indented or not. Default is ``false``.
|
* indent first line: whether first line of output should be indented or not. Default is ``false``.
|
||||||
|
|
||||||
|
``filter``
|
||||||
|
~~~~~~~~~
|
||||||
|
|
||||||
|
Applies the filter with the name provided as an argument to the current expression.
|
||||||
|
|
||||||
|
.. code-block:: html+django
|
||||||
|
|
||||||
|
{{ string|filter:myfilter }}
|
||||||
|
|
||||||
|
This expression will resolve the `myfilter` variable, find a filter named the same as resolved value, and will apply it to the `string` variable. I.e. if `myfilter` variable resolves to string `uppercase` this expression will apply file `uppercase` to `string` variable.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ master_doc = 'index'
|
|||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = 'Stencil'
|
project = 'Stencil'
|
||||||
copyright = '2016, Kyle Fuller'
|
copyright = '2022, Kyle Fuller'
|
||||||
author = 'Kyle Fuller'
|
author = 'Kyle Fuller'
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
@@ -58,9 +58,9 @@ author = 'Kyle Fuller'
|
|||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '0.13.1'
|
version = '0.15.1'
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = '0.13.1'
|
release = '0.15.1'
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
|
|||||||
@@ -32,7 +32,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: context)
|
let rendered = try environment.renderTemplate(name: "articles.html", context: context)
|
||||||
|
|
||||||
print(rendered)
|
print(rendered)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ dependencies inside ``Package.swift``.
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "MyApplication",
|
name: "MyApplication",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.Package(url: "https://github.com/stencilproject/Stencil.git", majorVersion: 0, minor: 13),
|
.package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ If you're using CocoaPods, you can add Stencil to your ``Podfile`` and then run
|
|||||||
|
|
||||||
.. code-block:: ruby
|
.. code-block:: ruby
|
||||||
|
|
||||||
pod 'Stencil', '~> 0.13.1'
|
pod 'Stencil', '~> 0.15.1'
|
||||||
|
|
||||||
Carthage
|
Carthage
|
||||||
--------
|
--------
|
||||||
@@ -37,7 +37,7 @@ Carthage
|
|||||||
|
|
||||||
.. code-block:: text
|
.. code-block:: text
|
||||||
|
|
||||||
github "stencilproject/Stencil" ~> 0.13.1
|
github "stencilproject/Stencil" ~> 0.15.1
|
||||||
|
|
||||||
2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil:
|
2) Checkout your dependencies, generate the Stencil Xcode project, and then use Carthage to build Stencil:
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ following lookup:
|
|||||||
- Dictionary lookup
|
- Dictionary lookup
|
||||||
- Array and string lookup (first, last, count, by index)
|
- Array and string lookup (first, last, count, by index)
|
||||||
- Key value coding lookup
|
- Key value coding lookup
|
||||||
|
- @dynamicMemberLookup when conforming to our `DynamicMemberLookup` marker protocol
|
||||||
- Type introspection (via ``Mirror``)
|
- Type introspection (via ``Mirror``)
|
||||||
|
|
||||||
For example, if `people` was an array:
|
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.
|
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
|
Filters
|
||||||
~~~~~~~
|
~~~~~~~
|
||||||
|
|
||||||
@@ -94,6 +113,17 @@ To comment out part of your template, you can use the following syntax:
|
|||||||
|
|
||||||
{# My comment is completely hidden #}
|
{# 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:
|
||||||
|
|
||||||
Template inheritance
|
Template inheritance
|
||||||
@@ -129,7 +159,7 @@ Let's take a look at an example. Here is our base template (``base.html``):
|
|||||||
</html>
|
</html>
|
||||||
|
|
||||||
This example declares three blocks, ``title``, ``sidebar`` and ``content``. We
|
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.
|
and then use ``{% block %}`` to override any blocks from our base template.
|
||||||
|
|
||||||
A child template might look like the following:
|
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.
|
extend ``base.html`` and include section-specific styles/design.
|
||||||
* Create individual templates for each type of page, such as a news article or
|
* Create individual templates for each type of page, such as a news article or
|
||||||
blog entry. These templates extend the appropriate section template.
|
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.
|
||||||
|
|||||||
97
rakelib/Dangerfile
Normal file
97
rakelib/Dangerfile
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative 'check_changelog'
|
||||||
|
|
||||||
|
is_release = github.branch_for_head.start_with?('release/')
|
||||||
|
is_hotfix = github.branch_for_head.start_with?('hotfix/')
|
||||||
|
|
||||||
|
################################################
|
||||||
|
# Welcome message
|
||||||
|
markdown [
|
||||||
|
"Hey 👋 I'm Eve, the friendly bot watching over Stencil 🤖",
|
||||||
|
'Thanks a lot for your contribution!',
|
||||||
|
'', '---', ''
|
||||||
|
]
|
||||||
|
|
||||||
|
need_fixes = []
|
||||||
|
|
||||||
|
################################################
|
||||||
|
# Make it more obvious that a PR is a work in progress and shouldn't be merged yet
|
||||||
|
warn('PR is classed as Work in Progress') if github.pr_title.include? '[WIP]'
|
||||||
|
|
||||||
|
# Note when there is a big PR
|
||||||
|
message('Big PR') if git.lines_of_code > 500 && !is_release
|
||||||
|
|
||||||
|
################################################
|
||||||
|
# Check for correct base branch
|
||||||
|
if is_release
|
||||||
|
message('This is a Release PR')
|
||||||
|
|
||||||
|
require 'open3'
|
||||||
|
|
||||||
|
stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'changelog:check')
|
||||||
|
markdown [
|
||||||
|
'',
|
||||||
|
'### ChangeLog check',
|
||||||
|
'',
|
||||||
|
stdout
|
||||||
|
]
|
||||||
|
need_fixes << fail('Please fix the CHANGELOG errors') unless status.success?
|
||||||
|
|
||||||
|
stdout, _, status = Open3.capture3('bundle', 'exec', 'rake', 'release:check_versions')
|
||||||
|
markdown [
|
||||||
|
'',
|
||||||
|
'### Release version check',
|
||||||
|
'',
|
||||||
|
stdout
|
||||||
|
]
|
||||||
|
need_fixes << fail('Please fix the versions inconsistencies') unless status.success?
|
||||||
|
elsif is_hotfix
|
||||||
|
message('This is a Hotfix PR')
|
||||||
|
end
|
||||||
|
|
||||||
|
################################################
|
||||||
|
# Check for a CHANGELOG entry
|
||||||
|
declared_trivial = github.pr_title.include? '#trivial'
|
||||||
|
has_changelog = git.modified_files.include?('CHANGELOG.md')
|
||||||
|
changelog_msg = ''
|
||||||
|
unless has_changelog || declared_trivial
|
||||||
|
repo_url = github.pr_json['head']['repo']['html_url']
|
||||||
|
pr_title = github.pr_title
|
||||||
|
pr_title += '.' unless pr_title.end_with?('.')
|
||||||
|
pr_number = github.pr_json['number']
|
||||||
|
pr_url = github.pr_json['html_url']
|
||||||
|
pr_author = github.pr_author
|
||||||
|
pr_author_url = "https://github.com/#{pr_author}"
|
||||||
|
|
||||||
|
need_fixes = fail("Please include a CHANGELOG entry to credit your work. \nYou can find it at [CHANGELOG.md](#{repo_url}/blob/#{github.branch_for_head}/CHANGELOG.md).")
|
||||||
|
|
||||||
|
changelog_msg = <<-CHANGELOG_FORMAT.gsub(/^ *\|/, '')
|
||||||
|
|📝 We use the following format for CHANGELOG entries:
|
||||||
|
|```
|
||||||
|
|* #{pr_title}
|
||||||
|
| [##{pr_number}](#{pr_url})
|
||||||
|
| [@#{pr_author}](#{pr_author_url})
|
||||||
|
|```
|
||||||
|
|:bulb: Don't forget to end the line describing your changes by a period and two spaces.
|
||||||
|
CHANGELOG_FORMAT
|
||||||
|
# changelog_msg is printed during the "Encouragement message" section, see below
|
||||||
|
end
|
||||||
|
|
||||||
|
changelog_warnings = check_changelog
|
||||||
|
unless changelog_warnings.empty?
|
||||||
|
need_fixes << warn('Found some warnings in CHANGELOG.md')
|
||||||
|
changelog_warnings.each do |warning|
|
||||||
|
warn(warning[:message], file: 'CHANGELOG.md', line: warning[:line])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
################################################
|
||||||
|
# Encouragement message
|
||||||
|
if need_fixes.empty?
|
||||||
|
markdown('Seems like everything is in order 👍 You did a good job here! 🤝')
|
||||||
|
else
|
||||||
|
markdown('Once you fix those tiny nitpickings above, we should be good to go! 🙌')
|
||||||
|
markdown(changelog_msg) unless changelog_msg.empty?
|
||||||
|
markdown('ℹ️ _I will update this comment as you add new commits_')
|
||||||
|
end
|
||||||
56
rakelib/changelog.rake
Normal file
56
rakelib/changelog.rake
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Used constants:
|
||||||
|
# _none_
|
||||||
|
|
||||||
|
require_relative 'check_changelog'
|
||||||
|
|
||||||
|
namespace :changelog do
|
||||||
|
desc 'Add the empty CHANGELOG entries after a new release'
|
||||||
|
task :reset do
|
||||||
|
changelog = File.read('CHANGELOG.md')
|
||||||
|
abort('A Master entry already exists') if changelog =~ /^##\s*Master$/
|
||||||
|
changelog.sub!(/^##[^#]/, "#{header}\\0")
|
||||||
|
File.write('CHANGELOG.md', changelog)
|
||||||
|
end
|
||||||
|
|
||||||
|
def header
|
||||||
|
<<-HEADER.gsub(/^\s*\|/, '')
|
||||||
|
|## Master
|
||||||
|
|
|
||||||
|
|### Breaking
|
||||||
|
|
|
||||||
|
|_None_
|
||||||
|
|
|
||||||
|
|### Enhancements
|
||||||
|
|
|
||||||
|
|_None_
|
||||||
|
|
|
||||||
|
|### Deprecations
|
||||||
|
|
|
||||||
|
|_None_
|
||||||
|
|
|
||||||
|
|### Bug Fixes
|
||||||
|
|
|
||||||
|
|_None_
|
||||||
|
|
|
||||||
|
|### Internal Changes
|
||||||
|
|
|
||||||
|
|_None_
|
||||||
|
|
|
||||||
|
HEADER
|
||||||
|
end
|
||||||
|
|
||||||
|
desc 'Check if links to issues and PRs use matching numbers between text & link'
|
||||||
|
task :check do
|
||||||
|
warnings = check_changelog
|
||||||
|
if warnings.empty?
|
||||||
|
puts "\u{2705} All entries seems OK (end with period + 2 spaces, correct links)"
|
||||||
|
else
|
||||||
|
puts "\u{274C} Some warnings were found:\n" + Array(warnings.map do |warning|
|
||||||
|
" - Line #{warning[:line]}: #{warning[:message]}"
|
||||||
|
end).join("\n")
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
61
rakelib/check_changelog.rb
Normal file
61
rakelib/check_changelog.rb
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# This analyze the CHANGELOG.md file and report warnings on its content
|
||||||
|
#
|
||||||
|
# It checks:
|
||||||
|
# - if the description part of each entry ends with a period and two spaces
|
||||||
|
# - that all links to PRs & issues with format [#nn](repo_url/nn) are consistent
|
||||||
|
# (use the same number in the link title and URL)
|
||||||
|
#
|
||||||
|
# @return Array of Hashes with keys `:line` & `:message` for each element
|
||||||
|
#
|
||||||
|
def check_changelog
|
||||||
|
current_repo = File.basename(`git remote get-url origin`.chomp, '.git').freeze
|
||||||
|
slug_re = '([a-zA-Z]*/[a-zA-Z]*)'
|
||||||
|
links = %r{\[#{slug_re}?\#([0-9]+)\]\(https://github.com/#{slug_re}/(issues|pull)/([0-9]+)\)}
|
||||||
|
links_typos = %r{https://github.com/#{slug_re}/(issue|pulls)/([0-9]+)}
|
||||||
|
|
||||||
|
all_warnings = []
|
||||||
|
inside_entry = false
|
||||||
|
last_line_has_correct_ending = false
|
||||||
|
|
||||||
|
File.readlines('CHANGELOG.md').each_with_index do |line, idx|
|
||||||
|
line.chomp! # Remove \n the end, it's easier for checks below
|
||||||
|
was_inside_entry = inside_entry
|
||||||
|
just_started_new_entry = line.start_with?('* ')
|
||||||
|
inside_entry = true if just_started_new_entry
|
||||||
|
inside_entry = false if /^ \[.*\]\(.*\)$/ =~ line # link-only line
|
||||||
|
|
||||||
|
if was_inside_entry && !inside_entry && !last_line_has_correct_ending
|
||||||
|
# We just ended an entry's description by starting the links, but description didn't end with '. '
|
||||||
|
# Note: entry descriptions can be on multiple lines, hence the need to wait for the next line
|
||||||
|
# to not be inside an entry to be able to consider the previous line as the end of entry description.
|
||||||
|
all_warnings.concat [
|
||||||
|
{ line: idx, message: 'Line describing your entry should end with a period and 2 spaces.' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
# Store if current line has correct ending, for next iteration, so that if the next line isn't
|
||||||
|
# part of the entry description, we can check if previous line ends description correctly.
|
||||||
|
# Also, lines just linking to CHANGELOG to other repositories (StencilSwiftKit & Stencil mainly)
|
||||||
|
# should be considered as not needing the '. ' ending.
|
||||||
|
last_line_has_correct_ending = line.end_with?('. ') || line.end_with?('/CHANGELOG.md)')
|
||||||
|
|
||||||
|
# Now, check that links [#nn](.../nn) have matching numbers in link title & URL
|
||||||
|
wrong_links = line.scan(links).reject do |m|
|
||||||
|
slug = m[0] || "stencilproject/#{current_repo}"
|
||||||
|
(slug == m[2]) && (m[1] == m[4])
|
||||||
|
end
|
||||||
|
all_warnings.concat Array(wrong_links.map do |m|
|
||||||
|
link_text = "#{m[0]}##{m[1]}"
|
||||||
|
link_url = "#{m[2]}##{m[4]}"
|
||||||
|
{ line: idx + 1, message: "Link text is #{link_text} but links points to #{link_url}." }
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Flag common typos in GitHub issue/PR URLs
|
||||||
|
typo_links = line.scan(links_typos)
|
||||||
|
all_warnings.concat Array(typo_links.map do |_|
|
||||||
|
{ line: idx + 1, message: 'This looks like a GitHub link URL with a typo. Issue links should use `/issues/123` (plural) and PR links should use `/pull/123` (singular).' }
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
all_warnings
|
||||||
|
end
|
||||||
52
rakelib/github.rake
Normal file
52
rakelib/github.rake
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
require 'octokit'
|
||||||
|
|
||||||
|
def repo_slug
|
||||||
|
url_parts = `git remote get-url origin`.chomp.split(%r{/|:})
|
||||||
|
last_two_parts = url_parts[-2..-1].join('/')
|
||||||
|
last_two_parts.gsub(/\.git$/, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
def github_client
|
||||||
|
Octokit::Client.new(:netrc => true)
|
||||||
|
end
|
||||||
|
|
||||||
|
namespace :github do
|
||||||
|
# rake github:create_release_pr[version]
|
||||||
|
task :create_release_pr, [:version] do |_, args|
|
||||||
|
version = args[:version]
|
||||||
|
branch = release_branch(version)
|
||||||
|
|
||||||
|
title = "Release #{version}"
|
||||||
|
body = <<~BODY
|
||||||
|
This PR prepares the release for version #{version}.
|
||||||
|
|
||||||
|
Once the PR is merged into master, run `bundle exec rake release:finish` to tag and push to trunk.
|
||||||
|
BODY
|
||||||
|
|
||||||
|
header "Opening PR"
|
||||||
|
res = github_client.create_pull_request(repo_slug, "master", branch, title, body)
|
||||||
|
info "Pull request created: #{res['html_url']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# rake github:tag
|
||||||
|
task :tag do
|
||||||
|
tag = current_pod_version
|
||||||
|
sh("git", "tag", tag)
|
||||||
|
sh("git", "push", "origin", tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
# rake github:create_release
|
||||||
|
task :create_release do
|
||||||
|
tag_name = current_pod_version
|
||||||
|
title = tag_name
|
||||||
|
body = changelog_first_section()
|
||||||
|
res = github_client.create_release(repo_slug, tag_name, name: title, body: body)
|
||||||
|
info "GitHub Release created: #{res['html_url']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# rake github:pull_master
|
||||||
|
task :pull_master do
|
||||||
|
sh("git", "switch", "master")
|
||||||
|
sh("git", "pull")
|
||||||
|
end
|
||||||
|
end
|
||||||
50
rakelib/lint.rake
Normal file
50
rakelib/lint.rake
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Used constants:
|
||||||
|
# - BUILD_DIR
|
||||||
|
|
||||||
|
namespace :lint do
|
||||||
|
SWIFTLINT = 'rakelib/lint.sh'
|
||||||
|
SWIFTLINT_VERSION = '0.48.0'
|
||||||
|
|
||||||
|
task :install do |task|
|
||||||
|
next if check_version
|
||||||
|
|
||||||
|
if OS.mac?
|
||||||
|
url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/portable_swiftlint.zip"
|
||||||
|
else
|
||||||
|
url = "https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/swiftlint_linux.zip"
|
||||||
|
end
|
||||||
|
tmppath = '/tmp/swiftlint.zip'
|
||||||
|
destination = "#{BUILD_DIR}/swiftlint"
|
||||||
|
|
||||||
|
Utils.run([
|
||||||
|
%(curl -Lo #{tmppath} #{url}),
|
||||||
|
%(rm -rf #{destination}),
|
||||||
|
%(mkdir -p #{destination}),
|
||||||
|
%(unzip #{tmppath} -d #{destination})
|
||||||
|
], task)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc 'Lint the code'
|
||||||
|
task :code => :install do |task|
|
||||||
|
Utils.print_header 'Linting the code'
|
||||||
|
Utils.run(%(#{SWIFTLINT} sources), task)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc 'Lint the tests'
|
||||||
|
task :tests => :install do |task|
|
||||||
|
Utils.print_header 'Linting the unit test code'
|
||||||
|
Utils.run(%(#{SWIFTLINT} tests), task)
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_version
|
||||||
|
swiftlint = "#{BUILD_DIR}/swiftlint/swiftlint"
|
||||||
|
return false unless File.executable?(swiftlint)
|
||||||
|
|
||||||
|
current = `#{swiftlint} version`.chomp
|
||||||
|
required = SWIFTLINT_VERSION.chomp
|
||||||
|
|
||||||
|
current == required
|
||||||
|
end
|
||||||
|
end
|
||||||
35
rakelib/lint.sh
Executable file
35
rakelib/lint.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
PROJECT_DIR="${PROJECT_DIR:-`cd "$(dirname $0)/..";pwd`}"
|
||||||
|
SWIFTLINT="${PROJECT_DIR}/.build/swiftlint/swiftlint"
|
||||||
|
CONFIG="${PROJECT_DIR}/.swiftlint.yml"
|
||||||
|
if [ $CI ]; then
|
||||||
|
REPORTER="--reporter github-actions-logging"
|
||||||
|
else
|
||||||
|
REPORTER=
|
||||||
|
fi
|
||||||
|
|
||||||
|
# possible paths
|
||||||
|
paths_sources="Sources/Stencil"
|
||||||
|
paths_tests="Tests/StencilTests"
|
||||||
|
|
||||||
|
# load selected group
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
key="$1"
|
||||||
|
else
|
||||||
|
echo "error: need group to lint."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
selected_path=`eval echo '$'paths_$key`
|
||||||
|
if [ -z "$selected_path" ]; then
|
||||||
|
echo "error: need a valid group to lint."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUB_CONFIG="${PROJECT_DIR}/${selected_path}/.swiftlint.yml"
|
||||||
|
if [ -f "$SUB_CONFIG" ]; then
|
||||||
|
"$SWIFTLINT" lint --strict --config "$SUB_CONFIG" $REPORTER "${PROJECT_DIR}/${selected_path}"
|
||||||
|
else
|
||||||
|
"$SWIFTLINT" lint --strict --config "$CONFIG" $REPORTER "${PROJECT_DIR}/${selected_path}"
|
||||||
|
fi
|
||||||
12
rakelib/pod.rake
Normal file
12
rakelib/pod.rake
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Used constants:
|
||||||
|
# - POD_NAME
|
||||||
|
|
||||||
|
namespace :pod do
|
||||||
|
desc 'Lint the Pod'
|
||||||
|
task :lint do |task|
|
||||||
|
Utils.print_header 'Linting the pod spec'
|
||||||
|
Utils.run(%(bundle exec pod lib lint "#{POD_NAME}.podspec.json" --quick), task)
|
||||||
|
end
|
||||||
|
end
|
||||||
97
rakelib/release.rake
Normal file
97
rakelib/release.rake
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Used constants:
|
||||||
|
# - BUILD_DIR
|
||||||
|
|
||||||
|
require 'json'
|
||||||
|
|
||||||
|
namespace :release do
|
||||||
|
desc 'Create a new release'
|
||||||
|
task :new => [:check_versions, :check_tag_and_ask_to_release, 'spm:test', :github, :cocoapods]
|
||||||
|
|
||||||
|
desc 'Check if all versions from the podspecs and CHANGELOG match'
|
||||||
|
task :check_versions do
|
||||||
|
results = []
|
||||||
|
|
||||||
|
Utils.table_header('Check', 'Status')
|
||||||
|
|
||||||
|
# Check if bundler is installed first, as we'll need it for the cocoapods task (and we prefer to fail early)
|
||||||
|
`which bundler`
|
||||||
|
results << Utils.table_result(
|
||||||
|
$CHILD_STATUS.success?,
|
||||||
|
'Bundler installed',
|
||||||
|
'Install bundler using `gem install bundler` and run `bundle install` first.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract version from podspec
|
||||||
|
podspec = Utils::podspec(POD_NAME)
|
||||||
|
v = podspec['version']
|
||||||
|
Utils.table_info("#{POD_NAME}.podspec", v)
|
||||||
|
|
||||||
|
# Check podspec tag
|
||||||
|
podspec_tag = podspec['source']['tag']
|
||||||
|
results << Utils.table_result(podspec_tag == v, 'Podspec version & tag equal', 'Update the `tag` in podspec')
|
||||||
|
|
||||||
|
# Check docs config
|
||||||
|
docs_version = Utils.first_match_in_file('docs/conf.py', /version = '(.+)'/, 1)
|
||||||
|
docs_release = Utils.first_match_in_file('docs/conf.py', /release = '(.+)'/, 1)
|
||||||
|
results << Utils.table_result(docs_version == v,'Docs, version updated', 'Update the `version` in docs/conf.py')
|
||||||
|
results << Utils.table_result(docs_release == v, 'Docs, release updated', 'Update the `release` in docs/conf.py')
|
||||||
|
|
||||||
|
# Check docs installation
|
||||||
|
docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
|
||||||
|
docs_cocoapods = Utils.first_match_in_file('docs/installation.rst', /pod 'Stencil', '~> (.+)'/, 1)
|
||||||
|
docs_carthage = Utils.first_match_in_file('docs/installation.rst', /github ".+\/Stencil" ~> (.+)/, 1)
|
||||||
|
results << Utils.table_result(docs_package == v, 'Docs, package updated', 'Update the package version in docs/installation.rst')
|
||||||
|
results << Utils.table_result(docs_cocoapods == v, 'Docs, cocoapods updated', 'Update the cocoapods version in docs/installation.rst')
|
||||||
|
results << Utils.table_result(docs_carthage == v, 'Docs, carthage updated', 'Update the carthage version in docs/installation.rst')
|
||||||
|
|
||||||
|
# Check if entry present in CHANGELOG
|
||||||
|
changelog_entry = Utils.first_match_in_file('CHANGELOG.md', /^## #{Regexp.quote(v)}$/)
|
||||||
|
results << Utils.table_result(changelog_entry, 'CHANGELOG, Entry added', "Add an entry for #{v} in CHANGELOG.md")
|
||||||
|
|
||||||
|
changelog_has_stable = system("grep -qi '^## Master' CHANGELOG.md")
|
||||||
|
results << Utils.table_result(!changelog_has_stable, 'CHANGELOG, No master', 'Remove section for master branch in CHANGELOG')
|
||||||
|
|
||||||
|
exit 1 unless results.all?
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Check tag and ask to release"
|
||||||
|
task :check_tag_and_ask_to_release do
|
||||||
|
results = []
|
||||||
|
podspec_version = Utils.podspec_version(POD_NAME)
|
||||||
|
|
||||||
|
tag_set = !`git ls-remote --tags . refs/tags/#{podspec_version}`.empty?
|
||||||
|
results << Utils.table_result(
|
||||||
|
tag_set,
|
||||||
|
'Tag pushed',
|
||||||
|
'Please create a tag and push it'
|
||||||
|
)
|
||||||
|
|
||||||
|
exit 1 unless results.all?
|
||||||
|
|
||||||
|
print "Release version #{podspec_version} [Y/n]? "
|
||||||
|
exit 2 unless STDIN.gets.chomp == 'Y'
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Create a new GitHub release"
|
||||||
|
task :github do
|
||||||
|
require 'octokit'
|
||||||
|
|
||||||
|
client = Utils.octokit_client
|
||||||
|
tag = Utils.top_changelog_version
|
||||||
|
body = Utils.top_changelog_entry
|
||||||
|
|
||||||
|
raise 'Must be a valid version' if tag == 'Master'
|
||||||
|
|
||||||
|
repo_name = File.basename(`git remote get-url origin`.chomp, '.git').freeze
|
||||||
|
puts "Pushing release notes for tag #{tag}"
|
||||||
|
client.create_release("stencilproject/#{repo_name}", tag, name: tag, body: body)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "pod trunk push #{POD_NAME} to CocoaPods"
|
||||||
|
task :cocoapods do
|
||||||
|
Utils.print_header 'Pushing pod to CocoaPods Trunk'
|
||||||
|
sh "bundle exec pod trunk push #{POD_NAME}.podspec.json"
|
||||||
|
end
|
||||||
|
end
|
||||||
18
rakelib/spm.rake
Normal file
18
rakelib/spm.rake
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Used constants:
|
||||||
|
# _none_
|
||||||
|
|
||||||
|
namespace :spm do
|
||||||
|
desc 'Build using SPM'
|
||||||
|
task :build do |task|
|
||||||
|
Utils.print_header 'Compile using SPM'
|
||||||
|
Utils.run('swift build', task, xcrun: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc 'Run SPM Unit Tests'
|
||||||
|
task :test => :build do |task|
|
||||||
|
Utils.print_header 'Run the unit tests using SPM'
|
||||||
|
Utils.run('swift test --parallel', task, xcrun: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
266
rakelib/utils.rake
Normal file
266
rakelib/utils.rake
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Used constants:
|
||||||
|
# - MIN_XCODE_VERSION
|
||||||
|
|
||||||
|
require 'json'
|
||||||
|
require 'open3'
|
||||||
|
require 'pathname'
|
||||||
|
|
||||||
|
# Utility functions to run Xcode commands, extract versionning info and logs messages
|
||||||
|
#
|
||||||
|
class Utils
|
||||||
|
COLUMN_WIDTHS = [45, 12].freeze
|
||||||
|
|
||||||
|
## [ Run commands ] #########################################################
|
||||||
|
|
||||||
|
# formatter types
|
||||||
|
# :xcpretty : through xcpretty and store in artifacts
|
||||||
|
# :raw : store in artifacts
|
||||||
|
# :to_string : run using backticks and return output
|
||||||
|
|
||||||
|
# run a command using xcrun and xcpretty if applicable
|
||||||
|
def self.run(command, task, subtask = '', xcrun: false, formatter: :raw)
|
||||||
|
commands = if xcrun and OS.mac?
|
||||||
|
Array(command).map { |cmd| "#{version_select} xcrun #{cmd}" }
|
||||||
|
else
|
||||||
|
Array(command)
|
||||||
|
end
|
||||||
|
case formatter
|
||||||
|
when :xcpretty then xcpretty(commands, task, subtask)
|
||||||
|
when :raw then plain(commands, task, subtask)
|
||||||
|
when :to_string then `#{commands.join(' && ')}`
|
||||||
|
else raise "Unknown formatter '#{formatter}'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## [ Convenience Helpers ] ##################################################
|
||||||
|
|
||||||
|
def self.podspec(file)
|
||||||
|
JSON.parse(File.read("#{file}.podspec.json"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.podspec_version(file)
|
||||||
|
podspec_as_json(file)['version']
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.pod_trunk_last_version(pod)
|
||||||
|
require 'yaml'
|
||||||
|
stdout, _, _ = Open3.capture3('bundle', 'exec', 'pod', 'trunk', 'info', pod)
|
||||||
|
stdout.sub!("\n#{pod}\n", '')
|
||||||
|
last_version_line = YAML.safe_load(stdout).first['Versions'].last
|
||||||
|
/^[0-9.]*/.match(last_version_line)[0] # Just the 'x.y.z' part
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.spm_own_version(dep)
|
||||||
|
dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
|
||||||
|
dependencies.find { |d| d['package'] == dep }['state']['version']
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.spm_resolved_version(dep)
|
||||||
|
dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
|
||||||
|
dependencies.find { |d| d['package'] == dep }['state']['version']
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.last_git_tag_version
|
||||||
|
`git describe --tags --abbrev=0`.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.octokit_client
|
||||||
|
token = ENV['DANGER_GITHUB_API_TOKEN']
|
||||||
|
token ||= File.exist?('.apitoken') && File.read('.apitoken')
|
||||||
|
token ||= File.exist?('../.apitoken') && File.read('../.apitoken')
|
||||||
|
Utils.print_error('No .apitoken file found') unless token
|
||||||
|
require 'octokit'
|
||||||
|
Octokit::Client.new(access_token: token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.top_changelog_version(changelog_file = 'CHANGELOG.md')
|
||||||
|
header, _, _ = Open3.capture3('grep', '-m', '1', '^## ', changelog_file)
|
||||||
|
header.gsub('## ', '').strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.top_changelog_entry(changelog_file = 'CHANGELOG.md')
|
||||||
|
tag = top_changelog_version
|
||||||
|
stdout, _, _ = Open3.capture3('sed', '-n', "/^## #{tag}$/,/^## /p", changelog_file)
|
||||||
|
stdout.gsub(/^## .*$/, '').strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.first_match_in_file(file, regexp, index = 0)
|
||||||
|
File.foreach(file) do |line|
|
||||||
|
m = regexp.match(line)
|
||||||
|
return m[index] if m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## [ Print info/errors ] ####################################################
|
||||||
|
|
||||||
|
# print an info header
|
||||||
|
def self.print_header(str)
|
||||||
|
puts "== #{str.chomp} ==".format(:yellow, :bold)
|
||||||
|
end
|
||||||
|
|
||||||
|
# print an info message
|
||||||
|
def self.print_info(str)
|
||||||
|
puts str.chomp.format(:green)
|
||||||
|
end
|
||||||
|
|
||||||
|
# print an error message
|
||||||
|
def self.print_error(str)
|
||||||
|
puts str.chomp.format(:red)
|
||||||
|
end
|
||||||
|
|
||||||
|
# format an info message in a 2 column table
|
||||||
|
def self.table_header(col1, col2)
|
||||||
|
puts "| #{col1.ljust(COLUMN_WIDTHS[0])} | #{col2.ljust(COLUMN_WIDTHS[1])} |"
|
||||||
|
puts "| #{'-' * COLUMN_WIDTHS[0]} | #{'-' * COLUMN_WIDTHS[1]} |"
|
||||||
|
end
|
||||||
|
|
||||||
|
# format an info message in a 2 column table
|
||||||
|
def self.table_info(label, msg)
|
||||||
|
puts "| #{label.ljust(COLUMN_WIDTHS[0])} | 👉 #{msg.ljust(COLUMN_WIDTHS[1] - 4)} |"
|
||||||
|
end
|
||||||
|
|
||||||
|
# format a result message in a 2 column table
|
||||||
|
def self.table_result(result, label, error_msg)
|
||||||
|
if result
|
||||||
|
puts "| #{label.ljust(COLUMN_WIDTHS[0])} | #{'✅'.ljust(COLUMN_WIDTHS[1] - 1)} |"
|
||||||
|
else
|
||||||
|
puts "| #{label.ljust(COLUMN_WIDTHS[0])} | ❌ - #{error_msg.ljust(COLUMN_WIDTHS[1] - 6)} |"
|
||||||
|
end
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
## [ Private helper functions ] ##################################################
|
||||||
|
|
||||||
|
# run a command, pipe output through 'xcpretty' and store the output in CI artifacts
|
||||||
|
def self.xcpretty(cmd, task, subtask)
|
||||||
|
command = Array(cmd).join(' && \\' + "\n")
|
||||||
|
|
||||||
|
if ENV['CI']
|
||||||
|
Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color --report junit)
|
||||||
|
elsif system('which xcpretty > /dev/null')
|
||||||
|
Rake.sh %(set -o pipefail && (\\\n#{command} \\\n) | bundle exec xcpretty --color)
|
||||||
|
else
|
||||||
|
Rake.sh command
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :xcpretty
|
||||||
|
|
||||||
|
# run a command and store the output in CI artifacts
|
||||||
|
def self.plain(cmd, task, subtask)
|
||||||
|
command = Array(cmd).join(' && \\' + "\n")
|
||||||
|
|
||||||
|
if ENV['CI']
|
||||||
|
if OS.mac?
|
||||||
|
Rake.sh %(set -o pipefail && (#{command}))
|
||||||
|
else
|
||||||
|
# dash on linux doesn't support `set -o`
|
||||||
|
Rake.sh %(/bin/bash -eo pipefail -c "#{command}")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Rake.sh command
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :plain
|
||||||
|
|
||||||
|
# select the xcode version we want/support
|
||||||
|
def self.version_select
|
||||||
|
@version_select ||= compute_developer_dir(MIN_XCODE_VERSION)
|
||||||
|
end
|
||||||
|
private_class_method :version_select
|
||||||
|
|
||||||
|
# Return the "DEVELOPER_DIR=..." prefix to use in order to point to the best Xcode version
|
||||||
|
#
|
||||||
|
# @param [String|Float|Gem::Requirement] version_req
|
||||||
|
# The Xcode version requirement.
|
||||||
|
# - If it's a Float, it's converted to a "~> x.y" requirement
|
||||||
|
# - If it's a String, it's converted to a Gem::Requirement as is
|
||||||
|
# @note If you pass a String, be sure to use "~> " in the string unless you really want
|
||||||
|
# to point to an exact, very specific version
|
||||||
|
#
|
||||||
|
def self.compute_developer_dir(version_req)
|
||||||
|
version_req = Gem::Requirement.new("~> #{version_req}") if version_req.is_a?(Float)
|
||||||
|
version_req = Gem::Requirement.new(version_req) unless version_req.is_a?(Gem::Requirement)
|
||||||
|
# if current Xcode already fulfills min version don't force DEVELOPER_DIR=...
|
||||||
|
current_xcode_version = `xcodebuild -version`.split("\n").first.match(/[0-9.]+/).to_s
|
||||||
|
return '' if version_req.satisfied_by? Gem::Version.new(current_xcode_version)
|
||||||
|
|
||||||
|
supported_versions = all_xcode_versions.select { |app| version_req.satisfied_by?(app[:vers]) }
|
||||||
|
latest_supported_xcode = supported_versions.sort_by { |app| app[:vers] }.last
|
||||||
|
|
||||||
|
# Check if it's at least the right version
|
||||||
|
if latest_supported_xcode.nil?
|
||||||
|
raise "\n[!!!] Requires Xcode #{version_req}, but we were not able to find it. " \
|
||||||
|
"If it's already installed, either `xcode-select -s` to it, or update your Spotlight index " \
|
||||||
|
"with 'mdimport /Applications/Xcode*'\n\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
%(DEVELOPER_DIR="#{latest_supported_xcode[:path]}/Contents/Developer")
|
||||||
|
end
|
||||||
|
private_class_method :compute_developer_dir
|
||||||
|
|
||||||
|
# @return [Array<Hash>] A list of { :vers => ... , :path => ... } hashes
|
||||||
|
# of all Xcodes found on the machine using Spotlight
|
||||||
|
def self.all_xcode_versions
|
||||||
|
xcodes = `mdfind "kMDItemCFBundleIdentifier = 'com.apple.dt.Xcode'"`.chomp.split("\n")
|
||||||
|
xcodes.map do |path|
|
||||||
|
{ vers: Gem::Version.new(`mdls -name kMDItemVersion -raw "#{path}"`), path: path }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
private_class_method :all_xcode_versions
|
||||||
|
end
|
||||||
|
|
||||||
|
# OS detection
|
||||||
|
#
|
||||||
|
module OS
|
||||||
|
def OS.mac?
|
||||||
|
(/darwin/ =~ RUBY_PLATFORM) != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def OS.linux?
|
||||||
|
OS.unix? and not OS.mac?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Colorization support for Strings
|
||||||
|
#
|
||||||
|
class String
|
||||||
|
# colorization
|
||||||
|
FORMATTING = {
|
||||||
|
# text styling
|
||||||
|
bold: 1,
|
||||||
|
faint: 2,
|
||||||
|
italic: 3,
|
||||||
|
underline: 4,
|
||||||
|
# foreground colors
|
||||||
|
black: 30,
|
||||||
|
red: 31,
|
||||||
|
green: 32,
|
||||||
|
yellow: 33,
|
||||||
|
blue: 34,
|
||||||
|
magenta: 35,
|
||||||
|
cyan: 36,
|
||||||
|
white: 37,
|
||||||
|
# background colors
|
||||||
|
bg_black: 40,
|
||||||
|
bg_red: 41,
|
||||||
|
bg_green: 42,
|
||||||
|
bg_yellow: 43,
|
||||||
|
bg_blue: 44,
|
||||||
|
bg_magenta: 45,
|
||||||
|
bg_cyan: 46,
|
||||||
|
bg_white: 47
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# only enable formatting if terminal supports it
|
||||||
|
if `tput colors`.chomp.to_i >= 8
|
||||||
|
def format(*styles)
|
||||||
|
styles.map { |s| "\e[#{FORMATTING[s]}m" }.join + self + "\e[0m"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
def format(*_styles)
|
||||||
|
self
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user