mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-14 08:20:39 -07:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820b238288 | ||
|
|
5e459b884a |
@@ -1,6 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Liquid
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
BUNDLE_JOBS: 4
|
||||
@@ -9,65 +9,28 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
entry:
|
||||
- { ruby: 3.3, allowed-failure: false } # minimum supported
|
||||
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
|
||||
- { ruby: 4.0, allowed-failure: false } # latest stable
|
||||
- {
|
||||
ruby: 4.0,
|
||||
allowed-failure: false,
|
||||
rubyopt: "--enable-frozen-string-literal",
|
||||
}
|
||||
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
|
||||
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
|
||||
- { ruby: truffleruby, allowed-failure: false }
|
||||
|
||||
# Head can have failures due to being in development
|
||||
- { ruby: head, allowed-failure: true }
|
||||
- {
|
||||
ruby: head,
|
||||
allowed-failure: true,
|
||||
rubyopt: "--enable-frozen-string-literal",
|
||||
}
|
||||
- { ruby: head, allowed-failure: true, rubyopt: "--yjit" }
|
||||
- { ruby: head, allowed-failure: true, rubyopt: "--zjit" }
|
||||
name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }}
|
||||
- { ruby: 3.0, allowed-failure: false } # minimum supported
|
||||
- { ruby: 3.2, allowed-failure: false }
|
||||
- { ruby: 3.3, allowed-failure: false } # latest
|
||||
- { ruby: ruby-head, allowed-failure: false }
|
||||
name: Test Ruby ${{ matrix.entry.ruby }}
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: ${{ matrix.entry.ruby }}
|
||||
bundler-cache: true
|
||||
bundler: latest
|
||||
- run: bundle exec rake
|
||||
continue-on-error: ${{ matrix.entry.allowed-failure }}
|
||||
env:
|
||||
RUBYOPT: ${{ matrix.entry.rubyopt }}
|
||||
|
||||
spec:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUNDLE_WITH: spec
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
bundler: latest
|
||||
- name: Run liquid-spec for all adapters
|
||||
run: |
|
||||
for adapter in spec/*.rb; do
|
||||
echo "=== Running $adapter ==="
|
||||
bundle exec liquid-spec run "$adapter" --no-max-failures
|
||||
done
|
||||
|
||||
memory_profile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
- run: bundle exec rake memory_profile:run
|
||||
|
||||
+1
-10
@@ -174,16 +174,7 @@ Style/WordArray:
|
||||
|
||||
# Offense count: 117
|
||||
# This cop supports safe auto-correction (--auto-correct).
|
||||
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, AllowCopDirectives, AllowedPatterns.
|
||||
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns.
|
||||
# URISchemes: http, https
|
||||
Layout/LineLength:
|
||||
Max: 260
|
||||
|
||||
Naming/PredicatePrefix:
|
||||
Enabled: false
|
||||
|
||||
# Offense count: 1
|
||||
# This is intentional - early return from begin/rescue in assignment context
|
||||
Lint/NoReturnInBeginEndBlocks:
|
||||
Exclude:
|
||||
- 'lib/liquid/standardfilters.rb'
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.1
|
||||
3.3.4
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -13,25 +13,18 @@ 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 'benchmark'
|
||||
gem 'rubocop', '~> 1.82.0'
|
||||
gem 'rubocop-shopify', '~> 2.18.0', require: false
|
||||
gem 'rubocop', '~> 1.61.0'
|
||||
gem 'rubocop-shopify', '~> 2.12.0', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
end
|
||||
|
||||
group :spec do
|
||||
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main'
|
||||
gem 'activesupport', require: false
|
||||
platform :mri, :truffleruby do
|
||||
gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'main'
|
||||
end
|
||||
end
|
||||
|
||||
-82
@@ -1,87 +1,5 @@
|
||||
# Liquid Change Log
|
||||
|
||||
## 5.11.0
|
||||
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
|
||||
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
|
||||
|
||||
## 5.10.0
|
||||
* Introduce support for Inline Snippets [Julia Boutin]
|
||||
|
||||
## 5.9.0
|
||||
* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro]
|
||||
|
||||
## 5.8.7
|
||||
* Expose body content in the `Doc` tag [James Meng]
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -91,7 +91,7 @@ Liquid::Template.parse(<<~LIQUID, environment: email_environment)
|
||||
LIQUID
|
||||
```
|
||||
|
||||
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
|
||||
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.
|
||||
|
||||
### Error Modes
|
||||
|
||||
@@ -99,14 +99,14 @@ Setting the error mode of Liquid lets you specify how strictly you want your tem
|
||||
Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make
|
||||
it very hard to debug and can lead to unexpected behaviour.
|
||||
|
||||
Liquid also comes with different parsers that can be used when editing templates to give better error messages
|
||||
Liquid also comes with a stricter parser that can be used when editing templates to give better error messages
|
||||
when templates are invalid. You can enable this new parser like this:
|
||||
|
||||
```ruby
|
||||
Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags
|
||||
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
|
||||
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::Environment.default.error_mode = :strict
|
||||
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
|
||||
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
|
||||
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
|
||||
```
|
||||
|
||||
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
|
||||
|
||||
@@ -33,7 +33,7 @@ task :rubocop do
|
||||
end
|
||||
end
|
||||
|
||||
desc('runs test suite with lax, strict, and strict2 parsers')
|
||||
desc('runs test suite with both strict and lax parsers')
|
||||
task :test do
|
||||
ENV['LIQUID_PARSER_MODE'] = 'lax'
|
||||
Rake::Task['base_test'].invoke
|
||||
@@ -42,11 +42,9 @@ task :test do
|
||||
Rake::Task['base_test'].reenable
|
||||
Rake::Task['base_test'].invoke
|
||||
|
||||
ENV['LIQUID_PARSER_MODE'] = 'strict2'
|
||||
Rake::Task['base_test'].reenable
|
||||
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
|
||||
@@ -54,10 +52,6 @@ task :test do
|
||||
ENV['LIQUID_PARSER_MODE'] = 'strict'
|
||||
Rake::Task['integration_test'].reenable
|
||||
Rake::Task['integration_test'].invoke
|
||||
|
||||
ENV['LIQUID_PARSER_MODE'] = 'strict2'
|
||||
Rake::Task['integration_test'].reenable
|
||||
Rake::Task['integration_test'].invoke
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,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
|
||||
|
||||
@@ -88,35 +82,11 @@ namespace :benchmark do
|
||||
ruby "./performance/benchmark.rb strict"
|
||||
end
|
||||
|
||||
desc "Run the liquid benchmark with strict2 parsing"
|
||||
task :strict2 do
|
||||
ruby "./performance/benchmark.rb strict2"
|
||||
end
|
||||
|
||||
desc "Run the liquid benchmark with lax, strict, and strict2 parsing"
|
||||
task run: [:lax, :strict, :strict2]
|
||||
|
||||
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
|
||||
task :unit do
|
||||
Dir["./performance/unit/*_benchmark.rb"].each do |file|
|
||||
puts "🧪 Running #{file}"
|
||||
ruby file
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -148,9 +118,3 @@ end
|
||||
task :console do
|
||||
exec 'irb -I lib -r liquid'
|
||||
end
|
||||
|
||||
desc('run liquid-spec suite across all adapters')
|
||||
task :spec do
|
||||
adapters = Dir['./spec/*.rb'].join(',')
|
||||
sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid"
|
||||
end
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Autoresearch Ideas
|
||||
|
||||
## Dead Ends (tried and failed)
|
||||
|
||||
- **Tag name interning** (skip+byte dispatch): saves 878 allocs but verification loop overhead kills speed
|
||||
- **String dedup (-@)** for filter names: no alloc savings, creates temp strings anyway
|
||||
- **Split-based tokenizer**: 2.5x faster C-level split but can't handle {{ followed by %} nesting
|
||||
- **Streaming tokenizer**: needs own StringScanner (+alloc), per-shift overhead worse than eager array
|
||||
- **Merge simple_lookup? into initialize**: logic overhead offsets saved index call
|
||||
- **Cursor for filter scanning**: cursor.reset overhead worse than inline byte loops
|
||||
- **Direct strainer call**: YJIT already inlines context.invoke_single well
|
||||
- **TruthyCondition subclass**: YJIT polymorphism at evaluate call site hurts more than 115 saved allocs
|
||||
- **Index loop for filters**: YJIT optimizes each+destructure MUCH better than manual filter[0]/filter[1]
|
||||
|
||||
## Key Insights
|
||||
|
||||
- YJIT monomorphism > allocation reduction at this scale
|
||||
- C-level StringScanner.scan/skip > Ruby-level byte loops (already applied)
|
||||
- String#split is 2.5x faster than manual tokenization, but Liquid's grammar is too complex for regex
|
||||
- 74% of total CPU time is GC — alloc reduction is the highest-leverage optimization
|
||||
- But YJIT-deoptimization from polymorphism costs more than the GC savings
|
||||
|
||||
## Remaining Ideas
|
||||
|
||||
- **Tokenizer: use String#index + byteslice instead of StringScanner**: avoid the StringScanner overhead entirely for the simple case of finding {%/{{ delimiters
|
||||
- **Pre-freeze all Condition operator lambdas**: reduce alloc in Condition initialization
|
||||
- **Avoid `@blocks = []` in If with single-element optimization**: use `@block` ivar for single condition, only create array for elsif
|
||||
- **Reduce ForloopDrop allocation**: reuse ForloopDrop objects across iterations or use a lighter-weight object
|
||||
- **VariableLookup: single-segment optimization**: for "product.title" (1 lookup), use an ivar instead of 1-element Array
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# Autoresearch: Liquid Parse+Render Performance
|
||||
|
||||
## Objective
|
||||
Optimize the Shopify Liquid template engine's parse and render performance.
|
||||
The workload is the ThemeRunner benchmark which parses and renders real Shopify
|
||||
theme templates (dropify, ripen, tribble, vogue) with realistic data from
|
||||
`performance/shopify/database.rb`. We measure parse time, render time, and
|
||||
object allocations. The optimization target is combined parse+render time (µs).
|
||||
|
||||
## How to Run
|
||||
Run `./auto/autoresearch.sh` — it runs unit tests, liquid-spec conformance,
|
||||
then the performance benchmark, outputting metrics in parseable format.
|
||||
|
||||
## Metrics
|
||||
- **Primary (optimization target)**: `combined_µs` (µs, lower is better) — sum of parse + render time
|
||||
- **Secondary (tradeoff monitoring)**:
|
||||
- `parse_µs` — time to parse all theme templates (Liquid::Template#parse)
|
||||
- `render_µs` — time to render all pre-compiled templates
|
||||
- `allocations` — total object allocations for one parse+render cycle
|
||||
Parse dominates (~70-75% of combined). Allocations correlate with GC pressure.
|
||||
|
||||
## Files in Scope
|
||||
- `lib/liquid/*.rb` — core Liquid library (parser, lexer, context, expression, etc.)
|
||||
- `lib/liquid/tags/*.rb` — tag implementations (for, if, assign, etc.)
|
||||
- `performance/bench_quick.rb` — benchmark script
|
||||
|
||||
## Off Limits
|
||||
- `test/` — tests must continue to pass unchanged
|
||||
- `performance/tests/` — benchmark templates, do not modify
|
||||
- `performance/shopify/` — benchmark data/filters, do not modify
|
||||
|
||||
## Constraints
|
||||
- All unit tests must pass (`bundle exec rake base_test`)
|
||||
- liquid-spec failures must not increase beyond 2 (pre-existing UTF-8 edge cases)
|
||||
- No new gem dependencies
|
||||
- Semantic correctness must be preserved — templates must render identical output
|
||||
- **Security**: Liquid runs untrusted user code. See Strategic Direction for details.
|
||||
|
||||
## Strategic Direction
|
||||
The long-term goal is to converge toward a **single-pass, forward-only parsing
|
||||
architecture** using one shared StringScanner instance. The current system has
|
||||
multiple redundant passes: Tokenizer → BlockBody → Lexer → Parser → Expression
|
||||
→ VariableLookup, each re-scanning portions of the source. A unified scanner
|
||||
approach would:
|
||||
|
||||
1. **One StringScanner** flows through the entire parse — no intermediate token
|
||||
arrays, no re-lexing filter chains, no string reconstruction in Parser#expression.
|
||||
2. **Emit a lightweight IL or normalized AST** during the single forward pass,
|
||||
decoupling strictness checking from the hot parse path. The LiquidIL project
|
||||
(`~/src/tries/2026-01-05-liquid-il`) demonstrated this: a recursive-descent
|
||||
parser emitting IL directly achieved significant speedups.
|
||||
3. **Minimal backtracking** — the scanner advances forward, byte-checking as it
|
||||
goes. liquid-c (`~/src/tries/2026-01-16-Shopify-liquid-c`) showed that a
|
||||
C-level cursor-based tokenizer eliminates most allocation overhead.
|
||||
|
||||
Current fast-path optimizations (byte-level tag/variable/for/if parsing) are
|
||||
steps toward this goal. Each one replaces a regex+MatchData pattern with
|
||||
forward-only byte scanning. The remaining Lexer→Parser path for filter args
|
||||
is the next target for elimination.
|
||||
|
||||
**Security note**: Liquid executes untrusted user templates. All parsing must
|
||||
use explicit byte-range checks. Never use eval, send on user input, dynamic
|
||||
method dispatch, const_get, or any pattern that lets template authors escape
|
||||
the sandbox.
|
||||
|
||||
## Baseline
|
||||
- **Commit**: 4ea835a (original, before any optimizations)
|
||||
- **combined_µs**: 7,374
|
||||
- **parse_µs**: 5,928
|
||||
- **render_µs**: 1,446
|
||||
- **allocations**: 62,620
|
||||
|
||||
## Progress Log
|
||||
- 3329b09: Replace FullToken regex with manual byte parsing → combined 7,262 (-1.5%)
|
||||
- 97e6893: Replace VariableParser regex with manual byte scanner → combined 6,945 (-5.8%), allocs 58,009
|
||||
- 2b78e4b: getbyte instead of string indexing in whitespace_handler/create_variable → allocs 51,477
|
||||
- d291e63: Lexer equal? for frozen arrays, \s+ whitespace skip → combined ~6,331
|
||||
- d79b9fa: Avoid strip alloc in Expression.parse, byteslice for strings → allocs 49,151
|
||||
- fa41224: Short-circuit parse_number with first-byte check → allocs 48,240
|
||||
- c1113ad: Fast-path String in render_obj_to_output → combined ~6,071
|
||||
- 25f9224: Fast-path simple variable parsing (skip Lexer/Parser) → combined ~5,860, allocs 45,202
|
||||
- 3939d74: Replace SIMPLE_VARIABLE regex with byte scanner → combined ~5,717, allocs 42,763
|
||||
- fe7a2f5: Fast-path simple if conditions → combined ~5,444, allocs 41,490
|
||||
- cfa0dfe: Replace For tag Syntax regex with manual byte parser → combined ~4,974, allocs 39,847
|
||||
- 8a92a4e: Unified fast-path Variable: parse name directly, only lex filter chain → combined ~5,060, allocs 40,520
|
||||
- 58d2514: parse_tag_token returns [tag_name, markup, newlines] → combined ~4,815, allocs 37,355
|
||||
- db43492: Hoist write score check out of render loop → render ~1,345
|
||||
- 17daac9: Extend fast-path to quoted string literal variables → all 1,197 variables fast-pathed
|
||||
- 9fd7cec: Split filter parsing: no-arg filters scanned directly, Lexer only for args → combined ~4,595, allocs 35,159
|
||||
- e5933fc: Avoid array alloc in parse_tag_token via class ivars → allocs 34,281
|
||||
- 2e207e6: Replace WhitespaceOrNothing regex with byte-level blank_string? → combined ~4,800
|
||||
- 526af22: invoke_single fast path for no-arg filter invocation → allocs 32,621
|
||||
- 76ae8f1: find_variable top-scope fast path → combined ~4,740
|
||||
- 4cda1a5: slice_collection: skip copy for full Array → allocs 32,004
|
||||
- 79840b1: Replace SIMPLE_CONDITION regex with manual byte parser → combined ~4,663, allocs 31,465
|
||||
- 69430e9: Replace INTEGER_REGEX/FLOAT_REGEX with byte-level parse_number → allocs 31,129
|
||||
- 405e3dc: Frozen EMPTY_ARRAY/EMPTY_HASH for Context @filters/@disabled_tags → allocs 31,009
|
||||
- b90d7f0: Avoid unnecessary array wrapping for Context environments → allocs 30,709
|
||||
- 3799d4c: Lazy seen={} hash in Utils.to_s/inspect → allocs 30,169
|
||||
- 0b07487: Fast-path VariableLookup: skip scan_variable for simple identifiers → allocs 29,711
|
||||
- 9de1527: Introduce Cursor class for centralized byte-level scanning
|
||||
- dd4a100: Remove dead parse_tag_token/SIMPLE_CONDITION (now in Cursor)
|
||||
- cdc3438: For tag: migrate lax_parse to Cursor with zero-alloc scanning → allocs 29,620
|
||||
|
||||
## Current Best
|
||||
- **combined_µs**: ~3,400 (-54% from original 7,374 baseline)
|
||||
- **parse_µs**: ~2,300
|
||||
- **render_µs**: ~1,100
|
||||
- **allocations**: 24,882 (-60% from original 62,620 baseline)
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Autoresearch benchmark runner for Liquid performance optimization
|
||||
# Runs: unit tests → performance benchmark (3 runs, takes best)
|
||||
# Outputs METRIC lines for the agent to parse
|
||||
# Exit code 0 = all good, non-zero = broken
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# ── Step 1: Unit tests (fast gate) ──────────────────────────────────
|
||||
echo "=== Unit Tests ==="
|
||||
TEST_OUT=$(bundle exec rake base_test 2>&1)
|
||||
TEST_RESULT=$(echo "$TEST_OUT" | tail -1)
|
||||
if echo "$TEST_OUT" | grep -q 'failures\|errors' && ! echo "$TEST_RESULT" | grep -q '0 failures, 0 errors'; then
|
||||
echo "$TEST_OUT" | grep -E 'Failure|Error|failures|errors' | head -20
|
||||
echo "FATAL: unit tests failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "$TEST_RESULT"
|
||||
|
||||
# ── Step 2: Performance benchmark (3 runs, take best) ──────────────
|
||||
echo ""
|
||||
echo "=== Performance Benchmark (3 runs) ==="
|
||||
BEST_COMBINED=999999
|
||||
BEST_PARSE=0
|
||||
BEST_RENDER=0
|
||||
BEST_ALLOC=0
|
||||
|
||||
for i in 1 2 3; do
|
||||
OUT=$(bundle exec ruby performance/bench_quick.rb 2>&1)
|
||||
P=$(echo "$OUT" | grep '^parse_us=' | cut -d= -f2)
|
||||
R=$(echo "$OUT" | grep '^render_us=' | cut -d= -f2)
|
||||
C=$(echo "$OUT" | grep '^combined_us=' | cut -d= -f2)
|
||||
A=$(echo "$OUT" | grep '^allocations=' | cut -d= -f2)
|
||||
echo " run $i: combined=${C}µs (parse=${P} render=${R}) allocs=${A}"
|
||||
if [ "$C" -lt "$BEST_COMBINED" ]; then
|
||||
BEST_COMBINED=$C
|
||||
BEST_PARSE=$P
|
||||
BEST_RENDER=$R
|
||||
BEST_ALLOC=$A
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "METRIC combined_us=$BEST_COMBINED"
|
||||
echo "METRIC parse_us=$BEST_PARSE"
|
||||
echo "METRIC render_us=$BEST_RENDER"
|
||||
echo "METRIC allocations=$BEST_ALLOC"
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-research benchmark script for Liquid
|
||||
# Runs: unit tests → liquid-spec → performance benchmark
|
||||
# Outputs machine-readable metrics on success
|
||||
# Exit code 0 = all good, non-zero = broken
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# ── Step 1: Unit tests (fast gate) ──────────────────────────────────
|
||||
echo "=== Unit Tests ==="
|
||||
if ! bundle exec rake base_test 2>&1; then
|
||||
echo "FATAL: unit tests failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Step 2: liquid-spec (correctness gate) ──────────────────────────
|
||||
echo ""
|
||||
echo "=== Liquid Spec ==="
|
||||
SPEC_OUTPUT=$(bundle exec liquid-spec run spec/ruby_liquid.rb 2>&1 || true)
|
||||
echo "$SPEC_OUTPUT" | tail -3
|
||||
|
||||
# Extract failure count from "Total: N passed, N failed, N errors" line
|
||||
# Allow known pre-existing failures (≤2)
|
||||
TOTAL_LINE=$(echo "$SPEC_OUTPUT" | grep "^Total:" || echo "Total: 0 passed, 0 failed, 0 errors")
|
||||
FAILURES=$(echo "$TOTAL_LINE" | sed -n 's/.*\([0-9][0-9]*\) failed.*/\1/p')
|
||||
ERRORS=$(echo "$TOTAL_LINE" | sed -n 's/.*\([0-9][0-9]*\) error.*/\1/p')
|
||||
FAILURES=${FAILURES:-0}
|
||||
ERRORS=${ERRORS:-0}
|
||||
TOTAL_BAD=$((FAILURES + ERRORS))
|
||||
|
||||
if [ "$TOTAL_BAD" -gt 2 ]; then
|
||||
echo "FATAL: liquid-spec has $FAILURES failures and $ERRORS errors (threshold: 2)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Step 3: Performance benchmark ──────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Performance Benchmark ==="
|
||||
bundle exec ruby performance/bench_quick.rb 2>&1
|
||||
@@ -1,30 +0,0 @@
|
||||
{"type":"config","name":"Liquid parse+render performance (tenderlove-inspired)","metricName":"combined_µs","metricUnit":"µs","bestDirection":"lower"}
|
||||
{"run":1,"commit":"c09e722","metric":3818,"metrics":{"parse_µs":2722,"render_µs":1096,"allocations":24881},"status":"keep","description":"Baseline: 3,818µs combined, 24,881 allocs","timestamp":1773348490227}
|
||||
{"run":2,"commit":"c09e722","metric":4063,"metrics":{"parse_µs":2901,"render_µs":1162,"allocations":24003},"status":"discard","description":"Tag name interning via skip+byte dispatch: saves 878 allocs but verification loop slower than scan","timestamp":1773348738557,"segment":0}
|
||||
{"run":3,"commit":"c09e722","metric":3881,"metrics":{"parse_µs":2720,"render_µs":1161,"allocations":24881},"status":"discard","description":"String dedup (-@) for filter names: no alloc savings, no speed benefit","timestamp":1773348781481,"segment":0}
|
||||
{"run":4,"commit":"c09e722","metric":3970,"metrics":{"parse_µs":2829,"render_µs":1141,"allocations":24881},"status":"discard","description":"Streaming tokenizer: needs own StringScanner (+1 alloc), per-shift overhead worse than saved array","timestamp":1773348883093,"segment":0}
|
||||
{"run":5,"commit":"c09e722","metric":0,"metrics":{"parse_µs":0,"render_µs":0,"allocations":0},"status":"crash","description":"REVERTED: split-based tokenizer — regex can't handle unclosed tags inside raw blocks","timestamp":1773349089230,"segment":0}
|
||||
{"run":6,"commit":"c09e722","metric":0,"metrics":{"parse_µs":0,"render_µs":0,"allocations":0},"status":"crash","description":"REVERTED: split regex tokenizer v2 — can't handle {{ followed by %} (variable-becomes-tag nesting)","timestamp":1773349248313,"segment":0}
|
||||
{"run":7,"commit":"c09e722","metric":3861,"metrics":{"parse_µs":2744,"render_µs":1117,"allocations":24881},"status":"discard","description":"Merge simple_lookup? dot position into initialize — logic overhead offsets saved index call","timestamp":1773349376707,"segment":0}
|
||||
{"run":8,"commit":"c09e722","metric":4048,"metrics":{"parse_µs":2929,"render_µs":1119,"allocations":24881},"status":"discard","description":"Use Cursor regex for filter name scanning — cursor.reset + method dispatch overhead worse than inline bytes","timestamp":1773349447172,"segment":0}
|
||||
{"run":9,"commit":"c09e722","metric":3872,"metrics":{"parse_µs":2744,"render_µs":1128,"allocations":24881},"status":"discard","description":"Direct strainer call in Variable#render — YJIT already inlines context.invoke_single well","timestamp":1773349497593,"segment":0}
|
||||
{"run":10,"commit":"c09e722","metric":3839,"metrics":{"parse_µs":2732,"render_µs":1107,"allocations":24879},"status":"discard","description":"Array#[] fast path for slice_collection with limit/offset — only 2 alloc savings, not meaningful","timestamp":1773349555348,"segment":0}
|
||||
{"run":11,"commit":"c09e722","metric":3889,"metrics":{"parse_µs":2770,"render_µs":1119,"allocations":24766},"status":"discard","description":"TruthyCondition for simple if checks: -115 allocs but YJIT polymorphism at evaluate call site hurts speed","timestamp":1773349649377,"segment":0}
|
||||
{"run":12,"commit":"c09e722","metric":4150,"metrics":{"parse_µs":2769,"render_µs":1381,"allocations":24881},"status":"discard","description":"Index loop for filters: YJIT optimizes each+destructure better than manual indexing","timestamp":1773349699285,"segment":0}
|
||||
{"run":13,"commit":"b7ae55f","metric":3556,"metrics":{"parse_µs":2388,"render_µs":1168,"allocations":24882},"status":"keep","description":"Replace StringScanner tokenizer with String#byteindex — 12% faster parse, no regex overhead for delimiter finding","timestamp":1773349875890,"segment":0}
|
||||
{"run":14,"commit":"e25f2f1","metric":3464,"metrics":{"parse_µs":2335,"render_µs":1129,"allocations":24882},"status":"keep","description":"Confirmation run: byteindex tokenizer consistently 3,400-3,600µs","timestamp":1773349889465,"segment":0}
|
||||
{"run":15,"commit":"b37fa98","metric":3490,"metrics":{"parse_µs":2331,"render_µs":1159,"allocations":24882},"status":"keep","description":"Clean up tokenizer: remove unused StringScanner setup and regex constants","timestamp":1773349928672,"segment":0}
|
||||
{"run":16,"commit":"b37fa98","metric":3638,"metrics":{"parse_µs":2460,"render_µs":1178,"allocations":24882},"status":"discard","description":"Single-char byteindex for %} search: Ruby loop overhead worse for nearby targets","timestamp":1773349985509,"segment":0}
|
||||
{"run":17,"commit":"b37fa98","metric":3553,"metrics":{"parse_µs":2431,"render_µs":1122,"allocations":25256},"status":"discard","description":"Regex simple_variable_markup: MatchData creates 374 extra allocs, offsetting speed gain","timestamp":1773350066627,"segment":0}
|
||||
{"run":18,"commit":"b37fa98","metric":3629,"metrics":{"parse_µs":2455,"render_µs":1174,"allocations":25002},"status":"discard","description":"String.new(capacity: 4096) for output buffer: allocates more objects, not fewer","timestamp":1773350101852,"segment":0}
|
||||
{"run":19,"commit":"f6baeae","metric":3350,"metrics":{"parse_µs":2212,"render_µs":1138,"allocations":24882},"status":"keep","description":"parse_tag_token without StringScanner: pure byte ops avoid reset(token) overhead, -12% combined","timestamp":1773350230252,"segment":0}
|
||||
{"run":20,"commit":"f6baead","metric":0,"metrics":{"parse_µs":0,"render_µs":0,"allocations":0},"status":"crash","description":"REVERTED: regex ultra-fast path for Variable — name pattern too broad, matches invalid trailing dots","timestamp":1773350472859,"segment":0}
|
||||
{"run":21,"commit":"ae9a2e2","metric":3314,"metrics":{"parse_µs":2203,"render_µs":1111,"allocations":24882},"status":"keep","description":"Clean confirmation run: 3,314µs (-55% from main), stable","timestamp":1773350544354,"segment":0}
|
||||
{"run":22,"commit":"ae9a2e2","metric":3497,"metrics":{"parse_µs":2336,"render_µs":1161,"allocations":24882},"status":"discard","description":"Regex fast path for no-filter variables: include? + match? overhead exceeds byte scan savings","timestamp":1773350641375,"segment":0}
|
||||
{"run":23,"commit":"ca327b0","metric":3445,"metrics":{"parse_µs":2284,"render_µs":1161,"allocations":24647},"status":"keep","description":"Condition#evaluate: skip loop block for simple conditions (no child_relation) — saves 235 allocs","timestamp":1773350691752,"segment":0}
|
||||
{"run":24,"commit":"99454a9","metric":3489,"metrics":{"parse_µs":2353,"render_µs":1136,"allocations":24647},"status":"keep","description":"Replace simple_lookup? byte scan with match? regex — 8x faster per call, cleaner code","timestamp":1773350837721,"segment":0}
|
||||
{"run":25,"commit":"99454a9","metric":3797,"metrics":{"parse_µs":2636,"render_µs":1161,"allocations":29627},"status":"discard","description":"Regex name extraction in try_fast_parse: MatchData creates 5K extra allocs, much worse","timestamp":1773351048938,"segment":0}
|
||||
{"run":26,"commit":"db348e0","metric":3459,"metrics":{"parse_µs":2318,"render_µs":1141,"allocations":24647},"status":"keep","description":"Inline to_liquid_value in If render — avoids one method dispatch per condition evaluation","timestamp":1773351080001,"segment":0}
|
||||
{"run":27,"commit":"b195d09","metric":3496,"metrics":{"parse_µs":2356,"render_µs":1140,"allocations":24530},"status":"keep","description":"Replace @blocks.each with while loop in If render — avoids block proc allocation per render","timestamp":1773351101134,"segment":0}
|
||||
{"run":28,"commit":"b195d09","metric":3648,"metrics":{"parse_µs":2457,"render_µs":1191,"allocations":24530},"status":"discard","description":"While loop in For render: YJIT optimizes each well for hot loops with many iterations","timestamp":1773351142275,"segment":0}
|
||||
{"run":29,"commit":"b195d09","metric":3966,"metrics":{"parse_µs":2641,"render_µs":1325,"allocations":24060},"status":"discard","description":"While loop for environment search: -470 allocs but YJIT deopt makes render 16% slower","timestamp":1773351193863,"segment":0}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/setup'
|
||||
require 'liquid'
|
||||
|
||||
class VirtualFileSystem
|
||||
def initialize
|
||||
snippet_1 = <<~LIQUID
|
||||
<h1>
|
||||
{{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!
|
||||
</h1>
|
||||
LIQUID
|
||||
snippet_2 = <<~LIQUID
|
||||
{%- for i in (1..5) -%}
|
||||
> {{ i }}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
@templates = {
|
||||
'snippet-1' => snippet_1,
|
||||
'snippet-2' => snippet_2,
|
||||
}
|
||||
end
|
||||
|
||||
def read_template_file(key)
|
||||
@templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'")
|
||||
end
|
||||
end
|
||||
|
||||
def source
|
||||
File.read(ARGV[0])
|
||||
rescue StandardError
|
||||
'Usage: bin/render example/server/templates/index.liquid'
|
||||
end
|
||||
|
||||
def assigns
|
||||
{
|
||||
'date' => Time.now,
|
||||
}
|
||||
end
|
||||
|
||||
puts Liquid::Template
|
||||
.parse(source, error_mode: :strict2)
|
||||
.tap { |t| t.registers[:file_system] = VirtualFileSystem.new }
|
||||
.render(assigns)
|
||||
+1
-6
@@ -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 = ','
|
||||
@@ -46,14 +44,11 @@ module Liquid
|
||||
VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o
|
||||
|
||||
RAISE_EXCEPTION_LAMBDA = ->(_e) { raise }
|
||||
HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte)
|
||||
end
|
||||
|
||||
require "liquid/version"
|
||||
require "liquid/deprecations"
|
||||
require "liquid/const"
|
||||
require 'liquid/byte_tables'
|
||||
require 'liquid/cursor'
|
||||
require 'liquid/standardfilters'
|
||||
require 'liquid/file_system'
|
||||
require 'liquid/parser_switching'
|
||||
@@ -73,6 +68,7 @@ require 'liquid/extensions'
|
||||
require 'liquid/errors'
|
||||
require 'liquid/interrupts'
|
||||
require 'liquid/strainer_template'
|
||||
require 'liquid/expression'
|
||||
require 'liquid/context'
|
||||
require 'liquid/tag'
|
||||
require 'liquid/block_body'
|
||||
@@ -81,7 +77,6 @@ require 'liquid/variable'
|
||||
require 'liquid/variable_lookup'
|
||||
require 'liquid/range_lookup'
|
||||
require 'liquid/resource_limits'
|
||||
require 'liquid/expression'
|
||||
require 'liquid/template'
|
||||
require 'liquid/condition'
|
||||
require 'liquid/utils'
|
||||
|
||||
+1
-4
@@ -60,11 +60,8 @@ module Liquid
|
||||
@tag_name
|
||||
end
|
||||
|
||||
# Cache block delimiters per tag name to avoid repeated string allocation
|
||||
BLOCK_DELIMITER_CACHE = Hash.new { |h, k| h[k] = "end#{k}".freeze }
|
||||
|
||||
def block_delimiter
|
||||
@block_delimiter ||= BLOCK_DELIMITER_CACHE[block_name]
|
||||
@block_delimiter ||= "end#{block_name}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
+67
-107
@@ -1,5 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'English'
|
||||
|
||||
module Liquid
|
||||
class BlockBody
|
||||
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
|
||||
@@ -36,7 +38,7 @@ module Liquid
|
||||
|
||||
private def parse_for_liquid_tag(tokenizer, parse_context)
|
||||
while (token = tokenizer.shift)
|
||||
unless token.empty? || BlockBody.blank_string?(token)
|
||||
unless token.empty? || token.match?(WhitespaceOrNothing)
|
||||
unless token =~ LiquidTagToken
|
||||
# line isn't empty but didn't match tag syntax, yield and let the
|
||||
# caller raise a syntax error
|
||||
@@ -51,7 +53,8 @@ module Liquid
|
||||
end
|
||||
|
||||
unless (tag = parse_context.environment.tag_for_name(tag_name))
|
||||
# end parsing if we reach an unknown tag; let the caller determine how to proceed
|
||||
# end parsing if we reach an unknown tag and let the caller decide
|
||||
# determine how to proceed
|
||||
return yield tag_name, markup
|
||||
end
|
||||
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
|
||||
@@ -121,38 +124,48 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
def self.blank_string?(str)
|
||||
str.match?(WhitespaceOrNothing)
|
||||
end
|
||||
|
||||
private def parse_for_document(tokenizer, parse_context, &block)
|
||||
while (token = tokenizer.shift)
|
||||
next if token.empty?
|
||||
|
||||
first_byte = token.getbyte(0)
|
||||
if first_byte == Cursor::LCURLY
|
||||
second_byte = token.getbyte(1)
|
||||
if second_byte == Cursor::PCT
|
||||
# handle_tag_token returns:
|
||||
# nil — tag parsed normally, continue (update line number)
|
||||
# :next — 'liquid' inline tag; skip line number update
|
||||
# :unknown — end tag or unknown tag; yield to caller and return
|
||||
# :invalid — malformed tag token; delegate to handle_invalid_tag_token
|
||||
result = handle_tag_token(token, parse_context, tokenizer)
|
||||
next unless result # nil: normal
|
||||
next if result == :next # :next: 'liquid'
|
||||
return yield(@_unknown_tag_name, parse_context.cursor.tag_markup) if result == :unknown
|
||||
return handle_invalid_tag_token(token, parse_context, &block) # :invalid
|
||||
elsif second_byte == Cursor::LCURLY
|
||||
whitespace_handler(token, parse_context)
|
||||
@nodelist << create_variable(token, parse_context)
|
||||
@blank = false
|
||||
else
|
||||
# Fallback: text token starting with '{'
|
||||
append_text_token(token, parse_context)
|
||||
case
|
||||
when token.start_with?(TAGSTART)
|
||||
whitespace_handler(token, parse_context)
|
||||
unless token =~ FullToken
|
||||
return handle_invalid_tag_token(token, parse_context, &block)
|
||||
end
|
||||
tag_name = Regexp.last_match(2)
|
||||
markup = Regexp.last_match(4)
|
||||
|
||||
if parse_context.line_number
|
||||
# newlines inside the tag should increase the line number,
|
||||
# particularly important for multiline {% liquid %} tags
|
||||
parse_context.line_number += Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n")
|
||||
end
|
||||
|
||||
if tag_name == 'liquid'
|
||||
parse_liquid_tag(markup, parse_context)
|
||||
next
|
||||
end
|
||||
|
||||
unless (tag = parse_context.environment.tag_for_name(tag_name))
|
||||
# end parsing if we reach an unknown tag and let the caller decide
|
||||
# determine how to proceed
|
||||
return yield tag_name, markup
|
||||
end
|
||||
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
|
||||
@blank &&= new_tag.blank?
|
||||
@nodelist << new_tag
|
||||
when token.start_with?(VARSTART)
|
||||
whitespace_handler(token, parse_context)
|
||||
@nodelist << create_variable(token, parse_context)
|
||||
@blank = false
|
||||
else
|
||||
append_text_token(token, parse_context)
|
||||
if parse_context.trim_whitespace
|
||||
token.lstrip!
|
||||
end
|
||||
parse_context.trim_whitespace = false
|
||||
@nodelist << token
|
||||
@blank &&= token.match?(WhitespaceOrNothing)
|
||||
end
|
||||
parse_context.line_number = tokenizer.line_number
|
||||
end
|
||||
@@ -160,54 +173,8 @@ module Liquid
|
||||
yield nil, nil
|
||||
end
|
||||
|
||||
# Handles a {%...%} tag token. Does not receive the outer block — callers handle
|
||||
# yield/block passing themselves, keeping the Proc off the hot path.
|
||||
# Returns:
|
||||
# nil — tag parsed, caller continues the loop
|
||||
# :next — 'liquid' inline tag; caller skips line number update
|
||||
# :unknown — unknown/end tag; @_unknown_tag_name holds the tag name;
|
||||
# markup is in parse_context.cursor.tag_markup
|
||||
# :invalid — malformed token; caller delegates to handle_invalid_tag_token
|
||||
private def handle_tag_token(token, parse_context, tokenizer)
|
||||
whitespace_handler(token, parse_context)
|
||||
cursor = parse_context.cursor
|
||||
tag_name = cursor.parse_tag_token(token)
|
||||
return :invalid unless tag_name
|
||||
|
||||
markup = cursor.tag_markup
|
||||
if parse_context.line_number
|
||||
newlines = cursor.tag_newlines
|
||||
parse_context.line_number += newlines if newlines > 0
|
||||
end
|
||||
|
||||
if tag_name == 'liquid'
|
||||
parse_liquid_tag(markup, parse_context)
|
||||
return :next
|
||||
end
|
||||
|
||||
tag = parse_context.environment.tag_for_name(tag_name)
|
||||
unless tag
|
||||
# end parsing if we reach an unknown tag; let the caller determine how to proceed
|
||||
@_unknown_tag_name = tag_name
|
||||
return :unknown
|
||||
end
|
||||
|
||||
new_tag = tag.parse(tag_name, markup, tokenizer, parse_context)
|
||||
@blank &&= new_tag.blank?
|
||||
@nodelist << new_tag
|
||||
nil
|
||||
end
|
||||
|
||||
def append_text_token(token, parse_context)
|
||||
token.lstrip! if parse_context.trim_whitespace
|
||||
parse_context.trim_whitespace = false
|
||||
@nodelist << token
|
||||
@blank &&= BlockBody.blank_string?(token)
|
||||
end
|
||||
private :append_text_token
|
||||
|
||||
private def whitespace_handler(token, parse_context)
|
||||
if token.getbyte(2) == Cursor::DASH
|
||||
def whitespace_handler(token, parse_context)
|
||||
if token[2] == WhitespaceControl
|
||||
previous_token = @nodelist.last
|
||||
if previous_token.is_a?(String)
|
||||
first_byte = previous_token.getbyte(0)
|
||||
@@ -217,7 +184,7 @@ module Liquid
|
||||
end
|
||||
end
|
||||
end
|
||||
parse_context.trim_whitespace = (token.getbyte(token.bytesize - 3) == Cursor::DASH)
|
||||
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
|
||||
end
|
||||
|
||||
def blank?
|
||||
@@ -249,35 +216,24 @@ module Liquid
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
freeze
|
||||
freeze unless frozen?
|
||||
|
||||
resource_limits = context.resource_limits
|
||||
resource_limits.increment_render_score(@nodelist.length)
|
||||
context.resource_limits.increment_render_score(@nodelist.length)
|
||||
|
||||
# Hot render loop — split on check_write so the common case (no resource
|
||||
# limits) pays zero branch cost per node.
|
||||
idx = 0
|
||||
if resource_limits.render_length_limit || resource_limits.last_capture_length
|
||||
while (node = @nodelist[idx])
|
||||
if node.instance_of?(String)
|
||||
output << node
|
||||
else
|
||||
render_node(context, output, node)
|
||||
break if context.interrupt?
|
||||
end
|
||||
idx += 1
|
||||
resource_limits.increment_write_score(output)
|
||||
end
|
||||
else
|
||||
while (node = @nodelist[idx])
|
||||
if node.instance_of?(String)
|
||||
output << node
|
||||
else
|
||||
render_node(context, output, node)
|
||||
break if context.interrupt?
|
||||
end
|
||||
idx += 1
|
||||
while (node = @nodelist[idx])
|
||||
if node.instance_of?(String)
|
||||
output << node
|
||||
else
|
||||
render_node(context, output, node)
|
||||
# If we get an Interrupt that means the block must stop processing. An
|
||||
# Interrupt is any command that stops block execution such as {% break %}
|
||||
# or {% continue %}. These tags may also occur through Block or Include tags.
|
||||
break if context.interrupt? # might have happened in a for-block
|
||||
end
|
||||
idx += 1
|
||||
|
||||
context.resource_limits.increment_write_score(output)
|
||||
end
|
||||
|
||||
output
|
||||
@@ -285,15 +241,19 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
# Indirection allows subclasses to intercept per-node rendering.
|
||||
def render_node(context, output, node)
|
||||
BlockBody.render_node(context, output, node)
|
||||
end
|
||||
|
||||
def create_variable(token, parse_context)
|
||||
len = token.bytesize
|
||||
if len >= 4 && token.getbyte(len - 1) == Cursor::RCURLY && token.getbyte(len - 2) == Cursor::RCURLY
|
||||
markup = parse_context.cursor.parse_variable_token(token)
|
||||
if token.end_with?("}}")
|
||||
i = 2
|
||||
i = 3 if token[i] == "-"
|
||||
parse_end = token.length - 3
|
||||
parse_end -= 1 if token[parse_end] == "-"
|
||||
markup_end = parse_end - i + 1
|
||||
markup = markup_end <= 0 ? "" : token.slice(i, markup_end)
|
||||
|
||||
return Variable.new(markup, parse_context)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
# Pre-computed 256-entry boolean lookup tables for byte classification.
|
||||
# Built once at load time; used as TABLE[byte] — a single array index
|
||||
# instead of 3-5 comparison operators per check.
|
||||
#
|
||||
# Performance: neutral to slightly faster vs. chained comparisons.
|
||||
# Readability: replaces expressions like
|
||||
# (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || b == 95
|
||||
# with the intent-revealing
|
||||
# ByteTables::IDENT_START[b]
|
||||
module ByteTables
|
||||
# [a-zA-Z_] — valid first byte of an identifier
|
||||
IDENT_START = Array.new(256, false).tap do |t|
|
||||
(97..122).each { |b| t[b] = true } # a-z
|
||||
(65..90).each { |b| t[b] = true } # A-Z
|
||||
t[95] = true # _
|
||||
end.freeze
|
||||
|
||||
# [a-zA-Z0-9_-] — valid continuation byte of an identifier
|
||||
IDENT_CONT = Array.new(256, false).tap do |t|
|
||||
(97..122).each { |b| t[b] = true } # a-z
|
||||
(65..90).each { |b| t[b] = true } # A-Z
|
||||
(48..57).each { |b| t[b] = true } # 0-9
|
||||
t[95] = true # _
|
||||
t[45] = true # -
|
||||
end.freeze
|
||||
|
||||
# [0-9] — ASCII digit
|
||||
DIGIT = Array.new(256, false).tap do |t|
|
||||
(48..57).each { |b| t[b] = true }
|
||||
end.freeze
|
||||
|
||||
# [ \t\n\v\f\r] — ASCII whitespace (mirrors Ruby's \s)
|
||||
WHITESPACE = Array.new(256, false).tap do |t|
|
||||
[32, 9, 10, 11, 12, 13].each { |b| t[b] = true } # space, tab, \n, \v, \f, \r
|
||||
end.freeze
|
||||
end
|
||||
end
|
||||
+20
-64
@@ -48,8 +48,8 @@ module Liquid
|
||||
@@operators
|
||||
end
|
||||
|
||||
def self.parse_expression(parse_context, markup, safe: false)
|
||||
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
|
||||
def self.parse_expression(parse_context, markup)
|
||||
@@method_literals[markup] || parse_context.parse_expression(markup)
|
||||
end
|
||||
|
||||
attr_reader :attachment, :child_condition
|
||||
@@ -65,21 +65,20 @@ module Liquid
|
||||
end
|
||||
|
||||
def evaluate(context = deprecated_default_context)
|
||||
result = interpret_condition(@left, @right, @operator, context)
|
||||
|
||||
# Fast path: no child conditions (most common)
|
||||
return result unless @child_relation
|
||||
|
||||
condition = self
|
||||
while condition.child_relation
|
||||
result = nil
|
||||
loop do
|
||||
result = interpret_condition(condition.left, condition.right, condition.operator, context)
|
||||
|
||||
case condition.child_relation
|
||||
when :or
|
||||
break if Liquid::Utils.to_liquid_value(result)
|
||||
when :and
|
||||
break unless Liquid::Utils.to_liquid_value(result)
|
||||
else
|
||||
break
|
||||
end
|
||||
condition = condition.child_condition
|
||||
result = interpret_condition(condition.left, condition.right, condition.operator, context)
|
||||
end
|
||||
result
|
||||
end
|
||||
@@ -114,67 +113,24 @@ module Liquid
|
||||
|
||||
def equal_variables(left, right)
|
||||
if left.is_a?(MethodLiteral)
|
||||
return call_method_literal(left, right)
|
||||
if right.respond_to?(left.method_name)
|
||||
return right.send(left.method_name)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
if right.is_a?(MethodLiteral)
|
||||
return call_method_literal(right, left)
|
||||
if left.respond_to?(right.method_name)
|
||||
return left.send(right.method_name)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
left == right
|
||||
end
|
||||
|
||||
def call_method_literal(literal, value)
|
||||
method_name = literal.method_name
|
||||
|
||||
# If the object responds to the method (e.g., ActiveSupport is loaded), use it
|
||||
if value.respond_to?(method_name)
|
||||
value.send(method_name)
|
||||
else
|
||||
# Emulate ActiveSupport's blank?/empty? to make Liquid invariant
|
||||
# to whether ActiveSupport is loaded or not
|
||||
case method_name
|
||||
when :blank?
|
||||
liquid_blank?(value)
|
||||
when :empty?
|
||||
liquid_empty?(value)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Implement blank? semantics matching ActiveSupport
|
||||
# blank? returns true for nil, false, empty strings, whitespace-only strings,
|
||||
# empty arrays, and empty hashes
|
||||
def liquid_blank?(value)
|
||||
case value
|
||||
when NilClass, FalseClass
|
||||
true
|
||||
when TrueClass, Numeric
|
||||
false
|
||||
when String
|
||||
# Blank if empty or whitespace only (matches ActiveSupport)
|
||||
value.empty? || value.match?(/\A\s*\z/)
|
||||
when Array, Hash
|
||||
value.empty?
|
||||
else
|
||||
# Fall back to empty? if available, otherwise false
|
||||
value.respond_to?(:empty?) ? value.empty? : false
|
||||
end
|
||||
end
|
||||
|
||||
# Implement empty? semantics
|
||||
# Note: nil is NOT empty. empty? checks if a collection has zero elements.
|
||||
def liquid_empty?(value)
|
||||
case value
|
||||
when String, Array, Hash
|
||||
value.empty?
|
||||
else
|
||||
value.respond_to?(:empty?) ? value.empty? : false
|
||||
end
|
||||
end
|
||||
|
||||
def interpret_condition(left, right, op, context)
|
||||
# If the operator is empty this means that the decision statement is just
|
||||
# a single variable. We can just poll this variable from the context and
|
||||
@@ -198,8 +154,8 @@ module Liquid
|
||||
end
|
||||
|
||||
def deprecated_default_context
|
||||
warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \
|
||||
"and will be removed from Liquid 6.0.0.")
|
||||
warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
|
||||
" and will be removed from Liquid 6.0.0.")
|
||||
Context.new
|
||||
end
|
||||
|
||||
|
||||
+28
-67
@@ -24,15 +24,10 @@ module Liquid
|
||||
|
||||
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default)
|
||||
@environment = environment
|
||||
@environments = environments.is_a?(Array) ? environments : [environments]
|
||||
@environments = [environments]
|
||||
@environments.flatten!
|
||||
|
||||
@static_environments = if static_environments.is_a?(Array)
|
||||
static_environments.frozen? ? static_environments : static_environments.freeze
|
||||
elsif static_environments.empty?
|
||||
Const::EMPTY_ARRAY
|
||||
else
|
||||
[static_environments].freeze
|
||||
end
|
||||
@static_environments = [static_environments].flatten(1).freeze
|
||||
@scopes = [outer_scope || {}]
|
||||
@registers = registers.is_a?(Registers) ? registers : Registers.new(registers)
|
||||
@errors = []
|
||||
@@ -40,13 +35,10 @@ module Liquid
|
||||
@strict_variables = false
|
||||
@resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits)
|
||||
@base_scope_depth = 0
|
||||
@interrupts = Const::EMPTY_ARRAY
|
||||
@filters = Const::EMPTY_ARRAY
|
||||
@interrupts = []
|
||||
@filters = []
|
||||
@global_filter = nil
|
||||
@disabled_tags = Const::EMPTY_HASH
|
||||
|
||||
# Lazy-init StringScanner — only needed if Context#[] is called during render
|
||||
@string_scanner = nil
|
||||
@disabled_tags = {}
|
||||
|
||||
@registers.static[:cached_partials] ||= {}
|
||||
@registers.static[:file_system] ||= environment.file_system
|
||||
@@ -77,7 +69,7 @@ module Liquid
|
||||
# Note that this does not register the filters with the main Template object. see <tt>Template.register_filter</tt>
|
||||
# for that
|
||||
def add_filters(filters)
|
||||
filters = Array(filters).flatten.compact
|
||||
filters = [filters].flatten.compact
|
||||
@filters += filters
|
||||
@strainer = nil
|
||||
end
|
||||
@@ -88,12 +80,11 @@ module Liquid
|
||||
|
||||
# are there any not handled interrupts?
|
||||
def interrupt?
|
||||
!@interrupts.equal?(Const::EMPTY_ARRAY) && @interrupts.any?
|
||||
!@interrupts.empty?
|
||||
end
|
||||
|
||||
# push an interrupt to the stack. this interrupt is considered not handled.
|
||||
def push_interrupt(e)
|
||||
@interrupts = [] if @interrupts.frozen?
|
||||
@interrupts.push(e)
|
||||
end
|
||||
|
||||
@@ -114,20 +105,6 @@ module Liquid
|
||||
strainer.invoke(method, *args).to_liquid
|
||||
end
|
||||
|
||||
# Arity-specialized filter delegation — generated to match StrainerTemplate's specializations.
|
||||
# The pattern (avoid *args splat) is the same for each arity; generating makes it explicit.
|
||||
{
|
||||
invoke_single: ['input'],
|
||||
invoke_two: ['input', 'arg1'],
|
||||
}.each do |method_name, params|
|
||||
all_params = (["method"] + params).join(", ")
|
||||
module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
||||
def #{method_name}(#{all_params})
|
||||
strainer.#{method_name}(#{all_params}).to_liquid
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
|
||||
# Push new local scope on the stack. use <tt>Context#stack</tt> instead
|
||||
def push(new_scope = {})
|
||||
@scopes.unshift(new_scope)
|
||||
@@ -199,11 +176,11 @@ module Liquid
|
||||
# Example:
|
||||
# products == empty #=> products.empty?
|
||||
def [](expression)
|
||||
evaluate(Expression.parse(expression, @string_scanner ||= StringScanner.new("")))
|
||||
evaluate(Expression.parse(expression))
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
!find_variable(key, raise_on_not_found: false).nil?
|
||||
self[key] != nil
|
||||
end
|
||||
|
||||
def evaluate(object)
|
||||
@@ -212,38 +189,22 @@ module Liquid
|
||||
|
||||
# Fetches an object starting at the local scope and then moving up the hierachy
|
||||
def find_variable(key, raise_on_not_found: true)
|
||||
# Fast path: check top scope first (most common in for loops)
|
||||
scope = @scopes[0]
|
||||
if scope.key?(key)
|
||||
variable = lookup_and_evaluate(scope, key, raise_on_not_found: raise_on_not_found)
|
||||
elsif @scopes.length == 1
|
||||
# Only one scope and key not found — go straight to environments
|
||||
variable = try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
|
||||
else
|
||||
# Multiple scopes — search through all of them
|
||||
scope = @scopes.find { |s| s.key?(key) }
|
||||
# This was changed from find() to find_index() because this is a very hot
|
||||
# path and find_index() is optimized in MRI to reduce object allocation
|
||||
index = @scopes.find_index { |s| s.key?(key) }
|
||||
|
||||
variable = if scope
|
||||
lookup_and_evaluate(scope, key, raise_on_not_found: raise_on_not_found)
|
||||
else
|
||||
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
|
||||
end
|
||||
variable = if index
|
||||
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
|
||||
else
|
||||
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
|
||||
end
|
||||
|
||||
# update variable's context before invoking #to_liquid
|
||||
# Fast path: primitive types don't need context= or to_liquid conversion
|
||||
case variable
|
||||
when String, Integer, Float, NilClass, TrueClass, FalseClass, Array, Hash, Time
|
||||
return variable
|
||||
end
|
||||
|
||||
variable.context = self if variable.respond_to?(:context=)
|
||||
|
||||
liquid_variable = variable.to_liquid
|
||||
|
||||
if variable != liquid_variable
|
||||
liquid_variable.context = self if liquid_variable.respond_to?(:context=)
|
||||
end
|
||||
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
|
||||
|
||||
liquid_variable
|
||||
end
|
||||
@@ -263,7 +224,6 @@ module Liquid
|
||||
end
|
||||
|
||||
def with_disabled_tags(tag_names)
|
||||
@disabled_tags = {} if @disabled_tags.frozen?
|
||||
tag_names.each do |name|
|
||||
@disabled_tags[name] = @disabled_tags.fetch(name, 0) + 1
|
||||
end
|
||||
@@ -287,16 +247,17 @@ module Liquid
|
||||
attr_reader :base_scope_depth
|
||||
|
||||
def try_variable_find_in_environments(key, raise_on_not_found:)
|
||||
found = find_in_envs(@environments, key, raise_on_not_found: raise_on_not_found)
|
||||
return found unless found.nil? && !(@strict_variables && raise_on_not_found)
|
||||
|
||||
find_in_envs(@static_environments, key, raise_on_not_found: raise_on_not_found)
|
||||
end
|
||||
|
||||
def find_in_envs(envs, key, raise_on_not_found:)
|
||||
envs.each do |environment|
|
||||
@environments.each do |environment|
|
||||
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
|
||||
return found_variable if !found_variable.nil? || (@strict_variables && raise_on_not_found)
|
||||
if !found_variable.nil? || @strict_variables && raise_on_not_found
|
||||
return found_variable
|
||||
end
|
||||
end
|
||||
@static_environments.each do |environment|
|
||||
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
|
||||
if !found_variable.nil? || @strict_variables && raise_on_not_found
|
||||
return found_variable
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "strscan"
|
||||
|
||||
module Liquid
|
||||
# Single-pass forward-only scanner for Liquid parsing.
|
||||
# Wraps StringScanner with higher-level methods for common Liquid constructs.
|
||||
# One Cursor per template parse — threaded through all parsing code.
|
||||
class Cursor
|
||||
# Byte constants
|
||||
SPACE = 32
|
||||
TAB = 9
|
||||
NL = 10
|
||||
CR = 13
|
||||
FF = 12
|
||||
DASH = 45 # '-'
|
||||
DOT = 46 # '.'
|
||||
COLON = 58 # ':'
|
||||
PIPE = 124 # '|'
|
||||
QUOTE_S = 39 # "'"
|
||||
QUOTE_D = 34 # '"'
|
||||
LBRACK = 91 # '['
|
||||
RBRACK = 93 # ']'
|
||||
LPAREN = 40 # '('
|
||||
RPAREN = 41 # ')'
|
||||
QMARK = 63 # '?'
|
||||
HASH = 35 # '#'
|
||||
USCORE = 95 # '_'
|
||||
COMMA = 44
|
||||
ZERO = 48
|
||||
NINE = 57
|
||||
PCT = 37 # '%'
|
||||
LCURLY = 123 # '{'
|
||||
RCURLY = 125 # '}'
|
||||
|
||||
attr_reader :ss
|
||||
|
||||
def initialize(source)
|
||||
@source = source
|
||||
@ss = StringScanner.new(source)
|
||||
end
|
||||
|
||||
# ── Position ────────────────────────────────────────────────────
|
||||
def pos = @ss.pos
|
||||
|
||||
def pos=(n)
|
||||
@ss.pos = n
|
||||
end
|
||||
|
||||
def eos? = @ss.eos?
|
||||
def peek_byte = @ss.peek_byte
|
||||
def scan_byte = @ss.scan_byte
|
||||
|
||||
# Reset scanner to a new string (for reuse on sub-markup)
|
||||
def reset(source)
|
||||
@source = source
|
||||
@ss.string = source
|
||||
end
|
||||
|
||||
# Extract a slice from the source (deferred allocation)
|
||||
def slice(start, len)
|
||||
@source.byteslice(start, len)
|
||||
end
|
||||
|
||||
# ── Whitespace ──────────────────────────────────────────────────
|
||||
# Skip spaces/tabs/newlines/cr
|
||||
def skip_ws
|
||||
while (b = @ss.peek_byte)
|
||||
case b
|
||||
when SPACE, TAB, CR, FF, NL then @ss.scan_byte
|
||||
else break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Check if remaining bytes are all whitespace (or EOS).
|
||||
# exist?(/\S/) returns nil when no non-whitespace remains, without advancing position.
|
||||
def rest_blank?
|
||||
!@ss.exist?(/\S/)
|
||||
end
|
||||
|
||||
# Regex for identifier: [a-zA-Z_][\w-]*\??
|
||||
ID_REGEX = /[a-zA-Z_][\w-]*\??/
|
||||
|
||||
# ── Identifiers ─────────────────────────────────────────────────
|
||||
# Skip an identifier without allocating a string. Returns length skipped, or 0.
|
||||
def skip_id
|
||||
@ss.skip(ID_REGEX) || 0
|
||||
end
|
||||
|
||||
# Check if next id matches expected string, consume if so. No allocation.
|
||||
def expect_id(expected)
|
||||
start = @ss.pos
|
||||
len = @ss.skip(ID_REGEX)
|
||||
if len == expected.bytesize
|
||||
# Compare bytes directly without allocating a string
|
||||
i = 0
|
||||
while i < len
|
||||
unless @source.getbyte(start + i) == expected.getbyte(i)
|
||||
@ss.pos = start
|
||||
return false
|
||||
end
|
||||
i += 1
|
||||
end
|
||||
return true
|
||||
end
|
||||
@ss.pos = start if len
|
||||
false
|
||||
end
|
||||
|
||||
# Scan a single identifier: [a-zA-Z_][\w-]*\??
|
||||
# Returns the string or nil if not at an identifier
|
||||
def scan_id
|
||||
@ss.scan(ID_REGEX)
|
||||
end
|
||||
|
||||
# Scan a tag name: '#' or \w+
|
||||
def scan_tag_name
|
||||
if @ss.peek_byte == HASH
|
||||
@ss.scan_byte
|
||||
"#"
|
||||
else
|
||||
scan_id
|
||||
end
|
||||
end
|
||||
|
||||
# Regex for numbers: -?\d+(\.\d+)?
|
||||
FLOAT_REGEX = /-?\d+\.\d+/
|
||||
INT_REGEX = /-?\d+/
|
||||
|
||||
# ── Numbers ─────────────────────────────────────────────────────
|
||||
# Try to scan an integer or float. Returns the number or nil.
|
||||
def scan_number
|
||||
if (s = @ss.scan(FLOAT_REGEX))
|
||||
s.to_f
|
||||
elsif (s = @ss.scan(INT_REGEX))
|
||||
s.to_i
|
||||
end
|
||||
end
|
||||
|
||||
# Regex for quoted string content (without quotes)
|
||||
SINGLE_QUOTED_CONTENT = /'([^']*)'/
|
||||
DOUBLE_QUOTED_CONTENT = /"([^"]*)"/
|
||||
|
||||
# ── Strings ─────────────────────────────────────────────────────
|
||||
# Scan a quoted string ('...' or "..."). Returns the content without quotes, or nil.
|
||||
def scan_quoted_string
|
||||
if @ss.scan(SINGLE_QUOTED_CONTENT) || @ss.scan(DOUBLE_QUOTED_CONTENT)
|
||||
@ss[1]
|
||||
end
|
||||
end
|
||||
|
||||
# Regex for quoted strings (single or double quoted, including quotes)
|
||||
QUOTED_STRING_RAW = /"[^"]*"|'[^']*'/
|
||||
|
||||
# Scan a quoted string including quotes. Returns the full "..." or '...' string, or nil.
|
||||
def scan_quoted_string_raw
|
||||
@ss.scan(QUOTED_STRING_RAW)
|
||||
end
|
||||
|
||||
# Regex for dotted identifier: name(.name)*
|
||||
DOTTED_ID_REGEX = /[a-zA-Z_][\w-]*\??(?:\.[a-zA-Z_][\w-]*\??)*/
|
||||
|
||||
# ── Expressions ─────────────────────────────────────────────────
|
||||
# Scan a simple variable lookup: name(.name)* — no brackets, no filters
|
||||
# Returns the string or nil
|
||||
def scan_dotted_id
|
||||
@ss.scan(DOTTED_ID_REGEX)
|
||||
end
|
||||
|
||||
# Skip a fragment without allocating. Returns length skipped, or 0.
|
||||
def skip_fragment
|
||||
@ss.skip(QUOTED_STRING_RAW) || @ss.skip(UNQUOTED_FRAGMENT) || 0
|
||||
end
|
||||
|
||||
# Regex for unquoted fragment: non-whitespace/comma/pipe sequence
|
||||
UNQUOTED_FRAGMENT = /[^\s,|]+/
|
||||
|
||||
# Scan a "QuotedFragment" — a quoted string or non-whitespace/comma/pipe run
|
||||
def scan_fragment
|
||||
@ss.scan(QUOTED_STRING_RAW) || @ss.scan(UNQUOTED_FRAGMENT)
|
||||
end
|
||||
|
||||
# ── Comparison operators ────────────────────────────────────────
|
||||
# Identity map used for frozen string interning: StringScanner#scan returns a
|
||||
# new unfrozen String on every call. Indexing into this hash returns the frozen
|
||||
# literal stored here, avoiding a separate allocation and enabling faster
|
||||
# equality checks downstream (frozen strings can be compared by identity).
|
||||
COMPARISON_OPS = {
|
||||
'==' => '==',
|
||||
'!=' => '!=',
|
||||
'<>' => '<>',
|
||||
'<=' => '<=',
|
||||
'>=' => '>=',
|
||||
'<' => '<',
|
||||
'>' => '>',
|
||||
'contains' => 'contains',
|
||||
}.freeze
|
||||
|
||||
# Scan a comparison operator. Returns frozen string or nil.
|
||||
# Regex for comparison operators
|
||||
COMPARISON_OP_REGEX = /==|!=|<>|<=|>=|<|>|contains(?!\w)/
|
||||
|
||||
def scan_comparison_op
|
||||
if (op = @ss.scan(COMPARISON_OP_REGEX))
|
||||
COMPARISON_OPS[op]
|
||||
end
|
||||
end
|
||||
|
||||
# ── Tag parsing helpers ─────────────────────────────────────────
|
||||
# Results from last parse_tag_token call (avoids array allocation)
|
||||
attr_reader :tag_markup, :tag_newlines
|
||||
|
||||
# Parse the interior of a tag token: "{%[-] tag_name markup [-]%}"
|
||||
# Pure byte operations — avoids StringScanner reset overhead.
|
||||
# Returns tag_name string or nil. Sets tag_markup and tag_newlines.
|
||||
def parse_tag_token(token)
|
||||
len = token.bytesize
|
||||
pos = 2 # skip "{%"
|
||||
pos += 1 if token.getbyte(pos) == DASH # skip '-'
|
||||
nl = 0
|
||||
|
||||
# Skip whitespace, count newlines
|
||||
while pos < len
|
||||
b = token.getbyte(pos)
|
||||
case b
|
||||
when SPACE, TAB, CR, FF then pos += 1
|
||||
when NL then pos += 1
|
||||
nl += 1
|
||||
else break
|
||||
end
|
||||
end
|
||||
|
||||
# Scan tag name: '#' or [a-zA-Z_][\w-]*
|
||||
name_start = pos
|
||||
b = token.getbyte(pos)
|
||||
if b == HASH
|
||||
pos += 1
|
||||
elsif b && ByteTables::IDENT_START[b]
|
||||
pos += 1
|
||||
while pos < len
|
||||
b = token.getbyte(pos)
|
||||
break unless ByteTables::IDENT_CONT[b]
|
||||
|
||||
pos += 1
|
||||
end
|
||||
pos += 1 if pos < len && token.getbyte(pos) == QMARK
|
||||
else
|
||||
return
|
||||
end
|
||||
tag_name = token.byteslice(name_start, pos - name_start)
|
||||
|
||||
# Skip whitespace after tag name, count newlines
|
||||
while pos < len
|
||||
b = token.getbyte(pos)
|
||||
case b
|
||||
when SPACE, TAB, CR, FF then pos += 1
|
||||
when NL then pos += 1
|
||||
nl += 1
|
||||
else break
|
||||
end
|
||||
end
|
||||
|
||||
# markup is everything up to optional '-' before '%}'
|
||||
markup_end = len - 2
|
||||
markup_end -= 1 if markup_end > pos && token.getbyte(markup_end - 1) == DASH
|
||||
@tag_markup = pos >= markup_end ? "" : token.byteslice(pos, markup_end - pos)
|
||||
@tag_newlines = nl
|
||||
|
||||
tag_name
|
||||
end
|
||||
|
||||
# Parse variable token interior: extract markup from "{{[-] ... [-]}}"
|
||||
def parse_variable_token(token)
|
||||
len = token.bytesize
|
||||
return if len < 4
|
||||
|
||||
i = 2
|
||||
i = 3 if token.getbyte(i) == DASH
|
||||
parse_end = len - 3
|
||||
parse_end -= 1 if token.getbyte(parse_end) == DASH
|
||||
markup_len = parse_end - i + 1
|
||||
markup_len <= 0 ? "" : token.byteslice(i, markup_len)
|
||||
end
|
||||
|
||||
# ── Simple condition parser ─────────────────────────────────────
|
||||
# Results from last parse_simple_condition call
|
||||
attr_reader :cond_left, :cond_op, :cond_right
|
||||
|
||||
# Parse "expr [op expr]" from current position to end.
|
||||
# Returns true on success, nil on failure. Sets cond_left, cond_op, cond_right.
|
||||
def parse_simple_condition
|
||||
skip_ws
|
||||
@cond_left = scan_fragment
|
||||
return unless @cond_left
|
||||
|
||||
skip_ws
|
||||
if eos?
|
||||
@cond_op = nil
|
||||
@cond_right = nil
|
||||
return true
|
||||
end
|
||||
|
||||
@cond_op = scan_comparison_op
|
||||
return unless @cond_op
|
||||
|
||||
skip_ws
|
||||
@cond_right = scan_fragment
|
||||
return unless @cond_right
|
||||
|
||||
skip_ws
|
||||
return unless eos? # trailing junk
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -31,7 +31,7 @@ module Liquid
|
||||
|
||||
# Catch all for the method
|
||||
def liquid_method_missing(method)
|
||||
return unless @context&.strict_variables
|
||||
return nil unless @context&.strict_variables
|
||||
raise Liquid::UndefinedDropMethod, "undefined method #{method}"
|
||||
end
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ module Liquid
|
||||
# @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 :strict2, :strict, :warn, or :lax).
|
||||
# (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.
|
||||
|
||||
+23
-130
@@ -10,146 +10,39 @@ module Liquid
|
||||
'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
|
||||
|
||||
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/
|
||||
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
|
||||
|
||||
class << self
|
||||
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
|
||||
parse(parser.expression, ss, cache)
|
||||
def self.parse(markup)
|
||||
return nil unless markup
|
||||
|
||||
markup = markup.strip
|
||||
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
||||
(markup.start_with?("'") && markup.end_with?("'"))
|
||||
return markup[1..-2]
|
||||
end
|
||||
|
||||
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
||||
return unless markup
|
||||
|
||||
# Only strip if there's leading/trailing whitespace (avoids allocation)
|
||||
first_byte = markup.getbyte(0)
|
||||
if first_byte && ByteTables::WHITESPACE[first_byte]
|
||||
markup = markup.strip
|
||||
elsif first_byte
|
||||
markup = markup.strip if ByteTables::WHITESPACE[markup.getbyte(markup.bytesize - 1)]
|
||||
end
|
||||
|
||||
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
||||
(markup.start_with?("'") && markup.end_with?("'"))
|
||||
return markup.byteslice(1, markup.bytesize - 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
|
||||
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
|
||||
inner_parse(markup, ss, nil).freeze
|
||||
VariableLookup.parse(markup)
|
||||
end
|
||||
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
|
||||
else
|
||||
VariableLookup.parse(markup, ss, cache)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_number(markup, _ss = nil)
|
||||
len = markup.bytesize
|
||||
return if len == 0
|
||||
|
||||
# Quick reject: first byte must be digit or dash
|
||||
pos = 0
|
||||
first = markup.getbyte(pos)
|
||||
if first == Cursor::DASH
|
||||
pos += 1
|
||||
return if pos >= len
|
||||
|
||||
b = markup.getbyte(pos)
|
||||
return unless ByteTables::DIGIT[b]
|
||||
|
||||
pos += 1
|
||||
elsif ByteTables::DIGIT[first]
|
||||
pos += 1
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
# Scan digits
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
break unless ByteTables::DIGIT[b]
|
||||
|
||||
pos += 1
|
||||
end
|
||||
|
||||
# If we consumed everything, it's a simple integer
|
||||
if pos == len
|
||||
return Integer(markup, 10)
|
||||
end
|
||||
|
||||
# Check for dot (float)
|
||||
if markup.getbyte(pos) == Cursor::DOT
|
||||
dot_pos = pos
|
||||
pos += 1
|
||||
# Must have at least one digit after dot
|
||||
digit_after_dot = pos
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
break unless ByteTables::DIGIT[b]
|
||||
|
||||
pos += 1
|
||||
end
|
||||
|
||||
if pos > digit_after_dot && pos == len
|
||||
# Simple float like "123.456"
|
||||
return markup.to_f
|
||||
elsif pos > digit_after_dot
|
||||
# Float followed by more content: "1.2.3.4" — scan to find where the
|
||||
# numeric portion ends (stop at next dot or non-digit).
|
||||
return scan_float_with_trailing(markup, pos, len)
|
||||
else
|
||||
# dot at end: "123."
|
||||
return markup.byteslice(0, dot_pos).to_f
|
||||
end
|
||||
end
|
||||
|
||||
# Not a number (has non-digit, non-dot characters)
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Scans forward from `pos` through digits, returning the float up to the
|
||||
# next dot or the end of string. Returns nil when a non-digit, non-dot
|
||||
# byte is found (not a valid number). Used by parse_number for inputs
|
||||
# like "1.2.3.4" where the float literal ends at the second dot.
|
||||
def scan_float_with_trailing(markup, pos, len)
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
return markup.byteslice(0, pos).to_f if b == Cursor::DOT
|
||||
return unless ByteTables::DIGIT[b]
|
||||
|
||||
pos += 1
|
||||
end
|
||||
markup.byteslice(0, pos).to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ module Liquid
|
||||
def interpolate(name, vars)
|
||||
name.gsub(/%\{(\w+)\}/) do
|
||||
# raise TranslationError, "Undefined key #{$1} for interpolation in translation #{name}" unless vars[$1.to_sym]
|
||||
vars[Regexp.last_match(1).to_sym].to_s
|
||||
(vars[Regexp.last_match(1).to_sym]).to_s
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+122
-65
@@ -1,7 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "strscan"
|
||||
|
||||
module Liquid
|
||||
class Lexer
|
||||
class Lexer1
|
||||
SPECIALS = {
|
||||
'|' => :pipe,
|
||||
'.' => :dot,
|
||||
':' => :colon,
|
||||
',' => :comma,
|
||||
'[' => :open_square,
|
||||
']' => :close_square,
|
||||
'(' => :open_round,
|
||||
')' => :close_round,
|
||||
'?' => :question,
|
||||
'-' => :dash,
|
||||
}.freeze
|
||||
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
|
||||
SINGLE_STRING_LITERAL = /'[^\']*'/
|
||||
DOUBLE_STRING_LITERAL = /"[^\"]*"/
|
||||
STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL)
|
||||
NUMBER_LITERAL = /-?\d+(\.\d+)?/
|
||||
DOTDOT = /\.\./
|
||||
COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/
|
||||
WHITESPACE_OR_NOTHING = /\s*/
|
||||
|
||||
def initialize(input)
|
||||
@ss = StringScanner.new(input)
|
||||
end
|
||||
|
||||
def tokenize
|
||||
@output = []
|
||||
|
||||
until @ss.eos?
|
||||
@ss.skip(WHITESPACE_OR_NOTHING)
|
||||
break if @ss.eos?
|
||||
tok = if (t = @ss.scan(COMPARISON_OPERATOR))
|
||||
[:comparison, t]
|
||||
elsif (t = @ss.scan(STRING_LITERAL))
|
||||
[:string, t]
|
||||
elsif (t = @ss.scan(NUMBER_LITERAL))
|
||||
[:number, t]
|
||||
elsif (t = @ss.scan(IDENTIFIER))
|
||||
[:id, t]
|
||||
elsif (t = @ss.scan(DOTDOT))
|
||||
[:dotdot, t]
|
||||
else
|
||||
c = @ss.getch
|
||||
if (s = SPECIALS[c])
|
||||
[s, c]
|
||||
else
|
||||
raise SyntaxError, "Unexpected character #{c}"
|
||||
end
|
||||
end
|
||||
@output << tok
|
||||
end
|
||||
|
||||
@output << [:end_of_string]
|
||||
end
|
||||
end
|
||||
|
||||
class Lexer2
|
||||
CLOSE_ROUND = [:close_round, ")"].freeze
|
||||
CLOSE_SQUARE = [:close_square, "]"].freeze
|
||||
COLON = [:colon, ":"].freeze
|
||||
@@ -29,12 +88,10 @@ module Liquid
|
||||
RUBY_WHITESPACE = [" ", "\t", "\r", "\n", "\f"].freeze
|
||||
SINGLE_STRING_LITERAL = /'[^\']*'/
|
||||
WHITESPACE_OR_NOTHING = /\s*/
|
||||
WHITESPACE = /\s+/
|
||||
|
||||
SINGLE_COMPARISON_TOKENS = [].tap do |table|
|
||||
table["<".ord] = COMPARISON_LESS_THAN
|
||||
table[">".ord] = COMPARISON_GREATER_THAN
|
||||
table.freeze
|
||||
end
|
||||
|
||||
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
|
||||
@@ -46,17 +103,18 @@ module Liquid
|
||||
sub_table["=".ord] = COMPARISION_NOT_EQUAL
|
||||
sub_table.freeze
|
||||
end
|
||||
table.freeze
|
||||
end
|
||||
|
||||
COMPARISON_JUMP_TABLE = [].tap do |table|
|
||||
table["<".ord] = [].tap do |sub_table|
|
||||
sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
|
||||
sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
|
||||
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_LESS_THAN }
|
||||
sub_table.freeze
|
||||
end
|
||||
table[">".ord] = [].tap do |sub_table|
|
||||
sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL
|
||||
RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_GREATER_THAN }
|
||||
sub_table.freeze
|
||||
end
|
||||
table.freeze
|
||||
@@ -99,82 +157,81 @@ module Liquid
|
||||
table.freeze
|
||||
end
|
||||
|
||||
def initialize(input)
|
||||
@ss = StringScanner.new(input)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/BlockNesting
|
||||
class << self
|
||||
def tokenize(ss)
|
||||
output = []
|
||||
def tokenize
|
||||
@output = []
|
||||
|
||||
until ss.eos?
|
||||
ss.skip(WHITESPACE)
|
||||
until @ss.eos?
|
||||
@ss.skip(WHITESPACE_OR_NOTHING)
|
||||
|
||||
break if ss.eos?
|
||||
break if @ss.eos?
|
||||
|
||||
start_pos = ss.pos
|
||||
peeked = ss.peek_byte
|
||||
start_pos = @ss.pos
|
||||
peeked = @ss.peek_byte
|
||||
|
||||
if (special = SPECIAL_TABLE[peeked])
|
||||
ss.scan_byte
|
||||
# Special case for ".."
|
||||
if special.equal?(DOT) && ss.peek_byte == DOT_ORD
|
||||
ss.scan_byte
|
||||
output << DOTDOT
|
||||
elsif special.equal?(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
|
||||
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
|
||||
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]
|
||||
@output << special
|
||||
end
|
||||
else
|
||||
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
|
||||
@output << special
|
||||
end
|
||||
elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked])
|
||||
@ss.scan_byte
|
||||
if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
|
||||
@output << found
|
||||
@ss.scan_byte
|
||||
else
|
||||
raise_syntax_error(start_pos)
|
||||
end
|
||||
elsif (sub_table = COMPARISON_JUMP_TABLE[peeked])
|
||||
@ss.scan_byte
|
||||
if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte])
|
||||
@output << found
|
||||
@ss.scan_byte
|
||||
else
|
||||
@output << SINGLE_COMPARISON_TOKENS[peeked]
|
||||
end
|
||||
else
|
||||
type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked]
|
||||
|
||||
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
|
||||
if type && (t = @ss.scan(pattern))
|
||||
# Special case for "contains"
|
||||
@output << if type == :id && t == "contains" && @output.last&.first != :dot
|
||||
COMPARISON_CONTAINS
|
||||
else
|
||||
raise_syntax_error(start_pos, ss)
|
||||
[type, t]
|
||||
end
|
||||
else
|
||||
raise_syntax_error(start_pos)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
output << EOS
|
||||
rescue ::ArgumentError => e
|
||||
if e.message == "invalid byte sequence in #{ss.string.encoding}"
|
||||
raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}"
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
|
||||
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 << EOS
|
||||
end
|
||||
|
||||
def raise_syntax_error(start_pos)
|
||||
@ss.pos = start_pos
|
||||
# the character could be a UTF-8 character, use getch to get all the bytes
|
||||
raise SyntaxError, "Unexpected character #{@ss.getch}"
|
||||
end
|
||||
end
|
||||
|
||||
Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1
|
||||
end
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
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"
|
||||
@@ -20,7 +18,6 @@
|
||||
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"
|
||||
table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
|
||||
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"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
module Liquid
|
||||
class ParseContext
|
||||
attr_accessor :locale, :line_number, :trim_whitespace, :depth
|
||||
attr_reader :partial, :warnings, :error_mode, :environment, :expression_cache, :string_scanner, :cursor
|
||||
attr_reader :partial, :warnings, :error_mode, :environment
|
||||
|
||||
def initialize(options = Const::EMPTY_HASH)
|
||||
@environment = options.fetch(:environment, Environment.default)
|
||||
@@ -12,20 +12,6 @@ module Liquid
|
||||
@locale = @template_options[:locale] ||= I18n.new
|
||||
@warnings = []
|
||||
|
||||
# constructing new StringScanner in Lexer, Tokenizer, etc is expensive
|
||||
# This StringScanner will be shared by all of them
|
||||
@string_scanner = StringScanner.new("")
|
||||
|
||||
@expression_cache = if options[:expression_cache].nil?
|
||||
{}
|
||||
elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=)
|
||||
options[:expression_cache]
|
||||
elsif options[:expression_cache]
|
||||
{}
|
||||
end
|
||||
|
||||
@cursor = Cursor.new("")
|
||||
|
||||
self.depth = 0
|
||||
self.partial = false
|
||||
end
|
||||
@@ -38,37 +24,12 @@ module Liquid
|
||||
Liquid::BlockBody.new
|
||||
end
|
||||
|
||||
def new_parser(input)
|
||||
@string_scanner.string = input
|
||||
Parser.new(@string_scanner)
|
||||
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 new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
|
||||
Tokenizer.new(
|
||||
source: source,
|
||||
string_scanner: @string_scanner,
|
||||
line_number: start_line_number,
|
||||
for_liquid_tag: for_liquid_tag,
|
||||
)
|
||||
end
|
||||
|
||||
def safe_parse_expression(parser)
|
||||
Expression.safe_parse(parser, @string_scanner, @expression_cache)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
if !safe && @error_mode == :strict2
|
||||
# parse_expression is a widely used API. To maintain backward
|
||||
# compatibility while raising awareness about strict2 parser standards,
|
||||
# the safe flag supports API users make a deliberate decision.
|
||||
#
|
||||
# In strict2 mode, markup MUST come from a string returned by the parser
|
||||
# (e.g., parser.expression). We're not calling the parser here to
|
||||
# prevent redundant parser overhead.
|
||||
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
|
||||
end
|
||||
|
||||
Expression.parse(markup, @string_scanner, @expression_cache)
|
||||
def parse_expression(markup)
|
||||
Expression.parse(markup)
|
||||
end
|
||||
|
||||
def partial=(value)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,9 +83,6 @@ module Liquid
|
||||
end
|
||||
|
||||
def variable_lookups
|
||||
# Fast path: no lookups at all (most common case for simple identifiers)
|
||||
return "" unless look(:dot) || look(:open_square)
|
||||
|
||||
str = +""
|
||||
loop do
|
||||
if look(:open_square)
|
||||
|
||||
@@ -2,25 +2,10 @@
|
||||
|
||||
module Liquid
|
||||
module ParserSwitching
|
||||
# Do not use this.
|
||||
#
|
||||
# It's basically doing the same thing the {#parse_with_selected_parser},
|
||||
# except this will try the strict parser regardless of the error mode,
|
||||
# and fall back to the lax parser if the error mode is lax or warn,
|
||||
# except when in strict2 mode where it uses the strict2 parser.
|
||||
#
|
||||
# @deprecated Use {#parse_with_selected_parser} instead.
|
||||
def strict_parse_with_error_mode_fallback(markup)
|
||||
return strict2_parse_with_error_context(markup) if strict2_mode?
|
||||
|
||||
strict_parse_with_error_context(markup)
|
||||
rescue SyntaxError => e
|
||||
case parse_context.error_mode
|
||||
when :rigid
|
||||
rigid_warn
|
||||
raise
|
||||
when :strict2
|
||||
raise
|
||||
when :strict
|
||||
raise
|
||||
when :warn
|
||||
@@ -31,13 +16,11 @@ module Liquid
|
||||
|
||||
def parse_with_selected_parser(markup)
|
||||
case parse_context.error_mode
|
||||
when :rigid then rigid_warn && strict2_parse_with_error_context(markup)
|
||||
when :strict2 then strict2_parse_with_error_context(markup)
|
||||
when :strict then strict_parse_with_error_context(markup)
|
||||
when :lax then lax_parse(markup)
|
||||
when :strict then strict_parse_with_error_context(markup)
|
||||
when :lax then lax_parse(markup)
|
||||
when :warn
|
||||
begin
|
||||
strict2_parse_with_error_context(markup)
|
||||
strict_parse_with_error_context(markup)
|
||||
rescue SyntaxError => e
|
||||
parse_context.warnings << e
|
||||
lax_parse(markup)
|
||||
@@ -45,24 +28,8 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
def strict2_mode?
|
||||
parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rigid_warn
|
||||
Deprecations.warn(':rigid', ':strict2')
|
||||
end
|
||||
|
||||
def strict2_parse_with_error_context(markup)
|
||||
strict2_parse(markup)
|
||||
rescue SyntaxError => e
|
||||
e.line_number = line_number
|
||||
e.markup_context = markup_context(markup)
|
||||
raise e
|
||||
end
|
||||
|
||||
def strict_parse_with_error_context(markup)
|
||||
strict_parse(markup)
|
||||
rescue SyntaxError => e
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -6,15 +6,15 @@ module Liquid
|
||||
|
||||
def initialize(registers = {})
|
||||
@static = registers.is_a?(Registers) ? registers.static : registers
|
||||
@changes = nil
|
||||
@changes = {}
|
||||
end
|
||||
|
||||
def []=(key, value)
|
||||
(@changes ||= {})[key] = value
|
||||
@changes[key] = value
|
||||
end
|
||||
|
||||
def [](key)
|
||||
if @changes&.key?(key)
|
||||
if @changes.key?(key)
|
||||
@changes[key]
|
||||
else
|
||||
@static[key]
|
||||
@@ -22,13 +22,13 @@ module Liquid
|
||||
end
|
||||
|
||||
def delete(key)
|
||||
@changes&.delete(key)
|
||||
@changes.delete(key)
|
||||
end
|
||||
|
||||
UNDEFINED = Object.new
|
||||
|
||||
def fetch(key, default = UNDEFINED, &block)
|
||||
if @changes&.key?(key)
|
||||
if @changes.key?(key)
|
||||
@changes.fetch(key)
|
||||
elsif default != UNDEFINED
|
||||
if block_given?
|
||||
@@ -42,7 +42,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
@changes&.key?(key) || @static.key?(key)
|
||||
@changes.key?(key) || @static.key?(key)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,40 +2,24 @@
|
||||
|
||||
module Liquid
|
||||
class ResourceLimits
|
||||
attr_accessor :render_length_limit,
|
||||
:render_score_limit,
|
||||
:assign_score_limit,
|
||||
:cumulative_render_score_limit,
|
||||
:cumulative_assign_score_limit
|
||||
attr_reader :render_score,
|
||||
:assign_score,
|
||||
:last_capture_length,
|
||||
:cumulative_render_score,
|
||||
:cumulative_assign_score
|
||||
attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
|
||||
attr_reader :render_score, :assign_score
|
||||
|
||||
def initialize(limits)
|
||||
@render_length_limit = limits[:render_length_limit]
|
||||
@render_score_limit = limits[:render_score_limit]
|
||||
@assign_score_limit = limits[:assign_score_limit]
|
||||
@cumulative_render_score_limit = limits[:cumulative_render_score_limit]
|
||||
@cumulative_assign_score_limit = limits[:cumulative_assign_score_limit]
|
||||
@cumulative_render_score = 0
|
||||
@cumulative_assign_score = 0
|
||||
@render_length_limit = limits[:render_length_limit]
|
||||
@render_score_limit = limits[:render_score_limit]
|
||||
@assign_score_limit = limits[:assign_score_limit]
|
||||
reset
|
||||
end
|
||||
|
||||
def increment_render_score(amount)
|
||||
@render_score += amount
|
||||
@cumulative_render_score += amount
|
||||
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
|
||||
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
|
||||
end
|
||||
|
||||
def increment_assign_score(amount)
|
||||
@assign_score += amount
|
||||
@cumulative_assign_score += amount
|
||||
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
|
||||
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
|
||||
end
|
||||
|
||||
# update either render_length or assign_score based on whether or not the writes are captured
|
||||
@@ -63,8 +47,6 @@ module Liquid
|
||||
@reached_limit = false
|
||||
@last_capture_length = nil
|
||||
@render_score = @assign_score = 0
|
||||
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
|
||||
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
|
||||
end
|
||||
|
||||
def with_capture
|
||||
|
||||
+76
-247
@@ -3,24 +3,16 @@
|
||||
require 'cgi'
|
||||
require 'base64'
|
||||
require 'bigdecimal'
|
||||
|
||||
module Liquid
|
||||
module StandardFilters
|
||||
MAX_I32 = (1 << 31) - 1
|
||||
private_constant :MAX_I32
|
||||
|
||||
supports_64bit_indices = begin
|
||||
[][1 << 33, 1 << 33]
|
||||
true
|
||||
rescue RangeError
|
||||
false
|
||||
end
|
||||
|
||||
INDEX_RANGE = if supports_64bit_indices
|
||||
(-(1 << 63))..((1 << 63) - 1)
|
||||
else
|
||||
(-(1 << 31))..((1 << 31) - 1)
|
||||
end
|
||||
private_constant :INDEX_RANGE
|
||||
MIN_I64 = -(1 << 63)
|
||||
MAX_I64 = (1 << 63) - 1
|
||||
I64_RANGE = MIN_I64..MAX_I64
|
||||
private_constant :MIN_I64, :MAX_I64, :I64_RANGE
|
||||
|
||||
HTML_ESCAPE = {
|
||||
'&' => '&',
|
||||
@@ -72,7 +64,7 @@ module Liquid
|
||||
# @liquid_syntax string | downcase
|
||||
# @liquid_return [string]
|
||||
def downcase(input)
|
||||
Utils.to_s(input).downcase
|
||||
input.to_s.downcase
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -83,7 +75,7 @@ module Liquid
|
||||
# @liquid_syntax string | upcase
|
||||
# @liquid_return [string]
|
||||
def upcase(input)
|
||||
Utils.to_s(input).upcase
|
||||
input.to_s.upcase
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -94,7 +86,7 @@ module Liquid
|
||||
# @liquid_syntax string | capitalize
|
||||
# @liquid_return [string]
|
||||
def capitalize(input)
|
||||
Utils.to_s(input).capitalize
|
||||
input.to_s.capitalize
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -105,7 +97,7 @@ module Liquid
|
||||
# @liquid_syntax string | escape
|
||||
# @liquid_return [string]
|
||||
def escape(input)
|
||||
CGI.escapeHTML(Utils.to_s(input)) unless input.nil?
|
||||
CGI.escapeHTML(input.to_s) unless input.nil?
|
||||
end
|
||||
alias_method :h, :escape
|
||||
|
||||
@@ -117,7 +109,7 @@ module Liquid
|
||||
# @liquid_syntax string | escape_once
|
||||
# @liquid_return [string]
|
||||
def escape_once(input)
|
||||
Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
|
||||
input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -132,7 +124,7 @@ module Liquid
|
||||
# @liquid_syntax string | url_encode
|
||||
# @liquid_return [string]
|
||||
def url_encode(input)
|
||||
CGI.escape(Utils.to_s(input)) unless input.nil?
|
||||
CGI.escape(input.to_s) unless input.nil?
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -146,7 +138,7 @@ module Liquid
|
||||
def url_decode(input)
|
||||
return if input.nil?
|
||||
|
||||
result = CGI.unescape(Utils.to_s(input))
|
||||
result = CGI.unescape(input.to_s)
|
||||
raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding?
|
||||
|
||||
result
|
||||
@@ -160,7 +152,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_encode
|
||||
# @liquid_return [string]
|
||||
def base64_encode(input)
|
||||
Base64.strict_encode64(Utils.to_s(input))
|
||||
Base64.strict_encode64(input.to_s)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -171,7 +163,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_decode
|
||||
# @liquid_return [string]
|
||||
def base64_decode(input)
|
||||
input = Utils.to_s(input)
|
||||
input = input.to_s
|
||||
StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding)
|
||||
rescue ::ArgumentError
|
||||
raise Liquid::ArgumentError, "invalid base64 provided to base64_decode"
|
||||
@@ -185,7 +177,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_url_safe_encode
|
||||
# @liquid_return [string]
|
||||
def base64_url_safe_encode(input)
|
||||
Base64.urlsafe_encode64(Utils.to_s(input))
|
||||
Base64.urlsafe_encode64(input.to_s)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -196,7 +188,7 @@ module Liquid
|
||||
# @liquid_syntax string | base64_url_safe_decode
|
||||
# @liquid_return [string]
|
||||
def base64_url_safe_decode(input)
|
||||
input = Utils.to_s(input)
|
||||
input = input.to_s
|
||||
StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding)
|
||||
rescue ::ArgumentError
|
||||
raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode"
|
||||
@@ -220,14 +212,14 @@ module Liquid
|
||||
if input.is_a?(Array)
|
||||
input.slice(offset, length) || []
|
||||
else
|
||||
Utils.to_s(input).slice(offset, length) || ''
|
||||
input.to_s.slice(offset, length) || ''
|
||||
end
|
||||
rescue RangeError
|
||||
if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset)
|
||||
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
|
||||
raise # unexpected error
|
||||
end
|
||||
offset = offset.clamp(INDEX_RANGE)
|
||||
length = length.clamp(INDEX_RANGE)
|
||||
offset = offset.clamp(I64_RANGE)
|
||||
length = length.clamp(I64_RANGE)
|
||||
retry
|
||||
end
|
||||
end
|
||||
@@ -244,10 +236,10 @@ module Liquid
|
||||
# @liquid_return [string]
|
||||
def truncate(input, length = 50, truncate_string = "...")
|
||||
return if input.nil?
|
||||
input_str = Utils.to_s(input)
|
||||
input_str = input.to_s
|
||||
length = Utils.to_integer(length)
|
||||
|
||||
truncate_string_str = Utils.to_s(truncate_string)
|
||||
truncate_string_str = truncate_string.to_s
|
||||
|
||||
l = length - truncate_string_str.length
|
||||
l = 0 if l < 0
|
||||
@@ -271,75 +263,21 @@ module Liquid
|
||||
# @liquid_return [string]
|
||||
def truncatewords(input, words = 15, truncate_string = "...")
|
||||
return if input.nil?
|
||||
input = Utils.to_s(input)
|
||||
input = input.to_s
|
||||
words = Utils.to_integer(words)
|
||||
words = 1 if words <= 0
|
||||
|
||||
return input if words + 1 > MAX_I32
|
||||
|
||||
# Scan words tracking byte positions; build the normalized (single-space)
|
||||
# result string only when truncation is actually needed.
|
||||
len = input.bytesize
|
||||
pos = 0
|
||||
word_count = 0
|
||||
# Flat array of [start, end, start, end, ...] for up to `words` words.
|
||||
# Avoids allocating a result string in the common no-truncation case.
|
||||
positions = []
|
||||
|
||||
# Skip leading whitespace
|
||||
while pos < len
|
||||
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
|
||||
pos += 1
|
||||
wordlist = begin
|
||||
input.split(" ", words + 1)
|
||||
rescue RangeError
|
||||
# integer too big for String#split, but we can semantically assume no truncation is needed
|
||||
return input if words + 1 > MAX_I32
|
||||
raise # unexpected error
|
||||
end
|
||||
return input if wordlist.length <= words
|
||||
|
||||
while pos < len
|
||||
word_start = pos
|
||||
word_count += 1
|
||||
|
||||
# Scan to end of word
|
||||
while pos < len
|
||||
break if ByteTables::WHITESPACE[input.getbyte(pos)]
|
||||
pos += 1
|
||||
end
|
||||
|
||||
if word_count <= words
|
||||
positions.push(word_start, pos) # [start, end, start, end, ...]
|
||||
else
|
||||
# Truncation confirmed — build normalized result from stored positions
|
||||
result = +input.byteslice(positions[0], positions[1] - positions[0])
|
||||
i = 2
|
||||
while i < positions.length
|
||||
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
|
||||
i += 2
|
||||
end
|
||||
return result << Utils.to_s(truncate_string)
|
||||
end
|
||||
|
||||
# Skip whitespace between words
|
||||
while pos < len
|
||||
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
|
||||
pos += 1
|
||||
end
|
||||
end
|
||||
|
||||
# Fewer words than requested — no truncation needed, return original unchanged.
|
||||
return input if word_count < words
|
||||
|
||||
# Exactly `words` words. Ruby's split(" ", words+1) would produce a words+1-th
|
||||
# empty element when input has trailing whitespace, triggering the truncation path.
|
||||
# Match that behaviour: if the input ends with whitespace, normalize and append
|
||||
# truncate_string even though no word was cut.
|
||||
if len > 0 && ByteTables::WHITESPACE[input.getbyte(len - 1)]
|
||||
result = +input.byteslice(positions[0], positions[1] - positions[0])
|
||||
i = 2
|
||||
while i < positions.length
|
||||
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
|
||||
i += 2
|
||||
end
|
||||
return result << Utils.to_s(truncate_string)
|
||||
end
|
||||
|
||||
input
|
||||
wordlist.pop
|
||||
wordlist.join(" ").concat(truncate_string.to_s)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -350,22 +288,7 @@ module Liquid
|
||||
# @liquid_syntax string | split: string
|
||||
# @liquid_return [array[string]]
|
||||
def split(input, pattern)
|
||||
pattern = Utils.to_s(pattern)
|
||||
input = Utils.to_s(input)
|
||||
input.split(pattern)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category string
|
||||
# @liquid_summary
|
||||
# Removes leading and trailing whitespace and collapses consecutive whitespace to a single space.
|
||||
# @liquid_syntax string | squish
|
||||
# @liquid_return [string]
|
||||
def squish(input)
|
||||
return if input.nil?
|
||||
|
||||
Utils.to_s(input).strip.gsub(/\s+/, ' ')
|
||||
input.to_s.split(pattern.to_s)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -376,8 +299,7 @@ module Liquid
|
||||
# @liquid_syntax string | strip
|
||||
# @liquid_return [string]
|
||||
def strip(input)
|
||||
input = Utils.to_s(input)
|
||||
input.strip
|
||||
input.to_s.strip
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -388,8 +310,7 @@ module Liquid
|
||||
# @liquid_syntax string | lstrip
|
||||
# @liquid_return [string]
|
||||
def lstrip(input)
|
||||
input = Utils.to_s(input)
|
||||
input.lstrip
|
||||
input.to_s.lstrip
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -400,8 +321,7 @@ module Liquid
|
||||
# @liquid_syntax string | rstrip
|
||||
# @liquid_return [string]
|
||||
def rstrip(input)
|
||||
input = Utils.to_s(input)
|
||||
input.rstrip
|
||||
input.to_s.rstrip
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -412,9 +332,8 @@ module Liquid
|
||||
# @liquid_syntax string | strip_html
|
||||
# @liquid_return [string]
|
||||
def strip_html(input)
|
||||
input = Utils.to_s(input)
|
||||
empty = ''
|
||||
result = input.gsub(STRIP_HTML_BLOCKS, empty)
|
||||
result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty)
|
||||
result.gsub!(STRIP_HTML_TAGS, empty)
|
||||
result
|
||||
end
|
||||
@@ -427,8 +346,7 @@ module Liquid
|
||||
# @liquid_syntax string | strip_newlines
|
||||
# @liquid_return [string]
|
||||
def strip_newlines(input)
|
||||
input = Utils.to_s(input)
|
||||
input.gsub(/\r?\n/, '')
|
||||
input.to_s.gsub(/\r?\n/, '')
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -439,7 +357,6 @@ module Liquid
|
||||
# @liquid_syntax array | join
|
||||
# @liquid_return [string]
|
||||
def join(input, glue = ' ')
|
||||
glue = Utils.to_s(glue)
|
||||
InputIterator.new(input, context).join(glue)
|
||||
end
|
||||
|
||||
@@ -507,59 +424,29 @@ module Liquid
|
||||
# @liquid_syntax array | where: string, string
|
||||
# @liquid_return [array[untyped]]
|
||||
def where(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value) { |ary, &block| ary.select(&block) }
|
||||
end
|
||||
ary = InputIterator.new(input, context)
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Filters an array to exclude items with a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | reject: string, string
|
||||
# @liquid_return [array[untyped]]
|
||||
def reject(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) }
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Tests if any item in an array has a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | has: string, string
|
||||
# @liquid_return [boolean]
|
||||
def has(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) }
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Returns the first item in an array with a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | find: string, string
|
||||
# @liquid_return [untyped]
|
||||
def find(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) }
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category array
|
||||
# @liquid_summary
|
||||
# Returns the index of the first item in an array with a specific property value.
|
||||
# @liquid_description
|
||||
# This requires you to provide both the property name and the associated value.
|
||||
# @liquid_syntax array | find_index: string, string
|
||||
# @liquid_return [number]
|
||||
def find_index(input, property, target_value = nil)
|
||||
filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) }
|
||||
if ary.empty?
|
||||
[]
|
||||
elsif target_value.nil?
|
||||
ary.select do |item|
|
||||
item[property]
|
||||
rescue TypeError
|
||||
raise_property_error(property)
|
||||
rescue NoMethodError
|
||||
return nil unless item.respond_to?(:[])
|
||||
raise
|
||||
end
|
||||
else
|
||||
ary.select do |item|
|
||||
item[property] == target_value
|
||||
rescue TypeError
|
||||
raise_property_error(property)
|
||||
rescue NoMethodError
|
||||
return nil unless item.respond_to?(:[])
|
||||
raise
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -656,10 +543,7 @@ module Liquid
|
||||
# @liquid_syntax string | replace: string, string
|
||||
# @liquid_return [string]
|
||||
def replace(input, string, replacement = '')
|
||||
string = Utils.to_s(string)
|
||||
replacement = Utils.to_s(replacement)
|
||||
input = Utils.to_s(input)
|
||||
input.gsub(string, replacement)
|
||||
input.to_s.gsub(string.to_s, replacement.to_s)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -670,10 +554,7 @@ module Liquid
|
||||
# @liquid_syntax string | replace_first: string, string
|
||||
# @liquid_return [string]
|
||||
def replace_first(input, string, replacement = '')
|
||||
string = Utils.to_s(string)
|
||||
replacement = Utils.to_s(replacement)
|
||||
input = Utils.to_s(input)
|
||||
input.sub(string, replacement)
|
||||
input.to_s.sub(string.to_s, replacement.to_s)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -684,9 +565,9 @@ module Liquid
|
||||
# @liquid_syntax string | replace_last: string, string
|
||||
# @liquid_return [string]
|
||||
def replace_last(input, string, replacement)
|
||||
input = Utils.to_s(input)
|
||||
string = Utils.to_s(string)
|
||||
replacement = Utils.to_s(replacement)
|
||||
input = input.to_s
|
||||
string = string.to_s
|
||||
replacement = replacement.to_s
|
||||
|
||||
start_index = input.rindex(string)
|
||||
|
||||
@@ -738,9 +619,7 @@ module Liquid
|
||||
# @liquid_syntax string | append: string
|
||||
# @liquid_return [string]
|
||||
def append(input, string)
|
||||
input = Utils.to_s(input)
|
||||
string = Utils.to_s(string)
|
||||
input + string
|
||||
input.to_s + string.to_s
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -769,9 +648,7 @@ module Liquid
|
||||
# @liquid_syntax string | prepend: string
|
||||
# @liquid_return [string]
|
||||
def prepend(input, string)
|
||||
input = Utils.to_s(input)
|
||||
string = Utils.to_s(string)
|
||||
string + input
|
||||
string.to_s + input.to_s
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -782,20 +659,10 @@ module Liquid
|
||||
# @liquid_syntax string | newline_to_br
|
||||
# @liquid_return [string]
|
||||
def newline_to_br(input)
|
||||
input = Utils.to_s(input)
|
||||
input.gsub(/\r?\n/, "<br />\n")
|
||||
input.to_s.gsub(/\r?\n/, "<br />\n")
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category date
|
||||
# @liquid_summary
|
||||
# Formats a date according to a specified format string.
|
||||
# @liquid_description
|
||||
# This filter formats a date using various format specifiers. If the format string is empty,
|
||||
# the original input is returned. If the input cannot be converted to a date, the original input is returned.
|
||||
#
|
||||
# The following format specifiers can be used:
|
||||
# Reformat a date using Ruby's core Time#strftime( string ) -> string
|
||||
#
|
||||
# %a - The abbreviated weekday name (``Sun'')
|
||||
# %A - The full weekday name (``Sunday'')
|
||||
@@ -824,15 +691,14 @@ module Liquid
|
||||
# %Y - Year with century
|
||||
# %Z - Time zone name
|
||||
# %% - Literal ``%'' character
|
||||
# @liquid_syntax date | date: string
|
||||
# @liquid_return [string]
|
||||
#
|
||||
# See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
|
||||
def date(input, format)
|
||||
str_format = Utils.to_s(format)
|
||||
return input if str_format.empty?
|
||||
return input if format.to_s.empty?
|
||||
|
||||
return input unless (date = Utils.to_date(input))
|
||||
|
||||
date.strftime(str_format)
|
||||
date.strftime(format.to_s)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
@@ -843,8 +709,6 @@ module Liquid
|
||||
# @liquid_syntax array | first
|
||||
# @liquid_return [untyped]
|
||||
def first(array)
|
||||
# ActiveSupport returns "" for empty strings, not nil
|
||||
return array[0] || "" if array.is_a?(String)
|
||||
array.first if array.respond_to?(:first)
|
||||
end
|
||||
|
||||
@@ -856,8 +720,6 @@ module Liquid
|
||||
# @liquid_syntax array | last
|
||||
# @liquid_return [untyped]
|
||||
def last(array)
|
||||
# ActiveSupport returns "" for empty strings, not nil
|
||||
return array[-1] || "" if array.is_a?(String)
|
||||
array.last if array.respond_to?(:last)
|
||||
end
|
||||
|
||||
@@ -1056,27 +918,8 @@ module Liquid
|
||||
|
||||
attr_reader :context
|
||||
|
||||
def filter_array(input, property, target_value, default_value = [], &block)
|
||||
ary = InputIterator.new(input, context)
|
||||
|
||||
return default_value if ary.empty?
|
||||
|
||||
block.call(ary) do |item|
|
||||
if target_value.nil?
|
||||
item[property]
|
||||
else
|
||||
item[property] == target_value
|
||||
end
|
||||
rescue TypeError
|
||||
raise_property_error(property)
|
||||
rescue NoMethodError
|
||||
return nil unless item.respond_to?(:[])
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def raise_property_error(property)
|
||||
raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'"
|
||||
raise Liquid::ArgumentError, "cannot select the property '#{property}'"
|
||||
end
|
||||
|
||||
def apply_operation(input, operand, operation)
|
||||
@@ -1125,18 +968,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def join(glue)
|
||||
first = true
|
||||
output = +""
|
||||
each do |item|
|
||||
if first
|
||||
first = false
|
||||
else
|
||||
output << glue
|
||||
end
|
||||
|
||||
output << Liquid::Utils.to_s(item)
|
||||
end
|
||||
output
|
||||
to_a.join(glue.to_s)
|
||||
end
|
||||
|
||||
def concat(args)
|
||||
@@ -1148,10 +980,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def uniq(&block)
|
||||
to_a.uniq do |item|
|
||||
item = Utils.to_liquid_value(item)
|
||||
block ? yield(item) : item
|
||||
end
|
||||
to_a.uniq(&block)
|
||||
end
|
||||
|
||||
def compact
|
||||
|
||||
@@ -58,31 +58,5 @@ module Liquid
|
||||
rescue ::ArgumentError => e
|
||||
raise Liquid::ArgumentError, e.message, e.backtrace
|
||||
end
|
||||
|
||||
# Arity-specialized filter invocation.
|
||||
# Avoids *args splat allocation for the common 0-arg and 1-arg cases.
|
||||
# `invoke` (general case) still uses *args for 2+ extra arguments.
|
||||
{
|
||||
invoke_single: ['input'],
|
||||
invoke_two: ['input', 'arg1'],
|
||||
}.each do |method_name, params|
|
||||
all_params = (["method"] + params).join(", ")
|
||||
send_params = params.join(", ")
|
||||
# __LINE__ + 1 is a parse-time constant; both generated methods will report
|
||||
# the same file:line in backtraces. The method name in the trace distinguishes them.
|
||||
module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
||||
def #{method_name}(#{all_params})
|
||||
if self.class.invokable?(method)
|
||||
send(method, #{send_params})
|
||||
elsif @context.strict_filters
|
||||
raise Liquid::UndefinedFilter, "undefined filter \#{method}"
|
||||
else
|
||||
input
|
||||
end
|
||||
rescue ::ArgumentError => e
|
||||
raise Liquid::ArgumentError, e.message, e.backtrace
|
||||
end
|
||||
RUBY
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+2
-6
@@ -68,12 +68,8 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def safe_parse_expression(parser)
|
||||
parse_context.safe_parse_expression(parser)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
parse_context.parse_expression(markup, safe: safe)
|
||||
def parse_expression(markup)
|
||||
parse_context.parse_expression(markup)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,6 @@ require_relative "tags/comment"
|
||||
require_relative "tags/raw"
|
||||
require_relative "tags/render"
|
||||
require_relative "tags/cycle"
|
||||
require_relative "tags/doc"
|
||||
|
||||
module Liquid
|
||||
module Tags
|
||||
@@ -43,7 +42,6 @@ module Liquid
|
||||
'if' => If,
|
||||
'echo' => Echo,
|
||||
'tablerow' => TableRow,
|
||||
'doc' => Doc,
|
||||
}.freeze
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,10 +9,6 @@ module Liquid
|
||||
# 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.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_syntax
|
||||
# {% assign variable_name = value %}
|
||||
# @liquid_syntax_keyword variable_name The name of the variable being created.
|
||||
|
||||
@@ -9,10 +9,6 @@ module Liquid
|
||||
# Creates a new variable with a string value.
|
||||
# @liquid_description
|
||||
# You can create complex strings with Liquid logic and variables.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_syntax
|
||||
# {% capture variable %}
|
||||
# value
|
||||
|
||||
+6
-42
@@ -31,7 +31,12 @@ module Liquid
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
@blocks = []
|
||||
parse_with_selected_parser(markup)
|
||||
|
||||
if markup =~ Syntax
|
||||
@left = parse_expression(Regexp.last_match(1))
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case")
|
||||
end
|
||||
end
|
||||
|
||||
def parse(tokens)
|
||||
@@ -86,50 +91,9 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def strict2_parse(markup)
|
||||
parser = @parse_context.new_parser(markup)
|
||||
@left = safe_parse_expression(parser)
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@left = parse_expression(Regexp.last_match(1))
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case")
|
||||
end
|
||||
end
|
||||
|
||||
def record_when_condition(markup)
|
||||
body = new_body
|
||||
|
||||
if strict2_mode?
|
||||
parse_strict2_when(markup, body)
|
||||
else
|
||||
parse_lax_when(markup, body)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_strict2_when(markup, body)
|
||||
parser = @parse_context.new_parser(markup)
|
||||
|
||||
loop do
|
||||
expr = Condition.parse_expression(parse_context, parser.expression, safe: true)
|
||||
block = Condition.new(@left, '==', expr)
|
||||
block.attach(body)
|
||||
@blocks << block
|
||||
|
||||
break unless parser.id?('or') || parser.consume?(:comma)
|
||||
end
|
||||
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def parse_lax_when(markup, body)
|
||||
while markup
|
||||
unless markup =~ WhenSyntax
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
|
||||
|
||||
+13
-72
@@ -17,13 +17,23 @@ module Liquid
|
||||
class Cycle < Tag
|
||||
SimpleSyntax = /\A#{QuotedFragment}+/o
|
||||
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
|
||||
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
|
||||
|
||||
attr_reader :variables
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
case markup
|
||||
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?
|
||||
@@ -55,82 +65,13 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
# cycle [name:] expression(, expression)*
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@variables = []
|
||||
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string)
|
||||
|
||||
first_expression = safe_parse_expression(p)
|
||||
if p.look(:colon)
|
||||
# cycle name: expr1, expr2, ...
|
||||
@name = first_expression
|
||||
@is_named = true
|
||||
p.consume(:colon)
|
||||
# After the colon, parse the first variable (required for named cycles)
|
||||
@variables << maybe_dup_lookup(safe_parse_expression(p))
|
||||
else
|
||||
# cycle expr1, expr2, ...
|
||||
@variables << maybe_dup_lookup(first_expression)
|
||||
end
|
||||
|
||||
# Parse remaining comma-separated expressions
|
||||
while p.consume?(:comma)
|
||||
break if p.look(:end_of_string)
|
||||
|
||||
@variables << maybe_dup_lookup(safe_parse_expression(p))
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
|
||||
unless @is_named
|
||||
@name = @variables.to_s
|
||||
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
|
||||
end
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
case markup
|
||||
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?(UNNAMED_CYCLE_PATTERN)
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
|
||||
end
|
||||
end
|
||||
|
||||
def variables_from_string(markup)
|
||||
markup.split(',').collect do |var|
|
||||
var =~ /\s*(#{QuotedFragment})\s*/o
|
||||
next unless Regexp.last_match(1)
|
||||
|
||||
var = parse_expression(Regexp.last_match(1))
|
||||
maybe_dup_lookup(var)
|
||||
Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil
|
||||
end.compact
|
||||
end
|
||||
|
||||
# For backwards compatibility, whenever a lookup is used in an unnamed cycle,
|
||||
# we make it so that the @variables.to_s produces different strings for cycles
|
||||
# called with the same arguments (since @variables.to_s is used as the cycle counter key)
|
||||
# This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value.
|
||||
# This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter.
|
||||
# I was really scratching my head about this one, but migrating away from this would be more headache
|
||||
# than it's worth. So we're keeping this quirk for now.
|
||||
def maybe_dup_lookup(var)
|
||||
var.is_a?(VariableLookup) ? var.dup : var
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
Array(@node.variables)
|
||||
|
||||
@@ -7,14 +7,10 @@ module Liquid
|
||||
# @liquid_name decrement
|
||||
# @liquid_summary
|
||||
# Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_description
|
||||
# Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
|
||||
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
|
||||
# [snippets](/themes/architecture/snippets) included in the file.
|
||||
# [snippets](/themes/architecture#snippets) included in the file.
|
||||
#
|
||||
# Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
|
||||
# and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share
|
||||
|
||||
@@ -1,81 +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)
|
||||
@body = +""
|
||||
|
||||
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)
|
||||
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
|
||||
return
|
||||
end
|
||||
@body << token unless token.empty?
|
||||
end
|
||||
|
||||
raise_tag_never_closed(block_name)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(_context, output)
|
||||
output
|
||||
end
|
||||
|
||||
def blank?
|
||||
@body.empty?
|
||||
end
|
||||
|
||||
def nodelist
|
||||
[@body]
|
||||
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
|
||||
+34
-56
@@ -20,11 +20,13 @@ module Liquid
|
||||
# @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 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
|
||||
|
||||
attr_reader :collection_name, :variable_name, :limit, :from
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
@@ -70,62 +72,28 @@ module Liquid
|
||||
|
||||
protected
|
||||
|
||||
# Fast byte-level parser for "var in collection [reversed] [limit:N] [offset:N]"
|
||||
def lax_parse(markup)
|
||||
c = @parse_context.cursor
|
||||
c.reset(markup)
|
||||
c.skip_ws
|
||||
|
||||
# Parse variable name
|
||||
var_start = c.pos
|
||||
var_len = c.skip_id
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for") if var_len == 0
|
||||
@variable_name = c.slice(var_start, var_len)
|
||||
|
||||
# Expect "in"
|
||||
c.skip_ws
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for") unless c.expect_id("in")
|
||||
c.skip_ws
|
||||
|
||||
# Parse collection name
|
||||
col_start = c.pos
|
||||
if c.peek_byte == Cursor::LPAREN
|
||||
# Parenthesized range: (1..10)
|
||||
depth = 1
|
||||
c.scan_byte
|
||||
while !c.eos? && depth > 0
|
||||
b = c.scan_byte
|
||||
depth += 1 if b == Cursor::LPAREN
|
||||
depth -= 1 if b == Cursor::RPAREN
|
||||
end
|
||||
else
|
||||
c.skip_fragment
|
||||
end
|
||||
collection_name = c.slice(col_start, c.pos - col_start)
|
||||
|
||||
@name = "#{@variable_name}-#{collection_name}"
|
||||
@collection_name = parse_expression(collection_name)
|
||||
|
||||
c.skip_ws
|
||||
@reversed = c.expect_id("reversed")
|
||||
c.skip_ws
|
||||
|
||||
# Parse limit:/offset: if present.
|
||||
# Cursor doesn't handle key:value attributes — delegate to regex for limit:/offset:.
|
||||
if !c.eos? && (rest = c.slice(c.pos, markup.bytesize - c.pos)).include?(':')
|
||||
rest.scan(TagAttributes) do |key, value|
|
||||
if markup =~ Syntax
|
||||
@variable_name = Regexp.last_match(1)
|
||||
collection_name = Regexp.last_match(2)
|
||||
@reversed = !!Regexp.last_match(3)
|
||||
@name = "#{@variable_name}-#{collection_name}"
|
||||
@collection_name = parse_expression(collection_name)
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
set_attribute(key, value)
|
||||
end
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for")
|
||||
end
|
||||
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')
|
||||
|
||||
collection_name = p.expression
|
||||
@collection_name = parse_expression(collection_name, safe: true)
|
||||
@collection_name = parse_expression(collection_name)
|
||||
|
||||
@name = "#{@variable_name}-#{collection_name}"
|
||||
@reversed = p.id?('reversed')
|
||||
@@ -136,15 +104,13 @@ module Liquid
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
|
||||
end
|
||||
p.consume(:colon)
|
||||
set_attribute(attribute, p.expression, safe: true)
|
||||
set_attribute(attribute, p.expression)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
alias_method :strict2_parse, :strict_parse
|
||||
|
||||
def collection_segment(context)
|
||||
offsets = context.registers[:for] ||= {}
|
||||
|
||||
@@ -152,14 +118,22 @@ module Liquid
|
||||
offsets[@name].to_i
|
||||
else
|
||||
from_value = context.evaluate(@from)
|
||||
from_value.nil? ? 0 : Utils.to_integer(from_value)
|
||||
if from_value.nil?
|
||||
0
|
||||
else
|
||||
Utils.to_integer(from_value)
|
||||
end
|
||||
end
|
||||
|
||||
collection = context.evaluate(@collection_name)
|
||||
collection = collection.to_a if collection.is_a?(Range)
|
||||
|
||||
limit_value = context.evaluate(@limit)
|
||||
to = limit_value && (Utils.to_integer(limit_value) + from)
|
||||
to = if limit_value.nil?
|
||||
nil
|
||||
else
|
||||
Utils.to_integer(limit_value) + from
|
||||
end
|
||||
|
||||
segment = Utils.slice_collection(collection, from, to)
|
||||
segment.reverse! if @reversed
|
||||
@@ -200,21 +174,25 @@ module Liquid
|
||||
output
|
||||
end
|
||||
|
||||
def set_attribute(key, expr, safe: false)
|
||||
def set_attribute(key, expr)
|
||||
case key
|
||||
when 'offset'
|
||||
@from = if expr == 'continue'
|
||||
:continue
|
||||
else
|
||||
parse_expression(expr, safe: safe)
|
||||
parse_expression(expr)
|
||||
end
|
||||
when 'limit'
|
||||
@limit = parse_expression(expr, safe: safe)
|
||||
@limit = parse_expression(expr)
|
||||
end
|
||||
end
|
||||
|
||||
def render_else(context, output)
|
||||
@else_block ? @else_block.render_to_output_buffer(context, output) : output
|
||||
if @else_block
|
||||
@else_block.render_to_output_buffer(context, output)
|
||||
else
|
||||
output
|
||||
end
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
|
||||
+9
-37
@@ -51,17 +51,14 @@ module Liquid
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
idx = 0
|
||||
blocks = @blocks
|
||||
while idx < blocks.length
|
||||
block = blocks[idx]
|
||||
result = block.evaluate(context)
|
||||
result = result.to_liquid_value if result.respond_to?(:to_liquid_value)
|
||||
@blocks.each do |block|
|
||||
result = Liquid::Utils.to_liquid_value(
|
||||
block.evaluate(context),
|
||||
)
|
||||
|
||||
if result
|
||||
return block.attachment.render_to_output_buffer(context, output)
|
||||
end
|
||||
idx += 1
|
||||
end
|
||||
|
||||
output
|
||||
@@ -69,10 +66,6 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def strict2_parse(markup)
|
||||
strict_parse(markup)
|
||||
end
|
||||
|
||||
def push_block(tag, markup)
|
||||
block = if tag == 'else'
|
||||
ElseCondition.new
|
||||
@@ -84,32 +77,11 @@ module Liquid
|
||||
block.attach(new_body)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
Condition.parse_expression(parse_context, markup, safe: safe)
|
||||
def parse_expression(markup)
|
||||
Condition.parse_expression(parse_context, markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
# Fastest path: simple identifier truthiness like "product.available" or "forloop.first"
|
||||
if (simple = Variable.simple_variable_markup(markup))
|
||||
return Condition.new(parse_expression(simple))
|
||||
end
|
||||
|
||||
# Fast path: simple condition without and/or — use Cursor.
|
||||
# The include? pre-checks are both a correctness guard (parse_simple_condition
|
||||
# only handles a single comparison) and a perf gate (avoids cursor allocation
|
||||
# for the compound-condition case that will always fall through to lax_parse).
|
||||
if !markup.include?(' and ') && !markup.include?(' or ')
|
||||
cursor = @parse_context.cursor
|
||||
cursor.reset(markup)
|
||||
if cursor.parse_simple_condition
|
||||
return Condition.new(
|
||||
parse_expression(cursor.cond_left),
|
||||
cursor.cond_op,
|
||||
cursor.cond_right ? parse_expression(cursor.cond_right) : nil,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
expressions = markup.scan(ExpressionsAndOperators)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop =~ Syntax
|
||||
|
||||
@@ -130,7 +102,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
|
||||
@@ -148,9 +120,9 @@ module Liquid
|
||||
end
|
||||
|
||||
def parse_comparison(p)
|
||||
a = parse_expression(p.expression, safe: true)
|
||||
a = parse_expression(p.expression)
|
||||
if (op = p.consume?(:comparison))
|
||||
b = parse_expression(p.expression, safe: true)
|
||||
b = parse_expression(p.expression)
|
||||
Condition.new(a, op, b)
|
||||
else
|
||||
Condition.new(a)
|
||||
|
||||
+19
-45
@@ -6,7 +6,7 @@ module Liquid
|
||||
# @liquid_category theme
|
||||
# @liquid_name include
|
||||
# @liquid_summary
|
||||
# Renders a [snippet](/themes/architecture/snippets).
|
||||
# Renders a [snippet](/themes/architecture#snippets).
|
||||
# @liquid_description
|
||||
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
|
||||
# snippet.
|
||||
@@ -27,7 +27,24 @@ module Liquid
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
|
||||
if markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
variable_name = Regexp.last_match(3)
|
||||
|
||||
@alias_name = Regexp.last_match(5)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@attributes = {}
|
||||
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.include")
|
||||
end
|
||||
end
|
||||
|
||||
def parse(_tokens)
|
||||
@@ -84,49 +101,6 @@ module Liquid
|
||||
alias_method :parse_context, :options
|
||||
private :parse_context
|
||||
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@template_name_expr = safe_parse_expression(p)
|
||||
@variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with")
|
||||
@alias_name = p.consume(:id) if p.id?("as")
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ SYNTAX
|
||||
template_name = Regexp.last_match(1)
|
||||
variable_name = Regexp.last_match(3)
|
||||
|
||||
@alias_name = Regexp.last_match(5)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@attributes = {}
|
||||
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.include")
|
||||
end
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[
|
||||
|
||||
@@ -7,14 +7,10 @@ module Liquid
|
||||
# @liquid_name increment
|
||||
# @liquid_summary
|
||||
# Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_description
|
||||
# Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
|
||||
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
|
||||
# [snippets](/themes/architecture/snippets) included in the file.
|
||||
# [snippets](/themes/architecture#snippets) included in the file.
|
||||
#
|
||||
# Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
|
||||
# and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share
|
||||
|
||||
+17
-51
@@ -6,7 +6,7 @@ module Liquid
|
||||
# @liquid_category theme
|
||||
# @liquid_name render
|
||||
# @liquid_summary
|
||||
# Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
|
||||
# Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
|
||||
# @liquid_description
|
||||
# Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside
|
||||
# of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet)
|
||||
@@ -35,7 +35,22 @@ module Liquid
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
|
||||
@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)
|
||||
|
||||
@attributes = {}
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
end
|
||||
|
||||
def for_loop?
|
||||
@@ -84,55 +99,6 @@ module Liquid
|
||||
output
|
||||
end
|
||||
|
||||
# render (string) (with|for expression)? (as id)? (key: value)*
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@template_name_expr = parse_expression(strict2_template_name(p), safe: true)
|
||||
with_or_for = p.id?("for") || p.id?("with")
|
||||
@variable_name_expr = safe_parse_expression(p) if with_or_for
|
||||
@alias_name = p.consume(:id) if p.id?("as")
|
||||
@is_for_loop = (with_or_for == FOR)
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict2_template_name(p)
|
||||
p.consume(:string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
|
||||
@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)
|
||||
|
||||
@attributes = {}
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[
|
||||
|
||||
@@ -19,54 +19,17 @@ module Liquid
|
||||
# @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 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
|
||||
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
|
||||
|
||||
attr_reader :variable_name, :collection_name, :attributes
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@variable_name = p.consume(:id)
|
||||
|
||||
unless p.id?("in")
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in")
|
||||
end
|
||||
|
||||
@collection_name = safe_parse_expression(p)
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
unless ALLOWED_ATTRIBUTES.include?(key)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.table_row_invalid_attribute", attribute: key)
|
||||
end
|
||||
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@variable_name = Regexp.last_match(1)
|
||||
@collection_name = parse_expression(Regexp.last_match(2))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
module Liquid
|
||||
# Templates are central to liquid.
|
||||
# Interpreting templates is a two step process. First you compile the
|
||||
# Interpretating templates is a two step process. First you compile the
|
||||
# source code you got. During compile time some extensive error checking is performed.
|
||||
# your code should expect to get some SyntaxErrors.
|
||||
#
|
||||
@@ -24,8 +24,7 @@ module Liquid
|
||||
# Sets how strict the parser should be.
|
||||
# :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 enforces correct syntax for most tags
|
||||
# :strict2 enforces correct syntax for all tags
|
||||
# :strict will enforce correct syntax.
|
||||
def error_mode=(mode)
|
||||
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
|
||||
Environment.default.error_mode = mode
|
||||
|
||||
+16
-123
@@ -4,26 +4,17 @@ module Liquid
|
||||
class Tokenizer
|
||||
attr_reader :line_number, :for_liquid_tag
|
||||
|
||||
def initialize(
|
||||
source:,
|
||||
string_scanner: nil,
|
||||
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
|
||||
@line_number = line_number || (line_numbers ? 1 : nil)
|
||||
@for_liquid_tag = for_liquid_tag
|
||||
@source = source.to_s
|
||||
@offset = 0
|
||||
@tokens = []
|
||||
|
||||
tokenize
|
||||
@offset = 0
|
||||
@tokens = tokenize
|
||||
end
|
||||
|
||||
def shift
|
||||
token = @tokens[@offset]
|
||||
|
||||
return unless token
|
||||
return nil unless token
|
||||
|
||||
@offset += 1
|
||||
|
||||
@@ -37,116 +28,18 @@ module Liquid
|
||||
private
|
||||
|
||||
def tokenize
|
||||
if @for_liquid_tag
|
||||
@tokens = @source.split("\n")
|
||||
else
|
||||
tokenize_fast
|
||||
return [] if @source.empty?
|
||||
|
||||
return @source.split("\n") if @for_liquid_tag
|
||||
|
||||
tokens = @source.split(TemplateParser)
|
||||
|
||||
# removes the rogue empty element at the beginning of the array
|
||||
if tokens[0]&.empty?
|
||||
@offset += 1
|
||||
end
|
||||
|
||||
@source = nil
|
||||
end
|
||||
|
||||
# Fast tokenizer using String#byteindex instead of StringScanner regex.
|
||||
# String#byteindex is ~40% faster for finding { delimiters.
|
||||
def tokenize_fast
|
||||
src = @source
|
||||
unless src.valid_encoding?
|
||||
raise SyntaxError, "Invalid byte sequence in #{src.encoding}"
|
||||
end
|
||||
|
||||
len = src.bytesize
|
||||
pos = 0
|
||||
|
||||
while pos < len
|
||||
# Find next { which could start a tag or variable
|
||||
idx = src.byteindex('{', pos)
|
||||
|
||||
unless idx
|
||||
# No more tags/variables — rest is text
|
||||
@tokens << src.byteslice(pos, len - pos) if pos < len
|
||||
break
|
||||
end
|
||||
|
||||
next_byte = idx + 1 < len ? src.getbyte(idx + 1) : nil
|
||||
|
||||
if next_byte == Cursor::PCT # {%
|
||||
# Emit text before tag
|
||||
@tokens << src.byteslice(pos, idx - pos) if idx > pos
|
||||
|
||||
# Find %} to close the tag
|
||||
close = src.byteindex('%}', idx + 2)
|
||||
if close
|
||||
@tokens << src.byteslice(idx, close + 2 - idx)
|
||||
pos = close + 2
|
||||
else
|
||||
# Emit malformed token to propagate a missing-terminator error in the parser
|
||||
@tokens << "{%"
|
||||
pos = idx + 2
|
||||
end
|
||||
elsif next_byte == Cursor::LCURLY # {{
|
||||
# Emit text before variable, then scan for the closing }}.
|
||||
@tokens << src.byteslice(pos, idx - pos) if idx > pos
|
||||
pos = scan_variable_token(src, idx, len)
|
||||
else
|
||||
# Lone '{' — not the start of a tag or variable.
|
||||
# Find the next '{{' or '{%' to know where this text token ends.
|
||||
# Using two byteindex calls avoids a nested loop and is always O(n).
|
||||
tag_start = src.byteindex('{%', idx + 1)
|
||||
var_start = src.byteindex('{{', idx + 1)
|
||||
next_token = [tag_start, var_start].compact.min
|
||||
if next_token
|
||||
@tokens << src.byteslice(pos, next_token - pos)
|
||||
pos = next_token
|
||||
else
|
||||
@tokens << src.byteslice(pos, len - pos)
|
||||
pos = len
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Scans a {{ ... }} variable token starting at `idx` in `src`.
|
||||
# Emits the token to @tokens and returns the new position after the token.
|
||||
# Handles }}, single }, and embedded {% ... %} (nested tag inside variable).
|
||||
private def scan_variable_token(src, idx, len)
|
||||
# Byte-by-byte scan: find } or {, then inspect the next byte.
|
||||
scan_pos = idx + 2
|
||||
while scan_pos < len
|
||||
b = src.getbyte(scan_pos)
|
||||
if b == Cursor::RCURLY # }
|
||||
if scan_pos + 1 >= len
|
||||
# } at end of string — emit token up to here
|
||||
@tokens << src.byteslice(idx, scan_pos + 1 - idx)
|
||||
return scan_pos + 1
|
||||
end
|
||||
b2 = src.getbyte(scan_pos + 1)
|
||||
if b2 == Cursor::RCURLY
|
||||
# Found }} — close variable
|
||||
@tokens << src.byteslice(idx, scan_pos + 2 - idx)
|
||||
return scan_pos + 2
|
||||
else
|
||||
# } followed by non-} — emit token up to here (matches original: @ss.pos -= 1)
|
||||
@tokens << src.byteslice(idx, scan_pos + 1 - idx)
|
||||
return scan_pos + 1
|
||||
end
|
||||
elsif b == Cursor::LCURLY && scan_pos + 1 < len && src.getbyte(scan_pos + 1) == Cursor::PCT
|
||||
# Found {% inside {{ — scan to %} and emit as one token
|
||||
close = src.byteindex('%}', scan_pos + 2)
|
||||
if close
|
||||
@tokens << src.byteslice(idx, close + 2 - idx)
|
||||
return close + 2
|
||||
else
|
||||
@tokens << src.byteslice(idx, len - idx)
|
||||
return len
|
||||
end
|
||||
else
|
||||
scan_pos += 1
|
||||
end
|
||||
end
|
||||
|
||||
# Reached end without finding }} — malformed
|
||||
@tokens << "{{"
|
||||
idx + 2
|
||||
tokens
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+13
-117
@@ -2,15 +2,9 @@
|
||||
|
||||
module Liquid
|
||||
module Utils
|
||||
DECIMAL_REGEX = /\A-?\d+\.\d+\z/
|
||||
UNIX_TIMESTAMP_REGEX = /\A\d+\z/
|
||||
|
||||
def self.slice_collection(collection, from, to)
|
||||
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
|
||||
collection.load_slice(from, to)
|
||||
elsif from == 0 && to.nil? && collection.is_a?(Array)
|
||||
# Fast path: no offset/limit on an Array — return as-is (avoid copy)
|
||||
collection
|
||||
else
|
||||
slice_collection_using_each(collection, from, to)
|
||||
end
|
||||
@@ -18,17 +12,23 @@ module Liquid
|
||||
|
||||
def self.slice_collection_using_each(collection, from, to)
|
||||
segments = []
|
||||
index = 0
|
||||
|
||||
# String is Enumerable but #each is not defined; handle it as a single-element collection
|
||||
# Maintains Ruby 1.8.7 String#each behaviour on 1.9
|
||||
if collection.is_a?(String)
|
||||
return collection.empty? ? [] : [collection]
|
||||
end
|
||||
return [] unless collection.respond_to?(:each)
|
||||
|
||||
index = 0
|
||||
collection.each do |item|
|
||||
break if to && to <= index
|
||||
segments << item if from <= index
|
||||
if to && to <= index
|
||||
break
|
||||
end
|
||||
|
||||
if from <= index
|
||||
segments << item
|
||||
end
|
||||
|
||||
index += 1
|
||||
end
|
||||
|
||||
@@ -52,7 +52,7 @@ module Liquid
|
||||
when Numeric
|
||||
obj
|
||||
when String
|
||||
DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
|
||||
/\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
|
||||
else
|
||||
if obj.respond_to?(:to_number)
|
||||
obj.to_number
|
||||
@@ -66,14 +66,14 @@ module Liquid
|
||||
return obj if obj.respond_to?(:strftime)
|
||||
|
||||
if obj.is_a?(String)
|
||||
return if obj.empty?
|
||||
return nil if obj.empty?
|
||||
obj = obj.downcase
|
||||
end
|
||||
|
||||
case obj
|
||||
when 'now', 'today'
|
||||
Time.now
|
||||
when UNIX_TIMESTAMP_REGEX, Integer
|
||||
when /\A\d+\z/, Integer
|
||||
Time.at(obj.to_i)
|
||||
when String
|
||||
Time.parse(obj)
|
||||
@@ -89,109 +89,5 @@ module Liquid
|
||||
# Otherwise return the object itself
|
||||
obj
|
||||
end
|
||||
|
||||
# Cached string representations for common small integers (0-999)
|
||||
# Avoids repeated Integer#to_s allocations during rendering
|
||||
SMALL_INT_STRINGS = Array.new(1000) { |i| i.to_s.freeze }.freeze
|
||||
|
||||
def self.to_s(obj, seen = nil)
|
||||
case obj
|
||||
when Integer
|
||||
obj >= 0 && obj < 1000 ? SMALL_INT_STRINGS[obj] : obj.to_s
|
||||
when BigDecimal
|
||||
obj.to_s("F")
|
||||
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 = nil)
|
||||
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
|
||||
|
||||
+20
-385
@@ -12,26 +12,6 @@ module Liquid
|
||||
# {{ user | link }}
|
||||
#
|
||||
class Variable
|
||||
# Checks if markup is a simple "name.lookup.chain" with no filters/brackets/quotes.
|
||||
# Returns the trimmed markup string, or nil if not simple.
|
||||
def self.simple_variable_markup(markup)
|
||||
return if markup.empty?
|
||||
return unless markup.match?(SIMPLE_VARIABLE_RE)
|
||||
# Avoid allocation when there's no surrounding whitespace (the common case)
|
||||
first = markup.getbyte(0)
|
||||
last = markup.getbyte(markup.bytesize - 1)
|
||||
needs_strip = first == Cursor::SPACE || first == Cursor::TAB || first == Cursor::NL || first == Cursor::CR ||
|
||||
last == Cursor::SPACE || last == Cursor::TAB || last == Cursor::NL || last == Cursor::CR
|
||||
needs_strip ? markup.strip : markup
|
||||
end
|
||||
|
||||
# Cache for [filtername, EMPTY_ARRAY] tuples — avoids repeated array creation
|
||||
NO_ARG_FILTER_CACHE = Hash.new { |h, k| h[k] = [k, Const::EMPTY_ARRAY].freeze }
|
||||
|
||||
# Regex for a simple variable lookup with optional surrounding whitespace.
|
||||
# Shares the identifier grammar with VariableLookup::SIMPLE_LOOKUP_RE.
|
||||
SIMPLE_VARIABLE_RE = /\A\s*[\w-]+\??(?:\.[\w-]+\??)*\s*\z/
|
||||
|
||||
FilterMarkupRegex = /#{FilterSeparator}\s*(.*)/om
|
||||
FilterParser = /(?:\s+|#{QuotedFragment}|#{ArgumentSeparator})+/o
|
||||
FilterArgsRegex = /(?:#{FilterArgumentSeparator}|#{ArgumentSeparator})\s*((?:\w+\s*\:\s*)?#{QuotedFragment})/o
|
||||
@@ -50,278 +30,7 @@ module Liquid
|
||||
@parse_context = parse_context
|
||||
@line_number = parse_context.line_number
|
||||
|
||||
# Fast path: try to parse without going through Lexer → Parser
|
||||
# Skip for strict2/rigid modes which require different parsing
|
||||
# Fast path only for lax/warn modes — strict modes need full error checking
|
||||
error_mode = parse_context.error_mode
|
||||
if error_mode == :strict2 || error_mode == :rigid || error_mode == :strict || !try_fast_parse(markup, parse_context)
|
||||
strict_parse_with_error_mode_fallback(markup)
|
||||
end
|
||||
end
|
||||
|
||||
private def try_fast_parse(markup, parse_context)
|
||||
pos = fast_scan_name(markup)
|
||||
return false unless pos
|
||||
|
||||
# fast_resolve_name calls VariableLookup.parse_simple / Expression::LITERALS — the
|
||||
# only sites that can raise SyntaxError on malformed input. The byte scanners return
|
||||
# false instead of raising.
|
||||
begin
|
||||
fast_resolve_name(markup, parse_context)
|
||||
rescue SyntaxError
|
||||
return false
|
||||
end
|
||||
|
||||
# End of markup — no filters
|
||||
if pos >= markup.bytesize
|
||||
@filters = Const::EMPTY_ARRAY
|
||||
return true
|
||||
end
|
||||
|
||||
# Must be followed by a pipe filter separator
|
||||
return false unless markup.getbyte(pos) == Cursor::PIPE
|
||||
|
||||
fast_scan_filters(markup, pos, parse_context)
|
||||
end
|
||||
|
||||
# Scan the variable name (quoted string or identifier chain) at the start of markup.
|
||||
# Returns the position after the name + trailing whitespace, or false on failure.
|
||||
# Sets @_fast_name_start and @_fast_name_end for fast_resolve_name.
|
||||
private def fast_scan_name(markup)
|
||||
len = markup.bytesize
|
||||
return false if len == 0
|
||||
|
||||
# Skip leading whitespace
|
||||
pos = 0
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
break unless b == Cursor::SPACE || b == Cursor::TAB || b == Cursor::NL || b == Cursor::CR
|
||||
pos += 1
|
||||
end
|
||||
return false if pos >= len
|
||||
|
||||
b = markup.getbyte(pos)
|
||||
|
||||
if b == Cursor::QUOTE_S || b == Cursor::QUOTE_D
|
||||
# Quoted string literal: scan to matching close quote
|
||||
quote = b
|
||||
@_fast_name_start = pos
|
||||
pos += 1
|
||||
pos += 1 while pos < len && markup.getbyte(pos) != quote
|
||||
pos += 1 if pos < len # skip closing quote
|
||||
@_fast_name_end = pos
|
||||
elsif ByteTables::IDENT_START[b]
|
||||
# Identifier chain: [a-zA-Z_][a-zA-Z0-9_-]*(.[a-zA-Z_][a-zA-Z0-9_-]*)*
|
||||
@_fast_name_start = pos
|
||||
pos += 1
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
if ByteTables::IDENT_CONT[b]
|
||||
pos += 1
|
||||
elsif b == Cursor::DOT
|
||||
pos += 1
|
||||
return false if pos >= len
|
||||
b = markup.getbyte(pos)
|
||||
return false unless ByteTables::IDENT_START[b]
|
||||
pos += 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
@_fast_name_end = pos
|
||||
else
|
||||
return false
|
||||
end
|
||||
|
||||
# Skip whitespace after name
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
break unless b == Cursor::SPACE || b == Cursor::TAB || b == Cursor::NL || b == Cursor::CR
|
||||
pos += 1
|
||||
end
|
||||
|
||||
pos
|
||||
end
|
||||
|
||||
# Resolve the scanned name bytes to a Liquid expression object.
|
||||
# Reads @_fast_name_start / @_fast_name_end set by fast_scan_name.
|
||||
# Sets @name. May raise SyntaxError (rescued in try_fast_parse).
|
||||
private def fast_resolve_name(markup, parse_context)
|
||||
name_start = @_fast_name_start
|
||||
name_end = @_fast_name_end
|
||||
len = markup.bytesize
|
||||
|
||||
# Avoid byteslice when the name spans the whole markup (no surrounding whitespace/filters)
|
||||
expr_markup = name_start == 0 && name_end == len ? markup : markup.byteslice(name_start, name_end - name_start)
|
||||
|
||||
cache = parse_context.expression_cache
|
||||
ss = parse_context.string_scanner
|
||||
|
||||
first_byte = expr_markup.getbyte(0)
|
||||
@name = if first_byte == Cursor::QUOTE_S || first_byte == Cursor::QUOTE_D
|
||||
# String literal — strip enclosing quotes
|
||||
expr_markup.byteslice(1, expr_markup.bytesize - 2)
|
||||
elsif Expression::LITERALS.key?(expr_markup)
|
||||
Expression::LITERALS[expr_markup]
|
||||
elsif cache
|
||||
cache[expr_markup] || (cache[expr_markup] = VariableLookup.parse_simple(expr_markup, ss, cache).freeze)
|
||||
else
|
||||
VariableLookup.parse_simple(expr_markup, ss || StringScanner.new(""), nil).freeze
|
||||
end
|
||||
end
|
||||
|
||||
# Scan the filter chain starting at `pos` (the first '|').
|
||||
# Returns true on success (sets @filters), false to fall back to the Lexer.
|
||||
# Rescues SyntaxError from Expression.parse inside fast_scan_filter_args.
|
||||
private def fast_scan_filters(markup, pos, parse_context)
|
||||
len = markup.bytesize
|
||||
@filters = []
|
||||
filter_pos = pos
|
||||
|
||||
while filter_pos < len && markup.getbyte(filter_pos) == Cursor::PIPE
|
||||
filter_pos += 1
|
||||
# Skip spaces after pipe (tabs/newlines handled in the between-filters skip below)
|
||||
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == Cursor::SPACE
|
||||
|
||||
# Scan filter name: must start with [a-zA-Z_]
|
||||
fname_start = filter_pos
|
||||
b = filter_pos < len ? markup.getbyte(filter_pos) : nil
|
||||
break unless b && ByteTables::IDENT_START[b]
|
||||
filter_pos += 1
|
||||
while filter_pos < len
|
||||
b = markup.getbyte(filter_pos)
|
||||
break unless ByteTables::IDENT_CONT[b]
|
||||
filter_pos += 1
|
||||
end
|
||||
filtername = markup.byteslice(fname_start, filter_pos - fname_start)
|
||||
|
||||
# Skip whitespace after filter name
|
||||
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == Cursor::SPACE
|
||||
|
||||
if filter_pos < len && markup.getbyte(filter_pos) == Cursor::COLON
|
||||
# Has arguments — fast-scan positional args; fall to Lexer on keyword args
|
||||
filter_pos += 1 # skip ':'
|
||||
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == Cursor::SPACE
|
||||
|
||||
result = fast_scan_filter_args(markup, filter_pos, parse_context)
|
||||
return fall_to_lexer_filters(markup, pos, fname_start, len, parse_context) if result == :fall_to_lexer
|
||||
|
||||
filter_args, filter_pos = result
|
||||
@filters << [filtername, filter_args]
|
||||
else
|
||||
# No-arg filter — reuse the cached [name, EMPTY_ARRAY] tuple
|
||||
@filters << NO_ARG_FILTER_CACHE[filtername]
|
||||
end
|
||||
|
||||
# Skip whitespace (including tabs and newlines) between filters
|
||||
filter_pos += 1 while filter_pos < len && (
|
||||
markup.getbyte(filter_pos) == Cursor::SPACE ||
|
||||
markup.getbyte(filter_pos) == Cursor::TAB ||
|
||||
markup.getbyte(filter_pos) == Cursor::NL ||
|
||||
markup.getbyte(filter_pos) == Cursor::CR
|
||||
)
|
||||
end
|
||||
|
||||
# Trailing bytes that aren't a pipe mean something the fast path doesn't handle
|
||||
return false if filter_pos < len
|
||||
|
||||
@filters = Const::EMPTY_ARRAY if @filters.empty?
|
||||
true
|
||||
rescue SyntaxError
|
||||
# Expression.parse (called inside fast_scan_filter_args for identifier args) can
|
||||
# raise SyntaxError on malformed input. Fall back to full Lexer parse.
|
||||
@name = nil
|
||||
@filters = nil
|
||||
false
|
||||
end
|
||||
|
||||
# Called when fast_scan_filter_args encounters keyword args or an unrecognised
|
||||
# token. Hands the remaining filter chain (from the pipe before fname_start)
|
||||
# to the full Lexer-based parser, merges results into @filters, and returns true.
|
||||
private def fall_to_lexer_filters(markup, pos, fname_start, len, parse_context)
|
||||
# Walk back from fname_start to find the pipe that opened this filter.
|
||||
# Equivalent to: markup.rindex('|', fname_start), bounded by pos.
|
||||
rest_start = fname_start
|
||||
rest_start -= 1 while rest_start > pos && markup.getbyte(rest_start) != Cursor::PIPE
|
||||
rest_markup = markup.byteslice(rest_start, len - rest_start)
|
||||
p = parse_context.new_parser(rest_markup)
|
||||
while p.consume?(:pipe)
|
||||
fn = p.consume(:id)
|
||||
fa = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
|
||||
@filters << lax_parse_filter_expressions(fn, fa)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
@filters = Const::EMPTY_ARRAY if @filters.empty?
|
||||
true
|
||||
end
|
||||
|
||||
# Scan positional filter arguments starting at `filter_pos`.
|
||||
# Returns [filter_args_array, new_filter_pos] on success, or :fall_to_lexer when
|
||||
# keyword args or unrecognised tokens are encountered.
|
||||
private def fast_scan_filter_args(markup, filter_pos, parse_context)
|
||||
len = markup.bytesize
|
||||
filter_args = []
|
||||
|
||||
loop do
|
||||
arg_start = filter_pos
|
||||
b = filter_pos < len ? markup.getbyte(filter_pos) : nil
|
||||
|
||||
if b == Cursor::QUOTE_S || b == Cursor::QUOTE_D
|
||||
# Quoted string argument
|
||||
quote = b
|
||||
filter_pos += 1
|
||||
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) != quote
|
||||
filter_pos += 1 if filter_pos < len # skip closing quote
|
||||
filter_args << markup.byteslice(arg_start + 1, filter_pos - arg_start - 2)
|
||||
|
||||
elsif b && (ByteTables::DIGIT[b] ||
|
||||
(b == Cursor::DASH && filter_pos + 1 < len && ByteTables::DIGIT[markup.getbyte(filter_pos + 1)]))
|
||||
# Numeric argument (integer or float, optionally negative)
|
||||
filter_pos += 1 if b == Cursor::DASH
|
||||
filter_pos += 1 while filter_pos < len && ByteTables::DIGIT[markup.getbyte(filter_pos)]
|
||||
if filter_pos < len && markup.getbyte(filter_pos) == Cursor::DOT # float
|
||||
filter_pos += 1
|
||||
filter_pos += 1 while filter_pos < len && ByteTables::DIGIT[markup.getbyte(filter_pos)]
|
||||
end
|
||||
num_str = markup.byteslice(arg_start, filter_pos - arg_start)
|
||||
filter_args << (num_str.include?('.') ? num_str.to_f : num_str.to_i)
|
||||
|
||||
elsif b && ByteTables::IDENT_START[b]
|
||||
# Identifier argument — may be a variable lookup or keyword arg
|
||||
id_start = filter_pos
|
||||
filter_pos += 1
|
||||
while filter_pos < len
|
||||
b2 = markup.getbyte(filter_pos)
|
||||
break unless ByteTables::IDENT_CONT[b2] || b2 == Cursor::DOT
|
||||
filter_pos += 1
|
||||
end
|
||||
filter_pos += 1 if filter_pos < len && markup.getbyte(filter_pos) == Cursor::QMARK
|
||||
|
||||
# Peek past whitespace: if followed by ':', this is a keyword arg → fall to Lexer
|
||||
kw_check = filter_pos
|
||||
kw_check += 1 while kw_check < len && markup.getbyte(kw_check) == Cursor::SPACE
|
||||
return :fall_to_lexer if kw_check < len && markup.getbyte(kw_check) == Cursor::COLON
|
||||
|
||||
id_markup = markup.byteslice(id_start, filter_pos - id_start)
|
||||
filter_args << Expression.parse(id_markup, parse_context.string_scanner, parse_context.expression_cache)
|
||||
|
||||
else
|
||||
return :fall_to_lexer
|
||||
end
|
||||
|
||||
# Skip whitespace after argument
|
||||
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == Cursor::SPACE
|
||||
|
||||
# Comma: more arguments follow; anything else: done with this filter's args
|
||||
if filter_pos < len && markup.getbyte(filter_pos) == Cursor::COMMA
|
||||
filter_pos += 1
|
||||
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == Cursor::SPACE
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
[filter_args, filter_pos]
|
||||
strict_parse_with_error_mode_fallback(markup)
|
||||
end
|
||||
|
||||
def raw
|
||||
@@ -333,7 +42,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
@filters = Const::EMPTY_ARRAY
|
||||
@filters = []
|
||||
return unless markup =~ MarkupWithQuotedFragment
|
||||
|
||||
name_markup = Regexp.last_match(1)
|
||||
@@ -345,38 +54,22 @@ module Liquid
|
||||
next unless f =~ /\w+/
|
||||
filtername = Regexp.last_match(0)
|
||||
filterargs = f.scan(FilterArgsRegex).flatten
|
||||
@filters = [] if @filters.frozen?
|
||||
@filters << lax_parse_filter_expressions(filtername, filterargs)
|
||||
@filters << parse_filter_expressions(filtername, filterargs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
@filters = Const::EMPTY_ARRAY
|
||||
p = @parse_context.new_parser(markup)
|
||||
@filters = []
|
||||
p = Parser.new(markup)
|
||||
|
||||
return if p.look(:end_of_string)
|
||||
|
||||
@name = parse_context.safe_parse_expression(p)
|
||||
@name = parse_context.parse_expression(p.expression)
|
||||
while p.consume?(:pipe)
|
||||
@filters = [] if @filters.frozen?
|
||||
filtername = p.consume(:id)
|
||||
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
|
||||
@filters << lax_parse_filter_expressions(filtername, filterargs)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
@filters = Const::EMPTY_ARRAY
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
return if p.look(:end_of_string)
|
||||
|
||||
@name = parse_context.safe_parse_expression(p)
|
||||
while p.consume?(:pipe)
|
||||
@filters = [] if @filters.frozen?
|
||||
@filters << strict2_parse_filter_expressions(p)
|
||||
@filters << parse_filter_expressions(filtername, filterargs)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
@@ -393,42 +86,29 @@ module Liquid
|
||||
obj = context.evaluate(@name)
|
||||
|
||||
@filters.each do |filter_name, filter_args, filter_kwargs|
|
||||
if filter_args.empty? && !filter_kwargs
|
||||
obj = context.invoke_single(filter_name, obj)
|
||||
elsif !filter_kwargs && filter_args.length == 1
|
||||
# Single positional arg — most common after no-arg
|
||||
obj = context.invoke_two(filter_name, obj, context.evaluate(filter_args[0]))
|
||||
else
|
||||
filter_args = evaluate_filter_expressions(context, filter_args, filter_kwargs)
|
||||
obj = context.invoke(filter_name, obj, *filter_args)
|
||||
end
|
||||
filter_args = evaluate_filter_expressions(context, filter_args, filter_kwargs)
|
||||
obj = context.invoke(filter_name, obj, *filter_args)
|
||||
end
|
||||
|
||||
context.apply_global_filter(obj)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
# Fast path: no filters and no global filter
|
||||
obj = if @filters.empty? && context.global_filter.nil?
|
||||
context.evaluate(@name)
|
||||
else
|
||||
render(context)
|
||||
end
|
||||
obj = render(context)
|
||||
render_obj_to_output(obj, output)
|
||||
output
|
||||
end
|
||||
|
||||
def render_obj_to_output(obj, output)
|
||||
if obj.instance_of?(String)
|
||||
output << obj
|
||||
elsif obj.nil?
|
||||
case obj
|
||||
when NilClass
|
||||
# Do nothing
|
||||
elsif obj.instance_of?(Array)
|
||||
when Array
|
||||
obj.each do |o|
|
||||
render_obj_to_output(o, output)
|
||||
end
|
||||
else
|
||||
output << Liquid::Utils.to_s(obj)
|
||||
when
|
||||
output << obj.to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -437,17 +117,16 @@ module Liquid
|
||||
end
|
||||
|
||||
def disabled_tags
|
||||
Const::EMPTY_ARRAY
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lax_parse_filter_expressions(filter_name, unparsed_args)
|
||||
def parse_filter_expressions(filter_name, unparsed_args)
|
||||
filter_args = []
|
||||
keyword_args = nil
|
||||
unparsed_args.each do |a|
|
||||
# Fast check: keyword args must contain ':'
|
||||
if a.include?(':') && (matches = a.match(JustTagAttributes))
|
||||
if (matches = a.match(JustTagAttributes))
|
||||
keyword_args ||= {}
|
||||
keyword_args[matches[1]] = parse_context.parse_expression(matches[2])
|
||||
else
|
||||
@@ -459,60 +138,16 @@ module Liquid
|
||||
result
|
||||
end
|
||||
|
||||
# Surprisingly, positional and keyword arguments can be mixed.
|
||||
#
|
||||
# filter = filtername [":" filterargs?]
|
||||
# filterargs = argument ("," argument)*
|
||||
# argument = (positional_argument | keyword_argument)
|
||||
# positional_argument = expression
|
||||
# keyword_argument = id ":" expression
|
||||
def strict2_parse_filter_expressions(p)
|
||||
filtername = p.consume(:id)
|
||||
filter_args = []
|
||||
keyword_args = {}
|
||||
|
||||
if p.consume?(:colon)
|
||||
# Parse first argument (no leading comma)
|
||||
argument(p, filter_args, keyword_args) unless end_of_arguments?(p)
|
||||
|
||||
# Parse remaining arguments (with leading commas) and optional trailing comma
|
||||
argument(p, filter_args, keyword_args) while p.consume?(:comma) && !end_of_arguments?(p)
|
||||
end
|
||||
|
||||
result = [filtername, filter_args]
|
||||
result << keyword_args unless keyword_args.empty?
|
||||
result
|
||||
end
|
||||
|
||||
def argument(p, positional_arguments, keyword_arguments)
|
||||
if p.look(:id) && p.look(:colon, 1)
|
||||
key = p.consume(:id)
|
||||
p.consume(:colon)
|
||||
value = parse_context.safe_parse_expression(p)
|
||||
keyword_arguments[key] = value
|
||||
else
|
||||
positional_arguments << parse_context.safe_parse_expression(p)
|
||||
end
|
||||
end
|
||||
|
||||
def end_of_arguments?(p)
|
||||
p.look(:pipe) || p.look(:end_of_string)
|
||||
end
|
||||
|
||||
def evaluate_filter_expressions(context, filter_args, filter_kwargs)
|
||||
parsed_args = filter_args.map { |expr| context.evaluate(expr) }
|
||||
if filter_kwargs
|
||||
parsed_args = filter_args.map { |expr| context.evaluate(expr) }
|
||||
parsed_kwargs = {}
|
||||
filter_kwargs.each do |key, expr|
|
||||
parsed_kwargs[key] = context.evaluate(expr)
|
||||
end
|
||||
parsed_args << parsed_kwargs
|
||||
parsed_args
|
||||
elsif filter_args.empty?
|
||||
Const::EMPTY_ARRAY
|
||||
else
|
||||
filter_args.map { |expr| context.evaluate(expr) }
|
||||
end
|
||||
parsed_args
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
|
||||
+22
-157
@@ -6,130 +6,26 @@ module Liquid
|
||||
|
||||
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
|
||||
|
||||
# Fast parse that skips simple_lookup? check — caller guarantees simple identifier chain
|
||||
def self.parse_simple(markup, string_scanner = nil, cache = nil)
|
||||
new(markup, string_scanner, cache, true)
|
||||
end
|
||||
|
||||
# Fast manual scanner replacing markup.scan(VariableParser)
|
||||
# VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|[\w-]+\??/
|
||||
# Splits "product.variants[0].title" into ["product", "variants", "[0]", "title"]
|
||||
def self.scan_variable(markup)
|
||||
result = []
|
||||
pos = 0
|
||||
len = markup.bytesize
|
||||
|
||||
while pos < len
|
||||
byte = markup.getbyte(pos)
|
||||
|
||||
if byte == 91 # '['
|
||||
# Scan balanced brackets
|
||||
depth = 1
|
||||
start = pos
|
||||
pos += 1
|
||||
while pos < len && depth > 0
|
||||
b = markup.getbyte(pos)
|
||||
depth += 1 if b == 91
|
||||
depth -= 1 if b == 93
|
||||
pos += 1
|
||||
end
|
||||
if depth == 0
|
||||
result << markup.byteslice(start, pos - start)
|
||||
else
|
||||
# Unbalanced bracket - skip '[' and continue
|
||||
pos = start + 1
|
||||
end
|
||||
elsif byte == 46 # '.'
|
||||
pos += 1
|
||||
elsif ByteTables::IDENT_CONT[byte] # [\w-]
|
||||
start = pos
|
||||
pos += 1
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
break unless ByteTables::IDENT_CONT[b]
|
||||
pos += 1
|
||||
end
|
||||
# Check trailing '?'
|
||||
if pos < len && markup.getbyte(pos) == 63
|
||||
pos += 1
|
||||
end
|
||||
result << markup.byteslice(start, pos - start)
|
||||
else
|
||||
pos += 1
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Check if markup is a simple identifier chain: [\w-]+\??(.[\w-]+\??)*
|
||||
# Uses C-level match? — 8x faster than Ruby byte scanning
|
||||
SIMPLE_LOOKUP_RE = /\A[\w-]+\??(?:\.[\w-]+\??)*\z/
|
||||
|
||||
def self.simple_lookup?(markup)
|
||||
markup.bytesize > 0 && markup.match?(SIMPLE_LOOKUP_RE)
|
||||
end
|
||||
|
||||
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil, simple = false)
|
||||
# Fast path: simple identifier chain without brackets
|
||||
if simple || self.class.simple_lookup?(markup)
|
||||
dot_pos = markup.index('.')
|
||||
if dot_pos.nil?
|
||||
@name = markup
|
||||
@lookups = Const::EMPTY_ARRAY
|
||||
@command_flags = 0
|
||||
return
|
||||
end
|
||||
@name = markup.byteslice(0, dot_pos)
|
||||
# Build lookups array from remaining dot-separated segments
|
||||
lookups = []
|
||||
@command_flags = 0
|
||||
pos = dot_pos + 1
|
||||
len = markup.bytesize
|
||||
while pos < len
|
||||
seg_start = pos
|
||||
while pos < len
|
||||
b = markup.getbyte(pos)
|
||||
break if b == 46 # '.'
|
||||
pos += 1
|
||||
end
|
||||
seg = markup.byteslice(seg_start, pos - seg_start)
|
||||
if COMMAND_METHODS.include?(seg)
|
||||
@command_flags |= 1 << lookups.length
|
||||
end
|
||||
lookups << seg
|
||||
pos += 1 # skip dot
|
||||
end
|
||||
@lookups = lookups
|
||||
return
|
||||
end
|
||||
|
||||
lookups = self.class.scan_variable(markup)
|
||||
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&.start_with?('[') && name&.end_with?(']')
|
||||
name = Expression.parse(name[1..-2])
|
||||
end
|
||||
@name = name
|
||||
|
||||
@lookups = lookups
|
||||
@command_flags = 0
|
||||
|
||||
@lookups.each_with_index do |lookup, i|
|
||||
if lookup&.start_with?('[') && lookup.end_with?(']')
|
||||
lookups[i] = Expression.parse(
|
||||
lookup[1..-2],
|
||||
string_scanner,
|
||||
cache,
|
||||
)
|
||||
@lookups.each_index do |i|
|
||||
lookup = lookups[i]
|
||||
if lookup&.start_with?('[') && lookup&.end_with?(']')
|
||||
lookups[i] = Expression.parse(lookup[1..-2])
|
||||
elsif COMMAND_METHODS.include?(lookup)
|
||||
@command_flags |= 1 << i
|
||||
end
|
||||
@@ -145,39 +41,26 @@ module Liquid
|
||||
object = context.find_variable(name)
|
||||
|
||||
@lookups.each_index do |i|
|
||||
lookup = @lookups[i]
|
||||
key = lookup.instance_of?(String) ? lookup : context.evaluate(lookup)
|
||||
key = context.evaluate(@lookups[i])
|
||||
|
||||
# Cast "key" to its liquid value to enable it to act as a primitive value
|
||||
# Fast path: strings and integers (most common key types) don't need conversion
|
||||
unless key.instance_of?(String) || key.instance_of?(Integer)
|
||||
key = Liquid::Utils.to_liquid_value(key)
|
||||
end
|
||||
key = Liquid::Utils.to_liquid_value(key)
|
||||
|
||||
# If object is a hash- or array-like object we look for the
|
||||
# presence of the key and if its available we return it
|
||||
if accessible?(object, key)
|
||||
if object.respond_to?(:[]) &&
|
||||
((object.respond_to?(:key?) && object.key?(key)) ||
|
||||
(object.respond_to?(:fetch) && key.is_a?(Integer)))
|
||||
|
||||
# if its a proc we will replace the entry with the proc
|
||||
object = context.lookup_and_evaluate(object, key)
|
||||
# Skip to_liquid for common primitive types (they return self)
|
||||
unless object.instance_of?(String) || object.instance_of?(Integer) || object.instance_of?(Float) ||
|
||||
object.instance_of?(Array) || object.instance_of?(Hash) || object.nil?
|
||||
object = liquidize(object, context)
|
||||
end
|
||||
res = context.lookup_and_evaluate(object, key)
|
||||
object = res.to_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)
|
||||
object = object.send(key)
|
||||
unless object.instance_of?(String) || object.instance_of?(Integer) || object.instance_of?(Array) || object.nil?
|
||||
object = liquidize(object, context)
|
||||
end
|
||||
|
||||
# Handle string first/last like ActiveSupport does (returns first/last character)
|
||||
# ActiveSupport returns "" for empty strings, not nil
|
||||
elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last")
|
||||
object = key == "first" ? (object[0] || "") : (object[-1] || "")
|
||||
object = object.send(key).to_liquid
|
||||
|
||||
# No key was present with the desired value and it wasn't one of the directly supported
|
||||
# keywords either. The only thing we got left is to return nil or
|
||||
@@ -186,6 +69,9 @@ module Liquid
|
||||
return nil unless context.strict_variables
|
||||
raise Liquid::UndefinedVariable, "undefined variable #{key}"
|
||||
end
|
||||
|
||||
# If we are dealing with a drop here we have to
|
||||
object.context = context if object.respond_to?(:context=)
|
||||
end
|
||||
|
||||
object
|
||||
@@ -195,27 +81,6 @@ module Liquid
|
||||
self.class == other.class && state == other.state
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns true if +object+ has +key+ accessible via [] lookup.
|
||||
def accessible?(object, key)
|
||||
if object.instance_of?(Hash)
|
||||
object.key?(key)
|
||||
else
|
||||
object.respond_to?(:[]) &&
|
||||
((object.respond_to?(:key?) && object.key?(key)) ||
|
||||
(object.respond_to?(:fetch) && key.is_a?(Integer)))
|
||||
end
|
||||
end
|
||||
|
||||
# Calls to_liquid on +object+ and wires up the context reference if needed.
|
||||
# Skipped for primitive types that return self from to_liquid.
|
||||
def liquidize(object, context)
|
||||
object = object.to_liquid
|
||||
object.context = context if object.respond_to?(:context=)
|
||||
object
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def state
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
VERSION = "5.12.0"
|
||||
VERSION = "5.6.0.rc1"
|
||||
end
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
|
||||
s.summary = "A secure, non-evaling end user template engine with aesthetic markup."
|
||||
s.authors = ["Tobias Lütke"]
|
||||
s.email = ["[email protected]"]
|
||||
s.homepage = "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."
|
||||
|
||||
@@ -28,7 +28,7 @@ Gem::Specification.new do |s|
|
||||
|
||||
s.require_path = "lib"
|
||||
|
||||
s.add_dependency("strscan", ">= 3.1.1")
|
||||
s.add_dependency("strscan")
|
||||
s.add_dependency("bigdecimal")
|
||||
|
||||
s.add_development_dependency('rake', '~> 13.0')
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Quick benchmark for autoresearch: measures parse µs, render µs, and object allocations
|
||||
# Outputs machine-readable metrics to stdout
|
||||
|
||||
require_relative 'theme_runner'
|
||||
|
||||
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
|
||||
|
||||
runner = ThemeRunner.new
|
||||
|
||||
# Warmup — enough iterations for YJIT to fully optimize hot paths
|
||||
20.times { runner.compile }
|
||||
20.times { runner.render }
|
||||
|
||||
GC.start
|
||||
GC.compact if GC.respond_to?(:compact)
|
||||
|
||||
# Measure parse
|
||||
parse_times = []
|
||||
10.times do
|
||||
GC.disable
|
||||
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
runner.compile
|
||||
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
GC.enable
|
||||
GC.start
|
||||
parse_times << (t1 - t0) * 1_000_000 # µs
|
||||
end
|
||||
|
||||
# Measure render
|
||||
render_times = []
|
||||
10.times do
|
||||
GC.disable
|
||||
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
runner.render
|
||||
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
GC.enable
|
||||
GC.start
|
||||
render_times << (t1 - t0) * 1_000_000 # µs
|
||||
end
|
||||
|
||||
# Measure object allocations for one parse+render cycle
|
||||
require 'objspace'
|
||||
GC.start
|
||||
GC.disable
|
||||
before = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
|
||||
runner.compile
|
||||
runner.render
|
||||
after = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
|
||||
GC.enable
|
||||
allocations = after - before
|
||||
|
||||
parse_us = parse_times.min.round(0)
|
||||
render_us = render_times.min.round(0)
|
||||
combined_us = parse_us + render_us
|
||||
|
||||
puts "RESULTS"
|
||||
puts "parse_us=#{parse_us}"
|
||||
puts "render_us=#{render_us}"
|
||||
puts "combined_us=#{combined_us}"
|
||||
puts "allocations=#{allocations}"
|
||||
@@ -9,17 +9,14 @@ Liquid::Environment.default.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
|
||||
|
||||
@@ -48,19 +48,6 @@ class ThemeRunner
|
||||
end
|
||||
end
|
||||
|
||||
# `tokenize` will just test the tokenizen portion of liquid without any templates
|
||||
def tokenize
|
||||
ss = StringScanner.new("")
|
||||
@tests.each do |test_hash|
|
||||
tokenizer = Liquid::Tokenizer.new(
|
||||
source: test_hash[:liquid],
|
||||
string_scanner: ss,
|
||||
line_numbers: true,
|
||||
)
|
||||
while tokenizer.shift; end
|
||||
end
|
||||
end
|
||||
|
||||
# `run` is called to benchmark rendering and compiling at the same time
|
||||
def run
|
||||
each_test do |liquid, layout, assigns, page_template, template_name|
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "benchmark/ips"
|
||||
|
||||
# benchmark liquid lexing
|
||||
|
||||
require 'liquid'
|
||||
|
||||
RubyVM::YJIT.enable
|
||||
|
||||
STRING_MARKUPS = [
|
||||
"\"foo\"",
|
||||
"\"fooooooooooo\"",
|
||||
"\"foooooooooooooooooooooooooooooo\"",
|
||||
"'foo'",
|
||||
"'fooooooooooo'",
|
||||
"'foooooooooooooooooooooooooooooo'",
|
||||
]
|
||||
|
||||
VARIABLE_MARKUPS = [
|
||||
"article",
|
||||
"article.title",
|
||||
"article.title.size",
|
||||
"very_long_variable_name_2024_11_05",
|
||||
"very_long_variable_name_2024_11_05.size",
|
||||
]
|
||||
|
||||
NUMBER_MARKUPS = [
|
||||
"0",
|
||||
"35",
|
||||
"1241891024912849",
|
||||
"3.5",
|
||||
"3.51214128409128",
|
||||
"12381902839.123819283910283",
|
||||
"123.456.789",
|
||||
"-123",
|
||||
"-12.33",
|
||||
"-405.231",
|
||||
"-0",
|
||||
"0",
|
||||
"0.0",
|
||||
"0.0000000000000000000000",
|
||||
"0.00000000001",
|
||||
]
|
||||
|
||||
RANGE_MARKUPS = [
|
||||
"(1..30)",
|
||||
"(1...30)",
|
||||
"(1..30..5)",
|
||||
"(1.0...30.0)",
|
||||
"(1.........30)",
|
||||
"(1..foo)",
|
||||
"(foo..30)",
|
||||
"(foo..bar)",
|
||||
"(foo...bar...100)",
|
||||
"(foo...bar...100.0)",
|
||||
]
|
||||
|
||||
LITERAL_MARKUPS = [
|
||||
nil,
|
||||
'nil',
|
||||
'null',
|
||||
'',
|
||||
'true',
|
||||
'false',
|
||||
'blank',
|
||||
'empty',
|
||||
]
|
||||
|
||||
MARKUPS = {
|
||||
"string" => STRING_MARKUPS,
|
||||
"literal" => LITERAL_MARKUPS,
|
||||
"variable" => VARIABLE_MARKUPS,
|
||||
"number" => NUMBER_MARKUPS,
|
||||
"range" => RANGE_MARKUPS,
|
||||
}
|
||||
|
||||
Benchmark.ips do |x|
|
||||
x.config(time: 5, warmup: 5)
|
||||
|
||||
MARKUPS.each do |type, markups|
|
||||
x.report("Liquid::Expression#parse: #{type}") do
|
||||
markups.each do |markup|
|
||||
Liquid::Expression.parse(markup)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
x.report("Liquid::Expression#parse: all") do
|
||||
MARKUPS.values.flatten.each do |markup|
|
||||
Liquid::Expression.parse(markup)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,12 +29,31 @@ EXPRESSIONS = [
|
||||
"foo | default: -1",
|
||||
]
|
||||
|
||||
EXPRESSIONS.each do |expr|
|
||||
lexer_1_result = Liquid::Lexer1.new(expr).tokenize
|
||||
lexer_2_result = Liquid::Lexer2.new(expr).tokenize
|
||||
|
||||
next if lexer_1_result == lexer_2_result
|
||||
|
||||
warn "Lexer1 and Lexer2 results are different for expression: #{expr}"
|
||||
warn "expected: #{lexer_1_result}"
|
||||
warn "got: #{lexer_2_result}"
|
||||
abort
|
||||
end
|
||||
|
||||
Benchmark.ips do |x|
|
||||
x.config(time: 10, warmup: 5)
|
||||
|
||||
x.report("Liquid::Lexer#tokenize") do
|
||||
x.report("Liquid::Lexer1#tokenize") do
|
||||
EXPRESSIONS.each do |expr|
|
||||
l = Liquid::Lexer.new(expr)
|
||||
l = Liquid::Lexer1.new(expr)
|
||||
l.tokenize
|
||||
end
|
||||
end
|
||||
|
||||
x.report("Liquid::Lexer2#tokenize") do
|
||||
EXPRESSIONS.each do |expr|
|
||||
l = Liquid::Lexer2.new(expr)
|
||||
l.tokenize
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation)
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
# Run core Liquid specs
|
||||
config.features = [:core]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
# @param ctx [Hash] adapter context containing :template
|
||||
# @param assigns [Hash] environment variables
|
||||
# @param options [Hash] :registers, :strict_errors, :exception_renderer
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -1,34 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid with lax parsing mode
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
config.features = [:core, :lax_parsing]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
# Force lax mode
|
||||
options = options.merge(error_mode: :lax)
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid with ActiveSupport loaded
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid_with_active_support.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'active_support/all'
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
|
||||
config.features = [:core, :activesupport]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
# @param ctx [Hash] adapter context containing :template
|
||||
# @param assigns [Hash] environment variables
|
||||
# @param options [Hash] :registers, :strict_errors, :exception_renderer
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
|
||||
# Enable YJIT if available
|
||||
if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
|
||||
RubyVM::YJIT.enable
|
||||
end
|
||||
|
||||
require 'active_support/all'
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
config.features = [:core, :activesupport]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
# Force strict mode
|
||||
options = { error_mode: :strict }.merge(options)
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -632,28 +632,13 @@ class ContextTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_has_key_will_not_add_an_error_for_missing_keys
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
context = Context.new
|
||||
context.key?('unknown')
|
||||
assert_empty(context.errors)
|
||||
end
|
||||
end
|
||||
|
||||
def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled
|
||||
context = Context.new
|
||||
context.strict_variables = true
|
||||
assert_raises(Liquid::UndefinedVariable) do
|
||||
context['unknown']
|
||||
end
|
||||
end
|
||||
|
||||
def test_has_key_will_not_raise_for_missing_keys_when_strict_variables_is_enabled
|
||||
context = Context.new
|
||||
context.strict_variables = true
|
||||
refute(context.key?('unknown'))
|
||||
assert_empty(context.errors)
|
||||
end
|
||||
|
||||
def test_context_always_uses_static_registers
|
||||
registers = {
|
||||
my_register: :my_value,
|
||||
|
||||
@@ -67,7 +67,7 @@ class ErrorHandlingTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unrecognized_operator
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
require 'lru_redux'
|
||||
|
||||
class ExpressionTest < Minitest::Test
|
||||
def test_keyword_literals
|
||||
@@ -14,7 +13,6 @@ class ExpressionTest < Minitest::Test
|
||||
assert_template_result("double quoted", '{{"double quoted"}}')
|
||||
assert_template_result("spaced", "{{ 'spaced' }}")
|
||||
assert_template_result("spaced2", "{{ 'spaced2' }}")
|
||||
assert_template_result("emoji🔥", "{{ 'emoji🔥' }}")
|
||||
end
|
||||
|
||||
def test_int
|
||||
@@ -24,22 +22,8 @@ class ExpressionTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_float
|
||||
assert_template_result("-17.42", "{{ -17.42 }}")
|
||||
assert_template_result("2.5", "{{ 2.5 }}")
|
||||
|
||||
with_error_modes(:lax) do
|
||||
assert_expression_result(0.0, "0.....5")
|
||||
assert_expression_result(0.0, "-0..1")
|
||||
end
|
||||
|
||||
assert_expression_result(1.5, "1.5")
|
||||
|
||||
# this is a unfortunate quirky behavior of Liquid
|
||||
result = Expression.parse(".5")
|
||||
assert_kind_of(Liquid::VariableLookup, result)
|
||||
|
||||
result = Expression.parse("-.5")
|
||||
assert_kind_of(Liquid::VariableLookup, result)
|
||||
end
|
||||
|
||||
def test_range
|
||||
@@ -56,131 +40,6 @@ class ExpressionTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_quirky_negative_sign_expression_markup
|
||||
result = Expression.parse("-", nil)
|
||||
assert(result.is_a?(VariableLookup))
|
||||
assert_equal("-", result.name)
|
||||
|
||||
# for this template, the expression markup is "-"
|
||||
assert_template_result(
|
||||
"",
|
||||
"{{ - 'theme.css' - }}",
|
||||
error_mode: :lax,
|
||||
)
|
||||
end
|
||||
|
||||
def test_expression_cache
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
cache = {}
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template, expression_cache: cache).render
|
||||
|
||||
assert_equal(
|
||||
["1", "2", "x", "y"],
|
||||
cache.to_a.map { _1[0] }.sort,
|
||||
)
|
||||
end
|
||||
|
||||
def test_expression_cache_with_true_boolean
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
parse_context = ParseContext.new(expression_cache: true)
|
||||
|
||||
Liquid::Template.parse(template, parse_context).render
|
||||
|
||||
cache = parse_context.instance_variable_get(:@expression_cache)
|
||||
|
||||
assert_equal(
|
||||
["1", "2", "x", "y"],
|
||||
cache.to_a.map { _1[0] }.sort,
|
||||
)
|
||||
end
|
||||
|
||||
def test_expression_cache_with_lru_redux
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
cache = LruRedux::Cache.new(10)
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template, expression_cache: cache).render
|
||||
|
||||
assert_equal(
|
||||
["1", "2", "x", "y"],
|
||||
cache.to_a.map { _1[0] }.sort,
|
||||
)
|
||||
end
|
||||
|
||||
def test_disable_expression_cache
|
||||
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
|
||||
|
||||
template = <<~LIQUID
|
||||
{% assign x = 1 %}
|
||||
{{ x }}
|
||||
{% assign x = 2 %}
|
||||
{{ x }}
|
||||
{% assign y = 1 %}
|
||||
{{ y }}
|
||||
LIQUID
|
||||
|
||||
parse_context = Liquid::ParseContext.new(expression_cache: false)
|
||||
Liquid::Template.parse(template, parse_context).render
|
||||
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
|
||||
end
|
||||
|
||||
def test_safe_parse_with_variable_lookup
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('product.title')
|
||||
result = Liquid::Expression.safe_parse(parser)
|
||||
|
||||
assert_instance_of(Liquid::VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
def test_safe_parse_with_number
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('42')
|
||||
result = Liquid::Expression.safe_parse(parser)
|
||||
|
||||
assert_equal(42, result)
|
||||
end
|
||||
|
||||
def test_safe_parse_raises_syntax_error_for_invalid_expression
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('')
|
||||
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Expression.safe_parse(parser)
|
||||
end
|
||||
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_expression_result(expect, markup, **assigns)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class HashRenderingTest < Minitest::Test
|
||||
def test_render_empty_hash
|
||||
assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} })
|
||||
end
|
||||
|
||||
def test_render_hash_with_string_keys_and_values
|
||||
assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_symbol_keys_and_integer_values
|
||||
assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } })
|
||||
end
|
||||
|
||||
def test_render_nested_hash
|
||||
assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_array_values
|
||||
assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } })
|
||||
end
|
||||
|
||||
def test_render_recursive_hash
|
||||
recursive_hash = { "self" => {} }
|
||||
recursive_hash["self"]["self"] = recursive_hash
|
||||
assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash })
|
||||
end
|
||||
|
||||
def test_hash_with_downcase_filter
|
||||
assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_upcase_filter
|
||||
assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_strip_filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_escape_filter
|
||||
assert_template_result("{"Key"=>"Value", "AnotherKey"=>"AnotherValue"}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_url_encode_filter
|
||||
assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_strip_html_filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_truncate__20_filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_replace___key____replaced_key__filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_append____appended_text__filter
|
||||
assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_hash_with_prepend___prepended_text___filter
|
||||
assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_array_values_empty
|
||||
assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } })
|
||||
end
|
||||
|
||||
def test_render_hash_with_array_values_hash
|
||||
assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } })
|
||||
end
|
||||
|
||||
def test_join_filter_with_hash
|
||||
array = [{ "key1" => "value1" }, { "key2" => "value2" }]
|
||||
glue = { "lol" => "wut" }
|
||||
assert_template_result("{\"key1\"=>\"value1\"}{\"lol\"=>\"wut\"}{\"key2\"=>\"value2\"}", "{{ my_array | join: glue }}", { "my_array" => array, "glue" => glue })
|
||||
end
|
||||
|
||||
def test_render_hash_with_hash_key
|
||||
assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } })
|
||||
end
|
||||
|
||||
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
|
||||
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new })
|
||||
end
|
||||
|
||||
def test_rendering_hash_without_custom_to_s_uses_default_inspect
|
||||
my_hash = HashWithoutCustomToS.new
|
||||
my_hash[:foo] = :bar
|
||||
|
||||
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
|
||||
end
|
||||
end
|
||||
@@ -31,18 +31,18 @@ class ParsingQuirksTest < Minitest::Test
|
||||
def test_error_on_empty_filter
|
||||
assert(Template.parse("{{test}}"))
|
||||
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert(Template.parse("{{|test}}"))
|
||||
end
|
||||
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
|
||||
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_meaningless_parens_error
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
|
||||
Template.parse("{% if #{markup} %} YES {% endif %}")
|
||||
@@ -51,7 +51,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unexpected_characters_syntax_error
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
markup = "true && false"
|
||||
Template.parse("{% if #{markup} %} YES {% endif %}")
|
||||
@@ -70,7 +70,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_meaningless_parens_lax
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assigns = { 'b' => 'bar', 'c' => 'baz' }
|
||||
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
|
||||
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
|
||||
@@ -78,7 +78,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unexpected_characters_silently_eat_logic_lax
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
markup = "true && false"
|
||||
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
|
||||
markup = "false || true"
|
||||
@@ -93,7 +93,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unanchored_filter_arguments
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
|
||||
|
||||
assert_template_result('x', "{{ 'X' | downcase) }}")
|
||||
@@ -106,14 +106,14 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_invalid_variables_work
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
|
||||
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
|
||||
end
|
||||
end
|
||||
|
||||
def test_extra_dots_in_ranges
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
|
||||
end
|
||||
end
|
||||
@@ -133,15 +133,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_incomplete_expression
|
||||
with_error_modes(:lax) do
|
||||
assert_template_result("false", "{{ false - }}")
|
||||
assert_template_result("false", "{{ false > }}")
|
||||
assert_template_result("false", "{{ false < }}")
|
||||
assert_template_result("false", "{{ false = }}")
|
||||
assert_template_result("false", "{{ false ! }}")
|
||||
assert_template_result("false", "{{ false 1 }}")
|
||||
assert_template_result("false", "{{ false a }}")
|
||||
|
||||
with_error_mode(:lax) do
|
||||
assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}")
|
||||
assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}")
|
||||
assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}")
|
||||
|
||||
@@ -44,40 +44,33 @@ class SecurityTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_does_not_permanently_add_filters_to_symbol_table
|
||||
assert_no_new_symbols do
|
||||
# MRI imprecisely marks objects found on the C stack, which can result
|
||||
# in uninitialized memory being marked. This can even result in the test failing
|
||||
# deterministically for a given compilation of ruby. Using a separate thread will
|
||||
# keep these writes of the symbol pointer on a separate stack that will be garbage
|
||||
# collected after Thread#join.
|
||||
Thread.new do
|
||||
test = %( {{ "some_string" | a_bad_filter }} )
|
||||
Template.parse(test).render!
|
||||
nil
|
||||
end.join
|
||||
current_symbols = Symbol.all_symbols
|
||||
|
||||
GC.start
|
||||
end
|
||||
# MRI imprecisely marks objects found on the C stack, which can result
|
||||
# in uninitialized memory being marked. This can even result in the test failing
|
||||
# deterministically for a given compilation of ruby. Using a separate thread will
|
||||
# keep these writes of the symbol pointer on a separate stack that will be garbage
|
||||
# collected after Thread#join.
|
||||
Thread.new do
|
||||
test = %( {{ "some_string" | a_bad_filter }} )
|
||||
Template.parse(test).render!
|
||||
nil
|
||||
end.join
|
||||
|
||||
GC.start
|
||||
|
||||
assert_equal([], (Symbol.all_symbols - current_symbols))
|
||||
end
|
||||
|
||||
def test_does_not_add_drop_methods_to_symbol_table
|
||||
assert_no_new_symbols do
|
||||
assigns = { 'drop' => Drop.new }
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
|
||||
end
|
||||
end
|
||||
|
||||
def assert_no_new_symbols
|
||||
# Run once to trigger any first-time initialization which might create some symbols,
|
||||
# for example autoload or lazy method parsing might create symbols on first execution.
|
||||
yield
|
||||
|
||||
# Ensure no new symbols for further runs, i.e. the code does not leak symbols
|
||||
current_symbols = Symbol.all_symbols
|
||||
yield
|
||||
assert_equal([], Symbol.all_symbols - current_symbols)
|
||||
|
||||
assigns = { 'drop' => Drop.new }
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
|
||||
|
||||
assert_equal([], (Symbol.all_symbols - current_symbols))
|
||||
end
|
||||
|
||||
def test_max_depth_nested_blocks_does_not_raise_exception
|
||||
|
||||
@@ -116,7 +116,7 @@ class StandardFiltersTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_slice_on_arrays
|
||||
input = 'foobar'.split('')
|
||||
input = 'foobar'.split(//)
|
||||
assert_equal(%w(o o b), @filters.slice(input, 1, 3))
|
||||
assert_equal(%w(o o b a r), @filters.slice(input, 1, 1000))
|
||||
assert_equal(%w(), @filters.slice(input, 1, 0))
|
||||
@@ -133,18 +133,6 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_equal([], @filters.slice(input, -(1 << 63), 6))
|
||||
end
|
||||
|
||||
def test_find_on_empty_array
|
||||
assert_nil(@filters.find([], 'foo', 'bar'))
|
||||
end
|
||||
|
||||
def test_find_index_on_empty_array
|
||||
assert_nil(@filters.find_index([], 'foo', 'bar'))
|
||||
end
|
||||
|
||||
def test_has_on_empty_array
|
||||
refute(@filters.has([], 'foo', 'bar'))
|
||||
end
|
||||
|
||||
def test_truncate
|
||||
assert_equal('1234...', @filters.truncate('1234567890', 7))
|
||||
assert_equal('1234567890', @filters.truncate('1234567890', 20))
|
||||
@@ -164,13 +152,6 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_equal(['A', 'Z'], @filters.split('A1Z', 1))
|
||||
end
|
||||
|
||||
def test_squish_filter
|
||||
assert_equal("foo bar boo", Liquid::Template.parse(%({{ " foo bar
|
||||
\t boo " | squish }})).render)
|
||||
assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render)
|
||||
assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render)
|
||||
end
|
||||
|
||||
def test_escape
|
||||
assert_equal('<strong>', @filters.escape('<strong>'))
|
||||
assert_equal('1', @filters.escape(1))
|
||||
@@ -300,10 +281,6 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_equal('1121314', @filters.join([1, 2, 3, 4], 1))
|
||||
end
|
||||
|
||||
def test_join_calls_to_liquid_on_each_element
|
||||
assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", "))
|
||||
end
|
||||
|
||||
def test_sort
|
||||
assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1]))
|
||||
assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a"))
|
||||
@@ -561,23 +538,12 @@ class StandardFiltersTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
|
||||
def test_map_with_value_property
|
||||
array = [
|
||||
{ "handle" => "alpha", "value" => "A" },
|
||||
{ "handle" => "beta", "value" => "B" },
|
||||
{ "handle" => "gamma", "value" => "C" }
|
||||
]
|
||||
|
||||
assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array })
|
||||
end
|
||||
|
||||
def test_map_returns_input_with_no_property
|
||||
def test_map_returns_empty_with_no_property
|
||||
foo = [
|
||||
[1],
|
||||
[2],
|
||||
[3],
|
||||
]
|
||||
|
||||
assert_raises(Liquid::ArgumentError) do
|
||||
@filters.map(foo, nil)
|
||||
end
|
||||
@@ -634,40 +600,6 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_nil(@filters.last([]))
|
||||
end
|
||||
|
||||
def test_first_last_on_strings
|
||||
# Ruby's String class does not have first/last methods by default.
|
||||
# ActiveSupport adds String#first and String#last to return the first/last character.
|
||||
# Liquid must work without ActiveSupport, so the first/last filters handle strings specially.
|
||||
#
|
||||
# This enables template patterns like:
|
||||
# {{ product.title | first }} => "S" (for "Snowboard")
|
||||
# {{ customer.name | last }} => "h" (for "Smith")
|
||||
#
|
||||
# Note: ActiveSupport returns "" for empty strings, not nil.
|
||||
assert_equal('f', @filters.first('foo'))
|
||||
assert_equal('o', @filters.last('foo'))
|
||||
assert_equal('', @filters.first(''))
|
||||
assert_equal('', @filters.last(''))
|
||||
end
|
||||
|
||||
def test_first_last_on_unicode_strings
|
||||
# Unicode strings should return the first/last grapheme cluster (character),
|
||||
# not the first/last byte. Ruby's String#[] handles this correctly with index 0/-1.
|
||||
# This ensures international text works properly:
|
||||
# {{ korean_name | first }} => "고" (not a partial byte sequence)
|
||||
assert_equal('고', @filters.first('고스트빈'))
|
||||
assert_equal('빈', @filters.last('고스트빈'))
|
||||
end
|
||||
|
||||
def test_first_last_on_strings_via_template
|
||||
# Integration test to verify the filter works end-to-end in templates.
|
||||
# Empty strings return empty output (nil renders as empty string).
|
||||
assert_template_result('f', '{{ name | first }}', { 'name' => 'foo' })
|
||||
assert_template_result('o', '{{ name | last }}', { 'name' => 'foo' })
|
||||
assert_template_result('', '{{ name | first }}', { 'name' => '' })
|
||||
assert_template_result('', '{{ name | last }}', { 'name' => '' })
|
||||
end
|
||||
|
||||
def test_replace
|
||||
assert_equal('b b b b', @filters.replace('a a a a', 'a', 'b'))
|
||||
assert_equal('2 2 2 2', @filters.replace('1 1 1 1', 1, 2))
|
||||
@@ -895,233 +827,21 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_template_result('abc', "{{ 'abc' | date: '%D' }}")
|
||||
end
|
||||
|
||||
def test_reject
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "beta gamma"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_reject_with_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "beta gamma"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_reject_with_false_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "alpha delta"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_has
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => false },
|
||||
]
|
||||
|
||||
expected_output = "true"
|
||||
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
|
||||
end
|
||||
|
||||
def test_has_when_does_not_have_it
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => false },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => false },
|
||||
]
|
||||
|
||||
expected_output = "false"
|
||||
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array })
|
||||
assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array })
|
||||
end
|
||||
|
||||
def test_has_with_empty_arrays
|
||||
template = <<~LIQUID
|
||||
{%- assign has_product = products | has: 'title.content', 'Not found' -%}
|
||||
{%- unless has_product -%}
|
||||
Product not found.
|
||||
{%- endunless -%}
|
||||
LIQUID
|
||||
expected_output = "Product not found."
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => [] })
|
||||
end
|
||||
|
||||
def test_has_with_false_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | has: 'ok', false }}"
|
||||
expected_output = "true"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_has_with_false_value_when_does_not_have_it
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => true },
|
||||
{ "handle" => "gamma", "ok" => true },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | has: 'ok', false }}"
|
||||
expected_output = "false"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_find_with_value
|
||||
products = [
|
||||
{ "title" => "Pro goggles", "price" => 1299 },
|
||||
{ "title" => "Thermal gloves", "price" => 1499 },
|
||||
{ "title" => "Alpine jacket", "price" => 3999 },
|
||||
{ "title" => "Mountain boots", "price" => 3899 },
|
||||
{ "title" => "Safety helmet", "price" => 1999 }
|
||||
]
|
||||
|
||||
template = <<~LIQUID
|
||||
{%- assign product = products | find: 'price', 3999 -%}
|
||||
{{- product.title -}}
|
||||
LIQUID
|
||||
expected_output = "Alpine jacket"
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => products })
|
||||
end
|
||||
|
||||
def test_find_with_empty_arrays
|
||||
template = <<~LIQUID
|
||||
{%- assign product = products | find: 'title.content', 'Not found' -%}
|
||||
{%- unless product -%}
|
||||
Product not found.
|
||||
{%- endunless -%}
|
||||
LIQUID
|
||||
expected_output = "Product not found."
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => [] })
|
||||
end
|
||||
|
||||
def test_find_index_with_value
|
||||
products = [
|
||||
{ "title" => "Pro goggles", "price" => 1299 },
|
||||
{ "title" => "Thermal gloves", "price" => 1499 },
|
||||
{ "title" => "Alpine jacket", "price" => 3999 },
|
||||
{ "title" => "Mountain boots", "price" => 3899 },
|
||||
{ "title" => "Safety helmet", "price" => 1999 }
|
||||
]
|
||||
|
||||
template = <<~LIQUID
|
||||
{%- assign index = products | find_index: 'price', 3999 -%}
|
||||
{{- index -}}
|
||||
LIQUID
|
||||
expected_output = "2"
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => products })
|
||||
end
|
||||
|
||||
def test_find_index_with_empty_arrays
|
||||
template = <<~LIQUID
|
||||
{%- assign index = products | find_index: 'title.content', 'Not found' -%}
|
||||
{%- unless index -%}
|
||||
Index not found.
|
||||
{%- endunless -%}
|
||||
LIQUID
|
||||
expected_output = "Index not found."
|
||||
|
||||
assert_template_result(expected_output, template, { "products" => [] })
|
||||
end
|
||||
|
||||
def test_where
|
||||
array = [
|
||||
input = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "alpha delta"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_where_with_empty_string_is_a_no_op
|
||||
environment = { "array" => ["alpha", "beta", "gamma"] }
|
||||
expected_output = "alpha beta gamma"
|
||||
template = "{{ array | where: '' | join: ' ' }}"
|
||||
|
||||
assert_template_result(expected_output, template, environment)
|
||||
end
|
||||
|
||||
def test_where_with_nil_is_a_no_op
|
||||
environment = { "array" => ["alpha", "beta", "gamma"] }
|
||||
template = "{{ array | where: nil | join: ' ' }}"
|
||||
|
||||
assert_raises(Liquid::ArgumentError) do
|
||||
assert_template_result("alpha beta gamma", template, environment)
|
||||
end
|
||||
end
|
||||
|
||||
def test_where_with_value
|
||||
array = [
|
||||
expectation = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "alpha delta"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
end
|
||||
|
||||
def test_where_with_false_value
|
||||
array = [
|
||||
{ "handle" => "alpha", "ok" => true },
|
||||
{ "handle" => "beta", "ok" => false },
|
||||
{ "handle" => "gamma", "ok" => false },
|
||||
{ "handle" => "delta", "ok" => true },
|
||||
]
|
||||
|
||||
template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}"
|
||||
expected_output = "beta gamma"
|
||||
|
||||
assert_template_result(expected_output, template, { "array" => array })
|
||||
assert_equal(expectation, @filters.where(input, "ok", true))
|
||||
assert_equal(expectation, @filters.where(input, "ok"))
|
||||
end
|
||||
|
||||
def test_where_string_keys
|
||||
@@ -1181,8 +901,6 @@ class StandardFiltersTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_all_filters_never_raise_non_liquid_exception
|
||||
skip("too slow on non-CRuby due to many exceptions") unless RUBY_ENGINE == 'ruby'
|
||||
|
||||
test_drop = TestDrop.new(value: "test")
|
||||
test_drop.context = Context.new
|
||||
test_enum = TestEnumerable.new
|
||||
@@ -1333,33 +1051,6 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
|
||||
end
|
||||
|
||||
def test_sum_with_non_string_property
|
||||
input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }]
|
||||
|
||||
assert_equal(1, @filters.sum(input, true))
|
||||
assert_equal(0.2, @filters.sum(input, 1.0))
|
||||
assert_equal(-0.3, @filters.sum(input, 1))
|
||||
assert_equal(0.4, @filters.sum(input, 1..5))
|
||||
assert_equal(0, @filters.sum(input, nil))
|
||||
assert_equal(0, @filters.sum(input, ""))
|
||||
end
|
||||
|
||||
def test_uniq_with_to_liquid_value
|
||||
input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"]
|
||||
expected = [StringDrop.new("foo"), StringDrop.new("bar")]
|
||||
result = @filters.uniq(input)
|
||||
|
||||
assert_equal(expected, result)
|
||||
end
|
||||
|
||||
def test_uniq_with_to_liquid_value_pick_correct_classes
|
||||
input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")]
|
||||
expected = [String, StringDrop]
|
||||
result = @filters.uniq(input).map(&:class)
|
||||
|
||||
assert_equal(expected, result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_timezone(tz)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class CycleTagTest < Minitest::Test
|
||||
def test_simple_cycle_inside_for_loop
|
||||
template = <<~LIQUID
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_cycle_with_variables_inside_for_loop
|
||||
template = <<~LIQUID
|
||||
{%- assign a = 1 -%}
|
||||
{%- assign b = 2 -%}
|
||||
{%- assign c = 3 -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{% cycle a, b, c %}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_cycle_named_groups_string
|
||||
template = <<~LIQUID
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle 'placeholder1': 1, 2, 3 -%}
|
||||
{%- cycle 'placeholder2': 1, 2, 3 -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112233", template)
|
||||
end
|
||||
|
||||
def test_cycle_named_groups_vlookup
|
||||
template = <<~LIQUID
|
||||
{%- assign placeholder1 = 'placeholder1' -%}
|
||||
{%- assign placeholder2 = 'placeholder2' -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle placeholder1: 1, 2, 3 -%}
|
||||
{%- cycle placeholder2: 1, 2, 3 -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112233", template)
|
||||
end
|
||||
|
||||
def test_unnamed_cycle_have_independent_counters_when_used_with_lookups
|
||||
template = <<~LIQUID
|
||||
{%- assign a = "1" -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112211", template)
|
||||
end
|
||||
|
||||
def test_unnamed_cycle_dependent_counter_when_used_with_literal_values
|
||||
template = <<~LIQUID
|
||||
{%- cycle "1", "2" -%}
|
||||
{%- cycle "1", "2" -%}
|
||||
{%- cycle "1", "2" -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("121", template)
|
||||
end
|
||||
|
||||
def test_optional_trailing_comma
|
||||
template = <<~LIQUID
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("1211", template)
|
||||
end
|
||||
|
||||
def test_cycle_tag_without_arguments
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% cycle %}")
|
||||
end
|
||||
|
||||
assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message)
|
||||
end
|
||||
|
||||
def test_cycle_tag_with_error_mode
|
||||
# QuotedFragment is more permissive than what Parser#expression allows.
|
||||
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
|
||||
template2 = "{% cycle .5: 'a', 'b' %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("b", template1)
|
||||
assert_template_result("a", template2)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
||||
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
||||
|
||||
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
|
||||
|
||||
assert_match(expected_error, error1.message)
|
||||
assert_match(expected_error, error2.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_with_trailing_elements
|
||||
assignments = "{% assign a = 'A' %}{% assign n = 'N' %}"
|
||||
|
||||
template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}"
|
||||
template2 = "#{assignments}{% cycle name: 'a' 'b', 'c' %}"
|
||||
template3 = "#{assignments}{% cycle name: 'a', 'b' 'c' %}"
|
||||
template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}"
|
||||
template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("a", template1)
|
||||
assert_template_result("a", template2)
|
||||
assert_template_result("a", template3)
|
||||
assert_template_result("N", template4)
|
||||
assert_template_result("N", template5)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
||||
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
||||
error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
|
||||
error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
|
||||
error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
|
||||
|
||||
expected_error = /Expected end_of_string but found/
|
||||
|
||||
assert_match(expected_error, error1.message)
|
||||
assert_match(expected_error, error2.message)
|
||||
assert_match(expected_error, error3.message)
|
||||
assert_match(expected_error, error4.message)
|
||||
assert_match(expected_error, error5.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_name_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{% for i in (1..3) %}
|
||||
{% cycle foo=>bar: "a", "b" %}
|
||||
{% endfor %}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_variable_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{% for i in (1..3) %}
|
||||
{% cycle foo=>bar, "a", "b" %}
|
||||
{% endfor %}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -204,32 +204,6 @@ class IncludeTagTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_strict2_parsing_errors
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(
|
||||
'hello value1 value2',
|
||||
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
|
||||
)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_syntax_error(
|
||||
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
)
|
||||
assert_syntax_error(
|
||||
'{% include "snippet" | filter %}',
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_optional_commas
|
||||
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
|
||||
assert_template_result('hello value1 value2', '{% include "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
|
||||
end
|
||||
|
||||
def test_include_tag_caches_second_read_of_same_partial
|
||||
file_system = CountingFileSystem.new
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
@@ -303,13 +277,13 @@ class IncludeTagTest < Minitest::Test
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
|
||||
end
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
|
||||
end
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
|
||||
end
|
||||
with_error_modes(:lax) do
|
||||
with_error_mode(:lax) do
|
||||
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
|
||||
end
|
||||
end
|
||||
@@ -400,43 +374,4 @@ class IncludeTagTest < Minitest::Test
|
||||
render_errors: true,
|
||||
)
|
||||
end
|
||||
|
||||
def test_include_template_with_invalid_expression
|
||||
template = "{% include foo=>bar %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_with_invalid_expression
|
||||
template = '{% include "snippet" with foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_attribute_with_invalid_expression
|
||||
template = '{% include "snippet", key: foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end # IncludeTagTest
|
||||
|
||||
@@ -16,7 +16,6 @@ class RawTagTest < Minitest::Test
|
||||
assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <')
|
||||
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <")
|
||||
assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <")
|
||||
assert_template_result("{Hello}", "{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}")
|
||||
end
|
||||
|
||||
def test_open_tag_in_raw
|
||||
|
||||
@@ -105,33 +105,7 @@ class RenderTagTest < Minitest::Test
|
||||
assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
|
||||
end
|
||||
|
||||
def test_strict2_parsing_errors
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(
|
||||
'hello value1 value2',
|
||||
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
|
||||
)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_syntax_error(
|
||||
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
)
|
||||
assert_syntax_error(
|
||||
'{% render "snippet" | filter %}',
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_optional_commas
|
||||
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
|
||||
assert_template_result('hello value1 value2', '{% render "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
|
||||
end
|
||||
|
||||
def test_render_tag_caches_second_read_of_same_partial
|
||||
def test_include_tag_caches_second_read_of_same_partial
|
||||
file_system = StubFileSystem.new('snippet' => 'echo')
|
||||
assert_equal(
|
||||
'echoecho',
|
||||
@@ -314,30 +288,4 @@ class RenderTagTest < Minitest::Test
|
||||
render_errors: true,
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_with_invalid_expression
|
||||
template = '{% render "snippet" with foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_render_attribute_with_invalid_expression
|
||||
template = '{% render "snippet", key: foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,7 +117,7 @@ class StandardTagTest < Minitest::Test
|
||||
assigns = { 'condition' => "bad string here" }
|
||||
assert_template_result(
|
||||
'',
|
||||
'{% case condition %}{% when "string here" %} hit {% endcase %}',
|
||||
'{% case condition %}{% when "string here" %} hit {% endcase %}',\
|
||||
assigns,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -138,7 +138,7 @@ class TableRowTest < Minitest::Test
|
||||
|
||||
def test_tablerow_loop_drop_attributes
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..2) %}
|
||||
{% tablerow i in (1...2) %}
|
||||
col: {{ tablerowloop.col }}
|
||||
col0: {{ tablerowloop.col0 }}
|
||||
col_first: {{ tablerowloop.col_first }}
|
||||
@@ -192,14 +192,12 @@ class TableRowTest < Minitest::Test
|
||||
assert_template_result(
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
|
||||
error_mode: :warn,
|
||||
render_errors: true,
|
||||
)
|
||||
|
||||
assert_template_result(
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
|
||||
error_mode: :warn,
|
||||
render_errors: true,
|
||||
)
|
||||
|
||||
@@ -207,19 +205,18 @@ class TableRowTest < Minitest::Test
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
|
||||
render_errors: true,
|
||||
error_mode: :warn,
|
||||
)
|
||||
end
|
||||
|
||||
def test_table_row_handles_interrupts
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td></tr>\n",
|
||||
'{% tablerow n in (1..3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
|
||||
'{% tablerow n in (1...3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
|
||||
)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td><td class=\"col2\"> 2 </td></tr>\n<tr class=\"row2\"><td class=\"col1\"> 3 </td></tr>\n",
|
||||
'{% tablerow n in (1..3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
|
||||
'{% tablerow n in (1...3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
|
||||
)
|
||||
end
|
||||
|
||||
@@ -258,211 +255,4 @@ class TableRowTest < Minitest::Test
|
||||
template,
|
||||
)
|
||||
end
|
||||
|
||||
def test_tablerow_with_cols_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
<tr class="row2"><td class="col1">4</td><td class="col2">5</td><td class="col3">6</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_limit_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_offset_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">3</td><td class="col2">4</td><td class="col3">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_range_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_multiple_attributes_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">2</td><td class="col2">3</td></tr>
|
||||
<tr class="row2"><td class="col1">4</td><td class="col2">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_variable_collection_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td></tr>
|
||||
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_dotted_access_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td></tr>
|
||||
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_bracketed_access_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">10</td><td class="col2">20</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_without_attributes_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_without_in_keyword_in_strict2_mode
|
||||
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode
|
||||
template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_empty_collection_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
</tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'empty_array' => [] })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_invalid_attribute_strict_vs_strict2
|
||||
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td><td class="col4">4</td><td class="col5">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_invalid_expression_strict_vs_strict2
|
||||
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
</tr>
|
||||
OUTPUT
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,7 +133,7 @@ class TemplateTest < Minitest::Test
|
||||
assert(t.resource_limits.reached?)
|
||||
|
||||
t.resource_limits.render_score_limit = 200
|
||||
assert_equal(" foo " * 100, t.render!)
|
||||
assert_equal((" foo " * 100), t.render!)
|
||||
refute_nil(t.resource_limits.render_score)
|
||||
end
|
||||
|
||||
@@ -179,86 +179,6 @@ class TemplateTest < Minitest::Test
|
||||
assert_equal("すごい", t.render)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_limit_across_render_tags
|
||||
file_system = StubFileSystem.new(
|
||||
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
|
||||
# Without cumulative limit, all 5 partials render successfully
|
||||
t = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
unlimited_output = t.render!
|
||||
total_cumulative = t.resource_limits.cumulative_render_score
|
||||
|
||||
# With cumulative limit set below the total, rendering stops early
|
||||
t2 = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2
|
||||
limited_output = t2.render
|
||||
assert(t2.resource_limits.reached?)
|
||||
assert_operator(limited_output.length, :<, unlimited_output.length)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_limit_raises_on_render_bang
|
||||
file_system = StubFileSystem.new(
|
||||
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
t = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t.resource_limits.cumulative_render_score_limit = 20
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
t.render!
|
||||
end
|
||||
end
|
||||
|
||||
def test_cumulative_assign_score_limit_across_include_tags
|
||||
file_system = StubFileSystem.new(
|
||||
'assign_partial' => '{% assign x = "a long string value here" %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
|
||||
# Without cumulative limit, all 5 partials render
|
||||
t = Template.parse(
|
||||
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t.render!
|
||||
total_cumulative = t.resource_limits.cumulative_assign_score
|
||||
|
||||
# With cumulative limit set below the total, rendering stops early
|
||||
t2 = Template.parse(
|
||||
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2
|
||||
t2.render
|
||||
assert(t2.resource_limits.reached?)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_tracks_across_partials_without_limit
|
||||
file_system = StubFileSystem.new(
|
||||
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
t = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t.render!
|
||||
assert(
|
||||
t.resource_limits.cumulative_render_score > t.resource_limits.render_score,
|
||||
"cumulative should exceed per-template score after multiple partials",
|
||||
)
|
||||
end
|
||||
|
||||
def test_default_resource_limits_unaffected_by_render_with_context
|
||||
context = Context.new
|
||||
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
|
||||
|
||||
@@ -209,69 +209,4 @@ class VariableTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_with_single_trailing_comma
|
||||
template = '{{ "hello" | append: "world", }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('helloworld', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_multiple_filters_with_trailing_commas
|
||||
template = '{{ "hello" | append: "1", | append: "2", }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('hello12', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_with_colon_but_no_arguments
|
||||
template = '{{ "test" | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('TEST', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_chain_with_colon_no_args
|
||||
template = '{{ "test" | append: "x" | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('TESTX', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_combining_trailing_comma_and_empty_args
|
||||
template = '{{ "test" | append: "x", | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('TESTX', template)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+9
-55
@@ -15,6 +15,11 @@ if (env_mode = ENV['LIQUID_PARSER_MODE'])
|
||||
end
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
|
||||
if ENV['LIQUID_C'] == '1'
|
||||
puts "-- LIQUID C"
|
||||
require 'liquid/c'
|
||||
end
|
||||
|
||||
if Minitest.const_defined?('Test')
|
||||
# We're on Minitest 5+. Nothing to do here.
|
||||
else
|
||||
@@ -34,7 +39,7 @@ module Minitest
|
||||
|
||||
def assert_template_result(
|
||||
expected, template, assigns = {},
|
||||
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
|
||||
message: nil, partials: nil, error_mode: nil, render_errors: false,
|
||||
template_factory: nil
|
||||
)
|
||||
file_system = StubFileSystem.new(partials || {})
|
||||
@@ -82,12 +87,10 @@ module Minitest
|
||||
Environment.dangerously_override(environment, &blk)
|
||||
end
|
||||
|
||||
def with_error_modes(*modes)
|
||||
def with_error_mode(mode)
|
||||
old_mode = Liquid::Environment.default.error_mode
|
||||
modes.each do |mode|
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
yield
|
||||
end
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
yield
|
||||
ensure
|
||||
Liquid::Environment.default.error_mode = old_mode
|
||||
end
|
||||
@@ -148,35 +151,6 @@ class BooleanDrop < Liquid::Drop
|
||||
end
|
||||
end
|
||||
|
||||
class StringDrop < Liquid::Drop
|
||||
include Comparable
|
||||
|
||||
def initialize(value)
|
||||
super()
|
||||
@value = value
|
||||
end
|
||||
|
||||
def to_liquid_value
|
||||
@value
|
||||
end
|
||||
|
||||
def to_s
|
||||
@value
|
||||
end
|
||||
|
||||
def to_str
|
||||
@value
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#<StringDrop @value=#{@value.inspect}>"
|
||||
end
|
||||
|
||||
def <=>(other)
|
||||
to_liquid_value <=> Liquid::Utils.to_liquid_value(other)
|
||||
end
|
||||
end
|
||||
|
||||
class ErrorDrop < Liquid::Drop
|
||||
def standard_error
|
||||
raise Liquid::StandardError, 'standard error'
|
||||
@@ -199,26 +173,6 @@ class ErrorDrop < Liquid::Drop
|
||||
end
|
||||
end
|
||||
|
||||
class CustomToLiquidDrop < Liquid::Drop
|
||||
def initialize(value)
|
||||
@value = value
|
||||
super()
|
||||
end
|
||||
|
||||
def to_liquid
|
||||
@value
|
||||
end
|
||||
end
|
||||
|
||||
class HashWithCustomToS < Hash
|
||||
def to_s
|
||||
"kewl"
|
||||
end
|
||||
end
|
||||
|
||||
class HashWithoutCustomToS < Hash
|
||||
end
|
||||
|
||||
class StubFileSystem
|
||||
attr_reader :file_read_count
|
||||
|
||||
|
||||
@@ -47,18 +47,12 @@ class BlockUnitTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_comment_tag_with_block
|
||||
def test_with_block
|
||||
template = Liquid::Template.parse(" {% comment %} {% endcomment %} ")
|
||||
assert_equal([String, Comment, String], block_types(template.root.nodelist))
|
||||
assert_equal(3, template.root.nodelist.size)
|
||||
end
|
||||
|
||||
def test_doc_tag_with_block
|
||||
template = Liquid::Template.parse(" {% doc %} {% enddoc %} ")
|
||||
assert_equal([String, Doc, String], block_types(template.root.nodelist))
|
||||
assert_equal(3, template.root.nodelist.size)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def block_types(nodelist)
|
||||
|
||||
@@ -161,208 +161,11 @@ class ConditionUnitTest < Minitest::Test
|
||||
assert_equal(true, Condition.new(1, '==', 1).evaluate)
|
||||
end
|
||||
|
||||
expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \
|
||||
"and will be removed from Liquid 6.0.0."
|
||||
expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
|
||||
" and will be removed from Liquid 6.0.0."
|
||||
assert_includes(err.lines.map(&:strip), expected)
|
||||
end
|
||||
|
||||
def test_parse_expression_in_strict_mode
|
||||
environment = Environment.build(error_mode: :strict)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
result = Condition.parse_expression(parse_context, 'product.title')
|
||||
|
||||
assert_instance_of(VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
def test_parse_expression_in_strict2_mode_raises_internal_error
|
||||
environment = Environment.build(error_mode: :strict2)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
Condition.parse_expression(parse_context, 'product.title')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_safe_true_in_strict2_mode
|
||||
environment = Environment.build(error_mode: :strict2)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
result = Condition.parse_expression(parse_context, 'product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
# Tests for blank? comparison without ActiveSupport
|
||||
#
|
||||
# Ruby's standard library does not include blank? on String, Array, Hash, etc.
|
||||
# ActiveSupport adds blank? but Liquid must work without it. These tests verify
|
||||
# that Liquid implements blank? semantics internally for use in templates like:
|
||||
# {% if x == blank %}...{% endif %}
|
||||
#
|
||||
# The blank? semantics match ActiveSupport's behavior:
|
||||
# - nil and false are blank
|
||||
# - Strings are blank if empty or contain only whitespace
|
||||
# - Arrays and Hashes are blank if empty
|
||||
# - true and numbers are never blank
|
||||
|
||||
def test_blank_with_whitespace_string
|
||||
# Template authors expect " " to be blank since it has no visible content.
|
||||
# This matches ActiveSupport's String#blank? which returns true for whitespace-only strings.
|
||||
@context['whitespace'] = ' '
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('whitespace'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_empty_string
|
||||
# An empty string has no content, so it should be considered blank.
|
||||
# This is the most basic case of a blank string.
|
||||
@context['empty_string'] = ''
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_string'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_empty_array
|
||||
# Empty arrays have no elements, so they are blank.
|
||||
# Useful for checking if a collection has items: {% if products == blank %}
|
||||
@context['empty_array'] = []
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_array'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_empty_hash
|
||||
# Empty hashes have no key-value pairs, so they are blank.
|
||||
# Useful for checking if settings/options exist: {% if settings == blank %}
|
||||
@context['empty_hash'] = {}
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_hash'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_nil
|
||||
# nil represents "nothing" and is the canonical blank value.
|
||||
# Unassigned variables resolve to nil, so this enables: {% if missing_var == blank %}
|
||||
@context['nil_value'] = nil
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('nil_value'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_false
|
||||
# false is considered blank to match ActiveSupport semantics.
|
||||
# This allows {% if some_flag == blank %} to work when flag is false.
|
||||
@context['false_value'] = false
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('false_value'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_true
|
||||
# true is a definite value, not blank.
|
||||
# Ensures {% if flag == blank %} works correctly for boolean flags.
|
||||
@context['true_value'] = true
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('true_value'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_number
|
||||
# Numbers (including zero) are never blank - they represent actual values.
|
||||
# 0 is a valid quantity, not the absence of a value.
|
||||
@context['number'] = 42
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('number'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_string_content
|
||||
# A string with actual content is not blank.
|
||||
# This is the expected behavior for most template string comparisons.
|
||||
@context['string'] = 'hello'
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('string'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_non_empty_array
|
||||
# An array with elements has content, so it's not blank.
|
||||
# Enables patterns like {% unless products == blank %}Show products{% endunless %}
|
||||
@context['array'] = [1, 2, 3]
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('array'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_non_empty_hash
|
||||
# A hash with key-value pairs has content, so it's not blank.
|
||||
# Useful for checking if configuration exists: {% if config != blank %}
|
||||
@context['hash'] = { 'a' => 1 }
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('hash'), '==', blank_literal)
|
||||
end
|
||||
|
||||
# Tests for empty? comparison without ActiveSupport
|
||||
#
|
||||
# empty? is distinct from blank? - it only checks if a collection has zero elements.
|
||||
# For strings, empty? checks length == 0, NOT whitespace content.
|
||||
# Ruby's standard library has empty? on String, Array, and Hash, but Liquid
|
||||
# provides a fallback implementation for consistency.
|
||||
|
||||
def test_empty_with_empty_string
|
||||
# An empty string ("") has length 0, so it's empty.
|
||||
# Different from blank - empty is a stricter check.
|
||||
@context['empty_string'] = ''
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_string'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_empty_with_whitespace_string_not_empty
|
||||
# Whitespace strings have length > 0, so they are NOT empty.
|
||||
# This is the key difference between empty and blank:
|
||||
# " ".empty? => false, but " ".blank? => true
|
||||
@context['whitespace'] = ' '
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('whitespace'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_empty_with_empty_array
|
||||
# An array with no elements is empty.
|
||||
# [].empty? => true
|
||||
@context['empty_array'] = []
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_array'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_empty_with_empty_hash
|
||||
# A hash with no key-value pairs is empty.
|
||||
# {}.empty? => true
|
||||
@context['empty_hash'] = {}
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_nil_is_not_empty
|
||||
# nil is NOT empty - empty? checks if a collection has zero elements.
|
||||
# nil is not a collection, so it cannot be empty.
|
||||
# This differs from blank: nil IS blank, but nil is NOT empty.
|
||||
@context['nil_value'] = nil
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('nil_value'), '==', empty_literal)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_evaluates_true(left, op, right)
|
||||
|
||||
@@ -36,14 +36,14 @@ class EnvironmentFilterTest < Minitest::Test
|
||||
assert_equal("public", strainer.invoke("public_filter"))
|
||||
end
|
||||
|
||||
def test_strainer_raises_argument_error
|
||||
def test_stainer_raises_argument_error
|
||||
strainer = @environment.create_strainer(@context)
|
||||
assert_raises(Liquid::ArgumentError) do
|
||||
strainer.invoke("public_filter", 1)
|
||||
end
|
||||
end
|
||||
|
||||
def test_strainer_argument_error_contains_backtrace
|
||||
def test_stainer_argument_error_contains_backtrace
|
||||
strainer = @environment.create_strainer(@context)
|
||||
|
||||
exception = assert_raises(Liquid::ArgumentError) do
|
||||
@@ -54,9 +54,8 @@ class EnvironmentFilterTest < Minitest::Test
|
||||
/\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/,
|
||||
exception.message,
|
||||
)
|
||||
|
||||
source = AccessScopeFilters.instance_method(:public_filter).source_location
|
||||
assert_equal(source[0..1].map(&:to_s), exception.backtrace[0].split(':')[0..1])
|
||||
assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1])
|
||||
end
|
||||
|
||||
def test_strainer_only_invokes_public_filter_methods
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class EnvironmentTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
class UnsubscribeFooter < Liquid::Tag
|
||||
def render(_context)
|
||||
'Unsubscribe Footer'
|
||||
end
|
||||
end
|
||||
|
||||
def test_custom_tag
|
||||
email_environment = Liquid::Environment.build do |environment|
|
||||
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
|
||||
end
|
||||
|
||||
assert(email_environment.tags["unsubscribe_footer"])
|
||||
assert(email_environment.tag_for_name("unsubscribe_footer"))
|
||||
template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment)
|
||||
|
||||
assert_equal('Unsubscribe Footer', template.render)
|
||||
end
|
||||
end
|
||||
@@ -131,19 +131,9 @@ class LexerUnitTest < Minitest::Test
|
||||
assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1"))
|
||||
end
|
||||
|
||||
def test_error_with_invalid_utf8
|
||||
error = assert_raises(SyntaxError) do
|
||||
tokenize("\x00\xff")
|
||||
end
|
||||
assert_equal(
|
||||
'Liquid syntax error: Invalid byte sequence in UTF-8',
|
||||
error.message,
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tokenize(input)
|
||||
Lexer.tokenize(StringScanner.new(input))
|
||||
Lexer.new(input).tokenize
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class ParseContextUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_safe_parse_expression_with_variable_lookup
|
||||
parser_strict = strict_parse_context.new_parser('product.title')
|
||||
result_strict = strict_parse_context.safe_parse_expression(parser_strict)
|
||||
|
||||
parser_strict2 = strict2_parse_context.new_parser('product.title')
|
||||
result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict2)
|
||||
assert_equal('product', result_strict2.name)
|
||||
assert_equal(['title'], result_strict2.lookups)
|
||||
end
|
||||
|
||||
def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
|
||||
parser_strict = strict_parse_context.new_parser('')
|
||||
parser_strict2 = strict2_parse_context.new_parser('')
|
||||
|
||||
error_strict = assert_raises(Liquid::SyntaxError) do
|
||||
strict_parse_context.safe_parse_expression(parser_strict)
|
||||
end
|
||||
assert_match(/is not a valid expression/, error_strict.message)
|
||||
|
||||
error_strict2 = assert_raises(Liquid::SyntaxError) do
|
||||
strict2_parse_context.safe_parse_expression(parser_strict2)
|
||||
end
|
||||
|
||||
assert_match(/is not a valid expression/, error_strict2.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_variable_lookup
|
||||
result_strict = strict_parse_context.parse_expression('product.title')
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
strict2_parse_context.parse_expression('product.title')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_safe_true
|
||||
result_strict = strict_parse_context.parse_expression('product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict2)
|
||||
assert_equal('product', result_strict2.name)
|
||||
assert_equal(['title'], result_strict2.lookups)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_empty_string
|
||||
result_strict = strict_parse_context.parse_expression('')
|
||||
assert_nil(result_strict)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
strict2_parse_context.parse_expression('')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_empty_string_and_safe_true
|
||||
result_strict = strict_parse_context.parse_expression('', safe: true)
|
||||
assert_nil(result_strict)
|
||||
|
||||
result_strict2 = strict2_parse_context.parse_expression('', safe: true)
|
||||
assert_nil(result_strict2)
|
||||
end
|
||||
|
||||
def test_safe_parse_expression_advances_parser_pointer
|
||||
parser = strict2_parse_context.new_parser('foo, bar')
|
||||
|
||||
# safe_parse_expression consumes "foo"
|
||||
first_result = strict2_parse_context.safe_parse_expression(parser)
|
||||
assert_instance_of(VariableLookup, first_result)
|
||||
assert_equal('foo', first_result.name)
|
||||
|
||||
parser.consume(:comma)
|
||||
|
||||
# safe_parse_expression consumes "bar"
|
||||
second_result = strict2_parse_context.safe_parse_expression(parser)
|
||||
assert_instance_of(VariableLookup, second_result)
|
||||
assert_equal('bar', second_result.name)
|
||||
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_whitespace_in_strict2_mode
|
||||
result = strict2_parse_context.parse_expression(' ', safe: true)
|
||||
assert_nil(result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strict_parse_context
|
||||
@strict_parse_context ||= ParseContext.new(
|
||||
environment: Environment.build(error_mode: :strict),
|
||||
)
|
||||
end
|
||||
|
||||
def strict2_parse_context
|
||||
@strict2_parse_context ||= ParseContext.new(
|
||||
environment: Environment.build(error_mode: :strict2),
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -6,20 +6,20 @@ class ParserUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_consume
|
||||
p = new_parser("wat: 7")
|
||||
p = Parser.new("wat: 7")
|
||||
assert_equal('wat', p.consume(:id))
|
||||
assert_equal(':', p.consume(:colon))
|
||||
assert_equal('7', p.consume(:number))
|
||||
end
|
||||
|
||||
def test_jump
|
||||
p = new_parser("wat: 7")
|
||||
p = Parser.new("wat: 7")
|
||||
p.jump(2)
|
||||
assert_equal('7', p.consume(:number))
|
||||
end
|
||||
|
||||
def test_consume?
|
||||
p = new_parser("wat: 7")
|
||||
p = Parser.new("wat: 7")
|
||||
assert_equal('wat', p.consume?(:id))
|
||||
assert_equal(false, p.consume?(:dot))
|
||||
assert_equal(':', p.consume(:colon))
|
||||
@@ -27,7 +27,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_id?
|
||||
p = new_parser("wat 6 Peter Hegemon")
|
||||
p = Parser.new("wat 6 Peter Hegemon")
|
||||
assert_equal('wat', p.id?('wat'))
|
||||
assert_equal(false, p.id?('endgame'))
|
||||
assert_equal('6', p.consume(:number))
|
||||
@@ -36,7 +36,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_look
|
||||
p = new_parser("wat 6 Peter Hegemon")
|
||||
p = Parser.new("wat 6 Peter Hegemon")
|
||||
assert_equal(true, p.look(:id))
|
||||
assert_equal('wat', p.consume(:id))
|
||||
assert_equal(false, p.look(:comparison))
|
||||
@@ -46,12 +46,12 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_expressions
|
||||
p = new_parser("hi.there hi?[5].there? hi.there.bob")
|
||||
p = Parser.new("hi.there hi?[5].there? hi.there.bob")
|
||||
assert_equal('hi.there', p.expression)
|
||||
assert_equal('hi?[5].there?', p.expression)
|
||||
assert_equal('hi.there.bob', p.expression)
|
||||
|
||||
p = new_parser("567 6.0 'lol' \"wut\"")
|
||||
p = Parser.new("567 6.0 'lol' \"wut\"")
|
||||
assert_equal('567', p.expression)
|
||||
assert_equal('6.0', p.expression)
|
||||
assert_equal("'lol'", p.expression)
|
||||
@@ -59,7 +59,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_ranges
|
||||
p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
|
||||
p = Parser.new("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
|
||||
assert_equal('(5..7)', p.expression)
|
||||
assert_equal('(1.5..9.6)', p.expression)
|
||||
assert_equal('(young..old)', p.expression)
|
||||
@@ -67,7 +67,7 @@ class ParserUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_arguments
|
||||
p = new_parser("filter: hi.there[5], keyarg: 7")
|
||||
p = Parser.new("filter: hi.there[5], keyarg: 7")
|
||||
assert_equal('filter', p.consume(:id))
|
||||
assert_equal(':', p.consume(:colon))
|
||||
assert_equal('hi.there[5]', p.argument)
|
||||
@@ -77,14 +77,8 @@ class ParserUnitTest < Minitest::Test
|
||||
|
||||
def test_invalid_expression
|
||||
assert_raises(SyntaxError) do
|
||||
p = new_parser("==")
|
||||
p = Parser.new("==")
|
||||
p.expression
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_parser(str)
|
||||
Parser.new(StringScanner.new(str))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -184,7 +184,7 @@ class PartialCacheUnitTest < Minitest::Test
|
||||
},
|
||||
)
|
||||
|
||||
[:lax, :warn, :strict, :strict2].each do |error_mode|
|
||||
[:lax, :warn, :strict].each do |error_mode|
|
||||
Liquid::PartialCache.load(
|
||||
'my_partial',
|
||||
context: context,
|
||||
@@ -193,7 +193,7 @@ class PartialCacheUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
assert_equal(
|
||||
["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:strict2"],
|
||||
["my_partial:lax", "my_partial:warn", "my_partial:strict"],
|
||||
context.registers[:cached_partials].keys,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class ResourceLimitsUnitTest < Minitest::Test
|
||||
def test_cumulative_scores_initialize_to_zero
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
assert_equal(0, limits.cumulative_render_score)
|
||||
assert_equal(0, limits.cumulative_assign_score)
|
||||
end
|
||||
|
||||
def test_cumulative_limits_default_to_nil
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
assert_nil(limits.cumulative_render_score_limit)
|
||||
assert_nil(limits.cumulative_assign_score_limit)
|
||||
end
|
||||
|
||||
def test_cumulative_limits_configurable_via_hash
|
||||
limits = Liquid::ResourceLimits.new(
|
||||
cumulative_render_score_limit: 500,
|
||||
cumulative_assign_score_limit: 300,
|
||||
)
|
||||
assert_equal(500, limits.cumulative_render_score_limit)
|
||||
assert_equal(300, limits.cumulative_assign_score_limit)
|
||||
end
|
||||
|
||||
def test_cumulative_limits_configurable_via_accessor
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
limits.cumulative_render_score_limit = 500
|
||||
assert_equal(500, limits.cumulative_render_score_limit)
|
||||
end
|
||||
|
||||
def test_cumulative_scores_survive_reset
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
limits.increment_render_score(10)
|
||||
limits.increment_assign_score(5)
|
||||
|
||||
limits.reset
|
||||
|
||||
assert_equal(0, limits.render_score)
|
||||
assert_equal(0, limits.assign_score)
|
||||
assert_equal(10, limits.cumulative_render_score)
|
||||
assert_equal(5, limits.cumulative_assign_score)
|
||||
end
|
||||
|
||||
def test_cumulative_scores_accumulate_across_resets
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
limits.increment_render_score(10)
|
||||
limits.reset
|
||||
limits.increment_render_score(20)
|
||||
limits.reset
|
||||
limits.increment_render_score(30)
|
||||
|
||||
assert_equal(30, limits.render_score)
|
||||
assert_equal(60, limits.cumulative_render_score)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_limit_raises
|
||||
limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25)
|
||||
limits.increment_render_score(10)
|
||||
limits.reset
|
||||
limits.increment_render_score(10)
|
||||
limits.reset
|
||||
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
limits.increment_render_score(10)
|
||||
end
|
||||
assert(limits.reached?)
|
||||
end
|
||||
|
||||
def test_cumulative_assign_score_limit_raises
|
||||
limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15)
|
||||
limits.increment_assign_score(8)
|
||||
limits.reset
|
||||
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
limits.increment_assign_score(8)
|
||||
end
|
||||
assert(limits.reached?)
|
||||
end
|
||||
|
||||
def test_per_template_limits_still_work_with_cumulative
|
||||
limits = Liquid::ResourceLimits.new(
|
||||
render_score_limit: 50,
|
||||
cumulative_render_score_limit: 1000,
|
||||
)
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
limits.increment_render_score(51)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ class StrainerTemplateUnitTest < Minitest::Test
|
||||
def test_add_filter_when_wrong_filter_class
|
||||
c = Context.new
|
||||
s = c.strainer
|
||||
wrong_filter = lambda(&:reverse)
|
||||
wrong_filter = ->(v) { v.reverse }
|
||||
|
||||
exception = assert_raises(TypeError) do
|
||||
s.class.add_filter(wrong_filter)
|
||||
|
||||
@@ -6,18 +6,18 @@ class TagUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_tag
|
||||
tag = Tag.parse('tag', "", new_tokenizer, ParseContext.new)
|
||||
tag = Tag.parse('tag', "", Tokenizer.new(""), ParseContext.new)
|
||||
assert_equal('liquid::tag', tag.name)
|
||||
assert_equal('', tag.render(Context.new))
|
||||
end
|
||||
|
||||
def test_return_raw_text_of_tag
|
||||
tag = Tag.parse("long_tag", "param1, param2, param3", new_tokenizer, ParseContext.new)
|
||||
tag = Tag.parse("long_tag", "param1, param2, param3", Tokenizer.new(""), ParseContext.new)
|
||||
assert_equal("long_tag param1, param2, param3", tag.raw)
|
||||
end
|
||||
|
||||
def test_tag_name_should_return_name_of_the_tag
|
||||
tag = Tag.parse("some_tag", "", new_tokenizer, ParseContext.new)
|
||||
tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
|
||||
assert_equal('some_tag', tag.tag_name)
|
||||
end
|
||||
|
||||
@@ -26,16 +26,7 @@ class TagUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_tag_render_to_output_buffer_nil_value
|
||||
custom_tag = CustomTag.parse("some_tag", "", new_tokenizer, ParseContext.new)
|
||||
custom_tag = CustomTag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new)
|
||||
assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_tokenizer
|
||||
Tokenizer.new(
|
||||
source: "",
|
||||
string_scanner: StringScanner.new(""),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,140 +9,4 @@ class CaseTagUnitTest < Minitest::Test
|
||||
template = Liquid::Template.parse('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}')
|
||||
assert_equal(['WHEN', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten)
|
||||
end
|
||||
|
||||
def test_case_with_trailing_element
|
||||
template = <<~LIQUID
|
||||
{%- case 1 bar -%}
|
||||
{%- when 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Expected end_of_string but found/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_trailing_element
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 1 bar -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Expected end_of_string but found/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_comma
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 2, 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_or
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 2 or 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_empty
|
||||
template = <<~LIQUID
|
||||
{%- case x -%}
|
||||
{%- when 2 or empty -%}
|
||||
2 or empty
|
||||
{%- else -%}
|
||||
not 2 or empty
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
assert_template_result("2 or empty", template, { 'x' => 2 })
|
||||
assert_template_result("2 or empty", template, { 'x' => {} })
|
||||
assert_template_result("2 or empty", template, { 'x' => [] })
|
||||
assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } })
|
||||
assert_template_result("not 2 or empty", template, { 'x' => ['a'] })
|
||||
assert_template_result("not 2 or empty", template, { 'x' => 4 })
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{%- case foo=>bar -%}
|
||||
{%- when 'baz' -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
assigns = { 'foo' => { 'bar' => 'baz' } }
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template, assigns)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{%- case 'baz' -%}
|
||||
{%- when foo=>bar -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
assigns = { 'foo' => { 'bar' => 'baz' } }
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template, assigns)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class DocTagUnitTest < Minitest::Test
|
||||
def test_doc_tag
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
Renders loading-spinner.
|
||||
|
||||
@param {string} foo - some foo
|
||||
@param {string} [bar] - optional bar
|
||||
|
||||
@example
|
||||
{% render 'loading-spinner', foo: 'foo' %}
|
||||
{% render 'loading-spinner', foo: 'foo', bar: 'bar' %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_body_content
|
||||
doc_content = " Documentation content\n @param {string} foo - test\n"
|
||||
template_source = "{% doc %}#{doc_content}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(doc_content, doc_tag.nodelist.first.to_s)
|
||||
end
|
||||
|
||||
def test_doc_tag_does_not_support_extra_arguments
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc extra %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template)
|
||||
end
|
||||
|
||||
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}"
|
||||
act_error = error.message
|
||||
|
||||
assert_equal(exp_error, act_error)
|
||||
end
|
||||
|
||||
def test_doc_tag_must_support_valid_tags
|
||||
assert_match_syntax_error("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %} foo')
|
||||
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo {% enddoc %}')
|
||||
assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo %}{% enddoc %}')
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_liquid_nodes
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% if true %}
|
||||
{% if ... %}
|
||||
{%- for ? -%}
|
||||
{% while true %}
|
||||
{%
|
||||
unless if
|
||||
%}
|
||||
{% endcase %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_unclosed_liquid_tags
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% if true %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_does_not_allow_nested_docs
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% doc %}
|
||||
{% doc %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
Liquid::Template.parse(template)
|
||||
end
|
||||
|
||||
exp_error = "Liquid syntax error: Syntax Error in 'doc' - Nested doc tags are not allowed"
|
||||
act_error = error.message
|
||||
|
||||
assert_equal(exp_error, act_error)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_nested_raw_tags
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% raw %}
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_unclosed_assign
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% assign foo = "1"
|
||||
{% enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_ignores_malformed_syntax
|
||||
template = <<~LIQUID.chomp
|
||||
{% doc %}
|
||||
{% {{ {%- enddoc %}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_captures_token_before_enddoc
|
||||
template_source = "{% doc %}{{ incomplete{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s)
|
||||
end
|
||||
|
||||
def test_doc_tag_preserves_error_line_numbers
|
||||
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
|
||||
{% doc %}
|
||||
{% if true %}
|
||||
{% enddoc %}
|
||||
{{ errors.standard_error }}
|
||||
LIQUID
|
||||
|
||||
expected = <<~TEXT.chomp
|
||||
|
||||
Liquid error (line 4): standard error
|
||||
TEXT
|
||||
|
||||
assert_equal(expected, template.render('errors' => ErrorDrop.new))
|
||||
end
|
||||
|
||||
def test_doc_tag_whitespace_control
|
||||
# Basic whitespace control
|
||||
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!")
|
||||
assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!")
|
||||
assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!")
|
||||
assert_template_result("Hello!", <<~LIQUID.chomp)
|
||||
{%- doc %}Whitespace control!{% enddoc -%}
|
||||
Hello!
|
||||
LIQUID
|
||||
end
|
||||
|
||||
def test_doc_tag_delimiter_handling
|
||||
assert_template_result('', <<~LIQUID.chomp)
|
||||
{%- if true -%}
|
||||
{%- doc -%}
|
||||
{%- docEXTRA -%}wut{% enddocEXTRA -%}xyz
|
||||
{%- enddoc -%}
|
||||
{%- endif -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', "{% doc %}123{% enddoc xyz %}")
|
||||
assert_template_result('', "{% doc %}123{% enddoc\txyz %}")
|
||||
assert_template_result('', "{% doc %}123{% enddoc\nxyz %}")
|
||||
assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}")
|
||||
end
|
||||
|
||||
def test_doc_tag_visitor
|
||||
template_source = '{% doc %}{% enddoc %}'
|
||||
|
||||
assert_equal(
|
||||
[Liquid::Doc],
|
||||
visit(template_source),
|
||||
)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_empty_content
|
||||
template_source = "{% doc %}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(true, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_content
|
||||
template_source = "{% doc %}Some documentation{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(false, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_whitespace_only
|
||||
template_source = "{% doc %} {% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(false, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_nodelist_returns_array_with_body
|
||||
doc_content = "Documentation content\n@param {string} foo"
|
||||
template_source = "{% doc %}#{doc_content}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal([doc_content], doc_tag.nodelist)
|
||||
assert_equal(1, doc_tag.nodelist.length)
|
||||
assert_equal(doc_content, doc_tag.nodelist.first)
|
||||
end
|
||||
|
||||
def test_doc_tag_nodelist_with_empty_content
|
||||
template_source = "{% doc %}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal([""], doc_tag.nodelist)
|
||||
assert_equal(1, doc_tag.nodelist.length)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def traversal(template)
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
tag_class = tag.class
|
||||
tag_class
|
||||
end
|
||||
end
|
||||
|
||||
def visit(template)
|
||||
traversal(template).visit.flatten.compact
|
||||
end
|
||||
end
|
||||
@@ -35,15 +35,4 @@ class TemplateUnitTest < Minitest::Test
|
||||
def test_template_inheritance
|
||||
assert_equal("foo", TemplateSubclass.parse("foo").render)
|
||||
end
|
||||
|
||||
def test_invalid_utf8
|
||||
input = "\xff\x00"
|
||||
error = assert_raises(SyntaxError) do
|
||||
Liquid::Tokenizer.new(source: input, string_scanner: StringScanner.new(input))
|
||||
end
|
||||
assert_equal(
|
||||
'Liquid syntax error: Invalid byte sequence in UTF-8',
|
||||
error.message,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,6 @@ class TokenizerTest < Minitest::Test
|
||||
def test_tokenize_strings
|
||||
assert_equal([' '], tokenize(' '))
|
||||
assert_equal(['hello world'], tokenize('hello world'))
|
||||
assert_equal(['{}'], tokenize('{}'))
|
||||
end
|
||||
|
||||
def test_tokenize_variables
|
||||
@@ -31,50 +30,6 @@ class TokenizerTest < Minitest::Test
|
||||
assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} "))
|
||||
end
|
||||
|
||||
def test_tokenize_with_nil_source_returns_empty_array
|
||||
assert_equal([], tokenize(nil))
|
||||
end
|
||||
|
||||
def test_incomplete_curly_braces
|
||||
assert_equal(["{{.}", " "], tokenize('{{.} '))
|
||||
assert_equal(["{{}", "%}"], tokenize('{{}%}'))
|
||||
assert_equal(["{{}}", "}"], tokenize('{{}}}'))
|
||||
end
|
||||
|
||||
def test_unmatching_start_and_end
|
||||
assert_equal(["{{%}"], tokenize('{{%}'))
|
||||
assert_equal(["{{%%%}}"], tokenize('{{%%%}}'))
|
||||
assert_equal(["{%", "}}"], tokenize('{%}}'))
|
||||
assert_equal(["{%%}", "}"], tokenize('{%%}}'))
|
||||
end
|
||||
|
||||
# Regression: lone '{' at or near end of string previously caused an infinite
|
||||
# loop. The stray-{ else branch left `pos` unchanged when no further '{{' or
|
||||
# '{%' existed, so the outer loop found the same '{' on every iteration.
|
||||
def test_lone_brace_does_not_loop
|
||||
assert_equal(["{"], tokenize('{'))
|
||||
assert_equal(["a{"], tokenize('a{'))
|
||||
assert_equal(["hello { world {"], tokenize('hello { world {'))
|
||||
assert_equal(["{ world"], tokenize('{ world'))
|
||||
assert_equal(["x{y"], tokenize('x{y'))
|
||||
assert_equal(["{b{c"], tokenize('{b{c'))
|
||||
end
|
||||
|
||||
def test_lone_brace_before_real_token
|
||||
assert_equal(
|
||||
["a { b ", "{% if x %}", "yes", "{% endif %}", " c"],
|
||||
tokenize('a { b {% if x %}yes{% endif %} c'),
|
||||
)
|
||||
assert_equal(
|
||||
["x { ", "{{ var }}", " y"],
|
||||
tokenize('x { {{ var }} y'),
|
||||
)
|
||||
assert_equal(
|
||||
["{ ", "{{ var }}"],
|
||||
tokenize('{ {{ var }}'),
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil)
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
# Tests that the fast-path parser (try_fast_parse) produces the same result as the
|
||||
# full Lexer → Parser pipeline for every input we expect it to handle.
|
||||
#
|
||||
# This protects against silent regressions where a change to try_fast_parse causes it
|
||||
# to produce different output from the slow path (the existing test suite would still
|
||||
# pass because the slow path catches it, but correctness would be silently lost).
|
||||
class VariableFastParseTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
EQUIVALENCE_CASES = [
|
||||
# Simple lookups
|
||||
"product",
|
||||
"product.title",
|
||||
"product.variants.first.title",
|
||||
# Quoted string literals
|
||||
"'hello'",
|
||||
'"hello"',
|
||||
# Variables with no-arg filters
|
||||
"product | upcase",
|
||||
"product | upcase | downcase",
|
||||
"product | strip | upcase | downcase",
|
||||
# Variables with single-arg filters
|
||||
"product | truncate: 50",
|
||||
"product | plus: 1",
|
||||
"product | plus: -3",
|
||||
"product | round: 2",
|
||||
"product | append: ' world'",
|
||||
# Variables with multi-arg filters
|
||||
"product | replace: 'a', 'b'",
|
||||
"product | pluralize: 'item', 'items'",
|
||||
"product | slice: 0, 5",
|
||||
# Chained mixed filters
|
||||
"product.title | truncate: 50",
|
||||
"'hello' | append: ' world' | upcase",
|
||||
"name | prepend: 'Dr. ' | append: ' PhD' | upcase",
|
||||
# Numeric args
|
||||
"count | plus: 1.5",
|
||||
"price | minus: 0.99",
|
||||
# No whitespace around pipe
|
||||
"x|upcase",
|
||||
"x|replace:'a','b'|upcase",
|
||||
# Leading/trailing whitespace
|
||||
" product ",
|
||||
" product.title | upcase ",
|
||||
].freeze
|
||||
|
||||
EQUIVALENCE_CASES.each_with_index do |markup, i|
|
||||
define_method(:"test_fast_parse_equivalence_#{i.to_s.rjust(2, "0")}") do
|
||||
lax_ctx = Liquid::ParseContext.new(error_mode: :lax)
|
||||
strict_ctx = Liquid::ParseContext.new(error_mode: :strict)
|
||||
|
||||
lax_var = Liquid::Variable.new(markup, lax_ctx)
|
||||
strict_var = Liquid::Variable.new(markup, strict_ctx)
|
||||
|
||||
assert_equal strict_var.name,
|
||||
lax_var.name,
|
||||
"Name mismatch for #{markup.inspect}: " \
|
||||
"lax=#{lax_var.name.inspect} strict=#{strict_var.name.inspect}"
|
||||
assert_equal strict_var.filters.length,
|
||||
lax_var.filters.length,
|
||||
"Filter count mismatch for #{markup.inspect}: " \
|
||||
"lax=#{lax_var.filters.inspect} strict=#{strict_var.filters.inspect}"
|
||||
strict_var.filters.each_with_index do |(s_name, *), i|
|
||||
l_name = lax_var.filters[i][0]
|
||||
assert_equal s_name,
|
||||
l_name,
|
||||
"Filter name mismatch at index #{i} for #{markup.inspect}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Verify the fast path is actually taken for simple variables (i.e. filters is the
|
||||
# shared frozen EMPTY_ARRAY, not a newly allocated array).
|
||||
def test_fast_path_taken_for_simple_variable
|
||||
ctx = Liquid::ParseContext.new(error_mode: :lax)
|
||||
var = Liquid::Variable.new("product.title", ctx)
|
||||
assert_same(
|
||||
Liquid::Const::EMPTY_ARRAY,
|
||||
var.filters,
|
||||
"Expected fast path (frozen EMPTY_ARRAY) for simple variable",
|
||||
)
|
||||
end
|
||||
|
||||
def test_fast_path_taken_for_no_arg_filter
|
||||
ctx = Liquid::ParseContext.new(error_mode: :lax)
|
||||
var = Liquid::Variable.new("product | upcase", ctx)
|
||||
assert_equal(1, var.filters.length)
|
||||
assert_equal("upcase", var.filters[0][0])
|
||||
# The no-arg filter tuple should come from NO_ARG_FILTER_CACHE (frozen)
|
||||
assert_predicate(var.filters[0], :frozen?)
|
||||
end
|
||||
|
||||
def test_fast_path_taken_for_single_arg_filter
|
||||
ctx = Liquid::ParseContext.new(error_mode: :lax)
|
||||
var = Liquid::Variable.new("product | truncate: 50", ctx)
|
||||
assert_equal(1, var.filters.length)
|
||||
assert_equal("truncate", var.filters[0][0])
|
||||
assert_equal([50], var.filters[0][1])
|
||||
end
|
||||
|
||||
# Keyword args must fall through to the Lexer — verify the result is still correct.
|
||||
def test_keyword_arg_falls_to_lexer_and_parses_correctly
|
||||
ctx = Liquid::ParseContext.new(error_mode: :lax)
|
||||
var = Liquid::Variable.new("img | img_tag: class: 'hero'", ctx)
|
||||
assert_equal(1, var.filters.length)
|
||||
assert_equal("img_tag", var.filters[0][0])
|
||||
end
|
||||
|
||||
# Numeric filter arguments: integers and floats
|
||||
def test_numeric_filter_args
|
||||
ctx = Liquid::ParseContext.new(error_mode: :lax)
|
||||
|
||||
int_var = Liquid::Variable.new("price | plus: 3", ctx)
|
||||
assert_equal([3], int_var.filters[0][1])
|
||||
|
||||
neg_var = Liquid::Variable.new("price | minus: -1", ctx)
|
||||
assert_equal([-1], neg_var.filters[0][1])
|
||||
|
||||
float_var = Liquid::Variable.new("price | round: 2.5", ctx)
|
||||
assert_equal([2.5], float_var.filters[0][1])
|
||||
end
|
||||
end
|
||||
@@ -108,7 +108,7 @@ class VariableUnitTest < Minitest::Test
|
||||
assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name)
|
||||
assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name)
|
||||
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
|
||||
@@ -135,68 +135,16 @@ class VariableUnitTest < Minitest::Test
|
||||
var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax)
|
||||
assert_equal(VariableLookup.new('number_of_comments'), var.name)
|
||||
assert_equal([['pluralize', ['comment', 'comments']]], var.filters)
|
||||
|
||||
# missing does not throws error
|
||||
create_variable(%(n | f1: ,), error_mode: :lax)
|
||||
create_variable(%(n | f1: ,| f2), error_mode: :lax)
|
||||
|
||||
# arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':'
|
||||
var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax)
|
||||
assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters)
|
||||
|
||||
# positional and kwargs parsing
|
||||
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax)
|
||||
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs intermixed (pos1, key1: val1, pos2)
|
||||
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax)
|
||||
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
|
||||
end
|
||||
|
||||
def test_strict_filter_argument_parsing
|
||||
with_error_modes(:strict) do
|
||||
with_error_mode(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_strict2_filter_argument_parsing
|
||||
with_error_modes(:strict2) do
|
||||
# optional colon
|
||||
var = create_variable(%(n | f1 | f2:))
|
||||
assert_equal([['f1', []], ['f2', []]], var.filters)
|
||||
|
||||
# missing argument throws error
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
|
||||
|
||||
# arg requires colon
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
|
||||
|
||||
# trailing comma doesn't throw
|
||||
create_variable(%(n | f1: 1, 2, 3, | f2:))
|
||||
|
||||
# missing comma throws error
|
||||
assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
|
||||
|
||||
# positional and kwargs parsing
|
||||
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
|
||||
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs mixed
|
||||
var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
|
||||
assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs intermixed (pos1, key1: val1, pos2)
|
||||
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
|
||||
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
|
||||
|
||||
# string key throws
|
||||
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
|
||||
end
|
||||
end
|
||||
|
||||
def test_output_raw_source_of_variable
|
||||
var = create_variable(%( name_of_variable | upcase ))
|
||||
assert_equal(" name_of_variable | upcase ", var.raw)
|
||||
|
||||
Reference in New Issue
Block a user