yard_example_runner 0.1.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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardExampleRunner
4
+ class Example < ::Minitest::Spec
5
+ # Isolates constant definitions introduced during example evaluation
6
+ #
7
+ # Resolves the top-level class or module for a YARD definition path,
8
+ # snapshots the constants on both +Object+ and that scope, yields control
9
+ # to the caller, then removes any constants that were added during the
10
+ # block. This prevents one example's constant definitions from leaking
11
+ # into subsequent examples.
12
+ #
13
+ # @example
14
+ # ConstantSandbox.new('MyClass#foo').isolate do |scope|
15
+ # # evaluate code that may define constants…
16
+ # end
17
+ # # any constants defined inside the block are now removed
18
+ #
19
+ class ConstantSandbox
20
+ # Creates a sandbox for the given YARD definition path
21
+ #
22
+ # @param definition [String] a YARD path such as +"MyClass#method"+ or
23
+ # +"MyClass.method"+
24
+ #
25
+ # @example
26
+ # ConstantSandbox.new('MyClass#foo')
27
+ #
28
+ # @api private
29
+ #
30
+ def initialize(definition)
31
+ @scope = resolve_scope(definition)
32
+ end
33
+
34
+ # Snapshots constants, yields the resolved scope, then cleans up
35
+ #
36
+ # Any constants added to +Object+ or the resolved scope during the
37
+ # block are removed when the block returns (or raises), with one
38
+ # exception: constants added to +Object+ whose source file was newly
39
+ # loaded via +require+ during the block are preserved. This prevents
40
+ # one example's constant definitions from leaking into subsequent
41
+ # examples while still allowing +require+ calls to have their normal
42
+ # lasting effect (re-requiring a cached file would be a no-op, so
43
+ # stripping those constants would cause +NameError+ in later examples).
44
+ #
45
+ # The resolved scope is yielded so that callers can use it as the
46
+ # evaluation binding.
47
+ #
48
+ # @yield [scope] gives the resolved class/module (or +nil+) to the block
49
+ # @yieldparam scope [Class, Module, nil] the resolved scope constant
50
+ #
51
+ # @return [void]
52
+ #
53
+ # @example
54
+ # sandbox.isolate { |scope| scope.class_eval(code) }
55
+ #
56
+ # @api private
57
+ #
58
+ def isolate
59
+ global_before = Object.constants
60
+ scope_before = @scope.respond_to?(:constants) ? @scope.constants : nil
61
+ loaded_before = $LOADED_FEATURES.dup
62
+
63
+ yield @scope
64
+ ensure
65
+ loaded_during = $LOADED_FEATURES - loaded_before
66
+ clear_extra_constants(Object, global_before, skip_if_loaded_by: loaded_during)
67
+ clear_extra_constants(@scope, scope_before) if scope_before
68
+ end
69
+
70
+ private
71
+
72
+ # Resolves the top-level class or module constant for a YARD definition path
73
+ #
74
+ # Extracts the leading constant name from +definition+ (the portion before
75
+ # the first +#+ or +.+ separator), then returns the corresponding constant
76
+ # from +Object+ if it exists. Returns +nil+ if the definition does not start
77
+ # with a constant name or if the constant is not currently defined.
78
+ #
79
+ # @param definition [String] a YARD path such as +"MyClass#method"+
80
+ #
81
+ # @return [Class, Module, nil] the resolved constant, or +nil+
82
+ #
83
+ # @example
84
+ # resolve_scope('MyClass#foo') # => MyClass
85
+ #
86
+ # @api private
87
+ #
88
+ def resolve_scope(definition)
89
+ name = definition.split(/#|\./).first
90
+ Object.const_get(name) if name&.match?(/\A[A-Z]/) && Object.const_defined?(name)
91
+ end
92
+
93
+ # Removes constants from +scope+ that were not present in +before+
94
+ #
95
+ # When +skip_if_loaded_by+ is non-empty, any constant on +scope+ whose
96
+ # source file (as reported by +Module#const_source_location+) appears in
97
+ # +skip_if_loaded_by+ is preserved rather than removed. This is used to
98
+ # retain constants introduced by +require+ calls during example evaluation.
99
+ #
100
+ # @param scope [Module] the scope to clean up
101
+ # @param before [Array<Symbol>] the constant names present before evaluation
102
+ # @param skip_if_loaded_by [Array<String>] absolute paths of files newly
103
+ # loaded during evaluation; constants defined in these files are kept
104
+ #
105
+ # @return [void]
106
+ #
107
+ # @example
108
+ # clear_extra_constants(Object, before_constants)
109
+ #
110
+ # @api private
111
+ #
112
+ def clear_extra_constants(scope, before, skip_if_loaded_by: [])
113
+ (scope.constants - before).each do |constant|
114
+ if skip_if_loaded_by.any?
115
+ source_file, = scope.const_source_location(constant.to_s)
116
+ next if source_file && skip_if_loaded_by.include?(source_file)
117
+ end
118
+ scope.__send__(:remove_const, constant)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardExampleRunner
4
+ class Example < ::Minitest::Spec
5
+ # Manages binding creation and code evaluation for example expressions
6
+ #
7
+ # Each {Evaluator} is constructed with a fallback binding (whose +self+ is
8
+ # the +Minitest::Spec+ instance, providing access to any methods included
9
+ # on {Example} such as +RSpec::Matchers+) and a snapshot of instance
10
+ # variables from the spec instance (set by +before+ hooks). These are used
11
+ # to build per-scope evaluation contexts that mirror the documented class's
12
+ # namespace.
13
+ #
14
+ # @see Example
15
+ #
16
+ class Evaluator
17
+ # Creates a new evaluator
18
+ #
19
+ # @param fallback_binding [Binding] a binding whose +self+ is the spec
20
+ # instance, used when the expression is not scoped to a specific class
21
+ #
22
+ # @param instance_variables [Hash{Symbol => Object}] a snapshot of instance
23
+ # variable names to values from the spec instance, transplanted into
24
+ # class-scoped bindings so that hook-set state is accessible
25
+ #
26
+ # @example
27
+ # Evaluator.new(fallback_binding: binding, instance_variables: {})
28
+ #
29
+ # @api private
30
+ #
31
+ def initialize(fallback_binding:, instance_variables:)
32
+ @fallback_binding = fallback_binding
33
+ @instance_variables = instance_variables
34
+ end
35
+
36
+ # Evaluates a Ruby code string in the given scope
37
+ #
38
+ # @param code [String] the Ruby expression to evaluate
39
+ # @param bind [Class, nil] the class scope to evaluate in, or +nil+ for
40
+ # the default (fallback) binding
41
+ #
42
+ # @return [Object] the result of evaluating +code+
43
+ #
44
+ # @raise [StandardError] any error raised during evaluation propagates
45
+ #
46
+ # @example
47
+ # evaluator.evaluate('1 + 1', nil) # => 2
48
+ #
49
+ # @api private
50
+ #
51
+ def evaluate(code, bind)
52
+ context(bind).eval(code)
53
+ end
54
+
55
+ # Evaluates a Ruby code string, capturing any +StandardError+ as a value
56
+ #
57
+ # If evaluation raises a +StandardError+, the error itself is returned
58
+ # instead of propagating. This allows callers to compare raised errors
59
+ # against expected error values.
60
+ #
61
+ # @param code [String] the Ruby expression to evaluate
62
+ # @param bind [Class, nil] the class scope to evaluate in
63
+ #
64
+ # @return [Object, StandardError] the result of evaluation, or the error
65
+ #
66
+ # @example
67
+ # evaluator.evaluate_with_assertion('raise "oops"', nil) # => RuntimeError
68
+ #
69
+ # @api private
70
+ #
71
+ def evaluate_with_assertion(code, bind)
72
+ evaluate(code, bind)
73
+ rescue StandardError => e
74
+ e
75
+ end
76
+
77
+ private
78
+
79
+ # Returns or creates the cached evaluation context for the given scope
80
+ #
81
+ # @param bind [Class, nil] the class scope
82
+ #
83
+ # @return [Binding] a binding suitable for evaluating code in +bind+
84
+ #
85
+ # @example
86
+ # context(MyClass) # => Binding
87
+ #
88
+ # @api private
89
+ #
90
+ def context(bind)
91
+ @contexts ||= {}.compare_by_identity
92
+ @contexts[bind] ||= build_context(bind)
93
+ end
94
+
95
+ # Builds an evaluation context for the given scope
96
+ #
97
+ # When +bind+ responds to +class_eval+, a new binding is opened inside
98
+ # that class and instance variables are transplanted into it. Otherwise
99
+ # the fallback binding (whose +self+ is the spec instance) is returned.
100
+ #
101
+ # @param bind [Class, nil] the class scope
102
+ #
103
+ # @return [Binding]
104
+ #
105
+ # @example
106
+ # build_context(MyClass) # => Binding
107
+ #
108
+ # @api private
109
+ #
110
+ def build_context(bind)
111
+ if bind.respond_to?(:class_eval)
112
+ ctx = bind.class_eval('binding', __FILE__, __LINE__)
113
+ transplant_instance_variables(ctx)
114
+ ctx
115
+ else
116
+ @fallback_binding
117
+ end
118
+ end
119
+
120
+ # Copies instance variables into an evaluation binding
121
+ #
122
+ # Sets each instance variable from the snapshot as a local, then
123
+ # assigns it to the corresponding instance variable name via +eval+.
124
+ # This makes hook-set state (e.g. +@flag+) available in class-scoped
125
+ # bindings.
126
+ #
127
+ # @param ctx [Binding] the target binding
128
+ #
129
+ # @return [void]
130
+ #
131
+ # @example
132
+ # transplant_instance_variables(ctx)
133
+ #
134
+ # @api private
135
+ #
136
+ def transplant_instance_variables(ctx)
137
+ @instance_variables.each do |ivar, value|
138
+ local = "__yard_example_runner__#{ivar.to_s.delete('@')}"
139
+ ctx.local_variable_set(local, value)
140
+ ctx.eval("#{ivar} = #{local}")
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'example/constant_sandbox'
4
+ require_relative 'example/evaluator'
5
+ require_relative 'example/comparison'
6
+
7
+ module YardExampleRunner
8
+ # Represents a YARD +@example+ tag as a runnable +Minitest::Spec+
9
+ #
10
+ # Each instance is populated from a single +@example+ tag by
11
+ # {YARD::CLI::RunExamples#build_spec} and holds everything needed to
12
+ # generate and execute a test:
13
+ #
14
+ # - {#definition} — the YARD path of the documented object
15
+ # (e.g. +"MyClass#my_method"+), used as the spec description
16
+ #
17
+ # - {#filepath} — the source location of the documented object
18
+ # (e.g. +"lib/my_class.rb:10"+), prepended to failure backtraces
19
+ #
20
+ # - {#expectations} — the list of {Expectation} objects parsed from the
21
+ # example body, each pairing a Ruby expression to evaluate with an
22
+ # optional expected return value
23
+ #
24
+ # Calling {#generate} dynamically defines and registers an anonymous
25
+ # +Minitest::Spec+ subclass that wraps the expectations in a single +it+
26
+ # block. The registered spec is then picked up by +Minitest.autorun+ when
27
+ # the process exits.
28
+ #
29
+ # Each expectation is evaluated inside a binding scoped to the owning
30
+ # object's class (if one can be resolved), so instance methods and
31
+ # constants are available without qualification, mirroring how the code
32
+ # appears in the source documentation.
33
+ #
34
+ # Evaluation is delegated to an {Evaluator} (binding management and
35
+ # +eval+), constant isolation is handled by {ConstantSandbox}, and
36
+ # assertion / matcher logic lives in the {Comparison} module.
37
+ #
38
+ # @see YARD::CLI::RunExamples
39
+ #
40
+ # @see Expectation
41
+ #
42
+ # @see Evaluator
43
+ #
44
+ # @see ConstantSandbox
45
+ #
46
+ # @see Comparison
47
+ #
48
+ # @api public
49
+ #
50
+ class Example < ::Minitest::Spec
51
+ include Comparison
52
+
53
+ # The YARD namespace path of the documented object (e.g. +Foo#bar+)
54
+ #
55
+ # @example
56
+ # example.definition #=> 'Foo#bar'
57
+ #
58
+ # @return [String] namespace path of example
59
+ #
60
+ # @api public
61
+ attr_accessor :definition
62
+
63
+ # The source location of the documented object (e.g. +app/app.rb:10+)
64
+ #
65
+ # @example
66
+ # example.filepath #=> 'app/app.rb:10'
67
+ #
68
+ # @return [String] filepath to definition
69
+ #
70
+ # @api public
71
+ attr_accessor :filepath
72
+
73
+ # The list of expectations parsed from the example body
74
+ #
75
+ # @example
76
+ # example.expectations #=> []
77
+ #
78
+ # @return [Array<YardExampleRunner::Expectation>] expectations to be verified
79
+ #
80
+ # @api public
81
+ attr_accessor :expectations
82
+
83
+ # Dynamically defines and registers a +Minitest::Spec+ for this example
84
+ #
85
+ # Creates an anonymous subclass of this class and evaluates a +describe+/+it+
86
+ # block inside it. The steps are:
87
+ #
88
+ # 1. Calls +load_helpers+ to require any +example_runner_helper+ files found in
89
+ # +.+, +support/+, +spec/+, or +test/+.
90
+ # 2. Skips silently if {YardExampleRunner.skips} contains a substring that
91
+ # matches {#definition}.
92
+ # 3. Opens a +describe+ block keyed on {#definition}, which becomes the spec
93
+ # group name reported by Minitest.
94
+ # 4. Registers any matching +before+/+after+ hooks via +register_hooks+. These
95
+ # are registered by the user with {YardExampleRunner.before} and
96
+ # {YardExampleRunner.after}.
97
+ # 5. Opens an +it+ block keyed on +name+ (the +@example+ tag title) that calls
98
+ # +run_expectations+ to evaluate every {Expectation} in {#expectations}.
99
+ #
100
+ # The anonymous class and its specs are registered with Minitest's internal list
101
+ # by the +describe+ call. They will be executed when +Minitest.autorun+'s
102
+ # +at_exit+ hook fires.
103
+ #
104
+ # @example
105
+ # example.generate
106
+ #
107
+ # @return [void]
108
+ #
109
+ def generate
110
+ self.class.send(:load_helpers)
111
+ return if skipped?
112
+
113
+ this = self
114
+ Class.new(this.class).class_eval do
115
+ describe this.definition do
116
+ register_hooks(example_name_for(this), YardExampleRunner.hooks, this)
117
+ it(this.name) { run_expectations(this) }
118
+ end
119
+ end
120
+ end
121
+
122
+ protected
123
+
124
+ # Returns +true+ if this example's {#definition} matches any skip pattern
125
+ #
126
+ # Iterates over {YardExampleRunner.skips} and returns +true+ as soon as a
127
+ # pattern is found that is a substring of {#definition}. Used by {#generate}
128
+ # to bail out before registering any +Minitest::Spec+ subclass.
129
+ #
130
+ # @example
131
+ # example.skipped? #=> false
132
+ #
133
+ # @return [Boolean] +true+ if the example should be skipped, +false+ otherwise
134
+ #
135
+ # @api private
136
+ #
137
+ def skipped?
138
+ YardExampleRunner.skips.any? { |skip| definition.include?(skip) }
139
+ end
140
+
141
+ # Evaluates every {Expectation} in the given example
142
+ #
143
+ # Delegates constant isolation to {ConstantSandbox}, which snapshots
144
+ # the current constants on +Object+ and the resolved scope, yields the
145
+ # scope for evaluation, then removes any constants that were introduced
146
+ # during evaluation.
147
+ #
148
+ # @param example [Example] the example whose {Example#expectations} are to be run
149
+ #
150
+ # @example
151
+ # run_expectations(example)
152
+ #
153
+ # @return [void]
154
+ #
155
+ # @api private
156
+ #
157
+ def run_expectations(example)
158
+ ConstantSandbox.new(example.definition).isolate do |scope|
159
+ example.expectations.each { |expectation| run_expectation(example, expectation, scope) }
160
+ end
161
+ end
162
+
163
+ # Evaluates a single {Expectation} within the given scope
164
+ #
165
+ # If the expectation has no expected value ({Expectation#expected} is +nil+),
166
+ # the actual expression is evaluated for its side-effects only via
167
+ # {#evaluate_actual}. Otherwise the actual and expected expressions are both
168
+ # evaluated and compared via {Comparison#verify_actual}.
169
+ #
170
+ # @param example [Example] the owning example, used for backtrace decoration
171
+ #
172
+ # @param expectation [Expectation] the expectation to evaluate
173
+ #
174
+ # @param scope [Class, nil] the class scope to evaluate expressions in, or
175
+ # +nil+ to evaluate in the default binding
176
+ #
177
+ # @example
178
+ # run_expectation(example, expectation, MyClass)
179
+ #
180
+ # @return [void]
181
+ #
182
+ # @api private
183
+ #
184
+ def run_expectation(example, expectation, scope)
185
+ if expectation.expected.nil?
186
+ evaluate_actual(example, expectation.actual, scope)
187
+ else
188
+ verify_actual(example, expectation.expected, expectation.actual, scope)
189
+ end
190
+ end
191
+
192
+ # Evaluates the actual expression for side-effects only
193
+ #
194
+ # Delegates to {Evaluator#evaluate} and re-raises any +StandardError+ after
195
+ # prepending the example's source location to the backtrace via
196
+ # {#add_filepath_to_backtrace}, so that failure output points to the
197
+ # documented source rather than this file.
198
+ #
199
+ # @param example [Example] the owning example, used to decorate error backtraces
200
+ #
201
+ # @param actual [String] the Ruby expression to evaluate
202
+ #
203
+ # @param bind [Class, nil] the class scope to evaluate the expression in, or
204
+ # +nil+ to use the default binding
205
+ #
206
+ # @example
207
+ # evaluate_actual(example, 'foo(1)', MyClass)
208
+ #
209
+ # @return [void]
210
+ #
211
+ # @raise [StandardError] re-raises any error raised during evaluation, with the
212
+ # example's filepath prepended to the backtrace
213
+ #
214
+ # @api private
215
+ #
216
+ def evaluate_actual(example, actual, bind)
217
+ evaluator.evaluate(actual, bind)
218
+ rescue StandardError => e
219
+ add_filepath_to_backtrace(e, example.filepath)
220
+ raise e
221
+ end
222
+
223
+ # Returns the lazily-initialized {Evaluator} for this spec instance
224
+ #
225
+ # The evaluator is created with a fallback binding (whose +self+ is this
226
+ # spec instance, so that methods included on {Example} — such as
227
+ # +RSpec::Matchers+ — are accessible in evaluated code) and a snapshot of
228
+ # the spec instance's instance variables (set by +before+ hooks).
229
+ #
230
+ # @example
231
+ # evaluator.evaluate('1 + 1', nil)
232
+ #
233
+ # @return [Evaluator]
234
+ #
235
+ # @api private
236
+ #
237
+ def evaluator
238
+ @evaluator ||= Evaluator.new(
239
+ fallback_binding: create_fallback_binding,
240
+ instance_variables: instance_variable_hash
241
+ )
242
+ end
243
+
244
+ # Prepends the example's filepath to an exception's backtrace
245
+ #
246
+ # @param exception [Exception] the exception to decorate
247
+ #
248
+ # @param filepath [String] the source location to prepend
249
+ #
250
+ # @example
251
+ # add_filepath_to_backtrace(exception, 'app/app.rb:10')
252
+ #
253
+ # @return [void]
254
+ #
255
+ # @api private
256
+ #
257
+ def add_filepath_to_backtrace(exception, filepath)
258
+ exception.set_backtrace([filepath] + exception.backtrace)
259
+ end
260
+
261
+ private
262
+
263
+ # Returns a binding whose +self+ is this spec instance
264
+ #
265
+ # Because {Example} includes any modules the user adds (e.g.
266
+ # +RSpec::Matchers+), the returned binding automatically exposes those
267
+ # methods to code evaluated via the {Evaluator}'s fallback path.
268
+ #
269
+ # @example
270
+ # create_fallback_binding
271
+ #
272
+ # @return [Binding]
273
+ #
274
+ # @api private
275
+ #
276
+ def create_fallback_binding
277
+ binding
278
+ end
279
+
280
+ # Snapshots instance variables as a +Hash+
281
+ #
282
+ # Returns a hash mapping instance variable names to their current values
283
+ # on this spec instance. The {Evaluator} uses this snapshot to transplant
284
+ # hook-set state into class-scoped bindings.
285
+ #
286
+ # @example
287
+ # instance_variable_hash #=> { :@foo => 1 }
288
+ #
289
+ # @return [Hash{Symbol => Object}]
290
+ #
291
+ # @api private
292
+ #
293
+ def instance_variable_hash
294
+ instance_variables.to_h do |ivar|
295
+ [ivar, instance_variable_get(ivar)]
296
+ end
297
+ end
298
+
299
+ class << self
300
+ protected
301
+
302
+ # Requires any +example_runner_helper+ files found in known directories
303
+ #
304
+ # @example
305
+ # load_helpers
306
+ #
307
+ # @return [void]
308
+ #
309
+ # @api private
310
+ #
311
+ def load_helpers
312
+ %w[. support spec test].each do |dir|
313
+ require "#{dir}/example_runner_helper" if File.exist?("#{dir}/example_runner_helper.rb")
314
+ end
315
+ end
316
+
317
+ # Returns the full example name including an optional title suffix
318
+ #
319
+ # @param example [Example] the example to build a name for
320
+ #
321
+ # @example
322
+ # example_name_for(example) #=> 'Foo#bar'
323
+ #
324
+ # @return [String] the example name
325
+ #
326
+ # @api private
327
+ #
328
+ def example_name_for(example)
329
+ return example.definition if example.name.empty?
330
+
331
+ "#{example.definition}@#{example.name}"
332
+ end
333
+
334
+ # Registers matching before/after hooks on the current spec context
335
+ #
336
+ # @param example_name [String] the name of the example
337
+ #
338
+ # @param all_hooks [Hash{Symbol => Array<Hash>}] hooks grouped by type
339
+ #
340
+ # @param example [Example] the example being registered
341
+ #
342
+ # @example
343
+ # register_hooks('Foo#bar', YardExampleRunner.hooks, example)
344
+ #
345
+ # @return [void]
346
+ #
347
+ # @api private
348
+ #
349
+ def register_hooks(example_name, all_hooks, example)
350
+ all_hooks.each do |type, hooks|
351
+ global_hooks = hooks.reject { |hook| hook[:test] }
352
+ test_hooks = hooks.select { |hook| hook[:test] && example_name.include?(hook[:test]) }
353
+ __send__(type) do
354
+ (global_hooks + test_hooks).each { |hook| instance_exec(example, &hook[:block]) }
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YardExampleRunner
4
+ # @!parse
5
+ # # Represents a single expected outcome parsed from a YARD +@example+ tag
6
+ # #
7
+ # # Each instance holds the Ruby expression to evaluate (+actual+) and the
8
+ # # string representation of the value it should return (+expected+). When
9
+ # # +expected+ is +nil+, the expression is evaluated for side-effects only and
10
+ # # no assertion is made against its return value.
11
+ # #
12
+ # # @!attribute actual [r]
13
+ # # @return [String] the Ruby expression to evaluate
14
+ # #
15
+ # # @!attribute expected [r]
16
+ # # @return [String, nil] the expected return value, or +nil+ if no
17
+ # # assertion should be made
18
+ # #
19
+ # # @api public
20
+ # #
21
+ # class Expectation < Data
22
+ Expectation = Data.define(:actual, :expected)
23
+ end