nero 0.5.0 → 0.7.0.rc2

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: 4e28a4b8bcd989c20dd60dc592bfb31039314437b3d8ca5df33b79adefd5cb2e
4
- data.tar.gz: f6eb0ec1e9ceb041088a7d2b850494e090f08d98f4e8369f82f0608a4449ab69
3
+ metadata.gz: 69db9ab0dfd43a40d12a05bd3eb311d12a292a4ba7f1ce0c9f965e908344d659
4
+ data.tar.gz: 6c29320c5e9f8deafc4c3e207554f59df3e2a97240b73bcc3debefca0a6e3d04
5
5
  SHA512:
6
- metadata.gz: 98dc2c475e0e164f47d1738dbe32d6dfeeba6b531f6db0b396a25d3e29219c312ac0750206fddea1d9ee450e9205425a5f0342c6b5b13d38867a364e1219bed0
7
- data.tar.gz: e9daf1ee287679b10d4ea744c41d3f93bf50570394eb261f7c8b3587a1008efe57b58b7957141aeea48229b65efdaa614f23c181fbf08659a0a878a7a944e773
6
+ metadata.gz: 5351b2b97b1d49b3a405b5f829d0f716e190a9d9439dd5d54eb8ac673b11efaed1665e352a8973de4a80cde786fda5e0061646d63beafe2626f4fd95d25bf7df
7
+ data.tar.gz: 3a296f997e83ffd96a508553a638b74abbb0adef2e6487904a4c1be64809f2d5e62cfd7bc98ab0b1ee6e0092cf6888a01359209d540632325542e970102e11f3
data/.envrc CHANGED
@@ -1 +1,3 @@
1
- PATH_add bin
1
+ PATH_add bin
2
+
3
+ source_env_if_exists .envrc.local
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,5 +1,53 @@
1
1
  ## [Unreleased]
2
- ...
2
+
3
+ Total refactor and so quite some breaking changes.
4
+
5
+ ## Breaking Changes
6
+
7
+ - Nero.load / Nero.load_file removed — replaced by Nero.parse and Nero.parse_file. These return values directly (or raise Nero::ParseError).
8
+ - Nero.configure / Nero.configuration removed — custom tags are now registered via a block passed to parse/parse_file/config_for:
9
+ ```ruby
10
+ # Before
11
+ Nero.configure { |c| c.add_tag("rot/13", klass: RotTag[n: 13]) }
12
+ Nero.load("secret: !rot/13 uryyb")
13
+
14
+ # After
15
+ Nero.parse("secret: !rot/13 uryyb") { |c| c.add_tag("rot/13", RotTag.new(n: 13)) }
16
+ ```
17
+ - `Nero::Config` removed — results are plain Hashes (string keys) instead of a Config subclass with DigExt/dig!.
18
+ - `Nero::BaseTag` API changed — tags now implement resolve(args, context:) instead of accessing coder/ctx. Tag options are passed via initialize instead of init_options.
19
+ - !ref syntax changed — from sequence-based !ref [base, url] to dot-notation !ref base.url.
20
+ - root: option replaces root:/env: and uses string keys (e.g. root: :development matches the string key "development").
21
+ - Keys are strings — previously keys were symbolized; now they stay as strings.
22
+ - !uri and !path tags removed — !uri is gone entirely; !path is no longer a built-in.
23
+ - `Nero::PathRootTag` renamed to `Nero::RootPathTag` with a new constructor API.
24
+ - !str/format changed — now also available as !format; the map form (fmt: key) is replaced by a sequence with named-parameter hashes: !format ["http://%<host>s", host: !ref host].
25
+ - load_config removed (was already deprecated).
26
+ - Nero::Util removed — deep_symbolize_keys and deep_transform_values are no longer needed.
27
+ - NERO_ENV_ALL_OPTIONAL env var no longer supported.
28
+
29
+ ## [0.6.0] - 2025-04-10
30
+
31
+ ### deprecations
32
+
33
+ - `Nero.load_config` - use `Nero.load_file` or `Nero.config_for`.
34
+
35
+ ### other
36
+
37
+ - API docs live at https://eval.github.io/nero/
38
+ - Config for Rails
39
+ The `config.config_dir` is automatically setup, so `Nero.config_for` (formerly `Nero.load_config`) just works.
40
+ - `Nero::Config.dig!` ⛏️💥
41
+ Any (Hash-)result from `Nero.load/load_file/config_for` is now an instance of `Nero::Config`.
42
+ This class contains `dig!`, a fail-hard variant of `dig`:
43
+ ```ruby
44
+ Nero.load(<<~Y).dig!(:smtp_settings, :hose) # 💥 typo
45
+ smtp_settings:
46
+ host: 127.0.0.1
47
+ port: 1025
48
+ Y
49
+ #=> 'Nero::DigExt#dig!': path not found [:smtp_settings, :hose] (ArgumentError)
50
+ ```
3
51
 
4
52
  ## [0.5.0] - 2025-03-20
5
53
 
data/README.md CHANGED
@@ -37,7 +37,7 @@ production:
37
37
  * 💎 declarative YAML-tags for e.g. requiring and coercing env-vars
38
38
  * 🛠️ add custom tags
39
39
  * 🛤️ `Rails.application.config_for` drop-in
40
- * ♻️ Zeitwerk-only dependency
40
+ * ♻️ no dependencies
41
41
 
42
42
  ## Installation
43
43
 
@@ -47,6 +47,16 @@ Install the gem and add to the application's Gemfile by executing:
47
47
  bundle add nero
48
48
  ```
49
49
 
50
+ ## Configuration
51
+
52
+ ```ruby
53
+ parser = Nero::Parser.new do |config|
54
+ config.add_tag("str/upcase", ->(args, **) { args.join.upcase })
55
+ end
56
+ parser.parse("hello: !str/upcase world")
57
+ # => #<Nero::Result:0x00000001239f8de0 @errors=[], @value={"hello" => "WORLD"}>
58
+ ```
59
+
50
60
  ## Usage
51
61
 
52
62
  > [!WARNING]
@@ -56,7 +66,7 @@ bundle add nero
56
66
 
57
67
  Given the following config:
58
68
  ```yaml
59
- # config/settings.yml
69
+ # config/app.yml
60
70
  development:
61
71
  # env-var with a fallback
62
72
  secret: !env [SECRET, "dummy"]
@@ -73,7 +83,7 @@ Loading this config:
73
83
 
74
84
  ```ruby
75
85
  # Loading development
76
- Nero.load_config("config/settings", root: :development)
86
+ Nero.parse_file("config/app.yml", root: :development)
77
87
  # ...and no ENV-vars were provided
78
88
  #=> {secret: "dummy", debug?: false}
79
89
 
@@ -81,7 +91,7 @@ Nero.load_config("config/settings", root: :development)
81
91
  #=> {secret: "dummy", debug?: true}
82
92
 
83
93
  # Loading production
84
- Nero.load_config("config/settings", root: :production)
94
+ Nero.parse_file("config/app.yml", root: :production)
85
95
  # ...and no ENV-vars were provided
86
96
  # raises error: key not found: "SECRET" (KeyError)
87
97
 
@@ -89,15 +99,14 @@ Nero.load_config("config/settings", root: :production)
89
99
  #=> {secret: "s3cr3t", max_threads: 3}
90
100
  ```
91
101
  > [!TIP]
92
- > The following configuration would make `Nero.load_config` a drop-in replacement for [Rails.application.config_for](https://api.rubyonrails.org/classes/Rails/Application.html#method-i-config_for):
102
+ > You can also use `Nero.config_for(:app)` (similar to [Rails.application.config_for](https://api.rubyonrails.org/classes/Rails/Application.html#method-i-config_for)).
103
+ > In Rails applications this gets configured for you. For other application you might need to adjust the `config_dir`:
93
104
  ```ruby
94
- Nero.configure do |config|
95
- config.config_dir = Rails.root / "config"
96
- end
97
-
98
- Nero.load_config(:settings, env: Rails.env)
105
+ Nero.config_for(:settings, env: Rails.env)
99
106
  ```
100
107
 
108
+ [API Documentation](https://eval.github.io/nero/).
109
+
101
110
  ### built-in tags
102
111
 
103
112
  The following tags are provided:
@@ -169,9 +178,9 @@ $ env NERO_ENV_ALL_OPTIONAL=1 SECRET_KEY_BASE_DUMMY=1 rails asset:precompile
169
178
 
170
179
  # pass it a map (including a key 'fmt') to use references
171
180
  smtp_url: !str/format
172
- fmt: smtps://%<user>s:%<pass>s@smtp.gmail.com
173
- user: !env SMTP_USER
174
- pass: !env SMTP_PASS
181
+ - smtps://%<user>s:%<pass>s@smtp.gmail.com
182
+ - user: !env SMTP_USER
183
+ pass: !env SMTP_PASS
175
184
  ```
176
185
  - `!ref`
177
186
  Include values from elsewhere:
@@ -189,7 +198,7 @@ $ env NERO_ENV_ALL_OPTIONAL=1 SECRET_KEY_BASE_DUMMY=1 rails asset:precompile
189
198
  - !ref[base, url]
190
199
 
191
200
  # refs are resolved within the tree of the selected root.
192
- # The following config won't work when doing `Nero.load_config(:app, root: :prod)`
201
+ # The following config won't work when doing `Nero.load_file("config/app.yml", root: :prod)`
193
202
  dev:
194
203
  max_threads: 5
195
204
  prod:
@@ -199,13 +208,14 @@ $ env NERO_ENV_ALL_OPTIONAL=1 SECRET_KEY_BASE_DUMMY=1 rails asset:precompile
199
208
 
200
209
  ### custom tags
201
210
 
202
- Three ways to do this:
211
+ There's three ways to create your own tags.
212
+
213
+ For all these methods it's helpful to see the API-docs for [Nero::BaseTag](https://eval.github.io/nero/Nero/BaseTag.html).
203
214
 
204
- 1. a block
215
+ 1. **a proc**
205
216
  ```ruby
206
217
  Nero.configure do |nero|
207
- nero.add_tag("upcase") do |tag|
208
- # `tag` is a `Nero::BaseTag`.
218
+ nero.add_tag("upcase") do |args, context:|
209
219
  # In YAML args are provided as scalar, seq or map:
210
220
  # ---
211
221
  # k: !upcase bar
@@ -217,42 +227,38 @@ Three ways to do this:
217
227
  # k: !upcase
218
228
  # bar: baz
219
229
  #
220
- # Find these args via `tag.args` (Array or Hash):
221
- case tag.args
230
+ case args
222
231
  when Hash
223
- tag.args.each_with_object({}) {|(k,v), acc| acc[k] = v.upcase }
232
+ args.each_with_object({}) {|(k,v), acc| acc[k] = v.upcase }
224
233
  else
225
- tag.args.map(&:upcase)
234
+ args.map(&:upcase)
226
235
  end
227
236
 
228
- # NOTE though you might just need one argument, it's helpful to accept a seq nonetheless
229
- # as it allows for chaining:
237
+ # NOTE though a tag might just need one argument (ie scalar),
238
+ # it's helpful to accept a seq as it allows for chaining:
230
239
  # a: !my/inc 4 # scalar suffices
231
- # ...but when chaining, it comes as a seq:
232
- # a: !my/inc [!my/square 2]
240
+ # ...but when chaining, it needs to be a seq:
241
+ # a: !my/inc [ !my/square 2 ]
233
242
  end
234
243
  end
235
244
  ```
236
- 1. re-use existing tag-class
245
+ Blocks are passed instances of [Nero::BaseTag](https://eval.github.io/nero/Nero/BaseTag.html).
246
+ 1. **re-use existing tag-class**
237
247
  You can add an existing tag under a better fitting name this way.
238
248
  Also: some tag-classes have options that allow for simple customizations (like `coerce` below):
239
249
  ```ruby
240
250
  Nero.configure do |nero|
241
- nero.add_tag("env/upcase", klass: Nero::EnvTag[coerce: :upcase])
242
-
243
251
  # Alias for path/git_root:
244
- nero.add_tag("path/project_root", klass: Nero::PathRootTag[containing: '.git'])
252
+ nero.add_tag("path/project_root", Nero::RootPathTag.new(".git"))
245
253
  end
246
254
  ```
247
- 1. custom class
255
+ 1. **custom class**
248
256
  ```ruby
249
257
  class RotTag < Nero::BaseTag
250
258
  # Configure:
251
259
  # ```
252
- # config.add_tag("rot/12", klass: RotTag[n: 12])
253
- # config.add_tag("rot/10", klass: RotTag[n: 10]) do |secret|
254
- # "#{secret} (try breaking this!)"
255
- # end
260
+ # config.add_tag("rot/12", RotTag.new(12))
261
+ # config.add_tag("rot/10", RotTag.new(10))
256
262
  # ```
257
263
  #
258
264
  # Usage in YAML:
@@ -260,35 +266,52 @@ Three ways to do this:
260
266
  # secret: !rot/12 some message
261
267
  # very_secret: !rot/10 [ !env [ MSG, some message ] ]
262
268
  # ```
263
- # => {secret: "EAyq yqEEmsq", very_secret: "Cywo woCCkqo (try breaking this!)"}
264
-
265
- # By overriding `init_options` we can restrict/require options,
266
- # provide default values and do any other setup.
267
- # By default an option is available via `options[:foo]`.
268
- def init_options(n: 10)
269
- super # no specific assignments, so available via `options[:n]`.
270
- end
269
+ # => {secret: "EAyq yqEEmsq", very_secret: "Cywo woCCkqo)"}
271
270
 
272
271
  def chars
273
272
  @chars ||= (('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a)
274
273
  end
275
274
 
276
- def resolve(**) # currently no keywords are passed, but `**` allows for future ones.
275
+ def resolve(args, context:)
277
276
  # Here we actually do the work: get the args, rotate strings and delegate to the block.
278
277
  # `args` are the resolved nested args (so e.g. `!env MSG` is already resolved).
279
- # `config` is the tag's config, and contains e.g. the block.
280
- block = config.fetch(:block, :itself.to_proc)
281
278
  # String#tr replaces any character from the first collection with the same position in the other:
282
- args.join.tr(chars.join, chars.rotate(options[:n]).join).then(&block)
279
+ args.join.tr(chars.join, chars.rotate(@n).join)
283
280
  end
284
281
  end
285
282
  ```
286
283
 
287
284
  ## Development
288
285
 
289
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
286
+ ```bash
287
+ # Setup
288
+ bin/setup # Make sure it exits with code 0
289
+
290
+ # Run tests
291
+ rake
292
+ ```
290
293
 
291
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
294
+ Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
295
+
296
+ ### Releasing
297
+
298
+ 1. Update `lib/bonchi/version.rb`
299
+ ```
300
+ bin/rake 'gem:write_version[0.5.0]'
301
+ # commit&push
302
+ # check CI
303
+ ```
304
+ 1. Tag
305
+ ```
306
+ gem_push=no bin/rake release
307
+ ```
308
+ 1. Release workflow from GitHub Actions...
309
+ - ...publishes to RubyGems (with Sigstore attestation)
310
+ - ...creates git GitHub release after successful publish
311
+ 1. Update `version.rb` for next dev-cycle
312
+ ```
313
+ bin/rake 'gem:write_version[0.6.0.dev]'
314
+ ```
292
315
 
293
316
  ## Contributing
294
317
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class BaseTag
5
+ def resolve(args, context:)
6
+ raise NotImplementedError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class Context
5
+ attr_reader :errors, :environ, :dir
6
+
7
+ def initialize(environ:, errors:, dir: nil)
8
+ @environ = environ
9
+ @errors = errors
10
+ @dir = dir || File.realpath(".")
11
+ end
12
+
13
+ def add_error(message)
14
+ @errors << Error.new(message)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class Deferred
5
+ attr_reader :tag, :args
6
+
7
+ def initialize(tag, args) = (@tag, @args = tag, args)
8
+ end
9
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class EnvTag < BaseTag
5
+ def initialize(coerce: nil, optional: false)
6
+ @coerce = coerce
7
+ @optional = optional
8
+ end
9
+
10
+ def resolve(args, context:)
11
+ var_name = args[0]
12
+ default = args[1]
13
+ raw = context.environ[var_name]
14
+
15
+ if raw.nil? && default.nil?
16
+ context.add_error("environment variable #{var_name} is not set") unless @optional
17
+ return nil
18
+ end
19
+
20
+ value = raw || default.to_s
21
+
22
+ return value unless @coerce
23
+
24
+ begin
25
+ @coerce.call(value)
26
+ rescue ArgumentError => e
27
+ context.add_error("cannot coerce #{var_name}=#{value.inspect}: #{e.message}")
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/nero/error.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class Error
5
+ attr_reader :message
6
+
7
+ def initialize(message)
8
+ @message = message
9
+ end
10
+
11
+ def to_s = message
12
+ end
13
+
14
+ class ParseError < StandardError
15
+ attr_reader :errors
16
+
17
+ def initialize(errors)
18
+ @errors = errors
19
+ super(errors.map(&:message).join(", "))
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class FormatTag < BaseTag
5
+ def resolve(args, context:)
6
+ template = args[0]
7
+ opts = args.each_with_object({}) do |arg, h|
8
+ h.merge!(arg.transform_keys(&:to_sym)) if arg.is_a?(Hash)
9
+ end
10
+ format(template, opts)
11
+ rescue KeyError => e
12
+ context.add_error("format error: #{e.message}")
13
+ nil
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class Parser
5
+ TO_INT = ->(v) { Integer(v) }
6
+ TO_FLOAT = ->(v) { Float(v) }
7
+ TO_BOOL = ->(v) { !%w[0 false no off].include?(v.downcase) }
8
+ TO_PATH = ->(v) { Pathname.new(v) }
9
+
10
+ def initialize(environ: ENV, root: nil, &block)
11
+ @environ = environ
12
+ @root = root&.to_s
13
+ @tags = {}
14
+ add_tag("env", EnvTag.new)
15
+ add_tag("env?", EnvTag.new(optional: true))
16
+ add_tag("env/int", EnvTag.new(coerce: TO_INT))
17
+ add_tag("env/int?", EnvTag.new(coerce: TO_INT, optional: true))
18
+ add_tag("env/integer", EnvTag.new(coerce: TO_INT))
19
+ add_tag("env/integer?", EnvTag.new(coerce: TO_INT, optional: true))
20
+ add_tag("env/bool", EnvTag.new(coerce: TO_BOOL))
21
+ add_tag("env/bool?", EnvTag.new(coerce: TO_BOOL, optional: true))
22
+ add_tag("env/boolean", EnvTag.new(coerce: TO_BOOL))
23
+ add_tag("env/boolean?", EnvTag.new(coerce: TO_BOOL, optional: true))
24
+ add_tag("env/float", EnvTag.new(coerce: TO_FLOAT))
25
+ add_tag("env/float?", EnvTag.new(coerce: TO_FLOAT, optional: true))
26
+ add_tag("env/path", EnvTag.new(coerce: TO_PATH))
27
+ add_tag("env/path?", EnvTag.new(coerce: TO_PATH, optional: true))
28
+ add_tag("str/format", FormatTag.new)
29
+ add_tag("format", FormatTag.new)
30
+ add_tag("ref", RefTag.new)
31
+ block&.call(self)
32
+ end
33
+
34
+ def add_tag(name, handler)
35
+ @tags[name] = handler.is_a?(Proc) ? ProcTag.new(handler) : handler
36
+ end
37
+
38
+ def parse(yaml, dir: nil)
39
+ errors = []
40
+ tree = ::Psych.parse_stream(yaml)
41
+ ctx = Context.new(environ: @environ, errors: errors, dir: dir)
42
+
43
+ if @root
44
+ mark_inactive_roots(tree)
45
+ end
46
+
47
+ visitor = Visitor.build(@tags, ctx)
48
+ result = visitor.accept(tree)
49
+ value = result.first
50
+
51
+ if @root
52
+ unless value.is_a?(Hash) && value.key?(@root)
53
+ ctx.add_error("root #{@root.inspect} not found in top-level keys")
54
+ return Result.new(nil, errors.freeze)
55
+ end
56
+ value = value[@root]
57
+ end
58
+
59
+ value = resolve_refs(value, value, ctx) if contains_ref?(value)
60
+ Result.new(value, errors.freeze)
61
+ end
62
+
63
+ def parse_file(path)
64
+ dir = File.dirname(File.realpath(path))
65
+ parse(File.read(path), dir: dir)
66
+ end
67
+
68
+ private
69
+
70
+ def mark_inactive_roots(tree)
71
+ doc = tree.children.first
72
+ mapping = doc&.root
73
+ return unless mapping.is_a?(::Psych::Nodes::Mapping)
74
+
75
+ root_node = mapping.children.each_slice(2).find { |k, _| k.value == @root }&.last
76
+ used_anchors = root_node ? collect_aliases(root_node) : Set.new
77
+
78
+ mapping.children.each_slice(2) do |key_node, val_node|
79
+ next if key_node.value == @root
80
+ next if used_anchors.include?(val_node.anchor)
81
+
82
+ strip_custom_tags(val_node)
83
+ end
84
+ end
85
+
86
+ def collect_aliases(node, result = Set.new)
87
+ if node.is_a?(::Psych::Nodes::Alias)
88
+ result << node.anchor
89
+ elsif node.respond_to?(:children) && node.children
90
+ node.children.each { |child| collect_aliases(child, result) }
91
+ end
92
+ result
93
+ end
94
+
95
+ def strip_custom_tags(node)
96
+ case node
97
+ when ::Psych::Nodes::Scalar, ::Psych::Nodes::Sequence, ::Psych::Nodes::Mapping
98
+ node.tag = nil if node.tag&.start_with?("!")
99
+ end
100
+ return unless node.respond_to?(:children) && node.children
101
+ node.children.each { |child| strip_custom_tags(child) }
102
+ end
103
+
104
+ def contains_ref?(value)
105
+ case value
106
+ when Ref, Deferred then true
107
+ when Hash then value.values.any? { |v| contains_ref?(v) }
108
+ when Array then value.any? { |v| contains_ref?(v) }
109
+ else false
110
+ end
111
+ end
112
+
113
+ def resolve_refs(value, root, ctx, visited = Set.new)
114
+ case value
115
+ when Ref
116
+ key = value.path.join(".")
117
+ if visited.include?(key)
118
+ ctx.add_error("circular reference: #{key}")
119
+ return nil
120
+ end
121
+ target = value.path.reduce(root) { |h, k| h.is_a?(Hash) ? h[k] : nil }
122
+ if target.nil?
123
+ ctx.add_error("unknown ref #{key}")
124
+ return nil
125
+ end
126
+ resolve_refs(target, root, ctx, visited | [key])
127
+ when Deferred
128
+ resolved_args = resolve_refs(value.args, root, ctx, visited)
129
+ value.tag.resolve(resolved_args, context: ctx)
130
+ when Hash
131
+ value.transform_values { |v| resolve_refs(v, root, ctx, visited) }
132
+ when Array
133
+ value.map { |v| resolve_refs(v, root, ctx, visited) }
134
+ else
135
+ value
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ class ProcTag < BaseTag
5
+ def initialize(callable) = @callable = callable
6
+
7
+ def resolve(args, context:) = @callable.call(args, context:)
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ module Rails
5
+ class CredentialsTag < BaseTag
6
+ attr_reader :credentials
7
+
8
+ # TODO lookup?
9
+ def initialize(credentials)
10
+ @credentials = credentials
11
+ end
12
+
13
+ def resolve(args, context:)
14
+ keys = args.map(&:to_sym)
15
+ #value = ::Rails.application.credentials.dig(*keys)
16
+ value = credentials.dig(*keys)
17
+ return value if value
18
+
19
+ context.add_error("credential #{args.join(".")} not found")
20
+ nil
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ module Rails
5
+ class DurationTag < BaseTag
6
+ UNITS = %w[seconds minutes hours days weeks months years].freeze
7
+
8
+ def resolve(args, context:)
9
+ amount = args[0]
10
+ unit = args[1]&.to_s
11
+
12
+ unless amount.is_a?(Numeric)
13
+ context.add_error("duration requires a numeric amount, got #{amount.inspect}")
14
+ return nil
15
+ end
16
+
17
+ unless UNITS.include?(unit)
18
+ context.add_error("duration unknown unit #{unit.inspect}, expected one of: #{UNITS.join(", ")}")
19
+ return nil
20
+ end
21
+
22
+ amount.public_send(unit)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nero
4
+ module Rails
5
+ class StringInquirerTag < BaseTag
6
+ def resolve(args, context:)
7
+ value = args[0]&.to_s
8
+ if value.nil? || value.empty?
9
+ context.add_error("str/inquirer requires a non-empty string argument")
10
+ return nil
11
+ end
12
+ ActiveSupport::StringInquirer.new(value)
13
+ end
14
+ end
15
+ end
16
+ end