rspock 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require 'ast_transform/transformation_helper'
2
3
  require 'rspock/ast/node'
3
4
 
4
5
  module RSpock
@@ -10,12 +11,13 @@ module RSpock
10
11
  # - Binary operators (==, !=, =~, etc.) become :rspock_binary_statement nodes.
11
12
  # - Everything else becomes :rspock_statement nodes with the original source text captured.
12
13
  class StatementParser
13
- include RSpock::AST::NodeBuilder
14
+ include ASTTransform::TransformationHelper
14
15
 
15
16
  BINARY_OPERATORS = %i[== != =~ !~ > < >= <=].freeze
16
17
  ASSIGNMENT_TYPES = %i[lvasgn masgn op_asgn or_asgn and_asgn].freeze
17
18
 
18
19
  def parse(node)
20
+ return build_raises(node) if raises_condition?(node)
19
21
  return node if assignment?(node)
20
22
  return build_binary_statement(node) if binary_statement?(node)
21
23
 
@@ -24,6 +26,32 @@ module RSpock
24
26
 
25
27
  private
26
28
 
29
+ def raises_condition?(node)
30
+ direct_raises?(node) || assigned_raises?(node)
31
+ end
32
+
33
+ def direct_raises?(node)
34
+ node.type == :send && node.children[0].nil? && node.children[1] == :raises
35
+ end
36
+
37
+ def assigned_raises?(node)
38
+ node.type == :lvasgn &&
39
+ node.children[1]&.type == :send &&
40
+ node.children[1].children[0].nil? &&
41
+ node.children[1].children[1] == :raises
42
+ end
43
+
44
+ def build_raises(node)
45
+ if node.type == :lvasgn
46
+ variable = s(:sym, node.children[0])
47
+ exception_class = node.children[1].children[2]
48
+ s_anchored(node, :rspock_raises, exception_class, variable)
49
+ else
50
+ exception_class = node.children[2]
51
+ s_anchored(node, :rspock_raises, exception_class)
52
+ end
53
+ end
54
+
27
55
  def assignment?(node)
28
56
  ASSIGNMENT_TYPES.include?(node.type)
29
57
  end
@@ -34,13 +62,20 @@ module RSpock
34
62
  BINARY_OPERATORS.include?(node.children[1])
35
63
  end
36
64
 
65
+ # RSpock IR nodes are anchored at the statement they classify, so the assertions they lower into are
66
+ # emitted at the statement's source line.
37
67
  def build_binary_statement(node)
38
- s(:rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2])
68
+ s_anchored(node, :rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2])
39
69
  end
40
70
 
41
71
  def build_statement(node)
42
72
  source = node.loc&.expression&.source || node.inspect
43
- s(:rspock_statement, node, s(:str, source))
73
+ s_anchored(node, :rspock_statement, node, s(:str, source))
74
+ end
75
+
76
+ # s_at that tolerates loc-less anchors (unit tests build synthetic ASTs).
77
+ def s_anchored(anchor, type, *children)
78
+ anchor.loc&.expression ? s_at(anchor, type, *children) : s(type, *children)
44
79
  end
45
80
  end
46
81
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require 'ast_transform/transformation_helper'
2
3
  require 'rspock/ast/node'
3
4
 
4
5
  module RSpock
@@ -12,7 +13,7 @@ module RSpock
12
13
  # s(:rspock_body, s(:rspock_given, ...), s(:rspock_when, ...), ...),
13
14
  # s(:rspock_where, ...)) # optional
14
15
  class TestMethodParser
15
- include RSpock::AST::NodeBuilder
16
+ include ASTTransform::TransformationHelper
16
17
 
17
18
  def initialize(block_registry, strict: true)
18
19
  @block_registry = block_registry
@@ -74,7 +75,8 @@ module RSpock
74
75
  end
75
76
 
76
77
  unless blocks.last.can_end?
77
- raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} must be followed by one of these Blocks: #{blocks.last.successors}"
78
+ raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} " \
79
+ "must be followed by one of these Blocks: #{blocks.last.successors}"
78
80
  end
79
81
  end
80
82
 
@@ -30,6 +30,11 @@ module RSpock
30
30
  statement_parser.parse(child)
31
31
  end
32
32
 
33
+ raises_count = spock_children.count { |c| c.type == :rspock_raises }
34
+ if raises_count > 1
35
+ raise BlockError, "Then block @ #{range} may contain at most one raises() condition"
36
+ end
37
+
33
38
  s(:rspock_then, *spock_children)
34
39
  end
35
40
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require 'ast_transform/transformation_helper'
2
3
  require 'rspock/ast/node'
3
4
 
4
5
  module RSpock
@@ -8,7 +9,7 @@ module RSpock
8
9
  # Binary statements dispatch to specialized assertions (assert_equal, assert_match, assert_operator).
9
10
  # General statements use assert_equal(true/false, expr, source_message) with negation detection.
10
11
  class StatementToAssertionTransformation
11
- include RSpock::AST::NodeBuilder
12
+ include ASTTransform::TransformationHelper
12
13
 
13
14
  BINARY_DISPATCH = {
14
15
  :== => :assert_equal,
@@ -4,15 +4,16 @@ require 'rspock/ast/test_method_dstr_transformation'
4
4
 
5
5
  module RSpock
6
6
  module AST
7
+ # Appends the data row's index and source line to a Where-driven test's name. The values arrive through
8
+ # internal block parameters on the row iterator (see TestMethodTransformation#build_where_args) and surface in
9
+ # the test NAME only: the name is what makes identical data rows unique and what the -n selector matches to
10
+ # isolate a row. They are deliberately not exposed as test-scope variables.
7
11
  class TestMethodDefTransformation < ASTTransform::AbstractTransformation
8
- TEST_INDEX_AST = s(:begin,
9
- s(:lvar, :_test_index_))
10
-
11
- LINE_NUMBER_AST = s(:begin,
12
- s(:lvar, :_line_number_))
13
-
14
- SPACE_STR_AST = s(:str, " ")
12
+ ROW_INDEX_ARG = :__rspock_row_index__
13
+ ROW_LINE_ARG = :__rspock_row_line__
15
14
 
15
+ ROW_INDEX_AST = s(:begin, s(:lvar, ROW_INDEX_ARG))
16
+ ROW_LINE_AST = s(:begin, s(:lvar, ROW_LINE_ARG))
16
17
  LINE_NUMBER_STR_AST = s(:str, " line ")
17
18
 
18
19
  def run(node)
@@ -23,7 +24,7 @@ module RSpock
23
24
 
24
25
  def on_str(node)
25
26
  merged = s(:str, "#{node.children[0]} ")
26
- node.updated(:dstr, [merged, TEST_INDEX_AST, LINE_NUMBER_STR_AST, LINE_NUMBER_AST])
27
+ node.updated(:dstr, [merged, ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST])
27
28
  end
28
29
 
29
30
  def on_dstr(node)
@@ -3,15 +3,13 @@ require 'ast_transform/abstract_transformation'
3
3
 
4
4
  module RSpock
5
5
  module AST
6
+ # dstr counterpart of TestMethodDefTransformation: appends the row index and source line interpolations to an
7
+ # already-interpolated test name.
6
8
  class TestMethodDstrTransformation < ASTTransform::AbstractTransformation
7
- TEST_INDEX_AST = s(:begin,
8
- s(:lvar, :_test_index_))
9
-
10
- LINE_NUMBER_AST = s(:begin,
11
- s(:lvar, :_line_number_))
9
+ ROW_INDEX_AST = s(:begin, s(:lvar, :__rspock_row_index__))
10
+ ROW_LINE_AST = s(:begin, s(:lvar, :__rspock_row_line__))
12
11
 
13
12
  SPACE_STR_AST = s(:str, " ")
14
-
15
13
  LINE_NUMBER_STR_AST = s(:str, " line ")
16
14
 
17
15
  def on_dstr(node)
@@ -24,7 +22,7 @@ module RSpock
24
22
  children << SPACE_STR_AST
25
23
  end
26
24
 
27
- children.push(TEST_INDEX_AST, LINE_NUMBER_STR_AST, LINE_NUMBER_AST)
25
+ children.push(ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST)
28
26
  node.updated(nil, children)
29
27
  end
30
28
  end
@@ -5,7 +5,6 @@ require 'rspock/ast/statement_to_assertion_transformation'
5
5
  require 'rspock/ast/header_nodes_transformation'
6
6
  require 'rspock/ast/interaction_to_mocha_mock_transformation'
7
7
  require 'rspock/ast/interaction_to_block_identity_assertion_transformation'
8
- require 'rspock/ast/method_call_to_lvar_transformation'
9
8
  require 'rspock/ast/test_method_def_transformation'
10
9
  require 'rspock/ast/parser/test_method_parser'
11
10
 
@@ -26,79 +25,187 @@ module RSpock
26
25
  private
27
26
 
28
27
  def transform(rspock_ast)
29
- hoisted_setups = []
30
-
31
28
  method_call = rspock_ast.def_node.method_call
32
29
  method_args = rspock_ast.def_node.args
33
30
  where = rspock_ast.where_node
34
31
 
35
- transformed_blocks = rspock_ast.body_node.children.map do |block_node|
36
- case block_node.type
37
- when :rspock_then
38
- transform_then_block(block_node, hoisted_setups)
39
- when :rspock_expect
40
- transform_expect_block(block_node)
41
- else
42
- block_node
43
- end
32
+ body = build_test_body(rspock_ast.body_node)
33
+ build_ruby_ast(method_call, method_args, body, where)
34
+ end
35
+
36
+ # --- Test body assembly ---
37
+ #
38
+ # Statements are assembled in SOURCE order so line-aligned emission keeps each one on its own line.
39
+ # Execution-order requirements that source order cannot express (interaction setups in Then must run before the
40
+ # When body they observe) are carried by ast-transform's thunk facility (run_after / thunk) instead of by
41
+ # textual hoisting.
42
+ def build_test_body(body_node)
43
+ blocks = body_node.children
44
+ sections = blocks.map { |block_node| transform_block(block_node) }
45
+
46
+ when_statements = statements_of_type(blocks, sections, :rspock_when)
47
+ cleanup_statements = statements_of_type(blocks, sections, :rspock_cleanup)
48
+ interaction_setups = sections.flat_map(&:interaction_setups)
49
+ raises_node = blocks.filter_map { |block_node| find_raises(block_node) }.first
50
+
51
+ source_order = blocks.zip(sections).flat_map do |block_node, section|
52
+ next [] if block_node.type == :rspock_cleanup
53
+
54
+ section.statements.reject { |statement| statement.type == :rspock_raises }
44
55
  end
45
56
 
46
- transformed_body = rspock_ast.body_node.updated(nil, transformed_blocks)
47
- build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups)
57
+ body_children = order_execution(source_order, when_statements, interaction_setups, raises_node)
58
+
59
+ ast = s(:begin, *body_children)
60
+ cleanup_statements.empty? ? ast : s(:kwbegin, s(:ensure, ast, s(:begin, *cleanup_statements)))
48
61
  end
49
62
 
50
- def transform_then_block(then_node, hoisted_setups)
51
- interaction_setups = []
52
- then_children = []
63
+ Section = Data.define(:statements, :interaction_setups)
64
+
65
+ def transform_block(block_node)
66
+ case block_node.type
67
+ when :rspock_then, :rspock_expect
68
+ transform_assertion_block(block_node)
69
+ else
70
+ Section.new(statements: block_node.children, interaction_setups: [])
71
+ end
72
+ end
53
73
 
54
- then_node.children.each_with_index do |child, idx|
55
- if child.type == :rspock_interaction
56
- setup = InteractionToMochaMockTransformation.new(idx).run(child)
57
- assertion = InteractionToBlockIdentityAssertionTransformation.new(idx).run(child)
74
+ # Then/Expect children become plain Ruby in place: interactions lower to Mocha setups anchored at the
75
+ # interaction's own source line (plus an identity assertion for &block forwarding), statements become
76
+ # assertions at their own lines.
77
+ #
78
+ # Within the section, ALL setups come before all assertions: the thunked When body executes right after the
79
+ # last setup, and every assertion (identity or otherwise) observes the When body's effects, so none may
80
+ # precede that point. Setups keep source order and their anchors, so alignment holds; assertions after them
81
+ # are either synthetic (identity assertions, loc-less, pack anywhere) or textually below the interactions in
82
+ # the common case.
83
+ def transform_assertion_block(block_node)
84
+ setups = []
85
+ assertions = []
86
+ interaction_index = 0
58
87
 
59
- interaction_setups << setup
60
- then_children << assertion unless assertion.equal?(child)
88
+ block_node.children.each do |child|
89
+ case child.type
90
+ when :rspock_interaction
91
+ interaction_setups, identity_assertions = lower_interaction(child, interaction_index)
92
+ interaction_index += 1
93
+ setups.concat(interaction_setups)
94
+ assertions.concat(identity_assertions)
95
+ when :rspock_binary_statement, :rspock_statement
96
+ assertions << anchored_at(child, @statement_transformation.run(child))
61
97
  else
62
- then_children << transform_statement_or_passthrough(child)
98
+ assertions << child
63
99
  end
64
100
  end
65
101
 
66
- unless interaction_setups.empty?
67
- interaction_setups.each do |node|
68
- if node.type == :begin
69
- hoisted_setups.concat(node.children)
70
- else
71
- hoisted_setups << node
72
- end
73
- end
74
- end
102
+ Section.new(statements: setups + assertions, interaction_setups: setups)
103
+ end
104
+
105
+ # @return [Array(Array, Array)] Mocha setup statements, identity assertions
106
+ def lower_interaction(interaction, index)
107
+ setup = InteractionToMochaMockTransformation.new(index).run(interaction)
108
+ assertion = InteractionToBlockIdentityAssertionTransformation.new(index).run(interaction)
109
+
110
+ setups = setup.type == :begin ? setup.children.dup : [setup]
111
+ setups[0] = anchored_at(interaction, setups[0])
112
+
113
+ [setups, assertion.equal?(interaction) ? [] : [assertion]]
114
+ end
75
115
 
76
- then_node.updated(nil, then_children)
116
+ # Reorders execution (not text) where required:
117
+ # - interactions without raises: run the When body after the last interaction setup (run_after — the paved
118
+ # road).
119
+ # - raises without interactions: the When body inlines directly into assert_raises; no thunk needed.
120
+ # - raises with interactions: the When body is thunked into the assert_raises block inserted after the last
121
+ # setup; the lowering re-emits the body at its own source lines.
122
+ def order_execution(source_order, when_statements, interaction_setups, raises_node)
123
+ if raises_node
124
+ build_raises_body(source_order, when_statements, interaction_setups, raises_node)
125
+ elsif interaction_setups.any? && when_statements.any?
126
+ run_after(source_order, run: when_statements, after: interaction_setups.last)
127
+ else
128
+ source_order
129
+ end
77
130
  end
78
131
 
79
- def transform_expect_block(expect_node)
80
- new_children = expect_node.children.map { |child| transform_statement_or_passthrough(child) }
81
- expect_node.updated(nil, new_children)
132
+ def build_raises_body(source_order, when_statements, interaction_setups, raises_node)
133
+ if interaction_setups.any?
134
+ assertion = build_assert_raises(raises_node, thunk(*when_statements))
135
+
136
+ reordered = replace_run(source_order, when_statements, [])
137
+ insert_after(reordered, interaction_setups.last, assertion)
138
+ else
139
+ when_body = when_statements.length == 1 ? when_statements[0] : s(:begin, *when_statements)
140
+ assertion = build_assert_raises(raises_node, when_body)
141
+
142
+ replace_run(source_order, when_statements, [assertion])
143
+ end
82
144
  end
83
145
 
84
- def transform_statement_or_passthrough(child)
85
- case child.type
86
- when :rspock_binary_statement, :rspock_statement
87
- @statement_transformation.run(child)
146
+ def build_assert_raises(raises_node, body)
147
+ assert_raises_call = s(:block,
148
+ s(:send, nil, :assert_raises, raises_node.exception_class),
149
+ s(:args),
150
+ body
151
+ )
152
+
153
+ if raises_node.capture_name
154
+ s(:lvasgn, raises_node.capture_name, assert_raises_call)
88
155
  else
89
- child
156
+ assert_raises_call
90
157
  end
91
158
  end
92
159
 
160
+ def find_raises(block_node)
161
+ return nil unless block_node.type == :rspock_then
162
+
163
+ block_node.children.find { |child| child.type == :rspock_raises }
164
+ end
165
+
166
+ def statements_of_type(blocks, sections, type)
167
+ blocks.zip(sections)
168
+ .select { |block_node, _section| block_node.type == type }
169
+ .flat_map { |_block_node, section| section.statements }
170
+ end
171
+
172
+ # --- Identity-based sequence edits (non-thunk counterparts of run_after) ---
173
+
174
+ def replace_run(statements, run, replacement)
175
+ start = statements.index { |statement| statement.equal?(run.first) }
176
+ raise ArgumentError, "run is not part of statements" unless start
177
+
178
+ result = statements.dup
179
+ result[start, run.length] = replacement
180
+ result
181
+ end
182
+
183
+ def insert_after(statements, anchor, insertion)
184
+ index = statements.index { |statement| statement.equal?(anchor) }
185
+ raise ArgumentError, "anchor is not part of statements" unless index
186
+
187
+ result = statements.dup
188
+ result.insert(index + 1, insertion)
189
+ result
190
+ end
191
+
192
+ # Re-anchors +node+ at +anchor+'s source location so emission places it on the anchor's line.
193
+ # No-op for anchors without locations.
194
+ def anchored_at(anchor, node)
195
+ return node unless anchor.loc&.expression
196
+
197
+ s_at(anchor, node.type, *node.children)
198
+ end
199
+
93
200
  # --- Build final Ruby AST ---
94
201
 
95
- def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups)
202
+ def build_ruby_ast(method_call, method_args, body_node, where)
96
203
  if where
97
- test_def = s(:block,
204
+ test_def = anchored_at(method_call, s(:block,
98
205
  TestMethodDefTransformation.new.run(method_call),
99
206
  method_args,
100
- build_test_body(body_node, hoisted_setups)
101
- )
207
+ body_node
208
+ ))
102
209
  test_def = HeaderNodesTransformation.new(where.header).run(test_def)
103
210
 
104
211
  s(:block,
@@ -107,43 +214,19 @@ module RSpock
107
214
  test_def
108
215
  )
109
216
  else
110
- s(:block,
217
+ anchored_at(method_call, s(:block,
111
218
  method_call,
112
219
  method_args,
113
- build_test_body(body_node, hoisted_setups)
114
- )
115
- end
116
- end
117
-
118
- def build_test_body(body_node, hoisted_setups)
119
- body_children = []
120
-
121
- body_node.children.each do |block_node|
122
- case block_node.type
123
- when :rspock_given
124
- body_children.concat(block_node.children)
125
- when :rspock_when
126
- body_children.concat(hoisted_setups)
127
- body_children.concat(block_node.children)
128
- when :rspock_then, :rspock_expect
129
- body_children.concat(block_node.children)
130
- when :rspock_cleanup
131
- # handled below as ensure
132
- end
220
+ body_node
221
+ ))
133
222
  end
134
-
135
- ast = s(:begin, *body_children)
136
-
137
- cleanup = body_node.children.find { |n| n.type == :rspock_cleanup }
138
- if cleanup && !cleanup.children.empty?
139
- ensure_node = s(:begin, *cleanup.children)
140
- ast = s(:kwbegin, s(:ensure, ast, ensure_node))
141
- end
142
-
143
- MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast)
144
223
  end
145
224
 
146
225
  # --- Where block helpers ---
226
+ #
227
+ # Each data row carries its source line as a trailing element, surfaced in the generated test NAME only
228
+ # (uniqueness for identical rows + the -n selector target) through internal block parameters. There is no
229
+ # user-facing runtime variable: isolate a row by running its generated test by name, then break normally.
147
230
 
148
231
  def build_where_iterator(data_rows)
149
232
  s(:send,
@@ -158,15 +241,16 @@ module RSpock
158
241
  def build_where_data_row(row)
159
242
  children = row.dup
160
243
  children << s(:int, row.first&.loc&.expression&.line)
161
- s(:array, *children)
244
+ anchor = row.first
245
+ anchor&.loc&.expression ? s_at(anchor, :array, *children) : s(:array, *children)
162
246
  end
163
247
 
164
248
  def build_where_args(header)
165
249
  injected_args = header.map { |column| s(:arg, column) }
166
- injected_args << s(:arg, :_line_number_)
250
+ injected_args << s(:arg, TestMethodDefTransformation::ROW_LINE_ARG)
167
251
  s(:args,
168
252
  s(:mlhs, *injected_args),
169
- s(:arg, :_test_index_),
253
+ s(:arg, TestMethodDefTransformation::ROW_INDEX_ARG),
170
254
  )
171
255
  end
172
256
  end
@@ -77,8 +77,7 @@ module RSpock
77
77
 
78
78
  def process_rspock(node)
79
79
  processed = process_all(node).compact
80
- children = [source_map_rescue_wrapper(s(:begin, *[EXTEND_RSPOCK_DECLARATIVE, *processed]))]
81
- node.updated(nil, children)
80
+ node.updated(nil, [EXTEND_RSPOCK_DECLARATIVE, *processed])
82
81
  end
83
82
 
84
83
  def on_block(node)
@@ -91,31 +90,6 @@ module RSpock
91
90
  strict: @strict
92
91
  ).run(node)
93
92
  end
94
-
95
- def source_map_rescue_wrapper(node)
96
- s(:kwbegin,
97
- s(:rescue,
98
- node,
99
- s(:resbody,
100
- s(:array,
101
- s(:const, nil, :StandardError)
102
- ),
103
- s(:lvasgn, :e),
104
- s(:begin,
105
- s(:send,
106
- s(:send,
107
- s(:const,
108
- s(:const,
109
- s(:cbase), :RSpock), :BacktraceFilter), :new), :filter_exception,
110
- s(:lvar, :e)
111
- ),
112
- s(:send, nil, :raise)
113
- )
114
- ),
115
- nil
116
- )
117
- )
118
- end
119
93
  end
120
94
  end
121
95
  end
@@ -1,3 +1,3 @@
1
1
  module RSpock
2
- VERSION = "2.4.0"
2
+ VERSION = "3.0.0"
3
3
  end
data/lib/rspock.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'rspock/version'
3
3
 
4
- require 'rspock/backtrace_filter'
5
4
  require 'rspock/declarative'
6
5
 
7
6
  require 'ast_transform'
data/rspock.gemspec CHANGED
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.bindir = "exe"
20
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
21
  spec.require_paths = ["lib"]
22
- spec.required_ruby_version = '>= 3.2'
22
+ spec.required_ruby_version = '>= 3.3'
23
23
 
24
24
  # Development dependencies
25
25
  spec.add_development_dependency "bundler", ">= 2.1"
@@ -31,9 +31,8 @@ Gem::Specification.new do |spec|
31
31
  spec.add_development_dependency "simplecov", "~> 0.22"
32
32
 
33
33
  # Runtime dependencies
34
- spec.add_runtime_dependency "ast_transform", "~> 2.0"
34
+ # parser and unparser are used only through ast_transform, which owns their floors.
35
+ spec.add_runtime_dependency "ast_transform", "~> 3.0"
35
36
  spec.add_runtime_dependency "minitest", "~> 5.0"
36
37
  spec.add_runtime_dependency "mocha", ">= 1.0"
37
- spec.add_runtime_dependency "parser", ">= 3.0"
38
- spec.add_runtime_dependency "unparser", ">= 0.6"
39
38
  end