mutineer 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,705 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "yaml"
5
+ require_relative "job_plan"
6
+ require_relative "mutator_registry"
7
+
8
+ module Mutineer
9
+ # Rewrites old-format mutant ids in a `.mutineer.yml` `ignore:` list to the
10
+ # current id format. The map is the inverse of the `id_map` from
11
+ # {JobPlan.collect_jobs}: each old id goes to every new id that still hashes
12
+ # to it, in collection order. The map covers only the configured operators
13
+ # and the sources on the command line. Ids are static hashes, so this runs
14
+ # no tests.
15
+ #
16
+ # The file is edited as text. A YAML load and dump would drop comments and
17
+ # reorder keys. Only a bare id inside `ignore:` is replaced. A flow list may
18
+ # span lines. A scalar changes only when its whole value is an id. A block
19
+ # scalar under `ignore:` raises ArgumentError. In a `{ id:, reason: }`
20
+ # mapping the id is replaced in place: `- id: x` lines, `- { id: x }` items,
21
+ # and `{ id: x }` entries inside `ignore: [...]`. Comments and reason text
22
+ # are never touched. An old id that now matches several mutants cannot fit
23
+ # one mapping, so it is left in place and reported for a hand edit. A
24
+ # baseline file is never read.
25
+ module Migrate
26
+ # One old id and the new ids that replace it, in collection order.
27
+ Replacement = Data.define(:old_id, :new_ids)
28
+
29
+ # The outcome of rewriting one file in memory. `text` is what to write.
30
+ # It equals the input when nothing changed. `unmapped` holds bare ids that
31
+ # matched no mutant, in file order, each once.
32
+ #
33
+ # `split` holds old ids in a mapping entry that now match several new ids,
34
+ # as {Replacement}s: one mapping holds one id, so the user splits it by hand.
35
+ Outcome = Data.define(:replacements, :unmapped, :split, :text)
36
+
37
+ # Builds the id maps for `config`'s sources and operators.
38
+ #
39
+ # @param config [Mutineer::Config] run configuration, with sources set.
40
+ # @return [Array(Set<String>, Hash{String => Array<String>})]
41
+ # the current new ids, and each old id => its new ids.
42
+ def self.id_maps(config)
43
+ names = config.operators || MutatorRegistry::DEFAULT_NAMES
44
+ _jobs, _ignored, _source_map, extras = JobPlan.collect_jobs(config, MutatorRegistry.resolve(names))
45
+ old_to_new = {}
46
+ extras[:id_map].each do |new_id, old_id|
47
+ list = (old_to_new[old_id] ||= [])
48
+ list << new_id unless list.include?(new_id)
49
+ end
50
+ [extras[:id_map].keys.to_set, old_to_new]
51
+ end
52
+
53
+ # Rewrites `ignore:` ids in `text`. An id that is already a current new id
54
+ # stays, so a second run does not change the file. An old id is replaced by
55
+ # every new id it maps to. Any other bare id stays and is listed in
56
+ # {Outcome#unmapped}.
57
+ #
58
+ # A flow list may span lines. Only a scalar whose whole value is an id
59
+ # changes. A block scalar under `ignore:` raises ArgumentError, and so
60
+ # does a flow list this rewrite cannot read safely. The caller writes
61
+ # nothing in that case.
62
+ #
63
+ # @param text [String] the `.mutineer.yml` contents.
64
+ # @param current [Set<String>] new-format ids in this run.
65
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
66
+ # @return [Outcome]
67
+ # @raise [ArgumentError] when an `ignore:` shape cannot be rewritten safely.
68
+ def self.rewrite(text, current, old_to_new)
69
+ replacements = []
70
+ unmapped = []
71
+ split = []
72
+ lines = []
73
+ pending = text.lines
74
+ while (line = pending.shift)
75
+ case line_kind(line)
76
+ when :flow
77
+ lines << rewrite_flow(collect_flow(line, pending), current, old_to_new, replacements, unmapped, split)
78
+ when :scalar
79
+ lines.concat(rewrite_scalar(line, current, old_to_new, replacements, unmapped))
80
+ when :block_scalar
81
+ reject_block_scalar
82
+ when :block
83
+ lines << line
84
+ lines.concat(rewrite_block(pending, current, old_to_new, replacements, unmapped, split))
85
+ else
86
+ lines << line
87
+ end
88
+ end
89
+ out = lines.join
90
+ verify_rewrite!(text, out, current, old_to_new, split)
91
+ Outcome.new(replacements: replacements, unmapped: unmapped, split: split, text: out)
92
+ end
93
+
94
+ # Reads both texts as YAML and checks the text edit did what it claims.
95
+ # An id that still maps to a new id, and was not left for a hand split,
96
+ # means an entry shape the line edit missed (a mapping over several
97
+ # lines, a quoted key). A changed reason means the edit touched reason
98
+ # text. Either is refused, so nothing is written and nothing passes
99
+ # silently.
100
+ #
101
+ # @param before [String] the original file text.
102
+ # @param after [String] the rewritten text.
103
+ # @param current [Set<String>] new-format ids in this run.
104
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
105
+ # @param split [Array<Replacement>] mapping ids left for a hand split.
106
+ # @return [void]
107
+ # @raise [ArgumentError] when an old id was missed or a reason changed.
108
+ def self.verify_rewrite!(before, after, current, old_to_new, split)
109
+ old_ids, old_reasons = ignore_entries(before) || [[], []]
110
+ parsed = ignore_entries(after)
111
+ raise ArgumentError, "migrate would write a .mutineer.yml that is not valid YAML. Edit the ignore ids by hand." unless parsed
112
+
113
+ new_ids, new_reasons = parsed
114
+ left = split.map(&:old_id)
115
+ missed = (new_ids & old_ids).find { |id| old_to_new.key?(id) && !current.include?(id) && !left.include?(id) }
116
+ if missed
117
+ raise ArgumentError, "ignore entry #{missed} is in a shape migrate cannot edit. " \
118
+ "Write it as \"- id: #{missed}\" on its own line, or as a bare id."
119
+ end
120
+ return if old_reasons == new_reasons
121
+
122
+ raise ArgumentError, "migrate would change the text of an ignore: reason. Edit those ids by hand."
123
+ end
124
+
125
+ # The ids and reasons in a `.mutineer.yml` `ignore:` value, read as YAML,
126
+ # in file order. Ids are read as {Config.ignore_id_text} reads them.
127
+ #
128
+ # @param text [String] the file text.
129
+ # @return [Array(Array<String>, Array<String>), nil] ids and reasons, or nil when the text is not valid YAML.
130
+ def self.ignore_entries(text)
131
+ doc = YAML.safe_load(text)
132
+ value = doc.is_a?(Hash) ? doc["ignore"] : nil
133
+ items = value.is_a?(Array) ? value : [value].compact
134
+ ids = []
135
+ reasons = []
136
+ items.each do |item|
137
+ if item.is_a?(Hash)
138
+ ids << Config.ignore_id_text(item["id"])
139
+ reasons << item["reason"].to_s
140
+ else
141
+ ids << Config.ignore_id_text(item)
142
+ end
143
+ end
144
+ [ids.compact, reasons]
145
+ rescue Psych::Exception
146
+ nil
147
+ end
148
+ private_class_method :verify_rewrite!, :ignore_entries
149
+
150
+ class << self
151
+ private
152
+
153
+ # A block-list item whose value is one bare id.
154
+ ITEM = /\A([ \t]*-[ \t]+)(['"]?)([0-9a-f]{12})\2([ \t]*(?:\#.*)?)(\r?\n)?\z/
155
+
156
+ # A block-mapping line whose whole value is the `id:` key: `- id: x` or
157
+ # an indented `id: x`. Comments and reason text never match.
158
+ MAPPING_ID_LINE = /\A([ \t]*(?:-[ \t]+)?)(id[ \t]*:[ \t]*)(['"]?)([0-9a-f]{12})\3([ \t]*(?:\#.*)?)(\r?\n)?\z/
159
+
160
+ # A block-list item whose value is a flow mapping, `- { id: x, ... }`.
161
+ FLOW_MAPPING_ITEM = /\A([ \t]*-[ \t]+)(\{.*\})([ \t]*(?:\#.*)?)(\r?\n)?\z/
162
+
163
+ # Told to the user when a flow mapping's `id` cannot be located safely.
164
+ MAPPING_UNSUPPORTED_MESSAGE = "this ignore: { id:, reason: } entry is not supported. " \
165
+ "Write id: on its own line under the list item."
166
+
167
+ # `ignore:` followed by one bare id on the same line.
168
+ SCALAR = /\A(ignore:[ \t]*)(['"]?)([0-9a-f]{12})\2([ \t]*(?:\#.*)?)(\r?\n)?\z/
169
+
170
+ # `ignore: |` or `ignore: >`, including chomp and indent markers.
171
+ KEY_BLOCK_SCALAR = /\Aignore:[ \t]*[|>]/
172
+
173
+ # A list item whose value is a block scalar, such as `- |`.
174
+ ITEM_BLOCK_SCALAR = /\A[ \t]*-[ \t]+[|>]/
175
+
176
+ # An indented `|` or `>` that is the whole `ignore:` value.
177
+ VALUE_BLOCK_SCALAR = /\A[ \t]+[|>]/
178
+
179
+ # Told to the user when `ignore:` is a YAML block scalar. Exit 2.
180
+ BLOCK_SCALAR_MESSAGE = "a block scalar under ignore: is not supported. " \
181
+ "Write ignore as a list of ids, one per line."
182
+
183
+ # Told to the user when a flow `ignore:` list cannot be rewritten safely.
184
+ FLOW_UNSUPPORTED_MESSAGE = "this ignore: flow list is not supported. " \
185
+ "Write ignore as a list of ids, one per line."
186
+
187
+ # Told to the user when `ignore: [` never finds its closing bracket.
188
+ UNCLOSED_FLOW_MESSAGE = "an ignore: flow list has no closing ]. " \
189
+ "Write ignore as a list of ids, one per line."
190
+
191
+ # Which `ignore:` shape this line opens, or nil when it is some other line.
192
+ #
193
+ # @param line [String] one line of the file, its terminator included.
194
+ # @return [Symbol, nil] `:flow`, `:block_scalar`, `:block`, `:scalar`, or nil.
195
+ def line_kind(line)
196
+ return :flow if line.match?(/\Aignore:[ \t]*\[/)
197
+ return :block_scalar if line.match?(KEY_BLOCK_SCALAR)
198
+ return :block if line.match?(/\Aignore:[ \t]*(?:\#.*)?\r?\n?\z/)
199
+ return :scalar if line.match?(/\Aignore:[ \t]*\S/)
200
+
201
+ nil
202
+ end
203
+
204
+ # Whether `line` still belongs to a block-style `ignore:` value.
205
+ # A later root key does not. A blank line, a comment, an indented line,
206
+ # or a list item (indented or at column 0) does.
207
+ #
208
+ # @param line [String] the next line.
209
+ # @return [Boolean]
210
+ def inside_block?(line)
211
+ line.match?(/\A[ \t]*(?:\#.*)?\r?\n?\z/) || line.match?(/\A[ \t]*-[ \t]/) || line.match?(/\A[ \t]+\S/)
212
+ end
213
+
214
+ # The ids to write for one bare id. Records a replacement or an unmapped
215
+ # id. An id that is already current is returned unchanged and unrecorded,
216
+ # even when it also equals some other mutant's old id.
217
+ #
218
+ # @param id [String] the id in the file.
219
+ # @param current [Set<String>] new-format ids in this run.
220
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
221
+ # @param replacements [Array<Replacement>] recorded changes, in file order.
222
+ # @param unmapped [Array<String>] bare ids that matched nothing.
223
+ # @return [Array<String>] the ids to write in this position.
224
+ def mapped_ids(id, current, old_to_new, replacements, unmapped)
225
+ return [id] if current.include?(id)
226
+
227
+ news = old_to_new[id]
228
+ if news
229
+ replacements << Replacement.new(old_id: id, new_ids: news.dup) unless news == [id]
230
+ news.dup
231
+ else
232
+ unmapped << id unless unmapped.include?(id)
233
+ [id]
234
+ end
235
+ end
236
+
237
+ # Lines from `ignore: [` through the line that closes the flow list.
238
+ # Quotes and comments do not hide the closing bracket, and they do not
239
+ # supply a false one.
240
+ #
241
+ # @param line [String] the opening line, its terminator included.
242
+ # @param pending [Array<String>] the lines still to read.
243
+ # @return [String] the whole flow value, newlines included.
244
+ # @raise [ArgumentError] when the closing bracket is missing.
245
+ def collect_flow(line, pending)
246
+ chunk = line.dup
247
+ return chunk if matching_index(chunk, chunk.index("["), "[", "]")
248
+
249
+ while (next_line = pending.shift)
250
+ chunk << next_line
251
+ return chunk if matching_index(chunk, chunk.index("["), "[", "]")
252
+ end
253
+ raise ArgumentError, UNCLOSED_FLOW_MESSAGE
254
+ end
255
+
256
+ # Index of the `closer` that matches the `opener` at `start`, or nil.
257
+ # Quoted text and comments are not structure.
258
+ #
259
+ # @param text [String]
260
+ # @param start [Integer] index of the opening bracket.
261
+ # @param opener [String] `[` or `{`.
262
+ # @param closer [String] `]` or `}`.
263
+ # @return [Integer, nil]
264
+ def matching_index(text, start, opener, closer)
265
+ index = start + 1
266
+ depth = 1
267
+ state = :plain
268
+ while index < text.length
269
+ char = text[index]
270
+ case state
271
+ when :plain
272
+ if char == "'"
273
+ state = :single
274
+ elsif char == '"'
275
+ state = :double
276
+ elsif comment_at?(text, index)
277
+ newline = text.index("\n", index)
278
+ return nil unless newline
279
+
280
+ index = newline + 1
281
+ next
282
+ elsif char == opener
283
+ depth += 1
284
+ elsif char == closer
285
+ depth -= 1
286
+ return index if depth.zero?
287
+ end
288
+ when :single
289
+ if char == "'"
290
+ if text[index + 1] == "'"
291
+ index += 1
292
+ else
293
+ state = :plain
294
+ end
295
+ end
296
+ when :double
297
+ if char == "\\"
298
+ return nil if index + 1 >= text.length
299
+
300
+ index += 1
301
+ elsif char == '"'
302
+ state = :plain
303
+ end
304
+ end
305
+ index += 1
306
+ end
307
+ nil
308
+ end
309
+
310
+ # Whether `index` starts a YAML comment. A `#` inside a token does not.
311
+ #
312
+ # @param text [String]
313
+ # @param index [Integer]
314
+ # @return [Boolean]
315
+ def comment_at?(text, index)
316
+ return false unless text[index] == "#"
317
+
318
+ index.zero? || text[index - 1].match?(/\s/)
319
+ end
320
+
321
+ # Rewrites whole id scalars between the brackets of a flow list.
322
+ # The list may span lines. A scalar that only contains an id is kept.
323
+ # A `{ ... }` entry is kept. Nested lists and other shapes raise.
324
+ #
325
+ # @param chunk [String] `ignore: [` through the closing bracket.
326
+ # @param current [Set<String>] new-format ids in this run.
327
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
328
+ # @param replacements [Array<Replacement>] recorded changes.
329
+ # @param unmapped [Array<String>] bare ids that matched nothing.
330
+ # @param split [Array<Replacement>] mapping ids that now match several mutants.
331
+ # @return [String] the text to write.
332
+ # @raise [ArgumentError] when the list is not a flat list of scalars.
333
+ def rewrite_flow(chunk, current, old_to_new, replacements, unmapped, split)
334
+ open = chunk.index("[")
335
+ close = matching_index(chunk, open, "[", "]")
336
+ raise ArgumentError, UNCLOSED_FLOW_MESSAGE unless close
337
+
338
+ inner = rewrite_flow_items(chunk[(open + 1)...close], current, old_to_new, replacements, unmapped, split)
339
+ "#{chunk[0..open]}#{inner}#{chunk[close..]}"
340
+ end
341
+
342
+ # Rewrites the text between the flow brackets.
343
+ #
344
+ # @param inner [String] text between `[` and `]`.
345
+ # @param current [Set<String>] new-format ids in this run.
346
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
347
+ # @param replacements [Array<Replacement>] recorded changes.
348
+ # @param unmapped [Array<String>] bare ids that matched nothing.
349
+ # @param split [Array<Replacement>] mapping ids that now match several mutants.
350
+ # @return [String]
351
+ # @raise [ArgumentError] when an entry is not a scalar or a `{ ... }` entry.
352
+ def rewrite_flow_items(inner, current, old_to_new, replacements, unmapped, split)
353
+ out = +""
354
+ index = 0
355
+ while index < inner.length
356
+ char = inner[index]
357
+ if char.match?(/\s/) || char == ","
358
+ out << char
359
+ index += 1
360
+ elsif comment_at?(inner, index)
361
+ newline = inner.index("\n", index) || inner.length
362
+ out << inner[index...newline]
363
+ index = newline
364
+ else
365
+ raw, next_index = read_flow_item(inner, index)
366
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE if next_index <= index
367
+
368
+ out << if raw.start_with?("{")
369
+ rewrite_flow_mapping(raw, current, old_to_new, replacements, unmapped, split)
370
+ else
371
+ replace_flow_scalar(raw, current, old_to_new, replacements, unmapped)
372
+ end
373
+ index = next_index
374
+ end
375
+ end
376
+ out
377
+ end
378
+
379
+ # One flow entry starting at `index`: a quoted scalar, a plain scalar,
380
+ # or a whole `{ ... }` entry (its id is rewritten by {rewrite_flow_mapping}).
381
+ #
382
+ # @param text [String]
383
+ # @param index [Integer]
384
+ # @return [Array(String, Integer)] the raw entry and the index after it.
385
+ # @raise [ArgumentError] when the entry is not one of those shapes.
386
+ def read_flow_item(text, index)
387
+ case text[index]
388
+ when "'" then read_single_quoted(text, index)
389
+ when '"' then read_double_quoted(text, index)
390
+ when "{" then read_wrapped(text, index, "{", "}")
391
+ when "[", "|", ">", "!", "&", "*", "?"
392
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE
393
+ else
394
+ read_plain(text, index)
395
+ end
396
+ end
397
+
398
+ # A single-quoted scalar, including its quotes. `''` is an escaped quote.
399
+ #
400
+ # @param text [String]
401
+ # @param start [Integer] index of the opening quote.
402
+ # @return [Array(String, Integer)]
403
+ # @raise [ArgumentError] when the quote does not close.
404
+ def read_single_quoted(text, start)
405
+ index = start + 1
406
+ while index < text.length
407
+ if text[index] == "'"
408
+ if text[index + 1] == "'"
409
+ index += 2
410
+ next
411
+ end
412
+ return [text[start..index], index + 1]
413
+ end
414
+ index += 1
415
+ end
416
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE
417
+ end
418
+
419
+ # A double-quoted scalar, including its quotes. Backslash escapes are
420
+ # skipped so they cannot end the scalar early.
421
+ #
422
+ # @param text [String]
423
+ # @param start [Integer] index of the opening quote.
424
+ # @return [Array(String, Integer)]
425
+ # @raise [ArgumentError] when the quote does not close.
426
+ def read_double_quoted(text, start)
427
+ index = start + 1
428
+ while index < text.length
429
+ char = text[index]
430
+ if char == "\\"
431
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE if index + 1 >= text.length
432
+
433
+ index += 2
434
+ next
435
+ end
436
+ return [text[start..index], index + 1] if char == '"'
437
+
438
+ index += 1
439
+ end
440
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE
441
+ end
442
+
443
+ # The `{ ... }` or other wrapped entry starting at `start`, copied whole.
444
+ #
445
+ # @param text [String]
446
+ # @param start [Integer] index of the opener.
447
+ # @param opener [String]
448
+ # @param closer [String]
449
+ # @return [Array(String, Integer)]
450
+ # @raise [ArgumentError] when the closer is missing.
451
+ def read_wrapped(text, start, opener, closer)
452
+ stop = matching_index(text, start, opener, closer)
453
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE unless stop
454
+
455
+ [text[start..stop], stop + 1]
456
+ end
457
+
458
+ # A plain flow scalar. It ends at a comma, a comment, or a line break.
459
+ # A colon means the entry may be a mapping, which this rewrite does not
460
+ # split safely.
461
+ #
462
+ # @param text [String]
463
+ # @param start [Integer]
464
+ # @return [Array(String, Integer)]
465
+ # @raise [ArgumentError] when the entry is not a plain scalar.
466
+ def read_plain(text, start)
467
+ index = start
468
+ while index < text.length
469
+ char = text[index]
470
+ break if char == "," || char == "\n" || char == "\r"
471
+ break if comment_at?(text, index)
472
+ if char == ":" || char == "[" || char == "]" || char == "{" || char == "}" ||
473
+ char == "'" || char == '"'
474
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE
475
+ end
476
+
477
+ index += 1
478
+ end
479
+ [text[start...index], index]
480
+ end
481
+
482
+ # The quote, the id, and the gaps around it when `raw` is exactly one id.
483
+ # Nil when `raw` is any other scalar.
484
+ #
485
+ # @param raw [String] one flow entry, quotes included.
486
+ # @return [Array(String, String, String, String), nil]
487
+ def flow_scalar_id(raw)
488
+ if (match = raw.match(/\A([0-9a-f]{12})([ \t]*)\z/))
489
+ return ["", match[1], "", match[2]]
490
+ end
491
+ if raw.start_with?("'") && raw.end_with?("'")
492
+ value = raw[1..-2].gsub("''", "'")
493
+ return ["'", value, "", ""] if value.match?(/\A[0-9a-f]{12}\z/)
494
+ end
495
+ if raw.start_with?('"') && raw.end_with?('"')
496
+ value = raw[1..-2]
497
+ # A backslash can hide an id. Do not guess the decoded text.
498
+ raise ArgumentError, FLOW_UNSUPPORTED_MESSAGE if value.include?("\\")
499
+
500
+ return ['"', value, "", ""] if value.match?(/\A[0-9a-f]{12}\z/)
501
+ end
502
+ nil
503
+ end
504
+
505
+ # Replaces `raw` when its whole scalar value is an id. Several new ids
506
+ # become several entries. Any other entry is returned unchanged.
507
+ #
508
+ # @param raw [String] one flow entry.
509
+ # @param current [Set<String>] new-format ids in this run.
510
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
511
+ # @param replacements [Array<Replacement>] recorded changes.
512
+ # @param unmapped [Array<String>] bare ids that matched nothing.
513
+ # @return [String]
514
+ def replace_flow_scalar(raw, current, old_to_new, replacements, unmapped)
515
+ parts = flow_scalar_id(raw)
516
+ return raw unless parts
517
+
518
+ quote, value, lead, trail = parts
519
+ ids = mapped_ids(value, current, old_to_new, replacements, unmapped)
520
+ return raw if ids == [value]
521
+
522
+ "#{lead}#{ids.map { |id| "#{quote}#{id}#{quote}" }.join(", ")}#{trail}"
523
+ end
524
+
525
+ # The lines of a block-style `ignore:` value. A block scalar here is
526
+ # rejected. A `{ id:, reason: }` entry is rewritten by {rewrite_item}.
527
+ #
528
+ # @param pending [Array<String>] the lines still to read.
529
+ # @param current [Set<String>] new-format ids in this run.
530
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
531
+ # @param replacements [Array<Replacement>] recorded changes.
532
+ # @param unmapped [Array<String>] bare ids that matched nothing.
533
+ # @param split [Array<Replacement>] mapping ids that now match several mutants.
534
+ # @return [Array<String>]
535
+ # @raise [ArgumentError] when the value or a list item is a block scalar.
536
+ def rewrite_block(pending, current, old_to_new, replacements, unmapped, split)
537
+ written = []
538
+ saw_entry = false
539
+ while (next_line = pending.first) && inside_block?(next_line)
540
+ raise ArgumentError, BLOCK_SCALAR_MESSAGE if block_scalar_entry?(next_line, saw_entry)
541
+
542
+ saw_entry = true if next_line.match?(/\A[ \t]*[^ \t#\r\n]/)
543
+ written.concat(rewrite_item(pending.shift, current, old_to_new, replacements, unmapped, split))
544
+ end
545
+ written
546
+ end
547
+
548
+ # Rejects a block scalar `ignore:` value. The constant lives on this
549
+ # singleton, so {rewrite} calls this method instead of reading it.
550
+ #
551
+ # @return [void]
552
+ # @raise [ArgumentError] always.
553
+ def reject_block_scalar
554
+ raise ArgumentError, BLOCK_SCALAR_MESSAGE
555
+ end
556
+
557
+ # Whether `line` is a block scalar used as the ignore value or as one
558
+ # list item. A `|` under a later key, such as `reason: |`, is not.
559
+ #
560
+ # @param line [String]
561
+ # @param saw_entry [Boolean] true after the first real entry in the value.
562
+ # @return [Boolean]
563
+ def block_scalar_entry?(line, saw_entry)
564
+ line.match?(ITEM_BLOCK_SCALAR) || (!saw_entry && line.match?(VALUE_BLOCK_SCALAR))
565
+ end
566
+
567
+ # Rewrites a one-line `ignore: <id>`. Several new ids become a block list,
568
+ # so each id stays a bare string. The comment stays on the key line.
569
+ #
570
+ # @param line [String] an `ignore: <id>` line.
571
+ # @param current [Set<String>] new-format ids in this run.
572
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
573
+ # @param replacements [Array<Replacement>] recorded changes.
574
+ # @param unmapped [Array<String>] bare ids that matched nothing.
575
+ # @return [Array<String>] the lines to write.
576
+ def rewrite_scalar(line, current, old_to_new, replacements, unmapped)
577
+ match = SCALAR.match(line)
578
+ return [line] unless match
579
+
580
+ ids = mapped_ids(match[3], current, old_to_new, replacements, unmapped)
581
+ quote = match[2]
582
+ if ids.size == 1
583
+ ["#{match[1]}#{quote}#{ids.first}#{quote}#{match[4]}#{match[5]}"]
584
+ else
585
+ ending = match[5].to_s
586
+ ending = "\n" if ending.empty?
587
+ ["ignore:#{match[4]}#{ending}", *render_lines(" - ", quote, ids, "", ending)]
588
+ end
589
+ end
590
+
591
+ # Rewrites one block-list item, or returns the line unchanged when it is
592
+ # not a bare id (a comment, a blank line, or a `{ id:, reason: }` entry).
593
+ #
594
+ # @param line [String] one line inside an `ignore:` block.
595
+ # @param current [Set<String>] new-format ids in this run.
596
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
597
+ # @param replacements [Array<Replacement>] recorded changes.
598
+ # @param unmapped [Array<String>] bare ids that matched nothing.
599
+ # @param split [Array<Replacement>] mapping ids that now match several mutants.
600
+ # @return [Array<String>] the lines to write.
601
+ def rewrite_item(line, current, old_to_new, replacements, unmapped, split)
602
+ match = ITEM.match(line)
603
+ return [rewrite_mapping_id(line, current, old_to_new, replacements, unmapped, split)] unless match
604
+
605
+ ids = mapped_ids(match[3], current, old_to_new, replacements, unmapped)
606
+ render_lines(match[1], match[2], ids, match[4], match[5].to_s)
607
+ end
608
+
609
+ # Replaces the id in a block-mapping `id:` line or a `- { ... }` item,
610
+ # keeping the reason, quotes and comment. Other lines are returned as is.
611
+ #
612
+ # @param line [String] one line inside the `ignore:` block.
613
+ # @param current [Set<String>] new-format ids in this run.
614
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
615
+ # @param replacements [Array<Replacement>] recorded changes, in file order.
616
+ # @param unmapped [Array<String>] bare ids that matched nothing.
617
+ # @param split [Array<Replacement>] mapping ids that now match several mutants.
618
+ # @return [String] the line to write.
619
+ # @raise [ArgumentError] when a flow mapping's id cannot be located safely.
620
+ def rewrite_mapping_id(line, current, old_to_new, replacements, unmapped, split)
621
+ if (match = MAPPING_ID_LINE.match(line))
622
+ new_id = mapping_id(match[4], current, old_to_new, replacements, unmapped, split)
623
+ return "#{match[1]}#{match[2]}#{match[3]}#{new_id}#{match[3]}#{match[5]}#{match[6]}"
624
+ end
625
+ if (match = FLOW_MAPPING_ITEM.match(line))
626
+ mapping = rewrite_flow_mapping(match[2], current, old_to_new, replacements, unmapped, split)
627
+ return "#{match[1]}#{mapping}#{match[3]}#{match[4]}"
628
+ end
629
+ line
630
+ end
631
+
632
+ # Replaces the `id` value in one flow mapping, `{ id: x, reason: y }`.
633
+ # YAML reads the entry first, and the id is replaced only at a key
634
+ # position (after `{` or `,`), so an id quoted inside a reason stays.
635
+ # A mapping with no 12-hex `id` is returned unchanged.
636
+ #
637
+ # @param raw [String] the `{ ... }` text.
638
+ # @param current [Set<String>] new-format ids in this run.
639
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
640
+ # @param replacements [Array<Replacement>] recorded changes, in file order.
641
+ # @param unmapped [Array<String>] bare ids that matched nothing.
642
+ # @param split [Array<Replacement>] mapping ids that now match several mutants.
643
+ # @return [String]
644
+ # @raise [ArgumentError] when the entry does not parse or its id is not at exactly one key position.
645
+ def rewrite_flow_mapping(raw, current, old_to_new, replacements, unmapped, split)
646
+ parsed = begin
647
+ YAML.safe_load(raw)
648
+ rescue Psych::SyntaxError
649
+ raise ArgumentError, MAPPING_UNSUPPORTED_MESSAGE
650
+ end
651
+ id = parsed.is_a?(Hash) ? Config.ignore_id_text(parsed["id"]) : nil
652
+ return raw unless id&.match?(/\A[0-9a-f]{12}\z/)
653
+
654
+ key = /([{,][ \t\r\n]*id[ \t]*:[ \t]*)(['"]?)#{id}\2(?=[ \t\r\n]*[,}])/
655
+ raise ArgumentError, MAPPING_UNSUPPORTED_MESSAGE unless raw.scan(key).size == 1
656
+
657
+ new_id = mapping_id(id, current, old_to_new, replacements, unmapped, split)
658
+ raw.sub(key) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{new_id}#{Regexp.last_match(2)}" }
659
+ end
660
+
661
+ # The one id a mapping entry can hold. An old id that now matches several
662
+ # mutants stays and is recorded in `split` for a hand edit.
663
+ #
664
+ # @param id [String] the id in the file.
665
+ # @param current [Set<String>] new-format ids in this run.
666
+ # @param old_to_new [Hash{String => Array<String>}] old id => new ids.
667
+ # @param replacements [Array<Replacement>] recorded changes, in file order.
668
+ # @param unmapped [Array<String>] bare ids that matched nothing.
669
+ # @param split [Array<Replacement>] mapping ids that now match several mutants.
670
+ # @return [String]
671
+ def mapping_id(id, current, old_to_new, replacements, unmapped, split)
672
+ news = old_to_new[id]
673
+ if news && news.size > 1 && !current.include?(id)
674
+ split << Replacement.new(old_id: id, new_ids: news.dup)
675
+ return id
676
+ end
677
+
678
+ mapped_ids(id, current, old_to_new, replacements, unmapped).first
679
+ end
680
+
681
+ # One list item per id. The original comment stays on the first item.
682
+ # Only the last item keeps the original line ending, so a file that does
683
+ # not end in a newline still does not.
684
+ #
685
+ # @param prefix [String] the indentation and list marker.
686
+ # @param quote [String] `"` or `'` or `""`.
687
+ # @param ids [Array<String>] ids to write.
688
+ # @param suffix [String] the original trailing comment, spaces included.
689
+ # @param ending [String] the original line ending, or `""`.
690
+ # @return [Array<String>]
691
+ def render_lines(prefix, quote, ids, suffix, ending)
692
+ last = ids.size - 1
693
+ ids.each_with_index.map do |id, i|
694
+ tail = i.zero? ? suffix : ""
695
+ mark = if i == last
696
+ ending
697
+ else
698
+ ending.empty? ? "\n" : ending
699
+ end
700
+ "#{prefix}#{quote}#{id}#{quote}#{tail}#{mark}"
701
+ end
702
+ end
703
+ end
704
+ end
705
+ end