diff --git a/Changelog.md b/Changelog.md index da6944a2d..fa3bcda4c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,10 @@ +# v0.10.33 2021-08-25 + +* [#1249](https://github.com/mbj/mutant/pull/1249/files) + Add `mutant util mutation` subcommand to allow inspect mutations of + a code snippet outside a booted environment. + This eases debugging, learning and mutant developers life. + # v0.10.32 2021-05-16 * [#1235](https://github.com/mbj/mutant/pull/1235) diff --git a/lib/mutant.rb b/lib/mutant.rb index 3380a9f15..c2590a573 100644 --- a/lib/mutant.rb +++ b/lib/mutant.rb @@ -197,6 +197,7 @@ module Mutant require 'mutant/cli/command/environment/show' require 'mutant/cli/command/environment/subject' require 'mutant/cli/command/environment/test' +require 'mutant/cli/command/util' require 'mutant/cli/command/root' require 'mutant/runner' require 'mutant/runner/sink' @@ -212,6 +213,7 @@ module Mutant require 'mutant/reporter/cli/printer/env_progress' require 'mutant/reporter/cli/printer/env_result' require 'mutant/reporter/cli/printer/isolation_result' +require 'mutant/reporter/cli/printer/mutation' require 'mutant/reporter/cli/printer/mutation_result' require 'mutant/reporter/cli/printer/status_progressive' require 'mutant/reporter/cli/printer/subject_result' diff --git a/lib/mutant/cli/command.rb b/lib/mutant/cli/command.rb index 5afde7a5d..0a19853c4 100644 --- a/lib/mutant/cli/command.rb +++ b/lib/mutant/cli/command.rb @@ -69,6 +69,8 @@ def zombie? false end + abstract_method :action + private def subcommands diff --git a/lib/mutant/cli/command/root.rb b/lib/mutant/cli/command/root.rb index 949ebf2ff..2d862551a 100644 --- a/lib/mutant/cli/command/root.rb +++ b/lib/mutant/cli/command/root.rb @@ -10,7 +10,7 @@ class Environment < self class Root < self NAME = 'mutant' SHORT_DESCRIPTION = 'mutation testing engine main command' - SUBCOMMANDS = [Environment::Run, Environment, Subscription].freeze + SUBCOMMANDS = [Environment::Run, Environment, Subscription, Util].freeze end # Root end # Command end # CLI diff --git a/lib/mutant/cli/command/util.rb b/lib/mutant/cli/command/util.rb new file mode 100644 index 000000000..27cd36c5a --- /dev/null +++ b/lib/mutant/cli/command/util.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Mutant + module CLI + class Command + class Util < self + NAME = 'util' + SHORT_DESCRIPTION = 'Utility subcommands' + + class Mutation < self + NAME = 'mutation' + SHORT_DESCRIPTION = 'Print mutations of a code snippet' + SUBCOMMANDS = [].freeze + OPTIONS = %i[add_target_options].freeze + + def action + @targets.each(&method(:print_mutations)) + Either::Right.new(nil) + end + + private + + class Target + include Adamantium + + def node + Unparser.parse(source) + end + memoize :node + + class File < self + include Concord.new(:pathname, :source) + + public :source + + def identification + "file:#{pathname}" + end + end # File + + class Source < self + include Concord::Public.new(:source) + + def identification + '' + end + end # source + end # Target + + def initialize(_arguments) + super + + @targets = [] + end + + def add_target_options(parser) + parser.on('-e', '--evaluate SOURCE') do |source| + @targets << Target::Source.new(source) + end + end + + def print_mutations(target) + world.stdout.puts(target.identification) + Mutator.mutate(target.node).each do |mutation| + Reporter::CLI::Printer::Mutation.call( + world.stdout, + Mutant::Mutation::Evil.new(target, mutation) + ) + end + end + + def parse_remaining_arguments(arguments) + @targets.concat( + arguments.map do |argument| + parse_pathname(argument) + .bind(&method(:read_file)) + .from_right { |error| return Either::Left.new(error) } + end + ) + + Either::Right.new(self) + end + + def read_file(pathname) + Either::Right.new(Target::File.new(pathname, pathname.read)) + rescue StandardError => exception + Either::Left.new("Cannot read file: #{exception}") + end + + def parse_pathname(input) + Either.wrap_error(ArgumentError) { Pathname.new(input) } + .lmap(&:message) + end + end # Mutation + + SUBCOMMANDS = [Mutation].freeze + end # Util + end # Command + end # CLI +end # Mutant diff --git a/lib/mutant/mutation.rb b/lib/mutant/mutation.rb index 33c1f2777..0c6f8fda2 100644 --- a/lib/mutant/mutation.rb +++ b/lib/mutant/mutation.rb @@ -72,6 +72,15 @@ def insert(kernel) ) end + # Rendered mutation diff + # + # @return [String, nil] + # the diff, if present + def diff + Unparser::Diff.build(original_source, source) + end + memoize :diff + private def sha1 @@ -80,7 +89,6 @@ def sha1 # Evil mutation that should case mutations to fail tests class Evil < self - SYMBOL = 'evil' TEST_PASS_SUCCESS = false diff --git a/lib/mutant/reporter/cli/printer/mutation.rb b/lib/mutant/reporter/cli/printer/mutation.rb new file mode 100644 index 000000000..1cb447065 --- /dev/null +++ b/lib/mutant/reporter/cli/printer/mutation.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Mutant + class Reporter + class CLI + class Printer + # Reporter for mutations + class Mutation < self + NO_DIFF_MESSAGE = <<~'MESSAGE' + --- Internal failure --- + BUG: A generted mutation did not result in exactly one diff hunk! + This is an invariant violation by the mutation generation engine. + Please report a reproduction to https://github.com/mbj/mutant + Original unparsed source: + %s + Original AST: + %s + Mutated unparsed source: + %s + Mutated AST: + %s + MESSAGE + + SEPARATOR = '-----------------------' + + # Run report printer + # + # @return [undefined] + def run + diff = object.diff + diff = color? ? diff.colorized_diff : diff.diff + + if diff + output.write(diff) + else + print_no_diff_message + end + end + + def print_no_diff_message + info( + NO_DIFF_MESSAGE, + object.original_source, + original_node.inspect, + object.source, + object.node.inspect + ) + end + + def original_node + object.subject.node + end + + end # MutationResult + end # Printer + end # CLI + end # Reporter +end # Mutant diff --git a/lib/mutant/reporter/cli/printer/mutation_result.rb b/lib/mutant/reporter/cli/printer/mutation_result.rb index 5561d3701..8af68212b 100644 --- a/lib/mutant/reporter/cli/printer/mutation_result.rb +++ b/lib/mutant/reporter/cli/printer/mutation_result.rb @@ -66,23 +66,7 @@ def print_details end def evil_details - diff = Unparser::Diff.build(mutation.original_source, mutation.source) - diff = color? ? diff.colorized_diff : diff.diff - if diff - output.write(diff) - else - print_no_diff_message - end - end - - def print_no_diff_message - info( - NO_DIFF_MESSAGE, - mutation.original_source, - original_node.inspect, - mutation.source, - mutation.node.inspect - ) + visit(Mutation, mutation) end def noop_details @@ -90,11 +74,7 @@ def noop_details end def neutral_details - info(NEUTRAL_MESSAGE, original_node.inspect, mutation.source) - end - - def original_node - mutation.subject.node + info(NEUTRAL_MESSAGE, mutation.node.inspect, mutation.source) end end # MutationResult diff --git a/spec/unit/mutant/cli_spec.rb b/spec/unit/mutant/cli_spec.rb index 61fcad23c..5d5ca2a70 100644 --- a/spec/unit/mutant/cli_spec.rb +++ b/spec/unit/mutant/cli_spec.rb @@ -2,13 +2,13 @@ RSpec.describe Mutant::CLI do describe '.parse' do - let(:env_config) { Mutant::Config::DEFAULT.with(jobs: 4) } - let(:events) { [] } - let(:expect_zombie) { false } - let(:kernel) { class_double(Kernel) } - let(:stderr) { instance_double(IO, :stderr) } - let(:stdout) { instance_double(IO, :stdout) } - let(:timer) { instance_double(Mutant::Timer) } + let(:env_config) { Mutant::Config::DEFAULT.with(jobs: 4) } + let(:events) { [] } + let(:expect_zombie) { false } + let(:kernel) { class_double(Kernel) } + let(:stderr) { instance_double(IO, :stderr, tty?: false) } + let(:stdout) { instance_double(IO, :stdout, tty?: false) } + let(:timer) { instance_double(Mutant::Timer) } let(:world) do instance_double( @@ -31,9 +31,15 @@ def apply allow(stderr) .to receive(:puts) { |message| events << [:stderr, :puts, message] } + allow(stderr) + .to receive(:write) { |message| events << [:stderr, :write, message] } + allow(stdout) .to receive(:puts) { |message| events << [:stdout, :puts, message] } + allow(stdout) + .to receive(:write) { |message| events << [:stdout, :write, message] } + allow(Mutant::Config).to receive_messages(env: env_config) end @@ -79,7 +85,7 @@ def self.make def self.main_body <<~'MESSAGE'.strip - usage: mutant [options] + usage: mutant [options] Summary: mutation testing engine main command @@ -93,6 +99,7 @@ def self.main_body run - Run code analysis environment - Environment subcommands subscription - Subscription subcommands + util - Utility subcommands MESSAGE end @@ -261,6 +268,110 @@ def self.main_body } end + make do + message = <<~'MESSAGE' + usage: mutant util [options] + + Summary: Utility subcommands + + Global Options: + + --help Print help + --version Print mutants version + + Available subcommands: + + mutation - Print mutations of a code snippet + MESSAGE + + { + arguments: %w[util --help], + expected_events: [[:stdout, :puts, message]], + expected_exit: true, + expected_zombie: false + } + end + + make do + message = <<~'MESSAGE' + usage: mutant util mutation [options] + + Summary: Print mutations of a code snippet + + Global Options: + + --help Print help + --version Print mutants version + + + -e, --evaluate SOURCE + MESSAGE + + { + arguments: %w[util mutation --help], + expected_events: [[:stdout, :puts, message]], + expected_exit: true, + expected_zombie: false + } + end + + make do + message = <<~'MESSAGE' + @@ -1 +1 @@ + -true + +false + MESSAGE + + { + arguments: %w[util mutation -e true], + expected_events: [ + [:stdout, :puts, ''], + [:stdout, :write, message] + ], + expected_exit: true, + expected_zombie: false + } + end + + make do + message = <<~'MESSAGE' + @@ -1 +1 @@ + -true + +false + MESSAGE + + { + arguments: %w[util mutation test_app/simple.rb], + expected_events: [ + [:stdout, :puts, 'file:test_app/simple.rb'], + [:stdout, :write, message] + ], + expected_exit: true, + expected_zombie: false + } + end + + make do + { + arguments: %w[util mutation] + ["\0"], + expected_events: [ + [:stderr, :puts, 'pathname contains null byte'] + ], + expected_exit: false, + expected_zombie: false + } + end + + make do + { + arguments: %w[util mutation does-not-exist.rb], + expected_events: [[:stderr, :puts, + 'Cannot read file: No such file or directory @ rb_sysopen - does-not-exist.rb']], + expected_exit: false, + expected_zombie: false + } + end + @tests.each do |example| context example.arguments.inspect do example.to_h.each do |key, value| diff --git a/spec/unit/mutant/mutation_spec.rb b/spec/unit/mutant/mutation_spec.rb index 35cd7a241..aa043dbdc 100644 --- a/spec/unit/mutant/mutation_spec.rb +++ b/spec/unit/mutant/mutation_spec.rb @@ -144,6 +144,18 @@ def apply end end + describe '#diff' do + def apply + object.diff + end + + it 'returns expected diff' do + expect(apply).to eql( + Unparser::Diff.new(%w[original], %w[nil]) + ) + end + end + describe '#identification' do subject { object.identification } diff --git a/test_app/simple.rb b/test_app/simple.rb new file mode 100644 index 000000000..27ba77dda --- /dev/null +++ b/test_app/simple.rb @@ -0,0 +1 @@ +true