diff --git a/README.md b/README.md index 74ae3ec..5769bf7 100644 --- a/README.md +++ b/README.md @@ -16,32 +16,32 @@ Building a Liquid implementation (compiler, interpreter, or transpiler)? liquid- ## How It Works ``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ liquid-spec │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ -│ │ YAML Spec │ │ Adapter │ │ Your Implementation │ │ -│ │ Files │─────▶│ (Bridge) │─────▶│ (compile + render) │ │ -│ │ │ │ │ │ │ │ -│ │ • template │ │ LiquidSpec │ │ MyLiquid.parse(src) │ │ -│ │ • env vars │ │ .compile │ │ template.render(vars) │ │ -│ │ • expected │ │ .render │ │ │ │ -│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ -│ │ │ │ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ Test Runner │ │ -│ │ │ │ -│ │ For each spec: │ │ -│ │ 1. Compile template via adapter │ │ -│ │ 2. Render with environment variables │ │ -│ │ 3. Compare output to expected │ │ -│ │ 4. Report pass/fail │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────┐ +│ liquid-spec │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ YAML Spec │ │ Adapter │ │ Your Implementation │ │ +│ │ Files │────▶│ (Bridge) │────▶│ (compile + render) │ │ +│ │ │ │ │ │ │ │ +│ │ • template │ │ LiquidSpec │ │ MyLiquid.parse(src) │ │ +│ │ • env vars │ │ .compile │ │ template.render(vars) │ │ +│ │ • expected │ │ .render │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ +│ │ │ │ │ +│ └───────────────────┼────────────────────────┘ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Test Runner │ │ +│ │ │ │ +│ │ For each spec: │ │ +│ │ 1. Compile template via adapter │ │ +│ │ 2. Render with environment variables │ │ +│ │ 3. Compare output to expected │ │ +│ │ 4. Report pass/fail │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ ``` ## Installation @@ -84,21 +84,22 @@ end # Parse template source into a template object LiquidSpec.compile do |source, options| + # options includes: :line_numbers, :error_mode MyLiquid::Template.parse(source, **options) end -# Render a compiled template with test context -LiquidSpec.render do |template, ctx| - template.render(ctx.environment) +# Render a compiled template +LiquidSpec.render do |template, assigns, options| + # assigns = variables hash + # options includes: :registers, :strict_errors, :exception_renderer + template.render(assigns, **options) end ``` -The `ctx` object in render provides: -- `ctx.environment` - Variables to render with (Hash) -- `ctx.file_system` - For `{% include %}` and `{% render %}` tags -- `ctx.error_mode` - `:lax` or `:strict` -- `ctx.registers` - Implementation-specific data -- `ctx.rethrow_errors?` - Whether to raise or capture errors +The `options` hash in render includes: +- `:registers` - Hash with `:file_system` and `:template_factory` +- `:strict_errors` - If true, raise errors; if false, render them inline +- `:exception_renderer` - Custom exception handler (optional) ## Test Suites diff --git a/examples/liquid_c.rb b/examples/liquid_c.rb index d195bb9..f11135c 100644 --- a/examples/liquid_c.rb +++ b/examples/liquid_c.rb @@ -20,21 +20,20 @@ end LiquidSpec.configure do |config| - # liquid-c supports both core and lax parsing config.features = [:core, :lax_parsing] end LiquidSpec.compile do |source, options| - Liquid::Template.parse(source, line_numbers: true, **options) + Liquid::Template.parse(source, **options) end -LiquidSpec.render do |template, ctx| - liquid_ctx = ctx.context_klass.build( - static_environments: ctx.environment, - registers: Liquid::Registers.new(ctx.registers), - rethrow_errors: ctx.rethrow_errors?, +LiquidSpec.render do |template, assigns, options| + context = Liquid::Context.build( + static_environments: assigns, + registers: Liquid::Registers.new(options[:registers] || {}), + rethrow_errors: options[:strict_errors], ) - liquid_ctx.exception_renderer = ctx.exception_renderer + context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - template.render(liquid_ctx) + template.render(context) end diff --git a/examples/liquid_ruby.rb b/examples/liquid_ruby.rb index e5b988c..ad2f497 100644 --- a/examples/liquid_ruby.rb +++ b/examples/liquid_ruby.rb @@ -19,25 +19,21 @@ end LiquidSpec.configure do |config| - # Declare which features this Liquid implementation supports - config.features = [ - :core, # Basic Liquid parsing and rendering - :lax_parsing, # Supports error_mode: :lax - ] + config.features = [:core, :lax_parsing] end LiquidSpec.compile do |source, options| - Liquid::Template.parse(source, line_numbers: true, disable_liquid_c_nodes: true, **options) + Liquid::Template.parse(source, **options) end -LiquidSpec.render do |template, ctx| - liquid_ctx = ctx.context_klass.build( - static_environments: ctx.environment, - registers: Liquid::Registers.new(ctx.registers), - rethrow_errors: ctx.rethrow_errors?, +LiquidSpec.render do |template, assigns, options| + # Build context with static_environments (read-only assigns that can be shadowed) + context = Liquid::Context.build( + static_environments: assigns, + registers: Liquid::Registers.new(options[:registers] || {}), + rethrow_errors: options[:strict_errors], ) - # Only set exception_renderer if provided (otherwise keep default) - liquid_ctx.exception_renderer = ctx.exception_renderer if ctx.exception_renderer + context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - template.render(liquid_ctx) + template.render(context) end diff --git a/examples/liquid_ruby_strict.rb b/examples/liquid_ruby_strict.rb index 9617763..cd36a1e 100644 --- a/examples/liquid_ruby_strict.rb +++ b/examples/liquid_ruby_strict.rb @@ -19,22 +19,21 @@ end LiquidSpec.configure do |config| - # Only core feature - no lax_parsing support config.features = [:core] end LiquidSpec.compile do |source, options| # Force strict mode regardless of spec - Liquid::Template.parse(source, line_numbers: true, error_mode: :strict, disable_liquid_c_nodes: true, **options) + Liquid::Template.parse(source, error_mode: :strict, **options) end -LiquidSpec.render do |template, ctx| - liquid_ctx = ctx.context_klass.build( - static_environments: ctx.environment, - registers: Liquid::Registers.new(ctx.registers), - rethrow_errors: ctx.rethrow_errors?, +LiquidSpec.render do |template, assigns, options| + context = Liquid::Context.build( + static_environments: assigns, + registers: Liquid::Registers.new(options[:registers] || {}), + rethrow_errors: options[:strict_errors], ) - liquid_ctx.exception_renderer = ctx.exception_renderer + context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - template.render(liquid_ctx) + template.render(context) end diff --git a/lib/liquid/spec/cli/adapter_dsl.rb b/lib/liquid/spec/cli/adapter_dsl.rb index 9b87836..ec35534 100644 --- a/lib/liquid/spec/cli/adapter_dsl.rb +++ b/lib/liquid/spec/cli/adapter_dsl.rb @@ -17,18 +17,21 @@ module LiquidSpec }.freeze class Configuration + # CLI-controlled options attr_accessor :suite, :filter, :verbose, :strict_only - attr_reader :features - # Default suite - :all runs all suites the adapter supports - DEFAULT_SUITE = :all + # Adapter-declared features + attr_reader :features def initialize - @suite = DEFAULT_SUITE + # CLI defaults + @suite = :all @filter = nil @verbose = false @strict_only = false - @features = [:core] # Core is always enabled by default + + # Adapter defaults + @features = [:core] end # Set the features this adapter implements @@ -42,72 +45,6 @@ def features=(list) def feature?(name) @features.include?(name.to_sym) end - - # Add features - def add_features(*names) - names.each { |n| @features << n.to_sym unless @features.include?(n.to_sym) } - end - end - - # Context passed to render block - provides clean, ready-to-use data - class Context - attr_reader :environment, - :file_system, - :exception_renderer, - :template_factory, - :error_mode, - :render_errors, - :context_klass - - def initialize(spec_context) - # Deep copy environment to avoid mutation - # Skip deep copy for recursive structures (they'll cause stack overflow) - env = spec_context[:environment] || {} - @environment = safe_deep_copy(env) - - @file_system = spec_context[:file_system] - @exception_renderer = spec_context[:exception_renderer] - @template_factory = spec_context[:template_factory] - @error_mode = spec_context[:error_mode] - @render_errors = spec_context[:render_errors] - @context_klass = spec_context[:context_klass] - end - - # Build registers hash with file_system and template_factory - def registers - { - file_system: @file_system, - template_factory: @template_factory, - }.compact - end - - # Should errors be rethrown or rendered inline? - def rethrow_errors? - !@render_errors - end - - private - - def safe_deep_copy(obj, seen = {}.compare_by_identity) - return seen[obj] if seen.key?(obj) - - case obj - when Hash - # Preserve the original class (for custom Hash subclasses like HashWithCustomToS) - copy = obj.class.new - seen[obj] = copy - obj.each { |k, v| copy[safe_deep_copy(k, seen)] = safe_deep_copy(v, seen) } - copy - when Array - copy = [] - seen[obj] = copy - obj.each { |v| copy << safe_deep_copy(v, seen) } - copy - else - # Immutable or not worth copying (strings, numbers, symbols, drops, etc.) - obj - end - end end class << self @@ -125,11 +62,13 @@ def setup(&block) end # Define how to compile/parse a template + # Block receives: |source, options| def compile(&block) @compile_block = block end # Define how to render a compiled template + # Block receives: |template, assigns, options| def render(&block) @render_block = block end @@ -160,12 +99,11 @@ def do_compile(source, options = {}) end # Internal: render a template using the adapter - def do_render(template, context) + def do_render(template, assigns, options = {}) run_setup! - raise "No render block defined. Use LiquidSpec.render { |template, ctx| ... }" unless @render_block + raise "No render block defined. Use LiquidSpec.render { |template, assigns, options| ... }" unless @render_block - ctx = Context.new(context) - @render_block.call(template, ctx) + @render_block.call(template, assigns, options) end def running_from_cli! diff --git a/lib/liquid/spec/cli/runner.rb b/lib/liquid/spec/cli/runner.rb index 1b735d7..928db3e 100644 --- a/lib/liquid/spec/cli/runner.rb +++ b/lib/liquid/spec/cli/runner.rb @@ -183,8 +183,10 @@ def self.run_specs_frozen(config, options) puts "Features: #{features.join(", ")}" puts "" - # Collect suites to run - suites_to_run = Liquid::Spec::Suite.all.select { |s| s.default? && s.runnable_with?(features) } + # Collect suites to run (basics first, then others alphabetically) + suites_to_run = Liquid::Spec::Suite.all + .select { |s| s.default? && s.runnable_with?(features) } + .sort_by { |s| s.id == :basics ? "" : s.id.to_s } skipped_suites = Liquid::Spec::Suite.all.select { |s| s.default? && !s.runnable_with?(features) } total_passed = 0 @@ -200,6 +202,7 @@ def self.run_specs_frozen(config, options) suite_specs = suite.specs suite_specs = filter_specs(suite_specs, config.filter) if config.filter + suite_specs = sort_by_complexity(suite_specs) next if suite_specs.empty? @@ -343,22 +346,17 @@ def self.run_single_spec(spec, _config) template.name = spec.template_name end - # Build file system from spec - file_system = build_file_system(spec) + # Build assigns (deep copy to avoid mutation between tests) + assigns = deep_copy(spec.environment || {}) - # Pass ALL spec options to the render context - # Spec-level options override source-level required_options - context = { - environment: spec.environment || {}, - file_system: file_system, - template_factory: spec.template_factory, + # Build render options + render_options = { + registers: build_registers(spec), + strict_errors: !render_errors, exception_renderer: spec.exception_renderer, - error_mode: spec.error_mode&.to_sym || required_opts[:error_mode], - render_errors: render_errors, - context_klass: spec.context_klass, - } + }.compact - actual = LiquidSpec.do_render(template, context) + actual = LiquidSpec.do_render(template, assigns, render_options) compare_result(actual, spec.expected) rescue Exception => e # Catch all exceptions including SyntaxError @@ -385,6 +383,32 @@ def self.build_file_system(spec) end end + def self.build_registers(spec) + registers = {} + registers[:file_system] = build_file_system(spec) + registers[:template_factory] = spec.template_factory if spec.template_factory + registers + end + + def self.deep_copy(obj, seen = {}.compare_by_identity) + return seen[obj] if seen.key?(obj) + + case obj + when Hash + copy = obj.class.new + seen[obj] = copy + obj.each { |k, v| copy[deep_copy(k, seen)] = deep_copy(v, seen) } + copy + when Array + copy = [] + seen[obj] = copy + obj.each { |v| copy << deep_copy(v, seen) } + copy + else + obj + end + end + def self.load_specs(config) # Ensure setup has run (loads liquid gem) LiquidSpec.run_setup! @@ -440,6 +464,14 @@ def self.filter_strict_only(specs) mode.nil? || mode == :strict end end + + # Sort specs by complexity (lower first), specs without complexity come last + def self.sort_by_complexity(specs) + specs.sort_by do |s| + # Specs with complexity sort by it, specs without go to the end (infinity) + s.complexity || Float::INFINITY + end + end end end end diff --git a/lib/liquid/spec/unit.rb b/lib/liquid/spec/unit.rb index f04369d..c555357 100644 --- a/lib/liquid/spec/unit.rb +++ b/lib/liquid/spec/unit.rb @@ -22,6 +22,7 @@ module Spec :hint, :source_hint, :source_required_options, + :complexity, keyword_init: true, ) do def initialize(**orig) diff --git a/lib/liquid/spec/yaml_source.rb b/lib/liquid/spec/yaml_source.rb index ed22778..f7acda0 100644 --- a/lib/liquid/spec/yaml_source.rb +++ b/lib/liquid/spec/yaml_source.rb @@ -57,6 +57,7 @@ def specs hint: data["hint"], source_hint: effective_hint, source_required_options: effective_defaults, + complexity: data["complexity"], ) end end diff --git a/specs/basics/specs.yml b/specs/basics/specs.yml new file mode 100644 index 0000000..6bbcb72 --- /dev/null +++ b/specs/basics/specs.yml @@ -0,0 +1,1789 @@ +--- +# ============================================================================= +# BASICS SUITE - Essential Liquid Features +# ============================================================================= +# +# Every Liquid implementation must pass these tests. +# Specs are ordered by complexity (lower numbers = simpler features). +# +# Complexity guide: +# 10-20: Raw text, literals (no parsing logic) +# 30-40: Variables, basic filters +# 50-60: Assign, simple conditionals +# 70-80: For loops, filter chains +# 90-100: Complex control flow, capture +# 110-130: Special tags (increment, comments, liquid) +# 150-170: Property access, truthy/falsy edge cases +# 180-200: Advanced features, edge cases +# +# Reference: https://shopify.github.io/liquid/ +# +# ============================================================================= + +# ============================================================================= +# COMPLEXITY 10: Raw Text Output +# The most basic - templates with no Liquid syntax at all +# ============================================================================= + +- name: raw_text_empty + template: "" + expected: "" + complexity: 10 + hint: | + The simplest possible template is an empty string. Your implementation should + return an empty string when given an empty template. This tests that your + basic rendering pipeline works - you can parse nothing and output nothing. + +- name: raw_text_hello_world + template: "Hello World" + expected: "Hello World" + complexity: 10 + hint: | + A template with no Liquid tags or objects should pass through unchanged. + This is plain text - your lexer/parser should recognize it as raw text + and your renderer should output it exactly as-is. This is the foundation + of template engines: literal content passes through verbatim. + +- name: raw_text_with_newlines + template: "Line 1\nLine 2\nLine 3" + expected: "Line 1\nLine 2\nLine 3" + complexity: 10 + hint: | + Whitespace including newlines should be preserved exactly. Liquid templates + often contain HTML with specific formatting, so preserving whitespace is + critical. Do not trim, collapse, or modify whitespace in any way. + +- name: raw_text_special_characters + template: "Special chars: <>&\"' and unicode: äöü 中文" + expected: "Special chars: <>&\"' and unicode: äöü 中文" + complexity: 10 + hint: | + Special characters and unicode should pass through unchanged. Liquid does NOT + automatically escape HTML - that's the job of filters like 'escape'. Raw text + is always output verbatim. Your implementation must handle UTF-8 correctly. + +# ============================================================================= +# COMPLEXITY 20: Object Output with Literals +# Outputting literal values - strings, numbers, booleans +# ============================================================================= + +- name: object_string_literal_single_quotes + template: "{{ 'hello' }}" + expected: "hello" + complexity: 20 + hint: | + String literals in Liquid use single quotes. The {{ }} syntax outputs the value + of an expression. Here we're outputting a literal string 'hello'. Your parser + needs to recognize single-quoted strings as string literals. The quotes are + delimiters, not part of the output. + +- name: object_string_literal_double_quotes + template: '{{ "hello" }}' + expected: "hello" + complexity: 20 + hint: | + String literals can also use double quotes. Both single and double quotes + create string literals - there is no difference in behavior between them. + Support both for compatibility with different coding styles. + +- name: object_integer_literal + template: "{{ 42 }}" + expected: "42" + complexity: 20 + hint: | + Integer literals are written without quotes. When output, they are converted + to their string representation. Your implementation needs to parse integers + (sequences of digits, optionally with a leading minus) and convert them to + strings for output using standard decimal notation. + +- name: object_negative_integer + template: "{{ -17 }}" + expected: "-17" + complexity: 20 + hint: | + Negative integers are supported. The minus sign is part of the number literal. + Parse this as a single negative number token, not as a subtraction operation. + +- name: object_float_literal + template: "{{ 3.14 }}" + expected: "3.14" + complexity: 20 + hint: | + Float literals use a decimal point. When output, floats should preserve their + decimal representation. Be careful with floating point precision - you should + output what the user wrote, not a rounded approximation. The format is + digits, decimal point, digits (e.g., 3.14, 0.5, 123.456). + +- name: object_negative_float + template: "{{ -2.5 }}" + expected: "-2.5" + complexity: 20 + hint: | + Negative floats combine the negative sign with decimal numbers. + +- name: object_true_literal + template: "{{ true }}" + expected: "true" + complexity: 20 + hint: | + Boolean true is a literal keyword (not a string). When output, it renders as + the lowercase string "true". Note: booleans are primarily used in conditions, + but can be output directly. Do not confuse with the string 'true'. + +- name: object_false_literal + template: "{{ false }}" + expected: "false" + complexity: 20 + hint: | + Boolean false is a literal keyword. When output, it renders as the lowercase + string "false". + +- name: object_nil_literal + template: "{{ nil }}" + expected: "" + complexity: 20 + hint: | + The nil literal represents "nothing" or "no value". When output, nil renders + as an empty string - it produces NO visible output. This is important behavior + that many templates rely on. Do not output "nil" as a string. + +- name: object_whitespace_inside_braces + template: "{{ 'hi' }}" + expected: "hi" + complexity: 20 + hint: | + Whitespace inside {{ }} (between the braces and the expression) is ignored. + This allows for readable formatting without affecting output. Your lexer + should skip whitespace between the {{ and the start of the expression. + +# ============================================================================= +# COMPLEXITY 30: Variables from Environment +# Looking up values passed to the template +# ============================================================================= + +- name: object_variable_string + template: "{{ name }}" + environment: + name: "World" + expected: "World" + complexity: 30 + hint: | + Variables are looked up by name in the environment (also called "assigns" or + "context"). When you write {{ name }}, Liquid looks for a variable called + "name" in the current scope and outputs its value. Variable lookup is case- + sensitive. The environment is passed when rendering the template. + +- name: object_variable_integer + template: "{{ count }}" + environment: + count: 42 + expected: "42" + complexity: 30 + hint: | + Variables can hold any type. When outputting an integer variable, it should + be converted to its string representation automatically. + +- name: object_undefined_variable + template: "{{ undefined_var }}" + expected: "" + complexity: 30 + hint: | + Accessing an undefined variable returns nil, which outputs as empty string. + This is a key Liquid design decision - undefined variables don't cause errors, + they silently output nothing. This makes templates more forgiving and is + important for backwards compatibility when adding new variables. + +- name: object_with_surrounding_text + template: "Hello {{ name }}!" + environment: + name: "World" + expected: "Hello World!" + complexity: 30 + hint: | + Object tags can be mixed with raw text. The {{ }} is replaced with the value, + and the surrounding text is preserved. This is the bread and butter of + templating - inserting dynamic values into static content. + +- name: object_multiple_variables + template: "{{ greeting }} {{ name }}!" + environment: + greeting: "Hello" + name: "World" + expected: "Hello World!" + complexity: 30 + hint: | + Multiple object tags can appear in a single template. Each is evaluated + independently and replaced with its value. Process them left to right. + +# ============================================================================= +# COMPLEXITY 40: Basic Filters +# Single filter applications +# ============================================================================= + +- name: filter_upcase + template: "{{ 'hello' | upcase }}" + expected: "HELLO" + complexity: 40 + hint: | + Filters modify the output of an expression. The pipe | symbol connects a + value to a filter. upcase converts a string to UPPERCASE. Filters are + called like functions with the value as the implicit first argument. + upcase should handle ASCII letters; unicode behavior may vary. + +- name: filter_downcase + template: "{{ 'HELLO' | downcase }}" + expected: "hello" + complexity: 40 + hint: | + downcase converts a string to lowercase. It's the opposite of upcase. + +- name: filter_capitalize + template: "{{ 'hello world' | capitalize }}" + expected: "Hello world" + complexity: 40 + hint: | + capitalize uppercases the FIRST character and lowercases ALL the rest. + Note: it only capitalizes the very first letter, not each word. "hELLO" + becomes "Hello". This is different from title case. + +- name: filter_size_string + template: "{{ 'hello' | size }}" + expected: "5" + complexity: 40 + hint: | + size returns the length of a string (number of characters) or array + (number of elements). The result is a number that gets converted to + string for output. For strings, count characters, not bytes. + +- name: filter_size_array + template: "{{ items | size }}" + environment: + items: + - a + - b + - c + expected: "3" + complexity: 40 + hint: | + size on arrays returns the number of elements. + +- name: filter_with_argument + template: "{{ 'hello' | append: ' world' }}" + expected: "hello world" + complexity: 40 + hint: | + Some filters take arguments, specified after a colon. append adds a string + to the end of the input. Syntax: value | filter: arg1, arg2, ... + The colon separates the filter name from its first argument. + +- name: filter_strip + template: "{{ ' hello ' | strip }}" + expected: "hello" + complexity: 40 + hint: | + strip removes leading and trailing whitespace from a string. It does NOT + affect spaces between words. + +- name: filter_default_nil + template: "{{ missing | default: 'fallback' }}" + expected: "fallback" + complexity: 40 + hint: | + The default filter returns a fallback value if the input is nil, false, + or empty string. This is useful for providing default values for missing + variables. When 'missing' is undefined (nil), output 'fallback'. + +- name: filter_default_with_value + template: "{{ 'exists' | default: 'fallback' }}" + expected: "exists" + complexity: 40 + hint: | + When the input has a truthy value, default passes it through unchanged. + +# ============================================================================= +# COMPLEXITY 50: Variable Assignment +# Creating variables with assign +# ============================================================================= + +- name: assign_string + template: "{% assign foo = 'bar' %}{{ foo }}" + expected: "bar" + complexity: 50 + hint: | + The assign tag creates a new variable. Syntax: {% assign name = value %} + The variable is available for the rest of the template. Note that assign + itself produces NO output - only {{ foo }} outputs the value. The assigned + variable is stored in the template's scope and persists until the end. + +- name: assign_integer + template: "{% assign num = 42 %}{{ num }}" + expected: "42" + complexity: 50 + hint: | + Variables can be assigned integer values. The value is stored as a number + type, then converted to string when output. + +- name: assign_boolean_true + template: "{% assign flag = true %}{{ flag }}" + expected: "true" + complexity: 50 + hint: | + Variables can hold boolean values. Remember that true and false are keywords, + not strings - don't put them in quotes. {% assign x = true %} is correct, + {% assign x = 'true' %} would create a string. + +- name: assign_boolean_false + template: "{% assign flag = false %}{{ flag }}" + expected: "false" + complexity: 50 + hint: | + The false keyword assigns the boolean false value. + +- name: assign_from_variable + template: "{% assign copy = original %}{{ copy }}" + environment: + original: "source" + expected: "source" + complexity: 50 + hint: | + You can assign one variable to another. This copies the value. For simple + values like strings and numbers, this creates an independent copy. + +- name: assign_overwrites + template: "{% assign x = 'first' %}{% assign x = 'second' %}{{ x }}" + expected: "second" + complexity: 50 + hint: | + Assigning to an existing variable overwrites it. There's no error or warning. + The last assignment wins. + +- name: assign_produces_no_output + template: "before{% assign x = 'test' %}after" + expected: "beforeafter" + complexity: 50 + hint: | + The assign tag itself produces NO output, not even whitespace. The output + contains only "before" and "after" with nothing between them. Tags like + assign perform actions but don't render anything. + +- name: assign_with_filter + template: "{% assign upper = 'hello' | upcase %}{{ upper }}" + expected: "HELLO" + complexity: 50 + hint: | + You can use filters in assign expressions. The filter is applied before + the value is stored in the variable. This is powerful for pre-processing. + +# ============================================================================= +# COMPLEXITY 55: Whitespace Control +# Using hyphens to strip whitespace +# ============================================================================= + +- name: whitespace_control_left_strip + template: " {{- 'hi' }}" + expected: "hi" + complexity: 55 + hint: | + The {{- syntax (hyphen after opening braces) strips whitespace to the LEFT + of the tag. This removes all whitespace characters (spaces, tabs, newlines) + before the tag. Implementation: when you see {{-, after rendering, look + backwards and remove all contiguous whitespace until you hit non-whitespace. + +- name: whitespace_control_right_strip + template: "{{ 'hi' -}} " + expected: "hi" + complexity: 55 + hint: | + The -}} syntax (hyphen before closing braces) strips whitespace to the RIGHT + of the tag. This removes all whitespace characters after the tag. + Implementation: when you see -}}, look ahead and skip all whitespace. + +- name: whitespace_control_both_sides + template: " {{- 'hi' -}} " + expected: "hi" + complexity: 55 + hint: | + Both {{- and -}} can be used together to strip whitespace on both sides. + This is common for producing compact output from formatted template source. + +- name: whitespace_control_tag_left + template: " {%- assign x = 'hi' %}{{ x }}" + expected: "hi" + complexity: 55 + hint: | + Whitespace control also works on tag delimiters {% %}. The {%- syntax strips + whitespace to the left of the tag. + +- name: whitespace_control_tag_right + template: "{% assign x = 'hi' -%} {{ x }}" + expected: "hi" + complexity: 55 + hint: | + The -%} syntax strips whitespace to the right of the tag. + +- name: whitespace_control_newlines + template: "\n{{- 'hi' -}}\n" + expected: "hi" + complexity: 55 + hint: | + Whitespace stripping includes newlines. This is particularly useful for + keeping template source readable while producing compact output. Strip + ALL whitespace characters: space, tab, newline, carriage return. + +# ============================================================================= +# COMPLEXITY 60: Basic Conditionals +# If/else with simple conditions +# ============================================================================= + +- name: if_true_literal + template: "{% if true %}yes{% endif %}" + expected: "yes" + complexity: 60 + hint: | + The if tag executes its block if the condition is truthy. Syntax: + {% if condition %}content{% endif %}. The block between if and endif + is only rendered when condition evaluates to true. + +- name: if_false_literal + template: "{% if false %}yes{% endif %}" + expected: "" + complexity: 60 + hint: | + When the condition is false, the block is skipped entirely. No output + is produced, not even whitespace from the tags themselves. + +- name: if_variable_truthy + template: "{% if name %}Hello {{ name }}{% endif %}" + environment: + name: "World" + expected: "Hello World" + complexity: 60 + hint: | + Variables are evaluated for truthiness. In Liquid, EVERYTHING is truthy + EXCEPT nil and false. A string like "World" is truthy. + +- name: if_variable_nil + template: "{% if missing %}yes{% endif %}" + expected: "" + complexity: 60 + hint: | + Undefined variables are nil, which is falsy. This is a common pattern to + check if a variable exists before using it. + +- name: if_else + template: "{% if false %}yes{% else %}no{% endif %}" + expected: "no" + complexity: 60 + hint: | + else provides an alternative block when the condition is false. Exactly + one block executes - either the if block or the else block, never both. + +- name: if_equality_string + template: "{% if name == 'Bob' %}yes{% endif %}" + environment: + name: "Bob" + expected: "yes" + complexity: 60 + hint: | + The == operator tests equality. String comparison is case-sensitive. + 'Bob' == 'Bob' is true, 'Bob' == 'bob' is false. + +- name: if_equality_integer + template: "{% if count == 5 %}yes{% endif %}" + environment: + count: 5 + expected: "yes" + complexity: 60 + hint: | + == works with numbers too. Compare values, not types. + +- name: if_inequality + template: "{% if name != 'Alice' %}not Alice{% endif %}" + environment: + name: "Bob" + expected: "not Alice" + complexity: 60 + hint: | + The != operator tests inequality (not equal). + +- name: unless_basic + template: "{% unless false %}shown{% endunless %}" + expected: "shown" + complexity: 60 + hint: | + unless is the opposite of if - it executes when condition is FALSE. + {% unless x %} is equivalent to {% if x == false or x == nil %}. + +- name: unless_true + template: "{% unless true %}hidden{% endunless %}" + expected: "" + complexity: 60 + hint: | + When the condition is true, unless skips the block. + +# ============================================================================= +# COMPLEXITY 70: For Loops Basics +# Iterating over arrays and ranges +# ============================================================================= + +- name: for_basic_array + template: "{% for item in items %}{{ item }}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "abc" + complexity: 70 + hint: | + The for loop iterates over each element. Syntax: {% for var in array %} + The loop variable (item) takes each value in turn. The block executes + once per element. + +- name: for_range_literal + template: "{% for i in (1..3) %}{{ i }}{% endfor %}" + expected: "123" + complexity: 70 + hint: | + Ranges use (start..end) syntax. Both ends are inclusive. + (1..3) produces 1, 2, 3. + +- name: for_range_variable + template: "{% for i in (1..count) %}{{ i }}{% endfor %}" + environment: + count: 4 + expected: "1234" + complexity: 70 + hint: | + Range bounds can be variables. + +- name: for_else + template: "{% for item in items %}{{ item }}{% else %}empty{% endfor %}" + environment: + items: [] + expected: "empty" + complexity: 70 + hint: | + else block in for loop executes if the array is empty. Great for + "no results found" messages. + +- name: for_limit + template: "{% for i in (1..10) limit:3 %}{{ i }}{% endfor %}" + expected: "123" + complexity: 75 + hint: | + limit stops after N iterations. Useful for "show first N items". + +- name: for_offset + template: "{% for i in (1..5) offset:2 %}{{ i }}{% endfor %}" + expected: "345" + complexity: 75 + hint: | + offset skips the first N items. 0-based: offset:2 skips items 0 and 1. + +- name: for_reversed + template: "{% for i in (1..3) reversed %}{{ i }}{% endfor %}" + expected: "321" + complexity: 75 + hint: | + reversed iterates in reverse order. Note: it's "reversed" not "reverse". + +- name: for_break + template: "{% for i in (1..5) %}{% if i == 3 %}{% break %}{% endif %}{{ i }}{% endfor %}" + expected: "12" + complexity: 75 + hint: | + break exits the loop immediately. Here we stop when i reaches 3, + so only 1 and 2 are output. + +- name: for_continue + template: "{% for i in (1..5) %}{% if i == 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}" + expected: "1245" + complexity: 75 + hint: | + continue skips the rest of the current iteration. We skip i=3, + so output is 1,2,4,5 (no 3). + +# ============================================================================= +# COMPLEXITY 80: Filter Chains and More Operators +# Combining filters and comparison operators +# ============================================================================= + +- name: filter_chain + template: "{{ 'hello' | upcase | append: '!' }}" + expected: "HELLO!" + complexity: 80 + hint: | + Filters can be chained - the output of one becomes the input of the next. + They are applied left to right. Here: 'hello' -> 'HELLO' -> 'HELLO!' + Each pipe passes the result to the next filter. + +- name: filter_chain_three + template: "{{ ' hello ' | strip | upcase | append: '!' }}" + expected: "HELLO!" + complexity: 80 + hint: | + You can chain as many filters as needed. strip removes leading/trailing + whitespace, then upcase converts to uppercase, then append adds '!'. + +- name: if_greater_than + template: "{% if count > 5 %}big{% endif %}" + environment: + count: 10 + expected: "big" + complexity: 80 + hint: | + The > operator tests if left > right. + +- name: if_less_than + template: "{% if count < 5 %}small{% endif %}" + environment: + count: 3 + expected: "small" + complexity: 80 + hint: | + The < operator tests if left < right. + +- name: if_greater_or_equal + template: "{% if count >= 5 %}enough{% endif %}" + environment: + count: 5 + expected: "enough" + complexity: 80 + hint: | + The >= operator tests greater than or equal to. + +- name: if_less_or_equal + template: "{% if count <= 5 %}ok{% endif %}" + environment: + count: 5 + expected: "ok" + complexity: 80 + hint: | + The <= operator tests less than or equal to. + +- name: if_and_operator + template: "{% if a and b %}both{% endif %}" + environment: + a: true + b: true + expected: "both" + complexity: 80 + hint: | + The 'and' operator requires BOTH conditions to be truthy. + +- name: if_and_short_circuit + template: "{% if a and b %}both{% endif %}" + environment: + a: false + b: true + expected: "" + complexity: 80 + hint: | + If the first operand of 'and' is falsy, the whole expression is falsy. + +- name: if_or_operator + template: "{% if a or b %}either{% endif %}" + environment: + a: false + b: true + expected: "either" + complexity: 80 + hint: | + The 'or' operator requires at least ONE condition to be truthy. + +- name: if_contains_string + template: "{% if title contains 'hello' %}found{% endif %}" + environment: + title: "say hello world" + expected: "found" + complexity: 80 + hint: | + The 'contains' operator checks if a string contains a substring. + It's case-sensitive: 'Hello' contains 'ello' but not 'ELLO'. + +- name: if_contains_array + template: "{% if tags contains 'sale' %}on sale{% endif %}" + environment: + tags: + - new + - sale + - featured + expected: "on sale" + complexity: 80 + hint: | + contains also checks if an array includes a specific value. + +# ============================================================================= +# COMPLEXITY 85: Math Filters +# Arithmetic operations +# ============================================================================= + +- name: filter_plus + template: "{{ 4 | plus: 2 }}" + expected: "6" + complexity: 85 + hint: | + The plus filter adds to a number. Liquid doesn't have arithmetic operators + in expressions - use filters instead. 4 + 2 is written as {{ 4 | plus: 2 }}. + +- name: filter_minus + template: "{{ 4 | minus: 2 }}" + expected: "2" + complexity: 85 + hint: | + The minus filter subtracts from a number. + +- name: filter_times + template: "{{ 4 | times: 2 }}" + expected: "8" + complexity: 85 + hint: | + The times filter multiplies numbers. + +- name: filter_divided_by_integer + template: "{{ 10 | divided_by: 2 }}" + expected: "5" + complexity: 85 + hint: | + The divided_by filter divides numbers. When dividing integers, the result + is an integer (floor division). + +- name: filter_divided_by_truncates + template: "{{ 5 | divided_by: 3 }}" + expected: "1" + complexity: 85 + hint: | + Integer division truncates toward zero. 5/3 = 1 (not 1.666 or 2). + +- name: filter_divided_by_float + template: "{{ 20 | divided_by: 7.0 }}" + expected: "2.857142857142857" + complexity: 85 + hint: | + When the divisor is a float, the result is a float with full precision. + +- name: filter_modulo + template: "{{ 10 | modulo: 3 }}" + expected: "1" + complexity: 85 + hint: | + The modulo filter returns the remainder after division. 10 mod 3 = 1. + +- name: filter_abs + template: "{{ -5 | abs }}" + expected: "5" + complexity: 85 + hint: | + The abs filter returns the absolute value (removes negative sign). + +- name: filter_abs_positive + template: "{{ 5 | abs }}" + expected: "5" + complexity: 85 + hint: | + abs on a positive number returns it unchanged. + +- name: filter_ceil + template: "{{ 4.3 | ceil }}" + expected: "5" + complexity: 85 + hint: | + The ceil filter rounds UP to the nearest integer. 4.3 -> 5, 4.0 -> 4. + +- name: filter_floor + template: "{{ 4.9 | floor }}" + expected: "4" + complexity: 85 + hint: | + The floor filter rounds DOWN to the nearest integer. 4.9 -> 4. + +- name: filter_round + template: "{{ 4.5 | round }}" + expected: "5" + complexity: 85 + hint: | + The round filter rounds to the nearest integer. 4.5 rounds up to 5. + +- name: filter_round_precision + template: "{{ 183.357 | round: 2 }}" + expected: "183.36" + complexity: 85 + hint: | + round can take an argument specifying decimal places to round to. + +- name: filter_at_least + template: "{{ 4 | at_least: 5 }}" + expected: "5" + complexity: 85 + hint: | + at_least ensures a minimum value. If input < minimum, return minimum. + +- name: filter_at_least_pass + template: "{{ 4 | at_least: 3 }}" + expected: "4" + complexity: 85 + hint: | + If input >= minimum, pass through unchanged. + +- name: filter_at_most + template: "{{ 4 | at_most: 3 }}" + expected: "3" + complexity: 85 + hint: | + at_most ensures a maximum value. If input > maximum, return maximum. + +- name: filter_at_most_pass + template: "{{ 4 | at_most: 5 }}" + expected: "4" + complexity: 85 + hint: | + If input <= maximum, pass through unchanged. + +# ============================================================================= +# COMPLEXITY 90: Forloop Object and Capture +# Accessing loop metadata, capturing output +# ============================================================================= + +- name: forloop_index + template: "{% for item in items %}{{ forloop.index }}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "123" + complexity: 90 + hint: | + forloop.index is the 1-based iteration number. First iteration is 1. + +- name: forloop_index0 + template: "{% for item in items %}{{ forloop.index0 }}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "012" + complexity: 90 + hint: | + forloop.index0 is 0-based. First iteration is 0. + +- name: forloop_first + template: "{% for item in items %}{% if forloop.first %}[{% endif %}{{ item }}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "[abc" + complexity: 90 + hint: | + forloop.first is true only on the first iteration. + +- name: forloop_last + template: "{% for item in items %}{{ item }}{% unless forloop.last %},{% endunless %}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "a,b,c" + complexity: 90 + hint: | + forloop.last is true only on the last iteration. Standard way to + avoid trailing separators. + +- name: forloop_length + template: "{% for item in items %}{{ forloop.length }}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "333" + complexity: 90 + hint: | + forloop.length is the total number of iterations (constant throughout loop). + +- name: forloop_rindex + template: "{% for item in items %}{{ forloop.rindex }}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "321" + complexity: 90 + hint: | + forloop.rindex is the 1-based index counting from the end. + On last item, rindex is 1. + +- name: forloop_rindex0 + template: "{% for item in items %}{{ forloop.rindex0 }}{% endfor %}" + environment: + items: + - a + - b + - c + expected: "210" + complexity: 90 + hint: | + forloop.rindex0 is 0-based reverse index. Last item is 0. + +- name: capture_basic + template: "{% capture greeting %}Hello World{% endcapture %}{{ greeting }}" + expected: "Hello World" + complexity: 90 + hint: | + capture collects everything between its tags and stores it as a string. + Unlike assign which stores a value directly, capture renders content + and saves the result. + +- name: capture_with_variables + template: "{% capture msg %}Hello {{ name }}!{% endcapture %}{{ msg }}" + environment: + name: "World" + expected: "Hello World!" + complexity: 90 + hint: | + Content inside capture is processed normally - variables resolved, + filters applied. The final rendered result becomes the variable value. + +- name: capture_with_logic + template: "{% capture x %}{% if true %}yes{% endif %}{% endcapture %}{{ x }}" + expected: "yes" + complexity: 90 + hint: | + Any Liquid code can appear inside capture - conditionals, loops, etc. + Everything renders and the output is captured. + +# ============================================================================= +# COMPLEXITY 100: Complex Control Flow +# elsif, case/when, nested conditions +# ============================================================================= + +- name: if_elsif + template: "{% if a == 1 %}one{% elsif a == 2 %}two{% else %}other{% endif %}" + environment: + a: 2 + expected: "two" + complexity: 100 + hint: | + elsif adds additional conditions. Conditions are evaluated in order - + the first true condition's block executes, then we exit the whole if. + +- name: case_basic + template: "{% case x %}{% when 1 %}one{% when 2 %}two{% endcase %}" + environment: + x: 1 + expected: "one" + complexity: 100 + hint: | + case/when is a switch statement. case takes a value, when defines matches. + When x equals 1, the "one" block executes. Only one when block runs. + +- name: case_no_match + template: "{% case x %}{% when 1 %}one{% when 2 %}two{% endcase %}" + environment: + x: 3 + expected: "" + complexity: 100 + hint: | + If no when clause matches, nothing is output (unless there's an else). + +- name: case_else + template: "{% case x %}{% when 1 %}one{% else %}other{% endcase %}" + environment: + x: 99 + expected: "other" + complexity: 100 + hint: | + else in a case handles all unmatched values. It's the default case. + +- name: case_multiple_values + template: "{% case x %}{% when 1, 2, 3 %}small{% else %}big{% endcase %}" + environment: + x: 2 + expected: "small" + complexity: 100 + hint: | + A when clause can match multiple values separated by commas. + Match ANY of them triggers the block. + +- name: case_string + template: "{% case color %}{% when 'red' %}R{% when 'green' %}G{% when 'blue' %}B{% endcase %}" + environment: + color: "green" + expected: "G" + complexity: 100 + hint: | + case/when works with any type including strings. + +- name: unless_variable + template: "{% unless sold_out %}available{% endunless %}" + environment: + sold_out: false + expected: "available" + complexity: 100 + hint: | + Common pattern: "unless bad_condition, show good_thing". + +# ============================================================================= +# COMPLEXITY 105: String Filters +# String manipulation +# ============================================================================= + +- name: filter_append + template: "{{ 'hello' | append: ' world' }}" + expected: "hello world" + complexity: 105 + hint: | + append adds a string to the end of another string. + +- name: filter_prepend + template: "{{ 'world' | prepend: 'hello ' }}" + expected: "hello world" + complexity: 105 + hint: | + prepend adds a string to the beginning. + +- name: filter_remove + template: "{{ 'hello world' | remove: 'world' }}" + expected: "hello " + complexity: 105 + hint: | + remove deletes ALL occurrences of a substring. + +- name: filter_remove_first + template: "{{ 'hello hello' | remove_first: 'hello' }}" + expected: " hello" + complexity: 105 + hint: | + remove_first deletes only the FIRST occurrence. + +- name: filter_remove_last + template: "{{ 'hello hello' | remove_last: 'hello' }}" + expected: "hello " + complexity: 105 + hint: | + remove_last deletes only the LAST occurrence. + +- name: filter_replace + template: "{{ 'hello' | replace: 'e', 'a' }}" + expected: "hallo" + complexity: 105 + hint: | + replace substitutes ALL occurrences of a substring with another. + First arg is what to find, second arg is replacement. + +- name: filter_replace_first + template: "{{ 'hello hello' | replace_first: 'hello', 'hi' }}" + expected: "hi hello" + complexity: 105 + hint: | + replace_first substitutes only the FIRST occurrence. + +- name: filter_replace_last + template: "{{ 'hello hello' | replace_last: 'hello', 'hi' }}" + expected: "hello hi" + complexity: 105 + hint: | + replace_last substitutes only the LAST occurrence. + +- name: filter_lstrip + template: "{{ ' hello ' | lstrip }}" + expected: "hello " + complexity: 105 + hint: | + lstrip removes whitespace only from the LEFT (beginning) of a string. + +- name: filter_rstrip + template: "{{ ' hello ' | rstrip }}" + expected: " hello" + complexity: 105 + hint: | + rstrip removes whitespace only from the RIGHT (end) of a string. + +- name: filter_split + template: "{% assign parts = 'a,b,c' | split: ',' %}{{ parts | size }}" + expected: "3" + complexity: 105 + hint: | + split breaks a string into an array using a delimiter. This creates + ['a', 'b', 'c']. split is often used to create arrays since Liquid can't + initialize arrays directly with literal syntax. + +- name: filter_slice_single + template: "{{ 'Liquid' | slice: 0 }}" + expected: "L" + complexity: 105 + hint: | + slice extracts a substring. With one argument, returns a single character + at that index. Indices are 0-based. + +- name: filter_slice_range + template: "{{ 'Liquid' | slice: 2, 4 }}" + expected: "quid" + complexity: 105 + hint: | + With two arguments, first is start index, second is LENGTH (not end index). + +- name: filter_slice_negative + template: "{{ 'Liquid' | slice: -3, 2 }}" + expected: "ui" + complexity: 105 + hint: | + Negative indices count from the end. -1 is last char, -3 is third from end. + +- name: filter_truncate + template: "{{ 'Ground control to Major Tom.' | truncate: 20 }}" + expected: "Ground control to..." + complexity: 105 + hint: | + truncate shortens a string to max length INCLUDING the ellipsis. + Default ellipsis is "..." (3 chars). 20 - 3 = 17 chars of content. + +- name: filter_truncate_custom_ellipsis + template: "{{ 'Ground control to Major Tom.' | truncate: 25, ', and so on' }}" + expected: "Ground control, and so on" + complexity: 105 + hint: | + Second argument customizes the ellipsis. The total length includes it. + +- name: filter_truncate_no_ellipsis + template: "{{ 'Ground control to Major Tom.' | truncate: 20, '' }}" + expected: "Ground control to Ma" + complexity: 105 + hint: | + Empty string as ellipsis means just truncate without adding anything. + +- name: filter_truncatewords + template: "{{ 'Ground control to Major Tom.' | truncatewords: 3 }}" + expected: "Ground control to..." + complexity: 105 + hint: | + truncatewords limits to N words, adding ellipsis if truncated. + Words are separated by spaces. + +- name: filter_truncatewords_custom + template: "{{ 'Ground control to Major Tom.' | truncatewords: 3, '--' }}" + expected: "Ground control to--" + complexity: 105 + hint: | + Custom ellipsis works the same as truncate. + +# ============================================================================= +# COMPLEXITY 110: Special String Filters +# HTML, URL, and newline handling +# ============================================================================= + +- name: filter_escape + template: "{{ '' | escape }}" + expected: "<script>alert(1)</script>" + complexity: 110 + hint: | + escape converts HTML special characters to their entity equivalents. + < becomes <, > becomes >, & becomes &, " becomes ", + ' becomes '. This prevents XSS attacks when outputting user content. + +- name: filter_escape_ampersand + template: "{{ 'Tom & Jerry' | escape }}" + expected: "Tom & Jerry" + complexity: 110 + hint: | + The & character is escaped to & to prevent it from being interpreted + as the start of an HTML entity. + +- name: filter_strip_newlines + template: "{% capture x %}\na\nb\nc\n{% endcapture %}{{ x | strip_newlines }}" + expected: "abc" + complexity: 110 + hint: | + strip_newlines removes all newline characters from a string. + Useful for compacting multi-line content. + +- name: filter_newline_to_br + template: "{% capture x %}a\nb{% endcapture %}{{ x | newline_to_br }}" + expected: "a
\nb" + complexity: 110 + hint: | + newline_to_br inserts
BEFORE each newline (preserving the newline). + Useful for displaying text with line breaks in HTML. + +- name: filter_strip_html + template: "{{ 'Have you read Ulysses?' | strip_html }}" + expected: "Have you read Ulysses?" + complexity: 110 + hint: | + strip_html removes all HTML tags from a string, leaving only text content. + +- name: filter_url_encode + template: "{{ 'john@liquid.com' | url_encode }}" + expected: "john%40liquid.com" + complexity: 110 + hint: | + url_encode converts URL-unsafe characters to percent-encoded form. + @ becomes %40, space becomes + or %20, etc. + +- name: filter_url_decode + template: "{{ '%27Stop%21%27+said+Fred' | url_decode }}" + expected: "'Stop!' said Fred" + complexity: 110 + hint: | + url_decode reverses url_encode. %27 -> ', %21 -> !, + -> space. + +- name: filter_default_false + template: "{{ false | default: 'fallback' }}" + expected: "fallback" + complexity: 110 + hint: | + false is considered "falsy" by default filter, so the fallback is used. + +- name: filter_default_empty_string + template: "{{ '' | default: 'fallback' }}" + expected: "fallback" + complexity: 110 + hint: | + Empty string is also considered "empty" by default filter. + +# ============================================================================= +# COMPLEXITY 115: Increment/Decrement +# Tricky counter semantics +# ============================================================================= + +- name: increment_basic + template: "{% increment counter %}{% increment counter %}{% increment counter %}" + expected: "012" + complexity: 115 + hint: | + increment creates a counter starting at 0, outputs current value, + then increments. First call outputs 0, second outputs 1, etc. + Note: increment OUTPUTS the value (unlike assign). + +- name: decrement_basic + template: "{% decrement counter %}{% decrement counter %}{% decrement counter %}" + expected: "-1-2-3" + complexity: 115 + hint: | + decrement starts at -1 and decrements after each output. + First call outputs -1, second -2, etc. + +- name: increment_independent + template: "{% assign var = 10 %}{% increment var %}{% increment var %}{{ var }}" + expected: "0110" + complexity: 115 + hint: | + CRITICAL: increment/decrement counters are INDEPENDENT from assign + variables, even with the same name! The assign var (10) and increment + var (0,1) are separate. This quirk trips up many implementers. + +# ============================================================================= +# COMPLEXITY 120: Comments and Raw +# Non-rendering content +# ============================================================================= + +- name: comment_block + template: "before{% comment %}hidden{% endcomment %}after" + expected: "beforeafter" + complexity: 120 + hint: | + Everything between {% comment %} and {% endcomment %} is completely + ignored. It produces no output. Use for documentation or temporarily + disabling code. + +- name: comment_with_liquid_code + template: "{% comment %}{% if true %}ignored{% endif %}{% endcomment %}shown" + expected: "shown" + complexity: 120 + hint: | + Liquid code inside comments is NOT executed. The parser should skip + everything until {% endcomment %}. + +- name: comment_inline + template: "before{% # this is ignored %}after" + expected: "beforeafter" + complexity: 120 + hint: | + Inline comments use {% # ... %}. Everything from # to %} is ignored. + This is more concise than block comments for single lines. + +- name: comment_inline_multiline + template: "before{%\n # line 1\n # line 2\n%}after" + expected: "beforeafter" + complexity: 120 + hint: | + Multi-line inline comments: each line inside the tag must start with #. + +- name: raw_basic + template: "{% raw %}{{ not_processed }}{% endraw %}" + expected: "{{ not_processed }}" + complexity: 120 + hint: | + The raw tag outputs its contents WITHOUT processing Liquid syntax. + {{ }} and {% %} are output literally. Use for showing Liquid code + examples or when you need literal {{ }} in output. + +- name: raw_with_tags + template: "{% raw %}{% if true %}shown{% endif %}{% endraw %}" + expected: "{% if true %}shown{% endif %}" + complexity: 120 + hint: | + Tag syntax is also preserved literally inside raw blocks. + +# ============================================================================= +# COMPLEXITY 130: Echo and Liquid Tag +# Alternative output syntax +# ============================================================================= + +- name: echo_basic + template: "{% echo 'hello' %}" + expected: "hello" + complexity: 130 + hint: | + echo outputs a value, like {{ }}. It's useful inside {% liquid %} tags + where {{ }} syntax isn't allowed. {% echo x %} is equivalent to {{ x }}. + +- name: echo_with_filter + template: "{% echo 'hello' | upcase %}" + expected: "HELLO" + complexity: 130 + hint: | + echo supports filters just like {{ }}. + +- name: echo_variable + template: "{% echo name %}" + environment: + name: "World" + expected: "World" + complexity: 130 + hint: | + echo works with variables too. + +- name: liquid_tag_basic + template: | + {% liquid + assign x = 'hello' + echo x + %} + expected: "hello\n" + complexity: 130 + hint: | + The {% liquid %} tag allows writing multiple Liquid statements without + repeating {% %} delimiters. Each line is a separate statement. + Use 'echo' to output values ({{ }} doesn't work inside liquid tags). + +- name: liquid_tag_with_logic + template: | + {% liquid + if true + echo 'yes' + endif + %} + expected: "yes\n" + complexity: 130 + hint: | + Control structures work inside liquid tags. Don't use {% %} for + nested tags - just write the tag names directly. + +- name: liquid_tag_for_loop + template: | + {%- liquid + for i in (1..3) + echo i + endfor + -%} + expected: "123" + complexity: 130 + hint: | + Loops work too. Each iteration outputs via echo. + +- name: liquid_tag_with_comments + template: | + {% liquid + # this is a comment + assign topic = 'hello' + echo topic + %} + expected: "hello\n" + complexity: 130 + hint: | + Inside liquid tags, use # for comments (one per line). + +# ============================================================================= +# COMPLEXITY 140: Array Filters +# Array manipulation +# ============================================================================= + +- name: filter_first + template: "{{ items | first }}" + environment: + items: + - a + - b + - c + expected: "a" + complexity: 140 + hint: | + first returns the first element of an array. + +- name: filter_last + template: "{{ items | last }}" + environment: + items: + - a + - b + - c + expected: "c" + complexity: 140 + hint: | + last returns the last element of an array. + +- name: filter_join + template: "{{ items | join: ', ' }}" + environment: + items: + - a + - b + - c + expected: "a, b, c" + complexity: 140 + hint: | + join combines array elements into a string with a separator between them. + +- name: filter_join_empty + template: "{{ items | join: '' }}" + environment: + items: + - a + - b + - c + expected: "abc" + complexity: 140 + hint: | + Empty separator concatenates elements directly. + +- name: filter_reverse + template: "{{ items | reverse | join: '' }}" + environment: + items: + - a + - b + - c + expected: "cba" + complexity: 140 + hint: | + reverse reverses the order of array elements. Note: reverse only works + on arrays, not strings (use split, reverse, join for strings). + +- name: filter_sort + template: "{{ items | sort | join: '' }}" + environment: + items: + - c + - a + - b + expected: "abc" + complexity: 140 + hint: | + sort arranges elements in ascending order. Default sort is case-sensitive + for strings (uppercase before lowercase in ASCII). + +- name: filter_sort_natural + template: "{{ items | sort_natural | join: ', ' }}" + environment: + items: + - zebra + - Apple + - banana + expected: "Apple, banana, zebra" + complexity: 140 + hint: | + sort_natural sorts case-insensitively. "Apple" and "apple" sort together. + +- name: filter_uniq + template: "{{ items | uniq | join: '' }}" + environment: + items: + - a + - b + - a + - c + - b + expected: "abc" + complexity: 140 + hint: | + uniq removes duplicates, keeping the first occurrence of each value. + +- name: filter_compact + template: "{{ items | compact | join: '' }}" + environment: + items: + - a + - null + - b + - null + - c + expected: "abc" + complexity: 140 + hint: | + compact removes nil/null values from an array. + +- name: filter_concat + template: "{{ a | concat: b | join: '' }}" + environment: + a: + - "1" + - "2" + b: + - "3" + - "4" + expected: "1234" + complexity: 140 + hint: | + concat combines two arrays into one, preserving order. + +- name: filter_map + template: "{{ items | map: 'name' | join: ', ' }}" + environment: + items: + - name: Alice + - name: Bob + expected: "Alice, Bob" + complexity: 140 + hint: | + map extracts a property from each element in an array of objects. + Returns an array of property values. + +- name: filter_where + template: "{{ items | where: 'active', true | map: 'name' | join: ', ' }}" + environment: + items: + - name: Alice + active: true + - name: Bob + active: false + - name: Carol + active: true + expected: "Alice, Carol" + complexity: 140 + hint: | + where filters an array to only elements where a property equals a value. + First arg is property name, second arg is value to match. + +# ============================================================================= +# COMPLEXITY 150: Property Access +# Dot and bracket notation +# ============================================================================= + +- name: property_dot_notation + template: "{{ user.name }}" + environment: + user: + name: "Alice" + expected: "Alice" + complexity: 150 + hint: | + Dot notation accesses properties of objects (hashes). user.name looks up + the 'name' key in the user hash/object. + +- name: property_nested + template: "{{ user.address.city }}" + environment: + user: + address: + city: "Seattle" + expected: "Seattle" + complexity: 150 + hint: | + Chain dot notation for nested access: a.b.c looks up c in b in a. + +- name: property_bracket_notation + template: "{{ user['name'] }}" + environment: + user: + name: "Alice" + expected: "Alice" + complexity: 150 + hint: | + Bracket notation is an alternative to dot notation. Required when + property names have special characters or spaces. + +- name: property_bracket_variable + template: "{% assign key = 'name' %}{{ user[key] }}" + environment: + user: + name: "Alice" + expected: "Alice" + complexity: 150 + hint: | + Bracket notation allows DYNAMIC property access using a variable. + The variable's value becomes the property name. + +- name: array_index_access + template: "{{ items[0] }}" + environment: + items: + - first + - second + - third + expected: "first" + complexity: 150 + hint: | + Array elements are accessed by index using brackets. Indices are 0-based. + +- name: array_negative_index + template: "{{ items[-1] }}" + environment: + items: + - first + - second + - third + expected: "third" + complexity: 150 + hint: | + Negative indices count from the end. -1 is last, -2 is second-to-last. + +- name: property_missing_returns_nil + template: "[{{ user.missing }}]" + environment: + user: + name: "Alice" + expected: "[]" + complexity: 150 + hint: | + Accessing a non-existent property returns nil, which outputs empty string. + No error is raised - this is intentional for template flexibility. + +# ============================================================================= +# COMPLEXITY 170: Truthy/Falsy Edge Cases +# Understanding boolean evaluation quirks +# ============================================================================= + +- name: truthy_string + template: "{% if 'hello' %}yes{% endif %}" + expected: "yes" + complexity: 170 + hint: | + Strings are truthy - they evaluate to true in boolean context. + +- name: truthy_empty_string + template: "{% if '' %}yes{% endif %}" + expected: "yes" + complexity: 170 + hint: | + IMPORTANT: Empty strings are TRUTHY in Liquid! This is different from + many languages. Use {{ str | size }} > 0 to check for non-empty. + +- name: truthy_zero + template: "{% if 0 %}yes{% endif %}" + expected: "yes" + complexity: 170 + hint: | + IMPORTANT: Zero is TRUTHY in Liquid! This is different from many + languages. Use {% if num != 0 %} to check for non-zero. + +- name: truthy_empty_array + template: "{% if items %}yes{% endif %}" + environment: + items: [] + expected: "yes" + complexity: 170 + hint: | + IMPORTANT: Empty arrays are TRUTHY! Use {% if items.size > 0 %} + or {% if items != empty %} to check for non-empty arrays. + +- name: falsy_nil + template: "{% if nil %}yes{% else %}no{% endif %}" + expected: "no" + complexity: 170 + hint: | + nil is FALSY - one of only two falsy values in Liquid. + +- name: falsy_false + template: "{% if false %}yes{% else %}no{% endif %}" + expected: "no" + complexity: 170 + hint: | + false is FALSY - the other falsy value. Only nil and false are falsy. + Everything else is truthy. + +- name: falsy_undefined + template: "{% if undefined_var %}yes{% else %}no{% endif %}" + expected: "no" + complexity: 170 + hint: | + Undefined variables are nil, which is falsy. + +- name: empty_comparison_string + template: "{% if '' == empty %}yes{% endif %}" + expected: "yes" + complexity: 170 + hint: | + The special 'empty' keyword can be compared with == to check for + empty strings, arrays, or hashes. + +- name: empty_comparison_array + template: "{% if items == empty %}yes{% endif %}" + environment: + items: [] + expected: "yes" + complexity: 170 + hint: | + empty comparison works for arrays too. + +# ============================================================================= +# COMPLEXITY 180: Cycle and TableRow +# Specialized iteration helpers +# ============================================================================= + +- name: cycle_basic + template: "{% for i in (1..4) %}{% cycle 'a', 'b' %}{% endfor %}" + expected: "abab" + complexity: 180 + hint: | + cycle rotates through its arguments on each call. First call returns 'a', + second 'b', third 'a' again, etc. Commonly used for alternating row colors. + +- name: cycle_three_values + template: "{% for i in (1..6) %}{% cycle 'a', 'b', 'c' %}{% endfor %}" + expected: "abcabc" + complexity: 180 + hint: | + cycle works with any number of values. + +- name: cycle_named + template: "{% for i in (1..2) %}{% cycle 'a': 'x', 'y' %}{% cycle 'b': '1', '2' %}{% endfor %}" + expected: "x1y2" + complexity: 180 + hint: | + Named cycles track position independently. 'a' and 'b' are separate + cycles that don't interfere with each other. + +- name: tablerow_basic + template: "{% tablerow item in items %}{{ item }}{% endtablerow %}" + environment: + items: + - a + - b + - c + expected: "\nabc\n" + complexity: 180 + hint: | + tablerow generates HTML table rows. It wraps items in and tags + with row and column classes. Note the newline after the opening tag. + You must wrap in tags yourself. + +- name: tablerow_cols + template: "{% tablerow item in items cols:2 %}{{ item }}{% endtablerow %}" + environment: + items: + - a + - b + - c + - d + expected: "\n\n\n" + complexity: 180 + hint: | + cols parameter sets how many columns per row. A new starts after + that many
ab
cd
s. diff --git a/specs/basics/suite.yml b/specs/basics/suite.yml new file mode 100644 index 0000000..c8e4d0c --- /dev/null +++ b/specs/basics/suite.yml @@ -0,0 +1,16 @@ +--- +name: "Basics" +description: "Essential Liquid features every implementation must support - start here" + +# Always run first, no features required +default: true + +hint: | + These specs cover the fundamental Liquid features documented at https://shopify.github.io/liquid/ + Every Liquid implementation should pass these tests. They are ordered from simplest to most complex. + If you're building a new Liquid implementation, start here and work through each failure. + +required_features: [] + +defaults: + render_errors: false