brutal 1.6.0.beta3 → 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 -163
- data/lib/brutal/format.rb +0 -11
- data/lib/brutal/manifest/file/name.rb +0 -18
- data/lib/brutal/manifest/file.rb +0 -26
data/lib/brutal/manifest.rb
CHANGED
|
@@ -1,107 +1,148 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
#
|
|
3
|
+
require "psych"
|
|
4
|
+
|
|
5
|
+
module Brutal
|
|
6
|
+
# The sections of a `NAME_brutal.yaml` file, as the text written in it.
|
|
7
|
+
#
|
|
8
|
+
# The YAML file is read as a tree of nodes and never loaded into Ruby
|
|
9
|
+
# objects: `1,2`, `yes` and `~` stay the text `1,2`, `yes` and `~`. Only
|
|
10
|
+
# the line breaks that end a value are dropped. A value of several lines
|
|
11
|
+
# must be a literal block (`|`), which YAML does not fold. Anchors, aliases
|
|
12
|
+
# and tags are refused, as are unknown, duplicate, missing or empty
|
|
13
|
+
# sections.
|
|
14
|
+
#
|
|
15
|
+
# @!attribute [r] path
|
|
16
|
+
# @return [String] the path of the manifest
|
|
17
|
+
# @!attribute [r] header
|
|
18
|
+
# @return [String, nil] code run once, before the first example
|
|
19
|
+
# @!attribute [r] before
|
|
20
|
+
# @return [String, nil] code run before each example
|
|
21
|
+
# @!attribute [r] subject
|
|
22
|
+
# @return [String] the code of each example, with a `%{name}` for each context
|
|
23
|
+
# @!attribute [r] contexts
|
|
24
|
+
# @return [Hash{String => Array<String>}] the code fragments of each context, in the order of the file
|
|
25
|
+
# @!attribute [r] actuals
|
|
26
|
+
# @return [Array<String>] the expressions observed on the subject, with `%{subject}` for its value
|
|
27
|
+
# @!attribute [r] after
|
|
28
|
+
# @return [String, nil] code run after each example
|
|
29
|
+
# @!attribute [r] footer
|
|
30
|
+
# @return [String, nil] code run once, after the last example
|
|
31
|
+
Manifest = ::Data.define(:path, :header, :before, :subject, :contexts, :actuals, :after, :footer)
|
|
32
|
+
|
|
33
|
+
# Reading a manifest.
|
|
7
34
|
class Manifest
|
|
8
|
-
# The
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# The _contexts_ top-level section key.
|
|
12
|
-
CONTEXTS_KEY = "contexts"
|
|
35
|
+
# The end of a manifest's file name.
|
|
36
|
+
SUFFIX = "_brutal.yaml"
|
|
13
37
|
|
|
14
|
-
|
|
15
|
-
|
|
38
|
+
NAME = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*_brutal\.yaml\z/
|
|
39
|
+
BOM = "\xEF\xBB\xBF".b
|
|
40
|
+
CONTEXT = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
41
|
+
SECTIONS = %w[header before subject contexts actuals after footer].freeze
|
|
42
|
+
REQUIRED = %w[subject contexts actuals].freeze
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
BEFORE_KEY = "before"
|
|
44
|
+
private_constant :NAME, :BOM, :CONTEXT, :SECTIONS, :REQUIRED
|
|
19
45
|
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
|
|
46
|
+
# Reads the manifest at `path`.
|
|
47
|
+
#
|
|
48
|
+
# @param path [String]
|
|
49
|
+
# @return [Brutal::Manifest]
|
|
50
|
+
# @raise [Brutal::Error] when the manifest is not valid
|
|
51
|
+
# @raise [SystemCallError] when the file cannot be read
|
|
52
|
+
def self.read(path)
|
|
53
|
+
unless NAME.match?(::File.basename(path).b)
|
|
54
|
+
raise Error, "the file name must be NAME#{SUFFIX}, NAME made of ASCII letters, digits, " \
|
|
55
|
+
"\".\", \"_\" and \"-\", not starting with \".\" or \"-\""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sections = mapping(root(path), "the manifest")
|
|
59
|
+
check(sections)
|
|
60
|
+
new(path:, contexts: contexts(sections.fetch("contexts")),
|
|
61
|
+
actuals: sequence(sections.fetch("actuals"), "actuals"),
|
|
62
|
+
**(SECTIONS - %w[contexts actuals]).to_h { |name| [name.to_sym, optional(sections, name)] })
|
|
63
|
+
end
|
|
25
64
|
|
|
26
|
-
# The
|
|
27
|
-
|
|
65
|
+
# The root node of the only document of the file.
|
|
66
|
+
def self.root(path)
|
|
67
|
+
text = ::File.binread(path).delete_prefix(BOM).force_encoding(::Encoding::UTF_8)
|
|
68
|
+
raise Error, "the file is not valid UTF-8" unless text.valid_encoding?
|
|
28
69
|
|
|
29
|
-
|
|
30
|
-
|
|
70
|
+
documents = ::Psych.parse_stream(text, filename: path).children
|
|
71
|
+
raise Error, "the file must hold one YAML document" unless documents.size == 1
|
|
31
72
|
|
|
32
|
-
|
|
33
|
-
|
|
73
|
+
documents.first.root
|
|
74
|
+
rescue ::Psych::SyntaxError => e
|
|
75
|
+
raise Error, "invalid YAML at line #{e.line}, column #{e.column}: #{e.problem}"
|
|
76
|
+
end
|
|
34
77
|
|
|
35
|
-
#
|
|
36
|
-
|
|
78
|
+
# Refuses unknown and missing sections.
|
|
79
|
+
def self.check(sections)
|
|
80
|
+
unknown = sections.keys - SECTIONS
|
|
81
|
+
raise Error, "unknown section #{unknown.first.dump}" unless unknown.empty?
|
|
37
82
|
|
|
38
|
-
|
|
39
|
-
|
|
83
|
+
missing = REQUIRED - sections.keys
|
|
84
|
+
raise Error, "the section #{missing.first} is missing" unless missing.empty?
|
|
85
|
+
end
|
|
40
86
|
|
|
41
|
-
#
|
|
42
|
-
|
|
87
|
+
# The fragments of each context, by name.
|
|
88
|
+
def self.contexts(node)
|
|
89
|
+
contexts = mapping(node, "contexts").to_h do |name, list|
|
|
90
|
+
unless CONTEXT.match?(name)
|
|
91
|
+
raise Error, "the context #{name.dump} must be named with letters, digits and _, not starting with a digit"
|
|
92
|
+
end
|
|
43
93
|
|
|
44
|
-
|
|
45
|
-
|
|
94
|
+
[name, sequence(list, "contexts.#{name}")]
|
|
95
|
+
end
|
|
96
|
+
raise Error, "contexts must not be empty" if contexts.empty?
|
|
46
97
|
|
|
47
|
-
|
|
48
|
-
def self.parse_file(pathname)
|
|
49
|
-
load(File.new(pathname).parse)
|
|
98
|
+
contexts.freeze
|
|
50
99
|
end
|
|
51
100
|
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def self.load(params)
|
|
56
|
-
new(
|
|
57
|
-
actuals: params.fetch(ACTUALS_KEY, DEFAULT_ACTUALS),
|
|
58
|
-
contexts: params.fetch(CONTEXTS_KEY, DEFAULT_CONTEXTS),
|
|
59
|
-
header: params.fetch(HEADER_KEY, DEFAULT_HEADER),
|
|
60
|
-
before: params.fetch(BEFORE_KEY, DEFAULT_BEFORE),
|
|
61
|
-
subject: params.fetch(SUBJECT_KEY),
|
|
62
|
-
after: params.fetch(AFTER_KEY, DEFAULT_AFTER),
|
|
63
|
-
footer: params.fetch(FOOTER_KEY, DEFAULT_FOOTER)
|
|
64
|
-
)
|
|
101
|
+
# The text of an optional section, or nil.
|
|
102
|
+
def self.optional(sections, name)
|
|
103
|
+
scalar(sections[name], name) if sections.key?(name)
|
|
65
104
|
end
|
|
66
105
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
# A mapping, as a hash of its keys' text to its value nodes.
|
|
107
|
+
def self.mapping(node, where)
|
|
108
|
+
plain(node, ::Psych::Nodes::Mapping, where, "a mapping")
|
|
109
|
+
node.children.each_slice(2).with_object({}) do |(key, value), hash|
|
|
110
|
+
name = scalar(key, "a key of #{where}")
|
|
111
|
+
raise Error, "#{where} has the key #{name.dump} twice" if hash.key?(name)
|
|
72
112
|
|
|
73
|
-
|
|
74
|
-
|
|
113
|
+
hash[name] = value
|
|
114
|
+
end
|
|
115
|
+
end
|
|
75
116
|
|
|
76
|
-
#
|
|
77
|
-
|
|
117
|
+
# A non-empty sequence, as the text of its items.
|
|
118
|
+
def self.sequence(node, where)
|
|
119
|
+
plain(node, ::Psych::Nodes::Sequence, where, "a list")
|
|
120
|
+
raise Error, "#{where} must not be empty" if node.children.empty?
|
|
78
121
|
|
|
79
|
-
|
|
80
|
-
|
|
122
|
+
node.children.each_with_index.map { |item, index| scalar(item, "#{where}[#{index}]") }.freeze
|
|
123
|
+
end
|
|
81
124
|
|
|
82
|
-
#
|
|
83
|
-
|
|
125
|
+
# The text of a scalar, without the line breaks that end it. Only a
|
|
126
|
+
# literal block may span several lines: YAML folds the others.
|
|
127
|
+
def self.scalar(node, where)
|
|
128
|
+
plain(node, ::Psych::Nodes::Scalar, where, "text")
|
|
129
|
+
if node.style != ::Psych::Nodes::Scalar::LITERAL && node.end_line > node.start_line
|
|
130
|
+
raise Error, "#{where} spans several lines: write it as a literal block (|)"
|
|
131
|
+
end
|
|
84
132
|
|
|
85
|
-
|
|
86
|
-
|
|
133
|
+
text = node.value.sub(/\n+\z/, "")
|
|
134
|
+
raise Error, "#{where} must not be empty" if text.strip.empty?
|
|
87
135
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
raise ::TypeError, actuals.inspect unless actuals.is_a?(::Array)
|
|
91
|
-
raise ::TypeError, contexts.inspect unless contexts.is_a?(::Hash)
|
|
92
|
-
raise ::TypeError, header.inspect unless header.is_a?(::String)
|
|
93
|
-
raise ::TypeError, before.inspect unless before.is_a?(::String)
|
|
94
|
-
raise ::TypeError, subject.inspect unless subject.is_a?(::String)
|
|
95
|
-
raise ::TypeError, after.inspect unless after.is_a?(::String)
|
|
96
|
-
raise ::TypeError, footer.inspect unless footer.is_a?(::String)
|
|
136
|
+
text.freeze
|
|
137
|
+
end
|
|
97
138
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
@after = after
|
|
104
|
-
@footer = footer
|
|
139
|
+
# Checks the type of a node, and that it has no alias, anchor or tag.
|
|
140
|
+
def self.plain(node, type, where, what)
|
|
141
|
+
raise Error, "#{where} must not be an alias" if node.instance_of?(::Psych::Nodes::Alias)
|
|
142
|
+
raise Error, "#{where} must be #{what}" unless node.instance_of?(type)
|
|
143
|
+
raise Error, "#{where} must not have an anchor or a tag" if node.anchor || node.tag
|
|
105
144
|
end
|
|
145
|
+
|
|
146
|
+
private_class_method :root, :check, :contexts, :optional, :mapping, :sequence, :scalar, :plain
|
|
106
147
|
end
|
|
107
148
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brutal
|
|
4
|
+
# Where a test file goes, and how it is written: next to its manifest,
|
|
5
|
+
# whole or not at all, and only over a file that brutal wrote.
|
|
6
|
+
module Output
|
|
7
|
+
CREATE = ::File::WRONLY | ::File::CREAT | ::File::EXCL
|
|
8
|
+
private_constant :CREATE
|
|
9
|
+
|
|
10
|
+
# @param manifest_path [String] the path of a `NAME_brutal.yaml` file
|
|
11
|
+
# @return [String] the path of `test_NAME.rb`, in the same directory, spelled like the manifest's path
|
|
12
|
+
def self.target(manifest_path)
|
|
13
|
+
name = ::File.basename(manifest_path)
|
|
14
|
+
"#{manifest_path.delete_suffix(name)}test_#{name.delete_suffix(Manifest::SUFFIX)}.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param path [String]
|
|
18
|
+
# @param source [String]
|
|
19
|
+
# @return [Boolean] whether the file at `path` is a regular file holding exactly `source`
|
|
20
|
+
def self.current?(path, source)
|
|
21
|
+
::File.file?(path) && !::File.symlink?(path) && ::File.binread(path) == source.b
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Refuses to replace anything but a file that brutal wrote.
|
|
25
|
+
#
|
|
26
|
+
# @param path [String]
|
|
27
|
+
# @return [void]
|
|
28
|
+
# @raise [Brutal::Error] when `path` is a symbolic link, is not a regular file, or was not written by brutal
|
|
29
|
+
def self.check(path)
|
|
30
|
+
raise Error, "#{path} is a symbolic link" if ::File.symlink?(path)
|
|
31
|
+
return unless ::File.exist?(path)
|
|
32
|
+
raise Error, "#{path} is not a regular file" unless ::File.file?(path)
|
|
33
|
+
|
|
34
|
+
first = ::File.open(path, "rb") { |file| file.read(Program::MARK.bytesize) }
|
|
35
|
+
return if first == Program::MARK.b
|
|
36
|
+
|
|
37
|
+
raise Error, "#{path} was not written by brutal 2 (delete it once if brutal 1 wrote it)"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Writes the source to a temporary file in the same directory, with the
|
|
41
|
+
# permissions of the file it replaces (never its setuid, setgid or sticky
|
|
42
|
+
# bits), hands it to the block, then renames it
|
|
43
|
+
# over `path`: a reader sees the old file or the new one, never a part of
|
|
44
|
+
# either. An unchanged file is left untouched, and handed to the block.
|
|
45
|
+
#
|
|
46
|
+
# @param path [String]
|
|
47
|
+
# @param source [String]
|
|
48
|
+
# @yieldparam file [String] the path of the temporary file, or of the
|
|
49
|
+
# unchanged file; the block refuses it by raising
|
|
50
|
+
# @return [void]
|
|
51
|
+
# @raise [Brutal::Error] when `path` cannot be replaced (see {check})
|
|
52
|
+
# @raise [SystemCallError] when the file cannot be written
|
|
53
|
+
def self.write(path, source, &)
|
|
54
|
+
check(path)
|
|
55
|
+
if current?(path, source)
|
|
56
|
+
yield path if block_given?
|
|
57
|
+
else
|
|
58
|
+
replace(path, source, &)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Writes the source to a temporary file, then installs it.
|
|
63
|
+
def self.replace(path, source, &)
|
|
64
|
+
temporary = ::File.join(::File.dirname(path), ".#{::File.basename(path)}.#{::Process.pid}.tmp")
|
|
65
|
+
create(temporary, source, (::File.stat(path).mode & 0o777 if ::File.exist?(path)))
|
|
66
|
+
install(temporary, path, &)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Creates the temporary file, which must not exist yet: a file left by
|
|
70
|
+
# another process is an error, and is not removed.
|
|
71
|
+
def self.create(temporary, source, mode)
|
|
72
|
+
::File.open(temporary, CREATE, 0o666, binmode: true) do |file|
|
|
73
|
+
file.chmod(mode) if mode
|
|
74
|
+
file.write(source)
|
|
75
|
+
file.fsync
|
|
76
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
77
|
+
::File.unlink(temporary)
|
|
78
|
+
raise
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Hands the temporary file to the block, then renames it over the path;
|
|
83
|
+
# removes it if either fails.
|
|
84
|
+
def self.install(temporary, path)
|
|
85
|
+
yield temporary if block_given?
|
|
86
|
+
::File.rename(temporary, path)
|
|
87
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
88
|
+
::File.unlink(temporary)
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private_class_method :replace, :create, :install
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brutal
|
|
4
|
+
# The test a manifest describes, as Ruby source code, in two forms with the
|
|
5
|
+
# same lines: one that records the observed values, and the test file,
|
|
6
|
+
# which checks them.
|
|
7
|
+
#
|
|
8
|
+
# `%{name}` in the subject is replaced by each fragment of the context
|
|
9
|
+
# `name`, for every combination of the contexts, and `%{subject}` in an
|
|
10
|
+
# actual by `actual`, the local variable that holds the subject's value.
|
|
11
|
+
# Nothing else is interpreted: a `%` is a `%`.
|
|
12
|
+
class Program
|
|
13
|
+
# The first line of every test file, without the name of the manifest.
|
|
14
|
+
MARK = "# Generated by brutal from "
|
|
15
|
+
|
|
16
|
+
PLACEHOLDER = /%\{([A-Za-z_][A-Za-z0-9_]*)\}/
|
|
17
|
+
SEPARATOR = "# #{"-" * 78}".freeze
|
|
18
|
+
SECTIONS = %i[header before after footer].freeze
|
|
19
|
+
|
|
20
|
+
private_constant :PLACEHOLDER, :SEPARATOR, :SECTIONS
|
|
21
|
+
|
|
22
|
+
# @return [Array<Hash{String => String}>] the fragment of each context, for each example
|
|
23
|
+
attr_reader :cases
|
|
24
|
+
|
|
25
|
+
# @param manifest [Brutal::Manifest]
|
|
26
|
+
# @raise [Brutal::Error] when a placeholder or a context is unknown or
|
|
27
|
+
# unused, or a section, a subject or an actual is not complete code on
|
|
28
|
+
# its own
|
|
29
|
+
def initialize(manifest)
|
|
30
|
+
@manifest = manifest
|
|
31
|
+
check
|
|
32
|
+
names = manifest.contexts.keys
|
|
33
|
+
first, *rest = manifest.contexts.values
|
|
34
|
+
@cases = first.product(*rest).map { |fragments| names.zip(fragments).to_h.freeze }.freeze
|
|
35
|
+
parse
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Integer] the number of observed values: one per example and actual
|
|
40
|
+
def size
|
|
41
|
+
cases.size * @manifest.actuals.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The program that records the observed values: `$__brutal__` receives
|
|
45
|
+
# `enter` with the index of each example (`cases.size` for the footer),
|
|
46
|
+
# and `observe` with each observed value.
|
|
47
|
+
#
|
|
48
|
+
# @return [String]
|
|
49
|
+
def record
|
|
50
|
+
source(separator: ->(index) { "$__brutal__.enter(#{index})" }, assertion: ->(_index) { "$__brutal__.observe((" })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The test file: each observed value must be `eql?` to its literal.
|
|
54
|
+
#
|
|
55
|
+
# @param literals [Array<String>] the literal of each observed value, in order
|
|
56
|
+
# @return [String]
|
|
57
|
+
def test(literals)
|
|
58
|
+
assertion = ->(index) { "::Kernel.raise #{label(index).dump} unless (#{literals.fetch(index)}).eql?((" }
|
|
59
|
+
source(separator: ->(_index) { SEPARATOR }, assertion:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Where an observed value comes from, for messages.
|
|
63
|
+
#
|
|
64
|
+
# @param index [Integer] the index of an observed value
|
|
65
|
+
# @return [String] the fragments of its example, and its actual
|
|
66
|
+
def label(index)
|
|
67
|
+
example, actual = index.divmod(@manifest.actuals.size)
|
|
68
|
+
"#{phase(example)}: #{observation(@manifest.actuals.fetch(actual))}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Names a part of the program, for messages.
|
|
72
|
+
#
|
|
73
|
+
# @param index [Integer, nil] the index of an example, nil for the header, `cases.size` for the footer
|
|
74
|
+
# @return [String]
|
|
75
|
+
def phase(index)
|
|
76
|
+
return "the header" if index.nil?
|
|
77
|
+
return "the footer" if index == cases.size
|
|
78
|
+
|
|
79
|
+
cases.fetch(index).map { |name, fragment| "#{name} = #{fragment}" }.join(", ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Refuses a placeholder without context, a context without placeholder,
|
|
85
|
+
# and a placeholder where brutal does not replace it.
|
|
86
|
+
def check
|
|
87
|
+
names = @manifest.contexts.keys
|
|
88
|
+
used = placeholders(@manifest.subject)
|
|
89
|
+
refuse(used - names, "the subject uses %{NAME}, which is not a context")
|
|
90
|
+
refuse(names - used, "the context NAME is not used in the subject")
|
|
91
|
+
refuse(@manifest.actuals.flat_map { |actual| placeholders(actual) } - ["subject"],
|
|
92
|
+
"an actual can only use %{subject}, not %{NAME}")
|
|
93
|
+
SECTIONS.each do |section|
|
|
94
|
+
refuse(placeholders(@manifest.public_send(section).to_s), "the #{section} uses %{NAME}, which is not replaced")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Refuses code that is not complete on its own: each part must parse
|
|
99
|
+
# alone, so that no part can change how the others parse, or hide a
|
|
100
|
+
# check.
|
|
101
|
+
def parse
|
|
102
|
+
SECTIONS.each { |section| alone(@manifest.public_send(section), "the #{section}") }
|
|
103
|
+
cases.each_with_index { |fragments, index| alone(subject(fragments), "the subject, for #{phase(index)},") }
|
|
104
|
+
@manifest.actuals.each { |actual| alone(observation(actual), "the actual #{actual.dump}") }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Compiles a part of the code alone, warnings off: nothing runs. The part
|
|
108
|
+
# starts on the third line, as the header does in the test file, so that a
|
|
109
|
+
# magic comment of the first line (`# encoding:`) is ignored as it is
|
|
110
|
+
# there; the line numbers of the part are kept.
|
|
111
|
+
def alone(code, part)
|
|
112
|
+
return if code.nil?
|
|
113
|
+
raise Error, "#{part} holds an end of file (__END__, NUL, ^D or ^Z)" if code.match?(/^__END__\r?$|[\0\x04\x1a]/)
|
|
114
|
+
|
|
115
|
+
verbose = $VERBOSE
|
|
116
|
+
$VERBOSE = nil
|
|
117
|
+
::RubyVM::InstructionSequence.compile("\n\n#{code}", "<compiled>", "<compiled>", -1)
|
|
118
|
+
rescue ::SyntaxError => e
|
|
119
|
+
raise Error, "#{part} is not complete code on its own: #{e.message.sub("<compiled>:", "line ")}"
|
|
120
|
+
ensure
|
|
121
|
+
$VERBOSE = verbose unless verbose.nil?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Raises the message, with the first name for NAME, if there is one.
|
|
125
|
+
def refuse(names, message)
|
|
126
|
+
raise Error, message.sub("NAME", names.first) unless names.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The names of the placeholders of a template.
|
|
130
|
+
def placeholders(template)
|
|
131
|
+
template.scan(PLACEHOLDER).flatten.uniq
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The program; `separator` and `assertion` give the lines that differ
|
|
135
|
+
# between its two forms, from the index of an example and of an observed
|
|
136
|
+
# value.
|
|
137
|
+
def source(separator:, assertion:)
|
|
138
|
+
[
|
|
139
|
+
"#{MARK}#{::File.basename(@manifest.path)}: edit the manifest, then run brutal again.", "",
|
|
140
|
+
*([@manifest.header, ""] if @manifest.header),
|
|
141
|
+
*cases.each_with_index.flat_map { |fragments, index| example(fragments, index, separator, assertion) },
|
|
142
|
+
*([separator.call(cases.size), "", @manifest.footer, ""] if @manifest.footer)
|
|
143
|
+
].join("\n")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The lines of one example.
|
|
147
|
+
def example(fragments, index, separator, assertion)
|
|
148
|
+
[
|
|
149
|
+
separator.call(index), "",
|
|
150
|
+
*([@manifest.before, ""] if @manifest.before),
|
|
151
|
+
"actual = begin", nest(subject(fragments)), "end", "",
|
|
152
|
+
*assertions(index, assertion),
|
|
153
|
+
*(["", @manifest.after] if @manifest.after),
|
|
154
|
+
""
|
|
155
|
+
]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The lines of the assertions of one example.
|
|
159
|
+
def assertions(index, assertion)
|
|
160
|
+
@manifest.actuals.each_with_index.flat_map do |actual, position|
|
|
161
|
+
[assertion.call((index * @manifest.actuals.size) + position), nest(observation(actual)), "))"]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The subject, with the fragment of each context for its placeholder.
|
|
166
|
+
def subject(fragments)
|
|
167
|
+
@manifest.subject.gsub(PLACEHOLDER) { fragments.fetch(::Regexp.last_match(1)) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# An actual, with `actual` for `%{subject}`.
|
|
171
|
+
def observation(actual)
|
|
172
|
+
actual.gsub(PLACEHOLDER, "actual")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Indents a code fragment of one line. A fragment of several lines is
|
|
176
|
+
# left as written: indenting it would change a multi-line string or a
|
|
177
|
+
# heredoc.
|
|
178
|
+
def nest(code)
|
|
179
|
+
code.include?("\n") ? code : " #{code}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|