liquid_xlsx 0.2.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 405eceeb12cbc1b76e2bb0fa1ef24f351f544551268799cffd3f1265fa3bda7f
4
- data.tar.gz: 13f0be15c97eaf8f21c8e4b4b1c4a2665032f8a8b7fc0ae885a1c1a2fb2cb126
3
+ metadata.gz: 77859fcf54ec602752b61cb982570912a6da4acfe35da51bf159a6afa641e552
4
+ data.tar.gz: adf22d59468df31118460448d0e6f4c84b3f2a16facc4cb5ff849ef652202ecc
5
5
  SHA512:
6
- metadata.gz: 1677f21ae1c65f95cf62fe1728cdeadef70e96a051ed33a1ed7acf57eb2b9067e00f3f4d1901caf20d9d23f37edbec601cef7115704404e75eaaca160d1d6405
7
- data.tar.gz: 9eda27bf7211ffc329af17e9ddf9992dcda7e551ff9526d62afb8df870e56d88381cd369acf120704ede782e10ded1650c410e97d71ebbf5159bff46b311e630
6
+ metadata.gz: 7a6de15d7778d554d86582f64e641d9dd328ef743adc4dba868e06c936b39eaed6093fca1ca533ebb5745856304710013a11d61af41b80df3b40ed5a56e08109
7
+ data.tar.gz: 76e95b63732e91ef4202efc71d80b1f791d84108d1857c9eac9d74ea3f0695d65ef7591ff7c4dcf28cf92d89c3b96caca81089de997a4fcf4ada199346532f3d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-08-14)
4
+
5
+ ### Added
6
+
7
+ - `LiquidXlsx.validate(template:)` — check a template without rendering it and
8
+ without any data. Returns a `Validation::Result` listing every problem found
9
+ instead of raising on the first one.
10
+
11
+ This is deliberately not "render it and see what happens". The renderer parses
12
+ only the Liquid it actually executes, so a mistake inside an `{% if %}` branch
13
+ the current document does not take, or inside a loop over an empty collection,
14
+ stays invisible until some other document hits it. Rendering also stops at the
15
+ first failure and runs the template against live records — a heavy price for
16
+ the question "is the file I just uploaded any good?".
17
+
18
+ Issue codes: `:invalid_xlsx`, `:missing_worksheet`, `:template_syntax`,
19
+ `:liquid_syntax`, `:liquid_expression` (warning), `:unknown_filter`,
20
+ `:sheet_tag_disabled`, `:sheet_template_missing`,
21
+ `:merged_cell_crosses_block`, `:liquid_in_formula` (warning),
22
+ `:liquid_in_rich_text` (warning). Each carries the sheet, row and cell it came
23
+ from, plus a stable `code` a host application can map to its own wording.
24
+
25
+ Beyond issues, the result reports what the template *uses*: `roots` (the input
26
+ variables it expects, with loop variables and `{% assign %}` names excluded),
27
+ full `variables` paths, and `filters` — so a host application can check them
28
+ against its own data model without parsing Liquid itself.
29
+
30
+ - Two silent losses are now reported, both of which used to produce a document
31
+ with tags left in it and no error anywhere:
32
+ - Liquid inside a formula is never evaluated (`:liquid_in_formula`).
33
+ - Liquid inside a rich-text inline string — text split into runs because part
34
+ of the cell is formatted differently — is not read (`:liquid_in_rich_text`).
35
+
36
+ - Expressions Liquid silently misreads are reported as warnings
37
+ (`:liquid_expression`): `{{ invoice.total b c }}` and `{{ invoice..total }}`
38
+ print the total and quietly drop the rest. Liquid's default lax parser accepts
39
+ them, so nothing fails — which is exactly why they are worth pointing out.
40
+ Only fragments the renderer would actually reject are errors.
41
+
42
+ - `Tags::SheetTag#template_name` / `#as_name` are now public, so the sheet a
43
+ `{% sheet %}` clones can be checked without rendering the tag.
44
+
3
45
  ## 0.2.2 (2026-08-14)
4
46
 
5
47
  ### Fixed
data/README.md CHANGED
@@ -45,6 +45,8 @@ Object API: `LiquidXlsx::Template.new(path).render_to_file(data, out)`, or
45
45
  - `{% sheet %}` — generate whole worksheets from data
46
46
  - `{% image_tag %}` — embed PNG / JPEG / GIF
47
47
  - Errors report the sheet, row and cell they came from
48
+ - `LiquidXlsx.validate` — check a template without data: every problem at once,
49
+ including the branches a trial render would never reach
48
50
 
49
51
  **Requirements:** Ruby >= 3.1. Works with both rubyzip 2.x and 3.x.
50
52
 
@@ -727,6 +729,72 @@ LiquidXlsx.render(template: "invoice.xlsx", output: "out.xlsx",
727
729
  на `Liquid::Environment` ничего не регистрируется — глобальная конфигурация
728
730
  Liquid в приложении остаётся нетронутой.
729
731
 
732
+ ## Проверка шаблона без рендера
733
+
734
+ `LiquidXlsx.validate` разбирает шаблон и возвращает список всех найденных
735
+ проблем. Данные не нужны, ничего не исполняется, на первой ошибке разбор не
736
+ останавливается.
737
+
738
+ ```ruby
739
+ result = LiquidXlsx.validate(
740
+ template: "invoice.xlsx",
741
+ known_filters: MyApp::Filters.public_instance_methods(false), # необязательно
742
+ dynamic_sheets: false # как будете рендерить
743
+ )
744
+
745
+ result.valid? # => false
746
+ result.errors # ошибки: документ не напечатается
747
+ result.warnings # предупреждения: напечатается, но не так, как задумано
748
+
749
+ result.errors.first.code # => :liquid_syntax
750
+ result.errors.first.location # => "Invoice!B12"
751
+ ```
752
+
753
+ Почему это не «отрендерить и посмотреть»: рендер разбирает только тот Liquid,
754
+ который реально исполняет. Опечатка в ветке `{% if %}`, которую этот документ не
755
+ выбрал, или в теле цикла по пустой коллекции при пробном рендере не всплывёт — и
756
+ дождётся другого документа. Плюс рендер выполняет шаблон на живых записях, что
757
+ для проверки только что загруженного файла — лишнее.
758
+
759
+ ### Что находит
760
+
761
+ | Код | Уровень | Что это |
762
+ |---|---|---|
763
+ | `:invalid_xlsx` | ошибка | файл не читается как книга `.xlsx` |
764
+ | `:missing_worksheet` | ошибка | лист объявлен в `workbook.xml`, а его части в пакете нет |
765
+ | `:template_syntax` | ошибка | непарные структурные теги, тег не на отдельной строке |
766
+ | `:liquid_syntax` | ошибка | Liquid не разберёт фрагмент — рендер упадёт |
767
+ | `:liquid_expression` | предупреждение | Liquid прочтёт выражение иначе, чем написано |
768
+ | `:unknown_filter` | ошибка | фильтра не будет при рендере |
769
+ | `:sheet_tag_disabled` | ошибка | `{% sheet %}` при `dynamic_sheets: false` |
770
+ | `:sheet_template_missing` | ошибка | `{% sheet template: "X" %}`, а листа `X` нет |
771
+ | `:merged_cell_crosses_block` | ошибка | объединение пересекает границу блока |
772
+ | `:liquid_in_formula` | предупреждение | Liquid внутри формулы не вычисляется |
773
+ | `:liquid_in_rich_text` | предупреждение | Liquid в ячейке с разным форматированием не читается |
774
+
775
+ Разница между `:liquid_syntax` и `:liquid_expression` существенна.
776
+ `{{ invoice.total b c }}` и `{{ invoice..total }}` Liquid принимает: печатает
777
+ сумму, а лишнее молча выбрасывает. Документ выйдет, поэтому это предупреждение,
778
+ а не ошибка — но в шаблоне написано не то, что имелось в виду.
779
+
780
+ ### Что шаблон использует
781
+
782
+ ```ruby
783
+ result.roots # => ["invoice", "customer"] — что шаблон ждёт на входе
784
+ result.variables # => [#<VariableUse root: "invoice", segments: ["items", "title"]>, ...]
785
+ result.filters # => ["money2", "date_words"]
786
+ result.sheets # => ["Invoice", "Приложение"]
787
+ ```
788
+
789
+ `roots` — именно входные переменные: имена, которые шаблон заводит сам
790
+ (`{% for item in ... %}`, `{% assign %}`, `{% capture %}`), из списка исключены.
791
+ Так приложение может сверить шаблон со своей моделью данных, не разбирая Liquid
792
+ самостоятельно.
793
+
794
+ У пути есть признак `truncated?`: в `{{ items[i].title }}` за динамическим
795
+ индексом проверять нечего, и об этом сказано явно — чтобы вызывающий код не
796
+ объявил «нет такого поля» там, где поле определяется в рантайме.
797
+
730
798
  ## Обработка ошибок
731
799
 
732
800
  Все ошибки наследуются от `LiquidXlsx::Error < StandardError`. Там, где
@@ -741,18 +741,10 @@ module LiquidXlsx
741
741
 
742
742
  private
743
743
 
744
+ # Shared with Validator: the renderer only sees blocks it executes, the
745
+ # validator sees all of them, and the two must judge a crossing the same way.
744
746
  def crosses_boundary?(merge_start, merge_end, block_start, block_end)
745
- # Merge entirely above or below the block
746
- return false if merge_end < block_start || merge_start > block_end
747
-
748
- # Merge strictly inside the block body (between the tag rows)
749
- return false if merge_start > block_start && merge_end < block_end
750
-
751
- # Merge entirely on a single structural tag row (it is consumed with it)
752
- return false if merge_start == merge_end && (merge_start == block_start || merge_start == block_end)
753
-
754
- # Any other overlap = crossing (including merges that swallow a tag row)
755
- true
747
+ Validation::BlockBoundary.crosses?(merge_start, merge_end, block_start, block_end)
756
748
  end
757
749
 
758
750
  def clone_merge(ref, start_offset, end_offset)
@@ -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
@@ -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
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ module Validation
5
+ # One variable reference found in a template fragment.
6
+ #
7
+ # `segments` is the lookup chain after the root: `invoice.items.first.name`
8
+ # becomes root "invoice" with segments ["items", "first", "name"].
9
+ #
10
+ # `truncated` marks a path the host application must not check past its last
11
+ # segment: a dynamic lookup (`items[idx].name`) hides what comes next, and
12
+ # reporting "no such field" for it would be a false alarm.
13
+ VariableUse = Struct.new(:root, :segments, :truncated, keyword_init: true) do
14
+ def path
15
+ ([root] + Array(segments)).join(".")
16
+ end
17
+
18
+ def truncated?
19
+ truncated ? true : false
20
+ end
21
+
22
+ def to_h
23
+ { root: root, segments: Array(segments), truncated: truncated?, path: path }
24
+ end
25
+ end
26
+
27
+ # Everything a single template fragment refers to.
28
+ #
29
+ # A fragment is one unit of Liquid the renderer will actually parse: a cell
30
+ # template, a structural `{% for %}` collection or an `{% if %}` condition.
31
+ # `kind` says which, so a host application can phrase its diagnostics in the
32
+ # user's terms ("cell B12" vs "the {% if %} in row 12").
33
+ # `kind` is one of :cell, :condition, :loop.
34
+ Usage = Struct.new(:sheet, :row, :cell, :kind, :source, :variables, :filters,
35
+ :assigns, :locals, keyword_init: true) do
36
+ def variables
37
+ self[:variables] || []
38
+ end
39
+
40
+ def filters
41
+ self[:filters] || []
42
+ end
43
+
44
+ # Names bound by the fragment itself ({% assign %}, {% capture %}).
45
+ def assigns
46
+ self[:assigns] || []
47
+ end
48
+
49
+ # Names bound for the fragment's body ({% for item in ... %}, sheet `as:`).
50
+ def locals
51
+ self[:locals] || []
52
+ end
53
+
54
+ def to_h
55
+ { sheet: sheet, row: row, cell: cell, kind: kind, source: source,
56
+ variables: variables.map(&:to_h), filters: filters,
57
+ assigns: assigns, locals: locals }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiquidXlsx
4
+ # Checks a template without rendering it and without any data.
5
+ #
6
+ # Why this is not "render and see what happens": the renderer parses only the
7
+ # Liquid it actually executes (Renderer#cached_parse), so a typo inside an
8
+ # {% if %} branch that this document does not take, or inside a loop over an
9
+ # empty collection, stays invisible until the day some other document takes
10
+ # that branch. It also stops at the first failure, and rendering executes the
11
+ # template against live records — printing a document is a side effect nobody
12
+ # asked for when all they did was upload a file.
13
+ #
14
+ # So: parse everything, execute nothing, and report every problem found.
15
+ class Validator
16
+ LIQUID_MARKER = /\{\{|\{%/
17
+
18
+ # @param template_path [String] path to the .xlsx template
19
+ # @param dynamic_sheets [Boolean] whether the caller renders with
20
+ # dynamic_sheets: true — {% sheet %} is an error when it does not
21
+ # @param known_filters [Array<String>, nil] filter names available at render
22
+ # time; nil disables the unknown-filter check
23
+ def initialize(template_path, dynamic_sheets: false, known_filters: nil)
24
+ @template_path = template_path
25
+ @dynamic_sheets = dynamic_sheets
26
+ @known_filters = known_filters&.map(&:to_s)
27
+ @result = Validation::Result.new
28
+ end
29
+
30
+ # @return [Validation::Result]
31
+ def call
32
+ package = open_package
33
+ return @result if package.nil?
34
+
35
+ sheet_names = safe_sheet_names(package)
36
+ @result.sheets.concat(sheet_names)
37
+
38
+ package.sheets.each { |sheet_info| validate_sheet(package, sheet_info) }
39
+
40
+ check_sheet_tags(sheet_names)
41
+ check_filters
42
+
43
+ @result
44
+ rescue Nokogiri::XML::SyntaxError => e
45
+ invalid_xlsx("Corrupt XML inside the workbook: #{e.message}")
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :result
51
+
52
+ def open_package
53
+ package = Package.new(@template_path)
54
+ package.read
55
+ package
56
+ rescue InvalidXlsxError => e
57
+ invalid_xlsx(e.message)
58
+ nil
59
+ rescue Zip::Error, Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
60
+ invalid_xlsx("#{e.class}: #{e.message}")
61
+ nil
62
+ end
63
+
64
+ def invalid_xlsx(message)
65
+ @result.add(code: :invalid_xlsx, message: "Not a readable .xlsx workbook. #{message}")
66
+ @result
67
+ end
68
+
69
+ # Sheet names are needed for {% sheet template: "X" %}; a workbook broken
70
+ # enough to fail here still gets its parts validated below.
71
+ def safe_sheet_names(package)
72
+ package.sheet_names
73
+ rescue StandardError
74
+ []
75
+ end
76
+
77
+ def validate_sheet(package, sheet_info)
78
+ name = sheet_info[:name]
79
+ xml = worksheet_xml(package, sheet_info)
80
+ return if xml.nil?
81
+
82
+ worksheet = Worksheet.new(xml, name, SharedStrings.new(package.shared_strings_xml))
83
+ rows = worksheet.parse_rows
84
+
85
+ check_formulas(name, rows)
86
+ check_rich_text(name, xml)
87
+
88
+ ast = parse_structure(name, rows)
89
+ # Structure did not parse: the cells are still worth checking, and a flat
90
+ # pass over them is exactly what the caller wants to see next to the
91
+ # structural error.
92
+ return validate_cells_flat(name, rows) if ast.nil?
93
+
94
+ validate_nodes(name, ast)
95
+ check_merges(name, worksheet, ast)
96
+ rescue Nokogiri::XML::SyntaxError => e
97
+ @result.add(code: :invalid_xlsx, sheet: name,
98
+ message: "Corrupt XML in sheet '#{name}': #{e.message}")
99
+ end
100
+
101
+ # A workbook with no readable relationships part makes the lookup itself
102
+ # fail; for the caller that is the same problem as a missing sheet file, and
103
+ # the validator must not propagate it as a crash.
104
+ def worksheet_xml(package, sheet_info)
105
+ xml = begin
106
+ package.worksheet_xml(sheet_info[:r_id])
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ return xml if xml
111
+
112
+ @result.add(code: :missing_worksheet, sheet: sheet_info[:name],
113
+ message: "The workbook lists sheet '#{sheet_info[:name]}' " \
114
+ "(#{sheet_info[:r_id]}) but the file for it is missing from the package.")
115
+ nil
116
+ end
117
+
118
+ # @return [Array<Nodes::Base>, nil] nil when the structure is broken
119
+ def parse_structure(name, rows)
120
+ TemplateParser.new(rows, name).parse
121
+ rescue TemplateSyntaxError => e
122
+ @result.add(code: :template_syntax, sheet: e.sheet || name, row: e.row, cell: e.cell,
123
+ message: e.message.lines.first.to_s.strip, source: e.tag)
124
+ nil
125
+ end
126
+
127
+ # --- fragments ---------------------------------------------------------
128
+
129
+ def validate_nodes(sheet, nodes)
130
+ Array(nodes).each do |node|
131
+ case node
132
+ when Nodes::RowNode then validate_row(sheet, node.row_data)
133
+ when Nodes::ForNode then validate_for(sheet, node)
134
+ when Nodes::IfNode then validate_if(sheet, node)
135
+ end
136
+ end
137
+ end
138
+
139
+ def validate_row(sheet, row)
140
+ row[:cells].each do |cell|
141
+ next unless cell[:template]
142
+
143
+ parse_fragment(sheet: sheet, row: row[:row_number], cell: "#{cell[:col]}#{row[:row_number]}",
144
+ kind: :cell, source: cell[:template])
145
+ end
146
+ end
147
+
148
+ # The renderer looks the collection up by name (Renderer#resolve_for_collection),
149
+ # so validating `{{ name }}` sees exactly what it will see.
150
+ def validate_for(sheet, node)
151
+ usage = parse_fragment(sheet: sheet, row: node.for_row, kind: :loop,
152
+ source: "{% for #{node.variable_name} in #{node.collection_name} %}",
153
+ liquid: "{{ #{node.collection_name} }}")
154
+ if usage
155
+ usage.locals.push(node.variable_name.to_s, "forloop")
156
+ usage.locals.uniq!
157
+ end
158
+
159
+ validate_nodes(sheet, node.body)
160
+ validate_nodes(sheet, node.else_body)
161
+ end
162
+
163
+ # Conditions are wrapped exactly as Renderer#evaluate_condition wraps them:
164
+ # validating a different string than the one that gets executed would check
165
+ # the wrong thing.
166
+ def validate_if(sheet, node)
167
+ node.branches.each do |branch|
168
+ condition = branch[:condition]
169
+ if condition
170
+ parse_fragment(sheet: sheet, row: node.if_row, kind: :condition,
171
+ source: "{% if #{condition} %}",
172
+ liquid: "{% if #{condition} %}true{% endif %}")
173
+ end
174
+
175
+ validate_nodes(sheet, branch[:body])
176
+ end
177
+ end
178
+
179
+ # Cells of a sheet whose structure did not parse.
180
+ def validate_cells_flat(sheet, rows)
181
+ rows.each { |row| validate_row(sheet, row) }
182
+ end
183
+
184
+ # Fragments are parsed twice, in strict mode and in Liquid's default lax
185
+ # mode, because the two modes answer different questions.
186
+ #
187
+ # Lax mode is what the renderer uses, so a fragment it rejects will not
188
+ # print — that is an error. What lax mode accepts, it may still not read the
189
+ # way the author meant: `{{ invoice.total b c }}` and `{{ invoice..total }}`
190
+ # both render the total and quietly drop the rest. Strict mode is the only
191
+ # thing that notices, and the right word for it is a warning, not an error:
192
+ # the document does print, and calling it broken would be false.
193
+ #
194
+ # @return [Validation::Usage, nil] nil when the fragment does not parse
195
+ def parse_fragment(sheet:, kind:, source:, row: nil, cell: nil, liquid: nil)
196
+ text = liquid || source
197
+ template = parse_liquid(text, :strict)
198
+ usage = build_usage(template, sheet: sheet, row: row, cell: cell, kind: kind, source: source)
199
+ @result.add_usage(usage)
200
+ usage
201
+ rescue ::Liquid::SyntaxError => e
202
+ report_syntax_error(e, text: text, sheet: sheet, row: row, cell: cell, source: source)
203
+ nil
204
+ rescue TemplateSyntaxError, LiquidXlsx::Error => e
205
+ # Tag constructors of this gem validate their own arguments at parse time
206
+ # ({% sheet %} without `template:`, for instance).
207
+ @result.add(code: :liquid_syntax, sheet: sheet, row: row, cell: cell,
208
+ message: e.message.lines.first.to_s.strip, source: source)
209
+ nil
210
+ end
211
+
212
+ def parse_liquid(text, error_mode)
213
+ ::Liquid::Template.parse(text, environment: LiquidXlsx.liquid_environment, error_mode: error_mode)
214
+ end
215
+
216
+ # Two different problems wear the same exception, and a user needs to tell
217
+ # them apart: a fragment the renderer will refuse outright (error), and a
218
+ # fragment it will accept while silently ignoring part of what was written
219
+ # (warning).
220
+ def report_syntax_error(error, text:, sheet:, row:, cell:, source:)
221
+ if parses_leniently?(text)
222
+ @result.add(code: :liquid_expression, severity: :warning, sheet: sheet, row: row, cell: cell,
223
+ message: "Liquid does not read this expression the way it is written and will " \
224
+ "ignore part of it: #{error.message}",
225
+ source: source)
226
+ else
227
+ @result.add(code: :liquid_syntax, sheet: sheet, row: row, cell: cell,
228
+ message: error.message, source: source)
229
+ end
230
+ end
231
+
232
+ def parses_leniently?(text)
233
+ parse_liquid(text, :lax)
234
+ true
235
+ rescue ::Liquid::SyntaxError, LiquidXlsx::Error
236
+ false
237
+ end
238
+
239
+ def build_usage(template, sheet:, row:, cell:, kind:, source:)
240
+ collected = Validation::LiquidUsage.call(template.root)
241
+ @sheet_tags = (@sheet_tags || []).concat(collect_sheet_tags(template.root, sheet, row, cell))
242
+
243
+ Validation::Usage.new(
244
+ sheet: sheet, row: row, cell: cell, kind: kind, source: source,
245
+ variables: collected.variables.uniq(&:path),
246
+ filters: collected.filters.uniq,
247
+ assigns: collected.assigns.uniq,
248
+ locals: collected.locals.uniq
249
+ )
250
+ end
251
+
252
+ # --- whole-workbook checks --------------------------------------------
253
+
254
+ def collect_sheet_tags(node, sheet, row, cell)
255
+ found = []
256
+ walk_liquid(node) do |child|
257
+ next unless child.is_a?(Tags::SheetTag)
258
+
259
+ found << { tag: child, sheet: sheet, row: row, cell: cell }
260
+ end
261
+ found
262
+ end
263
+
264
+ def walk_liquid(node, &block)
265
+ yield node
266
+ return unless node.respond_to?(:nodelist) && node.nodelist
267
+
268
+ node.nodelist.each do |child|
269
+ next if child.is_a?(::String)
270
+
271
+ walk_liquid(child, &block)
272
+ end
273
+ end
274
+
275
+ # {% sheet %} needs two things the fragment itself cannot know: the caller
276
+ # must render with dynamic_sheets, and the sheet named in `template:` must
277
+ # exist. Both fail at render time today, long after the template was saved.
278
+ def check_sheet_tags(sheet_names)
279
+ Array(@sheet_tags).each do |entry|
280
+ tag = entry[:tag]
281
+
282
+ unless @dynamic_sheets
283
+ @result.add(code: :sheet_tag_disabled, sheet: entry[:sheet], row: entry[:row], cell: entry[:cell],
284
+ message: "{% sheet %} requires rendering with dynamic_sheets: true.")
285
+ end
286
+
287
+ template_name = tag.template_name
288
+ next if template_name.nil? || sheet_names.empty? || sheet_names.include?(template_name)
289
+
290
+ @result.add(code: :sheet_template_missing, sheet: entry[:sheet], row: entry[:row], cell: entry[:cell],
291
+ message: "{% sheet %} refers to template sheet '#{template_name}', " \
292
+ "which does not exist in this workbook.")
293
+ end
294
+ end
295
+
296
+ def check_filters
297
+ return if @known_filters.nil?
298
+
299
+ @result.usages.each do |usage|
300
+ usage.filters.each do |filter|
301
+ next if @known_filters.include?(filter)
302
+
303
+ @result.add(code: :unknown_filter, sheet: usage.sheet, row: usage.row, cell: usage.cell,
304
+ message: "Unknown filter '#{filter}'.", source: usage.source)
305
+ end
306
+ end
307
+ end
308
+
309
+ # Liquid inside a formula is never evaluated: Worksheet#extract_cell_text
310
+ # keeps formula cells as formulas, so the tag travels into the output file
311
+ # untouched. Silent, and hard to spot in a large sheet.
312
+ def check_formulas(sheet, rows)
313
+ rows.each do |row|
314
+ row[:cells].each do |cell|
315
+ formula = cell[:formula]
316
+ next unless formula&.match?(LIQUID_MARKER)
317
+
318
+ @result.add(code: :liquid_in_formula, severity: :warning, sheet: sheet,
319
+ row: row[:row_number], cell: "#{cell[:col]}#{row[:row_number]}",
320
+ message: "Liquid inside a formula is not evaluated.",
321
+ source: "=#{formula}")
322
+ end
323
+ end
324
+ end
325
+
326
+ # Rich-text inline strings: text split into runs (<is><r><t>) is not read by
327
+ # the renderer, so Liquid written there disappears from the output without
328
+ # any error. Editors produce runs as soon as part of a cell is formatted
329
+ # differently — a bold word inside the cell is enough.
330
+ def check_rich_text(sheet, xml)
331
+ doc = Nokogiri::XML(xml)
332
+ ns = doc.root&.namespace
333
+ return unless ns
334
+
335
+ doc.xpath("//xmlns:c[@t='inlineStr']", "xmlns" => ns.href).each do |cell|
336
+ is_element = cell.at_xpath("xmlns:is")
337
+ next if is_element.nil? || is_element.at_xpath("xmlns:t")
338
+
339
+ text = is_element.xpath(".//xmlns:t").map(&:text).join
340
+ next unless text.match?(LIQUID_MARKER)
341
+
342
+ @result.add(code: :liquid_in_rich_text, severity: :warning, sheet: sheet,
343
+ cell: cell["r"],
344
+ message: "Liquid in a rich-text cell is not read. Retype the cell " \
345
+ "so all of its text has the same formatting.",
346
+ source: text)
347
+ end
348
+ end
349
+
350
+ def check_merges(sheet, worksheet, ast)
351
+ merges = worksheet.merge_cells
352
+ return if merges.empty?
353
+
354
+ blocks = collect_blocks(ast)
355
+ return if blocks.empty?
356
+
357
+ merges.each do |merge|
358
+ blocks.each do |block|
359
+ next unless Validation::BlockBoundary.crosses?(merge[:start_row], merge[:end_row],
360
+ block[:start], block[:end])
361
+
362
+ @result.add(code: :merged_cell_crosses_block, sheet: sheet, row: block[:start],
363
+ message: "Merged cell '#{merge[:ref]}' crosses the boundary of a " \
364
+ "#{block[:type]} block (rows #{block[:start]}-#{block[:end]}).",
365
+ source: merge[:ref])
366
+ end
367
+ end
368
+ end
369
+
370
+ def collect_blocks(nodes, blocks = [])
371
+ Array(nodes).each do |node|
372
+ case node
373
+ when Nodes::ForNode
374
+ blocks << { type: :for, start: node.for_row, end: node.endfor_row } if node.for_row && node.endfor_row
375
+ collect_blocks(node.body, blocks)
376
+ collect_blocks(node.else_body, blocks)
377
+ when Nodes::IfNode
378
+ blocks << { type: :if, start: node.if_row, end: node.endif_row } if node.if_row && node.endif_row
379
+ node.branches.each { |branch| collect_blocks(branch[:body], blocks) }
380
+ end
381
+ end
382
+
383
+ blocks
384
+ end
385
+ end
386
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LiquidXlsx
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/liquid_xlsx.rb CHANGED
@@ -20,6 +20,12 @@ require_relative "liquid_xlsx/worksheet"
20
20
  require_relative "liquid_xlsx/renderer"
21
21
  require_relative "liquid_xlsx/workbook"
22
22
  require_relative "liquid_xlsx/template"
23
+ require_relative "liquid_xlsx/validation/issue"
24
+ require_relative "liquid_xlsx/validation/usage"
25
+ require_relative "liquid_xlsx/validation/result"
26
+ require_relative "liquid_xlsx/validation/block_boundary"
27
+ require_relative "liquid_xlsx/validation/liquid_usage"
28
+ require_relative "liquid_xlsx/validator"
23
29
 
24
30
  # LiquidXlsx is a Ruby gem for generating .xlsx files from Excel templates
25
31
  # with Liquid syntax. Edit templates in Excel, write Liquid in cells,
@@ -89,5 +95,38 @@ module LiquidXlsx
89
95
  # rubocop:enable Metrics/ParameterLists
90
96
  tpl.render_to_file(data, output)
91
97
  end
98
+
99
+ # Check a template without rendering it and without any data.
100
+ #
101
+ # Unlike a trial render, this parses every cell and every branch — including
102
+ # the ones a particular document would not take — collects all problems
103
+ # instead of stopping at the first, and executes nothing, so validating a
104
+ # template has no side effects on the application's records.
105
+ #
106
+ # result = LiquidXlsx.validate(template: "invoice.xlsx",
107
+ # known_filters: MyFilters.public_instance_methods(false))
108
+ # result.valid? # => false
109
+ # result.issues # => [#<Issue code: :liquid_syntax, sheet: "Sheet1", cell: "B12", ...>]
110
+ # result.roots # => ["invoice", "customer"] — what the template expects as input
111
+ #
112
+ # @param template [String] path to the .xlsx template
113
+ # @param dynamic_sheets [Boolean] pass true if you render with dynamic_sheets:
114
+ # true; otherwise {% sheet %} in the template is reported as an error,
115
+ # because that is what rendering it would do
116
+ # @param known_filters [Array<String, Symbol>, nil] filter names available at
117
+ # render time (standard Liquid filters are included automatically); nil
118
+ # disables the unknown-filter check
119
+ # @return [Validation::Result]
120
+ def validate(template:, dynamic_sheets: false, known_filters: nil)
121
+ Validator.new(template,
122
+ dynamic_sheets: dynamic_sheets,
123
+ known_filters: known_filters && (known_filters.map(&:to_s) + standard_filters))
124
+ .call
125
+ end
126
+
127
+ # Filters every Liquid template can use without the host registering anything.
128
+ def standard_filters
129
+ @standard_filters ||= ::Liquid::StandardFilters.public_instance_methods(false).map(&:to_s)
130
+ end
92
131
  end
93
132
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: liquid_xlsx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Khlipitkin
@@ -112,6 +112,12 @@ files:
112
112
  - lib/liquid_xlsx/template.rb
113
113
  - lib/liquid_xlsx/template_nodes.rb
114
114
  - lib/liquid_xlsx/template_parser.rb
115
+ - lib/liquid_xlsx/validation/block_boundary.rb
116
+ - lib/liquid_xlsx/validation/issue.rb
117
+ - lib/liquid_xlsx/validation/liquid_usage.rb
118
+ - lib/liquid_xlsx/validation/result.rb
119
+ - lib/liquid_xlsx/validation/usage.rb
120
+ - lib/liquid_xlsx/validator.rb
115
121
  - lib/liquid_xlsx/version.rb
116
122
  - lib/liquid_xlsx/workbook.rb
117
123
  - lib/liquid_xlsx/worksheet.rb