carray 3.0.1 → 3.0.3

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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +824 -0
  3. data/README.md +5 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +258 -4
  6. data/ext/ca_axis_group.c +368 -184
  7. data/ext/ca_binop_dispatch.c +54 -32
  8. data/ext/ca_binop_dispatch.h +1 -0
  9. data/ext/ca_categorical_iterator.c +90 -41
  10. data/ext/ca_compare.c +8 -2
  11. data/ext/ca_for_buffer.h +21 -68
  12. data/ext/ca_group_iter.c +27 -11
  13. data/ext/ca_kernel_iterator.c +960 -245
  14. data/ext/ca_kernel_iterator.h +352 -181
  15. data/ext/ca_monop_dispatch.c +4 -4
  16. data/ext/ca_monop_dispatch.h +4 -0
  17. data/ext/ca_obj_array.c +194 -42
  18. data/ext/ca_obj_bincmp.c +152 -121
  19. data/ext/ca_obj_binop.c +107 -210
  20. data/ext/ca_obj_bitarray.c +37 -24
  21. data/ext/ca_obj_bitfield.c +95 -62
  22. data/ext/ca_obj_block.c +5 -4
  23. data/ext/ca_obj_byte_swap.c +40 -40
  24. data/ext/ca_obj_const_string.c +180 -50
  25. data/ext/ca_obj_face.c +428 -62
  26. data/ext/ca_obj_face.h +107 -47
  27. data/ext/ca_obj_fake.c +21 -50
  28. data/ext/ca_obj_farray.c +1 -0
  29. data/ext/ca_obj_field.c +16 -3
  30. data/ext/ca_obj_fixlen_string.c +39 -26
  31. data/ext/ca_obj_grid.c +181 -124
  32. data/ext/ca_obj_meld.c +235 -55
  33. data/ext/ca_obj_moncmp.c +61 -61
  34. data/ext/ca_obj_monop.c +125 -107
  35. data/ext/ca_obj_object.c +551 -233
  36. data/ext/ca_obj_record.c +51 -16
  37. data/ext/ca_obj_reduce.c +5 -8
  38. data/ext/ca_obj_refer.c +117 -33
  39. data/ext/ca_obj_remap.c +57 -17
  40. data/ext/ca_obj_repeat.c +21 -5
  41. data/ext/ca_obj_roll.c +115 -11
  42. data/ext/ca_obj_select.c +88 -75
  43. data/ext/ca_obj_select_axis.c +58 -60
  44. data/ext/ca_obj_shift.c +22 -102
  45. data/ext/ca_obj_stack.c +223 -123
  46. data/ext/ca_obj_stride.c +267 -35
  47. data/ext/ca_obj_string.c +25 -22
  48. data/ext/ca_obj_tile.c +32 -16
  49. data/ext/ca_obj_time.c +23 -25
  50. data/ext/ca_obj_timedelta.c +21 -23
  51. data/ext/ca_obj_transpose.c +12 -3
  52. data/ext/ca_obj_triop.c +110 -117
  53. data/ext/ca_obj_window.c +157 -29
  54. data/ext/ca_op_ipower.c +25 -13
  55. data/ext/ca_op_powi.h +57 -30
  56. data/ext/ca_pow5_table.h +656 -0
  57. data/ext/ca_rng_normal.h +42 -0
  58. data/ext/ca_rng_xoshiro256pp.h +105 -0
  59. data/ext/ca_sort_kernels.h +70 -0
  60. data/ext/ca_sweep_engine.c +385 -198
  61. data/ext/ca_sweep_engine.h +31 -23
  62. data/ext/ca_triop_dispatch.c +4 -1
  63. data/ext/ca_triop_dispatch.h +5 -2
  64. data/ext/caframe_csv_reader.c +463 -0
  65. data/ext/carray.h +90 -28
  66. data/ext/carray_access.c +487 -231
  67. data/ext/carray_address_basis.c +731 -0
  68. data/ext/carray_attribute.c +45 -3
  69. data/ext/carray_bincount.c +32 -2
  70. data/ext/carray_broadcast.c +49 -17
  71. data/ext/carray_call_cfunc.c +667 -511
  72. data/ext/carray_cast.c +1556 -247
  73. data/ext/carray_cast_func.rb +34 -21
  74. data/ext/carray_conversion.c +197 -73
  75. data/ext/carray_copy.c +84 -38
  76. data/ext/carray_core.c +591 -119
  77. data/ext/carray_count.c +148 -96
  78. data/ext/carray_element.c +18 -27
  79. data/ext/carray_factorize.c +604 -291
  80. data/ext/carray_generate.c +76 -40
  81. data/ext/carray_histogram.c +152 -94
  82. data/ext/carray_hold.c +2 -1
  83. data/ext/carray_index_classifier.c +63 -96
  84. data/ext/carray_internal.h +400 -1
  85. data/ext/carray_lazy.c +834 -79
  86. data/ext/carray_loop.c +88 -66
  87. data/ext/carray_mask.c +705 -324
  88. data/ext/carray_math_kernel.h +1 -0
  89. data/ext/carray_median_percentile.c +263 -63
  90. data/ext/carray_memory_view.c +254 -248
  91. data/ext/carray_operator.c +1093 -363
  92. data/ext/carray_order.c +133 -69
  93. data/ext/carray_partition.c +102 -56
  94. data/ext/carray_random.c +582 -204
  95. data/ext/carray_scatter.c +308 -65
  96. data/ext/carray_segment.c +257 -0
  97. data/ext/carray_slab.c +183 -70
  98. data/ext/carray_sort.c +235 -240
  99. data/ext/carray_sort_kernel.c +8 -1
  100. data/ext/carray_struct.c +99 -21
  101. data/ext/carray_test.c +167 -64
  102. data/ext/carray_time_text.c +1182 -0
  103. data/ext/carray_utils.c +363 -98
  104. data/ext/extconf.rb +27 -2
  105. data/ext/mk_call_cfunc.rb +103 -118
  106. data/ext/mk_pow5_table.rb +35 -0
  107. data/ext/mkkernel.rb +1349 -851
  108. data/ext/ruby_carray.c +17 -2
  109. data/ext/version.h +4 -4
  110. data/ext/version.rb +4 -2
  111. data/lib/carray/attribute.rb +39 -0
  112. data/lib/carray/autoload_carray.rb +24 -3
  113. data/lib/carray/autoload_method_extension.rb +18 -0
  114. data/lib/carray/axis_group.rb +245 -20
  115. data/lib/carray/basics.rb +95 -25
  116. data/lib/carray/bincount_nd.rb +6 -0
  117. data/lib/carray/block_iterator.rb +200 -54
  118. data/lib/carray/boolean_reduce.rb +21 -29
  119. data/lib/carray/categorical.rb +244 -59
  120. data/lib/carray/categorical_iterator.rb +391 -757
  121. data/lib/carray/complex.rb +36 -29
  122. data/lib/carray/conditional.rb +94 -7
  123. data/lib/carray/const_string.rb +177 -27
  124. data/lib/carray/construct.rb +40 -0
  125. data/lib/carray/data_type_extension.rb +3 -0
  126. data/lib/carray/data_type_limits.rb +91 -0
  127. data/lib/carray/fixlen_string.rb +1 -1
  128. data/lib/carray/frame/concat.rb +157 -6
  129. data/lib/carray/frame/convert.rb +13 -7
  130. data/lib/carray/frame/csv_parser.rb +590 -76
  131. data/lib/carray/frame/describe.rb +79 -0
  132. data/lib/carray/frame/frame.rb +205 -35
  133. data/lib/carray/frame/group.rb +199 -40
  134. data/lib/carray/frame/io.rb +452 -60
  135. data/lib/carray/frame/join.rb +134 -46
  136. data/lib/carray/frame/records.rb +54 -19
  137. data/lib/carray/frame/reshape.rb +448 -0
  138. data/lib/carray/frame/sort.rb +26 -2
  139. data/lib/carray/frame/verbs.rb +726 -56
  140. data/lib/carray/frame.rb +2 -0
  141. data/lib/carray/fuse_source.rb +111 -2
  142. data/lib/carray/fusion.rb +229 -38
  143. data/lib/carray/inspect.rb +71 -20
  144. data/lib/carray/iterator.rb +157 -2
  145. data/lib/carray/lazy.rb +54 -67
  146. data/lib/carray/mask_gap_fill.rb +66 -50
  147. data/lib/carray/math.rb +32 -19
  148. data/lib/carray/meld_reduce.rb +40 -7
  149. data/lib/carray/methods/align_addr.rb +8 -4
  150. data/lib/carray/methods/bincount.rb +2 -2
  151. data/lib/carray/methods/bit_string.rb +14 -3
  152. data/lib/carray/methods/composition.rb +16 -10
  153. data/lib/carray/methods/discovery_along.rb +74 -0
  154. data/lib/carray/methods/factorize.rb +50 -0
  155. data/lib/carray/methods/gather_nd.rb +13 -2
  156. data/lib/carray/methods/insert_block.rb +9 -14
  157. data/lib/carray/methods/is_in.rb +130 -6
  158. data/lib/carray/methods/join.rb +1 -1
  159. data/lib/carray/methods/locate_addr.rb +235 -21
  160. data/lib/carray/methods/mask_duplicates.rb +35 -1
  161. data/lib/carray/methods/mode.rb +18 -1
  162. data/lib/carray/methods/nunique.rb +22 -1
  163. data/lib/carray/methods/repeat.rb +91 -0
  164. data/lib/carray/methods/resize.rb +21 -23
  165. data/lib/carray/methods/snap.rb +34 -12
  166. data/lib/carray/methods/unique.rb +52 -3
  167. data/lib/carray/record_order.rb +208 -0
  168. data/lib/carray/rng.rb +86 -0
  169. data/lib/carray/runtime.rb +14 -11
  170. data/lib/carray/segment_iterator.rb +990 -0
  171. data/lib/carray/serialize.rb +197 -27
  172. data/lib/carray/slab_iterator.rb +109 -40
  173. data/lib/carray/stack.rb +10 -4
  174. data/lib/carray/string_operation_extension.rb +9 -4
  175. data/lib/carray/struct.rb +142 -51
  176. data/lib/carray/struct_builder.rb +110 -14
  177. data/lib/carray/table.rb +2 -2
  178. data/lib/carray/time.rb +535 -105
  179. data/lib/carray/window_iterator.rb +259 -82
  180. data/lib/carray.rb +4 -2
  181. data/yard-stubs/ca_obj_array.rb +4 -4
  182. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  183. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  184. data/yard-stubs/ca_obj_block.rb +2 -7
  185. data/yard-stubs/ca_obj_fake.rb +1 -1
  186. data/yard-stubs/ca_obj_field.rb +1 -1
  187. data/yard-stubs/ca_obj_refer.rb +3 -3
  188. data/yard-stubs/ca_obj_roll.rb +0 -1
  189. data/yard-stubs/ca_obj_stride.rb +10 -7
  190. data/yard-stubs/ca_obj_transpose.rb +1 -1
  191. data/yard-stubs/ca_obj_window.rb +11 -4
  192. data/yard-stubs/carray_access.rb +6 -5
  193. data/yard-stubs/carray_broadcast.rb +2 -2
  194. data/yard-stubs/carray_cast.rb +78 -43
  195. data/yard-stubs/carray_conversion.rb +13 -1
  196. data/yard-stubs/carray_copy.rb +8 -3
  197. data/yard-stubs/carray_core.rb +3 -83
  198. data/yard-stubs/carray_count.rb +16 -2
  199. data/yard-stubs/carray_lazy.rb +210 -1
  200. data/yard-stubs/carray_loop.rb +4 -1
  201. data/yard-stubs/carray_mask.rb +34 -10
  202. data/yard-stubs/carray_math.rb +1499 -4
  203. data/yard-stubs/carray_median_percentile.rb +26 -8
  204. data/yard-stubs/carray_order.rb +43 -82
  205. data/yard-stubs/carray_scatter.rb +10 -3
  206. data/yard-stubs/carray_segment.rb +72 -0
  207. data/yard-stubs/carray_slab.rb +11 -8
  208. data/yard-stubs/carray_sort.rb +50 -8
  209. data/yard-stubs/carray_test.rb +10 -0
  210. data/yard-stubs/carray_utils.rb +0 -26
  211. metadata +19 -19
  212. data/ext/ca_for_each_element.h +0 -239
  213. data/ext/carray_build_flags.h +0 -3
  214. data/ext/carray_config.h +0 -28
  215. data/ext/carray_kernels_bincmp.c +0 -4446
  216. data/ext/carray_kernels_binop.c +0 -11001
  217. data/ext/carray_kernels_init.c +0 -1131
  218. data/ext/carray_kernels_map.c +0 -3467
  219. data/ext/carray_kernels_moncmp.c +0 -2097
  220. data/ext/carray_kernels_monop.c +0 -18313
  221. data/ext/carray_kernels_reduce_aggregate.c +0 -25837
  222. data/ext/carray_kernels_reduce_boolean.c +0 -330
  223. data/ext/carray_kernels_reduce_cumulative.c +0 -14593
  224. data/ext/carray_kernels_reduce_extreme.c +0 -16948
  225. data/ext/carray_kernels_reduce_variance.c +0 -3910
  226. data/ext/carray_kernels_scan.c +0 -3693
  227. data/ext/carray_kernels_search.c +0 -32138
  228. data/ext/carray_kernels_sort.c +0 -10626
  229. data/ext/carray_kernels_triop.c +0 -1392
@@ -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,105 +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
92
147
  end
93
148
 
94
- # Fields of the next non-blank record, or nil at EOF.
95
- def read(io)
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)
162
+ end
163
+
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
170
+ # +blank_is_row: true+ once the column count is known to be one, and
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)
96
181
  loop do
97
- rec = CSVParser.read_record(io, @quote)
98
- return nil if rec.nil?
182
+ return nil if last_line && @lineno >= last_line
183
+ line = gets(io)
184
+ return nil if line.nil?
99
185
  @recno += 1
100
- next if rec.empty?
101
- 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)
102
197
  end
103
198
  end
104
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.
105
213
  private def simple(rec)
106
- rec.split(@sep, -1).map do |cell|
107
- cell = cell.strip if @strip
108
- 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 }
109
219
  end
220
+ fields
110
221
  end
111
222
 
112
- private def scan(rec)
113
- 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)
114
258
  fields = []
115
259
  loop do
116
- if sc.scan(@quote_re)
117
- buf = +""
118
- loop do
119
- buf << sc.scan(@inner)
120
- unless sc.scan(@quote_re)
121
- raise MalformedCSV, "unterminated quoted field in record #{@recno}"
122
- end
123
- if sc.scan(@quote_re) # doubled quote -> literal quote, field continues
124
- buf << @quote
125
- else
126
- break
127
- 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))
128
271
  end
129
- fields << buf
130
272
  else
131
- 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
132
279
  cell = cell.strip if @strip
133
280
  fields << (cell.empty? ? nil : cell)
134
281
  end
135
- break unless sc.scan(@sep_re)
282
+ break unless sc.skip(@sep_re)
136
283
  end
284
+ sc.skip(@eol)
137
285
  fields
138
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
139
315
  end
140
316
  end
141
317
 
142
- # The block reading-control DSL for CAFrame.from_csv. A file
143
- # often has preamble lines, a units row, or no header at all; the block says,
144
- # 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:
145
321
  #
146
- # CAFrame.from_csv(path) do
147
- # skip 3 # drop 3 preamble lines
148
- # header # next record supplies the column names
149
- # skip 1 # drop a units row
150
- # 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
151
327
  # end
152
328
  #
153
- # CAFrame.from_csv(path) do # headerless file
154
- # column_names "date", "temp", "rh"
155
- # body
156
- # end
329
+ # CAFrame.from_csv(path) { it.column_names "date", "temp", "rh"; it.data }
157
330
  #
158
- # The verbs are +skip+ / +header+ / +column_names+ / +body+; each returns a
159
- # value useful inline (header returns its fields) and the ordering is the
160
- # 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+).
161
336
  class CSVReader
162
- 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
163
339
  @io = io
340
+ @path = io.path if io.respond_to?(:path)
164
341
  @tok = CSVParser::Tokenizer.new(sep, quote, strip)
165
342
  @names = nil
166
343
  @rows = nil
@@ -168,7 +345,11 @@ class CAFrame
168
345
 
169
346
  # Drop +n+ raw lines (preamble, units, notes).
170
347
  def skip(n = 1)
171
- 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
172
353
  self
173
354
  end
174
355
 
@@ -177,35 +358,368 @@ class CAFrame
177
358
  # names. Returns the record's fields either way.
178
359
  def header(name = nil)
179
360
  fields = @tok.read(@io)
180
- raise CSVParser::MalformedCSV, "header expected but input ended" if fields.nil?
361
+ if fields.nil?
362
+ # An input with no record at all is a table with no columns: that is
363
+ # what to_csv writes for a frame without columns or index. Once lines
364
+ # have been skipped to reach the header, a missing header is an error.
365
+ if name.nil? && !@skipped
366
+ @names = []
367
+ @header_line = @tok.lineno
368
+ @names_to_check = nil
369
+ return []
370
+ end
371
+ raise CSVParser::MalformedCSV.new("header expected but input ended",
372
+ lineno: @tok.lineno + 1)
373
+ end
181
374
  if name.nil?
182
375
  @names = fields.map(&:to_s)
376
+ @header_line = @tok.record_line
377
+ @names_to_check = nil
183
378
  else
184
379
  (@named_headers ||= {})[name.to_s] = fields
185
380
  end
186
381
  fields
187
382
  end
188
383
 
189
- # Set the column names explicitly (headerless files).
384
+ # Set the column names explicitly: for a file with no header, or in
385
+ # place of the names a header gave. Their number has to be the file's
386
+ # number of columns -- the header's, or else that of the first record
387
+ # of the data -- or a name would go to no column, or a column without
388
+ # one.
190
389
  def column_names(*names)
191
- @names = names.flatten.map(&:to_s)
390
+ names = names.flatten.map(&:to_s)
391
+ if @header_line
392
+ check_column_names(names, @names.size, @header_line)
393
+ else
394
+ @names_to_check = names
395
+ end
396
+ @names = names
192
397
  self
193
398
  end
194
399
 
195
- # Consume the remaining records as data rows.
196
- def body
197
- rows = []
198
- while (fields = @tok.read(@io))
400
+ # Read only these columns: names of the header or of column_names, or
401
+ # indexes of the file's columns from 0 (Integers or Ranges), in the order
402
+ # the frame is to have them. The fields of the others are read past, so
403
+ # a few columns of a wide file cost little more than a narrow file.
404
+ def columns(*selection)
405
+ selection = selection.flatten
406
+ if selection.empty?
407
+ raise ArgumentError, "from_csv: columns: names no column"
408
+ end
409
+ selection.each do |c|
410
+ next if c.is_a?(String)
411
+ next if c.is_a?(Integer) && c >= 0
412
+ next if c.is_a?(Range) && (c.begin.nil? || (c.begin.is_a?(Integer) && c.begin >= 0)) &&
413
+ (c.end.nil? || c.end.is_a?(Integer))
414
+ raise ArgumentError, "from_csv: columns: takes column names, or indexes from 0 " \
415
+ "and Ranges of them (got #{c.inspect})"
416
+ end
417
+ @selection = selection
418
+ self
419
+ end
420
+
421
+ # The indexes of the selected columns, in the order asked for, among
422
+ # ncol columns named +names+ (nil: by index only). nil when ncol is not
423
+ # known yet and the selection needs it.
424
+ private def selected_indexes(names, ncol)
425
+ indexes = @selection.flat_map do |c|
426
+ case c
427
+ when String
428
+ j = names&.index(c)
429
+ raise KeyError, "from_csv: columns: names no column #{c.inspect}" if j.nil?
430
+ j
431
+ when Integer then c
432
+ else
433
+ first = c.begin || 0
434
+ last = c.end
435
+ return nil if last.nil? && ncol.nil?
436
+ last = ncol - 1 if last.nil?
437
+ last -= 1 if c.end && c.exclude_end?
438
+ (first..last).to_a
439
+ end
440
+ end
441
+ dup = indexes.tally.select { |_, k| k > 1 }.keys
442
+ unless dup.empty?
443
+ raise ArgumentError, "from_csv: columns: selects column #{dup.first} more than once"
444
+ end
445
+ if ncol && (bad = indexes.find { |j| j >= ncol })
446
+ raise ArgumentError, "from_csv: columns: #{bad} is past the #{ncol} columns of the file"
447
+ end
448
+ indexes
449
+ end
450
+
451
+ # The indexes C is to read, ascending, when it can tell which they are
452
+ # before it reads; nil for every column.
453
+ private def columns_to_read
454
+ @selection && selected_indexes(@names, @names&.size)&.sort
455
+ end
456
+
457
+ private def check_column_names(names, ncol, line)
458
+ return if names.size == ncol
459
+ raise ArgumentError, "from_csv: column_names: gives #{names.size} " \
460
+ "name#{names.size == 1 ? '' : 's'} for the #{ncol} columns of line #{line}"
461
+ end
462
+
463
+ # Consume the remaining records as data rows. A second data adds what is
464
+ # left, which is nothing; it does not discard the rows already read.
465
+ #
466
+ # Without names the column count is known only once every row is read,
467
+ # so blank lines are kept and marked, and +result+ drops them unless the
468
+ # file turns out to have one column -- the same rows a header would have
469
+ # given.
470
+ #
471
+ # Without strip:, the rest of the input is read in C (CArray.__csv_read_body_as_string_cells__)
472
+ # straight into the table, with the column count of the names, or of the
473
+ # first record when there are none. When C declines -- text not in
474
+ # UTF-8, a record it does not take, or one longer than the first in a
475
+ # file without names -- the text from there on is read here, which pads
476
+ # the earlier rows or reports what is wrong.
477
+ def data
478
+ read_data(nil)
479
+ end
480
+
481
+ # The header: / data: / column_names: of from_csv, as the verbs above.
482
+ # They index the lines of the file from 0, as File.readlines does (an
483
+ # error names a line from 1, as an editor does). header: defaults to 0,
484
+ # or to none (false) when column_names: is given; data: is the first line
485
+ # of the data or a Range of lines, by default from the line after the
486
+ # header. A record that starts within data: is read whole.
487
+ def layout(header: nil, data: nil, column_names: nil, columns: nil)
488
+ header = column_names ? false : 0 if header.nil?
489
+ unless header == false || (header.is_a?(Integer) && header >= 0)
490
+ raise ArgumentError, "from_csv: header: takes the index of a line, from 0, " \
491
+ "or false (got #{header.inspect})"
492
+ end
493
+ first, last = CSVReader.data_lines(data)
494
+ if header
495
+ skip(header - @tok.lineno) if header > @tok.lineno
496
+ self.header
497
+ end
498
+ column_names(*column_names) if column_names
499
+ self.columns(*(columns.is_a?(Array) ? columns : [columns])) if columns
500
+ first ||= @tok.lineno
501
+ if first < @tok.lineno
502
+ raise ArgumentError, "from_csv: data: #{first} does not come after header: #{header}"
503
+ end
504
+ skip(first - @tok.lineno)
505
+ read_data(last && last + 1)
506
+ end
507
+
508
+ # [first, last] line indexes of a data: setting, nil for an end left open.
509
+ def self.data_lines(spec)
510
+ first, last =
511
+ case spec
512
+ when nil then [nil, nil]
513
+ when Integer then [spec, nil]
514
+ when Range
515
+ e = spec.end
516
+ e -= 1 if e.is_a?(Integer) && spec.exclude_end?
517
+ [spec.begin, e]
518
+ else
519
+ raise ArgumentError, "from_csv: data: takes the index of a line or a Range of " \
520
+ "them, from 0 (got #{spec.inspect})"
521
+ end
522
+ [first, last].each do |n|
523
+ next if n.nil? || (n.is_a?(Integer) && n >= 0)
524
+ raise ArgumentError, "from_csv: data: lines are indexed from 0 (got #{spec.inspect})"
525
+ end
526
+ if first && last && last < first
527
+ raise ArgumentError, "from_csv: data: #{spec.inspect} ends before it starts"
528
+ end
529
+ [first, last]
530
+ end
531
+
532
+ # The records from here through those starting on +last+ (nil: the end).
533
+ private def read_data(last)
534
+ body_in_c(last) if @rows.nil? && !@tok.strip && @io.respond_to?(:read)
535
+ return self if @table
536
+ rows = (@rows ||= [])
537
+ @blank_rows ||= []
538
+ blank_is_row = @names.nil? || @names.size == 1
539
+ ncol = @names&.size
540
+ while (fields = @tok.read(@io, blank_is_row: blank_is_row, last_line: last))
541
+ if @names_to_check && !@tok.blank?
542
+ check_column_names(@names_to_check, fields.size, @tok.record_line)
543
+ @names_to_check = nil
544
+ end
545
+ if ncol && fields.size > ncol
546
+ raise @tok.malformed("#{fields.size} fields, but there are #{ncol} columns")
547
+ end
548
+ @blank_rows << rows.size if @names.nil? && @tok.blank?
199
549
  rows << fields
200
550
  end
201
- @rows = rows
202
551
  self
203
552
  end
204
553
 
554
+ # Run the block, naming the file in a MalformedCSV it raises.
555
+ def reporting
556
+ yield
557
+ rescue CSVParser::MalformedCSV => e
558
+ e.path ||= @path
559
+ raise
560
+ end
561
+
562
+ # The text C reads at a time when the column count is known; a chunk
563
+ # ends at a line end, and not inside a quoted field.
564
+ CHUNK_BYTES = 1 << 22
565
+
566
+ # With names, the input goes to C a chunk at a time, so the text is not
567
+ # held whole beside its cells. Without them, the column count and which
568
+ # blank lines are rows are known only at the end, so it goes in whole;
569
+ # so it does when the IO transcodes, since read(length) does not.
570
+ #
571
+ # With read_text_as_const_string:, the body goes to C whole, into one
572
+ # CAConstString per column over the text itself: the text is the buffer
573
+ # the columns read, so it is held whole either way. When C declines, the
574
+ # text is read here from the start of the body.
575
+ private def read_body_into_const_string_columns
576
+ ncol = @names && !@names_to_check ? @names.size : 0
577
+ keep = columns_to_read
578
+ text = @io.read || ""
579
+ n, buffer, pairs, records = CArray.__csv_read_body_as_const_string_columns__(text, @tok.sep, @tok.quote, ncol, keep)
580
+ unless buffer
581
+ @rows = []
582
+ @io = StringIO.new(text)
583
+ return
584
+ end
585
+ if @names_to_check
586
+ check_column_names(@names_to_check, n, @tok.lineno + first_record_line(text))
587
+ @names_to_check = nil
588
+ end
589
+ @tok.advance(records, text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1))
590
+ @table = ConstStringColumns.new(pairs.map { |pr| CAConstString.wrap(pr, buffer: buffer) })
591
+ @table_ncol = n
592
+ @table_keep = keep
593
+ @rows = []
594
+ end
595
+
596
+ # The columns read_body_into_const_string_columns made, as +result+
597
+ # hands them to CAFrame.from_csv.
598
+ ConstStringColumns = Struct.new(:columns)
599
+
600
+ private def body_in_c(last = nil, chunk_bytes = CHUNK_BYTES)
601
+ return read_body_into_const_string_columns if last.nil? && @read_text_as_const_string
602
+ chunked = last.nil? && @names && @io.respond_to?(:external_encoding) &&
603
+ @io.internal_encoding.nil?
604
+ # Names that have to agree with the first record leave its column
605
+ # count to C, to be checked against them.
606
+ ncol = @names && !@names_to_check ? @names.size : 0
607
+ keep = columns_to_read
608
+ cells = []
609
+ loop do
610
+ text = if chunked then next_chunk(chunk_bytes)
611
+ elsif last then lines_through(last)
612
+ else @io.read || ""
613
+ end
614
+ break if text.nil?
615
+ n, flat, records = CArray.__csv_read_body_as_string_cells__(text, @tok.sep, @tok.quote, ncol, keep)
616
+ unless flat
617
+ # This chunk and the rest are read by the Ruby tokenizer, after the
618
+ # rows read so far.
619
+ if keep && ncol > 0 && !cells.empty?
620
+ # The rows read so far hold the selected fields only; spread
621
+ # them back to their columns for the Ruby reader.
622
+ @rows = cells.each_slice(keep.size).map do |row|
623
+ full = Array.new(ncol)
624
+ keep.each_with_index { |j, k| full[j] = UNDEF.equal?(row[k]) ? nil : row[k] }
625
+ full
626
+ end
627
+ else
628
+ @rows = ncol > 0 ? cells.each_slice(ncol).to_a : []
629
+ end
630
+ @io = StringIO.new(chunked ? text + (@io.read || "") : text)
631
+ return
632
+ end
633
+ if @names_to_check
634
+ check_column_names(@names_to_check, n, @tok.lineno + first_record_line(text))
635
+ @names_to_check = nil
636
+ end
637
+ ncol = n
638
+ cells.concat(flat)
639
+ @tok.advance(records, text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1))
640
+ break unless chunked
641
+ end
642
+ if ncol == 0 # no record, for the names to be checked against
643
+ @rows = []
644
+ return
645
+ end
646
+ width = keep ? keep.size : ncol
647
+ @table = CArray.object(cells.size / width, width) { cells }
648
+ @table_ncol = ncol
649
+ @table_keep = keep
650
+ @rows = []
651
+ end
652
+
653
+ # The line, from 1 within +text+, of its first record: past the blank
654
+ # lines before it, as C reads them.
655
+ private def first_record_line(text)
656
+ line = 1
657
+ text.each_line do |l|
658
+ rec = l.chomp
659
+ break unless rec.match?(/\A[ \t]*\z/) && !rec.include?(@tok.sep)
660
+ line += 1
661
+ end
662
+ line
663
+ end
664
+
665
+ # The lines through +last+, read on past any quoted field still open.
666
+ private def lines_through(last)
667
+ buf = +""
668
+ (last - @tok.lineno).times do
669
+ line = @io.gets or break
670
+ buf << line
671
+ end
672
+ while buf.count(@tok.quote).odd? && (more = @io.gets)
673
+ buf << more
674
+ end
675
+ buf
676
+ end
677
+
678
+ # The next chunk_bytes of the input, read on to the end of its line and
679
+ # past any quoted field still open, in the IO's encoding; nil at EOF.
680
+ private def next_chunk(chunk_bytes)
681
+ buf = @io.read(chunk_bytes)
682
+ return nil if buf.nil?
683
+ buf.force_encoding(@io.external_encoding || Encoding.default_external)
684
+ if !buf.end_with?("\n") && (rest = @io.gets)
685
+ buf << rest
686
+ end
687
+ while buf.count(@tok.quote).odd? && (more = @io.gets)
688
+ buf << more
689
+ end
690
+ buf
691
+ end
692
+
205
693
  # [names_or_nil, rows] for CAFrame.from_csv to build from. names is nil when
206
694
  # neither header nor column_names ran (positional names are generated).
207
695
  def result
208
- [@names, @rows || []]
696
+ return table_result if @table
697
+ rows = @rows || []
698
+ if @names.nil? && @blank_rows && !@blank_rows.empty? &&
699
+ rows.map(&:size).max != 1
700
+ drop = @blank_rows.to_h { |i| [i, true] }
701
+ rows = rows.reject.with_index { |_, i| drop[i] }
702
+ end
703
+ return [@names, rows] unless @selection
704
+ ncol = @names ? @names.size : (rows.map(&:size).max || 0)
705
+ indexes = selected_indexes(@names, ncol)
706
+ names = indexes.map { |j| @names ? @names[j] : "c#{j}" }
707
+ [names, rows.map { |r| r.values_at(*indexes) }]
708
+ end
709
+
710
+ # The table C read, its columns those selected, in the order asked for.
711
+ private def table_result
712
+ return [@names, @table] unless @selection
713
+ indexes = selected_indexes(@names, @table_ncol)
714
+ names = indexes.map { |j| @names ? @names[j] : "c#{j}" }
715
+ kept = @table_keep || (0...@table_ncol).to_a
716
+ order = indexes.map { |j| kept.index(j) }
717
+ table =
718
+ if order == (0...kept.size).to_a then @table
719
+ elsif @table.is_a?(ConstStringColumns) then ConstStringColumns.new(@table.columns.values_at(*order))
720
+ else @table[nil, CA_INT64(order)]
721
+ end
722
+ [names, table]
209
723
  end
210
724
  end
211
725
  end