liquid2 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,272 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO
3
+ require_relative "../../tag"
4
+
5
+ module Liquid2
6
+ # The _extends_ tag.
7
+ class ExtendsTag < Tag
8
+ attr_reader :template_name
9
+
10
+ # @param token [[Symbol, String?, Integer]]
11
+ # @param parser [Parser]
12
+ # @return [ExtendsTag]
13
+ def self.parse(token, parser)
14
+ name = parser.parse_name
15
+ parser.carry_whitespace_control
16
+ parser.eat(:token_tag_end)
17
+ new(token, name)
18
+ end
19
+
20
+ def initialize(token, name)
21
+ super(token)
22
+ @template_name = name
23
+ @blank = false
24
+ end
25
+
26
+ def render(context, buffer)
27
+ base_template = stack_blocks(context, context.template)
28
+ context.extend({}, template: base_template) do
29
+ base_template&.render_with_context(context, buffer)
30
+ end
31
+ context.tag_namespace[:extends].clear
32
+ context.interrupts << :stop_render
33
+ end
34
+
35
+ def children(static_context, include_partials: true)
36
+ return [] unless include_partials
37
+
38
+ begin
39
+ parent = static_context.env.get_template(
40
+ @template_name,
41
+ context: static_context,
42
+ tag: "extends"
43
+ )
44
+ parent.ast
45
+ rescue LiquidTemplateNotFoundError => e
46
+ e.token = @token
47
+ e.template_name = static_context.template.full_name
48
+ raise e
49
+ end
50
+ end
51
+
52
+ def partial_scope
53
+ Partial.new(@template_name, :inherited, [])
54
+ end
55
+
56
+ protected
57
+
58
+ # Visit all templates in the inheritance chain and build a stack for each `block` tag.
59
+ def stack_blocks(context, template)
60
+ # @type var stacks: Hash[String, Array[untyped]]
61
+ stacks = context.tag_namespace[:extends]
62
+
63
+ # Guard against recursive `extends`.
64
+ seen_extends = Set[] # : Set[String]
65
+
66
+ # @type var stack_blocks_: ^(Template) -> Template?
67
+ stack_blocks_ = lambda do |template_|
68
+ extends_nodes, block_nodes = inheritance_nodes(context, template_)
69
+ template_name = template_.path || template_.name
70
+
71
+ if extends_nodes.length > 1
72
+ raise TemplateInheritanceError.new("too many 'extends' tags", extends_nodes[1].token,
73
+ template_name: template_name)
74
+ end
75
+
76
+ # Identify duplicate blocks.
77
+ seen_blocks = Set[] # : Set[String]
78
+
79
+ block_nodes.each do |block|
80
+ if seen_blocks.include?(block.block_name)
81
+ raise TemplateInheritanceError.new("duplicate block #{block.block_name.inspect}",
82
+ block.token, template_name: template_name)
83
+ end
84
+
85
+ seen_blocks.add(block.block_name)
86
+
87
+ stack = stacks[block.block_name]
88
+ required = !stack.empty? && !block.required ? false : block.required
89
+ # [block, required, template, parent]
90
+ # [BlockTag, bool, Template, untyped?]
91
+ stack << [block, required, template_, nil]
92
+ # Populate parent block.
93
+ stack[-2][-1] = stack.last if stack.length > 1
94
+ end
95
+
96
+ return nil if extends_nodes.empty? # steep:ignore
97
+
98
+ extends_node = extends_nodes.first
99
+
100
+ if seen_extends.include?(extends_node.template_name)
101
+ raise TemplateInheritanceError.new(
102
+ "circular extends #{extends_node.template_name.inspect}",
103
+ extends_node.token,
104
+ template_name: template_name
105
+ )
106
+ end
107
+
108
+ seen_extends.add(extends_node.template_name)
109
+
110
+ begin
111
+ context.env.get_template(extends_node.template_name, context: context, tag: "extends")
112
+ rescue LiquidTemplateNotFoundError => e
113
+ e.token = extends_node.token
114
+ e.template_name = template_.full_name
115
+ raise e
116
+ end
117
+ end
118
+
119
+ # @type var next_template: Template?
120
+ base = next_template = stack_blocks_.call(template)
121
+
122
+ while next_template
123
+ next_template = stack_blocks_.call(next_template)
124
+ base = next_template if next_template
125
+ end
126
+
127
+ base
128
+ end
129
+
130
+ # Traverse the template's syntax tree looking for `{% extends %}` and `{% block %}`.
131
+ # @return [[Array[ExtendsTag], Array[BlockTag]]]
132
+ def inheritance_nodes(context, template)
133
+ extends_nodes = [] # : Array[ExtendsTag]
134
+ block_nodes = [] # : Array[BlockTag]
135
+
136
+ # @type var visit: ^(Node) -> void
137
+ visit = lambda do |node|
138
+ extends_nodes << node if node.is_a?(ExtendsTag)
139
+ block_nodes << node if node.is_a?(BlockTag)
140
+
141
+ node.children(context, include_partials: false).each do |child|
142
+ visit.call(child) if child.is_a?(Node)
143
+ end
144
+ end
145
+
146
+ template.ast.each { |node| visit.call(node) if node.is_a?(Node) }
147
+
148
+ [extends_nodes, block_nodes]
149
+ end
150
+ end
151
+
152
+ # The _block_ tag.
153
+ class BlockTag < Tag
154
+ attr_reader :block_name, :required, :block
155
+
156
+ END_BLOCK = Set["endblock"]
157
+
158
+ # @param token [[Symbol, String?, Integer]]
159
+ # @param parser [Parser]
160
+ # @return [BlockTag]
161
+ def self.parse(token, parser)
162
+ block_name = parser.parse_name
163
+ required = if parser.current_kind == :token_required
164
+ parser.next
165
+ true
166
+ else
167
+ false
168
+ end
169
+
170
+ parser.carry_whitespace_control
171
+ parser.eat(:token_tag_end)
172
+ block = parser.parse_block(END_BLOCK)
173
+ parser.eat_empty_tag("endblock")
174
+ new(token, block_name, block, required: required)
175
+ end
176
+
177
+ def initialize(token, name, block, required:)
178
+ super(token)
179
+ @block_name = name
180
+ @block = block
181
+ @required = required
182
+ @blank = false
183
+ end
184
+
185
+ def render(context, buffer)
186
+ # @type var stack: Array[[BlockTag, bool, Template, untyped?]]
187
+ stack = context.tag_namespace[:extends][@block_name]
188
+
189
+ if stack.empty?
190
+ # This base block is being rendered directly.
191
+ if @required
192
+ raise RequiredBlockError.new("block #{@block_name.inspect} is required",
193
+ @token)
194
+ end
195
+
196
+ context.extend({ "block" => BlockDrop.new(token, context, @block_name, nil) }) do
197
+ @block.render(context, buffer)
198
+ end
199
+
200
+ return
201
+ end
202
+
203
+ block_tag, required, template, parent = stack.first
204
+
205
+ if required
206
+ raise RequiredBlockError.new("block #{@block_name.inspect} is required", @token,
207
+ template_name: template.path || template.name)
208
+ end
209
+
210
+ namespace = { "block" => BlockDrop.new(token, context, @block_name, parent) }
211
+
212
+ block_context = context.copy(namespace,
213
+ carry_loop_iterations: true,
214
+ block_scope: true,
215
+ template: template)
216
+
217
+ begin
218
+ block_tag.block.render(block_context, buffer)
219
+ rescue LiquidError => e
220
+ e.template_name = template.path || template.name
221
+ e.source = template.source
222
+ raise
223
+ end
224
+ end
225
+
226
+ def children(_static_context, include_partials: true)
227
+ [@block]
228
+ end
229
+
230
+ def block_scope
231
+ [Identifier.new([:token_word, "block", @token.last])]
232
+ end
233
+ end
234
+
235
+ # A `block` object available within `{% block %}` tags.
236
+ class BlockDrop
237
+ attr_reader :token
238
+
239
+ # @param token [[Symbol, String?, Integer]]
240
+ # @param context [RenderContext]
241
+ # @param name [String]
242
+ # @param parent [[BlockTag, bool, Template, Block?]?]
243
+ def initialize(token, context, name, parent)
244
+ @token = token
245
+ @context = context
246
+ @name = name
247
+ @parent = parent
248
+ end
249
+
250
+ def to_s = "BlockDrop(#{@name})"
251
+
252
+ def key?(key)
253
+ key == "super" && @parent
254
+ end
255
+
256
+ def [](key)
257
+ return nil if key != "super" || @parent.nil?
258
+
259
+ parent = @parent || raise
260
+ buf = +""
261
+ namespace = { "block" => BlockDrop.new(parent.first.token,
262
+ @context,
263
+ parent[2].path || parent[2].name,
264
+ parent.last) }
265
+ @context.extend(namespace) do
266
+ parent.first.block.render(@context, buf)
267
+ end
268
+
269
+ buf.freeze
270
+ end
271
+ end
272
+ end
@@ -47,7 +47,7 @@ module Liquid2
47
47
  # @param args [Array<KeywordArgument> | nil]
48
48
  def initialize(token, name, repeat, var, as, args)
49
49
  super(token)
50
- @name = name
50
+ @template_name = name
51
51
  @repeat = repeat
52
52
  @var = var
53
53
  @as = as
@@ -56,7 +56,7 @@ module Liquid2
56
56
  end
57
57
 
58
58
  def render(context, buffer)
59
- name = context.evaluate(@name)
59
+ name = context.evaluate(@template_name)
60
60
  template = context.env.get_template(name.to_s, context: context, tag: :include)
61
61
  namespace = @args.to_h { |arg| [arg.name, context.evaluate(arg.value)] }
62
62
 
@@ -90,7 +90,7 @@ module Liquid2
90
90
  def children(static_context, include_partials: true)
91
91
  return [] unless include_partials
92
92
 
93
- name = static_context.evaluate(@name)
93
+ name = static_context.evaluate(@template_name)
94
94
  template = static_context.env.get_template(name.to_s, context: static_context, tag: :include)
95
95
  template.ast
96
96
  rescue LiquidTemplateNotFoundError => e
@@ -100,7 +100,7 @@ module Liquid2
100
100
  end
101
101
 
102
102
  def expressions
103
- exprs = [@name]
103
+ exprs = [@template_name]
104
104
  exprs << @var if @var
105
105
  exprs.concat(@args.map(&:value))
106
106
  exprs
@@ -112,12 +112,12 @@ module Liquid2
112
112
  if @var
113
113
  if @as
114
114
  scope << @as # steep:ignore
115
- elsif @name.is_a?(String)
116
- scope << Identifier.new([:token_word, @name.split(".").first, @token.last])
115
+ elsif @template_name.is_a?(String)
116
+ scope << Identifier.new([:token_word, @template_name.split(".").first, @token.last])
117
117
  end
118
118
  end
119
119
 
120
- Partial.new(@name, :shared, scope)
120
+ Partial.new(@template_name, :shared, scope)
121
121
  end
122
122
  end
123
123
  end
@@ -1,3 +1,147 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO
3
+ require_relative "../../tag"
4
+
5
+ module Liquid2
6
+ # The _macro_ tag.
7
+ class MacroTag < Tag
8
+ attr_reader :macro_name, :params, :block
9
+
10
+ END_BLOCK = Set["endmacro"]
11
+
12
+ # @param token [[Symbol, String?, Integer]]
13
+ # @param parser [Parser]
14
+ # @return [MacroTag]
15
+ def self.parse(token, parser)
16
+ name = parser.parse_name
17
+ parser.next if parser.current_kind == :token_comma
18
+ params = parser.parse_parameters
19
+ parser.next if parser.current_kind == :token_comma
20
+ parser.carry_whitespace_control
21
+ parser.eat(:token_tag_end)
22
+ block = parser.parse_block(END_BLOCK)
23
+ parser.eat_empty_tag("endmacro")
24
+ new(token, name, params, block)
25
+ end
26
+
27
+ def initialize(token, name, params, block)
28
+ super(token)
29
+ @macro_name = name
30
+ @params = params
31
+ @block = block
32
+ @blank = true
33
+ end
34
+
35
+ def render(context, _buffer)
36
+ # Macro tags don't render or evaluate anything, just store their parameter list
37
+ # and block on the render context so it can be called later by a `call` tag.
38
+ context.tag_namespace[:macros][@macro_name] = [@params, @block]
39
+ end
40
+
41
+ def children(_static_context, include_partials: true) = [@block]
42
+ def expressions = @params.values.filter_map(&:value)
43
+
44
+ def block_scope
45
+ [
46
+ Identifier.new([:token_word, "args", @token.last]),
47
+ Identifier.new([:token_word, "kwargs", @token.last]),
48
+ *@params.values.map { |param| Identifier.new([:token_word, param.name, param.token.last]) }
49
+ ]
50
+ end
51
+ end
52
+
53
+ # The _call_ tag.
54
+ class CallTag < Tag
55
+ attr_reader :macro_name, :args, :kwargs
56
+
57
+ DISABLED_TAGS = Set["include", "block"]
58
+
59
+ # @param token [[Symbol, String?, Integer]]
60
+ # @param parser [Parser]
61
+ # @return [CallTag]
62
+ def self.parse(token, parser)
63
+ name = parser.parse_name
64
+ parser.next if parser.current_kind == :token_comma
65
+ args, kwargs = parser.parse_arguments
66
+ parser.carry_whitespace_control
67
+ parser.eat(:token_tag_end)
68
+ new(token, name, args, kwargs)
69
+ end
70
+
71
+ def initialize(token, name, args, kwargs)
72
+ super(token)
73
+ @macro_name = name
74
+ @args = args
75
+ @kwargs = kwargs
76
+ @blank = false
77
+ end
78
+
79
+ def render(context, buffer)
80
+ # @type var params: Hash[String, Parameter]?
81
+ # @type var block: Block
82
+ params, block = context.tag_namespace[:macros][@macro_name]
83
+
84
+ unless params
85
+ buffer << Liquid2.to_output_string(context.env.undefined(@macro_name, node: self))
86
+ return
87
+ end
88
+
89
+ # Parameter names mapped to default values. :undefined is used if there is no default.
90
+ args = params.values.to_h { |p| [p.name, p.value] }
91
+ excess_args = [] # : Array[untyped]
92
+ excess_kwargs = {} # : Hash[String, untyped]
93
+
94
+ # Update args with positional arguments.
95
+ # Keyword arguments are pushed to the end if they appear before positional arguments.
96
+ names = args.keys
97
+ length = @args.length
98
+ index = 0
99
+ while index < length
100
+ name = names[index]
101
+ expr = @args[index]
102
+ if name.nil?
103
+ excess_args << expr
104
+ else
105
+ args[name] = expr
106
+ end
107
+ index += 1
108
+ end
109
+
110
+ # Update args with keyword arguments.
111
+ @kwargs.each do |arg|
112
+ if params.include?(arg.name)
113
+ # This has the potential to override a positional argument.
114
+ args[arg.name] = arg.value
115
+ else
116
+ excess_kwargs[arg.name] = arg.value
117
+ end
118
+ end
119
+
120
+ # @type var namespace: Hash[String, untyped]
121
+ namespace = {
122
+ "args" => excess_args.map { |arg| context.evaluate(arg) },
123
+ "kwargs" => excess_kwargs.transform_values! { |val| context.evaluate(val) }
124
+ }
125
+
126
+ args.each do |k, v|
127
+ namespace[k] = if v == :undefined
128
+ context.env.undefined(k, node: params[k])
129
+ else
130
+ context.evaluate(v)
131
+ end
132
+ end
133
+
134
+ macro_context = context.copy(
135
+ namespace,
136
+ disabled_tags: DISABLED_TAGS,
137
+ carry_loop_iterations: true
138
+ )
139
+
140
+ block.render(macro_context, buffer)
141
+ end
142
+
143
+ def expressions
144
+ [*@args, *@kwargs.map(&:value)]
145
+ end
146
+ end
147
+ end
@@ -10,7 +10,8 @@ module Liquid2
10
10
  def self.parse(token, parser)
11
11
  parser.carry_whitespace_control
12
12
  parser.eat(:token_tag_end)
13
- # TODO: apply whitespace control to raw text
13
+ # TODO: apply whitespace control to raw text?
14
+ # Shopify/liquid does not apply whitespace control to raw content.
14
15
  raw = parser.eat(:token_raw)
15
16
  parser.eat_empty_tag("endraw")
16
17
  new(token, raw[1] || raise)
@@ -12,8 +12,6 @@ module Liquid2
12
12
  # @return [RenderTag]
13
13
  def self.parse(token, parser)
14
14
  name = parser.parse_string
15
- raise LiquidTypeError, "expected a string literal" unless name.is_a?(String)
16
-
17
15
  repeat = false
18
16
  var = nil # : Expression?
19
17
  as = nil # : Identifier?
@@ -51,7 +49,7 @@ module Liquid2
51
49
  # @param args [Array<KeywordArgument> | nil]
52
50
  def initialize(token, name, repeat, var, as, args)
53
51
  super(token)
54
- @name = name
52
+ @template_name = name
55
53
  @repeat = repeat
56
54
  @var = var
57
55
  @as = as&.name
@@ -60,7 +58,7 @@ module Liquid2
60
58
  end
61
59
 
62
60
  def render(context, buffer)
63
- template = context.env.get_template(@name, context: context, tag: :render)
61
+ template = context.env.get_template(@template_name, context: context, tag: :render)
64
62
  namespace = @args.to_h { |arg| [arg.name, context.evaluate(arg.value)] }
65
63
 
66
64
  ctx = context.copy(namespace,
@@ -96,7 +94,7 @@ module Liquid2
96
94
  template.render_with_context(ctx, buffer, partial: true, block_scope: true)
97
95
  end
98
96
  rescue LiquidTemplateNotFoundError => e
99
- e.token = @name
97
+ e.token = @template_name
100
98
  e.template_name = context.template.full_name
101
99
  raise e
102
100
  end
@@ -104,7 +102,7 @@ module Liquid2
104
102
  def children(static_context, include_partials: true)
105
103
  return [] unless include_partials
106
104
 
107
- name = static_context.evaluate(@name)
105
+ name = static_context.evaluate(@template_name)
108
106
  template = static_context.env.get_template(name.to_s, context: static_context, tag: :include)
109
107
  template.ast
110
108
  rescue LiquidTemplateNotFoundError => e
@@ -114,7 +112,7 @@ module Liquid2
114
112
  end
115
113
 
116
114
  def expressions
117
- exprs = [@name]
115
+ exprs = [@template_name]
118
116
  exprs << @var if @var
119
117
  exprs.concat(@args.map(&:value))
120
118
  exprs
@@ -126,12 +124,12 @@ module Liquid2
126
124
  if @var
127
125
  if @as
128
126
  scope << @as # steep:ignore
129
- elsif @name.is_a?(String)
130
- scope << Identifier.new([:token_word, @name.split(".").first, @token.last])
127
+ elsif @template_name.is_a?(String)
128
+ scope << Identifier.new([:token_word, @template_name.split(".").first, @token.last])
131
129
  end
132
130
  end
133
131
 
134
- Partial.new(@name, :isolated, scope)
132
+ Partial.new(@template_name, :isolated, scope)
135
133
  end
136
134
  end
137
135
  end
@@ -1,3 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO
3
+ require_relative "../../tag"
4
+
5
+ module Liquid2
6
+ # The _with_ tag.
7
+ class WithTag < Tag
8
+ END_BLOCK = Set["endwith"]
9
+
10
+ # @param token [[Symbol, String?, Integer]]
11
+ # @param parser [Parser]
12
+ # @return [WithTag]
13
+ def self.parse(token, parser)
14
+ parser.next if parser.current_kind == :token_comma
15
+ args = parser.parse_keyword_arguments
16
+ parser.next if parser.current_kind == :token_comma
17
+ parser.carry_whitespace_control
18
+ parser.eat(:token_tag_end)
19
+ block = parser.parse_block(END_BLOCK)
20
+ parser.eat_empty_tag("endwith")
21
+ new(token, args, block)
22
+ end
23
+
24
+ # @param token [[Symbol, String?, Integer]]
25
+ # @param args [Array[KeywordArgument]]
26
+ # @param block [Block]
27
+ def initialize(token, args, block)
28
+ super(token)
29
+ @args = args
30
+ @block = block
31
+ @blank = block.blank
32
+ end
33
+
34
+ def render(context, buffer)
35
+ namespace = @args.to_h { |arg| [arg.name, context.evaluate(arg.value)] }
36
+ context.extend(namespace) do
37
+ @block.render(context, buffer)
38
+ end
39
+ end
40
+
41
+ def children(_static_context, include_partials: true) = [@block]
42
+ def block_scope = @args.map { |arg| Identifier.new(arg.token) }
43
+ end
44
+ end