zlight_csv 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,19 @@
1
1
  use magnus::{
2
+ prelude::*,
2
3
  scan_args::{get_kwargs, scan_args},
3
- RHash, RString, Ruby, Symbol, Value,
4
+ RArray, RHash, RString, Ruby, Symbol, Value,
4
5
  };
5
6
 
7
+ use crate::convert::{Converter, Step};
6
8
  use crate::error::{Result, ZlightError};
7
9
 
8
- /// Configuration options for CSV parsing.
9
- #[derive(Debug, Clone, Copy)]
10
+ #[derive(Debug, Clone)]
10
11
  pub struct ParseOptions {
11
- /// Whether the CSV has a header row.
12
12
  pub has_headers: bool,
13
- /// Whether to convert numeric strings to integers/floats.
14
- pub convert_numeric: bool,
15
- /// Field delimiter character (defaults to comma).
13
+ /// How each field is turned into a Ruby value.
14
+ pub converter: Converter,
16
15
  pub delimiter: u8,
17
- /// Quote character (defaults to double quote).
18
16
  pub quote: u8,
19
- /// Whether to allow flexible record lengths.
20
17
  pub flexible: bool,
21
18
  }
22
19
 
@@ -25,7 +22,7 @@ impl Default for ParseOptions {
25
22
  fn default() -> Self {
26
23
  Self {
27
24
  has_headers: true,
28
- convert_numeric: false,
25
+ converter: Converter::default(),
29
26
  delimiter: b',',
30
27
  quote: b'"',
31
28
  flexible: true,
@@ -48,9 +45,8 @@ impl ParseOptions {
48
45
  Ok((input, Self::from_kwargs(ruby, parsed.keywords)?))
49
46
  }
50
47
 
51
- /// Builds options from a keyword arguments hash, applying the defaults.
52
48
  fn from_kwargs(ruby: &Ruby, kwargs: RHash) -> Result<Self> {
53
- let kw = get_kwargs::<_, (), (Option<bool>, Option<Symbol>, Option<RString>, Option<RString>, Option<bool>), ()>(
49
+ let kw = get_kwargs::<_, (), (Option<bool>, Option<Value>, Option<RString>, Option<RString>, Option<bool>), ()>(
54
50
  kwargs,
55
51
  &[],
56
52
  &["headers", "converters", "col_sep", "quote_char", "flexible"],
@@ -65,7 +61,7 @@ impl ParseOptions {
65
61
 
66
62
  Ok(ParseOptions {
67
63
  has_headers: headers.unwrap_or(true),
68
- convert_numeric: converters.is_some_and(|sym| sym == ruby.to_symbol("numeric")),
64
+ converter: converter_from(ruby, converters)?,
69
65
  delimiter: single_byte(col_sep, "col_sep")?.unwrap_or(b','),
70
66
  quote: single_byte(quote_char, "quote_char")?.unwrap_or(b'"'),
71
67
  flexible: flexible.unwrap_or(true),
@@ -93,3 +89,55 @@ pub fn single_byte(opt: Option<RString>, key: &'static str) -> Result<Option<u8>
93
89
  }),
94
90
  }
95
91
  }
92
+
93
+ /// Builds a `Converter` from the `converters:` option.
94
+ ///
95
+ /// Accepts a single converter or an Array of them, where each is either the
96
+ /// name of a built-in or any object answering `call`. Ruby's CSV reports an
97
+ /// unusable converter as a NoMethodError from deep inside itself; an
98
+ /// ArgumentError naming the problem is more use.
99
+ fn converter_from(ruby: &Ruby, requested: Option<Value>) -> Result<Converter> {
100
+ let Some(value) = requested.filter(|value| !value.is_nil()) else {
101
+ return Ok(Converter::default());
102
+ };
103
+
104
+ let steps = match RArray::from_value(value) {
105
+ Some(array) => array
106
+ .into_iter()
107
+ .map(|element| step_from(ruby, element))
108
+ .collect::<Result<Vec<_>>>()?,
109
+ None => vec![step_from(ruby, value)?],
110
+ };
111
+
112
+ Ok(Converter::new(steps))
113
+ }
114
+
115
+ fn step_from(ruby: &Ruby, value: Value) -> Result<Step> {
116
+ if let Some(symbol) = Symbol::from_value(value) {
117
+ return match symbol.name() {
118
+ Ok(name) if name == "numeric" => Ok(Step::Numeric),
119
+ _ => Err(ZlightError::InvalidOption {
120
+ key: "converters",
121
+ expected: "a known converter name (:numeric)",
122
+ actual: format!(":{}", symbol.name().unwrap_or("?".into())),
123
+ }),
124
+ };
125
+ }
126
+
127
+ if responds_to_call(value) {
128
+ return Ok(Step::Callable(value));
129
+ }
130
+
131
+ let _ = ruby;
132
+ Err(ZlightError::InvalidOption {
133
+ key: "converters",
134
+ expected: "a converter name, or an object responding to #call",
135
+ actual: format!("{}", value.class()),
136
+ })
137
+ }
138
+
139
+ fn responds_to_call(value: Value) -> bool {
140
+ value
141
+ .funcall::<_, _, bool>("respond_to?", ("call",))
142
+ .unwrap_or(false)
143
+ }
@@ -0,0 +1,26 @@
1
+ //! Constructing the underlying CSV reader.
2
+ //!
3
+ //! Both read paths build their `csv::Reader` here so that a parse option can
4
+ //! never apply to one and not the other. `ZLight.parse` reads from a byte
5
+ //! slice borrowed from Ruby; `ZLight::StreamReader` reads from a file or an
6
+ //! owned buffer. Only the source differs, which is why this is generic.
7
+
8
+ use std::io::Read;
9
+
10
+ use csv::{Reader, ReaderBuilder};
11
+
12
+ use crate::options::ParseOptions;
13
+
14
+ /// Reading in 64KB chunks rather than the crate default, which measurably
15
+ /// helps on the large files this extension exists for.
16
+ const BUFFER_CAPACITY: usize = 64 * 1024;
17
+
18
+ pub fn build<R: Read>(source: R, options: &ParseOptions) -> Reader<R> {
19
+ ReaderBuilder::new()
20
+ .has_headers(options.has_headers)
21
+ .delimiter(options.delimiter)
22
+ .quote(options.quote)
23
+ .flexible(options.flexible)
24
+ .buffer_capacity(BUFFER_CAPACITY)
25
+ .from_reader(source)
26
+ }
@@ -0,0 +1,85 @@
1
+ //! Turning a parsed record into the Ruby value a caller sees.
2
+ //!
3
+ //! # Where new output shapes go
4
+ //!
5
+ //! This is the plug point for result shape. Both read paths — the eager
6
+ //! `ZLight.parse` and the streaming `ZLight::StreamReader` — build every row
7
+ //! through `RowShape`, so they cannot drift apart. To return something new (a
8
+ //! `ZLight::Row` that keeps duplicate headers, a struct, a columnar batch),
9
+ //! add a variant here and select it in `options.rs`.
10
+
11
+ use csv::ByteRecord;
12
+ use magnus::{prelude::*, encoding::Index, Error as MagnusError, Ruby, StaticSymbol, Value};
13
+
14
+ use crate::convert::Converter;
15
+ use crate::error::ZlightError;
16
+
17
+ /// The shape each parsed row takes on the Ruby side.
18
+ pub enum RowShape {
19
+ /// A Hash keyed by the header row's symbols, resolved once up front.
20
+ Hashes(Vec<StaticSymbol>),
21
+ /// A positional Array, used when `headers: false`.
22
+ Arrays,
23
+ }
24
+
25
+ impl RowShape {
26
+ #[inline]
27
+ pub fn build(
28
+ &self,
29
+ ruby: &Ruby,
30
+ record: &ByteRecord,
31
+ converter: &Converter,
32
+ encoding: Index,
33
+ ) -> Result<Value, MagnusError> {
34
+ match self {
35
+ RowShape::Hashes(headers) => {
36
+ let hash = ruby.hash_new();
37
+
38
+ // zip stops at the shorter side, so a row with more fields
39
+ // than there are headers drops the extras, matching Ruby CSV.
40
+ for (header, field) in headers.iter().zip(record.iter()) {
41
+ hash.aset(*header, converter.apply(ruby, field, encoding)?)?;
42
+ }
43
+
44
+ Ok(hash.as_value())
45
+ }
46
+ RowShape::Arrays => {
47
+ let array = ruby.ary_new_capa(record.len());
48
+
49
+ for field in record.iter() {
50
+ array.push(converter.apply(ruby, field, encoding)?)?;
51
+ }
52
+
53
+ Ok(array.as_value())
54
+ }
55
+ }
56
+ }
57
+
58
+ pub fn headers(&self) -> Option<&[StaticSymbol]> {
59
+ match self {
60
+ RowShape::Hashes(headers) => Some(headers),
61
+ RowShape::Arrays => None,
62
+ }
63
+ }
64
+ }
65
+
66
+ /// Interns a header record as Ruby symbols.
67
+ ///
68
+ /// Headers become symbols once per parse rather than per row, which is why
69
+ /// `RowShape::Hashes` carries them. A header that is not valid UTF-8 cannot
70
+ /// become a symbol, so it is reported rather than lossily replaced.
71
+ pub fn header_symbols(
72
+ ruby: &Ruby,
73
+ headers: &ByteRecord,
74
+ ) -> Result<Vec<StaticSymbol>, ZlightError> {
75
+ headers
76
+ .iter()
77
+ .map(|header| {
78
+ std::str::from_utf8(header)
79
+ .map(|name| ruby.sym_new(name))
80
+ .map_err(|_| {
81
+ ZlightError::InvalidHeaderEncoding(String::from_utf8_lossy(header).to_string())
82
+ })
83
+ })
84
+ .collect()
85
+ }
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZLight
4
+ # Base class for everything this library raises.
5
+ #
6
+ # Rescuing ZLight::Error catches any failure originating in the gem. The
7
+ # subclasses are raised from the Rust extension, which looks them up by name
8
+ # at raise time; see ext/zlight_csv/src/error.rs.
9
+ class Error < StandardError; end
10
+
11
+ # Raised when the CSV itself is malformed, such as a record whose field count
12
+ # does not match the header under `flexible: false`.
13
+ class ParseError < Error; end
14
+
15
+ # Raised when a header is not valid UTF-8 and so cannot become a Symbol.
16
+ class EncodingError < Error; end
17
+
18
+ # Raised when a row is read from a StreamReader that has been closed.
19
+ class StreamClosedError < Error; end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZLight
4
+ # Loads the compiled extension.
5
+ #
6
+ # A precompiled gem ships one shared library per Ruby minor version, laid out
7
+ # as lib/zlight_csv/<major.minor>/zlight_csv.<so|bundle|dll>, because a C
8
+ # extension built against one Ruby's ABI cannot be loaded by another. A gem
9
+ # built from source has a single library at lib/zlight_csv instead, so try
10
+ # the versioned path first and fall back.
11
+ module Native
12
+ def self.load!
13
+ require_relative "#{RUBY_VERSION[/\d+\.\d+/]}/zlight_csv"
14
+ rescue LoadError
15
+ require_relative 'zlight_csv'
16
+ end
17
+ end
18
+ end
19
+
20
+ ZLight::Native.load!