duckling 0.2.0 → 0.3.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/.claude/settings.json +46 -0
- data/AGENTS.md +178 -0
- data/Brewfile +3 -0
- data/CHANGELOG.md +43 -0
- data/CLAUDE.md +1 -0
- data/{ext/duckling/Cargo.lock → Cargo.lock} +20 -0
- data/Cargo.toml +9 -0
- data/README.md +176 -4
- data/Rakefile +229 -3
- data/docs/2026-07-01-roadmap.md +106 -0
- data/ext/duckling/Cargo.toml +4 -2
- data/ext/duckling/src/lib.rs +411 -79
- data/ext/duckling/src/ruby_value.rs +104 -0
- data/lib/duckling/version.rb +1 -1
- data/lib/duckling.rb +265 -1
- metadata +70 -7
data/ext/duckling/src/lib.rs
CHANGED
|
@@ -1,29 +1,207 @@
|
|
|
1
|
-
use chrono::
|
|
1
|
+
use chrono::{FixedOffset, TimeZone};
|
|
2
2
|
use duckling::{
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Context, DimensionKind, DimensionValue, Entity, Lang, Locale, Options, Region, TimePoint,
|
|
4
|
+
TimeValue, parse as duckling_parse,
|
|
5
5
|
};
|
|
6
|
-
use magnus::{
|
|
6
|
+
use magnus::{
|
|
7
|
+
Error, RArray, RHash, Ruby, Time as RubyTime, Value, function, prelude::*, scan_args,
|
|
8
|
+
};
|
|
9
|
+
use std::os::raw::c_void;
|
|
10
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
11
|
+
|
|
12
|
+
mod ruby_value;
|
|
13
|
+
use ruby_value::serialize_symbolized;
|
|
7
14
|
|
|
8
|
-
// `Duckling`
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
15
|
+
// `Duckling::Native` holds the raw Magnus-defined entrypoint; `Duckling.parse`
|
|
16
|
+
// itself is a thin Ruby-level wrapper (see lib/duckling.rb) that dispatches
|
|
17
|
+
// through a `Thread.new { ... }.value` so a calling Fiber on an Async::Reactor
|
|
18
|
+
// can yield to sibling Fibers while the GVL-released native call runs (issue
|
|
19
|
+
// #64). Keeping the native singleton method under a separate `Native` module
|
|
20
|
+
// — rather than directly on `Duckling` — is what makes that split possible:
|
|
21
|
+
// it gives Ruby code something to call *without* the thread-spawn, which the
|
|
22
|
+
// benchmark suite also relies on to measure the dispatch overhead directly.
|
|
13
23
|
#[magnus::init]
|
|
14
24
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
15
25
|
let module = ruby.define_module("Duckling")?;
|
|
16
|
-
module.
|
|
26
|
+
let native = module.define_module("Native")?;
|
|
27
|
+
native.define_singleton_method("parse", function!(parse, -1))?;
|
|
28
|
+
let panicking_fake = module.define_module("PanickingNativeFake")?;
|
|
29
|
+
panicking_fake.define_singleton_method("parse", function!(panicking_parse, -1))?;
|
|
17
30
|
Ok(())
|
|
18
31
|
}
|
|
19
32
|
|
|
20
|
-
///
|
|
33
|
+
/// Everything the off-GVL callback needs, and everything it produces.
|
|
34
|
+
/// Deliberately holds only fully-owned Rust data — no `magnus::Value`, no
|
|
35
|
+
/// `magnus::Error`, no other Ruby-VM-touching type crosses this struct in
|
|
36
|
+
/// either direction (this repo's established rule: never stash a bare
|
|
37
|
+
/// `magnus::Value`, or anything wrapping one, across a Magnus call boundary —
|
|
38
|
+
/// a past incident here caused a real GC-safety segfault).
|
|
39
|
+
struct ParsePayload {
|
|
40
|
+
text: String,
|
|
41
|
+
locale: Locale,
|
|
42
|
+
dims: Vec<DimensionKind>,
|
|
43
|
+
context: Context,
|
|
44
|
+
options: Options,
|
|
45
|
+
result: Option<Result<Vec<Entity>, String>>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// The raw callback handed to `rb_thread_call_without_gvl` as `func`. Runs
|
|
49
|
+
/// with the GVL released: no Ruby method calls, no `Value`/`RArray`
|
|
50
|
+
/// construction, no `magnus::Error` construction or raising is permitted
|
|
51
|
+
/// here — only the plain Rust computation and writing plain Rust data back
|
|
52
|
+
/// into `*payload`. Wraps the call in `std::panic::catch_unwind` directly
|
|
53
|
+
/// (not `magnus::rb_sys::catch_unwind`) to keep the payload's "plain Rust
|
|
54
|
+
/// data only" invariant simple and mechanically checkable.
|
|
55
|
+
///
|
|
56
|
+
/// This guard is required unconditionally, not just as release-profile
|
|
57
|
+
/// defense-in-depth: the wrapped `duckling` crate's own internal
|
|
58
|
+
/// `catch_unwind` is compiled out entirely under `#[cfg(not(debug_assertions))]`,
|
|
59
|
+
/// which is absent from this repo's own `dev`-profile local default
|
|
60
|
+
/// (`RB_SYS_CARGO_PROFILE=dev`, set via `.env.local`).
|
|
61
|
+
unsafe extern "C" fn parse_without_gvl(payload: *mut c_void) -> *mut c_void {
|
|
62
|
+
// Edition 2024 requires unsafe operations to be wrapped in their own
|
|
63
|
+
// `unsafe` block even inside an `unsafe fn` (unsafe_op_in_unsafe_fn).
|
|
64
|
+
let payload = unsafe { &mut *(payload as *mut ParsePayload) };
|
|
65
|
+
|
|
66
|
+
let outcome = catch_unwind(AssertUnwindSafe(|| {
|
|
67
|
+
duckling_parse(
|
|
68
|
+
&payload.text,
|
|
69
|
+
&payload.locale,
|
|
70
|
+
&payload.dims,
|
|
71
|
+
&payload.context,
|
|
72
|
+
&payload.options,
|
|
73
|
+
)
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
payload.result = Some(match outcome {
|
|
77
|
+
Ok(entities) => Ok(entities),
|
|
78
|
+
Err(panic_payload) => Err(panic_message(&*panic_payload)),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Return value is unused by our caller (the real result is read back out
|
|
82
|
+
// of `*payload`); the C API just requires we return *something*.
|
|
83
|
+
std::ptr::null_mut()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Mirrors duckling's own panic_payload_message downcast (`&str` / `String` /
|
|
87
|
+
/// fallback), kept local since we don't have access to duckling's private helper.
|
|
88
|
+
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
|
89
|
+
if let Some(&s) = payload.downcast_ref::<&'static str>() {
|
|
90
|
+
s.to_string()
|
|
91
|
+
} else if let Some(s) = payload.downcast_ref::<String>() {
|
|
92
|
+
s.clone()
|
|
93
|
+
} else {
|
|
94
|
+
"no panic message".to_string()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Converts a caught `duckling::parse` panic into the Ruby error the
|
|
99
|
+
/// extension raises. Single choke point for the panic → exception mapping,
|
|
100
|
+
/// shared by the real entrypoint and `Duckling::PanickingNativeFake`, so
|
|
101
|
+
/// tests exercising the fake exercise the exact mapping callers see.
|
|
102
|
+
///
|
|
103
|
+
/// Deliberately `RuntimeError` (a `StandardError`), not magnus's own
|
|
104
|
+
/// `Error::from_panic` convention of `fatal`: a native panic already cost
|
|
105
|
+
/// the caller nothing but this one call, so it must be an ordinary
|
|
106
|
+
/// `rescue => e`-able error, not one that also tears down the calling
|
|
107
|
+
/// Thread's `Thread#value`/`Thread#join` propagation as unrescuable.
|
|
108
|
+
fn panic_error(ruby: &Ruby, message: &str) -> Error {
|
|
109
|
+
Error::new(
|
|
110
|
+
ruby.exception_runtime_error(),
|
|
111
|
+
format!("duckling::parse panicked: {message}"),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Shorthand for the `Error::new(ruby.exception_arg_error(), ...)` pattern
|
|
116
|
+
/// repeated across `parse_locale`, `parse_dims`, and `build_context`.
|
|
117
|
+
fn arg_error(ruby: &Ruby, message: impl Into<String>) -> Error {
|
|
118
|
+
Error::new(ruby.exception_arg_error(), message.into())
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Shorthand for internal invariant violations in the generic-serialize-then-
|
|
122
|
+
/// patch walk (`patch_time_point`, `patch_time_value`, and `entity_to_ruby`'s
|
|
123
|
+
/// `Time` branch): these only fire if `serde_magnus`'s serialization of
|
|
124
|
+
/// `TimeValue`/`TimePoint` drifts from the typed walk built against it — a
|
|
125
|
+
/// wrapper bug, not anything the caller did wrong. Deliberately
|
|
126
|
+
/// `RuntimeError` (matching `panic_error`'s convention for "shouldn't happen"
|
|
127
|
+
/// cases), not `arg_error`'s `ArgumentError`, so a caller `rescue
|
|
128
|
+
/// ArgumentError`-ing their own bad `locale:`/`dims:` input handling doesn't
|
|
129
|
+
/// inadvertently swallow a real bug here.
|
|
130
|
+
fn internal_error(ruby: &Ruby, message: impl Into<String>) -> Error {
|
|
131
|
+
Error::new(ruby.exception_runtime_error(), message.into())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Payload for the test-only panicking fake below — same "plain owned Rust
|
|
135
|
+
/// data only" rule as `ParsePayload`.
|
|
136
|
+
struct PanicFakePayload {
|
|
137
|
+
result: Option<Result<Vec<Entity>, String>>,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Off-GVL callback for `Duckling::PanickingNativeFake.parse`: identical
|
|
141
|
+
/// shape to `parse_without_gvl`, but the guarded computation always panics —
|
|
142
|
+
/// standing in for a `duckling::parse` panic without needing a real
|
|
143
|
+
/// panic-triggering input.
|
|
144
|
+
unsafe extern "C" fn panic_fake_without_gvl(payload: *mut c_void) -> *mut c_void {
|
|
145
|
+
let payload = unsafe { &mut *(payload as *mut PanicFakePayload) };
|
|
146
|
+
|
|
147
|
+
let outcome = catch_unwind(AssertUnwindSafe(|| -> Vec<Entity> {
|
|
148
|
+
panic!("intentional panic from Duckling::PanickingNativeFake")
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
payload.result = Some(match outcome {
|
|
152
|
+
Ok(entities) => Ok(entities),
|
|
153
|
+
Err(panic_payload) => Err(panic_message(&*panic_payload)),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
std::ptr::null_mut()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// `Duckling::PanickingNativeFake.parse(*)` — test-only stand-in for
|
|
160
|
+
/// `Duckling::Native` whose native call always panics. Accepts (and
|
|
161
|
+
/// ignores) `Native.parse`'s arguments so tests can swap the `Native`
|
|
162
|
+
/// constant and drive the public `Duckling.parse` through the real
|
|
163
|
+
/// GVL-release + `catch_unwind` + `panic_error` path, observing exactly
|
|
164
|
+
/// what a `duckling::parse` panic does to a Ruby caller. Not part of the
|
|
165
|
+
/// public API.
|
|
166
|
+
fn panicking_parse(ruby: &Ruby, _args: &[Value]) -> Result<RArray, Error> {
|
|
167
|
+
let mut payload = PanicFakePayload { result: None };
|
|
168
|
+
|
|
169
|
+
unsafe {
|
|
170
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
171
|
+
Some(panic_fake_without_gvl),
|
|
172
|
+
&mut payload as *mut PanicFakePayload as *mut c_void,
|
|
173
|
+
None,
|
|
174
|
+
std::ptr::null_mut(),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
match payload
|
|
179
|
+
.result
|
|
180
|
+
.expect("panic_fake_without_gvl always sets result before returning")
|
|
181
|
+
{
|
|
182
|
+
Ok(_) => Ok(ruby.ary_new()),
|
|
183
|
+
Err(message) => Err(panic_error(ruby, &message)),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// `Duckling::Native.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false)`
|
|
188
|
+
///
|
|
189
|
+
/// The raw native entrypoint — no Thread spawn, no GVL-release considerations
|
|
190
|
+
/// visible at the call site. `Duckling.parse` (see lib/duckling.rb) is the
|
|
191
|
+
/// public API; it wraps this in `Thread.new { ... }.value` for thread-per-call
|
|
192
|
+
/// dispatch. Called directly (no thread), this is also the "without" baseline
|
|
193
|
+
/// the benchmark suite compares thread-per-call dispatch overhead against.
|
|
21
194
|
///
|
|
22
195
|
/// - `locale`: BCP-47 tag (e.g. `"en"`, `"en-GB"`); unsupported codes raise `ArgumentError`.
|
|
23
196
|
/// - `dims`: dimension names to extract; only `"time"` is implemented in 0.2.0,
|
|
24
197
|
/// other values raise `ArgumentError`.
|
|
25
|
-
/// - `reference_time`:
|
|
26
|
-
///
|
|
198
|
+
/// - `reference_time`: a Ruby `Time` anchoring relative expressions like "tomorrow";
|
|
199
|
+
/// its `utc_offset` is preserved into every time result's `:value` — both
|
|
200
|
+
/// `Instant` results (e.g. "in one hour") and `Naive` (wall-clock) results
|
|
201
|
+
/// (e.g. "tomorrow", "5pm"), which are resolved against this offset before
|
|
202
|
+
/// being returned. `:value` is always a real Ruby `Time`, never a string.
|
|
203
|
+
/// Defaults to `Context::default()` (now, UTC) when `nil`/omitted. A
|
|
204
|
+
/// non-`Time` value raises `TypeError`.
|
|
27
205
|
/// - `with_latent`: include ambiguous/latent matches (e.g. bare "morning").
|
|
28
206
|
fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
|
|
29
207
|
let args = scan_args::scan_args::<(String,), (), (), (), _, ()>(args)?;
|
|
@@ -33,7 +211,7 @@ fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
|
|
|
33
211
|
(
|
|
34
212
|
Option<String>,
|
|
35
213
|
Option<Vec<String>>,
|
|
36
|
-
Option<
|
|
214
|
+
Option<RubyTime>,
|
|
37
215
|
Option<bool>,
|
|
38
216
|
),
|
|
39
217
|
(),
|
|
@@ -44,21 +222,54 @@ fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
|
|
|
44
222
|
)?;
|
|
45
223
|
|
|
46
224
|
let text = args.required.0;
|
|
47
|
-
let (locale_str, dims_strs,
|
|
225
|
+
let (locale_str, dims_strs, ref_time, with_latent) = kw.optional;
|
|
48
226
|
let locale_str = locale_str.unwrap_or_else(|| "en".to_string());
|
|
49
227
|
let dims_strs = dims_strs.unwrap_or_else(|| vec!["time".to_string()]);
|
|
50
228
|
let with_latent = with_latent.unwrap_or(false);
|
|
51
229
|
|
|
52
230
|
let locale = parse_locale(ruby, &locale_str)?;
|
|
53
231
|
let dims = parse_dims(ruby, &dims_strs)?;
|
|
54
|
-
let context = build_context(ruby,
|
|
232
|
+
let context = build_context(ruby, ref_time)?;
|
|
55
233
|
let options = Options { with_latent };
|
|
56
234
|
|
|
57
|
-
|
|
235
|
+
// Release the GVL, call duckling::parse off-GVL, and block until the
|
|
236
|
+
// GVL is reacquired (rb_thread_call_without_gvl's documented step 4)
|
|
237
|
+
// before touching any Ruby Value again. The payload lives on this stack
|
|
238
|
+
// frame: rb_thread_call_without_gvl runs the callback to completion
|
|
239
|
+
// before returning, so no heap allocation or ownership transfer is
|
|
240
|
+
// needed — the borrow ends when the call returns.
|
|
241
|
+
let mut payload = ParsePayload {
|
|
242
|
+
text,
|
|
243
|
+
locale,
|
|
244
|
+
dims,
|
|
245
|
+
context,
|
|
246
|
+
options,
|
|
247
|
+
result: None,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
unsafe {
|
|
251
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
252
|
+
Some(parse_without_gvl),
|
|
253
|
+
&mut payload as *mut ParsePayload as *mut c_void,
|
|
254
|
+
None, // ubf: no cancellation hook (Thread#raise/#kill against an
|
|
255
|
+
// in-flight parse isn't handled — see issue #64's "Out of scope")
|
|
256
|
+
std::ptr::null_mut(),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let offset = payload.context.timezone();
|
|
261
|
+
let entities = match payload
|
|
262
|
+
.result
|
|
263
|
+
.expect("parse_without_gvl always sets result before returning")
|
|
264
|
+
{
|
|
265
|
+
Ok(entities) => entities,
|
|
266
|
+
// Safe to construct/raise now: the GVL is confirmed held again.
|
|
267
|
+
Err(message) => return Err(panic_error(ruby, &message)),
|
|
268
|
+
};
|
|
58
269
|
|
|
59
270
|
let out = ruby.ary_new();
|
|
60
271
|
for e in &entities {
|
|
61
|
-
out.push(entity_to_ruby(ruby, e)?)?;
|
|
272
|
+
out.push(entity_to_ruby(ruby, e, offset)?)?;
|
|
62
273
|
}
|
|
63
274
|
Ok(out)
|
|
64
275
|
}
|
|
@@ -68,20 +279,14 @@ fn parse_locale(ruby: &Ruby, locale_str: &str) -> Result<Locale, Error> {
|
|
|
68
279
|
let lang_code = parts.next().unwrap_or("");
|
|
69
280
|
let region_code = parts.next();
|
|
70
281
|
|
|
71
|
-
let lang = lang_from_code(lang_code)
|
|
72
|
-
|
|
73
|
-
ruby.exception_arg_error(),
|
|
74
|
-
format!("unsupported locale: {locale_str:?}"),
|
|
75
|
-
)
|
|
76
|
-
})?;
|
|
282
|
+
let lang = lang_from_code(lang_code)
|
|
283
|
+
.ok_or_else(|| arg_error(ruby, format!("unsupported locale: {locale_str:?}")))?;
|
|
77
284
|
|
|
78
285
|
let region = match region_code {
|
|
79
|
-
Some(code) => Some(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
})?),
|
|
286
|
+
Some(code) => Some(
|
|
287
|
+
region_from_code(code)
|
|
288
|
+
.ok_or_else(|| arg_error(ruby, format!("unsupported locale: {locale_str:?}")))?,
|
|
289
|
+
),
|
|
85
290
|
None => None,
|
|
86
291
|
};
|
|
87
292
|
|
|
@@ -192,82 +397,165 @@ fn parse_dims(ruby: &Ruby, dims_strs: &[String]) -> Result<Vec<DimensionKind>, E
|
|
|
192
397
|
"credit-card-number" => Ok(DimensionKind::CreditCardNumber),
|
|
193
398
|
"time-grain" => Ok(DimensionKind::TimeGrain),
|
|
194
399
|
"duration" => Ok(DimensionKind::Duration),
|
|
195
|
-
other => Err(
|
|
196
|
-
ruby.exception_arg_error(),
|
|
197
|
-
format!("unsupported dimension: {other:?}"),
|
|
198
|
-
)),
|
|
400
|
+
other => Err(arg_error(ruby, format!("unsupported dimension: {other:?}"))),
|
|
199
401
|
})
|
|
200
402
|
.collect()
|
|
201
403
|
}
|
|
202
404
|
|
|
203
|
-
fn build_context(ruby: &Ruby,
|
|
204
|
-
match
|
|
205
|
-
Some(
|
|
206
|
-
let
|
|
207
|
-
|
|
208
|
-
|
|
405
|
+
fn build_context(ruby: &Ruby, ref_time: Option<RubyTime>) -> Result<Context, Error> {
|
|
406
|
+
match ref_time {
|
|
407
|
+
Some(time) => {
|
|
408
|
+
let ts = time.timespec()?;
|
|
409
|
+
let offset = FixedOffset::east_opt(time.utc_offset() as i32).ok_or_else(|| {
|
|
410
|
+
arg_error(ruby, "invalid reference_time: utc_offset out of range")
|
|
411
|
+
})?;
|
|
412
|
+
let anchor = offset
|
|
413
|
+
.timestamp_opt(ts.tv_sec, ts.tv_nsec as u32)
|
|
414
|
+
.single()
|
|
415
|
+
.ok_or_else(|| arg_error(ruby, "invalid reference_time: timestamp out of range"))?;
|
|
416
|
+
Ok(Context::new(anchor, Locale::default()))
|
|
209
417
|
}
|
|
210
418
|
None => Ok(Context::default()),
|
|
211
419
|
}
|
|
212
420
|
}
|
|
213
421
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
422
|
+
/// Resolves a bare `NaiveDateTime` (wall-clock, no offset) against the
|
|
423
|
+
/// reference offset into an absolute `DateTime<FixedOffset>`. Shared by
|
|
424
|
+
/// `time_point_to_ruby` and `time_value_to_ruby` so the two call sites can't
|
|
425
|
+
/// drift apart on error message or ambiguity-handling strategy.
|
|
426
|
+
/// `FixedOffset` has no DST, so `.single()` is total in practice for any
|
|
427
|
+
/// `NaiveDateTime` duckling can produce; the `ok_or_else` is defensive.
|
|
428
|
+
fn resolve_naive(
|
|
429
|
+
ruby: &Ruby,
|
|
430
|
+
offset: FixedOffset,
|
|
431
|
+
value: &chrono::NaiveDateTime,
|
|
432
|
+
) -> Result<chrono::DateTime<FixedOffset>, Error> {
|
|
433
|
+
offset
|
|
434
|
+
.from_local_datetime(value)
|
|
435
|
+
.single()
|
|
436
|
+
.ok_or_else(|| arg_error(ruby, "invalid or ambiguous naive time for reference offset"))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/// Patches a single generically-serialized `TimePoint` leaf in place: `point`
|
|
440
|
+
/// is the externally-tagged one-key Hash `serde_magnus` produced for it
|
|
441
|
+
/// (`{Naive: {value:, grain:}}` or `{Instant: {value:, grain:}}`), still
|
|
442
|
+
/// holding serde's placeholder String datetime and raw PascalCase grain. `tp`
|
|
443
|
+
/// is the typed Rust value the serialization came from, giving us everything
|
|
444
|
+
/// needed to overwrite both leaves with the real thing: a genuine Magnus
|
|
445
|
+
/// `Time` (running `resolve_naive`'s reference-offset resolution for
|
|
446
|
+
/// `Naive`, direct `IntoValue` for `Instant`) and `Grain::as_str()`'s
|
|
447
|
+
/// lowercase-snake_case symbol.
|
|
448
|
+
fn patch_time_point(
|
|
449
|
+
ruby: &Ruby,
|
|
450
|
+
point: Value,
|
|
451
|
+
tp: &TimePoint,
|
|
452
|
+
offset: FixedOffset,
|
|
453
|
+
) -> Result<(), Error> {
|
|
454
|
+
let outer = RHash::from_value(point)
|
|
455
|
+
.ok_or_else(|| internal_error(ruby, "expected serialized TimePoint to be a Hash"))?;
|
|
456
|
+
let tag = match tp {
|
|
457
|
+
TimePoint::Naive { .. } => "Naive",
|
|
458
|
+
TimePoint::Instant { .. } => "Instant",
|
|
459
|
+
};
|
|
460
|
+
let inner: Value = outer.aref(ruby.to_symbol(tag))?;
|
|
461
|
+
let inner = RHash::from_value(inner).ok_or_else(|| {
|
|
462
|
+
internal_error(ruby, "expected serialized TimePoint payload to be a Hash")
|
|
463
|
+
})?;
|
|
217
464
|
match tp {
|
|
218
465
|
TimePoint::Naive { value, grain } => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
value.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
|
222
|
-
)?;
|
|
223
|
-
h.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
|
|
466
|
+
inner.aset(ruby.to_symbol("value"), resolve_naive(ruby, offset, value)?)?;
|
|
467
|
+
inner.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
|
|
224
468
|
}
|
|
225
469
|
TimePoint::Instant { value, grain } => {
|
|
226
|
-
|
|
227
|
-
|
|
470
|
+
inner.aset(ruby.to_symbol("value"), *value)?;
|
|
471
|
+
inner.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
|
|
228
472
|
}
|
|
229
473
|
}
|
|
230
|
-
Ok(
|
|
474
|
+
Ok(())
|
|
231
475
|
}
|
|
232
476
|
|
|
233
|
-
|
|
234
|
-
|
|
477
|
+
/// Patches every datetime/grain leaf reachable from a generically-serialized
|
|
478
|
+
/// `TimeValue` (`serialized` is the `{Single: {...}}` / `{Interval: {...}}`
|
|
479
|
+
/// payload, i.e. `entity.value`'s serialization with the outer `Time` tag
|
|
480
|
+
/// already unwrapped) using the typed `tv` this serialization came from to
|
|
481
|
+
/// walk to exactly the same positions `serde_magnus` placed them at: the
|
|
482
|
+
/// `Single` case's primary `value` plus every `values` recurrence entry, or
|
|
483
|
+
/// the `Interval` case's `from`/`to` plus every `values` recurrence entry's
|
|
484
|
+
/// own `from`/`to`. Every other field `serde_magnus` produced (`values`
|
|
485
|
+
/// array framing, `holidayBeta`) is left untouched — this only overwrites
|
|
486
|
+
/// the known problem leaves, per issue #91's "generic-serialize-then-patch"
|
|
487
|
+
/// design.
|
|
488
|
+
fn patch_time_value(
|
|
489
|
+
ruby: &Ruby,
|
|
490
|
+
serialized: Value,
|
|
491
|
+
tv: &TimeValue,
|
|
492
|
+
offset: FixedOffset,
|
|
493
|
+
) -> Result<(), Error> {
|
|
494
|
+
let outer = RHash::from_value(serialized)
|
|
495
|
+
.ok_or_else(|| internal_error(ruby, "expected serialized TimeValue to be a Hash"))?;
|
|
235
496
|
match tv {
|
|
236
497
|
TimeValue::Single { value, values, .. } => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
for tp in values {
|
|
253
|
-
vals.push(time_point_to_ruby(ruby, tp)?)?;
|
|
498
|
+
let inner: Value = outer.aref(ruby.to_symbol("Single"))?;
|
|
499
|
+
let inner = RHash::from_value(inner).ok_or_else(|| {
|
|
500
|
+
internal_error(ruby, "expected serialized Single payload to be a Hash")
|
|
501
|
+
})?;
|
|
502
|
+
|
|
503
|
+
let point: Value = inner.aref(ruby.to_symbol("value"))?;
|
|
504
|
+
patch_time_point(ruby, point, value, offset)?;
|
|
505
|
+
|
|
506
|
+
let vals: Value = inner.aref(ruby.to_symbol("values"))?;
|
|
507
|
+
let vals = RArray::from_value(vals).ok_or_else(|| {
|
|
508
|
+
internal_error(ruby, "expected serialized Single values to be an Array")
|
|
509
|
+
})?;
|
|
510
|
+
for (i, tp) in values.iter().enumerate() {
|
|
511
|
+
let entry: Value = vals.entry(i as isize)?;
|
|
512
|
+
patch_time_point(ruby, entry, tp, offset)?;
|
|
254
513
|
}
|
|
255
|
-
h.aset(ruby.to_symbol("values"), vals)?;
|
|
256
514
|
}
|
|
257
|
-
TimeValue::Interval {
|
|
258
|
-
|
|
515
|
+
TimeValue::Interval {
|
|
516
|
+
from, to, values, ..
|
|
517
|
+
} => {
|
|
518
|
+
let inner: Value = outer.aref(ruby.to_symbol("Interval"))?;
|
|
519
|
+
let inner = RHash::from_value(inner).ok_or_else(|| {
|
|
520
|
+
internal_error(ruby, "expected serialized Interval payload to be a Hash")
|
|
521
|
+
})?;
|
|
522
|
+
|
|
259
523
|
if let Some(tp) = from {
|
|
260
|
-
|
|
524
|
+
let point: Value = inner.aref(ruby.to_symbol("from"))?;
|
|
525
|
+
patch_time_point(ruby, point, tp, offset)?;
|
|
261
526
|
}
|
|
262
527
|
if let Some(tp) = to {
|
|
263
|
-
|
|
528
|
+
let point: Value = inner.aref(ruby.to_symbol("to"))?;
|
|
529
|
+
patch_time_point(ruby, point, tp, offset)?;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let vals: Value = inner.aref(ruby.to_symbol("values"))?;
|
|
533
|
+
let vals = RArray::from_value(vals).ok_or_else(|| {
|
|
534
|
+
internal_error(ruby, "expected serialized Interval values to be an Array")
|
|
535
|
+
})?;
|
|
536
|
+
for (i, endpoints) in values.iter().enumerate() {
|
|
537
|
+
let entry: Value = vals.entry(i as isize)?;
|
|
538
|
+
let entry = RHash::from_value(entry).ok_or_else(|| {
|
|
539
|
+
internal_error(
|
|
540
|
+
ruby,
|
|
541
|
+
"expected serialized IntervalEndpoints entry to be a Hash",
|
|
542
|
+
)
|
|
543
|
+
})?;
|
|
544
|
+
if let Some(tp) = &endpoints.from {
|
|
545
|
+
let point: Value = entry.aref(ruby.to_symbol("from"))?;
|
|
546
|
+
patch_time_point(ruby, point, tp, offset)?;
|
|
547
|
+
}
|
|
548
|
+
if let Some(tp) = &endpoints.to {
|
|
549
|
+
let point: Value = entry.aref(ruby.to_symbol("to"))?;
|
|
550
|
+
patch_time_point(ruby, point, tp, offset)?;
|
|
551
|
+
}
|
|
264
552
|
}
|
|
265
553
|
}
|
|
266
554
|
}
|
|
267
|
-
Ok(
|
|
555
|
+
Ok(())
|
|
268
556
|
}
|
|
269
557
|
|
|
270
|
-
fn entity_to_ruby(ruby: &Ruby, entity: &Entity) -> Result<Value, Error> {
|
|
558
|
+
fn entity_to_ruby(ruby: &Ruby, entity: &Entity, offset: FixedOffset) -> Result<Value, Error> {
|
|
271
559
|
let h = ruby.hash_new();
|
|
272
560
|
h.aset(ruby.to_symbol("body"), entity.body.clone())?;
|
|
273
561
|
h.aset(ruby.to_symbol("start"), entity.start)?;
|
|
@@ -277,8 +565,52 @@ fn entity_to_ruby(ruby: &Ruby, entity: &Entity) -> Result<Value, Error> {
|
|
|
277
565
|
if let Some(latent) = entity.latent {
|
|
278
566
|
h.aset(ruby.to_symbol("latent"), latent)?;
|
|
279
567
|
}
|
|
280
|
-
|
|
281
|
-
|
|
568
|
+
match &entity.value {
|
|
569
|
+
DimensionValue::Time(tv) => {
|
|
570
|
+
// Generic serialize-then-patch (issue #91), same convention #90
|
|
571
|
+
// uses for the other 13 dimensions: serde_magnus gives us the
|
|
572
|
+
// structural shape (`Single`/`Interval` discrimination, `values`,
|
|
573
|
+
// `holidayBeta`) for free; `patch_time_value` then overwrites the
|
|
574
|
+
// known-problem leaves (datetime placeholders, raw-PascalCase
|
|
575
|
+
// grains) using the typed `tv` this serialization came from.
|
|
576
|
+
let serialized = serialize_symbolized(ruby, &entity.value)?;
|
|
577
|
+
let outer = RHash::from_value(serialized).ok_or_else(|| {
|
|
578
|
+
internal_error(ruby, "expected serialized DimensionValue to be a Hash")
|
|
579
|
+
})?;
|
|
580
|
+
let time_payload: Value = outer.aref(ruby.to_symbol("Time"))?;
|
|
581
|
+
patch_time_value(ruby, time_payload, tv, offset)?;
|
|
582
|
+
h.aset(ruby.to_symbol("value"), serialized)?;
|
|
583
|
+
}
|
|
584
|
+
// `Grain` serde-serializes as PascalCase variant names ("Second",
|
|
585
|
+
// "NoGrain"); the shipped convention is `Grain::as_str()` symbols
|
|
586
|
+
// (:second, :no_grain) — see time_point_to_ruby. For TimeGrain the
|
|
587
|
+
// grain *is* the whole tagged payload, so the hash is built directly
|
|
588
|
+
// rather than serialized-then-patched.
|
|
589
|
+
DimensionValue::TimeGrain(grain) => {
|
|
590
|
+
let value = ruby.hash_new();
|
|
591
|
+
value.aset(ruby.to_symbol("TimeGrain"), ruby.to_symbol(grain.as_str()))?;
|
|
592
|
+
h.aset(ruby.to_symbol("value"), value)?;
|
|
593
|
+
}
|
|
594
|
+
// Duration has exactly three scalar fields, so — like TimeGrain above
|
|
595
|
+
// — the tagged hash is built directly rather than serialized then
|
|
596
|
+
// patched: a serialize-then-patch here would silently no-op if the
|
|
597
|
+
// crate's serde representation of Duration ever changed shape.
|
|
598
|
+
DimensionValue::Duration {
|
|
599
|
+
value: count,
|
|
600
|
+
grain,
|
|
601
|
+
normalized_seconds,
|
|
602
|
+
} => {
|
|
603
|
+
let payload = ruby.hash_new();
|
|
604
|
+
payload.aset(ruby.to_symbol("value"), *count)?;
|
|
605
|
+
payload.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
|
|
606
|
+
payload.aset(ruby.to_symbol("normalized_seconds"), *normalized_seconds)?;
|
|
607
|
+
let tagged = ruby.hash_new();
|
|
608
|
+
tagged.aset(ruby.to_symbol("Duration"), payload)?;
|
|
609
|
+
h.aset(ruby.to_symbol("value"), tagged)?;
|
|
610
|
+
}
|
|
611
|
+
other => {
|
|
612
|
+
h.aset(ruby.to_symbol("value"), serialize_symbolized(ruby, other)?)?;
|
|
613
|
+
}
|
|
282
614
|
}
|
|
283
615
|
Ok(h.as_value())
|
|
284
616
|
}
|