liquid_xlsx 0.1.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.
@@ -0,0 +1,446 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Parses worksheet rows into an AST of template nodes.
5
+ class TemplateParser # rubocop:disable Metrics/ClassLength
6
+ FOR_START = /\A\{%-?\s*for\s+(\w+)\s+in\s+([\w.]+)\s*-?%\}\z/
7
+ FOR_END = /\A\{%-?\s*endfor\s*-?%\}\z/
8
+ IF_START = /\A\{%-?\s*if\s+((?:(?!%\}).)+)\s*-?%\}\z/
9
+ ELSIF = /\A\{%-?\s*elsif\s+((?:(?!%\}).)+)\s*-?%\}\z/
10
+ ELSE_TAG = /\A\{%-?\s*else\s*-?%\}\z/
11
+ ENDIF = /\A\{%-?\s*endif\s*-?%\}\z/
12
+ STRUCTURAL_KEYWORDS = /\A\{%-?\s*(for|endfor|if|elsif|else|endif)\b/
13
+ MIXED_STRUCTURAL = /\{%-?\s*(?:for|endfor|if|elsif|else|endif)\b/
14
+ PURE_STRUCTURAL = [FOR_START, FOR_END, IF_START, ELSIF, ELSE_TAG, ENDIF].freeze
15
+
16
+ # Matches balanced pairs of non-structural Liquid blocks so they can be
17
+ # masked before MIXED_STRUCTURAL checks.
18
+ NON_STRUCTURAL_PAIR_RE = /
19
+ \{%-?\s*
20
+ (case|unless|capture|comment|raw|tablerow)
21
+ \b
22
+ .*?%\}
23
+ .*?
24
+ \{%-?\s*end\1\s*-?%\}
25
+ /mx
26
+
27
+ attr_reader :sheet_name
28
+
29
+ def initialize(rows, sheet_name = "Sheet1")
30
+ @rows = rows
31
+ @sheet_name = sheet_name
32
+ @pos = 0
33
+ end
34
+
35
+ def parse
36
+ nodes = []
37
+ nodes << parse_element while @pos < @rows.length
38
+ nodes
39
+ end
40
+
41
+ private
42
+
43
+ def current_row
44
+ @rows[@pos]
45
+ end
46
+
47
+ def advance
48
+ @pos += 1
49
+ end
50
+
51
+ # Iteratively remove balanced non-structural Liquid blocks (case/endcase,
52
+ # unless/endunless, capture/endcapture, comment/endcomment, raw/endraw,
53
+ # tablerow/endtablerow) so that inner {% else %}, {% if %}, etc. do not
54
+ # trigger false-positive MIXED_STRUCTURAL matches.
55
+ def mask_non_structural_blocks(text)
56
+ result = text.dup
57
+ prev = nil
58
+ while result != prev
59
+ prev = result
60
+ result = result.gsub(NON_STRUCTURAL_PAIR_RE, "")
61
+ end
62
+ result
63
+ end
64
+
65
+ # Check if a row is a pure structural tag row.
66
+ # This means the first cell contains ONLY a structural tag (nothing before/after),
67
+ # and no other cells have content.
68
+ def structural_row?(row)
69
+ first = first_cell_value(row)
70
+ return false unless first
71
+
72
+ stripped = first.strip
73
+ masked = mask_non_structural_blocks(stripped)
74
+ if masked.match?(MIXED_STRUCTURAL) && !stripped.match?(STRUCTURAL_KEYWORDS)
75
+ raise TemplateSyntaxError.new(
76
+ "Structural tag must be placed on a dedicated row. Found mixed content: #{stripped.inspect}",
77
+ sheet: @sheet_name,
78
+ row: row[:row_number],
79
+ cell: cell_reference(row),
80
+ tag: stripped
81
+ )
82
+ end
83
+
84
+ return false unless stripped.match?(STRUCTURAL_KEYWORDS)
85
+
86
+ # Must match exactly one of the structural patterns
87
+ matches_any = [FOR_START, FOR_END, IF_START, ELSIF, ELSE_TAG, ENDIF].any? do |pat|
88
+ first.strip.match?(pat)
89
+ end
90
+ return false unless matches_any
91
+
92
+ # Other cells must be empty
93
+ cells = row[:cells]
94
+ return true if cells.nil? || cells.length <= 1
95
+
96
+ sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
97
+ first_content = sorted.find { |c| cell_content(c) }
98
+ return false unless first_content
99
+
100
+ # All cells AFTER the first content cell must be empty
101
+ first_idx = sorted.index(first_content)
102
+ sorted[(first_idx + 1)..].all? { |c| cell_content(c).nil? }
103
+ end
104
+
105
+ # Raise TemplateSyntaxError if any cell mixes structural tag with other content,
106
+ # or if a row has a structural tag in one cell and other content cells.
107
+ def validate_no_mixed_structural_content(row)
108
+ check_mixed_content_in_cells(row)
109
+ check_tag_cell_with_other_content(row)
110
+ end
111
+
112
+ def check_mixed_content_in_cells(row)
113
+ row[:cells].each do |c|
114
+ cell_val = c[:text] || c[:template]
115
+ next unless cell_val
116
+
117
+ stripped = cell_val.strip
118
+ next unless stripped.match?(MIXED_STRUCTURAL)
119
+
120
+ masked = mask_non_structural_blocks(stripped)
121
+ next unless masked.match?(MIXED_STRUCTURAL)
122
+ next if stripped.match?(STRUCTURAL_KEYWORDS)
123
+
124
+ raise TemplateSyntaxError.new(
125
+ "Structural tag must be placed on a dedicated row. " \
126
+ "Found mixed content: #{stripped.inspect}",
127
+ sheet: @sheet_name,
128
+ row: row[:row_number],
129
+ cell: "#{c[:col]}#{row[:row_number]}",
130
+ tag: stripped
131
+ )
132
+ end
133
+ end
134
+
135
+ def check_tag_cell_with_other_content(row)
136
+ cells_with_tag = row[:cells].select do |c|
137
+ val = c[:text] || c[:template]
138
+ next unless val
139
+
140
+ PURE_STRUCTURAL.any? { |pat| val.strip.match?(pat) }
141
+ end
142
+ cells_with_content = row[:cells].select do |c|
143
+ val = cell_content(c)
144
+ next unless val
145
+ next if PURE_STRUCTURAL.any? { |pat| val.strip.match?(pat) }
146
+
147
+ true
148
+ end
149
+ return unless cells_with_tag.any? && cells_with_content.any?
150
+
151
+ tag_cell = cells_with_tag.first
152
+ tag_text = tag_cell[:text] || tag_cell[:template]
153
+ raise TemplateSyntaxError.new(
154
+ "Structural tag must be on a dedicated row. " \
155
+ "Found tag in #{tag_cell[:col]}#{row[:row_number]} with other cells having content.",
156
+ sheet: @sheet_name,
157
+ row: row[:row_number],
158
+ cell: "#{tag_cell[:col]}#{row[:row_number]}",
159
+ tag: tag_text.strip
160
+ )
161
+ end
162
+
163
+ def parse_element
164
+ row = current_row
165
+
166
+ validate_no_mixed_structural_content(row)
167
+
168
+ first = first_cell_value(row)
169
+
170
+ # Check if first cell is a structural tag but row has other content
171
+ if first&.strip&.match?(STRUCTURAL_KEYWORDS)
172
+ pure_tag = [FOR_START, FOR_END, IF_START, ELSIF, ELSE_TAG, ENDIF].any? do |pat|
173
+ first.strip.match?(pat)
174
+ end
175
+ if pure_tag
176
+ # First cell is a structural tag. Check that other cells are empty.
177
+ cells = row[:cells]
178
+ if cells && cells.length > 1
179
+ sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
180
+ first_content = sorted.find { |c| cell_content(c) }
181
+ other_have_content = if first_content
182
+ first_idx = sorted.index(first_content)
183
+ sorted[(first_idx + 1)..].any? { |c| cell_content(c) }
184
+ else
185
+ false
186
+ end
187
+ if other_have_content
188
+ raise TemplateSyntaxError.new(
189
+ "Structural tag must be placed on a dedicated row.",
190
+ sheet: @sheet_name,
191
+ row: row[:row_number],
192
+ cell: cell_reference(row),
193
+ tag: first.strip
194
+ )
195
+ end
196
+ end
197
+ end
198
+ end
199
+
200
+ return advance && Nodes::RowNode.new(row) unless structural_row?(row)
201
+
202
+ first_cell_text = first_cell_value(row).strip
203
+
204
+ case first_cell_text
205
+ when FOR_START
206
+ parse_for(::Regexp.last_match(1), ::Regexp.last_match(2))
207
+ when IF_START
208
+ parse_if(::Regexp.last_match(1))
209
+ when FOR_END
210
+ raise TemplateSyntaxError.new(
211
+ "Unexpected {% endfor %}",
212
+ sheet: @sheet_name,
213
+ row: row[:row_number],
214
+ cell: cell_reference(row),
215
+ tag: first_cell_text
216
+ )
217
+ when ENDIF
218
+ raise TemplateSyntaxError.new(
219
+ "Unexpected {% endif %}",
220
+ sheet: @sheet_name,
221
+ row: row[:row_number],
222
+ cell: cell_reference(row),
223
+ tag: first_cell_text
224
+ )
225
+ when ELSIF
226
+ raise TemplateSyntaxError.new(
227
+ "Unexpected {% elsif %} outside if block",
228
+ sheet: @sheet_name,
229
+ row: row[:row_number],
230
+ cell: cell_reference(row),
231
+ tag: first_cell_text
232
+ )
233
+ when ELSE_TAG
234
+ raise TemplateSyntaxError.new(
235
+ "Unexpected {% else %} outside if/for block",
236
+ sheet: @sheet_name,
237
+ row: row[:row_number],
238
+ cell: cell_reference(row),
239
+ tag: first_cell_text
240
+ )
241
+ else
242
+ advance
243
+ Nodes::RowNode.new(row)
244
+ end
245
+ end
246
+
247
+ def parse_for(var_name, collection_name)
248
+ for_tag_row = current_row[:row_number]
249
+ advance # skip for tag
250
+ body = []
251
+ else_body = nil
252
+
253
+ while @pos < @rows.length
254
+ row = current_row
255
+ first_cell_value(row)
256
+
257
+ if match_row?(row, FOR_END)
258
+ validate_no_mixed_structural_content(row)
259
+ endfor_tag_row = row[:row_number]
260
+ advance
261
+ return Nodes::ForNode.new(var_name, collection_name, body, else_body,
262
+ for_row: for_tag_row, endfor_row: endfor_tag_row)
263
+ elsif match_row?(row, ELSE_TAG)
264
+ validate_no_mixed_structural_content(row)
265
+ advance
266
+ else_body = []
267
+ while @pos < @rows.length
268
+ else_row = current_row
269
+ if match_row?(else_row, FOR_END)
270
+ validate_no_mixed_structural_content(else_row)
271
+ endfor_tag_row = else_row[:row_number]
272
+ advance
273
+ return Nodes::ForNode.new(var_name, collection_name, body, else_body,
274
+ for_row: for_tag_row, endfor_row: endfor_tag_row)
275
+ end
276
+ if match_row?(else_row, ENDIF)
277
+ validate_no_mixed_structural_content(else_row)
278
+ raise TemplateSyntaxError.new(
279
+ "Unexpected {% endif %} inside a {% for %} block — check that " \
280
+ "{% for %} is closed with {% endfor %}",
281
+ sheet: @sheet_name,
282
+ row: else_row[:row_number],
283
+ cell: cell_reference(else_row),
284
+ tag: first_cell_value(else_row).strip
285
+ )
286
+ end
287
+ else_body << parse_element
288
+ end
289
+ raise TemplateSyntaxError.new(
290
+ "Unclosed {% for %} block (missing {% endfor %})",
291
+ sheet: @sheet_name,
292
+ row: @rows[@pos > 0 ? @pos - 1 : 0][:row_number],
293
+ tag: "{% for #{var_name} in #{collection_name} %}"
294
+ )
295
+ elsif match_row?(row, ENDIF)
296
+ validate_no_mixed_structural_content(row)
297
+ raise TemplateSyntaxError.new(
298
+ "Unexpected {% endif %} inside a {% for %} block — check that " \
299
+ "{% for %} is closed with {% endfor %}",
300
+ sheet: @sheet_name,
301
+ row: row[:row_number],
302
+ cell: cell_reference(row),
303
+ tag: first_cell_value(row).strip
304
+ )
305
+ end
306
+
307
+ body << parse_element
308
+ end
309
+
310
+ raise TemplateSyntaxError.new(
311
+ "Unclosed {% for %} block (missing {% endfor %})",
312
+ sheet: @sheet_name,
313
+ row: @rows.last[:row_number],
314
+ tag: "{% for #{var_name} in #{collection_name} %}"
315
+ )
316
+ end
317
+
318
+ def parse_if(condition)
319
+ condition = condition.strip
320
+ if_tag_row = current_row[:row_number]
321
+ advance # skip if tag
322
+ branches = [{ condition: condition, body: [] }]
323
+
324
+ while @pos < @rows.length
325
+ row = current_row
326
+ first_cell_value(row)
327
+
328
+ if match_row?(row, ENDIF)
329
+ validate_no_mixed_structural_content(row)
330
+ endif_tag_row = row[:row_number]
331
+ advance
332
+ return Nodes::IfNode.new(branches, if_row: if_tag_row, endif_row: endif_tag_row)
333
+ end
334
+
335
+ elsif_match = match_row_with_capture(row, ELSIF)
336
+ if elsif_match
337
+ validate_no_mixed_structural_content(row)
338
+ advance
339
+ branches << { condition: elsif_match[1].strip, body: [] }
340
+ next
341
+ end
342
+
343
+ if match_row?(row, ELSE_TAG)
344
+ validate_no_mixed_structural_content(row)
345
+ advance
346
+ branches << { condition: nil, body: [] }
347
+ while @pos < @rows.length
348
+ else_row = current_row
349
+ if match_row?(else_row, ENDIF)
350
+ validate_no_mixed_structural_content(else_row)
351
+ endif_tag_row = else_row[:row_number]
352
+ advance
353
+ return Nodes::IfNode.new(branches, if_row: if_tag_row, endif_row: endif_tag_row)
354
+ end
355
+ if match_row?(else_row, FOR_END)
356
+ validate_no_mixed_structural_content(else_row)
357
+ raise TemplateSyntaxError.new(
358
+ "Unexpected {% endfor %} inside an {% if %} block — check that " \
359
+ "{% if %} is closed with {% endif %}",
360
+ sheet: @sheet_name,
361
+ row: else_row[:row_number],
362
+ cell: cell_reference(else_row),
363
+ tag: first_cell_value(else_row).strip
364
+ )
365
+ end
366
+ branches.last[:body] << parse_element
367
+ end
368
+ raise TemplateSyntaxError.new(
369
+ "Unclosed {% if %} block (missing {% endif %})",
370
+ sheet: @sheet_name,
371
+ row: @rows[@pos > 0 ? @pos - 1 : 0][:row_number],
372
+ tag: "{% if #{condition} %}"
373
+ )
374
+ end
375
+
376
+ if match_row?(row, FOR_END)
377
+ validate_no_mixed_structural_content(row)
378
+ raise TemplateSyntaxError.new(
379
+ "Unexpected {% endfor %} inside an {% if %} block — check that " \
380
+ "{% if %} is closed with {% endif %}",
381
+ sheet: @sheet_name,
382
+ row: row[:row_number],
383
+ cell: cell_reference(row),
384
+ tag: first_cell_value(row).strip
385
+ )
386
+ end
387
+
388
+ branches.last[:body] << parse_element
389
+ end
390
+
391
+ raise TemplateSyntaxError.new(
392
+ "Unclosed {% if %} block (missing {% endif %})",
393
+ sheet: @sheet_name,
394
+ row: @rows.last[:row_number],
395
+ tag: "{% if #{condition} %}"
396
+ )
397
+ end
398
+
399
+ # Match a row against a pattern (returns MatchData or nil)
400
+ def match_row_with_capture(row, pattern)
401
+ first = first_cell_value(row)
402
+ return nil unless first
403
+
404
+ first.strip.match(pattern)
405
+ end
406
+
407
+ # Check if a row matches a pattern (returns boolean)
408
+ def match_row?(row, pattern)
409
+ first = first_cell_value(row)
410
+ return false unless first
411
+
412
+ first.strip.match?(pattern)
413
+ end
414
+
415
+ # Effective content of a cell for "dedicated row" validation.
416
+ # Formula cells count as content (represented as "=FORMULA") so they are
417
+ # not silently swallowed by structural tag rows.
418
+ def cell_content(cell)
419
+ val = cell[:text] || cell[:template]
420
+ return val if val && !val.strip.empty?
421
+ return "=#{cell[:formula]}" if cell[:formula]
422
+
423
+ nil
424
+ end
425
+
426
+ def first_cell_value(row)
427
+ cells = row[:cells]
428
+ return nil if cells.nil? || cells.empty?
429
+
430
+ # Find the first NON-EMPTY cell by column order (A, B, C...)
431
+ sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
432
+ first = sorted.find { |c| cell_content(c) }
433
+ return nil unless first
434
+
435
+ cell_content(first)
436
+ end
437
+
438
+ def cell_reference(row)
439
+ cells = row[:cells]
440
+ return nil if cells.nil? || cells.empty?
441
+
442
+ sorted = cells.sort_by { |c| CellReference.col_to_index(c[:col]) }
443
+ "#{sorted.first[:col]}#{row[:row_number]}"
444
+ end
445
+ end
446
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ VERSION = "0.1.0"
5
+ end