brutal 1.6.0.beta4 → 2.0.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/LICENSE.md +1 -1
- data/README.md +155 -77
- data/bin/brutal +7 -12
- data/lib/brutal/cli.rb +134 -0
- data/lib/brutal/literal.rb +62 -0
- data/lib/brutal/manifest.rb +120 -79
- data/lib/brutal/output.rb +94 -0
- data/lib/brutal/program.rb +182 -0
- data/lib/brutal/runner.rb +186 -0
- data/lib/brutal.rb +81 -25
- metadata +29 -122
- data/lib/brutal/command_line_arguments_parser.rb +0 -115
- data/lib/brutal/format/ruby/filename.rb +0 -15
- data/lib/brutal/format/ruby.rb +0 -170
- data/lib/brutal/format.rb +0 -11
- data/lib/brutal/manifest/file/name.rb +0 -18
- data/lib/brutal/manifest/file.rb +0 -26
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Brutal
|
|
6
|
+
# Runs the code of a manifest, in two ways.
|
|
7
|
+
#
|
|
8
|
+
# {record} runs the recording program in a child process forked through
|
|
9
|
+
# Aw, as `ruby` would run the test file: compiled at the top level, with
|
|
10
|
+
# `self` as `main`, the magic comments of the header, `__FILE__`, `$0` and
|
|
11
|
+
# `__dir__` those of the test file, no arguments and nothing on the
|
|
12
|
+
# standard input. What the code does to the process stays in the child,
|
|
13
|
+
# and only data come back.
|
|
14
|
+
#
|
|
15
|
+
# {run} runs a test file with the Ruby that runs brutal, in a new process,
|
|
16
|
+
# as `ruby test_NAME.rb` would.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
module Runner
|
|
20
|
+
CLASS = ::Kernel.instance_method(:class)
|
|
21
|
+
VARIABLE = ::Kernel.instance_method(:instance_variable_get)
|
|
22
|
+
SCALARS = [::NilClass, ::TrueClass, ::FalseClass, ::Integer, ::Float, ::Symbol].freeze
|
|
23
|
+
DEPTH = 100
|
|
24
|
+
CREATE = ::File::RDWR | ::File::CREAT | ::File::EXCL
|
|
25
|
+
LIMIT = 65_536
|
|
26
|
+
|
|
27
|
+
private_constant :CLASS, :VARIABLE, :SCALARS, :DEPTH, :CREATE, :LIMIT
|
|
28
|
+
|
|
29
|
+
# Raised in the child for a value that cannot leave it as data.
|
|
30
|
+
class Unsupported < ::StandardError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# What the recording program calls, as `$__brutal__`. Its state is read
|
|
34
|
+
# without calling its methods, which the code may redefine.
|
|
35
|
+
class Recorder
|
|
36
|
+
def initialize
|
|
37
|
+
@phase = nil
|
|
38
|
+
@snapshots = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param phase [Integer] the index of the example about to run, or of the footer
|
|
42
|
+
# @return [nil]
|
|
43
|
+
def enter(phase)
|
|
44
|
+
@phase = phase
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param value [Object] an observed value
|
|
49
|
+
# @return [nil]
|
|
50
|
+
def observe(value)
|
|
51
|
+
@snapshots << Runner.snapshot(value)
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Runs the recording program in a child process.
|
|
57
|
+
#
|
|
58
|
+
# @param source [String]
|
|
59
|
+
# @param path [String] the absolute path of the test file
|
|
60
|
+
# @return [Array((Integer, nil), Array<Array(Symbol, Object)>, (String, nil))] the last phase (nil for the
|
|
61
|
+
# header), the snapshots, and the error, if any
|
|
62
|
+
# @raise [Brutal::Error] when the source does not compile, or the child ends without a result
|
|
63
|
+
def self.record(source, path)
|
|
64
|
+
program = compile(source, path)
|
|
65
|
+
fork do
|
|
66
|
+
recorder = Recorder.new
|
|
67
|
+
$__brutal__ = recorder # rubocop:disable Style/GlobalVars
|
|
68
|
+
::ARGV.clear
|
|
69
|
+
$PROGRAM_NAME = path
|
|
70
|
+
$stdin.reopen(::File::NULL)
|
|
71
|
+
error = evaluate(program)
|
|
72
|
+
[VARIABLE.bind_call(recorder, :@phase), VARIABLE.bind_call(recorder, :@snapshots), error]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Runs a test file with `RbConfig.ruby`, from the current directory, with nothing
|
|
77
|
+
# on its standard input and its standard output discarded. Its standard
|
|
78
|
+
# error goes to a file created next to it and removed at once: a process
|
|
79
|
+
# the test leaves running cannot keep brutal waiting.
|
|
80
|
+
#
|
|
81
|
+
# @param path [String]
|
|
82
|
+
# @return [String, nil] why it failed, from its standard error, or nil when it passed
|
|
83
|
+
def self.run(path)
|
|
84
|
+
log = ::File.join(::File.dirname(path), ".#{::File.basename(path)}.#{::Process.pid}.err")
|
|
85
|
+
pid = nil
|
|
86
|
+
::File.open(log, CREATE, 0o600, binmode: true) do |errors|
|
|
87
|
+
::File.unlink(log)
|
|
88
|
+
pid = ::Process.spawn(::RbConfig.ruby, path, in: ::File::NULL, out: ::File::NULL, err: errors)
|
|
89
|
+
_, status = ::Process.wait2(pid)
|
|
90
|
+
pid = nil
|
|
91
|
+
errors.rewind
|
|
92
|
+
failure(errors.read(LIMIT).to_s, status) unless status.success?
|
|
93
|
+
end
|
|
94
|
+
ensure
|
|
95
|
+
reap(pid)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The first line of the standard error that is not a warning, and how
|
|
99
|
+
# the process ended.
|
|
100
|
+
def self.failure(errors, status)
|
|
101
|
+
line = ::String.new(errors, encoding: ::Encoding::UTF_8).scrub.lines.find { |text| !text.include?(": warning: ") }
|
|
102
|
+
"#{line&.chomp || "no message"} (#{status(status)})"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Converts an observed value to data made of core classes only.
|
|
106
|
+
#
|
|
107
|
+
# @param value [Object]
|
|
108
|
+
# @return [Array(Symbol, Object)] `[:value, data]` or `[:unsupported, reason]`
|
|
109
|
+
def self.snapshot(value)
|
|
110
|
+
[:value, plain(value, 0)]
|
|
111
|
+
rescue Unsupported => e
|
|
112
|
+
[:unsupported, e.message]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Runs a block in a child process.
|
|
116
|
+
def self.fork(&)
|
|
117
|
+
raise Error, "brutal needs fork(2), which this Ruby does not have" unless ::Aw.supported?
|
|
118
|
+
|
|
119
|
+
::Aw.fork!(&)
|
|
120
|
+
rescue ::Aw::ChildError => e
|
|
121
|
+
raise Error, "the process running the code ended without a result (#{status(e.status)})"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Compiles the source, in the parent: nothing runs.
|
|
125
|
+
def self.compile(source, path)
|
|
126
|
+
::RubyVM::InstructionSequence.compile(source, path, path, 1)
|
|
127
|
+
rescue ::SyntaxError => e
|
|
128
|
+
raise Error, "the code does not compile: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Runs the program at the top level; returns what it raised, as text.
|
|
132
|
+
def self.evaluate(program)
|
|
133
|
+
program.eval
|
|
134
|
+
nil
|
|
135
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
136
|
+
describe(e)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The class and the message of an exception, built defensively: its
|
|
140
|
+
# methods may raise.
|
|
141
|
+
def self.describe(error)
|
|
142
|
+
name = CLASS.bind_call(error).name || "an anonymous exception"
|
|
143
|
+
message = error.message.to_s.encode(::Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
144
|
+
"#{name}: #{message}"
|
|
145
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
146
|
+
"#{name || "an exception"}, whose message cannot be read"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A copy of the value made of the classes Literal writes, by their exact
|
|
150
|
+
# class: an instance of a subclass, or of a class the parent does not
|
|
151
|
+
# know, cannot come back.
|
|
152
|
+
def self.plain(value, depth)
|
|
153
|
+
raise Unsupported, "is nested more than #{DEPTH} levels deep, or contains itself" if depth > DEPTH
|
|
154
|
+
raise Unsupported, "is a BasicObject" unless ::Kernel === value # rubocop:disable Style/CaseEquality
|
|
155
|
+
|
|
156
|
+
copy(value, CLASS.bind_call(value), depth + 1)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A copy of a value of the given class.
|
|
160
|
+
def self.copy(value, klass, depth)
|
|
161
|
+
return value if SCALARS.include?(klass)
|
|
162
|
+
return ::String.new(value) if klass.equal?(::String)
|
|
163
|
+
return value.map { |item| plain(item, depth) } if klass.equal?(::Array)
|
|
164
|
+
return value.to_h { |key, item| [plain(key, depth), plain(item, depth)] } if klass.equal?(::Hash)
|
|
165
|
+
|
|
166
|
+
raise Unsupported, "is an instance of #{klass.name || "an anonymous class"}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# How a process ended.
|
|
170
|
+
def self.status(status)
|
|
171
|
+
return "killed by SIG#{::Signal.signame(status.termsig)}" if status.signaled?
|
|
172
|
+
|
|
173
|
+
"exit status #{status.exitstatus}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Kills and reaps a process left running by an interruption.
|
|
177
|
+
def self.reap(pid)
|
|
178
|
+
return if pid.nil?
|
|
179
|
+
|
|
180
|
+
::Process.kill(:KILL, pid)
|
|
181
|
+
::Process.wait(pid)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private_class_method :failure, :fork, :compile, :evaluate, :describe, :plain, :copy, :status, :reap
|
|
185
|
+
end
|
|
186
|
+
end
|
data/lib/brutal.rb
CHANGED
|
@@ -1,40 +1,96 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
format
|
|
5
|
-
manifest
|
|
6
|
-
].each { |filename| require_relative(File.join("brutal", filename)) }
|
|
3
|
+
require "aw"
|
|
7
4
|
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
# Writes plain Ruby test files from YAML manifests. A manifest gives a
|
|
6
|
+
# subject, contexts to combine and actuals to observe; brutal runs every
|
|
7
|
+
# combination in a child process, records what the code returns, and writes
|
|
8
|
+
# a test file that checks these values with `eql?`, without any framework.
|
|
9
|
+
#
|
|
10
|
+
# The test file describes what the code does, not what it should do: read
|
|
11
|
+
# it before trusting it.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# Brutal.generate("test/calc_brutal.yaml").path # => "test/test_calc.rb"
|
|
15
|
+
module Brutal
|
|
16
|
+
# Raised for a manifest brutal cannot turn into a test file: the message
|
|
17
|
+
# says why, without the path of the manifest.
|
|
18
|
+
class Error < ::StandardError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A test file to write.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] path
|
|
24
|
+
# @return [String] where to write it: `test_NAME.rb`, next to the manifest
|
|
25
|
+
# @!attribute [r] source
|
|
26
|
+
# @return [String] its content
|
|
27
|
+
Test = ::Data.define(:path, :source)
|
|
11
28
|
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
# Reads a manifest, runs its code in a child process to record the values
|
|
30
|
+
# it returns, and builds the test file. Nothing is written.
|
|
31
|
+
#
|
|
32
|
+
# @param manifest_path [String] the path of a `NAME_brutal.yaml` file
|
|
33
|
+
# @return [Brutal::Test]
|
|
34
|
+
# @raise [Brutal::Error] when the manifest is not valid, the code does not
|
|
35
|
+
# compile, raises, ends its process or skips a check, a value cannot be
|
|
36
|
+
# written as a literal, or this Ruby cannot fork
|
|
37
|
+
# @raise [SystemCallError] when the manifest cannot be read
|
|
38
|
+
def self.generate(manifest_path)
|
|
39
|
+
program = Program.new(Manifest.read(manifest_path))
|
|
40
|
+
path = Output.target(manifest_path)
|
|
41
|
+
absolute = ::File.join(::File.realpath(::File.dirname(path)), ::File.basename(path))
|
|
42
|
+
Test.new(path:, source: program.test(literals(program, Runner.record(program.record, absolute))))
|
|
14
43
|
end
|
|
15
44
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
45
|
+
# Writes a test file, once the Ruby that runs brutal has run it, in a new
|
|
46
|
+
# process, and it passed: the code runs a second time. An unchanged file is
|
|
47
|
+
# run too.
|
|
48
|
+
#
|
|
49
|
+
# @param test [Brutal::Test]
|
|
50
|
+
# @return [void]
|
|
51
|
+
# @raise [Brutal::Error] when the test file fails, or cannot replace the file in place
|
|
52
|
+
# @raise [SystemCallError] when the file cannot be written
|
|
53
|
+
def self.write(test)
|
|
54
|
+
Output.write(test.path, test.source) do |temporary|
|
|
55
|
+
error = Runner.run(temporary)
|
|
56
|
+
# The name of the temporary file, which is ASCII, gives way to the test
|
|
57
|
+
# file's: the directory is the same, whatever the encoding of its name.
|
|
58
|
+
error &&= error.gsub(::File.basename(temporary), ::File.basename(test.path))
|
|
59
|
+
raise Error, "the test file fails when ruby runs it: #{error}" if error
|
|
60
|
+
end
|
|
21
61
|
end
|
|
22
62
|
|
|
23
|
-
|
|
63
|
+
# The literal of each observed value, once the recording ran to its end.
|
|
64
|
+
def self.literals(program, recording)
|
|
65
|
+
phase, snapshots, error = recording
|
|
66
|
+
raise Error, "#{program.phase(phase)}: #{error}" if error
|
|
67
|
+
unless snapshots.size == program.size
|
|
68
|
+
raise Error, "observed values: #{snapshots.size}, expected: #{program.size}; a check was skipped or repeated"
|
|
69
|
+
end
|
|
24
70
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
71
|
+
snapshots.each_with_index.map do |(kind, data), index|
|
|
72
|
+
literal, reason = literal(kind, data)
|
|
73
|
+
raise Error, "#{program.label(index)} #{reason}, which brutal cannot write as a literal" if reason
|
|
28
74
|
|
|
29
|
-
|
|
30
|
-
|
|
75
|
+
literal
|
|
76
|
+
end
|
|
31
77
|
end
|
|
32
78
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
79
|
+
# `[literal, nil]`, or `[nil, reason]` for a value that has no literal.
|
|
80
|
+
def self.literal(kind, data)
|
|
81
|
+
return [nil, data] if kind == :unsupported
|
|
36
82
|
|
|
37
|
-
|
|
38
|
-
|
|
83
|
+
[Literal.dump(data), nil]
|
|
84
|
+
rescue Error => e
|
|
85
|
+
[nil, e.message]
|
|
39
86
|
end
|
|
87
|
+
|
|
88
|
+
private_class_method :literals, :literal
|
|
40
89
|
end
|
|
90
|
+
|
|
91
|
+
require_relative "brutal/cli"
|
|
92
|
+
require_relative "brutal/literal"
|
|
93
|
+
require_relative "brutal/manifest"
|
|
94
|
+
require_relative "brutal/output"
|
|
95
|
+
require_relative "brutal/program"
|
|
96
|
+
require_relative "brutal/runner"
|
metadata
CHANGED
|
@@ -1,128 +1,32 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brutal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cyril Kato
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
14
|
+
name: aw
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- - "
|
|
17
|
+
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '0'
|
|
20
|
-
type: :
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
|
-
- - "
|
|
24
|
+
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '0'
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
requirements:
|
|
31
|
-
- - ">="
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0'
|
|
34
|
-
type: :development
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - ">="
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0'
|
|
41
|
-
- !ruby/object:Gem::Dependency
|
|
42
|
-
name: rubocop-gitlab-security
|
|
43
|
-
requirement: !ruby/object:Gem::Requirement
|
|
44
|
-
requirements:
|
|
45
|
-
- - ">="
|
|
46
|
-
- !ruby/object:Gem::Version
|
|
47
|
-
version: '0'
|
|
48
|
-
type: :development
|
|
49
|
-
prerelease: false
|
|
50
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
-
requirements:
|
|
52
|
-
- - ">="
|
|
53
|
-
- !ruby/object:Gem::Version
|
|
54
|
-
version: '0'
|
|
55
|
-
- !ruby/object:Gem::Dependency
|
|
56
|
-
name: rubocop-md
|
|
57
|
-
requirement: !ruby/object:Gem::Requirement
|
|
58
|
-
requirements:
|
|
59
|
-
- - ">="
|
|
60
|
-
- !ruby/object:Gem::Version
|
|
61
|
-
version: '0'
|
|
62
|
-
type: :development
|
|
63
|
-
prerelease: false
|
|
64
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
-
requirements:
|
|
66
|
-
- - ">="
|
|
67
|
-
- !ruby/object:Gem::Version
|
|
68
|
-
version: '0'
|
|
69
|
-
- !ruby/object:Gem::Dependency
|
|
70
|
-
name: rubocop-performance
|
|
71
|
-
requirement: !ruby/object:Gem::Requirement
|
|
72
|
-
requirements:
|
|
73
|
-
- - ">="
|
|
74
|
-
- !ruby/object:Gem::Version
|
|
75
|
-
version: '0'
|
|
76
|
-
type: :development
|
|
77
|
-
prerelease: false
|
|
78
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
-
requirements:
|
|
80
|
-
- - ">="
|
|
81
|
-
- !ruby/object:Gem::Version
|
|
82
|
-
version: '0'
|
|
83
|
-
- !ruby/object:Gem::Dependency
|
|
84
|
-
name: rubocop-rake
|
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
|
86
|
-
requirements:
|
|
87
|
-
- - ">="
|
|
88
|
-
- !ruby/object:Gem::Version
|
|
89
|
-
version: '0'
|
|
90
|
-
type: :development
|
|
91
|
-
prerelease: false
|
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
-
requirements:
|
|
94
|
-
- - ">="
|
|
95
|
-
- !ruby/object:Gem::Version
|
|
96
|
-
version: '0'
|
|
97
|
-
- !ruby/object:Gem::Dependency
|
|
98
|
-
name: rubocop-thread_safety
|
|
99
|
-
requirement: !ruby/object:Gem::Requirement
|
|
100
|
-
requirements:
|
|
101
|
-
- - ">="
|
|
102
|
-
- !ruby/object:Gem::Version
|
|
103
|
-
version: '0'
|
|
104
|
-
type: :development
|
|
105
|
-
prerelease: false
|
|
106
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
-
requirements:
|
|
108
|
-
- - ">="
|
|
109
|
-
- !ruby/object:Gem::Version
|
|
110
|
-
version: '0'
|
|
111
|
-
- !ruby/object:Gem::Dependency
|
|
112
|
-
name: yard
|
|
113
|
-
requirement: !ruby/object:Gem::Requirement
|
|
114
|
-
requirements:
|
|
115
|
-
- - ">="
|
|
116
|
-
- !ruby/object:Gem::Version
|
|
117
|
-
version: '0'
|
|
118
|
-
type: :development
|
|
119
|
-
prerelease: false
|
|
120
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
-
requirements:
|
|
122
|
-
- - ">="
|
|
123
|
-
- !ruby/object:Gem::Version
|
|
124
|
-
version: '0'
|
|
125
|
-
description: A code-first approach to automate the writing of unit tests.
|
|
26
|
+
version: '1.0'
|
|
27
|
+
description: 'Writes framework-free Ruby test files from YAML manifests: runs every
|
|
28
|
+
combination of contexts in a child process, and checks each observed value with
|
|
29
|
+
eql?.'
|
|
126
30
|
email: contact@cyril.email
|
|
127
31
|
executables:
|
|
128
32
|
- brutal
|
|
@@ -133,19 +37,22 @@ files:
|
|
|
133
37
|
- README.md
|
|
134
38
|
- bin/brutal
|
|
135
39
|
- lib/brutal.rb
|
|
136
|
-
- lib/brutal/
|
|
137
|
-
- lib/brutal/
|
|
138
|
-
- lib/brutal/format/ruby.rb
|
|
139
|
-
- lib/brutal/format/ruby/filename.rb
|
|
40
|
+
- lib/brutal/cli.rb
|
|
41
|
+
- lib/brutal/literal.rb
|
|
140
42
|
- lib/brutal/manifest.rb
|
|
141
|
-
- lib/brutal/
|
|
142
|
-
- lib/brutal/
|
|
143
|
-
|
|
43
|
+
- lib/brutal/output.rb
|
|
44
|
+
- lib/brutal/program.rb
|
|
45
|
+
- lib/brutal/runner.rb
|
|
46
|
+
homepage: https://fixrb.dev/gems/brutal/
|
|
144
47
|
licenses:
|
|
145
48
|
- MIT
|
|
146
49
|
metadata:
|
|
50
|
+
bug_tracker_uri: https://github.com/fixrb/brutal/issues
|
|
51
|
+
changelog_uri: https://github.com/fixrb/brutal#changes-in-20
|
|
52
|
+
documentation_uri: https://rubydoc.info/gems/brutal
|
|
147
53
|
rubygems_mfa_required: 'true'
|
|
148
|
-
|
|
54
|
+
source_code_uri: https://github.com/fixrb/brutal
|
|
55
|
+
post_install_message:
|
|
149
56
|
rdoc_options: []
|
|
150
57
|
require_paths:
|
|
151
58
|
- lib
|
|
@@ -153,15 +60,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
153
60
|
requirements:
|
|
154
61
|
- - ">="
|
|
155
62
|
- !ruby/object:Gem::Version
|
|
156
|
-
version:
|
|
63
|
+
version: 3.3.0
|
|
157
64
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
158
65
|
requirements:
|
|
159
|
-
- - "
|
|
66
|
+
- - ">="
|
|
160
67
|
- !ruby/object:Gem::Version
|
|
161
|
-
version:
|
|
68
|
+
version: '0'
|
|
162
69
|
requirements: []
|
|
163
|
-
rubygems_version: 3.
|
|
164
|
-
signing_key:
|
|
70
|
+
rubygems_version: 3.5.22
|
|
71
|
+
signing_key:
|
|
165
72
|
specification_version: 4
|
|
166
|
-
summary:
|
|
73
|
+
summary: Plain Ruby tests, written from what the code does
|
|
167
74
|
test_files: []
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative File.join("manifest", "file", "name")
|
|
4
|
-
|
|
5
|
-
require "pathname"
|
|
6
|
-
|
|
7
|
-
class Brutal
|
|
8
|
-
# Accept an arbitrary number of arguments passed from the command-line.
|
|
9
|
-
class CommandLineArgumentsParser
|
|
10
|
-
MANIFEST_FILENAME_PATTERN = ::File.join("**", Manifest::File::Name::SUFFIX_PATTERN)
|
|
11
|
-
CURRENT_DIRECTORY_CONTEXT = "."
|
|
12
|
-
GEM_NAME = "brutal"
|
|
13
|
-
HELP_OPTION = "--help"
|
|
14
|
-
VERSION_OPTION = "--version"
|
|
15
|
-
RUBY_FORMAT_OPTION = "--format=ruby"
|
|
16
|
-
DEFAULT_FORMAT_OPTION = RUBY_FORMAT_OPTION
|
|
17
|
-
|
|
18
|
-
FORMAT_OPTIONS = {
|
|
19
|
-
RUBY_FORMAT_OPTION => :Ruby
|
|
20
|
-
}.freeze
|
|
21
|
-
|
|
22
|
-
DEFAULT_FORMAT = FORMAT_OPTIONS.fetch(DEFAULT_FORMAT_OPTION)
|
|
23
|
-
DEFAULT_FORMATS = [DEFAULT_FORMAT].freeze
|
|
24
|
-
|
|
25
|
-
attr_reader :pathnames
|
|
26
|
-
|
|
27
|
-
def initialize(*args)
|
|
28
|
-
args.each do |arg|
|
|
29
|
-
help! if arg == HELP_OPTION
|
|
30
|
-
version! if arg == VERSION_OPTION
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
@formats = []
|
|
34
|
-
@pathnames = []
|
|
35
|
-
|
|
36
|
-
args << CURRENT_DIRECTORY_CONTEXT unless any_path_given?(*args)
|
|
37
|
-
args.each { |arg| parse!(arg) }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def call
|
|
41
|
-
[formats, pathnames]
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
def formats
|
|
47
|
-
@formats.empty? ? DEFAULT_FORMATS : @formats
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def parse!(arg)
|
|
51
|
-
case arg
|
|
52
|
-
when RUBY_FORMAT_OPTION
|
|
53
|
-
@formats << FORMAT_OPTIONS.fetch(RUBY_FORMAT_OPTION)
|
|
54
|
-
else
|
|
55
|
-
load!(::Pathname.new(arg))
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def help!
|
|
60
|
-
puts help_message
|
|
61
|
-
exit
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def help_message
|
|
65
|
-
<<~TXT
|
|
66
|
-
Usage: #{$PROGRAM_NAME} [options] [files or directories]
|
|
67
|
-
|
|
68
|
-
--format=FORMAT Choose "ruby" (default).
|
|
69
|
-
--help Display this help.
|
|
70
|
-
--version Display the version.
|
|
71
|
-
TXT
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def load!(pathname)
|
|
75
|
-
if pathname.directory?
|
|
76
|
-
pathname.glob(MANIFEST_FILENAME_PATTERN).each { |filename| load!(filename) }
|
|
77
|
-
elsif pathname.file?
|
|
78
|
-
load_file!(pathname)
|
|
79
|
-
else
|
|
80
|
-
raise ::ArgumentError, "#{pathname} is neither a file nor a directory."
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def load_file!(pathname)
|
|
85
|
-
if pathname.fnmatch?(MANIFEST_FILENAME_PATTERN, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH)
|
|
86
|
-
@pathnames << pathname
|
|
87
|
-
else
|
|
88
|
-
warn "Skipping #{pathname} because not matched against #{MANIFEST_FILENAME_PATTERN}."
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def any_path_given?(*args)
|
|
93
|
-
path_args(*args).any?
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def path_args(*args)
|
|
97
|
-
args - FORMAT_OPTIONS.keys
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def version!
|
|
101
|
-
abort "Gem not found on the system. Unknown version." if not_loaded_spec?
|
|
102
|
-
|
|
103
|
-
puts loaded_spec.version
|
|
104
|
-
exit
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def loaded_spec
|
|
108
|
-
::Gem.loaded_specs[GEM_NAME]
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def not_loaded_spec?
|
|
112
|
-
loaded_spec.nil?
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|