Compare commits
314 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ff4a6b85d | ||
|
|
02f61d55a4 | ||
|
|
c25b7a52e7 | ||
|
|
25d1507159 | ||
|
|
6811c71bd6 | ||
|
|
17af3bace1 | ||
|
|
1aeeced65d | ||
|
|
ea58733eb6 | ||
|
|
003341d94c | ||
|
|
930db33028 | ||
|
|
6b6d6c2730 | ||
|
|
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 | ||
|
|
0e9a78d658 | ||
|
|
8eae79dbff | ||
|
|
8cceac921a | ||
|
|
7417332fa2 | ||
|
|
524c0acce6 | ||
|
|
2e67755118 | ||
|
|
c7dbba41a5 | ||
|
|
69af469d0d | ||
|
|
42e415a9bf | ||
|
|
2760843236 | ||
|
|
535a8061d9 | ||
|
|
88bec575a5 | ||
|
|
6f9bb3e931 | ||
|
|
cb4e514846 | ||
|
|
fff93f18dd | ||
|
|
652dcd246d | ||
|
|
e77bd22e83 | ||
|
|
4f84627caa | ||
|
|
07a6b2aea5 | ||
|
|
fce3dc5e48 | ||
|
|
f7bda226e8 | ||
|
|
d238c25eef | ||
|
|
df2e193891 | ||
|
|
2c3962a3de | ||
|
|
7ed95aec91 | ||
|
|
064b2f706c | ||
|
|
fce4e85a63 | ||
|
|
275e583e4a | ||
|
|
9c408d488e | ||
|
|
f9f6d95f25 | ||
|
|
0d4dee29b2 | ||
|
|
1704cd2ddf | ||
|
|
831cdf5f36 | ||
|
|
8210fa57f1 | ||
|
|
0074ee1d4a | ||
|
|
d71fe2a2ee | ||
|
|
93ccc56540 | ||
|
|
247a35fd2c | ||
|
|
8e9692c696 | ||
|
|
8bda4d5bbb | ||
|
|
e6b12c09d3 | ||
|
|
420c0eacd7 | ||
|
|
adb443229d | ||
|
|
1098921dc8 | ||
|
|
9de8190988 | ||
|
|
acda1b0caf | ||
|
|
00e71c1b4d | ||
|
|
1b85b816fd | ||
|
|
b476e50f89 | ||
|
|
2ed5763fe4 | ||
|
|
fff3d21e37 | ||
|
|
99be5f0459 | ||
|
|
2eeb7babd3 | ||
|
|
fc404b25d8 | ||
|
|
42972a1c10 | ||
|
|
6a4959cea0 | ||
|
|
ffe8f9dab0 | ||
|
|
96a004eb34 | ||
|
|
92ebfe59b1 | ||
|
|
71ad162268 | ||
|
|
b9702afbd4 | ||
|
|
4f1a5b3e3d | ||
|
|
3a4cd8aa27 | ||
|
|
e795f052ea | ||
|
|
2c411ca494 | ||
|
|
f3d5843e78 | ||
|
|
4f14b4b044 | ||
|
|
b66abc3112 | ||
|
|
5bbd994581 | ||
|
|
3995ff9acf | ||
|
|
2e18892f4c | ||
|
|
564ccb7af7 | ||
|
|
39ed9aa753 | ||
|
|
d935f65d56 | ||
|
|
2627d3e0d1 | ||
|
|
1e77f1e85f | ||
|
|
47f2b33d80 | ||
|
|
1427e10698 | ||
|
|
e070ae7851 | ||
|
|
fc6c0208b2 | ||
|
|
34dbafa789 | ||
|
|
eb8c875853 | ||
|
|
098af2a7b6 | ||
|
|
7679b48164 | ||
|
|
7c499cc077 | ||
|
|
88e54ab4ba | ||
|
|
c2e25f25ac | ||
|
|
fe01beb4bb | ||
|
|
2e6a7215c5 | ||
|
|
f457cddd3f | ||
|
|
6b02fccf84 | ||
|
|
29e859f1aa | ||
|
|
8fa0bd275c | ||
|
|
91847208a3 | ||
|
|
86ed8770e1 | ||
|
|
0bc6bd974e | ||
|
|
fa68ba9df8 | ||
|
|
4827fb8e20 | ||
|
|
359d086c02 | ||
|
|
24c9746689 | ||
|
|
c4a84a6375 | ||
|
|
c30597457f | ||
|
|
b54292788f | ||
|
|
d6766b43da | ||
|
|
662849e968 | ||
|
|
4bfdb73175 | ||
|
|
a165a6715f | ||
|
|
ac2fd56e8e | ||
|
|
cb124319ec | ||
|
|
abeb30bb1c | ||
|
|
ed885f462a | ||
|
|
7756522317 | ||
|
|
8d68edd725 | ||
|
|
218822fcb0 | ||
|
|
ea7e1efac7 | ||
|
|
bb3f33724b | ||
|
|
c486617854 | ||
|
|
9a28142fa6 | ||
|
|
53c1550c5b | ||
|
|
27135f3ea3 | ||
|
|
5878c323a2 | ||
|
|
97ab3cf31d | ||
|
|
7688326204 | ||
|
|
079fdf39b8 | ||
|
|
e59609f140 | ||
|
|
d5f0be959f | ||
|
|
0edb38588d | ||
|
|
69cd8e4d3b | ||
|
|
6300dbc7bf | ||
|
|
b4dc8dbb76 | ||
|
|
2e80f70f67 | ||
|
|
a6dba67828 | ||
|
|
691fe523b3 | ||
|
|
c0e66eb96f | ||
|
|
0156f6f37b | ||
|
|
79a16854e7 | ||
|
|
a4b75f3c89 | ||
|
|
0f3a302108 | ||
|
|
1223efbc7e | ||
|
|
9357df35d1 | ||
|
|
a96fcff680 | ||
|
|
0017aee5a8 | ||
|
|
1e6846867e | ||
|
|
93c07e22b1 | ||
|
|
98461c75b0 | ||
|
|
9994972a24 | ||
|
|
cf7acea440 | ||
|
|
9e24ab658b | ||
|
|
a52ee21b72 | ||
|
|
4a93815d4c | ||
|
|
7e88cbde11 | ||
|
|
e7a0738bda | ||
|
|
46f179e3ed | ||
|
|
bf4be38377 | ||
|
|
14bac03990 | ||
|
|
3180b26673 | ||
|
|
000e9a7f1a | ||
|
|
7b9817ed50 | ||
|
|
482d595d01 | ||
|
|
f1fc747897 | ||
|
|
0444f45d2b | ||
|
|
86bfbf215f | ||
|
|
039bf4b7cb | ||
|
|
4308baf5f0 | ||
|
|
2455fb9ed0 | ||
|
|
64571464d9 | ||
|
|
5821e4849e | ||
|
|
793773f191 | ||
|
|
e217a9c873 | ||
|
|
584ed916ab |
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"
|
||||||
26
.github/workflows/danger.yml
vendored
Normal file
26
.github/workflows/danger.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: Danger
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Danger Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
-
|
||||||
|
name: Setup Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
-
|
||||||
|
name: Run Danger
|
||||||
|
run: bundle exec danger --verbose --dangerfile=rakelib/Dangerfile
|
||||||
|
env:
|
||||||
|
DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}
|
||||||
24
.github/workflows/lint-cocoapods.yml
vendored
Normal file
24
.github/workflows/lint-cocoapods.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: Lint Cocoapods
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Pod Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
-
|
||||||
|
name: Setup Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
-
|
||||||
|
name: Lint podspec
|
||||||
|
run: bundle exec rake pod:lint
|
||||||
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
|
||||||
27
.github/workflows/swiftlint.yml
vendored
Normal file
27
.github/workflows/swiftlint.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: SwiftLint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: SwiftLint
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
-
|
||||||
|
name: Setup Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
-
|
||||||
|
name: Lint source code
|
||||||
|
run: bundle exec rake lint:code
|
||||||
|
-
|
||||||
|
name: Lint tests source code
|
||||||
|
run: bundle exec rake lint:tests
|
||||||
26
.github/workflows/tag-publish.yml
vendored
Normal file
26
.github/workflows/tag-publish.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: Publish on Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
github:
|
||||||
|
name: GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
-
|
||||||
|
name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
-
|
||||||
|
name: Create release on GitHub
|
||||||
|
run: bundle exec rake release:github
|
||||||
|
env:
|
||||||
|
DANGER_GITHUB_API_TOKEN: ${{ secrets.danger_github_api_token }}
|
||||||
67
.github/workflows/test-spm.yml
vendored
Normal file
67
.github/workflows/test-spm.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Test SPM
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
name: Test SPM Linux
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: swiftgen/swift:5.6
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
-
|
||||||
|
# Note: we can't use `ruby/setup-ruby` on custom docker images, so we
|
||||||
|
# have to do our own caching
|
||||||
|
name: Cache gems
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: vendor/bundle
|
||||||
|
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gems-
|
||||||
|
-
|
||||||
|
name: Cache SPM
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: .build
|
||||||
|
key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-spm-
|
||||||
|
-
|
||||||
|
name: Bundle install
|
||||||
|
run: |
|
||||||
|
bundle config path vendor/bundle
|
||||||
|
bundle install --jobs 4 --retry 3
|
||||||
|
-
|
||||||
|
name: Run tests
|
||||||
|
run: bundle exec rake spm:test
|
||||||
|
|
||||||
|
macos:
|
||||||
|
name: Test SPM macOS
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
-
|
||||||
|
name: Setup Ruby
|
||||||
|
uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
-
|
||||||
|
name: Cache SPM
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: .build
|
||||||
|
key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-spm-
|
||||||
|
-
|
||||||
|
name: Run tests
|
||||||
|
run: bundle exec rake spm:test
|
||||||
76
.gitignore
vendored
76
.gitignore
vendored
@@ -1,3 +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/
|
||||||
|
.build/
|
||||||
|
.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
|
||||||
@@ -1 +0,0 @@
|
|||||||
3.1
|
|
||||||
116
.swiftlint.yml
Normal file
116
.swiftlint.yml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
swiftlint_version: 0.61.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
|
||||||
|
|
||||||
|
indentation_width:
|
||||||
|
indentation_width: 2
|
||||||
|
|
||||||
|
line_length:
|
||||||
|
warning: 120
|
||||||
|
error: 200
|
||||||
|
|
||||||
|
nesting:
|
||||||
|
type_level:
|
||||||
|
warning: 2
|
||||||
11
.travis.yml
11
.travis.yml
@@ -1,11 +0,0 @@
|
|||||||
os:
|
|
||||||
- osx
|
|
||||||
- linux
|
|
||||||
language: generic
|
|
||||||
sudo: required
|
|
||||||
dist: trusty
|
|
||||||
osx_image: xcode8
|
|
||||||
install:
|
|
||||||
- eval "$(curl -sL https://gist.githubusercontent.com/kylef/5c0475ff02b7c7671d2a/raw/9f442512a46d7a2af7b850d65a7e9bd31edfb09b/swiftenv-install.sh)"
|
|
||||||
script:
|
|
||||||
- swift test
|
|
||||||
322
CHANGELOG.md
322
CHANGELOG.md
@@ -1,4 +1,318 @@
|
|||||||
# Stencil Changelog
|
## 0.15.2
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- Prefer `DynamicMemberLookup` over KVC.
|
||||||
|
[##342](https://github.com/stencilproject/Stencil/pull/342)
|
||||||
|
[@art-divin](https://github.com/art-divin)
|
||||||
|
|
||||||
|
- Allow tokens to be escaped by a backslash, i.e. `\{{ something }}` would render to `{{ something }}`.
|
||||||
|
|
||||||
|
## 0.15.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix bug in `LazyValueWrapper`, causing it to never resolve.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#328](https://github.com/stencilproject/Stencil/pull/328)
|
||||||
|
|
||||||
|
## 0.15.0
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- Drop support for Swift < 5. For Swift 4.2 support, you should use Stencil 0.14.2.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#323](https://github.com/stencilproject/Stencil/pull/323)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- Added support for trimming whitespace around blocks with Jinja2 whitespace control symbols. eg `{%- if value +%}`.
|
||||||
|
[Miguel Bejar](https://github.com/bejar37)
|
||||||
|
[Yonas Kolb](https://github.com/yonaskolb)
|
||||||
|
[#92](https://github.com/stencilproject/Stencil/pull/92)
|
||||||
|
[#287](https://github.com/stencilproject/Stencil/pull/287)
|
||||||
|
- Added support for adding default whitespace trimming behaviour to an environment.
|
||||||
|
[Yonas Kolb](https://github.com/yonaskolb)
|
||||||
|
[#287](https://github.com/stencilproject/Stencil/pull/287)
|
||||||
|
- Blocks now can be used repeatedly in the template. When block is rendered for the first time its content will be cached and it can be rendered again later using `{{ block.block_name }}`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#158](https://github.com/stencilproject/Stencil/issues/158)
|
||||||
|
[#182](https://github.com/stencilproject/Stencil/pull/182)
|
||||||
|
- Added `break` and `continue` tags to break or continue current loop.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#175](https://github.com/stencilproject/Stencil/pull/175)
|
||||||
|
- You can now access outer loop's scope by labeling it: `{% outer: for ... %}... {% for ... %} {{ outer.counter }} {% endfor %}{% endfor %}`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#175](https://github.com/stencilproject/Stencil/pull/175)
|
||||||
|
- Boolean expressions can now be rendered, i.e `{{ name == "John" }}` will render `true` or `false` depending on the evaluation result.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#164](https://github.com/stencilproject/Stencil/pull/164)
|
||||||
|
[#325](https://github.com/stencilproject/Stencil/pull/325)
|
||||||
|
- Enable dynamic member lookup using a new `DynamicMemberLookup` marker protocol. Conform your own types to this protocol to support dynamic member from with contexts.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#219](https://github.com/stencilproject/Stencil/issues/219)
|
||||||
|
[#246](https://github.com/stencilproject/Stencil/pull/246)
|
||||||
|
- Allow providing lazily evaluated context data, using the `LazyValueWrapper` structure.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#324](https://github.com/stencilproject/Stencil/pull/324)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed using `{{ block.super }}` inside nodes other than `block`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#266](https://github.com/stencilproject/Stencil/issues/266)
|
||||||
|
[#267](https://github.com/stencilproject/Stencil/pull/267)
|
||||||
|
|
||||||
|
### Internal Changes
|
||||||
|
|
||||||
|
- Updated internal maintenance scripts, and switched to GitHub actions.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#321](https://github.com/stencilproject/Stencil/pull/321)
|
||||||
|
- Made the `tokens` property on a `Template` public.
|
||||||
|
[Stefanomondino](https://github.com/stefanomondino)
|
||||||
|
[#292](https://github.com/stencilproject/Stencil/pull/292)
|
||||||
|
- Made the `Template.render(_:)` method (that accepts a `Context`) public.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#322](https://github.com/stencilproject/Stencil/pull/322)
|
||||||
|
|
||||||
|
## 0.14.2
|
||||||
|
|
||||||
|
### Internal Changes
|
||||||
|
|
||||||
|
- Update Spectre (0.10) and PathKit to support Xcode 13.
|
||||||
|
[Astromonkee](https://github.com/astromonkee)
|
||||||
|
[#314](https://github.com/stencilproject/Stencil/pull/314)
|
||||||
|
|
||||||
|
## 0.14.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix for crashing range indexes when variable length is 1.
|
||||||
|
[Łukasz Kuczborski](https://github.com/lkuczborski)
|
||||||
|
[#306](https://github.com/stencilproject/Stencil/pull/306)
|
||||||
|
|
||||||
|
|
||||||
|
## 0.14.0
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- Drop support for Swift < 4.2. For Swift 4 support, you should use Stencil 0.13.1.
|
||||||
|
[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
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed a bug in Stencil 0.13 where tags without spaces were incorrectly parsed.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#252](https://github.com/stencilproject/Stencil/pull/252)
|
||||||
|
|
||||||
|
|
||||||
|
## 0.13.0
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
- Now requires Swift 4.1 or newer.
|
||||||
|
[Yonas Kolb](https://github.com/yonaskolb)
|
||||||
|
[#228](https://github.com/stencilproject/Stencil/pull/228)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- You can now use parentheses in boolean expressions to change operator precedence.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#165](https://github.com/stencilproject/Stencil/pull/165)
|
||||||
|
- Added method to add boolean filters with their negative counterparts.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#160](https://github.com/stencilproject/Stencil/pull/160)
|
||||||
|
- Now you can conditionally render variables with `{{ variable if condition }}`, which is a shorthand for `{% if condition %}{{ variable }}{% endif %}`. You can also use `else` like `{{ variable1 if condition else variable2 }}`, which is a shorthand for `{% if condition %}{{ variable1 }}{% else %}{{ variable2 }}{% endif %}`
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#243](https://github.com/stencilproject/Stencil/pull/243)
|
||||||
|
- Now you can access string characters by index or get string length the same was as if it was an array, i.e. `{{ 'string'.first }}`, `{{ 'string'.last }}`, `{{ 'string'.1 }}`, `{{ 'string'.count }}`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#245](https://github.com/stencilproject/Stencil/pull/245)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed the performance issues introduced in Stencil 0.12 with the error log improvements.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#230](https://github.com/stencilproject/Stencil/pull/230)
|
||||||
|
- Now accessing undefined keys in NSObject does not cause runtime crash and instead renders empty string.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#234](https://github.com/stencilproject/Stencil/pull/234)
|
||||||
|
- `for` tag: When iterating over a dictionary the keys will now always be sorted (in an ascending order) to ensure consistent output generation.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#240](https://github.com/stencilproject/Stencil/pull/240)
|
||||||
|
|
||||||
|
### Internal Changes
|
||||||
|
|
||||||
|
- Updated the codebase to use Swift 4 features.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#239](https://github.com/stencilproject/Stencil/pull/239)
|
||||||
|
- Update to Spectre 0.9.0.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#247](https://github.com/stencilproject/Stencil/pull/247)
|
||||||
|
- Optimise Scanner performance.
|
||||||
|
[Eric Thorpe](https://github.com/trametheka)
|
||||||
|
[Sébastien Duperron](https://github.com/Liquidsoul)
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#226](https://github.com/stencilproject/Stencil/pull/226)
|
||||||
|
|
||||||
|
|
||||||
|
## 0.12.1
|
||||||
|
|
||||||
|
### Internal Changes
|
||||||
|
|
||||||
|
- Updated the PathKit dependency to 0.9.0 in CocoaPods, to be in line with SPM.
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#227](https://github.com/stencilproject/Stencil/pull/227)
|
||||||
|
|
||||||
|
|
||||||
|
## 0.12.0
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- Added an optional second parameter to the `include` tag for passing a sub context to the included file.
|
||||||
|
[Yonas Kolb](https://github.com/yonaskolb)
|
||||||
|
[#214](https://github.com/stencilproject/Stencil/pull/214)
|
||||||
|
- Variables now support the subscript notation. For example, if you have a variable `key = "name"`, and an
|
||||||
|
object `item = ["name": "John"]`, then `{{ item[key] }}` will evaluate to "John".
|
||||||
|
[David Jennes](https://github.com/djbe)
|
||||||
|
[#215](https://github.com/stencilproject/Stencil/pull/215)
|
||||||
|
- Adds support for using spaces in filter expression.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#178](https://github.com/stencilproject/Stencil/pull/178)
|
||||||
|
- Improvements in error reporting.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#167](https://github.com/stencilproject/Stencil/pull/167)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed using quote as a filter parameter.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#210](https://github.com/stencilproject/Stencil/pull/210)
|
||||||
|
|
||||||
|
|
||||||
|
## 0.11.0 (2018-04-04)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- Added support for resolving superclass properties for not-NSObject subclasses.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#152](https://github.com/stencilproject/Stencil/pull/152)
|
||||||
|
- The `{% for %}` tag can now iterate over tuples, structures and classes via
|
||||||
|
their stored properties.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#173](https://github.com/stencilproject/Stencil/pull/173)
|
||||||
|
- Added `split` filter.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#187](https://github.com/stencilproject/Stencil/pull/187)
|
||||||
|
- Allow default string filters to be applied to arrays.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#190](https://github.com/stencilproject/Stencil/pull/190)
|
||||||
|
- Similar filters are suggested when unknown filter is used.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#186](https://github.com/stencilproject/Stencil/pull/186)
|
||||||
|
- Added `indent` filter.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#188](https://github.com/stencilproject/Stencil/pull/188)
|
||||||
|
- Allow using new lines inside tags.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#202](https://github.com/stencilproject/Stencil/pull/202)
|
||||||
|
- Added support for iterating arrays of tuples.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#177](https://github.com/stencilproject/Stencil/pull/177)
|
||||||
|
- Added support for ranges in if-in expression.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#193](https://github.com/stencilproject/Stencil/pull/193)
|
||||||
|
- Added property `forloop.length` to get number of items in the loop.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#171](https://github.com/stencilproject/Stencil/pull/171)
|
||||||
|
- Now you can construct ranges for loops using `a...b` syntax, i.e. `for i in 1...array.count`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#192](https://github.com/stencilproject/Stencil/pull/192)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixed rendering `{{ block.super }}` with several levels of inheritance.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#154](https://github.com/stencilproject/Stencil/pull/154)
|
||||||
|
- Fixed checking dictionary values for nil in `default` filter.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#162](https://github.com/stencilproject/Stencil/pull/162)
|
||||||
|
- Fixed comparing string variables with string literals, in Swift 4 string literals became `Substring` and thus couldn't be directly compared to strings.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#168](https://github.com/stencilproject/Stencil/pull/168)
|
||||||
|
- Integer literals now resolve into Int values, not Float.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#181](https://github.com/stencilproject/Stencil/pull/181)
|
||||||
|
- Fixed accessing properties of optional properties via reflection.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#204](https://github.com/stencilproject/Stencil/pull/204)
|
||||||
|
- No longer render optional values in arrays as `Optional(..)`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#205](https://github.com/stencilproject/Stencil/pull/205)
|
||||||
|
- Fixed subscription tuples by value index, i.e. `{{ tuple.0 }}`.
|
||||||
|
[Ilya Puchka](https://github.com/ilyapuchka)
|
||||||
|
[#172](https://github.com/stencilproject/Stencil/pull/172)
|
||||||
|
|
||||||
|
|
||||||
|
## 0.10.1
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- Add support for Xcode 9.1.
|
||||||
|
|
||||||
|
## 0.10.0
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- Adds `counter0` to for loop context allowing you to get the current index of
|
||||||
|
the for loop 0 indexed.
|
||||||
|
- Introduces a new `DictionaryLoader` for loading templates from a Swift
|
||||||
|
Dictionary.
|
||||||
|
- Added `in` expression in if tag for strings and arrays of hashable types
|
||||||
|
- You can now access the amount of items in a dictionary using the `count`
|
||||||
|
property.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixes a potential crash when using the `{% for %}` template tag with the
|
||||||
|
incorrect amount of arguments.
|
||||||
|
- Fixes a potential crash when using incomplete tokens in a template for
|
||||||
|
example, `{%%}` or `{{}}`.
|
||||||
|
- Fixes evaluating nil properties as true
|
||||||
|
|
||||||
|
|
||||||
## 0.9.0
|
## 0.9.0
|
||||||
|
|
||||||
@@ -28,7 +342,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
|
||||||
@@ -172,10 +486,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 'base64', '~> 0.3'
|
||||||
|
gem 'rake', '~> 13.0'
|
||||||
|
gem 'xcpretty', '~> 0.3'
|
||||||
|
end
|
||||||
|
|
||||||
|
# In addition to :build, for contributing
|
||||||
|
group :development do
|
||||||
|
gem 'danger', '~> 8.4'
|
||||||
|
gem 'rubocop', '~> 1.22'
|
||||||
|
end
|
||||||
|
|
||||||
|
# For releasing to GitHub
|
||||||
|
group :release do
|
||||||
|
gem 'octokit', '~> 4.7'
|
||||||
|
end
|
||||||
132
Gemfile.lock
Normal file
132
Gemfile.lock
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
addressable (2.8.7)
|
||||||
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
|
ast (2.4.3)
|
||||||
|
base64 (0.3.0)
|
||||||
|
claide (1.1.0)
|
||||||
|
claide-plugins (0.9.2)
|
||||||
|
cork
|
||||||
|
nap
|
||||||
|
open4 (~> 1.3)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
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)
|
||||||
|
faraday (1.10.4)
|
||||||
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.1)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-http-cache (2.5.1)
|
||||||
|
faraday (>= 0.8)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.1.1)
|
||||||
|
multipart-post (~> 2.0)
|
||||||
|
faraday-net_http (1.0.2)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
|
git (1.19.1)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
rchardet (~> 1.8)
|
||||||
|
json (2.15.0)
|
||||||
|
kramdown (2.5.1)
|
||||||
|
rexml (>= 3.3.9)
|
||||||
|
kramdown-parser-gfm (1.1.0)
|
||||||
|
kramdown (~> 2.0)
|
||||||
|
language_server-protocol (3.17.0.5)
|
||||||
|
lint_roller (1.1.0)
|
||||||
|
multipart-post (2.4.1)
|
||||||
|
nap (1.1.0)
|
||||||
|
no_proxy_fix (0.1.2)
|
||||||
|
octokit (4.25.1)
|
||||||
|
faraday (>= 1, < 3)
|
||||||
|
sawyer (~> 0.9)
|
||||||
|
open4 (1.3.4)
|
||||||
|
parallel (1.27.0)
|
||||||
|
parser (3.3.9.0)
|
||||||
|
ast (~> 2.4.1)
|
||||||
|
racc
|
||||||
|
prism (1.5.1)
|
||||||
|
public_suffix (4.0.7)
|
||||||
|
racc (1.8.1)
|
||||||
|
rainbow (3.1.1)
|
||||||
|
rake (13.3.0)
|
||||||
|
rchardet (1.10.0)
|
||||||
|
regexp_parser (2.11.3)
|
||||||
|
rexml (3.4.4)
|
||||||
|
rouge (3.28.0)
|
||||||
|
rubocop (1.81.1)
|
||||||
|
json (~> 2.3)
|
||||||
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
lint_roller (~> 1.1.0)
|
||||||
|
parallel (~> 1.10)
|
||||||
|
parser (>= 3.3.0.2)
|
||||||
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
|
ruby-progressbar (~> 1.7)
|
||||||
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
|
rubocop-ast (1.47.1)
|
||||||
|
parser (>= 3.3.7.2)
|
||||||
|
prism (~> 1.4)
|
||||||
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
sawyer (0.9.2)
|
||||||
|
addressable (>= 2.3.5)
|
||||||
|
faraday (>= 0.17.3, < 3)
|
||||||
|
terminal-table (1.6.0)
|
||||||
|
unicode-display_width (3.2.0)
|
||||||
|
unicode-emoji (~> 4.1)
|
||||||
|
unicode-emoji (4.1.0)
|
||||||
|
xcpretty (0.4.1)
|
||||||
|
rouge (~> 3.28.0)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
aarch64-linux-gnu
|
||||||
|
aarch64-linux-musl
|
||||||
|
arm-linux-gnu
|
||||||
|
arm-linux-musl
|
||||||
|
arm64-darwin
|
||||||
|
ruby
|
||||||
|
x86-linux-gnu
|
||||||
|
x86-linux-musl
|
||||||
|
x86_64-darwin
|
||||||
|
x86_64-linux-gnu
|
||||||
|
x86_64-linux-musl
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
base64 (~> 0.3)
|
||||||
|
danger (~> 8.4)
|
||||||
|
octokit (~> 4.7)
|
||||||
|
rake (~> 13.0)
|
||||||
|
rubocop (~> 1.22)
|
||||||
|
xcpretty (~> 0.3)
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.7.2
|
||||||
4
LICENSE
4
LICENSE
@@ -1,4 +1,5 @@
|
|||||||
Copyright (c) 2014, Kyle Fuller
|
Copyright (c) 2022, Kyle Fuller as the original author
|
||||||
|
Copyright (c) 2025, Astzweig GmbH & Co. KG
|
||||||
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
|
||||||
@@ -21,4 +22,3 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
|||||||
25
Package.resolved
Normal file
25
Package.resolved
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"object": {
|
||||||
|
"pins": [
|
||||||
|
{
|
||||||
|
"package": "Spectre",
|
||||||
|
"repositoryURL": "https://github.com/kylef/Spectre.git",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
|
||||||
|
"version": "0.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"package": "swiftpm-pathkit",
|
||||||
|
"repositoryURL": "https://github.com/astzweig/swiftpm-pathkit.git",
|
||||||
|
"state": {
|
||||||
|
"branch": null,
|
||||||
|
"revision": "1280d78aa2c1532800d7e820607f123236dc5f54",
|
||||||
|
"version": "1.5.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
|
// swift-tools-version:5.0
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Stencil",
|
name: "Stencil",
|
||||||
|
products: [
|
||||||
|
.library(name: "Stencil", targets: ["Stencil"])
|
||||||
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.Package(url: "https://github.com/kylef/PathKit.git", majorVersion: 0, minor: 8),
|
.package(url: "https://github.com/astzweig/swiftpm-pathkit.git", from: "1.5.1"),
|
||||||
|
.package(url: "https://github.com/kylef/Spectre.git", from: "0.10.1")
|
||||||
// https://github.com/apple/swift-package-manager/pull/597
|
],
|
||||||
.Package(url: "https://github.com/kylef/Spectre.git", majorVersion: 0, minor: 7),
|
targets: [
|
||||||
]
|
.target(name: "Stencil", dependencies: [
|
||||||
|
"PathKit"
|
||||||
|
]),
|
||||||
|
.testTarget(name: "StencilTests", dependencies: [
|
||||||
|
"Stencil",
|
||||||
|
"Spectre"
|
||||||
|
])
|
||||||
|
],
|
||||||
|
swiftLanguageVersions: [.v5]
|
||||||
)
|
)
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,7 +1,5 @@
|
|||||||
# Stencil
|
# Stencil
|
||||||
|
|
||||||
[](https://travis-ci.org/kylef/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.
|
||||||
@@ -34,7 +32,7 @@ let context = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
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: "article_list.html", context: context)
|
||||||
|
|
||||||
print(rendered)
|
print(rendered)
|
||||||
```
|
```
|
||||||
@@ -63,6 +61,14 @@ Resources to help you integrate Stencil into a Swift project:
|
|||||||
- [API Reference](http://stencil.fuller.li/en/latest/api.html)
|
- [API Reference](http://stencil.fuller.li/en/latest/api.html)
|
||||||
- [Custom Template Tags and Filters](http://stencil.fuller.li/en/latest/custom-template-tags-and-filters.html)
|
- [Custom Template Tags and Filters](http://stencil.fuller.li/en/latest/custom-template-tags-and-filters.html)
|
||||||
|
|
||||||
|
## Projects that use Stencil
|
||||||
|
|
||||||
|
[Sourcery](https://github.com/krzysztofzablocki/Sourcery),
|
||||||
|
[SwiftGen](https://github.com/SwiftGen/SwiftGen),
|
||||||
|
[Kitura](https://github.com/IBM-Swift/Kitura),
|
||||||
|
[Weaver](https://github.com/scribd/Weaver),
|
||||||
|
[Genesis](https://github.com/yonaskolb/Genesis)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more
|
Stencil is licensed under the BSD license. See [LICENSE](LICENSE) for more
|
||||||
|
|||||||
50
Rakefile
Executable file
50
Rakefile
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/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/swiftstencil/swiftpm-stencil.git", from: "#{version}")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def replace(file, replacements)
|
||||||
|
content = File.read(file)
|
||||||
|
replacements.each do |match, replacement|
|
||||||
|
content.gsub!(match, replacement)
|
||||||
|
end
|
||||||
|
File.write(file, content)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
task :default => 'release:new'
|
||||||
@@ -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,38 +0,0 @@
|
|||||||
public struct Environment {
|
|
||||||
public let templateClass: Template.Type
|
|
||||||
public let 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 template.render(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func renderTemplate(string: String, context: [String: Any]? = nil) throws -> String {
|
|
||||||
let template = templateClass.init(templateString: string, environment: self)
|
|
||||||
return try template.render(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
public class TemplateDoesNotExist: Error, CustomStringConvertible {
|
|
||||||
let templateNames: [String]
|
|
||||||
let loader: Loader?
|
|
||||||
|
|
||||||
public init(templateNames: [String], loader: Loader? = nil) {
|
|
||||||
self.templateNames = templateNames
|
|
||||||
self.loader = loader
|
|
||||||
}
|
|
||||||
|
|
||||||
public var description: String {
|
|
||||||
let templates = templateNames.joined(separator: ", ")
|
|
||||||
|
|
||||||
if let loader = loader {
|
|
||||||
return "Template named `\(templates)` does not exist in loader \(loader)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Template named `\(templates)` does not exist. No loaders found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
protocol Expression: CustomStringConvertible {
|
|
||||||
func evaluate(context: Context) throws -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
protocol InfixOperator: Expression {
|
|
||||||
init(lhs: Expression, rhs: Expression)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
protocol PrefixOperator: Expression {
|
|
||||||
init(expression: Expression)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
final class StaticExpression: Expression, CustomStringConvertible {
|
|
||||||
let value: Bool
|
|
||||||
|
|
||||||
init(value: Bool) {
|
|
||||||
self.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "\(value)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
final class VariableExpression: Expression, CustomStringConvertible {
|
|
||||||
let variable: Resolvable
|
|
||||||
|
|
||||||
init(variable: Resolvable) {
|
|
||||||
self.variable = variable
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "(variable: \(variable))"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves a variable in the given context as boolean
|
|
||||||
func resolve(context: Context, variable: Resolvable) throws -> Bool {
|
|
||||||
let result = try variable.resolve(context)
|
|
||||||
var truthy = false
|
|
||||||
|
|
||||||
if let result = result as? [Any] {
|
|
||||||
truthy = !result.isEmpty
|
|
||||||
} else if let result = result as? [String:Any] {
|
|
||||||
truthy = !result.isEmpty
|
|
||||||
} else if let result = result as? Bool {
|
|
||||||
truthy = result
|
|
||||||
} else if let result = result as? String {
|
|
||||||
truthy = !result.isEmpty
|
|
||||||
} else if let value = result, let result = toNumber(value: value) {
|
|
||||||
truthy = result > 0
|
|
||||||
} else if result != nil {
|
|
||||||
truthy = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return truthy
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
return try resolve(context: context, variable: variable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
|
|
||||||
let expression: Expression
|
|
||||||
|
|
||||||
init(expression: Expression) {
|
|
||||||
self.expression = expression
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "not \(expression)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
return try !expression.evaluate(context: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
|
||||||
let lhs: Expression
|
|
||||||
let rhs: Expression
|
|
||||||
|
|
||||||
init(lhs: Expression, rhs: Expression) {
|
|
||||||
self.lhs = lhs
|
|
||||||
self.rhs = rhs
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "(\(lhs) or \(rhs))"
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
let lhs = try self.lhs.evaluate(context: context)
|
|
||||||
if lhs {
|
|
||||||
return lhs
|
|
||||||
}
|
|
||||||
|
|
||||||
return try rhs.evaluate(context: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
|
||||||
let lhs: Expression
|
|
||||||
let rhs: Expression
|
|
||||||
|
|
||||||
init(lhs: Expression, rhs: Expression) {
|
|
||||||
self.lhs = lhs
|
|
||||||
self.rhs = rhs
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "(\(lhs) and \(rhs))"
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
let lhs = try self.lhs.evaluate(context: context)
|
|
||||||
if !lhs {
|
|
||||||
return lhs
|
|
||||||
}
|
|
||||||
|
|
||||||
return try rhs.evaluate(context: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
|
||||||
let lhs: Expression
|
|
||||||
let rhs: Expression
|
|
||||||
|
|
||||||
required init(lhs: Expression, rhs: Expression) {
|
|
||||||
self.lhs = lhs
|
|
||||||
self.rhs = rhs
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "(\(lhs) == \(rhs))"
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
|
||||||
let lhsValue = try lhs.variable.resolve(context)
|
|
||||||
let rhsValue = try rhs.variable.resolve(context)
|
|
||||||
|
|
||||||
if let lhs = lhsValue, let rhs = rhsValue {
|
|
||||||
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
|
||||||
return lhs == rhs
|
|
||||||
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
|
|
||||||
return lhs == rhs
|
|
||||||
} else if let lhs = lhsValue as? Bool, let rhs = rhsValue as? Bool {
|
|
||||||
return lhs == rhs
|
|
||||||
}
|
|
||||||
} else if lhsValue == nil && rhsValue == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
|
||||||
let lhs: Expression
|
|
||||||
let rhs: Expression
|
|
||||||
|
|
||||||
required init(lhs: Expression, rhs: Expression) {
|
|
||||||
self.lhs = lhs
|
|
||||||
self.rhs = rhs
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "(\(lhs) \(op) \(rhs))"
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluate(context: Context) throws -> Bool {
|
|
||||||
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
|
||||||
let lhsValue = try lhs.variable.resolve(context)
|
|
||||||
let rhsValue = try rhs.variable.resolve(context)
|
|
||||||
|
|
||||||
if let lhs = lhsValue, let rhs = rhsValue {
|
|
||||||
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
|
||||||
return compare(lhs: lhs, rhs: rhs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var op: String {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func compare(lhs: Number, rhs: Number) -> Bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MoreThanExpression: NumericExpression {
|
|
||||||
override var op: String {
|
|
||||||
return ">"
|
|
||||||
}
|
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
|
||||||
return lhs > rhs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MoreThanEqualExpression: NumericExpression {
|
|
||||||
override var op: String {
|
|
||||||
return ">="
|
|
||||||
}
|
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
|
||||||
return lhs >= rhs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LessThanExpression: NumericExpression {
|
|
||||||
override var op: String {
|
|
||||||
return "<"
|
|
||||||
}
|
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
|
||||||
return lhs < rhs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LessThanEqualExpression: NumericExpression {
|
|
||||||
override var op: String {
|
|
||||||
return "<="
|
|
||||||
}
|
|
||||||
|
|
||||||
override func compare(lhs: Number, rhs: Number) -> Bool {
|
|
||||||
return lhs <= rhs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class InequalityExpression: EqualityExpression {
|
|
||||||
override var description: String {
|
|
||||||
return "(\(lhs) != \(rhs))"
|
|
||||||
}
|
|
||||||
|
|
||||||
override func evaluate(context: Context) throws -> Bool {
|
|
||||||
return try !super.evaluate(context: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func toNumber(value: Any) -> Number? {
|
|
||||||
if let value = value as? Float {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Double {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? UInt {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Int {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Int8 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Int16 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Int32 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Int64 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? UInt8 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? UInt16 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? UInt32 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? UInt64 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Number {
|
|
||||||
return value
|
|
||||||
} else if let value = value as? Float64 {
|
|
||||||
return Number(value)
|
|
||||||
} else if let value = value as? Float32 {
|
|
||||||
return Number(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
open class Extension {
|
|
||||||
typealias TagParser = (TokenParser, Token) throws -> NodeType
|
|
||||||
var tags = [String: TagParser]()
|
|
||||||
|
|
||||||
var filters = [String: Filter]()
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a new template tag
|
|
||||||
public func registerTag(_ name: String, parser: @escaping (TokenParser, Token) throws -> NodeType) {
|
|
||||||
tags[name] = parser
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a simple template tag with a name and a handler
|
|
||||||
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
|
|
||||||
registerTag(name, parser: { parser, token in
|
|
||||||
return SimpleNode(handler: handler)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a template filter with the given name
|
|
||||||
public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
|
|
||||||
filters[name] = .simple(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a template filter with the given name
|
|
||||||
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
|
|
||||||
filters[name] = .arguments(filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultExtension: Extension {
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
registerDefaultTags()
|
|
||||||
registerDefaultFilters()
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func registerDefaultTags() {
|
|
||||||
registerTag("for", parser: ForNode.parse)
|
|
||||||
registerTag("if", parser: IfNode.parse)
|
|
||||||
registerTag("ifnot", parser: IfNode.parse_ifnot)
|
|
||||||
#if !os(Linux)
|
|
||||||
registerTag("now", parser: NowNode.parse)
|
|
||||||
#endif
|
|
||||||
registerTag("include", parser: IncludeNode.parse)
|
|
||||||
registerTag("extends", parser: ExtendsNode.parse)
|
|
||||||
registerTag("block", parser: BlockNode.parse)
|
|
||||||
registerTag("filter", parser: FilterNode.parse)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func registerDefaultFilters() {
|
|
||||||
registerFilter("default", filter: defaultFilter)
|
|
||||||
registerFilter("capitalize", filter: capitalise)
|
|
||||||
registerFilter("uppercase", filter: uppercase)
|
|
||||||
registerFilter("lowercase", filter: lowercase)
|
|
||||||
registerFilter("join", filter: joinFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
protocol FilterType {
|
|
||||||
func invoke(value: Any?, arguments: [Any?]) throws -> Any?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Filter: FilterType {
|
|
||||||
case simple(((Any?) throws -> Any?))
|
|
||||||
case arguments(((Any?, [Any?]) throws -> Any?))
|
|
||||||
|
|
||||||
func invoke(value: Any?, arguments: [Any?]) throws -> Any? {
|
|
||||||
switch self {
|
|
||||||
case let .simple(filter):
|
|
||||||
if !arguments.isEmpty {
|
|
||||||
throw TemplateSyntaxError("cannot invoke filter with an argument")
|
|
||||||
}
|
|
||||||
|
|
||||||
return try filter(value)
|
|
||||||
case let .arguments(filter):
|
|
||||||
return try filter(value, arguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
class FilterNode : NodeType {
|
|
||||||
let resolvable: Resolvable
|
|
||||||
let nodes: [NodeType]
|
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
|
||||||
let bits = token.components()
|
|
||||||
|
|
||||||
guard bits.count == 2 else {
|
|
||||||
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
|
|
||||||
}
|
|
||||||
|
|
||||||
let blocks = try parser.parse(until(["endfilter"]))
|
|
||||||
|
|
||||||
guard parser.nextToken() != nil else {
|
|
||||||
throw TemplateSyntaxError("`endfilter` was not found.")
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvable = try parser.compileFilter("filter_value|\(bits[1])")
|
|
||||||
return FilterNode(nodes: blocks, resolvable: resolvable)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(nodes: [NodeType], resolvable: Resolvable) {
|
|
||||||
self.nodes = nodes
|
|
||||||
self.resolvable = resolvable
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
let value = try renderNodes(nodes, context)
|
|
||||||
|
|
||||||
return try context.push(dictionary: ["filter_value": value]) {
|
|
||||||
return try VariableNode(variable: resolvable).render(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
func capitalise(_ value: Any?) -> Any? {
|
|
||||||
return stringify(value).capitalized
|
|
||||||
}
|
|
||||||
|
|
||||||
func uppercase(_ value: Any?) -> Any? {
|
|
||||||
return stringify(value).uppercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
func lowercase(_ value: Any?) -> Any? {
|
|
||||||
return stringify(value).lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
|
|
||||||
if let value = value {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
for argument in arguments {
|
|
||||||
if let argument = argument {
|
|
||||||
return argument
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
|
||||||
guard arguments.count < 2 else {
|
|
||||||
throw TemplateSyntaxError("'join' filter takes a single argument")
|
|
||||||
}
|
|
||||||
|
|
||||||
let separator = stringify(arguments.first ?? "")
|
|
||||||
|
|
||||||
if let value = value as? [Any] {
|
|
||||||
return value
|
|
||||||
.map(stringify)
|
|
||||||
.joined(separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
class ForNode : NodeType {
|
|
||||||
let resolvable: Resolvable
|
|
||||||
let loopVariables: [String]
|
|
||||||
let nodes:[NodeType]
|
|
||||||
let emptyNodes: [NodeType]
|
|
||||||
let `where`: Expression?
|
|
||||||
|
|
||||||
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
|
||||||
let components = token.components()
|
|
||||||
|
|
||||||
guard components.count >= 2 && components[2] == "in" &&
|
|
||||||
(components.count == 4 || (components.count >= 6 && components[4] == "where")) else {
|
|
||||||
throw TemplateSyntaxError("'for' statements should use the following 'for x in y where condition' `\(token.contents)`.")
|
|
||||||
}
|
|
||||||
|
|
||||||
let loopVariables = components[1].characters
|
|
||||||
.split(separator: ",")
|
|
||||||
.map(String.init)
|
|
||||||
.map { $0.trimmingCharacters(in: CharacterSet.whitespaces) }
|
|
||||||
|
|
||||||
let variable = components[3]
|
|
||||||
|
|
||||||
var emptyNodes = [NodeType]()
|
|
||||||
|
|
||||||
let forNodes = try parser.parse(until(["endfor", "empty"]))
|
|
||||||
|
|
||||||
guard let token = parser.nextToken() else {
|
|
||||||
throw TemplateSyntaxError("`endfor` was not found.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if token.contents == "empty" {
|
|
||||||
emptyNodes = try parser.parse(until(["endfor"]))
|
|
||||||
_ = parser.nextToken()
|
|
||||||
}
|
|
||||||
|
|
||||||
let filter = try parser.compileFilter(variable)
|
|
||||||
let `where`: Expression?
|
|
||||||
if components.count >= 6 {
|
|
||||||
`where` = try parseExpression(components: Array(components.suffix(from: 5)), tokenParser: parser)
|
|
||||||
} else {
|
|
||||||
`where` = nil
|
|
||||||
}
|
|
||||||
return ForNode(resolvable: filter, loopVariables: loopVariables, nodes: forNodes, emptyNodes:emptyNodes, where: `where`)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(resolvable: Resolvable, loopVariables: [String], nodes:[NodeType], emptyNodes:[NodeType], where: Expression? = nil) {
|
|
||||||
self.resolvable = resolvable
|
|
||||||
self.loopVariables = loopVariables
|
|
||||||
self.nodes = nodes
|
|
||||||
self.emptyNodes = emptyNodes
|
|
||||||
self.where = `where`
|
|
||||||
}
|
|
||||||
|
|
||||||
func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) rethrows -> Result {
|
|
||||||
if loopVariables.isEmpty {
|
|
||||||
return try context.push() {
|
|
||||||
return try closure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let value = value as? (Any, Any) {
|
|
||||||
let first = loopVariables[0]
|
|
||||||
|
|
||||||
if loopVariables.count == 2 {
|
|
||||||
let second = loopVariables[1]
|
|
||||||
|
|
||||||
return try context.push(dictionary: [first: value.0, second: value.1]) {
|
|
||||||
return try closure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return try context.push(dictionary: [first: value.0]) {
|
|
||||||
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.map { ($0.key, $0.value) }
|
|
||||||
} else if let array = resolved as? [Any] {
|
|
||||||
values = array
|
|
||||||
} 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,
|
|
||||||
]
|
|
||||||
|
|
||||||
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,264 +0,0 @@
|
|||||||
enum Operator {
|
|
||||||
case infix(String, Int, InfixOperator.Type)
|
|
||||||
case prefix(String, Int, PrefixOperator.Type)
|
|
||||||
|
|
||||||
var name: String {
|
|
||||||
switch self {
|
|
||||||
case .infix(let name, _, _):
|
|
||||||
return name
|
|
||||||
case .prefix(let name, _, _):
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let operators: [Operator] = [
|
|
||||||
.infix("or", 6, OrExpression.self),
|
|
||||||
.infix("and", 7, AndExpression.self),
|
|
||||||
.prefix("not", 8, NotExpression.self),
|
|
||||||
.infix("==", 10, EqualityExpression.self),
|
|
||||||
.infix("!=", 10, InequalityExpression.self),
|
|
||||||
.infix(">", 10, MoreThanExpression.self),
|
|
||||||
.infix(">=", 10, MoreThanEqualExpression.self),
|
|
||||||
.infix("<", 10, LessThanExpression.self),
|
|
||||||
.infix("<=", 10, LessThanEqualExpression.self),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
func findOperator(name: String) -> Operator? {
|
|
||||||
for op in operators {
|
|
||||||
if op.name == name {
|
|
||||||
return op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
enum IfToken {
|
|
||||||
case infix(name: String, bindingPower: Int, op: InfixOperator.Type)
|
|
||||||
case prefix(name: String, bindingPower: Int, op: PrefixOperator.Type)
|
|
||||||
case variable(Resolvable)
|
|
||||||
case end
|
|
||||||
|
|
||||||
var bindingPower: Int {
|
|
||||||
switch self {
|
|
||||||
case .infix(_, let bindingPower, _):
|
|
||||||
return bindingPower
|
|
||||||
case .prefix(_, let bindingPower, _):
|
|
||||||
return bindingPower
|
|
||||||
case .variable(_):
|
|
||||||
return 0
|
|
||||||
case .end:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullDenotation(parser: IfExpressionParser) throws -> Expression {
|
|
||||||
switch self {
|
|
||||||
case .infix(let name, _, _):
|
|
||||||
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
|
|
||||||
case .prefix(_, let bindingPower, let op):
|
|
||||||
let expression = try parser.expression(bindingPower: bindingPower)
|
|
||||||
return op.init(expression: expression)
|
|
||||||
case .variable(let variable):
|
|
||||||
return VariableExpression(variable: variable)
|
|
||||||
case .end:
|
|
||||||
throw TemplateSyntaxError("'if' expression error: end")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
|
|
||||||
switch self {
|
|
||||||
case .infix(_, let bindingPower, let op):
|
|
||||||
let right = try parser.expression(bindingPower: bindingPower)
|
|
||||||
return op.init(lhs: left, rhs: right)
|
|
||||||
case .prefix(let name, _, _):
|
|
||||||
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
|
|
||||||
case .variable(let variable):
|
|
||||||
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
|
|
||||||
case .end:
|
|
||||||
throw TemplateSyntaxError("'if' expression error: end")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isEnd: Bool {
|
|
||||||
switch self {
|
|
||||||
case .end:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
final class IfExpressionParser {
|
|
||||||
let tokens: [IfToken]
|
|
||||||
var position: Int = 0
|
|
||||||
|
|
||||||
init(components: [String], tokenParser: TokenParser) throws {
|
|
||||||
self.tokens = try components.map { component in
|
|
||||||
if let op = findOperator(name: component) {
|
|
||||||
switch op {
|
|
||||||
case .infix(let name, let bindingPower, let cls):
|
|
||||||
return .infix(name: name, bindingPower: bindingPower, op: cls)
|
|
||||||
case .prefix(let name, let bindingPower, let cls):
|
|
||||||
return .prefix(name: name, bindingPower: bindingPower, op: cls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return .variable(try tokenParser.compileFilter(component))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentToken: IfToken {
|
|
||||||
if tokens.count > position {
|
|
||||||
return tokens[position]
|
|
||||||
}
|
|
||||||
|
|
||||||
return .end
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextToken: IfToken {
|
|
||||||
position += 1
|
|
||||||
return currentToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func parse() throws -> Expression {
|
|
||||||
let expression = try self.expression()
|
|
||||||
|
|
||||||
if !currentToken.isEnd {
|
|
||||||
throw TemplateSyntaxError("'if' expression error: dangling token")
|
|
||||||
}
|
|
||||||
|
|
||||||
return expression
|
|
||||||
}
|
|
||||||
|
|
||||||
func expression(bindingPower: Int = 0) throws -> Expression {
|
|
||||||
var token = currentToken
|
|
||||||
position += 1
|
|
||||||
|
|
||||||
var left = try token.nullDenotation(parser: self)
|
|
||||||
|
|
||||||
while bindingPower < currentToken.bindingPower {
|
|
||||||
token = currentToken
|
|
||||||
position += 1
|
|
||||||
left = try token.leftDenotation(left: left, parser: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func parseExpression(components: [String], tokenParser: TokenParser) throws -> Expression {
|
|
||||||
let parser = try IfExpressionParser(components: components, tokenParser: tokenParser)
|
|
||||||
return try parser.parse()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Represents an if condition and the associated nodes when the condition
|
|
||||||
/// evaluates
|
|
||||||
final class IfCondition {
|
|
||||||
let expression: Expression?
|
|
||||||
let nodes: [NodeType]
|
|
||||||
|
|
||||||
init(expression: Expression?, nodes: [NodeType]) {
|
|
||||||
self.expression = expression
|
|
||||||
self.nodes = nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
return try context.push {
|
|
||||||
return try renderNodes(nodes, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IfNode : NodeType {
|
|
||||||
let conditions: [IfCondition]
|
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
|
||||||
var components = token.components()
|
|
||||||
components.removeFirst()
|
|
||||||
|
|
||||||
let expression = try parseExpression(components: components, tokenParser: parser)
|
|
||||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
|
||||||
var conditions: [IfCondition] = [
|
|
||||||
IfCondition(expression: expression, nodes: nodes)
|
|
||||||
]
|
|
||||||
|
|
||||||
var token = parser.nextToken()
|
|
||||||
while let current = token, current.contents.hasPrefix("elif") {
|
|
||||||
var components = current.components()
|
|
||||||
components.removeFirst()
|
|
||||||
let expression = try parseExpression(components: components, tokenParser: parser)
|
|
||||||
|
|
||||||
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
|
||||||
token = parser.nextToken()
|
|
||||||
conditions.append(IfCondition(expression: expression, nodes: nodes))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let current = token, current.contents == "else" {
|
|
||||||
conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"]))))
|
|
||||||
token = parser.nextToken()
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let current = token, current.contents == "endif" else {
|
|
||||||
throw TemplateSyntaxError("`endif` was not found.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return IfNode(conditions: conditions)
|
|
||||||
}
|
|
||||||
|
|
||||||
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
|
|
||||||
var components = token.components()
|
|
||||||
guard components.count == 2 else {
|
|
||||||
throw TemplateSyntaxError("'ifnot' statements should use the following 'ifnot condition' `\(token.contents)`.")
|
|
||||||
}
|
|
||||||
components.removeFirst()
|
|
||||||
var trueNodes = [NodeType]()
|
|
||||||
var falseNodes = [NodeType]()
|
|
||||||
|
|
||||||
falseNodes = try parser.parse(until(["endif", "else"]))
|
|
||||||
|
|
||||||
guard let token = parser.nextToken() else {
|
|
||||||
throw TemplateSyntaxError("`endif` was not found.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if token.contents == "else" {
|
|
||||||
trueNodes = try parser.parse(until(["endif"]))
|
|
||||||
_ = parser.nextToken()
|
|
||||||
}
|
|
||||||
|
|
||||||
let expression = try parseExpression(components: components, tokenParser: parser)
|
|
||||||
return IfNode(conditions: [
|
|
||||||
IfCondition(expression: expression, nodes: trueNodes),
|
|
||||||
IfCondition(expression: nil, nodes: falseNodes),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
init(conditions: [IfCondition]) {
|
|
||||||
self.conditions = conditions
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
for condition in conditions {
|
|
||||||
if let expression = condition.expression {
|
|
||||||
let truthy = try expression.evaluate(context: context)
|
|
||||||
|
|
||||||
if truthy {
|
|
||||||
return try condition.render(context)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return try condition.render(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import PathKit
|
|
||||||
|
|
||||||
|
|
||||||
class IncludeNode : NodeType {
|
|
||||||
let templateName: Variable
|
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
|
||||||
let bits = token.components()
|
|
||||||
|
|
||||||
guard bits.count == 2 else {
|
|
||||||
throw TemplateSyntaxError("'include' tag takes one argument, the template file to be included")
|
|
||||||
}
|
|
||||||
|
|
||||||
return IncludeNode(templateName: Variable(bits[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
init(templateName: Variable) {
|
|
||||||
self.templateName = templateName
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
guard let templateName = try self.templateName.resolve(context) as? String else {
|
|
||||||
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
|
|
||||||
}
|
|
||||||
|
|
||||||
let template = try context.environment.loadTemplate(name: templateName)
|
|
||||||
|
|
||||||
return try context.push {
|
|
||||||
return try template.render(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
class BlockContext {
|
|
||||||
class var contextKey: String { return "block_context" }
|
|
||||||
|
|
||||||
var blocks: [String: BlockNode]
|
|
||||||
|
|
||||||
init(blocks: [String: BlockNode]) {
|
|
||||||
self.blocks = blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
func pop(_ blockName: String) -> BlockNode? {
|
|
||||||
return blocks.removeValue(forKey: blockName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension Collection {
|
|
||||||
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
|
|
||||||
for element in self {
|
|
||||||
if closure(element) {
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ExtendsNode : NodeType {
|
|
||||||
let templateName: Variable
|
|
||||||
let blocks: [String:BlockNode]
|
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
|
||||||
let bits = token.components()
|
|
||||||
|
|
||||||
guard bits.count == 2 else {
|
|
||||||
throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedNodes = try parser.parse()
|
|
||||||
guard (parsedNodes.any { $0 is ExtendsNode }) == nil else {
|
|
||||||
throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
|
|
||||||
}
|
|
||||||
|
|
||||||
let blockNodes = parsedNodes.flatMap { $0 as? BlockNode }
|
|
||||||
|
|
||||||
let nodes = blockNodes.reduce([String: BlockNode]()) { (accumulator, node) -> [String: BlockNode] in
|
|
||||||
var dict = accumulator
|
|
||||||
dict[node.name] = node
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(templateName: Variable, blocks: [String: BlockNode]) {
|
|
||||||
self.templateName = templateName
|
|
||||||
self.blocks = blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
guard let templateName = try self.templateName.resolve(context) as? String else {
|
|
||||||
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
|
|
||||||
}
|
|
||||||
|
|
||||||
let template = try context.environment.loadTemplate(name: templateName)
|
|
||||||
|
|
||||||
let blockContext: BlockContext
|
|
||||||
if let context = context[BlockContext.contextKey] as? BlockContext {
|
|
||||||
blockContext = context
|
|
||||||
|
|
||||||
for (key, value) in blocks {
|
|
||||||
if !blockContext.blocks.keys.contains(key) {
|
|
||||||
blockContext.blocks[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
blockContext = BlockContext(blocks: blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
|
|
||||||
return try template.render(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BlockNode : NodeType {
|
|
||||||
let name: String
|
|
||||||
let nodes: [NodeType]
|
|
||||||
|
|
||||||
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
|
||||||
let bits = token.components()
|
|
||||||
|
|
||||||
guard bits.count == 2 else {
|
|
||||||
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
|
|
||||||
}
|
|
||||||
|
|
||||||
let blockName = bits[1]
|
|
||||||
let nodes = try parser.parse(until(["endblock"]))
|
|
||||||
_ = parser.nextToken()
|
|
||||||
return BlockNode(name:blockName, nodes:nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(name: String, nodes: [NodeType]) {
|
|
||||||
self.name = name
|
|
||||||
self.nodes = nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let node = blockContext.pop(name) {
|
|
||||||
return try context.push(dictionary: ["block": ["super": self]]) {
|
|
||||||
return try node.render(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return try renderNodes(nodes, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
struct Lexer {
|
|
||||||
let templateString: String
|
|
||||||
|
|
||||||
init(templateString: String) {
|
|
||||||
self.templateString = templateString
|
|
||||||
}
|
|
||||||
|
|
||||||
func createToken(string:String) -> Token {
|
|
||||||
func strip() -> String {
|
|
||||||
let start = string.index(string.startIndex, offsetBy: 2)
|
|
||||||
let end = string.index(string.endIndex, offsetBy: -2)
|
|
||||||
return string[start..<end].trim(character: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if string.hasPrefix("{{") {
|
|
||||||
return .variable(value: strip())
|
|
||||||
} else if string.hasPrefix("{%") {
|
|
||||||
return .block(value: strip())
|
|
||||||
} else if string.hasPrefix("{#") {
|
|
||||||
return .comment(value: strip())
|
|
||||||
}
|
|
||||||
|
|
||||||
return .text(value: string)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an array of tokens from a given template string.
|
|
||||||
func tokenize() -> [Token] {
|
|
||||||
var tokens: [Token] = []
|
|
||||||
|
|
||||||
let scanner = Scanner(templateString)
|
|
||||||
|
|
||||||
let map = [
|
|
||||||
"{{": "}}",
|
|
||||||
"{%": "%}",
|
|
||||||
"{#": "#}",
|
|
||||||
]
|
|
||||||
|
|
||||||
while !scanner.isEmpty {
|
|
||||||
if let text = scanner.scan(until: ["{{", "{%", "{#"]) {
|
|
||||||
if !text.1.isEmpty {
|
|
||||||
tokens.append(createToken(string: text.1))
|
|
||||||
}
|
|
||||||
|
|
||||||
let end = map[text.0]!
|
|
||||||
let result = scanner.scan(until: end, returnUntil: true)
|
|
||||||
tokens.append(createToken(string: result))
|
|
||||||
} else {
|
|
||||||
tokens.append(createToken(string: scanner.content))
|
|
||||||
scanner.content = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Scanner {
|
|
||||||
var content: String
|
|
||||||
|
|
||||||
init(_ content: String) {
|
|
||||||
self.content = content
|
|
||||||
}
|
|
||||||
|
|
||||||
var isEmpty: Bool {
|
|
||||||
return content.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
func scan(until: String, returnUntil: Bool = false) -> String {
|
|
||||||
if until.isEmpty {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = content.startIndex
|
|
||||||
while index != content.endIndex {
|
|
||||||
let substring = content.substring(from: index)
|
|
||||||
|
|
||||||
if substring.hasPrefix(until) {
|
|
||||||
let result = content.substring(to: index)
|
|
||||||
content = substring
|
|
||||||
|
|
||||||
if returnUntil {
|
|
||||||
content = content.substring(from: until.endIndex)
|
|
||||||
return result + until
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
index = content.index(after: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func scan(until: [String]) -> (String, String)? {
|
|
||||||
if until.isEmpty {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = content.startIndex
|
|
||||||
while index != content.endIndex {
|
|
||||||
let substring = content.substring(from: index)
|
|
||||||
for string in until {
|
|
||||||
if substring.hasPrefix(string) {
|
|
||||||
let result = content.substring(to: index)
|
|
||||||
content = substring
|
|
||||||
return (string, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
index = content.index(after: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
func findFirstNot(character: Character) -> String.Index? {
|
|
||||||
var index = startIndex
|
|
||||||
|
|
||||||
while index != endIndex {
|
|
||||||
if character != self[index] {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
index = self.index(after: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findLastNot(character: Character) -> String.Index? {
|
|
||||||
var index = self.index(before: endIndex)
|
|
||||||
|
|
||||||
while index != startIndex {
|
|
||||||
if character != self[index] {
|
|
||||||
return self.index(after: index)
|
|
||||||
}
|
|
||||||
index = self.index(before: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func trim(character: Character) -> String {
|
|
||||||
let first = findFirstNot(character: character) ?? startIndex
|
|
||||||
let last = findLastNot(character: character) ?? endIndex
|
|
||||||
return self[first..<last]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import PathKit
|
|
||||||
|
|
||||||
|
|
||||||
public protocol Loader {
|
|
||||||
func loadTemplate(name: String, environment: Environment) throws -> Template
|
|
||||||
func loadTemplate(names: [String], environment: Environment) throws -> Template
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension Loader {
|
|
||||||
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
|
||||||
for name in names {
|
|
||||||
do {
|
|
||||||
return try loadTemplate(name: name, environment: environment)
|
|
||||||
} catch is TemplateDoesNotExist {
|
|
||||||
continue
|
|
||||||
} catch {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// A class for loading a template from disk
|
|
||||||
public class FileSystemLoader: Loader, CustomStringConvertible {
|
|
||||||
public let paths: [Path]
|
|
||||||
|
|
||||||
public init(paths: [Path]) {
|
|
||||||
self.paths = paths
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(bundle: [Bundle]) {
|
|
||||||
self.paths = bundle.map {
|
|
||||||
return Path($0.bundlePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var description: String {
|
|
||||||
return "FileSystemLoader(\(paths))"
|
|
||||||
}
|
|
||||||
|
|
||||||
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
|
||||||
for path in paths {
|
|
||||||
let templatePath = try path.safeJoin(path: Path(name))
|
|
||||||
|
|
||||||
if !templatePath.exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let content: String = try templatePath.read()
|
|
||||||
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
|
||||||
for path in paths {
|
|
||||||
for templateName in names {
|
|
||||||
let templatePath = try path.safeJoin(path: Path(templateName))
|
|
||||||
|
|
||||||
if templatePath.exists {
|
|
||||||
let content: String = try templatePath.read()
|
|
||||||
return environment.templateClass.init(templateString: content, environment: environment, name: templateName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
extension Path {
|
|
||||||
func safeJoin(path: Path) throws -> Path {
|
|
||||||
let newPath = self + path
|
|
||||||
|
|
||||||
if !newPath.absolute().description.hasPrefix(absolute().description) {
|
|
||||||
throw SuspiciousFileOperation(basePath: self, path: newPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SuspiciousFileOperation: Error {
|
|
||||||
let basePath: Path
|
|
||||||
let path: Path
|
|
||||||
|
|
||||||
init(basePath: Path, path: Path) {
|
|
||||||
self.basePath = basePath
|
|
||||||
self.path = path
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
return "Path `\(path)` is located outside of base path `\(basePath)`"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
|
|
||||||
public struct TemplateSyntaxError : Error, Equatable, CustomStringConvertible {
|
|
||||||
public let description:String
|
|
||||||
|
|
||||||
public init(_ description:String) {
|
|
||||||
self.description = description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public func ==(lhs:TemplateSyntaxError, rhs:TemplateSyntaxError) -> Bool {
|
|
||||||
return lhs.description == rhs.description
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public protocol NodeType {
|
|
||||||
/// Render the node in the given context
|
|
||||||
func render(_ context:Context) throws -> String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Render the collection of nodes in the given context
|
|
||||||
public func renderNodes(_ nodes:[NodeType], _ context:Context) throws -> String {
|
|
||||||
return try nodes.map { try $0.render(context) }.joined(separator: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SimpleNode : NodeType {
|
|
||||||
public let handler:(Context) throws -> String
|
|
||||||
|
|
||||||
public init(handler: @escaping (Context) throws -> String) {
|
|
||||||
self.handler = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
public func render(_ context: Context) throws -> String {
|
|
||||||
return try handler(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public class TextNode : NodeType {
|
|
||||||
public let text:String
|
|
||||||
|
|
||||||
public init(text:String) {
|
|
||||||
self.text = text
|
|
||||||
}
|
|
||||||
|
|
||||||
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 init(variable: Resolvable) {
|
|
||||||
self.variable = variable
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(variable: String) {
|
|
||||||
self.variable = Variable(variable)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func render(_ context: Context) throws -> String {
|
|
||||||
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 result = result as? CustomStringConvertible {
|
|
||||||
return result.description
|
|
||||||
} else if let result = result as? NSObject {
|
|
||||||
return result.description
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#if !os(Linux)
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
|
|
||||||
class NowNode : NodeType {
|
|
||||||
let format:Variable
|
|
||||||
|
|
||||||
class func parse(_ parser:TokenParser, token:Token) throws -> NodeType {
|
|
||||||
var format:Variable?
|
|
||||||
|
|
||||||
let components = token.components()
|
|
||||||
guard components.count <= 2 else {
|
|
||||||
throw TemplateSyntaxError("'now' tags may only have one argument: the format string `\(token.contents)`.")
|
|
||||||
}
|
|
||||||
if components.count == 2 {
|
|
||||||
format = Variable(components[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
return NowNode(format:format)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(format:Variable?) {
|
|
||||||
self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
let date = Date()
|
|
||||||
let format = try self.format.resolve(context)
|
|
||||||
var formatter:DateFormatter?
|
|
||||||
|
|
||||||
if let format = format as? DateFormatter {
|
|
||||||
formatter = format
|
|
||||||
} else if let format = format as? String {
|
|
||||||
formatter = DateFormatter()
|
|
||||||
formatter!.dateFormat = format
|
|
||||||
} else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatter!.string(from: date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,98 +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:
|
|
||||||
nodes.append(VariableNode(variable: try compileFilter(token.contents)))
|
|
||||||
case .block:
|
|
||||||
if let parse_until = parse_until , parse_until(self, token) {
|
|
||||||
prependToken(token)
|
|
||||||
return nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tag = token.components().first {
|
|
||||||
let parser = try findTag(name: tag)
|
|
||||||
nodes.append(try parser(self, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw TemplateSyntaxError("Unknown filter '\(name)'")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func compileFilter(_ token: String) throws -> Resolvable {
|
|
||||||
return try FilterExpression(token: token, parser: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
106
Sources/Stencil/Context.swift
Normal file
106
Sources/Stencil/Context.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/// A container for template variables.
|
||||||
|
public class Context {
|
||||||
|
var dictionaries: [[String: Any?]]
|
||||||
|
|
||||||
|
/// The context's environment, such as registered extensions, classes, …
|
||||||
|
public let environment: Environment
|
||||||
|
|
||||||
|
init(dictionaries: [[String: Any?]], environment: Environment) {
|
||||||
|
self.dictionaries = dictionaries
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a context from a dictionary (and an env.)
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - dictionary: The context's data
|
||||||
|
/// - environment: Environment such as extensions, …
|
||||||
|
public convenience init(dictionary: [String: Any] = [:], environment: Environment? = nil) {
|
||||||
|
self.init(
|
||||||
|
dictionaries: dictionary.isEmpty ? [] : [dictionary],
|
||||||
|
environment: environment ?? Environment()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access variables in this context by name
|
||||||
|
public subscript(key: String) -> Any? {
|
||||||
|
/// Retrieves a variable's value, starting at the current context and going upwards
|
||||||
|
get {
|
||||||
|
for dictionary in Array(dictionaries.reversed()) {
|
||||||
|
if let value = dictionary[key] {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a variable in the current context, deleting the variable if it's nil
|
||||||
|
set(value) {
|
||||||
|
if var dictionary = dictionaries.popLast() {
|
||||||
|
dictionary[key] = value
|
||||||
|
dictionaries.append(dictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a new level into the Context
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - dictionary: The new level data
|
||||||
|
fileprivate func push(_ dictionary: [String: Any] = [:]) {
|
||||||
|
dictionaries.append(dictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the last level off of the Context
|
||||||
|
///
|
||||||
|
/// - returns: The popped level
|
||||||
|
// swiftlint:disable:next discouraged_optional_collection
|
||||||
|
fileprivate func pop() -> [String: Any?]? {
|
||||||
|
dictionaries.popLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a new level onto the context for the duration of the execution of the given closure
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - dictionary: The new level data
|
||||||
|
/// - closure: The closure to execute
|
||||||
|
/// - returns: Return value of the closure
|
||||||
|
public func push<Result>(dictionary: [String: Any] = [:], closure: (() throws -> Result)) rethrows -> Result {
|
||||||
|
push(dictionary)
|
||||||
|
defer { _ = pop() }
|
||||||
|
return try closure()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten all levels of context data into 1, merging duplicate variables
|
||||||
|
///
|
||||||
|
/// - returns: All collected variables
|
||||||
|
public func flatten() -> [String: Any] {
|
||||||
|
var accumulator: [String: Any] = [:]
|
||||||
|
|
||||||
|
for dictionary in dictionaries {
|
||||||
|
for (key, value) in dictionary {
|
||||||
|
if let value = value {
|
||||||
|
accumulator.updateValue(value, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache result of block by its name in the context top-level, so that it can be later rendered
|
||||||
|
/// via `{{ block.name }}`
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: The name of the stored block
|
||||||
|
/// - content: The block's rendered content
|
||||||
|
public func cacheBlock(_ name: String, content: String) {
|
||||||
|
if var block = dictionaries.first?["block"] as? [String: String] {
|
||||||
|
block[name] = content
|
||||||
|
dictionaries[0]["block"] = block
|
||||||
|
} else {
|
||||||
|
dictionaries.insert(["block": [name: content]], at: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Sources/Stencil/DynamicMemberLookup.swift
Normal file
18
Sources/Stencil/DynamicMemberLookup.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/// Marker protocol so we can know which types support `@dynamicMemberLookup`. Add this to your own types that support
|
||||||
|
/// lookup by String.
|
||||||
|
public protocol DynamicMemberLookup {
|
||||||
|
/// Get a value for a given `String` key
|
||||||
|
subscript(dynamicMember member: String) -> Any? { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension DynamicMemberLookup where Self: RawRepresentable {
|
||||||
|
/// Get a value for a given `String` key
|
||||||
|
subscript(dynamicMember member: String) -> Any? {
|
||||||
|
switch member {
|
||||||
|
case "rawValue":
|
||||||
|
return rawValue
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
Sources/Stencil/Environment.swift
Normal file
84
Sources/Stencil/Environment.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/// Container for environment data, such as registered extensions
|
||||||
|
public struct Environment {
|
||||||
|
/// The class for loading new templates
|
||||||
|
public let templateClass: Template.Type
|
||||||
|
/// List of registered extensions
|
||||||
|
public var extensions: [Extension]
|
||||||
|
/// How to handle whitespace
|
||||||
|
public var trimBehaviour: TrimBehaviour
|
||||||
|
/// Mechanism for loading new files
|
||||||
|
public var loader: Loader?
|
||||||
|
|
||||||
|
/// Basic initializer
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - loader: Mechanism for loading new files
|
||||||
|
/// - extensions: List of extension containers
|
||||||
|
/// - templateClass: Class for newly loaded templates
|
||||||
|
/// - trimBehaviour: How to handle whitespace
|
||||||
|
public init(
|
||||||
|
loader: Loader? = nil,
|
||||||
|
extensions: [Extension] = [],
|
||||||
|
templateClass: Template.Type = Template.self,
|
||||||
|
trimBehaviour: TrimBehaviour = .nothing
|
||||||
|
) {
|
||||||
|
self.templateClass = templateClass
|
||||||
|
self.loader = loader
|
||||||
|
self.extensions = extensions + [DefaultExtension()]
|
||||||
|
self.trimBehaviour = trimBehaviour
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a template with the given name
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: Name of the template
|
||||||
|
/// - returns: Loaded template instance
|
||||||
|
public func loadTemplate(name: String) throws -> Template {
|
||||||
|
if let loader = loader {
|
||||||
|
return try loader.loadTemplate(name: name, environment: self)
|
||||||
|
} else {
|
||||||
|
throw TemplateDoesNotExist(templateNames: [name], loader: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a template with the given names
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - names: Names of the template
|
||||||
|
/// - returns: Loaded template instance
|
||||||
|
public func loadTemplate(names: [String]) throws -> Template {
|
||||||
|
if let loader = loader {
|
||||||
|
return try loader.loadTemplate(names: names, environment: self)
|
||||||
|
} else {
|
||||||
|
throw TemplateDoesNotExist(templateNames: names, loader: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a template with the given name, providing some data
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: Name of the template
|
||||||
|
/// - context: Data for rendering
|
||||||
|
/// - returns: Rendered output
|
||||||
|
public func renderTemplate(name: String, context: [String: Any] = [:]) throws -> String {
|
||||||
|
let template = try loadTemplate(name: name)
|
||||||
|
return try render(template: template, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the given template string, providing some data
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - string: Template string
|
||||||
|
/// - context: Data for rendering
|
||||||
|
/// - returns: Rendered output
|
||||||
|
public func renderTemplate(string: String, context: [String: Any] = [:]) throws -> String {
|
||||||
|
let template = templateClass.init(templateString: string, environment: self)
|
||||||
|
return try render(template: template, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(template: Template, context: [String: Any]) throws -> String {
|
||||||
|
// update template environment as it can be created from string literal with default environment
|
||||||
|
template.environment = self
|
||||||
|
return try template.render(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
Sources/Stencil/Errors.swift
Normal file
81
Sources/Stencil/Errors.swift
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
public class TemplateDoesNotExist: Error, CustomStringConvertible {
|
||||||
|
let templateNames: [String]
|
||||||
|
let loader: Loader?
|
||||||
|
|
||||||
|
public init(templateNames: [String], loader: Loader? = nil) {
|
||||||
|
self.templateNames = templateNames
|
||||||
|
self.loader = loader
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
let templates = templateNames.joined(separator: ", ")
|
||||||
|
|
||||||
|
if let loader = loader {
|
||||||
|
return "Template named `\(templates)` does not exist in loader \(loader)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Template named `\(templates)` does not exist. No loaders found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TemplateSyntaxError: Error, Equatable, CustomStringConvertible {
|
||||||
|
public let reason: String
|
||||||
|
public var description: String { reason }
|
||||||
|
public internal(set) var token: Token?
|
||||||
|
public internal(set) var stackTrace: [Token]
|
||||||
|
public var templateName: String? { token?.sourceMap.filename }
|
||||||
|
var allTokens: [Token] {
|
||||||
|
stackTrace + (token.map { [$0] } ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(reason: String, token: Token? = nil, stackTrace: [Token] = []) {
|
||||||
|
self.reason = reason
|
||||||
|
self.stackTrace = stackTrace
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ description: String) {
|
||||||
|
self.init(reason: description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Error {
|
||||||
|
func withToken(_ token: Token?) -> Error {
|
||||||
|
if var error = self as? TemplateSyntaxError {
|
||||||
|
error.token = error.token ?? token
|
||||||
|
return error
|
||||||
|
} else {
|
||||||
|
return TemplateSyntaxError(reason: "\(self)", token: token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol ErrorReporter: AnyObject {
|
||||||
|
func renderError(_ error: Error) -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
open class SimpleErrorReporter: ErrorReporter {
|
||||||
|
open func renderError(_ error: Error) -> String {
|
||||||
|
guard let templateError = error as? TemplateSyntaxError else { return error.localizedDescription }
|
||||||
|
|
||||||
|
func describe(token: Token) -> String {
|
||||||
|
let templateName = token.sourceMap.filename ?? ""
|
||||||
|
let location = token.sourceMap.location
|
||||||
|
let highlight = """
|
||||||
|
\(String(Array(repeating: " ", count: location.lineOffset)))\
|
||||||
|
^\(String(Array(repeating: "~", count: max(token.contents.count - 1, 0))))
|
||||||
|
"""
|
||||||
|
|
||||||
|
return """
|
||||||
|
\(templateName)\(location.lineNumber):\(location.lineOffset): error: \(templateError.reason)
|
||||||
|
\(location.content)
|
||||||
|
\(highlight)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptions = templateError.stackTrace.reduce(into: []) { $0.append(describe(token: $1)) }
|
||||||
|
let description = templateError.token.map(describe(token:)) ?? templateError.reason
|
||||||
|
descriptions.append(description)
|
||||||
|
return descriptions.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
327
Sources/Stencil/Expression.swift
Normal file
327
Sources/Stencil/Expression.swift
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
public protocol Expression: CustomStringConvertible, Resolvable {
|
||||||
|
func evaluate(context: Context) throws -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Expression {
|
||||||
|
func resolve(_ context: Context) throws -> Any? {
|
||||||
|
try "\(evaluate(context: context))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol InfixOperator: Expression {
|
||||||
|
init(lhs: Expression, rhs: Expression)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol PrefixOperator: Expression {
|
||||||
|
init(expression: Expression)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class StaticExpression: Expression, CustomStringConvertible {
|
||||||
|
let value: Bool
|
||||||
|
|
||||||
|
init(value: Bool) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"\(value)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class VariableExpression: Expression, CustomStringConvertible {
|
||||||
|
let variable: Resolvable
|
||||||
|
|
||||||
|
init(variable: Resolvable) {
|
||||||
|
self.variable = variable
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"(variable: \(variable))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(_ context: Context) throws -> Any? {
|
||||||
|
try variable.resolve(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a variable in the given context as boolean
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
let result = try variable.resolve(context)
|
||||||
|
var truthy = false
|
||||||
|
|
||||||
|
if let result = result as? [Any] {
|
||||||
|
truthy = !result.isEmpty
|
||||||
|
} else if let result = result as? [String: Any] {
|
||||||
|
truthy = !result.isEmpty
|
||||||
|
} else if let result = result as? Bool {
|
||||||
|
truthy = result
|
||||||
|
} else if let result = result as? String {
|
||||||
|
truthy = !result.isEmpty
|
||||||
|
} else if let value = result, let result = toNumber(value: value) {
|
||||||
|
truthy = result > 0
|
||||||
|
} else if result != nil {
|
||||||
|
truthy = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return truthy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class NotExpression: Expression, PrefixOperator, CustomStringConvertible {
|
||||||
|
let expression: Expression
|
||||||
|
|
||||||
|
init(expression: Expression) {
|
||||||
|
self.expression = expression
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"not \(expression)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
try !expression.evaluate(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class InExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
|
let lhs: Expression
|
||||||
|
let rhs: Expression
|
||||||
|
|
||||||
|
init(lhs: Expression, rhs: Expression) {
|
||||||
|
self.lhs = lhs
|
||||||
|
self.rhs = rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"(\(lhs) in \(rhs))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||||
|
let lhsValue = try lhs.variable.resolve(context)
|
||||||
|
let rhsValue = try rhs.variable.resolve(context)
|
||||||
|
|
||||||
|
if let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? [AnyHashable] {
|
||||||
|
return rhs.contains(lhs)
|
||||||
|
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableClosedRange<Int> {
|
||||||
|
return rhs.contains(lhs)
|
||||||
|
} else if let lhs = lhsValue as? Int, let rhs = rhsValue as? CountableRange<Int> {
|
||||||
|
return rhs.contains(lhs)
|
||||||
|
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
|
||||||
|
return rhs.contains(lhs)
|
||||||
|
} else if lhsValue == nil && rhsValue == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class OrExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
|
let lhs: Expression
|
||||||
|
let rhs: Expression
|
||||||
|
|
||||||
|
init(lhs: Expression, rhs: Expression) {
|
||||||
|
self.lhs = lhs
|
||||||
|
self.rhs = rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"(\(lhs) or \(rhs))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
let lhs = try self.lhs.evaluate(context: context)
|
||||||
|
if lhs {
|
||||||
|
return lhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return try rhs.evaluate(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AndExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
|
let lhs: Expression
|
||||||
|
let rhs: Expression
|
||||||
|
|
||||||
|
init(lhs: Expression, rhs: Expression) {
|
||||||
|
self.lhs = lhs
|
||||||
|
self.rhs = rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"(\(lhs) and \(rhs))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
let lhs = try self.lhs.evaluate(context: context)
|
||||||
|
if !lhs {
|
||||||
|
return lhs
|
||||||
|
}
|
||||||
|
|
||||||
|
return try rhs.evaluate(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EqualityExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
|
let lhs: Expression
|
||||||
|
let rhs: Expression
|
||||||
|
|
||||||
|
required init(lhs: Expression, rhs: Expression) {
|
||||||
|
self.lhs = lhs
|
||||||
|
self.rhs = rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"(\(lhs) == \(rhs))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||||
|
let lhsValue = try lhs.variable.resolve(context)
|
||||||
|
let rhsValue = try rhs.variable.resolve(context)
|
||||||
|
|
||||||
|
if let lhs = lhsValue, let rhs = rhsValue {
|
||||||
|
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
||||||
|
return lhs == rhs
|
||||||
|
} else if let lhs = lhsValue as? String, let rhs = rhsValue as? String {
|
||||||
|
return lhs == rhs
|
||||||
|
} else if let lhs = lhsValue as? Bool, let rhs = rhsValue as? Bool {
|
||||||
|
return lhs == rhs
|
||||||
|
}
|
||||||
|
} else if lhsValue == nil && rhsValue == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NumericExpression: Expression, InfixOperator, CustomStringConvertible {
|
||||||
|
let lhs: Expression
|
||||||
|
let rhs: Expression
|
||||||
|
|
||||||
|
required init(lhs: Expression, rhs: Expression) {
|
||||||
|
self.lhs = lhs
|
||||||
|
self.rhs = rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"(\(lhs) \(symbol) \(rhs))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(context: Context) throws -> Bool {
|
||||||
|
if let lhs = lhs as? VariableExpression, let rhs = rhs as? VariableExpression {
|
||||||
|
let lhsValue = try lhs.variable.resolve(context)
|
||||||
|
let rhsValue = try rhs.variable.resolve(context)
|
||||||
|
|
||||||
|
if let lhs = lhsValue, let rhs = rhsValue {
|
||||||
|
if let lhs = toNumber(value: lhs), let rhs = toNumber(value: rhs) {
|
||||||
|
return compare(lhs: lhs, rhs: rhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MoreThanExpression: NumericExpression {
|
||||||
|
override var symbol: String {
|
||||||
|
">"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
|
lhs > rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MoreThanEqualExpression: NumericExpression {
|
||||||
|
override var symbol: String {
|
||||||
|
">="
|
||||||
|
}
|
||||||
|
|
||||||
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
|
lhs >= rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LessThanExpression: NumericExpression {
|
||||||
|
override var symbol: String {
|
||||||
|
"<"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
|
lhs < rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LessThanEqualExpression: NumericExpression {
|
||||||
|
override var symbol: String {
|
||||||
|
"<="
|
||||||
|
}
|
||||||
|
|
||||||
|
override func compare(lhs: Number, rhs: Number) -> Bool {
|
||||||
|
lhs <= rhs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InequalityExpression: EqualityExpression {
|
||||||
|
override var description: String {
|
||||||
|
"(\(lhs) != \(rhs))"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func evaluate(context: Context) throws -> Bool {
|
||||||
|
try !super.evaluate(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next cyclomatic_complexity
|
||||||
|
func toNumber(value: Any) -> Number? {
|
||||||
|
if let value = value as? Float {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Double {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? UInt {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Int {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Int8 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Int16 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Int32 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Int64 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? UInt8 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? UInt16 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? UInt32 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? UInt64 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Number {
|
||||||
|
return value
|
||||||
|
} else if let value = value as? Float64 {
|
||||||
|
return Number(value)
|
||||||
|
} else if let value = value as? Float32 {
|
||||||
|
return Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
103
Sources/Stencil/Extension.swift
Normal file
103
Sources/Stencil/Extension.swift
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/// Container for registered tags and filters
|
||||||
|
open class Extension {
|
||||||
|
typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||||
|
|
||||||
|
var tags = [String: TagParser]()
|
||||||
|
var filters = [String: Filter]()
|
||||||
|
|
||||||
|
/// Simple initializer
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a new template tag
|
||||||
|
public func registerTag(_ name: String, parser: @escaping (TokenParser, Token) throws -> NodeType) {
|
||||||
|
tags[name] = parser
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a simple template tag with a name and a handler
|
||||||
|
public func registerSimpleTag(_ name: String, handler: @escaping (Context) throws -> String) {
|
||||||
|
registerTag(name) { _, token in
|
||||||
|
SimpleNode(token: token, handler: handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers boolean filter with it's negative counterpart
|
||||||
|
public func registerFilter(name: String, negativeFilterName: String, filter: @escaping (Any?) throws -> Bool?) {
|
||||||
|
// swiftlint:disable:previous discouraged_optional_boolean
|
||||||
|
filters[name] = .simple(filter)
|
||||||
|
filters[negativeFilterName] = .simple { value in
|
||||||
|
guard let result = try filter(value) else { return nil }
|
||||||
|
return !result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a template filter with the given name
|
||||||
|
public func registerFilter(_ name: String, filter: @escaping (Any?) throws -> Any?) {
|
||||||
|
filters[name] = .simple(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a template filter with the given name
|
||||||
|
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?]) throws -> Any?) {
|
||||||
|
filters[name] = .arguments { value, args, _ in try filter(value, args) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a template filter with the given name
|
||||||
|
public func registerFilter(_ name: String, filter: @escaping (Any?, [Any?], Context) throws -> Any?) {
|
||||||
|
filters[name] = .arguments(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultExtension: Extension {
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
registerDefaultTags()
|
||||||
|
registerDefaultFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func registerDefaultTags() {
|
||||||
|
registerTag("for", parser: ForNode.parse)
|
||||||
|
registerTag("break", parser: LoopTerminationNode.parse)
|
||||||
|
registerTag("continue", parser: LoopTerminationNode.parse)
|
||||||
|
registerTag("if", parser: IfNode.parse)
|
||||||
|
registerTag("ifnot", parser: IfNode.parse_ifnot)
|
||||||
|
#if !os(Linux)
|
||||||
|
registerTag("now", parser: NowNode.parse)
|
||||||
|
#endif
|
||||||
|
registerTag("include", parser: IncludeNode.parse)
|
||||||
|
registerTag("extends", parser: ExtendsNode.parse)
|
||||||
|
registerTag("block", parser: BlockNode.parse)
|
||||||
|
registerTag("filter", parser: FilterNode.parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func registerDefaultFilters() {
|
||||||
|
registerFilter("default", filter: defaultFilter)
|
||||||
|
registerFilter("capitalize", filter: capitalise)
|
||||||
|
registerFilter("uppercase", filter: uppercase)
|
||||||
|
registerFilter("lowercase", filter: lowercase)
|
||||||
|
registerFilter("join", filter: joinFilter)
|
||||||
|
registerFilter("split", filter: splitFilter)
|
||||||
|
registerFilter("indent", filter: indentFilter)
|
||||||
|
registerFilter("filter", filter: filterFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol FilterType {
|
||||||
|
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Filter: FilterType {
|
||||||
|
case simple(((Any?) throws -> Any?))
|
||||||
|
case arguments(((Any?, [Any?], Context) throws -> Any?))
|
||||||
|
|
||||||
|
func invoke(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
|
||||||
|
switch self {
|
||||||
|
case let .simple(filter):
|
||||||
|
if !arguments.isEmpty {
|
||||||
|
throw TemplateSyntaxError("Can't invoke filter with an argument")
|
||||||
|
}
|
||||||
|
return try filter(value)
|
||||||
|
case let .arguments(filter):
|
||||||
|
return try filter(value, arguments, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Sources/Stencil/FilterTag.swift
Normal file
36
Sources/Stencil/FilterTag.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
class FilterNode: NodeType {
|
||||||
|
let resolvable: Resolvable
|
||||||
|
let nodes: [NodeType]
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
let bits = token.components
|
||||||
|
|
||||||
|
guard bits.count == 2 else {
|
||||||
|
throw TemplateSyntaxError("'filter' tag takes one argument, the filter expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
let blocks = try parser.parse(until(["endfilter"]))
|
||||||
|
|
||||||
|
guard parser.nextToken() != nil else {
|
||||||
|
throw TemplateSyntaxError("`endfilter` was not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvable = try parser.compileFilter("filter_value|\(bits[1])", containedIn: token)
|
||||||
|
return FilterNode(nodes: blocks, resolvable: resolvable, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(nodes: [NodeType], resolvable: Resolvable, token: Token) {
|
||||||
|
self.nodes = nodes
|
||||||
|
self.resolvable = resolvable
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
let value = try renderNodes(nodes, context)
|
||||||
|
|
||||||
|
return try context.push(dictionary: ["filter_value": value]) {
|
||||||
|
try VariableNode(variable: resolvable, token: token).render(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
133
Sources/Stencil/Filters.swift
Normal file
133
Sources/Stencil/Filters.swift
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
func capitalise(_ value: Any?) -> Any? {
|
||||||
|
if let array = value as? [Any?] {
|
||||||
|
return array.map { stringify($0).capitalized }
|
||||||
|
} else {
|
||||||
|
return stringify(value).capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uppercase(_ value: Any?) -> Any? {
|
||||||
|
if let array = value as? [Any?] {
|
||||||
|
return array.map { stringify($0).uppercased() }
|
||||||
|
} else {
|
||||||
|
return stringify(value).uppercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lowercase(_ value: Any?) -> Any? {
|
||||||
|
if let array = value as? [Any?] {
|
||||||
|
return array.map { stringify($0).lowercased() }
|
||||||
|
} else {
|
||||||
|
return stringify(value).lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFilter(value: Any?, arguments: [Any?]) -> Any? {
|
||||||
|
// value can be optional wrapping nil, so this way we check for underlying value
|
||||||
|
if let value = value, String(describing: value) != "nil" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
for argument in arguments {
|
||||||
|
if let argument = argument {
|
||||||
|
return argument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||||
|
guard arguments.count < 2 else {
|
||||||
|
throw TemplateSyntaxError("'join' filter takes at most one argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
let separator = stringify(arguments.first ?? "")
|
||||||
|
|
||||||
|
if let value = value as? [Any] {
|
||||||
|
return value
|
||||||
|
.map(stringify)
|
||||||
|
.joined(separator: separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||||
|
guard arguments.count < 2 else {
|
||||||
|
throw TemplateSyntaxError("'split' filter takes at most one argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
let separator = stringify(arguments.first ?? " ")
|
||||||
|
if let value = value as? String {
|
||||||
|
return value.components(separatedBy: separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func indentFilter(value: Any?, arguments: [Any?]) throws -> Any? {
|
||||||
|
guard arguments.count <= 3 else {
|
||||||
|
throw TemplateSyntaxError("'indent' filter can take at most 3 arguments")
|
||||||
|
}
|
||||||
|
|
||||||
|
var indentWidth = 4
|
||||||
|
if !arguments.isEmpty {
|
||||||
|
guard let value = arguments[0] as? Int else {
|
||||||
|
throw TemplateSyntaxError(
|
||||||
|
"""
|
||||||
|
'indent' filter width argument must be an Integer (\(String(describing: arguments[0])))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
indentWidth = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var indentationChar = " "
|
||||||
|
if arguments.count > 1 {
|
||||||
|
guard let value = arguments[1] as? String else {
|
||||||
|
throw TemplateSyntaxError(
|
||||||
|
"""
|
||||||
|
'indent' filter indentation argument must be a String (\(String(describing: arguments[1]))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
indentationChar = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var indentFirst = false
|
||||||
|
if arguments.count > 2 {
|
||||||
|
guard let value = arguments[2] as? Bool else {
|
||||||
|
throw TemplateSyntaxError("'indent' filter indentFirst argument must be a Bool")
|
||||||
|
}
|
||||||
|
indentFirst = value
|
||||||
|
}
|
||||||
|
|
||||||
|
let indentation = [String](repeating: indentationChar, count: indentWidth).joined()
|
||||||
|
return indent(stringify(value), indentation: indentation, indentFirst: indentFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(_ content: String, indentation: String, indentFirst: Bool) -> String {
|
||||||
|
guard !indentation.isEmpty else { return content }
|
||||||
|
|
||||||
|
var lines = content.components(separatedBy: .newlines)
|
||||||
|
let firstLine = (indentFirst ? indentation : "") + lines.removeFirst()
|
||||||
|
let result = lines.reduce(into: [firstLine]) { result, line in
|
||||||
|
result.append(line.isEmpty ? "" : "\(indentation)\(line)")
|
||||||
|
}
|
||||||
|
return result.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterFilter(value: Any?, arguments: [Any?], context: Context) throws -> Any? {
|
||||||
|
guard let value = value else { return nil }
|
||||||
|
guard arguments.count == 1 else {
|
||||||
|
throw TemplateSyntaxError("'filter' filter takes one argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
let attribute = stringify(arguments[0])
|
||||||
|
|
||||||
|
let expr = try context.environment.compileFilter("$0|\(attribute)")
|
||||||
|
return try context.push(dictionary: ["$0": value]) {
|
||||||
|
try expr.resolve(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
Sources/Stencil/ForTag.swift
Normal file
274
Sources/Stencil/ForTag.swift
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class ForNode: NodeType {
|
||||||
|
let resolvable: Resolvable
|
||||||
|
let loopVariables: [String]
|
||||||
|
let nodes: [NodeType]
|
||||||
|
let emptyNodes: [NodeType]
|
||||||
|
let `where`: Expression?
|
||||||
|
let label: String?
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
var components = token.components
|
||||||
|
|
||||||
|
var label: String?
|
||||||
|
if components.first?.hasSuffix(":") == true {
|
||||||
|
label = String(components.removeFirst().dropLast())
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasToken(_ token: String, at index: Int) -> Bool {
|
||||||
|
components.count > (index + 1) && components[index] == token
|
||||||
|
}
|
||||||
|
|
||||||
|
func endsOrHasToken(_ token: String, at index: Int) -> Bool {
|
||||||
|
components.count == index || hasToken(token, at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard hasToken("in", at: 2) && endsOrHasToken("where", at: 4) else {
|
||||||
|
throw TemplateSyntaxError("'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let loopVariables = components[1]
|
||||||
|
.split(separator: ",")
|
||||||
|
.map(String.init)
|
||||||
|
.map { $0.trim(character: " ") }
|
||||||
|
|
||||||
|
let resolvable = try parser.compileResolvable(components[3], containedIn: token)
|
||||||
|
|
||||||
|
let `where` = hasToken("where", at: 4)
|
||||||
|
? try parser.compileExpression(components: Array(components.suffix(from: 5)), token: token)
|
||||||
|
: nil
|
||||||
|
|
||||||
|
let forNodes = try parser.parse(until(["endfor", "empty"]))
|
||||||
|
|
||||||
|
guard let token = parser.nextToken() else {
|
||||||
|
throw TemplateSyntaxError("`endfor` was not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var emptyNodes = [NodeType]()
|
||||||
|
if token.contents == "empty" {
|
||||||
|
emptyNodes = try parser.parse(until(["endfor"]))
|
||||||
|
_ = parser.nextToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ForNode(
|
||||||
|
resolvable: resolvable,
|
||||||
|
loopVariables: loopVariables,
|
||||||
|
nodes: forNodes,
|
||||||
|
emptyNodes: emptyNodes,
|
||||||
|
where: `where`,
|
||||||
|
label: label,
|
||||||
|
token: token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
resolvable: Resolvable,
|
||||||
|
loopVariables: [String],
|
||||||
|
nodes: [NodeType],
|
||||||
|
emptyNodes: [NodeType],
|
||||||
|
where: Expression? = nil,
|
||||||
|
label: String? = nil,
|
||||||
|
token: Token? = nil
|
||||||
|
) {
|
||||||
|
self.resolvable = resolvable
|
||||||
|
self.loopVariables = loopVariables
|
||||||
|
self.nodes = nodes
|
||||||
|
self.emptyNodes = emptyNodes
|
||||||
|
self.where = `where`
|
||||||
|
self.label = label
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
var values = try resolve(context)
|
||||||
|
|
||||||
|
if let `where` = self.where {
|
||||||
|
values = try values.filter { item -> Bool in
|
||||||
|
try push(value: item, context: context) {
|
||||||
|
try `where`.evaluate(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !values.isEmpty {
|
||||||
|
let count = values.count
|
||||||
|
var result = ""
|
||||||
|
|
||||||
|
// collect parent loop contexts
|
||||||
|
let parentLoopContexts = (context["forloop"] as? [String: Any])?
|
||||||
|
.filter { ($1 as? [String: Any])?["label"] != nil } ?? [:]
|
||||||
|
|
||||||
|
for (index, item) in zip(0..., values) {
|
||||||
|
var forContext: [String: Any] = [
|
||||||
|
"first": index == 0,
|
||||||
|
"last": index == (count - 1),
|
||||||
|
"counter": index + 1,
|
||||||
|
"counter0": index,
|
||||||
|
"length": count
|
||||||
|
]
|
||||||
|
if let label = label {
|
||||||
|
forContext["label"] = label
|
||||||
|
forContext[label] = forContext
|
||||||
|
}
|
||||||
|
forContext.merge(parentLoopContexts) { lhs, _ in lhs }
|
||||||
|
|
||||||
|
var shouldBreak = false
|
||||||
|
result += try context.push(dictionary: ["forloop": forContext]) {
|
||||||
|
defer {
|
||||||
|
// if outer loop should be continued we should break from current loop
|
||||||
|
if let shouldContinueLabel = context[LoopTerminationNode.continueContextKey] as? String {
|
||||||
|
shouldBreak = shouldContinueLabel != label || label == nil
|
||||||
|
} else {
|
||||||
|
shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try push(value: item, context: context) {
|
||||||
|
try renderNodes(nodes, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldBreak {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
return try context.push {
|
||||||
|
try renderNodes(emptyNodes, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func push<Result>(value: Any, context: Context, closure: () throws -> (Result)) throws -> Result {
|
||||||
|
if loopVariables.isEmpty {
|
||||||
|
return try context.push {
|
||||||
|
try closure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let valueMirror = Mirror(reflecting: value)
|
||||||
|
if case .tuple? = valueMirror.displayStyle {
|
||||||
|
if loopVariables.count > Int(valueMirror.children.count) {
|
||||||
|
throw TemplateSyntaxError("Tuple '\(value)' has less values than loop variables")
|
||||||
|
}
|
||||||
|
var variablesContext = [String: Any]()
|
||||||
|
valueMirror.children.prefix(loopVariables.count).enumerated().forEach { offset, element in
|
||||||
|
if loopVariables[offset] != "_" {
|
||||||
|
variablesContext[loopVariables[offset]] = element.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try context.push(dictionary: variablesContext) {
|
||||||
|
try closure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try context.push(dictionary: [loopVariables.first ?? "": value]) {
|
||||||
|
try closure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(_ context: Context) throws -> [Any] {
|
||||||
|
let resolved = try resolvable.resolve(context)
|
||||||
|
|
||||||
|
var values: [Any]
|
||||||
|
if let dictionary = resolved as? [String: Any], !dictionary.isEmpty {
|
||||||
|
values = dictionary.sorted { $0.key < $1.key }
|
||||||
|
} else if let array = resolved as? [Any] {
|
||||||
|
values = array
|
||||||
|
} else if let range = resolved as? CountableClosedRange<Int> {
|
||||||
|
values = Array(range)
|
||||||
|
} else if let range = resolved as? CountableRange<Int> {
|
||||||
|
values = Array(range)
|
||||||
|
} else if let resolved = resolved {
|
||||||
|
let mirror = Mirror(reflecting: resolved)
|
||||||
|
switch mirror.displayStyle {
|
||||||
|
case .struct, .tuple:
|
||||||
|
values = Array(mirror.children)
|
||||||
|
case .class:
|
||||||
|
var children = Array(mirror.children)
|
||||||
|
var currentMirror: Mirror? = mirror
|
||||||
|
while let superclassMirror = currentMirror?.superclassMirror {
|
||||||
|
children.append(contentsOf: superclassMirror.children)
|
||||||
|
currentMirror = superclassMirror
|
||||||
|
}
|
||||||
|
values = Array(children)
|
||||||
|
default:
|
||||||
|
values = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
values = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoopTerminationNode: NodeType {
|
||||||
|
static let breakContextKey = "_internal_forloop_break"
|
||||||
|
static let continueContextKey = "_internal_forloop_continue"
|
||||||
|
|
||||||
|
let name: String
|
||||||
|
let label: String?
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
var contextKey: String {
|
||||||
|
"_internal_forloop_\(name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(name: String, label: String? = nil, token: Token? = nil) {
|
||||||
|
self.name = name
|
||||||
|
self.label = label
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ parser: TokenParser, token: Token) throws -> Self {
|
||||||
|
let components = token.components
|
||||||
|
|
||||||
|
guard components.count <= 2 else {
|
||||||
|
throw TemplateSyntaxError("'\(token.contents)' can accept only one parameter")
|
||||||
|
}
|
||||||
|
guard parser.hasOpenedForTag() else {
|
||||||
|
throw TemplateSyntaxError("'\(token.contents)' can be used only inside loop body")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Self(name: components[0], label: components.count == 2 ? components[1] : nil, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
let offset = zip(0..., context.dictionaries).reversed().first { _, dictionary in
|
||||||
|
guard let forContext = dictionary["forloop"] as? [String: Any],
|
||||||
|
dictionary["forloop"] != nil else { return false }
|
||||||
|
|
||||||
|
if let label = label {
|
||||||
|
return label == forContext["label"] as? String
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}?.0
|
||||||
|
|
||||||
|
if let offset = offset {
|
||||||
|
context.dictionaries[offset][contextKey] = label ?? true
|
||||||
|
} else if let label = label {
|
||||||
|
throw TemplateSyntaxError("No loop labeled '\(label)' is currently running")
|
||||||
|
} else {
|
||||||
|
throw TemplateSyntaxError("No loop is currently running")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TokenParser {
|
||||||
|
func hasOpenedForTag() -> Bool {
|
||||||
|
var openForCount = 0
|
||||||
|
for parsedToken in parsedTokens.reversed() where parsedToken.kind == .block {
|
||||||
|
if parsedToken.components.first == "endfor" { openForCount -= 1 }
|
||||||
|
if parsedToken.components.first == "for" { openForCount += 1 }
|
||||||
|
}
|
||||||
|
return openForCount > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
314
Sources/Stencil/IfTag.swift
Normal file
314
Sources/Stencil/IfTag.swift
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
enum Operator {
|
||||||
|
case infix(String, Int, InfixOperator.Type)
|
||||||
|
case prefix(String, Int, PrefixOperator.Type)
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .infix(let name, _, _):
|
||||||
|
return name
|
||||||
|
case .prefix(let name, _, _):
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let all: [Self] = [
|
||||||
|
.infix("in", 5, InExpression.self),
|
||||||
|
.infix("or", 6, OrExpression.self),
|
||||||
|
.infix("and", 7, AndExpression.self),
|
||||||
|
.prefix("not", 8, NotExpression.self),
|
||||||
|
.infix("==", 10, EqualityExpression.self),
|
||||||
|
.infix("!=", 10, InequalityExpression.self),
|
||||||
|
.infix(">", 10, MoreThanExpression.self),
|
||||||
|
.infix(">=", 10, MoreThanEqualExpression.self),
|
||||||
|
.infix("<", 10, LessThanExpression.self),
|
||||||
|
.infix("<=", 10, LessThanEqualExpression.self)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOperator(name: String) -> Operator? {
|
||||||
|
for `operator` in Operator.all where `operator`.name == name {
|
||||||
|
return `operator`
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
indirect enum IfToken {
|
||||||
|
case infix(name: String, bindingPower: Int, operatorType: InfixOperator.Type)
|
||||||
|
case prefix(name: String, bindingPower: Int, operatorType: PrefixOperator.Type)
|
||||||
|
case variable(Resolvable)
|
||||||
|
case subExpression(Expression)
|
||||||
|
case end
|
||||||
|
|
||||||
|
var bindingPower: Int {
|
||||||
|
switch self {
|
||||||
|
case .infix(_, let bindingPower, _):
|
||||||
|
return bindingPower
|
||||||
|
case .prefix(_, let bindingPower, _):
|
||||||
|
return bindingPower
|
||||||
|
case .variable:
|
||||||
|
return 0
|
||||||
|
case .subExpression:
|
||||||
|
return 0
|
||||||
|
case .end:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullDenotation(parser: IfExpressionParser) throws -> Expression {
|
||||||
|
switch self {
|
||||||
|
case .infix(let name, _, _):
|
||||||
|
throw TemplateSyntaxError("'if' expression error: infix operator '\(name)' doesn't have a left hand side")
|
||||||
|
case .prefix(_, let bindingPower, let operatorType):
|
||||||
|
let expression = try parser.expression(bindingPower: bindingPower)
|
||||||
|
return operatorType.init(expression: expression)
|
||||||
|
case .variable(let variable):
|
||||||
|
return VariableExpression(variable: variable)
|
||||||
|
case .subExpression(let expression):
|
||||||
|
return expression
|
||||||
|
case .end:
|
||||||
|
throw TemplateSyntaxError("'if' expression error: end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func leftDenotation(left: Expression, parser: IfExpressionParser) throws -> Expression {
|
||||||
|
switch self {
|
||||||
|
case .infix(_, let bindingPower, let operatorType):
|
||||||
|
let right = try parser.expression(bindingPower: bindingPower)
|
||||||
|
return operatorType.init(lhs: left, rhs: right)
|
||||||
|
case .prefix(let name, _, _):
|
||||||
|
throw TemplateSyntaxError("'if' expression error: prefix operator '\(name)' was called with a left hand side")
|
||||||
|
case .variable(let variable):
|
||||||
|
throw TemplateSyntaxError("'if' expression error: variable '\(variable)' was called with a left hand side")
|
||||||
|
case .subExpression:
|
||||||
|
throw TemplateSyntaxError("'if' expression error: sub expression was called with a left hand side")
|
||||||
|
case .end:
|
||||||
|
throw TemplateSyntaxError("'if' expression error: end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEnd: Bool {
|
||||||
|
switch self {
|
||||||
|
case .end:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class IfExpressionParser {
|
||||||
|
let tokens: [IfToken]
|
||||||
|
var position: Int = 0
|
||||||
|
|
||||||
|
private init(tokens: [IfToken]) {
|
||||||
|
self.tokens = tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parser(components: [String], environment: Environment, token: Token) throws -> IfExpressionParser {
|
||||||
|
try IfExpressionParser(components: ArraySlice(components), environment: environment, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(components: ArraySlice<String>, environment: Environment, token: Token) throws {
|
||||||
|
var parsedComponents = Set<Int>()
|
||||||
|
var bracketsBalance = 0
|
||||||
|
// swiftlint:disable:next closure_body_length
|
||||||
|
self.tokens = try zip(components.indices, components).compactMap { index, component in
|
||||||
|
guard !parsedComponents.contains(index) else { return nil }
|
||||||
|
|
||||||
|
if component == "(" {
|
||||||
|
bracketsBalance += 1
|
||||||
|
let (expression, parsedCount) = try Self.subExpression(
|
||||||
|
from: components.suffix(from: index + 1),
|
||||||
|
environment: environment,
|
||||||
|
token: token
|
||||||
|
)
|
||||||
|
parsedComponents.formUnion(Set(index...(index + parsedCount)))
|
||||||
|
return .subExpression(expression)
|
||||||
|
} else if component == ")" {
|
||||||
|
bracketsBalance -= 1
|
||||||
|
if bracketsBalance < 0 {
|
||||||
|
throw TemplateSyntaxError("'if' expression error: missing opening bracket")
|
||||||
|
}
|
||||||
|
parsedComponents.insert(index)
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
parsedComponents.insert(index)
|
||||||
|
if let `operator` = findOperator(name: component) {
|
||||||
|
switch `operator` {
|
||||||
|
case .infix(let name, let bindingPower, let operatorType):
|
||||||
|
return .infix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
||||||
|
case .prefix(let name, let bindingPower, let operatorType):
|
||||||
|
return .prefix(name: name, bindingPower: bindingPower, operatorType: operatorType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .variable(try environment.compileResolvable(component, containedIn: token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func subExpression(
|
||||||
|
from components: ArraySlice<String>,
|
||||||
|
environment: Environment,
|
||||||
|
token: Token
|
||||||
|
) throws -> (Expression, Int) {
|
||||||
|
var bracketsBalance = 1
|
||||||
|
let subComponents = components.prefix { component in
|
||||||
|
if component == "(" {
|
||||||
|
bracketsBalance += 1
|
||||||
|
} else if component == ")" {
|
||||||
|
bracketsBalance -= 1
|
||||||
|
}
|
||||||
|
return bracketsBalance != 0
|
||||||
|
}
|
||||||
|
if bracketsBalance > 0 {
|
||||||
|
throw TemplateSyntaxError("'if' expression error: missing closing bracket")
|
||||||
|
}
|
||||||
|
|
||||||
|
let expressionParser = try IfExpressionParser(components: subComponents, environment: environment, token: token)
|
||||||
|
let expression = try expressionParser.parse()
|
||||||
|
return (expression, subComponents.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentToken: IfToken {
|
||||||
|
if tokens.count > position {
|
||||||
|
return tokens[position]
|
||||||
|
}
|
||||||
|
|
||||||
|
return .end
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextToken: IfToken {
|
||||||
|
position += 1
|
||||||
|
return currentToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse() throws -> Expression {
|
||||||
|
let expression = try self.expression()
|
||||||
|
|
||||||
|
if !currentToken.isEnd {
|
||||||
|
throw TemplateSyntaxError("'if' expression error: dangling token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return expression
|
||||||
|
}
|
||||||
|
|
||||||
|
func expression(bindingPower: Int = 0) throws -> Expression {
|
||||||
|
var token = currentToken
|
||||||
|
position += 1
|
||||||
|
|
||||||
|
var left = try token.nullDenotation(parser: self)
|
||||||
|
|
||||||
|
while bindingPower < currentToken.bindingPower {
|
||||||
|
token = currentToken
|
||||||
|
position += 1
|
||||||
|
left = try token.leftDenotation(left: left, parser: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an if condition and the associated nodes when the condition
|
||||||
|
/// evaluates
|
||||||
|
final class IfCondition {
|
||||||
|
let expression: Expression?
|
||||||
|
let nodes: [NodeType]
|
||||||
|
|
||||||
|
init(expression: Expression?, nodes: [NodeType]) {
|
||||||
|
self.expression = expression
|
||||||
|
self.nodes = nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
try context.push {
|
||||||
|
try renderNodes(nodes, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IfNode: NodeType {
|
||||||
|
let conditions: [IfCondition]
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
var components = token.components
|
||||||
|
components.removeFirst()
|
||||||
|
|
||||||
|
let expression = try parser.compileExpression(components: components, token: token)
|
||||||
|
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||||
|
var conditions: [IfCondition] = [
|
||||||
|
IfCondition(expression: expression, nodes: nodes)
|
||||||
|
]
|
||||||
|
|
||||||
|
var nextToken = parser.nextToken()
|
||||||
|
while let current = nextToken, current.contents.hasPrefix("elif") {
|
||||||
|
var components = current.components
|
||||||
|
components.removeFirst()
|
||||||
|
let expression = try parser.compileExpression(components: components, token: current)
|
||||||
|
|
||||||
|
let nodes = try parser.parse(until(["endif", "elif", "else"]))
|
||||||
|
nextToken = parser.nextToken()
|
||||||
|
conditions.append(IfCondition(expression: expression, nodes: nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let current = nextToken, current.contents == "else" {
|
||||||
|
conditions.append(IfCondition(expression: nil, nodes: try parser.parse(until(["endif"]))))
|
||||||
|
nextToken = parser.nextToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let current = nextToken, current.contents == "endif" else {
|
||||||
|
throw TemplateSyntaxError("`endif` was not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return IfNode(conditions: conditions, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
class func parse_ifnot(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
var components = token.components
|
||||||
|
guard components.count == 2 else {
|
||||||
|
throw TemplateSyntaxError("'ifnot' statements should use the following syntax 'ifnot condition'.")
|
||||||
|
}
|
||||||
|
components.removeFirst()
|
||||||
|
var trueNodes = [NodeType]()
|
||||||
|
var falseNodes = [NodeType]()
|
||||||
|
|
||||||
|
let expression = try parser.compileExpression(components: components, token: token)
|
||||||
|
falseNodes = try parser.parse(until(["endif", "else"]))
|
||||||
|
|
||||||
|
guard let token = parser.nextToken() else {
|
||||||
|
throw TemplateSyntaxError("`endif` was not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.contents == "else" {
|
||||||
|
trueNodes = try parser.parse(until(["endif"]))
|
||||||
|
_ = parser.nextToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return IfNode(conditions: [
|
||||||
|
IfCondition(expression: expression, nodes: trueNodes),
|
||||||
|
IfCondition(expression: nil, nodes: falseNodes)
|
||||||
|
], token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(conditions: [IfCondition], token: Token? = nil) {
|
||||||
|
self.conditions = conditions
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
for condition in conditions {
|
||||||
|
if let expression = condition.expression {
|
||||||
|
let truthy = try expression.evaluate(context: context)
|
||||||
|
|
||||||
|
if truthy {
|
||||||
|
return try condition.render(context)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return try condition.render(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Sources/Stencil/Include.swift
Normal file
48
Sources/Stencil/Include.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
class IncludeNode: NodeType {
|
||||||
|
let templateName: Variable
|
||||||
|
let includeContext: String?
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
let bits = token.components
|
||||||
|
|
||||||
|
guard bits.count == 2 || bits.count == 3 else {
|
||||||
|
throw TemplateSyntaxError(
|
||||||
|
"""
|
||||||
|
'include' tag requires one argument, the template file to be included. \
|
||||||
|
A second optional argument can be used to specify the context that will \
|
||||||
|
be passed to the included file
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return IncludeNode(templateName: Variable(bits[1]), includeContext: bits.count == 3 ? bits[2] : nil, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(templateName: Variable, includeContext: String? = nil, token: Token) {
|
||||||
|
self.templateName = templateName
|
||||||
|
self.includeContext = includeContext
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
guard let templateName = try self.templateName.resolve(context) as? String else {
|
||||||
|
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = try context.environment.loadTemplate(name: templateName)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let subContext = includeContext.flatMap { context[$0] as? [String: Any] } ?? [:]
|
||||||
|
return try context.push(dictionary: subContext) {
|
||||||
|
try template.render(context)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if let error = error as? TemplateSyntaxError {
|
||||||
|
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
Sources/Stencil/Inheritance.swift
Normal file
158
Sources/Stencil/Inheritance.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
class BlockContext {
|
||||||
|
class var contextKey: String { "block_context" }
|
||||||
|
|
||||||
|
// contains mapping of block names to their nodes and templates where they are defined
|
||||||
|
var blocks: [String: [BlockNode]]
|
||||||
|
|
||||||
|
init(blocks: [String: BlockNode]) {
|
||||||
|
self.blocks = [:]
|
||||||
|
blocks.forEach { self.blocks[$0.key] = [$0.value] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func push(_ block: BlockNode, forKey blockName: String) {
|
||||||
|
if var blocks = blocks[blockName] {
|
||||||
|
blocks.append(block)
|
||||||
|
self.blocks[blockName] = blocks
|
||||||
|
} else {
|
||||||
|
self.blocks[blockName] = [block]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pop(_ blockName: String) -> BlockNode? {
|
||||||
|
if var blocks = blocks[blockName] {
|
||||||
|
let block = blocks.removeFirst()
|
||||||
|
if blocks.isEmpty {
|
||||||
|
self.blocks.removeValue(forKey: blockName)
|
||||||
|
} else {
|
||||||
|
self.blocks[blockName] = blocks
|
||||||
|
}
|
||||||
|
return block
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Collection {
|
||||||
|
func any(_ closure: (Iterator.Element) -> Bool) -> Iterator.Element? {
|
||||||
|
for element in self where closure(element) {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExtendsNode: NodeType {
|
||||||
|
let templateName: Variable
|
||||||
|
let blocks: [String: BlockNode]
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
let bits = token.components
|
||||||
|
|
||||||
|
guard bits.count == 2 else {
|
||||||
|
throw TemplateSyntaxError("'extends' takes one argument, the template file to be extended")
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedNodes = try parser.parse()
|
||||||
|
guard (parsedNodes.any { $0 is Self }) == nil else {
|
||||||
|
throw TemplateSyntaxError("'extends' cannot appear more than once in the same template")
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockNodes = parsedNodes.compactMap { $0 as? BlockNode }
|
||||||
|
let nodes = blockNodes.reduce(into: [String: BlockNode]()) { accumulator, node in
|
||||||
|
accumulator[node.name] = node
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtendsNode(templateName: Variable(bits[1]), blocks: nodes, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(templateName: Variable, blocks: [String: BlockNode], token: Token) {
|
||||||
|
self.templateName = templateName
|
||||||
|
self.blocks = blocks
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
guard let templateName = try self.templateName.resolve(context) as? String else {
|
||||||
|
throw TemplateSyntaxError("'\(self.templateName)' could not be resolved as a string")
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseTemplate = try context.environment.loadTemplate(name: templateName)
|
||||||
|
|
||||||
|
let blockContext: BlockContext
|
||||||
|
if let currentBlockContext = context[BlockContext.contextKey] as? BlockContext {
|
||||||
|
blockContext = currentBlockContext
|
||||||
|
for (name, block) in blocks {
|
||||||
|
blockContext.push(block, forKey: name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blockContext = BlockContext(blocks: blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// pushes base template and renders it's content
|
||||||
|
// block_context contains all blocks from child templates
|
||||||
|
return try context.push(dictionary: [BlockContext.contextKey: blockContext]) {
|
||||||
|
try baseTemplate.render(context)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// if error template is already set (see catch in BlockNode)
|
||||||
|
// and it happend in the same template as current template
|
||||||
|
// there is no need to wrap it in another error
|
||||||
|
if let error = error as? TemplateSyntaxError, error.templateName != token?.sourceMap.filename {
|
||||||
|
throw TemplateSyntaxError(reason: error.reason, stackTrace: error.allTokens)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockNode: NodeType {
|
||||||
|
let name: String
|
||||||
|
let nodes: [NodeType]
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
let bits = token.components
|
||||||
|
|
||||||
|
guard bits.count == 2 else {
|
||||||
|
throw TemplateSyntaxError("'block' tag takes one argument, the block name")
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockName = bits[1]
|
||||||
|
let nodes = try parser.parse(until(["endblock"]))
|
||||||
|
_ = parser.nextToken()
|
||||||
|
return BlockNode(name: blockName, nodes: nodes, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(name: String, nodes: [NodeType], token: Token) {
|
||||||
|
self.name = name
|
||||||
|
self.nodes = nodes
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
if let blockContext = context[BlockContext.contextKey] as? BlockContext, let child = blockContext.pop(name) {
|
||||||
|
let childContext: [String: Any] = [
|
||||||
|
BlockContext.contextKey: blockContext,
|
||||||
|
"block": ["super": try self.render(context)]
|
||||||
|
]
|
||||||
|
|
||||||
|
// render extension node
|
||||||
|
do {
|
||||||
|
return try context.push(dictionary: childContext) {
|
||||||
|
try child.render(context)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw error.withToken(child.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = try renderNodes(nodes, context)
|
||||||
|
context.cacheBlock(name, content: result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
112
Sources/Stencil/KeyPath.swift
Normal file
112
Sources/Stencil/KeyPath.swift
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A structure used to represent a template variable, and to resolve it in a given context.
|
||||||
|
final class KeyPath {
|
||||||
|
private var components = [String]()
|
||||||
|
private var current = ""
|
||||||
|
private var partialComponents = [String]()
|
||||||
|
private var subscriptLevel = 0
|
||||||
|
|
||||||
|
let variable: String
|
||||||
|
let context: Context
|
||||||
|
|
||||||
|
// Split the keypath string and resolve references if possible
|
||||||
|
init(_ variable: String, in context: Context) {
|
||||||
|
self.variable = variable
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse() throws -> [String] {
|
||||||
|
defer {
|
||||||
|
components = []
|
||||||
|
current = ""
|
||||||
|
partialComponents = []
|
||||||
|
subscriptLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for character in variable {
|
||||||
|
switch character {
|
||||||
|
case "." where subscriptLevel == 0:
|
||||||
|
try foundSeparator()
|
||||||
|
case "[":
|
||||||
|
try openBracket()
|
||||||
|
case "]":
|
||||||
|
try closeBracket()
|
||||||
|
default:
|
||||||
|
try addCharacter(character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try finish()
|
||||||
|
|
||||||
|
return components
|
||||||
|
}
|
||||||
|
|
||||||
|
private func foundSeparator() throws {
|
||||||
|
if !current.isEmpty {
|
||||||
|
partialComponents.append(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !partialComponents.isEmpty else {
|
||||||
|
throw TemplateSyntaxError("Unexpected '.' in variable '\(variable)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
components += partialComponents
|
||||||
|
current = ""
|
||||||
|
partialComponents = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// when opening the first bracket, we must have a partial component
|
||||||
|
private func openBracket() throws {
|
||||||
|
guard !partialComponents.isEmpty || !current.isEmpty else {
|
||||||
|
throw TemplateSyntaxError("Unexpected '[' in variable '\(variable)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if subscriptLevel > 0 {
|
||||||
|
current.append("[")
|
||||||
|
} else if !current.isEmpty {
|
||||||
|
partialComponents.append(current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptLevel += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// for a closing bracket at root level, try to resolve the reference
|
||||||
|
private func closeBracket() throws {
|
||||||
|
guard subscriptLevel > 0 else {
|
||||||
|
throw TemplateSyntaxError("Unbalanced ']' in variable '\(variable)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if subscriptLevel > 1 {
|
||||||
|
current.append("]")
|
||||||
|
} else if !current.isEmpty,
|
||||||
|
let value = try Variable(current).resolve(context) {
|
||||||
|
partialComponents.append("\(value)")
|
||||||
|
current = ""
|
||||||
|
} else {
|
||||||
|
throw TemplateSyntaxError("Unable to resolve subscript '\(current)' in variable '\(variable)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptLevel -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addCharacter(_ character: Character) throws {
|
||||||
|
guard partialComponents.isEmpty || subscriptLevel > 0 else {
|
||||||
|
throw TemplateSyntaxError("Unexpected character '\(character)' in variable '\(variable)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
current.append(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish() throws {
|
||||||
|
// check if we have a last piece
|
||||||
|
if !current.isEmpty {
|
||||||
|
partialComponents.append(current)
|
||||||
|
}
|
||||||
|
components += partialComponents
|
||||||
|
|
||||||
|
guard subscriptLevel == 0 else {
|
||||||
|
throw TemplateSyntaxError("Unbalanced subscript brackets in variable '\(variable)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Sources/Stencil/LazyValueWrapper.swift
Normal file
57
Sources/Stencil/LazyValueWrapper.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/// Used to lazily set context data. Useful for example if you have some data that requires heavy calculations, and may
|
||||||
|
/// not be used in every render possiblity.
|
||||||
|
public final class LazyValueWrapper {
|
||||||
|
private let closure: (Context) throws -> Any
|
||||||
|
private let context: Context?
|
||||||
|
private var cachedValue: Any?
|
||||||
|
|
||||||
|
/// Create a wrapper that'll use a **reference** to the current context.
|
||||||
|
/// This means when the closure is evaluated, it'll use the **active** context at that moment.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - closure: The closure to lazily evaluate
|
||||||
|
public init(closure: @escaping (Context) throws -> Any) {
|
||||||
|
self.context = nil
|
||||||
|
self.closure = closure
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a wrapper that'll create a **copy** of the current context.
|
||||||
|
/// This means when the closure is evaluated, it'll use the context **as it was** when this wrapper was created.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - context: The context to use during evaluation
|
||||||
|
/// - closure: The closure to lazily evaluate
|
||||||
|
/// - Note: This will use more memory than the other `init` as it needs to keep a copy of the full context around.
|
||||||
|
public init(copying context: Context, closure: @escaping (Context) throws -> Any) {
|
||||||
|
self.context = Context(dictionaries: context.dictionaries, environment: context.environment)
|
||||||
|
self.closure = closure
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shortcut for creating a lazy wrapper when you don't need access to the Stencil context.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - closure: The closure to lazily evaluate
|
||||||
|
public init(_ closure: @autoclosure @escaping () throws -> Any) {
|
||||||
|
self.context = nil
|
||||||
|
self.closure = { _ in try closure() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LazyValueWrapper {
|
||||||
|
func value(context: Context) throws -> Any {
|
||||||
|
if let value = cachedValue {
|
||||||
|
return value
|
||||||
|
} else {
|
||||||
|
let value = try closure(self.context ?? context)
|
||||||
|
cachedValue = value
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LazyValueWrapper: Resolvable {
|
||||||
|
public func resolve(_ context: Context) throws -> Any? {
|
||||||
|
let value = try self.value(context: context)
|
||||||
|
return try (value as? Resolvable)?.resolve(context) ?? value
|
||||||
|
}
|
||||||
|
}
|
||||||
265
Sources/Stencil/Lexer.swift
Normal file
265
Sources/Stencil/Lexer.swift
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// swiftlint:disable large_tuple
|
||||||
|
typealias Line = (content: String, number: UInt, range: Range<String.Index>)
|
||||||
|
/// Location in some content (text)
|
||||||
|
public typealias ContentLocation = (content: String, lineNumber: UInt, lineOffset: Int)
|
||||||
|
// swiftlint:enable large_tuple
|
||||||
|
|
||||||
|
struct Lexer {
|
||||||
|
let templateName: String?
|
||||||
|
let templateString: String
|
||||||
|
let lines: [Line]
|
||||||
|
|
||||||
|
/// The potential token start characters. In a template these appear after a
|
||||||
|
/// `{` character, for example `{{`, `{%`, `{#`, ...
|
||||||
|
private static let tokenChars: [Unicode.Scalar] = ["{", "%", "#"]
|
||||||
|
|
||||||
|
/// The minimum length of a tag
|
||||||
|
private static let tagLength = 2
|
||||||
|
|
||||||
|
/// The token end characters, corresponding to their token start characters.
|
||||||
|
/// For example, a variable token starts with `{{` and ends with `}}`
|
||||||
|
private static let tokenCharMap: [Unicode.Scalar: Unicode.Scalar] = [
|
||||||
|
"{": "}",
|
||||||
|
"%": "%",
|
||||||
|
"#": "#"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Characters controlling whitespace trimming behaviour
|
||||||
|
private static let behaviourMap: [Character: WhitespaceBehaviour.Behaviour] = [
|
||||||
|
"+": .keep,
|
||||||
|
"-": .trim
|
||||||
|
]
|
||||||
|
|
||||||
|
init(templateName: String? = nil, templateString: String) {
|
||||||
|
self.templateName = templateName
|
||||||
|
self.templateString = templateString
|
||||||
|
|
||||||
|
self.lines = zip(1..., templateString.components(separatedBy: .newlines)).compactMap { index, line in
|
||||||
|
guard !line.isEmpty,
|
||||||
|
let range = templateString.range(of: line) else { return nil }
|
||||||
|
return (content: line, number: UInt(index), range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func behaviour(string: String, tagLength: Int) -> WhitespaceBehaviour {
|
||||||
|
let leftIndex = string.index(string.startIndex, offsetBy: tagLength, limitedBy: string.endIndex)
|
||||||
|
let rightIndex = string.index(string.endIndex, offsetBy: -(tagLength + 1), limitedBy: string.startIndex)
|
||||||
|
|
||||||
|
return WhitespaceBehaviour(
|
||||||
|
leading: Self.behaviourMap[leftIndex.map { string[$0] } ?? " "] ?? .unspecified,
|
||||||
|
trailing: Self.behaviourMap[rightIndex.map { string[$0] } ?? " "] ?? .unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a token that will be passed on to the parser, with the given
|
||||||
|
/// content and a range. The content will be tested to see if it's a
|
||||||
|
/// `variable`, a `block` or a `comment`, otherwise it'll default to a simple
|
||||||
|
/// `text` token.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - string: The content string of the token
|
||||||
|
/// - range: The range within the template content, used for smart
|
||||||
|
/// error reporting
|
||||||
|
func createToken(string: String, at range: Range<String.Index>, _ isInEscapeMode: Bool = false) -> Token {
|
||||||
|
func strip(length: (Int, Int) = (Self.tagLength, Self.tagLength)) -> String {
|
||||||
|
guard string.count > (length.0 + length.1) else { return "" }
|
||||||
|
let trimmed = String(string.dropFirst(length.0).dropLast(length.1))
|
||||||
|
.components(separatedBy: "\n")
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.map { $0.trim(character: " ") }
|
||||||
|
.joined(separator: " ")
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isInEscapeMode && (string.hasPrefix("{{") || string.hasPrefix("{%") || string.hasPrefix("{#")) {
|
||||||
|
let behaviour = string.hasPrefix("{%") ? behaviour(string: string, tagLength: Self.tagLength) : .unspecified
|
||||||
|
let stripLengths = (
|
||||||
|
Self.tagLength + (behaviour.leading != .unspecified ? 1 : 0),
|
||||||
|
Self.tagLength + (behaviour.trailing != .unspecified ? 1 : 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
let value = strip(length: stripLengths)
|
||||||
|
let range = templateString.range(of: value, range: range) ?? range
|
||||||
|
let location = rangeLocation(range)
|
||||||
|
let sourceMap = SourceMap(filename: templateName, location: location)
|
||||||
|
|
||||||
|
if string.hasPrefix("{{") {
|
||||||
|
return .variable(value: value, at: sourceMap)
|
||||||
|
} else if string.hasPrefix("{%") {
|
||||||
|
return .block(value: strip(length: stripLengths), at: sourceMap, whitespace: behaviour)
|
||||||
|
} else if string.hasPrefix("{#") {
|
||||||
|
return .comment(value: value, at: sourceMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let location = rangeLocation(range)
|
||||||
|
let sourceMap = SourceMap(filename: templateName, location: location)
|
||||||
|
return .text(value: string, at: sourceMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms the template into a list of tokens, that will eventually be
|
||||||
|
/// passed on to the parser.
|
||||||
|
///
|
||||||
|
/// - Returns: The list of tokens (see `createToken(string: at:)`).
|
||||||
|
func tokenize() -> [Token] {
|
||||||
|
var tokens: [Token] = []
|
||||||
|
|
||||||
|
let scanner = Scanner(templateString)
|
||||||
|
while !scanner.isEmpty {
|
||||||
|
if let (char, text, isInEscapeMode) = scanner.scanForTokenStart(Self.tokenChars) {
|
||||||
|
if !text.isEmpty {
|
||||||
|
tokens.append(createToken(string: text, at: scanner.range))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let end = Self.tokenCharMap[char] else { continue }
|
||||||
|
let result = scanner.scanForTokenEnd(end)
|
||||||
|
tokens.append(createToken(string: result, at: scanner.range, isInEscapeMode))
|
||||||
|
} else {
|
||||||
|
tokens.append(createToken(string: scanner.content, at: scanner.range))
|
||||||
|
scanner.content = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the line matching the given range (for a token)
|
||||||
|
///
|
||||||
|
/// - Parameter range: The range to search for.
|
||||||
|
/// - Returns: The content for that line, the line number and offset within
|
||||||
|
/// the line.
|
||||||
|
func rangeLocation(_ range: Range<String.Index>) -> ContentLocation {
|
||||||
|
guard let line = self.lines.first(where: { $0.range.contains(range.lowerBound) }) else {
|
||||||
|
return ("", 0, 0)
|
||||||
|
}
|
||||||
|
let offset = templateString.distance(from: line.range.lowerBound, to: range.lowerBound)
|
||||||
|
return (line.content, line.number, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Scanner {
|
||||||
|
let originalContent: String
|
||||||
|
var content: String
|
||||||
|
var range: Range<String.UnicodeScalarView.Index>
|
||||||
|
|
||||||
|
/// The start delimiter for a token.
|
||||||
|
private static let tokenStartDelimiter: Unicode.Scalar = "{"
|
||||||
|
/// And the corresponding end delimiter for a token.
|
||||||
|
private static let tokenEndDelimiter: Unicode.Scalar = "}"
|
||||||
|
private static let tokenDelimiterEscape: Unicode.Scalar = "\\"
|
||||||
|
|
||||||
|
init(_ content: String) {
|
||||||
|
self.originalContent = content
|
||||||
|
self.content = content
|
||||||
|
range = content.unicodeScalars.startIndex..<content.unicodeScalars.startIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
content.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans for the end of a token, with a specific ending character. If we're
|
||||||
|
/// searching for the end of a block token `%}`, this method receives a `%`.
|
||||||
|
/// The scanner will search for that `%` followed by a `}`.
|
||||||
|
///
|
||||||
|
/// Note: if the end of a token is found, the `content` and `range`
|
||||||
|
/// properties are updated to reflect this. `content` will be set to what
|
||||||
|
/// remains of the template after the token. `range` will be set to the range
|
||||||
|
/// of the token within the template.
|
||||||
|
///
|
||||||
|
/// - Parameter tokenChar: The token end character to search for.
|
||||||
|
/// - Returns: The content of a token, or "" if no token end was found.
|
||||||
|
func scanForTokenEnd(_ tokenChar: Unicode.Scalar) -> String {
|
||||||
|
var foundChar = false
|
||||||
|
|
||||||
|
for (index, char) in zip(0..., content.unicodeScalars) {
|
||||||
|
if foundChar && char == Self.tokenEndDelimiter {
|
||||||
|
let result = String(content.unicodeScalars.prefix(index + 1))
|
||||||
|
content = String(content.unicodeScalars.dropFirst(index + 1))
|
||||||
|
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index + 1)
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
foundChar = (char == tokenChar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans for the start of a token, with a list of potential starting
|
||||||
|
/// characters. To scan for the start of variables (`{{`), blocks (`{%`) and
|
||||||
|
/// comments (`{#`), this method receives the characters `{`, `%` and `#`.
|
||||||
|
/// The scanner will search for a `{`, followed by one of the search
|
||||||
|
/// characters. It will give the found character, and the content that came
|
||||||
|
/// before the token.
|
||||||
|
///
|
||||||
|
/// Note: if the start of a token is found, the `content` and `range`
|
||||||
|
/// properties are updated to reflect this. `content` will be set to what
|
||||||
|
/// remains of the template starting with the token. `range` will be set to
|
||||||
|
/// the start of the token within the template.
|
||||||
|
///
|
||||||
|
/// - Parameter tokenChars: List of token start characters to search for.
|
||||||
|
/// - Returns: The found token start character, together with the content
|
||||||
|
/// before the token, or nil of no token start was found.
|
||||||
|
// swiftlint:disable:next large_tuple
|
||||||
|
func scanForTokenStart(_ tokenChars: [Unicode.Scalar]) -> (Unicode.Scalar, String, Bool)? {
|
||||||
|
var foundBrace = false
|
||||||
|
var isInEscapeMode = false
|
||||||
|
var lastChar: Unicode.Scalar = " "
|
||||||
|
|
||||||
|
range = range.upperBound..<range.upperBound
|
||||||
|
for (index, char) in zip(0..., content.unicodeScalars) {
|
||||||
|
if foundBrace && tokenChars.contains(char) {
|
||||||
|
let prefixOffset = isInEscapeMode ? 1 : 0
|
||||||
|
let prefix = String(content.unicodeScalars.prefix(index - 1 - prefixOffset))
|
||||||
|
content = String(content.unicodeScalars.dropFirst(index - 1))
|
||||||
|
range = range.upperBound..<originalContent.unicodeScalars.index(range.upperBound, offsetBy: index - 1)
|
||||||
|
|
||||||
|
return (char, prefix, isInEscapeMode)
|
||||||
|
} else {
|
||||||
|
foundBrace = (char == Self.tokenStartDelimiter)
|
||||||
|
isInEscapeMode = (lastChar == Self.tokenDelimiterEscape)
|
||||||
|
lastChar = char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
func findFirstNot(character: Character) -> String.Index? {
|
||||||
|
var index = startIndex
|
||||||
|
|
||||||
|
while index != endIndex {
|
||||||
|
if character != self[index] {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
index = self.index(after: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLastNot(character: Character) -> String.Index? {
|
||||||
|
var index = self.index(before: endIndex)
|
||||||
|
|
||||||
|
while index != startIndex {
|
||||||
|
if character != self[index] {
|
||||||
|
return self.index(after: index)
|
||||||
|
}
|
||||||
|
index = self.index(before: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trim(character: Character) -> String {
|
||||||
|
let first = findFirstNot(character: character) ?? startIndex
|
||||||
|
let last = findLastNot(character: character) ?? endIndex
|
||||||
|
return String(self[first..<last])
|
||||||
|
}
|
||||||
|
}
|
||||||
128
Sources/Stencil/Loader.swift
Normal file
128
Sources/Stencil/Loader.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import Foundation
|
||||||
|
import PathKit
|
||||||
|
|
||||||
|
/// Type used for loading a template
|
||||||
|
public protocol Loader {
|
||||||
|
/// Load a template with the given name
|
||||||
|
func loadTemplate(name: String, environment: Environment) throws -> Template
|
||||||
|
/// Load a template with the given list of names
|
||||||
|
func loadTemplate(names: [String], environment: Environment) throws -> Template
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Loader {
|
||||||
|
/// Default implementation, tries to load the first template that exists from the list of given names
|
||||||
|
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||||
|
for name in names {
|
||||||
|
do {
|
||||||
|
return try loadTemplate(name: name, environment: environment)
|
||||||
|
} catch is TemplateDoesNotExist {
|
||||||
|
continue
|
||||||
|
} catch {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A class for loading a template from disk
|
||||||
|
public class FileSystemLoader: Loader, CustomStringConvertible {
|
||||||
|
public let paths: [Path]
|
||||||
|
|
||||||
|
public init(paths: [Path]) {
|
||||||
|
self.paths = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(bundle: [Bundle]) {
|
||||||
|
self.paths = bundle.compactMap { bundle in
|
||||||
|
Path(bundle.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
"FileSystemLoader(\(paths))"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||||
|
for path in paths {
|
||||||
|
let templatePath = try path.safeJoin(path: name)
|
||||||
|
|
||||||
|
if !templatePath.exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: String = try String(contentsOf: templatePath)
|
||||||
|
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||||
|
for path in paths {
|
||||||
|
for templateName in names {
|
||||||
|
let templatePath = try path.safeJoin(path: templateName)
|
||||||
|
|
||||||
|
if templatePath.exists {
|
||||||
|
let content: String = try String(contentsOf: templatePath)
|
||||||
|
return environment.templateClass.init(templateString: content, environment: environment, name: templateName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DictionaryLoader: Loader {
|
||||||
|
public let templates: [String: String]
|
||||||
|
|
||||||
|
public init(templates: [String: String]) {
|
||||||
|
self.templates = templates
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||||
|
if let content = templates[name] {
|
||||||
|
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadTemplate(names: [String], environment: Environment) throws -> Template {
|
||||||
|
for name in names {
|
||||||
|
if let content = templates[name] {
|
||||||
|
return environment.templateClass.init(templateString: content, environment: environment, name: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TemplateDoesNotExist(templateNames: names, loader: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Path {
|
||||||
|
func safeJoin(path: String) throws -> Path {
|
||||||
|
let newPath = self / path
|
||||||
|
|
||||||
|
if !newPath.string.hasPrefix(self.string) {
|
||||||
|
throw SuspiciousFileOperation(basePath: self, path: newPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SuspiciousFileOperation: Error {
|
||||||
|
let basePath: Path
|
||||||
|
let path: Path
|
||||||
|
|
||||||
|
init(basePath: Path, path: Path) {
|
||||||
|
self.basePath = basePath
|
||||||
|
self.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"Path `\(path)` is located outside of base path `\(basePath)`"
|
||||||
|
}
|
||||||
|
}
|
||||||
185
Sources/Stencil/Node.swift
Normal file
185
Sources/Stencil/Node.swift
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a parsed node
|
||||||
|
public protocol NodeType {
|
||||||
|
/// Render the node in the given context
|
||||||
|
func render(_ context: Context) throws -> String
|
||||||
|
|
||||||
|
/// Reference to this node's token
|
||||||
|
var token: Token? { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the collection of nodes in the given context
|
||||||
|
public func renderNodes(_ nodes: [NodeType], _ context: Context) throws -> String {
|
||||||
|
var result = ""
|
||||||
|
|
||||||
|
for node in nodes {
|
||||||
|
do {
|
||||||
|
result += try node.render(context)
|
||||||
|
} catch {
|
||||||
|
throw error.withToken(node.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldBreak = context[LoopTerminationNode.breakContextKey] != nil
|
||||||
|
let shouldContinue = context[LoopTerminationNode.continueContextKey] != nil
|
||||||
|
|
||||||
|
if shouldBreak || shouldContinue {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple node, used for triggering a closure during rendering
|
||||||
|
public class SimpleNode: NodeType {
|
||||||
|
public let handler: (Context) throws -> String
|
||||||
|
public let token: Token?
|
||||||
|
|
||||||
|
public init(token: Token, handler: @escaping (Context) throws -> String) {
|
||||||
|
self.token = token
|
||||||
|
self.handler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
public func render(_ context: Context) throws -> String {
|
||||||
|
try handler(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a block of text, renders the text
|
||||||
|
public class TextNode: NodeType {
|
||||||
|
public let text: String
|
||||||
|
public let token: Token?
|
||||||
|
public let trimBehaviour: TrimBehaviour
|
||||||
|
|
||||||
|
public init(text: String, trimBehaviour: TrimBehaviour = .nothing) {
|
||||||
|
self.text = text
|
||||||
|
self.token = nil
|
||||||
|
self.trimBehaviour = trimBehaviour
|
||||||
|
}
|
||||||
|
|
||||||
|
public func render(_ context: Context) throws -> String {
|
||||||
|
var string = self.text
|
||||||
|
if trimBehaviour.leading != .nothing, !string.isEmpty {
|
||||||
|
let range = NSRange(..<string.endIndex, in: string)
|
||||||
|
string = TrimBehaviour.leadingRegex(trim: trimBehaviour.leading)
|
||||||
|
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||||
|
}
|
||||||
|
if trimBehaviour.trailing != .nothing, !string.isEmpty {
|
||||||
|
let range = NSRange(..<string.endIndex, in: string)
|
||||||
|
string = TrimBehaviour.trailingRegex(trim: trimBehaviour.trailing)
|
||||||
|
.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||||
|
}
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Representing something that can be resolved in a context
|
||||||
|
public protocol Resolvable {
|
||||||
|
/// Try to resolve this with the given context
|
||||||
|
func resolve(_ context: Context) throws -> Any?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a variable, renders the variable, may have conditional expressions.
|
||||||
|
public class VariableNode: NodeType {
|
||||||
|
public let variable: Resolvable
|
||||||
|
public var token: Token?
|
||||||
|
let condition: Expression?
|
||||||
|
let elseExpression: Resolvable?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
let components = token.components
|
||||||
|
|
||||||
|
func hasToken(_ token: String, at index: Int) -> Bool {
|
||||||
|
components.count > (index + 1) && components[index] == token
|
||||||
|
}
|
||||||
|
func compileResolvable(_ components: [String], containedIn token: Token) throws -> Resolvable {
|
||||||
|
try (try? parser.compileExpression(components: components, token: token)) ??
|
||||||
|
parser.compileFilter(components.joined(separator: " "), containedIn: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
let variable: Resolvable
|
||||||
|
let condition: Expression?
|
||||||
|
let elseExpression: Resolvable?
|
||||||
|
|
||||||
|
if hasToken("if", at: 1) {
|
||||||
|
variable = try compileResolvable([components[0]], containedIn: token)
|
||||||
|
|
||||||
|
let components = components.suffix(from: 2)
|
||||||
|
if let elseIndex = components.firstIndex(of: "else") {
|
||||||
|
condition = try parser.compileExpression(components: Array(components.prefix(upTo: elseIndex)), token: token)
|
||||||
|
let elseToken = Array(components.suffix(from: elseIndex.advanced(by: 1)))
|
||||||
|
elseExpression = try compileResolvable(elseToken, containedIn: token)
|
||||||
|
} else {
|
||||||
|
condition = try parser.compileExpression(components: Array(components), token: token)
|
||||||
|
elseExpression = nil
|
||||||
|
}
|
||||||
|
} else if !components.isEmpty {
|
||||||
|
variable = try compileResolvable(components, containedIn: token)
|
||||||
|
condition = nil
|
||||||
|
elseExpression = nil
|
||||||
|
} else {
|
||||||
|
throw TemplateSyntaxError(reason: "Missing variable name", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return VariableNode(variable: variable, token: token, condition: condition, elseExpression: elseExpression)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(variable: Resolvable, token: Token? = nil) {
|
||||||
|
self.variable = variable
|
||||||
|
self.token = token
|
||||||
|
self.condition = nil
|
||||||
|
self.elseExpression = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(variable: Resolvable, token: Token? = nil, condition: Expression?, elseExpression: Resolvable?) {
|
||||||
|
self.variable = variable
|
||||||
|
self.token = token
|
||||||
|
self.condition = condition
|
||||||
|
self.elseExpression = elseExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(variable: String, token: Token? = nil) {
|
||||||
|
self.variable = Variable(variable)
|
||||||
|
self.token = token
|
||||||
|
self.condition = nil
|
||||||
|
self.elseExpression = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func render(_ context: Context) throws -> String {
|
||||||
|
if let condition = self.condition, try condition.evaluate(context: context) == false {
|
||||||
|
return try elseExpression?.resolve(context).map(stringify) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = try variable.resolve(context)
|
||||||
|
return stringify(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringify(_ result: Any?) -> String {
|
||||||
|
if let result = result as? String {
|
||||||
|
return result
|
||||||
|
} else if let array = result as? [Any?] {
|
||||||
|
return unwrap(array).description
|
||||||
|
} else if let result = result as? CustomStringConvertible {
|
||||||
|
return result.description
|
||||||
|
} else if let result = result as? NSObject {
|
||||||
|
return result.description
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrap(_ array: [Any?]) -> [Any] {
|
||||||
|
array.map { (item: Any?) -> Any in
|
||||||
|
if let item = item {
|
||||||
|
if let items = item as? [Any?] {
|
||||||
|
return unwrap(items)
|
||||||
|
} else {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return item as Any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Sources/Stencil/NowTag.swift
Normal file
44
Sources/Stencil/NowTag.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#if !os(Linux)
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class NowNode: NodeType {
|
||||||
|
let format: Variable
|
||||||
|
let token: Token?
|
||||||
|
|
||||||
|
class func parse(_ parser: TokenParser, token: Token) throws -> NodeType {
|
||||||
|
var format: Variable?
|
||||||
|
|
||||||
|
let components = token.components
|
||||||
|
guard components.count <= 2 else {
|
||||||
|
throw TemplateSyntaxError("'now' tags may only have one argument: the format string.")
|
||||||
|
}
|
||||||
|
if components.count == 2 {
|
||||||
|
format = Variable(components[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return NowNode(format: format, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(format: Variable?, token: Token? = nil) {
|
||||||
|
self.format = format ?? Variable("\"yyyy-MM-dd 'at' HH:mm\"")
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
let date = Date()
|
||||||
|
let format = try self.format.resolve(context)
|
||||||
|
|
||||||
|
var formatter: DateFormatter
|
||||||
|
if let format = format as? DateFormatter {
|
||||||
|
formatter = format
|
||||||
|
} else if let format = format as? String {
|
||||||
|
formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = format
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
272
Sources/Stencil/Parser.swift
Normal file
272
Sources/Stencil/Parser.swift
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/// Creates a checker that will stop parsing if it encounters a list of tags.
|
||||||
|
/// Useful for example for scanning until a given "end"-node.
|
||||||
|
public func until(_ tags: [String]) -> ((TokenParser, Token) -> Bool) {
|
||||||
|
{ _, token in
|
||||||
|
if let name = token.components.first {
|
||||||
|
for tag in tags where name == tag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A class for parsing an array of tokens and converts them into a collection of Node's
|
||||||
|
public class TokenParser {
|
||||||
|
/// Parser for finding a kind of node
|
||||||
|
public typealias TagParser = (TokenParser, Token) throws -> NodeType
|
||||||
|
|
||||||
|
fileprivate var tokens: [Token]
|
||||||
|
fileprivate(set) var parsedTokens: [Token] = []
|
||||||
|
fileprivate let environment: Environment
|
||||||
|
fileprivate var previousWhiteSpace: WhitespaceBehaviour.Behaviour?
|
||||||
|
|
||||||
|
/// Simple initializer
|
||||||
|
public init(tokens: [Token], environment: Environment) {
|
||||||
|
self.tokens = tokens
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the given tokens into nodes
|
||||||
|
public func parse() throws -> [NodeType] {
|
||||||
|
try parse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse nodes until a specific "something" is detected, determined by the provided closure.
|
||||||
|
/// Combine this with the `until(:)` function above to scan nodes until a given token.
|
||||||
|
public func parse(_ parseUntil: ((_ parser: TokenParser, _ token: Token) -> (Bool))?) throws -> [NodeType] {
|
||||||
|
var nodes = [NodeType]()
|
||||||
|
|
||||||
|
while !tokens.isEmpty {
|
||||||
|
guard let token = nextToken() else { break }
|
||||||
|
|
||||||
|
switch token.kind {
|
||||||
|
case .text:
|
||||||
|
nodes.append(TextNode(text: token.contents, trimBehaviour: trimBehaviour))
|
||||||
|
case .variable:
|
||||||
|
previousWhiteSpace = nil
|
||||||
|
try nodes.append(VariableNode.parse(self, token: token))
|
||||||
|
case .block:
|
||||||
|
previousWhiteSpace = token.whitespace?.trailing
|
||||||
|
if let parseUntil = parseUntil, parseUntil(self, token) {
|
||||||
|
prependToken(token)
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
if var tag = token.components.first {
|
||||||
|
do {
|
||||||
|
// special case for labeled tags (such as for loops)
|
||||||
|
if tag.hasSuffix(":") && token.components.count >= 2 {
|
||||||
|
tag = token.components[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
let parser = try environment.findTag(name: tag)
|
||||||
|
let node = try parser(self, token)
|
||||||
|
nodes.append(node)
|
||||||
|
} catch {
|
||||||
|
throw error.withToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .comment:
|
||||||
|
previousWhiteSpace = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the next token (returning it)
|
||||||
|
public func nextToken() -> Token? {
|
||||||
|
if !tokens.isEmpty {
|
||||||
|
let nextToken = tokens.remove(at: 0)
|
||||||
|
parsedTokens.append(nextToken)
|
||||||
|
return nextToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func peekWhitespace() -> WhitespaceBehaviour.Behaviour? {
|
||||||
|
tokens.first?.whitespace?.leading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a token
|
||||||
|
public func prependToken(_ token: Token) {
|
||||||
|
tokens.insert(token, at: 0)
|
||||||
|
if parsedTokens.last == token {
|
||||||
|
parsedTokens.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create filter expression from a string contained in provided token
|
||||||
|
public func compileFilter(_ filterToken: String, containedIn token: Token) throws -> Resolvable {
|
||||||
|
try environment.compileFilter(filterToken, containedIn: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create boolean expression from components contained in provided token
|
||||||
|
public func compileExpression(components: [String], token: Token) throws -> Expression {
|
||||||
|
try environment.compileExpression(components: components, containedIn: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
|
||||||
|
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||||
|
try environment.compileResolvable(token, containedIn: containingToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trimBehaviour: TrimBehaviour {
|
||||||
|
var behaviour: TrimBehaviour = .nothing
|
||||||
|
|
||||||
|
if let leading = previousWhiteSpace {
|
||||||
|
if leading == .unspecified {
|
||||||
|
behaviour.leading = environment.trimBehaviour.trailing
|
||||||
|
} else {
|
||||||
|
behaviour.leading = leading == .trim ? .whitespaceAndNewLines : .nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let trailing = peekWhitespace() {
|
||||||
|
if trailing == .unspecified {
|
||||||
|
behaviour.trailing = environment.trimBehaviour.leading
|
||||||
|
} else {
|
||||||
|
behaviour.trailing = trailing == .trim ? .whitespaceAndNewLines : .nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return behaviour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Environment {
|
||||||
|
func findTag(name: String) throws -> Extension.TagParser {
|
||||||
|
for ext in extensions {
|
||||||
|
if let filter = ext.tags[name] {
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TemplateSyntaxError("Unknown template tag '\(name)'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFilter(_ name: String) throws -> FilterType {
|
||||||
|
for ext in extensions {
|
||||||
|
if let filter = ext.filters[name] {
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let suggestedFilters = self.suggestedFilters(for: name)
|
||||||
|
if suggestedFilters.isEmpty {
|
||||||
|
throw TemplateSyntaxError("Unknown filter '\(name)'.")
|
||||||
|
} else {
|
||||||
|
throw TemplateSyntaxError(
|
||||||
|
"""
|
||||||
|
Unknown filter '\(name)'. \
|
||||||
|
Found similar filters: \(suggestedFilters.map { "'\($0)'" }.joined(separator: ", ")).
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suggestedFilters(for name: String) -> [String] {
|
||||||
|
let allFilters = extensions.flatMap { $0.filters.keys }
|
||||||
|
|
||||||
|
let filtersWithDistance = allFilters
|
||||||
|
.map { (filterName: $0, distance: $0.levenshteinDistance(name)) }
|
||||||
|
// do not suggest filters which names are shorter than the distance
|
||||||
|
.filter { $0.filterName.count > $0.distance }
|
||||||
|
guard let minDistance = filtersWithDistance.min(by: { $0.distance < $1.distance })?.distance else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
// suggest all filters with the same distance
|
||||||
|
return filtersWithDistance.filter { $0.distance == minDistance }.map { $0.filterName }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create filter expression from a string
|
||||||
|
public func compileFilter(_ token: String) throws -> Resolvable {
|
||||||
|
try FilterExpression(token: token, environment: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create filter expression from a string contained in provided token
|
||||||
|
public func compileFilter(_ filterToken: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||||
|
do {
|
||||||
|
return try FilterExpression(token: filterToken, environment: self)
|
||||||
|
} catch {
|
||||||
|
guard var syntaxError = error as? TemplateSyntaxError, syntaxError.token == nil else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// find offset of filter in the containing token so that only filter is highligted, not the whole token
|
||||||
|
if let filterTokenRange = containingToken.contents.range(of: filterToken) {
|
||||||
|
var location = containingToken.sourceMap.location
|
||||||
|
location.lineOffset += containingToken.contents.distance(
|
||||||
|
from: containingToken.contents.startIndex,
|
||||||
|
to: filterTokenRange.lowerBound
|
||||||
|
)
|
||||||
|
syntaxError.token = .variable(
|
||||||
|
value: filterToken,
|
||||||
|
at: SourceMap(filename: containingToken.sourceMap.filename, location: location)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
syntaxError.token = containingToken
|
||||||
|
}
|
||||||
|
throw syntaxError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create resolvable (i.e. range variable or filter expression) from a string
|
||||||
|
public func compileResolvable(_ token: String) throws -> Resolvable {
|
||||||
|
try RangeVariable(token, environment: self)
|
||||||
|
?? compileFilter(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create resolvable (i.e. range variable or filter expression) from a string contained in provided token
|
||||||
|
public func compileResolvable(_ token: String, containedIn containingToken: Token) throws -> Resolvable {
|
||||||
|
try RangeVariable(token, environment: self, containedIn: containingToken)
|
||||||
|
?? compileFilter(token, containedIn: containingToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create boolean expression from components contained in provided token
|
||||||
|
public func compileExpression(components: [String], containedIn token: Token) throws -> Expression {
|
||||||
|
try IfExpressionParser.parser(components: components, environment: self, token: token).parse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
|
||||||
|
extension String {
|
||||||
|
subscript(_ index: Int) -> Character {
|
||||||
|
self[self.index(self.startIndex, offsetBy: index)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func levenshteinDistance(_ target: String) -> Int {
|
||||||
|
// create two work vectors of integer distances
|
||||||
|
var last, current: [Int]
|
||||||
|
|
||||||
|
// initialize v0 (the previous row of distances)
|
||||||
|
// this row is A[0][i]: edit distance for an empty s
|
||||||
|
// the distance is just the number of characters to delete from t
|
||||||
|
last = [Int](0...target.count)
|
||||||
|
current = [Int](repeating: 0, count: target.count + 1)
|
||||||
|
|
||||||
|
for selfIndex in 0..<self.count {
|
||||||
|
// calculate v1 (current row distances) from the previous row v0
|
||||||
|
|
||||||
|
// first element of v1 is A[i+1][0]
|
||||||
|
// edit distance is delete (i+1) chars from s to match empty t
|
||||||
|
current[0] = selfIndex + 1
|
||||||
|
|
||||||
|
// use formula to fill in the rest of the row
|
||||||
|
for targetIndex in 0..<target.count {
|
||||||
|
current[targetIndex + 1] = Swift.min(
|
||||||
|
last[targetIndex + 1] + 1,
|
||||||
|
current[targetIndex] + 1,
|
||||||
|
last[targetIndex] + (self[selfIndex] == target[targetIndex] ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy v1 (current row) to v0 (previous row) for next iteration
|
||||||
|
last = current
|
||||||
|
}
|
||||||
|
|
||||||
|
return current[target.count]
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Sources/Stencil/Template.swift
Normal file
87
Sources/Stencil/Template.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
import PathKit
|
||||||
|
|
||||||
|
#if os(Linux)
|
||||||
|
// swiftlint:disable:next prefixed_toplevel_constant
|
||||||
|
let NSFileNoSuchFileError = 4
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// A class representing a template
|
||||||
|
open class Template: ExpressibleByStringLiteral {
|
||||||
|
let templateString: String
|
||||||
|
var environment: Environment
|
||||||
|
|
||||||
|
/// The list of parsed (lexed) tokens
|
||||||
|
public let tokens: [Token]
|
||||||
|
|
||||||
|
/// The name of the loaded Template if the Template was loaded from a Loader
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
/// Create a template with a template string
|
||||||
|
public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
|
||||||
|
self.environment = environment ?? Environment()
|
||||||
|
self.name = name
|
||||||
|
self.templateString = templateString
|
||||||
|
|
||||||
|
let lexer = Lexer(templateName: name, templateString: templateString)
|
||||||
|
tokens = lexer.tokenize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a template with the given name inside the given bundle
|
||||||
|
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||||
|
public convenience init(named: String, inBundle bundle: Bundle? = nil) throws {
|
||||||
|
let useBundle = bundle ?? Bundle.main
|
||||||
|
guard let url = useBundle.url(forResource: named, withExtension: nil) else {
|
||||||
|
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.init(URL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a template with a file found at the given URL
|
||||||
|
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||||
|
public convenience init(URL: Foundation.URL) throws {
|
||||||
|
guard let path = Path(url: URL) else {
|
||||||
|
throw TemplateDoesNotExist(templateNames: [URL.lastPathComponent])
|
||||||
|
}
|
||||||
|
try self.init(path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a template with a file found at the given path
|
||||||
|
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
||||||
|
public convenience init(path: Path, environment: Environment? = nil, name: String? = nil) throws {
|
||||||
|
let value = try String(contentsOf: path)
|
||||||
|
self.init(templateString: value, environment: environment, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ExpressibleByStringLiteral
|
||||||
|
|
||||||
|
// Create a templaVte with a template string literal
|
||||||
|
public required convenience init(stringLiteral value: String) {
|
||||||
|
self.init(templateString: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a template with a template string literal
|
||||||
|
public required convenience init(extendedGraphemeClusterLiteral value: StringLiteralType) {
|
||||||
|
self.init(stringLiteral: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a template with a template string literal
|
||||||
|
public required convenience init(unicodeScalarLiteral value: StringLiteralType) {
|
||||||
|
self.init(stringLiteral: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the given template with a context
|
||||||
|
public func render(_ context: Context) throws -> String {
|
||||||
|
let context = context
|
||||||
|
let parser = TokenParser(tokens: tokens, environment: context.environment)
|
||||||
|
let nodes = try parser.parse()
|
||||||
|
return try renderNodes(nodes, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the given template
|
||||||
|
// swiftlint:disable:next discouraged_optional_collection
|
||||||
|
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
||||||
|
try render(Context(dictionary: dictionary ?? [:], environment: environment))
|
||||||
|
}
|
||||||
|
}
|
||||||
154
Sources/Stencil/Tokenizer.swift
Normal file
154
Sources/Stencil/Tokenizer.swift
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// Split a string by a separator leaving quoted phrases together
|
||||||
|
func smartSplit(separator: Character = " ") -> [String] {
|
||||||
|
var word = ""
|
||||||
|
var components: [String] = []
|
||||||
|
var separate: Character = separator
|
||||||
|
var singleQuoteCount = 0
|
||||||
|
var doubleQuoteCount = 0
|
||||||
|
|
||||||
|
for character in self {
|
||||||
|
if character == "'" {
|
||||||
|
singleQuoteCount += 1
|
||||||
|
} else if character == "\"" {
|
||||||
|
doubleQuoteCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if character == separate {
|
||||||
|
if separate != separator {
|
||||||
|
word.append(separate)
|
||||||
|
} else if (singleQuoteCount.isMultiple(of: 2) || doubleQuoteCount.isMultiple(of: 2)) && !word.isEmpty {
|
||||||
|
appendWord(word, to: &components)
|
||||||
|
word = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
separate = separator
|
||||||
|
} else {
|
||||||
|
if separate == separator && (character == "'" || character == "\"") {
|
||||||
|
separate = character
|
||||||
|
}
|
||||||
|
word.append(character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !word.isEmpty {
|
||||||
|
appendWord(word, to: &components)
|
||||||
|
}
|
||||||
|
|
||||||
|
return components
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendWord(_ word: String, to components: inout [String]) {
|
||||||
|
let specialCharacters = ",|:"
|
||||||
|
|
||||||
|
if !components.isEmpty {
|
||||||
|
if let precedingChar = components.last?.last, specialCharacters.contains(precedingChar) {
|
||||||
|
// special case for labeled for-loops
|
||||||
|
if components.count == 1 && word == "for" {
|
||||||
|
components.append(word)
|
||||||
|
} else {
|
||||||
|
components[components.count - 1] += word
|
||||||
|
}
|
||||||
|
} else if specialCharacters.contains(word) {
|
||||||
|
components[components.count - 1] += word
|
||||||
|
} else if word != "(" && word.first == "(" || word != ")" && word.first == ")" {
|
||||||
|
components.append(String(word.prefix(1)))
|
||||||
|
appendWord(String(word.dropFirst()), to: &components)
|
||||||
|
} else if word != "(" && word.last == "(" || word != ")" && word.last == ")" {
|
||||||
|
appendWord(String(word.dropLast()), to: &components)
|
||||||
|
components.append(String(word.suffix(1)))
|
||||||
|
} else {
|
||||||
|
components.append(word)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
components.append(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SourceMap: Equatable {
|
||||||
|
public let filename: String?
|
||||||
|
public let location: ContentLocation
|
||||||
|
|
||||||
|
init(filename: String? = nil, location: ContentLocation = ("", 0, 0)) {
|
||||||
|
self.filename = filename
|
||||||
|
self.location = location
|
||||||
|
}
|
||||||
|
|
||||||
|
static let unknown = Self()
|
||||||
|
|
||||||
|
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.filename == rhs.filename && lhs.location == rhs.location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct WhitespaceBehaviour: Equatable {
|
||||||
|
public enum Behaviour {
|
||||||
|
case unspecified
|
||||||
|
case trim
|
||||||
|
case keep
|
||||||
|
}
|
||||||
|
|
||||||
|
let leading: Behaviour
|
||||||
|
let trailing: Behaviour
|
||||||
|
|
||||||
|
public static let unspecified = Self(leading: .unspecified, trailing: .unspecified)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Token: Equatable {
|
||||||
|
public enum Kind: Equatable {
|
||||||
|
/// A token representing a piece of text.
|
||||||
|
case text
|
||||||
|
/// A token representing a variable.
|
||||||
|
case variable
|
||||||
|
/// A token representing a comment.
|
||||||
|
case comment
|
||||||
|
/// A token representing a template block.
|
||||||
|
case block
|
||||||
|
}
|
||||||
|
|
||||||
|
public let contents: String
|
||||||
|
public let kind: Kind
|
||||||
|
public let sourceMap: SourceMap
|
||||||
|
public var whitespace: WhitespaceBehaviour?
|
||||||
|
|
||||||
|
/// Returns the underlying value as an array seperated by spaces
|
||||||
|
public private(set) lazy var components: [String] = self.contents.smartSplit()
|
||||||
|
|
||||||
|
init(contents: String, kind: Kind, sourceMap: SourceMap, whitespace: WhitespaceBehaviour? = nil) {
|
||||||
|
self.contents = contents
|
||||||
|
self.kind = kind
|
||||||
|
self.sourceMap = sourceMap
|
||||||
|
self.whitespace = whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token representing a piece of text.
|
||||||
|
public static func text(value: String, at sourceMap: SourceMap) -> Token {
|
||||||
|
Token(contents: value, kind: .text, sourceMap: sourceMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token representing a variable.
|
||||||
|
public static func variable(value: String, at sourceMap: SourceMap) -> Token {
|
||||||
|
Token(contents: value, kind: .variable, sourceMap: sourceMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token representing a comment.
|
||||||
|
public static func comment(value: String, at sourceMap: SourceMap) -> Token {
|
||||||
|
Token(contents: value, kind: .comment, sourceMap: sourceMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token representing a template block.
|
||||||
|
public static func block(
|
||||||
|
value: String,
|
||||||
|
at sourceMap: SourceMap,
|
||||||
|
whitespace: WhitespaceBehaviour = .unspecified
|
||||||
|
) -> Token {
|
||||||
|
Token(contents: value, kind: .block, sourceMap: sourceMap, whitespace: whitespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func == (lhs: Token, rhs: Token) -> Bool {
|
||||||
|
lhs.contents == rhs.contents && lhs.kind == rhs.kind && lhs.sourceMap == rhs.sourceMap
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Sources/Stencil/TrimBehaviour.swift
Normal file
75
Sources/Stencil/TrimBehaviour.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct TrimBehaviour: Equatable {
|
||||||
|
var leading: Trim
|
||||||
|
var trailing: Trim
|
||||||
|
|
||||||
|
public enum Trim {
|
||||||
|
/// nothing
|
||||||
|
case nothing
|
||||||
|
|
||||||
|
/// tabs and spaces
|
||||||
|
case whitespace
|
||||||
|
|
||||||
|
/// tabs and spaces and a single new line
|
||||||
|
case whitespaceAndOneNewLine
|
||||||
|
|
||||||
|
/// all tabs spaces and newlines
|
||||||
|
case whitespaceAndNewLines
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(leading: Trim, trailing: Trim) {
|
||||||
|
self.leading = leading
|
||||||
|
self.trailing = trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// doesn't touch newlines
|
||||||
|
public static let nothing = Self(leading: .nothing, trailing: .nothing)
|
||||||
|
|
||||||
|
/// removes whitespace before a block and whitespace and a single newline after a block
|
||||||
|
public static let smart = Self(leading: .whitespace, trailing: .whitespaceAndOneNewLine)
|
||||||
|
|
||||||
|
/// removes all whitespace and newlines before and after a block
|
||||||
|
public static let all = Self(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
|
||||||
|
|
||||||
|
static func leadingRegex(trim: Trim) -> NSRegularExpression {
|
||||||
|
switch trim {
|
||||||
|
case .nothing:
|
||||||
|
fatalError("No RegularExpression for none")
|
||||||
|
case .whitespace:
|
||||||
|
return Self.leadingWhitespace
|
||||||
|
case .whitespaceAndOneNewLine:
|
||||||
|
return Self.leadingWhitespaceAndOneNewLine
|
||||||
|
case .whitespaceAndNewLines:
|
||||||
|
return Self.leadingWhitespaceAndNewlines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func trailingRegex(trim: Trim) -> NSRegularExpression {
|
||||||
|
switch trim {
|
||||||
|
case .nothing:
|
||||||
|
fatalError("No RegularExpression for none")
|
||||||
|
case .whitespace:
|
||||||
|
return Self.trailingWhitespace
|
||||||
|
case .whitespaceAndOneNewLine:
|
||||||
|
return Self.trailingWhitespaceAndOneNewLine
|
||||||
|
case .whitespaceAndNewLines:
|
||||||
|
return Self.trailingWhitespaceAndNewLines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
private static let leadingWhitespaceAndNewlines = try! NSRegularExpression(pattern: "^\\s+")
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
private static let trailingWhitespaceAndNewLines = try! NSRegularExpression(pattern: "\\s+$")
|
||||||
|
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
private static let leadingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "^[ \t]*\n")
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
private static let trailingWhitespaceAndOneNewLine = try! NSRegularExpression(pattern: "\n[ \t]*$")
|
||||||
|
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
private static let leadingWhitespace = try! NSRegularExpression(pattern: "^[ \t]*")
|
||||||
|
// swiftlint:disable:next force_try
|
||||||
|
private static let trailingWhitespace = try! NSRegularExpression(pattern: "[ \t]*$")
|
||||||
|
}
|
||||||
285
Sources/Stencil/Variable.swift
Normal file
285
Sources/Stencil/Variable.swift
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
typealias Number = Float
|
||||||
|
|
||||||
|
class FilterExpression: Resolvable {
|
||||||
|
let filters: [(FilterType, [Variable])]
|
||||||
|
let variable: Variable
|
||||||
|
|
||||||
|
init(token: String, environment: Environment) throws {
|
||||||
|
let bits = token.smartSplit(separator: "|").map { String($0).trim(character: " ") }
|
||||||
|
if bits.isEmpty {
|
||||||
|
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
variable = Variable(bits[0])
|
||||||
|
let filterBits = bits[bits.indices.suffix(from: 1)]
|
||||||
|
|
||||||
|
do {
|
||||||
|
filters = try filterBits.map { bit in
|
||||||
|
let (name, arguments) = parseFilterComponents(token: bit)
|
||||||
|
let filter = try environment.findFilter(name)
|
||||||
|
return (filter, arguments)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
filters = []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(_ context: Context) throws -> Any? {
|
||||||
|
let result = try variable.resolve(context)
|
||||||
|
|
||||||
|
return try filters.reduce(result) { value, filter in
|
||||||
|
let arguments = try filter.1.map { try $0.resolve(context) }
|
||||||
|
return try filter.0.invoke(value: value, arguments: arguments, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A structure used to represent a template variable, and to resolve it in a given context.
|
||||||
|
public struct Variable: Equatable, Resolvable {
|
||||||
|
public let variable: String
|
||||||
|
|
||||||
|
/// Create a variable with a string representing the variable
|
||||||
|
public init(_ variable: String) {
|
||||||
|
self.variable = variable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the variable in the given context
|
||||||
|
public func resolve(_ context: Context) throws -> Any? {
|
||||||
|
if variable.count > 1 &&
|
||||||
|
((variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\""))) {
|
||||||
|
// String literal
|
||||||
|
return String(variable[variable.index(after: variable.startIndex) ..< variable.index(before: variable.endIndex)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number literal
|
||||||
|
if let int = Int(variable) {
|
||||||
|
return int
|
||||||
|
}
|
||||||
|
if let number = Number(variable) {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
// Boolean literal
|
||||||
|
if let bool = Bool(variable) {
|
||||||
|
return bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: Any? = context
|
||||||
|
for bit in try lookup(context) {
|
||||||
|
current = resolve(bit: bit, context: current)
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
return nil
|
||||||
|
} else if let lazyCurrent = current as? LazyValueWrapper {
|
||||||
|
current = try lazyCurrent.value(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let resolvable = current as? Resolvable {
|
||||||
|
current = try resolvable.resolve(context)
|
||||||
|
} else if let node = current as? NodeType {
|
||||||
|
current = try node.render(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalize(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the lookup string and resolve references if possible
|
||||||
|
private func lookup(_ context: Context) throws -> [String] {
|
||||||
|
let keyPath = KeyPath(variable, in: context)
|
||||||
|
return try keyPath.parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve a partial keypath for the given context
|
||||||
|
private func resolve(bit: String, context: Any?) -> Any? {
|
||||||
|
let context = normalize(context)
|
||||||
|
|
||||||
|
if let context = context as? Context {
|
||||||
|
return context[bit]
|
||||||
|
} else if let dictionary = context as? [String: Any] {
|
||||||
|
return resolve(bit: bit, dictionary: dictionary)
|
||||||
|
} else if let array = context as? [Any] {
|
||||||
|
return resolve(bit: bit, collection: array)
|
||||||
|
} else if let string = context as? String {
|
||||||
|
return resolve(bit: bit, collection: string)
|
||||||
|
} else if let value = context as? DynamicMemberLookup {
|
||||||
|
return value[dynamicMember: bit]
|
||||||
|
} else if let object = context as? NSObject { // NSKeyValueCoding
|
||||||
|
#if canImport(ObjectiveC)
|
||||||
|
if object.responds(to: Selector(bit)) {
|
||||||
|
return object.value(forKey: bit)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif
|
||||||
|
} else if let value = context {
|
||||||
|
return Mirror(reflecting: value).getValue(for: bit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve a partial keypath for the given dictionary
|
||||||
|
private func resolve(bit: String, dictionary: [String: Any]) -> Any? {
|
||||||
|
if bit == "count" {
|
||||||
|
return dictionary.count
|
||||||
|
} else {
|
||||||
|
return dictionary[bit]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve a partial keypath for the given collection
|
||||||
|
private func resolve<T: Collection>(bit: String, collection: T) -> Any? {
|
||||||
|
if let index = Int(bit) {
|
||||||
|
if index >= 0 && index < collection.count {
|
||||||
|
return collection[collection.index(collection.startIndex, offsetBy: index)]
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else if bit == "first" {
|
||||||
|
return collection.first
|
||||||
|
} else if bit == "last" {
|
||||||
|
return collection[collection.index(collection.endIndex, offsetBy: -1)]
|
||||||
|
} else if bit == "count" {
|
||||||
|
return collection.count
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A structure used to represet range of two integer values expressed as `from...to`.
|
||||||
|
/// Values should be numbers (they will be converted to integers).
|
||||||
|
/// Rendering this variable produces array from range `from...to`.
|
||||||
|
/// If `from` is more than `to` array will contain values of reversed range.
|
||||||
|
public struct RangeVariable: Resolvable {
|
||||||
|
public let from: Resolvable
|
||||||
|
// swiftlint:disable:next identifier_name
|
||||||
|
public let to: Resolvable
|
||||||
|
|
||||||
|
public init?(_ token: String, environment: Environment) throws {
|
||||||
|
let components = token.components(separatedBy: "...")
|
||||||
|
guard components.count == 2 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.from = try environment.compileFilter(components[0])
|
||||||
|
self.to = try environment.compileFilter(components[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
public init?(_ token: String, environment: Environment, containedIn containingToken: Token) throws {
|
||||||
|
let components = token.components(separatedBy: "...")
|
||||||
|
guard components.count == 2 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.from = try environment.compileFilter(components[0], containedIn: containingToken)
|
||||||
|
self.to = try environment.compileFilter(components[1], containedIn: containingToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolve(_ context: Context) throws -> Any? {
|
||||||
|
let lowerResolved = try from.resolve(context)
|
||||||
|
let upperResolved = try to.resolve(context)
|
||||||
|
|
||||||
|
guard let lower = lowerResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||||
|
throw TemplateSyntaxError("'from' value is not an Integer (\(lowerResolved ?? "nil"))")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let upper = upperResolved.flatMap(toNumber(value:)).flatMap(Int.init) else {
|
||||||
|
throw TemplateSyntaxError("'to' value is not an Integer (\(upperResolved ?? "nil") )")
|
||||||
|
}
|
||||||
|
|
||||||
|
let range = min(lower, upper)...max(lower, upper)
|
||||||
|
return lower > upper ? Array(range.reversed()) : Array(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(_ current: Any?) -> Any? {
|
||||||
|
if let current = current as? Normalizable {
|
||||||
|
return current.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol Normalizable {
|
||||||
|
func normalize() -> Any?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array: Normalizable {
|
||||||
|
func normalize() -> Any? {
|
||||||
|
map { $0 as Any }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next legacy_objc_type
|
||||||
|
extension NSArray: Normalizable {
|
||||||
|
func normalize() -> Any? {
|
||||||
|
map { $0 as Any }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Dictionary: Normalizable {
|
||||||
|
func normalize() -> Any? {
|
||||||
|
var dictionary: [String: Any] = [:]
|
||||||
|
|
||||||
|
for (key, value) in self {
|
||||||
|
if let key = key as? String {
|
||||||
|
dictionary[key] = Stencil.normalize(value)
|
||||||
|
} else if let key = key as? CustomStringConvertible {
|
||||||
|
dictionary[key.description] = Stencil.normalize(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dictionary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFilterComponents(token: String) -> (String, [Variable]) {
|
||||||
|
var components = token.smartSplit(separator: ":")
|
||||||
|
let name = components.removeFirst().trim(character: " ")
|
||||||
|
let variables = components
|
||||||
|
.joined(separator: ":")
|
||||||
|
.smartSplit(separator: ",")
|
||||||
|
.map { Variable($0.trim(character: " ")) }
|
||||||
|
return (name, variables)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Mirror {
|
||||||
|
func getValue(for key: String) -> Any? {
|
||||||
|
let result = descendant(key) ?? Int(key).flatMap { descendant($0) }
|
||||||
|
if result == nil {
|
||||||
|
// go through inheritance chain to reach superclass properties
|
||||||
|
return superclassMirror?.getValue(for: key)
|
||||||
|
} else if let result = result {
|
||||||
|
guard String(describing: result) != "nil" else {
|
||||||
|
// mirror returns non-nil value even for nil-containing properties
|
||||||
|
// so we have to check if its value is actually nil or not
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let result = (result as? AnyOptional)?.wrapped {
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol AnyOptional {
|
||||||
|
var wrapped: Any? { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Optional: AnyOptional {
|
||||||
|
var wrapped: Any? {
|
||||||
|
switch self {
|
||||||
|
case let .some(value):
|
||||||
|
return value
|
||||||
|
case .none:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import PathKit
|
|
||||||
|
|
||||||
#if os(Linux)
|
|
||||||
let NSFileNoSuchFileError = 4
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// A class representing a template
|
|
||||||
open class Template: ExpressibleByStringLiteral {
|
|
||||||
let environment: Environment
|
|
||||||
let tokens: [Token]
|
|
||||||
|
|
||||||
/// The name of the loaded Template if the Template was loaded from a Loader
|
|
||||||
public let name: String?
|
|
||||||
|
|
||||||
/// Create a template with a template string
|
|
||||||
public required init(templateString: String, environment: Environment? = nil, name: String? = nil) {
|
|
||||||
self.environment = environment ?? Environment()
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
let lexer = Lexer(templateString: templateString)
|
|
||||||
tokens = lexer.tokenize()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a template with the given name inside the given bundle
|
|
||||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
|
||||||
public convenience init(named:String, inBundle bundle:Bundle? = nil) throws {
|
|
||||||
let useBundle = bundle ?? Bundle.main
|
|
||||||
guard let url = useBundle.url(forResource: named, withExtension: nil) else {
|
|
||||||
throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
try self.init(URL:url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a template with a file found at the given URL
|
|
||||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
|
||||||
public convenience init(URL:Foundation.URL) throws {
|
|
||||||
try self.init(path: Path(URL.path))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a template with a file found at the given path
|
|
||||||
@available(*, deprecated, message: "Use Environment/FileSystemLoader instead")
|
|
||||||
public convenience init(path: Path, environment: Environment? = nil, name: String? = nil) throws {
|
|
||||||
self.init(templateString: try path.read(), environment: environment, name: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: ExpressibleByStringLiteral
|
|
||||||
|
|
||||||
// Create a templaVte with a template string literal
|
|
||||||
public convenience required init(stringLiteral value: String) {
|
|
||||||
self.init(templateString: value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a template with a template string literal
|
|
||||||
public convenience required init(extendedGraphemeClusterLiteral value: StringLiteralType) {
|
|
||||||
self.init(stringLiteral: value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a template with a template string literal
|
|
||||||
public convenience required init(unicodeScalarLiteral value: StringLiteralType) {
|
|
||||||
self.init(stringLiteral: value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the given template with a context
|
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
let context = context
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: context.environment)
|
|
||||||
let nodes = try parser.parse()
|
|
||||||
return try renderNodes(nodes, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the given template
|
|
||||||
open func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
|
||||||
return try render(Context(dictionary: dictionary, environment: environment))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
|
|
||||||
extension String {
|
|
||||||
/// Split a string by a separator leaving quoted phrases together
|
|
||||||
func smartSplit(separator: Character = " ") -> [String] {
|
|
||||||
var word = ""
|
|
||||||
var components: [String] = []
|
|
||||||
var separate: Character = separator
|
|
||||||
var singleQuoteCount = 0
|
|
||||||
var doubleQuoteCount = 0
|
|
||||||
|
|
||||||
for character in self.characters {
|
|
||||||
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 {
|
|
||||||
components.append(word)
|
|
||||||
word = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
separate = separator
|
|
||||||
} else {
|
|
||||||
if separate == separator && (character == "'" || character == "\"") {
|
|
||||||
separate = character
|
|
||||||
}
|
|
||||||
word.append(character)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !word.isEmpty {
|
|
||||||
components.append(word)
|
|
||||||
}
|
|
||||||
|
|
||||||
return components
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public enum Token : Equatable {
|
|
||||||
/// A token representing a piece of text.
|
|
||||||
case text(value: String)
|
|
||||||
|
|
||||||
/// A token representing a variable.
|
|
||||||
case variable(value: String)
|
|
||||||
|
|
||||||
/// A token representing a comment.
|
|
||||||
case comment(value: String)
|
|
||||||
|
|
||||||
/// A token representing a template block.
|
|
||||||
case block(value: String)
|
|
||||||
|
|
||||||
/// Returns the underlying value as an array seperated by spaces
|
|
||||||
public func components() -> [String] {
|
|
||||||
switch self {
|
|
||||||
case .block(let value):
|
|
||||||
return value.smartSplit()
|
|
||||||
case .variable(let value):
|
|
||||||
return value.smartSplit()
|
|
||||||
case .text(let value):
|
|
||||||
return value.smartSplit()
|
|
||||||
case .comment(let value):
|
|
||||||
return value.smartSplit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var contents: String {
|
|
||||||
switch self {
|
|
||||||
case .block(let value):
|
|
||||||
return value
|
|
||||||
case .variable(let value):
|
|
||||||
return value
|
|
||||||
case .text(let value):
|
|
||||||
return value
|
|
||||||
case .comment(let value):
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public func == (lhs: Token, rhs: Token) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.text(let lhsValue), .text(let rhsValue)):
|
|
||||||
return lhsValue == rhsValue
|
|
||||||
case (.variable(let lhsValue), .variable(let rhsValue)):
|
|
||||||
return lhsValue == rhsValue
|
|
||||||
case (.block(let lhsValue), .block(let rhsValue)):
|
|
||||||
return lhsValue == rhsValue
|
|
||||||
case (.comment(let lhsValue), .comment(let rhsValue)):
|
|
||||||
return lhsValue == rhsValue
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
|
|
||||||
typealias Number = Float
|
|
||||||
|
|
||||||
|
|
||||||
class FilterExpression : Resolvable {
|
|
||||||
let filters: [(FilterType, [Variable])]
|
|
||||||
let variable: Variable
|
|
||||||
|
|
||||||
init(token: String, parser: TokenParser) throws {
|
|
||||||
let bits = token.characters.split(separator: "|").map({ String($0).trim(character: " ") })
|
|
||||||
if bits.isEmpty {
|
|
||||||
filters = []
|
|
||||||
variable = Variable("")
|
|
||||||
throw TemplateSyntaxError("Variable tags must include at least 1 argument")
|
|
||||||
}
|
|
||||||
|
|
||||||
variable = Variable(bits[0])
|
|
||||||
let filterBits = bits[bits.indices.suffix(from: 1)]
|
|
||||||
|
|
||||||
do {
|
|
||||||
filters = try filterBits.map {
|
|
||||||
let (name, arguments) = parseFilterComponents(token: $0)
|
|
||||||
let filter = try parser.findFilter(name)
|
|
||||||
return (filter, arguments)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
filters = []
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolve(_ context: Context) throws -> Any? {
|
|
||||||
let result = try variable.resolve(context)
|
|
||||||
|
|
||||||
return try filters.reduce(result) { x, y in
|
|
||||||
let arguments = try y.1.map { try $0.resolve(context) }
|
|
||||||
return try y.0.invoke(value: x, arguments: arguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A structure used to represent a template variable, and to resolve it in a given context.
|
|
||||||
public struct Variable : Equatable, Resolvable {
|
|
||||||
public let variable: String
|
|
||||||
|
|
||||||
/// Create a variable with a string representing the variable
|
|
||||||
public init(_ variable: String) {
|
|
||||||
self.variable = variable
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func lookup() -> [String] {
|
|
||||||
return variable.characters.split(separator: ".").map(String.init)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the variable in the given context
|
|
||||||
public func resolve(_ context: Context) throws -> Any? {
|
|
||||||
var current: Any? = context
|
|
||||||
|
|
||||||
if (variable.hasPrefix("'") && variable.hasSuffix("'")) || (variable.hasPrefix("\"") && variable.hasSuffix("\"")) {
|
|
||||||
// String literal
|
|
||||||
return variable[variable.characters.index(after: variable.startIndex) ..< variable.characters.index(before: variable.endIndex)]
|
|
||||||
}
|
|
||||||
|
|
||||||
if let number = Number(variable) {
|
|
||||||
// Number literal
|
|
||||||
return number
|
|
||||||
}
|
|
||||||
|
|
||||||
for bit in lookup() {
|
|
||||||
current = normalize(current)
|
|
||||||
|
|
||||||
if let context = current as? Context {
|
|
||||||
current = context[bit]
|
|
||||||
} else if let dictionary = current as? [String: Any] {
|
|
||||||
current = dictionary[bit]
|
|
||||||
} else if let array = current as? [Any] {
|
|
||||||
if let index = Int(bit) {
|
|
||||||
if index >= 0 && index < array.count {
|
|
||||||
current = array[index]
|
|
||||||
} else {
|
|
||||||
current = nil
|
|
||||||
}
|
|
||||||
} else if bit == "first" {
|
|
||||||
current = array.first
|
|
||||||
} else if bit == "last" {
|
|
||||||
current = array.last
|
|
||||||
} else if bit == "count" {
|
|
||||||
current = array.count
|
|
||||||
}
|
|
||||||
} else if let object = current as? NSObject { // NSKeyValueCoding
|
|
||||||
#if os(Linux)
|
|
||||||
return nil
|
|
||||||
#else
|
|
||||||
current = object.value(forKey: bit)
|
|
||||||
#endif
|
|
||||||
} else if let value = current {
|
|
||||||
let mirror = Mirror(reflecting: value)
|
|
||||||
current = mirror.descendant(bit)
|
|
||||||
|
|
||||||
if current == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let resolvable = current as? Resolvable {
|
|
||||||
current = try resolvable.resolve(context)
|
|
||||||
} else if let node = current as? NodeType {
|
|
||||||
current = try node.render(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalize(current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func ==(lhs: Variable, rhs: Variable) -> Bool {
|
|
||||||
return lhs.variable == rhs.variable
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func normalize(_ current: Any?) -> Any? {
|
|
||||||
if let current = current as? Normalizable {
|
|
||||||
return current.normalize()
|
|
||||||
}
|
|
||||||
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol Normalizable {
|
|
||||||
func normalize() -> Any?
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array : Normalizable {
|
|
||||||
func normalize() -> Any? {
|
|
||||||
return map { $0 as Any }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NSArray : Normalizable {
|
|
||||||
func normalize() -> Any? {
|
|
||||||
return map { $0 as Any }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Dictionary : Normalizable {
|
|
||||||
func normalize() -> Any? {
|
|
||||||
var dictionary: [String: Any] = [:]
|
|
||||||
|
|
||||||
for (key, value) in self {
|
|
||||||
if let key = key as? String {
|
|
||||||
dictionary[key] = Stencil.normalize(value)
|
|
||||||
} else if let key = key as? CustomStringConvertible {
|
|
||||||
dictionary[key.description] = Stencil.normalize(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dictionary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFilterComponents(token: String) -> (String, [Variable]) {
|
|
||||||
var components = token.smartSplit(separator: ":")
|
|
||||||
let name = components.removeFirst()
|
|
||||||
let variables = components
|
|
||||||
.joined(separator: ":")
|
|
||||||
.smartSplit(separator: ",")
|
|
||||||
.map { Variable($0) }
|
|
||||||
return (name, variables)
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "Stencil",
|
"name": "Stencil",
|
||||||
"version": "0.9.0",
|
"version": "0.15.2",
|
||||||
"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",
|
|
||||||
"license": {
|
"license": {
|
||||||
"type": "BSD",
|
"type": "BSD",
|
||||||
"file": "LICENSE"
|
"file": "LICENSE"
|
||||||
},
|
},
|
||||||
"authors": {
|
"authors": {
|
||||||
"Kyle Fuller": "kyle@fuller.li"
|
"Thomas Bernstein": "developer@astzweig.kg"
|
||||||
},
|
},
|
||||||
"social_media_url": "https://twitter.com/kylefuller",
|
"social_media_url": "https://twitter.com/trbernstein",
|
||||||
"source": {
|
"source": {
|
||||||
"git": "https://github.com/kylef/Stencil.git",
|
"git": "https://github.com/swiftstencil/swiftpm-stencil.git",
|
||||||
"tag": "0.9.0"
|
"tag": "0.15.2"
|
||||||
},
|
},
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
"swift_versions": [
|
||||||
|
"5.0"
|
||||||
|
],
|
||||||
"requires_arc": true,
|
"requires_arc": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"PathKit": [ "~> 0.8.0" ]
|
"PathKit": [
|
||||||
|
"~> 1.5.0"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import StencilTests
|
|
||||||
|
|
||||||
stencilTests()
|
|
||||||
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,45 +1,54 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ContextTests: XCTestCase {
|
||||||
func testContext() {
|
func testContextSubscripting() {
|
||||||
describe("Context") {
|
describe("Context Subscripting") { test in
|
||||||
var context: Context!
|
var context = Context()
|
||||||
|
test.before {
|
||||||
$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"
|
||||||
}
|
}
|
||||||
@@ -47,7 +56,7 @@ func testContext() {
|
|||||||
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()
|
||||||
@@ -56,7 +65,7 @@ func testContext() {
|
|||||||
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"]) {
|
||||||
@@ -68,7 +77,7 @@ func testContext() {
|
|||||||
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()
|
||||||
|
|
||||||
@@ -79,3 +88,73 @@ func testContext() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
125
Tests/StencilTests/EnvironmentBaseAndChildTemplateSpec.swift
Normal file
125
Tests/StencilTests/EnvironmentBaseAndChildTemplateSpec.swift
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import PathKit
|
||||||
|
import Spectre
|
||||||
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class EnvironmentBaseAndChildTemplateTests: XCTestCase {
|
||||||
|
private var environment = Environment(loader: ExampleLoader())
|
||||||
|
private var childTemplate: Template = ""
|
||||||
|
private var baseTemplate: Template = ""
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
|
||||||
|
let path = Path(#file as String)! / ".." / "fixtures"
|
||||||
|
let loader = FileSystemLoader(paths: [path])
|
||||||
|
environment = Environment(loader: loader)
|
||||||
|
childTemplate = ""
|
||||||
|
baseTemplate = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyntaxErrorInBaseTemplate() throws {
|
||||||
|
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
|
||||||
|
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
|
||||||
|
|
||||||
|
try expectError(
|
||||||
|
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
|
||||||
|
childToken: "extends \"invalid-base.html\"",
|
||||||
|
baseToken: "target|unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRuntimeErrorInBaseTemplate() throws {
|
||||||
|
let filterExtension = Extension()
|
||||||
|
filterExtension.registerFilter("unknown") { (_: Any?) in
|
||||||
|
throw TemplateSyntaxError("filter error")
|
||||||
|
}
|
||||||
|
environment.extensions += [filterExtension]
|
||||||
|
|
||||||
|
childTemplate = try environment.loadTemplate(name: "invalid-child-super.html")
|
||||||
|
baseTemplate = try environment.loadTemplate(name: "invalid-base.html")
|
||||||
|
|
||||||
|
try expectError(
|
||||||
|
reason: "filter error",
|
||||||
|
childToken: "extends \"invalid-base.html\"",
|
||||||
|
baseToken: "target|unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyntaxErrorInChildTemplate() throws {
|
||||||
|
childTemplate = Template(
|
||||||
|
templateString: """
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block body %}Child {{ target|unknown }}{% endblock %}
|
||||||
|
""",
|
||||||
|
environment: environment,
|
||||||
|
name: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try expectError(
|
||||||
|
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
|
||||||
|
childToken: "target|unknown",
|
||||||
|
baseToken: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRuntimeErrorInChildTemplate() throws {
|
||||||
|
let filterExtension = Extension()
|
||||||
|
filterExtension.registerFilter("unknown") { (_: Any?) in
|
||||||
|
throw TemplateSyntaxError("filter error")
|
||||||
|
}
|
||||||
|
environment.extensions += [filterExtension]
|
||||||
|
|
||||||
|
childTemplate = Template(
|
||||||
|
templateString: """
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block body %}Child {{ target|unknown }}{% endblock %}
|
||||||
|
""",
|
||||||
|
environment: environment,
|
||||||
|
name: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try expectError(
|
||||||
|
reason: "filter error",
|
||||||
|
childToken: "target|unknown",
|
||||||
|
baseToken: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func expectError(
|
||||||
|
reason: String,
|
||||||
|
childToken: String,
|
||||||
|
baseToken: String?,
|
||||||
|
file: String = #file,
|
||||||
|
line: Int = #line,
|
||||||
|
function: String = #function
|
||||||
|
) throws {
|
||||||
|
var expectedError = expectedSyntaxError(token: childToken, template: childTemplate, description: reason)
|
||||||
|
if let baseToken = baseToken {
|
||||||
|
expectedError.stackTrace = [
|
||||||
|
expectedSyntaxError(
|
||||||
|
token: baseToken,
|
||||||
|
template: baseTemplate,
|
||||||
|
description: reason
|
||||||
|
).token
|
||||||
|
].compactMap { $0 }
|
||||||
|
}
|
||||||
|
let error = try expect(
|
||||||
|
self.environment.render(template: self.childTemplate, context: ["target": "World"]),
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function
|
||||||
|
).toThrow() as TemplateSyntaxError
|
||||||
|
let reporter = SimpleErrorReporter()
|
||||||
|
try expect(
|
||||||
|
reporter.renderError(error),
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function
|
||||||
|
) == reporter.renderError(expectedError)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
Tests/StencilTests/EnvironmentIncludeTemplateSpec.swift
Normal file
88
Tests/StencilTests/EnvironmentIncludeTemplateSpec.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import PathKit
|
||||||
|
import Spectre
|
||||||
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class EnvironmentIncludeTemplateTests: XCTestCase {
|
||||||
|
private var environment = Environment(loader: ExampleLoader())
|
||||||
|
private var template: Template = ""
|
||||||
|
private var includedTemplate: Template = ""
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
|
||||||
|
let path = Path(#file as String)! / ".." / "fixtures"
|
||||||
|
let loader = FileSystemLoader(paths: [path])
|
||||||
|
environment = Environment(loader: loader)
|
||||||
|
template = ""
|
||||||
|
includedTemplate = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyntaxError() throws {
|
||||||
|
template = Template(templateString: """
|
||||||
|
{% include "invalid-include.html" %}
|
||||||
|
""", environment: environment)
|
||||||
|
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
|
||||||
|
|
||||||
|
try expectError(
|
||||||
|
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
|
||||||
|
token: #"include "invalid-include.html""#,
|
||||||
|
includedToken: "target|unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRuntimeError() throws {
|
||||||
|
let filterExtension = Extension()
|
||||||
|
filterExtension.registerFilter("unknown") { (_: Any?) in
|
||||||
|
throw TemplateSyntaxError("filter error")
|
||||||
|
}
|
||||||
|
environment.extensions += [filterExtension]
|
||||||
|
|
||||||
|
template = Template(templateString: """
|
||||||
|
{% include "invalid-include.html" %}
|
||||||
|
""", environment: environment)
|
||||||
|
includedTemplate = try environment.loadTemplate(name: "invalid-include.html")
|
||||||
|
|
||||||
|
try expectError(
|
||||||
|
reason: "filter error",
|
||||||
|
token: "include \"invalid-include.html\"",
|
||||||
|
includedToken: "target|unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func expectError(
|
||||||
|
reason: String,
|
||||||
|
token: String,
|
||||||
|
includedToken: String,
|
||||||
|
file: String = #file,
|
||||||
|
line: Int = #line,
|
||||||
|
function: String = #function
|
||||||
|
) throws {
|
||||||
|
var expectedError = expectedSyntaxError(token: token, template: template, description: reason)
|
||||||
|
expectedError.stackTrace = [
|
||||||
|
expectedSyntaxError(
|
||||||
|
token: includedToken,
|
||||||
|
template: includedTemplate,
|
||||||
|
description: reason
|
||||||
|
).token
|
||||||
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
let error = try expect(
|
||||||
|
self.environment.render(template: self.template, context: ["target": "World"]),
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function
|
||||||
|
).toThrow() as TemplateSyntaxError
|
||||||
|
let reporter = SimpleErrorReporter()
|
||||||
|
try expect(
|
||||||
|
reporter.renderError(error),
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function
|
||||||
|
) == reporter.renderError(expectedError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +1,220 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class EnvironmentTests: XCTestCase {
|
||||||
|
private var environment = Environment(loader: ExampleLoader())
|
||||||
|
private var template: Template = ""
|
||||||
|
|
||||||
func testEnvironment() {
|
override func setUp() {
|
||||||
describe("Environment") {
|
super.setUp()
|
||||||
let environment = Environment(loader: ExampleLoader())
|
|
||||||
|
|
||||||
$0.it("can load a template from a name") {
|
let errorExtension = Extension()
|
||||||
let template = try environment.loadTemplate(name: "example.html")
|
errorExtension.registerFilter("throw") { (_: Any?) in
|
||||||
|
throw TemplateSyntaxError("filter error")
|
||||||
|
}
|
||||||
|
errorExtension.registerSimpleTag("simpletag") { _ in
|
||||||
|
throw TemplateSyntaxError("simpletag error")
|
||||||
|
}
|
||||||
|
errorExtension.registerTag("customtag") { _, token in
|
||||||
|
ErrorNode(token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
environment = Environment(loader: ExampleLoader())
|
||||||
|
environment.extensions += [errorExtension]
|
||||||
|
template = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
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 testSyntaxError() {
|
||||||
|
it("reports syntax error on invalid for tag syntax") {
|
||||||
|
self.template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
|
||||||
|
try self.expectError(
|
||||||
|
reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.",
|
||||||
|
token: "for name in"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("reports syntax error on missing endfor") {
|
||||||
fileprivate class ExampleLoader: Loader {
|
self.template = "{% for name in names %}{{ name }}"
|
||||||
func loadTemplate(name: String, environment: Environment) throws -> Template {
|
try self.expectError(reason: "`endfor` was not found.", token: "for name in names")
|
||||||
if name == "example.html" {
|
|
||||||
return Template(templateString: "Hello World!", environment: environment, name: name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
class CustomTemplate: Template {
|
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 error = try expect(
|
||||||
|
self.environment.render(template: self.template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function
|
||||||
|
).toThrow() as TemplateSyntaxError
|
||||||
|
let reporter = SimpleErrorReporter()
|
||||||
|
try expect(
|
||||||
|
reporter.renderError(error),
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function
|
||||||
|
) == reporter.renderError(expectedError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private class CustomTemplate: Template {
|
||||||
|
// swiftlint:disable discouraged_optional_collection
|
||||||
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
override func render(_ dictionary: [String: Any]? = nil) throws -> String {
|
||||||
return "here"
|
"here"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,284 +1,355 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ExpressionsTests: XCTestCase {
|
||||||
|
private let parser = TokenParser(tokens: [], environment: Environment())
|
||||||
|
|
||||||
func testExpressions() {
|
private func makeExpression(_ components: [String]) -> Stencil.Expression {
|
||||||
describe("Expression") {
|
do {
|
||||||
let parser = TokenParser(tokens: [], environment: Environment())
|
let parser = try IfExpressionParser.parser(
|
||||||
|
components: components,
|
||||||
|
environment: Environment(),
|
||||||
|
token: .text(value: "", at: .unknown)
|
||||||
|
)
|
||||||
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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"], tokenParser: parser)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func testTrueInExpression() throws {
|
||||||
|
let expression = makeExpression(["lhs", "in", "rhs"])
|
||||||
|
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": 1,
|
||||||
|
"rhs": [1, 2, 3]
|
||||||
|
]))).to.beTrue()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": "a",
|
||||||
|
"rhs": ["a", "b", "c"]
|
||||||
|
]))).to.beTrue()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": "a",
|
||||||
|
"rhs": "abc"
|
||||||
|
]))).to.beTrue()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": 1,
|
||||||
|
"rhs": 1...3
|
||||||
|
]))).to.beTrue()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": 1,
|
||||||
|
"rhs": 1..<3
|
||||||
|
]))).to.beTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFalseInExpression() throws {
|
||||||
|
let expression = makeExpression(["lhs", "in", "rhs"])
|
||||||
|
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": 1,
|
||||||
|
"rhs": [2, 3, 4]
|
||||||
|
]))).to.beFalse()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": "a",
|
||||||
|
"rhs": ["b", "c", "d"]
|
||||||
|
]))).to.beFalse()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": "a",
|
||||||
|
"rhs": "bcd"
|
||||||
|
]))).to.beFalse()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": 4,
|
||||||
|
"rhs": 1...3
|
||||||
|
]))).to.beFalse()
|
||||||
|
try expect(expression.evaluate(context: Context(dictionary: [
|
||||||
|
"lhs": 3,
|
||||||
|
"rhs": 1..<3
|
||||||
|
]))).to.beFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -18,149 +18,440 @@ func testFilter() {
|
|||||||
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 a custom filter which accepts single argument") {
|
it("allows you to register boolean filters") {
|
||||||
let template = Template(templateString: "{{ name|repeat:'value1, \"value2\"' }}")
|
|
||||||
|
|
||||||
let repeatExtension = Extension()
|
let repeatExtension = Extension()
|
||||||
repeatExtension.registerFilter("repeat") { value, arguments in
|
repeatExtension.registerFilter(name: "isPositive", negativeFilterName: "isNotPositive") { (value: Any?) in
|
||||||
if !arguments.isEmpty {
|
if let value = value as? Int {
|
||||||
return "\(value!) \(value!) with args \(arguments.first!!)"
|
return value > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
let result = try Template(templateString: "{{ value|isPositive }}")
|
||||||
try expect(result) == "Kyle Kyle with args value1, \"value2\""
|
.render(Context(dictionary: ["value": 1], environment: Environment(extensions: [repeatExtension])))
|
||||||
|
try expect(result) == "true"
|
||||||
|
|
||||||
|
let negativeResult = try Template(templateString: "{{ value|isNotPositive }}")
|
||||||
|
.render(Context(dictionary: ["value": -1], environment: Environment(extensions: [repeatExtension])))
|
||||||
|
try expect(negativeResult) == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to register a custom filter which accepts several arguments") {
|
it("allows you to register a custom which throws") {
|
||||||
let template = Template(templateString: "{{ name|repeat:'value\"1\"',\"value'2'\",'(key, value)' }}")
|
|
||||||
|
|
||||||
let repeatExtension = Extension()
|
|
||||||
repeatExtension.registerFilter("repeat") { value, arguments in
|
|
||||||
if !arguments.isEmpty {
|
|
||||||
return "\(value!) \(value!) with args 0: \(arguments[0]!), 1: \(arguments[1]!), 2: \(arguments[2]!)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = try template.render(Context(dictionary: context, environment: Environment(extensions: [repeatExtension])))
|
|
||||||
try expect(result) == "Kyle Kyle with args 0: value\"1\", 1: value'2', 2: (key, value)"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("allows you to register a custom which throws") {
|
|
||||||
let template = Template(templateString: "{{ name|repeat }}")
|
let template = Template(templateString: "{{ name|repeat }}")
|
||||||
let repeatExtension = Extension()
|
let repeatExtension = Extension()
|
||||||
repeatExtension.registerFilter("repeat") { (value: Any?) in
|
repeatExtension.registerFilter("repeat") { (_: Any?) in
|
||||||
throw TemplateSyntaxError("No Repeat")
|
throw TemplateSyntaxError("No Repeat")
|
||||||
}
|
}
|
||||||
|
|
||||||
let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))
|
let context = Context(dictionary: context, environment: Environment(extensions: [repeatExtension]))
|
||||||
try expect(try template.render(context)).toThrow(TemplateSyntaxError("No Repeat"))
|
try expect(try template.render(context))
|
||||||
|
.toThrow(TemplateSyntaxError(reason: "No Repeat", token: template.tokens.first))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("allows you to override a default filter") {
|
it("throws when you pass arguments to simple 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: "{{ name | uppercase }}")
|
|
||||||
let result = try template.render(Context(dictionary: ["name": "kyle"]))
|
|
||||||
try expect(result) == "KYLE"
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("throws when you pass arguments to simple filter") {
|
|
||||||
let template = Template(templateString: "{{ name|uppercase:5 }}")
|
let template = Template(templateString: "{{ name|uppercase:5 }}")
|
||||||
try expect(try template.render(Context(dictionary: ["name": "kyle"]))).toThrow()
|
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"]
|
||||||
|
|
||||||
describe("capitalize filter") {
|
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: """
|
||||||
|
{{ name|repeat:'value1, "value2"' }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
let repeatExtension = Extension()
|
||||||
|
repeatExtension.registerFilter("repeat") { value, arguments in
|
||||||
|
guard let value = value,
|
||||||
|
let argument = arguments.first else { return nil }
|
||||||
|
|
||||||
|
return "\(value) \(value) with args \(argument ?? "")"
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = try template.render(Context(
|
||||||
|
dictionary: context,
|
||||||
|
environment: Environment(extensions: [repeatExtension])
|
||||||
|
))
|
||||||
|
try expect(result) == """
|
||||||
|
Kyle Kyle with args value1, "value2"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("allows you to register a custom filter which accepts several arguments") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ name|repeat:'value"1"',"value'2'",'(key, value)' }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
let repeatExtension = Extension()
|
||||||
|
repeatExtension.registerFilter("repeat") { value, arguments in
|
||||||
|
guard let value = value else { return nil }
|
||||||
|
let args = arguments.compactMap { $0 }
|
||||||
|
return "\(value) \(value) with args 0: \(args[0]), 1: \(args[1]), 2: \(args[2])"
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = try template.render(Context(
|
||||||
|
dictionary: context,
|
||||||
|
environment: Environment(extensions: [repeatExtension])
|
||||||
|
))
|
||||||
|
try expect(result) == """
|
||||||
|
Kyle Kyle with args 0: value"1", 1: value'2', 2: (key, value)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("allows whitespace in expression") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value | join : ", " }}
|
||||||
|
""")
|
||||||
|
let result = try template.render(Context(dictionary: ["value": ["One", "Two"]]))
|
||||||
|
try expect(result) == "One, Two"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStringFilters() {
|
||||||
|
it("transforms a string to be capitalized") {
|
||||||
let template = Template(templateString: "{{ name|capitalize }}")
|
let template = Template(templateString: "{{ name|capitalize }}")
|
||||||
|
|
||||||
$0.it("capitalizes a string") {
|
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
it("transforms a string to be uppercase") {
|
||||||
describe("uppercase filter") {
|
|
||||||
let template = Template(templateString: "{{ name|uppercase }}")
|
let template = Template(templateString: "{{ name|uppercase }}")
|
||||||
|
|
||||||
$0.it("transforms a string to be 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"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
describe("lowercase filter") {
|
it("transforms a string to be lowercase") {
|
||||||
let template = Template(templateString: "{{ name|lowercase }}")
|
let template = Template(templateString: "{{ name|lowercase }}")
|
||||||
|
|
||||||
$0.it("transforms a string to be 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("default filter") {
|
func testStringFiltersWithArrays() {
|
||||||
let template = Template(templateString: "Hello {{ name|default:\"World\" }}")
|
it("transforms a string to be capitalized") {
|
||||||
|
let template = Template(templateString: "{{ names|capitalize }}")
|
||||||
|
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
||||||
|
try expect(result) == """
|
||||||
|
["Kyle", "Kyle"]
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
$0.it("shows the variable value") {
|
it("transforms a string to be uppercase") {
|
||||||
|
let template = Template(templateString: "{{ names|uppercase }}")
|
||||||
|
let result = try template.render(Context(dictionary: ["names": ["kyle", "kyle"]]))
|
||||||
|
try expect(result) == """
|
||||||
|
["KYLE", "KYLE"]
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("transforms a string to be lowercase") {
|
||||||
|
let template = Template(templateString: "{{ names|lowercase }}")
|
||||||
|
let result = try template.render(Context(dictionary: ["names": ["Kyle", "Kyle"]]))
|
||||||
|
try expect(result) == """
|
||||||
|
["kyle", "kyle"]
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDefaultFilter() {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
Hello {{ name|default:"World" }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
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: "Hello {{ name|default:a,b,c,\"World\" }}")
|
let template = Template(templateString: """
|
||||||
|
Hello {{ name|default:a,b,c,"World" }}
|
||||||
|
""")
|
||||||
let result = try template.render(Context(dictionary: [:]))
|
let result = try template.render(Context(dictionary: [:]))
|
||||||
try expect(result) == "Hello World"
|
try expect(result) == "Hello World"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("can use int as default") {
|
||||||
|
let template = Template(templateString: "{{ value|default:1 }}")
|
||||||
|
let result = try template.render(Context(dictionary: [:]))
|
||||||
|
try expect(result) == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("join filter") {
|
it("can use float as default") {
|
||||||
let template = Template(templateString: "{{ value|join:\", \" }}")
|
let template = Template(templateString: "{{ value|default:1.5 }}")
|
||||||
|
let result = try template.render(Context(dictionary: [:]))
|
||||||
|
try expect(result) == "1.5"
|
||||||
|
}
|
||||||
|
|
||||||
$0.it("joins a collection of strings") {
|
it("checks for underlying nil value correctly") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
Hello {{ user.name|default:"anonymous" }}
|
||||||
|
""")
|
||||||
|
let nilName: String? = nil
|
||||||
|
let user: [String: Any?] = ["name": nilName]
|
||||||
|
let result = try template.render(Context(dictionary: ["user": user]))
|
||||||
|
try expect(result) == "Hello anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJoinFilter() {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value|join:", " }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
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: "{{ value|join:separator }}")
|
let template = Template(templateString: """
|
||||||
|
{{ value|join:separator }}
|
||||||
|
""")
|
||||||
let result = try template.render(Context(dictionary: ["value": ["One", "Two"], "separator": true]))
|
let result = try template.render(Context(dictionary: ["value": ["One", "Two"], "separator": true]))
|
||||||
try expect(result) == "OnetrueTwo"
|
try expect(result) == "OnetrueTwo"
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can join without arguments") {
|
it("can join without arguments") {
|
||||||
let template = Template(templateString: "{{ value|join }}")
|
let template = Template(templateString: """
|
||||||
|
{{ 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) == "OneTwo"
|
try expect(result) == "OneTwo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSplitFilter() {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value|split:", " }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
it("split a string into array") {
|
||||||
|
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
|
||||||
|
try expect(result) == """
|
||||||
|
["One", "Two"]
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can split without arguments") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value|split }}
|
||||||
|
""")
|
||||||
|
let result = try template.render(Context(dictionary: ["value": "One, Two"]))
|
||||||
|
try expect(result) == """
|
||||||
|
["One,", "Two"]
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFilterSuggestion() {
|
||||||
|
it("made for unknown filter") {
|
||||||
|
let template = Template(templateString: "{{ value|unknownFilter }}")
|
||||||
|
let filterExtension = Extension()
|
||||||
|
filterExtension.registerFilter("knownFilter") { value, _ in value }
|
||||||
|
|
||||||
|
try self.expectError(
|
||||||
|
reason: "Unknown filter 'unknownFilter'. Found similar filters: 'knownFilter'.",
|
||||||
|
token: "value|unknownFilter",
|
||||||
|
template: template,
|
||||||
|
extension: filterExtension
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("made for multiple similar filters") {
|
||||||
|
let template = Template(templateString: "{{ value|lowerFirst }}")
|
||||||
|
let filterExtension = Extension()
|
||||||
|
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
|
||||||
|
filterExtension.registerFilter("lowerFirstLetter") { value, _ in value }
|
||||||
|
|
||||||
|
try self.expectError(
|
||||||
|
reason: "Unknown filter 'lowerFirst'. Found similar filters: 'lowerFirstWord', 'lowercase'.",
|
||||||
|
token: "value|lowerFirst",
|
||||||
|
template: template,
|
||||||
|
extension: filterExtension
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("not made when can't find similar filter") {
|
||||||
|
let template = Template(templateString: "{{ value|unknownFilter }}")
|
||||||
|
let filterExtension = Extension()
|
||||||
|
filterExtension.registerFilter("lowerFirstWord") { value, _ in value }
|
||||||
|
|
||||||
|
try self.expectError(
|
||||||
|
reason: "Unknown filter 'unknownFilter'. Found similar filters: 'lowerFirstWord'.",
|
||||||
|
token: "value|unknownFilter",
|
||||||
|
template: template,
|
||||||
|
extension: filterExtension
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIndentContent() throws {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value|indent:2 }}
|
||||||
|
""")
|
||||||
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
|
One
|
||||||
|
Two
|
||||||
|
"""
|
||||||
|
]))
|
||||||
|
try expect(result) == """
|
||||||
|
One
|
||||||
|
Two
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIndentWithArbitraryCharacter() throws {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value|indent:2,"\t" }}
|
||||||
|
""")
|
||||||
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
|
One
|
||||||
|
Two
|
||||||
|
"""
|
||||||
|
]))
|
||||||
|
try expect(result) == """
|
||||||
|
One
|
||||||
|
\t\tTwo
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIndentFirstLine() throws {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value|indent:2," ",true }}
|
||||||
|
""")
|
||||||
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
|
One
|
||||||
|
Two
|
||||||
|
"""
|
||||||
|
]))
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
One
|
||||||
|
Two
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIndentNotEmptyLines() throws {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{{ value|indent }}
|
||||||
|
""")
|
||||||
|
let result = try template.render(Context(dictionary: [
|
||||||
|
"value": """
|
||||||
|
One
|
||||||
|
|
||||||
|
|
||||||
|
Two
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
]))
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
One
|
||||||
|
|
||||||
|
|
||||||
|
Two
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
// 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,25 +1,54 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
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: "{% filter %}Test{% endfilter %}")
|
let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
|
||||||
try expect(try template.render()).toThrow()
|
try expect(try template.render()).toThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("can render filters with arguments") {
|
||||||
|
let ext = Extension()
|
||||||
|
ext.registerFilter("split") { value, args in
|
||||||
|
guard let value = value as? String,
|
||||||
|
let argument = args.first as? String else { return value }
|
||||||
|
return value.components(separatedBy: argument)
|
||||||
|
}
|
||||||
|
let env = Environment(extensions: [ext])
|
||||||
|
let result = try env.renderTemplate(string: """
|
||||||
|
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
|
||||||
|
""", context: ["items": [1, 2]])
|
||||||
|
try expect(result) == "1;2"
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can render filters with quote as an argument") {
|
||||||
|
let ext = Extension()
|
||||||
|
ext.registerFilter("replace") { value, args in
|
||||||
|
guard let value = value as? String,
|
||||||
|
args.count == 2,
|
||||||
|
let search = args.first as? String,
|
||||||
|
let replacement = args.last as? String else { return value }
|
||||||
|
return value.replacingOccurrences(of: search, with: replacement)
|
||||||
|
}
|
||||||
|
let env = Environment(extensions: [ext])
|
||||||
|
let result = try env.renderTemplate(string: """
|
||||||
|
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
|
||||||
|
""", context: ["items": ["\"1\"", "\"2\""]])
|
||||||
|
try expect(result) == "1,2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +1,591 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
import Foundation
|
import XCTest
|
||||||
|
|
||||||
|
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)]
|
||||||
])
|
])
|
||||||
|
|
||||||
$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"
|
||||||
}
|
}
|
||||||
|
|
||||||
#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: [])
|
{% for article in ars | default: a, b , articles %}\
|
||||||
try expect(try node.render(context)) == "1true2false3false"
|
- {{ article.title }} by {{ article.author }}.
|
||||||
}
|
{% endfor %}
|
||||||
|
""")
|
||||||
$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 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()))
|
|
||||||
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()))
|
|
||||||
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") {
|
|
||||||
let templateString = "{% for article in ars|default:articles %}" +
|
|
||||||
"- {{ article.title }} by {{ article.author }}.\n" +
|
|
||||||
"{% endfor %}\n"
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
let fixture = "" +
|
try expect(result) == """
|
||||||
"- Migrating from OCUnit to XCTest by Kyle Fuller.\n" +
|
- Migrating from OCUnit to XCTest by Kyle Fuller.
|
||||||
"- Memory Management with ARC by Kyle Fuller.\n" +
|
- Memory Management with ARC by Kyle Fuller.
|
||||||
"\n"
|
|
||||||
|
|
||||||
try expect(result) == fixture
|
"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders supports iterating over dictionary") {
|
func testLoopMetadata() {
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "key")]
|
it("renders the given nodes while providing if the item is first in the context") {
|
||||||
|
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.first")]
|
||||||
|
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||||
|
try expect(try node.render(self.context)) == "1true2false3false"
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders the given nodes while providing if the item is last in the context") {
|
||||||
|
let nodes: [NodeType] = [VariableNode(variable: "item"), VariableNode(variable: "forloop.last")]
|
||||||
|
let node = ForNode(resolvable: Variable("items"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||||
|
try expect(try node.render(self.context)) == "1false2false3true"
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders the given nodes while providing 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 emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||||
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
|
let parser = TokenParser(tokens: [], environment: Environment())
|
||||||
try expect(try node.render(context)) == "onetwo"
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("renders supports iterating over dictionary") {
|
func testArrayOfTuples() {
|
||||||
let nodes: [NodeType] = [VariableNode(variable: "key"), VariableNode(variable: "value")]
|
it("can iterate over all tuple values") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{% for first,second,third in tuples %}\
|
||||||
|
{{ first }}, {{ second }}, {{ third }}
|
||||||
|
{% endfor %}
|
||||||
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
|
1, 2, 3
|
||||||
|
4, 5, 6
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can iterate with less number of variables") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{% for first,second in tuples %}\
|
||||||
|
{{ first }}, {{ second }}
|
||||||
|
{% endfor %}
|
||||||
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
|
1, 2
|
||||||
|
4, 5
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can use _ to skip variables") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{% for first,_,third in tuples %}\
|
||||||
|
{{ first }}, {{ third }}
|
||||||
|
{% endfor %}
|
||||||
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
|
1, 3
|
||||||
|
4, 6
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("throws when number of variables is more than number of tuple values") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{% for key,value,smth in dict %}{% endfor %}
|
||||||
|
""")
|
||||||
|
try expect(template.render(self.context)).toThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIterateDictionary() {
|
||||||
|
it("can iterate over dictionary") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{% for key, value in dict %}\
|
||||||
|
{{ key }}: {{ value }},\
|
||||||
|
{% endfor %}
|
||||||
|
""")
|
||||||
|
try expect(template.render(self.context)) == """
|
||||||
|
one: I,two: II,
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders supports iterating over dictionary") {
|
||||||
|
let nodes: [NodeType] = [
|
||||||
|
VariableNode(variable: "key"),
|
||||||
|
TextNode(text: ",")
|
||||||
|
]
|
||||||
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||||
let node = ForNode(resolvable: Variable("dict"), loopVariables: ["key", "value"], nodes: nodes, emptyNodes: emptyNodes, where: nil)
|
let node = ForNode(
|
||||||
try expect(try node.render(context)) == "oneItwoII"
|
resolvable: Variable("dict"),
|
||||||
|
loopVariables: ["key"],
|
||||||
|
nodes: nodes,
|
||||||
|
emptyNodes: emptyNodes
|
||||||
|
)
|
||||||
|
|
||||||
|
try expect(node.render(self.context)) == """
|
||||||
|
one,two,
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders supports iterating over dictionary with values") {
|
||||||
|
let nodes: [NodeType] = [
|
||||||
|
VariableNode(variable: "key"),
|
||||||
|
TextNode(text: "="),
|
||||||
|
VariableNode(variable: "value"),
|
||||||
|
TextNode(text: ",")
|
||||||
|
]
|
||||||
|
let emptyNodes: [NodeType] = [TextNode(text: "empty")]
|
||||||
|
let node = ForNode(
|
||||||
|
resolvable: Variable("dict"),
|
||||||
|
loopVariables: ["key", "value"],
|
||||||
|
nodes: nodes,
|
||||||
|
emptyNodes: emptyNodes
|
||||||
|
)
|
||||||
|
|
||||||
|
try expect(node.render(self.context)) == """
|
||||||
|
one=I,two=II,
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIterateUsingMirroring() {
|
||||||
|
let nodes: [NodeType] = [
|
||||||
|
VariableNode(variable: "label"),
|
||||||
|
TextNode(text: "="),
|
||||||
|
VariableNode(variable: "value"),
|
||||||
|
TextNode(text: "\n")
|
||||||
|
]
|
||||||
|
let node = ForNode(
|
||||||
|
resolvable: Variable("item"),
|
||||||
|
loopVariables: ["label", "value"],
|
||||||
|
nodes: nodes,
|
||||||
|
emptyNodes: []
|
||||||
|
)
|
||||||
|
|
||||||
|
it("can iterate over struct properties") {
|
||||||
|
let context = Context(dictionary: [
|
||||||
|
"item": MyStruct(string: "abc", number: 123)
|
||||||
|
])
|
||||||
|
try expect(node.render(context)) == """
|
||||||
|
string=abc
|
||||||
|
number=123
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can iterate tuple items") {
|
||||||
|
let context = Context(dictionary: [
|
||||||
|
"item": (one: 1, two: "dva")
|
||||||
|
])
|
||||||
|
try expect(node.render(context)) == """
|
||||||
|
one=1
|
||||||
|
two=dva
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can iterate over class properties") {
|
||||||
|
let context = Context(dictionary: [
|
||||||
|
"item": MySubclass("child", "base", 1)
|
||||||
|
])
|
||||||
|
try expect(node.render(context)) == """
|
||||||
|
childString=child
|
||||||
|
baseString=base
|
||||||
|
baseInt=1
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIterateRange() {
|
||||||
|
it("renders a context variable of type CountableClosedRange<Int>") {
|
||||||
|
let context = Context(dictionary: ["range": 1...3])
|
||||||
|
let nodes: [NodeType] = [VariableNode(variable: "item")]
|
||||||
|
let node = ForNode(resolvable: Variable("range"), loopVariables: ["item"], nodes: nodes, emptyNodes: [])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
fileprivate struct Article {
|
private struct MyStruct {
|
||||||
|
let string: String
|
||||||
|
let number: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Article {
|
||||||
let title: String
|
let title: String
|
||||||
let author: String
|
let author: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class MyClass {
|
||||||
|
var baseString: String
|
||||||
|
var baseInt: Int
|
||||||
|
init(_ string: String, _ int: Int) {
|
||||||
|
baseString = string
|
||||||
|
baseInt = int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MySubclass: MyClass {
|
||||||
|
var childString: String
|
||||||
|
init(_ childString: String, _ string: String, _ int: Int) {
|
||||||
|
self.childString = childString
|
||||||
|
super.init(string, int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
63
Tests/StencilTests/Helpers.swift
Normal file
63
Tests/StencilTests/Helpers.swift
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import PathKit
|
||||||
|
import Spectre
|
||||||
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
extension Expectation {
|
||||||
|
@discardableResult
|
||||||
|
func toThrow<E: Error>() throws -> E {
|
||||||
|
var thrownError: Error?
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try expression()
|
||||||
|
} catch {
|
||||||
|
thrownError = error
|
||||||
|
}
|
||||||
|
|
||||||
|
if let thrownError = thrownError {
|
||||||
|
if let thrownError = thrownError as? E {
|
||||||
|
return thrownError
|
||||||
|
} else {
|
||||||
|
throw failure("\(thrownError) is not \(T.self)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw failure("expression did not throw an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XCTestCase {
|
||||||
|
func expectedSyntaxError(token: String, template: Template, description: String) -> TemplateSyntaxError {
|
||||||
|
guard let range = template.templateString.range(of: token) else {
|
||||||
|
fatalError("Can't find '\(token)' in '\(template)'")
|
||||||
|
}
|
||||||
|
let lexer = Lexer(templateString: template.templateString)
|
||||||
|
let location = lexer.rangeLocation(range)
|
||||||
|
let sourceMap = SourceMap(filename: template.name, location: location)
|
||||||
|
let token = Token.block(value: token, at: sourceMap)
|
||||||
|
return TemplateSyntaxError(reason: description, token: token, stackTrace: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test Types
|
||||||
|
|
||||||
|
class ExampleLoader: Loader {
|
||||||
|
func loadTemplate(name: String, environment: Environment) throws -> Template {
|
||||||
|
if name == "example.html" {
|
||||||
|
return Template(templateString: "Hello World!", environment: environment, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TemplateDoesNotExist(templateNames: [name], loader: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorNode: NodeType {
|
||||||
|
let token: Token?
|
||||||
|
init(token: Token? = nil) {
|
||||||
|
self.token = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func render(_ context: Context) throws -> String {
|
||||||
|
throw TemplateSyntaxError("Custom Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
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"),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true"),
|
.text(value: "true", at: .unknown),
|
||||||
.block(value: "endif")
|
.block(value: "endif", at: .unknown)
|
||||||
]
|
]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -23,13 +22,28 @@ func testIfNode() {
|
|||||||
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] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value"),
|
.block(value: """
|
||||||
.text(value: "true"),
|
if value == \"test\" and (not name or not (name and surname) or( some )and other )
|
||||||
.block(value: "else"),
|
""", at: .unknown),
|
||||||
.text(value: "false"),
|
.text(value: "true", at: .unknown),
|
||||||
.block(value: "endif")
|
.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] = [
|
||||||
|
.block(value: "if value", at: .unknown),
|
||||||
|
.text(value: "true", at: .unknown),
|
||||||
|
.block(value: "else", at: .unknown),
|
||||||
|
.text(value: "false", at: .unknown),
|
||||||
|
.block(value: "endif", at: .unknown)
|
||||||
]
|
]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -48,15 +62,15 @@ func testIfNode() {
|
|||||||
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"),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true"),
|
.text(value: "true", at: .unknown),
|
||||||
.block(value: "elif something"),
|
.block(value: "elif something", at: .unknown),
|
||||||
.text(value: "some"),
|
.text(value: "some", at: .unknown),
|
||||||
.block(value: "else"),
|
.block(value: "else", at: .unknown),
|
||||||
.text(value: "false"),
|
.text(value: "false", at: .unknown),
|
||||||
.block(value: "endif")
|
.block(value: "endif", at: .unknown)
|
||||||
]
|
]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -79,13 +93,13 @@ func testIfNode() {
|
|||||||
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"),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true"),
|
.text(value: "true", at: .unknown),
|
||||||
.block(value: "elif something"),
|
.block(value: "elif something", at: .unknown),
|
||||||
.text(value: "some"),
|
.text(value: "some", at: .unknown),
|
||||||
.block(value: "endif")
|
.block(value: "endif", at: .unknown)
|
||||||
]
|
]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -104,17 +118,17 @@ func testIfNode() {
|
|||||||
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"),
|
.block(value: "if value", at: .unknown),
|
||||||
.text(value: "true"),
|
.text(value: "true", at: .unknown),
|
||||||
.block(value: "elif something1"),
|
.block(value: "elif something1", at: .unknown),
|
||||||
.text(value: "some1"),
|
.text(value: "some1", at: .unknown),
|
||||||
.block(value: "elif something2"),
|
.block(value: "elif something2", at: .unknown),
|
||||||
.text(value: "some2"),
|
.text(value: "some2", at: .unknown),
|
||||||
.block(value: "else"),
|
.block(value: "else", at: .unknown),
|
||||||
.text(value: "false"),
|
.text(value: "false", at: .unknown),
|
||||||
.block(value: "endif")
|
.block(value: "endif", at: .unknown)
|
||||||
]
|
]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -141,26 +155,13 @@ func testIfNode() {
|
|||||||
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] = [
|
let tokens: [Token] = [
|
||||||
.block(value: "if value == \"test\" and not name"),
|
.block(value: "ifnot value", at: .unknown),
|
||||||
.text(value: "true"),
|
.text(value: "false", at: .unknown),
|
||||||
.block(value: "endif")
|
.block(value: "else", at: .unknown),
|
||||||
]
|
.text(value: "true", at: .unknown),
|
||||||
|
.block(value: "endif", at: .unknown)
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
|
||||||
let nodes = try parser.parse()
|
|
||||||
try expect(nodes.first is IfNode).beTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
$0.it("can parse an ifnot block") {
|
|
||||||
let tokens: [Token] = [
|
|
||||||
.block(value: "ifnot value"),
|
|
||||||
.text(value: "false"),
|
|
||||||
.block(value: "else"),
|
|
||||||
.text(value: "true"),
|
|
||||||
.block(value: "endif")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -178,73 +179,70 @@ func testIfNode() {
|
|||||||
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() {
|
||||||
let tokens: [Token] = [
|
it("throws an error when parsing an if block without an endif") {
|
||||||
.block(value: "if value"),
|
let tokens: [Token] = [.block(value: "if value", at: .unknown)]
|
||||||
]
|
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
let error = TemplateSyntaxError("`endif` was not found.")
|
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
|
||||||
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] = [
|
let tokens: [Token] = [.block(value: "ifnot value", at: .unknown)]
|
||||||
.block(value: "ifnot value"),
|
|
||||||
]
|
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
let error = TemplateSyntaxError("`endif` was not found.")
|
let error = TemplateSyntaxError(reason: "`endif` was not found.", token: tokens.first)
|
||||||
try expect(try parser.parse()).toThrow(error)
|
try expect(try parser.parse()).toThrow(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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\""),
|
.block(value: "if value|uppercase == \"TEST\"", at: .unknown),
|
||||||
.text(value: "true"),
|
.text(value: "true", at: .unknown),
|
||||||
.block(value: "endif")
|
.block(value: "endif", at: .unknown)
|
||||||
]
|
]
|
||||||
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
@@ -253,5 +251,40 @@ func testIfNode() {
|
|||||||
let result = try renderNodes(nodes, Context(dictionary: ["value": "test"]))
|
let result = try renderNodes(nodes, Context(dictionary: ["value": "test"]))
|
||||||
try expect(result) == "true"
|
try expect(result) == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testEvaluatesNilAsFalse() throws {
|
||||||
|
let tokens: [Token] = [
|
||||||
|
.block(value: "if instance.value", at: .unknown),
|
||||||
|
.text(value: "true", at: .unknown),
|
||||||
|
.block(value: "endif", at: .unknown)
|
||||||
|
]
|
||||||
|
|
||||||
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
|
let nodes = try parser.parse()
|
||||||
|
|
||||||
|
let result = try renderNodes(nodes, Context(dictionary: ["instance": SomeType()]))
|
||||||
|
try expect(result) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupportsRangeVariables() throws {
|
||||||
|
let tokens: [Token] = [
|
||||||
|
.block(value: "if value in 1...3", at: .unknown),
|
||||||
|
.text(value: "true", at: .unknown),
|
||||||
|
.block(value: "else", at: .unknown),
|
||||||
|
.text(value: "false", at: .unknown),
|
||||||
|
.block(value: "endif", at: .unknown)
|
||||||
|
]
|
||||||
|
|
||||||
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
|
let nodes = try parser.parse()
|
||||||
|
|
||||||
|
try expect(renderNodes(nodes, Context(dictionary: ["value": 3]))) == "true"
|
||||||
|
try expect(renderNodes(nodes, Context(dictionary: ["value": 4]))) == "false"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private struct SomeType {
|
||||||
|
let value: String? = nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
|
import PathKit
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
import PathKit
|
import XCTest
|
||||||
|
|
||||||
|
final class IncludeTests: XCTestCase {
|
||||||
|
private let path = Path(#file as String)! / ".." / "fixtures"
|
||||||
|
private lazy var loader = FileSystemLoader(paths: [path])
|
||||||
|
private lazy var environment = Environment(loader: loader)
|
||||||
|
|
||||||
func testInclude() {
|
func testParsing() {
|
||||||
describe("Include") {
|
it("throws an error when no template is given") {
|
||||||
let path = Path(#file) + ".." + "fixtures"
|
let tokens: [Token] = [ .block(value: "include", at: .unknown) ]
|
||||||
let loader = FileSystemLoader(paths: [path])
|
|
||||||
let environment = Environment(loader: loader)
|
|
||||||
|
|
||||||
$0.describe("parsing") {
|
|
||||||
$0.it("throws an error when no template is given") {
|
|
||||||
let tokens: [Token] = [ .block(value: "include") ]
|
|
||||||
let parser = TokenParser(tokens: tokens, environment: Environment())
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
|
|
||||||
let error = TemplateSyntaxError("'include' tag takes one argument, the template file to be included")
|
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\"") ]
|
let tokens: [Token] = [ .block(value: "include \"test.html\"", 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()
|
||||||
@@ -29,9 +32,9 @@ func testInclude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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\""))
|
let node = IncludeNode(templateName: Variable("\"test.html\""), token: .block(value: "", at: .unknown))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try node.render(Context())
|
_ = try node.render(Context())
|
||||||
@@ -40,22 +43,30 @@ func testInclude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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\""))
|
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\""))
|
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!"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("successfully passes context") {
|
||||||
|
let template = Template(templateString: """
|
||||||
|
{% include "test.html" child %}
|
||||||
|
""")
|
||||||
|
let context = Context(dictionary: ["child": ["target": "World"]], environment: self.environment)
|
||||||
|
let value = try template.render(context)
|
||||||
|
try expect(value) == "Hello World!"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
73
Tests/StencilTests/InheritanceSpec.swift
Normal file
73
Tests/StencilTests/InheritanceSpec.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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,27 +0,0 @@
|
|||||||
import Spectre
|
|
||||||
import Stencil
|
|
||||||
import PathKit
|
|
||||||
|
|
||||||
|
|
||||||
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()) == "Header\nChild"
|
|
||||||
}
|
|
||||||
|
|
||||||
$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()) == "Child Child Header\nChild"
|
|
||||||
}
|
|
||||||
|
|
||||||
$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\nChild Body"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,177 @@
|
|||||||
|
import PathKit
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class LexerTests: XCTestCase {
|
||||||
func testLexer() {
|
func testText() throws {
|
||||||
describe("Lexer") {
|
|
||||||
$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()
|
||||||
|
|
||||||
try expect(tokens.count) == 1
|
try expect(tokens.count) == 1
|
||||||
try expect(tokens.first) == .text(value: "Hello World")
|
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()
|
||||||
|
|
||||||
try expect(tokens.count) == (1)
|
try expect(tokens.count) == 1
|
||||||
try expect(tokens.first) == .comment(value: "Comment")
|
try expect(tokens.first) == .comment(value: "Comment", at: makeSourceMap("Comment", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize a variable") {
|
func testEscapedVariableToken() throws {
|
||||||
|
let lexer = Lexer(templateString: "\\{{ Variable }}")
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
|
try expect(tokens.count) == 1
|
||||||
|
try expect(tokens.first) == .text(value: "{{ Variable }}", at: makeSourceMap("{{ Variable }}", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEscapedBehaviourToken() throws {
|
||||||
|
let lexer = Lexer(templateString: "\\{% Variable %}")
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
|
try expect(tokens.count) == 1
|
||||||
|
try expect(tokens.first) == .text(value: "{% Variable %}", at: makeSourceMap("{% Variable %}", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVariable() throws {
|
||||||
let lexer = Lexer(templateString: "{{ Variable }}")
|
let lexer = Lexer(templateString: "{{ Variable }}")
|
||||||
let tokens = lexer.tokenize()
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
try expect(tokens.count) == 1
|
try expect(tokens.count) == 1
|
||||||
try expect(tokens.first) == .variable(value: "Variable")
|
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize a mixture of content") {
|
func testTokenWithoutSpaces() throws {
|
||||||
let lexer = Lexer(templateString: "My name is {{ name }}.")
|
let lexer = Lexer(templateString: "{{Variable}}")
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
|
try expect(tokens.count) == 1
|
||||||
|
try expect(tokens.first) == .variable(value: "Variable", at: makeSourceMap("Variable", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnclosedTag() throws {
|
||||||
|
let templateString = "{{ thing"
|
||||||
|
let lexer = Lexer(templateString: templateString)
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
|
try expect(tokens.count) == 1
|
||||||
|
try expect(tokens.first) == .text(value: "", at: makeSourceMap("{{ thing", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContentMixture() throws {
|
||||||
|
let templateString = "My name is {{ myname }}."
|
||||||
|
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 ")
|
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is ", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "name")
|
try expect(tokens[1]) == .variable(value: "myname", at: makeSourceMap("myname", for: lexer))
|
||||||
try expect(tokens[2]) == Token.text(value: ".")
|
try expect(tokens[2]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can tokenize two variables without being greedy") {
|
func testVariablesWithoutBeingGreedy() throws {
|
||||||
let lexer = Lexer(templateString: "{{ thing }}{{ name }}")
|
let templateString = "{{ thing }}{{ name }}"
|
||||||
|
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")
|
try expect(tokens[0]) == .variable(value: "thing", at: makeSourceMap("thing", for: lexer))
|
||||||
try expect(tokens[1]) == Token.variable(value: "name")
|
try expect(tokens[1]) == .variable(value: "name", at: makeSourceMap("name", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnclosedBlock() throws {
|
||||||
|
let lexer = Lexer(templateString: "{%}")
|
||||||
|
_ = lexer.tokenize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTokenizeIncorrectSyntaxWithoutCrashing() throws {
|
||||||
|
let lexer = Lexer(templateString: "func some() {{% if %}")
|
||||||
|
_ = lexer.tokenize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyVariable() throws {
|
||||||
|
let lexer = Lexer(templateString: "{{}}")
|
||||||
|
_ = lexer.tokenize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewlines() throws {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let templateString = """
|
||||||
|
My name is {%
|
||||||
|
if name
|
||||||
|
and
|
||||||
|
name
|
||||||
|
%}{{
|
||||||
|
name
|
||||||
|
}}{%
|
||||||
|
endif %}.
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
let lexer = Lexer(templateString: templateString)
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
|
try expect(tokens.count) == 5
|
||||||
|
try expect(tokens[0]) == .text(value: "My name is ", at: makeSourceMap("My name is", for: lexer))
|
||||||
|
try expect(tokens[1]) == .block(value: "if name and name", at: makeSourceMap("{%", for: lexer))
|
||||||
|
try expect(tokens[2]) == .variable(value: "name", at: makeSourceMap("name", for: lexer, options: .backwards))
|
||||||
|
try expect(tokens[3]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||||
|
try expect(tokens[4]) == .text(value: ".", at: makeSourceMap(".", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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 lexer = Lexer(templateString: templateString)
|
||||||
|
let tokens = lexer.tokenize()
|
||||||
|
|
||||||
|
try expect(tokens.count) == 5
|
||||||
|
try expect(tokens[0]) == .text(value: "class Some ", at: makeSourceMap("class Some ", for: lexer))
|
||||||
|
try expect(tokens[1]) == .variable(value: "'{'", at: makeSourceMap("'{'", for: lexer))
|
||||||
|
try expect(tokens[2]) == .block(value: "if true", at: makeSourceMap("if true", for: lexer))
|
||||||
|
try expect(tokens[3]) == .variable(value: "stuff", at: makeSourceMap("stuff", for: lexer))
|
||||||
|
try expect(tokens[4]) == .block(value: "endif", at: makeSourceMap("endif", for: lexer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPerformance() throws {
|
||||||
|
let path = Path(#file as String)! / ".." / "fixtures" / "huge.html"
|
||||||
|
let content: String = try NSString(contentsOfFile: path.string, encoding: String.Encoding.utf8.rawValue).substring(from: 0) as String
|
||||||
|
|
||||||
|
measure {
|
||||||
|
let lexer = Lexer(templateString: content)
|
||||||
|
_ = 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,32 +1,55 @@
|
|||||||
|
import PathKit
|
||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
import Stencil
|
||||||
import PathKit
|
import XCTest
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDictionaryLoader() {
|
||||||
|
let loader = DictionaryLoader(templates: [
|
||||||
|
"index.html": "Hello World"
|
||||||
|
])
|
||||||
|
let environment = Environment(loader: loader)
|
||||||
|
|
||||||
|
it("errors when a template cannot be found") {
|
||||||
|
try expect(try environment.loadTemplate(name: "unknown.html")).toThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
it("errors when an array of templates cannot be found") {
|
||||||
|
try expect(try environment.loadTemplate(names: ["unknown.html", "unknown2.html"])).toThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can load a template from a known templates") {
|
||||||
|
_ = try environment.loadTemplate(name: "index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can load a known template from a collection of templates") {
|
||||||
|
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,111 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class NodeTests: XCTestCase {
|
||||||
class ErrorNode : NodeType {
|
private let context = Context(dictionary: [
|
||||||
func render(_ context: Context) throws -> String {
|
|
||||||
throw TemplateSyntaxError("Custom Error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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,43 +1,50 @@
|
|||||||
import Foundation
|
|
||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class NowNodeTests: XCTestCase {
|
||||||
func testNowNode() {
|
func testParsing() {
|
||||||
#if !os(Linux)
|
it("parses default format without any now arguments") {
|
||||||
describe("NowNode") {
|
#if os(Linux)
|
||||||
$0.describe("parsing") {
|
throw skip()
|
||||||
$0.it("parses default format without any now arguments") {
|
#else
|
||||||
let tokens: [Token] = [ .block(value: "now") ]
|
let tokens: [Token] = [ .block(value: "now", 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) == "\"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") {
|
||||||
let tokens: [Token] = [ .block(value: "now \"HH:mm\"") ]
|
#if os(Linux)
|
||||||
|
throw skip()
|
||||||
|
#else
|
||||||
|
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,12 +1,11 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
@testable import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
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")
|
.text(value: "Hello World", at: .unknown)
|
||||||
], environment: Environment())
|
], environment: Environment())
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
@@ -16,9 +15,9 @@ func testTokenParser() {
|
|||||||
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'")
|
.variable(value: "'name'", at: .unknown)
|
||||||
], environment: Environment())
|
], environment: Environment())
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
@@ -28,35 +27,53 @@ func testTokenParser() {
|
|||||||
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!")
|
.comment(value: "Secret stuff!", at: .unknown)
|
||||||
], environment: Environment())
|
], environment: Environment())
|
||||||
|
|
||||||
let nodes = try parser.parse()
|
let nodes = try parser.parse()
|
||||||
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"),
|
.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 parser = TokenParser(tokens: [
|
let tokens: [Token] = [.block(value: "unknown", at: .unknown)]
|
||||||
.block(value: "unknown"),
|
let parser = TokenParser(tokens: tokens, environment: Environment())
|
||||||
], environment: Environment())
|
|
||||||
|
|
||||||
try expect(try parser.parse()).toThrow(TemplateSyntaxError("Unknown template tag 'unknown'"))
|
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,69 +1,70 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class StencilTests: XCTestCase {
|
||||||
fileprivate class CustomNode : NodeType {
|
private lazy var environment: Environment = {
|
||||||
func render(_ context:Context) throws -> String {
|
let exampleExtension = Extension()
|
||||||
return "Hello World"
|
exampleExtension.registerSimpleTag("simpletag") { _ in
|
||||||
|
"Hello World"
|
||||||
}
|
}
|
||||||
|
exampleExtension.registerTag("customtag") { _, token in
|
||||||
|
CustomNode(token: token)
|
||||||
}
|
}
|
||||||
|
return Environment(extensions: [exampleExtension])
|
||||||
|
}()
|
||||||
fileprivate struct Article {
|
|
||||||
let title: String
|
|
||||||
let author: String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func testStencil() {
|
func testStencil() {
|
||||||
describe("Stencil") {
|
it("can render the README example") {
|
||||||
let exampleExtension = Extension()
|
let templateString = """
|
||||||
|
There are {{ articles.count }} articles.
|
||||||
|
|
||||||
exampleExtension.registerSimpleTag("simpletag") { context in
|
{% for article in articles %}\
|
||||||
return "Hello World"
|
- {{ article.title }} by {{ article.author }}.
|
||||||
}
|
{% endfor %}
|
||||||
|
"""
|
||||||
exampleExtension.registerTag("customtag") { parser, token in
|
|
||||||
return CustomNode()
|
|
||||||
}
|
|
||||||
|
|
||||||
let environment = Environment(extensions: [exampleExtension])
|
|
||||||
|
|
||||||
$0.it("can render the README example") {
|
|
||||||
|
|
||||||
let templateString = "There are {{ articles.count }} articles.\n" +
|
|
||||||
"\n" +
|
|
||||||
"{% for article in articles %}" +
|
|
||||||
" - {{ article.title }} by {{ article.author }}.\n" +
|
|
||||||
"{% endfor %}\n"
|
|
||||||
|
|
||||||
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")
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
let template = Template(templateString: templateString)
|
let template = Template(templateString: templateString)
|
||||||
let result = try template.render(context)
|
let result = try template.render(context)
|
||||||
|
|
||||||
let fixture = "There are 2 articles.\n" +
|
try expect(result) == """
|
||||||
"\n" +
|
There are 2 articles.
|
||||||
" - Migrating from OCUnit to XCTest by Kyle Fuller.\n" +
|
|
||||||
" - Memory Management with ARC by Kyle Fuller.\n" +
|
|
||||||
"\n"
|
|
||||||
|
|
||||||
try expect(result) == fixture
|
- Migrating from OCUnit to XCTest by Kyle Fuller.
|
||||||
|
- Memory Management with ARC by Kyle Fuller.
|
||||||
|
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
$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,19 +1,25 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("can render a template with escaped token") {
|
||||||
|
let template: Template = "Hello \\{{ name }}"
|
||||||
|
let result = try template.render([ "name": "Kyle" ])
|
||||||
|
try expect(result) == "Hello {{ name }}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import Spectre
|
import Spectre
|
||||||
import Stencil
|
@testable import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
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")
|
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'")
|
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\"")
|
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"
|
||||||
|
|||||||
137
Tests/StencilTests/TrimBehaviourSpec.swift
Normal file
137
Tests/StencilTests/TrimBehaviourSpec.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import Spectre
|
||||||
|
import Stencil
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class TrimBehaviourTests: XCTestCase {
|
||||||
|
func testSmartTrimCanRemoveNewlines() throws {
|
||||||
|
let templateString = """
|
||||||
|
{% for item in items %}
|
||||||
|
- {{item}}
|
||||||
|
{% endfor %}
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
- item 1
|
||||||
|
- item 2
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSmartTrimOnlyRemoveSingleNewlines() throws {
|
||||||
|
let templateString = """
|
||||||
|
{% for item in items %}
|
||||||
|
|
||||||
|
- {{item}}
|
||||||
|
{% endfor %}
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
|
||||||
|
- item 1
|
||||||
|
|
||||||
|
- item 2
|
||||||
|
text
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSmartTrimCanRemoveNewlinesWhileKeepingWhitespace() throws {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let templateString = """
|
||||||
|
Items:
|
||||||
|
{% for item in items %}
|
||||||
|
- {{item}}
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: .init(trimBehaviour: .smart))
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
Items:
|
||||||
|
- item 1
|
||||||
|
- item 2
|
||||||
|
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTrimSymbols() {
|
||||||
|
it("Respects whitespace control symbols in for tags") {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let template: Template = """
|
||||||
|
{% for num in numbers -%}
|
||||||
|
{{num}}
|
||||||
|
{%- endfor %}
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
let result = try template.render([ "numbers": Array(1...9) ])
|
||||||
|
try expect(result) == "123456789"
|
||||||
|
}
|
||||||
|
it("Respects whitespace control symbols in if tags") {
|
||||||
|
let template: Template = """
|
||||||
|
{% if value -%}
|
||||||
|
{{text}}
|
||||||
|
{%- endif %}
|
||||||
|
"""
|
||||||
|
let result = try template.render([ "text": "hello", "value": true ])
|
||||||
|
try expect(result) == "hello"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTrimSymbolsOverridingEnvironment() {
|
||||||
|
let environment = Environment(trimBehaviour: .all)
|
||||||
|
|
||||||
|
it("respects whitespace control symbols in if tags") {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let templateString = """
|
||||||
|
{% if value +%}
|
||||||
|
{{text}}
|
||||||
|
{%+ endif %}
|
||||||
|
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
let template = Template(templateString: templateString, environment: environment)
|
||||||
|
let result = try template.render([ "text": "hello", "value": true ])
|
||||||
|
try expect(result) == "\n hello\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can customize blocks on same line as text") {
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
let templateString = """
|
||||||
|
Items:{% for item in items +%}
|
||||||
|
- {{item}}
|
||||||
|
{%- endfor %}
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
|
||||||
|
let context = ["items": ["item 1", "item 2"]]
|
||||||
|
let template = Template(templateString: templateString, environment: environment)
|
||||||
|
let result = try template.render(context)
|
||||||
|
|
||||||
|
// swiftlint:disable indentation_width
|
||||||
|
try expect(result) == """
|
||||||
|
Items:
|
||||||
|
- item 1
|
||||||
|
- item 2
|
||||||
|
"""
|
||||||
|
// swiftlint:enable indentation_width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,118 +1,398 @@
|
|||||||
import Foundation
|
|
||||||
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 Object : NSObject {
|
let ext = Extension()
|
||||||
let title = "Hello World"
|
ext.registerFilter("incr") { arg in
|
||||||
|
(arg.flatMap { toNumber(value: $0) } ?? 0) + 1
|
||||||
}
|
}
|
||||||
#endif
|
let environment = Environment(extensions: [ext])
|
||||||
|
|
||||||
fileprivate struct Person {
|
var context = Context(dictionary: [
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct Article {
|
|
||||||
let author: Person
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func testVariable() {
|
|
||||||
describe("Variable") {
|
|
||||||
let context = Context(dictionary: [
|
|
||||||
"name": "Kyle",
|
"name": "Kyle",
|
||||||
"contacts": ["Katie", "Carlton"],
|
"contacts": ["Katie", "Carlton"],
|
||||||
"profiles": [
|
"profiles": [
|
||||||
"github": "kylef",
|
"github": "kylef"
|
||||||
],
|
],
|
||||||
"article": Article(author: Person(name: "Kyle"))
|
"counter": [
|
||||||
])
|
"count": "kylef"
|
||||||
|
],
|
||||||
|
"article": Article(author: Person(name: "Kyle")),
|
||||||
|
"blog": Blog(),
|
||||||
|
"tuple": (one: 1, two: 2),
|
||||||
|
"dynamic": [
|
||||||
|
"enum": DynamicEnum.someValue,
|
||||||
|
"struct": DynamicStruct()
|
||||||
|
]
|
||||||
|
], environment: environment)
|
||||||
#if os(OSX)
|
#if os(OSX)
|
||||||
context["object"] = Object()
|
context["object"] = Object()
|
||||||
#endif
|
#endif
|
||||||
|
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? Number
|
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 a string variable") {
|
it("can resolve boolean literal") {
|
||||||
let variable = Variable("name")
|
try expect(Variable("true").resolve(self.context) as? Bool) == true
|
||||||
let result = try variable.resolve(context) as? String
|
try expect(Variable("false").resolve(self.context) as? Bool) == false
|
||||||
try expect(result) == "Kyle"
|
try expect(Variable("0").resolve(self.context) as? Int) == 0
|
||||||
|
try expect(Variable("1").resolve(self.context) as? Int) == 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$0.it("can resolve an item from a dictionary") {
|
func testVariable() {
|
||||||
|
it("can resolve a string variable") {
|
||||||
|
let variable = Variable("name")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "Kyle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDictionary() {
|
||||||
|
it("can resolve an item from a dictionary") {
|
||||||
let variable = Variable("profiles.github")
|
let 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 resolve an item from an array via it's index") {
|
it("can get the count of a dictionary") {
|
||||||
|
let variable = Variable("profiles.count")
|
||||||
|
let result = try variable.resolve(self.context) as? Int
|
||||||
|
try expect(result) == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testArray() {
|
||||||
|
it("can resolve an item from an array via it's index") {
|
||||||
let variable = Variable("contacts.0")
|
let 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 from an array 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 from an array") {
|
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 from an array") {
|
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 resolve a property with reflection") {
|
func testDynamicMemberLookup() {
|
||||||
|
it("can resolve dynamic member lookup") {
|
||||||
|
let variable = Variable("dynamic.struct.test")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "this is a dynamic response"
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can resolve dynamic enum rawValue") {
|
||||||
|
let variable = Variable("dynamic.enum.rawValue")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "this is raw value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReflection() {
|
||||||
|
it("can resolve a property with reflection") {
|
||||||
let variable = Variable("article.author.name")
|
let variable = Variable("article.author.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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
#if os(OSX)
|
||||||
$0.it("can resolve a value via KVO") {
|
it("can resolve a value via KVO") {
|
||||||
let variable = Variable("object.title")
|
let variable = Variable("object.title")
|
||||||
let result = try variable.resolve(context) as? String
|
let result = try variable.resolve(self.context) as? String
|
||||||
try expect(result) == "Hello World"
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTuple() {
|
||||||
|
it("can resolve tuple by index") {
|
||||||
|
let variable = Variable("tuple.0")
|
||||||
|
let result = try variable.resolve(self.context) as? Int
|
||||||
|
try expect(result) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can resolve tuple by label") {
|
||||||
|
let variable = Variable("tuple.two")
|
||||||
|
let result = try variable.resolve(self.context) as? Int
|
||||||
|
try expect(result) == 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptional() {
|
||||||
|
it("does not render Optional") {
|
||||||
|
var array: [Any?] = [1, nil]
|
||||||
|
array.append(array)
|
||||||
|
let context = Context(dictionary: ["values": array])
|
||||||
|
|
||||||
|
try expect(VariableNode(variable: "values").render(context)) == "[1, nil, [1, nil]]"
|
||||||
|
try expect(VariableNode(variable: "values.1").render(context)) == ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscripting() {
|
||||||
|
it("can resolve a property subscript via reflection") {
|
||||||
|
try self.context.push(dictionary: ["property": "name"]) {
|
||||||
|
let variable = Variable("article.author[property]")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "Kyle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can subscript an array with a valid index") {
|
||||||
|
try self.context.push(dictionary: ["property": 0]) {
|
||||||
|
let variable = Variable("contacts[property]")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "Katie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can subscript an array with an unknown index") {
|
||||||
|
try self.context.push(dictionary: ["property": 5]) {
|
||||||
|
let variable = Variable("contacts[property]")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result).to.beNil()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(OSX)
|
||||||
|
it("can resolve a subscript via KVO") {
|
||||||
|
try self.context.push(dictionary: ["property": "name"]) {
|
||||||
|
let variable = Variable("object[property]")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "Foo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
it("can resolve an optional subscript via reflection") {
|
||||||
|
try self.context.push(dictionary: ["property": "featuring"]) {
|
||||||
|
let variable = Variable("blog[property].author.name")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "Jhon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleSubscripting() {
|
||||||
|
it("can resolve multiple subscripts") {
|
||||||
|
try self.context.push(dictionary: [
|
||||||
|
"prop1": "articles",
|
||||||
|
"prop2": 0,
|
||||||
|
"prop3": "name"
|
||||||
|
]) {
|
||||||
|
let variable = Variable("blog[prop1][prop2].author[prop3]")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "Kyle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can resolve nested subscripts") {
|
||||||
|
try self.context.push(dictionary: [
|
||||||
|
"prop1": "prop2",
|
||||||
|
"ref": ["prop2": "name"]
|
||||||
|
]) {
|
||||||
|
let variable = Variable("article.author[ref[prop1]]")
|
||||||
|
let result = try variable.resolve(self.context) as? String
|
||||||
|
try expect(result) == "Kyle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("throws for invalid keypath syntax") {
|
||||||
|
try self.context.push(dictionary: ["prop": "name"]) {
|
||||||
|
let samples = [
|
||||||
|
".",
|
||||||
|
"..",
|
||||||
|
".test",
|
||||||
|
"test..test",
|
||||||
|
"[prop]",
|
||||||
|
"article.author[prop",
|
||||||
|
"article.author[[prop]",
|
||||||
|
"article.author[prop]]",
|
||||||
|
"article.author[]",
|
||||||
|
"article.author[[]]",
|
||||||
|
"article.author[prop][]",
|
||||||
|
"article.author[prop]comments",
|
||||||
|
"article.author[.]"
|
||||||
|
]
|
||||||
|
|
||||||
|
for lookup in samples {
|
||||||
|
let variable = Variable(lookup)
|
||||||
|
try expect(variable.resolve(self.context)).toThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRangeVariable() {
|
||||||
|
func makeVariable(_ token: String) throws -> RangeVariable? {
|
||||||
|
let token = Token.variable(value: token, at: .unknown)
|
||||||
|
return try RangeVariable(token.contents, environment: context.environment, containedIn: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can resolve closed range as array") {
|
||||||
|
let result = try makeVariable("1...3")?.resolve(self.context) as? [Int]
|
||||||
|
try expect(result) == [1, 2, 3]
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can resolve decreasing closed range as reversed array") {
|
||||||
|
let result = try makeVariable("3...1")?.resolve(self.context) as? [Int]
|
||||||
|
try expect(result) == [3, 2, 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can use filter on range variables") {
|
||||||
|
let result = try makeVariable("1|incr...3|incr")?.resolve(self.context) as? [Int]
|
||||||
|
try expect(result) == [2, 3, 4]
|
||||||
|
}
|
||||||
|
|
||||||
|
it("throws when left value is not int") {
|
||||||
|
let template: Template = "{% for i in k...j %}{{ i }}{% endfor %}"
|
||||||
|
try expect(try template.render(Context(dictionary: ["j": 3, "k": "1"]))).toThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
it("throws when right value is not int") {
|
||||||
|
let variable = try makeVariable("k...j")
|
||||||
|
try expect(try variable?.resolve(Context(dictionary: ["j": "3", "k": 1]))).toThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
it("throws is left range value is missing") {
|
||||||
|
try expect(makeVariable("...1")).toThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
it("throws is right range value is missing") {
|
||||||
|
try expect(makeVariable("1...")).toThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
#if os(OSX)
|
||||||
|
@objc
|
||||||
|
class Superclass: NSObject {
|
||||||
|
@objc let name = "Foo"
|
||||||
|
}
|
||||||
|
@objc
|
||||||
|
class Object: Superclass {
|
||||||
|
@objc let title = "Hello World"
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct Person {
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Article {
|
||||||
|
let author: Person
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WebSite {
|
||||||
|
let url: String = "blog.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Blog: WebSite {
|
||||||
|
let articles: [Article] = [Article(author: Person(name: "Kyle"))]
|
||||||
|
let featuring: Article? = Article(author: Person(name: "Jhon"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,30 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
|
|
||||||
public func stencilTests() {
|
|
||||||
testContext()
|
|
||||||
testFilter()
|
|
||||||
testLexer()
|
|
||||||
testToken()
|
|
||||||
testTokenParser()
|
|
||||||
testTemplateLoader()
|
|
||||||
testTemplate()
|
|
||||||
testVariable()
|
|
||||||
testNode()
|
|
||||||
testForNode()
|
|
||||||
testExpressions()
|
|
||||||
testIfNode()
|
|
||||||
testNowNode()
|
|
||||||
testInclude()
|
|
||||||
testInheritence()
|
|
||||||
testFilterTag()
|
|
||||||
testEnvironment()
|
|
||||||
testStencil()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class StencilTests: XCTestCase {
|
|
||||||
func testRunStencilTests() {
|
|
||||||
stencilTests()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 }}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
{% extends "child.html" %}
|
{% extends "child.html" %}
|
||||||
{% block header %}Child Child Header{% endblock %}
|
{% block header %}{{ block.super }} Child_Child_Header{% endblock %}
|
||||||
|
|||||||
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 %}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block body %}Child {{ block.super }}{% endblock %}
|
{% block body %}Child_{{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block body %}Child{% endblock %}
|
{% block header %}Super_{{ block.super }} Child_Header{% endblock %}
|
||||||
|
{% block body %}Child_Body{% endblock %}
|
||||||
|
|||||||
1131
Tests/StencilTests/fixtures/huge.html
Normal file
1131
Tests/StencilTests/fixtures/huge.html
Normal file
File diff suppressed because it is too large
Load Diff
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 %}
|
||||||
2
Tests/StencilTests/fixtures/invalid-base.html
Normal file
2
Tests/StencilTests/fixtures/invalid-base.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% block header %}Header{% endblock %}
|
||||||
|
{% block body %}Body {{ target|unknown }} {% endblock %}
|
||||||
3
Tests/StencilTests/fixtures/invalid-child-super.html
Normal file
3
Tests/StencilTests/fixtures/invalid-child-super.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{% extends "invalid-base.html" %}
|
||||||
|
{% block body %}Child {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
1
Tests/StencilTests/fixtures/invalid-include.html
Normal file
1
Tests/StencilTests/fixtures/invalid-include.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hello {{ target|unknown }}!
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user