liquid_xlsx 0.2.2 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +146 -4
- data/lib/liquid_xlsx/errors.rb +2 -0
- data/lib/liquid_xlsx/file_part.rb +32 -0
- data/lib/liquid_xlsx/package.rb +18 -1
- data/lib/liquid_xlsx/renderer.rb +3 -11
- data/lib/liquid_xlsx/streaming_context.rb +34 -0
- data/lib/liquid_xlsx/streaming_renderer.rb +300 -0
- data/lib/liquid_xlsx/tags/sheet_tag.rb +4 -0
- data/lib/liquid_xlsx/template.rb +13 -1
- data/lib/liquid_xlsx/validation/block_boundary.rb +30 -0
- data/lib/liquid_xlsx/validation/issue.rb +36 -0
- data/lib/liquid_xlsx/validation/liquid_usage.rb +149 -0
- data/lib/liquid_xlsx/validation/result.rb +80 -0
- data/lib/liquid_xlsx/validation/usage.rb +61 -0
- data/lib/liquid_xlsx/validator.rb +386 -0
- data/lib/liquid_xlsx/version.rb +1 -1
- data/lib/liquid_xlsx/workbook.rb +27 -0
- data/lib/liquid_xlsx/worksheet.rb +24 -0
- data/lib/liquid_xlsx.rb +46 -2
- metadata +10 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
# An independent AST walker: output rows and loop iterations are never retained.
|
|
5
|
+
class StreamingRenderer < Renderer
|
|
6
|
+
# MRI sizes its young heap relative to live input, including large Arrays.
|
|
7
|
+
# Occasional minor collections bound transient XML garbage without changing
|
|
8
|
+
# process-wide GC configuration or retaining output metadata.
|
|
9
|
+
GC_ROW_INTERVAL = 16_384
|
|
10
|
+
|
|
11
|
+
TEXT_ESCAPES = { "&" => "&", "<" => "<", ">" => ">", "\r" => " " }.freeze
|
|
12
|
+
ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge('"' => """, "\n" => " ", "\t" => "	").freeze
|
|
13
|
+
|
|
14
|
+
def prepare
|
|
15
|
+
@rows = worksheet.parse_rows
|
|
16
|
+
@merges = worksheet.merge_cells
|
|
17
|
+
validate_cells
|
|
18
|
+
@ast = TemplateParser.new(@rows, worksheet.name).parse
|
|
19
|
+
@merge_state = RenderState.new(MergeCellsTransformer.new, @merges, FormulaTranslator.new)
|
|
20
|
+
prepare_nodes(@ast)
|
|
21
|
+
@merge_state.check_block_boundaries(worksheet.name)
|
|
22
|
+
# Only endpoints of header merges need mapping, bounded by template size.
|
|
23
|
+
@merge_rows = @merges.flat_map { |m| [m[:start_row], m[:end_row]] }.uniq.to_h { |r| [r, true] }
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# True when the sheet iterates a collection; a one-shot source must feed only one sheet.
|
|
28
|
+
def loops?
|
|
29
|
+
@loops == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render(data, **_unused)
|
|
33
|
+
prepare unless @ast
|
|
34
|
+
file = Tempfile.new(["liquid_xlsx_rows", ".xml"])
|
|
35
|
+
file.binmode
|
|
36
|
+
unless @rows.any? { |row| row[:cells].any? { |cell| cell[:template] } }
|
|
37
|
+
part = FilePart.new(file, worksheet.to_xml, "")
|
|
38
|
+
return part
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
context = streaming_context(data)
|
|
42
|
+
@output = file
|
|
43
|
+
@row_buffer = +""
|
|
44
|
+
@row_number = 0
|
|
45
|
+
@max_col_index = 0
|
|
46
|
+
@max_col = "A"
|
|
47
|
+
walk(@ast, context)
|
|
48
|
+
file.flush
|
|
49
|
+
worksheet.update_merge_cells(@merge_state.build_merge_ranges)
|
|
50
|
+
prefix, suffix = worksheet.streaming_envelope(@row_number, @max_col)
|
|
51
|
+
part = FilePart.new(file, prefix, suffix)
|
|
52
|
+
part
|
|
53
|
+
ensure
|
|
54
|
+
file&.close! unless part
|
|
55
|
+
@output = nil
|
|
56
|
+
@row_buffer = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_cells
|
|
62
|
+
first_loop = @rows.find do |row|
|
|
63
|
+
row[:cells].any? { |c| c[:template]&.strip&.match?(TemplateParser::FOR_START) }
|
|
64
|
+
end
|
|
65
|
+
@rows.each do |row|
|
|
66
|
+
row[:cells].each do |cell|
|
|
67
|
+
if cell[:raw_cell].at_xpath("xmlns:f")
|
|
68
|
+
unsupported("Formulas are not supported in streaming mode", row[:row_number], cell[:col])
|
|
69
|
+
end
|
|
70
|
+
# Inspect all visible runs as well, including tags in unused branches.
|
|
71
|
+
text = cell[:text].to_s + cell[:raw_cell].xpath("xmlns:is//xmlns:t").map(&:text).join
|
|
72
|
+
next unless text.match?(/\{%-?\s*(?:image_tag|sheet)\b/)
|
|
73
|
+
|
|
74
|
+
unsupported("image_tag and sheet tags are not supported in streaming mode", row[:row_number], cell[:col])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
return unless first_loop
|
|
78
|
+
|
|
79
|
+
@merges.each do |merge|
|
|
80
|
+
next if merge[:end_row] < first_loop[:row_number]
|
|
81
|
+
|
|
82
|
+
ref = CellReference.new(merge[:ref].split(":").first)
|
|
83
|
+
unsupported("Merges at or below the first loop are not supported in streaming mode", ref.row, ref.col)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def unsupported(message, row, col)
|
|
88
|
+
raise StreamingUnsupportedError.new(message, sheet: worksheet.name, row: row, cell: "#{col}#{row}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def prepare_nodes(nodes)
|
|
92
|
+
nodes.each do |node|
|
|
93
|
+
case node
|
|
94
|
+
when Nodes::RowNode
|
|
95
|
+
row = node.row_data
|
|
96
|
+
row[:stream_attrs] = (row[:row_attrs] || {}).filter_map do |key, value|
|
|
97
|
+
%( #{key}="#{escape_attribute(value)}") if value
|
|
98
|
+
end.join
|
|
99
|
+
row[:cells].each do |cell|
|
|
100
|
+
cell[:stream_index] = CellReference.col_to_index(cell[:col])
|
|
101
|
+
cell[:stream_style] = cell[:style] ? %( s="#{escape_attribute(cell[:style])}") : ""
|
|
102
|
+
text = cell[:template]
|
|
103
|
+
match = text&.strip&.match(SINGLE_VAR)
|
|
104
|
+
cell[:stream_lookup] = Liquid::Expression.parse(match[1]) if match
|
|
105
|
+
cell[:stream_string_fast] = text&.match?(SINGLE_VAR)
|
|
106
|
+
cell[:stream_static] = static_cell(cell) unless text
|
|
107
|
+
end
|
|
108
|
+
when Nodes::ForNode
|
|
109
|
+
@loops = true
|
|
110
|
+
prepare_nodes(node.body)
|
|
111
|
+
prepare_nodes(node.else_body || [])
|
|
112
|
+
when Nodes::IfNode
|
|
113
|
+
@merge_state.enter_block(:if, node.if_row, node.endif_row)
|
|
114
|
+
prepare_nodes(node.branches.flat_map { |b| b[:body] })
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def streaming_context(data)
|
|
120
|
+
normalized = normalize_data(data)
|
|
121
|
+
# Normalize the root without walking or duplicating collection arrays.
|
|
122
|
+
normalized = normalized.to_h { |key, value| [key.to_s, streaming_value(value)] } if normalized.is_a?(Hash)
|
|
123
|
+
context = StreamingContext.new(normalized, {}, {}, true)
|
|
124
|
+
context.strict_variables = @options.fetch(:strict_variables, false)
|
|
125
|
+
context.strict_filters = @options.fetch(:strict_filters, false)
|
|
126
|
+
context.add_filters(@options[:filters]) if @options[:filters]
|
|
127
|
+
apply_resource_limits(context)
|
|
128
|
+
context
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def streaming_value(value)
|
|
132
|
+
return value unless value.is_a?(Hash)
|
|
133
|
+
return value if value.each_key.all?(String)
|
|
134
|
+
|
|
135
|
+
value.to_h { |key, item| [key.to_s, streaming_value(item)] }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def streaming_item(item)
|
|
139
|
+
return item if item.is_a?(Hash) && item.each_key.all?(String)
|
|
140
|
+
|
|
141
|
+
stringify_value(item)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def walk(nodes, context)
|
|
145
|
+
nodes.each do |node|
|
|
146
|
+
case node
|
|
147
|
+
when Nodes::RowNode then write_row(node.row_data, context)
|
|
148
|
+
when Nodes::ForNode then walk_for(node, context)
|
|
149
|
+
when Nodes::IfNode
|
|
150
|
+
branch = node.branches.find do |candidate|
|
|
151
|
+
candidate[:condition].nil? || evaluate_condition(candidate[:condition], context, node.if_row)
|
|
152
|
+
end
|
|
153
|
+
walk(branch[:body], context) if branch
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def walk_for(node, context)
|
|
159
|
+
collection = if context.strict_variables
|
|
160
|
+
strict_collection_lookup(context, node)
|
|
161
|
+
else
|
|
162
|
+
safe_context_lookup(context, node.collection_name)
|
|
163
|
+
end
|
|
164
|
+
collection = [] if collection.nil?
|
|
165
|
+
unless collection.respond_to?(:each)
|
|
166
|
+
raise RenderError.new("Cannot iterate over #{collection.class}: expected an object with #each",
|
|
167
|
+
sheet: worksheet.name, row: node.for_row)
|
|
168
|
+
end
|
|
169
|
+
size = collection.size if collection.respond_to?(:size)
|
|
170
|
+
size = nil unless size.is_a?(Integer)
|
|
171
|
+
loop_info = {}
|
|
172
|
+
loop_info["length"] = size if size
|
|
173
|
+
index = 0
|
|
174
|
+
collection.each do |item|
|
|
175
|
+
loop_info["index"] = index + 1
|
|
176
|
+
loop_info["index0"] = index
|
|
177
|
+
loop_info["first"] = index.zero?
|
|
178
|
+
if size
|
|
179
|
+
loop_info["last"] = index == size - 1
|
|
180
|
+
loop_info["rindex"] = size - index
|
|
181
|
+
loop_info["rindex0"] = size - index - 1
|
|
182
|
+
end
|
|
183
|
+
context.stack do
|
|
184
|
+
context[node.variable_name] = streaming_item(item)
|
|
185
|
+
context["forloop"] = loop_info
|
|
186
|
+
walk(node.body, context)
|
|
187
|
+
end
|
|
188
|
+
index += 1
|
|
189
|
+
end
|
|
190
|
+
walk(node.else_body, context) if index.zero? && node.else_body
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def write_row(row, context)
|
|
194
|
+
@row_number += 1
|
|
195
|
+
number = @row_number.to_s
|
|
196
|
+
xml = @row_buffer.clear
|
|
197
|
+
xml << '<row r="' << number << '"' << row[:stream_attrs] << ">"
|
|
198
|
+
row[:cells].each do |cell|
|
|
199
|
+
if cell[:template]
|
|
200
|
+
value = streaming_cell_value(cell, context, row[:row_number])
|
|
201
|
+
next if (value.nil? || value == "") && !cell[:style]
|
|
202
|
+
|
|
203
|
+
xml << '<c r="' << cell[:col] << number << '"' << cell[:stream_style]
|
|
204
|
+
append_value(xml, value)
|
|
205
|
+
else
|
|
206
|
+
content = cell[:stream_static]
|
|
207
|
+
next unless content
|
|
208
|
+
|
|
209
|
+
xml << '<c r="' << cell[:col] << number << '"' << content
|
|
210
|
+
end
|
|
211
|
+
xml << "</c>"
|
|
212
|
+
if cell[:stream_index] > @max_col_index
|
|
213
|
+
@max_col_index = cell[:stream_index]
|
|
214
|
+
@max_col = cell[:col]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
xml << "</row>\n"
|
|
218
|
+
@output.write(xml)
|
|
219
|
+
GC.start(full_mark: false) if (@row_number % GC_ROW_INTERVAL).zero?
|
|
220
|
+
@merge_state.record_row(row[:row_number], @row_number) if @merge_rows[row[:row_number]]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def streaming_cell_value(cell, context, row)
|
|
224
|
+
if (expression = cell[:stream_lookup])
|
|
225
|
+
value = begin
|
|
226
|
+
context.evaluate(expression)
|
|
227
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
return value if value.is_a?(String) && cell[:stream_string_fast] && !@options[:liquid_resource_limits]
|
|
231
|
+
return value if numeric_for_excel?(value) || value == true || value == false
|
|
232
|
+
return date_time_to_serial(value) if date_time?(value)
|
|
233
|
+
|
|
234
|
+
return render_liquid_cell(cell, context, row)
|
|
235
|
+
end
|
|
236
|
+
render_cell_template(cell, context, nil, row)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The compiled lookup already performed the original type-preserving probe.
|
|
240
|
+
# Go straight to Liquid on fallback so nil/Drop methods are read twice, as
|
|
241
|
+
# before, rather than probing a third time through render_cell_template.
|
|
242
|
+
def render_liquid_cell(cell, context, row)
|
|
243
|
+
ref = "#{cell[:col]}#{row}"
|
|
244
|
+
context.registers[:liquid_xlsx_source_row] = row
|
|
245
|
+
context.registers[:liquid_xlsx_source_cell] = ref
|
|
246
|
+
coerce_value(cached_parse(cell[:template]).render(context))
|
|
247
|
+
rescue Liquid::UndefinedVariable, Liquid::UndefinedDropMethod => e
|
|
248
|
+
raise MissingVariableError.new("Missing variable: #{e.message}", sheet: worksheet.name,
|
|
249
|
+
row: row, cell: ref, template: cell[:template])
|
|
250
|
+
rescue Liquid::Error => e
|
|
251
|
+
raise RenderError.new("Liquid render error: #{e.message}", sheet: worksheet.name,
|
|
252
|
+
row: row, cell: ref, template: cell[:template])
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def static_cell(cell)
|
|
256
|
+
style = cell[:stream_style]
|
|
257
|
+
if cell[:type] == "s" && cell[:text]
|
|
258
|
+
%(#{style} t="s"><v>#{escape_text(cell[:value].to_s)}</v>)
|
|
259
|
+
elsif cell[:type] != "inlineStr" && cell[:value]
|
|
260
|
+
type = cell[:type] ? %( t="#{escape_attribute(cell[:type])}") : ""
|
|
261
|
+
%(#{style}#{type}><v>#{escape_text(cell[:value])}</v>)
|
|
262
|
+
elsif cell[:text].nil? || cell[:text].empty?
|
|
263
|
+
serialize_value("", style) if cell[:style]
|
|
264
|
+
else
|
|
265
|
+
serialize_value(cell[:text], style)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Static cell content is compiled once; dynamic cells append directly into
|
|
270
|
+
# the reused row buffer, avoiding two XML-sized temporary strings per cell.
|
|
271
|
+
def serialize_value(value, style)
|
|
272
|
+
xml = style.dup
|
|
273
|
+
append_value(xml, value)
|
|
274
|
+
xml
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def append_value(xml, value)
|
|
278
|
+
case value
|
|
279
|
+
when String
|
|
280
|
+
text = Worksheet.sanitize_text(value)
|
|
281
|
+
xml << ' t="inlineStr"><is><t'
|
|
282
|
+
xml << ' xml:space="preserve"' if text.match?(/\A\s|\s\z/) || text.include?("\n")
|
|
283
|
+
xml << ">" << escape_text(text) << "</t></is>"
|
|
284
|
+
when Numeric then xml << "><v>" << value.to_s << "</v>"
|
|
285
|
+
when true, false then xml << ' t="b"><v>' << (value ? "1" : "0") << "</v>"
|
|
286
|
+
else append_value(xml, value.to_s)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def escape_text(text)
|
|
291
|
+
return text unless text.match?(/[&<>\r]/)
|
|
292
|
+
|
|
293
|
+
text.gsub(/[&<>\r]/, TEXT_ESCAPES)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def escape_attribute(text)
|
|
297
|
+
text.to_s.gsub(/[&<>"\r\n\t]/, ATTRIBUTE_ESCAPES)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -12,6 +12,10 @@ module LiquidXlsx
|
|
|
12
12
|
# - +data+: Liquid expression — object to pass as local variable
|
|
13
13
|
# - +as+: (optional) quoted string — local variable name, defaults to "item"
|
|
14
14
|
class SheetTag < Liquid::Tag
|
|
15
|
+
# Exposed for Validator: it has to know which sheet the tag clones, to
|
|
16
|
+
# check that the sheet exists, without rendering the tag.
|
|
17
|
+
attr_reader :template_name, :as_name
|
|
18
|
+
|
|
15
19
|
SYNTAX = /\A\s*(.+)\s*\z/
|
|
16
20
|
# Known argument keys. Values are split on the positions of these keys
|
|
17
21
|
# (not on whitespace) so that a name/data value may itself contain
|
data/lib/liquid_xlsx/template.rb
CHANGED
|
@@ -14,7 +14,12 @@ module LiquidXlsx
|
|
|
14
14
|
# @param data [Hash, Liquid::Drop, #to_liquid] data to render (Hash/Drop,
|
|
15
15
|
# or any object whose `#to_liquid` returns one)
|
|
16
16
|
# @param output_path [String]
|
|
17
|
-
def render_to_file(data, output_path)
|
|
17
|
+
def render_to_file(data, output_path, **options)
|
|
18
|
+
unless options.empty?
|
|
19
|
+
configured = self.class.new(@template_path, @options.merge(options))
|
|
20
|
+
return configured.render_to_file(data, output_path)
|
|
21
|
+
end
|
|
22
|
+
|
|
18
23
|
package = render_package(data)
|
|
19
24
|
package.write(output_path)
|
|
20
25
|
rescue LiquidXlsx::Error
|
|
@@ -22,6 +27,8 @@ module LiquidXlsx
|
|
|
22
27
|
rescue Zip::Error, Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::EISDIR => e
|
|
23
28
|
raise OutputWriteError,
|
|
24
29
|
"Failed to write output '#{output_path}': #{e.class}: #{e.message}"
|
|
30
|
+
ensure
|
|
31
|
+
package&.close_file_parts
|
|
25
32
|
end
|
|
26
33
|
|
|
27
34
|
# Render template and return binary content.
|
|
@@ -29,6 +36,8 @@ module LiquidXlsx
|
|
|
29
36
|
# or any object whose `#to_liquid` returns one)
|
|
30
37
|
# @return [String] binary .xlsx content
|
|
31
38
|
def render(data)
|
|
39
|
+
raise ArgumentError, "Streaming requires render_to_file; binary output is not supported" if @options[:streaming]
|
|
40
|
+
|
|
32
41
|
package = render_package(data)
|
|
33
42
|
package.to_binary
|
|
34
43
|
rescue LiquidXlsx::Error
|
|
@@ -63,7 +72,10 @@ module LiquidXlsx
|
|
|
63
72
|
"Failed to read template '#{@template_path}': #{e.class}: #{e.message}"
|
|
64
73
|
end
|
|
65
74
|
|
|
75
|
+
workbook_rendered = true
|
|
66
76
|
package
|
|
77
|
+
ensure
|
|
78
|
+
package&.close_file_parts unless workbook_rendered
|
|
67
79
|
end
|
|
68
80
|
end
|
|
69
81
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# Whether a merged range crosses the boundary of a structural block.
|
|
6
|
+
#
|
|
7
|
+
# A merge that starts outside a `{% for %}` and ends inside it cannot be
|
|
8
|
+
# cloned per iteration, so the renderer refuses such templates. The rule
|
|
9
|
+
# lives here because two callers need it and they must agree: the renderer
|
|
10
|
+
# (which only sees blocks it actually executes) and the validator (which
|
|
11
|
+
# sees every block in the AST, executed or not).
|
|
12
|
+
module BlockBoundary
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def crosses?(merge_start, merge_end, block_start, block_end)
|
|
16
|
+
# Merge entirely above or below the block
|
|
17
|
+
return false if merge_end < block_start || merge_start > block_end
|
|
18
|
+
|
|
19
|
+
# Merge strictly inside the block body (between the tag rows)
|
|
20
|
+
return false if merge_start > block_start && merge_end < block_end
|
|
21
|
+
|
|
22
|
+
# Merge entirely on a single structural tag row (it is consumed with it)
|
|
23
|
+
return false if merge_start == merge_end && (merge_start == block_start || merge_start == block_end)
|
|
24
|
+
|
|
25
|
+
# Any other overlap = crossing (including merges that swallow a tag row)
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# A single problem found in a template.
|
|
6
|
+
#
|
|
7
|
+
# `code` is a stable machine-readable symbol: host applications map it to
|
|
8
|
+
# their own localized text, so the English `message` here is a fallback for
|
|
9
|
+
# logs and specs, never the only thing a user can be shown.
|
|
10
|
+
Issue = Struct.new(:code, :severity, :sheet, :row, :cell, :message, :source,
|
|
11
|
+
keyword_init: true) do
|
|
12
|
+
def error?
|
|
13
|
+
severity == :error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def warning?
|
|
17
|
+
severity == :warning
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Human-readable location: "Sheet1!B12", "Sheet1 row 12" or "Sheet1".
|
|
21
|
+
def location
|
|
22
|
+
return nil unless sheet
|
|
23
|
+
|
|
24
|
+
return "#{sheet}!#{cell}" if cell
|
|
25
|
+
return "#{sheet} row #{row}" if row
|
|
26
|
+
|
|
27
|
+
sheet
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{ code: code, severity: severity, sheet: sheet, row: row, cell: cell,
|
|
32
|
+
message: message, source: source }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# Walks a parsed Liquid template and collects what it refers to: variable
|
|
6
|
+
# paths, filter names, and the names it binds itself.
|
|
7
|
+
#
|
|
8
|
+
# The point of doing this here rather than in the host application is that
|
|
9
|
+
# the host would have to parse every fragment a second time — and parse it
|
|
10
|
+
# with this gem's Liquid environment, or `{% sheet %}` and `{% image_tag %}`
|
|
11
|
+
# would come back as unknown tags. Handing back extracted usages keeps
|
|
12
|
+
# Liquid entirely on this side of the boundary.
|
|
13
|
+
class LiquidUsage
|
|
14
|
+
# Tags whose bodies Liquid exposes through an attachment rather than a
|
|
15
|
+
# nodelist ({% case %} branches).
|
|
16
|
+
def self.call(root)
|
|
17
|
+
collector = new
|
|
18
|
+
collector.walk(root)
|
|
19
|
+
collector
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :variables, :filters, :assigns, :locals
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@variables = []
|
|
26
|
+
@filters = []
|
|
27
|
+
@assigns = []
|
|
28
|
+
@locals = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def walk(node)
|
|
32
|
+
visit(node)
|
|
33
|
+
each_child(node) { |child| walk(child) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def visit(node)
|
|
39
|
+
case node
|
|
40
|
+
when ::Liquid::Variable then visit_variable(node)
|
|
41
|
+
when ::Liquid::Assign then visit_assign(node)
|
|
42
|
+
when ::Liquid::For then visit_for(node)
|
|
43
|
+
when ::Liquid::TableRow then visit_for(node)
|
|
44
|
+
when ::Liquid::If then visit_conditions(node.blocks)
|
|
45
|
+
when ::Liquid::Case then visit_case(node)
|
|
46
|
+
when ::Liquid::Capture then visit_capture(node)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def visit_variable(node)
|
|
51
|
+
collect_expression(node.name)
|
|
52
|
+
node.filters.each do |(name, *args)|
|
|
53
|
+
@filters << name.to_s
|
|
54
|
+
args.flatten.each { |arg| collect_expression(arg) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def visit_assign(node)
|
|
59
|
+
@assigns << node.to.to_s
|
|
60
|
+
visit_variable(node.from) if node.from.is_a?(::Liquid::Variable)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# {% for item in list %} binds `item` (and `forloop`) for its body; the
|
|
64
|
+
# collection itself is an ordinary reference. `limit:`/`offset:` may be
|
|
65
|
+
# variables too.
|
|
66
|
+
def visit_for(node)
|
|
67
|
+
@locals << node.variable_name.to_s
|
|
68
|
+
@locals << "forloop"
|
|
69
|
+
collect_expression(node.collection_name)
|
|
70
|
+
collect_expression(node.limit)
|
|
71
|
+
collect_expression(node.from)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def visit_case(node)
|
|
75
|
+
collect_expression(node.left)
|
|
76
|
+
visit_conditions(node.blocks)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Liquid does not expose the capture target, but a name captured and then
|
|
80
|
+
# used must not be reported as missing — read the ivar rather than let the
|
|
81
|
+
# host produce a false "unknown variable".
|
|
82
|
+
def visit_capture(node)
|
|
83
|
+
target = node.instance_variable_get(:@to)
|
|
84
|
+
@assigns << target.to_s if target
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def visit_conditions(blocks)
|
|
88
|
+
Array(blocks).each do |condition|
|
|
89
|
+
next unless condition.respond_to?(:left)
|
|
90
|
+
|
|
91
|
+
collect_expression(condition.left)
|
|
92
|
+
collect_expression(condition.right)
|
|
93
|
+
visit_conditions([condition.child_condition]) if condition.child_condition
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Liquid's own children: BlockBody, nested tags, and the bodies {% case %}
|
|
98
|
+
# hangs off its conditions as attachments.
|
|
99
|
+
def each_child(node)
|
|
100
|
+
if node.respond_to?(:nodelist) && node.nodelist
|
|
101
|
+
node.nodelist.each { |child| yield child unless child.is_a?(::String) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return unless node.is_a?(::Liquid::Case)
|
|
105
|
+
|
|
106
|
+
node.blocks.each do |condition|
|
|
107
|
+
attachment = condition.attachment
|
|
108
|
+
yield attachment if attachment
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A variable reference is a VariableLookup; literals and numbers are not
|
|
113
|
+
# references at all. Lookups that are themselves expressions (`items[i]`)
|
|
114
|
+
# end the path: what follows depends on a runtime value, so the caller is
|
|
115
|
+
# told the path is truncated instead of being handed a name to check.
|
|
116
|
+
def collect_expression(expression)
|
|
117
|
+
case expression
|
|
118
|
+
when ::Liquid::VariableLookup then collect_lookup(expression)
|
|
119
|
+
when ::Liquid::Variable then visit_variable(expression)
|
|
120
|
+
when ::Liquid::RangeLookup
|
|
121
|
+
collect_expression(expression.instance_variable_get(:@start_obj))
|
|
122
|
+
collect_expression(expression.instance_variable_get(:@end_obj))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def collect_lookup(lookup)
|
|
127
|
+
root = lookup.name
|
|
128
|
+
# A lookup whose own name is an expression (`{{ [dynamic] }}`) has no
|
|
129
|
+
# static root to report.
|
|
130
|
+
return collect_expression(root) unless root.is_a?(::String)
|
|
131
|
+
|
|
132
|
+
segments = []
|
|
133
|
+
truncated = false
|
|
134
|
+
|
|
135
|
+
Array(lookup.lookups).each do |segment|
|
|
136
|
+
unless segment.is_a?(::String)
|
|
137
|
+
collect_expression(segment)
|
|
138
|
+
truncated = true
|
|
139
|
+
break
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
segments << segment
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@variables << VariableUse.new(root: root, segments: segments, truncated: truncated)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiquidXlsx
|
|
4
|
+
module Validation
|
|
5
|
+
# Outcome of validating a template: what is wrong with it, and what it uses.
|
|
6
|
+
#
|
|
7
|
+
# Validation never raises for a broken template — a broken template is the
|
|
8
|
+
# expected input here, and a caller wants the whole list, not the first
|
|
9
|
+
# failure. Only programming errors (a missing file handed to the validator,
|
|
10
|
+
# for instance) still raise.
|
|
11
|
+
class Result
|
|
12
|
+
attr_reader :issues, :usages, :sheets
|
|
13
|
+
|
|
14
|
+
def initialize(issues: [], usages: [], sheets: [])
|
|
15
|
+
@issues = issues
|
|
16
|
+
@usages = usages
|
|
17
|
+
@sheets = sheets
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def valid?
|
|
21
|
+
errors.empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def errors
|
|
25
|
+
issues.select(&:error?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def warnings
|
|
29
|
+
issues.select(&:warning?)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add(code:, severity: :error, sheet: nil, row: nil, cell: nil, message: nil, source: nil)
|
|
33
|
+
issues << Issue.new(code: code, severity: severity, sheet: sheet, row: row,
|
|
34
|
+
cell: cell, message: message, source: source)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_usage(usage)
|
|
39
|
+
usages << usage
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Every variable reference in the workbook, deduplicated by path.
|
|
44
|
+
# Root names alone are rarely enough for a caller: `invoice.customer.name`
|
|
45
|
+
# and `invoice.customer.inn` are different questions to ask of a model.
|
|
46
|
+
def variables
|
|
47
|
+
usages.flat_map(&:variables).uniq(&:path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Root variable names the template expects to find in the data.
|
|
51
|
+
# Names bound by the template itself ({% for %}, {% assign %}) are not
|
|
52
|
+
# roots — they are excluded, otherwise every loop variable would look
|
|
53
|
+
# like a missing input.
|
|
54
|
+
def roots
|
|
55
|
+
bound = bound_names
|
|
56
|
+
|
|
57
|
+
variables.map(&:root).uniq.reject { |name| bound.include?(name) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def filters
|
|
61
|
+
usages.flat_map(&:filters).uniq
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Names the template binds itself, in any sheet: loop variables, assigns,
|
|
65
|
+
# captures. Scope is deliberately ignored — a name bound anywhere counts
|
|
66
|
+
# as known everywhere. Tracking real scopes would let the validator report
|
|
67
|
+
# "unknown variable" for a name that is bound two rows above, and a false
|
|
68
|
+
# error costs more than a missed one here.
|
|
69
|
+
def bound_names
|
|
70
|
+
@bound_names ||= usages.flat_map { |usage| usage.assigns + usage.locals }.uniq
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_h
|
|
74
|
+
{ valid: valid?, issues: issues.map(&:to_h), sheets: sheets,
|
|
75
|
+
roots: roots, filters: filters,
|
|
76
|
+
variables: variables.map(&:path) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|