Compare commits

...

161 Commits

Author SHA1 Message Date
Chris AtLee
da4afd4156
Merge pull request #1905 from Shopify/catlee/invalid_utf8
Raise SyntaxError on invalid UTF8 strings in lexer/tokenizer
2025-02-13 09:24:11 -05:00
Chris AtLee
1bb3091208
Merge pull request #1909 from Shopify/catlee/5.7.3
Bump version to 5.7.3
2025-02-13 09:22:47 -05:00
Max Stoiber
040801b32c
Fix array has filters referring to some (#1910) 2025-02-12 17:37:49 +01:00
Chris AtLee
550135c0b9 Raise SyntaxError on invalid UTF8 strings in lexer/tokenizer 2025-02-11 14:23:15 -05:00
Chris AtLee
aec966eed7 Bump version to 5.7.3 2025-02-11 14:21:14 -05:00
Michael Go
bfe29e11be
Merge pull request #1907 from Shopify/nested-properties
Fix array filters to not support nested properties
2025-01-31 12:35:03 -04:00
Guilherme Carreiro
f9454d8cf3 Fix array filters to not support nested properties 2025-01-31 13:53:17 +01:00
Guilherme Carreiro
8dd9279265 Fix release date on History.md 2025-01-24 15:34:48 +01:00
Guilherme Carreiro
bf1419b8ac Apply the same fix for find_index and has 2025-01-24 08:39:01 +01:00
Guilherme Carreiro
5718c4cee2 Fix the find filter to return nil when filtering empty arrays 2025-01-24 08:39:01 +01:00
Guilherme Carreiro
b0dbc62696
Fix bundle exec rake example (#1900)
* Fix `bundle exec rake example`

* Move 'webrick' from 'benchmark/test' to 'development'
2025-01-23 08:21:38 +01:00
Guilherme Carreiro
03aafa974c
Bump to 5.7.0 (#1894)
* Bump to 5.7.0

* Update 'History.md'

* Update 'Releasing' steps

* Update CONTRIBUTING.md

Co-authored-by: Gray Gilmore <graygilmore@gmail.com>

* Update History.md

Co-authored-by: Ian Ker-Seymer <ian.kerseymer@shopify.com>

---------

Co-authored-by: Gray Gilmore <graygilmore@gmail.com>
Co-authored-by: Ian Ker-Seymer <ian.kerseymer@shopify.com>
2025-01-17 12:10:18 +01:00
Ian Ker-Seymer
6372289ba3
Ensure we use InputIterator#each when in join filter (#1898) 2025-01-16 11:36:36 -05:00
Ian Ker-Seymer
0ec52a40b5
Use Liquid::Utils.to_s for join filter (#1897) 2025-01-16 11:21:58 -05:00
Ian Ker-Seymer
74af735f0e
Allow for custom < Hash classes to override #to_s (#1896) 2025-01-16 11:16:13 -05:00
Ian Ker-Seymer
4b65a28722
Implement logic for stringify Hashes to keep compat with 3.4 (#1892)
* Exploring

* Bump to v5.6.5

---------

Co-authored-by: Dominic Petrick <dominic.petrick@shopify.com>
2025-01-15 16:36:53 -05:00
Guilherme Carreiro
ecf25ea83d
Add the "Releasing" section to CONTRIBUTING.md (#1891)
* Add 'Releasing' workflow to 'CONTRIBUTING.md'

* Update CONTRIBUTING.md

Co-authored-by: Michael Go <michael.go@shopify.com>

---------

Co-authored-by: Michael Go <michael.go@shopify.com>
2025-01-14 19:07:11 +01:00
Guilherme Carreiro
6909570f8e
Add find, find_index, has, and reject filters to arrays (#1869)
* Add reject filter #1573

* Add deep search for filter taking in properties #1749

* Update branch with main

* Add `find`, `find_index`, `has`, and `reject` filters to arrays

* Refactor: avoid usage of public_send

---------

Co-authored-by: Anders Søgaard <andershagbard@gmail.com>
Co-authored-by: Anders Søgaard <9662430+andershagbard@users.noreply.github.com>
2025-01-14 10:33:31 +01:00
Michael Go
cd9971579f
Merge pull request #1889 from Shopify/default-variable-lookup-string-scanner
Add default string_scanner to `Liquid::VariableLookup.parse`
2025-01-13 23:28:11 -04:00
Ian Ker-Seymer
8e37c5e18b
Bump to v5.6.4 2025-01-13 21:57:52 -05:00
Ian Ker-Seymer
b3f9639e7d
Add default string_scanner to Liquid::VariableLookup.parse 2025-01-13 21:56:39 -05:00
Michael Go
fe3da0e17d
Merge pull request #1887 from Shopify/remove-lru-redux
remove lru-redux
2025-01-13 18:37:17 -04:00
Michael Go
e200c4544b remove expression caching while rendering 2025-01-13 18:36:30 -04:00
Michael Go
7124540563 remove lru-redux 2025-01-13 18:27:56 -04:00
Michael Go
0558bd12c4
Merge pull request #1886 from Shopify/fix-parsing-float-with-leading-point
float has to start with a digit
2025-01-13 17:17:15 -04:00
Michael Go
0639a094a8 bump version to 5.6.2 2025-01-13 17:16:33 -04:00
Michael Go
58777bcd93 float has to start with a digit 2025-01-13 17:13:27 -04:00
Michael Go
323951b36f
Merge pull request #1844 from Shopify/fast-expression-parse
Faster Expression parser / Tokenizer with StringScanner
2025-01-10 15:34:06 -04:00
Benjamin Stein
a5b91e83ae
Update README.md with default environment (#1879)
Smaller projects, especially when upgrading from earlier versions, may not need different environment scopes, so just adding to the docs.
2025-01-10 14:28:46 -05:00
Michael Go
10114b333e minor version bump 2025-01-10 15:28:02 -04:00
Michael Go
a07ae90523 use Ruby Hash as default expression cache 2025-01-10 15:07:57 -04:00
Michael Go
2e236b0a0e fix expression cache to be compatible with Ruby Hash and LruCache 2025-01-10 14:58:23 -04:00
Michael Go
bd05dfbd1c use lru_redux getset for simplicity 2025-01-10 14:41:35 -04:00
Michael Go
2a5ecf068f remove lru_redux from Gemfile 2025-01-10 14:38:50 -04:00
Michael Go
0b8e30b819 update unit test to be compatible with new source_location format 2025-01-10 14:37:54 -04:00
Michael Go
97cada61f9 remove Expression2 strscan workaround 2025-01-10 14:23:57 -04:00
Michael Go
7c9b77e8cf remove StringScanner version check in Lexer and Tokenizer 2025-01-07 14:51:29 -04:00
Michael Go
d48708ae46 skip expression caching with Expression1 2025-01-07 14:32:46 -04:00
Michael Go
42d822bda9 users can provide optional expression cache 2025-01-07 14:32:46 -04:00
Michael Go
2c7d686690 adding caching to context variable lookup 2025-01-07 14:32:46 -04:00
Michael Go
313d01706a code styling 2025-01-07 14:32:46 -04:00
Michael Go
e9f86724f6 code styling 2025-01-07 14:32:46 -04:00
Michael Go
0a17c15289 freeze '-' VariableLookup in Expression 2025-01-07 14:32:46 -04:00
Michael Go
04bd9dbe90 refactor Lexer to only take StringScanner 2025-01-07 14:32:46 -04:00
Michael Go
253ec81b56 don't add a breaking change to Parser constructor 2025-01-07 14:32:46 -04:00
Michael Go
e05719fb82 use Kernel Integer parsing for simple Integer expression 2025-01-07 14:32:46 -04:00
Michael Go
ac374a208f freeze Expression2 parse results 2025-01-07 14:32:46 -04:00
Michael Go
91be3dd75e keep the expression parse caching per document parsing 2025-01-07 14:32:46 -04:00
Michael Go
40191022e8 initialize StringScanner with input for default 2025-01-07 14:32:46 -04:00
Michael Go
71506ad54e avoid setting up StringScanner for parsing simple numbers 2025-01-07 14:32:46 -04:00
Michael Go
cef64e277e early match number expressions with Regex 2025-01-07 14:32:46 -04:00
Michael Go
7c592c1c00 store StringScanner in ParseContext and reuse it through parsing 2025-01-07 14:32:45 -04:00
Michael Go
002e4caea7 refactor Lexer to be static class function 2025-01-07 14:32:07 -04:00
Michael Go
3c16c27ee1 add StringScannerPool for thread safety 2025-01-07 14:32:07 -04:00
Michael Go
eff5c5de8e code clean up 2025-01-07 14:32:07 -04:00
Michael Go
19adfbd863 don't force strscan 3.1 2025-01-07 14:32:07 -04:00
Michael Go
a672f3836c don't cache literals for Expression parser 2025-01-07 14:32:07 -04:00
Michael Go
92fa334192 don't cache string expressions to be more perfomant 2025-01-07 14:32:07 -04:00
Michael Go
7c8a269b4d fix cycle tag not resetting 2025-01-07 14:32:07 -04:00
Michael Go
0b1dc295ff fix quirky negative sign expression markup parsing 2025-01-07 14:32:07 -04:00
Michael Go
1de6025362 decrease the size of Expression2 LRU cache to store 5 themes 2025-01-07 14:32:07 -04:00
Michael Go
8ecb703d4d use StringScanner to improve Expression Parsing and Tokenizer 2025-01-07 14:32:06 -04:00
Ian Ker-Seymer
b4667adadf
Bump to v5.6.0 (#1876) 2024-12-19 15:01:16 -05:00
Bahar Pourazar
94e02d765f
Merge pull request #1874 from Shopify/bp/bump-version
Bump version patch
2024-12-17 17:39:35 -05:00
Bahar Pourazar
60701f865d Bump version patch 2024-12-17 17:37:00 +00:00
Bahar Pourazar
0c49dd592f bring back to_s.to_str 2024-12-17 17:37:00 +00:00
Bahar Pourazar
c3ac0e0127
Merge pull request #1873 from Shopify/bp/tokenizer-fix
Fix bug in tokenizer with nil source value
2024-12-17 11:43:16 -05:00
Bahar Pourazar
a0a4307e7d Fix bug in tokenizer with nil value 2024-12-17 15:34:13 +00:00
Ian Ker-Seymer
fdd8c714b2
Stop testing against liquid-c (#1868)
* Stop testing against `liquid-c`

* Bump to `v5.6.0.rc2`
2024-12-11 12:23:50 -05:00
Ian Ker-Seymer
63583ffe5b
Write one value at a time for array variables (#1863)
* Write one value at a time for array variables

* Handle recursive array
2024-12-11 10:16:58 -05:00
Benjamin Sehl
9a06cedbba
Merge pull request #1634 from tjoyal/patch-1
Update homepage url
2024-12-11 09:20:15 -05:00
Ian Ker-Seymer
42b6763546
Bump to v5.6.0.rc1 2024-11-04 15:26:56 -05:00
Michael Go
e5d18c83bb
Merge pull request #1848 from Shopify/env-warn-cleanup
clean up all warnings by using new Environment
2024-11-04 16:17:17 -04:00
Michael Go
c77ff68573 clean up all warnings by using new Environment 2024-11-04 16:15:05 -04:00
Ian Ker-Seymer
b0cba0bfd2
Remove Liquid.cache_classes option (#1847) 2024-11-04 14:41:56 -05:00
Michael Go
8d8661349a
Merge pull request #1843 from Shopify/empty-array
avoid allocating new empty array
2024-11-04 15:36:46 -04:00
Michael Go
1f3ea7322b avoid allocating new empty array 2024-11-04 15:35:45 -04:00
Michael Go
06f44226c0
Merge pull request #1846 from Shopify/env-propgating
propagate Environment on new Context creation
2024-11-04 15:33:50 -04:00
Michael Go
4bd22a26dc
Merge pull request #1845 from Shopify/remove-tag-registry
remove TagRegistry
2024-11-04 15:33:30 -04:00
Michael Go
3ed54bfdf9 propagate Environment on new Context creation 2024-11-04 15:32:07 -04:00
Michael Go
29986d3704 remove TagRegistry 2024-11-04 15:22:56 -04:00
Michael Go
8e40f8050a
Merge pull request #1838 from Shopify/quirky-lexer-parsing
fix parsing quirky incomplete expressions
2024-10-30 13:52:47 -03:00
Michael Go
ffce6de8bb avoid using StringScanner eos 2024-10-30 13:45:10 -03:00
Michael Go
f00670cb01 refactor lexer unit test 2024-10-30 13:44:36 -03:00
Michael Go
f6a3e25e2e fix parsing quirky incomplete expressions 2024-10-30 13:44:35 -03:00
Michael Go
f6ffc37cf2
Merge pull request #1840 from Shopify/fix-lexer-contains-as-id
fix lexer parsing ID 'contains' as comparison
2024-10-30 13:43:48 -03:00
Michael Go
1375a9e4dc fix lexer parsing ID 'contains' as comparison 2024-10-30 13:39:55 -03:00
Michael Go
c626dfa1a1
Merge pull request #1839 from Shopify/lexer-parse-error-with-utf8
raise syntax error from lexer parser with UTF-8 character
2024-10-30 13:39:05 -03:00
Michael Go
8a9f33a060 raise syntax error from lexer parser with utf8 character 2024-10-29 22:04:37 -03:00
Michael Go
1943441361
Merge pull request #1835 from Shopify/fix-multibyte-variable-parsing
fix parsing Variable blockbody with multibyte character
2024-10-28 19:31:21 -03:00
Michael Go
36251e640c
Merge pull request #1837 from Shopify/lexer-comparison-fix
fix lexer parsing comparison without whitespaces
2024-10-28 19:31:11 -03:00
Michael Go
d94293a464 fix lexer parsing comparison without whitespaces 2024-10-28 19:30:12 -03:00
Michael Go
6c13805a60 fix parsing Variable blockbody with multibyte character 2024-10-28 17:33:55 -03:00
Michael Go
b4196489c2
Merge pull request #1833 from Shopify/fast-variable-parse
Faster Variable BlockBody Matching
2024-10-28 15:28:04 -03:00
Gray Gilmore
6d58c41440
Merge pull request #1831 from Shopify/gg-add-named-params-docs
Update liquid docs for named parameters
2024-10-28 09:20:55 -07:00
Michael Go
fb6ac72520 use byteslice to create Variable BlockBody 2024-10-25 15:41:35 -03:00
Michael Go
cb16219552 faster BlockBody variable matching 2024-10-25 15:22:56 -03:00
Gray Gilmore
8d7ed706f4
Update liquid docs for named parameters
The YARD liquid gem now supports specifying named parameters. For the
core liquid tags and filters this is the only object I could find that
needed to be updated.
2024-10-24 09:56:50 -07:00
Ian Ker-Seymer
b3553787c8
Speed up the lexer for Ruby 3.4+ (#1832)
* Speed up lexing

* Bump msrv to 3.0 (from 2.7)

* Normalize test for ruby-head compat

* Fix bug when parsing negative numbers
2024-10-23 14:15:33 -04:00
Ian Ker-Seymer
b233b3d081
Bump to v5.6.0.alpha (#1819) 2024-08-15 16:12:16 -04:00
Alex Coco
ac91d31268
Merge pull request #1818 from Shopify/tablerow-interrupt
Handle interrupts in table row
2024-08-15 14:04:35 -04:00
Alex Coco
9067e5167a
Handle interrupts in table row 2024-08-15 11:56:29 -04:00
Ian Ker-Seymer
fb6634f454
Add concept of Liquid::Environment (#1815)
* Add concept of `Liquid::World`

* Rename `World` to `Environment`
2024-08-07 15:00:44 -04:00
Ian Ker-Seymer
a0411e0927 Bump to v5.5.1 2024-07-22 23:25:56 -04:00
Ian Ker-Seymer
ed421202e2
Merge pull request #1811 from Shopify/marco-cycle-update
Add named? method to Cycle class for checking if the cycle is named
2024-07-16 15:58:21 -04:00
Ian Ker-Seymer
d6ca569e8a
Require base64 2024-07-16 15:56:47 -04:00
Marco Concetto Rudilosso
d36937d17f
Add named? method to Cycle class for checking if the cycle is named 2024-07-16 15:52:25 -04:00
Peter Zhu
77bc56a1c2
Merge pull request #1792 from Shopify/centralize_ruby_version
Centralize Ruby Version to `.ruby-version`
2024-04-11 11:12:08 -04:00
Jenny Shen
88d013c8da Use latest compatible Bundler of each Ruby version run 2024-04-05 10:33:27 -04:00
Jenny Shen
36c7fc8e07 Remove ruby version definition in CI/CD
it will be read from .ruby-version
2024-04-05 10:33:06 -04:00
Jenny Shen
2b4810006b Remove TargetRubyVersion in RuboCop config
reads from required_ruby_version in RuboCop 1.61+
2024-04-05 10:23:39 -04:00
Jenny Shen
fc4f19471e Update rubocop to 1.61.0 2024-04-05 10:23:37 -04:00
Jenny Shen
8596bb2e38 Commit a .ruby-version 2024-04-05 10:23:05 -04:00
Ian Ker-Seymer
6bf18775e7
Merge pull request #1791 from Shopify/v5.5.0
Bump to v5.5.0
2024-03-21 15:50:59 -04:00
Ian Ker-Seymer
56a0b7c42b
Bump to v5.5.0 2024-03-21 15:48:54 -04:00
Ian Ker-Seymer
dba733084e
Merge pull request #1760 from mtasaka/nil-nil-comparison-fix
change: make nil_safe_casecmp judge compatible for nil-nil comparison
2024-03-21 15:38:13 -04:00
Michael Go
4a4fe3c72a
Merge pull request #1781 from Shopify/contextualize-before-to-liquid
update variable's context before invoking its to_liquid
2024-02-12 16:58:54 -04:00
Michael Go
4f35b0bc66 update liquid-c 2024-02-12 16:49:52 -04:00
Michael Go
a5e5fab82a update variable's context before invoking its to_liquid 2024-02-12 16:48:20 -04:00
Ian Ker-Seymer
02ecaab9d1
Merge pull request #1783 from Shopify/contains-encoding
Fallback to binary comparison when `contains` RHS is  UTF8 encoded
2024-01-31 11:14:01 -05:00
Ian Ker-Seymer
1b2b62964e
Allow for binary comparison of incompatible strings 2024-01-30 19:03:05 -05:00
Michael Go
9b38a15282
Merge pull request #1776 from Shopify/refactor/invalid-encoding-error
add new TemplateEncodingError
2024-01-12 12:38:34 -04:00
Michael Go
7b25b770af add new TemplateEncodingError 2024-01-12 12:17:20 -04:00
Michael Go
cf76c0bbec
Merge pull request #1775 from Shopify/allow-nil-template-source
allow non-string template source
2024-01-12 10:48:30 -04:00
Michael Go
6a0fe3f7e3 convert template source to string to ensure encoding validity 2024-01-10 16:50:31 -04:00
Michael Go
730ad3684a allow nil template source 2024-01-10 16:32:08 -04:00
Michael Go
3ac7e470e6
Merge pull request #1774 from Shopify/check-utf8-validity
check template UTF8 validity before parsing
2024-01-10 15:27:49 -04:00
Michael Go
369a6c55e3 check template UTF8 validity before parsing 2024-01-10 18:58:15 +00:00
Michael Go
f5ed5404b5
Merge pull request #1773 from Shopify/liquid-tag-whitespace-control-with-comment
don't reset Liquid tag's whitespace control from comment tag
2024-01-05 10:54:32 -04:00
Michael Go
a3c837687e don't reset Liquid tag's whitespace control from comment tag 2024-01-05 10:21:33 -04:00
Michael Go
96a036372d
Merge pull request #1770 from Shopify/revert-invalid-comment-body
don't allow invalid syntax inside comment tag
2024-01-02 11:07:05 -04:00
Mamoru TASAKA
4924822c88 change: make nil_safe_casecmp judge compatible for nil-nil comparison
Ruby returns 0 (not nil) for nil <=> nil, i.e. nil and nil are judged
as equal for comparison, and so returns nil_safe_compare .
ref: https://github.com/Shopify/liquid/pull/1476

To make the behavior of nil_safe_casecmp consistent with
nil_safe_compare , change nil_safe_casecmp so that comparison between
nil <=> nil return 0 (equal).

Also change testsuite to reflect this change.

Fixes #1759 .
2023-12-15 15:09:57 +09:00
Michael Go
fc9c338682 refactor: rename comment tag unit test 2023-12-14 17:08:26 -04:00
Michael Go
3c5ad7db61 don't allow invalid syntax inside comment tag 2023-12-14 17:01:03 -04:00
Michael Go
c658bf970a
Merge pull request #1769 from Shopify/comment-tag-with-extra-string
fix parsing comment tag with extra string
2023-12-13 14:16:44 -04:00
Michael Go
c618ac1c9f fix parsing comment tag with extra string 2023-12-12 16:23:37 -04:00
Ian Ker-Seymer
0f0d5d889f
Merge pull request #1750 from IevaGraz/fix/custom_tag_rendering
Fix for custom tag rendering
2023-12-10 11:28:50 -05:00
Michael Go
11a1f8e673
Merge pull request #1764 from Shopify/fix-comment-tag-whitespace-control
implement whitespace control to comment tag
2023-12-06 14:30:16 -05:00
Michael Go
24d461a9e3 implement whitespace control to comment tag 2023-12-01 16:04:03 -04:00
Michael Go
cbb422e5d3
Merge pull request #1763 from Shopify/fix-comment-tag-delim-parsing
fix parsing comment tag delimiter
2023-12-01 15:35:32 -04:00
Michael Go
bf0f79f36c fix parsing comment tag delimiter 2023-11-30 13:24:54 -04:00
Michael Go
cf2787791e
Merge pull request #1755 from Shopify/non-parsing-comment
Don't parse nodes inside a comment tag
2023-11-29 14:10:20 -04:00
Michael Go
6a5ebb0e85 fix parsing nested comment tag with extra strings 2023-11-11 12:19:44 -04:00
Michael Go
6dafc19b6d fix parsing comment tag delimiter with extra strings 2023-11-10 15:42:54 -04:00
Michael Go
41f65173b0 fix parsing comment block body inside a liquid tag 2023-11-09 16:27:23 -04:00
Michael Go
e180535784 allow incomplete tags inside a comment tag 2023-11-08 16:50:41 -04:00
Michael Go
a681e73aec refactor comment tag unit test to use test helper 2023-11-08 16:45:11 -04:00
Michael Go
2abf52d546 add a quirky comment tag unit test 2023-11-08 11:23:00 -04:00
Michael Go
eada2b65a2
clean up comment tag body parsing
Co-authored-by: Peter Zhu <peter@peterzhu.ca>
2023-11-08 11:13:13 -04:00
Michael Go
dbf0aa8cd5 don't parse nodes inside a comment tag
Co-authored-by: Alex Coco <alex.coco@shopify.com>
2023-11-07 18:38:13 -04:00
Ieva Grazuleviciute
407a8e5b0f Fix for custom tag rendering 2023-10-20 11:42:54 +03:00
Ateş Göral
e3dcc75ab5
Merge pull request #1748 from Shopify/atesgoral/update-ci-status-badge
Update CI status badge
2023-10-16 11:23:04 -04:00
Ates Goral
ceb7a4237f Update CI status badge 2023-10-16 11:17:24 -04:00
Guillaume Malette
7b60b7fef5
Merge pull request #1746 from Shopify/gm/base64-encoding
[base64] Respect string encoding of input in base64_decode filters
2023-09-29 11:13:18 -04:00
Guillaume Malette
33e1a8ffbc
fix style 2023-09-29 09:50:42 -04:00
Guillaume Malette
cc47fa8f03
[base64] Respect string encoding of input in base64_decode filters 2023-09-29 09:39:59 -04:00
Adam Klingbaum
b1b9b9f691
Merge pull request #1739 from Shopify/klingbaum/fix-sum-filter-float-output
Fix `BigDecimal` output in `sum` filter
2023-09-27 13:34:27 -04:00
Adam Klingbaum
75e7725f57
Streamline new tests for sum filter 2023-08-08 16:15:48 +00:00
James Prior
de6d15a73e
Test sum floats from properties. 2023-08-08 16:10:04 +00:00
James Prior
2c5d2be193
Don't render sum filter results in scientific notation. 2023-08-08 16:10:03 +00:00
Thierry Joyal
347a2418c4
Update homepage url
`http://www.liquidmarkup.org` is `http` 
`http://www.liquidmarkup.org` redirects to `https://shopify.github.io/liquid/`
`https://www.liquidmarkup.org` can’t provide a secure connection (ERR_SSL_PROTOCOL_ERROR)
2022-10-03 16:52:22 -04:00
84 changed files with 2687 additions and 597 deletions

View File

@ -11,9 +11,11 @@ jobs:
strategy: strategy:
matrix: matrix:
entry: entry:
- { ruby: 2.7, allowed-failure: false } # minimum supported - { ruby: 3.0, allowed-failure: false } # minimum supported
- { ruby: 3.2, allowed-failure: false } # latest - { ruby: 3.2, allowed-failure: false }
- { ruby: ruby-head, allowed-failure: true } - { ruby: 3.3, allowed-failure: false }
- { ruby: 3.4, allowed-failure: false } # latest
- { ruby: ruby-head, allowed-failure: false }
name: Test Ruby ${{ matrix.entry.ruby }} name: Test Ruby ${{ matrix.entry.ruby }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -21,6 +23,7 @@ jobs:
with: with:
ruby-version: ${{ matrix.entry.ruby }} ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true bundler-cache: true
bundler: latest
- run: bundle exec rake - run: bundle exec rake
continue-on-error: ${{ matrix.entry.allowed-failure }} continue-on-error: ${{ matrix.entry.allowed-failure }}
@ -30,6 +33,5 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
ruby-version: 2.7
bundler-cache: true bundler-cache: true
- run: bundle exec rake memory_profile:run - run: bundle exec rake memory_profile:run

3
.gitignore vendored
View File

@ -4,7 +4,6 @@
pkg pkg
*.rbc *.rbc
.rvmrc .rvmrc
.ruby-version
Gemfile.lock
.bundle .bundle
.byebug_history .byebug_history
Gemfile.lock

View File

@ -10,7 +10,6 @@ Performance:
Enabled: true Enabled: true
AllCops: AllCops:
TargetRubyVersion: 2.7
NewCops: disable NewCops: disable
SuggestExtensions: false SuggestExtensions: false
Exclude: Exclude:

1
.ruby-version Normal file
View File

@ -0,0 +1 @@
3.4.1

View File

@ -26,3 +26,11 @@
* If it makes sense, add tests for your code and/or run a performance benchmark * If it makes sense, add tests for your code and/or run a performance benchmark
* Make sure all tests pass (`bundle exec rake`) * Make sure all tests pass (`bundle exec rake`)
* Create a pull request * Create a pull request
## Releasing
* Bump the version in `lib/liquid/version.rb`
* Update the `History.md` file
* Open a PR like [this one](https://github.com/Shopify/liquid/pull/1894) and merge it to `main`
* Create a new release using the [GitHub UI](https://github.com/Shopify/liquid/releases/new)

13
Gemfile
View File

@ -7,22 +7,25 @@ end
gemspec gemspec
gem "base64"
group :benchmark, :test do group :benchmark, :test do
gem 'benchmark-ips' gem 'benchmark-ips'
gem 'memory_profiler' gem 'memory_profiler'
gem 'terminal-table' gem 'terminal-table'
gem "lru_redux"
install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do
gem 'stackprof' gem 'stackprof'
end end
end end
group :development do
gem "webrick"
end
group :test do group :test do
gem 'rubocop', '~> 1.44.0' gem 'rubocop', '~> 1.61.0'
gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-shopify', '~> 2.12.0', require: false
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
platform :mri, :truffleruby do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'master'
end
end end

View File

@ -1,5 +1,70 @@
# Liquid Change Log # Liquid Change Log
## 5.7.3 (unreleased)
* Raise Liquid::SyntaxError when parsing invalidly encoded strings
## 5.7.2 2025-01-31
* Fix array filters to not support nested properties
## 5.7.1 2025-01-24
* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays
* Fix the `has` filter to return `false` when filtering empty arrays
## 5.7.0 2025-01-16
### Features
* Add `find`, `find_index`, `has`, and `reject` filters to arrays
* Compatibility with Ruby 3.4
## 5.6.4 2025-01-14
### Fixes
* Add a default `string_scanner` to avoid errors with `Liquid::VariableLookup.parse("foo.bar")` [Ian Ker-Seymer]
## 5.6.3 2025-01-13
* Remove `lru_redux` dependency [Michael Go]
## 5.6.2 2025-01-13
### Fixes
* Preserve the old behavior of requiring floats to start with a digit [Michael Go]
## 5.6.1 2025-01-13
### Performance improvements
* Faster Expression parser / Tokenizer with StringScanner [Michael Go]
## 5.6.0 2024-12-19
### Architectural changes
* Added new `Environment` class to manage configuration and state that was previously stored in `Template` [Ian Ker-Seymer]
* Moved tag registration from `Template` to `Environment` [Ian Ker-Seymer]
* Removed `StrainerFactory` in favor of `Environment`-based strainer creation [Ian Ker-Seymer]
* Consolidated standard tags into a new `Tags` module with `STANDARD_TAGS` constant [Ian Ker-Seymer]
### Performance improvements
* Optimized `Lexer` with a new `Lexer2` implementation using jump tables for faster tokenization, requires Ruby 3.4 [Ian Ker-Seymer]
* Improved variable rendering with specialized handling for different types [Michael Go]
* Reduced array allocations by using frozen empty constants [Michael Go]
### API changes
* Deprecated several `Template` class methods in favor of `Environment` methods [Ian Ker-Seymer]
* Added deprecation warnings system [Ian Ker-Seymer]
* Changed how filters and tags are registered to use Environment [Ian Ker-Seymer]
### Fixes
* Fixed table row handling of break interrupts [Alex Coco]
* Improved variable output handling for arrays [Ian Ker-Seymer]
* Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar]
## 5.5.0 2024-03-21
Please reference the GitHub release for more information.
## 5.4.0 2022-07-29 ## 5.4.0 2022-07-29
### Breaking Changes ### Breaking Changes

View File

@ -1,4 +1,4 @@
[![Build Status](https://api.travis-ci.org/Shopify/liquid.svg?branch=master)](http://travis-ci.org/Shopify/liquid) [![Build status](https://github.com/Shopify/liquid/actions/workflows/liquid.yml/badge.svg)](https://github.com/Shopify/liquid/actions/workflows/liquid.yml)
[![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](http://inch-ci.org/github/Shopify/liquid) [![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](http://inch-ci.org/github/Shopify/liquid)
# Liquid template engine # Liquid template engine
@ -52,6 +52,47 @@ For standard use you can just pass it the content of a file and call render with
@template.render('name' => 'tobi') # => "hi tobi" @template.render('name' => 'tobi') # => "hi tobi"
``` ```
### Concept of Environments
In Liquid, a "Environment" is a scoped environment that encapsulates custom tags, filters, and other configurations. This allows you to define and isolate different sets of functionality for different contexts, avoiding global overrides that can lead to conflicts and unexpected behavior.
By using environments, you can:
1. **Encapsulate Logic**: Keep the logic for different parts of your application separate.
2. **Avoid Conflicts**: Prevent custom tags and filters from clashing with each other.
3. **Improve Maintainability**: Make it easier to manage and understand the scope of customizations.
4. **Enhance Security**: Limit the availability of certain tags and filters to specific contexts.
We encourage the use of Environments over globally overriding things because it promotes better software design principles such as modularity, encapsulation, and separation of concerns.
Here's an example of how you can define and use Environments in Liquid:
```ruby
user_environment = Liquid::Environment.build do |environment|
environment.register_tag("renderobj", RenderObjTag)
end
Liquid::Template.parse(<<~LIQUID, environment: user_environment)
{% renderobj src: "path/to/model.obj" %}
LIQUID
```
In this example, `RenderObjTag` is a custom tag that is only available within the `user_environment`.
Similarly, you can define another environment for a different context, such as email templates:
```ruby
email_environment = Liquid::Environment.build do |environment|
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
end
Liquid::Template.parse(<<~LIQUID, environment: email_environment)
{% unsubscribe_footer %}
LIQUID
```
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
### Error Modes ### Error Modes
Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted. Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted.
@ -62,9 +103,10 @@ Liquid also comes with a stricter parser that can be used when editing templates
when templates are invalid. You can enable this new parser like this: when templates are invalid. You can enable this new parser like this:
```ruby ```ruby
Liquid::Template.error_mode = :strict # Raises a SyntaxError when invalid syntax is used Liquid::Environment.default.error_mode = :strict
Liquid::Template.error_mode = :warn # Adds strict errors to template.errors but continues as normal Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
Liquid::Template.error_mode = :lax # The default mode, accepts almost anything. Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
``` ```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:

View File

@ -43,8 +43,6 @@ task :test do
Rake::Task['base_test'].invoke Rake::Task['base_test'].invoke
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby' if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_C'] = '1'
ENV['LIQUID_PARSER_MODE'] = 'lax' ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['integration_test'].reenable Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke Rake::Task['integration_test'].invoke
@ -73,7 +71,7 @@ end
namespace :benchmark do namespace :benchmark do
desc "Run the liquid benchmark with lax parsing" desc "Run the liquid benchmark with lax parsing"
task :run do task :lax do
ruby "./performance/benchmark.rb lax" ruby "./performance/benchmark.rb lax"
end end
@ -81,6 +79,33 @@ namespace :benchmark do
task :strict do task :strict do
ruby "./performance/benchmark.rb strict" ruby "./performance/benchmark.rb strict"
end end
desc "Run the liquid benchmark with both lax and strict parsing"
task run: [:lax, :strict]
desc "Run unit benchmarks"
namespace :unit do
task :all do
Dir["./performance/unit/*_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
end
end
task :lexer do
Dir["./performance/unit/lexer_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
end
end
task :expression do
Dir["./performance/unit/expression_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
end
end
end
end end
namespace :profile do namespace :profile do

View File

@ -21,6 +21,8 @@
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
require "strscan"
module Liquid module Liquid
FilterSeparator = /\|/ FilterSeparator = /\|/
ArgumentSeparator = ',' ArgumentSeparator = ','
@ -44,13 +46,21 @@ module Liquid
VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise } RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
singleton_class.send(:attr_accessor, :cache_classes)
self.cache_classes = true
end end
require "liquid/version" require "liquid/version"
require "liquid/deprecations"
require "liquid/const"
require 'liquid/standardfilters'
require 'liquid/file_system'
require 'liquid/parser_switching'
require 'liquid/tag'
require 'liquid/block'
require 'liquid/parse_tree_visitor' require 'liquid/parse_tree_visitor'
require 'liquid/interrupts'
require 'liquid/tags'
require "liquid/environment"
require 'liquid/lexer' require 'liquid/lexer'
require 'liquid/parser' require 'liquid/parser'
require 'liquid/i18n' require 'liquid/i18n'
@ -61,23 +71,16 @@ require 'liquid/extensions'
require 'liquid/errors' require 'liquid/errors'
require 'liquid/interrupts' require 'liquid/interrupts'
require 'liquid/strainer_template' require 'liquid/strainer_template'
require 'liquid/strainer_factory'
require 'liquid/expression'
require 'liquid/context' require 'liquid/context'
require 'liquid/parser_switching'
require 'liquid/tag' require 'liquid/tag'
require 'liquid/tag/disabler'
require 'liquid/tag/disableable'
require 'liquid/block'
require 'liquid/block_body' require 'liquid/block_body'
require 'liquid/document' require 'liquid/document'
require 'liquid/variable' require 'liquid/variable'
require 'liquid/variable_lookup' require 'liquid/variable_lookup'
require 'liquid/range_lookup' require 'liquid/range_lookup'
require 'liquid/file_system'
require 'liquid/resource_limits' require 'liquid/resource_limits'
require 'liquid/expression'
require 'liquid/template' require 'liquid/template'
require 'liquid/standardfilters'
require 'liquid/condition' require 'liquid/condition'
require 'liquid/utils' require 'liquid/utils'
require 'liquid/tokenizer' require 'liquid/tokenizer'
@ -86,7 +89,3 @@ require 'liquid/partial_cache'
require 'liquid/usage' require 'liquid/usage'
require 'liquid/registers' require 'liquid/registers'
require 'liquid/template_factory' require 'liquid/template_factory'
# Load all the tags of the standard library
#
Dir["#{__dir__}/liquid/tags/*.rb"].each { |f| require f }

View File

@ -6,6 +6,7 @@ module Liquid
class BlockBody class BlockBody
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om
FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om
ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om
WhitespaceOrNothing = /\A\s*\z/ WhitespaceOrNothing = /\A\s*\z/
TAGSTART = "{%" TAGSTART = "{%"
@ -51,7 +52,7 @@ module Liquid
next parse_liquid_tag(markup, parse_context) next parse_liquid_tag(markup, parse_context)
end end
unless (tag = registered_tags[tag_name]) unless (tag = parse_context.environment.tag_for_name(tag_name))
# end parsing if we reach an unknown tag and let the caller decide # end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed # determine how to proceed
return yield tag_name, markup return yield tag_name, markup
@ -146,7 +147,7 @@ module Liquid
next next
end end
unless (tag = registered_tags[tag_name]) unless (tag = parse_context.environment.tag_for_name(tag_name))
# end parsing if we reach an unknown tag and let the caller decide # end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed # determine how to proceed
return yield tag_name, markup return yield tag_name, markup
@ -245,10 +246,17 @@ module Liquid
end end
def create_variable(token, parse_context) def create_variable(token, parse_context)
if token =~ ContentOfVariable if token.end_with?("}}")
markup = Regexp.last_match(1) i = 2
i = 3 if token[i] == "-"
parse_end = token.length - 3
parse_end -= 1 if token[parse_end] == "-"
markup_end = parse_end - i + 1
markup = markup_end <= 0 ? "" : token.slice(i, markup_end)
return Variable.new(markup, parse_context) return Variable.new(markup, parse_context)
end end
BlockBody.raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context)
end end
@ -261,9 +269,5 @@ module Liquid
def raise_missing_variable_terminator(token, parse_context) def raise_missing_variable_terminator(token, parse_context)
BlockBody.raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context)
end end
def registered_tags
Template.tags
end
end end
end end

View File

@ -24,6 +24,9 @@ module Liquid
else else
false false
end end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end, end,
} }

8
lib/liquid/const.rb Normal file
View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
module Liquid
module Const
EMPTY_HASH = {}.freeze
EMPTY_ARRAY = [].freeze
end
end

View File

@ -15,35 +15,40 @@ module Liquid
# context['bob'] #=> nil class Context # context['bob'] #=> nil class Context
class Context class Context
attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments
attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters, :environment
# rubocop:disable Metrics/ParameterLists # rubocop:disable Metrics/ParameterLists
def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) def self.build(environment: Environment.default, environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block)
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, &block) new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, environment, &block)
end end
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}) def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default)
@environment = environment
@environments = [environments] @environments = [environments]
@environments.flatten! @environments.flatten!
@static_environments = [static_environments].flatten(1).freeze @static_environments = [static_environments].flatten(1).freeze
@scopes = [(outer_scope || {})] @scopes = [outer_scope || {}]
@registers = registers.is_a?(Registers) ? registers : Registers.new(registers) @registers = registers.is_a?(Registers) ? registers : Registers.new(registers)
@errors = [] @errors = []
@partial = false @partial = false
@strict_variables = false @strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits) @resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits)
@base_scope_depth = 0 @base_scope_depth = 0
@interrupts = [] @interrupts = []
@filters = [] @filters = []
@global_filter = nil @global_filter = nil
@disabled_tags = {} @disabled_tags = {}
# Instead of constructing new StringScanner objects for each Expression parse,
# we recycle the same one.
@string_scanner = StringScanner.new("")
@registers.static[:cached_partials] ||= {} @registers.static[:cached_partials] ||= {}
@registers.static[:file_system] ||= Liquid::Template.file_system @registers.static[:file_system] ||= environment.file_system
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new @registers.static[:template_factory] ||= Liquid::TemplateFactory.new
self.exception_renderer = Template.default_exception_renderer self.exception_renderer = environment.exception_renderer
if rethrow_errors if rethrow_errors
self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA
end end
@ -60,7 +65,7 @@ module Liquid
end end
def strainer def strainer
@strainer ||= StrainerFactory.create(self, @filters) @strainer ||= @environment.create_strainer(self, @filters)
end end
# Adds filters to this context. # Adds filters to this context.
@ -142,6 +147,7 @@ module Liquid
check_overflow check_overflow
self.class.build( self.class.build(
environment: @environment,
resource_limits: resource_limits, resource_limits: resource_limits,
static_environments: static_environments, static_environments: static_environments,
registers: Registers.new(registers), registers: Registers.new(registers),
@ -174,7 +180,7 @@ module Liquid
# Example: # Example:
# products == empty #=> products.empty? # products == empty #=> products.empty?
def [](expression) def [](expression)
evaluate(Expression.parse(expression)) evaluate(Expression.parse(expression, @string_scanner))
end end
def key?(key) def key?(key)
@ -197,10 +203,14 @@ module Liquid
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found) try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
end end
variable = variable.to_liquid # update variable's context before invoking #to_liquid
variable.context = self if variable.respond_to?(:context=) variable.context = self if variable.respond_to?(:context=)
variable liquid_variable = variable.to_liquid
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
liquid_variable
end end
def lookup_and_evaluate(obj, key, raise_on_not_found: true) def lookup_and_evaluate(obj, key, raise_on_not_found: true)

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
require "set"
module Liquid
class Deprecations
class << self
attr_accessor :warned
Deprecations.warned = Set.new
def warn(name, alternative)
return if warned.include?(name)
warned << name
caller_location = caller_locations(2, 1).first
Warning.warn("[DEPRECATION] #{name} is deprecated. Use #{alternative} instead. Called from #{caller_location}\n")
end
end
end
end

159
lib/liquid/environment.rb Normal file
View File

@ -0,0 +1,159 @@
# frozen_string_literal: true
module Liquid
# The Environment is the container for all configuration options of Liquid, such as
# the registered tags, filters, and the default error mode.
class Environment
# The default error mode for all templates. This can be overridden on a
# per-template basis.
attr_accessor :error_mode
# The tags that are available to use in the template.
attr_accessor :tags
# The strainer template which is used to store filters that are available to
# use in templates.
attr_accessor :strainer_template
# The exception renderer that is used to render exceptions that are raised
# when rendering a template
attr_accessor :exception_renderer
# The default file system that is used to load templates from.
attr_accessor :file_system
# The default resource limits that are used to limit the resources that a
# template can consume.
attr_accessor :default_resource_limits
class << self
# Creates a new environment instance.
#
# @param tags [Hash] The tags that are available to use in
# the template.
# @param file_system The default file system that is used
# to load templates from.
# @param error_mode [Symbol] The default error mode for all templates
# (either :strict, :warn, or :lax).
# @param exception_renderer [Proc] The exception renderer that is used to
# render exceptions.
# @yieldparam environment [Environment] The environment instance that is being built.
# @return [Environment] The new environment instance.
def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil)
ret = new
ret.tags = tags if tags
ret.file_system = file_system if file_system
ret.error_mode = error_mode if error_mode
ret.exception_renderer = exception_renderer if exception_renderer
yield ret if block_given?
ret.freeze
end
# Returns the default environment instance.
#
# @return [Environment] The default environment instance.
def default
@default ||= new
end
# Sets the default environment instance for the duration of the block
#
# @param environment [Environment] The environment instance to use as the default for the
# duration of the block.
# @yield
# @return [Object] The return value of the block.
def dangerously_override(environment)
original_default = @default
@default = environment
yield
ensure
@default = original_default
end
end
# Initializes a new environment instance.
# @api private
def initialize
@tags = Tags::STANDARD_TAGS.dup
@error_mode = :lax
@strainer_template = Class.new(StrainerTemplate).tap do |klass|
klass.add_filter(StandardFilters)
end
@exception_renderer = ->(exception) { exception }
@file_system = BlankFileSystem.new
@default_resource_limits = Const::EMPTY_HASH
@strainer_template_class_cache = {}
end
# Registers a new tag with the environment.
#
# @param name [String] The name of the tag.
# @param klass [Liquid::Tag] The class that implements the tag.
# @return [void]
def register_tag(name, klass)
@tags[name] = klass
end
# Registers a new filter with the environment.
#
# @param filter [Module] The module that contains the filter methods.
# @return [void]
def register_filter(filter)
@strainer_template_class_cache.clear
@strainer_template.add_filter(filter)
end
# Registers multiple filters with this environment.
#
# @param filters [Array<Module>] The modules that contain the filter methods.
# @return [self]
def register_filters(filters)
@strainer_template_class_cache.clear
filters.each { |f| @strainer_template.add_filter(f) }
self
end
# Creates a new strainer instance with the given filters, caching the result
# for faster lookup.
#
# @param context [Liquid::Context] The context that the strainer will be
# used in.
# @param filters [Array<Module>] The filters that the strainer will have
# access to.
# @return [Liquid::Strainer] The new strainer instance.
def create_strainer(context, filters = Const::EMPTY_ARRAY)
return @strainer_template.new(context) if filters.empty?
strainer_template = @strainer_template_class_cache[filters] ||= begin
klass = Class.new(@strainer_template)
filters.each { |f| klass.add_filter(f) }
klass
end
strainer_template.new(context)
end
# Returns the names of all the filter methods that are available to use in
# the strainer template.
#
# @return [Array<String>] The names of all the filter methods.
def filter_method_names
@strainer_template.filter_method_names
end
# Returns the tag class for the given tag name.
#
# @param name [String] The name of the tag.
# @return [Liquid::Tag] The tag class.
def tag_for_name(name)
@tags[name]
end
def freeze
@tags.freeze
# TODO: freeze the tags, currently this is not possible because of liquid-c
# @strainer_template.freeze
super
end
end
end

View File

@ -40,19 +40,20 @@ module Liquid
end end
end end
ArgumentError = Class.new(Error) ArgumentError = Class.new(Error)
ContextError = Class.new(Error) ContextError = Class.new(Error)
FileSystemError = Class.new(Error) FileSystemError = Class.new(Error)
StandardError = Class.new(Error) StandardError = Class.new(Error)
SyntaxError = Class.new(Error) SyntaxError = Class.new(Error)
StackLevelError = Class.new(Error) StackLevelError = Class.new(Error)
MemoryError = Class.new(Error) MemoryError = Class.new(Error)
ZeroDivisionError = Class.new(Error) ZeroDivisionError = Class.new(Error)
FloatDomainError = Class.new(Error) FloatDomainError = Class.new(Error)
UndefinedVariable = Class.new(Error) UndefinedVariable = Class.new(Error)
UndefinedDropMethod = Class.new(Error) UndefinedDropMethod = Class.new(Error)
UndefinedFilter = Class.new(Error) UndefinedFilter = Class.new(Error)
MethodOverrideError = Class.new(Error) MethodOverrideError = Class.new(Error)
DisabledError = Class.new(Error) DisabledError = Class.new(Error)
InternalError = Class.new(Error) InternalError = Class.new(Error)
TemplateEncodingError = Class.new(Error)
end end

View File

@ -10,37 +10,113 @@ module Liquid
'true' => true, 'true' => true,
'false' => false, 'false' => false,
'blank' => '', 'blank' => '',
'empty' => '' 'empty' => '',
# in lax mode, minus sign can be a VariableLookup
# For simplicity and performace, we treat it like a literal
'-' => VariableLookup.parse("-", nil).freeze,
}.freeze }.freeze
INTEGERS_REGEX = /\A(-?\d+)\z/ DOT = ".".ord
FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/ ZERO = "0".ord
NINE = "9".ord
DASH = "-".ord
# Use an atomic group (?>...) to avoid pathological backtracing from # Use an atomic group (?>...) to avoid pathological backtracing from
# malicious input as described in https://github.com/Shopify/liquid/issues/1357 # malicious input as described in https://github.com/Shopify/liquid/issues/1357
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
INTEGER_REGEX = /\A(-?\d+)\z/
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
def self.parse(markup) class << self
return nil unless markup def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
markup = markup.strip markup = markup.strip # markup can be a frozen string
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'")) if (markup.start_with?('"') && markup.end_with?('"')) ||
return markup[1..-2] (markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
elsif LITERALS.key?(markup)
return LITERALS[markup]
end
# Cache only exists during parsing
if cache
return cache[markup] if cache.key?(markup)
cache[markup] = inner_parse(markup, ss, cache).freeze
else
inner_parse(markup, ss, nil).freeze
end
end end
case markup def inner_parse(markup, ss, cache)
when INTEGERS_REGEX if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX
Regexp.last_match(1).to_i return RangeLookup.parse(
when RANGES_REGEX Regexp.last_match(1),
RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2)) Regexp.last_match(2),
when FLOATS_REGEX ss,
Regexp.last_match(1).to_f cache,
else )
if LITERALS.key?(markup) end
LITERALS[markup]
if (num = parse_number(markup, ss))
num
else else
VariableLookup.parse(markup) VariableLookup.parse(markup, ss, cache)
end
end
def parse_number(markup, ss)
# check if the markup is simple integer or float
case markup
when INTEGER_REGEX
return Integer(markup, 10)
when FLOAT_REGEX
return markup.to_f
end
ss.string = markup
# the first byte must be a digit or a dash
byte = ss.scan_byte
return false if byte != DASH && (byte < ZERO || byte > NINE)
if byte == DASH
peek_byte = ss.peek_byte
# if it starts with a dash, the next byte must be a digit
return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
end
# The markup could be a float with multiple dots
first_dot_pos = nil
num_end_pos = nil
while (byte = ss.scan_byte)
return false if byte != DOT && (byte < ZERO || byte > NINE)
# we found our number and now we are just scanning the rest of the string
next if num_end_pos
if byte == DOT
if first_dot_pos.nil?
first_dot_pos = ss.pos
else
# we found another dot, so we know that the number ends here
num_end_pos = ss.pos - 1
end
end
end
num_end_pos = markup.length if ss.eos?
if num_end_pos
# number ends with a number "123.123"
markup.byteslice(0, num_end_pos).to_f
else
# number ends with a dot "123."
markup.byteslice(0, first_dot_pos).to_f
end end
end end
end end

View File

@ -1,61 +1,179 @@
# frozen_string_literal: true # frozen_string_literal: true
require "strscan"
module Liquid module Liquid
class Lexer class Lexer
SPECIALS = { CLOSE_ROUND = [:close_round, ")"].freeze
'|' => :pipe, CLOSE_SQUARE = [:close_square, "]"].freeze
'.' => :dot, COLON = [:colon, ":"].freeze
':' => :colon, COMMA = [:comma, ","].freeze
',' => :comma, COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze
'[' => :open_square, COMPARISON_CONTAINS = [:comparison, "contains"].freeze
']' => :close_square, COMPARISON_EQUAL = [:comparison, "=="].freeze
'(' => :open_round, COMPARISON_GREATER_THAN = [:comparison, ">"].freeze
')' => :close_round, COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze
'?' => :question, COMPARISON_LESS_THAN = [:comparison, "<"].freeze
'-' => :dash, COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze
}.freeze COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/ DASH = [:dash, "-"].freeze
SINGLE_STRING_LITERAL = /'[^\']*'/ DOT = [:dot, "."].freeze
DOTDOT = [:dotdot, ".."].freeze
DOT_ORD = ".".ord
DOUBLE_STRING_LITERAL = /"[^\"]*"/ DOUBLE_STRING_LITERAL = /"[^\"]*"/
STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL) EOS = [:end_of_string].freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
NUMBER_LITERAL = /-?\d+(\.\d+)?/ NUMBER_LITERAL = /-?\d+(\.\d+)?/
DOTDOT = /\.\./ OPEN_ROUND = [:open_round, "("].freeze
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/ OPEN_SQUARE = [:open_square, "["].freeze
PIPE = [:pipe, "|"].freeze
QUESTION = [:question, "?"].freeze
RUBY_WHITESPACE = [" ", "\t", "\r", "\n", "\f"].freeze
SINGLE_STRING_LITERAL = /'[^\']*'/
WHITESPACE_OR_NOTHING = /\s*/ WHITESPACE_OR_NOTHING = /\s*/
def initialize(input) SINGLE_COMPARISON_TOKENS = [].tap do |table|
@ss = StringScanner.new(input) table["<".ord] = COMPARISON_LESS_THAN
table[">".ord] = COMPARISON_GREATER_THAN
table.freeze
end end
def tokenize TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
@output = [] table["=".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_EQUAL
sub_table.freeze
end
table["!".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISION_NOT_EQUAL
sub_table.freeze
end
table.freeze
end
until @ss.eos? COMPARISON_JUMP_TABLE = [].tap do |table|
@ss.skip(WHITESPACE_OR_NOTHING) table["<".ord] = [].tap do |sub_table|
break if @ss.eos? sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
tok = if (t = @ss.scan(COMPARISON_OPERATOR)) sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
[:comparison, t] sub_table.freeze
elsif (t = @ss.scan(STRING_LITERAL)) end
[:string, t] table[">".ord] = [].tap do |sub_table|
elsif (t = @ss.scan(NUMBER_LITERAL)) sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
[:number, t] sub_table.freeze
elsif (t = @ss.scan(IDENTIFIER)) end
[:id, t] table.freeze
elsif (t = @ss.scan(DOTDOT)) end
[:dotdot, t]
else NEXT_MATCHER_JUMP_TABLE = [].tap do |table|
c = @ss.getch "a".upto("z") do |c|
if (s = SPECIALS[c]) table[c.ord] = [:id, IDENTIFIER].freeze
[s, c] table[c.upcase.ord] = [:id, IDENTIFIER].freeze
end
table["_".ord] = [:id, IDENTIFIER].freeze
"0".upto("9") do |c|
table[c.ord] = [:number, NUMBER_LITERAL].freeze
end
table["-".ord] = [:number, NUMBER_LITERAL].freeze
table["'".ord] = [:string, SINGLE_STRING_LITERAL].freeze
table["\"".ord] = [:string, DOUBLE_STRING_LITERAL].freeze
table.freeze
end
SPECIAL_TABLE = [].tap do |table|
table["|".ord] = PIPE
table[".".ord] = DOT
table[":".ord] = COLON
table[",".ord] = COMMA
table["[".ord] = OPEN_SQUARE
table["]".ord] = CLOSE_SQUARE
table["(".ord] = OPEN_ROUND
table[")".ord] = CLOSE_ROUND
table["?".ord] = QUESTION
table["-".ord] = DASH
end
NUMBER_TABLE = [].tap do |table|
"0".upto("9") do |c|
table[c.ord] = true
end
table.freeze
end
# rubocop:disable Metrics/BlockNesting
class << self
def tokenize(ss)
output = []
until ss.eos?
ss.skip(WHITESPACE_OR_NOTHING)
break if ss.eos?
start_pos = ss.pos
peeked = ss.peek_byte
if (special = SPECIAL_TABLE[peeked])
ss.scan_byte
# Special case for ".."
if special == DOT && ss.peek_byte == DOT_ORD
ss.scan_byte
output << DOTDOT
elsif special == DASH
# Special case for negative numbers
if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte]
ss.pos -= 1
output << [:number, ss.scan(NUMBER_LITERAL)]
else
output << special
end
else
output << special
end
elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
ss.scan_byte
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
else
raise_syntax_error(start_pos, ss)
end
elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
ss.scan_byte
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
else
output << SINGLE_COMPARISON_TOKENS[peeked]
end
else else
raise SyntaxError, "Unexpected character #{c}" type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
if type && (t = ss.scan(pattern))
# Special case for "contains"
output << if type == :id && t == "contains" && output.last&.first != :dot
COMPARISON_CONTAINS
else
[type, t]
end
else
raise_syntax_error(start_pos, ss)
end
end end
end end
@output << tok # rubocop:enable Metrics/BlockNesting
output << EOS
rescue ::ArgumentError => e
if e.message == "invalid byte sequence in #{ss.string.encoding}"
raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}"
else
raise
end
end end
@output << [:end_of_string] def raise_syntax_error(start_pos, ss)
ss.pos = start_pos
# the character could be a UTF-8 character, use getch to get all the bytes
raise SyntaxError, "Unexpected character #{ss.getch}"
end
end end
end end
end end

View File

@ -15,6 +15,7 @@
include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]" include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]"
inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character" inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character"
invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}"
invalid_template_encoding: "Invalid template encoding"
render: "Syntax error in tag 'render' - Template name must be a quoted string" render: "Syntax error in tag 'render' - Template name must be a quoted string"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
tag_never_closed: "'%{block_name}' tag was never closed" tag_never_closed: "'%{block_name}' tag was never closed"

View File

@ -3,14 +3,27 @@
module Liquid module Liquid
class ParseContext class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode attr_reader :partial, :warnings, :error_mode, :environment
def initialize(options = {}) def initialize(options = Const::EMPTY_HASH)
@environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {} @template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new @locale = @template_options[:locale] ||= I18n.new
@warnings = [] @warnings = []
# constructing new StringScanner in Lexer, Tokenizer, etc is expensive
# This StringScanner will be shared by all of them
@string_scanner = StringScanner.new("")
@expression_cache = if options[:expression_cache].nil?
{}
elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=)
options[:expression_cache]
elsif options[:expression_cache]
{}
end
self.depth = 0 self.depth = 0
self.partial = false self.partial = false
end end
@ -23,19 +36,29 @@ module Liquid
Liquid::BlockBody.new Liquid::BlockBody.new
end end
def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false) def new_parser(input)
Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag) @string_scanner.string = input
Parser.new(@string_scanner)
end
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
Tokenizer.new(
source: source,
string_scanner: @string_scanner,
line_number: start_line_number,
for_liquid_tag: for_liquid_tag,
)
end end
def parse_expression(markup) def parse_expression(markup)
Expression.parse(markup) Expression.parse(markup, @string_scanner, @expression_cache)
end end
def partial=(value) def partial=(value)
@partial = value @partial = value
@options = value ? partial_options : @template_options @options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || Template.error_mode @error_mode = @options[:error_mode] || @environment.error_mode
end end
def partial_options def partial_options

View File

@ -36,7 +36,7 @@ module Liquid
protected protected
def children def children
@node.respond_to?(:nodelist) ? Array(@node.nodelist) : [] @node.respond_to?(:nodelist) ? Array(@node.nodelist) : Const::EMPTY_ARRAY
end end
end end
end end

View File

@ -3,8 +3,8 @@
module Liquid module Liquid
class Parser class Parser
def initialize(input) def initialize(input)
l = Lexer.new(input) ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = l.tokenize @tokens = Lexer.tokenize(ss)
@p = 0 # pointer to current location @p = 0 # pointer to current location
end end
@ -53,7 +53,7 @@ module Liquid
str = consume str = consume
str << variable_lookups str << variable_lookups
when :open_square when :open_square
str = consume str = consume.dup
str << expression str << expression
str << consume(:close_square) str << consume(:close_square)
str << variable_lookups str << variable_lookups

View File

@ -2,9 +2,9 @@
module Liquid module Liquid
class RangeLookup class RangeLookup
def self.parse(start_markup, end_markup) def self.parse(start_markup, end_markup, string_scanner, cache = nil)
start_obj = Expression.parse(start_markup) start_obj = Expression.parse(start_markup, string_scanner, cache)
end_obj = Expression.parse(end_markup) end_obj = Expression.parse(end_markup, string_scanner, cache)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj) new(start_obj, end_obj)
else else

View File

@ -29,6 +29,19 @@ module Liquid
) )
STRIP_HTML_TAGS = /<.*?>/m STRIP_HTML_TAGS = /<.*?>/m
class << self
def try_coerce_encoding(input, encoding:)
original_encoding = input.encoding
if input.encoding != encoding
input.force_encoding(encoding)
unless input.valid_encoding?
input.force_encoding(original_encoding)
end
end
input
end
end
# @liquid_public_docs # @liquid_public_docs
# @liquid_type filter # @liquid_type filter
# @liquid_category array # @liquid_category array
@ -51,7 +64,7 @@ module Liquid
# @liquid_syntax string | downcase # @liquid_syntax string | downcase
# @liquid_return [string] # @liquid_return [string]
def downcase(input) def downcase(input)
input.to_s.downcase Utils.to_s(input).downcase
end end
# @liquid_public_docs # @liquid_public_docs
@ -62,7 +75,7 @@ module Liquid
# @liquid_syntax string | upcase # @liquid_syntax string | upcase
# @liquid_return [string] # @liquid_return [string]
def upcase(input) def upcase(input)
input.to_s.upcase Utils.to_s(input).upcase
end end
# @liquid_public_docs # @liquid_public_docs
@ -73,7 +86,7 @@ module Liquid
# @liquid_syntax string | capitalize # @liquid_syntax string | capitalize
# @liquid_return [string] # @liquid_return [string]
def capitalize(input) def capitalize(input)
input.to_s.capitalize Utils.to_s(input).capitalize
end end
# @liquid_public_docs # @liquid_public_docs
@ -84,7 +97,7 @@ module Liquid
# @liquid_syntax string | escape # @liquid_syntax string | escape
# @liquid_return [string] # @liquid_return [string]
def escape(input) def escape(input)
CGI.escapeHTML(input.to_s) unless input.nil? CGI.escapeHTML(Utils.to_s(input)) unless input.nil?
end end
alias_method :h, :escape alias_method :h, :escape
@ -96,7 +109,7 @@ module Liquid
# @liquid_syntax string | escape_once # @liquid_syntax string | escape_once
# @liquid_return [string] # @liquid_return [string]
def escape_once(input) def escape_once(input)
input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
end end
# @liquid_public_docs # @liquid_public_docs
@ -111,7 +124,7 @@ module Liquid
# @liquid_syntax string | url_encode # @liquid_syntax string | url_encode
# @liquid_return [string] # @liquid_return [string]
def url_encode(input) def url_encode(input)
CGI.escape(input.to_s) unless input.nil? CGI.escape(Utils.to_s(input)) unless input.nil?
end end
# @liquid_public_docs # @liquid_public_docs
@ -125,7 +138,7 @@ module Liquid
def url_decode(input) def url_decode(input)
return if input.nil? return if input.nil?
result = CGI.unescape(input.to_s) result = CGI.unescape(Utils.to_s(input))
raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding? raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding?
result result
@ -139,7 +152,7 @@ module Liquid
# @liquid_syntax string | base64_encode # @liquid_syntax string | base64_encode
# @liquid_return [string] # @liquid_return [string]
def base64_encode(input) def base64_encode(input)
Base64.strict_encode64(input.to_s) Base64.strict_encode64(Utils.to_s(input))
end end
# @liquid_public_docs # @liquid_public_docs
@ -150,7 +163,8 @@ module Liquid
# @liquid_syntax string | base64_decode # @liquid_syntax string | base64_decode
# @liquid_return [string] # @liquid_return [string]
def base64_decode(input) def base64_decode(input)
Base64.strict_decode64(input.to_s) input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
rescue ::ArgumentError rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode" raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
end end
@ -163,7 +177,7 @@ module Liquid
# @liquid_syntax string | base64_url_safe_encode # @liquid_syntax string | base64_url_safe_encode
# @liquid_return [string] # @liquid_return [string]
def base64_url_safe_encode(input) def base64_url_safe_encode(input)
Base64.urlsafe_encode64(input.to_s) Base64.urlsafe_encode64(Utils.to_s(input))
end end
# @liquid_public_docs # @liquid_public_docs
@ -174,7 +188,8 @@ module Liquid
# @liquid_syntax string | base64_url_safe_decode # @liquid_syntax string | base64_url_safe_decode
# @liquid_return [string] # @liquid_return [string]
def base64_url_safe_decode(input) def base64_url_safe_decode(input)
Base64.urlsafe_decode64(input.to_s) input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
rescue ::ArgumentError rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode" raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
end end
@ -197,7 +212,7 @@ module Liquid
if input.is_a?(Array) if input.is_a?(Array)
input.slice(offset, length) || [] input.slice(offset, length) || []
else else
input.to_s.slice(offset, length) || '' Utils.to_s(input).slice(offset, length) || ''
end end
rescue RangeError rescue RangeError
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset) if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
@ -221,10 +236,10 @@ module Liquid
# @liquid_return [string] # @liquid_return [string]
def truncate(input, length = 50, truncate_string = "...") def truncate(input, length = 50, truncate_string = "...")
return if input.nil? return if input.nil?
input_str = input.to_s input_str = Utils.to_s(input)
length = Utils.to_integer(length) length = Utils.to_integer(length)
truncate_string_str = truncate_string.to_s truncate_string_str = Utils.to_s(truncate_string)
l = length - truncate_string_str.length l = length - truncate_string_str.length
l = 0 if l < 0 l = 0 if l < 0
@ -248,7 +263,7 @@ module Liquid
# @liquid_return [string] # @liquid_return [string]
def truncatewords(input, words = 15, truncate_string = "...") def truncatewords(input, words = 15, truncate_string = "...")
return if input.nil? return if input.nil?
input = input.to_s input = Utils.to_s(input)
words = Utils.to_integer(words) words = Utils.to_integer(words)
words = 1 if words <= 0 words = 1 if words <= 0
@ -262,7 +277,8 @@ module Liquid
return input if wordlist.length <= words return input if wordlist.length <= words
wordlist.pop wordlist.pop
wordlist.join(" ").concat(truncate_string.to_s) truncate_string = Utils.to_s(truncate_string)
wordlist.join(" ").concat(truncate_string)
end end
# @liquid_public_docs # @liquid_public_docs
@ -273,7 +289,9 @@ module Liquid
# @liquid_syntax string | split: string # @liquid_syntax string | split: string
# @liquid_return [array[string]] # @liquid_return [array[string]]
def split(input, pattern) def split(input, pattern)
input.to_s.split(pattern.to_s) pattern = Utils.to_s(pattern)
input = Utils.to_s(input)
input.split(pattern)
end end
# @liquid_public_docs # @liquid_public_docs
@ -284,7 +302,8 @@ module Liquid
# @liquid_syntax string | strip # @liquid_syntax string | strip
# @liquid_return [string] # @liquid_return [string]
def strip(input) def strip(input)
input.to_s.strip input = Utils.to_s(input)
input.strip
end end
# @liquid_public_docs # @liquid_public_docs
@ -295,7 +314,8 @@ module Liquid
# @liquid_syntax string | lstrip # @liquid_syntax string | lstrip
# @liquid_return [string] # @liquid_return [string]
def lstrip(input) def lstrip(input)
input.to_s.lstrip input = Utils.to_s(input)
input.lstrip
end end
# @liquid_public_docs # @liquid_public_docs
@ -306,7 +326,8 @@ module Liquid
# @liquid_syntax string | rstrip # @liquid_syntax string | rstrip
# @liquid_return [string] # @liquid_return [string]
def rstrip(input) def rstrip(input)
input.to_s.rstrip input = Utils.to_s(input)
input.rstrip
end end
# @liquid_public_docs # @liquid_public_docs
@ -317,8 +338,9 @@ module Liquid
# @liquid_syntax string | strip_html # @liquid_syntax string | strip_html
# @liquid_return [string] # @liquid_return [string]
def strip_html(input) def strip_html(input)
input = Utils.to_s(input)
empty = '' empty = ''
result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty) result = input.gsub(STRIP_HTML_BLOCKS, empty)
result.gsub!(STRIP_HTML_TAGS, empty) result.gsub!(STRIP_HTML_TAGS, empty)
result result
end end
@ -331,7 +353,8 @@ module Liquid
# @liquid_syntax string | strip_newlines # @liquid_syntax string | strip_newlines
# @liquid_return [string] # @liquid_return [string]
def strip_newlines(input) def strip_newlines(input)
input.to_s.gsub(/\r?\n/, '') input = Utils.to_s(input)
input.gsub(/\r?\n/, '')
end end
# @liquid_public_docs # @liquid_public_docs
@ -342,6 +365,7 @@ module Liquid
# @liquid_syntax array | join # @liquid_syntax array | join
# @liquid_return [string] # @liquid_return [string]
def join(input, glue = ' ') def join(input, glue = ' ')
glue = Utils.to_s(glue)
InputIterator.new(input, context).join(glue) InputIterator.new(input, context).join(glue)
end end
@ -409,29 +433,59 @@ module Liquid
# @liquid_syntax array | where: string, string # @liquid_syntax array | where: string, string
# @liquid_return [array[untyped]] # @liquid_return [array[untyped]]
def where(input, property, target_value = nil) def where(input, property, target_value = nil)
ary = InputIterator.new(input, context) filter_array(input, property, target_value) { |ary, &block| ary.select(&block) }
end
if ary.empty? # @liquid_public_docs
[] # @liquid_type filter
elsif target_value.nil? # @liquid_category array
ary.select do |item| # @liquid_summary
item[property] # Filters an array to exclude items with a specific property value.
rescue TypeError # @liquid_description
raise_property_error(property) # This requires you to provide both the property name and the associated value.
rescue NoMethodError # @liquid_syntax array | reject: string, string
return nil unless item.respond_to?(:[]) # @liquid_return [array[untyped]]
raise def reject(input, property, target_value = nil)
end filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) }
else end
ary.select do |item|
item[property] == target_value # @liquid_public_docs
rescue TypeError # @liquid_type filter
raise_property_error(property) # @liquid_category array
rescue NoMethodError # @liquid_summary
return nil unless item.respond_to?(:[]) # Tests if any item in an array has a specific property value.
raise # @liquid_description
end # This requires you to provide both the property name and the associated value.
end # @liquid_syntax array | has: string, string
# @liquid_return [boolean]
def has(input, property, target_value = nil)
filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) }
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Returns the first item in an array with a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | find: string, string
# @liquid_return [untyped]
def find(input, property, target_value = nil)
filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) }
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Returns the index of the first item in an array with a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | find_index: string, string
# @liquid_return [number]
def find_index(input, property, target_value = nil)
filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) }
end end
# @liquid_public_docs # @liquid_public_docs
@ -528,7 +582,10 @@ module Liquid
# @liquid_syntax string | replace: string, string # @liquid_syntax string | replace: string, string
# @liquid_return [string] # @liquid_return [string]
def replace(input, string, replacement = '') def replace(input, string, replacement = '')
input.to_s.gsub(string.to_s, replacement.to_s) string = Utils.to_s(string)
replacement = Utils.to_s(replacement)
input = Utils.to_s(input)
input.gsub(string, replacement)
end end
# @liquid_public_docs # @liquid_public_docs
@ -539,7 +596,10 @@ module Liquid
# @liquid_syntax string | replace_first: string, string # @liquid_syntax string | replace_first: string, string
# @liquid_return [string] # @liquid_return [string]
def replace_first(input, string, replacement = '') def replace_first(input, string, replacement = '')
input.to_s.sub(string.to_s, replacement.to_s) string = Utils.to_s(string)
replacement = Utils.to_s(replacement)
input = Utils.to_s(input)
input.sub(string, replacement)
end end
# @liquid_public_docs # @liquid_public_docs
@ -550,9 +610,9 @@ module Liquid
# @liquid_syntax string | replace_last: string, string # @liquid_syntax string | replace_last: string, string
# @liquid_return [string] # @liquid_return [string]
def replace_last(input, string, replacement) def replace_last(input, string, replacement)
input = input.to_s input = Utils.to_s(input)
string = string.to_s string = Utils.to_s(string)
replacement = replacement.to_s replacement = Utils.to_s(replacement)
start_index = input.rindex(string) start_index = input.rindex(string)
@ -604,7 +664,9 @@ module Liquid
# @liquid_syntax string | append: string # @liquid_syntax string | append: string
# @liquid_return [string] # @liquid_return [string]
def append(input, string) def append(input, string)
input.to_s + string.to_s input = Utils.to_s(input)
string = Utils.to_s(string)
input + string
end end
# @liquid_public_docs # @liquid_public_docs
@ -633,7 +695,9 @@ module Liquid
# @liquid_syntax string | prepend: string # @liquid_syntax string | prepend: string
# @liquid_return [string] # @liquid_return [string]
def prepend(input, string) def prepend(input, string)
string.to_s + input.to_s input = Utils.to_s(input)
string = Utils.to_s(string)
string + input
end end
# @liquid_public_docs # @liquid_public_docs
@ -644,7 +708,8 @@ module Liquid
# @liquid_syntax string | newline_to_br # @liquid_syntax string | newline_to_br
# @liquid_return [string] # @liquid_return [string]
def newline_to_br(input) def newline_to_br(input)
input.to_s.gsub(/\r?\n/, "<br />\n") input = Utils.to_s(input)
input.gsub(/\r?\n/, "<br />\n")
end end
# Reformat a date using Ruby's core Time#strftime( string ) -> string # Reformat a date using Ruby's core Time#strftime( string ) -> string
@ -679,11 +744,12 @@ module Liquid
# #
# See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime # See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
def date(input, format) def date(input, format)
return input if format.to_s.empty? str_format = Utils.to_s(format)
return input if str_format.empty?
return input unless (date = Utils.to_date(input)) return input unless (date = Utils.to_date(input))
date.strftime(format.to_s) date.strftime(str_format)
end end
# @liquid_public_docs # @liquid_public_docs
@ -862,7 +928,7 @@ module Liquid
# - [`nil`](/docs/api/liquid/basics#nil) # - [`nil`](/docs/api/liquid/basics#nil)
# @liquid_syntax variable | default: variable # @liquid_syntax variable | default: variable
# @liquid_return [untyped] # @liquid_return [untyped]
# @liquid_optional_param allow_false [boolean] Whether to use false values instead of the default. # @liquid_optional_param allow_false: [boolean] Whether to use false values instead of the default.
def default(input, default_value = '', options = {}) def default(input, default_value = '', options = {})
options = {} unless options.is_a?(Hash) options = {} unless options.is_a?(Hash)
false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input) false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input)
@ -892,15 +958,36 @@ module Liquid
raise_property_error(property) raise_property_error(property)
end end
InputIterator.new(values_for_sum, context).sum do |item| result = InputIterator.new(values_for_sum, context).sum do |item|
Utils.to_number(item) Utils.to_number(item)
end end
result.is_a?(BigDecimal) ? result.to_f : result
end end
private private
attr_reader :context attr_reader :context
def filter_array(input, property, target_value, default_value = [], &block)
ary = InputIterator.new(input, context)
return default_value if ary.empty?
block.call(ary) do |item|
if target_value.nil?
item[property]
else
item[property] == target_value
end
rescue TypeError
raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end
end
def raise_property_error(property) def raise_property_error(property)
raise Liquid::ArgumentError, "cannot select the property '#{property}'" raise Liquid::ArgumentError, "cannot select the property '#{property}'"
end end
@ -927,6 +1014,8 @@ module Liquid
def nil_safe_casecmp(a, b) def nil_safe_casecmp(a, b)
if !a.nil? && !b.nil? if !a.nil? && !b.nil?
a.to_s.casecmp(b.to_s) a.to_s.casecmp(b.to_s)
elsif a.nil? && b.nil?
0
else else
a.nil? ? 1 : -1 a.nil? ? 1 : -1
end end
@ -949,7 +1038,18 @@ module Liquid
end end
def join(glue) def join(glue)
to_a.join(glue.to_s) first = true
output = +""
each do |item|
if first
first = false
else
output << glue
end
output << Liquid::Utils.to_s(item)
end
output
end end
def concat(args) def concat(args)
@ -982,6 +1082,4 @@ module Liquid
end end
end end
end end
Template.register_filter(StandardFilters)
end end

View File

@ -1,41 +0,0 @@
# frozen_string_literal: true
module Liquid
# StrainerFactory is the factory for the filters system.
module StrainerFactory
extend self
def add_global_filter(filter)
strainer_class_cache.clear
GlobalCache.add_filter(filter)
end
def create(context, filters = [])
strainer_from_cache(filters).new(context)
end
def global_filter_names
GlobalCache.filter_method_names
end
GlobalCache = Class.new(StrainerTemplate)
private
def strainer_from_cache(filters)
if filters.empty?
GlobalCache
else
strainer_class_cache[filters] ||= begin
klass = Class.new(GlobalCache)
filters.each { |f| klass.add_filter(f) }
klass
end
end
end
def strainer_class_cache
@strainer_class_cache ||= {}
end
end
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'liquid/tag/disabler'
require 'liquid/tag/disableable'
module Liquid module Liquid
class Tag class Tag
attr_reader :nodelist, :tag_name, :line_number, :parse_context attr_reader :nodelist, :tag_name, :line_number, :parse_context
@ -54,7 +57,8 @@ module Liquid
# of the `render_to_output_buffer` method will become the default and the `render` # of the `render_to_output_buffer` method will become the default and the `render`
# method will be removed. # method will be removed.
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
output << render(context) render_result = render(context)
output << render_result if render_result
output output
end end

47
lib/liquid/tags.rb Normal file
View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
require_relative "tags/table_row"
require_relative "tags/echo"
require_relative "tags/if"
require_relative "tags/break"
require_relative "tags/inline_comment"
require_relative "tags/for"
require_relative "tags/assign"
require_relative "tags/ifchanged"
require_relative "tags/case"
require_relative "tags/include"
require_relative "tags/continue"
require_relative "tags/capture"
require_relative "tags/decrement"
require_relative "tags/unless"
require_relative "tags/increment"
require_relative "tags/comment"
require_relative "tags/raw"
require_relative "tags/render"
require_relative "tags/cycle"
module Liquid
module Tags
STANDARD_TAGS = {
'cycle' => Cycle,
'render' => Render,
'raw' => Raw,
'comment' => Comment,
'increment' => Increment,
'unless' => Unless,
'decrement' => Decrement,
'capture' => Capture,
'continue' => Continue,
'include' => Include,
'case' => Case,
'ifchanged' => Ifchanged,
'assign' => Assign,
'for' => For,
'#' => InlineComment,
'break' => Break,
'if' => If,
'echo' => Echo,
'tablerow' => TableRow,
}.freeze
end
end

View File

@ -72,6 +72,4 @@ module Liquid
end end
end end
end end
Template.register_tag('assign', Assign)
end end

View File

@ -26,6 +26,4 @@ module Liquid
output output
end end
end end
Template.register_tag('break', Break)
end end

View File

@ -39,6 +39,4 @@ module Liquid
true true
end end
end end
Template.register_tag('capture', Capture)
end end

View File

@ -123,6 +123,4 @@ module Liquid
end end
end end
end end
Template.register_tag('case', Case)
end end

View File

@ -25,7 +25,64 @@ module Liquid
def blank? def blank?
true true
end end
end
Template.register_tag('comment', Comment) private
def parse_body(body, tokenizer)
if parse_context.depth >= MAX_DEPTH
raise StackLevelError, "Nesting too deep"
end
parse_context.depth += 1
comment_tag_depth = 1
begin
# Consume tokens without creating child nodes.
# The children tag doesn't require to be a valid Liquid except the comment and raw tag.
# The child comment and raw tag must be closed.
while (token = tokenizer.send(:shift))
tag_name = if tokenizer.for_liquid_tag
next if token.empty? || token.match?(BlockBody::WhitespaceOrNothing)
tag_name_match = BlockBody::LiquidTagToken.match(token)
next if tag_name_match.nil?
tag_name_match[1]
else
token =~ BlockBody::FullToken
Regexp.last_match(2)
end
case tag_name
when "raw"
parse_raw_tag_body(tokenizer)
when "comment"
comment_tag_depth += 1
when "endcomment"
comment_tag_depth -= 1
end
if comment_tag_depth.zero?
parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag
return false
end
end
raise_tag_never_closed(block_name)
ensure
parse_context.depth -= 1
end
false
end
def parse_raw_tag_body(tokenizer)
while (token = tokenizer.send(:shift))
return if token =~ BlockBody::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2)
end
raise_tag_never_closed("raw")
end
end
end end

View File

@ -17,6 +17,4 @@ module Liquid
output output
end end
end end
Template.register_tag('continue', Continue)
end end

View File

@ -26,14 +26,20 @@ module Liquid
when NamedSyntax when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2)) @variables = variables_from_string(Regexp.last_match(2))
@name = parse_expression(Regexp.last_match(1)) @name = parse_expression(Regexp.last_match(1))
@is_named = true
when SimpleSyntax when SimpleSyntax
@variables = variables_from_string(markup) @variables = variables_from_string(markup)
@name = @variables.to_s @name = @variables.to_s
@is_named = !@name.match?(/\w+:0x\h{8}/)
else else
raise SyntaxError, options[:locale].t("errors.syntax.cycle") raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end end
end end
def named?
@is_named
end
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
context.registers[:cycle] ||= {} context.registers[:cycle] ||= {}
@ -62,7 +68,13 @@ module Liquid
def variables_from_string(markup) def variables_from_string(markup)
markup.split(',').collect do |var| markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o var =~ /\s*(#{QuotedFragment})\s*/o
Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil next unless Regexp.last_match(1)
# Expression Parser returns cached objects, and we need to dup them to
# start the cycle over for each new cycle call.
# Liquid-C does not have a cache, so we don't need to dup the object.
var = parse_expression(Regexp.last_match(1))
var.is_a?(VariableLookup) ? var.dup : var
end.compact end.compact
end end
@ -72,6 +84,4 @@ module Liquid
end end
end end
end end
Template.register_tag('cycle', Cycle)
end end

View File

@ -35,6 +35,4 @@ module Liquid
output output
end end
end end
Template.register_tag('decrement', Decrement)
end end

View File

@ -36,6 +36,4 @@ module Liquid
end end
end end
end end
Template.register_tag('echo', Echo)
end end

View File

@ -88,7 +88,7 @@ module Liquid
end end
def strict_parse(markup) def strict_parse(markup)
p = Parser.new(markup) p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id) @variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in') raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
@ -201,6 +201,4 @@ module Liquid
end end
end end
end end
Template.register_tag('for', For)
end end

View File

@ -102,7 +102,7 @@ module Liquid
end end
def strict_parse(markup) def strict_parse(markup)
p = Parser.new(markup) p = @parse_context.new_parser(markup)
condition = parse_binary_comparisons(p) condition = parse_binary_comparisons(p)
p.consume(:end_of_string) p.consume(:end_of_string)
condition condition
@ -111,7 +111,7 @@ module Liquid
def parse_binary_comparisons(p) def parse_binary_comparisons(p)
condition = parse_comparison(p) condition = parse_comparison(p)
first_condition = condition first_condition = condition
while (op = (p.id?('and') || p.id?('or'))) while (op = p.id?('and') || p.id?('or'))
child_condition = parse_comparison(p) child_condition = parse_comparison(p)
condition.send(op, child_condition) condition.send(op, child_condition)
condition = child_condition condition = child_condition
@ -135,6 +135,4 @@ module Liquid
end end
end end
end end
Template.register_tag('if', If)
end end

View File

@ -14,6 +14,4 @@ module Liquid
output output
end end
end end
Template.register_tag('ifchanged', Ifchanged)
end end

View File

@ -110,6 +110,4 @@ module Liquid
end end
end end
end end
Template.register_tag('include', Include)
end end

View File

@ -35,6 +35,4 @@ module Liquid
output output
end end
end end
Template.register_tag('increment', Increment)
end end

View File

@ -25,6 +25,4 @@ module Liquid
true true
end end
end end
Template.register_tag('#', InlineComment)
end end

View File

@ -14,7 +14,6 @@ module Liquid
# @liquid_syntax_keyword expression The expression to be output without being rendered. # @liquid_syntax_keyword expression The expression to be output without being rendered.
class Raw < Block class Raw < Block
Syntax = /\A\s*\z/ Syntax = /\A\s*\z/
FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om
def initialize(tag_name, markup, parse_context) def initialize(tag_name, markup, parse_context)
super super
@ -25,7 +24,7 @@ module Liquid
def parse(tokens) def parse(tokens)
@body = +'' @body = +''
while (token = tokens.shift) while (token = tokens.shift)
if token =~ FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2) if token =~ BlockBody::FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
parse_context.trim_whitespace = (token[-3] == WhitespaceControl) parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
@body << Regexp.last_match(1) if Regexp.last_match(1) != "" @body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return return
@ -57,6 +56,4 @@ module Liquid
end end
end end
end end
Template.register_tag('raw', Raw)
end end

View File

@ -108,6 +108,4 @@ module Liquid
end end
end end
end end
Template.register_tag('render', Render)
end end

View File

@ -65,6 +65,12 @@ module Liquid
super super
output << '</td>' output << '</td>'
# Handle any interrupts if they exist.
if context.interrupt?
interrupt = context.pop_interrupt
break if interrupt.is_a?(BreakInterrupt)
end
if tablerowloop.col_last && !tablerowloop.last if tablerowloop.col_last && !tablerowloop.last
output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">" output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">"
end end
@ -91,6 +97,4 @@ module Liquid
raise Liquid::ArgumentError, "invalid integer" raise Liquid::ArgumentError, "invalid integer"
end end
end end
Template.register_tag('tablerow', TableRow)
end end

View File

@ -44,6 +44,4 @@ module Liquid
output output
end end
end end
Template.register_tag('unless', Unless)
end end

View File

@ -18,42 +18,6 @@ module Liquid
attr_accessor :root, :name attr_accessor :root, :name
attr_reader :resource_limits, :warnings attr_reader :resource_limits, :warnings
class TagRegistry
include Enumerable
def initialize
@tags = {}
@cache = {}
end
def [](tag_name)
return nil unless @tags.key?(tag_name)
return @cache[tag_name] if Liquid.cache_classes
lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o }
end
def []=(tag_name, klass)
@tags[tag_name] = klass.name
@cache[tag_name] = klass
end
def delete(tag_name)
@tags.delete(tag_name)
@cache.delete(tag_name)
end
def each(&block)
@tags.each(&block)
end
private
def lookup_class(name)
Object.const_get(name)
end
end
attr_reader :profiler attr_reader :profiler
class << self class << self
@ -61,52 +25,83 @@ module Liquid
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
# :warn is the default and will give deprecation warnings when invalid syntax is used. # :warn is the default and will give deprecation warnings when invalid syntax is used.
# :strict will enforce correct syntax. # :strict will enforce correct syntax.
attr_accessor :error_mode def error_mode=(mode)
Template.error_mode = :lax Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
attr_accessor :default_exception_renderer
Template.default_exception_renderer = lambda do |exception|
exception
end end
attr_accessor :file_system def error_mode
Template.file_system = BlankFileSystem.new Environment.default.error_mode
end
attr_accessor :tags def default_exception_renderer=(renderer)
Template.tags = TagRegistry.new Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=")
private :tags= Environment.default.exception_renderer = renderer
end
def default_exception_renderer
Environment.default.exception_renderer
end
def file_system=(file_system)
Deprecations.warn("Template.file_system=", "Environment#file_system=")
Environment.default.file_system = file_system
end
def file_system
Environment.default.file_system
end
def tags
Environment.default.tags
end
def register_tag(name, klass) def register_tag(name, klass)
tags[name.to_s] = klass Deprecations.warn("Template.register_tag", "Environment#register_tag")
Environment.default.register_tag(name, klass)
end end
# Pass a module with filter methods which should be available # Pass a module with filter methods which should be available
# to all liquid views. Good for registering the standard library # to all liquid views. Good for registering the standard library
def register_filter(mod) def register_filter(mod)
StrainerFactory.add_global_filter(mod) Deprecations.warn("Template.register_filter", "Environment#register_filter")
Environment.default.register_filter(mod)
end end
attr_accessor :default_resource_limits private def default_resource_limits=(limits)
Template.default_resource_limits = {} Deprecations.warn("Template.default_resource_limits=", "Environment#default_resource_limits=")
private :default_resource_limits= Environment.default.default_resource_limits = limits
end
def default_resource_limits
Environment.default.default_resource_limits
end
# creates a new <tt>Template</tt> object from liquid source code # creates a new <tt>Template</tt> object from liquid source code
# To enable profiling, pass in <tt>profile: true</tt> as an option. # To enable profiling, pass in <tt>profile: true</tt> as an option.
# See Liquid::Profiler for more information # See Liquid::Profiler for more information
def parse(source, options = {}) def parse(source, options = {})
new.parse(source, options) environment = options[:environment] || Environment.default
new(environment: environment).parse(source, options)
end end
end end
def initialize def initialize(environment: Environment.default)
@environment = environment
@rethrow_errors = false @rethrow_errors = false
@resource_limits = ResourceLimits.new(Template.default_resource_limits) @resource_limits = ResourceLimits.new(environment.default_resource_limits)
end end
# Parse source code. # Parse source code.
# Returns self for easy chaining # Returns self for easy chaining
def parse(source, options = {}) def parse(source, options = {})
parse_context = configure_options(options) parse_context = configure_options(options)
source = source.to_s.to_str
unless source.valid_encoding?
raise TemplateEncodingError, parse_context.locale.t("errors.syntax.invalid_template_encoding")
end
tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1) tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
@root = Document.parse(tokenizer, parse_context) @root = Document.parse(tokenizer, parse_context)
self self
@ -156,11 +151,11 @@ module Liquid
c c
when Liquid::Drop when Liquid::Drop
drop = args.shift drop = args.shift
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits) drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when Hash when Hash
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits) Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when nil when nil
Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits) Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
else else
raise ArgumentError, "Expected Hash or Liquid::Context as parameter" raise ArgumentError, "Expected Hash or Liquid::Context as parameter"
end end
@ -220,8 +215,14 @@ module Liquid
@options = options @options = options
@profiling = profiling @profiling = profiling
@line_numbers = options[:line_numbers] || @profiling @line_numbers = options[:line_numbers] || @profiling
parse_context = options.is_a?(ParseContext) ? options : ParseContext.new(options) parse_context = if options.is_a?(ParseContext)
@warnings = parse_context.warnings options
else
opts = options.key?(:environment) ? options : options.merge(environment: @environment)
ParseContext.new(opts)
end
@warnings = parse_context.warnings
parse_context parse_context
end end

View File

@ -1,20 +1,43 @@
# frozen_string_literal: true # frozen_string_literal: true
require "strscan"
module Liquid module Liquid
class Tokenizer class Tokenizer
attr_reader :line_number, :for_liquid_tag attr_reader :line_number, :for_liquid_tag
def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) TAG_END = /%\}/
@source = source.to_s.to_str TAG_OR_VARIABLE_START = /\{[\{\%]/
@line_number = line_number || (line_numbers ? 1 : nil) NEWLINE = /\n/
OPEN_CURLEY = "{".ord
CLOSE_CURLEY = "}".ord
PERCENTAGE = "%".ord
def initialize(
source:,
string_scanner:,
line_numbers: false,
line_number: nil,
for_liquid_tag: false
)
@line_number = line_number || (line_numbers ? 1 : nil)
@for_liquid_tag = for_liquid_tag @for_liquid_tag = for_liquid_tag
@offset = 0 @source = source.to_s.to_str
@tokens = tokenize @offset = 0
@tokens = []
if @source
@ss = string_scanner
@ss.string = @source
tokenize
end
end end
def shift def shift
token = @tokens[@offset] token = @tokens[@offset]
return nil unless token
return unless token
@offset += 1 @offset += 1
@ -28,18 +51,111 @@ module Liquid
private private
def tokenize def tokenize
return [] if @source.empty? if @for_liquid_tag
@tokens = @source.split("\n")
return @source.split("\n") if @for_liquid_tag else
@tokens << shift_normal until @ss.eos?
tokens = @source.split(TemplateParser)
# removes the rogue empty element at the beginning of the array
if tokens[0]&.empty?
@offset += 1
end end
tokens @source = nil
@ss = nil
end
def shift_normal
token = next_token
return unless token
token
end
def next_token
# possible states: :text, :tag, :variable
byte_a = @ss.peek_byte
if byte_a == OPEN_CURLEY
@ss.scan_byte
byte_b = @ss.peek_byte
if byte_b == PERCENTAGE
@ss.scan_byte
return next_tag_token
elsif byte_b == OPEN_CURLEY
@ss.scan_byte
return next_variable_token
end
@ss.pos -= 1
end
next_text_token
end
def next_text_token
start = @ss.pos
unless @ss.skip_until(TAG_OR_VARIABLE_START)
token = @ss.rest
@ss.terminate
return token
end
pos = @ss.pos -= 2
@source.byteslice(start, pos - start)
rescue ::ArgumentError => e
if e.message == "invalid byte sequence in #{@ss.string.encoding}"
raise SyntaxError, "Invalid byte sequence in #{@ss.string.encoding}"
else
raise
end
end
def next_variable_token
start = @ss.pos - 2
byte_a = byte_b = @ss.scan_byte
while byte_b
byte_a = @ss.scan_byte while byte_a && (byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY)
break unless byte_a
if @ss.eos?
return byte_a == CLOSE_CURLEY ? @source.byteslice(start, @ss.pos - start) : "{{"
end
byte_b = @ss.scan_byte
if byte_a == CLOSE_CURLEY
if byte_b == CLOSE_CURLEY
return @source.byteslice(start, @ss.pos - start)
elsif byte_b != CLOSE_CURLEY
@ss.pos -= 1
return @source.byteslice(start, @ss.pos - start)
end
elsif byte_a == OPEN_CURLEY && byte_b == PERCENTAGE
return next_tag_token_with_start(start)
end
byte_a = byte_b
end
"{{"
end
def next_tag_token
start = @ss.pos - 2
if (len = @ss.skip_until(TAG_END))
@source.byteslice(start, len + 2)
else
"{%"
end
end
def next_tag_token_with_start(start)
@ss.skip_until(TAG_END)
@source.byteslice(start, @ss.pos - start)
end end
end end
end end

View File

@ -89,5 +89,101 @@ module Liquid
# Otherwise return the object itself # Otherwise return the object itself
obj obj
end end
def self.to_s(obj, seen = {})
case obj
when Hash
# If the custom hash implementation overrides `#to_s`, use their
# custom implementation. Otherwise we use Liquid's default
# implementation.
if obj.class.instance_method(:to_s) == HASH_TO_S_METHOD
hash_inspect(obj, seen)
else
obj.to_s
end
when Array
array_inspect(obj, seen)
else
obj.to_s
end
end
def self.inspect(obj, seen = {})
case obj
when Hash
# If the custom hash implementation overrides `#inspect`, use their
# custom implementation. Otherwise we use Liquid's default
# implementation.
if obj.class.instance_method(:inspect) == HASH_INSPECT_METHOD
hash_inspect(obj, seen)
else
obj.inspect
end
when Array
array_inspect(obj, seen)
else
obj.inspect
end
end
def self.array_inspect(arr, seen = {})
if seen[arr.object_id]
return "[...]"
end
seen[arr.object_id] = true
str = +"["
cursor = 0
len = arr.length
while cursor < len
if cursor > 0
str << ", "
end
item_str = inspect(arr[cursor], seen)
str << item_str
cursor += 1
end
str << "]"
str
ensure
seen.delete(arr.object_id)
end
def self.hash_inspect(hash, seen = {})
if seen[hash.object_id]
return "{...}"
end
seen[hash.object_id] = true
str = +"{"
first = true
hash.each do |key, value|
if first
first = false
else
str << ", "
end
key_str = inspect(key, seen)
str << key_str
str << "=>"
value_str = inspect(value, seen)
str << value_str
end
str << "}"
str
ensure
seen.delete(hash.object_id)
end
HASH_TO_S_METHOD = Hash.instance_method(:to_s)
private_constant :HASH_TO_S_METHOD
HASH_INSPECT_METHOD = Hash.instance_method(:inspect)
private_constant :HASH_INSPECT_METHOD
end end
end end

View File

@ -61,14 +61,14 @@ module Liquid
def strict_parse(markup) def strict_parse(markup)
@filters = [] @filters = []
p = Parser.new(markup) p = @parse_context.new_parser(markup)
return if p.look(:end_of_string) return if p.look(:end_of_string)
@name = parse_context.parse_expression(p.expression) @name = parse_context.parse_expression(p.expression)
while p.consume?(:pipe) while p.consume?(:pipe)
filtername = p.consume(:id) filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : [] filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << parse_filter_expressions(filtername, filterargs) @filters << parse_filter_expressions(filtername, filterargs)
end end
p.consume(:end_of_string) p.consume(:end_of_string)
@ -95,17 +95,23 @@ module Liquid
def render_to_output_buffer(context, output) def render_to_output_buffer(context, output)
obj = render(context) obj = render(context)
render_obj_to_output(obj, output)
if obj.is_a?(Array)
output << obj.join
elsif obj.nil?
else
output << obj.to_s
end
output output
end end
def render_obj_to_output(obj, output)
case obj
when NilClass
# Do nothing
when Array
obj.each do |o|
render_obj_to_output(o, output)
end
else
output << Liquid::Utils.to_s(obj)
end
end
def disabled?(_context) def disabled?(_context)
false false
end end

View File

@ -6,16 +6,20 @@ module Liquid
attr_reader :name, :lookups attr_reader :name, :lookups
def self.parse(markup) def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
new(markup) new(markup, string_scanner, cache)
end end
def initialize(markup) def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
lookups = markup.scan(VariableParser) lookups = markup.scan(VariableParser)
name = lookups.shift name = lookups.shift
if name&.start_with?('[') && name&.end_with?(']') if name&.start_with?('[') && name&.end_with?(']')
name = Expression.parse(name[1..-2]) name = Expression.parse(
name[1..-2],
string_scanner,
cache,
)
end end
@name = name @name = name
@ -25,7 +29,11 @@ module Liquid
@lookups.each_index do |i| @lookups.each_index do |i|
lookup = lookups[i] lookup = lookups[i]
if lookup&.start_with?('[') && lookup&.end_with?(']') if lookup&.start_with?('[') && lookup&.end_with?(']')
lookups[i] = Expression.parse(lookup[1..-2]) lookups[i] = Expression.parse(
lookup[1..-2],
string_scanner,
cache,
)
elsif COMMAND_METHODS.include?(lookup) elsif COMMAND_METHODS.include?(lookup)
@command_flags |= 1 << i @command_flags |= 1 << i
end end

View File

@ -2,5 +2,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Liquid module Liquid
VERSION = "5.4.0" VERSION = "5.7.3"
end end

View File

@ -13,11 +13,11 @@ Gem::Specification.new do |s|
s.summary = "A secure, non-evaling end user template engine with aesthetic markup." s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
s.authors = ["Tobias Lütke"] s.authors = ["Tobias Lütke"]
s.email = ["tobi@leetsoft.com"] s.email = ["tobi@leetsoft.com"]
s.homepage = "http://www.liquidmarkup.org" s.homepage = "https://shopify.github.io/liquid/"
s.license = "MIT" s.license = "MIT"
# s.description = "A secure, non-evaling end user template engine with aesthetic markup." # s.description = "A secure, non-evaling end user template engine with aesthetic markup."
s.required_ruby_version = ">= 2.7.0" s.required_ruby_version = ">= 3.0.0"
s.required_rubygems_version = ">= 1.3.7" s.required_rubygems_version = ">= 1.3.7"
s.metadata['allowed_push_host'] = 'https://rubygems.org' s.metadata['allowed_push_host'] = 'https://rubygems.org'
@ -28,6 +28,9 @@ Gem::Specification.new do |s|
s.require_path = "lib" s.require_path = "lib"
s.add_dependency("strscan", ">= 3.1.1")
s.add_dependency("bigdecimal")
s.add_development_dependency('rake', '~> 13.0') s.add_development_dependency('rake', '~> 13.0')
s.add_development_dependency('minitest') s.add_development_dependency('minitest')
end end

View File

@ -3,18 +3,23 @@
require 'benchmark/ips' require 'benchmark/ips'
require_relative 'theme_runner' require_relative 'theme_runner'
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new profiler = ThemeRunner.new
Benchmark.ips do |x| Benchmark.ips do |x|
x.time = 10 x.time = 20
x.warmup = 5 x.warmup = 10
puts puts
puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)." puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)."
puts puts
x.report("parse:") { profiler.compile } phase = ENV["PHASE"] || "all"
x.report("render:") { profiler.render }
x.report("parse & render:") { profiler.run } x.report("tokenize:") { profiler.tokenize } if phase == "all" || phase == "tokenize"
x.report("parse:") { profiler.compile } if phase == "all" || phase == "parse"
x.report("render:") { profiler.render } if phase == "all" || phase == "render"
x.report("parse & render:") { profiler.run } if phase == "all" || phase == "run"
end end

View File

@ -11,11 +11,12 @@ require_relative 'shop_filter'
require_relative 'tag_filter' require_relative 'tag_filter'
require_relative 'weight_filter' require_relative 'weight_filter'
Liquid::Template.register_tag('paginate', Paginate) default_environment = Liquid::Environment.default
Liquid::Template.register_tag('form', CommentForm) default_environment.register_tag('paginate', Paginate)
default_environment.register_tag('form', CommentForm)
Liquid::Template.register_filter(JsonFilter) default_environment.register_filter(JsonFilter)
Liquid::Template.register_filter(MoneyFilter) default_environment.register_filter(MoneyFilter)
Liquid::Template.register_filter(WeightFilter) default_environment.register_filter(WeightFilter)
Liquid::Template.register_filter(ShopFilter) default_environment.register_filter(ShopFilter)
Liquid::Template.register_filter(TagFilter) default_environment.register_filter(TagFilter)

View File

@ -48,6 +48,19 @@ class ThemeRunner
end end
end end
# `tokenize` will just test the tokenizen portion of liquid without any templates
def tokenize
ss = StringScanner.new("")
@tests.each do |test_hash|
tokenizer = Liquid::Tokenizer.new(
source: test_hash[:liquid],
string_scanner: ss,
line_numbers: true,
)
while tokenizer.shift; end
end
end
# `run` is called to benchmark rendering and compiling at the same time # `run` is called to benchmark rendering and compiling at the same time
def run def run
each_test do |liquid, layout, assigns, page_template, template_name| each_test do |liquid, layout, assigns, page_template, template_name|

View File

@ -0,0 +1,94 @@
# frozen_string_literal: true
require "benchmark/ips"
# benchmark liquid lexing
require 'liquid'
RubyVM::YJIT.enable
STRING_MARKUPS = [
"\"foo\"",
"\"fooooooooooo\"",
"\"foooooooooooooooooooooooooooooo\"",
"'foo'",
"'fooooooooooo'",
"'foooooooooooooooooooooooooooooo'",
]
VARIABLE_MARKUPS = [
"article",
"article.title",
"article.title.size",
"very_long_variable_name_2024_11_05",
"very_long_variable_name_2024_11_05.size",
]
NUMBER_MARKUPS = [
"0",
"35",
"1241891024912849",
"3.5",
"3.51214128409128",
"12381902839.123819283910283",
"123.456.789",
"-123",
"-12.33",
"-405.231",
"-0",
"0",
"0.0",
"0.0000000000000000000000",
"0.00000000001",
]
RANGE_MARKUPS = [
"(1..30)",
"(1...30)",
"(1..30..5)",
"(1.0...30.0)",
"(1.........30)",
"(1..foo)",
"(foo..30)",
"(foo..bar)",
"(foo...bar...100)",
"(foo...bar...100.0)",
]
LITERAL_MARKUPS = [
nil,
'nil',
'null',
'',
'true',
'false',
'blank',
'empty',
]
MARKUPS = {
"string" => STRING_MARKUPS,
"literal" => LITERAL_MARKUPS,
"variable" => VARIABLE_MARKUPS,
"number" => NUMBER_MARKUPS,
"range" => RANGE_MARKUPS,
}
Benchmark.ips do |x|
x.config(time: 5, warmup: 5)
MARKUPS.each do |type, markups|
x.report("Liquid::Expression#parse: #{type}") do
markups.each do |markup|
Liquid::Expression.parse(markup)
end
end
end
x.report("Liquid::Expression#parse: all") do
MARKUPS.values.flatten.each do |markup|
Liquid::Expression.parse(markup)
end
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require "benchmark/ips"
# benchmark liquid lexing
require 'liquid'
RubyVM::YJIT.enable
EXPRESSIONS = [
"foo[1..2].baz",
"12.0",
"foo.bar.based",
"21 - 62",
"foo.bar.baz",
"foo > 12",
"foo < 12",
"foo <= 12",
"foo >= 12",
"foo <> 12",
"foo == 12",
"foo != 12",
"foo contains 12",
"foo contains 'bar'",
"foo != 'bar'",
"'foo' contains 'bar'",
'234089',
"foo | default: -1",
]
Benchmark.ips do |x|
x.config(time: 10, warmup: 5)
x.report("Liquid::Lexer#tokenize") do
EXPRESSIONS.each do |expr|
l = Liquid::Lexer.new(expr)
l.tokenize
end
end
x.compare!
end

View File

@ -36,6 +36,24 @@ class Category
end end
end end
class ProductsDrop < Liquid::Drop
def initialize(products)
@products = products
end
def size
@products.size
end
def to_liquid
if @context["forloop"]
@products.first(@context["forloop"].length)
else
@products
end
end
end
class CategoryDrop < Liquid::Drop class CategoryDrop < Liquid::Drop
attr_accessor :category, :context attr_accessor :category, :context
@ -635,6 +653,40 @@ class ContextTest < Minitest::Test
assert_equal(:my_value, c.registers[:my_register]) assert_equal(:my_value, c.registers[:my_register])
end end
def test_variable_to_liquid_returns_contextual_drop
context = {
"products" => ProductsDrop.new(["A", "B", "C", "D", "E"]),
}
template = Liquid::Template.parse(<<~LIQUID)
{%- for i in (1..3) -%}
for_loop_products_count: {{ products | size }}
{% endfor %}
unscoped_products_count: {{ products | size }}
LIQUID
result = template.render(context)
assert_includes(result, "for_loop_products_count: 3")
assert_includes(result, "unscoped_products_count: 5")
end
def test_new_isolated_context_inherits_parent_environment
global_environment = Liquid::Environment.build(tags: {})
context = Context.build(environment: global_environment)
subcontext = context.new_isolated_subcontext
assert_equal(global_environment, subcontext.environment)
end
def test_newly_built_context_inherits_parent_environment
global_environment = Liquid::Environment.build(tags: {})
context = Context.build(environment: global_environment)
assert_equal(global_environment, context.environment)
assert(context.environment.tags.each.to_a.empty?)
end
private private
def assert_no_object_allocations def assert_no_object_allocations

View File

@ -203,20 +203,34 @@ class ErrorHandlingTest < Minitest::Test
end end
def test_setting_default_exception_renderer def test_setting_default_exception_renderer
old_exception_renderer = Liquid::Template.default_exception_renderer
exceptions = [] exceptions = []
Liquid::Template.default_exception_renderer = ->(e) { default_exception_renderer = ->(e) {
exceptions << e exceptions << e
'' ''
} }
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}')
env = Liquid::Environment.build(exception_renderer: default_exception_renderer)
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: env)
output = template.render('errors' => ErrorDrop.new) output = template.render('errors' => ErrorDrop.new)
assert_equal('This is a runtime error: ', output) assert_equal('This is a runtime error: ', output)
assert_equal([Liquid::ArgumentError], template.errors.map(&:class)) assert_equal([Liquid::ArgumentError], template.errors.map(&:class))
ensure end
Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer
def test_setting_exception_renderer_on_environment
exceptions = []
exception_renderer = ->(e) do
exceptions << e
''
end
environment = Liquid::Environment.build(exception_renderer: exception_renderer)
template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: environment)
output = template.render('errors' => ErrorDrop.new)
assert_equal('This is a runtime error: ', output)
assert_equal([Liquid::ArgumentError], template.errors.map(&:class))
end end
def test_exception_renderer_exposing_non_liquid_error def test_exception_renderer_exposing_non_liquid_error
@ -242,16 +256,10 @@ class ErrorHandlingTest < Minitest::Test
end end
def test_included_template_name_with_line_numbers def test_included_template_name_with_line_numbers
old_file_system = Liquid::Template.file_system environment = Liquid::Environment.build(file_system: TestFileSystem.new)
template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true, environment: environment)
page = template.render('errors' => ErrorDrop.new)
begin
Liquid::Template.file_system = TestFileSystem.new
template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true)
page = template.render('errors' => ErrorDrop.new)
ensure
Liquid::Template.file_system = old_file_system
end
assert_equal("Argument error:\nLiquid error (product line 1): argument error", page) assert_equal("Argument error:\nLiquid error (product line 1): argument error", page)
assert_equal("product", template.errors.first.template_name) assert_equal("product", template.errors.first.template_name)
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'test_helper' require 'test_helper'
require 'lru_redux'
class ExpressionTest < Minitest::Test class ExpressionTest < Minitest::Test
def test_keyword_literals def test_keyword_literals
@ -13,6 +14,7 @@ class ExpressionTest < Minitest::Test
assert_template_result("double quoted", '{{"double quoted"}}') assert_template_result("double quoted", '{{"double quoted"}}')
assert_template_result("spaced", "{{ 'spaced' }}") assert_template_result("spaced", "{{ 'spaced' }}")
assert_template_result("spaced2", "{{ 'spaced2' }}") assert_template_result("spaced2", "{{ 'spaced2' }}")
assert_template_result("emoji🔥", "{{ 'emoji🔥' }}")
end end
def test_int def test_int
@ -22,8 +24,18 @@ class ExpressionTest < Minitest::Test
end end
def test_float def test_float
assert_template_result("-17.42", "{{ -17.42 }}")
assert_template_result("2.5", "{{ 2.5 }}") assert_template_result("2.5", "{{ 2.5 }}")
assert_expression_result(0.0, "0.....5")
assert_expression_result(0.0, "-0..1")
assert_expression_result(1.5, "1.5") assert_expression_result(1.5, "1.5")
# this is a unfortunate quirky behavior of Liquid
result = Expression.parse(".5")
assert_kind_of(Liquid::VariableLookup, result)
result = Expression.parse("-.5")
assert_kind_of(Liquid::VariableLookup, result)
end end
def test_range def test_range
@ -40,6 +52,101 @@ class ExpressionTest < Minitest::Test
) )
end end
def test_quirky_negative_sign_expression_markup
result = Expression.parse("-", nil)
assert(result.is_a?(VariableLookup))
assert_equal("-", result.name)
# for this template, the expression markup is "-"
assert_template_result(
"",
"{{ - 'theme.css' - }}",
)
end
def test_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
cache = {}
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
def test_expression_cache_with_true_boolean
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
parse_context = ParseContext.new(expression_cache: true)
Liquid::Template.parse(template, parse_context).render
cache = parse_context.instance_variable_get(:@expression_cache)
assert_equal(
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
def test_expression_cache_with_lru_redux
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
cache = LruRedux::Cache.new(10)
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
def test_disable_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
template = <<~LIQUID
{% assign x = 1 %}
{{ x }}
{% assign x = 2 %}
{{ x }}
{% assign y = 1 %}
{{ y }}
LIQUID
parse_context = Liquid::ParseContext.new(expression_cache: false)
Liquid::Template.parse(template, parse_context).render
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
end
private private
def assert_expression_result(expect, markup, **assigns) def assert_expression_result(expect, markup, **assigns)

View File

@ -0,0 +1,106 @@
# frozen_string_literal: true
require 'test_helper'
class HashRenderingTest < Minitest::Test
def test_render_empty_hash
assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} })
end
def test_render_hash_with_string_keys_and_values
assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } })
end
def test_render_hash_with_symbol_keys_and_integer_values
assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } })
end
def test_render_nested_hash
assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } })
end
def test_render_hash_with_array_values
assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } })
end
def test_render_recursive_hash
recursive_hash = { "self" => {} }
recursive_hash["self"]["self"] = recursive_hash
assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash })
end
def test_hash_with_downcase_filter
assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_upcase_filter
assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_strip_filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_escape_filter
assert_template_result("{&quot;Key&quot;=&gt;&quot;Value&quot;, &quot;AnotherKey&quot;=&gt;&quot;AnotherValue&quot;}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_url_encode_filter
assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_strip_html_filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_truncate__20_filter
assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_replace___key____replaced_key__filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_append____appended_text__filter
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_hash_with_prepend___prepended_text___filter
assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
end
def test_render_hash_with_array_values_empty
assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } })
end
def test_render_hash_with_array_values_hash
assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } })
end
def test_join_filter_with_hash
array = [{ "key1" => "value1" }, { "key2" => "value2" }]
glue = { "lol" => "wut" }
assert_template_result("{\"key1\"=>\"value1\"}{\"lol\"=>\"wut\"}{\"key2\"=>\"value2\"}", "{{ my_array | join: glue }}", { "my_array" => array, "glue" => glue })
end
def test_render_hash_with_hash_key
assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } })
end
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
my_hash = Class.new(Hash) do
def to_s
"kewl"
end
end.new
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash })
end
def test_rendering_hash_without_custom_to_s_uses_default_inspect
my_hash = Class.new(Hash).new
my_hash[:foo] = :bar
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
end
end

View File

@ -131,4 +131,24 @@ class ParsingQuirksTest < Minitest::Test
def test_contains_in_id def test_contains_in_id
assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true }) assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true })
end end
def test_incomplete_expression
with_error_mode(:lax) do
assert_template_result("false", "{{ false - }}")
assert_template_result("false", "{{ false > }}")
assert_template_result("false", "{{ false < }}")
assert_template_result("false", "{{ false = }}")
assert_template_result("false", "{{ false ! }}")
assert_template_result("false", "{{ false 1 }}")
assert_template_result("false", "{{ false a }}")
assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false =\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false !\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false 1\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false a\n%}{{ foo }}")
end
end
end # ParsingQuirksTest end # ParsingQuirksTest

View File

@ -33,7 +33,7 @@ class ProfilerTest < Minitest::Test
end end
def setup def setup
Liquid::Template.file_system = ProfilingFileSystem.new Liquid::Environment.default.file_system = ProfilingFileSystem.new
end end
def test_template_allows_flagging_profiling def test_template_allows_flagging_profiling

View File

@ -32,7 +32,7 @@ class TestDrop < Liquid::Drop
attr_reader :value attr_reader :value
def registers def registers
{ @value => @context.registers[@value] } "{#{@value.inspect}=>#{@context.registers[@value].inspect}}"
end end
end end
@ -133,6 +133,18 @@ class StandardFiltersTest < Minitest::Test
assert_equal([], @filters.slice(input, -(1 << 63), 6)) assert_equal([], @filters.slice(input, -(1 << 63), 6))
end end
def test_find_on_empty_array
assert_nil(@filters.find([], 'foo', 'bar'))
end
def test_find_index_on_empty_array
assert_nil(@filters.find_index([], 'foo', 'bar'))
end
def test_has_on_empty_array
refute(@filters.has([], 'foo', 'bar'))
end
def test_truncate def test_truncate
assert_equal('1234...', @filters.truncate('1234567890', 7)) assert_equal('1234...', @filters.truncate('1234567890', 7))
assert_equal('1234567890', @filters.truncate('1234567890', 20)) assert_equal('1234567890', @filters.truncate('1234567890', 20))
@ -176,7 +188,17 @@ class StandardFiltersTest < Minitest::Test
end end
def test_base64_decode def test_base64_decode
assert_equal('one two three', @filters.base64_decode('b25lIHR3byB0aHJlZQ==')) decoded = @filters.base64_decode('b25lIHR3byB0aHJlZQ==')
assert_equal('one two three', decoded)
assert_equal(Encoding::UTF_8, decoded.encoding)
decoded = @filters.base64_decode('4pyF')
assert_equal('✅', decoded)
assert_equal(Encoding::UTF_8, decoded.encoding)
decoded = @filters.base64_decode("/w==")
assert_equal(Encoding::ASCII_8BIT, decoded.encoding)
assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded)
exception = assert_raises(Liquid::ArgumentError) do exception = assert_raises(Liquid::ArgumentError) do
@filters.base64_decode("invalidbase64") @filters.base64_decode("invalidbase64")
@ -194,10 +216,21 @@ class StandardFiltersTest < Minitest::Test
end end
def test_base64_url_safe_decode def test_base64_url_safe_decode
decoded = @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8')
assert_equal( assert_equal(
'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|', 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|',
@filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8'), decoded,
) )
assert_equal(Encoding::UTF_8, decoded.encoding)
decoded = @filters.base64_url_safe_decode('4pyF')
assert_equal('✅', decoded)
assert_equal(Encoding::UTF_8, decoded.encoding)
decoded = @filters.base64_url_safe_decode("_w==")
assert_equal(Encoding::ASCII_8BIT, decoded.encoding)
assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded)
exception = assert_raises(Liquid::ArgumentError) do exception = assert_raises(Liquid::ArgumentError) do
@filters.base64_url_safe_decode("invalidbase64") @filters.base64_url_safe_decode("invalidbase64")
end end
@ -260,6 +293,16 @@ class StandardFiltersTest < Minitest::Test
assert_equal('1121314', @filters.join([1, 2, 3, 4], 1)) assert_equal('1121314', @filters.join([1, 2, 3, 4], 1))
end end
def test_join_calls_to_liquid_on_each_element
drop = Class.new(Liquid::Drop) do
def to_liquid
'i did it'
end
end
assert_equal('i did it, i did it', @filters.join([drop.new, drop.new], ", "))
end
def test_sort def test_sort
assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1])) assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1]))
assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a")) assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a"))
@ -310,8 +353,8 @@ class StandardFiltersTest < Minitest::Test
{ "price" => "1", "handle" => "gamma" }, { "price" => "1", "handle" => "gamma" },
{ "price" => 2, "handle" => "epsilon" }, { "price" => 2, "handle" => "epsilon" },
{ "price" => "4", "handle" => "alpha" }, { "price" => "4", "handle" => "alpha" },
{ "handle" => "delta" },
{ "handle" => "beta" }, { "handle" => "beta" },
{ "handle" => "delta" },
] ]
assert_equal(expectation, @filters.sort_natural(input, "price")) assert_equal(expectation, @filters.sort_natural(input, "price"))
end end
@ -806,21 +849,216 @@ class StandardFiltersTest < Minitest::Test
assert_template_result('abc', "{{ 'abc' | date: '%D' }}") assert_template_result('abc', "{{ 'abc' | date: '%D' }}")
end end
def test_where def test_reject
input = [ array = [
{ "handle" => "alpha", "ok" => true }, { "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false }, { "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false }, { "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true }, { "handle" => "delta", "ok" => true },
] ]
expectation = [ template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}"
expected_output = "beta gamma"
assert_template_result(expected_output, template, { "array" => array })
end
def test_reject_with_value
array = [
{ "handle" => "alpha", "ok" => true }, { "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true }, { "handle" => "delta", "ok" => true },
] ]
assert_equal(expectation, @filters.where(input, "ok", true)) template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}"
assert_equal(expectation, @filters.where(input, "ok")) expected_output = "beta gamma"
assert_template_result(expected_output, template, { "array" => array })
end
def test_reject_with_false_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}"
expected_output = "alpha delta"
assert_template_result(expected_output, template, { "array" => array })
end
def test_has
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => false },
]
expected_output = "true"
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
end
def test_has_when_does_not_have_it
array = [
{ "handle" => "alpha", "ok" => false },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => false },
]
expected_output = "false"
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
end
def test_has_with_empty_arrays
template = <<~LIQUID
{%- assign has_product = products | has: 'title.content', 'Not found' -%}
{%- unless has_product -%}
Product not found.
{%- endunless -%}
LIQUID
expected_output = "Product not found."
assert_template_result(expected_output, template, { "products" => [] })
end
def test_has_with_false_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | has: 'ok', false }}"
expected_output = "true"
assert_template_result(expected_output, template, { "array" => array })
end
def test_has_with_false_value_when_does_not_have_it
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => true },
{ "handle" => "gamma", "ok" => true },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | has: 'ok', false }}"
expected_output = "false"
assert_template_result(expected_output, template, { "array" => array })
end
def test_find_with_value
products = [
{ "title" => "Pro goggles", "price" => 1299 },
{ "title" => "Thermal gloves", "price" => 1499 },
{ "title" => "Alpine jacket", "price" => 3999 },
{ "title" => "Mountain boots", "price" => 3899 },
{ "title" => "Safety helmet", "price" => 1999 }
]
template = <<~LIQUID
{%- assign product = products | find: 'price', 3999 -%}
{{- product.title -}}
LIQUID
expected_output = "Alpine jacket"
assert_template_result(expected_output, template, { "products" => products })
end
def test_find_with_empty_arrays
template = <<~LIQUID
{%- assign product = products | find: 'title.content', 'Not found' -%}
{%- unless product -%}
Product not found.
{%- endunless -%}
LIQUID
expected_output = "Product not found."
assert_template_result(expected_output, template, { "products" => [] })
end
def test_find_index_with_value
products = [
{ "title" => "Pro goggles", "price" => 1299 },
{ "title" => "Thermal gloves", "price" => 1499 },
{ "title" => "Alpine jacket", "price" => 3999 },
{ "title" => "Mountain boots", "price" => 3899 },
{ "title" => "Safety helmet", "price" => 1999 }
]
template = <<~LIQUID
{%- assign index = products | find_index: 'price', 3999 -%}
{{- index -}}
LIQUID
expected_output = "2"
assert_template_result(expected_output, template, { "products" => products })
end
def test_find_index_with_empty_arrays
template = <<~LIQUID
{%- assign index = products | find_index: 'title.content', 'Not found' -%}
{%- unless index -%}
Index not found.
{%- endunless -%}
LIQUID
expected_output = "Index not found."
assert_template_result(expected_output, template, { "products" => [] })
end
def test_where
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}"
expected_output = "alpha delta"
assert_template_result(expected_output, template, { "array" => array })
end
def test_where_with_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}"
expected_output = "alpha delta"
assert_template_result(expected_output, template, { "array" => array })
end
def test_where_with_false_value
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}"
expected_output = "beta gamma"
assert_template_result(expected_output, template, { "array" => array })
end end
def test_where_string_keys def test_where_string_keys
@ -994,6 +1232,42 @@ class StandardFiltersTest < Minitest::Test
assert(t.foo > 0) assert(t.foo > 0)
end end
def test_sum_of_floats
input = [0.1, 0.2, 0.3]
assert_equal(0.6, @filters.sum(input))
assert_template_result("0.6", "{{ input | sum }}", { "input" => input })
end
def test_sum_of_negative_floats
input = [0.1, 0.2, -0.3]
assert_equal(0.0, @filters.sum(input))
assert_template_result("0.0", "{{ input | sum }}", { "input" => input })
end
def test_sum_with_float_strings
input = [0.1, "0.2", "0.3"]
assert_equal(0.6, @filters.sum(input))
assert_template_result("0.6", "{{ input | sum }}", { "input" => input })
end
def test_sum_resulting_in_negative_float
input = [0.1, -0.2, -0.3]
assert_equal(-0.4, @filters.sum(input))
assert_template_result("-0.4", "{{ input | sum }}", { "input" => input })
end
def test_sum_with_floats_and_indexable_map_values
input = [{ "quantity" => 1 }, { "quantity" => 0.2, "weight" => -0.3 }, { "weight" => 0.4 }]
assert_equal(0.0, @filters.sum(input))
assert_equal(1.2, @filters.sum(input, "quantity"))
assert_equal(0.1, @filters.sum(input, "weight"))
assert_equal(0.0, @filters.sum(input, "subtotal"))
assert_template_result("0", "{{ input | sum }}", { "input" => input })
assert_template_result("1.2", "{{ input | sum: 'quantity' }}", { "input" => input })
assert_template_result("0.1", "{{ input | sum: 'weight' }}", { "input" => input })
assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
end
private private
def with_timezone(tz) def with_timezone(tz)

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'test_helper'
class CycleTagTest < Minitest::Test
def test_simple_cycle
template = <<~LIQUID
{%- cycle '1', '2', '3' -%}
{%- cycle '1', '2', '3' -%}
{%- cycle '1', '2', '3' -%}
LIQUID
assert_template_result("123", template)
end
def test_simple_cycle_inside_for_loop
template = <<~LIQUID
{%- for i in (1..3) -%}
{% cycle '1', '2', '3' %}
{%- endfor -%}
LIQUID
assert_template_result("123", template)
end
def test_cycle_with_variables_inside_for_loop
template = <<~LIQUID
{%- assign a = 1 -%}
{%- assign b = 2 -%}
{%- assign c = 3 -%}
{%- for i in (1..3) -%}
{% cycle a, b, c %}
{%- endfor -%}
LIQUID
assert_template_result("123", template)
end
def test_cycle_tag_always_resets_cycle
template = <<~LIQUID
{%- assign a = "1" -%}
{%- cycle a, "2" -%}
{%- cycle a, "2" -%}
LIQUID
assert_template_result("11", template)
end
end

View File

@ -49,14 +49,6 @@ end
class IncludeTagTest < Minitest::Test class IncludeTagTest < Minitest::Test
include Liquid include Liquid
def setup
@default_file_system = Liquid::Template.file_system
end
def teardown
Liquid::Template.file_system = @default_file_system
end
def test_include_tag_looks_for_file_system_in_registers_first def test_include_tag_looks_for_file_system_in_registers_first
assert_equal( assert_equal(
'from OtherFileSystem', 'from OtherFileSystem',
@ -182,10 +174,10 @@ class IncludeTagTest < Minitest::Test
end end
end end
Liquid::Template.file_system = infinite_file_system.new env = Liquid::Environment.build(file_system: infinite_file_system.new)
assert_raises(Liquid::StackLevelError) do assert_raises(Liquid::StackLevelError) do
Template.parse("{% include 'loop' %}").render! Template.parse("{% include 'loop' %}", environment: env).render!
end end
end end
@ -214,9 +206,10 @@ class IncludeTagTest < Minitest::Test
def test_include_tag_caches_second_read_of_same_partial def test_include_tag_caches_second_read_of_same_partial
file_system = CountingFileSystem.new file_system = CountingFileSystem.new
environment = Liquid::Environment.build(file_system: file_system)
assert_equal( assert_equal(
'from CountingFileSystemfrom CountingFileSystem', 'from CountingFileSystemfrom CountingFileSystem',
Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }), Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}", environment: environment).render!({}, registers: { file_system: file_system }),
) )
assert_equal(1, file_system.count) assert_equal(1, file_system.count)
end end
@ -271,26 +264,27 @@ class IncludeTagTest < Minitest::Test
end end
def test_does_not_add_error_in_strict_mode_for_missing_variable def test_does_not_add_error_in_strict_mode_for_missing_variable
Liquid::Template.file_system = TestFileSystem.new env = Liquid::Environment.build(file_system: TestFileSystem.new)
a = Liquid::Template.parse(' {% include "nested_template" %}') a = Liquid::Template.parse(' {% include "nested_template" %}', environment: env)
a.render! a.render!
assert_empty(a.errors) assert_empty(a.errors)
end end
def test_passing_options_to_included_templates def test_passing_options_to_included_templates
Liquid::Template.file_system = TestFileSystem.new env = Liquid::Environment.build(file_system: TestFileSystem.new)
assert_raises(Liquid::SyntaxError) do assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict).render!("template" => '{{ "X" || downcase }}') Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
end end
with_error_mode(:lax) do with_error_mode(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true).render!("template" => '{{ "X" || downcase }}')) assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
end end
assert_raises(Liquid::SyntaxError) do assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale]).render!("template" => '{{ "X" || downcase }}') Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
end end
with_error_mode(:lax) do with_error_mode(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode]).render!("template" => '{{ "X" || downcase }}')) assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
end end
end end
@ -341,8 +335,11 @@ class IncludeTagTest < Minitest::Test
end end
def test_including_with_strict_variables def test_including_with_strict_variables
Liquid::Template.file_system = StubFileSystem.new({ "simple" => "simple" }) env = Liquid::Environment.build(
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn) file_system: StubFileSystem.new('simple' => 'simple'),
)
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env)
template.render(nil, strict_variables: true) template.render(nil, strict_variables: true)
assert_equal([], template.errors) assert_equal([], template.errors)

View File

@ -16,6 +16,7 @@ class RawTagTest < Minitest::Test
assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <') assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <')
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <") assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <")
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <") assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <")
assert_template_result("{Hello}", "{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}")
end end
def test_open_tag_in_raw def test_open_tag_in_raw

View File

@ -82,19 +82,22 @@ class RenderTagTest < Minitest::Test
end end
def test_recursively_rendered_template_does_not_produce_endless_loop def test_recursively_rendered_template_does_not_produce_endless_loop
Liquid::Template.file_system = StubFileSystem.new('loop' => '{% render "loop" %}') env = Liquid::Environment.build(
file_system: StubFileSystem.new('loop' => '{% render "loop" %}'),
)
assert_raises(Liquid::StackLevelError) do assert_raises(Liquid::StackLevelError) do
Template.parse('{% render "loop" %}').render! Template.parse('{% render "loop" %}', environment: env).render!
end end
end end
def test_sub_contexts_count_towards_the_same_recursion_limit def test_sub_contexts_count_towards_the_same_recursion_limit
Liquid::Template.file_system = StubFileSystem.new( env = Liquid::Environment.build(
'loop_render' => '{% render "loop_render" %}', file_system: StubFileSystem.new('loop_render' => '{% render "loop_render" %}'),
) )
assert_raises(Liquid::StackLevelError) do assert_raises(Liquid::StackLevelError) do
Template.parse('{% render "loop_render" %}').render! Template.parse('{% render "loop_render" %}', environment: env).render!
end end
end end

View File

@ -207,4 +207,52 @@ class TableRowTest < Minitest::Test
render_errors: true, render_errors: true,
) )
end end
def test_table_row_handles_interrupts
assert_template_result(
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td></tr>\n",
'{% tablerow n in (1...3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
)
assert_template_result(
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td><td class=\"col2\"> 2 </td></tr>\n<tr class=\"row2\"><td class=\"col1\"> 3 </td></tr>\n",
'{% tablerow n in (1...3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
)
end
def test_table_row_does_not_leak_interrupts
template = <<~LIQUID
{% for i in (1..2) -%}
{% for j in (1..2) -%}
{% tablerow k in (1..3) %}{% break %}{% endtablerow -%}
loop j={{ j }}
{% endfor -%}
loop i={{ i }}
{% endfor -%}
after loop
LIQUID
expected = <<~STR
<tr class="row1">
<td class="col1"></td></tr>
loop j=1
<tr class="row1">
<td class="col1"></td></tr>
loop j=2
loop i=1
<tr class="row1">
<td class="col1"></td></tr>
loop j=1
<tr class="row1">
<td class="col1"></td></tr>
loop j=2
loop i=2
after loop
STR
assert_template_result(
expected,
template,
)
end
end end

View File

@ -337,4 +337,22 @@ class TemplateTest < Minitest::Test
assert_equal("x=2", output) assert_equal("x=2", output)
assert_instance_of(String, output) assert_instance_of(String, output)
end end
def test_raises_error_with_invalid_utf8
e = assert_raises(TemplateEncodingError) do
Template.parse(<<~LIQUID)
{% comment %}
\xC0
{% endcomment %}
LIQUID
end
assert_equal('Liquid error: Invalid template encoding', e.message)
end
def test_allows_non_string_values_as_source
assert_equal('', Template.parse(nil).render)
assert_equal('1', Template.parse(1).render)
assert_equal('true', Template.parse(true).render)
end
end end

View File

@ -130,6 +130,10 @@ class VariableTest < Minitest::Test
assert_template_result('bar', '{{ foo }}', { 'foo' => :bar }) assert_template_result('bar', '{{ foo }}', { 'foo' => :bar })
end end
def test_nested_array
assert_template_result('', '{{ foo }}', { 'foo' => [[nil]] })
end
def test_dynamic_find_var def test_dynamic_find_var
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end end

View File

@ -13,12 +13,7 @@ if (env_mode = ENV['LIQUID_PARSER_MODE'])
puts "-- #{env_mode.upcase} ERROR MODE" puts "-- #{env_mode.upcase} ERROR MODE"
mode = env_mode.to_sym mode = env_mode.to_sym
end end
Liquid::Template.error_mode = mode Liquid::Environment.default.error_mode = mode
if ENV['LIQUID_C'] == '1'
puts "-- LIQUID C"
require 'liquid/c'
end
if Minitest.const_defined?('Test') if Minitest.const_defined?('Test')
# We're on Minitest 5+. Nothing to do here. # We're on Minitest 5+. Nothing to do here.
@ -42,10 +37,11 @@ module Minitest
message: nil, partials: nil, error_mode: nil, render_errors: false, message: nil, partials: nil, error_mode: nil, render_errors: false,
template_factory: nil template_factory: nil
) )
template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym)
file_system = StubFileSystem.new(partials || {}) file_system = StubFileSystem.new(partials || {})
environment = Liquid::Environment.build(file_system: file_system)
template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, environment: environment)
registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory) registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory)
context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers) context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, environment: environment)
output = template.render(context) output = template.render(context)
assert_equal(expected, output, message) assert_equal(expected, output, message)
end end
@ -78,44 +74,27 @@ module Minitest
assert_equal(times, calls, "Number of calls to Usage.increment with #{name.inspect}") assert_equal(times, calls, "Number of calls to Usage.increment with #{name.inspect}")
end end
def with_global_filter(*globals) def with_global_filter(*globals, &blk)
original_global_cache = Liquid::StrainerFactory::GlobalCache environment = Liquid::Environment.build do |w|
Liquid::StrainerFactory.send(:remove_const, :GlobalCache) w.register_filters(globals)
Liquid::StrainerFactory.const_set(:GlobalCache, Class.new(Liquid::StrainerTemplate)) end
globals.each do |global| Environment.dangerously_override(environment, &blk)
Liquid::Template.register_filter(global)
end
Liquid::StrainerFactory.send(:strainer_class_cache).clear
begin
yield
ensure
Liquid::StrainerFactory.send(:remove_const, :GlobalCache)
Liquid::StrainerFactory.const_set(:GlobalCache, original_global_cache)
Liquid::StrainerFactory.send(:strainer_class_cache).clear
end
end end
def with_error_mode(mode) def with_error_mode(mode)
old_mode = Liquid::Template.error_mode old_mode = Liquid::Environment.default.error_mode
Liquid::Template.error_mode = mode Liquid::Environment.default.error_mode = mode
yield yield
ensure ensure
Liquid::Template.error_mode = old_mode Liquid::Environment.default.error_mode = old_mode
end end
def with_custom_tag(tag_name, tag_class) def with_custom_tag(tag_name, tag_class, &block)
old_tag = Liquid::Template.tags[tag_name] environment = Liquid::Environment.default.dup
begin environment.register_tag(tag_name, tag_class)
Liquid::Template.register_tag(tag_name, tag_class)
yield Environment.dangerously_override(environment, &block)
ensure
if old_tag
Liquid::Template.tags[tag_name] = old_tag
else
Liquid::Template.tags.delete(tag_name)
end
end
end end
end end
end end

View File

@ -32,6 +32,12 @@ class BlockUnitTest < Minitest::Test
assert_equal(String, template.root.nodelist[2].class) assert_equal(String, template.root.nodelist[2].class)
end end
def test_variable_with_multibyte_character
template = Liquid::Template.parse("{{ '❤️' }}")
assert_equal(1, template.root.nodelist.size)
assert_equal(Variable, template.root.nodelist[0].class)
end
def test_variable_many_embedded_fragments def test_variable_many_embedded_fragments
template = Liquid::Template.parse(" {{funk}} {{so}} {{brother}} ") template = Liquid::Template.parse(" {{funk}} {{so}} {{brother}} ")
assert_equal(7, template.root.nodelist.size) assert_equal(7, template.root.nodelist.size)

View File

@ -55,6 +55,11 @@ class ConditionUnitTest < Minitest::Test
assert_evaluates_false('bob', 'contains', '---') assert_evaluates_false('bob', 'contains', '---')
end end
def test_contains_binary_encoding_compatibility_with_utf8
assert_evaluates_true('🙈'.b, 'contains', '🙈')
assert_evaluates_true('🙈', 'contains', '🙈'.b)
end
def test_invalid_comparation_operator def test_invalid_comparation_operator
assert_evaluates_argument_error(1, '~~', 0) assert_evaluates_argument_error(1, '~~', 0)
end end
@ -166,14 +171,14 @@ class ConditionUnitTest < Minitest::Test
def assert_evaluates_true(left, op, right) def assert_evaluates_true(left, op, right)
assert( assert(
Condition.new(left, op, right).evaluate(@context), Condition.new(left, op, right).evaluate(@context),
"Evaluated false: #{left} #{op} #{right}", "Evaluated false: #{left.inspect} #{op} #{right.inspect}",
) )
end end
def assert_evaluates_false(left, op, right) def assert_evaluates_false(left, op, right)
assert( assert(
!Condition.new(left, op, right).evaluate(@context), !Condition.new(left, op, right).evaluate(@context),
"Evaluated true: #{left} #{op} #{right}", "Evaluated true: #{left.inspect} #{op} #{right.inspect}",
) )
end end

View File

@ -2,7 +2,7 @@
require 'test_helper' require 'test_helper'
class StrainerFactoryUnitTest < Minitest::Test class EnvironmentFilterTest < Minitest::Test
include Liquid include Liquid
module AccessScopeFilters module AccessScopeFilters
@ -16,8 +16,6 @@ class StrainerFactoryUnitTest < Minitest::Test
private :private_filter private :private_filter
end end
StrainerFactory.add_global_filter(AccessScopeFilters)
module LateAddedFilter module LateAddedFilter
def late_added_filter(_input) def late_added_filter(_input)
"filtered" "filtered"
@ -25,24 +23,28 @@ class StrainerFactoryUnitTest < Minitest::Test
end end
def setup def setup
@context = Context.build @environment = Liquid::Environment.build do |env|
env.register_filter(AccessScopeFilters)
end
@context = Context.build(environment: @environment)
end end
def test_strainer def test_strainer
strainer = StrainerFactory.create(@context) strainer = @environment.create_strainer(@context)
assert_equal(5, strainer.invoke('size', 'input')) assert_equal(5, strainer.invoke('size', 'input'))
assert_equal("public", strainer.invoke("public_filter")) assert_equal("public", strainer.invoke("public_filter"))
end end
def test_stainer_raises_argument_error def test_strainer_raises_argument_error
strainer = StrainerFactory.create(@context) strainer = @environment.create_strainer(@context)
assert_raises(Liquid::ArgumentError) do assert_raises(Liquid::ArgumentError) do
strainer.invoke("public_filter", 1) strainer.invoke("public_filter", 1)
end end
end end
def test_stainer_argument_error_contains_backtrace def test_strainer_argument_error_contains_backtrace
strainer = StrainerFactory.create(@context) strainer = @environment.create_strainer(@context)
exception = assert_raises(Liquid::ArgumentError) do exception = assert_raises(Liquid::ArgumentError) do
strainer.invoke("public_filter", 1) strainer.invoke("public_filter", 1)
@ -52,12 +54,13 @@ class StrainerFactoryUnitTest < Minitest::Test
/\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/, /\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/,
exception.message, exception.message,
) )
source = AccessScopeFilters.instance_method(:public_filter).source_location source = AccessScopeFilters.instance_method(:public_filter).source_location
assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1]) assert_equal(source[0..1].map(&:to_s), exception.backtrace[0].split(':')[0..1])
end end
def test_strainer_only_invokes_public_filter_methods def test_strainer_only_invokes_public_filter_methods
strainer = StrainerFactory.create(@context) strainer = @environment.create_strainer(@context)
assert_equal(false, strainer.class.invokable?('__test__')) assert_equal(false, strainer.class.invokable?('__test__'))
assert_equal(false, strainer.class.invokable?('test')) assert_equal(false, strainer.class.invokable?('test'))
assert_equal(false, strainer.class.invokable?('instance_eval')) assert_equal(false, strainer.class.invokable?('instance_eval'))
@ -66,18 +69,18 @@ class StrainerFactoryUnitTest < Minitest::Test
end end
def test_strainer_returns_nil_if_no_filter_method_found def test_strainer_returns_nil_if_no_filter_method_found
strainer = StrainerFactory.create(@context) strainer = @environment.create_strainer(@context)
assert_nil(strainer.invoke("private_filter")) assert_nil(strainer.invoke("private_filter"))
assert_nil(strainer.invoke("undef_the_filter")) assert_nil(strainer.invoke("undef_the_filter"))
end end
def test_strainer_returns_first_argument_if_no_method_and_arguments_given def test_strainer_returns_first_argument_if_no_method_and_arguments_given
strainer = StrainerFactory.create(@context) strainer = @environment.create_strainer(@context)
assert_equal("password", strainer.invoke("undef_the_method", "password")) assert_equal("password", strainer.invoke("undef_the_method", "password"))
end end
def test_strainer_only_allows_methods_defined_in_filters def test_strainer_only_allows_methods_defined_in_filters
strainer = StrainerFactory.create(@context) strainer = @environment.create_strainer(@context)
assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1")) assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1"))
assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom")) assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom"))
assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke")) assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke"))
@ -86,7 +89,9 @@ class StrainerFactoryUnitTest < Minitest::Test
def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation
a = Module.new a = Module.new
b = Module.new b = Module.new
strainer = StrainerFactory.create(@context, [a, b])
strainer = @environment.create_strainer(@context, [a, b])
assert_kind_of(StrainerTemplate, strainer) assert_kind_of(StrainerTemplate, strainer)
assert_kind_of(a, strainer) assert_kind_of(a, strainer)
assert_kind_of(b, strainer) assert_kind_of(b, strainer)
@ -94,8 +99,10 @@ class StrainerFactoryUnitTest < Minitest::Test
end end
def test_add_global_filter_clears_cache def test_add_global_filter_clears_cache
assert_equal('input', StrainerFactory.create(@context).invoke('late_added_filter', 'input')) assert_equal('input', @environment.create_strainer(@context).invoke('late_added_filter', 'input'))
StrainerFactory.add_global_filter(LateAddedFilter)
assert_equal('filtered', StrainerFactory.create(nil).invoke('late_added_filter', 'input')) @environment.register_filter(LateAddedFilter)
assert_equal('filtered', @environment.create_strainer(nil).invoke('late_added_filter', 'input'))
end end
end end

View File

@ -6,48 +6,144 @@ class LexerUnitTest < Minitest::Test
include Liquid include Liquid
def test_strings def test_strings
tokens = Lexer.new(%( 'this is a test""' "wat 'lol'")).tokenize assert_equal(
assert_equal([[:string, %('this is a test""')], [:string, %("wat 'lol'")], [:end_of_string]], tokens) [[:string, %('this is a test""')], [:string, %("wat 'lol'")], [:end_of_string]],
tokenize(%( 'this is a test""' "wat 'lol'")),
)
end end
def test_integer def test_integer
tokens = Lexer.new('hi 50').tokenize assert_equal(
assert_equal([[:id, 'hi'], [:number, '50'], [:end_of_string]], tokens) [[:id, 'hi'], [:number, '50'], [:end_of_string]],
tokenize('hi 50'),
)
end end
def test_float def test_float
tokens = Lexer.new('hi 5.0').tokenize assert_equal(
assert_equal([[:id, 'hi'], [:number, '5.0'], [:end_of_string]], tokens) [[:id, 'hi'], [:number, '5.0'], [:end_of_string]],
tokenize('hi 5.0'),
)
end end
def test_comparison def test_comparison
tokens = Lexer.new('== <> contains ').tokenize assert_equal(
assert_equal([[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], tokens) [[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]],
tokenize('== <> contains '),
)
end
def test_comparison_without_whitespace
assert_equal(
[[:number, '1'], [:comparison, '>'], [:number, '0'], [:end_of_string]],
tokenize('1>0'),
)
end
def test_comparison_with_negative_number
assert_equal(
[[:number, '1'], [:comparison, '>'], [:number, '-1'], [:end_of_string]],
tokenize('1>-1'),
)
end
def test_raise_for_invalid_comparison
assert_raises(SyntaxError) do
tokenize('1>!1')
end
assert_raises(SyntaxError) do
tokenize('1=<1')
end
assert_raises(SyntaxError) do
tokenize('1!!1')
end
end end
def test_specials def test_specials
tokens = Lexer.new('| .:').tokenize assert_equal(
assert_equal([[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]], tokens) [[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]],
tokens = Lexer.new('[,]').tokenize tokenize('| .:'),
assert_equal([[:open_square, '['], [:comma, ','], [:close_square, ']'], [:end_of_string]], tokens) )
assert_equal(
[[:open_square, '['], [:comma, ','], [:close_square, ']'], [:end_of_string]],
tokenize('[,]'),
)
end end
def test_fancy_identifiers def test_fancy_identifiers
tokens = Lexer.new('hi five?').tokenize assert_equal([[:id, 'hi'], [:id, 'five?'], [:end_of_string]], tokenize('hi five?'))
assert_equal([[:id, 'hi'], [:id, 'five?'], [:end_of_string]], tokens)
tokens = Lexer.new('2foo').tokenize assert_equal([[:number, '2'], [:id, 'foo'], [:end_of_string]], tokenize('2foo'))
assert_equal([[:number, '2'], [:id, 'foo'], [:end_of_string]], tokens)
end end
def test_whitespace def test_whitespace
tokens = Lexer.new("five|\n\t ==").tokenize assert_equal(
assert_equal([[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]], tokens) [[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]],
tokenize("five|\n\t =="),
)
end end
def test_unexpected_character def test_unexpected_character
assert_raises(SyntaxError) do assert_raises(SyntaxError) do
Lexer.new("%").tokenize tokenize("%")
end end
end end
def test_negative_numbers
assert_equal(
[[:id, 'foo'], [:pipe, '|'], [:id, 'default'], [:colon, ":"], [:number, '-1'], [:end_of_string]],
tokenize("foo | default: -1"),
)
end
def test_greater_than_two_digits
assert_equal(
[[:id, 'foo'], [:comparison, '>'], [:number, '12'], [:end_of_string]],
tokenize("foo > 12"),
)
end
def test_error_with_utf8_character
error = assert_raises(SyntaxError) do
tokenize("1 < 1Ø")
end
assert_equal(
'Liquid syntax error: Unexpected character Ø',
error.message,
)
end
def test_contains_as_attribute_name
assert_equal(
[[:id, "a"], [:dot, "."], [:id, "contains"], [:dot, "."], [:id, "b"], [:end_of_string]],
tokenize("a.contains.b"),
)
end
def test_tokenize_incomplete_expression
assert_equal([[:id, "false"], [:dash, "-"], [:end_of_string]], tokenize("false -"))
assert_equal([[:id, "false"], [:comparison, "<"], [:end_of_string]], tokenize("false <"))
assert_equal([[:id, "false"], [:comparison, ">"], [:end_of_string]], tokenize("false >"))
assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1"))
end
def test_error_with_invalid_utf8
error = assert_raises(SyntaxError) do
tokenize("\x00\xff")
end
assert_equal(
'Liquid syntax error: Invalid byte sequence in UTF-8',
error.message,
)
end
private
def tokenize(input)
Lexer.tokenize(StringScanner.new(input))
end
end end

View File

@ -6,20 +6,20 @@ class ParserUnitTest < Minitest::Test
include Liquid include Liquid
def test_consume def test_consume
p = Parser.new("wat: 7") p = new_parser("wat: 7")
assert_equal('wat', p.consume(:id)) assert_equal('wat', p.consume(:id))
assert_equal(':', p.consume(:colon)) assert_equal(':', p.consume(:colon))
assert_equal('7', p.consume(:number)) assert_equal('7', p.consume(:number))
end end
def test_jump def test_jump
p = Parser.new("wat: 7") p = new_parser("wat: 7")
p.jump(2) p.jump(2)
assert_equal('7', p.consume(:number)) assert_equal('7', p.consume(:number))
end end
def test_consume? def test_consume?
p = Parser.new("wat: 7") p = new_parser("wat: 7")
assert_equal('wat', p.consume?(:id)) assert_equal('wat', p.consume?(:id))
assert_equal(false, p.consume?(:dot)) assert_equal(false, p.consume?(:dot))
assert_equal(':', p.consume(:colon)) assert_equal(':', p.consume(:colon))
@ -27,7 +27,7 @@ class ParserUnitTest < Minitest::Test
end end
def test_id? def test_id?
p = Parser.new("wat 6 Peter Hegemon") p = new_parser("wat 6 Peter Hegemon")
assert_equal('wat', p.id?('wat')) assert_equal('wat', p.id?('wat'))
assert_equal(false, p.id?('endgame')) assert_equal(false, p.id?('endgame'))
assert_equal('6', p.consume(:number)) assert_equal('6', p.consume(:number))
@ -36,7 +36,7 @@ class ParserUnitTest < Minitest::Test
end end
def test_look def test_look
p = Parser.new("wat 6 Peter Hegemon") p = new_parser("wat 6 Peter Hegemon")
assert_equal(true, p.look(:id)) assert_equal(true, p.look(:id))
assert_equal('wat', p.consume(:id)) assert_equal('wat', p.consume(:id))
assert_equal(false, p.look(:comparison)) assert_equal(false, p.look(:comparison))
@ -46,12 +46,12 @@ class ParserUnitTest < Minitest::Test
end end
def test_expressions def test_expressions
p = Parser.new("hi.there hi?[5].there? hi.there.bob") p = new_parser("hi.there hi?[5].there? hi.there.bob")
assert_equal('hi.there', p.expression) assert_equal('hi.there', p.expression)
assert_equal('hi?[5].there?', p.expression) assert_equal('hi?[5].there?', p.expression)
assert_equal('hi.there.bob', p.expression) assert_equal('hi.there.bob', p.expression)
p = Parser.new("567 6.0 'lol' \"wut\"") p = new_parser("567 6.0 'lol' \"wut\"")
assert_equal('567', p.expression) assert_equal('567', p.expression)
assert_equal('6.0', p.expression) assert_equal('6.0', p.expression)
assert_equal("'lol'", p.expression) assert_equal("'lol'", p.expression)
@ -59,7 +59,7 @@ class ParserUnitTest < Minitest::Test
end end
def test_ranges def test_ranges
p = Parser.new("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)") p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
assert_equal('(5..7)', p.expression) assert_equal('(5..7)', p.expression)
assert_equal('(1.5..9.6)', p.expression) assert_equal('(1.5..9.6)', p.expression)
assert_equal('(young..old)', p.expression) assert_equal('(young..old)', p.expression)
@ -67,7 +67,7 @@ class ParserUnitTest < Minitest::Test
end end
def test_arguments def test_arguments
p = Parser.new("filter: hi.there[5], keyarg: 7") p = new_parser("filter: hi.there[5], keyarg: 7")
assert_equal('filter', p.consume(:id)) assert_equal('filter', p.consume(:id))
assert_equal(':', p.consume(:colon)) assert_equal(':', p.consume(:colon))
assert_equal('hi.there[5]', p.argument) assert_equal('hi.there[5]', p.argument)
@ -77,8 +77,14 @@ class ParserUnitTest < Minitest::Test
def test_invalid_expression def test_invalid_expression
assert_raises(SyntaxError) do assert_raises(SyntaxError) do
p = Parser.new("==") p = new_parser("==")
p.expression p.expression
end end
end end
private
def new_parser(str)
Parser.new(StringScanner.new(str))
end
end end

View File

@ -25,11 +25,13 @@ class StrainerTemplateUnitTest < Minitest::Test
end end
def test_add_filter_raises_when_module_privately_overrides_registered_public_methods def test_add_filter_raises_when_module_privately_overrides_registered_public_methods
strainer = Context.new.strainer
error = assert_raises(Liquid::MethodOverrideError) do error = assert_raises(Liquid::MethodOverrideError) do
strainer.class.add_filter(PrivateMethodOverrideFilter) Liquid::Environment.build do |env|
env.register_filter(PublicMethodOverrideFilter)
env.register_filter(PrivateMethodOverrideFilter)
end
end end
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end end
@ -42,11 +44,13 @@ class StrainerTemplateUnitTest < Minitest::Test
end end
def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected
strainer = Context.new.strainer
error = assert_raises(Liquid::MethodOverrideError) do error = assert_raises(Liquid::MethodOverrideError) do
strainer.class.add_filter(ProtectedMethodOverrideFilter) Liquid::Environment.build do |env|
env.register_filter(PublicMethodOverrideFilter)
env.register_filter(ProtectedMethodOverrideFilter)
end
end end
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end end
@ -58,8 +62,9 @@ class StrainerTemplateUnitTest < Minitest::Test
def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method
with_global_filter do with_global_filter do
strainer = Context.new.strainer context = Context.new
strainer.class.add_filter(PublicMethodOverrideFilter) context.add_filters([PublicMethodOverrideFilter])
strainer = context.strainer
assert(strainer.class.send(:filter_methods).include?('public_filter')) assert(strainer.class.send(:filter_methods).include?('public_filter'))
end end
end end

View File

@ -6,18 +6,36 @@ class TagUnitTest < Minitest::Test
include Liquid include Liquid
def test_tag def test_tag
tag = Tag.parse('tag', "", Tokenizer.new(""), ParseContext.new) tag = Tag.parse('tag', "", new_tokenizer, ParseContext.new)
assert_equal('liquid::tag', tag.name) assert_equal('liquid::tag', tag.name)
assert_equal('', tag.render(Context.new)) assert_equal('', tag.render(Context.new))
end end
def test_return_raw_text_of_tag def test_return_raw_text_of_tag
tag = Tag.parse("long_tag", "param1, param2, param3", Tokenizer.new(""), ParseContext.new) tag = Tag.parse("long_tag", "param1, param2, param3", new_tokenizer, ParseContext.new)
assert_equal("long_tag param1, param2, param3", tag.raw) assert_equal("long_tag param1, param2, param3", tag.raw)
end end
def test_tag_name_should_return_name_of_the_tag def test_tag_name_should_return_name_of_the_tag
tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new) tag = Tag.parse("some_tag", "", new_tokenizer, ParseContext.new)
assert_equal('some_tag', tag.tag_name) assert_equal('some_tag', tag.tag_name)
end end
class CustomTag < Liquid::Tag
def render(_context); end
end
def test_tag_render_to_output_buffer_nil_value
custom_tag = CustomTag.parse("some_tag", "", new_tokenizer, ParseContext.new)
assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string"))
end
private
def new_tokenizer
Tokenizer.new(
source: "",
string_scanner: StringScanner.new(""),
)
end
end end

View File

@ -0,0 +1,202 @@
# frozen_string_literal: true
require 'test_helper'
class CommentTagUnitTest < Minitest::Test
def test_comment_inside_liquid_tag
assert_template_result("", <<~LIQUID.chomp)
{% liquid
if 1 != 1
comment
else
echo 123
endcomment
endif
%}
LIQUID
end
def test_does_not_parse_nodes_inside_a_comment
assert_template_result("", <<~LIQUID.chomp)
{% comment %}
{% if true %}
{% if ... %}
{%- for ? -%}
{% while true %}
{%
unless if
%}
{% endcase %}
{% endcomment %}
LIQUID
end
def test_allows_unclosed_tags
assert_template_result('', <<~LIQUID.chomp)
{% comment %}
{% if true %}
{% endcomment %}
LIQUID
end
def test_open_tags_in_comment
assert_template_result('', <<~LIQUID.chomp)
{% comment %}
{% assign a = 123 {% comment %}
{% endcomment %}
LIQUID
assert_raises(Liquid::SyntaxError) do
assert_template_result("", <<~LIQUID.chomp)
{% comment %}
{% assign foo = "1"
{% endcomment %}
LIQUID
end
assert_raises(Liquid::SyntaxError) do
assert_template_result("", <<~LIQUID.chomp)
{% comment %}
{% comment %}
{% invalid
{% endcomment %}
{% endcomment %}
LIQUID
end
assert_raises(Liquid::SyntaxError) do
assert_template_result("", <<~LIQUID.chomp)
{% comment %}
{% {{ {%- endcomment %}
LIQUID
end
end
def test_child_comment_tags_need_to_be_closed
assert_template_result("", <<~LIQUID.chomp)
{% comment %}
{% comment %}
{% comment %}{% endcomment %}
{% endcomment %}
{% endcomment %}
LIQUID
assert_raises(Liquid::SyntaxError) do
assert_template_result("", <<~LIQUID.chomp)
{% comment %}
{% comment %}
{% comment %}
{% endcomment %}
{% endcomment %}
LIQUID
end
end
def test_child_raw_tags_need_to_be_closed
assert_template_result("", <<~LIQUID.chomp)
{% comment %}
{% raw %}
{% endcomment %}
{% endraw %}
{% endcomment %}
LIQUID
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse(<<~LIQUID.chomp)
{% comment %}
{% raw %}
{% endcomment %}
{% endcomment %}
LIQUID
end
end
def test_error_line_number_is_correct
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
{% comment %}
{% if true %}
{% endcomment %}
{{ errors.standard_error }}
LIQUID
output = template.render('errors' => ErrorDrop.new)
expected = <<~TEXT.chomp
Liquid error (line 4): standard error
TEXT
assert_equal(expected, output)
end
def test_comment_tag_delimiter_with_extra_strings
assert_template_result(
'',
<<~LIQUID.chomp,
{% comment %}
{% comment %}
{% endcomment
{% if true %}
{% endif %}
{% endcomment %}
LIQUID
)
end
def test_nested_comment_tag_with_extra_strings
assert_template_result(
'',
<<~LIQUID.chomp,
{% comment %}
{% comment
{% assign foo = 1 %}
{% endcomment
{% assign foo = 1 %}
{% endcomment %}
LIQUID
)
end
def test_ignores_delimiter_with_extra_strings
assert_template_result(
'',
<<~LIQUID.chomp,
{% if true %}
{% comment %}
{% commentXXXXX %}wut{% endcommentXXXXX %}
{% endcomment %}
{% endif %}
LIQUID
)
end
def test_delimiter_can_have_extra_strings
assert_template_result('', "{% comment %}123{% endcomment xyz %}")
assert_template_result('', "{% comment %}123{% endcomment\txyz %}")
assert_template_result('', "{% comment %}123{% endcomment\nxyz %}")
assert_template_result('', "{% comment %}123{% endcomment\n xyz endcomment %}")
assert_template_result('', "{%comment}{% assign a = 1 %}{%endcomment}{% endif %}")
end
def test_with_whitespace_control
assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%}Hello!")
assert_template_result("Hello!", "{%- comment -%}123{%- endcomment -%} Hello!")
assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%} Hello!")
assert_template_result("Hello!", <<~LIQUID.chomp)
{%- comment %}Whitespace control!{% endcomment -%}
Hello!
LIQUID
end
def test_dont_override_liquid_tag_whitespace_control
assert_template_result("Hello!World!", <<~LIQUID.chomp)
Hello!
{%- liquid
comment
this is inside a liquid tag
endcomment
-%}
World!
LIQUID
end
end

View File

@ -20,62 +20,13 @@ class TemplateUnitTest < Minitest::Test
assert_equal(fixture("en_locale.yml"), locale.path) assert_equal(fixture("en_locale.yml"), locale.path)
end end
def test_with_cache_classes_tags_returns_the_same_class
original_cache_setting = Liquid.cache_classes
Liquid.cache_classes = true
original_klass = Class.new
Object.send(:const_set, :CustomTag, original_klass)
Template.register_tag('custom', CustomTag)
Object.send(:remove_const, :CustomTag)
new_klass = Class.new
Object.send(:const_set, :CustomTag, new_klass)
assert(Template.tags['custom'].equal?(original_klass))
ensure
Object.send(:remove_const, :CustomTag)
Template.tags.delete('custom')
Liquid.cache_classes = original_cache_setting
end
def test_without_cache_classes_tags_reloads_the_class
original_cache_setting = Liquid.cache_classes
Liquid.cache_classes = false
original_klass = Class.new
Object.send(:const_set, :CustomTag, original_klass)
Template.register_tag('custom', CustomTag)
Object.send(:remove_const, :CustomTag)
new_klass = Class.new
Object.send(:const_set, :CustomTag, new_klass)
assert(Template.tags['custom'].equal?(new_klass))
ensure
Object.send(:remove_const, :CustomTag)
Template.tags.delete('custom')
Liquid.cache_classes = original_cache_setting
end
class FakeTag; end class FakeTag; end
def test_tags_delete
Template.register_tag('fake', FakeTag)
assert_equal(FakeTag, Template.tags['fake'])
Template.tags.delete('fake')
assert_nil(Template.tags['fake'])
end
def test_tags_can_be_looped_over def test_tags_can_be_looped_over
Template.register_tag('fake', FakeTag) with_custom_tag('fake', FakeTag) do
result = Template.tags.map { |name, klass| [name, klass] } result = Template.tags.map { |name, klass| [name, klass] }
assert(result.include?(["fake", "TemplateUnitTest::FakeTag"])) assert(result.include?(["fake", TemplateUnitTest::FakeTag]))
ensure end
Template.tags.delete('fake')
end end
class TemplateSubclass < Liquid::Template class TemplateSubclass < Liquid::Template
@ -84,4 +35,15 @@ class TemplateUnitTest < Minitest::Test
def test_template_inheritance def test_template_inheritance
assert_equal("foo", TemplateSubclass.parse("foo").render) assert_equal("foo", TemplateSubclass.parse("foo").render)
end end
def test_invalid_utf8
input = "\xff\x00"
error = assert_raises(SyntaxError) do
Liquid::Tokenizer.new(source: input, string_scanner: StringScanner.new(input))
end
assert_equal(
'Liquid syntax error: Invalid byte sequence in UTF-8',
error.message,
)
end
end end

View File

@ -6,6 +6,7 @@ class TokenizerTest < Minitest::Test
def test_tokenize_strings def test_tokenize_strings
assert_equal([' '], tokenize(' ')) assert_equal([' '], tokenize(' '))
assert_equal(['hello world'], tokenize('hello world')) assert_equal(['hello world'], tokenize('hello world'))
assert_equal(['{}'], tokenize('{}'))
end end
def test_tokenize_variables def test_tokenize_variables
@ -30,6 +31,23 @@ class TokenizerTest < Minitest::Test
assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} ")) assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} "))
end end
def test_tokenize_with_nil_source_returns_empty_array
assert_equal([], tokenize(nil))
end
def test_incomplete_curly_braces
assert_equal(["{{.}", " "], tokenize('{{.} '))
assert_equal(["{{}", "%}"], tokenize('{{}%}'))
assert_equal(["{{}}", "}"], tokenize('{{}}}'))
end
def test_unmatching_start_and_end
assert_equal(["{{%}"], tokenize('{{%}'))
assert_equal(["{{%%%}}"], tokenize('{{%%%}}'))
assert_equal(["{%", "}}"], tokenize('{%}}'))
assert_equal(["{%%}", "}"], tokenize('{%%}}'))
end
private private
def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil) def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil)