diff --git a/.formatter.exs b/.formatter.exs
index 2bed17cc..1c8126ca 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,3 +1,4 @@
[
- inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
+ inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"],
+ line_length: 98
]
diff --git a/.gitignore b/.gitignore
index 37858604..a4f38414 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@
/bench/snapshots
erl_crash.dump
/cover
+/bench/results/
+/.elixir_ls
diff --git a/.travis.yml b/.travis.yml
index 5842a9dc..2a679d69 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,7 @@
language: elixir
sudo: false
elixir:
- - 1.5.1
+ - 1.6.6
- 1.7.4
- 1.8.0
otp_release:
@@ -10,8 +10,6 @@ otp_release:
- 21.2
matrix:
exclude:
- - elixir: 1.5.1
- otp_release: 21.2
- elixir: 1.8.0
otp_release: 18.3
- elixir: 1.8.0
diff --git a/bench/full_benchmark.exs b/bench/full_benchmark.exs
new file mode 100644
index 00000000..6e1b6698
--- /dev/null
+++ b/bench/full_benchmark.exs
@@ -0,0 +1,36 @@
+Liquid.start()
+
+path = "test/templates"
+levels = ["simple", "medium", "complex"]
+
+data =
+ "#{path}/db.json"
+ |> File.read!()
+ |> Poison.decode!()
+
+levels_map =
+ for level <- levels,
+ test_case <- File.ls!("#{path}/#{level}"),
+ into: %{} do
+ markup = File.read!("#{path}/#{level}/#{test_case}/input.liquid")
+ parsed = Liquid.Template.parse(markup)
+ {level, %{test_case => %{parse: markup, render: parsed}}}
+ end
+
+create_phase = fn cases, phase ->
+ fn ->
+ for {_, %{^phase => param}} <- cases do
+ args = if phase == :render, do: [param, data], else: [param]
+ apply(Liquid.Template, phase, args)
+ end
+ end
+end
+
+for phase <- [:parse] do
+ time = DateTime.to_string(DateTime.utc_now())
+ benchmark = for {level, cases} <- levels_map, into: %{} do
+ {"#{level} #{phase}", create_phase.(cases, phase)}
+ end
+
+ Benchee.run(benchmark, warmup: 5, time: 60)
+end
diff --git a/bench/parser_benchmark.exs b/bench/parser_benchmark.exs
new file mode 100644
index 00000000..57fb4868
--- /dev/null
+++ b/bench/parser_benchmark.exs
@@ -0,0 +1,43 @@
+Liquid.start()
+
+complex = File.read!("test/templates/complex/01/input.liquid")
+
+middle = """
+
{{ product.name }}
+ {{ product.price }}
+ {{ product.price }}
+ {% comment %}This is a commentary{% endcomment %}
+ {% raw %}This is a raw tag{% endraw %}
+ {% for item in array %} Repeat this {% else %} Array Empty {% endfor %}
+"""
+
+simple = """
+ {% for item in array %} Repeat this {% else %} Array Empty {% endfor %}
+"""
+
+empty = ""
+
+templates = [complex: complex, middle: middle, simple: simple, empty: empty]
+
+time = DateTime.to_string(DateTime.utc_now())
+
+Enum.each(templates,
+ fn {name, template} ->
+ IO.puts "running: #{name}"
+ Benchee.run(
+ %{
+ "#{name}-regex" => fn -> Liquid.Template.old_parse(template) end,
+ "#{name}-nimble-with-translate" => fn -> Liquid.Template.parse(template) end,
+ "#{name}-nimble" => fn -> Liquid.NimbleParser.parse(template) end
+ },
+ warmup: 5,
+ time: 60,
+ formatters: [
+ Benchee.Formatters.Console,
+ Benchee.Formatters.CSV
+ ],
+ formatter_options: [csv: [file: "bench/results/parser-benchmarks-#{time}.csv"]]
+ )
+ end
+)
+
diff --git a/bench/parser_custom_benchmark.exs b/bench/parser_custom_benchmark.exs
new file mode 100644
index 00000000..d59bfb8a
--- /dev/null
+++ b/bench/parser_custom_benchmark.exs
@@ -0,0 +1,44 @@
+Liquid.start()
+
+defmodule Random do
+ def render(output, tag, context) do
+ {"MyCustomTag Results...output:#{output} tag:#{tag}", context}
+ end
+end
+
+defmodule RandomBlock do
+ def render(output, tag, context) do
+ {"MyCustomBlock Results...output:#{output} tag:#{tag}", context}
+ end
+end
+
+
+Liquid.Registers.register("random", Random, Liquid.Tag)
+Liquid.Registers.register("randomblock", RandomBlock, Liquid.Block)
+
+custom_tag = "{% random 5 %}"
+custom_block = "{% randomblock 5 %} This is a Random Number: ^^^ {% endrandomblock %}"
+
+templates = [
+ custom_tag: custom_tag,
+ custom_block: custom_block
+]
+
+benchmarks =
+ for {name, template} <- templates, into: %{} do
+ {name, fn -> Liquid.Parser.parse(template) end}
+ end
+
+Benchee.run(
+ benchmarks,
+ warmup: 5,
+ time: 20,
+ print: [
+ benchmarking: true,
+ configuration: false,
+ fast_warning: false
+ ],
+ console: [
+ comparison: false
+ ]
+)
diff --git a/bench/parser_tags_benchmark.exs b/bench/parser_tags_benchmark.exs
new file mode 100644
index 00000000..fc4521fc
--- /dev/null
+++ b/bench/parser_tags_benchmark.exs
@@ -0,0 +1,69 @@
+Liquid.start()
+
+complex =
+ "{% increment a %}{% if true %}{% decrement b %}{% if false %}{% increment c %}One{% decrement d %}{% elsif true %}Two{% else %}Three{% endif %}{% endif %}{% decrement d %}{% if false %}Four{% endif %}Last"
+
+big_literal = File.read!("bench/templates/big_literal.liquid")
+big_literal_with_tags = File.read!("bench/templates/big_literal_with_tags.liquid")
+small_literal = "X"
+assign = "Price in stock {% assign a = 5 %} Final Price"
+
+capture = """
+Lorem Ipsum is simply dummy text {% capture first_variable %}Hey{% endcapture %}of the printing and typesetting industry. Lorem Ipsum has {% capture first_variable %}Hey{% endcapture %}been the industry's standard dummy text ever since the {% capture first_variable %}Hey{% endcapture %}1500s, when an unknown printer {% capture first_variable %}Hey{% endcapture %}took a galley of type and scrambled it {% capture first_variable %}Hey{% endcapture %}to make a type specimen book. It has survived {% capture first_variable %}Hey{% endcapture %}not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged{% capture first_variable %}Hey{% endcapture %}. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker {% capture first_variable %}Hey{% endcapture %}including versions of Lorem Ipsum.Open{% capture first_variable %}Hey{% endcapture %}{% capture second_variable %}Hello{% endcapture %}{% capture last_variable %}{% endcapture %}CloseOpen{% capture first_variable %}Hey{% endcapture %}{% capture second_variable %}Hello{% endcapture %}{% capture last_variable %}{% endcapture %}Close
+"""
+
+small_capture = "{% capture x %}X{% endcapture %}"
+case_tag = "{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}"
+comment = "{% comment %} {% if true %} This is a commented block {% afi true %}{% endcomment %}"
+cycle = "This time {%cycle \"one\", \"two\"%} we win MF!"
+decrement = "Total Price: {% decrement a %}"
+for_tag = "{%for i in array.items offset:continue limit:1000 %}{{i}}{%endfor%}"
+
+if_tag =
+ "{% if false %} this text should not {% elsif true %} tests {% else %} go into the output {% endif %}"
+
+include =
+ "With text {% include 'snippet', my_variable: 'apples', my_other_variable: 'oranges' %} finally!"
+
+increment = "Price with discount: {% increment a %}"
+raw = "{% raw %} {% if true %} this is a raw block {% endraw %}"
+tablerow = "{% tablerow item in array %}{% endtablerow %}"
+
+templates = [
+ complex: complex,
+ literal: big_literal,
+ big_literal_with_tags: big_literal_with_tags,
+ small_literal: small_literal,
+ assign: assign,
+ capture: capture,
+ small_capture: small_capture,
+ case: case_tag,
+ comment: comment,
+ cycle: cycle,
+ decrement: decrement,
+ for: for_tag,
+ if: if_tag,
+ include: include,
+ increment: increment,
+ raw: raw,
+ tablerow: tablerow
+]
+
+benchmarks =
+ for {name, template} <- templates, into: %{} do
+ {name, fn -> Liquid.Parser.parse(template) end}
+ end
+
+Benchee.run(
+ benchmarks,
+ warmup: 5,
+ time: 20,
+ print: [
+ benchmarking: true,
+ configuration: false,
+ fast_warning: false
+ ],
+ console: [
+ comparison: false
+ ]
+)
diff --git a/bench/render_tags_benchmark.exs b/bench/render_tags_benchmark.exs
new file mode 100644
index 00000000..14eb3462
--- /dev/null
+++ b/bench/render_tags_benchmark.exs
@@ -0,0 +1,80 @@
+alias Liquid.Template
+
+Liquid.start()
+
+complex =
+ Template.parse(
+ "{% increment a %}{% if true %}{% decrement b %}{% if false %}{% increment c %}One{% decrement d %}{% elsif true %}Two{% else %}Three{% endif %}{% endif %}{% decrement d %}{% if false %}Four{% endif %}Last"
+ )
+
+big_literal = Template.parse(File.read!("bench/templates/big_literal.liquid"))
+big_literal_with_tags = Template.parse(File.read!("bench/templates/big_literal_with_tags.liquid"))
+small_literal = Template.parse("X")
+assign = Template.parse("Price in stock {% assign a = 5 %} Final Price")
+
+capture =
+ Template.parse("""
+ Lorem Ipsum is simply dummy text {% capture first_variable %}Hey{% endcapture %}of the printing and typesetting industry. Lorem Ipsum has {% capture first_variable %}Hey{% endcapture %}been the industry's standard dummy text ever since the {% capture first_variable %}Hey{% endcapture %}1500s, when an unknown printer {% capture first_variable %}Hey{% endcapture %}took a galley of type and scrambled it {% capture first_variable %}Hey{% endcapture %}to make a type specimen book. It has survived {% capture first_variable %}Hey{% endcapture %}not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged{% capture first_variable %}Hey{% endcapture %}. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker {% capture first_variable %}Hey{% endcapture %}including versions of Lorem Ipsum.Open{% capture first_variable %}Hey{% endcapture %}{% capture second_variable %}Hello{% endcapture %}{% capture last_variable %}{% endcapture %}CloseOpen{% capture first_variable %}Hey{% endcapture %}{% capture second_variable %}Hello{% endcapture %}{% capture last_variable %}{% endcapture %}Close
+ """)
+
+small_capture = Template.parse("{% capture x %}X{% endcapture %}")
+
+case_tag =
+ Template.parse("{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}")
+
+comment =
+ Template.parse(
+ "{% comment %} {% if true %} This is a commented block {% afi true %}{% endcomment %}"
+ )
+
+cycle = Template.parse(~S(This time {%cycle "one", "two"%} we win MF!))
+decrement = Template.parse("Total Price: {% decrement a %}")
+for_tag = Template.parse("{%for i in array.items offset:continue limit:1000 %}{{i}}{%endfor%}")
+
+if_tag =
+ Template.parse(
+ "{% if false %} this text should not {% elsif true %} tests {% else %} go into the output {% endif %}"
+ )
+
+increment = Template.parse("Price with discount: {% increment a %}")
+raw = Template.parse("{% raw %} {% if true %} this is a raw block {% endraw %}")
+tablerow = Template.parse("{% tablerow item in array %}{% endtablerow %}")
+
+templates = [
+ complex: complex,
+ literal: big_literal,
+ big_literal_with_tags: big_literal_with_tags,
+ small_literal: small_literal,
+ assign: assign,
+ capture: capture,
+ small_capture: small_capture,
+ case: case_tag,
+ comment: comment,
+ cycle: cycle,
+ decrement: decrement,
+ for: for_tag,
+ if: if_tag,
+ increment: increment,
+ raw: raw,
+ tablerow: tablerow
+]
+
+benchmarks =
+ for {name, template} <- templates, into: %{} do
+ {name, fn -> Liquid.Template.render(template) end}
+ end
+
+Benchee.run(
+ benchmarks,
+ warmup: 5,
+ time: 20,
+ print: [
+ benchmarking: true,
+ configuration: false,
+ fast_warning: false
+ ],
+ console: [
+ comparison: false
+ ],
+ memory_time: 10
+)
diff --git a/bench/templates/big_literal.liquid b/bench/templates/big_literal.liquid
new file mode 100644
index 00000000..b9d808fd
--- /dev/null
+++ b/bench/templates/big_literal.liquid
@@ -0,0 +1,149 @@
+ Look, I was gonna go easy on you and not to hurt your feelings
+ But I'm only going to get this one chance
+ Something's wrong, I can feel it (Six minutes, Slim Shady, you're on)
+ Just a feeling I've got, like something's about to happen, but I don't know what
+ If that means, what I think it means, we're in trouble, big trouble,
+ And if he is as bananas as you say, I'm not taking any chances
+ You were just what the doctor ordered
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ They said I rap like a robot, so call me Rapbot
+ But for me to rap like a computer must be in my genes
+ I got a laptop in my back pocket
+ My pen'll go off when I half-cock it
+ Got a fat knot from that rap profit
+ Made a living and a killing off it
+ Ever since Bill Clinton was still in office
+ With Monica Lewinsky feeling on his nut-sack
+ I'm an MC still as honest
+ But as rude and indecent as all hell syllables, killaholic (Kill 'em all with)
+ This slickety, gibbedy, hibbedy hip hop
+ You don't really wanna get into a pissing match with this rappidy rap
+ Packing a Mac in the back of the Ac, pack backpack rap, yep, yackidy-yac
+ The exact same time I attempt these lyrical acrobat stunts while I'm practicing
+ That I'll still be able to break a motherfuckin' table
+ Over the back of a couple of faggots and crack it in half
+ Only realized it was ironic I was signed to Aftermath after the fact
+ How could I not blow? All I do is drop F-bombs, feel my wrath of attack
+ Rappers are having a rough time period, here's a Maxipad
+ It's actually disastrously bad
+ For the wack while I'm masterfully constructing this masterpiece as
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ Let me show you maintaining this shit ain't that hard, that hard
+ Everybody want the key and the secret to rap immortality like I have got
+ Well, to be truthful the blueprint's simply rage and youthful exuberance
+ Everybody loves to root for a nuisance
+ Hit the earth like an asteroid, did nothing but shoot for the moon since
+ MC's get taken to school with this music
+ 'Cause I use it as a vehicle to bust a rhyme
+ Now I lead a new school full of students
+ Me? I'm a product of Rakim, Lakim Shabazz, 2Pac N-
+ -W.A, Cube, hey, Doc, Ren, Yella, Eazy, thank you, they got Slim
+ Inspired enough to one day grow up, blow up and be in a position
+ To meet Run DMC and induct them into the motherfuckin' Rock n'
+ Roll Hall of Fame
+ Even though I walk in the church and burst in a ball of flames
+ Only Hall of Fame I be inducted in is the alcohol of fame
+ On the wall of shame
+ You fags think it's all a game 'til I walk a flock of flames
+ Off of planking, tell me what in the fuck are you thinking?
+ Little gay looking boy
+ So gay I can barely say it with a straight face looking boy
+ You witnessing a massacre
+ Like you watching a church gathering take place looking boy
+ Oy vey, that boy's gay, that's all they say looking boy
+ You get a thumbs up, pat on the back
+ And a way to go from your label everyday looking boy
+ Hey, looking boy, what you say looking boy?
+ I got a "hell yeah" from Dre looking boy
+ I'mma work for everything I have
+ Never ask nobody for shit, get outta my face looking boy
+ Basically boy you're never gonna be capable
+ To keep up with the same pace looking boy
+ 'Cause I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ The way I'm racing around the track, call me Nascar, Nascar
+ Dale Earnhardt of the trailer park, the White Trash God
+ Kneel before General Zod this planet's Krypton, no Asgard, Asgard
+ So you be Thor and I'll be Odin, you rodent, I'm omnipotent
+ Let off then I'm reloading immediately with these bombs I'm totin'
+ And I should not be woken
+ I'm the walking dead, but I'm just a talking head, a zombie floating
+ But I got your mom deep throating
+ I'm out my ramen noodle, we have nothing in common, poodle
+ I'm a doberman, pinch yourself in the arm and pay homage, pupil
+ It's me, my honesty's brutal
+ But it's honestly futile if I don't utilize what I do though
+ For good at least once in a while
+ So I wanna make sure somewhere in this chicken scratch I scribble and doodle
+ Enough rhymes to maybe to try and help get some people through tough times
+ But I gotta keep a few punchlines just in case cause even you unsigned
+ Rappers are hungry looking at me like it's lunchtime
+ I know there was a time where once I
+ Was king of the underground, but I still rap like I'm on my Pharoahe Monch grind
+ So I crunch rhymes, but sometimes when you combine
+ Appeal with the skin color of mine
+ You get too big and here they come trying to,
+ Censor you like that one line I said on "I'm Back" from the Marshall Mathers LP
+ One where I tried to say I take seven kids from Columbine
+ Put 'em all in a line, add an AK-47, a revolver and a nine
+ See if I get away with it now that I ain't as big as I was, but I've
+ Morphed into an immortal coming through the portal
+ You're stuck in a time warp from 2004 though
+ And I don't know what the fuck that you rhyme for
+ You're pointless as Rapunzel with fucking cornrows
+ You're like normal, fuck being normal
+ And I just bought a new Raygun from the future
+ To just come and shoot ya like when Fabolous made Ray J mad
+ 'Cause Fab said he looked like a fag at Maywhether’s pad
+ Singin' to a man while they played piano
+ Man, oh man, that was a 24/7 special on the cable channel
+ So Ray J went straight to the radio station the very next day
+ "Hey, Fab, I'mma kill you"
+ Lyrics coming at you at supersonic speed, (JJ Fad)
+ Uh, sama lamaa duma lamaa you assuming I'm a human
+ What I gotta do to get it through to you I'm superhuman
+ Innovative and I'm made of rubber
+ So that anything you saying ricocheting off of me and it'll glue to you
+ I'm never stating, more than never demonstrating
+ How to give a motherfuckin' audience a feeling like it's levitating
+ Never fading, and I know that the haters are forever waiting
+ For the day that they can say I fell off, they'd be celebrating
+ Cause I know the way to get 'em motivated
+ I make elevating music, you make elevator music
+ Oh, he's too mainstream
+ Well, that's what they do when they get jealous, they confuse it
+ It's not hip hop, it's pop, cause I found a hella way to fuse it
+ With rock, shock rap with Doc
+ Throw on Lose Yourself and make 'em lose it
+ I don't know how to make songs like that
+ I don't know what words to use
+ Let me know when it occurs to you
+ While I’m ripping any one of these verses diverse as you
+ It’s curtains, I’m inadvertently hurtin' you
+ How many verses I gotta murder to,
+ Prove that if you're half as nice at songs you can sacrifice virgins too uh!
+ School flunkie, pill junky
+ But look at the accolades the skills brung me
+ Full of myself, but still hungry
+ I bully myself cause I make me do what I put my mind to
+ And I'm a million leagues above you, ill when I speak in tongues
+ But it's still tongue in cheek, fuck you
+ I'm drunk so Satan take the fucking wheel, I'm asleep in the front seat
+ Bumping Heavy D and the Boys, still chunky, but funky
+ But in my head there's something I can feel tugging and struggling
+ Angels fight with devils, here's what they want from me
+ They asking me to eliminate some of the women hate
+ But if you take into consideration the bitter hatred that I had
+ Then you may be a little patient and more sympathetic to the situation
+ And understand the discrimination
+ But fuck it, life's handing you lemons, make lemonade then
+ But if I can't batter the women how the fuck am I supposed to bake them a cake then?
+ Don't mistake it for Satan
+ It's a fatal mistake if you think I need to be overseas
+ And take a vacation to trip a broad
+ And make her fall on her face and don't be a retard
+ Be a king? Think not, why be a king when you can be a God?
diff --git a/bench/templates/big_literal_with_tags.liquid b/bench/templates/big_literal_with_tags.liquid
new file mode 100644
index 00000000..73671f54
--- /dev/null
+++ b/bench/templates/big_literal_with_tags.liquid
@@ -0,0 +1,453 @@
+{% if true %}
+ Look, I was gonna go easy on you and not to hurt your feelings
+ But I'm only going to get this one chance
+ Something's wrong, I can feel it (Six minutes, Slim Shady, you're on)
+ Just a feeling I've got, like something's about to happen, but I don't know what
+ If that means, what I think it means, we're in trouble, big trouble,
+ And if he is as bananas as you say, I'm not taking any chances
+ You were just what the doctor ordered
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ They said I rap like a robot, so call me Rapbot
+ But for me to rap like a computer must be in my genes
+ I got a laptop in my back pocket
+ My pen'll go off when I half-cock it
+ Got a fat knot from that rap profit
+ Made a living and a killing off it
+ Ever since Bill Clinton was still in office
+ With Monica Lewinsky feeling on his nut-sack
+ I'm an MC still as honest
+ But as rude and indecent as all hell syllables, killaholic (Kill 'em all with)
+ This slickety, gibbedy, hibbedy hip hop
+ You don't really wanna get into a pissing match with this rappidy rap
+ Packing a Mac in the back of the Ac, pack backpack rap, yep, yackidy-yac
+ The exact same time I attempt these lyrical acrobat stunts while I'm practicing
+ That I'll still be able to break a motherfuckin' table
+ Over the back of a couple of faggots and crack it in half
+ Only realized it was ironic I was signed to Aftermath after the fact
+ How could I not blow? All I do is drop F-bombs, feel my wrath of attack
+ Rappers are having a rough time period, here's a Maxipad
+ It's actually disastrously bad
+ For the wack while I'm masterfully constructing this masterpiece as
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ Let me show you maintaining this shit ain't that hard, that hard
+ Everybody want the key and the secret to rap immortality like I have got
+ Well, to be truthful the blueprint's simply rage and youthful exuberance
+ Everybody loves to root for a nuisance
+ Hit the earth like an asteroid, did nothing but shoot for the moon since
+ MC's get taken to school with this music
+ 'Cause I use it as a vehicle to bust a rhyme
+ Now I lead a new school full of students
+ Me? I'm a product of Rakim, Lakim Shabazz, 2Pac N-
+ -W.A, Cube, hey, Doc, Ren, Yella, Eazy, thank you, they got Slim
+ Inspired enough to one day grow up, blow up and be in a position
+ To meet Run DMC and induct them into the motherfuckin' Rock n'
+ Roll Hall of Fame
+ Even though I walk in the church and burst in a ball of flames
+ Only Hall of Fame I be inducted in is the alcohol of fame
+ On the wall of shame
+ You fags think it's all a game 'til I walk a flock of flames
+ Off of planking, tell me what in the fuck are you thinking?
+ Little gay looking boy
+ So gay I can barely say it with a straight face looking boy
+ You witnessing a massacre
+ Like you watching a church gathering take place looking boy
+ Oy vey, that boy's gay, that's all they say looking boy
+ You get a thumbs up, pat on the back
+ And a way to go from your label everyday looking boy
+ Hey, looking boy, what you say looking boy?
+ I got a "hell yeah" from Dre looking boy
+ I'mma work for everything I have
+ Never ask nobody for shit, get outta my face looking boy
+ Basically boy you're never gonna be capable
+ To keep up with the same pace looking boy
+ 'Cause I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ The way I'm racing around the track, call me Nascar, Nascar
+ Dale Earnhardt of the trailer park, the White Trash God
+ Kneel before General Zod this planet's Krypton, no Asgard, Asgard
+ So you be Thor and I'll be Odin, you rodent, I'm omnipotent
+ Let off then I'm reloading immediately with these bombs I'm totin'
+ And I should not be woken
+ I'm the walking dead, but I'm just a talking head, a zombie floating
+ But I got your mom deep throating
+ I'm out my ramen noodle, we have nothing in common, poodle
+ I'm a doberman, pinch yourself in the arm and pay homage, pupil
+ It's me, my honesty's brutal
+ But it's honestly futile if I don't utilize what I do though
+ For good at least once in a while
+ So I wanna make sure somewhere in this chicken scratch I scribble and doodle
+ Enough rhymes to maybe to try and help get some people through tough times
+ But I gotta keep a few punchlines just in case cause even you unsigned
+ Rappers are hungry looking at me like it's lunchtime
+ I know there was a time where once I
+ Was king of the underground, but I still rap like I'm on my Pharoahe Monch grind
+ So I crunch rhymes, but sometimes when you combine
+ Appeal with the skin color of mine
+ You get too big and here they come trying to,
+ Censor you like that one line I said on "I'm Back" from the Marshall Mathers LP
+ One where I tried to say I take seven kids from Columbine
+ Put 'em all in a line, add an AK-47, a revolver and a nine
+ See if I get away with it now that I ain't as big as I was, but I've
+ Morphed into an immortal coming through the portal
+ You're stuck in a time warp from 2004 though
+ And I don't know what the fuck that you rhyme for
+ You're pointless as Rapunzel with fucking cornrows
+ You're like normal, fuck being normal
+ And I just bought a new Raygun from the future
+ To just come and shoot ya like when Fabolous made Ray J mad
+ 'Cause Fab said he looked like a fag at Maywhether’s pad
+ Singin' to a man while they played piano
+ Man, oh man, that was a 24/7 special on the cable channel
+ So Ray J went straight to the radio station the very next day
+ "Hey, Fab, I'mma kill you"
+ Lyrics coming at you at supersonic speed, (JJ Fad)
+ Uh, sama lamaa duma lamaa you assuming I'm a human
+ What I gotta do to get it through to you I'm superhuman
+ Innovative and I'm made of rubber
+ So that anything you saying ricocheting off of me and it'll glue to you
+ I'm never stating, more than never demonstrating
+ How to give a motherfuckin' audience a feeling like it's levitating
+ Never fading, and I know that the haters are forever waiting
+ For the day that they can say I fell off, they'd be celebrating
+ Cause I know the way to get 'em motivated
+ I make elevating music, you make elevator music
+ Oh, he's too mainstream
+ Well, that's what they do when they get jealous, they confuse it
+ It's not hip hop, it's pop, cause I found a hella way to fuse it
+ With rock, shock rap with Doc
+ Throw on Lose Yourself and make 'em lose it
+ I don't know how to make songs like that
+ I don't know what words to use
+ Let me know when it occurs to you
+ While I’m ripping any one of these verses diverse as you
+ It’s curtains, I’m inadvertently hurtin' you
+ How many verses I gotta murder to,
+ Prove that if you're half as nice at songs you can sacrifice virgins too uh!
+ School flunkie, pill junky
+ But look at the accolades the skills brung me
+ Full of myself, but still hungry
+ I bully myself cause I make me do what I put my mind to
+ And I'm a million leagues above you, ill when I speak in tongues
+ But it's still tongue in cheek, fuck you
+ I'm drunk so Satan take the fucking wheel, I'm asleep in the front seat
+ Bumping Heavy D and the Boys, still chunky, but funky
+ But in my head there's something I can feel tugging and struggling
+ Angels fight with devils, here's what they want from me
+ They asking me to eliminate some of the women hate
+ But if you take into consideration the bitter hatred that I had
+ Then you may be a little patient and more sympathetic to the situation
+ And understand the discrimination
+ But fuck it, life's handing you lemons, make lemonade then
+ But if I can't batter the women how the fuck am I supposed to bake them a cake then?
+ Don't mistake it for Satan
+ It's a fatal mistake if you think I need to be overseas
+ And take a vacation to trip a broad
+ And make her fall on her face and don't be a retard
+ Be a king? Think not, why be a king when you can be a God?
+{% endif %}
+{% if true %}
+ Look, I was gonna go easy on you and not to hurt your feelings
+ But I'm only going to get this one chance
+ Something's wrong, I can feel it (Six minutes, Slim Shady, you're on)
+ Just a feeling I've got, like something's about to happen, but I don't know what
+ If that means, what I think it means, we're in trouble, big trouble,
+ And if he is as bananas as you say, I'm not taking any chances
+ You were just what the doctor ordered
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ They said I rap like a robot, so call me Rapbot
+ But for me to rap like a computer must be in my genes
+ I got a laptop in my back pocket
+ My pen'll go off when I half-cock it
+ Got a fat knot from that rap profit
+ Made a living and a killing off it
+ Ever since Bill Clinton was still in office
+ With Monica Lewinsky feeling on his nut-sack
+ I'm an MC still as honest
+ But as rude and indecent as all hell syllables, killaholic (Kill 'em all with)
+ This slickety, gibbedy, hibbedy hip hop
+ You don't really wanna get into a pissing match with this rappidy rap
+ Packing a Mac in the back of the Ac, pack backpack rap, yep, yackidy-yac
+ The exact same time I attempt these lyrical acrobat stunts while I'm practicing
+ That I'll still be able to break a motherfuckin' table
+ Over the back of a couple of faggots and crack it in half
+ Only realized it was ironic I was signed to Aftermath after the fact
+ How could I not blow? All I do is drop F-bombs, feel my wrath of attack
+ Rappers are having a rough time period, here's a Maxipad
+ It's actually disastrously bad
+ For the wack while I'm masterfully constructing this masterpiece as
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ Let me show you maintaining this shit ain't that hard, that hard
+ Everybody want the key and the secret to rap immortality like I have got
+ Well, to be truthful the blueprint's simply rage and youthful exuberance
+ Everybody loves to root for a nuisance
+ Hit the earth like an asteroid, did nothing but shoot for the moon since
+ MC's get taken to school with this music
+ 'Cause I use it as a vehicle to bust a rhyme
+ Now I lead a new school full of students
+ Me? I'm a product of Rakim, Lakim Shabazz, 2Pac N-
+ -W.A, Cube, hey, Doc, Ren, Yella, Eazy, thank you, they got Slim
+ Inspired enough to one day grow up, blow up and be in a position
+ To meet Run DMC and induct them into the motherfuckin' Rock n'
+ Roll Hall of Fame
+ Even though I walk in the church and burst in a ball of flames
+ Only Hall of Fame I be inducted in is the alcohol of fame
+ On the wall of shame
+ You fags think it's all a game 'til I walk a flock of flames
+ Off of planking, tell me what in the fuck are you thinking?
+ Little gay looking boy
+ So gay I can barely say it with a straight face looking boy
+ You witnessing a massacre
+ Like you watching a church gathering take place looking boy
+ Oy vey, that boy's gay, that's all they say looking boy
+ You get a thumbs up, pat on the back
+ And a way to go from your label everyday looking boy
+ Hey, looking boy, what you say looking boy?
+ I got a "hell yeah" from Dre looking boy
+ I'mma work for everything I have
+ Never ask nobody for shit, get outta my face looking boy
+ Basically boy you're never gonna be capable
+ To keep up with the same pace looking boy
+ 'Cause I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ The way I'm racing around the track, call me Nascar, Nascar
+ Dale Earnhardt of the trailer park, the White Trash God
+ Kneel before General Zod this planet's Krypton, no Asgard, Asgard
+ So you be Thor and I'll be Odin, you rodent, I'm omnipotent
+ Let off then I'm reloading immediately with these bombs I'm totin'
+ And I should not be woken
+ I'm the walking dead, but I'm just a talking head, a zombie floating
+ But I got your mom deep throating
+ I'm out my ramen noodle, we have nothing in common, poodle
+ I'm a doberman, pinch yourself in the arm and pay homage, pupil
+ It's me, my honesty's brutal
+ But it's honestly futile if I don't utilize what I do though
+ For good at least once in a while
+ So I wanna make sure somewhere in this chicken scratch I scribble and doodle
+ Enough rhymes to maybe to try and help get some people through tough times
+ But I gotta keep a few punchlines just in case cause even you unsigned
+ Rappers are hungry looking at me like it's lunchtime
+ I know there was a time where once I
+ Was king of the underground, but I still rap like I'm on my Pharoahe Monch grind
+ So I crunch rhymes, but sometimes when you combine
+ Appeal with the skin color of mine
+ You get too big and here they come trying to,
+ Censor you like that one line I said on "I'm Back" from the Marshall Mathers LP
+ One where I tried to say I take seven kids from Columbine
+ Put 'em all in a line, add an AK-47, a revolver and a nine
+ See if I get away with it now that I ain't as big as I was, but I've
+ Morphed into an immortal coming through the portal
+ You're stuck in a time warp from 2004 though
+ And I don't know what the fuck that you rhyme for
+ You're pointless as Rapunzel with fucking cornrows
+ You're like normal, fuck being normal
+ And I just bought a new Raygun from the future
+ To just come and shoot ya like when Fabolous made Ray J mad
+ 'Cause Fab said he looked like a fag at Maywhether’s pad
+ Singin' to a man while they played piano
+ Man, oh man, that was a 24/7 special on the cable channel
+ So Ray J went straight to the radio station the very next day
+ "Hey, Fab, I'mma kill you"
+ Lyrics coming at you at supersonic speed, (JJ Fad)
+ Uh, sama lamaa duma lamaa you assuming I'm a human
+ What I gotta do to get it through to you I'm superhuman
+ Innovative and I'm made of rubber
+ So that anything you saying ricocheting off of me and it'll glue to you
+ I'm never stating, more than never demonstrating
+ How to give a motherfuckin' audience a feeling like it's levitating
+ Never fading, and I know that the haters are forever waiting
+ For the day that they can say I fell off, they'd be celebrating
+ Cause I know the way to get 'em motivated
+ I make elevating music, you make elevator music
+ Oh, he's too mainstream
+ Well, that's what they do when they get jealous, they confuse it
+ It's not hip hop, it's pop, cause I found a hella way to fuse it
+ With rock, shock rap with Doc
+ Throw on Lose Yourself and make 'em lose it
+ I don't know how to make songs like that
+ I don't know what words to use
+ Let me know when it occurs to you
+ While I’m ripping any one of these verses diverse as you
+ It’s curtains, I’m inadvertently hurtin' you
+ How many verses I gotta murder to,
+ Prove that if you're half as nice at songs you can sacrifice virgins too uh!
+ School flunkie, pill junky
+ But look at the accolades the skills brung me
+ Full of myself, but still hungry
+ I bully myself cause I make me do what I put my mind to
+ And I'm a million leagues above you, ill when I speak in tongues
+ But it's still tongue in cheek, fuck you
+ I'm drunk so Satan take the fucking wheel, I'm asleep in the front seat
+ Bumping Heavy D and the Boys, still chunky, but funky
+ But in my head there's something I can feel tugging and struggling
+ Angels fight with devils, here's what they want from me
+ They asking me to eliminate some of the women hate
+ But if you take into consideration the bitter hatred that I had
+ Then you may be a little patient and more sympathetic to the situation
+ And understand the discrimination
+ But fuck it, life's handing you lemons, make lemonade then
+ But if I can't batter the women how the fuck am I supposed to bake them a cake then?
+ Don't mistake it for Satan
+ It's a fatal mistake if you think I need to be overseas
+ And take a vacation to trip a broad
+ And make her fall on her face and don't be a retard
+ Be a king? Think not, why be a king when you can be a God?
+{% endif %}
+{% if true %}
+ Look, I was gonna go easy on you and not to hurt your feelings
+ But I'm only going to get this one chance
+ Something's wrong, I can feel it (Six minutes, Slim Shady, you're on)
+ Just a feeling I've got, like something's about to happen, but I don't know what
+ If that means, what I think it means, we're in trouble, big trouble,
+ And if he is as bananas as you say, I'm not taking any chances
+ You were just what the doctor ordered
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ They said I rap like a robot, so call me Rapbot
+ But for me to rap like a computer must be in my genes
+ I got a laptop in my back pocket
+ My pen'll go off when I half-cock it
+ Got a fat knot from that rap profit
+ Made a living and a killing off it
+ Ever since Bill Clinton was still in office
+ With Monica Lewinsky feeling on his nut-sack
+ I'm an MC still as honest
+ But as rude and indecent as all hell syllables, killaholic (Kill 'em all with)
+ This slickety, gibbedy, hibbedy hip hop
+ You don't really wanna get into a pissing match with this rappidy rap
+ Packing a Mac in the back of the Ac, pack backpack rap, yep, yackidy-yac
+ The exact same time I attempt these lyrical acrobat stunts while I'm practicing
+ That I'll still be able to break a motherfuckin' table
+ Over the back of a couple of faggots and crack it in half
+ Only realized it was ironic I was signed to Aftermath after the fact
+ How could I not blow? All I do is drop F-bombs, feel my wrath of attack
+ Rappers are having a rough time period, here's a Maxipad
+ It's actually disastrously bad
+ For the wack while I'm masterfully constructing this masterpiece as
+ I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ Now who thinks their arms are long enough to slap box, slap box?
+ Let me show you maintaining this shit ain't that hard, that hard
+ Everybody want the key and the secret to rap immortality like I have got
+ Well, to be truthful the blueprint's simply rage and youthful exuberance
+ Everybody loves to root for a nuisance
+ Hit the earth like an asteroid, did nothing but shoot for the moon since
+ MC's get taken to school with this music
+ 'Cause I use it as a vehicle to bust a rhyme
+ Now I lead a new school full of students
+ Me? I'm a product of Rakim, Lakim Shabazz, 2Pac N-
+ -W.A, Cube, hey, Doc, Ren, Yella, Eazy, thank you, they got Slim
+ Inspired enough to one day grow up, blow up and be in a position
+ To meet Run DMC and induct them into the motherfuckin' Rock n'
+ Roll Hall of Fame
+ Even though I walk in the church and burst in a ball of flames
+ Only Hall of Fame I be inducted in is the alcohol of fame
+ On the wall of shame
+ You fags think it's all a game 'til I walk a flock of flames
+ Off of planking, tell me what in the fuck are you thinking?
+ Little gay looking boy
+ So gay I can barely say it with a straight face looking boy
+ You witnessing a massacre
+ Like you watching a church gathering take place looking boy
+ Oy vey, that boy's gay, that's all they say looking boy
+ You get a thumbs up, pat on the back
+ And a way to go from your label everyday looking boy
+ Hey, looking boy, what you say looking boy?
+ I got a "hell yeah" from Dre looking boy
+ I'mma work for everything I have
+ Never ask nobody for shit, get outta my face looking boy
+ Basically boy you're never gonna be capable
+ To keep up with the same pace looking boy
+ 'Cause I'm beginning to feel like a Rap God, Rap God
+ All my people from the front to the back nod, back nod
+ The way I'm racing around the track, call me Nascar, Nascar
+ Dale Earnhardt of the trailer park, the White Trash God
+ Kneel before General Zod this planet's Krypton, no Asgard, Asgard
+ So you be Thor and I'll be Odin, you rodent, I'm omnipotent
+ Let off then I'm reloading immediately with these bombs I'm totin'
+ And I should not be woken
+ I'm the walking dead, but I'm just a talking head, a zombie floating
+ But I got your mom deep throating
+ I'm out my ramen noodle, we have nothing in common, poodle
+ I'm a doberman, pinch yourself in the arm and pay homage, pupil
+ It's me, my honesty's brutal
+ But it's honestly futile if I don't utilize what I do though
+ For good at least once in a while
+ So I wanna make sure somewhere in this chicken scratch I scribble and doodle
+ Enough rhymes to maybe to try and help get some people through tough times
+ But I gotta keep a few punchlines just in case cause even you unsigned
+ Rappers are hungry looking at me like it's lunchtime
+ I know there was a time where once I
+ Was king of the underground, but I still rap like I'm on my Pharoahe Monch grind
+ So I crunch rhymes, but sometimes when you combine
+ Appeal with the skin color of mine
+ You get too big and here they come trying to,
+ Censor you like that one line I said on "I'm Back" from the Marshall Mathers LP
+ One where I tried to say I take seven kids from Columbine
+ Put 'em all in a line, add an AK-47, a revolver and a nine
+ See if I get away with it now that I ain't as big as I was, but I've
+ Morphed into an immortal coming through the portal
+ You're stuck in a time warp from 2004 though
+ And I don't know what the fuck that you rhyme for
+ You're pointless as Rapunzel with fucking cornrows
+ You're like normal, fuck being normal
+ And I just bought a new Raygun from the future
+ To just come and shoot ya like when Fabolous made Ray J mad
+ 'Cause Fab said he looked like a fag at Maywhether’s pad
+ Singin' to a man while they played piano
+ Man, oh man, that was a 24/7 special on the cable channel
+ So Ray J went straight to the radio station the very next day
+ "Hey, Fab, I'mma kill you"
+ Lyrics coming at you at supersonic speed, (JJ Fad)
+ Uh, sama lamaa duma lamaa you assuming I'm a human
+ What I gotta do to get it through to you I'm superhuman
+ Innovative and I'm made of rubber
+ So that anything you saying ricocheting off of me and it'll glue to you
+ I'm never stating, more than never demonstrating
+ How to give a motherfuckin' audience a feeling like it's levitating
+ Never fading, and I know that the haters are forever waiting
+ For the day that they can say I fell off, they'd be celebrating
+ Cause I know the way to get 'em motivated
+ I make elevating music, you make elevator music
+ Oh, he's too mainstream
+ Well, that's what they do when they get jealous, they confuse it
+ It's not hip hop, it's pop, cause I found a hella way to fuse it
+ With rock, shock rap with Doc
+ Throw on Lose Yourself and make 'em lose it
+ I don't know how to make songs like that
+ I don't know what words to use
+ Let me know when it occurs to you
+ While I’m ripping any one of these verses diverse as you
+ It’s curtains, I’m inadvertently hurtin' you
+ How many verses I gotta murder to,
+ Prove that if you're half as nice at songs you can sacrifice virgins too uh!
+ School flunkie, pill junky
+ But look at the accolades the skills brung me
+ Full of myself, but still hungry
+ I bully myself cause I make me do what I put my mind to
+ And I'm a million leagues above you, ill when I speak in tongues
+ But it's still tongue in cheek, fuck you
+ I'm drunk so Satan take the fucking wheel, I'm asleep in the front seat
+ Bumping Heavy D and the Boys, still chunky, but funky
+ But in my head there's something I can feel tugging and struggling
+ Angels fight with devils, here's what they want from me
+ They asking me to eliminate some of the women hate
+ But if you take into consideration the bitter hatred that I had
+ Then you may be a little patient and more sympathetic to the situation
+ And understand the discrimination
+ But fuck it, life's handing you lemons, make lemonade then
+ But if I can't batter the women how the fuck am I supposed to bake them a cake then?
+ Don't mistake it for Satan
+ It's a fatal mistake if you think I need to be overseas
+ And take a vacation to trip a broad
+ And make her fall on her face and don't be a retard
+ Be a king? Think not, why be a king when you can be a God?
+{% endif %}
diff --git a/bench/templates/complex.liquid b/bench/templates/complex.liquid
new file mode 100644
index 00000000..072b4cc2
--- /dev/null
+++ b/bench/templates/complex.liquid
@@ -0,0 +1,75 @@
+
+ {% for collection in collections %}
+ {% for product in collection.products %}
+ {% for variant in product.variants %}
+
{{ collection.title | upcase }}/br{{ collection.title }}
+ {% if collection.description.size > 0 %}
+
{{ collection.description }}
+ {% endif %}
+
+
+ {{ product.price | plus: 1000}}
+ {{ product.price | divide_by: 1000}}
+ {{ product.price | minus: 1000}}
+ {% assign all_products = collection.products | map: "price" %}
+ {% for item in all_products %}
+ {{ item }}
+ {{ all_products | sort | join: ", " }}
+ {% case product.vendor%}
+ {% when 'Nikon' %}
+ This is a camera
+ {% when 'Stormtech' %}
+ This is a Sweater
+ {% else %}
+ This is not a camera nor a Sweater
+ {% endcase %}
+ {% endfor %}
+
+ {% endfor %}
+ {% endfor %}
+ {% endfor %}
+ {% assign fruits = "apples, oranges, peaches" | split: ", " %}
+ {% assign vegetables = "carrots, turnips, potatoes" | split: ", " %}
+ {% for item in fruits %}
+ - {{ item }}
+ {% endfor %}
+ {% for item in vegetables %}
+ - {{ item }}
+ {% endfor %}
+
diff --git a/bench/templates/medium.liquid b/bench/templates/medium.liquid
new file mode 100644
index 00000000..181201db
--- /dev/null
+++ b/bench/templates/medium.liquid
@@ -0,0 +1,16 @@
+
+ {% for collection in collections%}
+ {% for product in collection.products %}
+ -
+
+
+
+
{{ product.description | strip_html | truncatewords: 35 }}
+
{% if product.price_varies %} -
Varies the price<\p>{% endif %}
+
+
+ {% endfor %}
+ {% endfor %}
+
diff --git a/bench/templates/simple.liquid b/bench/templates/simple.liquid
new file mode 100644
index 00000000..2acd50e6
--- /dev/null
+++ b/bench/templates/simple.liquid
@@ -0,0 +1,17 @@
+{% for page in pages %}
+ {% for blog in blogs %}
+
+
{{page.title}}
+ {% for article in blog.articles %}
+
+ {{ article.created_at | date: "%d %b" }}
+ {{ article.title }}
+
+ {{ article.content }}
+ {% if blog.comments_enabled %}
+
{{ article.comments_count }} comments
+ {% endif %}
+ {% endfor %}
+
+ {% endfor %}
+{% endfor %}
diff --git a/lib/liquid.ex b/lib/liquid.ex
index 506f7da2..fd94c2d6 100644
--- a/lib/liquid.ex
+++ b/lib/liquid.ex
@@ -15,32 +15,8 @@ defmodule Liquid do
def double_quote, do: "\""
def quote_matcher, do: ~r/#{single_quote()}|#{double_quote()}/
- def variable_start, do: "{{"
- def variable_end, do: "}}"
- def variable_incomplete_end, do: "\}\}?"
-
- def tag_start, do: "{%"
- def tag_end, do: "%}"
-
def any_starting_tag, do: "(){{()|(){%()"
- def invalid_expression,
- do: ~r/^{%.*}}$|^{{.*%}$|^{%.*([^}%]}|[^}%])$|^{{.*([^}%]}|[^}%])$|(^{{|^{%)/ms
-
- def tokenizer,
- do: ~r/()#{tag_start()}.*?#{tag_end()}()|()#{variable_start()}.*?#{variable_end()}()/
-
- def parser,
- do:
- ~r/#{tag_start()}\s*(?.*?)\s*#{tag_end()}|#{variable_start()}\s*(?.*?)\s*#{
- variable_end()
- }/m
-
- def template_parser, do: ~r/#{partial_template_parser()}|#{any_starting_tag()}/ms
-
- def partial_template_parser,
- do: "()#{tag_start()}.*?#{tag_end()}()|()#{variable_start()}.*?#{variable_incomplete_end()}()"
-
def quoted_string, do: "\"[^\"]*\"|'[^']*'"
def quoted_fragment, do: "#{quoted_string()}|(?:[^\s,\|'\"]|#{quoted_string()})+"
diff --git a/lib/liquid/ast.ex b/lib/liquid/ast.ex
new file mode 100644
index 00000000..94049c71
--- /dev/null
+++ b/lib/liquid/ast.ex
@@ -0,0 +1,56 @@
+defmodule Liquid.Ast do
+ @moduledoc """
+ Builds the AST processing with Nimble, only liquid valid tags and variables. It uses Tokenizer
+ to send to Nimble only tags and variables, without literals.
+ Literals (any markup which is not liquid variable or tag) are slow to be processed by Nimble thus
+ this module improve performance between 30% and 100% depending how much text is processed.
+ """
+ alias Liquid.{Tokenizer, Parser, Block}
+
+ @doc """
+ Recursively builds the AST taking a markup, or a tuple with a literal and a rest markup.
+ It uses `context` to validate the correct opening and close of blocks and sub blocks.
+ """
+ @spec build(binary() | {binary(), binary()}, Keyword.t(), list()) ::
+ {:ok, list(), Keyword.t(), binary()} | {:error, binary(), binary()}
+ def build({literal, ""}, context, ast), do: {:ok, Enum.reverse([literal | ast]), context, ""}
+ def build({"", markup}, context, ast), do: parse_liquid(markup, context, ast)
+ def build({literal, markup}, context, ast), do: parse_liquid(markup, context, [literal | ast])
+ def build("", context, ast), do: {:ok, Enum.reverse(ast), context, ""}
+ def build(markup, context, ast), do: markup |> Tokenizer.tokenize() |> build(context, ast)
+
+ @spec build({:error, binary(), binary()}) :: {:error, binary(), binary()}
+ def build({:error, error_message, rest_markup}), do: {:error, error_message, rest_markup}
+
+ defp parse_liquid(markup, context, ast),
+ do: markup |> Parser.__parse__(context: context) |> do_parse_liquid(ast)
+
+ defp do_parse_liquid({:ok, [{:error, message}], rest, _, _, _}, _), do: {:error, message, rest}
+ defp do_parse_liquid({:ok, [{:block, _}], _, _, _, _} = liquid, ast), do: block(liquid, ast)
+
+ defp do_parse_liquid({:ok, [{:sub_block, _}] = tag, rest, context, _, _}, ast),
+ do: {:ok, [tag | ast], context, rest}
+
+ defp do_parse_liquid({:ok, [{:end_block, [{_, [tag]}]}], rest, %{tags: []}, _, _}, _),
+ do: {:error, "The tag '#{tag}' was not opened", rest}
+
+ defp do_parse_liquid(
+ {:ok, [{:end_block, [{_, [tag_name]}]}] = tag, rest,
+ %{tags: [last_tag | tags]} = context, _, _},
+ ast
+ )
+ when tag_name == last_tag do
+ {:ok, [tag | ast], %{context | tags: tags}, rest}
+ end
+
+ defp do_parse_liquid({:ok, [{:end_block, _}], rest, %{tags: [last_tag | _]}, _, _}, _),
+ do: {:error, "The '#{last_tag}' tag has not been correctly closed", rest}
+
+ defp do_parse_liquid({:ok, [tags], rest, context, _, _}, ast),
+ do: build(rest, context, [tags | ast])
+
+ defp do_parse_liquid({:error, message, rest, _, _, _}, _), do: {:error, message, rest}
+
+ defp block({:ok, [{:block, [tag]}], markup, context, _, _}, ast),
+ do: Block.build(markup, tag, [], [], context, ast)
+end
diff --git a/lib/liquid/block.ex b/lib/liquid/block.ex
index d30d2e6c..97bdbfda 100644
--- a/lib/liquid/block.ex
+++ b/lib/liquid/block.ex
@@ -9,11 +9,24 @@ defmodule Liquid.Block do
blank: false,
strict: true
+ alias Liquid.Ast
alias Liquid.Tag, as: Tag
alias Liquid.Block, as: Block
+ @type t :: %Liquid.Block{
+ name: String.t() | nil,
+ markup: String.t() | nil,
+ condition: String.t() | nil,
+ parts: [...],
+ iterator: [...],
+ nodelist: [...],
+ elselist: [...],
+ blank: boolean(),
+ strict: boolean()
+ }
+
def create(markup) do
- destructure [name, rest], String.split(markup, " ", parts: 2)
+ destructure([name, rest], String.split(markup, " ", parts: 2))
%Block{name: name |> String.to_atom(), markup: rest}
end
@@ -25,4 +38,49 @@ defmodule Liquid.Block do
!(is_map(x) and x.__struct__ == Tag and Enum.member?(namelist, x.name))
end)
end
+
+ @doc """
+ Build a liquid block (if, for, capture, case, unless, tablerow) with optional
+ subblocks (else, when, elseif)
+ """
+ @spec build(binary(), {atom(), list()}, list(), list(), list(), list()) ::
+ {:error, binary(), binary()} | {:ok, list(), list(), binary()}
+ def build(markup, block, sub_blocks, bodies, context, ast),
+ do: markup |> Ast.build(context, []) |> do_build(block, sub_blocks, bodies, ast)
+
+ defp do_build(
+ {:ok, [[{:sub_block, [sub_block]}] | body], context, rest},
+ block,
+ sub_blocks,
+ bodies,
+ ast
+ ) do
+ build(rest, block, [sub_block | sub_blocks], [body | bodies], context, ast)
+ end
+
+ defp do_build(
+ {:ok, [[{:end_block, _}] | last_body], context, rest},
+ block,
+ sub_blocks,
+ bodies,
+ ast
+ ),
+ do: Ast.build(rest, context, [close(block, sub_blocks, bodies, last_body) | ast])
+
+ defp do_build({:ok, acc, context, rest}, _, _, _, ast), do: {:ok, [acc | ast], context, rest}
+
+ defp do_build({:error, error, rest}, _, _, _, _), do: {:error, error, rest}
+
+ defp close({tag, body_block}, sub_blocks, bodies, last_body) do
+ all_blocks = [body_block | do_close(sub_blocks, bodies, last_body, [])] |> List.flatten()
+ {tag, all_blocks}
+ end
+
+ defp do_close([], [], last_body, all_blocks),
+ do: [{:body, Enum.reverse(last_body)} | all_blocks]
+
+ defp do_close([{sub_block, block_body} | sub_blocks], [body | bodies], current_body, all_blocks) do
+ block_body = block_body |> Keyword.put(:body, Enum.reverse(current_body)) |> Enum.reverse()
+ do_close(sub_blocks, bodies, body, [{sub_block, block_body} | all_blocks])
+ end
end
diff --git a/lib/liquid/combinators/general.ex b/lib/liquid/combinators/general.ex
new file mode 100644
index 00000000..40cb897c
--- /dev/null
+++ b/lib/liquid/combinators/general.ex
@@ -0,0 +1,391 @@
+defmodule Liquid.Combinators.General do
+ @moduledoc """
+ General purpose combinators used by almost every other combinator
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.LexicalToken
+
+ @type comparison_operators :: :== | :!= | :> | :< | :>= | :<= | :contains
+ @type conditions :: [
+ condition:
+ {LexicalToken.value(), comparison_operators(), LexicalToken.value()}
+ | [logical: [and: General.condition()]]
+ | [logical: [or: General.condition()]]
+ ]
+ @type liquid_variable :: [liquid_variable: LexicalToken.variable_value(), filters: [filter()]]
+ @type filter :: [filter: String.t(), params: [value: LexicalToken.value()]]
+
+ # Codepoints
+ @horizontal_tab 0x0009
+ @space 0x0020
+ @colon 0x003A
+ @point 0x002E
+ @newline 0x000A
+ @carriage_return 0x000D
+ @comma 0x002C
+ @single_quote 0x0027
+ @double_quote 0x0022
+ @question_mark 0x003F
+ @underscore 0x005F
+ @dash 0x002D
+ @equal 0x003D
+ @vertical_line 0x007C
+ @rigth_curly_bracket 0x007D
+ @start_tag "{%"
+ @end_tag "%}"
+ @start_variable "{{"
+ @end_variable "}}"
+ @start_filter "|"
+ @equals "=="
+ @does_not_equal "!="
+ @greater_than ">"
+ @less_than "<"
+ @greater_or_equal ">="
+ @less_or_equal "<="
+ @digit ?0..?9
+ @uppercase_letter ?A..?Z
+ @lowercase_letter ?a..?z
+
+ def codepoints do
+ %{
+ horizontal_tab: @horizontal_tab,
+ space: @space,
+ colon: @colon,
+ point: @point,
+ carriage_return: @carriage_return,
+ newline: @newline,
+ comma: @comma,
+ equal: @equal,
+ vertical_line: @vertical_line,
+ right_curly_bracket: @rigth_curly_bracket,
+ quote: @double_quote,
+ single_quote: @single_quote,
+ question_mark: @question_mark,
+ underscore: @underscore,
+ start_tag: @start_tag,
+ end_tag: @end_tag,
+ start_variable: @start_variable,
+ end_variable: @end_variable,
+ start_filter: @start_filter,
+ digit: @digit,
+ uppercase_letter: @uppercase_letter,
+ lowercase_letter: @lowercase_letter
+ }
+ end
+
+ @doc """
+ Horizontal Tab (U+0009) +
+ Space (U+0020) +
+ Carriage Return (U+000D)
+ New Line (U+000A)
+ """
+ def whitespace do
+ ascii_char([
+ @horizontal_tab,
+ @carriage_return,
+ @newline,
+ @space
+ ])
+ end
+
+ @doc """
+ Remove all :whitespace
+ """
+ def ignore_whitespaces do
+ whitespace()
+ |> repeat()
+ |> ignore()
+ end
+
+ @doc """
+ Comma without spaces
+ """
+ def cleaned_comma do
+ ignore_whitespaces()
+ |> concat(ascii_char([@comma]))
+ |> concat(ignore_whitespaces())
+ |> ignore()
+ end
+
+ @doc """
+ Start of liquid Tag
+ """
+ def start_tag do
+ empty()
+ |> string(@start_tag)
+ |> concat(ignore_whitespaces())
+ |> ignore()
+ end
+
+ @doc """
+ End of liquid Tag
+ """
+ def end_tag do
+ ignore_whitespaces()
+ |> concat(string(@end_tag))
+ |> ignore()
+ end
+
+ @doc """
+ Start of liquid Variable
+ """
+ def start_variable do
+ empty()
+ |> string(@start_variable)
+ |> concat(ignore_whitespaces())
+ |> ignore()
+ end
+
+ @doc """
+ End of liquid Variable
+ """
+ def end_variable do
+ ignore_whitespaces()
+ |> string(@end_variable)
+ |> ignore()
+ end
+
+ @doc """
+ Comparison operators:
+ == != > < >= <=
+ """
+ def comparison_operators do
+ empty()
+ |> choice([
+ string(@equals),
+ string(@greater_or_equal),
+ string(@less_or_equal),
+ string(@does_not_equal),
+ string(@greater_than),
+ string(@less_than),
+ string("contains")
+ ])
+ |> map({String, :to_atom, []})
+ end
+
+ @doc """
+ Logical operators:
+ `and` `or`
+ """
+ def logical_operators do
+ empty()
+ |> choice([
+ string("or"),
+ string("and"),
+ string(",") |> replace("or")
+ ])
+ |> map({String, :to_atom, []})
+ end
+
+ def condition do
+ empty()
+ |> parsec(:value_definition)
+ |> parsec(:comparison_operators)
+ |> parsec(:value_definition)
+ |> reduce({List, :to_tuple, []})
+ |> unwrap_and_tag(:condition)
+ end
+
+ def logical_condition do
+ logical_operators()
+ |> choice([parsec(:condition), parsec(:value_definition)])
+ |> tag(:logical)
+ end
+
+ @doc """
+ All utf8 valid characters or empty limited by start of tag
+ """
+ def literal_until_tag do
+ empty()
+ |> repeat_until(utf8_char([]), [string(@start_tag)])
+ |> reduce({List, :to_string, []})
+ end
+
+ defp allowed_chars do
+ [
+ @digit,
+ @uppercase_letter,
+ @lowercase_letter,
+ @underscore,
+ @dash
+ ]
+ end
+
+ @doc """
+ Valid variable definition represented by:
+ start char [A..Z, a..z, _] plus optional n times [A..Z, a..z, 0..9, _, -]
+ """
+ def variable_definition_for_assignment do
+ empty()
+ |> concat(ignore_whitespaces())
+ |> utf8_char([@uppercase_letter, @lowercase_letter, @underscore])
+ |> optional(times(utf8_char(allowed_chars()), min: 1))
+ |> concat(ignore_whitespaces())
+ |> reduce({List, :to_string, []})
+ end
+
+ def variable_name_for_assignment do
+ parsec(:variable_definition_for_assignment)
+ |> tag(:variable_name)
+ end
+
+ def variable_definition do
+ empty()
+ |> parsec(:variable_definition_for_assignment)
+ |> optional(utf8_char([@question_mark]))
+ |> concat(ignore_whitespaces())
+ |> reduce({List, :to_string, []})
+ end
+
+ @doc """
+ Valid variable name which is a tagged variable_definition
+ """
+ def variable_name do
+ parsec(:variable_definition)
+ |> unwrap_and_tag(:variable_name)
+ end
+
+ def quoted_variable_name do
+ ignore_whitespaces()
+ |> ignore(utf8_char([@single_quote]))
+ |> parsec(:variable_definition)
+ |> ignore(utf8_char([@single_quote]))
+ |> concat(ignore_whitespaces())
+ |> unwrap_and_tag(:variable_name)
+ end
+
+ def not_empty_liquid_variable do
+ start_variable()
+ |> parsec(:value_definition)
+ |> optional(times(parsec(:filters), min: 1))
+ |> concat(end_variable())
+ |> tag(:liquid_variable)
+ end
+
+ def empty_liquid_variable do
+ start_variable()
+ |> string("")
+ |> concat(end_variable())
+ |> tag(:liquid_variable)
+ end
+
+ def liquid_variable do
+ empty()
+ |> choice([empty_liquid_variable(), not_empty_liquid_variable()])
+ end
+
+ def single_quoted_token do
+ ignore_whitespaces()
+ |> concat(utf8_char([@single_quote]))
+ |> concat(repeat(utf8_char(not: @comma, not: @single_quote)))
+ |> concat(utf8_char([@single_quote]))
+ |> reduce({List, :to_string, []})
+ |> concat(ignore_whitespaces())
+ end
+
+ def double_quoted_token do
+ ignore_whitespaces()
+ |> concat(utf8_char([@double_quote]))
+ |> concat(repeat(utf8_char(not: @comma, not: @double_quote)))
+ |> concat(utf8_char([@double_quote]))
+ |> reduce({List, :to_string, []})
+ |> concat(ignore_whitespaces())
+ end
+
+ def quoted_token do
+ choice([double_quoted_token(), single_quoted_token()])
+ end
+
+ @doc """
+ Filter basic structure, it acepts any kind of filter with the following structure:
+ start char: '|' plus filter's parameters as optional: ':' plus optional: parameters values [value]
+ """
+ def filter_param do
+ empty()
+ |> optional(ignore(utf8_char([@colon])))
+ |> concat(ignore_whitespaces())
+ |> parsec(:value)
+ |> optional(ignore(utf8_char([@comma])))
+ |> optional(ignore_whitespaces())
+ |> optional(parsec(:value))
+ |> tag(:params)
+ end
+
+ def filters do
+ filter()
+ |> times(min: 1)
+ |> tag(:filters)
+ end
+
+ @doc """
+ Filter parameters structure: it acepts any kind of parameters with the following structure:
+ start char: ':' plus optional: parameters values [value]
+ """
+ def filter do
+ ignore_whitespaces()
+ |> ignore(string(@start_filter))
+ |> concat(ignore_whitespaces())
+ |> utf8_string(
+ [not: @colon, not: @vertical_line, not: @rigth_curly_bracket, not: @space],
+ min: 1
+ )
+ |> concat(ignore_whitespaces())
+ |> reduce({List, :to_string, []})
+ |> optional(filter_param())
+ |> tag(:filter)
+ |> optional(parsec(:filter))
+ end
+
+ @doc """
+ Parse and ignore an assign symbol
+ """
+ def assignment(symbol) do
+ empty()
+ |> optional(cleaned_comma())
+ |> parsec(:variable_name)
+ |> ignore(utf8_char([symbol]))
+ |> parsec(:value)
+ end
+
+ def tag_param(name) do
+ empty()
+ |> concat(ignore_whitespaces())
+ |> ignore(string(name))
+ |> ignore(ascii_char([@colon]))
+ |> concat(ignore_whitespaces())
+ |> choice([parsec(:number), parsec(:variable_definition)])
+ |> concat(ignore_whitespaces())
+ |> tag(String.to_atom(name))
+ end
+
+ def conditions(combinator) do
+ combinator
+ |> choice([
+ parsec(:condition),
+ parsec(:value_definition),
+ parsec(:variable_definition)
+ ])
+ |> optional(times(parsec(:logical_condition), min: 1))
+ |> tag(:conditions)
+ end
+
+ @doc """
+ Parses a `Liquid` tag name, isolates tag name from markup. It represents the tag name parsed
+ until end tag `%}`
+ """
+ @spec valid_tag_name() :: NimbleParsec.t()
+ def valid_tag_name do
+ empty()
+ |> repeat_until(utf8_char([]), [
+ string(" "),
+ string("%}"),
+ ascii_char([
+ @horizontal_tab,
+ @carriage_return,
+ @newline,
+ @space
+ ])
+ ])
+ |> reduce({List, :to_string, []})
+ end
+end
diff --git a/lib/liquid/combinators/lexical_token.ex b/lib/liquid/combinators/lexical_token.ex
new file mode 100644
index 00000000..42e01478
--- /dev/null
+++ b/lib/liquid/combinators/lexical_token.ex
@@ -0,0 +1,181 @@
+defmodule Liquid.Combinators.LexicalToken do
+ @moduledoc """
+ String with an assigned and thus identified meaning such as
+ - Punctuator
+ - Number
+ - String
+ - Boolean
+ - List
+ - Object
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.General
+
+ @type variable_value ::
+ {:variable, [parts: [part: String.t(), index: integer() | variable_value]]}
+ @type value :: number() | boolean() | nil | String.t() | Range.t() | variable_value()
+
+ # NegativeSign :: -
+ @negative_sign ascii_char([?-])
+
+ # Digit :: one of 0 1 2 3 4 5 6 7 8 9
+ @digit ascii_char([?0..?9])
+
+ # NonZeroDigit :: Digit but not `0`
+ @non_zero_digit ascii_char([?1..?9])
+
+ # IntegerPart ::
+ # - NegativeSign? 0
+ # - NegativeSign? NonZeroDigit Digit*
+ @integer_part empty()
+ |> optional(@negative_sign)
+ |> choice([
+ ascii_char([?0]),
+ @non_zero_digit |> repeat(@digit)
+ ])
+
+ # IntValue :: IntegerPart
+ @integer_value reduce(@integer_part, {List, :to_integer, []})
+
+ # FractionalPart :: . Digit+
+ @fractional_part empty()
+ |> ascii_char([?.])
+ |> times(@digit, min: 1)
+
+ # ExponentIndicator :: one of `e` `E`
+ @exponent_indicator ascii_char([?e, ?E])
+
+ # Sign :: one of + -
+ @sign ascii_char([?+, ?-])
+
+ # ExponentPart :: ExponentIndicator Sign? Digit+
+ @exponent_part @exponent_indicator
+ |> optional(@sign)
+ |> times(@digit, min: 1)
+
+ # FloatValue ::
+ # - IntegerPart FractionalPart
+ # - IntegerPart ExponentPart
+ # - IntegerPart FractionalPart ExponentPart
+ @float_value empty()
+ |> choice([
+ @integer_part |> concat(@fractional_part) |> concat(@exponent_part),
+ @integer_part |> concat(@fractional_part)
+ ])
+ |> reduce({List, :to_float, []})
+
+ @double_quoted_string empty()
+ |> ignore(ascii_char([?"]))
+ |> repeat_until(utf8_char([]), [ascii_char([?"])])
+ |> ignore(ascii_char([?"]))
+
+ @quoted_string empty()
+ |> ignore(ascii_char([?']))
+ |> repeat_until(utf8_char([]), [ascii_char([?'])])
+ |> ignore(ascii_char([?']))
+
+ # StringValue ::
+ # - `"` StringCharacter* `"`
+ # - `'` StringCharacter* `'`
+ @string_value empty()
+ |> choice([@double_quoted_string, @quoted_string])
+ |> reduce({List, :to_string, []})
+
+ # BooleanValue : one of `true` `false`
+ @boolean_value empty()
+ |> choice([
+ string("true"),
+ string("false")
+ ])
+ |> map({String, :to_atom, []})
+
+ # NullValue : `nil`
+ @null_value replace(choice([string("nil"), string("null"), string("NIL"), string("NULL")]), nil)
+
+ def number do
+ choice([@float_value, @integer_value])
+ end
+
+ defp range_limit(combinator \\ empty(), tag_name) do
+ combinator
+ |> choice([@integer_value, variable_value()])
+ |> unwrap_and_tag(tag_name)
+ end
+
+ # RangeValue :: (1..10) | (var..10) | (1..var) | (var1..var2) | (1..var.content[0])
+ defp range_value do
+ string("(")
+ |> ignore()
+ |> range_limit(:start)
+ |> ignore(string(".."))
+ |> concat(range_limit(:end))
+ |> ignore(string(")"))
+ |> tag(:range)
+ end
+
+ # Value[Const] :
+ # - Number
+ # - StringValue
+ # - BooleanValue
+ # - NullValue
+ # - ListValue[?Const]
+ # - Variable
+ def value_definition do
+ General.ignore_whitespaces()
+ |> choice([
+ number(),
+ @boolean_value,
+ @null_value,
+ @string_value,
+ range_value(),
+ variable_value()
+ ])
+ |> concat(General.ignore_whitespaces())
+ end
+
+ def variable_value, do: tag(object_value(), :variable)
+
+ @spec value :: value
+ def value do
+ parsec(:value_definition)
+ |> unwrap_and_tag(:value)
+ end
+
+ def object_property do
+ string(".")
+ |> ignore()
+ |> parsec(:variable_part)
+ |> optional(times(list_index(), min: 1))
+ end
+
+ def variable_part do
+ parsec(:variable_definition)
+ |> unwrap_and_tag(:part)
+ end
+
+ def object_value do
+ parsec(:variable_part)
+ |> optional(choice([times(list_index(), min: 1), times(object_property(), min: 1)]))
+ |> tag(:parts)
+ |> optional(parsec(:filters))
+ end
+
+ defp list_definition do
+ choice([
+ @integer_value,
+ @string_value,
+ parsec(:variable_value)
+ ])
+ end
+
+ defp list_index do
+ string("[")
+ |> ignore()
+ |> concat(General.ignore_whitespaces())
+ |> concat(optional(list_definition()))
+ |> concat(General.ignore_whitespaces())
+ |> ignore(string("]"))
+ |> unwrap_and_tag(:index)
+ |> optional(parsec(:object_property))
+ end
+end
diff --git a/lib/liquid/combinators/tag.ex b/lib/liquid/combinators/tag.ex
new file mode 100644
index 00000000..a57de19a
--- /dev/null
+++ b/lib/liquid/combinators/tag.ex
@@ -0,0 +1,149 @@
+defmodule Liquid.Combinators.Tag do
+ @moduledoc """
+ Helper to create tags
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.General
+
+ @doc """
+ Define a block from a tag_name and, optionally, a function to parse tag parameters,
+ and a function to parse the body inside the tag
+ Both functions must receive a combinator and must return a combinator
+
+ The returned tag is a combinator which expect a start tag `{%` a tag name and a end tag `%}`
+
+ ## Examples
+
+ Tag.define_closed(
+ "comment",
+ & &1,
+ fn combinator ->
+ combinator
+ |> optional(parsec(:comment_content))
+ |> reduce({Markup, :literal, []})
+ end,
+ ""
+ """
+ @spec define_closed(String.t(), fun(), fun(), String.t()) :: fun()
+ def define_closed(tag_name, combinator_head \\ & &1, combinator_body \\ & &1, separator \\ " ")
+
+ def define_closed(tag_name, combinator_head, combinator_body, separator) do
+ tag_name
+ |> open_tag(combinator_head, separator)
+ |> combinator_body.()
+ |> close_tag(tag_name)
+ |> tag(String.to_atom(tag_name))
+ end
+
+ @doc """
+ Define a tag from a tag_name and, optionally, a function to parse tag parameters,
+ the tag and a function to parse the body inside the tag
+ Both functions must receive a combinator and must return a combinator
+
+ The returned tag is a combinator which expect a start tag `{%` a tag name and a end tag `%}`
+
+ ## Examples
+
+ defmodule MyParser do
+ import NimbleParsec
+ alias Liquid.Combinators.Tag
+
+ def ignorable, do: Tag.define_closed(
+ "ignorable",
+ fn combinator -> combinator |> string("T") |> ignore() |> integer(2,2))
+
+ MyParser.ignorable("{% ignorable T12 %}")
+ #=> {:ok, {:ignorable, [12]}, "", %{}, {1, 0}, 2}
+ """
+ @spec define_open(String.t(), fun()) :: fun()
+ def define_open(tag_name, combinator_head \\ & &1) do
+ tag_name
+ |> open_tag(combinator_head)
+ |> tag(String.to_atom(tag_name))
+ end
+
+ @doc """
+ Creates a new combinator to parse subblocks (else, elsif, when)
+ """
+ @spec define_sub_block(binary(), list(), function()) :: function()
+ def define_sub_block(tag_name, allowed_tags, combinator \\ & &1) do
+ General.start_tag()
+ |> ignore(string(tag_name))
+ |> combinator.()
+ |> concat(General.end_tag())
+ |> tag(String.to_atom(tag_name))
+ |> tag(:sub_block)
+ |> traverse({__MODULE__, :check_allowed_tags, [allowed_tags]})
+ end
+
+ @doc """
+ Creates a new combinator to parse blocks (if, for, tablerow, etc)
+ """
+ @spec define_block(binary(), function(), binary()) :: function()
+ def define_block(tag_name, combinator_head \\ & &1, separator \\ " ")
+
+ def define_block(tag_name, combinator_head, separator) do
+ tag_name
+ |> open_tag(combinator_head, separator)
+ |> tag(String.to_atom(tag_name))
+ |> tag(:block)
+ |> traverse({__MODULE__, :store_tag_in_context, []})
+ end
+
+ @doc """
+ Creates a new combinator to parse open tags.
+ An open tag is a open tag symbol `{%` and a name
+ """
+ @spec open_tag(binary(), function(), binary()) :: function()
+ def open_tag(tag_name, combinator \\ & &1, separator \\ " ")
+
+ def open_tag(tag_name, combinator, separator) do
+ General.start_tag()
+ |> ignore(string(tag_name <> separator))
+ |> combinator.()
+ |> concat(General.end_tag())
+ end
+
+ @doc """
+ Creates a new combinator to parse the close of tags.
+ The close of a tag is a close tag symbol `%}`
+ """
+ @spec close_tag(function(), binary()) :: function()
+ def close_tag(combinator \\ empty(), tag_name) do
+ combinator
+ |> concat(General.start_tag())
+ |> ignore(string("end" <> tag_name))
+ |> concat(General.end_tag())
+ end
+
+ def store_tag_in_context(_, [{:block, [{tag_name, _}]}] = acc, %{tags: tags} = context, _, _) do
+ {acc, %{context | tags: [to_string(tag_name) | tags]}}
+ end
+
+ @doc """
+ Returns a valid tag when the tag is inside an allowed tag, else returns an error
+ """
+ @spec check_allowed_tags(binary(), list(), tuple(), tuple(), integer(), list()) :: tuple()
+ def check_allowed_tags(_rest, acc, %{tags: []} = context, _line, _offset, _allowed_tags) do
+ tag_name = tag_name(acc)
+ {[error: "Unexpected outer '#{tag_name}' tag"], context}
+ end
+
+ def check_allowed_tags(_rest, acc, %{tags: [tag | _]} = context, _line, _offset, allowed_tags) do
+ tag_name = tag_name(acc)
+
+ if Enum.member?(allowed_tags, tag) do
+ {acc, context}
+ else
+ {[
+ error:
+ "#{tag} does not expect #{tag_name} tag. The #{tag_name} tag is valid only inside: #{
+ Enum.join(allowed_tags, ", ")
+ }"
+ ], context}
+ end
+ end
+
+ defp tag_name([{:sub_block, [{tag, _}]}]), do: tag
+ defp tag_name([{:sub_block, [tag]}]), do: tag
+end
diff --git a/lib/liquid/combinators/tags/assign.ex b/lib/liquid/combinators/tags/assign.ex
new file mode 100644
index 00000000..3191b57b
--- /dev/null
+++ b/lib/liquid/combinators/tags/assign.ex
@@ -0,0 +1,32 @@
+defmodule Liquid.Combinators.Tags.Assign do
+ @moduledoc """
+ Sets variables in a template.
+ ```
+ {% assign foo = 'monkey' %}
+ ```
+ User can then use the variables later in the page.
+ ```
+ {{ foo }}
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{General, Tag, LexicalToken}
+
+ @type t :: [assign: Assign.markup()]
+
+ @type markup :: [variable_name: String.t(), value: LexicalToken.value()]
+
+ @doc """
+ Parses a `Liquid` Assign tag, creates a Keyword list where the key is the name of the tag
+ (assign in this case) and the value is another keyword list which represent the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ Tag.define_open("assign", fn combinator ->
+ combinator
+ |> concat(General.assignment(General.codepoints().equal))
+ |> optional(General.filters())
+ end)
+ end
+end
diff --git a/lib/liquid/combinators/tags/capture.ex b/lib/liquid/combinators/tags/capture.ex
new file mode 100644
index 00000000..8fd2c3b1
--- /dev/null
+++ b/lib/liquid/combinators/tags/capture.ex
@@ -0,0 +1,37 @@
+defmodule Liquid.Combinators.Tags.Capture do
+ @moduledoc """
+ Stores the result of a block into a variable without rendering it in place.
+ ```
+ {% capture heading %}
+ Monkeys!
+ {% endcapture %}
+ ...
+ {{ heading }}
+ ```
+ Capture is useful for saving content for use later in your template, such as in a sidebar or footer.
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{Tag, General}
+
+ @type t :: [capture: Capture.markup()]
+
+ @type markup :: [
+ variable_name: String.t(),
+ body: Liquid.NimbleParser.t()
+ ]
+
+ @doc """
+ Parses a `Liquid` Capture tag, creates a Keyword list where the key is the name of the tag
+ (capture in this case) and the value is another keyword list which represent the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ Tag.define_block("capture", fn combinator ->
+ choice(combinator, [
+ General.quoted_variable_name(),
+ General.variable_name()
+ ])
+ end)
+ end
+end
diff --git a/lib/liquid/combinators/tags/case.ex b/lib/liquid/combinators/tags/case.ex
new file mode 100644
index 00000000..27a6172c
--- /dev/null
+++ b/lib/liquid/combinators/tags/case.ex
@@ -0,0 +1,54 @@
+defmodule Liquid.Combinators.Tags.Case do
+ @moduledoc """
+ Creates a switch statement to compare a variable against different values.
+ `case` initializes the switch statement, and `when` compares its values.
+ Input:
+ ```
+ {% assign handle = 'cake' %}
+ {% case handle %}
+ {% when 'cake' %}
+ This is a cake
+ {% when 'cookie' %}
+ This is a cookie
+ {% else %}
+ This is not a cake nor a cookie
+ {% endcase %}
+ ```
+ Output:
+ ```
+ This is a cake
+ ```
+ """
+
+ alias Liquid.Combinators.{Tag, General}
+
+ @type t :: [case: Case.markup()]
+
+ @type markup :: [
+ variable: LexicalToken.value(),
+ clauses: [
+ String.t()
+ | [
+ when: [
+ conditions: [LexicalToken.value() | {:logical, [or: LexicalToken.value()]}],
+ body: Liquid.NimbleParser.t()
+ ]
+ ]
+ ]
+ ]
+
+ @doc """
+ Parses a `Liquid` Case tag, creates a Keyword list where the key is the name of the tag
+ (case in this function) and the value is another keyword list which represents the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag, do: Tag.define_block("case", &General.conditions/1)
+
+ @doc """
+ Parse When tag clauses.
+ """
+ def when_tag do
+ Tag.define_sub_block("when", ["case"], &General.conditions/1)
+ end
+end
diff --git a/lib/liquid/combinators/tags/comment.ex b/lib/liquid/combinators/tags/comment.ex
new file mode 100644
index 00000000..ab8c0c3e
--- /dev/null
+++ b/lib/liquid/combinators/tags/comment.ex
@@ -0,0 +1,111 @@
+defmodule Liquid.Combinators.Tags.Comment do
+ @moduledoc """
+ Allows you to leave un-rendered code inside a Liquid template.
+ Any text within the opening and closing comment blocks will not be output,
+ and any Liquid code within will not be executed
+ Input:
+ ```
+ Anything you put between {% comment %} and {% endcomment %} tags
+ is turned into a comment.
+ ```
+ Output:
+ ```
+ Anything you put between tags
+ is turned into a comment
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{General, Tag}
+ alias Liquid.Translators.Markup
+
+ @type t :: [comment: Comment.markup()]
+
+ @type markup :: [String.t() | Comment.t() | Raw.t()]
+
+ @doc """
+ Parses Comment content, creating a keyword list, the value of this list is the internal behaviour of the comment tag.
+ """
+ @spec comment_content() :: NimbleParsec.t()
+ def comment_content do
+ General.literal_until_tag()
+ |> optional(
+ choice([
+ parsec(:comment) |> optional(parsec(:comment_content)),
+ parsec(:raw) |> optional(parsec(:comment_content)),
+ any_tag() |> optional(parsec(:comment_content))
+ ])
+ )
+ |> concat(General.literal_until_tag())
+ end
+
+ @doc """
+ Parses a `Liquid` Comment tag, creates a Keyword list where the key is the name of the tag
+ (comment in this case) and the value is another keyword list which represents the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ Tag.define_closed(
+ "comment",
+ & &1,
+ fn combinator ->
+ combinator
+ |> optional(parsec(:comment_content))
+ |> reduce({Markup, :literal, []})
+ end,
+ ""
+ )
+ end
+
+ @doc """
+ Combinator that parse the syntax of a tag ({% anything_here %})but not of a valid `Liquid` tag.
+ """
+ @spec any_tag() :: NimbleParsec.t()
+ def any_tag do
+ empty()
+ |> string(General.codepoints().start_tag)
+ |> optional(repeat(General.whitespace()))
+ |> choice([
+ string_with_comment(),
+ string_with_endcomment(),
+ string_without_comment()
+ ])
+ |> reduce({List, :to_string, []})
+ |> string(General.codepoints().end_tag)
+ end
+
+ @doc """
+ Combinator that parse a string that can contain a "endcomment" string in it.
+ """
+ @spec string_with_endcomment() :: NimbleParsec.t()
+ def string_with_endcomment do
+ utf8_char([])
+ |> concat(string_without_comment())
+ |> concat(string("endcomment"))
+ |> optional(string_without_comment())
+ end
+
+ @doc """
+ Combinator that parse a string that can contain a "comment" string in it.
+ """
+ @spec string_with_comment() :: NimbleParsec.t()
+ def string_with_comment do
+ string_without_comment()
+ |> concat(string("comment"))
+ |> concat(string_without_comment())
+ end
+
+ @doc """
+ Combinator that parse a string that can not contain a "comment" or "endcomment" string in it.
+ """
+ @spec string_without_comment() :: NimbleParsec.t()
+ def string_without_comment do
+ empty()
+ |> repeat_until(utf8_char([]), [
+ string(General.codepoints().start_tag),
+ string(General.codepoints().end_tag),
+ string("endcomment"),
+ string("comment")
+ ])
+ end
+end
diff --git a/lib/liquid/combinators/tags/custom_tag.ex b/lib/liquid/combinators/tags/custom_tag.ex
new file mode 100644
index 00000000..f62a1460
--- /dev/null
+++ b/lib/liquid/combinators/tags/custom_tag.ex
@@ -0,0 +1,65 @@
+defmodule Liquid.Combinators.Tags.CustomTag do
+ @moduledoc """
+ Implementation of custom tag. "Tags" are tags that take any number of arguments, but do not contain a block of template code.
+ To create a new tag, Use Liquid.Register module and register your tag with Liquid.Register.register/3.
+ The register tag takes three arguments: the user-facing name of the tag, the module where code of parsing/rendering is located
+ and the type that implements it (tag or block).
+
+ ```
+ {% MyCustomTag argument1 = 1, argument2, argument3 = 5 %}
+ ```
+
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.General
+
+ @type t :: [custom_tag: Custom_tag.markup()]
+ @type markup :: [custom_name: String.t(), custom_markup: [String.t()]]
+
+ @doc """
+ Parses a `Liquid` Custom tag, creates a Keyword list where the key is the name of the custom tag
+ (custom_tag in this case) and the value is another keyword list which represent the internal
+ structure of the tag (arguments).
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ General.start_tag()
+ |> concat(General.valid_tag_name())
+ |> optional(markup())
+ |> concat(General.end_tag())
+ |> traverse({__MODULE__, :check_customs, []})
+ end
+
+ def check_customs(_rest, [params | tag], %{tags: tags} = context, _line, _offset) do
+ [tag_name] = tag
+ name = String.to_atom(tag_name)
+
+ Application.get_env(:liquid, :extra_tags, %{})
+ |> Map.get(name)
+ |> case do
+ nil ->
+ {[
+ error:
+ "Error processing tag '#{tag}'. It is malformed or you are creating a custom '#{tag}' without register it"
+ ], context}
+
+ {_, Liquid.Block} ->
+ {[block: [custom: [{:custom_name, tag}, params]]], %{context | tags: [tag_name | tags]}}
+
+ {_, Liquid.Tag} ->
+ {[custom: [{:custom_name, tag}, params]], context}
+ end
+ end
+
+ defp markup do
+ empty()
+ |> concat(General.ignore_whitespaces())
+ |> concat(valid_markup())
+ |> reduce({List, :to_string, []})
+ |> unwrap_and_tag(:custom_markup)
+ end
+
+ defp valid_markup do
+ repeat_until(utf8_char([]), [string("{%"), string("%}"), string("{{"), string("}}")])
+ end
+end
diff --git a/lib/liquid/combinators/tags/cycle.ex b/lib/liquid/combinators/tags/cycle.ex
new file mode 100644
index 00000000..3899c651
--- /dev/null
+++ b/lib/liquid/combinators/tags/cycle.ex
@@ -0,0 +1,82 @@
+defmodule Liquid.Combinators.Tags.Cycle do
+ @moduledoc """
+ Implementation of `cycle` tag. Can be named or anonymous, rotates through pre-set values
+ Cycle is usually used within a loop to alternate between values, like colors or DOM classes.
+ ```
+ {% for item in items %}
+ {{ item }}
+ {% end %}
+ ```
+ ```
+ Item one
+ Item two
+ Item three
+ Item four
+ Item five
+ ```
+ Loops through a group of strings and outputs them in the order that they were passed as parameters.
+ Each time cycle is called, the next string that was passed as a parameter is output.
+ cycle must be used within a for loop block.
+ Input:
+ ```
+ {% cycle 'one', 'two', 'three' %}
+ {% cycle 'one', 'two', 'three' %}
+ {% cycle 'one', 'two', 'three' %}
+ {% cycle 'one', 'two', 'three' %}
+ ```
+ Output:
+ ```
+ one
+ two
+ three
+ one
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{Tag, General, LexicalToken}
+
+ @type t :: [cycle: Cycle.markup()]
+ @type markup :: [group: String.t(), values: [LexicalToken.value()]]
+
+ defp group do
+ General.ignore_whitespaces()
+ |> concat(
+ choice([
+ General.quoted_token(),
+ repeat(utf8_char(not: ?,, not: ?:))
+ ])
+ )
+ |> ignore(utf8_char([?:]))
+ |> reduce({List, :to_string, []})
+ |> tag(:group)
+ end
+
+ defp body, do: tag(cycle_values(), :values)
+
+ @doc """
+ Groups the values of the cycle and creates a regular list containing the results
+ of the `Liquid.Combinators.LexicalToken.value_definition()` combinator.
+ """
+ @spec cycle_values() :: NimbleParsec.t()
+ def cycle_values do
+ empty()
+ |> times(LexicalToken.value_definition(), min: 1)
+ |> optional(ignore(utf8_char([General.codepoints().comma])))
+ |> optional(parsec(:cycle_values))
+ end
+
+ @doc """
+ Parses a `Liquid` Cycle tag, creates a Keyword list where the key is the name of the tag
+ (cycle in this case) and the value is another keyword list which represent the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ Tag.define_open("cycle", fn combinator ->
+ combinator
+ |> optional(group())
+ |> concat(General.ignore_whitespaces())
+ |> concat(body())
+ end)
+ end
+end
diff --git a/lib/liquid/combinators/tags/decrement.ex b/lib/liquid/combinators/tags/decrement.ex
new file mode 100644
index 00000000..f625f662
--- /dev/null
+++ b/lib/liquid/combinators/tags/decrement.ex
@@ -0,0 +1,41 @@
+defmodule Liquid.Combinators.Tags.Decrement do
+ @moduledoc """
+ Creates a new number variable, and decreases its value by one every time it is called.
+ The initial value is -1.
+ Decrement is used in a place where one needs to insert a counter into a template,
+ and needs the counter to survive across
+ multiple instantiations of the template.
+ NOTE: decrement is a pre-decrement, -i, while increment is post: i+.
+ (To achieve the survival, the application must keep the context)
+
+ if the variable does not exist, it is created with value -1:
+ Input:
+ ```
+ Hello: {% decrement variable %}
+ ```
+ Output:
+ ```
+ Hello: -1
+ Hello: -2
+ Hello: -3
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{Tag, LexicalToken}
+
+ @type t :: [decrement: Decrement.markup()]
+
+ @type markup :: LexicalToken.variable_value()
+
+ @doc """
+ Parses a `Liquid` Decrement tag, creates a Keyword list where the key is the name of the tag
+ (decrement in this case) and the value is another keyword list, that represent the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ Tag.define_open("decrement", fn combinator ->
+ concat(combinator, LexicalToken.variable_value())
+ end)
+ end
+end
diff --git a/lib/liquid/combinators/tags/end_block.ex b/lib/liquid/combinators/tags/end_block.ex
new file mode 100644
index 00000000..b2c9f8ea
--- /dev/null
+++ b/lib/liquid/combinators/tags/end_block.ex
@@ -0,0 +1,17 @@
+defmodule Liquid.Combinators.Tags.EndBlock do
+ @moduledoc """
+ Verifies when block is closed and send the AST to end the block
+ """
+ alias Liquid.Combinators.General
+
+ import NimbleParsec
+
+ def tag do
+ General.start_tag()
+ |> ignore(string("end"))
+ |> concat(General.valid_tag_name())
+ |> tag(:tag_name)
+ |> concat(General.end_tag())
+ |> tag(:end_block)
+ end
+end
diff --git a/lib/liquid/combinators/tags/for.ex b/lib/liquid/combinators/tags/for.ex
new file mode 100644
index 00000000..94cc332c
--- /dev/null
+++ b/lib/liquid/combinators/tags/for.ex
@@ -0,0 +1,121 @@
+defmodule Liquid.Combinators.Tags.For do
+ @moduledoc """
+ "for" tag iterates over an array or collection.
+ Several useful variables are available to you within the loop.
+
+ Basic usage:
+ ```
+ {% for item in collection %}
+ {{ forloop.index }}: {{ item.name }}
+ {% endfor %}
+ ```
+ Advanced usage:
+ ```
+ {% for item in collection %}
+
+ Item {{ forloop.index }}: {{ item.name }}
+
+ {% else %}
+ There is nothing in the collection.
+ {% endfor %}
+ ```
+ You can also define a limit and offset much like SQL. Remember
+ that offset starts at 0 for the first item.
+ ```
+ {% for item in collection limit:5 offset:10 %}
+ {{ item.name }}
+ {% end %}
+ ```
+ To reverse the for loop simply use {% for item in collection reversed %}
+
+ Available variables:
+ ```
+ forloop.name:: 'item-collection'
+ forloop.length:: Length of the loop
+ forloop.index:: The current item's position in the collection;
+ forloop.index starts at 1.
+ This is helpful for non-programmers who start believe
+ the first item in an array is 1, not 0.
+ forloop.index0:: The current item's position in the collection
+ where the first item is 0
+ forloop.rindex:: Number of items remaining in the loop
+ (length - index) where 1 is the last item.
+ forloop.rindex0:: Number of items remaining in the loop
+ where 0 is the last item.
+ forloop.first:: Returns true if the item is the first item.
+ forloop.last:: Returns true if the item is the last item.
+ forloop.parentloop:: Provides access to the parent loop, if present.
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{General, Tag, LexicalToken}
+ alias Liquid.Combinators.Tags.Generic
+
+ @type t :: [for: For.markup()]
+ @type markup :: [
+ statements: [
+ variable: String.t(),
+ value: LexicalToken.value(),
+ params: [
+ [offset: Integer.t() | String.t()]
+ | [limit: Integer.t() | String.t()]
+ ],
+ body:
+ Liquid.NimbleParser.t()
+ | Generic.else_tag()
+ ]
+ ]
+
+ @doc """
+ Parses a `Liquid` Continue tag, this is used for a internal behavior of the `for` tag,
+ creates a keyword list with a key `continue` and the value is an empty list.
+ """
+ @spec continue_tag() :: NimbleParsec.t()
+ def continue_tag, do: Tag.define_open("continue")
+
+ @doc """
+ Parses a `Liquid` Break tag, this is used for a internal behavior of the `for` tag,
+ creates a keyword list with a key `break` and the value is an empty list.
+ """
+ @spec break_tag() :: NimbleParsec.t()
+ def break_tag, do: Tag.define_open("break")
+
+ @doc """
+ Parses a `Liquid` For tag, creates a Keyword list where the key is the name of the tag
+ (for in this case) and the value is another keyword list which represents the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag, do: Tag.define_block("for", &statements/1)
+
+ defp statements(combinator) do
+ combinator
+ |> concat(LexicalToken.variable_value())
+ |> concat(General.ignore_whitespaces())
+ |> ignore(string("in"))
+ |> concat(General.ignore_whitespaces())
+ |> concat(LexicalToken.value())
+ |> optional(params())
+ |> concat(General.ignore_whitespaces())
+ |> tag(:statements)
+ end
+
+ defp reversed_param do
+ empty()
+ |> concat(General.ignore_whitespaces())
+ |> ignore(string("reversed"))
+ |> concat(General.ignore_whitespaces())
+ |> tag(:reversed)
+ end
+
+ defp params do
+ empty()
+ |> optional(
+ times(
+ choice([General.tag_param("offset"), General.tag_param("limit"), reversed_param()]),
+ min: 1
+ )
+ )
+ |> tag(:params)
+ end
+end
diff --git a/lib/liquid/combinators/tags/generic.ex b/lib/liquid/combinators/tags/generic.ex
new file mode 100644
index 00000000..3b4314cd
--- /dev/null
+++ b/lib/liquid/combinators/tags/generic.ex
@@ -0,0 +1,19 @@
+defmodule Liquid.Combinators.Tags.Generic do
+ @moduledoc """
+ Secondary tags used inside primary tags.
+ We defined a tag as secondary when it needs a primary tag to work.
+ For example, `else` tag is used by `if`, `for` and `cycle` but id doesn't
+ work alone
+ """
+ alias Liquid.Combinators.Tag
+
+ @type else_tag :: [else: Liquid.NimbleParser.t()]
+
+ @doc """
+ Parses a `Liquid` Else tag, creates a Keyword list where the key is the name of the tag
+ (else in this case) and the value is another keyword list which represent the internal
+ structure of the tag.
+ """
+ @spec else_tag() :: NimbleParsec.t()
+ def else_tag, do: Tag.define_sub_block("else", ["if", "unless", "case", "for"])
+end
diff --git a/lib/liquid/combinators/tags/if.ex b/lib/liquid/combinators/tags/if.ex
new file mode 100644
index 00000000..812a574b
--- /dev/null
+++ b/lib/liquid/combinators/tags/if.ex
@@ -0,0 +1,57 @@
+defmodule Liquid.Combinators.Tags.If do
+ @moduledoc """
+ Executes a block of code only if a certain condition is true.
+ If this condition is false executes `else` block of code.
+ Input:
+ ```
+ {% if product.title == 'Awesome Shoes' %}
+ These shoes are awesome!
+ {% else %}
+ These shoes are ugly!
+ {% endif %}
+ ```
+ Output:
+ ```
+ These shoes are ugly!
+ ```
+ """
+ alias Liquid.Combinators.{Tag, General}
+
+ @type t :: [if: conditional_body()]
+ @type unless_tag :: [unless: conditional_body()]
+ @type conditional_body :: [
+ conditions: General.conditions(),
+ body: [
+ Liquid.NimbleParser.t()
+ | [elsif: conditional_body()]
+ | [else: Liquid.NimbleParser.t()]
+ ]
+ ]
+
+ @doc """
+ Parses a `Liquid` If tag, creates a Keyword list where the key is the name of the tag
+ (if in this case) and the value is another keyword list which represent the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag, do: do_tag("if")
+
+ @doc """
+ Parses a `Liquid` Unless tag, creates a Keyword list where the key is the name of the tag
+ (unless in this case) and the value is another keyword list, that represent the internal
+ structure of the tag.
+ """
+ @spec unless_tag() :: NimbleParsec.t()
+ def unless_tag, do: do_tag("unless")
+
+ @doc """
+ Parses a `Liquid` Elsif tag, creates a Keyword list where the key is the name of the tag
+ (elsif in this case) and the value is the result of the `body_elsif()` combinator.
+ """
+ @spec elsif_tag() :: NimbleParsec.t()
+ def elsif_tag, do: Tag.define_sub_block("elsif", ["if", "unless"], &General.conditions/1)
+
+ defp do_tag(name) do
+ Tag.define_block(name, &General.conditions/1)
+ end
+end
diff --git a/lib/liquid/combinators/tags/ifchanged.ex b/lib/liquid/combinators/tags/ifchanged.ex
new file mode 100644
index 00000000..a4b35b66
--- /dev/null
+++ b/lib/liquid/combinators/tags/ifchanged.ex
@@ -0,0 +1,23 @@
+defmodule Liquid.Combinators.Tags.Ifchanged do
+ @moduledoc """
+ The block contained within ifchanged will only be rendered to the output if the last call to ifchanged returned different output.
+
+ Here is an example:
+
+ Product Listing
+ {% for product in products %}
+ {% ifchanged %}{{ product.created_at | date:"%w" }}
{% endifchanged %}
+ {{ product.title }}
+ ...
+ {% endfor %}
+ """
+ alias Liquid.Combinators.Tag
+
+ @doc """
+ Parses a `Liquid` IfChanged tag, creates a Keyword list where the key is the name of the tag
+ (ifchanged in this case) and the value is another keyword list which represent the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag, do: Tag.define_block("ifchanged", & &1, "")
+end
diff --git a/lib/liquid/combinators/tags/include.ex b/lib/liquid/combinators/tags/include.ex
new file mode 100644
index 00000000..f71170e0
--- /dev/null
+++ b/lib/liquid/combinators/tags/include.ex
@@ -0,0 +1,46 @@
+defmodule Liquid.Combinators.Tags.Include do
+ @moduledoc """
+ Include enables the possibility to include and render other liquid templates.
+ Templates can also be recursively included.
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{Tag, General, LexicalToken}
+
+ @type t :: [include: Include.markup()]
+
+ @type markup :: [
+ variable_name: String.t(),
+ params: [assignment: [variable_name: String.t(), value: LexicalToken.value()]]
+ ]
+
+ @doc """
+ Parses a `Liquid` Include tag, creates a Keyword list where the key is the name of the tag
+ (include in this case) and the value is another keyword list which represents the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag, do: Tag.define_open("include", &head/1)
+
+ def tag2, do: head(empty())
+
+ defp params do
+ General.codepoints().colon
+ |> General.assignment()
+ |> tag(:assignment)
+ |> times(min: 1)
+ |> tag(:params)
+ end
+
+ defp predicate(name) do
+ empty()
+ |> ignore(string(name))
+ |> concat(LexicalToken.value_definition())
+ |> tag(String.to_atom(name))
+ end
+
+ defp head(combinator) do
+ combinator
+ |> concat(General.quoted_variable_name())
+ |> optional(choice([predicate("with"), predicate("for"), params()]))
+ end
+end
diff --git a/lib/liquid/combinators/tags/increment.ex b/lib/liquid/combinators/tags/increment.ex
new file mode 100644
index 00000000..b22cabfc
--- /dev/null
+++ b/lib/liquid/combinators/tags/increment.ex
@@ -0,0 +1,37 @@
+defmodule Liquid.Combinators.Tags.Increment do
+ @moduledoc """
+ Creates a new number variable, and increases its value by one every time it is called. The initial value is 0.
+ Increment is used in a place where one needs to insert a counter into a template, and needs the counter
+ to survive across multiple instantiations of the template.
+ (To achieve the survival, the application must keep the context)
+ if the variable does not exist, it is created with value 0.
+ Input:
+ ```
+ Hello: {% increment variable %}
+ ```
+ Output:
+ ```
+ Hello: 0
+ Hello: 1
+ Hello: 2
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{Tag, LexicalToken}
+
+ @type t :: [increment: Increment.markup()]
+
+ @type markup :: LexicalToken.variable_value()
+
+ @doc """
+ Parses a `Liquid` Increment tag, creates a Keyword list where the key is the name of the tag
+ (increment in this case) and the value is another keyword list which represents the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ Tag.define_open("increment", fn combinator ->
+ concat(combinator, LexicalToken.variable_value())
+ end)
+ end
+end
diff --git a/lib/liquid/combinators/tags/raw.ex b/lib/liquid/combinators/tags/raw.ex
new file mode 100644
index 00000000..00ff4cf7
--- /dev/null
+++ b/lib/liquid/combinators/tags/raw.ex
@@ -0,0 +1,50 @@
+defmodule Liquid.Combinators.Tags.Raw do
+ @moduledoc """
+ Temporarily disables tag processing. This is useful for generating content (eg, Mustache, Handlebars)
+ which uses conflicting syntax.
+ Input:
+ ```
+ {% raw %}
+ In Handlebars, {{ this }} will be HTML-escaped, but
+ {{{ that }}} will not.
+ {% endraw %}
+ ```
+ Output:
+ ```
+ In Handlebars, {{ this }} will be HTML-escaped, but {{{ that }}} will not.
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{Tag, General}
+ @name "raw"
+
+ @type t :: [raw: [String.t()]]
+
+ @doc """
+ Creates a list of string, this is to emulate the behaviuor of the `Liquid` raw tag
+ """
+ @spec raw_content() :: NimbleParsec.t()
+ def raw_content do
+ General.literal_until_tag()
+ |> choice([Tag.close_tag(@name), any_tag()])
+ |> reduce({List, :to_string, []})
+ end
+
+ @doc """
+ Parses a `Liquid` Raw tag, creates a Keyword list where the key is the name of the tag
+ (raw in this case) and the value is the result of the `raw_content()` combinator.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ @name
+ |> Tag.open_tag()
+ |> concat(raw_content())
+ |> tag(:raw)
+ end
+
+ defp any_tag do
+ empty()
+ |> string(General.codepoints().start_tag)
+ |> parsec(:raw_content)
+ end
+end
diff --git a/lib/liquid/combinators/tags/tablerow.ex b/lib/liquid/combinators/tags/tablerow.ex
new file mode 100644
index 00000000..85c63804
--- /dev/null
+++ b/lib/liquid/combinators/tags/tablerow.ex
@@ -0,0 +1,85 @@
+defmodule Liquid.Combinators.Tags.Tablerow do
+ @moduledoc """
+ Iterates over an array or collection splitting it up to a table with pre-set columns number
+ Several useful variables are available to you within the loop.
+ Generates an HTML table. Must be wrapped in opening HTML tags.
+ Input:
+ ```
+
+ {% tablerow product in collection.products %}
+ {{ product.title }}
+ {% endtablerow %}
+
+ ```
+ Output:
+ ```
+
+
+
+ Cool Shirt
+ |
+
+ Alien Poster
+ |
+
+ Batman Poster
+ |
+
+ Bullseye Shirt
+ |
+
+ Another Classic Vinyl
+ |
+
+ Awesome Jeans
+ |
+
+
+ ```
+ """
+ import NimbleParsec
+ alias Liquid.Combinators.{General, Tag, LexicalToken}
+
+ @type t :: [tablerow: Tablerow.markup()]
+
+ @type markup :: [
+ statements: [
+ variable: Liquid.Combinators.LexicalToken.variable_value(),
+ value: Liquid.Combinators.LexicalToken.value()
+ ],
+ params: [limit: [LexicalToken.value()], cols: [LexicalToken.value()]],
+ body: Liquid.NimbleParser.t()
+ ]
+
+ @doc """
+ Parses a `Liquid` Tablerow tag, creates a Keyword list where the key is the name of the tag
+ (tablerow in this case) and the value is another keyword list which represents the internal
+ structure of the tag.
+ """
+ @spec tag() :: NimbleParsec.t()
+ def tag do
+ Tag.define_block("tablerow", &statements/1)
+ end
+
+ defp params do
+ empty()
+ |> times(
+ choice([General.tag_param("offset"), General.tag_param("cols"), General.tag_param("limit")]),
+ min: 1
+ )
+ |> optional()
+ |> tag(:params)
+ end
+
+ defp statements(combinator) do
+ combinator
+ |> concat(LexicalToken.variable_value())
+ |> concat(General.ignore_whitespaces())
+ |> ignore(string("in"))
+ |> concat(General.ignore_whitespaces())
+ |> concat(LexicalToken.value())
+ |> optional(params())
+ |> concat(General.ignore_whitespaces())
+ |> tag(:statements)
+ end
+end
diff --git a/lib/liquid/filters.ex b/lib/liquid/filters.ex
index 7e3c353b..69912866 100644
--- a/lib/liquid/filters.ex
+++ b/lib/liquid/filters.ex
@@ -2,520 +2,49 @@ defmodule Liquid.Filters do
@moduledoc """
Applies a chain of filters passed from Liquid.Variable
"""
- import Kernel, except: [round: 1, abs: 1, floor: 1, ceil: 1]
- import Liquid.Utils, only: [to_number: 1]
- alias Liquid.HTML
- defmodule Functions do
- @moduledoc """
- Structure that holds all the basic filter functions used in Liquid 3.
- """
- use Timex
-
- def size(input) when is_binary(input) do
- String.length(input)
- end
-
- def size(input) when is_list(input) do
- length(input)
- end
-
- def size(input) when is_tuple(input) do
- tuple_size(input)
- end
-
- def size(_), do: 0
-
- @doc """
- Makes each character in a string lowercase.
- It has no effect on strings which are already all lowercase.
- """
- @spec downcase(any) :: String.t()
- def downcase(input) do
- input |> to_string |> String.downcase()
- end
-
- def upcase(input) do
- input |> to_string |> String.upcase()
- end
-
- def capitalize(input) do
- input |> to_string |> String.capitalize()
- end
-
- def first(array) when is_list(array), do: array |> List.first()
-
- def last(array) when is_list(array), do: array |> List.last()
-
- def reverse(array), do: array |> to_iterable |> Enum.reverse()
-
- def sort(array), do: array |> Enum.sort()
-
- def sort(array, key) when is_list(array) and is_map(hd(array)) do
- array |> Enum.sort_by(& &1[key])
- end
-
- def sort(array, _) when is_list(array) do
- array |> Enum.sort()
- end
-
- def uniq(array) when is_list(array), do: array |> Enum.uniq()
-
- def uniq(_), do: raise("Called `uniq` with non-list parameter.")
-
- def uniq(array, key) when is_list(array) and is_map(hd(array)) do
- array |> Enum.uniq_by(& &1[key])
- end
-
- def uniq(array, _) when is_list(array) do
- array |> Enum.uniq()
- end
-
- def uniq(_, _), do: raise("Called `uniq` with non-list parameter.")
-
- def join(array, separator \\ " ") do
- array |> to_iterable |> Enum.join(separator)
- end
-
- def map(array, key) when is_list(array) do
- with mapped <- array |> Enum.map(fn arg -> arg[key] end) do
- case Enum.all?(mapped, &is_binary/1) do
- true -> mapped |> Enum.reduce("", fn el, acc -> acc <> el end)
- _ -> mapped
- end
- end
- end
-
- def map(_, _), do: ""
-
- def plus(value, operand) when is_number(value) and is_number(operand) do
- value + operand
- end
-
- def plus(value, operand) when is_number(value) do
- plus(value, to_number(operand))
- end
-
- def plus(value, operand) do
- value |> to_number |> plus(to_number(operand))
- end
-
- def minus(value, operand) when is_number(value) and is_number(operand) do
- value - operand
- end
-
- def minus(value, operand) when is_number(value) do
- minus(value, to_number(operand))
- end
-
- def minus(value, operand) do
- value |> to_number |> minus(to_number(operand))
- end
-
- def times(value, operand) when is_integer(value) and is_integer(operand) do
- value * operand
- end
-
- def times(value, operand) do
- {value_int, value_len} = value |> get_int_and_counter
- {operand_int, operand_len} = operand |> get_int_and_counter
-
- case value_len + operand_len do
- 0 ->
- value_int * operand_int
-
- precision ->
- Float.round(value_int * operand_int / :math.pow(10, precision), precision)
- end
- end
-
- def divided_by(input, operand) when is_number(input) do
- case {input, operand |> to_number} do
- {_, 0} ->
- raise ArithmeticError, message: "divided by 0"
-
- {input, number_operand} when is_integer(input) ->
- (input / number_operand) |> floor
-
- {input, number_operand} ->
- input / number_operand
- end
- end
-
- def divided_by(input, operand) do
- input |> to_number |> divided_by(operand)
- end
-
- def floor(input) when is_integer(input), do: input
-
- def floor(input) when is_number(input), do: input |> trunc
-
- def floor(input), do: input |> to_number |> floor
-
- def floor(input, precision) when is_number(precision) do
- input |> to_number |> Float.floor(precision)
- end
-
- def floor(input, precision) do
- input |> floor(to_number(precision))
- end
-
- def ceil(input) when is_integer(input), do: input
-
- def ceil(input) when is_number(input) do
- input |> Float.ceil() |> trunc
- end
-
- def ceil(input), do: input |> to_number |> ceil
-
- def ceil(input, precision) when is_number(precision) do
- input |> to_number |> Float.ceil(precision)
- end
-
- def ceil(input, precision) do
- input |> ceil(to_number(precision))
- end
-
- def round(input) when is_integer(input), do: input
-
- def round(input) when is_number(input) do
- input |> Float.round() |> trunc
- end
-
- def round(input), do: input |> to_number |> round
-
- def round(input, precision) when is_number(precision) do
- input |> to_number |> Float.round(precision)
- end
-
- def round(input, precision) do
- input |> round(to_number(precision))
- end
-
- @doc """
- Allows you to specify a fallback in case a value doesn’t exist.
- `default` will show its value if the left side is nil, false, or empty
- """
- @spec default(any, any) :: any
- def default(input, default_val \\ "")
-
- def default(input, default_val) when input in [nil, false, '', "", [], {}, %{}],
- do: default_val
-
- def default(input, _), do: input
-
- @doc """
- Returns a single or plural word depending on input number
- """
- def pluralize(1, single, _), do: single
-
- def pluralize(input, _, plural) when is_number(input), do: plural
-
- def pluralize(input, single, plural), do: input |> to_number |> pluralize(single, plural)
-
- defdelegate pluralise(input, single, plural), to: __MODULE__, as: :pluralize
-
- def abs(input) when is_binary(input), do: input |> to_number |> abs
-
- def abs(input) when input < 0, do: -input
-
- def abs(input), do: input
-
- def modulo(0, _), do: 0
-
- def modulo(input, operand) when is_number(input) and is_number(operand) and input > 0,
- do: input |> rem(operand)
-
- def modulo(input, operand) when is_number(input) and is_number(operand) and input < 0,
- do: modulo(input + operand, operand)
-
- def modulo(input, operand) do
- input |> to_number |> modulo(to_number(operand))
- end
-
- def truncate(input, l \\ 50, truncate_string \\ "...")
-
- def truncate(nil, _, _), do: nil
-
- def truncate(input, l, truncate_string) when is_number(l) do
- l = l - String.length(truncate_string) - 1
-
- case {l, String.length(input)} do
- {l, _} when l <= 0 -> truncate_string
- {l, len} when l < len -> String.slice(input, 0..l) <> truncate_string
- _ -> input
- end
- end
-
- def truncate(input, l, truncate_string), do: truncate(input, to_number(l), truncate_string)
-
- def truncatewords(input, words \\ 15)
-
- def truncatewords(nil, _), do: nil
-
- def truncatewords(input, words) when is_number(words) and words < 1 do
- input |> String.split(" ") |> hd
- end
-
- def truncatewords(input, words) when is_number(words) do
- truncate_string = "..."
- wordlist = input |> String.split(" ")
-
- case words - 1 do
- l when l < length(wordlist) ->
- words = wordlist |> Enum.slice(0..l) |> Enum.join(" ")
- words <> truncate_string
-
- _ ->
- input
- end
- end
-
- def truncatewords(input, words), do: truncatewords(input, to_number(words))
-
- def replace(string, from, to \\ "")
-
- def replace(<>, <>, <>) do
- string |> String.replace(from, to)
- end
-
- def replace(<>, <>, to) do
- string |> replace(from, to_string(to))
- end
-
- def replace(<>, from, to) do
- string |> replace(to_string(from), to)
- end
-
- def replace(string, from, to) do
- string |> to_string |> replace(from, to)
- end
-
- def replace_first(string, from, to \\ "")
-
- def replace_first(<>, <>, to) do
- string |> String.replace(from, to_string(to), global: false)
- end
-
- def replace_first(string, from, to) do
- to = to |> to_string
- string |> to_string |> String.replace(to_string(from), to, global: false)
- end
-
- def remove(<>, <>) do
- string |> String.replace(remove, "")
- end
-
- def remove_first(<>, <>) do
- string |> String.replace(remove, "", global: false)
- end
-
- def remove_first(string, operand) do
- string |> to_string |> remove_first(to_string(operand))
- end
-
- def append(<>, <>) do
- string <> operand
- end
-
- def append(input, nil), do: input
-
- def append(string, operand) do
- string |> to_string |> append(to_string(operand))
- end
-
- def prepend(<>, <>) do
- addition <> string
- end
-
- def prepend(string, nil), do: string
-
- def prepend(string, addition) do
- string |> to_string |> append(to_string(addition))
- end
-
- def strip(<>) do
- string |> String.trim()
- end
-
- def lstrip(<>) do
- string |> String.trim_leading()
- end
-
- def rstrip(<>) do
- string |> String.trim_trailing()
- end
-
- def strip_newlines(<>) do
- string |> String.replace(~r/\r?\n/, "")
- end
-
- def newline_to_br(<>) do
- string |> String.replace("\n", "
\n")
- end
-
- def split(<>, <>) do
- String.split(string, separator)
- end
-
- def split(nil, _), do: []
-
- def slice(list, from, to) when is_list(list) do
- list |> Enum.slice(from, to)
- end
-
- def slice(<>, from, to) do
- string |> String.slice(from, to)
- end
-
- def slice(list, 0) when is_list(list), do: list
-
- def slice(list, range) when is_list(list) and range > 0 do
- list |> Enum.slice(range, length(list))
- end
-
- def slice(list, range) when is_list(list) do
- len = length(list)
- list |> Enum.slice(len + range, len)
- end
-
- def slice(<>, 0), do: string
-
- def slice(<>, range) when range > 0 do
- string |> String.slice(range, String.length(string))
- end
-
- def slice(<>, range) do
- len = String.length(string)
- string |> String.slice(len + range, len)
- end
-
- def slice(nil, _), do: ""
-
- def escape(input) when is_binary(input) do
- input |> HTML.html_escape()
- end
-
- defdelegate h(input), to: __MODULE__, as: :escape
-
- def escape_once(input) when is_binary(input) do
- input |> HTML.html_escape_once()
- end
-
- def strip_html(nil), do: ""
-
- def strip_html(input) when is_binary(input) do
- input
- |> String.replace(~r//m, "")
- |> String.replace(~r//m, "")
- |> String.replace(~r//m, "")
- |> String.replace(~r/<.*?>/m, "")
- end
-
- def url_encode(input) when is_binary(input) do
- input |> URI.encode_www_form()
- end
-
- def url_encode(nil), do: nil
-
- def url_decode(input) when is_binary(input) do
- input |> URI.decode_www_form()
- end
-
- def url_decode(nil), do: nil
-
- def date(input, format \\ "%F %T")
-
- def date(nil, _), do: nil
-
- def date(input, format) when is_nil(format) or format == "" do
- input |> date
- end
-
- def date("now", format), do: Timex.now() |> date(format)
-
- def date("today", format), do: Timex.now() |> date(format)
-
- def date(input, format) when is_binary(input) do
- with {:ok, input_date} <- NaiveDateTime.from_iso8601(input) do
- input_date |> date(format)
- else
- {:error, :invalid_format} ->
- with {:ok, input_date} <- Timex.parse(input, "%a %b %d %T %Y", :strftime),
- do: input_date |> date(format)
- end
- end
-
- def date(input, format) do
- with {:ok, date_str} <- Timex.format(input, format, :strftime), do: date_str
- end
-
- # Helpers
-
- defp to_iterable(input) when is_list(input) do
- case List.first(input) do
- first when is_nil(first) -> []
- first when is_tuple(first) -> [input]
- _ -> input |> List.flatten()
- end
- end
-
- defp to_iterable(input) do
- # input when is_map(input) -> [input]
- # input when is_tuple(input) -> input
- List.wrap(input)
- end
-
- defp get_int_and_counter(input) when is_integer(input), do: {input, 0}
-
- defp get_int_and_counter(input) when is_number(input) do
- {_, remainder} = input |> Float.to_string() |> Integer.parse()
- len = String.length(remainder) - 1
- new_value = input * :math.pow(10, len)
- new_value = new_value |> Float.round() |> trunc
- {new_value, len}
- end
-
- defp get_int_and_counter(input) do
- input |> to_number |> get_int_and_counter
- end
- end
+ @filters_modules [
+ Liquid.Filters.Additionals,
+ Liquid.Filters.HTML,
+ Liquid.Filters.List,
+ Liquid.Filters.Math,
+ Liquid.Filters.String
+ ]
@doc """
Recursively pass through all of the input filters applying them
"""
+ @spec filter(list(), String.t()) :: String.t() | list()
def filter([], value), do: value
- def filter([filter | rest], value) do
- [name, args] = filter
-
+ def filter([[name, args] | rest], value) do
args =
for arg <- args do
- Liquid.quote_matcher() |> Regex.replace(arg, "")
+ Regex.replace(Liquid.quote_matcher(), arg, "")
end
- functions = Functions.__info__(:functions)
- custom_filters = Application.get_env(:liquid, :custom_filters)
+ standard_filter_module = @filters_modules |> Enum.flat_map(&set_module/1) |> Keyword.get(name)
+ custom_filter_module = Application.get_env(:liquid, :custom_filters, %{}) |> Map.get(name)
- ret =
- case {name, functions[name], custom_filters[name]} do
+ filtered_markup =
+ case {name, custom_filter_module, standard_filter_module} do
# pass value in case of no filters
{nil, _, _} ->
value
- # pass non-existend filter
+ # pass non-existent filter
{_, nil, nil} ->
value
- # Fallback to custom if no standard
+ # Fallback to standard if no custom
{_, nil, _} ->
- apply_function(custom_filters[name], name, [value | args])
+ apply_function(standard_filter_module, name, [value | args])
_ ->
- apply_function(Functions, name, [value | args])
+ apply_function(custom_filter_module, name, [value | args])
end
- filter(rest, ret)
+ filter(rest, filtered_markup)
end
@doc """
@@ -529,7 +58,7 @@ defmodule Liquid.Filters do
@doc """
Fetches the current custom filters and extends with the functions from passed module
- NB: you can't override the standard filters though
+ You can override the standard filters with custom filters
"""
def add_filters(module) do
custom_filters = Application.get_env(:liquid, :custom_filters) || %{}
@@ -542,6 +71,10 @@ defmodule Liquid.Filters do
Application.put_env(:liquid, :custom_filters, custom_filters)
end
+ def set_module(module) do
+ Enum.map(module.__info__(:functions), fn {fname, _} -> {fname, module} end)
+ end
+
defp apply_function(module, name, args) do
try do
apply(module, name, args)
diff --git a/lib/liquid/filters/additionals.ex b/lib/liquid/filters/additionals.ex
new file mode 100644
index 00000000..832dc099
--- /dev/null
+++ b/lib/liquid/filters/additionals.ex
@@ -0,0 +1,52 @@
+defmodule Liquid.Filters.Additionals do
+ @moduledoc """
+ Applies a chain of 'Additionals' filters passed from Liquid.Variable
+ """
+
+ @doc """
+ Allows you to specify a fallback in case a value doesn’t exist.
+ `default` will show its value if the left side is nil, false, or empty
+ """
+ @spec default(any(), any()) :: any()
+ def default(input, default_val \\ "")
+
+ def default(input, default_val) when input in [nil, false, '', "", [], {}, %{}],
+ do: default_val
+
+ def default(input, _), do: input
+
+ @doc """
+ Converts a timestamp into another date format.
+
+ ## Examples
+
+ iex> Liquid.Filters.Additionals.date("Mon Nov 19 9:45:0 1990")
+ "1990-11-19 09:45:00"
+ """
+ @spec date(String.t() | Date.t(), Date.t() | String.t()) :: String.t() | Date.t()
+ def date(input, format \\ "%F %T")
+
+ def date(nil, _), do: nil
+
+ def date(input, format) when is_nil(format) or format == "" do
+ date(input)
+ end
+
+ def date("now", format), do: date(Timex.now(), format)
+
+ def date("today", format), do: date(Timex.now(), format)
+
+ def date(input, format) when is_binary(input) do
+ with {:ok, input_date} <- NaiveDateTime.from_iso8601(input) do
+ input_date |> date(format)
+ else
+ {:error, :invalid_format} ->
+ with {:ok, input_date} <- Timex.parse(input, "%a %b %d %T %Y", :strftime),
+ do: input_date |> date(format)
+ end
+ end
+
+ def date(input, format) do
+ with {:ok, date_str} <- Timex.format(input, format, :strftime), do: date_str
+ end
+end
diff --git a/lib/liquid/filters/html.ex b/lib/liquid/filters/html.ex
new file mode 100644
index 00000000..a6e4a62b
--- /dev/null
+++ b/lib/liquid/filters/html.ex
@@ -0,0 +1,93 @@
+defmodule Liquid.Filters.HTML do
+ @moduledoc """
+ Applies a chain of 'HTML' filters passed from Liquid.Variable
+ """
+
+ alias Liquid.HTML
+
+ @doc """
+ Removes any newline characters (line breaks) from a string.
+ """
+ @spec strip_newlines(String.t()) :: String.t()
+ def strip_newlines(<>) do
+ String.replace(string, ~r/\r?\n/, "")
+ end
+
+ @doc """
+ Replaces every newline (\n) with an HTML line break (
).
+ """
+ @spec newline_to_br(String.t()) :: String.t()
+ def newline_to_br(<>) do
+ String.replace(string, "\n", "
\n")
+ end
+
+ @doc """
+ Escapes a string by replacing characters with escape sequences (so that the string can be used in a URL,
+ for example). It doesn’t change strings that don’t have anything to escape.
+
+ ## Examples
+
+ iex> Liquid.Filters.HTML.escape("Have you read 'James & the Giant Peach'?")
+ "Have you read 'James & the Giant Peach'?"
+ """
+ @spec escape(String.t()) :: String.t()
+ def escape(input) when is_binary(input) do
+ input |> HTML.html_escape()
+ end
+
+ defdelegate h(input), to: __MODULE__, as: :escape
+
+ @doc """
+ Escapes a string without changing existing escaped entities. It doesn’t change strings that don’t
+ have anything to escape.
+
+ ## Examples
+
+ iex> Liquid.Filters.HTML.escape_once("1 < 2 & 3")
+ "1 < 2 & 3"
+ """
+ @spec escape_once(String.t()) :: String.t()
+ def escape_once(input) when is_binary(input) do
+ input |> HTML.html_escape_once()
+ end
+
+ @doc """
+ Removes any HTML tags from a string
+
+ ## Examples
+
+ iex> Liquid.Filters.HTML.strip_html("Have you read Ulysses?")
+ "Have you read Ulysses?"
+ """
+ @spec strip_html(String.t()) :: String.t()
+ def strip_html(nil), do: ""
+
+ def strip_html(input) when is_binary(input) do
+ input
+ |> String.replace(~r//m, "")
+ |> String.replace(~r//m, "")
+ |> String.replace(~r//m, "")
+ |> String.replace(~r/<.*?>/m, "")
+ end
+
+ @doc """
+ Converts any URL-unsafe characters in a string into percent-encoded characters.
+
+ ## Examples
+
+ iex> Liquid.Filters.HTML.url_encode("john@test.com")
+ "john%40test.com"
+ """
+ @spec url_encode(String.t()) :: String.t()
+ def url_encode(input) when is_binary(input) do
+ input |> URI.encode_www_form()
+ end
+
+ def url_encode(nil), do: nil
+
+ def url_decode(input) when is_binary(input) do
+ input |> URI.decode_www_form()
+ end
+
+ def url_decode(nil), do: nil
+end
diff --git a/lib/liquid/filters/list.ex b/lib/liquid/filters/list.ex
new file mode 100644
index 00000000..606f7086
--- /dev/null
+++ b/lib/liquid/filters/list.ex
@@ -0,0 +1,150 @@
+defmodule Liquid.Filters.List do
+ @moduledoc """
+ Applies a chain of 'List' filters passed from Liquid.Variable
+ """
+
+ @doc """
+ Returns the number of characters in a string or the number of items in an list or a tuple
+
+ ## Examples
+
+ iex> Liquid.Filters.List.size("test")
+ 4
+ """
+ @spec size(any()) :: integer()
+ def size(input) when is_binary(input) do
+ String.length(input)
+ end
+
+ def size(input) when is_list(input) do
+ length(input)
+ end
+
+ def size(input) when is_tuple(input) do
+ tuple_size(input)
+ end
+
+ def size(_), do: 0
+
+ @doc """
+ Returns the first item of an array.
+
+ ## Examples
+
+ iex> Liquid.Filters.List.first(["testy", "the", "test"])
+ "testy"
+ """
+ @spec first(list()) :: any()
+ def first(list) when is_list(list), do: list |> List.first()
+
+ @doc """
+ Returns the last item of an array.
+
+ ## Examples
+
+ iex> Liquid.Filters.List.last(["testy", "the", "test"])
+ "test"
+ """
+ @spec last(list()) :: any()
+ def last(list) when is_list(list), do: list |> List.last()
+
+ @doc """
+ Reverses the order of the items in an array. reverse cannot reverse a string.
+
+ ## Examples
+
+ iex> Liquid.Filters.List.reverse(["testy", "the", "test"])
+ ["test", "the", "testy"]
+ """
+ @spec reverse(list()) :: list()
+ def reverse(array), do: array |> to_iterable |> Enum.reverse()
+
+ defp to_iterable(input) when is_list(input) do
+ case List.first(input) do
+ first when is_nil(first) -> []
+ first when is_tuple(first) -> [input]
+ _ -> input |> List.flatten()
+ end
+ end
+
+ defp to_iterable(input) do
+ # input when is_map(input) -> [input]
+ # input when is_tuple(input) -> input
+ List.wrap(input)
+ end
+
+ @doc """
+ Sorts items in an array by a property of an item in the array. The order of the sorted array is case-sensitive.
+
+ ## Examples
+
+ iex> Liquid.Filters.List.sort(["do", "a", "sort", "by","clown"])
+ ["a", "by", "clown", "do", "sort"]
+ """
+ @spec sort(list()) :: list()
+ def sort(array), do: array |> Enum.sort()
+
+ def sort(array, key) when is_list(array) and is_map(hd(array)) do
+ array |> Enum.sort_by(& &1[key])
+ end
+
+ def sort(array, _) when is_list(array) do
+ array |> Enum.sort()
+ end
+
+ @doc """
+ Removes any duplicate elements in an array.
+
+ ## Examples
+
+ iex> Liquid.Filters.List.uniq(["pls", "pls", "remove", "remove","duplicates"])
+ ["pls", "remove", "duplicates"]
+ """
+ @spec uniq(list(), String.t()) :: list() | String.t()
+ def uniq(array) when is_list(array), do: array |> Enum.uniq()
+
+ def uniq(_), do: raise("Called `uniq` with non-list parameter.")
+
+ def uniq(array, key) when is_list(array) and is_map(hd(array)) do
+ array |> Enum.uniq_by(& &1[key])
+ end
+
+ def uniq(array, _) when is_list(array) do
+ array |> Enum.uniq()
+ end
+
+ def uniq(_, _), do: raise("Called `uniq` with non-list parameter.")
+
+ @doc """
+ Combines the items in an array into a single string using the argument as a separator.
+
+ ## Examples
+
+ iex> Liquid.Filters.List.join(["1","2","3"], " and ")
+ "1 and 2 and 3"
+ """
+ @spec join(list(), String.t()) :: String.t()
+ def join(array, separator \\ " ") do
+ array |> to_iterable |> Enum.join(separator)
+ end
+
+ @doc """
+ Creates an array of values by extracting the values of a named property from another object
+
+ ## Examples
+
+ iex> Liquid.Filters.List.map([%{:hallo=>"1", :hola=>"2"}], :hallo)
+ "1"
+ """
+ @spec map(list(), String.t()) :: list() | String.t()
+ def map(array, key) when is_list(array) do
+ with mapped <- array |> Enum.map(fn arg -> arg[key] end) do
+ case Enum.all?(mapped, &is_binary/1) do
+ true -> mapped |> Enum.reduce("", fn el, acc -> acc <> el end)
+ _ -> mapped
+ end
+ end
+ end
+
+ def map(_, _), do: ""
+end
diff --git a/lib/liquid/filters/math.ex b/lib/liquid/filters/math.ex
new file mode 100644
index 00000000..45bdaabf
--- /dev/null
+++ b/lib/liquid/filters/math.ex
@@ -0,0 +1,245 @@
+defmodule Liquid.Filters.Math do
+ @moduledoc """
+ Applies a chain of 'Math' filters passed from Liquid.Variable
+ """
+ import Kernel, except: [round: 1, abs: 1, floor: 1, ceil: 1]
+ import Liquid.Utils, only: [to_number: 1]
+
+ @doc """
+ Adds a number to another number. Can use strings
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.plus(100, 200)
+ 300
+
+ iex> Liquid.Filters.Math.plus("100", "200")
+ 300
+ """
+ @spec plus(number() | String.t(), number() | String.t()) :: integer()
+ def plus(value, operand) when is_number(value) and is_number(operand) do
+ value + operand
+ end
+
+ def plus(value, operand) when is_number(value) do
+ plus(value, to_number(operand))
+ end
+
+ def plus(value, operand) do
+ value |> to_number |> plus(to_number(operand))
+ end
+
+ @doc """
+ Subtracts a number from another number. Can use strings
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.minus(200, 200)
+ 0
+
+ iex> Liquid.Filters.Math.minus("200", "200")
+ 0
+ """
+ @spec minus(number() | String.t(), number() | String.t()) :: number()
+ def minus(value, operand) when is_number(value) and is_number(operand) do
+ value - operand
+ end
+
+ def minus(value, operand) when is_number(value) do
+ minus(value, to_number(operand))
+ end
+
+ def minus(value, operand) do
+ value |> to_number |> minus(to_number(operand))
+ end
+
+ @doc """
+ Multiplies a number by another number. Can use strings
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.times(2, 4)
+ 8
+
+ iex> Liquid.Filters.Math.times("2","4")
+ 8
+ """
+ @spec times(number() | String.t(), number() | String.t()) :: number()
+ def times(value, operand) when is_integer(value) and is_integer(operand) do
+ value * operand
+ end
+
+ def times(value, operand) do
+ {value_int, value_len} = value |> get_int_and_counter
+ {operand_int, operand_len} = operand |> get_int_and_counter
+
+ case value_len + operand_len do
+ 0 ->
+ value_int * operand_int
+
+ precision ->
+ Float.round(value_int * operand_int / :math.pow(10, precision), precision)
+ end
+ end
+
+ defp get_int_and_counter(input) when is_integer(input), do: {input, 0}
+
+ defp get_int_and_counter(input) when is_number(input) do
+ {_, remainder} = input |> Float.to_string() |> Integer.parse()
+ len = String.length(remainder) - 1
+ new_value = input * :math.pow(10, len)
+ new_value = new_value |> Float.round() |> trunc
+ {new_value, len}
+ end
+
+ defp get_int_and_counter(input) do
+ input |> to_number |> get_int_and_counter
+ end
+
+ @doc """
+ Divides a number by the specified number. Can use strings
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.divided_by(12, 2)
+ 6
+
+ iex> Liquid.Filters.Math.divided_by("2","0")
+ ** (ArithmeticError) divided by 0
+ """
+ @spec divided_by(number() | String.t(), number() | String.t()) :: number()
+ def divided_by(input, operand) when is_number(input) do
+ case {input, operand |> to_number} do
+ {_, 0} ->
+ raise ArithmeticError, message: "divided by 0"
+
+ {input, number_operand} when is_integer(input) ->
+ floor(input / number_operand)
+
+ {input, number_operand} ->
+ input / number_operand
+ end
+ end
+
+ def divided_by(input, operand) do
+ input |> to_number |> divided_by(operand)
+ end
+
+ @doc """
+ Rounds a number down to the nearest whole number. tries to convert the input to a number before the
+ filter is applied. Can use strings and you have the option to put a precision number
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.floor(11.2)
+ 11
+
+ iex> Liquid.Filters.Math.floor(11.22222222222,4)
+ 11.2222
+ """
+ @spec floor(integer() | number() | String.t()) :: integer() | number()
+ def floor(input) when is_integer(input), do: input
+
+ def floor(input) when is_number(input), do: input |> trunc
+
+ def floor(input), do: input |> to_number |> floor
+
+ def floor(input, precision) when is_number(precision) do
+ input |> to_number |> Float.floor(precision)
+ end
+
+ def floor(input, precision) do
+ input |> floor(to_number(precision))
+ end
+
+ @doc """
+ Rounds the input up to the nearest whole number. Can use strings
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.ceil(11.2)
+ 12
+ """
+ @spec ceil(input :: integer | number | String.t()) :: integer | number
+ def ceil(input) when is_integer(input), do: input
+
+ def ceil(input) when is_number(input) do
+ input |> Float.ceil() |> trunc
+ end
+
+ def ceil(input), do: input |> to_number |> ceil
+
+ def ceil(input, precision) when is_number(precision) do
+ input |> to_number |> Float.ceil(precision)
+ end
+
+ def ceil(input, precision) do
+ input |> ceil(to_number(precision))
+ end
+
+ @doc """
+ Rounds an input number to the nearest integer or,
+ if a number is specified as an argument, to that number of decimal places.
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.round(11.2)
+ 11
+
+ iex> Liquid.Filters.Math.round(11.6)
+ 12
+ """
+ @spec round(integer() | number() | String.t()) :: integer() | number()
+ def round(input) when is_integer(input), do: input
+
+ def round(input) when is_number(input) do
+ input |> Float.round() |> trunc
+ end
+
+ def round(input), do: input |> to_number |> round
+
+ def round(input, precision) when is_number(precision) do
+ input |> to_number |> Float.round(precision)
+ end
+
+ def round(input, precision) do
+ input |> round(to_number(precision))
+ end
+
+ @doc """
+ Returns the absolute value of a number.
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.abs(-17)
+ 17
+ """
+ @spec abs(integer() | number() | String.t()) :: integer() | number() | String.t()
+ def abs(input) when is_binary(input), do: input |> to_number |> abs
+
+ def abs(input) when input < 0, do: -input
+
+ def abs(input), do: input
+
+ @doc """
+ Returns the remainder of a division operation.
+
+ ## Examples
+
+ iex> Liquid.Filters.Math.modulo(31,4)
+ 3
+ """
+ @spec modulo(integer() | number() | String.t(), integer() | number() | String.t()) ::
+ integer() | number()
+ def modulo(0, _), do: 0
+
+ def modulo(input, operand) when is_number(input) and is_number(operand) and input > 0,
+ do: input |> rem(operand)
+
+ def modulo(input, operand) when is_number(input) and is_number(operand) and input < 0,
+ do: modulo(input + operand, operand)
+
+ def modulo(input, operand) do
+ input |> to_number |> modulo(to_number(operand))
+ end
+end
diff --git a/lib/liquid/filters/string.ex b/lib/liquid/filters/string.ex
new file mode 100644
index 00000000..dbbea959
--- /dev/null
+++ b/lib/liquid/filters/string.ex
@@ -0,0 +1,330 @@
+defmodule Liquid.Filters.String do
+ @moduledoc """
+ Applies a chain of 'String' filters passed from Liquid.Variable
+ """
+
+ import Kernel, except: [round: 1, abs: 1]
+ import Liquid.Utils, only: [to_number: 1]
+
+ @doc """
+ Makes each character in a string lowercase.
+ It has no effect on strings which are already all lowercase.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.downcase("Testy the Test")
+ "testy the test"
+ """
+ @spec downcase(any()) :: String.t()
+ def downcase(input) do
+ input |> to_string |> String.downcase()
+ end
+
+ @doc """
+ Makes each character in a string uppercase.
+ It has no effect on strings which are already upercase.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.upcase("Testy the Test")
+ "TESTY THE TEST"
+ """
+ @spec upcase(any()) :: String.t()
+ def upcase(input) do
+ input |> to_string |> String.upcase()
+ end
+
+ @doc """
+ Makes the first character of a string capitalized.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.capitalize("testy the test")
+ "Testy the test"
+ """
+ @spec capitalize(any()) :: String.t()
+ def capitalize(input) do
+ input |> to_string |> String.capitalize()
+ end
+
+ @doc """
+ Shortens a string down to the number of characters passed as a parameter.
+ If the number of characters
+ specified is less than the length of the string, an ellipsis (…) is appended to the
+ string and is included in the character count
+
+ ## Examples
+
+ iex> Liquid.Filters.String.truncate("cut this please i need it",18)
+ "cut this please..."
+ """
+ @spec truncate(String.t(), integer(), String.t()) :: String.t()
+ def truncate(input, l \\ 50, truncate_string \\ "...")
+
+ def truncate(nil, _, _), do: nil
+
+ def truncate(input, l, truncate_string) when is_number(l) do
+ l = l - String.length(truncate_string) - 1
+
+ case {l, String.length(input)} do
+ {l, _} when l <= 0 -> truncate_string
+ {l, len} when l < len -> String.slice(input, 0..l) <> truncate_string
+ _ -> input
+ end
+ end
+
+ def truncate(input, l, truncate_string), do: truncate(input, to_number(l), truncate_string)
+
+ @doc """
+ Shortens a string down to the number of words passed as the argument.
+ If the specified number of words is less than the number of words in the string,
+ an ellipsis (…) is appended to the string
+
+ ## Examples
+
+ iex> Liquid.Filters.String.truncatewords("cut this please i need it",3)
+ "cut this please..."
+ """
+ @spec truncatewords(String.t(), integer()) :: String.t()
+ def truncatewords(input, words \\ 15)
+
+ def truncatewords(nil, _), do: nil
+
+ def truncatewords(input, words) when is_number(words) and words < 1 do
+ input |> String.split(" ") |> hd
+ end
+
+ def truncatewords(input, words) when is_number(words) do
+ truncate_string = "..."
+ wordlist = input |> String.split(" ")
+
+ case words - 1 do
+ l when l < length(wordlist) ->
+ words = wordlist |> Enum.slice(0..l) |> Enum.join(" ")
+ words <> truncate_string
+
+ _ ->
+ input
+ end
+ end
+
+ def truncatewords(input, words), do: truncatewords(input, to_number(words))
+
+ @doc """
+ Replaces every occurrence of an argument in a string with the second argument.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.replace("cut this please i need it","cut", "replace")
+ "replace this please i need it"
+ """
+ @spec replace(String.t(), String.t(), String.t()) :: String.t()
+ def replace(string, from, to \\ "")
+
+ def replace(<>, <>, <>) do
+ string |> String.replace(from, to)
+ end
+
+ def replace(<>, <>, to) do
+ string |> replace(from, to_string(to))
+ end
+
+ def replace(<>, from, to) do
+ string |> replace(to_string(from), to)
+ end
+
+ def replace(string, from, to) do
+ string |> to_string |> replace(from, to)
+ end
+
+ @doc """
+ Replaces only the first occurrence of the first argument in a string with the second argument.
+
+ ## Examples
+ iex> Liquid.Filters.String.replace_first("cut this please i need it cut it pls","cut", "replace")
+ "replace this please i need it cut it pls"
+ """
+ @spec replace_first(String.t(), String.t(), String.t()) :: String.t()
+ def replace_first(string, from, to \\ "")
+
+ def replace_first(<>, <>, to) do
+ string |> String.replace(from, to_string(to), global: false)
+ end
+
+ def replace_first(string, from, to) do
+ to = to |> to_string
+ string |> to_string |> String.replace(to_string(from), to, global: false)
+ end
+
+ @doc """
+ Removes every occurrence of the specified substring from a string.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.remove("cut this please i need it cut it pls","cut")
+ " this please i need it it pls"
+ """
+ @spec remove(String.t(), String.t()) :: String.t()
+ def remove(<>, <>) do
+ string |> String.replace(remove, "")
+ end
+
+ @spec remove_first(String.t(), String.t()) :: String.t()
+ def remove_first(<>, <>) do
+ string |> String.replace(remove, "", global: false)
+ end
+
+ def remove_first(string, operand) do
+ string |> to_string |> remove_first(to_string(operand))
+ end
+
+ @doc """
+ Concatenates two strings and returns the concatenated value.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.append("this with"," this")
+ "this with this"
+ """
+ @spec append(String.t(), String.t()) :: String.t()
+ def append(<>, <>) do
+ string <> operand
+ end
+
+ def append(input, nil), do: input
+
+ def append(string, operand) do
+ string |> to_string |> append(to_string(operand))
+ end
+
+ @doc """
+ Adds the specified string to the beginning of another string.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.prepend("this with","what is ")
+ "what is this with"
+ """
+ @spec prepend(String.t(), String.t()) :: String.t()
+ def prepend(<>, <>) do
+ addition <> string
+ end
+
+ def prepend(string, nil), do: string
+
+ def prepend(string, addition) do
+ string |> to_string |> append(to_string(addition))
+ end
+
+ @doc """
+ Divides an input string into an array using the argument as a separator. split is commonly used to
+ convert comma-separated items from a string to an array.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.split("this test is cool", " ")
+ ["this", "test", "is", "cool"]
+ """
+ @spec split(String.t(), String.t()) :: list()
+ def split(<>, <>) do
+ String.split(string, separator)
+ end
+
+ def split(nil, _), do: []
+
+ @doc """
+ Removes all whitespace (tabs, spaces, and newlines) from both the left and right side of a string.
+ It does not affect spaces between words.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.strip(" this test is just for the strip ")
+ "this test is just for the strip"
+ """
+ @spec strip(String.t()) :: String.t()
+ def strip(<>) do
+ String.trim(string)
+ end
+
+ @doc """
+ Removes all whitespaces (tabs, spaces, and newlines) from the beginning of a string.
+ The filter does not affect spaces between words.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.lstrip(" this test is just for the strip ")
+ "this test is just for the strip "
+ """
+ @spec lstrip(String.t()) :: String.t()
+ def lstrip(<>) do
+ String.trim_leading(string)
+ end
+
+ @doc """
+ Removes all whitespace (tabs, spaces, and newlines) from the right side of a string.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.rstrip(" this test is just for the strip ")
+ " this test is just for the strip"
+ """
+ @spec rstrip(String.t()) :: String.t()
+ def rstrip(<>) do
+ String.trim_trailing(string)
+ end
+
+ @doc """
+ Returns a substring of 1 character beginning at the index specified by the argument passed in.
+ An optional second argument specifies the length of the substring to be returned.
+ String indices are numbered starting from 0.
+
+ ## Examples
+
+ iex> Liquid.Filters.String.slice("this test is cool", 5)
+ "test is cool"
+ """
+ @spec slice(String.t() | list(), integer()) :: String.t() | list()
+ def slice(list, from, to) when is_list(list) do
+ list |> Enum.slice(from, to)
+ end
+
+ def slice(<>, from, to) do
+ string |> String.slice(from, to)
+ end
+
+ def slice(list, 0) when is_list(list), do: list
+
+ def slice(list, range) when is_list(list) and range > 0 do
+ list |> Enum.slice(range, length(list))
+ end
+
+ def slice(list, range) when is_list(list) do
+ len = length(list)
+ list |> Enum.slice(len + range, len)
+ end
+
+ def slice(<>, 0), do: string
+
+ def slice(<>, range) when range > 0 do
+ string |> String.slice(range, String.length(string))
+ end
+
+ def slice(<>, range) do
+ len = String.length(string)
+ string |> String.slice(len + range, len)
+ end
+
+ def slice(nil, _), do: ""
+
+ @doc """
+ Returns a single or plural word depending on input number
+ """
+ @spec pluralize(integer() | number() | String.t(), String.t(), String.t()) :: String.t()
+ def pluralize(1, single, _), do: single
+
+ def pluralize(input, _, plural) when is_number(input), do: plural
+
+ def pluralize(input, single, plural), do: input |> to_number |> pluralize(single, plural)
+
+ defdelegate pluralise(input, single, plural), to: __MODULE__, as: :pluralize
+end
diff --git a/lib/liquid/include.ex b/lib/liquid/include.ex
index 47ac8230..d5f573c9 100644
--- a/lib/liquid/include.ex
+++ b/lib/liquid/include.ex
@@ -9,12 +9,19 @@ defmodule Liquid.Include do
do: ~r/(#{Liquid.quoted_fragment()}+)(\s+(?:with|for)\s+(#{Liquid.quoted_fragment()}+))?/
def parse(%Tag{markup: markup} = tag, %Template{} = template) do
- [parts | _] = syntax() |> Regex.scan(markup)
+ [parts | _] = Regex.scan(syntax(), markup)
tag = parse_tag(tag, parts)
attributes = parse_attributes(markup)
{%{tag | attributes: attributes}, template}
end
+ def parse(%Tag{markup: markup} = tag) do
+ [parts | _] = Regex.scan(syntax(), markup)
+ tag = parse_tag(tag, parts)
+ attributes = parse_attributes(markup)
+ %{tag | attributes: attributes}
+ end
+
defp parse_tag(%Tag{} = tag, parts) do
case parts do
[_, name] ->
diff --git a/lib/liquid/nimble_translator.ex b/lib/liquid/nimble_translator.ex
new file mode 100644
index 00000000..db8a2456
--- /dev/null
+++ b/lib/liquid/nimble_translator.ex
@@ -0,0 +1,146 @@
+defmodule Liquid.NimbleTranslator do
+ @moduledoc """
+ Translate NimbleParser AST to old AST.
+ """
+ alias Liquid.Template
+
+ alias Liquid.Translators.Tags.{
+ Assign,
+ Break,
+ Capture,
+ Case,
+ Comment,
+ Continue,
+ Cycle,
+ Decrement,
+ For,
+ If,
+ Ifchanged,
+ Include,
+ Increment,
+ LiquidVariable,
+ Raw,
+ Tablerow,
+ CustomTag
+ }
+
+ @doc """
+ Converts Nimble AST into old AST in order to use old render.
+ """
+ def translate({:ok, [""]}) do
+ %Template{root: %Liquid.Block{name: :document}}
+ end
+
+ def translate({:ok, [literal_text]}) when is_bitstring(literal_text) do
+ %Template{root: %Liquid.Block{name: :document, nodelist: [literal_text]}}
+ end
+
+ def translate({:ok, nodelist}) when is_list(nodelist) do
+ list = process_node(nodelist)
+ %Template{root: %Liquid.Block{name: :document, nodelist: list}}
+ end
+
+ @doc """
+ Takes the new parsed tag and match it with his translator, then return the old parser struct.
+ """
+ @spec process_node(Liquid.NimbleParser.t()) :: Liquid.Tag.t() | Liquid.Block.t()
+ def process_node(elem) when is_bitstring(elem), do: elem
+
+ def process_node([elem]) when is_bitstring(elem), do: elem
+
+ def process_node(nodelist) when is_list(nodelist) do
+ Enum.map(nodelist, &process_node/1)
+ end
+
+ def process_node({tag, markup}) do
+ translated =
+ case tag do
+ :liquid_variable ->
+ LiquidVariable.translate(markup)
+
+ :assign ->
+ Assign.translate(markup)
+
+ :capture ->
+ Capture.translate(markup)
+
+ :comment ->
+ Comment.translate(markup)
+
+ :cycle ->
+ Cycle.translate(markup)
+
+ :decrement ->
+ Decrement.translate(markup)
+
+ :for ->
+ For.translate(markup)
+
+ :if ->
+ If.translate(:if, markup)
+
+ :unless ->
+ If.translate(:unless, markup)
+
+ :elsif ->
+ If.translate(:if, markup)
+
+ :else ->
+ [body: body_parts] = markup
+ process_node(body_parts)
+
+ :include ->
+ Include.translate(markup)
+
+ :increment ->
+ Increment.translate(markup)
+
+ :tablerow ->
+ Tablerow.translate(markup)
+
+ :ifchanged ->
+ Ifchanged.translate(markup)
+
+ :raw ->
+ Raw.translate(markup)
+
+ :break ->
+ Break.translate(markup)
+
+ :continue ->
+ Continue.translate(markup)
+
+ :case ->
+ Case.translate(markup)
+
+ :custom ->
+ CustomTag.translate(markup)
+ end
+
+ check_blank(translated)
+ end
+
+ @doc """
+ Emulates the `Liquid` behavior for blanks blocks. Checks all the blocks and determine if it is blank or not.
+ """
+ @spec check_blank(Liquid.Tag.t() | Liquid.Block.t()) :: Liquid.Tag.t() | Liquid.Block.t()
+ def check_blank(%Liquid.Block{name: :if, nodelist: nodelist, elselist: elselist} = translated)
+ when is_list(nodelist) and is_list(elselist) do
+ if Blank.blank?(nodelist) and Blank.blank?(elselist) do
+ %{translated | blank: true}
+ else
+ translated
+ end
+ end
+
+ def check_blank(%Liquid.Block{nodelist: nodelist} = translated)
+ when is_list(nodelist) do
+ if Blank.blank?(nodelist) do
+ %{translated | blank: true}
+ else
+ translated
+ end
+ end
+
+ def check_blank(translated), do: translated
+end
diff --git a/lib/liquid/parse.ex b/lib/liquid/parse.ex
deleted file mode 100644
index e5105e97..00000000
--- a/lib/liquid/parse.ex
+++ /dev/null
@@ -1,156 +0,0 @@
-defmodule Liquid.Parse do
- alias Liquid.Template
- alias Liquid.Variable
- alias Liquid.Registers
- alias Liquid.Block
-
- def tokenize(<>) do
- Liquid.template_parser()
- |> Regex.split(string, on: :all_but_first, trim: true)
- |> List.flatten()
- |> Enum.filter(&(&1 != ""))
- end
-
- def parse("", %Template{} = template) do
- %{template | root: %Liquid.Block{name: :document}}
- end
-
- def parse(<>, %Template{} = template) do
- tokens = string |> tokenize
- name = tokens |> hd
- tag_name = parse_tag_name(name)
- tokens = parse_tokens(string, tag_name) || tokens
- {root, template} = parse(%Liquid.Block{name: :document}, tokens, [], template)
- %{template | root: root}
- end
-
- def parse(%Block{name: :document} = block, [], accum, %Template{} = template) do
- unless nodelist_invalid?(block, accum), do: {%{block | nodelist: accum}, template}
- end
-
- def parse(%Block{name: :comment} = block, [h | t], accum, %Template{} = template) do
- cond do
- Regex.match?(~r/{%\s*endcomment\s*%}/, h) ->
- {%{block | nodelist: accum}, t, template}
-
- Regex.match?(~r/{%\send.*?\s*$}/, h) ->
- raise "Unmatched block close: #{h}"
-
- true ->
- {result, rest, template} =
- try do
- parse_node(h, t, template)
- rescue
- # Ignore undefined tags inside comments
- RuntimeError ->
- {h, t, template}
- end
-
- parse(block, rest, accum ++ [result], template)
- end
- end
-
- def parse(%Block{name: name}, [], _, _) do
- raise "No matching end for block {% #{to_string(name)} %}"
- end
-
- def parse(%Block{name: name} = block, [h | t], accum, %Template{} = template) do
- endblock = "end" <> to_string(name)
-
- cond do
- Regex.match?(~r/{%\s*#{endblock}\s*%}/, h) ->
- unless nodelist_invalid?(block, accum), do: {%{block | nodelist: accum}, t, template}
-
- Regex.match?(~r/{%\send.*?\s*$}/, h) ->
- raise "Unmatched block close: #{h}"
-
- true ->
- {result, rest, template} = parse_node(h, t, template)
- parse(block, rest, accum ++ [result], template)
- end
- end
-
- defp invalid_expression?(expression) when is_binary(expression) do
- Regex.match?(Liquid.invalid_expression(), expression)
- end
-
- defp invalid_expression?(_), do: false
-
- defp nodelist_invalid?(block, nodelist) do
- case block.strict do
- true ->
- if Enum.any?(nodelist, &invalid_expression?(&1)) do
- raise Liquid.SyntaxError,
- message: "no match delimiters in #{block.name}: #{block.markup}"
- end
-
- false ->
- false
- end
- end
-
- defp parse_tokens(<>, tag_name) do
- case Registers.lookup(tag_name) do
- {mod, Liquid.Block} ->
- try do
- mod.tokenize(string)
- rescue
- UndefinedFunctionError -> nil
- end
-
- _ ->
- nil
- end
- end
-
- defp parse_tag_name(name) do
- case Regex.named_captures(Liquid.parser(), name) do
- %{"tag" => tag_name, "variable" => _} -> tag_name
- _ -> nil
- end
- end
-
- defp parse_node(<>, rest, %Template{} = template) do
- case Regex.named_captures(Liquid.parser(), name) do
- %{"tag" => "", "variable" => markup} when is_binary(markup) ->
- {Variable.create(markup), rest, template}
-
- %{"tag" => markup, "variable" => ""} when is_binary(markup) ->
- parse_markup(markup, rest, template)
-
- nil ->
- {name, rest, template}
- end
- end
-
- defp parse_markup(markup, rest, template) do
- name = markup |> String.split(" ") |> hd
-
- case Registers.lookup(name) do
- {mod, Liquid.Block} ->
- parse_block(mod, markup, rest, template)
-
- {mod, Liquid.Tag} ->
- tag = Liquid.Tag.create(markup)
- {tag, template} = mod.parse(tag, template)
- {tag, rest, template}
-
- nil ->
- raise "unregistered tag: #{name}"
- end
- end
-
- defp parse_block(mod, markup, rest, template) do
- block = Liquid.Block.create(markup)
-
- {block, rest, template} =
- try do
- mod.parse(block, rest, [], template)
- rescue
- UndefinedFunctionError -> parse(block, rest, [], template)
- end
-
- {block, template} = mod.parse(block, template)
- {block, rest, template}
- end
-end
diff --git a/lib/liquid/parser.ex b/lib/liquid/parser.ex
new file mode 100644
index 00000000..32263938
--- /dev/null
+++ b/lib/liquid/parser.ex
@@ -0,0 +1,125 @@
+defmodule Liquid.Parser do
+ @moduledoc """
+ Transform a valid liquid markup in an AST to be executed by `render`.
+ """
+ @inline true
+
+ import NimbleParsec
+
+ alias Liquid.Combinators.{General, LexicalToken}
+ alias Liquid.Combinators.Tags.Generic
+ alias Liquid.Ast
+
+ alias Liquid.Combinators.Tags.{
+ Assign,
+ Comment,
+ Decrement,
+ EndBlock,
+ Increment,
+ Include,
+ Raw,
+ Cycle,
+ If,
+ For,
+ Tablerow,
+ Case,
+ Capture,
+ Ifchanged,
+ CustomTag
+ }
+
+ @type t :: [
+ Assign.t()
+ | Capture.t()
+ | Increment.t()
+ | Decrement.t()
+ | Include.t()
+ | Cycle.t()
+ | Raw.t()
+ | Comment.t()
+ | For.t()
+ | If.t()
+ | Unless.t()
+ | Tablerow.t()
+ | Case.t()
+ | Ifchanged.t()
+ | CustomTag.t()
+ | CustomBlock.t()
+ | General.liquid_variable()
+ | String.t()
+ ]
+
+ defparsec(:variable_definition, General.variable_definition(), inline: @inline)
+ defparsec(:variable_name, General.variable_name(), inline: @inline)
+ defparsec(:variable_definition_for_assignment, General.variable_definition_for_assignment(), inline: @inline)
+ defparsec(:filter, General.filter(), inline: @inline)
+ defparsec(:filters, General.filters(), inline: @inline)
+ defparsec(:comparison_operators, General.comparison_operators(), inline: @inline)
+ defparsec(:condition, General.condition(), inline: @inline)
+ defparsec(:logical_condition, General.logical_condition(), inline: @inline)
+
+ defparsec(:value_definition, LexicalToken.value_definition(), inline: @inline)
+ defparsec(:value, LexicalToken.value(), inline: @inline)
+ defparsec(:number, LexicalToken.number(), inline: @inline)
+ defparsec(:object_property, LexicalToken.object_property(), inline: @inline)
+ defparsec(:variable_value, LexicalToken.variable_value(), inline: @inline)
+ defparsec(:variable_part, LexicalToken.variable_part(), inline: @inline)
+
+ defparsec(:cycle_values, Cycle.cycle_values(), inline: @inline)
+ defparsec(:comment, Comment.tag(), inline: @inline)
+ defparsec(:comment_content, Comment.comment_content(), inline: @inline)
+ defparsecp(:raw, Raw.tag(), inline: @inline)
+ defparsecp(:raw_content, Raw.raw_content(), inline: @inline)
+
+ # The tag order affects the parser execution any change can break the app
+ liquid_tag =
+ choice([
+ Raw.tag(),
+ Comment.tag(),
+ If.tag(),
+ If.unless_tag(),
+ For.tag(),
+ Case.tag(),
+ Capture.tag(),
+ Tablerow.tag(),
+ Cycle.tag(),
+ Assign.tag(),
+ Increment.tag(),
+ Decrement.tag(),
+ Include.tag(),
+ Ifchanged.tag(),
+ Generic.else_tag(),
+ Case.when_tag(),
+ If.elsif_tag(),
+ For.break_tag(),
+ For.continue_tag(),
+ EndBlock.tag(),
+ CustomTag.tag()
+ ])
+
+ defparsec(
+ :__parse__,
+ empty()
+ |> choice([
+ liquid_tag,
+ General.liquid_variable(),
+ ]), inline: @inline
+ )
+
+ @doc """
+ Validates and parse liquid markup.
+ """
+ @spec parse(String.t()) :: {:ok | :error, any()}
+ def parse(markup) do
+ case Ast.build(markup, %{tags: []}, []) do
+ {:ok, template, %{tags: []}, ""} ->
+ {:ok, template}
+
+ {:ok, _, %{tags: [unclosed | _]}, ""} ->
+ {:error, "Malformed tag, open without close: '#{unclosed}'", ""}
+
+ {:error, message, rest_markup} ->
+ {:error, message, rest_markup}
+ end
+ end
+end
diff --git a/lib/liquid/tag.ex b/lib/liquid/tag.ex
index a7c73005..49ed69a8 100644
--- a/lib/liquid/tag.ex
+++ b/lib/liquid/tag.ex
@@ -1,6 +1,14 @@
defmodule Liquid.Tag do
defstruct name: nil, markup: nil, parts: [], attributes: [], blank: false
+ @type t :: %Liquid.Tag{
+ name: String.t() | nil,
+ markup: String.t() | nil,
+ parts: [...],
+ attributes: [...],
+ blank: boolean()
+ }
+
def create(markup) do
destructure [name, rest], String.split(markup, " ", parts: 2)
%Liquid.Tag{name: name |> String.to_atom(), markup: rest}
diff --git a/lib/liquid/tags/assign.ex b/lib/liquid/tags/assign.ex
index a6845bbc..fe52d988 100644
--- a/lib/liquid/tags/assign.ex
+++ b/lib/liquid/tags/assign.ex
@@ -1,14 +1,25 @@
defmodule Liquid.Assign do
- alias Liquid.Variable
- alias Liquid.Tag
- alias Liquid.Context
+ @moduledoc """
+ Sets variables in a template
+ ```
+ {% assign foo = 'monkey' %}
+ ```
+ User can then use the variables later in the page.
+ ```
+ {{ foo }}
+ ```
+ """
+ alias Liquid.{Context, Tag, Variable}
def syntax, do: ~r/([\w\-]+)\s*=\s*(.*)\s*/
- def parse(%Tag{} = tag, %Liquid.Template{} = template), do: {%{tag | blank: true}, template}
-
+ @doc """
+ Renders the Assign markup adding the rendered parts to the output list and returning it,
+ in a tuple, with the new context.
+ """
+ @spec render(list(), %Tag{}, %Context{}) :: {list(), %Context{}}
def render(output, %Tag{markup: markup}, %Context{} = context) do
- [[_, to, from]] = syntax() |> Regex.scan(markup)
+ [[_, to, from]] = Regex.scan(syntax(), markup)
{from_value, context} =
from
diff --git a/lib/liquid/tags/capture.ex b/lib/liquid/tags/capture.ex
index 83edbf96..57b3d71c 100644
--- a/lib/liquid/tags/capture.ex
+++ b/lib/liquid/tags/capture.ex
@@ -1,18 +1,28 @@
defmodule Liquid.Capture do
- alias Liquid.Block
- alias Liquid.Context
- alias Liquid.Template
-
- def parse(%Block{} = block, %Template{} = template) do
- {%{block | blank: true}, template}
- end
+ @moduledoc """
+ Stores the result of a block into a variable without rendering it inplace.
+ ```
+ {% capture heading %}
+ Monkeys!
+ {% endcapture %}
+ ...
+ {{ heading }}
+ ```
+ Capture is useful for saving content for use later in your template, such as in a sidebar or footer.
+ """
+ alias Liquid.{Block, Render, Context}
+ @doc """
+ Renders the Capture markup adding the rendered parts to the output list and returning it,
+ in a tuple, with the new context.
+ """
+ @spec render(list(), %Block{}, %Context{}) ::
+ {list(), %Context{}} | {list(), %Block{}, %Context{}}
def render(output, %Block{markup: markup, nodelist: content}, %Context{} = context) do
variable_name = Liquid.variable_parser() |> Regex.run(markup) |> hd
- {block_output, context} = Liquid.Render.render([], content, context)
+ {block_output, context} = Render.render([], content, context)
- result_assign =
- context.assigns |> Map.put(variable_name, block_output |> Liquid.Render.to_text())
+ result_assign = context.assigns |> Map.put(variable_name, block_output |> Render.to_text())
context = %{context | assigns: result_assign}
{output, context}
diff --git a/lib/liquid/tags/case.ex b/lib/liquid/tags/case.ex
index 2e146001..9351b6f8 100644
--- a/lib/liquid/tags/case.ex
+++ b/lib/liquid/tags/case.ex
@@ -1,25 +1,42 @@
defmodule Liquid.Case do
- alias Liquid.Tag
- alias Liquid.Block
- alias Liquid.Template
- alias Liquid.Variable
- alias Liquid.Condition
+ @moduledoc """
+ Creates a switch statement to compare a variable against different values.
+ `case` initializes the switch statement, and `when` compares its values.
+ Input:
+ ```
+ {% assign handle = 'cake' %}
+ {% case handle %}
+ {% when 'cake' %}
+ This is a cake
+ {% when 'cookie' %}
+ This is a cookie
+ {% else %}
+ This is not a cake nor a cookie
+ {% endcase %}
+ ```
+ Output:
+ ```
+ This is a cake
+ ```
+ """
+ alias Liquid.{Block, Condition, Tag, Template, Variable}
+ @doc """
+ Returns a regex for Case expressions syntax validation.
+ """
def syntax, do: ~r/(#{Liquid.quoted_fragment()})/
+ @doc """
+ Returns a regex for When expressions syntax validation.
+ """
def when_syntax,
do: ~r/(#{Liquid.quoted_fragment()})(?:(?:\s+or\s+|\s*\,\s*)(#{Liquid.quoted_fragment()}.*))?/
- def parse(%Block{markup: markup} = b, %Template{} = t) do
- [[_, name]] = syntax() |> Regex.scan(markup)
- {split(name |> Variable.create(), b.nodelist), t}
- end
-
- defp split(%Variable{}, []), do: []
- defp split(%Variable{} = v, [h | t]) when is_binary(h), do: split(v, t)
- defp split(%Variable{} = _, [%Liquid.Tag{name: :else} | t]), do: t
+ def split(%Variable{}, []), do: []
+ def split(%Variable{} = v, [h | t]) when is_binary(h), do: split(v, t)
+ def split(%Variable{} = _, [%Liquid.Tag{name: :else} | t]), do: t
- defp split(%Variable{} = v, [%Liquid.Tag{name: :when, markup: markup} | t]) do
+ def split(%Variable{} = v, [%Liquid.Tag{name: :when, markup: markup} | t]) do
{nodelist, t} = Block.split(t, [:when, :else])
condition = parse_condition(v, markup)
%Block{name: :if, nodelist: nodelist, condition: condition, elselist: split(v, t)}
@@ -38,17 +55,24 @@ defmodule Liquid.Case do
end
defp parse_when(markup) do
- [[_, h | t] | m] = when_syntax() |> Regex.scan(markup)
+ [[_, h | t] | m] = Regex.scan(when_syntax(), markup)
m = m |> List.flatten() |> Liquid.List.even_elements()
- t = [t | m] |> Enum.join(" ")
+ t = Enum.join([t | m], " ")
t = if t == "", do: [], else: [t]
{h, t}
end
end
defmodule Liquid.When do
- alias Liquid.Tag, as: Tag
- alias Liquid.Template, as: Template
+ @moduledoc """
+ Defines `When` implementations (sub-component of Case). Case creates a switch statement to compare a variable with different values.
+ Case initializes the switch statement, and When compares its values.
+ """
+ alias Liquid.{Tag, Template}
+ @doc """
+ Parses a `When` tag (sub-component of Case).
+ """
+ @spec parse(%Tag{}, %Template{}) :: {%Tag{}, %Template{}}
def parse(%Tag{} = tag, %Template{} = t), do: {tag, t}
end
diff --git a/lib/liquid/tags/comment.ex b/lib/liquid/tags/comment.ex
index 2628ac82..2ca0e4b6 100644
--- a/lib/liquid/tags/comment.ex
+++ b/lib/liquid/tags/comment.ex
@@ -1,6 +1,3 @@
defmodule Liquid.Comment do
- def parse(%Liquid.Block{} = block, %Liquid.Template{} = template),
- do: {%{block | blank: true, strict: false}, template}
-
def render(output, %Liquid.Block{}, context), do: {output, context}
end
diff --git a/lib/liquid/tags/cycle.ex b/lib/liquid/tags/cycle.ex
index 7c2342c8..082a5bf2 100644
--- a/lib/liquid/tags/cycle.ex
+++ b/lib/liquid/tags/cycle.ex
@@ -3,19 +3,7 @@ defmodule Liquid.Cycle do
Implementation of `cycle` tag. Can be named or anonymous, rotates through pre-set values
Cycle is usually used within a loop to alternate between values, like colors or DOM classes.
"""
- alias Liquid.{Tag, Template, Context, Variable}
-
- @colon_parser ~r/\:(?=(?:[^'"]|'[^']*'|"[^"]*")*$)/
- # @except_colon_parser ~r/(?:[^:"']|"[^"]*"|'[^']*')+/
-
- @doc """
- Sets up the cycle name and variables to cycle through
- """
- def parse(%Tag{markup: markup} = tag, %Template{} = template) do
- {name, values} = markup |> get_name_and_values
- tag = %{tag | parts: [name | values]}
- {tag, template}
- end
+ alias Liquid.{Tag, Context, Variable}
@doc """
Returns a corresponding cycle value and increments the cycle counter
@@ -42,11 +30,4 @@ defmodule Liquid.Cycle do
variable = %Variable{parts: [], literal: parsed}
Variable.lookup(variable, context)
end
-
- defp get_name_and_values(markup) do
- [name | values] = markup |> String.split(@colon_parser, parts: 2, trim: true)
- values = if values == [], do: [name], else: values
- values = values |> hd |> String.split(",", trim: true) |> Enum.map(&String.trim(&1))
- {name, values}
- end
end
diff --git a/lib/liquid/tags/decrement.ex b/lib/liquid/tags/decrement.ex
index 0b111022..fca5f2d8 100644
--- a/lib/liquid/tags/decrement.ex
+++ b/lib/liquid/tags/decrement.ex
@@ -1,12 +1,6 @@
defmodule Liquid.Decrement do
alias Liquid.Tag
- alias Liquid.Template
- alias Liquid.Context
- alias Liquid.Variable
-
- def parse(%Tag{} = tag, %Template{} = template) do
- {tag, template}
- end
+ alias Liquid.{Context, Variable}
def render(output, %Tag{markup: markup}, %Context{} = context) do
variable = Variable.create(markup)
diff --git a/lib/liquid/tags/for_else.ex b/lib/liquid/tags/for_else.ex
index 652f9354..87554cc2 100644
--- a/lib/liquid/tags/for_else.ex
+++ b/lib/liquid/tags/for_else.ex
@@ -78,7 +78,7 @@ defmodule Liquid.ForElse do
end
end
- defp parse_iterator(%Block{markup: markup}) do
+ def parse_iterator(%Block{markup: markup}) do
[[_, item | [orig_collection | reversed]]] = Regex.scan(syntax(), markup)
collection = Expression.parse(orig_collection)
reversed = !(reversed |> List.first() |> is_nil)
diff --git a/lib/liquid/tags/if_else.ex b/lib/liquid/tags/if_else.ex
index f29a51ba..bc3ace2e 100644
--- a/lib/liquid/tags/if_else.ex
+++ b/lib/liquid/tags/if_else.ex
@@ -1,10 +1,8 @@
defmodule Liquid.ElseIf do
- def parse(%Liquid.Tag{} = tag, %Liquid.Template{} = t), do: {tag, t}
def render(_, _, _, _), do: raise("should never get here")
end
defmodule Liquid.Else do
- def parse(%Liquid.Tag{} = tag, %Liquid.Template{} = t), do: {tag, t}
def render(_, _, _, _), do: raise("should never get here")
end
@@ -13,7 +11,6 @@ defmodule Liquid.IfElse do
alias Liquid.Render
alias Liquid.Block
alias Liquid.Tag
- alias Liquid.Template
def syntax,
do: ~r/(#{Liquid.quoted_fragment()})\s*([=!<>a-z_]+)?\s*(#{Liquid.quoted_fragment()})?/
@@ -24,33 +21,6 @@ defmodule Liquid.IfElse do
}|\S+)\s*)+)/
end
- def parse(%Block{} = block, %Template{} = t) do
- block = parse_conditions(block)
-
- case Block.split(block, [:else, :elsif]) do
- {true_block, [%Tag{name: :elsif, markup: markup} | elsif_block]} ->
- {elseif, t} =
- parse(
- %Block{
- name: :if,
- markup: markup,
- nodelist: elsif_block,
- blank: Blank.blank?(elsif_block)
- },
- t
- )
-
- {%{block | nodelist: true_block, elselist: [elseif], blank: Blank.blank?(true_block)}, t}
-
- {true_block, [%Tag{name: :else} | false_block]} ->
- blank? = Blank.blank?(true_block) && Blank.blank?(false_block)
- {%{block | nodelist: true_block, elselist: false_block, blank: blank?}, t}
-
- {_, []} ->
- {%{block | blank: Blank.blank?(block.nodelist)}, t}
- end
- end
-
def render(output, %Tag{}, context) do
{output, context}
end
@@ -83,7 +53,7 @@ defmodule Liquid.IfElse do
end)
end
- defp parse_conditions(%Block{markup: markup} = block) do
+ def parse_conditions(%Block{markup: markup} = block) do
expressions = Regex.scan(expressions_and_operators(), markup)
expressions = expressions |> split_conditions |> Enum.reverse()
condition = Condition.create(expressions)
diff --git a/lib/liquid/tags/increment.ex b/lib/liquid/tags/increment.ex
index a8b512d0..f18ec107 100644
--- a/lib/liquid/tags/increment.ex
+++ b/lib/liquid/tags/increment.ex
@@ -1,13 +1,8 @@
defmodule Liquid.Increment do
alias Liquid.Tag
- alias Liquid.Template
alias Liquid.Context
alias Liquid.Variable
- def parse(%Tag{} = tag, %Template{} = template) do
- {tag, template}
- end
-
def render(output, %Tag{markup: markup}, %Context{} = context) do
variable = Variable.create(markup)
{rendered, context} = Variable.lookup(variable, context)
diff --git a/lib/liquid/tags/raw.ex b/lib/liquid/tags/raw.ex
index d1bafb4c..abbbe048 100644
--- a/lib/liquid/tags/raw.ex
+++ b/lib/liquid/tags/raw.ex
@@ -1,40 +1,7 @@
defmodule Liquid.Raw do
- alias Liquid.Template
alias Liquid.Render
alias Liquid.Block
- def full_token_possibly_invalid,
- do: ~r/\A(.*)#{Liquid.tag_start()}\s*(\w+)\s*(.*)?#{Liquid.tag_end()}\z/m
-
- def parse(%Block{name: name} = block, [h | t], accum, %Template{} = template) do
- if Regex.match?(Liquid.Raw.full_token_possibly_invalid(), h) do
- block_delimiter = "end" <> to_string(name)
-
- regex_result =
- Regex.scan(Liquid.Raw.full_token_possibly_invalid(), h, capture: :all_but_first)
-
- [extra_data, endblock | _] = regex_result |> List.flatten()
-
- if block_delimiter == endblock do
- extra_accum = accum ++ [extra_data]
- block = %{block | strict: false, nodelist: extra_accum |> Enum.filter(&(&1 != ""))}
- {block, t, template}
- else
- if length(t) > 0 do
- parse(block, t, accum ++ [h], template)
- else
- raise "No matching end for block {% #{to_string(name)} %}"
- end
- end
- else
- parse(block, t, accum ++ [h], template)
- end
- end
-
- def parse(%Block{} = block, %Template{} = t) do
- {block, t}
- end
-
def render(output, %Block{} = block, context) do
Render.render(output, block.nodelist, context)
end
diff --git a/lib/liquid/tags/table_row.ex b/lib/liquid/tags/table_row.ex
index be6bc6a0..9453c76b 100644
--- a/lib/liquid/tags/table_row.ex
+++ b/lib/liquid/tags/table_row.ex
@@ -41,7 +41,7 @@ defmodule Liquid.TableRow do
end
end
- defp parse_iterator(%Block{markup: markup}) do
+ def parse_iterator(%Block{markup: markup}) do
[[_, item | [orig_collection]]] = Regex.scan(syntax(), markup)
collection = Expression.parse(orig_collection)
attributes = Liquid.tag_attributes() |> Regex.scan(markup)
diff --git a/lib/liquid/tags/unless.ex b/lib/liquid/tags/unless.ex
index 684f06be..c6eb5b1a 100644
--- a/lib/liquid/tags/unless.ex
+++ b/lib/liquid/tags/unless.ex
@@ -1,15 +1,9 @@
defmodule Liquid.Unless do
- alias Liquid.IfElse
alias Liquid.Block
- alias Liquid.Template
alias Liquid.Condition
alias Liquid.Tag
alias Liquid.Render
- def parse(%Block{} = block, %Template{} = t) do
- IfElse.parse(block, t)
- end
-
def render(output, %Tag{}, context) do
{output, context}
end
diff --git a/lib/liquid/template.ex b/lib/liquid/template.ex
index 76b84f25..9a94d803 100644
--- a/lib/liquid/template.ex
+++ b/lib/liquid/template.ex
@@ -4,7 +4,7 @@ defmodule Liquid.Template do
"""
defstruct root: nil, presets: %{}, blocks: [], errors: []
- alias Liquid.{Template, Render, Context}
+ alias Liquid.{Template, Parser, NimbleTranslator, Render, Context}
@doc """
Function that renders passed template and context to string
@@ -61,11 +61,20 @@ defmodule Liquid.Template do
def parse(value, presets \\ %{})
def parse(<>, presets) do
- Liquid.Parse.parse(markup, %Template{presets: presets})
+ result = Parser.parse(markup)
+
+ template =
+ case result do
+ {:ok, _value} -> NimbleTranslator.translate(result)
+ {:error, value, _} -> raise value
+ _ -> ""
+ end
+
+ %{template | presets: presets}
end
@spec parse(nil, map) :: Liquid.Template
def parse(nil, presets) do
- Liquid.Parse.parse("", %Template{presets: presets})
+ parse("", %Template{presets: presets})
end
end
diff --git a/lib/liquid/tokenizer.ex b/lib/liquid/tokenizer.ex
new file mode 100644
index 00000000..06ca5ceb
--- /dev/null
+++ b/lib/liquid/tokenizer.ex
@@ -0,0 +1,31 @@
+defmodule Liquid.Tokenizer do
+ @moduledoc """
+ Prepares markup to be parsed. Tokenizer splits the code between starting literal and rest of markup.
+ When called recursively, it allows to process only liquid part (tags and variables) and bypass the slower literal.
+ """
+
+ alias Liquid.Combinators.General
+
+ @doc """
+ Takes a markup, find start of liquid construction (tag or variable) and returns
+ a tuple with two elements: a literal and rest(with tags/variables and optionally more literals)
+ """
+ @spec tokenize(String.t()) :: {String.t(), String.t()}
+ def tokenize(markup) do
+ case :binary.match(markup, [
+ General.codepoints().start_tag,
+ General.codepoints().start_variable
+ ]) do
+ :nomatch -> {markup, ""}
+ {0, _} -> {"", markup}
+ {start, _} -> split(markup, start)
+ end
+ end
+
+ defp split(markup, start) do
+ len = byte_size(markup)
+ literal = :binary.part(markup, {0, start})
+ rest_markup = :binary.part(markup, {len, start - len})
+ {literal, rest_markup}
+ end
+end
diff --git a/lib/liquid/translators/general.ex b/lib/liquid/translators/general.ex
new file mode 100644
index 00000000..5bc37f67
--- /dev/null
+++ b/lib/liquid/translators/general.ex
@@ -0,0 +1,48 @@
+defmodule Liquid.Translators.General do
+ @moduledoc """
+ General purpose functions used by multiple translators.
+ """
+ alias Liquid.Translators.Markup
+
+ @doc """
+ Returns a corresponding type value:
+
+ Simple Value Type:
+ {variable: [parts: [part: "i"]]} -> "i"
+ {variable: [parts: [part: "products", part: "title"]]} -> "product.title"
+ {variable: [parts: [part: "product", part: "title", index: 0]]} -> "product.title[0]"
+ "string_value" -> "'string_value'"
+ 2 -> "2"
+
+ Complex Value Type: {:range, [start: "any_simple_type", end: "any_simple_type"]} -> "(any_simple_type..any_simple_type)"
+ """
+ @spec variable_in_parts(Liquid.Combinators.LexicalToken.variable_value()) :: String.t()
+ def variable_in_parts(variable) do
+ Enum.map(variable, fn {key, value} ->
+ case key do
+ :part -> value |> Markup.literal() |> String.replace("?", "")
+ :index -> "[#{Markup.literal(value)}]"
+ _ -> "[#{Markup.literal(value)}]"
+ end
+ end)
+ end
+
+ @doc """
+ Returns true when a tuple is an Else/Elseif tag.
+ """
+ @spec else?(tuple()) :: boolean()
+ def else?({key, _}) when key in [:else, :elsif], do: true
+ def else?(_), do: false
+
+ @doc """
+ Returns true when a tag is an conditional statement (evaluation, else, elsif).
+ `if` statement is excluded because it only process tags inside `if`.
+ """
+ @spec conditional_statement?(tuple()) :: boolean()
+ def conditional_statement?({key, _}) when key in [:evaluation, :else, :elsif], do: true
+ def conditional_statement?(_), do: false
+
+ def types_only_list(element) do
+ if is_list(element), do: element, else: [element]
+ end
+end
diff --git a/lib/liquid/translators/markup.ex b/lib/liquid/translators/markup.ex
new file mode 100644
index 00000000..3ce75b13
--- /dev/null
+++ b/lib/liquid/translators/markup.ex
@@ -0,0 +1,61 @@
+defmodule Liquid.Translators.Markup do
+ @moduledoc """
+ Transform AST to String
+ """
+
+ @doc """
+ Takes the New (NimbleParser) AST and creates a String and use it as a markup for the old AST.
+ """
+ @spec literal(list() | tuple()) :: String.t()
+ def literal(elem, join_with) when is_list(elem) do
+ elem
+ |> Enum.map(&literal/1)
+ |> Enum.join(join_with)
+ end
+
+ def literal({:parts, value}) do
+ value |> literal(".") |> String.replace(".[", "[")
+ end
+
+ def literal(elem) when is_list(elem), do: literal(elem, "")
+ def literal({:index, value}) when is_binary(value), do: "[\"#{literal(value)}\"]"
+ def literal({:index, value}), do: "[#{literal(value)}]"
+ def literal({:value, value}) when is_binary(value), do: "\"#{literal(value)}\""
+ def literal({:value, value}), do: literal(value)
+ def literal({:variable, value}), do: literal(value)
+ def literal({:variable_name, value}), do: literal(value)
+ def literal({:filters, value}), do: " | " <> literal(value, " | ")
+ def literal({:params, value}), do: ": " <> literal(value, ", ")
+ def literal({:assignment, [name | value]}), do: "#{literal(name)}: #{literal(value)}"
+
+ def literal({:logical, [operator, value]}),
+ do: " #{literal(operator)} #{normalize_value(value)}"
+
+ def literal({:condition, {left, op, right}}),
+ do: "#{normalize_value(left)} #{literal(op)} #{normalize_value(right)}"
+
+ def literal({:conditions, [nil]}), do: "null"
+ def literal({:conditions, [value]}) when is_bitstring(value), do: "\"#{literal(value)}\""
+
+ def literal({predicate, value}) when predicate in [:for, :with],
+ do: "#{literal(predicate)} #{literal(value)}"
+
+ def literal({:start, value}), do: "(#{literal(value)}."
+
+ def literal({:end, value}), do: ".#{literal(value)})"
+
+ def literal({parameter, value}) when parameter in [:offset, :limit, :cols],
+ do: " #{literal(parameter)}: #{literal(value)}"
+
+ def literal({:reversed, _value}), do: " reversed"
+
+ def literal({_, nil}), do: "null"
+
+ def literal({_, value}), do: literal(value)
+ def literal(elem), do: to_string(elem)
+
+ # This is to manage the strings and nulls to string
+ def normalize_value(value) when is_nil(value), do: "null"
+ def normalize_value(value) when is_bitstring(value), do: "\"#{value}\""
+ def normalize_value(value), do: literal(value)
+end
diff --git a/lib/liquid/translators/tags/assign.ex b/lib/liquid/translators/tags/assign.ex
new file mode 100644
index 00000000..1ebfa35d
--- /dev/null
+++ b/lib/liquid/translators/tags/assign.ex
@@ -0,0 +1,17 @@
+defmodule Liquid.Translators.Tags.Assign do
+ @moduledoc """
+ Translate new AST to old AST for Assign tag.
+ """
+ alias Liquid.Translators.Markup
+ alias Liquid.Combinators.Tags.Assign
+ alias Liquid.Tag
+
+ @doc """
+ Takes the markup of the new AST creates a `Liquid.Tag` struct (old AST) and fill the keys needed to render an Assign tag.
+ """
+ @spec translate(Assign.markup()) :: Tag.t()
+ def translate([h | t]) do
+ markup = [h | ["=" | t]]
+ %Liquid.Tag{name: :assign, markup: Markup.literal(markup), blank: true}
+ end
+end
diff --git a/lib/liquid/translators/tags/break.ex b/lib/liquid/translators/tags/break.ex
new file mode 100644
index 00000000..75ce81b9
--- /dev/null
+++ b/lib/liquid/translators/tags/break.ex
@@ -0,0 +1,13 @@
+defmodule Liquid.Translators.Tags.Break do
+ @moduledoc """
+ Translate new AST to old AST for the Break tag, this tag is only present inside For tag.
+ """
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Tag` struct (old AST) and fill the keys needed, this is used recursively by the For tag.
+ """
+ @spec translate(List.t() | String.t()) :: Tag.t()
+ def translate(_markup) do
+ %Liquid.Tag{name: :break}
+ end
+end
diff --git a/lib/liquid/translators/tags/capture.ex b/lib/liquid/translators/tags/capture.ex
new file mode 100644
index 00000000..3de090fb
--- /dev/null
+++ b/lib/liquid/translators/tags/capture.ex
@@ -0,0 +1,26 @@
+defmodule Liquid.Translators.Tags.Capture do
+ @moduledoc """
+ Translate new AST to old AST for the Capture tag.
+ """
+ alias Liquid.Translators.{General, Markup}
+ alias Liquid.Combinators.Tags.Capture
+ alias Liquid.Block
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a Capture tag.
+ """
+ @spec translate(Capture.markup()) :: Block.t()
+ def translate([variable, body: parts]) do
+ nodelist =
+ parts
+ |> Liquid.NimbleTranslator.process_node()
+ |> General.types_only_list()
+
+ %Liquid.Block{
+ name: :capture,
+ markup: Markup.literal(variable),
+ blank: true,
+ nodelist: nodelist
+ }
+ end
+end
diff --git a/lib/liquid/translators/tags/case.ex b/lib/liquid/translators/tags/case.ex
new file mode 100644
index 00000000..696de8bf
--- /dev/null
+++ b/lib/liquid/translators/tags/case.ex
@@ -0,0 +1,43 @@
+defmodule Liquid.Translators.Tags.Case do
+ @moduledoc """
+ Translate new AST to old AST for the Case tag.
+ """
+ alias Liquid.Translators.Markup
+ alias Liquid.Combinators.Tags.Case
+ alias Liquid.{NimbleTranslator, Block, Variable, Case}
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a Case tag.
+ """
+ @spec translate(Case.markup()) :: Block.t()
+ def translate([condition, {:body, _} | when_list]) do
+ to_case_block(Markup.literal(condition), Enum.flat_map(when_list, &process_clauses/1))
+ end
+
+ defp process_clauses({:when, [condition, body: values]}) do
+ tag = %Liquid.Tag{
+ name: :when,
+ markup: Markup.literal(condition)
+ }
+
+ result = NimbleTranslator.process_node(values)
+ [tag, result]
+ end
+
+ defp process_clauses({:else, [body: values]}) do
+ process_list = NimbleTranslator.process_node(values)
+
+ else_liquid_tag = %Liquid.Tag{name: :else}
+
+ if is_list(process_list) do
+ [else_liquid_tag | process_list]
+ else
+ [else_liquid_tag, process_list]
+ end
+ end
+
+ defp to_case_block(markup, nodelist) do
+ [[_, name]] = Regex.scan(Case.syntax(), markup)
+ Case.split(Variable.create(name), nodelist)
+ end
+end
diff --git a/lib/liquid/translators/tags/comment.ex b/lib/liquid/translators/tags/comment.ex
new file mode 100644
index 00000000..f378920b
--- /dev/null
+++ b/lib/liquid/translators/tags/comment.ex
@@ -0,0 +1,16 @@
+defmodule Liquid.Translators.Tags.Comment do
+ @moduledoc """
+ Translate new AST to old AST for the Comment tag.
+ """
+
+ alias Liquid.Combinators.Tags.Comment
+ alias Liquid.Block
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a Comment tag.
+ """
+ @spec translate(Comment.markup()) :: Block.t()
+ def translate(_markup) do
+ %Liquid.Block{name: :comment, blank: true, strict: false, nodelist: [""]}
+ end
+end
diff --git a/lib/liquid/translators/tags/continue.ex b/lib/liquid/translators/tags/continue.ex
new file mode 100644
index 00000000..ad59357d
--- /dev/null
+++ b/lib/liquid/translators/tags/continue.ex
@@ -0,0 +1,13 @@
+defmodule Liquid.Translators.Tags.Continue do
+ @moduledoc """
+ Translate new AST to old AST for the continue tag, this tag is only present inside the For body tag.
+ """
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Tag` struct (old AST) and fill the keys needed, this is used recursively by the for tag.
+ """
+ @spec translate(List.t() | String.t()) :: Tag.t()
+ def translate(_markup) do
+ %Liquid.Tag{name: :continue}
+ end
+end
diff --git a/lib/liquid/translators/tags/custom_tag.ex b/lib/liquid/translators/tags/custom_tag.ex
new file mode 100644
index 00000000..8929a104
--- /dev/null
+++ b/lib/liquid/translators/tags/custom_tag.ex
@@ -0,0 +1,59 @@
+defmodule Liquid.Translators.Tags.CustomTag do
+ @moduledoc """
+ Translates new AST to old AST for the Custom tag.
+ """
+ alias Liquid.{Template, Tag, Block}
+ alias Liquid.Translators.General
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Tag` struct (old AST) and fill the keys needed to render a Custom tag.
+ """
+ @spec translate(Custom_tag.markup()) :: Tag.t()
+ def translate(custom_name: [name], custom_markup: markup) do
+ tag_name = String.to_atom(name)
+ custom_tag = Application.get_env(:liquid, :extra_tags)
+
+ case is_map(custom_tag) do
+ true ->
+ case Map.has_key?(custom_tag, tag_name) do
+ true ->
+ partial_tag = %Tag{
+ name: String.to_atom(name),
+ markup: String.trim(markup)
+ }
+
+ user_parse(partial_tag, custom_tag, tag_name)
+
+ false ->
+ raise Liquid.SyntaxError, message: "This custom tag: {% #{name} %} is not registered"
+ end
+
+ false ->
+ raise Liquid.SyntaxError, message: "This custom tag: {% #{name} %} is not registered"
+ end
+ end
+
+ def translate(custom_name: [name], custom_markup: markup, body: body) do
+ tag_name = String.to_atom(name)
+ custom_tags = Application.get_env(:liquid, :extra_tags)
+
+ nodelist =
+ body
+ |> Liquid.NimbleTranslator.process_node()
+ |> General.types_only_list()
+
+ partial_block = %Block{
+ name: tag_name,
+ markup: String.trim(markup),
+ nodelist: nodelist
+ }
+
+ user_parse(partial_block, custom_tags, tag_name)
+ end
+
+ defp user_parse(partial_block, map_of_tags, tag_name) do
+ {module, _type} = Map.get(map_of_tags, tag_name)
+ {block, _contex} = module.parse(partial_block, %Template{})
+ block
+ end
+end
diff --git a/lib/liquid/translators/tags/cycle.ex b/lib/liquid/translators/tags/cycle.ex
new file mode 100644
index 00000000..2848c942
--- /dev/null
+++ b/lib/liquid/translators/tags/cycle.ex
@@ -0,0 +1,31 @@
+defmodule Liquid.Translators.Tags.Cycle do
+ @moduledoc """
+ Translate new AST to old AST for the Cycle tag.
+ """
+
+ alias Liquid.Translators.Markup
+ alias Liquid.Combinators.Tags.Cycle
+ alias Liquid.Tag
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Tag` struct (old AST) and fill the keys needed to render a Cycle tag.
+ """
+ @spec translate(Cycle.markup()) :: Tag.t()
+ def translate(values: values) do
+ parts = Enum.map(values, &cycle_to_string/1)
+ markup = Markup.literal(parts, ", ")
+ %Liquid.Tag{name: :cycle, markup: markup, parts: [markup | parts]}
+ end
+
+ def translate(group: [cycle_group_value], values: cycle_values) do
+ cycle_value_in_parts = Enum.map(cycle_values, &cycle_to_string/1)
+ markup = cycle_group_value <> ": " <> Markup.literal(cycle_value_in_parts, ", ")
+ parts = [cycle_group_value | cycle_value_in_parts]
+
+ %Liquid.Tag{name: :cycle, markup: markup, parts: parts}
+ end
+
+ defp cycle_to_string(value) when is_bitstring(value), do: "'#{value}'"
+ defp cycle_to_string(nil), do: "null"
+ defp cycle_to_string(value), do: "#{Markup.literal(value)}"
+end
diff --git a/lib/liquid/translators/tags/decrement.ex b/lib/liquid/translators/tags/decrement.ex
new file mode 100644
index 00000000..211be23d
--- /dev/null
+++ b/lib/liquid/translators/tags/decrement.ex
@@ -0,0 +1,18 @@
+defmodule Liquid.Translators.Tags.Decrement do
+ @moduledoc """
+ Translate new AST to old AST for the Decrement tag.
+ """
+
+ alias Liquid.Translators.Markup
+ alias Liquid.Combinators.Tags.Decrement
+ alias Liquid.Tag
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Tag` struct (old AST) and fill the keys needed to render a Decrement tag.
+ """
+ @spec translate(Decrement.markup()) :: Tag.t()
+ def translate(markup) do
+ variable_name = Keyword.get(markup, :variable)
+ %Liquid.Tag{name: :decrement, markup: Markup.literal(variable_name)}
+ end
+end
diff --git a/lib/liquid/translators/tags/for.ex b/lib/liquid/translators/tags/for.ex
new file mode 100644
index 00000000..e693bc06
--- /dev/null
+++ b/lib/liquid/translators/tags/for.ex
@@ -0,0 +1,51 @@
+defmodule Liquid.Translators.Tags.For do
+ @moduledoc """
+ Translate new AST to old AST for the For tag.
+ """
+
+ alias Liquid.Block
+ alias Liquid.Translators.{General, Markup}
+ alias Liquid.NimbleTranslator
+ alias Liquid.Combinators.Tags.For
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a For tag.
+ """
+ @spec translate(For.markup()) :: Block.t()
+ def translate(
+ statements: [variable: variable, value: value, params: params],
+ body: body,
+ else: [body: else_body]
+ ) do
+ create_block_for(variable, value, params, body, else_body)
+ end
+
+ def translate(
+ statements: [variable: variable, value: value, params: params],
+ body: body
+ ) do
+ create_block_for(variable, value, params, body, [])
+ end
+
+ defp create_block_for(variable, value, params, body, else_body) do
+ variable_markup = Markup.literal(variable)
+ for_params_markup = Markup.literal(params)
+ markup = "#{variable_markup} in #{Markup.literal(value)} #{for_params_markup}"
+
+ %Liquid.Block{
+ elselist: unwrap(NimbleTranslator.process_node(else_body)),
+ iterator: process_iterator(%Block{markup: markup}),
+ markup: markup,
+ name: :for,
+ nodelist: General.types_only_list(NimbleTranslator.process_node(body))
+ }
+ end
+
+ defp process_iterator(%Block{markup: markup}) do
+ Liquid.ForElse.parse_iterator(%Block{markup: markup})
+ end
+
+ defp unwrap([]), do: []
+ defp unwrap([first | _]), do: first
+ defp unwrap(element), do: element
+end
diff --git a/lib/liquid/translators/tags/if.ex b/lib/liquid/translators/tags/if.ex
new file mode 100644
index 00000000..352750c2
--- /dev/null
+++ b/lib/liquid/translators/tags/if.ex
@@ -0,0 +1,92 @@
+defmodule Liquid.Translators.Tags.If do
+ @moduledoc """
+ Translate new AST to old AST for the If tag.
+ """
+ alias Liquid.Translators.{General, Markup}
+ alias Liquid.Combinators.Tags.If
+ alias Liquid.{Block, IfElse, NimbleTranslator}
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a If tag.
+ """
+ @spec translate(atom(), If.conditional_body()) :: Block.t()
+ def translate(name, conditions: [value], body: body) when is_bitstring(value) do
+ create_block_if(name, "\"#{Markup.literal(value)}\"", body)
+ end
+
+ def translate(name, [{:conditions, [value]}, {:body, body_parts} | elselist])
+ when is_bitstring(value) do
+ create_block_if(
+ name,
+ "\"#{Markup.literal(value)}\"",
+ body_parts,
+ normalize_elselist(elselist)
+ )
+ end
+
+ def translate(name, conditions: conditions, body: body) do
+ create_block_if(name, "#{Markup.literal(conditions)}", body)
+ end
+
+ def translate(name, [{:conditions, conditions}, {:body, body_parts} | elselist]) do
+ create_block_if(
+ name,
+ "#{Markup.literal(conditions)}",
+ body_parts,
+ normalize_elselist(elselist)
+ )
+ end
+
+ defp normalize_elselist([{:elsif, _} | [_]] = else_list) do
+ {list, _} =
+ Enum.reduce_while(else_list, {[], nil}, fn x, {list, last} ->
+ case x do
+ {:elsif, _} ->
+ {:cont, {[x | list], x}}
+
+ _ ->
+ {tag, params} = last
+
+ final_list =
+ List.replace_at(
+ list,
+ length(list) - 1,
+ {tag, Enum.reverse([x | Enum.reverse(params)])}
+ )
+
+ {:halt, {final_list, nil}}
+ end
+ end)
+
+ list
+ end
+
+ defp normalize_elselist(else_list), do: else_list
+
+ defp create_block_if(name, markup, nodelist) do
+ block = %Liquid.Block{
+ name: name,
+ markup: markup,
+ nodelist: nodelist |> NimbleTranslator.process_node() |> General.types_only_list(),
+ blank: Blank.blank?(nodelist)
+ }
+
+ IfElse.parse_conditions(block)
+ end
+
+ defp create_block_if(name, markup, nodelist, else_list) do
+ block = %Liquid.Block{
+ name: name,
+ markup: markup,
+ nodelist: nodelist |> NimbleTranslator.process_node() |> General.types_only_list(),
+ blank: Blank.blank?(nodelist) and Blank.blank?(else_list),
+ elselist:
+ else_list
+ |> NimbleTranslator.process_node()
+ |> List.flatten()
+ |> General.types_only_list()
+ }
+
+ IfElse.parse_conditions(block)
+ end
+end
diff --git a/lib/liquid/translators/tags/ifchanged.ex b/lib/liquid/translators/tags/ifchanged.ex
new file mode 100644
index 00000000..b584d65d
--- /dev/null
+++ b/lib/liquid/translators/tags/ifchanged.ex
@@ -0,0 +1,14 @@
+defmodule Liquid.Translators.Tags.Ifchanged do
+ @moduledoc """
+ Translate new AST to old AST for the Ifchanged tag.
+ """
+ alias Liquid.NimbleTranslator
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a Ifchanged tag.
+ """
+ def translate(body: body_parts) do
+ nodelist = NimbleTranslator.process_node(body_parts)
+ %Liquid.Block{name: :ifchanged, nodelist: nodelist}
+ end
+end
diff --git a/lib/liquid/translators/tags/include.ex b/lib/liquid/translators/tags/include.ex
new file mode 100644
index 00000000..1bed90ad
--- /dev/null
+++ b/lib/liquid/translators/tags/include.ex
@@ -0,0 +1,22 @@
+defmodule Liquid.Translators.Tags.Include do
+ @moduledoc """
+ Translate new AST to old AST for the Include tag.
+ """
+
+ alias Liquid.{Tag, Include}
+ alias Liquid.Translators.Markup
+ alias Liquid.Combinators.Tags.Include, as: IncludeCombinator
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Tag` struct (old AST) and fill the keys needed to render a Include tag.
+ """
+ @spec translate(IncludeCombinator.markup()) :: Tag.t()
+ def translate([snippet]), do: parse("'#{Markup.literal(snippet)}'")
+
+ def translate([snippet, rest]),
+ do: parse("'#{Markup.literal(snippet)}' #{Markup.literal(rest)}")
+
+ defp parse(markup) do
+ Include.parse(%Tag{markup: markup, name: :include})
+ end
+end
diff --git a/lib/liquid/translators/tags/increment.ex b/lib/liquid/translators/tags/increment.ex
new file mode 100644
index 00000000..a76f67ab
--- /dev/null
+++ b/lib/liquid/translators/tags/increment.ex
@@ -0,0 +1,18 @@
+defmodule Liquid.Translators.Tags.Increment do
+ @moduledoc """
+ Translate new AST to old AST for the Increment tag.
+ """
+
+ alias Liquid.Translators.Markup
+ alias Liquid.Combinators.Tags.Increment
+ alias Liquid.Tag
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Tag` struct (old AST) and fill the keys needed to render a Increment tag.
+ """
+ @spec translate(Increment.markup()) :: Tag.t()
+ def translate(markup) do
+ variable_name = Keyword.get(markup, :variable)
+ %Liquid.Tag{name: :increment, markup: "#{Markup.literal(variable_name)}"}
+ end
+end
diff --git a/lib/liquid/translators/tags/liquid_variable.ex b/lib/liquid/translators/tags/liquid_variable.ex
new file mode 100644
index 00000000..d2dbece2
--- /dev/null
+++ b/lib/liquid/translators/tags/liquid_variable.ex
@@ -0,0 +1,58 @@
+defmodule Liquid.Translators.Tags.LiquidVariable do
+ @moduledoc """
+ Translate new AST to old AST for liquid variables.
+ """
+
+ alias Liquid.Translators.{General, Markup}
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Variable` struct (old AST) and fill the keys needed to render a variable and filters.
+ """
+ def translate(variable: [parts: variable_list]) do
+ parts = General.variable_in_parts(variable_list)
+ variable_name = Markup.literal(parts: variable_list)
+ %Liquid.Variable{name: variable_name, parts: parts}
+ end
+
+ def translate(variable: [parts: variable_list, filters: filters]) do
+ parts = General.variable_in_parts(variable_list)
+ variable_name = Markup.literal(parts: variable_list)
+ filters_markup = transform_filters(filters)
+ %Liquid.Variable{name: variable_name, parts: parts, filters: filters_markup}
+ end
+
+ def translate([value, filters: filters]) when is_bitstring(value) do
+ filters_markup = transform_filters(filters)
+
+ %Liquid.Variable{
+ name: "'#{Markup.literal(value)}'",
+ filters: filters_markup,
+ literal: Markup.literal(value)
+ }
+ end
+
+ def translate([value, filters: filters]) do
+ filters_markup = transform_filters(filters)
+ %Liquid.Variable{name: Markup.literal(value), filters: filters_markup, literal: value}
+ end
+
+ def translate([value]) when is_bitstring(value),
+ do: %Liquid.Variable{name: "'#{Markup.literal(value)}'", literal: Markup.literal(value)}
+
+ def translate([value]), do: %Liquid.Variable{name: Markup.literal(value), literal: value}
+
+ defp transform_filters(filters_list) do
+ Keyword.get_values(filters_list, :filter)
+ |> Enum.map(&filters_to_list/1)
+ end
+
+ defp filters_to_list([filter_name]) do
+ [String.to_atom(filter_name), []]
+ end
+
+ defp filters_to_list([filter_name, filter_param]) do
+ {_, param_value} = filter_param
+ filter_list = Enum.map(param_value, &Markup.literal/1)
+ [String.to_atom(filter_name), filter_list]
+ end
+end
diff --git a/lib/liquid/translators/tags/raw.ex b/lib/liquid/translators/tags/raw.ex
new file mode 100644
index 00000000..6341561b
--- /dev/null
+++ b/lib/liquid/translators/tags/raw.ex
@@ -0,0 +1,17 @@
+defmodule Liquid.Translators.Tags.Raw do
+ alias Liquid.Translators.Markup
+
+ @moduledoc """
+ Translate new AST to old AST for Raw tag.
+ """
+
+ alias Liquid.Block
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a Raw tag.
+ """
+ @spec translate(String.t()) :: Block.t()
+ def translate([markup]) do
+ %Liquid.Block{name: :raw, strict: false, nodelist: ["#{Markup.literal(markup)}"]}
+ end
+end
diff --git a/lib/liquid/translators/tags/tablerow.ex b/lib/liquid/translators/tags/tablerow.ex
new file mode 100644
index 00000000..5ed41171
--- /dev/null
+++ b/lib/liquid/translators/tags/tablerow.ex
@@ -0,0 +1,31 @@
+defmodule Liquid.Translators.Tags.Tablerow do
+ @moduledoc """
+ Translate new AST to old AST for the Tablerow tag.
+ """
+ alias Liquid.{Block, NimbleTranslator, TableRow}
+ alias Liquid.Translators.Markup
+ alias Liquid.Combinators.Tags.Tablerow, as: TablerowMarkup
+
+ @doc """
+ Takes the markup of the new AST, creates a `Liquid.Block` struct (old AST) and fill the keys needed to render a Tablerow tag.
+ """
+ @spec translate(TablerowMarkup.markup()) :: Block.t()
+ def translate(
+ statements: [variable: variable, value: value, params: params],
+ body: body
+ ) do
+ markup = "#{Markup.literal(variable)} in #{Markup.literal(value)} #{Markup.literal(params)}"
+
+ %Liquid.Block{
+ iterator: TableRow.parse_iterator(%Block{markup: markup}),
+ markup: markup,
+ name: :tablerow,
+ nodelist: fixer_tablerow_types_only_list(NimbleTranslator.process_node(body))
+ }
+ end
+
+ # fix current parser tablerow tag bug and compatibility
+ defp fixer_tablerow_types_only_list(element) do
+ if is_list(element), do: element, else: [element]
+ end
+end
diff --git a/lib/protocols/blank.ex b/lib/protocols/blank.ex
index e9ce3bbf..6e822418 100644
--- a/lib/protocols/blank.ex
+++ b/lib/protocols/blank.ex
@@ -12,7 +12,7 @@ defimpl Blank, for: List do
def blank?(list) do
list
|> Enum.all?(fn
- x when is_binary(x) -> !!Regex.match?(~r/\A\s*\z/, x)
+ x when is_binary(x) -> Regex.match?(~r/\A\s*\z/, x)
%Block{blank: true} -> true
%Tag{blank: true} -> true
_ -> false
diff --git a/mix.exs b/mix.exs
index b4f1c7a3..e74e87de 100644
--- a/mix.exs
+++ b/mix.exs
@@ -4,8 +4,8 @@ defmodule Liquid.Mixfile do
def project do
[
app: :liquid,
- version: "0.9.1",
- elixir: "~> 1.5",
+ version: "1.0.0-alpha.1",
+ elixir: "~> 1.6",
deps: deps(),
name: "Liquid",
description: description(),
@@ -31,10 +31,12 @@ defmodule Liquid.Mixfile do
defp deps do
[
{:credo, "~> 0.9.0 or ~> 1.0", only: [:dev, :test]},
- {:benchee, "~> 0.11", only: :dev},
+ {:benchee, "~> 0.13", only: :dev},
+ {:benchee_csv, "~> 0.7", only: :dev},
{:benchfella, "~> 0.3", only: [:dev, :test]},
{:timex, "~> 3.0"},
- {:excoveralls, "~> 0.8", only: :test},
+ {:excoveralls, "~> 0.10.1", only: :test},
+ {:nimble_parsec, "~> 0.4.0"},
{:jason, "~> 1.1", only: [:dev, :test]},
{:ex_doc, ">= 0.0.0", only: :dev}
]
diff --git a/mix.lock b/mix.lock
index 7c08690f..1bae63ce 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,10 +1,12 @@
%{
"benchee": {:hex, :benchee, "0.13.2", "30cd4ff5f593fdd218a9b26f3c24d580274f297d88ad43383afe525b1543b165", [:mix], [{:deep_merge, "~> 0.1", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"},
+ "benchee_csv": {:hex, :benchee_csv, "0.8.0", "0ca094677d6e2b2f601b7ee7864b754789ef9d24d079432e5e3d6f4fb83a4d80", [:mix], [{:benchee, "~> 0.12", [hex: :benchee, repo: "hexpm", optional: false]}, {:csv, "~> 2.0", [hex: :csv, repo: "hexpm", optional: false]}], "hexpm"},
"benchfella": {:hex, :benchfella, "0.3.5", "b2122c234117b3f91ed7b43b6e915e19e1ab216971154acd0a80ce0e9b8c05f5", [:mix], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
"credo": {:hex, :credo, "1.0.0", "aaa40fdd0543a0cf8080e8c5949d8c25f0a24e4fc8c1d83d06c388f5e5e0ea42", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
+ "csv": {:hex, :csv, "2.1.1", "a4c1a7c30d2151b6e4976cb2f52c0a1d49ec965afb737ed84a684bc4284d1627", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm"},
"deep_merge": {:hex, :deep_merge, "0.2.0", "c1050fa2edf4848b9f556fba1b75afc66608a4219659e3311d9c9427b5b680b3", [:mix], [], "hexpm"},
"earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
@@ -13,11 +15,12 @@
"hackney": {:hex, :hackney, "1.15.0", "287a5d2304d516f63e56c469511c42b016423bcb167e61b611f6bad47e3ca60e", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
- "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
- "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
+ "makeup": {:hex, :makeup, "0.5.6", "da47b331b1fe0a5f0380cc3a6967200eac5e1daaa9c6bff4b0310b3fcc12b98f", [:mix], [{:nimble_parsec, "~> 0.4.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
+ "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
- "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"},
+ "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"},
+ "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"},
"timex": {:hex, :timex, "3.5.0", "b0a23167da02d0fe4f1a4e104d1f929a00d348502b52432c05de875d0b9cffa5", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
diff --git a/test/integration/cases_test.exs b/test/integration/cases_test.exs
index ed37c4d4..994ebece 100644
--- a/test/integration/cases_test.exs
+++ b/test/integration/cases_test.exs
@@ -8,10 +8,15 @@ defmodule Liquid.Test.Integration.CasesTest do
|> File.read!()
|> Jason.decode!()
- for level <- @levels, test_case <- File.ls!("#{@cases_dir}/#{level}") do
+ for level <- @levels,
+ test_case <- File.ls!("#{@cases_dir}/#{level}") do
test "case #{level} - #{test_case}" do
- input_liquid = File.read!("#{@cases_dir}/#{unquote(level)}/#{unquote(test_case)}/input.liquid")
- expected_output = File.read!("#{@cases_dir}/#{unquote(level)}/#{unquote(test_case)}/output.html")
+ input_liquid =
+ File.read!("#{@cases_dir}/#{unquote(level)}/#{unquote(test_case)}/input.liquid")
+
+ expected_output =
+ File.read!("#{@cases_dir}/#{unquote(level)}/#{unquote(test_case)}/output.html")
+
liquid_output = render(input_liquid, @data)
assert liquid_output == expected_output
end
diff --git a/test/liquid/block_test.exs b/test/liquid/block_test.exs
index c0095376..a4952dce 100644
--- a/test/liquid/block_test.exs
+++ b/test/liquid/block_test.exs
@@ -69,15 +69,17 @@ defmodule Liquid.BlockTest do
assert {TestTag, Liquid.Tag} = Liquid.Registers.lookup("test")
end
- test "with custom block" do
- Liquid.Registers.register("testblock", TestBlock, Liquid.Block)
- template = Liquid.Template.parse("{% testblock %}{% endtestblock %}")
- assert [%Liquid.Block{name: :testblock}] = template.root.nodelist
- end
+ # TODO: Custom Tag
+ # test "with custom block" do
+ # Liquid.Registers.register("testblock", TestBlock, Liquid.Block)
+ # template = Liquid.Template.parse("{% testblock %}{% endtestblock %}")
+ # assert [%Liquid.Block{name: :testblock}] = template.root.nodelist
+ # end
- test "with custom tag" do
- Liquid.Registers.register("testtag", TestTag, Liquid.Tag)
- template = Liquid.Template.parse("{% testtag %}")
- assert [%Liquid.Tag{name: :testtag}] = template.root.nodelist
- end
+ # TODO: Custom Tag
+ # test "with custom tag" do
+ # Liquid.Registers.register("testtag", TestTag, Liquid.Tag)
+ # template = Liquid.Template.parse("{% testtag %}")
+ # assert [%Liquid.Tag{name: :testtag}] = template.root.nodelist
+ # end
end
diff --git a/test/liquid/combinators/general_test.exs b/test/liquid/combinators/general_test.exs
new file mode 100644
index 00000000..644bc173
--- /dev/null
+++ b/test/liquid/combinators/general_test.exs
@@ -0,0 +1,108 @@
+defmodule Liquid.Combinators.GeneralTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ defmodule Parser do
+ import NimbleParsec
+ alias Liquid.Combinators.{General, LexicalToken}
+ defparsec(:whitespace, General.whitespace())
+ defparsec(:ignore_whitespaces, General.ignore_whitespaces())
+ defparsec(:start_tag, General.start_tag())
+ defparsec(:end_tag, General.end_tag())
+ defparsec(:start_variable, General.start_variable())
+ defparsec(:end_variable, General.end_variable())
+ defparsec(:variable_definition_for_assignment, General.variable_definition_for_assignment())
+ defparsec(:variable_name_for_assignment, General.variable_name_for_assignment())
+ defparsec(:variable_definition, General.variable_definition())
+ defparsec(:variable_name, General.variable_name())
+ defparsec(:filter, General.filter())
+ defparsec(:filter_param, General.filter_param())
+ defparsec(:filters, General.filters())
+ defparsec(:liquid_variable, General.liquid_variable())
+ defparsec(:value, LexicalToken.value())
+ defparsec(:value_definition, LexicalToken.value_definition())
+ defparsec(:object_property, LexicalToken.object_property())
+ defparsec(:variable_value, LexicalToken.variable_value())
+ defparsec(:object_value, LexicalToken.object_value())
+ defparsec(:variable_part, LexicalToken.variable_part())
+ end
+
+ test "whitespace must parse 0x0020 and 0x0009" do
+ test_combinator(" ", &Parser.whitespace/1, ' ')
+ test_combinator("\t", &Parser.whitespace/1, '\t')
+ test_combinator("\n", &Parser.whitespace/1, '\n')
+ test_combinator("\r", &Parser.whitespace/1, '\r')
+ end
+
+ test "extra_spaces ignore all :whitespaces" do
+ test_combinator(" ", &Parser.ignore_whitespaces/1, [])
+ test_combinator(" \t\t\t ", &Parser.ignore_whitespaces/1, [])
+ test_combinator("", &Parser.ignore_whitespaces/1, [])
+ end
+
+ test "start_tag" do
+ test_combinator("{%", &Parser.start_tag/1, [])
+ test_combinator("{% \t \t", &Parser.start_tag/1, [])
+ end
+
+ test "end_tag" do
+ test_combinator("%}", &Parser.end_tag/1, [])
+ test_combinator(" \t \t%}", &Parser.end_tag/1, [])
+ end
+
+ test "start_variable" do
+ test_combinator("{{", &Parser.start_variable/1, [])
+ test_combinator("{{ \t \t", &Parser.start_variable/1, [])
+ end
+
+ test "end_variable" do
+ test_combinator("}}", &Parser.end_variable/1, [])
+ test_combinator(" \t \t}}", &Parser.end_variable/1, [])
+ end
+
+ test "variable name valid" do
+ valid_names = ~w(v v1 _v1 _1 v-1 v- v_ a)
+
+ Enum.each(valid_names, fn n ->
+ test_combinator(n, &Parser.variable_name/1, variable_name: n)
+ end)
+ end
+
+ test "variable name invalid" do
+ invalid_names = ~w(. .a @a #a ^a 好a ,a -a)
+
+ Enum.each(invalid_names, fn n ->
+ test_combinator_internal_error(n, &Parser.variable_name/1)
+ end)
+ end
+
+ test "variable with filters and params" do
+ test_combinator(
+ "{{ var.var1[0][0].var2[3] | filter1 | f2: 1 | f3: 2 | f4: 2, 3 }}",
+ &Parser.liquid_variable/1,
+ liquid_variable: [
+ variable: [
+ parts: [
+ part: "var",
+ part: "var1",
+ index: 0,
+ index: 0,
+ part: "var2",
+ index: 3
+ ],
+ filters: [
+ filter: ["filter1"],
+ filter: ["f2", {:params, [value: 1]}],
+ filter: ["f3", {:params, [value: 2]}],
+ filter: ["f4", {:params, [value: 2, value: 3]}]
+ ]
+ ]
+ ]
+ )
+ end
+
+ defp test_combinator_internal_error(markup, combiner) do
+ {:error, _, _, _, _, _} = combiner.(markup)
+ assert true
+ end
+end
diff --git a/test/liquid/combinators/lexical_token_test.exs b/test/liquid/combinators/lexical_token_test.exs
new file mode 100644
index 00000000..bddda42c
--- /dev/null
+++ b/test/liquid/combinators/lexical_token_test.exs
@@ -0,0 +1,165 @@
+defmodule Liquid.Combinators.LexicalTokenTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+ alias Liquid.Parser, as: Parser
+
+ test "integer value" do
+ test_combinator("5", &Parser.value/1, value: 5)
+ test_combinator("-5", &Parser.value/1, value: -5)
+ test_combinator("0", &Parser.value/1, value: 0)
+ end
+
+ test "float value" do
+ test_combinator("3.14", &Parser.value/1, value: 3.14)
+ test_combinator("-3.14", &Parser.value/1, value: -3.14)
+ test_combinator("1.0E5", &Parser.value/1, value: 1.0e5)
+ test_combinator("1.0e5", &Parser.value/1, value: 1.0e5)
+ test_combinator("-1.0e5", &Parser.value/1, value: -1.0e5)
+ test_combinator("1.0e-5", &Parser.value/1, value: 1.0e-5)
+ test_combinator("-1.0e-5", &Parser.value/1, value: -1.0e-5)
+ end
+
+ test "string value" do
+ test_combinator(~S("abc"), &Parser.value/1, value: "abc")
+ test_combinator(~S('abc'), &Parser.value/1, value: "abc")
+ test_combinator(~S(""), &Parser.value/1, value: "")
+ test_combinator(~S("mom's chicken"), &Parser.value/1, value: "mom's chicken")
+
+ test_combinator(
+ ~S("text with true and false inside"),
+ &Parser.value/1,
+ value: "text with true and false inside"
+ )
+
+ test_combinator(
+ ~S("text with null inside"),
+ &Parser.value/1,
+ value: "text with null inside"
+ )
+
+ test_combinator(~S("這是傳統的中文"), &Parser.value/1, value: "這是傳統的中文")
+ test_combinator(~S( "هذا باللغة العربية"), &Parser.value/1, value: "هذا باللغة العربية")
+ test_combinator(~S("😁😂😃😉"), &Parser.value/1, value: "😁😂😃😉")
+ end
+
+ test "boolean values" do
+ test_combinator("true", &Parser.value/1, value: true)
+ test_combinator("false", &Parser.value/1, value: false)
+ end
+
+ test "nil values" do
+ test_combinator("null", &Parser.value/1, value: nil)
+ test_combinator("nil", &Parser.value/1, value: nil)
+ end
+
+ test "range values" do
+ test_combinator(
+ "(10..1)",
+ &Parser.value/1,
+ value: {:range, [start: 10, end: 1]}
+ )
+
+ test_combinator("(-10..1)", &Parser.value/1, value: {:range, [start: -10, end: 1]})
+ test_combinator("(1..10)", &Parser.value/1, value: {:range, [start: 1, end: 10]})
+
+ test_combinator(
+ "(1..var)",
+ &Parser.value/1,
+ value: {:range, [start: 1, end: {:variable, [parts: [part: "var"]]}]}
+ )
+
+ test_combinator(
+ "(var[0]..10)",
+ &Parser.value/1,
+ value:
+ {:range,
+ [
+ start: {:variable, [parts: [part: "var", index: 0]]},
+ end: 10
+ ]}
+ )
+
+ test_combinator(
+ "(var1[0].var2[0]..var3[0])",
+ &Parser.value/1,
+ value:
+ {:range,
+ [
+ start:
+ {:variable,
+ [
+ parts: [
+ part: "var1",
+ index: 0,
+ part: "var2",
+ index: 0
+ ]
+ ]},
+ end: {:variable, [parts: [part: "var3", index: 0]]}
+ ]}
+ )
+ end
+
+ test "object values" do
+ test_combinator(
+ "variable",
+ &Parser.value/1,
+ value: {:variable, [parts: [part: "variable"]]}
+ )
+
+ test_combinator(
+ "variable.value",
+ &Parser.value/1,
+ value: {:variable, [parts: [part: "variable", part: "value"]]}
+ )
+ end
+
+ test "list values" do
+ test_combinator(
+ "product[0]",
+ &Parser.value/1,
+ value: {:variable, [parts: [part: "product", index: 0]]}
+ )
+ end
+
+ test "object and list values" do
+ test_combinator(
+ "products[0].parts[0].providers[0]",
+ &Parser.value/1,
+ value:
+ {:variable,
+ [
+ parts: [
+ part: "products",
+ index: 0,
+ part: "parts",
+ index: 0,
+ part: "providers",
+ index: 0
+ ]
+ ]}
+ )
+
+ test_combinator(
+ "products[parts[0].providers[0]]",
+ &Parser.value/1,
+ value:
+ {:variable,
+ [
+ parts: [
+ part: "products",
+ index:
+ {:variable,
+ [
+ parts: [
+ part: "parts",
+ index: 0,
+ part: "providers",
+ index: 0
+ ]
+ ]}
+ ]
+ ]}
+ )
+ end
+end
diff --git a/test/liquid/combinators/tags/assign_test.exs b/test/liquid/combinators/tags/assign_test.exs
new file mode 100644
index 00000000..7e7de8d0
--- /dev/null
+++ b/test/liquid/combinators/tags/assign_test.exs
@@ -0,0 +1,134 @@
+defmodule Liquid.Combinators.Tags.AssignTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "assign" do
+ tags = [
+ "{% assign cart = 5 %}",
+ "{% assign cart = 5 %}",
+ "{%assign cart = 5%}",
+ "{% assign cart=5 %}",
+ "{%assign cart=5%}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ assign: [variable_name: "cart", value: 5]
+ )
+ end)
+
+ test_parse(
+ "{% assign cart = old_var %}",
+ assign: [
+ variable_name: "cart",
+ value: {:variable, [parts: [part: "old_var"]]}
+ ]
+ )
+
+ test_parse(
+ "{% assign cart = 'empty cart' %}",
+ assign: [variable_name: "cart", value: "empty cart"]
+ )
+
+ test_parse(
+ ~s({% assign cart = "empty cart" %}),
+ assign: [variable_name: "cart", value: "empty cart"]
+ )
+ end
+
+ test "assign with variable" do
+ test_parse(
+ "{% assign cart = 5 %}{{ cart }}",
+ assign: [variable_name: "cart", value: 5],
+ liquid_variable: [variable: [parts: [part: "cart"]]]
+ )
+ end
+
+ test "assign a list" do
+ test_parse(
+ "{% assign cart = product[0] %}",
+ assign: [
+ variable_name: "cart",
+ value: {:variable, [parts: [part: "product", index: 0]]}
+ ]
+ )
+
+ test_parse(
+ "{% assign cart = products[0][0] %}",
+ assign: [
+ variable_name: "cart",
+ value: {:variable, [parts: [part: "products", index: 0, index: 0]]}
+ ]
+ )
+
+ test_parse(
+ "{% assign cart = products[ 0 ][ 0 ] %}",
+ assign: [
+ variable_name: "cart",
+ value: {:variable, [parts: [part: "products", index: 0, index: 0]]}
+ ]
+ )
+ end
+
+ test "assign an object" do
+ test_parse(
+ "{% assign cart = company.employees.first.name %}",
+ assign: [
+ variable_name: "cart",
+ value:
+ {:variable,
+ [
+ parts: [
+ part: "company",
+ part: "employees",
+ part: "first",
+ part: "name"
+ ]
+ ]}
+ ]
+ )
+
+ test_parse(
+ "{% assign cart = company.managers[1].name %}",
+ assign: [
+ variable_name: "cart",
+ value:
+ {:variable,
+ [
+ parts: [
+ part: "company",
+ part: "managers",
+ index: 1,
+ part: "name"
+ ]
+ ]}
+ ]
+ )
+
+ test_parse(
+ "{% assign cart = company.managers[1][0].name %}",
+ assign: [
+ variable_name: "cart",
+ value:
+ {:variable,
+ [
+ parts: [
+ part: "company",
+ part: "managers",
+ index: 1,
+ index: 0,
+ part: "name"
+ ]
+ ]}
+ ]
+ )
+ end
+
+ test "incorrect variable assignment" do
+ test_combinator_error("{% assign cart@ = 5 %}")
+ test_combinator_error("{% assign cart. = 5 %}")
+ test_combinator_error("{% assign .cart = 5 %}")
+ end
+end
diff --git a/test/liquid/combinators/tags/capture_test.exs b/test/liquid/combinators/tags/capture_test.exs
new file mode 100644
index 00000000..1f137a0a
--- /dev/null
+++ b/test/liquid/combinators/tags/capture_test.exs
@@ -0,0 +1,28 @@
+defmodule Liquid.Combinators.Tags.CaptureTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "capture tag: parser basic structures" do
+ test_parse(
+ "{% capture about_me %} I am {{ age }} and my favorite food is {{ favorite_food }}{% endcapture %}",
+ capture: [
+ variable_name: "about_me",
+ body: [
+ " I am ",
+ {:liquid_variable, [variable: [parts: [part: "age"]]]},
+ " and my favorite food is ",
+ {:liquid_variable, [variable: [parts: [part: "favorite_food"]]]}
+ ]
+ ]
+ )
+ end
+
+ test "fails in capture tag" do
+ [
+ "{% capture about_me %} I am {{ age } and my favorite food is { favorite_food }} {% endcapture %}",
+ "{% capture about_me %}{% ndcapture %}"
+ ]
+ |> Enum.each(fn bad_markup -> test_combinator_error(bad_markup) end)
+ end
+end
diff --git a/test/liquid/combinators/tags/case_test.exs b/test/liquid/combinators/tags/case_test.exs
new file mode 100644
index 00000000..d6da8b40
--- /dev/null
+++ b/test/liquid/combinators/tags/case_test.exs
@@ -0,0 +1,169 @@
+defmodule Liquid.Combinators.Tags.CaseTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "case using multiples when" do
+ test_parse(
+ "{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "condition"]]],
+ body: [],
+ when: [conditions: [1], body: [" its 1 "]],
+ when: [conditions: [2], body: [" its 2 "]]
+ ]
+ )
+ end
+
+ test "case using a single when" do
+ test_parse(
+ "{% case condition %}{% when \"string here\" %} hit {% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "condition"]]],
+ body: [],
+ when: [conditions: ["string here"], body: [" hit "]]
+ ]
+ )
+ end
+
+ test "evaluate variables and expressions" do
+ test_parse(
+ "{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "a", part: "size"]]],
+ body: [],
+ when: [conditions: [1], body: ["1"]],
+ when: [conditions: [2], body: ["2"]]
+ ]
+ )
+ end
+
+ test "case with body" do
+ test_parse("{% case condition %} hit {% else %} else {% endcase %}",
+ case: [
+ {:conditions, [variable: [parts: [part: "condition"]]]},
+ body: [" hit "],
+ else: [body: [" else "]]
+ ]
+ )
+ end
+
+ test "case with a else tag" do
+ test_parse(
+ "{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "condition"]]],
+ body: [],
+ when: [conditions: [5], body: [" hit "]],
+ else: [body: [" else "]]
+ ]
+ )
+ end
+
+ test "when tag with an or condition" do
+ test_parse(
+ "{% case condition %}{% when 1 or 2 or 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "condition"]]],
+ body: [],
+ when: [
+ conditions: [1, {:logical, [:or, 2]}, {:logical, [:or, 3]}],
+ body: [" its 1 or 2 or 3 "]
+ ],
+ when: [conditions: [4], body: [" its 4 "]]
+ ]
+ )
+ end
+
+ test "when with comma's" do
+ test_parse(
+ "{% case condition %}{% when 1, 2, 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "condition"]]],
+ body: [],
+ when: [
+ conditions: [1, {:logical, [:or, 2]}, {:logical, [:or, 3]}],
+ body: [" its 1 or 2 or 3 "]
+ ],
+ when: [conditions: [4], body: [" its 4 "]]
+ ]
+ )
+ end
+
+ test "when tag separated by commas and with different values" do
+ test_parse(
+ "{% case condition %}{% when 1, \"string\", null %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "condition"]]],
+ body: [],
+ when: [
+ conditions: [
+ 1,
+ {:logical, [:or, "string"]},
+ {:logical, [:or, nil]}
+ ],
+ body: [" its 1 or 2 or 3 "]
+ ],
+ when: [conditions: [4], body: [" its 4 "]]
+ ]
+ )
+ end
+
+ test "when tag with assign tag" do
+ test_parse(
+ "{% case collection.handle %}{% when 'menswear-jackets' %}{% assign ptitle = 'menswear' %}{% when 'menswear-t-shirts' %}{% assign ptitle = 'menswear' %}{% else %}{% assign ptitle = 'womenswear' %}{% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "collection", part: "handle"]]],
+ body: [],
+ when: [
+ conditions: ["menswear-jackets"],
+ body: [
+ assign: [
+ variable_name: "ptitle",
+ value: "menswear"
+ ]
+ ]
+ ],
+ when: [
+ conditions: ["menswear-t-shirts"],
+ body: [
+ assign: [
+ variable_name: "ptitle",
+ value: "menswear"
+ ]
+ ]
+ ],
+ else: [
+ body: [
+ assign: [
+ variable_name: "ptitle",
+ value: "womenswear"
+ ]
+ ]
+ ]
+ ]
+ )
+ end
+
+ test "bad formed cases" do
+ test_combinator_error("{% case condition %}{% when 5 %} hit {% else %} else endcase %}")
+
+ test_combinator_error("{% case condition %}{% when 5 %} hit {% else %} else {% endcas %}")
+
+ test_combinator_error("{ case condition %}{% when 5 %} hit {% else %} else {% endcase %}")
+
+ test_combinator_error("case condition %}{% when 5 %} hit {% else %} else {% endcase %}")
+
+ test_combinator_error("{% casa condition %}{% when 5 %} hit {% else %} else {% endcase %}")
+
+ test_combinator_error("{% case condition %}{% when 5 5 %} hit {% else %} else {% endcase %}")
+
+ test_combinator_error("{% case condition %}{% when 5 or %} hit {% else %} else {% endcase %}")
+
+ test_combinator_error("{% case condition %}{% when 5 hit {% else %} else {% endcase %}")
+
+ test_combinator_error(
+ "{% case condition condition condition2 %}{% when 5 %} hit {% else %} else {% endcase %}"
+ )
+ end
+end
diff --git a/test/liquid/combinators/tags/comment_test.exs b/test/liquid/combinators/tags/comment_test.exs
new file mode 100644
index 00000000..90be062a
--- /dev/null
+++ b/test/liquid/combinators/tags/comment_test.exs
@@ -0,0 +1,125 @@
+defmodule Liquid.Combinators.Tags.CommentTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "comment tag parser" do
+ test_parse(
+ "{% comment %} Allows you to leave un-rendered code inside a Liquid template. Any text within the opening and closing comment blocks will not be output, and any Liquid code within will not be executed. {% endcomment %}",
+ comment: [
+ " Allows you to leave un-rendered code inside a Liquid template. Any text within the opening and closing comment blocks will not be output, and any Liquid code within will not be executed. "
+ ]
+ )
+ end
+
+ test "comment with tags and variables in body" do
+ test_parse(
+ "{% comment %} {% if true %} {% endcomment %}",
+ comment: [" {% if true %} "]
+ )
+ end
+
+ test "comment with any tags in body" do
+ test_parse(
+ "{% comment %} {% if true %} sadsadasd {% afi true %}{% endcomment %}",
+ comment: [" {% if true %} sadsadasd {% afi true %}"]
+ )
+ end
+
+ test "comment with any tags and comments or raw in body" do
+ test_parse(
+ "{% comment %} {% if true %} {% comment %} {% if true %} {% endcomment %} {% endcomment %}",
+ comment: [" {% if true %} {% if true %} "]
+ )
+ end
+
+ test "comment with any tags and nested comment" do
+ test_parse(
+ "{% comment %} {% comment %} {% if true %} {% comment %} {% if true %} {% endcomment %} {% endcomment %} {% endcomment %}",
+ comment: [" {% if true %} {% if true %} "]
+ )
+
+ test_parse(
+ "{% comment %} {% comment %} {% comment %} {% comment %} {% comment %} {% comment %} {% endcomment %} {% endcomment %} {% endcomment %}{% endcomment %} {% endcomment %} {% endcomment %}",
+ comment: [" "]
+ )
+
+ test_parse(
+ "{% comment %} a {% comment %} b {% endcomment %} {% comment %} c{% endcomment %} {% comment %} d {% endcomment %}{% endcomment %}",
+ comment: [" a b c d "]
+ )
+
+ test_parse(
+ "{% comment %} a {% comment %} b {% endcomment %} {% comment %} c{% endcomment %} {% comment %} d {% endcomment %}{% endcomment %}",
+ comment: [" a b c d "]
+ )
+
+ test_parse(
+ "{% comment %} hi {% endcomment %}{% comment %} there {% endcomment %}",
+ comment: [" hi "],
+ comment: [" there "]
+ )
+ end
+
+ test "comment with any tag that are similar to comment and endcomment" do
+ test_parse(
+ "{% comment %} {% if true %} {% andcomment %} {% aendcomment %} {% acomment %} {% endcomment %}",
+ comment: [" {% if true %} {% andcomment %} {% aendcomment %} {% acomment %} "]
+ )
+
+ test_parse(
+ "{% comment %} {% commenta %} {% endcomment %}",
+ comment: [" {% commenta %} "]
+ )
+ end
+
+ test "comment with several raw" do
+ test_parse(
+ "{% comment %} {% raw %} {% comment %} {% endraw %} {% raw %} {% endcomment %}{% endraw %} {% raw %} hi there .. {% endraw %}{% endcomment %}",
+ comment: [" {% comment %} {% endcomment %} hi there .. "]
+ )
+
+ test_parse(
+ "{% comment %} {% raw %} {% comment %} {% endraw %} {% comment %} hi {% endcomment %} {% raw %} i am raw text .. {% endraw %} {% comment %} i am a comment {% endcomment %}{% endcomment %}",
+ comment: [" {% comment %} hi i am raw text .. i am a comment "]
+ )
+
+ test_parse(
+ "{% comment %}{% raw %}any {% endraw %}{% if true %}{% endcomment %}",
+ comment: ["any {% if true %}"]
+ )
+
+ test_parse(
+ "{% comment %}{% raw %}any {% endraw %}{% if true %}{% endif %}{% for item in products %}{% endfor %}{% endcomment %}",
+ comment: ["any {% if true %}{% endif %}{% for item in products %}{% endfor %}"]
+ )
+
+ test_parse(
+ "{% comment %}{% raw %}any {% endraw %}{% if true %}{% endif %}{% for item in products %}{%%}{% endcomment %}",
+ comment: ["any {% if true %}{% endif %}{% for item in products %}{%%}"]
+ )
+ end
+
+ test "comment start and end tags" do
+ test_parse(
+ "{% comment \n\n\n %}%}{% \n\n\n endcomment \n\n\n %}",
+ comment: ["%}"]
+ )
+
+ test_parse(
+ "{% comment %}{%%}{%%}{%%}{%%}{%%}{% endcomment %}",
+ comment: ["{%%}{%%}{%%}{%%}{%%}"]
+ )
+ end
+
+ test "comment must fails with this one" do
+ test_combinator_error(
+ "{% comment %} {% if true %} {% comment %} {% aendcomment %} {% acomment %} {% endcomment %}"
+ )
+
+ test_combinator_error("{% comment %}{%}{% endcomment %}")
+ test_combinator_error("{% comment %} {% comment %} {% endcomment %}")
+ test_combinator_error("{%comment%}{%comment%}{%endcomment%}")
+ test_combinator_error("{% comment %} {% endcomment %} {% endcomment %}")
+ end
+end
diff --git a/test/liquid/combinators/tags/custom_test.exs b/test/liquid/combinators/tags/custom_test.exs
new file mode 100644
index 00000000..ca4835c6
--- /dev/null
+++ b/test/liquid/combinators/tags/custom_test.exs
@@ -0,0 +1,117 @@
+defmodule Liquid.Combinators.Tags.CustomTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+ alias Liquid.{Tag, Block, Registers}
+
+ defmodule MyCustomTag do
+ def render(output, tag, context) do
+ {"MyCustomTag Results...output:#{output} tag:#{tag}", context}
+ end
+ end
+
+ defmodule MyCustomBlock do
+ def render(output, tag, context) do
+ {"MyCustomBlock Results...output:#{output} tag:#{tag}", context}
+ end
+ end
+
+ setup_all do
+ Registers.register("MyCustomTag", MyCustomTag, Tag)
+ Registers.register("MyCustomBlock", MyCustomBlock, Block)
+ Liquid.start()
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test "custom tags: basic tag structures" do
+ tags = [
+ {"{% MyCustomTag argument = 1 %}",
+ [
+ custom: [
+ {:custom_name, ["MyCustomTag"]},
+ {:custom_markup, "argument = 1 "}
+ ]
+ ]},
+ {"{%MyCustomTag argument = 1%}",
+ [
+ custom: [
+ {:custom_name, ["MyCustomTag"]},
+ {:custom_markup, "argument = 1"}
+ ]
+ ]},
+ {"{% MyCustomTag argument = 1 %}",
+ [
+ custom: [
+ {:custom_name, ["MyCustomTag"]},
+ {:custom_markup, "argument = 1 "}
+ ]
+ ]}
+ ]
+
+ Enum.each(tags, fn {tag, expected} ->
+ test_parse(tag, expected)
+ end)
+ end
+
+ test "custom blocks: basic blocks structures" do
+ tags = [
+ {"{% MyCustomBlock argument = 1 %}{% if true %}this is true{% endif %}{% endMyCustomBlock %}",
+ [
+ custom: [
+ custom_name: ["MyCustomBlock"],
+ custom_markup: "argument = 1 ",
+ body: [if: [conditions: [true], body: ["this is true"]]]
+ ]
+ ]},
+ {"{%MyCustomBlock argument = 1%}{%endMyCustomBlock%}",
+ [
+ custom: [
+ custom_name: ["MyCustomBlock"],
+ custom_markup: "argument = 1",
+ body: []
+ ]
+ ]},
+ {"{% MyCustomBlock argument = 1 %}{% endMyCustomBlock %}",
+ [
+ custom: [
+ custom_name: ["MyCustomBlock"],
+ custom_markup: "argument = 1 ",
+ body: []
+ ]
+ ]}
+ ]
+
+ Enum.each(tags, fn {tag, expected} ->
+ test_parse(tag, expected)
+ end)
+ end
+
+ test "nested custom blocks and tags" do
+ tag =
+ "{% MyCustomBlock %}{% MyCustomTag %}{% MyCustomBlock %}{% MyCustomTag %}{% endMyCustomBlock %}{% endMyCustomBlock %}"
+
+ test_parse(
+ tag,
+ custom: [
+ custom_name: ["MyCustomBlock"],
+ custom_markup: "",
+ body: [
+ custom: [
+ {:custom_name, ["MyCustomTag"]},
+ {:custom_markup, ""}
+ ],
+ custom: [
+ custom_name: ["MyCustomBlock"],
+ custom_markup: "",
+ body: [
+ custom: [
+ {:custom_name, ["MyCustomTag"]},
+ {:custom_markup, ""}
+ ]
+ ]
+ ]
+ ]
+ ]
+ )
+ end
+end
diff --git a/test/liquid/combinators/tags/cycle_test.exs b/test/liquid/combinators/tags/cycle_test.exs
new file mode 100644
index 00000000..84792682
--- /dev/null
+++ b/test/liquid/combinators/tags/cycle_test.exs
@@ -0,0 +1,110 @@
+defmodule Liquid.Combinators.Tags.CycleTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "cycle tag with 2 values" do
+ test_parse(
+ "{%cycle \"one\", \"two\"%}",
+ cycle: [values: ["one", "two"]]
+ )
+ end
+
+ test "cycle tag 2 times" do
+ test_parse("{%cycle \"one\", \"two\"%} {%cycle \"one\", \"two\"%}", [
+ {:cycle, [values: ["one", "two"]]},
+ " ",
+ {:cycle, [values: ["one", "two"]]}
+ ])
+ end
+
+ test "cycle tag with quoted blanks" do
+ test_parse("{%cycle \"\", \"two\"%} {%cycle \"\", \"two\"%}", [
+ {:cycle, [values: ["", "two"]]},
+ " ",
+ {:cycle, [values: ["", "two"]]}
+ ])
+ end
+
+ test "cycle tag 3 times" do
+ test_parse(
+ "{%cycle \"one\", \"two\"%} {%cycle \"one\", \"two\"%} {%cycle \"one\", \"two\"%}",
+ [
+ {:cycle, [values: ["one", "two"]]},
+ " ",
+ {:cycle, [values: ["one", "two"]]},
+ " ",
+ {:cycle, [values: ["one", "two"]]}
+ ]
+ )
+ end
+
+ test "cycle with html values" do
+ test_parse(
+ "{%cycle \"text-align: left\", \"text-align: right\" %} {%cycle \"text-align: left\", \"text-align: right\"%}",
+ [
+ {:cycle, [values: ["text-align: left", "text-align: right"]]},
+ " ",
+ {:cycle, [values: ["text-align: left", "text-align: right"]]}
+ ]
+ )
+ end
+
+ test "cycle tag with integers" do
+ test_parse(
+ "{%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%}",
+ [
+ {:cycle, [values: [1, 2]]},
+ " ",
+ {:cycle, [values: [1, 2]]},
+ " ",
+ {:cycle, [values: [1, 2]]},
+ " ",
+ {:cycle, [values: [1, 2, 3]]},
+ " ",
+ {:cycle, [values: [1, 2, 3]]},
+ " ",
+ {:cycle, [values: [1, 2, 3]]},
+ " ",
+ {:cycle, [values: [1, 2, 3]]}
+ ]
+ )
+ end
+
+ test "cycle tag group by numbers" do
+ test_parse(
+ "{%cycle 1: \"one\", \"two\" %} {%cycle 2: \"one\", \"two\" %} {%cycle 1: \"one\", \"two\" %} {%cycle 2: \"one\", \"two\" %} {%cycle 1: \"one\", \"two\" %} {%cycle 2: \"one\", \"two\" %}",
+ [
+ {:cycle, [group: ["1"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["2"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["1"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["2"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["1"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["2"], values: ["one", "two"]]}
+ ]
+ )
+ end
+
+ test "cycle tag group by strings" do
+ test_parse(
+ "{%cycle var1: \"one\", \"two\" %} {%cycle var2: \"one\", \"two\" %} {%cycle var1: \"one\", \"two\" %} {%cycle var2: \"one\", \"two\" %} {%cycle var1: \"one\", \"two\" %} {%cycle var2: \"one\", \"two\" %}",
+ [
+ {:cycle, [group: ["var1"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["var2"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["var1"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["var2"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["var1"], values: ["one", "two"]]},
+ " ",
+ {:cycle, [group: ["var2"], values: ["one", "two"]]}
+ ]
+ )
+ end
+end
diff --git a/test/liquid/combinators/tags/for_test.exs b/test/liquid/combinators/tags/for_test.exs
new file mode 100644
index 00000000..0e792b02
--- /dev/null
+++ b/test/liquid/combinators/tags/for_test.exs
@@ -0,0 +1,190 @@
+defmodule Liquid.Combinators.Tags.ForTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "for tag: basic tag structures" do
+ tags = [
+ "{% for item in array %}{% endfor %}",
+ "{%for item in array%}{%endfor%}",
+ "{% for item in array %}{% endfor %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ for: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: []
+ ],
+ body: []
+ ]
+ )
+ end)
+ end
+
+ test "for tag: else tag structures" do
+ tags = [
+ "{% for item in array %}{% else %}{% endfor %}",
+ "{%for item in array%}{%else%}{%endfor%}",
+ "{% for item in array %}{% else %}{% endfor %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ for: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: []
+ ],
+ body: [],
+ else: [body: []]
+ ]
+ )
+ end)
+ end
+
+ test "for tag: limit parameter" do
+ tags = [
+ "{% for item in array limit:2 %}{% else %}{% endfor %}",
+ "{%for item in array limit:2%}{%else%}{%endfor%}",
+ "{% for item in array limit:2 %}{% else %}{% endfor %}",
+ "{% for item in array limit: 2 %}{% else %}{% endfor %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ for: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: [limit: [2]]
+ ],
+ body: [],
+ else: [body: []]
+ ]
+ )
+ end)
+ end
+
+ test "for tag: offset parameter" do
+ tags = [
+ "{% for item in array offset:2 %}{% else %}{% endfor %}",
+ "{%for item in array offset:2%}{%else%}{%endfor%}",
+ "{% for item in array offset:2 %}{% else %}{% endfor %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ for: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: [offset: [2]]
+ ],
+ body: [],
+ else: [body: []]
+ ]
+ )
+ end)
+ end
+
+ test "for tag: reversed parameter" do
+ tags = [
+ "{% for item in array reversed %}{% else %}{% endfor %}",
+ "{%for item in array reversed%}{%else%}{%endfor%}",
+ "{% for item in array reversed %}{% else %}{% endfor %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ for: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: [reversed: []]
+ ],
+ body: [],
+ else: [body: []]
+ ]
+ )
+ end)
+ end
+
+ test "for tag: range parameter" do
+ tags = [
+ "{% for i in (1..10) %}{{ i }}{% endfor %}",
+ "{%for i in (1..10)%}{{ i }}{% endfor %}",
+ "{% for i in (1..10) %}{{ i }}{% endfor %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ for: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:range, [start: 1, end: 10]},
+ params: []
+ ],
+ body: [liquid_variable: [variable: [parts: [part: "i"]]]]
+ ]
+ )
+ end)
+ end
+
+ test "for tag: range with variables" do
+ test_parse(
+ "{% for i in (my_var..10) %}{{ i }}{% endfor %}",
+ for: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:range, [start: {:variable, [parts: [part: "my_var"]]}, end: 10]},
+ params: []
+ ],
+ body: [liquid_variable: [variable: [parts: [part: "i"]]]]
+ ]
+ )
+ end
+
+ test "for tag: break tag" do
+ test_parse(
+ "{% for i in (my_var..10) %}{{ i }}{% break %}{% endfor %}",
+ for: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:range, [start: {:variable, [parts: [part: "my_var"]]}, end: 10]},
+ params: []
+ ],
+ body: [
+ liquid_variable: [variable: [parts: [part: "i"]]],
+ break: []
+ ]
+ ]
+ )
+ end
+
+ test "for tag: continue tag" do
+ test_parse(
+ "{% for i in (1..my_var) %}{{ i }}{% continue %}{% endfor %}",
+ for: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:range, [start: 1, end: {:variable, [parts: [part: "my_var"]]}]},
+ params: []
+ ],
+ body: [
+ liquid_variable: [variable: [parts: [part: "i"]]],
+ continue: []
+ ]
+ ]
+ )
+ end
+end
diff --git a/test/liquid/combinators/tags/if_test.exs b/test/liquid/combinators/tags/if_test.exs
new file mode 100644
index 00000000..4609b3f4
--- /dev/null
+++ b/test/liquid/combinators/tags/if_test.exs
@@ -0,0 +1,203 @@
+defmodule Liquid.Combinators.Tags.IfTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "if using booleans" do
+ test_parse(
+ "{% if false %} this text should not go into the output {% endif %}",
+ if: [
+ conditions: [false],
+ body: [" this text should not go into the output "]
+ ]
+ )
+
+ test_parse(
+ "{% if true %} this text should go into the output {% endif %}",
+ if: [
+ conditions: [true],
+ body: [" this text should go into the output "]
+ ]
+ )
+ end
+
+ test "if else " do
+ test_parse(
+ "{% if \"foo\" %} YES {% else %} NO {% endif %}",
+ if: [conditions: ["foo"], body: [" YES "], else: [body: [" NO "]]]
+ )
+ end
+
+ test "opening if tag with multiple conditions " do
+ test_parse(
+ "{% if line_item.grams > 20000 and customer_address.city == 'Ottawa' or customer_address.city == 'Seatle' %}hello test{% endif %}",
+ if: [
+ conditions: [
+ condition: {{:variable, [parts: [part: "line_item", part: "grams"]]}, :>, 20000},
+ logical: [
+ :and,
+ {:condition,
+ {{:variable, [parts: [part: "customer_address", part: "city"]]}, :==, "Ottawa"}}
+ ],
+ logical: [
+ :or,
+ {:condition,
+ {{:variable, [parts: [part: "customer_address", part: "city"]]}, :==, "Seatle"}}
+ ]
+ ],
+ body: ["hello test"]
+ ]
+ )
+ end
+
+ test "using values" do
+ test_parse(
+ "{% if a == true or b == 4 %} YES {% endif %}",
+ if: [
+ conditions: [
+ condition: {{:variable, [parts: [part: "a"]]}, :==, true},
+ logical: [
+ :or,
+ {:condition, {{:variable, [parts: [part: "b"]]}, :==, 4}}
+ ]
+ ],
+ body: [" YES "]
+ ]
+ )
+ end
+
+ test "parsing an awful markup" 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"
+
+ test_parse(
+ "{% if #{awful_markup} %} YES {% endif %}",
+ if: [
+ conditions: [
+ condition: {{:variable, [parts: [part: "a"]]}, :==, "and"},
+ logical: [
+ :and,
+ {:condition, {{:variable, [parts: [part: "b"]]}, :==, "or"}}
+ ],
+ logical: [
+ :and,
+ {:condition, {{:variable, [parts: [part: "c"]]}, :==, "foo and bar"}}
+ ],
+ logical: [
+ :and,
+ {:condition, {{:variable, [parts: [part: "d"]]}, :==, "bar or baz"}}
+ ],
+ logical: [
+ :and,
+ {:condition, {{:variable, [parts: [part: "e"]]}, :==, "foo"}}
+ ],
+ logical: [:and, {:variable, [parts: [part: "foo"]]}],
+ logical: [:and, {:variable, [parts: [part: "bar"]]}]
+ ],
+ body: [" YES "]
+ ]
+ )
+ end
+
+ test "nested if" do
+ test_parse(
+ "{% if false %}{% if false %} NO {% endif %}{% endif %}",
+ if: [
+ conditions: [false],
+ body: [if: [conditions: [false], body: [" NO "]]]
+ ]
+ )
+
+ test_parse(
+ "{% if false %}{% if shipping_method.title == 'International Shipping' %}You're shipping internationally. Your order should arrive in 2–3 weeks.{% elsif shipping_method.title == 'Domestic Shipping' %}Your order should arrive in 3–4 days.{% else %} Thank you for your order!{% endif %}{% endif %}",
+ if: [
+ conditions: [false],
+ body: [
+ if: [
+ conditions: [
+ condition:
+ {{:variable, [parts: [part: "shipping_method", part: "title"]]}, :==,
+ "International Shipping"}
+ ],
+ body: ["You're shipping internationally. Your order should arrive in 2–3 weeks."],
+ elsif: [
+ conditions: [
+ condition:
+ {{:variable, [parts: [part: "shipping_method", part: "title"]]}, :==,
+ "Domestic Shipping"}
+ ],
+ body: ["Your order should arrive in 3–4 days."]
+ ],
+ else: [body: [" Thank you for your order!"]]
+ ]
+ ]
+ ]
+ )
+ end
+
+ test "comparing values" do
+ test_parse(
+ "{% if null < 10 %} NO {% endif %}",
+ if: [conditions: [condition: {nil, :<, 10}], body: [" NO "]]
+ )
+
+ test_parse(
+ "{% if 10 < null %} NO {% endif %}",
+ if: [conditions: [condition: {10, :<, nil}], body: [" NO "]]
+ )
+ end
+
+ test "usisng contains" do
+ test_parse(
+ "{% if 'bob' contains 'f' %}yes{% else %}no{% endif %}",
+ if: [
+ conditions: [condition: {"bob", :contains, "f"}],
+ body: ["yes"],
+ else: [body: ["no"]]
+ ]
+ )
+ end
+
+ test "using elsif and else" do
+ test_parse(
+ "{% if shipping_method.title == 'International Shipping' %}You're shipping internationally. Your order should arrive in 2–3 weeks.{% elsif shipping_method.title == 'Domestic Shipping' %}Your order should arrive in 3–4 days.{% else %} Thank you for your order!{% endif %}",
+ if: [
+ conditions: [
+ condition:
+ {{:variable, [parts: [part: "shipping_method", part: "title"]]}, :==,
+ "International Shipping"}
+ ],
+ body: ["You're shipping internationally. Your order should arrive in 2–3 weeks."],
+ elsif: [
+ conditions: [
+ condition:
+ {{:variable, [parts: [part: "shipping_method", part: "title"]]}, :==,
+ "Domestic Shipping"}
+ ],
+ body: ["Your order should arrive in 3–4 days."]
+ ],
+ else: [body: [" Thank you for your order!"]]
+ ]
+ )
+ end
+
+ test "2 else conditions in one if" do
+ test_parse(
+ "{% if true %}test{% else %} a {% else %} b {% endif %}",
+ if: [
+ conditions: [true],
+ body: ["test"],
+ else: [body: [" a "]],
+ else: [body: [" b "]]
+ ]
+ )
+ end
+
+ test "missing a opening tag and a closing tag" do
+ test_combinator_error(" if true %}test{% else %} a {% endif %}")
+ test_combinator_error("test{% else %} a {% endif %}")
+ test_combinator_error("{% if true %}test{% else %} a ")
+ test_combinator_error(" if true %}test{% else a {% endif %}")
+ test_combinator_error("{% if true %}test{% else %} a endif %}")
+ end
+end
diff --git a/test/liquid/combinators/tags/ifchanged_test.exs b/test/liquid/combinators/tags/ifchanged_test.exs
new file mode 100644
index 00000000..1e8891f8
--- /dev/null
+++ b/test/liquid/combinators/tags/ifchanged_test.exs
@@ -0,0 +1,32 @@
+defmodule Liquid.Combinators.Tags.IfchangedTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "ifchanged tag: basic tag structures" do
+ tags = [
+ "{% ifchanged %}{{ product.created_at | date:\"%w\" }}
{% endifchanged %}",
+ "{%ifchanged%}{{ product.created_at | date:\"%w\" }}
{%endifchanged%}",
+ "{% ifchanged %}{{ product.created_at | date:\"%w\" }}
{% endifchanged %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ ifchanged: [
+ body: [
+ "",
+ {:liquid_variable,
+ [
+ variable: [
+ parts: [part: "product", part: "created_at"],
+ filters: [filter: ["date", {:params, [value: "%w"]}]]
+ ]
+ ]},
+ "
"
+ ]
+ ]
+ )
+ end)
+ end
+end
diff --git a/test/liquid/combinators/tags/include_test.exs b/test/liquid/combinators/tags/include_test.exs
new file mode 100644
index 00000000..4a598edc
--- /dev/null
+++ b/test/liquid/combinators/tags/include_test.exs
@@ -0,0 +1,54 @@
+defmodule Liquid.Combinators.Tags.IncludeTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "include tag parser" do
+ test_parse(
+ "{% include 'snippet', my_variable: 'apples', my_other_variable: 'oranges' %}",
+ include: [
+ variable_name: "snippet",
+ params: [
+ assignment: [variable_name: "my_variable", value: "apples"],
+ assignment: [variable_name: "my_other_variable", value: "oranges"]
+ ]
+ ]
+ )
+
+ test_parse(
+ "{% include 'snippet' my_variable: 'apples', my_other_variable: 'oranges' %}",
+ include: [
+ variable_name: "snippet",
+ params: [
+ assignment: [variable_name: "my_variable", value: "apples"],
+ assignment: [variable_name: "my_other_variable", value: "oranges"]
+ ]
+ ]
+ )
+
+ test_parse(
+ "{% include 'pick_a_source' %}",
+ include: [variable_name: "pick_a_source"]
+ )
+
+ test_parse(
+ "{% include 'product' with products[0] %}",
+ include: [
+ variable_name: "product",
+ with: [
+ variable: [parts: [part: "products", index: 0]]
+ ]
+ ]
+ )
+
+ test_parse(
+ "{% include 'product' with 'products' %}",
+ include: [variable_name: "product", with: ["products"]]
+ )
+
+ test_parse(
+ "{% include 'product' for 'products' %}",
+ include: [variable_name: "product", for: ["products"]]
+ )
+ end
+end
diff --git a/test/liquid/combinators/tags/raw_test.exs b/test/liquid/combinators/tags/raw_test.exs
new file mode 100644
index 00000000..9ad988a5
--- /dev/null
+++ b/test/liquid/combinators/tags/raw_test.exs
@@ -0,0 +1,21 @@
+defmodule Liquid.Combinators.Tags.RawTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "raw tag parser" do
+ test_parse(
+ "{% raw %} Raw temporarily disables tag processing. This is useful for generating content (eg, Mustache, Handlebars )like this {{ product }} which uses conflicting syntax.{% endraw %}",
+ raw: [
+ " Raw temporarily disables tag processing. This is useful for generating content (eg, Mustache, Handlebars )like this {{ product }} which uses conflicting syntax."
+ ]
+ )
+ end
+
+ test "raw with tags and variables in body" do
+ test_parse(
+ "{% raw %} {% if true %} {% endraw %}",
+ raw: [" {% if true %} "]
+ )
+ end
+end
diff --git a/test/liquid/combinators/tags/tablerow_test.exs b/test/liquid/combinators/tags/tablerow_test.exs
new file mode 100644
index 00000000..0820fe45
--- /dev/null
+++ b/test/liquid/combinators/tags/tablerow_test.exs
@@ -0,0 +1,164 @@
+defmodule Liquid.Combinators.Tags.TablerowTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "tablerow tag: basic tag structures" do
+ tags = [
+ "{% tablerow item in array %}{% endtablerow %}",
+ "{%tablerow item in array%}{%endtablerow%}",
+ "{% tablerow item in array %}{% endtablerow %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: []
+ ],
+ body: []
+ ]
+ )
+ end)
+ end
+
+ test "tablerow tag: limit parameter" do
+ tags = [
+ "{% tablerow item in array limit:2 %}{% endtablerow %}",
+ "{%tablerow item in array limit:2%}{%endtablerow%}",
+ "{% tablerow item in array limit:2 %}{% endtablerow %}",
+ "{% tablerow item in array limit: 2 %}{% endtablerow %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: [limit: [2]]
+ ],
+ body: []
+ ]
+ )
+ end)
+ end
+
+ test "tablerow tag: offset parameter" do
+ tags = [
+ "{% tablerow item in array offset:2 %}{% endtablerow %}",
+ "{%tablerow item in array offset:2%}{%endtablerow%}",
+ "{% tablerow item in array offset:2 %}{% endtablerow %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: [offset: [2]]
+ ],
+ body: []
+ ]
+ )
+ end)
+ end
+
+ test "tablerow tag: cols parameter" do
+ tags = [
+ "{% tablerow item in array cols:2 %}{% endtablerow %}",
+ "{%tablerow item in array cols:2%}{%endtablerow%}",
+ "{% tablerow item in array cols:2 %}{% endtablerow %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: [cols: [2]]
+ ],
+ body: []
+ ]
+ )
+ end)
+ end
+
+ test "tablerow tag: range parameter" do
+ tags = [
+ "{% tablerow i in (1..10) %}{{ i }}{% endtablerow %}",
+ "{%tablerow i in (1..10)%}{{ i }}{% endtablerow %}",
+ "{% tablerow i in (1..10) %}{{ i }}{% endtablerow %}"
+ ]
+
+ Enum.each(tags, fn tag ->
+ test_parse(
+ tag,
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:range, [start: 1, end: 10]},
+ params: []
+ ],
+ body: [
+ liquid_variable: [variable: [parts: [part: "i"]]]
+ ]
+ ]
+ )
+ end)
+ end
+
+ test "tablerow tag: range with variables" do
+ test_parse(
+ "{% tablerow i in (my_var..10) %}{{ i }}{% endtablerow %}",
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:range, [start: {:variable, [parts: [part: "my_var"]]}, end: 10]},
+ params: []
+ ],
+ body: [
+ liquid_variable: [variable: [parts: [part: "i"]]]
+ ]
+ ]
+ )
+ end
+
+ test "tablerow tag: call with 2 parameters" do
+ test_parse(
+ "{% tablerow i in (my_var..10) limit:2 cols:2 %}{{ i }}{% endtablerow %}",
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:range, [start: {:variable, [parts: [part: "my_var"]]}, end: 10]},
+ params: [limit: [2], cols: [2]]
+ ],
+ body: [
+ liquid_variable: [variable: [parts: [part: "i"]]]
+ ]
+ ]
+ )
+ end
+
+ test "tablerow tag: invalid tag structure and variable values" do
+ test_combinator_error(
+ "{% tablerow i in (my_var..10) %}{{ i }}{% else %}{% else %}{% endtablerow %}"
+ )
+
+ test_combinator_error(
+ "{% tablerow i in (my_var..product.title[2]) %}{{ i }}{% else %}{% endtablerow %}"
+ )
+
+ test_combinator_error(
+ "{% tablerow i in products limit: a %}{{ i }}{% else %}{% endtablerow %}"
+ )
+ end
+end
diff --git a/test/liquid/custom_filter_test.exs b/test/liquid/custom_filter_test.exs
index b0a79b48..ff6ac52f 100644
--- a/test/liquid/custom_filter_test.exs
+++ b/test/liquid/custom_filter_test.exs
@@ -13,8 +13,20 @@ defmodule Liquid.CustomFilterTest do
def not_meaning_of_life(_), do: 2
end
+ defmodule OverrideStandardFilter do
+ @doc """
+ This method overrides Liquid.Filters.List.size, returning always 0.
+ """
+ def size(_), do: 0
+ end
+
setup_all do
- Application.put_env(:liquid, :extra_filter_modules, [MyFilter, MyFilterTwo])
+ Application.put_env(:liquid, :extra_filter_modules, [
+ MyFilter,
+ MyFilterTwo,
+ OverrideStandardFilter
+ ])
+
Liquid.start()
on_exit(fn -> Liquid.stop() end)
:ok
@@ -38,6 +50,13 @@ defmodule Liquid.CustomFilterTest do
)
end
+ test "overrides standard filter with custom filter" do
+ assert_template_result(
+ "0",
+ "{{ 'text' | size }}"
+ )
+ end
+
defp assert_template_result(expected, markup, assigns \\ %{}) do
assert_result(expected, markup, assigns)
end
diff --git a/test/liquid/filter_test.exs b/test/liquid/filter_test.exs
index 4e7f4476..1812b251 100644
--- a/test/liquid/filter_test.exs
+++ b/test/liquid/filter_test.exs
@@ -4,7 +4,6 @@ defmodule Liquid.FilterTest do
use ExUnit.Case
use Timex
alias Liquid.{Filters, Template, Variable}
- alias Liquid.Filters.Functions
setup_all do
Liquid.start()
@@ -25,263 +24,15 @@ defmodule Liquid.FilterTest do
assert "'barbar'" == Filters.filter(filters, name)
end
- test :size do
- assert 3 == Functions.size([1, 2, 3])
- assert 0 == Functions.size([])
- assert 0 == Functions.size(nil)
-
- # for strings
- assert 3 == Functions.size("foo")
- assert 0 == Functions.size("")
- end
-
- test :downcase do
- assert "testing", Functions.downcase("Testing")
- assert "" == Functions.downcase(nil)
- end
-
- test :upcase do
- assert "TESTING" == Functions.upcase("Testing")
- assert "" == Functions.upcase(nil)
- end
-
- test :capitalize do
- assert "Testing" == Functions.capitalize("testing")
- assert "Testing 2 words" == Functions.capitalize("testing 2 wOrds")
- assert "" == Functions.capitalize(nil)
- end
-
- test :prepend do
- assert "Testing" == Functions.prepend("ing", "Test")
- assert "Test" == Functions.prepend("Test", nil)
- end
-
- test :slice do
- assert "oob" == Functions.slice("foobar", 1, 3)
- assert "oobar" == Functions.slice("foobar", 1, 1000)
- assert "" == Functions.slice("foobar", 1, 0)
- assert "o" == Functions.slice("foobar", 1, 1)
- assert "bar" == Functions.slice("foobar", 3, 3)
- assert "ar" == Functions.slice("foobar", -2, 2)
- assert "ar" == Functions.slice("foobar", -2, 1000)
- assert "r" == Functions.slice("foobar", -1)
- assert "" == Functions.slice(nil, 0)
- assert "" == Functions.slice("foobar", 100, 10)
- assert "" == Functions.slice("foobar", -100, 10)
- end
-
- test :slice_on_arrays do
- input = "foobar" |> String.split("", trim: true)
- assert ~w{o o b} == Functions.slice(input, 1, 3)
- assert ~w{o o b a r} == Functions.slice(input, 1, 1000)
- assert ~w{} == Functions.slice(input, 1, 0)
- assert ~w{o} == Functions.slice(input, 1, 1)
- assert ~w{b a r} == Functions.slice(input, 3, 3)
- assert ~w{a r} == Functions.slice(input, -2, 2)
- assert ~w{a r} == Functions.slice(input, -2, 1000)
- assert ~w{r} == Functions.slice(input, -1)
- assert ~w{} == Functions.slice(input, 100, 10)
- assert ~w{} == Functions.slice(input, -100, 10)
- end
-
- test :truncate do
- assert "1234..." == Functions.truncate("1234567890", 7)
- assert "1234567890" == Functions.truncate("1234567890", 20)
- assert "..." == Functions.truncate("1234567890", 0)
- assert "1234567890" == Functions.truncate("1234567890")
- assert "测试..." == Functions.truncate("测试测试测试测试", 5)
- assert "1234..." == Functions.truncate("1234567890", "7")
- assert "1234!!!" == Functions.truncate("1234567890", 7, "!!!")
- assert "1234567" == Functions.truncate("1234567890", 7, "")
- end
-
- test :split do
- assert ["12", "34"] == Functions.split("12~34", "~")
- assert ["A? ", " ,Z"] == Functions.split("A? ~ ~ ~ ,Z", "~ ~ ~")
- assert ["A?Z"] == Functions.split("A?Z", "~")
- # Regexp works although Liquid does not support.
- # assert ["A","Z"] == Functions.split("AxZ", ~r/x/)
- assert [] == Functions.split(nil, " ")
- end
-
- test :escape do
- assert "<strong>" == Functions.escape("")
- assert "<strong>" == Functions.h("")
- end
-
- test :escape_once do
- assert "<strong>Hulk</strong>" ==
- Functions.escape_once("<strong>Hulk")
- end
-
- test :url_encode do
- assert "foo%2B1%40example.com" == Functions.url_encode("foo+1@example.com")
- assert nil == Functions.url_encode(nil)
- end
-
- test :url_decode do
- assert "foo+1@example.com" == Functions.url_decode("foo%2B1%40example.com")
- assert nil == Functions.url_decode(nil)
- end
-
- test :truncatewords do
- assert "one two three" == Functions.truncatewords("one two three", 4)
- assert "one two..." == Functions.truncatewords("one two three", 2)
- assert "one two three" == Functions.truncatewords("one two three")
-
- assert "Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”..." ==
- Functions.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
- )
-
- assert "测试测试测试测试" == Functions.truncatewords("测试测试测试测试", 5)
- assert "one two three" == Functions.truncatewords("one two three", "4")
- end
-
- test :strip_html do
- assert "test" == Functions.strip_html("test
")
- assert "test" == Functions.strip_html(~s{test
})
-
- assert "" ==
- Functions.strip_html(
- ~S{}
- )
-
- assert "" == Functions.strip_html(~S{})
- assert "test" == Functions.strip_html(~S{test
})
- assert "test" == Functions.strip_html(~S{test})
- assert "" == Functions.strip_html(nil)
- end
-
- test :join do
- assert "1 2 3 4" == Functions.join([1, 2, 3, 4])
- assert "1 - 2 - 3 - 4" == Functions.join([1, 2, 3, 4], " - ")
-
- assert_template_result(
- "1, 1, 2, 4, 5",
- ~s({{"1: 2: 1: 4: 5" | split: ": " | sort | join: ", " }})
- )
- end
-
- test :sort do
- assert [1, 2, 3, 4] == Functions.sort([4, 3, 2, 1])
-
- assert [%{"a" => 1}, %{"a" => 2}, %{"a" => 3}, %{"a" => 4}] ==
- Functions.sort([%{"a" => 4}, %{"a" => 3}, %{"a" => 1}, %{"a" => 2}], "a")
-
- assert [%{"a" => 1, "b" => 1}, %{"a" => 3, "b" => 2}, %{"a" => 2, "b" => 3}] ==
- Functions.sort(
- [%{"a" => 3, "b" => 2}, %{"a" => 1, "b" => 1}, %{"a" => 2, "b" => 3}],
- "b"
- )
-
- # Elixir keyword list support
- assert [a: 1, a: 2, a: 3, a: 4] == Functions.sort([{:a, 4}, {:a, 3}, {:a, 1}, {:a, 2}], "a")
- end
-
test :sort_integrity do
assert_template_result("11245", ~s({{"1: 2: 1: 4: 5" | split: ": " | sort }}))
end
- test :legacy_sort_hash do
- assert Map.to_list(%{a: 1, b: 2}) == Functions.sort(a: 1, b: 2)
- end
-
- test :numerical_vs_lexicographical_sort do
- assert [2, 10] == Functions.sort([10, 2])
- assert [{"a", 2}, {"a", 10}] == Functions.sort([{"a", 10}, {"a", 2}], "a")
- assert ["10", "2"] == Functions.sort(["10", "2"])
- assert [{"a", "10"}, {"a", "2"}] == Functions.sort([{"a", "10"}, {"a", "2"}], "a")
- end
-
- test :uniq do
- assert [1, 3, 2, 4] == Functions.uniq([1, 1, 3, 2, 3, 1, 4, 3, 2, 1])
-
- assert [{"a", 1}, {"a", 3}, {"a", 2}] ==
- Functions.uniq([{"a", 1}, {"a", 3}, {"a", 1}, {"a", 2}], "a")
-
- # testdrop = TestDrop.new
- # assert [testdrop] == Functions.uniq([testdrop, TestDrop.new], "test")
- end
-
- test :reverse do
- assert [4, 3, 2, 1] == Functions.reverse([1, 2, 3, 4])
- end
-
- test :legacy_reverse_hash do
- assert [Map.to_list(%{a: 1, b: 2})] == Functions.reverse(a: 1, b: 2)
- end
-
- test :map do
- assert [1, 2, 3, 4] ==
- Functions.map([%{"a" => 1}, %{"a" => 2}, %{"a" => 3}, %{"a" => 4}], "a")
-
- assert_template_result("abc", "{{ ary | map:'foo' | map:'bar' }}", %{
- "ary" => [
- %{"foo" => %{"bar" => "a"}},
- %{"foo" => %{"bar" => "b"}},
- %{"foo" => %{"bar" => "c"}}
- ]
- })
- end
-
test :map_doesnt_call_arbitrary_stuff do
assert_template_result("", ~s[{{ "foo" | map: "__id__" }}])
assert_template_result("", ~s[{{ "foo" | map: "inspect" }}])
end
- test :replace do
- assert "Tes1ing" == Functions.replace("Testing", "t", "1")
- assert "Tesing" == Functions.replace("Testing", "t", "")
- assert "2 2 2 2" == Functions.replace("1 1 1 1", "1", 2)
- assert "2 1 1 1" == Functions.replace_first("1 1 1 1", "1", 2)
- assert_template_result("2 1 1 1", "{{ '1 1 1 1' | replace_first: '1', 2 }}")
- end
-
- test :date do
- assert "May" == Functions.date(~N[2006-05-05 10:00:00], "%B")
- assert "June" == Functions.date(Timex.parse!("2006-06-05 10:00:00", "%F %T", :strftime), "%B")
- assert "July" == Functions.date(~N[2006-07-05 10:00:00], "%B")
-
- assert "May" == Functions.date("2006-05-05 10:00:00", "%B")
- assert "June" == Functions.date("2006-06-05 10:00:00", "%B")
- assert "July" == Functions.date("2006-07-05 10:00:00", "%B")
-
- assert "2006-07-05 10:00:00" == Functions.date("2006-07-05 10:00:00", "")
- assert "2006-07-05 10:00:00" == Functions.date("2006-07-05 10:00:00", "")
- assert "2006-07-05 10:00:00" == Functions.date("2006-07-05 10:00:00", "")
- assert "2006-07-05 10:00:00" == Functions.date("2006-07-05 10:00:00", nil)
-
- assert "07/05/2006" == Functions.date("2006-07-05 10:00:00", "%m/%d/%Y")
-
- assert "07/16/2004" == Functions.date("Fri Jul 16 01:00:00 2004", "%m/%d/%Y")
-
- assert "#{Timex.today().year}" == Functions.date("now", "%Y")
- assert "#{Timex.today().year}" == Functions.date("today", "%Y")
-
- assert nil == Functions.date(nil, "%B")
-
- # Timex already uses UTC
- # with_timezone("UTC") do
- # assert "07/05/2006" == Functions.date(1152098955, "%m/%d/%Y")
- # assert "07/05/2006" == Functions.date("1152098955", "%m/%d/%Y")
- # end
- end
-
- test :first_last do
- assert 1 == Functions.first([1, 2, 3])
- assert 3 == Functions.last([1, 2, 3])
- assert nil == Functions.first([])
- assert nil == Functions.last([])
- end
-
- test :remove do
- assert " " == Functions.remove("a a a a", "a")
- assert "a a a" == Functions.remove_first("a a a a", "a ")
- assert_template_result("a a a", "{{ 'a a a a' | remove_first: 'a ' }}")
- end
-
test :pipes_in_string_arguments do
assert_template_result("foobar", "{{ 'foo|bar' | remove: '|' }}")
end
@@ -383,15 +134,6 @@ defmodule Liquid.FilterTest do
assert_template_result("abc", "{{ a | prepend: b}}", assigns)
end
- test :default do
- assert "foo" == Functions.default("foo", "bar")
- assert "bar" == Functions.default(nil, "bar")
- assert "bar" == Functions.default("", "bar")
- assert "bar" == Functions.default(false, "bar")
- assert "bar" == Functions.default([], "bar")
- assert "bar" == Functions.default({}, "bar")
- end
-
test :pluralize do
assert_template_result("items", "{{ 3 | pluralize: 'item', 'items' }}")
assert_template_result("word", "{{ 1 | pluralize: 'word', 'words' }}")
diff --git a/test/liquid/filters/additionals_test.exs b/test/liquid/filters/additionals_test.exs
new file mode 100644
index 00000000..dadc5214
--- /dev/null
+++ b/test/liquid/filters/additionals_test.exs
@@ -0,0 +1,49 @@
+defmodule Liquid.Filters.AdditionalsTest do
+ use ExUnit.Case
+ use Timex
+ doctest Liquid.Filters.Additionals
+
+ alias Liquid.Filters.Additionals
+
+ setup_all do
+ Liquid.start()
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test :default do
+ assert "foo" == Additionals.default("foo", "bar")
+ assert "bar" == Additionals.default(nil, "bar")
+ assert "bar" == Additionals.default("", "bar")
+ assert "bar" == Additionals.default(false, "bar")
+ assert "bar" == Additionals.default([], "bar")
+ assert "bar" == Additionals.default({}, "bar")
+ end
+
+ test :date do
+ assert "May" == Additionals.date(~N[2006-05-05 10:00:00], "%B")
+
+ assert "June" ==
+ Additionals.date(Timex.parse!("2006-06-05 10:00:00", "%F %T", :strftime), "%B")
+
+ assert "July" == Additionals.date(~N[2006-07-05 10:00:00], "%B")
+
+ assert "May" == Additionals.date("2006-05-05 10:00:00", "%B")
+ assert "June" == Additionals.date("2006-06-05 10:00:00", "%B")
+ assert "July" == Additionals.date("2006-07-05 10:00:00", "%B")
+
+ assert "2006-07-05 10:00:00" == Additionals.date("2006-07-05 10:00:00", "")
+ assert "2006-07-05 10:00:00" == Additionals.date("2006-07-05 10:00:00", "")
+ assert "2006-07-05 10:00:00" == Additionals.date("2006-07-05 10:00:00", "")
+ assert "2006-07-05 10:00:00" == Additionals.date("2006-07-05 10:00:00", nil)
+
+ assert "07/05/2006" == Additionals.date("2006-07-05 10:00:00", "%m/%d/%Y")
+
+ assert "07/16/2004" == Additionals.date("Fri Jul 16 01:00:00 2004", "%m/%d/%Y")
+
+ assert "#{Timex.today().year}" == Additionals.date("now", "%Y")
+ assert "#{Timex.today().year}" == Additionals.date("today", "%Y")
+
+ assert nil == Additionals.date(nil, "%B")
+ end
+end
diff --git a/test/liquid/filters/html_test.exs b/test/liquid/filters/html_test.exs
new file mode 100644
index 00000000..cb62a183
--- /dev/null
+++ b/test/liquid/filters/html_test.exs
@@ -0,0 +1,71 @@
+defmodule Liquid.Filters.HTMLTest do
+ use ExUnit.Case
+ use Timex
+ doctest Liquid.Filters.HTML
+
+ alias Liquid.Template
+ alias Liquid.Filters.HTML
+
+ setup_all do
+ Liquid.start()
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test :escape do
+ assert "<strong>" == HTML.escape("")
+ assert "<strong>" == HTML.h("")
+ end
+
+ test :escape_once do
+ assert "<strong>Hulk</strong>" == HTML.escape_once("<strong>Hulk")
+ end
+
+ test :url_encode do
+ assert "foo%2B1%40example.com" == HTML.url_encode("foo+1@example.com")
+ assert nil == HTML.url_encode(nil)
+ end
+
+ test :url_decode do
+ assert "foo+1@example.com" == HTML.url_decode("foo%2B1%40example.com")
+ assert nil == HTML.url_decode(nil)
+ end
+
+ test :strip_html do
+ assert "test" == HTML.strip_html("test
")
+ assert "test" == HTML.strip_html(~s{test
})
+
+ assert "" ==
+ HTML.strip_html(
+ ~S{}
+ )
+
+ assert "" == HTML.strip_html(~S{})
+ assert "test" == HTML.strip_html(~S{test
})
+ assert "test" == HTML.strip_html(~S{test})
+ assert "" == HTML.strip_html(nil)
+ end
+
+ test :strip_newlines do
+ assert_template_result("abc", "{{ source | strip_newlines }}", %{"source" => "a\nb\nc"})
+ assert_template_result("abc", "{{ source | strip_newlines }}", %{"source" => "a\r\nb\nc"})
+ assert_template_result("abc", "{{ source | strip_newlines }}", %{"source" => "a\r\nb\nc\r\n"})
+ end
+
+ test :newlines_to_br do
+ assert_template_result("a
\nb
\nc", "{{ source | newline_to_br }}", %{
+ "source" => "a\nb\nc"
+ })
+ end
+
+ defp assert_template_result(expected, markup, assigns) do
+ template = Template.parse(markup)
+
+ with {:ok, result, _} <- Template.render(template, assigns) do
+ assert result == expected
+ else
+ {:error, message, _} ->
+ assert message == expected
+ end
+ end
+end
diff --git a/test/liquid/filters/list_test.exs b/test/liquid/filters/list_test.exs
new file mode 100644
index 00000000..7b17d668
--- /dev/null
+++ b/test/liquid/filters/list_test.exs
@@ -0,0 +1,118 @@
+defmodule Liquid.Filters.ListTest do
+ use ExUnit.Case
+ use Timex
+ doctest Liquid.Filters.List
+
+ alias Liquid.Template
+ alias Liquid.Filters.List
+
+ setup_all do
+ Liquid.start()
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test :join do
+ assert "1 2 3 4" == List.join([1, 2, 3, 4])
+ assert "1 - 2 - 3 - 4" == List.join([1, 2, 3, 4], " - ")
+
+ assert_template_result(
+ "1, 1, 2, 4, 5",
+ ~s({{"1: 2: 1: 4: 5" | split: ": " | sort | join: ", " }})
+ )
+ end
+
+ test :sort do
+ assert [1, 2, 3, 4] == List.sort([4, 3, 2, 1])
+
+ assert [%{"a" => 1}, %{"a" => 2}, %{"a" => 3}, %{"a" => 4}] ==
+ List.sort([%{"a" => 4}, %{"a" => 3}, %{"a" => 1}, %{"a" => 2}], "a")
+
+ assert [%{"a" => 1, "b" => 1}, %{"a" => 3, "b" => 2}, %{"a" => 2, "b" => 3}] ==
+ List.sort(
+ [%{"a" => 3, "b" => 2}, %{"a" => 1, "b" => 1}, %{"a" => 2, "b" => 3}],
+ "b"
+ )
+
+ # Elixir keyword list support
+ assert [a: 1, a: 2, a: 3, a: 4] == List.sort([{:a, 4}, {:a, 3}, {:a, 1}, {:a, 2}], "a")
+ end
+
+ test :sort_integrity do
+ assert_template_result("11245", ~s({{"1: 2: 1: 4: 5" | split: ": " | sort }}))
+ end
+
+ test :legacy_sort_hash do
+ assert Map.to_list(%{a: 1, b: 2}) == List.sort(a: 1, b: 2)
+ end
+
+ test :numerical_vs_lexicographical_sort do
+ assert [2, 10] == List.sort([10, 2])
+ assert [{"a", 2}, {"a", 10}] == List.sort([{"a", 10}, {"a", 2}], "a")
+ assert ["10", "2"] == List.sort(["10", "2"])
+ assert [{"a", "10"}, {"a", "2"}] == List.sort([{"a", "10"}, {"a", "2"}], "a")
+ end
+
+ test :uniq do
+ assert [1, 3, 2, 4] == List.uniq([1, 1, 3, 2, 3, 1, 4, 3, 2, 1])
+
+ assert [{"a", 1}, {"a", 3}, {"a", 2}] ==
+ List.uniq([{"a", 1}, {"a", 3}, {"a", 1}, {"a", 2}], "a")
+
+ # testdrop = TestDrop.new
+ # assert [testdrop] == List.uniq([testdrop, TestDrop.new], "test")
+ end
+
+ test :reverse do
+ assert [4, 3, 2, 1] == List.reverse([1, 2, 3, 4])
+ end
+
+ test :legacy_reverse_hash do
+ assert [Map.to_list(%{a: 1, b: 2})] == List.reverse(a: 1, b: 2)
+ end
+
+ test :map do
+ assert [1, 2, 3, 4] == List.map([%{"a" => 1}, %{"a" => 2}, %{"a" => 3}, %{"a" => 4}], "a")
+
+ assert_template_result("abc", "{{ ary | map:'foo' | map:'bar' }}", %{
+ "ary" => [
+ %{"foo" => %{"bar" => "a"}},
+ %{"foo" => %{"bar" => "b"}},
+ %{"foo" => %{"bar" => "c"}}
+ ]
+ })
+ end
+
+ test :map_doesnt_call_arbitrary_stuff do
+ assert_template_result("", ~s[{{ "foo" | map: "__id__" }}])
+ assert_template_result("", ~s[{{ "foo" | map: "inspect" }}])
+ end
+
+ test :first_last do
+ assert 1 == List.first([1, 2, 3])
+ assert 3 == List.last([1, 2, 3])
+ assert nil == List.first([])
+ assert nil == List.last([])
+ end
+
+ test :size do
+ assert 3 == List.size([1, 2, 3])
+ assert 0 == List.size([])
+ assert 0 == List.size(nil)
+
+ # for strings
+ assert 3 == List.size("foo")
+ assert 0 == List.size("")
+ end
+
+ defp assert_template_result(expected, markup, assigns \\ %{}) do
+ template = Template.parse(markup)
+
+ with {:ok, result, _} <- Template.render(template, assigns) do
+ assert result == expected
+ else
+ {:error, message, _} ->
+ assert message == expected
+ end
+ end
+end
diff --git a/test/liquid/filters/math_test.exs b/test/liquid/filters/math_test.exs
new file mode 100644
index 00000000..ab7bfe33
--- /dev/null
+++ b/test/liquid/filters/math_test.exs
@@ -0,0 +1,80 @@
+defmodule Liquid.Filters.MathTest do
+ use ExUnit.Case
+ doctest Liquid.Filters.Math
+
+ alias Liquid.Template
+
+ setup_all do
+ Liquid.start()
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test :plus do
+ assert_template_result("2", "{{ 1 | plus:1 }}")
+ assert_template_result("2.0", "{{ '1' | plus:'1.0' }}")
+ end
+
+ test :minus do
+ assert_template_result("4", "{{ input | minus:operand }}", %{"input" => 5, "operand" => 1})
+ assert_template_result("2.3", "{{ '4.3' | minus:'2' }}")
+ end
+
+ test :times do
+ assert_template_result("12", "{{ 3 | times:4 }}")
+ assert_template_result("0", "{{ 'foo' | times:4 }}")
+
+ assert_template_result("6", "{{ '2.1' | times:3 | replace: '.','-' | plus:0}}")
+
+ assert_template_result("7.25", "{{ 0.0725 | times:100 }}")
+ end
+
+ test :divided_by do
+ assert_template_result("4", "{{ 12 | divided_by:3 }}")
+ assert_template_result("4", "{{ 14 | divided_by:3 }}")
+ assert_template_result("5", "{{ 15 | divided_by:3 }}")
+
+ assert_template_result("Liquid error: divided by 0", "{{ 5 | divided_by:0 }}")
+
+ assert_template_result("0.5", "{{ 2.0 | divided_by:4 }}")
+ end
+
+ test :abs do
+ assert_template_result("3", "{{ '3' | abs }}")
+ assert_template_result("3", "{{ -3 | abs }}")
+ assert_template_result("0", "{{ 0 | abs }}")
+ assert_template_result("0.1", "{{ -0.1 | abs }}")
+ end
+
+ test :modulo do
+ assert_template_result("1", "{{ 3 | modulo:2 }}")
+ assert_template_result("24", "{{ -1 | modulo:25 }}")
+ end
+
+ test :round do
+ assert_template_result("4", "{{ '4.3' | round }}")
+ assert_template_result("5", "{{ input | round }}", %{"input" => 4.6})
+ assert_template_result("4.56", "{{ input | round: 2 }}", %{"input" => 4.5612})
+ end
+
+ test :ceil do
+ assert_template_result("5", "{{ '4.3' | ceil }}")
+ assert_template_result("5", "{{ input | ceil }}", %{"input" => 4.6})
+ end
+
+ test :floor do
+ assert_template_result("4", "{{ '4.3' | floor }}")
+ assert_template_result("4", "{{ input | floor }}", %{"input" => 4.6})
+ end
+
+ defp assert_template_result(expected, markup, assigns \\ %{}) do
+ template = Template.parse(markup)
+
+ with {:ok, result, _} <- Template.render(template, assigns) do
+ assert result == expected
+ else
+ {:error, message, _} ->
+ assert message == expected
+ end
+ end
+end
diff --git a/test/liquid/filters/string_test.exs b/test/liquid/filters/string_test.exs
new file mode 100644
index 00000000..efe4f1ce
--- /dev/null
+++ b/test/liquid/filters/string_test.exs
@@ -0,0 +1,155 @@
+defmodule Liquid.Filters.StringTest do
+ use ExUnit.Case
+ doctest Liquid.Filters.String
+
+ alias Liquid.Template
+ alias Liquid.Filters.String, as: FString
+
+ setup_all do
+ Liquid.start()
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test :downcase do
+ assert "testing", FString.downcase("Testing")
+ assert "" == FString.downcase(nil)
+ end
+
+ test :upcase do
+ assert "TESTING" == FString.upcase("Testing")
+ assert "" == FString.upcase(nil)
+ end
+
+ test :capitalize do
+ assert "Testing" == FString.capitalize("testing")
+ assert "Testing 2 words" == FString.capitalize("testing 2 wOrds")
+ assert "" == FString.capitalize(nil)
+ end
+
+ test :prepend do
+ assert "Testing" == FString.prepend("ing", "Test")
+ assert "Test" == FString.prepend("Test", nil)
+ end
+
+ test :truncate do
+ assert "1234..." == FString.truncate("1234567890", 7)
+ assert "1234567890" == FString.truncate("1234567890", 20)
+ assert "..." == FString.truncate("1234567890", 0)
+ assert "1234567890" == FString.truncate("1234567890")
+ assert "测试..." == FString.truncate("测试测试测试测试", 5)
+ assert "1234..." == FString.truncate("1234567890", "7")
+ assert "1234!!!" == FString.truncate("1234567890", 7, "!!!")
+ assert "1234567" == FString.truncate("1234567890", 7, "")
+ end
+
+ test :split do
+ assert ["12", "34"] == FString.split("12~34", "~")
+ assert ["A? ", " ,Z"] == FString.split("A? ~ ~ ~ ,Z", "~ ~ ~")
+ assert ["A?Z"] == FString.split("A?Z", "~")
+ # Regexp works although Liquid does not support.
+ # assert ["A","Z"] == FString.split("AxZ", ~r/x/)
+ assert [] == FString.split(nil, " ")
+ end
+
+ test :truncatewords do
+ assert "one two three" == FString.truncatewords("one two three", 4)
+ assert "one two..." == FString.truncatewords("one two three", 2)
+ assert "one two three" == FString.truncatewords("one two three")
+
+ assert "Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”..." ==
+ FString.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
+ )
+
+ assert "测试测试测试测试" == FString.truncatewords("测试测试测试测试", 5)
+ assert "one two three" == FString.truncatewords("one two three", "4")
+ end
+
+ test :append do
+ assigns = %{"a" => "bc", "b" => "d"}
+ assert_template_result("bcd", "{{ a | append: 'd'}}", assigns)
+ assert_template_result("bcd", "{{ a | append: b}}", assigns)
+ end
+
+ test :prepend_template do
+ assigns = %{"a" => "bc", "b" => "a"}
+ assert_template_result("abc", "{{ a | prepend: 'a'}}", assigns)
+ assert_template_result("abc", "{{ a | prepend: b}}", assigns)
+ end
+
+ test :replace do
+ assert "Tes1ing" == FString.replace("Testing", "t", "1")
+ assert "Tesing" == FString.replace("Testing", "t", "")
+ assert "2 2 2 2" == FString.replace("1 1 1 1", "1", 2)
+ assert "2 1 1 1" == FString.replace_first("1 1 1 1", "1", 2)
+ assert_template_result("2 1 1 1", "{{ '1 1 1 1' | replace_first: '1', 2 }}")
+ end
+
+ test :remove do
+ assert " " == FString.remove("a a a a", "a")
+ assert "a a a" == FString.remove_first("a a a a", "a ")
+ assert_template_result("a a a", "{{ 'a a a a' | remove_first: 'a ' }}")
+ end
+
+ test :strip do
+ assert_template_result("ab c", "{{ source | strip }}", %{"source" => " ab c "})
+ assert_template_result("ab c", "{{ source | strip }}", %{"source" => " \tab c \n \t"})
+ end
+
+ test :lstrip do
+ assert_template_result("ab c ", "{{ source | lstrip }}", %{"source" => " ab c "})
+
+ assert_template_result("ab c \n \t", "{{ source | lstrip }}", %{"source" => " \tab c \n \t"})
+ end
+
+ test :rstrip do
+ assert_template_result(" ab c", "{{ source | rstrip }}", %{"source" => " ab c "})
+ assert_template_result(" \tab c", "{{ source | rstrip }}", %{"source" => " \tab c \n \t"})
+ end
+
+ test :pluralize do
+ assert_template_result("items", "{{ 3 | pluralize: 'item', 'items' }}")
+ assert_template_result("word", "{{ 1 | pluralize: 'word', 'words' }}")
+ end
+
+ test :slice do
+ assert "oob" == FString.slice("foobar", 1, 3)
+ assert "oobar" == FString.slice("foobar", 1, 1000)
+ assert "" == FString.slice("foobar", 1, 0)
+ assert "o" == FString.slice("foobar", 1, 1)
+ assert "bar" == FString.slice("foobar", 3, 3)
+ assert "ar" == FString.slice("foobar", -2, 2)
+ assert "ar" == FString.slice("foobar", -2, 1000)
+ assert "r" == FString.slice("foobar", -1)
+ assert "" == FString.slice(nil, 0)
+ assert "" == FString.slice("foobar", 100, 10)
+ assert "" == FString.slice("foobar", -100, 10)
+ end
+
+ test :slice_on_arrays do
+ input = String.split("foobar", "", trim: true)
+ assert ~w{o o b} == FString.slice(input, 1, 3)
+ assert ~w{o o b a r} == FString.slice(input, 1, 1000)
+ assert ~w{} == FString.slice(input, 1, 0)
+ assert ~w{o} == FString.slice(input, 1, 1)
+ assert ~w{b a r} == FString.slice(input, 3, 3)
+ assert ~w{a r} == FString.slice(input, -2, 2)
+ assert ~w{a r} == FString.slice(input, -2, 1000)
+ assert ~w{r} == FString.slice(input, -1)
+ assert ~w{} == FString.slice(input, 100, 10)
+ assert ~w{} == FString.slice(input, -100, 10)
+ end
+
+ defp assert_template_result(expected, markup, assigns \\ %{}) do
+ template = Template.parse(markup)
+
+ with {:ok, result, _} <- Template.render(template, assigns) do
+ assert result == expected
+ else
+ {:error, message, _} ->
+ assert message == expected
+ end
+ end
+end
diff --git a/test/liquid/parser_test.exs b/test/liquid/parser_test.exs
new file mode 100644
index 00000000..972475dd
--- /dev/null
+++ b/test/liquid/parser_test.exs
@@ -0,0 +1,395 @@
+defmodule Liquid.ParserTest do
+ use ExUnit.Case
+ alias Liquid.{Template, Tag, Block, Registers}
+ import Liquid.Helpers
+
+ test "only literal" do
+ test_parse("Hello", ["Hello"])
+ end
+
+ test "liquid variable" do
+ test_parse("{{ X }}", liquid_variable: [variable: [parts: [part: "X"]]])
+ end
+
+ test "test liquid open tag" do
+ test_parse("{% assign a = 5 %}", assign: [variable_name: "a", value: 5])
+ end
+
+ test "test literal + liquid open tag" do
+ test_parse("Hello {% assign a = 5 %}", ["Hello ", {:assign, [variable_name: "a", value: 5]}])
+ end
+
+ test "test liquid open tag + literal" do
+ test_parse("{% assign a = 5 %} Hello", [{:assign, [variable_name: "a", value: 5]}, " Hello"])
+ end
+
+ test "test literal + liquid open tag + literal" do
+ test_parse("Hello {% assign a = 5 %} Hello", [
+ "Hello ",
+ {:assign, [variable_name: "a", value: 5]},
+ " Hello"
+ ])
+ end
+
+ test "test multiple open tags" do
+ test_parse("{% assign a = 5 %}{% increment a %}", [
+ {:assign, [variable_name: "a", value: 5]},
+ {:increment, [variable: [parts: [part: "a"]]]}
+ ])
+ end
+
+ test "unclosed block must fails" do
+ test_combinator_error(
+ "{% capture variable %}",
+ "Malformed tag, open without close: 'capture'"
+ )
+ end
+
+ test "empty closed tag" do
+ test_parse("{% capture variable %}{% endcapture %}", [
+ {:capture, [variable_name: "variable", body: []]}
+ ])
+ end
+
+ test "tag without open" do
+ test_combinator_error(
+ "{% if true %}{% endiif %}",
+ "The 'if' tag has not been correctly closed"
+ )
+ end
+
+ test "literal left, right and inside block" do
+ test_parse("Hello{% capture variable %}World{% endcapture %}Here", [
+ "Hello",
+ {:capture, [variable_name: "variable", body: ["World"]]},
+ "Here"
+ ])
+ end
+
+ test "multiple closed tags" do
+ test_parse(
+ "Open{% capture first_variable %}Hey{% endcapture %}{% capture second_variable %}Hello{% endcapture %}{% capture last_variable %}{% endcapture %}Close",
+ [
+ "Open",
+ {:capture, [variable_name: "first_variable", body: ["Hey"]]},
+ {:capture, [variable_name: "second_variable", body: ["Hello"]]},
+ {:capture, [variable_name: "last_variable", body: []]},
+ "Close"
+ ]
+ )
+ end
+
+ test "tag inside block" do
+ test_parse("{% capture x %}{% decrement x %}{% endcapture %}", [
+ {:capture, [variable_name: "x", body: [{:decrement, [variable: [parts: [part: "x"]]]}]]}
+ ])
+ end
+
+ test "literal and tag inside block" do
+ test_parse("{% capture x %}X{% decrement x %}{% endcapture %}", [
+ {:capture,
+ [variable_name: "x", body: ["X", {:decrement, [variable: [parts: [part: "x"]]]}]]}
+ ])
+ end
+
+ test "two tags inside block" do
+ test_parse("{% capture x %}{% decrement x %}{% decrement x %}{% endcapture %}", [
+ {:capture,
+ [
+ variable_name: "x",
+ body: [
+ {:decrement, [variable: [parts: [part: "x"]]]},
+ {:decrement, [variable: [parts: [part: "x"]]]}
+ ]
+ ]}
+ ])
+ end
+
+ test "tag inside block with tag ending" do
+ test_parse(
+ "{% capture x %}{% increment x %}{% endcapture %}{% decrement y %}",
+ capture: [variable_name: "x", body: [increment: [variable: [parts: [part: "x"]]]]],
+ decrement: [variable: [parts: [part: "y"]]]
+ )
+ end
+
+ test "nested closed tags" do
+ test_parse(
+ "{% capture variable %}{% capture internal_variable %}{% endcapture %}{% endcapture %}",
+ capture: [
+ variable_name: "variable",
+ body: [capture: [variable_name: "internal_variable", body: []]]
+ ]
+ )
+ end
+
+ test "block without endblock" do
+ test_combinator_error("{% capture variable %}{% capture internal_variable %}{% endcapture %}")
+ end
+
+ test "block closed without open" do
+ test_combinator_error(
+ "{% endcapture %}",
+ "The tag 'capture' was not opened"
+ )
+ end
+
+ test "bad endblock" do
+ test_combinator_error(
+ "{% capture variable %}{% capture internal_variable %}{% endif %}{% endcapture %}"
+ )
+ end
+
+ test "if block" do
+ test_parse(
+ "{% if a == b or c == d %}Hello{% endif %}",
+ if: [
+ conditions: [
+ {:condition,
+ {{:variable, [parts: [part: "a"]]}, :==, {:variable, [parts: [part: "b"]]}}},
+ logical: [
+ :or,
+ {:condition,
+ {{:variable, [parts: [part: "c"]]}, :==, {:variable, [parts: [part: "d"]]}}}
+ ]
+ ],
+ body: ["Hello"]
+ ]
+ )
+ end
+
+ test "tablerow block" do
+ test_parse(
+ "{% tablerow item in array limit:2 %}{% endtablerow %}",
+ tablerow: [
+ statements: [
+ variable: [parts: [part: "item"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: [limit: [2]]
+ ],
+ body: []
+ ]
+ )
+ end
+
+ test "unexpected outer else tag" do
+ test_combinator_error(
+ "{% else %}{% increment a %}",
+ "Unexpected outer 'else' tag"
+ )
+ end
+
+ test "else out of valid tag" do
+ test_combinator_error(
+ "{% capture z %}{% else %}{% endcapture %}",
+ "capture does not expect else tag. The else tag is valid only inside: if, unless, case, for"
+ )
+ end
+
+ test "for block with break and continue" do
+ test_parse(
+ "{%for i in array.items offset:continue limit:1000 %}{{i}}{%endfor%}",
+ for: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:variable, [parts: [part: "array", part: "items"]]},
+ params: [offset: ["continue"], limit: [1000]]
+ ],
+ body: [liquid_variable: [variable: [parts: [part: "i"]]]]
+ ]
+ )
+ end
+
+ test "for block with else" do
+ test_parse(
+ "{% for i in array %}x{% else %}y{% else %}z{% endfor %}",
+ for: [
+ statements: [
+ variable: [parts: [part: "i"]],
+ value: {:variable, [parts: [part: "array"]]},
+ params: []
+ ],
+ body: ["x"],
+ else: [body: ["y"]],
+ else: [body: ["z"]]
+ ]
+ )
+ end
+
+ test "if block with elsif" do
+ test_parse(
+ "{% if a == b or c == d %}Hello{% elsif z > x %}bye{% endif %}",
+ if: [
+ conditions: [
+ {:condition,
+ {{:variable, [parts: [part: "a"]]}, :==, {:variable, [parts: [part: "b"]]}}},
+ logical: [
+ :or,
+ {:condition,
+ {{:variable, [parts: [part: "c"]]}, :==, {:variable, [parts: [part: "d"]]}}}
+ ]
+ ],
+ body: ["Hello"],
+ elsif: [
+ conditions: [
+ {:condition,
+ {{:variable, [parts: [part: "z"]]}, :>, {:variable, [parts: [part: "x"]]}}}
+ ],
+ body: ["bye"]
+ ]
+ ]
+ )
+ end
+
+ test "if block with several elsif" do
+ test_parse(
+ "{% if true %}Hello{% elsif true %}second{% decrement a %}third{% elsif false %}bye{% else %}clear{% endif %}",
+ if: [
+ conditions: [true],
+ body: ["Hello"],
+ elsif: [
+ conditions: [true],
+ body: ["second", {:decrement, [variable: [parts: [part: "a"]]]}, "third"]
+ ],
+ elsif: [conditions: [false], body: ["bye"]],
+ else: [body: ["clear"]]
+ ]
+ )
+ end
+
+ test "multi blocks with subblocks" do
+ test_parse(
+ "{% if true %}{% if false %}One{% elsif true %}Two{% else %}Three{% endif %}{% endif %}{% if false %}Four{% endif %}",
+ if: [
+ conditions: [true],
+ body: [
+ if: [
+ conditions: [false],
+ body: ["One"],
+ elsif: [
+ conditions: [true],
+ body: ["Two"]
+ ],
+ else: [
+ body: ["Three"]
+ ]
+ ]
+ ]
+ ],
+ if: [
+ conditions: [false],
+ body: ["Four"]
+ ]
+ )
+ end
+
+ test "multi blocks order" do
+ test_parse(
+ "{% assign a = 5 %}{% capture a %}body_a{% capture a_1 %}body_a_1{% endcapture %}{% endcapture %}{% capture b %}body_b{% endcapture %}",
+ assign: [variable_name: "a", value: 5],
+ capture: [
+ variable_name: "a",
+ body: ["body_a", {:capture, [variable_name: "a_1", body: ["body_a_1"]]}]
+ ],
+ capture: [variable_name: "b", body: ["body_b"]]
+ )
+ end
+
+ test "multi tags" do
+ test_parse(
+ "{% decrement a %}{% increment b %}{% decrement c %}{% increment d %}",
+ decrement: [variable: [parts: [part: "a"]]],
+ increment: [variable: [parts: [part: "b"]]],
+ decrement: [variable: [parts: [part: "c"]]],
+ increment: [variable: [parts: [part: "d"]]]
+ )
+ end
+
+ test "case block with when" do
+ test_parse(
+ "{% case x %}useless{% when x > 10 %}y{% when x > 1 %}z{% else %}A{% endcase %}",
+ case: [
+ conditions: [variable: [parts: [part: "x"]]],
+ body: ["useless"],
+ when: [
+ conditions: [condition: {{:variable, [parts: [part: "x"]]}, :>, 10}],
+ body: ["y"]
+ ],
+ when: [
+ conditions: [condition: {{:variable, [parts: [part: "x"]]}, :>, 1}],
+ body: ["z"]
+ ],
+ else: [body: ["A"]]
+ ]
+ )
+ end
+
+ defmodule MinusOneTag do
+ def parse(%Tag{} = tag, %Template{} = context) do
+ {tag, context}
+ end
+
+ def render(output, tag, context) do
+ number = tag.markup |> Integer.parse() |> elem(0)
+ {["#{number - 1}"] ++ output, context}
+ end
+ end
+
+ defmodule MundoTag do
+ def parse(%Tag{} = tag, %Template{} = context) do
+ {tag, context}
+ end
+
+ def render(output, tag, context) do
+ number = tag.markup |> Integer.parse() |> elem(0)
+ {["#{number - 1}"] ++ output, context}
+ end
+ end
+
+ setup_all do
+ Registers.register("minus_one", MinusOneTag, Tag)
+ Registers.register("Mundo", MundoTag, Block)
+ Liquid.start()
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test "custom tag from example(almost random now :)" do
+ test_parse("{% minus_one 5 %}",
+ custom: [{:custom_name, ["minus_one"]}, {:custom_markup, "5 "}]
+ )
+ end
+
+ test "custom block from example(almost random now :)" do
+ test_parse(
+ "{% Mundo 5 %}my body{% endMundo %}",
+ custom: [
+ custom_name: ["Mundo"],
+ custom_markup: "5 ",
+ body: ["my body"]
+ ]
+ )
+ end
+
+ test "custom tag error" do
+ test_combinator_error(
+ "{% hola 5 %}",
+ "Error processing tag 'hola'. It is malformed or you are creating a custom 'hola' without register it"
+ )
+
+ test_combinator_error(
+ "{% hola %}body{% endhola a %}",
+ "Error processing tag 'hola'. It is malformed or you are creating a custom 'hola' without register it"
+ )
+
+ test_combinator_error(
+ "{% Mundo 10 %}body{% endMunda %}",
+ "The 'Mundo' tag has not been correctly closed"
+ )
+
+ test_combinator_error(
+ "{% Mundo 10 %}body",
+ "Malformed tag, open without close: 'Mundo'"
+ )
+ end
+end
diff --git a/test/liquid/strict_parse_test.exs b/test/liquid/strict_parse_test.exs
index 8db53030..476eb1b2 100644
--- a/test/liquid/strict_parse_test.exs
+++ b/test/liquid/strict_parse_test.exs
@@ -1,7 +1,7 @@
defmodule Liquid.StrictParseTest do
use ExUnit.Case
- alias Liquid.{Template, SyntaxError}
+ alias Liquid.Template
test "error on empty filter" do
assert_syntax_error("{{|test}}")
@@ -42,20 +42,28 @@ defmodule Liquid.StrictParseTest do
end
test "missing endtag parse time error" do
- assert_raise RuntimeError, "No matching end for block {% for %}", fn ->
- Template.parse("{% for a in b %} ...")
- end
+ assert_raise RuntimeError,
+ "Malformed tag, open without close: 'for'",
+ fn ->
+ Template.parse("{% for a in b %} ...")
+ end
end
test "unrecognized operator" do
- assert_raise SyntaxError, "Unexpected character in '1 =! 2'", fn ->
- Template.parse("{% if 1 =! 2 %}ok{% endif %}")
- end
+ assert_raise RuntimeError,
+ "Error processing tag 'if'. It is malformed or you are creating a custom 'if' without register it",
+ fn ->
+ Template.parse("{% if 1 =! 2 %}ok{% endif %}")
+ end
- assert_raise SyntaxError, "Invalid variable name", fn -> Template.parse("{{%%%}}") end
+ assert_raise RuntimeError,
+ "expected utf8 codepoint in the range ?A..?Z or in the range ?a..?z or equal to ?_",
+ fn ->
+ Template.parse("{{%%%}}")
+ end
end
defp assert_syntax_error(markup) do
- assert_raise(SyntaxError, fn -> Template.parse(markup) end)
+ assert_raise(RuntimeError, fn -> Template.parse(markup) end)
end
end
diff --git a/test/liquid/template_test.exs b/test/liquid/template_test.exs
index 79548b4f..ed8a165f 100644
--- a/test/liquid/template_test.exs
+++ b/test/liquid/template_test.exs
@@ -4,39 +4,12 @@ defmodule Liquid.TemplateTest do
use ExUnit.Case
alias Liquid.Template, as: Template
- alias Liquid.Parse, as: Parse
setup_all do
Liquid.start()
:ok
end
- test :tokenize_strings do
- assert [" "] == Parse.tokenize(" ")
- assert ["hello world"] == Parse.tokenize("hello world")
- end
-
- test :tokenize_variables do
- assert ["{{funk}}"] == Parse.tokenize("{{funk}}")
- assert [" ", "{{funk}}", " "] == Parse.tokenize(" {{funk}} ")
-
- assert [" ", "{{funk}}", " ", "{{so}}", " ", "{{brother}}", " "] ==
- Parse.tokenize(" {{funk}} {{so}} {{brother}} ")
-
- assert [" ", "{{ funk }}", " "] == Parse.tokenize(" {{ funk }} ")
- end
-
- test :tokenize_blocks do
- assert ["{%comment%}"] == Parse.tokenize("{%comment%}")
- assert [" ", "{%comment%}", " "] == Parse.tokenize(" {%comment%} ")
-
- assert [" ", "{%comment%}", " ", "{%endcomment%}", " "] ==
- Parse.tokenize(" {%comment%} {%endcomment%} ")
-
- assert [" ", "{% comment %}", " ", "{% endcomment %}", " "] ==
- Parse.tokenize(" {% comment %} {% endcomment %} ")
- end
-
test :should_be_able_to_handle_nil_in_parse do
t = Template.parse(nil)
assert {:ok, "", _context} = Template.render(t)
diff --git a/test/liquid/tokenizer_test.exs b/test/liquid/tokenizer_test.exs
new file mode 100644
index 00000000..418cbfba
--- /dev/null
+++ b/test/liquid/tokenizer_test.exs
@@ -0,0 +1,34 @@
+defmodule Liquid.TokenizerTest do
+ use ExUnit.Case
+
+ alias Liquid.Tokenizer
+
+ test "empty string" do
+ assert Tokenizer.tokenize("") == {"", ""}
+ end
+
+ test "white string" do
+ assert Tokenizer.tokenize(" ") == {" ", ""}
+ end
+
+ test "starting tag" do
+ assert Tokenizer.tokenize("{% hello %}") == {"", "{% hello %}"}
+ end
+
+ test "starting variable" do
+ assert Tokenizer.tokenize("{{ hello }}") == {"", "{{ hello }}"}
+ end
+
+ test "tag starting with literal" do
+ assert Tokenizer.tokenize("world {% hello %}") == {"world ", "{% hello %}"}
+ end
+
+ test "variable starting with literal" do
+ assert Tokenizer.tokenize("world {{ hello }}") == {"world ", "{{ hello }}"}
+ end
+
+ test "literal inside block" do
+ assert Tokenizer.tokenize("{% hello %} Hello {% endhello %}") ==
+ {"", "{% hello %} Hello {% endhello %}"}
+ end
+end
diff --git a/test/liquid/translators/markup_test.exs b/test/liquid/translators/markup_test.exs
new file mode 100644
index 00000000..b469ae2d
--- /dev/null
+++ b/test/liquid/translators/markup_test.exs
@@ -0,0 +1,75 @@
+defmodule Liquid.Translators.MarkupTest do
+ use ExUnit.Case
+ alias Liquid.Translators.Markup
+
+ test "transforms {:parts} tag" do
+ assert Markup.literal(
+ {:parts, [{:part, "company"}, {:part, "name"}, {:part, "employee"}, {:index, 0}]}
+ ) == "company.name.employee[0]"
+
+ assert Markup.literal(
+ {:parts,
+ [
+ {:part, "company"},
+ {:part, "name"},
+ {:part, "employee"},
+ {:index, {:variable, [parts: [part: "store", part: "state", index: 1]]}}
+ ]}
+ ) == "company.name.employee[store.state[1]]"
+ end
+
+ test "transforms {:variable} tag" do
+ assert Markup.literal({:variable, [parts: [part: "store", part: "state", index: 1]]}) ==
+ "store.state[1]"
+
+ assert Markup.literal(
+ {:variable, [parts: [part: "store", part: "state", index: 0, index: 0, index: 1]]}
+ ) == "store.state[0][0][1]"
+
+ assert Markup.literal({:variable, [parts: [part: "var", index: "a:b c", index: "paged"]]}) ==
+ "var[\"a:b c\"][\"paged\"]"
+ end
+
+ test "transforms {:logical} tag" do
+ assert Markup.literal({:logical, [:or, {:variable, [parts: [part: "b"]]}]}) == " or b"
+ end
+
+ test "transforms {:condition} tag" do
+ assert Markup.literal({:condition, {true, :==, nil}}) == "true == null"
+ end
+
+ test "transforms {:conditions} tag" do
+ assert Markup.literal(
+ {:conditions,
+ [variable: [parts: [part: "a"]], logical: [:or, {:variable, [parts: [part: "b"]]}]]}
+ ) == "a or b"
+ end
+
+ test "transforms {:variable_name} tag" do
+ assert Markup.literal({:variable_name, "cart"}) == "cart"
+ end
+
+ test "transforms {:filters} tag" do
+ assert Markup.literal({:filters, [filter: ["date", {:params, [value: "%w"]}]]}) ==
+ " | date: \"%w\""
+ end
+
+ test "transforms {:assignment} tag" do
+ assert Markup.literal({
+ :params,
+ [
+ assignment: [variable_name: "my_variable", value: "apples"],
+ assignment: [variable_name: "my_other_variable", value: "oranges"]
+ ]
+ }) == ": my_variable: \"apples\", my_other_variable: \"oranges\""
+ end
+
+ test "transforms {:range} tag" do
+ assert Markup.literal({:range, [start: 1, end: 10]}) == "(1..10)"
+ assert Markup.literal({:range, [start: -10, end: 1]}) == "(-10..1)"
+ end
+
+ test "transforms {:reverse} tag" do
+ assert Markup.literal({:reversed, []}) == " reversed"
+ end
+end
diff --git a/test/liquid/translators/tags/assign_test.exs b/test/liquid/translators/tags/assign_test.exs
new file mode 100644
index 00000000..295ebb93
--- /dev/null
+++ b/test/liquid/translators/tags/assign_test.exs
@@ -0,0 +1,16 @@
+defmodule Liquid.Translators.Tags.AssignTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "assign translate new AST to old AST" do
+ [
+ {"{% assign a = 5 %}{{ a }}", %{}},
+ {"{% assign foo = values %}.{{ foo[0] }}.", %{"values" => ["foo", "bar", "baz"]}},
+ {"{% assign foo = values %}.{{ foo[1] }}.", %{"values" => ["foo", "bar", "baz"]}},
+ {"{% assign foo = values | split: ',' %}.{{ foo[1] }}.", %{"values" => "foo,bar,baz"}}
+ ]
+ |> Enum.each(fn {tag, params} ->
+ test_ast_translation(tag, params)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/capture_test.exs b/test/liquid/translators/tags/capture_test.exs
new file mode 100644
index 00000000..d36afd1f
--- /dev/null
+++ b/test/liquid/translators/tags/capture_test.exs
@@ -0,0 +1,33 @@
+defmodule Liquid.Translators.Tags.CaptureTests do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "capture translate new AST to old AST" do
+ [
+ "{% capture 'var' %}test string{% endcapture %}{{var}}",
+ "{% capture this-thing %}Print this-thing{% endcapture %} {{ this-thing }}",
+ """
+ {% assign var = '' %}
+ {% if true %}
+ {% capture var %}first-block-string{% endcapture %}
+ {% endif %}
+ {% if true %}
+ {% capture var %}test-string{% endcapture %}
+ {% endif %}
+ {{var}}
+ """,
+ """
+ {% assign first = '' %}
+ {% assign second = '' %}
+ {% for number in (1..3) %}
+ {% capture first %}{{number}}{% endcapture %}
+ {% assign second = first %}
+ {% endfor %}
+ {{ first }}-{{ second }}
+ """
+ ]
+ |> Enum.each(fn tag ->
+ test_ast_translation(tag)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/case_test.exs b/test/liquid/translators/tags/case_test.exs
new file mode 100644
index 00000000..43fbc2d4
--- /dev/null
+++ b/test/liquid/translators/tags/case_test.exs
@@ -0,0 +1,45 @@
+defmodule Liquid.Translators.Tags.CaseTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "case translate new AST to old AST" do
+ [
+ {"{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}",
+ %{"condition" => 2}},
+ {"{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}",
+ %{"condition" => 1}},
+ {"{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}",
+ %{"condition" => 3}},
+ {"{% case condition %}{% when \"string here\" %} hit {% endcase %}",
+ %{"condition" => "string here"}},
+ {"{% case condition %}{% when \"string here\" %} hit {% endcase %}",
+ %{"condition" => "string here"}},
+ {"{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}",
+ %{"condition" => "bad string here"}},
+ {"{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}", %{"condition" => 5}}
+ # {"{% case condition %} {% when 5 %} hit {% else %} else {% endcase %}", %{"condition" => 6}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}", %{"a" => []}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}", %{"a" => [1]}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}", %{"a" => [1, 1]}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}", %{"a" => [1, 1, 1]}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}", %{"a" => [1, 1, 1, 1]}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}", %{"a" => [1, 1, 1, 1, 1]}},
+ #########################################################################
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case a.empty? %}{% when true %}true{% when false %}false{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case false %}{% when true %}true{% when false %}false{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case true %}{% when true %}true{% when false %}false{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case NULL %}{% when true %}true{% when false %}false{% else %}else{% endcase %}", %{"condition" => 2}},
+ # {"{% case collection.handle %}{% when 'menswear-jackets' %}{% assign ptitle = 'menswear' %}{% when 'menswear-t-shirts' %}{% assign ptitle = 'menswear' %}{% else %}{% assign ptitle = 'womenswear' %}{% endcase %}{{ ptitle }}", %{"condition" => 2}}
+ ]
+ |> Enum.each(fn {markup, params} ->
+ test_ast_translation(markup, params)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/comment_test.exs b/test/liquid/translators/tags/comment_test.exs
new file mode 100644
index 00000000..abef841e
--- /dev/null
+++ b/test/liquid/translators/tags/comment_test.exs
@@ -0,0 +1,15 @@
+defmodule Liquid.Translators.Tags.CommentTest do
+ use ExUnit.Case
+
+ import Liquid.Helpers
+
+ test "capture translate new AST to old AST" do
+ [
+ "{% comment %} whatever, no matter {% endcomment %}",
+ "{% comment %} {% if true %} {% endcomment %}"
+ ]
+ |> Enum.each(fn tag ->
+ test_ast_translation(tag)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/cycle_test.exs b/test/liquid/translators/tags/cycle_test.exs
new file mode 100644
index 00000000..379dba84
--- /dev/null
+++ b/test/liquid/translators/tags/cycle_test.exs
@@ -0,0 +1,28 @@
+defmodule Liquid.Translators.Tags.CycleTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "cycle translate new AST to old AST" do
+ [
+ "{%cycle \"one\", \"two\"%}",
+ "{%cycle \"one\", \"two\"%} {%cycle \"one\", \"two\"%}",
+ "{%cycle \"\", \"two\"%} {%cycle \"\", \"two\"%}",
+ "{%cycle \"one\", \"two\"%} {%cycle \"one\", \"two\"%} {%cycle \"one\", \"two\"%}",
+ "{%cycle \"text-align: left\", \"text-align: right\" %} {%cycle \"text-align: left\", \"text-align: right\"%}",
+ "{%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%}"
+ ]
+ |> Enum.each(fn tag ->
+ test_ast_translation(tag)
+ end)
+
+ params = %{"var1" => 1, "var2" => 2}
+
+ tag = """
+ {%cycle 1: \"one\", \"two\" %} {%cycle 2: \"one\", \"two\" %}
+ {%cycle 1: \"one\", \"two\" %} {%cycle 2: \"one\", \"two\" %}
+ {%cycle 1: \"one\", \"two\" %} {%cycle 2: \"one\", \"two\" %}
+ """
+
+ test_ast_translation(tag, params)
+ end
+end
diff --git a/test/liquid/translators/tags/for_test.exs b/test/liquid/translators/tags/for_test.exs
new file mode 100644
index 00000000..f9579bad
--- /dev/null
+++ b/test/liquid/translators/tags/for_test.exs
@@ -0,0 +1,89 @@
+defmodule Liquid.Translators.Tags.ForTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "for translate new AST to old AST" do
+ params = %{"array" => [1, 1, 2, 2, 3, 3], "repeat_array" => [1, 1, 1, 1]}
+
+ [
+ "{%for item in array%}{%ifchanged%}{{item}}{% endifchanged %}{%endfor%}",
+ "{%for i in (1..2) %}{% assign a = \"variable\"%}{% endfor %}{{a}}",
+ "{%for item in repeat_array%}{%ifchanged%}{{item}}{% endifchanged %}{%endfor%}",
+ "{%for item in (1..3)%}{%ifchanged%}{{item}}{%for item in (4..6)%}{{item}}{%endfor%}{% endifchanged %}{%endfor%}",
+ "0{% for i in (1..3) %} {{ i }}{% endfor %}",
+ "0{%\nfor i in (1..3)\n%} {{\ni\n}}{%\nendfor\n%}",
+ """
+ {%for val in array%}
+ {{forloop.name}}-
+ {{forloop.index}}-
+ {{forloop.length}}-
+ {{forloop.index0}}-
+ {{forloop.rindex}}-
+ {{forloop.rindex0}}-
+ {{forloop.first}}-{{forloop.last}}-{{val}}{%endfor%}
+ """,
+ "{%for item in array%}\r{% if forloop.first %}\r+{% else %}\n-\r{% endif %}{%endfor%}",
+ """
+ {%for item in array%}
+ yo
+ {%endfor%}
+ """,
+ "{%for item in array reversed %}{{item}}{%endfor%}",
+ "{%for item in (1..3) %} {{item}} {%endfor%}",
+ "{%for item in array%} {{item}} {%endfor%}",
+ "{% for item in array %}{{item}}{% endfor %}",
+ "{%for item in array%}{{item}}{%endfor%}",
+ "{%for i in array limit:2 %}{{ i }}{%endfor%}",
+ "{%for i in array limit:4 %}{{ i }}{%endfor%}",
+ "{%for i in array limit:4 offset:2 %}{{ i }}{%endfor%}",
+ "{%for i in array limit: 4 offset: 2 %}{{ i }}{%endfor%}",
+ "{%for item in array%}{%for i in item%}{{ i }}{%endfor%}{%endfor%}",
+ "{% for i in array %}{% break %}{% endfor %}",
+ "{% for i in array %}{{ i }}{% break %}{% endfor %}",
+ "{% for i in array %}{% break %}{{ i }}{% endfor %}",
+ "{% for i in array %}{{ i }}{% if i > 3 %}{% break %}{% endif %}{% endfor %}"
+ ]
+ |> Enum.each(fn tag ->
+ test_ast_translation(tag, params)
+ end)
+ end
+
+ test "for translate advanced test" do
+ [
+ {"{% for item in array %}{% for i in item %}{{ i }}{% endfor %}{% endfor %}",
+ %{"array" => [[1, 2], [3, 4], [5, 6]]}},
+ {"{%for i in array limit: limit offset: offset %}{{ i }}{%endfor%}",
+ %{"array" => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0], "limit" => 2, "offset" => 2}},
+ {"""
+ {%for i in array.items limit:3 %}{{i}}{%endfor%}
+ next
+ {%for i in array.items offset:continue limit:3 %}{{i}}{%endfor%}
+ next
+ {%for i in array.items offset:continue limit:3 offset:1000 %}{{i}}{%endfor%}
+ """, %{"array" => %{"items" => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]}}},
+ {"""
+ {%for i in array.items limit: 3 %}{{i}}{%endfor%}
+ next
+ {%for i in array.items offset:continue limit: 3 %}{{i}}{%endfor%}
+ next
+ {%for i in array.items offset:continue limit: 3 %}{{i}}{%endfor%}
+ """, %{"array" => %{"items" => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]}}},
+ {"""
+ {%for i in array.items limit:3 %}{{i}}{%endfor%}
+ next
+ {%for i in array.items offset:continue limit:3 %}{{i}}{%endfor%}
+ next
+ {%for i in array.items offset:continue limit:1000 %}{{i}}{%endfor%}
+ """, %{"array" => %{"items" => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]}}},
+ {"""
+ {%for i in array.items limit:3 %}{{i}}{%endfor%}
+ next
+ {%for i in array.items offset:continue limit:3 %}{{i}}{%endfor%}
+ next{%for i in array.items offset:continue limit:3 offset:1000 %}{{i}}{%endfor%}
+ """, %{"array" => %{"items" => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]}}}
+ ]
+ |> Enum.each(fn {markup, params} ->
+ test_ast_translation(markup, params)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/if_test.exs b/test/liquid/translators/tags/if_test.exs
new file mode 100644
index 00000000..a64ae86f
--- /dev/null
+++ b/test/liquid/translators/tags/if_test.exs
@@ -0,0 +1,38 @@
+defmodule Liquid.Translators.Tags.IfTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "if translate new AST to old AST, without params" do
+ [
+ "{% if true == empty %}?{% endif %}",
+ "{% if true == null %}?{% endif %}",
+ "{% if empty == true %}?{% endif %}",
+ "{% if null == true %}?{% endif %}",
+ "{% if false %} this text should not go into the output {% endif %}",
+ "{% if true %} this text should go into the output {% endif %}",
+ "{% if false %} you suck {% endif %} {% if true %} you rock {% endif %}?",
+ "{% if false %} NO {% else %} YES {% endif %}",
+ "{% if true %} YES {% else %} NO {% endif %}",
+ "{% if \"foo\" %} YES {% else %} NO {% endif %}",
+ "{% if true %} YES\n\r\n {% else %} NO\n\r\n {% endif %}"
+ ]
+ |> Enum.each(fn tag ->
+ test_ast_translation(tag)
+ end)
+ end
+
+ test "if translate new AST to old AST, with params" do
+ [
+ {"{% if var %} YES {% endif %}", %{"var" => true}},
+ {"{% if a or b %} YES {% endif %}", %{"a" => true, "b" => true}},
+ {"{% if a or b %} YES {% endif %}", %{"a" => true, "b" => false}},
+ {"{% if a or b %} YES {% endif %}", %{"a" => false, "b" => true}},
+ {"{% if a or b %} YES {% endif %}", %{"a" => false, "b" => false}},
+ {"{% if a or b or c%} YES {% endif %}", %{"a" => false, "b" => false, "c" => true}},
+ {"{% if a or b or c%} YES {% endif %}", %{"a" => false, "b" => false, "c" => false}}
+ ]
+ |> Enum.each(fn {tag, params} ->
+ test_ast_translation(tag, params)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/include_test.exs b/test/liquid/translators/tags/include_test.exs
new file mode 100644
index 00000000..16336aca
--- /dev/null
+++ b/test/liquid/translators/tags/include_test.exs
@@ -0,0 +1,69 @@
+defmodule Liquid.Translators.Tags.IncludeTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+ alias Liquid.FileSystem
+
+ defmodule TestFileSystem do
+ def read_template_file(_root, template_path, _context) do
+ case template_path do
+ "product" ->
+ {:ok, "Product: {{ product.title }} "}
+
+ "locale_variables" ->
+ {:ok, "Locale: {{echo1}} {{echo2}}"}
+
+ "variant" ->
+ {:ok, "Variant: {{ variant.title }}"}
+
+ "nested_template" ->
+ {:ok, "{% include 'header' %} {% include 'body' %} {% include 'footer' %}"}
+
+ "body" ->
+ {:ok, "body {% include 'body_detail' %}"}
+
+ "nested_product_template" ->
+ {:ok, "Product: {{ nested_product_template.title }} {%include 'details'%} "}
+
+ "recursively_nested_template" ->
+ {:ok, "-{% include 'recursively_nested_template' %}"}
+
+ "pick_a_source" ->
+ {:ok, "from TestFileSystem"}
+
+ _ ->
+ {:ok, template_path}
+ end
+ end
+ end
+
+ setup_all do
+ Liquid.start()
+ FileSystem.register(TestFileSystem)
+ on_exit(fn -> Liquid.stop() end)
+ :ok
+ end
+
+ test "include translate new AST to old AST" do
+ [
+ {"{% include 'product' %}", %{}},
+ {"{% include 'product' with products[0] %}",
+ %{"products" => [%{"title" => "Draft 151cm"}, %{"title" => "Element 155cm"}]}},
+ {"{% include 'product' for products %}",
+ %{"products" => [%{"title" => "Draft 151cm"}, %{"title" => "Element 155cm"}]}},
+ {"{% include 'locale_variables' echo1: 'test123' %}", %{}},
+ {"{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}", %{}},
+ {"{% include 'locale_variables' echo1: echo1, echo2: more_echos.echo2 %}",
+ %{"echo1" => "test123", "more_echos" => %{"echo2" => "test321"}}},
+ {"{% include 'body' %}", %{}},
+ {"{% include 'nested_template' %}", %{}},
+ {"{% include 'nested_product_template' with product %}",
+ %{"product" => %{"title" => "Draft 151cm"}}},
+ {"{% include 'nested_product_template' for products %}",
+ %{"products" => [%{"title" => "Draft 151cm"}, %{"title" => "Element 155cm"}]}},
+ {"{% include 'cart' %}", %{"cart" => %{"title" => "Draft 151cm"}}}
+ ]
+ |> Enum.each(fn {markup, params} ->
+ test_ast_translation(markup, params)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/increment_decrement_test.exs b/test/liquid/translators/tags/increment_decrement_test.exs
new file mode 100644
index 00000000..b80149cb
--- /dev/null
+++ b/test/liquid/translators/tags/increment_decrement_test.exs
@@ -0,0 +1,20 @@
+defmodule Liquid.Translators.Tags.IncrementDecrementTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "increment / decrement translate new AST to old AST" do
+ params = %{"port" => 1, "startboard" => 2}
+
+ [
+ "{%increment port %}",
+ "{%increment port %} {%increment port%}",
+ "{%increment port %} {%increment starboard%} {%increment port %} {%increment port%} {%increment starboard %}",
+ "{%decrement port %}",
+ "{%decrement port %} {%decrement port%}",
+ "{%increment port %} {%increment starboard%} {%increment port %} {%decrement port%} {%decrement starboard %}"
+ ]
+ |> Enum.each(fn tag ->
+ test_ast_translation(tag, params)
+ end)
+ end
+end
diff --git a/test/liquid/translators/tags/variable_test.exs b/test/liquid/translators/tags/variable_test.exs
new file mode 100644
index 00000000..f6b41c14
--- /dev/null
+++ b/test/liquid/translators/tags/variable_test.exs
@@ -0,0 +1,27 @@
+defmodule Liquid.Translators.Tags.VariableTest do
+ use ExUnit.Case
+ import Liquid.Helpers
+
+ test "assign translate new AST to old AST" do
+ [
+ {"{{ 'string' }}", %{}},
+ {"{{ variable }}", %{}},
+ {"{{ variable.value }}", %{}},
+ {"{{ variable.value[0] }}", %{}},
+ {"{{ variable.value[index] }}", %{}},
+ {"{{ 'string' }} | capitalize ", %{}},
+ {"{{ variable | capitalize }}", %{}},
+ {"{{ variable.value | capitalize}}", %{}},
+ {"{{ variable.value[0] | capitalize}}", %{}},
+ {"{{ variable.value[index] | capitalize}}", %{}},
+ {"{{ 'string' | capitalize | divided_by: 0}}", %{}},
+ {"{{ variable | capitalize | divided_by: 0}}", %{}},
+ {"{{ variable.value | capitalize | divided_by: 0}}", %{}},
+ {"{{ variable.value[0] | capitalize | divided_by: 0}}", %{}},
+ {"{{ variable.value[index] | capitalize | divided_by: 0}}", %{}}
+ ]
+ |> Enum.each(fn {tag, params} ->
+ test_ast_translation(tag, params)
+ end)
+ end
+end
diff --git a/test/tags/assign_test.exs b/test/tags/assign_test.exs
index b43d3c25..fce6fa8f 100644
--- a/test/tags/assign_test.exs
+++ b/test/tags/assign_test.exs
@@ -1,5 +1,3 @@
-Code.require_file("../../test_helper.exs", __ENV__.file)
-
defmodule Liquid.AssignTest do
use ExUnit.Case
@@ -20,7 +18,7 @@ defmodule Liquid.AssignTest do
end
test :assign_with_filter do
- assert_result(".bar.", "{% assign foo = values | split: ',' %}.{{ foo[1] }}.", %{
+ assert_result(".Foo.", "{% assign foo = values | capitalize | split: ',' %}.{{ foo[0] }}.", %{
"values" => "foo,bar,baz"
})
end
diff --git a/test/tags/case_test.exs b/test/tags/case_test.exs
index 8677ea72..83998ec5 100644
--- a/test/tags/case_test.exs
+++ b/test/tags/case_test.exs
@@ -1,5 +1,3 @@
-Code.require_file("../../test_helper.exs", __ENV__.file)
-
defmodule Liquid.CaseTest do
use ExUnit.Case
diff --git a/test/tags/if_else_test.exs b/test/tags/if_else_test.exs
index ddd251c1..815d7d7b 100644
--- a/test/tags/if_else_test.exs
+++ b/test/tags/if_else_test.exs
@@ -1,5 +1,3 @@
-Code.require_file("../../test_helper.exs", __ENV__.file)
-
defmodule Liquid.Tags.IfElseTagTest do
use ExUnit.Case
@@ -238,5 +236,16 @@ defmodule Liquid.Tags.IfElseTagTest do
t = Template.parse(markup)
{:ok, rendered, _} = Template.render(t, assigns)
assert rendered == expected
+
+ ast =
+ quote do
+ cond do
+ 1 == 1 or 3 == 3 -> 55
+ 2 == 2 -> 2
+ true -> 3
+ end
+ end
+
+ Code.eval_quoted(ast)
end
end
diff --git a/test/tags/include_test.exs b/test/tags/include_test.exs
index a4fb81e7..ef2e1bc9 100644
--- a/test/tags/include_test.exs
+++ b/test/tags/include_test.exs
@@ -1,5 +1,3 @@
-Code.require_file("../../test_helper.exs", __ENV__.file)
-
defmodule TestFileSystem do
def read_template_file(_root, template_path, _context) do
case template_path do
diff --git a/test/tags/standard_tag_test.exs b/test/tags/standard_tag_test.exs
index a3d22506..8548a01e 100644
--- a/test/tags/standard_tag_test.exs
+++ b/test/tags/standard_tag_test.exs
@@ -1,5 +1,3 @@
-Code.require_file("../../test_helper.exs", __ENV__.file)
-
defmodule StandardTagTest do
use ExUnit.Case
@@ -80,7 +78,7 @@ defmodule StandardTagTest do
assert_template_result("foo bar", "foo {%comment%} comment {%endcomment%} bar")
assert_template_result("foobar", "foo{%comment%}
- {%endcomment%}bar")
+ {%endcomment%}bar")
end
test :test_hyphenated_assign do
diff --git a/test/tags/unless_test.exs b/test/tags/unless_test.exs
index 845ee98d..14706a91 100644
--- a/test/tags/unless_test.exs
+++ b/test/tags/unless_test.exs
@@ -1,5 +1,3 @@
-Code.require_file("../../test_helper.exs", __ENV__.file)
-
defmodule Liquid.UnlessTest do
use ExUnit.Case
alias Liquid.Template
diff --git a/test/templates/complex/01/big_literal.liquid b/test/templates/complex/01/big_literal.liquid
new file mode 100644
index 00000000..1f6b5efe
--- /dev/null
+++ b/test/templates/complex/01/big_literal.liquid
@@ -0,0 +1,149 @@
+Look, I was gonna go easy on you and not to hurt your feelings
+But I'm only going to get this one chance
+Something's wrong, I can feel it (Six minutes, Slim Shady, you're on)
+Just a feeling I've got, like something's about to happen, but I don't know what
+If that means, what I think it means, we're in trouble, big trouble,
+And if he is as bananas as you say, I'm not taking any chances
+You were just what the doctor ordered
+I'm beginning to feel like a Rap God, Rap God
+All my people from the front to the back nod, back nod
+Now who thinks their arms are long enough to slap box, slap box?
+They said I rap like a robot, so call me Rapbot
+But for me to rap like a computer must be in my genes
+I got a laptop in my back pocket
+My pen'll go off when I half-cock it
+Got a fat knot from that rap profit
+Made a living and a killing off it
+Ever since Bill Clinton was still in office
+With Monica Lewinsky feeling on his nut-sack
+I'm an MC still as honest
+But as rude and indecent as all hell syllables, killaholic (Kill 'em all with)
+This slickety, gibbedy, hibbedy hip hop
+You don't really wanna get into a pissing match with this rappidy rap
+Packing a Mac in the back of the Ac, pack backpack rap, yep, yackidy-yac
+The exact same time I attempt these lyrical acrobat stunts while I'm practicing
+That I'll still be able to break a motherfuckin' table
+Over the back of a couple of faggots and crack it in half
+Only realized it was ironic I was signed to Aftermath after the fact
+How could I not blow? All I do is drop F-bombs, feel my wrath of attack
+Rappers are having a rough time period, here's a Maxipad
+It's actually disastrously bad
+For the wack while I'm masterfully constructing this masterpiece as
+I'm beginning to feel like a Rap God, Rap God
+All my people from the front to the back nod, back nod
+Now who thinks their arms are long enough to slap box, slap box?
+Let me show you maintaining this shit ain't that hard, that hard
+Everybody want the key and the secret to rap immortality like I have got
+Well, to be truthful the blueprint's simply rage and youthful exuberance
+Everybody loves to root for a nuisance
+Hit the earth like an asteroid, did nothing but shoot for the moon since
+MC's get taken to school with this music
+'Cause I use it as a vehicle to bust a rhyme
+Now I lead a new school full of students
+Me? I'm a product of Rakim, Lakim Shabazz, 2Pac N-
+-W.A, Cube, hey, Doc, Ren, Yella, Eazy, thank you, they got Slim
+Inspired enough to one day grow up, blow up and be in a position
+To meet Run DMC and induct them into the motherfuckin' Rock n'
+Roll Hall of Fame
+Even though I walk in the church and burst in a ball of flames
+Only Hall of Fame I be inducted in is the alcohol of fame
+On the wall of shame
+You fags think it's all a game 'til I walk a flock of flames
+Off of planking, tell me what in the fuck are you thinking?
+Little gay looking boy
+So gay I can barely say it with a straight face looking boy
+You witnessing a massacre
+Like you watching a church gathering take place looking boy
+Oy vey, that boy's gay, that's all they say looking boy
+You get a thumbs up, pat on the back
+And a way to go from your label everyday looking boy
+Hey, looking boy, what you say looking boy?
+I got a "hell yeah" from Dre looking boy
+I'mma work for everything I have
+Never ask nobody for shit, get outta my face looking boy
+Basically boy you're never gonna be capable
+To keep up with the same pace looking boy
+'Cause I'm beginning to feel like a Rap God, Rap God
+All my people from the front to the back nod, back nod
+The way I'm racing around the track, call me Nascar, Nascar
+Dale Earnhardt of the trailer park, the White Trash God
+Kneel before General Zod this planet's Krypton, no Asgard, Asgard
+So you be Thor and I'll be Odin, you rodent, I'm omnipotent
+Let off then I'm reloading immediately with these bombs I'm totin'
+And I should not be woken
+I'm the walking dead, but I'm just a talking head, a zombie floating
+But I got your mom deep throating
+I'm out my ramen noodle, we have nothing in common, poodle
+I'm a doberman, pinch yourself in the arm and pay homage, pupil
+It's me, my honesty's brutal
+But it's honestly futile if I don't utilize what I do though
+For good at least once in a while
+So I wanna make sure somewhere in this chicken scratch I scribble and doodle
+Enough rhymes to maybe to try and help get some people through tough times
+But I gotta keep a few punchlines just in case cause even you unsigned
+Rappers are hungry looking at me like it's lunchtime
+I know there was a time where once I
+Was king of the underground, but I still rap like I'm on my Pharoahe Monch grind
+So I crunch rhymes, but sometimes when you combine
+Appeal with the skin color of mine
+You get too big and here they come trying to,
+Censor you like that one line I said on "I'm Back" from the Marshall Mathers LP
+One where I tried to say I take seven kids from Columbine
+Put 'em all in a line, add an AK-47, a revolver and a nine
+See if I get away with it now that I ain't as big as I was, but I've
+Morphed into an immortal coming through the portal
+You're stuck in a time warp from 2004 though
+And I don't know what the fuck that you rhyme for
+You're pointless as Rapunzel with fucking cornrows
+You're like normal, fuck being normal
+And I just bought a new Raygun from the future
+To just come and shoot ya like when Fabolous made Ray J mad
+'Cause Fab said he looked like a fag at Maywhether’s pad
+Singin' to a man while they played piano
+Man, oh man, that was a 24/7 special on the cable channel
+So Ray J went straight to the radio station the very next day
+"Hey, Fab, I'mma kill you"
+Lyrics coming at you at supersonic speed, (JJ Fad)
+Uh, sama lamaa duma lamaa you assuming I'm a human
+What I gotta do to get it through to you I'm superhuman
+Innovative and I'm made of rubber
+So that anything you saying ricocheting off of me and it'll glue to you
+I'm never stating, more than never demonstrating
+How to give a motherfuckin' audience a feeling like it's levitating
+Never fading, and I know that the haters are forever waiting
+For the day that they can say I fell off, they'd be celebrating
+Cause I know the way to get 'em motivated
+I make elevating music, you make elevator music
+Oh, he's too mainstream
+Well, that's what they do when they get jealous, they confuse it
+It's not hip hop, it's pop, cause I found a hella way to fuse it
+With rock, shock rap with Doc
+Throw on Lose Yourself and make 'em lose it
+I don't know how to make songs like that
+I don't know what words to use
+Let me know when it occurs to you
+While I’m ripping any one of these verses diverse as you
+It’s curtains, I’m inadvertently hurtin' you
+How many verses I gotta murder to,
+Prove that if you're half as nice at songs you can sacrifice virgins too uh!
+School flunkie, pill junky
+But look at the accolades the skills brung me
+Full of myself, but still hungry
+I bully myself cause I make me do what I put my mind to
+And I'm a million leagues above you, ill when I speak in tongues
+But it's still tongue in cheek, fuck you
+I'm drunk so Satan take the fucking wheel, I'm asleep in the front seat
+Bumping Heavy D and the Boys, still chunky, but funky
+But in my head there's something I can feel tugging and struggling
+Angels fight with devils, here's what they want from me
+They asking me to eliminate some of the women hate
+But if you take into consideration the bitter hatred that I had
+Then you may be a little patient and more sympathetic to the situation
+And understand the discrimination
+But fuck it, life's handing you lemons, make lemonade then
+But if I can't batter the women how the fuck am I supposed to bake them a cake then?
+Don't mistake it for Satan
+It's a fatal mistake if you think I need to be overseas
+And take a vacation to trip a broad
+And make her fall on her face and don't be a retard
+Be a king? Think not, why be a king when you can be a God?
diff --git a/test/test_helper.exs b/test/test_helper.exs
index f22637e3..dbde4c43 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -1,7 +1,42 @@
ExUnit.start(exclude: [:skip])
defmodule Liquid.Helpers do
+ use ExUnit.Case
+ alias Liquid.{Template, Parser, NimbleTranslator}
+
def render(text, data \\ %{}) do
- text |> Liquid.Template.parse() |> Liquid.Template.render(data) |> elem(1)
+ text |> Template.parse() |> Template.render(data) |> elem(1)
+ end
+
+ def test_parse(markup, expected) do
+ {:ok, response} = Parser.parse(markup)
+ assert response == expected
+ end
+
+ def test_combinator(markup, combiner, expected) do
+ {:ok, response, _, _, _, _} = combiner.(markup)
+ assert response == expected
+ end
+
+ def test_combinator_error(markup, expected_message \\ nil) do
+ {:error, message, _rest} = Parser.parse(markup)
+ assert message != ""
+
+ if expected_message do
+ assert expected_message == message
+ end
+ end
+
+ def test_ast_translation(markup, params \\ %{}) do
+ old = markup |> Template.parse() |> Template.render(params) |> elem(1)
+
+ new =
+ markup
+ |> Parser.parse()
+ |> NimbleTranslator.translate()
+ |> Template.render(params)
+ |> elem(1)
+
+ assert old == new
end
end