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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.ja.md +903 -0
  5. data/README.md +586 -0
  6. data/bin/branchout +15 -0
  7. data/examples/arithmetic.rb +24 -0
  8. data/examples/hidden_bug.rb +17 -0
  9. data/examples/inputs/arithmetic.json +10 -0
  10. data/examples/inputs/arithmetic.seed.json +5 -0
  11. data/examples/inputs/hidden_bug.json +9 -0
  12. data/examples/inputs/hidden_bug.seed.json +5 -0
  13. data/examples/inputs/magic_value.json +7 -0
  14. data/examples/inputs/magic_value.seed.json +5 -0
  15. data/examples/inputs/version_parser.json +11 -0
  16. data/examples/inputs/version_parser.seed.json +5 -0
  17. data/examples/magic_value.rb +11 -0
  18. data/examples/version_parser.rb +29 -0
  19. data/lib/branchout/canon.rb +59 -0
  20. data/lib/branchout/cli.rb +302 -0
  21. data/lib/branchout/coverage.rb +9 -0
  22. data/lib/branchout/coverage_session.rb +49 -0
  23. data/lib/branchout/emitters/minitest.rb +92 -0
  24. data/lib/branchout/emitters/rspec.rb +532 -0
  25. data/lib/branchout/emitters/support.rb +1248 -0
  26. data/lib/branchout/emitters.rb +101 -0
  27. data/lib/branchout/engine.rb +303 -0
  28. data/lib/branchout/explore.rb +124 -0
  29. data/lib/branchout/input.rb +215 -0
  30. data/lib/branchout/input_file.rb +125 -0
  31. data/lib/branchout/mutator.rb +124 -0
  32. data/lib/branchout/prism_choice.rb +25 -0
  33. data/lib/branchout/report.rb +449 -0
  34. data/lib/branchout/rng.rb +31 -0
  35. data/lib/branchout/runner.rb +591 -0
  36. data/lib/branchout/sampler.rb +70 -0
  37. data/lib/branchout/target.rb +696 -0
  38. data/lib/branchout/version.rb +8 -0
  39. data/lib/branchout.rb +31 -0
  40. data/skills/branchout/SKILL.md +230 -0
  41. data/skills/branchout/inputs.example.json +10 -0
  42. metadata +114 -0
@@ -0,0 +1,532 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchout
4
+ module Emitters
5
+ # Emits RSpec source. This file does not load the rspec gem.
6
+ #
7
+ # Each input becomes a list of steps: the receiver (a let), each setup
8
+ # call (a before, or inline when the input raised), and each leaf of the
9
+ # arguments (a fact, which only names). Inputs that start with the same
10
+ # steps share a context that holds them, so an example is left with the
11
+ # outcome and the facts of its own.
12
+ module RSpec
13
+ # A fact runs no code; its +leaf+ is the [path, value] it names, so
14
+ # two values whose labels are cut alike stay two facts.
15
+ Step = Data.define(:kind, :label, :code, :leaf) do
16
+ def initialize(kind:, label:, code: nil, leaf: nil) = super
17
+ end
18
+ # The longest description or context text.
19
+ LIMIT = 80
20
+ # rspec puts these on the load path of a run from the project root.
21
+ RUNNER_LOAD_PATHS = %w[spec lib].freeze
22
+ CALLS = %i[before inline].freeze
23
+ # The steps whose code runs in a context, so an input that still has
24
+ # one gets a context of its own.
25
+ CONTEXT_STEPS = %i[let before].freeze
26
+ Node = Data.define(:steps, :members)
27
+ Example = Data.define(:pin, :steps, :facts)
28
+
29
+ # Names that a let or a subject must not take: RSpec's own methods.
30
+ TAKEN_NAMES = %w[
31
+ subject example expect is_expected described_class context describe let before after it
32
+ raise_error eq include be_a skip pending
33
+ ].freeze
34
+
35
+ module_function
36
+
37
+ def render(result, target, destination, require_by_name: false, pin_keys: [], from: Dir.pwd, all_varied: false)
38
+ lines = Support.header(target, "specs", destination)
39
+ lines << ""
40
+ lines.concat(Support.require_lines(result.requires, (target.path unless result.target_required), destination,
41
+ by_name: require_by_name, load_paths: result.load_paths,
42
+ runner_load_paths: RUNNER_LOAD_PATHS, from: from))
43
+ lines << ""
44
+ const = result.const_expression || target.const_name
45
+ type = ", type: #{result.spec_type.inspect}" if result.spec_type.is_a?(Symbol)
46
+ # A private constant cannot be named bare at load time, so the
47
+ # describe names it as text.
48
+ lines << "RSpec.describe #{const == target.const_name ? const : target.const_name.inspect}#{type} do"
49
+ lines << " describe #{"#{target.singleton ? "." : "#"}#{target.method_name}".inspect} do"
50
+ if result.pins.empty?
51
+ lines << " it #{(all_varied ? "pins no input" : "discovers no inputs").inspect} do"
52
+ lines << " skip #{Support.no_pin_reason(all_varied).inspect}"
53
+ lines << " end"
54
+ else
55
+ spec = Spec.new(target, result.pins, result.private_calls, pin_keys, const)
56
+ lines.concat(spec.lines)
57
+ end
58
+ lines << " end"
59
+ lines << "end"
60
+ "#{lines.join("\n")}\n"
61
+ end
62
+
63
+ # The describe block of one entry: its subject, its tree of contexts,
64
+ # and the examples.
65
+ class Spec
66
+ def initialize(target, pins, private_calls, pin_keys, const = target.const_name)
67
+ @target = target
68
+ # The describe names the class, so rubocop-rspec's
69
+ # RSpec/DescribedClass wants it as described_class. A private
70
+ # constant is described by its name, which leaves described_class nil.
71
+ @const = const == target.const_name ? "described_class" : const
72
+ @private_calls = private_calls
73
+ @pin_keys = pin_keys
74
+ @receiver = receiver_name
75
+ @subject = subject_name if pins.all? { |pin| pin.input.args.empty? && pin.input.kwargs.empty? && pin.input.block.nil? }
76
+ @one_argument = pins.all? { |pin| pin.input.args.length == 1 && pin.input.kwargs.empty? }
77
+ @outcomes = Support.outcomes(pins, pin_keys)
78
+ @short_outcomes = Support.outcomes(pins, pin_keys, short: true)
79
+ entries = pins.map { |pin| [pin, steps_of(pin)] }
80
+ shared = entries.map { |_, steps| steps.select { |step| step.kind == :fact } }.reduce(:&)
81
+ entries = entries.map { |pin, steps| [pin, steps - shared] }
82
+ @root = merged(build([], entries))
83
+ end
84
+
85
+ def lines
86
+ out = case @subject
87
+ when String then [" subject(:#{@subject}) { #{entry_call} }", ""]
88
+ when false then [" subject { #{entry_call} }", ""]
89
+ else []
90
+ end
91
+ out + node_lines(@root, " ")
92
+ end
93
+
94
+ private
95
+
96
+ def node_lines(node, indent)
97
+ out = []
98
+ node.steps.select { |step| step.kind == :let }.each do |step|
99
+ out << "#{indent}let(:#{@receiver}) { #{step.code} }"
100
+ end
101
+ befores = node.steps.select { |step| step.kind == :before }
102
+ unless befores.empty?
103
+ out << "" unless out.empty?
104
+ out << "#{indent}before do"
105
+ out.concat(Support.indent(befores.flat_map { |step| step.code.lines(chomp: true) }, "#{indent} "))
106
+ out << "#{indent}end"
107
+ end
108
+ texts = member_texts(node.members)
109
+ node.members.each do |member|
110
+ out << "" unless out.empty?
111
+ if member.is_a?(Example)
112
+ out.concat(example_lines(member, texts[member], indent))
113
+ else
114
+ out << "#{indent}context #{quoted(texts[member])} do"
115
+ out.concat(node_lines(member, "#{indent} "))
116
+ out << "#{indent}end"
117
+ end
118
+ end
119
+ out
120
+ end
121
+
122
+ def example_lines(example, text, indent)
123
+ pin = example.pin
124
+ lines = ["#{indent}it #{quoted(text)} do"]
125
+ lines.concat(Support.cover_comment(pin, "#{indent} "))
126
+ lines.concat(Support.thawed_comment(pin, "#{indent} "))
127
+ inline = example.steps.select { |step| step.kind == :inline }.map(&:code)
128
+ call = @subject || (@subject == false ? "subject" : entry_call(pin.input, pin.thawed))
129
+ if pin.error
130
+ if inline.empty?
131
+ lines << "#{indent} expect { #{call} }.to #{error_matcher(pin.error)}"
132
+ else
133
+ lines << "#{indent} expect {"
134
+ (inline + [call]).each { |line| lines << "#{indent} #{line}" }
135
+ lines << "#{indent} }.to #{error_matcher(pin.error)}"
136
+ end
137
+ else
138
+ lines << "#{indent} expect(#{call}).to #{value_matcher(pin.value, Support.keys_for(pin, @pin_keys))}"
139
+ end
140
+ lines << "#{indent}end"
141
+ end
142
+
143
+ def error_matcher(error)
144
+ if error.name
145
+ "raise_error(#{error.class_name}) { |error| expect(error.name).to eq(#{error.name.inspect}) }"
146
+ elsif error.stable_message
147
+ "raise_error(#{error.class_name}, #{Support.ruby_literal(error.exact_message || error.message)})"
148
+ elsif error.message_pattern
149
+ "raise_error(#{error.class_name}, #{Regexp.new(error.message_pattern).inspect})"
150
+ else
151
+ "raise_error(#{error.class_name})"
152
+ end
153
+ end
154
+
155
+ def value_matcher(value, pin_keys)
156
+ pinned = Support.pinned_value(value, pin_keys)
157
+ unless pinned.equal?(value)
158
+ return "include(#{Support.literal?(pinned) ? Support.ruby_literal(pinned) : Support.shape_matcher(pinned)})"
159
+ end
160
+ return "match(#{Support.frame_pattern(value)})" if Support.frame_pattern(value)
161
+ unless Support.literal?(value)
162
+ return "match(#{Support.shape_matcher(value)})" if value.is_a?(Array) || value.is_a?(Hash)
163
+ return Support.shape_matcher(value) if value.is_a?(Struct) || value.is_a?(Data)
164
+
165
+ return "be_a(#{Support.class_literal(value)})"
166
+ end
167
+
168
+ # rubocop-rspec's RSpec/BeEq and RSpec/BeNil.
169
+ return "be_nil" if value.nil?
170
+ return "be(#{value})" if value == true || value == false
171
+
172
+ "eq(#{Support.ruby_literal(value)})"
173
+ end
174
+
175
+ # The text of each member of one group, at most LIMIT characters. A
176
+ # context, and a member whose text is longer, says only the facts
177
+ # that tell it from its siblings; a text still too long names them
178
+ # by their deepest key, then is cut. Texts that would still be the
179
+ # same end in their first input's digest.
180
+ def member_texts(members)
181
+ peers = members.flat_map { |member| pins_of(member) }
182
+ texts = members.to_h do |member|
183
+ others = members.reject { |other| other.equal?(member) }.flat_map { |other| pins_of(other) }
184
+ steps = member.is_a?(Example) ? member.facts : member.steps
185
+ text = member_text(member, steps, peers)
186
+ text = member_text(member, telling(steps, others), peers) if text.length > LIMIT || member.is_a?(Node)
187
+ text = member_text(member, telling(steps, others), peers, short: true) if text.length > LIMIT
188
+ [member, text.length > LIMIT ? fitted(member, telling(steps, others), peers) : text]
189
+ end
190
+ repeated = texts.values.tally
191
+ texts.to_h do |member, text|
192
+ next [member, text] if repeated[text] == 1
193
+
194
+ digest = " (input #{Digest::SHA256.hexdigest(pins_of(member).first.input.canonical)[0, 6]})"
195
+ [member, text.length + digest.length > LIMIT ? "#{cut_after(text, kept_start(member, text), LIMIT - digest.length)}#{digest}" : "#{text}#{digest}"]
196
+ end
197
+ end
198
+
199
+ # +text+ cut to +limit+ characters with "...", but never inside its
200
+ # first +kept+ characters.
201
+ def cut_after(text, kept, limit = LIMIT)
202
+ text.length <= limit ? text : "#{text[0, [limit - 3, kept].max]}..."
203
+ end
204
+
205
+ # The start of +text+ a cut keeps: an error's class, whole or by its
206
+ # last segment, as +text+ names it.
207
+ def kept_start(member, text)
208
+ return 0 unless member.is_a?(Example) && member.pin.error
209
+
210
+ outcome = @short_outcomes[member.pin]
211
+ text.start_with?(outcome) ? outcome.length : last_segment(outcome).length
212
+ end
213
+
214
+ def last_segment(outcome) = outcome.sub(/\Araises (?:\w+::)+/, "raises ")
215
+
216
+ # +steps+ without the facts that tell no sibling apart: the fewest
217
+ # facts, in their order, that each sibling input in +others+ lacks
218
+ # one of. A context keeps at least one fact.
219
+ def telling(steps, others)
220
+ facts = steps.select { |step| step.kind == :fact }
221
+ left = others.reject { |pin| (facts - steps_of(pin)).empty? }
222
+ chosen = []
223
+ until left.empty?
224
+ best = facts.max_by { |fact| [left.count { |pin| !steps_of(pin).include?(fact) }, -facts.index(fact)] }
225
+ break if best.nil? || chosen.include?(best)
226
+
227
+ chosen << best
228
+ left.reject! { |pin| !steps_of(pin).include?(best) }
229
+ end
230
+ chosen = facts.first(1) if chosen.empty? && steps.none? { |step| step.kind != :fact }
231
+ steps.reject { |step| step.kind == :fact && !chosen.include?(step) }
232
+ end
233
+
234
+ # A text still over LIMIT. An example cuts its outcome, which leads,
235
+ # and keeps whole the facts that tell it from its siblings, while the
236
+ # outcome keeps a start to read; otherwise the end is cut.
237
+ # An error's class is never cut, since a reader looks it up, but it
238
+ # gives way to its last segment before the facts after it are cut.
239
+ def fitted(member, steps, peers)
240
+ text = member_text(member, steps, peers, short: true)
241
+ facts = label(steps, peers, short: true)
242
+ if member.is_a?(Example) && member.pin.error
243
+ outcome = @short_outcomes[member.pin]
244
+ last = last_segment(outcome)
245
+ return cut_after("#{last}#{text[outcome.length..]}", last.length)
246
+ end
247
+ room = LIMIT - facts.length - " ...".length
248
+ return "#{text[0, LIMIT - 3]}..." unless member.is_a?(Example) && !facts.empty? && room >= 12
249
+
250
+ "#{@short_outcomes[member.pin][0, room]}... #{facts}"
251
+ end
252
+
253
+ def member_text(member, steps, peers, short: false)
254
+ text = label(steps, peers, short: short)
255
+ return text unless member.is_a?(Example)
256
+
257
+ outcome = (short ? @short_outcomes : @outcomes)[member.pin]
258
+ text.empty? ? outcome : "#{outcome} #{text}"
259
+ end
260
+
261
+ # A text as a literal that needs no escape, so it reads as it prints:
262
+ # 'when kind is "int"', or %q(...) when it holds both quotes.
263
+ def quoted(text)
264
+ plain = text.inspect
265
+ # Single quotes and %q read only a doubled backslash or their own
266
+ # delimiter as an escape, so a text that a value's own literal put
267
+ # a backslash in still reads as written.
268
+ literal = text.match?(/\A[[:print:]]*\z/) && !text.include?("\\\\") && !text.end_with?("\\")
269
+ return plain unless text.include?('"') && literal
270
+ return "'#{text}'" unless text.include?("'")
271
+ return "%q(#{text})" unless text.match?(/[()]/)
272
+
273
+ plain
274
+ end
275
+
276
+ def pins_of(member)
277
+ member.is_a?(Example) ? [member.pin] : member.members.flat_map { |inner| pins_of(inner) }
278
+ end
279
+
280
+ # "with new(10) after add("apple", 3) when price is 5": the receiver,
281
+ # the calls, then the facts.
282
+ def label(steps, peers = [], short: false)
283
+ receiver = steps.select { |step| step.kind == :let }.map { |step| receiver_step_label(step, peers) }
284
+ calls = steps.select { |step| CALLS.include?(step.kind) }.map { |step| call_label(step, peers, short: short) }
285
+ facts = steps.select { |step| step.kind == :fact }.map { |step| fact_label(step.leaf, peers, short: short) }
286
+ parts = receiver
287
+ parts += ["after #{series(calls)}"] unless calls.empty?
288
+ parts += ["when #{series(facts)}"] unless facts.empty?
289
+ parts.join(" ")
290
+ end
291
+
292
+ # An input's before that calls the method a sibling's before calls,
293
+ # with other keywords, is named by the keywords that differ:
294
+ # create(:part) with size: 1. A before of several statements is
295
+ # named by the statements that differ from a sibling's of as many
296
+ # statements, each in that way. Any other step is named by its
297
+ # source, on one line. +short+ keeps only the keywords and block that tell it
298
+ # from each sibling, as a fact is kept.
299
+ def call_label(step, peers, short: false)
300
+ return step.label unless step.leaf
301
+
302
+ mine = Support.statements(step.leaf[1])
303
+ others = peers.filter_map { |pin| pin.input.before }.uniq.reject { |code| code == step.leaf[1] }
304
+ .map { |code| Support.statements(code) }.select { |statements| statements.length == mine.length }
305
+ telling = mine.each_index.select { |index| others.any? { |other| other[index] != mine[index] } }
306
+ return step.label if telling.empty? || telling.length == mine.length && mine.length > 1
307
+
308
+ series(telling.map { |index| keyword_label(mine[index], others.map { |other| other[index] }, short: short) }).gsub(/\s*\n\s*/, " ")
309
+ end
310
+
311
+ def keyword_label(code, others, short: false)
312
+ head, *keywords = Support.call_parts(code)
313
+ others = others.reject { |other| other == code }.map { |other| Support.call_parts(other) }
314
+ .select { |parts| parts.first == head }.map { |parts| parts.drop(1).to_h }
315
+ tells = ->((key, value), other) { !other.key?(key) || !other[key].eql?(value) }
316
+ differing = keywords.select { |keyword| others.any? { |other| tells.(keyword, other) } }
317
+ return code.source if differing.empty?
318
+
319
+ if short
320
+ left = others.select { |other| differing.any? { |keyword| tells.(keyword, other) } }
321
+ chosen = []
322
+ until left.empty?
323
+ best = differing.max_by { |keyword| [left.count { |other| tells.(keyword, other) }, -differing.index(keyword)] }
324
+ chosen << best
325
+ left.reject! { |other| tells.(best, other) }
326
+ end
327
+ differing &= chosen
328
+ end
329
+
330
+ block, keywords = differing.partition { |key, _| key.is_a?(Support::TapBlock) }
331
+ texts = keywords.map { |key, value| "#{key}: #{value.is_a?(Code) ? value.source : value_text(value, [])}" }
332
+ tapped = block.map { |key, value| ".#{key.name} #{block_part(value.source, others.filter_map { |other| other[key]&.source }, short: short)}" }.join
333
+ return "#{head[1].source}#{tapped}" if texts.empty?
334
+
335
+ "#{head[1].source} with #{texts.join(", ")}#{" and #{tapped}" unless tapped.empty?}"
336
+ end
337
+
338
+ def series(words)
339
+ words.length < 2 ? words.join : "#{words[0..-2].join(", ")} and #{words.last}"
340
+ end
341
+
342
+ # Groups +entries+ by their first step, in the order they appear. An
343
+ # entry left with only facts, or none, is an example; the rest share
344
+ # a node. Examples come before nodes.
345
+ def build(steps, entries)
346
+ members = []
347
+ groups = entries.group_by { |_, rest| rest.first }
348
+ groups.each do |first, group|
349
+ if group.one? && group.first[1].none? { |step| CONTEXT_STEPS.include?(step.kind) }
350
+ pin, rest = group.first
351
+ members << Example.new(pin, steps_of(pin), rest)
352
+ elsif first.nil?
353
+ group.each { |pin, _| members << Example.new(pin, steps_of(pin), []) }
354
+ else
355
+ members << merged(build([first], group.map { |pin, rest| [pin, rest.drop(1)] }))
356
+ end
357
+ end
358
+ Node.new(steps, members.partition { |member| member.is_a?(Example) }.flatten)
359
+ end
360
+
361
+ # A node whose one member is a node takes that node's steps and
362
+ # members, so a chain of single contexts reads as one.
363
+ def merged(node)
364
+ while node.members.one? && node.members.first.is_a?(Node)
365
+ node = Node.new(node.steps + node.members.first.steps, node.members.first.members)
366
+ end
367
+ node
368
+ end
369
+
370
+ def steps_of(pin)
371
+ @steps ||= {}
372
+ @steps[pin] ||= begin
373
+ input = pin.input
374
+ steps = []
375
+ steps << Step.new(:before, Support.statements(input.before).map { |code| code.source.gsub(/\s*\n\s*/, " ") }.join("; "), input.before.source, [[:before], input.before]) if input.before
376
+ unless @target.singleton
377
+ steps << Step.new(:let, receiver_label(input), Support.receiver_text(input, @const, @private_calls, thawed: pin.thawed),
378
+ ([[:new], input.receiver.to_h] if input.receiver.is_a?(Input)))
379
+ end
380
+ # A setup call that may be the one that raised stays inside the
381
+ # expect block, so the example still pins the error.
382
+ kind = pin.error ? :inline : :before
383
+ input.setup.each do |call|
384
+ plain = Support.argument_list(call.arguments)
385
+ code = Support.call_text(receiver_expression, call.method_name, Support.argument_list(call.arguments, thawed: pin.thawed), [])
386
+ steps << Step.new(kind, "#{call.method_name}#{"(#{plain})" unless plain.empty?}", code)
387
+ end
388
+ steps + fact_steps(input)
389
+ end
390
+ end
391
+
392
+ # A fact names its leaf by a path into the argument (args[0].value),
393
+ # and an Array of scalars as a whole (items is [1, 2]). When every
394
+ # input passes one argument and no keyword, a path that starts at a
395
+ # key inside that argument says enough, and the parameter's name is
396
+ # left out; a path that starts at a position keeps it (items[0].sku).
397
+ def fact_steps(input)
398
+ names = parameter_names
399
+ leaves = []
400
+ input.args.each_with_index do |arg, index|
401
+ Support.collect_leaves(arg, [names[index] || "argument #{index + 1}"], leaves, whole_arrays: true)
402
+ end
403
+ input.kwargs.each { |key, value| Support.collect_leaves(value, [key], leaves, whole_arrays: true) }
404
+ leaves << [["block"], input.block] if input.block
405
+ leaves.map { |leaf| Step.new(:fact, fact_label(leaf), nil, leaf) }
406
+ end
407
+
408
+ # +short+ names the leaf from its deepest key, as a minitest name
409
+ # does, for a text that is still too long: status is "failed". A key
410
+ # that ends another leaf's path too keeps the keys above it. A path
411
+ # that would start at a bracket names the argument and the literal
412
+ # that holds the leaf, doc holds "status" => "failed", since
413
+ # doc["status"] would name another leaf.
414
+ def fact_label(leaf, peers = [], short: false)
415
+ (root, *path), value = leaf
416
+ return "#{Support.path_text(path, root)} #{block_part(value.source, peer_values(leaf, peers).map(&:source), short: short)}" if path.last.is_a?(Support::TapBlock)
417
+
418
+ value = value_text(value, peer_values(leaf, peers))
419
+ telling = Support.telling_path(path, peer_paths(root, peers)) if short && path.any? { |step| Support.key_step?(step) }
420
+ if telling && telling.length < path.length
421
+ return "#{root} holds #{Support.entries_text([[telling, value]])}" if Support.path_text(telling).start_with?("[")
422
+
423
+ return "#{Support.path_text(telling)} is #{value}"
424
+ end
425
+ name = Support.path_text(path)
426
+ name = Support.path_text(path, root) if !@one_argument || name.empty? || name.start_with?("[")
427
+ "#{name} is #{value}"
428
+ end
429
+
430
+ # A block's source, or with +short+, a block over 30 characters cut
431
+ # where it parts from its siblings' blocks, after its opening:
432
+ # { ...long piece', 2]) }.
433
+ def block_part(source, peer_sources, short: false)
434
+ return source unless short && source.length > 30
435
+
436
+ text = parted(source, peer_sources)
437
+ text.start_with?("...") ? "#{source[/\A(?:\{|do)/]} #{text}" : text
438
+ end
439
+
440
+ # A value longer than 30 characters is cut. A String is cut where it
441
+ # parts from the other Strings at its leaf, so two values that share
442
+ # a long start do not read alike.
443
+ def value_text(value, peer_values)
444
+ value = Support.described(value)
445
+ peer_values = peer_values.map { |other| Support.described(other) }
446
+ text = Support.ruby_literal(value)
447
+ return text if text.length <= 30
448
+ return "#{text[0, 27]}..." unless value.is_a?(String)
449
+
450
+ Support.ruby_literal(parted(value, peer_values.grep(String)))
451
+ end
452
+
453
+ # +value+ cut to about 25 characters around where it parts from the
454
+ # most alike of +peer_values+, with "..." for each cut end.
455
+ def parted(value, peer_values)
456
+ parts = peer_values.reject { |other| other == value }.map do |other|
457
+ value.each_char.zip(other.each_char).take_while { |mine, theirs| mine == theirs }.length
458
+ end
459
+ start = (parts.max || 0) < 20 ? 0 : parts.max - 10
460
+ # Start at a word or a path segment when one begins shortly before.
461
+ separator = value.rindex(%r{[\s/]}, start)
462
+ start = separator + 1 if start.positive? && separator && start - separator <= 12
463
+ # An end "..." that would stand for no more than its own three
464
+ # characters keeps the end instead.
465
+ length = value.length - start <= 25 ? 25 : 22
466
+ finish = Support.word_end(value, start, start + length)
467
+ "#{"..." if start.positive?}#{value[start...finish]}#{"..." if finish < value.length}"
468
+ end
469
+
470
+ def peer_paths(root, peers)
471
+ peers.flat_map { |pin| steps_of(pin).filter_map { |step| step.leaf[0].drop(1) if step.kind == :fact && step.leaf[0][0] == root } }.uniq
472
+ end
473
+
474
+ def peer_values(leaf, peers)
475
+ peers.flat_map { |pin| steps_of(pin).filter_map { |step| step.leaf[1] if step.kind == :fact && step.leaf[0] == leaf[0] } }
476
+ end
477
+
478
+ def parameter_names
479
+ return [] unless @target.respond_to?(:positional) && @target.parameters
480
+
481
+ @target.positional.map { |param| param.name.to_s }
482
+ end
483
+
484
+ # "with new(10)", or "with create(:loan)" for a receiver the input builds.
485
+ def receiver_label(input)
486
+ return "with #{input.receiver.source}" if input.receiver.is_a?(Code)
487
+
488
+ plain = input.receiver ? Support.argument_list(input.receiver) : ""
489
+ plain.empty? ? "with new" : "with new(#{plain})"
490
+ end
491
+
492
+ # A receiver whose new takes the arguments a sibling's new takes,
493
+ # with other values at some keywords, is named by those keywords:
494
+ # with start_at: Date.new(2026, 5, 1). Any other is named by new.
495
+ def receiver_step_label(step, peers)
496
+ return step.label unless step.leaf
497
+
498
+ mine = step.leaf[1]
499
+ others = peers.map { |pin| pin.input.receiver }.grep(Input).map(&:to_h).uniq
500
+ .select { |other| other["args"] == mine["args"] && other["kwargs"].keys.sort == mine["kwargs"].keys.sort && other != mine }
501
+ differing = mine["kwargs"].select { |key, value| others.any? { |other| !other["kwargs"][key].eql?(value) } }
502
+ return step.label if differing.empty?
503
+
504
+ "with #{differing.map { |key, value| "#{Support.keyword_literal(key)}: #{value.is_a?(Code) ? value.source : value_text(value, [])}" }.join(", ")}"
505
+ end
506
+
507
+ def receiver_expression
508
+ @target.singleton ? @const : @receiver
509
+ end
510
+
511
+ def entry_call(input = nil, thawed = false)
512
+ arguments = input ? Support.argument_list(input, thawed: thawed) : ""
513
+ Support.with_block(Support.call_text(receiver_expression, @target.method_name, arguments, @private_calls), input&.block)
514
+ end
515
+
516
+ def receiver_name
517
+ Support.built_receiver_name(@target)
518
+ end
519
+
520
+ # The method's name, when it reads as a plain local name; false for
521
+ # an unnamed subject.
522
+ def subject_name
523
+ name = @target.method_name.to_s
524
+ return false unless name.match?(/\A[a-z_][a-z0-9_]*[?!]?\z/)
525
+ return false if name == @receiver || TAKEN_NAMES.include?(name) || Support::RESERVED_NAMES.include?(name)
526
+
527
+ name
528
+ end
529
+ end
530
+ end
531
+ end
532
+ end