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 @@ + 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 and closing
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