flexor 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 957721d918f235f01e558af4a5303fd07a6e92ca70d0ef11b49d2fb58ea9ccfe
4
- data.tar.gz: 32dd414daa542578908e01fd737f932a5a01ec886571bde813cf5634d82fe7e9
3
+ metadata.gz: 9f8b7ac8e70f6731e117cdb6682c20a503749b202fa61b6dad9d2e89334fefa5
4
+ data.tar.gz: 0d9fc1b7946a7fc7ec5ccae87502d132864ac2993812bdd99835d7d6926ca481
5
5
  SHA512:
6
- metadata.gz: a9082e8189768c8c073448fff223b1aaf3054d9474ac64faf8b3030fd27296c01d1f40a83356b69aa020e32b98ac4d2d0003f5eeebb8bd7652f98413033b9a4d
7
- data.tar.gz: cef0d9d8d02589775c9361f21e00e494a208555c3048270ac937ebd995a834ab5a66d075c690404feeccbbd344c8a7ae1e37ca1161ef702676c5767ffbc6b359
6
+ metadata.gz: 1f77a7de94d262e22227b41757bf14863f13fba7ab1868cdfd170985ff50b933714fcb5c0060fbc78c9f7967ab55048b82353ca3ccc4f832cf86e324f152ceac
7
+ data.tar.gz: 0a6527a96bdf205ccb9293cccfc2044c6762f3d2db2702bfdf4366854f432d4011c08c3a5cd0a1714e7fabe3dbae6710d4f1539ab66231e3c5b2c5f49d6c7943
data/.rubocop.yml CHANGED
@@ -400,6 +400,13 @@ RSpec/MultipleExpectations:
400
400
  Naming/BlockForwarding:
401
401
  Enabled: false
402
402
 
403
+ # has_key? is a standard Ruby Hash method. Renaming it would break the
404
+ # public interface contract that Flexor quacks like a Hash.
405
+ Naming/PredicatePrefix:
406
+ AllowedMethods:
407
+ - is_a?
408
+ - has_key?
409
+
403
410
  # This has bugs and is causing specs to fail when autocorrected
404
411
  Performance/RedundantMerge:
405
412
  Enabled: false
data/CLAUDE.md CHANGED
@@ -32,17 +32,27 @@ Flexor is a Ruby gem providing a Hash-like data store with autovivifying nested
32
32
 
33
33
  ## Architecture
34
34
 
35
- Single class `Flexor` in `lib/flexor.rb` with three mixins:
35
+ Plugin-based architecture following the Sequel/Roda pattern. `Flexor` class body is a minimal plugin dispatcher. All behavior lives in plugins under `Flexor::Plugins`.
36
36
 
37
- - **`Vivification`** (`lib/flexor/vivification.rb`) — recursively converts Hashes/Arrays into Flexor objects on write; reverses via `recurse_to_h` on read. The `@store` uses a `Hash.new` default block that auto-creates child Flexor nodes (autovivification).
38
- - **`HashDelegation`** (`lib/flexor/hash_delegation.rb`)delegates `keys`, `values`, `size`, `empty?`, `key?` to `@store`.
39
- - **`Serialization`** (`lib/flexor/serialization.rb`)Marshal and YAML round-trip support.
37
+ **Plugin dispatcher** (`lib/flexor.rb`):
38
+ - `Flexor.plugin(mod)` — includes `mod::StoreMethods`, extends `mod::ClassMethods`
39
+ - `Flexor.register_plugin(:name, mod)` — symbol registration for lazy loading
40
+ - Lifecycle hooks: `before_load` (dependencies), `after_load` (initialization)
41
+ - Plugins compose via Ruby's method lookup chain — each calls `super`
42
+
43
+ **Plugins:**
44
+ - **`Plugins::Core`** (`lib/flexor/plugins/core.rb`) — all base store behavior: autovivifying `@store`, method-style access via `method_missing`, hash delegation, serialization (Marshal/YAML), vivification
45
+ - **`Plugins::FlexKeys`** (`lib/flexor/plugins/flex_keys.rb`) — overrides `[]`, `[]=`, `delete`, `key?`, `set_raw`, `deconstruct_keys`, `read_via_method` to resolve camelCase/snake_case alternates before calling `super`
46
+
47
+ **Utilities:**
48
+ - **`CaseConversion`** (`lib/flexor/case_conversion.rb`) — pure `module_function` utilities: `camelize`, `underscore`, `case_counterpart`
40
49
 
41
50
  Key internals:
42
51
  - `@store` is the backing Hash with an autovivifying default block
43
52
  - `@root` flag distinguishes top-level instances from auto-created children (affects `inspect` and `nil?` behavior)
44
53
  - `method_missing` handles dynamic getter/setter; once accessed, singleton methods are cached for performance (`cache_getter`/`cache_setter`)
45
54
  - `nil?` returns `true` when `@store` is empty (non-root nodes appear nil-like until written to)
55
+ - Adding a new feature means adding a new file in `lib/flexor/plugins/` — no existing files modified
46
56
 
47
57
  ## Style Conventions (from .rubocop.yml)
48
58
 
data/Rakefile CHANGED
@@ -1,8 +1,10 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
3
  require "rubocop/rake_task"
4
+ require "gempilot/version_task"
4
5
 
5
6
  RSpec::Core::RakeTask.new(:spec)
6
7
  RuboCop::RakeTask.new
8
+ Gempilot::VersionTask.new
7
9
 
8
10
  task default: [:spec, :rubocop]
data/issues.rec CHANGED
@@ -1,24 +1,183 @@
1
1
  %rec: Issue
2
2
  %key: Id
3
3
  %typedef: Status_t enum open in_progress closed
4
- %typedef: Description_t regexp /.*/
4
+ %typedef: text_t regexp /.*/
5
5
  %type: Id uuid
6
6
  %type: Title line
7
- %type: Description Description_t
7
+ %type: Description text_t
8
8
  %type: Updated date
9
9
  %type: Status Status_t
10
10
  %auto: Id Updated
11
11
 
12
- Id: BAA28228-220B-11F1-88B7-FE6CB9572C2E
12
+ Id: 710B6B1A-5EE5-4899-BB12-E41B44DAC090
13
13
  Updated: Tue, 17 Mar 2026 10:15:19 -0400
14
14
  Title: Add constant F that is set equal to Flexor
15
15
  Description: As Flexor is built to maximize ergonomics, it would be ideal to have a shorter way to call it. In lib, there should be f.rb, where F = Flexor as the only line. It could be called like:
16
16
  + puts F[json_string].people.first.first_name
17
- +
17
+ +
18
+ Status: closed
19
+
20
+ Id: E4B0F842-0EFD-4FA9-9841-0984A905C67B
21
+ Updated: Tue, 17 Mar 2026 12:45:02 -0400
22
+ Title: Address rubocop offenses
23
+ Description: Currently 30 violations as I write this
24
+ Status: closed
25
+
26
+ Id: 573035C3-BEDE-4C1F-9F88-3FBD4E870973
27
+ Updated: Tue, 17 Mar 2026 14:40:13 -0400
28
+ Title: Auto resolve fields against ones following a different style/casing
29
+ Description: It would be useful to have the ability for flexor to auto-resolve or infer fields, like:
30
+ + 1. key `fooBar` is on data, user types `foo_bar`. Flexor returns value of `fooBar`
31
+ + 2. Vice-versa of 1
32
+ +
33
+ + Note that the behavior would be optional/opt-in
34
+ Status: closed
35
+
36
+ Id: 115686E8-6961-4B6C-9B2F-EB4DABD8A900
37
+ Updated: Tue, 17 Mar 2026 15:00:34 -0400
38
+ Title: Using F "alias" requires "require 'f'", which is undesirable
39
+ Description: The requirement of requiring 'f' for usage of "F" is undesirable as it pollutes global namespace, and is not intuitive for users that install flexor as the name of the gem. That alias should be loaded automatically when user does `require 'flexor'`.
40
+ Status: closed
41
+
42
+ Id: 19A6A0B1-D762-45D3-95E9-32545E230126
43
+ Updated: Mon, 30 Mar 2026 12:00:29 -0400
44
+ Title: Add plugin to "compile" keys into real methods
45
+ Description: Currently Flexor when used with IRB lacks good autocompletion. It would be beneficial to precompile the keys into methods such that they can be completed. There are also potential performance benefits to doing this. ActiveSupport::Configurable does something similar:
46
+ Status: open
47
+
48
+ Id: 1FC0E609-B57F-45C3-9305-EB47601B259E
49
+ Updated: Mon, 30 Mar 2026 12:01:05 -0400
50
+ Title: ActiveSupport::Configurable example (tied to previous ticket):
51
+ Description: 4 │ module ActiveSupport
52
+ + 5 │ # = Active Support \Configurable
53
+ + 6 │ # Configurable provides a <tt>config</tt> method to store and retrieve
54
+ + 7 │ # configuration options as an OrderedOptions.
55
+ + 8 │ module Configurable
56
+ + 9 │ extend ActiveSupport::Concern
57
+ + 10 │ class Configuration < ActiveSupport::InheritableOptions
58
+ + 11 │ def compile_methods\\\\\\\\\\\\!
59
+ + 12 │ self.class.compile_methods\\\\\\\\\\\\!(keys)
60
+ + 13 │ end
61
+ + 14 │ # Compiles reader methods so we don't have to go through method_missing.
62
+ + 15 │ def self.compile_methods\\\\\\\\\\\\!(keys)
63
+ + 16 │ keys.reject { |m| method_defined?(m) }.each do |key|
64
+ + 17 │ class_eval <<-RUBY, __FILE__, __LINE__ + 1
65
+ + 18 │ def #{key}; _get(#{key.inspect}); end
66
+ + 19 │ RUBY
67
+ + 20 │ end
68
+ + 21 │ end
69
+ + 22 │ end
18
70
  Status: open
19
71
 
20
- Id: A4C9FDF4-2220-11F1-BED9-FE6CB9572C2E
72
+ Id: 3B49DC55-C580-42C7-AB0D-08F0CB899B2D
73
+ Updated: Sat, 11 Apr 2026 13:30:31 -0400
74
+ Title: Create plugin to allow for the "?" method suffix
75
+ Description: For example, when doing something like
76
+ + ```ruby
77
+ + object = Flexor[mandatory: true]
78
+ +
79
+ + # CURRENT BEHAVIOR:
80
+ + #
81
+ + object.mandatory
82
+ + # => true
83
+ + #
84
+ + object.mandatory?
85
+ + # => nil
86
+ + #
87
+ +
88
+ + # DESIRED BEHAVIOR:
89
+ + #
90
+ + object.mandatory
91
+ + # => true
92
+ + #
93
+ + object.mandatory?
94
+ + # => true
95
+ + #
96
+ + ```
97
+ Status: open
98
+
99
+ Id: B0673B2E-A2A2-4FBE-BBF9-F41FA1CA3169
100
+ Updated: Tue, 17 Mar 2026 10:15:19 -0400
101
+ Title: Add constant F that is set equal to Flexor
102
+ Description: As Flexor is built to maximize ergonomics, it would be ideal to have a shorter way to call it. In lib, there should be f.rb, where F = Flexor as the only line. It could be called like:
103
+ + puts F[json_string].people.first.first_name
104
+ +
105
+ Status: closed
106
+
107
+ Id: 9938B05E-15F6-402B-AF42-7046D5B5F671
21
108
  Updated: Tue, 17 Mar 2026 12:45:02 -0400
22
109
  Title: Address rubocop offenses
23
110
  Description: Currently 30 violations as I write this
111
+ Status: closed
112
+
113
+ Id: 4BAD9D04-F360-4859-8454-B40CB80F0F85
114
+ Updated: Tue, 17 Mar 2026 14:40:13 -0400
115
+ Title: Auto resolve fields against ones following a different style/casing
116
+ Description: It would be useful to have the ability for flexor to auto-resolve or infer fields, like:
117
+ + 1. key `fooBar` is on data, user types `foo_bar`. Flexor returns value of `fooBar`
118
+ + 2. Vice-versa of 1
119
+ +
120
+ + Note that the behavior would be optional/opt-in
121
+ Status: closed
122
+
123
+ Id: B85383C2-5142-464F-81CF-7938445411E1
124
+ Updated: Tue, 17 Mar 2026 15:00:34 -0400
125
+ Title: Using F "alias" requires "require 'f'", which is undesirable
126
+ Description: The requirement of requiring 'f' for usage of "F" is undesirable as it pollutes global namespace, and is not intuitive for users that install flexor as the name of the gem. That alias should be loaded automatically when user does `require 'flexor'`.
127
+ Status: closed
128
+
129
+ Id: 67833956-1397-4820-887B-60EE975CDF46
130
+ Updated: Sat, 21 Mar 2026 22:38:19 -0400
131
+ Title: Perform spike around IRB autocompletions
132
+ Description: IRB autocompletions are incredibly useful, but flexor only exposes its own internal methods, rather than the fields for autocompletions. Some thoughts:
133
+ + 1. Method caching is already (or was) built into flexor - if methods get defined, IRB can cache them
134
+ + 2. Alternatively but more robustly, flexor could dynamically generate RBS types for the RBS type infer completor.
135
+ + 3. Either which way, the plugin system allows us to isolate this behvior and only trigger it when IRB is active
136
+ Status: open
137
+
138
+ Id: 3ED3C15C-4051-11F1-9F71-FE6CB9572C2F
139
+ Updated: Fri, 24 Apr 2026 22:48:31 -0400
140
+ Title: Conflicting nil behavior between ||=, implicit nil, and ".nil?"
141
+ Description: #Write a spec/test for the following example:
142
+ + ```ruby
143
+ + context 'when flexor has no key set' do
144
+ + describe 'setting via ||=' do
145
+ + it 'sets the attribute' do
146
+ + f = Flexor.new
147
+ + f[:foo] ||= :bar
148
+ + expect(f.foo).to eq(:bar)
149
+ + end
150
+ + end
151
+ +
152
+ + describe 'setting via implicit nil OR' do
153
+ + it 'correctly sets the attribute' do
154
+ + f = Flexor.new
155
+ + f[:foo] || f[:foo] = :bar
156
+ + expect(f.foo).to eq(:bar)
157
+ + end
158
+ + end
159
+ +
160
+ + describe 'setting via nil? OR' do
161
+ + it 'correctly sets the attribute' do
162
+ + f = Flexor.new
163
+ + f[:foo].nil? || f[:foo] = :bar
164
+ + expect(f.foo).to eq(:bar)
165
+ + end
166
+ + end
167
+ + end
168
+ + ```
169
+ Status: open
170
+
171
+ Id: 86B389D2-41A8-11F1-89B0-FE6CB9572C2F
172
+ Updated: Sun, 26 Apr 2026 15:45:49 -0400
173
+ Title: 'Coerce' error when using sum
174
+ Description: Running this code raised the error below it:
175
+ +
176
+ + ```ruby
177
+ + f.records.map(&:delta_revenue).map(&:to_f).sum
178
+ + ```
179
+ + /Users/davidgillis/.rbenv/versions/4.0.1/lib/ruby/gems/4.0.0/gems/flexor-0.1.2/lib/flexor/method_dispatch.rb:18:in "Flexor::MethodDispatch#method_missing": undefined method "coerce" for an instance of Flexor (NoMethodError)
180
+ +
181
+ + Regardless, this error looks like an internal bug and should be invisible to the user
182
+ +
24
183
  Status: open
data/lib/f.rb CHANGED
@@ -1,3 +1 @@
1
- require_relative "flexor"
2
-
3
1
  F = Flexor
@@ -0,0 +1,43 @@
1
+ class Flexor
2
+ ##
3
+ # Pure-function utilities for converting between camelCase and snake_case.
4
+ # Used by the FlexKeys plugin to compute alternate key forms.
5
+ module CaseConversion
6
+ CAMEL_BOUNDARY = /(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-z\d])(?=[A-Z])/
7
+ UNDERSCORE_SEGMENT = %r{(?:_|(/))([a-z\d]*)}
8
+
9
+ module_function
10
+
11
+ def camelize(term)
12
+ string = term.to_s.dup
13
+ return string if string.empty?
14
+
15
+ string.gsub!(UNDERSCORE_SEGMENT) do
16
+ "#{Regexp.last_match(1) && "::"}#{Regexp.last_match(2).capitalize}"
17
+ end
18
+ return string if string.empty?
19
+
20
+ string[0] = string[0].downcase
21
+ string
22
+ end
23
+
24
+ def underscore(camel_cased_word)
25
+ return camel_cased_word.to_s.dup unless /[A-Z-]/.match?(camel_cased_word)
26
+
27
+ word = camel_cased_word.to_s.dup
28
+ word.gsub!(CAMEL_BOUNDARY, "_")
29
+ word.tr!("-", "_")
30
+ word.downcase!
31
+ word
32
+ end
33
+
34
+ def case_counterpart(key)
35
+ str = key.to_s
36
+ if str.include?("_")
37
+ camelize(str).to_sym
38
+ elsif str.match?(/[A-Z]/)
39
+ underscore(str).to_sym
40
+ end
41
+ end
42
+ end
43
+ end
@@ -25,6 +25,9 @@ class Flexor
25
25
  def key?(key)
26
26
  @store.key?(key)
27
27
  end
28
- alias has_key? key?
28
+
29
+ def has_key?(key)
30
+ key?(key)
31
+ end
29
32
  end
30
33
  end
@@ -0,0 +1,49 @@
1
+ class Flexor
2
+ ##
3
+ # Handles dynamic getter/setter dispatch via method_missing and
4
+ # caches singleton methods for repeated access.
5
+ module MethodDispatch
6
+ def respond_to_missing?(_name, _include_private = false)
7
+ true
8
+ end
9
+
10
+ private
11
+
12
+ def method_missing(name, *args, &block)
13
+ return super if block
14
+
15
+ case [name, args]
16
+ in /^[^=]+=$/, [arg] then write_via_method(name, arg)
17
+ in _, [] then read_via_method(name)
18
+ else super
19
+ end
20
+ end
21
+
22
+ def write_via_method(name, arg)
23
+ key = name.to_s.chomp("=").to_sym
24
+ cache_setter(name, key)
25
+ self[key] = arg
26
+ end
27
+
28
+ def read_via_method(name)
29
+ cache_getter(name) if !frozen? && @store.key?(name)
30
+ self[name]
31
+ end
32
+
33
+ def cache_setter(name, key)
34
+ define_singleton_method(name) do |val = nil, &blk|
35
+ raise NoMethodError, "undefined method '#{name}' for #{inspect}" if blk
36
+
37
+ self[key] = val
38
+ end
39
+ end
40
+
41
+ def cache_getter(name)
42
+ define_singleton_method(name) do |*a, &blk|
43
+ raise NoMethodError, "undefined method '#{name}' for #{inspect}" if blk || !a.empty?
44
+
45
+ self[name]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,153 @@
1
+ class Flexor
2
+ module Plugins
3
+ ##
4
+ # Core plugin providing all fundamental Flexor behavior.
5
+ # Bundles Vivification, HashDelegation, Serialization, and
6
+ # MethodDispatch along with every instance and class method
7
+ # that ships with a default Flexor install.
8
+ module Core
9
+ ##
10
+ # Instance methods for the core Flexor data store.
11
+ module StoreMethods
12
+ include Flexor::Vivification
13
+ include Flexor::HashDelegation
14
+ include Flexor::Serialization
15
+ include Flexor::MethodDispatch
16
+
17
+ def initialize(hash = {}, root: true)
18
+ raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
19
+
20
+ @root = root
21
+ @store = vivify(hash)
22
+ end
23
+
24
+ def initialize_copy(original)
25
+ super
26
+ @store = @store.dup
27
+ end
28
+
29
+ def [](key)
30
+ @store[key]
31
+ end
32
+
33
+ def []=(key, value)
34
+ @store[key] = vivify_value(value)
35
+ end
36
+
37
+ def set_raw(key, value)
38
+ @store[key] = value
39
+ end
40
+
41
+ def delete(key)
42
+ @store.delete(key)
43
+ end
44
+
45
+ def clear
46
+ @store.clear
47
+ self
48
+ end
49
+
50
+ def to_ary
51
+ nil
52
+ end
53
+
54
+ def freeze
55
+ @store.freeze
56
+ super
57
+ end
58
+
59
+ def to_h
60
+ @store.each_with_object({}) do |(key, value), hash|
61
+ result = recurse_to_h(value)
62
+ hash[key] = result unless value.is_a?(Flexor) && result.nil?
63
+ end
64
+ end
65
+
66
+ def to_json(...)
67
+ require "json"
68
+ to_h.to_json(...)
69
+ end
70
+
71
+ def to_s
72
+ return "" if nil?
73
+
74
+ @store.to_s
75
+ end
76
+
77
+ def inspect
78
+ return @store.inspect if @root
79
+ return nil.inspect if @store.empty?
80
+
81
+ @store.inspect
82
+ end
83
+
84
+ def deconstruct
85
+ @store.values
86
+ end
87
+
88
+ def deconstruct_keys(keys)
89
+ return @store if keys.nil?
90
+
91
+ @store.slice(*keys)
92
+ end
93
+
94
+ def nil?
95
+ @store.empty?
96
+ end
97
+
98
+ def merge!(other)
99
+ other = other.to_h if other.is_a?(Flexor)
100
+ other.each do |key, value|
101
+ if value.is_a?(Hash) && self[key].is_a?(Flexor) && !self[key].nil?
102
+ self[key].merge!(value)
103
+ else
104
+ self[key] = value
105
+ end
106
+ end
107
+ self
108
+ end
109
+
110
+ def merge(other)
111
+ dup.merge!(other)
112
+ end
113
+
114
+ def ==(other)
115
+ case other
116
+ in nil then nil?
117
+ in Flexor then to_h == other.to_h
118
+ in Hash then to_h == other
119
+ else super
120
+ end
121
+ end
122
+
123
+ def ===(other)
124
+ other.nil? ? nil? : super
125
+ end
126
+ end
127
+
128
+ ##
129
+ # Class-level methods for the core Flexor data store.
130
+ module ClassMethods
131
+ def [](input = {})
132
+ case input
133
+ when String then from_json(input)
134
+ when Hash then new(input)
135
+ else raise ArgumentError, "expected a String or Hash, got #{input.class}"
136
+ end
137
+ end
138
+
139
+ def from_json(json)
140
+ require "json"
141
+ JSON.parse(json, symbolize_names: true)
142
+ .then { new it }
143
+ end
144
+
145
+ def ===(other)
146
+ other.is_a?(self)
147
+ end
148
+ end
149
+
150
+ Flexor.register_plugin(:core, self)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,61 @@
1
+ class Flexor
2
+ module Plugins
3
+ ##
4
+ # CamelCase/snake_case key resolution plugin. Overrides key-accepting
5
+ # methods to check for an alternate-case match when the exact key is
6
+ # not found. Composes via +super+ with Core (or any plugin below it).
7
+ module FlexKeys
8
+ ##
9
+ # Instance methods that resolve symbol keys to their alternate-case
10
+ # counterpart before delegating to the underlying store.
11
+ module StoreMethods
12
+ def [](key)
13
+ super(resolve_flex_key(key))
14
+ end
15
+
16
+ def []=(key, value)
17
+ super(resolve_flex_key(key), value)
18
+ end
19
+
20
+ def set_raw(key, value)
21
+ super(resolve_flex_key(key), value)
22
+ end
23
+
24
+ def delete(key)
25
+ super(resolve_flex_key(key))
26
+ end
27
+
28
+ def key?(key)
29
+ super(resolve_flex_key(key))
30
+ end
31
+
32
+ def deconstruct_keys(keys)
33
+ return super if keys.nil?
34
+
35
+ keys.each_with_object({}) do |key, hash|
36
+ resolved = resolve_flex_key(key)
37
+ hash[key] = @store[resolved] if @store.key?(resolved)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def resolve_flex_key(key)
44
+ return key if @store.key?(key)
45
+ return key unless key.is_a?(Symbol)
46
+
47
+ alt = CaseConversion.case_counterpart(key)
48
+ alt && @store.key?(alt) ? alt : key
49
+ end
50
+
51
+ def read_via_method(name)
52
+ resolved = resolve_flex_key(name)
53
+ cache_getter(name) if !frozen? && @store.key?(resolved)
54
+ self[name]
55
+ end
56
+ end
57
+
58
+ Flexor.register_plugin(:flex_keys, self)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,35 @@
1
+ class Flexor
2
+ module Plugins
3
+ ##
4
+ # Normalizes string keys to symbols at ingestion and access time.
5
+ # Ensures that data arriving with string keys (from JSON, YAML, or
6
+ # plain Ruby hashes) is accessible via method and symbol-bracket
7
+ # access without manual conversion.
8
+ module SymbolizeKeys
9
+ ##
10
+ # Instance methods that convert string keys to symbols on
11
+ # ingestion (+vivify+, +[]=+) and read (+[]+).
12
+ module StoreMethods
13
+ def [](key)
14
+ super(symbolize_key(key))
15
+ end
16
+
17
+ def []=(key, value)
18
+ super(symbolize_key(key), value)
19
+ end
20
+
21
+ private
22
+
23
+ def symbolize_key(key)
24
+ key.is_a?(String) ? key.to_sym : key
25
+ end
26
+
27
+ def vivify(hash)
28
+ super(hash.transform_keys { |k| symbolize_key(k) })
29
+ end
30
+ end
31
+
32
+ Flexor.register_plugin(:symbolize_keys, self)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ class Flexor
2
+ ##
3
+ # Namespace for Flexor plugins. Each plugin is a module containing
4
+ # optional +StoreMethods+ and +ClassMethods+ submodules.
5
+ module Plugins
6
+ ##
7
+ # Class-level methods for registering and loading plugins.
8
+ # Extended onto Flexor to provide +.plugin+ and +.register_plugin+.
9
+ module Dispatcher
10
+ def register_plugin(symbol, mod)
11
+ Flexor.plugin_registry[symbol] = mod
12
+ end
13
+
14
+ def plugin(mod, ...)
15
+ mod = resolve_plugin(mod)
16
+ mod.before_load(self, ...) if mod.respond_to?(:before_load)
17
+
18
+ include(mod::StoreMethods) if defined?(mod::StoreMethods)
19
+ extend(mod::ClassMethods) if defined?(mod::ClassMethods)
20
+
21
+ mod.after_load(self, ...) if mod.respond_to?(:after_load)
22
+ nil
23
+ end
24
+
25
+ def plugin_registry
26
+ @plugin_registry ||= {}
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_plugin(mod)
32
+ return mod unless mod.is_a?(Symbol)
33
+
34
+ Flexor.plugin_registry.fetch(mod)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -18,13 +18,18 @@ class Flexor
18
18
 
19
19
  def init_with(coder)
20
20
  @root = coder["root"]
21
- @store = vivify(symbolize_keys(coder["store"] || {}))
21
+
22
+ @store = coder.then { it["store"] || {} }
23
+ .then { symbolize_keys it }
24
+ .then { vivify it }
22
25
  end
23
26
 
24
27
  private
25
28
 
26
29
  def symbolize_keys(hash)
27
- hash.transform_keys(&:to_sym).transform_values do |v|
30
+ hash
31
+ .transform_keys(&:to_sym)
32
+ .transform_values do |v|
28
33
  v.is_a?(Hash) ? symbolize_keys(v) : v
29
34
  end
30
35
  end
@@ -1,3 +1,3 @@
1
1
  class Flexor
2
- VERSION = "0.1.1".freeze
2
+ VERSION = "0.1.3".freeze
3
3
  end
@@ -2,6 +2,10 @@ class Flexor
2
2
  ##
3
3
  # Methods for recursively converting raw Hashes and Arrays into Flexor objects.
4
4
  module Vivification
5
+ FLOAT = /[0-9.-]+/
6
+ INTEGER = /^(?<!0)[0-9-]+/
7
+ DATELIKE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}/
8
+
5
9
  private
6
10
 
7
11
  def vivify(hash)
@@ -18,8 +22,13 @@ class Flexor
18
22
 
19
23
  def vivify_value(value)
20
24
  case value
21
- when Hash then self.class.new(value, root: false)
22
- when Array then vivify_array(value)
25
+ in Hash then self.class.new(value, root: false)
26
+ in Array then vivify_array(value)
27
+ in INTEGER then value.to_i
28
+ in FLOAT then value.to_f
29
+ in DATELIKE
30
+ require "datetime"
31
+ DateTime.parse(it)
23
32
  else value
24
33
  end
25
34
  end
data/lib/flexor.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  require_relative "flexor/version"
2
+ require_relative "flexor/plugins"
2
3
  require_relative "flexor/hash_delegation"
3
4
  require_relative "flexor/serialization"
4
5
  require_relative "flexor/vivification"
6
+ require_relative "flexor/method_dispatch"
7
+ require_relative "flexor/case_conversion"
5
8
 
6
9
  ##
7
10
  # A Hash-like data store with autovivifying nested access, nil-safe
@@ -10,178 +13,15 @@ require_relative "flexor/vivification"
10
13
  class Flexor
11
14
  class Error < StandardError; end
12
15
 
13
- include HashDelegation
14
- include Serialization
15
- include Vivification
16
-
17
- def self.[](input = {})
18
- case input
19
- when String then from_json(input)
20
- when Hash then new(input)
21
- else raise ArgumentError, "expected a String or Hash, got #{input.class}"
22
- end
23
- end
24
-
25
- def self.from_json(json)
26
- require "json"
27
- JSON.parse(json, symbolize_names: true)
28
- .then { new it }
29
- end
30
-
31
- def self.===(other)
32
- other.is_a?(self)
33
- end
34
-
35
- def initialize(hash = {}, root: true)
36
- raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
37
-
38
- @root = root
39
- @store = vivify(hash)
40
- end
41
-
42
- def initialize_copy(original)
43
- super
44
- @store = @store.dup
45
- end
46
-
47
- def [](key)
48
- @store[key]
49
- end
50
-
51
- def []=(key, value)
52
- @store[key] = vivify_value(value)
53
- end
54
-
55
- def set_raw(key, value)
56
- @store[key] = value
57
- end
58
-
59
- def delete(key)
60
- @store.delete(key)
61
- end
62
-
63
- def clear
64
- @store.clear
65
- self
66
- end
67
-
68
- def to_ary
69
- nil
70
- end
71
-
72
- def freeze
73
- @store.freeze
74
- super
75
- end
76
-
77
- def to_h
78
- @store.each_with_object({}) do |(key, value), hash|
79
- result = recurse_to_h(value)
80
- hash[key] = result unless value.is_a?(Flexor) && result.nil?
81
- end
82
- end
83
-
84
- def to_json(...)
85
- require "json"
86
- to_h.to_json(...)
87
- end
88
-
89
- def to_s
90
- return "" if nil?
91
-
92
- @store.to_s
93
- end
94
-
95
- def inspect
96
- return @store.inspect if @root
97
- return nil.inspect if @store.empty?
98
-
99
- @store.inspect
100
- end
101
-
102
- def deconstruct
103
- @store.values
104
- end
105
-
106
- def deconstruct_keys(keys)
107
- return @store if keys.nil?
108
-
109
- @store.slice(*keys)
110
- end
111
-
112
- def nil?
113
- @store.empty?
114
- end
115
-
116
- def merge!(other)
117
- other = other.to_h if other.is_a?(Flexor)
118
- other.each do |key, value|
119
- if value.is_a?(Hash) && self[key].is_a?(Flexor) && !self[key].nil?
120
- self[key].merge!(value)
121
- else
122
- self[key] = value
123
- end
124
- end
125
- self
126
- end
127
-
128
- def merge(other)
129
- dup.merge!(other)
130
- end
131
-
132
- def ==(other)
133
- case other
134
- in nil then nil?
135
- in Flexor then to_h == other.to_h
136
- in Hash then to_h == other
137
- else super
138
- end
139
- end
140
-
141
- def ===(other)
142
- other.nil? ? nil? : super
143
- end
144
-
145
- def respond_to_missing?(_name, _include_private = false)
146
- true
147
- end
148
-
149
- private
150
-
151
- def method_missing(name, *args, &block)
152
- return super if block
153
-
154
- case [name, args]
155
- in /^[^=]+=$/, [arg] then write_via_method(name, arg)
156
- in _, [] then read_via_method(name)
157
- else super
158
- end
159
- end
160
-
161
- def write_via_method(name, arg)
162
- key = name.to_s.chomp("=").to_sym
163
- cache_setter(name, key)
164
- self[key] = arg
165
- end
166
-
167
- def read_via_method(name)
168
- cache_getter(name) if !frozen? && @store.key?(name)
169
- self[name]
170
- end
171
-
172
- def cache_setter(name, key)
173
- define_singleton_method(name) do |val = nil, &blk|
174
- raise NoMethodError, "undefined method '#{name}' for #{inspect}" if blk
16
+ extend Plugins::Dispatcher
17
+ end
175
18
 
176
- self[key] = val
177
- end
178
- end
19
+ require_relative "flexor/plugins/core"
20
+ require_relative "flexor/plugins/symbolize_keys"
21
+ require_relative "flexor/plugins/flex_keys"
179
22
 
180
- def cache_getter(name)
181
- define_singleton_method(name) do |*a, &blk|
182
- raise NoMethodError, "undefined method '#{name}' for #{inspect}" if blk || !a.empty?
23
+ Flexor.plugin(:core)
24
+ Flexor.plugin(:flex_keys)
25
+ Flexor.plugin(:symbolize_keys)
183
26
 
184
- self[name]
185
- end
186
- end
187
- end
27
+ require_relative "f"
data/patterns_spec.rb ADDED
@@ -0,0 +1,10 @@
1
+ require "rspec"
2
+
3
+ FLOAT = /^(?:(?<!0)(?:[-+] ?)?[0-9])+(?:\.(?:[0-9](?!\.))+)?$/
4
+ RSpec.describe "Patterns" do
5
+ describe FLOAT do
6
+ it "does not allow leading zeroes" do
7
+ skip "not done yet"
8
+ end
9
+ end
10
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flexor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Gillis
@@ -33,13 +33,19 @@ files:
33
33
  - issues.rec
34
34
  - lib/f.rb
35
35
  - lib/flexor.rb
36
+ - lib/flexor/case_conversion.rb
36
37
  - lib/flexor/hash_delegation.rb
38
+ - lib/flexor/method_dispatch.rb
39
+ - lib/flexor/plugins.rb
40
+ - lib/flexor/plugins/core.rb
41
+ - lib/flexor/plugins/flex_keys.rb
42
+ - lib/flexor/plugins/symbolize_keys.rb
37
43
  - lib/flexor/serialization.rb
38
44
  - lib/flexor/version.rb
39
45
  - lib/flexor/vivification.rb
46
+ - patterns_spec.rb
40
47
  - rakelib/benchmark.rake
41
48
  - rakelib/rdoc.rake
42
- - rakelib/version.rake
43
49
  homepage: https://github.com/gillisd/flexor
44
50
  licenses:
45
51
  - MIT
@@ -61,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
67
  - !ruby/object:Gem::Version
62
68
  version: '0'
63
69
  requirements: []
64
- rubygems_version: 4.0.7
70
+ rubygems_version: 4.0.13
65
71
  specification_version: 4
66
72
  summary: A Hash-like data store that does what you tell it to do
67
73
  test_files: []
data/rakelib/version.rake DELETED
@@ -1,72 +0,0 @@
1
- VERSION_PATTERN = /VERSION\s*=\s*"(\d+\.\d+\.\d+)"/
2
-
3
- # Encapsulates version file manipulation logic for rake tasks.
4
- module VersionBumper
5
- module_function
6
-
7
- def version_path
8
- File.expand_path("../lib/flexor/version.rb", __dir__)
9
- end
10
-
11
- def print_current
12
- require_relative "../lib/flexor/version"
13
- puts "Current version: #{Flexor::VERSION}"
14
- end
15
-
16
- def bump
17
- File.open(version_path, File::RDWR, 0o644) do |f|
18
- f.flock(File::LOCK_EX)
19
- old_version, new_version, new_source = compute_bump(f.read)
20
- f.rewind
21
- f.write(new_source)
22
- f.truncate(f.pos)
23
- puts "Version bumped from #{old_version} to #{new_version}"
24
- end
25
- end
26
-
27
- def compute_bump(source)
28
- match = source.match(VERSION_PATTERN)
29
- abort "Could not find VERSION in #{version_path}" unless match
30
-
31
- old_version = match[1]
32
- parts = old_version.split(".").map(&:to_i)
33
- parts[-1] += 1
34
- new_version = parts.join(".")
35
- new_source = source.sub(/VERSION\s*=\s*"#{Regexp.escape(old_version)}"/, "VERSION = \"#{new_version}\"")
36
- [old_version, new_version, new_source]
37
- end
38
-
39
- def commit
40
- require_relative "../lib/flexor/version"
41
- system("git", "add", version_path) || abort("git add failed")
42
- system("git", "commit", "-m", "Bump version to #{Flexor::VERSION}") || abort("git commit failed")
43
- puts "Version change committed."
44
- end
45
-
46
- def revert
47
- last_message = `git log -1 --pretty=%B`.strip
48
- abort "Last commit does not appear to be a version bump." unless last_message.start_with?("Bump version to ")
49
-
50
- system("git", "revert", "HEAD", "--no-edit") || abort("git revert failed")
51
- puts "Version bump reverted."
52
- end
53
- end
54
-
55
- namespace :version do
56
- desc "Display the current version"
57
- task(:current) { VersionBumper.print_current }
58
-
59
- desc "Bump the patch version"
60
- task(:bump) { VersionBumper.bump }
61
-
62
- desc "Commit the version change"
63
- task(:commit) { VersionBumper.commit }
64
-
65
- desc "Revert the last version bump commit"
66
- task(:revert) { VersionBumper.revert }
67
- end
68
-
69
- namespace :release do
70
- desc "Bump version, commit, and release"
71
- task full: ["version:bump", "version:commit", :release]
72
- end