aspera-cli 4.27.2 → 4.27.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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +50 -0
  4. data/bin/ascli +2 -1
  5. data/docs/README.md +804 -746
  6. data/lib/aspera/agent/connect.rb +6 -4
  7. data/lib/aspera/agent/desktop.rb +2 -2
  8. data/lib/aspera/agent/direct.rb +3 -1
  9. data/lib/aspera/agent/node.rb +3 -3
  10. data/lib/aspera/api/alee.rb +1 -1
  11. data/lib/aspera/api/aoc.rb +14 -12
  12. data/lib/aspera/api/ats.rb +1 -1
  13. data/lib/aspera/api/cos_node.rb +2 -2
  14. data/lib/aspera/api/faspex.rb +9 -7
  15. data/lib/aspera/api/httpgw.rb +37 -33
  16. data/lib/aspera/api/node.rb +38 -33
  17. data/lib/aspera/ascmd.rb +3 -1
  18. data/lib/aspera/ascp/installation.rb +62 -27
  19. data/lib/aspera/ascp/management.rb +1 -0
  20. data/lib/aspera/assert.rb +4 -0
  21. data/lib/aspera/cli/ascp_actions.rb +20 -41
  22. data/lib/aspera/cli/async_transfer_store.rb +2 -2
  23. data/lib/aspera/cli/bootstrapper.rb +11 -15
  24. data/lib/aspera/cli/command_line.rb +252 -0
  25. data/lib/aspera/cli/command_registry.rb +149 -33
  26. data/lib/aspera/cli/command_spec.rb +103 -14
  27. data/lib/aspera/cli/completion/ascli.bash +12 -0
  28. data/lib/aspera/cli/completion/ascli.fish +16 -0
  29. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  30. data/lib/aspera/cli/context.rb +3 -0
  31. data/lib/aspera/cli/deprecation.rb +37 -0
  32. data/lib/aspera/cli/extended_value.rb +2 -0
  33. data/lib/aspera/cli/formatter.rb +87 -75
  34. data/lib/aspera/cli/gem_checker.rb +1 -1
  35. data/lib/aspera/cli/hints.rb +7 -6
  36. data/lib/aspera/cli/http.rb +21 -21
  37. data/lib/aspera/cli/info.rb +3 -0
  38. data/lib/aspera/cli/mcp_tool.rb +47 -83
  39. data/lib/aspera/cli/option_declarator.rb +33 -42
  40. data/lib/aspera/cli/option_registry.rb +69 -0
  41. data/lib/aspera/cli/option_types.rb +103 -0
  42. data/lib/aspera/cli/option_value.rb +281 -0
  43. data/lib/aspera/cli/options.schema.yaml +38 -5
  44. data/lib/aspera/cli/parser.rb +307 -848
  45. data/lib/aspera/cli/plugins/alee.rb +7 -4
  46. data/lib/aspera/cli/plugins/aoc.rb +430 -380
  47. data/lib/aspera/cli/plugins/ats.rb +58 -73
  48. data/lib/aspera/cli/plugins/base.rb +190 -240
  49. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  50. data/lib/aspera/cli/plugins/config.rb +244 -178
  51. data/lib/aspera/cli/plugins/console.rb +102 -38
  52. data/lib/aspera/cli/plugins/cos.rb +6 -23
  53. data/lib/aspera/cli/plugins/factory.rb +3 -0
  54. data/lib/aspera/cli/plugins/faspex5.rb +176 -173
  55. data/lib/aspera/cli/plugins/faspio.rb +5 -10
  56. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  57. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  58. data/lib/aspera/cli/plugins/node.rb +277 -311
  59. data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
  60. data/lib/aspera/cli/plugins/preview.rb +79 -90
  61. data/lib/aspera/cli/plugins/server.rb +76 -50
  62. data/lib/aspera/cli/plugins/shares.rb +68 -116
  63. data/lib/aspera/cli/preset_actions.rb +17 -10
  64. data/lib/aspera/cli/preset_manager.rb +12 -2
  65. data/lib/aspera/cli/prompt.rb +35 -0
  66. data/lib/aspera/cli/result.rb +13 -18
  67. data/lib/aspera/cli/runner.rb +31 -54
  68. data/lib/aspera/cli/special_values.rb +5 -0
  69. data/lib/aspera/cli/sync_actions.rb +41 -37
  70. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  71. data/lib/aspera/cli/transfer_actions.rb +0 -6
  72. data/lib/aspera/cli/transfer_agent.rb +29 -35
  73. data/lib/aspera/cli/vault_manager.rb +0 -17
  74. data/lib/aspera/cli/version.rb +1 -1
  75. data/lib/aspera/cli/wizard.rb +4 -2
  76. data/lib/aspera/coverage.rb +1 -0
  77. data/lib/aspera/environment.rb +6 -0
  78. data/lib/aspera/faspex_gw.rb +2 -1
  79. data/lib/aspera/faspex_postproc.rb +1 -0
  80. data/lib/aspera/graphql.rb +5 -5
  81. data/lib/aspera/json_rpc/client.rb +5 -5
  82. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  83. data/lib/aspera/keychain/one_password_api.rb +1 -1
  84. data/lib/aspera/link_header.rb +2 -2
  85. data/lib/aspera/log.rb +22 -25
  86. data/lib/aspera/markdown.rb +2 -0
  87. data/lib/aspera/mime.rb +25 -0
  88. data/lib/aspera/node_simulator.rb +1 -0
  89. data/lib/aspera/oauth/base.rb +35 -25
  90. data/lib/aspera/oauth/factory.rb +1 -0
  91. data/lib/aspera/oauth/generic.rb +1 -1
  92. data/lib/aspera/oauth/jwt.rb +1 -1
  93. data/lib/aspera/oauth/web.rb +9 -8
  94. data/lib/aspera/preview/file_types.rb +4 -4
  95. data/lib/aspera/preview/generator.rb +7 -0
  96. data/lib/aspera/preview/options.rb +4 -4
  97. data/lib/aspera/preview/terminal.rb +4 -3
  98. data/lib/aspera/preview/utils.rb +9 -6
  99. data/lib/aspera/products/connect.rb +1 -1
  100. data/lib/aspera/rainbow.rb +7 -0
  101. data/lib/aspera/rest/aspera_errors.rb +60 -0
  102. data/lib/aspera/rest/call_error.rb +27 -0
  103. data/lib/aspera/rest/client.rb +514 -0
  104. data/lib/aspera/rest/error_analyzer.rb +113 -0
  105. data/lib/aspera/rest/list.rb +143 -0
  106. data/lib/aspera/rest/parameters.rb +55 -0
  107. data/lib/aspera/rest/util.rb +176 -0
  108. data/lib/aspera/rest.rb +7 -621
  109. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  110. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  111. data/lib/aspera/schema/documentation.rb +13 -3
  112. data/lib/aspera/schema/registry.rb +18 -1
  113. data/lib/aspera/schema/validator.rb +92 -0
  114. data/lib/aspera/secret_hider.rb +36 -25
  115. data/lib/aspera/string_ext.rb +15 -0
  116. data/lib/aspera/temp_file_manager.rb +6 -5
  117. data/lib/aspera/transfer/parameters.rb +2 -0
  118. data/lib/aspera/transfer/spec.rb +1 -0
  119. data/lib/aspera/uri_reader.rb +11 -11
  120. data/lib/aspera/web_auth/index.html +147 -0
  121. data/lib/aspera/web_auth/server.rb +81 -0
  122. data.tar.gz.sig +0 -0
  123. metadata +39 -7
  124. metadata.gz.sig +0 -0
  125. data/lib/aspera/colors.rb +0 -79
  126. data/lib/aspera/rest_call_error.rb +0 -25
  127. data/lib/aspera/rest_error_analyzer.rb +0 -111
  128. data/lib/aspera/rest_errors_aspera.rb +0 -58
  129. data/lib/aspera/rest_list.rb +0 -136
  130. data/lib/aspera/web_auth.rb +0 -211
@@ -5,417 +5,41 @@ require 'aspera/cli/extended_value'
5
5
  require 'aspera/cli/error'
6
6
  require 'aspera/cli/special_values'
7
7
  require 'aspera/cli/terminal_formatter'
8
- require 'aspera/colors'
9
- require 'aspera/secret_hider'
8
+ require 'aspera/cli/option_types'
9
+ require 'aspera/cli/option_registry'
10
+ require 'aspera/cli/command_line'
11
+ require 'aspera/cli/prompt'
12
+ require 'aspera/schema/validator'
10
13
  require 'aspera/log'
11
14
  require 'aspera/assert'
12
15
  require 'aspera/dot_container'
13
- require 'aspera/schema/registry'
14
- require 'io/console'
15
16
  require 'terminal-table'
17
+ require 'aspera/rainbow'
18
+ using Rainbow
16
19
 
17
20
  module Aspera
18
21
  module Cli
19
- # Exception raised when schema is asked (`help`)
20
- class SchemaRequest < Error
21
- # @return [String, nil] path to schema file
22
- attr_reader :path
23
-
24
- # @param type [Symbol] :argument or :option
25
- # @param name [String] name of the option/argument
26
- # @param schema_path [String, nil] path to schema file, or `nil` if not available
27
- def initialize(type, name, schema_path)
28
- super("#{type}: #{name}")
29
- @path = schema_path
30
- end
31
- end
32
-
33
- module BoolValue
34
- # boolean options are set to true/false from the following values
35
- YES_SYM = :yes
36
- NO_SYM = :no
37
- FALSE_VALUES = [NO_SYM, false].freeze
38
- TRUE_VALUES = [YES_SYM, true].freeze
39
- private_constant :YES_SYM, :NO_SYM, :FALSE_VALUES, :TRUE_VALUES
40
- # Boolean values
41
- # @return [Array<true, false, :yes, :no>]
42
- ALL = (TRUE_VALUES + FALSE_VALUES).freeze
43
- # `false` and `true`
44
- TYPES = [FalseClass, TrueClass].freeze
45
- SYMBOLS = [NO_SYM, YES_SYM].freeze
46
- # @return [Boolean] `true` if value is a value for `true` in ALL
47
- def true?(enum)
48
- Aspera.assert_values(enum, ALL) { 'boolean' }
49
- TRUE_VALUES.include?(enum)
50
- end
51
-
52
- # @return [:yes, :no]
53
- def to_sym(enum) = true?(enum) ? YES_SYM : NO_SYM
54
-
55
- # @return [Boolean] `true` if value is a value for `true` or `false` in ALL
56
- def symbol?(sym)
57
- ALL.include?(sym)
58
- end
59
- module_function :true?, :to_sym, :symbol?
60
- end
61
-
62
- # Type specifiers for the `allowed:` parameter of option declarations.
63
- # Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, BOOLEAN, NONE.
64
- # Internal (do not pass as `allowed:`):
65
- # ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
66
- # STRING - the implicit default; equivalent to omitting `allowed:` entirely
67
- module Type
68
- # Option value is a String or Array of Strings (cumulative)
69
- STRING_ARRAY = [Array, String].freeze
70
- # Option value is a Symbol from a constrained list; use as prefix: SYMBOL_ARRAY + [:val1, :val2]
71
- SYMBOL_ARRAY = [Array, Symbol].freeze
72
- # Option value is coerced to Integer
73
- INTEGER = [Integer].freeze
74
- # Option value is a Boolean
75
- BOOLEAN = BoolValue::TYPES
76
- # Option has no value — it is a flag switch (e.g. `-N`, `--help`)
77
- NONE = [].freeze
78
- # Internal: derived when allowed: is an Array<Symbol>; do not pass directly
79
- ENUM = [Symbol].freeze
80
- # Internal: implicit default (String); equivalent to omitting allowed: entirely
81
- STRING = [String].freeze
82
- end
83
-
84
- # Description of option, how to manage
85
- class OptionValue
86
- # [Array(Class)] List of allowed types
87
- attr_reader :types, :sensitive, :schema, :option, :deprecation
88
- # [Array] List of allowed values (Symbols and specific values)
89
- attr_accessor :values
90
- # [String] Help section group name (set by Parser#group)
91
- attr_accessor :group
92
- # [Proc, nil] Block to call for flag options (TYPES_NONE)
93
- attr_accessor :block
94
-
95
- # @param option [Symbol] Name of option
96
- # @param description [String, nil] Description for help; if nil, derived from schema
97
- # @param allowed [nil,Class,Array<Class>,Array<Symbol>] Allowed values
98
- # @param handler [Hash, nil] Accessor: keys: :o(object) and :m(method); nil for local storage
99
- # @param deprecation [String] Deprecation message
100
- # @param schema [String] Declaration of schema
101
- # `allowed`:
102
- # - `nil` No validation, so just a string
103
- # - `Class` The single allowed Class
104
- # - `Array<Class>` Multiple allowed classes
105
- # - `Array<Symbol>` List of allowed values
106
- def initialize(option:, description: nil, allowed: Type::STRING, handler: nil, deprecation: nil, schema: nil)
107
- Log.log.trace1 { "option: #{option}, allowed: #{allowed}" }
108
- @option = option
109
- @description = description
110
- @group = nil
111
- @block = nil
112
- # by default passwords and secrets are sensitive, else specify when declaring the option
113
- @sensitive = SecretHider.instance.secret?(@option, '')
114
- @deprecation = deprecation
115
- @schema = schema
116
- # Start with local storage; bind_handler wires the delegation if a handler is given.
117
- @object = nil
118
- @read_method = nil
119
- @write_method = nil
120
- @access = :local
121
- bind_handler(handler) unless handler.nil?
122
- @types = nil
123
- @values = nil
124
- allowed = infer_allowed_from_schema(schema, allowed) if schema
125
- apply_allowed(allowed) unless allowed.nil?
126
- Log.log.trace1 { "declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
127
- end
128
-
129
- private
130
-
131
- # Derive the `allowed:` value from the schema when not explicitly provided.
132
- # Returns `allowed` unchanged when the schema provides no usable type information.
133
- # @param schema [String] schema identifier
134
- # @param allowed [Object] caller-supplied allowed value (may be nil or Type::STRING)
135
- # @return [Object] resolved allowed (Hash, Array, or the original value)
136
- def infer_allowed_from_schema(schema, allowed)
137
- return allowed unless allowed.nil? || allowed.eql?(Type::STRING)
138
-
139
- schema_reader = Schema::Registry.instance.reader(schema) rescue nil
140
- schema_node = schema_reader&.current
141
- return allowed unless schema_node
142
-
143
- case schema_node['type']
144
- when 'object' then return Hash
145
- when 'array' then return Array
146
- end
147
- # No top-level type: inspect oneOf/anyOf branches; if all resolve to 'object', infer Hash
148
- composite_key = (%w[oneOf anyOf] & schema_node.keys).first
149
- return allowed unless composite_key
150
-
151
- branch_types = schema_node[composite_key].map do |branch|
152
- resolved = branch['$ref'] ? schema_reader.resolve_ref(branch['$ref']).current : branch
153
- resolved['type']
154
- end
155
- if branch_types.all?('object')
156
- Hash
157
- else
158
- Aspera.assert(
159
- !allowed.nil? && !allowed.eql?(Type::STRING),
160
- "option :#{@option}: schema '#{schema}' has mixed-type oneOf branches #{branch_types.uniq}: specify allowed: explicitly"
161
- )
162
- allowed
163
- end
164
- end
165
-
166
- # Initialise @types and @values from the resolved `allowed` specifier.
167
- # @param allowed [Class, Array<Class>, Array<Symbol>] resolved allowed value (never nil)
168
- def apply_allowed(allowed)
169
- allowed = [allowed] if allowed.is_a?(Class)
170
- Aspera.assert_type(allowed, Array)
171
- if allowed.take(Type::SYMBOL_ARRAY.length) == Type::SYMBOL_ARRAY
172
- # Special case: array of defined symbol values
173
- @types = Type::SYMBOL_ARRAY
174
- @values = allowed[Type::SYMBOL_ARRAY.length..]
175
- assign_value([], where: 'array default', warn_deprecation: false) if value(log: false).nil?
176
- elsif allowed.all?(Class)
177
- @types = allowed
178
- @values = BoolValue::ALL if allowed.eql?(Type::BOOLEAN)
179
- if @types.first.eql?(Array) && !@types.include?(NilClass) && value(log: false).nil?
180
- assign_value([], where: 'array default', warn_deprecation: false)
181
- elsif @types.first.eql?(Hash) && !@types.include?(NilClass) && value(log: false).nil?
182
- assign_value({}, where: 'hash default', warn_deprecation: false)
183
- end
184
- elsif allowed.all?(Symbol)
185
- @types = Type::ENUM
186
- @values = allowed
187
- else
188
- Aspera.error_unexpected_value(allowed)
189
- end
190
- end
191
-
192
- public
193
-
194
- # Wire (or re-wire) the getter/setter delegation for this option.
195
- # Safe to call after construction - used by Parser#set_handler to bind a composed
196
- # instance variable that did not exist at class-load time (Category C handlers).
197
- # @param handler [Hash] Accessor hash with keys :o (object) and :m (method symbol)
198
- # @return [nil]
199
- def bind_handler(handler)
200
- Aspera.assert_type(handler, Hash) { 'handler' }
201
- # Capture any value already stored locally before switching to delegated storage.
202
- # This transfers defaults (and any preset values already applied) to the new target.
203
- pending_value = @access.eql?(:local) ? @object : nil
204
- @object = handler[:o]
205
- @read_method = handler[:m]
206
- @write_method = "#{@read_method}=".to_sym
207
- @access = if @object.respond_to?(@write_method)
208
- :attr # delegation via attr_accessor-style m / m=
209
- else
210
- :custom # delegation via generic 3-arg method m(sym, :get/:set, val)
211
- end
212
- Aspera.assert(@object.respond_to?(@read_method)) { "#{@object} does not respond to #{@read_method}" }
213
- Log.log.trace1 { "bind_handler: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
214
- # Push the pending local value to the new target if one was stored
215
- assign_value(pending_value, where: 'bind_handler', warn_deprecation: false) unless pending_value.nil?
216
- nil
217
- end
218
-
219
- # @return [String] description of the option: explicit one, or first line of schema description
220
- def description
221
- return @description unless @description.nil?
222
- return if @schema.nil?
223
- schema_node = Schema::Registry.instance.reader(@schema).current
224
- first_line = (schema_node['title'] || schema_node['description'].to_s).lines.first.to_s.strip
225
- first_line.end_with?('.') ? first_line[0..-2] : first_line
226
- end
227
-
228
- # Reset stored value to nil
229
- # @return [nil]
230
- def clear
231
- @object = nil
232
- end
233
-
234
- # Get current option value
235
- # @param log [Boolean] whether to log the value retrieval
236
- # @return [Object] current value
237
- def value(log: true)
238
- current_value =
239
- case @access
240
- when :local then @object
241
- when :attr then @object.send(@read_method)
242
- when :custom then @object.send(@read_method, @option, :get)
243
- end
244
- Log.log.trace1 { "#{@option} -> (#{current_value.class})#{current_value}" } if log
245
- current_value
246
- end
247
-
248
- # Assign value to option.
249
- # Value can be a `String`, then evaluated with `ExtendedValue`, or directly a value.
250
- # @param value [String, Object] Value to assign to option
251
- # @param where [String] Where the value is assigned from
252
- # @param warn_deprecation [Boolean] Emit deprecation warning (false for internal transfers)
253
- # @return [nil]
254
- def assign_value(value, where:, warn_deprecation: true)
255
- Aspera.assert(!@deprecation, type: :warn) { "Option #{@option} is deprecated: #{@deprecation}" } if warn_deprecation
256
- new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
257
- Log.log.trace1 { "#{where}: #{@option} <- (#{new_value.class})#{new_value}" }
258
- # Per-type coercion: String input from CLI/env/preset is normalized to the expected type.
259
- # Centralized here so all sources (CLI dispatch, preset, env) go through the same path.
260
- case @types
261
- when Type::ENUM
262
- new_value = Parser.get_from_list(new_value, @option, @values) if new_value.is_a?(String)
263
- when Type::BOOLEAN
264
- new_value = Parser.get_from_list(new_value, @option, BoolValue::ALL) if new_value.is_a?(String)
265
- new_value = BoolValue.true?(new_value)
266
- when Type::INTEGER
267
- new_value = Integer(new_value)
268
- when Type::STRING_ARRAY
269
- new_value = [new_value] if new_value.is_a?(String)
270
- when Type::SYMBOL_ARRAY
271
- new_value = [new_value] if new_value.is_a?(String)
272
- Aspera.assert_array_all(new_value, String, type: BadArgument)
273
- new_value = new_value.map { |v| Parser.get_from_list(v, @option, @values) }
274
- else
275
- # nil (setting nil on a Hash/Array option resets to empty container)
276
- new_value = {} if new_value.nil? && @types&.first.eql?(Hash)
277
- new_value = [] if new_value.nil? && @types&.first.eql?(Array)
278
- end
279
- # Skip type validation for the special 'help' value on Hash options: store it as-is
280
- # so that get_option(schema:) can raise SchemaRequest with the contextual schema later.
281
- # Note: set_option already raises SchemaRequest when @schema is set, so this path is
282
- # only reached when @schema is nil (e.g. --query=help before schema is known).
283
- if new_value.eql?(Parser::HELP) && @types&.include?(Hash)
284
- store(new_value)
285
- return
286
- end
287
- Aspera.assert_type(new_value, *@types, type: BadArgument) { "Option #{@option}" } if @types
288
- if new_value.is_a?(Hash) || new_value.is_a?(Array)
289
- current_value = value(log: false)
290
- new_value = current_value.deep_merge(new_value) if new_value.is_a?(Hash) && current_value.is_a?(Hash) && !current_value.empty?
291
- new_value = current_value + new_value if new_value.is_a?(Array) && current_value.is_a?(Array) && !current_value.empty?
292
- end
293
- store(new_value)
294
- Log.log.trace1 { v = value(log: false); "#{@option} <- (#{v.class})#{v}" } # rubocop:disable Style/Semicolon
295
- nil
296
- end
297
-
298
- private
299
-
300
- # Store value according to access mode
301
- # @param new_value [Object] value to store
302
- # @return [Object] stored value
303
- def store(new_value)
304
- case @access
305
- when :local then @object = new_value
306
- when :attr then @object.send(@write_method, new_value)
307
- when :custom then @object.send(@read_method, @option, :set, new_value)
308
- end
309
- end
310
- end
311
-
312
- # Represents a positional (non-option) CLI argument token.
313
- class Argument
314
- # @return [String] the raw argument value
315
- attr_reader :value
316
-
317
- def initialize(value)
318
- @value = value
319
- end
320
-
321
- def to_s = @value
322
- end
323
-
324
- # Represents a parsed CLI option token (long or short form).
325
- # Pre-computed at argv-scan time; resolution against @declared_options happens later in parse_options!
326
- class Option
327
- # @return [String] raw token as it appeared in argv (e.g. "--log-level=debug", "-Pval")
328
- attr_reader :raw
329
- # @return [String, nil] option name with underscores (e.g. "log_level", "custom"); nil for short options
330
- attr_reader :name
331
- # @return [String, nil] single-char short option letter (e.g. "P"), nil for long options
332
- attr_reader :short_char
333
- # @return [Array<String>, nil] sub-keys for dot-path notation (e.g. ["field"] for --custom.field),
334
- # nil when name is the full option name (no dot)
335
- attr_reader :dot_path
336
- # @return [String, nil] inline value string, or nil if no value was provided inline
337
- attr_reader :value
338
- # @return [Boolean] true if `=` (long) or glued value (short) was present in the raw token;
339
- # false means no inline separator — value may come from the next argv token
340
- attr_reader :has_value
341
-
342
- # @param raw [String] full raw token
343
- # @param name [String, nil] option name (underscored, no prefix)
344
- # @param short_char [String, nil] single-char short letter
345
- # @param dot_path [Array<String>, nil] dot-path sub-keys, or nil
346
- # @param value [String, nil] inline value
347
- # @param has_value [Boolean] whether an inline value separator was present
348
- def initialize(raw:, name:, short_char:, dot_path:, value:, has_value:)
349
- @raw = raw
350
- @name = name
351
- @short_char = short_char
352
- @dot_path = dot_path
353
- @value = value
354
- @has_value = has_value
355
- end
356
-
357
- def to_s = @raw
358
-
359
- class << self
360
- # Build an Option from a raw long-option token (starts with `--`)
361
- # @param raw [String] e.g. "--log-level=debug" or "--custom.field" or "--log-level"
362
- def from_long(raw)
363
- without_prefix = raw.delete_prefix(PREFIX)
364
- eq_idx = without_prefix.index(VALUE_SEP)
365
- if eq_idx
366
- name_raw = without_prefix[0, eq_idx]
367
- value = without_prefix[eq_idx + 1..]
368
- has_value = true
369
- else
370
- name_raw = without_prefix
371
- value = nil
372
- has_value = false
373
- end
374
- parts = name_raw.split(DotContainer::SEPARATOR)
375
- root = parts.shift.gsub(NAME_SEP_LINE, NAME_SEP_SYMBOL)
376
- dot_path = parts.empty? ? nil : parts
377
- new(raw: raw, name: root, short_char: nil, dot_path: dot_path, value: value, has_value: has_value)
378
- end
379
-
380
- # Build an Option from a raw short-option token (starts with `-` but not `--`)
381
- # @param raw [String] e.g. "-P", "-Pval", "-h"
382
- def from_short(raw)
383
- short_char = raw[1]
384
- if raw.length > 2
385
- new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: raw[2..], has_value: true)
386
- else
387
- new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: nil, has_value: false)
388
- end
389
- end
390
- end
391
-
392
- # Option name separator on command line (e.g. `--option-name`, the `-` between words)
393
- NAME_SEP_LINE = '-'
394
- # Option name separator in code/symbol (e.g. `:option_name`, the `_` between words)
395
- NAME_SEP_SYMBOL = '_'
396
- # Separator between option name and its inline value (e.g. `--opt=val`, the `=`)
397
- VALUE_SEP = '='
398
- # Long-option prefix (e.g. `--opt`)
399
- PREFIX = '--'
400
- # (public: shared with Parser)
401
- end
402
-
403
- # parse command line options
404
- # arguments options start with '-', others are commands
405
- # resolves on extended value syntax
22
+ # Parse command line options and positional arguments.
23
+ #
24
+ # Options are declared incrementally (global, then plugin options).
25
+ # Command line options are applied when their option is declared: explicitly with `parse_options!`,
26
+ # or automatically on next read of an option or argument. Unknown options are kept for later.
27
+ #
28
+ # Option values come from several sources, see `OptionSource`: highest priority wins, whatever the order.
406
29
  class Parser
30
+ include Prompt
31
+
407
32
  class << self
408
33
  # Find shortened string value in allowed symbol list
409
- # @param short_value [String] value or prefix to find
410
- # @param descr [String] description for error messages
411
- # @param allowed_values [Array] list of allowed values
34
+ # @param short_value [String] Value or prefix to find
35
+ # @param descr [String] Description for error messages
36
+ # @param allowed_values [Array] List of allowed values
412
37
  # @return [Symbol, Boolean] matched symbol or boolean value
413
38
  def get_from_list(short_value, descr, allowed_values)
414
39
  Aspera.assert_type(short_value, String)
415
40
  # we accept shortcuts
416
- matching_exact = allowed_values.select { |i| i.to_s.eql?(short_value) }
417
- return matching_exact.first if matching_exact.length == 1
418
- matching = allowed_values.select { |i| i.to_s.start_with?(short_value) }
41
+ matching = allowed_values.select { |i| i.to_s.eql?(short_value) }
42
+ matching = allowed_values.select { |i| i.to_s.start_with?(short_value) } unless matching.length.eql?(1)
419
43
  raise BadArgument, "Identifier '#{short_value}' used where a #{descr} is expected: place the identifier after the command" if matching.empty? && short_value.match?(REGEX_LOOKUP_ID_BY_FIELD)
420
44
  Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
421
45
  Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
@@ -423,14 +47,6 @@ module Aspera
423
47
  matching.first
424
48
  end
425
49
 
426
- # Find a key in a list by exact match or unique prefix match
427
- # @return [Object, nil] the matching key, or nil if none or ambiguous
428
- def match_prefix(short_value, allowed_values)
429
- return short_value if allowed_values.include?(short_value)
430
- matches = allowed_values.select { |k| k.to_s.start_with?(short_value.to_s) }
431
- matches.length == 1 ? matches.first : nil
432
- end
433
-
434
50
  # Generates error message with list of allowed values
435
51
  # @param error_msg [String] Error message
436
52
  # @param accept_list [Array<Symbol>] List of allowed values
@@ -473,12 +89,13 @@ module Aspera
473
89
  end
474
90
 
475
91
  # Using dotted hash notation, convert value to bool, int, float or extended value
92
+ # `true` and `yes` are converted to `true`, `false` and `no` to `false`
476
93
  # @param value [String] The value to convert to appropriate type
477
94
  # @return [Boolean, Integer, Float, String, Array, Hash] the converted value
478
95
  def smart_convert(value)
479
96
  case value
480
- when 'true' then true
481
- when 'false' then false
97
+ when 'true', BoolValue::YES_SYM.to_s then true
98
+ when 'false', BoolValue::NO_SYM.to_s then false
482
99
  else
483
100
  Integer(value, exception: false) ||
484
101
  Float(value, exception: false) ||
@@ -487,76 +104,32 @@ module Aspera
487
104
  end
488
105
  end
489
106
 
490
- attr_accessor :ask_missing_mandatory, :ask_missing_optional, :help_requested
107
+ attr_accessor :ask_missing_mandatory, :ask_missing_optional
491
108
  attr_writer :fail_on_missing_mandatory
492
109
 
493
110
  # @param program_name [String] Name of the program
494
111
  # @param argv [Array<String>, nil] Command line arguments to parse
495
112
  def initialize(program_name, argv = nil)
496
- # Option descriptions: maps option symbol to its OptionValue descriptor
497
- # @type [Hash{Symbol => OptionValue}]
498
- @declared_options = {}
113
+ @registry = OptionRegistry.new
499
114
  # do we ask missing options and arguments to user ?
500
115
  @ask_missing_mandatory = false # STDIN.isatty
501
116
  # ask optional options if not provided and in interactive
502
117
  @ask_missing_optional = false
503
118
  # get_option fails if a mandatory parameter is asked
504
119
  @fail_on_missing_mandatory = true
505
- # set to true when --help / -h is parsed
506
- @help_requested = false
507
- # options explicitly reset to nil from CLI (e.g. --opt=@none:); preset injection skips these
508
- @explicitly_cleared = {}
120
+ # Values from presets and env vars, for options not declared yet: option symbol -> [{value:, source:}]
121
+ @pending_values = {}
122
+ # `true` when options were declared or presets added since last parse
123
+ @parse_needed = true
509
124
  # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
510
- @option_pairs_batch = {}
511
- @option_pairs_env = {}
512
- # Short option char -> option symbol, e.g. {'h' => :help, 'v' => :version}
513
- @short_options = {}
514
- # Current help section group name, set by #group
515
- @current_group = 'global'
516
125
  env_prefix = program_name.upcase + Option::NAME_SEP_SYMBOL
517
126
  ENV.each do |k, v|
518
- @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
127
+ add_pending_value(k.delete_prefix(env_prefix).downcase.to_sym, v, :env, replace: true) if k.start_with?(env_prefix)
519
128
  end
520
- Log.dump(:env, @option_pairs_env)
521
- # Ordered list of all CLI tokens after `--` splitting.
522
- # Single source of truth for CLI parsing — pending_arguments and pending_options are derived views.
523
- # @type [Array<Option, Argument>]
524
- @argv_tokens = []
525
- # Frozen snapshot of @argv_tokens used by unprocessed_options_with_value.
526
- @initial_argv_tokens = [].freeze
527
- # Number of original positional args before the option currently being parsed (nil = positional context).
528
- @current_option_args_offset = nil
529
- # Current index in @argv_tokens during parse_options! loop (used by shift_next_argument_token).
530
- @current_parse_idx = nil
531
- return if argv.nil?
532
- # true until `--` is found (stop options)
533
- process_options = true
534
- argv.each do |value|
535
- if process_options && value.start_with?('-')
536
- Log.log.trace1 { "opt: #{value}" }
537
- if value.eql?(OPTIONS_STOP)
538
- process_options = false
539
- else
540
- @argv_tokens.push(value.start_with?(Option::PREFIX) ? Option.from_long(value) : Option.from_short(value))
541
- end
542
- else
543
- Log.log.trace1 { "arg: #{value}" }
544
- @argv_tokens.push(Argument.new(value))
545
- end
546
- end
547
- @initial_argv_tokens = @argv_tokens.dup.freeze
548
- Log.log.trace1 { "add_cmd_line_options:arguments=#{pending_arguments},options=#{pending_options}".red }
549
- declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
550
- declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_optional})
551
- # do not parse options yet, let's wait for option `-h` to be overridden
552
- end
553
-
554
- # Add a type to the message if not special types
555
- # @param types [Array<Class>] types to add
556
- # @return [String] Types if relevant
557
- def add_types_info(types)
558
- return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
559
- " (#{types.map(&:name).join(', ')})"
129
+ Log.dump(:env, @pending_values)
130
+ @command_line = CommandLine.new(argv || [])
131
+ declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, default: false, on_set: method(:ask_missing_mandatory=))
132
+ declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, default: false, on_set: method(:ask_missing_optional=))
560
133
  end
561
134
 
562
135
  # Declare an option
@@ -570,70 +143,71 @@ module Aspera
570
143
  # - Use `allowed: [Hash, String]` when the option additionally accepts a plain String shorthand;
571
144
  # the schema then documents the Hash form and `=help` still shows it.
572
145
  # @param default [Object] default value
573
- # @param handler [Hash] handler for option value: keys: :o(object) and :m(method)
574
- # @param deprecation [String] deprecation
146
+ # @param on_set [#call] Called with the new value each time the value is set (e.g. a `Method`, or a lambda).
147
+ # For a flag (`Type::NONE`): called without argument when the flag is found
148
+ # @param shorthand [String] For a `Hash` option: a `String` value is stored as `{shorthand => value}`
149
+ # @param deprecation [Hash, nil] deprecation: `{last:, message:}`, see `Deprecation`
575
150
  # @param schema [String] schema path documenting the Hash form of this option
576
151
  # @param block [Proc] Block to execute when option is found
577
- def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
152
+ def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, on_set: nil, shorthand: nil, deprecation: nil, schema: nil, &block)
578
153
  Aspera.assert_type(option_symbol, Symbol)
579
- Aspera.assert(!@declared_options.key?(option_symbol)) { "#{option_symbol} already declared" }
580
- Aspera.assert_type(handler, Hash) if handler
581
- Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
582
- option_attrs = @declared_options[option_symbol] = OptionValue.new(
583
- option: option_symbol,
584
- description: description,
585
- allowed: allowed,
586
- handler: handler,
587
- deprecation: deprecation,
588
- schema: schema
154
+ Aspera.assert(!@registry.declared?(option_symbol)) { "#{option_symbol} already declared" }
155
+ if on_set && allowed.eql?(Type::NONE)
156
+ Aspera.assert(block.nil?) { "#{option_symbol}: flag with both on_set and block" }
157
+ block = on_set
158
+ on_set = nil
159
+ end
160
+ # An abbreviation already used on command line must stay unambiguous
161
+ @command_line.abbreviated_option_tokens.each do |tok|
162
+ Aspera.assert(!option_symbol.to_s.start_with?(tok.name), type: BadArgument) do
163
+ "Ambiguous option #{tok.raw}: used as #{self.class.option_name_to_line(tok.abbreviation_of)}, but also matches #{self.class.option_name_to_line(option_symbol)}"
164
+ end
165
+ end
166
+ option = @registry.add(
167
+ OptionValue.new(
168
+ option: option_symbol,
169
+ description: description,
170
+ allowed: allowed,
171
+ on_set: on_set,
172
+ shorthand: shorthand,
173
+ deprecation: deprecation,
174
+ schema: schema
175
+ ),
176
+ short: short
589
177
  )
590
- option_attrs.group = @current_group
591
- description = option_attrs.description
178
+ description = option.description
592
179
  Aspera.assert(!description.nil?) { "#{option_symbol}: no description and no schema to derive one from" }
593
180
  Aspera.assert(description[-1] != '.') { "#{option_symbol} ends with dot" }
594
181
  Aspera.assert(description[0] == description[0].upcase) { "#{option_symbol} description does not start with an uppercase" }
595
182
  Aspera.assert(!['hash', 'extended value'].any? { |s| description.downcase.include?(s) }) { "#{option_symbol} shall use :allowed instead of hash/extended value in option description" }
596
- set_option(option_symbol, default, where: 'default', warn_deprecation: false) unless default.nil?
597
- case option_attrs.types
598
- when Type::ENUM, Type::BOOLEAN
599
- # This option value must be a symbol (or array of symbols)
600
- set_option(option_symbol, BoolValue.true?(default), where: 'default', warn_deprecation: false) if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
601
- when Type::NONE
602
- Aspera.assert_type(block, Proc) { "missing execution block for #{option_symbol}" }
603
- option_attrs.block = block
183
+ set_option(option_symbol, default, source: :default, warn_deprecation: false) unless default.nil?
184
+ if option.flag?
185
+ Aspera.assert(block.respond_to?(:call)) { "missing execution block for #{option_symbol}" }
186
+ option.block = block
604
187
  end
605
- @short_options[short] = option_symbol unless short.nil?
606
- Log.log.trace1 { "declare: #{option_symbol}, group: #{@current_group}, short: #{short}" }
188
+ @parse_needed = true
189
+ Log.log.trace1 { "declare: #{option_symbol}, group: #{option.group}, short: #{short}" }
607
190
  end
608
191
 
609
192
  # Set the current help section group name for subsequent declarations
610
193
  # @param name [String] group name, shown as section header in help text
611
194
  def group(name)
612
- @current_group = name
613
- end
614
-
615
- # Rename all options currently tagged with @current_group to a new name,
616
- # then update @current_group. Used by add_manual_header when a plugin
617
- # declares its options before its group name is known (e.g. Plugins::Config).
618
- # @param name [String] new group name
619
- def rename_current_group(name)
620
- @declared_options.each_value { |opt| opt.group = name if opt.group.eql?(@current_group) }
621
- @current_group = name
195
+ @registry.group = name
622
196
  end
623
197
 
624
- # Low-level positional argument reader. Prefer +Base#resolve_argument+ from action methods.
625
- # Direct calls from outside +Parser+ are legacy exceptions documented in ST12/ST13
626
- # (mixins without DSL: sync_actions, ascp_actions; setup callbacks: aoc.rb).
627
- # @api private
628
- # @param descr [String] description for help
198
+ # Low-level positional argument reader.
199
+ # Prefer `Base#resolve_argument` from action methods.
200
+ # The only direct call from outside `Cli::Parser` and `Cli::Plugins::Base` is to retrieve the list of files.
201
+ # @param descr [String] Description for help
629
202
  # @param mandatory [Boolean] `true`: raise error no more argument
630
203
  # @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
631
- # @param accept_list [Array<Symbol>, NilClass] list of allowed values
632
- # @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
204
+ # @param accept_list [Array<Symbol>, nil] list of allowed values
205
+ # @param validation [Class, Array, nil] Accepted value type(s) or list of Symbols
633
206
  # @param aliases [Hash] map of aliases: key = alias, value = real value
634
207
  # @param default [Object] default value
635
208
  # @return [Object, Array, nil] one value, list or nil (if optional and no default)
636
209
  def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil)
210
+ ensure_parsed
637
211
  Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
638
212
  Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
639
213
  validation = Symbol unless accept_list.nil?
@@ -641,34 +215,14 @@ module Aspera
641
215
  Aspera.assert_array_all(validation, Class) { 'validation' } unless validation.nil?
642
216
  descr = "#{descr}#{add_types_info(validation)}"
643
217
  result =
644
- if !pending_arguments.empty?
645
- values = extract_argument_tokens(multiple)
646
- values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
647
- # If expecting list and only one arg of type array : it is the list
648
- values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
649
- if accept_list
650
- allowed_values = [].concat(accept_list)
651
- allowed_values.concat(aliases.keys) unless aliases.nil?
652
- values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
653
- end
654
- multiple ? values : values.first
218
+ if !@command_line.pending_arguments.empty? then read_arguments(descr, multiple: multiple, validation: validation, accept_list: accept_list, aliases: aliases)
655
219
  elsif !default.nil? then default
656
220
  elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, aliases: aliases, schema: schema)
657
221
  end
658
222
  Log.log.trace1 { "#{descr}=#{result}" }
659
223
  result = aliases[result] if aliases&.key?(result)
660
- # if value comes from JSON/YAML, it may come as Integer
661
- result = result.to_s if result.is_a?(Integer) && validation&.eql?(Type::STRING)
662
- # Integer coercion: a String argument for an INTEGER option must be parseable
663
- if result.is_a?(String) && validation&.eql?(Type::INTEGER)
664
- int_result = Integer(result, exception: false)
665
- raise Cli::BadArgument, "Invalid integer: #{result}" if int_result.nil?
666
- result = int_result
667
- end
668
- if validation && (mandatory || !result.nil?)
669
- value_list = multiple ? result : [result]
670
- value_list.each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) }
671
- end
224
+ result = convert_argument(result, validation)
225
+ (multiple ? result : [result]).each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) } if validation && (mandatory || !result.nil?)
672
226
  result
673
227
  end
674
228
 
@@ -700,23 +254,21 @@ module Aspera
700
254
  # @param option_symbol [Symbol] name of the option
701
255
  # @return [Boolean]
702
256
  def option_declared?(option_symbol)
703
- @declared_options.key?(option_symbol)
257
+ @registry.declared?(option_symbol)
704
258
  end
705
259
 
706
260
  # @return [Hash{Symbol => OptionValue}] all declared options (read-only view)
707
- attr_reader :declared_options
261
+ def declared_options = @registry.options
708
262
 
709
263
  # Get an option definition by name
710
264
  # @param option_symbol [Symbol] name of the option
711
265
  # @return [OptionValue] Option definition
712
266
  # @raise [Cli::BadArgument] if option not found
713
267
  def option_def(option_symbol)
714
- Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument) { "Unknown option: #{option_symbol}" }
715
- @declared_options[option_symbol]
268
+ @registry.fetch(option_symbol)
716
269
  end
717
270
 
718
- # Get an option value by name
719
- # either return value or calls handler, can return nil
271
+ # Get an option value by name, can return nil
720
272
  # ask interactively if requested/required
721
273
  # @param option_symbol [Symbol] name of the option to retrieve
722
274
  # @param mandatory [Boolean] if true, raise error if option not set
@@ -724,37 +276,32 @@ module Aspera
724
276
  # if the option value is 'help' (used for --query whose schema depends on the current command)
725
277
  def get_option(option_symbol, mandatory: false, schema: nil)
726
278
  Aspera.assert_type(option_symbol, Symbol)
727
- option_attrs = option_def(option_symbol)
728
- result = option_attrs.value
279
+ ensure_parsed
280
+ option = option_def(option_symbol)
281
+ result = option.value
729
282
  # Contextual schema: raise SchemaRequest when value is 'help'
730
- raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(HELP)
283
+ raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(SchemaRequest::KEYWORD)
731
284
  # Do not fail for manual generation if option mandatory but not set
732
285
  return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
733
286
  if result.nil?
734
287
  if !@ask_missing_mandatory
735
288
  Aspera.assert(!mandatory, type: Cli::BadArgument) { "Missing mandatory option: #{option_symbol}" }
736
289
  elsif @ask_missing_optional || mandatory
737
- # ask_missing_mandatory
738
- result = get_interactive(option_symbol.to_s, check_option: true, accept_list: option_attrs.values, schema: option_attrs.schema)
739
- set_option(option_symbol, result, where: 'interactive')
290
+ result = get_interactive(option_symbol.to_s, accept_list: option.values, schema: option.schema)
291
+ set_option(option_symbol, result, source: :interactive)
740
292
  end
741
293
  end
742
294
  result
743
295
  end
744
296
 
745
- # Set an option value by name, either store value or call handler
297
+ # Set an option value by name: store value and call the `on_set` callback
746
298
  # String is given to extended value
747
299
  # @param option_symbol [Symbol] option name
748
- # @param value [String] Value to set
749
- # @param where [String] Where the value comes from
750
- def set_option(option_symbol, value, where: 'code override', warn_deprecation: true)
300
+ # @param value [String] Value to set
301
+ # @param source [Symbol] `OptionSource` of value
302
+ def set_option(option_symbol, value, source: :code, warn_deprecation: true)
751
303
  Aspera.assert_type(option_symbol, Symbol)
752
- option = option_def(option_symbol)
753
- # Raise immediately only when the option has a static schema: the schema is known at parse time.
754
- # When schema is nil (e.g. --query), 'help' is stored as-is and SchemaRequest is raised later
755
- # in get_option() with the contextual schema provided by the calling command.
756
- raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP) && option.schema
757
- option.assign_value(value, where: where, warn_deprecation: warn_deprecation)
304
+ option_def(option_symbol).assign_value(value, source: source, warn_deprecation: warn_deprecation)
758
305
  end
759
306
 
760
307
  # Set option to `nil`
@@ -765,72 +312,73 @@ module Aspera
765
312
  option_def(option_symbol).clear
766
313
  end
767
314
 
768
- # Bind (or re-bind) a runtime handler to an already-declared option.
769
- # Called from plugin initialize() for Category C handlers whose target object
770
- # (e.g. @gen_options) is created after class-load time.
315
+ # Bind (or re-bind) an `on_set` callback to an already-declared option, for a target object created after declaration.
316
+ # The callback is called with the current value, if any.
771
317
  # @param option_symbol [Symbol] name of the already-declared option
772
- # @param object [Object] the target object for get/set delegation
773
- # @param method [Symbol] accessor method name on object
318
+ # @param callback [#call] called with the new value each time the value is set (e.g. a `Method`)
774
319
  # @return [nil]
775
- def set_handler(option_symbol, object:, method:)
320
+ def on_set(option_symbol, callback)
776
321
  Aspera.assert_type(option_symbol, Symbol)
777
- option_def(option_symbol).bind_handler(o: object, m: method)
322
+ option_def(option_symbol).bind_on_set(callback)
778
323
  end
779
324
 
780
- # Adds each of the keys of specified hash as an option
325
+ # Adds each of the keys of specified hash as an option.
326
+ # Values are applied by the next parse, and never override a value from env or command line.
781
327
  # @param preset_hash [Hash] Options to add
782
- # @param where [String] Where the value comes from
783
- # @param override [Boolean] Override if already present
328
+ # @param where [String] Where the value comes from (for logs)
329
+ # @param override [Boolean] `false` for plugin default presets: lower priority than other presets
784
330
  def add_option_preset(preset_hash, where, override: true)
785
331
  Aspera.assert_type(preset_hash, Hash)
786
332
  Log.log.debug { "add_option_preset: #{preset_hash}, #{where}, #{override}" }
333
+ source = override ? :preset : :plugin_preset
787
334
  preset_hash.each do |k, v|
788
335
  # Ignore comment/meta keys (e.g. _comment, _description)
789
336
  next if k.to_s.start_with?(PresetManager::Key::META_PREFIX)
790
- option_symbol = k.to_sym
791
- # Never restore an option that was explicitly cleared from the CLI (e.g. --opt=@none:)
792
- next if @explicitly_cleared.key?(option_symbol)
793
- @option_pairs_batch[option_symbol] = v if override || !@option_pairs_batch.key?(option_symbol)
337
+ add_pending_value(k.to_sym, v, source, replace: override)
794
338
  end
339
+ @parse_needed = true
795
340
  end
796
341
 
797
342
  # Allows a plugin to add an argument as next argument to process
798
343
  # @param argument [String] argument value to prepend
799
- # @return [Array<Option, Argument>] updated tokens list
344
+ # @return [nil]
800
345
  def unshift_next_argument(argument)
801
- @argv_tokens.unshift(Argument.new(argument))
346
+ @command_line.unshift_argument(argument)
347
+ nil
802
348
  end
803
349
 
804
350
  # Check if there are no pending positional arguments
805
351
  # @return [Boolean] true if no pending positional arguments
806
352
  def command_or_arg_empty?
807
- pending_arguments.empty?
353
+ ensure_parsed
354
+ @command_line.pending_arguments.empty?
808
355
  end
809
356
 
810
357
  # Check for unprocessed options or arguments error messages
811
358
  # @return [Array<String>] list of error messages for unprocessed tokens
812
359
  def final_errors
360
+ begin
361
+ ensure_parsed
362
+ rescue StandardError => e
363
+ # already in error processing: report only unprocessed tokens
364
+ Log.log.debug { "final parse: #{e}" }
365
+ end
813
366
  result = []
814
- result.push("unprocessed options: #{pending_options}") unless pending_options.empty?
815
- result.push("unprocessed values: #{pending_arguments}") unless pending_arguments.empty?
367
+ result.push("unprocessed options: #{@command_line.pending_options}") unless @command_line.pending_options.empty?
368
+ result.push("unprocessed values: #{@command_line.pending_arguments}") unless @command_line.pending_arguments.empty?
816
369
  result
817
370
  end
818
371
 
819
- # Get all original options on command line used to generate a config in config file
820
- # @return [Hash] options as taken from config file and command line just before command execution
372
+ # Get all long options with a value from command line, used to generate a config in config file.
373
+ # They are marked as processed.
374
+ # @return [Hash] options with value, dotted notation expanded
821
375
  def unprocessed_options_with_value
376
+ ensure_parsed
822
377
  result = {}
823
- @initial_argv_tokens.each_with_index do |tok, idx|
824
- next unless tok.is_a?(Option) && tok.short_char.nil?
825
- # For space-separated form: value is the immediately following :argument token (if any)
826
- value = tok.value || @initial_argv_tokens[idx + 1]&.then { |t| t.value if t.is_a?(Argument) }
827
- # ignore options without value
828
- next if value.nil?
829
- name = tok.dot_path ? [tok.name, *tok.dot_path].join(DotContainer::SEPARATOR) : tok.name
830
- Log.log.debug { "option #{name}=#{value}" }
831
- path = [tok.name, *(tok.dot_path || [])]
832
- DotContainer.dotted_to_container(path, Parser.smart_convert(value), result)
833
- @argv_tokens.reject! { |t| t.is_a?(Option) && t.raw.eql?(tok.raw) }
378
+ @command_line.each_long_option_with_value do |tok|
379
+ path = [tok.name, *tok.dot_path]
380
+ Log.log.debug { "option #{path.join(DotContainer::SEPARATOR)}=#{tok.value}" }
381
+ DotContainer.dotted_to_container(path, Parser.smart_convert(tok.value), result)
834
382
  end
835
383
  result
836
384
  end
@@ -839,7 +387,7 @@ module Aspera
839
387
  # @return [Hash] options as taken from config file and command line just before command execution
840
388
  def known_options(only_defined: false)
841
389
  result = {}
842
- @declared_options.each_key do |option_symbol|
390
+ @registry.options.each_key do |option_symbol|
843
391
  v = get_option(option_symbol)
844
392
  result[option_symbol] = v unless only_defined && v.nil?
845
393
  rescue => e
@@ -848,186 +396,43 @@ module Aspera
848
396
  result
849
397
  end
850
398
 
851
- # Removes already known options from the list
399
+ # Apply values of options declared so far: from presets, env vars and command line.
400
+ # Can be called any number of times: tokens of options not declared yet are kept for a later call.
401
+ # Called automatically on read of option or argument, but must be called explicitly
402
+ # when values set by `on_set` callbacks are used.
852
403
  def parse_options!
853
404
  Log.log.trace1('parse_options!'.red)
854
- # First options from conf file
855
- @option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
856
- # Then, env var (to override)
857
- @option_pairs_env = consume_option_pairs(@option_pairs_env, 'env')
858
- # Then, command line override.
859
- # Iterate @argv_tokens in order so that --opt val and -s val can consume the next argument
860
- # token directly, without any secondary index.
861
- # Process one option at a time so that @current_option_args_offset can be set before each
862
- # option is evaluated (used by `@:` extended value).
863
- deferred_tokens = []
864
- Log.log.trace1('Before parse')
865
- Log.dump(:argv_tokens, @argv_tokens, level: :trace1)
866
- # Iterate with an index so that Argument tokens stay in @argv_tokens (not shifted).
867
- # Only Option tokens are removed; Arguments remain until consumed by extract_argument_tokens.
868
- @current_parse_idx = 0
869
- while @current_parse_idx < @argv_tokens.length
870
- tok = @argv_tokens[@current_parse_idx]
871
- if tok.is_a?(Argument)
872
- @current_parse_idx += 1
873
- next
874
- end
875
- # tok is an Option — remove it from @argv_tokens
876
- @argv_tokens.delete_at(@current_parse_idx)
877
- # @current_option_args_offset = number of positional Argument tokens that appear BEFORE
878
- # this option in @argv_tokens (i.e. at indices 0...@current_parse_idx after the delete).
879
- # Used by args_as_extended to skip those leading args when collecting @: values.
880
- @current_option_args_offset = @argv_tokens[0...@current_parse_idx].count { |t| t.is_a?(Argument) }
881
- if tok.short_char
882
- # Short option: -X or -Xvalue
883
- option_sym = @short_options[tok.short_char]
884
- if option_sym
885
- raw_value = tok.value
886
- # No inline value and option expects a value: consume the next :argument token
887
- raw_value = shift_next_argument_token \
888
- if !tok.has_value && !@declared_options[option_sym].types.eql?(Type::NONE)
889
- dispatch_option(option_sym, raw_value)
890
- else
891
- deferred_tokens.push(tok)
892
- end
893
- elsif tok.dot_path
894
- # Dotted notation: --a.b.c=val or --a.b.c val (always takes priority over plain option lookup)
895
- Log.log.trace1 { "Dotted option: #{tok.raw}".red }
896
- if tok.has_value
897
- raw_value = tok.value
898
- value_from_next_token = false
899
- else
900
- raw_value = shift_next_argument_token
901
- value_from_next_token = true
902
- end
903
- if @declared_options.key?(tok.name.to_sym)
904
- set_option(tok.name.to_sym, DotContainer.dotted_to_container(tok.dot_path, Parser.smart_convert(raw_value), get_option(tok.name.to_sym)), where: 'dotted')
905
- else
906
- # Only re-inject if value was space-separated (consumed from next token); inline values stay in tok.value
907
- @argv_tokens.unshift(Argument.new(raw_value)) if raw_value && value_from_next_token
908
- deferred_tokens.push(tok)
909
- end
910
- elsif (resolved_sym = self.class.match_prefix(tok.name.to_sym, @declared_options.keys))
911
- # Known long option (plain, no dot-path)
912
- raw_value = tok.value
913
- # No inline `=` and option expects a value: consume the next :argument token
914
- raw_value = shift_next_argument_token \
915
- if !tok.has_value && !@declared_options[resolved_sym].types.eql?(Type::NONE)
916
- dispatch_option(resolved_sym, raw_value)
917
- else
918
- Log.log.trace1 { "Unknown long option: #{tok.raw}".red }
919
- deferred_tokens.push(tok)
920
- end
921
- end
922
- @current_option_args_offset = nil
923
- @current_parse_idx = nil
924
- Log.log.trace1('After parse')
925
- Log.log.trace1 { "deferred: #{deferred_tokens}" }
926
- # @argv_tokens now contains only: remaining Arguments + deferred Options (already in correct order).
927
- # Re-insert deferred Options at their original positions by rebuilding from @initial_argv_tokens.
928
- deferred_ids = deferred_tokens.map(&:object_id).to_set
929
- remaining_arg_ids = @argv_tokens.grep(Argument).map(&:object_id).to_set
930
- injected = @argv_tokens.grep(Argument).reject { |t| @initial_argv_tokens.include?(t) }
931
- @argv_tokens = @initial_argv_tokens.select do |t|
932
- (t.is_a?(Option) && deferred_ids.include?(t.object_id)) ||
933
- (t.is_a?(Argument) && remaining_arg_ids.include?(t.object_id))
934
- end
935
- # Prepend arguments injected at runtime (e.g. via unshift_next_argument) not in @initial_argv_tokens.
936
- @argv_tokens.unshift(*injected)
937
- end
938
-
939
- # Extract raw string tokens from @argv_tokens (Argument objects) according to `multiple`.
940
- # Consumed Argument tokens are removed from @argv_tokens.
941
- # @param multiple [false, true, String] consumption mode:
942
- # false — consume exactly one token
943
- # true — consume all remaining tokens
944
- # String — consume up to (but not including) the marker token, or all if absent
945
- # @return [Array<String>] consumed raw token strings
946
- def extract_argument_tokens(multiple)
947
- arg_tokens = @argv_tokens.grep(Argument)
948
- case multiple
949
- when false
950
- tok = arg_tokens.first
951
- @argv_tokens.delete(tok)
952
- [tok.value]
953
- when true
954
- arg_tokens.each { |t| @argv_tokens.delete(t) }
955
- arg_tokens.map(&:value)
956
- when String
957
- idx = arg_tokens.index { |t| t.value.eql?(multiple) }
958
- consumed = idx ? arg_tokens[0, idx] : arg_tokens
959
- marker = idx ? arg_tokens[idx] : nil
960
- (consumed + [marker].compact).each { |t| @argv_tokens.delete(t) }
961
- consumed.map(&:value)
962
- else Aspera.error_unexpected_value(multiple) { 'multiple' }
963
- end
405
+ @parse_needed = false
406
+ apply_pending_values
407
+ @command_line.pending_option_tokens.each { |tok| apply_option_token(tok) }
408
+ # Presets loaded by a command line option (e.g. `-P`)
409
+ apply_pending_values
410
+ Log.log.trace1 { "unprocessed options: #{@command_line.pending_options}" }
964
411
  end
965
412
 
966
- # Validate and coerce a single argument value.
967
- # Raises SchemaRequest when the value is 'help' and validation includes Hash.
968
- # Raises BadArgument when the value is an Integer on a STRING validation (coerced upstream).
969
- # Raises BadArgument when the value's type is not in the validation list.
970
- # @param value [Object] the value to validate
971
- # @param validation [Array<Class>] accepted types
972
- # @param descr [String] argument description (for error messages)
973
- # @param schema [String, nil] schema path for SchemaRequest
974
- def validate_argument(value, validation:, descr:, schema:)
975
- raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(HELP)
976
- raise Cli::BadArgument,
977
- "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
978
- unless validation.any? { |t| value.is_a?(t) }
979
- end
980
-
981
- # Prompt user for console input
982
- # @param prompt [String] prompt string to display
983
- # @param sensitive [Boolean] whether to hide typed input
984
- # @return [String] user input stripped of trailing newline
985
- def prompt_user_input(prompt, sensitive: false)
986
- return $stdin.getpass("#{prompt}> ") if sensitive
987
- print("#{prompt}> ")
988
- line = $stdin.gets
989
- Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
990
- line.chomp
991
- end
992
-
993
- # prompt user for input in a list of symbols
994
- # @param prompt [String] prompt to display
995
- # @param sym_list [Array] list of symbols to select from
996
- # @return [Symbol] selected symbol
997
- def prompt_user_input_in_list(prompt, sym_list)
998
- loop do
999
- input = prompt_user_input(prompt).to_sym
1000
- if sym_list.any? { |a| a.eql?(input) }
1001
- return input
1002
- else
1003
- $stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
1004
- end
1005
- end
1006
- end
1007
-
1008
- # Prompt user for input in a list of symbols
1009
- # @param descr [String] description for help
1010
- # @param check_option [Boolean] Check attributes of option with name=descr
413
+ # Prompt user for missing option or argument, or raise if not interactive
414
+ # @param descr [String] option name, or argument description
1011
415
  # @param multiple [Boolean, String] `true` if multiple values expected
1012
- # @param accept_list [Array<Symbol>,NilClass] List of expected values
416
+ # @param accept_list [Array<Symbol>, nil] List of expected values
417
+ # @param aliases [Hash, nil] aliases of values, for error message
418
+ # @param schema [String, nil] schema of value, for error message
1013
419
  # @return [String] user input
1014
- def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, aliases: nil, schema: nil)
1015
- option_attrs = @declared_options[descr.to_sym]
1016
- what = option_attrs ? 'option' : 'argument'
1017
- default_prompt = "#{what}: #{descr}"
420
+ def get_interactive(descr, multiple: false, accept_list: nil, aliases: nil, schema: nil)
421
+ option = @registry.options[descr.to_sym]
422
+ default_prompt = "#{option ? 'option' : 'argument'}: #{descr}"
1018
423
  if !@ask_missing_mandatory
1019
424
  message = "Missing #{default_prompt}"
1020
425
  message = self.class.multi_choice_assert_msg(message, accept_list, aliases: aliases) if accept_list
1021
- message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
426
+ message += "\n#{TerminalFormatter.hint}Give `#{SchemaRequest::KEYWORD}` as argument to retrieve the schema of the missing argument." if schema
1022
427
  raise Cli::MissingArgument, message
1023
428
  end
1024
- # ask interactively
429
+ # Ask interactively
1025
430
  result = []
1026
431
  puts(' (one per line, end with empty line)') if multiple
1027
432
  loop do
1028
433
  prompt = default_prompt
1029
434
  prompt = "#{accept_list.join(' ')}\n#{default_prompt}" if accept_list
1030
- entry = prompt_user_input(prompt, sensitive: option_attrs&.sensitive)
435
+ entry = prompt_user_input(prompt, sensitive: option&.sensitive)
1031
436
  break if entry.empty? && multiple
1032
437
  entry = ExtendedValue.instance.evaluate(entry, context: 'interactive input')
1033
438
  entry = self.class.get_from_list(entry, descr, accept_list) if accept_list
@@ -1038,30 +443,19 @@ module Aspera
1038
443
  end
1039
444
 
1040
445
  # Read remaining args and build an `Array` or `Hash`
1041
- # @param value [String] Argument to `@:` extended value
446
+ # When used in an option value, only positional arguments after the option are used.
447
+ # @param end_marker [String] Argument to `@:` extended value
1042
448
  # @return [Hash, Array] Object representing dot-path values
1043
449
  def args_as_extended(end_marker)
1044
- # This extended value does not take args (`@:`)
1045
- # ExtendedValue.assert_no_value(end_marker, :p)
1046
450
  end_marker = SpecialValues::EOA if end_marker.empty?
1047
- # When called from an option value, skip positional args that appear before the option in argv.
1048
- # @current_option_args_offset is set by parse_options! to the number of args in
1049
- # @unprocessed_cmd_line_arguments that preceded this option; nil when called from a positional context.
1050
- skip_count = @current_option_args_offset || 0
1051
- # Skip leading positional args that precede this option in original argv order.
1052
- # Grab the first `skip_count` Argument tokens and temporarily remove them.
1053
- arg_tokens = @argv_tokens.grep(Argument)
1054
- skipped_tokens = skip_count.positive? ? arg_tokens.first(skip_count) : []
1055
- skipped_tokens.each { |t| @argv_tokens.delete(t) }
1056
- Log.log.trace1 { "args_as_extended: skipping #{skipped_tokens.length} args before option: #{skipped_tokens.map(&:value)}" } unless skipped_tokens.empty?
1057
451
  result = nil
1058
- get_next_argument('args', multiple: end_marker).each do |argument|
1059
- Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
1060
- path, value = argument.split(Option::VALUE_SEP, 2)
1061
- result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
452
+ @command_line.with_arguments_after_current_option do
453
+ get_next_argument('args', multiple: end_marker).each do |argument|
454
+ Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
455
+ path, value = argument.split(Option::VALUE_SEP, 2)
456
+ result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
457
+ end
1062
458
  end
1063
- # Restore skipped tokens so they remain available for command dispatching
1064
- skipped_tokens.reverse_each { |t| @argv_tokens.unshift(t) }
1065
459
  result
1066
460
  end
1067
461
 
@@ -1071,15 +465,14 @@ module Aspera
1071
465
  def help_text(banner: nil)
1072
466
  rows = []
1073
467
  current_group = nil
1074
- @declared_options.each do |sym, opt|
468
+ @registry.options.each do |sym, opt|
1075
469
  if opt.group != current_group
1076
470
  current_group = opt.group
1077
471
  rows << [{value: "OPTIONS: #{current_group}", colspan: 2}]
1078
472
  end
1079
- short_char = @short_options.key(sym)
1080
- short_part = short_char ? "-#{short_char}, " : ' '
473
+ short_part = opt.short ? "-#{opt.short}, " : ' '
1081
474
  flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
1082
- desc = opt.deprecation ? "#{opt.description} (deprecated: #{opt.deprecation})" : opt.description
475
+ desc = opt.deprecation ? "#{opt.description} (#{opt.deprecation})" : opt.description
1083
476
  rows << [flag, desc]
1084
477
  end
1085
478
  table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
@@ -1099,14 +492,69 @@ module Aspera
1099
492
  b.remove_horizontals
1100
493
  end.freeze
1101
494
 
495
+ # Parse if options were declared or presets added since last parse
496
+ def ensure_parsed
497
+ parse_options! if @parse_needed
498
+ end
499
+
500
+ # Add a type to the message if not special types
501
+ # @param types [Array<Class>] types to add
502
+ # @return [String] Types if relevant
503
+ def add_types_info(types)
504
+ return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
505
+ " (#{types.map(&:name).join(', ')})"
506
+ end
507
+
508
+ # Consume and evaluate positional arguments
509
+ # @return [Object, Array] one value, or list if `multiple`
510
+ def read_arguments(descr, multiple:, validation:, accept_list:, aliases:)
511
+ values = @command_line.shift_arguments(multiple)
512
+ values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
513
+ # If expecting list and only one arg of type array : it is the list
514
+ values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
515
+ if accept_list
516
+ allowed_values = accept_list + (aliases&.keys || [])
517
+ values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
518
+ end
519
+ multiple ? values : values.first
520
+ end
521
+
522
+ # Convert argument to expected type, when unambiguous
523
+ # @param value [Object] argument value
524
+ # @param validation [Array<Class>, nil] accepted types
525
+ # @return [Object] converted value
526
+ def convert_argument(value, validation)
527
+ # if value comes from JSON/YAML, it may come as Integer
528
+ return value.to_s if value.is_a?(Integer) && validation.eql?(Type::STRING)
529
+ return value unless value.is_a?(String) && validation.eql?(Type::INTEGER)
530
+ Integer(value, exception: false).tap { |i| raise Cli::BadArgument, "Invalid integer: #{value}" if i.nil? }
531
+ end
532
+
533
+ # Validate a single argument value.
534
+ # @param value [Object] the value to validate
535
+ # @param validation [Array<Class>] accepted types
536
+ # @param descr [String] argument description (for error messages)
537
+ # @param schema [String, nil] schema path for SchemaRequest and validation
538
+ # @raise [SchemaRequest] when the value is 'help' and validation includes Hash.
539
+ # @raise [BadArgument] when the value's type is not in the validation list.
540
+ # @raise [BadArgument] when the value does not match its schema.
541
+ def validate_argument(value, validation:, descr:, schema:)
542
+ raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(SchemaRequest::KEYWORD)
543
+ raise BadArgument,
544
+ "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
545
+ unless validation.any? { |t| value.is_a?(t) }
546
+ errors = Schema::Validator.instance.errors(value, schema) if schema && (value.is_a?(Hash) || value.is_a?(Array))
547
+ raise BadArgument, "Argument #{descr}: #{errors.join('; ')} (give `#{SchemaRequest::KEYWORD}` as argument for schema)" unless errors.nil? || errors.empty?
548
+ end
549
+
1102
550
  # @param opt [OptionValue] option descriptor
1103
- # @return [String, nil] placeholder shown in flag column: 'ENUM', 'HASH', 'INT', 'LIST', 'VALUE', or nil for flag switches
551
+ # @return [String, nil] placeholder shown in flag column, or nil for flags
1104
552
  def option_display_value(opt)
1105
- case opt.types
1106
- when Type::NONE then nil
1107
- when Type::BOOLEAN then 'yes|no'
1108
- when Type::INTEGER then 'INT'
1109
- when Type::ENUM
553
+ case opt.kind
554
+ when :flag then nil
555
+ when :boolean then 'yes|no'
556
+ when :integer then 'INT'
557
+ when :enum
1110
558
  opt.values&.any? && opt.values.length <= 4 ? opt.values.join('|') : 'ENUM'
1111
559
  else
1112
560
  if opt.types&.include?(Hash) || !opt.schema.nil?
@@ -1119,24 +567,71 @@ module Aspera
1119
567
  end
1120
568
  end
1121
569
 
1122
- # Dispatch a parsed CLI option to its handler.
1123
- # @param sym [Symbol] option symbol
1124
- # @param raw_value [String, nil] raw string value from command line, or nil for flag switches
1125
- def dispatch_option(sym, raw_value)
1126
- opt = @declared_options[sym]
1127
- if opt.types.eql?(Type::NONE)
1128
- opt.block.call
570
+ # Apply a command line option token if its option is declared, else keep it for later.
571
+ # @param tok [Option] option token
572
+ def apply_option_token(tok)
573
+ return if tok.consumed
574
+ option = tok.short_char ? @registry.by_short(tok.short_char) : resolve_long_name(tok)
575
+ return if option.nil?
576
+ if option.flag?
577
+ flags = flag_options(tok, option)
578
+ return if flags.nil?
579
+ @command_line.consume(tok, takes_value: false)
580
+ flags.each { |f| f.block.call }
1129
581
  else
1130
- set_option(sym, raw_value, where: SOURCE_USER)
1131
- # Track options explicitly cleared from CLI (e.g. --opt=@none:) so that
1132
- # subsequent preset injection does not silently restore the value.
1133
- # @none: always evaluates to nil; detect it by checking the raw token directly
1134
- # to avoid re-evaluating side-effectful extended values (e.g. @: consumes positional args).
1135
- cleared = get_option(sym).nil? || raw_value.eql?('@none:')
1136
- @explicitly_cleared[sym] = true if cleared
582
+ value = @command_line.consume(tok, takes_value: true)
583
+ @command_line.with_current_option(tok) do
584
+ if tok.dot_path.nil?
585
+ option.assign_value(value, source: :cmdline)
586
+ else
587
+ # Fill a copy of the current value: the current value is not modified in place (`on_set` callback already received it),
588
+ # and the result is complete (e.g. `--opt.0=a --opt.1=b`): not merged again
589
+ current = copy_containers(option.value(log: false))
590
+ value = DotContainer.dotted_to_container(tok.dot_path, Parser.smart_convert(value), current)
591
+ option.assign_value(value, source: :cmdline, merge: false)
592
+ end
593
+ end
594
+ end
595
+ end
596
+
597
+ # Copy nested `Hash` and `Array` containers, keep other values as-is (e.g. a `Proc` cannot be marshalled)
598
+ # @param value [Object] value to copy
599
+ # @return [Object] copy
600
+ def copy_containers(value)
601
+ case value
602
+ when Hash then value.transform_values { |v| copy_containers(v) }
603
+ when Array then value.map { |v| copy_containers(v) }
604
+ else value
1137
605
  end
1138
606
  end
1139
607
 
608
+ # Flags to execute for a flag token: `--help`, `-h`, or combined `-hN`
609
+ # @param tok [Option] flag token
610
+ # @param option [OptionValue] declared flag option
611
+ # @return [Array<OptionValue>, nil] flags, or `nil` if combined flags are not all declared yet
612
+ # @raise [BadArgument] if a value is given
613
+ def flag_options(tok, option)
614
+ if tok.short_char.nil?
615
+ Aspera.assert(!tok.inline? && tok.dot_path.nil?, type: BadArgument) { "Option #{self.class.option_name_to_line(option.option)} does not take a value" }
616
+ return [option]
617
+ end
618
+ flags = [option, *tok.inline_value.to_s.chars.map { |c| @registry.by_short(c) }]
619
+ return if flags.any?(&:nil?)
620
+ Aspera.assert(flags.all?(&:flag?), type: BadArgument) { "Option -#{tok.short_char} does not take a value: #{tok.raw}" }
621
+ flags
622
+ end
623
+
624
+ # Resolve long option name: exact match, or unique abbreviation (recorded on token).
625
+ # @param tok [Option] long option token
626
+ # @return [OptionValue, nil] declared option, or `nil` if not declared yet
627
+ # @raise [BadArgument] if abbreviation is ambiguous
628
+ def resolve_long_name(tok)
629
+ # No abbreviation with dotted notation
630
+ option = @registry.by_long(tok.name, allow_abbreviation: tok.dot_path.nil?)
631
+ tok.abbreviation_of = option.option if !option.nil? && !option.option.to_s.eql?(tok.name)
632
+ option
633
+ end
634
+
1140
635
  # Generate command line option string from option symbol
1141
636
  # @param symbol [Symbol] option name
1142
637
  # @param opt_val [String, nil] optional value placeholder
@@ -1146,72 +641,36 @@ module Aspera
1146
641
  opt_val.nil? ? result : "#{result}#{Option::VALUE_SEP}#{opt_val}"
1147
642
  end
1148
643
 
1149
- # TODO: use formatter
1150
- # Highlight current value in list
1151
- # @param list [Array<Symbol>] List of possible values
1152
- # @param current [Symbol] Current value
1153
- # @return [String] comma separated sorted list of values, with the current value highlighted
1154
- def highlight_current_in_list(list, current)
1155
- list.sort.map do |i|
1156
- if i.eql?(current)
1157
- $stdout.isatty ? i.to_s.red.bold : "[#{i}]"
1158
- else
1159
- i
1160
- end
1161
- end.join(', ')
1162
- end
1163
-
1164
- # Try to evaluate options set in batch
1165
- # @param unprocessed_options [Array] list of options to apply (key_sym,value)
1166
- # @param where [String] where the options come from
1167
- # Apply all known options from the given pairs hash and return the remaining (unknown) pairs.
1168
- # Pure: does not mutate the argument; the caller is responsible for storing the returned value.
1169
- # @param option_pairs [Hash{Symbol => Object}] candidate key/value pairs
1170
- # @param where [String] label used in log messages and error context
1171
- # @return [Hash{Symbol => Object}] pairs whose keys were not yet declared (deferred to next round)
1172
- def consume_option_pairs(option_pairs, where)
1173
- Log.log.trace1 { "consume_option_pairs: #{where}" }
1174
- remaining = {}
1175
- option_pairs.each do |k, v|
1176
- if @declared_options.key?(k)
1177
- set_option(k, v, where: where)
1178
- else
1179
- Log.log.trace1 { "unprocessed: #{k}: #{v}" }
1180
- remaining[k] = v
644
+ # Keep value from preset or env until option is declared
645
+ # @param option_symbol [Symbol] option name
646
+ # @param value [Object] value
647
+ # @param source [Symbol] `OptionSource`
648
+ # @param replace [Boolean] replace a pending value from same source
649
+ def add_pending_value(option_symbol, value, source, replace:)
650
+ entries = (@pending_values[option_symbol] ||= [])
651
+ index = entries.index { |e| e[:source].eql?(source) }
652
+ if index.nil?
653
+ entries.push({value: value, source: source})
654
+ elsif replace
655
+ entries[index] = {value: value, source: source}
656
+ end
657
+ end
658
+
659
+ # Apply pending values (presets, env) of declared options, lowest priority first
660
+ def apply_pending_values
661
+ ready = @pending_values.select { |k, _| @registry.declared?(k) }
662
+ ready.each_key { |k| @pending_values.delete(k) }
663
+ ready.each do |option_symbol, entries|
664
+ entries.sort_by { |e| OptionSource.priority(e[:source]) }.each do |e|
665
+ set_option(option_symbol, e[:value], source: e[:source])
1181
666
  end
1182
667
  end
1183
- remaining
1184
- end
1185
-
1186
- # Consume the Argument token immediately following the current option in @argv_tokens.
1187
- # After delete_at(@current_parse_idx), the next token is at @argv_tokens[@current_parse_idx].
1188
- # @return [String, nil] the consumed argument value, or nil if the next token is not an Argument
1189
- def shift_next_argument_token
1190
- pos = @current_parse_idx || 0
1191
- return unless @argv_tokens[pos]&.is_a?(Argument)
1192
-
1193
- @argv_tokens.delete_at(pos).value
1194
- end
1195
-
1196
- # @return [Array<String>] values of all pending positional Argument tokens
1197
- def pending_arguments
1198
- @argv_tokens.grep(Argument).map(&:value)
1199
- end
1200
-
1201
- # @return [Array<String>] raw strings of all pending Option tokens
1202
- def pending_options
1203
- @argv_tokens.grep(Option).map(&:raw)
1204
668
  end
1205
669
 
1206
- # when this is alone, this stops option processing (same characters as Option::PREFIX but distinct semantics)
1207
- OPTIONS_STOP = '--'
1208
- SOURCE_USER = 'cmdline' # cspell:disable-line
1209
670
  # Percent selector: select by this field for this value
1210
671
  REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/
1211
- # Ask for schema of Extended value
1212
- HELP = 'help'
1213
672
 
1214
- private_constant :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
673
+ private_constant :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
1215
674
  end
1216
675
  end
1217
676
  end