From 5c1941a9be56a979c27d740370b781882d344f79 Mon Sep 17 00:00:00 2001 From: hsbt Date: Wed, 9 May 2018 04:39:16 +0000 Subject: [PATCH] Merge csv-1.0.2 from upstream. git-svn-id: svn+ssh://ci.ruby-lang.org/ruby/trunk@63364 b2dd03c8-39d4-4d8f-98ff-823fe69b080e --- lib/csv.gemspec | 21 - lib/csv.rb | 1025 +++++++----------------------- lib/csv/core_ext/array.rb | 9 + lib/csv/core_ext/string.rb | 9 + lib/csv/csv.gemspec | 24 + lib/csv/row.rb | 388 +++++++++++ lib/csv/table.rb | 378 +++++++++++ lib/csv/version.rb | 6 + test/csv/test_csv_parsing.rb | 16 +- test/csv/test_csv_writing.rb | 5 +- test/csv/test_data_converters.rb | 69 +- test/csv/test_encodings.rb | 20 +- test/csv/test_features.rb | 18 +- test/csv/test_headers.rb | 4 +- test/csv/test_interface.rb | 56 +- test/csv/test_row.rb | 50 +- test/csv/test_table.rb | 103 ++- test/csv/ts_all.rb | 4 +- 18 files changed, 1340 insertions(+), 865 deletions(-) delete mode 100644 lib/csv.gemspec mode change 100755 => 100644 lib/csv.rb create mode 100644 lib/csv/core_ext/array.rb create mode 100644 lib/csv/core_ext/string.rb create mode 100644 lib/csv/csv.gemspec create mode 100644 lib/csv/row.rb create mode 100644 lib/csv/table.rb create mode 100644 lib/csv/version.rb diff --git a/lib/csv.gemspec b/lib/csv.gemspec deleted file mode 100644 index 2862c1fbc2..0000000000 --- a/lib/csv.gemspec +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -Gem::Specification.new do |spec| - spec.name = "csv" - spec.version = "1.0.0" - spec.date = "2017-12-13" - spec.authors = ["James Edward Gray II"] - spec.email = [nil] - - spec.summary = "CSV Reading and Writing" - spec.description = "the CSV library began its life as FasterCSV." - spec.homepage = "https://github.com/ruby/csv" - spec.license = "BSD-2-Clause" - - spec.files = ["lib/csv.rb"] - spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 2.4.0" - - spec.add_development_dependency "bundler", "~> 1.14" - spec.add_development_dependency "rake", "~> 12" -end diff --git a/lib/csv.rb b/lib/csv.rb old mode 100755 new mode 100644 index 732d4f1bd6..ebb6d67968 --- a/lib/csv.rb +++ b/lib/csv.rb @@ -2,9 +2,7 @@ # frozen_string_literal: true # = csv.rb -- CSV Reading and Writing # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. # # See CSV for documentation. # @@ -95,74 +93,146 @@ require "forwardable" require "English" require "date" require "stringio" +require_relative "csv/table" +require_relative "csv/row" + +# This provides String#match? and Regexp#match? for Ruby 2.3. +unless String.method_defined?(:match?) + class CSV + module MatchP + refine String do + def match?(pattern) + self =~ pattern + end + end + + refine Regexp do + def match?(string) + self =~ string + end + end + end + end + + using CSV::MatchP +end # # This class provides a complete interface to CSV files and data. It offers # tools to enable you to read and write to and from Strings or IO objects, as # needed. # -# == Reading +# The most generic interface of a class is: # -# === From a File +# csv = CSV.new(string_or_io, **options) # -# ==== A Line at a Time +# # Reading: IO object should be open for read +# csv.read # => array of rows +# # or +# csv.each do |row| +# # ... +# end +# # or +# row = csv.shift # -# CSV.foreach("path/to/file.csv") do |row| -# # use row here... +# # Writing: IO object should be open for write +# csv << row +# +# There are several specialized class methods for one-statement reading or writing, +# described in the Specialized Methods section. +# +# If a String passed into ::new, it is internally wrapped into a StringIO object. +# +# +options+ can be used for specifying the particular CSV flavor (column +# separators, row separators, value quoting and so on), and for data conversion, +# see Data Conversion section for the description of the latter. +# +# == Specialized Methods +# +# === Reading +# +# # From a file: all at once +# arr_of_rows = CSV.read("path/to/file.csv", **options) +# # iterator-style: +# CSV.foreach("path/to/file.csv", **options) do |row| +# # ... # end # -# ==== All at Once -# -# arr_of_arrs = CSV.read("path/to/file.csv") -# -# === From a String -# -# ==== A Line at a Time -# -# CSV.parse("CSV,data,String") do |row| -# # use row here... +# # From a string +# arr_of_rows = CSV.parse("CSV,data,String", **options) +# # or +# CSV.parse("CSV,data,String", **options) do |row| +# # ... # end # -# ==== All at Once -# -# arr_of_arrs = CSV.parse("CSV,data,String") -# -# == Writing -# -# === To a File +# === Writing # +# # To a file # CSV.open("path/to/file.csv", "wb") do |csv| # csv << ["row", "of", "CSV", "data"] # csv << ["another", "row"] # # ... # end # -# === To a String -# +# # To a String # csv_string = CSV.generate do |csv| # csv << ["row", "of", "CSV", "data"] # csv << ["another", "row"] # # ... # end # -# == Convert a Single Line +# === Shortcuts # +# # Core extensions for converting one line # csv_string = ["CSV", "data"].to_csv # to CSV # csv_array = "CSV,String".parse_csv # from CSV # -# == Shortcut Interface -# +# # CSV() method # CSV { |csv_out| csv_out << %w{my data here} } # to $stdout # CSV(csv = "") { |csv_str| csv_str << %w{my data here} } # to a String # CSV($stderr) { |csv_err| csv_err << %w{my data here} } # to $stderr # CSV($stdin) { |csv_in| csv_in.each { |row| p row } } # from $stdin # -# == Advanced Usage +# == Data Conversion # -# === Wrap an IO Object +# === CSV with headers # -# csv = CSV.new(io, options) -# # ... read (with gets() or each()) from and write (with <<) to csv here ... +# CSV allows to specify column names of CSV file, whether they are in data, or +# provided separately. If headers specified, reading methods return an instance +# of CSV::Table, consisting of CSV::Row. +# +# # Headers are part of data +# data = CSV.parse(<<~ROWS, headers: true) +# Name,Department,Salary +# Bob,Engeneering,1000 +# Jane,Sales,2000 +# John,Management,5000 +# ROWS +# +# data.class #=> CSV::Table +# data.first #=> # +# data.first.to_h #=> {"Name"=>"Bob", "Department"=>"Engeneering", "Salary"=>"1000"} +# +# # Headers provided by developer +# data = CSV.parse('Bob,Engeneering,1000', headers: %i[name department salary]) +# data.first #=> # +# +# === Typed data reading +# +# CSV allows to provide a set of data _converters_ e.g. transformations to try on input +# data. Converter could be a symbol from CSV::Converters constant's keys, or lambda. +# +# # Without any converters: +# CSV.parse('Bob,2018-03-01,100') +# #=> [["Bob", "2018-03-01", "100"]] +# +# # With built-in converters: +# CSV.parse('Bob,2018-03-01,100', converters: %i[numeric date]) +# #=> [["Bob", #, 100]] +# +# # With custom converters: +# CSV.parse('Bob,2018-03-01,100', converters: [->(v) { Time.parse(v) rescue v }]) +# #=> [["Bob", 2018-03-01 00:00:00 +0200, "100"]] # # == CSV and Character Encodings (M17n or Multilingualization) # @@ -207,710 +277,16 @@ require "stringio" # find with it. # class CSV - # The version of the installed library. - VERSION = "2.4.8" - - # - # A CSV::Row is part Array and part Hash. It retains an order for the fields - # and allows duplicates just as an Array would, but also allows you to access - # fields by name just as you could if they were in a Hash. - # - # All rows returned by CSV will be constructed from this class, if header row - # processing is activated. - # - class Row - # - # Construct a new CSV::Row from +headers+ and +fields+, which are expected - # to be Arrays. If one Array is shorter than the other, it will be padded - # with +nil+ objects. - # - # The optional +header_row+ parameter can be set to +true+ to indicate, via - # CSV::Row.header_row?() and CSV::Row.field_row?(), that this is a header - # row. Otherwise, the row is assumes to be a field row. - # - # A CSV::Row object supports the following Array methods through delegation: - # - # * empty?() - # * length() - # * size() - # - def initialize(headers, fields, header_row = false) - @header_row = header_row - headers.each { |h| h.freeze if h.is_a? String } - - # handle extra headers or fields - @row = if headers.size >= fields.size - headers.zip(fields) - else - fields.zip(headers).each(&:reverse!) - end - end - - # Internal data format used to compare equality. - attr_reader :row - protected :row - - ### Array Delegation ### - - extend Forwardable - def_delegators :@row, :empty?, :length, :size - - # Returns +true+ if this is a header row. - def header_row? - @header_row - end - - # Returns +true+ if this is a field row. - def field_row? - not header_row? - end - - # Returns the headers of this row. - def headers - @row.map(&:first) - end - - # - # :call-seq: - # field( header ) - # field( header, offset ) - # field( index ) - # - # This method will return the field value by +header+ or +index+. If a field - # is not found, +nil+ is returned. - # - # When provided, +offset+ ensures that a header match occurs on or later - # than the +offset+ index. You can use this to find duplicate headers, - # without resorting to hard-coding exact indices. - # - def field(header_or_index, minimum_index = 0) - # locate the pair - finder = (header_or_index.is_a?(Integer) || header_or_index.is_a?(Range)) ? :[] : :assoc - pair = @row[minimum_index..-1].send(finder, header_or_index) - - # return the field if we have a pair - if pair.nil? - nil - else - header_or_index.is_a?(Range) ? pair.map(&:last) : pair.last - end - end - alias_method :[], :field - - # - # :call-seq: - # fetch( header ) - # fetch( header ) { |row| ... } - # fetch( header, default ) - # - # This method will fetch the field value by +header+. It has the same - # behavior as Hash#fetch: if there is a field with the given +header+, its - # value is returned. Otherwise, if a block is given, it is yielded the - # +header+ and its result is returned; if a +default+ is given as the - # second argument, it is returned; otherwise a KeyError is raised. - # - def fetch(header, *varargs) - raise ArgumentError, "Too many arguments" if varargs.length > 1 - pair = @row.assoc(header) - if pair - pair.last - else - if block_given? - yield header - elsif varargs.empty? - raise KeyError, "key not found: #{header}" - else - varargs.first - end - end - end - - # Returns +true+ if there is a field with the given +header+. - def has_key?(header) - !!@row.assoc(header) - end - alias_method :include?, :has_key? - alias_method :key?, :has_key? - alias_method :member?, :has_key? - - # - # :call-seq: - # []=( header, value ) - # []=( header, offset, value ) - # []=( index, value ) - # - # Looks up the field by the semantics described in CSV::Row.field() and - # assigns the +value+. - # - # Assigning past the end of the row with an index will set all pairs between - # to [nil, nil]. Assigning to an unused header appends the new - # pair. - # - def []=(*args) - value = args.pop - - if args.first.is_a? Integer - if @row[args.first].nil? # extending past the end with index - @row[args.first] = [nil, value] - @row.map! { |pair| pair.nil? ? [nil, nil] : pair } - else # normal index assignment - @row[args.first][1] = value - end - else - index = index(*args) - if index.nil? # appending a field - self << [args.first, value] - else # normal header assignment - @row[index][1] = value - end - end - end - - # - # :call-seq: - # <<( field ) - # <<( header_and_field_array ) - # <<( header_and_field_hash ) - # - # If a two-element Array is provided, it is assumed to be a header and field - # and the pair is appended. A Hash works the same way with the key being - # the header and the value being the field. Anything else is assumed to be - # a lone field which is appended with a +nil+ header. - # - # This method returns the row for chaining. - # - def <<(arg) - if arg.is_a?(Array) and arg.size == 2 # appending a header and name - @row << arg - elsif arg.is_a?(Hash) # append header and name pairs - arg.each { |pair| @row << pair } - else # append field value - @row << [nil, arg] - end - - self # for chaining - end - - # - # A shortcut for appending multiple fields. Equivalent to: - # - # args.each { |arg| csv_row << arg } - # - # This method returns the row for chaining. - # - def push(*args) - args.each { |arg| self << arg } - - self # for chaining - end - - # - # :call-seq: - # delete( header ) - # delete( header, offset ) - # delete( index ) - # - # Used to remove a pair from the row by +header+ or +index+. The pair is - # located as described in CSV::Row.field(). The deleted pair is returned, - # or +nil+ if a pair could not be found. - # - def delete(header_or_index, minimum_index = 0) - if header_or_index.is_a? Integer # by index - @row.delete_at(header_or_index) - elsif i = index(header_or_index, minimum_index) # by header - @row.delete_at(i) - else - [ ] - end - end - - # - # The provided +block+ is passed a header and field for each pair in the row - # and expected to return +true+ or +false+, depending on whether the pair - # should be deleted. - # - # This method returns the row for chaining. - # - # If no block is given, an Enumerator is returned. - # - def delete_if(&block) - block or return enum_for(__method__) { size } - - @row.delete_if(&block) - - self # for chaining - end - - # - # This method accepts any number of arguments which can be headers, indices, - # Ranges of either, or two-element Arrays containing a header and offset. - # Each argument will be replaced with a field lookup as described in - # CSV::Row.field(). - # - # If called with no arguments, all fields are returned. - # - def fields(*headers_and_or_indices) - if headers_and_or_indices.empty? # return all fields--no arguments - @row.map(&:last) - else # or work like values_at() - all = [] - headers_and_or_indices.each do |h_or_i| - if h_or_i.is_a? Range - index_begin = h_or_i.begin.is_a?(Integer) ? h_or_i.begin : - index(h_or_i.begin) - index_end = h_or_i.end.is_a?(Integer) ? h_or_i.end : - index(h_or_i.end) - new_range = h_or_i.exclude_end? ? (index_begin...index_end) : - (index_begin..index_end) - all.concat(fields.values_at(new_range)) - else - all << field(*Array(h_or_i)) - end - end - return all - end - end - alias_method :values_at, :fields - - # - # :call-seq: - # index( header ) - # index( header, offset ) - # - # This method will return the index of a field with the provided +header+. - # The +offset+ can be used to locate duplicate header names, as described in - # CSV::Row.field(). - # - def index(header, minimum_index = 0) - # find the pair - index = headers[minimum_index..-1].index(header) - # return the index at the right offset, if we found one - index.nil? ? nil : index + minimum_index - end - - # Returns +true+ if +name+ is a header for this row, and +false+ otherwise. - def header?(name) - headers.include? name - end - alias_method :include?, :header? - - # - # Returns +true+ if +data+ matches a field in this row, and +false+ - # otherwise. - # - def field?(data) - fields.include? data - end - - include Enumerable - - # - # Yields each pair of the row as header and field tuples (much like - # iterating over a Hash). This method returns the row for chaining. - # - # If no block is given, an Enumerator is returned. - # - # Support for Enumerable. - # - def each(&block) - block or return enum_for(__method__) { size } - - @row.each(&block) - - self # for chaining - end - - # - # Returns +true+ if this row contains the same headers and fields in the - # same order as +other+. - # - def ==(other) - return @row == other.row if other.is_a? CSV::Row - @row == other - end - - # - # Collapses the row into a simple Hash. Be warned that this discards field - # order and clobbers duplicate fields. - # - def to_hash - @row.to_h - end - - # - # Returns the row as a CSV String. Headers are not used. Equivalent to: - # - # csv_row.fields.to_csv( options ) - # - def to_csv(**options) - fields.to_csv(options) - end - alias_method :to_s, :to_csv - - # A summary of fields, by header, in an ASCII compatible String. - def inspect - str = ["#<", self.class.to_s] - each do |header, field| - str << " " << (header.is_a?(Symbol) ? header.to_s : header.inspect) << - ":" << field.inspect - end - str << ">" - begin - str.join('') - rescue # any encoding error - str.map do |s| - e = Encoding::Converter.asciicompat_encoding(s.encoding) - e ? s.encode(e) : s.force_encoding("ASCII-8BIT") - end.join('') - end - end - end - - # - # A CSV::Table is a two-dimensional data structure for representing CSV - # documents. Tables allow you to work with the data by row or column, - # manipulate the data, and even convert the results back to CSV, if needed. - # - # All tables returned by CSV will be constructed from this class, if header - # row processing is activated. - # - class Table - # - # Construct a new CSV::Table from +array_of_rows+, which are expected - # to be CSV::Row objects. All rows are assumed to have the same headers. - # - # A CSV::Table object supports the following Array methods through - # delegation: - # - # * empty?() - # * length() - # * size() - # - def initialize(array_of_rows) - @table = array_of_rows - @mode = :col_or_row - end - - # The current access mode for indexing and iteration. - attr_reader :mode - - # Internal data format used to compare equality. - attr_reader :table - protected :table - - ### Array Delegation ### - - extend Forwardable - def_delegators :@table, :empty?, :length, :size - - # - # Returns a duplicate table object, in column mode. This is handy for - # chaining in a single call without changing the table mode, but be aware - # that this method can consume a fair amount of memory for bigger data sets. - # - # This method returns the duplicate table for chaining. Don't chain - # destructive methods (like []=()) this way though, since you are working - # with a duplicate. - # - def by_col - self.class.new(@table.dup).by_col! - end - - # - # Switches the mode of this table to column mode. All calls to indexing and - # iteration methods will work with columns until the mode is changed again. - # - # This method returns the table and is safe to chain. - # - def by_col! - @mode = :col - - self - end - - # - # Returns a duplicate table object, in mixed mode. This is handy for - # chaining in a single call without changing the table mode, but be aware - # that this method can consume a fair amount of memory for bigger data sets. - # - # This method returns the duplicate table for chaining. Don't chain - # destructive methods (like []=()) this way though, since you are working - # with a duplicate. - # - def by_col_or_row - self.class.new(@table.dup).by_col_or_row! - end - - # - # Switches the mode of this table to mixed mode. All calls to indexing and - # iteration methods will use the default intelligent indexing system until - # the mode is changed again. In mixed mode an index is assumed to be a row - # reference while anything else is assumed to be column access by headers. - # - # This method returns the table and is safe to chain. - # - def by_col_or_row! - @mode = :col_or_row - - self - end - - # - # Returns a duplicate table object, in row mode. This is handy for chaining - # in a single call without changing the table mode, but be aware that this - # method can consume a fair amount of memory for bigger data sets. - # - # This method returns the duplicate table for chaining. Don't chain - # destructive methods (like []=()) this way though, since you are working - # with a duplicate. - # - def by_row - self.class.new(@table.dup).by_row! - end - - # - # Switches the mode of this table to row mode. All calls to indexing and - # iteration methods will work with rows until the mode is changed again. - # - # This method returns the table and is safe to chain. - # - def by_row! - @mode = :row - - self - end - - # - # Returns the headers for the first row of this table (assumed to match all - # other rows). An empty Array is returned for empty tables. - # - def headers - if @table.empty? - Array.new - else - @table.first.headers - end - end - - # - # In the default mixed mode, this method returns rows for index access and - # columns for header access. You can force the index association by first - # calling by_col!() or by_row!(). - # - # Columns are returned as an Array of values. Altering that Array has no - # effect on the table. - # - def [](index_or_header) - if @mode == :row or # by index - (@mode == :col_or_row and (index_or_header.is_a?(Integer) or index_or_header.is_a?(Range))) - @table[index_or_header] - else # by header - @table.map { |row| row[index_or_header] } - end - end - - # - # In the default mixed mode, this method assigns rows for index access and - # columns for header access. You can force the index association by first - # calling by_col!() or by_row!(). - # - # Rows may be set to an Array of values (which will inherit the table's - # headers()) or a CSV::Row. - # - # Columns may be set to a single value, which is copied to each row of the - # column, or an Array of values. Arrays of values are assigned to rows top - # to bottom in row major order. Excess values are ignored and if the Array - # does not have a value for each row the extra rows will receive a +nil+. - # - # Assigning to an existing column or row clobbers the data. Assigning to - # new columns creates them at the right end of the table. - # - def []=(index_or_header, value) - if @mode == :row or # by index - (@mode == :col_or_row and index_or_header.is_a? Integer) - if value.is_a? Array - @table[index_or_header] = Row.new(headers, value) - else - @table[index_or_header] = value - end - else # set column - if value.is_a? Array # multiple values - @table.each_with_index do |row, i| - if row.header_row? - row[index_or_header] = index_or_header - else - row[index_or_header] = value[i] - end - end - else # repeated value - @table.each do |row| - if row.header_row? - row[index_or_header] = index_or_header - else - row[index_or_header] = value - end - end - end - end - end - - # - # The mixed mode default is to treat a list of indices as row access, - # returning the rows indicated. Anything else is considered columnar - # access. For columnar access, the return set has an Array for each row - # with the values indicated by the headers in each Array. You can force - # column or row mode using by_col!() or by_row!(). - # - # You cannot mix column and row access. - # - def values_at(*indices_or_headers) - if @mode == :row or # by indices - ( @mode == :col_or_row and indices_or_headers.all? do |index| - index.is_a?(Integer) or - ( index.is_a?(Range) and - index.first.is_a?(Integer) and - index.last.is_a?(Integer) ) - end ) - @table.values_at(*indices_or_headers) - else # by headers - @table.map { |row| row.values_at(*indices_or_headers) } - end - end - - # - # Adds a new row to the bottom end of this table. You can provide an Array, - # which will be converted to a CSV::Row (inheriting the table's headers()), - # or a CSV::Row. - # - # This method returns the table for chaining. - # - def <<(row_or_array) - if row_or_array.is_a? Array # append Array - @table << Row.new(headers, row_or_array) - else # append Row - @table << row_or_array - end - - self # for chaining - end - - # - # A shortcut for appending multiple rows. Equivalent to: - # - # rows.each { |row| self << row } - # - # This method returns the table for chaining. - # - def push(*rows) - rows.each { |row| self << row } - - self # for chaining - end - - # - # Removes and returns the indicated column or row. In the default mixed - # mode indices refer to rows and everything else is assumed to be a column - # header. Use by_col!() or by_row!() to force the lookup. - # - def delete(index_or_header) - if @mode == :row or # by index - (@mode == :col_or_row and index_or_header.is_a? Integer) - @table.delete_at(index_or_header) - else # by header - @table.map { |row| row.delete(index_or_header).last } - end - end - - # - # Removes any column or row for which the block returns +true+. In the - # default mixed mode or row mode, iteration is the standard row major - # walking of rows. In column mode, iteration will +yield+ two element - # tuples containing the column name and an Array of values for that column. - # - # This method returns the table for chaining. - # - # If no block is given, an Enumerator is returned. - # - def delete_if(&block) - block or return enum_for(__method__) { @mode == :row or @mode == :col_or_row ? size : headers.size } - - if @mode == :row or @mode == :col_or_row # by index - @table.delete_if(&block) - else # by header - deleted = [] - headers.each do |header| - deleted << delete(header) if block[[header, self[header]]] - end - end - - self # for chaining - end - - include Enumerable - - # - # In the default mixed mode or row mode, iteration is the standard row major - # walking of rows. In column mode, iteration will +yield+ two element - # tuples containing the column name and an Array of values for that column. - # - # This method returns the table for chaining. - # - # If no block is given, an Enumerator is returned. - # - def each(&block) - block or return enum_for(__method__) { @mode == :col ? headers.size : size } - - if @mode == :col - headers.each { |header| block[[header, self[header]]] } - else - @table.each(&block) - end - - self # for chaining - end - - # Returns +true+ if all rows of this table ==() +other+'s rows. - def ==(other) - return @table == other.table if other.is_a? CSV::Table - @table == other - end - - # - # Returns the table as an Array of Arrays. Headers will be the first row, - # then all of the field rows will follow. - # - def to_a - array = [headers] - @table.each do |row| - array.push(row.fields) unless row.header_row? - end - return array - end - - # - # Returns the table as a complete CSV String. Headers will be listed first, - # then all of the field rows. - # - # This method assumes you want the Table.headers(), unless you explicitly - # pass :write_headers => false. - # - def to_csv(write_headers: true, **options) - array = write_headers ? [headers.to_csv(options)] : [] - @table.each do |row| - array.push(row.fields.to_csv(options)) unless row.header_row? - end - return array.join('') - end - alias_method :to_s, :to_csv - - # Shows the mode and size of this table in a US-ASCII String. - def inspect - "#<#{self.class} mode:#{@mode} row_count:#{to_a.size}>".encode("US-ASCII") - end - end # The error thrown when the parser encounters illegal CSV formatting. - class MalformedCSVError < RuntimeError; end + class MalformedCSVError < RuntimeError + attr_reader :line_number + alias_method :lineno, :line_number + def initialize(message, line_number) + @line_number = line_number + super("#{message} in line #{line_number}.") + end + end # # A FieldInfo Struct contains details about a field's position in the data @@ -930,7 +306,11 @@ class CSV # A Regexp used to find and convert some common DateTime formats. DateTimeMatcher = / \A(?: (\w+,?\s+)?\w+\s+\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2},?\s+\d{2,4} | - \d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2} )\z /x + \d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2} | + # ISO-8601 + \d{4}-\d{2}-\d{2} + (?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?(?:[+-]\d{2}(?::\d{2})|Z)?)?)? + )\z /x # The encoding used by all converters. ConverterEncoding = Encoding.find("UTF-8") @@ -1137,7 +517,7 @@ class CSV # but transcode it to UTF-8 before CSV parses it. # def self.foreach(path, **options, &block) - return to_enum(__method__, path, options) unless block + return to_enum(__method__, path, options) unless block_given? open(path, options) do |csv| csv.each(&block) end @@ -1164,8 +544,8 @@ class CSV def self.generate(str=nil, **options) # add a default empty String, if none was given if str - io = StringIO.new(str) - io.seek(0, IO::SEEK_END) + str = StringIO.new(str) + str.seek(0, IO::SEEK_END) else encoding = options[:encoding] str = String.new @@ -1309,14 +689,14 @@ class CSV # def self.parse(*args, &block) csv = new(*args) - if block.nil? # slurp contents, if no block is given - begin - csv.read - ensure - csv.close - end - else # or pass each row to a provided block - csv.each(&block) + + return csv.each(&block) if block_given? + + # slurp contents, if no block is given + begin + csv.read + ensure + csv.close end end @@ -1510,6 +890,8 @@ class CSV # attempt to parse input not conformant # with RFC 4180, such as double quotes # in unquoted fields. + # :nil_value:: TODO: WRITE ME. + # :empty_value:: TODO: WRITE ME. # # See CSV::DEFAULT_OPTIONS for the default settings. # @@ -1519,20 +901,14 @@ class CSV def initialize(data, col_sep: ",", row_sep: :auto, quote_char: '"', field_size_limit: nil, converters: nil, unconverted_fields: nil, headers: false, return_headers: false, write_headers: nil, header_converters: nil, skip_blanks: false, force_quotes: false, - skip_lines: nil, liberal_parsing: false, internal_encoding: nil, external_encoding: nil, encoding: nil) + skip_lines: nil, liberal_parsing: false, internal_encoding: nil, external_encoding: nil, encoding: nil, + nil_value: nil, + empty_value: "") raise ArgumentError.new("Cannot parse nil as CSV") if data.nil? # create the IO object we will read from @io = data.is_a?(String) ? StringIO.new(data) : data - # honor the IO encoding if we can, otherwise default to ASCII-8BIT - internal_encoding = Encoding.find(internal_encoding) if internal_encoding - external_encoding = Encoding.find(external_encoding) if external_encoding - if encoding - encoding, = encoding.split(":", 2) if encoding.is_a?(String) - encoding = Encoding.find(encoding) - end - @encoding = raw_encoding(nil) || internal_encoding || encoding || - Encoding.default_internal || Encoding.default_external + @encoding = determine_encoding(encoding, internal_encoding) # # prepare for building safe regular expressions in the target encoding, # if we can transcode the needed characters @@ -1549,6 +925,10 @@ class CSV # headers must be delayed until shift(), in case they need a row of content @headers = nil + @nil_value = nil_value + @empty_value = empty_value + @empty_value_is_empty_string = (empty_value == "") + init_separators(col_sep, row_sep, quote_char, force_quotes) init_parsers(skip_blanks, field_size_limit, liberal_parsing) init_converters(converters, :@converters, :convert) @@ -1830,7 +1210,15 @@ class CSV @line = parse.clone end - parse.sub!(@parsers[:line_end], "") + begin + parse.sub!(@parsers[:line_end], "") + rescue ArgumentError + unless parse.valid_encoding? + message = "Invalid byte sequence in #{parse.encoding}" + raise MalformedCSVError.new(message, lineno + 1) + end + raise + end if csv.empty? # @@ -1853,7 +1241,7 @@ class CSV next if @skip_lines and @skip_lines.match parse - parts = parse.split(@col_sep, -1) + parts = parse.split(@col_sep_split_separator, -1) if parts.empty? if in_extended_col csv[-1] << @col_sep # will be replaced with a @row_sep after the parts.each loop @@ -1871,8 +1259,8 @@ class CSV # extended column ends csv.last << part[0..-2] if csv.last.match?(@parsers[:stray_quote]) - raise MalformedCSVError, - "Missing or stray quote in line #{lineno + 1}" + raise MalformedCSVError.new("Missing or stray quote", + lineno + 1) end csv.last.gsub!(@double_quote_char, @quote_char) in_extended_col = false @@ -1889,26 +1277,26 @@ class CSV # regular quoted column csv << part[1..-2] if csv.last.match?(@parsers[:stray_quote]) - raise MalformedCSVError, - "Missing or stray quote in line #{lineno + 1}" + raise MalformedCSVError.new("Missing or stray quote", + lineno + 1) end csv.last.gsub!(@double_quote_char, @quote_char) elsif @liberal_parsing csv << part else - raise MalformedCSVError, - "Missing or stray quote in line #{lineno + 1}" + raise MalformedCSVError.new("Missing or stray quote", + lineno + 1) end elsif part.match?(@parsers[:quote_or_nl]) # Unquoted field with bad characters. if part.match?(@parsers[:nl_or_lf]) - raise MalformedCSVError, "Unquoted fields do not allow " + - "\\r or \\n (line #{lineno + 1})." + message = "Unquoted fields do not allow \\r or \\n" + raise MalformedCSVError.new(message, lineno + 1) else if @liberal_parsing csv << part else - raise MalformedCSVError, "Illegal quoting in line #{lineno + 1}." + raise MalformedCSVError.new("Illegal quoting", lineno + 1) end end else @@ -1924,10 +1312,11 @@ class CSV if in_extended_col # if we're at eof?(), a quoted field wasn't closed... if @io.eof? - raise MalformedCSVError, - "Unclosed quoted field on line #{lineno + 1}." + raise MalformedCSVError.new("Unclosed quoted field", + lineno + 1) elsif @field_size_limit and csv.last.size >= @field_size_limit - raise MalformedCSVError, "Field size exceeded on line #{lineno + 1}." + raise MalformedCSVError.new("Field size exceeded", + lineno + 1) end # otherwise, we need to loop and pull some more data to complete the row else @@ -1936,10 +1325,13 @@ class CSV # save fields unconverted fields, if needed... unconverted = csv.dup if @unconverted_fields - # convert fields, if needed... - csv = convert_fields(csv) unless @use_headers or @converters.empty? - # parse out header rows and handle CSV::Row conversions... - csv = parse_headers(csv) if @use_headers + if @use_headers + # parse out header rows and handle CSV::Row conversions... + csv = parse_headers(csv) + else + # convert fields, if needed... + csv = convert_fields(csv) + end # inject unconverted fields and accessor, if requested... if @unconverted_fields and not csv.respond_to? :unconverted_fields @@ -1995,6 +1387,21 @@ class CSV private + def determine_encoding(encoding, internal_encoding) + # honor the IO encoding if we can, otherwise default to ASCII-8BIT + io_encoding = raw_encoding(nil) + return io_encoding if io_encoding + + return Encoding.find(internal_encoding) if internal_encoding + + if encoding + encoding, = encoding.split(":", 2) if encoding.is_a?(String) + return Encoding.find(encoding) + end + + Encoding.default_internal || Encoding.default_external + end + # # Stores the indicated separators for later use. # @@ -2008,6 +1415,11 @@ class CSV def init_separators(col_sep, row_sep, quote_char, force_quotes) # store the selected separators @col_sep = col_sep.to_s.encode(@encoding) + if @col_sep == " " + @col_sep_split_separator = Regexp.new(/#{Regexp.escape(@col_sep)}/) + else + @col_sep_split_separator = @col_sep + end @row_sep = row_sep # encode after resolving :auto @quote_char = quote_char.to_s.encode(@encoding) @double_quote_char = @quote_char * 2 @@ -2037,15 +1449,28 @@ class CSV # (ensure will set default value) # break unless sample = @io.gets(nil, 1024) + + cr = encode_str("\r") + lf = encode_str("\n") # extend sample if we're unsure of the line ending - if sample.end_with? encode_str("\r") + if sample.end_with?(cr) sample << (@io.gets(nil, 1) || "") end # try to find a standard separator - if sample =~ encode_re("\r\n?|\n") - @row_sep = $& - break + sample.each_char.each_cons(2) do |char, next_char| + case char + when cr + if next_char == lf + @row_sep = encode_str("\r\n") + else + @row_sep = cr + end + break + when lf + @row_sep = lf + break + end end end @@ -2199,10 +1624,24 @@ class CSV # shortcut. # def convert_fields(fields, headers = false) - # see if we are converting headers or fields - converters = headers ? @header_converters : @converters + if headers + converters = @header_converters + else + converters = @converters + if !@use_headers and + converters.empty? and + @nil_value.nil? and + @empty_value_is_empty_string + return fields + end + end fields.map.with_index do |field, index| + if field.nil? + field = @nil_value + elsif field.empty? + field = @empty_value unless @empty_value_is_empty_string + end converters.each do |converter| break if headers && field.nil? field = if converter.arity == 1 # straight field converter @@ -2334,22 +1773,6 @@ def CSV(*args, &block) CSV.instance(*args, &block) end -class Array # :nodoc: - # Equivalent to CSV::generate_line(self, options) - # - # ["CSV", "data"].to_csv - # #=> "CSV,data\n" - def to_csv(**options) - CSV.generate_line(self, options) - end -end - -class String # :nodoc: - # Equivalent to CSV::parse_line(self, options) - # - # "CSV,data".parse_csv - # #=> ["CSV", "data"] - def parse_csv(**options) - CSV.parse_line(self, options) - end -end +require_relative "csv/version" +require_relative "csv/core_ext/array" +require_relative "csv/core_ext/string" diff --git a/lib/csv/core_ext/array.rb b/lib/csv/core_ext/array.rb new file mode 100644 index 0000000000..94df7d5c35 --- /dev/null +++ b/lib/csv/core_ext/array.rb @@ -0,0 +1,9 @@ +class Array # :nodoc: + # Equivalent to CSV::generate_line(self, options) + # + # ["CSV", "data"].to_csv + # #=> "CSV,data\n" + def to_csv(**options) + CSV.generate_line(self, options) + end +end diff --git a/lib/csv/core_ext/string.rb b/lib/csv/core_ext/string.rb new file mode 100644 index 0000000000..8f2070f3bd --- /dev/null +++ b/lib/csv/core_ext/string.rb @@ -0,0 +1,9 @@ +class String # :nodoc: + # Equivalent to CSV::parse_line(self, options) + # + # "CSV,data".parse_csv + # #=> ["CSV", "data"] + def parse_csv(**options) + CSV.parse_line(self, options) + end +end diff --git a/lib/csv/csv.gemspec b/lib/csv/csv.gemspec new file mode 100644 index 0000000000..38ead7c81d --- /dev/null +++ b/lib/csv/csv.gemspec @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative "lib/csv/version" + +Gem::Specification.new do |spec| + spec.name = "csv" + spec.version = CSV::VERSION + spec.authors = ["James Edward Gray II", "Kouhei Sutou"] + spec.email = [nil, "kou@cozmixng.org"] + + spec.summary = "CSV Reading and Writing" + spec.description = "The CSV library provides a complete interface to CSV files and data. It offers tools to enable you to read and write to and from Strings or IO objects, as needed." + spec.homepage = "https://github.com/ruby/csv" + spec.license = "BSD-2-Clause" + + spec.files = Dir.glob("lib/**/*.rb") + spec.files += ["README.md", "LICENSE.txt", "news.md"] + spec.require_paths = ["lib"] + spec.required_ruby_version = ">= 2.3.0" + + spec.add_development_dependency "bundler" + spec.add_development_dependency "rake" + spec.add_development_dependency "benchmark-ips" +end diff --git a/lib/csv/row.rb b/lib/csv/row.rb new file mode 100644 index 0000000000..8ff3480ae8 --- /dev/null +++ b/lib/csv/row.rb @@ -0,0 +1,388 @@ +# frozen_string_literal: true + +require "forwardable" + +class CSV + # + # A CSV::Row is part Array and part Hash. It retains an order for the fields + # and allows duplicates just as an Array would, but also allows you to access + # fields by name just as you could if they were in a Hash. + # + # All rows returned by CSV will be constructed from this class, if header row + # processing is activated. + # + class Row + # + # Construct a new CSV::Row from +headers+ and +fields+, which are expected + # to be Arrays. If one Array is shorter than the other, it will be padded + # with +nil+ objects. + # + # The optional +header_row+ parameter can be set to +true+ to indicate, via + # CSV::Row.header_row?() and CSV::Row.field_row?(), that this is a header + # row. Otherwise, the row is assumes to be a field row. + # + # A CSV::Row object supports the following Array methods through delegation: + # + # * empty?() + # * length() + # * size() + # + def initialize(headers, fields, header_row = false) + @header_row = header_row + headers.each { |h| h.freeze if h.is_a? String } + + # handle extra headers or fields + @row = if headers.size >= fields.size + headers.zip(fields) + else + fields.zip(headers).each(&:reverse!) + end + end + + # Internal data format used to compare equality. + attr_reader :row + protected :row + + ### Array Delegation ### + + extend Forwardable + def_delegators :@row, :empty?, :length, :size + + # Returns +true+ if this is a header row. + def header_row? + @header_row + end + + # Returns +true+ if this is a field row. + def field_row? + not header_row? + end + + # Returns the headers of this row. + def headers + @row.map(&:first) + end + + # + # :call-seq: + # field( header ) + # field( header, offset ) + # field( index ) + # + # This method will return the field value by +header+ or +index+. If a field + # is not found, +nil+ is returned. + # + # When provided, +offset+ ensures that a header match occurs on or later + # than the +offset+ index. You can use this to find duplicate headers, + # without resorting to hard-coding exact indices. + # + def field(header_or_index, minimum_index = 0) + # locate the pair + finder = (header_or_index.is_a?(Integer) || header_or_index.is_a?(Range)) ? :[] : :assoc + pair = @row[minimum_index..-1].send(finder, header_or_index) + + # return the field if we have a pair + if pair.nil? + nil + else + header_or_index.is_a?(Range) ? pair.map(&:last) : pair.last + end + end + alias_method :[], :field + + # + # :call-seq: + # fetch( header ) + # fetch( header ) { |row| ... } + # fetch( header, default ) + # + # This method will fetch the field value by +header+. It has the same + # behavior as Hash#fetch: if there is a field with the given +header+, its + # value is returned. Otherwise, if a block is given, it is yielded the + # +header+ and its result is returned; if a +default+ is given as the + # second argument, it is returned; otherwise a KeyError is raised. + # + def fetch(header, *varargs) + raise ArgumentError, "Too many arguments" if varargs.length > 1 + pair = @row.assoc(header) + if pair + pair.last + else + if block_given? + yield header + elsif varargs.empty? + raise KeyError, "key not found: #{header}" + else + varargs.first + end + end + end + + # Returns +true+ if there is a field with the given +header+. + def has_key?(header) + !!@row.assoc(header) + end + alias_method :include?, :has_key? + alias_method :key?, :has_key? + alias_method :member?, :has_key? + + # + # :call-seq: + # []=( header, value ) + # []=( header, offset, value ) + # []=( index, value ) + # + # Looks up the field by the semantics described in CSV::Row.field() and + # assigns the +value+. + # + # Assigning past the end of the row with an index will set all pairs between + # to [nil, nil]. Assigning to an unused header appends the new + # pair. + # + def []=(*args) + value = args.pop + + if args.first.is_a? Integer + if @row[args.first].nil? # extending past the end with index + @row[args.first] = [nil, value] + @row.map! { |pair| pair.nil? ? [nil, nil] : pair } + else # normal index assignment + @row[args.first][1] = value + end + else + index = index(*args) + if index.nil? # appending a field + self << [args.first, value] + else # normal header assignment + @row[index][1] = value + end + end + end + + # + # :call-seq: + # <<( field ) + # <<( header_and_field_array ) + # <<( header_and_field_hash ) + # + # If a two-element Array is provided, it is assumed to be a header and field + # and the pair is appended. A Hash works the same way with the key being + # the header and the value being the field. Anything else is assumed to be + # a lone field which is appended with a +nil+ header. + # + # This method returns the row for chaining. + # + def <<(arg) + if arg.is_a?(Array) and arg.size == 2 # appending a header and name + @row << arg + elsif arg.is_a?(Hash) # append header and name pairs + arg.each { |pair| @row << pair } + else # append field value + @row << [nil, arg] + end + + self # for chaining + end + + # + # A shortcut for appending multiple fields. Equivalent to: + # + # args.each { |arg| csv_row << arg } + # + # This method returns the row for chaining. + # + def push(*args) + args.each { |arg| self << arg } + + self # for chaining + end + + # + # :call-seq: + # delete( header ) + # delete( header, offset ) + # delete( index ) + # + # Used to remove a pair from the row by +header+ or +index+. The pair is + # located as described in CSV::Row.field(). The deleted pair is returned, + # or +nil+ if a pair could not be found. + # + def delete(header_or_index, minimum_index = 0) + if header_or_index.is_a? Integer # by index + @row.delete_at(header_or_index) + elsif i = index(header_or_index, minimum_index) # by header + @row.delete_at(i) + else + [ ] + end + end + + # + # The provided +block+ is passed a header and field for each pair in the row + # and expected to return +true+ or +false+, depending on whether the pair + # should be deleted. + # + # This method returns the row for chaining. + # + # If no block is given, an Enumerator is returned. + # + def delete_if(&block) + return enum_for(__method__) { size } unless block_given? + + @row.delete_if(&block) + + self # for chaining + end + + # + # This method accepts any number of arguments which can be headers, indices, + # Ranges of either, or two-element Arrays containing a header and offset. + # Each argument will be replaced with a field lookup as described in + # CSV::Row.field(). + # + # If called with no arguments, all fields are returned. + # + def fields(*headers_and_or_indices) + if headers_and_or_indices.empty? # return all fields--no arguments + @row.map(&:last) + else # or work like values_at() + all = [] + headers_and_or_indices.each do |h_or_i| + if h_or_i.is_a? Range + index_begin = h_or_i.begin.is_a?(Integer) ? h_or_i.begin : + index(h_or_i.begin) + index_end = h_or_i.end.is_a?(Integer) ? h_or_i.end : + index(h_or_i.end) + new_range = h_or_i.exclude_end? ? (index_begin...index_end) : + (index_begin..index_end) + all.concat(fields.values_at(new_range)) + else + all << field(*Array(h_or_i)) + end + end + return all + end + end + alias_method :values_at, :fields + + # + # :call-seq: + # index( header ) + # index( header, offset ) + # + # This method will return the index of a field with the provided +header+. + # The +offset+ can be used to locate duplicate header names, as described in + # CSV::Row.field(). + # + def index(header, minimum_index = 0) + # find the pair + index = headers[minimum_index..-1].index(header) + # return the index at the right offset, if we found one + index.nil? ? nil : index + minimum_index + end + + # Returns +true+ if +name+ is a header for this row, and +false+ otherwise. + def header?(name) + headers.include? name + end + alias_method :include?, :header? + + # + # Returns +true+ if +data+ matches a field in this row, and +false+ + # otherwise. + # + def field?(data) + fields.include? data + end + + include Enumerable + + # + # Yields each pair of the row as header and field tuples (much like + # iterating over a Hash). This method returns the row for chaining. + # + # If no block is given, an Enumerator is returned. + # + # Support for Enumerable. + # + def each(&block) + return enum_for(__method__) { size } unless block_given? + + @row.each(&block) + + self # for chaining + end + + alias_method :each_pair, :each + + # + # Returns +true+ if this row contains the same headers and fields in the + # same order as +other+. + # + def ==(other) + return @row == other.row if other.is_a? CSV::Row + @row == other + end + + # + # Collapses the row into a simple Hash. Be warned that this discards field + # order and clobbers duplicate fields. + # + def to_h + hash = {} + each do |key, _value| + hash[key] = self[key] unless hash.key?(key) + end + hash + end + alias_method :to_hash, :to_h + + alias_method :to_ary, :to_a + + # + # Returns the row as a CSV String. Headers are not used. Equivalent to: + # + # csv_row.fields.to_csv( options ) + # + def to_csv(**options) + fields.to_csv(options) + end + alias_method :to_s, :to_csv + + # + # Extracts the nested value specified by the sequence of +index+ or +header+ objects by calling dig at each step, + # returning nil if any intermediate step is nil. + # + def dig(index_or_header, *indexes) + value = field(index_or_header) + if value.nil? + nil + elsif indexes.empty? + value + else + unless value.respond_to?(:dig) + raise TypeError, "#{value.class} does not have \#dig method" + end + value.dig(*indexes) + end + end + + # A summary of fields, by header, in an ASCII compatible String. + def inspect + str = ["#<", self.class.to_s] + each do |header, field| + str << " " << (header.is_a?(Symbol) ? header.to_s : header.inspect) << + ":" << field.inspect + end + str << ">" + begin + str.join('') + rescue # any encoding error + str.map do |s| + e = Encoding::Converter.asciicompat_encoding(s.encoding) + e ? s.encode(e) : s.force_encoding("ASCII-8BIT") + end.join('') + end + end + end +end diff --git a/lib/csv/table.rb b/lib/csv/table.rb new file mode 100644 index 0000000000..e9f3366a4a --- /dev/null +++ b/lib/csv/table.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require "forwardable" + +class CSV + # + # A CSV::Table is a two-dimensional data structure for representing CSV + # documents. Tables allow you to work with the data by row or column, + # manipulate the data, and even convert the results back to CSV, if needed. + # + # All tables returned by CSV will be constructed from this class, if header + # row processing is activated. + # + class Table + # + # Construct a new CSV::Table from +array_of_rows+, which are expected + # to be CSV::Row objects. All rows are assumed to have the same headers. + # + # A CSV::Table object supports the following Array methods through + # delegation: + # + # * empty?() + # * length() + # * size() + # + def initialize(array_of_rows) + @table = array_of_rows + @mode = :col_or_row + end + + # The current access mode for indexing and iteration. + attr_reader :mode + + # Internal data format used to compare equality. + attr_reader :table + protected :table + + ### Array Delegation ### + + extend Forwardable + def_delegators :@table, :empty?, :length, :size + + # + # Returns a duplicate table object, in column mode. This is handy for + # chaining in a single call without changing the table mode, but be aware + # that this method can consume a fair amount of memory for bigger data sets. + # + # This method returns the duplicate table for chaining. Don't chain + # destructive methods (like []=()) this way though, since you are working + # with a duplicate. + # + def by_col + self.class.new(@table.dup).by_col! + end + + # + # Switches the mode of this table to column mode. All calls to indexing and + # iteration methods will work with columns until the mode is changed again. + # + # This method returns the table and is safe to chain. + # + def by_col! + @mode = :col + + self + end + + # + # Returns a duplicate table object, in mixed mode. This is handy for + # chaining in a single call without changing the table mode, but be aware + # that this method can consume a fair amount of memory for bigger data sets. + # + # This method returns the duplicate table for chaining. Don't chain + # destructive methods (like []=()) this way though, since you are working + # with a duplicate. + # + def by_col_or_row + self.class.new(@table.dup).by_col_or_row! + end + + # + # Switches the mode of this table to mixed mode. All calls to indexing and + # iteration methods will use the default intelligent indexing system until + # the mode is changed again. In mixed mode an index is assumed to be a row + # reference while anything else is assumed to be column access by headers. + # + # This method returns the table and is safe to chain. + # + def by_col_or_row! + @mode = :col_or_row + + self + end + + # + # Returns a duplicate table object, in row mode. This is handy for chaining + # in a single call without changing the table mode, but be aware that this + # method can consume a fair amount of memory for bigger data sets. + # + # This method returns the duplicate table for chaining. Don't chain + # destructive methods (like []=()) this way though, since you are working + # with a duplicate. + # + def by_row + self.class.new(@table.dup).by_row! + end + + # + # Switches the mode of this table to row mode. All calls to indexing and + # iteration methods will work with rows until the mode is changed again. + # + # This method returns the table and is safe to chain. + # + def by_row! + @mode = :row + + self + end + + # + # Returns the headers for the first row of this table (assumed to match all + # other rows). An empty Array is returned for empty tables. + # + def headers + if @table.empty? + Array.new + else + @table.first.headers + end + end + + # + # In the default mixed mode, this method returns rows for index access and + # columns for header access. You can force the index association by first + # calling by_col!() or by_row!(). + # + # Columns are returned as an Array of values. Altering that Array has no + # effect on the table. + # + def [](index_or_header) + if @mode == :row or # by index + (@mode == :col_or_row and (index_or_header.is_a?(Integer) or index_or_header.is_a?(Range))) + @table[index_or_header] + else # by header + @table.map { |row| row[index_or_header] } + end + end + + # + # In the default mixed mode, this method assigns rows for index access and + # columns for header access. You can force the index association by first + # calling by_col!() or by_row!(). + # + # Rows may be set to an Array of values (which will inherit the table's + # headers()) or a CSV::Row. + # + # Columns may be set to a single value, which is copied to each row of the + # column, or an Array of values. Arrays of values are assigned to rows top + # to bottom in row major order. Excess values are ignored and if the Array + # does not have a value for each row the extra rows will receive a +nil+. + # + # Assigning to an existing column or row clobbers the data. Assigning to + # new columns creates them at the right end of the table. + # + def []=(index_or_header, value) + if @mode == :row or # by index + (@mode == :col_or_row and index_or_header.is_a? Integer) + if value.is_a? Array + @table[index_or_header] = Row.new(headers, value) + else + @table[index_or_header] = value + end + else # set column + if value.is_a? Array # multiple values + @table.each_with_index do |row, i| + if row.header_row? + row[index_or_header] = index_or_header + else + row[index_or_header] = value[i] + end + end + else # repeated value + @table.each do |row| + if row.header_row? + row[index_or_header] = index_or_header + else + row[index_or_header] = value + end + end + end + end + end + + # + # The mixed mode default is to treat a list of indices as row access, + # returning the rows indicated. Anything else is considered columnar + # access. For columnar access, the return set has an Array for each row + # with the values indicated by the headers in each Array. You can force + # column or row mode using by_col!() or by_row!(). + # + # You cannot mix column and row access. + # + def values_at(*indices_or_headers) + if @mode == :row or # by indices + ( @mode == :col_or_row and indices_or_headers.all? do |index| + index.is_a?(Integer) or + ( index.is_a?(Range) and + index.first.is_a?(Integer) and + index.last.is_a?(Integer) ) + end ) + @table.values_at(*indices_or_headers) + else # by headers + @table.map { |row| row.values_at(*indices_or_headers) } + end + end + + # + # Adds a new row to the bottom end of this table. You can provide an Array, + # which will be converted to a CSV::Row (inheriting the table's headers()), + # or a CSV::Row. + # + # This method returns the table for chaining. + # + def <<(row_or_array) + if row_or_array.is_a? Array # append Array + @table << Row.new(headers, row_or_array) + else # append Row + @table << row_or_array + end + + self # for chaining + end + + # + # A shortcut for appending multiple rows. Equivalent to: + # + # rows.each { |row| self << row } + # + # This method returns the table for chaining. + # + def push(*rows) + rows.each { |row| self << row } + + self # for chaining + end + + # + # Removes and returns the indicated columns or rows. In the default mixed + # mode indices refer to rows and everything else is assumed to be a column + # headers. Use by_col!() or by_row!() to force the lookup. + # + def delete(*indexes_or_headers) + if indexes_or_headers.empty? + raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" + end + deleted_values = indexes_or_headers.map do |index_or_header| + if @mode == :row or # by index + (@mode == :col_or_row and index_or_header.is_a? Integer) + @table.delete_at(index_or_header) + else # by header + @table.map { |row| row.delete(index_or_header).last } + end + end + if indexes_or_headers.size == 1 + deleted_values[0] + else + deleted_values + end + end + + # + # Removes any column or row for which the block returns +true+. In the + # default mixed mode or row mode, iteration is the standard row major + # walking of rows. In column mode, iteration will +yield+ two element + # tuples containing the column name and an Array of values for that column. + # + # This method returns the table for chaining. + # + # If no block is given, an Enumerator is returned. + # + def delete_if(&block) + return enum_for(__method__) { @mode == :row or @mode == :col_or_row ? size : headers.size } unless block_given? + + if @mode == :row or @mode == :col_or_row # by index + @table.delete_if(&block) + else # by header + deleted = [] + headers.each do |header| + deleted << delete(header) if yield([header, self[header]]) + end + end + + self # for chaining + end + + include Enumerable + + # + # In the default mixed mode or row mode, iteration is the standard row major + # walking of rows. In column mode, iteration will +yield+ two element + # tuples containing the column name and an Array of values for that column. + # + # This method returns the table for chaining. + # + # If no block is given, an Enumerator is returned. + # + def each(&block) + return enum_for(__method__) { @mode == :col ? headers.size : size } unless block_given? + + if @mode == :col + headers.each { |header| yield([header, self[header]]) } + else + @table.each(&block) + end + + self # for chaining + end + + # Returns +true+ if all rows of this table ==() +other+'s rows. + def ==(other) + return @table == other.table if other.is_a? CSV::Table + @table == other + end + + # + # Returns the table as an Array of Arrays. Headers will be the first row, + # then all of the field rows will follow. + # + def to_a + array = [headers] + @table.each do |row| + array.push(row.fields) unless row.header_row? + end + + array + end + + # + # Returns the table as a complete CSV String. Headers will be listed first, + # then all of the field rows. + # + # This method assumes you want the Table.headers(), unless you explicitly + # pass :write_headers => false. + # + def to_csv(write_headers: true, **options) + array = write_headers ? [headers.to_csv(options)] : [] + @table.each do |row| + array.push(row.fields.to_csv(options)) unless row.header_row? + end + + array.join("") + end + alias_method :to_s, :to_csv + + # + # Extracts the nested value specified by the sequence of +index+ or +header+ objects by calling dig at each step, + # returning nil if any intermediate step is nil. + # + def dig(index_or_header, *index_or_headers) + value = self[index_or_header] + if value.nil? + nil + elsif index_or_headers.empty? + value + else + unless value.respond_to?(:dig) + raise TypeError, "#{value.class} does not have \#dig method" + end + value.dig(*index_or_headers) + end + end + + # Shows the mode and size of this table in a US-ASCII String. + def inspect + "#<#{self.class} mode:#{@mode} row_count:#{to_a.size}>".encode("US-ASCII") + end + end +end \ No newline at end of file diff --git a/lib/csv/version.rb b/lib/csv/version.rb new file mode 100644 index 0000000000..35adea3a92 --- /dev/null +++ b/lib/csv/version.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class CSV + # The version of the installed library. + VERSION = "1.0.2" +end diff --git a/test/csv/test_csv_parsing.rb b/test/csv/test_csv_parsing.rb index 547e70e933..ab8e97f4bb 100755 --- a/test/csv/test_csv_parsing.rb +++ b/test/csv/test_csv_parsing.rb @@ -4,9 +4,7 @@ # tc_csv_parsing.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require "timeout" @@ -168,7 +166,7 @@ class TestCSV::Parsing < TestCSV assert_send([csv.lineno, :<, 4]) end rescue CSV::MalformedCSVError - assert_equal( "Unquoted fields do not allow \\r or \\n (line 4).", + assert_equal( "Unquoted fields do not allow \\r or \\n in line 4.", $!.message ) end @@ -231,6 +229,16 @@ class TestCSV::Parsing < TestCSV assert_parse_errors_out(data, field_size_limit: 5) end + def test_col_sep_comma + assert_equal([["a", "b", nil, "d"]], + CSV.parse("a,b,,d", col_sep: ",")) + end + + def test_col_sep_space + assert_equal([["a", "b", nil, "d"]], + CSV.parse("a b d", col_sep: " ")) + end + private def assert_parse_errors_out(*args) diff --git a/test/csv/test_csv_writing.rb b/test/csv/test_csv_writing.rb index de82dae244..e1c02c1fb9 100755 --- a/test/csv/test_csv_writing.rb +++ b/test/csv/test_csv_writing.rb @@ -4,10 +4,7 @@ # tc_csv_writing.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. - +# Created by James Edward Gray II on 2005-10-31. require_relative "base" class TestCSV::Writing < TestCSV diff --git a/test/csv/test_data_converters.rb b/test/csv/test_data_converters.rb index 0786ca6d0f..04970cd461 100755 --- a/test/csv/test_data_converters.rb +++ b/test/csv/test_data_converters.rb @@ -4,9 +4,7 @@ # tc_data_converters.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require_relative "base" @@ -67,6 +65,55 @@ class TestCSV::DataConverters < TestCSV assert_instance_of(String, CSV::Converters[:date_time]["junk"]) end + def test_builtin_date_time_converter_iso8601_date + iso8601_string = "2018-01-14" + datetime = DateTime.new(2018, 1, 14) + assert_equal(datetime, + CSV::Converters[:date_time][iso8601_string]) + end + + def test_builtin_date_time_converter_iso8601_minute + iso8601_string = "2018-01-14T22:25" + datetime = DateTime.new(2018, 1, 14, 22, 25) + assert_equal(datetime, + CSV::Converters[:date_time][iso8601_string]) + end + + def test_builtin_date_time_converter_iso8601_second + iso8601_string = "2018-01-14T22:25:19" + datetime = DateTime.new(2018, 1, 14, 22, 25, 19) + assert_equal(datetime, + CSV::Converters[:date_time][iso8601_string]) + end + + def test_builtin_date_time_converter_iso8601_under_second + iso8601_string = "2018-01-14T22:25:19.1" + datetime = DateTime.new(2018, 1, 14, 22, 25, 19.1) + assert_equal(datetime, + CSV::Converters[:date_time][iso8601_string]) + end + + def test_builtin_date_time_converter_iso8601_under_second_offset + iso8601_string = "2018-01-14T22:25:19.1+09:00" + datetime = DateTime.new(2018, 1, 14, 22, 25, 19.1, "+9") + assert_equal(datetime, + CSV::Converters[:date_time][iso8601_string]) + end + + def test_builtin_date_time_converter_iso8601_offset + iso8601_string = "2018-01-14T22:25:19+09:00" + datetime = DateTime.new(2018, 1, 14, 22, 25, 19, "+9") + assert_equal(datetime, + CSV::Converters[:date_time][iso8601_string]) + end + + def test_builtin_date_time_converter_iso8601_utc + iso8601_string = "2018-01-14T22:25:19Z" + datetime = DateTime.new(2018, 1, 14, 22, 25, 19) + assert_equal(datetime, + CSV::Converters[:date_time][iso8601_string]) + end + def test_convert_with_builtin_integer # setup parser... assert_respond_to(@parser, :convert) @@ -105,7 +152,7 @@ class TestCSV::DataConverters < TestCSV end # gives us proper number conversion - assert_equal( [String, String, Integer, String, Float], + assert_equal( [String, String, 0.class, String, Float], @parser.shift.map { |field| field.class } ) end @@ -114,7 +161,7 @@ class TestCSV::DataConverters < TestCSV assert_nothing_raised(Exception) { @parser.convert(:numeric) } # and use - assert_equal( [String, String, Integer, String, Float], + assert_equal( [String, String, 0.class, String, Float], @parser.shift.map { |field| field.class } ) end @@ -125,7 +172,7 @@ class TestCSV::DataConverters < TestCSV assert_nothing_raised(Exception) { @parser.convert(:all) } # and use - assert_equal( [String, String, Integer, String, Float, DateTime], + assert_equal( [String, String, 0.class, String, Float, DateTime], @parser.shift.map { |field| field.class } ) end @@ -270,4 +317,14 @@ class TestCSV::DataConverters < TestCSV assert_respond_to(row, :unconverted_fields) assert_equal(Array.new, row.unconverted_fields) end + + def test_nil_value + assert_equal(["nil", "", "a"], + CSV.parse_line(',"",a', nil_value: "nil")) + end + + def test_empty_value + assert_equal([nil, "empty", "a"], + CSV.parse_line(',"",a', empty_value: "empty")) + end end diff --git a/test/csv/test_encodings.rb b/test/csv/test_encodings.rb index 7460a3ff34..fcad90e007 100755 --- a/test/csv/test_encodings.rb +++ b/test/csv/test_encodings.rb @@ -4,9 +4,7 @@ # tc_encodings.rb # -# Created by James Edward Gray II on 2008-09-13. -# Copyright 2008 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require_relative "base" @@ -256,6 +254,22 @@ class TestCSV::Encodings < TestCSV assert_equal(["foo,\u3042\n".encode(Encoding::Windows_31J), Encoding::Windows_31J], [s, s.encoding], bug9766) end + def test_row_separator_detection_with_invalid_encoding + csv = CSV.new("invalid,\xF8\r\nvalid,x\r\n".force_encoding("UTF-8"), + encoding: "UTF-8") + assert_equal("\r\n", csv.row_sep) + end + + def test_invalid_encoding_row_error + csv = CSV.new("invalid,\xF8\r\nvalid,x\r\n".force_encoding("UTF-8"), + encoding: "UTF-8") + error = assert_raise(CSV::MalformedCSVError) do + csv.shift + end + assert_equal("Invalid byte sequence in UTF-8 in line 1.", + error.message) + end + private def assert_parses(fields, encoding, options = { }) diff --git a/test/csv/test_features.rb b/test/csv/test_features.rb index 6dc6d34d46..a851c908f0 100755 --- a/test/csv/test_features.rb +++ b/test/csv/test_features.rb @@ -4,9 +4,7 @@ # tc_features.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. begin require "zlib" @@ -313,7 +311,7 @@ class TestCSV::Features < TestCSV def test_inspect_encoding_is_ascii_compatible csv = CSV.new("one,two,three\n1,2,3\n".encode("UTF-16BE")) assert_send([Encoding, :compatible?, - Encoding.find("US-ASCII"), csv.inspect.encoding], + Encoding.find("US-ASCII"), csv.inspect.encoding], "inspect() was not ASCII compatible.") end @@ -321,7 +319,7 @@ class TestCSV::Features < TestCSV assert_not_nil(CSV::VERSION) assert_instance_of(String, CSV::VERSION) assert_predicate(CSV::VERSION, :frozen?) - assert_match(/\A\d\.\d\.\d\Z/, CSV::VERSION) + assert_match(/\A\d\.\d\.\d\z/, CSV::VERSION) end def test_accepts_comment_skip_lines_option @@ -352,11 +350,13 @@ class TestCSV::Features < TestCSV end def test_comment_rows_are_ignored_with_heredoc - c = CSV.new(<<~EOL, skip_lines: ".") - 1,foo - .2,bar - 3,baz + sample_data = <<~EOL + 1,foo + .2,bar + 3,baz EOL + + c = CSV.new(sample_data, skip_lines: ".") assert_equal [["1", "foo"], ["3", "baz"]], c.each.to_a end diff --git a/test/csv/test_headers.rb b/test/csv/test_headers.rb index 5ec8932d95..94f4359671 100755 --- a/test/csv/test_headers.rb +++ b/test/csv/test_headers.rb @@ -4,9 +4,7 @@ # tc_headers.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require_relative "base" diff --git a/test/csv/test_interface.rb b/test/csv/test_interface.rb index be27fcd616..912e2ec7f5 100755 --- a/test/csv/test_interface.rb +++ b/test/csv/test_interface.rb @@ -4,9 +4,7 @@ # tc_interface.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require_relative "base" require "tempfile" @@ -64,6 +62,55 @@ class TestCSV::Interface < TestCSV assert_equal("Return value.", ret) end + def test_open_encoding_valid + # U+1F600 GRINNING FACE + # U+1F601 GRINNING FACE WITH SMILING EYES + File.open(@path, "w") do |file| + file << "\u{1F600},\u{1F601}" + end + CSV.open(@path, encoding: "utf-8") do |csv| + assert_equal([["\u{1F600}", "\u{1F601}"]], + csv.to_a) + end + end + + def test_open_encoding_invalid + # U+1F600 GRINNING FACE + # U+1F601 GRINNING FACE WITH SMILING EYES + File.open(@path, "w") do |file| + file << "\u{1F600},\u{1F601}" + end + CSV.open(@path, encoding: "EUC-JP") do |csv| + error = assert_raise(CSV::MalformedCSVError) do + csv.shift + end + assert_equal("Invalid byte sequence in EUC-JP in line 1.", + error.message) + end + end + + def test_open_encoding_nonexistent + _output, error = capture_io do + CSV.open(@path, encoding: "nonexistent") do + end + end + assert_equal("path:0: warning: Unsupported encoding nonexistent ignored\n", + error.gsub(/\A.+:\d+: /, "path:0: ")) + end + + def test_open_encoding_utf_8_with_bom + # U+FEFF ZERO WIDTH NO-BREAK SPACE, BOM + # U+1F600 GRINNING FACE + # U+1F601 GRINNING FACE WITH SMILING EYES + File.open(@path, "w") do |file| + file << "\u{FEFF}\u{1F600},\u{1F601}" + end + CSV.open(@path, encoding: "bom|utf-8") do |csv| + assert_equal([["\u{1F600}", "\u{1F601}"]], + csv.to_a) + end + end + def test_parse data = File.binread(@path) assert_equal( @expected, @@ -161,6 +208,9 @@ class TestCSV::Interface < TestCSV assert_equal(csv, csv << ["last", %Q{"row"}]) end assert_equal(%Q{1,2,3\n4,,5\nlast,"""row"""\n}, str) + + out = CSV.generate("test") { |csv| csv << ["row"] } + assert_equal("testrow\n", out) end def test_generate_line diff --git a/test/csv/test_row.rb b/test/csv/test_row.rb index 1cb851b027..23df4d4fe6 100755 --- a/test/csv/test_row.rb +++ b/test/csv/test_row.rb @@ -4,9 +4,7 @@ # tc_row.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require_relative "base" @@ -304,6 +302,17 @@ class TestCSV::Row < TestCSV end end + def test_each_pair + assert_equal([ + ["A", 1], + ["B", 2], + ["C", 3], + ["A", 4], + ["A", nil], + ], + @row.each_pair.to_a) + end + def test_enumerable assert_equal( [["A", 1], ["A", 4], ["A", nil]], @row.select { |pair| pair.first == "A" } ) @@ -323,7 +332,7 @@ class TestCSV::Row < TestCSV def test_to_hash hash = @row.to_hash - assert_equal({"A" => nil, "B" => 2, "C" => 3}, hash) + assert_equal({"A" => @row["A"], "B" => @row["B"], "C" => @row["C"]}, hash) hash.keys.each_with_index do |string_key, h| assert_predicate(string_key, :frozen?) assert_same(string_key, @row.headers[h]) @@ -377,4 +386,37 @@ class TestCSV::Row < TestCSV r = @row == [] assert_equal false, r end + + def test_dig_by_index + assert_equal(2, @row.dig(1)) + + assert_nil(@row.dig(100)) + end + + def test_dig_by_header + assert_equal(2, @row.dig("B")) + + assert_nil(@row.dig("Missing")) + end + + def test_dig_cell + row = CSV::Row.new(%w{A}, [["foo", ["bar", ["baz"]]]]) + + assert_equal("foo", row.dig(0, 0)) + assert_equal("bar", row.dig(0, 1, 0)) + + assert_equal("foo", row.dig("A", 0)) + assert_equal("bar", row.dig("A", 1, 0)) + end + + def test_dig_cell_no_dig + row = CSV::Row.new(%w{A}, ["foo"]) + + assert_raise(TypeError) do + row.dig(0, 0) + end + assert_raise(TypeError) do + row.dig("A", 0) + end + end end diff --git a/test/csv/test_table.rb b/test/csv/test_table.rb index 25ef11a772..34ea2c5c6b 100755 --- a/test/csv/test_table.rb +++ b/test/csv/test_table.rb @@ -4,9 +4,7 @@ # tc_table.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require_relative "base" @@ -263,6 +261,15 @@ class TestCSV::Table < TestCSV @table.each { |row| assert_instance_of(CSV::Row, row) } end + def test_each_split + yielded_values = [] + @table.each do |column1, column2, column3| + yielded_values << [column1, column2, column3] + end + assert_equal(@rows.collect(&:to_a), + yielded_values) + end + def test_enumerable assert_equal( @rows.values_at(0, 2), @table.select { |row| (row["B"] % 2).zero? } ) @@ -312,7 +319,7 @@ class TestCSV::Table < TestCSV assert_equal(CSV::Row.new(%w[A B C], [13, 14, 15]), @table[-1]) end - def test_delete_mixed + def test_delete_mixed_one ################## ### Mixed Mode ### ################## @@ -330,6 +337,28 @@ class TestCSV::Table < TestCSV END_RESULT end + def test_delete_mixed_multiple + ################## + ### Mixed Mode ### + ################## + # delete row and col + second_row = @rows[1] + a_col = @rows.map { |row| row["A"] } + a_col_without_second_row = a_col[0..0] + a_col[2..-1] + assert_equal([ + second_row, + a_col_without_second_row, + ], + @table.delete(1, "A")) + + # verify resulting table + assert_equal(<<-END_RESULT.gsub(/^\s+/, ""), @table.to_csv) + B,C + 2,3 + 8,9 + END_RESULT + end + def test_delete_column ################### ### Column Mode ### @@ -494,4 +523,70 @@ class TestCSV::Table < TestCSV @table.inspect.encoding], "inspect() was not ASCII compatible." ) end + + def test_dig_mixed + # by row + assert_equal(@rows[0], @table.dig(0)) + assert_nil(@table.dig(100)) # empty row + + # by col + assert_equal([2, 5, 8], @table.dig("B")) + assert_equal([nil] * @rows.size, @table.dig("Z")) # empty col + + # by row then col + assert_equal(2, @table.dig(0, 1)) + assert_equal(6, @table.dig(1, "C")) + + # by col then row + assert_equal(5, @table.dig("B", 1)) + assert_equal(9, @table.dig("C", 2)) + end + + def test_dig_by_column + @table.by_col! + + assert_equal([2, 5, 8], @table.dig(1)) + assert_equal([2, 5, 8], @table.dig("B")) + + # by col then row + assert_equal(5, @table.dig("B", 1)) + assert_equal(9, @table.dig("C", 2)) + end + + def test_dig_by_row + @table.by_row! + + assert_equal(@rows[1], @table.dig(1)) + assert_raise(TypeError) { @table.dig("B") } + + # by row then col + assert_equal(2, @table.dig(0, 1)) + assert_equal(6, @table.dig(1, "C")) + end + + def test_dig_cell + table = CSV::Table.new([CSV::Row.new(["A"], [["foo", ["bar", ["baz"]]]])]) + + # by row, col then cell + assert_equal("foo", table.dig(0, "A", 0)) + assert_equal(["baz"], table.dig(0, "A", 1, 1)) + + # by col, row then cell + assert_equal("foo", table.dig("A", 0, 0)) + assert_equal(["baz"], table.dig("A", 0, 1, 1)) + end + + def test_dig_cell_no_dig + table = CSV::Table.new([CSV::Row.new(["A"], ["foo"])]) + + # by row, col then cell + assert_raise(TypeError) do + table.dig(0, "A", 0) + end + + # by col, row then cell + assert_raise(TypeError) do + table.dig("A", 0, 0) + end + end end diff --git a/test/csv/ts_all.rb b/test/csv/ts_all.rb index 9eadf12918..f632c8195f 100644 --- a/test/csv/ts_all.rb +++ b/test/csv/ts_all.rb @@ -4,9 +4,7 @@ # ts_all.rb # -# Created by James Edward Gray II on 2005-10-31. -# Copyright 2005 James Edward Gray II. You can redistribute or modify this code -# under the terms of Ruby's license. +# Created by James Edward Gray II on 2005-10-31. require "test/unit"