1# frozen_string_literal: true
2
3require "forwardable"
4
5class CSV
6  #
7  # A CSV::Row is part Array and part Hash.  It retains an order for the fields
8  # and allows duplicates just as an Array would, but also allows you to access
9  # fields by name just as you could if they were in a Hash.
10  #
11  # All rows returned by CSV will be constructed from this class, if header row
12  # processing is activated.
13  #
14  class Row
15    #
16    # Construct a new CSV::Row from +headers+ and +fields+, which are expected
17    # to be Arrays.  If one Array is shorter than the other, it will be padded
18    # with +nil+ objects.
19    #
20    # The optional +header_row+ parameter can be set to +true+ to indicate, via
21    # CSV::Row.header_row?() and CSV::Row.field_row?(), that this is a header
22    # row.  Otherwise, the row is assumes to be a field row.
23    #
24    # A CSV::Row object supports the following Array methods through delegation:
25    #
26    # * empty?()
27    # * length()
28    # * size()
29    #
30    def initialize(headers, fields, header_row = false)
31      @header_row = header_row
32      headers.each { |h| h.freeze if h.is_a? String }
33
34      # handle extra headers or fields
35      @row = if headers.size >= fields.size
36        headers.zip(fields)
37      else
38        fields.zip(headers).each(&:reverse!)
39      end
40    end
41
42    # Internal data format used to compare equality.
43    attr_reader :row
44    protected   :row
45
46    ### Array Delegation ###
47
48    extend Forwardable
49    def_delegators :@row, :empty?, :length, :size
50
51    def initialize_copy(other)
52      super
53      @row = @row.dup
54    end
55
56    # Returns +true+ if this is a header row.
57    def header_row?
58      @header_row
59    end
60
61    # Returns +true+ if this is a field row.
62    def field_row?
63      not header_row?
64    end
65
66    # Returns the headers of this row.
67    def headers
68      @row.map(&:first)
69    end
70
71    #
72    # :call-seq:
73    #   field( header )
74    #   field( header, offset )
75    #   field( index )
76    #
77    # This method will return the field value by +header+ or +index+.  If a field
78    # is not found, +nil+ is returned.
79    #
80    # When provided, +offset+ ensures that a header match occurs on or later
81    # than the +offset+ index.  You can use this to find duplicate headers,
82    # without resorting to hard-coding exact indices.
83    #
84    def field(header_or_index, minimum_index = 0)
85      # locate the pair
86      finder = (header_or_index.is_a?(Integer) || header_or_index.is_a?(Range)) ? :[] : :assoc
87      pair   = @row[minimum_index..-1].send(finder, header_or_index)
88
89      # return the field if we have a pair
90      if pair.nil?
91        nil
92      else
93        header_or_index.is_a?(Range) ? pair.map(&:last) : pair.last
94      end
95    end
96    alias_method :[], :field
97
98    #
99    # :call-seq:
100    #   fetch( header )
101    #   fetch( header ) { |row| ... }
102    #   fetch( header, default )
103    #
104    # This method will fetch the field value by +header+. It has the same
105    # behavior as Hash#fetch: if there is a field with the given +header+, its
106    # value is returned. Otherwise, if a block is given, it is yielded the
107    # +header+ and its result is returned; if a +default+ is given as the
108    # second argument, it is returned; otherwise a KeyError is raised.
109    #
110    def fetch(header, *varargs)
111      raise ArgumentError, "Too many arguments" if varargs.length > 1
112      pair = @row.assoc(header)
113      if pair
114        pair.last
115      else
116        if block_given?
117          yield header
118        elsif varargs.empty?
119          raise KeyError, "key not found: #{header}"
120        else
121          varargs.first
122        end
123      end
124    end
125
126    # Returns +true+ if there is a field with the given +header+.
127    def has_key?(header)
128      !!@row.assoc(header)
129    end
130    alias_method :include?, :has_key?
131    alias_method :key?,     :has_key?
132    alias_method :member?,  :has_key?
133    alias_method :header?,  :has_key?
134
135    #
136    # :call-seq:
137    #   []=( header, value )
138    #   []=( header, offset, value )
139    #   []=( index, value )
140    #
141    # Looks up the field by the semantics described in CSV::Row.field() and
142    # assigns the +value+.
143    #
144    # Assigning past the end of the row with an index will set all pairs between
145    # to <tt>[nil, nil]</tt>.  Assigning to an unused header appends the new
146    # pair.
147    #
148    def []=(*args)
149      value = args.pop
150
151      if args.first.is_a? Integer
152        if @row[args.first].nil?  # extending past the end with index
153          @row[args.first] = [nil, value]
154          @row.map! { |pair| pair.nil? ? [nil, nil] : pair }
155        else                      # normal index assignment
156          @row[args.first][1] = value
157        end
158      else
159        index = index(*args)
160        if index.nil?             # appending a field
161          self << [args.first, value]
162        else                      # normal header assignment
163          @row[index][1] = value
164        end
165      end
166    end
167
168    #
169    # :call-seq:
170    #   <<( field )
171    #   <<( header_and_field_array )
172    #   <<( header_and_field_hash )
173    #
174    # If a two-element Array is provided, it is assumed to be a header and field
175    # and the pair is appended.  A Hash works the same way with the key being
176    # the header and the value being the field.  Anything else is assumed to be
177    # a lone field which is appended with a +nil+ header.
178    #
179    # This method returns the row for chaining.
180    #
181    def <<(arg)
182      if arg.is_a?(Array) and arg.size == 2  # appending a header and name
183        @row << arg
184      elsif arg.is_a?(Hash)                  # append header and name pairs
185        arg.each { |pair| @row << pair }
186      else                                   # append field value
187        @row << [nil, arg]
188      end
189
190      self  # for chaining
191    end
192
193    #
194    # A shortcut for appending multiple fields.  Equivalent to:
195    #
196    #   args.each { |arg| csv_row << arg }
197    #
198    # This method returns the row for chaining.
199    #
200    def push(*args)
201      args.each { |arg| self << arg }
202
203      self  # for chaining
204    end
205
206    #
207    # :call-seq:
208    #   delete( header )
209    #   delete( header, offset )
210    #   delete( index )
211    #
212    # Used to remove a pair from the row by +header+ or +index+.  The pair is
213    # located as described in CSV::Row.field().  The deleted pair is returned,
214    # or +nil+ if a pair could not be found.
215    #
216    def delete(header_or_index, minimum_index = 0)
217      if header_or_index.is_a? Integer                 # by index
218        @row.delete_at(header_or_index)
219      elsif i = index(header_or_index, minimum_index)  # by header
220        @row.delete_at(i)
221      else
222        [ ]
223      end
224    end
225
226    #
227    # The provided +block+ is passed a header and field for each pair in the row
228    # and expected to return +true+ or +false+, depending on whether the pair
229    # should be deleted.
230    #
231    # This method returns the row for chaining.
232    #
233    # If no block is given, an Enumerator is returned.
234    #
235    def delete_if(&block)
236      return enum_for(__method__) { size } unless block_given?
237
238      @row.delete_if(&block)
239
240      self  # for chaining
241    end
242
243    #
244    # This method accepts any number of arguments which can be headers, indices,
245    # Ranges of either, or two-element Arrays containing a header and offset.
246    # Each argument will be replaced with a field lookup as described in
247    # CSV::Row.field().
248    #
249    # If called with no arguments, all fields are returned.
250    #
251    def fields(*headers_and_or_indices)
252      if headers_and_or_indices.empty?  # return all fields--no arguments
253        @row.map(&:last)
254      else                              # or work like values_at()
255        all = []
256        headers_and_or_indices.each do |h_or_i|
257          if h_or_i.is_a? Range
258            index_begin = h_or_i.begin.is_a?(Integer) ? h_or_i.begin :
259                                                        index(h_or_i.begin)
260            index_end   = h_or_i.end.is_a?(Integer)   ? h_or_i.end :
261                                                        index(h_or_i.end)
262            new_range   = h_or_i.exclude_end? ? (index_begin...index_end) :
263                                                (index_begin..index_end)
264            all.concat(fields.values_at(new_range))
265          else
266            all << field(*Array(h_or_i))
267          end
268        end
269        return all
270      end
271    end
272    alias_method :values_at, :fields
273
274    #
275    # :call-seq:
276    #   index( header )
277    #   index( header, offset )
278    #
279    # This method will return the index of a field with the provided +header+.
280    # The +offset+ can be used to locate duplicate header names, as described in
281    # CSV::Row.field().
282    #
283    def index(header, minimum_index = 0)
284      # find the pair
285      index = headers[minimum_index..-1].index(header)
286      # return the index at the right offset, if we found one
287      index.nil? ? nil : index + minimum_index
288    end
289
290    #
291    # Returns +true+ if +data+ matches a field in this row, and +false+
292    # otherwise.
293    #
294    def field?(data)
295      fields.include? data
296    end
297
298    include Enumerable
299
300    #
301    # Yields each pair of the row as header and field tuples (much like
302    # iterating over a Hash). This method returns the row for chaining.
303    #
304    # If no block is given, an Enumerator is returned.
305    #
306    # Support for Enumerable.
307    #
308    def each(&block)
309      return enum_for(__method__) { size } unless block_given?
310
311      @row.each(&block)
312
313      self  # for chaining
314    end
315
316    alias_method :each_pair, :each
317
318    #
319    # Returns +true+ if this row contains the same headers and fields in the
320    # same order as +other+.
321    #
322    def ==(other)
323      return @row == other.row if other.is_a? CSV::Row
324      @row == other
325    end
326
327    #
328    # Collapses the row into a simple Hash.  Be warned that this discards field
329    # order and clobbers duplicate fields.
330    #
331    def to_h
332      hash = {}
333      each do |key, _value|
334        hash[key] = self[key] unless hash.key?(key)
335      end
336      hash
337    end
338    alias_method :to_hash, :to_h
339
340    alias_method :to_ary, :to_a
341
342    #
343    # Returns the row as a CSV String.  Headers are not used.  Equivalent to:
344    #
345    #   csv_row.fields.to_csv( options )
346    #
347    def to_csv(**options)
348      fields.to_csv(options)
349    end
350    alias_method :to_s, :to_csv
351
352    #
353    # Extracts the nested value specified by the sequence of +index+ or +header+ objects by calling dig at each step,
354    # returning nil if any intermediate step is nil.
355    #
356    def dig(index_or_header, *indexes)
357      value = field(index_or_header)
358      if value.nil?
359        nil
360      elsif indexes.empty?
361        value
362      else
363        unless value.respond_to?(:dig)
364          raise TypeError, "#{value.class} does not have \#dig method"
365        end
366        value.dig(*indexes)
367      end
368    end
369
370    # A summary of fields, by header, in an ASCII compatible String.
371    def inspect
372      str = ["#<", self.class.to_s]
373      each do |header, field|
374        str << " " << (header.is_a?(Symbol) ? header.to_s : header.inspect) <<
375               ":" << field.inspect
376      end
377      str << ">"
378      begin
379        str.join('')
380      rescue  # any encoding error
381        str.map do |s|
382          e = Encoding::Converter.asciicompat_encoding(s.encoding)
383          e ? s.encode(e) : s.force_encoding("ASCII-8BIT")
384        end.join('')
385      end
386    end
387  end
388end
389