csv-utils 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/csv-validator CHANGED
@@ -1,26 +1,26 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'csv'
4
5
  begin
5
6
  require 'rchardet'
6
7
  rescue LoadError
7
- $stderr.puts 'gem install rchardet'
8
+ warn 'gem install rchardet'
8
9
  exit 1
9
10
  end
10
11
 
12
+ # checks copies, so the row's values keep their bytes for the messages and the correction file
11
13
  def utf8?(str)
12
- str
13
- .force_encoding('utf-8')
14
- .valid_encoding?
14
+ str.dup.force_encoding('utf-8').valid_encoding?
15
15
  end
16
16
 
17
17
  def convert_to_utf8(str, current_encoding)
18
18
  return nil unless current_encoding
19
19
 
20
- str.force_encoding(current_encoding)
21
- return nil unless str.valid_encoding?
20
+ converted = str.dup.force_encoding(current_encoding)
21
+ return nil unless converted.valid_encoding?
22
22
 
23
- str.encode('utf-8')
23
+ converted.encode('utf-8')
24
24
  end
25
25
 
26
26
  def detect_encoding(col)
@@ -28,7 +28,7 @@ def detect_encoding(col)
28
28
  end
29
29
 
30
30
  def strip_bom!(col)
31
- col.sub!("\xEF\xBB\xBF".force_encoding('ASCII-8BIT'), '')
31
+ col.sub!("\xEF\xBB\xBF".b, '')
32
32
  end
33
33
 
34
34
  id_column_name = ARGV[1]
@@ -42,7 +42,7 @@ begin
42
42
 
43
43
  id_column_name ||= headers[0]
44
44
  unless headers.include?(id_column_name)
45
- $stderr.puts("header #{id_column_name} not found in current set of headers")
45
+ warn("header #{id_column_name} not found in current set of headers")
46
46
  exit 1
47
47
  end
48
48
 
@@ -64,20 +64,18 @@ begin
64
64
  while (row = csv.shift)
65
65
  csv_lineno += 1
66
66
 
67
- unless row.size == headers.size
68
- $stderr.puts "row(#{csv_lineno}): invalid number of columns, expected #{headers.size} got #{row.size}"
69
- end
67
+ warn "row(#{csv_lineno}): invalid number of columns, expected #{headers.size} got #{row.size}" unless row.size == headers.size
70
68
 
71
69
  row.each_with_index do |col, idx|
72
70
  next if col.nil? || utf8?(col)
73
71
 
74
- $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: non UTF-8 characters found in #{col.inspect}"
72
+ warn "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: non UTF-8 characters found in #{col.inspect}"
75
73
  encoding = detect_encoding(col)
76
74
  if (col_utf8_encoded = convert_to_utf8(col, encoding))
77
75
  puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: converted to UTF-8 from #{encoding} \"#{col_utf8_encoded}\""
78
76
  out_proc.call [row[id_column_num], csv_lineno, (idx + 1), headers[idx], col_utf8_encoded]
79
77
  else
80
- $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: unknown character encoding"
78
+ warn "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: unknown character encoding"
81
79
  end
82
80
  end
83
81
  end
data/lib/csv-utils.rb CHANGED
@@ -8,6 +8,11 @@ module CSVUtils
8
8
  autoload :ByteOrderMark, 'csv_utils/byte_order_mark'
9
9
  autoload :CharacterEncoding, 'csv_utils/character_encoding'
10
10
  autoload :EncodingOptions, 'csv_utils/encoding_options'
11
+ autoload :Error, 'csv_utils/error'
12
+ autoload :HeaderNotFoundError, 'csv_utils/header_not_found_error'
13
+ autoload :MalformedRowError, 'csv_utils/malformed_row_error'
14
+ autoload :RowReader, 'csv_utils/row_reader'
15
+ autoload :UnsortedFileError, 'csv_utils/unsorted_file_error'
11
16
  autoload :CSVCompare, 'csv_utils/csv_compare'
12
17
  autoload :CSVExtender, 'csv_utils/csv_extender'
13
18
  autoload :CSVIterator, 'csv_utils/csv_iterator'
@@ -1,21 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CSVUtils
4
- # Guesses the character encoding of a file without a byte order mark from a sample of its bytes.
4
+ # Guesses the character encoding of a file without a byte order mark from its bytes.
5
5
  module CharacterEncoding
6
6
  # Bytes Windows-1252 leaves undefined; converting them to UTF-8 raises.
7
7
  # @return [Regexp]
8
8
  WINDOWS_1252_UNDEFINED = /[\x81\x8D\x8F\x90\x9D]/n
9
9
 
10
- # 'UTF-8' when the sample is valid UTF-8. Otherwise 'Windows-1252', the usual encoding of Excel exports,
11
- # or 'ISO-8859-1' when the sample has a byte Windows-1252 leaves undefined, since every ISO-8859-1 byte
10
+ # The last character of a chunk when it's multibyte, whole or cut off: a lead byte and continuation bytes.
11
+ TRAILING_MULTIBYTE_CHAR = /[\xC0-\xFF][\x80-\xBF]*\z/n
12
+ private_constant :TRAILING_MULTIBYTE_CHAR
13
+
14
+ # 'UTF-8' when the bytes are valid UTF-8. Otherwise 'Windows-1252', the usual encoding of Excel exports,
15
+ # or 'ISO-8859-1' when they have a byte Windows-1252 leaves undefined, since every ISO-8859-1 byte
12
16
  # converts to UTF-8.
13
- # @param sample [String] bytes from the start of the file, ending on a whole character
17
+ # @param sample [String] bytes from the start of the file
18
+ # @param truncated [Boolean] whether the sample was cut off, so its last character may be incomplete
19
+ # @return [String]
20
+ def self.detect(sample, truncated: false)
21
+ scan([sample], truncated)
22
+ end
23
+
24
+ # Like {.detect}, for a file read in chunks, so it can check a whole file without holding it in memory.
25
+ # A character split between chunks is checked whole.
26
+ # @param chunks [Enumerable<String>] the file's bytes in order
27
+ # @return [String]
28
+ def self.detect_stream(chunks)
29
+ scan(chunks, false)
30
+ end
31
+
32
+ # @api private
33
+ # @param chunks [Enumerable<String>]
34
+ # @param truncated [Boolean] whether to ignore an incomplete character at the end
35
+ # @return [String]
36
+ def self.scan(chunks, truncated)
37
+ utf8 = true
38
+ undefined = false
39
+ carry = ''.b
40
+ chunks.each do |chunk|
41
+ bytes = carry + chunk.b
42
+ whole, carry = split_trailing_char(bytes)
43
+ utf8 &&= whole.force_encoding(Encoding::UTF_8).valid_encoding?
44
+ undefined ||= bytes.match?(WINDOWS_1252_UNDEFINED)
45
+ break unless utf8 || !undefined
46
+ end
47
+ utf8 &&= truncated || carry.force_encoding(Encoding::UTF_8).valid_encoding?
48
+ encoding_name(utf8, undefined)
49
+ end
50
+
51
+ # @api private
52
+ # @param bytes [String] binary
53
+ # @return [Array(String, String)] the bytes before their last multibyte character, and that character
54
+ def self.split_trailing_char(bytes)
55
+ tail_start = [bytes.bytesize - 4, 0].max
56
+ idx = bytes.byteslice(tail_start..) =~ TRAILING_MULTIBYTE_CHAR
57
+ return [bytes.dup, ''.b] unless idx
58
+
59
+ [bytes.byteslice(0, tail_start + idx), bytes.byteslice((tail_start + idx)..)]
60
+ end
61
+
62
+ # @api private
63
+ # @param utf8 [Boolean]
64
+ # @param undefined [Boolean]
14
65
  # @return [String]
15
- def self.detect(sample)
16
- bytes = sample.b
17
- return 'UTF-8' if bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
18
- return 'ISO-8859-1' if bytes.match?(WINDOWS_1252_UNDEFINED)
66
+ def self.encoding_name(utf8, undefined)
67
+ return 'UTF-8' if utf8
68
+ return 'ISO-8859-1' if undefined
19
69
 
20
70
  'Windows-1252'
21
71
  end
@@ -37,15 +37,21 @@ module CSVUtils
37
37
 
38
38
  # Walks both files and yields each difference.
39
39
  # @param secondary_data_file [String] path of the file to bring in line with the primary file
40
+ # @param check_order [Boolean] raise when a file isn't sorted the way the compare block expects, by comparing
41
+ # each record with the one before it in the same file. A file is only checked when the block compares its
42
+ # first record equal to itself, so a block written for different headers in each file skips the check.
40
43
  # @yieldparam action [Symbol] :create (only in the primary file), :update (in both, with changed
41
44
  # update_comparison_columns) or :delete (only in the secondary file)
42
- # @yieldparam record [Hash{String => String}] the primary record for :create and :update,
43
- # the secondary record for :delete
45
+ # @yieldparam record [CSVIterator::RowWrapper] the primary record for :create and :update, the secondary
46
+ # record for :delete; a Hash of header to value that knows the line it starts on
44
47
  # @return [void]
45
- def compare(secondary_data_file, &)
46
- open_csv(primary_data_file) do |src, src_headers|
47
- open_csv(secondary_data_file) do |dest, dest_headers|
48
- compare_records(src, src_headers, dest, dest_headers, &)
48
+ # @raise [HeaderNotFoundError] when an update_comparison_column isn't in one of the files
49
+ # @raise [UnsortedFileError] when check_order finds a record out of order
50
+ def compare(secondary_data_file, check_order: true, &)
51
+ @check_order = check_order
52
+ open_csv(primary_data_file) do |src|
53
+ open_csv(secondary_data_file) do |dest|
54
+ compare_records(src, dest, &)
49
55
  end
50
56
  end
51
57
  end
@@ -53,22 +59,22 @@ module CSVUtils
53
59
  private
54
60
 
55
61
  # walks both sorted files at once, advancing whichever side is behind
56
- def compare_records(src, src_headers, dest, dest_headers)
57
- src_record = next_record(src_headers, src)
58
- dest_record = next_record(dest_headers, dest)
62
+ def compare_records(src, dest)
63
+ src_record = next_record(src)
64
+ dest_record = next_record(dest)
59
65
 
60
66
  while src_record || dest_record
61
67
  case record_action(src_record, dest_record)
62
68
  when :create
63
69
  yield :create, src_record
64
- src_record = next_record(src_headers, src)
70
+ src_record = next_record(src, src_record)
65
71
  when :delete
66
72
  yield :delete, dest_record
67
- dest_record = next_record(dest_headers, dest)
73
+ dest_record = next_record(dest, dest_record)
68
74
  else
69
75
  yield :update, src_record if update_row?(src_record, dest_record)
70
- src_record = next_record(src_headers, src)
71
- dest_record = next_record(dest_headers, dest)
76
+ src_record = next_record(src, src_record)
77
+ dest_record = next_record(dest, dest_record)
72
78
  end
73
79
  end
74
80
  end
@@ -87,23 +93,33 @@ module CSVUtils
87
93
  end
88
94
  end
89
95
 
96
+ # yields the file's path, reader, headers and whether its order is checked, decided at its first record
90
97
  def open_csv(file)
91
98
  csv = CSV.open(file, 'rb', **EncodingOptions.read(csv_options))
92
- yield csv, read_headers(csv)
99
+ reader = RowReader.new(csv)
100
+ headers = reader.shift || []
101
+ check_update_comparison_columns(headers)
102
+ yield({ file: file, reader: reader, headers: headers, check_order: nil })
93
103
  ensure
94
104
  csv&.close
95
105
  end
96
106
 
97
- def read_headers(csv)
98
- headers = csv.shift || []
99
- headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
100
- headers
107
+ def check_update_comparison_columns(headers)
108
+ missing = (update_comparison_columns || []) - headers
109
+ raise HeaderNotFoundError.new(missing.first, headers) unless missing.empty?
101
110
  end
102
111
 
103
- def next_record(headers, csv)
104
- return unless (row = csv.shift)
112
+ def next_record(source, previous_record = nil)
113
+ return unless (row = source[:reader].shift)
105
114
 
106
- headers.zip(row).to_h
115
+ record = CSVIterator::RowWrapper.create(source[:headers], row, source[:reader].lineno)
116
+ source[:check_order] = @check_order && compare_proc.call(record, record).zero? if source[:check_order].nil?
117
+ check_order(source[:file], previous_record, record) if previous_record && source[:check_order]
118
+ record
119
+ end
120
+
121
+ def check_order(file, previous_record, record)
122
+ raise UnsortedFileError.new(file, record.lineno) if compare_proc.call(previous_record, record).positive?
107
123
  end
108
124
 
109
125
  def update_row?(src_record, dest_record)
@@ -13,12 +13,10 @@ module CSVUtils
13
13
  # @return [Array<String>] the byte order marks stripped from the first header, as binary strings
14
14
  BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS.keys.freeze
15
15
 
16
- # @return [Array<String>, nil] the raw row read before the current one, for debugging malformed rows
17
- attr_reader :prev_row
18
-
19
16
  # A row as a hash of header to value that knows its line number in the file.
20
17
  class RowWrapper < Hash
21
- # @return [Integer] line number of the row in the file, counting the header row as line 1
18
+ # @return [Integer] line the row starts on in the file, counting the header row as line 1. A quoted value
19
+ # with a line break spans several lines, so this can differ from the row's position in the file.
22
20
  attr_accessor :lineno
23
21
 
24
22
  # @param headers [Array<String>]
@@ -52,9 +50,10 @@ module CSVUtils
52
50
  # @example
53
51
  # CSVUtils::CSVIterator.auto_detect('export.tsv').each { |row| puts row['name'] }
54
52
  # @param path [String]
53
+ # @param full_scan [Boolean] check the encoding of the whole file, not only its first megabyte
55
54
  # @return [CSVIterator]
56
- def self.auto_detect(path)
57
- options = CSVOptions.new(path)
55
+ def self.auto_detect(path, full_scan: false)
56
+ options = CSVOptions.new(path, full_scan: full_scan)
58
57
  new(path, options.to_csv_options)
59
58
  end
60
59
 
@@ -68,10 +67,19 @@ module CSVUtils
68
67
  @csv_options = EncodingOptions.read(csv_options)
69
68
  end
70
69
 
70
+ # The raw row read before the current one (the header row for the first row), for debugging;
71
+ # after a full pass it's the last row.
72
+ # @return [Array<String>, nil]
73
+ def prev_row
74
+ @reader&.prev_row
75
+ end
76
+
71
77
  # Yields each row from the start of the file.
72
78
  # @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
73
79
  # @yieldparam row [RowWrapper]
74
80
  # @return [Enumerator, nil] an enumerator without a block
81
+ # @raise [MalformedRowError] for a row CSV can't parse, with the line it starts on and {#prev_row}
82
+ # @raise [CSV::InvalidEncodingError] for bytes that don't match the encoding, with their line
75
83
  def each(headers = nil, &)
76
84
  return enum_for(:each, headers) unless block_given?
77
85
 
@@ -89,11 +97,11 @@ module CSVUtils
89
97
  # @param value [String, nil] header whose value is the hash value
90
98
  # @yieldparam row [RowWrapper] used instead of value to compute the hash value
91
99
  # @return [Hash]
92
- # @raise [RuntimeError] when key or value isn't a header
100
+ # @raise [HeaderNotFoundError] when key or value isn't a header
93
101
  def to_hash(key, value = nil, &)
94
102
  file_headers = headers
95
- raise("header #{key} not found in #{file_headers}") unless file_headers.include?(key)
96
- raise("headers #{value} not found in #{file_headers}") if value && !file_headers.include?(value)
103
+ raise HeaderNotFoundError.new(key, file_headers) unless file_headers.include?(key)
104
+ raise HeaderNotFoundError.new(value, file_headers) if value && !file_headers.include?(value)
97
105
 
98
106
  value_proc =
99
107
  if value
@@ -152,33 +160,16 @@ module CSVUtils
152
160
  end
153
161
 
154
162
  def each_row(csv, headers)
155
- @prev_row = nil
156
- row = shift_first_row(csv)
157
- lineno = 1
158
- unless headers
159
- headers = row || []
160
- row = csv.shift
161
- lineno += 1
162
- end
163
-
164
- while row
165
- yield RowWrapper.create(headers, row, lineno)
166
- @prev_row = row
167
- row = csv.shift
168
- lineno += 1
163
+ @reader = RowReader.new(csv)
164
+ headers ||= @reader.shift || []
165
+ while (row = @reader.shift)
166
+ yield RowWrapper.create(headers, row, @reader.lineno)
169
167
  end
170
168
  end
171
169
 
172
170
  # an empty file has no headers, and an empty first header cell is nil
173
171
  def read_headers(csv)
174
- shift_first_row(csv) || []
175
- end
176
-
177
- # the first row without the byte order mark, whether it's read as headers or as data
178
- def shift_first_row(csv)
179
- row = csv.shift
180
- row[0] = ByteOrderMark.strip(row[0]) if row && row[0]
181
- row
172
+ RowReader.new(csv).shift || []
182
173
  end
183
174
  end
184
175
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CSVUtils
4
- # Detects the separators, byte order mark, encoding and column count of a CSV file from its first line.
4
+ # Detects the separators, byte order mark, encoding and column count of a CSV file from its header row
5
+ # and, for the encoding, its first {SAMPLE_SIZE} bytes or, with full_scan, all of them.
5
6
  #
6
7
  # @example
7
8
  # options = CSVUtils::CSVOptions.new('data.csv')
@@ -10,12 +11,14 @@ module CSVUtils
10
11
  # @return [Hash{String => String}] byte order marks, as binary strings, to the encoding they indicate
11
12
  BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS
12
13
 
13
- # @return [Array<String>] column separators to look for, the first one found in the line wins
14
+ # @return [Array<String>] column separators to look for; the one found most often outside quotes in the
15
+ # header row wins, and on a tie the one listed first
14
16
  COL_SEPARATORS = [
15
17
  "\x02",
16
18
  "\t",
17
19
  '|',
18
- ','
20
+ ',',
21
+ ';'
19
22
  ].freeze
20
23
 
21
24
  # @return [Array<String>] row separators to look for, the first one found in the line wins
@@ -37,12 +40,19 @@ module CSVUtils
37
40
  # @return [Integer] bytes read to detect the encoding of a file without a byte order mark
38
41
  SAMPLE_SIZE = 1024 * 1024
39
42
 
43
+ QUOTED = /"[^"]*"/n
44
+ # the header row: up to the first line break outside quotes
45
+ HEADER_ROW = /\A(?:[^"\r\n]|"[^"]*")*(?:\r\n|\n|\r)?/n
46
+ LINE_BREAK = /\r\n|\n|\r/n
47
+ private_constant :QUOTED, :HEADER_ROW, :LINE_BREAK
48
+
40
49
  # @return [Integer, nil] number of headers, nil without a column separator
41
50
  attr_reader :columns
42
51
  # @return [String, nil] the byte order mark the file starts with, as a binary string
43
52
  attr_reader :byte_order_mark
44
- # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the bytes of
45
- # the first {SAMPLE_SIZE} bytes: 'UTF-8', 'Windows-1252' or 'ISO-8859-1' (see {CharacterEncoding.detect})
53
+ # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the first
54
+ # {SAMPLE_SIZE} bytes, or all of them with full_scan: 'UTF-8', 'Windows-1252' or 'ISO-8859-1'
55
+ # (see {CharacterEncoding.detect})
46
56
  attr_reader :encoding
47
57
  # @return [String, nil] one of {COL_SEPARATORS}
48
58
  attr_reader :col_separator
@@ -50,16 +60,20 @@ module CSVUtils
50
60
  attr_reader :row_separator
51
61
 
52
62
  # @param io [String, IO] path of the file, or an IO positioned at its first line; up to {SAMPLE_SIZE}
53
- # bytes are read from it
54
- def initialize(io)
55
- sample = read_sample(io)
56
- line = first_line(sample)
57
-
58
- @col_separator = auto_detect_col_sep(line)
59
- @row_separator = auto_detect_row_sep(line)
60
- @byte_order_mark = get_byte_order_mark(line)
61
- @encoding = get_character_encoding(@byte_order_mark, sample)
62
- @columns = get_number_of_columns(line) if @col_separator
63
+ # bytes are read from it, or all of it with full_scan
64
+ # @param full_scan [Boolean] check the encoding of the whole file, in {SAMPLE_SIZE} chunks, for files whose
65
+ # first bytes that aren't UTF-8 may come after the sample
66
+ def initialize(io, full_scan: false)
67
+ open_io(io) do |input|
68
+ sample = input.read(SAMPLE_SIZE) || ''
69
+ line = first_line(sample)
70
+
71
+ @col_separator = auto_detect_col_sep(line)
72
+ @row_separator = auto_detect_row_sep(line)
73
+ @byte_order_mark = get_byte_order_mark(line)
74
+ @encoding = get_character_encoding(@byte_order_mark, sample, full_scan ? input : nil)
75
+ @columns = get_number_of_columns(line) if @col_separator
76
+ end
63
77
  end
64
78
 
65
79
  # Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
@@ -86,10 +100,14 @@ module CSVUtils
86
100
  end
87
101
 
88
102
  # @api private
89
- # @param line [String]
90
- # @return [String, nil]
103
+ # @param line [String] the header row
104
+ # @return [String, nil] the separator found most often outside quotes, nil when there's none
91
105
  def auto_detect_col_sep(line)
92
- COL_SEPARATORS.detect { |sep| line.include?(sep) }
106
+ unquoted = line.b.gsub(QUOTED, '')
107
+ counts = COL_SEPARATORS.map { |sep| unquoted.count(sep) }
108
+ return if counts.max.zero?
109
+
110
+ COL_SEPARATORS[counts.index(counts.max)]
93
111
  end
94
112
 
95
113
  # @api private
@@ -127,9 +145,13 @@ module CSVUtils
127
145
  # @api private
128
146
  # @param bom [String, nil]
129
147
  # @param sample [String] bytes from the start of the file
148
+ # @param rest [IO, nil] the rest of the file, to scan it all
130
149
  # @return [String]
131
- def get_character_encoding(bom, sample)
132
- BYTE_ORDER_MARKS[bom] || CharacterEncoding.detect(sample)
150
+ def get_character_encoding(bom, sample, rest = nil)
151
+ return BYTE_ORDER_MARKS[bom] if bom
152
+ return CharacterEncoding.detect_stream(chunks(sample, rest)) if rest
153
+
154
+ CharacterEncoding.detect(sample, truncated: sample.bytesize == SAMPLE_SIZE)
133
155
  end
134
156
 
135
157
  # @api private
@@ -142,19 +164,26 @@ module CSVUtils
142
164
  private
143
165
 
144
166
  # an empty file reads as an empty sample, which makes the options invalid
145
- def read_sample(io)
146
- sample = (io.is_a?(String) ? File.open(io, 'rb') { |file| file.read(SAMPLE_SIZE) } : io.read(SAMPLE_SIZE)) || ''
147
- sample.bytesize < SAMPLE_SIZE ? sample : drop_last_multibyte_char(sample)
167
+ def open_io(io, &)
168
+ io.is_a?(String) ? File.open(io, 'rb', &) : yield(io)
148
169
  end
149
170
 
150
- # a full sample can end mid-character, so its last multibyte character, whole or not, is dropped
151
- def drop_last_multibyte_char(sample)
152
- sample.sub(/[\xC0-\xFF][\x80-\xBF]*\z/n, '')
171
+ def chunks(sample, rest)
172
+ Enumerator.new do |chunks|
173
+ chunks << sample
174
+ while (chunk = rest.read(SAMPLE_SIZE))
175
+ chunks << chunk
176
+ end
177
+ end
153
178
  end
154
179
 
180
+ # The header row, which a quoted line break doesn't end. A stray quote falls back to the first line.
155
181
  def first_line(sample)
156
- line_end = sample.index("\n")
157
- line_end ? sample.byteslice(0, line_end + 1) : sample
182
+ line = sample.b[HEADER_ROW]
183
+ return line if line.bytesize == sample.bytesize || line.end_with?("\n", "\r")
184
+
185
+ line_break = sample.b.match(LINE_BREAK)
186
+ line_break ? sample.b.byteslice(0, line_break.end(0)) : sample.b
158
187
  end
159
188
 
160
189
  def wide_encoding?