# frozen-string-literal: true require "tempfile" require "fiber" module Down class ChunkedIO attr_accessor :size, :data, :encoding def initialize(chunks:, size: nil, on_close: nil, data: {}, rewindable: true, encoding: nil) @chunks = chunks @size = size @on_close = on_close @data = data @encoding = find_encoding(encoding || Encoding::BINARY) @rewindable = rewindable @buffer = nil retrieve_chunk end def each_chunk raise IOError, "closed stream" if closed? return enum_for(__method__) if !block_given? yield retrieve_chunk until chunks_depleted? end def read(length = nil, outbuf = nil) raise IOError, "closed stream" if closed? remaining_length = length begin data = readpartial(remaining_length, outbuf) data = data.dup unless outbuf remaining_length = length - data.bytesize if length rescue EOFError end until remaining_length == 0 || eof? data << readpartial(remaining_length) remaining_length = length - data.bytesize if length end data.to_s unless length && (data.nil? || data.empty?) end def readpartial(length = nil, outbuf = nil) raise IOError, "closed stream" if closed? data = outbuf.replace("").force_encoding(@encoding) if outbuf if cache && !cache.eof? data = cache.read(length, outbuf) data.force_encoding(@encoding) end if @buffer.nil? && (data.nil? || data.empty?) raise EOFError, "end of file reached" if chunks_depleted? @buffer = retrieve_chunk end remaining_length = data && length ? length - data.bytesize : length unless @buffer.nil? || remaining_length == 0 buffered_data = if remaining_length && remaining_length < @buffer.bytesize @buffer.byteslice(0, remaining_length) else @buffer end if data data << buffered_data else data = buffered_data end cache.write(buffered_data) if cache if buffered_data.bytesize < @buffer.bytesize @buffer = @buffer.byteslice(buffered_data.bytesize..-1) else @buffer = nil end end data end def eof? raise IOError, "closed stream" if closed? return false if cache && !cache.eof? @buffer.nil? && chunks_depleted? end def rewind raise IOError, "closed stream" if closed? raise IOError, "this Down::ChunkedIO is not rewindable" if cache.nil? cache.rewind end def close return if @closed chunks_fiber.resume(:terminate) if chunks_fiber.alive? @buffer = nil cache.close! if cache @closed = true end def closed? !!@closed end def rewindable? @rewindable end def inspect string = String.new string << "#<Down::ChunkedIO" string << " chunks=#{@chunks.inspect}" string << " size=#{size.inspect}" string << " encoding=#{encoding.inspect}" string << " data=#{data.inspect}" string << " on_close=#{@on_close.inspect}" string << " rewindable=#{@rewindable.inspect}" string << " (closed)" if closed? string << ">" end private def cache @cache ||= Tempfile.new("down-chunked_io", binmode: true) if @rewindable end def retrieve_chunk chunk = @next_chunk @next_chunk = chunks_fiber.resume chunk.force_encoding(@encoding) if chunk end def chunks_depleted? !chunks_fiber.alive? end def chunks_fiber @chunks_fiber ||= Fiber.new do begin @chunks.each do |chunk| action = Fiber.yield chunk break if action == :terminate end ensure @on_close.call if @on_close end end end def find_encoding(encoding) Encoding.find(encoding) rescue ArgumentError Encoding::BINARY end end end