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.
@@ -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 = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", "\r" => "&#13;" }.freeze
12
+ ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge('"' => "&quot;", "\n" => "&#10;", "\t" => "&#9;").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
@@ -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