duckling 0.2.0 → 0.4.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.
@@ -0,0 +1,104 @@
1
+ use magnus::r_hash::ForEach;
2
+ use magnus::value::ReprValue;
3
+ use magnus::{Error, RArray, RHash, RString, Ruby, Symbol, Value};
4
+
5
+ /// Recursively rewrites every `Hash` reachable from `value` (including
6
+ /// through `Array` elements, at any depth) so its keys are `Symbol`s instead
7
+ /// of `String`s, mutating in place rather than allocating replacement
8
+ /// Hashes/Arrays. Keys that aren't `String` (e.g. already a `Symbol`) pass
9
+ /// through unchanged; non-Hash, non-Array values are untouched.
10
+ ///
11
+ /// This is deliberately an in-place rewrite rather than a recursive rebuild:
12
+ /// `serde_magnus::serialize`'s externally-tagged output already allocates one
13
+ /// Hash per enum layer (e.g. `{"Naive" => {...}}`), so a rebuild pass would
14
+ /// double that cost by discarding and reallocating the whole tree just to
15
+ /// change key types.
16
+ ///
17
+ /// GC safety note: keys collected off the `Hash` are staged in a Ruby
18
+ /// `RArray`, never a Rust-native `Vec<Value>`. A `Value` held only in a
19
+ /// `Vec` (heap-allocated Rust memory) is invisible to MRI's conservative
20
+ /// stack-scanning GC once it's no longer reachable from any Ruby-visible
21
+ /// root — deleting an entry from its `Hash` and stashing the freed key/value
22
+ /// `Value`s in a `Vec` across further Magnus calls (which can trigger GC) is
23
+ /// a real use-after-free, not just a style concern. Keeping them in an
24
+ /// `RArray` instead means the GC treats them as reachable for as long as
25
+ /// the array itself is reachable (it lives in a local, stack-scanned
26
+ /// `RArray` variable for the duration of this function).
27
+ pub fn symbolize_keys_in_place(ruby: &Ruby, value: Value) -> Result<(), Error> {
28
+ if let Some(hash) = RHash::from_value(value) {
29
+ let keys = ruby.ary_new();
30
+ hash.foreach(|k: Value, _v: Value| {
31
+ keys.push(k)?;
32
+ Ok(ForEach::Continue)
33
+ })?;
34
+
35
+ for i in 0..keys.len() as isize {
36
+ let k: Value = keys.entry(i)?;
37
+ // Only a String key needs the hash structure itself touched
38
+ // (delete under the old key, reinsert under the new Symbol);
39
+ // serde_magnus already emits Symbol keys for struct fields,
40
+ // so most entries just need their value recursed into.
41
+ //
42
+ // Tested with `RString::from_value` (an Option-returning type
43
+ // check) rather than `String::try_convert` (a Result-returning
44
+ // conversion): the latter builds a full TypeError — exception
45
+ // object plus its formatted message String plus the class-name
46
+ // String — for every already-Symbol key it rejects, which is most
47
+ // of them. That is three throwaway allocations per key on the
48
+ // hot path, purely to answer a question `from_value` answers for
49
+ // free.
50
+ match RString::from_value(k) {
51
+ Some(s) => {
52
+ let v: Value = hash.delete(k)?;
53
+ symbolize_keys_in_place(ruby, v)?;
54
+ // Interned via String#to_sym on the key's own VALUE
55
+ // rather than round-tripping through Rust: `as_str` +
56
+ // `to_owned` + `ruby.to_symbol` costs a Rust String copy
57
+ // plus a fresh Ruby String (magnus 0.8.2's
58
+ // `&str.into_symbol_with` is `rb_to_symbol(str_new(..))`)
59
+ // per key, where `to_sym` interns the existing String
60
+ // directly. `s` stays GC-reachable across the recursion
61
+ // above through the `keys` array (see the note on this
62
+ // function), so the deferred use is safe.
63
+ let sym: Symbol = s.funcall("to_sym", ())?;
64
+ hash.aset(sym, v)?;
65
+ }
66
+ None => {
67
+ let v: Value = hash.aref(k)?;
68
+ symbolize_keys_in_place(ruby, v)?;
69
+ }
70
+ }
71
+ }
72
+ return Ok(());
73
+ }
74
+
75
+ if let Some(arr) = RArray::from_value(value) {
76
+ for i in 0..arr.len() as isize {
77
+ let item: Value = arr.entry(i)?;
78
+ symbolize_keys_in_place(ruby, item)?;
79
+ }
80
+ }
81
+
82
+ Ok(())
83
+ }
84
+
85
+ /// Serializes `input` via `serde_magnus` and symbolizes every Hash key in
86
+ /// the result, preserving serde's externally-tagged representation verbatim
87
+ /// (e.g. `{Numeral: 42.0}`, `{Url: {value:, domain:}}`,
88
+ /// `{Temperature: {Value: {value:, unit:}}}`). The PascalCase tag key is
89
+ /// kept uniformly across every enum layer — the outer `DimensionValue` tag
90
+ /// and nested tags like `MeasurementValue`'s `Value`/`Interval` get the same
91
+ /// treatment, so consumers see one consistent tagged shape rather than a
92
+ /// mix of unwrapped and tagged layers.
93
+ ///
94
+ /// GC safety: the serialized `Value` lives in a stack-scanned local for the
95
+ /// duration of this function — see `symbolize_keys_in_place`'s note for why
96
+ /// heap-held `Vec<Value>`s are the thing to avoid.
97
+ pub fn serialize_symbolized<T>(ruby: &Ruby, input: &T) -> Result<Value, Error>
98
+ where
99
+ T: serde::Serialize + ?Sized,
100
+ {
101
+ let serialized: Value = serde_magnus::serialize(ruby, input)?;
102
+ symbolize_keys_in_place(ruby, serialized)?;
103
+ Ok(serialized)
104
+ }
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tzinfo"
4
+
5
+ module Duckling
6
+ # Which tz database `reference_zone:` resolves against, for the
7
+ # unknown-identifier error message. Behavioral because neither datasource
8
+ # exposes a version. Internal. See docs/tz-database-axis.md.
9
+ module TZInfoCapabilities
10
+ module_function
11
+
12
+ # DataSourceNotFound does not inherit from InvalidTimezoneIdentifier;
13
+ # name it explicitly. A host with no database answers false.
14
+ def backward_compat_links?
15
+ TZInfo::Timezone.get("US/Eastern")
16
+ true
17
+ rescue TZInfo::InvalidTimezoneIdentifier, TZInfo::DataSourceNotFound
18
+ false
19
+ end
20
+
21
+ def identifier_count
22
+ TZInfo::Timezone.all_identifiers.size
23
+ rescue TZInfo::DataSourceNotFound
24
+ 0
25
+ end
26
+
27
+ # Zoneinfo is detected by capability (a caller may install a subclass),
28
+ # the gem by class (loaded is not answered). Must not raise: it builds
29
+ # failure messages.
30
+ def datasource_description
31
+ source = begin
32
+ TZInfo::DataSource.get
33
+ rescue TZInfo::DataSourceNotFound
34
+ return "no tz datasource (no zoneinfo files, no tzinfo-data gem)"
35
+ end
36
+
37
+ return "system zoneinfo at #{source.zoneinfo_dir}" if source.respond_to?(:zoneinfo_dir)
38
+
39
+ if defined?(TZInfo::DataSources::RubyDataSource) && source.is_a?(TZInfo::DataSources::RubyDataSource)
40
+ version = " (tzdata #{TZInfo::Data::Version::TZDATA})" if defined?(TZInfo::Data::Version::TZDATA)
41
+ return "the tzinfo-data gem#{version}"
42
+ end
43
+
44
+ "the #{source.class} tz datasource"
45
+ end
46
+
47
+ # The remedy is phrased as a condition: only the database is checked.
48
+ # See docs/tz-database-axis.md.
49
+ def unknown_identifier_diagnosis
50
+ diagnosis = "resolved against #{datasource_description}, " \
51
+ "which provides #{identifier_count} identifiers"
52
+ return diagnosis if backward_compat_links?
53
+
54
+ "#{diagnosis}; this database has no backward-compat names (US/Eastern and ~100 others), " \
55
+ "so if that is what this is, it needs either the tzinfo-data gem or the " \
56
+ "tzdata-legacy system package"
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Duckling
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/duckling.rb CHANGED
@@ -1,4 +1,284 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tzinfo"
4
+
3
5
  require_relative "duckling/version"
4
- require_relative "duckling/duckling"
6
+ require_relative "duckling/tzinfo_capabilities"
7
+
8
+ # A precompiled gem carries one binary per Ruby ABI, each in its own
9
+ # lib/duckling/<major.minor>/ directory. Load the one for the running Ruby.
10
+ #
11
+ # rb-sys reads Ruby's internal object layout through the headers it compiles
12
+ # against, so a binary understands only the Ruby that built it. Load the wrong
13
+ # one and it misreads objects: magnus rejects a genuine Time as not a Time.
14
+ #
15
+ # A source-gem build, and a plain `rake compile` in a checkout, write one
16
+ # binary straight to lib/duckling/ instead. That build always matches the
17
+ # running Ruby, so it needs no version directory.
18
+ begin
19
+ require_relative "duckling/#{RUBY_VERSION[/\d+\.\d+/]}/duckling"
20
+ rescue LoadError => abi_load_error
21
+ begin
22
+ require_relative "duckling/duckling"
23
+ rescue LoadError
24
+ # The ABI directory is missing, and so is the plain path. Raise the first
25
+ # error.
26
+ #
27
+ # A binary that exists but refuses to load fails here too, and its error
28
+ # says why. An Alpine install does this: RubyGems matches an unversioned
29
+ # -linux gem against a musl runtime, so the binary is present and asks for
30
+ # glibc. Reporting a missing file there would hide the real reason.
31
+ raise abi_load_error
32
+ end
33
+ end
34
+
35
+ module Duckling
36
+ # Raised when the serialized :time :value shape drifts from the typed walk in
37
+ # ext/duckling/src/lib.rs's patch_time_value/patch_time_point — a wrapper bug.
38
+ # Deliberately a RuntimeError subclass (not ArgumentError) so a caller
39
+ # rescuing ArgumentError around bad locale:/reference_zone: input can't
40
+ # swallow it, and a *named* one (not bare RuntimeError) so it's greppable and
41
+ # can't be satisfied by the unrelated native-panic RuntimeError. Mirrors the
42
+ # internal_error() class in lib.rs.
43
+ class ShapeError < RuntimeError; end
44
+
45
+ # Raised when `reference_zone:` is given on a host with no tz database at
46
+ # all (no zoneinfo files, no tzinfo-data gem). Deliberately outside
47
+ # ArgumentError: it reports the deployment, so input-validation rescues do
48
+ # not swallow it. See docs/tz-database-axis.md.
49
+ class TZDataUnavailable < RuntimeError; end
50
+
51
+ # Native.parse already releases the GVL around the native call, but a bare
52
+ # GVL release alone does not hand control back to an Async::Reactor —
53
+ # Ruby 3.4's Fiber::Scheduler#blocking_operation_wait auto-offload path
54
+ # requires a flag rb_thread_call_without_gvl never sets. Spawning a real
55
+ # background Thread lets the calling Fiber yield to the reactor via
56
+ # Thread#value's block/unblock scheduler hooks instead, which have been
57
+ # present since Ruby 3.0. See
58
+ # https://github.com/cpb/duckling/wiki/research-fiber-scheduler-mechanism-spike
59
+ # for the empirical result driving this.
60
+ #
61
+ # Only worth paying for when a Fiber scheduler is actually installed on the
62
+ # calling thread: a plain thread pool (Puma/Sidekiq-style, no reactor to
63
+ # yield to) already gets its concurrency from Native.parse's own GVL
64
+ # release, so the extra Thread.new there is a pure spawn+join tax. Calling
65
+ # Native.parse directly (no thread) is also the benchmark suite's baseline
66
+ # for measuring the dispatch overhead itself.
67
+ #
68
+ # report_on_exception is disabled from the very first line inside the
69
+ # spawned thread (not set on the Thread object afterward, which would race
70
+ # a fast-failing call) so a rescued error doesn't also print a
71
+ # thread-termination backtrace to stderr — Thread#value still re-raises it
72
+ # to the caller as ordinary control flow.
73
+ #
74
+ # reference_time: is coerced here because the native extension cannot:
75
+ # Native.parse's Magnus binding only accepts a strict kind_of?(Time) (issue
76
+ # #45), which rejects ActiveSupport::TimeWithZone and stdlib DateTime even
77
+ # though both carry the same to_i/utc_offset a real Time does — #to_time
78
+ # normalizes any of those (and anything else that offers the same
79
+ # conversion) to a real Time before it crosses into Rust.
80
+ # reference_zone: never crosses into Native.parse — the wrapped Rust crate
81
+ # has no IANA-zone concept at all, only the single FixedOffset it derives
82
+ # from reference_time:. Per-date-correct offsets therefore have to come from
83
+ # a real tz database on the Ruby side, so reference_zone: is applied as a
84
+ # two-part step around the native call: validate the zone (and reference_time:'s
85
+ # agreement with it) before, then reinterpret Naive results after.
86
+ #
87
+ # A fixed offset and a zone that disagree at the reference instant have no
88
+ # principled resolution — silently preferring either would resolve results
89
+ # against an offset the caller never asked for — so that combination raises.
90
+ #
91
+ # reference_zone: only reinterprets result offsets after the fact; it does
92
+ # NOT anchor the parse. Given without reference_time:, relative expressions
93
+ # ("tomorrow") still anchor on the machine-local clock. They do not anchor
94
+ # on "now" in that zone, so on a US host reference_zone: "Asia/Tokyo" can land on the wrong
95
+ # calendar day. Pass a reference_time: in the zone to anchor as well.
96
+ def self.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)
97
+ reference_time = reference_time.to_time if reference_time && !reference_time.is_a?(Time) && reference_time.respond_to?(:to_time)
98
+
99
+ if reference_zone
100
+ zone = timezone_for(reference_zone)
101
+ verify_reference_time_offset!(reference_time, zone, reference_zone) if reference_time
102
+ end
103
+
104
+ kwargs = {locale: locale, dims: dims, with_latent: with_latent}
105
+ kwargs[:reference_time] = reference_time if reference_time
106
+
107
+ entities = if Fiber.scheduler
108
+ Thread.new do
109
+ Thread.current.report_on_exception = false
110
+ Native.parse(text, **kwargs)
111
+ end.value
112
+ else
113
+ Native.parse(text, **kwargs)
114
+ end
115
+
116
+ zone ? reinterpret_entities!(entities, zone) : entities
117
+ end
118
+
119
+ # Reinterprets every TimePoint::Naive (wall-clock) leaf of each :time entity
120
+ # against `reference_zone`, using the real IANA offset for that leaf's own
121
+ # date. reference_time: carries a single fixed offset, which cannot be right
122
+ # for every leaf.
123
+ #
124
+ # TimePoint::Instant leaves are left strictly alone: the wrapped crate
125
+ # already collapsed their relative arithmetic against one FixedOffset before
126
+ # this gem ever saw the result, so there is no wall-clock left to reinterpret.
127
+ # That arithmetic's DST imprecision is known and out of scope (issue #83).
128
+ #
129
+ # Walks the externally-tagged shape ext/duckling/src/lib.rs's patch_time_value
130
+ # produces, and raises on any tag it doesn't recognize: a shape drift on the
131
+ # Rust side must fail loudly here. The outcome to prevent is quiet results
132
+ # resolved against the wrong offset.
133
+ def self.apply_reference_zone(entities, reference_zone)
134
+ return entities unless reference_zone
135
+
136
+ reinterpret_entities!(entities, timezone_for(reference_zone))
137
+ end
138
+
139
+ # Zone-object core of apply_reference_zone. parse calls this directly with
140
+ # the TZInfo::Timezone it already resolved for offset validation, so the
141
+ # zone is looked up once per call. Validation and reinterpretation share the
142
+ # lookup.
143
+ def self.reinterpret_entities!(entities, zone)
144
+ entities.each do |entity|
145
+ next unless entity[:dim] == :time
146
+ reinterpret_time_value!(entity[:value][:Time], zone)
147
+ end
148
+ entities
149
+ end
150
+ private_class_method :reinterpret_entities!
151
+
152
+ # Walks the Single/Interval + Naive/Instant tagged shape. A primary value
153
+ # (single[:value], or an interval's from/to) and every `values` recurrence
154
+ # entry are resolved identically — see local_time_in_zone.
155
+ def self.reinterpret_time_value!(value, zone)
156
+ if (single = value && value[:Single])
157
+ reinterpret_time_point!(single[:value], zone)
158
+ single[:values]&.each { |point| reinterpret_time_point!(point, zone) }
159
+ elsif (interval = value && value[:Interval])
160
+ reinterpret_interval_endpoints!(interval, zone)
161
+ interval[:values]&.each { |endpoints| reinterpret_interval_endpoints!(endpoints, zone) }
162
+ else
163
+ raise ShapeError, "unrecognized :time value shape, expected a :Single or :Interval tag: #{value.inspect}"
164
+ end
165
+ end
166
+ private_class_method :reinterpret_time_value!
167
+
168
+ # An Interval's from/to are Option<TimePoint> on the Rust side, and serde
169
+ # emits Option::None as a present key holding nil — hence the nil tolerance
170
+ # in reinterpret_time_point!. A missing-key check here would reject that shape.
171
+ def self.reinterpret_interval_endpoints!(endpoints, zone)
172
+ reinterpret_time_point!(endpoints[:from], zone)
173
+ reinterpret_time_point!(endpoints[:to], zone)
174
+ end
175
+ private_class_method :reinterpret_interval_endpoints!
176
+
177
+ def self.reinterpret_time_point!(point, zone)
178
+ return if point.nil?
179
+
180
+ if (naive = point[:Naive])
181
+ naive[:value] = local_time_in_zone(zone, naive[:value])
182
+ elsif !point.key?(:Instant)
183
+ raise ShapeError, "unrecognized TimePoint shape, expected a :Naive or :Instant tag: #{point.inspect}"
184
+ end
185
+ end
186
+ private_class_method :reinterpret_time_point!
187
+
188
+ # The Rust side already resolved this Naive wall-clock against reference_time:'s
189
+ # fixed offset, so the Time's own calendar fields still read as that intended
190
+ # wall-clock — re-anchoring those same fields in `zone` is what picks up the
191
+ # per-date DST offset.
192
+ #
193
+ # A wall-clock that a spring-forward gap skipped, or that a fall-back overlap
194
+ # made ambiguous, has no single correct offset — but there's no benefit to
195
+ # raising over it either, whether the value is a primary one the caller
196
+ # literally named or a generated recurrence entry: both get the same
197
+ # deterministic resolution — a gap shifts the wall clock forward by the
198
+ # transition's delta (02:30 on a US spring-forward day becomes 03:30 EDT),
199
+ # an overlap takes the first (pre-transition) occurrence.
200
+ #
201
+ # The first occurrence is selected via the block form (periods_for_local
202
+ # yields periods in chronological order), NOT tzinfo's dst flag: dst=true
203
+ # only means "first" where the pre-transition period observes DST, and
204
+ # negative-DST zones invert that — Europe/Dublin models winter GMT as its
205
+ # dst?==true period, so dst=true there would pick the second occurrence, an
206
+ # hour off as an instant. (ActiveSupport::TimeZone#local has exactly that
207
+ # Dublin behavior, via period_for_local's dst=true default — a deliberate
208
+ # departure here, like the Lord Howe one on gap_delta.) The explicit nil dst
209
+ # argument keeps a global Timezone.default_dst setting from pre-filtering
210
+ # the periods before the block sees them.
211
+ #
212
+ # Preserving the gap's original wall clock and merely stamping the
213
+ # post-transition offset on it would produce a Time whose offset the zone
214
+ # does not observe at that instant — 02:30 -04:00 is 06:30Z, and at 06:30Z
215
+ # New York is still on EST — so it would read back as 01:30 EST, an hour
216
+ # before the occurrence it stands for and on the wrong side of the
217
+ # transition. Shifting forward keeps the instant and its rendered local
218
+ # time in agreement.
219
+ def self.local_time_in_zone(zone, time)
220
+ first_occurrence = lambda do |t|
221
+ zone.local_time(t.year, t.month, t.day, t.hour, t.min, t.sec, t.subsec, nil) do |periods|
222
+ periods.first
223
+ end
224
+ end
225
+ first_occurrence.call(time)
226
+ rescue TZInfo::PeriodNotFound
227
+ first_occurrence.call(time + gap_delta(zone, time))
228
+ end
229
+ private_class_method :local_time_in_zone
230
+
231
+ # How wide the spring-forward gap `time` fell into is — i.e. how far forward
232
+ # a skipped wall clock must move to land on a real one. DST transitions are
233
+ # months apart, so the single offset-increasing transition within a day of
234
+ # `time` is necessarily the one whose gap it landed in.
235
+ #
236
+ # The ±1-day scan window is centered on the skipped wall clock itself read
237
+ # as UTC. The UTC midnight of its date would be the wrong anchor. The transition's UTC instant
238
+ # is the wall clock minus a zone offset, and offsets never reach a day, so
239
+ # this window always contains it; a midnight-anchored window does not. A
240
+ # gap late in the local day in a negative-offset zone (America/Nuuk springs
241
+ # forward at 23:00 local) has its transition instant past the *next* UTC
242
+ # midnight, outside a midnight-anchored window — `find` returned nil and
243
+ # this method crashed.
244
+ #
245
+ # Read from the transition. Do not assume 3600. ActiveSupport's
246
+ # TimeWithZone#get_period_and_ensure_valid_local_time instead hardcodes
247
+ # `@time += 1.hour` and retries: for Australia/Lord_Howe's 30-minute gap
248
+ # that lands half an hour past the gap's end (02:15 → 03:15; the correct
249
+ # answer is 02:45). Every one-hour gap — i.e. every zone in current use but Lord Howe
250
+ # — resolves identically either way.
251
+ def self.gap_delta(zone, time)
252
+ wall_clock = Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec)
253
+ transition = zone.transitions_up_to(wall_clock + 86400, wall_clock - 86400)
254
+ .find { |t| t.offset.observed_utc_offset > t.previous_offset.observed_utc_offset }
255
+ transition.offset.observed_utc_offset - transition.previous_offset.observed_utc_offset
256
+ end
257
+ private_class_method :gap_delta
258
+
259
+ def self.timezone_for(reference_zone)
260
+ TZInfo::Timezone.get(reference_zone)
261
+ rescue TZInfo::InvalidTimezoneIdentifier
262
+ raise ArgumentError,
263
+ "invalid reference_zone: #{reference_zone.inspect} " \
264
+ "(#{TZInfoCapabilities.unknown_identifier_diagnosis})"
265
+ rescue TZInfo::DataSourceNotFound => error
266
+ # tzinfo raises this before any identifier lookup, so restate it in
267
+ # terms of the keyword the caller passed, naming both fixes.
268
+ raise TZDataUnavailable,
269
+ "cannot resolve reference_zone: #{reference_zone.inspect} — this host has no tz database. " \
270
+ "Add `gem \"tzinfo-data\"` to bundle the IANA data with your app, or install the system " \
271
+ "tzdata package to provide zoneinfo files. (#{error.message.lines.first.to_s.strip})"
272
+ end
273
+ private_class_method :timezone_for
274
+
275
+ def self.verify_reference_time_offset!(reference_time, zone, reference_zone)
276
+ zone_offset = zone.period_for(reference_time).observed_utc_offset
277
+ return if reference_time.utc_offset == zone_offset
278
+
279
+ raise ArgumentError,
280
+ "reference_time's utc_offset (#{reference_time.utc_offset}) does not match " \
281
+ "reference_zone #{reference_zone.inspect}'s utc_offset (#{zone_offset}) at that instant"
282
+ end
283
+ private_class_method :verify_reference_time_offset!
284
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duckling
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Caleb Buxton
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -16,28 +16,70 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.9.39
19
+ version: 0.9.128
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.9.39
26
+ version: 0.9.128
27
+ - !ruby/object:Gem::Dependency
28
+ name: tzinfo
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rake-compiler
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
45
  - - "~>"
32
46
  - !ruby/object:Gem::Version
33
- version: 1.2.0
47
+ version: 1.3.1
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.3.1
55
+ - !ruby/object:Gem::Dependency
56
+ name: benchmark-ips
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: async
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.41'
34
76
  type: :development
35
77
  prerelease: false
36
78
  version_requirements: !ruby/object:Gem::Requirement
37
79
  requirements:
38
80
  - - "~>"
39
81
  - !ruby/object:Gem::Version
40
- version: 1.2.0
82
+ version: '2.41'
41
83
  description: Duckling NER without an HTTP service for Ruby
42
84
  email:
43
85
  - me@cpb.ca
@@ -46,16 +88,25 @@ extensions:
46
88
  - ext/duckling/extconf.rb
47
89
  extra_rdoc_files: []
48
90
  files:
91
+ - ".claude/settings.json"
92
+ - AGENTS.md
93
+ - Brewfile
49
94
  - CHANGELOG.md
95
+ - CLAUDE.md
50
96
  - CODE_OF_CONDUCT.md
97
+ - Cargo.lock
98
+ - Cargo.toml
51
99
  - LICENSE.txt
52
100
  - README.md
53
101
  - Rakefile
54
- - ext/duckling/Cargo.lock
102
+ - docs/2026-07-01-roadmap.md
103
+ - docs/tz-database-axis.md
55
104
  - ext/duckling/Cargo.toml
56
105
  - ext/duckling/extconf.rb
57
106
  - ext/duckling/src/lib.rs
107
+ - ext/duckling/src/ruby_value.rs
58
108
  - lib/duckling.rb
109
+ - lib/duckling/tzinfo_capabilities.rb
59
110
  - lib/duckling/version.rb
60
111
  homepage: https://github.com/cpb/duckling
61
112
  licenses: