crumbly 2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +455 -0
- data/exe/crumbly-convert +38 -0
- data/lib/crumbly/controller.rb +17 -0
- data/lib/crumbly/converter/crumb_definition.rb +476 -0
- data/lib/crumbly/converter/source.rb +70 -0
- data/lib/crumbly/converter.rb +288 -0
- data/lib/crumbly/crumb.rb +7 -0
- data/lib/crumbly/derived_call_sites.rb +50 -0
- data/lib/crumbly/errors.rb +15 -0
- data/lib/crumbly/json_ld.rb +51 -0
- data/lib/crumbly/level.rb +9 -0
- data/lib/crumbly/oracle.rb +147 -0
- data/lib/crumbly/railtie.rb +10 -0
- data/lib/crumbly/test_helper.rb +16 -0
- data/lib/crumbly/trail.rb +232 -0
- data/lib/crumbly/verifier.rb +201 -0
- data/lib/crumbly/version.rb +5 -0
- data/lib/crumbly/view_helpers.rb +30 -0
- data/lib/crumbly.rb +78 -0
- metadata +110 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "crumbly"
|
|
5
|
+
require_relative "converter/source"
|
|
6
|
+
require_relative "converter/crumb_definition"
|
|
7
|
+
|
|
8
|
+
if Gem::Version.new(Prism::VERSION) < Gem::Version.new("1.2")
|
|
9
|
+
raise LoadError, "crumbly-convert needs prism 1.2 or later (#{Prism::VERSION} is loaded): add gem \"prism\" " \
|
|
10
|
+
"to your Gemfile"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module Crumbly
|
|
14
|
+
# Turns gretel's breadcrumbs files into one Crumbly::Trail subclass, and lists what it leaves out.
|
|
15
|
+
class Converter
|
|
16
|
+
Result = Data.define(:ruby, :notes)
|
|
17
|
+
|
|
18
|
+
Note = Data.define(:path, :line, :message) do
|
|
19
|
+
def to_s
|
|
20
|
+
"#{path}:#{line}: #{message}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Unconvertible < StandardError
|
|
25
|
+
attr_reader :line
|
|
26
|
+
|
|
27
|
+
def initialize(message, line)
|
|
28
|
+
super(message)
|
|
29
|
+
@line = line
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Definition = Data.define(:key, :path, :line, :lines)
|
|
34
|
+
|
|
35
|
+
STATIC_NODES = [
|
|
36
|
+
Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode, Prism::FloatNode,
|
|
37
|
+
Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
38
|
+
].freeze
|
|
39
|
+
LoopValue = Data.define(:source, :text)
|
|
40
|
+
|
|
41
|
+
def self.call(source_by_path, namespace: nil)
|
|
42
|
+
new(source_by_path, namespace:).call
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(source_by_path, namespace:)
|
|
46
|
+
unless namespace.nil? || namespace.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
|
|
47
|
+
raise ArgumentError, "#{namespace.inspect} is not a module name"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@source_by_path = source_by_path
|
|
51
|
+
@namespace = namespace
|
|
52
|
+
@entries = []
|
|
53
|
+
@definition_by_key = {}
|
|
54
|
+
@notes = []
|
|
55
|
+
@helper_methods = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def call
|
|
59
|
+
source_by_path.each do |path, text|
|
|
60
|
+
source = Source.parse(text, path:)
|
|
61
|
+
convert_statements(source.statements, source)
|
|
62
|
+
trailing_comments = source.comment_lines(source.statements.last&.location&.end_offset || 0, text.bytesize)
|
|
63
|
+
entries << trailing_comments if trailing_comments.any?
|
|
64
|
+
end
|
|
65
|
+
Result.new(ruby: render, notes: notes.sort_by { |note| [note.path, note.line] })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :source_by_path, :namespace, :entries, :definition_by_key, :notes, :helper_methods
|
|
71
|
+
|
|
72
|
+
# A crumb becomes a method, which cannot see the locals assigned around it: each one it reads is copied in.
|
|
73
|
+
def convert_statements(statements, source, local_by_name = {})
|
|
74
|
+
previous_end_offset = source.start_offset
|
|
75
|
+
statements.each do |statement|
|
|
76
|
+
comments = source.comment_lines(previous_end_offset, statement.location.start_offset)
|
|
77
|
+
previous_end_offset = statement.location.end_offset
|
|
78
|
+
convert_statement(statement, source, comments, local_by_name)
|
|
79
|
+
rescue Unconvertible => exception
|
|
80
|
+
note(source, exception.line, exception.message)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def convert_statement(statement, source, comments, local_by_name)
|
|
85
|
+
if crumb_call?(statement)
|
|
86
|
+
define(CrumbDefinition.new(statement, source, local_by_name:), source, comments)
|
|
87
|
+
elsif statement.is_a?(Prism::LocalVariableWriteNode)
|
|
88
|
+
if local_by_name.key?(statement.name)
|
|
89
|
+
note(source, statement.location.start_line, "#{statement.name} is assigned again: a crumb above that " \
|
|
90
|
+
"reads it gets its first value, where gretel read the last one")
|
|
91
|
+
end
|
|
92
|
+
local = CrumbDefinition::Local.new(node: statement, source:)
|
|
93
|
+
helper_methods << comments + local.method_lines if local.helper_method?
|
|
94
|
+
local_by_name[statement.name] = local
|
|
95
|
+
elsif loop_call?(statement)
|
|
96
|
+
entries << comments if comments.any?
|
|
97
|
+
expand_loop(statement, source, local_by_name)
|
|
98
|
+
else
|
|
99
|
+
raise Unconvertible.new("not a crumb definition, left out: #{summary(statement)}",
|
|
100
|
+
statement.location.start_line)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def crumb_call?(node)
|
|
105
|
+
node in Prism::CallNode[receiver: nil, name: :crumb]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def loop_call?(node)
|
|
109
|
+
node in Prism::CallNode[name: :each | :each_pair, receiver: Prism::Node, block: Prism::BlockNode]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def define(crumb_definition, source, comments)
|
|
113
|
+
key = crumb_definition.key
|
|
114
|
+
lines = comments + crumb_definition.lines
|
|
115
|
+
crumb_definition.notes.uniq.each { |message| note(source, crumb_definition.line, message) }
|
|
116
|
+
line = crumb_definition.line
|
|
117
|
+
|
|
118
|
+
if (previous = definition_by_key[key])
|
|
119
|
+
entries.delete(previous)
|
|
120
|
+
notes << Note.new(path: previous.path, line: previous.line,
|
|
121
|
+
message: "crumb :#{key} is defined again on #{source.path}:#{line}, which gretel kept: left out")
|
|
122
|
+
end
|
|
123
|
+
definition = Definition.new(key:, path: source.path, line:, lines:)
|
|
124
|
+
definition_by_key[key] = definition
|
|
125
|
+
entries << definition
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def expand_loop(call, source, local_by_name)
|
|
129
|
+
block = call.block
|
|
130
|
+
parameters = loop_parameters(block)
|
|
131
|
+
unless parameters
|
|
132
|
+
raise Unconvertible.new("crumbs defined in a loop whose block takes #{block.parameters&.slice.inspect}: " \
|
|
133
|
+
"left out, write them by hand", call.location.start_line)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
bindings = literal_bindings(call.receiver, parameters, call.name)
|
|
137
|
+
unless bindings
|
|
138
|
+
raise Unconvertible.new("crumbs defined in a loop over #{summary(call.receiver)}, which is not a literal: " \
|
|
139
|
+
"left out, write them by hand", call.location.start_line)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
start_offset = block.parameters ? block.parameters.location.end_offset : block.opening_loc.end_offset
|
|
143
|
+
bindings.each do |value_by_name|
|
|
144
|
+
text = source.rewrite(start_offset, block.closing_loc.start_offset, substitutions(block.body, value_by_name))
|
|
145
|
+
expansion = value_by_name.map { |name, value| "#{name} = #{value.source}" }.join(", ")
|
|
146
|
+
expanded_source = source.expansion(text, start_offset, expansion, local_by_name.keys)
|
|
147
|
+
convert_statements(expanded_source.statements, expanded_source, local_by_name.dup)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A parameter destructured by the block (`|name, (parent, label)|`) is an array of names.
|
|
152
|
+
def loop_parameters(block)
|
|
153
|
+
parameters = block.parameters&.parameters
|
|
154
|
+
return unless parameters in {optionals: [], rest: nil, posts: [], keywords: [], keyword_rest: nil, block: nil}
|
|
155
|
+
|
|
156
|
+
parameters.requireds.map { |parameter| loop_parameter(parameter) || (return nil) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def loop_parameter(parameter)
|
|
160
|
+
case parameter
|
|
161
|
+
when Prism::RequiredParameterNode then parameter.name
|
|
162
|
+
when Prism::MultiTargetNode
|
|
163
|
+
return unless parameter.rest.nil? && parameter.rights.empty?
|
|
164
|
+
|
|
165
|
+
parameter.lefts.map { |left| loop_parameter(left) || (return nil) }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The value of each parameter, for each element: Ruby splats what the loop yields over several parameters,
|
|
170
|
+
# or over a single destructured one.
|
|
171
|
+
def literal_bindings(collection, parameters, method_name)
|
|
172
|
+
collection = collection.receiver while collection.is_a?(Prism::CallNode) && collection.name == :freeze
|
|
173
|
+
return unless static?(collection)
|
|
174
|
+
|
|
175
|
+
single_name = parameters.first if parameters.size == 1 && parameters.first.is_a?(Symbol)
|
|
176
|
+
case collection
|
|
177
|
+
when Prism::ArrayNode
|
|
178
|
+
return if method_name == :each_pair
|
|
179
|
+
|
|
180
|
+
collection.elements.map do |element|
|
|
181
|
+
next {single_name => loop_value(element)} if single_name
|
|
182
|
+
|
|
183
|
+
bind(parameters, element.is_a?(Prism::ArrayNode) ? element.elements : [element])
|
|
184
|
+
end
|
|
185
|
+
when Prism::HashNode
|
|
186
|
+
collection.elements.map do |association|
|
|
187
|
+
pair = [association.key, association.value]
|
|
188
|
+
next bind(parameters, pair) unless single_name
|
|
189
|
+
|
|
190
|
+
{single_name => LoopValue.new(source: "[#{pair.map { |node| loop_value(node).source }.join(", ")}]", text: nil)}
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def bind(parameters, values, value_by_name = {})
|
|
196
|
+
parameters = parameters.first if parameters.size == 1 && parameters.first.is_a?(Array)
|
|
197
|
+
parameters.each_with_index do |parameter, index|
|
|
198
|
+
value = values[index]
|
|
199
|
+
if parameter.is_a?(Symbol)
|
|
200
|
+
value_by_name[parameter] = value ? loop_value(value) : LoopValue.new(source: "nil", text: nil)
|
|
201
|
+
else
|
|
202
|
+
bind(parameter, value.is_a?(Prism::ArrayNode) ? value.elements : [value].compact, value_by_name)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
value_by_name
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def static?(node)
|
|
209
|
+
case node
|
|
210
|
+
when Prism::ArrayNode then node.elements.all? { |element| static?(element) }
|
|
211
|
+
when Prism::HashNode
|
|
212
|
+
node.elements.all? do |element|
|
|
213
|
+
element.is_a?(Prism::AssocNode) && static?(element.key) && static?(element.value)
|
|
214
|
+
end
|
|
215
|
+
else STATIC_NODES.any? { |node_class| node.is_a?(node_class) }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def loop_value(node)
|
|
220
|
+
case node
|
|
221
|
+
when Prism::SymbolNode then LoopValue.new(source: node.unescaped.to_sym.inspect, text: node.unescaped)
|
|
222
|
+
when Prism::StringNode then LoopValue.new(source: node.unescaped.inspect, text: node.unescaped)
|
|
223
|
+
else LoopValue.new(source: node.slice, text: nil)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def substitutions(node, value_by_name, depth = 0, edits = [])
|
|
228
|
+
case node
|
|
229
|
+
when Prism::LocalVariableReadNode
|
|
230
|
+
value = value_by_name[node.name] if node.depth == depth
|
|
231
|
+
edits << [node.location.start_offset, node.location.end_offset, value.source] if value
|
|
232
|
+
return edits
|
|
233
|
+
when Prism::CallNode
|
|
234
|
+
if (node in {name: :to_s, arguments: nil, receiver: Prism::LocalVariableReadNode[depth: ^depth, name:]}) &&
|
|
235
|
+
(text = value_by_name[name]&.text)
|
|
236
|
+
return edits << [node.location.start_offset, node.location.end_offset, text.inspect]
|
|
237
|
+
end
|
|
238
|
+
when Prism::EmbeddedStatementsNode
|
|
239
|
+
if node in {statements: {body: [Prism::LocalVariableReadNode[depth: ^depth, name:]]}}
|
|
240
|
+
text = value_by_name[name]&.text
|
|
241
|
+
if text&.match?(%r{\A[\w ./-]*\z})
|
|
242
|
+
edits << [node.location.start_offset, node.location.end_offset, text]
|
|
243
|
+
return edits
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
when Prism::BlockNode, Prism::LambdaNode then depth += 1
|
|
247
|
+
when Prism::DefNode then return edits
|
|
248
|
+
end
|
|
249
|
+
node&.compact_child_nodes&.each { |child| substitutions(child, value_by_name, depth, edits) }
|
|
250
|
+
edits
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def note(source, line, message)
|
|
254
|
+
message += " (#{source.expansion_description})" if source.expansion_description
|
|
255
|
+
notes << Note.new(path: source.path, line:, message:)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def summary(node)
|
|
259
|
+
first_line = node.slice.lines.first.strip
|
|
260
|
+
(first_line.length > 60) ? "#{first_line[0, 57]}..." : first_line
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def render
|
|
264
|
+
modules = namespace.to_s.split("::")
|
|
265
|
+
depth = modules.size
|
|
266
|
+
body = entries.map do |entry|
|
|
267
|
+
lines = entry.is_a?(Definition) ? entry.lines : entry
|
|
268
|
+
indent(lines, depth + 1).join("\n")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
ruby = +"# frozen_string_literal: true\n\n"
|
|
272
|
+
modules.each_with_index { |name, index| ruby << "#{" " * index}module #{name}\n" }
|
|
273
|
+
ruby << "#{" " * depth}class Breadcrumbs < Crumbly::Trail\n"
|
|
274
|
+
if helper_methods.any?
|
|
275
|
+
body << "#{" " * (depth + 1)}private"
|
|
276
|
+
body.concat(helper_methods.map { |lines| indent(lines, depth + 1).join("\n") })
|
|
277
|
+
end
|
|
278
|
+
ruby << body.join("\n\n") << "\n" if body.any?
|
|
279
|
+
ruby << "#{" " * depth}end\n"
|
|
280
|
+
depth.downto(1) { |index| ruby << "#{" " * (index - 1)}end\n" }
|
|
281
|
+
ruby
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def indent(lines, depth)
|
|
285
|
+
lines.map { |line| line.empty? ? line : "#{" " * depth}#{line}" }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "crumbly"
|
|
4
|
+
|
|
5
|
+
module Crumbly
|
|
6
|
+
# Lists the `breadcrumb` calls of views whose trail the action now derives, for a person to review: a call
|
|
7
|
+
# is only redundant when the derived trail matches the one its crumb drew.
|
|
8
|
+
module DerivedCallSites
|
|
9
|
+
CallSite = Data.define(:path, :line, :code, :reason) do
|
|
10
|
+
def to_s
|
|
11
|
+
"#{path}:#{line}: #{code} (#{reason})"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
BREADCRUMB_CALL = /\bbreadcrumb[ (]\s*(?<target>:\w+|@\w+)(?<arguments>[^\n%}]*)/
|
|
16
|
+
VIEW_GLOB = "**/*.{erb,haml,slim,ruby}"
|
|
17
|
+
FORM_CRUMBS = %w[generic_form new edit].freeze
|
|
18
|
+
|
|
19
|
+
def self.call(*directories)
|
|
20
|
+
directories.flat_map do |directory|
|
|
21
|
+
Dir.glob(VIEW_GLOB, base: directory).sort.flat_map { |path| call_sites(File.join(directory, path)) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.call_sites(path)
|
|
26
|
+
action = File.basename(path).split(".").first
|
|
27
|
+
controller_name = File.basename(File.dirname(path))
|
|
28
|
+
File.foreach(path).with_index(1).filter_map do |line, number|
|
|
29
|
+
match = BREADCRUMB_CALL.match(line)
|
|
30
|
+
reason = match && reason(match[:target], match[:arguments].strip.delete_suffix(")").strip, action, controller_name)
|
|
31
|
+
CallSite.new(path:, line: number, code: match[0].strip, reason:) if reason
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.reason(target, arguments, action, controller_name)
|
|
36
|
+
record = "@#{controller_name.singularize}"
|
|
37
|
+
if FORM_CRUMBS.include?(target.delete_prefix(":"))
|
|
38
|
+
case action
|
|
39
|
+
when "new" then 'new derives "New" under the collection'
|
|
40
|
+
when "edit" then 'edit derives "Edit" under the record'
|
|
41
|
+
else "only new and edit views derive a form's trail"
|
|
42
|
+
end
|
|
43
|
+
elsif action == "show" && (target == record || (target == ":#{controller_name.singularize}" && arguments == ", #{record}"))
|
|
44
|
+
"show derives the crumb of #{record}"
|
|
45
|
+
elsif action == "index" && target == ":#{controller_name}" && arguments.empty?
|
|
46
|
+
"index derives the #{controller_name} crumb"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crumbly
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class DuplicateCrumb < Error; end
|
|
7
|
+
|
|
8
|
+
class UnknownCrumb < Error; end
|
|
9
|
+
|
|
10
|
+
class MissingTrail < Error; end
|
|
11
|
+
|
|
12
|
+
class InvalidTrail < Error; end
|
|
13
|
+
|
|
14
|
+
class RelativeUrl < Error; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/filters"
|
|
4
|
+
require "active_support/core_ext/string/output_safety"
|
|
5
|
+
require "json"
|
|
6
|
+
require "loofah"
|
|
7
|
+
|
|
8
|
+
module Crumbly
|
|
9
|
+
# A schema.org BreadcrumbList, as Google's Rich Results test reads it.
|
|
10
|
+
module JsonLd
|
|
11
|
+
ABSOLUTE_URL_PREFIXES = ["https://", "http://"].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def call(levels)
|
|
15
|
+
list_items = []
|
|
16
|
+
levels.each do |level|
|
|
17
|
+
# Google requires an item on every ListItem but the last: a level without a page is left out.
|
|
18
|
+
next unless level.url || level.current?
|
|
19
|
+
|
|
20
|
+
list_item = {"@type" => "ListItem", "position" => list_items.size + 1, "name" => plain_text(level.label)}
|
|
21
|
+
if level.url
|
|
22
|
+
return relative_url(level) unless level.url.start_with?(*ABSOLUTE_URL_PREFIXES)
|
|
23
|
+
|
|
24
|
+
list_item["item"] = level.url
|
|
25
|
+
end
|
|
26
|
+
list_items << list_item
|
|
27
|
+
end
|
|
28
|
+
return if list_items.empty?
|
|
29
|
+
|
|
30
|
+
{"@context" => "https://schema.org", "@type" => "BreadcrumbList", "itemListElement" => list_items}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def plain_text(label)
|
|
36
|
+
return label.to_s.squish unless label.html_safe?
|
|
37
|
+
|
|
38
|
+
Loofah.html5_fragment(label).scrub!(:prune).text(encode_special_chars: false).squish
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def relative_url(level)
|
|
42
|
+
exception = RelativeUrl.new("JSON-LD needs absolute URLs: #{plain_text(level.label).inspect} links to " \
|
|
43
|
+
"#{level.url}, use a _url helper in its crumb")
|
|
44
|
+
raise exception if Crumbly.raise_on_relative_url?
|
|
45
|
+
|
|
46
|
+
ActiveSupport.error_reporter.report(exception, handled: true)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gretel"
|
|
4
|
+
require "crumbly"
|
|
5
|
+
|
|
6
|
+
module Crumbly
|
|
7
|
+
# Compares gretel's trail with Crumbly's for the same crumbs, from a test of an app that still bundles gretel.
|
|
8
|
+
module Oracle
|
|
9
|
+
Difference = Data.define(:target, :arguments, :gretel, :crumbly, :page) do
|
|
10
|
+
def initialize(target:, arguments:, gretel:, crumbly:, page: nil)
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_s
|
|
15
|
+
call = target ? "breadcrumb #{Oracle.format_call(target, arguments)}" : "no breadcrumb call"
|
|
16
|
+
"#{"#{page}, " if page}#{call}\n" \
|
|
17
|
+
" gretel: #{format_trail(gretel)}\n" \
|
|
18
|
+
" crumbly: #{format_trail(crumbly)}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def format_trail(trail)
|
|
24
|
+
return "(none)" if trail.empty?
|
|
25
|
+
|
|
26
|
+
trail.map { |label, url| url ? "#{label} (#{url})" : label }.join(" > ")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
RedundantCall = Data.define(:page, :target, :arguments) do
|
|
31
|
+
def to_s
|
|
32
|
+
"#{page}, breadcrumb #{Oracle.format_call(target, arguments)}: the action derives the same trail"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Prepended to the view helpers by `watch`: each page the tests render compares its trail with gretel's.
|
|
37
|
+
module Watch
|
|
38
|
+
def breadcrumb_trail
|
|
39
|
+
return super if @crumbly_trail || !Oracle.watching?
|
|
40
|
+
|
|
41
|
+
levels = super
|
|
42
|
+
Oracle.observe(self, @crumbly_breadcrumb, levels)
|
|
43
|
+
levels
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@differences = Set.new
|
|
48
|
+
@redundant_calls = Set.new
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@watching = false
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
attr_reader :differences, :redundant_calls
|
|
54
|
+
|
|
55
|
+
# `cases` lists what views pass to `breadcrumb`: `[[:orders], [:order, order], [order]]`.
|
|
56
|
+
def compare(view, trail_class, cases)
|
|
57
|
+
::Gretel::Crumbs.reload_if_needed
|
|
58
|
+
cases.filter_map do |target, *arguments|
|
|
59
|
+
gretel = gretel_trail(view, target, arguments)
|
|
60
|
+
crumbly = crumbly_trail(view, trail_class, target, arguments)
|
|
61
|
+
Difference.new(target:, arguments:, gretel:, crumbly:) unless gretel == crumbly
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# From then on, or for the block, each trail a view renders is compared with the one gretel draws for its
|
|
66
|
+
# `breadcrumb` call (nothing, without one). The differences go to `differences`, and the calls whose trail
|
|
67
|
+
# the action derives anyway to `redundant_calls`.
|
|
68
|
+
def watch
|
|
69
|
+
ViewHelpers.prepend(Watch) unless ViewHelpers <= Watch
|
|
70
|
+
watching = @watching
|
|
71
|
+
@watching = true
|
|
72
|
+
return unless block_given?
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
yield
|
|
76
|
+
ensure
|
|
77
|
+
@watching = watching
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def watching?
|
|
82
|
+
@watching
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def observe(view, breadcrumb_call, levels)
|
|
86
|
+
::Gretel::Crumbs.reload_if_needed
|
|
87
|
+
target, arguments = breadcrumb_call
|
|
88
|
+
page = "#{view.controller.controller_path}##{view.controller.action_name}"
|
|
89
|
+
gretel = target ? gretel_trail_or_error(view, target, arguments) : []
|
|
90
|
+
difference = Difference.new(target:, arguments:, gretel:, crumbly: flatten(levels), page:)
|
|
91
|
+
redundant = target && derived_trail(view) == gretel
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
differences << difference unless difference.gretel == difference.crumbly
|
|
94
|
+
redundant_calls << RedundantCall.new(page:, target:, arguments:) if redundant
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# gretel renders each link of a crumb as a level of its own; Crumbly keeps the extra ones in `links`.
|
|
99
|
+
def gretel_trail(view, target, arguments)
|
|
100
|
+
links = []
|
|
101
|
+
crumb = ::Gretel::Crumb.new(view, target, *arguments)
|
|
102
|
+
while crumb
|
|
103
|
+
links.unshift(*crumb.links)
|
|
104
|
+
crumb = crumb.parent
|
|
105
|
+
end
|
|
106
|
+
if links.none? { |link| link.key == :root } && ::Gretel::Crumbs.crumb_defined?(:root)
|
|
107
|
+
links.unshift(*::Gretel::Crumb.new(view, :root).links)
|
|
108
|
+
end
|
|
109
|
+
links.map { |link| [link.text.to_s, link.url] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def crumbly_trail(view, trail_class, target, arguments)
|
|
113
|
+
flatten(trail_class.resolve(view, target, *arguments))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def format_call(target, arguments)
|
|
117
|
+
[target, *arguments].map { |argument| format_argument(argument) }.join(", ")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# A call gretel cannot draw (a crumb renamed during the migration) is a difference, not a failing test.
|
|
123
|
+
def gretel_trail_or_error(view, target, arguments)
|
|
124
|
+
gretel_trail(view, target, arguments)
|
|
125
|
+
rescue => exception
|
|
126
|
+
[["gretel raised #{exception.class}: #{exception.message}", nil]]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def flatten(levels)
|
|
130
|
+
levels.flat_map do |level|
|
|
131
|
+
[[level.label.to_s, level.url], *level.links.map { |link| [link.label.to_s, link.url] }]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A derived trail that raises (an action loading no record, say) only means the call has to stay.
|
|
136
|
+
def derived_trail(view)
|
|
137
|
+
flatten(Crumbly.trail_class_for(view.controller.class).derive(view))
|
|
138
|
+
rescue
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_argument(argument)
|
|
143
|
+
argument.respond_to?(:model_name) ? "#<#{argument.class.name} #{argument.to_param}>" : argument.inspect
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crumbly
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "crumbly.helpers" do
|
|
6
|
+
ActiveSupport.on_load(:action_controller_base) { include Crumbly::Controller }
|
|
7
|
+
ActiveSupport.on_load(:action_view) { include Crumbly::ViewHelpers }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "crumbly"
|
|
4
|
+
|
|
5
|
+
module Crumbly
|
|
6
|
+
module TestHelper
|
|
7
|
+
def assert_breadcrumbs_valid(**options)
|
|
8
|
+
dead_crumbs = Crumbly.verify!(**options)
|
|
9
|
+
assert_empty dead_crumbs, "Dead crumbs, called by no crumb, view or controller: #{dead_crumbs.join(", ")}"
|
|
10
|
+
rescue InvalidTrail => exception
|
|
11
|
+
flunk exception.message
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
ActiveSupport.on_load(:active_support_test_case) { include Crumbly::TestHelper }
|