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 3ed54bfdf9 propagate Environment on new Context creation 2024-11-04 15:32:07 -04:00
Michael Go 29986d3704 remove TagRegistry 2024-11-04 15:22:56 -04:00
Michael GoandGitHub 8e40f8050a Merge pull request #1838 from Shopify/quirky-lexer-parsing
fix parsing quirky incomplete expressions
2024-10-30 13:52:47 -03:00
Michael Go ffce6de8bb avoid using StringScanner eos 2024-10-30 13:45:10 -03:00
Michael Go f00670cb01 refactor lexer unit test 2024-10-30 13:44:36 -03:00
Michael Go f6a3e25e2e fix parsing quirky incomplete expressions 2024-10-30 13:44:35 -03:00
Michael GoandGitHub f6ffc37cf2 Merge pull request #1840 from Shopify/fix-lexer-contains-as-id
fix lexer parsing ID 'contains' as comparison
2024-10-30 13:43:48 -03:00
Michael Go 1375a9e4dc fix lexer parsing ID 'contains' as comparison 2024-10-30 13:39:55 -03:00
Michael GoandGitHub c626dfa1a1 Merge pull request #1839 from Shopify/lexer-parse-error-with-utf8
raise syntax error from lexer parser with UTF-8 character
2024-10-30 13:39:05 -03:00
Michael Go 8a9f33a060 raise syntax error from lexer parser with utf8 character 2024-10-29 22:04:37 -03:00
Michael GoandGitHub 1943441361 Merge pull request #1835 from Shopify/fix-multibyte-variable-parsing
fix parsing Variable blockbody with multibyte character
2024-10-28 19:31:21 -03:00
Michael GoandGitHub 36251e640c Merge pull request #1837 from Shopify/lexer-comparison-fix
fix lexer parsing comparison without whitespaces
2024-10-28 19:31:11 -03:00
Michael Go d94293a464 fix lexer parsing comparison without whitespaces 2024-10-28 19:30:12 -03:00
Michael Go 6c13805a60 fix parsing Variable blockbody with multibyte character 2024-10-28 17:33:55 -03:00
Michael GoandGitHub b4196489c2 Merge pull request #1833 from Shopify/fast-variable-parse
Faster Variable BlockBody Matching
2024-10-28 15:28:04 -03:00
Gray GilmoreandGitHub 6d58c41440 Merge pull request #1831 from Shopify/gg-add-named-params-docs
Update liquid docs for named parameters
2024-10-28 09:20:55 -07:00
Michael Go fb6ac72520 use byteslice to create Variable BlockBody 2024-10-25 15:41:35 -03:00
Michael Go cb16219552 faster BlockBody variable matching 2024-10-25 15:22:56 -03:00
Gray Gilmore 8d7ed706f4 Update liquid docs for named parameters
The YARD liquid gem now supports specifying named parameters. For the
core liquid tags and filters this is the only object I could find that
needed to be updated.
2024-10-24 09:56:50 -07:00
Ian Ker-SeymerandGitHub b3553787c8 Speed up the lexer for Ruby 3.4+ (#1832)
* Speed up lexing

* Bump msrv to 3.0 (from 2.7)

* Normalize test for ruby-head compat

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

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

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

Also change testsuite to reflect this change.

Fixes #1759 .
2023-12-15 15:09:57 +09:00
Michael Go fc9c338682 refactor: rename comment tag unit test 2023-12-14 17:08:26 -04:00
Michael Go 3c5ad7db61 don't allow invalid syntax inside comment tag 2023-12-14 17:01:03 -04:00
Michael GoandGitHub c658bf970a Merge pull request #1769 from Shopify/comment-tag-with-extra-string
fix parsing comment tag with extra string
2023-12-13 14:16:44 -04:00
Michael Go c618ac1c9f fix parsing comment tag with extra string 2023-12-12 16:23:37 -04:00
Ian Ker-SeymerandGitHub 0f0d5d889f Merge pull request #1750 from IevaGraz/fix/custom_tag_rendering
Fix for custom tag rendering
2023-12-10 11:28:50 -05:00
Michael GoandGitHub 11a1f8e673 Merge pull request #1764 from Shopify/fix-comment-tag-whitespace-control
implement whitespace control to comment tag
2023-12-06 14:30:16 -05:00
Michael Go 24d461a9e3 implement whitespace control to comment tag 2023-12-01 16:04:03 -04:00
Michael GoandGitHub cbb422e5d3 Merge pull request #1763 from Shopify/fix-comment-tag-delim-parsing
fix parsing comment tag delimiter
2023-12-01 15:35:32 -04:00
Michael Go bf0f79f36c fix parsing comment tag delimiter 2023-11-30 13:24:54 -04:00
Michael GoandGitHub cf2787791e Merge pull request #1755 from Shopify/non-parsing-comment
Don't parse nodes inside a comment tag
2023-11-29 14:10:20 -04:00
Michael Go 6a5ebb0e85 fix parsing nested comment tag with extra strings 2023-11-11 12:19:44 -04:00
Michael Go 6dafc19b6d fix parsing comment tag delimiter with extra strings 2023-11-10 15:42:54 -04:00
Michael Go 41f65173b0 fix parsing comment block body inside a liquid tag 2023-11-09 16:27:23 -04:00
Michael Go e180535784 allow incomplete tags inside a comment tag 2023-11-08 16:50:41 -04:00
Michael Go a681e73aec refactor comment tag unit test to use test helper 2023-11-08 16:45:11 -04:00
Michael Go 2abf52d546 add a quirky comment tag unit test 2023-11-08 11:23:00 -04:00
eada2b65a2 clean up comment tag body parsing
Co-authored-by: Peter Zhu <[email protected]>
2023-11-08 11:13:13 -04:00
Michael GoandAlex Coco dbf0aa8cd5 don't parse nodes inside a comment tag
Co-authored-by: Alex Coco <[email protected]>
2023-11-07 18:38:13 -04:00
Ieva Grazuleviciute 407a8e5b0f Fix for custom tag rendering 2023-10-20 11:42:54 +03:00
Ateş GöralandGitHub e3dcc75ab5 Merge pull request #1748 from Shopify/atesgoral/update-ci-status-badge
Update CI status badge
2023-10-16 11:23:04 -04:00
Ates Goral ceb7a4237f Update CI status badge 2023-10-16 11:17:24 -04:00
Guillaume MaletteandGitHub 7b60b7fef5 Merge pull request #1746 from Shopify/gm/base64-encoding
[base64] Respect string encoding of input in base64_decode filters
2023-09-29 11:13:18 -04:00
Guillaume Malette 33e1a8ffbc fix style 2023-09-29 09:50:42 -04:00
Guillaume Malette cc47fa8f03 [base64] Respect string encoding of input in base64_decode filters 2023-09-29 09:39:59 -04:00
Adam KlingbaumandGitHub b1b9b9f691 Merge pull request #1739 from Shopify/klingbaum/fix-sum-filter-float-output
Fix `BigDecimal` output in `sum` filter
2023-09-27 13:34:27 -04:00
Adam Klingbaum 75e7725f57 Streamline new tests for sum filter 2023-08-08 16:15:48 +00:00
James PriorandAdam Klingbaum de6d15a73e Test sum floats from properties. 2023-08-08 16:10:04 +00:00
James PriorandAdam Klingbaum 2c5d2be193 Don't render sum filter results in scientific notation. 2023-08-08 16:10:03 +00:00
Samuel DoironandGitHub 0b9318222b Merge pull request #1735 from Shopify/marco-condition-to-liquid-value
Call to_liquid_value when short circuiting conditions
2023-07-19 15:05:00 -03:00
Marco Rudilosso 21d6197533 Call to_liquid_value when short circuiting conditions 2023-07-19 15:22:12 +00:00
Marco Concetto RudilossoandGitHub 5e92b3a89a Merge pull request #1731 from Shopify/allow-recursive-liquid-tags
Allow liquid tag inside liquid tag
2023-07-10 13:25:13 +01:00
Marco Concetto Rudilosso 7f2cf1fe67 Recursively parse for liquid tag 2023-07-07 11:20:34 +01:00
Marco Concetto Rudilosso 10e0fb795e Allow liquid tag inside liquid tag 2023-07-06 10:45:12 +01:00
Michelle VinciandGitHub 546dd9bc06 Merge pull request #1730 from Shopify/capitalize-desc-fix
[Liquid reference]: Update description of `capitalize`
2023-07-05 08:52:26 -07:00
Michelle Vinci 9a77e3e923 update desc 2023-07-05 08:44:06 -07:00
Michael GoandGitHub c44d1d9193 Merge pull request #1727 from Shopify/partial-cache-with-error-mode
include parse mode to partial cache key
2023-06-27 15:06:58 -03:00
Michael Go dd7bbf26bc include error mode to partial cache key 2023-06-23 18:48:07 -03:00
Adam KlingbaumandGitHub cca24a2226 Merge pull request #1726 from Shopify/klingbaum/ensure_sum_filter_calls_#to_liquid
Ensure `sum` filter calls `to_liquid` on evaluated property value
2023-06-22 15:47:36 -04:00
Adam Klingbaum 98ce25cb40 Ensure sum filter calls to_liquid 2023-06-22 16:25:11 +00:00
Adam KlingbaumandGitHub 77293d4524 Merge pull request #1722 from Shopify/klingbaum/sum-filter
Add `sum` filter to standard filters
2023-06-19 15:15:49 -04:00
Adam Klingbaum af66bc8a5f Add sum filter to standard filters 2023-06-19 19:11:05 +00:00
Guillaume MaletteandGitHub 42e5c52336 Merge pull request #1717 from Shopify/gm/fix-raw-trimmode-at-end-of-block
[trim] Fix trimming at end of raw block when specified in the opening tag
2023-05-19 14:08:02 -04:00
Guillaume Malette 649cca1349 [trim] Fix trimming at end of raw block when specified in the opening tag 2023-05-19 11:41:45 -04:00
Ian Ker-SeymerandGitHub 81ed65f2a1 Merge pull request #1688 from Shopify/fix-readme-docs-link
Fix broken documentantion link in README
2023-05-11 18:48:09 -04:00
Jeffrey LoppertandGitHub 6ca06c22b8 Merge pull request #1712 from Shopify/jloppert/update-readme-docs-link
Update Liquid Documentation from Shopify URL
2023-05-10 16:20:40 -04:00
Jeffrey LoppertandGitHub 80bc7ffdf2 Update Liquid Documentation from Shopify URL
Current url https://shopify.dev/docs/docs/api/liquid 404s. Updated to https://shopify.dev/docs/api/liquid
2023-05-10 14:47:16 -04:00
Michael GoandGitHub 48cb643c02 Merge pull request #1695 from Shopify/syntax-error-with-filename
render template name with Liquid Syntax errors
2023-03-02 17:08:27 -04:00
Michael Go 1d97389fb0 render template name with Liquid Syntax errors 2023-03-02 16:50:55 -04:00
Michael GoandGitHub 3ff4170cb0 Merge pull request #1692 from Shopify/error-message-with-filepath
set context's template_name with template.name in render and include tag
2023-02-28 10:57:24 -04:00
Michael Go 24dceef552 set context's template_name with template's name 2023-02-28 10:55:38 -04:00
Michael Go 428c66ffac add name attribute to Template for more flexibility to set context's template name 2023-02-27 17:11:52 -04:00
Michael Go 0fe4a5d144 render error message with actual template path 2023-02-27 11:01:21 -04:00
Dylan Thacker-SmithandGitHub e650dc4195 Revert "Instrument usage of bug with iteration of String with offset or 0 limit (#1667)" (#1677)
This reverts commit c743936a78.
2023-02-22 12:48:18 -05:00
Robertas Godelis a75517e2c7 Fix broken documentantion link in README 2023-02-15 18:27:36 +02:00
Guilherme CarreiroandGitHub 9ab688eada Update rubocop-shopify (2.7.0 -> 2.12.0) (#1687) 2023-02-15 08:40:59 +01:00
Erin MorrisseyandGitHub abef59d129 Merge pull request #1685 from Shopify/update-liquid-paths
Update link paths in docs markup
2023-02-14 10:37:15 -08:00
Erin Morrissey 940c3a4207 update link paths in docs markup 2023-02-14 10:34:15 -08:00
Guilherme CarreiroandGitHub 84a0289ebc Fix CI (as it installs RuboCop 1.45.1 and fails) (#1686) 2023-02-14 12:24:38 +01:00
Michael GoandGitHub 4599e5459f Merge pull request #1684 from Shopify/fix-variable-lookup-parse-timeout
fix variable lookup parse timing out with missing closing bracket
2023-02-02 10:39:46 -04:00
Dylan Thacker-SmithandGitHub 59c445f0e1 Avoid a couple of duplicate assertions (#1675) 2023-02-02 09:31:01 -05:00
Michael Go bd9c3802c8 add variable parser timeout unit tests 2023-02-01 21:15:35 -04:00
Michael Go 2b40850e4a fix variable lookup parse timing out with missing closing bracket 2023-02-01 19:52:30 -04:00
Marco Concetto RudilossoandGitHub 22ded5f304 Merge pull request #1683 from Shopify/marco/raw-tag-whitespace-control
Allow raw tag to be used with whitespace control
2023-02-01 16:51:46 +00:00
Marco Concetto Rudilosso ddc32b7bd8 actually test whitespace stripping 2023-02-01 15:52:34 +00:00
Marco Concetto Rudilosso 74e505f6fa Allow raw tag to be used with whitespace control 2023-02-01 13:49:51 +00:00
Michael GoandGitHub 6a888d4564 Merge pull request #1680 from Shopify/recursively-parse-brackets
recursively parse brackets on variable lookup
2023-01-31 16:29:54 -04:00
Michael Go dd257b3d66 add an unit test for double nest variable lookup 2023-01-30 20:36:14 -04:00
Michael Go 1aaf6ed019 recursively parse brackets on variable lookup 2023-01-30 20:19:35 -04:00
Michael GoandGitHub daf93a83c2 Merge pull request #1676 from Shopify/tablerow-nil-params
raise invalid integer argument error from tablerow
2023-01-18 11:08:04 -04:00
Michael Go e889a9da0b use to_i to parse parameters of tablerow tag 2023-01-17 17:48:16 -04:00
Michael Go 0f11c97623 raise invalid integer argument error from tablerow 2023-01-16 18:53:53 -04:00
Dylan Thacker-SmithandGitHub e804f36681 Merge pull request #1674 from Shopify/better-test-to-liquid-value
Improve test coverage of the to_liquid_value feature.
2023-01-16 10:44:41 -05:00
Dylan Thacker-Smith 619ed3fcd7 Add some additional to_liquid_value assertions 2023-01-13 15:46:12 -05:00
Dylan Thacker-Smith cdb5cb06b2 Remove == method from drops from testing to_liquid_value
Since they could cause tests to pass without to_liquid_value being called
on the left side of the equality comparison.
2023-01-13 15:37:27 -05:00
Jean byroot BoussierandGitHub bc153159e6 Merge pull request #1673 from Shopify/initialize-context
Initialize context to nil on the Drop class
2023-01-12 22:19:28 +01:00
Jemma Issroff 128b4e35be Initialize context to nil on the Drop class
We believe this will reduce megamorphic exits in YJIT. There are
currently 241 separate shapes generated with edge_name "@context" in
SFR. Initializing it to nil will significantly reduce this number.
2023-01-12 15:35:22 -05:00
Dylan Thacker-SmithandGitHub c743936a78 Instrument usage of bug with iteration of String with offset or 0 limit (#1667) 2023-01-11 14:05:50 -05:00
Dylan Thacker-SmithandGitHub bf711a0521 Provide another assertion for to_liquid_value unless tag test (#1672)
Since the original one would pass even if to_liquid_value isn't called on
the BooleanDrop object.
2023-01-11 14:05:27 -05:00
Dylan Thacker-SmithandGitHub e8731f27d9 Merge pull request #1671 from ashmaroli/use-builtin-bundle-cache
Use cache built into `ruby/setup-ruby` action
2023-01-11 11:40:11 -05:00
Ashwin Maroli 6a44c1ec77 Do not set ENV["BUNDLE_PATH"] 2023-01-11 22:06:47 +05:30
Ashwin Maroli 1beb87b446 Bump latest Ruby version in CI matrix 2023-01-11 20:58:17 +05:30
Ashwin Maroli b839deb3a8 Use cache built into ruby/setup-ruby action 2023-01-11 20:50:44 +05:30
liamgriffinandGitHub 0b826120c0 Merge pull request #1665 from Shopify/update-description-divided_by-filter
[Liquid reference docs] Adding more content to divided_by filter
2023-01-09 14:12:23 +00:00
liamgriffin 936f803a4e Removed trailing space 2023-01-06 11:36:20 +00:00
liamgriffin 5cd8a83fa6 Adding more content to divided_by filter
Adding more content to divided_by filter, by detailing the behaviour of how different results are generated by different types of divisors.
2023-01-06 11:19:13 +00:00
Dylan Thacker-SmithandGitHub c2c6cb2b15 Allow commas to separate for tag attributes (#1658)
For consistency with tags like the `render` tag, where we actually
prefer to use commas to separate attributes
2022-11-29 09:00:55 -05:00
shainaraskasandGitHub 9ccbf64571 Merge pull request #1656 from Shopify/sr-inline-comment
clarify inline_comment
2022-11-14 10:38:55 -05: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
144 changed files with 9481 additions and 1054 deletions
+30 -17
View File
@@ -1,39 +1,52 @@
name: Liquid
on: [push, pull_request]
env:
BUNDLE_JOBS: 4
BUNDLE_RETRY: 3
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
entry:
- { ruby: 2.7, allowed-failure: false } # minimum supported
- { ruby: 3.1, allowed-failure: false } # latest
- { ruby: ruby-head, allowed-failure: true }
name: test (${{ matrix.entry.ruby }})
- { ruby: 3.0, allowed-failure: false } # minimum supported
- { ruby: 3.2, allowed-failure: false }
- { 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
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.entry.ruby }}
- uses: actions/cache@v1
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
restore-keys: ${{ runner.os }}-gems-
- run: bundle install --jobs=3 --retry=3 --path=vendor/bundle
bundler-cache: true
bundler: latest
- run: bundle exec rake
continue-on-error: ${{ matrix.entry.allowed-failure }}
env:
RUBYOPT: ${{ matrix.entry.rubyopt }}
memory_profile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
ruby-version: 2.7
- uses: actions/cache@v1
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
restore-keys: ${{ runner.os }}-gems-
- run: bundle install --jobs=3 --retry=3 --path=vendor/bundle
bundler-cache: true
- run: bundle exec rake memory_profile:run
+1 -2
View File
@@ -4,7 +4,6 @@
pkg
*.rbc
.rvmrc
.ruby-version
Gemfile.lock
.bundle
.byebug_history
Gemfile.lock
+8 -4
View File
@@ -10,8 +10,8 @@ Performance:
Enabled: true
AllCops:
TargetRubyVersion: 2.7
NewCops: disable
SuggestExtensions: false
Exclude:
- 'vendor/bundle/**/*'
@@ -19,6 +19,10 @@ Naming/MethodName:
Exclude:
- 'example/server/liquid_servlet.rb'
# Backport https://github.com/Shopify/ruby-style-guide/pull/258
Layout/BeginEndAlignment:
Enabled: true
Style/ClassMethodsDefinitions:
Enabled: false
# liquid filter calls were being mistaken to be calls on arrays
Style/ConcatArrayLiterals:
Exclude:
- 'test/integration/standard_filter_test.rb'
+1
View File
@@ -0,0 +1 @@
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)
+11 -7
View File
@@ -7,21 +7,25 @@ end
gemspec
gem "base64"
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 :test do
gem 'rubocop-shopify', '~> 2.7.0', require: false
gem 'rubocop-performance', require: false
platform :mri, :truffleruby do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'master'
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
end
+75
View File
@@ -1,5 +1,80 @@
# 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.
## 5.4.0 2022-07-29
### Breaking Changes
+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 -->
+48 -6
View File
@@ -1,11 +1,11 @@
[![Build Status](https://api.travis-ci.org/Shopify/liquid.svg?branch=master)](http://travis-ci.org/Shopify/liquid)
[![Build status](https://github.com/Shopify/liquid/actions/workflows/liquid.yml/badge.svg)](https://github.com/Shopify/liquid/actions/workflows/liquid.yml)
[![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](http://inch-ci.org/github/Shopify/liquid)
# Liquid template engine
* [Contributing guidelines](CONTRIBUTING.md)
* [Version history](History.md)
* [Liquid documentation from Shopify](https://shopify.dev/api/liquid)
* [Liquid documentation from Shopify](https://shopify.dev/docs/api/liquid)
* [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki)
* [Website](http://liquidmarkup.org/)
@@ -52,6 +52,47 @@ For standard use you can just pass it the content of a file and call render with
@template.render('name' => 'tobi') # => "hi tobi"
```
### Concept of Environments
In Liquid, a "Environment" is a scoped environment that encapsulates custom tags, filters, and other configurations. This allows you to define and isolate different sets of functionality for different contexts, avoiding global overrides that can lead to conflicts and unexpected behavior.
By using environments, you can:
1. **Encapsulate Logic**: Keep the logic for different parts of your application separate.
2. **Avoid Conflicts**: Prevent custom tags and filters from clashing with each other.
3. **Improve Maintainability**: Make it easier to manage and understand the scope of customizations.
4. **Enhance Security**: Limit the availability of certain tags and filters to specific contexts.
We encourage the use of Environments over globally overriding things because it promotes better software design principles such as modularity, encapsulation, and separation of concerns.
Here's an example of how you can define and use Environments in Liquid:
```ruby
user_environment = Liquid::Environment.build do |environment|
environment.register_tag("renderobj", RenderObjTag)
end
Liquid::Template.parse(<<~LIQUID, environment: user_environment)
{% renderobj src: "path/to/model.obj" %}
LIQUID
```
In this example, `RenderObjTag` is a custom tag that is only available within the `user_environment`.
Similarly, you can define another environment for a different context, such as email templates:
```ruby
email_environment = Liquid::Environment.build do |environment|
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
end
Liquid::Template.parse(<<~LIQUID, environment: email_environment)
{% unsubscribe_footer %}
LIQUID
```
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
### Error Modes
Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted.
@@ -62,9 +103,10 @@ Liquid also comes with a stricter parser that can be used when editing templates
when templates are invalid. You can enable this new parser like this:
```ruby
Liquid::Template.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
Liquid::Template.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Template.error_mode = :lax # The default mode, accepts almost anything.
Liquid::Environment.default.error_mode = :strict
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
@@ -111,4 +153,4 @@ template.render!({ 'x' => 1}, { strict_variables: true })
To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you.
Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns.
Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns.
+28 -3
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
@@ -81,6 +79,33 @@ namespace :benchmark do
task :strict 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"
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
namespace :profile do
+9 -5
View File
@@ -30,14 +30,18 @@ class Servlet < LiquidServlet
private
def products_list
[{ 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' },
{ 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' },
{ 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' }]
[
{ 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' },
{ 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' },
{ 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' }
]
end
def more_products_list
[{ 'name' => 'Arbor Catalyst', 'price' => 39900, 'description' => 'the *arbor catalyst* is an advanced drop-through for freestyle and flatground performance and versatility' },
{ 'name' => 'Arbor Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' }]
[
{ 'name' => 'Arbor Catalyst', 'price' => 39900, 'description' => 'the *arbor catalyst* is an advanced drop-through for freestyle and flatground performance and versatility' },
{ 'name' => 'Arbor Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' }
]
end
def description
+16 -16
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 = ','
@@ -41,16 +43,24 @@ module Liquid
AnyStartingTag = /#{TagStart}|#{VariableStart}/o
PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om
TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om
VariableParser = /\[[^\]]+\]|#{VariableSegment}+\??/o
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/standardfilters'
require 'liquid/file_system'
require 'liquid/parser_switching'
require 'liquid/tag'
require 'liquid/block'
require 'liquid/parse_tree_visitor'
require 'liquid/interrupts'
require 'liquid/tags'
require "liquid/environment"
require 'liquid/lexer'
require 'liquid/parser'
require 'liquid/i18n'
@@ -61,23 +71,16 @@ 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/parser_switching'
require 'liquid/tag'
require 'liquid/tag/disabler'
require 'liquid/tag/disableable'
require 'liquid/block'
require 'liquid/block_body'
require 'liquid/document'
require 'liquid/variable'
require 'liquid/variable_lookup'
require 'liquid/range_lookup'
require 'liquid/file_system'
require 'liquid/resource_limits'
require 'liquid/expression'
require 'liquid/template'
require 'liquid/standardfilters'
require 'liquid/condition'
require 'liquid/utils'
require 'liquid/tokenizer'
@@ -86,7 +89,4 @@ require 'liquid/partial_cache'
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
# Load all the tags of the standard library
#
Dir["#{__dir__}/liquid/tags/*.rb"].each { |f| require f }
require 'liquid/box'
+8 -4
View File
@@ -36,13 +36,17 @@ module Liquid
# @api private
def self.raise_unknown_tag(tag, block_name, block_delimiter, parse_context)
if tag == 'else'
raise SyntaxError, parse_context.locale.t("errors.syntax.unexpected_else",
block_name: block_name)
raise SyntaxError, parse_context.locale.t(
"errors.syntax.unexpected_else",
block_name: block_name,
)
elsif tag.start_with?('end')
raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_delimiter",
raise SyntaxError, parse_context.locale.t(
"errors.syntax.invalid_delimiter",
tag: tag,
block_name: block_name,
block_delimiter: block_delimiter)
block_delimiter: block_delimiter,
)
else
raise SyntaxError, parse_context.locale.t("errors.syntax.unknown_tag", tag: tag)
end
+18 -8
View File
@@ -6,6 +6,7 @@ module Liquid
class BlockBody
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om
FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om
ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om
WhitespaceOrNothing = /\A\s*\z/
TAGSTART = "{%"
@@ -45,7 +46,13 @@ module Liquid
end
tag_name = Regexp.last_match(1)
markup = Regexp.last_match(2)
unless (tag = registered_tags[tag_name])
if tag_name == 'liquid'
parse_context.line_number -= 1
next parse_liquid_tag(markup, parse_context)
end
unless (tag = parse_context.environment.tag_for_name(tag_name))
# end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed
return yield tag_name, markup
@@ -140,7 +147,7 @@ module Liquid
next
end
unless (tag = registered_tags[tag_name])
unless (tag = parse_context.environment.tag_for_name(tag_name))
# end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed
return yield tag_name, markup
@@ -239,10 +246,17 @@ module Liquid
end
def create_variable(token, parse_context)
if token =~ ContentOfVariable
markup = Regexp.last_match(1)
if token.end_with?("}}")
i = 2
i = 3 if token[i] == "-"
parse_end = token.length - 3
parse_end -= 1 if token[parse_end] == "-"
markup_end = parse_end - i + 1
markup = markup_end <= 0 ? "" : token.slice(i, markup_end)
return Variable.new(markup, parse_context)
end
BlockBody.raise_missing_variable_terminator(token, parse_context)
end
@@ -255,9 +269,5 @@ module Liquid
def raise_missing_variable_terminator(token, parse_context)
BlockBody.raise_missing_variable_terminator(token, parse_context)
end
def registered_tags
Template.tags
end
end
end
+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
+9 -4
View File
@@ -24,6 +24,9 @@ module Liquid
else
false
end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end,
}
@@ -69,9 +72,9 @@ module Liquid
case condition.child_relation
when :or
break if result
break if Liquid::Utils.to_liquid_value(result)
when :and
break unless result
break unless Liquid::Utils.to_liquid_value(result)
else
break
end
@@ -159,8 +162,10 @@ module Liquid
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
@node.left, @node.right,
@node.child_condition, @node.attachment
@node.left,
@node.right,
@node.child_condition,
@node.attachment
].compact
end
end
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
module Liquid
module Const
EMPTY_HASH = {}.freeze
EMPTY_ARRAY = [].freeze
end
end
+23 -13
View File
@@ -15,35 +15,40 @@ module Liquid
# context['bob'] #=> nil class Context
class Context
attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments
attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters
attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters, :environment
# rubocop:disable Metrics/ParameterLists
def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block)
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, &block)
def self.build(environment: Environment.default, environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block)
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, environment, &block)
end
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {})
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default)
@environment = environment
@environments = [environments]
@environments.flatten!
@static_environments = [static_environments].flatten(1).freeze
@scopes = [(outer_scope || {})]
@scopes = [outer_scope || {}]
@registers = registers.is_a?(Registers) ? registers : Registers.new(registers)
@errors = []
@partial = false
@strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
@resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits)
@base_scope_depth = 0
@interrupts = []
@filters = []
@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] ||= Liquid::Template.file_system
@registers.static[:file_system] ||= environment.file_system
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new
self.exception_renderer = Template.default_exception_renderer
self.exception_renderer = environment.exception_renderer
if rethrow_errors
self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA
end
@@ -60,7 +65,7 @@ module Liquid
end
def strainer
@strainer ||= StrainerFactory.create(self, @filters)
@strainer ||= @environment.create_strainer(self, @filters)
end
# Adds filters to this context.
@@ -142,9 +147,10 @@ module Liquid
check_overflow
self.class.build(
environment: @environment,
resource_limits: resource_limits,
static_environments: static_environments,
registers: Registers.new(registers)
registers: Registers.new(registers),
).tap do |subcontext|
subcontext.base_scope_depth = base_scope_depth + 1
subcontext.exception_renderer = exception_renderer
@@ -174,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)
@@ -197,10 +203,14 @@ module Liquid
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
end
variable = variable.to_liquid
# update variable's context before invoking #to_liquid
variable.context = self if variable.respond_to?(:context=)
variable
liquid_variable = variable.to_liquid
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
liquid_variable
end
def lookup_and_evaluate(obj, key, raise_on_not_found: true)
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
require "set"
module Liquid
class Deprecations
class << self
attr_accessor :warned
Deprecations.warned = Set.new
def warn(name, alternative)
return if warned.include?(name)
warned << name
caller_location = caller_locations(2, 1).first
Warning.warn("[DEPRECATION] #{name} is deprecated. Use #{alternative} instead. Called from #{caller_location}\n")
end
end
end
end
+4
View File
@@ -25,6 +25,10 @@ module Liquid
class Drop
attr_writer :context
def initialize
@context = nil
end
# Catch all for the method
def liquid_method_missing(method)
return nil unless @context&.strict_variables
+159
View File
@@ -0,0 +1,159 @@
# frozen_string_literal: true
module Liquid
# The Environment is the container for all configuration options of Liquid, such as
# the registered tags, filters, and the default error mode.
class Environment
# The default error mode for all templates. This can be overridden on a
# per-template basis.
attr_accessor :error_mode
# The tags that are available to use in the template.
attr_accessor :tags
# The strainer template which is used to store filters that are available to
# use in templates.
attr_accessor :strainer_template
# The exception renderer that is used to render exceptions that are raised
# when rendering a template
attr_accessor :exception_renderer
# The default file system that is used to load templates from.
attr_accessor :file_system
# The default resource limits that are used to limit the resources that a
# template can consume.
attr_accessor :default_resource_limits
class << self
# Creates a new environment instance.
#
# @param tags [Hash] The tags that are available to use in
# the template.
# @param file_system The default file system that is used
# to load templates from.
# @param error_mode [Symbol] The default error mode for all templates
# (either :strict, :warn, or :lax).
# @param exception_renderer [Proc] The exception renderer that is used to
# render exceptions.
# @yieldparam environment [Environment] The environment instance that is being built.
# @return [Environment] The new environment instance.
def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil)
ret = new
ret.tags = tags if tags
ret.file_system = file_system if file_system
ret.error_mode = error_mode if error_mode
ret.exception_renderer = exception_renderer if exception_renderer
yield ret if block_given?
ret.freeze
end
# Returns the default environment instance.
#
# @return [Environment] The default environment instance.
def default
@default ||= new
end
# Sets the default environment instance for the duration of the block
#
# @param environment [Environment] The environment instance to use as the default for the
# duration of the block.
# @yield
# @return [Object] The return value of the block.
def dangerously_override(environment)
original_default = @default
@default = environment
yield
ensure
@default = original_default
end
end
# Initializes a new environment instance.
# @api private
def initialize
@tags = Tags::STANDARD_TAGS.dup
@error_mode = :lax
@strainer_template = Class.new(StrainerTemplate).tap do |klass|
klass.add_filter(StandardFilters)
end
@exception_renderer = ->(exception) { exception }
@file_system = BlankFileSystem.new
@default_resource_limits = Const::EMPTY_HASH
@strainer_template_class_cache = {}
end
# Registers a new tag with the environment.
#
# @param name [String] The name of the tag.
# @param klass [Liquid::Tag] The class that implements the tag.
# @return [void]
def register_tag(name, klass)
@tags[name] = klass
end
# Registers a new filter with the environment.
#
# @param filter [Module] The module that contains the filter methods.
# @return [void]
def register_filter(filter)
@strainer_template_class_cache.clear
@strainer_template.add_filter(filter)
end
# Registers multiple filters with this environment.
#
# @param filters [Array<Module>] The modules that contain the filter methods.
# @return [self]
def register_filters(filters)
@strainer_template_class_cache.clear
filters.each { |f| @strainer_template.add_filter(f) }
self
end
# Creates a new strainer instance with the given filters, caching the result
# for faster lookup.
#
# @param context [Liquid::Context] The context that the strainer will be
# used in.
# @param filters [Array<Module>] The filters that the strainer will have
# access to.
# @return [Liquid::Strainer] The new strainer instance.
def create_strainer(context, filters = Const::EMPTY_ARRAY)
return @strainer_template.new(context) if filters.empty?
strainer_template = @strainer_template_class_cache[filters] ||= begin
klass = Class.new(@strainer_template)
filters.each { |f| klass.add_filter(f) }
klass
end
strainer_template.new(context)
end
# Returns the names of all the filter methods that are available to use in
# the strainer template.
#
# @return [Array<String>] The names of all the filter methods.
def filter_method_names
@strainer_template.filter_method_names
end
# Returns the tag class for the given tag name.
#
# @param name [String] The name of the tag.
# @return [Liquid::Tag] The tag class.
def tag_for_name(name)
@tags[name]
end
def freeze
@tags.freeze
# TODO: freeze the tags, currently this is not possible because of liquid-c
# @strainer_template.freeze
super
end
end
end
+16 -15
View File
@@ -40,19 +40,20 @@ module Liquid
end
end
ArgumentError = Class.new(Error)
ContextError = Class.new(Error)
FileSystemError = Class.new(Error)
StandardError = Class.new(Error)
SyntaxError = Class.new(Error)
StackLevelError = Class.new(Error)
MemoryError = Class.new(Error)
ZeroDivisionError = Class.new(Error)
FloatDomainError = Class.new(Error)
UndefinedVariable = Class.new(Error)
UndefinedDropMethod = Class.new(Error)
UndefinedFilter = Class.new(Error)
MethodOverrideError = Class.new(Error)
DisabledError = Class.new(Error)
InternalError = Class.new(Error)
ArgumentError = Class.new(Error)
ContextError = Class.new(Error)
FileSystemError = Class.new(Error)
StandardError = Class.new(Error)
SyntaxError = Class.new(Error)
StackLevelError = Class.new(Error)
MemoryError = Class.new(Error)
ZeroDivisionError = Class.new(Error)
FloatDomainError = Class.new(Error)
UndefinedVariable = Class.new(Error)
UndefinedDropMethod = Class.new(Error)
UndefinedFilter = Class.new(Error)
MethodOverrideError = Class.new(Error)
DisabledError = Class.new(Error)
InternalError = Class.new(Error)
TemplateEncodingError = Class.new(Error)
end
+101 -22
View File
@@ -3,41 +3,120 @@
module Liquid
class Expression
LITERALS = {
nil => nil, 'nil' => nil, 'null' => nil, '' => nil,
nil => nil,
'nil' => nil,
'null' => nil,
'' => nil,
'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
+1 -1
View File
@@ -5,7 +5,7 @@ module Liquid
# @liquid_type object
# @liquid_name forloop
# @liquid_summary
# Information about a parent [`for` loop](/api/liquid/tags/for).
# Information about a parent [`for` loop](/docs/api/liquid/tags/for).
class ForloopDrop < Drop
def initialize(name, length, parentloop)
@name = name
+160 -42
View File
@@ -1,61 +1,179 @@
# frozen_string_literal: true
require "strscan"
module Liquid
class Lexer
SPECIALS = {
'|' => :pipe,
'.' => :dot,
':' => :colon,
',' => :comma,
'[' => :open_square,
']' => :close_square,
'(' => :open_round,
')' => :close_round,
'?' => :question,
'-' => :dash,
}.freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
SINGLE_STRING_LITERAL = /'[^\']*'/
CLOSE_ROUND = [:close_round, ")"].freeze
CLOSE_SQUARE = [:close_square, "]"].freeze
COLON = [:colon, ":"].freeze
COMMA = [:comma, ","].freeze
COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze
COMPARISON_CONTAINS = [:comparison, "contains"].freeze
COMPARISON_EQUAL = [:comparison, "=="].freeze
COMPARISON_GREATER_THAN = [:comparison, ">"].freeze
COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze
COMPARISON_LESS_THAN = [:comparison, "<"].freeze
COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze
COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze
DASH = [:dash, "-"].freeze
DOT = [:dot, "."].freeze
DOTDOT = [:dotdot, ".."].freeze
DOT_ORD = ".".ord
DOUBLE_STRING_LITERAL = /"[^\"]*"/
STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL)
EOS = [:end_of_string].freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
NUMBER_LITERAL = /-?\d+(\.\d+)?/
DOTDOT = /\.\./
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
OPEN_ROUND = [:open_round, "("].freeze
OPEN_SQUARE = [:open_square, "["].freeze
PIPE = [:pipe, "|"].freeze
QUESTION = [:question, "?"].freeze
RUBY_WHITESPACE = [" ", "\t", "\r", "\n", "\f"].freeze
SINGLE_STRING_LITERAL = /'[^\']*'/
WHITESPACE_OR_NOTHING = /\s*/
def initialize(input)
@ss = StringScanner.new(input)
SINGLE_COMPARISON_TOKENS = [].tap do |table|
table["<".ord] = COMPARISON_LESS_THAN
table[">".ord] = COMPARISON_GREATER_THAN
table.freeze
end
def tokenize
@output = []
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
table["=".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_EQUAL
sub_table.freeze
end
table["!".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISION_NOT_EQUAL
sub_table.freeze
end
table.freeze
end
until @ss.eos?
@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]
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
sub_table.freeze
end
table[">".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
sub_table.freeze
end
table.freeze
end
NEXT_MATCHER_JUMP_TABLE = [].tap do |table|
"a".upto("z") do |c|
table[c.ord] = [:id, IDENTIFIER].freeze
table[c.upcase.ord] = [:id, IDENTIFIER].freeze
end
table["_".ord] = [:id, IDENTIFIER].freeze
"0".upto("9") do |c|
table[c.ord] = [:number, NUMBER_LITERAL].freeze
end
table["-".ord] = [:number, NUMBER_LITERAL].freeze
table["'".ord] = [:string, SINGLE_STRING_LITERAL].freeze
table["\"".ord] = [:string, DOUBLE_STRING_LITERAL].freeze
table.freeze
end
SPECIAL_TABLE = [].tap do |table|
table["|".ord] = PIPE
table[".".ord] = DOT
table[":".ord] = COLON
table[",".ord] = COMMA
table["[".ord] = OPEN_SQUARE
table["]".ord] = CLOSE_SQUARE
table["(".ord] = OPEN_ROUND
table[")".ord] = CLOSE_ROUND
table["?".ord] = QUESTION
table["-".ord] = DASH
end
NUMBER_TABLE = [].tap do |table|
"0".upto("9") do |c|
table[c.ord] = true
end
table.freeze
end
# rubocop:disable Metrics/BlockNesting
class << self
def tokenize(ss)
output = []
until ss.eos?
ss.skip(WHITESPACE_OR_NOTHING)
break if ss.eos?
start_pos = ss.pos
peeked = ss.peek_byte
if (special = SPECIAL_TABLE[peeked])
ss.scan_byte
# Special case for ".."
if special == DOT && ss.peek_byte == DOT_ORD
ss.scan_byte
output << DOTDOT
elsif special == DASH
# Special case for negative numbers
if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte]
ss.pos -= 1
output << [:number, ss.scan(NUMBER_LITERAL)]
else
output << special
end
else
output << special
end
elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
ss.scan_byte
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
else
raise_syntax_error(start_pos, ss)
end
elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
ss.scan_byte
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
else
output << SINGLE_COMPARISON_TOKENS[peeked]
end
else
raise SyntaxError, "Unexpected character #{c}"
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
if type && (t = ss.scan(pattern))
# Special case for "contains"
output << if type == :id && t == "contains" && output.last&.first != :dot
COMPARISON_CONTAINS
else
[type, t]
end
else
raise_syntax_error(start_pos, ss)
end
end
end
@output << tok
# rubocop:enable Metrics/BlockNesting
output << EOS
rescue ::ArgumentError => e
if e.message == "invalid byte sequence in #{ss.string.encoding}"
raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}"
else
raise
end
end
@output << [:end_of_string]
def raise_syntax_error(start_pos, ss)
ss.pos = start_pos
# the character could be a UTF-8 character, use getch to get all the bytes
raise SyntaxError, "Unexpected character #{ss.getch}"
end
end
end
end
+3
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"
@@ -15,6 +17,7 @@
include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]"
inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character"
invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}"
invalid_template_encoding: "Invalid template encoding"
render: "Syntax error in tag 'render' - Template name must be a quoted string"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
tag_never_closed: "'%{block_name}' tag was never closed"
+29 -6
View File
@@ -3,14 +3,27 @@
module Liquid
class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode
attr_reader :partial, :warnings, :error_mode, :environment
def initialize(options = {})
def initialize(options = Const::EMPTY_HASH)
@environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {}
@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
@@ -23,19 +36,29 @@ 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)
@partial = value
@options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || Template.error_mode
@error_mode = @options[:error_mode] || @environment.error_mode
end
def partial_options
+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
+3 -3
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
@@ -53,7 +53,7 @@ module Liquid
str = consume
str << variable_lookups
when :open_square
str = consume
str = consume.dup
str << expression
str << consume(:close_square)
str << variable_lookups
+12 -3
View File
@@ -4,7 +4,8 @@ module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = context.registers[:cached_partials]
cached = cached_partials[template_name]
cache_key = "#{template_name}:#{parse_context.error_mode}"
cached = cached_partials[cache_key]
return cached if cached
file_system = context.registers[:file_system]
@@ -15,8 +16,16 @@ module Liquid
template_factory = context.registers[:template_factory]
template = template_factory.for(template_name)
partial = template.parse(source, parse_context)
cached_partials[template_name] = partial
begin
partial = template.parse(source, parse_context)
rescue Liquid::Error => e
e.template_name = template&.name || template_name
raise e
end
partial.name ||= template_name
cached_partials[cache_key] = partial
ensure
parse_context.partial = false
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
+208 -71
View File
@@ -3,7 +3,6 @@
require 'cgi'
require 'base64'
require 'bigdecimal'
module Liquid
module StandardFilters
MAX_I32 = (1 << 31) - 1
@@ -25,10 +24,23 @@ module Liquid
STRIP_HTML_BLOCKS = Regexp.union(
%r{<script.*?</script>}m,
/<!--.*?-->/m,
%r{<style.*?</style>}m
%r{<style.*?</style>}m,
)
STRIP_HTML_TAGS = /<.*?>/m
class << self
def try_coerce_encoding(input, encoding:)
original_encoding = input.encoding
if input.encoding != encoding
input.force_encoding(encoding)
unless input.valid_encoding?
input.force_encoding(original_encoding)
end
end
input
end
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
@@ -51,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
@@ -62,18 +74,18 @@ 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
# @liquid_type filter
# @liquid_category string
# @liquid_summary
# Capitalizes the first word in a string.
# Capitalizes the first word in a string and downcases the remaining characters.
# @liquid_syntax string | capitalize
# @liquid_return [string]
def capitalize(input)
input.to_s.capitalize
Utils.to_s(input).capitalize
end
# @liquid_public_docs
@@ -84,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
@@ -96,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
@@ -111,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
@@ -125,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
@@ -139,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
@@ -150,7 +162,8 @@ module Liquid
# @liquid_syntax string | base64_decode
# @liquid_return [string]
def base64_decode(input)
Base64.strict_decode64(input.to_s)
input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
end
@@ -163,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
@@ -174,7 +187,8 @@ module Liquid
# @liquid_syntax string | base64_url_safe_decode
# @liquid_return [string]
def base64_url_safe_decode(input)
Base64.urlsafe_decode64(input.to_s)
input = Utils.to_s(input)
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
rescue ::ArgumentError
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
end
@@ -197,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)
@@ -221,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
@@ -248,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
@@ -262,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
@@ -273,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
@@ -284,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
@@ -295,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
@@ -306,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
@@ -317,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
@@ -331,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
@@ -342,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
@@ -409,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
@@ -528,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
@@ -539,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
@@ -550,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)
@@ -604,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
@@ -615,7 +676,7 @@ module Liquid
# @liquid_description
# > Note:
# > The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the
# > [`uniq` filter](/api/liquid/filters/uniq).
# > [`uniq` filter](/docs/api/liquid/filters/uniq).
# @liquid_syntax array | concat: array
# @liquid_return [array[untyped]]
def concat(input, array)
@@ -633,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
@@ -644,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'')
@@ -676,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
@@ -757,7 +831,7 @@ module Liquid
# @liquid_type filter
# @liquid_category math
# @liquid_summary
# Divides a number by a given number.
# Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float.
# @liquid_syntax number | divided_by: number
# @liquid_return [number]
def divided_by(input, operand)
@@ -857,24 +931,73 @@ module Liquid
# @liquid_summary
# Sets a default value for any variable whose value is one of the following:
#
# - [`empty`](/api/liquid/basics#empty)
# - [`false`](/api/liquid/basics#truthy-and-falsy)
# - [`nil`](/api/liquid/basics#nil)
# - [`empty`](/docs/api/liquid/basics#empty)
# - [`false`](/docs/api/liquid/basics#truthy-and-falsy)
# - [`nil`](/docs/api/liquid/basics#nil)
# @liquid_syntax variable | default: variable
# @liquid_return [untyped]
# @liquid_optional_param allow_false [boolean] Whether to use false values instead of the default.
# @liquid_optional_param allow_false: [boolean] Whether to use false values instead of the default.
def default(input, default_value = '', options = {})
options = {} unless options.is_a?(Hash)
false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input)
false_check || (input.respond_to?(:empty?) && input.empty?) ? default_value : input
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category array
# @liquid_summary
# Returns the sum of all elements in an array.
# @liquid_syntax array | sum
# @liquid_return [number]
def sum(input, property = nil)
ary = InputIterator.new(input, context)
return 0 if ary.empty?
values_for_sum = ary.map do |item|
if property.nil?
item
elsif item.respond_to?(:[])
item[property]
else
0
end
rescue TypeError
raise_property_error(property)
end
result = InputIterator.new(values_for_sum, context).sum do |item|
Utils.to_number(item)
end
result.is_a?(BigDecimal) ? result.to_f : result
end
private
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)
@@ -899,6 +1022,8 @@ module Liquid
def nil_safe_casecmp(a, b)
if !a.nil? && !b.nil?
a.to_s.casecmp(b.to_s)
elsif a.nil? && b.nil?
0
else
a.nil? ? 1 : -1
end
@@ -921,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)
@@ -933,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
@@ -954,6 +1093,4 @@ module Liquid
end
end
end
Template.register_filter(StandardFilters)
end
-41
View File
@@ -1,41 +0,0 @@
# frozen_string_literal: true
module Liquid
# StrainerFactory is the factory for the filters system.
module StrainerFactory
extend self
def add_global_filter(filter)
strainer_class_cache.clear
GlobalCache.add_filter(filter)
end
def create(context, filters = [])
strainer_from_cache(filters).new(context)
end
def global_filter_names
GlobalCache.filter_method_names
end
GlobalCache = Class.new(StrainerTemplate)
private
def strainer_from_cache(filters)
if filters.empty?
GlobalCache
else
strainer_class_cache[filters] ||= begin
klass = Class.new(GlobalCache)
filters.each { |f| klass.add_filter(f) }
klass
end
end
end
def strainer_class_cache
@strainer_class_cache ||= {}
end
end
end
+1 -1
View File
@@ -5,7 +5,7 @@ module Liquid
# @liquid_type object
# @liquid_name tablerowloop
# @liquid_summary
# Information about a parent [`tablerow` loop](/api/liquid/tags/tablerow).
# Information about a parent [`tablerow` loop](/docs/api/liquid/tags/tablerow).
class TablerowloopDrop < Drop
def initialize(length, cols)
@length = length
+5 -1
View File
@@ -1,5 +1,8 @@
# frozen_string_literal: true
require 'liquid/tag/disabler'
require 'liquid/tag/disableable'
module Liquid
class Tag
attr_reader :nodelist, :tag_name, :line_number, :parse_context
@@ -54,7 +57,8 @@ module Liquid
# of the `render_to_output_buffer` method will become the default and the `render`
# method will be removed.
def render_to_output_buffer(context, output)
output << render(context)
render_result = render(context)
output << render_result if render_result
output
end
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
require_relative "tags/table_row"
require_relative "tags/echo"
require_relative "tags/if"
require_relative "tags/break"
require_relative "tags/inline_comment"
require_relative "tags/for"
require_relative "tags/assign"
require_relative "tags/ifchanged"
require_relative "tags/case"
require_relative "tags/include"
require_relative "tags/continue"
require_relative "tags/capture"
require_relative "tags/decrement"
require_relative "tags/unless"
require_relative "tags/increment"
require_relative "tags/comment"
require_relative "tags/raw"
require_relative "tags/render"
require_relative "tags/cycle"
require_relative "tags/doc"
module Liquid
module Tags
STANDARD_TAGS = {
'cycle' => Cycle,
'render' => Render,
'raw' => Raw,
'comment' => Comment,
'increment' => Increment,
'unless' => Unless,
'decrement' => Decrement,
'capture' => Capture,
'continue' => Continue,
'include' => Include,
'case' => Case,
'ifchanged' => Ifchanged,
'assign' => Assign,
'for' => For,
'#' => InlineComment,
'break' => Break,
'if' => If,
'echo' => Echo,
'tablerow' => TableRow,
'doc' => Doc,
}.freeze
end
end
+1 -3
View File
@@ -8,7 +8,7 @@ module Liquid
# @liquid_summary
# Creates a new variable.
# @liquid_description
# You can create variables of any [basic type](/api/liquid/basics#types), [object](/api/liquid/objects), or object property.
# You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property.
# @liquid_syntax
# {% assign variable_name = value %}
# @liquid_syntax_keyword variable_name The name of the variable being created.
@@ -72,6 +72,4 @@ module Liquid
end
end
end
Template.register_tag('assign', Assign)
end
+1 -3
View File
@@ -15,7 +15,7 @@ module Liquid
# @liquid_category iteration
# @liquid_name break
# @liquid_summary
# Stops a [`for` loop](/api/liquid/tags/for) from iterating.
# Stops a [`for` loop](/docs/api/liquid/tags/for) from iterating.
# @liquid_syntax
# {% break %}
class Break < Tag
@@ -26,6 +26,4 @@ module Liquid
output
end
end
Template.register_tag('break', Break)
end
-2
View File
@@ -39,6 +39,4 @@ module Liquid
true
end
end
Template.register_tag('capture', Capture)
end
+1 -3
View File
@@ -77,7 +77,7 @@ module Liquid
end
result = Liquid::Utils.to_liquid_value(
block.evaluate(context)
block.evaluate(context),
)
if result
@@ -123,6 +123,4 @@ module Liquid
end
end
end
Template.register_tag('case', Case)
end
+59 -2
View File
@@ -25,7 +25,64 @@ module Liquid
def blank?
true
end
end
Template.register_tag('comment', Comment)
private
def parse_body(body, tokenizer)
if parse_context.depth >= MAX_DEPTH
raise StackLevelError, "Nesting too deep"
end
parse_context.depth += 1
comment_tag_depth = 1
begin
# Consume tokens without creating child nodes.
# The children tag doesn't require to be a valid Liquid except the comment and raw tag.
# The child comment and raw tag must be closed.
while (token = tokenizer.send(:shift))
tag_name = if tokenizer.for_liquid_tag
next if token.empty? || token.match?(BlockBody::WhitespaceOrNothing)
tag_name_match = BlockBody::LiquidTagToken.match(token)
next if tag_name_match.nil?
tag_name_match[1]
else
token =~ BlockBody::FullToken
Regexp.last_match(2)
end
case tag_name
when "raw"
parse_raw_tag_body(tokenizer)
when "comment"
comment_tag_depth += 1
when "endcomment"
comment_tag_depth -= 1
end
if comment_tag_depth.zero?
parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag
return false
end
end
raise_tag_never_closed(block_name)
ensure
parse_context.depth -= 1
end
false
end
def parse_raw_tag_body(tokenizer)
while (token = tokenizer.send(:shift))
return if token =~ BlockBody::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2)
end
raise_tag_never_closed("raw")
end
end
end
+1 -3
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category iteration
# @liquid_name continue
# @liquid_summary
# Causes a [`for` loop](/api/liquid/tags/for) to skip to the next iteration.
# Causes a [`for` loop](/docs/api/liquid/tags/for) to skip to the next iteration.
# @liquid_syntax
# {% continue %}
class Continue < Tag
@@ -17,6 +17,4 @@ module Liquid
output
end
end
Template.register_tag('continue', Continue)
end
+14 -4
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category iteration
# @liquid_name cycle
# @liquid_summary
# Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/api/liquid/tags/for).
# Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/docs/api/liquid/tags/for).
# @liquid_description
# The `cycle` tag must be used inside a `for` loop.
#
@@ -26,14 +26,20 @@ module Liquid
when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2))
@name = parse_expression(Regexp.last_match(1))
@is_named = true
when SimpleSyntax
@variables = variables_from_string(markup)
@name = @variables.to_s
@is_named = !@name.match?(/\w+:0x\h{8}/)
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
end
def named?
@is_named
end
def render_to_output_buffer(context, output)
context.registers[:cycle] ||= {}
@@ -62,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
@@ -72,6 +84,4 @@ module Liquid
end
end
end
Template.register_tag('cycle', Cycle)
end
+3 -5
View File
@@ -10,10 +10,10 @@ 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`](/api/liquid/tags/assign)
# and [`capture`](/api/liquid/tags/capture). However, `decrement` and [`increment`](/api/liquid/tags/increment) share
# 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
# variables.
# @liquid_syntax
# {% decrement variable_name %}
@@ -35,6 +35,4 @@ module Liquid
output
end
end
Template.register_tag('decrement', Decrement)
end
+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
+2 -4
View File
@@ -9,10 +9,10 @@ module Liquid
# Outputs an expression.
# @liquid_description
# Using the `echo` tag is the same as wrapping an expression in curly brackets (`{{` and `}}`). However, unlike the curly
# bracket method, you can use the `echo` tag inside [`liquid` tags](/api/liquid/tags/liquid).
# bracket method, you can use the `echo` tag inside [`liquid` tags](/docs/api/liquid/tags/liquid).
#
# > Tip:
# > You can use [filters](/api/liquid/filters) on expressions inside `echo` tags.
# > You can use [filters](/docs/api/liquid/filters) on expressions inside `echo` tags.
# @liquid_syntax
# {% liquid
# echo expression
@@ -36,6 +36,4 @@ module Liquid
end
end
end
Template.register_tag('echo', Echo)
end
+6 -7
View File
@@ -9,10 +9,10 @@ module Liquid
# Renders an expression for every item in an array.
# @liquid_description
# You can do a maximum of 50 iterations with a `for` loop. If you need to iterate over more than 50 items, then use the
# [`paginate` tag](/api/liquid/tags/paginate) to split the items over multiple pages.
# [`paginate` tag](/docs/api/liquid/tags/paginate) to split the items over multiple pages.
#
# > Tip:
# > Every `for` loop has an associated [`forloop` object](/api/liquid/objects/forloop) with information about the loop.
# > Every `for` loop has an associated [`forloop` object](/docs/api/liquid/objects/forloop) with information about the loop.
# @liquid_syntax
# {% for variable in array %}
# expression
@@ -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')
@@ -98,11 +98,12 @@ module Liquid
@name = "#{@variable_name}-#{collection_name}"
@reversed = p.id?('reversed')
while p.look(:id) && p.look(:colon, 1)
while p.look(:comma) || p.look(:id)
p.consume?(:comma)
unless (attribute = p.id?('limit') || p.id?('offset'))
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
end
p.consume
p.consume(:colon)
set_attribute(attribute, p.expression)
end
p.consume(:end_of_string)
@@ -200,6 +201,4 @@ module Liquid
end
end
end
Template.register_tag('for', For)
end
+3 -5
View File
@@ -53,7 +53,7 @@ module Liquid
def render_to_output_buffer(context, output)
@blocks.each do |block|
result = Liquid::Utils.to_liquid_value(
block.evaluate(context)
block.evaluate(context),
)
if result
@@ -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
@@ -111,7 +111,7 @@ module Liquid
def parse_binary_comparisons(p)
condition = parse_comparison(p)
first_condition = condition
while (op = (p.id?('and') || p.id?('or')))
while (op = p.id?('and') || p.id?('or'))
child_condition = parse_comparison(p)
condition.send(op, child_condition)
condition = child_condition
@@ -135,6 +135,4 @@ module Liquid
end
end
end
Template.register_tag('if', If)
end
-2
View File
@@ -14,6 +14,4 @@ module Liquid
output
end
end
Template.register_tag('ifchanged', Ifchanged)
end
+8 -8
View File
@@ -6,9 +6,9 @@ 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](/api/liquid/tags/variable-tags) outside of the
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
# snippet.
# @liquid_syntax
# {% include 'filename' %}
@@ -16,7 +16,7 @@ module Liquid
# @liquid_deprecated
# Deprecated because the way that variables are handled reduces performance and makes code harder to both read and maintain.
#
# The `include` tag has been replaced by [`render`](/api/liquid/tags/render).
# The `include` tag has been replaced by [`render`](/docs/api/liquid/tags/render).
class Include < Tag
prepend Tag::Disableable
@@ -57,7 +57,7 @@ module Liquid
partial = PartialCache.load(
template_name,
context: context,
parse_context: parse_context
parse_context: parse_context,
)
context_variable_name = @alias_name || template_name.split('/').last
@@ -70,9 +70,11 @@ module Liquid
old_template_name = context.template_name
old_partial = context.partial
begin
context.template_name = template_name
context.partial = true
context.template_name = partial.name
context.partial = true
context.stack do
@attributes.each do |key, value|
context[key] = context.evaluate(value)
@@ -108,6 +110,4 @@ module Liquid
end
end
end
Template.register_tag('include', Include)
end
+3 -5
View File
@@ -10,10 +10,10 @@ 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`](/api/liquid/tags/assign)
# and [`capture`](/api/liquid/tags/capture). However, `increment` and [`decrement`](/api/liquid/tags/decrement) share
# 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
# variables.
# @liquid_syntax
# {% increment variable_name %}
@@ -35,6 +35,4 @@ module Liquid
output
end
end
Template.register_tag('increment', Increment)
end
-2
View File
@@ -25,6 +25,4 @@ module Liquid
true
end
end
Template.register_tag('#', InlineComment)
end
+2 -4
View File
@@ -14,7 +14,6 @@ module Liquid
# @liquid_syntax_keyword expression The expression to be output without being rendered.
class Raw < Block
Syntax = /\A\s*\z/
FullTokenPossiblyInvalid = /\A(.*)#{TagStart}\s*(\w+)\s*(.*)?#{TagEnd}\z/om
def initialize(tag_name, markup, parse_context)
super
@@ -25,7 +24,8 @@ module Liquid
def parse(tokens)
@body = +''
while (token = tokens.shift)
if token =~ FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
if token =~ BlockBody::FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return
end
@@ -56,6 +56,4 @@ module Liquid
end
end
end
Template.register_tag('raw', Raw)
end
+8 -10
View File
@@ -6,21 +6,21 @@ 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](/api/liquid/tags/variable-tags) outside
# of the snippet or app block. However, you can [specify variables as parameters](/api/liquid/tags/render#render-passing-variables-to-a-snippet)
# 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)
# to pass outside variables to snippets.
#
# While you can't directly access created variables, you can access global objects, as well as any objects that are
# directly accessible outside the snippet or app block. For example, a snippet or app block inside the [product template](/themes/architecture/templates/product)
# can access the [`product` object](/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections)
# can access the [`section` object](/api/liquid/objects/section).
# can access the [`product` object](/docs/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections)
# can access the [`section` object](/docs/api/liquid/objects/section).
#
# Outside a snippet or app block, you can't access variables created inside the snippet or app block.
#
# > Note:
# > When you render a snippet using the `render` tag, you can't use the [`include` tag](/api/liquid/tags/include)
# > When you render a snippet using the `render` tag, you can't use the [`include` tag](/docs/api/liquid/tags/include)
# > inside the snippet.
# @liquid_syntax
# {% render 'filename' %}
@@ -69,14 +69,14 @@ module Liquid
partial = PartialCache.load(
template_name,
context: context,
parse_context: parse_context
parse_context: parse_context,
)
context_variable_name = @alias_name || template_name.split('/').last
render_partial_func = ->(var, forloop) {
inner_context = context.new_isolated_subcontext
inner_context.template_name = template_name
inner_context.template_name = partial.name
inner_context.partial = true
inner_context['forloop'] = forloop if forloop
@@ -108,6 +108,4 @@ module Liquid
end
end
end
Template.register_tag('render', Render)
end
+18 -6
View File
@@ -11,7 +11,7 @@ module Liquid
# The `tablerow` tag must be wrapped in HTML `<table>` and `</table>` tags.
#
# > Tip:
# > Every `tablerow` loop has an associated [`tablerowloop` object](/api/liquid/objects/tablerowloop) with information about the loop.
# > Every `tablerow` loop has an associated [`tablerowloop` object](/docs/api/liquid/objects/tablerowloop) with information about the loop.
# @liquid_syntax
# {% tablerow variable in array %}
# expression
@@ -45,13 +45,13 @@ module Liquid
def render_to_output_buffer(context, output)
(collection = context.evaluate(@collection_name)) || (return '')
from = @attributes.key?('offset') ? context.evaluate(@attributes['offset']).to_i : 0
to = @attributes.key?('limit') ? from + context.evaluate(@attributes['limit']).to_i : nil
from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0
to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil
collection = Utils.slice_collection(collection, from, to)
length = collection.length
cols = @attributes.key?('cols') ? context.evaluate(@attributes['cols']).to_i : length
cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length
output << "<tr class=\"row1\">\n"
context.stack do
@@ -65,6 +65,12 @@ module Liquid
super
output << '</td>'
# Handle any interrupts if they exist.
if context.interrupt?
interrupt = context.pop_interrupt
break if interrupt.is_a?(BreakInterrupt)
end
if tablerowloop.col_last && !tablerowloop.last
output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">"
end
@@ -82,7 +88,13 @@ module Liquid
super + @node.attributes.values + [@node.collection_name]
end
end
end
Template.register_tag('tablerow', TableRow)
private
def to_integer(value)
value.to_i
rescue NoMethodError
raise Liquid::ArgumentError, "invalid integer"
end
end
end
+3 -5
View File
@@ -11,7 +11,7 @@ module Liquid
# Renders an expression unless a specific condition is `true`.
# @liquid_description
# > Tip:
# > Similar to the [`if` tag](/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag.
# > Similar to the [`if` tag](/docs/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag.
# @liquid_syntax
# {% unless condition %}
# expression
@@ -23,7 +23,7 @@ module Liquid
# First condition is interpreted backwards ( if not )
first_block = @blocks.first
result = Liquid::Utils.to_liquid_value(
first_block.evaluate(context)
first_block.evaluate(context),
)
unless result
@@ -33,7 +33,7 @@ module Liquid
# After the first condition unless works just like if
@blocks[1..-1].each do |block|
result = Liquid::Utils.to_liquid_value(
block.evaluate(context)
block.evaluate(context),
)
if result
@@ -44,6 +44,4 @@ module Liquid
output
end
end
Template.register_tag('unless', Unless)
end
+126 -61
View File
@@ -15,45 +15,9 @@ module Liquid
# template.render('user_name' => 'bob')
#
class Template
attr_accessor :root
attr_accessor :root, :name
attr_reader :resource_limits, :warnings
class TagRegistry
include Enumerable
def initialize
@tags = {}
@cache = {}
end
def [](tag_name)
return nil unless @tags.key?(tag_name)
return @cache[tag_name] if Liquid.cache_classes
lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o }
end
def []=(tag_name, klass)
@tags[tag_name] = klass.name
@cache[tag_name] = klass
end
def delete(tag_name)
@tags.delete(tag_name)
@cache.delete(tag_name)
end
def each(&block)
@tags.each(&block)
end
private
def lookup_class(name)
Object.const_get(name)
end
end
attr_reader :profiler
class << self
@@ -61,52 +25,83 @@ module Liquid
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
# :warn is the default and will give deprecation warnings when invalid syntax is used.
# :strict will enforce correct syntax.
attr_accessor :error_mode
Template.error_mode = :lax
attr_accessor :default_exception_renderer
Template.default_exception_renderer = lambda do |exception|
exception
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
end
attr_accessor :file_system
Template.file_system = BlankFileSystem.new
def error_mode
Environment.default.error_mode
end
attr_accessor :tags
Template.tags = TagRegistry.new
private :tags=
def default_exception_renderer=(renderer)
Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=")
Environment.default.exception_renderer = renderer
end
def default_exception_renderer
Environment.default.exception_renderer
end
def file_system=(file_system)
Deprecations.warn("Template.file_system=", "Environment#file_system=")
Environment.default.file_system = file_system
end
def file_system
Environment.default.file_system
end
def tags
Environment.default.tags
end
def register_tag(name, klass)
tags[name.to_s] = klass
Deprecations.warn("Template.register_tag", "Environment#register_tag")
Environment.default.register_tag(name, klass)
end
# Pass a module with filter methods which should be available
# to all liquid views. Good for registering the standard library
def register_filter(mod)
StrainerFactory.add_global_filter(mod)
Deprecations.warn("Template.register_filter", "Environment#register_filter")
Environment.default.register_filter(mod)
end
attr_accessor :default_resource_limits
Template.default_resource_limits = {}
private :default_resource_limits=
private def default_resource_limits=(limits)
Deprecations.warn("Template.default_resource_limits=", "Environment#default_resource_limits=")
Environment.default.default_resource_limits = limits
end
def default_resource_limits
Environment.default.default_resource_limits
end
# creates a new <tt>Template</tt> object from liquid source code
# To enable profiling, pass in <tt>profile: true</tt> as an option.
# See Liquid::Profiler for more information
def parse(source, options = {})
new.parse(source, options)
environment = options[:environment] || Environment.default
new(environment: environment).parse(source, options)
end
end
def initialize
def initialize(environment: Environment.default)
@environment = environment
@rethrow_errors = false
@resource_limits = ResourceLimits.new(Template.default_resource_limits)
@resource_limits = ResourceLimits.new(environment.default_resource_limits)
end
# Parse source code.
# Returns self for easy chaining
def parse(source, options = {})
parse_context = configure_options(options)
source = source.to_s.to_str
unless source.valid_encoding?
raise TemplateEncodingError, parse_context.locale.t("errors.syntax.invalid_template_encoding")
end
tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
@root = Document.parse(tokenizer, parse_context)
self
@@ -156,11 +151,11 @@ module Liquid
c
when Liquid::Drop
drop = args.shift
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when Hash
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when nil
Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits)
Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
else
raise ArgumentError, "Expected Hash or Liquid::Context as parameter"
end
@@ -189,6 +184,8 @@ module Liquid
@profiler = context.profiler = Liquid::Profiler.new
end
context.template_name ||= name
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
@@ -208,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)
@@ -218,8 +277,14 @@ module Liquid
@options = options
@profiling = profiling
@line_numbers = options[:line_numbers] || @profiling
parse_context = options.is_a?(ParseContext) ? options : ParseContext.new(options)
@warnings = parse_context.warnings
parse_context = if options.is_a?(ParseContext)
options
else
opts = options.key?(:environment) ? options : options.merge(environment: @environment)
ParseContext.new(opts)
end
@warnings = parse_context.warnings
parse_context
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.to_s.to_str
@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.4.0"
VERSION = "5.8.6"
end
+5 -2
View File
@@ -13,11 +13,11 @@ Gem::Specification.new do |s|
s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
s.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."
s.required_ruby_version = ">= 2.7.0"
s.required_ruby_version = ">= 3.0.0"
s.required_rubygems_version = ">= 1.3.7"
s.metadata['allowed_push_host'] = 'https://rubygems.org'
@@ -28,6 +28,9 @@ Gem::Specification.new do |s|
s.require_path = "lib"
s.add_dependency("strscan", ">= 3.1.1")
s.add_dependency("bigdecimal")
s.add_development_dependency('rake', '~> 13.0')
s.add_development_dependency('minitest')
end
+11 -6
View File
@@ -3,18 +3,23 @@
require 'benchmark/ips'
require_relative 'theme_runner'
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
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
-5
View File
@@ -49,8 +49,3 @@ module Database
end
end
end
if __FILE__ == $PROGRAM_NAME
p(Database.tables['collections']['frontpage'].keys)
# p Database.tables['blog']['articles']
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)
+2 -2
View File
@@ -45,8 +45,8 @@ class Paginate < Liquid::Block
pagination['items'] = collection_size
pagination['pages'] = page_count - 1
pagination['previous'] = link('&laquo; Previous', current_page - 1) unless 1 >= current_page
pagination['next'] = link('Next &raquo;', current_page + 1) unless page_count <= current_page + 1
pagination['previous'] = link('&laquo; Previous', current_page - 1) if 1 < current_page
pagination['next'] = link('Next &raquo;', current_page + 1) if page_count > current_page + 1
pagination['parts'] = []
hellip_break = false

Some files were not shown because too many files have changed in this diff Show More