nero 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9822fcc078e4b2c0e9f049da7cf6f37a98a5b9cf607434057f266a393154296e
4
- data.tar.gz: b71a5be94f469b564e3a80c80a0545246afbc053f619201f8a0c6c0923a776a4
3
+ metadata.gz: 136899b1a5b7fd355608891703ad0e8b0c628dfcb3ae74d9a5344fb0a78b41b8
4
+ data.tar.gz: 47fce2793b077af0a637d7cac6a5e31b471c16bbbff71561f97bdf4aa904a3d5
5
5
  SHA512:
6
- metadata.gz: 863f2c0fbeb21286a0d0f51219e07351a93489688eb7c49be5c1c720a3e414d8e43129e2c0e4d23c6e44213e8547a03c28df128210026414e9e3e209bb7ef472
7
- data.tar.gz: c431e49a4d5d422b554a891973c0e24b7c1810dd922492d56b39ca6157dc70e3110114d7474d0d2ed3b5076f2bea63d8493b2cd9db64c3354ea051022f8887cb
6
+ metadata.gz: 979f099766b6c33577346e608fa8e82f890d4d3ef5b49913a142cdbcb7385ecff78776882e5ee78420f0b285388972aef8e6c4c980d4aff120b8c142efec2212
7
+ data.tar.gz: e43d9f3916bfc43b3abe49939b3953a72679fec64e8de4de5b682600d6d5ef2f66f97557e262a5558046c9e332532bc908a4317d4a114bc46754d9ff60d3242c
data/.yardopts ADDED
@@ -0,0 +1,6 @@
1
+ --readme README.md
2
+ --title 'Nero Documentation'
3
+ --charset utf-8
4
+ --markup markdown
5
+ --no-private
6
+ 'lib/**/*.rb' - '*.md'
data/CHANGELOG.md CHANGED
@@ -1,6 +1,65 @@
1
1
  ## [Unreleased]
2
2
  ...
3
3
 
4
+ ## [0.6.0] - 2025-04-10
5
+
6
+ ### deprecations
7
+
8
+ - `Nero.load_config` - use `Nero.load_file` or `Nero.config_for`.
9
+
10
+ ### other
11
+
12
+ - API docs live at https://eval.github.io/nero/
13
+ - Config for Rails
14
+ The `config.config_dir` is automatically setup, so `Nero.config_for` (formerly `Nero.load_config`) just works.
15
+ - `Nero::Config.dig!` ⛏️💥
16
+ Any (Hash-)result from `Nero.load/load_file/config_for` is now an instance of `Nero::Config`.
17
+ This class contains `dig!`, a fail-hard variant of `dig`:
18
+ ```ruby
19
+ Nero.load(<<~Y).dig!(:smtp_settings, :hose) # 💥 typo
20
+ smtp_settings:
21
+ host: 127.0.0.1
22
+ port: 1025
23
+ Y
24
+ #=> 'Nero::DigExt#dig!': path not found [:smtp_settings, :hose] (ArgumentError)
25
+ ```
26
+
27
+ ## [0.5.0] - 2025-03-20
28
+
29
+ - tag-classes
30
+ Added [`Nero::BaseTag`](https://rubydoc.info/github/eval/nero/main/Nero/BaseTag) that is the basis of all existing tags.
31
+ This means that building upon existing tags is easier and custom tags can be more powerful.
32
+
33
+ Create new tags can be done in 3 ways:
34
+ By block (as before, but slightly changed interface):
35
+ ```ruby
36
+ Nero.configure do |nero|
37
+ nero.add_tag("foo") do |tag|
38
+ # tag of type Nero::BaseTag
39
+ end
40
+ end
41
+ ```
42
+ By re-using existing tags via options:
43
+ ```ruby
44
+ nero.add_tag("env/upcase", klass: Nero::EnvTag[coerce: :upcase])
45
+ ```
46
+ Finally, by subclassing [Nero::BaseTag](https://rubydoc.info/github/eval/nero/main/Nero/BaseTag). See the section ["custom tags"](https://github.com/eval/nero?tab=readme-ov-file#custom-tags) from the README.
47
+
48
+ - `!env/float` and `!env/float?`
49
+ - `!env/git_root` and `!env/rails_root`
50
+ Construct a path relative to some root-path:
51
+ ```yaml
52
+ asset_path: !path/rails_root [ public/assets ]
53
+ ```
54
+ Easy to use for your own tags:
55
+ ```ruby
56
+ config.add_tag("path/project_root", klass: Nero::PathRootTag[containing: '.git']) do |path|
57
+ # possible post-processing
58
+ end
59
+ ```
60
+ - [#2](https://github.com/eval/nero/pull/2) Add irb to gemfile (@dlibanori)
61
+ - [#3](https://github.com/eval/nero/pull/3) Fix missing require (@dlibanori)
62
+
4
63
  ## [0.4.0] - 2025-02-15
5
64
 
6
65
  - Add `!ref`-tag:
data/README.md CHANGED
@@ -11,14 +11,24 @@ Additionally, it allows you to create your own.
11
11
  development:
12
12
  # env-var with default value
13
13
  secret: !env [SECRET, "dummy"]
14
+
14
15
  # optional env-var with coercion
15
16
  debug?: !env/bool? DEBUG
17
+
16
18
  production:
17
- # required env-var (only when getting the production-root)
19
+ # required env-var (not required during development)
18
20
  secret: !env SECRET
19
- # int coercion
21
+
22
+ # coercion
20
23
  max_threads: !env/integer [MAX_THREADS, 5]
21
- # something custom
24
+
25
+ # refer to other keys
26
+ min_threads: !env/integer [MIN_THREADS, !ref max_threads ]
27
+
28
+ # descriptive names
29
+ asset_folder: !path/rails_root [ public/assets ]
30
+
31
+ # easy to add custom tags
22
32
  cache_ttl: !duration [2, hours]
23
33
  ```
24
34
 
@@ -26,7 +36,7 @@ production:
26
36
 
27
37
  * 💎 declarative YAML-tags for e.g. requiring and coercing env-vars
28
38
  * 🛠️ add custom tags
29
- * 🛤️ `Rails.application.config_for` stand-in
39
+ * 🛤️ `Rails.application.config_for` drop-in
30
40
  * ♻️ Zeitwerk-only dependency
31
41
 
32
42
  ## Installation
@@ -37,11 +47,28 @@ Install the gem and add to the application's Gemfile by executing:
37
47
  bundle add nero
38
48
  ```
39
49
 
50
+ ## Configuration
51
+
52
+ ```ruby
53
+ Nero.configure do |nero|
54
+ # Path that `Nero.config_for` uses to resolve Symbol or String files, e.g. `Nero.config_for(:app)`
55
+ nero.config_dir = "config"
56
+
57
+ # Add custom tags (also see section about custom tags)
58
+ nero.add_tag("upcase") do |tag|
59
+ # tag is an instance of [Nero::BaseTag](https://eval.github.io/nero/Nero/BaseTag.html).
60
+ tag.args.join.upcase
61
+ end
62
+ end
63
+ ```
64
+
40
65
  ## Usage
41
66
 
42
67
  > [!WARNING]
43
68
  > It's early days - the API and included tags will certainly change. Check the CHANGELOG when upgrading.
44
69
 
70
+ ### loading a config
71
+
45
72
  Given the following config:
46
73
  ```yaml
47
74
  # config/settings.yml
@@ -61,7 +88,7 @@ Loading this config:
61
88
 
62
89
  ```ruby
63
90
  # Loading development
64
- Nero.load_config("config/settings", root: :development)
91
+ Nero.load_file("config/settings", root: :development)
65
92
  # ...and no ENV-vars were provided
66
93
  #=> {secret: "dummy", debug?: false}
67
94
 
@@ -69,13 +96,27 @@ Nero.load_config("config/settings", root: :development)
69
96
  #=> {secret: "dummy", debug?: true}
70
97
 
71
98
  # Loading production
72
- Nero.load_config("config/settings", root: :production)
99
+ Nero.load_file("config/settings", root: :production)
73
100
  # ...and no ENV-vars were provided
74
101
  # raises error: key not found: "SECRET" (KeyError)
75
102
 
76
103
  # ...with ENV {"SECRET" => "s3cr3t", "MAX_THREADS" => "3"}
77
104
  #=> {secret: "s3cr3t", max_threads: 3}
78
105
  ```
106
+ > [!TIP]
107
+ > You can also use `Nero.config_for` (similar to [Rails.application.config_for](https://api.rubyonrails.org/classes/Rails/Application.html#method-i-config_for)).
108
+ > In Rails applications this gets configured for you. For other application you might need to adjust the `config_dir`:
109
+ ```ruby
110
+ Nero.configure do |config|
111
+ config.config_dir = "config"
112
+ end
113
+
114
+ Nero.config_for(:settings, env: Rails.env)
115
+ ```
116
+
117
+ [API Documentation](https://eval.github.io/nero/).
118
+
119
+ ### built-in tags
79
120
 
80
121
  The following tags are provided:
81
122
  - `!env KEY`, `!env? KEY`
@@ -90,10 +131,11 @@ The following tags are provided:
90
131
  secret: !env? SECRET
91
132
  ```
92
133
  - to coerce env-values:
93
- - `env/integer`, `env/integer?`:
134
+ - `env/integer`, `env/integer?`, `env/float`, `env/float?`:
94
135
  ```yaml
95
136
  port: !env/integer [PORT, 3000]
96
137
  threads: !env/integer? THREADS # nil when not provided
138
+ threshold: !env/float CUTOFF
97
139
  ```
98
140
  - `env/bool`, `env/bool?`:
99
141
  ```yaml
@@ -104,6 +146,11 @@ The following tags are provided:
104
146
  # ...or false:
105
147
  debug?: !env/bool? DEBUG
106
148
  ```
149
+ > [!TIP]
150
+ > Make all env-var's optional by providing `ENV["NERO_ENV_ALL_OPTIONAL"]`, e.g.
151
+ ```shell
152
+ $ env NERO_ENV_ALL_OPTIONAL=1 SECRET_KEY_BASE_DUMMY=1 rails asset:precompile
153
+ ```
107
154
  - `!path`
108
155
  Create a [Pathname](https://rubyapi.org/3.4/o/pathname):
109
156
  ```yaml
@@ -113,6 +160,15 @@ The following tags are provided:
113
160
  - !env PROJECT_ROOT
114
161
  - /public/assets
115
162
  ```
163
+ - `!path/git_root`, `!path/rails_root`
164
+ Create a Pathname relative to some root-path.
165
+ The root-path is expected to be an existing ancestor folder of the yaml-config being parsed.
166
+ It's found by traversing up and checking for the presence of specific files/folders, e.g. '.git' (`!path/git_root`) or 'config.ru' (`!path/rails_root`).
167
+ While the root-path needs to exist, the resulting Pathname doesn't need to.
168
+ ```yaml
169
+ project_root: !path/git_root
170
+ config_folder: !path/rails_root [ config ]
171
+ ```
116
172
  - `!uri`
117
173
  Create a [URI](https://rubyapi.org/3.4/o/uri):
118
174
  ```yaml
@@ -139,7 +195,7 @@ The following tags are provided:
139
195
  Include values from elsewhere:
140
196
  ```yaml
141
197
  # simple
142
- min_threads: !env [MIN_THREADS, !ref [max_threads]]
198
+ min_threads: !env/integer [MIN_THREADS, !ref [max_threads]]
143
199
  max_threads: 5
144
200
 
145
201
  # oauth_callback -refs-> base.url -refs-> base.host
@@ -158,39 +214,96 @@ The following tags are provided:
158
214
  max_threads: !env[MAX_THREADS, !ref[dev, max_threads]]
159
215
  ```
160
216
  NOTE future version should raise properly over ref-ing a non-existing path.
161
-
162
217
 
163
- Add one yourself:
164
- ```ruby
165
- Nero.configure do |nero|
166
- nero.add_tag("foo") do |coder|
167
- # coder.type is one of :scalar, :seq or :map
168
- # e.g. respective YAML:
169
- # ---
170
- # !foo bar
171
- # ---
172
- # !foo
173
- # - bar
174
- # ---
175
- # !foo
176
- # bar: baz
177
- #
178
- # Find the value in the respective attribute, e.g. `coder.scalar`:
179
- coder.scalar.upcase
180
-
181
- # NOTE when needing just one argument, supporting both scalar and seq allows for chaining:
182
- # a: !my/inc 4 # scalar suffices
183
- # ...but when chaining, a seq is required:
184
- # a: !my/inc [!my/square 2]
185
- end
218
+ ### custom tags
186
219
 
187
- # Other configuration options:
188
- #
189
- # `config_dir` (default: Pathname.pwd) - path used for expanding non-Pathnames passed to `load_config`, e.g.
190
- # `Nero.load_config(:app)` loads file `Pathname.pwd / "app.yml"`.
191
- nero.config_dir = Rails.root / "config"
192
- end
193
- ```
220
+ There's three ways to create your own tags.
221
+
222
+ For all these methods it's helpful to see the API-docs for [Nero::BaseTag](https://eval.github.io/nero/Nero/BaseTag.html).
223
+
224
+ 1. **a proc**
225
+ ```ruby
226
+ Nero.configure do |nero|
227
+ nero.add_tag("upcase") do |tag|
228
+ # `tag` is a `Nero::BaseTag`.
229
+ # In YAML args are provided as scalar, seq or map:
230
+ # ---
231
+ # k: !upcase bar
232
+ # ---
233
+ # k: !upcase [bar] # equivalent to:
234
+ # k: !upcase
235
+ # - bar
236
+ # ---
237
+ # k: !upcase
238
+ # bar: baz
239
+ #
240
+ # Find these args via `tag.args` (Array or Hash):
241
+ case tag.args
242
+ when Hash
243
+ tag.args.each_with_object({}) {|(k,v), acc| acc[k] = v.upcase }
244
+ else
245
+ tag.args.map(&:upcase)
246
+ end
247
+
248
+ # NOTE though a tag might just need one argument (ie scalar),
249
+ # it's helpful to accept a seq as it allows for chaining:
250
+ # a: !my/inc 4 # scalar suffices
251
+ # ...but when chaining, it needs to be a seq:
252
+ # a: !my/inc [ !my/square 2 ]
253
+ end
254
+ end
255
+ ```
256
+ Blocks are passed instances of [Nero::BaseTag](https://eval.github.io/nero/Nero/BaseTag.html).
257
+ 1. **re-use existing tag-class**
258
+ You can add an existing tag under a better fitting name this way.
259
+ Also: some tag-classes have options that allow for simple customizations (like `coerce` below):
260
+ ```ruby
261
+ Nero.configure do |nero|
262
+ nero.add_tag("env/upcase", klass: Nero::EnvTag[coerce: :upcase])
263
+
264
+ # Alias for path/git_root:
265
+ nero.add_tag("path/project_root", klass: Nero::PathRootTag[containing: '.git'])
266
+ end
267
+ ```
268
+ 1. **custom class**
269
+ ```ruby
270
+ class RotTag < Nero::BaseTag
271
+ # Configure:
272
+ # ```
273
+ # config.add_tag("rot/12", klass: RotTag[n: 12])
274
+ # config.add_tag("rot/10", klass: RotTag[n: 10]) do |secret|
275
+ # "#{secret} (try breaking this!)"
276
+ # end
277
+ # ```
278
+ #
279
+ # Usage in YAML:
280
+ # ```
281
+ # secret: !rot/12 some message
282
+ # very_secret: !rot/10 [ !env [ MSG, some message ] ]
283
+ # ```
284
+ # => {secret: "EAyq yqEEmsq", very_secret: "Cywo woCCkqo (try breaking this!)"}
285
+
286
+ # By overriding `init_options` we can restrict/require options,
287
+ # provide default values and do any other setup.
288
+ # By default an option is available via `options[:foo]`.
289
+ def init_options(n: 10)
290
+ super # no specific assignments, so available via `options[:n]`.
291
+ end
292
+
293
+ def chars
294
+ @chars ||= (('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a)
295
+ end
296
+
297
+ def resolve(**) # currently no keywords are passed, but `**` allows for future ones.
298
+ # Here we actually do the work: get the args, rotate strings and delegate to the block.
299
+ # `args` are the resolved nested args (so e.g. `!env MSG` is already resolved).
300
+ # `config` is the tag's config, and contains e.g. the block.
301
+ block = config.fetch(:block, :itself.to_proc)
302
+ # String#tr replaces any character from the first collection with the same position in the other:
303
+ args.join.tr(chars.join, chars.rotate(options[:n]).join).then(&block)
304
+ end
305
+ end
306
+ ```
194
307
 
195
308
  ## Development
196
309
 
@@ -0,0 +1,10 @@
1
+ module Nero
2
+ # @private
3
+ class Railtie < ::Rails::Railtie
4
+ config.before_configuration do
5
+ Nero.configure do |nero|
6
+ nero.config_dir = Rails.application.paths["config"].existent.first
7
+ end
8
+ end
9
+ end
10
+ end
data/lib/nero/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  module Nero
4
4
  # NOTE this is written upon release via:
5
5
  # $ rake gem:build[version=0.3.0]
6
- VERSION = "0.4.0"
6
+ VERSION = "0.6.0"
7
7
  end
data/lib/nero.rb CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  require "zeitwerk"
4
4
  loader = Zeitwerk::Loader.for_gem
5
+ loader.do_not_eager_load("#{__dir__}/nero/railtie.rb")
5
6
  loader.setup
6
7
 
7
- require "uri" # why needed?
8
+ require "uri"
8
9
  require "yaml"
10
+ require "pathname"
9
11
 
10
12
  # TODO fail on unknown tag
11
13
  # TODO show missing env's at once
@@ -13,57 +15,105 @@ require "yaml"
13
15
  module Nero
14
16
  class Error < StandardError; end
15
17
 
18
+ module DigExt
19
+ # ⛏️💥 Like `dig`, but raises `ArgumentError` when `path` does not exist.
20
+ # @example like dig
21
+ # {a: {b: 2}}.dig!(:a, :b) #=> 2
22
+ # {a: {b: 2}}.dig!(:a, :c) #=> ArgumentError, path not found [:a, :c] (ArgumentError)
23
+ # @raise [ArgumentError] when `path` does not exist.
24
+ # @overload dig!(*path)
25
+ # @param path nested keys into config
26
+ def dig!(k0, *k)
27
+ k.unshift(k0)
28
+
29
+ unless paths.include?(k)
30
+ raise ArgumentError, "path not found #{k}"
31
+ end
32
+ dig(*k)
33
+ end
34
+
35
+ private
36
+
37
+ def paths
38
+ @paths ||= gather_paths(self).to_set
39
+ end
40
+
41
+ def gather_paths(item, acc: [], path: [])
42
+ acc += [path]
43
+
44
+ case item
45
+ when NilClass
46
+ []
47
+ when Hash
48
+ item.flat_map { |(k, v)| gather_paths(v, acc: acc, path: path + [k]) }
49
+ when Array
50
+ item.each_with_index.flat_map do |item, ix|
51
+ gather_paths(item, acc: acc, path: path + [ix])
52
+ end
53
+ else
54
+ acc
55
+ end
56
+ end
57
+ end
58
+
59
+ class Config < Hash
60
+ include DigExt
61
+
62
+ def self.for(v)
63
+ case v
64
+ when self then v
65
+ when Hash then self.[](v)
66
+ else
67
+ v
68
+ end
69
+ end
70
+ end
71
+
16
72
  module Resolvable
17
- def try_resolve(ctx, object)
73
+ def try_resolve(object)
18
74
  if object.respond_to?(:resolve)
19
- object.resolve(ctx)
75
+ object.resolve
20
76
  else
21
77
  object
22
78
  end
23
79
  end
24
80
 
25
- def gen_resolve_tryer(ctx)
26
- method(:try_resolve).curry.call(ctx)
81
+ def deep_resolve(object)
82
+ Util.deep_transform_values(object, &method(:try_resolve))
27
83
  end
28
84
 
29
- def deep_resolve(object, **ctx)
30
- Util.deep_transform_values(object, &gen_resolve_tryer(ctx))
85
+ def resolve_nested!(coder)
86
+ case coder.type
87
+ when :seq
88
+ coder.seq.map!(&method(:try_resolve))
89
+ when :map
90
+ coder.map = deep_resolve(coder.map)
91
+ end
31
92
  end
32
93
  end
33
94
  extend Resolvable
34
95
  private_class_method \
35
96
  :deep_resolve,
36
- :gen_resolve_tryer,
37
97
  :try_resolve
38
98
 
39
- class TagResolver
40
- include Resolvable
41
-
42
- def init_with(coder)
43
- @coder = coder
44
- end
99
+ class Configuration
100
+ attr_reader :config_dir
45
101
 
46
- def resolve(ctx)
47
- resolve_nested!(ctx)
48
- ctx[:tags][@coder.tag].call(@coder, ctx)
102
+ def tags
103
+ @tags ||= {}
49
104
  end
50
105
 
51
- def resolve_nested!(ctx)
52
- case @coder.type
53
- when :seq
54
- @coder.seq.map!(&gen_resolve_tryer(ctx))
55
- when :map
56
- @coder.map = deep_resolve(@coder.map, **ctx)
57
- end
106
+ def config_dir=(dir)
107
+ @config_dir = Pathname(dir).expand_path
58
108
  end
59
- end
60
109
 
61
- class Configuration
62
- attr_reader :tags
63
- attr_accessor :config_dir
110
+ def add_tag(name, klass: BaseTag, &block)
111
+ klass, klass_options = klass
64
112
 
65
- def add_tag(name, &block)
66
- (@tags ||= {})["!#{name}"] = block
113
+ tags[name] = {klass:}.tap do |h|
114
+ h[:block] = block if block
115
+ h[:options] = klass_options if klass_options
116
+ end
67
117
  end
68
118
  end
69
119
 
@@ -71,96 +121,295 @@ module Nero
71
121
  @configuration ||= Configuration.new
72
122
  end
73
123
 
124
+ def self.add_tags!
125
+ configuration.tags.each do |tag_name, tag|
126
+ YAML.add_tag("!#{tag_name}", tag[:klass])
127
+ end
128
+ end
129
+ private_class_method :add_tags!
130
+
74
131
  def self.configure
75
132
  yield configuration if block_given?
133
+ ensure
134
+ add_tags!
76
135
  end
77
136
 
78
- # helpers for configuration
79
- # module TagHelpers
80
- # def to_boolean(s)
81
- # end
137
+ # Superclass for all tags.
138
+ #
139
+ # Writing your own tag-class would look something like this:
140
+ #
141
+ # Wanted usage in YAML:
142
+ # ```ruby
143
+ # Nero.load(<<~YAML)
144
+ # secret: !rot/12 "some message"
145
+ # other_secret: !rot/13 [ !env [SECRET, some message] ]
146
+ # YAML
147
+ # ```
148
+ #
149
+ # Required config:
150
+ # ```ruby
151
+ # config.add_tag("rot/12", klass: RotTag[n: 12])
152
+ # config.add_tag("rot/13", klass: RotTag[n: 13]) do |secret|
153
+ # "#{secret} (try breaking this!)"
154
+ # end
155
+ # ```
156
+ # The class then would look like this:
157
+ # ```ruby
158
+ # class RotTag < Nero::BaseTag
159
+ # attr_reader :n
160
+ #
161
+ # # Overriding this method...:
162
+ # # - restricts options
163
+ # # ie `RotTag[x: 1]` would raise.
164
+ # # - sets default values
165
+ # # - makes options available via getters
166
+ # # (otherwise available via `options[:n]`).
167
+ # def init_options(n: 10)
168
+ # super
169
+ # @n = n
170
+ # end
171
+ #
172
+ # # This is where the magic happens.
173
+ # # (Accepting any keyword arguments keeps the method fw-compatible).
174
+ # def resolve(**)
175
+ # # `args` are the resolved arguments (Array or Hash).
176
+ # # `config` the config of the tag (containing e.g. the proc).
177
+ # block = config.fetch(:block, :itself.to_proc)
178
+ # args.join.tr(chars.join, chars.rotate(n).join).then(&block)
179
+ # end
180
+ #
181
+ # # Just some helper method with all characters that can be rotated.
182
+ # def chars
183
+ # %w(a b c) # etc
184
+ # end
82
185
  # end
186
+ # ```
187
+ #
188
+ class BaseTag
189
+ include Resolvable
83
190
 
84
- def self.add_default_tags!
85
- # extend TagHelpers
191
+ attr_reader :coder, :options, :ctx
86
192
 
87
- configure do |config|
88
- config.add_tag("ref") do |coder, ctx|
89
- # validate: non-empty coder.seq, only strs, path must exists in ctx[:config]
193
+ # Convenience method simplifying {Nero::Configuration#add_tag}:
194
+ #
195
+ # ```ruby
196
+ # config.add_tag("foo", klass: SomeTag[some_option: 1])
197
+ # ```
198
+ def self.[](**options)
199
+ [self, options]
200
+ end
201
+
202
+ # @private used by YAML
203
+ def init_with(coder)
204
+ @coder = coder
205
+ end
90
206
 
91
- path = coder.seq.map(&:to_sym)
92
- deep_resolve(ctx[:config].dig(*path), **ctx)
207
+ def init(ctx:, options:)
208
+ init_ctx(ctx)
209
+ init_options(**options)
210
+ end
211
+
212
+ def init_ctx(ctx)
213
+ @ctx = ctx
214
+ end
215
+
216
+ def init_options(**options)
217
+ @options = options
218
+ end
219
+
220
+ def tag_name
221
+ coder.tag[1..]
222
+ end
223
+
224
+ def args
225
+ @args ||= begin
226
+ resolve_nested!(coder)
227
+ case coder.type
228
+ when :map then Util.deep_symbolize_keys(coder.map)
229
+ else
230
+ Array(coder.public_send(coder.type))
231
+ end
232
+ end
233
+ end
234
+
235
+ def config
236
+ ctx.dig(:tags, tag_name)
237
+ end
238
+
239
+ def resolve(**)
240
+ if (block = config[:block])
241
+ if block.parameters.map(&:last).include?(:coder)
242
+ # legacy
243
+ block.call(coder, ctx)
244
+ else
245
+ block.call(self)
246
+ end
247
+ else
248
+ args
93
249
  end
250
+ end
251
+ end
94
252
 
95
- config.add_tag("env/integer") do |coder|
96
- Integer(env_fetch(*(coder.scalar || coder.seq), all_optional: "999"))
253
+ # Requires an env-var to be available and coerces the value.
254
+ # When tag-name ends with "?", the env-var is optional.
255
+ #
256
+ # Given config:
257
+ # ```ruby
258
+ # config.add_tag("env/upcase", klass: Nero::EnvTag[coerce: :upcase])
259
+ # config.add_tag("env/upcase?", klass: Nero::EnvTag[coerce: :upcase])
260
+ # ```
261
+ #
262
+ # Then YAML => result:
263
+ # ```ruby
264
+ # "--- env/upcase [MSG, Hello World]" #=> "HELLO WORLD"
265
+ # "--- env/upcase MSG" #=> raises when not ENV.has_key? "MSG"
266
+ # "--- env/upcase? MSG" #=> nil
267
+ # ```
268
+ #
269
+ # YAML-args supported:
270
+ # - scalar —
271
+ # name of env-var, e.g. `!env HOME`
272
+ # - seq —
273
+ # name of env-var and fallback, e.g. `!env [HOME, /root]`
274
+ #
275
+ # Options:
276
+ # - `coerce` —
277
+ # symbol or proc to be applied to value of env-var.
278
+ # when using coerce, the block is ignored.
279
+ #
280
+ class EnvTag < BaseTag
281
+ def resolve(**)
282
+ if coercer
283
+ coercer.call(env_value) unless env_value.nil?
284
+ elsif ctx.dig(:tags, tag_name, :block)
285
+ super
286
+ else
287
+ env_value
97
288
  end
289
+ end
290
+
291
+ def coercer
292
+ return unless @coerce
98
293
 
99
- config.add_tag("env/integer?") do |coder|
100
- Integer(ENV[coder.scalar]) if ENV[coder.scalar]
294
+ @coercer ||= case @coerce
295
+ when Symbol then @coerce.to_proc
296
+ else
297
+ @coerce
101
298
  end
299
+ end
102
300
 
103
- config.add_tag("env/bool") do |coder|
104
- re_true = /y|Y|yes|Yes|YES|true|True|TRUE|on|On|ON/
105
- re_false = /n|N|no|No|NO|false|False|FALSE|off|Off|OFF/
106
-
107
- coerce = ->(s) do
108
- case s
109
- when TrueClass, FalseClass then s
110
- when re_true then true
111
- when re_false then false
112
- else
113
- raise "bool value should be one of y(es)/n(o), on/off, true/false (got #{s.inspect})"
114
- end
115
- end
301
+ def init_options(coerce: nil)
302
+ @coerce = coerce
303
+ end
304
+
305
+ def optional
306
+ tag_name.end_with?("?") || !!ENV["NERO_ENV_ALL_OPTIONAL"]
307
+ end
308
+ alias_method :optional?, :optional
116
309
 
117
- coerce[env_fetch(*(coder.scalar || coder.seq), all_optional: "false")]
310
+ def env_value
311
+ self.class.env_value(*args, optional:)
312
+ end
313
+
314
+ def self.env_value(k, fallback = nil, optional: false)
315
+ if fallback.nil? && !optional
316
+ ENV.fetch(k)
317
+ else
318
+ ENV.fetch(k, fallback)
118
319
  end
320
+ end
119
321
 
120
- config.add_tag("env/bool?") do |coder|
121
- re_true = /y|Y|yes|Yes|YES|true|True|TRUE|on|On|ON/
122
- re_false = /n|N|no|No|NO|false|False|FALSE|off|Off|OFF/
123
-
124
- coerce = ->(s) do
125
- case s
126
- when TrueClass, FalseClass then s
127
- when re_true then true
128
- when re_false then false
129
- else
130
- raise "bool value should be one of y(es)/n(o), on/off, true/false (got #{s.inspect})"
131
- end
132
- end
322
+ def self.coerce_bool(v)
323
+ return false unless v
324
+
325
+ re_true = /y|Y|yes|Yes|YES|true|True|TRUE|on|On|ON/
326
+ re_false = /n|N|no|No|NO|false|False|FALSE|off|Off|OFF/
133
327
 
134
- ENV[coder.scalar] ? coerce[ENV[coder.scalar]] : false
328
+ case v
329
+ when TrueClass, FalseClass then v
330
+ when re_true then true
331
+ when re_false then false
332
+ else
333
+ raise "bool value should be one of y(es)/n(o), on/off, true/false (got #{v.inspect})"
135
334
  end
335
+ end
336
+ end
337
+
338
+ # Construct path relative to some root-path.
339
+ # Root-paths are expected to be ancestors of the yaml-file being parsed.
340
+ # They are found by traversing up and checking for specific files/folders, e.g. '.git' or 'Gemfile'.
341
+ # Any argument is appended to the root-path, constructing a path-instance that may exist.
342
+ class PathRootTag < BaseTag
343
+ # Config:
344
+ # config.add_tag("path/git_root", klass: PathRootTag[containing: ".git"])
345
+ # config.add_tag("path/rails_root", klass: PathRootTag[containing: "Gemfile"])
346
+ #
347
+ # YAML:
348
+ # project_root: !path/git_root
349
+ # config_path: !path/git_root [ config ]
350
+ def init_options(containing:)
351
+ super
352
+ end
353
+
354
+ def resolve(**)
355
+ # TODO validate upfront
356
+ raise <<~ERR unless root_path
357
+ #{tag_name}: failed to find root-path (ie an ancestor of #{ctx[:yaml_file]} containing #{options[:containing].inspect}).
358
+ ERR
359
+ root_path.join(*args).then(&config.fetch(:block, :itself.to_proc))
360
+ end
361
+
362
+ def root_path
363
+ find_up(ctx[:yaml_file], options[:containing])
364
+ end
365
+
366
+ def find_up(path, containing)
367
+ (path = path.parent) until path.root? || (path / containing).exist?
368
+ path unless path.root?
369
+ end
370
+ end
371
+
372
+ def self.add_default_tags!
373
+ configure do |config|
374
+ config.add_tag("ref") do |tag|
375
+ # validate: non-empty coder.seq, only strs, path must exists in ctx[:config]
136
376
 
137
- config.add_tag("env") do |coder|
138
- env_fetch(*(coder.scalar || coder.seq))
377
+ path = tag.args.map(&:to_sym)
378
+ deep_resolve(tag.ctx[:yaml].dig(*path))
139
379
  end
140
380
 
141
- config.add_tag("env?") do |coder|
142
- fetch_args = coder.scalar ? [coder.scalar, nil] : coder.seq
143
- ENV.fetch(*fetch_args)
381
+ config.add_tag("env", klass: EnvTag)
382
+ config.add_tag("env?", klass: EnvTag)
383
+ config.add_tag("env/float", klass: EnvTag[coerce: :to_f])
384
+ config.add_tag("env/float?", klass: EnvTag[coerce: :to_f])
385
+
386
+ config.add_tag("env/integer", klass: EnvTag[coerce: :to_i])
387
+ config.add_tag("env/integer?", klass: EnvTag[coerce: :to_i])
388
+
389
+ config.add_tag("env/bool", klass: EnvTag) do |tag|
390
+ EnvTag.coerce_bool(tag.env_value)
391
+ end
392
+ config.add_tag("env/bool?", klass: EnvTag) do |tag|
393
+ EnvTag.coerce_bool(tag.env_value)
144
394
  end
145
395
 
146
- config.add_tag("path") do |coder|
147
- Pathname.new(coder.scalar || coder.seq.join("/"))
396
+ config.add_tag("path") do |tag|
397
+ Pathname.new(tag.args.join("/"))
148
398
  end
399
+ config.add_tag("path/git_root", klass: PathRootTag[containing: ".git"])
400
+ config.add_tag("path/rails_root", klass: PathRootTag[containing: "config.ru"])
149
401
 
150
- config.add_tag("uri") do |coder|
151
- URI(coder.scalar || coder.seq.join)
402
+ config.add_tag("uri") do |tag|
403
+ URI.join(*tag.args.join)
152
404
  end
153
405
 
154
- config.add_tag("str/format") do |coder|
155
- case coder.type
156
- when :seq
157
- sprintf(*coder.seq)
158
- when :map
159
- m = Util.deep_symbolize_keys(coder.map)
160
- fmt = m.delete(:fmt)
161
- sprintf(fmt, m)
406
+ config.add_tag("str/format") do |tag|
407
+ case tag.args
408
+ when Hash
409
+ fmt = tag.args.delete(:fmt)
410
+ sprintf(fmt, tag.args)
162
411
  else
163
- coder.scalar
412
+ sprintf(*tag.args)
164
413
  end
165
414
  end
166
415
  end
@@ -171,64 +420,128 @@ module Nero
171
420
  @configuration = nil
172
421
 
173
422
  configure do |config|
174
- config.config_dir = Pathname.pwd
423
+ config.config_dir = Pathname.new("config").expand_path
175
424
  end
176
425
 
177
426
  add_default_tags!
427
+ add_tags!
178
428
  end
179
429
  reset_configuration!
180
430
 
181
- def self.env_fetch(k, fallback = nil, all_optional: "dummy")
182
- fallback ||= all_optional if ENV["NERO_ENV_ALL_OPTIONAL"]
431
+ def self.default_yaml_options
432
+ {
433
+ permitted_classes: [Symbol] + configuration.tags.values.map { _1[:klass] },
434
+ aliases: true
435
+ }
436
+ end
437
+ private_class_method :default_yaml_options
438
+
439
+ def self.yaml_options(yaml_options)
440
+ epc = yaml_options.delete(:extra_permitted_classes)
441
+ default_yaml_options.merge(yaml_options).tap do
442
+ _1[:permitted_classes].push(*epc)
443
+ end
444
+ end
445
+ private_class_method :yaml_options
446
+
447
+ # Like `YAML.load` with extra options.
448
+ #
449
+ # @param [Symbol, String] root return the value of this root key.
450
+ # @param [Boolean] resolve (for debug purposes) not resolving would leave the Nero-tags as-is.
451
+ # @param [Array<ClassName>] extra_permitted_classes classes that are added
452
+ # to the default permitted_classes and passed to `YAML.load`.
453
+ # @param [Hash] yaml_options options passed to `YAML.load`.
454
+ # @return [Nero::Config (when the data is a Hash)]
455
+ # @example
456
+ # Nero.load(<<~YAML, extra_permitted_classes: [Time])
457
+ # home: !env HOME,
458
+ # created_at: 2010-02-11 11:02:57
459
+ # project_root: !path/git_root
460
+ # YAML
461
+ # #=> {
462
+ # # home: "/Users/gert",
463
+ # # created_at: 2010-02-11 12:02:57 +0100,
464
+ # # project_root: #<Pathname:/Users/gert/projects/nero>
465
+ # # }
466
+ def self.load(yaml, root: nil, resolve: true, **yaml_options)
467
+ process_yaml(yaml_load(yaml, yaml_options(yaml_options)), root:, resolve:)
468
+ end
183
469
 
184
- fallback.nil? ? ENV.fetch(k) : ENV.fetch(k, fallback)
470
+ # Like `YAML.load_file`. See {load} for options.
471
+ # @return [Nero::Config (when the YAML-data is a Hash)]
472
+ def self.load_file(file, root: nil, resolve: true, **yaml_options)
473
+ config_file = (file.is_a?(Pathname) ? file : Pathname.new(file)).expand_path
474
+ process_yaml(yaml_load_file(config_file, yaml_options(yaml_options)), root:, config_file:, resolve:)
185
475
  end
186
- private_class_method :env_fetch
187
476
 
188
- @yaml_options = {
189
- permitted_classes: [Symbol, TagResolver],
190
- aliases: true
191
- }
477
+ # Convenience wrapper for {load_file} that works like `Rails.application.config_for`.
478
+ # @see https://api.rubyonrails.org/classes/Rails/Application.html#method-i-config_for Rails' config_for documentation
479
+ #
480
+ # The file-argument is expanded like so `(configuration.config_dir / "#{file}.yml").expand_path`.
481
+ #
482
+ # @param [Symbol, String, Pathname] file `Symbol` or `String` are expanded as shown above. A `Pathname` is used as-is.
483
+ # @param [Symbol, String] env return the value of this root key.
484
+ # @param [Symbol, String] root return the value of this root key.
485
+ # @param [Boolean] resolve (for debug purposes) not resolving would leave the Nero-tags as-is.
486
+ # @param [Array<ClassName>] extra_permitted_classes classes that are added
487
+ # to the default permitted_classes and passed to `YAML.load`.
488
+ # @param [Hash] yaml_options options passed to `YAML.load_file`.
489
+ # @return [Nero::Config (when the data is a Hash)]
490
+ # @example
491
+ # Nero.config_for(:app, env: Rails.env) #=> {...}
492
+ def self.config_for(file, root: nil, env: nil, **yaml_options)
493
+ root ||= env
494
+
495
+ load_file(resolve_file(file), root:, **yaml_options)
496
+ end
192
497
 
193
- def self.load_config(file, root: nil, env: nil)
498
+ # @deprecated Use `load_file` or `config_for` instead.
499
+ def self.load_config(file, root: nil, env: nil, resolve: true)
500
+ warn "[DEPRECATION] `load_config` is deprecated. Use `load_file` or `config_for` instead."
194
501
  root ||= env
195
502
  add_tags!
196
503
 
197
- file = resolve_file(file)
504
+ config_file = resolve_file(file)
198
505
 
199
- if file.exist?
200
- process_yaml(yaml_load_file(file, @yaml_options), root:)
506
+ if config_file.exist?
507
+ process_yaml(yaml_load_file(config_file, yaml_options), root:, config_file:, resolve:)
201
508
  else
202
- raise "Can't find file #{file}"
509
+ raise "Can't find file #{config_file}"
203
510
  end
204
511
  end
205
512
 
206
513
  def self.resolve_file(file)
207
514
  case file
208
515
  when Pathname then file
209
- # TODO expand full path
210
516
  else
211
- configuration.config_dir / "#{file}.yml"
517
+ (configuration.config_dir / "#{file}.yml").expand_path
212
518
  end
213
519
  end
214
520
  private_class_method :resolve_file
215
521
 
216
- def self.load(raw, root: nil, env: nil)
217
- root ||= env
218
- add_tags!
219
-
220
- process_yaml(yaml_load(raw, @yaml_options), root:)
221
- end
522
+ def self.process_yaml(yaml, root: nil, resolve: true, config_file: nil)
523
+ config_file ||= (Pathname.pwd / __FILE__)
222
524
 
223
- def self.process_yaml(yaml, root: nil)
224
525
  unresolved = Util.deep_symbolize_keys(yaml).then do
225
526
  root ? _1[root.to_sym] : _1
226
527
  end
528
+ ctx = {tags: configuration.tags, yaml: unresolved, yaml_file: config_file}
529
+ init_tags!(collect_tags(unresolved), ctx:)
227
530
 
228
- deep_resolve(unresolved, tags: configuration.tags, config: unresolved)
531
+ return unresolved unless resolve
532
+
533
+ Config.for(deep_resolve(unresolved))
229
534
  end
230
535
  private_class_method :process_yaml
231
536
 
537
+ def self.init_tags!(tags, ctx:)
538
+ tags.each do |tag|
539
+ options = ctx.dig(:tags, tag.tag_name, :options) || {}
540
+ tag.init(ctx:, options:)
541
+ end
542
+ end
543
+ private_class_method :init_tags!
544
+
232
545
  def self.yaml_load_file(file, opts = {})
233
546
  if Psych::VERSION < "4"
234
547
  YAML.load_file(file)
@@ -247,12 +560,29 @@ module Nero
247
560
  end
248
561
  private_class_method :yaml_load
249
562
 
250
- def self.add_tags!
251
- configuration.tags.keys.each do
252
- YAML.add_tag(_1, TagResolver)
563
+ def self.collect_tags(obj)
564
+ case obj
565
+ when Hash
566
+ obj.each_value.flat_map { collect_tags(_1) }.compact
567
+ when Nero::BaseTag
568
+ [obj] +
569
+ case obj.coder.type
570
+ when :seq
571
+ collect_tags(obj.coder.seq)
572
+ when :map
573
+ collect_tags(obj.coder.map)
574
+ else
575
+ []
576
+ end
577
+ when Array
578
+ obj.flat_map { collect_tags(_1) }.compact
579
+ else
580
+ []
253
581
  end
254
582
  end
255
- private_class_method :add_tags!
583
+ private_class_method :collect_tags
256
584
  end
257
585
 
586
+ require "nero/railtie" if defined?(Rails::Railtie)
587
+
258
588
  loader.eager_load if ENV.key?("CI")
data/rakelib/yard.rake ADDED
@@ -0,0 +1,12 @@
1
+ require "yard"
2
+
3
+ YARD::Rake::YardocTask.new(:docs) do |t|
4
+ # Options defined in `.yardopts` are read first, then merged with
5
+ # options defined here.
6
+ #
7
+ # It's recommended to define options in `.yardopts` instead of here,
8
+ # as `.yardopts` can be read by external YARD tools, like the
9
+ # hot-reload YARD server `yard server --reload`.
10
+
11
+ # t.options += ['--title', "Something custom"]
12
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nero
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-02-15 00:00:00.000000000 Z
10
+ date: 2025-04-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -53,6 +53,7 @@ files:
53
53
  - ".envrc"
54
54
  - ".rspec"
55
55
  - ".standard.yml"
56
+ - ".yardopts"
56
57
  - Appraisals
57
58
  - CHANGELOG.md
58
59
  - LICENSE.txt
@@ -61,9 +62,11 @@ files:
61
62
  - gemfiles/psych_3.gemfile
62
63
  - gemfiles/psych_4.gemfile
63
64
  - lib/nero.rb
65
+ - lib/nero/railtie.rb
64
66
  - lib/nero/util.rb
65
67
  - lib/nero/version.rb
66
68
  - rakelib/gem.rake
69
+ - rakelib/yard.rake
67
70
  - sig/nero.rbs
68
71
  homepage: https://github.com/eval/nero
69
72
  licenses: