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.
@@ -45,6 +45,8 @@ VALUE Qempty_string = Qnil;
45
45
  static ID id_col_sep, id_quote_char, id_row_sep, id_missing_header_prefix;
46
46
  static ID id_strip_whitespace, id_remove_empty_hashes, id_remove_empty_values;
47
47
  static ID id_quote_escaping, id_convert_values_to_numeric, id_remove_zero_values;
48
+ static ID id_nil_values_matching, id_field_size_limit;
49
+ static VALUE eFieldSizeLimitExceeded = Qnil;
48
50
  static ID id_only, id_except, id_quote_boundary;
49
51
  static ID id_only_headers, id_except_headers, id_keep_cols, id_strict;
50
52
  static ID id_keep_bitmap, id_keep_extra_cols, id_early_exit_after_sym;
@@ -76,6 +78,7 @@ typedef struct {
76
78
  bool remove_zero_values;
77
79
  bool allow_escaped_quotes; /* quote_escaping == :backslash */
78
80
  bool quote_boundary_standard;
81
+ long field_size_limit; /* 0 = no limit (see field_transform_opts) */
79
82
 
80
83
  /* Numeric conversion: 0=off, 1=all, 2=only listed keys, 3=except listed keys */
81
84
  int numeric_mode;
@@ -235,18 +238,27 @@ VALUE return_parser_result(VALUE elements, long data_size) {
235
238
  return result;
236
239
  }
237
240
 
238
- /* Helper: trim leading/trailing spaces and tabs from a field when strip_ws is set.
239
- * Sets *out_start to the first kept byte and returns the trimmed length (0 when the
240
- * field is empty or all whitespace). This is the trim performed at every field
241
- * boundary in all three parsers; kept always_inline so each call site compiles to
242
- * the same code as the hand-written loops it replaces (no performance cost). */
241
+ /* Byte set stripped by Ruby's String#strip: space, \t, \n, \v, \f, \r, and \0.
242
+ * trim_field must match it exactly so the C path strips the same characters as the
243
+ * Ruby path's fields.each(&:strip!) e.g. the stray trailing \r a mixed LF/CRLF
244
+ * file leaves at the end of a field. */
245
+ static inline __attribute__((always_inline))
246
+ bool ruby_strip_byte(char c) {
247
+ return c == ' ' || (c >= '\t' && c <= '\r') || c == '\0';
248
+ }
249
+
250
+ /* Helper: trim leading/trailing whitespace (Ruby String#strip semantics) from a field
251
+ * when strip_ws is set. Sets *out_start to the first kept byte and returns the trimmed
252
+ * length (0 when the field is empty or all whitespace). This is the trim performed at
253
+ * every field boundary in all three parsers; kept always_inline so each call site
254
+ * compiles to the same code as the hand-written loops it replaces (no performance cost). */
243
255
  static inline __attribute__((always_inline))
244
256
  long trim_field(char *field, long field_len, bool strip_ws, char **out_start) {
245
257
  char *trim_start = field;
246
258
  char *trim_end = field + field_len - 1;
247
259
  if (strip_ws) {
248
- while (trim_start <= trim_end && (*trim_start == ' ' || *trim_start == '\t')) trim_start++;
249
- while (trim_end >= trim_start && (*trim_end == ' ' || *trim_end == '\t')) trim_end--;
260
+ while (trim_start <= trim_end && ruby_strip_byte(*trim_start)) trim_start++;
261
+ while (trim_end >= trim_start && ruby_strip_byte(*trim_end)) trim_end--;
250
262
  }
251
263
  *out_start = trim_start;
252
264
  return (trim_end >= trim_start) ? (trim_end - trim_start + 1) : 0;
@@ -293,14 +305,16 @@ static inline __attribute__((always_inline))
293
305
  bool is_valid_close(const char *p, const char *endP,
294
306
  const char *col_sepP, long col_sep_len,
295
307
  const char *row_sepP, long row_sep_len) {
308
+ /* Each separator comparison is bounded by endP: a separator truncated by
309
+ * end-of-line is not a separator (and reading past endP would be out of bounds). */
296
310
  bool valid_close = (p + 1 >= endP);
297
- if (!valid_close) {
311
+ if (!valid_close && p + 1 + col_sep_len <= endP) {
298
312
  valid_close = true;
299
313
  for (long j = 0; j < col_sep_len; j++) {
300
314
  if (*(p + 1 + j) != *(col_sepP + j)) { valid_close = false; break; }
301
315
  }
302
316
  }
303
- if (!valid_close && row_sep_len > 0) {
317
+ if (!valid_close && row_sep_len > 0 && p + 1 + row_sep_len <= endP) {
304
318
  valid_close = true;
305
319
  for (long j = 0; j < row_sep_len; j++) {
306
320
  if (*(p + 1 + j) != *(row_sepP + j)) { valid_close = false; break; }
@@ -317,6 +331,18 @@ bool is_valid_close(const char *p, const char *endP,
317
331
  * site as cheap as the hand-written check it replaces. */
318
332
  static inline __attribute__((always_inline))
319
333
  char *chomp_row_sep(char *endP, long line_len, const char *row_sepP, long row_sep_len) {
334
+ /* When the row separator is a lone LF, mirror Ruby's String#chomp("\n") exactly:
335
+ * remove a trailing "\r\n", "\n", or "\r" — the trailing \r is part of the LINE
336
+ * TERMINATOR, not data (CRLF lines read with row_sep "\n"). The Ruby path chomps
337
+ * with String#chomp (parser.rb), so the C path must match or the surviving \r
338
+ * corrupts values (strip_whitespace: false) and invalidates a close-quote on the
339
+ * last field of a CRLF line. */
340
+ if (row_sep_len == 1 && row_sepP[0] == '\n') {
341
+ char *startP = endP - line_len;
342
+ if (endP > startP && endP[-1] == '\n') endP--;
343
+ if (endP > startP && endP[-1] == '\r') endP--;
344
+ return endP;
345
+ }
320
346
  if (row_sep_len > 0
321
347
  && line_len >= row_sep_len
322
348
  && memcmp(endP - row_sep_len, row_sepP, (size_t)row_sep_len) == 0) {
@@ -410,11 +436,15 @@ static VALUE rb_parse_csv_line(VALUE self, VALUE line, VALUE col_sep, VALUE quot
410
436
  bool field_started = false; // for quote_boundary_standard: true once field has non-boundary content
411
437
 
412
438
  while (p < endP) {
413
- col_sep_found = true;
414
- for (i = 0; (i < col_sep_len) && (p + i < endP); i++) {
415
- if (*(p + i) != *(col_sepP + i)) {
416
- col_sep_found = false;
417
- break;
439
+ /* A separator only matches when it fits completely before endP — a partial
440
+ * separator truncated by end-of-line is field content, not a separator. */
441
+ col_sep_found = (p + col_sep_len <= endP);
442
+ if (col_sep_found) {
443
+ for (i = 0; i < col_sep_len; i++) {
444
+ if (*(p + i) != *(col_sepP + i)) {
445
+ col_sep_found = false;
446
+ break;
447
+ }
418
448
  }
419
449
  }
420
450
 
@@ -576,10 +606,13 @@ static inline VALUE get_key_for_index(long index, VALUE headers, long headers_le
576
606
  // Use existing header from the headers array
577
607
  return rb_ary_entry(headers, index);
578
608
  } else {
579
- // Generate a new key for extra columns: "column_7" -> :column_7
580
- char key_buf[64];
581
- snprintf(key_buf, sizeof(key_buf), "%s%ld", prefix_str, index + 1);
582
- return ID2SYM(rb_intern(key_buf));
609
+ // Generate a new key for extra columns: "column_7" -> :column_7.
610
+ // Built as a UTF-8 Ruby string and interned via rb_str_intern: rb_intern on a
611
+ // char* interns US-ASCII only and raises EncodingError for non-ASCII prefixes
612
+ // (e.g. missing_header_prefix: "spalte_ä_"). Extra columns are rare, so the
613
+ // extra allocation is not on the hot path.
614
+ VALUE key_str = rb_enc_sprintf(rb_utf8_encoding(), "%s%ld", prefix_str, index + 1);
615
+ return rb_str_intern(key_str);
583
616
  }
584
617
  }
585
618
 
@@ -605,12 +638,14 @@ static inline VALUE try_numeric_conversion(char *s, long n, int decimal_precisio
605
638
  }
606
639
 
607
640
  /* Single pass: validate the token against the same grammar as the Ruby path's
608
- * NUMERIC_REGEX = /\A[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\z/ and, in the same pass,
609
- * extract everything the fast paths need:
641
+ * NUMERIC_REGEX = /\A[+-]?\d+(?:\.\d+)?\z/ and, in the same pass, extract everything
642
+ * the fast paths need:
610
643
  * - mantissa value m10 (exact for <= 18 digits; `overflow` flags beyond)
611
644
  * - significant-digit count `sig` (leading zeros excluded; matches the Ruby
612
- * significant_digits helper / Oj dec_cnt) — drives the :auto Float/BigDecimal split
613
- * - base-10 exponent e10 (from the fraction length and any explicit exponent)
645
+ * significant_digits helper) — drives the :auto Float/BigDecimal split
646
+ * - base-10 exponent e10 (from the fraction length)
647
+ * Exponent forms ("1e3", "12E5") are deliberately NOT numbers: in real-world CSV data
648
+ * they are far more often identifiers than scientific notation (issue #345).
614
649
  * Anything the grammar rejects returns Qundef (stays a String), keeping the C and
615
650
  * Ruby paths byte-identical on what does and does not convert. */
616
651
  long i = 0;
@@ -623,41 +658,29 @@ static inline VALUE try_numeric_conversion(char *s, long n, int decimal_precisio
623
658
  int sig_started = 0;
624
659
  bool overflow = false;
625
660
  long int_digits = 0, frac_digits = 0;
626
- bool seen_dot = false, seen_exp = false, any_digit = false, exp_any = false;
627
- int64_t exp_val = 0; int exp_neg = 0;
661
+ bool seen_dot = false;
628
662
 
629
663
  for (; i < n; i++) {
630
664
  char c = s[i];
631
665
  if (c >= '0' && c <= '9') {
632
- any_digit = true;
633
- if (!seen_exp) {
634
- if (seen_dot) frac_digits++; else int_digits++;
635
- if (sig_started) sig++;
636
- else if (c != '0') { sig_started = 1; sig = 1; }
637
- if (m10digits < 19) { m10 = m10 * 10 + (uint64_t)(c - '0'); m10digits++; }
638
- else overflow = true;
639
- } else {
640
- exp_any = true;
641
- exp_val = exp_val * 10 + (c - '0');
642
- if (exp_val > 1000000) overflow = true; /* extreme exponent → strtod fallback */
643
- }
644
- } else if (c == '.' && !seen_dot && !seen_exp) {
666
+ if (seen_dot) frac_digits++; else int_digits++;
667
+ if (sig_started) sig++;
668
+ else if (c != '0') { sig_started = 1; sig = 1; }
669
+ if (m10digits < 19) { m10 = m10 * 10 + (uint64_t)(c - '0'); m10digits++; }
670
+ else overflow = true;
671
+ } else if (c == '.' && !seen_dot) {
645
672
  seen_dot = true;
646
- } else if ((c == 'e' || c == 'E') && !seen_exp && any_digit) {
647
- seen_exp = true;
648
- if (i + 1 < n && (s[i + 1] == '+' || s[i + 1] == '-')) { exp_neg = (s[i + 1] == '-'); i++; }
649
673
  } else {
650
674
  return Qundef; /* invalid char for a number → not numeric */
651
675
  }
652
676
  }
653
677
 
654
678
  /* Enforce NUMERIC_REGEX exactly: an integer part is required; a dot requires a
655
- * fraction digit; an exponent requires an exponent digit. */
679
+ * fraction digit. */
656
680
  if (int_digits == 0) return Qundef;
657
681
  if (seen_dot && frac_digits == 0) return Qundef;
658
- if (seen_exp && !exp_any) return Qundef;
659
682
 
660
- bool is_decimal = seen_dot || seen_exp;
683
+ bool is_decimal = seen_dot;
661
684
 
662
685
  if (!is_decimal) {
663
686
  /* Integer. Fast path when it fits in a long; otherwise a Ruby Integer/Bignum. */
@@ -669,14 +692,14 @@ static inline VALUE try_numeric_conversion(char *s, long n, int decimal_precisio
669
692
  return rb_cstr_to_inum(RSTRING_PTR(str), 10, false);
670
693
  }
671
694
 
672
- /* Decimal (has a '.' or an exponent) — honor decimal_precision. 0=float, 1=auto, 2=bigdecimal */
695
+ /* Decimal (has a '.') — honor decimal_precision. 0=float, 1=auto, 2=bigdecimal */
673
696
  if (decimal_precision == 2 || (decimal_precision == 1 && sig > 16)) {
674
697
  VALUE str = rb_str_new(s, n);
675
698
  return rb_funcall(rb_cObject, id_BigDecimal, 1, str);
676
699
  }
677
700
 
678
- /* Float. base-10 exponent = explicit exponent minus the fraction length. */
679
- int64_t e10 = (exp_neg ? -exp_val : exp_val) - (int64_t)frac_digits;
701
+ /* Float. base-10 exponent = minus the fraction length. */
702
+ int64_t e10 = -(int64_t)frac_digits;
680
703
  double d;
681
704
  if (!overflow && m10digits >= 1 && m10digits <= 19 && ((long)m10digits + e10) >= -307) {
682
705
  /* Eisel-Lemire is correctly-rounded for any nonzero mantissa that fits exactly in a
@@ -684,7 +707,7 @@ static inline VALUE try_numeric_conversion(char *s, long n, int decimal_precisio
684
707
  * UINT64_MAX ~1.8e19). Verified bit-for-bit vs the stdlib over 1..19-digit ties. */
685
708
  d = (m10 == 0) ? (neg ? -0.0 : 0.0) : fj_eisel_lemire_s2d(e10, m10, neg);
686
709
  } else {
687
- /* >19 digits / extreme or subnormal exponent: fall back to Ruby's own correctly-rounded
710
+ /* >19 digits / subnormal magnitude (very long fraction): fall back to Ruby's own correctly-rounded
688
711
  * strtod (rb_cstr_to_dbl) — the exact conversion String#to_f uses — so the C path and the
689
712
  * Ruby path produce the identical double on every platform, not just where the system
690
713
  * strtod happens to be correctly rounded. The token is pre-validated, so badcheck=0. */
@@ -739,6 +762,7 @@ typedef struct {
739
762
  const char *prefix_str;
740
763
  long headers_len;
741
764
  long hash_capa; // Pre-computed capacity for lazy hash allocation
765
+ long field_size_limit; // 0 = no limit; raw field bytes above this raise FieldSizeLimitExceeded
742
766
  int numeric_mode; // 0=off, 1=all, 2=only, 3=except
743
767
  int decimal_precision; // 0=float, 1=auto (BigDecimal above 16 sig digits), 2=bigdecimal
744
768
  bool remove_empty_values;
@@ -768,8 +792,10 @@ static inline void ensure_hash_allocated(field_transform_opts *opts) {
768
792
  * 3. Try numeric conversion (strtol/strtod) — avoids Ruby String allocation
769
793
  * 4. Insert the final value into the hash as String
770
794
  *
771
- * For quoted fields, pass is_quoted=true — numeric conversion is skipped since
772
- * the raw C string may differ from the unescaped content.
795
+ * For quoted fields, pass is_quoted=true — it routes the value through quote
796
+ * unescaping. Numeric conversion runs the same as for unquoted fields: quoting
797
+ * does NOT suppress conversion ("42" in quotes becomes 42), matching the Ruby
798
+ * path, where hash_transformations sees the already-unquoted value.
773
799
  *
774
800
  * Returns: true if a non-blank value was inserted, false otherwise.
775
801
  * (Used to track all_blank for remove_empty_hashes.)
@@ -780,6 +806,17 @@ static inline __attribute__((always_inline)) bool insert_field_into_hash(
780
806
  long element_count, bool is_quoted,
781
807
  char quote_char_val, rb_encoding *encoding
782
808
  ) {
809
+ // 0. Overrun protection: check the RAW field size BEFORE any conversion, so an
810
+ // oversized digit-only field raises here instead of being converted to a huge
811
+ // Integer (Bignum conversion cost grows with the square of the digit count —
812
+ // the exact overrun field_size_limit exists to prevent). Same error and message
813
+ // as the Ruby path's post-parse check; on_bad_row can quarantine it as usual.
814
+ if (opts->field_size_limit > 0 && trimmed_len > opts->field_size_limit) {
815
+ rb_raise(eFieldSizeLimitExceeded,
816
+ "Field exceeds field_size_limit of %ld bytes (got %ld bytes)",
817
+ opts->field_size_limit, trimmed_len);
818
+ }
819
+
783
820
  VALUE key = get_key_for_index(element_count, opts->headers, opts->headers_len, opts->prefix_str);
784
821
 
785
822
  // 1. Empty/blank field handling
@@ -850,9 +887,31 @@ static inline __attribute__((always_inline)) bool insert_field_into_hash(
850
887
  : rb_enc_str_new(trim_start, trimmed_len, encoding);
851
888
  ensure_hash_allocated(opts);
852
889
  rb_hash_aset(opts->hash, key, field);
890
+
891
+ /* Blank-ROW semantics: the Ruby path's row test is `value.strip.empty?`, and
892
+ * String#strip also removes NUL bytes — so a field of only strip-set bytes
893
+ * (space, \t, \n, \v, \f, \r, \0) is inserted as data but must NOT mark the
894
+ * row non-blank. The first-byte check keeps this off the hot path: real
895
+ * values almost never start with a strip-set byte here (strip_whitespace
896
+ * already trimmed them when it is on). */
897
+ if (ruby_strip_byte(trim_start[0])) {
898
+ for (long j = 1; j < trimmed_len; j++) {
899
+ if (!ruby_strip_byte(trim_start[j])) return true;
900
+ }
901
+ return false; /* only strip-set bytes → row-blank */
902
+ }
853
903
  return true;
854
904
  }
855
905
 
906
+ /* nil_values_matching must be matched against the RAW string value of a field, before
907
+ * numeric conversion or zero-removal (the Ruby hash-transformation order). When the option
908
+ * is set, the C parser therefore defers those two value transformations to the Ruby side:
909
+ * numeric_mode stays 0 and remove_zero_values is forced off, so fields reach
910
+ * hash_transformations as raw Strings. */
911
+ static inline bool defer_value_transforms_to_ruby(VALUE options_hash) {
912
+ return RTEST(rb_hash_aref(options_hash, ID2SYM(id_nil_values_matching)));
913
+ }
914
+
856
915
  /* Helper: parse the convert_values_to_numeric option into a mode + key list.
857
916
  * mode: 0=off, 1=all, 2=only listed keys, 3=except listed keys.
858
917
  * Writes through the out-params only when the option is set, so callers must
@@ -959,12 +1018,18 @@ __attribute__((hot)) static VALUE rb_parse_line_to_hash(VALUE self, VALUE line,
959
1018
  bool remove_empty = RTEST(rb_hash_aref(options_hash, ID2SYM(id_remove_empty_hashes)));
960
1019
  bool remove_empty_values = RTEST(rb_hash_aref(options_hash, ID2SYM(id_remove_empty_values)));
961
1020
  bool remove_zero_values = RTEST(rb_hash_aref(options_hash, ID2SYM(id_remove_zero_values)));
1021
+ VALUE fsl_val = rb_hash_aref(options_hash, ID2SYM(id_field_size_limit));
1022
+ long field_size_limit = NIL_P(fsl_val) ? 0 : NUM2LONG(fsl_val);
962
1023
 
963
1024
  // Numeric conversion: supports true (all), {only: [...]}, {except: [...]}
964
1025
  // numeric_mode: 0=off, 1=all, 2=only listed keys, 3=except listed keys
965
1026
  int numeric_mode = 0;
966
1027
  VALUE numeric_keys = Qnil;
967
- parse_numeric_option(options_hash, &numeric_mode, &numeric_keys);
1028
+ if (defer_value_transforms_to_ruby(options_hash)) {
1029
+ remove_zero_values = false; /* Ruby applies nil_values_matching first, then these */
1030
+ } else {
1031
+ parse_numeric_option(options_hash, &numeric_mode, &numeric_keys);
1032
+ }
968
1033
  int decimal_precision = parse_decimal_precision(options_hash);
969
1034
 
970
1035
  // quote_escaping and quote_boundary are only needed in Section 5 (quoted/slow path).
@@ -1132,6 +1197,7 @@ __attribute__((hot)) static VALUE rb_parse_line_to_hash(VALUE self, VALUE line,
1132
1197
  .numeric_mode = numeric_mode,
1133
1198
  .decimal_precision = decimal_precision,
1134
1199
  .remove_empty_values = remove_empty_values,
1200
+ .field_size_limit = field_size_limit,
1135
1201
  .remove_zero_values = remove_zero_values,
1136
1202
  };
1137
1203
 
@@ -1143,7 +1209,11 @@ __attribute__((hot)) static VALUE rb_parse_line_to_hash(VALUE self, VALUE line,
1143
1209
  *
1144
1210
  * __builtin_expect hints to the compiler that this branch is likely taken.
1145
1211
  */
1146
- if (__builtin_expect(!has_quotes && col_sep_len == 1, 1)) {
1212
+ if (endP == startP) {
1213
+ /* Empty line (after chomp) → zero fields, matching Ruby's "".split(col_sep, -1) == [].
1214
+ * Sections 6/7 then handle blank-row removal / nil-padding for ALL headers —
1215
+ * no column gets an empty string. */
1216
+ } else if (__builtin_expect(!has_quotes && col_sep_len == 1, 1)) {
1147
1217
  char sep = *col_sepP;
1148
1218
  char *sep_pos = NULL;
1149
1219
 
@@ -1254,9 +1324,11 @@ __attribute__((hot)) static VALUE rb_parse_line_to_hash(VALUE self, VALUE line,
1254
1324
  // so skip the comparison entirely.
1255
1325
  // For single-char separator: direct byte compare.
1256
1326
  // For multi-char separator: pre-filter on first byte, then check the rest.
1257
- if (!in_quotes && *p == sep_char_slow) {
1327
+ if (!in_quotes && *p == sep_char_slow && p + col_sep_len <= endP) {
1328
+ /* The full separator must fit before endP — a partial separator truncated
1329
+ * by end-of-line is field content, not a separator. */
1258
1330
  col_sep_found = true;
1259
- for (i = 1; (i < col_sep_len) && (p + i < endP); i++) {
1331
+ for (i = 1; i < col_sep_len; i++) {
1260
1332
  if (*(p + i) != *(col_sepP + i)) { col_sep_found = false; break; }
1261
1333
  }
1262
1334
  } else {
@@ -1506,9 +1578,17 @@ __attribute__((cold)) static VALUE rb_new_parse_context(VALUE self, VALUE header
1506
1578
  ctx->remove_empty = RTEST(rb_hash_aref(options_hash, ID2SYM(id_remove_empty_hashes)));
1507
1579
  ctx->remove_empty_values = RTEST(rb_hash_aref(options_hash, ID2SYM(id_remove_empty_values)));
1508
1580
  ctx->remove_zero_values = RTEST(rb_hash_aref(options_hash, ID2SYM(id_remove_zero_values)));
1581
+ {
1582
+ VALUE fsl_val = rb_hash_aref(options_hash, ID2SYM(id_field_size_limit));
1583
+ ctx->field_size_limit = NIL_P(fsl_val) ? 0 : NUM2LONG(fsl_val);
1584
+ }
1509
1585
 
1510
1586
  /* Numeric conversion */
1511
- parse_numeric_option(options_hash, &ctx->numeric_mode, &ctx->numeric_keys);
1587
+ if (defer_value_transforms_to_ruby(options_hash)) {
1588
+ ctx->remove_zero_values = false; /* Ruby applies nil_values_matching first, then these */
1589
+ } else {
1590
+ parse_numeric_option(options_hash, &ctx->numeric_mode, &ctx->numeric_keys);
1591
+ }
1512
1592
  ctx->decimal_precision = parse_decimal_precision(options_hash);
1513
1593
 
1514
1594
  /* quote_escaping → allow_escaped_quotes */
@@ -1684,6 +1764,7 @@ __attribute__((hot)) static VALUE rb_parse_line_to_hash_ctx(VALUE self, VALUE li
1684
1764
  .numeric_mode = numeric_mode,
1685
1765
  .decimal_precision = decimal_precision,
1686
1766
  .remove_empty_values = remove_empty_values,
1767
+ .field_size_limit = ctx->field_size_limit,
1687
1768
  .remove_zero_values = remove_zero_values,
1688
1769
  };
1689
1770
 
@@ -1693,7 +1774,11 @@ __attribute__((hot)) static VALUE rb_parse_line_to_hash_ctx(VALUE self, VALUE li
1693
1774
  * (a) no filter + no early exit → pure memchr loop, zero extra branches
1694
1775
  * (b) filter active → bitmap/early-exit checks per field
1695
1776
  * ======================================== */
1696
- if (__builtin_expect(!has_quotes && col_sep_len == 1, 1)) {
1777
+ if (endP == startP) {
1778
+ /* Empty line (after chomp) → zero fields, matching Ruby's "".split(col_sep, -1) == [].
1779
+ * Sections 6/7 then handle blank-row removal / nil-padding for ALL headers —
1780
+ * no column gets an empty string. */
1781
+ } else if (__builtin_expect(!has_quotes && col_sep_len == 1, 1)) {
1697
1782
  char sep = *col_sepP;
1698
1783
  char *sep_pos = NULL;
1699
1784
 
@@ -1771,9 +1856,11 @@ __attribute__((hot)) static VALUE rb_parse_line_to_hash_ctx(VALUE self, VALUE li
1771
1856
  char sep_char_slow = *col_sepP;
1772
1857
 
1773
1858
  while (p < endP) {
1774
- if (!in_quotes && *p == sep_char_slow) {
1859
+ if (!in_quotes && *p == sep_char_slow && p + col_sep_len <= endP) {
1860
+ /* The full separator must fit before endP — a partial separator truncated
1861
+ * by end-of-line is field content, not a separator. */
1775
1862
  col_sep_found = true;
1776
- for (i = 1; (i < col_sep_len) && (p + i < endP); i++) {
1863
+ for (i = 1; i < col_sep_len; i++) {
1777
1864
  if (*(p + i) != *(col_sepP + i)) { col_sep_found = false; break; }
1778
1865
  }
1779
1866
  } else {
@@ -2018,9 +2105,16 @@ static VALUE rb_count_quote_chars_auto(VALUE self, VALUE line, VALUE quote_char,
2018
2105
 
2019
2106
  void Init_smarter_csv(void) {
2020
2107
  SmarterCSV = rb_const_get(rb_cObject, rb_intern("SmarterCSV"));
2108
+ eFieldSizeLimitExceeded = rb_const_get(SmarterCSV, rb_intern("FieldSizeLimitExceeded"));
2109
+ rb_gc_register_address(&eFieldSizeLimitExceeded);
2021
2110
  Parser = rb_const_get(SmarterCSV, rb_intern("Parser"));
2022
2111
  eMalformedCSVError = rb_const_get(SmarterCSV, rb_intern("MalformedCSV"));
2112
+ /* One shared empty string for all empty field values (avoids a String allocation per
2113
+ * empty field). It MUST be frozen — shared and mutable would mean mutating one empty
2114
+ * value silently changes every other one — and UTF-8, like Ruby's empty strings. */
2023
2115
  Qempty_string = rb_str_new_literal("");
2116
+ rb_enc_associate(Qempty_string, rb_utf8_encoding());
2117
+ rb_obj_freeze(Qempty_string);
2024
2118
  rb_gc_register_address(&Qempty_string);
2025
2119
 
2026
2120
  // Cache symbol IDs for fast options hash lookups
@@ -2034,6 +2128,8 @@ void Init_smarter_csv(void) {
2034
2128
  id_quote_escaping = rb_intern("quote_escaping");
2035
2129
  id_convert_values_to_numeric = rb_intern("convert_values_to_numeric");
2036
2130
  id_remove_zero_values = rb_intern("remove_zero_values");
2131
+ id_nil_values_matching = rb_intern("nil_values_matching");
2132
+ id_field_size_limit = rb_intern("field_size_limit");
2037
2133
  id_only = rb_intern("only");
2038
2134
  id_except = rb_intern("except");
2039
2135
  id_quote_boundary = rb_intern("quote_boundary");
@@ -3,11 +3,12 @@
3
3
  module SmarterCSV
4
4
  module HashTransformations
5
5
  # Frozen regex constants for performance (avoid recompilation on every value)
6
- NUMERIC_REGEX = /\A[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\z/.freeze
6
+ # Exponent forms ("1e3", "12E5") are deliberately NOT numbers: in real-world CSV data
7
+ # they are far more often identifiers than scientific notation (issue #345).
8
+ NUMERIC_REGEX = /\A[+-]?\d+(?:\.\d+)?\z/.freeze
7
9
  # FLOAT_REGEX = /\A[+-]?\d+\.\d+\z/.freeze
8
10
  # INTEGER_REGEX = /\A[+-]?\d+\z/.freeze
9
11
  ZERO_REGEX = /\A[+-]?0+(?:\.0+)?\z/.freeze # could be +0.0
10
- EXPONENT_CHARS = %w[e E].freeze # mantissa scan stops here in significant_digits
11
12
 
12
13
  # First-byte values that can begin a numeric literal — used to skip the numeric
13
14
  # regexes for values that obviously aren't numbers (e.g. city names).
@@ -40,10 +41,12 @@ module SmarterCSV
40
41
  keys_to_delete = nil # lazily allocated only if something is actually removed
41
42
 
42
43
  hash.each do |k, v|
43
- # Nil-ify values matching the pattern (keeps the key; remove_empty_values handles deletion)
44
+ # Nil-ify values matching the pattern (keeps the key; remove_empty_values handles deletion).
45
+ # A string with invalid bytes for its encoding would make the regex raise — and it
46
+ # cannot match a pattern, so skip it (same guard on the zero/numeric regexes below).
44
47
  if nil_values_matching
45
48
  str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
46
- if str_val && nil_values_matching.match?(str_val)
49
+ if str_val && str_val.valid_encoding? && nil_values_matching.match?(str_val)
47
50
  hash[k] = nil
48
51
  v = nil
49
52
  # fall through: remove_empty_values will delete the key if true
@@ -58,7 +61,7 @@ module SmarterCSV
58
61
  end
59
62
 
60
63
  # Handle both string zeros ("0", "0.0") and numeric zeros (already converted by C)
61
- if remove_zero_values && ((v.is_a?(String) && ZERO_REGEX.match?(v)) || (v.is_a?(Numeric) && v == 0))
64
+ if remove_zero_values && ((v.is_a?(String) && v.valid_encoding? && ZERO_REGEX.match?(v)) || (v.is_a?(Numeric) && v == 0))
62
65
  (keys_to_delete ||= []) << k
63
66
  next
64
67
  end
@@ -70,10 +73,9 @@ module SmarterCSV
70
73
  # so a value whose first byte isn't a digit, '+', or '-' cannot be numeric — skip the regex entirely.
71
74
  first_byte = v.getbyte(0)
72
75
  if first_byte && ((first_byte >= ZERO_BYTE && first_byte <= NINE_BYTE) || first_byte == MINUS_BYTE || first_byte == PLUS_BYTE)
73
- if NUMERIC_REGEX.match?(v)
74
- # A value with a '.' or an exponent is a decimal → honor decimal_precision;
75
- # otherwise it's an integer.
76
- hash[k] = if v.include?('.') || v.include?('e') || v.include?('E')
76
+ if v.valid_encoding? && NUMERIC_REGEX.match?(v)
77
+ # A value with a '.' is a decimal → honor decimal_precision; otherwise it's an integer.
78
+ hash[k] = if v.include?('.')
77
79
  convert_decimal(v, options[:decimal_precision])
78
80
  else
79
81
  v.to_i
@@ -128,7 +130,7 @@ module SmarterCSV
128
130
 
129
131
  protected
130
132
 
131
- # Convert a decimal string (has a '.' or an exponent) to a numeric, honoring
133
+ # Convert a decimal string (has a '.') to a numeric, honoring
132
134
  # decimal_precision: :float -> Float, :bigdecimal -> BigDecimal, :auto -> Float unless
133
135
  # the value carries more than 16 significant digits (then BigDecimal, no precision loss).
134
136
  def convert_decimal(str, decimal_precision)
@@ -138,7 +140,7 @@ module SmarterCSV
138
140
  when :bigdecimal
139
141
  BigDecimal(str)
140
142
  else # :auto
141
- # A float token always has a '.' or 'e', so a token of <= 17 bytes holds at most
143
+ # A float token always has a '.', so a token of <= 17 bytes holds at most
142
144
  # 16 digits and therefore <= 16 significant digits — skip the per-char scan and go
143
145
  # straight to Float (the common case: coordinates, sensor readings, prices). Only
144
146
  # longer tokens can reach the BigDecimal threshold, so pay for the scan only then.
@@ -150,14 +152,13 @@ module SmarterCSV
150
152
  end
151
153
  end
152
154
 
153
- # Count significant mantissa digits (leading zeros excluded, trailing and fraction
154
- # digits included, exponent excluded). Matches the C path's fj_sig_digits / Oj's dec_cnt
155
- # so :auto picks Float vs BigDecimal identically on both paths.
155
+ # Count significant digits (leading zeros excluded, trailing and fraction
156
+ # digits included). Matches the C path's count so :auto picks Float vs BigDecimal
157
+ # identically on both paths.
156
158
  def significant_digits(str)
157
159
  cnt = 0
158
160
  started = false
159
161
  str.each_char do |c|
160
- break if EXPONENT_CHARS.include?(c)
161
162
  next unless c >= '0' && c <= '9'
162
163
 
163
164
  if started
@@ -46,7 +46,20 @@ module SmarterCSV
46
46
  candidate
47
47
  else
48
48
  counts[header] += 1
49
- counts[header] > 1 ? "#{header}#{options[:duplicate_header_suffix]}#{counts[header]}" : header
49
+ if counts[header] == 1
50
+ header
51
+ else
52
+ # The disambiguated name must not steal the name of a real column (or of a
53
+ # previously assigned name) — e.g. headers name,name,name2: "name2" is taken,
54
+ # so bump the counter until a free name is found.
55
+ candidate = "#{header}#{options[:duplicate_header_suffix]}#{counts[header]}"
56
+ while used.include?(candidate)
57
+ counts[header] += 1
58
+ candidate = "#{header}#{options[:duplicate_header_suffix]}#{counts[header]}"
59
+ end
60
+ used << candidate
61
+ candidate
62
+ end
50
63
  end
51
64
  end
52
65
  end
@@ -18,6 +18,19 @@ module SmarterCSV
18
18
 
19
19
  file_header_array, file_header_size = parse(header_line, options)
20
20
 
21
+ # A quoted header containing an embedded newline is stitched across physical lines,
22
+ # the same way data rows are (the parser signals an unclosed quoted field with
23
+ # size -1). The embedded newline then becomes '_' via the header transformations.
24
+ while file_header_size == -1
25
+ next_line = filehandle.gets(options[:row_sep])
26
+ raise SmarterCSV::MalformedCSV, "Unclosed quoted field detected in the header" if next_line.nil?
27
+
28
+ @file_line_count += 1
29
+ @raw_header += next_line
30
+ header_line = preprocess_header_line(@raw_header, options)
31
+ file_header_array, file_header_size = parse(header_line, options)
32
+ end
33
+
21
34
  file_header_array = header_transformations(file_header_array, options)
22
35
 
23
36
  else
@@ -42,7 +55,9 @@ module SmarterCSV
42
55
  end
43
56
  end
44
57
 
45
- header_array = user_header_array
58
+ # dup: the array belongs to the caller. The reader appends column_N entries for
59
+ # extra data columns — those must go to our own copy, not the caller's array.
60
+ header_array = user_header_array.dup
46
61
  else
47
62
  header_array = file_header_array
48
63
  end