Compare commits

...
Author SHA1 Message Date
Tobi Lutke 9416d7a9ef Refactor CompiledTemplate to handle include/render internally
- render() now accepts Liquid::Context or Hash
- include/render handled internally using file_system from registers
- Only yields to block for truly external tags/filters
- Cleaner separation of concerns
2025-12-31 13:26:24 -05:00
Tobi Lutke 542deb4a21 Fix compiled template external calls
- Don't undef __send__ (causes warnings)
- Dynamic include/render now call __external__.call(:include/render, ...)
- Default external handler raises FileSystemError for missing assets
- Enables proper yield-based external call handling
2025-12-31 14:20:36 -04:00
Tobi Lutke c5a44be104 perf: optimize compiled template allocations and performance
Major optimizations to reduce allocations and improve execution speed:

1. For loops: Replace catch/throw with while + break flag
   - Uses while loop with index instead of .each with catch/throw
   - Break implemented with flag variable, continue with next
   - Result: 18% fewer allocations, 85% faster for simple loops

2. Forloop property inlining
   - Inline forloop.index as (__idx__ + 1), forloop.first as (__idx__ == 0), etc.
   - Completely eliminates forloop hash allocation when all properties inlinable
   - Result: Loop with forloop went from +46% MORE to -16% FEWER allocations

3. LR.to_array helper with EMPTY_ARRAY constant
   - Centralized array conversion with frozen empty array for nil
   - Avoids allocations for empty collections

4. Inline LR.truthy? calls
   - Replace LR.truthy?(x) with (x != nil && x != false)
   - Eliminates method call overhead in conditions

5. Keep Time methods available in sandbox for date filter

Overall results:
- Allocations: 3.5% MORE -> 24% FEWER (27% improvement)
- Time: 64% faster -> 89% faster (25% improvement)

Also adds:
- compile_profiler.rb for measuring allocations/performance
- compile_acceptance_test.rb for output equivalence testing
- OPTIMIZATION.md documenting optimization status
2025-12-31 13:24:33 -04:00
Tobi Lutke 652b9c0897 Add to_ruby convention for custom tag compilation
Tags can now implement to_ruby(code, compiler) to provide their own
optimized Ruby code generation. This allows:

1. Third-party tags to participate in compilation
2. Shopify-specific tags to be lowered to Ruby
3. Better performance for frequently-used custom tags

Priority order for tag compilation:
1. tag.to_ruby(code, compiler) - Custom implementation
2. Built-in compiler (IfCompiler, ForCompiler, etc.)
3. Yield to caller as external tag

Also updated PROJECT.md with:
- External calls yielding documentation
- to_ruby convention documentation
2025-12-31 12:33:14 -04:00
Tobi Lutke 6c0d599c89 Yield to caller for external tags and filters
Instead of trying to handle external tags/filters inside the sandbox,
yield back to the caller with [:tag, ...] or [:filter, ...] args.

This cleanly separates concerns:
- Sandbox handles compiled template logic
- Caller handles external calls with full Ruby access

API:
  compiled.render(assigns) do |call_type, *args|
    case call_type
    when :tag
      tag_var, tag_assigns = args
      # Handle with full Liquid context
    when :filter
      filter_name, input, filter_args = args
      # Handle with custom filter handler
    end
  end

If no block is given, a default handler is used that:
- Renders external tags using Liquid::Context
- Calls filter methods via filter_handler

Also:
- Keep public_send in sandbox (safe, only calls public methods)
- Load date/time libs into sandbox for date filter support
- Preserve Date, DateTime, Time constants after lock
2025-12-31 12:32:41 -04:00
Tobi Lutke 560d2ce9d4 Update compilers to use LR.method() calls
Replace inline helper calls with LR runtime methods:
- __to_s__() -> LR.to_s()
- __to_number__() -> LR.to_number()
- __to_integer__() -> LR.to_integer()
- __truthy__() -> LR.truthy?()
- __output_value__() -> LR.output()
- __lookup__() -> LR.lookup()

Filter compiler now generates calls like:
- LR.escape_html(), LR.url_encode(), LR.base64_encode()
- LR.truncate(), LR.truncatewords(), LR.slice()
- LR.default(), LR.date()

This reduces generated code size significantly since helpers
are defined once in the pre-loaded runtime.
2025-12-31 12:20:26 -04:00
Tobi Lutke 154343e64f Integrate Box and runtime into compiled template execution
- compiled_template.rb: Use Liquid::Box for secure execution on Ruby 4.0+
  - Creates box, loads runtime, locks, then evals template code
  - Provides render() method and secure? check
  - Falls back to insecure eval with warning on Ruby < 4.0

- ruby_compiler.rb: Remove inline helper generation
  - Helpers now provided by pre-loaded LR module
  - Generated code is much smaller (just control flow + LR calls)

- compile.rb: Update documentation for new security model

- template.rb: Update compile_to_ruby docs
2025-12-31 12:20:19 -04:00
Tobi Lutke 6e680a35b3 Add LR runtime module with pre-loaded helpers
The LR module provides all helper methods for compiled templates.
It is loaded into the sandbox BEFORE lock!, so helpers are defined
once and shared by all templates.

Helpers include:
- Type conversion: to_s, to_number, to_integer
- Output: output (handles nil, arrays, BigDecimal formatting)
- Lookup: lookup (hash/array access, Drop context support)
- Encoding: escape_html, url_encode/decode, base64_encode/decode
- Filters: truncate, truncatewords, slice, date, default, etc.

Method references to CGI, Base64, BigDecimal are captured at load
time, enabling safe use of these libraries within the sandbox.

Design principle: Maximize work in pre-loaded runtime, minimize
generated template code.
2025-12-31 12:20:10 -04:00
Tobi Lutke 9367b8b32e Add Liquid::Box for secure sandboxed template execution
Introduces Liquid::Box which wraps Ruby 4.0's Ruby::Box for secure
template execution. On Ruby < 4.0, provides a polyfill with security
warnings.

Key features:
- Detects Ruby::Box availability at load time
- Loads safe libraries (CGI, Base64, BigDecimal) into sandbox
- Neuters dangerous methods (file IO, process control, eval, etc.)
- Preserves user constants defined before lock!
- Provides setup_gem_load_paths! to enable gem requires in box

Security model: It is safe to expose side-effect-free, non-IO methods
that don't leak objects with dangerous methods. The sandbox blocks
capabilities, not data.
2025-12-31 12:19:53 -04:00
Claude 5cdbce7d6e Improve benchmark output clarity
Add explicit summary showing pre-compiled is X times FASTER,
and explain that "X slower" in comparison means relative to
the fastest option (pre-compiled Ruby). Higher i/s = faster.
2025-12-31 15:25:54 +00:00
Claude 520e86b8c8 Add Liquid Drop support for compiled templates
- Create CompiledContext class that duck-types to Liquid::Context
- CompiledContext provides: variable lookup, registers, strict_* flags
- Update __lookup__ helper to:
  - Set context on Drops BEFORE accessing their methods
  - Call to_liquid on objects before lookup
  - Set context on nested Drop results
- Change __lookup__ from def to lambda to capture __context__ closure
- Update expression compiler to use __lookup__.call() syntax
- Add CompiledTemplate.call options: registers, strict_variables, strict_filters

Tests added:
- test_compile_with_drop: Basic Drop property access
- test_compile_with_drop_context_access: Drop using context to access other vars
- test_compile_with_nested_drops: Chained Drop lookups
- test_compile_with_forloop_drop: Built-in forloop compatibility
- test_compile_with_registers: Drop accessing registers via context

All 51 tests pass, 30/30 benchmark templates produce matching output.
2025-12-31 15:21:30 +00:00
Claude 54e3d2328a Fix code injection vulnerability in template name handling
Use .inspect for all template/partial names inserted into generated
code strings to prevent code injection via maliciously crafted
template names like: {% render "foo'); system('rm -rf /'); #" %}
2025-12-31 15:05:20 +00:00
Claude 7be2922f91 Fix compiler output equivalence and add comprehensive tests
- Fix forloop.first/last/size lookups to try hash key before method call
- Fix tablerow cols parameter to use attributes['cols'] not @cols
- Fix tablerow output format to match interpreter (newlines, row boundaries)
- Fix capture compiler to access @body directly instead of iterating nodelist
- Add CompiledTemplate class to encapsulate code and external_tags
- Add external filter support via filter_handler
- Add debug mode warnings for external tag/filter calls
- Add Ruby 3.3 compatibility shim for peek_byte/scan_byte
- Add comprehensive unit tests for output equivalence
- Add benchmark comparing compiled vs interpreted rendering

All 30 test templates now produce identical output between compiled
Ruby and interpreted Liquid. Pre-compiled Ruby is 1.68x faster.
2025-12-31 14:58:06 +00:00
Claude e0f856fd11 Add Liquid to Ruby compiler for optimized template rendering
This adds a new `compile_to_ruby` method to Liquid::Template that compiles
Liquid templates to pure Ruby code. The compiled code can be eval'd to create
a proc that renders templates without needing the Liquid library at runtime.

## Features

- Compiles all standard Liquid tags: if/unless/case, for, assign, capture,
  cycle, increment/decrement, raw, echo, break/continue, comment, tablerow
- Compiles variable expressions with filter chains to direct Ruby method calls
- Supports static partial inlining ({% render %} and {% include %} with string
  literals are loaded and compiled at compile time)
- Dynamic partial support via runtime callbacks (__render_dynamic__, __include_dynamic__)
- Debug mode with source comments for error tracing (lightweight source map)
- SourceMapper utility to trace runtime errors back to Liquid source

## Optimization Opportunities

The compiled Ruby code has significant performance advantages:

1. No Context object - variables accessed directly from assigns hash
2. No filter invocation overhead - direct Ruby method calls
3. No resource limits tracking - no per-node render score updates
4. No stack-based scoping - uses Ruby's native block scoping
5. Direct string concatenation - no render_to_output_buffer abstraction
6. Native control flow - break/continue use Ruby's throw/catch
7. No to_liquid calls - values used directly
8. No profiling hooks - no profiler overhead
9. No exception rendering - errors propagate naturally

## Usage

```ruby
template = Liquid::Template.parse("Hello, {{ name }}!")
ruby_code = template.compile_to_ruby
render_proc = eval(ruby_code)
result = render_proc.call({ "name" => "World" })
# => "Hello, World!"

# With debug mode for error tracing:
ruby_code = template.compile_to_ruby(debug: true)
```

## Limitations

- Dynamic {% render %} and {% include %} require runtime callback methods
- Custom tags need explicit compiler implementations
- Custom filters must be available at runtime
2025-12-31 14:21:58 +00:00
James MengandGitHub c6f05eaf83 Merge pull request #1955 from Shopify/jm/trim_doc_tag_desc
Remove {{ foo }} and {{ bar }} from `doc` tag description
2025-05-06 15:29:29 -07:00
James Meng eabbd5cb6f Remove {{ foo }} and {{ bar }} from doc tag description 2025-05-06 15:20:35 -07:00
Marco Concetto RudilossoandGitHub ea864f1177 Merge pull request #1951 from Shopify/actual-revert
Fully revert calling to_s on filter array
2025-04-14 17:49:47 +02:00
Marco Concetto Rudilosso c34dd812c5 Fully revert calling to_s on filter array 2025-04-14 17:36:45 +02:00
Marco Concetto RudilossoandGitHub cc04892e54 Merge pull request #1943 from Shopify/revert-to-s
Revert `Utils.to_s` on all array filters
2025-04-14 17:22:10 +02:00
Marco Concetto Rudilosso f676e699a4 rollback changes to tests 2025-04-14 11:24:01 +02:00
Marco Concetto Rudilosso fc0f7f44c1 use Utils.to_s on property error 2025-04-14 11:17:59 +02:00
Marco Concetto Rudilosso b459eb656f remove test 2025-04-14 11:17:46 +02:00
Marco Concetto Rudilosso c6dcf3e714 add test 2025-04-14 11:17:46 +02:00
Marco Concetto Rudilosso 4dae678c63 Revert "Stringify properties before filtering (#1929)"
This reverts commit f5d6a36574.
2025-04-14 11:17:46 +02:00
Marco Concetto Rudilosso 4c07ff920b Revert "Always stringify properties in all array filters (#1936)"
This reverts commit aa1640035f.
2025-04-14 11:17:46 +02:00
Ian Ker-SeymerandGitHub dbe709c3bf Use to_liquid_value in uniq filter (#1948)
* Use to_liquid_value in uniq filter

* Bump version to 5.8.4
2025-04-09 15:00:01 -04:00
2b75bfaff4 Fix regression when using empty/nil properties with array filters (#1944)
* Make all array filters that use `filter_array` util process empty string and nil correctly

* up version

* fix ordering of checks

* also do it for map

* Do not raise property error

* Gracefully empty property in map filter

---------

Co-authored-by: Marco Concetto Rudilosso <[email protected]>
2025-04-04 11:27:29 -04:00
James MengandGitHub 87bc6e7cfa Merge pull request #1940 from Shopify/jm/update_snippet_url_links
Update snippet URLs in documentation links to point to newly created snippet page
2025-04-01 10:58:54 -07:00
James Meng 7f122aeed2 Fix snippet URLs in documentation links 2025-03-31 17:08:31 -07:00
aa1640035f Always stringify properties in all array filters (#1936)
* Always stringify sum property.

* Add test

* always stringify properties in all array filters

* fix syntax error

* up version

---------

Co-authored-by: Dominic Petrick <[email protected]>
2025-03-19 13:50:01 -04:00
Ian Ker-SeymerandGitHub f5d6a36574 Stringify properties before filtering (#1929) 2025-03-17 17:43:57 -04:00
Ian Ker-SeymerandGitHub c5711c095f Improve docs of date filter (#1920) 2025-03-13 17:36:29 -04:00
James MengandGitHub 284f5fb647 Merge pull request #1928 from Shopify/jm/add_ld_link_doc_tag
Add a link to the LiquidDoc tooling reference in `doc` tag documentation
2025-03-13 09:48:40 -07:00
James MengandGitHub 21432928d0 Use full relative path for hyperlink 2025-03-13 09:47:57 -07:00
James Meng 1783c0c084 Add a link to the LiquidDoc reference in doc tag documentation comment 2025-03-12 15:31:28 -07:00
Guilherme CarreiroandGuilherme Carreiro e38f730c00 Update LiquidDoc documentation 2025-03-07 18:59:59 +01:00
2d0442798b chore: Add RUBYOPT configuration to the tests (#1859)
Co-authored-by: Ian Ker-Seymer <[email protected]>
2025-02-26 14:28:17 -05:00
Guilherme CarreiroandGuilherme Carreiro 6453a0ea48 Implement nodelist in the Doc tag so it may be visited 2025-02-26 13:14:39 +01:00
Guilherme CarreiroandGuilherme Carreiro a398b4cc74 Fix History.md 2025-02-25 08:50:46 +01:00
Guilherme CarreiroandGuilherme Carreiro cca9fe99cf Bump version to 5.8.0 2025-02-25 08:50:46 +01:00
Guilherme CarreiroandGuilherme Carreiro 17d327988d Rename {% doc %} constant strictly validates the abscense of args 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro f643af4bac Update the implementation to make {% doc %} as strict as {% raw %} 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro ae8a0a86ac Remove misleading unit test (thank you, @EvilGenius13) 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro b439d0da53 Update {% doc %} to no longer support nested tags (as {% comment %} does) 2025-02-20 12:37:09 +01:00
Guilherme CarreiroandGuilherme Carreiro 16592cfb8f Add support to LiquidDoc with the new {% doc %} tag 2025-02-20 12:37:09 +01:00
Chris AtLeeandGitHub 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 AtLeeandGitHub 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 StoiberandGitHub 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 GoandGitHub 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 CarreiroandGuilherme Carreiro 8dd9279265 Fix release date on History.md 2025-01-24 15:34:48 +01:00
Guilherme CarreiroandGuilherme Carreiro bf1419b8ac Apply the same fix for find_index and has 2025-01-24 08:39:01 +01:00
Guilherme CarreiroandGuilherme Carreiro 5718c4cee2 Fix the find filter to return nil when filtering empty arrays 2025-01-24 08:39:01 +01:00
Guilherme CarreiroandGitHub 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
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 <[email protected]>

* Update History.md

Co-authored-by: Ian Ker-Seymer <[email protected]>

---------

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

* Bump to v5.6.5

---------

Co-authored-by: Dominic Petrick <[email protected]>
2025-01-15 16:36:53 -05:00
ecf25ea83d Add the "Releasing" section to CONTRIBUTING.md (#1891)
* Add 'Releasing' workflow to 'CONTRIBUTING.md'

* Update CONTRIBUTING.md

Co-authored-by: Michael Go <[email protected]>

---------

Co-authored-by: Michael Go <[email protected]>
2025-01-14 19:07:11 +01:00
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 <[email protected]>
Co-authored-by: Anders Søgaard <[email protected]>
2025-01-14 10:33:31 +01:00
Michael GoandGitHub 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 GoandGitHub 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 GoandGitHub 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 GoandGitHub 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 SteinandGitHub 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-SeymerandGitHub b4667adadf Bump to v5.6.0 (#1876) 2024-12-19 15:01:16 -05:00
Bahar PourazarandGitHub 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 PourazarandGitHub 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-SeymerandGitHub 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-SeymerandGitHub 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 SehlandGitHub 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 GoandGitHub 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-SeymerandGitHub b0cba0bfd2 Remove Liquid.cache_classes option (#1847) 2024-11-04 14:41:56 -05:00
Michael GoandGitHub 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 GoandGitHub 06f44226c0 Merge pull request #1846 from Shopify/env-propgating
propagate Environment on new Context creation
2024-11-04 15:33:50 -04:00
Michael GoandGitHub 4bd22a26dc Merge pull request #1845 from Shopify/remove-tag-registry
remove TagRegistry
2024-11-04 15:33:30 -04:00
Michael Go 29986d3704 remove TagRegistry 2024-11-04 15:22:56 -04:00
Thierry JoyalandGitHub 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
99 changed files with 7026 additions and 494 deletions
+17 -1
View File
@@ -13,8 +13,22 @@ jobs:
entry:
- { ruby: 3.0, allowed-failure: false } # minimum supported
- { ruby: 3.2, allowed-failure: false }
- { ruby: 3.3, allowed-failure: false } # latest
- { ruby: 3.3, allowed-failure: false }
- { ruby: 3.3, allowed-failure: false }
- { ruby: 3.4, allowed-failure: false } # latest
- {
ruby: 3.4,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: ruby-head, allowed-failure: false }
- {
ruby: ruby-head,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
name: Test Ruby ${{ matrix.entry.ruby }}
steps:
- uses: actions/checkout@v3
@@ -25,6 +39,8 @@ jobs:
bundler: latest
- run: bundle exec rake
continue-on-error: ${{ matrix.entry.allowed-failure }}
env:
RUBYOPT: ${{ matrix.entry.rubyopt }}
memory_profile:
runs-on: ubuntu-latest
+1 -1
View File
@@ -1 +1 @@
3.3.4
3.4.1
+8
View File
@@ -26,3 +26,11 @@
* If it makes sense, add tests for your code and/or run a performance benchmark
* Make sure all tests pass (`bundle exec rake`)
* 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)
+5 -4
View File
@@ -13,18 +13,19 @@ group :benchmark, :test do
gem 'benchmark-ips'
gem 'memory_profiler'
gem 'terminal-table'
gem "lru_redux"
install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do
gem 'stackprof'
end
end
group :development do
gem "webrick"
end
group :test do
gem 'rubocop', '~> 1.61.0'
gem 'rubocop-shopify', '~> 2.12.0', require: false
gem 'rubocop-performance', require: false
platform :mri, :truffleruby do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'main'
end
end
+71
View File
@@ -1,5 +1,76 @@
# Liquid Change Log
## 5.8.1 (unreleased)
## 5.8.1
* Fix `{% doc %}` tag to be visitable [Guilherme Carreiro]
## 5.8.0
* Introduce the new `{% doc %}` tag [Guilherme Carreiro]
## 5.7.3
* Raise Liquid::SyntaxError when parsing invalidly encoded strings [Chris AtLee]
## 5.7.2 2025-01-31
* Fix array filters to not support nested properties [Guilherme Carreiro]
## 5.7.1 2025-01-24
* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays [Guilherme Carreiro]
* Fix the `has` filter to return `false` when filtering empty arrays [Guilherme Carreiro]
## 5.7.0 2025-01-16
### Features
* Add `find`, `find_index`, `has`, and `reject` filters to arrays [Guilherme Carreiro]
* Compatibility with Ruby 3.4 [Ian Ker-Seymer]
## 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.
+70
View File
@@ -0,0 +1,70 @@
# Liquid Compiled Template Optimization Log
This document tracks optimizations made to the compiled Liquid template engine.
Each entry shows before/after code and measured impact.
---
## Baseline Measurement
**Date:** 2024-12-31
**Commit:** (pending profiler implementation)
### Current State
The compiled template engine generates Ruby code from Liquid templates.
Before optimizations, here's a sample of generated code for a simple loop:
```ruby
# Template: {% for product in products %}{{ forloop.index }}: {{ product.name }}{% endfor %}
->(assigns, __context__, __external__) do
__output__ = +""
__coll1__ = assigns["products"]
__coll1__ = __coll1__.to_a if __coll1__.is_a?(Range)
__len3__ = __coll1__.respond_to?(:length) ? __coll1__.length : 0
__idx2__ = 0
catch(:__loop__break__) do
(__coll1__.respond_to?(:each) ? __coll1__ : []).each do |__item__|
catch(:__loop__continue__) do
assigns["product"] = __item__
assigns['forloop'] = {
'name' => "product-products",
'length' => __len3__,
'index' => __idx2__ + 1,
'index0' => __idx2__,
'rindex' => __len3__ - __idx2__,
'rindex0' => __len3__ - __idx2__ - 1,
'first' => __idx2__ == 0,
'last' => __idx2__ == __len3__ - 1,
}
__output__ << LR.output(LR.lookup(assigns["forloop"], "index", __context__))
__output__ << ": "
__output__ << LR.output(LR.lookup(assigns["product"], "name", __context__))
end
__idx2__ += 1
end
end
assigns.delete("product")
assigns.delete('forloop')
__output__
end
```
### Issues Identified
1. **catch/throw overhead** - Used even when no break/continue in loop
2. **Hash allocation per iteration** - 8 key/value pairs computed every time
3. **respond_to? checks** - Redundant after type is known
4. **LR.lookup for forloop** - Unnecessary indirection for known hash
5. **String literals not frozen** - Allocates on each render
6. **Output buffer grows dynamically** - No pre-allocation
---
## Optimization Log
<!-- Entries will be added here as optimizations are implemented -->
+1 -1
View File
@@ -91,7 +91,7 @@ Liquid::Template.parse(<<~LIQUID, environment: email_environment)
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.
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
+24 -7
View File
@@ -43,8 +43,6 @@ task :test do
Rake::Task['base_test'].invoke
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_C'] = '1'
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
@@ -73,7 +71,7 @@ end
namespace :benchmark do
desc "Run the liquid benchmark with lax parsing"
task :run do
task :lax do
ruby "./performance/benchmark.rb lax"
end
@@ -82,11 +80,30 @@ namespace :benchmark do
ruby "./performance/benchmark.rb strict"
end
desc "Run the liquid benchmark with both lax and strict parsing"
task run: [:lax, :strict]
desc "Run unit benchmarks"
task :unit do
Dir["./performance/unit/*_benchmark.rb"].each do |file|
puts "🧪 Running #{file}"
ruby file
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
+5 -6
View File
@@ -21,6 +21,8 @@
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
require "strscan"
module Liquid
FilterSeparator = /\|/
ArgumentSeparator = ','
@@ -44,15 +46,12 @@ module Liquid
VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
singleton_class.send(:attr_accessor, :cache_classes)
self.cache_classes = true
HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
end
require "liquid/version"
require "liquid/deprecations"
require "liquid/const"
require "liquid/template/tag_registry"
require 'liquid/standardfilters'
require 'liquid/file_system'
require 'liquid/parser_switching'
@@ -72,8 +71,6 @@ require 'liquid/extensions'
require 'liquid/errors'
require 'liquid/interrupts'
require 'liquid/strainer_template'
require 'liquid/strainer_factory'
require 'liquid/expression'
require 'liquid/context'
require 'liquid/tag'
require 'liquid/block_body'
@@ -82,6 +79,7 @@ require 'liquid/variable'
require 'liquid/variable_lookup'
require 'liquid/range_lookup'
require 'liquid/resource_limits'
require 'liquid/expression'
require 'liquid/template'
require 'liquid/condition'
require 'liquid/utils'
@@ -91,3 +89,4 @@ require 'liquid/partial_cache'
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
require 'liquid/box'
+507
View File
@@ -0,0 +1,507 @@
# frozen_string_literal: true
# Liquid::Box - Secure sandboxed execution environment for compiled Liquid templates
#
# On Ruby 4.0+, this uses the native Ruby::Box for true isolation.
# On earlier Ruby versions, this provides a polyfill that WARNS about insecurity.
#
# == SECURITY WARNING
#
# The polyfill on Ruby < 4.0 provides NO REAL SECURITY. It's a compatibility shim
# that allows code to run, but malicious templates could potentially escape.
# Use Ruby 4.0+ in production for actual sandboxing.
#
# == Usage
#
# template = Liquid::Template.parse("Hello {{ name }}!")
# compiled = template.compile_to_ruby
#
# # compiled is a Liquid::CompiledTemplate which wraps a Box
# result = compiled.render({ "name" => "World" })
# # => "Hello World!"
#
# # Access the generated Ruby code:
# puts compiled.source
#
module Liquid
# Check if we have Ruby 4.0's native Box AND it's enabled
# Ruby::Box may be defined but disabled (requires RUBY_BOX=1 env var before Ruby starts)
RUBY_BOX_AVAILABLE = begin
if defined?(Ruby::Box)
# Try to create a box to see if it's actually enabled
test_box = Ruby::Box.new
true
else
false
end
rescue RuntimeError => e
# Ruby::Box exists but is disabled
false
end
unless RUBY_BOX_AVAILABLE
# Warn once on load that we're using the insecure polyfill
warn "[Liquid::Box] WARNING: Ruby::Box not available or disabled. " \
"Using INSECURE polyfill. Compiled templates are NOT sandboxed! " \
"(Ruby 4.0+ with RUBY_BOX=1 required for secure execution)" if $VERBOSE
end
# Polyfill for Ruby::Box when not available (Ruby < 4.0)
# This provides the same API but NO SECURITY - it just evals code in the main environment.
module BoxPolyfill
class Box
def initialize
@constants = {}
@binding = TOPLEVEL_BINDING.dup
end
def eval(code)
::Kernel.eval(code, @binding)
end
def const_get(name)
::Object.const_get(name)
end
end
end
# Liquid::Box wraps either Ruby::Box (secure) or BoxPolyfill::Box (insecure)
#
# This provides a secure sandboxed environment for executing compiled Liquid templates.
# On Ruby 4.0+, templates run in true isolation with dangerous methods removed.
# On Ruby < 4.0, templates run without sandboxing (development/testing only).
#
class Box
attr_reader :box
class << self
# Returns true if we have real sandboxing (Ruby 4.0+)
def secure?
RUBY_BOX_AVAILABLE
end
# Create a pre-configured box for Liquid template execution.
# This is the recommended way to get a Box instance.
def create_for_liquid
box = new
box.load_liquid_runtime!
box.lock!
box
end
end
def initialize
@box = RUBY_BOX_AVAILABLE ? Ruby::Box.new : BoxPolyfill::Box.new
@locked = false
@user_constants = []
@warned_insecure = false
end
# Load code into the sandbox before locking.
# Use this to define filters, helpers, and the Liquid runtime.
def load_runtime(code)
raise "Cannot load runtime after lock!" if @locked
before = @box.eval("Object.constants")
@box.eval(code)
after = @box.eval("Object.constants")
@user_constants += (after - before).map(&:to_s)
end
# Load the standard Liquid runtime helpers (LR module).
# Call this before lock! to set up the execution environment.
# The runtime provides all helper methods that compiled templates use.
def load_liquid_runtime!
raise "Cannot load runtime after lock!" if @locked
if RUBY_BOX_AVAILABLE
# Add gem paths to box's load_path so it can find base64, bigdecimal, etc.
# These are safe, side-effect-free libs that only provide pure functions
setup_gem_load_paths!
# Load dependencies INTO the box - we need the actual libraries,
# not reimplementations, to handle all edge cases correctly
@box.require('cgi')
@box.require('base64')
@box.require('bigdecimal')
@box.require('bigdecimal/util') # For String#to_d etc.
@box.require('date') # For date filter
@box.require('time') # For Time.parse
# Now load the runtime which captures method references from these
@box.require(RUNTIME_PATH)
else
# Polyfill: just require it normally
require 'cgi'
require 'base64'
require 'bigdecimal'
require 'bigdecimal/util'
require 'date'
require 'time'
require RUNTIME_PATH
end
# Track constants to preserve after lock
@user_constants << "LR"
@user_constants << "LiquidRuntime"
@user_constants << "CGI"
@user_constants << "Base64"
@user_constants << "BigDecimal"
@user_constants << "Date"
@user_constants << "DateTime"
@user_constants << "Time"
@user_constants << "Liquid" # For Liquid::Compile::CompiledContext
end
# Add gem paths to the box's load_path so require works for gems
def setup_gem_load_paths!
return unless RUBY_BOX_AVAILABLE
# Find gem paths from the main environment's $LOAD_PATH
# and from Gem.path if available
gem_lib_paths = []
# Method 1: Find from $LOAD_PATH entries containing "gems"
$LOAD_PATH.each do |path|
gem_lib_paths << path if path.include?('/gems/')
end
# Method 2: Use Gem.path if available
if defined?(Gem) && Gem.respond_to?(:path)
Gem.path.each do |gem_path|
Dir.glob("#{gem_path}/gems/*/lib").each do |lib_path|
gem_lib_paths << lib_path
end
end
end
# Add unique paths to box's load_path
gem_lib_paths.uniq.each do |path|
@box.load_path << path unless @box.load_path.include?(path)
end
end
# Lock the sandbox. After this:
# - No more runtime can be loaded
# - Dangerous methods are removed (on Ruby 4.0+)
# - Templates can be compiled and executed
def lock!
return if @locked
if RUBY_BOX_AVAILABLE
apply_sandbox!
else
warn_insecure!
end
@locked = true
end
def locked?
@locked
end
# Returns true if this box provides real security (Ruby 4.0+)
def secure?
RUBY_BOX_AVAILABLE
end
# Evaluate code in the sandbox.
# Use this to compile templates into the sandbox.
def eval(code)
raise "Must call lock! before eval" unless @locked
warn_insecure! unless RUBY_BOX_AVAILABLE
@box.eval(code)
end
# Get a constant from the sandbox by name.
def const_get(name)
@box.const_get(name)
end
def [](name)
const_get(name)
end
private
def warn_insecure!
return if @warned_insecure
@warned_insecure = true
$stderr.puts <<~WARNING
SECURITY WARNING: Liquid::Box running WITHOUT sandboxing
Ruby::Box requires Ruby 4.0+. On earlier versions, compiled Liquid templates
execute with FULL Ruby capabilities. This is NOT SECURE for untrusted input.
For production use with untrusted templates, upgrade to Ruby 4.0+.
WARNING
end
# Apply sandbox restrictions (Ruby 4.0+ only)
def apply_sandbox!
neuter_file_system!
neuter_process_control!
neuter_concurrency!
neuter_introspection!
neuter_serialization!
neuter_time!
neuter_environment!
neuter_kernel!
neuter_basic_object!
neuter_object!
neuter_main_singleton!
neuter_module!
cleanup_globals!
remove_dangerous_constants!
end
def neuter_file_system!
@box.eval(<<~'RUBY')
class << File
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << IO
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << Dir
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class IO
[:read, :write, :gets, :puts, :print, :readline, :readlines, :getc, :getbyte,
:sysread, :syswrite, :close, :eof, :eof?, :rewind, :seek].each do |m|
undef_method(m) rescue nil
end
end
RUBY
end
def neuter_process_control!
@box.eval(<<~'RUBY')
class << Process
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << Signal
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_concurrency!
@box.eval(<<~'RUBY')
class << Thread
[:new, :start, :fork, :kill, :exit, :pass, :stop, :main, :current, :list,
:abort_on_exception, :abort_on_exception=].each { |m| undef_method(m) rescue nil }
end
class << Fiber
[:new, :yield, :current].each { |m| undef_method(m) rescue nil }
end
if defined?(Ractor)
class << Ractor
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
end
RUBY
end
def neuter_introspection!
@box.eval(<<~'RUBY')
class << ObjectSpace
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
class << GC
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
if defined?(RubyVM)
class << RubyVM
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
if defined?(RubyVM::InstructionSequence)
class << RubyVM::InstructionSequence
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
end
end
class << TracePoint
[:new, :stat, :trace].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_serialization!
@box.eval(<<~'RUBY')
class << Marshal
[:dump, :load, :restore].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_time!
# Time is mostly safe for date filters - only neuter methods that could be used
# to manipulate system state or sleep/wait.
# Keep: now, at, parse, mktime - needed for date filter
# Remove: nothing for now - Time is pure computation
#
# Note: If you want stricter isolation, templates should receive "now" via assigns
# @box.eval(<<~'RUBY')
# class << Time
# [:now, :new, :at, :mktime, :local, :utc, :gm].each { |m| undef_method(m) rescue nil }
# end
# RUBY
end
def neuter_environment!
@box.eval(<<~'RUBY')
ENV.clear rescue nil
class << ENV
instance_methods(false).each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_kernel!
@box.eval(<<~'RUBY')
module Kernel
[:eval, :`, :system, :exec, :spawn, :fork, :binding,
:open, :require, :require_relative, :load, :autoload, :autoload?,
:gets, :readline, :readlines, :select, :test,
:trap, :exit, :exit!, :abort, :at_exit, :syscall, :sleep,
:puts, :print, :printf, :putc, :p, :pp, :warn,
:caller, :caller_locations, :set_trace_func, :trace_var, :untrace_var,
:global_variables, :local_variables,
:gem, :gem_original_require, :Pathname,
].each { |m| undef_method(m) rescue nil }
end
class << Kernel
[:eval, :`, :system, :exec, :spawn, :fork, :binding,
:open, :require, :require_relative, :load,
:puts, :print, :p, :pp, :warn,
].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_basic_object!
@box.eval(<<~'RUBY')
class BasicObject
undef_method(:instance_eval) rescue nil
undef_method(:instance_exec) rescue nil
# Don't undef __send__ - it causes warnings and is equivalent to send
# which we already restrict via public_send
end
RUBY
end
def neuter_object!
@box.eval(<<~'RUBY')
class Object
# Keep public_send - it's safe (only calls public methods) and useful
[:gem, :gem_original_require, :require, :require_relative, :load,
:display, :define_singleton_method,
:instance_variable_set, :remove_instance_variable,
:extend, :send,
].each { |m| undef_method(m) rescue nil }
end
RUBY
end
def neuter_main_singleton!
# 'using' is defined on main's singleton class, must remove before undef_method is gone
@box.eval(<<~'RUBY')
class << self
undef_method(:using) rescue nil
end
RUBY
end
def neuter_module!
@box.eval(<<~'RUBY')
class Module
undef_method(:refine) rescue nil
undef_method(:using) rescue nil
undef_method(:const_set) rescue nil
undef_method(:remove_const) rescue nil
undef_method(:include) rescue nil
undef_method(:prepend) rescue nil
undef_method(:extend) rescue nil
undef_method(:class_eval) rescue nil
undef_method(:module_eval) rescue nil
undef_method(:class_exec) rescue nil
undef_method(:module_exec) rescue nil
undef_method(:define_method) rescue nil
undef_method(:alias_method) rescue nil
undef_method(:module_function) rescue nil
undef_method(:prepend_features) rescue nil
undef_method(:append_features) rescue nil
undef_method(:extend_object) rescue nil
undef_method(:public) rescue nil
undef_method(:private) rescue nil
undef_method(:protected) rescue nil
undef_method(:attr) rescue nil
undef_method(:attr_reader) rescue nil
undef_method(:attr_writer) rescue nil
undef_method(:attr_accessor) rescue nil
# Remove escape hatches LAST
undef_method(:remove_method) rescue nil
undef_method(:undef_method) rescue nil
undef_method(:send) rescue nil
undef_method(:public_send) rescue nil
end
class Class
undef_method(:send) rescue nil
undef_method(:public_send) rescue nil
end
RUBY
end
def cleanup_globals!
@box.eval(<<~'RUBY')
$stdin = nil
$stdout = nil
$stderr = nil
$LOAD_PATH.clear rescue nil
$LOAD_PATH.freeze rescue nil
$LOADED_FEATURES.clear rescue nil
$LOADED_FEATURES.freeze rescue nil
ARGV.clear rescue nil
ARGV.freeze rescue nil
RUBY
end
def remove_dangerous_constants!
keep = %w[
BasicObject Object Module Class Kernel
String Integer Float Numeric Rational Complex
Array Hash Range Set
Symbol Regexp MatchData
TrueClass FalseClass NilClass
Proc Method UnboundMethod
Struct Data
Comparable Enumerable Enumerator
StandardError RuntimeError ArgumentError TypeError NameError
NoMethodError KeyError IndexError StopIteration FrozenError
ZeroDivisionError RangeError FloatDomainError LocalJumpError
Math Random
Exception SystemStackError
] + @user_constants
@box.eval(<<~RUBY)
_keep = #{keep.inspect}
(Object.constants.map(&:to_s) - _keep).each do |c|
Object.send(:remove_const, c.to_sym) rescue nil
end
(Kernel.constants.map(&:to_s) - _keep).each do |c|
Kernel.send(:remove_const, c.to_sym) rescue nil
end
RUBY
end
# Path to the runtime file that gets loaded into the sandbox
RUNTIME_PATH = File.expand_path('compile/runtime.rb', __dir__)
end
end
+88
View File
@@ -0,0 +1,88 @@
# frozen_string_literal: true
# Liquid Ruby Compiler
#
# This module provides the ability to compile Liquid templates to pure Ruby code.
# Compiled templates execute in a secure sandbox using Liquid::Box (on Ruby 4.0+).
#
# ## Usage
#
# template = Liquid::Template.parse("Hello, {{ name }}!")
# compiled = template.compile_to_ruby
#
# # Render securely (sandboxed on Ruby 4.0+)
# result = compiled.render({ "name" => "World" })
# # => "Hello, World!"
#
# # Access the generated Ruby source
# puts compiled.source
#
# # Check security status
# compiled.secure? # => true on Ruby 4.0+
#
# ## Security
#
# On Ruby 4.0+, compiled templates execute in a Ruby::Box sandbox that prevents:
# - File system access (File, IO, Dir)
# - Process control (system, exec, spawn, fork)
# - Network access (Socket, Net::HTTP)
# - Code loading (require, load, eval)
# - Dangerous metaprogramming (define_method, const_set, send)
#
# On Ruby < 4.0, a polyfill is used that prints a security warning to STDERR.
# The polyfill provides NO ACTUAL SECURITY - use Ruby 4.0+ in production.
#
# ## Performance Benefits
#
# Compiled templates are ~1.5x faster than interpreted Liquid because:
#
# 1. **No Context Object**: Variables accessed directly from assigns hash
# 2. **No Filter Dispatch**: Filters compiled to direct Ruby calls
# 3. **No Resource Limits**: No per-node overhead for limit tracking
# 4. **Native Scoping**: Ruby's block scoping instead of manual stacks
# 5. **Direct Concatenation**: Output built with << operations
# 6. **Native Control Flow**: break/continue use Ruby's throw/catch
# 7. **No to_liquid Calls**: Values used directly
# 8. **No Profiling Hooks**: No profiler overhead
#
# ## Limitations
#
# - {% render %} and {% include %} resolved at compile time when possible
# - Custom tags need explicit compiler implementations
# - Custom filters must be available at runtime
#
module Liquid
module Compile
autoload :CompiledTemplate, 'liquid/compile/compiled_template'
autoload :CompiledContext, 'liquid/compile/compiled_context'
autoload :CodeGenerator, 'liquid/compile/code_generator'
autoload :RubyCompiler, 'liquid/compile/ruby_compiler'
autoload :ExpressionCompiler, 'liquid/compile/expression_compiler'
autoload :FilterCompiler, 'liquid/compile/filter_compiler'
autoload :VariableCompiler, 'liquid/compile/variable_compiler'
autoload :BlockBodyCompiler, 'liquid/compile/block_body_compiler'
autoload :ConditionCompiler, 'liquid/compile/condition_compiler'
autoload :SourceMapper, 'liquid/compile/source_mapper'
module Tags
autoload :IfCompiler, 'liquid/compile/tags/if_compiler'
autoload :UnlessCompiler, 'liquid/compile/tags/unless_compiler'
autoload :CaseCompiler, 'liquid/compile/tags/case_compiler'
autoload :ForCompiler, 'liquid/compile/tags/for_compiler'
autoload :AssignCompiler, 'liquid/compile/tags/assign_compiler'
autoload :CaptureCompiler, 'liquid/compile/tags/capture_compiler'
autoload :CycleCompiler, 'liquid/compile/tags/cycle_compiler'
autoload :IncrementCompiler, 'liquid/compile/tags/increment_compiler'
autoload :DecrementCompiler, 'liquid/compile/tags/decrement_compiler'
autoload :RawCompiler, 'liquid/compile/tags/raw_compiler'
autoload :EchoCompiler, 'liquid/compile/tags/echo_compiler'
autoload :BreakCompiler, 'liquid/compile/tags/break_compiler'
autoload :ContinueCompiler, 'liquid/compile/tags/continue_compiler'
autoload :CommentCompiler, 'liquid/compile/tags/comment_compiler'
autoload :TableRowCompiler, 'liquid/compile/tags/tablerow_compiler'
autoload :RenderCompiler, 'liquid/compile/tags/render_compiler'
autoload :IncludeCompiler, 'liquid/compile/tags/include_compiler'
autoload :IfchangedCompiler, 'liquid/compile/tags/ifchanged_compiler'
end
end
end
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
module Liquid
module Compile
# BlockBodyCompiler compiles a BlockBody (a list of nodes) to Ruby code.
#
# A BlockBody contains:
# - String literals (text to output)
# - Variable expressions ({{ ... }})
# - Tags ({% ... %})
class BlockBodyCompiler
# Compile a BlockBody to Ruby code
# @param body [Liquid::BlockBody] The block body
# @param compiler [RubyCompiler] The main compiler instance
# @param code [CodeGenerator] The code generator
def self.compile(body, compiler, code)
return if body.nil?
nodelist = body.nodelist
return if nodelist.nil? || nodelist.empty?
nodelist.each do |node|
compile_node(node, compiler, code)
end
end
# Compile a single node
def self.compile_node(node, compiler, code)
case node
when String
compile_string(node, code)
when Variable
VariableCompiler.compile(node, compiler, code)
when Tag
compiler.send(:compile_tag, node, code)
else
raise CompileError, "Unknown node type in BlockBody: #{node.class}"
end
end
private
def self.compile_string(str, code)
return if str.empty?
code.line "__output__ << #{str.inspect}"
end
end
end
end
+82
View File
@@ -0,0 +1,82 @@
# frozen_string_literal: true
module Liquid
module Compile
# CodeGenerator provides a clean interface for building Ruby code strings
# with proper indentation and formatting.
class CodeGenerator
INDENT_SIZE = 2
def initialize
@lines = []
@indent_level = 0
end
# Add a line of code at the current indentation level
# @param text [String] The code to add
def line(text)
@lines << (" " * @indent_level) + text
end
# Add a blank line
def blank_line
@lines << ""
end
# Add multiple lines (useful for multi-line strings)
# @param text [String] Multi-line string to add
def lines(text)
text.each_line do |l|
line(l.chomp)
end
end
# Increase indentation for a block
def indent
@indent_level += 1
yield
@indent_level -= 1
end
# Get the current indentation string
def current_indent
" " * @indent_level
end
# Add raw code without indentation adjustment
def raw(text)
@lines << text
end
# Convert to final Ruby code string
def to_s
@lines.join("\n")
end
# Generate an inline expression (doesn't add to lines, returns string)
# @param expr [String] The expression
# @return [String] The expression wrapped appropriately
def self.inline(expr)
expr
end
# Generate a string literal
# @param str [String] The string to escape
# @return [String] Ruby string literal
def self.string_literal(str)
str.inspect
end
# Generate a safe variable name from a Liquid variable name
# @param name [String] The Liquid variable name
# @return [String] A safe Ruby variable name
def self.safe_var_name(name)
# Replace invalid characters with underscores
safe = name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
# Ensure it starts with a letter or underscore
safe = "_#{safe}" if safe =~ /\A\d/
safe
end
end
end
end
+81
View File
@@ -0,0 +1,81 @@
# frozen_string_literal: true
module Liquid
module Compile
# CompiledContext is a lightweight context-like object for compiled templates.
#
# It duck-types to Liquid::Context well enough for Drops to work, providing:
# - Variable lookup via [] and find_variable
# - strict_variables flag
# - registers hash
# - evaluate method for expressions
#
# This allows Drops to access other variables and use context features
# while still running in compiled mode.
class CompiledContext
attr_reader :assigns, :registers
attr_accessor :strict_variables, :strict_filters
def initialize(assigns, registers: {}, strict_variables: false, strict_filters: false)
@assigns = assigns
@registers = registers.is_a?(Liquid::Registers) ? registers : Liquid::Registers.new(registers)
@strict_variables = strict_variables
@strict_filters = strict_filters
end
# Variable lookup - used by Drops to access other variables
def [](key)
@assigns[key.to_s]
end
# Find a variable by name
def find_variable(key)
result = @assigns[key.to_s]
result = result.to_liquid if result.respond_to?(:to_liquid)
result.context = self if result.respond_to?(:context=)
result
end
# Evaluate an expression (for Drops that need to evaluate sub-expressions)
def evaluate(expr)
case expr
when String, Integer, Float, TrueClass, FalseClass, NilClass
expr
when Liquid::VariableLookup
expr.evaluate(self)
else
expr
end
end
# Lookup and evaluate - handles Procs in assigns
def lookup_and_evaluate(obj, key)
value = obj[key]
value = value.call(self) if value.is_a?(Proc)
value
end
# Handle errors (simplified - just return message)
def handle_error(error, _line_number = nil)
error.message
end
# Check if execution should be interrupted
def interrupt?
false
end
# Stub for resource limits (no-op in compiled mode)
def resource_limits
@resource_limits ||= ResourceLimitStub.new
end
end
# Stub for resource limits in compiled mode
class ResourceLimitStub
def increment_render_score(_score); end
def increment_write_score(_output); end
def reached?; false; end
end
end
end
+349
View File
@@ -0,0 +1,349 @@
# frozen_string_literal: true
module Liquid
module Compile
# CompiledTemplate represents a compiled Liquid template ready for secure execution.
#
# This class wraps generated Ruby code and provides a secure execution environment
# using Liquid::Box. On Ruby 4.0+, execution happens in a true sandbox. On earlier
# versions, a polyfill is used with a security warning.
#
# == Usage
#
# template = Liquid::Template.parse("Hello, {{ name }}!")
# compiled = template.compile_to_ruby
#
# # Render with a Liquid::Context (preferred)
# context = Liquid::Context.new({ "name" => "World" })
# result = compiled.render(context)
# # => "Hello, World!"
#
# # Or render with a simple hash
# result = compiled.render({ "name" => "World" })
#
class CompiledTemplate
attr_reader :source, :external_tags
attr_accessor :filter_handler
# @param source [String] The generated Ruby code
# @param external_tags [Hash] Map of variable names to Tag objects for runtime delegation
# @param has_external_filters [Boolean] Whether external filters are used
def initialize(source, external_tags = {}, has_external_filters = false)
@source = source
@external_tags = external_tags
@has_external_filters = has_external_filters
@filter_handler = nil
@proc = nil
@box = nil
end
# Returns true if this template has external tags that need runtime delegation
def has_external_tags?
!@external_tags.empty?
end
# Returns true if this template uses external filters
def has_external_filters?
@has_external_filters
end
# Returns true if execution will be sandboxed (Ruby 4.0+)
def secure?
Liquid::Box.secure?
end
# Render the compiled template.
#
# @param context_or_assigns [Liquid::Context, Hash] A Liquid context or hash of assigns
# @param registers [Hash] Registers (only used when passing a Hash)
# @param filter_handler [Object] Optional filter handler module
# @yield [call_type, *args] Called for external tags/filters only
# @return [String] The rendered output
#
def render(context_or_assigns = {}, registers: {}, filter_handler: nil, &block)
compiled_proc = to_proc
handler = filter_handler || @filter_handler
# Accept either a Liquid::Context or a Hash of assigns
if context_or_assigns.is_a?(Liquid::Context)
liquid_context = context_or_assigns
assigns = extract_assigns(liquid_context)
file_system = liquid_context.registers[:file_system]
# Build external handler that handles include/render internally
external_handler = build_external_handler(liquid_context, file_system, handler, &block)
# Use a wrapper context that delegates to the Liquid::Context
context = ContextWrapper.new(liquid_context)
else
assigns = context_or_assigns
file_system = registers[:file_system]
# Create a minimal context for Drop support
context = CompiledContext.new(assigns, registers: registers)
# Build external handler
external_handler = build_external_handler(nil, file_system, handler, &block)
end
compiled_proc.call(assigns, context, external_handler)
end
alias call render
def code
@source
end
def to_s
@source
end
def to_proc
@proc ||= compile_to_proc
end
private
def extract_assigns(liquid_context)
# Get the first environment (static_environments)
liquid_context.environments.first || {}
end
# Build the external call handler
# Handles :include and :render internally using file_system
# Yields to block for :tag and :filter if block given
def build_external_handler(liquid_context, file_system, filter_handler, &block)
external_tags = @external_tags
->(call_type, *args) do
case call_type
when :include
handle_include(liquid_context, file_system, *args)
when :render
handle_render(liquid_context, file_system, *args)
when :tag
if block
block.call(call_type, *args)
else
handle_tag(liquid_context, external_tags, *args)
end
when :filter
if block
block.call(call_type, *args)
else
handle_filter(liquid_context, filter_handler, *args)
end
else
if block
block.call(call_type, *args)
else
raise ArgumentError, "Unknown external call type: #{call_type}"
end
end
end
end
def handle_include(liquid_context, file_system, template_name, variable, attrs, alias_name, assigns, context)
raise Liquid::FileSystemError, "Could not find asset #{template_name}" unless file_system
snippet_source = file_system.read_template_file(template_name)
snippet = Liquid::Template.parse(snippet_source, line_numbers: true)
snippet.name = template_name
# Include shares scope with parent
if liquid_context
# Set attributes in context
attrs&.each { |k, v| liquid_context[k] = v }
context_var_name = alias_name || template_name.to_s.split('/').last
if variable
if variable.is_a?(Array)
return variable.map do |item|
liquid_context[context_var_name] = item
snippet.render(liquid_context)
end.join
else
liquid_context[context_var_name] = variable
end
end
snippet.render(liquid_context)
else
# No liquid context - just use assigns
render_assigns = assigns.merge(attrs || {})
snippet.render(render_assigns)
end
end
def handle_render(liquid_context, file_system, template_name, variable, attrs, alias_name, is_for_loop, context)
raise Liquid::FileSystemError, "Could not find asset #{template_name}" unless file_system
snippet_source = file_system.read_template_file(template_name)
snippet = Liquid::Template.parse(snippet_source, line_numbers: true)
snippet.name = template_name
# Render creates isolated scope - only attrs are passed
render_assigns = attrs&.dup || {}
context_var_name = alias_name || template_name.to_s.split('/').last.sub(/\.liquid$/, '')
if variable
if is_for_loop && variable.is_a?(Array)
return variable.map do |item|
render_assigns[context_var_name] = item
if liquid_context
isolated_ctx = Liquid::Context.build(
static_environments: render_assigns,
registers: liquid_context.registers,
rethrow_errors: false,
)
isolated_ctx.exception_renderer = liquid_context.exception_renderer
snippet.render(isolated_ctx)
else
snippet.render(render_assigns)
end
end.join
else
render_assigns[context_var_name] = variable
end
end
if liquid_context
isolated_ctx = Liquid::Context.build(
static_environments: render_assigns,
registers: liquid_context.registers,
rethrow_errors: false,
)
isolated_ctx.exception_renderer = liquid_context.exception_renderer
snippet.render(isolated_ctx)
else
snippet.render(render_assigns)
end
end
def handle_tag(liquid_context, external_tags, tag_var, tag_assigns)
tag = external_tags[tag_var]
return '' unless tag
if liquid_context
output = +''
tag.render_to_output_buffer(liquid_context, output)
output
else
# Create a minimal context
ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)
output = +''
tag.render_to_output_buffer(ctx, output)
output
end
end
def handle_filter(liquid_context, filter_handler, filter_name, input, *filter_args)
# Try filter handler first
if filter_handler&.respond_to?(filter_name)
return filter_handler.public_send(filter_name, input, *filter_args)
end
# Try liquid context's strainer
if liquid_context
strainer = liquid_context.strainer
if strainer.class.invokable?(filter_name)
return strainer.invoke(filter_name, input, *filter_args)
end
end
# Return input unchanged if filter not found
input
end
def compile_to_proc
if Liquid::Box.secure?
compile_in_sandbox
else
compile_insecure
end
end
def compile_in_sandbox
@box ||= begin
box = Liquid::Box.new
box.load_liquid_runtime!
box.lock!
box
end
template_class_name = "CompiledTemplate_#{object_id}"
class_code = <<~RUBY
class #{template_class_name}
TEMPLATE_PROC = #{@source}
def self.render(*args, &block)
TEMPLATE_PROC.call(*args, &block)
end
end
RUBY
@box.eval(class_code)
template_class = @box[template_class_name]
->(assigns, context, external_handler) do
template_class.render(assigns, context, external_handler)
end
end
def compile_insecure
warn_once_insecure unless Liquid::Box.secure?
require_relative 'runtime' unless defined?(::LR)
# rubocop:disable Security/Eval
eval(@source)
# rubocop:enable Security/Eval
end
def warn_once_insecure
return if @warned_insecure
@warned_insecure = true
warn "[SECURITY WARNING] Liquid compiled template running outside of Ruby::Box sandbox. " \
"On Ruby 4.0+, this runs in a secure sandbox. On earlier versions, be cautious " \
"about running untrusted templates."
end
# Wrapper around Liquid::Context for compiled template compatibility
class ContextWrapper
def initialize(liquid_context)
@liquid_context = liquid_context
end
def [](key)
@liquid_context[key]
end
def []=(key, value)
@liquid_context[key] = value
end
def key?(key)
@liquid_context.key?(key)
end
def registers
@liquid_context.registers
end
def strainer
@liquid_context.strainer
end
def handle_error(e, line_number = nil)
@liquid_context.handle_error(e, line_number)
end
end
end
end
end
+145
View File
@@ -0,0 +1,145 @@
# frozen_string_literal: true
module Liquid
module Compile
# ConditionCompiler compiles Liquid conditions to Ruby boolean expressions.
#
# Handles:
# - Simple truthiness: {% if variable %}
# - Comparisons: {% if a == b %}, {% if a > b %}
# - Logical operators: {% if a and b %}, {% if a or b %}
# - Special checks: {% if a == blank %}, {% if a == empty %}
class ConditionCompiler
# Operator mappings from Liquid to Ruby
OPERATORS = {
'==' => '==',
'!=' => '!=',
'<>' => '!=',
'<' => '<',
'>' => '>',
'<=' => '<=',
'>=' => '>=',
'contains' => :contains,
}.freeze
# Compile a Condition to a Ruby boolean expression
# @param condition [Liquid::Condition] The condition
# @param compiler [RubyCompiler] The main compiler instance
# @return [String] Ruby code expression that evaluates to true/false
def self.compile(condition, compiler)
if condition.is_a?(ElseCondition)
return "true"
end
compile_condition_chain(condition, compiler)
end
private
def self.compile_condition_chain(condition, compiler)
# Compile the current condition
current = compile_single_condition(condition, compiler)
# Check for chained conditions (and/or)
if condition.child_condition
child = compile_condition_chain(condition.child_condition, compiler)
child_relation = condition.send(:child_relation)
case child_relation
when :and
"(#{current} && #{child})"
when :or
"(#{current} || #{child})"
else
current
end
else
current
end
end
def self.compile_single_condition(condition, compiler)
left = condition.left
op = condition.operator
right = condition.right
# If no operator, just check truthiness
# Inline: Liquid truthiness is "not nil and not false"
if op.nil?
left_expr = ExpressionCompiler.compile(left, compiler)
# For simple variable access, we can use a more compact form
# Complex expressions need temp variable to avoid double evaluation
if simple_expression?(left)
return "(#{left_expr} != nil && #{left_expr} != false)"
else
return "((__v__ = #{left_expr}) != nil && __v__ != false)"
end
end
# Compile left and right expressions
left_expr = compile_condition_value(left, compiler)
right_expr = compile_condition_value(right, compiler)
# Handle special operators
case OPERATORS[op]
when :contains
compile_contains(left_expr, right_expr, compiler)
when '=='
compile_equality(left, right, left_expr, right_expr, compiler)
when '!='
"!(#{compile_equality(left, right, left_expr, right_expr, compiler)})"
else
# Standard comparison
ruby_op = OPERATORS[op] || op
"(#{left_expr} #{ruby_op} #{right_expr} rescue false)"
end
end
def self.compile_condition_value(expr, compiler)
if expr.is_a?(Condition::MethodLiteral)
# For blank/empty checks, we return a special marker
# The equality handler will deal with this
":__method_literal_#{expr.method_name}__"
else
ExpressionCompiler.compile(expr, compiler)
end
end
def self.compile_equality(left, right, left_expr, right_expr, compiler)
# Handle blank/empty method literals
if left.is_a?(Condition::MethodLiteral)
method_name = left.method_name
"(#{right_expr}.respond_to?(:#{method_name}) ? #{right_expr}.#{method_name} : nil)"
elsif right.is_a?(Condition::MethodLiteral)
method_name = right.method_name
"(#{left_expr}.respond_to?(:#{method_name}) ? #{left_expr}.#{method_name} : nil)"
else
"(#{left_expr} == #{right_expr})"
end
end
def self.compile_contains(left_expr, right_expr, compiler)
# The contains operator checks if left includes right
# For strings, right is converted to a string
"(lambda { |left, right| " \
"return false if left.nil? || right.nil? || !left.respond_to?(:include?); " \
"right = right.to_s if left.is_a?(String); " \
"left.include?(right) rescue false " \
"}.call(#{left_expr}, #{right_expr}))"
end
# Check if an expression is simple (doesn't need temp variable to avoid double evaluation)
def self.simple_expression?(expr)
case expr
when nil, true, false, Integer, Float, String
true
when VariableLookup
# Simple variable or property access is safe to evaluate twice
true
else
false
end
end
end
end
end
+155
View File
@@ -0,0 +1,155 @@
# frozen_string_literal: true
module Liquid
module Compile
# ExpressionCompiler compiles Liquid expressions to Ruby code.
#
# Expressions include:
# - Literals: nil, true, false, strings, numbers
# - Variable lookups: foo, foo.bar, foo[0], foo["key"]
# - Ranges: (1..10), (start..end)
class ExpressionCompiler
# Compile an expression to a Ruby code string
# @param expr [Object] The parsed expression
# @param compiler [RubyCompiler] The main compiler instance
# @return [String] Ruby code that evaluates to the expression value
def self.compile(expr, compiler)
case expr
when nil
"nil"
when true
"true"
when false
"false"
when String
expr.inspect
when Integer, Float
expr.inspect
when Range
"(#{expr.begin.inspect}..#{expr.end.inspect})"
when VariableLookup
compile_variable_lookup(expr, compiler)
when RangeLookup
compile_range_lookup(expr, compiler)
when Condition::MethodLiteral
# Handle blank/empty method literals
compile_method_literal(expr, compiler)
else
raise CompileError, "Unknown expression type: #{expr.class} (#{expr.inspect})"
end
end
# Compile a variable lookup expression
# @param lookup [VariableLookup] The variable lookup
# @param compiler [RubyCompiler] The main compiler instance
# @return [String] Ruby code that evaluates to the variable value
def self.compile_variable_lookup(lookup, compiler)
# Start with the base variable
name = lookup.name
# Check for forloop property inlining
if name == 'forloop' && lookup.lookups.length == 1
loop_ctx = compiler.current_loop_context
if loop_ctx && loop_ctx[:idx_var]
inlined = compile_forloop_property(lookup.lookups.first, loop_ctx)
return inlined if inlined
end
end
# Handle dynamic name (expression in brackets)
base = if name.is_a?(VariableLookup) || name.is_a?(RangeLookup)
# Dynamic name like [expr].foo
"assigns[#{compile(name, compiler)}]"
elsif name.is_a?(String)
"assigns[#{name.inspect}]"
elsif name.is_a?(Integer)
"assigns[#{name.inspect}]"
else
compile(name, compiler)
end
# Apply each lookup in the chain
lookup.lookups.each_with_index do |key, index|
if key.is_a?(VariableLookup) || key.is_a?(RangeLookup)
# Dynamic key like foo[expr]
base = "LR.lookup(#{base}, #{compile(key, compiler)}, __context__)"
elsif key.is_a?(Integer)
# Numeric index like foo[0]
base = "LR.lookup(#{base}, #{key}, __context__)"
elsif key.is_a?(String)
# Always use LR.lookup which tries key access first,
# then falls back to method call for command methods (first, last, size)
base = "LR.lookup(#{base}, #{key.inspect}, __context__)"
else
base = "LR.lookup(#{base}, #{compile(key, compiler)}, __context__)"
end
end
base
end
# Inline forloop property access to avoid hash allocation
# @param prop [String] Property name (index, index0, first, last, etc.)
# @param loop_ctx [Hash] Loop context with idx_var, len_var, loop_name
# @return [String, nil] Inlined Ruby code or nil if can't inline
def self.compile_forloop_property(prop, loop_ctx)
idx = loop_ctx[:idx_var]
len = loop_ctx[:len_var]
name = loop_ctx[:loop_name]
case prop
when 'index'
"(#{idx} + 1)"
when 'index0'
idx
when 'rindex'
"(#{len} - #{idx})"
when 'rindex0'
"(#{len} - #{idx} - 1)"
when 'first'
"(#{idx} == 0)"
when 'last'
"(#{idx} == #{len} - 1)"
when 'length'
len
when 'name'
name ? name.inspect : "nil"
else
nil # Unknown property, fall back to hash lookup
end
end
# Compile a range lookup expression
# @param range [RangeLookup] The range lookup
# @param compiler [RubyCompiler] The main compiler instance
# @return [String] Ruby code that evaluates to the range
def self.compile_range_lookup(range, compiler)
start_expr = compile(range.start_obj, compiler)
end_expr = compile(range.end_obj, compiler)
# Convert to integers and create range
"(LR.to_integer(#{start_expr})...LR.to_integer(#{end_expr})).to_a"
end
# Compile a method literal (blank/empty)
def self.compile_method_literal(literal, compiler)
# These are used in conditions like `if foo == blank`
# They represent special method calls
literal.to_s.inspect
end
# Compile an expression for use in a condition
# @param expr [Object] The expression
# @param compiler [RubyCompiler] The main compiler
# @return [String] Ruby code for the condition value
def self.compile_for_condition(expr, compiler)
if expr.is_a?(Condition::MethodLiteral)
# Return the method name symbol for special handling
":#{expr.method_name}"
else
compile(expr, compiler)
end
end
end
end
end
+230
View File
@@ -0,0 +1,230 @@
# frozen_string_literal: true
module Liquid
module Compile
# FilterCompiler compiles Liquid filter chains to Ruby code.
#
# Filters are applied in sequence: {{ value | filter1: arg1 | filter2: arg2 }}
# becomes a chain of method calls on the value.
class FilterCompiler
# Standard filters that map directly to Ruby methods or simple expressions
SIMPLE_FILTERS = {
'size' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:size) ? #{input}.size : 0)" },
'downcase' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).downcase" },
'upcase' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).upcase" },
'capitalize' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).capitalize" },
'strip' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).strip" },
'lstrip' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).lstrip" },
'rstrip' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).rstrip" },
'reverse' => ->(input, _args, _kwargs, _compiler) { "(#{input}.is_a?(Array) ? #{input}.reverse : LR.to_s(#{input}).reverse)" },
'first' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:first) ? #{input}.first : nil)" },
'last' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:last) ? #{input}.last : nil)" },
'uniq' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:uniq) ? #{input}.uniq : #{input})" },
'compact' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:compact) ? #{input}.compact : #{input})" },
'flatten' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:flatten) ? #{input}.flatten : #{input})" },
'sort' => ->(input, _args, _kwargs, _compiler) { "(#{input}.respond_to?(:sort) ? #{input}.sort : #{input})" },
'abs' => ->(input, _args, _kwargs, _compiler) { "LR.to_number(#{input}).abs" },
'ceil' => ->(input, _args, _kwargs, _compiler) { "LR.to_number(#{input}).ceil.to_i" },
'floor' => ->(input, _args, _kwargs, _compiler) { "LR.to_number(#{input}).floor.to_i" },
'escape' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.escape_html(#{input}))" },
'h' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.escape_html(#{input}))" },
'url_encode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.url_encode(#{input}))" },
'url_decode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : LR.url_decode(#{input}))" },
'base64_encode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_encode(#{input})" },
'base64_decode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_decode(#{input})" },
'base64_url_safe_encode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_url_safe_encode(#{input})" },
'base64_url_safe_decode' => ->(input, _args, _kwargs, _compiler) { "LR.base64_url_safe_decode(#{input})" },
'strip_html' => ->(input, _args, _kwargs, _compiler) { "LR.strip_html(#{input})" },
'strip_newlines' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).gsub(/\\r?\\n/, '')" },
'newline_to_br' => ->(input, _args, _kwargs, _compiler) { "LR.to_s(#{input}).gsub(/\\r?\\n/, \"<br />\\n\")" },
}.freeze
# Filters with arguments that need special handling
# All use LR.method() calls to pre-loaded runtime
PARAMETERIZED_FILTERS = {
'append' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"LR.to_s(#{input}) + LR.to_s(#{arg})"
},
'prepend' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"LR.to_s(#{arg}) + LR.to_s(#{input})"
},
'plus' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(LR.to_number(#{input}) + LR.to_number(#{arg}))"
},
'minus' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(LR.to_number(#{input}) - LR.to_number(#{arg}))"
},
'times' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(LR.to_number(#{input}) * LR.to_number(#{arg}))"
},
'divided_by' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(LR.to_number(#{input}) / LR.to_number(#{arg}))"
},
'modulo' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(LR.to_number(#{input}) % LR.to_number(#{arg}))"
},
'round' => ->(input, args, _kwargs, compiler) {
if args.empty?
"LR.to_number(#{input}).round.to_i"
else
arg = compile_arg(args[0], compiler)
"LR.to_number(#{input}).round(LR.to_number(#{arg}))"
end
},
'at_least' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"[LR.to_number(#{input}), LR.to_number(#{arg})].max"
},
'at_most' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"[LR.to_number(#{input}), LR.to_number(#{arg})].min"
},
'default' => ->(input, args, kwargs, compiler) {
default_val = args.empty? ? "''" : compile_arg(args[0], compiler)
allow_false = kwargs && kwargs['allow_false'] ? "allow_false: #{compile_arg(kwargs['allow_false'], compiler)}" : ''
"LR.default(#{input}, #{default_val}#{allow_false.empty? ? '' : ', ' + allow_false})"
},
'split' => ->(input, args, _kwargs, compiler) {
pattern = args.empty? ? "' '" : compile_arg(args[0], compiler)
"LR.to_s(#{input}).split(LR.to_s(#{pattern}))"
},
'join' => ->(input, args, _kwargs, compiler) {
glue = args.empty? ? "' '" : compile_arg(args[0], compiler)
"(#{input}.is_a?(Array) ? #{input}.map { |i| LR.to_s(i) }.join(LR.to_s(#{glue})) : LR.to_s(#{input}))"
},
'replace' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
"LR.to_s(#{input}).gsub(LR.to_s(#{string}), LR.to_s(#{replacement}))"
},
'replace_first' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
"LR.to_s(#{input}).sub(LR.to_s(#{string}), LR.to_s(#{replacement}))"
},
'remove' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
"LR.to_s(#{input}).gsub(LR.to_s(#{string}), '')"
},
'remove_first' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
"LR.to_s(#{input}).sub(LR.to_s(#{string}), '')"
},
'truncate' => ->(input, args, _kwargs, compiler) {
length = args.empty? ? "50" : compile_arg(args[0], compiler)
ellipsis = args.length > 1 ? compile_arg(args[1], compiler) : "'...'"
"LR.truncate(#{input}, #{length}, #{ellipsis})"
},
'truncatewords' => ->(input, args, _kwargs, compiler) {
words = args.empty? ? "15" : compile_arg(args[0], compiler)
ellipsis = args.length > 1 ? compile_arg(args[1], compiler) : "'...'"
"LR.truncatewords(#{input}, #{words}, #{ellipsis})"
},
'slice' => ->(input, args, _kwargs, compiler) {
offset = compile_arg(args[0], compiler)
length = args.length > 1 ? compile_arg(args[1], compiler) : "1"
"LR.slice(#{input}, #{offset}, #{length})"
},
'map' => ->(input, args, _kwargs, compiler) {
property = compile_arg(args[0], compiler)
"(#{input}.is_a?(Array) ? #{input}.map { |item| item.respond_to?(:[]) ? item[#{property}] : nil } : [])"
},
'where' => ->(input, args, _kwargs, compiler) {
property = compile_arg(args[0], compiler)
if args.length > 1
target = compile_arg(args[1], compiler)
"(#{input}.is_a?(Array) ? #{input}.select { |item| item.respond_to?(:[]) && item[#{property}] == #{target} } : [])"
else
"(#{input}.is_a?(Array) ? #{input}.select { |item| item.respond_to?(:[]) && LR.truthy?(item[#{property}]) } : [])"
end
},
'reject' => ->(input, args, _kwargs, compiler) {
property = compile_arg(args[0], compiler)
if args.length > 1
target = compile_arg(args[1], compiler)
"(#{input}.is_a?(Array) ? #{input}.reject { |item| item.respond_to?(:[]) && item[#{property}] == #{target} } : [])"
else
"(#{input}.is_a?(Array) ? #{input}.reject { |item| item.respond_to?(:[]) && LR.truthy?(item[#{property}]) } : [])"
end
},
'concat' => ->(input, args, _kwargs, compiler) {
arr = compile_arg(args[0], compiler)
"(#{input}.is_a?(Array) ? #{input} + (#{arr}.respond_to?(:to_ary) ? #{arr}.to_ary : []) : [])"
},
'sort_natural' => ->(input, args, _kwargs, compiler) {
if args.empty?
"(#{input}.is_a?(Array) ? #{input}.sort_by { |a| a.to_s.downcase } : #{input})"
else
property = compile_arg(args[0], compiler)
"(#{input}.is_a?(Array) ? #{input}.sort_by { |a| a.respond_to?(:[]) ? a[#{property}].to_s.downcase : '' } : #{input})"
end
},
'date' => ->(input, args, _kwargs, compiler) {
format = compile_arg(args[0], compiler)
"LR.date(#{input}, #{format})"
},
'escape_once' => ->(input, _args, _kwargs, _compiler) {
"LR.escape_once(#{input})"
},
}.freeze
# Compile a filter chain
# @param input_expr [String] Ruby expression for the input value
# @param filters [Array] Array of filter definitions [name, args, kwargs]
# @param compiler [RubyCompiler] The main compiler instance
# @return [String] Ruby code that applies all filters
def self.compile(input_expr, filters, compiler)
result = input_expr
filters.each do |filter_name, filter_args, filter_kwargs|
result = compile_filter(result, filter_name, filter_args || [], filter_kwargs, compiler)
end
result
end
# Compile a single filter application
def self.compile_filter(input, name, args, kwargs, compiler)
if SIMPLE_FILTERS.key?(name)
SIMPLE_FILTERS[name].call(input, args, kwargs, compiler)
elsif PARAMETERIZED_FILTERS.key?(name)
PARAMETERIZED_FILTERS[name].call(input, args, kwargs, compiler)
else
# Fall back to a generic filter call
compile_generic_filter(input, name, args, kwargs, compiler)
end
end
# Compile a filter that's not built-in
# Yields [:filter, name, input, args] to the external handler
def self.compile_generic_filter(input, name, args, kwargs, compiler)
# Mark that we're using external filters
compiler.register_external_filter
compiled_args = args.map { |arg| compile_arg(arg, compiler) }
if kwargs && !kwargs.empty?
kwargs_hash = kwargs.map { |k, v| "#{k.inspect} => #{compile_arg(v, compiler)}" }.join(", ")
compiled_args << "{ #{kwargs_hash} }"
end
args_str = compiled_args.empty? ? "[]" : "[#{compiled_args.join(', ')}]"
# Yield to the external handler
"__external__.call(:filter, #{name.inspect}, #{input}, #{args_str})"
end
# Compile a filter argument
def self.compile_arg(arg, compiler)
ExpressionCompiler.compile(arg, compiler)
end
end
end
end
+418
View File
@@ -0,0 +1,418 @@
# frozen_string_literal: true
module Liquid
module Compile
# RubyCompiler transforms a parsed Liquid template into pure Ruby code.
#
# The compiled code is a string that can be eval'd to create a proc/lambda
# that takes an assigns hash and returns the rendered output string.
#
# ## Optimization Opportunities
#
# The compiled Ruby code has several significant advantages over interpreted Liquid:
#
# 1. **No Context Object**: Instead of using a Context for variable lookups,
# variables are extracted directly from the assigns hash and stored in
# local Ruby variables. This eliminates hash lookups on every access.
#
# 2. **No Filter Invocation Overhead**: Filters are compiled to direct Ruby
# method calls rather than going through context.invoke().
#
# 3. **No Resource Limits Tracking**: The compiled code doesn't track render
# scores, write scores, or assign scores, eliminating per-node overhead.
#
# 4. **No Stack-based Scoping**: Ruby's native block scoping is used instead
# of manually managing scope stacks with push/pop operations.
#
# 5. **Direct String Concatenation**: Output is built with direct << operations
# rather than through render_to_output_buffer abstractions.
#
# 6. **Native Control Flow**: break/continue become Ruby's break/next,
# eliminating interrupt objects and checks.
#
# 7. **No to_liquid Calls**: Values are used directly without conversion.
#
# 8. **Potential for Constant Folding**: Expressions with only literals
# could be evaluated at compile time (future enhancement).
#
# 9. **Potential for Dead Code Elimination**: Unreachable branches like
# `{% if false %}` could be removed (future enhancement).
#
# 10. **No Profiling Hooks**: No profiler overhead in the generated code.
#
# 11. **No Exception Rendering**: Errors propagate naturally rather than
# being caught and rendered inline.
#
# ## Usage
#
# template = Liquid::Template.parse("Hello, {{ name }}!")
# ruby_code = template.compile_to_ruby
# render_proc = eval(ruby_code)
# result = render_proc.call({ "name" => "World" })
# # => "Hello, World!"
#
class RubyCompiler
attr_reader :template, :options
# @param template [Liquid::Template] The parsed template to compile
# @param options [Hash] Compilation options
# @option options [Boolean] :strict_variables Raise on undefined variables (default: false)
# @option options [Boolean] :include_filters Include filter helper methods (default: true)
# @option options [Boolean] :debug Emit source comments for debugging (default: false)
# @option options [Object] :file_system File system for loading partials (default: template's environment)
def initialize(template, options = {})
@template = template
@options = {
strict_variables: false,
include_filters: true,
debug: false,
file_system: nil,
}.merge(options)
@var_counter = 0
@partials = {} # Registered partials: name => method_name
@partial_sources = {} # Partial sources: name => source code
@partial_counter = 0
@external_tags = {} # External tags: var_name => tag object
@external_tag_counter = 0
@has_external_filters = false # Whether we need the filter helper
@loop_context_stack = [] # Stack of loop contexts for break/continue
end
# Push a loop context onto the stack (for nested loops)
# @param break_var [String, nil] Variable name for break flag, or nil if no break
# @param idx_var [String, nil] Variable name for loop index
# @param len_var [String, nil] Variable name for collection length
# @param loop_name [String, nil] Name of the loop (for forloop.name)
def push_loop_context(break_var: nil, idx_var: nil, len_var: nil, loop_name: nil)
@loop_context_stack.push({
break_var: break_var,
idx_var: idx_var,
len_var: len_var,
loop_name: loop_name
})
end
# Pop the current loop context
def pop_loop_context
@loop_context_stack.pop
end
# Get the current loop context (for break/continue compilation)
# @return [Hash, nil] Current loop context or nil if not in a loop
def current_loop_context
@loop_context_stack.last
end
# Mark that we have external filters
def register_external_filter
@has_external_filters = true
end
# Check if external filters are used
def has_external_filters?
@has_external_filters
end
# Register an external tag that will be called at runtime
# @param tag [Liquid::Tag] The tag to register
# @return [String] The variable name for this tag
def register_external_tag(tag)
@external_tag_counter += 1
var_name = "__ext_tag_#{@external_tag_counter}__"
@external_tags[var_name] = tag
var_name
end
# Get all registered external tags
# @return [Hash] Map of variable names to tag objects
def external_tags
@external_tags
end
# Get the file system for loading partials
def file_system
@options[:file_system] || @template.instance_variable_get(:@environment)&.file_system
end
# Load a partial source from the file system
# @param name [String] The partial name
# @return [String, nil] The partial source or nil if not found
def load_partial(name)
return @partial_sources[name] if @partial_sources.key?(name)
fs = file_system
return nil unless fs && fs.respond_to?(:read_template_file)
begin
source = fs.read_template_file(name)
@partial_sources[name] = source
source
rescue Liquid::FileSystemError
nil
end
end
# Register a partial and return its method name
# @param name [String] The partial name
# @param source [String] The partial source
# @return [String] The method name for this partial
def register_partial(name, source)
return @partials[name] if @partials.key?(name)
@partial_counter += 1
method_name = "__partial_#{@partial_counter}__"
@partials[name] = method_name
method_name
end
# Get all registered partials
def registered_partials
@partials
end
# Check if debug mode is enabled
def debug?
@options[:debug]
end
# Emit a debug comment with source location info
# This creates a lightweight source map that allows tracing errors
# back to the original Liquid source
def emit_debug_comment(code, node, description = nil)
return unless debug?
line_number = node.respond_to?(:line_number) ? node.line_number : nil
raw_markup = extract_raw_markup(node)
comment_parts = []
comment_parts << "LIQUID"
comment_parts << "L#{line_number}" if line_number
comment_parts << description if description
comment_parts << raw_markup.inspect if raw_markup && raw_markup.length < 80
code.line "# #{comment_parts.join(' | ')}"
end
# Extract the raw markup from a node for debug output
def extract_raw_markup(node)
case node
when Variable
"{{ #{node.raw} }}"
when Tag
if node.respond_to?(:raw)
"{% #{node.tag_name} #{node.raw} %}"
elsif node.respond_to?(:markup)
"{% #{node.tag_name} #{node.markup} %}"
else
"{% #{node.class.name.split('::').last.downcase} %}"
end
else
nil
end
end
# Compile the template to a Ruby code string
# @return [String] Ruby code that can be eval'd to create a render proc
# @return [Hash] If external tags are used, returns { code: String, external_tags: Hash }
def compile
code = CodeGenerator.new
# Add debug header if enabled
if debug?
code.line "# Compiled from Liquid template: #{@template.name || '(unnamed)'}"
code.line "# Debug mode enabled - comments contain source locations"
code.line "# Format: # LIQUID | L<line> | <description> | <source>"
code.blank_line
end
# First pass: compile the document body to discover partials and external tags
main_code = CodeGenerator.new
compile_node(@template.root, main_code)
# Lambda signature: (assigns, context, external_handler)
# - assigns: Hash of template variables
# - context: CompiledContext for Drop support
# - external_handler: Proc that handles [:tag, ...] and [:filter, ...] calls
code.line "->(assigns, __context__, __external__) do"
code.indent do
# Initialize the output buffer
code.line '__output__ = +""'
code.blank_line
# Compile partial methods (before main body so they're available)
compile_partials(code)
# Add the main body code
code.raw(main_code.to_s)
code.blank_line
code.line "__output__"
end
code.line "end"
code.to_s
end
# Compile all registered partials as inner methods
def compile_partials(code)
@partials.each do |name, method_name|
source = @partial_sources[name]
next unless source
code.line "# Partial: #{name}"
code.line "#{method_name} = ->(partial_assigns) do"
code.indent do
code.line "__partial_output__ = +''"
code.line "# Merge partial assigns with parent assigns"
code.line "__assigns_backup__ = assigns.dup"
code.line "assigns.merge!(partial_assigns)"
code.blank_line
# Parse and compile the partial
# Note: We need to handle this carefully to avoid circular references
begin
compile_partial_source(source, code)
rescue => e
code.line "# Error compiling partial: #{e.message.inspect}"
code.line "__partial_output__ << '[PARTIAL ERROR: ' + #{name.inspect} + ']'"
end
code.blank_line
code.line "# Restore assigns"
code.line "assigns.replace(__assigns_backup__)"
code.line "__partial_output__"
end
code.line "end"
code.blank_line
end
end
# Compile a partial source string
def compile_partial_source(source, code)
# Parse the partial source using the same environment
environment = @template.instance_variable_get(:@environment) || Liquid::Environment.default
parse_context = Liquid::ParseContext.new(environment: environment)
tokenizer = parse_context.new_tokenizer(source)
document = Liquid::Document.parse(tokenizer, parse_context)
# Compile the partial's body, but swap output variable
code.line "__saved_output__ = __output__"
code.line "__output__ = __partial_output__"
# Compile the document
BlockBodyCompiler.compile(document.body, self, code)
code.line "__output__ = __saved_output__"
end
# Generate a unique variable name for internal use
def generate_var_name(prefix = "v")
@var_counter += 1
"__#{prefix}#{@var_counter}__"
end
# Compile a single node
def compile_node(node, code)
case node
when Document
BlockBodyCompiler.compile(node.body, self, code)
when BlockBody
BlockBodyCompiler.compile(node, self, code)
when String
compile_string(node, code)
when Variable
emit_debug_comment(code, node, "variable")
VariableCompiler.compile(node, self, code)
when Tag
emit_debug_comment(code, node, node.class.name.split('::').last.downcase)
compile_tag(node, code)
else
raise CompileError, "Unknown node type: #{node.class}"
end
end
private
def compile_string(str, code)
return if str.empty?
if debug? && str.length < 40
code.line "# LIQUID | text | #{str.inspect}"
end
code.line "__output__ << #{str.inspect}"
end
def compile_tag(tag, code)
# First, check if the tag implements to_ruby (custom compilation)
if tag.respond_to?(:to_ruby)
tag.to_ruby(code, self)
# Then check for a built-in compiler class
elsif (compiler_class = find_tag_compiler(tag))
compiler_class.compile(tag, self, code)
else
# Unknown tag - yield to caller at runtime
compile_external_tag(tag, code)
end
end
def compile_external_tag(tag, code)
tag_var = register_external_tag(tag)
tag_name = tag.class.name.split('::').last
if debug?
code.line "# External tag: #{tag_name} (yields to caller)"
end
# Yield [:tag, tag_var, assigns] to the external handler
code.line "__output__ << __external__.call(:tag, #{tag_var.inspect}, assigns)"
end
def find_tag_compiler(tag)
case tag
when Liquid::Unless # Check Unless before If since Unless < If
Tags::UnlessCompiler
when Liquid::If
Tags::IfCompiler
when Liquid::Case
Tags::CaseCompiler
when Liquid::For
Tags::ForCompiler
when Liquid::Assign
Tags::AssignCompiler
when Liquid::Capture
Tags::CaptureCompiler
when Liquid::Cycle
Tags::CycleCompiler
when Liquid::Increment
Tags::IncrementCompiler
when Liquid::Decrement
Tags::DecrementCompiler
when Liquid::Raw
Tags::RawCompiler
when Liquid::Echo
Tags::EchoCompiler
when Liquid::Break
Tags::BreakCompiler
when Liquid::Continue
Tags::ContinueCompiler
when Liquid::Comment, Liquid::InlineComment, Liquid::Doc
Tags::CommentCompiler
when Liquid::TableRow
Tags::TableRowCompiler
when Liquid::Render
Tags::RenderCompiler
when Liquid::Include
Tags::IncludeCompiler
when Liquid::Ifchanged
Tags::IfchangedCompiler
else
nil
end
end
# NOTE: compile_helper_methods was removed - helpers are now provided by the
# pre-loaded LR module (compile/runtime.rb). Templates call LR.to_s(), LR.lookup(), etc.
end
# Custom error for compilation issues
class CompileError < StandardError; end
end
end
+252
View File
@@ -0,0 +1,252 @@
# frozen_string_literal: true
# Liquid Compiled Template Runtime
#
# This module provides ALL helpers for compiled Liquid templates.
# It is loaded into the sandbox BEFORE lock!, so these methods
# are available to all compiled templates.
#
# Templates reference these as LR.method_name(args)
#
# == Security Note
#
# It is SAFE to expose side-effect-free, non-IO methods that don't leak
# objects with dangerous methods. For example:
#
# - CGI.escapeHTML(str) -> returns a String (safe)
# - Base64.strict_encode64(str) -> returns a String (safe)
# - BigDecimal(str) -> returns a Numeric (safe)
#
# These are pure functions that take values and return values.
# They don't provide any capability to escape the sandbox.
#
# We capture METHOD REFERENCES (e.g., CGI.method(:escapeHTML)) before the
# sandbox locks. This means templates can use the functionality without
# having direct access to the CGI, Base64, or BigDecimal constants.
#
# Dependencies (CGI, Base64, BigDecimal) are loaded by box.rb into the
# sandbox BEFORE this file runs. The constants are preserved after lock.
module LR
# === Type Conversion ===
# Convert to string like Liquid does
def self.to_s(obj)
case obj
when nil then ''
when Array then obj.join
else obj.to_s
end
end
# Convert to number for arithmetic
# Use Kernel.BigDecimal to access from root namespace
def self.to_number(obj)
case obj
when Numeric then obj
when String
if obj.strip =~ /\A-?\d+\.\d+\z/
Kernel.BigDecimal(obj)
else
obj.to_i
end
else 0
end
end
# Convert to integer
def self.to_integer(obj)
return obj if obj.is_a?(Integer)
Integer(obj.to_s) rescue 0
end
# === Truthiness ===
# Liquid truthiness: only nil and false are falsy
def self.truthy?(obj)
obj != nil && obj != false
end
# === Output ===
# Output a value, converting to string safely (handles arrays recursively)
def self.output(obj)
case obj
when nil then ''
when Array then obj.map { |o| output(o) }.join
when BigDecimal
# Format BigDecimal like Liquid does - avoid scientific notation
obj.to_s('F')
else obj.to_s
end
end
# === Lookup ===
# Variable lookup - handles hash/array access, method calls, to_liquid, and drop context
def self.lookup(obj, key, context = nil)
return nil if obj.nil?
# Set context on Drops BEFORE accessing their methods
obj = obj.to_liquid if obj.respond_to?(:to_liquid)
obj.context = context if context && obj.respond_to?(:context=)
# Perform the lookup
result = if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))
obj[key]
elsif obj.respond_to?(key)
obj.public_send(key)
else
nil
end
# Convert result to liquid and set context for nested Drops
result = result.to_liquid if result.respond_to?(:to_liquid)
result.context = context if context && result.respond_to?(:context=)
result
end
# === HTML/URL Encoding ===
# Capture method references before sandbox locks - only the specific methods we need
CGI_ESCAPE_HTML = CGI.method(:escapeHTML)
CGI_ESCAPE = CGI.method(:escape)
CGI_UNESCAPE = CGI.method(:unescape)
HTML_ESCAPE_MAP = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;', "'" => '&#39;' }.freeze
def self.escape_html(obj)
CGI_ESCAPE_HTML.call(to_s(obj))
end
def self.url_encode(obj)
CGI_ESCAPE.call(to_s(obj))
end
def self.url_decode(obj)
CGI_UNESCAPE.call(to_s(obj))
end
def self.escape_once(obj)
# Only escape if not already escaped
to_s(obj).gsub(/["><']|&(?!([a-zA-Z]+|(#\d+));)/, HTML_ESCAPE_MAP)
end
# === Base64 ===
# Capture method references before sandbox locks
BASE64_ENCODE = Base64.method(:strict_encode64)
BASE64_DECODE = Base64.method(:strict_decode64)
BASE64_URL_ENCODE = Base64.method(:urlsafe_encode64)
BASE64_URL_DECODE = Base64.method(:urlsafe_decode64)
def self.base64_encode(obj)
BASE64_ENCODE.call(to_s(obj))
end
def self.base64_decode(obj)
BASE64_DECODE.call(to_s(obj))
end
def self.base64_url_safe_encode(obj)
BASE64_URL_ENCODE.call(to_s(obj))
end
def self.base64_url_safe_decode(obj)
BASE64_URL_DECODE.call(to_s(obj))
end
# === String Manipulation ===
def self.strip_html(obj)
to_s(obj).gsub(%r{<script.*?</script>|<!--.*?-->|<style.*?</style>}m, '').gsub(/<.*?>/m, '')
end
# Truncate string to length with ellipsis
def self.truncate(input, length = 50, ellipsis = '...')
str = to_s(input)
ell_str = to_s(ellipsis)
len = length.to_i
l = [len - ell_str.length, 0].max
str.length > len ? str[0, l] + ell_str : str
end
# Truncate to word count
def self.truncatewords(input, num_words = 15, ellipsis = '...')
max_words = [num_words.to_i, 1].max
words = to_s(input).split(' ', max_words + 1)
if words.length > max_words
words[0, max_words].join(' ') + to_s(ellipsis)
else
input.to_s
end
end
# === Date Formatting ===
def self.date(input, format)
return input if format.to_s.empty?
d = case input
when Time, Date then input
when 'now', 'today' then Time.now
when /\A\d+\z/, Integer then Time.at(input.to_i)
when String then (Time.parse(input) rescue input)
else input
end
d.respond_to?(:strftime) ? d.strftime(format.to_s) : input
end
# === Collection Helpers ===
# Convert to array for iteration - returns Array or empty Array
# This guarantees the result supports [], .length, .empty? without respond_to? checks
def self.to_array(collection)
case collection
when Array then collection
when Range then collection.to_a
when nil then EMPTY_ARRAY
else
collection.respond_to?(:to_a) ? collection.to_a : EMPTY_ARRAY
end
end
# Frozen empty array to avoid allocations
EMPTY_ARRAY = [].freeze
# Iterate safely, handling ranges and non-iterables
def self.iterate(collection)
case collection
when Range then collection.to_a
when nil then EMPTY_ARRAY
else collection.respond_to?(:each) ? collection : EMPTY_ARRAY
end
end
# Get collection length safely
def self.size(collection)
collection.respond_to?(:size) ? collection.size : 0
end
# === Default Filter ===
def self.default(input, default_value, allow_false: false)
if allow_false
(input.nil? || (input.respond_to?(:empty?) && input.empty?)) ? default_value : input
else
(!truthy?(input) || (input.respond_to?(:empty?) && input.empty?)) ? default_value : input
end
end
# === Array/String Slice ===
def self.slice(input, offset, length = 1)
off = to_integer(offset)
len = to_integer(length)
if input.is_a?(Array)
input.slice(off, len) || []
else
to_s(input).slice(off, len) || ''
end
end
end
# Alias for backwards compatibility
LiquidRuntime = LR
+130
View File
@@ -0,0 +1,130 @@
# frozen_string_literal: true
module Liquid
module Compile
# SourceMapper provides utilities for mapping errors in compiled Ruby code
# back to the original Liquid source.
#
# When code is compiled with debug: true, comments are embedded that contain
# source location information. This class can parse those comments and
# help trace errors back to the original Liquid template.
#
# ## Usage
#
# template = Liquid::Template.parse(source, line_numbers: true)
# ruby_code = template.compile_to_ruby(debug: true)
# render_proc = eval(ruby_code)
#
# begin
# result = render_proc.call(assigns)
# rescue => e
# location = SourceMapper.find_source_location(ruby_code, e)
# puts "Error at Liquid line #{location[:liquid_line]}: #{location[:source]}"
# end
#
class SourceMapper
# Pattern to match LIQUID debug comments
LIQUID_COMMENT_PATTERN = /^(\s*)# LIQUID(?: \| L(\d+))?(?: \| ([^|]+))?(?: \| (.+))?$/
# Parse compiled Ruby code and extract source mapping entries
# @param ruby_code [String] The compiled Ruby code with debug comments
# @return [Array<Hash>] Array of source mapping entries
def self.parse(ruby_code)
entries = []
ruby_line = 0
ruby_code.each_line do |line|
ruby_line += 1
if line =~ LIQUID_COMMENT_PATTERN
entries << {
ruby_line: ruby_line,
liquid_line: $2&.to_i,
type: $3&.strip,
source: parse_source_string($4),
}
end
end
entries
end
# Find the source location for an error based on Ruby line number
# @param ruby_code [String] The compiled Ruby code
# @param error [Exception] The exception that was raised
# @return [Hash, nil] Source location info or nil if not found
def self.find_source_location(ruby_code, error)
# Extract the line number from the error
ruby_line = extract_error_line(error)
return nil unless ruby_line
find_source_for_ruby_line(ruby_code, ruby_line)
end
# Find the source location for a specific Ruby line number
# @param ruby_code [String] The compiled Ruby code
# @param target_line [Integer] The Ruby line number
# @return [Hash, nil] Source location info or nil if not found
def self.find_source_for_ruby_line(ruby_code, target_line)
entries = parse(ruby_code)
# Find the closest LIQUID comment at or before the target line
closest = nil
entries.each do |entry|
break if entry[:ruby_line] > target_line
closest = entry
end
closest
end
# Format an error message with source location info
# @param ruby_code [String] The compiled Ruby code
# @param error [Exception] The exception
# @return [String] Formatted error message
def self.format_error(ruby_code, error)
location = find_source_location(ruby_code, error)
message = "#{error.class}: #{error.message}"
if location
liquid_line = location[:liquid_line] ? "line #{location[:liquid_line]}" : "unknown line"
source = location[:source] || location[:type] || "unknown"
message += "\n in Liquid template at #{liquid_line}"
message += "\n source: #{source}" if location[:source]
end
message
end
private
def self.extract_error_line(error)
# Look for (eval):N in backtrace
error.backtrace&.each do |frame|
if frame =~ /\(eval.*?\):(\d+)/
return $1.to_i
end
end
nil
end
def self.parse_source_string(str)
return nil unless str
# Remove surrounding quotes if present
str = str.strip
if str.start_with?('"') && str.end_with?('"')
# Unescape the string
begin
eval(str)
rescue
str[1..-2]
end
else
str
end
end
end
end
end
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% assign var = expression %} tags
class AssignCompiler
def self.compile(tag, compiler, code)
var_name = tag.to
# Use VariableCompiler to get the expression with filters applied
value_expr = VariableCompiler.compile_to_expression(tag.from, compiler)
# Store in the assigns hash
code.line "assigns[#{var_name.inspect}] = #{value_expr}"
end
end
end
end
end
+28
View File
@@ -0,0 +1,28 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% break %} tags
#
# Break is implemented with a flag variable that's checked in the while condition.
# This avoids catch/throw overhead entirely.
#
# Generated code sets the break flag and uses `next` to exit the current iteration.
# The while loop condition checks the flag and exits if set.
class BreakCompiler
def self.compile(_tag, compiler, code)
loop_ctx = compiler.current_loop_context
if loop_ctx && loop_ctx[:break_var]
# Set the break flag and exit this iteration
code.line "#{loop_ctx[:break_var]} = true"
code.line "next"
else
# Fallback: shouldn't happen if contains_tag? works correctly
code.line "break"
end
end
end
end
end
end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% capture var %}...{% endcapture %} tags
#
# Captures the output of the block into a variable
class CaptureCompiler
def self.compile(tag, compiler, code)
var_name = tag.instance_variable_get(:@to)
capture_var = compiler.generate_var_name("capture")
# Save current output, create new buffer for capture
code.line "#{capture_var} = __output__"
code.line "__output__ = +''"
# Compile the body - access the @body BlockBody directly
body = tag.instance_variable_get(:@body)
if body
BlockBodyCompiler.compile(body, compiler, code)
end
# Save captured content and restore output buffer
code.line "assigns[#{var_name.inspect}] = __output__"
code.line "__output__ = #{capture_var}"
end
end
end
end
end
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% case %} / {% when %} / {% else %} / {% endcase %} tags
class CaseCompiler
def self.compile(tag, compiler, code)
# Compile the case expression and store it in a variable
# to avoid evaluating it multiple times
case_var = compiler.generate_var_name("case")
case_expr = ExpressionCompiler.compile(tag.left, compiler)
code.line "#{case_var} = #{case_expr}"
blocks = tag.blocks
is_first = true
has_else = false
blocks.each do |block|
if block.else?
has_else = true
code.line "else"
else
# 'when' condition - compare case expression with the when value
when_expr = ExpressionCompiler.compile(block.right, compiler)
if is_first
code.line "if #{case_var} == #{when_expr}"
is_first = false
else
code.line "elsif #{case_var} == #{when_expr}"
end
end
code.indent do
if block.attachment
BlockBodyCompiler.compile(block.attachment, compiler, code)
end
end
end
code.line "end" unless blocks.empty?
end
end
end
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% comment %}...{% endcomment %} tags
# Also handles inline_comment (#) and doc tags
#
# Comments produce no output
class CommentCompiler
def self.compile(_tag, _compiler, code)
# Comments produce no output
code.line "# (liquid comment)"
end
end
end
end
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% continue %} tags
#
# Continue is implemented with Ruby's native `next` statement.
# Since we use a while loop (not each), `next` correctly skips
# to the next iteration, but we must increment the index first.
class ContinueCompiler
def self.compile(_tag, compiler, code)
# Get the index variable from the loop context
loop_ctx = compiler.current_loop_context
if loop_ctx && loop_ctx[:idx_var]
# Increment index before next, otherwise we'd infinite loop
code.line "#{loop_ctx[:idx_var]} += 1"
end
code.line "next"
end
end
end
end
end
+45
View File
@@ -0,0 +1,45 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% cycle 'a', 'b', 'c' %} tags
#
# Cycles through a list of values, outputting the next one each iteration
class CycleCompiler
def self.compile(tag, compiler, code)
variables = tag.variables
name_expr = ExpressionCompiler.compile(tag.instance_variable_get(:@name), compiler)
is_named = tag.named?
cycle_var = compiler.generate_var_name("cycle")
key_var = compiler.generate_var_name("cycle_key")
# Initialize cycle storage if needed
code.line "assigns[:__cycle__] ||= {}"
# Get the cycle key
if is_named
code.line "#{key_var} = #{name_expr}"
else
code.line "#{key_var} = #{variables.object_id}"
end
# Get current index
code.line "#{cycle_var} = assigns[:__cycle__][#{key_var}].to_i"
# Get the value at current index
code.line "case #{cycle_var} % #{variables.size}"
variables.each_with_index do |var, idx|
var_expr = ExpressionCompiler.compile(var, compiler)
code.line "when #{idx} then __output__ << LR.to_s(#{var_expr})"
end
code.line "end"
# Increment counter
code.line "assigns[:__cycle__][#{key_var}] = #{cycle_var} + 1"
end
end
end
end
end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% decrement var %} tags
#
# Decrements a counter and outputs its value.
# Uses a separate namespace from regular assigns (shares with increment).
class DecrementCompiler
def self.compile(tag, compiler, code)
var_name = tag.variable_name
# Initialize counter storage if needed
code.line "assigns[:__counters__] ||= {}"
# Get current value (default 0), decrement it, output, then store
dec_var = compiler.generate_var_name("dec")
code.line "#{dec_var} = (assigns[:__counters__][#{var_name.inspect}] || 0) - 1"
code.line "assigns[:__counters__][#{var_name.inspect}] = #{dec_var}"
code.line "__output__ << #{dec_var}.to_s"
end
end
end
end
end
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% echo expression %} tags
#
# Same as {{ expression }} but usable in {% liquid %} blocks
class EchoCompiler
def self.compile(tag, compiler, code)
variable = tag.variable
VariableCompiler.compile(variable, compiler, code)
end
end
end
end
end
+339
View File
@@ -0,0 +1,339 @@
# frozen_string_literal: true
require 'set'
module Liquid
module Compile
module Tags
# Compiles {% for %} / {% else %} / {% endfor %} tags
#
# Supports:
# - Iteration: {% for item in collection %}
# - Limit/Offset: {% for item in collection limit:3 offset:2 %}
# - Reversed: {% for item in collection reversed %}
# - Forloop object: forloop.index, forloop.first, forloop.last, etc.
# - Else block: {% for item in collection %}...{% else %}empty{% endfor %}
#
# Optimizations:
# - Detects break/continue usage at compile time
# - Uses while loop with index for minimal overhead
# - Break implemented with flag variable (no catch/throw)
# - Continue implemented with next (native Ruby)
# - Avoids Hash allocation for forloop when not used
class ForCompiler
def self.compile(tag, compiler, code)
var_name = tag.variable_name
collection_expr = ExpressionCompiler.compile(tag.collection_name, compiler)
# Generate unique variable names for this loop
coll_var = compiler.generate_var_name("coll")
idx_var = compiler.generate_var_name("idx")
len_var = compiler.generate_var_name("len")
# Evaluate the collection and convert to array for indexed access
# After this, coll_var is guaranteed to be an Array (or nil)
code.line "#{coll_var} = #{collection_expr}"
code.line "#{coll_var} = LR.to_array(#{coll_var})"
# Handle limit and offset
if tag.from || tag.limit
compile_slice(tag, coll_var, compiler, code)
end
# Handle reversed
if tag.instance_variable_get(:@reversed)
code.line "#{coll_var} = #{coll_var}.reverse"
end
# Check if collection is empty for else block
for_block = tag.instance_variable_get(:@for_block)
else_block = tag.instance_variable_get(:@else_block)
if else_block
code.line "if #{coll_var}.nil? || #{coll_var}.empty?"
code.indent do
BlockBodyCompiler.compile(else_block, compiler, code)
end
code.line "else"
code.indent do
compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
end
code.line "end"
else
code.line "if #{coll_var} && !#{coll_var}.empty?"
code.indent do
compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
end
code.line "end"
end
end
private
def self.compile_slice(tag, coll_var, compiler, code)
from_expr = if tag.from == :continue
# Continue from previous offset - we'd need register tracking for this
# For now, default to 0
"0"
elsif tag.from
ExpressionCompiler.compile(tag.from, compiler)
else
"0"
end
if tag.limit
limit_expr = ExpressionCompiler.compile(tag.limit, compiler)
code.line "#{coll_var} = #{coll_var}[LR.to_integer(#{from_expr}), LR.to_integer(#{limit_expr})] || []"
else
code.line "#{coll_var} = #{coll_var}.drop(LR.to_integer(#{from_expr}))"
end
end
def self.compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
# Analyze loop body for break/continue usage and forloop access
has_break = contains_tag?(for_block, Break)
forloop_props = detect_forloop_properties(for_block)
uses_forloop = !forloop_props.empty?
# Calculate length (needed for forloop or bounds checking)
code.line "#{len_var} = #{coll_var}.length"
code.line "#{idx_var} = 0"
# Break uses a flag variable - no catch/throw overhead
if has_break
break_var = compiler.generate_var_name("brk")
code.line "#{break_var} = false"
code.line "while #{idx_var} < #{len_var} && !#{break_var}"
else
code.line "while #{idx_var} < #{len_var}"
end
# Check if all forloop properties can be inlined (no hash needed)
inlinable_props = %w[index index0 rindex rindex0 first last length name]
needs_forloop_hash = uses_forloop && !forloop_props.all? { |p| inlinable_props.include?(p) }
code.indent do
# Set the loop variable directly from array index
code.line "assigns[#{var_name.inspect}] = #{coll_var}[#{idx_var}]"
# Only create forloop hash if we have properties that can't be inlined
if needs_forloop_hash
compile_forloop_hash(tag, idx_var, len_var, code)
end
# Compile the loop body
# The BreakCompiler/ContinueCompiler will emit the right code
# based on the context we pass through the compiler
compiler.push_loop_context(
break_var: has_break ? break_var : nil,
idx_var: idx_var,
len_var: len_var,
loop_name: tag.instance_variable_get(:@name)
)
BlockBodyCompiler.compile(for_block, compiler, code)
compiler.pop_loop_context
# Increment index
code.line "#{idx_var} += 1"
end
code.line "end"
# Clean up
code.line "assigns.delete(#{var_name.inspect})"
code.line "assigns.delete('forloop')" if needs_forloop_hash
end
def self.compile_forloop_hash(tag, idx_var, len_var, code)
loop_name = tag.instance_variable_get(:@name)
code.line "assigns['forloop'] = {"
code.indent do
code.line "'name' => #{loop_name.inspect},"
code.line "'length' => #{len_var},"
code.line "'index' => #{idx_var} + 1,"
code.line "'index0' => #{idx_var},"
code.line "'rindex' => #{len_var} - #{idx_var},"
code.line "'rindex0' => #{len_var} - #{idx_var} - 1,"
code.line "'first' => #{idx_var} == 0,"
code.line "'last' => #{idx_var} == #{len_var} - 1,"
end
code.line "}"
end
# Check if a block body contains a specific tag type (recursively)
def self.contains_tag?(body, tag_class)
return false if body.nil?
nodelist = body.nodelist
return false if nodelist.nil?
nodelist.any? do |node|
case node
when tag_class
true
when Block
# Check nested blocks (if, for, case, etc.)
contains_tag?(node.instance_variable_get(:@body), tag_class) ||
(node.respond_to?(:nodelist) && contains_tag_in_nodelist?(node.nodelist, tag_class))
when Tag
# Tags with blocks
check_tag_for_nested(node, tag_class)
else
false
end
end
end
def self.check_tag_for_nested(tag, tag_class)
# Check various block-holding tags
[:@for_block, :@else_block, :@body, :@consequent, :@alternative].each do |ivar|
if tag.instance_variable_defined?(ivar)
block = tag.instance_variable_get(ivar)
return true if contains_tag?(block, tag_class)
end
end
# Check If tag's blocks array
if tag.respond_to?(:blocks)
tag.blocks.each do |block|
if block.respond_to?(:attachment)
return true if contains_tag?(block.attachment, tag_class)
end
end
end
false
end
def self.contains_tag_in_nodelist?(nodelist, tag_class)
return false if nodelist.nil?
nodelist.any? { |n| n.is_a?(tag_class) || (n.is_a?(Tag) && check_tag_for_nested(n, tag_class)) }
end
# Check if the loop body accesses forloop variable
def self.uses_forloop_var?(body)
return false if body.nil?
nodelist = body.nodelist
return false if nodelist.nil?
nodelist.any? do |node|
case node
when Variable
# Check if variable references forloop
lookup = node.name
if lookup.is_a?(VariableLookup)
return true if lookup.name == 'forloop'
end
false
when Tag
# Recursively check tag bodies and conditions
check_tag_for_forloop(node)
else
false
end
end
end
def self.check_tag_for_forloop(tag)
# Check block bodies
[:@for_block, :@else_block, :@body, :@consequent, :@alternative].each do |ivar|
if tag.instance_variable_defined?(ivar)
block = tag.instance_variable_get(ivar)
return true if uses_forloop_var?(block)
end
end
# Check If/Unless/Case conditions
if tag.respond_to?(:blocks)
tag.blocks.each do |block|
# Check condition expressions
if block.respond_to?(:left) && variable_references_forloop?(block.left)
return true
end
if block.respond_to?(:right) && variable_references_forloop?(block.right)
return true
end
# Check block attachment (body)
if block.respond_to?(:attachment)
return true if uses_forloop_var?(block.attachment)
end
end
end
false
end
# Check if an expression references forloop variable
def self.variable_references_forloop?(expr)
case expr
when VariableLookup
expr.name == 'forloop'
when Variable
expr.name.is_a?(VariableLookup) && expr.name.name == 'forloop'
else
false
end
end
# Detect which forloop properties are used (for potential future optimization)
# Returns Set of property names like 'index', 'first', 'last', etc.
def self.detect_forloop_properties(body)
props = Set.new
collect_forloop_properties(body, props)
props
end
def self.collect_forloop_properties(body, props)
return if body.nil?
nodelist = body.nodelist
return if nodelist.nil?
nodelist.each do |node|
case node
when Variable
collect_forloop_from_variable(node, props)
when Tag
collect_forloop_from_tag(node, props)
end
end
end
def self.collect_forloop_from_variable(var, props)
lookup = var.name
if lookup.is_a?(VariableLookup) && lookup.name == 'forloop'
lookup.lookups.each do |prop|
props << prop if prop.is_a?(String)
end
end
end
def self.collect_forloop_from_tag(tag, props)
# Check block bodies
[:@for_block, :@else_block, :@body, :@consequent, :@alternative].each do |ivar|
if tag.instance_variable_defined?(ivar)
collect_forloop_properties(tag.instance_variable_get(ivar), props)
end
end
# Check conditions
if tag.respond_to?(:blocks)
tag.blocks.each do |block|
collect_forloop_from_condition(block, props) if block.respond_to?(:left)
collect_forloop_properties(block.attachment, props) if block.respond_to?(:attachment)
end
end
end
def self.collect_forloop_from_condition(condition, props)
[condition.left, condition.right].compact.each do |expr|
if expr.is_a?(VariableLookup) && expr.name == 'forloop'
expr.lookups.each do |prop|
props << prop if prop.is_a?(String)
end
end
end
# Check child conditions
collect_forloop_from_condition(condition.child_condition, props) if condition.respond_to?(:child_condition) && condition.child_condition
end
end
end
end
end
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% if %} / {% elsif %} / {% else %} / {% endif %} tags
class IfCompiler
def self.compile(tag, compiler, code)
blocks = tag.blocks
blocks.each_with_index do |block, index|
condition_expr = ConditionCompiler.compile(block, compiler)
if index == 0
code.line "if #{condition_expr}"
elsif block.else?
code.line "else"
else
code.line "elsif #{condition_expr}"
end
code.indent do
if block.attachment
BlockBodyCompiler.compile(block.attachment, compiler, code)
end
end
end
code.line "end"
end
end
end
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% ifchanged %}...{% endifchanged %} tags
#
# Only outputs if the content has changed since last render
class IfchangedCompiler
def self.compile(tag, compiler, code)
capture_var = compiler.generate_var_name("ifchanged")
# Capture the block output
code.line "#{capture_var} = +''"
code.line "begin"
code.indent do
code.line "__saved_output__ = __output__"
code.line "__output__ = #{capture_var}"
# Compile the body
tag.nodelist.each do |body|
BlockBodyCompiler.compile(body, compiler, code)
end
code.line "__output__ = __saved_output__"
end
code.line "end"
# Only output if changed
code.line "if #{capture_var} != assigns[:__ifchanged__]"
code.indent do
code.line "assigns[:__ifchanged__] = #{capture_var}"
code.line "__output__ << #{capture_var}"
end
code.line "end"
end
end
end
end
end
+119
View File
@@ -0,0 +1,119 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% include 'partial' %} tags
#
# Include is deprecated in favor of render, but we support it for compatibility.
# Unlike render, include shares the outer scope with the partial.
#
# For static template names, the partial is loaded and inlined at compile time.
# For dynamic template names, a runtime fallback is generated.
class IncludeCompiler
def self.compile(tag, compiler, code)
template_name_expr = tag.template_name_expr
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
alias_name = tag.instance_variable_get(:@alias_name)
# Check if the template name is a static string
if template_name_expr.is_a?(String)
compile_static_include(tag, template_name_expr, compiler, code)
else
compile_dynamic_include(tag, compiler, code)
end
end
private
def self.compile_static_include(tag, template_name, compiler, code)
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
alias_name = tag.instance_variable_get(:@alias_name)
# Try to load the partial at compile time
partial_source = compiler.load_partial(template_name)
if partial_source
if compiler.debug?
code.line "# Inlined partial #{template_name.inspect} at compile time"
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial ' + #{template_name.inspect} + ' at compile time' if $VERBOSE"
end
# Generate a unique method name for this partial
method_name = compiler.register_partial(template_name, partial_source)
context_var_name = alias_name || template_name.split('/').last
# Include shares scope, so we just set variables directly
code.line "# Include: #{template_name}"
# Set attributes
attributes.each do |key, value|
value_expr = ExpressionCompiler.compile(value, compiler)
code.line "assigns[#{key.inspect}] = #{value_expr}"
end
# Set the context variable
if variable_name_expr
var_expr = ExpressionCompiler.compile(variable_name_expr, compiler)
var_var = compiler.generate_var_name("incvar")
code.line "#{var_var} = #{var_expr}"
# If it's an array, loop through it
code.line "if #{var_var}.is_a?(Array)"
code.indent do
code.line "#{var_var}.each do |__include_item__|"
code.indent do
code.line "assigns[#{context_var_name.inspect}] = __include_item__"
code.line "__output__ << #{method_name}({})"
end
code.line "end"
end
code.line "else"
code.indent do
code.line "assigns[#{context_var_name.inspect}] = #{var_var}"
code.line "__output__ << #{method_name}({})"
end
code.line "end"
else
# Try to find a variable with the same name as the template
code.line "assigns[#{context_var_name.inspect}] = assigns[#{template_name.inspect}] if assigns.key?(#{template_name.inspect})"
code.line "__output__ << #{method_name}({})"
end
else
# Partial not found at compile time
code.line "# Partial '#{template_name}' not found at compile time"
compile_dynamic_include(tag, compiler, code)
end
end
def self.compile_dynamic_include(tag, compiler, code)
template_name_expr = tag.template_name_expr
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
alias_name = tag.instance_variable_get(:@alias_name)
if compiler.debug?
code.line "# Dynamic include (template name from variable)"
end
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
# Build attributes hash
attrs_var = compiler.generate_var_name("attrs")
code.line "#{attrs_var} = {}"
attributes.each do |key, value|
value_expr = ExpressionCompiler.compile(value, compiler)
code.line "#{attrs_var}[#{key.inspect}] = #{value_expr}"
end
var_expr = variable_name_expr ? ExpressionCompiler.compile(variable_name_expr, compiler) : "nil"
alias_expr = alias_name ? alias_name.inspect : "nil"
# Call the external handler for dynamic includes
code.line "__output__ << __external__.call(:include, #{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, assigns, __context__)"
end
end
end
end
end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% increment var %} tags
#
# Outputs the current counter value, then increments it.
# Uses a separate namespace from regular assigns (shares with decrement).
class IncrementCompiler
def self.compile(tag, compiler, code)
var_name = tag.variable_name
# Initialize counter storage if needed
code.line "assigns[:__counters__] ||= {}"
# Get current value (default 0), output it, then increment
inc_var = compiler.generate_var_name("inc")
code.line "#{inc_var} = assigns[:__counters__][#{var_name.inspect}] || 0"
code.line "__output__ << #{inc_var}.to_s"
code.line "assigns[:__counters__][#{var_name.inspect}] = #{inc_var} + 1"
end
end
end
end
end
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% raw %}...{% endraw %} tags
#
# Outputs the content as-is without parsing Liquid syntax
class RawCompiler
def self.compile(tag, compiler, code)
body = tag.instance_variable_get(:@body)
return if body.nil? || body.empty?
code.line "__output__ << #{body.inspect}"
end
end
end
end
end
+176
View File
@@ -0,0 +1,176 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% render 'partial' %} tags
#
# For static template names (string literals), the partial is loaded at
# compile time and inlined as a method.
#
# For dynamic template names (variables), a runtime fallback is generated
# that calls __render_dynamic__ which must be provided by the runtime.
class RenderCompiler
def self.compile(tag, compiler, code)
template_name_expr = tag.template_name_expr
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
alias_name = tag.alias_name
is_for_loop = tag.for_loop?
# Check if the template name is a static string
if template_name_expr.is_a?(String)
compile_static_render(tag, template_name_expr, compiler, code)
else
compile_dynamic_render(tag, compiler, code)
end
end
private
def self.compile_static_render(tag, template_name, compiler, code)
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
alias_name = tag.alias_name
is_for_loop = tag.for_loop?
# Try to load the partial at compile time
partial_source = compiler.load_partial(template_name)
if partial_source
if compiler.debug?
code.line "# Inlined partial #{template_name.inspect} at compile time"
code.line "$stderr.puts '* WARN: Liquid file system access - inlined partial ' + #{template_name.inspect} + ' at compile time' if $VERBOSE"
end
# Generate a unique method name for this partial
method_name = compiler.register_partial(template_name, partial_source)
context_var_name = alias_name || template_name.split('/').last
if is_for_loop && variable_name_expr
# Render for each item in collection
compile_for_loop_render(tag, method_name, context_var_name, compiler, code)
else
# Single render
compile_single_render(tag, method_name, context_var_name, compiler, code)
end
else
# Partial not found at compile time - generate runtime fallback
code.line "# Partial '#{template_name}' not found at compile time"
compile_dynamic_render(tag, compiler, code)
end
end
def self.compile_single_render(tag, method_name, context_var_name, compiler, code)
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
# Build the inner assigns hash
inner_assigns_var = compiler.generate_var_name("inner")
code.line "#{inner_assigns_var} = {}"
# Copy attributes
attributes.each do |key, value|
value_expr = ExpressionCompiler.compile(value, compiler)
code.line "#{inner_assigns_var}[#{key.inspect}] = #{value_expr}"
end
# Set the context variable if provided
if variable_name_expr
var_expr = ExpressionCompiler.compile(variable_name_expr, compiler)
code.line "#{inner_assigns_var}[#{context_var_name.inspect}] = #{var_expr}"
end
# Call the partial method
code.line "__output__ << #{method_name}(#{inner_assigns_var})"
end
def self.compile_for_loop_render(tag, method_name, context_var_name, compiler, code)
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
template_name = tag.template_name_expr
coll_var = compiler.generate_var_name("coll")
coll_expr = ExpressionCompiler.compile(variable_name_expr, compiler)
code.line "#{coll_var} = #{coll_expr}"
code.line "if #{coll_var}.respond_to?(:each) && #{coll_var}.respond_to?(:count)"
code.indent do
len_var = compiler.generate_var_name("len")
idx_var = compiler.generate_var_name("idx")
code.line "#{len_var} = #{coll_var}.count"
code.line "#{idx_var} = 0"
code.line "#{coll_var}.each do |__item__|"
code.indent do
inner_assigns_var = compiler.generate_var_name("inner")
code.line "#{inner_assigns_var} = {}"
# Copy attributes
attributes.each do |key, value|
value_expr = ExpressionCompiler.compile(value, compiler)
code.line "#{inner_assigns_var}[#{key.inspect}] = #{value_expr}"
end
# Set the context variable
code.line "#{inner_assigns_var}[#{context_var_name.inspect}] = __item__"
# Set forloop
code.line "#{inner_assigns_var}['forloop'] = {"
code.indent do
code.line "'name' => #{template_name.inspect},"
code.line "'length' => #{len_var},"
code.line "'index' => #{idx_var} + 1,"
code.line "'index0' => #{idx_var},"
code.line "'rindex' => #{len_var} - #{idx_var},"
code.line "'rindex0' => #{len_var} - #{idx_var} - 1,"
code.line "'first' => #{idx_var} == 0,"
code.line "'last' => #{idx_var} == #{len_var} - 1,"
end
code.line "}"
# Call the partial method
code.line "__output__ << #{method_name}(#{inner_assigns_var})"
code.line "#{idx_var} += 1"
end
code.line "end"
end
code.line "else"
code.indent do
# Single render if not a collection
compile_single_render(tag, method_name, context_var_name, compiler, code)
end
code.line "end"
end
def self.compile_dynamic_render(tag, compiler, code)
template_name_expr = tag.template_name_expr
variable_name_expr = tag.variable_name_expr
attributes = tag.attributes
alias_name = tag.alias_name
is_for_loop = tag.for_loop?
if compiler.debug?
code.line "# Dynamic render (template name from variable)"
end
name_expr = ExpressionCompiler.compile(template_name_expr, compiler)
# Build attributes hash
attrs_var = compiler.generate_var_name("attrs")
code.line "#{attrs_var} = {}"
attributes.each do |key, value|
value_expr = ExpressionCompiler.compile(value, compiler)
code.line "#{attrs_var}[#{key.inspect}] = #{value_expr}"
end
var_expr = variable_name_expr ? ExpressionCompiler.compile(variable_name_expr, compiler) : "nil"
alias_expr = alias_name ? alias_name.inspect : "nil"
# Call the external handler for dynamic renders
code.line "__output__ << __external__.call(:render, #{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, #{is_for_loop}, __context__)"
end
end
end
end
end
@@ -0,0 +1,117 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% tablerow %} tags
#
# Creates HTML table rows from a collection
class TableRowCompiler
def self.compile(tag, compiler, code)
var_name = tag.variable_name
collection_expr = ExpressionCompiler.compile(tag.collection_name, compiler)
attributes = tag.attributes
# Generate unique variable names
coll_var = compiler.generate_var_name("coll")
idx_var = compiler.generate_var_name("idx")
len_var = compiler.generate_var_name("len")
cols_var = compiler.generate_var_name("cols")
row_var = compiler.generate_var_name("row")
col_var = compiler.generate_var_name("col")
# Get parameters from attributes hash
cols = attributes['cols']
cols_expr = cols ? ExpressionCompiler.compile(cols, compiler) : "nil"
# Evaluate the collection
code.line "#{coll_var} = #{collection_expr}"
code.line "#{coll_var} = #{coll_var}.to_a if #{coll_var}.is_a?(Range)"
# Handle limit and offset from attributes
offset = attributes['offset']
limit = attributes['limit']
if offset || limit
if offset
offset_expr = ExpressionCompiler.compile(offset, compiler)
if limit
limit_expr = ExpressionCompiler.compile(limit, compiler)
code.line "#{coll_var} = #{coll_var}.slice(LR.to_integer(#{offset_expr}), LR.to_integer(#{limit_expr})) || []"
else
code.line "#{coll_var} = #{coll_var}.drop(LR.to_integer(#{offset_expr}))"
end
elsif limit
limit_expr = ExpressionCompiler.compile(limit, compiler)
code.line "#{coll_var} = #{coll_var}.first(LR.to_integer(#{limit_expr}))"
end
end
# Setup loop variables
code.line "#{len_var} = #{coll_var}.respond_to?(:length) ? #{coll_var}.length : 0"
code.line "#{cols_var} = #{cols_expr} || #{len_var}"
code.line "#{idx_var} = 0"
code.line "#{row_var} = 1"
code.line "#{col_var} = 0"
body = tag.instance_variable_get(:@body)
# Output initial row (matches interpreter behavior: outputs before loop)
code.line "__output__ << \"<tr class=\\\"row1\\\">\\n\""
# The loop
code.line "(#{coll_var}.respond_to?(:each) ? #{coll_var} : []).each do |__item__|"
code.indent do
code.line "#{col_var} += 1"
# Output cell start
code.line "__output__ << \"<td class=\\\"col\#{#{col_var}}\\\">\""
# Set loop variables
code.line "assigns[#{var_name.inspect}] = __item__"
code.line "assigns['tablerowloop'] = {"
code.indent do
code.line "'length' => #{len_var},"
code.line "'index' => #{idx_var} + 1,"
code.line "'index0' => #{idx_var},"
code.line "'rindex' => #{len_var} - #{idx_var},"
code.line "'rindex0' => #{len_var} - #{idx_var} - 1,"
code.line "'first' => #{idx_var} == 0,"
code.line "'last' => #{idx_var} == #{len_var} - 1,"
code.line "'col' => #{col_var},"
code.line "'col0' => #{col_var} - 1,"
code.line "'row' => #{row_var},"
code.line "'col_first' => #{col_var} == 1,"
code.line "'col_last' => #{col_var} == #{cols_var},"
end
code.line "}"
# Compile the body
BlockBodyCompiler.compile(body, compiler, code)
# Output cell end
code.line "__output__ << '</td>'"
# End row and start new row if needed (but not on last item)
code.line "if #{col_var} == #{cols_var} && #{idx_var} != #{len_var} - 1"
code.indent do
code.line "__output__ << \"</tr>\\n<tr class=\\\"row\#{#{row_var} + 1}\\\">\""
code.line "#{col_var} = 0"
code.line "#{row_var} += 1"
end
code.line "end"
code.line "#{idx_var} += 1"
end
code.line "end"
# Close the final row
code.line "__output__ << \"</tr>\\n\""
# Clean up
code.line "assigns.delete(#{var_name.inspect})"
code.line "assigns.delete('tablerowloop')"
end
end
end
end
end
@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% unless %} / {% elsif %} / {% else %} / {% endunless %} tags
#
# Unless is like if, but the first condition is negated
class UnlessCompiler
def self.compile(tag, compiler, code)
blocks = tag.blocks
blocks.each_with_index do |block, index|
condition_expr = ConditionCompiler.compile(block, compiler)
if index == 0
# First block is negated (unless = if not)
code.line "unless #{condition_expr}"
elsif block.else?
code.line "else"
else
# Subsequent blocks (elsif) are normal
code.line "elsif #{condition_expr}"
end
code.indent do
if block.attachment
BlockBodyCompiler.compile(block.attachment, compiler, code)
end
end
end
code.line "end"
end
end
end
end
end
+37
View File
@@ -0,0 +1,37 @@
# frozen_string_literal: true
module Liquid
module Compile
# VariableCompiler compiles Liquid variable expressions ({{ ... }}) to Ruby code.
#
# A Variable consists of:
# - A name expression (the value to output)
# - Zero or more filters to apply
class VariableCompiler
# Compile a Variable node and append the result to the output buffer
# @param variable [Liquid::Variable] The variable node
# @param compiler [RubyCompiler] The main compiler instance
# @param code [CodeGenerator] The code generator
def self.compile(variable, compiler, code)
value_expr = compile_to_expression(variable, compiler)
code.line "__output__ << LR.output(#{value_expr})"
end
# Compile a Variable node to a Ruby expression (without output)
# @param variable [Liquid::Variable] The variable node
# @param compiler [RubyCompiler] The main compiler instance
# @return [String] Ruby code expression
def self.compile_to_expression(variable, compiler)
# Compile the base name expression
base_expr = ExpressionCompiler.compile(variable.name, compiler)
# Apply filters if any
if variable.filters && !variable.filters.empty?
FilterCompiler.compile(base_expr, variable.filters, compiler)
else
base_expr
end
end
end
end
end
+5 -1
View File
@@ -40,6 +40,10 @@ module Liquid
@global_filter = nil
@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[:file_system] ||= environment.file_system
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new
@@ -176,7 +180,7 @@ module Liquid
# Example:
# products == empty #=> products.empty?
def [](expression)
evaluate(Expression.parse(expression))
evaluate(Expression.parse(expression, @string_scanner))
end
def key?(key)
+2 -2
View File
@@ -41,7 +41,7 @@ module Liquid
# @return [Environment] The new environment instance.
def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil)
ret = new
ret.tags = Template::TagRegistry.new(tags) if tags
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
@@ -74,7 +74,7 @@ module Liquid
# Initializes a new environment instance.
# @api private
def initialize
@tags = Template::TagRegistry.new(Tags::STANDARD_TAGS)
@tags = Tags::STANDARD_TAGS.dup
@error_mode = :lax
@strainer_template = Class.new(StrainerTemplate).tap do |klass|
klass.add_filter(StandardFilters)
+97 -21
View File
@@ -10,37 +10,113 @@ module Liquid
'true' => true,
'false' => false,
'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
INTEGERS_REGEX = /\A(-?\d+)\z/
FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
DOT = ".".ord
ZERO = "0".ord
NINE = "9".ord
DASH = "-".ord
# Use an atomic group (?>...) to avoid pathological backtracing from
# 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)
return nil unless markup
class << self
def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
markup = markup.strip
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
markup = markup.strip # markup can be a frozen string
if (markup.start_with?('"') && markup.end_with?('"')) ||
(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
case markup
when INTEGERS_REGEX
Regexp.last_match(1).to_i
when RANGES_REGEX
RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2))
when FLOATS_REGEX
Regexp.last_match(1).to_f
else
if LITERALS.key?(markup)
LITERALS[markup]
def inner_parse(markup, ss, cache)
if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX
return RangeLookup.parse(
Regexp.last_match(1),
Regexp.last_match(2),
ss,
cache,
)
end
if (num = parse_number(markup, ss))
num
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
+64 -122
View File
@@ -1,66 +1,7 @@
# frozen_string_literal: true
require "strscan"
module Liquid
class Lexer1
SPECIALS = {
'|' => :pipe,
'.' => :dot,
':' => :colon,
',' => :comma,
'[' => :open_square,
']' => :close_square,
'(' => :open_round,
')' => :close_round,
'?' => :question,
'-' => :dash,
}.freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
SINGLE_STRING_LITERAL = /'[^\']*'/
DOUBLE_STRING_LITERAL = /"[^\"]*"/
STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL)
NUMBER_LITERAL = /-?\d+(\.\d+)?/
DOTDOT = /\.\./
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
WHITESPACE_OR_NOTHING = /\s*/
def initialize(input)
@ss = StringScanner.new(input)
end
def tokenize
@output = []
until @ss.eos?
@ss.skip(WHITESPACE_OR_NOTHING)
break if @ss.eos?
tok = if (t = @ss.scan(COMPARISON_OPERATOR))
[:comparison, t]
elsif (t = @ss.scan(STRING_LITERAL))
[:string, t]
elsif (t = @ss.scan(NUMBER_LITERAL))
[:number, t]
elsif (t = @ss.scan(IDENTIFIER))
[:id, t]
elsif (t = @ss.scan(DOTDOT))
[:dotdot, t]
else
c = @ss.getch
if (s = SPECIALS[c])
[s, c]
else
raise SyntaxError, "Unexpected character #{c}"
end
end
@output << tok
end
@output << [:end_of_string]
end
end
class Lexer2
class Lexer
CLOSE_ROUND = [:close_round, ")"].freeze
CLOSE_SQUARE = [:close_square, "]"].freeze
COLON = [:colon, ":"].freeze
@@ -92,6 +33,7 @@ module Liquid
SINGLE_COMPARISON_TOKENS = [].tap do |table|
table["<".ord] = COMPARISON_LESS_THAN
table[">".ord] = COMPARISON_GREATER_THAN
table.freeze
end
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
@@ -103,18 +45,17 @@ module Liquid
sub_table["=".ord] = COMPARISION_NOT_EQUAL
sub_table.freeze
end
table.freeze
end
COMPARISON_JUMP_TABLE = [].tap do |table|
table["<".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_LESS_THAN }
sub_table.freeze
end
table[">".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_GREATER_THAN }
sub_table.freeze
end
table.freeze
@@ -157,81 +98,82 @@ module Liquid
table.freeze
end
def initialize(input)
@ss = StringScanner.new(input)
end
# rubocop:disable Metrics/BlockNesting
def tokenize
@output = []
class << self
def tokenize(ss)
output = []
until @ss.eos?
@ss.skip(WHITESPACE_OR_NOTHING)
until ss.eos?
ss.skip(WHITESPACE_OR_NOTHING)
break if @ss.eos?
break if ss.eos?
start_pos = @ss.pos
peeked = @ss.peek_byte
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)]
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
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
@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)
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
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
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
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
[type, t]
raise_syntax_error(start_pos, ss)
end
else
raise_syntax_error(start_pos)
end
end
# 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
# rubocop:enable Metrics/BlockNesting
@output << EOS
end
def raise_syntax_error(start_pos)
@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}"
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
Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1
end
+2
View File
@@ -2,12 +2,14 @@
errors:
syntax:
tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}"
block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}"
assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"
capture: "Syntax Error in 'capture' - Valid syntax: capture [var]"
case: "Syntax Error in 'case' - Valid syntax: case [condition]"
case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}"
case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) "
cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"
doc_invalid_nested: "Syntax Error in 'doc' - Nested doc tags are not allowed"
for: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"
for_invalid_in: "For loops require an 'in' clause"
for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset"
+25 -3
View File
@@ -12,6 +12,18 @@ module Liquid
@locale = @template_options[:locale] ||= I18n.new
@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.partial = false
end
@@ -24,12 +36,22 @@ module Liquid
Liquid::BlockBody.new
end
def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false)
Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag)
def new_parser(input)
@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
def parse_expression(markup)
Expression.parse(markup)
Expression.parse(markup, @string_scanner, @expression_cache)
end
def partial=(value)
+1 -1
View File
@@ -36,7 +36,7 @@ module Liquid
protected
def children
@node.respond_to?(:nodelist) ? Array(@node.nodelist) : []
@node.respond_to?(:nodelist) ? Array(@node.nodelist) : Const::EMPTY_ARRAY
end
end
end
+2 -2
View File
@@ -3,8 +3,8 @@
module Liquid
class Parser
def initialize(input)
l = Lexer.new(input)
@tokens = l.tokenize
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = Lexer.tokenize(ss)
@p = 0 # pointer to current location
end
+3 -3
View File
@@ -2,9 +2,9 @@
module Liquid
class RangeLookup
def self.parse(start_markup, end_markup)
start_obj = Expression.parse(start_markup)
end_obj = Expression.parse(end_markup)
def self.parse(start_markup, end_markup, string_scanner, cache = nil)
start_obj = Expression.parse(start_markup, string_scanner, cache)
end_obj = Expression.parse(end_markup, string_scanner, cache)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj)
else
+153 -61
View File
@@ -3,7 +3,6 @@
require 'cgi'
require 'base64'
require 'bigdecimal'
module Liquid
module StandardFilters
MAX_I32 = (1 << 31) - 1
@@ -64,7 +63,7 @@ module Liquid
# @liquid_syntax string | downcase
# @liquid_return [string]
def downcase(input)
input.to_s.downcase
Utils.to_s(input).downcase
end
# @liquid_public_docs
@@ -75,7 +74,7 @@ module Liquid
# @liquid_syntax string | upcase
# @liquid_return [string]
def upcase(input)
input.to_s.upcase
Utils.to_s(input).upcase
end
# @liquid_public_docs
@@ -86,7 +85,7 @@ module Liquid
# @liquid_syntax string | capitalize
# @liquid_return [string]
def capitalize(input)
input.to_s.capitalize
Utils.to_s(input).capitalize
end
# @liquid_public_docs
@@ -97,7 +96,7 @@ module Liquid
# @liquid_syntax string | escape
# @liquid_return [string]
def escape(input)
CGI.escapeHTML(input.to_s) unless input.nil?
CGI.escapeHTML(Utils.to_s(input)) unless input.nil?
end
alias_method :h, :escape
@@ -109,7 +108,7 @@ module Liquid
# @liquid_syntax string | escape_once
# @liquid_return [string]
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
# @liquid_public_docs
@@ -124,7 +123,7 @@ module Liquid
# @liquid_syntax string | url_encode
# @liquid_return [string]
def url_encode(input)
CGI.escape(input.to_s) unless input.nil?
CGI.escape(Utils.to_s(input)) unless input.nil?
end
# @liquid_public_docs
@@ -138,7 +137,7 @@ module Liquid
def url_decode(input)
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?
result
@@ -152,7 +151,7 @@ module Liquid
# @liquid_syntax string | base64_encode
# @liquid_return [string]
def base64_encode(input)
Base64.strict_encode64(input.to_s)
Base64.strict_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -163,7 +162,7 @@ module Liquid
# @liquid_syntax string | base64_decode
# @liquid_return [string]
def base64_decode(input)
input = input.to_s
input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
@@ -177,7 +176,7 @@ module Liquid
# @liquid_syntax string | base64_url_safe_encode
# @liquid_return [string]
def base64_url_safe_encode(input)
Base64.urlsafe_encode64(input.to_s)
Base64.urlsafe_encode64(Utils.to_s(input))
end
# @liquid_public_docs
@@ -188,7 +187,7 @@ module Liquid
# @liquid_syntax string | base64_url_safe_decode
# @liquid_return [string]
def base64_url_safe_decode(input)
input = input.to_s
input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
@@ -212,7 +211,7 @@ module Liquid
if input.is_a?(Array)
input.slice(offset, length) || []
else
input.to_s.slice(offset, length) || ''
Utils.to_s(input).slice(offset, length) || ''
end
rescue RangeError
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
@@ -236,10 +235,10 @@ module Liquid
# @liquid_return [string]
def truncate(input, length = 50, truncate_string = "...")
return if input.nil?
input_str = input.to_s
input_str = Utils.to_s(input)
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 = 0 if l < 0
@@ -263,7 +262,7 @@ module Liquid
# @liquid_return [string]
def truncatewords(input, words = 15, truncate_string = "...")
return if input.nil?
input = input.to_s
input = Utils.to_s(input)
words = Utils.to_integer(words)
words = 1 if words <= 0
@@ -277,7 +276,8 @@ module Liquid
return input if wordlist.length <= words
wordlist.pop
wordlist.join(" ").concat(truncate_string.to_s)
truncate_string = Utils.to_s(truncate_string)
wordlist.join(" ").concat(truncate_string)
end
# @liquid_public_docs
@@ -288,7 +288,9 @@ module Liquid
# @liquid_syntax string | split: string
# @liquid_return [array[string]]
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
# @liquid_public_docs
@@ -299,7 +301,8 @@ module Liquid
# @liquid_syntax string | strip
# @liquid_return [string]
def strip(input)
input.to_s.strip
input = Utils.to_s(input)
input.strip
end
# @liquid_public_docs
@@ -310,7 +313,8 @@ module Liquid
# @liquid_syntax string | lstrip
# @liquid_return [string]
def lstrip(input)
input.to_s.lstrip
input = Utils.to_s(input)
input.lstrip
end
# @liquid_public_docs
@@ -321,7 +325,8 @@ module Liquid
# @liquid_syntax string | rstrip
# @liquid_return [string]
def rstrip(input)
input.to_s.rstrip
input = Utils.to_s(input)
input.rstrip
end
# @liquid_public_docs
@@ -332,8 +337,9 @@ module Liquid
# @liquid_syntax string | strip_html
# @liquid_return [string]
def strip_html(input)
input = Utils.to_s(input)
empty = ''
result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty)
result = input.gsub(STRIP_HTML_BLOCKS, empty)
result.gsub!(STRIP_HTML_TAGS, empty)
result
end
@@ -346,7 +352,8 @@ module Liquid
# @liquid_syntax string | strip_newlines
# @liquid_return [string]
def strip_newlines(input)
input.to_s.gsub(/\r?\n/, '')
input = Utils.to_s(input)
input.gsub(/\r?\n/, '')
end
# @liquid_public_docs
@@ -357,6 +364,7 @@ module Liquid
# @liquid_syntax array | join
# @liquid_return [string]
def join(input, glue = ' ')
glue = Utils.to_s(glue)
InputIterator.new(input, context).join(glue)
end
@@ -424,29 +432,59 @@ module Liquid
# @liquid_syntax array | where: string, string
# @liquid_return [array[untyped]]
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?
[]
elsif target_value.nil?
ary.select do |item|
item[property]
rescue TypeError
raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end
else
ary.select do |item|
item[property] == target_value
rescue TypeError
raise_property_error(property)
rescue NoMethodError
return nil unless item.respond_to?(:[])
raise
end
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Filters an array to exclude items with a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @liquid_syntax array | reject: string, string
# @liquid_return [array[untyped]]
def reject(input, property, target_value = nil)
filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) }
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Tests if any item in an array has a specific property value.
# @liquid_description
# This requires you to provide both the property name and the associated value.
# @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
# @liquid_public_docs
@@ -543,7 +581,10 @@ module Liquid
# @liquid_syntax string | replace: string, string
# @liquid_return [string]
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
# @liquid_public_docs
@@ -554,7 +595,10 @@ module Liquid
# @liquid_syntax string | replace_first: string, string
# @liquid_return [string]
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
# @liquid_public_docs
@@ -565,9 +609,9 @@ module Liquid
# @liquid_syntax string | replace_last: string, string
# @liquid_return [string]
def replace_last(input, string, replacement)
input = input.to_s
string = string.to_s
replacement = replacement.to_s
input = Utils.to_s(input)
string = Utils.to_s(string)
replacement = Utils.to_s(replacement)
start_index = input.rindex(string)
@@ -619,7 +663,9 @@ module Liquid
# @liquid_syntax string | append: string
# @liquid_return [string]
def append(input, string)
input.to_s + string.to_s
input = Utils.to_s(input)
string = Utils.to_s(string)
input + string
end
# @liquid_public_docs
@@ -648,7 +694,9 @@ module Liquid
# @liquid_syntax string | prepend: string
# @liquid_return [string]
def prepend(input, string)
string.to_s + input.to_s
input = Utils.to_s(input)
string = Utils.to_s(string)
string + input
end
# @liquid_public_docs
@@ -659,10 +707,20 @@ module Liquid
# @liquid_syntax string | newline_to_br
# @liquid_return [string]
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
# Reformat a date using Ruby's core Time#strftime( string ) -> string
# @liquid_public_docs
# @liquid_type filter
# @liquid_category date
# @liquid_summary
# Formats a date according to a specified format string.
# @liquid_description
# This filter formats a date using various format specifiers. If the format string is empty,
# the original input is returned. If the input cannot be converted to a date, the original input is returned.
#
# The following format specifiers can be used:
#
# %a - The abbreviated weekday name (``Sun'')
# %A - The full weekday name (``Sunday'')
@@ -691,14 +749,15 @@ module Liquid
# %Y - Year with century
# %Z - Time zone name
# %% - Literal ``%'' character
#
# See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
# @liquid_syntax date | date: string
# @liquid_return [string]
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))
date.strftime(format.to_s)
date.strftime(str_format)
end
# @liquid_public_docs
@@ -918,8 +977,27 @@ module Liquid
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)
raise Liquid::ArgumentError, "cannot select the property '#{property}'"
raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'"
end
def apply_operation(input, operand, operation)
@@ -968,7 +1046,18 @@ module Liquid
end
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
def concat(args)
@@ -980,7 +1069,10 @@ module Liquid
end
def uniq(&block)
to_a.uniq(&block)
to_a.uniq do |item|
item = Utils.to_liquid_value(item)
block ? yield(item) : item
end
end
def compact
-23
View File
@@ -1,23 +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, environment = Environment.default)
Deprecations.warn("StrainerFactory.add_global_filter", "Environment#register_filter")
environment.register_filter(filter)
end
def create(context, filters = Const::EMPTY_ARRAY, environment = Environment.default)
Deprecations.warn("StrainerFactory.create", "StrainerFactory.create_strainer")
environment.create_strainer(context, filters)
end
def global_filter_names(environment = Environment.default)
Deprecations.warn("StrainerFactory.global_filter_names", "Environment#filter_method_names")
Environment.strainer_template.filter_method_names
end
end
end
+2
View File
@@ -19,6 +19,7 @@ require_relative "tags/comment"
require_relative "tags/raw"
require_relative "tags/render"
require_relative "tags/cycle"
require_relative "tags/doc"
module Liquid
module Tags
@@ -42,6 +43,7 @@ module Liquid
'if' => If,
'echo' => Echo,
'tablerow' => TableRow,
'doc' => Doc,
}.freeze
end
end
+7 -1
View File
@@ -68,7 +68,13 @@ module Liquid
def variables_from_string(markup)
markup.split(',').collect do |var|
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
+1 -1
View File
@@ -10,7 +10,7 @@ module Liquid
# @liquid_description
# Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
# [snippets](/themes/architecture#snippets) included in the file.
# [snippets](/themes/architecture/snippets) included in the file.
#
# Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share
+77
View File
@@ -0,0 +1,77 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category syntax
# @liquid_name doc
# @liquid_summary
# Documents template elements with annotations.
# @liquid_description
# The `doc` tag allows developers to include documentation within Liquid
# templates. Any content inside `doc` tags is not rendered or outputted.
# Liquid code inside will be parsed but not executed. This facilitates
# tooling support for features like code completion, linting, and inline
# documentation.
#
# For detailed documentation syntax and examples, see the
# [`LiquidDoc` reference](/docs/storefronts/themes/tools/liquid-doc).
#
# @liquid_syntax
# {% doc %}
# Renders a message.
#
# @param {string} foo - A string value.
# @param {string} [bar] - An optional string value.
#
# @example
# {% render 'message', foo: 'Hello', bar: 'World' %}
# {% enddoc %}
class Doc < Block
NO_UNEXPECTED_ARGS = /\A\s*\z/
def initialize(tag_name, markup, parse_context)
super
ensure_valid_markup(tag_name, markup, parse_context)
end
def parse(tokens)
while (token = tokens.shift)
tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2)
raise_nested_doc_error if tag_name == @tag_name
if tag_name == block_delimiter
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
return
end
end
raise_tag_never_closed(block_name)
end
def render_to_output_buffer(_context, output)
output
end
def blank?
true
end
def nodelist
[]
end
private
def ensure_valid_markup(tag_name, markup, parse_context)
unless NO_UNEXPECTED_ARGS.match?(markup)
raise SyntaxError, parse_context.locale.t("errors.syntax.block_tag_unexpected_args", tag: tag_name)
end
end
def raise_nested_doc_error
raise SyntaxError, parse_context.locale.t("errors.syntax.doc_invalid_nested")
end
end
end
+1 -1
View File
@@ -88,7 +88,7 @@ module Liquid
end
def strict_parse(markup)
p = Parser.new(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
+1 -1
View File
@@ -102,7 +102,7 @@ module Liquid
end
def strict_parse(markup)
p = Parser.new(markup)
p = @parse_context.new_parser(markup)
condition = parse_binary_comparisons(p)
p.consume(:end_of_string)
condition
+1 -1
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category theme
# @liquid_name include
# @liquid_summary
# Renders a [snippet](/themes/architecture#snippets).
# Renders a [snippet](/themes/architecture/snippets).
# @liquid_description
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
# snippet.
+1 -1
View File
@@ -10,7 +10,7 @@ module Liquid
# @liquid_description
# Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
# [snippets](/themes/architecture#snippets) included in the file.
# [snippets](/themes/architecture/snippets) included in the file.
#
# Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share
+1 -1
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category theme
# @liquid_name render
# @liquid_summary
# Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
# Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
# @liquid_description
# Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside
# of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet)
+62
View File
@@ -205,6 +205,68 @@ module Liquid
render(context, output: output)
end
# Compile the template to Ruby code for fast, secure execution.
#
# Returns a CompiledTemplate that can be rendered repeatedly. On Ruby 4.0+,
# rendering happens in a secure sandbox. On earlier versions, a security
# warning is printed to STDERR.
#
# == Example
#
# template = Liquid::Template.parse("Hello, {{ name }}!")
# compiled = template.compile_to_ruby
#
# # Render (fast, secure on Ruby 4.0+)
# result = compiled.render({ "name" => "World" })
# # => "Hello, World!"
#
# # Access the generated Ruby source
# puts compiled.source
#
# # Check if execution is sandboxed
# compiled.secure? # => true on Ruby 4.0+
#
# == Options
#
# * <tt>:strict_variables</tt> - Raise on undefined variables (default: false)
# * <tt>:include_filters</tt> - Include helper methods for filters (default: true)
# * <tt>:debug</tt> - Include source comments in generated code (default: false)
#
# == Performance
#
# Compiled templates are ~1.5x faster than interpreted Liquid:
# * No Context object overhead
# * No filter invocation overhead (direct method calls)
# * No resource limits tracking
# * No stack-based scoping (uses Ruby's native scoping)
# * Direct string concatenation
#
# == Security
#
# On Ruby 4.0+, templates execute in a Ruby::Box sandbox that blocks:
# * File/network access
# * System calls (exec, spawn, fork)
# * Code loading (require, eval)
# * Dangerous metaprogramming
#
# On Ruby < 4.0, templates execute WITHOUT sandboxing.
# A warning is printed to STDERR on first execution.
#
# == Limitations
#
# * {% render %} and {% include %} resolved at compile time when possible
# * Custom tags need explicit compiler implementations
# * Custom filters must be available at runtime
#
def compile_to_ruby(options = {})
return nil if @root.nil?
require 'liquid/compile'
compiler = Compile::RubyCompiler.new(self, options)
code = compiler.compile
Compile::CompiledTemplate.new(code, compiler.external_tags, compiler.has_external_filters?)
end
private
def configure_options(options)
-44
View File
@@ -1,44 +0,0 @@
# frozen_string_literal: true
module Liquid
class Template
class TagRegistry
include Enumerable
def initialize(tags = nil)
@tags = {}
@cache = {}
tags.each { |tag_name, klass| self[tag_name] = klass }
Deprecations.warn("Template::TagRegistry", "Use a Environment instance with zeitwerk")
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 delete(tag_name)
Deprecations.warn("Template::TagRegistry#delete", "Use a Environment instance with immutable tags")
@tags.delete(tag_name)
@cache.delete(tag_name)
end
def []=(tag_name, klass)
@tags[tag_name] = klass.name
@cache[tag_name] = klass
end
def each(&block)
@tags.each(&block)
end
private
def lookup_class(name)
Object.const_get(name)
end
end
end
end
+132 -16
View File
@@ -1,20 +1,43 @@
# frozen_string_literal: true
require "strscan"
module Liquid
class Tokenizer
attr_reader :line_number, :for_liquid_tag
def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false)
@source = source
@line_number = line_number || (line_numbers ? 1 : nil)
TAG_END = /%\}/
TAG_OR_VARIABLE_START = /\{[\{\%]/
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
@offset = 0
@tokens = tokenize
@source = source.to_s.to_str
@offset = 0
@tokens = []
if @source
@ss = string_scanner
@ss.string = @source
tokenize
end
end
def shift
token = @tokens[@offset]
return nil unless token
return unless token
@offset += 1
@@ -28,18 +51,111 @@ module Liquid
private
def tokenize
return [] if @source.empty?
return @source.split("\n") if @for_liquid_tag
tokens = @source.split(TemplateParser)
# removes the rogue empty element at the beginning of the array
if tokens[0]&.empty?
@offset += 1
if @for_liquid_tag
@tokens = @source.split("\n")
else
@tokens << shift_normal until @ss.eos?
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
+96
View File
@@ -89,5 +89,101 @@ module Liquid
# Otherwise return the object itself
obj
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
+16 -10
View File
@@ -61,14 +61,14 @@ module Liquid
def strict_parse(markup)
@filters = []
p = Parser.new(markup)
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.parse_expression(p.expression)
while p.consume?(:pipe)
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)
end
p.consume(:end_of_string)
@@ -95,17 +95,23 @@ module Liquid
def render_to_output_buffer(context, output)
obj = render(context)
if obj.is_a?(Array)
output << obj.join
elsif obj.nil?
else
output << obj.to_s
end
render_obj_to_output(obj, output)
output
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)
false
end
+13 -5
View File
@@ -6,16 +6,20 @@ module Liquid
attr_reader :name, :lookups
def self.parse(markup)
new(markup)
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
new(markup, string_scanner, cache)
end
def initialize(markup)
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
lookups = markup.scan(VariableParser)
name = lookups.shift
if name&.start_with?('[') && name&.end_with?(']')
name = Expression.parse(name[1..-2])
name = Expression.parse(
name[1..-2],
string_scanner,
cache,
)
end
@name = name
@@ -25,7 +29,11 @@ module Liquid
@lookups.each_index do |i|
lookup = lookups[i]
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)
@command_flags |= 1 << i
end
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.6.0.alpha"
VERSION = "5.8.6"
end
+2 -2
View File
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
s.authors = ["Tobias Lütke"]
s.email = ["[email protected]"]
s.homepage = "http://www.liquidmarkup.org"
s.homepage = "https://shopify.github.io/liquid/"
s.license = "MIT"
# s.description = "A secure, non-evaling end user template engine with aesthetic markup."
@@ -28,7 +28,7 @@ Gem::Specification.new do |s|
s.require_path = "lib"
s.add_dependency("strscan")
s.add_dependency("strscan", ">= 3.1.1")
s.add_dependency("bigdecimal")
s.add_development_dependency('rake', '~> 13.0')
+10 -6
View File
@@ -4,18 +4,22 @@ require 'benchmark/ips'
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
Benchmark.ips do |x|
x.time = 10
x.warmup = 5
x.time = 20
x.warmup = 10
puts
puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)."
puts
x.report("parse:") { profiler.compile }
x.report("render:") { profiler.render }
x.report("parse & render:") { profiler.run }
phase = ENV["PHASE"] || "all"
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
+222
View File
@@ -0,0 +1,222 @@
# frozen_string_literal: true
# Benchmark comparing compiled Ruby code vs interpreted Liquid rendering
#
# Usage:
# ruby performance/compile_benchmark.rb
#
# This benchmark compares:
# 1. Standard Liquid render (interpreted)
# 2. Compiled Ruby render (compiled once, executed many times)
# 3. Compile + render (includes compilation overhead)
require 'benchmark/ips'
require_relative 'ruby33_compat' # Add peek_byte for Ruby 3.3 compatibility
require_relative 'shopify/liquid'
require_relative 'shopify/database'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
# Combined filter handler that includes all Shopify filters
class ShopifyFilterHandler
include JsonFilter
include MoneyFilter
include ShopFilter
include TagFilter
include WeightFilter
end
class CompileBenchmarkRunner
def initialize
@templates = []
@compiled_procs = []
@filter_handler = ShopifyFilterHandler.new
# Load test templates
load_templates
end
def load_templates
puts "Loading templates..."
test_dirs = Dir[__dir__ + '/tests/*']
test_dirs.each do |dir|
next unless File.directory?(dir)
Dir[dir + '/*.liquid'].each do |file|
next if File.basename(file) == 'theme.liquid'
source = File.read(file)
template = Liquid::Template.parse(source)
@templates << {
name: File.basename(file),
source: source,
template: template,
assigns: Database.tables.dup,
}
end
end
puts "Loaded #{@templates.size} templates"
# Pre-compile all templates
puts "Pre-compiling templates to Ruby..."
@templates.each do |t|
begin
compiled = t[:template].compile_to_ruby
compiled.filter_handler = @filter_handler # Set the filter handler
t[:compiled] = compiled # CompiledTemplate object
t[:ruby_code] = compiled.code
notes = []
notes << "#{compiled.external_tags.size} external tag(s)" if compiled.has_external_tags?
notes << "external filters" if compiled.has_external_filters?
puts " #{t[:name]}: #{notes.join(', ')}" unless notes.empty?
rescue => e
puts " Warning: Failed to compile #{t[:name]}: #{e.message}"
puts " #{e.backtrace.first(3).join("\n ")}"
t[:compiled] = nil
end
end
compilable = @templates.count { |t| t[:compiled] }
puts "Successfully compiled #{compilable}/#{@templates.size} templates"
puts
end
# Benchmark: Standard Liquid render
def render_interpreted
@templates.each do |t|
t[:template].render!(t[:assigns].dup)
end
end
# Benchmark: Compiled Ruby render (already compiled)
def render_compiled
@templates.each do |t|
next unless t[:compiled]
t[:compiled].call(t[:assigns].dup)
end
end
# Benchmark: Compile + render (includes compilation time)
def compile_and_render
@templates.each do |t|
compiled = t[:template].compile_to_ruby
compiled.filter_handler = @filter_handler
compiled.call(t[:assigns].dup)
end
end
# Show sample output comparison
def verify_output
puts "Verifying output equivalence..."
@templates.each do |t|
next unless t[:compiled]
assigns = t[:assigns].dup
interpreted = t[:template].render!(assigns.dup)
compiled = t[:compiled].call(assigns.dup)
if interpreted == compiled
puts "#{t[:name]}: outputs match"
else
puts "#{t[:name]}: outputs differ!"
puts " Interpreted length: #{interpreted.length}"
puts " Compiled length: #{compiled.length}"
# Show first difference
interpreted.chars.each_with_index do |c, i|
if compiled[i] != c
puts " First diff at position #{i}:"
puts " Interpreted: #{interpreted[i-10, 30].inspect}"
puts " Compiled: #{compiled[i-10, 30].inspect}"
break
end
end
end
end
puts
end
# Show code size comparison
def show_stats
puts "Template Statistics:"
puts "=" * 60
total_liquid_size = 0
total_ruby_size = 0
@templates.each do |t|
next unless t[:ruby_code]
liquid_size = t[:source].length
ruby_size = t[:ruby_code].length
total_liquid_size += liquid_size
total_ruby_size += ruby_size
ratio = ruby_size.to_f / liquid_size
puts " #{t[:name]}: Liquid=#{liquid_size}b, Ruby=#{ruby_size}b (#{ratio.round(1)}x)"
end
puts "-" * 60
puts " Total: Liquid=#{total_liquid_size}b, Ruby=#{total_ruby_size}b"
puts
end
def run_benchmark
puts "Running benchmark..."
puts "=" * 60
puts
# Run quick timing to get approximate values for summary
iterations = 50
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
iterations.times { render_interpreted }
interpreted_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
iterations.times { render_compiled }
compiled_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
speedup = interpreted_time / compiled_time
Benchmark.ips do |x|
x.time = 10
x.warmup = 5
x.report("Liquid render (interpreted):") do
render_interpreted
end
x.report("Ruby render (pre-compiled):") do
render_compiled
end
x.report("Compile + render:") do
compile_and_render
end
x.compare!
end
# Print clear summary
puts
puts "=" * 60
puts "SUMMARY:"
puts " ✓ Pre-compiled Ruby is #{speedup.round(2)}x FASTER than interpreted Liquid"
puts
puts " (The 'X slower' above means compared to the fastest option,"
puts " which is pre-compiled Ruby. Higher i/s = faster.)"
puts "=" * 60
end
end
# Run the benchmark
runner = CompileBenchmarkRunner.new
runner.show_stats
runner.verify_output
runner.run_benchmark
+319
View File
@@ -0,0 +1,319 @@
# frozen_string_literal: true
# Compile Profiler - Measure allocations and performance of compiled vs interpreted Liquid
#
# Usage:
# RUBY_BOX=1 ruby -W:no-experimental performance/compile_profiler.rb
#
# This tool measures:
# - Allocation count (objects created during render)
# - Time per render
# - Comparison between interpreted and compiled
#
# Results are appended to ../timings.jsonl with git hash and timestamp
#
# REQUIRES Ruby 4.0+ with RUBY_BOX=1
unless ENV['RUBY_BOX'] == '1'
$stderr.puts "\e[31mERROR: Must run with RUBY_BOX=1\e[0m"
$stderr.puts "Usage: RUBY_BOX=1 ruby -W:no-experimental performance/compile_profiler.rb"
exit 1
end
require 'json'
require 'time'
require_relative '../lib/liquid'
require_relative '../lib/liquid/compile'
unless Liquid::Box.secure?
$stderr.puts "\e[31mERROR: Ruby::Box not available. Requires Ruby 4.0+\e[0m"
exit 1
end
class CompileProfiler
COLORS = {
reset: "\e[0m",
bold: "\e[1m",
red: "\e[31m",
green: "\e[32m",
yellow: "\e[33m",
blue: "\e[34m",
magenta: "\e[35m",
cyan: "\e[36m",
gray: "\e[90m",
}.freeze
BOX_CHARS = {
tl: "", tr: "", bl: "", br: "",
h: "", v: "",
check: "", cross: "", arrow: "", delta: "Δ",
}.freeze
TIMINGS_FILE = File.expand_path('../../timings.jsonl', __dir__)
def initialize
@results = {}
@git_hash = `git rev-parse --short HEAD 2>/dev/null`.strip
@git_hash = "unknown" if @git_hash.empty?
@timestamp = Time.now.utc.iso8601
end
def c(color, text)
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
end
def box(title, width: 70)
puts
puts "#{BOX_CHARS[:tl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:tr]}"
puts "#{BOX_CHARS[:v]} #{c(:bold, title)}#{' ' * (width - 4 - title.length)} #{BOX_CHARS[:v]}"
yield if block_given?
puts "#{BOX_CHARS[:bl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:br]}"
end
# Calculate visible length (excluding ANSI codes)
def visible_length(str)
str.gsub(/\e\[[0-9;]*m/, '').length
end
def row(label, value, width: 70)
label_str = label.to_s
value_str = value.to_s
label_visible = visible_length(label_str)
value_visible = visible_length(value_str)
padding = width - 4 - label_visible - value_visible
padding = 1 if padding < 1
puts "#{BOX_CHARS[:v]} #{label_str}#{' ' * padding}#{value_str} #{BOX_CHARS[:v]}"
end
def separator(width: 70)
puts "#{BOX_CHARS[:v]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:v]}"
end
def measure_allocations
GC.start
GC.disable
before = GC.stat(:total_allocated_objects)
yield
after = GC.stat(:total_allocated_objects)
GC.enable
after - before
end
def measure_time(iterations: 100)
GC.start
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
iterations.times { yield }
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
(elapsed / iterations * 1_000_000).round(2) # microseconds
end
def measure_objects
before = ObjectSpace.count_objects.dup
yield
after = ObjectSpace.count_objects
diff = {}
after.each do |k, v|
d = v - (before[k] || 0)
diff[k] = d if d > 0
end
diff
end
def profile_template(name, source, assigns, iterations: 100)
puts
puts c(:cyan, "#{BOX_CHARS[:arrow]} Profiling: #{c(:bold, name)}")
puts c(:gray, " Template: #{source[0..60]}#{'...' if source.length > 60}")
template = Liquid::Template.parse(source)
compiled = template.compile_to_ruby
# Warmup
10.times { template.render(assigns.dup) }
10.times { compiled.render(assigns.dup) }
# Measure interpreted
interp_allocs = measure_allocations { template.render(assigns.dup) }
interp_time = measure_time(iterations: iterations) { template.render(assigns.dup) }
interp_objects = measure_objects { template.render(assigns.dup) }
# Measure compiled
comp_allocs = measure_allocations { compiled.render(assigns.dup) }
comp_time = measure_time(iterations: iterations) { compiled.render(assigns.dup) }
comp_objects = measure_objects { compiled.render(assigns.dup) }
# Calculate deltas
alloc_delta = ((comp_allocs.to_f / interp_allocs - 1) * 100).round(1)
time_delta = ((comp_time / interp_time - 1) * 100).round(1)
alloc_color = alloc_delta < 0 ? :green : :red
time_color = time_delta < 0 ? :green : :red
box(name) do
# Header row
header = "#{' ' * 20}#{c(:gray, 'Interpreted')} #{c(:cyan, 'Compiled')} #{c(:yellow, 'Delta')}"
row(header, "")
separator
# Data rows with fixed-width columns
alloc_delta_str = "#{alloc_delta > 0 ? '+' : ''}#{alloc_delta}%"
time_delta_str = "#{time_delta > 0 ? '+' : ''}#{time_delta}%"
row("Allocations", "#{interp_allocs.to_s.rjust(11)} #{comp_allocs.to_s.rjust(8)} #{c(alloc_color, alloc_delta_str.rjust(7))}")
row("Time (μs)", "#{interp_time.to_s.rjust(11)} #{comp_time.to_s.rjust(8)} #{c(time_color, time_delta_str.rjust(7))}")
separator
row("Objects (compiled):", "")
comp_objects.sort_by { |_, v| -v }.first(3).each do |type, count|
row(" #{type}", count.to_s)
end
end
@results[name] = {
interp_allocs: interp_allocs,
comp_allocs: comp_allocs,
interp_time: interp_time,
comp_time: comp_time,
alloc_delta: alloc_delta,
time_delta: time_delta,
}
end
def print_summary
return if @results.empty?
width = 70
total_interp_allocs = @results.values.sum { |r| r[:interp_allocs] }
total_comp_allocs = @results.values.sum { |r| r[:comp_allocs] }
total_interp_time = @results.values.sum { |r| r[:interp_time] }
total_comp_time = @results.values.sum { |r| r[:comp_time] }
alloc_improvement = ((1 - total_comp_allocs.to_f / total_interp_allocs) * 100).round(1)
time_improvement = ((1 - total_comp_time / total_interp_time) * 100).round(1)
alloc_icon = alloc_improvement > 0 ? c(:green, BOX_CHARS[:check]) : c(:red, BOX_CHARS[:cross])
time_icon = time_improvement > 0 ? c(:green, BOX_CHARS[:check]) : c(:red, BOX_CHARS[:cross])
alloc_text = "#{alloc_icon} Allocations: #{c(:bold, "#{alloc_improvement}%")} #{alloc_improvement > 0 ? 'fewer' : 'more'} (#{total_comp_allocs} vs #{total_interp_allocs})"
time_text = "#{time_icon} Time: #{c(:bold, "#{time_improvement}%")} #{time_improvement > 0 ? 'faster' : 'slower'} (#{total_comp_time.round(0)}μs vs #{total_interp_time.round(0)}μs)"
puts
puts "#{BOX_CHARS[:tl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:tr]}"
title = "SUMMARY"
title_pad = (width - 4 - title.length) / 2
puts "#{BOX_CHARS[:v]} #{' ' * title_pad}#{c(:bold, title)}#{' ' * (width - 4 - title_pad - title.length)} #{BOX_CHARS[:v]}"
puts "#{BOX_CHARS[:v]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:v]}"
alloc_pad = width - 4 - visible_length(alloc_text)
puts "#{BOX_CHARS[:v]} #{alloc_text}#{' ' * alloc_pad} #{BOX_CHARS[:v]}"
time_pad = width - 4 - visible_length(time_text)
puts "#{BOX_CHARS[:v]} #{time_text}#{' ' * time_pad} #{BOX_CHARS[:v]}"
puts "#{BOX_CHARS[:bl]}#{BOX_CHARS[:h] * (width - 2)}#{BOX_CHARS[:br]}"
# Write to timings.jsonl
write_timings(total_interp_allocs, total_comp_allocs, total_interp_time, total_comp_time,
alloc_improvement, time_improvement)
end
def write_timings(total_interp_allocs, total_comp_allocs, total_interp_time, total_comp_time,
alloc_improvement, time_improvement)
entry = {
timestamp: @timestamp,
git_hash: @git_hash,
ruby_version: RUBY_VERSION,
summary: {
alloc_improvement_pct: alloc_improvement,
time_improvement_pct: time_improvement,
total_interp_allocs: total_interp_allocs,
total_comp_allocs: total_comp_allocs,
total_interp_time_us: total_interp_time.round(2),
total_comp_time_us: total_comp_time.round(2),
},
benchmarks: @results.transform_values { |r|
{
interp_allocs: r[:interp_allocs],
comp_allocs: r[:comp_allocs],
interp_time_us: r[:interp_time],
comp_time_us: r[:comp_time],
alloc_delta_pct: r[:alloc_delta],
time_delta_pct: r[:time_delta],
}
}
}
File.open(TIMINGS_FILE, 'a') do |f|
f.puts JSON.generate(entry)
end
puts
puts c(:gray, "Results appended to #{TIMINGS_FILE}")
end
def run_all
puts c(:bold, "\n🔬 Liquid Compile Profiler")
puts c(:gray, " Measuring allocations and performance...\n")
profile_template(
"Simple variable",
"Hello, {{ name }}!",
{ "name" => "World" }
)
profile_template(
"Variable with filter",
"{{ name | upcase | prepend: 'Hello, ' | append: '!' }}",
{ "name" => "world" }
)
profile_template(
"Simple loop",
"{% for item in items %}{{ item }} {% endfor %}",
{ "items" => %w[a b c d e] }
)
profile_template(
"Loop with forloop",
"{% for item in items %}{{ forloop.index }}: {{ item }} {% endfor %}",
{ "items" => %w[a b c d e] }
)
profile_template(
"Nested loop",
"{% for i in outer %}{% for j in inner %}{{ i }}.{{ j }} {% endfor %}{% endfor %}",
{ "outer" => [1, 2, 3], "inner" => %w[a b c] }
)
profile_template(
"Conditionals",
"{% if show %}{% if big %}BIG{% else %}small{% endif %}{% else %}hidden{% endif %}",
{ "show" => true, "big" => false }
)
profile_template(
"Property access",
"{{ user.profile.name }} - {{ user.profile.email }}",
{ "user" => { "profile" => { "name" => "Alice", "email" => "[email protected]" } } }
)
profile_template(
"Complex template",
<<~LIQUID,
{% for product in products %}
{{ forloop.index }}. {{ product.name | upcase }}
{% if product.on_sale %}SALE: ${{ product.price | times: 0.8 }}{% else %}${{ product.price }}{% endif %}
{% endfor %}
LIQUID
{
"products" => [
{ "name" => "Widget", "price" => 100, "on_sale" => true },
{ "name" => "Gadget", "price" => 200, "on_sale" => false },
{ "name" => "Gizmo", "price" => 150, "on_sale" => true },
]
}
)
print_summary
end
end
# Run the profiler
if __FILE__ == $0
profiler = CompileProfiler.new
profiler.run_all
end
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
# Compatibility shim for Ruby 3.3
# The Liquid library uses peek_byte and scan_byte which are only available in Ruby 3.4+
require 'strscan'
unless StringScanner.method_defined?(:peek_byte)
class StringScanner
def peek_byte
return nil if eos?
string.getbyte(pos)
end
end
end
unless StringScanner.method_defined?(:scan_byte)
class StringScanner
def scan_byte
return nil if eos?
byte = string.getbyte(pos)
self.pos += 1
byte
end
end
end
+8 -7
View File
@@ -11,11 +11,12 @@ require_relative 'shop_filter'
require_relative 'tag_filter'
require_relative 'weight_filter'
Liquid::Template.register_tag('paginate', Paginate)
Liquid::Template.register_tag('form', CommentForm)
default_environment = Liquid::Environment.default
default_environment.register_tag('paginate', Paginate)
default_environment.register_tag('form', CommentForm)
Liquid::Template.register_filter(JsonFilter)
Liquid::Template.register_filter(MoneyFilter)
Liquid::Template.register_filter(WeightFilter)
Liquid::Template.register_filter(ShopFilter)
Liquid::Template.register_filter(TagFilter)
default_environment.register_filter(JsonFilter)
default_environment.register_filter(MoneyFilter)
default_environment.register_filter(WeightFilter)
default_environment.register_filter(ShopFilter)
default_environment.register_filter(TagFilter)
+13
View File
@@ -48,6 +48,19 @@ class ThemeRunner
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
def run
each_test do |liquid, layout, assigns, page_template, template_name|
+94
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
+2 -21
View File
@@ -29,31 +29,12 @@ EXPRESSIONS = [
"foo | default: -1",
]
EXPRESSIONS.each do |expr|
lexer_1_result = Liquid::Lexer1.new(expr).tokenize
lexer_2_result = Liquid::Lexer2.new(expr).tokenize
next if lexer_1_result == lexer_2_result
warn "Lexer1 and Lexer2 results are different for expression: #{expr}"
warn "expected: #{lexer_1_result}"
warn "got: #{lexer_2_result}"
abort
end
Benchmark.ips do |x|
x.config(time: 10, warmup: 5)
x.report("Liquid::Lexer1#tokenize") do
x.report("Liquid::Lexer#tokenize") do
EXPRESSIONS.each do |expr|
l = Liquid::Lexer1.new(expr)
l.tokenize
end
end
x.report("Liquid::Lexer2#tokenize") do
EXPRESSIONS.each do |expr|
l = Liquid::Lexer2.new(expr)
l = Liquid::Lexer.new(expr)
l.tokenize
end
end
+4 -5
View File
@@ -203,20 +203,19 @@ class ErrorHandlingTest < Minitest::Test
end
def test_setting_default_exception_renderer
old_exception_renderer = Liquid::Template.default_exception_renderer
exceptions = []
Liquid::Template.default_exception_renderer = ->(e) {
default_exception_renderer = ->(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)
assert_equal('This is a runtime error: ', output)
assert_equal([Liquid::ArgumentError], template.errors.map(&:class))
ensure
Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer
end
def test_setting_exception_renderer_on_environment
+107
View File
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'test_helper'
require 'lru_redux'
class ExpressionTest < Minitest::Test
def test_keyword_literals
@@ -13,6 +14,7 @@ class ExpressionTest < Minitest::Test
assert_template_result("double quoted", '{{"double quoted"}}')
assert_template_result("spaced", "{{ 'spaced' }}")
assert_template_result("spaced2", "{{ 'spaced2' }}")
assert_template_result("emoji🔥", "{{ 'emoji🔥' }}")
end
def test_int
@@ -22,8 +24,18 @@ class ExpressionTest < Minitest::Test
end
def test_float
assert_template_result("-17.42", "{{ -17.42 }}")
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")
# 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
def test_range
@@ -40,6 +52,101 @@ class ExpressionTest < Minitest::Test
)
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
def assert_expression_result(expect, markup, **assigns)
+106
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
+8
View File
@@ -134,6 +134,14 @@ class ParsingQuirksTest < Minitest::Test
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 }}")
+1 -1
View File
@@ -33,7 +33,7 @@ class ProfilerTest < Minitest::Test
end
def setup
Liquid::Template.file_system = ProfilingFileSystem.new
Liquid::Environment.default.file_system = ProfilingFileSystem.new
end
def test_template_allows_flagging_profiling
+278 -6
View File
@@ -133,6 +133,18 @@ class StandardFiltersTest < Minitest::Test
assert_equal([], @filters.slice(input, -(1 << 63), 6))
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
assert_equal('1234...', @filters.truncate('1234567890', 7))
assert_equal('1234567890', @filters.truncate('1234567890', 20))
@@ -281,6 +293,16 @@ class StandardFiltersTest < Minitest::Test
assert_equal('1121314', @filters.join([1, 2, 3, 4], 1))
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
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"))
@@ -538,12 +560,23 @@ class StandardFiltersTest < Minitest::Test
end
end
def test_map_returns_empty_with_no_property
def test_map_with_value_property
array = [
{ "handle" => "alpha", "value" => "A" },
{ "handle" => "beta", "value" => "B" },
{ "handle" => "gamma", "value" => "C" }
]
assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array })
end
def test_map_returns_input_with_no_property
foo = [
[1],
[2],
[3],
]
assert_raises(Liquid::ArgumentError) do
@filters.map(foo, nil)
end
@@ -827,21 +860,233 @@ class StandardFiltersTest < Minitest::Test
assert_template_result('abc', "{{ 'abc' | date: '%D' }}")
end
def test_where
input = [
def test_reject
array = [
{ "handle" => "alpha", "ok" => true },
{ "handle" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "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" => "beta", "ok" => false },
{ "handle" => "gamma", "ok" => false },
{ "handle" => "delta", "ok" => true },
]
assert_equal(expectation, @filters.where(input, "ok", true))
assert_equal(expectation, @filters.where(input, "ok"))
template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}"
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_empty_string_is_a_no_op
environment = { "array" => ["alpha", "beta", "gamma"] }
expected_output = "alpha beta gamma"
template = "{{ array | where: '' | join: ' ' }}"
assert_template_result(expected_output, template, environment)
end
def test_where_with_nil_is_a_no_op
environment = { "array" => ["alpha", "beta", "gamma"] }
template = "{{ array | where: nil | join: ' ' }}"
assert_raises(Liquid::ArgumentError) do
assert_template_result("alpha beta gamma", template, environment)
end
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
def test_where_string_keys
@@ -1051,6 +1296,33 @@ class StandardFiltersTest < Minitest::Test
assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
end
def test_sum_with_non_string_property
input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }]
assert_equal(1, @filters.sum(input, true))
assert_equal(0.2, @filters.sum(input, 1.0))
assert_equal(-0.3, @filters.sum(input, 1))
assert_equal(0.4, @filters.sum(input, (1..5)))
assert_equal(0, @filters.sum(input, nil))
assert_equal(0, @filters.sum(input, ""))
end
def test_uniq_with_to_liquid_value
input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"]
expected = [StringDrop.new("foo"), StringDrop.new("bar")]
result = @filters.uniq(input)
assert_equal(expected, result)
end
def test_uniq_with_to_liquid_value_pick_correct_classes
input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")]
expected = [String, StringDrop]
result = @filters.uniq(input).map(&:class)
assert_equal(expected, result)
end
private
def with_timezone(tz)
+48
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
+15 -11
View File
@@ -174,10 +174,10 @@ class IncludeTagTest < Minitest::Test
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
Template.parse("{% include 'loop' %}").render!
Template.parse("{% include 'loop' %}", environment: env).render!
end
end
@@ -264,26 +264,27 @@ class IncludeTagTest < Minitest::Test
end
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!
assert_empty(a.errors)
end
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
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
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
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
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
@@ -334,8 +335,11 @@ class IncludeTagTest < Minitest::Test
end
def test_including_with_strict_variables
Liquid::Template.file_system = StubFileSystem.new({ "simple" => "simple" })
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn)
env = Liquid::Environment.build(
file_system: StubFileSystem.new('simple' => 'simple'),
)
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env)
template.render(nil, strict_variables: true)
assert_equal([], template.errors)
+1
View File
@@ -16,6 +16,7 @@ class RawTagTest < Minitest::Test
assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- 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
def test_open_tag_in_raw
+8 -5
View File
@@ -82,19 +82,22 @@ class RenderTagTest < Minitest::Test
end
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
Template.parse('{% render "loop" %}').render!
Template.parse('{% render "loop" %}', environment: env).render!
end
end
def test_sub_contexts_count_towards_the_same_recursion_limit
Liquid::Template.file_system = StubFileSystem.new(
'loop_render' => '{% render "loop_render" %}',
env = Liquid::Environment.build(
file_system: StubFileSystem.new('loop_render' => '{% render "loop_render" %}'),
)
assert_raises(Liquid::StackLevelError) do
Template.parse('{% render "loop_render" %}').render!
Template.parse('{% render "loop_render" %}', environment: env).render!
end
end
+4
View File
@@ -130,6 +130,10 @@ class VariableTest < Minitest::Test
assert_template_result('bar', '{{ foo }}', { 'foo' => :bar })
end
def test_nested_array
assert_template_result('', '{{ foo }}', { 'foo' => [[nil]] })
end
def test_dynamic_find_var
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
+33 -9
View File
@@ -13,12 +13,7 @@ if (env_mode = ENV['LIQUID_PARSER_MODE'])
puts "-- #{env_mode.upcase} ERROR MODE"
mode = env_mode.to_sym
end
Liquid::Template.error_mode = mode
if ENV['LIQUID_C'] == '1'
puts "-- LIQUID C"
require 'liquid/c'
end
Liquid::Environment.default.error_mode = mode
if Minitest.const_defined?('Test')
# We're on Minitest 5+. Nothing to do here.
@@ -88,11 +83,11 @@ module Minitest
end
def with_error_mode(mode)
old_mode = Liquid::Template.error_mode
Liquid::Template.error_mode = mode
old_mode = Liquid::Environment.default.error_mode
Liquid::Environment.default.error_mode = mode
yield
ensure
Liquid::Template.error_mode = old_mode
Liquid::Environment.default.error_mode = old_mode
end
def with_custom_tag(tag_name, tag_class, &block)
@@ -151,6 +146,35 @@ class BooleanDrop < Liquid::Drop
end
end
class StringDrop < Liquid::Drop
include Comparable
def initialize(value)
super()
@value = value
end
def to_liquid_value
@value
end
def to_s
@value
end
def to_str
@value
end
def inspect
"#<StringDrop @value=#{@value.inspect}>"
end
def <=>(other)
to_liquid_value <=> Liquid::Utils.to_liquid_value(other)
end
end
class ErrorDrop < Liquid::Drop
def standard_error
raise Liquid::StandardError, 'standard error'
+7 -1
View File
@@ -47,12 +47,18 @@ class BlockUnitTest < Minitest::Test
)
end
def test_with_block
def test_comment_tag_with_block
template = Liquid::Template.parse(" {% comment %} {% endcomment %} ")
assert_equal([String, Comment, String], block_types(template.root.nodelist))
assert_equal(3, template.root.nodelist.size)
end
def test_doc_tag_with_block
template = Liquid::Template.parse(" {% doc %} {% enddoc %} ")
assert_equal([String, Doc, String], block_types(template.root.nodelist))
assert_equal(3, template.root.nodelist.size)
end
private
def block_types(nodelist)
+211
View File
@@ -0,0 +1,211 @@
# frozen_string_literal: true
require 'test_helper'
require 'yaml'
# Load Shopify-style tags and filters for performance templates
require_relative '../../performance/shopify/comment_form'
require_relative '../../performance/shopify/paginate'
require_relative '../../performance/shopify/json_filter'
require_relative '../../performance/shopify/money_filter'
require_relative '../../performance/shopify/shop_filter'
require_relative '../../performance/shopify/tag_filter'
require_relative '../../performance/shopify/weight_filter'
# Acceptance tests for compiled templates
#
# These tests run every performance benchmark template through both
# the interpreted Liquid renderer and the compiled Ruby renderer,
# verifying that outputs match exactly.
#
# Run with: RUBY_BOX=1 ruby -W:no-experimental -Ilib:test test/unit/compile_acceptance_test.rb
class CompileAcceptanceTest < Minitest::Test
include Liquid
PERFORMANCE_DIR = File.expand_path('../../performance', __dir__)
TESTS_DIR = File.join(PERFORMANCE_DIR, 'tests')
DATABASE_FILE = File.join(PERFORMANCE_DIR, 'shopify/vision.database.yml')
class << self
def database
@database ||= load_database
end
def load_database
db = if YAML.respond_to?(:unsafe_load_file)
YAML.unsafe_load_file(DATABASE_FILE)
else
YAML.load_file(DATABASE_FILE)
end
# From vision source - link products to collections
db['products'].each do |product|
collections = db['collections'].find_all do |collection|
collection['products'].any? { |p| p['id'].to_i == product['id'].to_i }
end
product['collections'] = collections
end
# Key tables by handles
db = db.each_with_object({}) do |(key, values), assigns|
assigns[key] = values.each_with_object({}) do |v, h|
h[v['handle']] = v
end
end
# Standard direct accessors
db['collection'] = db['collections'].values.first
db['product'] = db['products'].values.first
db['blog'] = db['blogs'].values.first
db['article'] = db['blog']['articles'].first
db['cart'] = {
'total_price' => db['line_items'].values.inject(0) { |sum, item| sum + item['line_price'] * item['quantity'] },
'item_count' => db['line_items'].values.inject(0) { |sum, item| sum + item['quantity'] },
'items' => db['line_items'].values,
}
db
end
def register_shopify_extensions!
return if @extensions_registered
env = Liquid::Environment.default
env.register_tag('paginate', Paginate)
env.register_tag('form', CommentForm)
env.register_filter(JsonFilter)
env.register_filter(MoneyFilter)
env.register_filter(WeightFilter)
env.register_filter(ShopFilter)
env.register_filter(TagFilter)
@extensions_registered = true
end
end
# File system for {% render %} and {% include %} tags
class TestFileSystem
def initialize(path)
@path = path
end
def read_template_file(template_path)
File.read(File.join(@path, "#{template_path}.liquid"))
end
end
def setup
self.class.register_shopify_extensions!
@database = self.class.database
end
# Find all test templates and generate a test method for each
Dir.glob(File.join(TESTS_DIR, '**/*.liquid')).each do |template_path|
# Skip theme.liquid files - they're layouts, not standalone templates
next if File.basename(template_path) == 'theme.liquid'
# Extract theme name and template name for test method name
relative_path = template_path.sub("#{TESTS_DIR}/", '')
theme_name = File.dirname(relative_path)
template_name = File.basename(relative_path, '.liquid')
test_method_name = "test_#{theme_name}_#{template_name}".gsub(/[^a-zA-Z0-9_]/, '_')
define_method(test_method_name) do
run_acceptance_test(template_path, theme_name, template_name)
end
end
private
def run_acceptance_test(template_path, theme_name, template_name)
# Read the template
template_source = File.read(template_path)
# Check for a theme layout
theme_path = File.join(File.dirname(template_path), 'theme.liquid')
layout_source = File.exist?(theme_path) ? File.read(theme_path) : nil
# Set up assigns
assigns = @database.dup
assigns['page_title'] = 'Test Page'
assigns['template'] = template_name
# Set up file system for partials
file_system = TestFileSystem.new(File.dirname(template_path))
# Render with interpreted Liquid
interpreted_output = render_interpreted(template_source, layout_source, assigns, file_system)
# Render with compiled Ruby
compiled_output = render_compiled(template_source, layout_source, assigns, file_system)
# Compare outputs
assert_equal(
interpreted_output,
compiled_output,
"Output mismatch for #{theme_name}/#{template_name}.liquid\n" \
"Interpreted length: #{interpreted_output.length}\n" \
"Compiled length: #{compiled_output.length}\n" \
"First difference at: #{find_first_diff(interpreted_output, compiled_output)}"
)
end
def render_interpreted(template_source, layout_source, assigns, file_system)
template = Template.parse(template_source)
template.registers[:file_system] = file_system
content = template.render!(assigns.dup)
if layout_source
layout = Template.parse(layout_source)
layout.registers[:file_system] = file_system
layout_assigns = assigns.dup
layout_assigns['content_for_layout'] = content
layout.render!(layout_assigns)
else
content
end
end
def render_compiled(template_source, layout_source, assigns, file_system)
template = Template.parse(template_source)
compiled = template.compile_to_ruby
# Set up filter handler with Shopify filters
filter_handler = Class.new do
include JsonFilter
include MoneyFilter
include WeightFilter
include ShopFilter
include TagFilter
end.new
compiled.filter_handler = filter_handler
content = compiled.call(assigns.dup, registers: { file_system: file_system })
if layout_source
layout = Template.parse(layout_source)
layout_compiled = layout.compile_to_ruby
layout_compiled.filter_handler = filter_handler
layout_assigns = assigns.dup
layout_assigns['content_for_layout'] = content
layout_compiled.call(layout_assigns, registers: { file_system: file_system })
else
content
end
end
def find_first_diff(str1, str2)
min_len = [str1.length, str2.length].min
diff_pos = (0...min_len).find { |i| str1[i] != str2[i] } || min_len
context_start = [0, diff_pos - 20].max
context_end = [str1.length, str2.length, diff_pos + 30].min
"position #{diff_pos}:\n" \
" Interpreted: #{str1[context_start...context_end].inspect}\n" \
" Compiled: #{str2[context_start...context_end].inspect}"
end
end
+556
View File
@@ -0,0 +1,556 @@
# frozen_string_literal: true
require 'test_helper'
# Ruby 3.3 compatibility for peek_byte and scan_byte
require 'strscan'
unless StringScanner.method_defined?(:peek_byte)
class StringScanner
def peek_byte
return nil if eos?
string.getbyte(pos)
end
end
end
unless StringScanner.method_defined?(:scan_byte)
class StringScanner
def scan_byte
return nil if eos?
byte = string.getbyte(pos)
self.pos += 1
byte
end
end
end
class CompileTest < Minitest::Test
include Liquid
def test_compile_simple_string
template = Template.parse("Hello, World!")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({})
end
def test_compile_variable
template = Template.parse("Hello, {{ name }}!")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
end
def test_compile_variable_with_filter
template = Template.parse("{{ name | upcase }}")
compiled = template.compile_to_ruby
assert_equal "WORLD", compiled.call({ "name" => "world" })
end
def test_compile_variable_with_multiple_filters
template = Template.parse("{{ name | downcase | capitalize }}")
compiled = template.compile_to_ruby
assert_equal "Hello", compiled.call({ "name" => "HELLO" })
end
def test_compile_if_true
template = Template.parse("{% if show %}visible{% endif %}")
compiled = template.compile_to_ruby
assert_equal "visible", compiled.call({ "show" => true })
assert_equal "", compiled.call({ "show" => false })
end
def test_compile_if_else
template = Template.parse("{% if show %}yes{% else %}no{% endif %}")
compiled = template.compile_to_ruby
assert_equal "yes", compiled.call({ "show" => true })
assert_equal "no", compiled.call({ "show" => false })
end
def test_compile_if_elsif
template = Template.parse("{% if x == 1 %}one{% elsif x == 2 %}two{% else %}other{% endif %}")
compiled = template.compile_to_ruby
assert_equal "one", compiled.call({ "x" => 1 })
assert_equal "two", compiled.call({ "x" => 2 })
assert_equal "other", compiled.call({ "x" => 3 })
end
def test_compile_unless
template = Template.parse("{% unless hidden %}visible{% endunless %}")
compiled = template.compile_to_ruby
assert_equal "visible", compiled.call({ "hidden" => false })
assert_equal "", compiled.call({ "hidden" => true })
end
def test_compile_for_loop
template = Template.parse("{% for item in items %}{{ item }} {% endfor %}")
compiled = template.compile_to_ruby
assert_equal "a b c ", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_for_loop_with_forloop
template = Template.parse("{% for item in items %}{{ forloop.index }}:{{ item }} {% endfor %}")
compiled = template.compile_to_ruby
assert_equal "1:a 2:b 3:c ", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_for_loop_else
template = Template.parse("{% for item in items %}{{ item }}{% else %}empty{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "abc", compiled.call({ "items" => ["a", "b", "c"] })
assert_equal "empty", compiled.call({ "items" => [] })
end
def test_compile_for_with_limit
template = Template.parse("{% for item in items limit:2 %}{{ item }}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "ab", compiled.call({ "items" => ["a", "b", "c", "d"] })
end
def test_compile_assign
template = Template.parse("{% assign x = 'hello' %}{{ x }}")
compiled = template.compile_to_ruby
assert_equal "hello", compiled.call({})
end
def test_compile_assign_with_filter
template = Template.parse("{% assign x = name | upcase %}{{ x }}")
compiled = template.compile_to_ruby
assert_equal "WORLD", compiled.call({ "name" => "world" })
end
def test_compile_capture
template = Template.parse("{% capture greeting %}Hello, {{ name }}!{% endcapture %}{{ greeting }}")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
end
def test_compile_case
template = Template.parse("{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}")
compiled = template.compile_to_ruby
assert_equal "one", compiled.call({ "x" => 1 })
assert_equal "two", compiled.call({ "x" => 2 })
assert_equal "other", compiled.call({ "x" => 3 })
end
def test_compile_raw
template = Template.parse("{% raw %}{{ not_a_variable }}{% endraw %}")
compiled = template.compile_to_ruby
assert_equal "{{ not_a_variable }}", compiled.call({})
end
def test_compile_comment
template = Template.parse("before{% comment %}hidden{% endcomment %}after")
compiled = template.compile_to_ruby
assert_equal "beforeafter", compiled.call({})
end
def test_compile_increment
template = Template.parse("{% increment x %}{% increment x %}{% increment x %}")
compiled = template.compile_to_ruby
assert_equal "012", compiled.call({})
end
def test_compile_decrement
template = Template.parse("{% decrement x %}{% decrement x %}{% decrement x %}")
compiled = template.compile_to_ruby
assert_equal "-1-2-3", compiled.call({})
end
def test_compile_cycle
template = Template.parse("{% for i in (1..3) %}{% cycle 'a', 'b' %}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "aba", compiled.call({})
end
def test_compile_nested_property_access
template = Template.parse("{{ user.profile.name }}")
compiled = template.compile_to_ruby
data = { "user" => { "profile" => { "name" => "Alice" } } }
assert_equal "Alice", compiled.call(data)
end
def test_compile_array_access
template = Template.parse("{{ items[1] }}")
compiled = template.compile_to_ruby
assert_equal "b", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_size_filter
template = Template.parse("{{ items | size }}")
compiled = template.compile_to_ruby
assert_equal "3", compiled.call({ "items" => [1, 2, 3] })
end
def test_compile_join_filter
template = Template.parse("{{ items | join: ', ' }}")
compiled = template.compile_to_ruby
assert_equal "a, b, c", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_split_filter
template = Template.parse("{% assign arr = str | split: ',' %}{{ arr | size }}")
compiled = template.compile_to_ruby
assert_equal "3", compiled.call({ "str" => "a,b,c" })
end
def test_compile_math_filters
template = Template.parse("{{ x | plus: 5 | minus: 2 | times: 3 }}")
compiled = template.compile_to_ruby
assert_equal "24", compiled.call({ "x" => 5 })
end
def test_compile_default_filter
template = Template.parse("{{ x | default: 'nothing' }}")
compiled = template.compile_to_ruby
assert_equal "hello", compiled.call({ "x" => "hello" })
assert_equal "nothing", compiled.call({ "x" => nil })
assert_equal "nothing", compiled.call({})
end
def test_compile_first_last_filters
template = Template.parse("{{ items | first }}-{{ items | last }}")
compiled = template.compile_to_ruby
assert_equal "a-c", compiled.call({ "items" => ["a", "b", "c"] })
end
def test_compile_escape_filter
template = Template.parse("{{ html | escape }}")
compiled = template.compile_to_ruby
assert_equal "&lt;p&gt;hello&lt;/p&gt;", compiled.call({ "html" => "<p>hello</p>" })
end
def test_compile_replace_filter
template = Template.parse("{{ str | replace: 'foo', 'bar' }}")
compiled = template.compile_to_ruby
assert_equal "bar baz bar", compiled.call({ "str" => "foo baz foo" })
end
def test_compile_append_prepend_filters
template = Template.parse("{{ name | prepend: 'Hello, ' | append: '!' }}")
compiled = template.compile_to_ruby
assert_equal "Hello, World!", compiled.call({ "name" => "World" })
end
def test_compile_for_break
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "12", compiled.call({})
end
def test_compile_for_continue
template = Template.parse("{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}")
compiled = template.compile_to_ruby
assert_equal "1245", compiled.call({})
end
def test_compile_comparison_operators
template = Template.parse("{% if x > 5 %}big{% elsif x == 5 %}five{% else %}small{% endif %}")
compiled = template.compile_to_ruby
assert_equal "big", compiled.call({ "x" => 10 })
assert_equal "five", compiled.call({ "x" => 5 })
assert_equal "small", compiled.call({ "x" => 2 })
end
def test_compile_and_or_operators
template = Template.parse("{% if a and b %}both{% elsif a or b %}one{% else %}none{% endif %}")
compiled = template.compile_to_ruby
assert_equal "both", compiled.call({ "a" => true, "b" => true })
assert_equal "one", compiled.call({ "a" => true, "b" => false })
assert_equal "none", compiled.call({ "a" => false, "b" => false })
end
def test_compile_contains
template = Template.parse("{% if str contains 'hello' %}found{% else %}not found{% endif %}")
compiled = template.compile_to_ruby
assert_equal "found", compiled.call({ "str" => "say hello world" })
assert_equal "not found", compiled.call({ "str" => "goodbye" })
end
def test_compile_produces_valid_ruby
template = Template.parse("{% for item in items %}{{ item | upcase }}{% endfor %}")
compiled = template.compile_to_ruby
# Should produce valid Ruby syntax - if eval succeeds without error, it's valid
proc = eval(compiled.code)
assert_kind_of Proc, proc
end
def test_compile_vs_render_equivalence
templates = [
"Hello, {{ name }}!",
"{% if show %}visible{% else %}hidden{% endif %}",
"{% for i in (1..3) %}{{ i }}{% endfor %}",
"{{ str | upcase | split: '' | join: '-' }}",
"{% assign x = 5 %}{% assign y = x | plus: 3 %}{{ y }}",
]
assigns_list = [
{ "name" => "World", "show" => true, "str" => "hello" },
{ "name" => "Ruby", "show" => false, "str" => "test" },
]
templates.each do |source|
template = Template.parse(source)
compiled = template.compile_to_ruby
assigns_list.each do |assigns|
expected = template.render(assigns)
actual = compiled.call(assigns.dup)
assert_equal expected, actual, "Mismatch for template '#{source}' with assigns #{assigns}"
end
end
end
def test_compiled_template_class
template = Template.parse("Hello, {{ name }}!")
compiled = template.compile_to_ruby
assert_instance_of Liquid::Compile::CompiledTemplate, compiled
assert_respond_to compiled, :call
assert_respond_to compiled, :code
assert_respond_to compiled, :to_s
assert_respond_to compiled, :to_proc
end
def test_compiled_template_code
template = Template.parse("Hello!")
compiled = template.compile_to_ruby
assert_kind_of String, compiled.code
assert_includes compiled.code, "__output__"
assert_includes compiled.code, "Hello!"
end
def test_external_tags_indicator
# A simple template should have no external tags
template = Template.parse("Hello!")
compiled = template.compile_to_ruby
assert_equal false, compiled.has_external_tags?
assert_empty compiled.external_tags
end
def test_external_filters_indicator
# A simple template should have no external filters
template = Template.parse("{{ name | upcase }}")
compiled = template.compile_to_ruby
assert_equal false, compiled.has_external_filters?
end
def test_comprehensive_output_equivalence
# Comprehensive test comparing compiled vs interpreted output
test_cases = [
# Basic variables
{ source: "{{ x }}", assigns: { "x" => "hello" } },
{ source: "{{ x }}", assigns: { "x" => 123 } },
{ source: "{{ x }}", assigns: { "x" => nil } },
# Nested access
{ source: "{{ a.b.c }}", assigns: { "a" => { "b" => { "c" => "deep" } } } },
{ source: "{{ items[0] }}", assigns: { "items" => ["first", "second"] } },
# Filters
{ source: "{{ x | upcase }}", assigns: { "x" => "hello" } },
{ source: "{{ x | size }}", assigns: { "x" => [1, 2, 3] } },
{ source: "{{ x | default: 'fallback' }}", assigns: { "x" => nil } },
{ source: "{{ x | plus: 10 }}", assigns: { "x" => 5 } },
{ source: "{{ x | split: ',' | first }}", assigns: { "x" => "a,b,c" } },
# Conditionals
{ source: "{% if x %}yes{% endif %}", assigns: { "x" => true } },
{ source: "{% if x %}yes{% endif %}", assigns: { "x" => false } },
{ source: "{% if x > 5 %}big{% else %}small{% endif %}", assigns: { "x" => 10 } },
# Loops
{ source: "{% for i in items %}{{ i }}{% endfor %}", assigns: { "items" => [1, 2, 3] } },
{ source: "{% for i in (1..3) %}{{ i }}{% endfor %}", assigns: {} },
{ source: "{% for i in items %}{{ forloop.index }}{% endfor %}", assigns: { "items" => %w[a b] } },
{ source: "{% for i in items %}{% if forloop.first %}first{% endif %}{% endfor %}", assigns: { "items" => [1, 2] } },
# Assignments
{ source: "{% assign y = x | upcase %}{{ y }}", assigns: { "x" => "hello" } },
{ source: "{% capture c %}Hello {{ x }}{% endcapture %}{{ c }}", assigns: { "x" => "World" } },
# Case
{ source: "{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}", assigns: { "x" => 1 } },
{ source: "{% case x %}{% when 1 %}one{% when 2 %}two{% else %}other{% endcase %}", assigns: { "x" => 3 } },
# Increment/Decrement
{ source: "{% increment x %}{% increment x %}", assigns: {} },
{ source: "{% decrement x %}{% decrement x %}", assigns: {} },
# Cycle
{ source: "{% for i in (1..4) %}{% cycle 'a', 'b' %}{% endfor %}", assigns: {} },
# Break/Continue
{ source: "{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}", assigns: {} },
{ source: "{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}", assigns: {} },
# Raw
{ source: "{% raw %}{{ not a var }}{% endraw %}", assigns: {} },
# Comment
{ source: "before{% comment %}hidden{% endcomment %}after", assigns: {} },
# Tablerow
{ source: "{% tablerow i in items cols:2 %}{{ i }}{% endtablerow %}", assigns: { "items" => [1, 2, 3] } },
]
test_cases.each do |tc|
source = tc[:source]
assigns = tc[:assigns]
template = Template.parse(source)
compiled = template.compile_to_ruby
expected = template.render(assigns.dup)
actual = compiled.call(assigns.dup)
assert_equal expected, actual, "Output mismatch for: #{source.inspect}\nAssigns: #{assigns.inspect}"
end
end
def test_debug_mode_adds_comments
template = Template.parse("{{ name }}")
compiled = template.compile_to_ruby(debug: true)
assert_includes compiled.code, "# LIQUID"
assert_includes compiled.code, "# Compiled from Liquid template"
end
def test_filter_handler_can_be_set
# Create a custom filter module
filter_mod = Module.new do
def custom_filter(input)
"custom:#{input}"
end
end
class_with_filter = Class.new do
include filter_mod
end
template = Template.parse("{{ x | custom_filter }}")
compiled = template.compile_to_ruby
compiled.filter_handler = class_with_filter.new
# The template should use the custom filter
result = compiled.call({ "x" => "test" })
assert_equal "custom:test", result
end
# Test Drop support
def test_compile_with_drop
# Create a simple Drop class
product_drop = Class.new(Liquid::Drop) do
def initialize(name, price)
super()
@name = name
@price = price
end
def name
@name
end
def price
@price
end
def discounted_price
@price * 0.9
end
end
template = Template.parse("Product: {{ product.name }} costs ${{ product.price }}")
compiled = template.compile_to_ruby
drop = product_drop.new("Widget", 100)
result = compiled.call({ "product" => drop })
assert_equal "Product: Widget costs $100", result
end
def test_compile_with_drop_context_access
# Create a Drop that uses context
context_aware_drop = Class.new(Liquid::Drop) do
def initialize(multiplier)
super()
@multiplier = multiplier
end
def computed_value
# Access another variable via context
base = @context["base_value"] || 0
base * @multiplier
end
end
template = Template.parse("Result: {{ calc.computed_value }}")
compiled = template.compile_to_ruby
drop = context_aware_drop.new(3)
result = compiled.call({ "calc" => drop, "base_value" => 10 })
assert_equal "Result: 30", result
end
def test_compile_with_nested_drops
inner_drop = Class.new(Liquid::Drop) do
def initialize(value)
super()
@value = value
end
def value
@value
end
end
outer_drop = Class.new(Liquid::Drop) do
def initialize(inner)
super()
@inner = inner
end
def inner
@inner
end
end
template = Template.parse("{{ outer.inner.value }}")
compiled = template.compile_to_ruby
inner = inner_drop.new("nested!")
outer = outer_drop.new(inner)
result = compiled.call({ "outer" => outer })
assert_equal "nested!", result
end
def test_compile_with_forloop_drop
# ForloopDrop is a built-in Drop - ensure it works
template = Template.parse("{% for item in items %}{{ forloop.index }}:{{ item }} {% endfor %}")
compiled = template.compile_to_ruby
# Note: Compiled code uses a hash for forloop, not the actual ForloopDrop
# This test verifies the hash-based forloop still works
result = compiled.call({ "items" => ["a", "b", "c"] })
assert_equal "1:a 2:b 3:c ", result
end
def test_compile_with_registers
template = Template.parse("{{ product.name }}")
compiled = template.compile_to_ruby
# Create a Drop that checks registers
product_drop = Class.new(Liquid::Drop) do
def name
# Access registers through context
store = @context.registers[:store] || "Unknown Store"
"Product from #{store}"
end
end
drop = product_drop.new
result = compiled.call({ "product" => drop }, registers: { store: "Acme Corp" })
assert_equal "Product from Acme Corp", result
end
end
@@ -2,7 +2,7 @@
require 'test_helper'
class StrainerFactoryUnitTest < Minitest::Test
class EnvironmentFilterTest < Minitest::Test
include Liquid
module AccessScopeFilters
@@ -16,8 +16,6 @@ class StrainerFactoryUnitTest < Minitest::Test
private :private_filter
end
StrainerFactory.add_global_filter(AccessScopeFilters)
module LateAddedFilter
def late_added_filter(_input)
"filtered"
@@ -25,24 +23,28 @@ class StrainerFactoryUnitTest < Minitest::Test
end
def setup
@context = Context.build
@environment = Liquid::Environment.build do |env|
env.register_filter(AccessScopeFilters)
end
@context = Context.build(environment: @environment)
end
def test_strainer
strainer = StrainerFactory.create(@context)
strainer = @environment.create_strainer(@context)
assert_equal(5, strainer.invoke('size', 'input'))
assert_equal("public", strainer.invoke("public_filter"))
end
def test_stainer_raises_argument_error
strainer = StrainerFactory.create(@context)
def test_strainer_raises_argument_error
strainer = @environment.create_strainer(@context)
assert_raises(Liquid::ArgumentError) do
strainer.invoke("public_filter", 1)
end
end
def test_stainer_argument_error_contains_backtrace
strainer = StrainerFactory.create(@context)
def test_strainer_argument_error_contains_backtrace
strainer = @environment.create_strainer(@context)
exception = assert_raises(Liquid::ArgumentError) do
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/,
exception.message,
)
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
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?('instance_eval'))
@@ -66,18 +69,18 @@ class StrainerFactoryUnitTest < Minitest::Test
end
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("undef_the_filter"))
end
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"))
end
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("puts", strainer.invoke("__send__", "puts", "Hi Mom"))
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
a = 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(a, strainer)
assert_kind_of(b, strainer)
@@ -94,8 +99,10 @@ class StrainerFactoryUnitTest < Minitest::Test
end
def test_add_global_filter_clears_cache
assert_equal('input', StrainerFactory.create(@context).invoke('late_added_filter', 'input'))
StrainerFactory.add_global_filter(LateAddedFilter)
assert_equal('filtered', StrainerFactory.create(nil).invoke('late_added_filter', 'input'))
assert_equal('input', @environment.create_strainer(@context).invoke('late_added_filter', 'input'))
@environment.register_filter(LateAddedFilter)
assert_equal('filtered', @environment.create_strainer(nil).invoke('late_added_filter', 'input'))
end
end
+11 -1
View File
@@ -131,9 +131,19 @@ class LexerUnitTest < Minitest::Test
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.new(input).tokenize
Lexer.tokenize(StringScanner.new(input))
end
end
+16 -10
View File
@@ -6,20 +6,20 @@ class ParserUnitTest < Minitest::Test
include Liquid
def test_consume
p = Parser.new("wat: 7")
p = new_parser("wat: 7")
assert_equal('wat', p.consume(:id))
assert_equal(':', p.consume(:colon))
assert_equal('7', p.consume(:number))
end
def test_jump
p = Parser.new("wat: 7")
p = new_parser("wat: 7")
p.jump(2)
assert_equal('7', p.consume(:number))
end
def test_consume?
p = Parser.new("wat: 7")
p = new_parser("wat: 7")
assert_equal('wat', p.consume?(:id))
assert_equal(false, p.consume?(:dot))
assert_equal(':', p.consume(:colon))
@@ -27,7 +27,7 @@ class ParserUnitTest < Minitest::Test
end
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(false, p.id?('endgame'))
assert_equal('6', p.consume(:number))
@@ -36,7 +36,7 @@ class ParserUnitTest < Minitest::Test
end
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('wat', p.consume(:id))
assert_equal(false, p.look(:comparison))
@@ -46,12 +46,12 @@ class ParserUnitTest < Minitest::Test
end
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?[5].there?', 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('6.0', p.expression)
assert_equal("'lol'", p.expression)
@@ -59,7 +59,7 @@ class ParserUnitTest < Minitest::Test
end
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('(1.5..9.6)', p.expression)
assert_equal('(young..old)', p.expression)
@@ -67,7 +67,7 @@ class ParserUnitTest < Minitest::Test
end
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(':', p.consume(:colon))
assert_equal('hi.there[5]', p.argument)
@@ -77,8 +77,14 @@ class ParserUnitTest < Minitest::Test
def test_invalid_expression
assert_raises(SyntaxError) do
p = Parser.new("==")
p = new_parser("==")
p.expression
end
end
private
def new_parser(str)
Parser.new(StringScanner.new(str))
end
end
+10 -6
View File
@@ -25,11 +25,13 @@ class StrainerTemplateUnitTest < Minitest::Test
end
def test_add_filter_raises_when_module_privately_overrides_registered_public_methods
strainer = Context.new.strainer
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
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end
@@ -42,11 +44,13 @@ class StrainerTemplateUnitTest < Minitest::Test
end
def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected
strainer = Context.new.strainer
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
assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message)
end
+13 -4
View File
@@ -6,18 +6,18 @@ class TagUnitTest < Minitest::Test
include Liquid
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('', tag.render(Context.new))
end
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)
end
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)
end
@@ -26,7 +26,16 @@ class TagUnitTest < Minitest::Test
end
def test_tag_render_to_output_buffer_nil_value
custom_tag = CustomTag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
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
+184
View File
@@ -0,0 +1,184 @@
# frozen_string_literal: true
require 'test_helper'
class DocTagUnitTest < Minitest::Test
def test_doc_tag
template = <<~LIQUID.chomp
{% doc %}
Renders loading-spinner.
@param {string} foo - some foo
@param {string} [bar] - optional bar
@example
{% render 'loading-spinner', foo: 'foo' %}
{% render 'loading-spinner', foo: 'foo', bar: 'bar' %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_does_not_support_extra_arguments
error = assert_raises(Liquid::SyntaxError) do
template = <<~LIQUID.chomp
{% doc extra %}
{% enddoc %}
LIQUID
Liquid::Template.parse(template)
end
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}"
act_error = error.message
assert_equal(exp_error, act_error)
end
def test_doc_tag_must_support_valid_tags
assert_match_syntax_error("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %} foo')
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo {% enddoc %}')
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo %}{% enddoc %}')
end
def test_doc_tag_ignores_liquid_nodes
template = <<~LIQUID.chomp
{% doc %}
{% if true %}
{% if ... %}
{%- for ? -%}
{% while true %}
{%
unless if
%}
{% endcase %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_ignores_unclosed_liquid_tags
template = <<~LIQUID.chomp
{% doc %}
{% if true %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_does_not_allow_nested_docs
error = assert_raises(Liquid::SyntaxError) do
template = <<~LIQUID.chomp
{% doc %}
{% doc %}
{% doc %}
{% enddoc %}
LIQUID
Liquid::Template.parse(template)
end
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Nested doc tags are not allowed"
act_error = error.message
assert_equal(exp_error, act_error)
end
def test_doc_tag_ignores_nested_raw_tags
template = <<~LIQUID.chomp
{% doc %}
{% raw %}
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_ignores_unclosed_assign
template = <<~LIQUID.chomp
{% doc %}
{% assign foo = "1"
{% enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_ignores_malformed_syntax
template = <<~LIQUID.chomp
{% doc %}
{% {{ {%- enddoc %}
LIQUID
assert_template_result('', template)
end
def test_doc_tag_preserves_error_line_numbers
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
{% doc %}
{% if true %}
{% enddoc %}
{{ errors.standard_error }}
LIQUID
expected = <<~TEXT.chomp
Liquid error (line 4): standard error
TEXT
assert_equal(expected, template.render('errors' => ErrorDrop.new))
end
def test_doc_tag_whitespace_control
# Basic whitespace control
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!")
assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!")
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!")
assert_template_result("Hello!", <<~LIQUID.chomp)
{%- doc %}Whitespace control!{% enddoc -%}
Hello!
LIQUID
end
def test_doc_tag_delimiter_handling
assert_template_result('', <<~LIQUID.chomp)
{% if true %}
{% doc %}
{% docEXTRA %}wut{% enddocEXTRA %}xyz
{% enddoc %}
{% endif %}
LIQUID
assert_template_result('', "{% doc %}123{% enddoc xyz %}")
assert_template_result('', "{% doc %}123{% enddoc\txyz %}")
assert_template_result('', "{% doc %}123{% enddoc\nxyz %}")
assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}")
end
def test_doc_tag_visitor
template_source = '{% doc %}{% enddoc %}'
assert_equal(
[Liquid::Doc],
visit(template_source),
)
end
private
def traversal(template)
ParseTreeVisitor
.for(Template.parse(template).root)
.add_callback_for(Liquid::Doc) do |tag|
tag_class = tag.class
tag_class
end
end
def visit(template)
traversal(template).visit.flatten.compact
end
end
+12 -39
View File
@@ -20,50 +20,12 @@ class TemplateUnitTest < Minitest::Test
assert_equal(fixture("en_locale.yml"), locale.path)
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)
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)
with_custom_tag('custom', CustomTag) do
Object.send(:remove_const, :CustomTag)
new_klass = Class.new
Object.send(:const_set, :CustomTag, new_klass)
assert(Template.tags['custom'].equal?(new_klass))
end
ensure
Object.send(:remove_const, :CustomTag)
Liquid.cache_classes = original_cache_setting
end
class FakeTag; end
def test_tags_can_be_looped_over
with_custom_tag('fake', FakeTag) do
result = Template.tags.map { |name, klass| [name, klass] }
assert(result.include?(["fake", "TemplateUnitTest::FakeTag"]))
assert(result.include?(["fake", TemplateUnitTest::FakeTag]))
end
end
@@ -73,4 +35,15 @@ class TemplateUnitTest < Minitest::Test
def test_template_inheritance
assert_equal("foo", TemplateSubclass.parse("foo").render)
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
+18
View File
@@ -6,6 +6,7 @@ class TokenizerTest < Minitest::Test
def test_tokenize_strings
assert_equal([' '], tokenize(' '))
assert_equal(['hello world'], tokenize('hello world'))
assert_equal(['{}'], tokenize('{}'))
end
def test_tokenize_variables
@@ -30,6 +31,23 @@ class TokenizerTest < Minitest::Test
assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} "))
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
def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil)