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.
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Duckling
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/duckling.rb CHANGED
@@ -1,4 +1,268 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tzinfo"
4
+
3
5
  require_relative "duckling/version"
4
- require_relative "duckling/duckling"
6
+
7
+ # A precompiled gem carries one binary per Ruby ABI, each in its own
8
+ # lib/duckling/<major.minor>/ directory. Load the one for the running Ruby.
9
+ #
10
+ # rb-sys reads Ruby's internal object layout through the headers it compiles
11
+ # against, so a binary understands only the Ruby that built it. Load the wrong
12
+ # one and it misreads objects: magnus rejects a genuine Time as not a Time.
13
+ #
14
+ # A source-gem build, and a plain `rake compile` in a checkout, write one
15
+ # binary straight to lib/duckling/ instead. That build always matches the
16
+ # running Ruby, so it needs no version directory.
17
+ begin
18
+ require_relative "duckling/#{RUBY_VERSION[/\d+\.\d+/]}/duckling"
19
+ rescue LoadError => abi_load_error
20
+ begin
21
+ require_relative "duckling/duckling"
22
+ rescue LoadError
23
+ # The ABI directory is missing, and so is the plain path. Raise the first
24
+ # error, not the second.
25
+ #
26
+ # A binary that exists but refuses to load fails here too, and its error
27
+ # says why. An Alpine install does this: RubyGems matches an unversioned
28
+ # -linux gem against a musl runtime, so the binary is present and asks for
29
+ # glibc. Reporting a missing file there would hide the real reason.
30
+ raise abi_load_error
31
+ end
32
+ end
33
+
34
+ module Duckling
35
+ # Raised when the serialized :time :value shape drifts from the typed walk in
36
+ # ext/duckling/src/lib.rs's patch_time_value/patch_time_point — a wrapper bug.
37
+ # Deliberately a RuntimeError subclass (not ArgumentError) so a caller
38
+ # rescuing ArgumentError around bad locale:/reference_zone: input can't
39
+ # swallow it, and a *named* one (not bare RuntimeError) so it's greppable and
40
+ # can't be satisfied by the unrelated native-panic RuntimeError. Mirrors the
41
+ # internal_error() class in lib.rs.
42
+ class ShapeError < RuntimeError; end
43
+
44
+ # Native.parse already releases the GVL around the native call, but a bare
45
+ # GVL release alone does not hand control back to an Async::Reactor —
46
+ # Ruby 3.4's Fiber::Scheduler#blocking_operation_wait auto-offload path
47
+ # requires a flag rb_thread_call_without_gvl never sets. Spawning a real
48
+ # background Thread lets the calling Fiber yield to the reactor via
49
+ # Thread#value's block/unblock scheduler hooks instead, which have been
50
+ # present since Ruby 3.0. See
51
+ # https://github.com/cpb/duckling/wiki/research-fiber-scheduler-mechanism-spike
52
+ # for the empirical result driving this.
53
+ #
54
+ # Only worth paying for when a Fiber scheduler is actually installed on the
55
+ # calling thread: a plain thread pool (Puma/Sidekiq-style, no reactor to
56
+ # yield to) already gets its concurrency from Native.parse's own GVL
57
+ # release, so the extra Thread.new there is a pure spawn+join tax. Calling
58
+ # Native.parse directly (no thread) is also the benchmark suite's baseline
59
+ # for measuring the dispatch overhead itself.
60
+ #
61
+ # report_on_exception is disabled from the very first line inside the
62
+ # spawned thread (not set on the Thread object afterward, which would race
63
+ # a fast-failing call) so a rescued error doesn't also print a
64
+ # thread-termination backtrace to stderr — Thread#value still re-raises it
65
+ # to the caller as ordinary control flow.
66
+ #
67
+ # reference_time: is coerced here, not in the native extension:
68
+ # Native.parse's Magnus binding only accepts a strict kind_of?(Time) (issue
69
+ # #45), which rejects ActiveSupport::TimeWithZone and stdlib DateTime even
70
+ # though both carry the same to_i/utc_offset a real Time does — #to_time
71
+ # normalizes any of those (and anything else that offers the same
72
+ # conversion) to a real Time before it crosses into Rust.
73
+ # reference_zone: never crosses into Native.parse — the wrapped Rust crate
74
+ # has no IANA-zone concept at all, only the single FixedOffset it derives
75
+ # from reference_time:. Per-date-correct offsets therefore have to come from
76
+ # a real tz database on the Ruby side, so reference_zone: is applied as a
77
+ # two-part step around the native call: validate the zone (and reference_time:'s
78
+ # agreement with it) before, then reinterpret Naive results after.
79
+ #
80
+ # A fixed offset and a zone that disagree at the reference instant have no
81
+ # principled resolution — silently preferring either would resolve results
82
+ # against an offset the caller never asked for — so that combination raises
83
+ # rather than guessing.
84
+ #
85
+ # reference_zone: only reinterprets result offsets after the fact; it does
86
+ # NOT anchor the parse. Given without reference_time:, relative expressions
87
+ # ("tomorrow") still anchor on the machine-local clock, not on "now" in that
88
+ # zone, so on a US host reference_zone: "Asia/Tokyo" can land on the wrong
89
+ # calendar day. Pass a reference_time: in the zone to anchor as well.
90
+ def self.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)
91
+ reference_time = reference_time.to_time if reference_time && !reference_time.is_a?(Time) && reference_time.respond_to?(:to_time)
92
+
93
+ if reference_zone
94
+ zone = timezone_for(reference_zone)
95
+ verify_reference_time_offset!(reference_time, zone, reference_zone) if reference_time
96
+ end
97
+
98
+ kwargs = {locale: locale, dims: dims, with_latent: with_latent}
99
+ kwargs[:reference_time] = reference_time if reference_time
100
+
101
+ entities = if Fiber.scheduler
102
+ Thread.new do
103
+ Thread.current.report_on_exception = false
104
+ Native.parse(text, **kwargs)
105
+ end.value
106
+ else
107
+ Native.parse(text, **kwargs)
108
+ end
109
+
110
+ zone ? reinterpret_entities!(entities, zone) : entities
111
+ end
112
+
113
+ # Reinterprets every TimePoint::Naive (wall-clock) leaf of each :time entity
114
+ # against `reference_zone`, using the real IANA offset for that leaf's own
115
+ # date rather than the single fixed offset reference_time: carries.
116
+ #
117
+ # TimePoint::Instant leaves are left strictly alone: the wrapped crate
118
+ # already collapsed their relative arithmetic against one FixedOffset before
119
+ # this gem ever saw the result, so there is no wall-clock left to reinterpret.
120
+ # That arithmetic's DST imprecision is known and out of scope (issue #83).
121
+ #
122
+ # Walks the externally-tagged shape ext/duckling/src/lib.rs's patch_time_value
123
+ # produces, and raises on any tag it doesn't recognize: a shape drift on the
124
+ # Rust side must fail loudly here rather than quietly returning results
125
+ # resolved against the wrong offset.
126
+ def self.apply_reference_zone(entities, reference_zone)
127
+ return entities unless reference_zone
128
+
129
+ reinterpret_entities!(entities, timezone_for(reference_zone))
130
+ end
131
+
132
+ # Zone-object core of apply_reference_zone. parse calls this directly with
133
+ # the TZInfo::Timezone it already resolved for offset validation, so the
134
+ # zone is looked up once per call rather than once for validation and again
135
+ # for reinterpretation.
136
+ def self.reinterpret_entities!(entities, zone)
137
+ entities.each do |entity|
138
+ next unless entity[:dim] == :time
139
+ reinterpret_time_value!(entity[:value][:Time], zone)
140
+ end
141
+ entities
142
+ end
143
+ private_class_method :reinterpret_entities!
144
+
145
+ # Walks the Single/Interval + Naive/Instant tagged shape. A primary value
146
+ # (single[:value], or an interval's from/to) and every `values` recurrence
147
+ # entry are resolved identically — see local_time_in_zone.
148
+ def self.reinterpret_time_value!(value, zone)
149
+ if (single = value && value[:Single])
150
+ reinterpret_time_point!(single[:value], zone)
151
+ single[:values]&.each { |point| reinterpret_time_point!(point, zone) }
152
+ elsif (interval = value && value[:Interval])
153
+ reinterpret_interval_endpoints!(interval, zone)
154
+ interval[:values]&.each { |endpoints| reinterpret_interval_endpoints!(endpoints, zone) }
155
+ else
156
+ raise ShapeError, "unrecognized :time value shape, expected a :Single or :Interval tag: #{value.inspect}"
157
+ end
158
+ end
159
+ private_class_method :reinterpret_time_value!
160
+
161
+ # An Interval's from/to are Option<TimePoint> on the Rust side, and serde
162
+ # emits Option::None as a present key holding nil — hence the nil tolerance
163
+ # in reinterpret_time_point!, rather than a missing-key check here.
164
+ def self.reinterpret_interval_endpoints!(endpoints, zone)
165
+ reinterpret_time_point!(endpoints[:from], zone)
166
+ reinterpret_time_point!(endpoints[:to], zone)
167
+ end
168
+ private_class_method :reinterpret_interval_endpoints!
169
+
170
+ def self.reinterpret_time_point!(point, zone)
171
+ return if point.nil?
172
+
173
+ if (naive = point[:Naive])
174
+ naive[:value] = local_time_in_zone(zone, naive[:value])
175
+ elsif !point.key?(:Instant)
176
+ raise ShapeError, "unrecognized TimePoint shape, expected a :Naive or :Instant tag: #{point.inspect}"
177
+ end
178
+ end
179
+ private_class_method :reinterpret_time_point!
180
+
181
+ # The Rust side already resolved this Naive wall-clock against reference_time:'s
182
+ # fixed offset, so the Time's own calendar fields still read as that intended
183
+ # wall-clock — re-anchoring those same fields in `zone` is what picks up the
184
+ # per-date DST offset.
185
+ #
186
+ # A wall-clock that a spring-forward gap skipped, or that a fall-back overlap
187
+ # made ambiguous, has no single correct offset — but there's no benefit to
188
+ # raising over it either, whether the value is a primary one the caller
189
+ # literally named or a generated recurrence entry: both get the same
190
+ # deterministic resolution — a gap shifts the wall clock forward by the
191
+ # transition's delta (02:30 on a US spring-forward day becomes 03:30 EDT),
192
+ # an overlap takes the first (pre-transition) occurrence.
193
+ #
194
+ # The first occurrence is selected via the block form (periods_for_local
195
+ # yields periods in chronological order), NOT tzinfo's dst flag: dst=true
196
+ # only means "first" where the pre-transition period observes DST, and
197
+ # negative-DST zones invert that — Europe/Dublin models winter GMT as its
198
+ # dst?==true period, so dst=true there would pick the second occurrence, an
199
+ # hour off as an instant. (ActiveSupport::TimeZone#local has exactly that
200
+ # Dublin behavior, via period_for_local's dst=true default — a deliberate
201
+ # departure here, like the Lord Howe one on gap_delta.) The explicit nil dst
202
+ # argument keeps a global Timezone.default_dst setting from pre-filtering
203
+ # the periods before the block sees them.
204
+ #
205
+ # Preserving the gap's original wall clock and merely stamping the
206
+ # post-transition offset on it would produce a Time whose offset the zone
207
+ # does not observe at that instant — 02:30 -04:00 is 06:30Z, and at 06:30Z
208
+ # New York is still on EST — so it would read back as 01:30 EST, an hour
209
+ # before the occurrence it stands for and on the wrong side of the
210
+ # transition. Shifting forward keeps the instant and its rendered local
211
+ # time in agreement.
212
+ def self.local_time_in_zone(zone, time)
213
+ first_occurrence = lambda do |t|
214
+ zone.local_time(t.year, t.month, t.day, t.hour, t.min, t.sec, t.subsec, nil) do |periods|
215
+ periods.first
216
+ end
217
+ end
218
+ first_occurrence.call(time)
219
+ rescue TZInfo::PeriodNotFound
220
+ first_occurrence.call(time + gap_delta(zone, time))
221
+ end
222
+ private_class_method :local_time_in_zone
223
+
224
+ # How wide the spring-forward gap `time` fell into is — i.e. how far forward
225
+ # a skipped wall clock must move to land on a real one. DST transitions are
226
+ # months apart, so the single offset-increasing transition within a day of
227
+ # `time` is necessarily the one whose gap it landed in.
228
+ #
229
+ # The ±1-day scan window is centered on the skipped wall clock itself read
230
+ # as UTC — not on the UTC midnight of its date. The transition's UTC instant
231
+ # is the wall clock minus a zone offset, and offsets never reach a day, so
232
+ # this window always contains it; a midnight-anchored window does not. A
233
+ # gap late in the local day in a negative-offset zone (America/Nuuk springs
234
+ # forward at 23:00 local) has its transition instant past the *next* UTC
235
+ # midnight, outside a midnight-anchored window — `find` returned nil and
236
+ # this method crashed instead of resolving.
237
+ #
238
+ # Read from the transition rather than assumed to be 3600. ActiveSupport's
239
+ # TimeWithZone#get_period_and_ensure_valid_local_time instead hardcodes
240
+ # `@time += 1.hour` and retries: for Australia/Lord_Howe's 30-minute gap
241
+ # that lands half an hour past the gap's end (02:15 → 03:15 rather than
242
+ # 02:45). Every one-hour gap — i.e. every zone in current use but Lord Howe
243
+ # — resolves identically either way.
244
+ def self.gap_delta(zone, time)
245
+ wall_clock = Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec)
246
+ transition = zone.transitions_up_to(wall_clock + 86400, wall_clock - 86400)
247
+ .find { |t| t.offset.observed_utc_offset > t.previous_offset.observed_utc_offset }
248
+ transition.offset.observed_utc_offset - transition.previous_offset.observed_utc_offset
249
+ end
250
+ private_class_method :gap_delta
251
+
252
+ def self.timezone_for(reference_zone)
253
+ TZInfo::Timezone.get(reference_zone)
254
+ rescue TZInfo::InvalidTimezoneIdentifier
255
+ raise ArgumentError, "invalid reference_zone: #{reference_zone.inspect}"
256
+ end
257
+ private_class_method :timezone_for
258
+
259
+ def self.verify_reference_time_offset!(reference_time, zone, reference_zone)
260
+ zone_offset = zone.period_for(reference_time).observed_utc_offset
261
+ return if reference_time.utc_offset == zone_offset
262
+
263
+ raise ArgumentError,
264
+ "reference_time's utc_offset (#{reference_time.utc_offset}) does not match " \
265
+ "reference_zone #{reference_zone.inspect}'s utc_offset (#{zone_offset}) at that instant"
266
+ end
267
+ private_class_method :verify_reference_time_offset!
268
+ 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.3.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-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -16,28 +16,84 @@ 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'
41
+ - !ruby/object:Gem::Dependency
42
+ name: tzinfo-data
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.2024'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.2024'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: rake-compiler
29
57
  requirement: !ruby/object:Gem::Requirement
30
58
  requirements:
31
59
  - - "~>"
32
60
  - !ruby/object:Gem::Version
33
- version: 1.2.0
61
+ version: 1.3.1
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.3.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: benchmark-ips
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: async
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.41'
34
90
  type: :development
35
91
  prerelease: false
36
92
  version_requirements: !ruby/object:Gem::Requirement
37
93
  requirements:
38
94
  - - "~>"
39
95
  - !ruby/object:Gem::Version
40
- version: 1.2.0
96
+ version: '2.41'
41
97
  description: Duckling NER without an HTTP service for Ruby
42
98
  email:
43
99
  - me@cpb.ca
@@ -46,15 +102,22 @@ extensions:
46
102
  - ext/duckling/extconf.rb
47
103
  extra_rdoc_files: []
48
104
  files:
105
+ - ".claude/settings.json"
106
+ - AGENTS.md
107
+ - Brewfile
49
108
  - CHANGELOG.md
109
+ - CLAUDE.md
50
110
  - CODE_OF_CONDUCT.md
111
+ - Cargo.lock
112
+ - Cargo.toml
51
113
  - LICENSE.txt
52
114
  - README.md
53
115
  - Rakefile
54
- - ext/duckling/Cargo.lock
116
+ - docs/2026-07-01-roadmap.md
55
117
  - ext/duckling/Cargo.toml
56
118
  - ext/duckling/extconf.rb
57
119
  - ext/duckling/src/lib.rs
120
+ - ext/duckling/src/ruby_value.rs
58
121
  - lib/duckling.rb
59
122
  - lib/duckling/version.rb
60
123
  homepage: https://github.com/cpb/duckling