carray 3.0.2 → 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 (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +304 -0
  3. data/README.md +3 -4
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +225 -0
  6. data/ext/ca_axis_group.c +177 -99
  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 +66 -71
  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 +663 -214
  14. data/ext/ca_kernel_iterator.h +231 -167
  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 +132 -22
  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 +1 -0
  23. data/ext/ca_obj_byte_swap.c +40 -40
  24. data/ext/ca_obj_const_string.c +96 -25
  25. data/ext/ca_obj_face.c +406 -64
  26. data/ext/ca_obj_face.h +92 -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 +21 -21
  31. data/ext/ca_obj_grid.c +181 -124
  32. data/ext/ca_obj_meld.c +189 -107
  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 +543 -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 +47 -49
  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 -118
  46. data/ext/ca_obj_stride.c +195 -34
  47. data/ext/ca_obj_string.c +17 -18
  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 +149 -27
  54. data/ext/ca_op_ipower.c +24 -11
  55. data/ext/ca_op_powi.h +57 -30
  56. data/ext/ca_pow5_table.h +656 -0
  57. data/ext/ca_sort_kernels.h +70 -0
  58. data/ext/ca_sweep_engine.c +102 -79
  59. data/ext/ca_sweep_engine.h +7 -20
  60. data/ext/ca_triop_dispatch.c +4 -1
  61. data/ext/ca_triop_dispatch.h +5 -2
  62. data/ext/caframe_csv_reader.c +463 -0
  63. data/ext/carray.h +70 -27
  64. data/ext/carray_access.c +455 -211
  65. data/ext/carray_address_basis.c +177 -36
  66. data/ext/carray_attribute.c +45 -3
  67. data/ext/carray_bincount.c +32 -2
  68. data/ext/carray_broadcast.c +46 -14
  69. data/ext/carray_call_cfunc.c +0 -28
  70. data/ext/carray_cast.c +1444 -209
  71. data/ext/carray_cast_func.rb +34 -21
  72. data/ext/carray_conversion.c +197 -73
  73. data/ext/carray_copy.c +32 -11
  74. data/ext/carray_core.c +514 -122
  75. data/ext/carray_count.c +140 -87
  76. data/ext/carray_element.c +18 -27
  77. data/ext/carray_factorize.c +569 -277
  78. data/ext/carray_generate.c +76 -40
  79. data/ext/carray_histogram.c +152 -94
  80. data/ext/carray_hold.c +2 -1
  81. data/ext/carray_index_classifier.c +63 -96
  82. data/ext/carray_internal.h +385 -3
  83. data/ext/carray_lazy.c +834 -79
  84. data/ext/carray_loop.c +81 -65
  85. data/ext/carray_mask.c +682 -316
  86. data/ext/carray_math_kernel.h +1 -0
  87. data/ext/carray_median_percentile.c +221 -76
  88. data/ext/carray_memory_view.c +254 -248
  89. data/ext/carray_operator.c +1089 -359
  90. data/ext/carray_order.c +133 -69
  91. data/ext/carray_partition.c +102 -56
  92. data/ext/carray_random.c +273 -239
  93. data/ext/carray_scatter.c +308 -65
  94. data/ext/carray_segment.c +257 -0
  95. data/ext/carray_slab.c +170 -70
  96. data/ext/carray_sort.c +215 -218
  97. data/ext/carray_sort_kernel.c +8 -1
  98. data/ext/carray_struct.c +99 -21
  99. data/ext/carray_test.c +167 -64
  100. data/ext/carray_time_text.c +1182 -0
  101. data/ext/carray_utils.c +363 -98
  102. data/ext/extconf.rb +27 -2
  103. data/ext/mk_call_cfunc.rb +0 -2
  104. data/ext/mk_pow5_table.rb +35 -0
  105. data/ext/mkkernel.rb +1076 -846
  106. data/ext/ruby_carray.c +7 -1
  107. data/ext/version.h +4 -4
  108. data/ext/version.rb +4 -2
  109. data/lib/carray/attribute.rb +39 -0
  110. data/lib/carray/autoload_carray.rb +19 -0
  111. data/lib/carray/autoload_method_extension.rb +6 -0
  112. data/lib/carray/axis_group.rb +174 -26
  113. data/lib/carray/basics.rb +91 -25
  114. data/lib/carray/bincount_nd.rb +6 -0
  115. data/lib/carray/block_iterator.rb +118 -48
  116. data/lib/carray/boolean_reduce.rb +21 -29
  117. data/lib/carray/categorical.rb +94 -26
  118. data/lib/carray/categorical_iterator.rb +333 -826
  119. data/lib/carray/complex.rb +36 -29
  120. data/lib/carray/conditional.rb +94 -7
  121. data/lib/carray/const_string.rb +46 -0
  122. data/lib/carray/frame/concat.rb +157 -6
  123. data/lib/carray/frame/convert.rb +13 -7
  124. data/lib/carray/frame/csv_parser.rb +589 -82
  125. data/lib/carray/frame/describe.rb +79 -0
  126. data/lib/carray/frame/frame.rb +124 -25
  127. data/lib/carray/frame/group.rb +189 -63
  128. data/lib/carray/frame/io.rb +412 -72
  129. data/lib/carray/frame/join.rb +134 -46
  130. data/lib/carray/frame/records.rb +37 -16
  131. data/lib/carray/frame/reshape.rb +448 -0
  132. data/lib/carray/frame/sort.rb +26 -2
  133. data/lib/carray/frame/verbs.rb +715 -48
  134. data/lib/carray/frame.rb +2 -0
  135. data/lib/carray/fuse_source.rb +111 -2
  136. data/lib/carray/fusion.rb +229 -38
  137. data/lib/carray/inspect.rb +29 -11
  138. data/lib/carray/iterator.rb +14 -2
  139. data/lib/carray/lazy.rb +54 -30
  140. data/lib/carray/mask_gap_fill.rb +66 -52
  141. data/lib/carray/math.rb +32 -19
  142. data/lib/carray/meld_reduce.rb +40 -7
  143. data/lib/carray/methods/align_addr.rb +8 -4
  144. data/lib/carray/methods/bincount.rb +2 -2
  145. data/lib/carray/methods/bit_string.rb +14 -3
  146. data/lib/carray/methods/composition.rb +16 -10
  147. data/lib/carray/methods/factorize.rb +2 -2
  148. data/lib/carray/methods/gather_nd.rb +13 -2
  149. data/lib/carray/methods/insert_block.rb +9 -14
  150. data/lib/carray/methods/is_in.rb +117 -4
  151. data/lib/carray/methods/join.rb +1 -1
  152. data/lib/carray/methods/locate_addr.rb +177 -36
  153. data/lib/carray/methods/mode.rb +18 -1
  154. data/lib/carray/methods/repeat.rb +1 -20
  155. data/lib/carray/methods/resize.rb +21 -23
  156. data/lib/carray/methods/snap.rb +34 -12
  157. data/lib/carray/methods/unique.rb +11 -2
  158. data/lib/carray/record_order.rb +208 -0
  159. data/lib/carray/runtime.rb +14 -11
  160. data/lib/carray/segment_iterator.rb +990 -0
  161. data/lib/carray/serialize.rb +197 -27
  162. data/lib/carray/slab_iterator.rb +51 -27
  163. data/lib/carray/stack.rb +10 -4
  164. data/lib/carray/string_operation_extension.rb +4 -3
  165. data/lib/carray/struct.rb +142 -51
  166. data/lib/carray/struct_builder.rb +110 -14
  167. data/lib/carray/table.rb +2 -2
  168. data/lib/carray/time.rb +517 -103
  169. data/lib/carray/window_iterator.rb +127 -72
  170. data/lib/carray.rb +2 -2
  171. data/yard-stubs/ca_obj_array.rb +4 -4
  172. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  173. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  174. data/yard-stubs/ca_obj_fake.rb +1 -1
  175. data/yard-stubs/ca_obj_field.rb +1 -1
  176. data/yard-stubs/ca_obj_refer.rb +3 -3
  177. data/yard-stubs/ca_obj_roll.rb +0 -1
  178. data/yard-stubs/ca_obj_stride.rb +10 -7
  179. data/yard-stubs/ca_obj_transpose.rb +1 -1
  180. data/yard-stubs/ca_obj_window.rb +1 -2
  181. data/yard-stubs/carray_access.rb +5 -4
  182. data/yard-stubs/carray_broadcast.rb +1 -1
  183. data/yard-stubs/carray_cast.rb +78 -43
  184. data/yard-stubs/carray_conversion.rb +13 -1
  185. data/yard-stubs/carray_copy.rb +8 -3
  186. data/yard-stubs/carray_core.rb +3 -3
  187. data/yard-stubs/carray_count.rb +9 -0
  188. data/yard-stubs/carray_lazy.rb +5 -1
  189. data/yard-stubs/carray_loop.rb +4 -1
  190. data/yard-stubs/carray_mask.rb +34 -10
  191. data/yard-stubs/carray_math.rb +25 -13
  192. data/yard-stubs/carray_median_percentile.rb +10 -6
  193. data/yard-stubs/carray_order.rb +34 -13
  194. data/yard-stubs/carray_scatter.rb +10 -3
  195. data/yard-stubs/carray_segment.rb +72 -0
  196. data/yard-stubs/carray_slab.rb +2 -1
  197. data/yard-stubs/carray_sort.rb +43 -3
  198. data/yard-stubs/carray_test.rb +10 -0
  199. data/yard-stubs/carray_utils.rb +0 -26
  200. metadata +11 -19
  201. data/ext/ca_for_each_element.h +0 -239
  202. data/ext/carray_build_flags.h +0 -3
  203. data/ext/carray_config.h +0 -28
  204. data/ext/carray_kernels_bincmp.c +0 -4446
  205. data/ext/carray_kernels_binop.c +0 -11001
  206. data/ext/carray_kernels_init.c +0 -1131
  207. data/ext/carray_kernels_map.c +0 -3467
  208. data/ext/carray_kernels_moncmp.c +0 -2097
  209. data/ext/carray_kernels_monop.c +0 -18313
  210. data/ext/carray_kernels_reduce_aggregate.c +0 -26005
  211. data/ext/carray_kernels_reduce_boolean.c +0 -330
  212. data/ext/carray_kernels_reduce_cumulative.c +0 -14862
  213. data/ext/carray_kernels_reduce_extreme.c +0 -17494
  214. data/ext/carray_kernels_reduce_variance.c +0 -3910
  215. data/ext/carray_kernels_scan.c +0 -3693
  216. data/ext/carray_kernels_search.c +0 -32218
  217. data/ext/carray_kernels_sort.c +0 -10626
  218. 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,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
92
147
  end
93
148
 
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
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
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,7 +345,11 @@ 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
 
@@ -183,36 +358,368 @@ class CAFrame
183
358
  # names. Returns the record's fields either way.
184
359
  def header(name = nil)
185
360
  fields = @tok.read(@io)
186
- 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
187
374
  if name.nil?
188
375
  @names = fields.map(&:to_s)
376
+ @header_line = @tok.record_line
377
+ @names_to_check = nil
189
378
  else
190
379
  (@named_headers ||= {})[name.to_s] = fields
191
380
  end
192
381
  fields
193
382
  end
194
383
 
195
- # 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.
196
389
  def column_names(*names)
197
- @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
198
397
  self
199
398
  end
200
399
 
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))
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?
206
549
  rows << fields
207
550
  end
208
- @rows = rows
209
551
  self
210
552
  end
211
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
+
212
693
  # [names_or_nil, rows] for CAFrame.from_csv to build from. names is nil when
213
694
  # neither header nor column_names ran (positional names are generated).
214
695
  def result
215
- [@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]
216
723
  end
217
724
  end
218
725
  end