git 4.3.2 → 5.0.0.beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/copilot-instructions.md +67 -2705
- data/.github/pull_request_template.md +3 -1
- data/.github/skills/breaking-change-analysis/SKILL.md +102 -0
- data/.github/skills/ci-cd-troubleshooting/SKILL.md +264 -0
- data/.github/skills/command-implementation/REFERENCE.md +993 -0
- data/.github/skills/command-implementation/SKILL.md +229 -0
- data/.github/skills/command-test-conventions/SKILL.md +660 -0
- data/.github/skills/command-yard-documentation/SKILL.md +426 -0
- data/.github/skills/dependency-management/SKILL.md +72 -0
- data/.github/skills/development-workflow/SKILL.md +506 -0
- data/.github/skills/extract-command-from-lib/SKILL.md +487 -0
- data/.github/skills/extract-facade-from-base-lib/SKILL.md +586 -0
- data/.github/skills/facade-implementation/REFERENCE.md +840 -0
- data/.github/skills/facade-implementation/SKILL.md +260 -0
- data/.github/skills/facade-test-conventions/SKILL.md +380 -0
- data/.github/skills/facade-yard-documentation/SKILL.md +429 -0
- data/.github/skills/make-skill-template/SKILL.md +176 -0
- data/.github/skills/pr-readiness-review/SKILL.md +185 -0
- data/.github/skills/project-context/SKILL.md +313 -0
- data/.github/skills/pull-request-review/SKILL.md +168 -0
- data/.github/skills/refactor-command-to-commandlineresult/SKILL.md +131 -0
- data/.github/skills/release-management/SKILL.md +125 -0
- data/.github/skills/review-arguments-dsl/CHECKLIST.md +788 -0
- data/.github/skills/review-arguments-dsl/SKILL.md +214 -0
- data/.github/skills/review-backward-compatibility/SKILL.md +275 -0
- data/.github/skills/review-cross-command-consistency/SKILL.md +139 -0
- data/.github/skills/reviewing-skills/SKILL.md +189 -0
- data/.github/skills/rspec-unit-testing-standards/SKILL.md +639 -0
- data/.github/skills/tdd-refactor-step/SKILL.md +236 -0
- data/.github/skills/test-debugging/SKILL.md +160 -0
- data/.github/skills/yard-documentation/SKILL.md +793 -0
- data/.github/workflows/continuous_integration.yml +3 -2
- data/.github/workflows/enforce_conventional_commits.yml +1 -1
- data/.github/workflows/experimental_continuous_integration.yml +2 -2
- data/.github/workflows/release.yml +3 -4
- data/.gitignore +8 -0
- data/.husky/pre-commit +13 -0
- data/.release-please-manifest.json +1 -1
- data/.rspec +3 -0
- data/.rubocop.yml +7 -3
- data/.rubocop_todo.yml +23 -5
- data/.yardopts +1 -0
- data/CHANGELOG.md +0 -40
- data/CONTRIBUTING.md +694 -53
- data/README.md +17 -5
- data/Rakefile +61 -9
- data/commitlint.test +4 -0
- data/git.gemspec +14 -8
- data/lib/git/args_builder.rb +0 -8
- data/lib/git/base.rb +486 -410
- data/lib/git/branch.rb +380 -43
- data/lib/git/branch_delete_failure.rb +31 -0
- data/lib/git/branch_delete_result.rb +63 -0
- data/lib/git/branch_info.rb +178 -0
- data/lib/git/branches.rb +130 -24
- data/lib/git/command_line/base.rb +245 -0
- data/lib/git/command_line/capturing.rb +249 -0
- data/lib/git/command_line/result.rb +96 -0
- data/lib/git/command_line/streaming.rb +194 -0
- data/lib/git/command_line.rb +43 -322
- data/lib/git/command_line_result.rb +4 -88
- data/lib/git/commands/add.rb +131 -0
- data/lib/git/commands/am/abort.rb +43 -0
- data/lib/git/commands/am/apply.rb +252 -0
- data/lib/git/commands/am/continue.rb +43 -0
- data/lib/git/commands/am/quit.rb +43 -0
- data/lib/git/commands/am/retry.rb +47 -0
- data/lib/git/commands/am/show_current_patch.rb +64 -0
- data/lib/git/commands/am/skip.rb +42 -0
- data/lib/git/commands/am.rb +33 -0
- data/lib/git/commands/apply.rb +237 -0
- data/lib/git/commands/archive/list_formats.rb +46 -0
- data/lib/git/commands/archive.rb +140 -0
- data/lib/git/commands/arguments.rb +3510 -0
- data/lib/git/commands/base.rb +403 -0
- data/lib/git/commands/branch/copy.rb +94 -0
- data/lib/git/commands/branch/create.rb +173 -0
- data/lib/git/commands/branch/delete.rb +80 -0
- data/lib/git/commands/branch/list.rb +162 -0
- data/lib/git/commands/branch/move.rb +94 -0
- data/lib/git/commands/branch/set_upstream.rb +86 -0
- data/lib/git/commands/branch/show_current.rb +49 -0
- data/lib/git/commands/branch/unset_upstream.rb +57 -0
- data/lib/git/commands/branch.rb +34 -0
- data/lib/git/commands/cat_file/batch.rb +364 -0
- data/lib/git/commands/cat_file/filtered.rb +105 -0
- data/lib/git/commands/cat_file/raw.rb +210 -0
- data/lib/git/commands/cat_file.rb +49 -0
- data/lib/git/commands/checkout/branch.rb +151 -0
- data/lib/git/commands/checkout/files.rb +115 -0
- data/lib/git/commands/checkout.rb +38 -0
- data/lib/git/commands/checkout_index.rb +105 -0
- data/lib/git/commands/clean.rb +100 -0
- data/lib/git/commands/clone.rb +240 -0
- data/lib/git/commands/commit.rb +272 -0
- data/lib/git/commands/commit_tree.rb +100 -0
- data/lib/git/commands/config_option_syntax/add.rb +83 -0
- data/lib/git/commands/config_option_syntax/get.rb +117 -0
- data/lib/git/commands/config_option_syntax/get_all.rb +115 -0
- data/lib/git/commands/config_option_syntax/get_color.rb +91 -0
- data/lib/git/commands/config_option_syntax/get_color_bool.rb +93 -0
- data/lib/git/commands/config_option_syntax/get_regexp.rb +115 -0
- data/lib/git/commands/config_option_syntax/get_urlmatch.rb +102 -0
- data/lib/git/commands/config_option_syntax/list.rb +107 -0
- data/lib/git/commands/config_option_syntax/remove_section.rb +74 -0
- data/lib/git/commands/config_option_syntax/rename_section.rb +78 -0
- data/lib/git/commands/config_option_syntax/replace_all.rb +104 -0
- data/lib/git/commands/config_option_syntax/set.rb +114 -0
- data/lib/git/commands/config_option_syntax/unset.rb +89 -0
- data/lib/git/commands/config_option_syntax/unset_all.rb +89 -0
- data/lib/git/commands/config_option_syntax.rb +56 -0
- data/lib/git/commands/describe.rb +155 -0
- data/lib/git/commands/diff.rb +656 -0
- data/lib/git/commands/diff_files.rb +518 -0
- data/lib/git/commands/diff_index.rb +496 -0
- data/lib/git/commands/fetch.rb +352 -0
- data/lib/git/commands/fsck.rb +136 -0
- data/lib/git/commands/gc.rb +132 -0
- data/lib/git/commands/grep.rb +338 -0
- data/lib/git/commands/init.rb +99 -0
- data/lib/git/commands/log.rb +632 -0
- data/lib/git/commands/ls_files.rb +191 -0
- data/lib/git/commands/ls_remote.rb +155 -0
- data/lib/git/commands/ls_tree.rb +131 -0
- data/lib/git/commands/maintenance/register.rb +75 -0
- data/lib/git/commands/maintenance/run.rb +104 -0
- data/lib/git/commands/maintenance/start.rb +66 -0
- data/lib/git/commands/maintenance/stop.rb +55 -0
- data/lib/git/commands/maintenance/unregister.rb +79 -0
- data/lib/git/commands/maintenance.rb +31 -0
- data/lib/git/commands/merge/abort.rb +44 -0
- data/lib/git/commands/merge/continue.rb +44 -0
- data/lib/git/commands/merge/quit.rb +46 -0
- data/lib/git/commands/merge/start.rb +245 -0
- data/lib/git/commands/merge.rb +28 -0
- data/lib/git/commands/merge_base.rb +86 -0
- data/lib/git/commands/mv.rb +77 -0
- data/lib/git/commands/name_rev.rb +114 -0
- data/lib/git/commands/pull.rb +377 -0
- data/lib/git/commands/push.rb +246 -0
- data/lib/git/commands/read_tree.rb +149 -0
- data/lib/git/commands/remote/add.rb +91 -0
- data/lib/git/commands/remote/get_url.rb +66 -0
- data/lib/git/commands/remote/list.rb +54 -0
- data/lib/git/commands/remote/prune.rb +61 -0
- data/lib/git/commands/remote/remove.rb +52 -0
- data/lib/git/commands/remote/rename.rb +69 -0
- data/lib/git/commands/remote/set_branches.rb +63 -0
- data/lib/git/commands/remote/set_head.rb +82 -0
- data/lib/git/commands/remote/set_url.rb +71 -0
- data/lib/git/commands/remote/set_url_add.rb +61 -0
- data/lib/git/commands/remote/set_url_delete.rb +64 -0
- data/lib/git/commands/remote/show.rb +71 -0
- data/lib/git/commands/remote/update.rb +72 -0
- data/lib/git/commands/remote.rb +42 -0
- data/lib/git/commands/repack.rb +277 -0
- data/lib/git/commands/reset.rb +147 -0
- data/lib/git/commands/rev_parse.rb +297 -0
- data/lib/git/commands/revert/abort.rb +45 -0
- data/lib/git/commands/revert/continue.rb +57 -0
- data/lib/git/commands/revert/quit.rb +47 -0
- data/lib/git/commands/revert/skip.rb +44 -0
- data/lib/git/commands/revert/start.rb +153 -0
- data/lib/git/commands/revert.rb +29 -0
- data/lib/git/commands/rm.rb +114 -0
- data/lib/git/commands/show.rb +632 -0
- data/lib/git/commands/show_ref/exclude_existing.rb +120 -0
- data/lib/git/commands/show_ref/exists.rb +78 -0
- data/lib/git/commands/show_ref/list.rb +145 -0
- data/lib/git/commands/show_ref/verify.rb +120 -0
- data/lib/git/commands/show_ref.rb +42 -0
- data/lib/git/commands/stash/apply.rb +75 -0
- data/lib/git/commands/stash/branch.rb +65 -0
- data/lib/git/commands/stash/clear.rb +41 -0
- data/lib/git/commands/stash/create.rb +58 -0
- data/lib/git/commands/stash/drop.rb +67 -0
- data/lib/git/commands/stash/list.rb +39 -0
- data/lib/git/commands/stash/pop.rb +78 -0
- data/lib/git/commands/stash/push.rb +103 -0
- data/lib/git/commands/stash/show.rb +149 -0
- data/lib/git/commands/stash/store.rb +63 -0
- data/lib/git/commands/stash.rb +38 -0
- data/lib/git/commands/status.rb +169 -0
- data/lib/git/commands/symbolic_ref/delete.rb +68 -0
- data/lib/git/commands/symbolic_ref/read.rb +95 -0
- data/lib/git/commands/symbolic_ref/update.rb +76 -0
- data/lib/git/commands/symbolic_ref.rb +38 -0
- data/lib/git/commands/tag/create.rb +139 -0
- data/lib/git/commands/tag/delete.rb +55 -0
- data/lib/git/commands/tag/list.rb +143 -0
- data/lib/git/commands/tag/verify.rb +71 -0
- data/lib/git/commands/tag.rb +26 -0
- data/lib/git/commands/update_ref/batch.rb +140 -0
- data/lib/git/commands/update_ref/delete.rb +92 -0
- data/lib/git/commands/update_ref/update.rb +106 -0
- data/lib/git/commands/update_ref.rb +42 -0
- data/lib/git/commands/version.rb +52 -0
- data/lib/git/commands/worktree/add.rb +140 -0
- data/lib/git/commands/worktree/list.rb +64 -0
- data/lib/git/commands/worktree/lock.rb +58 -0
- data/lib/git/commands/worktree/management_base.rb +51 -0
- data/lib/git/commands/worktree/move.rb +66 -0
- data/lib/git/commands/worktree/prune.rb +67 -0
- data/lib/git/commands/worktree/remove.rb +63 -0
- data/lib/git/commands/worktree/repair.rb +76 -0
- data/lib/git/commands/worktree/unlock.rb +47 -0
- data/lib/git/commands/worktree.rb +43 -0
- data/lib/git/commands/write_tree.rb +68 -0
- data/lib/git/commands.rb +89 -0
- data/lib/git/detached_head_info.rb +54 -0
- data/lib/git/diff.rb +297 -7
- data/lib/git/diff_file_numstat_info.rb +29 -0
- data/lib/git/diff_file_patch_info.rb +134 -0
- data/lib/git/diff_file_raw_info.rb +127 -0
- data/lib/git/diff_info.rb +169 -0
- data/lib/git/diff_path_status.rb +78 -19
- data/lib/git/diff_result.rb +32 -0
- data/lib/git/diff_stats.rb +59 -14
- data/lib/git/dirstat_info.rb +86 -0
- data/lib/git/errors.rb +65 -2
- data/lib/git/execution_context/global.rb +56 -0
- data/lib/git/execution_context/repository.rb +147 -0
- data/lib/git/execution_context.rb +482 -0
- data/lib/git/file_ref.rb +74 -0
- data/lib/git/fsck_object.rb +9 -9
- data/lib/git/fsck_result.rb +1 -1
- data/lib/git/lib.rb +1606 -1028
- data/lib/git/log.rb +15 -2
- data/lib/git/object.rb +92 -22
- data/lib/git/parsers/branch.rb +224 -0
- data/lib/git/parsers/cat_file.rb +111 -0
- data/lib/git/parsers/diff.rb +585 -0
- data/lib/git/parsers/fsck.rb +133 -0
- data/lib/git/parsers/grep.rb +42 -0
- data/lib/git/parsers/ls_tree.rb +58 -0
- data/lib/git/parsers/stash.rb +208 -0
- data/lib/git/parsers/tag.rb +257 -0
- data/lib/git/remote.rb +133 -9
- data/lib/git/repository/branching.rb +572 -0
- data/lib/git/repository/committing.rb +191 -0
- data/lib/git/repository/configuring.rb +156 -0
- data/lib/git/repository/diffing.rb +775 -0
- data/lib/git/repository/inspecting.rb +153 -0
- data/lib/git/repository/logging.rb +247 -0
- data/lib/git/repository/merging.rb +295 -0
- data/lib/git/repository/object_operations.rb +1101 -0
- data/lib/git/repository/path_resolver.rb +207 -0
- data/lib/git/repository/remote_operations.rb +753 -0
- data/lib/git/repository/shared_private.rb +51 -0
- data/lib/git/repository/staging.rb +390 -0
- data/lib/git/repository/stashing.rb +107 -0
- data/lib/git/repository/status_operations.rb +180 -0
- data/lib/git/repository/worktree_operations.rb +159 -0
- data/lib/git/repository.rb +264 -1
- data/lib/git/stash.rb +85 -4
- data/lib/git/stash_info.rb +104 -0
- data/lib/git/stashes.rb +130 -13
- data/lib/git/status.rb +224 -18
- data/lib/git/tag_delete_failure.rb +31 -0
- data/lib/git/tag_delete_result.rb +63 -0
- data/lib/git/tag_info.rb +105 -0
- data/lib/git/version.rb +109 -2
- data/lib/git/version_constraint.rb +81 -0
- data/lib/git/worktree.rb +120 -5
- data/lib/git/worktrees.rb +107 -7
- data/lib/git.rb +114 -18
- data/redesign/1_architecture_existing.md +54 -18
- data/redesign/2_architecture_redesign.md +365 -46
- data/redesign/3_architecture_implementation.md +1451 -54
- data/tasks/gem_tasks.rake +4 -0
- data/tasks/npm_tasks.rake +7 -0
- data/tasks/rspec.rake +48 -0
- data/tasks/test.rake +13 -1
- data/tasks/yard.rake +34 -7
- metadata +349 -20
- data/lib/git/index.rb +0 -6
- data/lib/git/path.rb +0 -38
- data/lib/git/working_directory.rb +0 -6
- /data/{release-please-config.json → .release-please-config.json} +0 -0
|
@@ -0,0 +1,3510 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Commands
|
|
5
|
+
# rubocop:disable Metrics/ParameterLists
|
|
6
|
+
|
|
7
|
+
# This class provides a DSL for mapping Ruby method arguments to git command-line
|
|
8
|
+
# arguments.
|
|
9
|
+
#
|
|
10
|
+
# ## Overview
|
|
11
|
+
#
|
|
12
|
+
# This class provides a DSL for defining how arguments passed to {#bind} should
|
|
13
|
+
# be mapped to git CLI argument arrays. The process follows four phases:
|
|
14
|
+
#
|
|
15
|
+
# 1. **Definition** of expected CLI arguments and their constraints
|
|
16
|
+
# 2. **Binding** of method arguments to the definition
|
|
17
|
+
# 3. **Validation** of values against argument constraints
|
|
18
|
+
# 4. **Building** of the CLI argument array
|
|
19
|
+
#
|
|
20
|
+
# See {Git::Commands::Init} for a usage example.
|
|
21
|
+
#
|
|
22
|
+
# Example: Defining arguments for a command
|
|
23
|
+
#
|
|
24
|
+
# ```ruby
|
|
25
|
+
# # 1. Definition of expected CLI arguments and their constraints
|
|
26
|
+
# args_def = Arguments.define do
|
|
27
|
+
# flag_option :force
|
|
28
|
+
# value_option :branch
|
|
29
|
+
# operand :repository, required: true
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # 2. Binding of method arguments to the definition
|
|
33
|
+
# # 3. Validation of values against argument constraints
|
|
34
|
+
# args = args_def.bind('https://github.com/user/repo', force: true, branch: 'main')
|
|
35
|
+
#
|
|
36
|
+
# # 4. Building of the CLI argument array
|
|
37
|
+
# args.to_a # => ['--force', '--branch', 'main', 'https://github.com/user/repo']
|
|
38
|
+
#
|
|
39
|
+
# # Bonus: accessing bound values
|
|
40
|
+
# args.force? # => true
|
|
41
|
+
# args.branch # => 'main'
|
|
42
|
+
# args.repository # => 'https://github.com/user/repo'
|
|
43
|
+
# ```
|
|
44
|
+
#
|
|
45
|
+
# ## Terminology
|
|
46
|
+
#
|
|
47
|
+
# This class bridges CLI and Ruby interfaces. While both use the term "arguments"
|
|
48
|
+
# for values passed to commands/methods, they differ in terminology for specific
|
|
49
|
+
# argument types:
|
|
50
|
+
#
|
|
51
|
+
# | CLI (POSIX) | Ruby Interface | Description |
|
|
52
|
+
# | ---------------------- | ---------------------- | --------------------------------------------------- |
|
|
53
|
+
# | argument specification | DSL definition | Declared command inputs and constraints |
|
|
54
|
+
# | arguments | arguments | Values passed when calling a command/method |
|
|
55
|
+
# | operands | positional arguments | Arguments identified by position |
|
|
56
|
+
# | options | keyword arguments | Arguments identified by name (`--force` / `force:`) |
|
|
57
|
+
#
|
|
58
|
+
# The following sections explain each interface in detail.
|
|
59
|
+
#
|
|
60
|
+
# ### CLI Interface (POSIX)
|
|
61
|
+
#
|
|
62
|
+
# An **argument specification** declares what command inputs are accepted and
|
|
63
|
+
# their constraints.
|
|
64
|
+
#
|
|
65
|
+
# Example:
|
|
66
|
+
#
|
|
67
|
+
# ```text
|
|
68
|
+
# git branch (--set-upstream-to=<upstream>|-u <upstream>) [<branch-name>]
|
|
69
|
+
# ```
|
|
70
|
+
#
|
|
71
|
+
# When a command is invoked, **arguments** are the values passed to it:
|
|
72
|
+
# - **Arguments**: Values passed when calling the command (everything after the
|
|
73
|
+
# command name)
|
|
74
|
+
# - **Operands**: Arguments identified by position
|
|
75
|
+
# - **Options**: Arguments identified by name (prefixed with `-` or `--`)
|
|
76
|
+
#
|
|
77
|
+
# Example:
|
|
78
|
+
#
|
|
79
|
+
# ```shell
|
|
80
|
+
# git branch --set-upstream-to=origin/main main
|
|
81
|
+
# ```
|
|
82
|
+
#
|
|
83
|
+
# - Operands: `main`
|
|
84
|
+
# - Options: `--set-upstream-to=origin/main`
|
|
85
|
+
#
|
|
86
|
+
# ### Ruby Interface
|
|
87
|
+
#
|
|
88
|
+
# A **DSL definition** declares what arguments the {#bind} method accepts and how
|
|
89
|
+
# they map to CLI arguments.
|
|
90
|
+
#
|
|
91
|
+
# Example:
|
|
92
|
+
#
|
|
93
|
+
# ```ruby
|
|
94
|
+
# Arguments.define do
|
|
95
|
+
# literal 'branch'
|
|
96
|
+
# value_option %i[set_upstream_to u], inline: true # primary name with short alias :u
|
|
97
|
+
# operand :branch_name
|
|
98
|
+
# end
|
|
99
|
+
# ```
|
|
100
|
+
#
|
|
101
|
+
# When {#bind} is called, **arguments** are the values passed to it:
|
|
102
|
+
# - **Arguments**: Values passed to {#bind}
|
|
103
|
+
# - **Positional arguments**: Arguments identified by position
|
|
104
|
+
# - **Keyword arguments**: Arguments identified by name
|
|
105
|
+
#
|
|
106
|
+
# Example:
|
|
107
|
+
#
|
|
108
|
+
# ```ruby
|
|
109
|
+
# args_def.bind('main', set_upstream_to: 'origin/main')
|
|
110
|
+
# ```
|
|
111
|
+
#
|
|
112
|
+
# - Positional argument: `'main'`
|
|
113
|
+
# - Keyword argument: `set_upstream_to: 'origin/main'`
|
|
114
|
+
#
|
|
115
|
+
# Calling {Bound#to_a} on the bound result produces the CLI argument array:
|
|
116
|
+
#
|
|
117
|
+
# ```ruby
|
|
118
|
+
# args_def.bind('main', set_upstream_to: 'origin/main').to_a
|
|
119
|
+
# # => ['branch', '--set-upstream-to=origin/main', 'main']
|
|
120
|
+
# ```
|
|
121
|
+
#
|
|
122
|
+
# ## Design
|
|
123
|
+
#
|
|
124
|
+
# The class operates in two stages:
|
|
125
|
+
#
|
|
126
|
+
# 1. **Definition stage**: DSL methods ({#flag_option}, {#value_option}, {#operand}, etc.)
|
|
127
|
+
# record argument definitions in internal data structures.
|
|
128
|
+
#
|
|
129
|
+
# 2. **Bind stage**: {#bind} binds Ruby values and validates them against constraints,
|
|
130
|
+
# returning a {Bound} object.
|
|
131
|
+
#
|
|
132
|
+
# The returned {Bound} object provides accessor methods for the bound values and handles
|
|
133
|
+
# the building phase, converting bound values to CLI arguments via {Bound#to_a}.
|
|
134
|
+
#
|
|
135
|
+
# Key internal components:
|
|
136
|
+
#
|
|
137
|
+
# - +@ordered_definitions+: Array tracking all definitions in definition order
|
|
138
|
+
# - +@option_definitions+: Hash mapping option names to their definitions
|
|
139
|
+
# - +@operand_definitions+: Array of operand (positional argument) definitions
|
|
140
|
+
# - +@alias_map+: Maps option aliases to their primary names
|
|
141
|
+
# - +BUILDERS+: Hash of lambdas that convert values to CLI arguments by type
|
|
142
|
+
# - {OperandAllocator}: Handles Ruby-like operand allocation
|
|
143
|
+
#
|
|
144
|
+
# ## Argument Ordering
|
|
145
|
+
#
|
|
146
|
+
# Arguments are rendered in the exact order they are defined in the DSL block,
|
|
147
|
+
# regardless of type (options, operands, or static flags). This is important
|
|
148
|
+
# for git commands where argument order matters, such as when using `--` to
|
|
149
|
+
# separate options from pathspecs.
|
|
150
|
+
#
|
|
151
|
+
# Use {#end_of_options} to emit `--` only when at least one following operand
|
|
152
|
+
# produces output, or {#literal} with `'--'` when `--` must always be present.
|
|
153
|
+
#
|
|
154
|
+
# @example Ordering example (end_of_options emits '--' only when path is present)
|
|
155
|
+
# args_def = Arguments.define do
|
|
156
|
+
# operand :ref
|
|
157
|
+
# end_of_options
|
|
158
|
+
# operand :path
|
|
159
|
+
# end
|
|
160
|
+
# args_def.bind('HEAD', 'file.txt').to_a # => ['HEAD', '--', 'file.txt']
|
|
161
|
+
# args_def.bind('HEAD').to_a # => ['HEAD'] # (no trailing --)
|
|
162
|
+
#
|
|
163
|
+
# ## Short Option Detection
|
|
164
|
+
#
|
|
165
|
+
# Option names are automatically formatted using POSIX conventions:
|
|
166
|
+
#
|
|
167
|
+
# - **Single-character names** use single-dash prefix: `:f` → `-f`
|
|
168
|
+
# - **Multi-character names** use double-dash prefix: `:force` → `--force`
|
|
169
|
+
#
|
|
170
|
+
# For inline values (`inline: true`), the separator also follows POSIX
|
|
171
|
+
# conventions:
|
|
172
|
+
#
|
|
173
|
+
# - **Short options** use no separator: `-n3`
|
|
174
|
+
# - **Long options** use `=` separator: `--name=value`
|
|
175
|
+
#
|
|
176
|
+
# Negated flags always use double-dash format (e.g., `-f` → `--no-f` when false).
|
|
177
|
+
#
|
|
178
|
+
# The `as:` parameter can override this automatic detection when needed.
|
|
179
|
+
#
|
|
180
|
+
# @example Short option detection
|
|
181
|
+
# args_def = Arguments.define do
|
|
182
|
+
# flag_option :f # true → '-f'
|
|
183
|
+
# flag_option :force # true → '--force'
|
|
184
|
+
# value_option :n, inline: true # 3 → '-n3'
|
|
185
|
+
# value_option :name, inline: true # 'test' → '--name=test'
|
|
186
|
+
# end
|
|
187
|
+
#
|
|
188
|
+
# args_def.bind(f: true, force: true, n: 3, name: 'test').to_a
|
|
189
|
+
# # => ['-f', '--force', '-n3', '--name=test']
|
|
190
|
+
#
|
|
191
|
+
# @example Explicit override with `as:`
|
|
192
|
+
# args_def = Arguments.define do
|
|
193
|
+
# flag_option :f, as: '--force'
|
|
194
|
+
# end
|
|
195
|
+
# args_def.bind(f: true).to_a # => ['--force']
|
|
196
|
+
#
|
|
197
|
+
# ## Option Types
|
|
198
|
+
#
|
|
199
|
+
# The DSL supports several option types with modifiers:
|
|
200
|
+
#
|
|
201
|
+
# ### Primary Option Types
|
|
202
|
+
# - {#flag_option} - Boolean flag (--flag when true, with `negatable: true` for --no-flag)
|
|
203
|
+
# - {#value_option} - Valued option (--flag value, with `inline: true` for --flag=value,
|
|
204
|
+
# or `as_operand: true` for operands)
|
|
205
|
+
# - {#flag_or_value_option} - Flag or value (--flag when true, --flag value when string,
|
|
206
|
+
# with `inline: true` and/or `negatable: true` modifiers)
|
|
207
|
+
# - {#key_value_option} - Key-value option that can be repeated (--trailer key=value)
|
|
208
|
+
# - {#literal} - Literal string always included in output
|
|
209
|
+
# - {#custom_option} - Custom option with builder block
|
|
210
|
+
# - {#execution_option} - Execution option (not included in CLI output, forwarded to command execution)
|
|
211
|
+
#
|
|
212
|
+
# {#value_option} supports a `repeatable: true` parameter that allows the option to accept
|
|
213
|
+
# an array of values. This repeats the flag for each value (or outputs each as an
|
|
214
|
+
# operand when using `as_operand: true`):
|
|
215
|
+
#
|
|
216
|
+
# Repeatable options:
|
|
217
|
+
#
|
|
218
|
+
# ```ruby
|
|
219
|
+
# value_option :config, repeatable: true
|
|
220
|
+
# # config: ['a=b', 'c=d'] => ['--config', 'a=b', '--config', 'c=d']
|
|
221
|
+
#
|
|
222
|
+
# value_option :sort, inline: true, repeatable: true
|
|
223
|
+
# # sort: ['refname', '-committerdate'] => ['--sort=refname', '--sort=-committerdate']
|
|
224
|
+
#
|
|
225
|
+
# end_of_options
|
|
226
|
+
# value_option :pathspecs, as_operand: true, repeatable: true
|
|
227
|
+
# # pathspecs: ['file1.txt', 'file2.txt'] => ['--', 'file1.txt', 'file2.txt']
|
|
228
|
+
# ```
|
|
229
|
+
#
|
|
230
|
+
# ## Common Option Parameters
|
|
231
|
+
#
|
|
232
|
+
# Most option types support parameters that affect **input validation** (checked
|
|
233
|
+
# during {#bind}):
|
|
234
|
+
#
|
|
235
|
+
# - **required:** - When true, the option key must be present in the provided
|
|
236
|
+
# opts. Raises ArgumentError if the key is missing. Defaults to false.
|
|
237
|
+
#
|
|
238
|
+
# Supported by: {#flag_option}, {#value_option}, {#flag_or_value_option},
|
|
239
|
+
# {#key_value_option}, {#custom_option}, {#operand}.
|
|
240
|
+
#
|
|
241
|
+
# - **allow_nil:** - When false (with required: true), the value cannot be nil.
|
|
242
|
+
# Raises ArgumentError if a nil value is provided. Defaults to true for
|
|
243
|
+
# options, false for operands.
|
|
244
|
+
#
|
|
245
|
+
# Supported by: same as **required:**.
|
|
246
|
+
#
|
|
247
|
+
# - **type:** - Validates the value is an instance of the specified class(es).
|
|
248
|
+
# Accepts a single class or an array of classes. Raises ArgumentError if type
|
|
249
|
+
# doesn't match. This parameter only performs type checking during validation;
|
|
250
|
+
# the conversion of values to CLI argument strings is handled separately during
|
|
251
|
+
# the build phase — see the *String Conversion* section below. Defaults to nil (no
|
|
252
|
+
# validation).
|
|
253
|
+
#
|
|
254
|
+
# Supported by: {#flag_option}, {#value_option}, {#flag_or_value_option}.
|
|
255
|
+
#
|
|
256
|
+
# Note: {#literal} and {#execution_option} do not support these validation parameters.
|
|
257
|
+
#
|
|
258
|
+
# These parameters affect **output generation** (what CLI arguments are
|
|
259
|
+
# produced):
|
|
260
|
+
#
|
|
261
|
+
# - **as:** - Override the CLI argument(s) derived from the option name
|
|
262
|
+
# Can be a String or an Array. Default is nil (derives from name).
|
|
263
|
+
#
|
|
264
|
+
# - **allow_empty:** - ({#value_option} only) When true, output the option
|
|
265
|
+
# even if the value is an empty string. Default is false (empty strings skipped).
|
|
266
|
+
#
|
|
267
|
+
# - **repeatable:** - ({#value_option}, {#flag_or_value_option}, and {#operand}
|
|
268
|
+
# only) Output an option or operand for each array element. Default is false.
|
|
269
|
+
#
|
|
270
|
+
# - **skip_cli:** - ({#operand} only) Bind, validate, and expose an operand
|
|
271
|
+
# accessor without emitting that operand in {Bound#to_a}. Default is false.
|
|
272
|
+
#
|
|
273
|
+
# @example Required option with non-nil value
|
|
274
|
+
# args_def = Arguments.define do
|
|
275
|
+
# value_option :upstream, inline: true, required: true, allow_nil: false
|
|
276
|
+
# end
|
|
277
|
+
# args_def.bind() #=> raise ArgumentError, "Required options not provided: :upstream"
|
|
278
|
+
# args_def.bind(upstream: nil) #=> raise ArgumentError, "Required options cannot be nil: :upstream"
|
|
279
|
+
# args_def.bind(upstream: 'origin').to_a # => ['--upstream=origin']
|
|
280
|
+
#
|
|
281
|
+
# @example Required option allowing nil (default)
|
|
282
|
+
# args_def = Arguments.define do
|
|
283
|
+
# value_option :branch, inline: true, required: true
|
|
284
|
+
# end
|
|
285
|
+
# args_def.bind() #=> raise ArgumentError, "Required options not provided: :branch"
|
|
286
|
+
# args_def.bind(branch: nil).to_a # => []
|
|
287
|
+
# args_def.bind(branch: 'main').to_a # => ['--branch=main']
|
|
288
|
+
#
|
|
289
|
+
# ## Operands (Positional Arguments)
|
|
290
|
+
#
|
|
291
|
+
# Operands are mapped using Ruby-like semantics:
|
|
292
|
+
#
|
|
293
|
+
# 1. Post-repeatable required operands are reserved first (from the end)
|
|
294
|
+
# 2. Pre-repeatable operands are filled with remaining values (required first, then optional)
|
|
295
|
+
# 3. Optional operands (with defaults) get values only if extras are available
|
|
296
|
+
# 4. Repeatable operand gets whatever is left in the middle
|
|
297
|
+
#
|
|
298
|
+
# This matches Ruby's parameter binding behavior, including patterns like `def
|
|
299
|
+
# foo(a = default, *rest, b)` where the required `b` is filled before optional
|
|
300
|
+
# `a`.
|
|
301
|
+
#
|
|
302
|
+
# @example Simple operand (like `git clone <repository>`)
|
|
303
|
+
# args_def = Arguments.define do
|
|
304
|
+
# literal 'clone'
|
|
305
|
+
# operand :repository, required: true
|
|
306
|
+
# end
|
|
307
|
+
# args_def.bind('https://github.com/user/repo').to_a
|
|
308
|
+
# # => ['clone', 'https://github.com/user/repo']
|
|
309
|
+
#
|
|
310
|
+
# @example Repeatable operand (like `git add <paths>...`)
|
|
311
|
+
# args_def = Arguments.define do
|
|
312
|
+
# literal 'add'
|
|
313
|
+
# operand :paths, repeatable: true
|
|
314
|
+
# end
|
|
315
|
+
# args_def.bind('file1', 'file2', 'file3').to_a
|
|
316
|
+
# # => ['add', 'file1', 'file2', 'file3']
|
|
317
|
+
#
|
|
318
|
+
# @example git mv pattern (like `git mv <sources>... <destination>`)
|
|
319
|
+
# args_def = Arguments.define do
|
|
320
|
+
# literal 'mv'
|
|
321
|
+
# operand :sources, repeatable: true, required: true
|
|
322
|
+
# operand :destination, required: true
|
|
323
|
+
# end
|
|
324
|
+
# args_def.bind('src1', 'src2', 'dest').to_a # => ['mv', 'src1', 'src2', 'dest']
|
|
325
|
+
#
|
|
326
|
+
# ## Nil Handling for Operands
|
|
327
|
+
#
|
|
328
|
+
# When nil values are allowed (see `required:` and `allow_nil:` above), they have
|
|
329
|
+
# special output behavior:
|
|
330
|
+
#
|
|
331
|
+
# - For non-repeating operands: nil values consume an operand slot during
|
|
332
|
+
# binding but are omitted from the resulting command-line arguments array
|
|
333
|
+
# - For repeatable operands: nil values within the array raise an error
|
|
334
|
+
#
|
|
335
|
+
# @example Nil value omitted from output
|
|
336
|
+
# args = Arguments.define do
|
|
337
|
+
# operand :tree_ish, required: true, allow_nil: true
|
|
338
|
+
# operand :paths, repeatable: true
|
|
339
|
+
# end.bind(nil, 'file1', 'file2')
|
|
340
|
+
# args.to_a # => ['file1', 'file2']
|
|
341
|
+
# args.tree_ish # => nil
|
|
342
|
+
# args.paths # => ['file1', 'file2']
|
|
343
|
+
#
|
|
344
|
+
# ## Option-like Operand Rejection
|
|
345
|
+
#
|
|
346
|
+
# Operands that appear **before** a `--` separator boundary in the argument
|
|
347
|
+
# definition are automatically validated to ensure their values don't start
|
|
348
|
+
# with `-`. This prevents user-supplied strings like `'-s'` from being
|
|
349
|
+
# misinterpreted as git flags when passed as positional arguments.
|
|
350
|
+
#
|
|
351
|
+
# The `--` boundary can come from:
|
|
352
|
+
# - A `literal '--'` definition
|
|
353
|
+
# - An `end_of_options` declaration
|
|
354
|
+
#
|
|
355
|
+
# Operands **after** the `--` boundary are not validated (they represent
|
|
356
|
+
# paths/filenames which may legitimately start with `-`). If no `--`
|
|
357
|
+
# boundary exists in the definition, **all** operands are validated.
|
|
358
|
+
#
|
|
359
|
+
# @example Operands before and after '--' end_of_options boundary
|
|
360
|
+
# args_def = Arguments.define do
|
|
361
|
+
# operand :commit1
|
|
362
|
+
# operand :commit2
|
|
363
|
+
# end_of_options
|
|
364
|
+
# operand :paths, repeatable: true
|
|
365
|
+
# end
|
|
366
|
+
# args_def.bind('-s') #=> raise ArgumentError, "operand :commit1 value '-s' looks like a command-line option"
|
|
367
|
+
# args_def.bind('HEAD', 'HEAD~1', '-file.txt').to_a
|
|
368
|
+
# # => ['HEAD', 'HEAD~1', '--', '-file.txt']
|
|
369
|
+
#
|
|
370
|
+
# @example All operands validated when no '--' boundary exists
|
|
371
|
+
# args_def = Arguments.define do
|
|
372
|
+
# operand :path1, required: true
|
|
373
|
+
# operand :path2, required: true
|
|
374
|
+
# end
|
|
375
|
+
# args_def.bind('-s', 'file.txt')
|
|
376
|
+
# #=> raise ArgumentError, "operand :path1 value '-s' looks like a command-line option"
|
|
377
|
+
#
|
|
378
|
+
# ## Options After Separator
|
|
379
|
+
#
|
|
380
|
+
# Options that produce CLI flags (e.g. `flag_option`, `value_option`,
|
|
381
|
+
# `key_value_option`, `custom_option`) cannot be defined after a `--`
|
|
382
|
+
# separator boundary. Git treats everything after `--` as operands, so
|
|
383
|
+
# flags emitted there would be misinterpreted.
|
|
384
|
+
#
|
|
385
|
+
# Only `value_option` with `as_operand: true` and `execution_option` are allowed
|
|
386
|
+
# after the boundary because they do not produce flag-prefixed output.
|
|
387
|
+
#
|
|
388
|
+
# For example, this will raise +ArgumentError+ during definition:
|
|
389
|
+
#
|
|
390
|
+
# Arguments.define do
|
|
391
|
+
# literal '--'
|
|
392
|
+
# flag_option :verbose
|
|
393
|
+
# end #=> raises ArgumentError
|
|
394
|
+
#
|
|
395
|
+
# @example Allowed: value_option as_operand after '--'
|
|
396
|
+
# Arguments.define do
|
|
397
|
+
# literal '--'
|
|
398
|
+
# value_option :paths, as_operand: true, repeatable: true
|
|
399
|
+
# end
|
|
400
|
+
#
|
|
401
|
+
# ## Type Validation
|
|
402
|
+
#
|
|
403
|
+
# The `type:` parameter provides declarative type validation for option values.
|
|
404
|
+
# When validation fails, an ArgumentError is raised with a descriptive message.
|
|
405
|
+
#
|
|
406
|
+
# @example Single type validation
|
|
407
|
+
# args_def = Arguments.define do
|
|
408
|
+
# value_option :date, type: String, inline: true
|
|
409
|
+
# end
|
|
410
|
+
# args_def.bind(date: "2024-01-01").to_a # => ['--date=2024-01-01']
|
|
411
|
+
# args_def.bind(date: 12345) #=> raise ArgumentError, "The :date option must be a String, but was a Integer"
|
|
412
|
+
#
|
|
413
|
+
# @example Multiple type validation (allows any of the specified types)
|
|
414
|
+
# args_def = Arguments.define do
|
|
415
|
+
# value_option :timeout, type: [Integer, Float], inline: true
|
|
416
|
+
# end
|
|
417
|
+
# args_def.bind(timeout: 30).to_a # => ['--timeout=30']
|
|
418
|
+
# args_def.bind(timeout: 30.5).to_a # => ['--timeout=30.5']
|
|
419
|
+
# args_def.bind(timeout: "30")
|
|
420
|
+
# #=> raise ArgumentError, "The :timeout option must be a Integer or Float, but was a String"
|
|
421
|
+
#
|
|
422
|
+
# ## String Conversion
|
|
423
|
+
#
|
|
424
|
+
# During the build phase, value-bearing option types (`value_option`,
|
|
425
|
+
# `flag_or_value_option`, `key_value_option`) and `operand` definitions convert
|
|
426
|
+
# their bound values to CLI argument strings by calling `#to_s`. This means any
|
|
427
|
+
# object with a meaningful `#to_s` implementation — `Integer`, `Float`,
|
|
428
|
+
# `Pathname`, etc. — can be passed as a value without the DSL needing to know
|
|
429
|
+
# about the type.
|
|
430
|
+
#
|
|
431
|
+
# Note: `flag_option` values control *presence or absence* of a flag and are not
|
|
432
|
+
# stringified. `custom_option` builders receive the raw value and are responsible
|
|
433
|
+
# for producing CLI strings themselves.
|
|
434
|
+
#
|
|
435
|
+
# The `type:` parameter does not affect this conversion; it only validates the
|
|
436
|
+
# Ruby class of the value *before* stringification.
|
|
437
|
+
#
|
|
438
|
+
# @example Numeric values are stringified automatically
|
|
439
|
+
# args_def = Arguments.define do
|
|
440
|
+
# value_option :depth, inline: true
|
|
441
|
+
# value_option :jobs, inline: true
|
|
442
|
+
# end
|
|
443
|
+
# args_def.bind(depth: 5, jobs: 4).to_a # => ['--depth=5', '--jobs=4']
|
|
444
|
+
#
|
|
445
|
+
# @example Pathname is also accepted (no type: needed)
|
|
446
|
+
# args_def = Arguments.define do
|
|
447
|
+
# operand :path, required: true
|
|
448
|
+
# end
|
|
449
|
+
# args_def.bind(Pathname.new('/tmp/foo')).to_a # => ['/tmp/foo']
|
|
450
|
+
#
|
|
451
|
+
# ## Conflict Detection
|
|
452
|
+
#
|
|
453
|
+
# Use {#conflicts} to declare mutually exclusive arguments. Names may refer to
|
|
454
|
+
# **options** (flag, value, flag-or-value, etc.) or **operands** (positional
|
|
455
|
+
# arguments) interchangeably. When {#bind} is called, if more than one argument
|
|
456
|
+
# in a conflict group is "present", an ArgumentError is raised.
|
|
457
|
+
#
|
|
458
|
+
# An argument is considered **present** when its value is not `nil`, `false`,
|
|
459
|
+
# `[]`, or `''`.
|
|
460
|
+
#
|
|
461
|
+
# @example Option vs option conflict
|
|
462
|
+
# args_def = Arguments.define do
|
|
463
|
+
# flag_option :force
|
|
464
|
+
# flag_option :force_force
|
|
465
|
+
# conflicts :force, :force_force
|
|
466
|
+
# end
|
|
467
|
+
# args_def.bind(force: true, force_force: true) #=> raise ArgumentError, "cannot specify :force and :force_force"
|
|
468
|
+
#
|
|
469
|
+
# @example Mixed option and operand conflict
|
|
470
|
+
# args_def = Arguments.define do
|
|
471
|
+
# flag_option %i[merge m], as: '--merge'
|
|
472
|
+
# operand :tree_ish, required: true, allow_nil: true
|
|
473
|
+
# conflicts :merge, :tree_ish
|
|
474
|
+
# end
|
|
475
|
+
# args_def.bind('main', merge: true) #=> raise ArgumentError, "cannot specify :merge and :tree_ish"
|
|
476
|
+
# args_def.bind(nil, merge: true).to_a # => ['--merge']
|
|
477
|
+
#
|
|
478
|
+
# ## Forbidden Value Combinations
|
|
479
|
+
#
|
|
480
|
+
# {#conflicts} is presence-based — it cannot distinguish between semantically
|
|
481
|
+
# equivalent and contradictory combinations of negatable flags. Use
|
|
482
|
+
# {#forbid_values} to declare specific **exact-value tuples** that are invalid.
|
|
483
|
+
#
|
|
484
|
+
# A `forbid_values` declaration matches only when **every** listed name has a
|
|
485
|
+
# bound value equal to the declared value (Ruby `==`). Only matching tuples raise
|
|
486
|
+
# ArgumentError; all other value combinations are permitted. Names may be options
|
|
487
|
+
# or operands; aliases are canonicalized before comparison.
|
|
488
|
+
#
|
|
489
|
+
# This is most useful for negatable flags where some value-pairings are
|
|
490
|
+
# contradictory but others are semantically equivalent and should remain valid.
|
|
491
|
+
#
|
|
492
|
+
# The error message has the form:
|
|
493
|
+
#
|
|
494
|
+
# "cannot specify :name1=value1 with :name2=value2"
|
|
495
|
+
#
|
|
496
|
+
# @example Reject contradictory pairs without blocking equivalent ones
|
|
497
|
+
# args_def = Arguments.define do
|
|
498
|
+
# flag_option :all, negatable: true
|
|
499
|
+
# flag_option :ignore_removal, negatable: true
|
|
500
|
+
# forbid_values all: true, ignore_removal: true # --all --ignore-removal: contradictory
|
|
501
|
+
# forbid_values no_all: true, no_ignore_removal: true # --no-all --no-ignore-removal: contradictory
|
|
502
|
+
# end
|
|
503
|
+
# args_def.bind(all: true, ignore_removal: true)
|
|
504
|
+
# #=> raise ArgumentError, 'cannot specify :all=true with :ignore_removal=true'
|
|
505
|
+
# args_def.bind(all: true, no_ignore_removal: true).to_a # => ['--all', '--no-ignore-removal']
|
|
506
|
+
# args_def.bind(no_all: true, ignore_removal: true).to_a # => ['--no-all', '--ignore-removal']
|
|
507
|
+
#
|
|
508
|
+
# ## At-Least-One Presence Validation
|
|
509
|
+
#
|
|
510
|
+
# Use {#requires_one_of} to declare groups of arguments where at least one must be
|
|
511
|
+
# present. Names may refer to **options** (flag, value, flag-or-value, etc.) or
|
|
512
|
+
# **operands** (positional arguments) interchangeably. When {#bind} is called, if
|
|
513
|
+
# none of the arguments in a group is present, an ArgumentError is raised.
|
|
514
|
+
#
|
|
515
|
+
# @example Requiring at least one path source (options only)
|
|
516
|
+
# args_def = Arguments.define do
|
|
517
|
+
# value_option :pathspec_from_file, inline: true
|
|
518
|
+
# end_of_options
|
|
519
|
+
# value_option :pathspec, as_operand: true, repeatable: true
|
|
520
|
+
# requires_one_of :pathspec, :pathspec_from_file
|
|
521
|
+
# end
|
|
522
|
+
# args_def.bind
|
|
523
|
+
# #=> raise ArgumentError, 'at least one of :pathspec, :pathspec_from_file must be provided'
|
|
524
|
+
# args_def.bind(pathspec: ['file.txt']).to_a # => ['--', 'file.txt']
|
|
525
|
+
#
|
|
526
|
+
# @example Mixed option and operand group
|
|
527
|
+
# args_def = Arguments.define do
|
|
528
|
+
# flag_option :all
|
|
529
|
+
# operand :paths, repeatable: true
|
|
530
|
+
# requires_one_of :all, :paths
|
|
531
|
+
# end
|
|
532
|
+
# args_def.bind
|
|
533
|
+
# #=> raise ArgumentError, 'at least one of :all, :paths must be provided'
|
|
534
|
+
# args_def.bind('file.txt').to_a # => ['file.txt']
|
|
535
|
+
#
|
|
536
|
+
# ## Conditional Argument Requirements
|
|
537
|
+
#
|
|
538
|
+
# Use {#requires} and the `when:` form of {#requires_one_of} to declare that an
|
|
539
|
+
# argument (or at least one of a group) must be present **only when** a specific
|
|
540
|
+
# trigger argument is present. These constraints are evaluated during {#bind}: if
|
|
541
|
+
# the trigger is absent the check is skipped entirely.
|
|
542
|
+
#
|
|
543
|
+
# An ArgumentError is raised at definition time if either the required name(s) or
|
|
544
|
+
# the trigger name are not known arguments, catching typos early.
|
|
545
|
+
#
|
|
546
|
+
# @example Single conditional requirement
|
|
547
|
+
# args_def = Arguments.define do
|
|
548
|
+
# flag_option :pathspec_file_nul
|
|
549
|
+
# value_option :pathspec_from_file, inline: true
|
|
550
|
+
# requires :pathspec_from_file, when: :pathspec_file_nul
|
|
551
|
+
# end
|
|
552
|
+
# args_def.bind(pathspec_file_nul: true, pathspec_from_file: 'paths.txt').to_a
|
|
553
|
+
# # => ['--pathspec-file-nul', '--pathspec-from-file=paths.txt']
|
|
554
|
+
# args_def.bind(pathspec_file_nul: true)
|
|
555
|
+
# #=> raise ArgumentError, ':pathspec_file_nul requires :pathspec_from_file'
|
|
556
|
+
# args_def.bind # trigger absent — no error
|
|
557
|
+
#
|
|
558
|
+
# @example Conditional at-least-one-of group
|
|
559
|
+
# args_def = Arguments.define do
|
|
560
|
+
# flag_option :annotate
|
|
561
|
+
# value_option :message, inline: true
|
|
562
|
+
# value_option :file, inline: true
|
|
563
|
+
# requires_one_of :message, :file, when: :annotate
|
|
564
|
+
# end
|
|
565
|
+
# args_def.bind(annotate: true, message: 'v1.0').to_a # => ['--annotate', '--message=v1.0']
|
|
566
|
+
# args_def.bind(annotate: true)
|
|
567
|
+
# #=> raise ArgumentError, ':annotate requires at least one of :message, :file'
|
|
568
|
+
# args_def.bind # trigger absent — no error
|
|
569
|
+
#
|
|
570
|
+
# ## Value Constraints
|
|
571
|
+
#
|
|
572
|
+
# In addition to presence-based validation ({#conflicts}, {#requires_one_of},
|
|
573
|
+
# and {#requires}) and value-combination constraints ({#forbid_values}), you can
|
|
574
|
+
# restrict the *set of acceptable values* for any value-type option using
|
|
575
|
+
# {#allowed_values}. If a bound value falls outside the configured set, {#bind}
|
|
576
|
+
# raises ArgumentError with a descriptive message.
|
|
577
|
+
#
|
|
578
|
+
# This is typically used to model git options that accept only a fixed list of
|
|
579
|
+
# modes or strategies.
|
|
580
|
+
#
|
|
581
|
+
# @example Restricting option values
|
|
582
|
+
# args_def = Arguments.define do
|
|
583
|
+
# value_option :strategy, inline: true
|
|
584
|
+
# allowed_values :strategy, in: %w[ours theirs]
|
|
585
|
+
# end
|
|
586
|
+
# args_def.bind(strategy: 'ours').to_a # => ['--strategy=ours']
|
|
587
|
+
# args_def.bind(strategy: 'theirs').to_a # => ['--strategy=theirs']
|
|
588
|
+
# args_def.bind(strategy: 'rebase')
|
|
589
|
+
# # => raise ArgumentError, 'Invalid value for :strategy: expected one of ["ours", "theirs"], got "rebase"'
|
|
590
|
+
#
|
|
591
|
+
# @api private
|
|
592
|
+
#
|
|
593
|
+
class Arguments
|
|
594
|
+
# Define a new Arguments instance using the DSL
|
|
595
|
+
#
|
|
596
|
+
# @yield [] block evaluated in the context of the new Arguments instance via
|
|
597
|
+
# +instance_eval+, so DSL methods ({#flag_option}, {#operand}, etc.) are called
|
|
598
|
+
# directly without an explicit receiver
|
|
599
|
+
#
|
|
600
|
+
# @return [Arguments] The configured Arguments instance
|
|
601
|
+
#
|
|
602
|
+
# @example Basic flag
|
|
603
|
+
# args_def = Arguments.define do
|
|
604
|
+
# flag_option :verbose
|
|
605
|
+
# end
|
|
606
|
+
# args_def.bind(verbose: true).to_a # => ['--verbose']
|
|
607
|
+
#
|
|
608
|
+
def self.define(&block)
|
|
609
|
+
args = new
|
|
610
|
+
args.instance_eval(&block) if block
|
|
611
|
+
args
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def initialize
|
|
615
|
+
@option_definitions = {}
|
|
616
|
+
@alias_map = {} # Maps alias keys to primary keys
|
|
617
|
+
@operand_definitions = []
|
|
618
|
+
@conflicts = [] # Array of conflicting option pairs/groups
|
|
619
|
+
@forbidden_values = [] # Array of forbidden exact-value tuples
|
|
620
|
+
@requires_one_of = [] # Array of "at least one must be present" groups
|
|
621
|
+
@ordered_definitions = [] # Tracks all definitions in definition order
|
|
622
|
+
@past_separator = false # Tracks whether a '--' boundary has been defined
|
|
623
|
+
@end_of_options_declared = false # Guards against duplicate end_of_options calls
|
|
624
|
+
@negatable_companions = Set.new # Synthesized :no_<name> companion entries
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Define a boolean flag option (--flag when true)
|
|
628
|
+
#
|
|
629
|
+
# @param names [Symbol, Array<Symbol>] the option name(s), first is primary
|
|
630
|
+
#
|
|
631
|
+
# @param as [String, Array<String>, nil] custom argument(s) to output (e.g., '-r' or ['--amend', '--no-edit'])
|
|
632
|
+
#
|
|
633
|
+
# @param negatable [Boolean] when true, registers a companion `no_<name>` key that emits
|
|
634
|
+
# `--no-<flag>` when set to `true`. Both keys use standard boolean semantics: `true`
|
|
635
|
+
# emits the flag, `false` or absent emits nothing. A conflict is automatically registered
|
|
636
|
+
# between the two keys so that `name: true, no_name: true` raises at bind time.
|
|
637
|
+
# The primary key must be snake_case (e.g. `:verify`, `:three_way`). When `as:` is
|
|
638
|
+
# given, it must be a long-form (`--flag`) String; Arrays and short-form flags (e.g.
|
|
639
|
+
# `-S`) are not compatible with `negatable: true` because the synthesized companion is
|
|
640
|
+
# always `--no-<flag>`.
|
|
641
|
+
#
|
|
642
|
+
# @param required [Boolean] whether the option must be provided (the key must be present
|
|
643
|
+
# in opts). When combined with +negatable: true+, a `requires_one_of [name, no_name]`
|
|
644
|
+
# group is automatically registered so that either the primary or companion key satisfies
|
|
645
|
+
# the requirement (e.g. `bind(no_verify: true)` satisfies `required: true` for `:verify`).
|
|
646
|
+
# Note that under the companion-key model, `bind(verify: false)` does **not** satisfy
|
|
647
|
+
# the requirement because `false` is treated as absent.
|
|
648
|
+
#
|
|
649
|
+
# @param allow_nil [Boolean] whether nil is allowed when required is true. Defaults to true.
|
|
650
|
+
# When false with required: true, raises ArgumentError if value is nil.
|
|
651
|
+
# Cannot be combined with +negatable: true+ and +required: true+ — raises ArgumentError
|
|
652
|
+
# at definition time (nil is already caught by the auto +requires_one_of+ group).
|
|
653
|
+
#
|
|
654
|
+
# @param max_times [Integer, nil] maximum number of times the flag may be repeated (default: nil).
|
|
655
|
+
# When set, the caller may pass a positive Integer up to this limit to emit the flag
|
|
656
|
+
# multiple times (e.g. `force: 2` emits `--force --force`). Must be an Integer >= 2;
|
|
657
|
+
# 0 and 1 raise ArgumentError at definition time. When nil (the default), only boolean
|
|
658
|
+
# values are accepted.
|
|
659
|
+
#
|
|
660
|
+
# @return [void]
|
|
661
|
+
#
|
|
662
|
+
# @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
|
|
663
|
+
#
|
|
664
|
+
# @raise [ArgumentError] if max_times is not nil and not an Integer >= 2
|
|
665
|
+
#
|
|
666
|
+
# @raise [ArgumentError] if negatable: true and the primary key is not snake_case
|
|
667
|
+
#
|
|
668
|
+
# @raise [ArgumentError] if negatable: true and the generated `no_<name>` key collides
|
|
669
|
+
# with an already-registered key
|
|
670
|
+
#
|
|
671
|
+
# @raise [ArgumentError] if negatable: true and as: is an Array
|
|
672
|
+
#
|
|
673
|
+
# @raise [ArgumentError] if negatable: true and as: is not a long-form (`--flag`) String
|
|
674
|
+
#
|
|
675
|
+
# @raise [ArgumentError] if negatable: true and required: true and allow_nil: false
|
|
676
|
+
#
|
|
677
|
+
# @example Basic flag
|
|
678
|
+
# args_def = Arguments.define do
|
|
679
|
+
# flag_option :force
|
|
680
|
+
# end
|
|
681
|
+
# args_def.bind(force: true).to_a # => ['--force']
|
|
682
|
+
# args_def.bind(force: false).to_a # => []
|
|
683
|
+
#
|
|
684
|
+
# @example Negatable flag (companion-key model)
|
|
685
|
+
# args_def = Arguments.define do
|
|
686
|
+
# flag_option :full, negatable: true
|
|
687
|
+
# end
|
|
688
|
+
# args_def.bind(full: true).to_a # => ['--full']
|
|
689
|
+
# args_def.bind(no_full: true).to_a # => ['--no-full']
|
|
690
|
+
# args_def.bind(full: false).to_a # => []
|
|
691
|
+
#
|
|
692
|
+
# @example Negatable flag with required: true (either companion key satisfies the requirement)
|
|
693
|
+
# args_def = Arguments.define do
|
|
694
|
+
# flag_option :verify, negatable: true, required: true
|
|
695
|
+
# end
|
|
696
|
+
# args_def.bind(verify: true).to_a # => ['--verify']
|
|
697
|
+
# args_def.bind(no_verify: true).to_a # => ['--no-verify']
|
|
698
|
+
# args_def.bind(verify: false)
|
|
699
|
+
# #=> raise ArgumentError, "at least one of :verify, :no_verify must be provided"
|
|
700
|
+
# args_def.bind
|
|
701
|
+
# #=> raise ArgumentError, "at least one of :verify, :no_verify must be provided"
|
|
702
|
+
#
|
|
703
|
+
# @example Repeatable flag with max_times
|
|
704
|
+
# args_def = Arguments.define do
|
|
705
|
+
# flag_option :force, max_times: 2
|
|
706
|
+
# end
|
|
707
|
+
# args_def.bind(force: true).to_a # => ['--force']
|
|
708
|
+
# args_def.bind(force: 1).to_a # => ['--force']
|
|
709
|
+
# args_def.bind(force: 2).to_a # => ['--force', '--force']
|
|
710
|
+
#
|
|
711
|
+
# @example Negatable flag with max_times
|
|
712
|
+
# args_def = Arguments.define do
|
|
713
|
+
# flag_option :force, negatable: true, max_times: 2
|
|
714
|
+
# end
|
|
715
|
+
# args_def.bind(no_force: true).to_a # => ['--no-force']
|
|
716
|
+
# args_def.bind(force: 2).to_a # => ['--force', '--force']
|
|
717
|
+
#
|
|
718
|
+
# @example With required and allow_nil: false
|
|
719
|
+
# args_def = Arguments.define do
|
|
720
|
+
# flag_option :force, required: true, allow_nil: false
|
|
721
|
+
# end
|
|
722
|
+
# args_def.bind() #=> raise ArgumentError, "Required options not provided: :force"
|
|
723
|
+
# args_def.bind(force: nil) #=> raise ArgumentError, "Required options cannot be nil: :force"
|
|
724
|
+
#
|
|
725
|
+
def flag_option(names, as: nil, negatable: false, required: false, allow_nil: true, max_times: nil)
|
|
726
|
+
primary = Array(names).first
|
|
727
|
+
validate_max_times!(primary, max_times)
|
|
728
|
+
|
|
729
|
+
if negatable
|
|
730
|
+
register_negatable_flag_pair(names, as: as, required: required,
|
|
731
|
+
allow_nil: allow_nil, max_times: max_times)
|
|
732
|
+
else
|
|
733
|
+
register_option(names, type: :flag, as: as, expected_type: nil, validator: nil,
|
|
734
|
+
required: required, allow_nil: allow_nil, max_times: max_times)
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Define a valued option (--flag value as separate arguments)
|
|
739
|
+
#
|
|
740
|
+
# This option type supports three output modes controlled by `inline:` and `as_operand:`:
|
|
741
|
+
#
|
|
742
|
+
# - **Default**: `--flag value` (flag and value as separate arguments)
|
|
743
|
+
# - **Inline**: `--flag=value` (single argument with `inline: true`)
|
|
744
|
+
# - **Operand**: `value` (no flag, just the value with `as_operand: true`)
|
|
745
|
+
#
|
|
746
|
+
# @param names [Symbol, Array<Symbol>] the option name(s), first is primary
|
|
747
|
+
#
|
|
748
|
+
# @param as [String, nil] custom option string (arrays not supported for value types)
|
|
749
|
+
#
|
|
750
|
+
# @param type [Class, Array<Class>, nil] expected type(s) for validation. Raises ArgumentError with
|
|
751
|
+
# descriptive message if value doesn't match.
|
|
752
|
+
#
|
|
753
|
+
# @param inline [Boolean] when true, outputs --flag=value as single argument instead of
|
|
754
|
+
# --flag value as separate arguments (default: false). Cannot be combined with as_operand:.
|
|
755
|
+
#
|
|
756
|
+
# @param as_operand [Boolean] when true, outputs value as operand without flag
|
|
757
|
+
# (default: false). Cannot be combined with inline:.
|
|
758
|
+
#
|
|
759
|
+
# @param allow_empty [Boolean] whether to include the option even when value is an empty string.
|
|
760
|
+
# When false (default), empty strings are skipped entirely. When true, the option and empty
|
|
761
|
+
# value are included in the output.
|
|
762
|
+
#
|
|
763
|
+
# @param repeatable [Boolean] whether to allow multiple values. When true, accepts an array
|
|
764
|
+
# of values and repeats the option for each value. A single value or nil is also accepted.
|
|
765
|
+
# Behavior varies by output mode (see examples below).
|
|
766
|
+
#
|
|
767
|
+
# @param required [Boolean] when true, the option key must be present in the provided options hash.
|
|
768
|
+
# Raises ArgumentError if the key is missing. Defaults to false.
|
|
769
|
+
#
|
|
770
|
+
# @param allow_nil [Boolean] when false (with required: true), the value cannot be nil.
|
|
771
|
+
# Raises ArgumentError if a nil value is provided. Defaults to true.
|
|
772
|
+
#
|
|
773
|
+
# @return [void]
|
|
774
|
+
#
|
|
775
|
+
# @raise [ArgumentError] if inline: and as_operand: are both true
|
|
776
|
+
#
|
|
777
|
+
# @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
|
|
778
|
+
# (unless as_operand: true)
|
|
779
|
+
#
|
|
780
|
+
# @example Basic value (default mode)
|
|
781
|
+
# args_def = Arguments.define do
|
|
782
|
+
# value_option :branch
|
|
783
|
+
# end
|
|
784
|
+
# args_def.bind(branch: 'main').to_a # => ['--branch', 'main']
|
|
785
|
+
#
|
|
786
|
+
# @example Inline value
|
|
787
|
+
# args_def = Arguments.define do
|
|
788
|
+
# value_option :format, inline: true
|
|
789
|
+
# end
|
|
790
|
+
# args_def.bind(format: 'short').to_a # => ['--format=short']
|
|
791
|
+
#
|
|
792
|
+
# @example Operand value (no flag output)
|
|
793
|
+
# args_def = Arguments.define do
|
|
794
|
+
# value_option :ref, as_operand: true
|
|
795
|
+
# end
|
|
796
|
+
# args_def.bind(ref: 'HEAD').to_a # => ['HEAD']
|
|
797
|
+
#
|
|
798
|
+
# @example Operand with end_of_options boundary
|
|
799
|
+
# args_def = Arguments.define do
|
|
800
|
+
# end_of_options
|
|
801
|
+
# value_option :paths, as_operand: true
|
|
802
|
+
# end
|
|
803
|
+
# args_def.bind(paths: 'file.txt').to_a # => ['--', 'file.txt']
|
|
804
|
+
#
|
|
805
|
+
# @example Multi-valued (default mode) - repeats option for each value
|
|
806
|
+
# args_def = Arguments.define do
|
|
807
|
+
# value_option :config, repeatable: true
|
|
808
|
+
# end
|
|
809
|
+
# args_def.bind(config: 'a=b').to_a # => ['--config', 'a=b']
|
|
810
|
+
# args_def.bind(config: ['a=b', 'c=d']).to_a # => ['--config', 'a=b', '--config', 'c=d']
|
|
811
|
+
# args_def.bind(config: nil).to_a # => []
|
|
812
|
+
#
|
|
813
|
+
# @example Multi-valued with inline - repeats inline option for each value
|
|
814
|
+
# args_def = Arguments.define do
|
|
815
|
+
# value_option :sort, inline: true, repeatable: true
|
|
816
|
+
# end
|
|
817
|
+
# args_def.bind(sort: ['refname', '-committerdate']).to_a
|
|
818
|
+
# # => ['--sort=refname', '--sort=-committerdate']
|
|
819
|
+
#
|
|
820
|
+
# @example Multi-valued with operand - outputs values without flags
|
|
821
|
+
# args_def = Arguments.define do
|
|
822
|
+
# end_of_options
|
|
823
|
+
# value_option :pathspecs, as_operand: true, repeatable: true
|
|
824
|
+
# end
|
|
825
|
+
# args_def.bind(pathspecs: ['file1.txt', 'file2.txt']).to_a
|
|
826
|
+
# # => ['--', 'file1.txt', 'file2.txt']
|
|
827
|
+
#
|
|
828
|
+
# @example With type validation
|
|
829
|
+
# args_def = Arguments.define do
|
|
830
|
+
# value_option :branch, type: String
|
|
831
|
+
# end
|
|
832
|
+
# args_def.bind(branch: 'main').to_a # => ['--branch', 'main']
|
|
833
|
+
#
|
|
834
|
+
# @example With allow_empty
|
|
835
|
+
# args_def = Arguments.define do
|
|
836
|
+
# value_option :message, allow_empty: true
|
|
837
|
+
# end
|
|
838
|
+
# args_def.bind(message: "").to_a # => ['--message', '']
|
|
839
|
+
# args_def.bind(message: "text").to_a # => ['--message', 'text']
|
|
840
|
+
#
|
|
841
|
+
# args_def2 = Arguments.define do
|
|
842
|
+
# value_option :message # allow_empty defaults to false
|
|
843
|
+
# end
|
|
844
|
+
# args_def2.bind(message: "").to_a # => []
|
|
845
|
+
# args_def2.bind(message: "text").to_a # => ['--message', 'text']
|
|
846
|
+
#
|
|
847
|
+
# @example With required
|
|
848
|
+
# args_def = Arguments.define do
|
|
849
|
+
# value_option :message, required: true
|
|
850
|
+
# end
|
|
851
|
+
# args_def.bind(message: 'text').to_a # => ['--message', 'text']
|
|
852
|
+
# args_def.bind(message: nil).to_a # => []
|
|
853
|
+
# args_def.bind() #=> raise ArgumentError, "Required options not provided: :message"
|
|
854
|
+
#
|
|
855
|
+
# @example With required and allow_nil: false
|
|
856
|
+
# args_def = Arguments.define do
|
|
857
|
+
# value_option :message, required: true, allow_nil: false
|
|
858
|
+
# end
|
|
859
|
+
# args_def.bind(message: 'text').to_a # => ['--message', 'text']
|
|
860
|
+
# args_def.bind(message: nil) #=> raise ArgumentError, "Required options cannot be nil: :message"
|
|
861
|
+
# args_def.bind() #=> raise ArgumentError, "Required options not provided: :message"
|
|
862
|
+
#
|
|
863
|
+
def value_option(names, as: nil, type: nil, inline: false, as_operand: false,
|
|
864
|
+
allow_empty: false, repeatable: false, required: false, allow_nil: true)
|
|
865
|
+
validate_value_modifiers!(names, inline, as_operand)
|
|
866
|
+
|
|
867
|
+
option_type = determine_value_option_type(inline, as_operand)
|
|
868
|
+
register_option(names, type: option_type, as: as, expected_type: type,
|
|
869
|
+
allow_empty: allow_empty, repeatable: repeatable, required: required,
|
|
870
|
+
allow_nil: allow_nil)
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# Define a flag or value option
|
|
874
|
+
#
|
|
875
|
+
# This is a flexible option type that outputs:
|
|
876
|
+
# - Just the flag (--flag) when value is true
|
|
877
|
+
# - Nothing when value is false
|
|
878
|
+
# - Flag with value when value is any non-boolean, non-nil object (stringified via #to_s;
|
|
879
|
+
# e.g., --flag value or --flag=value if inline: true)
|
|
880
|
+
# - Nothing when value is nil
|
|
881
|
+
#
|
|
882
|
+
# @param names [Symbol, Array<Symbol>] the option name(s), first is primary
|
|
883
|
+
#
|
|
884
|
+
# @param as [String, nil] custom option string
|
|
885
|
+
#
|
|
886
|
+
# @param type [Class, Array<Class>, nil] expected type(s) for validation
|
|
887
|
+
#
|
|
888
|
+
# @param negatable [Boolean] when true, registers a companion `no_<name>` key that emits
|
|
889
|
+
# `--no-<flag>` when set to `true`. The positive key retains flag-or-value semantics;
|
|
890
|
+
# the negative key is boolean-only (accepts only `true`/`false`/`nil`). A conflict is
|
|
891
|
+
# automatically registered so that `name: true, no_name: true` raises at bind time.
|
|
892
|
+
# The primary key must be snake_case. When `as:` is given, it must be a long-form
|
|
893
|
+
# (`--flag`) String; Arrays and short-form flags (e.g. `-S`) are not compatible with
|
|
894
|
+
# `negatable: true` because the synthesized companion is always `--no-<flag>`.
|
|
895
|
+
#
|
|
896
|
+
# @param inline [Boolean] when true, outputs --flag=value instead of --flag value (default: false)
|
|
897
|
+
#
|
|
898
|
+
# @param repeatable [Boolean] when true, accepts an Array of values and repeats the option
|
|
899
|
+
# for each element. Each element must be +true+, +false+, or a non-nil object (which is
|
|
900
|
+
# stringified via +#to_s+); nil elements raise ArgumentError at bind time.
|
|
901
|
+
# A single (non-Array) value is also accepted. Default false.
|
|
902
|
+
#
|
|
903
|
+
# @param required [Boolean] whether the option must be provided (the key must be present
|
|
904
|
+
# in opts). When combined with +negatable: true+, a `requires_one_of [name, no_name]`
|
|
905
|
+
# group is automatically registered so that either side satisfies the requirement. Note
|
|
906
|
+
# that `bind(name: false)` does **not** satisfy the requirement because `false` is
|
|
907
|
+
# treated as absent under the companion-key model.
|
|
908
|
+
#
|
|
909
|
+
# @param allow_nil [Boolean] whether nil is allowed when required is true. Defaults to true.
|
|
910
|
+
# Cannot be combined with +negatable: true+ and +required: true+ — raises ArgumentError
|
|
911
|
+
# at definition time (nil is already caught by the auto +requires_one_of+ group).
|
|
912
|
+
#
|
|
913
|
+
# @return [void]
|
|
914
|
+
#
|
|
915
|
+
# @raise [ArgumentError] at bind time if +repeatable: true+ is used and any
|
|
916
|
+
# Array element is nil
|
|
917
|
+
#
|
|
918
|
+
# @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
|
|
919
|
+
#
|
|
920
|
+
# @raise [ArgumentError] if negatable: true and the primary key is not snake_case
|
|
921
|
+
#
|
|
922
|
+
# @raise [ArgumentError] if negatable: true and the generated `no_<name>` key collides
|
|
923
|
+
# with an already-registered key
|
|
924
|
+
#
|
|
925
|
+
# @raise [ArgumentError] if negatable: true and as: is an Array
|
|
926
|
+
#
|
|
927
|
+
# @raise [ArgumentError] if negatable: true and as: is not a long-form (`--flag`) String
|
|
928
|
+
#
|
|
929
|
+
# @raise [ArgumentError] if negatable: true and required: true and allow_nil: false
|
|
930
|
+
#
|
|
931
|
+
# @example Basic flag or value (new capability - not possible with old DSL)
|
|
932
|
+
# args_def = Arguments.define do
|
|
933
|
+
# flag_or_value_option :contains
|
|
934
|
+
# end
|
|
935
|
+
# args_def.bind(contains: true).to_a # => ['--contains']
|
|
936
|
+
# args_def.bind(contains: false).to_a # => []
|
|
937
|
+
# args_def.bind(contains: "abc123").to_a # => ['--contains', 'abc123']
|
|
938
|
+
# args_def.bind(contains: nil).to_a # => []
|
|
939
|
+
#
|
|
940
|
+
# @example With inline: true
|
|
941
|
+
# args_def = Arguments.define do
|
|
942
|
+
# flag_or_value_option :gpg_sign, inline: true
|
|
943
|
+
# end
|
|
944
|
+
# args_def.bind(gpg_sign: true).to_a # => ['--gpg-sign']
|
|
945
|
+
# args_def.bind(gpg_sign: false).to_a # => []
|
|
946
|
+
# args_def.bind(gpg_sign: "KEY").to_a # => ['--gpg-sign=KEY']
|
|
947
|
+
# args_def.bind(gpg_sign: nil).to_a # => []
|
|
948
|
+
#
|
|
949
|
+
# @example With negatable: true (companion-key model)
|
|
950
|
+
# args_def = Arguments.define do
|
|
951
|
+
# flag_or_value_option :verify, negatable: true
|
|
952
|
+
# end
|
|
953
|
+
# args_def.bind(verify: true).to_a # => ['--verify']
|
|
954
|
+
# args_def.bind(verify: false).to_a # => []
|
|
955
|
+
# args_def.bind(no_verify: true).to_a # => ['--no-verify']
|
|
956
|
+
# args_def.bind(verify: "KEYID").to_a # => ['--verify', 'KEYID']
|
|
957
|
+
# args_def.bind(verify: nil).to_a # => []
|
|
958
|
+
#
|
|
959
|
+
# @example With negatable: true and inline: true
|
|
960
|
+
# args_def = Arguments.define do
|
|
961
|
+
# flag_or_value_option :sign, negatable: true, inline: true
|
|
962
|
+
# end
|
|
963
|
+
# args_def.bind(sign: true).to_a # => ['--sign']
|
|
964
|
+
# args_def.bind(sign: false).to_a # => []
|
|
965
|
+
# args_def.bind(no_sign: true).to_a # => ['--no-sign']
|
|
966
|
+
# args_def.bind(sign: "KEY").to_a # => ['--sign=KEY']
|
|
967
|
+
# args_def.bind(sign: nil).to_a # => []
|
|
968
|
+
#
|
|
969
|
+
# @example With inline: true and repeatable: true
|
|
970
|
+
# args_def = Arguments.define do
|
|
971
|
+
# flag_or_value_option :recurse_submodules, inline: true, repeatable: true
|
|
972
|
+
# end
|
|
973
|
+
# args_def.bind(recurse_submodules: true).to_a # => ['--recurse-submodules']
|
|
974
|
+
# args_def.bind(recurse_submodules: 'lib/').to_a # => ['--recurse-submodules=lib/']
|
|
975
|
+
# args_def.bind(recurse_submodules: ['lib/', 'ext/']).to_a
|
|
976
|
+
# # => ['--recurse-submodules=lib/', '--recurse-submodules=ext/']
|
|
977
|
+
# args_def.bind(recurse_submodules: [nil])
|
|
978
|
+
# # => raise_error ArgumentError, /Invalid value for flag_or_inline_value/
|
|
979
|
+
#
|
|
980
|
+
def flag_or_value_option(names, as: nil, type: nil, negatable: false, inline: false,
|
|
981
|
+
repeatable: false, required: false, allow_nil: true)
|
|
982
|
+
if negatable
|
|
983
|
+
register_negatable_flag_or_value_pair(names, as: as, type: type, inline: inline,
|
|
984
|
+
repeatable: repeatable, required: required,
|
|
985
|
+
allow_nil: allow_nil)
|
|
986
|
+
else
|
|
987
|
+
option_type = inline ? :flag_or_inline_value : :flag_or_value
|
|
988
|
+
register_option(names, type: option_type, as: as, expected_type: type,
|
|
989
|
+
repeatable: repeatable, required: required, allow_nil: allow_nil)
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# Define a key-value option that can be specified multiple times
|
|
994
|
+
#
|
|
995
|
+
# This is useful for git options like --trailer that take key=value pairs
|
|
996
|
+
# and can be repeated. Accepts Hash or Array of arrays for flexible input.
|
|
997
|
+
#
|
|
998
|
+
# @param names [Symbol, Array<Symbol>] the option name(s), first is primary
|
|
999
|
+
#
|
|
1000
|
+
# @param as [String, nil] custom option string (e.g., '--trailer')
|
|
1001
|
+
#
|
|
1002
|
+
# @param key_separator [String] separator between key and value (default: '=')
|
|
1003
|
+
#
|
|
1004
|
+
# @param inline [Boolean] when true, outputs --flag=key=value instead of --flag key=value
|
|
1005
|
+
#
|
|
1006
|
+
# @param required [Boolean] whether the option must be provided (key must exist in opts).
|
|
1007
|
+
# Note: empty hash/array is considered "present" and produces no output without error.
|
|
1008
|
+
#
|
|
1009
|
+
# @param allow_nil [Boolean] whether nil is allowed when required is true
|
|
1010
|
+
#
|
|
1011
|
+
# @return [void]
|
|
1012
|
+
#
|
|
1013
|
+
# @raise [ArgumentError] at bind time if array input is not a [key, value] pair or array of pairs
|
|
1014
|
+
#
|
|
1015
|
+
# @raise [ArgumentError] at bind time if a sub-array has more than 2 elements
|
|
1016
|
+
#
|
|
1017
|
+
# @raise [ArgumentError] at bind time if a key is nil, empty, or contains the separator
|
|
1018
|
+
#
|
|
1019
|
+
# @raise [ArgumentError] at bind time if a value is a Hash or Array (non-scalar)
|
|
1020
|
+
#
|
|
1021
|
+
# @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
|
|
1022
|
+
#
|
|
1023
|
+
# @example Basic key-value (like --trailer)
|
|
1024
|
+
# args_def = Arguments.define do
|
|
1025
|
+
# key_value_option :trailers, as: '--trailer'
|
|
1026
|
+
# end
|
|
1027
|
+
# args_def.bind(trailers: { 'Signed-off-by' => 'John' }).to_a
|
|
1028
|
+
# # => ['--trailer', 'Signed-off-by=John']
|
|
1029
|
+
#
|
|
1030
|
+
# @example Hash with array values (multiple values for same key)
|
|
1031
|
+
# args_def = Arguments.define do
|
|
1032
|
+
# key_value_option :trailers, as: '--trailer'
|
|
1033
|
+
# end
|
|
1034
|
+
# args_def.bind(trailers: { 'Signed-off-by' => ['John', 'Jane'] }).to_a
|
|
1035
|
+
# # => ['--trailer', 'Signed-off-by=John', '--trailer', 'Signed-off-by=Jane']
|
|
1036
|
+
#
|
|
1037
|
+
# @example Array of arrays (full ordering control)
|
|
1038
|
+
# args_def = Arguments.define do
|
|
1039
|
+
# key_value_option :trailers, as: '--trailer'
|
|
1040
|
+
# end
|
|
1041
|
+
# args_def.bind(trailers: [['Signed-off-by', 'John'], ['Acked-by', 'Bob']]).to_a
|
|
1042
|
+
# # => ['--trailer', 'Signed-off-by=John', '--trailer', 'Acked-by=Bob']
|
|
1043
|
+
#
|
|
1044
|
+
# @example Key without value (nil value omits separator)
|
|
1045
|
+
# args_def = Arguments.define do
|
|
1046
|
+
# key_value_option :trailers, as: '--trailer'
|
|
1047
|
+
# end
|
|
1048
|
+
# args_def.bind(trailers: [['Acked-by', nil]]).to_a
|
|
1049
|
+
# # => ['--trailer', 'Acked-by']
|
|
1050
|
+
#
|
|
1051
|
+
# @example Nil in array values produces key-only entries
|
|
1052
|
+
# args_def = Arguments.define do
|
|
1053
|
+
# key_value_option :trailers, as: '--trailer'
|
|
1054
|
+
# end
|
|
1055
|
+
# args_def.bind(trailers: { 'Key' => ['Value1', nil, 'Value2'] }).to_a
|
|
1056
|
+
# # => ['--trailer', 'Key=Value1', '--trailer', 'Key', '--trailer', 'Key=Value2']
|
|
1057
|
+
#
|
|
1058
|
+
# @example With custom separator
|
|
1059
|
+
# args_def = Arguments.define do
|
|
1060
|
+
# key_value_option :trailers, as: '--trailer', key_separator: ': '
|
|
1061
|
+
# end
|
|
1062
|
+
# args_def.bind(trailers: { 'Signed-off-by' => 'John' }).to_a
|
|
1063
|
+
# # => ['--trailer', 'Signed-off-by: John']
|
|
1064
|
+
#
|
|
1065
|
+
# @example Empty values produce no output
|
|
1066
|
+
# args_def = Arguments.define do
|
|
1067
|
+
# key_value_option :trailers, as: '--trailer', required: true
|
|
1068
|
+
# end
|
|
1069
|
+
# args_def.bind(trailers: {}).to_a # => []
|
|
1070
|
+
# args_def.bind(trailers: []).to_a # => []
|
|
1071
|
+
# args_def.bind(trailers: nil).to_a # => []
|
|
1072
|
+
#
|
|
1073
|
+
def key_value_option(names, as: nil, key_separator: '=', inline: false, required: false, allow_nil: true)
|
|
1074
|
+
option_type = inline ? :inline_key_value : :key_value
|
|
1075
|
+
register_option(names, type: option_type, as: as, key_separator: key_separator,
|
|
1076
|
+
required: required, allow_nil: allow_nil)
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# Define a literal string that is always included in the output
|
|
1080
|
+
#
|
|
1081
|
+
# Literals are output at their definition position (not grouped at the start).
|
|
1082
|
+
# This allows precise control over argument ordering, which is important for
|
|
1083
|
+
# git commands where argument position matters.
|
|
1084
|
+
#
|
|
1085
|
+
# @param flag_string [String] the static flag string (e.g., '--', '--no-progress')
|
|
1086
|
+
#
|
|
1087
|
+
# @return [void]
|
|
1088
|
+
#
|
|
1089
|
+
# @example Static flag for subcommand mode
|
|
1090
|
+
# args_def = Arguments.define do
|
|
1091
|
+
# literal '--delete'
|
|
1092
|
+
# flag_option :force
|
|
1093
|
+
# operand :branches, repeatable: true
|
|
1094
|
+
# end
|
|
1095
|
+
# args_def.bind('feature', force: true).to_a # => ['--delete', '--force', 'feature']
|
|
1096
|
+
#
|
|
1097
|
+
# @example Static separator between options and pathspecs
|
|
1098
|
+
# args_def = Arguments.define do
|
|
1099
|
+
# flag_option :force
|
|
1100
|
+
# operand :tree_ish
|
|
1101
|
+
# literal '--'
|
|
1102
|
+
# operand :paths, repeatable: true
|
|
1103
|
+
# end
|
|
1104
|
+
# args_def.bind('HEAD', 'file.txt', force: true).to_a
|
|
1105
|
+
# # => ['--force', 'HEAD', '--', 'file.txt']
|
|
1106
|
+
#
|
|
1107
|
+
def literal(flag_string)
|
|
1108
|
+
@ordered_definitions << { kind: :static, flag: flag_string }
|
|
1109
|
+
@past_separator = true if flag_string == '--'
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# Conditionally emit an options terminator only when at least one following
|
|
1113
|
+
# argument produces output
|
|
1114
|
+
#
|
|
1115
|
+
# This is the canonical form for declaring the options/operands boundary in a
|
|
1116
|
+
# command definition. Unlike {#literal} with `'--'` which always emits the
|
|
1117
|
+
# separator, `end_of_options` emits its terminator string only when at least one
|
|
1118
|
+
# argument defined after it will be emitted as part of the CLI (for example
|
|
1119
|
+
# operands or `value_option ... as_operand: true`). This avoids a trailing bare
|
|
1120
|
+
# terminator when no pathspecs or other post-separator arguments are provided.
|
|
1121
|
+
#
|
|
1122
|
+
# `end_of_options` also acts as an always-active validation boundary: operands
|
|
1123
|
+
# defined before it are always validated for option-like values (starting with
|
|
1124
|
+
# `-`), regardless of whether the terminator will ultimately be emitted.
|
|
1125
|
+
#
|
|
1126
|
+
# @param as [String] the CLI token to emit as the options terminator
|
|
1127
|
+
# (default `'--'`). Some commands use a different terminator; for example,
|
|
1128
|
+
# `git rev-parse` uses `'--end-of-options'`.
|
|
1129
|
+
#
|
|
1130
|
+
# @return [void]
|
|
1131
|
+
#
|
|
1132
|
+
# @raise [ArgumentError] if called more than once per definition block
|
|
1133
|
+
#
|
|
1134
|
+
# @raise [ArgumentError] if a flag-producing option is defined after this call
|
|
1135
|
+
#
|
|
1136
|
+
# @example Basic usage (git checkout tree-ish -- pathspecs)
|
|
1137
|
+
# args_def = Arguments.define do
|
|
1138
|
+
# flag_option :force
|
|
1139
|
+
# operand :tree_ish, required: true, allow_nil: true
|
|
1140
|
+
# end_of_options
|
|
1141
|
+
# operand :pathspecs, repeatable: true
|
|
1142
|
+
# end
|
|
1143
|
+
# args_def.bind('HEAD', 'file.txt').to_a # => ['HEAD', '--', 'file.txt']
|
|
1144
|
+
# args_def.bind('HEAD').to_a # => ['HEAD'] # (no --, nothing after it)
|
|
1145
|
+
# args_def.bind(nil, 'file.txt').to_a # => ['--', 'file.txt']
|
|
1146
|
+
# args_def.bind(nil).to_a # => []
|
|
1147
|
+
#
|
|
1148
|
+
# @example Custom terminator (git rev-parse --end-of-options)
|
|
1149
|
+
# args_def = Arguments.define do
|
|
1150
|
+
# flag_option :verify
|
|
1151
|
+
# end_of_options as: '--end-of-options'
|
|
1152
|
+
# operand :args, repeatable: true
|
|
1153
|
+
# end
|
|
1154
|
+
# args_def.bind('HEAD').to_a # => ['--end-of-options', 'HEAD']
|
|
1155
|
+
# args_def.bind.to_a # => []
|
|
1156
|
+
#
|
|
1157
|
+
def end_of_options(as: '--')
|
|
1158
|
+
raise ArgumentError, 'end_of_options cannot be declared twice' if @end_of_options_declared
|
|
1159
|
+
|
|
1160
|
+
@ordered_definitions << { kind: :end_of_options }
|
|
1161
|
+
@end_of_options_declared = true
|
|
1162
|
+
@end_of_options_as = as
|
|
1163
|
+
@past_separator = true
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
# Define a custom option with a custom builder block
|
|
1167
|
+
#
|
|
1168
|
+
# @param names [Symbol, Array<Symbol>] the option name(s), first is primary
|
|
1169
|
+
#
|
|
1170
|
+
# @param required [Boolean] whether the option must be provided (key must exist in opts)
|
|
1171
|
+
#
|
|
1172
|
+
# @param allow_nil [Boolean] whether nil is allowed when required is true. Defaults to true.
|
|
1173
|
+
# When false with required: true, raises ArgumentError if value is nil.
|
|
1174
|
+
#
|
|
1175
|
+
# @yield [value] block that receives the option value and returns the CLI argument(s)
|
|
1176
|
+
#
|
|
1177
|
+
# @yieldparam value [Object] the bound value for this option
|
|
1178
|
+
#
|
|
1179
|
+
# @yieldreturn [String, Array<String>, nil] the CLI argument(s) to emit;
|
|
1180
|
+
# nil or an empty array emits nothing
|
|
1181
|
+
#
|
|
1182
|
+
# @return [void]
|
|
1183
|
+
#
|
|
1184
|
+
# @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
|
|
1185
|
+
#
|
|
1186
|
+
# @example Custom transformation (e.g., formatting a Date value)
|
|
1187
|
+
# args_def = Arguments.define do
|
|
1188
|
+
# custom_option :since do |val|
|
|
1189
|
+
# val ? "--since=#{val.strftime('%Y-%m-%d')}" : nil
|
|
1190
|
+
# end
|
|
1191
|
+
# end
|
|
1192
|
+
# args_def.bind(since: Date.new(2024, 1, 1)).to_a # => ['--since=2024-01-01']
|
|
1193
|
+
# args_def.bind.to_a # => []
|
|
1194
|
+
#
|
|
1195
|
+
def custom_option(names, required: false, allow_nil: true, &block)
|
|
1196
|
+
register_option(names, type: :custom, builder: block, required: required, allow_nil: allow_nil)
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
# Define an execution option (not included in CLI output, forwarded to command execution)
|
|
1200
|
+
#
|
|
1201
|
+
# Execution options are omitted from the CLI argument array produced by {Bound#to_a}, but
|
|
1202
|
+
# their values are still accessible on the {Bound} object. This is useful for options that
|
|
1203
|
+
# control Ruby-side execution context (e.g., working directory) rather than git flags.
|
|
1204
|
+
#
|
|
1205
|
+
# @param names [Symbol, Array<Symbol>] the option name(s), first is primary
|
|
1206
|
+
#
|
|
1207
|
+
# @return [void]
|
|
1208
|
+
#
|
|
1209
|
+
# @example Chdir option forwarded to execution context, not emitted as a CLI flag
|
|
1210
|
+
# args_def = Arguments.define do
|
|
1211
|
+
# flag_option :verbose
|
|
1212
|
+
# execution_option :chdir
|
|
1213
|
+
# end
|
|
1214
|
+
# bound = args_def.bind(verbose: true, chdir: '/tmp')
|
|
1215
|
+
# bound.to_a # => ['--verbose'] # :chdir is not included
|
|
1216
|
+
# bound[:chdir] # => '/tmp' # still accessible on the Bound object
|
|
1217
|
+
#
|
|
1218
|
+
def execution_option(names)
|
|
1219
|
+
register_option(names, type: :execution_option)
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
# Declare that arguments conflict with each other (mutually exclusive)
|
|
1223
|
+
#
|
|
1224
|
+
# Each call to {#conflicts} defines a separate group of mutually exclusive
|
|
1225
|
+
# arguments. Names may refer to **options** (flag, value, flag-or-value, etc.)
|
|
1226
|
+
# or **operands** (positional arguments). When {#bind} is called, if more than
|
|
1227
|
+
# one argument in the same conflict group is "present", an ArgumentError is
|
|
1228
|
+
# raised.
|
|
1229
|
+
#
|
|
1230
|
+
# **Presence semantics** — an argument is present when its value is not `nil`,
|
|
1231
|
+
# `[]`, or `''`. `false` is always treated as absent for all option types.
|
|
1232
|
+
#
|
|
1233
|
+
# An ArgumentError is raised at definition time if any name given to
|
|
1234
|
+
# +conflicts+ is not a known option or operand, catching typos early.
|
|
1235
|
+
#
|
|
1236
|
+
# The error message has the general form:
|
|
1237
|
+
#
|
|
1238
|
+
# "cannot specify :name1 and :name2"
|
|
1239
|
+
#
|
|
1240
|
+
# @param names [Array<Symbol>] the option/operand names that conflict within
|
|
1241
|
+
# this group
|
|
1242
|
+
#
|
|
1243
|
+
# @return [void]
|
|
1244
|
+
#
|
|
1245
|
+
# @raise [ArgumentError] if any name is not a known option or operand
|
|
1246
|
+
#
|
|
1247
|
+
# @raise [ArgumentError] if more than one argument in the same conflict group
|
|
1248
|
+
# is present when building arguments
|
|
1249
|
+
#
|
|
1250
|
+
# @example Option-only conflict group
|
|
1251
|
+
# args_def = Arguments.define do
|
|
1252
|
+
# flag_option :gpg_sign
|
|
1253
|
+
# flag_option :no_gpg_sign
|
|
1254
|
+
# flag_option :force
|
|
1255
|
+
# flag_option :no_force
|
|
1256
|
+
# conflicts :gpg_sign, :no_gpg_sign
|
|
1257
|
+
# conflicts :force, :no_force
|
|
1258
|
+
# end
|
|
1259
|
+
# args_def.bind(gpg_sign: true).to_a # => ['--gpg-sign']
|
|
1260
|
+
#
|
|
1261
|
+
# @example Mixed option and operand conflict
|
|
1262
|
+
# args_def = Arguments.define do
|
|
1263
|
+
# flag_option %i[merge m], as: '--merge'
|
|
1264
|
+
# operand :tree_ish, required: true, allow_nil: true
|
|
1265
|
+
# end_of_options
|
|
1266
|
+
# operand :paths, repeatable: true
|
|
1267
|
+
# conflicts :merge, :tree_ish
|
|
1268
|
+
# end
|
|
1269
|
+
# args_def.bind(nil, 'file.txt', merge: true).to_a # => ['--merge', '--', 'file.txt']
|
|
1270
|
+
# args_def.bind('main', 'file.txt', merge: true)
|
|
1271
|
+
# # => raise ArgumentError, 'cannot specify :merge and :tree_ish'
|
|
1272
|
+
#
|
|
1273
|
+
def conflicts(*names)
|
|
1274
|
+
names.each do |name|
|
|
1275
|
+
sym = name.to_sym
|
|
1276
|
+
next if known_argument?(sym)
|
|
1277
|
+
|
|
1278
|
+
raise ArgumentError, "unknown argument :#{sym} in conflicts declaration"
|
|
1279
|
+
end
|
|
1280
|
+
@conflicts << names.map(&:to_sym)
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
# Declare that an exact combination of argument values is forbidden
|
|
1284
|
+
#
|
|
1285
|
+
# Each call to {#forbid_values} defines one forbidden tuple. A tuple matches
|
|
1286
|
+
# when **every** listed name is present (has a bound value after alias
|
|
1287
|
+
# normalization) **and** each value equals the declared value exactly (Ruby
|
|
1288
|
+
# `==`). When a tuple matches, {#bind} raises ArgumentError.
|
|
1289
|
+
#
|
|
1290
|
+
# This fills the gap left by {#conflicts}, which only checks *presence*.
|
|
1291
|
+
# `forbid_values` is useful for negatable flags whose combinations can be
|
|
1292
|
+
# semantically equivalent or contradictory depending on the actual boolean
|
|
1293
|
+
# values — presence-based exclusion would be too coarse.
|
|
1294
|
+
#
|
|
1295
|
+
# Names may refer to **options** (flag, value, flag-or-value, etc.) or
|
|
1296
|
+
# **operands** (positional arguments). Alias names are accepted and
|
|
1297
|
+
# canonicalized to their primary names.
|
|
1298
|
+
#
|
|
1299
|
+
# An ArgumentError is raised at **definition time** if any name is not a
|
|
1300
|
+
# known option or operand.
|
|
1301
|
+
#
|
|
1302
|
+
# The error message has the form:
|
|
1303
|
+
#
|
|
1304
|
+
# "cannot specify :name1=value1 with :name2=value2"
|
|
1305
|
+
#
|
|
1306
|
+
# @param pairs [Hash] keyword pairs mapping argument name to forbidden value
|
|
1307
|
+
#
|
|
1308
|
+
# @return [void]
|
|
1309
|
+
#
|
|
1310
|
+
# @raise [ArgumentError] if any name in +pairs+ is not a known option or
|
|
1311
|
+
# operand
|
|
1312
|
+
#
|
|
1313
|
+
# @raise [ArgumentError] during {#bind} if all names are present and all
|
|
1314
|
+
# values exactly match the declared tuple
|
|
1315
|
+
#
|
|
1316
|
+
# @example Reject only the contradictory negatable flag combinations
|
|
1317
|
+
# args_def = Arguments.define do
|
|
1318
|
+
# flag_option :all, negatable: true
|
|
1319
|
+
# flag_option :ignore_removal, negatable: true
|
|
1320
|
+
# # --all --ignore-removal: contradictory (add ALL vs ignore removals)
|
|
1321
|
+
# forbid_values all: true, ignore_removal: true
|
|
1322
|
+
# # --no-all --no-ignore-removal: contradictory (ignore removals vs include removals)
|
|
1323
|
+
# forbid_values no_all: true, no_ignore_removal: true
|
|
1324
|
+
# end
|
|
1325
|
+
# # Contradictory tuples raise:
|
|
1326
|
+
# args_def.bind(all: true, ignore_removal: true)
|
|
1327
|
+
# # => raise ArgumentError, 'cannot specify :all=true with :ignore_removal=true'
|
|
1328
|
+
# args_def.bind(no_all: true, no_ignore_removal: true)
|
|
1329
|
+
# # => raise ArgumentError, 'cannot specify :no_all=true with :no_ignore_removal=true'
|
|
1330
|
+
# # Semantically compatible pairs are allowed:
|
|
1331
|
+
# args_def.bind(all: true, no_ignore_removal: true).to_a # => ['--all', '--no-ignore-removal']
|
|
1332
|
+
# args_def.bind(no_all: true, ignore_removal: true).to_a # => ['--no-all', '--ignore-removal']
|
|
1333
|
+
#
|
|
1334
|
+
def forbid_values(**pairs)
|
|
1335
|
+
raise ArgumentError, 'forbid_values must be given at least one name-value pair' if pairs.empty?
|
|
1336
|
+
|
|
1337
|
+
pairs.each_key do |name|
|
|
1338
|
+
sym = name.to_sym
|
|
1339
|
+
next if known_argument?(sym)
|
|
1340
|
+
|
|
1341
|
+
raise ArgumentError, "unknown argument :#{sym} in forbid_values declaration"
|
|
1342
|
+
end
|
|
1343
|
+
canonical = pairs.transform_keys { |k| @alias_map[k] || k }
|
|
1344
|
+
@forbidden_values << canonical
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1347
|
+
# Declare that at least one of the named arguments must be present when binding
|
|
1348
|
+
#
|
|
1349
|
+
# Each call to {#requires_one_of} defines an independent "at least one" group.
|
|
1350
|
+
# When {#bind} is called, if none of the arguments in the group is present,
|
|
1351
|
+
# an ArgumentError is raised.
|
|
1352
|
+
#
|
|
1353
|
+
# **Conditional form** — when `when:` is given, the check is only performed if
|
|
1354
|
+
# the named trigger argument is present. If the trigger is absent the group is
|
|
1355
|
+
# skipped entirely.
|
|
1356
|
+
#
|
|
1357
|
+
# **Presence semantics** — two slightly different rules apply:
|
|
1358
|
+
#
|
|
1359
|
+
# - *`when:` trigger* — the trigger is considered present when its value is
|
|
1360
|
+
# not `nil`, `false`, `[]`, or `''`. A flag set to `false` means absent,
|
|
1361
|
+
# so the trigger does **not** fire.
|
|
1362
|
+
# - *Satisfied-by check* — a group member is considered present when its
|
|
1363
|
+
# value is not `nil`, `false`, `[]`, or `''`. `false` is treated as absent
|
|
1364
|
+
# for all option types under the companion-key model.
|
|
1365
|
+
#
|
|
1366
|
+
# Names may refer to **options** (flag, value, flag-or-value, etc.) or
|
|
1367
|
+
# **operands** (positional arguments) interchangeably. Alias resolution happens
|
|
1368
|
+
# before the check, so supplying an alias for one of the named options counts
|
|
1369
|
+
# as that option being present.
|
|
1370
|
+
#
|
|
1371
|
+
# An ArgumentError is raised at definition time if any name (including the
|
|
1372
|
+
# `when:` trigger) is not a known option or operand, catching typos early.
|
|
1373
|
+
#
|
|
1374
|
+
# The error message has the general form (unconditional):
|
|
1375
|
+
#
|
|
1376
|
+
# "at least one of :name1, :name2 must be provided"
|
|
1377
|
+
#
|
|
1378
|
+
# The error message has the general form (conditional, `when:` given):
|
|
1379
|
+
#
|
|
1380
|
+
# ":trigger requires at least one of :name1, :name2"
|
|
1381
|
+
#
|
|
1382
|
+
# @param names [Array<Symbol>] the option/operand names where at least one
|
|
1383
|
+
# must be present
|
|
1384
|
+
#
|
|
1385
|
+
# @option kwargs [Symbol] :when optional trigger argument; when given, the check is
|
|
1386
|
+
# only performed if the trigger argument is present
|
|
1387
|
+
#
|
|
1388
|
+
# @return [void]
|
|
1389
|
+
#
|
|
1390
|
+
# @raise [ArgumentError] if no names are given
|
|
1391
|
+
#
|
|
1392
|
+
# @raise [ArgumentError] if any name (or the `when:` trigger) is not a known
|
|
1393
|
+
# option or operand
|
|
1394
|
+
#
|
|
1395
|
+
# @raise [ArgumentError] if none of the arguments in the group is present
|
|
1396
|
+
# when binding arguments (and the trigger, if any, is present)
|
|
1397
|
+
#
|
|
1398
|
+
# @example At-least-one of two keyword options (unconditional)
|
|
1399
|
+
# args_def = Arguments.define do
|
|
1400
|
+
# value_option :pathspec_from_file, inline: true
|
|
1401
|
+
# end_of_options
|
|
1402
|
+
# value_option :pathspec, as_operand: true, repeatable: true
|
|
1403
|
+
# requires_one_of :pathspec, :pathspec_from_file
|
|
1404
|
+
# end
|
|
1405
|
+
# args_def.bind(pathspec: ['file.txt']).to_a # => ['--', 'file.txt']
|
|
1406
|
+
# args_def.bind(pathspec_from_file: 'paths.txt').to_a
|
|
1407
|
+
# # => ['--pathspec-from-file=paths.txt']
|
|
1408
|
+
# args_def.bind
|
|
1409
|
+
# # => raise ArgumentError, 'at least one of :pathspec, :pathspec_from_file must be provided'
|
|
1410
|
+
#
|
|
1411
|
+
# @example Mixed option and operand group (unconditional)
|
|
1412
|
+
# args_def = Arguments.define do
|
|
1413
|
+
# flag_option :all
|
|
1414
|
+
# operand :paths, repeatable: true
|
|
1415
|
+
# requires_one_of :all, :paths
|
|
1416
|
+
# end
|
|
1417
|
+
# args_def.bind('file.txt').to_a # passes — :paths is present
|
|
1418
|
+
# args_def.bind(all: true).to_a # passes — :all is present
|
|
1419
|
+
# args_def.bind
|
|
1420
|
+
# # => raise ArgumentError, 'at least one of :all, :paths must be provided'
|
|
1421
|
+
#
|
|
1422
|
+
# @example Multiple independent groups (unconditional)
|
|
1423
|
+
# args_def = Arguments.define do
|
|
1424
|
+
# flag_option :commit
|
|
1425
|
+
# flag_option :all
|
|
1426
|
+
# value_option :pathspec_from_file, inline: true
|
|
1427
|
+
# end_of_options
|
|
1428
|
+
# value_option :pathspec, as_operand: true, repeatable: true
|
|
1429
|
+
# requires_one_of :commit, :all
|
|
1430
|
+
# requires_one_of :pathspec, :pathspec_from_file
|
|
1431
|
+
# end
|
|
1432
|
+
#
|
|
1433
|
+
# @example Conditional at-least-one-of group (`when:` form)
|
|
1434
|
+
# args_def = Arguments.define do
|
|
1435
|
+
# flag_option :annotate
|
|
1436
|
+
# value_option :message, inline: true
|
|
1437
|
+
# value_option :file, inline: true
|
|
1438
|
+
# requires_one_of :message, :file, when: :annotate
|
|
1439
|
+
# end
|
|
1440
|
+
# args_def.bind(annotate: true, message: 'v1.0').to_a # passes
|
|
1441
|
+
# args_def.bind(annotate: true)
|
|
1442
|
+
# # => raise ArgumentError, ':annotate requires at least one of :message, :file'
|
|
1443
|
+
# args_def.bind # trigger absent — no error
|
|
1444
|
+
#
|
|
1445
|
+
def requires_one_of(*names, **kwargs)
|
|
1446
|
+
condition = kwargs.delete(:when)
|
|
1447
|
+
raise ArgumentError, "requires_one_of: unknown keyword arguments: #{kwargs.keys.inspect}" unless kwargs.empty?
|
|
1448
|
+
raise ArgumentError, 'requires_one_of must be given at least one argument name' if names.empty?
|
|
1449
|
+
|
|
1450
|
+
canonical_group = canonicalize_requires_names(names)
|
|
1451
|
+
canonical_condition = resolve_requires_condition(condition)
|
|
1452
|
+
@requires_one_of << { names: canonical_group, condition: canonical_condition, single: false }
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1455
|
+
# Declare that exactly one of the named arguments must be present when binding
|
|
1456
|
+
#
|
|
1457
|
+
# This is a convenience composite that combines {#requires_one_of} (at least one
|
|
1458
|
+
# must be present) and {#conflicts} (at most one may be present). Use it when a
|
|
1459
|
+
# group of arguments is mutually exclusive *and* the caller must supply precisely
|
|
1460
|
+
# one of them.
|
|
1461
|
+
#
|
|
1462
|
+
# The call:
|
|
1463
|
+
#
|
|
1464
|
+
# requires_exactly_one_of :a, :b, :c
|
|
1465
|
+
#
|
|
1466
|
+
# is exactly equivalent to:
|
|
1467
|
+
#
|
|
1468
|
+
# requires_one_of :a, :b, :c
|
|
1469
|
+
# conflicts :a, :b, :c
|
|
1470
|
+
#
|
|
1471
|
+
# **Presence semantics** — inherits the rules from the constituent methods.
|
|
1472
|
+
# See {#requires_one_of} and {#conflicts} for the full details.
|
|
1473
|
+
#
|
|
1474
|
+
# An ArgumentError is raised at definition time if any name is not a known
|
|
1475
|
+
# option or operand, catching typos early.
|
|
1476
|
+
#
|
|
1477
|
+
# Error messages reuse the formats from the constituent methods:
|
|
1478
|
+
#
|
|
1479
|
+
# "at least one of :a, :b, :c must be provided" # zero present
|
|
1480
|
+
# "cannot specify :a and :b" # two or more present
|
|
1481
|
+
#
|
|
1482
|
+
# @param names [Array<Symbol>] the option/operand names where exactly one
|
|
1483
|
+
# must be present
|
|
1484
|
+
#
|
|
1485
|
+
# @return [void]
|
|
1486
|
+
#
|
|
1487
|
+
# @raise [ArgumentError] if any name is not a known option or operand
|
|
1488
|
+
#
|
|
1489
|
+
# @raise [ArgumentError] at bind time if none of the arguments in the group is present
|
|
1490
|
+
#
|
|
1491
|
+
# @raise [ArgumentError] at bind time if more than one argument in the group is present
|
|
1492
|
+
#
|
|
1493
|
+
# @example Mode flags where exactly one must be supplied
|
|
1494
|
+
# args_def = Arguments.define do
|
|
1495
|
+
# flag_option :mode_a
|
|
1496
|
+
# flag_option :mode_b
|
|
1497
|
+
# flag_option :mode_c
|
|
1498
|
+
# requires_exactly_one_of :mode_a, :mode_b, :mode_c
|
|
1499
|
+
# end
|
|
1500
|
+
# args_def.bind(mode_a: true).to_a # => ['--mode-a']
|
|
1501
|
+
# args_def.bind
|
|
1502
|
+
# # => raise ArgumentError, 'at least one of :mode_a, :mode_b, :mode_c must be provided'
|
|
1503
|
+
# args_def.bind(mode_a: true, mode_c: true)
|
|
1504
|
+
# # => raise ArgumentError, 'cannot specify :mode_a and :mode_c'
|
|
1505
|
+
#
|
|
1506
|
+
def requires_exactly_one_of(*names)
|
|
1507
|
+
requires_one_of(*names)
|
|
1508
|
+
conflicts(*names)
|
|
1509
|
+
end
|
|
1510
|
+
|
|
1511
|
+
# Declare that *name* must be present whenever the trigger argument *when:* is present
|
|
1512
|
+
#
|
|
1513
|
+
# When {#bind} is called, if the trigger argument is present and *name* is absent,
|
|
1514
|
+
# an ArgumentError is raised. If the trigger is absent, the check is skipped.
|
|
1515
|
+
#
|
|
1516
|
+
# **Presence semantics** — two slightly different rules apply:
|
|
1517
|
+
#
|
|
1518
|
+
# - *`when:` trigger* — the trigger is considered present when its value is
|
|
1519
|
+
# not `nil`, `false`, `[]`, or `''`. A value of `false` is treated as
|
|
1520
|
+
# absent. If you need an explicit negative form for a negatable flag, use
|
|
1521
|
+
# its `no_<name>` companion key instead.
|
|
1522
|
+
# - *Required argument* — *name* is considered present when its value is
|
|
1523
|
+
# not `nil`, `false`, `[]`, or `''`.
|
|
1524
|
+
#
|
|
1525
|
+
# An ArgumentError is raised at definition time if either *name* or the `when:`
|
|
1526
|
+
# trigger is not a known option or operand, catching typos early.
|
|
1527
|
+
#
|
|
1528
|
+
# The error message has the form:
|
|
1529
|
+
#
|
|
1530
|
+
# ":trigger requires :name"
|
|
1531
|
+
#
|
|
1532
|
+
# @param name [Symbol] the option/operand name that must be present
|
|
1533
|
+
#
|
|
1534
|
+
# @option kwargs [Symbol] :when the trigger argument; when present, *name* must also be present
|
|
1535
|
+
#
|
|
1536
|
+
# @return [void]
|
|
1537
|
+
#
|
|
1538
|
+
# @raise [ArgumentError] if `when:` is not provided
|
|
1539
|
+
#
|
|
1540
|
+
# @raise [ArgumentError] if *name* or the `when:` trigger is not a known option
|
|
1541
|
+
# or operand
|
|
1542
|
+
#
|
|
1543
|
+
# @raise [ArgumentError] if the trigger is present and *name* is absent when
|
|
1544
|
+
# binding arguments
|
|
1545
|
+
#
|
|
1546
|
+
# @example Require pathspec_from_file when pathspec_file_nul is present
|
|
1547
|
+
# args_def = Arguments.define do
|
|
1548
|
+
# flag_option :pathspec_file_nul
|
|
1549
|
+
# value_option :pathspec_from_file, inline: true
|
|
1550
|
+
# requires :pathspec_from_file, when: :pathspec_file_nul
|
|
1551
|
+
# end
|
|
1552
|
+
# args_def.bind(pathspec_file_nul: true, pathspec_from_file: 'paths.txt').to_a
|
|
1553
|
+
# # => ['--pathspec-file-nul', '--pathspec-from-file=paths.txt']
|
|
1554
|
+
# args_def.bind(pathspec_file_nul: true)
|
|
1555
|
+
# # => raise ArgumentError, ':pathspec_file_nul requires :pathspec_from_file'
|
|
1556
|
+
# args_def.bind # trigger absent — no error
|
|
1557
|
+
#
|
|
1558
|
+
# @example Require dry_run when ignore_missing is present
|
|
1559
|
+
# args_def = Arguments.define do
|
|
1560
|
+
# flag_option :dry_run
|
|
1561
|
+
# flag_option :ignore_missing
|
|
1562
|
+
# requires :dry_run, when: :ignore_missing
|
|
1563
|
+
# end
|
|
1564
|
+
# args_def.bind(ignore_missing: true)
|
|
1565
|
+
# # => raise ArgumentError, ':ignore_missing requires :dry_run'
|
|
1566
|
+
#
|
|
1567
|
+
def requires(name, **kwargs)
|
|
1568
|
+
condition = kwargs.delete(:when)
|
|
1569
|
+
raise ArgumentError, 'requires: `when:` keyword is required' unless condition
|
|
1570
|
+
raise ArgumentError, "requires: unknown keyword arguments: #{kwargs.keys.inspect}" unless kwargs.empty?
|
|
1571
|
+
|
|
1572
|
+
sym = name.to_sym
|
|
1573
|
+
validate_requires_name!(sym)
|
|
1574
|
+
canonical_trigger = resolve_requires_condition(condition)
|
|
1575
|
+
@requires_one_of << { names: [@alias_map[sym] || sym], condition: canonical_trigger, single: true }
|
|
1576
|
+
end
|
|
1577
|
+
|
|
1578
|
+
# rubocop:disable Layout/LineLength
|
|
1579
|
+
|
|
1580
|
+
# Restrict a value option to a fixed set of accepted strings
|
|
1581
|
+
#
|
|
1582
|
+
# Declares that the named option must only receive values from the given list
|
|
1583
|
+
# when a value is provided. Validation runs during {#bind}, after type checking.
|
|
1584
|
+
# `nil` and absent values are always skipped. Empty strings are skipped when
|
|
1585
|
+
# `allow_empty: true` is set on the option. For `repeatable: true` options
|
|
1586
|
+
# each element of the array is validated individually.
|
|
1587
|
+
#
|
|
1588
|
+
# @param name [Symbol] the option name (primary or alias); must refer to a
|
|
1589
|
+
# previously defined {#value_option} or {#flag_or_value_option}
|
|
1590
|
+
#
|
|
1591
|
+
# @param in [#each] accepted values enumerable. Each value is coerced with
|
|
1592
|
+
# +to_s+ and compared as a string.
|
|
1593
|
+
#
|
|
1594
|
+
# For \\{#flag_or_value_option} variants (including +negatable: true+),
|
|
1595
|
+
# boolean values (+true+ / +false+) are skipped by this check because they
|
|
1596
|
+
# control flag-emission behavior rather than representing candidate string
|
|
1597
|
+
# values.
|
|
1598
|
+
#
|
|
1599
|
+
# @return [void]
|
|
1600
|
+
#
|
|
1601
|
+
# @raise [ArgumentError] if +name+ is not a known option at definition time
|
|
1602
|
+
#
|
|
1603
|
+
# @raise [ArgumentError] if +name+ refers to a non-value option (e.g., a flag)
|
|
1604
|
+
#
|
|
1605
|
+
# @raise [ArgumentError] during {#bind} if the bound value is not in the
|
|
1606
|
+
# accepted set, with a message of the form:
|
|
1607
|
+
# `"Invalid value for :name: expected one of [...], got \"actual\""`
|
|
1608
|
+
#
|
|
1609
|
+
# @example Constrain chmod to '+x' or '-x'
|
|
1610
|
+
# args_def = Arguments.define do
|
|
1611
|
+
# value_option :chmod, inline: true
|
|
1612
|
+
# allowed_values :chmod, in: ['+x', '-x']
|
|
1613
|
+
# end
|
|
1614
|
+
# args_def.bind(chmod: '+x').to_a # => ['--chmod=+x']
|
|
1615
|
+
# args_def.bind(chmod: 'rx')
|
|
1616
|
+
# # => raise ArgumentError, 'Invalid value for :chmod: expected one of ["+x", "-x"], got "rx"'
|
|
1617
|
+
# args_def.bind.to_a # => [] # (absent — no error)
|
|
1618
|
+
#
|
|
1619
|
+
# @example Constrain cleanup to an enumerated set
|
|
1620
|
+
# args_def = Arguments.define do
|
|
1621
|
+
# value_option :cleanup, inline: true
|
|
1622
|
+
# allowed_values :cleanup, in: %w[verbatim whitespace strip]
|
|
1623
|
+
# end
|
|
1624
|
+
# args_def.bind(cleanup: 'verbatim').to_a # => ['--cleanup=verbatim']
|
|
1625
|
+
# args_def.bind(cleanup: 'compact')
|
|
1626
|
+
# # => raise ArgumentError, 'Invalid value for :cleanup: expected one of ["verbatim", "whitespace", "strip"], got "compact"'
|
|
1627
|
+
#
|
|
1628
|
+
# @example Repeatable option — each element is validated
|
|
1629
|
+
# args_def = Arguments.define do
|
|
1630
|
+
# value_option :strategy, inline: true, repeatable: true
|
|
1631
|
+
# allowed_values :strategy, in: %w[ours theirs]
|
|
1632
|
+
# end
|
|
1633
|
+
# args_def.bind(strategy: %w[ours theirs]).to_a
|
|
1634
|
+
# # => ['--strategy=ours', '--strategy=theirs']
|
|
1635
|
+
# args_def.bind(strategy: %w[ours other])
|
|
1636
|
+
# # => raise ArgumentError, 'Invalid value for :strategy: expected one of ["ours", "theirs"], got "other"'
|
|
1637
|
+
#
|
|
1638
|
+
def allowed_values(name, in:)
|
|
1639
|
+
sym = name.to_sym
|
|
1640
|
+
defn = validate_allowed_values_definition!(sym)
|
|
1641
|
+
defn[:allowed_values] = coerce_allowed_values_set!(sym, binding.local_variable_get(:in))
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
# rubocop:enable Layout/LineLength
|
|
1645
|
+
|
|
1646
|
+
# Define an operand (positional argument in Ruby terminology)
|
|
1647
|
+
#
|
|
1648
|
+
# Operands are mapped to values following Ruby method signature
|
|
1649
|
+
# semantics. Required operands before a repeatable are filled left-to-right,
|
|
1650
|
+
# required operands after a repeatable are filled from the end, and the
|
|
1651
|
+
# repeatable gets whatever remains in the middle.
|
|
1652
|
+
#
|
|
1653
|
+
# @param name [Symbol] the operand name (used in error messages)
|
|
1654
|
+
#
|
|
1655
|
+
# @param required [Boolean] whether the argument is required. For repeatable
|
|
1656
|
+
# operands, this means at least one value must be provided.
|
|
1657
|
+
#
|
|
1658
|
+
# @param repeatable [Boolean] whether the argument accepts multiple values
|
|
1659
|
+
# (like Ruby's splat operator *args). Only one repeatable operand is
|
|
1660
|
+
# allowed per definition; attempting to define a second will raise an
|
|
1661
|
+
# ArgumentError.
|
|
1662
|
+
#
|
|
1663
|
+
# @param default [Object] the default value if not provided. For repeatable
|
|
1664
|
+
# operands, this should be an array (e.g., `default: ['.']`).
|
|
1665
|
+
#
|
|
1666
|
+
# @param allow_nil [Boolean] whether nil is a valid value for a required
|
|
1667
|
+
# operand. When true, nil consumes the operand slot but is omitted
|
|
1668
|
+
# from output. This is useful for commands like `git checkout` where
|
|
1669
|
+
# the tree-ish is required to consume a slot but may be nil to restore
|
|
1670
|
+
# from the index. Defaults to false.
|
|
1671
|
+
#
|
|
1672
|
+
# @param skip_cli [Boolean] whether this operand participates in binding,
|
|
1673
|
+
# validation, and accessors but is omitted from CLI argv emission.
|
|
1674
|
+
# Defaults to false.
|
|
1675
|
+
#
|
|
1676
|
+
# @return [void]
|
|
1677
|
+
#
|
|
1678
|
+
# @example Required operand (like `def clone(repository)`)
|
|
1679
|
+
# args_def = Arguments.define do
|
|
1680
|
+
# operand :repository, required: true
|
|
1681
|
+
# end
|
|
1682
|
+
# args_def.bind('https://github.com/user/repo').to_a
|
|
1683
|
+
# # => ['https://github.com/user/repo']
|
|
1684
|
+
#
|
|
1685
|
+
# @example Optional operand with default (like `def log(commit = 'HEAD')`)
|
|
1686
|
+
# args_def = Arguments.define do
|
|
1687
|
+
# operand :commit, default: 'HEAD'
|
|
1688
|
+
# end
|
|
1689
|
+
# args_def.bind().to_a # => ['HEAD']
|
|
1690
|
+
# args_def.bind('main').to_a # => ['main']
|
|
1691
|
+
#
|
|
1692
|
+
# @example Repeatable operand (like `def add(*paths)`)
|
|
1693
|
+
# args_def = Arguments.define do
|
|
1694
|
+
# operand :paths, repeatable: true
|
|
1695
|
+
# end
|
|
1696
|
+
# args_def.bind('file1', 'file2', 'file3').to_a
|
|
1697
|
+
# # => ['file1', 'file2', 'file3']
|
|
1698
|
+
#
|
|
1699
|
+
# @example Required repeatable with at least one value (like `def rm(*paths)` with validation)
|
|
1700
|
+
# args_def = Arguments.define do
|
|
1701
|
+
# operand :paths, repeatable: true, required: true
|
|
1702
|
+
# end
|
|
1703
|
+
# args_def.bind() #=> raise ArgumentError, "at least one value is required for paths"
|
|
1704
|
+
# args_def.bind('file1').to_a # => ['file1']
|
|
1705
|
+
#
|
|
1706
|
+
# @example git mv pattern (like `def mv(*sources, destination)`)
|
|
1707
|
+
# args_def = Arguments.define do
|
|
1708
|
+
# operand :sources, repeatable: true, required: true
|
|
1709
|
+
# operand :destination, required: true
|
|
1710
|
+
# end
|
|
1711
|
+
# args_def.bind('src1', 'src2', 'dest').to_a # => ['src1', 'src2', 'dest']
|
|
1712
|
+
# args_def.bind('src', 'dest').to_a # => ['src', 'dest']
|
|
1713
|
+
#
|
|
1714
|
+
# @example Optional before variadic with required after (like `def foo(a = 'default', *middle, b)`)
|
|
1715
|
+
# args_def = Arguments.define do
|
|
1716
|
+
# operand :a, default: 'default_a'
|
|
1717
|
+
# operand :middle, repeatable: true
|
|
1718
|
+
# operand :b, required: true
|
|
1719
|
+
# end
|
|
1720
|
+
# args_def.bind('x').to_a # => ['default_a', 'x']
|
|
1721
|
+
# args_def.bind('x', 'y').to_a # => ['x', 'y']
|
|
1722
|
+
# args_def.bind('x', 'm', 'y').to_a # => ['x', 'm', 'y']
|
|
1723
|
+
#
|
|
1724
|
+
# @example Operand after end_of_options boundary (pathspec after --)
|
|
1725
|
+
# args_def = Arguments.define do
|
|
1726
|
+
# flag_option :force
|
|
1727
|
+
# end_of_options
|
|
1728
|
+
# operand :paths, repeatable: true
|
|
1729
|
+
# end
|
|
1730
|
+
# args_def.bind('file1', 'file2', force: true).to_a
|
|
1731
|
+
# # => ['--force', '--', 'file1', 'file2']
|
|
1732
|
+
#
|
|
1733
|
+
# @example Complex pattern (like `def diff(commit1, commit2 = nil, *paths)`)
|
|
1734
|
+
# args_def = Arguments.define do
|
|
1735
|
+
# operand :commit1, required: true
|
|
1736
|
+
# operand :commit2
|
|
1737
|
+
# end_of_options
|
|
1738
|
+
# operand :paths, repeatable: true
|
|
1739
|
+
# end
|
|
1740
|
+
# args_def.bind('HEAD~1').to_a # => ['HEAD~1']
|
|
1741
|
+
# args_def.bind('HEAD~1', 'HEAD').to_a # => ['HEAD~1', 'HEAD']
|
|
1742
|
+
# args_def.bind('HEAD~1', 'HEAD', 'file.rb').to_a
|
|
1743
|
+
# # => ['HEAD~1', 'HEAD', '--', 'file.rb']
|
|
1744
|
+
#
|
|
1745
|
+
# @example Required operand that allows nil (like `git checkout [tree-ish] -- paths`)
|
|
1746
|
+
# args_def = Arguments.define do
|
|
1747
|
+
# operand :tree_ish, required: true, allow_nil: true
|
|
1748
|
+
# end_of_options
|
|
1749
|
+
# operand :paths, repeatable: true
|
|
1750
|
+
# end
|
|
1751
|
+
# args_def.bind(nil, 'file1.txt', 'file2.txt').to_a
|
|
1752
|
+
# # => ['--', 'file1.txt', 'file2.txt']
|
|
1753
|
+
# args_def.bind('HEAD', 'file.rb').to_a
|
|
1754
|
+
# # => ['HEAD', '--', 'file.rb']
|
|
1755
|
+
# args_def.bind(nil, 'file.rb').to_a
|
|
1756
|
+
# # => ['--', 'file.rb']
|
|
1757
|
+
#
|
|
1758
|
+
# @raise [ArgumentError] during {#bind} if the operand appears before a '--'
|
|
1759
|
+
# boundary (or no boundary exists) and the bound value starts with '-'
|
|
1760
|
+
#
|
|
1761
|
+
#
|
|
1762
|
+
def operand(name, required: false, repeatable: false, default: nil, allow_nil: false,
|
|
1763
|
+
skip_cli: false)
|
|
1764
|
+
validate_single_repeatable!(name) if repeatable
|
|
1765
|
+
add_operand_definition(name, required, repeatable, default, allow_nil, skip_cli)
|
|
1766
|
+
end
|
|
1767
|
+
|
|
1768
|
+
# Bind positionals and options, returning a Bound object with accessor methods
|
|
1769
|
+
#
|
|
1770
|
+
# Unlike the internal build method which returns a raw Array, this method
|
|
1771
|
+
# returns a {Bound} object that:
|
|
1772
|
+
# - Provides accessor methods for all defined options and positional arguments
|
|
1773
|
+
# - Automatically normalizes option aliases to their canonical names
|
|
1774
|
+
# - Supports splatting via `to_ary` for seamless use with `command(*bound)`
|
|
1775
|
+
#
|
|
1776
|
+
# @param positionals [Array] positional argument values
|
|
1777
|
+
#
|
|
1778
|
+
# @param opts [Hash] the keyword options
|
|
1779
|
+
#
|
|
1780
|
+
# @return [Bound] a frozen object with accessor methods for all arguments
|
|
1781
|
+
#
|
|
1782
|
+
# @raise [ArgumentError] if unsupported options are provided or validation fails
|
|
1783
|
+
#
|
|
1784
|
+
# @raise [ArgumentError] if an operand value before a '--' boundary starts with '-'
|
|
1785
|
+
#
|
|
1786
|
+
# @example Simple splatting (same behavior as build)
|
|
1787
|
+
# def call(*, **)
|
|
1788
|
+
# @execution_context.command_capturing(*ARGS.bind(*, **))
|
|
1789
|
+
# end
|
|
1790
|
+
#
|
|
1791
|
+
# @example Inspecting options before command execution
|
|
1792
|
+
# args_def = Arguments.define do
|
|
1793
|
+
# flag_option :force
|
|
1794
|
+
# flag_option :remotes, as: ['-r', '--remotes']
|
|
1795
|
+
# operand :branch_names, repeatable: true
|
|
1796
|
+
# end
|
|
1797
|
+
# bound_args = args_def.bind('branch1', 'branch2', force: true, remotes: true)
|
|
1798
|
+
# bound_args.force? # => true
|
|
1799
|
+
# bound_args.remotes? # => true
|
|
1800
|
+
# bound_args.branch_names # => ['branch1', 'branch2']
|
|
1801
|
+
#
|
|
1802
|
+
# @example Hash-style access for reserved names
|
|
1803
|
+
# args_def = Arguments.define do
|
|
1804
|
+
# value_option :hash
|
|
1805
|
+
# end
|
|
1806
|
+
# bound_args = args_def.bind(hash: 'abc123')
|
|
1807
|
+
# bound_args[:hash] # => 'abc123'
|
|
1808
|
+
#
|
|
1809
|
+
def bind(*positionals, **opts)
|
|
1810
|
+
normalized_opts = validate_and_normalize_options!(opts)
|
|
1811
|
+
allocated_positionals = allocate_and_validate_positionals(positionals)
|
|
1812
|
+
validate_bind_inputs!(normalized_opts, allocated_positionals)
|
|
1813
|
+
|
|
1814
|
+
args_array = build_ordered_arguments(allocated_positionals, normalized_opts)
|
|
1815
|
+
options_hash = build_options_hash(normalized_opts)
|
|
1816
|
+
execution_option_names = option_names_by_type(:execution_option)
|
|
1817
|
+
flag_names = option_names_by_type(:flag)
|
|
1818
|
+
|
|
1819
|
+
Bound.new(args_array, options_hash, allocated_positionals, execution_option_names, flag_names)
|
|
1820
|
+
end
|
|
1821
|
+
|
|
1822
|
+
# Option types allowed after a '--' separator boundary (they do not produce CLI flags)
|
|
1823
|
+
OPTION_TYPES_AFTER_SEPARATOR = %i[value_as_operand execution_option].freeze
|
|
1824
|
+
|
|
1825
|
+
# Sentinel object placed in the build array by an :end_of_options definition.
|
|
1826
|
+
# It is later replaced by the stored `as:` value (default `'--'`) if any element
|
|
1827
|
+
# follows it, or stripped if it is last.
|
|
1828
|
+
# Uses Object identity comparison (== is not overridden) so it can never collide
|
|
1829
|
+
# with the literal string '--' or any other real argument value.
|
|
1830
|
+
END_OF_OPTIONS_MARKER = Object.new.freeze
|
|
1831
|
+
private_constant :END_OF_OPTIONS_MARKER
|
|
1832
|
+
|
|
1833
|
+
# Option types that accept a string value — eligible for `allowed_values` constraints
|
|
1834
|
+
VALUE_OPTION_TYPES_FOR_ALLOWED_VALUES = %i[
|
|
1835
|
+
value inline_value value_as_operand
|
|
1836
|
+
flag_or_value flag_or_inline_value
|
|
1837
|
+
].freeze
|
|
1838
|
+
|
|
1839
|
+
# The subset of VALUE_OPTION_TYPES_FOR_ALLOWED_VALUES whose boolean values
|
|
1840
|
+
# carry semantic meaning (true = emit flag, false = suppress flag) and must
|
|
1841
|
+
# skip allowed_values validation rather than being compared against the set.
|
|
1842
|
+
FLAG_OR_VALUE_OPTION_TYPES = %i[
|
|
1843
|
+
flag_or_value flag_or_inline_value
|
|
1844
|
+
].freeze
|
|
1845
|
+
|
|
1846
|
+
private
|
|
1847
|
+
|
|
1848
|
+
# Run all cross-field validations on bound inputs
|
|
1849
|
+
#
|
|
1850
|
+
# @param normalized_opts [Hash] normalized keyword options
|
|
1851
|
+
# @param allocated_positionals [Hash] allocated positional arguments
|
|
1852
|
+
# @return [void]
|
|
1853
|
+
#
|
|
1854
|
+
def validate_bind_inputs!(normalized_opts, allocated_positionals)
|
|
1855
|
+
validate_no_option_like_operands!(allocated_positionals)
|
|
1856
|
+
validate_conflicts!(normalized_opts, allocated_positionals)
|
|
1857
|
+
validate_forbidden_values!(normalized_opts, allocated_positionals)
|
|
1858
|
+
validate_requires_one_of!(normalized_opts, allocated_positionals)
|
|
1859
|
+
end
|
|
1860
|
+
|
|
1861
|
+
# Collect option names whose definition type is one of the given types
|
|
1862
|
+
#
|
|
1863
|
+
# @param types [Array<Symbol>] the option types to match
|
|
1864
|
+
#
|
|
1865
|
+
# @return [Array<Symbol>]
|
|
1866
|
+
#
|
|
1867
|
+
def option_names_by_type(*types)
|
|
1868
|
+
@option_definitions.each_with_object([]) do |(name, definition), names|
|
|
1869
|
+
names << name if types.include?(definition[:type])
|
|
1870
|
+
end
|
|
1871
|
+
end
|
|
1872
|
+
|
|
1873
|
+
# Validate and normalize keyword options
|
|
1874
|
+
#
|
|
1875
|
+
# @param opts [Hash] raw keyword options
|
|
1876
|
+
# @return [Hash] normalized options with aliases resolved
|
|
1877
|
+
# @raise [ArgumentError] if options are unsupported, conflicting, or invalid
|
|
1878
|
+
#
|
|
1879
|
+
def validate_and_normalize_options!(opts)
|
|
1880
|
+
validate_unsupported_options!(opts)
|
|
1881
|
+
validate_conflicting_aliases!(opts)
|
|
1882
|
+
normalized_opts = normalize_aliases(opts)
|
|
1883
|
+
validate_required_options!(normalized_opts)
|
|
1884
|
+
validate_option_values!(normalized_opts)
|
|
1885
|
+
normalized_opts
|
|
1886
|
+
end
|
|
1887
|
+
|
|
1888
|
+
# Build a hash of all option values for the Bound object
|
|
1889
|
+
#
|
|
1890
|
+
# @param normalized_opts [Hash] the normalized options
|
|
1891
|
+
# @return [Hash{Symbol => Object}] option values with defaults applied
|
|
1892
|
+
def build_options_hash(normalized_opts)
|
|
1893
|
+
result = {}
|
|
1894
|
+
@option_definitions.each_key do |name|
|
|
1895
|
+
result[name] = normalized_opts.key?(name) ? normalized_opts[name] : default_option_value(name)
|
|
1896
|
+
end
|
|
1897
|
+
result
|
|
1898
|
+
end
|
|
1899
|
+
|
|
1900
|
+
# Get the default value for an option when not provided
|
|
1901
|
+
#
|
|
1902
|
+
# @param name [Symbol] the option name
|
|
1903
|
+
# @return [Object] the default value (false for flags, nil for values)
|
|
1904
|
+
def default_option_value(name)
|
|
1905
|
+
definition = @option_definitions[name]
|
|
1906
|
+
case definition[:type]
|
|
1907
|
+
when :flag
|
|
1908
|
+
false
|
|
1909
|
+
end
|
|
1910
|
+
end
|
|
1911
|
+
|
|
1912
|
+
# Determine the internal option type based on inline and as_operand modifiers
|
|
1913
|
+
#
|
|
1914
|
+
# @param inline [Boolean] whether to use inline format (--flag=value)
|
|
1915
|
+
# @param as_operand [Boolean] whether to output as operand (positional argument)
|
|
1916
|
+
# @return [Symbol] the internal option type
|
|
1917
|
+
#
|
|
1918
|
+
def determine_value_option_type(inline, as_operand)
|
|
1919
|
+
if as_operand
|
|
1920
|
+
:value_as_operand
|
|
1921
|
+
elsif inline
|
|
1922
|
+
:inline_value
|
|
1923
|
+
else
|
|
1924
|
+
:value
|
|
1925
|
+
end
|
|
1926
|
+
end
|
|
1927
|
+
|
|
1928
|
+
# Validate value modifier combinations
|
|
1929
|
+
#
|
|
1930
|
+
# @param names [Symbol, Array<Symbol>] the option name(s)
|
|
1931
|
+
# @param inline [Boolean] whether inline: true was specified
|
|
1932
|
+
# @param as_operand [Boolean] whether as_operand: true was specified
|
|
1933
|
+
# @raise [ArgumentError] if invalid modifier combination is used
|
|
1934
|
+
#
|
|
1935
|
+
def validate_value_modifiers!(names, inline, as_operand)
|
|
1936
|
+
primary = Array(names).first
|
|
1937
|
+
raise ArgumentError, "inline: and as_operand: cannot both be true for :#{primary}" if inline && as_operand
|
|
1938
|
+
end
|
|
1939
|
+
|
|
1940
|
+
# Register an option with optional aliases
|
|
1941
|
+
#
|
|
1942
|
+
# @param names [Symbol, Array<Symbol>] the option name(s), first is primary
|
|
1943
|
+
# @param definition [Hash] the option definition
|
|
1944
|
+
# @return [void]
|
|
1945
|
+
#
|
|
1946
|
+
def register_option(names, **definition)
|
|
1947
|
+
keys = Array(names)
|
|
1948
|
+
primary = keys.first
|
|
1949
|
+
definition[:aliases] = keys
|
|
1950
|
+
validate_no_duplicate_aliases!(keys)
|
|
1951
|
+
validate_no_companion_collision!(keys)
|
|
1952
|
+
validate_option_after_separator!(definition[:type], primary)
|
|
1953
|
+
validate_as_parameter!(definition, primary)
|
|
1954
|
+
apply_type_validator!(definition, primary)
|
|
1955
|
+
store_option(primary, keys, definition)
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
def store_option(primary, keys, definition)
|
|
1959
|
+
@option_definitions[primary] = definition
|
|
1960
|
+
keys.each { |key| @alias_map[key] = primary }
|
|
1961
|
+
@ordered_definitions << { kind: :option, name: primary }
|
|
1962
|
+
end
|
|
1963
|
+
|
|
1964
|
+
# Raise if any of +keys+ collides with a previously synthesized +no_<name>+
|
|
1965
|
+
# companion entry. This catches the case where a user declares
|
|
1966
|
+
# +flag_option :foo, negatable: true+ followed by +flag_option :no_foo+.
|
|
1967
|
+
def validate_no_companion_collision!(keys)
|
|
1968
|
+
keys.each do |key|
|
|
1969
|
+
next unless @negatable_companions.include?(key)
|
|
1970
|
+
|
|
1971
|
+
raise ArgumentError,
|
|
1972
|
+
"option key :#{key} is already registered as a negatable companion"
|
|
1973
|
+
end
|
|
1974
|
+
end
|
|
1975
|
+
|
|
1976
|
+
# Raise if the +keys+ array contains duplicate entries.
|
|
1977
|
+
#
|
|
1978
|
+
# Duplicate aliases in a single declaration (e.g. +flag_option %i[foo foo]+)
|
|
1979
|
+
# are a programming mistake and would silently overwrite each other in
|
|
1980
|
+
# +@alias_map+. Catching them at definition time makes the error obvious.
|
|
1981
|
+
def validate_no_duplicate_aliases!(keys)
|
|
1982
|
+
seen = Set.new
|
|
1983
|
+
keys.each do |key|
|
|
1984
|
+
raise ArgumentError, "duplicate alias key :#{key} in option definition" unless seen.add?(key)
|
|
1985
|
+
end
|
|
1986
|
+
end
|
|
1987
|
+
|
|
1988
|
+
def validate_max_times!(option_name, max_times)
|
|
1989
|
+
return if max_times.nil?
|
|
1990
|
+
|
|
1991
|
+
return if max_times.is_a?(Integer) && max_times >= 2
|
|
1992
|
+
|
|
1993
|
+
raise ArgumentError, "max_times for :#{option_name} must be an Integer >= 2"
|
|
1994
|
+
end
|
|
1995
|
+
|
|
1996
|
+
# Register two companion :flag entries for a negatable flag option
|
|
1997
|
+
#
|
|
1998
|
+
# Registers a positive entry for +names+ and a boolean-only negative entry for
|
|
1999
|
+
# <tt>:no_<primary></tt>. An automatic conflict is added so that both being
|
|
2000
|
+
# true at bind time raises ArgumentError.
|
|
2001
|
+
def register_negatable_flag_pair(names, as:, required:, allow_nil:, max_times:)
|
|
2002
|
+
primary = Array(names).first
|
|
2003
|
+
validate_negatable_allow_nil!(primary, required: required, allow_nil: allow_nil)
|
|
2004
|
+
prepare_negatable!(primary, names, as)
|
|
2005
|
+
|
|
2006
|
+
register_option(names, type: :flag, as: as, expected_type: nil, validator: nil,
|
|
2007
|
+
required: false, allow_nil: allow_nil, max_times: max_times)
|
|
2008
|
+
register_negative_companion(primary, as: as, required: required)
|
|
2009
|
+
end
|
|
2010
|
+
|
|
2011
|
+
# Register a positive flag-or-value entry and a boolean-only negative companion
|
|
2012
|
+
# entry for a negatable flag-or-value option
|
|
2013
|
+
def register_negatable_flag_or_value_pair(names, as:, type:, inline:, repeatable:, required:, allow_nil:)
|
|
2014
|
+
primary = Array(names).first
|
|
2015
|
+
validate_negatable_allow_nil!(primary, required: required, allow_nil: allow_nil)
|
|
2016
|
+
prepare_negatable!(primary, names, as)
|
|
2017
|
+
|
|
2018
|
+
positive_type = inline ? :flag_or_inline_value : :flag_or_value
|
|
2019
|
+
register_option(names, type: positive_type, as: as, expected_type: type,
|
|
2020
|
+
repeatable: repeatable, required: false, allow_nil: allow_nil)
|
|
2021
|
+
register_negative_companion(primary, as: as, required: required)
|
|
2022
|
+
end
|
|
2023
|
+
|
|
2024
|
+
# Run shared validations for a negatable option before registering either side
|
|
2025
|
+
def prepare_negatable!(primary, names, as)
|
|
2026
|
+
validate_negatable_primary_key!(primary)
|
|
2027
|
+
validate_negatable_as_not_array!(primary, as)
|
|
2028
|
+
validate_negatable_as_long_form!(primary, as)
|
|
2029
|
+
no_name = :"no_#{primary}"
|
|
2030
|
+
validate_no_negatable_collision!(no_name)
|
|
2031
|
+
validate_no_companion_in_alias_list!(no_name, Array(names))
|
|
2032
|
+
end
|
|
2033
|
+
|
|
2034
|
+
# Register the synthesized +no_<primary>+ flag entry, the auto-conflict, and
|
|
2035
|
+
# (when +required: true+) the auto requires_one_of group
|
|
2036
|
+
def register_negative_companion(primary, as:, required:)
|
|
2037
|
+
no_name = :"no_#{primary}"
|
|
2038
|
+
positive_flag = as || default_arg_spec(primary)
|
|
2039
|
+
negative_flag = negate_flag(positive_flag)
|
|
2040
|
+
|
|
2041
|
+
register_option(no_name, type: :flag, as: negative_flag, expected_type: nil, validator: nil,
|
|
2042
|
+
required: false, allow_nil: true)
|
|
2043
|
+
@negatable_companions << no_name
|
|
2044
|
+
@conflicts << [primary, no_name]
|
|
2045
|
+
@requires_one_of << { names: [primary, no_name], condition: nil, single: false } if required
|
|
2046
|
+
end
|
|
2047
|
+
|
|
2048
|
+
# Raise if `allow_nil: false` is combined with `negatable: true` and `required: true`
|
|
2049
|
+
#
|
|
2050
|
+
# When `negatable: true` and `required: true`, the "required" constraint is enforced
|
|
2051
|
+
# by an auto `requires_one_of` group (either the primary or its `no_<name>` companion
|
|
2052
|
+
# must be present). Because the primary option is internally registered with
|
|
2053
|
+
# `required: false`, the `allow_nil: false` nil-check never runs, making the
|
|
2054
|
+
# combination silently misleading. Fail at definition time instead.
|
|
2055
|
+
#
|
|
2056
|
+
# @param key [Symbol] the primary option name (for the error message)
|
|
2057
|
+
#
|
|
2058
|
+
# @param required [Boolean] whether the option is required
|
|
2059
|
+
#
|
|
2060
|
+
# @param allow_nil [Boolean] whether nil is allowed
|
|
2061
|
+
#
|
|
2062
|
+
# @raise [ArgumentError] if `required: true` and `allow_nil: false` are combined
|
|
2063
|
+
# with `negatable: true`
|
|
2064
|
+
#
|
|
2065
|
+
def validate_negatable_allow_nil!(key, required:, allow_nil:)
|
|
2066
|
+
return unless required && allow_nil == false
|
|
2067
|
+
|
|
2068
|
+
raise ArgumentError,
|
|
2069
|
+
"allow_nil: false cannot be used with negatable: true and required: true on :#{key} " \
|
|
2070
|
+
'(nil is caught by the auto requires_one_of group, not allow_nil)'
|
|
2071
|
+
end
|
|
2072
|
+
|
|
2073
|
+
# @raise [ArgumentError] if key is not snake_case
|
|
2074
|
+
#
|
|
2075
|
+
def validate_negatable_primary_key!(key)
|
|
2076
|
+
return if key.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
2077
|
+
|
|
2078
|
+
raise ArgumentError,
|
|
2079
|
+
"negatable: true requires a snake_case primary key, got :#{key} " \
|
|
2080
|
+
"(would generate :no_#{key} which is not a meaningful negative form)"
|
|
2081
|
+
end
|
|
2082
|
+
|
|
2083
|
+
# Raise if as: is an Array when negatable: true
|
|
2084
|
+
#
|
|
2085
|
+
# Arrays for as: are not compatible with negatable: true regardless of the
|
|
2086
|
+
# underlying option type — the synthesized +--no-<flag>+ form has no sensible
|
|
2087
|
+
# mapping when the positive form expands to multiple CLI tokens.
|
|
2088
|
+
def validate_negatable_as_not_array!(primary, as)
|
|
2089
|
+
return unless as.is_a?(Array)
|
|
2090
|
+
|
|
2091
|
+
raise ArgumentError,
|
|
2092
|
+
"arrays for as: parameter cannot be combined with negatable: true (option :#{primary})"
|
|
2093
|
+
end
|
|
2094
|
+
|
|
2095
|
+
# Raise if as: is given as a short-form flag (e.g. +-S+) when negatable: true.
|
|
2096
|
+
# Negation requires a long-form flag because the synthesized companion is
|
|
2097
|
+
# always +--no-<flag>+; deriving it from a short flag would yield a
|
|
2098
|
+
# nonexistent git form like +--no-S+.
|
|
2099
|
+
def validate_negatable_as_long_form!(primary, as)
|
|
2100
|
+
return if as.nil?
|
|
2101
|
+
return if as.is_a?(String) && as.start_with?('--')
|
|
2102
|
+
|
|
2103
|
+
raise ArgumentError,
|
|
2104
|
+
"negatable: true requires a long-form (--flag) value for as: on :#{primary}, got #{as.inspect}"
|
|
2105
|
+
end
|
|
2106
|
+
|
|
2107
|
+
# Raise if the generated no_ companion key is already registered
|
|
2108
|
+
#
|
|
2109
|
+
def validate_no_negatable_collision!(no_name)
|
|
2110
|
+
return unless @alias_map.key?(no_name)
|
|
2111
|
+
|
|
2112
|
+
raise ArgumentError,
|
|
2113
|
+
"negatable: true would register :#{no_name} but that key is already registered"
|
|
2114
|
+
end
|
|
2115
|
+
|
|
2116
|
+
# Raise if the synthesized companion key appears in the same declaration's alias list.
|
|
2117
|
+
#
|
|
2118
|
+
# This catches e.g. +flag_option %i[foo no_foo], negatable: true+ where :no_foo
|
|
2119
|
+
# is listed as an alias and would be silently overwritten when the companion is
|
|
2120
|
+
# registered, corrupting @alias_map and @option_definitions.
|
|
2121
|
+
def validate_no_companion_in_alias_list!(no_name, keys)
|
|
2122
|
+
return unless keys.include?(no_name)
|
|
2123
|
+
|
|
2124
|
+
raise ArgumentError,
|
|
2125
|
+
"negatable: true would register :#{no_name} as a companion, but :#{no_name} " \
|
|
2126
|
+
'is already listed as an alias in the same declaration'
|
|
2127
|
+
end
|
|
2128
|
+
|
|
2129
|
+
# Validate that flag-producing options are not defined after a '--' boundary
|
|
2130
|
+
#
|
|
2131
|
+
# @param type [Symbol] the option type
|
|
2132
|
+
# @param option_name [Symbol] the primary option name
|
|
2133
|
+
# @raise [ArgumentError] if a flag-producing option is defined after '--'
|
|
2134
|
+
#
|
|
2135
|
+
def validate_option_after_separator!(type, option_name)
|
|
2136
|
+
return unless @past_separator
|
|
2137
|
+
return if OPTION_TYPES_AFTER_SEPARATOR.include?(type)
|
|
2138
|
+
|
|
2139
|
+
raise ArgumentError,
|
|
2140
|
+
"option :#{option_name} cannot be defined after a '--' separator " \
|
|
2141
|
+
'boundary because its flags would be treated as operands by git'
|
|
2142
|
+
end
|
|
2143
|
+
|
|
2144
|
+
def apply_type_validator!(definition, option_name)
|
|
2145
|
+
return unless definition[:expected_type]
|
|
2146
|
+
|
|
2147
|
+
if definition[:validator]
|
|
2148
|
+
raise ArgumentError,
|
|
2149
|
+
"cannot specify both type: and validator: for :#{option_name}"
|
|
2150
|
+
end
|
|
2151
|
+
|
|
2152
|
+
definition[:validator] = create_type_validator(option_name, definition[:expected_type])
|
|
2153
|
+
end
|
|
2154
|
+
|
|
2155
|
+
def validate_as_parameter!(definition, option_name)
|
|
2156
|
+
return unless definition[:as].is_a?(Array)
|
|
2157
|
+
|
|
2158
|
+
return if definition[:type] == :flag
|
|
2159
|
+
|
|
2160
|
+
type = definition[:type]
|
|
2161
|
+
raise ArgumentError,
|
|
2162
|
+
"arrays for as: parameter are only supported for flag types, not :#{type} (option :#{option_name})"
|
|
2163
|
+
end
|
|
2164
|
+
|
|
2165
|
+
# Build arguments by iterating over definitions in their defined order
|
|
2166
|
+
#
|
|
2167
|
+
# @param allocated_positionals [Hash] the allocated positional values
|
|
2168
|
+
# @param normalized_opts [Hash] normalized keyword options
|
|
2169
|
+
# @return [Array<String>] the command-line arguments
|
|
2170
|
+
#
|
|
2171
|
+
def build_ordered_arguments(allocated_positionals, normalized_opts)
|
|
2172
|
+
args = []
|
|
2173
|
+
|
|
2174
|
+
@ordered_definitions.each do |entry|
|
|
2175
|
+
if entry[:kind] == :end_of_options
|
|
2176
|
+
args << END_OF_OPTIONS_MARKER
|
|
2177
|
+
else
|
|
2178
|
+
build_entry(args, entry, normalized_opts, allocated_positionals)
|
|
2179
|
+
end
|
|
2180
|
+
end
|
|
2181
|
+
|
|
2182
|
+
resolve_end_of_options_marker(args)
|
|
2183
|
+
end
|
|
2184
|
+
|
|
2185
|
+
# Build a single definition entry and append to args
|
|
2186
|
+
#
|
|
2187
|
+
# @param args [Array<String>] the argument array to append to
|
|
2188
|
+
# @param entry [Hash] the definition entry with :kind and name/flag
|
|
2189
|
+
# @param normalized_opts [Hash] normalized keyword options
|
|
2190
|
+
# @param allocated_positionals [Hash] the allocated positional values
|
|
2191
|
+
# @return [void]
|
|
2192
|
+
#
|
|
2193
|
+
def build_entry(args, entry, normalized_opts, allocated_positionals)
|
|
2194
|
+
case entry[:kind]
|
|
2195
|
+
when :static
|
|
2196
|
+
args << entry[:flag]
|
|
2197
|
+
when :option
|
|
2198
|
+
build_option(args, entry[:name], @option_definitions[entry[:name]], normalized_opts[entry[:name]])
|
|
2199
|
+
when :operand
|
|
2200
|
+
build_single_positional(args, entry[:name], allocated_positionals)
|
|
2201
|
+
# :nocov: this case should be unreachable
|
|
2202
|
+
else
|
|
2203
|
+
raise ArgumentError, "unknown entry kind: #{entry[:kind].inspect}"
|
|
2204
|
+
end
|
|
2205
|
+
# :nocov:
|
|
2206
|
+
end
|
|
2207
|
+
|
|
2208
|
+
# Replace the END_OF_OPTIONS_MARKER with the stored `as:` value if any element
|
|
2209
|
+
# follows it, or strip it
|
|
2210
|
+
#
|
|
2211
|
+
# @param args [Array] the built argument array (may contain END_OF_OPTIONS_MARKER)
|
|
2212
|
+
# @return [Array<String>] the argument array with the marker resolved
|
|
2213
|
+
#
|
|
2214
|
+
def resolve_end_of_options_marker(args)
|
|
2215
|
+
idx = args.index(END_OF_OPTIONS_MARKER)
|
|
2216
|
+
return args unless idx
|
|
2217
|
+
|
|
2218
|
+
if idx == args.size - 1
|
|
2219
|
+
args.delete_at(idx) # nothing follows — strip
|
|
2220
|
+
else
|
|
2221
|
+
args[idx] = @end_of_options_as # something follows — make it real
|
|
2222
|
+
end
|
|
2223
|
+
args
|
|
2224
|
+
end
|
|
2225
|
+
|
|
2226
|
+
# Allocate positionals and perform validation, returning the allocation hash
|
|
2227
|
+
#
|
|
2228
|
+
# @param positionals [Array] positional argument values
|
|
2229
|
+
# @return [Hash] allocation of positional names to values
|
|
2230
|
+
#
|
|
2231
|
+
def allocate_and_validate_positionals(positionals)
|
|
2232
|
+
positionals = normalize_positionals(positionals)
|
|
2233
|
+
allocation, consumed_count = allocate_positionals(positionals)
|
|
2234
|
+
|
|
2235
|
+
@operand_definitions.each do |definition|
|
|
2236
|
+
value = allocation[definition[:name]]
|
|
2237
|
+
validate_required_positional(value, definition)
|
|
2238
|
+
validate_no_nil_values!(value, definition)
|
|
2239
|
+
end
|
|
2240
|
+
|
|
2241
|
+
check_unexpected_positionals(positionals, consumed_count)
|
|
2242
|
+
allocation
|
|
2243
|
+
end
|
|
2244
|
+
|
|
2245
|
+
# Build a single positional argument
|
|
2246
|
+
#
|
|
2247
|
+
# @param args [Array<String>] the argument array to append to
|
|
2248
|
+
# @param name [Symbol] the positional argument name
|
|
2249
|
+
# @param allocation [Hash] the allocated positional values
|
|
2250
|
+
# @return [void]
|
|
2251
|
+
#
|
|
2252
|
+
def build_single_positional(args, name, allocation)
|
|
2253
|
+
definition = @operand_definitions.find { |d| d[:name] == name }
|
|
2254
|
+
return if definition[:skip_cli]
|
|
2255
|
+
|
|
2256
|
+
value = allocation[name]
|
|
2257
|
+
append_positional_to_args(args, value, definition)
|
|
2258
|
+
end
|
|
2259
|
+
|
|
2260
|
+
def validate_single_repeatable!(name)
|
|
2261
|
+
existing_repeatable = @operand_definitions.find { |d| d[:repeatable] }
|
|
2262
|
+
return unless existing_repeatable
|
|
2263
|
+
|
|
2264
|
+
raise ArgumentError,
|
|
2265
|
+
"only one repeatable operand is allowed; :#{existing_repeatable[:name]} is already repeatable, " \
|
|
2266
|
+
"cannot add :#{name} as repeatable"
|
|
2267
|
+
end
|
|
2268
|
+
|
|
2269
|
+
def add_operand_definition(name, required, repeatable, default, allow_nil, skip_cli)
|
|
2270
|
+
@operand_definitions << {
|
|
2271
|
+
name: name, required: required, repeatable: repeatable,
|
|
2272
|
+
default: default, allow_nil: allow_nil, skip_cli: skip_cli
|
|
2273
|
+
}
|
|
2274
|
+
@ordered_definitions << { kind: :operand, name: name }
|
|
2275
|
+
end
|
|
2276
|
+
|
|
2277
|
+
BUILDERS = {
|
|
2278
|
+
flag: :build_flag,
|
|
2279
|
+
value: lambda do |args, arg_spec, value, definition|
|
|
2280
|
+
if definition[:repeatable]
|
|
2281
|
+
Array(value).each { |v| args << arg_spec << v.to_s }
|
|
2282
|
+
else
|
|
2283
|
+
args << arg_spec << value.to_s
|
|
2284
|
+
end
|
|
2285
|
+
end,
|
|
2286
|
+
inline_value: :build_inline_value,
|
|
2287
|
+
flag_or_inline_value: :build_flag_or_inline_value,
|
|
2288
|
+
flag_or_value: :build_flag_or_value,
|
|
2289
|
+
value_as_operand: lambda do |args, _, value, definition|
|
|
2290
|
+
# Validate array usage when repeatable is false
|
|
2291
|
+
if value.is_a?(Array) && !definition[:repeatable]
|
|
2292
|
+
raise ArgumentError,
|
|
2293
|
+
"value_as_operand :#{definition[:aliases].first} requires repeatable: true to accept an array"
|
|
2294
|
+
end
|
|
2295
|
+
|
|
2296
|
+
# Validate no nil values in array
|
|
2297
|
+
if definition[:repeatable] && value.is_a?(Array) && value.any?(&:nil?)
|
|
2298
|
+
raise ArgumentError,
|
|
2299
|
+
"nil values are not allowed in value_as_operand :#{definition[:aliases].first}"
|
|
2300
|
+
end
|
|
2301
|
+
|
|
2302
|
+
# Add values as positional arguments
|
|
2303
|
+
if definition[:repeatable]
|
|
2304
|
+
Array(value).each { |v| args << v.to_s }
|
|
2305
|
+
else
|
|
2306
|
+
args << value.to_s
|
|
2307
|
+
end
|
|
2308
|
+
end,
|
|
2309
|
+
key_value: :build_key_value,
|
|
2310
|
+
inline_key_value: :build_inline_key_value,
|
|
2311
|
+
custom: lambda do |args, _, value, definition|
|
|
2312
|
+
result = definition[:builder]&.call(value)
|
|
2313
|
+
result.is_a?(Array) ? args.concat(result) : (args << result if result)
|
|
2314
|
+
end,
|
|
2315
|
+
execution_option: ->(*) {}
|
|
2316
|
+
}.freeze
|
|
2317
|
+
private_constant :BUILDERS
|
|
2318
|
+
|
|
2319
|
+
def build_option(args, name, definition, value)
|
|
2320
|
+
return if should_skip_option?(value, definition)
|
|
2321
|
+
|
|
2322
|
+
arg_spec = definition[:as] || default_arg_spec(name)
|
|
2323
|
+
builder = BUILDERS[definition[:type]]
|
|
2324
|
+
if builder.is_a?(Symbol)
|
|
2325
|
+
send(builder, args, arg_spec, value, definition)
|
|
2326
|
+
else
|
|
2327
|
+
builder.call(args, arg_spec, value, definition)
|
|
2328
|
+
end
|
|
2329
|
+
end
|
|
2330
|
+
|
|
2331
|
+
# Generate the default argument specification based on option name length
|
|
2332
|
+
#
|
|
2333
|
+
# POSIX convention: single-character options use single dash (-f),
|
|
2334
|
+
# multi-character options use double dash (--force)
|
|
2335
|
+
#
|
|
2336
|
+
# @param name [Symbol] the option name
|
|
2337
|
+
# @return [String] the argument specification (e.g., '-f' or '--force')
|
|
2338
|
+
#
|
|
2339
|
+
def default_arg_spec(name)
|
|
2340
|
+
name_str = name.to_s.tr('_', '-')
|
|
2341
|
+
name_str.length == 1 ? "-#{name_str}" : "--#{name_str}"
|
|
2342
|
+
end
|
|
2343
|
+
|
|
2344
|
+
# Check if an argument specification is for a short (single-character) option
|
|
2345
|
+
#
|
|
2346
|
+
# @param arg_spec [String] the argument specification
|
|
2347
|
+
# @return [Boolean] true if this is a short option (single dash, single char)
|
|
2348
|
+
#
|
|
2349
|
+
def short_option?(arg_spec)
|
|
2350
|
+
arg_spec.is_a?(String) && arg_spec.match?(/\A-[^-]\z/)
|
|
2351
|
+
end
|
|
2352
|
+
|
|
2353
|
+
def build_key_value(args, arg_spec, value, definition)
|
|
2354
|
+
sep = definition[:key_separator] || '='
|
|
2355
|
+
option_name = definition[:aliases].first
|
|
2356
|
+
normalize_key_value_pairs(value).each do |pair|
|
|
2357
|
+
validate_key_value_pair_size!(pair, option_name)
|
|
2358
|
+
k, v = pair
|
|
2359
|
+
validate_key_value_key!(k, sep, option_name)
|
|
2360
|
+
validate_key_value_value!(v, option_name)
|
|
2361
|
+
args << arg_spec << (v.nil? ? k.to_s : "#{k}#{sep}#{v}")
|
|
2362
|
+
end
|
|
2363
|
+
end
|
|
2364
|
+
|
|
2365
|
+
def build_inline_key_value(args, arg_spec, value, definition)
|
|
2366
|
+
sep = definition[:key_separator] || '='
|
|
2367
|
+
option_name = definition[:aliases].first
|
|
2368
|
+
normalize_key_value_pairs(value).each do |pair|
|
|
2369
|
+
validate_key_value_pair_size!(pair, option_name)
|
|
2370
|
+
k, v = pair
|
|
2371
|
+
validate_key_value_key!(k, sep, option_name)
|
|
2372
|
+
validate_key_value_value!(v, option_name)
|
|
2373
|
+
args << "#{arg_spec}=#{v.nil? ? k.to_s : "#{k}#{sep}#{v}"}"
|
|
2374
|
+
end
|
|
2375
|
+
end
|
|
2376
|
+
|
|
2377
|
+
# Build inline value option with POSIX-compliant formatting
|
|
2378
|
+
#
|
|
2379
|
+
# Short options (single-char) use no separator: -n3
|
|
2380
|
+
# Long options (multi-char) use = separator: --name=value
|
|
2381
|
+
#
|
|
2382
|
+
def build_inline_value(args, arg_spec, value, definition)
|
|
2383
|
+
sep = inline_value_separator(arg_spec)
|
|
2384
|
+
if definition[:repeatable]
|
|
2385
|
+
Array(value).each { |v| args << "#{arg_spec}#{sep}#{v}" }
|
|
2386
|
+
else
|
|
2387
|
+
args << "#{arg_spec}#{sep}#{value}"
|
|
2388
|
+
end
|
|
2389
|
+
end
|
|
2390
|
+
|
|
2391
|
+
# Build flag or inline value option with POSIX-compliant formatting
|
|
2392
|
+
#
|
|
2393
|
+
def build_flag_or_inline_value(args, arg_spec, value, definition)
|
|
2394
|
+
each_flag_or_value_value(value, definition, 'flag_or_inline_value') do |v|
|
|
2395
|
+
next if v == false
|
|
2396
|
+
|
|
2397
|
+
args << (v == true ? arg_spec : "#{arg_spec}#{inline_value_separator(arg_spec)}#{v}")
|
|
2398
|
+
end
|
|
2399
|
+
end
|
|
2400
|
+
|
|
2401
|
+
# Build flag or value option
|
|
2402
|
+
#
|
|
2403
|
+
def build_flag_or_value(args, arg_spec, value, definition)
|
|
2404
|
+
each_flag_or_value_value(value, definition, 'flag_or_value') do |v|
|
|
2405
|
+
next if v == false
|
|
2406
|
+
|
|
2407
|
+
if v == true
|
|
2408
|
+
args << arg_spec
|
|
2409
|
+
else
|
|
2410
|
+
args << arg_spec << v.to_s
|
|
2411
|
+
end
|
|
2412
|
+
end
|
|
2413
|
+
end
|
|
2414
|
+
|
|
2415
|
+
def each_flag_or_value_value(value, definition, option_type)
|
|
2416
|
+
values = definition[:repeatable] ? Array(value) : [value]
|
|
2417
|
+
values.each do |v|
|
|
2418
|
+
validate_flag_or_value_type!(v, option_type)
|
|
2419
|
+
yield v
|
|
2420
|
+
end
|
|
2421
|
+
end
|
|
2422
|
+
|
|
2423
|
+
# Validate that a flag_or_value element is not nil.
|
|
2424
|
+
#
|
|
2425
|
+
# Boolean values (true/false) control flag presence/absence. Any other non-nil
|
|
2426
|
+
# object is accepted and converted to a CLI argument string via +#to_s+.
|
|
2427
|
+
# Nil is rejected only within repeatable arrays — non-repeatable nil values are
|
|
2428
|
+
# filtered out earlier by +should_skip_option?+ and never reach here.
|
|
2429
|
+
#
|
|
2430
|
+
def validate_flag_or_value_type!(value, option_type)
|
|
2431
|
+
return unless value.nil?
|
|
2432
|
+
|
|
2433
|
+
raise ArgumentError,
|
|
2434
|
+
"Invalid value for #{option_type}: nil is not allowed as an array element; " \
|
|
2435
|
+
'expected true, false, or a non-nil object that responds to #to_s'
|
|
2436
|
+
end
|
|
2437
|
+
|
|
2438
|
+
# Determine the separator to use for inline values based on option type
|
|
2439
|
+
#
|
|
2440
|
+
# POSIX convention:
|
|
2441
|
+
# - Short options (single dash, single char like -n): no separator (-n3)
|
|
2442
|
+
# - Long options (double dash like --name): = separator (--name=value)
|
|
2443
|
+
#
|
|
2444
|
+
# @param arg_spec [String] the argument specification
|
|
2445
|
+
# @return [String] empty string ('') for short options, '=' for long options;
|
|
2446
|
+
# never returns nil, safe to concatenate directly
|
|
2447
|
+
#
|
|
2448
|
+
def inline_value_separator(arg_spec)
|
|
2449
|
+
short_option?(arg_spec) ? '' : '='
|
|
2450
|
+
end
|
|
2451
|
+
|
|
2452
|
+
def build_flag(args, arg_spec, value, definition)
|
|
2453
|
+
count = normalize_flag_value!(value, definition)
|
|
2454
|
+
append_repeated_flag(args, arg_spec, count)
|
|
2455
|
+
end
|
|
2456
|
+
|
|
2457
|
+
def append_repeated_flag(args, arg_spec, count)
|
|
2458
|
+
return if count <= 0
|
|
2459
|
+
|
|
2460
|
+
count.times do
|
|
2461
|
+
arg_spec.is_a?(Array) ? args.concat(arg_spec) : args << arg_spec
|
|
2462
|
+
end
|
|
2463
|
+
end
|
|
2464
|
+
|
|
2465
|
+
def normalize_flag_value!(value, definition)
|
|
2466
|
+
return 1 if value == true
|
|
2467
|
+
return 0 if value.nil? || value == false
|
|
2468
|
+
|
|
2469
|
+
option_name = definition[:aliases].first
|
|
2470
|
+
max_times = definition[:max_times]
|
|
2471
|
+
|
|
2472
|
+
raise_flag_type_boolean_error!(value, definition) if max_times.nil?
|
|
2473
|
+
|
|
2474
|
+
return normalize_flag_integer_value!(value, option_name, max_times) if value.is_a?(Integer)
|
|
2475
|
+
|
|
2476
|
+
raise ArgumentError, "Invalid value for :#{option_name}: expected true, false, or a positive Integer"
|
|
2477
|
+
end
|
|
2478
|
+
|
|
2479
|
+
def raise_flag_type_boolean_error!(value, definition)
|
|
2480
|
+
raise_flag_boolean_error!(definition[:aliases].first, value)
|
|
2481
|
+
end
|
|
2482
|
+
|
|
2483
|
+
def raise_flag_boolean_error!(option_name, value)
|
|
2484
|
+
raise ArgumentError,
|
|
2485
|
+
"flag_option :#{option_name} expects a boolean value, got #{value.inspect} (#{value.class})"
|
|
2486
|
+
end
|
|
2487
|
+
|
|
2488
|
+
def normalize_flag_integer_value!(value, option_name, max_times)
|
|
2489
|
+
raise ArgumentError, "Invalid value for :#{option_name}: expected a positive Integer" if value <= 0
|
|
2490
|
+
|
|
2491
|
+
raise_max_times_exceeded!(option_name, value, max_times) if value > max_times
|
|
2492
|
+
|
|
2493
|
+
value
|
|
2494
|
+
end
|
|
2495
|
+
|
|
2496
|
+
def raise_max_times_exceeded!(option_name, value, max_times)
|
|
2497
|
+
raise ArgumentError,
|
|
2498
|
+
"#{option_name}: #{value} exceeds max_times: #{max_times} for :#{option_name}"
|
|
2499
|
+
end
|
|
2500
|
+
|
|
2501
|
+
# Negate a flag by adding --no- prefix
|
|
2502
|
+
#
|
|
2503
|
+
# For short options (-f), expands to --no-f
|
|
2504
|
+
# For long options (--force), transforms to --no-force
|
|
2505
|
+
#
|
|
2506
|
+
# @param arg_spec [String] the argument specification
|
|
2507
|
+
# @return [String] the negated flag
|
|
2508
|
+
#
|
|
2509
|
+
def negate_flag(arg_spec)
|
|
2510
|
+
if short_option?(arg_spec)
|
|
2511
|
+
# -f => --no-f
|
|
2512
|
+
"--no-#{arg_spec[1]}"
|
|
2513
|
+
else
|
|
2514
|
+
# --force => --no-force
|
|
2515
|
+
arg_spec.sub(/\A--/, '--no-')
|
|
2516
|
+
end
|
|
2517
|
+
end
|
|
2518
|
+
|
|
2519
|
+
def should_skip_option?(value, definition)
|
|
2520
|
+
return true if value.nil?
|
|
2521
|
+
return true if value == false && %i[flag_or_inline_value flag_or_value].include?(definition[:type])
|
|
2522
|
+
return skip_value_as_operand_array?(value, definition) if value.is_a?(Array)
|
|
2523
|
+
|
|
2524
|
+
value.respond_to?(:empty?) && value.empty? && !definition[:allow_empty]
|
|
2525
|
+
end
|
|
2526
|
+
|
|
2527
|
+
# For value_as_operand, empty arrays always skip regardless of allow_empty
|
|
2528
|
+
# (allow_empty only applies to empty strings, not empty arrays)
|
|
2529
|
+
def skip_value_as_operand_array?(value, definition)
|
|
2530
|
+
return value.empty? if definition[:type] == :value_as_operand
|
|
2531
|
+
|
|
2532
|
+
value.empty? && !definition[:allow_empty]
|
|
2533
|
+
end
|
|
2534
|
+
|
|
2535
|
+
# Normalize key-value input to an array of [key, value] pairs
|
|
2536
|
+
#
|
|
2537
|
+
# Accepts:
|
|
2538
|
+
# - Hash: { 'key' => 'value' } or { 'key' => ['v1', 'v2'] }
|
|
2539
|
+
# - Array of arrays: [['key', 'value'], ['key2', 'value2']]
|
|
2540
|
+
# - Single array pair: ['key', 'value']
|
|
2541
|
+
#
|
|
2542
|
+
# @param value [Hash, Array] the input value
|
|
2543
|
+
# @return [Array<Array>] array of [key, value] pairs
|
|
2544
|
+
#
|
|
2545
|
+
def normalize_key_value_pairs(value)
|
|
2546
|
+
case value
|
|
2547
|
+
when Hash then normalize_hash_to_pairs(value)
|
|
2548
|
+
when Array then normalize_array_to_pairs(value)
|
|
2549
|
+
else
|
|
2550
|
+
raise ArgumentError,
|
|
2551
|
+
"key_value option must be a Hash or Array, got #{value.class}"
|
|
2552
|
+
end
|
|
2553
|
+
end
|
|
2554
|
+
|
|
2555
|
+
def normalize_hash_to_pairs(hash)
|
|
2556
|
+
hash.flat_map do |k, v|
|
|
2557
|
+
v.is_a?(Array) ? v.map { |val| [k, val] } : [[k, v]]
|
|
2558
|
+
end
|
|
2559
|
+
end
|
|
2560
|
+
|
|
2561
|
+
def normalize_array_to_pairs(array)
|
|
2562
|
+
# Check if it's a single [key, value] pair or array of pairs
|
|
2563
|
+
if array.size == 2 && !array.first.is_a?(Array)
|
|
2564
|
+
[array]
|
|
2565
|
+
elsif array.any? { |e| !e.is_a?(Array) }
|
|
2566
|
+
# Flat array with non-pair elements (e.g., ['a', 'b', 'c'])
|
|
2567
|
+
raise ArgumentError, 'key_value array input must be a [key, value] pair or array of pairs'
|
|
2568
|
+
else
|
|
2569
|
+
array
|
|
2570
|
+
end
|
|
2571
|
+
end
|
|
2572
|
+
|
|
2573
|
+
# Validate that a key-value pair array has at most 2 elements
|
|
2574
|
+
#
|
|
2575
|
+
# @param pair [Array] the pair to validate
|
|
2576
|
+
# @param option_name [Symbol] the option name for error messages
|
|
2577
|
+
# @raise [ArgumentError] if pair has more than 2 elements
|
|
2578
|
+
#
|
|
2579
|
+
def validate_key_value_pair_size!(pair, option_name)
|
|
2580
|
+
return unless pair.is_a?(Array) && pair.size > 2
|
|
2581
|
+
|
|
2582
|
+
raise ArgumentError,
|
|
2583
|
+
"key_value :#{option_name} pair #{pair.inspect} has too many elements (expected [key, value])"
|
|
2584
|
+
end
|
|
2585
|
+
|
|
2586
|
+
# Validate a key for key_value options
|
|
2587
|
+
#
|
|
2588
|
+
# @param key [Object] the key to validate
|
|
2589
|
+
# @param separator [String] the key-value separator
|
|
2590
|
+
# @param option_name [Symbol] the option name for error messages
|
|
2591
|
+
# @raise [ArgumentError] if key is nil, empty, or contains the separator
|
|
2592
|
+
#
|
|
2593
|
+
def validate_key_value_key!(key, separator, option_name)
|
|
2594
|
+
key_str = key.to_s
|
|
2595
|
+
raise ArgumentError, "key_value :#{option_name} requires a non-empty key" if key.nil? || key_str.empty?
|
|
2596
|
+
|
|
2597
|
+
return unless key_str.include?(separator)
|
|
2598
|
+
|
|
2599
|
+
raise ArgumentError,
|
|
2600
|
+
"key_value :#{option_name} key #{key_str.inspect} cannot contain the separator #{separator.inspect}"
|
|
2601
|
+
end
|
|
2602
|
+
|
|
2603
|
+
# Validate a value for key_value options
|
|
2604
|
+
#
|
|
2605
|
+
# @param value [Object] the value to validate
|
|
2606
|
+
# @param option_name [Symbol] the option name for error messages
|
|
2607
|
+
# @raise [ArgumentError] if value is a Hash or Array (non-scalar)
|
|
2608
|
+
#
|
|
2609
|
+
def validate_key_value_value!(value, option_name)
|
|
2610
|
+
return if value.nil?
|
|
2611
|
+
return unless value.is_a?(Hash) || value.is_a?(Array)
|
|
2612
|
+
|
|
2613
|
+
raise ArgumentError,
|
|
2614
|
+
"key_value :#{option_name} value must be a scalar (String, Symbol, Numeric, nil), " \
|
|
2615
|
+
"got #{value.class}: #{value.inspect}"
|
|
2616
|
+
end
|
|
2617
|
+
|
|
2618
|
+
def normalize_positionals(positionals)
|
|
2619
|
+
# Flatten if first element is an array (allows both splat and array syntax)
|
|
2620
|
+
positionals = positionals.first if positionals.size == 1 && positionals.first.is_a?(Array)
|
|
2621
|
+
Array(positionals)
|
|
2622
|
+
end
|
|
2623
|
+
|
|
2624
|
+
# Allocate positional arguments to definitions following Ruby semantics
|
|
2625
|
+
# Returns [allocation_hash, consumed_count] where consumed_count is the
|
|
2626
|
+
# number of non-nil positionals that were consumed by definitions.
|
|
2627
|
+
def allocate_positionals(positionals)
|
|
2628
|
+
OperandAllocator.new(@operand_definitions).allocate(positionals)
|
|
2629
|
+
end
|
|
2630
|
+
|
|
2631
|
+
def append_positional_to_args(args, value, definition)
|
|
2632
|
+
return if positional_value_empty?(value, definition)
|
|
2633
|
+
|
|
2634
|
+
append_positional_value(args, value, definition[:repeatable])
|
|
2635
|
+
end
|
|
2636
|
+
|
|
2637
|
+
def positional_value_empty?(value, definition)
|
|
2638
|
+
return true if value.nil?
|
|
2639
|
+
|
|
2640
|
+
definition[:repeatable] && value.respond_to?(:empty?) && value.empty?
|
|
2641
|
+
end
|
|
2642
|
+
|
|
2643
|
+
def append_positional_value(args, value, repeatable)
|
|
2644
|
+
if repeatable
|
|
2645
|
+
args.concat(Array(value).map(&:to_s))
|
|
2646
|
+
else
|
|
2647
|
+
args << value.to_s
|
|
2648
|
+
end
|
|
2649
|
+
end
|
|
2650
|
+
|
|
2651
|
+
def check_unexpected_positionals(positionals, consumed_count)
|
|
2652
|
+
provided_count = positionals.compact.size
|
|
2653
|
+
|
|
2654
|
+
return if provided_count <= consumed_count
|
|
2655
|
+
|
|
2656
|
+
unexpected_count = provided_count - consumed_count
|
|
2657
|
+
unexpected = positionals.compact.last(unexpected_count)
|
|
2658
|
+
raise ArgumentError, "Unexpected positional arguments: #{unexpected.join(', ')}"
|
|
2659
|
+
end
|
|
2660
|
+
|
|
2661
|
+
def validate_required_positional(value, definition)
|
|
2662
|
+
return unless definition[:required]
|
|
2663
|
+
return if definition[:allow_nil] && value.nil?
|
|
2664
|
+
return unless value_empty?(value)
|
|
2665
|
+
|
|
2666
|
+
raise ArgumentError, "at least one value is required for #{definition[:name]}" if definition[:repeatable]
|
|
2667
|
+
|
|
2668
|
+
raise ArgumentError, "#{definition[:name]} is required"
|
|
2669
|
+
end
|
|
2670
|
+
|
|
2671
|
+
def validate_no_nil_values!(value, definition)
|
|
2672
|
+
return unless definition[:repeatable]
|
|
2673
|
+
return if value.nil? # Allow nil as "not provided"
|
|
2674
|
+
|
|
2675
|
+
# For repeatable positionals, check if array contains any nil values
|
|
2676
|
+
values = Array(value)
|
|
2677
|
+
return unless values.any?(&:nil?)
|
|
2678
|
+
|
|
2679
|
+
raise ArgumentError, "nil values are not allowed in repeatable positional argument: #{definition[:name]}"
|
|
2680
|
+
end
|
|
2681
|
+
|
|
2682
|
+
# Reject operand values that look like command-line options
|
|
2683
|
+
#
|
|
2684
|
+
# Operands appearing before a '--' separator boundary (or all operands
|
|
2685
|
+
# if no boundary exists) are validated to ensure they don't start with
|
|
2686
|
+
# a hyphen, which could be misinterpreted as a git option.
|
|
2687
|
+
#
|
|
2688
|
+
# @param allocation [Hash{Symbol => Object}] the allocated operand values
|
|
2689
|
+
# @raise [ArgumentError] if any pre-separator operand value starts with '-'
|
|
2690
|
+
#
|
|
2691
|
+
def validate_no_option_like_operands!(allocation)
|
|
2692
|
+
pre_separator_operands = operand_names_before_separator
|
|
2693
|
+
pre_separator_operands.each do |name|
|
|
2694
|
+
value = allocation[name]
|
|
2695
|
+
check_operand_not_option_like(name, value)
|
|
2696
|
+
end
|
|
2697
|
+
end
|
|
2698
|
+
|
|
2699
|
+
# Determine which operands appear before any '--' separator boundary
|
|
2700
|
+
#
|
|
2701
|
+
# Walks the ordered definitions and collects operand names until hitting
|
|
2702
|
+
# a `literal '--'` or an `end_of_options` declaration. All operands after
|
|
2703
|
+
# any such boundary are excluded from option-like validation.
|
|
2704
|
+
#
|
|
2705
|
+
# @return [Array<Symbol>] operand names that need option-like validation
|
|
2706
|
+
#
|
|
2707
|
+
def operand_names_before_separator
|
|
2708
|
+
names = []
|
|
2709
|
+
@ordered_definitions.each do |defn|
|
|
2710
|
+
break if separator_boundary_active?(defn)
|
|
2711
|
+
|
|
2712
|
+
names << defn[:name] if defn[:kind] == :operand && !operand_skip_cli?(defn[:name])
|
|
2713
|
+
end
|
|
2714
|
+
names
|
|
2715
|
+
end
|
|
2716
|
+
|
|
2717
|
+
# Check if an operand is configured with skip_cli: true
|
|
2718
|
+
#
|
|
2719
|
+
# @param name [Symbol] the operand name
|
|
2720
|
+
# @return [Boolean] true if operand has skip_cli enabled
|
|
2721
|
+
#
|
|
2722
|
+
def operand_skip_cli?(name)
|
|
2723
|
+
operand_def = @operand_definitions.find { |d| d[:name] == name }
|
|
2724
|
+
operand_def[:skip_cli] == true
|
|
2725
|
+
end
|
|
2726
|
+
|
|
2727
|
+
# Check if a definition represents an active '--' separator boundary
|
|
2728
|
+
#
|
|
2729
|
+
# A `literal '--'` is always active. An `end_of_options` entry is also always active,
|
|
2730
|
+
# even when its runtime `--` may be suppressed by {#resolve_end_of_options_marker}.
|
|
2731
|
+
#
|
|
2732
|
+
# @param defn [Hash] a definition entry from @ordered_definitions
|
|
2733
|
+
# @return [Boolean] true if this definition is an active '--' boundary
|
|
2734
|
+
#
|
|
2735
|
+
def separator_boundary_active?(defn)
|
|
2736
|
+
return true if literal_separator_flag?(defn)
|
|
2737
|
+
return true if defn[:kind] == :end_of_options
|
|
2738
|
+
|
|
2739
|
+
false
|
|
2740
|
+
end
|
|
2741
|
+
|
|
2742
|
+
# Check if a definition is a literal '--' static flag
|
|
2743
|
+
#
|
|
2744
|
+
# @param defn [Hash] the entry definition
|
|
2745
|
+
# @return [Boolean]
|
|
2746
|
+
#
|
|
2747
|
+
def literal_separator_flag?(defn)
|
|
2748
|
+
defn[:kind] == :static && defn[:flag] == '--'
|
|
2749
|
+
end
|
|
2750
|
+
|
|
2751
|
+
# Check that a single operand value does not look like a command-line option
|
|
2752
|
+
#
|
|
2753
|
+
# @param name [Symbol] the operand name
|
|
2754
|
+
# @param value [Object] the operand value
|
|
2755
|
+
# @raise [ArgumentError] if the value starts with '-'
|
|
2756
|
+
#
|
|
2757
|
+
def check_operand_not_option_like(name, value)
|
|
2758
|
+
case value
|
|
2759
|
+
when String
|
|
2760
|
+
raise_option_like_error(name, value) if value.start_with?('-')
|
|
2761
|
+
when Array
|
|
2762
|
+
raise_option_like_array_error(name, value)
|
|
2763
|
+
end
|
|
2764
|
+
end
|
|
2765
|
+
|
|
2766
|
+
# @raise [ArgumentError] if the string value starts with '-'
|
|
2767
|
+
def raise_option_like_error(name, value)
|
|
2768
|
+
raise ArgumentError, "operand :#{name} value '#{value}' looks like a command-line option"
|
|
2769
|
+
end
|
|
2770
|
+
|
|
2771
|
+
# @raise [ArgumentError] if any array element starts with '-'
|
|
2772
|
+
def raise_option_like_array_error(name, values)
|
|
2773
|
+
invalid = values.select { |v| v.is_a?(String) && v.start_with?('-') }
|
|
2774
|
+
return if invalid.empty?
|
|
2775
|
+
|
|
2776
|
+
raise ArgumentError,
|
|
2777
|
+
"operand :#{name} contains option-like values: #{invalid.map { |v| "'#{v}'" }.join(', ')}"
|
|
2778
|
+
end
|
|
2779
|
+
|
|
2780
|
+
# Check if a positional value is empty (not provided)
|
|
2781
|
+
#
|
|
2782
|
+
# Only nil means "not provided" for positionals. Empty strings and empty
|
|
2783
|
+
# arrays are valid values that should be passed through.
|
|
2784
|
+
#
|
|
2785
|
+
# @param value [Object] the value to check
|
|
2786
|
+
# @return [Boolean] true if the value is nil
|
|
2787
|
+
#
|
|
2788
|
+
def value_empty?(value)
|
|
2789
|
+
value.nil?
|
|
2790
|
+
end
|
|
2791
|
+
|
|
2792
|
+
def validate_unsupported_options!(opts)
|
|
2793
|
+
unsupported = opts.keys - @alias_map.keys
|
|
2794
|
+
return if unsupported.empty?
|
|
2795
|
+
|
|
2796
|
+
raise ArgumentError, "Unsupported options: #{unsupported.map(&:inspect).join(', ')}"
|
|
2797
|
+
end
|
|
2798
|
+
|
|
2799
|
+
def validate_conflicting_aliases!(opts)
|
|
2800
|
+
@option_definitions.each_value do |definition|
|
|
2801
|
+
aliases = definition[:aliases]
|
|
2802
|
+
next unless aliases.size > 1
|
|
2803
|
+
|
|
2804
|
+
provided = aliases & opts.keys
|
|
2805
|
+
next unless provided.size > 1
|
|
2806
|
+
|
|
2807
|
+
raise ArgumentError, "Conflicting options: #{provided.map(&:inspect).join(' and ')}"
|
|
2808
|
+
end
|
|
2809
|
+
end
|
|
2810
|
+
|
|
2811
|
+
def normalize_aliases(opts)
|
|
2812
|
+
opts.transform_keys { |key| @alias_map[key] || key }
|
|
2813
|
+
end
|
|
2814
|
+
|
|
2815
|
+
def validate_required_options!(opts)
|
|
2816
|
+
missing, nil_not_allowed = collect_required_option_errors(opts)
|
|
2817
|
+
raise_missing_options_error(missing) if missing.any?
|
|
2818
|
+
raise_nil_options_error(nil_not_allowed) if nil_not_allowed.any?
|
|
2819
|
+
end
|
|
2820
|
+
|
|
2821
|
+
def collect_required_option_errors(opts)
|
|
2822
|
+
missing = []
|
|
2823
|
+
nil_not_allowed = []
|
|
2824
|
+
@option_definitions.each do |name, definition|
|
|
2825
|
+
next unless definition[:required]
|
|
2826
|
+
|
|
2827
|
+
missing << name unless opts.key?(name)
|
|
2828
|
+
nil_not_allowed << name if opts.key?(name) && opts[name].nil? && definition[:allow_nil] == false
|
|
2829
|
+
end
|
|
2830
|
+
[missing, nil_not_allowed]
|
|
2831
|
+
end
|
|
2832
|
+
|
|
2833
|
+
def raise_missing_options_error(missing)
|
|
2834
|
+
raise ArgumentError, "Required options not provided: #{missing.map(&:inspect).join(', ')}"
|
|
2835
|
+
end
|
|
2836
|
+
|
|
2837
|
+
def raise_nil_options_error(nil_not_allowed)
|
|
2838
|
+
raise ArgumentError, "Required options cannot be nil: #{nil_not_allowed.map(&:inspect).join(', ')}"
|
|
2839
|
+
end
|
|
2840
|
+
|
|
2841
|
+
def validate_option_values!(opts)
|
|
2842
|
+
@option_definitions.each do |name, definition|
|
|
2843
|
+
next unless opts.key?(name)
|
|
2844
|
+
|
|
2845
|
+
validate_single_option!(name, opts[name], definition)
|
|
2846
|
+
end
|
|
2847
|
+
end
|
|
2848
|
+
|
|
2849
|
+
def validate_single_option!(name, value, definition)
|
|
2850
|
+
run_validator!(name, value, definition[:validator]) if definition[:validator]
|
|
2851
|
+
check_allowed_values!(name, value, definition) if definition[:allowed_values]
|
|
2852
|
+
end
|
|
2853
|
+
|
|
2854
|
+
def run_validator!(name, value, validator)
|
|
2855
|
+
result = validator.call(value)
|
|
2856
|
+
return if result == true
|
|
2857
|
+
|
|
2858
|
+
error_msg = result.is_a?(String) ? result : "Invalid value for option: #{name}"
|
|
2859
|
+
raise ArgumentError, error_msg
|
|
2860
|
+
end
|
|
2861
|
+
|
|
2862
|
+
def check_allowed_values!(name, value, definition)
|
|
2863
|
+
allowed = definition[:allowed_values]
|
|
2864
|
+
type = definition[:type]
|
|
2865
|
+
if definition[:repeatable]
|
|
2866
|
+
check_repeatable_allowed_values!(name, value, allowed, definition[:allow_empty], type)
|
|
2867
|
+
else
|
|
2868
|
+
check_single_allowed_value!(name, value, allowed, definition[:allow_empty], type)
|
|
2869
|
+
end
|
|
2870
|
+
end
|
|
2871
|
+
|
|
2872
|
+
def coerce_allowed_values_set!(sym, values)
|
|
2873
|
+
unless values.respond_to?(:map)
|
|
2874
|
+
raise ArgumentError,
|
|
2875
|
+
"allowed_values :#{sym} expects an Enumerable for `in:`, got #{values.class}"
|
|
2876
|
+
end
|
|
2877
|
+
arr = values.map(&:to_s)
|
|
2878
|
+
raise ArgumentError, "allowed_values :#{sym} must specify at least one allowed value" if arr.empty?
|
|
2879
|
+
|
|
2880
|
+
arr.freeze
|
|
2881
|
+
end
|
|
2882
|
+
|
|
2883
|
+
def validate_allowed_values_definition!(sym)
|
|
2884
|
+
primary = @alias_map[sym]
|
|
2885
|
+
defn = primary && @option_definitions[primary]
|
|
2886
|
+
unless defn
|
|
2887
|
+
raise ArgumentError, ":#{sym} is not a value option" if @operand_definitions.any? { |d| d[:name] == sym }
|
|
2888
|
+
|
|
2889
|
+
raise ArgumentError, "unknown argument :#{sym} in allowed_values declaration"
|
|
2890
|
+
end
|
|
2891
|
+
unless VALUE_OPTION_TYPES_FOR_ALLOWED_VALUES.include?(defn[:type])
|
|
2892
|
+
raise ArgumentError, ":#{sym} is not a value option"
|
|
2893
|
+
end
|
|
2894
|
+
|
|
2895
|
+
defn
|
|
2896
|
+
end
|
|
2897
|
+
|
|
2898
|
+
def check_repeatable_allowed_values!(name, values, allowed, allow_empty, type)
|
|
2899
|
+
Array(values).each do |v|
|
|
2900
|
+
next if skip_allowed_values_check?(v, allow_empty, type)
|
|
2901
|
+
|
|
2902
|
+
unless allowed.include?(v.to_s)
|
|
2903
|
+
raise ArgumentError,
|
|
2904
|
+
"Invalid value for :#{name}: expected one of #{allowed.inspect}, got #{v.inspect}"
|
|
2905
|
+
end
|
|
2906
|
+
end
|
|
2907
|
+
end
|
|
2908
|
+
|
|
2909
|
+
def check_single_allowed_value!(name, value, allowed, allow_empty, type)
|
|
2910
|
+
return if skip_allowed_values_check?(value, allow_empty, type)
|
|
2911
|
+
|
|
2912
|
+
return if allowed.include?(value.to_s)
|
|
2913
|
+
|
|
2914
|
+
raise ArgumentError,
|
|
2915
|
+
"Invalid value for :#{name}: expected one of #{allowed.inspect}, got #{value.inspect}"
|
|
2916
|
+
end
|
|
2917
|
+
|
|
2918
|
+
def skip_allowed_values_check?(value, allow_empty, type)
|
|
2919
|
+
return true if value.nil?
|
|
2920
|
+
# Only skip boolean values for flag_or_value option types where true/false carry
|
|
2921
|
+
# semantic meaning (true = emit flag, false = suppress flag). For plain value
|
|
2922
|
+
# options, a boolean is an invalid value and should fail the allowed_values check.
|
|
2923
|
+
return true if [true, false].include?(value) && FLAG_OR_VALUE_OPTION_TYPES.include?(type)
|
|
2924
|
+
return true if value.to_s.empty? && allow_empty
|
|
2925
|
+
|
|
2926
|
+
false
|
|
2927
|
+
end
|
|
2928
|
+
|
|
2929
|
+
def create_type_validator(option_name, expected_type)
|
|
2930
|
+
types = Array(expected_type)
|
|
2931
|
+
|
|
2932
|
+
lambda do |value|
|
|
2933
|
+
return true if value.nil? # nil values are universally skipped by should_skip_option?
|
|
2934
|
+
return true if types.any? { |t| value.is_a?(t) }
|
|
2935
|
+
|
|
2936
|
+
# Generate a helpful error message
|
|
2937
|
+
type_names = types.map(&:name).join(' or ')
|
|
2938
|
+
actual_type = value.class.name
|
|
2939
|
+
"The :#{option_name} option must be a #{type_names}, but was a #{actual_type}"
|
|
2940
|
+
end
|
|
2941
|
+
end
|
|
2942
|
+
|
|
2943
|
+
def validate_conflicts!(opts, allocated_positionals = {})
|
|
2944
|
+
@conflicts.each do |conflict_group|
|
|
2945
|
+
provided = conflict_group.select { |name| conflict_present?(name, opts, allocated_positionals) }
|
|
2946
|
+
next if provided.size <= 1
|
|
2947
|
+
|
|
2948
|
+
formatted = provided.map { |name| ":#{name}" }.join(' and ')
|
|
2949
|
+
raise ArgumentError, "cannot specify #{formatted}"
|
|
2950
|
+
end
|
|
2951
|
+
end
|
|
2952
|
+
|
|
2953
|
+
# Return true if a named argument should be counted as present during conflict checking
|
|
2954
|
+
#
|
|
2955
|
+
# For registered keyword options only looks in opts; positional slots use
|
|
2956
|
+
# allocated_positionals. This prevents a positional operand that shares a
|
|
2957
|
+
# name with a keyword option from spuriously triggering keyword conflicts.
|
|
2958
|
+
def conflict_present?(name, opts, allocated_positionals)
|
|
2959
|
+
canonical_name = @alias_map[name] || name
|
|
2960
|
+
value = if @option_definitions.key?(canonical_name)
|
|
2961
|
+
opts[canonical_name]
|
|
2962
|
+
else
|
|
2963
|
+
allocated_positionals[canonical_name]
|
|
2964
|
+
end
|
|
2965
|
+
argument_present?(value)
|
|
2966
|
+
end
|
|
2967
|
+
|
|
2968
|
+
# Validate that no bound values match a forbidden exact-value tuple
|
|
2969
|
+
#
|
|
2970
|
+
# @param opts [Hash] normalized keyword options (aliases already resolved)
|
|
2971
|
+
# @param allocated_positionals [Hash] the allocated positional values
|
|
2972
|
+
# @raise [ArgumentError] if all names in a forbidden tuple are present with
|
|
2973
|
+
# their declared values
|
|
2974
|
+
#
|
|
2975
|
+
def validate_forbidden_values!(opts, allocated_positionals = {})
|
|
2976
|
+
@forbidden_values.each do |tuple|
|
|
2977
|
+
next unless forbidden_tuple_matches?(tuple, opts, allocated_positionals)
|
|
2978
|
+
|
|
2979
|
+
formatted = tuple.map { |name, value| ":#{name}=#{value.inspect}" }.join(' with ')
|
|
2980
|
+
raise ArgumentError, "cannot specify #{formatted}"
|
|
2981
|
+
end
|
|
2982
|
+
end
|
|
2983
|
+
|
|
2984
|
+
# Return true if every name in the tuple has a bound value equal to the
|
|
2985
|
+
# declared forbidden value.
|
|
2986
|
+
#
|
|
2987
|
+
# The check only fires when the key is actually present (bound) — an absent
|
|
2988
|
+
# key never triggers a forbidden-values match.
|
|
2989
|
+
#
|
|
2990
|
+
# @param tuple [Hash{Symbol => Object}] canonical name → forbidden value
|
|
2991
|
+
# @param opts [Hash] normalized keyword options
|
|
2992
|
+
# @param allocated_positionals [Hash] the allocated positional values
|
|
2993
|
+
# @return [Boolean]
|
|
2994
|
+
#
|
|
2995
|
+
def forbidden_tuple_matches?(tuple, opts, allocated_positionals)
|
|
2996
|
+
tuple.all? do |name, forbidden_value|
|
|
2997
|
+
if opts.key?(name)
|
|
2998
|
+
opts[name] == forbidden_value
|
|
2999
|
+
elsif allocated_positionals.key?(name)
|
|
3000
|
+
allocated_positionals[name] == forbidden_value
|
|
3001
|
+
else
|
|
3002
|
+
false
|
|
3003
|
+
end
|
|
3004
|
+
end
|
|
3005
|
+
end
|
|
3006
|
+
|
|
3007
|
+
# Validate conditional and unconditional requires_one_of groups
|
|
3008
|
+
#
|
|
3009
|
+
# Each entry in @requires_one_of is a Hash with keys:
|
|
3010
|
+
# :names — Array of canonical argument names that must collectively satisfy
|
|
3011
|
+
# the at-least-one constraint
|
|
3012
|
+
# :condition — canonical trigger name (Symbol), or nil for unconditional groups
|
|
3013
|
+
# :single — true when declared via `requires` (affects error message wording)
|
|
3014
|
+
#
|
|
3015
|
+
# @param opts [Hash] normalized keyword options (aliases already resolved)
|
|
3016
|
+
# @param allocated_positionals [Hash] the allocated positional values
|
|
3017
|
+
# @raise [ArgumentError] if none of the arguments in any applicable group is present
|
|
3018
|
+
#
|
|
3019
|
+
def validate_requires_one_of!(opts, allocated_positionals = {})
|
|
3020
|
+
@requires_one_of.each do |entry|
|
|
3021
|
+
validate_requires_one_of_entry!(entry, opts, allocated_positionals)
|
|
3022
|
+
end
|
|
3023
|
+
end
|
|
3024
|
+
|
|
3025
|
+
# Validate a single requires_one_of entry
|
|
3026
|
+
#
|
|
3027
|
+
# @param entry [Hash] the group entry with :names, :condition, :single keys
|
|
3028
|
+
# @param opts [Hash] normalized keyword options
|
|
3029
|
+
# @param allocated_positionals [Hash] the allocated positional values
|
|
3030
|
+
#
|
|
3031
|
+
def validate_requires_one_of_entry!(entry, opts, allocated_positionals)
|
|
3032
|
+
condition = entry[:condition]
|
|
3033
|
+
|
|
3034
|
+
return if condition && !conflict_present?(condition, opts, allocated_positionals)
|
|
3035
|
+
|
|
3036
|
+
names = entry[:names]
|
|
3037
|
+
return if names.any? { |n| conflict_present?(n, opts, allocated_positionals) }
|
|
3038
|
+
|
|
3039
|
+
raise ArgumentError, requires_one_of_error_message(names, condition, entry[:single])
|
|
3040
|
+
end
|
|
3041
|
+
|
|
3042
|
+
# Build the error message for a failed requires_one_of check
|
|
3043
|
+
#
|
|
3044
|
+
# @param names [Array<Symbol>] the required argument names
|
|
3045
|
+
# @param condition [Symbol, nil] the trigger argument name, or nil for unconditional
|
|
3046
|
+
# @param single [Boolean] true when declared via `requires` (single required arg)
|
|
3047
|
+
# @return [String] the error message
|
|
3048
|
+
#
|
|
3049
|
+
def requires_one_of_error_message(names, condition, single)
|
|
3050
|
+
formatted = names.map { |name| ":#{name}" }.join(', ')
|
|
3051
|
+
return "at least one of #{formatted} must be provided" unless condition
|
|
3052
|
+
return ":#{condition} requires #{formatted}" if single
|
|
3053
|
+
|
|
3054
|
+
":#{condition} requires at least one of #{formatted}"
|
|
3055
|
+
end
|
|
3056
|
+
|
|
3057
|
+
# Validate a single name used in a requires_one_of declaration
|
|
3058
|
+
#
|
|
3059
|
+
# @param sym [Symbol] the name to validate
|
|
3060
|
+
# @raise [ArgumentError] if sym is not a known option or operand
|
|
3061
|
+
#
|
|
3062
|
+
def validate_requires_one_of_name!(sym)
|
|
3063
|
+
raise ArgumentError, "unknown argument :#{sym} in requires_one_of declaration" unless known_argument?(sym)
|
|
3064
|
+
end
|
|
3065
|
+
|
|
3066
|
+
# Validate a single name used in a requires or conditional requires_one_of declaration
|
|
3067
|
+
#
|
|
3068
|
+
# @param sym [Symbol] the name to validate
|
|
3069
|
+
# @raise [ArgumentError] if sym is not a known option or operand
|
|
3070
|
+
#
|
|
3071
|
+
def validate_requires_name!(sym)
|
|
3072
|
+
raise ArgumentError, "unknown argument :#{sym} in requires declaration" unless known_argument?(sym)
|
|
3073
|
+
end
|
|
3074
|
+
|
|
3075
|
+
# Canonicalize an array of argument names for a requires_one_of group
|
|
3076
|
+
#
|
|
3077
|
+
# Validates each name, resolves aliases to their primary name, and deduplicates.
|
|
3078
|
+
# For options, canonical name comes from alias_map; for positional-only operands
|
|
3079
|
+
# the name is used directly.
|
|
3080
|
+
#
|
|
3081
|
+
# @param names [Array<Symbol, String>] raw argument names
|
|
3082
|
+
# @return [Array<Symbol>] canonical, deduplicated names
|
|
3083
|
+
#
|
|
3084
|
+
def canonicalize_requires_names(names)
|
|
3085
|
+
names.map do |name|
|
|
3086
|
+
sym = name.to_sym
|
|
3087
|
+
validate_requires_one_of_name!(sym)
|
|
3088
|
+
@alias_map[sym] || sym
|
|
3089
|
+
end.uniq
|
|
3090
|
+
end
|
|
3091
|
+
|
|
3092
|
+
# Validate and canonicalize the `when:` condition for requires/requires_one_of
|
|
3093
|
+
#
|
|
3094
|
+
# @param condition [Symbol, nil] the raw trigger argument name
|
|
3095
|
+
# @return [Symbol, nil] canonical trigger name, or nil when condition is nil
|
|
3096
|
+
#
|
|
3097
|
+
def resolve_requires_condition(condition)
|
|
3098
|
+
return nil unless condition
|
|
3099
|
+
|
|
3100
|
+
trigger_sym = condition.to_sym
|
|
3101
|
+
validate_requires_name!(trigger_sym)
|
|
3102
|
+
@alias_map[trigger_sym] || trigger_sym
|
|
3103
|
+
end
|
|
3104
|
+
|
|
3105
|
+
# Return true if the given name refers to a defined option or operand
|
|
3106
|
+
#
|
|
3107
|
+
# @param name [Symbol] the argument name to look up
|
|
3108
|
+
# @return [Boolean]
|
|
3109
|
+
def known_argument?(name)
|
|
3110
|
+
@alias_map.key?(name) || @operand_definitions.any? { |d| d[:name] == name }
|
|
3111
|
+
end
|
|
3112
|
+
|
|
3113
|
+
# Return true if a conflict-group value should be considered "present"
|
|
3114
|
+
#
|
|
3115
|
+
# A value is absent (not present) when it is nil, false, an empty array,
|
|
3116
|
+
# or an empty string. All other values — including non-empty arrays — are
|
|
3117
|
+
# present, regardless of their contents. This keeps validation consistent
|
|
3118
|
+
# with CLI emission: repeatable options (value_option, inline_value, etc.)
|
|
3119
|
+
# emit tokens for non-empty arrays even when every element is '' or false.
|
|
3120
|
+
#
|
|
3121
|
+
# @param value [Object] the argument value to test
|
|
3122
|
+
# @return [Boolean]
|
|
3123
|
+
def argument_present?(value)
|
|
3124
|
+
return false if value.nil?
|
|
3125
|
+
return false if value == false
|
|
3126
|
+
return false if value == []
|
|
3127
|
+
return false if value == ''
|
|
3128
|
+
|
|
3129
|
+
true
|
|
3130
|
+
end
|
|
3131
|
+
|
|
3132
|
+
# Bound arguments object returned by {Arguments#bind}
|
|
3133
|
+
#
|
|
3134
|
+
# Provides accessor methods for all defined options and positional arguments,
|
|
3135
|
+
# with automatic normalization of aliases to their canonical names.
|
|
3136
|
+
#
|
|
3137
|
+
# For every `flag_option`, both a plain accessor (e.g. `bound.force`) and a
|
|
3138
|
+
# `?`-suffixed predicate alias (e.g. `bound.force?`) are generated, following
|
|
3139
|
+
# Ruby convention for boolean predicates. Plain accessors are kept for backward
|
|
3140
|
+
# compatibility. `value_option` fields only receive plain accessors.
|
|
3141
|
+
#
|
|
3142
|
+
# **Reserved-name exception:** if the `?`-suffixed name conflicts with a name
|
|
3143
|
+
# in {RESERVED_NAMES} (e.g. `nil?`, `frozen?`), the predicate alias is *not*
|
|
3144
|
+
# generated to avoid overriding built-in `Object` methods. Use hash-style
|
|
3145
|
+
# access (`bound[:nil]`) when the flag name is reserved.
|
|
3146
|
+
#
|
|
3147
|
+
# @api private
|
|
3148
|
+
#
|
|
3149
|
+
# @example Accessing bound arguments
|
|
3150
|
+
# args_def = Arguments.define do
|
|
3151
|
+
# flag_option :force
|
|
3152
|
+
# flag_option :remotes, as: ['-r', '--remotes']
|
|
3153
|
+
# operand :branch_names, repeatable: true
|
|
3154
|
+
# end
|
|
3155
|
+
# bound = args_def.bind('branch1', 'branch2', force: true, remotes: true)
|
|
3156
|
+
# bound.force # => true
|
|
3157
|
+
# bound.force? # => true # ? alias for flag_option
|
|
3158
|
+
# bound.remotes # => true
|
|
3159
|
+
# bound.remotes? # => true # ? alias for flag_option
|
|
3160
|
+
# bound.branch_names # => ['branch1', 'branch2']
|
|
3161
|
+
#
|
|
3162
|
+
# @example Splatting for command execution
|
|
3163
|
+
# args_def = Arguments.define do
|
|
3164
|
+
# flag_option :force
|
|
3165
|
+
# operand :file
|
|
3166
|
+
# end
|
|
3167
|
+
# bound = args_def.bind('test.txt', force: true)
|
|
3168
|
+
# bound.to_a # => ['--force', 'test.txt']
|
|
3169
|
+
#
|
|
3170
|
+
# @example Hash-style access for reserved names
|
|
3171
|
+
# args_def = Arguments.define do
|
|
3172
|
+
# value_option :hash
|
|
3173
|
+
# end
|
|
3174
|
+
# bound = args_def.bind(hash: 'abc123')
|
|
3175
|
+
# bound[:hash] # => 'abc123'
|
|
3176
|
+
#
|
|
3177
|
+
class Bound
|
|
3178
|
+
# Names that cannot have accessor methods defined (would override Object methods)
|
|
3179
|
+
RESERVED_NAMES = (Object.instance_methods + [:to_ary]).freeze
|
|
3180
|
+
|
|
3181
|
+
# Canonical frozen empty hash returned by {#execution_options} when no
|
|
3182
|
+
# non-nil execution options are present.
|
|
3183
|
+
#
|
|
3184
|
+
# @return [Hash{Symbol => Object}]
|
|
3185
|
+
EMPTY_EXECUTION_OPTIONS = {}.freeze
|
|
3186
|
+
|
|
3187
|
+
# Execution options and values for command execution.
|
|
3188
|
+
#
|
|
3189
|
+
# Includes only options declared via {Arguments#execution_option} and
|
|
3190
|
+
# excludes options with nil values.
|
|
3191
|
+
#
|
|
3192
|
+
# @return [Hash{Symbol => Object}] frozen hash of execution option values
|
|
3193
|
+
# @!attribute [r] execution_options
|
|
3194
|
+
attr_reader :execution_options
|
|
3195
|
+
|
|
3196
|
+
# @param args_array [Array<String>] the CLI argument array (frozen)
|
|
3197
|
+
# @param options [Hash{Symbol => Object}] normalized options hash (frozen)
|
|
3198
|
+
# @param positionals [Hash{Symbol => Object}] positional arguments hash (frozen)
|
|
3199
|
+
# @param execution_option_names [Array<Symbol>] option names declared via {Arguments#execution_option}
|
|
3200
|
+
# @param flag_names [Array<Symbol>] option names declared via {Arguments#flag_option}
|
|
3201
|
+
#
|
|
3202
|
+
def initialize(args_array, options, positionals, execution_option_names = [], flag_names = [])
|
|
3203
|
+
@args_array = args_array.freeze
|
|
3204
|
+
@options = options.freeze
|
|
3205
|
+
@positionals = positionals.freeze
|
|
3206
|
+
@execution_options = build_execution_options(execution_option_names)
|
|
3207
|
+
|
|
3208
|
+
# Define accessor methods (skip reserved names)
|
|
3209
|
+
@options.each_key { |name| define_accessor(name, @options) }
|
|
3210
|
+
@positionals.each_key { |name| define_accessor(name, @positionals) }
|
|
3211
|
+
define_flag_predicate_accessors(flag_names)
|
|
3212
|
+
|
|
3213
|
+
freeze
|
|
3214
|
+
end
|
|
3215
|
+
|
|
3216
|
+
# Returns the CLI arguments array for splatting
|
|
3217
|
+
#
|
|
3218
|
+
# This enables direct splatting: `command(*bound_args)`.
|
|
3219
|
+
#
|
|
3220
|
+
# Operands declared with `skip_cli: true` are intentionally excluded.
|
|
3221
|
+
#
|
|
3222
|
+
# @return [Array<String>] the CLI arguments
|
|
3223
|
+
def to_ary
|
|
3224
|
+
@args_array
|
|
3225
|
+
end
|
|
3226
|
+
|
|
3227
|
+
# Returns the CLI arguments array for splatting
|
|
3228
|
+
#
|
|
3229
|
+
# Ruby's splat operator in array literals uses `to_a` for expansion.
|
|
3230
|
+
# This enables: `['git', 'branch', *bound_args]`.
|
|
3231
|
+
#
|
|
3232
|
+
# Operands declared with `skip_cli: true` are intentionally excluded.
|
|
3233
|
+
#
|
|
3234
|
+
# @return [Array<String>] the CLI arguments
|
|
3235
|
+
def to_a
|
|
3236
|
+
@args_array
|
|
3237
|
+
end
|
|
3238
|
+
|
|
3239
|
+
# Hash-style access to option and positional values
|
|
3240
|
+
#
|
|
3241
|
+
# Use this for reserved names (like :hash, :class) that cannot have
|
|
3242
|
+
# accessor methods defined.
|
|
3243
|
+
#
|
|
3244
|
+
# @param key [Symbol] the option or positional name
|
|
3245
|
+
# @return [Object, nil] the value, or nil if not found
|
|
3246
|
+
def [](key)
|
|
3247
|
+
return @options[key] if @options.key?(key)
|
|
3248
|
+
return @positionals[key] if @positionals.key?(key)
|
|
3249
|
+
|
|
3250
|
+
nil
|
|
3251
|
+
end
|
|
3252
|
+
|
|
3253
|
+
private
|
|
3254
|
+
|
|
3255
|
+
def build_execution_options(execution_option_names)
|
|
3256
|
+
result = execution_option_names.each_with_object({}) do |name, values|
|
|
3257
|
+
value = @options[name]
|
|
3258
|
+
values[name] = value unless value.nil?
|
|
3259
|
+
end
|
|
3260
|
+
|
|
3261
|
+
result.empty? ? EMPTY_EXECUTION_OPTIONS : result.freeze
|
|
3262
|
+
end
|
|
3263
|
+
|
|
3264
|
+
# Define an accessor method for the given name
|
|
3265
|
+
#
|
|
3266
|
+
# For `flag_option` names, a `?`-suffixed predicate alias is also defined
|
|
3267
|
+
# by {#initialize} after all plain accessors have been set up.
|
|
3268
|
+
#
|
|
3269
|
+
# @param name [Symbol] the option or positional name
|
|
3270
|
+
# @param source [Hash] the hash to read from (@options or @positionals)
|
|
3271
|
+
#
|
|
3272
|
+
def define_accessor(name, source)
|
|
3273
|
+
return if RESERVED_NAMES.include?(name)
|
|
3274
|
+
|
|
3275
|
+
define_singleton_method(name) { source[name] }
|
|
3276
|
+
end
|
|
3277
|
+
|
|
3278
|
+
# Define `?`-suffixed predicate aliases for each flag option
|
|
3279
|
+
#
|
|
3280
|
+
# Skips any name whose `?` form appears in {RESERVED_NAMES} and skips
|
|
3281
|
+
# names that are not present in the options hash.
|
|
3282
|
+
#
|
|
3283
|
+
# @param flag_names [Array<Symbol>] flag option names
|
|
3284
|
+
#
|
|
3285
|
+
def define_flag_predicate_accessors(flag_names)
|
|
3286
|
+
flag_names.each do |name|
|
|
3287
|
+
predicate_name = :"#{name}?"
|
|
3288
|
+
next if RESERVED_NAMES.include?(predicate_name)
|
|
3289
|
+
|
|
3290
|
+
define_singleton_method(predicate_name) { flag_predicate?(@options[name]) }
|
|
3291
|
+
end
|
|
3292
|
+
end
|
|
3293
|
+
|
|
3294
|
+
def flag_predicate?(value)
|
|
3295
|
+
return value.positive? if value.is_a?(Integer)
|
|
3296
|
+
|
|
3297
|
+
value == true
|
|
3298
|
+
end
|
|
3299
|
+
end
|
|
3300
|
+
end
|
|
3301
|
+
|
|
3302
|
+
# Allocates operand (positional argument) values to definitions following Ruby semantics.
|
|
3303
|
+
#
|
|
3304
|
+
# This class handles the complex logic of mapping positional values to their
|
|
3305
|
+
# definitions, supporting required, optional, and repeatable operands.
|
|
3306
|
+
#
|
|
3307
|
+
# @api private
|
|
3308
|
+
class OperandAllocator
|
|
3309
|
+
# @param definitions [Array<Hash>] operand definitions
|
|
3310
|
+
def initialize(definitions)
|
|
3311
|
+
@definitions = definitions
|
|
3312
|
+
end
|
|
3313
|
+
|
|
3314
|
+
# Allocate values to definitions
|
|
3315
|
+
# @param values [Array] the positional argument values
|
|
3316
|
+
# @return [Array(Hash, Integer)] [allocation_hash, consumed_count]
|
|
3317
|
+
def allocate(values)
|
|
3318
|
+
allocation = {}
|
|
3319
|
+
repeatable_index = @definitions.find_index { |d| d[:repeatable] }
|
|
3320
|
+
|
|
3321
|
+
consumed = if repeatable_index.nil?
|
|
3322
|
+
allocate_without_repeatable(values, allocation)
|
|
3323
|
+
else
|
|
3324
|
+
allocate_with_repeatable(values, allocation, repeatable_index)
|
|
3325
|
+
end
|
|
3326
|
+
|
|
3327
|
+
[allocation, consumed]
|
|
3328
|
+
end
|
|
3329
|
+
|
|
3330
|
+
private
|
|
3331
|
+
|
|
3332
|
+
# Allocate when there's no repeatable positional, following Ruby semantics:
|
|
3333
|
+
# - Required positionals at the END are reserved first
|
|
3334
|
+
# - Leading positionals get remaining values left-to-right
|
|
3335
|
+
# - Optional positionals are skipped when there aren't enough values
|
|
3336
|
+
def allocate_without_repeatable(values, allocation)
|
|
3337
|
+
trailing = count_trailing_required
|
|
3338
|
+
leading_defs = @definitions[0...(@definitions.size - trailing)]
|
|
3339
|
+
trailing_defs = @definitions[(@definitions.size - trailing)..]
|
|
3340
|
+
|
|
3341
|
+
values_for_leading = [values.size - trailing, 0].max
|
|
3342
|
+
leading_values = values[0...values_for_leading]
|
|
3343
|
+
trailing_values = values[values_for_leading..]
|
|
3344
|
+
|
|
3345
|
+
consumed = allocate_leading(allocation, leading_defs, leading_values)
|
|
3346
|
+
consumed + allocate_trailing(allocation, trailing_defs, trailing_values)
|
|
3347
|
+
end
|
|
3348
|
+
|
|
3349
|
+
def count_trailing_required
|
|
3350
|
+
count = 0
|
|
3351
|
+
@definitions.reverse_each do |d|
|
|
3352
|
+
break unless required?(d)
|
|
3353
|
+
|
|
3354
|
+
count += 1
|
|
3355
|
+
end
|
|
3356
|
+
count
|
|
3357
|
+
end
|
|
3358
|
+
|
|
3359
|
+
def required?(definition)
|
|
3360
|
+
definition[:required] && definition[:default].nil?
|
|
3361
|
+
end
|
|
3362
|
+
|
|
3363
|
+
# Allocate leading positionals (those before any trailing required)
|
|
3364
|
+
# Required positionals consume values; optional ones only consume if extras available
|
|
3365
|
+
def allocate_leading(allocation, definitions, values)
|
|
3366
|
+
return 0 if definitions.empty?
|
|
3367
|
+
|
|
3368
|
+
state = LeadingAllocationState.new(definitions, values, method(:required?))
|
|
3369
|
+
state.allocate(allocation)
|
|
3370
|
+
end
|
|
3371
|
+
|
|
3372
|
+
def allocate_trailing(allocation, definitions, values)
|
|
3373
|
+
consumed = 0
|
|
3374
|
+
definitions.each_with_index do |definition, index|
|
|
3375
|
+
allocation[definition[:name]] = index < values.size ? values[index] : definition[:default]
|
|
3376
|
+
consumed += 1 if index < values.size
|
|
3377
|
+
end
|
|
3378
|
+
consumed
|
|
3379
|
+
end
|
|
3380
|
+
|
|
3381
|
+
def allocate_with_repeatable(values, allocation, repeatable_index)
|
|
3382
|
+
parts = split_around_repeatable(repeatable_index)
|
|
3383
|
+
slices = calculate_repeatable_slices(values, parts)
|
|
3384
|
+
|
|
3385
|
+
pre_consumed = allocate_pre_repeatable_smart(allocation, parts[:pre], slices[:pre_values])
|
|
3386
|
+
repeatable_consumed = allocate_repeatable(
|
|
3387
|
+
allocation, parts[:repeatable], values, slices[:var_start], slices[:var_end]
|
|
3388
|
+
)
|
|
3389
|
+
post_consumed = allocate_post_repeatable(allocation, parts[:post], values, slices[:post_start])
|
|
3390
|
+
|
|
3391
|
+
pre_consumed + repeatable_consumed + post_consumed
|
|
3392
|
+
end
|
|
3393
|
+
|
|
3394
|
+
def calculate_repeatable_slices(values, parts)
|
|
3395
|
+
post_required_count = count_required(parts[:post])
|
|
3396
|
+
pre_available = [values.size - post_required_count, 0].max
|
|
3397
|
+
pre_end = [pre_available, parts[:pre].size].min
|
|
3398
|
+
post_start = [values.size - parts[:post].size, pre_end].max
|
|
3399
|
+
|
|
3400
|
+
{
|
|
3401
|
+
pre_values: values[0...pre_end],
|
|
3402
|
+
var_start: pre_end,
|
|
3403
|
+
var_end: post_start,
|
|
3404
|
+
post_start: post_start
|
|
3405
|
+
}
|
|
3406
|
+
end
|
|
3407
|
+
|
|
3408
|
+
def count_required(definitions)
|
|
3409
|
+
definitions.count { |d| required?(d) }
|
|
3410
|
+
end
|
|
3411
|
+
|
|
3412
|
+
def split_around_repeatable(repeatable_index)
|
|
3413
|
+
{
|
|
3414
|
+
pre: @definitions[0...repeatable_index],
|
|
3415
|
+
repeatable: @definitions[repeatable_index],
|
|
3416
|
+
post: @definitions[(repeatable_index + 1)..]
|
|
3417
|
+
}
|
|
3418
|
+
end
|
|
3419
|
+
|
|
3420
|
+
# Allocate pre-repeatable positionals with Ruby-like semantics
|
|
3421
|
+
# (required get values first, optional only if extra values available)
|
|
3422
|
+
def allocate_pre_repeatable_smart(allocation, definitions, values)
|
|
3423
|
+
return 0 if definitions.empty?
|
|
3424
|
+
|
|
3425
|
+
state = LeadingAllocationState.new(definitions, values, method(:required?))
|
|
3426
|
+
state.allocate(allocation)
|
|
3427
|
+
end
|
|
3428
|
+
|
|
3429
|
+
def allocate_repeatable(allocation, definition, values, start_idx, end_idx)
|
|
3430
|
+
repeatable_values = values[start_idx...end_idx] || []
|
|
3431
|
+
allocation[definition[:name]] =
|
|
3432
|
+
if repeatable_values.empty? || repeatable_values.all?(&:nil?)
|
|
3433
|
+
definition[:default]
|
|
3434
|
+
else
|
|
3435
|
+
repeatable_values
|
|
3436
|
+
end
|
|
3437
|
+
repeatable_values.compact.size
|
|
3438
|
+
end
|
|
3439
|
+
|
|
3440
|
+
def allocate_post_repeatable(allocation, definitions, values, post_start)
|
|
3441
|
+
consumed = 0
|
|
3442
|
+
definitions.each_with_index do |definition, offset|
|
|
3443
|
+
pos_index = post_start + offset
|
|
3444
|
+
value = pos_index < values.size ? values[pos_index] : nil
|
|
3445
|
+
allocation[definition[:name]] = value.nil? ? definition[:default] : value
|
|
3446
|
+
consumed += 1 if pos_index < values.size && !values[pos_index].nil?
|
|
3447
|
+
end
|
|
3448
|
+
consumed
|
|
3449
|
+
end
|
|
3450
|
+
|
|
3451
|
+
# Encapsulates state for allocating leading positionals
|
|
3452
|
+
# @api private
|
|
3453
|
+
class LeadingAllocationState
|
|
3454
|
+
def initialize(definitions, values, required_check)
|
|
3455
|
+
@definitions = definitions
|
|
3456
|
+
@values = values
|
|
3457
|
+
@required_check = required_check
|
|
3458
|
+
@required_count = definitions.count { |d| required_check.call(d) }
|
|
3459
|
+
@extra_for_optionals = [values.size - @required_count, 0].max
|
|
3460
|
+
@val_idx = 0
|
|
3461
|
+
@opt_idx = 0
|
|
3462
|
+
@consumed = 0
|
|
3463
|
+
end
|
|
3464
|
+
|
|
3465
|
+
# Allocates leading positional values and returns consumed non-nil count
|
|
3466
|
+
#
|
|
3467
|
+
# @param allocation [Hash{Symbol => Object}] allocation hash to populate
|
|
3468
|
+
#
|
|
3469
|
+
# @return [Integer] number of non-nil positional values consumed
|
|
3470
|
+
#
|
|
3471
|
+
def allocate(allocation)
|
|
3472
|
+
@definitions.each { |definition| allocate_one(allocation, definition) }
|
|
3473
|
+
@consumed
|
|
3474
|
+
end
|
|
3475
|
+
|
|
3476
|
+
private
|
|
3477
|
+
|
|
3478
|
+
def allocate_one(allocation, definition)
|
|
3479
|
+
if @required_check.call(definition)
|
|
3480
|
+
allocate_required(allocation, definition)
|
|
3481
|
+
else
|
|
3482
|
+
allocate_optional(allocation, definition)
|
|
3483
|
+
end
|
|
3484
|
+
end
|
|
3485
|
+
|
|
3486
|
+
def allocate_required(allocation, definition)
|
|
3487
|
+
allocation[definition[:name]] = value_or_default(definition)
|
|
3488
|
+
@consumed += 1 if @val_idx < @values.size
|
|
3489
|
+
@val_idx += 1
|
|
3490
|
+
end
|
|
3491
|
+
|
|
3492
|
+
def allocate_optional(allocation, definition)
|
|
3493
|
+
if @opt_idx < @extra_for_optionals
|
|
3494
|
+
allocation[definition[:name]] = value_or_default(definition)
|
|
3495
|
+
@consumed += 1
|
|
3496
|
+
@val_idx += 1
|
|
3497
|
+
else
|
|
3498
|
+
allocation[definition[:name]] = definition[:default]
|
|
3499
|
+
end
|
|
3500
|
+
@opt_idx += 1
|
|
3501
|
+
end
|
|
3502
|
+
|
|
3503
|
+
def value_or_default(definition)
|
|
3504
|
+
@val_idx < @values.size ? @values[@val_idx] : definition[:default]
|
|
3505
|
+
end
|
|
3506
|
+
end
|
|
3507
|
+
end
|
|
3508
|
+
# rubocop:enable Metrics/ParameterLists
|
|
3509
|
+
end
|
|
3510
|
+
end
|