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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fac7c55010f838339f889a969e3ba78fc7ee40b279b0d426248b0eec5f5f8b0f
|
|
4
|
+
data.tar.gz: 5be3b432b0319f72073043ddb2c8fad5c2cf053da211f80c6a5588942beaf701
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b52adf50058f49a60fb97d80f35340a845d489a1547cb9186ff2f6913990a064f23c65b2adf10968dec2fecc91e3f5b8259ab0c02b30a996776d123f85b3887a
|
|
7
|
+
data.tar.gz: 96ebc35d1e1326d99350e389787168e0dae7f65c26c273d943f4f516dac487505ed7e743bd556439cd03825d62bd357da37edd47071cb8b43d8c6f8821e0a596
|
data/LICENSE.md
CHANGED
data/README.md
CHANGED
|
@@ -2,147 +2,225 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/fixrb/brutal/tags)
|
|
4
4
|
[](https://rubydoc.info/github/fixrb/brutal/main)
|
|
5
|
-
[](https://github.com/fixrb/brutal/actions?query=workflow%3Aruby+branch%3Amain)
|
|
6
|
-
[](https://github.com/fixrb/brutal/actions?query=workflow%3Arubocop+branch%3Amain)
|
|
7
5
|
[](https://github.com/fixrb/brutal/raw/main/LICENSE.md)
|
|
8
6
|
|
|
9
7
|
> A _code-first_ approach to automate the writing of unit tests.
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
> I don't like tests. It's not DRY.
|
|
10
|
+
> -- [Matz](https://github.com/matz), [RubyHack 2019](https://www.youtube.com/embed/cmOt9HhszCI?start=1732&end=1736)
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+

|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
> -- [Matz](https://github.com/matz)
|
|
14
|
+
Brutal writes plain Ruby test files from YAML manifests. A manifest gives a subject, contexts to combine and actuals to observe; brutal runs every combination, records what the code returns, and writes a test file that checks each of these values, without any framework: `ruby test_user.rb` runs it.
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
A test file written by brutal is a picture of what the code does, not of what it should do. If the code is wrong, the picture is wrong too, and every check passes: read the test file before you trust it, as you would read any code under review.
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
## Installation
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
```ruby
|
|
21
|
+
# Gemfile
|
|
22
|
+
gem "brutal", require: false
|
|
23
|
+
```
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
or `gem install brutal` for the command alone.
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
Brutal requires Ruby 3.3 or later and `fork(2)`: it runs the code in a child process through [Aw](https://github.com/fixrb/aw), on Linux and macOS, with CRuby. Windows and JRuby are not supported.
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
## Usage
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
Given this class:
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
```ruby
|
|
34
|
+
# lib/greeter.rb
|
|
35
|
+
class Greeter
|
|
36
|
+
def initialize(name)
|
|
37
|
+
@name = name.capitalize
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def salute
|
|
41
|
+
"Hello #{@name}!"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
33
45
|
|
|
34
|
-
|
|
46
|
+
a manifest is a file named `NAME_brutal.yaml`:
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
```yaml
|
|
49
|
+
# test/greeter_brutal.yaml
|
|
50
|
+
header: |
|
|
51
|
+
require_relative "../lib/greeter"
|
|
37
52
|
|
|
38
|
-
|
|
53
|
+
subject: |
|
|
54
|
+
Greeter.new(%{name})
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
contexts:
|
|
57
|
+
name:
|
|
58
|
+
- '"Alice"'
|
|
59
|
+
- '"world"'
|
|
43
60
|
|
|
44
|
-
|
|
61
|
+
actuals:
|
|
62
|
+
- "%{subject}.salute"
|
|
63
|
+
- "%{subject}.salute.length"
|
|
64
|
+
```
|
|
45
65
|
|
|
46
66
|
```sh
|
|
47
|
-
bundle
|
|
67
|
+
bundle exec brutal test/greeter_brutal.yaml
|
|
48
68
|
```
|
|
49
69
|
|
|
50
|
-
|
|
70
|
+
Brutal writes `test/test_greeter.rb` next to it:
|
|
51
71
|
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
```
|
|
72
|
+
```ruby
|
|
73
|
+
# Generated by brutal from greeter_brutal.yaml: edit the manifest, then run brutal again.
|
|
55
74
|
|
|
56
|
-
|
|
75
|
+
require_relative "../lib/greeter"
|
|
57
76
|
|
|
58
|
-
|
|
77
|
+
# ------------------------------------------------------------------------------
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
actual = begin
|
|
80
|
+
Greeter.new("Alice")
|
|
81
|
+
end
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
* `footer` - Specifies a block of lines to be executed once after all examples.
|
|
83
|
+
::Kernel.raise "name = \"Alice\": actual.salute" unless ("Hello Alice!").eql?((
|
|
84
|
+
actual.salute
|
|
85
|
+
))
|
|
86
|
+
::Kernel.raise "name = \"Alice\": actual.salute.length" unless (12).eql?((
|
|
87
|
+
actual.salute.length
|
|
88
|
+
))
|
|
70
89
|
|
|
71
|
-
|
|
90
|
+
# ------------------------------------------------------------------------------
|
|
72
91
|
|
|
73
|
-
|
|
92
|
+
actual = begin
|
|
93
|
+
Greeter.new("world")
|
|
94
|
+
end
|
|
74
95
|
|
|
75
|
-
|
|
96
|
+
::Kernel.raise "name = \"world\": actual.salute" unless ("Hello World!").eql?((
|
|
97
|
+
actual.salute
|
|
98
|
+
))
|
|
99
|
+
::Kernel.raise "name = \"world\": actual.salute.length" unless (12).eql?((
|
|
100
|
+
actual.salute.length
|
|
101
|
+
))
|
|
102
|
+
```
|
|
76
103
|
|
|
77
104
|
```sh
|
|
78
|
-
|
|
105
|
+
ruby test/test_greeter.rb
|
|
79
106
|
```
|
|
80
107
|
|
|
81
|
-
|
|
82
|
-
Usage: brutal [options] [files or directories]
|
|
108
|
+
A check that fails raises a `RuntimeError` that names the example and the actual; the rest of the file does not run. More examples are in [examples/](https://github.com/fixrb/brutal/tree/main/examples/).
|
|
83
109
|
|
|
84
|
-
|
|
85
|
-
--help Display this help.
|
|
86
|
-
--version Display the version.
|
|
87
|
-
```
|
|
110
|
+
## The manifest
|
|
88
111
|
|
|
89
|
-
|
|
112
|
+
A manifest is a UTF-8 file (a BOM is accepted) holding one YAML document of seven sections; all but `contexts` hold Ruby code:
|
|
90
113
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
114
|
+
* `header` (optional): run once, before the first example.
|
|
115
|
+
* `before` (optional): run before each example.
|
|
116
|
+
* `subject`: the code of each example, with a `%{name}` for each context.
|
|
117
|
+
* `contexts`: for each name, a list of code fragments to put in place of `%{name}`.
|
|
118
|
+
* `actuals`: the expressions to observe, with `%{subject}` for the value of the subject.
|
|
119
|
+
* `after` (optional): run after each example.
|
|
120
|
+
* `footer` (optional): run once, after the last example.
|
|
94
121
|
|
|
95
|
-
|
|
122
|
+
There is one example for every combination of the contexts, in the order of the file, the first context varying the slowest. In each example, the value of the subject is held in a local variable named `actual`, and each actual is checked in turn.
|
|
96
123
|
|
|
97
|
-
|
|
98
|
-
brutal .
|
|
99
|
-
```
|
|
124
|
+
The values are read as text, never converted to YAML types: `1,2`, `yes`, `~` or `12:30` stay the Ruby code `1,2`, `yes`, `~` and `12:30` (`1,2` makes sense in a subject such as `[%{v}]`). Only the line breaks that end a value are dropped. YAML's own rules still apply: quotes and escapes are read as YAML reads them, a value that starts with a YAML indicator (`%`, `[`, `{`, `&`, `*`, `!`, `|`, `>`, `@`, `#`…) or holds `: ` or ` #` must be quoted (an unquoted ` #` starts a comment, silently), and in a flow list (`[a, b]`) a comma separates items. A value of several lines must be a literal block (`|`), which YAML does not fold. A fragment is code: a string needs its quotes (`'"Alice"'`), and a plain `Alice` is a constant.
|
|
100
125
|
|
|
101
|
-
or
|
|
126
|
+
`%{name}` is replaced in the subject, and `%{subject}` in the actuals; nothing else is interpreted, and any other `%` is a `%`. Brutal refuses a placeholder without a context, a context the subject does not use, an actual that uses anything but `%{subject}`, a placeholder in another section, unknown, duplicate, missing or empty sections, and YAML anchors, aliases and tags. `NAME` is made of ASCII letters, digits, `.`, `_` and `-`, and does not start with a dot or a dash; a context name is made of ASCII letters, digits and `_`, and does not start with a digit.
|
|
102
127
|
|
|
103
|
-
|
|
104
|
-
brutal
|
|
105
|
-
```
|
|
128
|
+
Each section, each subject once its fragments are in place, and each actual must be complete code on its own, and must not hold an end of file (an `__END__` line, a NUL, a ^D or a ^Z): a part cannot open a block, a string or a comment that another part closes, so it cannot skip, swallow or reorder a check. A part can still read differently once combined, through a local variable of an earlier part (`foo -1` is a call alone, a subtraction after `foo = 10`); the recording and the test file read it the same way.
|
|
106
129
|
|
|
107
|
-
|
|
130
|
+
In the test file, a subject or an actual of one line is indented; one of several lines is written as it is, so that a multi-line string or a heredoc keeps its content. The other sections are written as they are.
|
|
108
131
|
|
|
109
|
-
|
|
132
|
+
## How the code runs
|
|
110
133
|
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
Brutal runs the code in a child process, as `ruby` will run the test file: at the top level, with `self` as `main`, the magic comments of the header (`# frozen_string_literal: true`), the local variables of each section visible to the next ones, `__dir__` the directory of the test file, `__FILE__` and `$0` its absolute path, no arguments and nothing on the standard input. It runs in the current directory: use `require_relative` and `__dir__` rather than paths relative to it. What the code does to the process (a monkey patch, a global, a constant, the current directory, `ENV`, a crash) stays in the child; what it does outside, to files or other processes, happens.
|
|
135
|
+
|
|
136
|
+
Then brutal runs the test file with the Ruby that runs brutal, in a new process, from the current directory, under a temporary name next to it (`.test_NAME.rb.PID.tmp`), before it writes it; an unchanged test file is run where it is. It must pass; when it does not, brutal reports the first line of its standard error that is not a warning. This catches what differs between brutal's child and a real run: the child was forked from brutal, so it has the constants `Aw`, `Brutal`, `Psych` and `StringIO` (and Psych's `to_yaml`), which the test file alone does not; and a hash, an object id or a random number may differ from one process to the next. A value that happens to be the same in both runs (the current minute, a small random number) is not caught: do not observe it. Do not observe `__FILE__` or `$0` either: they are the absolute path of the test file in the recording, a temporary name in brutal's run, and what you type in yours; during the recording, the test file does not exist yet, or is the previous one.
|
|
137
|
+
|
|
138
|
+
So the code runs twice. Its output shows once, the test file's run being silent, but its side effects happen twice. `--check` runs it once, and writes nothing.
|
|
139
|
+
|
|
140
|
+
Brutal runs every manifest it is given: a manifest is code, and runs with your rights. Run brutal on your own manifests only.
|
|
141
|
+
|
|
142
|
+
## Expected values
|
|
143
|
+
|
|
144
|
+
An observed value is written as a Ruby literal, the same whatever the version of Ruby and the locale: `nil`, `true`, `false`, an `Integer`, a finite `Float`, a `String` in UTF-8, in binary, or with ASCII characters only, a `Symbol`, and an `Array` or a `Hash` of these, by their exact class. Non-ASCII and invisible characters are escaped (`"caf\u00E9"`), and a hash is written with `=>`.
|
|
145
|
+
|
|
146
|
+
Anything else is refused, with the example and the actual: an object, a `Struct`, a `Set`, a `Range`, a `Rational`, a `Time`, a subclass of `String`, `Array` or `Hash`, `NaN`, an infinite `Float`, a string with non-ASCII characters in another encoding, a structure nested more than 100 levels deep or that contains itself. Observe a projection instead: `.to_s`, `.to_a`, `.to_h`, `.size`, an attribute. Mind that some projections depend on the version of Ruby: `{a: 1}.to_s` is `"{:a=>1}"` before Ruby 3.4 and `"{a: 1}"` since, and the text of an error message changes from one version to another.
|
|
147
|
+
|
|
148
|
+
Each value is checked with `eql?`, the expected value receiving the call: `1` and `1.0` differ, and an object that claims to equal everything does not pass. `eql?` does not tell `0.0` from `-0.0`, nor the encoding of a string of ASCII characters. Once the test file is written, a string, an array or a hash of a subclass with the same content would pass it, and a hash compares its pairs with `eql?`; an object that is not a hash but converts to one (`to_hash`) is compared by its own `eql?`.
|
|
149
|
+
|
|
150
|
+
## The command
|
|
151
|
+
|
|
152
|
+
```txt
|
|
153
|
+
Usage: brutal [--check] PATH...
|
|
154
|
+
|
|
155
|
+
Writes test_NAME.rb next to each NAME_brutal.yaml given as PATH, or found
|
|
156
|
+
in a directory given as PATH and its subdirectories.
|
|
157
|
+
|
|
158
|
+
--check Run the code, write nothing; fail when a test file is missing or differs.
|
|
159
|
+
--help, -h Show this help.
|
|
113
160
|
```
|
|
114
161
|
|
|
115
|
-
|
|
162
|
+
A directory is searched with its subdirectories, in order; hidden files and directories, symbolic links to directories, and anything but a regular file (a symbolic link to one counts) are left out, and a directory that cannot be read is an error, and no manifest of that PATH runs. A manifest given twice, or through a symbolic link to its directory, runs once. Point brutal at your test directory: a directory holding installed gems (`vendor/bundle`) holds code you did not write. An argument that starts with `-` is an option: a path that does is written `./-x`.
|
|
116
163
|
|
|
117
|
-
|
|
164
|
+
A test file is written whole or not at all, through a temporary file renamed over it, with the permissions of the file it replaces (not its setuid, setgid or sticky bits); an unchanged file is left untouched, but run again. Brutal only replaces a file it wrote, whose first line is `# Generated by brutal from`, and checks this before running the code: a test file written by hand, or by brutal 1, a symbolic link or a directory in its place is an error.
|
|
118
165
|
|
|
119
|
-
|
|
166
|
+
Each manifest that fails is reported on the standard error, with its path and the reason, and the others still run. The exit status is 0 when every manifest succeeded, 1 when one failed or a path designates no manifest, and 2 for a usage error. Interrupted, brutal kills the code it runs, writes nothing more, and ends by the signal.
|
|
120
167
|
|
|
121
|
-
|
|
168
|
+
`--check` generates every test file in memory and compares it with the one in place, without running it: run it in CI, so that a test file cannot drift from its manifest. It reports a missing test file, one out of date and one brutal did not write, but not a test file whose manifest was removed.
|
|
122
169
|
|
|
123
|
-
|
|
170
|
+
## Rake integration
|
|
124
171
|
|
|
125
172
|
```ruby
|
|
173
|
+
# Rakefile
|
|
174
|
+
require "rake/testtask"
|
|
175
|
+
|
|
176
|
+
desc "Write the test files of the brutal manifests"
|
|
177
|
+
task :brutal do
|
|
178
|
+
require "brutal"
|
|
179
|
+
raise "brutal failed" unless Brutal::CLI.run(["test"]).zero?
|
|
180
|
+
end
|
|
181
|
+
|
|
126
182
|
Rake::TestTask.new do |t|
|
|
127
|
-
t.pattern = "
|
|
183
|
+
t.pattern = "test/**/test_*.rb"
|
|
128
184
|
end
|
|
129
185
|
```
|
|
130
186
|
|
|
131
|
-
|
|
187
|
+
In CI, run `bundle exec brutal --check test` before the tests.
|
|
132
188
|
|
|
133
|
-
|
|
189
|
+
## Things to know
|
|
190
|
+
|
|
191
|
+
- The test file is plain Ruby, run by `ruby` or any test runner that loads files. It uses one local variable, `actual`, at the top level of the file; under `ruby -w`, a manifest whose actuals do not use `%{subject}` warns that `actual` is unused.
|
|
192
|
+
- A failing check raises, and stops its file. `Rake::TestTask` loads every test file in one process: the first failure stops the run, and what a test file changes (a monkey patch) reaches the files loaded after it. Brutal runs each test file alone.
|
|
193
|
+
- An `exit`, an `exit!` or a signal during the recording, or a top-level `return` that skips a check of the recording, is reported as a failure of the manifest, and nothing is written. During the recording, `at_exit` handlers do not run.
|
|
194
|
+
- There is no timeout: code that never ends keeps brutal waiting, and so does, during the recording, a process the code forks and leaves running. Killed with `SIGKILL`, brutal leaves its child running, and may leave a `.test_NAME.rb.PID.tmp` file next to the test file.
|
|
195
|
+
- A magic comment that must be on the first line (`# encoding:`, `# warn_indent:`) has no effect in the header, which starts on the third line; `# frozen_string_literal:` and `# shareable_constant_value:` do. The test file is read as UTF-8.
|
|
196
|
+
- On macOS, code that uses Objective-C classes may crash in the child process: run brutal with `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`.
|
|
197
|
+
- A check is `::Kernel.raise … unless (expected).eql?((actual))`: code that redefines `Kernel.raise`, or `eql?` on core classes (a refinement included), ends the process from an `at_exit` handler or a `TracePoint`, or behaves differently when it finds brutal (`$__brutal__`), changes the checks. Brutal does not guard against code that sets out to defeat it.
|
|
198
|
+
|
|
199
|
+
## Changes in 2.0
|
|
200
|
+
|
|
201
|
+
Compared with 1.6.0.beta4:
|
|
202
|
+
|
|
203
|
+
- The code no longer runs inside brutal, through `eval` in a method of the generator, in the directory of the manifest, but in a child process, at the top level, from the current directory, as the test file runs: `self`, local variables across sections, reopened classes, magic comments and `__dir__` behave as in the test file, and nothing leaks from a manifest to the next one or to brutal. Before it is written, the test file must pass when `ruby` runs it.
|
|
204
|
+
- An expected value is written as a literal by brutal, never as its `inspect`: a value whose `inspect` is not Ruby (`#<Point …>`, which turned the rest of the line into a comment and hid the next check), an unparenthesized `Range` or a `Rational` is refused instead of written. Literals are the same whatever the version of Ruby and the locale.
|
|
205
|
+
- A check is `(expected).eql?((actual))`: the expected value decides, an `Integer` that becomes a `Float` fails, and an actual is parenthesized on its own lines, so that a `rescue` modifier, an `&&` or a trailing comment stays inside it. A check that fails names the example and the actual.
|
|
206
|
+
- Each section, subject and actual must be complete code on its own, without an end of file.
|
|
207
|
+
- A test file is written whole, through a temporary file, and only over a file brutal wrote. A manifest that fails no longer empties its test file.
|
|
208
|
+
- The manifest is read as text: YAML no longer turns `1,2` into `12` or `yes` into `true`, and fragments are no longer stripped. A value of several lines must be a literal block. `%{name}` is replaced by brutal itself: a `%` in the code is written once, and a `%%` that 1.x needed must be undoubled. Anchors, tags, unknown or duplicate sections, empty sections, a scalar context, a missing `contexts` or `actuals` section, unused contexts and placeholders outside the subject and the actuals are refused. Contexts combine in the order of the file, instead of the alphabetical order of their names. A manifest's name is limited to ASCII letters, digits, `.`, `_` and `-`, and does not start with `.` or `-`.
|
|
209
|
+
- Code of several lines is no longer indented, which changed multi-line strings and heredocs.
|
|
210
|
+
- `brutal` requires a path, and no longer searches the current directory by default. `--check` is new; `--format` and `--version` are gone. A failing manifest is reported with its path and the others still run, where 1.x stopped at the first, with a backtrace.
|
|
211
|
+
- The test file starts with a line saying it was generated, and has no default comments. Test files written by brutal 1 do not carry it: delete them once, then run brutal again.
|
|
212
|
+
- Ruby 3.3 or later, and Aw 1.0.
|
|
213
|
+
|
|
214
|
+
Compared with 1.5.0, the last stable release, a manifest is no longer any YAML file (`.brutal.yml` by default, written to `test.rb`, or `foo.yaml` to `foo.rb`), and `--no-force` is gone: every manifest is `NAME_brutal.yaml`, written to `test_NAME.rb`. The `before`, `after` and `footer` sections are new, and an unknown section, which 1.5.0 ignored, is refused.
|
|
134
215
|
|
|
135
216
|
## Versioning
|
|
136
217
|
|
|
137
|
-
|
|
218
|
+
Brutal follows [Semantic Versioning 2.0](https://semver.org/).
|
|
138
219
|
|
|
139
220
|
## License
|
|
140
221
|
|
|
141
222
|
The [gem](https://rubygems.org/gems/brutal) is available as open source under the terms of the [MIT License](https://github.com/fixrb/brutal/raw/main/LICENSE.md).
|
|
142
223
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
This project is sponsored by [Sashité](https://github.com/sashite/):
|
|
224
|
+
## Sponsors
|
|
146
225
|
|
|
147
|
-
|
|
148
|
-

|
|
226
|
+
This project is sponsored by [Sashité](https://sashite.com/)
|
data/bin/brutal
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require_relative File.join("..", "lib", "brutal", "command_line_arguments_parser")
|
|
5
|
-
|
|
6
|
-
formats, pathnames = Brutal::CommandLineArgumentsParser.new(*ARGV).call
|
|
7
|
-
|
|
8
4
|
require_relative File.join("..", "lib", "brutal")
|
|
9
5
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
end
|
|
6
|
+
begin
|
|
7
|
+
exit Brutal::CLI.run(ARGV)
|
|
8
|
+
rescue Interrupt
|
|
9
|
+
# Aw has killed and reaped the child. End by the signal, as an interrupted
|
|
10
|
+
# command does, so that a shell loop stops too, but without a backtrace.
|
|
11
|
+
Signal.trap(:INT, "SYSTEM_DEFAULT")
|
|
12
|
+
Process.kill(:INT, Process.pid)
|
|
18
13
|
end
|
data/lib/brutal/cli.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brutal
|
|
4
|
+
# The `brutal` command.
|
|
5
|
+
module CLI
|
|
6
|
+
# @return [String]
|
|
7
|
+
USAGE = <<~TXT
|
|
8
|
+
Usage: brutal [--check] PATH...
|
|
9
|
+
|
|
10
|
+
Writes test_NAME.rb next to each NAME_brutal.yaml given as PATH, or found
|
|
11
|
+
in a directory given as PATH and its subdirectories.
|
|
12
|
+
|
|
13
|
+
--check Run the code, write nothing; fail when a test file is missing or differs.
|
|
14
|
+
--help, -h Show this help.
|
|
15
|
+
TXT
|
|
16
|
+
|
|
17
|
+
OPTIONS = %w[--check --help -h].freeze
|
|
18
|
+
HELP = %w[--help -h].freeze
|
|
19
|
+
private_constant :OPTIONS, :HELP
|
|
20
|
+
|
|
21
|
+
# Runs the command. A manifest that fails is reported on `err`, and the
|
|
22
|
+
# others still run.
|
|
23
|
+
#
|
|
24
|
+
# @param arguments [Array<String>] the options and paths
|
|
25
|
+
# @param out [IO] where the help goes
|
|
26
|
+
# @param err [IO] where the errors go
|
|
27
|
+
# @return [Integer] 0 when every manifest succeeded, 1 when one failed or a path
|
|
28
|
+
# designates no manifest, 2 for a usage error
|
|
29
|
+
def self.run(arguments, out: $stdout, err: $stderr)
|
|
30
|
+
options, paths = arguments.partition { |argument| argument.start_with?("-") }
|
|
31
|
+
problem = problem(options, paths)
|
|
32
|
+
return usage(err, problem) if problem
|
|
33
|
+
return help(out) if options.intersect?(HELP)
|
|
34
|
+
|
|
35
|
+
succeeded?(paths, options.include?("--check"), err) ? 0 : 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# What is wrong with the arguments, if anything.
|
|
39
|
+
def self.problem(options, paths)
|
|
40
|
+
unknown = options - OPTIONS
|
|
41
|
+
return "unknown option #{unknown.first}" unless unknown.empty?
|
|
42
|
+
|
|
43
|
+
"no path given" if paths.empty? && !options.intersect?(HELP)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Processes the manifests of every path, each once (a manifest is known
|
|
47
|
+
# by its name and the real path of its directory, as its test file is);
|
|
48
|
+
# true when all succeeded.
|
|
49
|
+
def self.succeeded?(paths, check, err)
|
|
50
|
+
found = paths.map { |path| manifests(path, err) }
|
|
51
|
+
unique = found.compact.flatten.uniq do |path|
|
|
52
|
+
::File.join(::File.realpath(::File.dirname(path)), ::File.basename(path))
|
|
53
|
+
end
|
|
54
|
+
unique.map { |path| process(path, check, err) }.all? && !found.include?(nil)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The manifests a path designates: the path itself, or the manifests in
|
|
58
|
+
# a directory and its subdirectories, in order. Hidden files and
|
|
59
|
+
# directories, and symbolic links to directories, are left out. Nil,
|
|
60
|
+
# after a message, when there is none.
|
|
61
|
+
def self.manifests(path, err)
|
|
62
|
+
return [path] if ::File.file?(path)
|
|
63
|
+
|
|
64
|
+
found = ::File.directory?(path) ? search(path) : []
|
|
65
|
+
return found unless found.empty?
|
|
66
|
+
|
|
67
|
+
report(err, path, ::File.exist?(path) ? "no manifest found" : "no such file or directory")
|
|
68
|
+
nil
|
|
69
|
+
rescue ::SystemCallError => e
|
|
70
|
+
report(err, path, e.message)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The regular files named `*_brutal.yaml` in a directory and its
|
|
75
|
+
# subdirectories; a directory that cannot be read is an error.
|
|
76
|
+
def self.search(directory)
|
|
77
|
+
::Dir.children(directory).sort.flat_map do |name|
|
|
78
|
+
path = ::File.join(directory, name)
|
|
79
|
+
if name.start_with?(".") then []
|
|
80
|
+
elsif ::File.directory?(path) then ::File.symlink?(path) ? [] : search(path)
|
|
81
|
+
else name.end_with?(Manifest::SUFFIX) && ::File.file?(path) ? [path] : []
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Generates the test file of a manifest, then writes it, or compares it
|
|
87
|
+
# with the file in place. A file brutal cannot replace is refused before
|
|
88
|
+
# the code runs.
|
|
89
|
+
def self.process(path, check, err)
|
|
90
|
+
Output.check(Output.target(path))
|
|
91
|
+
return current?(Brutal.generate(path), path, err) if check
|
|
92
|
+
|
|
93
|
+
Brutal.write(Brutal.generate(path))
|
|
94
|
+
true
|
|
95
|
+
rescue Error, ::SystemCallError => e
|
|
96
|
+
report(err, path, e.message)
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Whether the test file in place is the one brutal would write.
|
|
101
|
+
def self.current?(test, path, err)
|
|
102
|
+
return true if Output.current?(test.path, test.source)
|
|
103
|
+
|
|
104
|
+
report(err, path, "#{text(test.path)} is #{::File.exist?(test.path) ? "not up to date" : "missing"}")
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Reports why a path failed, as `brutal: PATH: REASON`.
|
|
109
|
+
def self.report(err, path, reason)
|
|
110
|
+
err.puts("brutal: #{text(path)}: #{text(reason)}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A string relabelled as UTF-8, its bytes unchanged: the file system and
|
|
114
|
+
# the locale name a path in an encoding of their own, in which it could
|
|
115
|
+
# not join the text of a manifest.
|
|
116
|
+
def self.text(string)
|
|
117
|
+
string.dup.force_encoding(::Encoding::UTF_8)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Prints the help.
|
|
121
|
+
def self.help(out)
|
|
122
|
+
out.puts(USAGE)
|
|
123
|
+
0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Prints a usage error, then the help.
|
|
127
|
+
def self.usage(err, message)
|
|
128
|
+
err.puts("brutal: #{message}", "", USAGE)
|
|
129
|
+
2
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private_class_method :problem, :succeeded?, :manifests, :search, :process, :current?, :report, :text, :help, :usage
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brutal
|
|
4
|
+
# Writes an observed value as a Ruby literal, the same whatever the version
|
|
5
|
+
# of Ruby and the locale: `nil`, `true`, `false`, an `Integer`, a finite
|
|
6
|
+
# `Float`, a `String` in UTF-8, in binary or with ASCII characters only, a
|
|
7
|
+
# `Symbol`, and an `Array` or a `Hash` of these. Anything else is refused,
|
|
8
|
+
# never written as its `inspect`.
|
|
9
|
+
module Literal
|
|
10
|
+
SYMBOL = /\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/
|
|
11
|
+
private_constant :SYMBOL
|
|
12
|
+
|
|
13
|
+
# @param value [Object] a value made of the classes above only
|
|
14
|
+
# @return [String] Ruby source code that evaluates to a value `eql?` to it
|
|
15
|
+
# @raise [Brutal::Error] when the value cannot be written as a literal
|
|
16
|
+
def self.dump(value)
|
|
17
|
+
case value
|
|
18
|
+
when nil, true, false, ::Integer then value.inspect
|
|
19
|
+
when ::Float then float(value)
|
|
20
|
+
when ::String then string(value)
|
|
21
|
+
when ::Symbol then symbol(value)
|
|
22
|
+
when ::Array then "[#{value.map { |item| dump(item) }.join(", ")}]"
|
|
23
|
+
when ::Hash then pairs(value)
|
|
24
|
+
else raise Error, "is an instance of #{value.class}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A finite Float, as Float#to_s writes it: the shortest exact form.
|
|
29
|
+
def self.float(value)
|
|
30
|
+
raise Error, "is the Float #{value}" unless value.finite?
|
|
31
|
+
|
|
32
|
+
value.to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A String with its escapes from String#dump, which ignores the locale;
|
|
36
|
+
# `.b` keeps a binary string binary.
|
|
37
|
+
def self.string(value)
|
|
38
|
+
return value.dump if value.encoding == ::Encoding::UTF_8 || value.ascii_only?
|
|
39
|
+
return "#{value.dump}.b" if value.encoding == ::Encoding::BINARY
|
|
40
|
+
|
|
41
|
+
raise Error, "is a String in #{value.encoding}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A Symbol, quoted unless its name is a plain identifier.
|
|
45
|
+
def self.symbol(value)
|
|
46
|
+
name = value.name
|
|
47
|
+
return ":#{name}" if SYMBOL.match?(name)
|
|
48
|
+
return ":#{name.dump}" if name.encoding == ::Encoding::UTF_8 || name.ascii_only?
|
|
49
|
+
|
|
50
|
+
raise Error, "is a Symbol in #{name.encoding}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A Hash, with `=>` for every key.
|
|
54
|
+
def self.pairs(value)
|
|
55
|
+
return "{}" if value.empty?
|
|
56
|
+
|
|
57
|
+
"{#{value.map { |key, item| "#{dump(key)} => #{dump(item)}" }.join(", ")}}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private_class_method :float, :string, :symbol, :pairs
|
|
61
|
+
end
|
|
62
|
+
end
|