zlight_csv 0.4.0-aarch64-linux → 0.6.0-aarch64-linux

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.
@@ -0,0 +1,203 @@
1
+ //! Turning a raw CSV field into a Ruby value.
2
+ //!
3
+ //! # Where new field types go
4
+ //!
5
+ //! `Converter` is the only thing that decides what a `&[u8]` field becomes. To
6
+ //! support a new kind of value, add a `Step` variant here and map the option to
7
+ //! it in `options.rs`; the read paths call `Converter::apply` and nothing else.
8
+
9
+ use magnus::{
10
+ encoding::Index, gc, prelude::*, Error as MagnusError, RString, Ruby, Value,
11
+ };
12
+
13
+ /// One stage of a conversion chain.
14
+ #[derive(Debug, Clone)]
15
+ pub enum Step {
16
+ /// Recognise integers and floats, leaving anything else a String.
17
+ Numeric,
18
+ /// Any Ruby object answering `call`, given the field and returning its
19
+ /// replacement.
20
+ Callable(Value),
21
+ }
22
+
23
+ /// How each field is turned into a Ruby value.
24
+ ///
25
+ /// An empty chain means every field stays a String, which is the default.
26
+ #[derive(Debug, Clone, Default)]
27
+ pub struct Converter {
28
+ steps: Vec<Step>,
29
+ }
30
+
31
+ impl Converter {
32
+ pub fn new(steps: Vec<Step>) -> Self {
33
+ Self { steps }
34
+ }
35
+
36
+ /// Converts one field, running each step in turn.
37
+ ///
38
+ /// Matching Ruby's CSV, the chain stops as soon as a step returns anything
39
+ /// other than a String: a converter that has already produced a typed value
40
+ /// is not handed to the next converter.
41
+ pub fn apply(
42
+ &self,
43
+ ruby: &Ruby,
44
+ field: &[u8],
45
+ encoding: Index,
46
+ ) -> Result<Value, MagnusError> {
47
+ let mut steps = self.steps.iter();
48
+
49
+ // The first step reads the bytes directly, so `converters: :numeric`
50
+ // never builds a Ruby String for a field it is about to replace.
51
+ let Some(first) = steps.next() else {
52
+ return Ok(string(ruby, field, encoding));
53
+ };
54
+
55
+ let mut value = match first {
56
+ Step::Numeric => numeric(ruby, field).unwrap_or_else(|| string(ruby, field, encoding)),
57
+ Step::Callable(callable) => {
58
+ callable.funcall("call", (string(ruby, field, encoding),))?
59
+ }
60
+ };
61
+
62
+ for step in steps {
63
+ let Some(text) = RString::from_value(value) else {
64
+ break;
65
+ };
66
+
67
+ value = match step {
68
+ Step::Numeric => {
69
+ // Copied because the conversion below allocates, and an
70
+ // embedded Ruby string may move when it does.
71
+ let bytes = unsafe { text.as_slice() }.to_vec();
72
+ numeric(ruby, &bytes).unwrap_or(value)
73
+ }
74
+ Step::Callable(callable) => callable.funcall("call", (value,))?,
75
+ };
76
+ }
77
+
78
+ Ok(value)
79
+ }
80
+
81
+ /// Keeps any Ruby callables in the chain alive.
82
+ ///
83
+ /// A `StreamReader` outlives the call that created it, so a proc passed as
84
+ /// a converter is reachable only through this struct. Without marking it,
85
+ /// Ruby could collect it and the reader would call freed memory.
86
+ pub fn mark(&self, marker: &gc::Marker) {
87
+ for step in &self.steps {
88
+ if let Step::Callable(value) = step {
89
+ marker.mark(*value);
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ fn string(ruby: &Ruby, field: &[u8], encoding: Index) -> Value {
96
+ ruby.enc_str_new(field, encoding).as_value()
97
+ }
98
+
99
+ /// Scans for `.`, `e` or `E` in one pass to decide whether the integer fast
100
+ /// path is worth trying.
101
+ #[inline(always)]
102
+ fn looks_like_float(bytes: &[u8]) -> bool {
103
+ bytes.iter().any(|&b| b == b'.' || b == b'e' || b == b'E')
104
+ }
105
+
106
+ #[inline(always)]
107
+ fn trim_ascii(bytes: &[u8]) -> &[u8] {
108
+ let start = bytes
109
+ .iter()
110
+ .position(|&b| !b.is_ascii_whitespace())
111
+ .unwrap_or(bytes.len());
112
+ let end = bytes
113
+ .iter()
114
+ .rposition(|&b| !b.is_ascii_whitespace())
115
+ .map_or(start, |i| i + 1);
116
+ &bytes[start..end]
117
+ }
118
+
119
+ enum IntScan {
120
+ Fits(i64),
121
+ /// A valid decimal integer, but too large in magnitude for an i64.
122
+ TooLarge,
123
+ NotAnInteger,
124
+ }
125
+
126
+ /// Reads an optionally signed run of ASCII digits without validating UTF-8.
127
+ ///
128
+ /// Overflow is reported rather than rejected so the caller can fall back to an
129
+ /// exact Ruby Integer instead of an inexact float.
130
+ #[inline(always)]
131
+ fn scan_int(bytes: &[u8]) -> IntScan {
132
+ if bytes.is_empty() {
133
+ return IntScan::NotAnInteger;
134
+ }
135
+
136
+ let digits = match bytes[0] {
137
+ b'-' | b'+' => &bytes[1..],
138
+ _ => bytes,
139
+ };
140
+ let negative = bytes[0] == b'-';
141
+
142
+ if digits.is_empty() {
143
+ return IntScan::NotAnInteger;
144
+ }
145
+
146
+ let mut magnitude: i64 = 0;
147
+ let mut overflowed = false;
148
+
149
+ for &b in digits {
150
+ if !b.is_ascii_digit() {
151
+ return IntScan::NotAnInteger;
152
+ }
153
+ if overflowed {
154
+ continue;
155
+ }
156
+ match magnitude
157
+ .checked_mul(10)
158
+ .and_then(|m| m.checked_add((b - b'0') as i64))
159
+ {
160
+ Some(next) => magnitude = next,
161
+ None => overflowed = true,
162
+ }
163
+ }
164
+
165
+ if overflowed {
166
+ IntScan::TooLarge
167
+ } else if negative {
168
+ IntScan::Fits(-magnitude)
169
+ } else {
170
+ IntScan::Fits(magnitude)
171
+ }
172
+ }
173
+
174
+ /// Builds an exact Ruby Integer from digits too large for an i64.
175
+ ///
176
+ /// `scan_int` has already established the slice is ASCII digits with an
177
+ /// optional sign, which is what makes `String#to_i` safe here.
178
+ fn big_integer(ruby: &Ruby, digits: &[u8]) -> Option<Value> {
179
+ let text = std::str::from_utf8(digits).ok()?;
180
+ ruby.str_new(text).funcall("to_i", ()).ok()
181
+ }
182
+
183
+ /// Returns the numeric value of a field, or `None` if it is not a number.
184
+ fn numeric(ruby: &Ruby, field: &[u8]) -> Option<Value> {
185
+ let trimmed = trim_ascii(field);
186
+ if trimmed.is_empty() {
187
+ return None;
188
+ }
189
+
190
+ if !looks_like_float(trimmed) {
191
+ match scan_int(trimmed) {
192
+ IntScan::Fits(i) => return Some(ruby.integer_from_i64(i).as_value()),
193
+ IntScan::TooLarge => return big_integer(ruby, trimmed),
194
+ IntScan::NotAnInteger => {}
195
+ }
196
+ }
197
+
198
+ std::str::from_utf8(trimmed)
199
+ .ok()?
200
+ .parse::<f64>()
201
+ .ok()
202
+ .map(|f| ruby.float_from_f64(f).as_value())
203
+ }
@@ -0,0 +1,128 @@
1
+ use magnus::{prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
2
+ use thiserror::Error;
3
+
4
+ #[derive(Debug, Error)]
5
+ pub enum ZlightError {
6
+ #[error("CSV parsing error: {0}")]
7
+ CsvParse(csv::Error),
8
+
9
+ #[error("Invalid UTF-8 encoding in header: {0}")]
10
+ InvalidHeaderEncoding(String),
11
+
12
+ #[error("Missing required argument: {0}")]
13
+ MissingArgument(&'static str),
14
+
15
+ #[error("Invalid option value for '{key}': expected {expected}, got {actual}")]
16
+ InvalidOption {
17
+ key: &'static str,
18
+ expected: &'static str,
19
+ actual: String,
20
+ },
21
+
22
+ #[error("IO error: {0}")]
23
+ Io(#[from] std::io::Error),
24
+
25
+ #[error("Stream reader has been closed")]
26
+ StreamClosed,
27
+
28
+ #[error(
29
+ "Stream reader is already in use. Either a converter used the reader it \
30
+ was converting for, or another thread used it while a converter was \
31
+ running. Give each thread its own reader."
32
+ )]
33
+ ReaderBusy,
34
+ }
35
+
36
+ impl ZlightError {
37
+ /// Requires the Ruby handle, which is always available here: an error is
38
+ /// only converted on its way out of an extension method, and those run on
39
+ /// a Ruby thread holding the GVL. Building an exception needs a class, and
40
+ /// a class cannot be obtained without the handle, so there is no sensible
41
+ /// fallback for a VM we are not attached to.
42
+ #[inline]
43
+ pub fn to_magnus_error(&self) -> MagnusError {
44
+ let ruby = Ruby::get().expect("errors are only converted while a Ruby method is running");
45
+ MagnusError::new(self.exception_class(&ruby), self.to_string())
46
+ }
47
+
48
+ /// Resolves the exception class to raise, preferring the gem's own
49
+ /// `ZLight::*` class over the built-in it descends from.
50
+ fn exception_class(&self, ruby: &Ruby) -> ExceptionClass {
51
+ self.zlight_class_name()
52
+ .and_then(|name| resolve_zlight_class(ruby, name))
53
+ .unwrap_or_else(|| self.builtin_class(ruby))
54
+ }
55
+
56
+ /// Name of the `ZLight::*` class this error is documented to raise as, for
57
+ /// errors that have one.
58
+ fn zlight_class_name(&self) -> Option<&'static str> {
59
+ match self {
60
+ ZlightError::CsvParse(_) => Some("ParseError"),
61
+ ZlightError::InvalidHeaderEncoding(_) => Some("EncodingError"),
62
+ ZlightError::StreamClosed => Some("StreamClosedError"),
63
+ ZlightError::ReaderBusy => Some("Error"),
64
+ ZlightError::MissingArgument(_)
65
+ | ZlightError::InvalidOption { .. }
66
+ | ZlightError::Io(_) => None,
67
+ }
68
+ }
69
+
70
+ /// Built-in class to raise when there is no `ZLight::*` equivalent, and the
71
+ /// fallback if one cannot be resolved.
72
+ fn builtin_class(&self, ruby: &Ruby) -> ExceptionClass {
73
+ match self {
74
+ ZlightError::CsvParse(_) => ruby.exception_runtime_error(),
75
+ ZlightError::InvalidHeaderEncoding(_) => ruby.exception_encoding_error(),
76
+ ZlightError::MissingArgument(_) | ZlightError::InvalidOption { .. } => {
77
+ ruby.exception_arg_error()
78
+ }
79
+ ZlightError::Io(_) | ZlightError::StreamClosed => ruby.exception_io_error(),
80
+ ZlightError::ReaderBusy => ruby.exception_runtime_error(),
81
+ }
82
+ }
83
+ }
84
+
85
+ /// Looks up `ZLight::<name>`.
86
+ ///
87
+ /// The extension is loaded before lib/zlight_csv.rb defines these classes, so
88
+ /// the lookup happens when an error is raised rather than at init. It returns
89
+ /// `None` if the Ruby side was never loaded, leaving the caller to fall back to
90
+ /// a built-in class.
91
+ fn resolve_zlight_class(ruby: &Ruby, name: &'static str) -> Option<ExceptionClass> {
92
+ let zlight: RModule = ruby.class_object().const_get("ZLight").ok()?;
93
+ zlight.const_get(name).ok()
94
+ }
95
+
96
+ impl From<csv::Error> for ZlightError {
97
+ /// The csv crate wraps read failures in its own error type. A directory or
98
+ /// an unreadable file is an IO problem, not malformed CSV, so unwrap those
99
+ /// rather than reporting them as a parse failure.
100
+ fn from(err: csv::Error) -> Self {
101
+ if !err.is_io_error() {
102
+ return ZlightError::CsvParse(err);
103
+ }
104
+
105
+ match err.into_kind() {
106
+ csv::ErrorKind::Io(io) => ZlightError::Io(io),
107
+ kind => unreachable!("is_io_error() guarantees ErrorKind::Io, got {kind:?}"),
108
+ }
109
+ }
110
+ }
111
+
112
+ impl From<ZlightError> for MagnusError {
113
+ #[inline]
114
+ fn from(err: ZlightError) -> Self {
115
+ err.to_magnus_error()
116
+ }
117
+ }
118
+
119
+ pub type Result<T> = std::result::Result<T, ZlightError>;
120
+
121
+ /// Converts a `csv` crate error into a Ruby exception.
122
+ ///
123
+ /// `?` cannot bridge csv::Error to MagnusError on its own, because that would
124
+ /// need two chained `From` conversions.
125
+ #[inline]
126
+ pub fn csv_error(err: csv::Error) -> MagnusError {
127
+ ZlightError::from(err).into()
128
+ }
@@ -0,0 +1,57 @@
1
+ //! ZLight — a CSV parser and writer for Ruby, implemented in Rust.
2
+ //!
3
+ //! # Reading this codebase
4
+ //!
5
+ //! Every file has one job, and the layering is deliberate so that adding a
6
+ //! feature touches one place rather than several:
7
+ //!
8
+ //! | Module | Owns |
9
+ //! |---------------|--------------------------------------------------|
10
+ //! | `lib.rs` | what Ruby can call — the registration map, below |
11
+ //! | `options` | Ruby keyword arguments → typed settings |
12
+ //! | `convert` | one field → one Ruby value |
13
+ //! | `row` | one record → one Ruby row (Hash or Array) |
14
+ //! | `reader` | building the underlying `csv::Reader` |
15
+ //! | `read::eager` | `ZLight.parse`, whole input at once |
16
+ //! | `read::stream`| `ZLight::StreamReader`, one row at a time |
17
+ //! | `write` | `ZLight.generate` |
18
+ //! | `error` | Rust errors → Ruby exception classes |
19
+ //!
20
+ //! The two read paths sit on top of the same `reader`, `row` and `convert`,
21
+ //! which is what stops them drifting apart. If you are adding something:
22
+ //!
23
+ //! * a new field type (dates, booleans, a Ruby proc) → `convert.rs`
24
+ //! * a new result shape (a Row object, a struct) → `row.rs`
25
+ //! * a new option → `options.rs`
26
+ //! * a new Ruby method → a module under `read`/`write`, plus one line in
27
+ //! [`init`] so Ruby can see it
28
+ //!
29
+ //! See ARCHITECTURE.md for the same map written for someone who does not read
30
+ //! Rust.
31
+
32
+ mod convert;
33
+ mod error;
34
+ mod options;
35
+ mod read;
36
+ mod reader;
37
+ mod row;
38
+ mod write;
39
+
40
+ use magnus::{function, prelude::*, Error, Ruby};
41
+
42
+ /// Registers everything Ruby can call.
43
+ ///
44
+ /// This function is the complete list of the extension's public surface; if a
45
+ /// method is not named here, Ruby cannot reach it.
46
+ #[magnus::init]
47
+ fn init(ruby: &Ruby) -> Result<(), Error> {
48
+ let zlight = ruby.define_module("ZLight")?;
49
+
50
+ zlight.define_singleton_method("parse", function!(read::eager::parse, -1))?;
51
+ zlight.define_singleton_method("generate", function!(write::generate, -1))?;
52
+
53
+ // Defines ZLight::StreamReader along with .stream and .stream_file.
54
+ read::stream::init(ruby)?;
55
+
56
+ Ok(())
57
+ }
@@ -0,0 +1,143 @@
1
+ use magnus::{
2
+ prelude::*,
3
+ scan_args::{get_kwargs, scan_args},
4
+ RArray, RHash, RString, Ruby, Symbol, Value,
5
+ };
6
+
7
+ use crate::convert::{Converter, Step};
8
+ use crate::error::{Result, ZlightError};
9
+
10
+ #[derive(Debug, Clone)]
11
+ pub struct ParseOptions {
12
+ pub has_headers: bool,
13
+ /// How each field is turned into a Ruby value.
14
+ pub converter: Converter,
15
+ pub delimiter: u8,
16
+ pub quote: u8,
17
+ pub flexible: bool,
18
+ }
19
+
20
+ impl Default for ParseOptions {
21
+ #[inline]
22
+ fn default() -> Self {
23
+ Self {
24
+ has_headers: true,
25
+ converter: Converter::default(),
26
+ delimiter: b',',
27
+ quote: b'"',
28
+ flexible: true,
29
+ }
30
+ }
31
+ }
32
+
33
+ impl ParseOptions {
34
+ /// Scans a `(String, **options)` Ruby argument list.
35
+ ///
36
+ /// Every reader entry point takes the same shape, differing only in what
37
+ /// the leading string means. `argument` names it so that a missing
38
+ /// argument is reported as "path" or "csv_string" as appropriate.
39
+ pub fn scan(ruby: &Ruby, args: &[Value], argument: &'static str) -> Result<(RString, Self)> {
40
+ let parsed = scan_args::<(RString,), (), (), (), RHash, ()>(args)
41
+ .map_err(|_| ZlightError::MissingArgument(argument))?;
42
+
43
+ let (input,) = parsed.required;
44
+
45
+ Ok((input, Self::from_kwargs(ruby, parsed.keywords)?))
46
+ }
47
+
48
+ fn from_kwargs(ruby: &Ruby, kwargs: RHash) -> Result<Self> {
49
+ let kw = get_kwargs::<_, (), (Option<bool>, Option<Value>, Option<RString>, Option<RString>, Option<bool>), ()>(
50
+ kwargs,
51
+ &[],
52
+ &["headers", "converters", "col_sep", "quote_char", "flexible"],
53
+ )
54
+ .map_err(|e| ZlightError::InvalidOption {
55
+ key: "keywords",
56
+ expected: "valid keyword arguments",
57
+ actual: e.to_string(),
58
+ })?;
59
+
60
+ let (headers, converters, col_sep, quote_char, flexible) = kw.optional;
61
+
62
+ Ok(ParseOptions {
63
+ has_headers: headers.unwrap_or(true),
64
+ converter: converter_from(ruby, converters)?,
65
+ delimiter: single_byte(col_sep, "col_sep")?.unwrap_or(b','),
66
+ quote: single_byte(quote_char, "quote_char")?.unwrap_or(b'"'),
67
+ flexible: flexible.unwrap_or(true),
68
+ })
69
+ }
70
+ }
71
+
72
+ /// Validates that an optional separator/quote option is exactly one byte.
73
+ ///
74
+ /// The underlying `csv` crate only supports single-byte delimiters and quotes,
75
+ /// so a longer value would be truncated to its first byte and then silently
76
+ /// parse or write the wrong thing. Reject it explicitly instead.
77
+ pub fn single_byte(opt: Option<RString>, key: &'static str) -> Result<Option<u8>> {
78
+ let Some(string) = opt else { return Ok(None) };
79
+
80
+ // SAFETY: the slice is only read for its length and first byte before
81
+ // this function returns, with no Ruby allocation in between.
82
+ let bytes = unsafe { string.as_slice() };
83
+ match bytes.len() {
84
+ 1 => Ok(Some(bytes[0])),
85
+ n => Err(ZlightError::InvalidOption {
86
+ key,
87
+ expected: "a single-byte string",
88
+ actual: format!("{}-byte string", n),
89
+ }),
90
+ }
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
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -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