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.
@@ -1,107 +1,148 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("manifest", "file")
4
-
5
- class Brutal
6
- # Brutal YAML manifest file parser.
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 _actuals_ top-level section key.
9
- ACTUALS_KEY = "actuals"
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
- # The _header_ top-level section key.
15
- HEADER_KEY = "header"
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
- # The _before_ top-level section key.
18
- BEFORE_KEY = "before"
44
+ private_constant :NAME, :BOM, :CONTEXT, :SECTIONS, :REQUIRED
19
45
 
20
- # The _subject_ top-level section key.
21
- SUBJECT_KEY = "subject"
22
-
23
- # The _after_ top-level section key.
24
- AFTER_KEY = "after"
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 _footer_ top-level section key.
27
- FOOTER_KEY = "footer"
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
- # Default _actuals_ collection.
30
- DEFAULT_ACTUALS = [].freeze
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
- # Default _contexts_ collection.
33
- DEFAULT_CONTEXTS = {}.freeze
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
- # Default _header_ code to evaluate.
36
- DEFAULT_HEADER = ""
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
- # Default _before_ code to evaluate.
39
- DEFAULT_BEFORE = ""
83
+ missing = REQUIRED - sections.keys
84
+ raise Error, "the section #{missing.first} is missing" unless missing.empty?
85
+ end
40
86
 
41
- # Default _after_ code to evaluate.
42
- DEFAULT_AFTER = ""
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
- # Default _footer_ code to evaluate.
45
- DEFAULT_FOOTER = ""
94
+ [name, sequence(list, "contexts.#{name}")]
95
+ end
96
+ raise Error, "contexts must not be empty" if contexts.empty?
46
97
 
47
- # Parse a file at `pathname`. Returns the YAML manifest instance.
48
- def self.parse_file(pathname)
49
- load(File.new(pathname).parse)
98
+ contexts.freeze
50
99
  end
51
100
 
52
- # Load the configuration parameters.
53
- #
54
- # @param params [Hash] Receive the 7 top-level section parameters.
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
- # Specifies templates to challenge evaluated subjects & get results.
68
- attr_reader :actuals
69
-
70
- # Specifies a list of variables to populate the subject's template.
71
- attr_reader :contexts
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
- # Specifies a block of lines to be executed once before all examples.
74
- attr_reader :header
113
+ hash[name] = value
114
+ end
115
+ end
75
116
 
76
- # Specifies a block of lines to be executed before each example.
77
- attr_reader :before
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
- # Specifies the template of the code to be declined across contexts.
80
- attr_reader :subject
122
+ node.children.each_with_index.map { |item, index| scalar(item, "#{where}[#{index}]") }.freeze
123
+ end
81
124
 
82
- # Specifies a block of lines to be executed after each example.
83
- attr_reader :after
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
- # Specifies a block of lines to be executed once after all examples.
86
- attr_reader :footer
133
+ text = node.value.sub(/\n+\z/, "")
134
+ raise Error, "#{where} must not be empty" if text.strip.empty?
87
135
 
88
- # Initialize a new configuration.
89
- def initialize(actuals:, contexts:, header:, before:, subject:, after:, footer:)
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
- @actuals = actuals
99
- @contexts = contexts
100
- @header = header
101
- @before = before
102
- @subject = subject
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