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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +111 -0
- data/Rakefile +80 -0
- data/lib/rspec/capturing_formatter/configuration.rb +77 -0
- data/lib/rspec/capturing_formatter/renderer.rb +457 -0
- data/lib/rspec/capturing_formatter/sanitizer.rb +378 -0
- data/lib/rspec/capturing_formatter/stream_proxy.rb +414 -0
- data/lib/rspec/capturing_formatter/version.rb +9 -0
- data/lib/rspec/capturing_formatter/windows_command_line.rb +65 -0
- data/lib/rspec/capturing_formatter/windows_terminal.rb +87 -0
- data/lib/rspec/capturing_formatter.rb +319 -0
- metadata +120 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
class CapturingFormatter
|
|
5
|
+
# Incrementally decodes and sanitizes captured bytes into safe UTF-8 report text.
|
|
6
|
+
class Sanitizer
|
|
7
|
+
MAX_ESCAPE_BYTES = 128
|
|
8
|
+
UTF8_LEADERS = {
|
|
9
|
+
(0xC2..0xDF) => 2,
|
|
10
|
+
(0xE0..0xEF) => 3,
|
|
11
|
+
(0xF0..0xF4) => 4
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@source_encoding = nil
|
|
16
|
+
@bom_encoding = nil
|
|
17
|
+
@converter = nil
|
|
18
|
+
@converter_encoding = nil
|
|
19
|
+
@encoding_carry = +""
|
|
20
|
+
@escape_carry = +""
|
|
21
|
+
@pending_cr = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def process(value, encoding = nil)
|
|
25
|
+
source = resolve_encoding(value, encoding)
|
|
26
|
+
source_changed = @source_encoding && @source_encoding != source
|
|
27
|
+
prefix = +""
|
|
28
|
+
if source_changed
|
|
29
|
+
# Parser carry precedes decoder carry because it came from earlier, complete source bytes.
|
|
30
|
+
prefix << parse("", final: true) if !@escape_carry.empty?
|
|
31
|
+
prefix << flush_encoding_carry
|
|
32
|
+
prefix << finish_converter
|
|
33
|
+
end
|
|
34
|
+
@bom_encoding = nil if source_changed
|
|
35
|
+
@source_encoding = source
|
|
36
|
+
|
|
37
|
+
bytes = @encoding_carry + value.to_s.b
|
|
38
|
+
@encoding_carry = +""
|
|
39
|
+
converted, @encoding_carry = decode(bytes, source)
|
|
40
|
+
|
|
41
|
+
parse(prefix + converted)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def finish
|
|
45
|
+
result = parse("", final: true)
|
|
46
|
+
result << flush_encoding_carry
|
|
47
|
+
result << finish_converter
|
|
48
|
+
@escape_carry = +""
|
|
49
|
+
@pending_cr = false
|
|
50
|
+
@source_encoding = nil
|
|
51
|
+
@bom_encoding = nil
|
|
52
|
+
@converter = nil
|
|
53
|
+
@converter_encoding = nil
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def resolve_encoding(value, encoding)
|
|
60
|
+
return Encoding.find(encoding.to_s) if encoding
|
|
61
|
+
|
|
62
|
+
value.respond_to?(:encoding) ? value.encoding : Encoding::UTF_8
|
|
63
|
+
rescue ArgumentError
|
|
64
|
+
Encoding::UTF_8
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def flush_encoding_carry
|
|
68
|
+
return +"" if @encoding_carry.empty?
|
|
69
|
+
|
|
70
|
+
carry = @encoding_carry
|
|
71
|
+
@encoding_carry = +""
|
|
72
|
+
escape_bytes(carry)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def decode(bytes, source)
|
|
76
|
+
return [binary_text(bytes), +""] if source == Encoding::BINARY
|
|
77
|
+
return decode_utf8(bytes) if source == Encoding::UTF_8
|
|
78
|
+
return decode_utf16(bytes) if source == Encoding::UTF_16
|
|
79
|
+
return decode_utf8_variant(bytes, source) if source.name.start_with?("UTF8-")
|
|
80
|
+
|
|
81
|
+
decode_with_converter(bytes, source)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def decode_utf8_variant(bytes, source)
|
|
85
|
+
encoded = bytes.dup.force_encoding(source)
|
|
86
|
+
return [encoded.encode(Encoding::UTF_8), +""] if encoded.valid_encoding?
|
|
87
|
+
|
|
88
|
+
decode_utf8(bytes)
|
|
89
|
+
rescue EncodingError
|
|
90
|
+
decode_utf8(bytes)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def decode_utf16(bytes)
|
|
94
|
+
unless @bom_encoding
|
|
95
|
+
return ["", bytes] if bytes.bytesize < 2
|
|
96
|
+
|
|
97
|
+
@bom_encoding = case bytes.byteslice(0, 2).bytes
|
|
98
|
+
when [0xFE, 0xFF] then Encoding::UTF_16BE
|
|
99
|
+
when [0xFF, 0xFE] then Encoding::UTF_16LE
|
|
100
|
+
end
|
|
101
|
+
return decode_with_converter(bytes, Encoding::UTF_16) unless @bom_encoding
|
|
102
|
+
|
|
103
|
+
bytes = bytes.byteslice(2..).to_s.b
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
decode_with_converter(bytes, @bom_encoding)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def decode_utf8(bytes)
|
|
110
|
+
encoded = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
111
|
+
return [encoded, +""] if encoded.valid_encoding?
|
|
112
|
+
|
|
113
|
+
suffix_length = incomplete_utf8_suffix_length(bytes)
|
|
114
|
+
if suffix_length.positive?
|
|
115
|
+
prefix = bytes.byteslice(0, bytes.bytesize - suffix_length).to_s.force_encoding(Encoding::UTF_8)
|
|
116
|
+
prefix = prefix.scrub { |invalid| escape_bytes(invalid.b) }
|
|
117
|
+
return [prefix, bytes.byteslice(-suffix_length, suffix_length)]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
[encoded.scrub { |invalid| escape_bytes(invalid.b) }, +""]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def incomplete_utf8_suffix_length(bytes)
|
|
124
|
+
raw = bytes.bytes
|
|
125
|
+
|
|
126
|
+
[3, raw.length].min.downto(1) do |length|
|
|
127
|
+
suffix = raw.last(length)
|
|
128
|
+
next unless utf8_incomplete_prefix?(suffix)
|
|
129
|
+
|
|
130
|
+
return length
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def utf8_incomplete_prefix?(bytes)
|
|
137
|
+
leader = bytes.first
|
|
138
|
+
expected = UTF8_LEADERS.find { |range, _| range.include?(leader) }&.last
|
|
139
|
+
return false unless expected && bytes.length < expected
|
|
140
|
+
return false unless bytes.drop(1).all? { |byte| byte.between?(0x80, 0xBF) }
|
|
141
|
+
|
|
142
|
+
# These bounds reject overlong forms, surrogates, and code points above U+10FFFF.
|
|
143
|
+
case leader
|
|
144
|
+
when 0xE0
|
|
145
|
+
bytes[1].nil? || bytes[1] >= 0xA0
|
|
146
|
+
when 0xED
|
|
147
|
+
bytes[1].nil? || bytes[1] <= 0x9F
|
|
148
|
+
when 0xF0
|
|
149
|
+
bytes[1].nil? || bytes[1] >= 0x90
|
|
150
|
+
when 0xF4
|
|
151
|
+
bytes[1].nil? || bytes[1] <= 0x8F
|
|
152
|
+
else
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Preserve converter shift state across writes and retry read-ahead bytes after malformed input.
|
|
158
|
+
def decode_with_converter(bytes, source)
|
|
159
|
+
remaining = bytes.dup
|
|
160
|
+
output = +"".b
|
|
161
|
+
converter_for(source)
|
|
162
|
+
|
|
163
|
+
loop do
|
|
164
|
+
destination = +"".b
|
|
165
|
+
result = @converter.primitive_convert(
|
|
166
|
+
remaining, destination, nil, nil, Encoding::Converter::PARTIAL_INPUT
|
|
167
|
+
)
|
|
168
|
+
output << safe_utf8(destination)
|
|
169
|
+
|
|
170
|
+
case result
|
|
171
|
+
when :finished, :source_buffer_empty
|
|
172
|
+
return [output.force_encoding(Encoding::UTF_8), +""]
|
|
173
|
+
when :incomplete_input
|
|
174
|
+
carry = @converter.primitive_errinfo[3].to_s.b
|
|
175
|
+
return [output.force_encoding(Encoding::UTF_8), carry]
|
|
176
|
+
when :invalid_byte_sequence
|
|
177
|
+
error = @converter.primitive_errinfo
|
|
178
|
+
invalid = error[3].to_s.b
|
|
179
|
+
retry_bytes = error[4].to_s.b + remaining
|
|
180
|
+
if invalid.empty?
|
|
181
|
+
invalid = retry_bytes.byteslice(0, 1).to_s.b
|
|
182
|
+
retry_bytes = retry_bytes.byteslice(1..).to_s.b
|
|
183
|
+
end
|
|
184
|
+
output << escape_bytes(invalid)
|
|
185
|
+
return [output.force_encoding(Encoding::UTF_8), +""] if retry_bytes.empty?
|
|
186
|
+
|
|
187
|
+
reset_converter
|
|
188
|
+
converter_for(source)
|
|
189
|
+
remaining = retry_bytes
|
|
190
|
+
when :undefined_conversion
|
|
191
|
+
error = @converter.primitive_errinfo
|
|
192
|
+
invalid = error[3].to_s.b
|
|
193
|
+
retry_bytes = error[4].to_s.b + remaining
|
|
194
|
+
if invalid.empty?
|
|
195
|
+
invalid = retry_bytes.byteslice(0, 1).to_s.b
|
|
196
|
+
retry_bytes = retry_bytes.byteslice(1..).to_s.b
|
|
197
|
+
end
|
|
198
|
+
output << escape_bytes(invalid)
|
|
199
|
+
return [output.force_encoding(Encoding::UTF_8), +""] if retry_bytes.empty?
|
|
200
|
+
|
|
201
|
+
reset_converter
|
|
202
|
+
converter_for(source)
|
|
203
|
+
remaining = retry_bytes
|
|
204
|
+
else
|
|
205
|
+
return [output.force_encoding(Encoding::UTF_8), +""]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
rescue EncodingError
|
|
209
|
+
[escape_bytes(bytes), +""]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def converter_for(source)
|
|
213
|
+
return if @converter && @converter_encoding == source
|
|
214
|
+
|
|
215
|
+
@converter = Encoding::Converter.new(source, Encoding::UTF_8)
|
|
216
|
+
@converter_encoding = source
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def reset_converter
|
|
220
|
+
@converter = nil
|
|
221
|
+
@converter_encoding = nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def finish_converter
|
|
225
|
+
return +"" unless @converter
|
|
226
|
+
|
|
227
|
+
destination = +"".b
|
|
228
|
+
empty = +"".b
|
|
229
|
+
result = @converter.primitive_convert(empty, destination, nil, nil, 0)
|
|
230
|
+
output = safe_utf8(destination)
|
|
231
|
+
if result == :incomplete_input || result == :invalid_byte_sequence
|
|
232
|
+
output << escape_bytes(@converter.primitive_errinfo[3].to_s.b)
|
|
233
|
+
end
|
|
234
|
+
reset_converter
|
|
235
|
+
output
|
|
236
|
+
rescue EncodingError
|
|
237
|
+
reset_converter
|
|
238
|
+
escape_bytes(@encoding_carry)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def safe_utf8(bytes)
|
|
242
|
+
value = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
243
|
+
return value if value.valid_encoding?
|
|
244
|
+
|
|
245
|
+
value.scrub { |invalid| escape_bytes(invalid.b) }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def binary_text(bytes)
|
|
249
|
+
bytes.bytes.map do |byte|
|
|
250
|
+
if byte == 9 || byte == 10 || byte == 13 || byte.between?(0x20, 0x7E)
|
|
251
|
+
byte.chr
|
|
252
|
+
else
|
|
253
|
+
format("\\x%02X", byte)
|
|
254
|
+
end
|
|
255
|
+
end.join
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def escape_bytes(bytes)
|
|
259
|
+
bytes.bytes.map { |byte| format("\\x%02X", byte) }.join
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def parse(text, final: false)
|
|
263
|
+
# Input is valid UTF-8 here; byte offsets are used only for ASCII terminal-control syntax.
|
|
264
|
+
input = @escape_carry + text.to_s
|
|
265
|
+
@escape_carry = +""
|
|
266
|
+
output = +""
|
|
267
|
+
index = 0
|
|
268
|
+
|
|
269
|
+
while index < input.bytesize
|
|
270
|
+
if @pending_cr
|
|
271
|
+
@pending_cr = false if input.getbyte(index) != 10
|
|
272
|
+
if input.getbyte(index) == 10
|
|
273
|
+
index += 1
|
|
274
|
+
next
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if input.getbyte(index) == 0x1B
|
|
279
|
+
sequence, consumed, complete = terminal_sequence(input, index)
|
|
280
|
+
unless complete
|
|
281
|
+
if !final && input.bytesize - index <= MAX_ESCAPE_BYTES
|
|
282
|
+
@escape_carry = input.byteslice(index..)
|
|
283
|
+
break
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
output << visible_escape(input.byteslice(index..))
|
|
287
|
+
break
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
output << if sgr_sequence?(sequence)
|
|
291
|
+
sequence
|
|
292
|
+
else
|
|
293
|
+
visible_escape(sequence)
|
|
294
|
+
end
|
|
295
|
+
index += consumed
|
|
296
|
+
next
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
character = input.byteslice(index..).each_char.first
|
|
300
|
+
character_bytes = character.bytesize
|
|
301
|
+
case character
|
|
302
|
+
when "\r"
|
|
303
|
+
output << "\n"
|
|
304
|
+
@pending_cr = true
|
|
305
|
+
when "\n", "\t"
|
|
306
|
+
output << character
|
|
307
|
+
else
|
|
308
|
+
codepoint = character.ord
|
|
309
|
+
output << if codepoint < 0x20 || codepoint == 0x7F || codepoint.between?(0x80, 0x9F)
|
|
310
|
+
format("\\x%02X", codepoint)
|
|
311
|
+
else
|
|
312
|
+
character
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
index += character_bytes
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
output
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def terminal_sequence(input, start)
|
|
322
|
+
# CSI ends at an ANSI final byte; OSC ends at BEL or the String Terminator (ESC backslash).
|
|
323
|
+
return ["\e", 1, false] if start + 1 >= input.bytesize
|
|
324
|
+
|
|
325
|
+
second_byte = input.getbyte(start + 1)
|
|
326
|
+
return ["\e", 1, true] if second_byte >= 0x80
|
|
327
|
+
|
|
328
|
+
second = second_byte.chr
|
|
329
|
+
if second == "["
|
|
330
|
+
index = start + 2
|
|
331
|
+
while index < input.bytesize && index - start <= MAX_ESCAPE_BYTES
|
|
332
|
+
byte = input.getbyte(index)
|
|
333
|
+
return [input.byteslice(start..index), index - start + 1, true] if byte.between?(0x40, 0x7E)
|
|
334
|
+
if byte < 0x20 || byte == 0x7F || byte.between?(0x80, 0x9F)
|
|
335
|
+
return [input.byteslice(start..index), index - start + 1, true]
|
|
336
|
+
end
|
|
337
|
+
index += 1
|
|
338
|
+
end
|
|
339
|
+
return [input.byteslice(start..), input.bytesize - start, false]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
if second == "]"
|
|
343
|
+
index = start + 2
|
|
344
|
+
while index < input.bytesize && index - start <= MAX_ESCAPE_BYTES
|
|
345
|
+
return [input.byteslice(start..index), index - start + 1, true] if input.getbyte(index) == 7
|
|
346
|
+
if input.getbyte(index) == 0x1B && input.getbyte(index + 1) == 92
|
|
347
|
+
return [input.byteslice(start..index + 1), index - start + 2, true]
|
|
348
|
+
end
|
|
349
|
+
index += 1
|
|
350
|
+
end
|
|
351
|
+
return [input.byteslice(start..), input.bytesize - start, false]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
[input.byteslice(start, 2), 2, true]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def sgr_sequence?(sequence)
|
|
358
|
+
# Preserve only empty or numeric SGR parameters separated by semicolons or colons.
|
|
359
|
+
bytes = sequence.bytes
|
|
360
|
+
return false unless bytes.first(2) == [0x1B, 0x5B] && bytes.last == 0x6D
|
|
361
|
+
|
|
362
|
+
bytes[2...-1].all? do |byte|
|
|
363
|
+
byte.between?(0x30, 0x39) || byte == 0x3A || byte == 0x3B
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def visible_escape(sequence)
|
|
368
|
+
sequence.to_s.bytes.map do |byte|
|
|
369
|
+
case byte
|
|
370
|
+
when 0x1B then "\\e"
|
|
371
|
+
when 0x20..0x7E then byte.chr
|
|
372
|
+
else format("\\x%02X", byte)
|
|
373
|
+
end
|
|
374
|
+
end.join
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|