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 +4 -4
- data/ARCHITECTURE.md +132 -0
- data/CHANGELOG.md +38 -1
- data/README.md +55 -2
- data/VERSION +1 -1
- data/ext/zlight_csv/src/convert.rs +203 -0
- data/ext/zlight_csv/src/error.rs +9 -3
- data/ext/zlight_csv/src/lib.rs +47 -45
- data/ext/zlight_csv/src/options.rs +61 -13
- data/ext/zlight_csv/src/reader.rs +26 -0
- data/ext/zlight_csv/src/row.rs +85 -0
- data/lib/zlight_csv/errors.rb +20 -0
- data/lib/zlight_csv/native.rb +20 -0
- data/lib/zlight_csv.rb +132 -254
- metadata +7 -5
- data/ext/zlight_csv/src/converter.rs +0 -133
- data/ext/zlight_csv/src/parser.rs +0 -77
- data/ext/zlight_csv/src/stream.rs +0 -410
- data/ext/zlight_csv/src/writer.rs +0 -268
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f7e500659c606d547762f3eb7a39b074686e6e68e8a9318ec97aa6af50e5905
|
|
4
|
+
data.tar.gz: 1b0283e7036596faa5d148edec0edbe700fd90d20f9c75e65e287fe3686fd538
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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` |
|
|
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
|
-
- [
|
|
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.
|
|
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
|
+
}
|
data/ext/zlight_csv/src/error.rs
CHANGED
|
@@ -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
|
}
|
data/ext/zlight_csv/src/lib.rs
CHANGED
|
@@ -1,55 +1,57 @@
|
|
|
1
|
-
|
|
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
|
|
5
|
-
mod
|
|
6
|
-
mod
|
|
35
|
+
mod read;
|
|
36
|
+
mod reader;
|
|
37
|
+
mod row;
|
|
38
|
+
mod write;
|
|
7
39
|
|
|
8
|
-
use magnus::{function, prelude::*, Error,
|
|
40
|
+
use magnus::{function, prelude::*, Error, Ruby};
|
|
9
41
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
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
|
}
|