zlight_csv 0.4.0-x86_64-linux → 0.5.1-x86_64-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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2f876b5dca405c9cee64f60d93c27652f33ffb4a6b3e8c6eff6e7d1af1b3bf0
|
|
4
|
+
data.tar.gz: 58f1e880ec5e544ac0a64dcfb9f025829bcc90d4f7fcde3acec987a404c47014
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d088be4df8df53496eb25bc21640237c12d1db7d7df6b3a188d5624e5af1436f59295331d2407cddfde9da6f4059be9b01b7959d0ecd69e0272784d53f9c191d
|
|
7
|
+
data.tar.gz: d400dc067e043d99fc94d7ea2e3e72da9b6b9b1de5b6bed9b3e150e255fb7122136aad0ad576d77694f2be40c39d22db60d4a8e4064d0dda3798dc59ca164ba7
|
data/CHANGELOG.md
CHANGED
|
@@ -7,22 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.1
|
|
10
|
+
## [0.5.1] - 2026-09-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The extension now builds against a Ruby that rb-sys has no prebuilt bindings
|
|
15
|
+
for, via rb-sys `stable-api-compiled-fallback`, rather than failing outright.
|
|
16
|
+
This is the mechanism that breaks the build on every new Ruby release until
|
|
17
|
+
rb-sys catches up
|
|
18
|
+
- Windows CI could not build the extension at all: the runner paired a MinGW
|
|
19
|
+
Ruby with an MSVC Rust toolchain. Ruby and Rust are now set up together so
|
|
20
|
+
their ABIs match, and Windows is verified across Ruby 3.1-4.0
|
|
21
|
+
- Specs that asserted Unix-only error messages and paths now pass on Windows
|
|
22
|
+
- Dropped an unreachable credentials check from the release workflow: the
|
|
23
|
+
credentials action fails first, so the check never ran
|
|
24
|
+
|
|
25
|
+
### Note
|
|
26
|
+
|
|
27
|
+
- 0.5.0 was tagged but never reached RubyGems: its release run built every gem
|
|
28
|
+
and then failed to publish, because trusted publishing still named the
|
|
29
|
+
project's former GitHub account. 0.5.1 is the first release carrying the
|
|
30
|
+
0.5.0 changes below.
|
|
31
|
+
|
|
32
|
+
## [0.5.0] - 2026-09-12
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- Ruby 4.0 support
|
|
37
|
+
- `docker/test-matrix.sh`, which builds and tests the extension against every
|
|
38
|
+
supported Ruby in containers
|
|
39
|
+
- Windows, Ruby 4.0 and a `ruby-head` early-warning job in CI, plus weekly
|
|
40
|
+
scheduled runs and Dependabot
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Upgraded magnus from 0.6 to 0.8. magnus 0.6 read a field of Ruby's
|
|
45
|
+
RTypedData struct that Ruby 4.0 removed, so the extension could not compile
|
|
46
|
+
against it at all
|
|
47
|
+
- Minimum Ruby is now 3.1. Ruby 3.0 is end-of-life and no longer supported by
|
|
48
|
+
magnus
|
|
49
|
+
- Repository URLs now point at `codebyisaad/zlight`, which is where the code
|
|
50
|
+
is hosted
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- The source gem shipped no extension and could not be compiled, so any Ruby
|
|
55
|
+
or platform without a prebuilt binary installed a gem that failed to load
|
|
56
|
+
- `ZLight::ParseError`, `ZLight::EncodingError` and `ZLight::StreamClosedError`
|
|
57
|
+
were documented but never raised
|
|
58
|
+
- Integers outside the i64 range were silently converted to lossy Floats
|
|
59
|
+
- `col_sep` and `quote_char` longer than one byte were silently truncated when
|
|
60
|
+
reading, while writing rejected them
|
|
61
|
+
- Releases no longer yank every previously published version
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
## [0.4.0] - 2026-06-06
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- `ZLight.generate` — build a CSV string from an array of hashes or an array
|
|
69
|
+
of arrays, with `headers`, `col_sep`, `quote_char` and `force_quotes` options
|
|
70
|
+
- `ZLight.write` — generate a CSV and write it to a file in one call
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- Parsed fields are tagged with the encoding of the input string, matching
|
|
75
|
+
Ruby's stdlib CSV, so the parser and writer round-trip without
|
|
76
|
+
`force_encoding`
|
|
77
|
+
|
|
78
|
+
## [0.3.0] - 2026-05-21
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- Release pipeline updates
|
|
83
|
+
|
|
84
|
+
## [0.2.5] - 2026-05-21
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- `ZLight.stream` and `ZLight.stream_file` — read rows one at a time without
|
|
89
|
+
loading the whole input into memory
|
|
90
|
+
- `ZLight.open` — streaming with a block, closing the reader automatically
|
|
91
|
+
- `ZLight::StreamReader`, including `Enumerable`, so `lazy`, `select`, `find`
|
|
92
|
+
and friends work over a stream
|
|
93
|
+
|
|
94
|
+
### Fixed
|
|
95
|
+
|
|
96
|
+
- Packaging and gemspec metadata corrections across 0.2.1–0.2.5
|
|
97
|
+
|
|
98
|
+
## [0.2.0] - 2026-05-21
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
|
|
102
|
+
- Benchmark suite comparing ZLight against Ruby's stdlib CSV
|
|
103
|
+
- Edge case coverage for quoting, encodings and flexible records
|
|
104
|
+
|
|
105
|
+
## [0.1.2] - 2026-05-19
|
|
11
106
|
|
|
12
107
|
### Added
|
|
13
108
|
|
|
14
109
|
- Initial release
|
|
15
|
-
- `
|
|
16
|
-
|
|
17
|
-
- `
|
|
18
|
-
- `
|
|
19
|
-
-
|
|
20
|
-
- Support for custom quote characters (`quote_char`)
|
|
21
|
-
- Flexible record length support
|
|
22
|
-
- Enum-based error handling with descriptive messages
|
|
23
|
-
- Cross-platform native gem builds
|
|
110
|
+
- `ZLight.parse` — parse CSV strings, with `headers`, `converters`, `col_sep`,
|
|
111
|
+
`quote_char` and `flexible` options
|
|
112
|
+
- `ZLight.read` — read and parse a CSV file
|
|
113
|
+
- `ZLight.foreach` — iterate over parsed rows
|
|
114
|
+
- Cross-platform precompiled native gems
|
|
24
115
|
|
|
25
116
|
### Performance
|
|
26
117
|
|
|
27
|
-
-
|
|
28
|
-
|
|
118
|
+
- Substantially faster than Ruby's stdlib CSV; see the README for measured
|
|
119
|
+
figures
|
|
120
|
+
|
|
121
|
+
[Unreleased]: https://github.com/codebyisaad/zlight/compare/v0.5.1...HEAD
|
|
122
|
+
[0.5.1]: https://github.com/codebyisaad/zlight/compare/v0.5.0...v0.5.1
|
|
123
|
+
[0.5.0]: https://github.com/codebyisaad/zlight/compare/v0.4.0...v0.5.0
|
|
124
|
+
[0.4.0]: https://github.com/codebyisaad/zlight/compare/v0.3.0...v0.4.0
|
|
125
|
+
[0.3.0]: https://github.com/codebyisaad/zlight/compare/v0.2.5...v0.3.0
|
|
126
|
+
[0.2.5]: https://github.com/codebyisaad/zlight/compare/v0.2.0...v0.2.5
|
|
127
|
+
[0.2.0]: https://github.com/codebyisaad/zlight/compare/v0.1.2...v0.2.0
|
|
128
|
+
[0.1.2]: https://github.com/codebyisaad/zlight/releases/tag/v0.1.2
|
data/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2024 Zaidan Chaudhary
|
|
3
|
+
Copyright (c) 2024 Zaidan Chaudhary (also known as Saad Chaudhary)
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
data/README.md
CHANGED
|
@@ -7,7 +7,8 @@ A fast CSV parser for Ruby, powered by Rust.
|
|
|
7
7
|
|
|
8
8
|
## Why ZLight?
|
|
9
9
|
|
|
10
|
-
Ruby's built-in CSV library is slow. ZLight parses CSV files **up to
|
|
10
|
+
Ruby's built-in CSV library is slow. ZLight parses CSV files **up to 40x faster** by using Rust under the hood.
|
|
11
|
+
The gap is widest on small inputs and narrows as they grow; the measurements below show the range.
|
|
11
12
|
|
|
12
13
|
### Benchmark Results
|
|
13
14
|
|
|
@@ -156,11 +157,55 @@ CSV.parse(data, headers: true, header_converters: :symbol, converters: :numeric)
|
|
|
156
157
|
ZLight.parse(data, converters: :numeric)
|
|
157
158
|
```
|
|
158
159
|
|
|
160
|
+
### Differences from stdlib CSV
|
|
161
|
+
|
|
162
|
+
ZLight is not a complete reimplementation of `CSV`. The differences below are
|
|
163
|
+
deliberate, and are the ones most likely to matter when migrating.
|
|
164
|
+
|
|
165
|
+
**API shape**
|
|
166
|
+
|
|
167
|
+
- `ZLight.foreach` takes a **CSV string**, while `CSV.foreach` takes a **file
|
|
168
|
+
path**. Use `ZLight.open` to iterate a file.
|
|
169
|
+
- `ZLight.foreach` parses the whole input before yielding. For genuinely lazy
|
|
170
|
+
iteration use `ZLight.stream` or `ZLight.open`.
|
|
171
|
+
- `ZLight::StreamReader` is single-pass. It includes `Enumerable`, but each
|
|
172
|
+
row is consumed as it is read, so a second pass yields nothing and there is
|
|
173
|
+
no rewind. Call `ZLight.stream` again to re-read.
|
|
174
|
+
|
|
175
|
+
**Parsing**
|
|
176
|
+
|
|
177
|
+
- Duplicate headers collapse. Rows are `Hash`es, so `"a,a"` keeps only the
|
|
178
|
+
last `:a` column; `CSV` keeps both.
|
|
179
|
+
- Fields beyond the header count are dropped rather than collected.
|
|
180
|
+
- Headers become symbols always, equivalent to `header_converters: :symbol`.
|
|
181
|
+
|
|
182
|
+
**`converters: :numeric`**
|
|
183
|
+
|
|
184
|
+
| Input | ZLight | Ruby CSV |
|
|
185
|
+
|--------------|-------------------|------------|
|
|
186
|
+
| `""` | `""` | `nil` |
|
|
187
|
+
| `"0x10"` | `"0x10"` | `16` |
|
|
188
|
+
| `"1_000"` | `"1_000"` | `1000` |
|
|
189
|
+
| `"Infinity"` | `Float::INFINITY` | `"Infinity"` |
|
|
190
|
+
| `"NaN"` | `Float::NAN` | `"NaN"` |
|
|
191
|
+
|
|
192
|
+
Integers of any size are exact, as in `CSV`.
|
|
193
|
+
|
|
194
|
+
**Writing**
|
|
195
|
+
|
|
196
|
+
- `ZLight.generate` takes its column order from the keys of the **first**
|
|
197
|
+
hash. Keys that appear only in later rows are not written; keys missing
|
|
198
|
+
from a later row are written as empty fields.
|
|
199
|
+
|
|
159
200
|
## Requirements
|
|
160
201
|
|
|
161
|
-
- Ruby 3.0
|
|
202
|
+
- Ruby 3.1 or newer, including Ruby 4.0
|
|
162
203
|
- Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), or Windows (x64)
|
|
163
204
|
|
|
205
|
+
Every supported Ruby is built and tested on each change; see
|
|
206
|
+
`docker/test-matrix.sh` to run that matrix yourself. Rubies without a
|
|
207
|
+
precompiled binary install from the source gem and need a Rust toolchain.
|
|
208
|
+
|
|
164
209
|
## Roadmap
|
|
165
210
|
|
|
166
211
|
- [x] Streaming/lazy parsing for large files
|
|
@@ -169,8 +214,12 @@ ZLight.parse(data, converters: :numeric)
|
|
|
169
214
|
|
|
170
215
|
## Contributing
|
|
171
216
|
|
|
172
|
-
Bug reports and pull requests are welcome on [GitHub](https://github.com/
|
|
217
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/codebyisaad/zlight).
|
|
218
|
+
|
|
219
|
+
## Author
|
|
220
|
+
|
|
221
|
+
Zaidan Chaudhary, who also publishes as Saad Chaudhary.
|
|
173
222
|
|
|
174
223
|
## License
|
|
175
224
|
|
|
176
|
-
MIT
|
|
225
|
+
MIT. See [LICENSE](LICENSE).
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.5.1
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "zlight_csv"
|
|
3
|
+
# Synced from ../../VERSION by extconf.rb — do not edit manually.
|
|
4
|
+
version = "0.5.1"
|
|
5
|
+
edition = "2021"
|
|
6
|
+
authors = ["Zaidan Chaudhary", "Saad Chaudhary"]
|
|
7
|
+
description = "High-performance CSV parser for Ruby, powered by Rust"
|
|
8
|
+
license = "MIT"
|
|
9
|
+
repository = "https://github.com/codebyisaad/zlight"
|
|
10
|
+
|
|
11
|
+
[lib]
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
path = "src/lib.rs"
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
csv = "1"
|
|
17
|
+
magnus = "0.8"
|
|
18
|
+
# Depended on directly, only to turn on stable-api-compiled-fallback.
|
|
19
|
+
# rb-sys ships prebuilt bindings for Ruby versions it knows about and fails
|
|
20
|
+
# outright on ones it does not, which is what breaks the build on every new
|
|
21
|
+
# Ruby before rb-sys catches up. The fallback compiles the shim from the
|
|
22
|
+
# headers of whatever Ruby is present instead, so an unreleased Ruby builds.
|
|
23
|
+
rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
|
|
24
|
+
thiserror = "1"
|
|
25
|
+
|
|
26
|
+
[profile.release]
|
|
27
|
+
lto = true
|
|
28
|
+
codegen-units = 1
|
|
29
|
+
opt-level = 3
|
|
30
|
+
strip = true
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mkmf"
|
|
4
|
+
require "rb_sys/mkmf"
|
|
5
|
+
|
|
6
|
+
def sync_cargo_version!
|
|
7
|
+
version = File.read(File.expand_path("../../VERSION", __dir__), encoding: "UTF-8").strip
|
|
8
|
+
cargo_toml = File.join(__dir__, "Cargo.toml")
|
|
9
|
+
contents = File.read(cargo_toml, encoding: "UTF-8")
|
|
10
|
+
updated = contents.sub(/^version = ".*"$/, %(version = "#{version}"))
|
|
11
|
+
File.write(cargo_toml, updated) if contents != updated
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sync_cargo_version!
|
|
15
|
+
|
|
16
|
+
create_rust_makefile("zlight_csv/zlight_csv")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
use magnus::{encoding::Index, prelude::*, Ruby, Value};
|
|
2
|
+
|
|
3
|
+
/// Converts a byte slice field to a Ruby string value tagged with `encoding`.
|
|
4
|
+
///
|
|
5
|
+
/// Fields are labelled with the encoding of the original input string (or UTF-8
|
|
6
|
+
/// for file streams) so that parsed values match Ruby's stdlib CSV behavior and
|
|
7
|
+
/// round-trip cleanly through the writer.
|
|
8
|
+
#[inline(always)]
|
|
9
|
+
pub fn field_to_string(ruby: &Ruby, field: &[u8], encoding: Index) -> Value {
|
|
10
|
+
ruby.enc_str_new(field, encoding).as_value()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Fast check if a byte slice looks like it could be a float.
|
|
14
|
+
/// Scans for '.', 'e', or 'E' in a single pass.
|
|
15
|
+
#[inline(always)]
|
|
16
|
+
fn looks_like_float(bytes: &[u8]) -> bool {
|
|
17
|
+
bytes.iter().any(|&b| b == b'.' || b == b'e' || b == b'E')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Trim ASCII whitespace from both ends without allocation.
|
|
21
|
+
#[inline(always)]
|
|
22
|
+
fn trim_ascii(bytes: &[u8]) -> &[u8] {
|
|
23
|
+
let start = bytes.iter().position(|&b| !b.is_ascii_whitespace()).unwrap_or(bytes.len());
|
|
24
|
+
let end = bytes.iter().rposition(|&b| !b.is_ascii_whitespace()).map_or(start, |i| i + 1);
|
|
25
|
+
&bytes[start..end]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Outcome of the integer fast path.
|
|
29
|
+
enum IntScan {
|
|
30
|
+
/// Parsed exactly into an i64.
|
|
31
|
+
Fits(i64),
|
|
32
|
+
/// A valid decimal integer, but too large in magnitude for an i64.
|
|
33
|
+
TooLarge,
|
|
34
|
+
/// Not a decimal integer at all.
|
|
35
|
+
NotAnInteger,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Fast path: scan an optionally signed run of ASCII digits without full UTF-8
|
|
39
|
+
/// validation.
|
|
40
|
+
///
|
|
41
|
+
/// Values outside i64 are reported as `TooLarge` rather than rejected, so the
|
|
42
|
+
/// caller can fall back to an exact Ruby Integer instead of an inexact float.
|
|
43
|
+
#[inline(always)]
|
|
44
|
+
fn scan_int(bytes: &[u8]) -> IntScan {
|
|
45
|
+
if bytes.is_empty() {
|
|
46
|
+
return IntScan::NotAnInteger;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let digits = match bytes[0] {
|
|
50
|
+
b'-' | b'+' => &bytes[1..],
|
|
51
|
+
_ => bytes,
|
|
52
|
+
};
|
|
53
|
+
let negative = bytes[0] == b'-';
|
|
54
|
+
|
|
55
|
+
if digits.is_empty() {
|
|
56
|
+
return IntScan::NotAnInteger;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let mut magnitude: i64 = 0;
|
|
60
|
+
let mut overflowed = false;
|
|
61
|
+
|
|
62
|
+
for &b in digits {
|
|
63
|
+
if !b.is_ascii_digit() {
|
|
64
|
+
return IntScan::NotAnInteger;
|
|
65
|
+
}
|
|
66
|
+
if overflowed {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
match magnitude
|
|
70
|
+
.checked_mul(10)
|
|
71
|
+
.and_then(|m| m.checked_add((b - b'0') as i64))
|
|
72
|
+
{
|
|
73
|
+
Some(next) => magnitude = next,
|
|
74
|
+
// Keep scanning to confirm the rest is digits too, but remember
|
|
75
|
+
// that the value needs Ruby's arbitrary-precision Integer.
|
|
76
|
+
None => overflowed = true,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if overflowed {
|
|
81
|
+
IntScan::TooLarge
|
|
82
|
+
} else if negative {
|
|
83
|
+
IntScan::Fits(-magnitude)
|
|
84
|
+
} else {
|
|
85
|
+
IntScan::Fits(magnitude)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Builds an exact Ruby Integer from a decimal string too large for an i64.
|
|
90
|
+
///
|
|
91
|
+
/// Ruby Integers are arbitrary precision, so no digits are lost. `scan_int` has
|
|
92
|
+
/// already established that the slice is ASCII digits with an optional sign,
|
|
93
|
+
/// which is why `String#to_i` is safe here.
|
|
94
|
+
#[inline]
|
|
95
|
+
fn big_integer(ruby: &Ruby, digits: &[u8]) -> Option<Value> {
|
|
96
|
+
let text = std::str::from_utf8(digits).ok()?;
|
|
97
|
+
ruby.str_new(text).funcall("to_i", ()).ok()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Attempts to parse a byte slice as a numeric value (integer or float).
|
|
101
|
+
#[inline]
|
|
102
|
+
fn try_parse_numeric(ruby: &Ruby, field: &[u8]) -> Option<Value> {
|
|
103
|
+
let trimmed = trim_ascii(field);
|
|
104
|
+
if trimmed.is_empty() {
|
|
105
|
+
return None;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fast path: try integer parsing without UTF-8 conversion.
|
|
109
|
+
if !looks_like_float(trimmed) {
|
|
110
|
+
match scan_int(trimmed) {
|
|
111
|
+
IntScan::Fits(i) => return Some(ruby.integer_from_i64(i).as_value()),
|
|
112
|
+
IntScan::TooLarge => return big_integer(ruby, trimmed),
|
|
113
|
+
IntScan::NotAnInteger => {}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Slow path: need UTF-8 for float parsing.
|
|
118
|
+
let text = std::str::from_utf8(trimmed).ok()?;
|
|
119
|
+
text.parse::<f64>()
|
|
120
|
+
.ok()
|
|
121
|
+
.map(|f| ruby.float_from_f64(f).as_value())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// Converts a byte slice field to an appropriate Ruby value.
|
|
125
|
+
/// If `convert_numeric` is true, attempts to parse as number first.
|
|
126
|
+
#[inline(always)]
|
|
127
|
+
pub fn field_to_value(ruby: &Ruby, field: &[u8], convert_numeric: bool, encoding: Index) -> Value {
|
|
128
|
+
if !convert_numeric {
|
|
129
|
+
return field_to_string(ruby, field, encoding);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try_parse_numeric(ruby, field).unwrap_or_else(|| field_to_string(ruby, field, encoding))
|
|
133
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
use magnus::{prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
|
|
2
|
+
use thiserror::Error;
|
|
3
|
+
|
|
4
|
+
/// Enumerated error types for ZlightCsv operations.
|
|
5
|
+
#[derive(Debug, Error)]
|
|
6
|
+
pub enum ZlightError {
|
|
7
|
+
#[error("CSV parsing error: {0}")]
|
|
8
|
+
CsvParse(csv::Error),
|
|
9
|
+
|
|
10
|
+
#[error("Invalid UTF-8 encoding in header: {0}")]
|
|
11
|
+
InvalidHeaderEncoding(String),
|
|
12
|
+
|
|
13
|
+
#[error("Missing required argument: {0}")]
|
|
14
|
+
MissingArgument(&'static str),
|
|
15
|
+
|
|
16
|
+
#[error("Invalid option value for '{key}': expected {expected}, got {actual}")]
|
|
17
|
+
InvalidOption {
|
|
18
|
+
key: &'static str,
|
|
19
|
+
expected: &'static str,
|
|
20
|
+
actual: String,
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
#[error("IO error: {0}")]
|
|
24
|
+
Io(#[from] std::io::Error),
|
|
25
|
+
|
|
26
|
+
#[error("Stream reader has been closed")]
|
|
27
|
+
StreamClosed,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl ZlightError {
|
|
31
|
+
/// Converts to a Ruby exception with the appropriate exception class.
|
|
32
|
+
///
|
|
33
|
+
/// Requires the Ruby handle, which is always available here: an error is
|
|
34
|
+
/// only converted on its way out of an extension method, and those run on
|
|
35
|
+
/// a Ruby thread holding the GVL. Building an exception needs a class, and
|
|
36
|
+
/// a class cannot be obtained without the handle, so there is no sensible
|
|
37
|
+
/// fallback for a VM we are not attached to.
|
|
38
|
+
#[inline]
|
|
39
|
+
pub fn to_magnus_error(&self) -> MagnusError {
|
|
40
|
+
let ruby = Ruby::get().expect("errors are only converted while a Ruby method is running");
|
|
41
|
+
MagnusError::new(self.exception_class(&ruby), self.to_string())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Resolves the exception class to raise, preferring the gem's own
|
|
45
|
+
/// `ZLight::*` class over the built-in it descends from.
|
|
46
|
+
fn exception_class(&self, ruby: &Ruby) -> ExceptionClass {
|
|
47
|
+
self.zlight_class_name()
|
|
48
|
+
.and_then(|name| resolve_zlight_class(ruby, name))
|
|
49
|
+
.unwrap_or_else(|| self.builtin_class(ruby))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Name of the `ZLight::*` class this error is documented to raise as, for
|
|
53
|
+
/// errors that have one.
|
|
54
|
+
fn zlight_class_name(&self) -> Option<&'static str> {
|
|
55
|
+
match self {
|
|
56
|
+
ZlightError::CsvParse(_) => Some("ParseError"),
|
|
57
|
+
ZlightError::InvalidHeaderEncoding(_) => Some("EncodingError"),
|
|
58
|
+
ZlightError::StreamClosed => Some("StreamClosedError"),
|
|
59
|
+
ZlightError::MissingArgument(_)
|
|
60
|
+
| ZlightError::InvalidOption { .. }
|
|
61
|
+
| ZlightError::Io(_) => None,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Built-in class to raise when there is no `ZLight::*` equivalent, and the
|
|
66
|
+
/// fallback if one cannot be resolved.
|
|
67
|
+
fn builtin_class(&self, ruby: &Ruby) -> ExceptionClass {
|
|
68
|
+
match self {
|
|
69
|
+
ZlightError::CsvParse(_) => ruby.exception_runtime_error(),
|
|
70
|
+
ZlightError::InvalidHeaderEncoding(_) => ruby.exception_encoding_error(),
|
|
71
|
+
ZlightError::MissingArgument(_) | ZlightError::InvalidOption { .. } => {
|
|
72
|
+
ruby.exception_arg_error()
|
|
73
|
+
}
|
|
74
|
+
ZlightError::Io(_) | ZlightError::StreamClosed => ruby.exception_io_error(),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Looks up `ZLight::<name>`.
|
|
80
|
+
///
|
|
81
|
+
/// The extension is loaded before lib/zlight_csv.rb defines these classes, so
|
|
82
|
+
/// the lookup happens when an error is raised rather than at init. It returns
|
|
83
|
+
/// `None` if the Ruby side was never loaded, leaving the caller to fall back to
|
|
84
|
+
/// a built-in class.
|
|
85
|
+
fn resolve_zlight_class(ruby: &Ruby, name: &'static str) -> Option<ExceptionClass> {
|
|
86
|
+
let zlight: RModule = ruby.class_object().const_get("ZLight").ok()?;
|
|
87
|
+
zlight.const_get(name).ok()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
impl From<csv::Error> for ZlightError {
|
|
91
|
+
/// The csv crate wraps read failures in its own error type. A directory or
|
|
92
|
+
/// an unreadable file is an IO problem, not malformed CSV, so unwrap those
|
|
93
|
+
/// rather than reporting them as a parse failure.
|
|
94
|
+
fn from(err: csv::Error) -> Self {
|
|
95
|
+
if !err.is_io_error() {
|
|
96
|
+
return ZlightError::CsvParse(err);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
match err.into_kind() {
|
|
100
|
+
csv::ErrorKind::Io(io) => ZlightError::Io(io),
|
|
101
|
+
kind => unreachable!("is_io_error() guarantees ErrorKind::Io, got {kind:?}"),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
impl From<ZlightError> for MagnusError {
|
|
107
|
+
#[inline]
|
|
108
|
+
fn from(err: ZlightError) -> Self {
|
|
109
|
+
err.to_magnus_error()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pub type Result<T> = std::result::Result<T, ZlightError>;
|
|
114
|
+
|
|
115
|
+
/// Converts a `csv` crate error into a Ruby exception.
|
|
116
|
+
///
|
|
117
|
+
/// `?` cannot bridge csv::Error to MagnusError on its own, because that would
|
|
118
|
+
/// need two chained `From` conversions.
|
|
119
|
+
#[inline]
|
|
120
|
+
pub fn csv_error(err: csv::Error) -> MagnusError {
|
|
121
|
+
ZlightError::from(err).into()
|
|
122
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
mod converter;
|
|
2
|
+
mod error;
|
|
3
|
+
mod options;
|
|
4
|
+
mod parser;
|
|
5
|
+
mod stream;
|
|
6
|
+
mod writer;
|
|
7
|
+
|
|
8
|
+
use magnus::{function, prelude::*, Error, RArray, Ruby, Value};
|
|
9
|
+
|
|
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.
|
|
45
|
+
#[magnus::init]
|
|
46
|
+
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))?;
|
|
50
|
+
|
|
51
|
+
// Initialize streaming support
|
|
52
|
+
stream::init(ruby)?;
|
|
53
|
+
|
|
54
|
+
Ok(())
|
|
55
|
+
}
|