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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f39f334e466f02513f3458e0a3f20ac59a4a3d7bf76f1c0cb7bbe97128c4aae
4
- data.tar.gz: 06ef4476162a2f4570107ee9c70a97320aeeb95ee8f2851e27d07dc7aed86fe3
3
+ metadata.gz: fac7c55010f838339f889a969e3ba78fc7ee40b279b0d426248b0eec5f5f8b0f
4
+ data.tar.gz: 5be3b432b0319f72073043ddb2c8fad5c2cf053da211f80c6a5588942beaf701
5
5
  SHA512:
6
- metadata.gz: 22c03d27d6ab4e183f2469121e502bc0ad17705f90788dcb7b949b983cf70ba6f2e5344fa5db5f17a77a5cf6b66ec8a5c543bc7baf330b853acff70a82f8fdb7
7
- data.tar.gz: 0f65b683c466789227826090f31a0e1ef976bb0d83d73f99549862be928c8643af0b5fac82bf0572db17dc978bc1a666a5dd1b4bcfeb7be5274b836e380a26db
6
+ metadata.gz: b52adf50058f49a60fb97d80f35340a845d489a1547cb9186ff2f6913990a064f23c65b2adf10968dec2fecc91e3f5b8259ab0c02b30a996776d123f85b3887a
7
+ data.tar.gz: 96ebc35d1e1326d99350e389787168e0dae7f65c26c273d943f4f516dac487505ed7e743bd556439cd03825d62bd357da37edd47071cb8b43d8c6f8821e0a596
data/LICENSE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # The MIT License
2
2
 
3
- Copyright (c) 2020-2022 Cyril Kato
3
+ Copyright (c) 2020-2026 Cyril Kato
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,147 +2,225 @@
2
2
 
3
3
  [![Version](https://img.shields.io/github/v/tag/fixrb/brutal?label=Version&logo=github)](https://github.com/fixrb/brutal/tags)
4
4
  [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/fixrb/brutal/main)
5
- [![Ruby](https://github.com/fixrb/brutal/workflows/Ruby/badge.svg?branch=main)](https://github.com/fixrb/brutal/actions?query=workflow%3Aruby+branch%3Amain)
6
- [![RuboCop](https://github.com/fixrb/brutal/workflows/RuboCop/badge.svg?branch=main)](https://github.com/fixrb/brutal/actions?query=workflow%3Arubocop+branch%3Amain)
7
5
  [![License](https://img.shields.io/github/license/fixrb/brutal?label=License&logo=github)](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
- ## Intro
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
- [![I Hate Tests](https://github.com/fixrb/brutal/raw/main/img/rubyhack-2019-ruby3-what-s-missing-by-yukihiro-matsumoto.jpg)](https://www.youtube.com/embed/cmOt9HhszCI?start=1732&end=1736 "I don't like tests. It's not DRY.")
12
+ ![Brutal-Driven Development](https://github.com/fixrb/brutal/raw/main/img/brutal-driven-development.png)
14
13
 
15
- > I don't like tests. It's not DRY.
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
- ## Overview
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
- Let __Brutal__ craft for you in no time a (potentially huge) framework-less vanilla Ruby file describing the actual behavior of your code across as many context combinations as necessary.
18
+ ## Installation
21
19
 
22
- By delegating to __Brutal__ the repetitive (and redundant) task of writing tests, you will be able to focus on your core business: the code itself.
20
+ ```ruby
21
+ # Gemfile
22
+ gem "brutal", require: false
23
+ ```
23
24
 
24
- ![Brutal-Driven Development](https://github.com/fixrb/brutal/raw/main/img/brutal-driven-development.jpg)
25
+ or `gem install brutal` for the command alone.
25
26
 
26
- ## Warning
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
- The _Brutal-Driven Development_ process does not prevent bugs from appearing in the code.
29
+ ## Usage
29
30
 
30
- A generated test suite acts as a _picture of the code's behavior_. Therefore, if the code is wrong, the picture of the code's behavior will also be wrong.
31
+ Given this class:
31
32
 
32
- The mere fact that all expectations are true does not mean that the code behaves as it should.
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
- It is therefore the responsibility of the developer to analyze the generated behavioral pictures to ensure that the code reacts as it is supposed to according to the contexts in which it is evaluated.
46
+ a manifest is a file named `NAME_brutal.yaml`:
35
47
 
36
- ## Installation
48
+ ```yaml
49
+ # test/greeter_brutal.yaml
50
+ header: |
51
+ require_relative "../lib/greeter"
37
52
 
38
- Add this line to your application's Gemfile:
53
+ subject: |
54
+ Greeter.new(%{name})
39
55
 
40
- ```ruby
41
- gem "brutal", ">= 1.6.0.beta4", require: false
42
- ```
56
+ contexts:
57
+ name:
58
+ - '"Alice"'
59
+ - '"world"'
43
60
 
44
- And then execute:
61
+ actuals:
62
+ - "%{subject}.salute"
63
+ - "%{subject}.salute.length"
64
+ ```
45
65
 
46
66
  ```sh
47
- bundle install
67
+ bundle exec brutal test/greeter_brutal.yaml
48
68
  ```
49
69
 
50
- Or install it yourself as:
70
+ Brutal writes `test/test_greeter.rb` next to it:
51
71
 
52
- ```sh
53
- gem install brutal --pre
54
- ```
72
+ ```ruby
73
+ # Generated by brutal from greeter_brutal.yaml: edit the manifest, then run brutal again.
55
74
 
56
- ## Usage
75
+ require_relative "../lib/greeter"
57
76
 
58
- ### YAML manifest
77
+ # ------------------------------------------------------------------------------
59
78
 
60
- __Brutal__ needs configuration files in YAML format to know how to write tests.
61
- Configuration file names are suffixed by `_brutal.yaml` and composed of 7 top-level sections:
79
+ actual = begin
80
+ Greeter.new("Alice")
81
+ end
62
82
 
63
- * `header` - Specifies a block of lines to be executed once before all examples.
64
- * `before` - Specifies a block of lines to be executed before each example.
65
- * `subject` - Specifies the template of the code to be declined across contexts.
66
- * `contexts` - Specifies a list of variables to populate the subject's template.
67
- * `actuals` - Specifies templates to challenge evaluated subjects & get results.
68
- * `after` - Specifies a block of lines to be executed after each example.
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
- ### Command line
90
+ # ------------------------------------------------------------------------------
72
91
 
73
- The `brutal` command comes with several options you can use to customize Brutal's behavior.
92
+ actual = begin
93
+ Greeter.new("world")
94
+ end
74
95
 
75
- For a full list of options, run the `brutal` command with the `--help` flag:
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
- brutal --help
105
+ ruby test/test_greeter.rb
79
106
  ```
80
107
 
81
- ```txt
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
- --format=FORMAT Choose "ruby" (default).
85
- --help Display this help.
86
- --version Display the version.
87
- ```
110
+ ## The manifest
88
111
 
89
- Assuming that in the workspace there is a configuration file named `user_brutal.yaml`, the test suite can be generated via one of these commands:
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
- ```sh
92
- brutal user_brutal.yaml
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
- or:
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
- ```sh
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 even:
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
- ```sh
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
- This would create a `test_user.rb` file containing the test suite.
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
- Assuming now that in the workspace there are a large number of configuration files named in the `spec/` folder, the complete test suite could be generated recursively via this command:
132
+ ## How the code runs
110
133
 
111
- ```sh
112
- brutal spec/
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
- This would create one test file per configuration file matching `./spec/**/*_brutal.yaml` in to `./spec/**/test_*.rb`.
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
- ### Some examples
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
- <https://github.com/fixrb/brutal/tree/main/examples/>
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
- ## Rake integration example
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
- Generated test suite files could be matched as follows:
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 = "**/test_*.rb"
183
+ t.pattern = "test/**/test_*.rb"
128
184
  end
129
185
  ```
130
186
 
131
- ## Contact
187
+ In CI, run `bundle exec brutal --check test` before the tests.
132
188
 
133
- * Source code: https://github.com/fixrb/brutal
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
- __Brutal__ follows [Semantic Versioning 2.0](https://semver.org/).
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
- ![Sashité logo](https://github.com/fixrb/brutal/raw/main/img/sponsor/dark/en/sashite.png#gh-dark-mode-only "Sashité")
148
- ![Sashité logo](https://github.com/fixrb/brutal/raw/main/img/sponsor/light/en/sashite.png#gh-light-mode-only "Sashité")
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
- formats.each do |format|
11
- generator = Brutal.new(format)
12
-
13
- pathnames.each do |pathname|
14
- Dir.chdir(pathname.dirname) do
15
- generator.call(pathname.basename)
16
- end
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