io-like 0.3.1 → 0.4.0.pre1

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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +1 -1
  3. data/NEWS.md +14 -1
  4. data/README.md +75 -94
  5. data/lib/io/like.rb +1916 -1314
  6. data/lib/io/like_helpers/abstract_io.rb +512 -0
  7. data/lib/io/like_helpers/blocking_io.rb +86 -0
  8. data/lib/io/like_helpers/buffered_io.rb +555 -0
  9. data/lib/io/like_helpers/character_io/basic_reader.rb +122 -0
  10. data/lib/io/like_helpers/character_io/converter_reader.rb +252 -0
  11. data/lib/io/like_helpers/character_io.rb +529 -0
  12. data/lib/io/like_helpers/delegated_io.rb +250 -0
  13. data/lib/io/like_helpers/duplexed_io.rb +259 -0
  14. data/lib/io/like_helpers/io.rb +21 -0
  15. data/lib/io/like_helpers/io_wrapper.rb +290 -0
  16. data/lib/io/like_helpers/pipeline.rb +77 -0
  17. data/lib/io/like_helpers/ruby_facts.rb +33 -0
  18. data/lib/io/like_helpers.rb +14 -0
  19. metadata +107 -224
  20. data/.yardopts +0 -1
  21. data/Rakefile +0 -228
  22. data/ruby.1.8.mspec +0 -7
  23. data/spec/binmode_spec.rb +0 -29
  24. data/spec/close_read_spec.rb +0 -64
  25. data/spec/close_spec.rb +0 -36
  26. data/spec/close_write_spec.rb +0 -61
  27. data/spec/closed_spec.rb +0 -16
  28. data/spec/each_byte_spec.rb +0 -38
  29. data/spec/each_line_spec.rb +0 -11
  30. data/spec/each_spec.rb +0 -11
  31. data/spec/eof_spec.rb +0 -11
  32. data/spec/fixtures/classes.rb +0 -96
  33. data/spec/fixtures/gets.txt +0 -9
  34. data/spec/fixtures/numbered_lines.txt +0 -5
  35. data/spec/fixtures/one_byte.txt +0 -1
  36. data/spec/fixtures/paragraphs.txt +0 -7
  37. data/spec/fixtures/readlines.txt +0 -6
  38. data/spec/flush_spec.rb +0 -8
  39. data/spec/getc_spec.rb +0 -44
  40. data/spec/gets_spec.rb +0 -212
  41. data/spec/isatty_spec.rb +0 -6
  42. data/spec/lineno_spec.rb +0 -84
  43. data/spec/output_spec.rb +0 -47
  44. data/spec/pos_spec.rb +0 -53
  45. data/spec/print_spec.rb +0 -97
  46. data/spec/printf_spec.rb +0 -24
  47. data/spec/putc_spec.rb +0 -57
  48. data/spec/puts_spec.rb +0 -99
  49. data/spec/read_spec.rb +0 -162
  50. data/spec/readchar_spec.rb +0 -49
  51. data/spec/readline_spec.rb +0 -60
  52. data/spec/readlines_spec.rb +0 -140
  53. data/spec/readpartial_spec.rb +0 -92
  54. data/spec/rewind_spec.rb +0 -56
  55. data/spec/seek_spec.rb +0 -72
  56. data/spec/shared/each.rb +0 -204
  57. data/spec/shared/eof.rb +0 -116
  58. data/spec/shared/pos.rb +0 -39
  59. data/spec/shared/tty.rb +0 -12
  60. data/spec/shared/write.rb +0 -53
  61. data/spec/sync_spec.rb +0 -56
  62. data/spec/sysread_spec.rb +0 -87
  63. data/spec/sysseek_spec.rb +0 -68
  64. data/spec/syswrite_spec.rb +0 -60
  65. data/spec/tell_spec.rb +0 -7
  66. data/spec/to_io_spec.rb +0 -19
  67. data/spec/tty_spec.rb +0 -6
  68. data/spec/ungetc_spec.rb +0 -118
  69. data/spec/write_spec.rb +0 -61
  70. data/spec_helper.rb +0 -49
  71. /data/{LICENSE-rubyspec → rubyspec/LICENSE} +0 -0
@@ -0,0 +1,529 @@
1
+ # frozen_string_literal: true
2
+ require 'io/like_helpers/character_io/basic_reader'
3
+ require 'io/like_helpers/character_io/converter_reader'
4
+ require 'io/like_helpers/ruby_facts'
5
+
6
+ class IO; module LikeHelpers
7
+
8
+ ##
9
+ # This class implements a stream that reads or writes characters to or from a
10
+ # byte oriented stream.
11
+ class CharacterIO
12
+ include RubyFacts
13
+
14
+ ##
15
+ # Creates a new intance of this class.
16
+ #
17
+ # @param buffered_io [LikeHelpers::BufferedIO] a readable and/or writable
18
+ # stream that always blocks
19
+ # @param blocking_io [LikeHelpers::BlockingIO] a readable and/or writable
20
+ # stream that always blocks
21
+ # @param internal_encoding [Encoding, String] the internal encoding
22
+ # @param external_encoding [Encoding, String] the external encoding
23
+ # @param encoding_opts [Hash] options to be passed to String#encode
24
+ # @param sync [Boolean] when `true` causes write operations to bypass internal
25
+ # buffering
26
+ def initialize(
27
+ buffered_io,
28
+ blocking_io = buffered_io,
29
+ encoding_opts: {},
30
+ external_encoding: nil,
31
+ internal_encoding: nil,
32
+ sync: false
33
+ )
34
+ raise ArgumentError, 'buffered_io cannot be nil' if buffered_io.nil?
35
+ raise ArgumentError, 'blocking_io cannot be nil' if blocking_io.nil?
36
+
37
+ @buffered_io = buffered_io
38
+ @blocking_io = blocking_io
39
+ self.sync = sync
40
+
41
+ set_encoding(external_encoding, internal_encoding, **encoding_opts)
42
+ end
43
+
44
+ attr_accessor :buffered_io
45
+ attr_accessor :blocking_io
46
+
47
+ ##
48
+ # Returns `true` if the read buffer is empty and `false` otherwise.
49
+ #
50
+ # @return [Boolean]
51
+ def buffer_empty?
52
+ return true unless readable?
53
+ character_reader.empty?
54
+ end
55
+
56
+ ##
57
+ # The external encoding of this stream.
58
+ attr_reader :external_encoding
59
+
60
+ ##
61
+ # The internal encoding of this stream. This is only used for read
62
+ # operations.
63
+ attr_reader :internal_encoding
64
+
65
+ ##
66
+ # Reads all remaining characters from the stream.
67
+ #
68
+ # @return [String] a buffer containing the characters that were read
69
+ #
70
+ # @raise [Encoding::InvalidByteSequenceError] if character conversion is being
71
+ # performed and the next sequence of bytes are invalid in the external
72
+ # encoding
73
+ # @raise [EOFError] when reading at the end of the stream
74
+ # @raise [IOError] if the stream is not readable
75
+ def read_all
76
+ read_all_internal
77
+ end
78
+
79
+ ##
80
+ # Returns the next character from the stream.
81
+ #
82
+ # @return [String] a buffer containing the character that was read
83
+ #
84
+ # @raise [EOFError] when reading at the end of the stream
85
+ # @raise [IOError] if the stream is not readable
86
+ def read_char
87
+ char = nil
88
+
89
+ begin
90
+ # The delegate's read buffer will have at least 1 byte in it at this
91
+ # point.
92
+ loop do
93
+ buffer = character_reader.content
94
+ char = buffer.force_encoding(character_reader.encoding)[0]
95
+ # Return the next character if it is valid for the encoding.
96
+ break if ! char.nil? && char.valid_encoding?
97
+ # Or if the buffer has more than 16 bytes in it, valid or not.
98
+ break if buffer.bytesize >= 16
99
+
100
+ character_reader.refill(false)
101
+ # At least 1 byte was added to the buffer, so try again.
102
+ end
103
+ rescue EOFError, IOError
104
+ # Reraise when no bytes were available.
105
+ raise if char.nil?
106
+ end
107
+
108
+ character_reader.consume(char.bytesize)
109
+ char
110
+ end
111
+
112
+ ##
113
+ # Returns the next line from the stream.
114
+ #
115
+ # @param separator [String, nil] a non-empty String that separates each
116
+ # line, an empty String that equates to 2 or more successive newlines as
117
+ # the separator, or `nil` to indicate reading all remaining data
118
+ # @param limit [Integer, nil] an Integer limiting the number of bytes
119
+ # returned in each line or `nil` to indicate no limit
120
+ # @param chomp [Boolean] when `true` trailing newlines and carriage returns
121
+ # will be removed from each line; ignored when `separator` is `nil`
122
+ #
123
+ # @return [String] a buffer containing the characters that were read
124
+ #
125
+ # @raise [EOFError] when reading at the end of the stream
126
+ # @raise [IOError] if the stream is not readable
127
+ def read_line(separator: $/, limit: nil, chomp: false, discard_newlines: false)
128
+ return read_all_internal(chomp: chomp) if ! (separator || limit)
129
+
130
+ if String === separator && separator.encoding != Encoding::BINARY
131
+ separator = separator.encode(character_reader.encoding).b
132
+ end
133
+ content = ''.b
134
+
135
+ return content.force_encoding(character_reader.encoding) if limit == 0
136
+
137
+ begin
138
+ self.discard_newlines if discard_newlines
139
+
140
+ index = nil
141
+ extra = 0
142
+ need_more = false
143
+ offset = 0
144
+ loop do
145
+ already_consumed = content.bytesize
146
+ content << character_reader.content
147
+
148
+ if separator && ! index
149
+ if Regexp === separator
150
+ match = content.match(separator, offset)
151
+ if match
152
+ index = match.end(0)
153
+ # Truncate the content to the end of the separator.
154
+ content.slice!(index..-1)
155
+ end
156
+ else
157
+ index = content.index(separator, offset)
158
+ if index
159
+ index += separator.bytesize
160
+ # Truncate the content to the end of the separator.
161
+ content.slice!(index..-1)
162
+ else
163
+ # Optimize the search that happens in the next loop iteration by
164
+ # excluding the range of bytes already searched.
165
+ offset = [0, content.bytesize - separator.bytesize + 1].max
166
+ end
167
+ end
168
+ end
169
+
170
+ if limit && content.bytesize >= limit
171
+ # Truncate the content to no more than limit + 16 bytes in order to
172
+ # ensure that the last character is not truncated at the limit
173
+ # boundary.
174
+ need_more =
175
+ loop do
176
+ last_character =
177
+ content[0, limit + extra]
178
+ .force_encoding(character_reader.encoding)[-1]
179
+ # No more bytes are needed because the last character is whole and
180
+ # valid or we hit the limit + 16 bytes hard limit.
181
+ break false if last_character.valid_encoding?
182
+ break false if extra >= 16
183
+ extra += 1
184
+ # More bytes are needed, but the end of the character buffer has
185
+ # been reached.
186
+ break true if limit + extra > content.bytesize
187
+ end
188
+
189
+ content.slice!((limit + extra)..-1) unless need_more
190
+ end
191
+
192
+ character_reader.consume(content.bytesize - already_consumed)
193
+
194
+ # The separator string was found.
195
+ break if index
196
+ # The limit was reached.
197
+ break if limit && content.bytesize >= limit && ! need_more
198
+
199
+ character_reader.refill(false)
200
+ end
201
+
202
+ self.discard_newlines if discard_newlines
203
+ rescue EOFError
204
+ raise if content.empty?
205
+ end
206
+
207
+ # Remove the separator when requested.
208
+ content.slice!(separator) if chomp && separator
209
+
210
+ content.force_encoding(character_reader.encoding)
211
+ end
212
+
213
+ ##
214
+ # Returns `true` if the stream is readable and `false` otherwise.
215
+ #
216
+ # @return [Boolean]
217
+ def readable?
218
+ return @readable if defined?(@readable) && ! @readable.nil?
219
+ @readable = buffered_io.readable?
220
+ end
221
+
222
+ ##
223
+ # Clears the state of this stream.
224
+ #
225
+ # @return [nil]
226
+ def clear
227
+ return unless @character_reader
228
+ @character_reader.clear
229
+ nil
230
+ end
231
+
232
+ ##
233
+ # Sets the external and internal encodings of the stream.
234
+ #
235
+ # @param external [Encoding, nil] the external encoding
236
+ # @param internal [Encoding, nil] the internal encoding
237
+ # @param opts [Hash] encoding conversion options used when character or
238
+ # newline conversion is performed
239
+ #
240
+ # @return [nil]
241
+ def set_encoding(external, internal, **opts)
242
+ if external.nil? && ! internal.nil?
243
+ raise ArgumentError,
244
+ 'external encoding cannot be nil when internal encoding is not nil'
245
+ end
246
+
247
+ internal = nil if internal == external
248
+
249
+ self.encoding_opts = opts
250
+ @internal_encoding = internal
251
+ @external_encoding = external
252
+ @character_reader = nil
253
+
254
+ nil
255
+ end
256
+
257
+ ##
258
+ # When set to `true` the internal write buffer will be bypassed. Any data
259
+ # currently in the buffer will be flushed prior to the next output operation.
260
+ # When set to `false`, the internal write buffer will be enabled.
261
+ #
262
+ # @param sync [Boolean] the sync mode
263
+ #
264
+ # @return [Boolean] the given value for `sync`
265
+ def sync=(sync)
266
+ @sync = sync ? true : false
267
+ end
268
+
269
+ ##
270
+ # @return [Boolean] `true` if the internal write buffer is being bypassed and
271
+ # `false` otherwise
272
+ def sync?
273
+ @sync ||= false
274
+ end
275
+
276
+ ##
277
+ # Places bytes at the beginning of the read buffer.
278
+ #
279
+ # @param buffer [String] the bytes to insert into the read buffer
280
+ # @param length [Integer] the number of bytes from the beginning of `buffer`
281
+ # to insert into the read buffer
282
+ #
283
+ # @return [nil]
284
+ #
285
+ # @raise [IOError] if the remaining space in the internal buffer is
286
+ # insufficient to contain the given data
287
+ # @raise [IOError] if the stream is not readable
288
+ def unread(buffer, length: buffer.bytesize)
289
+ length = Integer(length)
290
+ raise ArgumentError, 'length must be at least 0' if length < 0
291
+
292
+ assert_readable
293
+
294
+ character_reader(length).unread(buffer.b, length: length)
295
+ end
296
+
297
+ ##
298
+ # Returns `true` if the stream is writable and `false` otherwise.
299
+ #
300
+ # @return [Boolean]
301
+ def writable?
302
+ return @writable if defined?(@writable) && ! @writable.nil?
303
+ @writable = buffered_io.writable?
304
+ end
305
+
306
+ ##
307
+ # Writes characters to the stream, performing character and newline conversion
308
+ # first if necessary.
309
+ #
310
+ # This method always blocks until all data is written.
311
+ #
312
+ # @param buffer [String] the characters to write
313
+ #
314
+ # @return [Integer] the number of bytes written, after conversion
315
+ #
316
+ # @raise [IOError] if the stream is not writable
317
+ def write(buffer)
318
+ assert_writable
319
+
320
+ target_encoding = external_encoding
321
+ if target_encoding.nil? || target_encoding == Encoding::BINARY
322
+ target_encoding = buffer.encoding
323
+ end
324
+ if target_encoding != buffer.encoding || ! encoding_opts_w.empty?
325
+ buffer = buffer.encode(target_encoding, **encoding_opts_w)
326
+ end
327
+
328
+ writer = sync? ? blocking_io : buffered_io
329
+ buffer = buffer.b
330
+ bytes_written = 0
331
+ while bytes_written < buffer.bytesize do
332
+ bytes_written += writer.write(buffer[bytes_written..-1])
333
+ end
334
+ bytes_written
335
+ end
336
+
337
+ private
338
+
339
+ ##
340
+ # Raises an exception if the stream is not open for reading.
341
+ #
342
+ # @return [nil]
343
+ #
344
+ # @raise IOError if the stream is not open for reading
345
+ def assert_readable
346
+ raise IOError, 'not opened for reading' unless readable?
347
+ end
348
+
349
+ ##
350
+ # Raises an exception if the stream is not open for writing.
351
+ #
352
+ # @return [nil]
353
+ #
354
+ # @raise IOError if the stream is not open for writing
355
+ def assert_writable
356
+ raise IOError, 'not opened for writing' unless writable?
357
+ end
358
+
359
+ ##
360
+ # @param buffer_size [Integer, nil] the size of the internal character buffer;
361
+ # ignored unless character or newline conversion will be performed
362
+ #
363
+ # @return [BasicReader, ConverterReader] a character reader based on the
364
+ # external encoding, internal encoding, and universal newline settings of
365
+ # this stream
366
+ def character_reader(buffer_size = nil)
367
+ return @character_reader if @character_reader
368
+
369
+ # Hack the internal encoding to be the default internal encoding when:
370
+ # 1. Ruby is less than version 3.3 (for compatibility)
371
+ # 2. The internal encoding is not set explicitly
372
+ # 3. Character conversion would be necessary with it set
373
+ internal_encoding = self.internal_encoding
374
+ if RBVER_LT_3_3 &&
375
+ ! internal_encoding &&
376
+ external_encoding != Encoding::BINARY &&
377
+ external_encoding != Encoding.default_internal
378
+ internal_encoding = Encoding.default_internal
379
+ end
380
+
381
+ @character_reader = if external_encoding &&
382
+ (internal_encoding || universal_newline?)
383
+ ConverterReader.new(
384
+ buffered_io,
385
+ buffer_size: buffer_size,
386
+ encoding_opts: encoding_opts_r,
387
+ external_encoding: external_encoding,
388
+ internal_encoding: internal_encoding
389
+ )
390
+ else
391
+ BasicReader.new(
392
+ buffered_io,
393
+ encoding: external_encoding
394
+ )
395
+ end
396
+ end
397
+
398
+ ##
399
+ # Consumes 1 or more consecutive newline characters from the beginning of the
400
+ # stream.
401
+ #
402
+ # @return [nil]
403
+ def discard_newlines
404
+ newline = "\n".dup
405
+ if RBVER_LT_3_4
406
+ newline.encode!(internal_encoding) if internal_encoding
407
+ else
408
+ newline.encode!(character_reader.encoding)
409
+ end
410
+ newline.force_encoding(Encoding::BINARY)
411
+ begin
412
+ loop do
413
+ # Consume bytes matching the newline character from the beginning of the
414
+ # buffer.
415
+ while character_reader.content.start_with?(newline) do
416
+ character_reader.consume(newline.bytesize)
417
+ end
418
+
419
+ # Stop when adding more bytes to the buffer could not possibly complete
420
+ # the newline character.
421
+ break unless newline.start_with?(character_reader.content)
422
+
423
+ # This will stop the loop by raising EOFError if there are no more
424
+ # bytes.
425
+ character_reader.refill
426
+ end
427
+ rescue EOFError
428
+ # Stop when there are no more bytes to read from the stream.
429
+ end
430
+
431
+ nil
432
+ end
433
+
434
+ ##
435
+ # Creates an instance of this class that copies state from `other`.
436
+ #
437
+ # @param other [CharacterIO] the instance to copy
438
+ #
439
+ # @return [nil]
440
+ #
441
+ # @raise [IOError] if `other` is closed
442
+ def initialize_copy(other)
443
+ super
444
+
445
+ @character_reader = nil
446
+
447
+ nil
448
+ end
449
+
450
+ ##
451
+ # Sets the encoding options.
452
+ #
453
+ # @return _opts_
454
+ def encoding_opts=(opts)
455
+ if opts.key?(:newline) &&
456
+ ! %i{universal crlf cr lf}.include?(opts[:newline])
457
+ raise ArgumentError, "unexpected value for newline option: #{opts[:newline]}"
458
+ end
459
+
460
+ # Ruby ignores xml conversion as well as newline decorators other than
461
+ # universal for reading.
462
+ @encoding_opts_r = opts.reject do |k, v|
463
+ k == :xml ||
464
+ k == :crlf_newline || k == :cr_newline || k == :lf_newline ||
465
+ (k == :newline && (v == :crlf || v == :cr || v == :lf))
466
+ end
467
+
468
+ # Ruby ignores the universal newline decorator for writing.
469
+ @encoding_opts_w = opts.reject do |k, v|
470
+ k == :universal_newline || (k == :newline && v == :universal)
471
+ end
472
+
473
+ opts
474
+ end
475
+
476
+ def universal_newline?
477
+ encoding_opts_r[:newline] ?
478
+ encoding_opts_r[:newline] == :universal :
479
+ !!encoding_opts_r.fetch(:universal_newline, false)
480
+ end
481
+
482
+ ##
483
+ # The encoding options for reading.
484
+ attr_reader :encoding_opts_r
485
+
486
+ ##
487
+ # The encoding options for writing.
488
+ attr_reader :encoding_opts_w
489
+
490
+ ##
491
+ # Reads all remaining characters from the stream. This exists only to handle
492
+ # chomp behavior on Ruby < 3.2 without exposing that interface publicly.
493
+ #
494
+ # @todo Move this method implementation to #read_all when Ruby < 3.2 support
495
+ # is dropped.
496
+ #
497
+ # @param chomp [Boolean] performs a chomp on the content when `true` on Ruby <
498
+ # 3.2; otherwise, ignored
499
+ #
500
+ # @return [String] a buffer containing the characters that were read
501
+ #
502
+ # @raise [Encoding::InvalidByteSequenceError] if character conversion is being
503
+ # performed and the next sequence of bytes are invalid in the external
504
+ # encoding
505
+ # @raise [EOFError] when reading at the end of the stream
506
+ # @raise [IOError] if the stream is not readable
507
+ def read_all_internal(chomp: false)
508
+ content = ''.b
509
+ begin
510
+ loop do
511
+ already_consumed = content.bytesize
512
+ content << character_reader.content
513
+ character_reader.consume(content.bytesize - already_consumed)
514
+ character_reader.refill
515
+ end
516
+ rescue EOFError
517
+ raise if content.empty?
518
+ end
519
+
520
+ # HACK:
521
+ # A default chomp is performed on Ruby <3.2 when chomp is requested.
522
+ content.chomp! if RBVER_LT_3_2 && chomp
523
+
524
+ content.force_encoding(character_reader.encoding)
525
+ end
526
+ end
527
+ end; end
528
+
529
+ # vim: ts=2 sw=2 et