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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +248 -0
- data/README.md +324 -23
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +182 -23
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +175 -12
- data/lib/mutineer/daemon_backend.rb +182 -56
- data/lib/mutineer/daemon_client.rb +30 -0
- data/lib/mutineer/daemon_server.rb +157 -25
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +14 -2
- data/lib/mutineer/job_plan.rb +129 -23
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +72 -0
- data/lib/mutineer/project.rb +80 -6
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1408 -53
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +66 -12
- data/lib/mutineer/result.rb +22 -13
- data/lib/mutineer/runner.rb +24 -12
- data/lib/mutineer/statement_lines.rb +15 -8
- data/lib/mutineer/subject.rb +22 -4
- data/lib/mutineer/test_runners/rspec.rb +15 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +4 -0
- metadata +9 -1
|
@@ -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
|