carray 3.0.2 → 3.0.4

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.
Files changed (247) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +568 -0
  3. data/README.md +6 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_descriptor.h +27 -24
  6. data/ext/ca_axis_dispatch.c +220 -0
  7. data/ext/ca_axis_group.c +201 -123
  8. data/ext/ca_binop_dispatch.c +46 -52
  9. data/ext/ca_binop_dispatch.h +1 -1
  10. data/ext/ca_categorical_iterator.c +249 -183
  11. data/ext/ca_compare.c +7 -12
  12. data/ext/ca_for_buffer.h +21 -68
  13. data/ext/ca_group_iter.c +38 -23
  14. data/ext/ca_kernel_iterator.c +1765 -2076
  15. data/ext/ca_kernel_iterator.h +584 -566
  16. data/ext/ca_monop_dispatch.c +86 -63
  17. data/ext/ca_monop_dispatch.h +5 -0
  18. data/ext/ca_obj_array.c +223 -56
  19. data/ext/ca_obj_bincmp.c +172 -138
  20. data/ext/ca_obj_binop.c +116 -230
  21. data/ext/ca_obj_bitarray.c +40 -26
  22. data/ext/ca_obj_bitfield.c +100 -68
  23. data/ext/ca_obj_block.c +19 -9
  24. data/ext/ca_obj_byte_swap.c +45 -46
  25. data/ext/ca_obj_const_string.c +269 -44
  26. data/ext/ca_obj_face.c +612 -89
  27. data/ext/ca_obj_face.h +111 -47
  28. data/ext/ca_obj_fake.c +36 -79
  29. data/ext/ca_obj_farray.c +7 -3
  30. data/ext/ca_obj_field.c +33 -8
  31. data/ext/ca_obj_fixlen_string.c +23 -24
  32. data/ext/ca_obj_grid.c +541 -210
  33. data/ext/ca_obj_meld.c +331 -111
  34. data/ext/ca_obj_moncmp.c +80 -65
  35. data/ext/ca_obj_monop.c +177 -149
  36. data/ext/ca_obj_object.c +664 -248
  37. data/ext/ca_obj_record.c +63 -23
  38. data/ext/ca_obj_reduce.c +18 -16
  39. data/ext/ca_obj_refer.c +140 -58
  40. data/ext/ca_obj_remap.c +85 -24
  41. data/ext/ca_obj_repeat.c +24 -9
  42. data/ext/ca_obj_roll.c +121 -16
  43. data/ext/ca_obj_select.c +61 -58
  44. data/ext/ca_obj_select_axis.c +73 -107
  45. data/ext/ca_obj_shift.c +32 -102
  46. data/ext/ca_obj_stack.c +372 -122
  47. data/ext/ca_obj_stride.c +348 -140
  48. data/ext/ca_obj_string.c +88 -22
  49. data/ext/ca_obj_tile.c +38 -21
  50. data/ext/ca_obj_time.c +45 -31
  51. data/ext/ca_obj_timedelta.c +43 -29
  52. data/ext/ca_obj_transpose.c +17 -6
  53. data/ext/ca_obj_triop.c +125 -141
  54. data/ext/ca_obj_window.c +330 -44
  55. data/ext/ca_op_ipower.c +24 -11
  56. data/ext/ca_op_powi.h +57 -30
  57. data/ext/ca_pow5_table.h +656 -0
  58. data/ext/ca_sort_kernels.h +70 -0
  59. data/ext/ca_sweep_engine.c +104 -81
  60. data/ext/ca_sweep_engine.h +7 -20
  61. data/ext/ca_triop_dispatch.c +4 -1
  62. data/ext/ca_triop_dispatch.h +5 -2
  63. data/ext/caframe_csv_reader.c +542 -0
  64. data/ext/carray.h +241 -92
  65. data/ext/carray_access.c +608 -276
  66. data/ext/carray_address_basis.c +494 -97
  67. data/ext/carray_attribute.c +129 -4
  68. data/ext/carray_bincount.c +39 -7
  69. data/ext/carray_broadcast.c +56 -19
  70. data/ext/carray_build_flags.h +1 -1
  71. data/ext/carray_call_cfunc.c +108 -28
  72. data/ext/carray_cast.c +1576 -223
  73. data/ext/carray_cast_func.rb +77 -24
  74. data/ext/carray_class.c +4 -3
  75. data/ext/carray_conversion.c +208 -75
  76. data/ext/carray_copy.c +82 -24
  77. data/ext/carray_core.c +646 -162
  78. data/ext/carray_count.c +171 -88
  79. data/ext/carray_element.c +94 -60
  80. data/ext/carray_factorize.c +670 -324
  81. data/ext/carray_generate.c +260 -83
  82. data/ext/carray_histogram.c +209 -106
  83. data/ext/carray_hold.c +14 -16
  84. data/ext/carray_index_classifier.c +422 -243
  85. data/ext/carray_internal.h +692 -3
  86. data/ext/carray_kernels_bincmp.c +7 -0
  87. data/ext/carray_kernels_binop.c +487 -120
  88. data/ext/carray_kernels_init.c +653 -50
  89. data/ext/carray_kernels_moncmp.c +15 -8
  90. data/ext/carray_kernels_monop.c +99 -43
  91. data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
  92. data/ext/carray_kernels_reduce_boolean.c +112 -36
  93. data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
  94. data/ext/carray_kernels_reduce_extreme.c +3032 -2892
  95. data/ext/carray_kernels_reduce_variance.c +439 -500
  96. data/ext/carray_kernels_scan.c +1097 -488
  97. data/ext/carray_kernels_search.c +3787 -2176
  98. data/ext/carray_kernels_sort.c +1200 -920
  99. data/ext/carray_kernels_triop.c +479 -7
  100. data/ext/carray_key_windows.c +216 -0
  101. data/ext/carray_lazy.c +967 -153
  102. data/ext/carray_loop.c +81 -65
  103. data/ext/carray_mask.c +910 -324
  104. data/ext/carray_math_kernel.h +1 -0
  105. data/ext/carray_median_percentile.c +299 -100
  106. data/ext/carray_memory_view.c +872 -758
  107. data/ext/carray_operator.c +1171 -364
  108. data/ext/carray_order.c +505 -133
  109. data/ext/carray_partition.c +112 -65
  110. data/ext/carray_random.c +327 -239
  111. data/ext/carray_round.c +273 -0
  112. data/ext/carray_scatter.c +346 -78
  113. data/ext/carray_segment.c +257 -0
  114. data/ext/carray_slab.c +249 -86
  115. data/ext/carray_sort.c +224 -227
  116. data/ext/carray_sort_kernel.c +8 -1
  117. data/ext/carray_struct.c +118 -39
  118. data/ext/carray_test.c +193 -69
  119. data/ext/carray_time_text.c +1182 -0
  120. data/ext/carray_undef.c +4 -0
  121. data/ext/carray_utils.c +579 -113
  122. data/ext/extconf.rb +27 -2
  123. data/ext/mk_call_cfunc.rb +19 -4
  124. data/ext/mk_pow5_table.rb +35 -0
  125. data/ext/mkkernel.rb +1202 -1014
  126. data/ext/ruby_carray.c +17 -5
  127. data/ext/version.h +4 -4
  128. data/ext/version.rb +4 -2
  129. data/lib/carray/attribute.rb +160 -17
  130. data/lib/carray/autoload_carray.rb +23 -27
  131. data/lib/carray/autoload_method_extension.rb +20 -5
  132. data/lib/carray/axis_group.rb +360 -63
  133. data/lib/carray/basics.rb +162 -28
  134. data/lib/carray/bincount_nd.rb +18 -2
  135. data/lib/carray/block_iterator.rb +231 -58
  136. data/lib/carray/boolean_reduce.rb +34 -42
  137. data/lib/carray/categorical.rb +242 -52
  138. data/lib/carray/categorical_iterator.rb +437 -827
  139. data/lib/carray/complex.rb +41 -34
  140. data/lib/carray/conditional.rb +150 -14
  141. data/lib/carray/const_string.rb +59 -4
  142. data/lib/carray/construct.rb +15 -11
  143. data/lib/carray/data_type_extension.rb +1 -0
  144. data/lib/carray/distance_limit.rb +77 -0
  145. data/lib/carray/fixlen_string.rb +7 -1
  146. data/lib/carray/frame/column_verbs.rb +70 -0
  147. data/lib/carray/frame/concat.rb +157 -6
  148. data/lib/carray/frame/convert.rb +82 -7
  149. data/lib/carray/frame/csv_parser.rb +688 -87
  150. data/lib/carray/frame/describe.rb +80 -0
  151. data/lib/carray/frame/frame.rb +369 -45
  152. data/lib/carray/frame/group.rb +631 -90
  153. data/lib/carray/frame/io.rb +506 -79
  154. data/lib/carray/frame/join.rb +411 -62
  155. data/lib/carray/frame/records.rb +116 -19
  156. data/lib/carray/frame/reshape.rb +455 -0
  157. data/lib/carray/frame/sort.rb +30 -18
  158. data/lib/carray/frame/verbs.rb +868 -61
  159. data/lib/carray/frame/windows.rb +117 -0
  160. data/lib/carray/frame.rb +4 -0
  161. data/lib/carray/fuse_source.rb +118 -2
  162. data/lib/carray/fusion.rb +257 -42
  163. data/lib/carray/histogram.rb +6 -1
  164. data/lib/carray/inspect.rb +54 -13
  165. data/lib/carray/iterator.rb +99 -9
  166. data/lib/carray/key_windows.rb +620 -0
  167. data/lib/carray/lazy.rb +216 -193
  168. data/lib/carray/mask_gap_fill.rb +112 -92
  169. data/lib/carray/math.rb +32 -19
  170. data/lib/carray/meld_reduce.rb +52 -21
  171. data/lib/carray/methods/align_addr.rb +8 -4
  172. data/lib/carray/methods/bin.rb +26 -18
  173. data/lib/carray/methods/bincount.rb +18 -2
  174. data/lib/carray/methods/bit_string.rb +15 -4
  175. data/lib/carray/methods/choose.rb +16 -3
  176. data/lib/carray/methods/composition.rb +18 -10
  177. data/lib/carray/methods/factorize.rb +2 -2
  178. data/lib/carray/methods/gather_nd.rb +25 -5
  179. data/lib/carray/methods/insert_block.rb +9 -14
  180. data/lib/carray/methods/is_between.rb +59 -0
  181. data/lib/carray/methods/is_in.rb +140 -10
  182. data/lib/carray/methods/join.rb +2 -7
  183. data/lib/carray/methods/lag.rb +49 -0
  184. data/lib/carray/methods/locate_addr.rb +182 -45
  185. data/lib/carray/methods/mode.rb +18 -1
  186. data/lib/carray/methods/nunique.rb +11 -6
  187. data/lib/carray/methods/repeat.rb +1 -20
  188. data/lib/carray/methods/resize.rb +21 -23
  189. data/lib/carray/methods/snap.rb +34 -12
  190. data/lib/carray/methods/unique.rb +11 -2
  191. data/lib/carray/methods/value_counts.rb +4 -11
  192. data/lib/carray/record_order.rb +208 -0
  193. data/lib/carray/rng.rb +3 -0
  194. data/lib/carray/runtime.rb +65 -17
  195. data/lib/carray/segment_iterator.rb +1221 -0
  196. data/lib/carray/serialize.rb +214 -30
  197. data/lib/carray/slab_iterator.rb +101 -27
  198. data/lib/carray/stack.rb +10 -4
  199. data/lib/carray/string.rb +105 -5
  200. data/lib/carray/string_operation_extension.rb +12 -4
  201. data/lib/carray/struct.rb +137 -58
  202. data/lib/carray/struct_builder.rb +124 -17
  203. data/lib/carray/time.rb +1222 -186
  204. data/lib/carray/window_iterator.rb +242 -81
  205. data/lib/carray.rb +9 -2
  206. data/yard-stubs/ca_obj_array.rb +4 -4
  207. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  208. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  209. data/yard-stubs/ca_obj_fake.rb +1 -1
  210. data/yard-stubs/ca_obj_field.rb +5 -1
  211. data/yard-stubs/ca_obj_grid.rb +21 -10
  212. data/yard-stubs/ca_obj_refer.rb +5 -4
  213. data/yard-stubs/ca_obj_roll.rb +2 -3
  214. data/yard-stubs/ca_obj_shift.rb +5 -1
  215. data/yard-stubs/ca_obj_stride.rb +10 -7
  216. data/yard-stubs/ca_obj_tile.rb +1 -1
  217. data/yard-stubs/ca_obj_transpose.rb +1 -1
  218. data/yard-stubs/ca_obj_window.rb +9 -5
  219. data/yard-stubs/carray_access.rb +11 -5
  220. data/yard-stubs/carray_attribute.rb +0 -5
  221. data/yard-stubs/carray_broadcast.rb +1 -1
  222. data/yard-stubs/carray_cast.rb +88 -43
  223. data/yard-stubs/carray_conversion.rb +15 -2
  224. data/yard-stubs/carray_copy.rb +12 -5
  225. data/yard-stubs/carray_core.rb +3 -3
  226. data/yard-stubs/carray_count.rb +9 -0
  227. data/yard-stubs/carray_element.rb +10 -4
  228. data/yard-stubs/carray_generate.rb +21 -1
  229. data/yard-stubs/carray_lazy.rb +5 -1
  230. data/yard-stubs/carray_loop.rb +4 -1
  231. data/yard-stubs/carray_mask.rb +60 -12
  232. data/yard-stubs/carray_math.rb +52 -14
  233. data/yard-stubs/carray_median_percentile.rb +15 -8
  234. data/yard-stubs/carray_memory_view.rb +40 -23
  235. data/yard-stubs/carray_order.rb +61 -29
  236. data/yard-stubs/carray_random.rb +13 -4
  237. data/yard-stubs/carray_scatter.rb +21 -9
  238. data/yard-stubs/carray_segment.rb +72 -0
  239. data/yard-stubs/carray_slab.rb +10 -2
  240. data/yard-stubs/carray_sort.rb +49 -8
  241. data/yard-stubs/carray_test.rb +15 -3
  242. data/yard-stubs/carray_utils.rb +0 -26
  243. metadata +19 -5
  244. data/ext/ca_for_each_element.h +0 -239
  245. data/ext/carray_kernels_map.c +0 -3467
  246. data/lib/carray/arrow_tensor.rb +0 -401
  247. data/lib/carray/table.rb +0 -108
@@ -13,7 +13,11 @@
13
13
  # * An empty unquoted field is nil (missing); an empty quoted field ("") is
14
14
  # the empty String. Both cast to UNDEF for numeric columns (parse-mask).
15
15
  # * A UTF-8 BOM is stripped via the "bom|utf-8" read mode by default.
16
- # * A parse error names the record number.
16
+ # * Malformed input raises MalformedCSV naming the file and the line: a
17
+ # quote inside an unquoted field, text after a closing quote, a quoted
18
+ # field still open at the end of input, or a record with more fields
19
+ # than there are columns. A record continues onto the next line only
20
+ # inside a quoted field.
17
21
  #
18
22
  # Two entry points share the Tokenizer:
19
23
  # * CSVParser.parse / parse_file -- whole-file parse into [headers, rows].
@@ -22,6 +26,7 @@
22
26
  # column_names).
23
27
 
24
28
  require "strscan"
29
+ require "stringio"
25
30
 
26
31
  class CAFrame
27
32
  # CSV tokenizer behind `CAFrame.from_csv`. It produces raw String cells
@@ -36,9 +41,35 @@ class CAFrame
36
41
  module CSVParser
37
42
  module_function
38
43
 
39
- # Raised when the input cannot be tokenized, e.g. a quoted field that
40
- # never closes before end of input.
41
- class MalformedCSV < StandardError; end
44
+ # Raised when the input cannot be read as written. The message starts
45
+ # with "path:line:" (or "line N:" for an IO with no path), the form an
46
+ # editor or terminal jumps to; the record number follows when it is not
47
+ # the line's, as after a quoted field of several lines or a +skip+.
48
+ # +path+, +lineno+ and +record+ give the same for a program.
49
+ class MalformedCSV < StandardError
50
+ attr_accessor :path
51
+ attr_reader :lineno, :record, :detail
52
+
53
+ def initialize(detail = nil, lineno: nil, record: nil, path: nil)
54
+ @detail = detail
55
+ @lineno = lineno
56
+ @record = record
57
+ @path = path
58
+ super(detail)
59
+ end
60
+
61
+ def message
62
+ return super if @detail.nil?
63
+ where =
64
+ if @path && @lineno then "#{@path}:#{@lineno}: "
65
+ elsif @path then "#{@path}: "
66
+ elsif @lineno then "line #{@lineno}: "
67
+ else ""
68
+ end
69
+ tail = @record && @record != @lineno ? " (record #{@record})" : ""
70
+ "#{where}#{@detail}#{tail}"
71
+ end
72
+ end
42
73
 
43
74
  # Parse a file into [headers, rows]. +encoding+ is an IO open-mode encoding
44
75
  # string; the default strips a leading BOM and reads UTF-8.
@@ -47,7 +78,8 @@ class CAFrame
47
78
  end
48
79
 
49
80
  # Parse an IO (or anything answering +gets+). The first record supplies the
50
- # headers; the rest are data rows. Fully blank lines are skipped.
81
+ # headers; the rest are data rows. Blank lines are skipped, including in a
82
+ # one-column file (from_csv reads them there as missing cells).
51
83
  def parse(io, sep: ",", quote: '"', strip: false)
52
84
  tok = Tokenizer.new(sep, quote, strip)
53
85
  headers = nil
@@ -62,111 +94,250 @@ class CAFrame
62
94
  [headers, rows]
63
95
  end
64
96
 
65
- # Read one logical record, joining continuation lines while a quoted field
66
- # is still open (an odd number of quote characters means unbalanced).
67
- def read_record(io, quote)
68
- line = io.gets
69
- return nil if line.nil?
70
- rec = line.dup
71
- while rec.count(quote).odd?
72
- more = io.gets
73
- raise MalformedCSV, "unterminated quoted field at end of input" if more.nil?
74
- rec << more
75
- end
76
- rec.chomp!
77
- rec
78
- end
79
-
80
97
  # Splits records into fields. Regexps are compiled once and reused across
81
98
  # every record, so per-row cost stays low.
82
99
  class Tokenizer
100
+ attr_reader :sep, :quote, :strip
101
+
83
102
  def initialize(sep, quote, strip)
103
+ unless sep.is_a?(String) && !sep.empty?
104
+ raise ArgumentError, "sep: must be a non-empty String (got #{sep.inspect})"
105
+ end
106
+ unless quote.is_a?(String) && quote.size == 1
107
+ raise ArgumentError, "quote: must be one character (got #{quote.inspect})"
108
+ end
109
+ if sep.include?(quote)
110
+ raise ArgumentError, "sep: #{sep.inspect} cannot contain the quote character"
111
+ end
84
112
  @sep = sep
85
113
  @quote = quote
86
114
  @strip = strip
87
- @sep_re = /#{Regexp.escape(sep)}/
88
- @quote_re = /#{Regexp.escape(quote)}/
89
- @unquoted = /[^#{Regexp.escape(sep)}]*/
90
- @inner = /[^#{Regexp.escape(quote)}]*/
115
+ s = Regexp.escape(sep)
116
+ q = Regexp.escape(quote)
117
+ @sep_re = /#{s}/
118
+ # String#split(" ") splits on runs of whitespace (awk style); a
119
+ # Regexp splits at each separator as written, as the C reader does.
120
+ @split_sep = sep == " " ? @sep_re : sep
121
+ # strip: skips the spaces and tabs around a field, but not one that is
122
+ # the separator: with sep: "\t" a tab ends the field.
123
+ pad = [" ", "\t"].reject { |c| sep.include?(c) }
124
+ @pad_re = pad.empty? ? nil : /[#{Regexp.escape(pad.join)}]+/
125
+ @quote_re = /#{q}/
126
+ # An unquoted field runs up to the separator, a quote or the end of
127
+ # the record; a CR that does not end the record is part of the field.
128
+ # A character class says "up to the separator" for a one-character
129
+ # one; a longer one has to be matched as a whole, or "::" would end
130
+ # the field at a single ":".
131
+ @unquoted =
132
+ if sep.size == 1
133
+ /(?:[^#{s}#{q}\r\n]|\r(?!\n|\z))*/
134
+ else
135
+ /(?:(?!#{s})(?:[^#{q}\r\n]|\r(?!\n|\z)))*/
136
+ end
137
+ @inner = /[^#{q}]*/
138
+ @eol = /\r?\n|\r\z/
139
+ @blank_re = /\A[ \t]*\z/
140
+ @sep2 = sep * 2
141
+ @qq = quote * 2
142
+ # A whole quoted field: its quotes closed, the ones inside doubled.
143
+ @quoted_re = /\A#{q}(?:[^#{q}]|#{q}#{q})*#{q}\z/m
91
144
  @recno = 0
145
+ @lineno = 0
146
+ @record_line = 0
147
+ end
148
+
149
+ # The lines read so far, and the line the last record started on.
150
+ attr_reader :lineno, :record_line
151
+
152
+ # The next line of +io+, counted.
153
+ def gets(io)
154
+ line = io.gets
155
+ @lineno += 1 if line
156
+ line
157
+ end
158
+
159
+ # A MalformedCSV at +line+ of the current record.
160
+ def malformed(detail, line = @record_line)
161
+ MalformedCSV.new(detail, lineno: line, record: @recno)
92
162
  end
93
163
 
94
- # Fields of the next record, or nil at EOF. A blank line carries no
95
- # separator, so it cannot be a row of a file with more than one column
96
- # and is skipped as noise between records. In a single-column file it
97
- # is the only spelling a missing single field has -- which is what
98
- # to_csv writes for a masked cell -- so the caller passes
164
+ # Fields of the next record, or nil at EOF. A blank line -- empty, or
165
+ # only spaces and tabs with no separator -- cannot be a row of a file
166
+ # with more than one column and is skipped as noise between records.
167
+ # In a single-column file it is a row: an empty line is the only
168
+ # spelling a missing single field has, which is what to_csv writes for
169
+ # a masked cell, and spaces are a value. So the caller passes
99
170
  # +blank_is_row: true+ once the column count is known to be one, and
100
- # the empty record becomes a row of no fields for build_frame to pad.
101
- def read(io, blank_is_row: false)
171
+ # the line becomes a row for build_frame to pad. +blank?+ says whether
172
+ # the record just returned was such a line, for a caller that learns
173
+ # the column count only after the body.
174
+ #
175
+ # A line with no quote character is a whole record. One with a quote
176
+ # goes to the scanner, which reads further lines only while a field
177
+ # that opened with a quote is still open -- so a quote inside an
178
+ # unquoted field is malformed rather than a reason to swallow the lines
179
+ # after it, and a field of many lines is read once, not re-counted.
180
+ def read(io, blank_is_row: false, last_line: nil)
102
181
  loop do
103
- rec = CSVParser.read_record(io, @quote)
104
- return nil if rec.nil?
182
+ return nil if last_line && @lineno >= last_line
183
+ line = gets(io)
184
+ return nil if line.nil?
105
185
  @recno += 1
106
- next if rec.empty? && !blank_is_row
107
- return rec.count(@quote).zero? ? simple(rec) : scan(rec)
186
+ @record_line = @lineno
187
+ @blank = false
188
+ if line.include?(@quote)
189
+ return (!@strip && quoted_line(line)) || scan(line, io)
190
+ end
191
+ rec = line.chomp
192
+ if rec.match?(@blank_re) && !rec.include?(@sep)
193
+ next unless blank_is_row
194
+ @blank = true
195
+ end
196
+ return simple(rec)
108
197
  end
109
198
  end
110
199
 
200
+ def blank?
201
+ @blank
202
+ end
203
+
204
+ # Count records and lines read elsewhere (the C reader), so the
205
+ # numbers in an error stay those of the file.
206
+ def advance(records, lines)
207
+ @recno += records
208
+ @lineno += lines
209
+ end
210
+
211
+ # An empty field lies at the start or the end of the record or between
212
+ # two separators, so a record without one is the split as it stands.
111
213
  private def simple(rec)
112
- rec.split(@sep, -1).map do |cell|
113
- cell = cell.strip if @strip
114
- cell.empty? ? nil : cell
214
+ fields = rec.split(@split_sep, -1)
215
+ if @strip
216
+ fields.map! { |cell| cell = cell.strip; cell.empty? ? nil : cell }
217
+ elsif rec.empty? || rec.start_with?(@sep) || rec.end_with?(@sep) || rec.include?(@sep2)
218
+ fields.map! { |cell| cell.empty? ? nil : cell }
115
219
  end
220
+ fields
116
221
  end
117
222
 
118
- private def scan(rec)
119
- sc = StringScanner.new(rec)
223
+ # The fields of a one-line record holding quotes, read by splitting at
224
+ # the separator: a quoted field is the pieces from one that opens with
225
+ # a quote to the one that closes it (it may hold the separator). nil
226
+ # when the record is not that simple -- a quoted field open at the end
227
+ # of the line, a quote inside an unquoted field, text after a closing
228
+ # quote -- for the scanner to read or to report.
229
+ private def quoted_line(line)
230
+ parts = line.chomp.split(@split_sep, -1)
231
+ fields = []
232
+ i = 0
233
+ while i < parts.size
234
+ part = parts[i]
235
+ if part.start_with?(@quote)
236
+ until part.match?(@quoted_re)
237
+ i += 1
238
+ return nil if i >= parts.size
239
+ part = part + @sep + parts[i]
240
+ end
241
+ inner = part[1...-1]
242
+ fields << (inner.include?(@quote) ? inner.gsub(@qq, @quote) : inner)
243
+ elsif part.include?(@quote)
244
+ return nil
245
+ else
246
+ fields << (part.empty? ? nil : part)
247
+ end
248
+ i += 1
249
+ end
250
+ fields
251
+ end
252
+
253
+ # Fields of the record that starts on +line+, reading more lines from
254
+ # +io+ while a quoted field is open. strip: skips spaces before a field,
255
+ # so a quote after them still opens one.
256
+ private def scan(line, io)
257
+ sc = StringScanner.new(line)
120
258
  fields = []
121
259
  loop do
122
- if sc.scan(@quote_re)
123
- buf = +""
124
- loop do
125
- buf << sc.scan(@inner)
126
- unless sc.scan(@quote_re)
127
- raise MalformedCSV, "unterminated quoted field in record #{@recno}"
128
- end
129
- if sc.scan(@quote_re) # doubled quote -> literal quote, field continues
130
- buf << @quote
131
- else
132
- break
133
- end
260
+ sc.skip(@pad_re) if @strip && @pad_re
261
+ if sc.skip(@quote_re)
262
+ fields << quoted_field(sc, io, fields.size + 1, line_at(sc))
263
+ # A closed quoted field ends at the separator or the end of the
264
+ # record. Anything else there is malformed, and read on it would
265
+ # drop the rest of the record without a word. strip: lets spaces
266
+ # through, as it does around an unquoted field.
267
+ sc.skip(@pad_re) if @strip && @pad_re
268
+ unless sc.eos? || sc.check(@sep_re) || sc.check(@eol)
269
+ raise malformed("text #{sc.rest.chomp[0, 20].inspect} after the closing quote " \
270
+ "of field #{fields.size}", line_at(sc))
134
271
  end
135
- fields << buf
136
272
  else
137
- cell = sc.scan(@unquoted) || ""
273
+ cell = sc.scan(@unquoted)
274
+ if sc.check(@quote_re)
275
+ raise malformed("a quote inside unquoted field #{fields.size + 1} " \
276
+ "(#{(cell + sc.rest.chomp)[0, 20].inspect}); a field holding " \
277
+ "a quote is written quoted, with the quote doubled", line_at(sc))
278
+ end
138
279
  cell = cell.strip if @strip
139
280
  fields << (cell.empty? ? nil : cell)
140
281
  end
141
- break unless sc.scan(@sep_re)
282
+ break unless sc.skip(@sep_re)
142
283
  end
284
+ sc.skip(@eol)
143
285
  fields
144
286
  end
287
+
288
+ # The line of the file the scanner is at: the record's first line and
289
+ # the line breaks it has passed in quoted fields.
290
+ private def line_at(sc)
291
+ @record_line + sc.string[0, sc.pos].count("\n")
292
+ end
293
+
294
+ # The rest of a field whose opening quote has been read, through its
295
+ # closing quote. A doubled quote is a literal one; the end of the line
296
+ # inside the field brings the next line in.
297
+ private def quoted_field(sc, io, field_no, open_line)
298
+ buf = +""
299
+ loop do
300
+ buf << sc.scan(@inner)
301
+ if sc.eos?
302
+ more = gets(io)
303
+ if more.nil?
304
+ raise malformed("quoted field #{field_no} opened here is never closed", open_line)
305
+ end
306
+ sc << more
307
+ next
308
+ end
309
+ sc.skip(@quote_re)
310
+ break unless sc.skip(@quote_re)
311
+ buf << @quote
312
+ end
313
+ buf
314
+ end
145
315
  end
146
316
  end
147
317
 
148
- # The block reading-control DSL for CAFrame.from_csv. A file
149
- # often has preamble lines, a units row, or no header at all; the block says,
150
- # in order, how to consume the stream:
318
+ # The reading-control block for CAFrame.from_csv. A file often has
319
+ # preamble lines, a units row, or no header at all; the block is given the
320
+ # reader and says, in order, how to consume the stream:
151
321
  #
152
- # CAFrame.from_csv(path) do
153
- # skip 3 # drop 3 preamble lines
154
- # header # next record supplies the column names
155
- # skip 1 # drop a units row
156
- # body # the rest are data rows
322
+ # CAFrame.from_csv(path) do |r|
323
+ # r.skip 3 # drop 3 preamble lines
324
+ # r.header # next record supplies the column names
325
+ # r.skip 1 # drop a units row
326
+ # r.data # the rest are data rows
157
327
  # end
158
328
  #
159
- # CAFrame.from_csv(path) do # headerless file
160
- # column_names "date", "temp", "rh"
161
- # body
162
- # end
329
+ # CAFrame.from_csv(path) { it.column_names "date", "temp", "rh"; it.data }
163
330
  #
164
- # The verbs are +skip+ / +header+ / +column_names+ / +body+; each returns a
165
- # value useful inline (header returns its fields) and the ordering is the
166
- # script. Without a block, from_csv runs the default +header+ then +body+.
331
+ # The verbs are +skip+ / +header+ / +column_names+ / +columns+ / +data+;
332
+ # each returns a value useful inline (header returns its fields) and the ordering is the
333
+ # script. The reader is a parameter, not self, so a local variable named
334
+ # +data+ or +header+ cannot stand in for the verb. Without a block,
335
+ # from_csv reads by its header: / data: / column_names: (see +layout+).
167
336
  class CSVReader
168
- def initialize(io, sep: ",", quote: '"', strip: false)
337
+ def initialize(io, sep: ",", quote: '"', strip: false, read_text_as_const_string: false)
338
+ @read_text_as_const_string = read_text_as_const_string
169
339
  @io = io
340
+ @path = io.path if io.respond_to?(:path)
170
341
  @tok = CSVParser::Tokenizer.new(sep, quote, strip)
171
342
  @names = nil
172
343
  @rows = nil
@@ -174,45 +345,475 @@ class CAFrame
174
345
 
175
346
  # Drop +n+ raw lines (preamble, units, notes).
176
347
  def skip(n = 1)
177
- n.times { @io.gets }
348
+ unless n.is_a?(Integer) && n >= 0
349
+ raise ArgumentError, "skip takes a number of lines (got #{n.inspect})"
350
+ end
351
+ n.times { @tok.gets(@io) }
352
+ @skipped = true if n > 0
178
353
  self
179
354
  end
180
355
 
181
356
  # Read one record. With no argument it becomes the column names; with a
182
- # name it is a secondary header (e.g. units) -- read, returned, not used as
183
- # names. Returns the record's fields either way.
357
+ # name it is a secondary header (e.g. units): its field j becomes the
358
+ # attribute +name+ of column j (an empty field gives none). Returns the
359
+ # record's fields either way.
184
360
  def header(name = nil)
185
- fields = @tok.read(@io)
186
- raise CSVParser::MalformedCSV, "header expected but input ended" if fields.nil?
361
+ # A named header of a file with at most one column may be an empty
362
+ # line: that is how to_csv writes a column without the attribute, so
363
+ # the line is the header's, as an empty line is a row in the data.
364
+ blank_is_row = !name.nil? && !@names.nil? && @names.size <= 1
365
+ fields = @tok.read(@io, blank_is_row: blank_is_row)
366
+ if fields.nil?
367
+ # A file with no columns has nothing to give a named header.
368
+ return [] if !name.nil? && @names&.empty?
369
+ # An input with no record at all is a table with no columns: that is
370
+ # what to_csv writes for a frame without columns or index. Once lines
371
+ # have been skipped to reach the header, a missing header is an error.
372
+ if name.nil? && !@skipped
373
+ @names = []
374
+ @header_line = @tok.lineno
375
+ @names_to_check = nil
376
+ return []
377
+ end
378
+ raise CSVParser::MalformedCSV.new("header expected but input ended",
379
+ lineno: @tok.lineno + 1)
380
+ end
187
381
  if name.nil?
188
- @names = fields.map(&:to_s)
382
+ # An empty field names no column: the column takes the name a file
383
+ # without a header gives it, from its place in the line.
384
+ @names = fields.each_with_index.map { |f, j| f.to_s.empty? ? "c#{j}" : f.to_s }
385
+ @header_line = @tok.record_line
386
+ @names_to_check = nil
189
387
  else
190
- (@named_headers ||= {})[name.to_s] = fields
388
+ key = name.to_s
389
+ @named_headers ||= {}
390
+ if @named_headers.key?(key)
391
+ raise ArgumentError, "from_csv: header(#{name.inspect}) is read twice"
392
+ end
393
+ @named_headers[key] = [fields, @tok.record_line, name]
191
394
  end
192
395
  fields
193
396
  end
194
397
 
195
- # Set the column names explicitly (headerless files).
398
+ # Set the column names explicitly: for a file with no header, or in
399
+ # place of the names a header gave. Their number has to be the file's
400
+ # number of columns -- the header's, or else that of the first record
401
+ # of the data -- or a name would go to no column, or a column without
402
+ # one.
196
403
  def column_names(*names)
197
- @names = names.flatten.map(&:to_s)
404
+ names = names.flatten.map(&:to_s)
405
+ if names.include?("")
406
+ raise ArgumentError, "from_csv: column_names: a column name can not be empty"
407
+ end
408
+ if @header_line
409
+ check_column_names(names, @names.size, @header_line)
410
+ else
411
+ @names_to_check = names
412
+ end
413
+ @names = names
414
+ self
415
+ end
416
+
417
+ # Read only these columns: names of the header or of column_names, or
418
+ # indexes of the file's columns from 0 (Integers or Ranges), in the order
419
+ # the frame is to have them. The fields of the others are read past, so
420
+ # a few columns of a wide file cost little more than a narrow file.
421
+ #
422
+ # A Hash selects by its keys, as above (a name or an index, not a Range),
423
+ # and names the columns read with its values:
424
+ #
425
+ # r.columns(2 => "code", "c7" => "area1")
426
+ def columns(*selection)
427
+ @renames = nil
428
+ if selection.size == 1 && selection[0].is_a?(Hash)
429
+ @renames = column_renames(selection[0])
430
+ selection = selection[0].keys
431
+ end
432
+ selection = selection.flatten
433
+ if selection.empty?
434
+ raise ArgumentError, "from_csv: columns: names no column"
435
+ end
436
+ selection.each do |c|
437
+ next if c.is_a?(String)
438
+ CAFrame.__send__(:column_key, c, "from_csv", "columns: a column name") if c.is_a?(Symbol)
439
+ next if c.is_a?(Integer) && c >= 0
440
+ next if c.is_a?(Range) && (c.begin.nil? || (c.begin.is_a?(Integer) && c.begin >= 0)) &&
441
+ (c.end.nil? || c.end.is_a?(Integer))
442
+ raise ArgumentError, "from_csv: columns: takes column names, or indexes from 0 " \
443
+ "and Ranges of them (got #{c.inspect})"
444
+ end
445
+ @selection = selection
198
446
  self
199
447
  end
200
448
 
201
- # Consume the remaining records as data rows.
202
- def body
203
- rows = []
204
- blank_is_row = @names && @names.size == 1
205
- while (fields = @tok.read(@io, blank_is_row: blank_is_row))
449
+ # The new names a Hash given to +columns+ holds, checked.
450
+ private def column_renames(map)
451
+ map.each do |key, name|
452
+ if key.is_a?(Range)
453
+ raise ArgumentError, "from_csv: columns: a Hash selects one column per key, " \
454
+ "not a Range (got #{key.inspect})"
455
+ end
456
+ CAFrame.__send__(:column_key, name, "from_csv", "columns: a column name",
457
+ hint: "given for #{key.inspect}")
458
+ if name.empty?
459
+ raise ArgumentError, "from_csv: columns: a column name can not be empty " \
460
+ "(given for #{key.inspect})"
461
+ end
462
+ end
463
+ CAFrame.__send__(:refuse_repeated_names, map.values, "from_csv", "columns: names")
464
+ map.values
465
+ end
466
+
467
+ # The indexes of the selected columns, in the order asked for, among
468
+ # ncol columns named +names+ (nil: by index only). nil when ncol is not
469
+ # known yet and the selection needs it.
470
+ private def selected_indexes(names, ncol)
471
+ indexes = @selection.flat_map do |c|
472
+ case c
473
+ when String
474
+ # Without a header the columns are named c0, c1, ... by position.
475
+ j = names ? names.index(c) : c[/\Ac(0|[1-9]\d*)\z/, 1]&.to_i
476
+ if j.nil?
477
+ raise KeyError, "from_csv: columns: names no column #{c.inspect}" +
478
+ (names ? "" : " (with no header the columns are c0, c1, ...)")
479
+ end
480
+ j
481
+ when Integer then c
482
+ else
483
+ first = c.begin || 0
484
+ last = c.end
485
+ return nil if last.nil? && ncol.nil?
486
+ last = ncol - 1 if last.nil?
487
+ last -= 1 if c.end && c.exclude_end?
488
+ (first..last).to_a
489
+ end
490
+ end
491
+ CAFrame.__send__(:refuse_repeated_names, indexes, "from_csv", "columns: selects column")
492
+ if ncol && (bad = indexes.find { |j| j >= ncol })
493
+ raise ArgumentError, "from_csv: columns: #{bad} is past the #{ncol} columns of the file"
494
+ end
495
+ indexes
496
+ end
497
+
498
+ # The indexes C is to read, ascending, when it can tell which they are
499
+ # before it reads; nil for every column.
500
+ private def columns_to_read
501
+ @selection && selected_indexes(@names, @names&.size)&.sort
502
+ end
503
+
504
+ private def check_column_names(names, ncol, line)
505
+ return if names.size == ncol
506
+ raise ArgumentError, "from_csv: column_names: gives #{names.size} " \
507
+ "name#{names.size == 1 ? '' : 's'} for the #{ncol} columns of line #{line}"
508
+ end
509
+
510
+ # Consume the remaining records as data rows. A second data adds what is
511
+ # left, which is nothing; it does not discard the rows already read.
512
+ #
513
+ # Without names the column count is known only once every row is read,
514
+ # so blank lines are kept and marked, and +result+ drops them unless the
515
+ # file turns out to have one column -- the same rows a header would have
516
+ # given.
517
+ #
518
+ # Without strip:, the rest of the input is read in C (CArray.__csv_read_body_as_string_cells__)
519
+ # straight into the table, with the column count of the names, or of the
520
+ # first record when there are none. When C declines -- text not in
521
+ # UTF-8, a record it does not take, or one longer than the first in a
522
+ # file without names -- the text from there on is read here, which pads
523
+ # the earlier rows or reports what is wrong.
524
+ def data
525
+ read_data(nil)
526
+ end
527
+
528
+ # The header: / data: / column_names: of from_csv, as the verbs above.
529
+ # They index the lines of the file from 0, as File.readlines does (an
530
+ # error names a line from 1, as an editor does). header: defaults to 0,
531
+ # or to none (false) when column_names: is given; data: is the first line
532
+ # of the data or a Range of lines, by default from the line after the
533
+ # header. A record that starts within data: is read whole.
534
+ def layout(header: nil, data: nil, column_names: nil, columns: nil, attrs: nil)
535
+ header = column_names ? false : 0 if header.nil?
536
+ unless header == false || (header.is_a?(Integer) && header >= 0)
537
+ raise ArgumentError, "from_csv: header: takes the index of a line, from 0, " \
538
+ "or false (got #{header.inspect})"
539
+ end
540
+ attr_keys = attrs.nil? ? [] : CAFrame.send(:csv_attr_keys, attrs, "from_csv")
541
+ if !header && !attr_keys.empty?
542
+ raise ArgumentError, "from_csv: attrs: reads the lines below the header, " \
543
+ "so it needs a header line"
544
+ end
545
+ first, last = CSVReader.data_lines(data)
546
+ if header
547
+ skip(header - @tok.lineno) if header > @tok.lineno
548
+ self.header
549
+ attr_keys.each { |key| self.header(key.to_sym) }
550
+ end
551
+ column_names(*column_names) if column_names
552
+ self.columns(*(columns.is_a?(Array) ? columns : [columns])) if columns
553
+ first ||= @tok.lineno
554
+ if first < @tok.lineno
555
+ raise ArgumentError, "from_csv: data: #{first} does not come after header: #{header}" +
556
+ (attr_keys.empty? ? "" : " and the #{attr_keys.size} attrs: lines")
557
+ end
558
+ skip(first - @tok.lineno)
559
+ read_data(last && last + 1)
560
+ end
561
+
562
+ # [first, last] line indexes of a data: setting, nil for an end left open.
563
+ def self.data_lines(spec)
564
+ first, last =
565
+ case spec
566
+ when nil then [nil, nil]
567
+ when Integer then [spec, nil]
568
+ when Range
569
+ e = spec.end
570
+ e -= 1 if e.is_a?(Integer) && spec.exclude_end?
571
+ [spec.begin, e]
572
+ else
573
+ raise ArgumentError, "from_csv: data: takes the index of a line or a Range of " \
574
+ "them, from 0 (got #{spec.inspect})"
575
+ end
576
+ [first, last].each do |n|
577
+ next if n.nil? || (n.is_a?(Integer) && n >= 0)
578
+ raise ArgumentError, "from_csv: data: lines are indexed from 0 (got #{spec.inspect})"
579
+ end
580
+ if first && last && last < first
581
+ raise ArgumentError, "from_csv: data: #{spec.inspect} ends before it starts"
582
+ end
583
+ [first, last]
584
+ end
585
+
586
+ # The records from here through those starting on +last+ (nil: the end).
587
+ private def read_data(last)
588
+ body_in_c(last) if @rows.nil? && !@tok.strip && @io.respond_to?(:read)
589
+ return self if @table
590
+ rows = (@rows ||= [])
591
+ @blank_rows ||= []
592
+ blank_is_row = @names.nil? || @names.size == 1
593
+ ncol = @names&.size
594
+ while (fields = @tok.read(@io, blank_is_row: blank_is_row, last_line: last))
595
+ if @names_to_check && !@tok.blank?
596
+ check_column_names(@names_to_check, fields.size, @tok.record_line)
597
+ @names_to_check = nil
598
+ end
599
+ if ncol && fields.size > ncol
600
+ raise @tok.malformed("#{fields.size} fields, but there are #{ncol} columns")
601
+ end
602
+ @blank_rows << rows.size if @names.nil? && @tok.blank?
206
603
  rows << fields
207
604
  end
208
- @rows = rows
209
605
  self
210
606
  end
211
607
 
608
+ # Run the block, naming the file in a MalformedCSV it raises.
609
+ def reporting
610
+ yield
611
+ rescue CSVParser::MalformedCSV => e
612
+ e.path ||= @path
613
+ raise
614
+ end
615
+
616
+ # The text C reads at a time when the column count is known; a chunk
617
+ # ends at a line end, and not inside a quoted field.
618
+ CHUNK_BYTES = 1 << 22
619
+
620
+ # With names, the input goes to C a chunk at a time, so the text is not
621
+ # held whole beside its cells. Without them, the column count and which
622
+ # blank lines are rows are known only at the end, so it goes in whole;
623
+ # so it does when the IO transcodes, since read(length) does not.
624
+ #
625
+ # With read_text_as_const_string:, the body goes to C whole, into one
626
+ # CAConstString per column over the text itself: the text is the buffer
627
+ # the columns read, so it is held whole either way. When C declines, the
628
+ # text is read here from the start of the body.
629
+ private def read_body_into_const_string_columns
630
+ ncol = @names && !@names_to_check ? @names.size : 0
631
+ keep = columns_to_read
632
+ text = read_rest
633
+ n, buffer, pairs, records = CArray.__csv_read_body_as_const_string_columns__(text, @tok.sep, @tok.quote, ncol, keep)
634
+ unless buffer
635
+ @rows = []
636
+ @io = StringIO.new(text)
637
+ return
638
+ end
639
+ if @names_to_check
640
+ check_column_names(@names_to_check, n, @tok.lineno + first_record_line(text))
641
+ @names_to_check = nil
642
+ end
643
+ @tok.advance(records, text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1))
644
+ @table = ConstStringColumns.new(pairs.map { |pr| CAConstString.wrap(pr, buffer: buffer) })
645
+ @table_ncol = n
646
+ @table_keep = keep
647
+ @rows = []
648
+ end
649
+
650
+ # The columns read_body_into_const_string_columns made, as +result+
651
+ # hands them to CAFrame.from_csv.
652
+ ConstStringColumns = Struct.new(:columns)
653
+
654
+ private def body_in_c(last = nil, chunk_bytes = CHUNK_BYTES)
655
+ return read_body_into_const_string_columns if last.nil? && @read_text_as_const_string
656
+ chunked = last.nil? && @names && @io.respond_to?(:external_encoding) &&
657
+ @io.internal_encoding.nil?
658
+ # Names that have to agree with the first record leave its column
659
+ # count to C, to be checked against them.
660
+ ncol = @names && !@names_to_check ? @names.size : 0
661
+ keep = columns_to_read
662
+ cells = []
663
+ loop do
664
+ text = if chunked then next_chunk(chunk_bytes)
665
+ elsif last then lines_through(last)
666
+ else read_rest
667
+ end
668
+ break if text.nil?
669
+ n, flat, records = CArray.__csv_read_body_as_string_cells__(text, @tok.sep, @tok.quote, ncol, keep)
670
+ unless flat
671
+ # This chunk and the rest are read by the Ruby tokenizer, after the
672
+ # rows read so far.
673
+ if keep && ncol > 0 && !cells.empty?
674
+ # The rows read so far hold the selected fields only; spread
675
+ # them back to their columns for the Ruby reader.
676
+ @rows = cells.each_slice(keep.size).map do |row|
677
+ full = Array.new(ncol)
678
+ keep.each_with_index { |j, k| full[j] = UNDEF.equal?(row[k]) ? nil : row[k] }
679
+ full
680
+ end
681
+ else
682
+ @rows = ncol > 0 ? cells.each_slice(ncol).to_a : []
683
+ end
684
+ @io = StringIO.new(chunked ? text + (@io.read || "") : text)
685
+ return
686
+ end
687
+ if @names_to_check
688
+ check_column_names(@names_to_check, n, @tok.lineno + first_record_line(text))
689
+ @names_to_check = nil
690
+ end
691
+ ncol = n
692
+ cells.concat(flat)
693
+ @tok.advance(records, text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1))
694
+ break unless chunked
695
+ end
696
+ if ncol == 0 # no record, for the names to be checked against
697
+ @rows = []
698
+ return
699
+ end
700
+ width = keep ? keep.size : ncol
701
+ @table = CArray.object(cells.size / width, width) { cells }
702
+ @table_ncol = ncol
703
+ @table_keep = keep
704
+ @rows = []
705
+ end
706
+
707
+ # The line, from 1 within +text+, of its first record: past the blank
708
+ # lines before it, as C reads them.
709
+ private def first_record_line(text)
710
+ line = 1
711
+ text.each_line do |l|
712
+ rec = l.chomp
713
+ break unless rec.match?(/\A[ \t]*\z/) && !rec.include?(@tok.sep)
714
+ line += 1
715
+ end
716
+ line
717
+ end
718
+
719
+ # The lines through +last+, read on past any quoted field still open.
720
+ private def lines_through(last)
721
+ buf = +""
722
+ (last - @tok.lineno).times do
723
+ line = @io.gets or break
724
+ buf << line
725
+ end
726
+ while buf.count(@tok.quote).odd? && (more = @io.gets)
727
+ buf << more
728
+ end
729
+ buf
730
+ end
731
+
732
+ # The rest of the input, in the IO's encoding. Read in pieces when the IO
733
+ # does not transcode: IO#read without a length checks the text's encoding
734
+ # as it reads, and the C reader checks it again faster.
735
+ private def read_rest
736
+ unless @io.respond_to?(:internal_encoding) && @io.internal_encoding.nil?
737
+ return @io.read || ""
738
+ end
739
+ buf = +""
740
+ while (piece = @io.read(CHUNK_BYTES))
741
+ buf << piece
742
+ end
743
+ buf.force_encoding(@io.external_encoding || Encoding.default_external)
744
+ end
745
+
746
+ # The next chunk_bytes of the input, read on to the end of its line and
747
+ # past any quoted field still open, in the IO's encoding; nil at EOF.
748
+ private def next_chunk(chunk_bytes)
749
+ buf = @io.read(chunk_bytes)
750
+ return nil if buf.nil?
751
+ buf.force_encoding(@io.external_encoding || Encoding.default_external)
752
+ if !buf.end_with?("\n") && (rest = @io.gets)
753
+ buf << rest
754
+ end
755
+ while buf.count(@tok.quote).odd? && (more = @io.gets)
756
+ buf << more
757
+ end
758
+ buf
759
+ end
760
+
761
+ # The attributes the named headers give the columns +result+ returns, as
762
+ # [{key => value}, ...] in the same order. A column of the file the
763
+ # selection left out takes no part.
764
+ def column_attrs (ncol)
765
+ return nil unless @named_headers
766
+ indexes = @selection ? selected_indexes(@names, ncol) : (0...ncol).to_a
767
+ attrs = indexes.map { {} }
768
+ @named_headers.each do |key, (fields, line, name)|
769
+ if fields.size > ncol
770
+ raise ArgumentError, "from_csv: header(#{name.inspect}) on line #{line} " \
771
+ "has #{fields.size} fields for the #{ncol} columns"
772
+ end
773
+ indexes.each_with_index do |j, k|
774
+ value = fields[j]
775
+ attrs[k][key] = value unless value.nil?
776
+ end
777
+ end
778
+ attrs
779
+ end
780
+
781
+ # The number of columns of the file, once the data is read.
782
+ def file_ncol
783
+ return @table_ncol if @table
784
+ @names ? @names.size : ((@rows || []).map(&:size).max || 0)
785
+ end
786
+
212
787
  # [names_or_nil, rows] for CAFrame.from_csv to build from. names is nil when
213
788
  # neither header nor column_names ran (positional names are generated).
214
789
  def result
215
- [@names, @rows || []]
790
+ return table_result if @table
791
+ rows = @rows || []
792
+ if @names.nil? && @blank_rows && !@blank_rows.empty? &&
793
+ rows.map(&:size).max != 1
794
+ drop = @blank_rows.to_h { |i| [i, true] }
795
+ rows = rows.reject.with_index { |_, i| drop[i] }
796
+ end
797
+ return [@names, rows] unless @selection
798
+ ncol = @names ? @names.size : (rows.map(&:size).max || 0)
799
+ indexes = selected_indexes(@names, ncol)
800
+ names = @renames || indexes.map { |j| @names ? @names[j] : "c#{j}" }
801
+ [names, rows.map { |r| r.values_at(*indexes) }]
802
+ end
803
+
804
+ # The table C read, its columns those selected, in the order asked for.
805
+ private def table_result
806
+ return [@names, @table] unless @selection
807
+ indexes = selected_indexes(@names, @table_ncol)
808
+ names = @renames || indexes.map { |j| @names ? @names[j] : "c#{j}" }
809
+ kept = @table_keep || (0...@table_ncol).to_a
810
+ order = indexes.map { |j| kept.index(j) }
811
+ table =
812
+ if order == (0...kept.size).to_a then @table
813
+ elsif @table.is_a?(ConstStringColumns) then ConstStringColumns.new(@table.columns.values_at(*order))
814
+ else @table[nil, CA_INT64(order)]
815
+ end
816
+ [names, table]
216
817
  end
217
818
  end
218
819
  end