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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be8e072a58943ca8b89b8f68c0943ad9daf80be6edb6274fad3f8fe7a20af34a
4
- data.tar.gz: 5bf977f61545af25293a5e2b3560ee5e06df896b373a4771252d9a9dc482451a
3
+ metadata.gz: 7f7e500659c606d547762f3eb7a39b074686e6e68e8a9318ec97aa6af50e5905
4
+ data.tar.gz: 1b0283e7036596faa5d148edec0edbe700fd90d20f9c75e65e287fe3686fd538
5
5
  SHA512:
6
- metadata.gz: fb006274ac24756d4336d27647343b64b6c753f6b6a5d4c35357b354558d379f8548dbef8825092c59ec9d8c4eb2431561177decb506816d22553f8d5a3eb10a
7
- data.tar.gz: 88fff6de78e745e818cf22a3e92f3772550824e2453d93bc7e864a59d03a7131dafe1a4ccb7116086b30161559b65ce0f1696f236c0d339078dfa47acbd222c6
6
+ metadata.gz: e0a54efcc8e8f478fd8d6c95de46e16e903c506afe93aa2384b91f53a85fb3ebdb00d359963aa2ab26af1c9cc771122e9506453ab80b440213d97404bd6ad58f
7
+ data.tar.gz: a6017c7cb1e64c66279a7280c41dc57719559b455d9319e3497808edae213c1cbf5c3c99022984060ac0616561d64f2cb11b993c3b4eaca7e7beee45918d2223
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,132 @@
1
+ # Architecture
2
+
3
+ This is a guide for changing the code, written on the assumption that you know
4
+ Ruby or Python and may not know Rust. You do not need to read Rust fluently to
5
+ find your way around — the layout is designed so that a given change lives in
6
+ one predictable place.
7
+
8
+ ## The short version
9
+
10
+ ZLight is a **Ruby gem with its hot loop written in Rust**. Ruby handles the
11
+ public API and file conveniences; Rust does the parsing and writing.
12
+
13
+ ```
14
+ your code
15
+
16
+
17
+ lib/zlight_csv.rb Ruby: the public API
18
+ │ read / write / foreach / open are plain Ruby
19
+ │ parse / generate / stream / stream_file are not —
20
+ ▼ they are implemented in Rust
21
+ ext/zlight_csv/src/ Rust: the parser, the writer, the streaming reader
22
+
23
+
24
+ the `csv` crate the actual CSV state machine
25
+ ```
26
+
27
+ The `magnus` crate is the bridge: it is what lets Rust define a module Ruby can
28
+ call and build Ruby objects (`Hash`, `Array`, `String`) from Rust data.
29
+
30
+ ## Which file does what
31
+
32
+ ### Ruby side — `lib/`
33
+
34
+ | File | Owns |
35
+ |---|---|
36
+ | `zlight_csv.rb` | the public API and its documentation |
37
+ | `zlight_csv/native.rb` | loading the compiled library |
38
+ | `zlight_csv/errors.rb` | the exception hierarchy |
39
+ | `zlight_csv/version.rb` | the version, read from the `VERSION` file |
40
+
41
+ `native.rb` is worth understanding once. A compiled extension is built against
42
+ one specific Ruby version's internals, so a precompiled gem ships **one library
43
+ per Ruby minor version**:
44
+
45
+ ```
46
+ lib/zlight_csv/3.1/zlight_csv.so
47
+ lib/zlight_csv/3.4/zlight_csv.so
48
+ ```
49
+
50
+ `native.rb` picks the right one at load time. A gem built from source has a
51
+ single library instead, which is the fallback path.
52
+
53
+ ### Rust side — `ext/zlight_csv/src/`
54
+
55
+ | File | Owns | Analogy |
56
+ |---|---|---|
57
+ | `lib.rs` | the list of what Ruby can call | a routes file |
58
+ | `options.rs` | keyword arguments → typed settings | parsing params |
59
+ | `convert.rs` | one field → one Ruby value | a type coercer |
60
+ | `row.rs` | one record → one Ruby row | a serializer |
61
+ | `reader.rs` | building the CSV reader | a factory |
62
+ | `read/eager.rs` | `ZLight.parse` | |
63
+ | `read/stream.rs` | `ZLight::StreamReader` | |
64
+ | `write/mod.rs` | `ZLight.generate` | |
65
+ | `error.rs` | Rust errors → Ruby exceptions | an error handler |
66
+
67
+ The important structural point: **both read paths sit on the same three
68
+ helpers.** `read/eager.rs` and `read/stream.rs` differ only in *when* they read
69
+ — all at once, or a row at a time — and both build their reader with
70
+ `reader.rs`, convert fields with `convert.rs`, and shape rows with `row.rs`.
71
+ That is what stops the two from drifting apart.
72
+
73
+ ## Where to put a change
74
+
75
+ | You want to… | Change | Why there |
76
+ |---|---|---|
77
+ | add a field type (dates, booleans, a Ruby proc) | `convert.rs` + one arm in `options.rs` | `Converter` is the only thing that turns bytes into values |
78
+ | change what a row looks like (a Row object, a struct) | `row.rs` | `RowShape` is the only thing that builds rows |
79
+ | add a parse or write option | `options.rs` | both readers and the writer read their settings from here |
80
+ | add a whole new Ruby method | a file under `read/` or `write/`, plus one line in `lib.rs` | `lib.rs` is the complete list of the public surface |
81
+ | change an error's Ruby class | `error.rs` | one table maps every Rust error to a Ruby class |
82
+
83
+ If a change seems to require editing two files that do the same kind of work,
84
+ that is a sign the seam is in the wrong place — say so rather than copying the
85
+ code.
86
+
87
+ ## Two ideas that are easy to miss
88
+
89
+ **Conversion is a value, not a flag.** `Converter` is an enum — think of it as
90
+ a small sealed class hierarchy. It used to be a boolean (`convert_numeric`)
91
+ threaded through every function, which meant a second converter could not be
92
+ added without changing every signature it passed through. Now adding one is a
93
+ new variant plus an arm in `options.rs`.
94
+
95
+ **Row shape is a value too.** `RowShape` is either `Hashes(headers)` or
96
+ `Arrays`. Deciding once, up front, means the per-row code does not re-ask "do
97
+ we have headers?" for every row, and there is exactly one place that knows how
98
+ a row is built.
99
+
100
+ ## Working on it
101
+
102
+ You do not need Ruby or Rust installed on your machine — everything runs in
103
+ containers:
104
+
105
+ ```bash
106
+ ./docker/test-matrix.sh # every supported Ruby, 3.1 through 4.0
107
+ ./docker/test-matrix.sh 3.4 # just one
108
+ ```
109
+
110
+ With a local Ruby (3.1–4.0) and a Rust toolchain:
111
+
112
+ ```bash
113
+ bundle install
114
+ bundle exec rake compile # build the extension
115
+ bundle exec rake spec # run the suite
116
+ bundle exec rake clobber # remove every build artifact, cargo cache included
117
+ ```
118
+
119
+ `rake compile` must be re-run after any change under `ext/`. Ruby-only changes
120
+ take effect immediately.
121
+
122
+ ## The rule the tests enforce
123
+
124
+ The RSpec suite is the contract. A refactor that changes behaviour will fail
125
+ it; a refactor that does not, will not. If you find yourself editing a spec to
126
+ make a refactor pass, you are changing behaviour — stop and decide whether you
127
+ meant to.
128
+
129
+ Several examples deliberately pin places where ZLight **differs** from Ruby's
130
+ stdlib CSV (duplicate headers, empty fields, numeric edge cases). They assert
131
+ both sides on purpose, so the documented differences and the code cannot drift
132
+ apart. See the "Differences from stdlib CSV" section of the README.
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-12
11
+
12
+ ### Added
13
+
14
+ - Custom converters. `converters:` now accepts any object answering `call`, a
15
+ built-in name, or an Array mixing the two, applied left to right:
16
+
17
+ ```ruby
18
+ ZLight.parse(csv, converters: ->(field) { field.strip })
19
+ ZLight.parse(csv, converters: [->(f) { f.strip }, :numeric])
20
+ ```
21
+
22
+ Semantics match Ruby's CSV, including that the chain stops as soon as a
23
+ converter returns something other than a String, and that converters are
24
+ never applied to the header row. An unusable converter raises ArgumentError
25
+ naming the problem, where Ruby's CSV raises NoMethodError from inside itself.
26
+
27
+ A converter must not read from or close the reader it is converting for, and
28
+ a reader running a converter must not be used from another thread. Either
29
+ raises `ZLight::Error` rather than corrupting a row. `ZLight.parse` is safe
30
+ from any thread, with or without a converter; Ractors are refused by Ruby.
31
+
32
+ ### Changed
33
+
34
+ - The extension is organised around three seams — field conversion, row shape
35
+ and reader construction — each owned by one file and shared by both read
36
+ paths, so a feature is added in one place rather than two. See
37
+ ARCHITECTURE.md
38
+ - Removed comments that restated the code they sat above, keeping those that
39
+ explain a decision, an invariant, or where new code belongs
40
+
41
+ ### Fixed
42
+
43
+ - The documentation for `parse`, `generate` and `StreamReader` was attached to
44
+ no method, so YARD rendered none of it
45
+
10
46
  ## [0.5.1] - 2026-09-12
11
47
 
12
48
  ### Fixed
@@ -118,7 +154,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
118
154
  - Substantially faster than Ruby's stdlib CSV; see the README for measured
119
155
  figures
120
156
 
121
- [Unreleased]: https://github.com/codebyisaad/zlight/compare/v0.5.1...HEAD
157
+ [Unreleased]: https://github.com/codebyisaad/zlight/compare/v0.6.0...HEAD
158
+ [0.6.0]: https://github.com/codebyisaad/zlight/compare/v0.5.1...v0.6.0
122
159
  [0.5.1]: https://github.com/codebyisaad/zlight/compare/v0.5.0...v0.5.1
123
160
  [0.5.0]: https://github.com/codebyisaad/zlight/compare/v0.4.0...v0.5.0
124
161
  [0.4.0]: https://github.com/codebyisaad/zlight/compare/v0.3.0...v0.4.0
data/README.md CHANGED
@@ -103,12 +103,64 @@ ZLight.parse (full): 73ms
103
103
  ZLight.stream (lazy): 0.3ms ← 5,600x faster!
104
104
  ```
105
105
 
106
+ ### Custom Converters
107
+
108
+ `converters:` takes a built-in name, any object answering `call`, or an Array
109
+ of them applied left to right:
110
+
111
+ ```ruby
112
+ # A single callable, given each field as a String
113
+ ZLight.parse(csv, converters: ->(field) { field.strip })
114
+
115
+ # Chained: trim, then recognise numbers
116
+ ZLight.parse(csv, converters: [->(f) { f.strip }, :numeric])
117
+
118
+ # Anything answering #call works
119
+ ZLight.parse(csv, converters: MyConverter.new)
120
+ ```
121
+
122
+ Two rules, both matching Ruby's CSV:
123
+
124
+ - **The chain stops** as soon as a converter returns something other than a
125
+ String, so `[:numeric, other]` never calls `other` for a field that was
126
+ converted to a number.
127
+ - **Converters never apply to the header row.** Headers are always symbols.
128
+
129
+ An exception raised inside a converter propagates to the caller. A converter
130
+ that is neither a known name nor callable raises `ArgumentError` naming the
131
+ problem.
132
+
133
+ A converter must not use the reader it is converting for — calling `next_row`,
134
+ `headers` or `close` on it raises `ZLight::Error`. Any other reader, and
135
+ `ZLight.parse` itself, are fine:
136
+
137
+ ```ruby
138
+ # Raises ZLight::Error
139
+ reader = ZLight.stream(csv, converters: ->(f) { reader.next_row })
140
+
141
+ # Fine
142
+ ZLight.parse(csv, converters: ->(f) { ZLight.parse(lookup_csv).first[:name] })
143
+ ```
144
+
145
+ ### Threads and Ractors
146
+
147
+ | | |
148
+ |---|---|
149
+ | `ZLight.parse`, `.generate`, `.read`, `.write` | safe from any thread |
150
+ | A `StreamReader` shared between threads | safe while no converter is running |
151
+ | A `StreamReader` shared between threads, with a converter | raises `ZLight::Error` |
152
+ | Ractors | not supported; Ruby refuses to cross the boundary |
153
+
154
+ A converter runs Ruby code, which lets another thread take the GVL mid-row. The
155
+ reader refuses that rather than returning a corrupted row. **Give each thread
156
+ its own reader** and the question does not arise.
157
+
106
158
  ### Options (Reading)
107
159
 
108
160
  | Option | Default | Description |
109
161
  |--------|---------|-------------|
110
162
  | `headers` | `true` | Use first row as headers (returns hashes). Set `false` for arrays. |
111
- | `converters` | `nil` | Set to `:numeric` to convert numbers automatically |
163
+ | `converters` | `nil` | `:numeric`, any object answering `call`, or an Array of them |
112
164
  | `col_sep` | `","` | Column separator (`"\t"` for TSV, `";"` for European CSV) |
113
165
  | `quote_char` | `"` | Quote character |
114
166
  | `flexible` | `true` | Allow rows with varying column counts |
@@ -210,7 +262,8 @@ precompiled binary install from the source gem and need a Rust toolchain.
210
262
 
211
263
  - [x] Streaming/lazy parsing for large files
212
264
  - [x] CSV writing support
213
- - [ ] Custom converter procs
265
+ - [x] Custom converter procs
266
+ - [ ] Streaming writer for large output
214
267
 
215
268
  ## Contributing
216
269
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.1
1
+ 0.6.0
@@ -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
+ }
@@ -1,7 +1,6 @@
1
1
  use magnus::{prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
2
2
  use thiserror::Error;
3
3
 
4
- /// Enumerated error types for ZlightCsv operations.
5
4
  #[derive(Debug, Error)]
6
5
  pub enum ZlightError {
7
6
  #[error("CSV parsing error: {0}")]
@@ -25,11 +24,16 @@ pub enum ZlightError {
25
24
 
26
25
  #[error("Stream reader has been closed")]
27
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,
28
34
  }
29
35
 
30
36
  impl ZlightError {
31
- /// Converts to a Ruby exception with the appropriate exception class.
32
- ///
33
37
  /// Requires the Ruby handle, which is always available here: an error is
34
38
  /// only converted on its way out of an extension method, and those run on
35
39
  /// a Ruby thread holding the GVL. Building an exception needs a class, and
@@ -56,6 +60,7 @@ impl ZlightError {
56
60
  ZlightError::CsvParse(_) => Some("ParseError"),
57
61
  ZlightError::InvalidHeaderEncoding(_) => Some("EncodingError"),
58
62
  ZlightError::StreamClosed => Some("StreamClosedError"),
63
+ ZlightError::ReaderBusy => Some("Error"),
59
64
  ZlightError::MissingArgument(_)
60
65
  | ZlightError::InvalidOption { .. }
61
66
  | ZlightError::Io(_) => None,
@@ -72,6 +77,7 @@ impl ZlightError {
72
77
  ruby.exception_arg_error()
73
78
  }
74
79
  ZlightError::Io(_) | ZlightError::StreamClosed => ruby.exception_io_error(),
80
+ ZlightError::ReaderBusy => ruby.exception_runtime_error(),
75
81
  }
76
82
  }
77
83
  }
@@ -1,55 +1,57 @@
1
- mod converter;
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;
2
33
  mod error;
3
34
  mod options;
4
- mod parser;
5
- mod stream;
6
- mod writer;
35
+ mod read;
36
+ mod reader;
37
+ mod row;
38
+ mod write;
7
39
 
8
- use magnus::{function, prelude::*, Error, RArray, Ruby, Value};
40
+ use magnus::{function, prelude::*, Error, Ruby};
9
41
 
10
- use options::ParseOptions;
11
- use parser::{build_reader, parse_as_arrays, parse_as_hashes};
12
-
13
- fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
14
- let (input, options) = ParseOptions::scan(ruby, args, "csv_string")?;
15
- // Capture the input's encoding so parsed fields are tagged consistently
16
- // (matching Ruby's stdlib CSV) rather than defaulting to BINARY.
17
- let encoding = input.enc_get();
18
-
19
- // SAFETY: `as_slice` borrows Ruby's own buffer, so Ruby must neither free
20
- // nor move the string while `bytes` is live. It cannot be freed: `input`
21
- // came from the argument list and stays reachable for the whole call.
22
- // Parsing it without copying is the point of this extension; a copy here
23
- // would double peak memory on large inputs.
24
- //
25
- // `input` is deliberately kept alive past the parse below, rather than
26
- // ending its borrow at this line, so that Ruby's conservative stack scan
27
- // still sees the string while the parser holds a pointer into it.
28
- let bytes = unsafe { input.as_slice() };
29
-
30
- let mut reader = build_reader(bytes, &options);
31
-
32
- let result = if options.has_headers {
33
- parse_as_hashes(ruby, &mut reader, options.convert_numeric, encoding)
34
- } else {
35
- parse_as_arrays(ruby, &mut reader, options.convert_numeric, encoding)
36
- };
37
-
38
- // Keeps `input` live until parsing has finished; see the note above.
39
- std::hint::black_box(input);
40
-
41
- result
42
- }
43
-
44
- /// Initializes the Ruby extension.
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.
45
46
  #[magnus::init]
46
47
  fn init(ruby: &Ruby) -> Result<(), Error> {
47
- let module = ruby.define_module("ZLight")?;
48
- module.define_singleton_method("parse", function!(parse, -1))?;
49
- module.define_singleton_method("generate", function!(writer::generate, -1))?;
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))?;
50
52
 
51
- // Initialize streaming support
52
- stream::init(ruby)?;
53
+ // Defines ZLight::StreamReader along with .stream and .stream_file.
54
+ read::stream::init(ruby)?;
53
55
 
54
56
  Ok(())
55
57
  }