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.
@@ -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