smarter_csv 1.18.0 → 1.19.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.
@@ -169,6 +169,24 @@ module SmarterCSV
169
169
  def parse_line_to_hash_ruby(line, headers, options, has_quotes = false)
170
170
  return [nil, 0] if line.nil?
171
171
 
172
+ # A line with invalid bytes for its encoding (typically Latin-1 data mislabeled as
173
+ # UTF-8) would make the encoding-aware operations below (split, strip!, ...) raise
174
+ # ArgumentError. Like the C path, we parse leniently and preserve the field's raw
175
+ # bytes: process the line as BINARY, then re-tag each field with the original
176
+ # encoding. Only relabels — never transcodes. Every byte sequence is valid BINARY,
177
+ # so the recursive call cannot take this branch again.
178
+ unless line.valid_encoding?
179
+ original_encoding = line.encoding
180
+ binary_options = options.dup
181
+ %i[col_sep quote_char row_sep].each do |opt|
182
+ binary_options[opt] = options[opt].dup.force_encoding(Encoding::BINARY) if options[opt].is_a?(String)
183
+ end
184
+ hash, data_size = parse_line_to_hash_ruby(line.dup.force_encoding(Encoding::BINARY), headers, binary_options, has_quotes)
185
+ # skip empty strings: relabeling "" is a no-op, and the shared EMPTY_STRING is frozen
186
+ hash&.transform_values! { |v| v.is_a?(String) && !v.empty? ? v.force_encoding(original_encoding) : v }
187
+ return [hash, data_size]
188
+ end
189
+
172
190
  # Chomp trailing row separator
173
191
  line = line.chomp(options[:row_sep]) if options[:row_sep]
174
192
 
@@ -176,6 +194,14 @@ module SmarterCSV
176
194
  strip = options[:strip_whitespace]
177
195
  prefix = options[:missing_header_prefix]
178
196
 
197
+ # headers: { only: } SHORT-CUT (mirrors the C path's early exit): stop parsing right
198
+ # after the last wanted column and ignore everything behind it — extra columns are
199
+ # not discovered (no :column_N growth) and even an unclosed quote in an unwanted
200
+ # trailing column is ignored. _early_exit_after is the 0-based index of the last
201
+ # wanted column (set by the reader for only: without missing_headers: :raise).
202
+ early_exit = options[:_early_exit_after]
203
+ max_fields = early_exit && early_exit >= 0 ? early_exit + 1 : nil
204
+
179
205
  # Optimization #11: for unquoted lines, build the hash in one pass directly
180
206
  # from String#split — no intermediate array returned from parse_csv_line_ruby
181
207
  # and no second iteration to convert array → hash. Saves one Array allocation
@@ -187,7 +213,14 @@ module SmarterCSV
187
213
  # (default), v.empty? after strip catches both empty and whitespace-only
188
214
  # fields without a regex. Most impactful on sparse files (many empty fields).
189
215
  unless has_quotes || col_sep == ' '
190
- fields = line.split(col_sep, -1)
216
+ if max_fields
217
+ # limited split: at most max_fields + 1 elements, the last being the unparsed
218
+ # remainder of the line — drop it, it is behind the last wanted column
219
+ fields = line.split(col_sep, max_fields + 1)
220
+ fields.pop if fields.size == max_fields + 1
221
+ else
222
+ fields = line.split(col_sep, -1)
223
+ end
191
224
  n = fields.size
192
225
 
193
226
  if options[:remove_empty_hashes]
@@ -205,7 +238,10 @@ module SmarterCSV
205
238
  fields.each_with_index do |v, i| # C-level iteration, faster than Ruby while counter loop
206
239
  next if remove_empty && v.empty?
207
240
 
208
- hash[i < headers.size ? headers[i] : :"#{prefix}#{i + 1}"] = v
241
+ # Empty values become the ONE shared frozen empty string (same design as the
242
+ # C path): the fresh "" from split dies in the next minor GC instead of being
243
+ # retained per empty field in the results.
244
+ hash[i < headers.size ? headers[i] : :"#{prefix}#{i + 1}"] = v.empty? ? EMPTY_STRING : v
209
245
  end
210
246
 
211
247
  unless remove_empty
@@ -216,7 +252,9 @@ module SmarterCSV
216
252
  end
217
253
 
218
254
  # Quoted/complex path: parse into elements array, then build hash.
219
- elements, data_size = parse_csv_line_ruby(line, options, nil, has_quotes)
255
+ # max_fields makes parse_csv_line_ruby stop scanning after the last wanted column
256
+ # (same short-cut as the C path's early exit).
257
+ elements, data_size = parse_csv_line_ruby(line, options, max_fields, has_quotes)
220
258
  return [nil, -1] if data_size == -1 # unclosed quote at EOL → caller stitches next line
221
259
 
222
260
  # Optimization #6: elements are always String or nil from parse_csv_line_ruby,
@@ -236,7 +274,10 @@ module SmarterCSV
236
274
  hash = {}
237
275
  i = 0
238
276
  while i < n
239
- hash[i < headers.size ? headers[i] : :"#{prefix}#{i + 1}"] = elements[i]
277
+ v = elements[i]
278
+ # Empty values become the ONE shared frozen empty string (same design as the C path)
279
+ v = EMPTY_STRING if v.is_a?(String) && v.empty?
280
+ hash[i < headers.size ? headers[i] : :"#{prefix}#{i + 1}"] = v
240
281
  i += 1
241
282
  end
242
283
 
@@ -320,12 +361,15 @@ module SmarterCSV
320
361
  row_sep = options[:row_sep]
321
362
  row_sep_size = row_sep.is_a?(String) ? row_sep.size : 0
322
363
 
323
- # Optimization #1: for the common single-char separator, use direct
324
- # character comparison instead of allocating a substring via line[i...i+n].
325
- if col_sep_size == 1
326
- # Optimization #13: byte-level indexing for single-char separator.
327
- # col_sep and quote_char are both validated to be single-byte at option
328
- # parsing time. UTF-8 multi-byte continuation bytes (0x80–0xBF) never
364
+ # Optimization #1: for the common single-BYTE separator, use direct
365
+ # byte comparison instead of allocating a substring via line[i...i+n].
366
+ # The gate must be on bytesize, not size: a one-character multi-byte separator
367
+ # (e.g. 'é') would be scanned by its first byte only, which also occurs as the
368
+ # lead byte of other characters the character-level path below handles it.
369
+ if col_sep.bytesize == 1
370
+ # Optimization #13: byte-level indexing for single-byte separator.
371
+ # quote_char is validated to be single-byte at option parsing time.
372
+ # UTF-8 multi-byte continuation bytes (0x80–0xBF) never
329
373
  # alias ASCII delimiter bytes (0x00–0x7F), so byte scanning is safe for
330
374
  # UTF-8 strings with ASCII delimiters — no String allocation per character.
331
375
  col_sep_byte = col_sep.getbyte(0)
@@ -356,8 +400,12 @@ module SmarterCSV
356
400
  # unquoted (field_started && !in_quotes), remaining quotes are literal and
357
401
  # cannot affect parser state — jump directly to the next col_sep.
358
402
  # Mirrors Opt #10 for the unquoted side of the same trade-off.
403
+ # byteindex requires the byte offset to be on a character boundary — after
404
+ # stepping over the first byte of a multi-byte character, i is mid-character
405
+ # (a UTF-8 continuation byte, 0b10xxxxxx), so fall back to the byte loop there.
406
+ # (The Opt #10 quote jump can't be mid-character: i is always at quote_byte + 1.)
359
407
  elsif quote_boundary_standard && field_started && !in_quotes
360
- next_sep = if BYTEINDEX_AVAILABLE
408
+ next_sep = if BYTEINDEX_AVAILABLE && (line.getbyte(i) & 0xC0) != 0x80
361
409
  line.byteindex(col_sep, i)
362
410
  else
363
411
  j = i
@@ -75,12 +75,16 @@ module SmarterCSV
75
75
  def each
76
76
  return enum_for(:each) unless block_given?
77
77
 
78
- # Force row-by-row mode regardless of chunk_size setting
78
+ # Force row-by-row mode regardless of chunk_size setting.
79
+ # The explicit begin/ensure keeps the restore off the enum_for path above,
80
+ # where original_chunk_size was never captured (it would restore nil).
79
81
  original_chunk_size = @options[:chunk_size]
80
82
  @options[:chunk_size] = nil
81
- process { |row_array, _| yield row_array.first }
82
- ensure
83
- @options[:chunk_size] = original_chunk_size
83
+ begin
84
+ process { |row_array, _| yield row_array.first }
85
+ ensure
86
+ @options[:chunk_size] = original_chunk_size
87
+ end
84
88
  end
85
89
 
86
90
  # Yields each chunk as Array<Hash> plus its 0-based chunk index.
@@ -236,6 +240,15 @@ module SmarterCSV
236
240
 
237
241
  @quote_escaping_auto = options[:quote_escaping] == :auto
238
242
  @use_acceleration = options[:acceleration] && has_acceleration
243
+ # The C ParseContext stores separators and the extra-column prefix in fixed-size
244
+ # buffers (col_sep 7 bytes, row_sep 15, missing_header_prefix 63) and would
245
+ # silently truncate anything longer — fall back to the pure-Ruby parser for such
246
+ # exotic options; it handles any length.
247
+ if @use_acceleration
248
+ @use_acceleration = false if options[:col_sep].is_a?(String) && options[:col_sep].bytesize > 7
249
+ @use_acceleration = false if options[:row_sep].is_a?(String) && options[:row_sep].bytesize > 15
250
+ @use_acceleration = false if options[:missing_header_prefix].is_a?(String) && options[:missing_header_prefix].bytesize > 63
251
+ end
239
252
 
240
253
  # The single options hash used on the hot path — for :auto we always try backslash
241
254
  # first (C downgrades to RFC internally via Opt #5 when no backslash is found).
@@ -254,8 +267,10 @@ module SmarterCSV
254
267
  # Key-cleanup flags — computed once, checked per row via cheap ivar reads.
255
268
  # hash.delete(nil) / hash.delete('') only occur when key_mapping maps a header to nil/"".
256
269
  # hash.delete(:"") also catches empty headers produced by ,, in the CSV.
257
- @delete_nil_keys = !!options[:key_mapping]
258
- @delete_empty_keys = !!options[:key_mapping] || @headers.include?(:"")
270
+ # A nil header (key_mapping to nil, or nil in user_provided_headers) drops the column
271
+ @delete_nil_keys = !!options[:key_mapping] || @headers.include?(nil)
272
+ # Empty header keys are :"" with symbol keys, '' with strings_as_keys / keep_original_headers
273
+ @delete_empty_keys = !!options[:key_mapping] || @headers.include?(:"") || @headers.include?('')
259
274
 
260
275
  # Cache field_size_limit as an ivar (nil when unset → one nil-check per row, no method calls).
261
276
  @field_size_limit = options[:field_size_limit]
@@ -406,24 +421,18 @@ module SmarterCSV
406
421
  hash.delete(nil)
407
422
  hash.delete('')
408
423
  end
409
- hash.delete(:"") if @delete_empty_keys
410
-
411
- if (matcher = options[:nil_values_matching])
412
- if options[:remove_empty_values]
413
- hash.delete_if do |_k, v|
414
- str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
415
- str_val && matcher.match?(str_val)
416
- end
417
- else
418
- hash.each_key do |k|
419
- v = hash[k]
420
- str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
421
- hash[k] = nil if str_val && matcher.match?(str_val)
422
- end
423
- end
424
+ if @delete_empty_keys
425
+ hash.delete(:"")
426
+ hash.delete('')
424
427
  end
425
428
 
426
- if options[:value_converters]
429
+ if options[:nil_values_matching]
430
+ # The C parser deferred numeric conversion and zero-removal (see
431
+ # defer_value_transforms_to_ruby in the extension), so run the full Ruby
432
+ # pipeline: nil-matching on the raw strings first, then zero-removal,
433
+ # numeric conversion, and value_converters — the pure-Ruby-path order.
434
+ hash = hash_transformations(hash, options)
435
+ elsif options[:value_converters]
427
436
  options[:value_converters].each do |key, converter|
428
437
  hash[key] = converter.respond_to?(:convert) ? converter.convert(hash[key]) : converter.call(hash[key]) if hash.key?(key)
429
438
  end
@@ -632,7 +641,22 @@ module SmarterCSV
632
641
  return false unless line.include?(options[:quote_char])
633
642
 
634
643
  if options[:quote_boundary] == :standard
635
- detect_multiline_strict(line, options)
644
+ case options[:quote_escaping]
645
+ when :backslash
646
+ detect_multiline_strict(line, options, true)
647
+ when :auto
648
+ if line.include?('\\')
649
+ # :auto parses with backslash semantics first and retries with RFC semantics
650
+ # (see @hot_path_options / @quote_escaping_double) — the row is only still
651
+ # open if BOTH interpretations leave the quote open, mirroring the dual
652
+ # counting in the non-strict branch below.
653
+ detect_multiline_strict(line, options, true) && detect_multiline_strict(line, options, false)
654
+ else
655
+ detect_multiline_strict(line, options, false)
656
+ end
657
+ else
658
+ detect_multiline_strict(line, options, false)
659
+ end
636
660
  elsif options[:quote_escaping] == :auto
637
661
  escaped_count, rfc_count = count_quote_chars_auto(line, options[:quote_char], options[:col_sep])
638
662
  # If backslash-aware count is even → line is self-contained either way
@@ -657,7 +681,7 @@ module SmarterCSV
657
681
  # - inside an unquoted field: jump directly to next col_sep via C-level byteindex
658
682
  # This makes detect_multiline_strict competitive with parse_csv_line_ruby on the same
659
683
  # content, enabling it to serve as a cheap gate in the stitch loop (Opt #18).
660
- def detect_multiline_strict(line, options)
684
+ def detect_multiline_strict(line, options, allow_escaped_quotes = options[:quote_escaping] == :backslash)
661
685
  col_sep = options[:col_sep]
662
686
  quote = options[:quote_char]
663
687
  strip = options[:strip_whitespace]
@@ -667,9 +691,26 @@ module SmarterCSV
667
691
  row_sep_size = row_sep.is_a?(String) ? row_sep.size : 0
668
692
  in_quotes = false
669
693
  field_started = false
670
-
671
- if col_sep_size == 1
672
- # Fast path: byte-level scanning with byteindex skip-ahead (Opt #17)
694
+ # The gate must agree with the parser, or the stitch loop keeps accumulating a row
695
+ # the parser would have closed and fabricates "Unclosed quoted field" at EOF. Two
696
+ # parser behaviors must therefore be modeled here exactly:
697
+ # - a doubled quote inside a quoted field ("" → ") takes precedence over the
698
+ # closing-quote check when another byte follows the pair (parser.rb, issue #334);
699
+ # - with allow_escaped_quotes, a quote preceded by an odd number of backslashes is
700
+ # escaped → literal, never a closing quote (detect_multiline passes the flag per
701
+ # quote_escaping mode; :auto runs both interpretations).
702
+
703
+ # Walk the same string the parser parses: parse_line_to_hash_ruby chomps the trailing
704
+ # row separator (String#chomp — for "\n" that also removes a trailing "\r\n" or "\r")
705
+ # before parsing. Without this, a terminal doubled quote or a CRLF line ending flips
706
+ # the pair-precedence / close-quote decisions at end-of-line.
707
+ line = line.chomp(row_sep) if row_sep.is_a?(String)
708
+
709
+ if col_sep.bytesize == 1
710
+ # Fast path: byte-level scanning with byteindex skip-ahead (Opt #17).
711
+ # Gated on bytesize, not size: a one-character multi-byte separator (e.g. 'é')
712
+ # must take the character-level path below — byte scanning would match its
713
+ # first byte inside other characters sharing that lead byte.
673
714
  col_sep_byte = col_sep.getbyte(0)
674
715
  quote_byte = quote.getbyte(0)
675
716
  row_sep_bytesize = row_sep.is_a?(String) ? row_sep.bytesize : 0
@@ -697,7 +738,10 @@ module SmarterCSV
697
738
  # Opt #12 mirror: unquoted field in progress — jump to next col_sep using C-level
698
739
  # byteindex (MRI Ruby ≥ 3.2). Fallback for older Ruby / JRuby: manual getbyte loop —
699
740
  # kept inline for the same reason as the Opt #10 mirror above.
700
- next_sep = if byteindex_available
741
+ # byteindex requires a character-boundary offset — after stepping over the first
742
+ # byte of a multi-byte character, i is mid-character (a UTF-8 continuation byte),
743
+ # so use the byte loop there.
744
+ next_sep = if byteindex_available && (line.getbyte(i) & 0xC0) != 0x80
701
745
  line.byteindex(col_sep, i)
702
746
  else
703
747
  j = i
@@ -716,15 +760,28 @@ module SmarterCSV
716
760
  field_started = false
717
761
  elsif b == quote_byte
718
762
  if in_quotes
719
- # closing quote: only valid if followed by col_sep, row_sep, or end of line
720
- next_i = i + 1
721
- if next_i >= bytesize ||
722
- line.getbyte(next_i) == col_sep_byte ||
723
- (row_sep_bytesize > 0 && line.byteslice(next_i, row_sep_bytesize) == row_sep)
724
- in_quotes = false
725
- field_started = true
763
+ escaped = false
764
+ if allow_escaped_quotes
765
+ k = i - 1
766
+ k -= 1 while k >= 0 && line.getbyte(k) == 0x5C # '\\'
767
+ escaped = (i - 1 - k).odd?
768
+ end
769
+ unless escaped # escaped quote → literal, stays inside the quoted field
770
+ next_i = i + 1
771
+ if next_i + 1 < bytesize && line.getbyte(next_i) == quote_byte
772
+ # doubled quote ("" → ") with another byte following: consume the pair,
773
+ # stay inside the quoted field (precedence over the closing-quote check;
774
+ # terminal "" keeps the parser's lenient close — see parse_csv_line_ruby)
775
+ i = next_i
776
+ # closing quote: only valid if followed by col_sep, row_sep, or end of line
777
+ elsif next_i >= bytesize ||
778
+ line.getbyte(next_i) == col_sep_byte ||
779
+ (row_sep_bytesize > 0 && line.byteslice(next_i, row_sep_bytesize) == row_sep)
780
+ in_quotes = false
781
+ field_started = true
782
+ end
783
+ # else: quote inside quoted field → literal
726
784
  end
727
- # else: quote inside quoted field → literal (handles "" doubling)
728
785
  elsif !field_started # at field boundary: open quoted field
729
786
  in_quotes = true
730
787
  field_started = true
@@ -754,15 +811,27 @@ module SmarterCSV
754
811
 
755
812
  if line[i] == quote
756
813
  if in_quotes
757
- # closing quote: only valid if followed by col_sep, row_sep, or end of line
758
- next_i = i + 1
759
- if next_i >= line_size ||
760
- line[next_i...next_i + col_sep_size] == col_sep ||
761
- (row_sep_size > 0 && line[next_i...next_i + row_sep_size] == row_sep)
762
- in_quotes = false
763
- field_started = true
814
+ escaped = false
815
+ if allow_escaped_quotes
816
+ k = i - 1
817
+ k -= 1 while k >= 0 && line[k] == '\\'
818
+ escaped = (i - 1 - k).odd?
819
+ end
820
+ unless escaped # escaped quote → literal, stays inside the quoted field
821
+ next_i = i + 1
822
+ if next_i + 1 < line_size && line[next_i] == quote
823
+ # doubled quote ("" → ") with another character following: consume the
824
+ # pair, stay inside the quoted field (see byte path above)
825
+ i = next_i
826
+ # closing quote: only valid if followed by col_sep, row_sep, or end of line
827
+ elsif next_i >= line_size ||
828
+ line[next_i...next_i + col_sep_size] == col_sep ||
829
+ (row_sep_size > 0 && line[next_i...next_i + row_sep_size] == row_sep)
830
+ in_quotes = false
831
+ field_started = true
832
+ end
833
+ # else: quote inside quoted field → literal
764
834
  end
765
- # else: quote inside quoted field → literal (handles "" doubling)
766
835
  elsif !field_started # at field boundary: open quoted field
767
836
  in_quotes = true
768
837
  field_started = true
@@ -793,7 +862,9 @@ module SmarterCSV
793
862
  def blank?(value)
794
863
  case value
795
864
  when String
796
- value.empty? || BLANK_RE.match?(value)
865
+ # A string with invalid bytes for its encoding would make the regex raise —
866
+ # and it necessarily contains non-blank bytes, so it is not blank.
867
+ value.empty? || (value.valid_encoding? && BLANK_RE.match?(value))
797
868
  when NilClass
798
869
  true
799
870
  when Array
@@ -845,25 +916,18 @@ module SmarterCSV
845
916
  hash.delete(nil)
846
917
  hash.delete('')
847
918
  end
848
- hash.delete(:"") if @delete_empty_keys
849
-
850
- # Only these Ruby-only post-filters remain (user-provided Ruby objects):
851
- if (matcher = options[:nil_values_matching])
852
- if options[:remove_empty_values]
853
- hash.delete_if do |_k, v|
854
- str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
855
- str_val && matcher.match?(str_val)
856
- end
857
- else
858
- hash.each_key do |k|
859
- v = hash[k]
860
- str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
861
- hash[k] = nil if str_val && matcher.match?(str_val)
862
- end
863
- end
919
+ if @delete_empty_keys
920
+ hash.delete(:"")
921
+ hash.delete('')
864
922
  end
865
923
 
866
- if options[:value_converters]
924
+ if options[:nil_values_matching]
925
+ # The C parser deferred numeric conversion and zero-removal (see
926
+ # defer_value_transforms_to_ruby in the extension), so run the full Ruby
927
+ # pipeline: nil-matching on the raw strings first, then zero-removal,
928
+ # numeric conversion, and value_converters — the pure-Ruby-path order.
929
+ hash = hash_transformations(hash, options)
930
+ elsif options[:value_converters]
867
931
  options[:value_converters].each do |key, converter|
868
932
  hash[key] = converter.respond_to?(:convert) ? converter.convert(hash[key]) : converter.call(hash[key]) if hash.key?(key)
869
933
  end
@@ -129,20 +129,53 @@ module SmarterCSV
129
129
  warn "DEPRECATION WARNING: 'except_headers:' is deprecated. Use 'headers: { except: [...] }' instead." unless @options[:verbose] == :quiet
130
130
  end
131
131
 
132
- # Normalize only_headers/except_headers to arrays of symbols (internal names, read by C extension)
132
+ # Normalize only_headers/except_headers to arrays of the row-key type (internal names,
133
+ # read by the C extension too): with strings_as_keys / keep_original_headers the row
134
+ # keys are Strings, otherwise Symbols — the selectors must match to select anything.
135
+ string_keys = @options[:strings_as_keys] || @options[:keep_original_headers]
133
136
  if @options[:only_headers]
134
137
  values = Array(@options[:only_headers])
135
138
  bad = values.reject { |v| v.is_a?(Symbol) || v.is_a?(String) }
136
139
  raise SmarterCSV::ValidationError, "headers: { only: } elements must be String or Symbol, got: #{bad.map(&:class).uniq.inspect}" if bad.any?
137
140
 
138
- @options[:only_headers] = values.map(&:to_sym)
141
+ @options[:only_headers] = string_keys ? values.map(&:to_s) : values.map(&:to_sym)
139
142
  end
140
143
  if @options[:except_headers]
141
144
  values = Array(@options[:except_headers])
142
145
  bad = values.reject { |v| v.is_a?(Symbol) || v.is_a?(String) }
143
146
  raise SmarterCSV::ValidationError, "headers: { except: } elements must be String or Symbol, got: #{bad.map(&:class).uniq.inspect}" if bad.any?
144
147
 
145
- @options[:except_headers] = values.map(&:to_sym)
148
+ @options[:except_headers] = string_keys ? values.map(&:to_s) : values.map(&:to_sym)
149
+ end
150
+
151
+ # The Hash form of convert_values_to_numeric accepts EXACTLY ONE of only:/except:,
152
+ # with field name(s) (String/Symbol or an Array of them) as the value. Anything else
153
+ # is an invalid declaration → ValidationError, instead of silently picking a behavior
154
+ # (the C and Ruby paths used to disagree on these shapes). Values are normalized to
155
+ # the row-key type, like headers: { only: } above.
156
+ if (cvn = @options[:convert_values_to_numeric]).is_a?(Hash)
157
+ unless (cvn.keys - %i[only except]).empty?
158
+ raise SmarterCSV::ValidationError, "convert_values_to_numeric: only the keys only:/except: are accepted, got: #{cvn.keys.inspect}"
159
+ end
160
+ if cvn.key?(:only) && cvn.key?(:except)
161
+ raise SmarterCSV::ValidationError, "convert_values_to_numeric: cannot use only: and except: at the same time"
162
+ end
163
+ unless cvn.key?(:only) || cvn.key?(:except)
164
+ raise SmarterCSV::ValidationError, "convert_values_to_numeric: the Hash form requires only: or except: with field name(s)"
165
+ end
166
+
167
+ list_key = cvn.key?(:only) ? :only : :except
168
+ values = cvn[list_key].is_a?(Array) ? cvn[list_key] : [cvn[list_key]]
169
+ if values.empty?
170
+ raise SmarterCSV::ValidationError, "convert_values_to_numeric: #{list_key}: must not be empty — expects field name(s)"
171
+ end
172
+
173
+ bad = values.reject { |v| v.is_a?(Symbol) || v.is_a?(String) }
174
+ unless bad.empty?
175
+ raise SmarterCSV::ValidationError, "convert_values_to_numeric: #{list_key}: expects field name(s) (String or Symbol), got: #{bad.map(&:class).uniq.inspect}"
176
+ end
177
+
178
+ @options[:convert_values_to_numeric] = { list_key => string_keys ? values.map(&:to_s) : values.map(&:to_sym) }
146
179
  end
147
180
 
148
181
  # Deprecation: remove_values_matching → nil_values_matching
@@ -195,7 +228,10 @@ module SmarterCSV
195
228
  errors = []
196
229
  errors << "invalid row_sep" if keys.include?(:row_sep) && !option_valid?(options[:row_sep])
197
230
  errors << "invalid col_sep" if keys.include?(:col_sep) && !option_valid?(options[:col_sep])
198
- errors << "invalid quote_char" if keys.include?(:quote_char) && !option_valid?(options[:quote_char])
231
+ # quote_char has no auto-detection :auto is only valid for row_sep and col_sep
232
+ if keys.include?(:quote_char) && !(options[:quote_char].is_a?(String) && !options[:quote_char].empty?)
233
+ errors << "invalid quote_char"
234
+ end
199
235
  if keys.include?(:quote_char) && options[:quote_char].is_a?(String) && options[:quote_char].bytesize > 1
200
236
  errors << "invalid quote_char: must be a single byte (got #{options[:quote_char].inspect})"
201
237
  end
@@ -261,9 +297,11 @@ module SmarterCSV
261
297
  warn "WARNING: buffer_size (#{options[:buffer_size]}) < auto_row_sep_chars (#{arc}); bumping buffer_size to #{bumped}" unless quiet
262
298
  options[:buffer_size] = bumped
263
299
  end
300
+ # field_size_limit is overrun protection (a hard upper bound against runaway fields),
301
+ # not per-field validation — small values make no sense and are rejected.
264
302
  fsl = options[:field_size_limit]
265
- unless fsl.nil? || (fsl.is_a?(Integer) && fsl > 0)
266
- errors << "invalid field_size_limit: must be nil or a positive Integer (got #{fsl.inspect})"
303
+ unless fsl.nil? || (fsl.is_a?(Integer) && fsl >= 4096)
304
+ errors << "invalid field_size_limit: must be nil or an Integer >= 4096 (got #{fsl.inspect})"
267
305
  end
268
306
  obr = options[:on_bad_row]
269
307
  unless %i[raise skip collect].include?(obr) || obr.respond_to?(:call)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmarterCSV
4
- VERSION = "1.18.0"
4
+ VERSION = "1.19.0"
5
5
  end
@@ -204,12 +204,12 @@ module SmarterCSV
204
204
  str = field.to_s
205
205
  return str if @disable_auto_quoting && !force_quotes
206
206
 
207
- # double-quote fields if we force that, or if the field contains the comma, new-line, or quote character
207
+ # quote fields if we force that, or if the field contains the col_sep, row_sep, or quote_char
208
208
  contains_special_char = str.match(@quote_regex)
209
209
  if force_quotes || contains_special_char
210
- str = str.gsub(@quote_char, @escaped_quote_char) if contains_special_char # escape double-quote
210
+ str = str.gsub(@quote_char, @escaped_quote_char) if contains_special_char # escape the quote_char
211
211
 
212
- "\"#{str}\""
212
+ "#{@quote_char}#{str}#{@quote_char}"
213
213
  else
214
214
  str
215
215
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smarter_csv
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.18.0
4
+ version: 1.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tilo Sloboda
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-19 00:00:00.000000000 Z
10
+ date: 2026-08-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal
@@ -83,6 +83,7 @@ files:
83
83
  - docs/upgrade_wizard.html
84
84
  - docs/value_converters.md
85
85
  - docs/warnings.md
86
+ - ext/smarter_csv/cpu_flags.rb
86
87
  - ext/smarter_csv/extconf.rb
87
88
  - ext/smarter_csv/smarter_csv.c
88
89
  - ext/smarter_csv/vendor/LICENSE-fast_float-MIT
@@ -140,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
141
  - !ruby/object:Gem::Version
141
142
  version: '0'
142
143
  requirements: []
143
- rubygems_version: 3.6.9
144
+ rubygems_version: 4.0.17
144
145
  specification_version: 4
145
146
  summary: Fastest end-to-end CSV ingestion for Ruby with smart defaults and Rails-ready
146
147
  hash output