branchout 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/AGENTS.md +38 -0
- data/LICENSE +21 -0
- data/README.ja.md +903 -0
- data/README.md +586 -0
- data/bin/branchout +15 -0
- data/examples/arithmetic.rb +24 -0
- data/examples/hidden_bug.rb +17 -0
- data/examples/inputs/arithmetic.json +10 -0
- data/examples/inputs/arithmetic.seed.json +5 -0
- data/examples/inputs/hidden_bug.json +9 -0
- data/examples/inputs/hidden_bug.seed.json +5 -0
- data/examples/inputs/magic_value.json +7 -0
- data/examples/inputs/magic_value.seed.json +5 -0
- data/examples/inputs/version_parser.json +11 -0
- data/examples/inputs/version_parser.seed.json +5 -0
- data/examples/magic_value.rb +11 -0
- data/examples/version_parser.rb +29 -0
- data/lib/branchout/canon.rb +59 -0
- data/lib/branchout/cli.rb +302 -0
- data/lib/branchout/coverage.rb +9 -0
- data/lib/branchout/coverage_session.rb +49 -0
- data/lib/branchout/emitters/minitest.rb +92 -0
- data/lib/branchout/emitters/rspec.rb +532 -0
- data/lib/branchout/emitters/support.rb +1248 -0
- data/lib/branchout/emitters.rb +101 -0
- data/lib/branchout/engine.rb +303 -0
- data/lib/branchout/explore.rb +124 -0
- data/lib/branchout/input.rb +215 -0
- data/lib/branchout/input_file.rb +125 -0
- data/lib/branchout/mutator.rb +124 -0
- data/lib/branchout/prism_choice.rb +25 -0
- data/lib/branchout/report.rb +449 -0
- data/lib/branchout/rng.rb +31 -0
- data/lib/branchout/runner.rb +591 -0
- data/lib/branchout/sampler.rb +70 -0
- data/lib/branchout/target.rb +696 -0
- data/lib/branchout/version.rb +8 -0
- data/lib/branchout.rb +31 -0
- data/skills/branchout/SKILL.md +230 -0
- data/skills/branchout/inputs.example.json +10 -0
- metadata +114 -0
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
module Branchout
|
|
9
|
+
module Emitters
|
|
10
|
+
# Shared, deterministic rendering used by every characterization emitter.
|
|
11
|
+
module Support
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def header(target, kind, destination)
|
|
15
|
+
[
|
|
16
|
+
"# frozen_string_literal: true",
|
|
17
|
+
"",
|
|
18
|
+
"# Generated by branchout #{VERSION}. Characterization #{kind} for discovered inputs.",
|
|
19
|
+
"# target: #{header_path(target, destination)}",
|
|
20
|
+
"# entry: #{target.entry}"
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The target as the report names it when it is under the working
|
|
25
|
+
# directory. Otherwise it is relative to the root of its project (the
|
|
26
|
+
# nearest directory with a Gemfile, a gemspec, or .git), so a committed
|
|
27
|
+
# file does not carry one machine's path.
|
|
28
|
+
def header_path(target, destination)
|
|
29
|
+
absolute = File.expand_path(target.path)
|
|
30
|
+
return target.display_path unless target.display_path == absolute
|
|
31
|
+
|
|
32
|
+
root = project_root(File.dirname(absolute))
|
|
33
|
+
root ? absolute.delete_prefix("#{root}/") : relative_require(destination, absolute)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def project_root(dir)
|
|
37
|
+
loop do
|
|
38
|
+
marked = %w[Gemfile .git].any? { |name| File.exist?(File.join(dir, name)) } ||
|
|
39
|
+
!Dir.glob("*.gemspec", base: dir).empty?
|
|
40
|
+
return dir if marked
|
|
41
|
+
|
|
42
|
+
parent = File.dirname(dir)
|
|
43
|
+
return nil if parent == dir
|
|
44
|
+
|
|
45
|
+
dir = parent
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The requires of an emitted test: the --require files, then the
|
|
50
|
+
# target. +by_name+ requires each through the load path, named from
|
|
51
|
+
# the first of the -I directories (+load_paths+) and the --require
|
|
52
|
+
# files' directories that holds it, so the test loads whichever
|
|
53
|
+
# checkout the runner's -I names. With neither, the nearest lib
|
|
54
|
+
# directory above the target names it, or the target's own directory,
|
|
55
|
+
# as for a library's entry file. A file under none of them keeps its
|
|
56
|
+
# path, behind a comment that says why. A comment above the requires
|
|
57
|
+
# names, as -I, each directory a require by name needs and the
|
|
58
|
+
# runner does not put on the load path itself (+runner_load_paths+,
|
|
59
|
+
# from +from+, where the runner starts).
|
|
60
|
+
def require_lines(requires, target_path, destination, by_name: false, load_paths: [], runner_load_paths: [], from: Dir.pwd)
|
|
61
|
+
roots = name_roots(requires, target_path, load_paths)
|
|
62
|
+
used = []
|
|
63
|
+
lines = [*requires, *target_path].flat_map do |path|
|
|
64
|
+
absolute = File.expand_path(path)
|
|
65
|
+
root = roots.find { |dir| absolute.start_with?(dir) }
|
|
66
|
+
if by_name && root
|
|
67
|
+
used |= [root]
|
|
68
|
+
next ["require #{absolute.delete_prefix(root).delete_suffix(".rb").inspect}"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
relative = relative_require(destination, path)
|
|
72
|
+
note = "# Required by path: no -I or --require directory holds #{relative}." if by_name
|
|
73
|
+
[*note, "require_relative #{relative.inspect}"]
|
|
74
|
+
end
|
|
75
|
+
used -= runner_load_paths.map { |dir| "#{File.expand_path(dir, from)}/" }
|
|
76
|
+
used.empty? ? lines : ["# Load path: #{used.map { |dir| "-I #{shown_directory(dir.chomp("/"), from: from)}" }.join(" ")}", *lines]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A directory as a person passes it to -I: from the working directory
|
|
80
|
+
# when it is under it, else from its project's root, named after it,
|
|
81
|
+
# so a committed test carries no machine's path where it can avoid one.
|
|
82
|
+
def shown_directory(dir, from: Dir.pwd)
|
|
83
|
+
cwd = "#{File.expand_path(from)}/"
|
|
84
|
+
return dir.delete_prefix(cwd) if dir.start_with?(cwd)
|
|
85
|
+
|
|
86
|
+
root = project_root(dir)
|
|
87
|
+
return dir unless root
|
|
88
|
+
|
|
89
|
+
"#{root == dir ? "." : dir.delete_prefix("#{root}/")} (in #{File.basename(root)})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The files of +requires+ and +target_path+ that --require-by-name
|
|
93
|
+
# cannot name, because no directory in +load_paths+ or of +requires+
|
|
94
|
+
# holds them.
|
|
95
|
+
def unnamed_requires(requires, target_path, load_paths: [])
|
|
96
|
+
roots = require_roots(requires, load_paths)
|
|
97
|
+
return [] if roots.empty?
|
|
98
|
+
|
|
99
|
+
[*requires, *target_path].reject { |path| roots.any? { |dir| File.expand_path(path).start_with?(dir) } }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The files require_lines requires by path that sit in another project
|
|
103
|
+
# than +destination+, or under no project when it has one: their
|
|
104
|
+
# require_relative names this machine's directories, absolute or
|
|
105
|
+
# climbing out of the test's project.
|
|
106
|
+
def required_outside_project(requires, target_path, destination, by_name: false, load_paths: [])
|
|
107
|
+
roots = by_name ? name_roots(requires, target_path, load_paths) : []
|
|
108
|
+
home = project_root(File.dirname(File.expand_path(destination)))
|
|
109
|
+
[*requires, *target_path].map { |path| File.expand_path(path) }.select do |absolute|
|
|
110
|
+
roots.none? { |dir| absolute.start_with?(dir) } &&
|
|
111
|
+
(project_root(File.dirname(absolute)) != home || File.absolute_path?(relative_require(destination, absolute)))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def name_roots(requires, target_path, load_paths)
|
|
116
|
+
roots = require_roots(requires, load_paths)
|
|
117
|
+
roots.empty? && target_path ? [load_root(target_path)] : roots
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def require_roots(requires, load_paths)
|
|
121
|
+
[*load_paths, *requires.map { |path| File.dirname(path) }].map { |dir| "#{File.expand_path(dir)}/" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def load_root(path)
|
|
125
|
+
dir = File.dirname(File.expand_path(path))
|
|
126
|
+
lib = dir.split("/").rindex("lib")
|
|
127
|
+
"#{lib ? dir.split("/")[0..lib].join("/") : dir}/"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Paths that share only / give the absolute path: a climb to / breaks
|
|
131
|
+
# as soon as either file moves, and require_relative accepts it.
|
|
132
|
+
def relative_require(destination, target_path)
|
|
133
|
+
from = Pathname.new(File.expand_path(File.dirname(destination)))
|
|
134
|
+
to = Pathname.new(File.expand_path(target_path))
|
|
135
|
+
return to.to_s if from.each_filename.first != to.each_filename.first
|
|
136
|
+
|
|
137
|
+
relative = to.relative_path_from(from).to_s.tr("\\", "/")
|
|
138
|
+
relative.start_with?(".") ? relative : "./#{relative}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# +input+ with each String that names a path under the project of
|
|
142
|
+
# +destination+ as a ProjectPath. A String that File.expand_path
|
|
143
|
+
# would not give back as written, such as one with a trailing /, stays.
|
|
144
|
+
def project_paths(input, destination)
|
|
145
|
+
root = project_root(File.dirname(File.expand_path(destination)))
|
|
146
|
+
return input unless root
|
|
147
|
+
|
|
148
|
+
rewrite = lambda do |value|
|
|
149
|
+
case value
|
|
150
|
+
when ProjectPath then value
|
|
151
|
+
when String then project_path(value, destination, root) || value
|
|
152
|
+
when Array then value.map(&rewrite)
|
|
153
|
+
when Hash then value.transform_values(&rewrite)
|
|
154
|
+
else value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
receiver = input.receiver.is_a?(Input) ? project_paths(input.receiver, destination) : input.receiver
|
|
158
|
+
setup = input.setup.map { |call| Input::SetupCall.new(call.method_name, project_paths(call.arguments, destination)) }
|
|
159
|
+
Input.new(args: rewrite.(input.args), kwargs: rewrite.(input.kwargs), receiver: receiver, setup: setup,
|
|
160
|
+
pin_keys: input.pin_keys, block: input.block, before: input.before)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# +value+, a returned value or an error message, with each String
|
|
164
|
+
# that holds a path under the project of +destination+ written from
|
|
165
|
+
# the test's directory: a String that is one such path as a
|
|
166
|
+
# ProjectPath, and one that holds such paths among other text as a
|
|
167
|
+
# ProjectText.
|
|
168
|
+
def project_value(value, destination)
|
|
169
|
+
root = project_root(File.dirname(File.expand_path(destination)))
|
|
170
|
+
return value unless root
|
|
171
|
+
|
|
172
|
+
rewrite = lambda do |item|
|
|
173
|
+
case item
|
|
174
|
+
when ProjectPath, ProjectText then item
|
|
175
|
+
when String
|
|
176
|
+
next item unless item.include?("#{root}/")
|
|
177
|
+
|
|
178
|
+
project_path(item, destination, root) ||
|
|
179
|
+
ProjectText.new(item.split("#{root}/", -1), from_test(destination, root))
|
|
180
|
+
when Array then item.map(&rewrite)
|
|
181
|
+
when Hash then item.transform_values(&rewrite)
|
|
182
|
+
else item
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
rewrite.(value)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# +value+ as a ProjectPath when it is one path under +root+ that
|
|
189
|
+
# File.expand_path gives back as written (a trailing / it would not).
|
|
190
|
+
def project_path(value, destination, root)
|
|
191
|
+
return nil unless value.start_with?("#{root}/")
|
|
192
|
+
|
|
193
|
+
relative = from_test(destination, value)
|
|
194
|
+
File.expand_path(relative, File.dirname(File.expand_path(destination))) == value ? ProjectPath.new(value.delete_prefix("#{root}/"), relative) : nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# File.expand_path reads a leading ~ as a home directory.
|
|
198
|
+
def from_test(destination, path)
|
|
199
|
+
relative_require(destination, path).sub(%r{\A\./(?!~)}, "")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The paths of this machine, under the home or the temporary
|
|
203
|
+
# directory, that the Strings of +value+ hold anywhere in their text.
|
|
204
|
+
def machine_paths_in(value)
|
|
205
|
+
homes = machine_homes.map { |home| Regexp.escape(home) }.join("|")
|
|
206
|
+
strings(value).flat_map { |text| text.scan(%r{(?:#{homes})[^\s"'`(),;]+}) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def machine_homes
|
|
210
|
+
[Dir.tmpdir, Dir.home].flat_map { |dir| ["#{dir}/", "#{File.realpath(dir)}/"] }.uniq
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The Strings of +input+ that are a path of this machine: one under
|
|
214
|
+
# the home or the temporary directory, whether it exists or not.
|
|
215
|
+
# /dev/null, or /tmp itself, is the same on every machine.
|
|
216
|
+
def machine_paths(input)
|
|
217
|
+
strings(input).select do |value|
|
|
218
|
+
File.absolute_path?(value) && machine_homes.any? { |home| value.start_with?(home) }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def strings(value)
|
|
223
|
+
case value
|
|
224
|
+
when ProjectPath, ProjectText then []
|
|
225
|
+
when String then [value]
|
|
226
|
+
when Array then value.flat_map { |item| strings(item) }
|
|
227
|
+
when Hash then strings(value.values)
|
|
228
|
+
when Input
|
|
229
|
+
[value.args, value.kwargs, value.receiver, *value.setup.map(&:arguments)].flat_map { |item| strings(item) }
|
|
230
|
+
else []
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The statements that build the receiver, and the entry call. With no
|
|
235
|
+
# setup calls the receiver is built inline and there is no statement.
|
|
236
|
+
# A name in +private_calls+ (the entry, or new) is called through send,
|
|
237
|
+
# as explore called it. +const+ is the class as the test writes it.
|
|
238
|
+
def arrange(target, input, private_calls: [], const: target.const_name, thawed: false)
|
|
239
|
+
receiver = if target.singleton
|
|
240
|
+
const
|
|
241
|
+
else
|
|
242
|
+
receiver_text(input, const, private_calls, thawed: thawed)
|
|
243
|
+
end
|
|
244
|
+
call = ->(on) { with_block(call_text(on, target.method_name, argument_list(input, thawed: thawed), private_calls), input.block) }
|
|
245
|
+
before = input.before ? input.before.source.lines(chomp: true) : []
|
|
246
|
+
return [before, call.(receiver)] if input.setup.empty? && !input.receiver.is_a?(Code)
|
|
247
|
+
|
|
248
|
+
name = input.receiver.is_a?(Code) ? built_receiver_name(target) : receiver_name(target)
|
|
249
|
+
statements = [*before, "#{name} = #{receiver}"]
|
|
250
|
+
input.setup.each do |setup_call|
|
|
251
|
+
statements << call_text(name, setup_call.method_name, argument_list(setup_call.arguments, thawed: thawed), [])
|
|
252
|
+
end
|
|
253
|
+
[statements, call.(name)]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# How the receiver of an instance method's input is built: the Code
|
|
257
|
+
# the input gives, or new with the input's arguments for it.
|
|
258
|
+
def receiver_text(input, const, private_calls, thawed: false)
|
|
259
|
+
return input.receiver.source if input.receiver.is_a?(Code)
|
|
260
|
+
|
|
261
|
+
call_text(const, :new, input.receiver ? argument_list(input.receiver, thawed: thawed) : "", private_calls)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def call_text(on, method_name, arguments, private_calls)
|
|
265
|
+
# A setter or an operator goes through send: "cart.limit=" and
|
|
266
|
+
# "cart.limit=(a: 1)" do not parse, and "cart.limit=()" passes nil.
|
|
267
|
+
plain = method_name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/)
|
|
268
|
+
if plain && !private_calls.include?(method_name)
|
|
269
|
+
return arguments.empty? ? "#{on}.#{method_name}" : "#{on}.#{method_name}(#{arguments})"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
"#{on}.send(#{[method_name.to_sym.inspect, *(arguments unless arguments.empty?)].join(", ")})"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def with_block(call, block)
|
|
276
|
+
block ? "#{call} #{block.source}" : call
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The name a receiver built by the input's Ruby takes in every format,
|
|
280
|
+
# which RSpec's own method names cannot take, so that the input's
|
|
281
|
+
# arguments can name it while explore runs and in the emitted test.
|
|
282
|
+
def built_receiver_name(target)
|
|
283
|
+
name = receiver_name(target)
|
|
284
|
+
Emitters::RSpec::TAKEN_NAMES.include?(name) ? "object" : name
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The class name in snake case, as a reader would name the object.
|
|
288
|
+
def receiver_name(target)
|
|
289
|
+
name = target.const_name.split("::").last
|
|
290
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
291
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
292
|
+
.downcase
|
|
293
|
+
RESERVED_NAMES.include?(name) ? "subject" : name
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
RESERVED_NAMES = %w[
|
|
297
|
+
alias and begin break case class def defined do else elsif end ensure false for if in
|
|
298
|
+
module next nil not or redo rescue retry return self super then true undef unless until
|
|
299
|
+
when while yield error
|
|
300
|
+
].freeze
|
|
301
|
+
|
|
302
|
+
def argument_list(input, thawed: false)
|
|
303
|
+
args = input.args.map { |value| ruby_literal(value, thawed: thawed) }
|
|
304
|
+
kwargs = input.kwargs.keys.map { |key|
|
|
305
|
+
"#{keyword_literal(key)}: #{ruby_literal(input.kwargs[key], thawed: thawed)}"
|
|
306
|
+
}
|
|
307
|
+
(args + kwargs).join(", ")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# An emitted test has frozen_string_literal, so a +thawed+ string is
|
|
311
|
+
# written with a leading + to match the copy the explore run passed.
|
|
312
|
+
def ruby_literal(value, thawed: false)
|
|
313
|
+
case value
|
|
314
|
+
when nil then "nil"
|
|
315
|
+
when true then "true"
|
|
316
|
+
when false then "false"
|
|
317
|
+
when Integer then value.to_s
|
|
318
|
+
when Float then Canon.format_float(value)
|
|
319
|
+
when ProjectPath then "File.expand_path(#{value.from_test.inspect}, __dir__)"
|
|
320
|
+
when ProjectText
|
|
321
|
+
root = "\#{File.expand_path(#{value.root_from_test.inspect}, __dir__)}/"
|
|
322
|
+
%("#{value.pieces.map { |piece| piece.inspect[1...-1] }.join(root)}")
|
|
323
|
+
when String then thawed ? "+#{value.inspect}" : value.inspect
|
|
324
|
+
when Symbol then value.inspect
|
|
325
|
+
when Code then value.source
|
|
326
|
+
when Struct, Data
|
|
327
|
+
members = value.to_h.map { |key, item| positional?(value) ? ruby_literal(item, thawed: thawed) : "#{keyword_literal(key)}: #{ruby_literal(item, thawed: thawed)}" }
|
|
328
|
+
"#{value.class.name}.new(#{members.join(", ")})"
|
|
329
|
+
when Set then "Set[#{value.map { |item| ruby_literal(item, thawed: thawed) }.join(", ")}]"
|
|
330
|
+
when Array
|
|
331
|
+
"[#{value.map { |item| ruby_literal(item, thawed: thawed) }.join(", ")}]"
|
|
332
|
+
when Hash
|
|
333
|
+
# Insertion order, as the value prints: a failure diff then lines
|
|
334
|
+
# up key by key, and Hash equality ignores order anyway.
|
|
335
|
+
value.empty? ? "{}" : "{ #{value.map { |key, item| "#{hash_key_literal(key)} #{ruby_literal(item, thawed: thawed)}" }.join(", ")} }"
|
|
336
|
+
else
|
|
337
|
+
return "Date.new(#{value.year}, #{value.mon}, #{value.mday})" if plain_date?(value)
|
|
338
|
+
|
|
339
|
+
raise Error, "cannot emit a literal for #{value.class}"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# A Regexp literal for a String that quotes a backtrace frame, whose
|
|
344
|
+
# label Ruby 3.4 writes in another form than 3.3, or nil.
|
|
345
|
+
def frame_pattern(value)
|
|
346
|
+
source = value.is_a?(String) && Runner::ExceptionInfo.frame_pattern(value)
|
|
347
|
+
Regexp.new(source).inspect if source
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def literal?(value)
|
|
351
|
+
case value
|
|
352
|
+
when nil, true, false, Integer, String, Symbol then true
|
|
353
|
+
when Float then value.finite?
|
|
354
|
+
when Array, Set then value.all? { |item| literal?(item) }
|
|
355
|
+
when Hash then value.all? { |key, item| literal?(key) && literal?(item) }
|
|
356
|
+
when Code then false
|
|
357
|
+
when Struct, Data then value_object?(value)
|
|
358
|
+
else plain_date?(value)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# A Date of the default calendar reads back from Date.new(year, month,
|
|
363
|
+
# day). A DateTime, or a Date of another calendar reform, does not.
|
|
364
|
+
def plain_date?(value)
|
|
365
|
+
defined?(::Date) && value.instance_of?(::Date) && value.start == ::Date::ITALY
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# A Data or a Struct reads back from its class's new when its class's
|
|
369
|
+
# name leads to it, each member has a literal, and new with those
|
|
370
|
+
# members makes an equal value: an initialize of its own may change
|
|
371
|
+
# what it is given.
|
|
372
|
+
def value_object?(value)
|
|
373
|
+
name = value.class.name
|
|
374
|
+
return false unless name && Object.const_get(name).equal?(value.class)
|
|
375
|
+
return false unless value.to_h.each_value.all? { |item| literal?(item) }
|
|
376
|
+
|
|
377
|
+
positional?(value) ? value.class.new(*value.to_a) == value : value.class.new(**value.to_h) == value
|
|
378
|
+
rescue StandardError
|
|
379
|
+
false
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# A Struct made with keyword_init: false takes its members by position.
|
|
383
|
+
def positional?(value)
|
|
384
|
+
value.is_a?(Struct) && value.class.keyword_init? == false
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# A value with no Ruby literal, such as a Set or a Struct, is pinned by
|
|
388
|
+
# its class. An anonymous class has no stable name, so its nearest
|
|
389
|
+
# named superclass stands in.
|
|
390
|
+
def class_literal(value)
|
|
391
|
+
value.class.ancestors.find { |mod| mod.is_a?(Class) && mod.name }.name
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# A Symbol key takes the key: form, as the keyword of a call does.
|
|
395
|
+
def hash_key_literal(key)
|
|
396
|
+
key.is_a?(Symbol) ? "#{keyword_literal(key)}:" : "#{ruby_literal(key)} =>"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def keyword_literal(key)
|
|
400
|
+
text = key.to_s
|
|
401
|
+
return text if text.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/)
|
|
402
|
+
|
|
403
|
+
text.inspect
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def class_name_for(target)
|
|
407
|
+
const_part = target.const_name.gsub("::", "")
|
|
408
|
+
method_part = target.method_name.to_s.sub(/[?!=]\z/, "?" => "_predicate", "!" => "_bang", "=" => "_writer")
|
|
409
|
+
method_part = method_part.split("_").map { |word| word.sub(/\A[a-z]/, &:upcase) }.join
|
|
410
|
+
"#{const_part}#{method_part}CharacterizationTest"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Minitest method names: the input's values, then what the test pins.
|
|
414
|
+
# Siblings, inputs whose names would be the same, are named by the
|
|
415
|
+
# leaves that differ between them instead, so a name tells them apart
|
|
416
|
+
# and adding an input can rename its siblings. A name that still
|
|
417
|
+
# collides gets six hex digits of its input's digest, and a digest
|
|
418
|
+
# collision a counter.
|
|
419
|
+
def test_names(pins, pin_keys = [])
|
|
420
|
+
pinned = pins.map { |pin| pinned_pin(pin, pin_keys) }
|
|
421
|
+
differences = hash_differences(pinned.map { |pin| pin.value unless pin.error }, pins.map(&:input))
|
|
422
|
+
# A Hash outcome may take more room, so the leaves RSpec names fit, and
|
|
423
|
+
# so may an Array of scalars and collections, so its keys fit after
|
|
424
|
+
# "array".
|
|
425
|
+
outcomes = pinned.zip(differences).map do |pin, differing|
|
|
426
|
+
roomy = !differing.empty? || (!pin.error && mixed_array?(pin.value))
|
|
427
|
+
clip(outcome_words(pin, differing), roomy ? OUTCOME_LIMIT : 30)
|
|
428
|
+
end
|
|
429
|
+
names = pins.each_with_index.map { |pin, index| name_of(input_words(pin.input), outcomes[index]) }
|
|
430
|
+
names.each_index.group_by { |index| names[index] }.each_value do |siblings|
|
|
431
|
+
next if siblings.one?
|
|
432
|
+
|
|
433
|
+
words = differing_words(siblings.map { |index| pins[index].input })
|
|
434
|
+
siblings.zip(words) { |index, differing| names[index] = name_of(differing, outcomes[index]) }
|
|
435
|
+
end
|
|
436
|
+
repeated = names.tally
|
|
437
|
+
names = names.each_with_index.map do |name, index|
|
|
438
|
+
repeated[name] > 1 ? "#{name}_#{Digest::SHA256.hexdigest(pins[index].input.canonical)[0, 6]}" : name
|
|
439
|
+
end
|
|
440
|
+
seen = Hash.new(0)
|
|
441
|
+
names.map { |name| (seen[name] += 1) == 1 ? name : "#{name}_#{seen[name]}" }
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def name_of(input_words, outcome)
|
|
445
|
+
["test", clip(input_words, 40), outcome].reject(&:empty?).join("_")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# For each input, the words of every leaf whose value is not the same
|
|
449
|
+
# in all +inputs+: the deepest key above it, then its value, or "none"
|
|
450
|
+
# where the input has no such leaf. Strings at one leaf drop the words
|
|
451
|
+
# they all start with, so a shared start does not fill the name.
|
|
452
|
+
# Leaves keep their order in the first input by canonical form, so the
|
|
453
|
+
# words do not depend on the order of +inputs+.
|
|
454
|
+
def differing_words(inputs)
|
|
455
|
+
leaves = inputs.map { |input| input_leaves(input).to_h }
|
|
456
|
+
paths = inputs.zip(leaves).sort_by { |input, _| input.canonical }.flat_map { |_, map| map.keys }.uniq
|
|
457
|
+
differing = paths.reject do |path|
|
|
458
|
+
leaves.all? { |map| map.key?(path) && map[path].eql?(leaves.first[path]) }
|
|
459
|
+
end
|
|
460
|
+
shared = differing.to_h { |path| [path, shared_start(leaves.select { |map| map.key?(path) }.map { |map| map[path] })] }
|
|
461
|
+
leaves.map do |map|
|
|
462
|
+
differing.flat_map do |path|
|
|
463
|
+
# An input that has an empty collection above this leaf, or
|
|
464
|
+
# items below it, does have the key; its own leaf names it.
|
|
465
|
+
next [] if !map.key?(path) && map.keys.any? { |other| other.first(path.length) == path || path.first(other.length) == other }
|
|
466
|
+
# So does one that holds another shape under the leaf's last key.
|
|
467
|
+
# A Symbol key and a String key of one name are the one key a
|
|
468
|
+
# name can say.
|
|
469
|
+
held = path.rindex { |step| key_step?(step) }
|
|
470
|
+
above = held && path.first(held + 1).map { |step| plain_step(step) }
|
|
471
|
+
next [] if !map.key?(path) && held && map.keys.any? { |other| other.first(held + 1).map { |step| plain_step(step) } == above }
|
|
472
|
+
# An item of an Array has no key to say "none" after, so a Hash
|
|
473
|
+
# that the input holds in that Array's place names it.
|
|
474
|
+
parent = path[0...-1]
|
|
475
|
+
next [] if !map.key?(path) && path.last.is_a?(Integer) &&
|
|
476
|
+
map.keys.any? { |other| other.first(parent.length) == parent && key_step?(other[parent.length]) }
|
|
477
|
+
|
|
478
|
+
key = telling_key(path, paths)
|
|
479
|
+
# Ruby source keeps the word before the part, as the name it
|
|
480
|
+
# compares (limit_100), where a String drops its whole shared start.
|
|
481
|
+
kept = map[path].is_a?(Code) ? [shared[path] - 1, 0].max : shared[path]
|
|
482
|
+
rest = worded?(map[path]) ? tokens(map[path]).drop(kept) : []
|
|
483
|
+
value = if !map.key?(path) then ["none"]
|
|
484
|
+
elsif map[path] == {} || map[path] == [] then ["empty"]
|
|
485
|
+
elsif !rest.empty? then rest
|
|
486
|
+
else value_words(map[path])
|
|
487
|
+
end
|
|
488
|
+
key ? [key, *value] : value
|
|
489
|
+
end.then { |words| without_repeated_none(words) }
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# A key that several leaves of an absent collection end in says
|
|
494
|
+
# "none" once, and so do the missing items at the end of a shorter
|
|
495
|
+
# Array, which have no key.
|
|
496
|
+
def without_repeated_none(words)
|
|
497
|
+
out = []
|
|
498
|
+
index = 0
|
|
499
|
+
while index < words.length
|
|
500
|
+
if words[index].is_a?(Key) && words[index + 1] == "none"
|
|
501
|
+
pair = words[index, 2]
|
|
502
|
+
out.concat(pair) unless out.each_cons(2).include?(pair)
|
|
503
|
+
index += 2
|
|
504
|
+
elsif words[index] == "none" && out.last == "none" && !out[-2].is_a?(Key)
|
|
505
|
+
index += 1
|
|
506
|
+
else
|
|
507
|
+
out << words[index]
|
|
508
|
+
index += 1
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
out
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# A String, or the Ruby source of a receiver or argument, reads as words.
|
|
515
|
+
def worded?(value) = value.is_a?(String) || value.is_a?(Code)
|
|
516
|
+
|
|
517
|
+
# How many leading words all of +values+ share, when they read as words.
|
|
518
|
+
def shared_start(values)
|
|
519
|
+
return 0 unless values.all? { |value| worded?(value) }
|
|
520
|
+
|
|
521
|
+
words = values.map { |value| tokens(value) }
|
|
522
|
+
words.first.each_index.take_while { |index| words.all? { |list| list[index] == words.first[index] } }.length
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# [path, value] for every scalar or empty collection of an input. A
|
|
526
|
+
# Symbol step names a part of the input, a String a String Hash key, a
|
|
527
|
+
# SymbolKey a Symbol Hash key, and an Integer a position.
|
|
528
|
+
def input_leaves(input)
|
|
529
|
+
out = []
|
|
530
|
+
input.args.each_with_index { |arg, index| collect_leaves(arg, [:args, index], out) }
|
|
531
|
+
input.kwargs.each { |key, value| collect_leaves(value, [:kwargs, key], out) }
|
|
532
|
+
if input.receiver.is_a?(Code)
|
|
533
|
+
out << [[:receiver], input.receiver]
|
|
534
|
+
elsif input.receiver
|
|
535
|
+
input_leaves(input.receiver).each { |path, value| out << [[:new, *path], value] }
|
|
536
|
+
end
|
|
537
|
+
input.setup.each_with_index do |call, index|
|
|
538
|
+
out << [[:setup, index, :method], call.method_name]
|
|
539
|
+
input_leaves(call.arguments).each { |path, value| out << [[:setup, index, *path], value] }
|
|
540
|
+
end
|
|
541
|
+
out << [[:block], input.block] if input.block
|
|
542
|
+
out << [[:before], input.before] if input.before
|
|
543
|
+
out
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# +whole_arrays+ keeps an Array of scalars as one leaf, so a reader
|
|
547
|
+
# sees items is [1, 2] and not each item by its position.
|
|
548
|
+
def collect_leaves(value, path, out, whole_arrays: false)
|
|
549
|
+
return out << [path, value] if path.last.is_a?(TapBlock)
|
|
550
|
+
|
|
551
|
+
value = written_value(value)
|
|
552
|
+
children = case value
|
|
553
|
+
when Code then call_parts(value)
|
|
554
|
+
when Hash then value.map { |key, item| [key.is_a?(Symbol) ? SymbolKey.new(key.to_s) : key.to_s, item] }
|
|
555
|
+
when Array
|
|
556
|
+
whole_arrays && value.none? { |item| collection?(item) } ? [] : value.each_with_index.map { |item, index| [index, item] }
|
|
557
|
+
else []
|
|
558
|
+
end
|
|
559
|
+
return out << [path, value] if children.empty?
|
|
560
|
+
|
|
561
|
+
children.each { |step, item| collect_leaves(item, path + [step], out, whole_arrays: whole_arrays) }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# What each pin's test pins, as RSpec says it: "raises KeyError",
|
|
565
|
+
# "returns :miss". A Hash says the scalar entries that differ between
|
|
566
|
+
# the Hashes of +pins+, so siblings say how their results differ.
|
|
567
|
+
# +short+ names a leaf by its deepest key (discarded: 1).
|
|
568
|
+
def outcomes(pins, pin_keys = [], short: false)
|
|
569
|
+
pinned = pins.map { |pin| pinned_pin(pin, pin_keys) }
|
|
570
|
+
differences = hash_differences(pinned.map { |pin| pin.value unless pin.error }, pins.map(&:input))
|
|
571
|
+
pins.each_index.to_h { |index| [pins[index], outcome_text(pinned[index], differences[index], short: short)] }
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# The entries of a returned Hash whose keys +pin_keys+ names, by
|
|
575
|
+
# name, so a key added later fails no test. A value that is not a
|
|
576
|
+
# Hash, or a Hash with none of the keys, is pinned whole.
|
|
577
|
+
def pinned_value(value, pin_keys)
|
|
578
|
+
return value if pin_keys.empty? || !value.is_a?(Hash)
|
|
579
|
+
|
|
580
|
+
chosen = value.select { |key, _| pin_keys.include?(key.to_s) }
|
|
581
|
+
chosen.empty? ? value : chosen
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def pinned_pin(pin, pin_keys)
|
|
585
|
+
pin.error ? pin : pin.dup.tap { |copy| copy.value = pinned_value(pin.value, keys_for(pin, pin_keys)) }
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# The keys that pin +pin+'s Hash: its input's own, or else +pin_keys+,
|
|
589
|
+
# without the keys whose values differed between two runs.
|
|
590
|
+
def keys_for(pin, pin_keys)
|
|
591
|
+
keys = pin.input.pin_keys || pin_keys
|
|
592
|
+
return keys unless pin.varying.is_a?(Array)
|
|
593
|
+
|
|
594
|
+
stable = pin.value.keys.map(&:to_s) - pin.varying
|
|
595
|
+
chosen = keys & stable
|
|
596
|
+
chosen.empty? ? stable : chosen
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def outcome_text(pin, differing, short: false)
|
|
600
|
+
return "raises #{pin.error.class_name}" if pin.error
|
|
601
|
+
|
|
602
|
+
value = pin.value
|
|
603
|
+
unless differing.empty?
|
|
604
|
+
entries = differing.map do |path, item|
|
|
605
|
+
[short ? telling_path(path, differing.map(&:first)) : path, ruby_literal(described(item))]
|
|
606
|
+
end
|
|
607
|
+
return "returns #{entries_text(entries, merge: !short)}"
|
|
608
|
+
end
|
|
609
|
+
unless literal?(value)
|
|
610
|
+
return "returns an Array of #{value.length}" if value.is_a?(Array)
|
|
611
|
+
return "returns a Hash of #{value.keys.map(&:to_s).join(", ")}" if keyed?(value)
|
|
612
|
+
|
|
613
|
+
return "returns #{article(class_literal(value))}"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
text = ruby_literal(described(value))
|
|
617
|
+
return "returns #{text}" if text.length <= 40
|
|
618
|
+
|
|
619
|
+
# A String or an Array of scalars says its start, as the minitest
|
|
620
|
+
# name says its first words.
|
|
621
|
+
case value
|
|
622
|
+
when Hash then "returns a Hash of #{value.keys.map(&:to_s).join(", ")}"
|
|
623
|
+
when Array
|
|
624
|
+
if value.all? { |item| composite?(item) } then "returns an Array of #{value.length}"
|
|
625
|
+
elsif value.any? { |item| composite?(item) } then "returns [#{value.map { |item| item_text(item) }.join(", ")}]"
|
|
626
|
+
else "returns #{text[0, 37]}..."
|
|
627
|
+
end
|
|
628
|
+
when String then "returns #{text[0...word_end(text, 0, 37)]}..."
|
|
629
|
+
when Struct, Data then "returns #{article(value.class.name)}"
|
|
630
|
+
else "returns #{text}"
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Where a cut of +text+ from +start+ to +finish+ ends: at +finish+,
|
|
635
|
+
# or, when that falls inside a word, before the word, unless that
|
|
636
|
+
# leaves fewer than 12 characters.
|
|
637
|
+
def word_end(text, start, finish)
|
|
638
|
+
return finish unless finish < text.length && text[finish - 1].match?(/\w/) && text[finish].match?(/\w/)
|
|
639
|
+
|
|
640
|
+
boundary = text.rindex(/\W/, finish - 1)
|
|
641
|
+
boundary && boundary - start >= 12 ? boundary : finish
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# An item of an Array that holds both scalars and collections: a
|
|
645
|
+
# scalar as written, and a collection by its keys or its size.
|
|
646
|
+
def item_text(item)
|
|
647
|
+
case item
|
|
648
|
+
when Hash then item.empty? ? "{}" : "a Hash of #{item.keys.map(&:to_s).join(", ")}"
|
|
649
|
+
when Array then item.empty? ? "[]" : "an Array of #{item.length}"
|
|
650
|
+
when Struct, Data then article(item.class.name)
|
|
651
|
+
else ruby_literal(item)
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# An RSpec matcher argument for a value with no literal: a Struct or
|
|
656
|
+
# Data by its class and members, an Array or Hash by its items, and
|
|
657
|
+
# anything else by its class.
|
|
658
|
+
def shape_matcher(value)
|
|
659
|
+
case value
|
|
660
|
+
when Struct, Data
|
|
661
|
+
members = value.to_h.map { |key, item| "#{key}: #{literal?(item) ? ruby_literal(item) : shape_matcher(item)}" }
|
|
662
|
+
"a_kind_of(#{class_literal(value)}).and(have_attributes(#{members.join(", ")}))"
|
|
663
|
+
when Array then "[#{value.map { |item| literal?(item) ? ruby_literal(item) : shape_matcher(item) }.join(", ")}]"
|
|
664
|
+
# RSpec finds each expected key in the Hash, so a key with no
|
|
665
|
+
# literal leaves the Hash pinned by its class, as class_pins says.
|
|
666
|
+
when Hash
|
|
667
|
+
return "a_kind_of(Hash)" unless value.each_key.all? { |key| literal?(key) }
|
|
668
|
+
|
|
669
|
+
entries = value.map { |key, item| "#{hash_key_literal(key)} #{literal?(item) ? ruby_literal(item) : shape_matcher(item)}" }
|
|
670
|
+
entries.empty? ? "{}" : "{ #{entries.join(", ")} }"
|
|
671
|
+
else "a_kind_of(#{class_literal(value)})"
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Minitest assertions on the returned +value+, held in a local named
|
|
676
|
+
# value, that check what shape_matcher checks: each part with a
|
|
677
|
+
# literal by its value, and each part class_pins names by its class.
|
|
678
|
+
# A Hash or an Array is compared whole, each part with no literal
|
|
679
|
+
# standing in as itself, so a missing key or item fails too.
|
|
680
|
+
def shape_assertions(value, path = "")
|
|
681
|
+
return [literal_assertion(value, "value#{path}")] if literal?(value)
|
|
682
|
+
|
|
683
|
+
case value
|
|
684
|
+
when Hash, Array
|
|
685
|
+
return ["assert_kind_of Hash, value#{path}"] if value.is_a?(Hash) && !value.each_key.all? { |key| literal?(key) }
|
|
686
|
+
|
|
687
|
+
parts = []
|
|
688
|
+
whole = shape_template(value, path, parts)
|
|
689
|
+
[*parts, "assert_equal(#{whole}, value#{path})"]
|
|
690
|
+
when Struct, Data
|
|
691
|
+
members = value.to_h
|
|
692
|
+
literal, other = members.partition { |_, item| literal?(item) }.map(&:to_h)
|
|
693
|
+
lines = ["assert_kind_of #{class_literal(value)}, value#{path}"]
|
|
694
|
+
unless literal.empty?
|
|
695
|
+
keys = literal.keys.map { |key| ruby_literal(key) }.join(", ")
|
|
696
|
+
lines << "assert_equal(#{ruby_literal(literal)}, value#{path}.to_h.slice(#{keys}))"
|
|
697
|
+
end
|
|
698
|
+
lines + other.flat_map { |key, item| shape_assertions(item, "#{path}.#{key}") }
|
|
699
|
+
else ["assert_kind_of #{class_literal(value)}, value#{path}"]
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Minitest 6 fails assert_equal with an expected nil.
|
|
704
|
+
def literal_assertion(value, actual)
|
|
705
|
+
return "assert_nil #{actual}" if value.nil?
|
|
706
|
+
|
|
707
|
+
"assert_equal(#{ruby_literal(value)}, #{actual})"
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# The literal of a Hash or an Array whose parts with no literal are
|
|
711
|
+
# read from the returned value, so comparing it with the value checks
|
|
712
|
+
# the rest; +parts+ gets the assertions on those parts.
|
|
713
|
+
def shape_template(value, path, parts)
|
|
714
|
+
return ruby_literal(value) if literal?(value)
|
|
715
|
+
|
|
716
|
+
case value
|
|
717
|
+
when Array then "[#{value.each_with_index.map { |item, index| shape_template(item, "#{path}[#{index}]", parts) }.join(", ")}]"
|
|
718
|
+
when Hash
|
|
719
|
+
if value.each_key.all? { |key| literal?(key) }
|
|
720
|
+
entries = value.map { |key, item| "#{hash_key_literal(key)} #{shape_template(item, "#{path}[#{ruby_literal(key)}]", parts)}" }
|
|
721
|
+
return entries.empty? ? "{}" : "{ #{entries.join(", ")} }"
|
|
722
|
+
end
|
|
723
|
+
parts.concat(shape_assertions(value, path))
|
|
724
|
+
"value#{path}"
|
|
725
|
+
else
|
|
726
|
+
parts.concat(shape_assertions(value, path))
|
|
727
|
+
"value#{path}"
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# [path, class name] for each part of +value+ that has no literal, as
|
|
732
|
+
# the RSpec matcher reaches it: into a Hash, an Array, and the members
|
|
733
|
+
# of a Struct or Data, and no further.
|
|
734
|
+
def class_pins(value, path = "")
|
|
735
|
+
return [] if literal?(value)
|
|
736
|
+
|
|
737
|
+
case value
|
|
738
|
+
when Hash
|
|
739
|
+
value.flat_map { |key, item| literal?(key) ? class_pins(item, "#{path}[#{ruby_literal(key)}]") : [[path, class_literal(value)]] }.uniq
|
|
740
|
+
when Array then value.each_with_index.flat_map { |item, index| class_pins(item, "#{path}[#{index}]") }
|
|
741
|
+
when Struct, Data then value.to_h.flat_map { |key, item| class_pins(item, "#{path}.#{key}") }
|
|
742
|
+
else [[path, class_literal(value)]]
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def article(name)
|
|
747
|
+
name.match?(/\A[AEIOU]/) ? "an #{name}" : "a #{name}"
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# For each of +values+, the [path, leaf] entries of a Hash at the
|
|
751
|
+
# paths chosen to tell its siblings apart: the fewest leaves, nested
|
|
752
|
+
# ones too, that tell every two different Hashes of +values+ apart, at
|
|
753
|
+
# most three. Each pick is a leaf that does not repeat its input,
|
|
754
|
+
# then a leaf every Hash has, when one tells a pair apart, then tells
|
|
755
|
+
# the most pairs apart, then has the most distinct values. A pick that would make a minitest outcome longer
|
|
756
|
+
# than OUTCOME_LIMIT is left out, so both formats name the same
|
|
757
|
+
# leaves. A value that is not a Hash has none.
|
|
758
|
+
def hash_differences(values, inputs = [])
|
|
759
|
+
# A part with no literal is checked by its class, so it tells no
|
|
760
|
+
# sibling apart.
|
|
761
|
+
leaves = values.map { |value| named_leaves(value).select { |_, (_, leaf)| literal?(leaf) } if keyed?(value) }
|
|
762
|
+
echoes = leaves.each_index.map { |index| inputs[index] ? inputs[index].args.map { |arg| written_value(arg) }.grep(Hash).map { |arg| named_leaves(arg) }.reduce({}, :merge) : {} }
|
|
763
|
+
distinct = leaves.compact.uniq.sort_by(&:inspect)
|
|
764
|
+
paths = distinct.flat_map(&:keys).uniq
|
|
765
|
+
pairs = distinct.combination(2).to_a
|
|
766
|
+
# A leaf every Hash has says something about each of them, so it
|
|
767
|
+
# comes before one that only some have.
|
|
768
|
+
shared = paths.to_h { |path| [path, distinct.all? { |map| map.key?(path) }] }
|
|
769
|
+
# A leaf that holds what the input passed at the same path, such as
|
|
770
|
+
# a document the method merges into, says nothing the input did not.
|
|
771
|
+
echoed = paths.to_h do |path|
|
|
772
|
+
[path, leaves.each_index.all? { |index| !leaves[index]&.key?(path) || (echoes[index].key?(path) && echoes[index][path].eql?(leaves[index][path])) }]
|
|
773
|
+
end
|
|
774
|
+
chosen = []
|
|
775
|
+
entries = ->(map, picks) { picks.filter_map { |path| map[path] if map.key?(path) } }
|
|
776
|
+
until pairs.empty? || chosen.length == 3
|
|
777
|
+
told = paths.to_h { |path| [path, pairs.count { |a, b| leaves_differ?(a, b, path) }] }
|
|
778
|
+
spread = paths.to_h { |path| [path, distinct.map { |map| map.fetch(path, MISSING) }.uniq.length] }
|
|
779
|
+
best = paths.each_with_index.max_by { |path, index| [told[path].positive? && !echoed[path] ? 1 : 0, told[path].positive? && shared[path] ? 1 : 0, told[path], spread[path], -index] }.first
|
|
780
|
+
break if chosen.any? && distinct.any? { |map| outcome_length(entries.(map, chosen + [best])) > OUTCOME_LIMIT }
|
|
781
|
+
|
|
782
|
+
chosen << best
|
|
783
|
+
pairs.reject! { |a, b| leaves_differ?(a, b, best) }
|
|
784
|
+
end
|
|
785
|
+
leaves.map { |map| map ? entries.(map, chosen) : [] }
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
MISSING = Object.new.freeze
|
|
789
|
+
# The longest minitest outcome part of a name, in characters: room for
|
|
790
|
+
# the "and" before each of the second and third leaves too.
|
|
791
|
+
OUTCOME_LIMIT = 51
|
|
792
|
+
|
|
793
|
+
def outcome_length(entries)
|
|
794
|
+
clip(outcome_entry_words(entries), Float::INFINITY).length
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# path => leaf for every scalar or empty collection under a Hash.
|
|
798
|
+
def hash_leaves(value)
|
|
799
|
+
[].tap { |out| collect_leaves(value, [], out) }.reject { |path, _| path.empty? }.to_h
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# hash_leaves by the names of their keys, each to its own [path, leaf],
|
|
803
|
+
# so a key that one Hash holds as a Symbol and another as a String is
|
|
804
|
+
# one leaf to compare, and each still reads as its Hash holds it.
|
|
805
|
+
def named_leaves(value)
|
|
806
|
+
hash_leaves(value).to_h { |path, leaf| [path.map { |step| plain_step(step) }, [path, leaf]] }
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def leaves_differ?(a, b, path)
|
|
810
|
+
!(a.key?(path) && b.key?(path) && a[path].eql?(b[path]))
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# "returns", then each entry's key, as telling_key names it, and its
|
|
814
|
+
# value.
|
|
815
|
+
def outcome_entry_words(entries)
|
|
816
|
+
words = entries.flat_map do |path, item|
|
|
817
|
+
[telling_key(path, entries.map(&:first)), *(item == [] || item == {} ? ["empty"] : value_words(item))].compact
|
|
818
|
+
end
|
|
819
|
+
["returns", *words]
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# A path into a value as a reader writes it. A Symbol key that is a
|
|
823
|
+
# plain name reads bare, lines[0].sku, and any other Symbol key as a
|
|
824
|
+
# Symbol, [:"a b"]. A String key keeps its quotes, params["sku"], so a
|
|
825
|
+
# String-keyed Hash does not read as if a Symbol lookup would find it.
|
|
826
|
+
# +root+, when given, is the name the path starts from.
|
|
827
|
+
def path_text(path, root = nil)
|
|
828
|
+
path.each_with_object(+"#{root}") do |step, text|
|
|
829
|
+
text << if step.is_a?(CallHead) then ""
|
|
830
|
+
elsif step.is_a?(TapBlock) then ".#{step.name}"
|
|
831
|
+
elsif step.is_a?(Integer) then "[#{step}]"
|
|
832
|
+
elsif !step.is_a?(SymbolKey) then "[#{step.inspect}]"
|
|
833
|
+
elsif !step.name.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/) then "[#{step.name.to_sym.inspect}]"
|
|
834
|
+
elsif text.empty? then step.name
|
|
835
|
+
else ".#{step.name}"
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# Leaves of a returned Hash, each a [path, value text], as a reader
|
|
841
|
+
# writes them: due: 0 and lines[0].sku: "A" by their path, and a leaf
|
|
842
|
+
# whose path would start at a bracket as the literal that holds it,
|
|
843
|
+
# "counts" => {"discarded" => 0}. With +merge+, leaves under one key
|
|
844
|
+
# share its literal, "tests" => [{"file" => "a.rb", "state" => "ok"}];
|
|
845
|
+
# a short path does not, since its first key may sit at two depths.
|
|
846
|
+
def entries_text(entries, merge: true)
|
|
847
|
+
groups = []
|
|
848
|
+
entries.each do |path, text|
|
|
849
|
+
name = path_text(path)
|
|
850
|
+
next groups << "#{name}#{path.last.is_a?(String) ? " =>" : ":"} #{text}" unless name.start_with?("[")
|
|
851
|
+
|
|
852
|
+
group = groups.find { |other| other.is_a?(Array) && other.first.first.first == path.first } if merge
|
|
853
|
+
group ? group << [path, text] : groups << [[path, text]]
|
|
854
|
+
end
|
|
855
|
+
groups.map { |group| group.is_a?(Array) ? held_entries(group, braces: false) : group }.join(", ")
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# Leaves that share a value, each a [path into it, value text], as
|
|
859
|
+
# the literal of that value, with _ for an Array element no leaf is in.
|
|
860
|
+
def held_entries(entries, braces: true)
|
|
861
|
+
return entries.first[1] if entries.first[0].empty?
|
|
862
|
+
|
|
863
|
+
steps = entries.group_by { |path, _| path.first }.transform_values { |group| group.map { |path, text| [path.drop(1), text] } }
|
|
864
|
+
return "[#{(0..steps.keys.max).map { |index| steps.key?(index) ? held_entries(steps[index]) : "_" }.join(", ")}]" if steps.keys.first.is_a?(Integer)
|
|
865
|
+
|
|
866
|
+
pairs = steps.map { |key, group| "#{key_literal(key)} #{held_entries(group)}" }.join(", ")
|
|
867
|
+
braces ? "{#{pairs}}" : pairs
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def key_literal(key)
|
|
871
|
+
return "#{key.inspect} =>" unless key.is_a?(SymbolKey)
|
|
872
|
+
|
|
873
|
+
key.name.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/) ? "#{key.name}:" : "#{key.name.to_sym.inspect} =>"
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# A Symbol Hash key in a path. It is a step of its own, so a String key
|
|
877
|
+
# of the same name makes another path and the two inputs read apart.
|
|
878
|
+
SymbolKey = Data.define(:name) do
|
|
879
|
+
def to_s = name
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def key_step?(step) = step.is_a?(String) || step.is_a?(SymbolKey)
|
|
883
|
+
|
|
884
|
+
def plain_step(step) = step.is_a?(SymbolKey) ? step.name : step
|
|
885
|
+
|
|
886
|
+
def key_texts(path) = path.select { |step| key_step?(step) }.map(&:to_s)
|
|
887
|
+
|
|
888
|
+
# A Code that is a plain literal, such as [{sku: "B"}], names as the
|
|
889
|
+
# value it builds, so it reads in the style of a JSON value and its
|
|
890
|
+
# Symbol keys read apart from String keys. Any other Code stays whole.
|
|
891
|
+
def written_value(value)
|
|
892
|
+
return value unless value.is_a?(Code)
|
|
893
|
+
|
|
894
|
+
body = Prism.parse(value.source).value.statements&.body || []
|
|
895
|
+
return value unless body.one?
|
|
896
|
+
|
|
897
|
+
catch(:not_plain) { return plain_literal(body.first) }
|
|
898
|
+
value
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# +lines+ of Ruby, each indented by +indent+, except an empty line and
|
|
902
|
+
# a line of the body or the terminator of a << or <<- heredoc: an
|
|
903
|
+
# indented terminator of << does not end it, and <<- keeps the
|
|
904
|
+
# indentation of its body in its text. A <<~ heredoc removes the
|
|
905
|
+
# indentation its lines share, so its body is indented with the rest.
|
|
906
|
+
def indent(lines, indent)
|
|
907
|
+
kept = heredoc_lines(lines.join("\n"))
|
|
908
|
+
lines.each_with_index.map { |line, index| line.empty? || kept.include?(index + 1) ? line : "#{indent}#{line}" }
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# Node types, not classes: naming a class would load prism when branchout loads, before the suite chose its version.
|
|
912
|
+
HEREDOCS = %i[string_node interpolated_string_node x_string_node interpolated_x_string_node].freeze
|
|
913
|
+
|
|
914
|
+
def heredoc_lines(source, node = Prism.parse(source).value, out = Set.new)
|
|
915
|
+
if HEREDOCS.include?(node.type) && node.opening_loc&.slice&.match?(/\A<<[-A-Za-z_"'`]/)
|
|
916
|
+
first = node.respond_to?(:content_loc) ? node.content_loc : node.parts.first&.location
|
|
917
|
+
out.merge((first || node.closing_loc).start_line..node.closing_loc.start_line)
|
|
918
|
+
end
|
|
919
|
+
node.compact_child_nodes.each { |child| heredoc_lines(source, child, out) }
|
|
920
|
+
out
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# The statements of Ruby source, each as its own Code, so sources
|
|
924
|
+
# that share some lines read apart by the lines that differ.
|
|
925
|
+
def statements(code)
|
|
926
|
+
body = Prism.parse(code.source).value.statements&.body || []
|
|
927
|
+
body.length < 2 ? [code] : body.map { |node| Code.new(node.slice) }
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# The call before its keyword arguments, in a path into a Ruby call.
|
|
931
|
+
# It has no name of its own, so the leaf reads as the argument.
|
|
932
|
+
CallHead = Data.define
|
|
933
|
+
|
|
934
|
+
# A Ruby call with keyword arguments, such as a factory call, as its
|
|
935
|
+
# head and each keyword, so inputs that share a long call read apart
|
|
936
|
+
# by the keywords that differ (b is "Y") and not by a cut source.
|
|
937
|
+
# Empty for any other Ruby, which stays one leaf.
|
|
938
|
+
def call_parts(code)
|
|
939
|
+
body = Prism.parse(code.source).value.statements&.body || []
|
|
940
|
+
call = body.first if body.one?
|
|
941
|
+
return tap_parts(call) if tapped?(call)
|
|
942
|
+
return [] unless call.is_a?(Prism::CallNode) && call.block.nil? && call.arguments
|
|
943
|
+
|
|
944
|
+
*positional, keywords = call.arguments.arguments
|
|
945
|
+
return [] unless keywords.is_a?(Prism::KeywordHashNode) &&
|
|
946
|
+
keywords.elements.all? { |pair| pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) }
|
|
947
|
+
|
|
948
|
+
head = code.source[0...keywords.location.start_offset - call.location.start_offset].sub(/[(,\s]+\z/, "")
|
|
949
|
+
head = "#{head})" if call.opening_loc && !positional.empty?
|
|
950
|
+
[[CallHead.new, Code.new(head)]] + keywords.elements.map do |pair|
|
|
951
|
+
plain = catch(:not_plain) { [plain_literal(pair.value)] }
|
|
952
|
+
[SymbolKey.new(pair.key.unescaped), plain ? plain.first : Code.new(pair.value.slice)]
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
# The block of a tap or then, as a step of a path. A Ruby argument
|
|
957
|
+
# that runs one, such as FactoryBot.build(:fact).tap { create(:part) },
|
|
958
|
+
# reads apart from its siblings by the block, which the call's source
|
|
959
|
+
# cut at its start would not show.
|
|
960
|
+
TapBlock = Data.define(:name)
|
|
961
|
+
|
|
962
|
+
def tapped?(call)
|
|
963
|
+
call.is_a?(Prism::CallNode) && %i[tap then].include?(call.name) && call.receiver && call.arguments.nil? &&
|
|
964
|
+
call.block.is_a?(Prism::BlockNode)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# The parts of the receiver, as call_parts gives them or as one head,
|
|
968
|
+
# then the block on one line.
|
|
969
|
+
def tap_parts(call)
|
|
970
|
+
receiver = Code.new(call.receiver.slice)
|
|
971
|
+
parts = call_parts(receiver)
|
|
972
|
+
parts = [[CallHead.new, receiver]] if parts.empty?
|
|
973
|
+
parts + [[TapBlock.new(call.name.to_s), Code.new(block_text(call.block))]]
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def block_text(block)
|
|
977
|
+
body = block.body
|
|
978
|
+
statements = body.is_a?(Prism::StatementsNode) ? body.body.map(&:slice) : [body&.slice].compact
|
|
979
|
+
words = [block.opening_loc.slice, block.parameters&.slice, statements.join("; "), block.closing_loc.slice]
|
|
980
|
+
words.compact.reject(&:empty?).join(" ").gsub(/\s*\n\s*/, " ")
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def plain_literal(node)
|
|
984
|
+
case node
|
|
985
|
+
when Prism::ArrayNode then node.elements.map { |item| plain_literal(item) }
|
|
986
|
+
when Prism::HashNode, Prism::KeywordHashNode
|
|
987
|
+
node.elements.to_h do |pair|
|
|
988
|
+
throw :not_plain unless pair.is_a?(Prism::AssocNode)
|
|
989
|
+
|
|
990
|
+
[plain_literal(pair.key), plain_literal(pair.value)]
|
|
991
|
+
end
|
|
992
|
+
when Prism::StringNode then node.unescaped
|
|
993
|
+
when Prism::SymbolNode then node.unescaped.to_sym
|
|
994
|
+
when Prism::IntegerNode, Prism::FloatNode then node.value
|
|
995
|
+
when Prism::NilNode then nil
|
|
996
|
+
when Prism::TrueNode then true
|
|
997
|
+
when Prism::FalseNode then false
|
|
998
|
+
else throw :not_plain
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
def input_words(input)
|
|
1003
|
+
words = input.args.flat_map { |arg| argument_words(arg) }
|
|
1004
|
+
words += input.kwargs.flat_map { |key, value| [key.to_s, *argument_words(value)] }
|
|
1005
|
+
if input.receiver.is_a?(Code)
|
|
1006
|
+
words += ["receiver", *value_words(input.receiver)]
|
|
1007
|
+
elsif input.receiver
|
|
1008
|
+
words += ["new", *input_words(input.receiver)]
|
|
1009
|
+
end
|
|
1010
|
+
input.setup.each { |call| words += [call.method_name, *input_words(call.arguments)] }
|
|
1011
|
+
words += %w[with block] if input.block
|
|
1012
|
+
words += ["after", *value_words(input.before)] if input.before
|
|
1013
|
+
words
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# +differing+ is the pin's entry of hash_differences: a Hash whose
|
|
1017
|
+
# siblings differ in a leaf is named by those leaves.
|
|
1018
|
+
def outcome_words(pin, differing = [])
|
|
1019
|
+
return ["raises", *snake_words(pin.error.class_name)] if pin.error
|
|
1020
|
+
return outcome_entry_words(differing) unless differing.empty?
|
|
1021
|
+
return ["returns", *shape_words(pin.value)] if literal?(pin.value) || keyed?(pin.value)
|
|
1022
|
+
return ["returns", "array", "of", pin.value.length.to_s] if pin.value.is_a?(Array)
|
|
1023
|
+
|
|
1024
|
+
["returns", *snake_words(class_literal(pin.value))]
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# A Hash whose keys all have a literal, so RSpec matches it key by key
|
|
1028
|
+
# and a name can say its keys.
|
|
1029
|
+
def keyed?(value) = value.is_a?(Hash) && value.each_key.all? { |key| literal?(key) }
|
|
1030
|
+
|
|
1031
|
+
# A Hash is named by its keys and an Array of collections by its size,
|
|
1032
|
+
# so the start of a long value does not fill the name.
|
|
1033
|
+
def shape_words(value)
|
|
1034
|
+
case value
|
|
1035
|
+
when {}, [] then ["empty", value.is_a?(Hash) ? "hash" : "array"]
|
|
1036
|
+
when Hash then ["hash", *value.keys.each_with_index.map { |key, index| Key.new(key.to_s, index.positive?) }]
|
|
1037
|
+
when Array
|
|
1038
|
+
if value.all? { |item| composite?(item) } then ["array", "of", value.length.to_s]
|
|
1039
|
+
# Any other Array says so, so [nil] is not named as nil, [3] not
|
|
1040
|
+
# as 3, and ["Done", []] not as the String "Done".
|
|
1041
|
+
elsif mixed_array?(value) then ["array", *value.flat_map { |item| collection?(item) ? shape_words(item) : value_words(item) }]
|
|
1042
|
+
else ["array", *value_words(value)]
|
|
1043
|
+
end
|
|
1044
|
+
when Struct, Data then snake_words(value.class.name)
|
|
1045
|
+
else value_words(value)
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
# A Hash argument is named by its values, a collection in it by its
|
|
1050
|
+
# key and size (groups_3, tags_empty), and an Array of collections by
|
|
1051
|
+
# its size, so a tree of arguments does not fill the name and two
|
|
1052
|
+
# trees of different sizes read apart. An empty argument still says
|
|
1053
|
+
# so (empty_array), so the name keeps every input.
|
|
1054
|
+
def argument_words(value)
|
|
1055
|
+
value = written_value(value)
|
|
1056
|
+
case value
|
|
1057
|
+
when {}, [] then shape_words(value)
|
|
1058
|
+
when Hash
|
|
1059
|
+
value.flat_map { |key, item| collection?(item) ? [key.to_s, item.empty? ? "empty" : item.length.to_s] : value_words(item) }
|
|
1060
|
+
when Array
|
|
1061
|
+
value.any? { |item| collection?(item) } ? ["array", "of", value.length.to_s] : value_words(value)
|
|
1062
|
+
else value_words(value)
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def collection?(value)
|
|
1067
|
+
value.is_a?(Hash) || value.is_a?(Array)
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
# A value object reads as one thing, as a collection does, and not as
|
|
1071
|
+
# the scalars it holds.
|
|
1072
|
+
def composite?(value)
|
|
1073
|
+
collection?(value) || value.is_a?(Struct) || value.is_a?(Data)
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def mixed_array?(value)
|
|
1077
|
+
value.is_a?(Array) && value.any? { |item| collection?(item) } && !value.all? { |item| collection?(item) }
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
# The shortest end of +path+ that starts at a Hash key and ends no
|
|
1081
|
+
# other path in +paths+, so id at the top and items[0].id read apart.
|
|
1082
|
+
# With no key in +path+, the whole path.
|
|
1083
|
+
def telling_path(path, paths)
|
|
1084
|
+
starts = path.each_index.select { |index| key_step?(path[index]) }
|
|
1085
|
+
return path if starts.empty?
|
|
1086
|
+
|
|
1087
|
+
others = paths.reject { |other| other == path }
|
|
1088
|
+
found = starts.reverse.find do |start|
|
|
1089
|
+
tail = path.drop(start)
|
|
1090
|
+
others.none? { |other| other.last(tail.length) == tail }
|
|
1091
|
+
end
|
|
1092
|
+
path.drop(found || starts.first)
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
# The fewest last Hash keys of +path+ that end the keys of no other
|
|
1096
|
+
# path in +paths+, as one Key: items_id. Paths with the same keys,
|
|
1097
|
+
# such as args[0].value and args[1].value, cannot be told apart by
|
|
1098
|
+
# keys, so they do not lengthen each other's.
|
|
1099
|
+
def telling_key(path, paths)
|
|
1100
|
+
keys = key_texts(path)
|
|
1101
|
+
return nil if keys.empty?
|
|
1102
|
+
|
|
1103
|
+
others = paths.map { |other| key_texts(other) }.reject { |other| other == keys }
|
|
1104
|
+
length = (1..keys.length).find { |count| others.none? { |other| other.last(count) == keys.last(count) } }
|
|
1105
|
+
Key.new(keys.last(length || keys.length).join(" "))
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
def value_words(value)
|
|
1109
|
+
case value
|
|
1110
|
+
when Array then value.flat_map { |item| value_words(item) }
|
|
1111
|
+
when Set then value.empty? ? %w[empty set] : ["set", *value.flat_map { |item| value_words(item) }]
|
|
1112
|
+
when Integer then [number_word(value)]
|
|
1113
|
+
when nil then ["nil"]
|
|
1114
|
+
when "" then ["empty"]
|
|
1115
|
+
when /\A\s+\z/ then ["blank"]
|
|
1116
|
+
when /\A[^A-Za-z0-9]+\z/ then ["text"]
|
|
1117
|
+
when Numeric then [value.to_s.sub(/\A-/, "neg")]
|
|
1118
|
+
when String then literal_safe([without_machine_directories(value)])
|
|
1119
|
+
when Symbol then literal_safe([value.to_s])
|
|
1120
|
+
else [value.to_s]
|
|
1121
|
+
end
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# A String or a Symbol that reads as nil, true, or false says it is
|
|
1125
|
+
# text, so a name does not claim the literal for it.
|
|
1126
|
+
def literal_safe(words)
|
|
1127
|
+
%w[nil true false].include?(tokens(words.first).join("_")) ? ["text", *words] : words
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
# +value+ as a name or a description says it: a path under the
|
|
1131
|
+
# project from the project root, and a path of this machine by its
|
|
1132
|
+
# file alone.
|
|
1133
|
+
def described(value)
|
|
1134
|
+
case value
|
|
1135
|
+
when ProjectPath, ProjectText then String.new(value)
|
|
1136
|
+
when String then without_machine_directories(value)
|
|
1137
|
+
when Array then value.map { |item| described(item) }
|
|
1138
|
+
when Hash then value.transform_values { |item| described(item) }
|
|
1139
|
+
else value
|
|
1140
|
+
end
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
# The directories of a path under the home or the temporary directory
|
|
1144
|
+
# differ per machine, so a name keeps only the file of such a path.
|
|
1145
|
+
# A whole path may hold spaces, so it keeps all of its file name.
|
|
1146
|
+
def without_machine_directories(text)
|
|
1147
|
+
return File.basename(text) if machine_homes.any? { |home| text.start_with?(home) } && !text.end_with?("/")
|
|
1148
|
+
|
|
1149
|
+
homes = machine_homes.map { |home| Regexp.escape(home) }.join("|")
|
|
1150
|
+
text.gsub(%r{(?:#{homes})(?:[^\s"\x27`(),;/]+/)*}, "")
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
# A number longer than LONG_NUMBER digits fills a name, so it keeps
|
|
1154
|
+
# its first three digits and its power of ten: 123e46.
|
|
1155
|
+
def number_word(value)
|
|
1156
|
+
digits = value.abs.to_s
|
|
1157
|
+
word = digits.length > LONG_NUMBER ? "#{digits[0, 3]}e#{digits.length - 3}" : digits
|
|
1158
|
+
value.negative? ? "neg#{word}" : word
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
LONG_NUMBER = 15
|
|
1162
|
+
|
|
1163
|
+
def snake_words(class_name)
|
|
1164
|
+
[class_name.gsub("::", "_").gsub(/([a-z\d])([A-Z])/, '\1_\2')]
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# A Hash key in a name. It is one unit that a cut never splits, and a
|
|
1168
|
+
# key that +follows+ another key of the same list comes after "and",
|
|
1169
|
+
# so line_items reads as one key and not as the keys line and items.
|
|
1170
|
+
# A key that starts a list, such as the first key of each Hash in an
|
|
1171
|
+
# Array, does not follow the keys before it.
|
|
1172
|
+
Key = Data.define(:text, :follows) do
|
|
1173
|
+
def initialize(text:, follows: true) = super
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
# Joins words into identifier characters, at most +limit+ long, cut
|
|
1177
|
+
# between words. Only a first word longer than +limit+ is cut inside.
|
|
1178
|
+
# A key whose value the cut drops goes too, so a name never ends in a
|
|
1179
|
+
# key that says nothing.
|
|
1180
|
+
def clip(words, limit)
|
|
1181
|
+
listed = false
|
|
1182
|
+
units = words.flat_map do |word|
|
|
1183
|
+
next tokens(word).map { |token| ["_", token, false] } unless word.is_a?(Key)
|
|
1184
|
+
|
|
1185
|
+
# A key with no letter or digit, such as "", still says it is a key,
|
|
1186
|
+
# so its value is not read as the whole outcome, and so does a key
|
|
1187
|
+
# that reads as nil, true, or false: {"NIL" => 0} is never
|
|
1188
|
+
# returns_nil_0.
|
|
1189
|
+
key = tokens(word.text).join("_")
|
|
1190
|
+
key = "key" if key.empty?
|
|
1191
|
+
key = "key_#{key}" if %w[nil true false].include?(key)
|
|
1192
|
+
|
|
1193
|
+
joiner = listed && word.follows ? "_and_" : "_"
|
|
1194
|
+
listed = true
|
|
1195
|
+
[[joiner, key, true]]
|
|
1196
|
+
end
|
|
1197
|
+
return "" if units.empty?
|
|
1198
|
+
|
|
1199
|
+
text = units.first[1]
|
|
1200
|
+
return text[0, limit] if text.length > limit
|
|
1201
|
+
|
|
1202
|
+
taken = 1
|
|
1203
|
+
units.drop(1).each do |joiner, unit|
|
|
1204
|
+
break if text.length + joiner.length + unit.length > limit
|
|
1205
|
+
|
|
1206
|
+
text += joiner + unit
|
|
1207
|
+
taken += 1
|
|
1208
|
+
end
|
|
1209
|
+
last, cut = units[taken - 1], units[taken]
|
|
1210
|
+
return text.delete_suffix("#{last[0]}#{last[1]}") if last[2] && cut && !cut[2]
|
|
1211
|
+
|
|
1212
|
+
text
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
def tokens(word)
|
|
1216
|
+
word.to_s.downcase.split(/[^a-z0-9]+/).reject(&:empty?)
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
# Why a file holds no test, in the words of the report's NOT PINNED.
|
|
1220
|
+
def no_pin_reason(all_varied)
|
|
1221
|
+
return "branchout did not discover an input" unless all_varied
|
|
1222
|
+
|
|
1223
|
+
"each input returned a value that differed between two runs, so no emitted test checks it"
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def cover_comment(pin, indent)
|
|
1227
|
+
return [] if pin.covers.empty?
|
|
1228
|
+
|
|
1229
|
+
["#{indent}# covers:", *pin.covers.map { |cover| "#{indent}# line #{cover.line}: #{cover.text}" }]
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
# Says why the Strings of a thawed input carry a +, which a reader of
|
|
1233
|
+
# a frozen_string_literal file would otherwise take for noise.
|
|
1234
|
+
def thawed_comment(pin, indent)
|
|
1235
|
+
return [] unless pin.thawed
|
|
1236
|
+
|
|
1237
|
+
["#{indent}# With frozen Strings this input raised FrozenError, so its Strings are unfrozen (+\"...\").",
|
|
1238
|
+
"#{indent}# This value is from a second run, which saw what the first run changed before it raised and no " \
|
|
1239
|
+
"savepoint undid, such as a global, a class variable, or a file."]
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
def write_file(destination, content)
|
|
1243
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
1244
|
+
File.write(destination, content)
|
|
1245
|
+
end
|
|
1246
|
+
end
|
|
1247
|
+
end
|
|
1248
|
+
end
|