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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/CONTRIBUTORS.md +2 -1
- data/README.md +24 -1
- data/UPGRADING.md +22 -7
- data/docs/_introduction.md +22 -0
- data/docs/bad_row_quarantine.md +3 -1
- data/docs/basic_read_api.md +1 -1
- data/docs/data_transformations.md +6 -2
- data/docs/header_transformations.md +3 -1
- data/docs/migrating_from_csv.md +1 -1
- data/docs/options.md +3 -3
- data/docs/real_world_csv.md +1 -0
- data/docs/upgrade_path.json +35 -2
- data/docs/upgrade_wizard.html +16 -3
- data/ext/smarter_csv/cpu_flags.rb +55 -0
- data/ext/smarter_csv/extconf.rb +23 -2
- data/ext/smarter_csv/smarter_csv.c +153 -57
- data/lib/smarter_csv/hash_transformations.rb +16 -15
- data/lib/smarter_csv/header_transformations.rb +14 -1
- data/lib/smarter_csv/headers.rb +16 -1
- data/lib/smarter_csv/parser.rb +59 -11
- data/lib/smarter_csv/reader.rb +126 -62
- data/lib/smarter_csv/reader_options.rb +44 -6
- data/lib/smarter_csv/version.rb +1 -1
- data/lib/smarter_csv/writer.rb +3 -3
- metadata +4 -3
|
@@ -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
|
-
/*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
|
|
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
|
|
249
|
-
while (trim_end >= trim_start && (*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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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+)
|
|
609
|
-
*
|
|
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
|
|
613
|
-
* - base-10 exponent e10 (from the fraction length
|
|
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
|
|
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
|
-
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
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
|
|
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 '.'
|
|
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 =
|
|
679
|
-
int64_t e10 =
|
|
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 /
|
|
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 —
|
|
772
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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;
|
|
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
|
-
|
|
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 (
|
|
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;
|
|
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
|
-
|
|
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 '.'
|
|
75
|
-
|
|
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 '.'
|
|
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 '.'
|
|
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
|
|
154
|
-
# digits included
|
|
155
|
-
#
|
|
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]
|
|
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
|
data/lib/smarter_csv/headers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|