rspec-capturing-formatter 1.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.
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module RSpec
6
+ class CapturingFormatter
7
+ # Owns process-global stream capture and serializes captured writes with formatter callbacks.
8
+ class CaptureManager
9
+ # Older supported Rubies need keyword_init: true to construct leases from keyword arguments.
10
+ # standard:disable Style/RedundantStructKeywordInit
11
+ Lease = Struct.new(:manager, :owner, :generation, keyword_init: true) do
12
+ # standard:enable Style/RedundantStructKeywordInit
13
+ def owner?
14
+ owner
15
+ end
16
+ end
17
+
18
+ class << self
19
+ def instance
20
+ @instance ||= new
21
+ end
22
+
23
+ def install!
24
+ instance.install!
25
+ end
26
+ end
27
+
28
+ attr_reader :stdout_proxy, :stderr_proxy
29
+
30
+ def initialize
31
+ # Renderer writes can re-enter through a proxy, so capture synchronization must be reentrant.
32
+ @monitor = Monitor.new
33
+ @installed = false
34
+ @active = false
35
+ @pending_nonblock = {}
36
+ @generation = 0
37
+ @bypass_key = :rspec_capturing_formatter_bypass_depth
38
+ end
39
+
40
+ def install!
41
+ @monitor.synchronize do
42
+ return self if @installed && $stdout.equal?(@stdout_proxy) && $stderr.equal?(@stderr_proxy)
43
+
44
+ @original_stdout ||= $stdout
45
+ @original_stderr ||= $stderr
46
+ @stdout_proxy ||= StreamProxy.new(@original_stdout, :stdout, self)
47
+ @stderr_proxy ||= StreamProxy.new(@original_stderr, :stderr, self)
48
+ $stdout = @stdout_proxy
49
+ $stderr = @stderr_proxy
50
+ @installed = true
51
+ end
52
+ self
53
+ end
54
+
55
+ def activate(output, formatter)
56
+ existing = @monitor.synchronize { active_lease }
57
+ return existing if existing
58
+
59
+ install!
60
+ @monitor.synchronize do
61
+ existing = active_lease
62
+ return existing if existing
63
+
64
+ begin
65
+ @formatter = formatter
66
+ @active = true
67
+ @generation += 1
68
+ @stdout_proxy.activate!
69
+ @stderr_proxy.activate!
70
+ Lease.new(manager: self, owner: true, generation: @generation)
71
+ # Activation mutates process-global capture state, so roll it back even outside StandardError.
72
+ # standard:disable Lint/RescueException
73
+ rescue Exception
74
+ # standard:enable Lint/RescueException
75
+ @active = false
76
+ @formatter = nil
77
+ @stdout_proxy.deactivate!
78
+ @stderr_proxy.deactivate!
79
+ restore_globals
80
+ raise
81
+ end
82
+ end
83
+ end
84
+
85
+ def active?
86
+ @monitor.synchronize { @active }
87
+ end
88
+
89
+ def generation
90
+ @monitor.synchronize { @generation }
91
+ end
92
+
93
+ def synchronize(&block)
94
+ @monitor.synchronize(&block)
95
+ end
96
+
97
+ def handle_write(proxy, value, method = :write, **options)
98
+ @monitor.synchronize do
99
+ pending = @pending_nonblock[proxy]
100
+ if pending
101
+ if nonblocking_method?(method) && pending != value
102
+ raise Errno::EAGAIN
103
+ end
104
+ # This payload is already captured; remove its marker while a bypassed flush may re-enter here.
105
+ @pending_nonblock.delete(proxy)
106
+ begin
107
+ @formatter.flush_pending
108
+ rescue IO::WaitWritable, Errno::EAGAIN
109
+ @pending_nonblock[proxy] = pending
110
+ raise
111
+ end
112
+ return value.bytesize if nonblocking_method?(method) && pending == value
113
+ end
114
+
115
+ if !@active || bypassing? || proxy.raw_mode?
116
+ proxy.write_backing(value, method, **options)
117
+ else
118
+ begin
119
+ @formatter.capture(proxy.source, value, value.encoding)
120
+ rescue IO::WaitWritable, Errno::EAGAIN
121
+ @pending_nonblock[proxy] = value if nonblocking_method?(method)
122
+ raise
123
+ end
124
+ value.bytesize
125
+ end
126
+ end
127
+ end
128
+
129
+ def bypass
130
+ depth = Thread.current[@bypass_key].to_i
131
+ Thread.current[@bypass_key] = depth + 1
132
+ yield
133
+ ensure
134
+ Thread.current[@bypass_key] = depth
135
+ end
136
+
137
+ def bypassing?
138
+ Thread.current[@bypass_key].to_i.positive?
139
+ end
140
+
141
+ def deactivate(lease)
142
+ return unless lease&.owner?
143
+ return unless lease.manager.equal?(self)
144
+
145
+ @monitor.synchronize do
146
+ return unless @active && lease.generation == @generation
147
+ begin
148
+ @formatter.finish_capture
149
+ ensure
150
+ @active = false
151
+ @formatter = nil
152
+ @pending_nonblock.clear
153
+ @stdout_proxy.deactivate!
154
+ @stderr_proxy.deactivate!
155
+ restore_globals
156
+ end
157
+ end
158
+ end
159
+
160
+ def restore!
161
+ @monitor.synchronize do
162
+ @active = false
163
+ @formatter = nil
164
+ @pending_nonblock.clear
165
+ @stdout_proxy&.deactivate!
166
+ @stderr_proxy&.deactivate!
167
+ restore_globals
168
+ end
169
+ end
170
+
171
+ def rollback_if_unchanged(expected_generation)
172
+ @monitor.synchronize do
173
+ return if @active || @generation != expected_generation
174
+
175
+ restore_globals
176
+ end
177
+ end
178
+
179
+ def uninstall!
180
+ restore!
181
+ end
182
+
183
+ private
184
+
185
+ def nonblocking_method?(method)
186
+ method == :write_nonblock || method == :syswrite
187
+ end
188
+
189
+ def active_lease
190
+ return unless @active
191
+
192
+ raise "another RSpec::CapturingFormatter is already active"
193
+ end
194
+
195
+ def restore_globals
196
+ # Do not overwrite a global stream that another component replaced while capture was active.
197
+ $stdout = @original_stdout if @stdout_proxy && $stdout.equal?(@stdout_proxy)
198
+ $stderr = @original_stderr if @stderr_proxy && $stderr.equal?(@stderr_proxy)
199
+ end
200
+ end
201
+
202
+ # Presents an IO-like stream whose writes are captured or passed unchanged to its backing stream.
203
+ class StreamProxy
204
+ attr_reader :source
205
+
206
+ def initialize(backing, source, manager)
207
+ @backing = backing
208
+ @source = source
209
+ @manager = manager
210
+ @raw_mode = false
211
+ @closed = false
212
+ end
213
+
214
+ def initialize_copy(other)
215
+ super
216
+ @manager = other.manager
217
+ # RSpec's any-process output matcher reopens from a clone and needs its current backing stream.
218
+ @manager.synchronize do
219
+ @backing = begin
220
+ other.backing.dup
221
+ rescue
222
+ other.backing
223
+ end
224
+ @raw_mode = other.raw_mode?
225
+ @closed = false
226
+ end
227
+ end
228
+
229
+ def raw_mode?
230
+ @raw_mode
231
+ end
232
+
233
+ attr_reader :manager
234
+
235
+ attr_reader :backing
236
+
237
+ def activate!
238
+ @raw_mode = false
239
+ @closed = false
240
+ end
241
+
242
+ def deactivate!
243
+ @raw_mode = true
244
+ end
245
+
246
+ def write(value)
247
+ string = String(value)
248
+ @manager.handle_write(self, string, :write)
249
+ end
250
+
251
+ def write_nonblock(value, exception: true)
252
+ string = String(value)
253
+ @manager.handle_write(self, string, :write_nonblock, exception: exception)
254
+ rescue IO::WaitWritable, Errno::EAGAIN
255
+ raise if exception
256
+
257
+ :wait_writable
258
+ end
259
+
260
+ def syswrite(value)
261
+ string = String(value)
262
+ @manager.handle_write(self, string, :syswrite)
263
+ end
264
+
265
+ def <<(value)
266
+ write(value)
267
+ self
268
+ end
269
+
270
+ def puts(*values)
271
+ values = [nil] if values.empty?
272
+ values.each { |value| put_value(value) }
273
+ nil
274
+ end
275
+
276
+ def put_value(value, ancestors = [])
277
+ if value.is_a?(Array)
278
+ if ancestors.include?(value.object_id)
279
+ write("[...]\n")
280
+ else
281
+ value.each { |entry| put_value(entry, ancestors + [value.object_id]) }
282
+ end
283
+ else
284
+ text = value.nil? ? "\n" : value.to_s
285
+ newline = "\n".encode(text.encoding)
286
+ write(text.end_with?(newline) ? text : text + newline)
287
+ end
288
+ end
289
+
290
+ def print(*values)
291
+ values = [$_] if values.empty? && defined?($_) && !$_.nil?
292
+ separator = $OUTPUT_FIELD_SEPARATOR
293
+ terminator = $OUTPUT_RECORD_SEPARATOR
294
+ values.each_with_index do |value, index|
295
+ write(separator) if index.positive? && separator
296
+ write(value)
297
+ end
298
+ write(terminator) if values.any? && terminator
299
+ nil
300
+ end
301
+
302
+ def printf(format_string, *values)
303
+ write(format_string % values)
304
+ nil
305
+ end
306
+
307
+ def putc(value)
308
+ character = value.is_a?(Integer) ? (value & 0xFF).chr(Encoding::BINARY) : value.to_s[0]
309
+ write(character)
310
+ value
311
+ end
312
+
313
+ def flush
314
+ @manager.bypass { @backing.flush } if @backing.respond_to?(:flush)
315
+ self
316
+ end
317
+
318
+ def sync
319
+ @backing.sync if @backing.respond_to?(:sync)
320
+ end
321
+
322
+ def sync=(value)
323
+ @backing.sync = value if @backing.respond_to?(:sync=)
324
+ end
325
+
326
+ def tty?
327
+ @backing.respond_to?(:tty?) && @backing.tty?
328
+ end
329
+ alias_method :isatty, :tty?
330
+
331
+ def closed?
332
+ @closed || (@backing.respond_to?(:closed?) && @backing.closed?)
333
+ end
334
+
335
+ def external_encoding
336
+ @backing.respond_to?(:external_encoding) ? @backing.external_encoding : Encoding::UTF_8
337
+ end
338
+
339
+ def internal_encoding
340
+ @backing.respond_to?(:internal_encoding) ? @backing.internal_encoding : nil
341
+ end
342
+
343
+ def set_encoding(*arguments)
344
+ @backing.set_encoding(*arguments) if @backing.respond_to?(:set_encoding)
345
+ self
346
+ end
347
+
348
+ def binmode
349
+ @backing.binmode if @backing.respond_to?(:binmode)
350
+ self
351
+ end
352
+
353
+ def fileno
354
+ @backing.fileno
355
+ end
356
+
357
+ def to_io
358
+ self
359
+ end
360
+
361
+ def reopen(target, *arguments)
362
+ # Proxy targets restore matcher snapshots; other targets must bypass capture.
363
+ @manager.synchronize do
364
+ if target.is_a?(StreamProxy)
365
+ if descriptor_backing?
366
+ @backing.reopen(target.backing)
367
+ else
368
+ @backing = target.backing
369
+ end
370
+ @raw_mode = target.raw_mode?
371
+ elsif descriptor_backing? && (target.respond_to?(:read) || target.respond_to?(:write))
372
+ @backing.reopen(target.respond_to?(:to_io) ? target.to_io : target, *arguments)
373
+ @raw_mode = true
374
+ elsif target.respond_to?(:read) || target.respond_to?(:write)
375
+ @backing = target
376
+ @raw_mode = true
377
+ elsif @backing.respond_to?(:reopen)
378
+ @backing.reopen(target, *arguments)
379
+ @raw_mode = true
380
+ else
381
+ @backing = target
382
+ @raw_mode = true
383
+ end
384
+ end
385
+ self
386
+ end
387
+
388
+ def descriptor_backing?
389
+ defined?(IO) && @backing.is_a?(IO)
390
+ end
391
+
392
+ def close
393
+ @closed = true
394
+ # A retained proxy can outlive capture; do not close a backing stream restored as a process global.
395
+ @backing.close unless @backing.equal?($stdout) || @backing.equal?($stderr)
396
+ nil
397
+ end
398
+
399
+ def write_backing(value, method = :write, **options)
400
+ @manager.bypass { @backing.public_send(method, value, **options) }
401
+ end
402
+
403
+ def method_missing(name, *arguments, &block)
404
+ return super unless @backing.respond_to?(name)
405
+
406
+ @backing.public_send(name, *arguments, &block)
407
+ end
408
+
409
+ def respond_to_missing?(name, include_private = false)
410
+ @backing.respond_to?(name, include_private) || super
411
+ end
412
+ end
413
+ end
414
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ class CapturingFormatter
5
+ # After updating this value, run `bundle exec rake bundle:install -m`
6
+ # to sync all Bundler lockfiles.
7
+ VERSION = "1.0.0"
8
+ end
9
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ class CapturingFormatter
5
+ # Builds rerun commands without exposing dynamic targets to cmd.exe parsing.
6
+ module WindowsCommandLine
7
+ DECODE_ARGUMENT = 'ARGV[0]=ARGV.fetch(0).unpack1("m0").force_encoding("UTF-8")'
8
+ RUN_RSPEC = 'load(Gem.bin_path("rspec-core","rspec"))'
9
+
10
+ # Protect characters that cmd.exe interprets while parsing a pasted command.
11
+ CMD_META = "()[]^\"`<>&|;, *?\t"
12
+
13
+ module_function
14
+
15
+ def rerun_command(value)
16
+ encoded_ruby_command(RUN_RSPEC, value)
17
+ end
18
+
19
+ # Dynamic values are encoded because cmd.exe expands paired percent signs
20
+ # and exclamation marks before an executable can receive them.
21
+ def encoded_ruby_command(script, value)
22
+ value = valid_value(value).encode(Encoding::UTF_8)
23
+ bootstrap = "#{DECODE_ARGUMENT};#{script}"
24
+ "ruby -e #{escape(bootstrap)} #{[value].pack("m0")}"
25
+ end
26
+
27
+ def escape(value)
28
+ value = valid_value(value)
29
+ if value.match?(/[%!]/)
30
+ raise ArgumentError, "dynamic cmd.exe arguments containing percent signs or exclamation marks must be encoded"
31
+ end
32
+
33
+ # Build Windows argv quoting before escaping the cmd.exe layer. A literal
34
+ # quote needs 2n + 1 backslashes; a closing quote after n backslashes needs 2n.
35
+ quoted = +'"'
36
+ backslashes = 0
37
+ value.each_char do |character|
38
+ if character == "\\"
39
+ backslashes += 1
40
+ elsif character == '"'
41
+ quoted << ("\\" * (backslashes * 2 + 1)) << '"'
42
+ backslashes = 0
43
+ else
44
+ quoted << ("\\" * backslashes) << character
45
+ backslashes = 0
46
+ end
47
+ end
48
+ quoted << ("\\" * (backslashes * 2)) << '"'
49
+
50
+ quoted.each_char.map do |character|
51
+ CMD_META.include?(character) ? "^#{character}" : character
52
+ end.join
53
+ end
54
+
55
+ def valid_value(value)
56
+ value = value.to_s
57
+ if value.match?(/[\0\r\n]/)
58
+ raise ArgumentError, "Windows command arguments cannot contain NUL or newlines"
59
+ end
60
+ value
61
+ end
62
+ private_class_method :valid_value
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle" if Gem.win_platform?
4
+
5
+ module RSpec
6
+ class CapturingFormatter
7
+ # Determines ANSI support and enables Virtual Terminal processing for Windows Terminal output.
8
+ module WindowsTerminal
9
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
10
+ STD_OUTPUT_HANDLE = -11
11
+ STD_ERROR_HANDLE = -12
12
+
13
+ class << self
14
+ # Redirected output remains ANSI-capable; interactive output requires Windows Terminal and VT support.
15
+ # Fiddle failures fall back to plain text, while unrelated errors propagate.
16
+ def ansi_supported?(output, on_windows: Gem.win_platform?, env: ENV, api: nil)
17
+ return true unless on_windows
18
+
19
+ begin
20
+ return true unless tty?(output)
21
+ return false unless windows_terminal?(env)
22
+
23
+ (api || NativeApi.new).enable_virtual_terminal_processing(output)
24
+ rescue Fiddle::Error
25
+ false
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def tty?(output)
32
+ output.respond_to?(:tty?) && output.tty?
33
+ end
34
+
35
+ def windows_terminal?(env)
36
+ value = env["WT_SESSION"]
37
+ value && !value.empty?
38
+ end
39
+ end
40
+
41
+ # Isolates the Fiddle bindings for the Windows console API.
42
+ class NativeApi
43
+ def initialize
44
+ kernel32 = Fiddle.dlopen("kernel32.dll")
45
+ @get_std_handle = function(kernel32, "GetStdHandle", [Fiddle::TYPE_LONG], Fiddle::TYPE_VOIDP)
46
+ @get_console_mode = function(
47
+ kernel32,
48
+ "GetConsoleMode",
49
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
50
+ Fiddle::TYPE_INT
51
+ )
52
+ @set_console_mode = function(
53
+ kernel32,
54
+ "SetConsoleMode",
55
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG],
56
+ Fiddle::TYPE_INT
57
+ )
58
+ end
59
+
60
+ def enable_virtual_terminal_processing(output)
61
+ handle = @get_std_handle.call(std_handle(output))
62
+ return false if handle.to_i.zero? || handle.to_i == -1
63
+
64
+ # Console modes are Windows DWORDs: four little-endian bytes.
65
+ mode = Fiddle::Pointer.malloc(4)
66
+ return false if @get_console_mode.call(handle, mode).zero?
67
+
68
+ current_mode = mode[0, 4].unpack1("V")
69
+ return true if (current_mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING).positive?
70
+
71
+ !@set_console_mode.call(handle, current_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING).zero?
72
+ end
73
+
74
+ private
75
+
76
+ def function(library, name, arguments, result)
77
+ Fiddle::Function.new(library[name], arguments, result)
78
+ end
79
+
80
+ def std_handle(output)
81
+ source = output.source if output.respond_to?(:source)
82
+ (source == :stderr) ? STD_ERROR_HANDLE : STD_OUTPUT_HANDLE
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end