Initial import master
authorTim Pope <code@tpope.net>
Tue, 17 Oct 2006 20:39:08 +0000 (20:39 +0000)
committerTim Pope <code@tpope.net>
Tue, 17 Oct 2006 20:39:08 +0000 (20:39 +0000)
.gitignore [new file with mode: 0644]
Rakefile [new file with mode: 0644]
lib/io/mixins.rb [new file with mode: 0644]
test/io_loop.rb [new file with mode: 0644]
test/io_mixins_test.rb [new file with mode: 0755]
test/read_joiner.rb [new file with mode: 0644]
test/rot13_filter.rb [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..7bbf483
--- /dev/null
@@ -0,0 +1,3 @@
+/doc
+/pkg
+/coverage
diff --git a/Rakefile b/Rakefile
new file mode 100644 (file)
index 0000000..bf70634
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,117 @@
+begin
+  require 'rubygems'
+rescue LoadError
+end
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+require 'rake/packagetask'
+require 'rake/gempackagetask'
+require 'rake/contrib/sshpublisher'
+# require 'rake/contrib/rubyforgepublisher'
+require File.join(File.dirname(__FILE__), 'lib', 'io', 'mixins')
+
+PKG_BUILD     = ENV['PKG_BUILD'] ? '.' + ENV['PKG_BUILD'] : ''
+PKG_NAME      = 'io-mixins'
+PKG_VERSION   = IO::MIXINS_VERSION
+PKG_FILE_NAME   = "#{PKG_NAME}-#{PKG_VERSION}"
+# PKG_DESTINATION = ENV["PKG_DESTINATION"] || "../#{PKG_NAME}"
+
+# RELEASE_NAME  = "REL #{PKG_VERSION}"
+
+RUBY_FORGE_PROJECT = PKG_NAME
+RUBY_FORGE_USER    = "tpope"
+
+desc "Default task: test"
+task :default => [ :test ]
+
+
+# Run the unit tests
+Rake::TestTask.new { |t|
+  t.libs << "test"
+  t.test_files = Dir['test/*_test.rb'] + Dir['test/test_*.rb']
+  t.verbose = true
+}
+
+
+# Generate the RDoc documentation
+Rake::RDocTask.new { |rdoc|
+  rdoc.rdoc_dir = 'doc'
+  rdoc.rdoc_files.add('lib')
+  rdoc.main     = "IO"
+  rdoc.title    = "IO Mixins"
+  rdoc.options << '--inline-source'
+  rdoc.options << '-d' if `which dot` =~ /\/dot/
+}
+
+desc "Generate the RDoc documentation for RI"
+task :ri do
+  system("rdoc","--ri","lib")
+end
+
+
+
+# Create compressed packages
+spec = Gem::Specification.new do |s|
+  s.platform = Gem::Platform::RUBY
+  s.name = PKG_NAME
+  s.summary = 'Pure Ruby IO Mixins'
+  s.description = 'IO::Readable, IO::Writable, and IO::Seekable'
+  s.version = PKG_VERSION
+
+  s.author = 'Tim Pope'
+  s.email = 'r*by@tpope.in#o'.tr('*#','uf')
+  s.rubyforge_project = RUBY_FORGE_PROJECT
+
+  s.has_rdoc = true
+  # s.requirements << 'none'
+  s.require_path = 'lib'
+  # s.autorequire = 'action_web_service'
+
+  s.files = [ "Rakefile", "setup.rb" ]
+  s.files = s.files + Dir.glob( "lib/**/*.rb" )
+  s.files = s.files + Dir.glob( "test/**/*" ).reject { |item| item.include?( "\.svn" ) }
+end
+
+Rake::GemPackageTask.new(spec) do |p|
+  p.gem_spec = spec
+  p.need_tar = true
+  p.need_zip = true
+end
+
+
+# Publish beta gem
+desc "Publish the gem"
+task :pgem => [:package] do
+  Rake::SshFilePublisher.new("tpope#{'@'}tpope.us", "public_html/gems", "pkg", "#{PKG_FILE_NAME}.gem").upload
+  # `ssh tpope@tpope.us './gemupdate.sh'`
+end
+
+# Publish documentation
+desc "Publish the API documentation"
+task :pdoc => [:rdoc] do
+  Rake::SshDirPublisher.new("tpope#{'@'}tpope.us", "public_html/#{PKG_NAME}", "doc").upload
+end
+
+# desc "Publish the release files to RubyForge."
+# task :release => [ :package ] do
+  # `rubyforge login`
+
+  # for ext in %w( gem tgz zip )
+    # release_command = "rubyforge add_release #{PKG_NAME} #{PKG_NAME} 'REL #{PKG_VERSION}' pkg/#{PKG_NAME}-#{PKG_VERSION}.#{ext}"
+    # puts release_command
+    # system(release_command)
+  # end
+# end
+
+begin
+  require 'rcov/rcovtask'
+  Rcov::RcovTask.new do |t|
+    t.test_files = Dir['test/*_test.rb'] + Dir['test/test_*.rb']
+    t.verbose = true
+    # t.rcov_opts << "--text-report"
+    # t.rcov_opts << "--exclude \\\\A/var/lib/gems"
+    t.rcov_opts << "--exclude '/(active_record|active_support)\\b'"
+  end
+rescue LoadError
+end
diff --git a/lib/io/mixins.rb b/lib/io/mixins.rb
new file mode 100644 (file)
index 0000000..6dad9a9
--- /dev/null
@@ -0,0 +1,659 @@
+# Methods common to IO::Readable, IO::Writable, and IO::Seekable.
+module IO::Common
+
+  # Calls both +close_read+ and +close_write+, and raises an exception unless
+  # at least one succeeds.  It may be desirable to reimplement this.
+  def close
+    cr = respond_to?(:close_read)
+    cw = respond_to?(:close_write)
+    if cr && cw
+      failed = false
+      begin
+        close_read
+      rescue IOError
+        failed = $!
+      end
+      begin
+        close_write
+      rescue IOError
+        return unless failed
+        raise
+      end
+    elsif cr
+      close_read
+    elsif cw
+      close_write
+    else
+      raise NotImplementedError, "close_read and close_write unimplemented", caller
+    end
+  end
+
+  # Checks both +closed_read+? and +closed_write+?, raising an exception if
+  # neither method exists.  This method must be redefined for IO::Readable
+  # and IO::Writable classes unless one of the previously mentioned methods
+  # has been provided.
+  def closed?
+    cr = respond_to?(:closed_read?)
+    cw = respond_to?(:closed_write?)
+    if cr && cw
+      return closed_read? && closed_write?
+    elsif cr
+      return closed_read?
+    elsif cw
+      return closed_write?
+    else
+      raise NotImplementedError, "closed_read? and closed_write? unimplemented", caller
+    end
+  end
+
+  # Do not override this method.  Instead, use +isatty+.
+  def tty?
+    isatty
+  end
+
+  # Always +false+.  May be redefined.
+  def isatty
+    false
+  end
+
+  # Always 0.  May be redefined.
+  def fsync
+    0
+  end
+
+  # Always +true+. May be redefined.
+  def sync
+    true
+  end
+
+  # Raises a +NotImplementedError+.  May be redefined.
+  def fcntl(*args)
+    raise NotImplementedError, "fcntl not implemented", caller
+  end
+
+  # Always returns the object.  May be redefined.
+  def binmode
+    self
+  end
+
+  [ :fileno, :pid, :sync=, :path ].each do |method|
+    define_method(method) {nil}
+  end
+
+end
+
+# The IO::Readable mixin provides classes with several methods for reading
+# a data stream, similar to those found in the IO class.  The +sysread+ and
+# +close_read+ methods must be defined.  Additionally, <tt>closed_read?</tt>,
+# <tt>closed?</tt>, and <tt>close</tt> are recommended.
+#
+# IO::Readable provides buffering, but only the absolute minimum needed to
+# provide a complete implementation.  For example, #eof checks for the end of
+# file by reading and buffering a single byte.
+#
+# The example below illustrates the use of IO::Readable in a class that
+# concatenates multiple streams.  Note that seeking is not supported but one
+# can start over with #rewind.
+#
+#   :include:../../test/read_joiner.rb
+module IO::Readable
+
+  REQUIRED_METHODS = [ :sysread, :close_read ]
+
+  include IO::Common
+  include Enumerable
+
+  # This adds three identically named class methods that function like
+  # IO::read, IO::readlines, and IO::foreach.  These may be factored into
+  # IO::Openable or removed entirely in a future release.
+  def self.included(base) #:nodoc:
+    unless base.respond_to?(:read)
+      def base.read(name,length=nil,offset=nil)
+        name = [name] unless name.respond_to?(:to_ary)
+        object = new(*name)
+        object.seek(offset) if offset
+        object.read(length)
+      ensure
+        object.close_read if object.respond_to?(:close_read)
+      end
+    end
+    unless base.respond_to?(:readlines)
+      def base.readlines(name,sep_string=$/)
+        name = [name] unless name.respond_to?(:to_ary)
+        object = new(*name)
+        object.readlines(sep_string)
+      ensure
+        object.close_read if object.respond_to?(:close_read)
+      end
+    end
+    unless base.respond_to?(:foreach)
+      def base.foreach(*args,&block)
+        readlines(*args).each(&block)
+      end
+    end
+    super
+  end
+
+  # As with with IO#gets, read and return a line, with lines separated by
+  # +sep_string+.  This method may be overridden, although this is not
+  # recommended unless one is intimately familiar with the required behaviors
+  # and side effects.
+  def gets(sep_string=$/)
+    return read(nil) unless sep_string
+    line = ""
+    paragraph = false
+    if sep_string == ""
+      sep_string = "\n\n"
+      paragraph = true
+    end
+    sep = sep_string.dup
+    position = @_iom_pos
+    while (char = getc)
+      if paragraph && line.empty?
+        if char == ?\n
+          next
+        # else
+          # paragraph = false
+        end
+      end
+      if char == sep[0]
+        sep[0] = ""
+      else
+        sep = sep_string.dup
+      end
+      if sep == ""
+        if paragraph
+          ungetc char
+        else
+          line << char
+        end
+        break
+      end
+      line << char
+      if position && @_iom_pos == position
+        raise IOError, "loop encountered", caller
+      end
+    end
+    line = nil if line == ""
+    if line && respond_to?(:lineno=) && respond_to?(:lineno)
+      self.lineno += 1
+      $. = lineno
+    end
+    $_ = line
+  end
+
+  # This method returns the number of times IO::Readable#gets was called.
+  # The count is reset on calls to IO::Seekable#rewind.
+  def lineno
+    @_iom_lineno ||= 0
+  end
+
+  # This method and +lineno+ must be redefined together, or not at all.
+  def lineno=(num)
+    @_iom_lineno = num
+  end
+
+  # Like IO#read, read up to +length+ bytes and return them, optionally
+  # assigning them to +buffer+ as well.
+  def read(length = nil,buffer = "")
+    raise ArgumentError, "negative length #{length} given", caller if (length||0) < 0
+    return "" if length == 0 && (@_iom_buffer.nil? || @_iom_buffer.length > 0)
+    return (length ? nil : "") if eof
+    return "" if length == 0
+    @_iom_buffer ||= ""
+    if length
+      begin
+        @_iom_buffer << sysread(length-@_iom_buffer.length) if @_iom_buffer.length<length
+      # rescue EOFError
+      end
+    else
+      begin
+        while str = sysread(1024)
+          @_iom_buffer << str
+        end
+      rescue EOFError
+        nil # For coverage
+      end
+    end
+    buffer[0..-1] = @_iom_buffer.slice!(0..(length || 0)-1)
+    @_iom_pos ||= 0
+    @_iom_pos += buffer.length
+    return buffer
+  end
+
+  # See IO#getc.  Implemented via #read.
+  def getc
+    read(1).to_s[0]
+  end
+
+  # See IO#ungetc.
+  def ungetc(char)
+    raise IOError, "unread stream", caller unless @_iom_buffer
+    @_iom_buffer[0,0] = char.chr
+    @_iom_pos -= 1
+    nil
+  end
+
+  # See IO#eof.
+  def eof
+    # tell if respond_to?(:tell)
+    @_iom_buffer ||= ""
+    return false unless @_iom_buffer.empty?
+    str = sysread(1)
+    if str
+      @_iom_buffer << str
+      @_iom_buffer.empty?
+    else
+      true
+    end
+  rescue EOFError
+    return true
+  end
+
+  # Reads a character as with IO#getc, but raises an +EOFError+ on end of
+  # file.  Implemented via #getc.
+  def readchar()
+    getc or raise EOFError, "end of file reached", caller
+  end
+
+  # Reads a character as with #gets, but raises an +EOFError+ on
+  # end of file.  Implemented via #gets.
+  def readline(sep_string = $/)
+    gets(sep_string) or raise EOFError, "end of file reached", caller
+  end
+
+  # See IO#readlines.  Implemented via #gets.
+  def readlines(sep_string = $/)
+    array = []
+    line = nil
+    array << line while line = gets(sep_string)
+    array
+  end
+
+  # See IO#each_byte.  Implemented via #getc.
+  def each_byte
+    while byte = getc
+      yield byte
+    end
+    self
+  end
+
+  # See IO#each_line.  Implemented via #gets.
+  def each_line(sep_string = $/)
+    while line = gets(sep_string)
+      yield line
+    end
+    self
+  end
+
+  alias each each_line
+
+  # See IO#eof?.  Implemented via #eof.
+  def eof?
+    eof
+  end
+
+end
+
+# The IO::Writable mixin provides classes with several methods for writing
+# a data stream, similar to those found in the IO class.  The methods
+# +syswrite+ and +close_write+ must be provided.  Additionally,
+# <tt>closed_write?</tt>, <tt>closed?</tt>, and +close+ are recommended.
+#
+# The following example illustrates the use of IO::Readable, IO::Writable, and
+# IO::Seekable.  It wraps around another stream and applies rot13 to the
+# contents.
+#
+#   :include:../../test/rot13_filter.rb
+module IO::Writable
+
+  REQUIRED_METHODS = [ :syswrite, :close_write ]
+  include IO::Common
+
+  # See IO#write.
+  def write(string)
+    @_iom_pos ||= 0
+    @_iom_pos += string.to_s.length
+    @_iom_buffer.slice!(0,string.to_s.length) if @_iom_buffer
+    syswrite(string.to_s)
+  end
+
+  # See IO#<<.  Implemented via #write.
+  def <<(string)
+    # write(string.respond_to?(:to_int) ? string.to_int.chr : string)
+    write(string)
+    self
+  end
+
+  # See IO#print.  Implemented via #write.
+  def print(obj = $_,*args)
+    ([obj]+args).each do |line|
+      write(line + ($\||''))
+    end
+    nil
+  end
+
+  # See IO#puts.  Implemented via #write.
+  def puts(obj = "\n",*args)
+    [obj,args].flatten.each do |line|
+      line = "nil" if line.nil?
+      write(line.to_s + (line.to_s[-1] == ?\n ? "" : "\n"))
+    end
+    nil
+  end
+
+  # See IO#printf.  Implemented via #write.
+  def printf(first="",*rest)
+    write(sprintf(first,*rest))
+    nil
+  end
+
+  # See IO#putc.  Implemented via #write
+  def putc(char)
+    if char.respond_to?(:to_int)
+      write((char.to_int & 0xFF).chr)
+    elsif char.respond_to?(:to_str) && char.to_str.length > 0
+      write(char.to_str[0,1])
+    else
+      raise TypeError, "can't convert #{char.class} to Integer", caller
+    end
+    char
+  end
+
+  # Returns +self+.  May be redefined.
+  def flush
+    self
+  end
+
+end
+
+# The IO::Seekable mixin provides classes with several methods for seeking
+# within a data stream, similar to those found in the IO class.  The method
+# +sysseek+ must be provided.  This method should operate identically to
+# IO#sysseek, seeking to the desired location and returning the final
+# absolute offset.  One should also consider optimizing for
+# <tt>sysseek(0,IO::SEEK_CUR)</tt>.  This particular call should simply return
+# the position in the stream.
+#
+# For an example implementation, see IO::Writable.
+module IO::Seekable
+
+  REQUIRED_METHODS = [ :sysseek ]
+  include IO::Common
+
+  # See IO#pos.  Implemented via #tell.
+  def pos
+    tell
+  end
+
+  # See IO#pos=.  Implemented via #seek.
+  def pos=(pos)
+    seek(pos)
+    tell
+  end
+
+  # See IO#rewind.  Calls both #seek and IO::Readable#lineno=, if included.
+  def rewind
+    seek(0)
+    self.lineno = 0 if respond_to?(:lineno=)
+    0
+  end
+
+  # See IO#seek.  This method calls #sysseek and only should be overridden in a
+  # class that is taking responsibility for tracking its own position.  If this
+  # is the case, the class should also override #seek, as well as
+  # IO::Readable#read, IO::Readable#ungetc, IO::Readable#getc,
+  # IO::Readable#eof, IO::Writable#write, and IO::Writable#putc, if the
+  # respective modules are included.
+  def seek(amount, whence = IO::SEEK_SET)
+    if whence == IO::SEEK_CUR && @_iom_buffer
+      amount -= @_iom_buffer.length
+    end
+    @_iom_pos ||= 0
+    @_iom_pos += @_iom_buffer.length if @_iom_buffer
+    @_iom_buffer = nil if @_iom_buffer
+    @_iom_pos = sysseek(amount, whence)
+    0
+  end
+
+  # See IO#tell.  See #seek for information on overriding this method.
+  def tell
+    @_iom_pos ||= 0
+  end
+
+end
+
+# The IO::Closable mixin works with IO::Readable, IO::Writable, and
+# IO::Seekable.  It provides #closed_read?, #closed_write?, #close_read, and
+# #close_write methods, and wraps the required methods for the aforementioned
+# modules such that they raise an exception if applied to a closed object.
+# You must include _at_ _least_ one of IO::Readable or IO::Writable for this
+# mixin to be useful.
+#
+# This mixin is highly experimental.  It currently works by trapping
+# method_added and wrapping the newly defined method.
+module IO::Closable
+  WRAPPED_READ_METHODS = [ :sysread, :getc, :ungetc, :eof, :read, :gets ]
+  WRAPPED_WRITE_METHODS = [ :syswrite, :putc, :write ]
+  WRAPPED_BOTH_METHODS = [ :sysseek, :tell, :seek, :reopen, :pos, :pos= ]
+  WRAPPED_METHODS = WRAPPED_READ_METHODS + WRAPPED_WRITE_METHODS + WRAPPED_BOTH_METHODS
+
+  # Either #close or #close_read was called.
+  def closed_read?
+    !respond_to?(:sysread) || !!@_closed_read
+  end
+
+  # Either #close or #close_write was called
+  def closed_write?
+    !respond_to?(:syswrite) || !!@_closed_write
+  end
+
+  # Object is closed for both reading and writing
+  def closed?
+    closed_read? && closed_write?
+  end
+
+  # Wrap an existing method in with a prerequisite that the file must not be
+  # closed.
+  #
+  # wrap_method_for_close(:printf, :closed_write?)
+  def self.wrap_method_for_close(method,condition = :closed?) #:nodoc:
+    class_eval(<<-EOF,__FILE__,__LINE__)
+    def with_io_sneakiness_do_#{method}(*args,&block)
+      raise IOError, "stream closed", caller if #{condition}
+      send(:without_io_sneakiness_do_#{method},*args,&block)
+    end
+    EOF
+    private "with_io_sneakiness_do_#{method}"
+  end
+
+  WRAPPED_READ_METHODS.each do |method|
+    wrap_method_for_close(method,:closed_read?)
+  end
+
+  WRAPPED_WRITE_METHODS.each do |method|
+    wrap_method_for_close(method,:closed_write?)
+  end
+
+  WRAPPED_BOTH_METHODS.each do |method|
+    wrap_method_for_close(method,:closed?)
+  end
+
+  def with_io_sneakiness_do_reopen(*args, &block) #:nodoc:
+    @_closed_read = nil
+    @_closed_write = nil
+    without_io_sneakiness_do_reopen(*args,&block)
+  end
+
+  # After this method is called, future read operations will fail, and
+  # #closed_read? will read return true.  If this method is redefined, it
+  # will be transparently wrapped to preserve this behavior.
+  def close_read(*args,&block)
+    closed_io_error if closed_read?
+    @_closed_read = true
+    without_io_sneakiness_do_close_read(*args,&block) if respond_to?(:without_io_sneakiness_do_close_read)
+    without_io_sneakiness_do_close(*args,&block) if closed_write? && respond_to?(:without_io_sneakiness_do_close)
+  end
+
+  # After this method is called, future write operations will fail, and
+  # #closed_write? will write return true.  If this method is redefined, it
+  # will be transparently wrapped to preserve this behavior.
+  def close_write(*args,&block)
+    closed_io_error if closed_write?
+    @_closed_write = true
+    without_io_sneakiness_do_close_write(*args,&block) if respond_to?(:without_io_sneakiness_do_close_write)
+    without_io_sneakiness_do_close(*args,&block) if closed_read? && respond_to?(:without_io_sneakiness_do_close)
+  end
+
+  # Calls both close_read and close_write.
+  def close(*args,&block)
+    closed_io_error if closed?
+    close_read(*args,&block) unless closed_read?
+    close_write(*args,&block) unless closed_write?
+  end
+
+  [:close_read, :close_write, :close].each do |method|
+    alias_method("with_io_sneakiness_do_#{method}", method)
+  end
+
+
+  def self.included(base) #:nodoc:
+    # p base.instance_methods.sort
+    WRAPPED_METHODS.each do |method|
+      if method_defined?(method) && !private_method_defined?(without) #&& private_method_defined?(with)
+        base.send(:alias_method, without, method)
+        base.send(:alias_method, method, with)
+        base.send(:private, with, without)
+        base.send(:public, method)
+      end
+    end
+    ::IO::Sneakiness.extend_into(base)
+end
+
+  private
+  def closed_io_error
+    raise IOError, "stream closed", caller[1..-1]
+  end
+
+end
+
+# The IO::Openable mixin is a set of _class_ methods concerning opening a
+# stream.  As such, it should be +extend+ed, not +include+d into a class.
+#
+#   class MyIO
+#     include IO::Readable
+#     include IO::Writable
+#     include IO::Seekable
+#     extend  IO::Openable
+#     # ...
+#   end
+module IO::Openable
+  # Currently the only method, this method creates a new object, and yields
+  # it if a block is given, in a manner similar to IO::open.
+  def open(*args)
+    io = new(*args)
+    if block_given?
+      begin
+        yield io
+      ensure
+        io.close
+      end
+    else
+      io
+    end
+  end
+end
+
+# The IO::Editable mixin allows for full emulation of the IO class by
+# including IO::Readable, IO::Writable, IO::Seekable, and IO::Closable.  The
+# following is a partial implementation of a looped IO class which writes to
+# its own input.
+#
+#   :include:../../test/io_loop.rb
+module IO::Editable
+  REQUIRED_METHODS = IO::Readable::REQUIRED_METHODS + IO::Writable::REQUIRED_METHODS + IO::Seekable::REQUIRED_METHODS
+  if false # for rdoc
+    include Readable
+    include Writable
+    include Seekable
+    include Closable
+  end
+
+  # Adds an +open+ class method which acts like IO::open.
+  def self.included(base)
+    unless base.respond_to?(:open)
+      base.extend(IO::Openable)
+      [ IO::Readable, IO::Writable, IO::Seekable, IO::Closable ].each do |mod|
+        base.send(:include,mod)
+        # mod.send(:included,base)
+      end
+    end
+    super
+  end
+end
+
+# We nest these in IO because otherwise, :nodoc: is ignored.
+class IO #:nodoc:
+
+  MIXINS_VERSION = '0.3'
+  module Sneakiness #:nodoc:
+    def method_added(id)
+      method = id.id2name
+      with = "with_io_sneakiness_do_#{method}"
+      without = "without_io_sneakiness_do_#{method}"
+      if !@io_sneakiness_disabled && (method_defined?(with) || private_method_defined?(with))
+        alias_method without, method
+        disable_sneakiness do
+          alias_method method, with
+          private with, without
+          public  method
+        end
+      end
+      method_added_without_io_sneakiness(method)
+    end
+
+    def self.extend_into(base)
+      unless base.respond_to?(:method_added_without_io_sneakiness)
+        class << base
+          alias_method :method_added_without_io_sneakiness, :method_added
+        end
+        base.extend(self)
+      end
+    end
+
+    protected
+    def disable_sneakiness
+      old_crit = Thread.critical
+      Thread.critical = true
+      @io_sneakiness_disabled = true
+      yield
+    ensure
+      @io_sneakiness_disabled = false
+      Thread.critical = old_crit if Thread.critical != old_crit
+    end
+
+  end
+
+  # Deprecated.
+  module Simple #:nodoc:
+    include IO::Editable
+
+    def sysread(length)
+      return "" if length == 0
+      @_simple_buffer ||= ""
+      while @_simple_buffer.length < length && chunk = (readchunk rescue nil)
+        break if chunk == ""
+        @_simple_buffer << chunk
+      end
+      out = @_simple_buffer.slice!(0,length)
+      if out.length == 0
+        raise EOFError, "end of file reached", caller
+      else
+        out
+      end
+    end
+
+  end
+end
+
+# end
diff --git a/test/io_loop.rb b/test/io_loop.rb
new file mode 100644 (file)
index 0000000..5957ece
--- /dev/null
@@ -0,0 +1,61 @@
+require 'io/mixins'
+class IOLoop
+
+  include IO::Editable
+
+  attr_reader :string
+
+  def initialize(string = "")
+    @string = string
+    @pos = 0
+  end
+
+  def sysseek(offset, whence = IO::SEEK_SET)
+    @pos = case whence
+           when IO::SEEK_SET then offset
+           when IO::SEEK_CUR then @pos + offset
+           when IO::SEEK_END then @string.length + offset
+           end % @string.size
+  end
+
+  # def eof
+    # @string.nil? || @string.empty?
+  # end
+
+  def putchar(char)
+    @string[@pos,1] = char.respond_to?(:chr) ? char.chr : char[0,0]
+    @pos += 1
+    @pos %= @string.length
+    char
+  end
+  private :putchar
+
+  def syswrite(string)
+    string.each_byte do |byte|
+      putchar(byte)
+    end
+  end
+
+  def sysread(length)
+    raise IOError if @string.length < length
+    if length <= @string.length - @pos
+      out = @string[@pos,length]
+      @pos += out.length
+      @pos %= @string.length
+      return out
+    else
+      out = @string[@pos..-1]
+      @pos = 0
+      out << @string[0,length-out.length]
+    end
+  end
+
+  private
+  # def ensure_open
+    # raise IOError, "closed stream" if closed?
+  # end
+
+  alias close_read close
+  alias close_write close
+
+end
diff --git a/test/io_mixins_test.rb b/test/io_mixins_test.rb
new file mode 100755 (executable)
index 0000000..f43268b
--- /dev/null
@@ -0,0 +1,304 @@
+#!/usr/bin/ruby
+# $Id$
+# -*- ruby -*- vim:set ft=ruby et sw=2 sts=2:
+
+require 'io/mixins'
+require 'stringio'
+require 'forwardable'
+require 'test/unit'
+
+class SimpleIO
+  include IO::Readable
+  include IO::Writable
+  include IO::Seekable
+  extend  IO::Openable
+  extend  Forwardable
+  def initialize(*args)
+    @string = StringIO.new(*args)
+  end
+  def sysread(length)
+    @string.read(length) or raise EOFError, "end of file reached", caller
+  end
+  def syswrite(chunk)
+    @string.write(chunk)
+  end
+  def sysseek(offset, whence = IO::SEEK_SET)
+    @string.seek(offset,whence)
+    @string.tell
+  end
+  DELEGATORS = [:close_read,:close_write,:closed_read?,:closed_write?,:reopen]
+  def_delegators :@string, *DELEGATORS
+  # def close_read()   @string.close_read    end
+  # def close_write()  @string.close_write   end
+  # def closed_read?() @string.closed_read?  end
+  # def closed_write?()@string.closed_write? end
+  # def close()        @string.close         end
+end
+
+require File.join(File.dirname(__FILE__),'rot13_filter')
+require File.join(File.dirname(__FILE__),'io_loop')
+require File.join(File.dirname(__FILE__),'read_joiner')
+
+class PairTester
+  def initialize(test,initial,*args)
+    @test = test
+    @string1 = initial.dup
+    @string2 = initial.dup
+    @s1 = StringIO.new(@string1,*args)
+    @s2 = SimpleIO.new(@string2,*args)
+  end
+
+  def perform(*args)
+    @s1.__send__(*args)
+    @s2.__send__(*args)
+  end
+
+  def assert_equal
+    @test.assert_equal @string1, @string2
+  end
+
+  def method_missing(*args)
+    value1 = @s1.__send__(*args) rescue $!.class
+    value2 = @s2.__send__(*args) rescue $!.class
+    value2 = value1 if value1 == @s1 && value2 == @s2
+    @test.assert_equal value1, value2
+    value1
+  end
+
+end
+
+class IOMixinsTest < Test::Unit::TestCase
+
+  def test_minimal
+    SimpleIO.open(":)\n") do |io|
+      assert_nothing_raised { io.gets }
+      assert_raise(NotImplementedError) { io.fcntl }
+    end
+  end
+
+  def test_enough_methods
+    junk = Object.instance_methods | Enumerable.instance_methods
+    good = IO.instance_methods(false) & StringIO.instance_methods(false)
+    mine = SimpleIO.instance_methods
+    assert_equal [], good - junk - mine
+  end
+
+  def test_io_loop
+    string = "foo\nbar\n"
+    io = IOLoop.open(string)
+    assert_equal ?f, io.getc
+    io.ungetc ?g
+    assert_equal ?g, io.getc
+    io.putc "lqqqqq" # Should use first character
+    assert_equal "o\n", io.gets
+    io.puts "baz\n"
+    assert_equal "flo\n", io.readline
+    assert_equal "baz\n", io.readline
+    io.close
+    io.instance_variable_set("@closed_read",false)
+    assert_equal true, io.closed_read?
+    assert_raise(IOError) { io.read }
+    io = IOLoop.open("foo")
+    assert_raise(IOError) { io.read }
+  end
+
+  def empty_file
+    File.open(RUBY_PLATFORM =~ /win32/ ? "NUL:" : "/dev/null")
+  end
+
+  def empty_string
+    StringIO.open("")
+  end
+
+  def empty_simple
+    SimpleIO.new("")
+  end
+
+  def assert_identical_response(object1,object2,method,*args,&block)
+    value1 = object1.send(method,*args,&block) rescue $!.class
+    value2 = object2.send(method,*args,&block) rescue $!.class
+    assert_equal value1, value2, "#{object1.class} gave #{value1.inspect} while #{object2.class} gave #{value2.inspect}"
+  end
+
+  def initialize_both(*args)
+    @both = PairTester.new(self,*args)
+  end
+
+  def test_simple_io_empty_read
+    [lambda {empty_file},lambda {empty_string}].each do |l|
+      assert_identical_response l.call,empty_simple,:read
+      assert_identical_response l.call,empty_simple,:read,0
+      assert_identical_response l.call,empty_simple,:read,2
+    end
+  end
+
+  def test_simple_io_read
+    initialize_both("Hello\nworld!")
+    @both.eof
+    @both.tty?
+    @both.lineno
+    @both.read
+    @both.eof?
+    @both.seek(2)
+    @both.pos
+    @both.rewind
+    @both.tell
+    @both.gets
+    @both.lineno
+    @both.read(4, "buffer")
+    @both.tell
+    @both.read(0)
+    @both.read(6)
+    @both.read
+    @both.eof
+    @both.read(0)
+    @both.rewind
+    @both.lineno
+    @both.read(8)
+    @both.read(0)
+    @both.read(1)
+    @both.readchar
+    @both.close_read
+    @both.close
+    @both.close_write
+    @both.closed_read?
+    @both.closed_write?
+    @both.sync
+    @both.closed?
+  end
+
+  def test_simple_io_gets
+    # io = SimpleIO.open(StringIO.new(<<-EOF))
+    string = <<-EOF
+
+
+Hello world!
+
+
+Chunky
+bacon!
+
+
+
+Goodbye world!
+    EOF
+    string.chomp!
+    initialize_both(string)
+    @both.gets ""
+    @both.readline ""
+    @both.map {|x|x}
+    @both.close
+  end
+
+  def test_simple_io_write
+    initialize_both("x","w")
+    @both.eof
+    @both.putc(42)
+    @both.eof # => IOError
+    @both.print("Hello ")
+    @both.binmode
+    @both.puts("world")
+    @both << ":-"
+    @both << ?)
+    @both.fsync
+    @both.printf("%4d",17)
+    @both.putc(1..3) # => TypeError
+    @both.putc(:bad) # => TypeError
+    @both.pos
+    @both.flush
+    @both.pos = 6
+    @both.putc(?_)
+    @both.close
+    @both.assert_equal
+  end
+
+  def test_simple_io_seek
+    initialize_both("Look ma!","r+")
+    @both.pos
+    @both.getc
+    @both.tell
+    @both.seek(3,IO::SEEK_SET)
+    @both.read(2)
+    @both.ungetc(?z)
+    @both.tell
+    @both.seek(-2,IO::SEEK_CUR)
+    @both.getc
+    @both.pos
+    @both.seek(-3,IO::SEEK_END)
+    @both.tell
+    @both.getc
+  end
+
+  def test_missing_methods
+    readable = Class.new do
+      include IO::Readable
+    end
+    writable = Class.new do
+      include IO::Writable
+      def close_write
+        if @closed_write
+          raise IOError
+        else
+          @closed_write = true
+        end
+      end
+      def closed_write?
+        !!@closed_write
+      end
+    end
+    streamable = Class.new(writable) do
+      include IO::Readable
+      def close_read
+        if @closed_read
+          raise IOError
+        else
+          @closed_read = true
+        end
+      end
+      def closed_read?
+        !!@closed_read
+      end
+    end
+    assert_raise(NotImplementedError) {readable.new.close}
+    assert_raise(NotImplementedError) {readable.new.closed?}
+    w = writable.new
+    assert_equal false, w.closed_write?
+    assert_nothing_thrown {w.close}
+    assert_raise(IOError) {w.close}
+    assert_equal true, w.closed?
+    s = streamable.new
+    assert_equal false, s.closed?
+    assert_nothing_thrown {s.close_read}
+    assert_nothing_thrown {s.close}
+    assert_raise(IOError) {s.close_write}
+    assert_raise(IOError) {s.close}
+    assert_equal true, s.closed?
+  end
+
+  def test_open_readers
+    assert_raise(EOFError) { SimpleIO.open("") {|io|io.sysread(1)}}
+    assert_equal "READ", SimpleIO.read("README",4)
+    assert_equal "x" * 1025, SimpleIO.read("x"*1025)
+    assert_equal ["foo\n","bar"], SimpleIO.readlines("foo\nbar")
+    assert_raise(LocalJumpError) { SimpleIO.foreach("foo\nbar") }
+  end
+
+  def test_rot13
+    string = "Puhaxl onpba!"
+    stringio = StringIO.open(string,"r")
+    filtered = Rot13Filter.open(stringio)
+    assert_equal "Chunky bacon!", filtered.read
+    stringio = StringIO.open(string,"r+")
+    filtered = Rot13Filter.open(stringio)
+    filtered.write("Rotten")
+    assert_equal "Ebggra onpba!", string
+  end
+
+  def test_joiner
+    joiner = ReadJoiner.new(StringIO.new("fo"),StringIO.new("o\nbar\n"))
+    assert_equal "foo\n", joiner.readline
+    assert_nil   joiner.close
+    assert_equal true, joiner.closed?
+  end
+
+end
diff --git a/test/read_joiner.rb b/test/read_joiner.rb
new file mode 100644 (file)
index 0000000..1d13543
--- /dev/null
@@ -0,0 +1,40 @@
+class ReadJoiner
+  include IO::Readable
+  extend  IO::Openable
+
+  def initialize(*streams)
+    @streams = streams
+    rewind
+  end
+
+  def sysread(length)
+    buffer = ""
+    return buffer if length == 0
+    while buffer.length < length && @streams[@index]
+      begin
+        buffer << @streams[@index].sysread(length-buffer.length)
+      rescue EOFError
+        @index += 1
+      end
+    end
+    return buffer if buffer.length == length || buffer.length > 0
+    raise EOFError, "end of file reached", caller
+  end
+
+  def close_read
+    @streams.each {|s| s.close}
+    @index = @streams.size - 1 if @index >= @streams.size
+    nil
+  end
+
+  def closed_read?
+    @streams.all? {|s|s.closed?}
+  end
+
+  def rewind
+    @streams.each {|s| s.rewind}
+    self.lineno = @index = 0
+  end
+
+end
+
diff --git a/test/rot13_filter.rb b/test/rot13_filter.rb
new file mode 100644 (file)
index 0000000..3aa05c0
--- /dev/null
@@ -0,0 +1,37 @@
+require 'io/mixins'
+
+class Rot13Filter
+  include IO::Readable
+  include IO::Writable
+  include IO::Seekable
+  extend  IO::Openable
+
+  def initialize(ios)
+    @ios = ios
+  end
+
+  def sysread(length)
+    rot13(@ios.sysread(length))
+  end
+  def syswrite(string)
+    @ios.syswrite(rot13(string))
+  end
+  def sysseek(*args)
+    if @ios.respond_to?(:sysseek)
+      @ios.sysseek(*args)
+    else
+      # StringIO is missing sysseek
+      @ios.seek(*args)
+      @ios.tell
+    end
+  end
+  def close_read()  @ios.close_read  end
+  def close_write() @ios.close_write end
+  def close()       @ios.close       end
+
+  private
+  def rot13(string)
+    string.tr("A-Za-z","N-ZA-Mn-za-m")
+  end
+end
+