Class: Aspera::Cli::Parser

Inherits:
Object
  • Object
show all
Defined in:
lib/aspera/cli/parser.rb

Overview

parse command line options arguments options start with '-', others are commands resolves on extended value syntax

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(program_name, argv = nil) ⇒ Parser

Returns a new instance of Parser.

Parameters:

  • program_name (String)

    Name of the program

  • argv (Array<String>, nil) (defaults to: nil)

    Command line arguments to parse



495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
# File 'lib/aspera/cli/parser.rb', line 495

def initialize(program_name, argv = nil)
  # Option descriptions: maps option symbol to its OptionValue descriptor
  # @type [Hash{Symbol => OptionValue}]
  @declared_options = {}
  # do we ask missing options and arguments to user ?
  @ask_missing_mandatory = false # STDIN.isatty
  # ask optional options if not provided and in interactive
  @ask_missing_optional = false
  # get_option fails if a mandatory parameter is asked
  @fail_on_missing_mandatory = true
  # set to true when --help / -h is parsed
  @help_requested = false
  # options explicitly reset to nil from CLI (e.g. --opt=@none:); preset injection skips these
  @explicitly_cleared = {}
  # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
  @option_pairs_batch = {}
  @option_pairs_env = {}
  # Short option char -> option symbol, e.g. {'h' => :help, 'v' => :version}
  @short_options = {}
  # Current help section group name, set by #group
  @current_group = 'global'
  env_prefix = program_name.upcase + Option::NAME_SEP_SYMBOL
  ENV.each do |k, v|
    @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
  end
  Log.dump(:env, @option_pairs_env)
  # Ordered list of all CLI tokens after `--` splitting.
  # Single source of truth for CLI parsing — pending_arguments and pending_options are derived views.
  # @type [Array<Option, Argument>]
  @argv_tokens = []
  # Frozen snapshot of @argv_tokens used by unprocessed_options_with_value.
  @initial_argv_tokens = [].freeze
  # Number of original positional args before the option currently being parsed (nil = positional context).
  @current_option_args_offset = nil
  # Current index in @argv_tokens during parse_options! loop (used by shift_next_argument_token).
  @current_parse_idx = nil
  return if argv.nil?
  # true until `--` is found (stop options)
  process_options = true
  argv.each do |value|
    if process_options && value.start_with?('-')
      Log.log.trace1 { "opt: #{value}" }
      if value.eql?(OPTIONS_STOP)
        process_options = false
      else
        @argv_tokens.push(value.start_with?(Option::PREFIX) ? Option.from_long(value) : Option.from_short(value))
      end
    else
      Log.log.trace1 { "arg: #{value}" }
      @argv_tokens.push(Argument.new(value))
    end
  end
  @initial_argv_tokens = @argv_tokens.dup.freeze
  Log.log.trace1 { "add_cmd_line_options:arguments=#{pending_arguments},options=#{pending_options}".red }
  declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
  declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_optional})
  # do not parse options yet, let's wait for option `-h` to be overridden
end

Instance Attribute Details

#ask_missing_mandatoryObject

Returns the value of attribute ask_missing_mandatory.



490
491
492
# File 'lib/aspera/cli/parser.rb', line 490

def ask_missing_mandatory
  @ask_missing_mandatory
end

#ask_missing_optionalObject

Returns the value of attribute ask_missing_optional.



490
491
492
# File 'lib/aspera/cli/parser.rb', line 490

def ask_missing_optional
  @ask_missing_optional
end

#declared_optionsHash{Symbol => OptionValue} (readonly)

Returns all declared options (read-only view).

Returns:



707
708
709
# File 'lib/aspera/cli/parser.rb', line 707

def declared_options
  @declared_options
end

#fail_on_missing_mandatory=(value) ⇒ Object (writeonly)

Sets the attribute fail_on_missing_mandatory

Parameters:

  • value

    the value to set the attribute fail_on_missing_mandatory to.



491
492
493
# File 'lib/aspera/cli/parser.rb', line 491

def fail_on_missing_mandatory=(value)
  @fail_on_missing_mandatory = value
end

#help_requestedObject

Returns the value of attribute help_requested.



490
491
492
# File 'lib/aspera/cli/parser.rb', line 490

def help_requested
  @help_requested
end

Class Method Details

.get_from_list(short_value, descr, allowed_values) ⇒ Symbol, Boolean

Find shortened string value in allowed symbol list

Parameters:

  • short_value (String)

    value or prefix to find

  • descr (String)

    description for error messages

  • allowed_values (Array)

    list of allowed values

Returns:

  • (Symbol, Boolean)

    matched symbol or boolean value

Raises:



413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/aspera/cli/parser.rb', line 413

def get_from_list(short_value, descr, allowed_values)
  Aspera.assert_type(short_value, String)
  # we accept shortcuts
  matching_exact = allowed_values.select { |i| i.to_s.eql?(short_value) }
  return matching_exact.first if matching_exact.length == 1
  matching = allowed_values.select { |i| i.to_s.start_with?(short_value) }
  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)
  Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
  Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
  return BoolValue.true?(matching.first) if allowed_values.eql?(BoolValue::ALL)
  matching.first
end

.match_prefix(short_value, allowed_values) ⇒ Object?

Find a key in a list by exact match or unique prefix match

Returns:

  • (Object, nil)

    the matching key, or nil if none or ambiguous



428
429
430
431
432
# File 'lib/aspera/cli/parser.rb', line 428

def match_prefix(short_value, allowed_values)
  return short_value if allowed_values.include?(short_value)
  matches = allowed_values.select { |k| k.to_s.start_with?(short_value.to_s) }
  matches.length == 1 ? matches.first : nil
end

.multi_choice_assert_msg(error_msg, accept_list, aliases: nil) ⇒ Object

Generates error message with list of allowed values

Parameters:

  • error_msg (String)

    Error message

  • accept_list (Array<Symbol>)

    List of allowed values

  • aliases (Hash{Symbol=>Symbol}, nil) (defaults to: nil)

    alias→id map; used to annotate entries with their aliases



438
439
440
441
442
443
444
445
446
447
448
# File 'lib/aspera/cli/parser.rb', line 438

def multi_choice_assert_msg(error_msg, accept_list, aliases: nil)
  # Build reverse map: id → [alias, ...] for annotation
  reverse = aliases&.each_with_object({}) do |(ali, id), h|
    (h[id] ||= []) << ali
  end
  lines = accept_list.map do |choice|
    suffix = reverse&.key?(choice) ? " (alias: #{reverse[choice].join(', ')})" : ''
    "- #{choice}#{suffix}"
  end
  [error_msg, 'Use:', *lines.sort].join("\n")
end

.option_line_to_name(name) ⇒ String

Change option name with dash to name with underscore

Parameters:

  • name (String)

    option name with dash separators

Returns:

  • (String)

    option name with underscore separators



453
454
455
# File 'lib/aspera/cli/parser.rb', line 453

def option_line_to_name(name)
  name.gsub(Option::NAME_SEP_LINE, Option::NAME_SEP_SYMBOL)
end

.option_name_to_line(name) ⇒ String

Convert option symbol to CLI line flag format

Parameters:

  • name (Symbol, String)

    option name

Returns:

  • (String)

    option flag (e.g. "--option-name")



460
461
462
# File 'lib/aspera/cli/parser.rb', line 460

def option_name_to_line(name)
  "#{Option::PREFIX}#{name.to_s.gsub(Option::NAME_SEP_SYMBOL, Option::NAME_SEP_LINE)}"
end

.percent_selector(identifier) ⇒ Hash{Symbol => String}?

Parse percent-selector string into field name and value (extended value is parsed in value)

Parameters:

  • identifier (String)

    identifier to parse

Returns:

  • (Hash{Symbol => String}, nil)

    {field:,value:} if identifier is a percent selector, else nil



467
468
469
470
471
472
473
# File 'lib/aspera/cli/parser.rb', line 467

def percent_selector(identifier)
  Aspera.assert_type(identifier, String)
  if (m = identifier.match(REGEX_LOOKUP_ID_BY_FIELD))
    return {field: m[1], value: ExtendedValue.instance.evaluate(m[2], context: "percent selector: #{m[1]}")}
  end
  nil
end

.smart_convert(value) ⇒ Boolean, ...

Using dotted hash notation, convert value to bool, int, float or extended value

Parameters:

  • value (String)

    The value to convert to appropriate type

Returns:

  • (Boolean, Integer, Float, String, Array, Hash)

    the converted value



478
479
480
481
482
483
484
485
486
487
# File 'lib/aspera/cli/parser.rb', line 478

def smart_convert(value)
  case value
  when 'true'  then true
  when 'false' then false
  else
    Integer(value, exception: false) ||
      Float(value, exception: false) ||
      ExtendedValue.instance.evaluate(value, context: 'dotted expression')
  end
end

Instance Method Details

#add_option_preset(preset_hash, where, override: true) ⇒ Object

Adds each of the keys of specified hash as an option

Parameters:

  • preset_hash (Hash)

    Options to add

  • where (String)

    Where the value comes from

  • override (Boolean) (defaults to: true)

    Override if already present



784
785
786
787
788
789
790
791
792
793
794
795
# File 'lib/aspera/cli/parser.rb', line 784

def add_option_preset(preset_hash, where, override: true)
  Aspera.assert_type(preset_hash, Hash)
  Log.log.debug { "add_option_preset: #{preset_hash}, #{where}, #{override}" }
  preset_hash.each do |k, v|
    # Ignore comment/meta keys (e.g. _comment, _description)
    next if k.to_s.start_with?(PresetManager::Key::META_PREFIX)
    option_symbol = k.to_sym
    # Never restore an option that was explicitly cleared from the CLI (e.g. --opt=@none:)
    next if @explicitly_cleared.key?(option_symbol)
    @option_pairs_batch[option_symbol] = v if override || !@option_pairs_batch.key?(option_symbol)
  end
end

#add_types_info(types) ⇒ String

Add a type to the message if not special types

Parameters:

  • types (Array<Class>)

    types to add

Returns:

  • (String)

    Types if relevant



557
558
559
560
# File 'lib/aspera/cli/parser.rb', line 557

def add_types_info(types)
  return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
  " (#{types.map(&:name).join(', ')})"
end

#args_as_extended(end_marker) ⇒ Hash, Array

Read remaining args and build an Array or Hash

Parameters:

  • value (String)

    Argument to @: extended value

Returns:

  • (Hash, Array)

    Object representing dot-path values



1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
# File 'lib/aspera/cli/parser.rb', line 1043

def args_as_extended(end_marker)
  # This extended value does not take args (`@:`)
  # ExtendedValue.assert_no_value(end_marker, :p)
  end_marker = SpecialValues::EOA if end_marker.empty?
  # When called from an option value, skip positional args that appear before the option in argv.
  # @current_option_args_offset is set by parse_options! to the number of args in
  # @unprocessed_cmd_line_arguments that preceded this option; nil when called from a positional context.
  skip_count = @current_option_args_offset || 0
  # Skip leading positional args that precede this option in original argv order.
  # Grab the first `skip_count` Argument tokens and temporarily remove them.
  arg_tokens = @argv_tokens.grep(Argument)
  skipped_tokens = skip_count.positive? ? arg_tokens.first(skip_count) : []
  skipped_tokens.each { |t| @argv_tokens.delete(t) }
  Log.log.trace1 { "args_as_extended: skipping #{skipped_tokens.length} args before option: #{skipped_tokens.map(&:value)}" } unless skipped_tokens.empty?
  result = nil
  get_next_argument('args', multiple: end_marker).each do |argument|
    Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
    path, value = argument.split(Option::VALUE_SEP, 2)
    result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
  end
  # Restore skipped tokens so they remain available for command dispatching
  skipped_tokens.reverse_each { |t| @argv_tokens.unshift(t) }
  result
end

#clear_option(option_symbol) ⇒ nil

Set option to nil

Parameters:

  • option_symbol (Symbol)

    option name

Returns:

  • (nil)


763
764
765
766
# File 'lib/aspera/cli/parser.rb', line 763

def clear_option(option_symbol)
  Aspera.assert_type(option_symbol, Symbol)
  option_def(option_symbol).clear
end

#command_or_arg_empty?Boolean

Check if there are no pending positional arguments

Returns:

  • (Boolean)

    true if no pending positional arguments



806
807
808
# File 'lib/aspera/cli/parser.rb', line 806

def command_or_arg_empty?
  pending_arguments.empty?
end

#declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block) ⇒ Object

Declare an option

Parameters:

  • option_symbol (Symbol)

    option name

  • description (String, nil) (defaults to: nil)

    description for help; if nil, derived from schema

  • short (String) (defaults to: nil)

    short option name

  • allowed (Object) (defaults to: nil)

    Allowed values, see OptionValue. When schema: is provided:

    • Omit allowed: when the schema has a single type (type: object/array): it is inferred automatically.
    • Omit allowed: when the schema uses oneOf/anyOf and all branches are object: Hash is inferred.
    • Use allowed: [Hash, String] when the option additionally accepts a plain String shorthand; the schema then documents the Hash form and =help still shows it.
  • default (Object) (defaults to: nil)

    default value

  • handler (Hash) (defaults to: nil)

    handler for option value: keys: :o(object) and :m(method)

  • deprecation (String) (defaults to: nil)

    deprecation

  • schema (String) (defaults to: nil)

    schema path documenting the Hash form of this option

  • block (Proc)

    Block to execute when option is found



577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
# File 'lib/aspera/cli/parser.rb', line 577

def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
  Aspera.assert_type(option_symbol, Symbol)
  Aspera.assert(!@declared_options.key?(option_symbol)) { "#{option_symbol} already declared" }
  Aspera.assert_type(handler, Hash) if handler
  Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
  option_attrs = @declared_options[option_symbol] = OptionValue.new(
    option:      option_symbol,
    description: description,
    allowed:     allowed,
    handler:     handler,
    deprecation: deprecation,
    schema:      schema
  )
  option_attrs.group = @current_group
  description = option_attrs.description
  Aspera.assert(!description.nil?) { "#{option_symbol}: no description and no schema to derive one from" }
  Aspera.assert(description[-1] != '.') { "#{option_symbol} ends with dot" }
  Aspera.assert(description[0] == description[0].upcase) { "#{option_symbol} description does not start with an uppercase" }
  Aspera.assert(!['hash', 'extended value'].any? { |s| description.downcase.include?(s) }) { "#{option_symbol} shall use :allowed instead of hash/extended value in option description" }
  set_option(option_symbol, default, where: 'default', warn_deprecation: false) unless default.nil?
  case option_attrs.types
  when Type::ENUM, Type::BOOLEAN
    # This option value must be a symbol (or array of symbols)
    set_option(option_symbol, BoolValue.true?(default), where: 'default', warn_deprecation: false) if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
  when Type::NONE
    Aspera.assert_type(block, Proc) { "missing execution block for #{option_symbol}" }
    option_attrs.block = block
  end
  @short_options[short] = option_symbol unless short.nil?
  Log.log.trace1 { "declare: #{option_symbol}, group: #{@current_group}, short: #{short}" }
end

#extract_argument_tokens(multiple) ⇒ Array<String>

Extract raw string tokens from @argv_tokens (Argument objects) according to multiple. Consumed Argument tokens are removed from @argv_tokens.

Parameters:

  • multiple (false, true, String)

    consumption mode: false — consume exactly one token true — consume all remaining tokens String — consume up to (but not including) the marker token, or all if absent

Returns:

  • (Array<String>)

    consumed raw token strings



946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
# File 'lib/aspera/cli/parser.rb', line 946

def extract_argument_tokens(multiple)
  arg_tokens = @argv_tokens.grep(Argument)
  case multiple
  when false
    tok = arg_tokens.first
    @argv_tokens.delete(tok)
    [tok.value]
  when true
    arg_tokens.each { |t| @argv_tokens.delete(t) }
    arg_tokens.map(&:value)
  when String
    idx = arg_tokens.index { |t| t.value.eql?(multiple) }
    consumed = idx ? arg_tokens[0, idx] : arg_tokens
    marker   = idx ? arg_tokens[idx] : nil
    (consumed + [marker].compact).each { |t| @argv_tokens.delete(t) }
    consumed.map(&:value)
  else Aspera.error_unexpected_value(multiple) { 'multiple' }
  end
end

#final_errorsArray<String>

Check for unprocessed options or arguments error messages

Returns:

  • (Array<String>)

    list of error messages for unprocessed tokens



812
813
814
815
816
817
# File 'lib/aspera/cli/parser.rb', line 812

def final_errors
  result = []
  result.push("unprocessed options: #{pending_options}") unless pending_options.empty?
  result.push("unprocessed values: #{pending_arguments}") unless pending_arguments.empty?
  result
end

#get_interactive(descr, check_option: false, multiple: false, accept_list: nil, aliases: nil, schema: nil) ⇒ String

Prompt user for input in a list of symbols

Parameters:

  • descr (String)

    description for help

  • check_option (Boolean) (defaults to: false)

    Check attributes of option with name=descr

  • multiple (Boolean, String) (defaults to: false)

    true if multiple values expected

  • accept_list (Array<Symbol>, NilClass) (defaults to: nil)

    List of expected values

Returns:



1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
# File 'lib/aspera/cli/parser.rb', line 1014

def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, aliases: nil, schema: nil)
  option_attrs = @declared_options[descr.to_sym]
  what = option_attrs ? 'option' : 'argument'
  default_prompt = "#{what}: #{descr}"
  if !@ask_missing_mandatory
    message = "Missing #{default_prompt}"
    message = self.class.multi_choice_assert_msg(message, accept_list, aliases: aliases) if accept_list
    message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
    raise Cli::MissingArgument, message
  end
  # ask interactively
  result = []
  puts(' (one per line, end with empty line)') if multiple
  loop do
    prompt = default_prompt
    prompt = "#{accept_list.join(' ')}\n#{default_prompt}" if accept_list
    entry = prompt_user_input(prompt, sensitive: option_attrs&.sensitive)
    break if entry.empty? && multiple
    entry = ExtendedValue.instance.evaluate(entry, context: 'interactive input')
    entry = self.class.get_from_list(entry, descr, accept_list) if accept_list
    return entry unless multiple
    result.push(entry)
  end
  result
end

#get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil) ⇒ Object, ...

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Low-level positional argument reader. Prefer Base#resolve_argument from action methods. Direct calls from outside Parser are legacy exceptions documented in ST12/ST13 (mixins without DSL: sync_actions, ascp_actions; setup callbacks: aoc.rb).

Parameters:

  • descr (String)

    description for help

  • mandatory (Boolean) (defaults to: true)

    true: raise error no more argument

  • multiple (Boolean) (defaults to: false)

    true: return all remaining arguments (Array). String: until marker

  • accept_list (Array<Symbol>, NilClass) (defaults to: nil)

    list of allowed values

  • validation (Class, Array, NilClass) (defaults to: Type::STRING)

    Accepted value type(s) or list of Symbols

  • aliases (Hash) (defaults to: nil)

    map of aliases: key = alias, value = real value

  • default (Object) (defaults to: nil)

    default value

Returns:

  • (Object, Array, nil)

    one value, list or nil (if optional and no default)



636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
# File 'lib/aspera/cli/parser.rb', line 636

def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil)
  Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
  Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
  validation = Symbol unless accept_list.nil?
  validation = [validation] unless validation.is_a?(Array) || validation.nil?
  Aspera.assert_array_all(validation, Class) { 'validation' } unless validation.nil?
  descr = "#{descr}#{add_types_info(validation)}"
  result =
    if !pending_arguments.empty?
      values = extract_argument_tokens(multiple)
      values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
      # If expecting list and only one arg of type array : it is the list
      values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
      if accept_list
        allowed_values = [].concat(accept_list)
        allowed_values.concat(aliases.keys) unless aliases.nil?
        values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
      end
      multiple ? values : values.first
    elsif !default.nil? then default
    elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, aliases: aliases, schema: schema)
    end
  Log.log.trace1 { "#{descr}=#{result}" }
  result = aliases[result] if aliases&.key?(result)
  # if value comes from JSON/YAML, it may come as Integer
  result = result.to_s if result.is_a?(Integer) && validation&.eql?(Type::STRING)
  # Integer coercion: a String argument for an INTEGER option must be parseable
  if result.is_a?(String) && validation&.eql?(Type::INTEGER)
    int_result = Integer(result, exception: false)
    raise Cli::BadArgument, "Invalid integer: #{result}" if int_result.nil?
    result = int_result
  end
  if validation && (mandatory || !result.nil?)
    value_list = multiple ? result : [result]
    value_list.each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) }
  end
  result
end

#get_next_command(command_list, aliases: nil) ⇒ Symbol

Get next positional command argument from accepted list

Parameters:

  • command_list (Array<Symbol>)

    accepted command names

  • aliases (Hash, nil) (defaults to: nil)

    command aliases

Returns:

  • (Symbol)

    selected command



697
# File 'lib/aspera/cli/parser.rb', line 697

def get_next_command(command_list, aliases: nil); get_next_argument('command', accept_list: command_list, aliases: aliases); end

#get_option(option_symbol, mandatory: false, schema: nil) ⇒ Object

Get an option value by name either return value or calls handler, can return nil ask interactively if requested/required

Parameters:

  • option_symbol (Symbol)

    name of the option to retrieve

  • mandatory (Boolean) (defaults to: false)

    if true, raise error if option not set

  • schema (String, nil) (defaults to: nil)

    contextual schema path override; when set, raises SchemaRequest if the option value is 'help' (used for --query whose schema depends on the current command)

Raises:



725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/aspera/cli/parser.rb', line 725

def get_option(option_symbol, mandatory: false, schema: nil)
  Aspera.assert_type(option_symbol, Symbol)
  option_attrs = option_def(option_symbol)
  result = option_attrs.value
  # Contextual schema: raise SchemaRequest when value is 'help'
  raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(HELP)
  # Do not fail for manual generation if option mandatory but not set
  return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
  if result.nil?
    if !@ask_missing_mandatory
      Aspera.assert(!mandatory, type: Cli::BadArgument) { "Missing mandatory option: #{option_symbol}" }
    elsif @ask_missing_optional || mandatory
      # ask_missing_mandatory
      result = get_interactive(option_symbol.to_s, check_option: true, accept_list: option_attrs.values, schema: option_attrs.schema)
      set_option(option_symbol, result, where: 'interactive')
    end
  end
  result
end

#group(name) ⇒ Object

Set the current help section group name for subsequent declarations

Parameters:

  • name (String)

    group name, shown as section header in help text



611
612
613
# File 'lib/aspera/cli/parser.rb', line 611

def group(name)
  @current_group = name
end

#help_text(banner: nil) ⇒ String

Generate help text for all declared options, grouped by section.

Parameters:

  • banner (String, nil) (defaults to: nil)

    Optional banner text to prepend

Returns:

  • (String)

    Formatted help text



1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
# File 'lib/aspera/cli/parser.rb', line 1071

def help_text(banner: nil)
  rows = []
  current_group = nil
  @declared_options.each do |sym, opt|
    if opt.group != current_group
      current_group = opt.group
      rows << [{value: "OPTIONS: #{current_group}", colspan: 2}]
    end
    short_char = @short_options.key(sym)
    short_part = short_char ? "-#{short_char}, " : '    '
    flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
    desc = opt.deprecation ? "#{opt.description} (deprecated: #{opt.deprecation})" : opt.description
    rows << [flag, desc]
  end
  table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
  banner.nil? ? table.to_s : "#{banner}\n#{table}"
end

#instance_identifier(description: 'identifier', &block) {|field, value| ... } ⇒ String+

Resource identifier as positional parameter

Parameters:

  • description (String) (defaults to: 'identifier')

    description of the identifier

  • block (Proc)

    block to search for identifier based on attribute value

Yield Parameters:

  • field (String)

    The field name from percent selector

  • value (String)

    The value from percent selector

Yield Returns:

  • (String)

    Resolved identifier

Returns:

  • (String, Array<String>)

    identifier or list of IDs (if bulk option is set)



683
684
685
686
687
688
689
690
691
# File 'lib/aspera/cli/parser.rb', line 683

def instance_identifier(description: 'identifier', &block)
  res_id = get_next_argument(description, multiple: get_option(:bulk))
  # Can be an Array
  if res_id.is_a?(String) && (m = Parser.percent_selector(res_id))
    Aspera.assert(block_given?, type: Cli::BadArgument) { "Percent syntax for #{description} not supported in this context" }
    res_id = yield(m[:field], m[:value])
  end
  res_id
end

#known_options(only_defined: false) ⇒ Hash

Returns options as taken from config file and command line just before command execution.

Parameters:

  • only_defined (Boolean) (defaults to: false)

    if true, only return options that were defined

Returns:

  • (Hash)

    options as taken from config file and command line just before command execution



840
841
842
843
844
845
846
847
848
849
# File 'lib/aspera/cli/parser.rb', line 840

def known_options(only_defined: false)
  result = {}
  @declared_options.each_key do |option_symbol|
    v = get_option(option_symbol)
    result[option_symbol] = v unless only_defined && v.nil?
  rescue => e
    result[option_symbol] = e.to_s
  end
  result
end

#option_declared?(option_symbol) ⇒ Boolean

Check whether an option has already been declared in this manager

Parameters:

  • option_symbol (Symbol)

    name of the option

Returns:

  • (Boolean)


702
703
704
# File 'lib/aspera/cli/parser.rb', line 702

def option_declared?(option_symbol)
  @declared_options.key?(option_symbol)
end

#option_def(option_symbol) ⇒ OptionValue

Get an option definition by name

Parameters:

  • option_symbol (Symbol)

    name of the option

Returns:

Raises:



713
714
715
716
# File 'lib/aspera/cli/parser.rb', line 713

def option_def(option_symbol)
  Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument) { "Unknown option: #{option_symbol}" }
  @declared_options[option_symbol]
end

#parse_options!Object

Removes already known options from the list



852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
# File 'lib/aspera/cli/parser.rb', line 852

def parse_options!
  Log.log.trace1('parse_options!'.red)
  # First options from conf file
  @option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
  # Then, env var (to override)
  @option_pairs_env = consume_option_pairs(@option_pairs_env, 'env')
  # Then, command line override.
  # Iterate @argv_tokens in order so that --opt val and -s val can consume the next argument
  # token directly, without any secondary index.
  # Process one option at a time so that @current_option_args_offset can be set before each
  # option is evaluated (used by `@:` extended value).
  deferred_tokens = []
  Log.log.trace1('Before parse')
  Log.dump(:argv_tokens, @argv_tokens, level: :trace1)
  # Iterate with an index so that Argument tokens stay in @argv_tokens (not shifted).
  # Only Option tokens are removed; Arguments remain until consumed by extract_argument_tokens.
  @current_parse_idx = 0
  while @current_parse_idx < @argv_tokens.length
    tok = @argv_tokens[@current_parse_idx]
    if tok.is_a?(Argument)
      @current_parse_idx += 1
      next
    end
    # tok is an Option — remove it from @argv_tokens
    @argv_tokens.delete_at(@current_parse_idx)
    # @current_option_args_offset = number of positional Argument tokens that appear BEFORE
    # this option in @argv_tokens (i.e. at indices 0...@current_parse_idx after the delete).
    # Used by args_as_extended to skip those leading args when collecting @: values.
    @current_option_args_offset = @argv_tokens[0...@current_parse_idx].count { |t| t.is_a?(Argument) }
    if tok.short_char
      # Short option: -X or -Xvalue
      option_sym = @short_options[tok.short_char]
      if option_sym
        raw_value = tok.value
        # No inline value and option expects a value: consume the next :argument token
        raw_value = shift_next_argument_token \
          if !tok.has_value && !@declared_options[option_sym].types.eql?(Type::NONE)
        dispatch_option(option_sym, raw_value)
      else
        deferred_tokens.push(tok)
      end
    elsif tok.dot_path
      # Dotted notation: --a.b.c=val or --a.b.c val (always takes priority over plain option lookup)
      Log.log.trace1 { "Dotted option: #{tok.raw}".red }
      if tok.has_value
        raw_value = tok.value
        value_from_next_token = false
      else
        raw_value = shift_next_argument_token
        value_from_next_token = true
      end
      if @declared_options.key?(tok.name.to_sym)
        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')
      else
        # Only re-inject if value was space-separated (consumed from next token); inline values stay in tok.value
        @argv_tokens.unshift(Argument.new(raw_value)) if raw_value && value_from_next_token
        deferred_tokens.push(tok)
      end
    elsif (resolved_sym = self.class.match_prefix(tok.name.to_sym, @declared_options.keys))
      # Known long option (plain, no dot-path)
      raw_value = tok.value
      # No inline `=` and option expects a value: consume the next :argument token
      raw_value = shift_next_argument_token \
        if !tok.has_value && !@declared_options[resolved_sym].types.eql?(Type::NONE)
      dispatch_option(resolved_sym, raw_value)
    else
      Log.log.trace1 { "Unknown long option: #{tok.raw}".red }
      deferred_tokens.push(tok)
    end
  end
  @current_option_args_offset = nil
  @current_parse_idx = nil
  Log.log.trace1('After parse')
  Log.log.trace1 { "deferred: #{deferred_tokens}" }
  # @argv_tokens now contains only: remaining Arguments + deferred Options (already in correct order).
  # Re-insert deferred Options at their original positions by rebuilding from @initial_argv_tokens.
  deferred_ids = deferred_tokens.map(&:object_id).to_set
  remaining_arg_ids = @argv_tokens.grep(Argument).map(&:object_id).to_set
  injected = @argv_tokens.grep(Argument).reject { |t| @initial_argv_tokens.include?(t) }
  @argv_tokens = @initial_argv_tokens.select do |t|
    (t.is_a?(Option) && deferred_ids.include?(t.object_id)) ||
      (t.is_a?(Argument) && remaining_arg_ids.include?(t.object_id))
  end
  # Prepend arguments injected at runtime (e.g. via unshift_next_argument) not in @initial_argv_tokens.
  @argv_tokens.unshift(*injected)
end

#prompt_user_input(prompt, sensitive: false) ⇒ String

Prompt user for console input

Parameters:

  • prompt (String)

    prompt string to display

  • sensitive (Boolean) (defaults to: false)

    whether to hide typed input

Returns:

  • (String)

    user input stripped of trailing newline



985
986
987
988
989
990
991
# File 'lib/aspera/cli/parser.rb', line 985

def prompt_user_input(prompt, sensitive: false)
  return $stdin.getpass("#{prompt}> ") if sensitive
  print("#{prompt}> ")
  line = $stdin.gets
  Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
  line.chomp
end

#prompt_user_input_in_list(prompt, sym_list) ⇒ Symbol

prompt user for input in a list of symbols

Parameters:

  • prompt (String)

    prompt to display

  • sym_list (Array)

    list of symbols to select from

Returns:

  • (Symbol)

    selected symbol



997
998
999
1000
1001
1002
1003
1004
1005
1006
# File 'lib/aspera/cli/parser.rb', line 997

def prompt_user_input_in_list(prompt, sym_list)
  loop do
    input = prompt_user_input(prompt).to_sym
    if sym_list.any? { |a| a.eql?(input) }
      return input
    else
      $stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
    end
  end
end

#rename_current_group(name) ⇒ Object

Rename all options currently tagged with @current_group to a new name, then update @current_group. Used by add_manual_header when a plugin declares its options before its group name is known (e.g. Plugins::Config).

Parameters:

  • name (String)

    new group name



619
620
621
622
# File 'lib/aspera/cli/parser.rb', line 619

def rename_current_group(name)
  @declared_options.each_value { |opt| opt.group = name if opt.group.eql?(@current_group) }
  @current_group = name
end

#set_handler(option_symbol, object:, method:) ⇒ nil

Bind (or re-bind) a runtime handler to an already-declared option. Called from plugin initialize() for Category C handlers whose target object (e.g. @gen_options) is created after class-load time.

Parameters:

  • option_symbol (Symbol)

    name of the already-declared option

  • object (Object)

    the target object for get/set delegation

  • method (Symbol)

    accessor method name on object

Returns:

  • (nil)


775
776
777
778
# File 'lib/aspera/cli/parser.rb', line 775

def set_handler(option_symbol, object:, method:)
  Aspera.assert_type(option_symbol, Symbol)
  option_def(option_symbol).bind_handler(o: object, m: method)
end

#set_option(option_symbol, value, where: 'code override', warn_deprecation: true) ⇒ Object

Set an option value by name, either store value or call handler String is given to extended value

Parameters:

  • option_symbol (Symbol)

    option name

  • value (String)

    Value to set

  • where (String) (defaults to: 'code override')

    Where the value comes from

Raises:



750
751
752
753
754
755
756
757
758
# File 'lib/aspera/cli/parser.rb', line 750

def set_option(option_symbol, value, where: 'code override', warn_deprecation: true)
  Aspera.assert_type(option_symbol, Symbol)
  option = option_def(option_symbol)
  # Raise immediately only when the option has a static schema: the schema is known at parse time.
  # When schema is nil (e.g. --query), 'help' is stored as-is and SchemaRequest is raised later
  # in get_option() with the contextual schema provided by the calling command.
  raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP) && option.schema
  option.assign_value(value, where: where, warn_deprecation: warn_deprecation)
end

#unprocessed_options_with_valueHash

Get all original options on command line used to generate a config in config file

Returns:

  • (Hash)

    options as taken from config file and command line just before command execution



821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
# File 'lib/aspera/cli/parser.rb', line 821

def unprocessed_options_with_value
  result = {}
  @initial_argv_tokens.each_with_index do |tok, idx|
    next unless tok.is_a?(Option) && tok.short_char.nil?
    # For space-separated form: value is the immediately following :argument token (if any)
    value = tok.value || @initial_argv_tokens[idx + 1]&.then { |t| t.value if t.is_a?(Argument) }
    # ignore options without value
    next if value.nil?
    name = tok.dot_path ? [tok.name, *tok.dot_path].join(DotContainer::SEPARATOR) : tok.name
    Log.log.debug { "option #{name}=#{value}" }
    path = [tok.name, *(tok.dot_path || [])]
    DotContainer.dotted_to_container(path, Parser.smart_convert(value), result)
    @argv_tokens.reject! { |t| t.is_a?(Option) && t.raw.eql?(tok.raw) }
  end
  result
end

#unshift_next_argument(argument) ⇒ Array<Option, Argument>

Allows a plugin to add an argument as next argument to process

Parameters:

  • argument (String)

    argument value to prepend

Returns:



800
801
802
# File 'lib/aspera/cli/parser.rb', line 800

def unshift_next_argument(argument)
  @argv_tokens.unshift(Argument.new(argument))
end

#validate_argument(value, validation:, descr:, schema:) ⇒ Object

Validate and coerce a single argument value. Raises SchemaRequest when the value is 'help' and validation includes Hash. Raises BadArgument when the value is an Integer on a STRING validation (coerced upstream). Raises BadArgument when the value's type is not in the validation list.

Parameters:

  • value (Object)

    the value to validate

  • validation (Array<Class>)

    accepted types

  • descr (String)

    argument description (for error messages)

  • schema (String, nil)

    schema path for SchemaRequest

Raises:



974
975
976
977
978
979
# File 'lib/aspera/cli/parser.rb', line 974

def validate_argument(value, validation:, descr:, schema:)
  raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(HELP)
  raise Cli::BadArgument,
    "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
    unless validation.any? { |t| value.is_a?(t) }
end