Compare commits

..
Author SHA1 Message Date
Shaina Raskas c8af0b06b9 remove spaces 2022-03-14 23:07:42 +00:00
Shaina Raskas faf208ad65 some tags 2022-03-14 23:06:01 +00:00
157 changed files with 1921 additions and 9934 deletions
+2
View File
@@ -0,0 +1,2 @@
enabled:
- cla
-22
View File
@@ -1,22 +0,0 @@
name: Contributor License Agreement (CLA)
on:
pull_request_target:
types: [opened, synchronize]
issue_comment:
types: [created]
jobs:
cla:
runs-on: ubuntu-latest
if: |
(github.event.issue.pull_request
&& !github.event.issue.pull_request.merged_at
&& contains(github.event.comment.body, 'signed')
)
|| (github.event.pull_request && !github.event.pull_request.merged)
steps:
- uses: Shopify/shopify-cla-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
cla-token: ${{ secrets.CLA_TOKEN }}
+19 -32
View File
@@ -1,52 +1,39 @@
name: Liquid
on: [push, pull_request]
env:
BUNDLE_JOBS: 4
BUNDLE_RETRY: 3
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
entry:
- { 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 }}
- { ruby: 2.5, allowed-failure: false } # minimum supported
- { ruby: 3.1, allowed-failure: false } # latest
- { ruby: ruby-head, allowed-failure: true }
name: test (${{ matrix.entry.ruby }})
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
bundler: latest
- 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
- 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: actions/checkout@v2
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
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
- run: bundle exec rake memory_profile:run
+2 -1
View File
@@ -4,6 +4,7 @@
pkg
*.rbc
.rvmrc
.ruby-version
Gemfile.lock
.bundle
.byebug_history
Gemfile.lock
+4 -8
View File
@@ -10,8 +10,8 @@ Performance:
Enabled: true
AllCops:
TargetRubyVersion: 2.5
NewCops: disable
SuggestExtensions: false
Exclude:
- 'vendor/bundle/**/*'
@@ -19,10 +19,6 @@ Naming/MethodName:
Exclude:
- 'example/server/liquid_servlet.rb'
Style/ClassMethodsDefinitions:
Enabled: false
# liquid filter calls were being mistaken to be calls on arrays
Style/ConcatArrayLiterals:
Exclude:
- 'test/integration/standard_filter_test.rb'
# Backport https://github.com/Shopify/ruby-style-guide/pull/258
Layout/BeginEndAlignment:
Enabled: true
+14 -137
View File
@@ -1,74 +1,27 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2022-05-18 19:25:47 UTC using RuboCop version 1.29.1.
# on 2020-12-11 18:53:41 UTC using RuboCop version 1.6.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include.
# Include: **/*.gemspec
Gemspec/OrderedDependencies:
# Offense count: 2
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: runtime_error, standard_error
Lint/InheritException:
Exclude:
- 'liquid.gemspec'
- 'lib/liquid/interrupts.rb'
# Offense count: 6
# This cop supports safe auto-correction (--auto-correct).
Layout/ClosingHeredocIndentation:
Exclude:
- 'test/integration/tags/for_tag_test.rb'
# Offense count: 34
# This cop supports safe auto-correction (--auto-correct).
Layout/EmptyLineAfterGuardClause:
Exclude:
- 'lib/liquid/block.rb'
- 'lib/liquid/block_body.rb'
- 'lib/liquid/context.rb'
- 'lib/liquid/drop.rb'
- 'lib/liquid/lexer.rb'
- 'lib/liquid/parser.rb'
- 'lib/liquid/profiler/hooks.rb'
- 'lib/liquid/standardfilters.rb'
- 'lib/liquid/tags/for.rb'
- 'lib/liquid/tags/if.rb'
- 'lib/liquid/utils.rb'
- 'lib/liquid/variable.rb'
- 'lib/liquid/variable_lookup.rb'
- 'performance/shopify/money_filter.rb'
- 'performance/shopify/paginate.rb'
# Offense count: 113
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
# URISchemes: http, https
Layout/LineLength:
Max: 260
# Offense count: 8
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: AllowAliasSyntax, AllowedMethods.
# AllowedMethods: alias_method, public, protected, private
Layout/EmptyLinesAroundAttributeAccessor:
Exclude:
- 'lib/liquid/template.rb'
- 'test/integration/filter_test.rb'
- 'test/integration/tags/include_tag_test.rb'
- 'test/unit/strainer_template_unit_test.rb'
# Offense count: 17
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, IndentationWidth.
# SupportedStyles: aligned, indented
Layout/LineEndStringConcatenationIndentation:
Exclude:
- 'test/integration/tags/for_tag_test.rb'
- 'test/integration/tags/increment_tag_test.rb'
# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, IndentationWidth.
# SupportedStyles: aligned, indented
Layout/MultilineOperationIndentation:
Exclude:
- 'lib/liquid/expression.rb'
# Offense count: 9
Lint/MissingSuper:
Exclude:
- 'lib/liquid/forloop_drop.rb'
@@ -80,7 +33,7 @@ Lint/MissingSuper:
- 'test/integration/tags/for_tag_test.rb'
- 'test/integration/tags/table_row_test.rb'
# Offense count: 44
# Offense count: 43
Naming/ConstantName:
Exclude:
- 'lib/liquid.rb'
@@ -98,83 +51,7 @@ Naming/ConstantName:
- 'performance/shopify/paginate.rb'
- 'test/integration/tags/include_tag_test.rb'
# Offense count: 9
# Configuration parameters: CheckIdentifiers, CheckConstants, CheckVariables, CheckStrings, CheckSymbols, CheckComments, CheckFilepaths, FlaggedTerms.
Naming/InclusiveLanguage:
Exclude:
- 'lib/liquid/drop.rb'
- 'lib/liquid/parse_context.rb'
- 'test/integration/drop_test.rb'
- 'test/integration/tags/if_else_tag_test.rb'
# Offense count: 2
Style/ClassVars:
Exclude:
- 'lib/liquid/condition.rb'
# Offense count: 3
# This cop supports safe auto-correction (--auto-correct).
Style/ExplicitBlockArgument:
Exclude:
- 'test/integration/context_test.rb'
- 'test/integration/tag/disableable_test.rb'
- 'test/integration/tags/for_tag_test.rb'
# Offense count: 2982
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiterals:
Enabled: false
# Offense count: 20
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: single_quotes, double_quotes
Style/StringLiteralsInInterpolation:
Exclude:
- 'lib/liquid/condition.rb'
- 'lib/liquid/strainer_template.rb'
- 'lib/liquid/tag/disableable.rb'
- 'performance/shopify/shop_filter.rb'
- 'performance/shopify/tag_filter.rb'
# Offense count: 6
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma
Style/TrailingCommaInArrayLiteral:
Exclude:
- 'example/server/example_servlet.rb'
- 'lib/liquid/condition.rb'
- 'test/integration/context_test.rb'
- 'test/integration/standard_filter_test.rb'
- 'test/unit/parse_tree_visitor_test.rb'
# Offense count: 1
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyleForMultiline.
# SupportedStylesForMultiline: comma, consistent_comma, no_comma
Style/TrailingCommaInHashLiteral:
Exclude:
- 'lib/liquid/expression.rb'
# Offense count: 19
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: EnforcedStyle, MinSize, WordRegex.
# SupportedStyles: percent, brackets
Style/WordArray:
Exclude:
- 'lib/liquid/tags/if.rb'
- 'liquid.gemspec'
- 'test/integration/assign_test.rb'
- 'test/integration/context_test.rb'
- 'test/integration/drop_test.rb'
- 'test/integration/standard_filter_test.rb'
# Offense count: 117
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns.
# URISchemes: http, https
Layout/LineLength:
Max: 260
-1
View File
@@ -1 +0,0 @@
3.4.1
-8
View File
@@ -26,11 +26,3 @@
* 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)
+6 -9
View File
@@ -7,25 +7,22 @@ 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 :development do
gem "webrick"
end
group :test do
gem 'rubocop', '~> 1.61.0'
gem 'rubocop-shopify', '~> 2.12.0', require: false
gem 'rubocop', '~> 1.4', require: false
gem 'rubocop-shopify', '~> 1.0.7', require: false
gem 'rubocop-performance', require: false
platform :mri, :truffleruby do
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'master'
end
end
+1 -93
View File
@@ -1,101 +1,9 @@
# 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
* Drop support for end-of-life Ruby versions (2.5 and 2.6) (#1578) [Andy Waite]
### Features
* Allow `#` to be used as an inline comment tag (#1498) [CP Clermont]
### Fixes
* `PartialCache` now shares snippet cache with subcontexts by default (#1553) [Chris AtLee]
* Hash registers no longer leak into subcontexts as static registers (#1564) [Chris AtLee]
* Fix `ParseTreeVisitor` for `with` variable expressions in `Render` tag (#1596) [CP Clermont]
### Changed
* Liquid::Context#registers now always returns a Liquid::Registers object, though supports the most used Hash functions for compatibility (#1553)
## 5.3.0 2022-03-22
## 5.3.0 (unreleased)
### Fixes
* StandardFilter: Fix missing @context on iterations (#1525) [Thierry Joyal]
* Fix warning about block and default value in `static_registers.rb` (#1531) [Peter Zhu]
### Deprecation
* Condition#evaluate to require mandatory context argument in Liquid 6.0.0 (#1527) [Thierry Joyal]
+7 -49
View File
@@ -1,11 +1,11 @@
[![Build status](https://github.com/Shopify/liquid/actions/workflows/liquid.yml/badge.svg)](https://github.com/Shopify/liquid/actions/workflows/liquid.yml)
[![Build Status](https://api.travis-ci.org/Shopify/liquid.svg?branch=master)](http://travis-ci.org/Shopify/liquid)
[![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/docs/api/liquid)
* [Liquid documentation from Shopify](https://shopify.dev/api/liquid)
* [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki)
* [Website](http://liquidmarkup.org/)
@@ -52,47 +52,6 @@ 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.
@@ -103,15 +62,14 @@ 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::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.
Liquid::Template.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
Liquid::Template.error_mode = :warn # Adds errors to template.errors but continues as normal
Liquid::Template.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`:
```ruby
Liquid::Template.parse(source, error_mode: :strict)
Liquid::Template.parse(source, :error_mode => :strict)
```
This is useful for doing things like enabling strict mode only in the theme editor.
@@ -153,4 +111,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.
+3 -28
View File
@@ -43,6 +43,8 @@ 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
@@ -71,7 +73,7 @@ end
namespace :benchmark do
desc "Run the liquid benchmark with lax parsing"
task :lax do
task :run do
ruby "./performance/benchmark.rb lax"
end
@@ -79,33 +81,6 @@ 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
+5 -9
View File
@@ -30,18 +30,14 @@ 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
+18 -18
View File
@@ -21,8 +21,6 @@
# 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 = ','
@@ -31,7 +29,6 @@ module Liquid
WhitespaceControl = '-'
TagStart = /\{\%/
TagEnd = /\%\}/
TagName = /#|\w+/
VariableSignature = /\(?[\w\-\.\[\]]\)?/
VariableSegment = /[\w\-]/
VariableStart = /\{\{/
@@ -43,24 +40,16 @@ module Liquid
AnyStartingTag = /#{TagStart}|#{VariableStart}/o
PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om
TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om
VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o
VariableParser = /\[[^\]]+\]|#{VariableSegment}+\??/o
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
singleton_class.send(:attr_accessor, :cache_classes)
self.cache_classes = true
end
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'
@@ -71,22 +60,33 @@ 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'
require 'liquid/parse_context'
require 'liquid/partial_cache'
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/register'
require 'liquid/static_registers'
require 'liquid/template_factory'
require 'liquid/box'
# Load all the tags of the standard library
#
Dir["#{__dir__}/liquid/tags/*.rb"].each { |f| require f }
+4 -8
View File
@@ -36,17 +36,13 @@ 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
+26 -32
View File
@@ -4,9 +4,8 @@ require 'English'
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
LiquidTagToken = /\A\s*(\w+)\s*(.*?)\z/o
FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(\w+)(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om
ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om
WhitespaceOrNothing = /\A\s*\z/
TAGSTART = "{%"
@@ -36,9 +35,21 @@ module Liquid
super
end
# @public_docs
# @type tag
# @category theme
# @title liquid
# @summary
# Allows you to write multiple tags within one set of delimiters.
# @description
# Use the [`echo`](/api/liquid/tags/theme-tags#echo) tag to output an expression within a `liquid` tag.
# @syntax
# {% liquid
# statement
# %}
private def parse_for_liquid_tag(tokenizer, parse_context)
while (token = tokenizer.shift)
unless token.empty? || token.match?(WhitespaceOrNothing)
unless token.empty? || token =~ WhitespaceOrNothing
unless token =~ LiquidTagToken
# line isn't empty but didn't match tag syntax, yield and let the
# caller raise a syntax error
@@ -46,13 +57,7 @@ module Liquid
end
tag_name = Regexp.last_match(1)
markup = Regexp.last_match(2)
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))
unless (tag = registered_tags[tag_name])
# end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed
return yield tag_name, markup
@@ -116,22 +121,14 @@ module Liquid
end
end
private def handle_invalid_tag_token(token, parse_context)
if token.end_with?('%}')
yield token, token
else
BlockBody.raise_missing_tag_terminator(token, parse_context)
end
end
private def parse_for_document(tokenizer, parse_context, &block)
private def parse_for_document(tokenizer, parse_context)
while (token = tokenizer.shift)
next if token.empty?
case
when token.start_with?(TAGSTART)
whitespace_handler(token, parse_context)
unless token =~ FullToken
return handle_invalid_tag_token(token, parse_context, &block)
BlockBody.raise_missing_tag_terminator(token, parse_context)
end
tag_name = Regexp.last_match(2)
markup = Regexp.last_match(4)
@@ -147,7 +144,7 @@ module Liquid
next
end
unless (tag = parse_context.environment.tag_for_name(tag_name))
unless (tag = registered_tags[tag_name])
# end parsing if we reach an unknown tag and let the caller decide
# determine how to proceed
return yield tag_name, markup
@@ -165,7 +162,7 @@ module Liquid
end
parse_context.trim_whitespace = false
@nodelist << token
@blank &&= token.match?(WhitespaceOrNothing)
@blank &&= !!(token =~ WhitespaceOrNothing)
end
parse_context.line_number = tokenizer.line_number
end
@@ -246,17 +243,10 @@ module Liquid
end
def create_variable(token, parse_context)
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)
if token =~ ContentOfVariable
markup = Regexp.last_match(1)
return Variable.new(markup, parse_context)
end
BlockBody.raise_missing_variable_terminator(token, parse_context)
end
@@ -269,5 +259,9 @@ 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
-493
View File
@@ -1,493 +0,0 @@
# 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.
# 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 RUNTIME_PATH
end
# Track constants to preserve after lock
@user_constants << "LR"
@user_constants << "LiquidRuntime"
@user_constants << "CGI"
@user_constants << "Base64"
@user_constants << "BigDecimal"
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 neutered by default for security.
# Templates that need time should receive it 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
undef_method(:__send__) rescue nil
end
RUBY
end
def neuter_object!
@box.eval(<<~'RUBY')
class Object
[:gem, :gem_original_require, :require, :require_relative, :load,
:display, :define_singleton_method,
:instance_variable_set, :remove_instance_variable,
:extend, :send, :public_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
-83
View File
@@ -1,83 +0,0 @@
# frozen_string_literal: true
# Liquid Ruby Compiler
#
# This module provides the ability to compile Liquid templates to pure Ruby code.
# The compiled code can be eval'd to create a proc that renders the template
# without needing the Liquid library at runtime.
#
# ## 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!"
#
# ## Optimization Opportunities
#
# The compiled Ruby code has several significant advantages over interpreted Liquid:
#
# 1. **No Context Object**: Variables are extracted directly from the assigns hash
# and accessed without the Context abstraction layer.
#
# 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.
#
# 5. **Direct String Concatenation**: Output is built with direct << operations.
#
# 6. **Native Control Flow**: break/continue use Ruby's throw/catch mechanism.
#
# 7. **No to_liquid Calls**: Values are used directly without conversion.
#
# 8. **No Profiling Hooks**: No profiler overhead in the generated code.
#
# 9. **No Exception Rendering**: Errors propagate naturally.
#
# ## Limitations
#
# - {% render %} and {% include %} tags require runtime support
# - Custom tags need explicit compiler implementations
# - Custom filters need to 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
@@ -1,49 +0,0 @@
# 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
@@ -1,82 +0,0 @@
# 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
@@ -1,81 +0,0 @@
# 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
-85
View File
@@ -1,85 +0,0 @@
# frozen_string_literal: true
module Liquid
module Compile
# Represents a compiled Liquid template ready for execution.
#
# Contains the Ruby source code and any external tags/filters that need to be
# passed to the generated lambda at runtime.
#
# Usage:
# compiled = template.compile_to_ruby
# result = compiled.call({ "name" => "World" })
#
# # With custom filters:
# compiled.filter_handler = MyFilterModule
# result = compiled.call({ "name" => "World" })
#
class CompiledTemplate
attr_reader :code, :external_tags
attr_accessor :filter_handler
# @param code [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(code, external_tags = {}, has_external_filters = false)
@code = code
@external_tags = external_tags
@has_external_filters = has_external_filters
@filter_handler = nil
@proc = 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 the compiled proc, caching it after first compilation
def to_proc
@proc ||= eval(@code)
end
# Execute the compiled template with the given assigns
# @param assigns [Hash] The variable assignments
# @param filter_handler [Object] Optional filter handler to override the default
# @param registers [Hash] Optional registers for context
# @param strict_variables [Boolean] Raise on undefined variables
# @param strict_filters [Boolean] Raise on undefined filters
# @return [String] The rendered output
def call(assigns = {}, filter_handler: nil, registers: {}, strict_variables: false, strict_filters: false)
proc = to_proc
handler = filter_handler || @filter_handler
# Create a context for Drop support
context = CompiledContext.new(
assigns,
registers: registers,
strict_variables: strict_variables,
strict_filters: strict_filters
)
# Build arguments based on what the lambda expects
args = [assigns]
args << @external_tags if has_external_tags?
args << handler if has_external_filters?
args << context # Always pass context as last arg
proc.call(*args)
end
# Returns the Ruby code as a string
def to_s
@code
end
end
end
# Make CompiledTemplate available at the top level for convenience
CompiledTemplate = Compile::CompiledTemplate
end
-125
View File
@@ -1,125 +0,0 @@
# 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
if op.nil?
left_expr = ExpressionCompiler.compile(left, compiler)
return "__truthy__(#{left_expr})"
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
end
end
end
-115
View File
@@ -1,115 +0,0 @@
# 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
# 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 = "__lookup__.call(#{base}, #{compile(key, compiler)})"
elsif key.is_a?(Integer)
# Numeric index like foo[0]
base = "__lookup__.call(#{base}, #{key})"
elsif key.is_a?(String)
# Always use __lookup__ which tries key access first,
# then falls back to method call for command methods (first, last, size)
base = "__lookup__.call(#{base}, #{key.inspect})"
else
base = "__lookup__.call(#{base}, #{compile(key, compiler)})"
end
end
base
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
"(__to_integer__(#{start_expr})...__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
-233
View File
@@ -1,233 +0,0 @@
# 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) { "__to_s__(#{input}).downcase" },
'upcase' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).upcase" },
'capitalize' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).capitalize" },
'strip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).strip" },
'lstrip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).lstrip" },
'rstrip' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).rstrip" },
'reverse' => ->(input, _args, _kwargs, _compiler) { "(#{input}.is_a?(Array) ? #{input}.reverse : __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) { "__to_number__(#{input}).abs" },
'ceil' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).ceil.to_i" },
'floor' => ->(input, _args, _kwargs, _compiler) { "__to_number__(#{input}).floor.to_i" },
'escape' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escapeHTML(__to_s__(#{input})))" },
'h' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escapeHTML(__to_s__(#{input})))" },
'url_encode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.escape(__to_s__(#{input})))" },
'url_decode' => ->(input, _args, _kwargs, _compiler) { "(#{input}.nil? ? nil : CGI.unescape(__to_s__(#{input})))" },
'base64_encode' => ->(input, _args, _kwargs, _compiler) { "Base64.strict_encode64(__to_s__(#{input}))" },
'base64_decode' => ->(input, _args, _kwargs, _compiler) { "Base64.strict_decode64(__to_s__(#{input}))" },
'base64_url_safe_encode' => ->(input, _args, _kwargs, _compiler) { "Base64.urlsafe_encode64(__to_s__(#{input}))" },
'base64_url_safe_decode' => ->(input, _args, _kwargs, _compiler) { "Base64.urlsafe_decode64(__to_s__(#{input}))" },
'strip_html' => ->(input, _args, _kwargs, _compiler) {
"__to_s__(#{input}).gsub(%r{<script.*?</script>|<!--.*?-->|<style.*?</style>}m, '').gsub(/<.*?>/m, '')"
},
'strip_newlines' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).gsub(/\\r?\\n/, '')" },
'newline_to_br' => ->(input, _args, _kwargs, _compiler) { "__to_s__(#{input}).gsub(/\\r?\\n/, \"<br />\\n\")" },
}.freeze
# Filters with arguments that need special handling
PARAMETERIZED_FILTERS = {
'append' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"__to_s__(#{input}) + __to_s__(#{arg})"
},
'prepend' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"__to_s__(#{arg}) + __to_s__(#{input})"
},
'plus' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(__to_number__(#{input}) + __to_number__(#{arg}))"
},
'minus' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(__to_number__(#{input}) - __to_number__(#{arg}))"
},
'times' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(__to_number__(#{input}) * __to_number__(#{arg}))"
},
'divided_by' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(__to_number__(#{input}) / __to_number__(#{arg}))"
},
'modulo' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"(__to_number__(#{input}) % __to_number__(#{arg}))"
},
'round' => ->(input, args, _kwargs, compiler) {
if args.empty?
"__to_number__(#{input}).round.to_i"
else
arg = compile_arg(args[0], compiler)
"__to_number__(#{input}).round(__to_number__(#{arg}))"
end
},
'at_least' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"[__to_number__(#{input}), __to_number__(#{arg})].max"
},
'at_most' => ->(input, args, _kwargs, compiler) {
arg = compile_arg(args[0], compiler)
"[__to_number__(#{input}), __to_number__(#{arg})].min"
},
'default' => ->(input, args, kwargs, compiler) {
default_val = args.empty? ? "''" : compile_arg(args[0], compiler)
allow_false = kwargs && kwargs['allow_false'] ? compile_arg(kwargs['allow_false'], compiler) : 'false'
"(if #{allow_false} then (#{input}.nil? || (#{input}.respond_to?(:empty?) && #{input}.empty?)) else (!__truthy__(#{input}) || (#{input}.respond_to?(:empty?) && #{input}.empty?)) end) ? #{default_val} : #{input}"
},
'split' => ->(input, args, _kwargs, compiler) {
pattern = args.empty? ? "' '" : compile_arg(args[0], compiler)
"__to_s__(#{input}).split(__to_s__(#{pattern}))"
},
'join' => ->(input, args, _kwargs, compiler) {
glue = args.empty? ? "' '" : compile_arg(args[0], compiler)
"(#{input}.is_a?(Array) ? #{input}.map { |i| __to_s__(i) }.join(__to_s__(#{glue})) : __to_s__(#{input}))"
},
'replace' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
"__to_s__(#{input}).gsub(__to_s__(#{string}), __to_s__(#{replacement}))"
},
'replace_first' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
replacement = args.length > 1 ? compile_arg(args[1], compiler) : "''"
"__to_s__(#{input}).sub(__to_s__(#{string}), __to_s__(#{replacement}))"
},
'remove' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
"__to_s__(#{input}).gsub(__to_s__(#{string}), '')"
},
'remove_first' => ->(input, args, _kwargs, compiler) {
string = compile_arg(args[0], compiler)
"__to_s__(#{input}).sub(__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) : "'...'"
var = compiler.generate_var_name("trunc")
"(lambda { |#{var}_input, #{var}_len, #{var}_ell| #{var}_str = __to_s__(#{var}_input); #{var}_ell_str = __to_s__(#{var}_ell); #{var}_l = [#{var}_len.to_i - #{var}_ell_str.length, 0].max; #{var}_str.length > #{var}_len.to_i ? #{var}_str[0, #{var}_l] + #{var}_ell_str : #{var}_str }).call(#{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) : "'...'"
"(lambda { |input, num_words, ell| words = __to_s__(input).split(' ', [num_words.to_i, 1].max + 1); words.length > [num_words.to_i, 1].max ? words[0, [num_words.to_i, 1].max].join(' ') + __to_s__(ell) : input.to_s }).call(#{input}, #{words}, #{ellipsis})"
},
'slice' => ->(input, args, _kwargs, compiler) {
offset = compile_arg(args[0], compiler)
length = args.length > 1 ? compile_arg(args[1], compiler) : "1"
"(#{input}.is_a?(Array) ? (#{input}.slice(__to_integer__(#{offset}), __to_integer__(#{length})) || []) : (__to_s__(#{input}).slice(__to_integer__(#{offset}), __to_integer__(#{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?(:[]) && __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?(:[]) && __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)
# This is a simplified version - full date parsing is complex
"(lambda { |input, fmt| return input if fmt.to_s.empty?; d = case input; when Time, Date, DateTime 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(fmt.to_s) : input }.call(#{input}, #{format}))"
},
'escape_once' => ->(input, _args, _kwargs, _compiler) {
"__to_s__(#{input}).gsub(/[\"><']|&(?!([a-zA-Z]+|(#\\d+));)/) { |c| {'&'=>'&amp;', '>'=>'&gt;', '<'=>'&lt;', '\"'=>'&quot;', \"'\"=>'&#39;'}[c] || c }"
},
}.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
# Uses __call_filter__ helper which must be provided by the runtime
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(', ')}]"
# Call through the filter helper which delegates to registered filters
"__call_filter__.call(#{name.inspect}, #{input}, #{args_str})"
end
# Compile a filter argument
def self.compile_arg(arg, compiler)
ExpressionCompiler.compile(arg, compiler)
end
end
end
end
-534
View File
@@ -1,534 +0,0 @@
# 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
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)
# Determine lambda parameters based on external dependencies
params = ["assigns = {}"]
params << "__external_tags__ = {}" unless @external_tags.empty?
params << "__filter_handler__ = nil" if @has_external_filters
params << "__context__ = nil"
code.line "->(#{params.join(', ')}) do"
code.indent do
# Initialize the output buffer
code.line '__output__ = +""'
code.blank_line
# Create a compiled context if not provided (for Drop support)
code.line "# Create context for Drop support"
code.line "__context__ ||= Liquid::Compile::CompiledContext.new(assigns)"
code.blank_line
# Compile helper methods if needed
if @options[:include_filters]
compile_helper_methods(code)
code.blank_line
end
# Add external tag runtime helper if needed
unless @external_tags.empty?
compile_external_tag_helper(code)
code.blank_line
end
# Add external filter helper if needed
if @has_external_filters
compile_filter_helper(code)
code.blank_line
end
# 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 helper for calling external tags at runtime
def compile_external_tag_helper(code)
code.line "# Helper for calling external (unknown) tags at runtime"
code.line "__call_external_tag__ = ->(tag_var, tag_assigns) {"
code.indent do
code.line "tag = __external_tags__[tag_var]"
code.line "next '' unless tag"
code.line "# Create a context using the default environment (which has filters registered)"
code.line "ctx = Liquid::Context.new([tag_assigns], {}, {}, false, nil, {}, Liquid::Environment.default)"
code.line "output = +''"
code.line "# Use render_to_output_buffer to ensure block tags work correctly"
code.line "tag.render_to_output_buffer(ctx, output)"
code.line "output"
end
code.line "}"
end
# Compile helper for calling external filters at runtime
def compile_filter_helper(code)
code.line "# Helper for calling external (unknown) filters at runtime"
code.line "__call_filter__ = ->(name, input, args) {"
code.indent do
code.line "if __filter_handler__&.respond_to?(name)"
code.indent do
code.line "__filter_handler__.send(name, input, *args)"
end
code.line "else"
code.indent do
code.line "input # Return input unchanged if filter not found"
end
code.line "end"
end
code.line "}"
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)
compiler_class = find_tag_compiler(tag)
if compiler_class
compiler_class.compile(tag, self, code)
else
# Unknown tag - delegate to the original tag's render method 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} (delegated to runtime)"
code.line "$stderr.puts '* WARN: Liquid external tag call - #{tag_name} (not compiled, delegated to runtime)' if $VERBOSE"
end
code.line "__output__ << __call_external_tag__.call(#{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
def compile_helper_methods(code)
code.line "# Helper methods for filters and utilities"
# to_s helper that handles arrays and hashes like Liquid does
code.line "def __to_s__(obj)"
code.indent do
code.line "case obj"
code.line "when NilClass then ''"
code.line "when Array then obj.join"
code.line "else obj.to_s"
code.line "end"
end
code.line "end"
code.blank_line
# to_number helper
code.line "def __to_number__(obj)"
code.indent do
code.line "case obj"
code.line "when Numeric then obj"
code.line "when String"
code.indent do
code.line "obj.strip =~ /\\A-?\\d+\\.\\d+\\z/ ? BigDecimal(obj) : obj.to_i"
end
code.line "else 0"
code.line "end"
end
code.line "end"
code.blank_line
# to_integer helper
code.line "def __to_integer__(obj)"
code.indent do
code.line "return obj if obj.is_a?(Integer)"
code.line "Integer(obj.to_s)"
end
code.line "end"
code.blank_line
# Liquid truthiness helper
code.line "def __truthy__(obj)"
code.indent do
code.line "obj != nil && obj != false"
end
code.line "end"
code.blank_line
# Variable lookup helper - handles hash/array access, method calls, to_liquid, and drop context
code.line "__lookup__ = ->(obj, key) {"
code.indent do
code.line "return nil if obj.nil?"
code.line "# Set context on Drops BEFORE accessing their methods"
code.line "obj = obj.to_liquid if obj.respond_to?(:to_liquid)"
code.line "obj.context = __context__ if obj.respond_to?(:context=)"
code.line "# Now perform the lookup"
code.line "result = if obj.respond_to?(:[]) && (obj.respond_to?(:key?) && obj.key?(key) || obj.respond_to?(:fetch) && key.is_a?(Integer))"
code.indent do
code.line "obj[key]"
end
code.line "elsif obj.respond_to?(key)"
code.indent do
code.line "obj.send(key)"
end
code.line "else"
code.indent do
code.line "nil"
end
code.line "end"
code.line "# Convert result to liquid and set context for nested Drops"
code.line "result = result.to_liquid if result.respond_to?(:to_liquid)"
code.line "result.context = __context__ if result.respond_to?(:context=)"
code.line "result"
end
code.line "}"
code.blank_line
# Output helper that handles nil and arrays
code.line "def __output_value__(obj)"
code.indent do
code.line "case obj"
code.line "when NilClass then ''"
code.line "when Array then obj.map { |o| __output_value__(o) }.join"
code.line "else obj.to_s"
code.line "end"
end
code.line "end"
end
end
# Custom error for compilation issues
class CompileError < StandardError; end
end
end
-130
View File
@@ -1,130 +0,0 @@
# 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
@@ -1,19 +0,0 @@
# 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
-18
View File
@@ -1,18 +0,0 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% break %} tags
#
# Breaks out of a for loop
class BreakCompiler
def self.compile(_tag, _compiler, code)
# We use throw/catch in the for loop to handle break
# This allows break to work from nested blocks
code.line "throw :__loop__break__"
end
end
end
end
end
@@ -1,31 +0,0 @@
# 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
@@ -1,47 +0,0 @@
# 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
@@ -1,18 +0,0 @@
# 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
@@ -1,17 +0,0 @@
# frozen_string_literal: true
module Liquid
module Compile
module Tags
# Compiles {% continue %} tags
#
# Skips to the next iteration of a for loop
class ContinueCompiler
def self.compile(_tag, _compiler, code)
# We use throw/catch in the for loop to handle continue
code.line "throw :__loop__continue__"
end
end
end
end
end
-45
View File
@@ -1,45 +0,0 @@
# 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__ << __to_s__(#{var_expr})"
end
code.line "end"
# Increment counter
code.line "assigns[:__cycle__][#{key_var}] = #{cycle_var} + 1"
end
end
end
end
end
@@ -1,26 +0,0 @@
# 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
@@ -1,17 +0,0 @@
# 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
-127
View File
@@ -1,127 +0,0 @@
# frozen_string_literal: true
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 %}
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
code.line "#{coll_var} = #{collection_expr}"
code.line "#{coll_var} = #{coll_var}.to_a if #{coll_var}.is_a?(Range)"
# 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}.respond_to?(:empty?) && #{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
compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
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}.respond_to?(:slice) ? #{coll_var}.slice(__to_integer__(#{from_expr}), __to_integer__(#{limit_expr})) : #{coll_var}) || []"
else
code.line "#{coll_var} = (#{coll_var}.respond_to?(:drop) ? #{coll_var}.drop(__to_integer__(#{from_expr})) : #{coll_var}) || []"
end
end
def self.compile_loop(tag, var_name, coll_var, idx_var, len_var, for_block, compiler, code)
# Calculate length for forloop
code.line "#{len_var} = #{coll_var}.respond_to?(:length) ? #{coll_var}.length : 0"
code.line "#{idx_var} = 0"
# The loop itself - use catch/throw for break support across nested blocks
code.line "catch(:__loop__break__) do"
code.indent do
code.line "(#{coll_var}.respond_to?(:each) ? #{coll_var} : []).each do |__item__|"
code.indent do
# Wrap each iteration in a catch for continue support
code.line "catch(:__loop__continue__) do"
code.indent do
# Set the loop variable
code.line "assigns[#{var_name.inspect}] = __item__"
# Build the forloop object as a hash
code.line "assigns['forloop'] = {"
code.indent do
code.line "'name' => #{tag.instance_variable_get(:@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 "}"
# Compile the loop body
BlockBodyCompiler.compile(for_block, compiler, code)
end
code.line "end"
# Increment index (runs even after continue)
code.line "#{idx_var} += 1"
end
code.line "end"
end
code.line "end"
# Clean up
code.line "assigns.delete(#{var_name.inspect})"
code.line "assigns.delete('forloop')"
end
end
end
end
end
-34
View File
@@ -1,34 +0,0 @@
# 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
@@ -1,40 +0,0 @@
# 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
-128
View File
@@ -1,128 +0,0 @@
# 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)"
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic include (template name from variable)' if $VERBOSE"
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 runtime dynamic include method
code.line "if defined?(__include_dynamic__)"
code.indent do
code.line "__output__ << __include_dynamic__(#{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, assigns)"
end
code.line "else"
code.indent do
code.line "raise RuntimeError, 'Dynamic include requires __include_dynamic__ method: ' + #{name_expr}.inspect"
end
code.line "end"
end
end
end
end
end
@@ -1,26 +0,0 @@
# 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
@@ -1,19 +0,0 @@
# 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
-185
View File
@@ -1,185 +0,0 @@
# 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)"
code.line "$stderr.puts '* WARN: Liquid runtime file system access - dynamic render (template name from variable)' if $VERBOSE"
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 runtime dynamic render method
code.line "if defined?(__render_dynamic__)"
code.indent do
code.line "__output__ << __render_dynamic__(#{name_expr}, #{var_expr}, #{attrs_var}, #{alias_expr}, #{is_for_loop})"
end
code.line "else"
code.indent do
code.line "raise RuntimeError, 'Dynamic render requires __render_dynamic__ method: ' + #{name_expr}.inspect"
end
code.line "end"
end
end
end
end
end
@@ -1,117 +0,0 @@
# 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(__to_integer__(#{offset_expr}), __to_integer__(#{limit_expr})) || []"
else
code.line "#{coll_var} = #{coll_var}.drop(__to_integer__(#{offset_expr}))"
end
elsif limit
limit_expr = ExpressionCompiler.compile(limit, compiler)
code.line "#{coll_var} = #{coll_var}.first(__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
@@ -1,38 +0,0 @@
# 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
@@ -1,37 +0,0 @@
# 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__ << __output_value__(#{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
+4 -9
View File
@@ -24,9 +24,6 @@ module Liquid
else
false
end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end,
}
@@ -72,9 +69,9 @@ module Liquid
case condition.child_relation
when :or
break if Liquid::Utils.to_liquid_value(result)
break if result
when :and
break unless Liquid::Utils.to_liquid_value(result)
break unless result
else
break
end
@@ -162,10 +159,8 @@ 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
@@ -1,8 +0,0 @@
# frozen_string_literal: true
module Liquid
module Const
EMPTY_HASH = {}.freeze
EMPTY_ARRAY = [].freeze
end
end
+14 -28
View File
@@ -15,40 +15,31 @@ 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, :environment
attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters
# rubocop:disable Metrics/ParameterLists
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)
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)
end
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default)
@environment = environment
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {})
@environments = [environments]
@environments.flatten!
@static_environments = [static_environments].flatten(1).freeze
@scopes = [outer_scope || {}]
@registers = registers.is_a?(Registers) ? registers : Registers.new(registers)
@static_environments = [static_environments].flat_map(&:freeze).freeze
@scopes = [(outer_scope || {})]
@registers = registers
@errors = []
@partial = false
@strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits)
@resource_limits = resource_limits || ResourceLimits.new(Template.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] ||= environment.file_system
@registers.static[:template_factory] ||= Liquid::TemplateFactory.new
self.exception_renderer = environment.exception_renderer
self.exception_renderer = Template.default_exception_renderer
if rethrow_errors
self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA
end
@@ -65,7 +56,7 @@ module Liquid
end
def strainer
@strainer ||= @environment.create_strainer(self, @filters)
@strainer ||= StrainerFactory.create(self, @filters)
end
# Adds filters to this context.
@@ -147,10 +138,9 @@ module Liquid
check_overflow
self.class.build(
environment: @environment,
resource_limits: resource_limits,
static_environments: static_environments,
registers: Registers.new(registers),
registers: StaticRegisters.new(registers)
).tap do |subcontext|
subcontext.base_scope_depth = base_scope_depth + 1
subcontext.exception_renderer = exception_renderer
@@ -180,7 +170,7 @@ module Liquid
# Example:
# products == empty #=> products.empty?
def [](expression)
evaluate(Expression.parse(expression, @string_scanner))
evaluate(Expression.parse(expression))
end
def key?(key)
@@ -203,14 +193,10 @@ module Liquid
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
end
# update variable's context before invoking #to_liquid
variable = variable.to_liquid
variable.context = self if variable.respond_to?(:context=)
liquid_variable = variable.to_liquid
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
liquid_variable
variable
end
def lookup_and_evaluate(obj, key, raise_on_not_found: true)
-22
View File
@@ -1,22 +0,0 @@
# 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,10 +25,6 @@ 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
@@ -1,159 +0,0 @@
# 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
+15 -16
View File
@@ -40,20 +40,19 @@ 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)
TemplateEncodingError = 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)
end
+22 -101
View File
@@ -3,120 +3,41 @@
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' => '',
# in lax mode, minus sign can be a VariableLookup
# For simplicity and performace, we treat it like a literal
'-' => VariableLookup.parse("-", nil).freeze,
'empty' => ''
}.freeze
DOT = ".".ord
ZERO = "0".ord
NINE = "9".ord
DASH = "-".ord
INTEGERS_REGEX = /\A(-?\d+)\z/
FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/
# 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/
INTEGER_REGEX = /\A(-?\d+)\z/
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
class << self
def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
def self.parse(markup)
return nil unless markup
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
markup = markup.strip
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
end
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
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]
else
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
VariableLookup.parse(markup)
end
end
end
+5 -45
View File
@@ -1,11 +1,6 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type object
# @liquid_name forloop
# @liquid_summary
# Information about a parent [`for` loop](/docs/api/liquid/tags/for).
class ForloopDrop < Drop
def initialize(name, length, parentloop)
@name = name
@@ -14,68 +9,33 @@ module Liquid
@index = 0
end
# @liquid_public_docs
# @liquid_name length
# @liquid_summary
# The total number of iterations in the loop.
# @liquid_return [number]
attr_reader :length
attr_reader :length, :parentloop
# @liquid_public_docs
# @liquid_name parentloop
# @liquid_summary
# The parent `forloop` object.
# @liquid_description
# If the current `for` loop isn't nested inside another `for` loop, then `nil` is returned.
# @liquid_return [forloop]
attr_reader :parentloop
def name
Usage.increment('forloop_drop_name')
@name
end
attr_reader :name
# @liquid_public_docs
# @liquid_summary
# The 1-based index of the current iteration.
# @liquid_return [number]
def index
@index + 1
end
# @liquid_public_docs
# @liquid_summary
# The 0-based index of the current iteration.
# @liquid_return [number]
def index0
@index
end
# @liquid_public_docs
# @liquid_summary
# The 1-based index of the current iteration, in reverse order.
# @liquid_return [number]
def rindex
@length - @index
end
# @liquid_public_docs
# @liquid_summary
# The 0-based index of the current iteration, in reverse order.
# @liquid_return [number]
def rindex0
@length - @index - 1
end
# @liquid_public_docs
# @liquid_summary
# Returns `true` if the current iteration is the first. Returns `false` if not.
# @liquid_return [boolean]
def first
@index == 0
end
# @liquid_public_docs
# @liquid_summary
# Returns `true` if the current iteration is the last. Returns `false` if not.
# @liquid_return [boolean]
def last
@index == @length - 1
end
+43 -160
View File
@@ -1,179 +1,62 @@
# frozen_string_literal: true
require "strscan"
module Liquid
class Lexer
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 = /"[^\"]*"/
EOS = [:end_of_string].freeze
SPECIALS = {
'|' => :pipe,
'.' => :dot,
':' => :colon,
',' => :comma,
'[' => :open_square,
']' => :close_square,
'(' => :open_round,
')' => :close_round,
'?' => :question,
'-' => :dash,
}.freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
NUMBER_LITERAL = /-?\d+(\.\d+)?/
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 = /'[^\']*'/
DOUBLE_STRING_LITERAL = /"[^\"]*"/
NUMBER_LITERAL = /-?\d+(\.\d+)?/
DOTDOT = /\.\./
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
WHITESPACE_OR_NOTHING = /\s*/
SINGLE_COMPARISON_TOKENS = [].tap do |table|
table["<".ord] = COMPARISON_LESS_THAN
table[">".ord] = COMPARISON_GREATER_THAN
table.freeze
def initialize(input)
@ss = StringScanner.new(input)
end
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
def tokenize
@output = []
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
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(SINGLE_STRING_LITERAL))
[:string, t]
elsif (t = @ss.scan(DOUBLE_STRING_LITERAL))
[:string, t]
elsif (t = @ss.scan(NUMBER_LITERAL))
[:number, t]
elsif (t = @ss.scan(IDENTIFIER))
[:id, t]
elsif (t = @ss.scan(DOTDOT))
[:dotdot, t]
else
c = @ss.getch
if (s = SPECIALS[c])
[s, c]
else
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
raise SyntaxError, "Unexpected character #{c}"
end
end
# rubocop:enable Metrics/BlockNesting
output << EOS
rescue ::ArgumentError => e
if e.message == "invalid byte sequence in #{ss.string.encoding}"
raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}"
else
raise
end
@output << tok
end
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
@output << [:end_of_string]
end
end
end
+5 -9
View File
@@ -2,30 +2,26 @@
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"
if: "Syntax Error in tag 'if' - Valid syntax: if [expression]"
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"
unknown_tag: "Unknown tag '%{tag}'"
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"
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
unexpected_else: "%{block_name} tag does not expect 'else' tag"
unexpected_outer_tag: "Unexpected outer '%{tag}' tag"
unknown_tag: "Unknown tag '%{tag}'"
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}"
tag_never_closed: "'%{block_name}' tag was never closed"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
render: "Syntax error in tag 'render' - Template name must be a quoted string"
argument:
include: "Argument error in tag 'include' - Illegal template name"
disabled:
+6 -29
View File
@@ -3,27 +3,14 @@
module Liquid
class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode, :environment
attr_reader :partial, :warnings, :error_mode
def initialize(options = Const::EMPTY_HASH)
@environment = options.fetch(:environment, Environment.default)
def initialize(options = {})
@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
@@ -36,29 +23,19 @@ module Liquid
Liquid::BlockBody.new
end
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,
)
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)
end
def parse_expression(markup)
Expression.parse(markup, @string_scanner, @expression_cache)
Expression.parse(markup)
end
def partial=(value)
@partial = value
@options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || @environment.error_mode
@error_mode = @options[:error_mode] || Template.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) : Const::EMPTY_ARRAY
@node.respond_to?(:nodelist) ? Array(@node.nodelist) : []
end
end
end
+3 -3
View File
@@ -3,8 +3,8 @@
module Liquid
class Parser
def initialize(input)
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = Lexer.tokenize(ss)
l = Lexer.new(input)
@tokens = l.tokenize
@p = 0 # pointer to current location
end
@@ -53,7 +53,7 @@ module Liquid
str = consume
str << variable_lookups
when :open_square
str = consume.dup
str = consume
str << expression
str << consume(:close_square)
str << variable_lookups
+6 -15
View File
@@ -3,29 +3,20 @@
module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = context.registers[:cached_partials]
cache_key = "#{template_name}:#{parse_context.error_mode}"
cached = cached_partials[cache_key]
cached_partials = (context.registers[:cached_partials] ||= {})
cached = cached_partials[template_name]
return cached if cached
file_system = context.registers[:file_system]
file_system = (context.registers[:file_system] ||= Liquid::Template.file_system)
source = file_system.read_template_file(template_name)
parse_context.partial = true
template_factory = context.registers[:template_factory]
template_factory = (context.registers[:template_factory] ||= Liquid::TemplateFactory.new)
template = template_factory.for(template_name)
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
partial = template.parse(source, parse_context)
cached_partials[template_name] = partial
ensure
parse_context.partial = false
end
+4 -14
View File
@@ -2,23 +2,13 @@
module Liquid
class RangeLookup
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)
def self.parse(start_markup, end_markup)
start_obj = Expression.parse(start_markup)
end_obj = Expression.parse(end_markup)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj)
else
begin
start_obj.to_i..end_obj.to_i
rescue NoMethodError
invalid_expr = start_markup unless start_obj.respond_to?(:to_i)
invalid_expr ||= end_markup unless end_obj.respond_to?(:to_i)
if invalid_expr
raise Liquid::SyntaxError, "Invalid expression type '#{invalid_expr}' in range expression"
end
raise
end
start_obj.to_i..end_obj.to_i
end
end
+6
View File
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Liquid
class Register
end
end
File diff suppressed because it is too large Load Diff
@@ -1,35 +1,35 @@
# frozen_string_literal: true
module Liquid
class Registers
class StaticRegisters
attr_reader :static
def initialize(registers = {})
@static = registers.is_a?(Registers) ? registers.static : registers
@changes = {}
@static = registers.is_a?(StaticRegisters) ? registers.static : registers
@registers = {}
end
def []=(key, value)
@changes[key] = value
@registers[key] = value
end
def [](key)
if @changes.key?(key)
@changes[key]
if @registers.key?(key)
@registers[key]
else
@static[key]
end
end
def delete(key)
@changes.delete(key)
@registers.delete(key)
end
UNDEFINED = Object.new
def fetch(key, default = UNDEFINED, &block)
if @changes.key?(key)
@changes.fetch(key)
if @registers.key?(key)
@registers.fetch(key)
elsif default != UNDEFINED
if block_given?
@static.fetch(key, &block)
@@ -42,10 +42,7 @@ module Liquid
end
def key?(key)
@changes.key?(key) || @static.key?(key)
@registers.key?(key) || @static.key?(key)
end
end
# Alias for backwards compatibility
StaticRegisters = Registers
end
+37
View File
@@ -0,0 +1,37 @@
# 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
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
-4
View File
@@ -36,10 +36,6 @@ module Liquid
subclass.instance_variable_set(:@filter_methods, @filter_methods.dup)
end
def filter_method_names
filter_methods.map(&:to_s).to_a
end
private
def filter_methods
+1 -58
View File
@@ -1,11 +1,6 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type object
# @liquid_name tablerowloop
# @liquid_summary
# Information about a parent [`tablerow` loop](/docs/api/liquid/tags/tablerow).
class TablerowloopDrop < Drop
def initialize(length, cols)
@length = length
@@ -15,92 +10,40 @@ module Liquid
@index = 0
end
# @liquid_public_docs
# @liquid_summary
# The total number of iterations in the loop.
# @liquid_return [number]
attr_reader :length
attr_reader :length, :col, :row
# @liquid_public_docs
# @liquid_summary
# The 1-based index of the current column.
# @liquid_return [number]
attr_reader :col
# @liquid_public_docs
# @liquid_summary
# The 1-based index of current row.
# @liquid_return [number]
attr_reader :row
# @liquid_public_docs
# @liquid_summary
# The 1-based index of the current iteration.
# @liquid_return [number]
def index
@index + 1
end
# @liquid_public_docs
# @liquid_summary
# The 0-based index of the current iteration.
# @liquid_return [number]
def index0
@index
end
# @liquid_public_docs
# @liquid_summary
# The 0-based index of the current column.
# @liquid_return [number]
def col0
@col - 1
end
# @liquid_public_docs
# @liquid_summary
# The 1-based index of the current iteration, in reverse order.
# @liquid_return [number]
def rindex
@length - @index
end
# @liquid_public_docs
# @liquid_summary
# The 0-based index of the current iteration, in reverse order.
# @liquid_return [number]
def rindex0
@length - @index - 1
end
# @liquid_public_docs
# @liquid_summary
# Returns `true` if the current iteration is the first. Returns `false` if not.
# @liquid_return [boolean]
def first
@index == 0
end
# @liquid_public_docs
# @liquid_summary
# Returns `true` if the current iteration is the last. Returns `false` if not.
# @liquid_return [boolean]
def last
@index == @length - 1
end
# @liquid_public_docs
# @liquid_summary
# Returns `true` if the current column is the first in the row. Returns `false` if not.
# @liquid_return [boolean]
def col_first
@col == 1
end
# @liquid_public_docs
# @liquid_summary
# Returns `true` if the current column is the last in the row. Returns `false` if not.
# @liquid_return [boolean]
def col_last
@col == @cols
end
+3 -13
View File
@@ -1,8 +1,5 @@
# 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
@@ -17,18 +14,12 @@ module Liquid
end
def disable_tags(*tag_names)
tag_names += disabled_tags
define_singleton_method(:disabled_tags) { tag_names }
@disabled_tags ||= []
@disabled_tags.concat(tag_names)
prepend(Disabler)
end
private :new
protected
def disabled_tags
[]
end
end
def initialize(tag_name, markup, parse_context)
@@ -57,8 +48,7 @@ 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)
render_result = render(context)
output << render_result if render_result
output << render(context)
output
end
+8
View File
@@ -3,6 +3,14 @@
module Liquid
class Tag
module Disabler
module ClassMethods
attr_reader :disabled_tags
end
def self.prepended(base)
base.extend(ClassMethods)
end
def render_to_output_buffer(context, output)
context.with_disabled_tags(self.class.disabled_tags) do
super
-49
View File
@@ -1,49 +0,0 @@
# 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
+10 -12
View File
@@ -1,18 +1,14 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category variable
# @liquid_name assign
# @liquid_summary
# Creates a new variable.
# @liquid_description
# 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.
# @liquid_syntax_keyword value The value you want to assign to the variable.
# Assign sets a variable in your template.
#
# {% assign foo = 'monkey' %}
#
# You can then use the variable later in the page.
#
# {{ foo }}
#
class Assign < Tag
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
@@ -72,4 +68,6 @@ module Liquid
end
end
end
Template.register_tag('assign', Assign)
end
+2 -8
View File
@@ -10,14 +10,6 @@ module Liquid
# {% endif %}
# {% endfor %}
#
# @liquid_public_docs
# @liquid_type tag
# @liquid_category iteration
# @liquid_name break
# @liquid_summary
# Stops a [`for` loop](/docs/api/liquid/tags/for) from iterating.
# @liquid_syntax
# {% break %}
class Break < Tag
INTERRUPT = BreakInterrupt.new.freeze
@@ -26,4 +18,6 @@ module Liquid
output
end
end
Template.register_tag('break', Break)
end
+12 -13
View File
@@ -1,20 +1,17 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category variable
# @liquid_name capture
# @liquid_summary
# Creates a new variable with a string value.
# @liquid_description
# You can create complex strings with Liquid logic and variables.
# @liquid_syntax
# {% capture variable %}
# value
# Capture stores the result of a block into a variable without rendering it inplace.
#
# {% capture heading %}
# Monkeys!
# {% endcapture %}
# @liquid_syntax_keyword variable The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
# ...
# <h1>{{ heading }}</h1>
#
# Capture is useful for saving content for use later in your template, such as
# in a sidebar or footer.
#
class Capture < Block
Syntax = /(#{VariableSignature}+)/o
@@ -39,4 +36,6 @@ module Liquid
true
end
end
Template.register_tag('capture', Capture)
end
+3 -22
View File
@@ -1,27 +1,6 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category conditional
# @liquid_name case
# @liquid_summary
# Renders a specific expression depending on the value of a specific variable.
# @liquid_syntax
# {% case variable %}
# {% when first_value %}
# first_expression
# {% when second_value %}
# second_expression
# {% else %}
# third_expression
# {% endcase %}
# @liquid_syntax_keyword variable The name of the variable you want to base your case statement on.
# @liquid_syntax_keyword first_value A specific value to check for.
# @liquid_syntax_keyword second_value A specific value to check for.
# @liquid_syntax_keyword first_expression An expression to be rendered when the variable's value matches `first_value`.
# @liquid_syntax_keyword second_expression An expression to be rendered when the variable's value matches `second_value`.
# @liquid_syntax_keyword third_expression An expression to be rendered when the variable's value has no match.
class Case < Block
Syntax = /(#{QuotedFragment})/o
WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/om
@@ -77,7 +56,7 @@ module Liquid
end
result = Liquid::Utils.to_liquid_value(
block.evaluate(context),
block.evaluate(context)
)
if result
@@ -123,4 +102,6 @@ module Liquid
end
end
end
Template.register_tag('case', Case)
end
+12 -71
View File
@@ -1,19 +1,17 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category syntax
# @liquid_name comment
# @liquid_summary
# Prevents an expression from being rendered or output.
# @liquid_description
# Any text inside `comment` tags won't be output, and any Liquid code will be parsed, but not executed.
# @liquid_syntax
# @public_docs
# @type tag
# @category theme
# @title comment
# @summary
# Allows you to comment out parts of a Liquid file.
# Any text within the opening and closing `comment` blocks won't be output,
# and any Liquid code won't be executed.
# @syntax
# {% comment %}
# content
# statement
# {% endcomment %}
# @liquid_syntax_keyword content The content of the comment.
class Comment < Block
def render_to_output_buffer(_context, output)
output
@@ -25,64 +23,7 @@ module Liquid
def blank?
true
end
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
Template.register_tag('comment', Comment)
end
+11 -8
View File
@@ -1,14 +1,15 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category iteration
# @liquid_name continue
# @liquid_summary
# Causes a [`for` loop](/docs/api/liquid/tags/for) to skip to the next iteration.
# @liquid_syntax
# {% continue %}
# Continue tag to be used to break out of a for loop.
#
# == Basic Usage:
# {% for item in collection %}
# {% if item.condition %}
# {% continue %}
# {% endif %}
# {% endfor %}
#
class Continue < Tag
INTERRUPT = ContinueInterrupt.new.freeze
@@ -17,4 +18,6 @@ module Liquid
output
end
end
Template.register_tag('continue', Continue)
end
+14 -25
View File
@@ -1,19 +1,18 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @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](/docs/api/liquid/tags/for).
# @liquid_description
# The `cycle` tag must be used inside a `for` loop.
# Cycle is usually used within a loop to alternate between values, like colors or DOM classes.
#
# {% for item in items %}
# <div class="{% cycle 'red', 'green', 'blue' %}"> {{ item }} </div>
# {% end %}
#
# <div class="red"> Item one </div>
# <div class="green"> Item two </div>
# <div class="blue"> Item three </div>
# <div class="red"> Item four </div>
# <div class="green"> Item five</div>
#
# > Tip:
# > Use the `cycle` tag to output text in a predictable pattern. For example, to apply odd/even classes to rows in a table.
# @liquid_syntax
# {% cycle string, string, ... %}
class Cycle < Tag
SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
@@ -26,20 +25,14 @@ 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] ||= {}
@@ -68,13 +61,7 @@ module Liquid
def variables_from_string(markup)
markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o
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
Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil
end.compact
end
@@ -84,4 +71,6 @@ module Liquid
end
end
end
Template.register_tag('cycle', Cycle)
end
+22 -22
View File
@@ -1,38 +1,38 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category variable
# @liquid_name decrement
# @liquid_summary
# Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call.
# @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.
# decrement is used in a place where one needs to insert a counter
# into a template, and needs the counter to survive across
# multiple instantiations of the template.
# NOTE: decrement is a pre-decrement, --i,
# while increment is post: i++.
#
# 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 %}
# @liquid_syntax_keyword variable_name The name of the variable being decremented.
class Decrement < Tag
attr_reader :variable_name
# (To achieve the survival, the application must keep the context)
#
# if the variable does not exist, it is created with value 0.
# Hello: {% decrement variable %}
#
# gives you:
#
# Hello: -1
# Hello: -2
# Hello: -3
#
class Decrement < Tag
def initialize(tag_name, markup, options)
super
@variable_name = markup.strip
@variable = markup.strip
end
def render_to_output_buffer(context, output)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
value = context.environments.first[@variable] ||= 0
value -= 1
counter_environment[@variable_name] = value
context.environments.first[@variable] = value
output << value.to_s
output
end
end
Template.register_tag('decrement', Decrement)
end
-77
View File
@@ -1,77 +0,0 @@
# 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
+12 -17
View File
@@ -1,23 +1,16 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category syntax
# @liquid_name echo
# @liquid_summary
# 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](/docs/api/liquid/tags/liquid).
#
# > Tip:
# > You can use [filters](/docs/api/liquid/filters) on expressions inside `echo` tags.
# @liquid_syntax
# {% liquid
# echo expression
# %}
# @liquid_syntax_keyword expression The expression to be output.
# @public_docs
# @type tag
# @category theme
# @title echo
# @summary
# Outputs an expression, or Liquid object, in the rendered HTML.
# Works the same as wrapping an expression in double curly brace delimiters `{{ }}`.
# Works inside the [`liquid`](/api/liquid/tags/theme-tags#liquid) tag and supports [filters](/api/liquid/filters).
# @syntax
# {% echo 'string' %}
class Echo < Tag
attr_reader :variable
@@ -36,4 +29,6 @@ module Liquid
end
end
end
Template.register_tag('echo', Echo)
end
+49 -26
View File
@@ -1,29 +1,50 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category iteration
# @liquid_name for
# @liquid_summary
# 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](/docs/api/liquid/tags/paginate) to split the items over multiple pages.
# "For" iterates over an array or collection.
# Several useful variables are available to you within the loop.
#
# == Basic usage:
# {% for item in collection %}
# {{ forloop.index }}: {{ item.name }}
# {% endfor %}
#
# == Advanced usage:
# {% for item in collection %}
# <div {% if forloop.first %}class="first"{% endif %}>
# Item {{ forloop.index }}: {{ item.name }}
# </div>
# {% else %}
# There is nothing in the collection.
# {% endfor %}
#
# You can also define a limit and offset much like SQL. Remember
# that offset starts at 0 for the first item.
#
# {% for item in collection limit:5 offset:10 %}
# {{ item.name }}
# {% end %}
#
# To reverse the for loop simply use {% for item in collection reversed %} (note that the flag's spelling is different to the filter `reverse`)
#
# == Available variables:
#
# forloop.name:: 'item-collection'
# forloop.length:: Length of the loop
# forloop.index:: The current item's position in the collection;
# forloop.index starts at 1.
# This is helpful for non-programmers who start believe
# the first item in an array is 1, not 0.
# forloop.index0:: The current item's position in the collection
# where the first item is 0
# forloop.rindex:: Number of items remaining in the loop
# (length - index) where 1 is the last item.
# forloop.rindex0:: Number of items remaining in the loop
# where 0 is the last item.
# forloop.first:: Returns true if the item is the first item.
# forloop.last:: Returns true if the item is the last item.
# forloop.parentloop:: Provides access to the parent loop, if present.
#
# > Tip:
# > 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
# {% endfor %}
# @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render for each iteration.
# @liquid_optional_param limit [number] The number of iterations to perform.
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
# @liquid_optional_param reversed [untyped] Iterate in reverse order.
class For < Block
Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o
@@ -88,7 +109,7 @@ module Liquid
end
def strict_parse(markup)
p = @parse_context.new_parser(markup)
p = Parser.new(markup)
@variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
@@ -98,12 +119,11 @@ module Liquid
@name = "#{@variable_name}-#{collection_name}"
@reversed = p.id?('reversed')
while p.look(:comma) || p.look(:id)
p.consume?(:comma)
while p.look(:id) && p.look(:colon, 1)
unless (attribute = p.id?('limit') || p.id?('offset'))
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
end
p.consume(:colon)
p.consume
set_attribute(attribute, p.expression)
end
p.consume(:end_of_string)
@@ -178,6 +198,7 @@ module Liquid
case key
when 'offset'
@from = if expr == 'continue'
Usage.increment('for_offset_continue')
:continue
else
parse_expression(expr)
@@ -201,4 +222,6 @@ module Liquid
end
end
end
Template.register_tag('for', For)
end
+14 -14
View File
@@ -1,18 +1,16 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category conditional
# @liquid_name if
# @liquid_summary
# Renders an expression if a specific condition is `true`.
# @liquid_syntax
# {% if condition %}
# expression
# If is the conditional block
#
# {% if user.admin %}
# Admin user!
# {% else %}
# Not admin user
# {% endif %}
# @liquid_syntax_keyword condition The condition to evaluate.
# @liquid_syntax_keyword expression The expression to render if the condition is met.
#
# There are {% if count < 5 %} less {% else %} more {% endif %} items than you need.
#
class If < Block
Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o
ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o
@@ -53,7 +51,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 +100,7 @@ module Liquid
end
def strict_parse(markup)
p = @parse_context.new_parser(markup)
p = Parser.new(markup)
condition = parse_binary_comparisons(p)
p.consume(:end_of_string)
condition
@@ -111,7 +109,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,4 +133,6 @@ module Liquid
end
end
end
Template.register_tag('if', If)
end
+2
View File
@@ -14,4 +14,6 @@ module Liquid
output
end
end
Template.register_tag('ifchanged', Ifchanged)
end
+19 -21
View File
@@ -1,22 +1,20 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category theme
# @liquid_name include
# @liquid_summary
# Renders a [snippet](/themes/architecture/snippets).
# @liquid_description
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
# snippet.
# @liquid_syntax
# {% include 'filename' %}
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
# @liquid_deprecated
# Deprecated because the way that variables are handled reduces performance and makes code harder to both read and maintain.
# Include allows templates to relate with other templates
#
# Simply include another template:
#
# {% include 'product' %}
#
# Include a template with a local variable:
#
# {% include 'product' with products[0] %}
#
# Include a template for a collection:
#
# {% include 'product' for products %}
#
# The `include` tag has been replaced by [`render`](/docs/api/liquid/tags/render).
class Include < Tag
prepend Tag::Disableable
@@ -52,12 +50,12 @@ module Liquid
def render_to_output_buffer(context, output)
template_name = context.evaluate(@template_name_expr)
raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name.is_a?(String)
raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name
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,11 +68,9 @@ module Liquid
old_template_name = context.template_name
old_partial = context.partial
begin
context.template_name = partial.name
context.partial = true
context.template_name = template_name
context.partial = true
context.stack do
@attributes.each do |key, value|
context[key] = context.evaluate(value)
@@ -110,4 +106,6 @@ module Liquid
end
end
end
Template.register_tag('include', Include)
end
+19 -22
View File
@@ -1,38 +1,35 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category variable
# @liquid_name increment
# @liquid_summary
# Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call.
# @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.
# increment is used in a place where one needs to insert a counter
# into a template, and needs the counter to survive across
# multiple instantiations of the template.
# (To achieve the survival, the application must keep the context)
#
# if the variable does not exist, it is created with value 0.
#
# Hello: {% increment variable %}
#
# gives you:
#
# Hello: 0
# Hello: 1
# Hello: 2
#
# 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 %}
# @liquid_syntax_keyword variable_name The name of the variable being incremented.
class Increment < Tag
attr_reader :variable_name
def initialize(tag_name, markup, options)
super
@variable_name = markup.strip
@variable = markup.strip
end
def render_to_output_buffer(context, output)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
counter_environment[@variable_name] = value + 1
value = context.environments.first[@variable] ||= 0
context.environments.first[@variable] = value + 1
output << value.to_s
output
end
end
Template.register_tag('increment', Increment)
end
-28
View File
@@ -1,28 +0,0 @@
# frozen_string_literal: true
module Liquid
class InlineComment < Tag
def initialize(tag_name, markup, options)
super
# Semantically, a comment should only ignore everything after it on the line.
# Currently, this implementation doesn't support mixing a comment with another tag
# but we need to reserve future support for this and prevent the introduction
# of inline comments from being backward incompatible change.
#
# As such, we're forcing users to put a # symbol on every line otherwise this
# tag will throw an error.
if markup.match?(/\n\s*[^#\s]/)
raise SyntaxError, options[:locale].t("errors.syntax.inline_comment_invalid")
end
end
def render_to_output_buffer(_context, output)
output
end
def blank?
true
end
end
end
+12 -11
View File
@@ -1,19 +1,19 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category syntax
# @liquid_name raw
# @liquid_summary
# Outputs any Liquid code as text instead of rendering it.
# @liquid_syntax
# @public_docs
# @type tag
# @category theme
# @title raw
# @summary
# Allows you to output Liquid code on a page without it being parsed.
# @syntax
# {% raw %}
# expression
# liquid
# {% endraw %}
# @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
@@ -24,8 +24,7 @@ module Liquid
def parse(tokens)
@body = +''
while (token = tokens.shift)
if token =~ BlockBody::FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
if token =~ FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2)
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
return
end
@@ -56,4 +55,6 @@ module Liquid
end
end
end
Template.register_tag('raw', Raw)
end
+30 -35
View File
@@ -1,37 +1,35 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @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).
# @liquid_description
# Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside
# of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet)
# to pass outside variables to snippets.
# @public_docs
# @type tag
# @category theme
# @title render
# @summary
# Renders a snippet from the **snippets** folder of a theme, or [code for an app block](/themes/architecture/sections/section-schema#render-app-blocks).
# You don't need to write the file's `.liquid` extension.
# @description
# When a snippet is rendered, the code inside it doesn't automatically have access to the variables assigned
# using [variable tags](/api/liquid/tags/variable-tags) within the snippet's parent template.
# Similarly, variables assigned within the snippet can't be accessed by the code outside of the snippet.
# This encapsulation increases performance and helps make theme code easier to understand and maintain.
#
# 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](/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).
# You can't use an [`include`](/api/liquid/tags/theme-tags#include) tag inside of a snippet rendered using the `render` tag.
#
# 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](/docs/api/liquid/tags/include)
# > inside the snippet.
# @liquid_syntax
# {% render 'filename' %}
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
# > Tip:
# > This tag replaces the deprecated [`include`](/api/liquid/tags/theme-tags#include) tag.
# @syntax
# {% render reference %}
# @optional_param with [string] Pass an object to use in the snippet. Use with the `as` parameter.
# @optional_param for [string] Render the snippet once for each value of an enumerable object. Use with the `as` parameter. When using the `for` parameter, the [`forloop`](/api/liquid/objects/for-loops) object is accessible within the snippet.
# @optional_param as [string] The variable that the object referenced by a `with` or `for` parameter represents within the snippet.
class Render < Tag
FOR = 'for'
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
disable_tags "include"
attr_reader :template_name_expr, :variable_name_expr, :attributes, :alias_name
attr_reader :template_name_expr, :attributes
def initialize(tag_name, markup, options)
super
@@ -45,7 +43,7 @@ module Liquid
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@for = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@@ -53,30 +51,26 @@ module Liquid
end
end
def for_loop?
@is_for_loop
end
def render_to_output_buffer(context, output)
render_tag(context, output)
end
def render_tag(context, output)
# The expression should be a String literal, which parses to a String object
template_name = @template_name_expr
raise ::ArgumentError unless template_name.is_a?(String)
# Though we evaluate this here we will only ever parse it as a string literal.
template_name = context.evaluate(@template_name_expr)
raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name
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 = partial.name
inner_context.template_name = template_name
inner_context.partial = true
inner_context['forloop'] = forloop if forloop
@@ -89,7 +83,7 @@ module Liquid
}
variable = @variable_name_expr ? context.evaluate(@variable_name_expr) : nil
if @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count)
if @for && variable.respond_to?(:each) && variable.respond_to?(:count)
forloop = Liquid::ForloopDrop.new(template_name, variable.count, nil)
variable.each { |var| render_partial_func.call(var, forloop) }
else
@@ -103,9 +97,10 @@ module Liquid
def children
[
@node.template_name_expr,
@node.variable_name_expr,
] + @node.attributes.values
end
end
end
Template.register_tag('render', Render)
end
+5 -39
View File
@@ -1,28 +1,6 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category iteration
# @liquid_name tablerow
# @liquid_summary
# Generates HTML table rows for every item in an array.
# @liquid_description
# The `tablerow` tag must be wrapped in HTML `<table>` and `</table>` tags.
#
# > Tip:
# > 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
# {% endtablerow %}
# @liquid_syntax_keyword variable The current item in the array.
# @liquid_syntax_keyword array The array to iterate over.
# @liquid_syntax_keyword expression The expression to render.
# @liquid_optional_param cols [number] The number of columns that the table should have.
# @liquid_optional_param limit [number] The number of iterations to perform.
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
class TableRow < Block
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
@@ -45,13 +23,13 @@ module Liquid
def render_to_output_buffer(context, output)
(collection = context.evaluate(@collection_name)) || (return '')
from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0
to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil
from = @attributes.key?('offset') ? context.evaluate(@attributes['offset']).to_i : 0
to = @attributes.key?('limit') ? from + context.evaluate(@attributes['limit']).to_i : nil
collection = Utils.slice_collection(collection, from, to)
length = collection.length
cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length
cols = context.evaluate(@attributes['cols']).to_i
output << "<tr class=\"row1\">\n"
context.stack do
@@ -65,12 +43,6 @@ 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
@@ -88,13 +60,7 @@ module Liquid
super + @node.attributes.values + [@node.collection_name]
end
end
private
def to_integer(value)
value.to_i
rescue NoMethodError
raise Liquid::ArgumentError, "invalid integer"
end
end
Template.register_tag('tablerow', TableRow)
end
+8 -17
View File
@@ -3,27 +3,16 @@
require_relative 'if'
module Liquid
# @liquid_public_docs
# @liquid_type tag
# @liquid_category conditional
# @liquid_name unless
# @liquid_summary
# Renders an expression unless a specific condition is `true`.
# @liquid_description
# > Tip:
# > 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
# {% endunless %}
# @liquid_syntax_keyword condition The condition to evaluate.
# @liquid_syntax_keyword expression The expression to render unless the condition is met.
# Unless is a conditional just like 'if' but works on the inverse logic.
#
# {% unless x < 0 %} x is greater than zero {% endunless %}
#
class Unless < If
def render_to_output_buffer(context, output)
# 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 +22,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,4 +33,6 @@ module Liquid
output
end
end
Template.register_tag('unless', Unless)
end
+64 -123
View File
@@ -15,9 +15,45 @@ module Liquid
# template.render('user_name' => 'bob')
#
class Template
attr_accessor :root, :name
attr_accessor :root
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
@@ -25,83 +61,52 @@ 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.
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
attr_accessor :error_mode
Template.error_mode = :lax
attr_accessor :default_exception_renderer
Template.default_exception_renderer = lambda do |exception|
exception
end
def error_mode
Environment.default.error_mode
end
attr_accessor :file_system
Template.file_system = BlankFileSystem.new
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
attr_accessor :tags
Template.tags = TagRegistry.new
private :tags=
def register_tag(name, klass)
Deprecations.warn("Template.register_tag", "Environment#register_tag")
Environment.default.register_tag(name, klass)
tags[name.to_s] = 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)
Deprecations.warn("Template.register_filter", "Environment#register_filter")
Environment.default.register_filter(mod)
StrainerFactory.add_global_filter(mod)
end
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
attr_accessor :default_resource_limits
Template.default_resource_limits = {}
private :default_resource_limits=
# 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 = {})
environment = options[:environment] || Environment.default
new(environment: environment).parse(source, options)
new.parse(source, options)
end
end
def initialize(environment: Environment.default)
@environment = environment
def initialize
@rethrow_errors = false
@resource_limits = ResourceLimits.new(environment.default_resource_limits)
@resource_limits = ResourceLimits.new(Template.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
@@ -151,25 +156,26 @@ module Liquid
c
when Liquid::Drop
drop = args.shift
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
when Hash
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits)
when nil
Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits)
else
raise ArgumentError, "Expected Hash or Liquid::Context as parameter"
end
output = nil
context_register = context.registers.is_a?(StaticRegisters) ? context.registers.static : context.registers
case args.last
when Hash
options = args.pop
output = options[:output] if options[:output]
static_registers = context.registers.static
options[:registers]&.each do |key, register|
static_registers[key] = register
context_register[key] = register
end
apply_options_to_context(context, options)
@@ -184,8 +190,6 @@ module Liquid
@profiler = context.profiler = Liquid::Profiler.new
end
context.template_name ||= name
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
@@ -205,63 +209,6 @@ module Liquid
render(context, output: output)
end
# Compile the template to pure Ruby code.
#
# Returns a string containing Ruby code that can be eval'd to create
# a proc/lambda. The proc takes an assigns hash and returns the rendered
# output string.
#
# This provides a way to convert Liquid templates to standalone Ruby code
# that can be executed without the Liquid library at runtime.
#
# == Example
#
# 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!"
#
# == Options
#
# * <tt>:strict_variables</tt> - Raise on undefined variables (default: false)
# * <tt>:include_filters</tt> - Include helper methods for filters (default: true)
#
# == Advantages of Compiled Code
#
# * No Context object overhead
# * No filter invocation overhead (direct method calls)
# * No resource limits tracking
# * No stack-based scoping (uses Ruby's native scoping)
# * No profiling hooks
# * Direct string concatenation
#
# == Limitations
#
# * {% render %} and {% include %} tags require runtime support
# * Custom tags need explicit compiler implementations
# * Custom filters must be available at runtime
#
# Returns a CompiledTemplate object with the Ruby code and any external tags
# that need to be passed to the generated lambda.
#
# Usage:
# compiled = template.compile_to_ruby
# result = compiled.call({ "name" => "World" }) # Handles external tags automatically
#
# Or manually:
# proc = eval(compiled.code)
# result = proc.call(assigns, compiled.external_tags)
#
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)
@@ -272,14 +219,8 @@ module Liquid
@options = options
@profiling = profiling
@line_numbers = options[:line_numbers] || @profiling
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 = options.is_a?(ParseContext) ? options : ParseContext.new(options)
@warnings = parse_context.warnings
parse_context
end
+11 -133
View File
@@ -1,45 +1,18 @@
# frozen_string_literal: true
require "strscan"
module Liquid
class Tokenizer
attr_reader :line_number, :for_liquid_tag
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)
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)
@for_liquid_tag = for_liquid_tag
@source = source.to_s.to_str
@offset = 0
@tokens = []
if @source
@ss = string_scanner
@ss.string = @source
tokenize
end
@tokens = tokenize
end
def shift
token = @tokens[@offset]
return unless token
@offset += 1
(token = @tokens.shift) || return
if @line_number
@line_number += @for_liquid_tag ? 1 : token.count("\n")
@@ -51,111 +24,16 @@ module Liquid
private
def tokenize
if @for_liquid_tag
@tokens = @source.split("\n")
else
@tokens << shift_normal until @ss.eos?
end
return [] if @source.empty?
@source = nil
@ss = nil
end
return @source.split("\n") if @for_liquid_tag
def shift_normal
token = next_token
tokens = @source.split(TemplateParser)
return unless token
# removes the rogue empty element at the beginning of the array
tokens.shift if tokens[0]&.empty?
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)
tokens
end
end
end
-96
View File
@@ -89,101 +89,5 @@ 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
+12 -18
View File
@@ -47,7 +47,7 @@ module Liquid
name_markup = Regexp.last_match(1)
filter_markup = Regexp.last_match(2)
@name = parse_context.parse_expression(name_markup)
@name = Expression.parse(name_markup)
if filter_markup =~ FilterMarkupRegex
filters = Regexp.last_match(1).scan(FilterParser)
filters.each do |f|
@@ -61,14 +61,14 @@ module Liquid
def strict_parse(markup)
@filters = []
p = @parse_context.new_parser(markup)
p = Parser.new(markup)
return if p.look(:end_of_string)
@name = parse_context.parse_expression(p.expression)
@name = Expression.parse(p.expression)
while p.consume?(:pipe)
filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
filterargs = p.consume?(:colon) ? parse_filterargs(p) : []
@filters << parse_filter_expressions(filtername, filterargs)
end
p.consume(:end_of_string)
@@ -95,21 +95,15 @@ module Liquid
def render_to_output_buffer(context, output)
obj = render(context)
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
if obj.is_a?(Array)
output << obj.join
elsif obj.nil?
else
output << Liquid::Utils.to_s(obj)
output << obj.to_s
end
output
end
def disabled?(_context)
@@ -128,9 +122,9 @@ module Liquid
unparsed_args.each do |a|
if (matches = a.match(JustTagAttributes))
keyword_args ||= {}
keyword_args[matches[1]] = parse_context.parse_expression(matches[2])
keyword_args[matches[1]] = Expression.parse(matches[2])
else
filter_args << parse_context.parse_expression(a)
filter_args << Expression.parse(a)
end
end
result = [filter_name, filter_args]
+10 -21
View File
@@ -2,24 +2,21 @@
module Liquid
class VariableLookup
COMMAND_METHODS = ['size', 'first', 'last'].freeze
SQUARE_BRACKETED = /\A\[(.*)\]\z/m
COMMAND_METHODS = ['size', 'first', 'last'].freeze
attr_reader :name, :lookups
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
new(markup, string_scanner, cache)
def self.parse(markup)
new(markup)
end
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
def initialize(markup)
lookups = markup.scan(VariableParser)
name = lookups.shift
if name&.start_with?('[') && name&.end_with?(']')
name = Expression.parse(
name[1..-2],
string_scanner,
cache,
)
if name =~ SQUARE_BRACKETED
name = Expression.parse(Regexp.last_match(1))
end
@name = name
@@ -28,22 +25,14 @@ module Liquid
@lookups.each_index do |i|
lookup = lookups[i]
if lookup&.start_with?('[') && lookup&.end_with?(']')
lookups[i] = Expression.parse(
lookup[1..-2],
string_scanner,
cache,
)
if lookup =~ SQUARE_BRACKETED
lookups[i] = Expression.parse(Regexp.last_match(1))
elsif COMMAND_METHODS.include?(lookup)
@command_flags |= 1 << i
end
end
end
def lookup_command?(lookup_index)
@command_flags & (1 << lookup_index) != 0
end
def evaluate(context)
name = context.evaluate(@name)
object = context.find_variable(name)
@@ -67,7 +56,7 @@ module Liquid
# Some special cases. If the part wasn't in square brackets and
# no key with the same name was found we interpret following calls
# as commands and call them on the current object
elsif lookup_command?(i) && object.respond_to?(key)
elsif @command_flags & (1 << i) != 0 && object.respond_to?(key)
object = object.send(key).to_liquid
# No key was present with the desired value and it wasn't one of the directly supported
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.8.6"
VERSION = "5.3.0.alpha"
end
+4 -6
View File
@@ -13,24 +13,22 @@ 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 = "https://shopify.github.io/liquid/"
s.homepage = "http://www.liquidmarkup.org"
s.license = "MIT"
# s.description = "A secure, non-evaling end user template engine with aesthetic markup."
s.required_ruby_version = ">= 3.0.0"
s.required_ruby_version = ">= 2.5.0"
s.required_rubygems_version = ">= 1.3.7"
s.metadata['allowed_push_host'] = 'https://rubygems.org'
s.files = Dir.glob("{lib}/**/*") + %w(LICENSE README.md)
s.test_files = Dir.glob("{test}/**/*")
s.files = Dir.glob("{lib}/**/*") + %w(LICENSE README.md)
s.extra_rdoc_files = ["History.md", "README.md"]
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
+6 -11
View File
@@ -3,23 +3,18 @@
require 'benchmark/ips'
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
Benchmark.ips do |x|
x.time = 20
x.warmup = 10
x.time = 10
x.warmup = 5
puts
puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)."
puts
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"
x.report("parse:") { profiler.compile }
x.report("render:") { profiler.render }
x.report("parse & render:") { profiler.run }
end
-222
View File
@@ -1,222 +0,0 @@
# 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

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