evilution 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/.gitignore +4 -0
  3. data/.beads/.migration-hint-ts +1 -1
  4. data/.beads/interactions.jsonl +12 -0
  5. data/.beads/issues.jsonl +22 -19
  6. data/CHANGELOG.md +35 -0
  7. data/README.md +17 -11
  8. data/comparison_results/baseline_2026-04-09.md +35 -0
  9. data/comparison_results/operator_classification.md +79 -0
  10. data/comparison_results/operator_prioritization.md +68 -0
  11. data/docs/mutation_density_benchmark.md +91 -0
  12. data/lib/evilution/ast/parser.rb +2 -1
  13. data/lib/evilution/baseline.rb +14 -11
  14. data/lib/evilution/cli.rb +13 -3
  15. data/lib/evilution/config.rb +27 -5
  16. data/lib/evilution/disable_comment.rb +2 -1
  17. data/lib/evilution/integration/base.rb +98 -1
  18. data/lib/evilution/integration/minitest.rb +145 -0
  19. data/lib/evilution/integration/minitest_crash_detector.rb +55 -0
  20. data/lib/evilution/integration/rspec.rb +33 -92
  21. data/lib/evilution/isolation/fork.rb +3 -6
  22. data/lib/evilution/mcp/mutate_tool.rb +6 -6
  23. data/lib/evilution/mutator/base.rb +5 -1
  24. data/lib/evilution/mutator/operator/bitwise_complement.rb +1 -1
  25. data/lib/evilution/mutator/operator/block_pass_removal.rb +30 -0
  26. data/lib/evilution/mutator/operator/ensure_removal.rb +1 -1
  27. data/lib/evilution/mutator/operator/index_to_at.rb +30 -0
  28. data/lib/evilution/mutator/operator/index_to_dig.rb +3 -3
  29. data/lib/evilution/mutator/operator/index_to_fetch.rb +2 -2
  30. data/lib/evilution/mutator/operator/keyword_argument.rb +1 -1
  31. data/lib/evilution/mutator/operator/regex_simplification.rb +169 -0
  32. data/lib/evilution/mutator/operator/rescue_body_replacement.rb +1 -1
  33. data/lib/evilution/mutator/operator/rescue_removal.rb +1 -1
  34. data/lib/evilution/mutator/operator/string_literal.rb +18 -0
  35. data/lib/evilution/mutator/registry.rb +12 -2
  36. data/lib/evilution/reporter/html.rb +2 -2
  37. data/lib/evilution/reporter/json.rb +2 -2
  38. data/lib/evilution/reporter/suggestion.rb +659 -2
  39. data/lib/evilution/runner.rb +59 -13
  40. data/lib/evilution/spec_resolver.rb +24 -16
  41. data/lib/evilution/temp_dir_tracker.rb +39 -0
  42. data/lib/evilution/version.rb +1 -1
  43. data/lib/evilution.rb +4 -0
  44. data/scripts/benchmark_density +261 -0
  45. data/scripts/benchmark_density.yml +19 -0
  46. data/scripts/compare_mutations +404 -0
  47. data/scripts/compare_mutations.yml +24 -0
  48. data/scripts/mutant_json_adapter +224 -0
  49. metadata +17 -2
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ class Evilution::Integration::MinitestCrashDetector
6
+ def initialize
7
+ reset
8
+ end
9
+
10
+ def start
11
+ # Required by Minitest reporter interface
12
+ end
13
+
14
+ def report
15
+ # Required by Minitest reporter interface
16
+ end
17
+
18
+ def passed?
19
+ @crashes.empty?
20
+ end
21
+
22
+ def reset
23
+ @assertion_failures = 0
24
+ @crashes = []
25
+ end
26
+
27
+ def record(result)
28
+ result.failures.each do |failure|
29
+ if failure.is_a?(::Minitest::UnexpectedError)
30
+ @crashes << failure.error
31
+ elsif failure.is_a?(::Minitest::Assertion)
32
+ @assertion_failures += 1
33
+ end
34
+ end
35
+ end
36
+
37
+ def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
38
+ @assertion_failures.positive?
39
+ end
40
+
41
+ def has_crash? # rubocop:disable Naming/PredicatePrefix
42
+ @crashes.any?
43
+ end
44
+
45
+ def only_crashes?
46
+ @crashes.any? && @assertion_failures.zero?
47
+ end
48
+
49
+ def crash_summary
50
+ return nil if @crashes.empty?
51
+
52
+ types = @crashes.map { |e| e.class.name }.uniq
53
+ "#{types.join(", ")} (#{@crashes.length} crash#{"es" unless @crashes.length == 1})"
54
+ end
55
+ end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
3
  require "stringio"
5
- require "tmpdir"
6
4
  require_relative "base"
7
5
  require_relative "crash_detector"
8
6
  require_relative "../spec_resolver"
@@ -11,6 +9,21 @@ require_relative "../related_spec_heuristic"
11
9
  require_relative "../integration"
12
10
 
13
11
  class Evilution::Integration::RSpec < Evilution::Integration::Base
12
+ def self.baseline_runner
13
+ lambda { |spec_file|
14
+ require "rspec/core"
15
+ ::RSpec.reset
16
+ status = ::RSpec::Core::Runner.run(
17
+ ["--format", "progress", "--no-color", "--order", "defined", spec_file]
18
+ )
19
+ status.zero?
20
+ }
21
+ end
22
+
23
+ def self.baseline_options
24
+ { runner: baseline_runner }
25
+ end
26
+
14
27
  def initialize(test_files: nil, hooks: nil)
15
28
  @test_files = test_files
16
29
  @rspec_loaded = false
@@ -21,98 +34,24 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
21
34
  super(hooks: hooks)
22
35
  end
23
36
 
24
- def call(mutation)
25
- @original_content = nil
26
- @temp_dir = nil
27
- @lock_file = nil
28
- ensure_rspec_loaded
29
- @hooks.fire(:mutation_insert_pre, mutation: mutation, file_path: mutation.file_path) if @hooks
30
- apply_mutation(mutation)
31
- @hooks.fire(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path) if @hooks
32
- run_rspec(mutation)
33
- ensure
34
- restore_original(mutation)
35
- end
36
-
37
37
  private
38
38
 
39
39
  attr_reader :test_files
40
40
 
41
- def ensure_rspec_loaded
41
+ def ensure_framework_loaded
42
42
  return if @rspec_loaded
43
43
 
44
- @hooks.fire(:setup_integration_pre, integration: :rspec) if @hooks
44
+ fire_hook(:setup_integration_pre, integration: :rspec)
45
45
  require "rspec/core"
46
46
  Evilution::Integration::CrashDetector.register_with_rspec
47
47
  @rspec_loaded = true
48
- @hooks.fire(:setup_integration_post, integration: :rspec) if @hooks
48
+ fire_hook(:setup_integration_post, integration: :rspec)
49
49
  rescue LoadError => e
50
50
  raise Evilution::Error, "rspec-core is required but not available: #{e.message}"
51
51
  end
52
52
 
53
- def apply_mutation(mutation)
54
- subpath = resolve_require_subpath(mutation.file_path)
55
-
56
- if subpath
57
- @temp_dir = Dir.mktmpdir("evilution")
58
- dest = File.join(@temp_dir, subpath)
59
- FileUtils.mkdir_p(File.dirname(dest))
60
- File.write(dest, mutation.mutated_source)
61
- $LOAD_PATH.unshift(@temp_dir)
62
- else
63
- # Fallback: direct write when file isn't under any $LOAD_PATH entry.
64
- # Acquire an exclusive lock to prevent concurrent workers from corrupting the file.
65
- lock_path = File.join(Dir.tmpdir, "evilution-#{File.expand_path(mutation.file_path).hash.abs}.lock")
66
- @lock_file = File.open(lock_path, File::CREAT | File::RDWR)
67
- @lock_file.flock(File::LOCK_EX)
68
- @original_content = File.read(mutation.file_path)
69
- File.write(mutation.file_path, mutation.mutated_source)
70
- end
71
- end
72
-
73
- def restore_original(mutation)
74
- if @temp_dir
75
- $LOAD_PATH.delete(@temp_dir)
76
- $LOADED_FEATURES.reject! { |f| f.start_with?(@temp_dir) }
77
- FileUtils.rm_rf(@temp_dir)
78
- @temp_dir = nil
79
- elsif @original_content
80
- File.write(mutation.file_path, @original_content)
81
- @lock_file&.flock(File::LOCK_UN)
82
- @lock_file&.close
83
- @lock_file = nil
84
- end
85
- end
86
-
87
- def resolve_require_subpath(file_path)
88
- absolute = File.expand_path(file_path)
89
-
90
- $LOAD_PATH.each do |entry|
91
- dir = File.expand_path(entry)
92
- prefix = dir.end_with?("/") ? dir : "#{dir}/"
93
- next unless absolute.start_with?(prefix)
94
-
95
- return absolute.delete_prefix(prefix)
96
- end
97
-
98
- nil
99
- end
100
-
101
- def run_rspec(mutation)
102
- # When used via the Runner with Isolation::Fork, each mutation is executed
103
- # in its own forked child process, so RSpec state (loaded example groups,
104
- # world, configuration) cannot accumulate across mutation runs — the child
105
- # process exits after each run.
106
- #
107
- # This integration can also be invoked directly (e.g. in specs or alternative
108
- # runners) without fork isolation. clear_examples reuses the existing World
109
- # and Configuration (avoiding per-run instance growth) while clearing loaded
110
- # example groups, constants, and configuration state.
111
- if ::RSpec.respond_to?(:clear_examples)
112
- ::RSpec.clear_examples
113
- else
114
- ::RSpec.reset
115
- end
53
+ def run_tests(mutation)
54
+ reset_state
116
55
 
117
56
  out = StringIO.new
118
57
  err = StringIO.new
@@ -131,6 +70,19 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
131
70
  release_rspec_state(eg_before)
132
71
  end
133
72
 
73
+ def build_args(mutation)
74
+ files = resolve_test_files(mutation)
75
+ ["--format", "progress", "--no-color", "--order", "defined", *files]
76
+ end
77
+
78
+ def reset_state
79
+ if ::RSpec.respond_to?(:clear_examples)
80
+ ::RSpec.clear_examples
81
+ else
82
+ ::RSpec.reset
83
+ end
84
+ end
85
+
134
86
  def snapshot_example_groups
135
87
  groups = Set.new
136
88
  ObjectSpace.each_object(Class) do |klass|
@@ -142,11 +94,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
142
94
 
143
95
  def release_rspec_state(eg_before)
144
96
  release_example_groups(eg_before)
145
- # Remove ExampleGroups constants so the named reference is dropped.
146
- # We avoid a full RSpec.reset here because it creates new World and
147
- # Configuration instances each call; the pre-run reset already handles
148
- # that. Instead, clear the world's example_groups array (which holds
149
- # direct class references) and the source cache.
150
97
  ::RSpec::ExampleGroups.remove_all_constants if defined?(::RSpec::ExampleGroups)
151
98
  release_world_example_groups
152
99
  end
@@ -158,7 +105,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
158
105
  next unless klass < ::RSpec::Core::ExampleGroup
159
106
  next if eg_before.include?(klass.object_id)
160
107
 
161
- # Remove nested module constants (LetDefinitions, NamedSubjectPreventSuper)
162
108
  klass.constants(false).each do |const|
163
109
  klass.send(:remove_const, const)
164
110
  rescue NameError # rubocop:disable Lint/SuppressedException
@@ -197,11 +143,6 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
197
143
  end
198
144
  end
199
145
 
200
- def build_args(mutation)
201
- files = resolve_test_files(mutation)
202
- ["--format", "progress", "--no-color", "--order", "defined", *files]
203
- end
204
-
205
146
  def resolve_test_files(mutation)
206
147
  return test_files if test_files
207
148
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
5
  require_relative "../memory"
6
+ require_relative "../temp_dir_tracker"
6
7
 
7
8
  require_relative "../isolation"
8
9
 
@@ -44,12 +45,8 @@ class Evilution::Isolation::Fork
44
45
 
45
46
  private
46
47
 
47
- def restore_original_source(mutation)
48
- return if File.read(mutation.file_path) == mutation.original_source
49
-
50
- File.write(mutation.file_path, mutation.original_source)
51
- rescue StandardError => e
52
- warn("Warning: failed to restore #{mutation.file_path}: #{e.message}")
48
+ def restore_original_source(mutation) # rubocop:disable Lint/UnusedMethodArgument
49
+ Evilution::TempDirTracker.cleanup_all
53
50
  end
54
51
 
55
52
  def suppress_child_output
@@ -12,7 +12,7 @@ require_relative "../mcp"
12
12
  class Evilution::MCP::MutateTool < MCP::Tool
13
13
  tool_name "evilution-mutate"
14
14
  description "Run mutation testing on Ruby source files. " \
15
- "Use suggest_tests: true to get concrete RSpec test code for surviving mutants."
15
+ "Use suggest_tests: true to get concrete test code (RSpec or Minitest) for surviving mutants."
16
16
  input_schema(
17
17
  properties: {
18
18
  files: {
@@ -43,7 +43,7 @@ class Evilution::MCP::MutateTool < MCP::Tool
43
43
  },
44
44
  suggest_tests: {
45
45
  type: "boolean",
46
- description: "When true, suggestions for survived mutants include concrete RSpec test code " \
46
+ description: "When true, suggestions for survived mutants include concrete test code " \
47
47
  "instead of static description text (default: false)"
48
48
  },
49
49
  verbosity: {
@@ -64,10 +64,10 @@ class Evilution::MCP::MutateTool < MCP::Tool
64
64
  config_opts = build_config_opts(parsed_files, line_ranges, target, timeout, jobs, fail_fast, spec,
65
65
  suggest_tests)
66
66
  config = Evilution::Config.new(**config_opts)
67
- on_result = build_streaming_callback(server_context, suggest_tests)
67
+ on_result = build_streaming_callback(server_context, suggest_tests, config.integration)
68
68
  runner = Evilution::Runner.new(config: config, on_result: on_result)
69
69
  summary = runner.call
70
- report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true).call(summary)
70
+ report = Evilution::Reporter::JSON.new(suggest_tests: suggest_tests == true, integration: config.integration).call(summary)
71
71
  compact = trim_report(report, normalize_verbosity(verbosity))
72
72
 
73
73
  ::MCP::Tool::Response.new([{ type: "text", text: compact }])
@@ -156,10 +156,10 @@ class Evilution::MCP::MutateTool < MCP::Tool
156
156
  data[key].each { |entry| entry.delete("diff") }
157
157
  end
158
158
 
159
- def build_streaming_callback(server_context, suggest_tests)
159
+ def build_streaming_callback(server_context, suggest_tests, integration)
160
160
  return nil unless suggest_tests && server_context.respond_to?(:report_progress)
161
161
 
162
- suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true)
162
+ suggestion = Evilution::Reporter::Suggestion.new(suggest_tests: true, integration: integration)
163
163
  survivor_index = 0
164
164
 
165
165
  proc do |result|
@@ -7,7 +7,7 @@ require_relative "../mutator"
7
7
  class Evilution::Mutator::Base < Prism::Visitor
8
8
  attr_reader :mutations
9
9
 
10
- def initialize
10
+ def initialize(**_options)
11
11
  @mutations = []
12
12
  @subject = nil
13
13
  @file_source = nil
@@ -45,6 +45,10 @@ class Evilution::Mutator::Base < Prism::Visitor
45
45
  )
46
46
  end
47
47
 
48
+ def byteslice_source(offset, length)
49
+ @file_source.byteslice(offset, length).force_encoding(@file_source.encoding)
50
+ end
51
+
48
52
  def self.operator_name
49
53
  class_name = name || "anonymous"
50
54
  class_name.split("::").last
@@ -9,7 +9,7 @@ class Evilution::Mutator::Operator::BitwiseComplement < Evilution::Mutator::Base
9
9
  receiver_loc = node.receiver.location
10
10
 
11
11
  # Remove ~: replace entire ~expr with just the receiver expression
12
- receiver_source = @file_source[receiver_loc.start_offset, receiver_loc.length]
12
+ receiver_source = byteslice_source(receiver_loc.start_offset, receiver_loc.length)
13
13
  add_mutation(
14
14
  offset: node.location.start_offset,
15
15
  length: node.location.length,
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::BlockPassRemoval < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if node.block.is_a?(Prism::BlockArgumentNode)
8
+ block_node = node.block
9
+ call_start = node.location.start_offset
10
+ node_end = call_start + node.location.length
11
+ block_end = block_node.location.start_offset + block_node.location.length
12
+
13
+ prefix = @file_source.byteslice(call_start...block_node.location.start_offset).rstrip
14
+ suffix = @file_source.byteslice(block_end...node_end)
15
+
16
+ # Clean up: remove trailing comma from prefix, remove empty parens
17
+ prefix = prefix.sub(/,\s*\z/, "")
18
+ replacement = "#{prefix}#{suffix}".sub(/\(\s*\)/, "")
19
+
20
+ add_mutation(
21
+ offset: call_start,
22
+ length: node.location.length,
23
+ replacement: replacement,
24
+ node: node
25
+ )
26
+ end
27
+
28
+ super
29
+ end
30
+ end
@@ -21,7 +21,7 @@ class Evilution::Mutator::Operator::EnsureRemoval < Evilution::Mutator::Base
21
21
 
22
22
  def line_start_after_newline(offset)
23
23
  pos = offset
24
- pos -= 1 while pos.positive? && @file_source[pos - 1] != "\n"
24
+ pos -= 1 while pos.positive? && @file_source.getbyte(pos - 1) != 0x0A
25
25
  pos
26
26
  end
27
27
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::IndexToAt < Evilution::Mutator::Base
6
+ def visit_call_node(node)
7
+ if indexable?(node)
8
+ receiver_source = @file_source.byteslice(node.receiver.location.start_offset, node.receiver.location.length)
9
+ arg_source = @file_source.byteslice(node.arguments.location.start_offset, node.arguments.location.length)
10
+
11
+ add_mutation(
12
+ offset: node.location.start_offset,
13
+ length: node.location.length,
14
+ replacement: "#{receiver_source}.at(#{arg_source})",
15
+ node: node
16
+ )
17
+ end
18
+
19
+ super
20
+ end
21
+
22
+ private
23
+
24
+ def indexable?(node)
25
+ node.name == :[] &&
26
+ node.receiver &&
27
+ node.arguments &&
28
+ node.arguments.arguments.length == 1
29
+ end
30
+ end
@@ -3,7 +3,7 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
6
- def initialize
6
+ def initialize(**options)
7
7
  super
8
8
  @consumed = Set.new
9
9
  end
@@ -11,8 +11,8 @@ class Evilution::Mutator::Operator::IndexToDig < Evilution::Mutator::Base
11
11
  def visit_call_node(node)
12
12
  if chain_head?(node)
13
13
  root, args = collect_chain(node)
14
- root_source = @file_source[root.location.start_offset, root.location.length]
15
- arg_sources = args.map { |a| @file_source[a.location.start_offset, a.location.length] }
14
+ root_source = byteslice_source(root.location.start_offset, root.location.length)
15
+ arg_sources = args.map { |a| byteslice_source(a.location.start_offset, a.location.length) }
16
16
 
17
17
  add_mutation(
18
18
  offset: node.location.start_offset,
@@ -5,8 +5,8 @@ require_relative "../operator"
5
5
  class Evilution::Mutator::Operator::IndexToFetch < Evilution::Mutator::Base
6
6
  def visit_call_node(node)
7
7
  if indexable?(node)
8
- receiver_source = @file_source[node.receiver.location.start_offset, node.receiver.location.length]
9
- arg_source = @file_source[node.arguments.location.start_offset, node.arguments.location.length]
8
+ receiver_source = byteslice_source(node.receiver.location.start_offset, node.receiver.location.length)
9
+ arg_source = byteslice_source(node.arguments.location.start_offset, node.arguments.location.length)
10
10
 
11
11
  add_mutation(
12
12
  offset: node.location.start_offset,
@@ -26,7 +26,7 @@ class Evilution::Mutator::Operator::KeywordArgument < Evilution::Mutator::Base
26
26
  add_mutation(
27
27
  offset: kw_loc.start_offset,
28
28
  length: kw_loc.length,
29
- replacement: @file_source[name_loc.start_offset...name_loc.end_offset],
29
+ replacement: byteslice_source(name_loc.start_offset, name_loc.end_offset - name_loc.start_offset),
30
30
  node: kw
31
31
  )
32
32
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../operator"
4
+
5
+ class Evilution::Mutator::Operator::RegexSimplification < Evilution::Mutator::Base
6
+ def visit_regular_expression_node(node)
7
+ content = node.content
8
+ return super if content.empty?
9
+
10
+ content_offset = node.content_loc.start_offset
11
+
12
+ remove_quantifiers(node, content, content_offset)
13
+ remove_anchors(node, content, content_offset)
14
+ remove_character_class_ranges(node, content, content_offset)
15
+
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def remove_quantifiers(node, content, content_offset)
22
+ i = 0
23
+ while i < content.length
24
+ if content[i] == "\\"
25
+ i += 2
26
+ next
27
+ end
28
+
29
+ if content[i] == "["
30
+ i = skip_character_class(content, i)
31
+ next
32
+ end
33
+
34
+ match = match_quantifier(content, i)
35
+ if match
36
+ add_mutation(
37
+ offset: content_offset + i,
38
+ length: match.length,
39
+ replacement: "",
40
+ node: node
41
+ )
42
+ i += match.length
43
+ else
44
+ i += 1
45
+ end
46
+ end
47
+ end
48
+
49
+ def match_quantifier(content, pos)
50
+ case content[pos]
51
+ when "+", "*", "?"
52
+ content[pos]
53
+ when "{"
54
+ if (m = content[pos..].match(/\A\{\d+(?:,\d*)?\}/))
55
+ m[0]
56
+ end
57
+ end
58
+ end
59
+
60
+ def remove_anchors(node, content, content_offset)
61
+ i = 0
62
+ while i < content.length
63
+ if content[i] == "\\"
64
+ anchor = match_backslash_anchor(content, i)
65
+ if anchor
66
+ add_mutation(
67
+ offset: content_offset + i,
68
+ length: anchor.length,
69
+ replacement: "",
70
+ node: node
71
+ )
72
+ i += anchor.length
73
+ else
74
+ i += 2
75
+ end
76
+ next
77
+ end
78
+
79
+ if content[i] == "["
80
+ i = skip_character_class(content, i)
81
+ next
82
+ end
83
+
84
+ if %w[^ $].include?(content[i])
85
+ add_mutation(
86
+ offset: content_offset + i,
87
+ length: 1,
88
+ replacement: "",
89
+ node: node
90
+ )
91
+ end
92
+
93
+ i += 1
94
+ end
95
+ end
96
+
97
+ def match_backslash_anchor(content, pos)
98
+ return nil unless content[pos] == "\\"
99
+
100
+ two_char = content[pos, 2]
101
+ return two_char if %w[\\A \\z \\Z].include?(two_char)
102
+
103
+ nil
104
+ end
105
+
106
+ def remove_character_class_ranges(node, content, content_offset)
107
+ i = 0
108
+ while i < content.length
109
+ if content[i] == "\\"
110
+ i += 2
111
+ next
112
+ end
113
+
114
+ if content[i] == "["
115
+ scan_ranges_in_class(node, content, content_offset, i)
116
+ i = skip_character_class(content, i)
117
+ else
118
+ i += 1
119
+ end
120
+ end
121
+ end
122
+
123
+ def scan_ranges_in_class(node, content, content_offset, class_start)
124
+ first_item = skip_class_prefix(content, class_start)
125
+ i = first_item
126
+
127
+ while i < content.length && content[i] != "]"
128
+ if content[i] == "\\"
129
+ i += 2
130
+ next
131
+ end
132
+
133
+ emit_range_removal(node, content, content_offset, first_item, i) if content[i] == "-"
134
+ i += 1
135
+ end
136
+ end
137
+
138
+ def skip_class_prefix(content, class_start)
139
+ i = class_start + 1
140
+ i += 1 if i < content.length && content[i] == "^"
141
+ i += 1 if i < content.length && content[i] == "]"
142
+ i
143
+ end
144
+
145
+ def emit_range_removal(node, content, content_offset, first_item, pos)
146
+ return unless pos > first_item && pos + 1 < content.length && content[pos + 1] != "]"
147
+
148
+ add_mutation(
149
+ offset: content_offset + pos,
150
+ length: 1,
151
+ replacement: "",
152
+ node: node
153
+ )
154
+ end
155
+
156
+ def skip_character_class(content, pos)
157
+ i = pos + 1
158
+ i += 1 if i < content.length && content[i] == "^"
159
+ i += 1 if i < content.length && content[i] == "]"
160
+
161
+ while i < content.length
162
+ return i + 1 if content[i] == "]"
163
+
164
+ i += content[i] == "\\" ? 2 : 1
165
+ end
166
+
167
+ i
168
+ end
169
+ end
@@ -85,7 +85,7 @@ class Evilution::Mutator::Operator::RescueBodyReplacement < Evilution::Mutator::
85
85
  def indentation_of(offset)
86
86
  pos = offset - 1
87
87
  col = 0
88
- while pos >= 0 && @file_source[pos] != "\n"
88
+ while pos >= 0 && @file_source.getbyte(pos) != 0x0A
89
89
  col += 1
90
90
  pos -= 1
91
91
  end
@@ -31,7 +31,7 @@ class Evilution::Mutator::Operator::RescueRemoval < Evilution::Mutator::Base
31
31
 
32
32
  def line_start_before(offset)
33
33
  pos = offset - 1
34
- pos -= 1 while pos.positive? && @file_source[pos] != "\n"
34
+ pos -= 1 while pos.positive? && @file_source.getbyte(pos) != 0x0A
35
35
  pos
36
36
  end
37
37
  end
@@ -3,7 +3,25 @@
3
3
  require_relative "../operator"
4
4
 
5
5
  class Evilution::Mutator::Operator::StringLiteral < Evilution::Mutator::Base
6
+ def initialize(skip_heredoc_literals: false, **rest)
7
+ super(**rest)
8
+ @skip_heredoc_literals = skip_heredoc_literals
9
+ end
10
+
11
+ def visit_interpolated_string_node(node)
12
+ return super unless node.heredoc?
13
+ return if @skip_heredoc_literals
14
+
15
+ node.parts.each do |part|
16
+ next if part.is_a?(Prism::StringNode)
17
+
18
+ visit(part)
19
+ end
20
+ end
21
+
6
22
  def visit_string_node(node)
23
+ return super if node.heredoc?
24
+
7
25
  replacement = node.content.empty? ? '"mutation"' : '""'
8
26
 
9
27
  add_mutation(