mutant 0.13.5 → 0.14.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 538aeb3b4a0e294ad4e9267116dc4cfc68f42fd195da8c6dcc7ba3eeaac9378f
4
- data.tar.gz: a09a2c06315294c7c88f42e4002afc02f73bba76f6d7c04e04bc436e9f70b421
3
+ metadata.gz: 19ddb5cce03b640bb465057b2e6a0ee983b44b3990fff144dcd3a737b6e7ba5c
4
+ data.tar.gz: c97e3a1ac3fc5f4e431e70e86ee477933f1fa59e9ce338308a785c7fdab990c5
5
5
  SHA512:
6
- metadata.gz: 818bda670d95c84e55413051f04e0afbd47973b1edf1840126f2109df8979d7bb5be3a5d2d560dbfc50b875137c1d45826b6ba9228b939b51b5e56b7d5c5d6d2
7
- data.tar.gz: 9ad79412af1baa590c8b88487b227d6235f15b399b8ea602ad75fc25abcdce252ea3a69766989937fa9915292396f252557a807cc7b23245ded8818c5b38007a
6
+ metadata.gz: aca0d3b5772f1b1caf6d075779dd6b54e02a4109917bc85f69b84afb038060f4610306b5f95c47f19cf429d287ffb4913b4cd5d71c19777a3f00a3df7cc7904e
7
+ data.tar.gz: 3dd977fa2018eac8935f471266af5fa77f1ca02095c130a3852f342fca3e2e099ab2cfabc75b746e3a44bcbbeeef9adf9979f92469e87df46227f98139fc859c
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.14.2
data/bin/mutant CHANGED
@@ -1,64 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- module Mutant
5
- # Record executable timestamp
6
- @executable_timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
4
+ # Dispatcher that selects between mutant-ruby and mutant-rust
5
+ # based on MUTANT_RUST environment variable.
6
+ #
7
+ # See RUST.md for documentation.
7
8
 
8
- trap('INT') do |status|
9
- effective_status = status ? status + 128 : 128
10
- exit! effective_status
11
- end
9
+ fail 'MUTANT_RUST=1 is not yet supported via gem installation' if ENV['MUTANT_RUST']
12
10
 
13
- require 'mutant'
14
-
15
- WORLD.record(:cli_parse) do
16
- CLI.parse(
17
- arguments: ARGV,
18
- world: Mutant::WORLD
19
- )
20
- end.either(
21
- ->(message) { Mutant::WORLD.stderr.puts(message); Kernel.exit(false) },
22
- # rubocop:disable Metrics/BlockLength
23
- lambda do |command|
24
- if command.zombie?
25
- command = WORLD.record(:zombify) do
26
- $stderr.puts('Running mutant zombified!')
27
- Zombifier.call(
28
- namespace: :Zombie,
29
- load_path: $LOAD_PATH,
30
- kernel: Kernel,
31
- pathname: Pathname,
32
- require_highjack: RequireHighjack
33
- .public_method(:call)
34
- .to_proc
35
- .curry
36
- .call(Kernel),
37
- root_require: 'mutant',
38
- includes: %w[
39
- adamantium
40
- anima
41
- concord
42
- equalizer
43
- mprelude
44
- mutant
45
- unparser
46
- variable
47
- ]
48
- )
49
-
50
- Zombie::Mutant::CLI.parse(
51
- arguments: ARGV,
52
- world: Mutant::WORLD
53
- ).from_right
54
- end
55
- end
56
-
57
- WORLD.record(:execute) { command.call }.tap do |status|
58
- WORLD.recorder.print_profile(WORLD.stderr) if command.print_profile?
59
- WORLD.kernel.exit(status)
60
- end
61
- end
62
- # rubocop:enable Metrics/BlockLength
63
- )
64
- end
11
+ exec('mutant-ruby', *ARGV)
data/bin/mutant-ruby ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ module Mutant
5
+ # Record executable timestamp
6
+ @executable_timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
+
8
+ trap('INT') do |status|
9
+ effective_status = status ? status + 128 : 128
10
+ exit! effective_status
11
+ end
12
+
13
+ require 'mutant'
14
+
15
+ WORLD.record(:cli_parse) do
16
+ CLI.parse(
17
+ arguments: ARGV,
18
+ world: Mutant::WORLD
19
+ )
20
+ end.either(
21
+ ->(message) { Mutant::WORLD.stderr.puts(message); Kernel.exit(false) },
22
+ # rubocop:disable Metrics/BlockLength
23
+ lambda do |command|
24
+ if command.zombie?
25
+ command = WORLD.record(:zombify) do
26
+ $stderr.puts('Running mutant zombified!')
27
+ Zombifier.call(
28
+ namespace: :Zombie,
29
+ load_path: $LOAD_PATH,
30
+ kernel: Kernel,
31
+ pathname: Pathname,
32
+ require_highjack: RequireHighjack
33
+ .public_method(:call)
34
+ .to_proc
35
+ .curry
36
+ .call(Kernel),
37
+ root_require: 'mutant',
38
+ includes: %w[
39
+ adamantium
40
+ anima
41
+ concord
42
+ equalizer
43
+ mprelude
44
+ mutant
45
+ unparser
46
+ variable
47
+ ]
48
+ )
49
+
50
+ Zombie::Mutant::CLI.parse(
51
+ arguments: ARGV,
52
+ world: Mutant::WORLD
53
+ ).from_right
54
+ end
55
+ end
56
+
57
+ WORLD.record(:execute) { command.call }.tap do |status|
58
+ WORLD.recorder.print_profile(WORLD.stderr) if command.print_profile?
59
+ WORLD.kernel.exit(status)
60
+ end
61
+ end
62
+ # rubocop:enable Metrics/BlockLength
63
+ )
64
+ end
@@ -6,7 +6,7 @@ module Mutant
6
6
  module NodePredicates
7
7
 
8
8
  Types::ALL.each do |type|
9
- fail "method: #{type} is already defined" if instance_methods(true).include?(type)
9
+ fail "method: #{type} is already defined" if method_defined?(type)
10
10
 
11
11
  name = "n_#{type.to_s.chomp('?')}?"
12
12
 
@@ -73,7 +73,6 @@ module Mutant
73
73
  end
74
74
 
75
75
  # rubocop:disable Metrics/MethodLength
76
- # rubocop:disable Style/MultilineBlockChain
77
76
  def self.setup_integration(env:, mutations:, selected_subjects:)
78
77
  env.record(__method__) do
79
78
  hooks = env.hooks
@@ -84,14 +83,13 @@ module Mutant
84
83
  mutations:,
85
84
  selector: Selector::Expression.new(integration:),
86
85
  subjects: selected_subjects
87
- )
88
- end.tap { hooks.run(:setup_integration_post) }
86
+ ).tap { hooks.run(:setup_integration_post) }
87
+ end
89
88
  end
90
89
  end
91
90
  private_class_method :setup_integration
92
- # rubocop:enable Metrics/MethodLength
93
- # rubocop:enable Style/MultilineBlockChain
94
91
 
92
+ # rubocop:enable Metrics/MethodLength
95
93
  def self.load_hooks(env)
96
94
  env.record(__method__) do
97
95
  env.with(hooks: Hooks.load_config(env.config))
@@ -36,7 +36,7 @@ module Mutant
36
36
  def methods(env)
37
37
  candidate_names.each_with_object([]) do |name, methods|
38
38
  method = access(env, name)
39
- methods << method if method&.owner.equal?(candidate_scope)
39
+ methods << method if method
40
40
  end
41
41
  end
42
42
 
@@ -50,10 +50,8 @@ module Mutant
50
50
  abstract_method :candidate_scope
51
51
  private :candidate_scope
52
52
 
53
- # Matcher for singleton methods
54
- class Singleton < self
55
- MATCHER = Matcher::Method::Singleton
56
-
53
+ # Matcher for eigenclass methods
54
+ class Eigenclass < self
57
55
  private
58
56
 
59
57
  def access(_env, method_name)
@@ -63,22 +61,16 @@ module Mutant
63
61
  def candidate_scope
64
62
  scope.raw.singleton_class
65
63
  end
64
+ end # Eigenclass
66
65
 
66
+ # Matcher for singleton methods
67
+ class Singleton < Eigenclass
68
+ MATCHER = Matcher::Method::Singleton
67
69
  end # Singleton
68
70
 
69
71
  # Matcher for metaclass methods
70
- class Metaclass < self
72
+ class Metaclass < Eigenclass
71
73
  MATCHER = Matcher::Method::Metaclass
72
-
73
- private
74
-
75
- def access(_env, method_name)
76
- scope.raw.method(method_name)
77
- end
78
-
79
- def candidate_scope
80
- scope.raw.singleton_class
81
- end
82
74
  end # Metaclass
83
75
 
84
76
  # Matcher for instance methods
@@ -104,17 +96,14 @@ module Mutant
104
96
 
105
97
  private
106
98
 
107
- # rubocop:disable Lint/RescueException
108
- # mutant:disable - unstable source locations under < ruby-3.2
109
99
  def access(env, method_name)
110
100
  candidate_scope.instance_method(method_name)
111
- rescue Exception => exception
101
+ rescue => exception
112
102
  env.warn(
113
103
  MESSAGE % { scope:, method_name:, exception: exception.inspect }
114
104
  )
115
105
  nil
116
106
  end
117
- # rubocop:enable Lint/RescueException
118
107
 
119
108
  def candidate_scope
120
109
  scope.raw
@@ -10,13 +10,18 @@ module Mutant
10
10
  NAME = :full
11
11
 
12
12
  SELECTOR_REPLACEMENTS = {
13
+ :& => %i[| ^],
13
14
  :< => %i[== eql? equal?],
15
+ :<< => %i[>>],
14
16
  :<= => %i[< == eql? equal?],
15
17
  :== => %i[eql? equal?],
16
18
  :=== => %i[is_a?],
17
19
  :=~ => %i[match?],
18
20
  :> => %i[== eql? equal?],
19
21
  :>= => %i[> == eql? equal?],
22
+ :>> => %i[<<],
23
+ :^ => %i[& |],
24
+ :| => %i[& ^],
20
25
  __send__: %i[public_send],
21
26
  all?: %i[any?],
22
27
  any?: %i[all?],
@@ -46,7 +51,7 @@ module Mutant
46
51
  to_i: %i[to_int],
47
52
  to_s: %i[to_str],
48
53
  values_at: %i[fetch_values]
49
- }.freeze.tap { |hash| hash.values(&:freeze) }
54
+ }.freeze.tap { |hash| hash.each_value(&:freeze) }
50
55
  end
51
56
 
52
57
  class Light < self
@@ -8,7 +8,7 @@ module Mutant
8
8
  # Mutation emitter to handle named value access nodes
9
9
  class Access < Node
10
10
 
11
- handle(:gvar, :cvar, :lvar, :self)
11
+ handle(:gvar, :cvar, :ivar, :lvar, :self)
12
12
 
13
13
  private
14
14
 
@@ -16,30 +16,6 @@ module Mutant
16
16
  emit_singletons
17
17
  end
18
18
 
19
- # Named value access emitter for instance variables
20
- class Ivar < Access
21
- NAME_RANGE = (1..-1)
22
-
23
- handle(:ivar)
24
-
25
- children :name
26
-
27
- private
28
-
29
- def dispatch
30
- emit_attribute_read
31
- super
32
- end
33
-
34
- def emit_attribute_read
35
- emit(s(:send, nil, attribute_name))
36
- end
37
-
38
- def attribute_name
39
- name.slice(NAME_RANGE).to_sym
40
- end
41
- end # Ivar
42
-
43
19
  end # Access
44
20
  end # NamedValue
45
21
  end # Node
@@ -10,6 +10,7 @@ module Mutant
10
10
  HEADER_FORMAT = 'N'
11
11
  HEADER_SIZE = 4
12
12
  MAX_BYTES = (2**32).pred
13
+ MAX_LOG_CHUNK = 4096
13
14
 
14
15
  class Reader
15
16
  include Anima.new(:deadline, :io, :marshal, :response_reader, :log_reader)
@@ -86,10 +87,10 @@ module Mutant
86
87
 
87
88
  def advance_result
88
89
  if length
89
- if read_buffer(length)
90
+ if read_result_buffer(length)
90
91
  @results << marshal.load(@buffer)
91
92
  end
92
- elsif read_buffer(HEADER_SIZE)
93
+ elsif read_result_buffer(HEADER_SIZE)
93
94
  @lengths << Util.one(@buffer.unpack(HEADER_FORMAT))
94
95
  @buffer = +''
95
96
  end
@@ -100,17 +101,16 @@ module Mutant
100
101
  end
101
102
 
102
103
  def advance_log
103
- with_nonblock_read(io: log_reader, max_bytes: 4096, &log.public_method(:<<))
104
+ while with_nonblock_read(io: log_reader, max_bytes: MAX_LOG_CHUNK, &@log.public_method(:<<))
105
+ end
104
106
  end
105
107
 
106
- def read_buffer(max_bytes)
108
+ def read_result_buffer(max_bytes)
107
109
  with_nonblock_read(
108
110
  io: response_reader,
109
- max_bytes: max_bytes - @buffer.bytesize
110
- ) do |chunk|
111
- @buffer << chunk
112
- @buffer.bytesize.equal?(max_bytes)
113
- end
111
+ max_bytes: max_bytes - @buffer.bytesize,
112
+ &@buffer.public_method(:<<)
113
+ )
114
114
  end
115
115
 
116
116
  # rubocop:disable Metrics/MethodLength
@@ -123,8 +123,11 @@ module Mutant
123
123
  when nil
124
124
  @errors << EOFError
125
125
  false
126
+ when :wait_readable
127
+ false
126
128
  when String
127
129
  yield chunk
130
+ chunk.bytesize.equal?(max_bytes)
128
131
  else
129
132
  fail "Unexpected nonblocking read return: #{chunk.inspect}"
130
133
  end
data/lib/mutant/range.rb CHANGED
@@ -12,4 +12,4 @@ module Mutant
12
12
  left.end >= right.begin && right.end >= left.begin
13
13
  end
14
14
  end # Range
15
- end # end
15
+ end # Mutant
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'io/console'
4
+
3
5
  module Mutant
4
6
  class Reporter
5
7
  class CLI
@@ -7,7 +9,20 @@ module Mutant
7
9
  #
8
10
  # rubocop:disable Style/FormatString
9
11
  class Format
10
- include AbstractType, Anima.new(:tty)
12
+ include AbstractType, Anima.new(:tty, :output_io)
13
+
14
+ DEFAULT_TERMINAL_WIDTH = 80
15
+
16
+ # Dynamic terminal width - queries current size on each call
17
+ #
18
+ # @return [Integer]
19
+ def terminal_width
20
+ return DEFAULT_TERMINAL_WIDTH unless tty && output_io.respond_to?(:winsize)
21
+
22
+ output_io.winsize.last
23
+ rescue Errno::ENOTTY, Errno::EOPNOTSUPP
24
+ DEFAULT_TERMINAL_WIDTH
25
+ end
11
26
 
12
27
  # Start representation
13
28
  #
@@ -35,7 +50,7 @@ module Mutant
35
50
 
36
51
  # Output abstraction to decouple tty? from buffer
37
52
  class Output
38
- include Anima.new(:tty, :buffer)
53
+ include Anima.new(:tty, :buffer, :terminal_width)
39
54
 
40
55
  # Test if output is a tty
41
56
  #
@@ -54,7 +69,7 @@ module Mutant
54
69
 
55
70
  def format(printer, object)
56
71
  buffer = new_buffer
57
- printer.call(output: Output.new(tty:, buffer:), object:)
72
+ printer.call(output: Output.new(tty:, buffer:, terminal_width:), object:)
58
73
  buffer.rewind
59
74
  buffer.read
60
75
  end
@@ -65,6 +80,14 @@ module Mutant
65
80
  REPORT_FREQUENCY = 1.0
66
81
  REPORT_DELAY = 1 / REPORT_FREQUENCY
67
82
 
83
+ # ANSI escape sequences
84
+ CLEAR_LINE = "\e[2K"
85
+ CURSOR_UP = "\e[A"
86
+ CURSOR_DOWN = "\e[B"
87
+
88
+ # Pattern to strip ANSI escape codes for visual length calculation
89
+ ANSI_ESCAPE = /\e\[[0-9;]*[A-Za-z]/
90
+
68
91
  # Start representation
69
92
  #
70
93
  # @return [String]
@@ -83,14 +106,14 @@ module Mutant
83
106
  #
84
107
  # @return [String]
85
108
  def progress(status)
86
- format(Printer::StatusProgressive, status)
109
+ wrap_progress { format(status_progressive_printer, status) }
87
110
  end
88
111
 
89
112
  # Progress representation
90
113
  #
91
114
  # @return [String]
92
115
  def test_progress(status)
93
- format(Printer::Test::StatusProgressive, status)
116
+ wrap_progress { format(test_status_progressive_printer, status) }
94
117
  end
95
118
 
96
119
  private
@@ -99,6 +122,75 @@ module Mutant
99
122
  StringIO.new
100
123
  end
101
124
 
125
+ def status_progressive_printer
126
+ tty ? Printer::StatusProgressive::Tty : Printer::StatusProgressive::Pipe
127
+ end
128
+
129
+ def test_status_progressive_printer
130
+ tty ? Printer::Test::StatusProgressive::Tty : Printer::Test::StatusProgressive::Pipe
131
+ end
132
+
133
+ # Wrap progress output with TTY-specific line handling
134
+ #
135
+ # Uses indicatif-style multi-line clearing to handle terminal resize:
136
+ # 1. Calculate how many visual lines previous content spans at current width
137
+ # 2. Clear all those lines using cursor movement
138
+ # 3. Write new content
139
+ #
140
+ # @return [String]
141
+ def wrap_progress
142
+ content = yield
143
+ return content unless tty
144
+
145
+ clear_seq = clear_last_lines(visual_lines_at_width(@last_content_length, terminal_width))
146
+ @last_content_length = visual_length(content)
147
+
148
+ "#{clear_seq}#{content}"
149
+ end
150
+
151
+ # Calculate visual length of string (excluding ANSI escape sequences)
152
+ #
153
+ # @param string [String]
154
+ # @return [Integer]
155
+ def visual_length(string)
156
+ string.gsub(ANSI_ESCAPE, '').length
157
+ end
158
+
159
+ # Calculate visual lines content occupies at given terminal width
160
+ #
161
+ # @param content_length [Integer, nil]
162
+ # @param width [Integer]
163
+ # @return [Integer]
164
+ def visual_lines_at_width(content_length, width)
165
+ return 1 if width < 1
166
+
167
+ # nil.to_f = 0.0, and ceil(0.0/w).clamp(1,10) = 1
168
+ (content_length.to_f / width).ceil.clamp(1, 10)
169
+ end
170
+
171
+ # Build escape sequence to clear n lines (indicatif/console pattern)
172
+ #
173
+ # Algorithm from console crate's clear_last_lines:
174
+ # 1. Move cursor up (n-1) lines to reach top
175
+ # 2. For each line: clear it, move down (except last)
176
+ # 3. Move cursor back up (n-1) lines
177
+ #
178
+ # For n=1: produces "\r\e[2K" (no cursor movement needed)
179
+ # For n>1: moves up, clears each line with downs between, moves back up
180
+ #
181
+ # @param lines [Integer] number of lines to clear (must be >= 1)
182
+ # @return [String]
183
+ def clear_last_lines(lines)
184
+ buffer = StringIO.new
185
+ buffer << (CURSOR_UP * (lines - 1))
186
+ lines.times do |i|
187
+ buffer << "\r" << CLEAR_LINE
188
+ buffer << CURSOR_DOWN if i < lines - 1
189
+ end
190
+ buffer << (CURSOR_UP * (lines - 1))
191
+ buffer.string
192
+ end
193
+
102
194
  end # Progressive
103
195
  end # Format
104
196
  end # CLI
@@ -46,7 +46,9 @@ module Mutant
46
46
  end
47
47
 
48
48
  def coverage_percent
49
- coverage * 100
49
+ # Floor to 2 decimal places to prevent rounding up to 100%
50
+ # when there are surviving mutations
51
+ (coverage * 10_000).floor / 100.0
50
52
  end
51
53
 
52
54
  def efficiency_percent
@@ -4,10 +4,8 @@ module Mutant
4
4
  class Reporter
5
5
  class CLI
6
6
  class Printer
7
- # Reporter for progressive output format on scheduler Status objects
7
+ # Base class for progressive status output
8
8
  class StatusProgressive < self
9
- FORMAT = 'progress: %02d/%02d alive: %d runtime: %0.02fs killtime: %0.02fs mutations/s: %0.02f'
10
-
11
9
  delegate(
12
10
  :amount_mutation_results,
13
11
  :amount_mutations,
@@ -17,21 +15,6 @@ module Mutant
17
15
  :runtime
18
16
  )
19
17
 
20
- # Run printer
21
- #
22
- # @return [undefined]
23
- def run
24
- status(
25
- FORMAT,
26
- amount_mutation_results,
27
- amount_mutations,
28
- amount_mutations_alive,
29
- runtime,
30
- killtime,
31
- mutations_per_second
32
- )
33
- end
34
-
35
18
  private
36
19
 
37
20
  def object
@@ -41,6 +24,50 @@ module Mutant
41
24
  def mutations_per_second
42
25
  amount_mutation_results / runtime
43
26
  end
27
+
28
+ # Pipe output format (non-TTY)
29
+ class Pipe < StatusProgressive
30
+ FORMAT = 'progress: %02d/%02d alive: %d runtime: %0.02fs killtime: %0.02fs mutations/s: %0.02f'
31
+
32
+ def run
33
+ status(
34
+ FORMAT,
35
+ amount_mutation_results,
36
+ amount_mutations,
37
+ amount_mutations_alive,
38
+ runtime,
39
+ killtime,
40
+ mutations_per_second
41
+ )
42
+ end
43
+ end # Pipe
44
+
45
+ # TTY output format with progress bar
46
+ class Tty < StatusProgressive
47
+ FORMAT = '%s %d/%d (%5.1f%%) %s alive: %d %0.1fs %0.2f/s'
48
+ MAX_BAR_WIDTH = 40
49
+ PREFIX = 'RUNNING'
50
+ PERCENTAGE_ESTIMATE = 99.9
51
+
52
+ def run
53
+ bar = ProgressBar.build(current: amount_mutation_results, total: amount_mutations, width: bar_width)
54
+ line = FORMAT % format_args(bar.percentage, bar.render)
55
+ output.write(colorize(status_color, line))
56
+ end
57
+
58
+ private
59
+
60
+ def format_args(percentage, bar)
61
+ [PREFIX, amount_mutation_results, amount_mutations, percentage, bar,
62
+ amount_mutations_alive, runtime, mutations_per_second]
63
+ end
64
+
65
+ def bar_width
66
+ non_bar_content = FORMAT % format_args(PERCENTAGE_ESTIMATE, nil)
67
+ available_width = output.terminal_width - non_bar_content.length
68
+ available_width.clamp(0, MAX_BAR_WIDTH)
69
+ end
70
+ end # Tty
44
71
  end # StatusProgressive
45
72
  end # Printer
46
73
  end # CLI
@@ -115,10 +115,8 @@ module Mutant
115
115
 
116
116
  end # Result
117
117
 
118
- # Reporter for progressive output format on scheduler Status objects
118
+ # Base class for progressive test status output
119
119
  class StatusProgressive < self
120
- FORMAT = 'progress: %02d/%02d failed: %d runtime: %0.02fs testtime: %0.02fs tests/s: %0.02f'
121
-
122
120
  delegate(
123
121
  :amount_test_results,
124
122
  :amount_tests,
@@ -127,21 +125,6 @@ module Mutant
127
125
  :runtime
128
126
  )
129
127
 
130
- # Run printer
131
- #
132
- # @return [undefined]
133
- def run
134
- status(
135
- FORMAT,
136
- amount_test_results,
137
- amount_tests,
138
- amount_tests_failed,
139
- runtime,
140
- testtime,
141
- tests_per_second
142
- )
143
- end
144
-
145
128
  private
146
129
 
147
130
  def object
@@ -151,6 +134,50 @@ module Mutant
151
134
  def tests_per_second
152
135
  amount_test_results / runtime
153
136
  end
137
+
138
+ # Pipe output format (non-TTY)
139
+ class Pipe < StatusProgressive
140
+ FORMAT = 'progress: %02d/%02d failed: %d runtime: %0.02fs testtime: %0.02fs tests/s: %0.02f'
141
+
142
+ def run
143
+ status(
144
+ FORMAT,
145
+ amount_test_results,
146
+ amount_tests,
147
+ amount_tests_failed,
148
+ runtime,
149
+ testtime,
150
+ tests_per_second
151
+ )
152
+ end
153
+ end # Pipe
154
+
155
+ # TTY output format with progress bar
156
+ class Tty < StatusProgressive
157
+ FORMAT = '%s %d/%d (%5.1f%%) %s failed: %d %0.1fs %0.2f/s'
158
+ MAX_BAR_WIDTH = 40
159
+ PREFIX = 'TESTING'
160
+ PERCENTAGE_ESTIMATE = 99.9
161
+
162
+ def run
163
+ bar = ProgressBar.build(current: amount_test_results, total: amount_tests, width: bar_width)
164
+ line = FORMAT % format_args(bar.percentage, bar.render)
165
+ output.write(colorize(status_color, line))
166
+ end
167
+
168
+ private
169
+
170
+ def format_args(percentage, bar)
171
+ [PREFIX, amount_test_results, amount_tests, percentage, bar,
172
+ amount_tests_failed, runtime, tests_per_second]
173
+ end
174
+
175
+ def bar_width
176
+ non_bar_content = FORMAT % format_args(PERCENTAGE_ESTIMATE, nil)
177
+ available_width = output.terminal_width - non_bar_content.length
178
+ available_width.clamp(0, MAX_BAR_WIDTH)
179
+ end
180
+ end # Tty
154
181
  end # StatusProgressive
155
182
  end # Test
156
183
  end # Printer
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutant
4
+ class Reporter
5
+ class CLI
6
+ # Visual progress bar renderer
7
+ #
8
+ # Renders nextest-style progress bars like:
9
+ # 45/100 (45.0%) ████████████░░░░░░░░ alive: 12 23.45s
10
+ class ProgressBar
11
+ include Anima.new(
12
+ :current,
13
+ :total,
14
+ :width,
15
+ :filled_char,
16
+ :empty_char
17
+ )
18
+
19
+ FILLED_CHAR = "\u2588" # █
20
+ EMPTY_CHAR = "\u2591" # ░
21
+
22
+ DEFAULT_WIDTH = 30
23
+
24
+ # Render the progress bar string
25
+ #
26
+ # @return [String]
27
+ def render
28
+ "#{filled}#{empty}"
29
+ end
30
+
31
+ # Calculate percentage completion
32
+ #
33
+ # @return [Float]
34
+ def percentage
35
+ return 0.0 if total.zero?
36
+
37
+ (current.to_f / total * 100)
38
+ end
39
+
40
+ # Build a progress bar with defaults
41
+ #
42
+ # @param current [Integer] current progress value
43
+ # @param total [Integer] total value
44
+ # @param width [Integer] bar width in characters
45
+ #
46
+ # @return [ProgressBar]
47
+ def self.build(current:, total:, width: DEFAULT_WIDTH)
48
+ new(
49
+ current:,
50
+ total:,
51
+ width:,
52
+ filled_char: FILLED_CHAR,
53
+ empty_char: EMPTY_CHAR
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def filled_width
60
+ return 0 if total.zero?
61
+
62
+ [((current.to_f / total) * width).round, width].min
63
+ end
64
+
65
+ def empty_width
66
+ width - filled_width
67
+ end
68
+
69
+ def filled
70
+ filled_char * filled_width
71
+ end
72
+
73
+ def empty
74
+ empty_char * empty_width
75
+ end
76
+ end # ProgressBar
77
+ end # CLI
78
+ end # Reporter
79
+ end # Mutant
@@ -12,8 +12,10 @@ module Mutant
12
12
  #
13
13
  # @return [Reporter::CLI]
14
14
  def self.build(output)
15
+ tty = output.respond_to?(:tty?) && output.tty?
16
+
15
17
  new(
16
- format: Format::Progressive.new(tty: output.respond_to?(:tty?) && output.tty?),
18
+ format: Format::Progressive.new(tty:, output_io: output),
17
19
  print_warnings: false,
18
20
  output:
19
21
  )
@@ -80,6 +82,7 @@ module Mutant
80
82
  #
81
83
  # @return [self]
82
84
  def report(env)
85
+ write_final_progress(env)
83
86
  Printer::EnvResult.call(output:, object: env)
84
87
  self
85
88
  end
@@ -90,6 +93,7 @@ module Mutant
90
93
  #
91
94
  # @return [self]
92
95
  def test_report(env)
96
+ write_final_test_progress(env)
93
97
  Printer::Test::EnvResult.call(output:, object: env)
94
98
  self
95
99
  end
@@ -100,6 +104,30 @@ module Mutant
100
104
  output.write(frame)
101
105
  end
102
106
 
107
+ def write_final_progress(env)
108
+ return unless format.tty
109
+
110
+ final_status = Parallel::Status.new(
111
+ active_jobs: Set.new,
112
+ done: true,
113
+ payload: env
114
+ )
115
+ write(format.progress(final_status))
116
+ output.puts
117
+ end
118
+
119
+ def write_final_test_progress(env)
120
+ return unless format.tty
121
+
122
+ final_status = Parallel::Status.new(
123
+ active_jobs: Set.new,
124
+ done: true,
125
+ payload: env
126
+ )
127
+ write(format.test_progress(final_status))
128
+ output.puts
129
+ end
130
+
103
131
  end # CLI
104
132
  end # Reporter
105
133
  end # Mutant
@@ -1,6 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
4
+
3
5
  module Mutant
4
6
  # Current mutant version
5
- VERSION = '0.13.5'
7
+ #
8
+ # See RUST.md for documentation on version loading behavior.
9
+ VERSION =
10
+ if ENV['MUTANT_RUST']
11
+ ENV.fetch('MUTANT_VERSION').freeze
12
+ else
13
+ Pathname
14
+ .new(__dir__)
15
+ .parent
16
+ .parent
17
+ .join('VERSION')
18
+ .read
19
+ .chomp
20
+ .freeze
21
+ end
6
22
  end # Mutant
data/lib/mutant.rb CHANGED
@@ -237,6 +237,7 @@ module Mutant
237
237
  require 'mutant/reporter/null'
238
238
  require 'mutant/reporter/sequence'
239
239
  require 'mutant/reporter/cli'
240
+ require 'mutant/reporter/cli/progress_bar'
240
241
  require 'mutant/reporter/cli/printer'
241
242
  require 'mutant/reporter/cli/printer/config'
242
243
  require 'mutant/reporter/cli/printer/coverage_result'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.5
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Markus Schirp
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 1.15.2
32
+ version: '1.15'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 1.15.2
39
+ version: '1.15'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: parser
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: 0.5.0
74
+ version: 0.6.0
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: 0.5.0
81
+ version: 0.6.0
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: unparser
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -127,14 +127,14 @@ dependencies:
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: 1.3.0
130
+ version: '2.0'
131
131
  type: :development
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
- version: 1.3.0
137
+ version: '2.0'
138
138
  - !ruby/object:Gem::Dependency
139
139
  name: rubocop
140
140
  requirement: !ruby/object:Gem::Requirement
@@ -154,12 +154,15 @@ email:
154
154
  - mbj@schirp-dso.com
155
155
  executables:
156
156
  - mutant
157
+ - mutant-ruby
157
158
  extensions: []
158
159
  extra_rdoc_files:
159
160
  - LICENSE
160
161
  files:
161
162
  - LICENSE
163
+ - VERSION
162
164
  - bin/mutant
165
+ - bin/mutant-ruby
163
166
  - lib/mutant.rb
164
167
  - lib/mutant/ast.rb
165
168
  - lib/mutant/ast/find_metaclass_containing.rb
@@ -324,6 +327,7 @@ files:
324
327
  - lib/mutant/reporter/cli/printer/status_progressive.rb
325
328
  - lib/mutant/reporter/cli/printer/subject_result.rb
326
329
  - lib/mutant/reporter/cli/printer/test.rb
330
+ - lib/mutant/reporter/cli/progress_bar.rb
327
331
  - lib/mutant/reporter/null.rb
328
332
  - lib/mutant/reporter/sequence.rb
329
333
  - lib/mutant/repository.rb
@@ -374,7 +378,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
374
378
  - !ruby/object:Gem::Version
375
379
  version: '0'
376
380
  requirements: []
377
- rubygems_version: 3.6.9
381
+ rubygems_version: 4.0.3
378
382
  specification_version: 4
379
383
  summary: ''
380
384
  test_files: []