zlight_csv 0.4.0-aarch64-linux → 0.5.1-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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -12
- data/LICENSE +1 -1
- data/README.md +53 -4
- data/VERSION +1 -1
- data/ext/zlight_csv/Cargo.toml +30 -0
- data/ext/zlight_csv/extconf.rb +16 -0
- data/ext/zlight_csv/src/converter.rs +133 -0
- data/ext/zlight_csv/src/error.rs +122 -0
- data/ext/zlight_csv/src/lib.rs +55 -0
- data/ext/zlight_csv/src/options.rs +95 -0
- data/ext/zlight_csv/src/parser.rs +77 -0
- data/ext/zlight_csv/src/stream.rs +410 -0
- data/ext/zlight_csv/src/writer.rs +268 -0
- data/lib/zlight_csv/3.1/zlight_csv.so +0 -0
- data/lib/zlight_csv/3.2/zlight_csv.so +0 -0
- data/lib/zlight_csv/3.3/zlight_csv.so +0 -0
- data/lib/zlight_csv/3.4/zlight_csv.so +0 -0
- data/lib/zlight_csv/4.0/zlight_csv.so +0 -0
- data/lib/zlight_csv.rb +7 -6
- metadata +37 -10
- data/lib/zlight_csv/3.0/zlight_csv.so +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
use magnus::{
|
|
2
|
+
scan_args::{get_kwargs, scan_args},
|
|
3
|
+
RHash, RString, Ruby, Symbol, Value,
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
use crate::error::{Result, ZlightError};
|
|
7
|
+
|
|
8
|
+
/// Configuration options for CSV parsing.
|
|
9
|
+
#[derive(Debug, Clone, Copy)]
|
|
10
|
+
pub struct ParseOptions {
|
|
11
|
+
/// Whether the CSV has a header row.
|
|
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).
|
|
16
|
+
pub delimiter: u8,
|
|
17
|
+
/// Quote character (defaults to double quote).
|
|
18
|
+
pub quote: u8,
|
|
19
|
+
/// Whether to allow flexible record lengths.
|
|
20
|
+
pub flexible: bool,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl Default for ParseOptions {
|
|
24
|
+
#[inline]
|
|
25
|
+
fn default() -> Self {
|
|
26
|
+
Self {
|
|
27
|
+
has_headers: true,
|
|
28
|
+
convert_numeric: false,
|
|
29
|
+
delimiter: b',',
|
|
30
|
+
quote: b'"',
|
|
31
|
+
flexible: true,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
impl ParseOptions {
|
|
37
|
+
/// Scans a `(String, **options)` Ruby argument list.
|
|
38
|
+
///
|
|
39
|
+
/// Every reader entry point takes the same shape, differing only in what
|
|
40
|
+
/// the leading string means. `argument` names it so that a missing
|
|
41
|
+
/// argument is reported as "path" or "csv_string" as appropriate.
|
|
42
|
+
pub fn scan(ruby: &Ruby, args: &[Value], argument: &'static str) -> Result<(RString, Self)> {
|
|
43
|
+
let parsed = scan_args::<(RString,), (), (), (), RHash, ()>(args)
|
|
44
|
+
.map_err(|_| ZlightError::MissingArgument(argument))?;
|
|
45
|
+
|
|
46
|
+
let (input,) = parsed.required;
|
|
47
|
+
|
|
48
|
+
Ok((input, Self::from_kwargs(ruby, parsed.keywords)?))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Builds options from a keyword arguments hash, applying the defaults.
|
|
52
|
+
fn from_kwargs(ruby: &Ruby, kwargs: RHash) -> Result<Self> {
|
|
53
|
+
let kw = get_kwargs::<_, (), (Option<bool>, Option<Symbol>, Option<RString>, Option<RString>, Option<bool>), ()>(
|
|
54
|
+
kwargs,
|
|
55
|
+
&[],
|
|
56
|
+
&["headers", "converters", "col_sep", "quote_char", "flexible"],
|
|
57
|
+
)
|
|
58
|
+
.map_err(|e| ZlightError::InvalidOption {
|
|
59
|
+
key: "keywords",
|
|
60
|
+
expected: "valid keyword arguments",
|
|
61
|
+
actual: e.to_string(),
|
|
62
|
+
})?;
|
|
63
|
+
|
|
64
|
+
let (headers, converters, col_sep, quote_char, flexible) = kw.optional;
|
|
65
|
+
|
|
66
|
+
Ok(ParseOptions {
|
|
67
|
+
has_headers: headers.unwrap_or(true),
|
|
68
|
+
convert_numeric: converters.is_some_and(|sym| sym == ruby.to_symbol("numeric")),
|
|
69
|
+
delimiter: single_byte(col_sep, "col_sep")?.unwrap_or(b','),
|
|
70
|
+
quote: single_byte(quote_char, "quote_char")?.unwrap_or(b'"'),
|
|
71
|
+
flexible: flexible.unwrap_or(true),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Validates that an optional separator/quote option is exactly one byte.
|
|
77
|
+
///
|
|
78
|
+
/// The underlying `csv` crate only supports single-byte delimiters and quotes,
|
|
79
|
+
/// so a longer value would be truncated to its first byte and then silently
|
|
80
|
+
/// parse or write the wrong thing. Reject it explicitly instead.
|
|
81
|
+
pub fn single_byte(opt: Option<RString>, key: &'static str) -> Result<Option<u8>> {
|
|
82
|
+
let Some(string) = opt else { return Ok(None) };
|
|
83
|
+
|
|
84
|
+
// SAFETY: the slice is only read for its length and first byte before
|
|
85
|
+
// this function returns, with no Ruby allocation in between.
|
|
86
|
+
let bytes = unsafe { string.as_slice() };
|
|
87
|
+
match bytes.len() {
|
|
88
|
+
1 => Ok(Some(bytes[0])),
|
|
89
|
+
n => Err(ZlightError::InvalidOption {
|
|
90
|
+
key,
|
|
91
|
+
expected: "a single-byte string",
|
|
92
|
+
actual: format!("{}-byte string", n),
|
|
93
|
+
}),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
use csv::{ByteRecord, Reader, ReaderBuilder};
|
|
2
|
+
use magnus::{encoding::Index, Error as MagnusError, RArray, Ruby};
|
|
3
|
+
|
|
4
|
+
use crate::converter::field_to_value;
|
|
5
|
+
use crate::error::{csv_error, ZlightError};
|
|
6
|
+
use crate::options::ParseOptions;
|
|
7
|
+
|
|
8
|
+
/// Parses CSV data into an array of arrays (no headers mode).
|
|
9
|
+
pub fn parse_as_arrays(
|
|
10
|
+
ruby: &Ruby,
|
|
11
|
+
reader: &mut Reader<&[u8]>,
|
|
12
|
+
convert_numeric: bool,
|
|
13
|
+
encoding: Index,
|
|
14
|
+
) -> Result<RArray, MagnusError> {
|
|
15
|
+
let result = ruby.ary_new();
|
|
16
|
+
let mut record = ByteRecord::new();
|
|
17
|
+
|
|
18
|
+
while reader.read_byte_record(&mut record).map_err(csv_error)? {
|
|
19
|
+
let row = ruby.ary_new_capa(record.len());
|
|
20
|
+
|
|
21
|
+
for field in record.iter() {
|
|
22
|
+
row.push(field_to_value(ruby, field, convert_numeric, encoding))?;
|
|
23
|
+
}
|
|
24
|
+
result.push(row)?;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Ok(result)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Parses CSV data into an array of hashes (with headers mode).
|
|
31
|
+
pub fn parse_as_hashes(
|
|
32
|
+
ruby: &Ruby,
|
|
33
|
+
reader: &mut Reader<&[u8]>,
|
|
34
|
+
convert_numeric: bool,
|
|
35
|
+
encoding: Index,
|
|
36
|
+
) -> Result<RArray, MagnusError> {
|
|
37
|
+
// Pre-compute header symbols once
|
|
38
|
+
let byte_headers = reader.byte_headers().map_err(csv_error)?;
|
|
39
|
+
|
|
40
|
+
let headers: Vec<_> = byte_headers
|
|
41
|
+
.iter()
|
|
42
|
+
.map(|h| {
|
|
43
|
+
std::str::from_utf8(h).map(|s| ruby.sym_new(s)).map_err(|_| {
|
|
44
|
+
ZlightError::InvalidHeaderEncoding(String::from_utf8_lossy(h).to_string())
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
48
|
+
|
|
49
|
+
let result = ruby.ary_new();
|
|
50
|
+
let mut record = ByteRecord::new();
|
|
51
|
+
|
|
52
|
+
while reader.read_byte_record(&mut record).map_err(csv_error)? {
|
|
53
|
+
let hash = ruby.hash_new();
|
|
54
|
+
|
|
55
|
+
// Zip stops at the shorter side, so a row with more fields than there
|
|
56
|
+
// are headers drops the extras, matching Ruby CSV.
|
|
57
|
+
for (header, field) in headers.iter().zip(record.iter()) {
|
|
58
|
+
hash.aset(*header, field_to_value(ruby, field, convert_numeric, encoding))?;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
result.push(hash)?;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Ok(result)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Builds a CSV reader with the given options.
|
|
68
|
+
#[inline]
|
|
69
|
+
pub fn build_reader<'a>(data: &'a [u8], options: &ParseOptions) -> Reader<&'a [u8]> {
|
|
70
|
+
ReaderBuilder::new()
|
|
71
|
+
.has_headers(options.has_headers)
|
|
72
|
+
.delimiter(options.delimiter)
|
|
73
|
+
.quote(options.quote)
|
|
74
|
+
.flexible(options.flexible)
|
|
75
|
+
.buffer_capacity(64 * 1024) // 64KB buffer for better I/O
|
|
76
|
+
.from_reader(data)
|
|
77
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
//! Streaming CSV parser for lazy/incremental parsing.
|
|
2
|
+
//!
|
|
3
|
+
//! Provides `StreamReader` which allows reading CSV rows one at a time
|
|
4
|
+
//! without loading the entire file into memory.
|
|
5
|
+
|
|
6
|
+
use std::cell::RefCell;
|
|
7
|
+
use std::fs::File;
|
|
8
|
+
use std::io::{BufReader, Cursor, Read};
|
|
9
|
+
|
|
10
|
+
use csv::{ByteRecord, Reader, ReaderBuilder};
|
|
11
|
+
use magnus::{
|
|
12
|
+
encoding::Index, function, method,
|
|
13
|
+
prelude::*,
|
|
14
|
+
typed_data::Obj,
|
|
15
|
+
DataTypeFunctions, Error as MagnusError, RArray, RString, Ruby, StaticSymbol, TypedData, Value,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
use crate::converter::field_to_value;
|
|
19
|
+
use crate::error::{csv_error, ZlightError};
|
|
20
|
+
use crate::options::ParseOptions;
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Type Aliases
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
type BoxedReader = Reader<Box<dyn Read + Send>>;
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Reader State
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/// State of the stream reader.
|
|
33
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
34
|
+
enum ReaderState {
|
|
35
|
+
/// Reader has not been initialized yet (lazy init).
|
|
36
|
+
Uninitialized,
|
|
37
|
+
/// Reader is active and can read rows.
|
|
38
|
+
Active,
|
|
39
|
+
/// Reader has been exhausted (no more rows).
|
|
40
|
+
Exhausted,
|
|
41
|
+
/// Reader has been closed.
|
|
42
|
+
Closed,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// StreamReader
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/// Internal mutable state for the stream reader.
|
|
50
|
+
struct StreamReaderInner {
|
|
51
|
+
/// The CSV reader (lazily initialized).
|
|
52
|
+
reader: Option<BoxedReader>,
|
|
53
|
+
/// Pending data source before initialization.
|
|
54
|
+
pending_data: Option<Vec<u8>>,
|
|
55
|
+
/// Opened file awaiting initialization.
|
|
56
|
+
pending_file: Option<File>,
|
|
57
|
+
/// Pre-computed header symbols.
|
|
58
|
+
headers: Option<Vec<StaticSymbol>>,
|
|
59
|
+
/// Reusable record buffer.
|
|
60
|
+
record: ByteRecord,
|
|
61
|
+
/// Current state.
|
|
62
|
+
state: ReaderState,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// A streaming CSV reader that yields rows one at a time.
|
|
66
|
+
///
|
|
67
|
+
/// Exposed to Ruby as `ZLight::StreamReader`. Implements Ruby's Enumerable
|
|
68
|
+
/// interface for seamless integration with Ruby iteration patterns.
|
|
69
|
+
///
|
|
70
|
+
/// # Example (Ruby)
|
|
71
|
+
///
|
|
72
|
+
/// ```ruby
|
|
73
|
+
/// reader = ZLight.stream_file("large_file.csv")
|
|
74
|
+
/// reader.each do |row|
|
|
75
|
+
/// puts row[:name]
|
|
76
|
+
/// end
|
|
77
|
+
/// reader.close
|
|
78
|
+
/// ```
|
|
79
|
+
#[derive(TypedData)]
|
|
80
|
+
#[magnus(class = "ZLight::StreamReader", free_immediately)]
|
|
81
|
+
pub struct StreamReader {
|
|
82
|
+
/// Mutable internal state wrapped in RefCell for interior mutability.
|
|
83
|
+
inner: RefCell<StreamReaderInner>,
|
|
84
|
+
/// Parsing options (immutable after creation).
|
|
85
|
+
options: ParseOptions,
|
|
86
|
+
/// Encoding used to tag parsed field strings.
|
|
87
|
+
encoding: Index,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// SAFETY: magnus requires `TypedData: Send`, but this struct holds a RefCell
|
|
91
|
+
// and Ruby values (StaticSymbol), neither of which is Send on its own.
|
|
92
|
+
//
|
|
93
|
+
// It is sound because a StreamReader is only ever reached through the Ruby
|
|
94
|
+
// methods defined at the bottom of this file, and those run holding the GVL,
|
|
95
|
+
// so no two threads touch one concurrently. Nothing here may be moved to a
|
|
96
|
+
// Ractor or handed to a Rust thread.
|
|
97
|
+
unsafe impl Send for StreamReader {}
|
|
98
|
+
|
|
99
|
+
// The only Ruby values held are StaticSymbols, which Ruby never collects,
|
|
100
|
+
// so there is nothing for the GC to mark. Add a `mark` implementation (and
|
|
101
|
+
// the `mark` attribute above) if a collectable value is ever stored here.
|
|
102
|
+
impl DataTypeFunctions for StreamReader {}
|
|
103
|
+
|
|
104
|
+
impl StreamReader {
|
|
105
|
+
/// Creates a new StreamReader from in-memory data.
|
|
106
|
+
pub fn from_string(data: Vec<u8>, options: ParseOptions, encoding: Index) -> Self {
|
|
107
|
+
Self {
|
|
108
|
+
inner: RefCell::new(StreamReaderInner {
|
|
109
|
+
reader: None,
|
|
110
|
+
pending_data: Some(data),
|
|
111
|
+
pending_file: None,
|
|
112
|
+
headers: None,
|
|
113
|
+
record: ByteRecord::new(),
|
|
114
|
+
state: ReaderState::Uninitialized,
|
|
115
|
+
}),
|
|
116
|
+
options,
|
|
117
|
+
encoding,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Creates a new StreamReader from an already-opened file.
|
|
122
|
+
pub fn from_file(file: File, options: ParseOptions, encoding: Index) -> Self {
|
|
123
|
+
Self {
|
|
124
|
+
inner: RefCell::new(StreamReaderInner {
|
|
125
|
+
reader: None,
|
|
126
|
+
pending_data: None,
|
|
127
|
+
pending_file: Some(file),
|
|
128
|
+
headers: None,
|
|
129
|
+
record: ByteRecord::new(),
|
|
130
|
+
state: ReaderState::Uninitialized,
|
|
131
|
+
}),
|
|
132
|
+
options,
|
|
133
|
+
encoding,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Ensures the CSV reader is initialized.
|
|
138
|
+
fn ensure_initialized(&self, ruby: &Ruby) -> Result<(), ZlightError> {
|
|
139
|
+
let mut inner = self.inner.borrow_mut();
|
|
140
|
+
|
|
141
|
+
if inner.state != ReaderState::Uninitialized {
|
|
142
|
+
return Ok(());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Create the reader from pending data or file
|
|
146
|
+
let boxed_reader: Box<dyn Read + Send> = if let Some(data) = inner.pending_data.take() {
|
|
147
|
+
Box::new(Cursor::new(data))
|
|
148
|
+
} else if let Some(file) = inner.pending_file.take() {
|
|
149
|
+
Box::new(BufReader::with_capacity(64 * 1024, file))
|
|
150
|
+
} else {
|
|
151
|
+
return Err(ZlightError::StreamClosed);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
let mut csv_reader = ReaderBuilder::new()
|
|
155
|
+
.has_headers(self.options.has_headers)
|
|
156
|
+
.delimiter(self.options.delimiter)
|
|
157
|
+
.quote(self.options.quote)
|
|
158
|
+
.flexible(self.options.flexible)
|
|
159
|
+
.buffer_capacity(64 * 1024)
|
|
160
|
+
.from_reader(boxed_reader);
|
|
161
|
+
|
|
162
|
+
// Extract headers if enabled
|
|
163
|
+
if self.options.has_headers {
|
|
164
|
+
let byte_headers = csv_reader.byte_headers()?;
|
|
165
|
+
let header_symbols: Vec<StaticSymbol> = byte_headers
|
|
166
|
+
.iter()
|
|
167
|
+
.map(|h| {
|
|
168
|
+
std::str::from_utf8(h)
|
|
169
|
+
.map(|name| ruby.sym_new(name))
|
|
170
|
+
.map_err(|_| {
|
|
171
|
+
ZlightError::InvalidHeaderEncoding(
|
|
172
|
+
String::from_utf8_lossy(h).to_string(),
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
177
|
+
inner.headers = Some(header_symbols);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
inner.reader = Some(csv_reader);
|
|
181
|
+
inner.state = ReaderState::Active;
|
|
182
|
+
|
|
183
|
+
Ok(())
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Reads the next row from the CSV.
|
|
187
|
+
///
|
|
188
|
+
/// Returns `Ok(Some(row))` for each row, `Ok(None)` when exhausted.
|
|
189
|
+
pub fn next_row(&self, ruby: &Ruby) -> Result<Option<Value>, MagnusError> {
|
|
190
|
+
self.ensure_initialized(ruby)?;
|
|
191
|
+
|
|
192
|
+
// Read into the reusable buffer, releasing the mutable borrow before
|
|
193
|
+
// any Ruby object is allocated below.
|
|
194
|
+
{
|
|
195
|
+
let mut guard = self.inner.borrow_mut();
|
|
196
|
+
// Reborrow so the reader and the record buffer can be borrowed as
|
|
197
|
+
// the separate fields they are.
|
|
198
|
+
let inner = &mut *guard;
|
|
199
|
+
|
|
200
|
+
match inner.state {
|
|
201
|
+
ReaderState::Closed => return Err(ZlightError::StreamClosed.into()),
|
|
202
|
+
ReaderState::Exhausted => return Ok(None),
|
|
203
|
+
ReaderState::Uninitialized => {
|
|
204
|
+
unreachable!("ensure_initialized leaves no Uninitialized state")
|
|
205
|
+
}
|
|
206
|
+
ReaderState::Active => {}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let reader = inner.reader.as_mut().ok_or(ZlightError::StreamClosed)?;
|
|
210
|
+
|
|
211
|
+
if !reader.read_byte_record(&mut inner.record).map_err(csv_error)? {
|
|
212
|
+
inner.state = ReaderState::Exhausted;
|
|
213
|
+
return Ok(None);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Now build the row value with a fresh borrow
|
|
218
|
+
let inner = self.inner.borrow();
|
|
219
|
+
let convert = self.options.convert_numeric;
|
|
220
|
+
|
|
221
|
+
let row = if self.options.has_headers {
|
|
222
|
+
let headers = inner.headers.as_ref().ok_or(ZlightError::StreamClosed)?;
|
|
223
|
+
let hash = ruby.hash_new();
|
|
224
|
+
|
|
225
|
+
// Zip stops at the shorter side, so a row with more fields than
|
|
226
|
+
// there are headers drops the extras, matching Ruby CSV.
|
|
227
|
+
for (header, field) in headers.iter().zip(inner.record.iter()) {
|
|
228
|
+
hash.aset(*header, field_to_value(ruby, field, convert, self.encoding))?;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
hash.as_value()
|
|
232
|
+
} else {
|
|
233
|
+
let arr = ruby.ary_new_capa(inner.record.len());
|
|
234
|
+
|
|
235
|
+
for field in inner.record.iter() {
|
|
236
|
+
arr.push(field_to_value(ruby, field, convert, self.encoding))?;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
arr.as_value()
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
Ok(Some(row))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// Returns the headers as a Ruby array of symbols.
|
|
246
|
+
pub fn headers(&self, ruby: &Ruby) -> Result<Option<RArray>, MagnusError> {
|
|
247
|
+
self.ensure_initialized(ruby)?;
|
|
248
|
+
|
|
249
|
+
let inner = self.inner.borrow();
|
|
250
|
+
|
|
251
|
+
match &inner.headers {
|
|
252
|
+
Some(headers) => {
|
|
253
|
+
let arr = ruby.ary_new_capa(headers.len());
|
|
254
|
+
for sym in headers {
|
|
255
|
+
arr.push(*sym)?;
|
|
256
|
+
}
|
|
257
|
+
Ok(Some(arr))
|
|
258
|
+
}
|
|
259
|
+
None => Ok(None),
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Closes the reader and releases all resources.
|
|
264
|
+
pub fn close(&self) {
|
|
265
|
+
let mut inner = self.inner.borrow_mut();
|
|
266
|
+
inner.reader = None;
|
|
267
|
+
inner.headers = None;
|
|
268
|
+
inner.pending_data = None;
|
|
269
|
+
// Dropping the file closes its descriptor.
|
|
270
|
+
inner.pending_file = None;
|
|
271
|
+
inner.state = ReaderState::Closed;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Returns whether the reader has been closed.
|
|
275
|
+
pub fn is_closed(&self) -> bool {
|
|
276
|
+
self.inner.borrow().state == ReaderState::Closed
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// Returns whether there are more rows to read.
|
|
280
|
+
pub fn is_exhausted(&self) -> bool {
|
|
281
|
+
let state = self.inner.borrow().state;
|
|
282
|
+
matches!(state, ReaderState::Exhausted | ReaderState::Closed)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ============================================================================
|
|
287
|
+
// Ruby Method Wrappers
|
|
288
|
+
// ============================================================================
|
|
289
|
+
|
|
290
|
+
/// Ruby: ZLight.stream(csv_string, **options) -> StreamReader
|
|
291
|
+
fn rb_stream_string(ruby: &Ruby, args: &[Value]) -> Result<Obj<StreamReader>, MagnusError> {
|
|
292
|
+
let (input, options) = ParseOptions::scan(ruby, args, "csv_string")?;
|
|
293
|
+
let encoding = input.enc_get();
|
|
294
|
+
// SAFETY: copied immediately, before anything can allocate. The stream
|
|
295
|
+
// outlives this call, so it owns its bytes rather than borrowing Ruby's.
|
|
296
|
+
let bytes = unsafe { input.as_slice().to_vec() };
|
|
297
|
+
|
|
298
|
+
let reader = StreamReader::from_string(bytes, options, encoding);
|
|
299
|
+
Ok(ruby.obj_wrap(reader))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// Ruby: ZLight.stream_file(path, **options) -> StreamReader
|
|
303
|
+
fn rb_stream_file(ruby: &Ruby, args: &[Value]) -> Result<Obj<StreamReader>, MagnusError> {
|
|
304
|
+
let (path_arg, options) = ParseOptions::scan(ruby, args, "path")?;
|
|
305
|
+
let path_str = path_to_string(path_arg)?;
|
|
306
|
+
|
|
307
|
+
// Open now rather than on the first read. Checking for existence and then
|
|
308
|
+
// opening later left a window for the file to change in between, and it
|
|
309
|
+
// reported a missing file from stream_file while an unreadable one only
|
|
310
|
+
// surfaced rows later. Opening here reports both from the same place, and
|
|
311
|
+
// the message comes from the OS instead of being reconstructed.
|
|
312
|
+
let file = File::open(&path_str).map_err(|e| {
|
|
313
|
+
ZlightError::Io(std::io::Error::new(e.kind(), format!("{} - {}", e, path_str)))
|
|
314
|
+
})?;
|
|
315
|
+
|
|
316
|
+
// File streams have no source Ruby string; default to UTF-8 to match
|
|
317
|
+
// `ZLight.read`, which reads files as UTF-8.
|
|
318
|
+
let reader = StreamReader::from_file(file, options, ruby.utf8_encindex());
|
|
319
|
+
Ok(ruby.obj_wrap(reader))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/// Converts the path argument to an owned Rust string.
|
|
323
|
+
fn path_to_string(path: RString) -> Result<String, ZlightError> {
|
|
324
|
+
// SAFETY: the slice is copied into an owned String before returning, with
|
|
325
|
+
// no Ruby allocation in between.
|
|
326
|
+
let bytes = unsafe { path.as_slice() };
|
|
327
|
+
std::str::from_utf8(bytes)
|
|
328
|
+
.map(str::to_owned)
|
|
329
|
+
.map_err(|_| ZlightError::InvalidOption {
|
|
330
|
+
key: "path",
|
|
331
|
+
expected: "valid UTF-8 string",
|
|
332
|
+
actual: "invalid encoding".to_string(),
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/// Ruby: reader.next_row -> Hash/Array or nil
|
|
337
|
+
fn rb_next_row(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Result<Value, MagnusError> {
|
|
338
|
+
Ok(rb_self.next_row(ruby)?.unwrap_or_else(|| ruby.qnil().as_value()))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/// Ruby: reader.each { |row| ... } -> self
|
|
342
|
+
/// Ruby: reader.each -> Enumerator
|
|
343
|
+
fn rb_each(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Result<Value, MagnusError> {
|
|
344
|
+
if !ruby.block_given() {
|
|
345
|
+
// Return Enumerator for lazy iteration
|
|
346
|
+
return rb_self.funcall("to_enum", ("each",));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let block = ruby.block_proc()?;
|
|
350
|
+
|
|
351
|
+
while let Some(row) = rb_self.next_row(ruby)? {
|
|
352
|
+
block.call::<_, Value>((row,))?;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
Ok(rb_self.as_value())
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// Ruby: reader.headers -> Array<Symbol> or nil
|
|
359
|
+
fn rb_headers(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Result<Value, MagnusError> {
|
|
360
|
+
Ok(rb_self
|
|
361
|
+
.headers(ruby)?
|
|
362
|
+
.map_or_else(|| ruby.qnil().as_value(), |arr| arr.as_value()))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/// Ruby: reader.close -> nil
|
|
366
|
+
fn rb_close(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Value {
|
|
367
|
+
rb_self.close();
|
|
368
|
+
ruby.qnil().as_value()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// Ruby: reader.closed? -> true/false
|
|
372
|
+
fn rb_is_closed(rb_self: Obj<StreamReader>) -> bool {
|
|
373
|
+
rb_self.is_closed()
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/// Ruby: reader.eof? -> true/false
|
|
377
|
+
fn rb_is_eof(rb_self: Obj<StreamReader>) -> bool {
|
|
378
|
+
rb_self.is_exhausted()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// Module Initialization
|
|
383
|
+
// ============================================================================
|
|
384
|
+
|
|
385
|
+
/// Initializes the StreamReader class and streaming methods.
|
|
386
|
+
pub fn init(ruby: &Ruby) -> Result<(), MagnusError> {
|
|
387
|
+
// Get the ZLight module
|
|
388
|
+
let zlight = ruby.define_module("ZLight")?;
|
|
389
|
+
|
|
390
|
+
// Define StreamReader class
|
|
391
|
+
let stream_reader_class = zlight.define_class("StreamReader", ruby.class_object())?;
|
|
392
|
+
|
|
393
|
+
// Include Enumerable for map, select, reduce, etc.
|
|
394
|
+
let enumerable: magnus::RModule = ruby.class_object().funcall("const_get", ("Enumerable",))?;
|
|
395
|
+
stream_reader_class.include_module(enumerable)?;
|
|
396
|
+
|
|
397
|
+
// Instance methods
|
|
398
|
+
stream_reader_class.define_method("next_row", method!(rb_next_row, 0))?;
|
|
399
|
+
stream_reader_class.define_method("each", method!(rb_each, 0))?;
|
|
400
|
+
stream_reader_class.define_method("headers", method!(rb_headers, 0))?;
|
|
401
|
+
stream_reader_class.define_method("close", method!(rb_close, 0))?;
|
|
402
|
+
stream_reader_class.define_method("closed?", method!(rb_is_closed, 0))?;
|
|
403
|
+
stream_reader_class.define_method("eof?", method!(rb_is_eof, 0))?;
|
|
404
|
+
|
|
405
|
+
// Module-level factory methods
|
|
406
|
+
zlight.define_singleton_method("stream", function!(rb_stream_string, -1))?;
|
|
407
|
+
zlight.define_singleton_method("stream_file", function!(rb_stream_file, -1))?;
|
|
408
|
+
|
|
409
|
+
Ok(())
|
|
410
|
+
}
|