20 Commits

Author SHA1 Message Date
T. R. Bernstein
6ff4a6b85d chore: Update PathKit dependency
Some checks are pending
Danger / Danger Check (push) Waiting to run
Lint Cocoapods / Pod Lint (push) Waiting to run
SwiftLint / SwiftLint (push) Waiting to run
Test SPM / Test SPM Linux (push) Waiting to run
Test SPM / Test SPM macOS (push) Waiting to run
Publish on Tag / GitHub Release (push) Waiting to run
2026-04-17 04:24:51 +02:00
T. R. Bernstein
02f61d55a4 chore: release 0.15.2
Some checks failed
Danger / Danger Check (push) Has been cancelled
Lint Cocoapods / Pod Lint (push) Has been cancelled
SwiftLint / SwiftLint (push) Has been cancelled
Test SPM / Test SPM Linux (push) Has been cancelled
Test SPM / Test SPM macOS (push) Has been cancelled
Publish on Tag / GitHub Release (push) Has been cancelled
2025-09-30 23:14:57 +02:00
T. R. Bernstein
c25b7a52e7 feat: Allow tokens to be escaped 2025-09-30 23:13:05 +02:00
T. R. Bernstein
25d1507159 refactor: Use tabs for indent 2025-09-30 23:13:04 +02:00
T. R. Bernstein
6811c71bd6 refactor: Adapt to new repository 2025-09-30 23:12:59 +02:00
twodayslate
17af3bace1 docs: fix code example syntax (#348) 2024-12-22 12:54:30 +00:00
David Jennes
1aeeced65d Merge pull request #342 from art-divin/master
Prefer DynamicMemberLookup over KVC
2023-08-28 02:34:58 +02:00
Ruslan Alikhamov
ea58733eb6 added new version in CHANGELOG 2023-08-26 05:10:17 +00:00
Ruslan Alikhamov
003341d94c Merge branch 'master' of https://github.com/art-divin/Stencil 2023-08-26 05:08:36 +00:00
Ruslan Alikhamov
930db33028 updated changelog 2023-08-26 05:07:29 +00:00
Ruslan Alikhamov
6b6d6c2730 changed order of condition to make positive case first 2023-08-26 01:03:49 -04:00
Ruslan Alikhamov
973609e141 Update Variable.swift - Fixed a typo for objc runtime check 2023-08-20 15:05:35 +04:00
Ruslan Alikhamov
644687b885 prefer DynamicMemberLookup over KVC 2023-08-20 10:28:27 +00:00
David Jennes
4f222ac85d Merge pull request #329 from stencilproject/release/0.15.1
Some checks failed
Publish on Tag / Push To CocoaPods (push) Has been cancelled
Publish on Tag / GitHub Release (push) Has been cancelled
Release 0.15.1
2022-07-31 23:07:25 +02:00
David Jennes
3a98d1ef7d Bump version to 0.15.1 2022-07-31 23:05:06 +02:00
David Jennes
95a24b950f Small docs fix 2022-07-31 23:03:40 +02:00
David Jennes
a3df900bd2 Merge pull request #328 from stencilproject/feature/fix-lazy
Fix `LazyValueWrapper`
2022-07-31 22:58:06 +02:00
David Jennes
59b0c176c7 Changelog entry 2022-07-31 22:54:47 +02:00
David Jennes
bc5051ffe3 Fix implementation of lazy value wrapper 2022-07-31 22:53:24 +02:00
David Jennes
9444ee5c86 Reset changelog 2022-07-30 00:30:18 +02:00
68 changed files with 5654 additions and 6010 deletions

View File

@@ -2,7 +2,8 @@ name: Danger
on: on:
push: push:
branches: master branches:
- main
pull_request: pull_request:
jobs: jobs:
@@ -10,10 +11,10 @@ jobs:
name: Danger Check name: Danger Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Setup Ruby name: Setup Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:

View File

@@ -2,7 +2,8 @@ name: Lint Cocoapods
on: on:
push: push:
branches: master branches:
- main
pull_request: pull_request:
jobs: jobs:
@@ -10,10 +11,10 @@ jobs:
name: Pod Lint name: Pod Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Setup Ruby name: Setup Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:

View File

@@ -2,7 +2,8 @@ name: SwiftLint
on: on:
push: push:
branches: master branches:
- main
pull_request: pull_request:
jobs: jobs:
@@ -10,10 +11,10 @@ jobs:
name: SwiftLint name: SwiftLint
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Setup Ruby name: Setup Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:

View File

@@ -7,24 +7,6 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
cocoapods:
name: Push To CocoaPods
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
-
name: Push to CocoaPods
run: bundle exec rake release:cocoapods
env:
COCOAPODS_TRUNK_TOKEN: ${{secrets.COCOAPODS_TRUNK_TOKEN}}
github: github:
name: GitHub Release name: GitHub Release
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -2,7 +2,8 @@ name: Test SPM
on: on:
push: push:
branches: master branches:
- main
pull_request: pull_request:
jobs: jobs:
@@ -11,10 +12,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: swiftgen/swift:5.6 container: swiftgen/swift:5.6
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
# Note: we can't use `ruby/setup-ruby` on custom docker images, so we # Note: we can't use `ruby/setup-ruby` on custom docker images, so we
# have to do our own caching # have to do our own caching
name: Cache gems name: Cache gems
@@ -24,7 +25,7 @@ jobs:
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }} key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-gems- ${{ runner.os }}-gems-
- -
name: Cache SPM name: Cache SPM
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
@@ -45,15 +46,15 @@ jobs:
name: Test SPM macOS name: Test SPM macOS
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Setup Ruby name: Setup Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
- -
name: Cache SPM name: Cache SPM
uses: actions/cache@v3 uses: actions/cache@v3
with: with:

View File

@@ -1,4 +1,4 @@
swiftlint_version: 0.48.0 swiftlint_version: 0.61.0
opt_in_rules: opt_in_rules:
- accessibility_label_for_image - accessibility_label_for_image
@@ -104,14 +104,6 @@ closure_body_length:
conditional_returns_on_newline: conditional_returns_on_newline:
if_only: true if_only: true
file_header:
required_pattern: |
\/\/
\/\/ Stencil
\/\/ Copyright © 2022 Stencil
\/\/ MIT Licence
\/\/
indentation_width: indentation_width:
indentation_width: 2 indentation_width: 2

View File

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

View File

@@ -4,13 +4,13 @@ source 'https://rubygems.org'
# The bare minimum for building, e.g. in Homebrew # The bare minimum for building, e.g. in Homebrew
group :build do group :build do
gem 'base64', '~> 0.3'
gem 'rake', '~> 13.0' gem 'rake', '~> 13.0'
gem 'xcpretty', '~> 0.3' gem 'xcpretty', '~> 0.3'
end end
# In addition to :build, for contributing # In addition to :build, for contributing
group :development do group :development do
gem 'cocoapods', '~> 1.11'
gem 'danger', '~> 8.4' gem 'danger', '~> 8.4'
gem 'rubocop', '~> 1.22' gem 'rubocop', '~> 1.22'
end end

View File

@@ -1,65 +1,16 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
CFPropertyList (3.0.5) addressable (2.8.7)
rexml public_suffix (>= 2.0.2, < 7.0)
activesupport (6.1.6.1) ast (2.4.3)
concurrent-ruby (~> 1.0, >= 1.0.2) base64 (0.3.0)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
ast (2.4.2)
atomos (0.1.3)
claide (1.1.0) claide (1.1.0)
claide-plugins (0.9.2) claide-plugins (0.9.2)
cork cork
nap nap
open4 (~> 1.3) open4 (~> 1.3)
cocoapods (1.11.3)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.11.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2) colored2 (3.1.2)
concurrent-ruby (1.1.10)
cork (0.3.0) cork (0.3.0)
colored2 (~> 3.1) colored2 (~> 3.1)
danger (8.6.1) danger (8.6.1)
@@ -75,10 +26,7 @@ GEM
no_proxy_fix no_proxy_fix
octokit (~> 4.7) octokit (~> 4.7)
terminal-table (>= 1, < 4) terminal-table (>= 1, < 4)
escape (0.0.4) faraday (1.10.4)
ethon (0.15.0)
ffi (>= 1.15.0)
faraday (1.10.0)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1) faraday-excon (~> 1.1)
@@ -91,94 +39,89 @@ GEM
faraday-retry (~> 1.0) faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4) ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0) faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0) faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0) faraday-excon (1.1.0)
faraday-http-cache (2.4.0) faraday-http-cache (2.5.1)
faraday (>= 0.8) faraday (>= 0.8)
faraday-httpclient (1.0.1) faraday-httpclient (1.0.1)
faraday-multipart (1.0.4) faraday-multipart (1.1.1)
multipart-post (~> 2) multipart-post (~> 2.0)
faraday-net_http (1.0.1) faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0) faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0) faraday-patron (1.0.0)
faraday-rack (1.0.0) faraday-rack (1.0.0)
faraday-retry (1.0.3) faraday-retry (1.0.3)
ffi (1.15.5) git (1.19.1)
fourflusher (2.3.1) addressable (~> 2.8)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
git (1.11.0)
rchardet (~> 1.8) rchardet (~> 1.8)
httpclient (2.8.3) json (2.15.0)
i18n (1.12.0) kramdown (2.5.1)
concurrent-ruby (~> 1.0) rexml (>= 3.3.9)
json (2.6.2)
kramdown (2.4.0)
rexml
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
minitest (5.16.2) language_server-protocol (3.17.0.5)
molinillo (0.8.0) lint_roller (1.1.0)
multipart-post (2.2.3) multipart-post (2.4.1)
nanaimo (0.3.0)
nap (1.1.0) nap (1.1.0)
netrc (0.11.0)
no_proxy_fix (0.1.2) no_proxy_fix (0.1.2)
octokit (4.25.1) octokit (4.25.1)
faraday (>= 1, < 3) faraday (>= 1, < 3)
sawyer (~> 0.9) sawyer (~> 0.9)
open4 (1.3.4) open4 (1.3.4)
parallel (1.22.1) parallel (1.27.0)
parser (3.1.2.0) parser (3.3.9.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc
prism (1.5.1)
public_suffix (4.0.7) public_suffix (4.0.7)
racc (1.8.1)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.3.0)
rchardet (1.8.0) rchardet (1.10.0)
regexp_parser (2.5.0) regexp_parser (2.11.3)
rexml (3.2.5) rexml (3.4.4)
rouge (2.0.7) rouge (3.28.0)
rubocop (1.32.0) rubocop (1.81.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.1.0.0) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.19.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.19.1) rubocop-ast (1.47.1)
parser (>= 3.1.1.0) parser (>= 3.3.7.2)
ruby-macho (2.5.1) prism (~> 1.4)
ruby-progressbar (1.11.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
sawyer (0.9.2) sawyer (0.9.2)
addressable (>= 2.3.5) addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3) faraday (>= 0.17.3, < 3)
terminal-table (3.0.2) terminal-table (1.6.0)
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (3.2.0)
typhoeus (1.4.0) unicode-emoji (~> 4.1)
ethon (>= 0.9.0) unicode-emoji (4.1.0)
tzinfo (2.0.5) xcpretty (0.4.1)
concurrent-ruby (~> 1.0) rouge (~> 3.28.0)
unicode-display_width (2.2.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
zeitwerk (2.6.0)
PLATFORMS PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
ruby ruby
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
cocoapods (~> 1.11) base64 (~> 0.3)
danger (~> 8.4) danger (~> 8.4)
octokit (~> 4.7) octokit (~> 4.7)
rake (~> 13.0) rake (~> 13.0)
@@ -186,4 +129,4 @@ DEPENDENCIES
xcpretty (~> 0.3) xcpretty (~> 0.3)
BUNDLED WITH BUNDLED WITH
2.2.33 2.7.2

View File

@@ -1,4 +1,5 @@
Copyright (c) 2022, 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

View File

@@ -1,15 +1,6 @@
{ {
"object": { "object": {
"pins": [ "pins": [
{
"package": "PathKit",
"repositoryURL": "https://github.com/kylef/PathKit.git",
"state": {
"branch": null,
"revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
"version": "1.0.1"
}
},
{ {
"package": "Spectre", "package": "Spectre",
"repositoryURL": "https://github.com/kylef/Spectre.git", "repositoryURL": "https://github.com/kylef/Spectre.git",
@@ -18,6 +9,15 @@
"revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7", "revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
"version": "0.10.1" "version": "0.10.1"
} }
},
{
"package": "swiftpm-pathkit",
"repositoryURL": "https://github.com/astzweig/swiftpm-pathkit.git",
"state": {
"branch": null,
"revision": "1280d78aa2c1532800d7e820607f123236dc5f54",
"version": "1.5.1"
}
} }
] ]
}, },

View File

@@ -7,7 +7,7 @@ let package = Package(
.library(name: "Stencil", targets: ["Stencil"]) .library(name: "Stencil", targets: ["Stencil"])
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.1"), .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") .package(url: "https://github.com/kylef/Spectre.git", from: "0.10.1")
], ],
targets: [ targets: [

View File

@@ -34,9 +34,7 @@ namespace :files do
) )
docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1) docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
replace("docs/installation.rst", replace("docs/installation.rst",
/\.package\(url: .+, from: "(.+)"/ => %Q(.package\(url: "https://github.com/stencilproject/Stencil.git", from: "#{version}"), /\.package\(url: .+, from: "(.+)"/ => %Q(.package\(url: "https://github.com/swiftstencil/swiftpm-stencil.git", from: "#{version}")
/pod 'Stencil', '.*'/ => %Q(pod 'Stencil', '~> #{version}'),
/github "stencilproject\/Stencil" ~> .*/ => %Q(github "stencilproject/Stencil" ~> #{version})
) )
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,18 @@
{ {
"name": "Stencil", "name": "Stencil",
"version": "0.15.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/stencilproject/Stencil.git", "git": "https://github.com/swiftstencil/swiftpm-stencil.git",
"tag": "0.15.0" "tag": "0.15.2"
}, },
"source_files": [ "source_files": [
"Sources/Stencil/*.swift" "Sources/Stencil/*.swift"
@@ -23,14 +22,13 @@
"osx": "10.9", "osx": "10.9",
"tvos": "9.0" "tvos": "9.0"
}, },
"cocoapods_version": ">= 1.7.0",
"swift_versions": [ "swift_versions": [
"5.0" "5.0"
], ],
"requires_arc": true, "requires_arc": true,
"dependencies": { "dependencies": {
"PathKit": [ "PathKit": [
"~> 1.0.0" "~> 1.5.0"
] ]
} }
} }

View File

@@ -1,166 +1,160 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import XCTest import XCTest
final class ContextTests: XCTestCase { final class ContextTests: XCTestCase {
func testContextSubscripting() { func testContextSubscripting() {
describe("Context Subscripting") { test in describe("Context Subscripting") { test in
var context = Context() var context = Context()
test.before { test.before {
context = Context(dictionary: ["name": "Kyle"]) context = Context(dictionary: ["name": "Kyle"])
} }
test.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"
} }
test.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"
} }
test.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()
} }
test.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"
} }
} }
test.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"
} }
} }
} }
} }
func testContextRestoration() { func testContextRestoration() {
describe("Context Restoration") { test in describe("Context Restoration") { test in
var context = Context() var context = Context()
test.before { test.before {
context = Context(dictionary: ["name": "Kyle"]) context = Context(dictionary: ["name": "Kyle"])
} }
test.it("allows you to pop to restore previous state") { test.it("allows you to pop to restore previous state") {
context.push { context.push {
context["name"] = "Katie" context["name"] = "Katie"
} }
try expect(context["name"] as? String) == "Kyle" try expect(context["name"] as? String) == "Kyle"
} }
test.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()
} }
try expect(context["name"] as? String) == "Kyle" try expect(context["name"] as? String) == "Kyle"
} }
test.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"]) {
didRun = true didRun = true
try expect(context["name"] as? String) == "Katie" try expect(context["name"] as? String) == "Katie"
} }
try expect(didRun).to.beTrue() try expect(didRun).to.beTrue()
try expect(context["name"] as? String) == "Kyle" try expect(context["name"] as? String) == "Kyle"
} }
test.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()
try expect(flattened.count) == 2 try expect(flattened.count) == 2
try expect(flattened["name"] as? String) == "Kyle" try expect(flattened["name"] as? String) == "Kyle"
try expect(flattened["test"] as? String) == "abc" try expect(flattened["test"] as? String) == "abc"
} }
} }
} }
} }
func testContextLazyEvaluation() { func testContextLazyEvaluation() {
let ticker = Ticker() let ticker = Ticker()
var context = Context() var context = Context()
var wrapper = LazyValueWrapper("") var wrapper = LazyValueWrapper("")
describe("Lazy evaluation") { test in describe("Lazy evaluation") { test in
test.before { test.before {
ticker.count = 0 ticker.count = 0
wrapper = LazyValueWrapper(ticker.tick()) wrapper = LazyValueWrapper(ticker.tick())
context = Context(dictionary: ["name": wrapper]) context = Context(dictionary: ["name": wrapper])
} }
test.it("Evaluates lazy data") { test.it("Evaluates lazy data") {
let template = Template(templateString: "{{ name }}") let template = Template(templateString: "{{ name }}")
let result = try template.render(context) let result = try template.render(context)
try expect(result) == "Kyle" try expect(result) == "Kyle"
try expect(ticker.count) == 1 try expect(ticker.count) == 1
} }
test.it("Evaluates lazy only once") { test.it("Evaluates lazy only once") {
let template = Template(templateString: "{{ name }}{{ name }}") let template = Template(templateString: "{{ name }}{{ name }}")
let result = try template.render(context) let result = try template.render(context)
try expect(result) == "KyleKyle" try expect(result) == "KyleKyle"
try expect(ticker.count) == 1 try expect(ticker.count) == 1
} }
test.it("Does not evaluate lazy data when not used") { test.it("Does not evaluate lazy data when not used") {
let template = Template(templateString: "{{ 'Katie' }}") let template = Template(templateString: "{{ 'Katie' }}")
let result = try template.render(context) let result = try template.render(context)
try expect(result) == "Katie" try expect(result) == "Katie"
try expect(ticker.count) == 0 try expect(ticker.count) == 0
} }
} }
} }
func testContextLazyAccessTypes() { func testContextLazyAccessTypes() {
it("Supports evaluation via context reference") { it("Supports evaluation via context reference") {
let context = Context(dictionary: ["name": "Kyle"]) let context = Context(dictionary: ["name": "Kyle"])
context["alias"] = LazyValueWrapper { $0["name"] ?? "" } context["alias"] = LazyValueWrapper { $0["name"] ?? "" }
let template = Template(templateString: "{{ alias }}") let template = Template(templateString: "{{ alias }}")
try context.push(dictionary: ["name": "Katie"]) { try context.push(dictionary: ["name": "Katie"]) {
let result = try template.render(context) let result = try template.render(context)
try expect(result) == "Katie" try expect(result) == "Katie"
} }
} }
it("Supports evaluation via context copy") { it("Supports evaluation via context copy") {
let context = Context(dictionary: ["name": "Kyle"]) let context = Context(dictionary: ["name": "Kyle"])
context["alias"] = LazyValueWrapper(copying: context) { $0["name"] ?? "" } context["alias"] = LazyValueWrapper(copying: context) { $0["name"] ?? "" }
let template = Template(templateString: "{{ alias }}") let template = Template(templateString: "{{ alias }}")
try context.push(dictionary: ["name": "Katie"]) { try context.push(dictionary: ["name": "Katie"]) {
let result = try template.render(context) let result = try template.render(context)
try expect(result) == "Kyle" try expect(result) == "Kyle"
} }
} }
} }
} }
// MARK: - Helpers // MARK: - Helpers
private final class Ticker { private final class Ticker {
var count: Int = 0 var count: Int = 0
func tick() -> String { func tick() -> String {
count += 1 count += 1
return "Kyle" return "Kyle"
} }
} }

View File

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

View File

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

View File

@@ -1,227 +1,220 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import XCTest import XCTest
final class EnvironmentTests: XCTestCase { final class EnvironmentTests: XCTestCase {
private var environment = Environment(loader: ExampleLoader()) private var environment = Environment(loader: ExampleLoader())
private var template: Template = "" private var template: Template = ""
override func setUp() { override func setUp() {
super.setUp() super.setUp()
let errorExtension = Extension() let errorExtension = Extension()
errorExtension.registerFilter("throw") { (_: Any?) in errorExtension.registerFilter("throw") { (_: Any?) in
throw TemplateSyntaxError("filter error") throw TemplateSyntaxError("filter error")
} }
errorExtension.registerSimpleTag("simpletag") { _ in errorExtension.registerSimpleTag("simpletag") { _ in
throw TemplateSyntaxError("simpletag error") throw TemplateSyntaxError("simpletag error")
} }
errorExtension.registerTag("customtag") { _, token in errorExtension.registerTag("customtag") { _, token in
ErrorNode(token: token) ErrorNode(token: token)
} }
environment = Environment(loader: ExampleLoader()) environment = Environment(loader: ExampleLoader())
environment.extensions += [errorExtension] environment.extensions += [errorExtension]
template = "" template = ""
} }
override func tearDown() { override func tearDown() {
super.tearDown() super.tearDown()
} }
func testLoading() { func testLoading() {
it("can load a template from a name") { it("can load a template from a name") {
let template = try self.environment.loadTemplate(name: "example.html") let template = try self.environment.loadTemplate(name: "example.html")
try expect(template.name) == "example.html" try expect(template.name) == "example.html"
} }
it("can load a template from a names") { it("can load a template from a names") {
let template = try self.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"
} }
} }
func testRendering() { func testRendering() {
it("can render a template from a string") { it("can render a template from a string") {
let result = try self.environment.renderTemplate(string: "Hello World") let result = try self.environment.renderTemplate(string: "Hello World")
try expect(result) == "Hello World" try expect(result) == "Hello World"
} }
it("can render a template from a file") { it("can render a template from a file") {
let result = try self.environment.renderTemplate(name: "example.html") let result = try self.environment.renderTemplate(name: "example.html")
try expect(result) == "Hello World!" try expect(result) == "Hello World!"
} }
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() { func testSyntaxError() {
it("reports syntax error on invalid for tag syntax") { it("reports syntax error on invalid for tag syntax") {
self.template = "Hello {% for name in %}{{ name }}, {% endfor %}!" self.template = "Hello {% for name in %}{{ name }}, {% endfor %}!"
try self.expectError( try self.expectError(
reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.", reason: "'for' statements should use the syntax: `for <x> in <y> [where <condition>]`.",
token: "for name in" token: "for name in"
) )
} }
it("reports syntax error on missing endfor") { it("reports syntax error on missing endfor") {
self.template = "{% for name in names %}{{ name }}" self.template = "{% for name in names %}{{ name }}"
try self.expectError(reason: "`endfor` was not found.", token: "for name in names") try self.expectError(reason: "`endfor` was not found.", token: "for name in names")
} }
it("reports syntax error on unknown tag") { it("reports syntax error on unknown tag") {
self.template = "{% for name in names %}{{ name }}{% end %}" self.template = "{% for name in names %}{{ name }}{% end %}"
try self.expectError(reason: "Unknown template tag 'end'", token: "end") try self.expectError(reason: "Unknown template tag 'end'", token: "end")
} }
} }
func testUnknownFilter() { func testUnknownFilter() {
it("reports syntax error in for tag") { it("reports syntax error in for tag") {
self.template = "{% for name in names|unknown %}{{ name }}{% endfor %}" self.template = "{% for name in names|unknown %}{{ name }}{% endfor %}"
try self.expectError( try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "names|unknown" token: "names|unknown"
) )
} }
it("reports syntax error in for-where tag") { it("reports syntax error in for-where tag") {
self.template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}" self.template = "{% for name in names where name|unknown %}{{ name }}{% endfor %}"
try self.expectError( try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown" token: "name|unknown"
) )
} }
it("reports syntax error in if tag") { it("reports syntax error in if tag") {
self.template = "{% if name|unknown %}{{ name }}{% endif %}" self.template = "{% if name|unknown %}{{ name }}{% endif %}"
try self.expectError( try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown" token: "name|unknown"
) )
} }
it("reports syntax error in elif tag") { it("reports syntax error in elif tag") {
self.template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}" self.template = "{% if name %}{{ name }}{% elif name|unknown %}{% endif %}"
try self.expectError( try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown" token: "name|unknown"
) )
} }
it("reports syntax error in ifnot tag") { it("reports syntax error in ifnot tag") {
self.template = "{% ifnot name|unknown %}{{ name }}{% endif %}" self.template = "{% ifnot name|unknown %}{{ name }}{% endif %}"
try self.expectError( try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown" token: "name|unknown"
) )
} }
it("reports syntax error in filter tag") { it("reports syntax error in filter tag") {
self.template = "{% filter unknown %}Text{% endfilter %}" self.template = "{% filter unknown %}Text{% endfilter %}"
try self.expectError( try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "filter unknown" token: "filter unknown"
) )
} }
it("reports syntax error in variable tag") { it("reports syntax error in variable tag") {
self.template = "{{ name|unknown }}" self.template = "{{ name|unknown }}"
try self.expectError( try self.expectError(
reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.", reason: "Unknown filter 'unknown'. Found similar filters: 'uppercase'.",
token: "name|unknown" token: "name|unknown"
) )
} }
it("reports error in variable tag") { it("reports error in variable tag") {
self.template = "{{ }}" self.template = "{{ }}"
try self.expectError(reason: "Missing variable name", token: " ") try self.expectError(reason: "Missing variable name", token: " ")
} }
} }
func testRenderingError() { func testRenderingError() {
it("reports rendering error in variable filter") { it("reports rendering error in variable filter") {
self.template = Template(templateString: "{{ name|throw }}", environment: self.environment) self.template = Template(templateString: "{{ name|throw }}", environment: self.environment)
try self.expectError(reason: "filter error", token: "name|throw") try self.expectError(reason: "filter error", token: "name|throw")
} }
it("reports rendering error in filter tag") { it("reports rendering error in filter tag") {
self.template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: self.environment) self.template = Template(templateString: "{% filter throw %}Test{% endfilter %}", environment: self.environment)
try self.expectError(reason: "filter error", token: "filter throw") try self.expectError(reason: "filter error", token: "filter throw")
} }
it("reports rendering error in simple tag") { it("reports rendering error in simple tag") {
self.template = Template(templateString: "{% simpletag %}", environment: self.environment) self.template = Template(templateString: "{% simpletag %}", environment: self.environment)
try self.expectError(reason: "simpletag error", token: "simpletag") try self.expectError(reason: "simpletag error", token: "simpletag")
} }
it("reports passing argument to simple filter") { it("reports passing argument to simple filter") {
self.template = "{{ name|uppercase:5 }}" self.template = "{{ name|uppercase:5 }}"
try self.expectError(reason: "Can't invoke filter with an argument", token: "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") { it("reports rendering error in custom tag") {
self.template = Template(templateString: "{% customtag %}", environment: self.environment) self.template = Template(templateString: "{% customtag %}", environment: self.environment)
try self.expectError(reason: "Custom Error", token: "customtag") try self.expectError(reason: "Custom Error", token: "customtag")
} }
it("reports rendering error in for body") { it("reports rendering error in for body") {
self.template = Template(templateString: """ self.template = Template(templateString: """
{% for name in names %}{% customtag %}{% endfor %} {% for name in names %}{% customtag %}{% endfor %}
""", environment: self.environment) """, environment: self.environment)
try self.expectError(reason: "Custom Error", token: "customtag") try self.expectError(reason: "Custom Error", token: "customtag")
} }
it("reports rendering error in block") { it("reports rendering error in block") {
self.template = Template( self.template = Template(
templateString: "{% block some %}{% customtag %}{% endblock %}", templateString: "{% block some %}{% customtag %}{% endblock %}",
environment: self.environment environment: self.environment
) )
try self.expectError(reason: "Custom Error", token: "customtag") try self.expectError(reason: "Custom Error", token: "customtag")
} }
} }
private func expectError( private func expectError(
reason: String, reason: String,
token: String, token: String,
file: String = #file, file: String = #file,
line: Int = #line, line: Int = #line,
function: String = #function function: String = #function
) throws { ) throws {
let expectedError = expectedSyntaxError(token: token, template: template, description: reason) let expectedError = expectedSyntaxError(token: token, template: template, description: reason)
let error = try expect( let error = try expect(
self.environment.render(template: self.template, context: ["names": ["Bob", "Alice"], "name": "Bob"]), self.environment.render(template: self.template, context: ["names": ["Bob", "Alice"], "name": "Bob"]),
file: file, file: file,
line: line, line: line,
function: function function: function
).toThrow() as TemplateSyntaxError ).toThrow() as TemplateSyntaxError
let reporter = SimpleErrorReporter() let reporter = SimpleErrorReporter()
try expect( try expect(
reporter.renderError(error), reporter.renderError(error),
file: file, file: file,
line: line, line: line,
function: function function: function
) == reporter.renderError(expectedError) ) == reporter.renderError(expectedError)
} }
} }
// MARK: - Helpers // MARK: - Helpers
private class CustomTemplate: Template { private class CustomTemplate: Template {
// swiftlint:disable discouraged_optional_collection // swiftlint:disable discouraged_optional_collection
override func render(_ dictionary: [String: Any]? = nil) throws -> String { override func render(_ dictionary: [String: Any]? = nil) throws -> String {
"here" "here"
} }
} }

View File

@@ -1,361 +1,355 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import XCTest import XCTest
final class ExpressionsTests: XCTestCase { final class ExpressionsTests: XCTestCase {
private let parser = TokenParser(tokens: [], environment: Environment()) private let parser = TokenParser(tokens: [], environment: Environment())
private func makeExpression(_ components: [String]) -> Expression { private func makeExpression(_ components: [String]) -> Stencil.Expression {
do { do {
let parser = try IfExpressionParser.parser( let parser = try IfExpressionParser.parser(
components: components, components: components,
environment: Environment(), environment: Environment(),
token: .text(value: "", at: .unknown) token: .text(value: "", at: .unknown)
) )
return try parser.parse() return try parser.parse()
} catch { } catch {
fatalError(error.localizedDescription) fatalError(error.localizedDescription)
} }
} }
func testTrueExpressions() { func testTrueExpressions() {
let expression = VariableExpression(variable: Variable("value")) let expression = VariableExpression(variable: Variable("value"))
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()
} }
it("evaluates to true when array variable is not empty") { it("evaluates to true when array variable is not empty") {
let items: [[String: Any]] = [["key": "key1", "value": 42], ["key": "key2", "value": 1_337]] let items: [[String: Any]] = [["key": "key1", "value": 42], ["key": "key2", "value": 1_337]]
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()
} }
it("evaluates to false when dictionary value is empty") { 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()
} }
it("evaluates to true when integer value is above 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()
} }
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()
} }
it("evaluates to true when float value is above 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()
} }
it("evaluates to true when double value is above 0") { it("evaluates to true when double value is above 0") {
let context = Context(dictionary: ["value": Double(0.5)]) let context = Context(dictionary: ["value": Double(0.5)])
try expect(try expression.evaluate(context: context)).to.beTrue() try expect(try expression.evaluate(context: context)).to.beTrue()
} }
} }
func testFalseExpressions() { func testFalseExpressions() {
let expression = VariableExpression(variable: Variable("value")) let expression = VariableExpression(variable: Variable("value"))
it("evaluates to false when value is unset") { it("evaluates to false when value is unset") {
let context = Context() let context = Context()
try expect(try expression.evaluate(context: context)).to.beFalse() try expect(try expression.evaluate(context: context)).to.beFalse()
} }
it("evaluates to false when array value is empty") { it("evaluates to false when array 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()
} }
it("evaluates to false when dictionary value is empty") { 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()
} }
it("evaluates to false when Array<Any> value is empty") { it("evaluates to false when Array<Any> value is empty") {
let context = Context(dictionary: ["value": ([] as [Any])]) let context = Context(dictionary: ["value": ([] as [Any])])
try expect(try expression.evaluate(context: context)).to.beFalse() try expect(try expression.evaluate(context: context)).to.beFalse()
} }
it("evaluates to false when empty string") { it("evaluates to false when empty string") {
let context = Context(dictionary: ["value": ""]) let context = Context(dictionary: ["value": ""])
try expect(try expression.evaluate(context: context)).to.beFalse() try expect(try expression.evaluate(context: context)).to.beFalse()
} }
it("evaluates to false when integer value is below 0 or below") { it("evaluates to false when integer value is below 0 or below") {
let context = Context(dictionary: ["value": 0]) let context = Context(dictionary: ["value": 0])
try expect(try expression.evaluate(context: context)).to.beFalse() try expect(try expression.evaluate(context: context)).to.beFalse()
let negativeContext = Context(dictionary: ["value": -1]) let negativeContext = Context(dictionary: ["value": -1])
try expect(try expression.evaluate(context: negativeContext)).to.beFalse() try expect(try expression.evaluate(context: negativeContext)).to.beFalse()
} }
it("evaluates to false when float is 0 or below") { 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()
} }
it("evaluates to false when double is 0 or below") { 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()
} }
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()
} }
} }
func testNotExpression() { func testNotExpression() {
it("returns truthy for positive expressions") { it("returns truthy for positive expressions") {
let expression = NotExpression(expression: VariableExpression(variable: Variable("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()
} }
it("returns falsy for negative expressions") { it("returns falsy for negative expressions") {
let expression = NotExpression(expression: VariableExpression(variable: Variable("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()
} }
} }
func testExpressionParsing() { func testExpressionParsing() {
it("can parse a variable expression") { it("can parse a variable expression") {
let expression = self.makeExpression(["value"]) let expression = self.makeExpression(["value"])
try expect(expression.evaluate(context: Context())).to.beFalse() try expect(expression.evaluate(context: Context())).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue() try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beTrue()
} }
it("can parse a not expression") { it("can parse a not expression") {
let expression = self.makeExpression(["not", "value"]) let expression = self.makeExpression(["not", "value"])
try expect(expression.evaluate(context: Context())).to.beTrue() try expect(expression.evaluate(context: Context())).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse() try expect(expression.evaluate(context: Context(dictionary: ["value": true]))).to.beFalse()
} }
} }
func testAndExpression() { func testAndExpression() {
let expression = makeExpression(["lhs", "and", "rhs"]) let expression = makeExpression(["lhs", "and", "rhs"])
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()
} }
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()
} }
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()
} }
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()
} }
} }
func testOrExpression() { func testOrExpression() {
let expression = makeExpression(["lhs", "or", "rhs"]) let expression = makeExpression(["lhs", "or", "rhs"])
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()
} }
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()
} }
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()
} }
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()
} }
} }
func testEqualityExpression() { func testEqualityExpression() {
let expression = makeExpression(["lhs", "==", "rhs"]) let expression = makeExpression(["lhs", "==", "rhs"])
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()
} }
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()
} }
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()
} }
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()
} }
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()
} }
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()
} }
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()
} }
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()
} }
} }
func testInequalityExpression() { func testInequalityExpression() {
let expression = makeExpression(["lhs", "!=", "rhs"]) let expression = makeExpression(["lhs", "!=", "rhs"])
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()
} }
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()
} }
} }
func testMoreThanExpression() { func testMoreThanExpression() {
let expression = makeExpression(["lhs", ">", "rhs"]) let expression = makeExpression(["lhs", ">", "rhs"])
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()
} }
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()
} }
} }
func testMoreThanEqualExpression() { func testMoreThanEqualExpression() {
let expression = makeExpression(["lhs", ">=", "rhs"]) let expression = makeExpression(["lhs", ">=", "rhs"])
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()
} }
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()
} }
} }
func testLessThanExpression() { func testLessThanExpression() {
let expression = makeExpression(["lhs", "<", "rhs"]) let expression = makeExpression(["lhs", "<", "rhs"])
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()
} }
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()
} }
} }
func testLessThanEqualExpression() { func testLessThanEqualExpression() {
let expression = makeExpression(["lhs", "<=", "rhs"]) let expression = makeExpression(["lhs", "<=", "rhs"])
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()
} }
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()
} }
} }
func testMultipleExpressions() { func testMultipleExpressions() {
let expression = makeExpression(["one", "or", "two", "and", "not", "three"]) let expression = makeExpression(["one", "or", "two", "and", "not", "three"])
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()
} }
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()
} }
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()
} }
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()
} }
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()
} }
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 { func testTrueInExpression() throws {
let expression = makeExpression(["lhs", "in", "rhs"]) let expression = makeExpression(["lhs", "in", "rhs"])
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1, "lhs": 1,
"rhs": [1, 2, 3] "rhs": [1, 2, 3]
]))).to.beTrue() ]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a", "lhs": "a",
"rhs": ["a", "b", "c"] "rhs": ["a", "b", "c"]
]))).to.beTrue() ]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a", "lhs": "a",
"rhs": "abc" "rhs": "abc"
]))).to.beTrue() ]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1, "lhs": 1,
"rhs": 1...3 "rhs": 1...3
]))).to.beTrue() ]))).to.beTrue()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1, "lhs": 1,
"rhs": 1..<3 "rhs": 1..<3
]))).to.beTrue() ]))).to.beTrue()
} }
func testFalseInExpression() throws { func testFalseInExpression() throws {
let expression = makeExpression(["lhs", "in", "rhs"]) let expression = makeExpression(["lhs", "in", "rhs"])
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 1, "lhs": 1,
"rhs": [2, 3, 4] "rhs": [2, 3, 4]
]))).to.beFalse() ]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a", "lhs": "a",
"rhs": ["b", "c", "d"] "rhs": ["b", "c", "d"]
]))).to.beFalse() ]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": "a", "lhs": "a",
"rhs": "bcd" "rhs": "bcd"
]))).to.beFalse() ]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 4, "lhs": 4,
"rhs": 1...3 "rhs": 1...3
]))).to.beFalse() ]))).to.beFalse()
try expect(expression.evaluate(context: Context(dictionary: [ try expect(expression.evaluate(context: Context(dictionary: [
"lhs": 3, "lhs": 3,
"rhs": 1..<3 "rhs": 1..<3
]))).to.beFalse() ]))).to.beFalse()
} }
} }

View File

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

View File

@@ -1,60 +1,54 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre import Spectre
import Stencil import Stencil
import XCTest import XCTest
final class FilterTagTests: XCTestCase { final class FilterTagTests: XCTestCase {
func testFilterTag() { func testFilterTag() {
it("allows you to use a filter") { 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"
} }
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"
} }
it("errors without a filter") { it("errors without a filter") {
let template = Template(templateString: "Some {% filter %}Test{% endfilter %}") let template = Template(templateString: "Some {% filter %}Test{% endfilter %}")
try expect(try template.render()).toThrow() try expect(try template.render()).toThrow()
} }
it("can render filters with arguments") { it("can render filters with arguments") {
let ext = Extension() let ext = Extension()
ext.registerFilter("split") { value, args in ext.registerFilter("split") { value, args in
guard let value = value as? String, guard let value = value as? String,
let argument = args.first as? String else { return value } let argument = args.first as? String else { return value }
return value.components(separatedBy: argument) return value.components(separatedBy: argument)
} }
let env = Environment(extensions: [ext]) let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: """ let result = try env.renderTemplate(string: """
{% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %} {% filter split:","|join:";" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": [1, 2]]) """, context: ["items": [1, 2]])
try expect(result) == "1;2" try expect(result) == "1;2"
} }
it("can render filters with quote as an argument") { it("can render filters with quote as an argument") {
let ext = Extension() let ext = Extension()
ext.registerFilter("replace") { value, args in ext.registerFilter("replace") { value, args in
guard let value = value as? String, guard let value = value as? String,
args.count == 2, args.count == 2,
let search = args.first as? String, let search = args.first as? String,
let replacement = args.last as? String else { return value } let replacement = args.last as? String else { return value }
return value.replacingOccurrences(of: search, with: replacement) return value.replacingOccurrences(of: search, with: replacement)
} }
let env = Environment(extensions: [ext]) let env = Environment(extensions: [ext])
let result = try env.renderTemplate(string: """ let result = try env.renderTemplate(string: """
{% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %} {% filter replace:'"',"" %}{{ items|join:"," }}{% endfilter %}
""", context: ["items": ["\"1\"", "\"2\""]]) """, context: ["items": ["\"1\"", "\"2\""]])
try expect(result) == "1,2" try expect(result) == "1,2"
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,61 +1,55 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import PathKit import PathKit
import Spectre import Spectre
import Stencil import Stencil
import XCTest import XCTest
final class TemplateLoaderTests: XCTestCase { final class TemplateLoaderTests: XCTestCase {
func testFileSystemLoader() { func testFileSystemLoader() {
let path = Path(#file as String) + ".." + "fixtures" let path = Path(#file as String)! / ".." / "fixtures"
let loader = FileSystemLoader(paths: [path]) let loader = FileSystemLoader(paths: [path])
let environment = Environment(loader: loader) let environment = Environment(loader: loader)
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()
} }
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()
} }
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")
} }
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()
} }
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() { func testDictionaryLoader() {
let loader = DictionaryLoader(templates: [ let loader = DictionaryLoader(templates: [
"index.html": "Hello World" "index.html": "Hello World"
]) ])
let environment = Environment(loader: loader) let environment = Environment(loader: loader)
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()
} }
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()
} }
it("can load a template from a known templates") { it("can load a template from a known templates") {
_ = try environment.loadTemplate(name: "index.html") _ = try environment.loadTemplate(name: "index.html")
} }
it("can load a known template from a collection of templates") { it("can load a known template from a collection of templates") {
_ = try environment.loadTemplate(names: ["unknown.html", "index.html"]) _ = try environment.loadTemplate(names: ["unknown.html", "index.html"])
} }
} }
} }

View File

@@ -1,117 +1,111 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import XCTest import XCTest
final class NodeTests: XCTestCase { final class NodeTests: XCTestCase {
private let context = Context(dictionary: [ private let context = Context(dictionary: [
"name": "Kyle", "name": "Kyle",
"age": 27, "age": 27,
"items": [1, 2, 3] "items": [1, 2, 3]
]) ])
func testTextNode() { func testTextNode() {
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(self.context)) == "Hello World" try expect(try node.render(self.context)) == "Hello World"
} }
it("Trims leading whitespace") { it("Trims leading whitespace") {
let text = " \n Some text " let text = " \n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespace, trailing: .nothing) let trimBehaviour = TrimBehaviour(leading: .whitespace, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour) let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "\n Some text " try expect(try node.render(self.context)) == "\n Some text "
} }
it("Trims leading whitespace and one newline") { it("Trims leading whitespace and one newline") {
let text = "\n\n Some text " let text = "\n\n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndOneNewLine, trailing: .nothing) let trimBehaviour = TrimBehaviour(leading: .whitespaceAndOneNewLine, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour) let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "\n Some text " try expect(try node.render(self.context)) == "\n Some text "
} }
it("Trims leading whitespace and one newline") { it("Trims leading whitespace and one newline") {
let text = "\n\n Some text " let text = "\n\n Some text "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing) let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .nothing)
let node = TextNode(text: text, trimBehaviour: trimBehaviour) let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "Some text " try expect(try node.render(self.context)) == "Some text "
} }
it("Trims trailing whitespace") { it("Trims trailing whitespace") {
let text = " Some text \n" let text = " Some text \n"
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespace) let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespace)
let node = TextNode(text: text, trimBehaviour: trimBehaviour) let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text\n" try expect(try node.render(self.context)) == " Some text\n"
} }
it("Trims trailing whitespace and one newline") { it("Trims trailing whitespace and one newline") {
let text = " Some text \n \n " let text = " Some text \n \n "
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndOneNewLine) let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndOneNewLine)
let node = TextNode(text: text, trimBehaviour: trimBehaviour) let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text \n " try expect(try node.render(self.context)) == " Some text \n "
} }
it("Trims trailing whitespace and newlines") { it("Trims trailing whitespace and newlines") {
let text = " Some text \n \n " let text = " Some text \n \n "
let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndNewLines) let trimBehaviour = TrimBehaviour(leading: .nothing, trailing: .whitespaceAndNewLines)
let node = TextNode(text: text, trimBehaviour: trimBehaviour) let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == " Some text" try expect(try node.render(self.context)) == " Some text"
} }
it("Trims all whitespace") { it("Trims all whitespace") {
let text = " \n \nSome text \n " let text = " \n \nSome text \n "
let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines) let trimBehaviour = TrimBehaviour(leading: .whitespaceAndNewLines, trailing: .whitespaceAndNewLines)
let node = TextNode(text: text, trimBehaviour: trimBehaviour) let node = TextNode(text: text, trimBehaviour: trimBehaviour)
try expect(try node.render(self.context)) == "Some text" try expect(try node.render(self.context)) == "Some text"
} }
} }
func testVariableNode() { func testVariableNode() {
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(self.context)) == "Kyle" try expect(try node.render(self.context)) == "Kyle"
} }
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(self.context)) == "27" try expect(try node.render(self.context)) == "27"
} }
} }
func testRendering() { func testRendering() {
it("renders the nodes") { it("renders the nodes") {
let nodes: [NodeType] = [ let nodes: [NodeType] = [
TextNode(text: "Hello "), TextNode(text: "Hello "),
VariableNode(variable: "name") VariableNode(variable: "name")
] ]
try expect(try renderNodes(nodes, self.context)) == "Hello Kyle" try expect(try renderNodes(nodes, self.context)) == "Hello Kyle"
} }
it("correctly throws a nodes failure") { 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() ErrorNode()
] ]
try expect(try renderNodes(nodes, self.context)).toThrow(TemplateSyntaxError("Custom Error")) try expect(try renderNodes(nodes, self.context)).toThrow(TemplateSyntaxError("Custom Error"))
} }
} }
func testRenderingBooleans() { func testRenderingBooleans() {
it("can render true & false") { it("can render true & false") {
try expect(Template(templateString: "{{ true }}").render()) == "true" try expect(Template(templateString: "{{ true }}").render()) == "true"
try expect(Template(templateString: "{{ false }}").render()) == "false" try expect(Template(templateString: "{{ false }}").render()) == "false"
} }
it("can resolve variable") { it("can resolve variable") {
let template = Template(templateString: "{{ value == \"known\" }}") let template = Template(templateString: "{{ value == \"known\" }}")
try expect(template.render(["value": "known"])) == "true" try expect(template.render(["value": "known"])) == "true"
try expect(template.render(["value": "unknown"])) == "false" try expect(template.render(["value": "unknown"])) == "false"
} }
it("can render a boolean expression") { it("can render a boolean expression") {
try expect(Template(templateString: "{{ 1 > 0 }}").render()) == "true" try expect(Template(templateString: "{{ 1 > 0 }}").render()) == "true"
try expect(Template(templateString: "{{ 1 == 2 }}").render()) == "false" try expect(Template(templateString: "{{ 1 == 2 }}").render()) == "false"
} }
} }
} }

View File

@@ -1,56 +1,50 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import XCTest import XCTest
final class NowNodeTests: XCTestCase { final class NowNodeTests: XCTestCase {
func testParsing() { func testParsing() {
it("parses default format without any now arguments") { it("parses default format without any now arguments") {
#if os(Linux) #if os(Linux)
throw skip() throw skip()
#else #else
let tokens: [Token] = [ .block(value: "now", at: .unknown) ] let tokens: [Token] = [ .block(value: "now", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
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 #endif
} }
it("parses now with a format") { it("parses now with a format") {
#if os(Linux) #if os(Linux)
throw skip() throw skip()
#else #else
let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ] let tokens: [Token] = [ .block(value: "now \"HH:mm\"", at: .unknown) ]
let parser = TokenParser(tokens: tokens, environment: Environment()) let parser = TokenParser(tokens: tokens, environment: Environment())
let nodes = try parser.parse() let nodes = try parser.parse()
let node = nodes.first as? NowNode let node = nodes.first as? NowNode
try expect(nodes.count) == 1 try expect(nodes.count) == 1
try expect(node?.format.variable) == "\"HH:mm\"" try expect(node?.format.variable) == "\"HH:mm\""
#endif #endif
} }
} }
func testRendering() { func testRendering() {
it("renders the date") { it("renders the date") {
#if os(Linux) #if os(Linux)
throw skip() throw skip()
#else #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: Date()) let date = formatter.string(from: Date())
try expect(try node.render(Context())) == date try expect(try node.render(Context())) == date
#endif #endif
} }
} }
} }

View File

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

View File

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

View File

@@ -1,25 +1,25 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre import Spectre
@testable import Stencil @testable import Stencil
import XCTest import XCTest
final class TemplateTests: XCTestCase { final class TemplateTests: XCTestCase {
func testTemplate() { func testTemplate() {
it("can render a template from a string") { 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"
} }
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 }}"
}
}
} }

View File

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

View File

@@ -1,9 +1,3 @@
//
// Stencil
// Copyright © 2022 Stencil
// MIT Licence
//
import Spectre import Spectre
import Stencil import Stencil
import XCTest import XCTest

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -113,12 +113,10 @@ To comment out part of your template, you can use the following syntax:
{# My comment is completely hidden #} {# My comment is completely hidden #}
.. _template-inheritance:
Whitespace Control Whitespace Control
------------------ ------------------
Stencil supports the same syntax as Jinja for whitespace control, see [their docs for more information](https://jinja.palletsprojects.com/en/3.1.x/templates/#whitespace-control). Stencil supports the same syntax as Jinja for whitespace control, see `their docs for more information <https://jinja.palletsprojects.com/en/3.1.x/templates/#whitespace-control>`_.
Essentially, Stencil will **not** trim whitespace by default. However you can: Essentially, Stencil will **not** trim whitespace by default. However you can:
@@ -126,6 +124,8 @@ Essentially, Stencil will **not** trim whitespace by default. However you can:
- You can disable this per-block using the `+` control character. For example `{{+ if … }}` to preserve whitespace before. - You can disable this per-block using the `+` control character. For example `{{+ if … }}` to preserve whitespace before.
- You can force trimming per-block by using the `-` control character. For example `{{ if … -}}` to trim whitespace after. - You can force trimming per-block by using the `-` control character. For example `{{ if … -}}` to trim whitespace after.
.. _template-inheritance:
Template inheritance Template inheritance
-------------------- --------------------

View File

@@ -42,7 +42,7 @@ def check_changelog
# Now, check that links [#nn](.../nn) have matching numbers in link title & URL # Now, check that links [#nn](.../nn) have matching numbers in link title & URL
wrong_links = line.scan(links).reject do |m| wrong_links = line.scan(links).reject do |m|
slug = m[0] || "stencilproject/#{current_repo}" slug = m[0] || "stencilproject/Stencil"
(slug == m[2]) && (m[1] == m[4]) (slug == m[2]) && (m[1] == m[4])
end end
all_warnings.concat Array(wrong_links.map do |m| all_warnings.concat Array(wrong_links.map do |m|

View File

@@ -5,9 +5,9 @@
namespace :lint do namespace :lint do
SWIFTLINT = 'rakelib/lint.sh' SWIFTLINT = 'rakelib/lint.sh'
SWIFTLINT_VERSION = '0.48.0' SWIFTLINT_VERSION = '0.61.0'
task :install do |task| task :install do |task|
next if check_version next if check_version
if OS.mac? if OS.mac?

View File

@@ -7,7 +7,7 @@ require 'json'
namespace :release do namespace :release do
desc 'Create a new release' desc 'Create a new release'
task :new => [:check_versions, :check_tag_and_ask_to_release, 'spm:test', :github, :cocoapods] task :new => [:check_versions, :check_tag_and_ask_to_release, 'spm:test', :github]
desc 'Check if all versions from the podspecs and CHANGELOG match' desc 'Check if all versions from the podspecs and CHANGELOG match'
task :check_versions do task :check_versions do
@@ -15,7 +15,7 @@ namespace :release do
Utils.table_header('Check', 'Status') Utils.table_header('Check', 'Status')
# Check if bundler is installed first, as we'll need it for the cocoapods task (and we prefer to fail early) # Check if bundler is installed first (we prefer to fail early)
`which bundler` `which bundler`
results << Utils.table_result( results << Utils.table_result(
$CHILD_STATUS.success?, $CHILD_STATUS.success?,
@@ -40,18 +40,14 @@ namespace :release do
# Check docs installation # Check docs installation
docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1) docs_package = Utils.first_match_in_file('docs/installation.rst', /\.package\(url: .+ from: "(.+)"/, 1)
docs_cocoapods = Utils.first_match_in_file('docs/installation.rst', /pod 'Stencil', '~> (.+)'/, 1)
docs_carthage = Utils.first_match_in_file('docs/installation.rst', /github ".+\/Stencil" ~> (.+)/, 1)
results << Utils.table_result(docs_package == v, 'Docs, package updated', 'Update the package version in docs/installation.rst') results << Utils.table_result(docs_package == v, 'Docs, package updated', 'Update the package version in docs/installation.rst')
results << Utils.table_result(docs_cocoapods == v, 'Docs, cocoapods updated', 'Update the cocoapods version in docs/installation.rst')
results << Utils.table_result(docs_carthage == v, 'Docs, carthage updated', 'Update the carthage version in docs/installation.rst')
# Check if entry present in CHANGELOG # Check if entry present in CHANGELOG
changelog_entry = Utils.first_match_in_file('CHANGELOG.md', /^## #{Regexp.quote(v)}$/) changelog_entry = Utils.first_match_in_file('CHANGELOG.md', /^## #{Regexp.quote(v)}$/)
results << Utils.table_result(changelog_entry, 'CHANGELOG, Entry added', "Add an entry for #{v} in CHANGELOG.md") results << Utils.table_result(changelog_entry, 'CHANGELOG, Entry added', "Add an entry for #{v} in CHANGELOG.md")
changelog_has_stable = system("grep -qi '^## Master' CHANGELOG.md") changelog_has_stable = system("grep -qi '^## Master' CHANGELOG.md")
results << Utils.table_result(!changelog_has_stable, 'CHANGELOG, No master', 'Remove section for master branch in CHANGELOG') results << Utils.table_result(!changelog_has_stable, 'CHANGELOG, No master', 'Remove section for main branch in CHANGELOG')
exit 1 unless results.all? exit 1 unless results.all?
end end
@@ -82,16 +78,10 @@ namespace :release do
tag = Utils.top_changelog_version tag = Utils.top_changelog_version
body = Utils.top_changelog_entry body = Utils.top_changelog_entry
raise 'Must be a valid version' if tag == 'Master' raise 'Must be a valid version' if tag == 'main'
repo_name = File.basename(`git remote get-url origin`.chomp, '.git').freeze repo_name = File.basename(`git remote get-url origin`.chomp, '.git').freeze
puts "Pushing release notes for tag #{tag}" puts "Pushing release notes for tag #{tag}"
client.create_release("stencilproject/#{repo_name}", tag, name: tag, body: body) client.create_release("swiftstencil/#{repo_name}", tag, name: tag, body: body)
end
desc "pod trunk push #{POD_NAME} to CocoaPods"
task :cocoapods do
Utils.print_header 'Pushing pod to CocoaPods Trunk'
sh "bundle exec pod trunk push #{POD_NAME}.podspec.json"
end end
end end

View File

@@ -55,7 +55,7 @@ class Utils
def self.spm_own_version(dep) def self.spm_own_version(dep)
dependencies = JSON.load(File.new('Package.resolved'))['object']['pins'] dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']
dependencies.find { |d| d['package'] == dep }['state']['version'] dependencies.find { |d| d['package'] == dep }['state']['version']
end end
def self.spm_resolved_version(dep) def self.spm_resolved_version(dep)
dependencies = JSON.load(File.new('Package.resolved'))['object']['pins'] dependencies = JSON.load(File.new('Package.resolved'))['object']['pins']