mutaruby 0.1.0 → 0.1.1

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.
@@ -26,7 +26,7 @@ module Mutaruby
26
26
  'loop-condition-false' => 'while c: c -> false',
27
27
  'literal-int' => 'n -> n + 1, n - 1, 0, 1 (each distinct value once)',
28
28
  'literal-float' => 'x -> 0.0, x + 1.0',
29
- 'literal-string' => '"abc" -> "", "" -> "mutaruby"',
29
+ 'literal-string' => '"abc" -> "", "" -> "mutaruby" (also single-quoted and %q/%Q strings)',
30
30
  'literal-symbol' => ':sym -> :__mutaruby__',
31
31
  'literal-boolean' => 'true <-> false',
32
32
  'literal-nil' => 'nil -> true',
@@ -62,23 +62,26 @@ module Mutaruby
62
62
  DECLARATIONS = %i[require require_relative load autoload attr_reader attr_writer attr_accessor include extend prepend
63
63
  private public protected module_function private_constant public_constant
64
64
  private_class_method public_class_method define_method alias_method raise fail].freeze
65
- VISIBILITY = %i[private public protected module_function].freeze
65
+ # Bare visibility calls (`private` on its own line) are never deleted: that only changes which methods are public.
66
+ VISIBILITY_CALLS = %i[private public protected module_function].map { |v| Parser::AST::Node.new(:send, [nil, v]) }.freeze
66
67
  # Definitions are not deleted as statements: deleting a whole class or method is caught by any test that touches
67
68
  # it, and the interesting mutants are inside (method-body-nil and the operators on the body).
68
69
  STRUCTURAL = %i[module class sclass def defs].freeze
69
70
  PREDICATE_SKIP = %i[block_given? frozen? respond_to? respond_to_missing? nil?].freeze
70
71
  DISABLE_MARK = 'mutaruby:disable'
72
+ MAX_ARGS = 6 # argument-removal skips calls with more arguments than this
71
73
 
72
74
  attr_reader :errors
73
75
 
74
76
  # path: file path for messages; rel: path relative to the project root (stored on the mutants).
77
+ # operators: nil (all) or a list of operator ids.
75
78
  def initialize(source, path:, rel: path, operators: nil)
76
79
  @src = source
77
80
  @path = path
78
81
  @rel = rel
79
- @operators = operators # nil = all, else a Set of ids
82
+ @operators = operators
80
83
  @valid_encoding = source.valid_encoding?
81
- @lines = @valid_encoding ? source.split("\n", -1) : []
84
+ @lines = @valid_encoding ? source.lines.map(&:strip) : []
82
85
  @occurrence = occurrence_index(@lines)
83
86
  @errors = []
84
87
  end
@@ -97,20 +100,19 @@ module Mutaruby
97
100
  ast = Parsing.parse(@path, @src)
98
101
  @out = []
99
102
  @seen = {}
100
- @scope = [] # namespace names ("Foo", "Bar")
101
- @meth = [] # enclosing method names, qualified
102
- @singleton = 0
103
- walk(ast, nil) if ast
103
+ @scope = [] # namespace names ("Foo", "Bar")
104
+ @meth = [] # enclosing method names, qualified; @singleton: inside `class << self`
105
+ walk(ast, nil)
104
106
  @out.each_with_index { |m, i| m.id = i }
105
- @out
106
- rescue Parser::SyntaxError, EncodingError, ArgumentError => e
107
- @errors << "#{@path}: cannot parse (#{e.message}); skipped"
107
+ rescue Parser::SyntaxError => e
108
+ @errors << "#{@path}: cannot parse (#{e}); skipped"
108
109
  []
109
110
  end
110
111
 
112
+ # lineno => 1-based index of that line among the identical (stripped) lines of the file.
111
113
  def occurrence_index(lines)
112
114
  seen = Hash.new(0)
113
- lines.each_with_index.to_h { |l, i| [i + 1, seen[l.strip] += 1] }
115
+ lines.each_with_index.to_h { |l, i| [i + 1, seen[l] += 1] }
114
116
  end
115
117
 
116
118
  def enabled?(op)
@@ -125,7 +127,7 @@ module Mutaruby
125
127
  return if orig == repl
126
128
 
127
129
  lineno = range.line
128
- line = @lines[lineno - 1].to_s
130
+ line = @lines[lineno - 1]
129
131
  return if line.include?(DISABLE_MARK)
130
132
 
131
133
  text = Splicer.splice(@src, range.begin_pos, range.end_pos, orig, repl)
@@ -134,7 +136,7 @@ module Mutaruby
134
136
  @seen[text] = true
135
137
  return unless Parsing.valid?(@path, text)
136
138
 
137
- @out << Mutation.new(file: @rel, operator: op, orig: orig, repl: repl, lineno: lineno, line_text: line.strip,
139
+ @out << Mutation.new(file: @rel, operator: op, orig: orig, repl: repl, lineno: lineno, line_text: line,
138
140
  occurrence: @occurrence[lineno], method: @meth.last, text: text)
139
141
  end
140
142
 
@@ -151,31 +153,34 @@ module Mutaruby
151
153
 
152
154
  case node.type
153
155
  when :module, :class
154
- visit(node, parent)
155
156
  @scope.push(src_of(node.children[0]))
156
- node.children.each { |c| walk(c, node) }
157
+ walk_children(node)
157
158
  @scope.pop
158
159
  when :sclass
159
- visit(node, parent)
160
- @singleton += 1
161
- node.children.each { |c| walk(c, node) }
162
- @singleton -= 1
160
+ outer = @singleton
161
+ @singleton = true
162
+ walk_children(node)
163
+ @singleton = outer
163
164
  when :def, :defs
164
165
  @meth.push(qualified(node))
165
166
  visit(node, parent)
166
- node.children.each { |c| walk(c, node) }
167
+ walk_children(node)
167
168
  @meth.pop
168
169
  else
169
170
  visit(node, parent)
170
- node.children.each { |c| walk(c, node) }
171
+ walk_children(node)
171
172
  end
172
173
  end
173
174
 
175
+ def walk_children(node)
176
+ node.children.each { |c| walk(c, node) }
177
+ end
178
+
174
179
  def qualified(node)
175
180
  ns = @scope.join('::')
176
181
  if node.type == :defs
177
182
  "#{ns.empty? ? src_of(node.children[0]) : ns}.#{node.children[1]}"
178
- elsif @singleton.positive?
183
+ elsif @singleton
179
184
  "#{ns}.#{node.children[0]}"
180
185
  else
181
186
  ns.empty? ? node.children[0].to_s : "#{ns}##{node.children[0]}"
@@ -218,44 +223,31 @@ module Mutaruby
218
223
 
219
224
  def visit_send(n, parent)
220
225
  recv, meth, *args = *n
221
- sel = n.loc.selector
222
226
  SWAPS.each do |op, table|
223
- add(sel, table[meth].to_s, op) if table.key?(meth) && recv && args.size == 1 && sel
224
- end
225
- if meth == :! && recv
226
- add(n.loc.expression, src_of(recv), 'negation-removal')
227
- elsif meth == :-@ && recv
228
- add(n.loc.expression, src_of(recv), 'unary-minus-removal')
227
+ add(n.loc.selector, table[meth].to_s, op) if table.key?(meth) && args.size == 1
229
228
  end
230
- add(n.loc.dot, '.', 'safe-navigation') if n.type == :csend && n.loc.dot
231
- if recv.nil? && %i[raise fail].include?(meth)
232
- add(n.loc.expression, 'nil', 'raise-removal')
233
- return
234
- end
235
- name = meth.to_s
236
- if name.end_with?('?') && !PREDICATE_SKIP.include?(meth)
229
+ add(n.loc.expression, src_of(recv), 'negation-removal') if meth == :!
230
+ add(n.loc.expression, src_of(recv), 'unary-minus-removal') if meth == :-@
231
+ add(n.loc.dot, '.', 'safe-navigation') if n.type == :csend
232
+ add(n.loc.expression, 'nil', 'raise-removal') if recv.nil? && %i[raise fail].include?(meth)
233
+ if meth.end_with?('?') && !PREDICATE_SKIP.include?(meth)
237
234
  add(n.loc.expression, 'true', 'predicate-true')
238
235
  add(n.loc.expression, 'false', 'predicate-false')
239
236
  end
240
- plain = name.match?(/\A[a-z_]\w*[?!]?\z/) # a named method, not an operator or a writer
237
+ plain = meth.match?(/\A[a-z_]\w*[?!]?\z/) # a named method, not an operator or a writer
241
238
  block_call = parent&.type == :block && parent.children[0].equal?(n)
242
- if recv && plain && sel && !block_call
239
+ if recv && plain && !block_call
243
240
  add(n.loc.expression, src_of(recv), 'call-to-receiver')
244
- first = args.first
245
- add(n.loc.expression, src_of(first), 'call-to-argument') if first && !%i[block_pass splat kwsplat hash].include?(first.type)
241
+ add(n.loc.expression, src_of(args.first), 'call-to-argument') if args.any?
246
242
  end
247
- remove_arguments(n, args) if plain && !DECLARATIONS.include?(meth) && args.any? && parent&.type != :masgn
243
+ remove_arguments(args) if plain && !DECLARATIONS.include?(meth) && args.size.between?(1, MAX_ARGS)
248
244
  end
249
245
 
250
- def remove_arguments(n, args)
251
- return if args.size > 6
252
-
253
- first = args.first.loc.expression
254
- last = args.last.loc.expression
255
- range = first.join(last)
246
+ def remove_arguments(args)
247
+ range = args.first.loc.expression.join(args.last.loc.expression)
256
248
  args.each_index do |i|
257
- rest = args.each_with_index.reject { |_, j| j == i }.map { |a, _| src_of(a) }
258
- add(range, rest.join(', '), 'argument-removal')
249
+ rest = args.reject.with_index { |_, j| j == i }
250
+ add(range, rest.map { |a| src_of(a) }.join(', '), 'argument-removal')
259
251
  end
260
252
  end
261
253
 
@@ -269,53 +261,47 @@ module Mutaruby
269
261
  end
270
262
 
271
263
  def visit_if(n)
272
- cond = n.children[0]
273
- return unless cond.is_a?(Parser::AST::Node)
274
-
264
+ cond = n.children.first
275
265
  add(cond.loc.expression, "!(#{src_of(cond)})", 'condition-negate')
276
266
  add(cond.loc.expression, 'true', 'condition-true')
277
267
  add(cond.loc.expression, 'false', 'condition-false')
278
268
  end
279
269
 
280
270
  def visit_loop(n)
281
- cond = n.children[0]
271
+ cond = n.children.first
282
272
  add(cond.loc.expression, "!(#{src_of(cond)})", 'loop-condition-negate')
283
273
  add(cond.loc.expression, 'false', 'loop-condition-false') if %i[while while_post].include?(n.type)
284
274
  end
285
275
 
286
276
  def visit_int(n)
287
- v = n.children[0]
288
- [v + 1, v - 1, 0, 1].uniq.reject { |x| x == v }.each { |x| add(n.loc.expression, x.to_s, 'literal-int') }
277
+ v = n.children.first
278
+ [v + 1, v - 1, 0, 1].each { |x| add(n.loc.expression, x.to_s, 'literal-int') }
289
279
  end
290
280
 
291
281
  def visit_float(n)
292
- v = n.children[0]
282
+ v = n.children.first
293
283
  add(n.loc.expression, '0.0', 'literal-float') unless v.zero?
294
284
  add(n.loc.expression, (v + 1.0).to_s, 'literal-float') if v.finite?
295
285
  end
296
286
 
287
+ # Strings with both delimiters in the source: '...', "...", %q(...), %Q[...]. Not heredocs, not the parts of an
288
+ # interpolated string, not %w elements, not ?c character literals.
297
289
  def visit_str(n, parent)
298
- return if parent && %i[dstr xstr dsym regexp].include?(parent.type)
299
290
  return if declaration?(parent)
300
291
 
301
- q = n.loc.respond_to?(:begin) && n.loc.begin ? n.loc.begin.source : nil
302
- return unless ['"', "'"].include?(q)
292
+ open = n.loc.begin if n.loc.respond_to?(:begin)
293
+ close = n.loc.end if open
294
+ return unless close
303
295
 
304
- if n.children[0].empty?
305
- add(n.loc.expression, "#{q}mutaruby#{q}", 'literal-string')
306
- else
307
- add(n.loc.expression, "#{q}#{q}", 'literal-string')
308
- end
296
+ fill = n.children.first.empty? ? 'mutaruby' : ''
297
+ add(n.loc.expression, "#{open.source}#{fill}#{close.source}", 'literal-string')
309
298
  end
310
299
 
311
300
  def visit_body(n)
312
- kids = n.children
313
- return unless kids.size > 1
301
+ return unless n.children.size > 1
314
302
 
315
- kids.each do |k|
316
- next unless k.is_a?(Parser::AST::Node)
317
- next if STRUCTURAL.include?(k.type)
318
- next if k.type == :send && k.children[0].nil? && VISIBILITY.include?(k.children[1]) && k.children.size == 2
303
+ n.children.each do |k|
304
+ next if STRUCTURAL.include?(k.type) || VISIBILITY_CALLS.include?(k)
319
305
 
320
306
  add(k.loc.expression, 'nil', 'statement-delete')
321
307
  end
@@ -323,11 +309,10 @@ module Mutaruby
323
309
 
324
310
  def visit_def(n)
325
311
  body = n.children.last
326
- return unless body.is_a?(Parser::AST::Node)
312
+ return unless body
327
313
 
328
314
  add(body.loc.expression, 'nil', 'method-body-nil')
329
- name = n.type == :def ? n.children[0] : n.children[1]
330
- return unless name.to_s.end_with?('?')
315
+ return unless n.children[n.type == :def ? 0 : 1].end_with?('?')
331
316
 
332
317
  add(body.loc.expression, 'true', 'method-body-bool')
333
318
  add(body.loc.expression, 'false', 'method-body-bool')
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require_relative 'generator'
4
5
 
5
6
  module Mutaruby
6
7
  # The ignore file lists equivalent mutants: mutants that cannot be killed because they do not change behaviour.
@@ -31,13 +32,20 @@ module Mutaruby
31
32
  end
32
33
 
33
34
  KEYS = %w[file operator line occurrence method mutation lineno reason].freeze
35
+ REQUIRED = %w[file operator line reason].freeze
36
+ STRINGS = %w[file operator reason method mutation].freeze
34
37
 
35
38
  attr_reader :entries, :path
36
39
 
37
40
  def self.load(path)
38
- new(YAML.safe_load(File.read(path), permitted_classes: [], aliases: false), path: path)
41
+ new(YAML.safe_load(File.read(path)), path: path)
39
42
  rescue Psych::SyntaxError => e
40
- raise Invalid, "#{path}: not valid YAML (#{e.message})"
43
+ raise Invalid, "#{path}: not valid YAML (#{e})"
44
+ end
45
+
46
+ # How many lines of `source` read `text` once stripped.
47
+ def self.count_lines(source, text)
48
+ source.each_line.count { |l| l.strip == text }
41
49
  end
42
50
 
43
51
  def initialize(data, path: '(ignore file)')
@@ -51,21 +59,19 @@ module Mutaruby
51
59
 
52
60
  # Static validation against the current sources: { rel_path => source }. Returns error strings.
53
61
  def validate(sources)
54
- errs = []
55
- @entries.each do |e|
62
+ @entries.filter_map do |e|
56
63
  src = sources[e.file]
57
64
  next unless src # file not part of this run
58
65
 
59
- count = src.split("\n", -1).count { |l| l.strip == e.line }
66
+ count = self.class.count_lines(src, e.line)
60
67
  if count.zero?
61
- errs << "STALE (the line is no longer in the file; re-review or remove the entry): #{e.label}"
68
+ "STALE (the line is no longer in the file; re-review or remove the entry): #{e.label}"
62
69
  elsif e.occurrence.nil? && count > 1
63
- errs << "AMBIGUOUS (the line occurs #{count} times; add `occurrence`): #{e.label}"
70
+ "AMBIGUOUS (the line occurs #{count} times; add `occurrence`): #{e.label}"
64
71
  elsif e.occurrence && e.occurrence > count
65
- errs << "STALE (occurrence #{e.occurrence} but the line now occurs #{count} time(s)): #{e.label}"
72
+ "STALE (occurrence #{e.occurrence} but the line now occurs #{count} time(s)): #{e.label}"
66
73
  end
67
74
  end
68
- errs
69
75
  end
70
76
 
71
77
  # The entry covering this mutant, or nil.
@@ -74,7 +80,7 @@ module Mutaruby
74
80
  e.file == mutation.file && e.operator == mutation.operator && e.line == mutation.line_text &&
75
81
  (e.occurrence || 1) == mutation.occurrence &&
76
82
  (e.method.nil? || e.method == mutation.method) &&
77
- (e.mutation.nil? || normalize(e.mutation) == mutation.change)
83
+ (e.mutation.nil? || e.mutation == mutation.change)
78
84
  end
79
85
  end
80
86
 
@@ -88,44 +94,34 @@ module Mutaruby
88
94
  def self.stub_for(mutations, sources)
89
95
  mutations.map do |m|
90
96
  h = { 'file' => m.file, 'operator' => m.operator, 'line' => m.line_text }
91
- count = sources[m.file].to_s.split("\n", -1).count { |l| l.strip == m.line_text }
92
- h['occurrence'] = m.occurrence if count > 1
97
+ h['occurrence'] = m.occurrence if count_lines(sources[m.file], m.line_text) > 1
93
98
  h['method'] = m.method if m.method
94
- h['mutation'] = m.change
95
- h['lineno'] = m.lineno
96
- h['reason'] = ''
97
- h
99
+ h.merge('mutation' => m.change, 'lineno' => m.lineno, 'reason' => '')
98
100
  end.to_yaml
99
101
  end
100
102
 
101
103
  private
102
104
 
103
- def normalize(s)
104
- s.to_s.gsub(/\s+/, ' ').strip
105
- end
106
-
107
105
  def parse_entry(raw, i)
108
106
  where = "#{@path}: entry ##{i + 1}"
109
107
  raise Invalid, "#{where}: expected a mapping" unless raw.is_a?(Hash)
110
108
 
111
- unknown = raw.keys.map(&:to_s) - KEYS
109
+ unknown = raw.keys - KEYS
112
110
  raise Invalid, "#{where}: unknown key(s) #{unknown.join(', ')}" if unknown.any?
113
111
 
114
- %w[file operator line reason].each do |k|
115
- v = raw[k]
116
- raise Invalid, "#{where}: `#{k}` is required" if v.nil? || v.to_s.strip.empty?
112
+ REQUIRED.each { |k| raise Invalid, "#{where}: `#{k}` is required" if raw[k].to_s.strip.empty? }
113
+ STRINGS.each do |k|
114
+ raise Invalid, "#{where}: `#{k}` must be a string" unless raw[k].nil? || raw[k].is_a?(String)
117
115
  end
118
116
  %w[occurrence lineno].each do |k|
119
117
  v = raw[k]
120
118
  raise Invalid, "#{where}: `#{k}` must be a positive integer" unless v.nil? || (v.is_a?(Integer) && v.positive?)
121
119
  end
122
- unless Generator::OPERATORS.key?(raw['operator'].to_s)
123
- raise Invalid, "#{where}: unknown operator #{raw['operator'].inspect}"
124
- end
120
+ raise Invalid, "#{where}: unknown operator #{raw['operator'].inspect}" unless Generator::OPERATORS.key?(raw['operator'])
125
121
 
126
- Entry.new(file: raw['file'].to_s.sub(%r{\A\./}, ''), operator: raw['operator'].to_s, line: raw['line'].to_s.strip,
127
- occurrence: raw['occurrence'], method: raw['method']&.to_s, mutation: raw['mutation']&.to_s,
128
- lineno: raw['lineno'], reason: raw['reason'].to_s.strip, index: i)
122
+ Entry.new(file: raw['file'].delete_prefix('./'), operator: raw['operator'], line: raw['line'].to_s.strip,
123
+ occurrence: raw['occurrence'], method: raw['method'], mutation: raw['mutation'] && Mutation.squeeze(raw['mutation']),
124
+ lineno: raw['lineno'], reason: raw['reason'].strip, index: i)
129
125
  end
130
126
  end
131
127
  end
@@ -13,9 +13,17 @@ module Mutaruby
13
13
  # text the complete mutated file
14
14
  Mutation = Struct.new(:id, :file, :operator, :orig, :repl, :lineno, :line_text, :occurrence, :method, :text,
15
15
  keyword_init: true) do
16
- def short(str, max = 80)
16
+ LABEL_MAX = 80
17
+
18
+ # Whitespace runs collapsed to one space, ends stripped.
19
+ def self.squeeze(text)
20
+ text.gsub(/\s+/, ' ').strip
21
+ end
22
+
23
+ # One-line form of a source slice, cut to LABEL_MAX characters.
24
+ def short(str)
17
25
  s = str.gsub(/\s+/, ' ')
18
- s.length > max ? "#{s[0, max - 3]}..." : s
26
+ s.length > LABEL_MAX ? "#{s[0, LABEL_MAX - 3]}..." : s
19
27
  end
20
28
 
21
29
  # Human label: "comparison: > => >="
@@ -23,26 +31,21 @@ module Mutaruby
23
31
  "#{operator}: #{short(orig)} => #{short(repl)}"
24
32
  end
25
33
 
26
- # Normalised "orig => repl" (whitespace collapsed); the optional `mutation` field of an ignore entry.
34
+ # "orig => repl", squeezed; the optional `mutation` field of an ignore entry.
27
35
  def change
28
- "#{orig.gsub(/\s+/, ' ').strip} => #{repl.gsub(/\s+/, ' ').strip}"
36
+ Mutation.squeeze("#{orig} => #{repl}")
29
37
  end
30
38
 
31
39
  def location
32
40
  "#{file}:#{lineno}"
33
41
  end
34
42
 
35
- # The mutated region with one line of context, as a minimal unified-style diff.
43
+ # The replaced lines and their replacement, as a minimal unified-style diff.
36
44
  def diff(original_source)
37
- lines = original_source.lines
38
- mlines = text.lines
39
45
  i = lineno - 1
40
- orig_n = orig.count("\n") + 1
41
- repl_n = repl.count("\n") + 1
42
- out = []
43
- (lines[i, orig_n] || []).each { |l| out << "- #{l.chomp}" }
44
- (mlines[i, repl_n] || []).each { |l| out << "+ #{l.chomp}" }
45
- out.join("\n")
46
+ old = original_source.lines[i, orig.count("\n") + 1].map { |l| "- #{l.chomp}" }
47
+ new = text.lines[i, repl.count("\n") + 1].map { |l| "+ #{l.chomp}" }
48
+ (old + new).join("\n")
46
49
  end
47
50
 
48
51
  def to_h
@@ -1,40 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'parser'
4
+ require_relative 'error'
4
5
 
5
6
  module Mutaruby
6
7
  # Picks the newest `parser` grammar that is not newer than the running Ruby. `parser/current` prints a warning on
7
8
  # Rubies it does not know yet; choosing the class ourselves keeps the output clean and the behaviour explicit.
8
9
  module Parsing
10
+ OLDEST = 31 # Ruby 3.1
11
+
9
12
  class << self
10
13
  def parser_class
11
- @parser_class ||= begin
12
- major, minor = RUBY_VERSION.split('.').map(&:to_i)
13
- wanted = (major * 10) + minor
14
- found = nil
15
- wanted.downto(31) do |v|
16
- begin
17
- require "parser/ruby#{v}"
18
- found = Parser.const_get("Ruby#{v}")
19
- break
20
- rescue LoadError, NameError
21
- next
22
- end
23
- end
24
- found || raise(Error, "no parser grammar available for Ruby #{RUBY_VERSION}")
25
- end
14
+ @parser_class ||= grammar_for(RUBY_VERSION)
26
15
  end
27
16
 
28
- def buffer(path, source)
29
- Parser::Source::Buffer.new(path, source: source)
17
+ # The parser class for a Ruby version string ("3.3.5" -> Parser::Ruby33), falling back to older grammars.
18
+ def grammar_for(version)
19
+ major, minor = version.split('.').map(&:to_i)
20
+ ((major * 10) + minor).downto(OLDEST) do |v|
21
+ require "parser/ruby#{v}"
22
+ return Parser.const_get("Ruby#{v}")
23
+ rescue LoadError
24
+ # this `parser` release has no grammar for v; try the next older one
25
+ end
26
+ raise Error, "no parser grammar available for Ruby #{version}"
30
27
  end
31
28
 
32
29
  # Parse `source`; returns the AST or raises Parser::SyntaxError.
33
30
  def parse(path, source)
34
31
  p = parser_class.new
35
32
  p.diagnostics.all_errors_are_fatal = true
36
- p.diagnostics.ignore_warnings = true
37
- p.parse(buffer(path, source))
33
+ p.parse(Parser::Source::Buffer.new(path, source: source))
38
34
  end
39
35
 
40
36
  def valid?(path, source)
@@ -22,7 +22,7 @@ module Mutaruby
22
22
  io.puts
23
23
  io.puts " #{m.location}#{m.method ? " in #{m.method}" : ''} [##{m.id}]"
24
24
  io.puts " #{m.label}"
25
- m.diff(report.sources[m.file]).each_line { |l| io.puts " #{l.chomp}" }
25
+ io.puts m.diff(report.sources[m.file]).gsub(/^/, ' ')
26
26
  end
27
27
  io.puts
28
28
  end
@@ -53,11 +53,11 @@ module Mutaruby
53
53
  score: report.score&.round(4), complete: report.complete,
54
54
  counts: { mutants: report.outcomes.size, killed: report.killed, timeout: report.count(:timeout),
55
55
  survived: report.count(:survived), equivalent: report.count(:equivalent) },
56
- files: report.files, baseline_seconds: report.baseline_seconds.round(3), timeout: report.timeout,
56
+ files: report.files, baseline_seconds: secs(report.baseline_seconds), timeout: report.timeout,
57
57
  mutants: report.outcomes.map do |o|
58
- o.mutation.to_h.merge(status: o.status.to_s, seconds: o.seconds.round(3), ignore_reason: o.ignore&.reason)
58
+ o.mutation.to_h.merge(status: o.status, seconds: secs(o.seconds), ignore_reason: o.ignore&.reason)
59
59
  end,
60
- stale_ignores: report.stale.map(&:to_h).map { |h| h.compact.except(:index) },
60
+ stale_ignores: report.stale.map { |e| e.to_h.compact.except(:index) },
61
61
  warnings: report.warnings
62
62
  }
63
63
  end
@@ -68,36 +68,41 @@ module Mutaruby
68
68
 
69
69
  # JUnit-style XML: one testcase per mutant. A survivor is a failure, an equivalent mutant is skipped.
70
70
  def junit(report, io)
71
- x = ->(s) { CGI.escapeHTML(s.to_s) }
71
+ n = report.outcomes.size
72
72
  failures = report.count(:survived)
73
+ t = secs(report.seconds)
73
74
  io.puts '<?xml version="1.0" encoding="UTF-8"?>'
74
- io.puts %(<testsuites name="mutaruby" tests="#{report.outcomes.size}" failures="#{failures}" time="#{report.seconds.round(3)}">)
75
- io.puts %( <testsuite name="mutaruby" tests="#{report.outcomes.size}" failures="#{failures}" errors="0" ) +
76
- %(skipped="#{report.count(:equivalent)}" time="#{report.seconds.round(3)}">)
77
- io.puts %( <properties><property name="score" value="#{x.call(score_text(report))}"/></properties>)
75
+ io.puts %(<testsuites name="mutaruby" tests="#{n}" failures="#{failures}" time="#{t}">)
76
+ io.puts %( <testsuite name="mutaruby" tests="#{n}" failures="#{failures}" errors="0" ) +
77
+ %(skipped="#{report.count(:equivalent)}" time="#{t}">)
78
+ io.puts %( <properties><property name="score" value="#{score_text(report)}"/></properties>)
78
79
  report.outcomes.each do |o|
79
80
  m = o.mutation
80
- name = "##{m.id} #{m.location} #{m.label}"
81
- io.print %( <testcase classname="#{x.call(m.file)}" name="#{x.call(name)}" time="#{o.seconds.round(3)}")
82
- case o.status
83
- when :survived
84
- io.puts '>'
85
- io.puts %( <failure message="#{x.call("survived: #{m.label}")}" type="survived">#{x.call(m.diff(report.sources[m.file]))}</failure>)
86
- io.puts ' </testcase>'
87
- when :equivalent
88
- io.puts '>'
89
- io.puts %( <skipped message="#{x.call("equivalent: #{o.ignore.reason}")}"/>)
90
- io.puts ' </testcase>'
91
- when :timeout
92
- io.puts '>'
93
- io.puts ' <system-out>killed by timeout</system-out>'
94
- io.puts ' </testcase>'
95
- else
96
- io.puts '/>'
97
- end
81
+ head = %( <testcase classname="#{esc(m.file)}" name="#{esc("##{m.id} #{m.location} #{m.label}")}" ) +
82
+ %(time="#{secs(o.seconds)}")
83
+ detail = junit_detail(o, report)
84
+ io.puts(detail ? "#{head}>\n #{detail}\n </testcase>" : "#{head}/>")
98
85
  end
99
86
  io.puts ' </testsuite>'
100
87
  io.puts '</testsuites>'
101
88
  end
89
+
90
+ def junit_detail(outcome, report)
91
+ m = outcome.mutation
92
+ case outcome.status
93
+ when :survived
94
+ %(<failure message="#{esc("survived: #{m.label}")}" type="survived">#{esc(m.diff(report.sources[m.file]))}</failure>)
95
+ when :equivalent then %(<skipped message="#{esc("equivalent: #{outcome.ignore.reason}")}"/>)
96
+ when :timeout then '<system-out>killed by timeout</system-out>'
97
+ end
98
+ end
99
+
100
+ def esc(text)
101
+ CGI.escapeHTML(text)
102
+ end
103
+
104
+ def secs(seconds)
105
+ seconds.round(3)
106
+ end
102
107
  end
103
108
  end