exhale 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +149 -0
- data/Rakefile +96 -0
- data/exe/exhale +6 -0
- data/exhale.gemspec +43 -0
- data/lib/exhale/cli.rb +148 -0
- data/lib/exhale/contract/markdown.rb +107 -0
- data/lib/exhale/contract/reference.rb +63 -0
- data/lib/exhale/contract/resolver.rb +142 -0
- data/lib/exhale/contract.rb +194 -0
- data/lib/exhale/dry/check.rb +266 -0
- data/lib/exhale/dry/fingerprints.rb +105 -0
- data/lib/exhale/dry/gate.rb +425 -0
- data/lib/exhale/dry/index.rb +59 -0
- data/lib/exhale/dry/matcher.rb +468 -0
- data/lib/exhale/dry/normalizer/erb.rb +289 -0
- data/lib/exhale/dry/normalizer/ruby.rb +193 -0
- data/lib/exhale/dry/normalizer.rb +26 -0
- data/lib/exhale/errors.rb +27 -0
- data/lib/exhale/git.rb +279 -0
- data/lib/exhale/report.rb +203 -0
- data/lib/exhale/shape.rb +32 -0
- data/lib/exhale/source_files.rb +84 -0
- data/lib/exhale/unit.rb +27 -0
- data/lib/exhale/units/erb.rb +30 -0
- data/lib/exhale/units/ruby.rb +283 -0
- data/lib/exhale/version.rb +5 -0
- data/lib/exhale.rb +25 -0
- metadata +152 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "herb"
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "../../shape"
|
|
6
|
+
require_relative "ruby"
|
|
7
|
+
|
|
8
|
+
module Exhale
|
|
9
|
+
module Dry
|
|
10
|
+
module Normalizer
|
|
11
|
+
# Turns a Herb node into a Shape. Markup keeps its tag and attribute
|
|
12
|
+
# names; attribute values and text drop out, except the Stimulus
|
|
13
|
+
# attributes whose values name behavior. Embedded Ruby goes through the
|
|
14
|
+
# Ruby normalizer, so `link_to "Edit", edit_order_path(@order)` and
|
|
15
|
+
# `link_to "Show", order_path(@invoice)` read the same.
|
|
16
|
+
module Erb
|
|
17
|
+
DROPPED = %w[
|
|
18
|
+
HTMLTextNode WhitespaceNode HTMLCommentNode ERBCommentNode ERBEndNode
|
|
19
|
+
HTMLCloseTagNode HTMLOmittedCloseTagNode HTMLVirtualCloseTagNode
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Herb classes for ERB tags that open (or continue) a Ruby construct
|
|
23
|
+
# spanning several tags.
|
|
24
|
+
CONTROL = %w[
|
|
25
|
+
ERBBlockNode ERBIfNode ERBUnlessNode ERBElseNode ERBCaseNode ERBCaseMatchNode ERBWhenNode ERBInNode
|
|
26
|
+
ERBForNode ERBWhileNode ERBUntilNode ERBBeginNode ERBRescueNode ERBEnsureNode ERBYieldNode
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
BRANCH_FIELDS = %i[conditions subsequent rescue_clause else_clause ensure_clause].freeze
|
|
30
|
+
|
|
31
|
+
# Values that name behavior, so they stay in the tree.
|
|
32
|
+
KEPT_VALUES = %w[data-controller data-action].freeze
|
|
33
|
+
|
|
34
|
+
STRICT_LOCALS = /\A\s*locals:\s*(\(.*\))\s*\z/m
|
|
35
|
+
|
|
36
|
+
# A head that can't parse alone, even with an `end`, gets wrapped in
|
|
37
|
+
# the construct it belongs to, and the part it wrote is picked back
|
|
38
|
+
# out. So the condition of `<% elsif admin? %>` or `<% when :draft %>`
|
|
39
|
+
# survives. Wrappers stay on the code's line so Prism's line numbers
|
|
40
|
+
# are the template's.
|
|
41
|
+
BRANCHES = {
|
|
42
|
+
"case" => [->(code) { "#{code}\nwhen nil\nend" }, ->(statement) { statement.predicate }],
|
|
43
|
+
"elsif" => [->(code) { "#{code.sub("elsif", "if")}\nend" }, ->(statement) { statement }],
|
|
44
|
+
"when" => [->(code) { "case;#{code}\nend" }, ->(statement) { statement.conditions.first }],
|
|
45
|
+
"in" => [->(code) { "case nil;#{code}\nend" }, ->(statement) { statement.conditions.first }],
|
|
46
|
+
"rescue" => [->(code) { "begin;#{code}\nend" }, ->(statement) { statement.rescue_clause }]
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def normalize(node)
|
|
52
|
+
Walker.new.normalize(node)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Each ERB tag parses on its own, so the walk carries the locals the
|
|
56
|
+
# template has defined so far (block parameters, `<% total = 0 %>`)
|
|
57
|
+
# into every later tag. Without them `item.name` inside
|
|
58
|
+
# `<% items.each do |item| %>` would read as a call to `item`.
|
|
59
|
+
class Walker
|
|
60
|
+
def initialize
|
|
61
|
+
@scopes = [[]]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize(node)
|
|
65
|
+
name = class_name(node)
|
|
66
|
+
return if DROPPED.include?(name)
|
|
67
|
+
|
|
68
|
+
case name
|
|
69
|
+
when "DocumentNode" then document(node)
|
|
70
|
+
when "HTMLElementNode" then element(node)
|
|
71
|
+
when "HTMLAttributeNode" then attribute(node)
|
|
72
|
+
when "LiteralNode" then leaf(":literal", node.location)
|
|
73
|
+
when "ERBContentNode" then tag(node)&.first
|
|
74
|
+
when *CONTROL then control(node)
|
|
75
|
+
else build(node)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def normalize_all(nodes)
|
|
82
|
+
Array(nodes).compact.filter_map { |child| normalize(child) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def document(node)
|
|
86
|
+
@scopes.first.concat(strict_locals(node.children))
|
|
87
|
+
build(node, children: normalize_all(node.children), sequence: true)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A partial's `<%# locals: (order:, compact: false) %>` magic comment
|
|
91
|
+
# names the locals `render` passes in.
|
|
92
|
+
def strict_locals(children)
|
|
93
|
+
Array(children).each do |child|
|
|
94
|
+
next unless class_name(child) == "ERBCommentNode"
|
|
95
|
+
|
|
96
|
+
signature = child.content&.value.to_s[STRICT_LOCALS, 1]
|
|
97
|
+
next unless signature
|
|
98
|
+
|
|
99
|
+
result = Prism.parse("def _#{signature}; end")
|
|
100
|
+
return result.value.statements.body.first.locals if result.success?
|
|
101
|
+
end
|
|
102
|
+
[]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def element(node)
|
|
106
|
+
attributes = node.open_tag ? normalize_all(node.open_tag.children) : []
|
|
107
|
+
body = normalize_all(node.body)
|
|
108
|
+
children = body.empty? ? attributes : attributes + [run("html_body", body, node)]
|
|
109
|
+
build(node, label: node.tag_name&.value&.downcase, children: children)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def attribute(node)
|
|
113
|
+
name = literal_text(node.name&.children)
|
|
114
|
+
build(node, label: name, children: [attribute_value(name, node.value)].compact)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A value that is plain text becomes ":literal"; a value with ERB in
|
|
118
|
+
# it keeps the ERB, normalized, beside ":literal" for each text part.
|
|
119
|
+
def attribute_value(name, value)
|
|
120
|
+
return unless value
|
|
121
|
+
|
|
122
|
+
parts = Array(value.children)
|
|
123
|
+
if KEPT_VALUES.include?(name.to_s.downcase)
|
|
124
|
+
erb_parts = normalize_all(parts.reject { |part| literal?(part) })
|
|
125
|
+
return build(value, kind: "html_attribute_value", label: literal_text(parts), children: erb_parts)
|
|
126
|
+
end
|
|
127
|
+
return leaf(":literal", value.location) if parts.all? { |part| literal?(part) }
|
|
128
|
+
|
|
129
|
+
build(value, children: normalize_all(parts))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# `<%= ... %>` or `<% ... %>` on its own. Returns the shape and the
|
|
133
|
+
# locals of any block the tag opens, or nil for an escaped `<%%`.
|
|
134
|
+
def tag(node)
|
|
135
|
+
opening = node.tag_opening&.value.to_s
|
|
136
|
+
return if opening.start_with?("<%#", "<%%")
|
|
137
|
+
|
|
138
|
+
kind = opening.start_with?("<%=") ? "erb_output" : "erb_logic"
|
|
139
|
+
statements, block_locals = node.content ? ruby(node.content.value, node.content.location.start.line) : nil
|
|
140
|
+
lines = tag_lines(node)
|
|
141
|
+
children = Array(statements).map { |statement| clamp(Ruby.normalize(statement, template: true), lines[:end_line]) }
|
|
142
|
+
[Shape.new(kind: kind, label: nil, children: children, sequence: false, **lines), block_locals]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A tag that opens a Ruby construct over several tags: its head (the
|
|
146
|
+
# tag itself), its body, then each later branch (else, elsif, when,
|
|
147
|
+
# rescue, ensure), normalized the same way. The end tag drops out.
|
|
148
|
+
def control(node)
|
|
149
|
+
head, block_locals = tag(node)
|
|
150
|
+
@scopes.push(block_locals) if block_locals
|
|
151
|
+
children = [head].compact
|
|
152
|
+
body_nodes = body_of(node)
|
|
153
|
+
children << run("erb_body", normalize_all(body_nodes), node) if body_nodes
|
|
154
|
+
children.concat(branches(node))
|
|
155
|
+
build(node, children: children)
|
|
156
|
+
ensure
|
|
157
|
+
@scopes.pop if block_locals
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A case's own children are the gap before its first `when`, which
|
|
161
|
+
# ERB never renders.
|
|
162
|
+
def body_of(node)
|
|
163
|
+
if node.respond_to?(:body) then node.body
|
|
164
|
+
elsif node.respond_to?(:statements) then node.statements
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def branches(node)
|
|
169
|
+
BRANCH_FIELDS.select { |field| node.respond_to?(field) }
|
|
170
|
+
.flat_map { |field| Array(node.public_send(field)) }
|
|
171
|
+
.filter_map { |branch| normalize(branch) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The Prism statements for one tag's Ruby, plus the locals of a
|
|
175
|
+
# block it opens (nil when it opens none). Nil statements when it
|
|
176
|
+
# won't parse even with a closing `end`.
|
|
177
|
+
def ruby(code, line)
|
|
178
|
+
statements = parse(code, line)
|
|
179
|
+
return [statements, nil] if statements
|
|
180
|
+
|
|
181
|
+
closed = "#{code}\nend"
|
|
182
|
+
statements = parse(closed, line)
|
|
183
|
+
return [statements, opened_block_locals(statements, code.bytesize)] if statements
|
|
184
|
+
|
|
185
|
+
[branch(code, line) || in_method(code, line), nil]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def branch(code, line)
|
|
189
|
+
wrap, pick = BRANCHES[code.strip[/\A\w+/]]
|
|
190
|
+
statements = wrap && parse(wrap.call(code), line)
|
|
191
|
+
picked = statements && pick.call(statements.first)
|
|
192
|
+
[picked] if picked
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# `yield` is only valid Ruby inside a method.
|
|
196
|
+
def in_method(code, line)
|
|
197
|
+
statements = parse("def _;#{code}\nend", line)
|
|
198
|
+
body = statements&.first&.body
|
|
199
|
+
body.body if body.is_a?(Prism::StatementsNode)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Parses with the template's locals in scope, and keeps any locals
|
|
203
|
+
# the code assigns for the tags after it.
|
|
204
|
+
def parse(code, line)
|
|
205
|
+
result = Prism.parse(code, line: line, scopes: @scopes)
|
|
206
|
+
return unless result.success?
|
|
207
|
+
|
|
208
|
+
@scopes.last.concat(result.value.locals - @scopes.last)
|
|
209
|
+
result.value.statements.body
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A block whose `end` is the one appended to the head is the block
|
|
213
|
+
# the ERB body runs inside.
|
|
214
|
+
def opened_block_locals(statements, code_size)
|
|
215
|
+
blocks = []
|
|
216
|
+
stack = statements.dup
|
|
217
|
+
until stack.empty?
|
|
218
|
+
node = stack.pop
|
|
219
|
+
blocks << node if node.is_a?(Prism::BlockNode) && node.location.end_offset > code_size
|
|
220
|
+
stack.concat(node.compact_child_nodes)
|
|
221
|
+
end
|
|
222
|
+
blocks.empty? ? nil : blocks.flat_map(&:locals).uniq
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A run spans the shapes it holds; the text and whitespace around
|
|
226
|
+
# them dropped out, so they don't stretch it. An empty run takes
|
|
227
|
+
# the lines of the node it belongs to.
|
|
228
|
+
def run(kind, children, fallback)
|
|
229
|
+
return build(fallback, kind: kind, children: children, sequence: true) if children.empty?
|
|
230
|
+
|
|
231
|
+
Shape.new(kind: kind, label: nil, children: children, sequence: true,
|
|
232
|
+
start_line: children.first.start_line, end_line: children.map(&:end_line).max)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build(node, kind: snake_case(class_name(node)), label: nil,
|
|
236
|
+
children: normalize_all(node.compact_child_nodes), sequence: false)
|
|
237
|
+
location = node.location
|
|
238
|
+
Shape.new(kind: kind, label: label, children: children, sequence: sequence,
|
|
239
|
+
start_line: location.start.line, end_line: end_line(location))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def leaf(kind, location)
|
|
243
|
+
Shape.new(kind: kind, label: nil, children: [], sequence: false,
|
|
244
|
+
start_line: location.start.line, end_line: end_line(location))
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def tag_lines(node)
|
|
248
|
+
first = node.tag_opening || node
|
|
249
|
+
last = node.tag_closing || node.content || node
|
|
250
|
+
{ start_line: first.location.start.line, end_line: end_line(last.location) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The `end` a head borrowed to parse sits on the line after the tag.
|
|
254
|
+
def clamp(shape, last)
|
|
255
|
+
return shape if shape.end_line <= last
|
|
256
|
+
|
|
257
|
+
shape.dup.tap do |copy|
|
|
258
|
+
copy.end_line = [last, shape.start_line].max
|
|
259
|
+
copy.children = shape.children.map { |child| clamp(child, last) }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Herb ends a node that runs to a newline at column 0 of the next
|
|
264
|
+
# line; that line holds none of the node.
|
|
265
|
+
def end_line(location)
|
|
266
|
+
finish = location.end
|
|
267
|
+
finish.column.zero? && finish.line > location.start.line ? finish.line - 1 : finish.line
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def literal?(node)
|
|
271
|
+
class_name(node) == "LiteralNode"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def literal_text(nodes)
|
|
275
|
+
Array(nodes).select { |part| literal?(part) }.map(&:content).join
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def class_name(node)
|
|
279
|
+
node.class.name.split("::").last
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def snake_case(name)
|
|
283
|
+
name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../../shape"
|
|
5
|
+
|
|
6
|
+
module Exhale
|
|
7
|
+
module Dry
|
|
8
|
+
module Normalizer
|
|
9
|
+
# Turns a Prism node into a Shape. Names of operations survive (the
|
|
10
|
+
# method name at every call site, operators included), names of things
|
|
11
|
+
# become markers (":local", ":ivar", ":const", ":literal"), and the tree
|
|
12
|
+
# keeps its shape. Two methods that do the same work on differently
|
|
13
|
+
# named locals and constants come out equal.
|
|
14
|
+
module Ruby
|
|
15
|
+
MARKERS = {
|
|
16
|
+
local_variable_read_node: ":local",
|
|
17
|
+
it_local_variable_read_node: ":local",
|
|
18
|
+
local_variable_target_node: ":local",
|
|
19
|
+
required_parameter_node: ":local",
|
|
20
|
+
block_local_variable_node: ":local",
|
|
21
|
+
instance_variable_read_node: ":ivar",
|
|
22
|
+
instance_variable_target_node: ":ivar",
|
|
23
|
+
class_variable_read_node: ":cvar",
|
|
24
|
+
class_variable_target_node: ":cvar",
|
|
25
|
+
global_variable_read_node: ":gvar",
|
|
26
|
+
global_variable_target_node: ":gvar",
|
|
27
|
+
back_reference_read_node: ":gvar",
|
|
28
|
+
numbered_reference_read_node: ":gvar",
|
|
29
|
+
constant_read_node: ":const",
|
|
30
|
+
constant_path_node: ":const",
|
|
31
|
+
constant_target_node: ":const",
|
|
32
|
+
constant_path_target_node: ":const",
|
|
33
|
+
symbol_node: ":literal",
|
|
34
|
+
string_node: ":literal",
|
|
35
|
+
x_string_node: ":literal",
|
|
36
|
+
integer_node: ":literal",
|
|
37
|
+
float_node: ":literal",
|
|
38
|
+
rational_node: ":literal",
|
|
39
|
+
imaginary_node: ":literal",
|
|
40
|
+
regular_expression_node: ":literal",
|
|
41
|
+
true_node: ":literal",
|
|
42
|
+
false_node: ":literal",
|
|
43
|
+
nil_node: ":literal",
|
|
44
|
+
source_file_node: ":literal",
|
|
45
|
+
source_line_node: ":literal",
|
|
46
|
+
source_encoding_node: ":literal"
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# `a.total += 1` and `a.total ||= 1` are calls to `total`, so the
|
|
50
|
+
# read name stays alongside the operator.
|
|
51
|
+
CALL_WRITES = %i[call_operator_write_node call_and_write_node call_or_write_node].freeze
|
|
52
|
+
|
|
53
|
+
# A route helper names a route, which is a thing, so
|
|
54
|
+
# `edit_order_path(@order)` and `invoice_path(@invoice)` read the same.
|
|
55
|
+
# The call stays a call; only its label becomes the marker.
|
|
56
|
+
ROUTE_HELPER = /_(?:path|url)\z/
|
|
57
|
+
ROUTE = ":route"
|
|
58
|
+
# Receivers that hand out route helpers: `main_app.orders_path`,
|
|
59
|
+
# `Rails.application.routes.url_helpers.order_url(o)`. On any other
|
|
60
|
+
# receiver (`request.original_url`, `blob.service_url`) the name is
|
|
61
|
+
# the receiver's own method and stays.
|
|
62
|
+
ROUTE_PROXIES = %i[url_helpers main_app helpers routes].freeze
|
|
63
|
+
|
|
64
|
+
# Stimulus names behavior in Ruby too: `data: { controller: "modal" }`
|
|
65
|
+
# and `"data-controller" => "modal"` keep their value, as the HTML
|
|
66
|
+
# attribute does.
|
|
67
|
+
STIMULUS_HASH_KEYS = %w[controller action].freeze
|
|
68
|
+
STIMULUS_KEYS = %w[data-controller data-action].freeze
|
|
69
|
+
STIMULUS = "stimulus_value"
|
|
70
|
+
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
# template: true reads a bare identifier (`order`, no receiver, no
|
|
74
|
+
# arguments) as a local. In a partial that is what it almost always
|
|
75
|
+
# is, and Prism can't tell because the locals come from `render`.
|
|
76
|
+
def normalize(node, template: false)
|
|
77
|
+
Walker.new(template).normalize(node)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class Walker
|
|
81
|
+
def initialize(template)
|
|
82
|
+
@template = template
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize(node)
|
|
86
|
+
marker = MARKERS[node.type]
|
|
87
|
+
return leaf(marker, node) if marker
|
|
88
|
+
|
|
89
|
+
case node.type
|
|
90
|
+
when :statements_node then build(node, children: normalize_all(node.body), sequence: true)
|
|
91
|
+
when :call_node then call(node)
|
|
92
|
+
when :parentheses_node then unwrap(node)
|
|
93
|
+
when :assoc_node then assoc(node)
|
|
94
|
+
else build(node, label: label_for(node))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def normalize_all(nodes)
|
|
101
|
+
nodes.compact.map { |child| normalize(child) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def call(node)
|
|
105
|
+
return leaf(":local", node) if @template && node.variable_call?
|
|
106
|
+
|
|
107
|
+
build(node, label: route_helper?(node) ? ROUTE : node.name.to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def route_helper?(node)
|
|
111
|
+
return false unless node.name.to_s.match?(ROUTE_HELPER)
|
|
112
|
+
|
|
113
|
+
receiver = node.receiver
|
|
114
|
+
receiver.nil? || (receiver.is_a?(Prism::CallNode) && ROUTE_PROXIES.include?(receiver.name))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `(a + b)` reads the same as `a + b`.
|
|
118
|
+
def unwrap(node)
|
|
119
|
+
body = node.body
|
|
120
|
+
return normalize(body.body.first) if body.is_a?(Prism::StatementsNode) && body.body.size == 1
|
|
121
|
+
return normalize(body) if body && !body.is_a?(Prism::StatementsNode)
|
|
122
|
+
|
|
123
|
+
build(node)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def assoc(node)
|
|
127
|
+
key = key_text(node.key)
|
|
128
|
+
value = node.value
|
|
129
|
+
if key == "data" && value.is_a?(Prism::HashNode)
|
|
130
|
+
build(node, children: [normalize(node.key), stimulus_hash(value)])
|
|
131
|
+
elsif STIMULUS_KEYS.include?(key) && value.is_a?(Prism::StringNode)
|
|
132
|
+
build(node, children: [normalize(node.key), stimulus(value)])
|
|
133
|
+
else
|
|
134
|
+
build(node)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def stimulus_hash(hash)
|
|
139
|
+
children = hash.elements.map do |element|
|
|
140
|
+
if element.is_a?(Prism::AssocNode) && STIMULUS_HASH_KEYS.include?(key_text(element.key)) &&
|
|
141
|
+
element.value.is_a?(Prism::StringNode)
|
|
142
|
+
build(element, children: [normalize(element.key), stimulus(element.value)])
|
|
143
|
+
else
|
|
144
|
+
normalize(element)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
build(hash, children: children)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def stimulus(string)
|
|
151
|
+
build(string, kind: STIMULUS, label: string.unescaped, children: [])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def key_text(key)
|
|
155
|
+
key.unescaped if key.is_a?(Prism::SymbolNode) || key.is_a?(Prism::StringNode)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Operator writes keep their operator; every other name on a write
|
|
159
|
+
# (the variable, constant or ivar being assigned) is dropped.
|
|
160
|
+
def label_for(node)
|
|
161
|
+
operator = node.binary_operator.to_s if node.respond_to?(:binary_operator)
|
|
162
|
+
return [node.read_name.to_s, operator].compact.join(" ") if CALL_WRITES.include?(node.type)
|
|
163
|
+
|
|
164
|
+
operator
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build(node, kind: node.type.to_s, label: nil, children: normalize_all(node.compact_child_nodes),
|
|
168
|
+
sequence: false)
|
|
169
|
+
Shape.new(kind: kind, label: label, children: children, sequence: sequence,
|
|
170
|
+
start_line: node.location.start_line, end_line: end_line(node, children))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def leaf(kind, node)
|
|
174
|
+
Shape.new(kind: kind, label: nil, children: [], sequence: false,
|
|
175
|
+
start_line: node.location.start_line, end_line: end_line(node, []))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A heredoc's body and terminator sit below the line its node
|
|
179
|
+
# ends on, so a shape ends at the furthest terminator inside it.
|
|
180
|
+
def end_line(node, children)
|
|
181
|
+
last = node.location.end_line
|
|
182
|
+
last = [last, node.closing_loc.start_line].max if heredoc?(node)
|
|
183
|
+
children.empty? ? last : [last, children.map(&:end_line).max].max
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def heredoc?(node)
|
|
187
|
+
node.respond_to?(:heredoc?) && node.heredoc? && node.closing_loc
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "normalizer/ruby"
|
|
4
|
+
require_relative "normalizer/erb"
|
|
5
|
+
|
|
6
|
+
module Exhale
|
|
7
|
+
module Dry
|
|
8
|
+
# Turns a unit's parser node into a normalized Shape tree.
|
|
9
|
+
module Normalizer
|
|
10
|
+
# Part of every cache key and the report header. Bump it whenever a
|
|
11
|
+
# normalization rule changes, since old fingerprints stop meaning the
|
|
12
|
+
# same thing.
|
|
13
|
+
VERSION = 2
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def normalize(unit)
|
|
18
|
+
case unit.language
|
|
19
|
+
when :ruby then Ruby.normalize(unit.node)
|
|
20
|
+
when :erb then Erb.normalize(unit.node)
|
|
21
|
+
else raise ArgumentError, "no normalizer for #{unit.language.inspect}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exhale
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# An error that points at a line in a file.
|
|
7
|
+
class LocatedError < Error
|
|
8
|
+
attr_reader :path, :line
|
|
9
|
+
|
|
10
|
+
def initialize(path, line, message)
|
|
11
|
+
@path = path
|
|
12
|
+
@line = line
|
|
13
|
+
super("#{path}:#{line}: #{message}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A Ruby or ERB file the parser reported errors for. The gate never passes
|
|
18
|
+
# code it couldn't read, so the CLI turns this into exit code 2.
|
|
19
|
+
class ParseError < LocatedError; end
|
|
20
|
+
|
|
21
|
+
# A problem in the Contract that makes it unusable as written: an empty
|
|
22
|
+
# block, a reference that resolves to nothing, a malformed settings block.
|
|
23
|
+
class ContractError < LocatedError; end
|
|
24
|
+
|
|
25
|
+
# git failed in a way exhale can't work around.
|
|
26
|
+
class GitError < Error; end
|
|
27
|
+
end
|