diff --git a/.gitignore b/.gitignore index 56e80efea..3a17e4003 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ *.gem *.swp pkg +.rvmrc +.bundle/config *.rbc .rvmrc diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..616c43385 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--colour +--backtrace diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..ab477d461 --- /dev/null +++ b/Gemfile @@ -0,0 +1,10 @@ +source :rubygems + +gem 'rake', '~> 0.9.2' +gem 'rspec', '~> 2.6' #>= 2.0.0.beta.22" +gem 'cucumber', '~> 1.1.9' #'> 0.9.0' +gem 'activesupport' + +platforms :ruby_18 do + gem 'ruby-debug' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..83f251f63 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,46 @@ +GEM + remote: http://rubygems.org/ + specs: + activesupport (3.2.13) + i18n (= 0.6.1) + multi_json (~> 1.0) + builder (3.0.0) + columnize (0.3.1) + cucumber (1.1.9) + builder (>= 2.1.2) + diff-lcs (>= 1.1.2) + gherkin (~> 2.9.0) + json (>= 1.4.6) + term-ansicolor (>= 1.0.6) + diff-lcs (1.1.3) + gherkin (2.9.3) + json (>= 1.4.6) + i18n (0.6.1) + json (1.7.3) + linecache (0.43) + multi_json (1.6.1) + rake (0.9.2.2) + rspec (2.11.0) + rspec-core (~> 2.11.0) + rspec-expectations (~> 2.11.0) + rspec-mocks (~> 2.11.0) + rspec-core (2.11.1) + rspec-expectations (2.11.1) + diff-lcs (~> 1.1.3) + rspec-mocks (2.11.1) + ruby-debug (0.10.3) + columnize (>= 0.1) + ruby-debug-base (~> 0.10.3.0) + ruby-debug-base (0.10.3) + linecache (>= 0.3) + term-ansicolor (1.0.7) + +PLATFORMS + ruby + +DEPENDENCIES + activesupport + cucumber (~> 1.1.9) + rake (~> 0.9.2) + rspec (~> 2.6) + ruby-debug diff --git a/History.txt b/History.txt new file mode 100644 index 000000000..d97c495c6 --- /dev/null +++ b/History.txt @@ -0,0 +1,57 @@ +2.2.2 + +* Added support for template inheritance {% extends %} + +2.2.1 / 2010-08-23 + +* Added support for literal tags + +2.2.0 / 2010-08-22 + +* Compatible with Ruby 1.8.7, 1.9.1 and 1.9.2-p0 +* Merged some changed made by the community + +1.9.0 / 2008-03-04 + +* Fixed gem install rake task +* Improve Error encapsulation in liquid by maintaining a own set of exceptions instead of relying on ruby build ins + +Before 1.9.0 + +* Added If with or / and expressions + +* Implemented .to_liquid for all objects which can be passed to liquid like Strings Arrays Hashes Numerics and Booleans. To export new objects to liquid just implement .to_liquid on them and return objects which themselves have .to_liquid methods. + +* Added more tags to standard library + +* Added include tag ( like partials in rails ) + +* [...] Gazillion of detail improvements + +* Added strainers as filter hosts for better security [Tobias Luetke] + +* Fixed that rails integration would call filter with the wrong "self" [Michael Geary] + +* Fixed bad error reporting when a filter called a method which doesn't exist. Liquid told you that it couldn't find the filter which was obviously misleading [Tobias Luetke] + +* Removed count helper from standard lib. use size [Tobias Luetke] + +* Fixed bug with string filter parameters failing to tolerate commas in strings. [Paul Hammond] + +* Improved filter parameters. Filter parameters are now context sensitive; Types are resolved according to the rules of the context. Multiple parameters are now separated by the Liquid::ArgumentSeparator: , by default [Paul Hammond] + + {{ 'Typo' | link_to: 'http://typo.leetsoft.com', 'Typo - a modern weblog engine' }} + + +* Added Liquid::Drop. A base class which you can use for exporting proxy objects to liquid which can acquire more data when used in liquid. [Tobias Luetke] + + class ProductDrop < Liquid::Drop + def top_sales + Shop.current.products.find(:all, :order => 'sales', :limit => 10 ) + end + end + t = Liquid::Template.parse( ' {% for product in product.top_sales %} {{ product.name }} {% endfor %} ' ) + t.render('product' => ProductDrop.new ) + + +* Added filter parameters support. Example: {{ date | format_date: "%Y" }} [Paul Hammond] diff --git a/README.md b/README.md index c3e08e07d..c6e9e6ce8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Liquid is a template engine which was written with very specific requirements: ``` ## How to use Liquid +>>>>>>> upstream/master Liquid supports a very simple API based around the Liquid::Template class. For standard use you can just pass it the content of a file and call render with a parameters hash. diff --git a/Rakefile b/Rakefile index 862e80f5c..d80d27492 100755 --- a/Rakefile +++ b/Rakefile @@ -1,11 +1,33 @@ #!/usr/bin/env ruby require 'rubygems' +require 'bundler/setup' + require 'rake' require 'rake/testtask' +require 'rspec' +require 'rspec/core/rake_task' require 'rubygems/package_task' -task :default => 'test' +RSpec::Core::RakeTask.new("spec") do |spec| + spec.pattern = "spec/**/*_spec.rb" +end + +desc "Run the Integration Specs (rendering)" +RSpec::Core::RakeTask.new("spec:integration") do |spec| + spec.pattern = "spec/unit/*_spec.rb" +end + +desc "Run the Unit Specs" +RSpec::Core::RakeTask.new("spec:unit") do |spec| + spec.pattern = "spec/unit/*_spec.rb" +end + +desc "Run all the specs without all the verbose spec output" +RSpec::Core::RakeTask.new('spec:progress') do |spec| + spec.rspec_opts = %w(--format progress) + spec.pattern = "spec/**/*_spec.rb" +end Rake::TestTask.new(:test) do |t| t.libs << '.' << 'lib' << 'test' @@ -13,14 +35,22 @@ Rake::TestTask.new(:test) do |t| t.verbose = false end -gemspec = eval(File.read('liquid.gemspec')) +task :default => [:spec, :test] + +gemspec = eval(File.read('locomotive_liquid.gemspec')) + Gem::PackageTask.new(gemspec) do |pkg| pkg.gem_spec = gemspec end desc "Build the gem and release it to rubygems.org" task :release => :gem do - sh "gem push pkg/liquid-#{gemspec.version}.gem" + puts "Tagging #{gemspec.version}..." + system "git tag -a #{gemspec.version} -m 'Tagging #{gemspec.version}'" + puts "Pushing to Github..." + system "git push --tags" + puts "Pushing to rubygems.org..." + system "gem push pkg/#{gemspec.name}-#{gemspec.version}.gem" end namespace :benchmark do @@ -32,7 +62,6 @@ namespace :benchmark do end - namespace :profile do desc "Run the liquid profile/performance coverage" diff --git a/autotest/discover.rb b/autotest/discover.rb new file mode 100644 index 000000000..cd6892ccb --- /dev/null +++ b/autotest/discover.rb @@ -0,0 +1 @@ +Autotest.add_discovery { "rspec2" } diff --git a/example/server/example_servlet.rb b/example/server/example_servlet.rb index e861b7773..5c7e87c50 100644 --- a/example/server/example_servlet.rb +++ b/example/server/example_servlet.rb @@ -2,28 +2,28 @@ module ProductsFilter def price(integer) sprintf("$%.2d USD", integer / 100.0) end - + def prettyprint(text) text.gsub( /\*(.*)\*/, '\1' ) end - + def count(array) array.size end - + def paragraph(p) "

#{p}

" end end class Servlet < LiquidServlet - + def index { 'date' => Time.now } end - - def products - { 'products' => products_list, 'section' => 'Snowboards', 'cool_products' => true} + + def products + { 'products' => products_list, 'section' => 'Snowboards', 'cool_products' => true} end def description @@ -31,11 +31,11 @@ def description end private - + def products_list [{'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' }, {'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling'}, {'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity'}] end - + end diff --git a/example/server/liquid_servlet.rb b/example/server/liquid_servlet.rb index 8f24f0026..b9d5cf6b8 100644 --- a/example/server/liquid_servlet.rb +++ b/example/server/liquid_servlet.rb @@ -7,21 +7,21 @@ def do_GET(req, res) def do_POST(req, res) handle(:post, req, res) end - + private - + def handle(type, req, res) @request, @response = req, res - + @request.path_info =~ /(\w+)$/ - @action = $1 || 'index' - @assigns = send(@action) if respond_to?(@action) + @action = $1 || 'index' + @assigns = send(@action) if respond_to?(@action) @response['Content-Type'] = "text/html" @response.status = 200 - @response.body = Liquid::Template.parse(read_template).render(@assigns, :filters => [ProductsFilter]) + @response.body = Liquid::Template.parse(read_template).render(@assigns, :filters => [ProductsFilter]) end - + def read_template(filename = @action) File.read( File.dirname(__FILE__) + "/templates/#{filename}.liquid" ) end diff --git a/example/server/templates/products.liquid b/example/server/templates/products.liquid index 191d99a9f..e9720c4e9 100644 --- a/example/server/templates/products.liquid +++ b/example/server/templates/products.liquid @@ -1,20 +1,20 @@ - + - + products - + - +

{{ description | split: '~' | first }}

@@ -24,26 +24,26 @@

There are currently {{products | count}} products in the {{section}} catalog

{% if cool_products %} - Cool products :) + Cool products :) {% else %} - Uncool products :( + Uncool products :( {% endif %} - - + + diff --git a/lib/extras/liquid_view.rb b/lib/extras/liquid_view.rb index 6b983be72..d1fe19a9d 100644 --- a/lib/extras/liquid_view.rb +++ b/lib/extras/liquid_view.rb @@ -2,15 +2,15 @@ # and use liquid as an template system for .liquid files # # Example -# +# # ActionView::Base::register_template_handler :liquid, LiquidView class LiquidView PROTECTED_ASSIGNS = %w( template_root response _session template_class action_name request_origin session template _response url _request _cookies variables_added _flash params _headers request cookies ignore_missing_templates flash _params logger before_filter_chain_aborted headers ) - PROTECTED_INSTANCE_VARIABLES = %w( @_request @controller @_first_render @_memoized__pick_template @view_paths + PROTECTED_INSTANCE_VARIABLES = %w( @_request @controller @_first_render @_memoized__pick_template @view_paths @helpers @assigns_added @template @_render_stack @template_format @assigns ) - + def self.call(template) "LiquidView.new(self).render(template, local_assigns)" end @@ -18,10 +18,10 @@ def self.call(template) def initialize(view) @view = view end - + def render(template, local_assigns = nil) @view.controller.headers["Content-Type"] ||= 'text/html; charset=utf-8' - + # Rails 2.2 Template has source, but not locals if template.respond_to?(:source) && !template.respond_to?(:locals) assigns = (@view.instance_variables - PROTECTED_INSTANCE_VARIABLES).inject({}) do |hash, ivar| @@ -31,15 +31,15 @@ def render(template, local_assigns = nil) else assigns = @view.assigns.reject{ |k,v| PROTECTED_ASSIGNS.include?(k) } end - + source = template.respond_to?(:source) ? template.source : template local_assigns = (template.respond_to?(:locals) ? template.locals : local_assigns) || {} - + if content_for_layout = @view.instance_variable_get("@content_for_layout") assigns['content_for_layout'] = content_for_layout end assigns.merge!(local_assigns.stringify_keys) - + liquid = Liquid::Template.parse(source) liquid.render(assigns, :filters => [@view.controller.master_helper_module], :registers => {:action_view => @view, :controller => @view.controller}) end diff --git a/lib/liquid/block.rb b/lib/liquid/block.rb index 8ea87b08e..17a3d4a6b 100644 --- a/lib/liquid/block.rb +++ b/lib/liquid/block.rb @@ -25,7 +25,7 @@ def parse(tokens) # fetch the tag from registered blocks if tag = Template.tags[$1] - @nodelist << tag.new($1, $2, tokens) + @nodelist << tag.new($1, $2, tokens, @context) else # this tag is not registered with the system # pass it to the current block for special handling or error reporting diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 4500aeff3..66993eb2c 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -128,6 +128,7 @@ def has_key?(key) end private + LITERALS = { nil => nil, 'nil' => nil, 'null' => nil, '' => nil, 'true' => true, diff --git a/lib/liquid/document.rb b/lib/liquid/document.rb index bf95478d8..fa691d0b8 100644 --- a/lib/liquid/document.rb +++ b/lib/liquid/document.rb @@ -1,7 +1,8 @@ module Liquid class Document < Block # we don't need markup to open this block - def initialize(tokens) + def initialize(tokens, context) + @context = context parse(tokens) end diff --git a/lib/liquid/errors.rb b/lib/liquid/errors.rb index b0add6f6d..fc97ddbb2 100644 --- a/lib/liquid/errors.rb +++ b/lib/liquid/errors.rb @@ -1,6 +1,6 @@ module Liquid class Error < ::StandardError; end - + class ArgumentError < Error; end class ContextError < Error; end class FilterNotFound < Error; end diff --git a/lib/liquid/file_system.rb b/lib/liquid/file_system.rb index 57363c395..da5100228 100644 --- a/lib/liquid/file_system.rb +++ b/lib/liquid/file_system.rb @@ -1,7 +1,7 @@ module Liquid # A Liquid file system is way to let your templates retrieve other templates for use with the include tag. # - # You can implement subclasses that retrieve templates from the database, from the file system using a different + # You can implement subclasses that retrieve templates from the database, from the file system using a different # path structure, you can provide them as hard-coded inline strings, or any manner that you see fit. # # You can add additional instance variables, arguments, or methods as needed. @@ -18,7 +18,7 @@ def read_template_file(template_path, context) raise FileSystemError, "This liquid context does not allow includes." end end - + # This implements an abstract file system which retrieves template files named in a manner similar to Rails partials, # ie. with the template name prefixed with an underscore. The extension ".liquid" is also added. # @@ -27,35 +27,35 @@ def read_template_file(template_path, context) # Example: # # file_system = Liquid::LocalFileSystem.new("/some/path") - # + # # file_system.full_path("mypartial") # => "/some/path/_mypartial.liquid" # file_system.full_path("dir/mypartial") # => "/some/path/dir/_mypartial.liquid" # class LocalFileSystem attr_accessor :root - + def initialize(root) @root = root end - + def read_template_file(template_path, context) full_path = full_path(template_path) raise FileSystemError, "No such template '#{template_path}'" unless File.exists?(full_path) - + File.read(full_path) end - + def full_path(template_path) raise FileSystemError, "Illegal template name '#{template_path}'" unless template_path =~ /^[^.\/][a-zA-Z0-9_\/]+$/ - + full_path = if template_path.include?('/') File.join(root, File.dirname(template_path), "_#{File.basename(template_path)}.liquid") else File.join(root, "_#{template_path}.liquid") end - + raise FileSystemError, "Illegal template path '#{File.expand_path(full_path)}'" unless File.expand_path(full_path) =~ /^#{File.expand_path(root)}/ - + full_path end end diff --git a/lib/liquid/htmltags.rb b/lib/liquid/htmltags.rb index 78424e655..dff08b0b0 100644 --- a/lib/liquid/htmltags.rb +++ b/lib/liquid/htmltags.rb @@ -2,7 +2,7 @@ module Liquid class TableRow < Block Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o - def initialize(tag_name, markup, tokens) + def initialize(tag_name, markup, tokens, context) if markup =~ Syntax @variable_name = $1 @collection_name = $2 diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 651216ac6..6147f267c 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -1,4 +1,5 @@ require 'cgi' +require 'active_support/core_ext' module Liquid @@ -6,23 +7,22 @@ module StandardFilters # Return the size of an array or of an string def size(input) - input.respond_to?(:size) ? input.size : 0 end # convert a input string to DOWNCASE def downcase(input) - input.to_s.downcase + input.to_s.mb_chars.downcase end # convert a input string to UPCASE def upcase(input) - input.to_s.upcase + input.to_s.mb_chars.upcase end # capitalize words in the input centence def capitalize(input) - input.to_s.capitalize + input.to_s.mb_chars.capitalize end def escape(input) @@ -71,10 +71,14 @@ def strip_newlines(input) input.to_s.gsub(/\n/, '') end - # Join elements of the array with certain character between them - def join(input, glue = ' ') - [input].flatten.join(glue) + def join(input, array_glue = ' ', hash_glue = nil) + hash_glue ||= array_glue + + # translate from hash to array if needed + input = input.map{|k,v| "#{k}#{hash_glue}#{v}" } if input.is_a?(Hash) + + [input].flatten.join(array_glue) end # Sort elements of the array diff --git a/lib/liquid/strainer.rb b/lib/liquid/strainer.rb index 445a0aead..2b14b0a50 100644 --- a/lib/liquid/strainer.rb +++ b/lib/liquid/strainer.rb @@ -19,6 +19,9 @@ class Strainer < parent_object #:nodoc: # Ruby 1.9.2 introduces Object#respond_to_missing?, which is invoked by Object#respond_to? @@required_methods << :respond_to_missing? if Object.respond_to? :respond_to_missing? + # Ruby 1.9.2 introduces Object#respond_to_missing?, which is invoked by Object#respond_to? + @@required_methods << :respond_to_missing? if Object.respond_to? :respond_to_missing? + @@filters = {} def initialize(context) diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index b7f2aa456..dd6ea67ec 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -2,11 +2,12 @@ module Liquid class Tag - attr_accessor :nodelist + attr_accessor :nodelist, :context - def initialize(tag_name, markup, tokens) + def initialize(tag_name, markup, tokens, context) @tag_name = tag_name @markup = markup + @context = context parse(tokens) end diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 3540b76ce..d1748b6cb 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -10,24 +10,24 @@ module Liquid # class Assign < Tag Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/o - - def initialize(tag_name, markup, tokens) + + def initialize(tag_name, markup, tokens, context) if markup =~ Syntax @to = $1 @from = Variable.new($2) else raise SyntaxError.new("Syntax Error in 'assign' - Valid syntax: assign [var] = [source]") end - - super + + super end - + def render(context) context.scopes.last[@to] = @from.render(context) '' - end - - end - - Template.register_tag('assign', Assign) + end + + end + + Template.register_tag('assign', Assign) end diff --git a/lib/liquid/tags/capture.rb b/lib/liquid/tags/capture.rb index 2f67a0b29..ea9e45278 100644 --- a/lib/liquid/tags/capture.rb +++ b/lib/liquid/tags/capture.rb @@ -14,7 +14,7 @@ module Liquid class Capture < Block Syntax = /(\w+)/ - def initialize(tag_name, markup, tokens) + def initialize(tag_name, markup, tokens, context) if markup =~ Syntax @to = $1 else diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 4e2fb2cca..6d1d4f95f 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -3,15 +3,15 @@ class Case < Block Syntax = /(#{QuotedFragment})/o WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/o - def initialize(tag_name, markup, tokens) + def initialize(tag_name, markup, tokens, context) @blocks = [] - + if markup =~ Syntax @left = $1 else raise SyntaxError.new("Syntax Error in tag 'case' - Valid syntax: case [condition]") end - + super end @@ -27,53 +27,53 @@ def unknown_tag(tag, markup, tokens) end end - def render(context) - context.stack do + def render(context) + context.stack do execute_else_block = true - + output = '' @blocks.each do |block| - if block.else? + if block.else? return render_all(block.attachment, context) if execute_else_block elsif block.evaluate(context) - execute_else_block = false + execute_else_block = false output << render_all(block.attachment, context) - end + end end output - end + end end - + private - - def record_when_condition(markup) + + def record_when_condition(markup) while markup - # Create a new nodelist and assign it to the new block - if not markup =~ WhenSyntax - raise SyntaxError.new("Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %} ") - end + # Create a new nodelist and assign it to the new block + if not markup =~ WhenSyntax + raise SyntaxError.new("Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %} ") + end - markup = $2 + markup = $2 - block = Condition.new(@left, '==', $1) - block.attach(@nodelist) - @blocks.push(block) + block = Condition.new(@left, '==', $1) + block.attach(@nodelist) + @blocks.push(block) end end - def record_else_condition(markup) + def record_else_condition(markup) if not markup.strip.empty? raise SyntaxError.new("Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) ") end - - block = ElseCondition.new + + block = ElseCondition.new block.attach(@nodelist) @blocks << block end - - - end - + + + end + Template.register_tag('case', Case) end diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 9b3f34233..e32068664 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -1,5 +1,5 @@ module Liquid - + # Cycle is usually used within a loop to alternate between values, like colors or DOM classes. # # {% for item in items %} @@ -15,45 +15,45 @@ module Liquid class Cycle < Tag SimpleSyntax = /^#{QuotedFragment}+/o NamedSyntax = /^(#{QuotedFragment})\s*\:\s*(.*)/o - - def initialize(tag_name, markup, tokens) + + def initialize(tag_name, markup, tokens, context) case markup when NamedSyntax - @variables = variables_from_string($2) - @name = $1 + @variables = variables_from_string($2) + @name = $1 when SimpleSyntax @variables = variables_from_string(markup) - @name = "'#{@variables.to_s}'" + @name = "'#{@variables.to_s}'" else raise SyntaxError.new("Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]") end - super - end - + super + end + def render(context) context.registers[:cycle] ||= Hash.new(0) - + context.stack do - key = context[@name] + key = context[@name] iteration = context.registers[:cycle][key] result = context[@variables[iteration]] - iteration += 1 - iteration = 0 if iteration >= @variables.size + iteration += 1 + iteration = 0 if iteration >= @variables.size context.registers[:cycle][key] = iteration - result + result end end - + private - + def variables_from_string(markup) markup.split(',').collect do |var| var =~ /\s*(#{QuotedFragment})\s*/o $1 ? $1 : nil end.compact end - + end - + Template.register_tag('cycle', Cycle) end diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index c22318e56..433b09abf 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -1,5 +1,5 @@ module Liquid - + # decrement is used in a place where one needs to insert a counter # into a template, and needs the counter to survive across # multiple instantiations of the template. @@ -19,21 +19,21 @@ module Liquid # Hello: -3 # class Decrement < Tag - def initialize(tag_name, markup, tokens) + def initialize(tag_name, markup, tokens, context) @variable = markup.strip - super - end - + super + end + def render(context) value = context.environments.first[@variable] ||= 0 value = value - 1 context.environments.first[@variable] = value value.to_s end - + private end - + Template.register_tag('decrement', Decrement) end diff --git a/lib/liquid/tags/default_content.rb b/lib/liquid/tags/default_content.rb new file mode 100644 index 000000000..3ae30d24c --- /dev/null +++ b/lib/liquid/tags/default_content.rb @@ -0,0 +1,21 @@ +module Liquid + + # InheritedContent pulls out the content from child templates that isnt defined in blocks + # + # {% defaultcontent %} + # + class DefaultContent < Tag + def initialize(tag_name, markup, tokens, context) + super + end + + def render(context) + context.stack do + "HELLO" + end + end + end + + + Template.register_tag('defaultcontent', DefaultContent) +end \ No newline at end of file diff --git a/lib/liquid/tags/extends.rb b/lib/liquid/tags/extends.rb new file mode 100644 index 000000000..49c1896de --- /dev/null +++ b/lib/liquid/tags/extends.rb @@ -0,0 +1,75 @@ +module Liquid + + # Extends allows designer to use template inheritance + # + # {% extends home %} + # {% block content }Hello world{% endblock %} + # + class Extends < Block + Syntax = /(#{QuotedFragment}+)/ + + def initialize(tag_name, markup, tokens, context) + if markup =~ Syntax + @template_name = $1.gsub(/["']/o, '').strip + else + raise SyntaxError.new("Error in tag 'extends' - Valid syntax: extends [template]") + end + + @context = context + + @parent_template = parse_parent_template + + prepare_parsing + + super + + end_tag + end + + def prepare_parsing + @context.merge!(:blocks => self.find_blocks(@parent_template.root.nodelist)) + end + + def end_tag + # replace the nodelist by the new one + @nodelist = @parent_template.root.nodelist.clone + + @parent_template = nil # no need to keep it + end + + protected + + def find_blocks(nodelist, blocks = {}) + if nodelist && nodelist.any? + 0.upto(nodelist.size - 1).each do |index| + node = nodelist[index] + + if node.respond_to?(:call_super) # inherited block ! + new_node = node.class.clone_block(node) + + nodelist.insert(index, new_node) + nodelist.delete_at(index + 1) + + blocks[node.name] = new_node + end + if node.respond_to?(:nodelist) + self.find_blocks(node.nodelist, blocks) # FIXME: find nested blocks too + end + end + end + blocks + end + + private + + def parse_parent_template + source = Template.file_system.read_template_file(@template_name) + Template.parse(source) + end + + def assert_missing_delimitation! + end + end + + Template.register_tag('extends', Extends) +end \ No newline at end of file diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index 8d2b27bf7..1f867bb63 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -1,6 +1,6 @@ module Liquid - # "For" iterates over an array or collection. + # "For" iterates over an array or collection. # Several useful variables are available to you within the loop. # # == Basic usage: @@ -22,7 +22,7 @@ module Liquid # # {% for item in collection limit:5 offset:10 %} # {{ item.name }} - # {% end %} + # {% end %} # # To reverse the for loop simply use {% for item in collection reversed %} # @@ -31,7 +31,7 @@ module Liquid # forloop.name:: 'item-collection' # forloop.length:: Length of the loop # forloop.index:: The current item's position in the collection; - # forloop.index starts at 1. + # forloop.index starts at 1. # This is helpful for non-programmers who start believe # the first item in an array is 1, not 0. # forloop.index0:: The current item's position in the collection @@ -43,19 +43,19 @@ module Liquid # forloop.first:: Returns true if the item is the first item. # forloop.last:: Returns true if the item is the last item. # - class For < Block + class For < Block Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o - - def initialize(tag_name, markup, tokens) + + def initialize(tag_name, markup, tokens, context) if markup =~ Syntax @variable_name = $1 @collection_name = $2 - @name = "#{$1}-#{$2}" - @reversed = $3 + @name = "#{$1}-#{$2}" + @reversed = $3 @attributes = {} markup.scan(TagAttributes) do |key, value| @attributes[key] = value - end + end else raise SyntaxError.new("Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]") end @@ -68,47 +68,47 @@ def unknown_tag(tag, markup, tokens) return super unless tag == 'else' @nodelist = @else_block = [] end - - def render(context) + + def render(context) context.registers[:for] ||= Hash.new(0) - + collection = context[@collection_name] collection = collection.to_a if collection.is_a?(Range) - + # Maintains Ruby 1.8.7 String#each behaviour on 1.9 return render_else(context) unless iterable?(collection) - + from = if @attributes['offset'] == 'continue' context.registers[:for][@name].to_i else context[@attributes['offset']].to_i end - + limit = context[@attributes['limit']] - to = limit ? limit.to_i + from : nil + to = limit ? limit.to_i + from : nil segment = Utils.slice_collection_using_each(collection, from, to) return render_else(context) if segment.empty? - + segment.reverse! if @reversed result = '' - - length = segment.length - + + length = segment.length + # Store our progress through the collection for the continue flag context.registers[:for][@name] = from + segment.length - + context.stack do segment.each_with_index do |item, index| context[@variable_name] = item context['forloop'] = { 'name' => @name, 'length' => length, - 'index' => index + 1, - 'index0' => index, + 'index' => index + 1, + 'index0' => index, 'rindex' => length - index, 'rindex0' => length - index - 1, 'first' => (index == 0), @@ -124,8 +124,8 @@ def render(context) end end end - result - end + result + end private diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 7f23ddeff..5fc7ab55c 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -16,7 +16,7 @@ class If < Block Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o - def initialize(tag_name, markup, tokens) + def initialize(tag_name, markup, tokens, context) @blocks = [] push_block('if', markup) @@ -72,7 +72,6 @@ def push_block(tag, markup) @nodelist = block.attach(Array.new) end - end Template.register_tag('if', If) diff --git a/lib/liquid/tags/ifchanged.rb b/lib/liquid/tags/ifchanged.rb index a4406c6f6..d7dafc043 100644 --- a/lib/liquid/tags/ifchanged.rb +++ b/lib/liquid/tags/ifchanged.rb @@ -1,20 +1,20 @@ module Liquid class Ifchanged < Block - + def render(context) - context.stack do - + context.stack do + output = render_all(@nodelist, context) - + if output != context.registers[:ifchanged] context.registers[:ifchanged] = output output else '' - end + end end end - end - - Template.register_tag('ifchanged', Ifchanged) + end + + Template.register_tag('ifchanged', Ifchanged) end \ No newline at end of file diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index f7400abdd..a1ea4f5e8 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -1,11 +1,11 @@ module Liquid class Include < Tag Syntax = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?/o - - def initialize(tag_name, markup, tokens) + + def initialize(tag_name, markup, tokens, context) if markup =~ Syntax - @template_name = $1 + @template_name = $1 @variable_name = $3 @attributes = {} @@ -19,15 +19,15 @@ def initialize(tag_name, markup, tokens) super end - + def parse(tokens) end - + def render(context) source = _read_template_from_file_system(context) partial = Liquid::Template.parse(source) variable = context[@variable_name || @template_name[1..-2]] - + context.stack do @attributes.each do |key, value| context[key] = context[value] @@ -44,11 +44,11 @@ def render(context) end end end - + private def _read_template_from_file_system(context) file_system = context.registers[:file_system] || Liquid::Template.file_system - + # make read_template_file call backwards-compatible. case file_system.method(:read_template_file).arity when 1 @@ -61,5 +61,5 @@ def _read_template_from_file_system(context) end end - Template.register_tag('include', Include) + Template.register_tag('include', Include) end diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index e6a30ca0a..046dfa209 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -1,5 +1,5 @@ module Liquid - + # increment is used in a place where one needs to insert a counter # into a template, and needs the counter to survive across # multiple instantiations of the template. @@ -16,20 +16,20 @@ module Liquid # Hello: 2 # class Increment < Tag - def initialize(tag_name, markup, tokens) + def initialize(tag_name, markup, tokens, context) @variable = markup.strip - super - end - + super + end + def render(context) value = context.environments.first[@variable] ||= 0 context.environments.first[@variable] = value + 1 value.to_s end - + private end - + Template.register_tag('increment', Increment) end diff --git a/lib/liquid/tags/inherited_block.rb b/lib/liquid/tags/inherited_block.rb new file mode 100644 index 000000000..855b401ca --- /dev/null +++ b/lib/liquid/tags/inherited_block.rb @@ -0,0 +1,101 @@ +module Liquid + + # Blocks are used with the Extends tag to define + # the content of blocks. Nested blocks are allowed. + # + # {% extends home %} + # {% block content }Hello world{% endblock %} + # + class InheritedBlock < Block + Syntax = /(#{QuotedFragment}+)/ + + attr_accessor :parent + attr_reader :name + + def initialize(tag_name, markup, tokens, context) + if markup =~ Syntax + @name = $1.gsub(/["']/o, '').strip + else + raise SyntaxError.new("Error in tag 'block' - Valid syntax: block [name]") + end + + self.set_full_name!(context) + + (context[:block_stack] ||= []).push(self) + context[:current_block] = self + + super if tokens + end + + def render(context) + context.stack do + context['block'] = InheritedBlockDrop.new(self) + render_all(@nodelist, context) + end + end + + def end_tag + self.register_current_block + + @context[:block_stack].pop + @context[:current_block] = @context[:block_stack].last + end + + def call_super(context) + if parent + parent.render(context) + else + '' + end + end + + def self.clone_block(block) + new_block = self.new(block.send(:instance_variable_get, :"@tag_name"), block.name, nil, {}) + new_block.parent = block.parent + new_block.nodelist = block.nodelist + new_block + end + + protected + + def set_full_name!(context) + if context[:current_block] + @name = context[:current_block].name + '/' + @name + end + end + + def register_current_block + @context[:blocks] ||= {} + + block = @context[:blocks][@name] + + if block + # copy the existing block in order to make it a parent of the parsed block + new_block = self.class.clone_block(block) + + # replace the up-to-date version of the block in the parent template + block.parent = new_block + block.nodelist = @nodelist + end + end + + end + + class InheritedBlockDrop < Drop + + def initialize(block) + @block = block + end + + def name + @block.name + end + + def super + @block.call_super(@context) + end + + end + + Template.register_tag('block', InheritedBlock) +end \ No newline at end of file diff --git a/lib/liquid/tags/raw.rb b/lib/liquid/tags/raw.rb index 9c07d66e3..c50543c91 100644 --- a/lib/liquid/tags/raw.rb +++ b/lib/liquid/tags/raw.rb @@ -17,5 +17,4 @@ def parse(tokens) end Template.register_tag('raw', Raw) -end - +end \ No newline at end of file diff --git a/lib/liquid/tags/unless.rb b/lib/liquid/tags/unless.rb index a3d4d0898..5c2e86dc5 100644 --- a/lib/liquid/tags/unless.rb +++ b/lib/liquid/tags/unless.rb @@ -9,25 +9,24 @@ module Liquid class Unless < If def render(context) context.stack do - + # First condition is interpreted backwards ( if not ) block = @blocks.first unless block.evaluate(context) return render_all(block.attachment, context) end - + # After the first condition unless works just like if @blocks[1..-1].each do |block| if block.evaluate(context) return render_all(block.attachment, context) end end - '' end end end - + Template.register_tag('unless', Unless) end \ No newline at end of file diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 1d0198252..dbe3a2c53 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -41,9 +41,9 @@ def register_filter(mod) end # creates a new Template object from liquid source code - def parse(source) + def parse(source, context = {}) template = Template.new - template.parse(source) + template.parse(source, context) template end end @@ -54,8 +54,8 @@ def initialize # Parse source code. # Returns self for easy chaining - def parse(source) - @root = Document.new(tokenize(source)) + def parse(source, context = {}) + @root = Document.new(tokenize(source), context.merge!(:template => self)) self end @@ -88,7 +88,7 @@ def errors # def render(*args) return '' if @root.nil? - + context = case args.first when Liquid::Context args.shift @@ -132,6 +132,31 @@ def render!(*args) @rethrow_errors = true; render(*args) end + def walk(memo = {}, &block) + # puts @root.nodelist.inspect + self._walk(@root.nodelist, memo, &block) + end + + def _walk(list, memo = {}, &block) + list.each do |node| + saved_memo = memo.clone + + # puts "fetch ! #{node.respond_to?(:name) ? node.name : 'String'} / #{node.respond_to?(:nodelist)}" + if block_given? + # puts "youpi ! #{node.name}" + _memo = yield(node, memo) || {} + memo.merge!(_memo) + end + + if node.respond_to?(:nodelist) && !node.nodelist.blank? + self._walk(node.nodelist, memo, &block) + end + + memo = saved_memo + end + memo + end + private # Uses the Liquid::TemplateParser regexp to tokenize the passed source diff --git a/lib/locomotive_liquid.rb b/lib/locomotive_liquid.rb new file mode 100644 index 000000000..bb00cd0e0 --- /dev/null +++ b/lib/locomotive_liquid.rb @@ -0,0 +1 @@ +require 'liquid' \ No newline at end of file diff --git a/liquid.gemspec b/liquid.gemspec deleted file mode 100644 index 91d4b9467..000000000 --- a/liquid.gemspec +++ /dev/null @@ -1,21 +0,0 @@ -# encoding: utf-8 - -Gem::Specification.new do |s| - s.name = "liquid" - s.version = "2.4.1" - s.platform = Gem::Platform::RUBY - s.summary = "A secure, non-evaling end user template engine with aesthetic markup." - s.authors = ["Tobias Luetke"] - s.email = ["tobi@leetsoft.com"] - s.homepage = "http://www.liquidmarkup.org" - #s.description = "A secure, non-evaling end user template engine with aesthetic markup." - - s.required_rubygems_version = ">= 1.3.7" - - s.test_files = Dir.glob("{test}/**/*") - s.files = Dir.glob("{lib}/**/*") + %w(MIT-LICENSE README.md) - - s.extra_rdoc_files = ["History.md", "README.md"] - - s.require_path = "lib" -end diff --git a/locomotive_liquid.gemspec b/locomotive_liquid.gemspec new file mode 100644 index 000000000..9e8309785 --- /dev/null +++ b/locomotive_liquid.gemspec @@ -0,0 +1,27 @@ +Gem::Specification.new do |s| + s.name = "locomotive_liquid" + s.version = "2.4.1" + + s.required_rubygems_version = ">= 1.3.6" + s.authors = ["Tobias Luetke", "Didier Lafforgue", "Jacques Crocker"] + s.email = ["tobi@leetsoft.com", "didier@nocoffee.fr", "railsjedi@gmail.com"] + s.summary = "A secure, non-evaling end user template engine with aesthetic markup." + s.description = "A secure, non-evaling end user template engine with aesthetic markup. Extended with liquid template inheritance for use in LocomotiveCMS" + + + s.extra_rdoc_files = ["History.txt", "README.md"] + s.files = Dir[ "CHANGELOG", + "History.txt", + "MIT-LICENSE", + "README.md", + "Rakefile", + "init.rb", + "{lib}/**/*"] + + s.has_rdoc = true + s.homepage = "http://www.locomotivecms.com" + s.rdoc_options = ["--main", "README.md"] + s.require_paths = ["lib"] + s.rubyforge_project = "locomotive_liquid" + +end diff --git a/spec/fixtures/drops/context_drop.rb b/spec/fixtures/drops/context_drop.rb new file mode 100644 index 000000000..7cf6db6da --- /dev/null +++ b/spec/fixtures/drops/context_drop.rb @@ -0,0 +1,30 @@ +class ContextDrop < Liquid::Drop + + def read_bar + @context['bar'] + end + + def read_foo + @context['foo'] + end + + def count_scopes + @context.scopes.size + end + + def scopes_as_array + (1..@context.scopes.size).to_a + end + + def loop_pos + @context['forloop.index'] + end + + def break + Breakpoint.breakpoint + end + + def before_method(method) + return @context[method] + end +end \ No newline at end of file diff --git a/spec/fixtures/drops/enumerable_drop.rb b/spec/fixtures/drops/enumerable_drop.rb new file mode 100644 index 000000000..8b371fbe6 --- /dev/null +++ b/spec/fixtures/drops/enumerable_drop.rb @@ -0,0 +1,12 @@ +class EnumerableDrop < Liquid::Drop + + def size + 3 + end + + def each + yield 1 + yield 2 + yield 3 + end +end diff --git a/spec/fixtures/drops/error_drop.rb b/spec/fixtures/drops/error_drop.rb new file mode 100644 index 000000000..246614d86 --- /dev/null +++ b/spec/fixtures/drops/error_drop.rb @@ -0,0 +1,17 @@ +class ErrorDrop < Liquid::Drop + def standard_error + raise Liquid::StandardError, 'standard error' + end + + def argument_error + raise Liquid::ArgumentError, 'argument error' + end + + def syntax_error + raise Liquid::SyntaxError, 'syntax error' + end + + def default_raise + raise "Another error" + end +end \ No newline at end of file diff --git a/spec/fixtures/drops/product_drop.rb b/spec/fixtures/drops/product_drop.rb new file mode 100644 index 000000000..9c6361c81 --- /dev/null +++ b/spec/fixtures/drops/product_drop.rb @@ -0,0 +1,34 @@ +class ProductDrop < Liquid::Drop + class TextDrop < Liquid::Drop + def array + ['text1', 'text2'] + end + + def text + 'text1' + end + end + + class CatchallDrop < Liquid::Drop + def before_method(method) + return 'method: ' << method + end + end + + def texts + TextDrop.new + end + + def catchall + CatchallDrop.new + end + + def context + ContextDrop.new + end + + protected + def callmenot + "protected" + end +end \ No newline at end of file diff --git a/spec/integration/assign_spec.rb b/spec/integration/assign_spec.rb new file mode 100644 index 000000000..dc469a870 --- /dev/null +++ b/spec/integration/assign_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Assignment" do + let(:template) do + Liquid::Template.parse(eval(subject)) + end + + context %|with 'values' => ["foo", "bar", "baz"]| do + let(:render_options) do + { + 'values' => ["foo", "bar", "baz"] + } + end + + describe %!"{% assign foo = values | first %}.{{ foo }}."! do + it{ template.render(render_options).should == '.foo.' } + end + + describe %!"{% assign foo = values | first | capitalize %}.{{ foo }}."! do + it{ template.render(render_options).should == '.Foo.' } + end + + describe %|"{% assign foo = values %}.{{ foo[0] }}."| do + it{ template.render(render_options).should == ".foo." } + end + + describe %|"{% assign foo = values %}.{{ foo[1] }}."| do + it{ template.render(render_options).should == ".bar." } + end + + describe %|"{% assign foo = values %}.{{ foo[2] }}."| do + it{ template.render(render_options).should == ".baz." } + end + end + end +end diff --git a/spec/integration/capture_spec.rb b/spec/integration/capture_spec.rb new file mode 100644 index 000000000..6dc218d2c --- /dev/null +++ b/spec/integration/capture_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Capture" do + + # capturing blocks content in a variable + describe "assigning a capture block" do + let(:template) do + Liquid::Template.parse multiline_string(<<-END_LIQUID) + | {% capture 'var' %}test string{% endcapture %} + | {{var}} + END_LIQUID + end + + it "render the captured block" do + template.render.strip.should == "test string" + end + end + + describe "capturing to a variable from outer scope (if existing)" do + let(:template) do + Liquid::Template.parse multiline_string(<<-END_LIQUID) + | {% assign var = '' %} + | {% if true %} + | {% capture var %}first-block-string{% endcapture %} + | {% endif %} + | {% if true %} + | {% capture var %}test-string{% endcapture %} + | {% endif %} + | {{var}} + END_LIQUID + end + + it "should render the captured variable" do + template.render.strip.should == "test-string" + end + end + + describe "assigning from a capture block" do + let(:template) do + Liquid::Template.parse multiline_string(<<-END_LIQUID) + | {% assign first = '' %} + | {% assign second = '' %} + | {% for number in (1..3) %} + | {% capture first %}{{number}}{% endcapture %} + | {% assign second = first %} + | {% endfor %} + | {{ first }}-{{ second }} + END_LIQUID + end + + it "should render the captured variable" do + template.render.strip.should == "3-3" + end + + end + + end +end diff --git a/spec/integration/case_spec.rb b/spec/integration/case_spec.rb new file mode 100644 index 000000000..f3d12679a --- /dev/null +++ b/spec/integration/case_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "case" do + + context "{% case %}" do + it "should render the first block with a matching {% when %} argument" do + data = {'condition' => 1 } + render('{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', data).should == ' its 1 ' + + data = {'condition' => 2 } + render('{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', data).should == ' its 2 ' + + # dont render whitespace between case and first when + data = {'condition' => 2 } + render('{% case condition %} {% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', data).should == ' its 2 ' + end + + it "should match strings correctly" do + data = {'condition' => "string here" } + render('{% case condition %}{% when "string here" %} hit {% endcase %}', data).should == ' hit ' + + data = {'condition' => "bad string here" } + render('{% case condition %}{% when "string here" %} hit {% endcase %}', data).should == '' + end + + it "should not render anything if no matches found" do + data = {'condition' => 3 } + render(' {% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %} ', data).should == ' ' + end + + it "should evaluate variables and expressions" do + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => []).should == '' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1]).should == '1' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1]).should == '2' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1, 1]).should == '' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1, 1, 1]).should == '' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1, 1, 1, 1]).should == '' + end + + it "should allow assignment from within a {% when %} block" do + # Example from the shopify forums + template = %q({% case collection.handle %}{% when 'menswear-jackets' %}{% assign ptitle = 'menswear' %}{% when 'menswear-t-shirts' %}{% assign ptitle = 'menswear' %}{% else %}{% assign ptitle = 'womenswear' %}{% endcase %}{{ ptitle }}) + + render(template, "collection" => {'handle' => 'menswear-jackets'}).should == 'menswear' + render(template, "collection" => {'handle' => 'menswear-t-shirts'}) == 'menswear' + render(template, "collection" => {'handle' => 'x'}) == 'womenswear' + render(template, "collection" => {'handle' => 'y'}) == 'womenswear' + render(template, "collection" => {'handle' => 'z'}) == 'womenswear' + end + + it "should allow the use of 'or' to chain parameters with {% when %}" do + template = '{% case condition %}{% when 1 or 2 or 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, {'condition' => 1 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 2 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 3 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 4 }).should == ' its 4 ' + render(template, {'condition' => 5 }).should == '' + + template = '{% case condition %}{% when 1 or "string" or null %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, 'condition' => 1).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'string').should == ' its 1 or 2 or 3 ' + render(template, 'condition' => nil).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'something else').should == '' + end + + it "should allow the use of commas to chain parameters with {% when %} " do + template = '{% case condition %}{% when 1, 2, 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, {'condition' => 1 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 2 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 3 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 4 }).should == ' its 4 ' + render(template, {'condition' => 5 }).should == '' + + template = '{% case condition %}{% when 1, "string", null %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, 'condition' => 1).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'string').should == ' its 1 or 2 or 3 ' + render(template, 'condition' => nil).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'something else').should == '' + end + + it "should raise an error when theres bad syntax" do + expect { + render!('{% case false %}{% when %}true{% endcase %}') + }.to raise_error(Liquid::SyntaxError) + + expect { + render!('{% case false %}{% huh %}true{% endcase %}') + }.to raise_error(Liquid::SyntaxError) + end + + context "with {% else %}" do + it "should render the {% else %} block when no matches found" do + data = {'condition' => 5 } + render('{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', data).should == ' hit ' + + data = {'condition' => 6 } + render('{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', data).should == ' else ' + end + + it "should evaluate variables and expressions" do + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => []).should == 'else' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1]).should == '1' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1]).should == '2' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1, 1]).should == 'else' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1, 1, 1]).should == 'else' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1, 1, 1, 1]).should == 'else' + + + render('{% case a.empty? %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "else" + render('{% case false %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "false" + render('{% case true %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "true" + render('{% case NULL %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "else" + + end + end + end + end +end \ No newline at end of file diff --git a/spec/integration/comment_spec.rb b/spec/integration/comment_spec.rb new file mode 100644 index 000000000..59300a6e2 --- /dev/null +++ b/spec/integration/comment_spec.rb @@ -0,0 +1,32 @@ +describe "Liquid Rendering" do + describe "comments" do + context "{% comment %}" do + it "should not render comment blocks" do + render('{%comment%}{%endcomment%}').should == '' + render('{%comment%}{% endcomment %}').should == '' + render('{% comment %}{%endcomment%}').should == '' + render('{% comment %}{% endcomment %}').should == '' + render('{%comment%}comment{%endcomment%}').should == '' + render('{% comment %}comment{% endcomment %}').should == '' + end + + it "should render the other content that isnt inside the comment block" do + + render(%|the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?|).should == + %|the comment block should be removed .. right?| + + render('foo{%comment%}comment{%endcomment%}bar').should == 'foobar' + render('foo{% comment %}comment{% endcomment %}bar').should == 'foobar' + render('foo{%comment%} comment {%endcomment%}bar').should == 'foobar' + render('foo{% comment %} comment {% endcomment %}bar').should == 'foobar' + + render('foo {%comment%} {%endcomment%} bar').should == 'foo bar' + render('foo {%comment%}comment{%endcomment%} bar').should == 'foo bar' + render('foo {%comment%} comment {%endcomment%} bar').should == 'foo bar' + + render('foo{%comment%} + {%endcomment%}bar').should == "foobar" + end + end + end +end \ No newline at end of file diff --git a/spec/integration/drop_spec.rb b/spec/integration/drop_spec.rb new file mode 100644 index 000000000..24ac68260 --- /dev/null +++ b/spec/integration/drop_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +require 'drops/product_drop' +require 'drops/context_drop' +require 'drops/enumerable_drop' + +describe "Liquid Rendering" do + describe "Drops" do + + it "allow rendering with a product" do + lambda { + Liquid::Template.parse(' ').render('product' => ProductDrop.new) + }.should_not raise_error + end + + it "should render drops within drops" do + template = Liquid::Template.parse ' {{ product.texts.text }} ' + template.render('product' => ProductDrop.new).should == ' text1 ' + end + + it "should render the text returned from a catchall method" do + template = Liquid::Template.parse ' {{ product.catchall.unknown }} ' + template.render('product' => ProductDrop.new).should == ' method: unknown ' + end + + it "should cycle through an array of text" do + template = Liquid::Template.parse multiline_string(<<-END_LIQUID) + | {% for text in product.texts.array %} {{text}} {% endfor %} + END_LIQUID + + template.render('product' => ProductDrop.new).strip.should == "text1 text2" + end + + it "should not allow protected methods to be called" do + template = Liquid::Template.parse(' {{ product.callmenot }} ') + + template.render('product' => ProductDrop.new).should == " " + + end + + describe "context" do + it "should allow using the context within a drop" do + template = Liquid::Template.parse(' {{ context.read_bar }} ') + data = {"context" => ContextDrop.new, "bar" => "carrot"} + + template.render(data).should == " carrot " + end + + it "should allow the use of context within nested drops" do + template = Liquid::Template.parse(' {{ product.context.read_foo }} ') + data = {"product" => ProductDrop.new, "foo" => "monkey"} + + template.render(data).should == " monkey " + end + end + + describe "scope" do + + it "should allow access to context scope from within a drop" do + template = Liquid::Template.parse('{{ context.count_scopes }}') + template.render("context" => ContextDrop.new).should == "1" + + template = Liquid::Template.parse('{%for i in dummy%}{{ context.count_scopes }}{%endfor%}') + template.render("context" => ContextDrop.new, 'dummy' => [1]).should == "2" + + template = Liquid::Template.parse('{%for i in dummy%}{%for i in dummy%}{{ context.count_scopes }}{%endfor%}{%endfor%}') + template.render("context" => ContextDrop.new, 'dummy' => [1]).should == "3" + end + + it "should allow access to context scope from within a drop through a scope" do + template = Liquid::Template.parse( '{{ s }}' ) + template.render('context' => ContextDrop.new, + 's' => Proc.new{|c| c['context.count_scopes'] }).should == "1" + + template = Liquid::Template.parse( '{%for i in dummy%}{{ s }}{%endfor%}' ) + template.render('context' => ContextDrop.new, + 's' => Proc.new{|c| c['context.count_scopes']}, + 'dummy' => [1]).should == "2" + + template = Liquid::Template.parse( '{%for i in dummy%}{%for i in dummy%}{{ s }}{%endfor%}{%endfor%}' ) + template.render('context' => ContextDrop.new, + 's' => Proc.new{|c| c['context.count_scopes'] }, + 'dummy' => [1]).should == "3" + end + + it "should allow access to assigned variables through as scope" do + template = Liquid::Template.parse( '{% assign a = "variable"%}{{context.a}}' ) + template.render('context' => ContextDrop.new).should == "variable" + + template = Liquid::Template.parse( '{% assign a = "variable"%}{%for i in dummy%}{{context.a}}{%endfor%}' ) + template.render('context' => ContextDrop.new, 'dummy' => [1]).should == "variable" + + template = Liquid::Template.parse( '{% assign header_gif = "test"%}{{context.header_gif}}' ) + template.render('context' => ContextDrop.new).should == "test" + + template = Liquid::Template.parse( "{% assign header_gif = 'test'%}{{context.header_gif}}" ) + template.render('context' => ContextDrop.new).should == "test" + end + + it "should allow access to scope from within tags" do + template = Liquid::Template.parse( '{% for i in context.scopes_as_array %}{{i}}{% endfor %}' ) + template.render('context' => ContextDrop.new, 'dummy' => [1]).should == "1" + + template = Liquid::Template.parse( '{%for a in dummy%}{% for i in context.scopes_as_array %}{{i}}{% endfor %}{% endfor %}' ) + template.render('context' => ContextDrop.new, 'dummy' => [1]).should == "12" + + template = Liquid::Template.parse( '{%for a in dummy%}{%for a in dummy%}{% for i in context.scopes_as_array %}{{i}}{% endfor %}{% endfor %}{% endfor %}' ) + template.render('context' => ContextDrop.new, 'dummy' => [1]).should == "123" + end + + it "should allow access to the forloop index within a drop" do + template = Liquid::Template.parse( '{%for a in dummy%}{{ context.loop_pos }}{% endfor %}' ) + template.render('context' => ContextDrop.new, 'dummy' => ["first","second","third"]).should == "123" + end + end + + context "enumerable drop" do + + it "should allow iteration through the drop" do + template = Liquid::Template.parse( '{% for c in collection %}{{c}}{% endfor %}') + template.render('collection' => EnumerableDrop.new).should == "123" + end + + it "should return the drops size" do + template = Liquid::Template.parse( '{{collection.size}}') + template.render('collection' => EnumerableDrop.new).should == "3" + end + + end + + end +end + diff --git a/spec/integration/error_handling_spec.rb b/spec/integration/error_handling_spec.rb new file mode 100644 index 000000000..c02f85893 --- /dev/null +++ b/spec/integration/error_handling_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +require 'drops/error_drop' + +describe "Liquid Rendering" do + describe "Error Handling" do + + context "template throws a standard error" do + + it "should render the standard error message" do + template = Liquid::Template.parse(" {{ errors.standard_error }} ") + template.render('errors' => ErrorDrop.new).should == " Liquid error: standard error " + + template.errors.size.should == 1 + template.errors.first.should be_an_instance_of(Liquid::StandardError) + end + end + + context "template throws a syntax error" do + it "should render the syntax error message" do + template = Liquid::Template.parse(" {{ errors.syntax_error }} ") + template.render('errors' => ErrorDrop.new).should == " Liquid syntax error: syntax error " + + template.errors.size.should == 1 + template.errors.first.should be_an_instance_of(Liquid::SyntaxError) + end + end + + context "template throws an argument error" do + it "should render the argument error message" do + template = Liquid::Template.parse(" {{ errors.argument_error }} ") + template.render('errors' => ErrorDrop.new).should == " Liquid error: argument error " + + template.errors.size.should == 1 + template.errors.first.should be_an_instance_of(Liquid::ArgumentError) + end + end + + context "template has a missing endtag" do + it "should raise an exception when parsing" do + expect { + Liquid::Template.parse(" {% for a in b %} ") + }.to raise_error(Liquid::SyntaxError) + end + end + + context "template has an unrecognized operator" do + it "should render the unrecognized argument error message" do + template = Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ') + template.render.should == ' Liquid error: Unknown operator =! ' + + template.errors.size.should == 1 + template.errors.first.should be_an_instance_of(Liquid::ArgumentError) + end + end + + end +end \ No newline at end of file diff --git a/spec/integration/extends_spec.rb b/spec/integration/extends_spec.rb new file mode 100644 index 000000000..be60093dc --- /dev/null +++ b/spec/integration/extends_spec.rb @@ -0,0 +1,130 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Template Inheritance" do + before(:each) do + @templates ||= {} + end + + before(:each) do + Liquid::Template.file_system = self + end + + def read_template_file(template_path) + @template_path ||= {} + @templates[template_path] || raise("TestFileSystem Error: No template defined for #{template_path}") + end + + it "should allow extending a path" do + @templates['parent-template'] = "Hurrah!" + + output = render("{% extends parent-template %}") + output.should == "Hurrah!" + end + + it "should allow include blocks within the parent template" do + @templates['partial1'] = "[Partial Content1]" + @templates['partial2'] = "[Partial Content2]" + @templates['parent-with-include'] = multiline_string(<<-END) + | {% include 'partial1' %} + | {% block thing %}{% include 'partial2' %}{% endblock %} + END + + # check with overridden block + output = render multiline_string(<<-END) + | {% extends parent-with-include %} + | {% block thing %}[Overridden Block]{% endblock %} + END + + output.should == multiline_string(<<-END) + | [Partial Content1] + | [Overridden Block] + END + + # check includes within the parent's default block + output = render("{% extends parent-with-include %}") + output.should == multiline_string(<<-END) + | [Partial Content1] + | [Partial Content2] + END + end + + it "should allow access to the context from the inherited template" do + @templates['parent-with-variable'] = "Hello, {{ name }}!" + + output = render("{% extends parent-with-variable %}", 'name' => 'Joe') + output.should == "Hello, Joe!" + end + + it "should allow deep nesting of inherited templates" do + @templates['parent-with-variable'] = "Hello, {{ name }}!!" + @templates['parent-with-parent'] = "{% extends parent-with-variable %}" + + output = render("{% extends parent-with-parent %}", 'name' => 'Joe') + output.should == "Hello, Joe!!" + end + + describe "{% defaultcontent %}" do + it "should allow me to render in all the nonblock wrapped content from a parent layout" do + pending "how do i get the content?" + + @templates['parent-template'] = multiline_string(<<-END) + | OUTSIDE {% defaultcontent %} + END + + # with content + template = Liquid::Template.parse "{% extends parent-template %} [INSIDE]" + template.render.should == "OUTSIDE [INSIDE]" + + # without content + template = Liquid::Template.parse "{% extends parent-template %}" + template.render.should == "OUTSIDE " + end + end + + describe "inherited blocks" do + before(:each) do + @templates['base'] = "Output / {% block content %}Hello, World!{% endblock %}" + end + + it "should allow overriding blocks from an inherited template" do + output = render("{% extends base %}{% block content %}Hola, Mundo!{% endblock %}") + output.should == 'Output / Hola, Mundo!' + end + + it "should allow an overriding block to call super" do + output = render("{% extends base %}{% block content %}Lorem ipsum: {{block.super}}{% endblock %}") + output.should == 'Output / Lorem ipsum: Hello, World!' + end + + it "should allow deep nested includes to call super within overriden blocks" do + @templates['deep'] = "{% extends base %}{% block content %}Deep: {{block.super}}{% endblock %}" + output = render("{% extends deep %}{% block content %}Lorem ipsum: {{block.super}}{% endblock %}") + output.should == 'Output / Lorem ipsum: Deep: Hello, World!' + + @templates['nested_and_deep'] = "{% extends base %}{% block content %}Deep: {{block.super}} -{% block inner %}FOO{% endblock %}-{% endblock %}" + output = render("{% extends nested_and_deep %}{% block content/inner %}BAR{% endblock %}") + output.should == 'Output / Deep: Hello, World! -BAR-' + end + end + + describe "nested inherited blocks" do + + before(:each) do + @templates['base'] = "Output / {% block content %}Hello, World!{% block tagline %}(My tagline){% endblock %}{% endblock %}" + end + + it "should allow overriding blocks from an inherited template" do + output = render("{% extends base %}{% block content %}Hola, Mundo!{% endblock %}") + output.should == 'Output / Hola, Mundo!' + end + + it "should allow overriding blocks from an inherited template" do + output = render("{% extends base %}{% block content/tagline %}(new tagline){% endblock %}") + output.should == 'Output / Hello, World!(new tagline)' + end + + end + + end +end \ No newline at end of file diff --git a/spec/integration/filter_spec.rb b/spec/integration/filter_spec.rb new file mode 100644 index 000000000..0d5410a1d --- /dev/null +++ b/spec/integration/filter_spec.rb @@ -0,0 +1,263 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Filters" do + before(:each) do + @context = Liquid::Context.new + end + + def render_variable(body) + Liquid::Variable.new(body).render(@context) + end + + context "standard filters" do + describe "size" do + it "should return the size of a string" do + @context['val'] = "abcd" + render_variable('val | size').should == 4 + end + + it "should return the size of an array" do + @context['val'] = [1,2,3,4] + render_variable('val | size').should == 4 + end + + it "should return the size of a hash" do + @context['val'] = {"one" => 1, "two" => 2, "three" => 3, "four" => 4} + render_variable('val | size').should == 4 + end + end + + describe "join" do + it "should join an array" do + @context['val'] = [1,2,3,4] + render_variable('val | join').should == "1 2 3 4" + end + + it "should join a hash" do + @context['val'] = {"one" => 1} + render_variable('val | join').should == "one 1" + + @context['val'] = {"two" => 2, "one" => 1} + output = render_variable('val | join: ":"') + output.should == "two:2:one:1" + end + + it "should join a hash with custom field and value separators" do + @context['val'] = {"one" => 1} + render_variable('val | join').should == "one 1" + + @context['val'] = {"two" => 2, "one" => 1} + output = render_variable('val | join: "|", ":"') + output.should == "two:2|one:1" + end + + + it "should join a string" do + @context['val'] = "one" + render_variable('val | join').should == "one" + end + end + + describe "sort" do + it "should sort a single value" do + @context['value'] = 3 + render_variable("value | sort").should == [3] + end + + it "should sort an array of numbers" do + @context['numbers'] = [2,1,4,3] + render_variable("numbers | sort").should == [1,2,3,4] + end + + it "should sort an array of words" do + @context['words'] = ['expected', 'as', 'alphabetic'] + render_variable("words | sort").should == ['alphabetic', 'as', 'expected'] + end + + it "should sort an array of arrays" do + @context['arrays'] = [['flattened'], ['are']] + render_variable('arrays | sort').should == ['are', 'flattened'] + end + end + + describe "strip_html" do + it "should strip out tags around a " do + @context['user_input'] = "bla blub" + render_variable('user_input | strip_html').should == "bla blub" + end + + it "should remove script tags entirely" do + @context['user_input'] = "" + render_variable('user_input | strip_html').should == "" + end + end + + describe "capitalize" do + it "should capitalize the first character" do + @context['val'] = "blub" + render_variable('val | capitalize').should == 'Blub' + end + end + + describe "strip_newlines" do + it "should remove newlines from a string" do + @context['source'] = "a\nb\nc" + render_variable('source | strip_newlines').should == 'abc' + end + end + + describe "newline_to_br" do + it "should convert line breaks to html
's" do + @context['source'] = "a\nb\nc" + render_variable('source | newline_to_br').should == "a
\nb
\nc" + end + end + + describe "plus" do + it "should increment a number by the specified amount" do + @context['val'] = 1 + render_variable('val | plus:1').should == 2 + + @context['val'] = "1" + render_variable('val | plus:1').should == 2 + + @context['val'] = "1" + render_variable('val | plus:"1"').should == 2 + end + end + + describe "minus" do + it "should decrement a number by the specified amount" do + @context['val'] = 2 + render_variable('val | minus:1').should == 1 + + @context['val'] = "2" + render_variable('val | minus:1').should == 1 + + @context['val'] = "2" + render_variable('val | minus:"1"').should == 1 + end + end + + describe "times" do + it "should multiply a number by the specified amount" do + @context['val'] = 2 + render_variable('val | times:2').should == 4 + + @context['val'] = "2" + render_variable('val | times:2').should == 4 + + @context['val'] = "2" + render_variable('val | times:"2"').should == 4 + end + end + + describe "divided_by" do + it "should divide a number the specified amount" do + @context['val'] = 12 + render_variable('val | divided_by:3').should == 4 + end + + it "should chop off the remainder when dividing by an integer" do + @context['val'] = 14 + render_variable('val | divided_by:3').should == 4 + end + + it "should return a float when dividing by another float" do + @context['val'] = 14 + render_variable('val | divided_by:3.0').should be_within(0.001).of(4.666) + end + + it "should return an errorm essage if divided by 0" do + @context['val'] = 5 + expect{ + render_variable('val | divided_by:0') + }.to raise_error(ZeroDivisionError) + end + end + + describe "append" do + it "should append a string to another string" do + @context['val'] = "bc" + render_variable('val | append: "d"').should == "bcd" + + @context['next'] = " :: next >>" + render_variable('val | append: next').should == "bc :: next >>" + end + end + + describe "prepend" do + it "should prepend a string onto another string" do + @context['val'] = "bc" + render_variable('val | prepend: "a"').should == "abc" + + @context['prev'] = "<< prev :: " + render_variable('val | prepend: prev').should == "<< prev :: bc" + end + end + end + + module MoneyFilter + def money(input) + sprintf('$%d', input) + end + + def money_with_underscores(input) + sprintf('_$%d_', input) + end + end + + module CanadianMoneyFilter + def money(input) + sprintf('$%d CAD', input) + end + end + + context "with custom filters added to context" do + before(:each) do + @context['val'] = 1000 + end + + it "should use the local filters" do + @context.add_filters(MoneyFilter) + render_variable('val | money').should == "$1000" + render_variable('val | money_with_underscores').should == "_$1000_" + end + + it "should allow filters to overwrite previous ones" do + @context.add_filters(MoneyFilter) + @context.add_filters(CanadianMoneyFilter) + render_variable('val | money').should == "$1000 CAD" + end + end + + context "filters in template" do + before(:each) do + Liquid::Template.register_filter(MoneyFilter) + end + + it "should use globally registered filters" do + render('{{1000 | money}}').should == "$1000" + end + + it "should allow custom filters to override registered filters" do + Liquid::Template.parse('{{1000 | money}}').render(nil, :filters => CanadianMoneyFilter).should == "$1000 CAD" + Liquid::Template.parse('{{1000 | money}}').render(nil, :filters => [CanadianMoneyFilter]).should == "$1000 CAD" + end + + it "should allow pipes in string arguments" do + render("{{ 'foo|bar' | remove: '|' }}").should == "foobar" + end + + it "cannot access private methods" do + render("{{ 'a' | to_number }}").should == "a" + end + + it "should ignore nonexistant filters" do + render("{{ val | xyzzy }}", 'val' => 1000).should == "1000" + end + end + + end +end \ No newline at end of file diff --git a/spec/integration/for_spec.rb b/spec/integration/for_spec.rb new file mode 100644 index 000000000..9f25475c2 --- /dev/null +++ b/spec/integration/for_spec.rb @@ -0,0 +1,415 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "for loops" do + + describe "for" do + describe "{% for item in collection %}" do + it "should repeat the block for each item in the collection" do + data = {'collection' => [1,2,3,4]} + render('{%for item in collection%} yo {%endfor%}', data).should == ' yo yo yo yo ' + + data = {'collection' => [1,2]} + render('{%for item in collection%}yo{%endfor%}', data).should == 'yoyo' + + data = {'collection' => [1]} + render('{%for item in collection%} yo {%endfor%}', data).should == ' yo ' + + data = {'collection' => [1,2]} + render('{%for item in collection%}{%endfor%}', data).should == '' + + data = {'collection' => [1,2,3]} + render('{%for item in collection%} yo {%endfor%}', data).should == " yo yo yo " + end + + it "should allow access to the current item via {{item}}" do + data = {'collection' => [1,2,3]} + render('{%for item in collection%} {{item}} {%endfor%}', data).should == ' 1 2 3 ' + render('{% for item in collection %}{{item}}{% endfor %}', data).should == '123' + render('{%for item in collection%}{{item}}{%endfor%}', data).should == '123' + + data = {'collection' => ['a','b','c','d']} + render('{%for item in collection%}{{item}}{%endfor%}', data).should == 'abcd' + + data = {'collection' => ['a',' ','b',' ','c']} + render('{%for item in collection%}{{item}}{%endfor%}', data).should == 'a b c' + + data = {'collection' => ['a','','b','','c']} + render('{%for item in collection%}{{item}}{%endfor%}', data).should == 'abc' + end + + it "should allow deep nesting" do + data = {'array' => [[1,2],[3,4],[5,6]] } + render('{%for item in array%}{%for i in item%}{{ i }}{%endfor%}{%endfor%}', data).should == '123456' + end + + it "should expose {{forloop.name}} to get the name of the collection" do + data = {'collection' => [1] } + render("{%for item in collection%} {{forloop.name}} {%endfor%}", data).should == " item-collection " + end + + it "should expose {{forloop.length}} for the overall size of the collection being looped" do + data = {'collection' => [1,2,3] } + render("{%for item in collection%} {{forloop.length}} {%endfor%}", data).should == " 3 3 3 " + end + + it "should expose {{forloop.index}} for the current item's position in the collection (1 based)" do + data = {'collection' => [1,2,3] } + render("{%for item in collection%} {{forloop.index}} {%endfor%}", data).should == " 1 2 3 " + end + + it "should expose {{forloop.index0}} for the current item's position in the collection (0 based)" do + data = {'collection' => [1,2,3] } + render("{%for item in collection%} {{forloop.index0}} {%endfor%}", data).should == " 0 1 2 " + end + + it "should expose {{forloop.rindex}} for the number of items remaining in the collection (1 based)" do + data = {'collection' => [1,2,3] } + render("{%for item in collection%} {{forloop.rindex}} {%endfor%}", data).should == " 3 2 1 " + end + + it "should expose {{forloop.rindex0}} for the number of items remaining in the collection (0 based)" do + data = {'collection' => [1,2,3] } + render("{%for item in collection%} {{forloop.rindex0}} {%endfor%}", data).should == " 2 1 0 " + end + + it "should expose {{forloop.first}} for the first item in the collection" do + data = {'collection' => [1,2,3] } + render("{%for item in collection%} {% if forloop.first %}y{% else %}n{% endif %} {%endfor%}", data).should == " y n n " + end + + it "should expose {{forloop.last}} for the last item in the collection" do + data = {'collection' => [1,2,3] } + render("{%for item in collection%} {% if forloop.last %}y{% else %}n{% endif %} {%endfor%}", data).should == " n n y " + end + end + + describe "{% for item in collection reversed %}" do + it "should reverse the loop" do + data = {'collection' => [1,2,3] } + render("{%for item in collection reversed%}{{item}}{%endfor%}", data).should == "321" + end + end + + context "with limit and offset" do + let(:data) do + {'collection' => [1,2,3,4,5,6,7,8,9,0] } + end + + describe "{% for item in collection limit: 4 %}" do + it "should only cycle through the first 4 items of the collection" do + render("{%for item in collection limit:4%}{{item}}{%endfor%}", data).should == "1234" + render("{%for item in collection limit: 4%}{{item}}{%endfor%}", data).should == "1234" + end + end + + describe "{% for item in collection offset:8 %}" do + it "should cycle throughthe collection starting on the 9th item" do + render("{%for item in collection offset:8%}{{item}}{%endfor%}", data).should == "90" + end + end + + describe "{% for item in collection limit:4 offset:2}" do + it "should only cycle through the 4 items of the collection, starting on the 3rd item" do + render("{%for item in collection limit:4 offset:2 %}{{item}}{%endfor%}", data).should == "3456" + render("{%for item in collection limit: 4 offset: 2 %}{{item}}{%endfor%}", data).should == "3456" + end + + it "{% for item in collection limit:limit offset:offset}" do + data.merge! 'limit' => '4', 'offset' => '2' + render("{%for item in collection limit:limit offset:offset %}{{item}}{%endfor%}", data).should == "3456" + render("{%for item in collection limit: limit offset: offset %}{{item}}{%endfor%}", data).should == "3456" + end + end + + describe "{% for item in collection offset:continue limit: 3}" do + it "should resume the iteration from where it ended earlier" do + + output = render multiline_string(<<-END), data + | {%for i in collection limit:3 %}{{i}}{%endfor%} + | next + | {%for i in collection offset:continue limit:3 %}{{i}}{%endfor%} + | next + | {%for i in collection offset:continue limit:3 %}{{i}}{%endfor%} + END + + output.should == multiline_string(<<-END) + | 123 + | next + | 456 + | next + | 789 + END + end + end + + + describe "edge cases" do + context "limit: -1" do + it "should ignore the limit" do + render("{%for item in collection limit:-1 offset:5 %}{{item}}{%endfor%}", data).should == "" + end + end + + context "offset: -1" do + it "should ignore the offset" do + render("{%for item in collection limit:1 offset:-1 %}{{item}}{%endfor%}", data).should == "" + end + end + + context "offset: 100" do + it "should render an empty string" do + render("{%for item in collection limit:1 offset:100 %} {{item}} {%endfor%}", data).should == "" + end + end + + context "resume with big limit" do + it "should complete the rest of the items" do + output = render multiline_string(<<-END), data + | {%for i in collection limit:3 %}{{i}}{%endfor%} + | next + | {%for i in collection offset:continue limit:10000 %}{{i}}{%endfor%} + END + + output.should == multiline_string(<<-END) + | 123 + | next + | 4567890 + END + end + end + + context "resume with big offset" do + it "should complete the rest of the items" do + output = render multiline_string(<<-END), data + | {%for i in collection limit:3 %}{{i}}{%endfor%} + | next + | {%for i in collection offset:continue offset:10000 %}{{i}}{%endfor%} + END + + output.should == multiline_string(<<-END) + | 123 + | next + | | + END + end + end + end + end + + context "{% for item in (1..3) %}" do + it "should repeat the block for each item in the range" do + render('{%for item in (1..3) %} {{item}} {%endfor%}').should == ' 1 2 3 ' + end + end + + context "{% ifchanged %}" do + it "should render the block only if the for item is different than the last" do + data = {'array' => [ 1, 1, 2, 2, 3, 3] } + render('{%for item in array%}{%ifchanged%}{{item}}{% endifchanged %}{%endfor%}',data).should == '123' + + data = {'array' => [ 1, 1, 1, 1] } + render('{%for item in array%}{%ifchanged%}{{item}}{% endifchanged %}{%endfor%}',data).should == '1' + end + end + end + + describe "{% assign %}" do + + it "should assign a variable to a string" do + render('{%assign var = "yo" %} var:{{var}} ').should == " var:yo " + render("{%assign var = 'yo' %} var:{{var}} ").should == " var:yo " + render("{%assign var='yo' %} var:{{var}} ").should == " var:yo " + render("{%assign var='yo'%} var:{{var}} ").should == " var:yo " + + render('{%assign var="" %} var:{{var}} ').should == " var: " + render("{%assign var='' %} var:{{var}} ").should == " var: " + end + + it "should assign a variable to an integer" do + render('{%assign var = 1 %} var:{{var}} ').should == " var:1 " + render("{%assign var=1 %} var:{{var}} ").should == " var:1 " + render("{%assign var =1 %} var:{{var}} ").should == " var:1 " + end + + it "should assign a variable to a float" do + render('{%assign var = 1.011 %} var:{{var}} ').should == " var:1.011 " + render("{%assign var=1.011 %} var:{{var}} ").should == " var:1.011 " + render("{%assign var =1.011 %} var:{{var}} ").should == " var:1.011 " + end + + it "should assign a variable that includes a hyphen" do + render('{%assign a-b = "yo" %} {{a-b}} ').should == " yo " + render('{{a-b}}{%assign a-b = "yo" %} {{a-b}} ').should == " yo " + render('{%assign a-b = "yo" %} {{a-b}} {{a}} {{b}} ', 'a' => 1, 'b' => 2).should == " yo 1 2 " + end + + it "should assign a variable to a complex accessor" do + data = {'var' => {'a:b c' => {'paged' => '1' }}} + render('{%assign var2 = var["a:b c"].paged %}var2: {{var2}}', data).should == 'var2: 1' + end + + it "should assign var2 to 'hello' when var is 'hello'" do + data = {'var' => 'Hello' } + render('var2:{{var2}} {%assign var2 = var%} var2:{{var2}}',data).should == 'var2: var2:Hello' + end + + it "should assign the variable in a global context, even if it is in a block" do + render( '{%for i in (1..2) %}{% assign a = "variable"%}{% endfor %}{{a}}' ).should == "variable" + end + end + + context "{% capture %}" do + it "should capture the result of a block into a variable" do + data = {'var' => 'content' } + render('{{ var2 }}{% capture var2 %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', data).should == 'content foo content foo ' + end + + it "should throw an error when it detects bad syntax" do + data = {'var' => 'content'} + expect { + render('{{ var2 }}{% capture %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', data) + }.to raise_error(Liquid::SyntaxError) + end + end + + context "{% case %}" do + it "should render the first block with a matching {% when %} argument" do + data = {'condition' => 1 } + render('{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', data).should == ' its 1 ' + + data = {'condition' => 2 } + render('{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', data).should == ' its 2 ' + + # dont render whitespace between case and first when + data = {'condition' => 2 } + render('{% case condition %} {% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', data).should == ' its 2 ' + end + + it "should match strings correctly" do + data = {'condition' => "string here" } + render('{% case condition %}{% when "string here" %} hit {% endcase %}', data).should == ' hit ' + + data = {'condition' => "bad string here" } + render('{% case condition %}{% when "string here" %} hit {% endcase %}', data).should == '' + end + + it "should not render anything if no matches found" do + data = {'condition' => 3 } + render(' {% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %} ', data).should == ' ' + end + + it "should evaluate variables and expressions" do + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => []).should == '' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1]).should == '1' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1]).should == '2' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1, 1]).should == '' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1, 1, 1]).should == '' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', 'a' => [1, 1, 1, 1, 1]).should == '' + end + + it "should allow assignment from within a {% when %} block" do + # Example from the shopify forums + template = %q({% case collection.handle %}{% when 'menswear-jackets' %}{% assign ptitle = 'menswear' %}{% when 'menswear-t-shirts' %}{% assign ptitle = 'menswear' %}{% else %}{% assign ptitle = 'womenswear' %}{% endcase %}{{ ptitle }}) + + render(template, "collection" => {'handle' => 'menswear-jackets'}).should == 'menswear' + render(template, "collection" => {'handle' => 'menswear-t-shirts'}) == 'menswear' + render(template, "collection" => {'handle' => 'x'}) == 'womenswear' + render(template, "collection" => {'handle' => 'y'}) == 'womenswear' + render(template, "collection" => {'handle' => 'z'}) == 'womenswear' + end + + it "should allow the use of 'or' to chain parameters with {% when %}" do + template = '{% case condition %}{% when 1 or 2 or 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, {'condition' => 1 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 2 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 3 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 4 }).should == ' its 4 ' + render(template, {'condition' => 5 }).should == '' + + template = '{% case condition %}{% when 1 or "string" or null %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, 'condition' => 1).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'string').should == ' its 1 or 2 or 3 ' + render(template, 'condition' => nil).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'something else').should == '' + end + + it "should allow the use of commas to chain parameters with {% when %} " do + template = '{% case condition %}{% when 1, 2, 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, {'condition' => 1 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 2 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 3 }).should == ' its 1 or 2 or 3 ' + render(template, {'condition' => 4 }).should == ' its 4 ' + render(template, {'condition' => 5 }).should == '' + + template = '{% case condition %}{% when 1, "string", null %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' + render(template, 'condition' => 1).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'string').should == ' its 1 or 2 or 3 ' + render(template, 'condition' => nil).should == ' its 1 or 2 or 3 ' + render(template, 'condition' => 'something else').should == '' + end + + it "should raise an error when theres bad syntax" do + expect { + render!('{% case false %}{% when %}true{% endcase %}') + }.to raise_error(Liquid::SyntaxError) + + expect { + render!('{% case false %}{% huh %}true{% endcase %}') + }.to raise_error(Liquid::SyntaxError) + end + + context "with {% else %}" do + it "should render the {% else %} block when no matches found" do + data = {'condition' => 5 } + render('{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', data).should == ' hit ' + + data = {'condition' => 6 } + render('{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', data).should == ' else ' + end + + it "should evaluate variables and expressions" do + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => []).should == 'else' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1]).should == '1' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1]).should == '2' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1, 1]).should == 'else' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1, 1, 1]).should == 'else' + render('{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', 'a' => [1, 1, 1, 1, 1]).should == 'else' + + + render('{% case a.empty? %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "else" + render('{% case false %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "false" + render('{% case true %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "true" + render('{% case NULL %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}).should == "else" + + end + end + end + + context "{% cycle %}" do + + it "should cycle through a list of strings" do + render('{%cycle "one", "two"%}').should == 'one' + render('{%cycle "one", "two"%} {%cycle "one", "two"%}').should == 'one two' + render('{%cycle "", "two"%} {%cycle "", "two"%}').should == ' two' + render('{%cycle "one", "two"%} {%cycle "one", "two"%} {%cycle "one", "two"%}').should == 'one two one' + render('{%cycle "text-align: left", "text-align: right" %} {%cycle "text-align: left", "text-align: right"%}').should == 'text-align: left text-align: right' + end + + it "should keep track of multiple cycles" do + render('{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}').should == '1 2 1 1 2 3 1' + end + + it "should keep track of multiple named cycles" do + render('{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}').should == 'one one two two one one' + end + + it "should allow multiple named cycles with names from context" do + data = {"var1" => 1, "var2" => 2 } + render('{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}', data).should == 'one one two two one one' + end + end + + + end +end \ No newline at end of file diff --git a/spec/integration/if_else_unless_spec.rb b/spec/integration/if_else_unless_spec.rb new file mode 100644 index 000000000..f39fd7799 --- /dev/null +++ b/spec/integration/if_else_unless_spec.rb @@ -0,0 +1,203 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "If/Else/Unless" do + + describe "{% if %}" do + it "should show/hide content correctly when passed a boolean constant" do + render(' {% if false %} this text should not go into the output {% endif %} ').should == " " + render(' {% if true %} this text should not go into the output {% endif %} ').should == " this text should not go into the output " + render('{% if false %} you suck {% endif %} {% if true %} you rock {% endif %}? ').should == " you rock ? " + end + + it "should show/hide content correctly when passed a variable" do + template = Liquid::Template.parse(' {% if var %} YES {% endif %} ') + template.render('var' => true).should == " YES " + template.render('var' => false).should == " " + + render('{% if var %} NO {% endif %}', 'var' => false).should == '' + render('{% if var %} NO {% endif %}', 'var' => nil).should == '' + render('{% if foo.bar %} NO {% endif %}', 'foo' => {'bar' => false}).should == '' + render('{% if foo.bar %} NO {% endif %}', 'foo' => {}).should == '' + render('{% if foo.bar %} NO {% endif %}', 'foo' => nil).should == '' + render('{% if foo.bar %} NO {% endif %}', 'foo' => true).should == '' + + render('{% if var %} YES {% endif %}', 'var' => "text").should == ' YES ' + render('{% if var %} YES {% endif %}', 'var' => true).should == ' YES ' + render('{% if var %} YES {% endif %}', 'var' => 1).should == ' YES ' + render('{% if var %} YES {% endif %}', 'var' => {}).should == ' YES ' + render('{% if var %} YES {% endif %}', 'var' => []).should == ' YES ' + render('{% if "foo" %} YES {% endif %}').should == ' YES ' + render('{% if foo.bar %} YES {% endif %}', 'foo' => {'bar' => true}).should == ' YES ' + render('{% if foo.bar %} YES {% endif %}', 'foo' => {'bar' => "text"}).should == ' YES ' + render('{% if foo.bar %} YES {% endif %}', 'foo' => {'bar' => 1 }).should == ' YES ' + render('{% if foo.bar %} YES {% endif %}', 'foo' => {'bar' => {} }).should == ' YES ' + render('{% if foo.bar %} YES {% endif %}', 'foo' => {'bar' => [] }).should == ' YES ' + + render('{% if var %} NO {% else %} YES {% endif %}', 'var' => false).should == ' YES ' + render('{% if var %} NO {% else %} YES {% endif %}', 'var' => nil).should == ' YES ' + render('{% if var %} YES {% else %} NO {% endif %}', 'var' => true).should == ' YES ' + render('{% if "foo" %} YES {% else %} NO {% endif %}', 'var' => "text").should == ' YES ' + + render('{% if foo.bar %} NO {% else %} YES {% endif %}', 'foo' => {'bar' => false}).should == ' YES ' + render('{% if foo.bar %} YES {% else %} NO {% endif %}', 'foo' => {'bar' => true}).should == ' YES ' + render('{% if foo.bar %} YES {% else %} NO {% endif %}', 'foo' => {'bar' => "text"}).should == ' YES ' + render('{% if foo.bar %} NO {% else %} YES {% endif %}', 'foo' => {'notbar' => true}).should == ' YES ' + render('{% if foo.bar %} NO {% else %} YES {% endif %}', 'foo' => {}).should == ' YES ' + render('{% if foo.bar %} NO {% else %} YES {% endif %}', 'notfoo' => {'bar' => true}).should == ' YES ' + end + + it "should allow nested if conditionals" do + render('{% if false %}{% if false %} NO {% endif %}{% endif %}').should == '' + render('{% if false %}{% if true %} NO {% endif %}{% endif %}').should == '' + render('{% if true %}{% if false %} NO {% endif %}{% endif %}').should == '' + render('{% if true %}{% if true %} YES {% endif %}{% endif %}').should == ' YES ' + + render('{% if true %}{% if true %} YES {% else %} NO {% endif %}{% else %} NO {% endif %}').should == ' YES ' + render('{% if true %}{% if false %} NO {% else %} YES {% endif %}{% else %} NO {% endif %}').should == ' YES ' + render('{% if false %}{% if true %} NO {% else %} NONO {% endif %}{% else %} YES {% endif %}').should == ' YES ' + end + + it "should allow if comparisons against null" do + render('{% if null < 10 %} NO {% endif %}').should == '' + render('{% if null <= 10 %} NO {% endif %}').should == '' + render('{% if null >= 10 %} NO {% endif %}').should == '' + render('{% if null > 10 %} NO {% endif %}').should == '' + render('{% if 10 < null %} NO {% endif %}').should == '' + render('{% if 10 <= null %} NO {% endif %}').should == '' + render('{% if 10 >= null %} NO {% endif %}').should == '' + render('{% if 10 > null %} NO {% endif %}').should == '' + end + + it "should raise a syntax error if there's no closing endif" do + expect { + render('{% if jerry == 1 %}') + }.to raise_error(Liquid::SyntaxError) + end + + it "should raise a syntax error if there's variable argument" do + expect { + render('{% if %}') + }.to raise_error(Liquid::SyntaxError) + end + + it "should work with custom conditions" do + Liquid::Condition.operators['containz'] = :[] + + render("{% if 'bob' containz 'o' %}yes{% endif %}").should == "yes" + render("{% if 'bob' containz 'f' %}yes{% else %}no{% endif %}").should == "no" + render("{% if 'gnomeslab-and-or-liquid' containz 'gnomeslab-and-or-liquid' %}yes{% endif %}").should == "yes" + end + + it "should allow illegal symbols in the condition" do + render('{% if true == empty %}hello{% endif %}').should == "" + render('{% if true == null %}hello{% endif %}').should == "" + render('{% if empty == true %}hello{% endif %}').should == "" + render('{% if null == true %}hello{% endif %}').should == "" + end + + context "or conditionals" do + it "should work correctly when passed 2 variables" do + body = '{% if a or b %} YES {% endif %}' + + render(body, 'a' => true, 'b' => true).should == " YES " + render(body, 'a' => true, 'b' => false).should == " YES " + render(body, 'a' => false, 'b' => true).should == " YES " + render(body, 'a' => false, 'b' => false).should == "" + end + + it "should work correctly when passed 3 variables" do + body = '{% if a or b or c %} YES {% endif %}' + + render(body, 'a' => false, 'b' => false, 'c' => true).should == " YES " + render(body, 'a' => false, 'b' => false, 'c' => false).should == "" + end + + it "should work correctly when passed comparison operators" do + data = {'a' => true, 'b' => true} + + render('{% if a == true or b == true %} YES {% endif %}', data).should == " YES " + render('{% if a == true or b == false %} YES {% endif %}', data).should == " YES " + render('{% if a == false or b == true %} YES {% endif %}', data).should == " YES " + render('{% if a == false or b == false %} YES {% endif %}', data).should == "" + end + + it "should handle correctly when used with string comparisons" do + awful_markup = "a == 'and' and b == 'or' and c == 'foo and bar' and d == 'bar or baz' and e == 'foo' and foo and bar" + data = {'a' => 'and', 'b' => 'or', 'c' => 'foo and bar', 'd' => 'bar or baz', 'e' => 'foo', 'foo' => true, 'bar' => true} + + render("{% if #{awful_markup} %} YES {% endif %}", data).should == " YES " + end + + it "should handle correctly when using nested expression comparisons" do + data = {'order' => {'items_count' => 0}, 'android' => {'name' => 'Roy'}} + + render("{% if android.name == 'Roy' %} YES {% endif %}", data).should == " YES " + render("{% if order.items_count == 0 %} YES {% endif %}", data).should == " YES " + end + end + + context "and conditionals" do + it "should work correctly when passed 2 variables" do + body = '{% if a and b %} YES {% endif %}' + + render(body, 'a' => true, 'b' => true).should == " YES " + render(body, 'a' => true, 'b' => false).should == "" + render(body, 'a' => false, 'b' => true).should == "" + render(body, 'a' => false, 'b' => false).should == "" + end + end + end + + describe "{% if %} {% else %}" do + it "should render the right block based on the input" do + render('{% if false %} NO {% else %} YES {% endif %}').should == " YES " + render('{% if true %} YES {% else %} NO {% endif %}').should == " YES " + render('{% if "foo" %} YES {% else %} NO {% endif %}').should == " YES " + end + + it "should allow elsif helper" do + render('{% if 0 == 0 %}0{% elsif 1 == 1%}1{% else %}2{% endif %}').should == '0' + render('{% if 0 != 0 %}0{% elsif 1 == 1%}1{% else %}2{% endif %}').should == '1' + render('{% if 0 != 0 %}0{% elsif 1 != 1%}1{% else %}2{% endif %}').should == '2' + render('{% if false %}if{% elsif true %}elsif{% endif %}').should == 'elsif' + end + end + + describe "{% unless %}" do + it "should show/hide content correctly when passed a boolean constant" do + render(' {% unless true %} this text should not go into the output {% endunless %} ').should == + ' ' + + render(' {% unless false %} this text should go into the output {% endunless %} ').should == + ' this text should go into the output ' + + render('{% unless true %} you suck {% endunless %} {% unless false %} you rock {% endunless %}?').should == + ' you rock ?' + + end + + it "should work within a loop" do + data = {'choices' => [1, nil, false]} + render('{% for i in choices %}{% unless i %}{{ forloop.index }}{% endunless %}{% endfor %}', data).should == '23' + end + + end + + describe "{% unless %} {% else %}" do + it "should show/hide the section based on the passed in data" do + render('{% unless true %} NO {% else %} YES {% endunless %}').should == ' YES ' + render('{% unless false %} YES {% else %} NO {% endunless %}').should == ' YES ' + render('{% unless "foo" %} NO {% else %} YES {% endunless %}').should == ' YES ' + end + + it "should work within a loop" do + data = {'choices' => [1, nil, false]} + render('{% for i in choices %}{% unless i %} {{ forloop.index }} {% else %} TRUE {% endunless %}{% endfor %}', data).should == + ' TRUE 2 3 ' + end + end + + end + +end \ No newline at end of file diff --git a/spec/integration/include_spec.rb b/spec/integration/include_spec.rb new file mode 100644 index 000000000..add007760 --- /dev/null +++ b/spec/integration/include_spec.rb @@ -0,0 +1,98 @@ +require 'spec_helper' + +class OtherFileSystem + def read_template_file(template_path) + 'from OtherFileSystem' + end +end + +describe "Liquid Rendering" do + describe "include tag" do + + before(:each) do + Liquid::Template.file_system = self + end + + before(:each) do + @templates ||= {} + @templates['product'] = "Product: {{ product.title }} " + @templates['locale_variables'] = "Locale: {{echo1}} {{echo2}} " + @templates['variant'] = "Variant: {{ variant.title }} " + @templates['nested_template'] = "{% include 'header' %} {% include 'body' %} {% include 'footer' %}" + @templates['body'] = "body {% include 'body_detail' %}" + @templates['nested_product_template'] = "Product: {{ nested_product_template.title }} {%include 'details'%} " + @templates['recursively_nested_template'] = "-{% include 'recursively_nested_template' %}" + @templates['pick_a_source'] = "from TestFileSystem" + end + + def read_template_file(template_path) + @template_path ||= {} + @templates[template_path] || template_path + end + + it "should look for file system in registers first" do + registers = {:registers => {:file_system => OtherFileSystem.new}} + render("{% include 'pick_a_source' %}", {}, registers).should == "from OtherFileSystem" + end + + it "should take a with option" do + data = {"products" => [ {'title' => 'Draft 151cm'}, {'title' => 'Element 155cm'} ]} + render("{% include 'product' with products[0] %}", data).should == "Product: Draft 151cm " + end + + it "should use a default name" do + data = {"product" => {'title' => 'Draft 151cm'}} + render("{% include 'product' %}", data).should == "Product: Draft 151cm " + end + + it "should allow cycling through a collection with the 'for' keyword" do + data = {"products" => [ {'title' => 'Draft 151cm'}, {'title' => 'Element 155cm'} ]} + render("{% include 'product' for products %}") + end + + it "should allow passing local variables" do + # one variable + render("{% include 'locale_variables' echo1: 'test123' %}").should == "Locale: test123 " + + # multiple variables + data = {'echo1' => 'test123', 'more_echos' => { "echo2" => 'test321'}} + render("{% include 'locale_variables' echo1: echo1, echo2: more_echos.echo2 %}", data).should == "Locale: test123 test321 " + + end + + it "should allow nested includes" do + render("{% include 'body' %}").should == "body body_detail" + render("{% include 'nested_template' %}").should == "header body body_detail footer" + end + + it "should allow nested includes with a variable" do + data = {"product" => {"title" => 'Draft 151cm'}} + render("{% include 'nested_product_template' with product %}", data).should == "Product: Draft 151cm details " + + data = {"products" => [{"title" => 'Draft 151cm'}, {"title" => 'Element 155cm'}]} + render("{% include 'nested_product_template' for products %}", data).should == "Product: Draft 151cm details Product: Element 155cm details " + end + + it "should raise an error if there's an endless loop" do + infinite_file_system = Class.new do + def read_template_file(template_path) + "-{% include 'loop' %}" + end + end + + Liquid::Template.file_system = infinite_file_system.new + + expect { + render!("{% include 'loop' %}") + }.to raise_error(Liquid::StackLevelError) + end + + it "should allow dynamically choosing templates" do + render("{% include template %}", "template" => 'Test123').should == "Test123" + render("{% include template %}", "template" => 'Test321').should == "Test321" + + data = {"template" => 'product', 'product' => { 'title' => 'Draft 151cm'}} + render("{% include template for product %}", data).should == "Product: Draft 151cm " + end + end +end \ No newline at end of file diff --git a/spec/integration/output_spec.rb b/spec/integration/output_spec.rb new file mode 100644 index 000000000..5aa48eba8 --- /dev/null +++ b/spec/integration/output_spec.rb @@ -0,0 +1,131 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Basic Output" do + + let(:filters) do + {:filters => [FunnyFilter, HtmlFilter]} + end + + let(:data) do + { + 'best_cars' => 'bmw', + 'car' => {'bmw' => 'good', 'gm' => 'bad'}, + 'brands' => ['bmw', 'gm', 'ford'] + } + end + + def render(text) + super(text, data, filters) + end + + it "should not transform plaintext" do + render('this text should come out of the template without change...').should == + 'this text should come out of the template without change...' + + render('blah').should == 'blah' + render('').should == '' + render('|,.:').should == '|,.:' + render('').should == '' + + text = %|this shouldnt see any transformation either but has multiple lines + as you can clearly see here ...| + render(text).should == text + end + + it "should render a variable's value" do + render(' {{best_cars}} ').should == " bmw " + end + + it "should render a traversed variable's value" do + render(' {{car.bmw}} {{car.gm}} {{car.bmw}} ').should == " good bad good " + end + + it "should output an array's size" do + render('{{brands.size}}').should == "3" + end + + it "should output a hash's size" do + render('{{car.size}}').should == "2" + end + + module FunnyFilter + def make_funny(input) + 'LOL' + end + end + + it "should allow piping to activate filters" do + render(' {{ car.gm | make_funny }} ').should == ' LOL ' + end + + module FunnyFilter + def cite_funny(input) + "LOL: #{input}" + end + end + + it "should allow filters to read the input" do + render(' {{ car.gm | cite_funny }} ').should == " LOL: bad " + end + + module FunnyFilter + def add_smiley(input, smiley = ":-)") + "#{input} #{smiley}" + end + end + + it "should allow filters to take in parameters" do + render(' {{ car.gm | add_smiley: ":-(" }} ').should == + ' bad :-( ' + + render(' {{ car.gm | add_smiley : ":-(" }} ').should == + ' bad :-( ' + + render(' {{ car.gm | add_smiley: \':-(\' }} ').should == + ' bad :-( ' + end + + it "should allow filters with no parameters and a default argument" do + render(' {{ car.gm | add_smiley }} ').should == + ' bad :-) ' + end + + it "should allow multiple filters with parameters" do + render(' {{ car.gm | add_smiley : ":-(" | add_smiley : ":-(" }} ').should == + ' bad :-( :-( ' + end + + module FunnyFilter + def add_tag(input, tag = "p", id = "foo") + %|<#{tag} id="#{id}">#{input}| + end + end + + it "should allow filters with multiple parameters" do + render(' {{ car.gm | add_tag : "span", "bar"}} ').should == + ' bad ' + end + + it "should allow filters with variable parameters" do + render(' {{ car.gm | add_tag : "span", car.bmw }} ').should == + ' bad ' + end + + module HtmlFilter + def paragraph(input) + "

#{input}

" + end + + def link_to(name, url) + %|#{name}| + end + end + + it "should allow multiple chained filters" do + render(' {{ best_cars | cite_funny | link_to: "http://www.google.com" | paragraph }} ').should == + '

LOL: bmw

' + end + + end +end \ No newline at end of file diff --git a/spec/integration/security_spec.rb b/spec/integration/security_spec.rb new file mode 100644 index 000000000..c69bba0b6 --- /dev/null +++ b/spec/integration/security_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Security" do + module SecurityFilter + def add_one(input) + "#{input} + 1" + end + end + + it "should not allow instance eval" do + render(" {{ '1+1' | instance_eval }} ").should == " 1+1 " + end + + it "should not allow existing instance eval" do + render(" {{ '1+1' | __instance_eval__ }} ").should == " 1+1 " + end + + it "should not allow instance eval later in chain" do + filters = {:filters => SecurityFilter} + render(" {{ '1+1' | add_one | instance_eval }} ", {}, filters).should == " 1+1 + 1 " + end + + end +end \ No newline at end of file diff --git a/spec/integration/statements_spec.rb b/spec/integration/statements_spec.rb new file mode 100644 index 000000000..76b3a41d1 --- /dev/null +++ b/spec/integration/statements_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "statements" do + let(:data) do + {} + end + + def render(*args) + super("#{subject} true {% else %} false {% endif %}", data).strip + end + + describe %| {% if true == true %} | do + it{ render.should == "true" } + end + + describe %| {% if true != true %} | do + it{ render.should == "false" } + end + + describe %| {% if 0 > 0 %} | do + it{ render.should == "false" } + end + + describe %| {% if 1 > 0 %} | do + it{ render.should == "true" } + end + + describe %| {% if 0 < 1 %} | do + it{ render.should == "true" } + end + + describe %| {% if 0 <= 0 %} | do + it{ render.should == "true" } + end + + describe %| {% if null <= 0 %} | do + it{ render.should == "false" } + end + + describe %| {% if 0 <= null %} | do + it{ render.should == "false" } + end + + describe %| {% if 0 >= 0 %} | do + it{ render.should == "true" } + end + + describe %| {% if 'test' == 'test' %} | do + it{ render.should == "true" } + end + + describe %| {% if 'test' != 'test' %} | do + it{ render.should == "false" } + end + + context 'when var is assigned to "hello there!"' do + let(:data) do + { 'var' => "hello there!" } + end + + describe %| {% if var == "hello there!" %} | do + it{ render.should == "true" } + end + + describe %| {% if "hello there!" == var %} | do + it{ render.should == "true" } + end + + describe %| {% if var == 'hello there!' %} | do + it{ render.should == "true" } + end + + describe %| {% if 'hello there!' == var %} | do + it{ render.should == "true" } + end + end + + context 'when array is assigned to []' do + let(:data) do + {'array' => ''} + end + describe %| {% if array == empty %} | do + it{ render.should == "true" } + end + end + + + context 'when array is assigned to [1,2,3]' do + let(:data) do + {'array' => [1,2,3]} + end + + describe %| {% if array == empty %} | do + it{ render.should == "false" } + end + end + + context "when var is assigned to nil" do + let(:data) do + {'var' => nil} + end + + describe %| {% if var == nil %} | do + it{ render.should == "true" } + end + + describe %| {% if var == null %} | do + it{ render.should == "true" } + end + end + + context "when var is assigned to 1" do + let(:data) do + {'var' => 1} + end + + describe %| {% if var != nil %} | do + it{ render.should == "true" } + end + + describe %| {% if var != null %} | do + it{ render.should == "true" } + end + end + + + end +end \ No newline at end of file diff --git a/spec/integration/table_spec.rb b/spec/integration/table_spec.rb new file mode 100644 index 000000000..275eaca9e --- /dev/null +++ b/spec/integration/table_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Table helpers " do + + describe "tablerow" do + it "should render a table with rows of 3 columns each" do + + template = Liquid::Template.parse multiline_string(<<-END) + | {% tablerow n in numbers cols: 3 %} {{ n }} {% endtablerow %} + END + + template.render('numbers' => [1,2,3,4,5,6]).strip.should == multiline_string(<<-END).strip + | + | 1 2 3 + | 4 5 6 + END + + end + + it "should render an empty table row of columns" do + template = Liquid::Template.parse multiline_string(<<-END) + | {% tablerow n in numbers cols: 3 %} {{ n }} {% endtablerow %} + END + + template.render('numbers' => []).should == "\n\n" + end + + it "should render a table with rows of 5 columns each" do + template = Liquid::Template.parse multiline_string(<<-END) + | {% tablerow n in numbers cols: 5 %} {{ n }} {% endtablerow %} + END + + template.render('numbers' => [1,2,3,4,5,6]).strip.should == multiline_string(<<-END).strip + | + | 1 2 3 4 5 + | 6 + END + end + + it "should provide a tablerowloop.col counter within the tablerow" do + template = Liquid::Template.parse multiline_string(<<-END) + | {% tablerow n in numbers cols: 2 %} {{ tablerowloop.col }} {% endtablerow %} + END + + template.render('numbers' => [1,2,3,4,5,6]).strip.should == multiline_string(<<-END).strip + | + | 1 2 + | 1 2 + | 1 2 + END + end + + end + + end +end \ No newline at end of file diff --git a/spec/integration/tag_spec.rb b/spec/integration/tag_spec.rb new file mode 100644 index 000000000..281cb9fdd --- /dev/null +++ b/spec/integration/tag_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Tags" do + + describe "{% assign %}" do + + it "should assign a variable to a string" do + render('{%assign var = "yo" %} var:{{var}} ').should == " var:yo " + render("{%assign var = 'yo' %} var:{{var}} ").should == " var:yo " + render("{%assign var='yo' %} var:{{var}} ").should == " var:yo " + render("{%assign var='yo'%} var:{{var}} ").should == " var:yo " + + render('{%assign var="" %} var:{{var}} ').should == " var: " + render("{%assign var='' %} var:{{var}} ").should == " var: " + end + + it "should assign a variable to an integer" do + render('{%assign var = 1 %} var:{{var}} ').should == " var:1 " + render("{%assign var=1 %} var:{{var}} ").should == " var:1 " + render("{%assign var =1 %} var:{{var}} ").should == " var:1 " + end + + it "should assign a variable to a float" do + render('{%assign var = 1.011 %} var:{{var}} ').should == " var:1.011 " + render("{%assign var=1.011 %} var:{{var}} ").should == " var:1.011 " + render("{%assign var =1.011 %} var:{{var}} ").should == " var:1.011 " + end + + it "should assign a variable that includes a hyphen" do + render('{%assign a-b = "yo" %} {{a-b}} ').should == " yo " + render('{{a-b}}{%assign a-b = "yo" %} {{a-b}} ').should == " yo " + render('{%assign a-b = "yo" %} {{a-b}} {{a}} {{b}} ', 'a' => 1, 'b' => 2).should == " yo 1 2 " + end + + it "should assign a variable to a complex accessor" do + data = {'var' => {'a:b c' => {'paged' => '1' }}} + render('{%assign var2 = var["a:b c"].paged %}var2: {{var2}}', data).should == 'var2: 1' + end + + it "should assign var2 to 'hello' when var is 'hello'" do + data = {'var' => 'Hello' } + render('var2:{{var2}} {%assign var2 = var%} var2:{{var2}}',data).should == 'var2: var2:Hello' + end + + it "should assign the variable in a global context, even if it is in a block" do + render( '{%for i in (1..2) %}{% assign a = "variable"%}{% endfor %}{{a}}' ).should == "variable" + end + end + + context "{% capture %}" do + it "should capture the result of a block into a variable" do + data = {'var' => 'content' } + render('{{ var2 }}{% capture var2 %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', data).should == 'content foo content foo ' + end + + it "should throw an error when it detects bad syntax" do + data = {'var' => 'content'} + expect { + render('{{ var2 }}{% capture %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', data) + }.to raise_error(Liquid::SyntaxError) + end + end + + context "{% cycle %}" do + + it "should cycle through a list of strings" do + render('{%cycle "one", "two"%}').should == 'one' + render('{%cycle "one", "two"%} {%cycle "one", "two"%}').should == 'one two' + render('{%cycle "", "two"%} {%cycle "", "two"%}').should == ' two' + render('{%cycle "one", "two"%} {%cycle "one", "two"%} {%cycle "one", "two"%}').should == 'one two one' + render('{%cycle "text-align: left", "text-align: right" %} {%cycle "text-align: left", "text-align: right"%}').should == 'text-align: left text-align: right' + end + + it "should keep track of multiple cycles" do + render('{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}').should == '1 2 1 1 2 3 1' + end + + it "should keep track of multiple named cycles" do + render('{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}').should == 'one one two two one one' + end + + it "should allow multiple named cycles with names from context" do + data = {"var1" => 1, "var2" => 2 } + render('{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}', data).should == 'one one two two one one' + end + end + + + end +end \ No newline at end of file diff --git a/spec/integration/variable_spec.rb b/spec/integration/variable_spec.rb new file mode 100644 index 000000000..a541cfe81 --- /dev/null +++ b/spec/integration/variable_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe "Liquid Rendering" do + describe "Variables" do + + it "should render simple variables" do + render('{{test}}', 'test' => 'worked').should == "worked" + render('{{test}}', 'test' => 'worked wonderfully').should == 'worked wonderfully' + end + + it "should render variables with whitespace" do + render(' {{ test }} ', 'test' => 'worked').should == ' worked ' + render(' {{ test }} ', 'test' => 'worked wonderfully').should == ' worked wonderfully ' + end + + it "should ignore unknown variables" do + render('{{ idontexistyet }}').should == "" + end + + it "should scope hash variables" do + data = {'test' => {'test' => 'worked'}} + render('{{ test.test }}', data).should == "worked" + end + + it "should render preset assigned variables" do + template = Liquid::Template.parse("{{ test }}") + template.assigns['test'] = 'worked' + template.render.should == "worked" + end + + it "should reuse parsed template" do + template = Liquid::Template.parse("{{ greeting }} {{ name }}") + template.assigns['greeting'] = 'Goodbye' + template.render('greeting' => 'Hello', 'name' => 'Tobi').should == 'Hello Tobi' + template.render('greeting' => 'Hello', 'unknown' => 'Tobi').should == 'Hello ' + template.render('greeting' => 'Hello', 'name' => 'Brian').should == 'Hello Brian' + template.render('name' => 'Brian').should == 'Goodbye Brian' + + template.assigns.should == {'greeting' => 'Goodbye'} + end + + it "should not get polluted with assignments from templates" do + template = Liquid::Template.parse(%|{{ test }}{% assign test = 'bar' %}{{ test }}|) + template.assigns['test'] = 'baz' + template.render.should == 'bazbar' + template.render.should == 'bazbar' + template.render('test' => 'foo').should == 'foobar' + template.render.should == 'bazbar' + end + + it "should allow a hash with a default proc" do + template = Liquid::Template.parse(%|Hello {{ test }}|) + assigns = Hash.new { |h,k| raise "Unknown variable '#{k}'" } + assigns['test'] = 'Tobi' + + template.render!(assigns).should == 'Hello Tobi' + assigns.delete('test') + + expect{ + template.render!(assigns) + }.to raise_error(RuntimeError, "Unknown variable 'test'") + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..0bb6c80b4 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,67 @@ +require 'rubygems' +require "bundler" +Bundler.setup + +# add spec folder to load path +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__))) + +# add lib to load path +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) + +# add fixtures to load path +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "fixtures")) + +require 'locomotive_liquid' +require 'active_support/core_ext' + +require 'rspec' + +# add support to load path +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "support")) + +# load support helpers +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} + +# Liquid Helpers for use within specs +module Liquid + module SpecHelpers + + # shortcut to render a template + def render(body, *args) + body = eval(subject) if body == :subject + Liquid::Template.parse(body).render(*args) + end + + def render!(body, *args) + body = eval(subject) if body == :subject + Liquid::Template.parse(body).render!(*args) + end + + # shortcut to parse a template + def parse(body = nil) + body = eval(subject) if body == :subject + Liquid::Template.parse(body) + end + + # helper to output a node's information + def print_child(node, depth = 0) + information = (case node + when Liquid::InheritedBlock + "Liquid::InheritedBlock #{node.object_id} / #{node.name} / #{!node.parent.nil?} / #{node.nodelist.first.inspect}" + else + node.class.name + end) + + puts information.insert(0, ' ' * (depth * 2)) + if node.respond_to?(:nodelist) + node.nodelist.each do |node| + print_child node, depth + 1 + end + end + end + end +end + +RSpec.configure do |c| + c.include Liquid::SpecHelpers +end diff --git a/spec/support/breakpoint.rb b/spec/support/breakpoint.rb new file mode 100755 index 000000000..c118e228a --- /dev/null +++ b/spec/support/breakpoint.rb @@ -0,0 +1,547 @@ +# The Breakpoint library provides the convenience of +# being able to inspect and modify state, diagnose +# bugs all via IRB by simply setting breakpoints in +# your applications by the call of a method. +# +# This library was written and is supported by me, +# Florian Gross. I can be reached at flgr@ccan.de +# and enjoy getting feedback about my libraries. +# +# The whole library (including breakpoint_client.rb +# and binding_of_caller.rb) is licensed under the +# same license that Ruby uses. (Which is currently +# either the GNU General Public License or a custom +# one that allows for commercial usage.) If you for +# some good reason need to use this under another +# license please contact me. + +require 'irb' +require 'caller' +require 'drb' +require 'drb/acl' +require 'thread' + +module Breakpoint + id = %q$Id: breakpoint.rb 52 2005-02-26 19:43:19Z flgr $ + current_version = id.split(" ")[2] + unless defined?(Version) + # The Version of ruby-breakpoint you are using as String of the + # 1.2.3 form where the digits stand for release, major and minor + # version respectively. + Version = "0.5.0" + end + + extend self + + # This will pop up an interactive ruby session at a + # pre-defined break point in a Ruby application. In + # this session you can examine the environment of + # the break point. + # + # You can get a list of variables in the context using + # local_variables via +local_variables+. You can then + # examine their values by typing their names. + # + # You can have a look at the call stack via +caller+. + # + # The source code around the location where the breakpoint + # was executed can be examined via +source_lines+. Its + # argument specifies how much lines of context to display. + # The default amount of context is 5 lines. Note that + # the call to +source_lines+ can raise an exception when + # it isn't able to read in the source code. + # + # breakpoints can also return a value. They will execute + # a supplied block for getting a default return value. + # A custom value can be returned from the session by doing + # +throw(:debug_return, value)+. + # + # You can also give names to break points which will be + # used in the message that is displayed upon execution + # of them. + # + # Here's a sample of how breakpoints should be placed: + # + # class Person + # def initialize(name, age) + # @name, @age = name, age + # breakpoint("Person#initialize") + # end + # + # attr_reader :age + # def name + # breakpoint("Person#name") { @name } + # end + # end + # + # person = Person.new("Random Person", 23) + # puts "Name: #{person.name}" + # + # And here is a sample debug session: + # + # Executing break point "Person#initialize" at file.rb:4 in `initialize' + # irb(#):001:0> local_variables + # => ["name", "age", "_", "__"] + # irb(#):002:0> [name, age] + # => ["Random Person", 23] + # irb(#):003:0> [@name, @age] + # => ["Random Person", 23] + # irb(#):004:0> self + # => # + # irb(#):005:0> @age += 1; self + # => # + # irb(#):006:0> exit + # Executing break point "Person#name" at file.rb:9 in `name' + # irb(#):001:0> throw(:debug_return, "Overriden name") + # Name: Overriden name + # + # Breakpoint sessions will automatically have a few + # convenience methods available. See Breakpoint::CommandBundle + # for a list of them. + # + # Breakpoints can also be used remotely over sockets. + # This is implemented by running part of the IRB session + # in the application and part of it in a special client. + # You have to call Breakpoint.activate_drb to enable + # support for remote breakpoints and then run + # breakpoint_client.rb which is distributed with this + # library. See the documentation of Breakpoint.activate_drb + # for details. + def breakpoint(id = nil, context = nil, &block) + callstack = caller + callstack.slice!(0, 3) if callstack.first["breakpoint"] + file, line, method = *callstack.first.match(/^(.+?):(\d+)(?::in `(.*?)')?/).captures + + message = "Executing break point " + (id ? "#{id.inspect} " : "") + + "at #{file}:#{line}" + (method ? " in `#{method}'" : "") + + if context then + return handle_breakpoint(context, message, file, line, &block) + end + + Binding.of_caller do |binding_context| + handle_breakpoint(binding_context, message, file, line, &block) + end + end + + # These commands are automatically available in all breakpoint shells. + module CommandBundle + # Proxy to a Breakpoint client. Lets you directly execute code + # in the context of the client. + class Client + def initialize(eval_handler) # :nodoc: + eval_handler.untaint + @eval_handler = eval_handler + end + + instance_methods.each do |method| + next if method[/^__.+__$/] + undef_method method + end + + # Executes the specified code at the client. + def eval(code) + @eval_handler.call(code) + end + + # Will execute the specified statement at the client. + def method_missing(method, *args, &block) + if args.empty? and not block + result = eval "#{method}" + else + # This is a bit ugly. The alternative would be using an + # eval context instead of an eval handler for executing + # the code at the client. The problem with that approach + # is that we would have to handle special expressions + # like "self", "nil" or constants ourself which is hard. + remote = eval %{ + result = lambda { |block, *args| #{method}(*args, &block) } + def result.call_with_block(*args, &block) + call(block, *args) + end + result + } + remote.call_with_block(*args, &block) + end + + return result + end + end + + # Returns the source code surrounding the location where the + # breakpoint was issued. + def source_lines(context = 5, return_line_numbers = false) + lines = File.readlines(@__bp_file).map { |line| line.chomp } + + break_line = @__bp_line + start_line = [break_line - context, 1].max + end_line = break_line + context + + result = lines[(start_line - 1) .. (end_line - 1)] + + if return_line_numbers then + return [start_line, break_line, result] + else + return result + end + end + + # Lets an object that will forward method calls to the breakpoint + # client. This is useful for outputting longer things at the client + # and so on. You can for example do these things: + # + # client.puts "Hello" # outputs "Hello" at client console + # # outputs "Hello" into the file temp.txt at the client + # client.File.open("temp.txt", "w") { |f| f.puts "Hello" } + def client() + if Breakpoint.use_drb? then + sleep(0.5) until Breakpoint.drb_service.eval_handler + Client.new(Breakpoint.drb_service.eval_handler) + else + Client.new(lambda { |code| eval(code, TOPLEVEL_BINDING) }) + end + end + end + + def handle_breakpoint(context, message, file = "", line = "", &block) # :nodoc: + catch(:debug_return) do |value| + eval(%{ + @__bp_file = #{file.inspect} + @__bp_line = #{line} + extend Breakpoint::CommandBundle + extend DRbUndumped if self + }, context) rescue nil + + if not use_drb? then + puts message + IRB.start(nil, IRB::WorkSpace.new(context)) + else + @drb_service.add_breakpoint(context, message) + end + + block.call if block + end + end + + # These exceptions will be raised on failed asserts + # if Breakpoint.asserts_cause_exceptions is set to + # true. + class FailedAssertError < RuntimeError + end + + # This asserts that the block evaluates to true. + # If it doesn't evaluate to true a breakpoint will + # automatically be created at that execution point. + # + # You can disable assert checking in production + # code by setting Breakpoint.optimize_asserts to + # true. (It will still be enabled when Ruby is run + # via the -d argument.) + # + # Example: + # person_name = "Foobar" + # assert { not person_name.nil? } + # + # Note: If you want to use this method from an + # unit test, you will have to call it by its full + # name, Breakpoint.assert. + def assert(context = nil, &condition) + return if Breakpoint.optimize_asserts and not $DEBUG + return if yield + + callstack = caller + callstack.slice!(0, 3) if callstack.first["assert"] + file, line, method = *callstack.first.match(/^(.+?):(\d+)(?::in `(.*?)')?/).captures + + message = "Assert failed at #{file}:#{line}#{" in `#{method}'" if method}." + + if Breakpoint.asserts_cause_exceptions and not $DEBUG then + raise(Breakpoint::FailedAssertError, message) + end + + message += " Executing implicit breakpoint." + + if context then + return handle_breakpoint(context, message, file, line) + end + + Binding.of_caller do |context| + handle_breakpoint(context, message, file, line) + end + end + + # Whether asserts should be ignored if not in debug mode. + # Debug mode can be enabled by running ruby with the -d + # switch or by setting $DEBUG to true. + attr_accessor :optimize_asserts + self.optimize_asserts = false + + # Whether an Exception should be raised on failed asserts + # in non-$DEBUG code or not. By default this is disabled. + attr_accessor :asserts_cause_exceptions + self.asserts_cause_exceptions = false + @use_drb = false + + attr_reader :drb_service # :nodoc: + + class DRbService # :nodoc: + include DRbUndumped + + def initialize + @handler = @eval_handler = @collision_handler = nil + + IRB.instance_eval { @CONF[:RC] = true } + IRB.run_config + end + + def collision + sleep(0.5) until @collision_handler + + @collision_handler.untaint + + @collision_handler.call + end + + def ping() end + + def add_breakpoint(context, message) + workspace = IRB::WorkSpace.new(context) + workspace.extend(DRbUndumped) + + sleep(0.5) until @handler + + @handler.untaint + @handler.call(workspace, message) + rescue Errno::ECONNREFUSED, DRb::DRbConnError + raise if Breakpoint.use_drb? + end + + attr_accessor :handler, :eval_handler, :collision_handler + end + + # Will run Breakpoint in DRb mode. This will spawn a server + # that can be attached to via the breakpoint-client command + # whenever a breakpoint is executed. This is useful when you + # are debugging CGI applications or other applications where + # you can't access debug sessions via the standard input and + # output of your application. + # + # You can specify an URI where the DRb server will run at. + # This way you can specify the port the server runs on. The + # default URI is druby://localhost:42531. + # + # Please note that breakpoints will be skipped silently in + # case the DRb server can not spawned. (This can happen if + # the port is already used by another instance of your + # application on CGI or another application.) + # + # Also note that by default this will only allow access + # from localhost. You can however specify a list of + # allowed hosts or nil (to allow access from everywhere). + # But that will still not protect you from somebody + # reading the data as it goes through the net. + # + # A good approach for getting security and remote access + # is setting up an SSH tunnel between the DRb service + # and the client. This is usually done like this: + # + # $ ssh -L20000:127.0.0.1:20000 -R10000:127.0.0.1:10000 example.com + # (This will connect port 20000 at the client side to port + # 20000 at the server side, and port 10000 at the server + # side to port 10000 at the client side.) + # + # After that do this on the server side: (the code being debugged) + # Breakpoint.activate_drb("druby://127.0.0.1:20000", "localhost") + # + # And at the client side: + # ruby breakpoint_client.rb -c druby://127.0.0.1:10000 -s druby://127.0.0.1:20000 + # + # Running through such a SSH proxy will also let you use + # breakpoint.rb in case you are behind a firewall. + # + # Detailed information about running DRb through firewalls is + # available at http://www.rubygarden.org/ruby?DrbTutorial + # + # == Security considerations + # Usually you will be fine when using the default druby:// URI and the default + # access control list. However, if you are sitting on a machine where there are + # local users that you likely can not trust (this is the case for example on + # most web hosts which have multiple users sitting on the same physical machine) + # you will be better off by doing client/server communication through a unix + # socket. This can be accomplished by calling with a drbunix:/ style URI, e.g. + # Breakpoint.activate_drb('drbunix:/tmp/breakpoint_server'). This + # will only work on Unix based platforms. + def activate_drb(uri = nil, allowed_hosts = ['localhost', '127.0.0.1', '::1'], + ignore_collisions = false) + + return false if @use_drb + + uri ||= 'druby://localhost:42531' + + if allowed_hosts then + acl = ["deny", "all"] + + Array(allowed_hosts).each do |host| + acl += ["allow", host] + end + + DRb.install_acl(ACL.new(acl)) + end + + @use_drb = true + @drb_service = DRbService.new + did_collision = false + begin + @service = DRb.start_service(uri, @drb_service) + rescue Errno::EADDRINUSE + if ignore_collisions then + nil + else + # The port is already occupied by another + # Breakpoint service. We will try to tell + # the old service that we want its port. + # It will then forward that request to the + # user and retry. + unless did_collision then + DRbObject.new(nil, uri).collision + did_collision = true + end + sleep(10) + retry + end + end + + return true + end + + # Deactivates a running Breakpoint service. + def deactivate_drb + Thread.exclusive do + @service.stop_service unless @service.nil? + @service = nil + @use_drb = false + @drb_service = nil + end + end + + # Returns true when Breakpoints are used over DRb. + # Breakpoint.activate_drb causes this to be true. + def use_drb? + @use_drb == true + end +end + +module IRB # :nodoc: + class << self; remove_method :start; end + def self.start(ap_path = nil, main_context = nil, workspace = nil) + $0 = File::basename(ap_path, ".rb") if ap_path + + # suppress some warnings about redefined constants + old_verbose, $VERBOSE = $VERBOSE, nil + IRB.setup(ap_path) + $VERBOSE = old_verbose + + if @CONF[:SCRIPT] then + irb = Irb.new(main_context, @CONF[:SCRIPT]) + else + irb = Irb.new(main_context) + end + + if workspace then + irb.context.workspace = workspace + end + + @CONF[:IRB_RC].call(irb.context) if @CONF[:IRB_RC] + @CONF[:MAIN_CONTEXT] = irb.context + + old_sigint = trap("SIGINT") do + begin + irb.signal_handle + rescue RubyLex::TerminateLineInput + # ignored + end + end + + catch(:IRB_EXIT) do + irb.eval_input + end + ensure + trap("SIGINT", old_sigint) + end + + class << self + alias :old_CurrentContext :CurrentContext + remove_method :CurrentContext + remove_method :parse_opts + end + + def IRB.CurrentContext + if old_CurrentContext.nil? and Breakpoint.use_drb? then + result = Object.new + def result.last_value; end + return result + else + old_CurrentContext + end + end + def IRB.parse_opts() end + + class Context # :nodoc: + alias :old_evaluate :evaluate + def evaluate(line, line_no) + if line.chomp == "exit" then + exit + else + old_evaluate(line, line_no) + end + end + end + + class WorkSpace # :nodoc: + alias :old_evaluate :evaluate + + def evaluate(*args) + if Breakpoint.use_drb? then + result = old_evaluate(*args) + if args[0] != :no_proxy and + not [true, false, nil].include?(result) + then + result.extend(DRbUndumped) rescue nil + end + return result + else + old_evaluate(*args) + end + end + end + + module InputCompletor # :nodoc: + def self.eval(code, context, *more) + # Big hack, this assumes that InputCompletor + # will only call eval() when it wants code + # to be executed in the IRB context. + IRB.conf[:MAIN_CONTEXT].workspace.evaluate(:no_proxy, code, *more) + end + end +end + +module DRb # :nodoc: + class DRbObject # :nodoc: + undef :inspect if method_defined?(:inspect) + undef :clone if method_defined?(:clone) + end +end + +# See Breakpoint.breakpoint +def breakpoint(id = nil, &block) + Binding.of_caller do |context| + Breakpoint.breakpoint(id, context, &block) + end +end + +# See Breakpoint.assert +def assert(&block) + Binding.of_caller do |context| + Breakpoint.assert(context, &block) + end +end diff --git a/spec/support/caller.rb b/spec/support/caller.rb new file mode 100755 index 000000000..14c96eb28 --- /dev/null +++ b/spec/support/caller.rb @@ -0,0 +1,80 @@ +class Continuation # :nodoc: + def self.create(*args, &block) # :nodoc: + cc = nil; result = callcc {|c| cc = c; block.call(cc) if block and args.empty?} + result ||= args + return *[cc, *result] + end +end + +class Binding; end # for RDoc +# This method returns the binding of the method that called your +# method. It will raise an Exception when you're not inside a method. +# +# It's used like this: +# def inc_counter(amount = 1) +# Binding.of_caller do |binding| +# # Create a lambda that will increase the variable 'counter' +# # in the caller of this method when called. +# inc = eval("lambda { |arg| counter += arg }", binding) +# # We can refer to amount from inside this block safely. +# inc.call(amount) +# end +# # No other statements can go here. Put them inside the block. +# end +# counter = 0 +# 2.times { inc_counter } +# counter # => 2 +# +# Binding.of_caller must be the last statement in the method. +# This means that you will have to put everything you want to +# do after the call to Binding.of_caller into the block of it. +# This should be no problem however, because Ruby has closures. +# If you don't do this an Exception will be raised. Because of +# the way that Binding.of_caller is implemented it has to be +# done this way. +def Binding.of_caller(&block) + old_critical = Thread.critical + Thread.critical = true + count = 0 + cc, result, error, extra_data = Continuation.create(nil, nil) + error.call if error + + tracer = lambda do |*args| + type, context, extra_data = args[0], args[4], args + if type == "return" + count += 1 + # First this method and then calling one will return -- + # the trace event of the second event gets the context + # of the method which called the method that called this + # method. + if count == 2 + # It would be nice if we could restore the trace_func + # that was set before we swapped in our own one, but + # this is impossible without overloading set_trace_func + # in current Ruby. + set_trace_func(nil) + cc.call(eval("binding", context), nil, extra_data) + end + elsif type == "line" then + nil + elsif type == "c-return" and extra_data[3] == :set_trace_func then + nil + else + set_trace_func(nil) + error_msg = "Binding.of_caller used in non-method context or " + + "trailing statements of method using it aren't in the block." + cc.call(nil, lambda { raise(ArgumentError, error_msg) }, nil) + end + end + + unless result + set_trace_func(tracer) + return nil + else + Thread.critical = old_critical + case block.arity + when 1 then yield(result) + else yield(result, extra_data) + end + end +end diff --git a/spec/support/multiline_string.rb b/spec/support/multiline_string.rb new file mode 100644 index 000000000..032600aa9 --- /dev/null +++ b/spec/support/multiline_string.rb @@ -0,0 +1,28 @@ +module RSpec + module MultilineString + # + # used to format multiline strings (prefix lines with |) + # + # example: + # + # multiline_template <<-END + # | hello + # | | + # | | + # END + # + # this parses to: + # " hello\n \n \n + # + def multiline_string(string, pipechar = '|') + arr = string.split("\n") # Split into lines + arr.map! {|x| x.sub(/^\s*\| /, "")} # Remove leading characters + arr.map! {|x| x.sub(/\|$/,"")} # Remove ending characters + arr.join("\n") # Rejoin into a single line + end + end +end + +RSpec.configure do |c| + c.include RSpec::MultilineString +end \ No newline at end of file diff --git a/spec/unit/condition_spec.rb b/spec/unit/condition_spec.rb new file mode 100644 index 000000000..bd8e4ab24 --- /dev/null +++ b/spec/unit/condition_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +module Liquid + describe Condition do + before(:each) do + @context = Context.new + end + + # simple wrapper around CheckCondition evaluate + def check_condition(*args) + Condition.new(*args).evaluate(@context) + end + + it "should check basic equality conditions" do + check_condition("1", "==", "2").should be_false + check_condition("1", "==", "1").should be_true + end + + it "should check expressions" do + @context['one'] = @context['another'] = "gnomeslab-and-or-liquid" + check_condition('one', '==', 'another').should be_true + end + + context "Default Operators (==, !=, <>, <, >, >=, <=)" do + it "should evaluate true when appropriate" do + check_condition('1', '==', '1').should be_true + check_condition('1', '!=', '2').should be_true + check_condition('1', '<>', '2').should be_true + check_condition('1', '<', '2').should be_true + check_condition('2', '>', '1').should be_true + check_condition('1', '>=', '1').should be_true + check_condition('2', '>=', '1').should be_true + check_condition('1', '<=', '2').should be_true + check_condition('1', '<=', '1').should be_true + end + + it "should evaluate false when appropriate" do + check_condition('1', '==', '2').should be_false + check_condition('1', '!=', '1').should be_false + check_condition('1', '<>', '1').should be_false + check_condition('1', '<', '0').should be_false + check_condition('2', '>', '4').should be_false + check_condition('1', '>=', '3').should be_false + check_condition('2', '>=', '4').should be_false + check_condition('1', '<=', '0').should be_false + check_condition('1', '<=', '0').should be_false + end + end + + context %{"contains"} do + + context "when operating on strings" do + it "should evaluate to true when appropriate" do + check_condition("'bob'", 'contains', "'o'").should be_true + check_condition("'bob'", 'contains', "'b'").should be_true + check_condition("'bob'", 'contains', "'bo'").should be_true + check_condition("'bob'", 'contains', "'ob'").should be_true + check_condition("'bob'", 'contains', "'bob'").should be_true + end + + it "should evaluate to false when appropriate" do + check_condition("'bob'", 'contains', "'bob2'").should be_false + check_condition("'bob'", 'contains', "'a'").should be_false + check_condition("'bob'", 'contains', "'---'").should be_false + end + end + + context "when operating on arrays" do + before(:each) do + @context['array'] = [1,2,3,4,5] + end + + it "should evaluate to true when appropriate" do + check_condition("array", "contains", "1").should be_true + check_condition("array", "contains", "2").should be_true + check_condition("array", "contains", "3").should be_true + check_condition("array", "contains", "4").should be_true + check_condition("array", "contains", "5").should be_true + end + + it "should evaluate to false when appropriate" do + check_condition("array", "contains", "0").should be_false + check_condition("array", "contains", "6").should be_false + end + + it "should not equate strings to integers" do + check_condition("array", "contains", "5").should be_true + check_condition("array", "contains", "'5'").should be_false + end + end + + it "should return false for all nil operands" do + check_condition("not_assigned", "contains", "0").should be_false + check_condition("0", "contains", "not_assigned").should be_false + end + end + + describe %{Chaining with "or"} do + before(:each) do + @condition = Condition.new("1", "==", "2") + @condition.evaluate.should be_false + end + + it "should return true when it you add a single condition that evaluates to true" do + @condition.or Condition.new("2", "==", "1") + @condition.evaluate.should be_false + + @condition.or Condition.new("1", "==", "1") + @condition.evaluate.should be_true + end + end + + describe %{Chaining with "and"} do + before(:each) do + @condition = Condition.new("1", "==", "1") + @condition.evaluate.should be_true + end + + it "should return false when it you add a single condition that evaluates to false" do + @condition.and Condition.new("2", "==", "2") + @condition.evaluate.should be_true + + @condition.and Condition.new("2", "==", "1") + @condition.evaluate.should be_false + end + end + + describe "Custom proc operator" do + before(:each) do + Condition.operators["starts_with"] = Proc.new { |cond, left, right| left =~ %r{^#{right}}} + end + + it "should use the assigned proc to evalue the operator" do + check_condition("'bob'", "starts_with", "'b'").should be_true + check_condition("'bob'", "starts_with", "'o'").should be_false + end + + after(:each) do + Condition.operators.delete('starts_with') + end + end + end +end \ No newline at end of file diff --git a/spec/unit/context_spec.rb b/spec/unit/context_spec.rb new file mode 100644 index 000000000..08172613a --- /dev/null +++ b/spec/unit/context_spec.rb @@ -0,0 +1,479 @@ +require 'spec_helper' + +module Liquid + describe Context do + + before(:each) do + @context = Context.new + end + + it "should allow assigning variables" do + @context['string'] = 'string' + @context['string'].should == 'string' + + @context['num'] = 5 + @context['num'].should == 5 + + @context['time'] = Time.parse('2006-06-06 12:00:00') + @context['time'].should == Time.parse('2006-06-06 12:00:00') + + @context['date'] = Date.today + @context['date'].should == Date.today + + now = DateTime.now + @context['datetime'] = now + @context['datetime'].should == now + + @context['bool'] = true + @context['bool'].should == true + + @context['bool'] = false + @context['bool'].should == false + + @context['nil'] = nil + @context['nil'].should == nil + end + + it "should return nil for variables that don't exist" do + @context["does_not_exist"].should == nil + end + + it "should return the size of an array" do + @context['numbers'] = [1,2,3,4] + @context['numbers.size'].should == 4 + end + + it "should return the size of an hash" do + @context['numbers'] = {1 => 1,2 => 2,3 => 3,4 => 4} + @context['numbers.size'].should == 4 + end + + it "should allow acess on a hash value by key" do + @context['numbers'] = {1 => 1,2 => 2,3 => 3,4 => 4, 'size' => 1000} + @context['numbers.size'].should == 1000 + end + + it "should handle hyphenated variables" do + @context["oh-my"] = "godz" + @context["oh-my"].should == "godz" + end + + it "should merge data" do + @context.merge("test" => "test") + @context["test"].should == "test" + + @context.merge("test" => "newvalue", "foo" => "bar") + @context["test"].should == "newvalue" + @context["foo"].should == "bar" + end + + describe "filters" do + before(:each) do + filter = Module.new do + def exclaim(output) + output + "!!!" + end + end + @context.add_filters(filter) + end + + it "should invoke a filter if found" do + @context.invoke(:exclaim, "hi").should == "hi!!!" + end + + it "should ignore a filter thats not found" do + local = Context.new + local.invoke(:exclaim, "hi").should == "hi" + end + + it "should override a global filter" do + global = Module.new do + def notice(output) + "Global #{output}" + end + end + + local = Module.new do + def notice(output) + "Local #{output}" + end + end + + Template.register_filter(global) + Template.parse("{{'test' | notice }}").render.should == "Global test" + Template.parse("{{'test' | notice }}").render({}, :filters => [local]).should == "Local test" + end + + it "should only include intended filters methods" do + filter = Module.new do + def hi(output) + output + ' hi!' + end + end + + local = Context.new + methods_before = local.strainer.methods.map { |method| method.to_s } + local.add_filters(filter) + methods_after = local.strainer.methods.map { |method| method.to_s } + methods_after.sort.should == (methods_before+["hi"]).sort + end + end + + describe "scopes" do + it "should handle scoping properly" do + expect { + @context.push + @context.pop + }.to_not raise_exception + + expect { + @context.pop + }.to raise_exception(ContextError) + + expect { + @context.push + @context.pop + @context.pop + }.to raise_exception(ContextError) + end + + it "should allow access to items from outer scope within an inner scope" do + @context["test"] = "test" + @context.push + @context["test"].should == "test" + @context.pop + @context["test"].should == "test" + end + + it "should not allow access to items from inner scope with an outer scope" do + @context.push + @context["test"] = 'test' + @context["test"].should == "test" + @context.pop + @context["test"].should == nil + end + end + + describe "literals" do + it "should recognize boolean keywords" do + @context["true"].should == true + @context["false"].should == false + end + + it "should recognize integers and floats" do + @context["100"].should == 100 + @context[%Q{100.00}].should == 100.00 + end + + it "should recognize strings" do + @context[%{"hello!"}].should == "hello!" + @context[%{'hello!'}].should == "hello!" + end + + it "should recognize ranges" do + @context.merge( "test" => '5' ) + @context['(1..5)'].should == (1..5) + @context['(1..test)'].should == (1..5) + @context['(test..test)'].should == (5..5) + end + end + + context "hierarchical data" do + it "should allow access to hierarchical data" do + @context["hash"] = {"name" => "tobi"} + @context['hash.name'].should == "tobi" + @context["hash['name']"].should == "tobi" + @context['hash["name"]'].should == "tobi" + end + + it "should allow access to arrays" do + @context["test"] = [1,2,3,4,5] + + @context["test[0]"].should == 1 + @context["test[1]"].should == 2 + @context["test[2]"].should == 3 + @context["test[3]"].should == 4 + @context["test[4]"].should == 5 + end + + it "should allow access to an array within a hash" do + @context['test'] = {'test' => [1,2,3,4,5]} + @context['test.test[0]'].should == 1 + + # more complex + @context['colors'] = { + 'Blue' => ['003366','336699', '6699CC', '99CCFF'], + 'Green' => ['003300','336633', '669966', '99CC99'], + 'Yellow' => ['CC9900','FFCC00', 'FFFF99', 'FFFFCC'], + 'Red' => ['660000','993333', 'CC6666', 'FF9999'] + } + @context['colors.Blue[0]'].should == '003366' + @context['colors.Red[3]'].should == 'FF9999' + end + + it "should allow access to a hash within an array" do + @context['test'] = [{'test' => 'worked'}] + @context['test[0].test'].should == "worked" + end + + it "should provide first and last helpers for arrays" do + @context['test'] = [1,2,3,4,5] + + @context['test.first'].should == 1 + @context['test.last'].should == 5 + + @context['test'] = {'test' => [1,2,3,4,5]} + + @context['test.test.first'].should == 1 + @context['test.test.last'].should == 5 + + @context['test'] = [1] + @context['test.first'].should == 1 + @context['test.last'].should == 1 + end + + it "should allow arbitrary depth chaining of hash and array notation" do + @context['products'] = {'count' => 5, 'tags' => ['deepsnow', 'freestyle'] } + @context['products["count"]'].should == 5 + @context['products["tags"][0]'].should == "deepsnow" + @context['products["tags"].first'].should == "deepsnow" + + @context['product'] = {'variants' => [ {'title' => 'draft151cm'}, {'title' => 'element151cm'} ]} + @context['product["variants"][0]["title"]'].should == "draft151cm" + @context['product["variants"][1]["title"]'].should == "element151cm" + @context['product["variants"][0]["title"]'].should == "draft151cm" + @context['product["variants"].last["title"]'].should == "element151cm" + end + + it "should allow variable access with hash notation" do + @context.merge("foo" => "baz", "bar" => "foo") + @context['["foo"]'].should == "baz" + @context['[bar]'].should == "baz" + end + + it "should allow hash access with hash variables" do + @context['var'] = 'tags' + @context['nested'] = {'var' => 'tags'} + @context['products'] = {'count' => 5, 'tags' => ['deepsnow', 'freestyle'] } + + @context['products[var].first'].should == "deepsnow" + @context['products[nested.var].last'].should == 'freestyle' + end + + it "should use hash notification only for hash access" do + @context['array'] = [1,2,3,4,5] + @context['hash'] = {'first' => 'Hello'} + + @context['array.first'].should == 1 + @context['array["first"]'].should == nil + @context['hash["first"]'].should == "Hello" + end + + it "should allow helpers (such as first and last) in the middle of a callchain" do + @context['product'] = {'variants' => [ {'title' => 'draft151cm'}, {'title' => 'element151cm'} ]} + + @context['product.variants[0].title'].should == 'draft151cm' + @context['product.variants[1].title'].should == 'element151cm' + @context['product.variants.first.title'].should == 'draft151cm' + @context['product.variants.last.title'].should == 'element151cm' + end + end + + describe "Custom Object with a to_liquid method" do + class HundredCentes + def to_liquid + 100 + end + end + + it "should resolve to whatever to_liquid returns from the object" do + @context["cents"] = HundredCentes.new + @context["cents"].should == 100 + end + + it "should allow access to the custom object within a hash" do + @context.merge( "cents" => { 'amount' => HundredCentes.new} ) + @context['cents.amount'].should == 100 + + @context.merge( "cents" => { 'cents' => { 'amount' => HundredCentes.new} } ) + @context['cents.cents.amount'].should == 100 + end + end + + describe "Liquid Drops" do + class CentsDrop < Drop + def amount + HundredCentes.new + end + + def non_zero? + true + end + end + + it "should allow access to the drop's methods" do + @context.merge( "cents" => CentsDrop.new ) + @context['cents.amount'].should == 100 + end + + it "should allow access to the drop's methods when nested in a hash" do + @context.merge( "vars" => {"cents" => CentsDrop.new} ) + @context['vars.cents.amount'].should == 100 + end + + it "should allow access to the a drop's methods that ends in a question mark" do + @context.merge( "cents" => CentsDrop.new ) + @context['cents.non_zero?'].should be_true + end + + it "should allow access to drop methods even when deeply nested" do + @context.merge( "cents" => {"cents" => CentsDrop.new} ) + @context['cents.cents.amount'].should == 100 + + @context.merge( "cents" => { "cents" => {"cents" => CentsDrop.new}} ) + @context['cents.cents.cents.amount'].should == 100 + end + + class ContextSensitiveDrop < Drop + def test + @context['test'] + end + + def read_test + @context["test"] + end + end + + it "should allow access to the current context from within a drop" do + @context.merge( "test" => '123', "vars" => ContextSensitiveDrop.new ) + @context["vars.test"].should == "123" + @context["vars.read_test"].should == "123" + end + + it "should allow access to the current context even when nested in a hash" do + @context.merge( "test" => '123', "vars" => {"local" => ContextSensitiveDrop.new } ) + @context['vars.local.test'].should == "123" + @context['vars.local.read_test'].should == "123" + end + + + class CounterDrop < Drop + def count + @count ||= 0 + @count += 1 + end + end + + it "should trigger a drop's autoincrementing variable" do + @context['counter'] = CounterDrop.new + + @context['counter.count'].should == 1 + @context['counter.count'].should == 2 + @context['counter.count'].should == 3 + end + + it "should trigger a drop's autoincrementing variable using hash syntax " do + @context['counter'] = CounterDrop.new + + @context['counter["count"]'].should == 1 + @context['counter["count"]'].should == 2 + @context['counter["count"]'].should == 3 + end + end + + context "lambas and procs" do + it "should trigger a proc if accessed as a variable" do + @context["dynamic1"] = Proc.new{ "Hello" } + @context['dynamic1'].should == "Hello" + + @context["dynamic2"] = proc{ "Hello" } + @context['dynamic2'].should == "Hello" + + end + + it "should trigger a proc within a hash" do + @context["dynamic"] = {"lambda" => proc{ "Hello" }} + @context["dynamic.lambda"].should == "Hello" + end + + it "should trigger a proc within an array" do + @context['dynamic'] = [1,2, proc { 'Hello' } ,4,5] + @context['dynamic[2]'].should == "Hello" + end + + it "should trigger the proc only the first time it's accessed" do + counter = 0 + @context["dynamic"] = proc{ "Hello #{counter += 1}" } + @context['dynamic'].should == "Hello 1" + @context['dynamic'].should == "Hello 1" + @context['dynamic'].should == "Hello 1" + end + + it "should trigger the proc within a hash only the first time it's accessed" do + counter = 0 + @context["dynamic"] = {"lambda" => proc{ "Hello #{counter += 1}" } } + @context['dynamic.lambda'].should == "Hello 1" + @context['dynamic.lambda'].should == "Hello 1" + @context['dynamic.lambda'].should == "Hello 1" + end + + it "should trigger the proc within an array only the first time it's accessed" do + counter = 0 + @context["dynamic"] = [1, 2, proc{ "Hello #{counter += 1}" }, 4] + @context['dynamic[2]'].should == "Hello 1" + @context['dynamic[2]'].should == "Hello 1" + @context['dynamic[2]'].should == "Hello 1" + end + + it "should allow access to context from within proc" do + @context.registers[:magic] = 345392 + @context['magic'] = proc { @context.registers[:magic] } + @context['magic'].should == 345392 + end + end + + + context "to_liquid returning a drop" do + class Category < Drop + attr_accessor :name + + def initialize(name) + @name = name + end + + def to_liquid + CategoryDrop.new(self) + end + end + + class CategoryDrop + attr_accessor :category, :context + def initialize(category) + @category = category + end + end + + it "should return a drop" do + @context['category'] = Category.new("foobar") + @context['category'].should be_an_instance_of(CategoryDrop) + @context['category'].context.should == @context + end + + class ArrayLike + def fetch(index) + end + + def [](index) + @counts ||= [] + @counts[index] ||= 0 + @counts[index] += 1 + end + + def to_liquid + self + end + end + + end + end +end \ No newline at end of file diff --git a/spec/unit/file_system_spec.rb b/spec/unit/file_system_spec.rb new file mode 100644 index 000000000..3c29c4e4b --- /dev/null +++ b/spec/unit/file_system_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +module Liquid + describe BlankFileSystem do + it "should error out when trying to ready any file" do + expect { + BlankFileSystem.new.read_template_file("dummy", nil) + }.to raise_error(Liquid::FileSystemError) + end + end + + describe LocalFileSystem do + describe "#full_path" do + before(:each) do + @file_system = LocalFileSystem.new("/some/path") + end + + it "should translate partial paths to the full filesystem path" do + @file_system.full_path('mypartial').should == "/some/path/_mypartial.liquid" + @file_system.full_path('dir/mypartial').should == "/some/path/dir/_mypartial.liquid" + end + + it "should raise errors if we try to go outside of the root" do + expect { + @file_system.full_path("../dir/mypartial") + }.to raise_error(Liquid::FileSystemError) + + expect { + @file_system.full_path("/dir/../../dir/mypartial") + }.to raise_error(Liquid::FileSystemError) + end + + it "should not allow absolute paths" do + expect { + @file_system.full_path("/etc/passwd") + }.to raise_error(Liquid::FileSystemError) + end + + end + end +end \ No newline at end of file diff --git a/spec/unit/filter_spec.rb b/spec/unit/filter_spec.rb new file mode 100644 index 000000000..d2fe643f6 --- /dev/null +++ b/spec/unit/filter_spec.rb @@ -0,0 +1,248 @@ +require 'spec_helper' + +module Liquid + describe StandardFilters do + + class TestFilters + include StandardFilters + end + + let(:filters) do + TestFilters.new + end + + context "#size" do + it "should return the size of the collection" do + filters.size([1,2,3]).should == 3 + filters.size([]).should == 0 + end + + it "should return 0 for nil" do + filters.size(nil).should == 0 + end + end + + context "#downcase" do + it "should make the string lower case" do + filters.downcase("Testing").should == "testing" + end + + it "should properly handle non ascii strings" do + filters.downcase("Проверка").should == "проверка" + end + + it "should return empty string for nil" do + filters.downcase(nil).should == "" + end + end + + context "#upcase" do + it "should make the string upper case" do + filters.upcase("Testing").should == "TESTING" + end + + it "should properly handle non ascii strings" do + filters.upcase("Проверка").should == "ПРОВЕРКА" + end + + it "should return empty string for nil" do + filters.upcase(nil).should == "" + end + end + + context "#capitalize" do + it "should make the first letter of string upper case" do + filters.capitalize("testing").should == "Testing" + end + + it "should properly handle non ascii strings" do + filters.capitalize("проверка").should == "Проверка" + end + + it "should return empty string for nil" do + filters.capitalize(nil).should == "" + end + end + + context "#truncate" do + it "should truncate string to the specified length, replacing with ellipsis" do + filters.truncate('1234567890', 7).should == '1234...' + filters.truncate('1234567890', 20).should == '1234567890' + filters.truncate('1234567890', 0).should == '...' + end + + it "should not truncate if no length is passed in" do + filters.truncate('1234567890').should == '1234567890' + end + + it "should allow overriding of the truncate character" do + filters.truncate('1234567890', 7, '---').should == '1234---' + filters.truncate('1234567890', 7, '--').should == '12345--' + filters.truncate('1234567890', 7, '-').should == '123456-' + end + end + + context "#escape" do + it "should escape html characters" do + filters.escape('').should == '<strong>' + end + + it "should be aliased with 'h'" do + filters.h('').should == '<strong>' + end + end + + context "#truncateword" do + it "should truncate the string to the amount of words specified" do + filters.truncatewords('one two three', 4).should == 'one two three' + + filters.truncatewords('one two three', 2).should == 'one two...' + end + + it "should be ignored if no length is specified" do + filters.truncatewords('one two three').should == 'one two three' + end + + it "should work with crazy special characters" do + filters.truncatewords('Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.', 15).should == + 'Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”...' + + end + end + + context "#split" do + + it "should split the given string into an array based on the given delimeter" do + filters.split("red|green|blue", "|").should == ['red', 'green', 'blue'] + end + + end + + context "#strip_html" do + it "should strip out the html tags but leave the content" do + filters.strip_html("
test
").should == "test" + filters.strip_html("
test
").should == "test" + end + + it "should completely remove the content of script tags" do + filters.strip_html("").should == '' + end + + it "should return empty string for nil" do + filters.strip_html(nil).should == '' + end + end + + context "#join" do + it "should default to joining an array by a space" do + filters.join([1,2,3,4]).should == "1 2 3 4" + end + + it "should allow you to specify the join character" do + filters.join([1,2,3,4], ' - ').should == "1 - 2 - 3 - 4" + end + + it "should join a hash" do + output = filters.join({"one" => 1, "two" => 2}) + output.should include("one 1") + output.should include("two 2") + end + + it "should join a hash with a character" do + output = filters.join({"one" => 1, "two" => 2}, ' - ') + output.should include("one - 1") + output.should include("two - 2") + end + + it "should join a hash with separate characters for fields, and keys" do + output = filters.join({"one" => 1, "two" => 2}, '|', '-') + output.should include("one-1") + output.should include("two-2") + end + end + + context "#sort" do + it "should sort an array" do + filters.sort([4,3,2,1]).should == [1,2,3,4] + end + end + + context "#map" do + it "should return a list of values that have a key matching the argument" do + filters.map([{"a" => 1}, {"a" => 2}, {"a" => 3}, {"a" => 4}], 'a').should == [1,2,3,4] + + data = {'ary' => [{'foo' => {'bar' => 'a'}}, {'foo' => {'bar' => 'b'}}, {'foo' => {'bar' => 'c'}}]} + render("{{ ary | map:'foo' | map:'bar' }}", data).should == "abc" + end + end + + context "#date" do + it "should format a date using a specified format string" do + filters.date(Time.parse("2006-05-05 10:00:00"), "%B").should == 'May' + filters.date(Time.parse("2006-06-05 10:00:00"), "%B").should == 'June' + filters.date(Time.parse("2006-07-05 10:00:00"), "%B").should == 'July' + + filters.date("2006-05-05 10:00:00", "%B").should == 'May' + filters.date("2006-06-05 10:00:00", "%B").should == 'June' + filters.date("2006-07-05 10:00:00", "%B").should == 'July' + + filters.date("2006-07-05 10:00:00", "").should == '2006-07-05 10:00:00' + filters.date("2006-07-05 10:00:00", nil).should == '2006-07-05 10:00:00' + + filters.date("2006-07-05 10:00:00", "%m/%d/%Y").should == '07/05/2006' + + filters.date("Fri Jul 16 01:00:00 2004", "%m/%d/%Y").should == "07/16/2004" + end + end + + context "#first" do + it "should return the first item in an array" do + filters.first([1,2,3]).should == 1 + end + + it "should return nil for an empty array" do + filters.first([]).should == nil + end + end + + context "#last" do + it "should return the last item in an array" do + filters.last([1,2,3]).should == 3 + end + + it "should return nil for an empty array" do + filters.last([]).should == nil + end + end + + context "#replace" do + it "should replace all matches in a string with the new string" do + filters.replace("a a a a", 'a', 'b').should == 'b b b b' + render("{{ 'a a a a' | replace: 'a', 'b' }}").should == "b b b b" + end + end + + context "#replace_first" do + it "should replace the first match in a string with the new string" do + filters.replace_first("a a a a", 'a', 'b').should == 'b a a a' + render("{{ 'a a a a' | replace_first: 'a', 'b' }}").should == "b a a a" + end + end + + context "#remove" do + it "should remove all matching strings" do + filters.remove("a a a a", 'a').should == ' ' + render("{{ 'a a a a' | remove: 'a' }}").should == " " + end + end + + context "#remove_first" do + it "should remove the first matching string" do + filters.remove_first("a a a a", 'a').should == ' a a a' + filters.remove_first("a a a a", 'a ').should == 'a a a' + render("{{ 'a a a a' | remove_first: 'a' }}").should == ' a a a' + end + end + + end +end diff --git a/spec/unit/liquid_methods_spec.rb b/spec/unit/liquid_methods_spec.rb new file mode 100644 index 000000000..994de2dc2 --- /dev/null +++ b/spec/unit/liquid_methods_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe "Liquid Methods" do + + class TestClassA + liquid_methods :allowedA, :chainedB + def allowedA + 'allowedA' + end + def restrictedA + 'restrictedA' + end + def chainedB + TestClassB.new + end + end + + class TestClassB + liquid_methods :allowedB, :chainedC + def allowedB + 'allowedB' + end + def chainedC + TestClassC.new + end + end + + class TestClassC + liquid_methods :allowedC + def allowedC + 'allowedC' + end + end + + class TestClassC::LiquidDropClass + def another_allowedC + 'another_allowedC' + end + end + + + before(:each) do + @a = TestClassA.new + @b = TestClassB.new + @c = TestClassC.new + end + + it "should create liquid drop classes" do + TestClassA::LiquidDropClass.should_not be_nil + TestClassB::LiquidDropClass.should_not be_nil + TestClassC::LiquidDropClass.should_not be_nil + end + + it "should respond to to_liquid" do + @a.should respond_to(:to_liquid) + @b.should respond_to(:to_liquid) + @c.should respond_to(:to_liquid) + end + + it "should return the liquid drop class" do + @a.to_liquid.should be_an_instance_of(TestClassA::LiquidDropClass) + @b.to_liquid.should be_an_instance_of(TestClassB::LiquidDropClass) + @c.to_liquid.should be_an_instance_of(TestClassC::LiquidDropClass) + end + + it "should respond to liquid methods" do + @a.to_liquid.should respond_to(:allowedA) + @a.to_liquid.should respond_to(:chainedB) + + @b.to_liquid.should respond_to(:allowedB) + @b.to_liquid.should respond_to(:chainedC) + + @c.to_liquid.should respond_to(:allowedC) + @c.to_liquid.should respond_to(:another_allowedC) + end + + it "should not respond to restricted methods" do + @a.to_liquid.should_not respond_to(:restricted) + end + + it "should use regular objects as drops" do + render('{{ a.allowedA }}', 'a' => @a).should == "allowedA" + render("{{ a.chainedB.allowedB }}", 'a'=>@a).should == 'allowedB' + render("{{ a.chainedB.chainedC.allowedC }}", 'a'=>@a).should == 'allowedC' + render("{{ a.chainedB.chainedC.another_allowedC }}", 'a'=>@a).should == 'another_allowedC' + render("{{ a.restricted }}", 'a'=>@a).should == '' + render("{{ a.unknown }}", 'a'=>@a).should == '' + end +end \ No newline at end of file diff --git a/spec/unit/parsing_spec.rb b/spec/unit/parsing_spec.rb new file mode 100644 index 000000000..e1eb79ae5 --- /dev/null +++ b/spec/unit/parsing_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +module Liquid + describe "Liquid Parsing" do + + it "should render whitespace properly" do + parse(" ").root.nodelist.should == [" "] + end + + describe %|"{{funk}} "| do + it{ parse(:subject).root.nodelist.should have(2).nodes } + + it "should parse to: Variable,String" do + parse(:subject).root.nodelist[0].should be_an_instance_of(Liquid::Variable) + parse(:subject).root.nodelist[1].should be_an_instance_of(String) + end + end + + describe %|" {{funk}}"| do + it{ parse(:subject).root.nodelist.should have(2).nodes } + + it "should parse to: String,Variable" do + parse(:subject).root.nodelist[0].should be_an_instance_of(String) + parse(:subject).root.nodelist[1].should be_an_instance_of(Liquid::Variable) + end + end + + describe %|" {{funk}} "| do + it{ parse(:subject).root.nodelist.should have(3).nodes } + + it "should parse to: String,Variable,String" do + parse(:subject).root.nodelist[0].should be_an_instance_of(String) + parse(:subject).root.nodelist[1].should be_an_instance_of(Liquid::Variable) + parse(:subject).root.nodelist[2].should be_an_instance_of(String) + end + end + + describe %|" {{funk}} {{so}} {{brother}} "| do + it{ parse(:subject).root.nodelist.should have(7).nodes } + + it "should parse to: String,Variable,String,Variable,String,Variable,String" do + parse(:subject).root.nodelist[0].should be_an_instance_of(String) + parse(:subject).root.nodelist[1].should be_an_instance_of(Liquid::Variable) + parse(:subject).root.nodelist[2].should be_an_instance_of(String) + parse(:subject).root.nodelist[3].should be_an_instance_of(Liquid::Variable) + parse(:subject).root.nodelist[4].should be_an_instance_of(String) + parse(:subject).root.nodelist[5].should be_an_instance_of(Liquid::Variable) + parse(:subject).root.nodelist[6].should be_an_instance_of(String) + end + end + + describe %|" {% comment %} {% endcomment %} "| do + it{ parse(:subject).root.nodelist.should have(3).nodes } + it "should parse to: String,Comment,String" do + parse(:subject).root.nodelist[0].should be_an_instance_of(String) + parse(:subject).root.nodelist[1].should be_an_instance_of(Liquid::Comment) + parse(:subject).root.nodelist[2].should be_an_instance_of(String) + end + end + + context "when the custom tag 'somethingaweful' is defined" do + before(:each) do + Liquid::Template.register_tag('somethingaweful', Liquid::Block) + end + + describe %|"{% somethingaweful %} {% endsomethingaweful %}"| do + it "should parse successfully" do + parse(:subject).root.nodelist.should have(1).nodes + end + end + end + + end +end \ No newline at end of file diff --git a/spec/unit/quirks_spec.rb b/spec/unit/quirks_spec.rb new file mode 100644 index 000000000..a0953dc5c --- /dev/null +++ b/spec/unit/quirks_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +module Liquid + describe "Liquid Parsing Quirks" do + it "should work with css syntax" do + template = parse(" div { font-weight: bold; } ") + template.render.should == " div { font-weight: bold; } " + template.root.nodelist[0].should be_an_instance_of(String) + end + + it "should raise an error on a single close brace" do + expect { + parse("text {{method} oh nos!") + }.to raise_error(SyntaxError) + end + + it "should raise an error with double braces and no matcing closing double braces" do + expect { + parse("TEST {{") + }.to raise_error(SyntaxError) + end + + it "should raise an error with open tag and no matching close tag" do + expect { + parse("TEST {%") + }.to raise_error(SyntaxError) + end + + it "should allow empty filters" do + parse("{{test |a|b|}}") + parse("{{test}}") + parse("{{|test|}}") + end + + it "should allow meaningless parens" do + data = {'b' => 'bar', 'c' => 'baz'} + markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" + + render("{% if #{markup} %} YES {% endif %}", data).should == " YES " + end + + it "should allow unexpected characters to silently eat logic" do + markup = "true && false" + render("{% if #{markup} %} YES {% endif %}").should == ' YES ' + + markup = "false || true" + render("{% if #{markup} %} YES {% endif %}").should == '' + end + end +end \ No newline at end of file diff --git a/spec/unit/regexp_spec.rb b/spec/unit/regexp_spec.rb new file mode 100644 index 000000000..b99f27364 --- /dev/null +++ b/spec/unit/regexp_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +module Liquid + describe "Liquid Regular Expressions" do + + describe "QuotedFragment" do + context "empty string" do + it{ ''.scan(QuotedFragment).should == [] } + end + + context %{quoted string: "arg 1"} do + it{ %{"arg 1"}.scan(QuotedFragment).should == [%{"arg 1"}] } + end + + context "arg1 arg2" do + it{ subject.scan(QuotedFragment).should == ["arg1", "arg2"] } + end + + context " " do + it{ subject.scan(QuotedFragment).should == ['', ''] } + end + + context "" do + it{ subject.scan(QuotedFragment).should == [''] } + end + + context %{} do + it{ subject.scan(QuotedFragment).should == ['', ''] } + end + + context %{arg1 arg2 "arg 3"} do + it{ subject.scan(QuotedFragment).should == ['arg1', 'arg2', '"arg 3"'] } + end + + context "arg1 arg2 'arg 3'" do + it{ subject.scan(QuotedFragment).should == ['arg1', 'arg2', "'arg 3'"] } + end + + context %{arg1 arg2 "arg 3" arg4 } do + it{ subject.scan(QuotedFragment).should == ['arg1', 'arg2', '"arg 3"', 'arg4'] } + end + end + + describe "VariableParser" do + context "var" do + it{ subject.scan(VariableParser).should == ['var'] } + end + + context "var.method" do + it{ subject.scan(VariableParser).should == ['var', 'method']} + end + + context "var[method]" do + it{ subject.scan(VariableParser).should == ['var', '[method]']} + end + + context "var[method][0]" do + it{ subject.scan(VariableParser).should == ['var', '[method]', '[0]'] } + end + + context %{var["method"][0]} do + it{ subject.scan(VariableParser).should == ['var', '["method"]', '[0]'] } + end + + context "var['method'][0]" do + it{ subject.scan(VariableParser).should == ['var', "['method']", '[0]'] } + end + + context "var[method][0].method" do + it{ subject.scan(VariableParser).should == ['var', '[method]', '[0]', 'method'] } + end + end + + end +end \ No newline at end of file diff --git a/spec/unit/strainer_spec.rb b/spec/unit/strainer_spec.rb new file mode 100644 index 000000000..8c3b90eb7 --- /dev/null +++ b/spec/unit/strainer_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +module Liquid + describe Strainer do + + let(:strainer) do + Strainer.create(nil) + end + + it "should remove standard Object methods" do + strainer.respond_to?('__test__').should be_false + strainer.respond_to?('test').should be_false + strainer.respond_to?('instance_eval').should be_false + strainer.respond_to?('__send__').should be_false + + # from the standard lib + strainer.respond_to?('size').should be_true + end + + it "should respond_to with 2 params" do + strainer.respond_to?('size', false).should be_true + end + + it "should repond_to_missing properly" do + strainer.respond_to?(:respond_to_missing?).should == Object.respond_to?(:respond_to_missing?) + end + + end +end \ No newline at end of file diff --git a/spec/unit/tag_spec.rb b/spec/unit/tag_spec.rb new file mode 100644 index 000000000..6c8beb61e --- /dev/null +++ b/spec/unit/tag_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +module Liquid + describe Tag do + + context "empty tag" do + before(:each) do + @tag = Tag.new('tag', [], [], {}) + end + + context "#name" do + it "should return the name of the tag" do + @tag.name.should == "liquid::tag" + end + end + + context "#render" do + it "should render an empty string" do + @tag.render(Context.new).should == '' + end + end + end + + context "tag with context" do + before(:each) do + @tag = Tag.new('tag', [], [], { :foo => 'bar' }) + end + + it "should store context at parse time" do + @tag.context[:foo].should == "bar" + end + end + + end +end \ No newline at end of file diff --git a/spec/unit/template_spec.rb b/spec/unit/template_spec.rb new file mode 100644 index 000000000..471b9e782 --- /dev/null +++ b/spec/unit/template_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +module Liquid + describe Template do + + def tokenize(text) + Template.new.send(:tokenize, text) + end + + it "should tokenize strings" do + tokenize(' ').should == [' '] + tokenize('hello world').should == ['hello world'] + end + + it "should tokenize variables" do + tokenize('{{funk}}').should == ['{{funk}}'] + tokenize(' {{funk}} ').should == [' ', '{{funk}}', ' '] + tokenize(' {{funk}} {{so}} {{brother}} ').should == [' ', '{{funk}}', ' ', '{{so}}', ' ', '{{brother}}', ' '] + tokenize(' {{ funk }} ').should == [' ', '{{ funk }}', ' '] + end + + it "should tokenize blocks" do + tokenize('{%comment%}').should == ['{%comment%}'] + tokenize(' {%comment%} ').should == [' ', '{%comment%}', ' '] + tokenize(' {%comment%} {%endcomment%} ').should == [' ', '{%comment%}', ' ', '{%endcomment%}', ' '] + tokenize(" {% comment %} {% endcomment %} ").should == [' ', '{% comment %}', ' ', '{% endcomment %}', ' '] + end + + it "should persist instance assignment on the same template object between parses " do + t = Template.new + t.parse("{% assign foo = 'from instance assigns' %}{{ foo }}").render.should == 'from instance assigns' + t.parse("{{ foo }}").render.should == 'from instance assigns' + end + + it "should persist instance assingment on the same template object between renders" do + t = Template.new.parse("{{ foo }}{% assign foo = 'foo' %}{{ foo }}") + t.render.should == "foo" + t.render.should == "foofoo" + end + + it "should not persist custom assignments on the same template" do + t = Template.new + t.parse("{{ foo }}").render('foo' => 'from custom assigns').should == 'from custom assigns' + t.parse("{{ foo }}").render.should == '' + end + + it "should squash instance assignments with custom assignments when specified" do + t = Template.new + t.parse("{% assign foo = 'from instance assigns' %}{{ foo }}").render.should == 'from instance assigns' + t.parse("{{ foo }}").render('foo' => 'from custom assigns').should == 'from custom assigns' + end + + it "should squash instance assignments with persistent assignments" do + t = Template.new + t.parse("{% assign foo = 'from instance assigns' %}{{ foo }}").render.should == 'from instance assigns' + t.assigns['foo'] = 'from persistent assigns' + t.parse("{{ foo }}").render.should == 'from persistent assigns' + end + + it "should call lambda only once from persistent assigns over multiple parses and renders" do + t = Template.new + t.assigns['number'] = lambda { @global ||= 0; @global += 1 } + t.parse("{{number}}").render.should == '1' + t.parse("{{number}}").render.should == '1' + t.render.should == '1' + @global = nil + end + + it "should call lambda only once from custom assigns over multiple parses and renders" do + t = Template.new + assigns = {'number' => lambda { @global ||= 0; @global += 1 }} + t.parse("{{number}}").render(assigns).should == '1' + t.parse("{{number}}").render(assigns).should == '1' + t.render(assigns).should == '1' + @global = nil + end + end +end \ No newline at end of file diff --git a/spec/unit/variable_spec.rb b/spec/unit/variable_spec.rb new file mode 100644 index 000000000..bc97658c7 --- /dev/null +++ b/spec/unit/variable_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +module Liquid + describe Variable do + it "#name" do + var = Variable.new('hello') + var.name.should == 'hello' + end + + it "should parse and store filters" do + var = Variable.new('hello | textileze') + var.name.should == 'hello' + var.filters.should == [[:textileze,[]]] + + var = Variable.new('hello | textileze | paragraph') + var.name.should == 'hello' + var.filters.should == [[:textileze,[]], [:paragraph,[]]] + + var = Variable.new(%! hello | strftime: '%Y'!) + var.name.should == 'hello' + var.filters.should == [[:strftime,["'%Y'"]]] + + var = Variable.new(%! 'typo' | link_to: 'Typo', true !) + var.name.should == %!'typo'! + var.filters.should == [[:link_to,["'Typo'", "true"]]] + + var = Variable.new(%! 'typo' | link_to: 'Typo', false !) + var.name.should == %!'typo'! + var.filters.should == [[:link_to,["'Typo'", "false"]]] + + var = Variable.new(%! 'foo' | repeat: 3 !) + var.name.should == %!'foo'! + var.filters.should == [[:repeat,["3"]]] + + var = Variable.new(%! 'foo' | repeat: 3, 3 !) + var.name.should == %!'foo'! + var.filters.should == [[:repeat,["3","3"]]] + + var = Variable.new(%! 'foo' | repeat: 3, 3, 3 !) + var.name.should == %!'foo'! + var.filters.should == [[:repeat,["3","3","3"]]] + + var = Variable.new(%! hello | strftime: '%Y, okay?'!) + var.name.should == 'hello' + var.filters.should == [[:strftime,["'%Y, okay?'"]]] + + var = Variable.new(%! hello | things: "%Y, okay?", 'the other one'!) + var.name.should == 'hello' + var.filters.should == [[:things,["\"%Y, okay?\"","'the other one'"]]] + end + + it "should store filters with parameters" do + var = Variable.new(%! '2006-06-06' | date: "%m/%d/%Y"!) + var.name.should == "'2006-06-06'" + var.filters.should == [[:date,["\"%m/%d/%Y\""]]] + end + + it "should allow filters without whitespace" do + var = Variable.new('hello | textileze | paragraph') + var.name.should == 'hello' + var.filters.should == [[:textileze,[]], [:paragraph,[]]] + + var = Variable.new('hello|textileze|paragraph') + var.name.should == 'hello' + var.filters.should == [[:textileze,[]], [:paragraph,[]]] + end + + it "should allow special characters" do + var = Variable.new("http://disney.com/logo.gif | image: 'med' ") + var.name.should == 'http://disney.com/logo.gif' + var.filters.should == [[:image,["'med'"]]] + end + + it "should allow double quoted strings" do + var = Variable.new(%| "hello" |) + var.name.should == '"hello"' + end + + it "should allow single quoted strings" do + var = Variable.new(%| 'hello' |) + var.name.should == "'hello'" + end + + it "should allow integers" do + var = Variable.new(%| 1000 |) + var.name.should == "1000" + end + + it "should allow floats" do + var = Variable.new(%| 1000.01 |) + var.name.should == "1000.01" + end + + it "should allow strings with special characters" do + var = Variable.new(%| 'hello! $!@.;"ddasd" ' |) + var.name.should == %|'hello! $!@.;"ddasd" '| + end + + it "should allow strings with dots" do + var = Variable.new(%| test.test |) + var.name.should == 'test.test' + end + end +end \ No newline at end of file diff --git a/test/liquid/tags/standard_tag_test.rb b/test/liquid/tags/standard_tag_test.rb index 4d8bdd339..ca6c4dc20 100644 --- a/test/liquid/tags/standard_tag_test.rb +++ b/test/liquid/tags/standard_tag_test.rb @@ -4,7 +4,7 @@ class StandardTagTest < Test::Unit::TestCase include Liquid def test_tag - tag = Tag.new('tag', [], []) + tag = Tag.new('tag', [], [], {}) assert_equal 'liquid::tag', tag.name assert_equal '', tag.render(Context.new) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 30dd9a163..f66c39280 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,7 @@ require 'test/unit' require 'test/unit/assertions' +require 'active_support/core_ext' begin require 'ruby-debug' rescue LoadError