git 1.19.1 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +5 -1
  3. data/AI_POLICY.md +24 -0
  4. data/CHANGELOG.md +689 -0
  5. data/CODE_OF_CONDUCT.md +25 -0
  6. data/CONTRIBUTING.md +1175 -97
  7. data/GOVERNANCE.md +106 -0
  8. data/LICENSE +1 -1
  9. data/MAINTAINERS.md +17 -4
  10. data/README.md +476 -320
  11. data/UPGRADING.md +1138 -0
  12. data/git.gemspec +124 -36
  13. data/lib/git/author.rb +39 -7
  14. data/lib/git/author_info.rb +66 -0
  15. data/lib/git/branch.rb +615 -65
  16. data/lib/git/branch_delete_failure.rb +34 -0
  17. data/lib/git/branch_delete_result.rb +66 -0
  18. data/lib/git/branch_info.rb +237 -0
  19. data/lib/git/branches.rb +167 -44
  20. data/lib/git/command_line/base.rb +247 -0
  21. data/lib/git/command_line/capturing.rb +308 -0
  22. data/lib/git/command_line/result.rb +88 -0
  23. data/lib/git/command_line/streaming.rb +236 -0
  24. data/lib/git/command_line.rb +52 -0
  25. data/lib/git/commands/add.rb +139 -0
  26. data/lib/git/commands/am/abort.rb +43 -0
  27. data/lib/git/commands/am/apply.rb +263 -0
  28. data/lib/git/commands/am/continue.rb +43 -0
  29. data/lib/git/commands/am/quit.rb +43 -0
  30. data/lib/git/commands/am/retry.rb +49 -0
  31. data/lib/git/commands/am/show_current_patch.rb +64 -0
  32. data/lib/git/commands/am/skip.rb +42 -0
  33. data/lib/git/commands/am.rb +33 -0
  34. data/lib/git/commands/apply.rb +242 -0
  35. data/lib/git/commands/archive/list_formats.rb +46 -0
  36. data/lib/git/commands/archive.rb +145 -0
  37. data/lib/git/commands/arguments.rb +4521 -0
  38. data/lib/git/commands/base.rb +502 -0
  39. data/lib/git/commands/branch/copy.rb +102 -0
  40. data/lib/git/commands/branch/create.rb +177 -0
  41. data/lib/git/commands/branch/delete.rb +88 -0
  42. data/lib/git/commands/branch/list.rb +178 -0
  43. data/lib/git/commands/branch/move.rb +102 -0
  44. data/lib/git/commands/branch/set_upstream.rb +86 -0
  45. data/lib/git/commands/branch/show_current.rb +49 -0
  46. data/lib/git/commands/branch/unset_upstream.rb +53 -0
  47. data/lib/git/commands/branch.rb +34 -0
  48. data/lib/git/commands/cat_file/batch.rb +385 -0
  49. data/lib/git/commands/cat_file/filtered.rb +105 -0
  50. data/lib/git/commands/cat_file/raw.rb +271 -0
  51. data/lib/git/commands/cat_file.rb +49 -0
  52. data/lib/git/commands/checkout/branch.rb +153 -0
  53. data/lib/git/commands/checkout/files.rb +116 -0
  54. data/lib/git/commands/checkout.rb +38 -0
  55. data/lib/git/commands/checkout_index.rb +106 -0
  56. data/lib/git/commands/clean.rb +102 -0
  57. data/lib/git/commands/clone.rb +241 -0
  58. data/lib/git/commands/commit.rb +273 -0
  59. data/lib/git/commands/commit_tree.rb +101 -0
  60. data/lib/git/commands/config_option_syntax/add.rb +86 -0
  61. data/lib/git/commands/config_option_syntax/get.rb +121 -0
  62. data/lib/git/commands/config_option_syntax/get_all.rb +118 -0
  63. data/lib/git/commands/config_option_syntax/get_color.rb +95 -0
  64. data/lib/git/commands/config_option_syntax/get_color_bool.rb +96 -0
  65. data/lib/git/commands/config_option_syntax/get_regexp.rb +119 -0
  66. data/lib/git/commands/config_option_syntax/get_urlmatch.rb +111 -0
  67. data/lib/git/commands/config_option_syntax/list.rb +111 -0
  68. data/lib/git/commands/config_option_syntax/remove_section.rb +79 -0
  69. data/lib/git/commands/config_option_syntax/rename_section.rb +83 -0
  70. data/lib/git/commands/config_option_syntax/replace_all.rb +109 -0
  71. data/lib/git/commands/config_option_syntax/set.rb +119 -0
  72. data/lib/git/commands/config_option_syntax/unset.rb +92 -0
  73. data/lib/git/commands/config_option_syntax/unset_all.rb +94 -0
  74. data/lib/git/commands/config_option_syntax.rb +56 -0
  75. data/lib/git/commands/describe.rb +156 -0
  76. data/lib/git/commands/diff.rb +657 -0
  77. data/lib/git/commands/diff_files.rb +519 -0
  78. data/lib/git/commands/diff_index.rb +499 -0
  79. data/lib/git/commands/fetch.rb +354 -0
  80. data/lib/git/commands/fsck.rb +138 -0
  81. data/lib/git/commands/gc.rb +134 -0
  82. data/lib/git/commands/grep.rb +339 -0
  83. data/lib/git/commands/init.rb +101 -0
  84. data/lib/git/commands/log.rb +634 -0
  85. data/lib/git/commands/ls_files.rb +195 -0
  86. data/lib/git/commands/ls_remote.rb +161 -0
  87. data/lib/git/commands/ls_tree.rb +135 -0
  88. data/lib/git/commands/maintenance/register.rb +77 -0
  89. data/lib/git/commands/maintenance/run.rb +109 -0
  90. data/lib/git/commands/maintenance/start.rb +71 -0
  91. data/lib/git/commands/maintenance/stop.rb +60 -0
  92. data/lib/git/commands/maintenance/unregister.rb +84 -0
  93. data/lib/git/commands/maintenance.rb +31 -0
  94. data/lib/git/commands/merge/abort.rb +44 -0
  95. data/lib/git/commands/merge/continue.rb +44 -0
  96. data/lib/git/commands/merge/quit.rb +46 -0
  97. data/lib/git/commands/merge/start.rb +250 -0
  98. data/lib/git/commands/merge.rb +28 -0
  99. data/lib/git/commands/merge_base.rb +91 -0
  100. data/lib/git/commands/mv.rb +82 -0
  101. data/lib/git/commands/name_rev.rb +119 -0
  102. data/lib/git/commands/pull.rb +382 -0
  103. data/lib/git/commands/push.rb +251 -0
  104. data/lib/git/commands/read_tree.rb +154 -0
  105. data/lib/git/commands/remote/add.rb +96 -0
  106. data/lib/git/commands/remote/get_url.rb +68 -0
  107. data/lib/git/commands/remote/list.rb +56 -0
  108. data/lib/git/commands/remote/prune.rb +63 -0
  109. data/lib/git/commands/remote/remove.rb +52 -0
  110. data/lib/git/commands/remote/rename.rb +76 -0
  111. data/lib/git/commands/remote/set_branches.rb +70 -0
  112. data/lib/git/commands/remote/set_head.rb +89 -0
  113. data/lib/git/commands/remote/set_url.rb +78 -0
  114. data/lib/git/commands/remote/set_url_add.rb +70 -0
  115. data/lib/git/commands/remote/set_url_delete.rb +71 -0
  116. data/lib/git/commands/remote/show.rb +77 -0
  117. data/lib/git/commands/remote/update.rb +79 -0
  118. data/lib/git/commands/remote.rb +42 -0
  119. data/lib/git/commands/repack.rb +281 -0
  120. data/lib/git/commands/reset.rb +154 -0
  121. data/lib/git/commands/rev_parse.rb +304 -0
  122. data/lib/git/commands/revert/abort.rb +45 -0
  123. data/lib/git/commands/revert/continue.rb +62 -0
  124. data/lib/git/commands/revert/quit.rb +47 -0
  125. data/lib/git/commands/revert/skip.rb +44 -0
  126. data/lib/git/commands/revert/start.rb +158 -0
  127. data/lib/git/commands/revert.rb +29 -0
  128. data/lib/git/commands/rm.rb +113 -0
  129. data/lib/git/commands/show.rb +632 -0
  130. data/lib/git/commands/show_ref/exclude_existing.rb +119 -0
  131. data/lib/git/commands/show_ref/exists.rb +80 -0
  132. data/lib/git/commands/show_ref/list.rb +149 -0
  133. data/lib/git/commands/show_ref/verify.rb +122 -0
  134. data/lib/git/commands/show_ref.rb +42 -0
  135. data/lib/git/commands/stash/apply.rb +81 -0
  136. data/lib/git/commands/stash/branch.rb +67 -0
  137. data/lib/git/commands/stash/clear.rb +43 -0
  138. data/lib/git/commands/stash/create.rb +60 -0
  139. data/lib/git/commands/stash/drop.rb +73 -0
  140. data/lib/git/commands/stash/list.rb +43 -0
  141. data/lib/git/commands/stash/pop.rb +87 -0
  142. data/lib/git/commands/stash/push.rb +112 -0
  143. data/lib/git/commands/stash/show.rb +158 -0
  144. data/lib/git/commands/stash/store.rb +72 -0
  145. data/lib/git/commands/stash.rb +38 -0
  146. data/lib/git/commands/status.rb +174 -0
  147. data/lib/git/commands/symbolic_ref/delete.rb +72 -0
  148. data/lib/git/commands/symbolic_ref/read.rb +99 -0
  149. data/lib/git/commands/symbolic_ref/update.rb +79 -0
  150. data/lib/git/commands/symbolic_ref.rb +38 -0
  151. data/lib/git/commands/tag/create.rb +142 -0
  152. data/lib/git/commands/tag/delete.rb +57 -0
  153. data/lib/git/commands/tag/list.rb +146 -0
  154. data/lib/git/commands/tag/verify.rb +71 -0
  155. data/lib/git/commands/tag.rb +26 -0
  156. data/lib/git/commands/update_ref/batch.rb +145 -0
  157. data/lib/git/commands/update_ref/delete.rb +90 -0
  158. data/lib/git/commands/update_ref/update.rb +103 -0
  159. data/lib/git/commands/update_ref.rb +42 -0
  160. data/lib/git/commands/version.rb +60 -0
  161. data/lib/git/commands/worktree/add.rb +139 -0
  162. data/lib/git/commands/worktree/list.rb +64 -0
  163. data/lib/git/commands/worktree/lock.rb +58 -0
  164. data/lib/git/commands/worktree/management_base.rb +51 -0
  165. data/lib/git/commands/worktree/move.rb +66 -0
  166. data/lib/git/commands/worktree/prune.rb +67 -0
  167. data/lib/git/commands/worktree/remove.rb +63 -0
  168. data/lib/git/commands/worktree/repair.rb +76 -0
  169. data/lib/git/commands/worktree/unlock.rb +47 -0
  170. data/lib/git/commands/worktree.rb +43 -0
  171. data/lib/git/commands/write_tree.rb +68 -0
  172. data/lib/git/commands.rb +88 -0
  173. data/lib/git/config.rb +72 -5
  174. data/lib/git/config_entry_info.rb +106 -0
  175. data/lib/git/configuring.rb +795 -0
  176. data/lib/git/detached_head_info.rb +57 -0
  177. data/lib/git/diff.rb +437 -86
  178. data/lib/git/diff_file_numstat_info.rb +31 -0
  179. data/lib/git/diff_file_patch_info.rb +136 -0
  180. data/lib/git/diff_file_raw_info.rb +129 -0
  181. data/lib/git/diff_info.rb +162 -0
  182. data/lib/git/diff_path_status.rb +107 -0
  183. data/lib/git/diff_result.rb +34 -0
  184. data/lib/git/diff_stats.rb +111 -0
  185. data/lib/git/dirstat_info.rb +102 -0
  186. data/lib/git/encoding_utils.rb +32 -1
  187. data/lib/git/errors.rb +285 -0
  188. data/lib/git/escaped_path.rb +57 -5
  189. data/lib/git/execution_context/global.rb +31 -0
  190. data/lib/git/execution_context/repository.rb +151 -0
  191. data/lib/git/execution_context.rb +559 -0
  192. data/lib/git/factories.rb +813 -0
  193. data/lib/git/file_ref.rb +77 -0
  194. data/lib/git/fsck_object.rb +56 -0
  195. data/lib/git/fsck_result.rb +132 -0
  196. data/lib/git/log.rb +306 -90
  197. data/lib/git/object.rb +563 -141
  198. data/lib/git/parsers/branch.rb +240 -0
  199. data/lib/git/parsers/cat_file.rb +111 -0
  200. data/lib/git/parsers/config_entry.rb +110 -0
  201. data/lib/git/parsers/diff.rb +792 -0
  202. data/lib/git/parsers/fsck.rb +144 -0
  203. data/lib/git/parsers/grep.rb +42 -0
  204. data/lib/git/parsers/ls_remote.rb +79 -0
  205. data/lib/git/parsers/ls_tree.rb +58 -0
  206. data/lib/git/parsers/remote.rb +162 -0
  207. data/lib/git/parsers/stash.rb +292 -0
  208. data/lib/git/parsers/status.rb +251 -0
  209. data/lib/git/parsers/tag.rb +341 -0
  210. data/lib/git/parsers/worktree.rb +185 -0
  211. data/lib/git/path_resolver.rb +206 -0
  212. data/lib/git/remote.rb +165 -12
  213. data/lib/git/remote_info.rb +203 -0
  214. data/lib/git/repository/branching.rb +964 -0
  215. data/lib/git/repository/committing.rb +246 -0
  216. data/lib/git/repository/context_helpers.rb +293 -0
  217. data/lib/git/repository/diffing.rb +785 -0
  218. data/lib/git/repository/inspecting.rb +252 -0
  219. data/lib/git/repository/logging.rb +410 -0
  220. data/lib/git/repository/maintenance.rb +65 -0
  221. data/lib/git/repository/merging.rb +451 -0
  222. data/lib/git/repository/object_operations.rb +1551 -0
  223. data/lib/git/repository/remote_operations.rb +984 -0
  224. data/lib/git/repository/shared_private.rb +120 -0
  225. data/lib/git/repository/staging.rb +587 -0
  226. data/lib/git/repository/stashing.rb +623 -0
  227. data/lib/git/repository/status_operations.rb +249 -0
  228. data/lib/git/repository/worktree_operations.rb +339 -0
  229. data/lib/git/repository.rb +484 -2
  230. data/lib/git/stash.rb +109 -12
  231. data/lib/git/stash_info.rb +102 -0
  232. data/lib/git/stashes.rb +169 -26
  233. data/lib/git/status.rb +308 -122
  234. data/lib/git/status_file_info.rb +258 -0
  235. data/lib/git/status_info.rb +189 -0
  236. data/lib/git/tag_delete_failure.rb +34 -0
  237. data/lib/git/tag_delete_result.rb +66 -0
  238. data/lib/git/tag_info.rb +99 -0
  239. data/lib/git/url.rb +15 -8
  240. data/lib/git/version.rb +113 -2
  241. data/lib/git/version_constraint.rb +85 -0
  242. data/lib/git/worktree.rb +150 -8
  243. data/lib/git/worktree_info.rb +128 -0
  244. data/lib/git/worktrees.rb +118 -13
  245. data/lib/git.rb +632 -234
  246. metadata +369 -54
  247. data/.github/stale.yml +0 -25
  248. data/.github/workflows/continuous_integration.yml +0 -49
  249. data/.gitignore +0 -10
  250. data/Dockerfile.changelog-rs +0 -12
  251. data/Gemfile +0 -5
  252. data/ISSUE_TEMPLATE.md +0 -15
  253. data/PULL_REQUEST_TEMPLATE.md +0 -9
  254. data/RELEASING.md +0 -70
  255. data/Rakefile +0 -60
  256. data/lib/git/base/factory.rb +0 -99
  257. data/lib/git/base.rb +0 -711
  258. data/lib/git/command_line_result.rb +0 -86
  259. data/lib/git/failed_error.rb +0 -53
  260. data/lib/git/git_execute_error.rb +0 -7
  261. data/lib/git/index.rb +0 -5
  262. data/lib/git/lib.rb +0 -1328
  263. data/lib/git/path.rb +0 -31
  264. data/lib/git/signaled_error.rb +0 -50
  265. data/lib/git/working_directory.rb +0 -4
@@ -0,0 +1,4521 @@
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
+ # For 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
+ # For 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
+ # For 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
+ # For 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
+ # For 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 that {#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 that `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
+ # @example Basic flag
597
+ # args_def = Arguments.define do
598
+ # flag_option :verbose
599
+ # end
600
+ # args_def.bind(verbose: true).to_a # => ['--verbose']
601
+ #
602
+ # @return [Arguments] the configured Arguments instance
603
+ #
604
+ # @yield [] block evaluated in the context of the new Arguments instance via
605
+ # `instance_eval`, so DSL methods ({#flag_option}, {#operand}, etc.) are called
606
+ # directly without an explicit receiver
607
+ #
608
+ def self.define(&block)
609
+ args = new
610
+ args.instance_eval(&block) if block
611
+ args
612
+ end
613
+
614
+ # Initialize an empty Arguments definition
615
+ def initialize
616
+ @option_definitions = {}
617
+ @alias_map = {} # Maps alias keys to primary keys
618
+ @operand_definitions = []
619
+ @conflicts = [] # Array of conflicting option pairs/groups
620
+ @forbidden_values = [] # Array of forbidden exact-value tuples
621
+ @requires_one_of = [] # Array of "at least one must be present" groups
622
+ @ordered_definitions = [] # Tracks all definitions in definition order
623
+ @past_separator = false # Tracks whether a '--' boundary has been defined
624
+ @end_of_options_declared = false # Guards against duplicate end_of_options calls
625
+ @negatable_companions = Set.new # Synthesized :no_<name> companion entries
626
+ end
627
+
628
+ # Define a boolean flag option (--flag when true)
629
+ #
630
+ # @example Basic flag
631
+ # args_def = Arguments.define do
632
+ # flag_option :force
633
+ # end
634
+ # args_def.bind(force: true).to_a # => ['--force']
635
+ # args_def.bind(force: false).to_a # => []
636
+ #
637
+ # @example Negatable flag (companion-key model)
638
+ # args_def = Arguments.define do
639
+ # flag_option :full, negatable: true
640
+ # end
641
+ # args_def.bind(full: true).to_a # => ['--full']
642
+ # args_def.bind(no_full: true).to_a # => ['--no-full']
643
+ # args_def.bind(full: false).to_a # => []
644
+ #
645
+ # @example Negatable flag with required: true (either companion key satisfies the requirement)
646
+ # args_def = Arguments.define do
647
+ # flag_option :verify, negatable: true, required: true
648
+ # end
649
+ # args_def.bind(verify: true).to_a # => ['--verify']
650
+ # args_def.bind(no_verify: true).to_a # => ['--no-verify']
651
+ # args_def.bind(verify: false)
652
+ # #=> raise ArgumentError, "at least one of :verify, :no_verify must be provided"
653
+ # args_def.bind
654
+ # #=> raise ArgumentError, "at least one of :verify, :no_verify must be provided"
655
+ #
656
+ # @example Repeatable flag with max_times
657
+ # args_def = Arguments.define do
658
+ # flag_option :force, max_times: 2
659
+ # end
660
+ # args_def.bind(force: true).to_a # => ['--force']
661
+ # args_def.bind(force: 1).to_a # => ['--force']
662
+ # args_def.bind(force: 2).to_a # => ['--force', '--force']
663
+ #
664
+ # @example Negatable flag with max_times
665
+ # args_def = Arguments.define do
666
+ # flag_option :force, negatable: true, max_times: 2
667
+ # end
668
+ # args_def.bind(no_force: true).to_a # => ['--no-force']
669
+ # args_def.bind(force: 2).to_a # => ['--force', '--force']
670
+ #
671
+ # @example With required and allow_nil: false
672
+ # args_def = Arguments.define do
673
+ # flag_option :force, required: true, allow_nil: false
674
+ # end
675
+ # args_def.bind() #=> raise ArgumentError, "Required options not provided: :force"
676
+ # args_def.bind(force: nil) #=> raise ArgumentError, "Required options cannot be nil: :force"
677
+ #
678
+ # @param names [Symbol, Array<Symbol>] the option name(s), first is primary
679
+ #
680
+ # @param as [String, Array<String>, nil] custom argument(s) to output (e.g., '-r' or ['--amend', '--no-edit'])
681
+ #
682
+ # @param negatable [Boolean] when true, registers a companion `no_<name>` key that emits
683
+ # `--no-<flag>` when set to `true`. Both keys use standard boolean semantics: `true`
684
+ # emits the flag, `false` or absent emits nothing. A conflict is automatically registered
685
+ # between the two keys so that `name: true, no_name: true` raises at bind time.
686
+ # The primary key must be snake_case (e.g. `:verify`, `:three_way`). When `as:` is
687
+ # given, it must be a long-form (`--flag`) String; Arrays and short-form flags (e.g.
688
+ # `-S`) are not compatible with `negatable: true` because the synthesized companion is
689
+ # always `--no-<flag>`.
690
+ #
691
+ # @param required [Boolean] whether the option must be provided (the key must be present
692
+ # in opts). When combined with `negatable: true`, a `requires_one_of [name, no_name]`
693
+ # group is automatically registered so that either the primary or companion key satisfies
694
+ # the requirement (e.g. `bind(no_verify: true)` satisfies `required: true` for `:verify`).
695
+ # Note that under the companion-key model, `bind(verify: false)` does **not** satisfy
696
+ # the requirement because `false` is treated as absent.
697
+ #
698
+ # @param allow_nil [Boolean] whether nil is allowed when required is true (defaults to true)
699
+ #
700
+ # When false with required: true, raises ArgumentError if value is nil.
701
+ # Cannot be combined with `negatable: true` and `required: true` — raises ArgumentError
702
+ # at definition time (nil is already caught by the auto `requires_one_of` group).
703
+ #
704
+ # @param max_times [Integer, nil] maximum number of times the flag may be repeated (default: nil)
705
+ # When set, the caller may pass a positive Integer up to this limit to emit the flag
706
+ # multiple times (e.g. `force: 2` emits `--force --force`). Must be an Integer >= 2;
707
+ # 0 and 1 raise ArgumentError at definition time. When nil (the default), only boolean
708
+ # values are accepted.
709
+ #
710
+ # @return [void]
711
+ #
712
+ # @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
713
+ #
714
+ # @raise [ArgumentError] if max_times is not nil and not an Integer >= 2
715
+ #
716
+ # @raise [ArgumentError] if negatable: true and the primary key is not snake_case
717
+ #
718
+ # @raise [ArgumentError] if negatable: true and the generated `no_<name>` key collides
719
+ # with an already-registered key
720
+ #
721
+ # @raise [ArgumentError] if negatable: true and as: is an Array
722
+ #
723
+ # @raise [ArgumentError] if negatable: true and as: is not a long-form (`--flag`) String
724
+ #
725
+ # @raise [ArgumentError] if negatable: true and required: true and allow_nil: false
726
+ def flag_option(names, as: nil, negatable: false, required: false, allow_nil: true, max_times: nil)
727
+ primary = Array(names).first
728
+ validate_max_times!(primary, max_times)
729
+
730
+ if negatable
731
+ register_negatable_flag_pair(names, as: as, required: required,
732
+ allow_nil: allow_nil, max_times: max_times)
733
+ else
734
+ register_option(names, type: :flag, as: as, expected_type: nil, validator: nil,
735
+ required: required, allow_nil: allow_nil, max_times: max_times)
736
+ end
737
+ end
738
+
739
+ # Define a valued option (--flag value as separate arguments)
740
+ #
741
+ # This option type supports three output modes controlled by `inline:` and `as_operand:`:
742
+ #
743
+ # - **Default**: `--flag value` (flag and value as separate arguments)
744
+ # - **Inline**: `--flag=value` (single argument with `inline: true`)
745
+ # - **Operand**: `value` (no flag, just the value with `as_operand: true`)
746
+ #
747
+ # @example Basic value (default mode)
748
+ # args_def = Arguments.define do
749
+ # value_option :branch
750
+ # end
751
+ # args_def.bind(branch: 'main').to_a # => ['--branch', 'main']
752
+ #
753
+ # @example Inline value
754
+ # args_def = Arguments.define do
755
+ # value_option :format, inline: true
756
+ # end
757
+ # args_def.bind(format: 'short').to_a # => ['--format=short']
758
+ #
759
+ # @example Operand value (no flag output)
760
+ # args_def = Arguments.define do
761
+ # value_option :ref, as_operand: true
762
+ # end
763
+ # args_def.bind(ref: 'HEAD').to_a # => ['HEAD']
764
+ #
765
+ # @example Operand with end_of_options boundary
766
+ # args_def = Arguments.define do
767
+ # end_of_options
768
+ # value_option :paths, as_operand: true
769
+ # end
770
+ # args_def.bind(paths: 'file.txt').to_a # => ['--', 'file.txt']
771
+ #
772
+ # @example Multi-valued (default mode) - repeats option for each value
773
+ # args_def = Arguments.define do
774
+ # value_option :config, repeatable: true
775
+ # end
776
+ # args_def.bind(config: 'a=b').to_a # => ['--config', 'a=b']
777
+ # args_def.bind(config: ['a=b', 'c=d']).to_a # => ['--config', 'a=b', '--config', 'c=d']
778
+ # args_def.bind(config: nil).to_a # => []
779
+ #
780
+ # @example Multi-valued with inline - repeats inline option for each value
781
+ # args_def = Arguments.define do
782
+ # value_option :sort, inline: true, repeatable: true
783
+ # end
784
+ # args_def.bind(sort: ['refname', '-committerdate']).to_a
785
+ # # => ['--sort=refname', '--sort=-committerdate']
786
+ #
787
+ # @example Multi-valued with operand - outputs values without flags
788
+ # args_def = Arguments.define do
789
+ # end_of_options
790
+ # value_option :pathspecs, as_operand: true, repeatable: true
791
+ # end
792
+ # args_def.bind(pathspecs: ['file1.txt', 'file2.txt']).to_a
793
+ # # => ['--', 'file1.txt', 'file2.txt']
794
+ #
795
+ # @example With type validation
796
+ # args_def = Arguments.define do
797
+ # value_option :branch, type: String
798
+ # end
799
+ # args_def.bind(branch: 'main').to_a # => ['--branch', 'main']
800
+ #
801
+ # @example With allow_empty
802
+ # args_def = Arguments.define do
803
+ # value_option :message, allow_empty: true
804
+ # end
805
+ # args_def.bind(message: "").to_a # => ['--message', '']
806
+ # args_def.bind(message: "text").to_a # => ['--message', 'text']
807
+ #
808
+ # args_def2 = Arguments.define do
809
+ # value_option :message # allow_empty defaults to false
810
+ # end
811
+ # args_def2.bind(message: "").to_a # => []
812
+ # args_def2.bind(message: "text").to_a # => ['--message', 'text']
813
+ #
814
+ # @example With required
815
+ # args_def = Arguments.define do
816
+ # value_option :message, required: true
817
+ # end
818
+ # args_def.bind(message: 'text').to_a # => ['--message', 'text']
819
+ # args_def.bind(message: nil).to_a # => []
820
+ # args_def.bind() #=> raise ArgumentError, "Required options not provided: :message"
821
+ #
822
+ # @example With required and allow_nil: false
823
+ # args_def = Arguments.define do
824
+ # value_option :message, required: true, allow_nil: false
825
+ # end
826
+ # args_def.bind(message: 'text').to_a # => ['--message', 'text']
827
+ # args_def.bind(message: nil) #=> raise ArgumentError, "Required options cannot be nil: :message"
828
+ # args_def.bind() #=> raise ArgumentError, "Required options not provided: :message"
829
+ #
830
+ # @param names [Symbol, Array<Symbol>] the option name(s), first is primary
831
+ #
832
+ # @param as [String, nil] custom option string (arrays not supported for value types)
833
+ #
834
+ # @param type [Class, Array<Class>, nil] expected type(s) for validation. Raises ArgumentError with
835
+ # descriptive message if value doesn't match.
836
+ #
837
+ # @param inline [Boolean] when true, outputs --flag=value as single argument instead of
838
+ # --flag value as separate arguments (default: false). Cannot be combined with as_operand:.
839
+ #
840
+ # @param as_operand [Boolean] when true, outputs value as operand without flag
841
+ # (default: false). Cannot be combined with inline:.
842
+ #
843
+ # @param allow_empty [Boolean] whether to include the option even when value is an empty string.
844
+ # When false (default), empty strings are skipped entirely. When true, the option and empty
845
+ # value are included in the output.
846
+ #
847
+ # @param repeatable [Boolean] whether to allow multiple values. When true, accepts an array
848
+ # of values and repeats the option for each value. A single value or nil is also accepted.
849
+ # Behavior varies by output mode (see examples below).
850
+ #
851
+ # @param required [Boolean] when true, the option key must be present in the provided options hash.
852
+ # Raises ArgumentError if the key is missing. Defaults to false.
853
+ #
854
+ # @param allow_nil [Boolean] when false (with required: true), the value cannot be nil.
855
+ # Raises ArgumentError if a nil value is provided. Defaults to true.
856
+ #
857
+ # @return [void]
858
+ #
859
+ # @raise [ArgumentError] if inline: and as_operand: are both true
860
+ #
861
+ # @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
862
+ # (unless as_operand: true)
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
+ # @example Basic flag or value (new capability - not possible with old DSL)
883
+ # args_def = Arguments.define do
884
+ # flag_or_value_option :contains
885
+ # end
886
+ # args_def.bind(contains: true).to_a # => ['--contains']
887
+ # args_def.bind(contains: false).to_a # => []
888
+ # args_def.bind(contains: "abc123").to_a # => ['--contains', 'abc123']
889
+ # args_def.bind(contains: nil).to_a # => []
890
+ #
891
+ # @example With inline: true
892
+ # args_def = Arguments.define do
893
+ # flag_or_value_option :gpg_sign, inline: true
894
+ # end
895
+ # args_def.bind(gpg_sign: true).to_a # => ['--gpg-sign']
896
+ # args_def.bind(gpg_sign: false).to_a # => []
897
+ # args_def.bind(gpg_sign: "KEY").to_a # => ['--gpg-sign=KEY']
898
+ # args_def.bind(gpg_sign: nil).to_a # => []
899
+ #
900
+ # @example With negatable: true (companion-key model)
901
+ # args_def = Arguments.define do
902
+ # flag_or_value_option :verify, negatable: true
903
+ # end
904
+ # args_def.bind(verify: true).to_a # => ['--verify']
905
+ # args_def.bind(verify: false).to_a # => []
906
+ # args_def.bind(no_verify: true).to_a # => ['--no-verify']
907
+ # args_def.bind(verify: "KEYID").to_a # => ['--verify', 'KEYID']
908
+ # args_def.bind(verify: nil).to_a # => []
909
+ #
910
+ # @example With negatable: true and inline: true
911
+ # args_def = Arguments.define do
912
+ # flag_or_value_option :sign, negatable: true, inline: true
913
+ # end
914
+ # args_def.bind(sign: true).to_a # => ['--sign']
915
+ # args_def.bind(sign: false).to_a # => []
916
+ # args_def.bind(no_sign: true).to_a # => ['--no-sign']
917
+ # args_def.bind(sign: "KEY").to_a # => ['--sign=KEY']
918
+ # args_def.bind(sign: nil).to_a # => []
919
+ #
920
+ # @example With inline: true and repeatable: true
921
+ # args_def = Arguments.define do
922
+ # flag_or_value_option :recurse_submodules, inline: true, repeatable: true
923
+ # end
924
+ # args_def.bind(recurse_submodules: true).to_a # => ['--recurse-submodules']
925
+ # args_def.bind(recurse_submodules: 'lib/').to_a # => ['--recurse-submodules=lib/']
926
+ # args_def.bind(recurse_submodules: ['lib/', 'ext/']).to_a
927
+ # # => ['--recurse-submodules=lib/', '--recurse-submodules=ext/']
928
+ # args_def.bind(recurse_submodules: [nil])
929
+ # # => raise_error ArgumentError, /Invalid value for flag_or_inline_value/
930
+ #
931
+ # @param names [Symbol, Array<Symbol>] the option name(s), first is primary
932
+ #
933
+ # @param as [String, nil] custom option string
934
+ #
935
+ # @param type [Class, Array<Class>, nil] expected type(s) for validation
936
+ #
937
+ # @param negatable [Boolean] when true, registers a companion `no_<name>` key that emits
938
+ # `--no-<flag>` when set to `true`. The positive key retains flag-or-value semantics;
939
+ # the negative key is boolean-only (accepts only `true`/`false`/`nil`). A conflict is
940
+ # automatically registered so that `name: true, no_name: true` raises at bind time.
941
+ # The primary key must be snake_case. When `as:` is given, it must be a long-form
942
+ # (`--flag`) String; Arrays and short-form flags (e.g. `-S`) are not compatible with
943
+ # `negatable: true` because the synthesized companion is always `--no-<flag>`.
944
+ #
945
+ # @param inline [Boolean] when true, outputs --flag=value instead of --flag value (default: false)
946
+ #
947
+ # @param repeatable [Boolean] when true, accepts an Array of values and repeats the option
948
+ # for each element. Each element must be `true`, `false`, or a non-nil object (which is
949
+ # stringified via `#to_s`); nil elements raise ArgumentError at bind time.
950
+ # A single (non-Array) value is also accepted. Default false.
951
+ #
952
+ # @param required [Boolean] whether the option must be provided (the key must be present
953
+ # in opts). When combined with `negatable: true`, a `requires_one_of [name, no_name]`
954
+ # group is automatically registered so that either side satisfies the requirement. Note
955
+ # that `bind(name: false)` does **not** satisfy the requirement because `false` is
956
+ # treated as absent under the companion-key model.
957
+ #
958
+ # @param allow_nil [Boolean] whether nil is allowed when required is true. Defaults to true.
959
+ # Cannot be combined with `negatable: true` and `required: true` — raises ArgumentError
960
+ # at definition time (nil is already caught by the auto `requires_one_of` group).
961
+ #
962
+ # @return [void]
963
+ #
964
+ # @raise [ArgumentError] at bind time if `repeatable: true` is used and any
965
+ # Array element is nil
966
+ #
967
+ # @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
968
+ #
969
+ # @raise [ArgumentError] if negatable: true and the primary key is not snake_case
970
+ #
971
+ # @raise [ArgumentError] if negatable: true and the generated `no_<name>` key collides
972
+ # with an already-registered key
973
+ #
974
+ # @raise [ArgumentError] if negatable: true and as: is an Array
975
+ #
976
+ # @raise [ArgumentError] if negatable: true and as: is not a long-form (`--flag`) String
977
+ #
978
+ # @raise [ArgumentError] if negatable: true and required: true and allow_nil: false
979
+ def flag_or_value_option(names, as: nil, type: nil, negatable: false, inline: false,
980
+ repeatable: false, required: false, allow_nil: true)
981
+ if negatable
982
+ register_negatable_flag_or_value_pair(names, as: as, type: type, inline: inline,
983
+ repeatable: repeatable, required: required,
984
+ allow_nil: allow_nil)
985
+ else
986
+ option_type = inline ? :flag_or_inline_value : :flag_or_value
987
+ register_option(names, type: option_type, as: as, expected_type: type,
988
+ repeatable: repeatable, required: required, allow_nil: allow_nil)
989
+ end
990
+ end
991
+
992
+ # Define a key-value option that can be specified multiple times
993
+ #
994
+ # This is useful for git options like --trailer that take key=value pairs
995
+ # and can be repeated. Accepts Hash or Array of arrays for flexible input.
996
+ #
997
+ # @example Basic key-value (like --trailer)
998
+ # args_def = Arguments.define do
999
+ # key_value_option :trailers, as: '--trailer'
1000
+ # end
1001
+ # args_def.bind(trailers: { 'Signed-off-by' => 'John' }).to_a
1002
+ # # => ['--trailer', 'Signed-off-by=John']
1003
+ #
1004
+ # @example Hash with array values (multiple values for same key)
1005
+ # args_def = Arguments.define do
1006
+ # key_value_option :trailers, as: '--trailer'
1007
+ # end
1008
+ # args_def.bind(trailers: { 'Signed-off-by' => ['John', 'Jane'] }).to_a
1009
+ # # => ['--trailer', 'Signed-off-by=John', '--trailer', 'Signed-off-by=Jane']
1010
+ #
1011
+ # @example Array of arrays (full ordering control)
1012
+ # args_def = Arguments.define do
1013
+ # key_value_option :trailers, as: '--trailer'
1014
+ # end
1015
+ # args_def.bind(trailers: [['Signed-off-by', 'John'], ['Acked-by', 'Bob']]).to_a
1016
+ # # => ['--trailer', 'Signed-off-by=John', '--trailer', 'Acked-by=Bob']
1017
+ #
1018
+ # @example Key without value (nil value omits separator)
1019
+ # args_def = Arguments.define do
1020
+ # key_value_option :trailers, as: '--trailer'
1021
+ # end
1022
+ # args_def.bind(trailers: [['Acked-by', nil]]).to_a
1023
+ # # => ['--trailer', 'Acked-by']
1024
+ #
1025
+ # @example Nil in array values produces key-only entries
1026
+ # args_def = Arguments.define do
1027
+ # key_value_option :trailers, as: '--trailer'
1028
+ # end
1029
+ # args_def.bind(trailers: { 'Key' => ['Value1', nil, 'Value2'] }).to_a
1030
+ # # => ['--trailer', 'Key=Value1', '--trailer', 'Key', '--trailer', 'Key=Value2']
1031
+ #
1032
+ # @example With custom separator
1033
+ # args_def = Arguments.define do
1034
+ # key_value_option :trailers, as: '--trailer', key_separator: ': '
1035
+ # end
1036
+ # args_def.bind(trailers: { 'Signed-off-by' => 'John' }).to_a
1037
+ # # => ['--trailer', 'Signed-off-by: John']
1038
+ #
1039
+ # @example Empty values produce no output
1040
+ # args_def = Arguments.define do
1041
+ # key_value_option :trailers, as: '--trailer', required: true
1042
+ # end
1043
+ # args_def.bind(trailers: {}).to_a # => []
1044
+ # args_def.bind(trailers: []).to_a # => []
1045
+ # args_def.bind(trailers: nil).to_a # => []
1046
+ #
1047
+ # @param names [Symbol, Array<Symbol>] the option name(s), first is primary
1048
+ #
1049
+ # @param as [String, nil] custom option string (e.g., '--trailer')
1050
+ #
1051
+ # @param key_separator [String] separator between key and value (default: '=')
1052
+ #
1053
+ # @param inline [Boolean] when true, outputs --flag=key=value instead of --flag key=value
1054
+ #
1055
+ # @param required [Boolean] whether the option must be provided (key must exist in opts).
1056
+ # Note: empty hash/array is considered "present" and produces no output without error.
1057
+ #
1058
+ # @param allow_nil [Boolean] whether nil is allowed when required is true
1059
+ #
1060
+ # @return [void]
1061
+ #
1062
+ # @raise [ArgumentError] at bind time if array input is not a [key, value] pair or array of pairs
1063
+ #
1064
+ # @raise [ArgumentError] at bind time if a sub-array has more than 2 elements
1065
+ #
1066
+ # @raise [ArgumentError] at bind time if a key is nil, empty, or contains the separator
1067
+ #
1068
+ # @raise [ArgumentError] at bind time if a value is a Hash or Array (non-scalar)
1069
+ #
1070
+ # @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
1071
+ def key_value_option(names, as: nil, key_separator: '=', inline: false, required: false, allow_nil: true)
1072
+ option_type = inline ? :inline_key_value : :key_value
1073
+ register_option(names, type: option_type, as: as, key_separator: key_separator,
1074
+ required: required, allow_nil: allow_nil)
1075
+ end
1076
+
1077
+ # Define a literal string that is always included in the output
1078
+ #
1079
+ # Literals are output at their definition position (not grouped at the start).
1080
+ # This allows precise control over argument ordering, which is important for
1081
+ # git commands where argument position matters.
1082
+ #
1083
+ # @example Static flag for subcommand mode
1084
+ # args_def = Arguments.define do
1085
+ # literal '--delete'
1086
+ # flag_option :force
1087
+ # operand :branches, repeatable: true
1088
+ # end
1089
+ # args_def.bind('feature', force: true).to_a # => ['--delete', '--force', 'feature']
1090
+ #
1091
+ # @example Static separator between options and pathspecs
1092
+ # args_def = Arguments.define do
1093
+ # flag_option :force
1094
+ # operand :tree_ish
1095
+ # literal '--'
1096
+ # operand :paths, repeatable: true
1097
+ # end
1098
+ # args_def.bind('HEAD', 'file.txt', force: true).to_a
1099
+ # # => ['--force', 'HEAD', '--', 'file.txt']
1100
+ #
1101
+ # @param flag_string [String] the static flag string (e.g., '--', '--no-progress')
1102
+ #
1103
+ # @return [void]
1104
+ def literal(flag_string)
1105
+ @ordered_definitions << { kind: :static, flag: flag_string }
1106
+ @past_separator = true if flag_string == '--'
1107
+ end
1108
+
1109
+ # Conditionally emit an options terminator only when at least one following
1110
+ # argument produces output
1111
+ #
1112
+ # This is the canonical form for declaring the options/operands boundary in a
1113
+ # command definition. Unlike {#literal} with `'--'` which always emits the
1114
+ # separator, `end_of_options` emits its terminator string only when at least one
1115
+ # argument defined after it will be emitted as part of the CLI (for example
1116
+ # operands or `value_option ... as_operand: true`). This avoids a trailing bare
1117
+ # terminator when no pathspecs or other post-separator arguments are provided.
1118
+ #
1119
+ # `end_of_options` also acts as an always-active validation boundary: operands
1120
+ # defined before it are always validated for option-like values (starting with
1121
+ # `-`), regardless of whether the terminator will ultimately be emitted.
1122
+ #
1123
+ # @example Basic usage (git checkout tree-ish -- pathspecs)
1124
+ # args_def = Arguments.define do
1125
+ # flag_option :force
1126
+ # operand :tree_ish, required: true, allow_nil: true
1127
+ # end_of_options
1128
+ # operand :pathspecs, repeatable: true
1129
+ # end
1130
+ # args_def.bind('HEAD', 'file.txt').to_a # => ['HEAD', '--', 'file.txt']
1131
+ # args_def.bind('HEAD').to_a # => ['HEAD'] # (no --, nothing after it)
1132
+ # args_def.bind(nil, 'file.txt').to_a # => ['--', 'file.txt']
1133
+ # args_def.bind(nil).to_a # => []
1134
+ #
1135
+ # @example Custom terminator (git rev-parse --end-of-options)
1136
+ # args_def = Arguments.define do
1137
+ # flag_option :verify
1138
+ # end_of_options as: '--end-of-options'
1139
+ # operand :args, repeatable: true
1140
+ # end
1141
+ # args_def.bind('HEAD').to_a # => ['--end-of-options', 'HEAD']
1142
+ # args_def.bind.to_a # => []
1143
+ #
1144
+ # @param as [String] the CLI token to emit as the options terminator
1145
+ # (default `'--'`). Some commands use a different terminator; for example,
1146
+ # `git rev-parse` uses `'--end-of-options'`.
1147
+ #
1148
+ # @return [void]
1149
+ #
1150
+ # @raise [ArgumentError] if called more than once per definition block
1151
+ #
1152
+ # @raise [ArgumentError] if a flag-producing option is defined after this call
1153
+ def end_of_options(as: '--')
1154
+ raise ArgumentError, 'end_of_options cannot be declared twice' if @end_of_options_declared
1155
+
1156
+ @ordered_definitions << { kind: :end_of_options }
1157
+ @end_of_options_declared = true
1158
+ @end_of_options_as = as
1159
+ @past_separator = true
1160
+ end
1161
+
1162
+ # Define a custom option with a custom builder block
1163
+ #
1164
+ # @example Custom transformation (e.g., formatting a Date value)
1165
+ # args_def = Arguments.define do
1166
+ # custom_option :since do |val|
1167
+ # val ? "--since=#{val.strftime('%Y-%m-%d')}" : nil
1168
+ # end
1169
+ # end
1170
+ # args_def.bind(since: Date.new(2024, 1, 1)).to_a # => ['--since=2024-01-01']
1171
+ # args_def.bind.to_a # => []
1172
+ #
1173
+ # @param names [Symbol, Array<Symbol>] the option name(s), first is primary
1174
+ #
1175
+ # @param required [Boolean] whether the option must be provided (key must exist in opts)
1176
+ #
1177
+ # @param allow_nil [Boolean] whether nil is allowed when required is true. Defaults to true.
1178
+ # When false with required: true, raises ArgumentError if value is nil.
1179
+ #
1180
+ # @return [void]
1181
+ #
1182
+ # @raise [ArgumentError] if defined after an `end_of_options` or `literal '--'` boundary
1183
+ #
1184
+ # @yield [value] block that receives the option value and returns the CLI argument(s)
1185
+ #
1186
+ # @yieldparam value [Object] the bound value for this option
1187
+ #
1188
+ # @yieldreturn [String, Array<String>, nil] the CLI argument(s) to emit;
1189
+ # nil or an empty array emits nothing
1190
+ #
1191
+ def custom_option(names, required: false, allow_nil: true, &block)
1192
+ register_option(names, type: :custom, builder: block, required: required, allow_nil: allow_nil)
1193
+ end
1194
+
1195
+ # Define an execution option (not included in CLI output, forwarded to command execution)
1196
+ #
1197
+ # Execution options are omitted from the CLI argument array produced by {Bound#to_a}, but
1198
+ # their values are still accessible on the {Bound} object. This is useful for options that
1199
+ # control Ruby-side execution context (e.g., working directory) rather than git flags.
1200
+ #
1201
+ # @example Chdir option forwarded to execution context, not emitted as a CLI flag
1202
+ # args_def = Arguments.define do
1203
+ # flag_option :verbose
1204
+ # execution_option :chdir
1205
+ # end
1206
+ # bound = args_def.bind(verbose: true, chdir: '/tmp')
1207
+ # bound.to_a # => ['--verbose'] # :chdir is not included
1208
+ # bound[:chdir] # => '/tmp' # still accessible on the Bound object
1209
+ #
1210
+ # @param names [Symbol, Array<Symbol>] the option name(s), first is primary
1211
+ #
1212
+ # @return [void]
1213
+ def execution_option(names)
1214
+ register_option(names, type: :execution_option)
1215
+ end
1216
+
1217
+ # Declare that arguments conflict with each other (mutually exclusive)
1218
+ #
1219
+ # Each call to {#conflicts} defines a separate group of mutually exclusive
1220
+ # arguments. Names may refer to **options** (flag, value, flag-or-value, etc.)
1221
+ # or **operands** (positional arguments). When {#bind} is called, if more than
1222
+ # one argument in the same conflict group is "present", an ArgumentError is
1223
+ # raised.
1224
+ #
1225
+ # **Presence semantics** — an argument is present when its value is not `nil`,
1226
+ # `[]`, or `''`. `false` is always treated as absent for all option types.
1227
+ #
1228
+ # An ArgumentError is raised at definition time if any name given to
1229
+ # `conflicts` is not a known option or operand, catching typos early.
1230
+ #
1231
+ # The error message has the general form:
1232
+ #
1233
+ # "cannot specify :name1 and :name2"
1234
+ #
1235
+ # @example Option-only conflict group
1236
+ # args_def = Arguments.define do
1237
+ # flag_option :gpg_sign
1238
+ # flag_option :no_gpg_sign
1239
+ # flag_option :force
1240
+ # flag_option :no_force
1241
+ # conflicts :gpg_sign, :no_gpg_sign
1242
+ # conflicts :force, :no_force
1243
+ # end
1244
+ # args_def.bind(gpg_sign: true).to_a # => ['--gpg-sign']
1245
+ #
1246
+ # @example Mixed option and operand conflict
1247
+ # args_def = Arguments.define do
1248
+ # flag_option %i[merge m], as: '--merge'
1249
+ # operand :tree_ish, required: true, allow_nil: true
1250
+ # end_of_options
1251
+ # operand :paths, repeatable: true
1252
+ # conflicts :merge, :tree_ish
1253
+ # end
1254
+ # args_def.bind(nil, 'file.txt', merge: true).to_a # => ['--merge', '--', 'file.txt']
1255
+ # args_def.bind('main', 'file.txt', merge: true)
1256
+ # # => raise ArgumentError, 'cannot specify :merge and :tree_ish'
1257
+ #
1258
+ # @param names [Array<Symbol>] the option/operand names that conflict within
1259
+ # this group
1260
+ #
1261
+ # @return [void]
1262
+ #
1263
+ # @raise [ArgumentError] if any name is not a known option or operand
1264
+ #
1265
+ # @raise [ArgumentError] if more than one argument in the same conflict group
1266
+ # is present when binding arguments (during {#bind})
1267
+ def conflicts(*names)
1268
+ names.each do |name|
1269
+ sym = name.to_sym
1270
+ next if known_argument?(sym)
1271
+
1272
+ raise ArgumentError, "unknown argument :#{sym} in conflicts declaration"
1273
+ end
1274
+ @conflicts << names.map(&:to_sym)
1275
+ end
1276
+
1277
+ # Declare that an exact combination of argument values is forbidden
1278
+ #
1279
+ # Each call to {#forbid_values} defines one forbidden tuple. A tuple matches
1280
+ # when **every** listed name is present (has a bound value after alias
1281
+ # normalization) **and** each value equals the declared value exactly (Ruby
1282
+ # `==`). When a tuple matches, {#bind} raises ArgumentError.
1283
+ #
1284
+ # This fills the gap left by {#conflicts}, which only checks *presence*.
1285
+ # `forbid_values` is useful for negatable flags whose combinations can be
1286
+ # semantically equivalent or contradictory depending on the actual boolean
1287
+ # values — presence-based exclusion would be too coarse.
1288
+ #
1289
+ # Names may refer to **options** (flag, value, flag-or-value, etc.) or
1290
+ # **operands** (positional arguments). Alias names are accepted and
1291
+ # canonicalized to their primary names.
1292
+ #
1293
+ # An ArgumentError is raised at **definition time** if any name is not a
1294
+ # known option or operand.
1295
+ #
1296
+ # The error message has the form:
1297
+ #
1298
+ # "cannot specify :name1=value1 with :name2=value2"
1299
+ #
1300
+ # @example Reject only the contradictory negatable flag combinations
1301
+ # args_def = Arguments.define do
1302
+ # flag_option :all, negatable: true
1303
+ # flag_option :ignore_removal, negatable: true
1304
+ # # --all --ignore-removal: contradictory (add ALL vs ignore removals)
1305
+ # forbid_values all: true, ignore_removal: true
1306
+ # # --no-all --no-ignore-removal: contradictory (ignore removals vs include removals)
1307
+ # forbid_values no_all: true, no_ignore_removal: true
1308
+ # end
1309
+ # # Contradictory tuples raise:
1310
+ # args_def.bind(all: true, ignore_removal: true)
1311
+ # # => raise ArgumentError, 'cannot specify :all=true with :ignore_removal=true'
1312
+ # args_def.bind(no_all: true, no_ignore_removal: true)
1313
+ # # => raise ArgumentError, 'cannot specify :no_all=true with :no_ignore_removal=true'
1314
+ # # Semantically compatible pairs are allowed:
1315
+ # args_def.bind(all: true, no_ignore_removal: true).to_a # => ['--all', '--no-ignore-removal']
1316
+ # args_def.bind(no_all: true, ignore_removal: true).to_a # => ['--no-all', '--ignore-removal']
1317
+ #
1318
+ # @param pairs [Hash{Symbol => Object}] keyword pairs mapping argument name to forbidden value
1319
+ #
1320
+ # Each key must be a known option or operand name. During {#bind}, an
1321
+ # `ArgumentError` is raised when all names are present and all values
1322
+ # exactly match the declared tuple.
1323
+ #
1324
+ # @option pairs [Object] :"argument_name" the value forbidden for the argument with that name
1325
+ #
1326
+ # @return [void]
1327
+ #
1328
+ # @raise [ArgumentError] if any name in `pairs` is not a known option or
1329
+ # operand
1330
+ #
1331
+ # @raise [ArgumentError] during {#bind} if all names are present and all
1332
+ # values exactly match the declared tuple
1333
+ def forbid_values(**pairs)
1334
+ raise ArgumentError, 'forbid_values must be given at least one name-value pair' if pairs.empty?
1335
+
1336
+ pairs.each_key do |name|
1337
+ sym = name.to_sym
1338
+ next if known_argument?(sym)
1339
+
1340
+ raise ArgumentError, "unknown argument :#{sym} in forbid_values declaration"
1341
+ end
1342
+ canonical = pairs.transform_keys { |k| @alias_map[k] || k }
1343
+ @forbidden_values << canonical
1344
+ end
1345
+
1346
+ # Declare that at least one of the named arguments must be present when binding
1347
+ #
1348
+ # Each call to {#requires_one_of} defines an independent "at least one" group.
1349
+ # When {#bind} is called, if none of the arguments in the group is present,
1350
+ # an ArgumentError is raised.
1351
+ #
1352
+ # **Conditional form** — when `when:` is given, the check is only performed if
1353
+ # the named trigger argument is present. If the trigger is absent the group is
1354
+ # skipped entirely.
1355
+ #
1356
+ # **Presence semantics** — two slightly different rules apply:
1357
+ #
1358
+ # - *`when:` trigger* — the trigger is considered present when its value is
1359
+ # not `nil`, `false`, `[]`, or `''`. A flag set to `false` means absent,
1360
+ # so the trigger does **not** fire.
1361
+ # - *Satisfied-by check* — a group member is considered present when its
1362
+ # value is not `nil`, `false`, `[]`, or `''`. `false` is treated as absent
1363
+ # for all option types under the companion-key model.
1364
+ #
1365
+ # Names may refer to **options** (flag, value, flag-or-value, etc.) or
1366
+ # **operands** (positional arguments) interchangeably. Alias resolution happens
1367
+ # before the check, so supplying an alias for one of the named options counts
1368
+ # as that option being present.
1369
+ #
1370
+ # An ArgumentError is raised at definition time if any name (including the
1371
+ # `when:` trigger) is not a known option or operand, catching typos early.
1372
+ #
1373
+ # The error message has the general form (unconditional):
1374
+ #
1375
+ # "at least one of :name1, :name2 must be provided"
1376
+ #
1377
+ # The error message has the general form (conditional, `when:` given):
1378
+ #
1379
+ # ":trigger requires at least one of :name1, :name2"
1380
+ #
1381
+ # @example At-least-one of two keyword options (unconditional)
1382
+ # args_def = Arguments.define do
1383
+ # value_option :pathspec_from_file, inline: true
1384
+ # end_of_options
1385
+ # value_option :pathspec, as_operand: true, repeatable: true
1386
+ # requires_one_of :pathspec, :pathspec_from_file
1387
+ # end
1388
+ # args_def.bind(pathspec: ['file.txt']).to_a # => ['--', 'file.txt']
1389
+ # args_def.bind(pathspec_from_file: 'paths.txt').to_a
1390
+ # # => ['--pathspec-from-file=paths.txt']
1391
+ # args_def.bind
1392
+ # # => raise ArgumentError, 'at least one of :pathspec, :pathspec_from_file must be provided'
1393
+ #
1394
+ # @example Mixed option and operand group (unconditional)
1395
+ # args_def = Arguments.define do
1396
+ # flag_option :all
1397
+ # operand :paths, repeatable: true
1398
+ # requires_one_of :all, :paths
1399
+ # end
1400
+ # args_def.bind('file.txt').to_a # passes — :paths is present
1401
+ # args_def.bind(all: true).to_a # passes — :all is present
1402
+ # args_def.bind
1403
+ # # => raise ArgumentError, 'at least one of :all, :paths must be provided'
1404
+ #
1405
+ # @example Multiple independent groups (unconditional)
1406
+ # args_def = Arguments.define do
1407
+ # flag_option :commit
1408
+ # flag_option :all
1409
+ # value_option :pathspec_from_file, inline: true
1410
+ # end_of_options
1411
+ # value_option :pathspec, as_operand: true, repeatable: true
1412
+ # requires_one_of :commit, :all
1413
+ # requires_one_of :pathspec, :pathspec_from_file
1414
+ # end
1415
+ #
1416
+ # @example Conditional at-least-one-of group (`when:` form)
1417
+ # args_def = Arguments.define do
1418
+ # flag_option :annotate
1419
+ # value_option :message, inline: true
1420
+ # value_option :file, inline: true
1421
+ # requires_one_of :message, :file, when: :annotate
1422
+ # end
1423
+ # args_def.bind(annotate: true, message: 'v1.0').to_a # passes
1424
+ # args_def.bind(annotate: true)
1425
+ # # => raise ArgumentError, ':annotate requires at least one of :message, :file'
1426
+ # args_def.bind # trigger absent — no error
1427
+ #
1428
+ # @param names [Array<Symbol>] the option/operand names where at least one
1429
+ # must be present
1430
+ #
1431
+ # @option kwargs [Symbol] :when optional trigger argument; when given, the check is
1432
+ # only performed if the trigger argument is present
1433
+ #
1434
+ # @return [void]
1435
+ #
1436
+ # @raise [ArgumentError] if no names are given
1437
+ #
1438
+ # @raise [ArgumentError] if any name (or the `when:` trigger) is not a known
1439
+ # option or operand
1440
+ #
1441
+ # @raise [ArgumentError] if none of the arguments in the group is present
1442
+ # when binding arguments (and the trigger, if any, is present)
1443
+ def requires_one_of(*names, **kwargs)
1444
+ condition = kwargs.delete(:when)
1445
+ raise ArgumentError, "requires_one_of: unknown keyword arguments: #{kwargs.keys.inspect}" unless kwargs.empty?
1446
+ raise ArgumentError, 'requires_one_of must be given at least one argument name' if names.empty?
1447
+
1448
+ canonical_group = canonicalize_requires_names(names)
1449
+ canonical_condition = resolve_requires_condition(condition)
1450
+ @requires_one_of << { names: canonical_group, condition: canonical_condition, single: false }
1451
+ end
1452
+
1453
+ # Declare that exactly one of the named arguments must be present when binding
1454
+ #
1455
+ # This is a convenience composite that combines {#requires_one_of} (at least one
1456
+ # must be present) and {#conflicts} (at most one may be present). Use it when a
1457
+ # group of arguments is mutually exclusive *and* the caller must supply precisely
1458
+ # one of them.
1459
+ #
1460
+ # The call:
1461
+ #
1462
+ # requires_exactly_one_of :a, :b, :c
1463
+ #
1464
+ # is exactly equivalent to:
1465
+ #
1466
+ # requires_one_of :a, :b, :c
1467
+ # conflicts :a, :b, :c
1468
+ #
1469
+ # **Presence semantics** — inherits the rules from the constituent methods.
1470
+ # See {#requires_one_of} and {#conflicts} for the full details.
1471
+ #
1472
+ # An ArgumentError is raised at definition time if any name is not a known
1473
+ # option or operand, catching typos early.
1474
+ #
1475
+ # Error messages reuse the formats from the constituent methods:
1476
+ #
1477
+ # "at least one of :a, :b, :c must be provided" # zero present
1478
+ # "cannot specify :a and :b" # two or more present
1479
+ #
1480
+ # @example Mode flags where exactly one must be supplied
1481
+ # args_def = Arguments.define do
1482
+ # flag_option :mode_a
1483
+ # flag_option :mode_b
1484
+ # flag_option :mode_c
1485
+ # requires_exactly_one_of :mode_a, :mode_b, :mode_c
1486
+ # end
1487
+ # args_def.bind(mode_a: true).to_a # => ['--mode-a']
1488
+ # args_def.bind
1489
+ # # => raise ArgumentError, 'at least one of :mode_a, :mode_b, :mode_c must be provided'
1490
+ # args_def.bind(mode_a: true, mode_c: true)
1491
+ # # => raise ArgumentError, 'cannot specify :mode_a and :mode_c'
1492
+ #
1493
+ # @param names [Array<Symbol>] the option/operand names where exactly one
1494
+ # must be present
1495
+ #
1496
+ # @return [void]
1497
+ #
1498
+ # @raise [ArgumentError] if any name is not a known option or operand
1499
+ #
1500
+ # @raise [ArgumentError] at bind time if none of the arguments in the group is present
1501
+ #
1502
+ # @raise [ArgumentError] at bind time if more than one argument in the group is present
1503
+ def requires_exactly_one_of(*names)
1504
+ requires_one_of(*names)
1505
+ conflicts(*names)
1506
+ end
1507
+
1508
+ # Declare that *name* must be present whenever the trigger argument *when:* is present
1509
+ #
1510
+ # When {#bind} is called, if the trigger argument is present and *name* is absent,
1511
+ # an ArgumentError is raised. If the trigger is absent, the check is skipped.
1512
+ #
1513
+ # **Presence semantics** — two slightly different rules apply:
1514
+ #
1515
+ # - *`when:` trigger* — the trigger is considered present when its value is
1516
+ # not `nil`, `false`, `[]`, or `''`. A value of `false` is treated as
1517
+ # absent. If you need an explicit negative form for a negatable flag, use
1518
+ # its `no_<name>` companion key instead.
1519
+ # - *Required argument* — *name* is considered present when its value is
1520
+ # not `nil`, `false`, `[]`, or `''`.
1521
+ #
1522
+ # An ArgumentError is raised at definition time if either *name* or the `when:`
1523
+ # trigger is not a known option or operand, catching typos early.
1524
+ #
1525
+ # The error message has the form:
1526
+ #
1527
+ # ":trigger requires :name"
1528
+ #
1529
+ # @example Require pathspec_from_file when pathspec_file_nul is present
1530
+ # args_def = Arguments.define do
1531
+ # flag_option :pathspec_file_nul
1532
+ # value_option :pathspec_from_file, inline: true
1533
+ # requires :pathspec_from_file, when: :pathspec_file_nul
1534
+ # end
1535
+ # args_def.bind(pathspec_file_nul: true, pathspec_from_file: 'paths.txt').to_a
1536
+ # # => ['--pathspec-file-nul', '--pathspec-from-file=paths.txt']
1537
+ # args_def.bind(pathspec_file_nul: true)
1538
+ # # => raise ArgumentError, ':pathspec_file_nul requires :pathspec_from_file'
1539
+ # args_def.bind # trigger absent — no error
1540
+ #
1541
+ # @example Require dry_run when ignore_missing is present
1542
+ # args_def = Arguments.define do
1543
+ # flag_option :dry_run
1544
+ # flag_option :ignore_missing
1545
+ # requires :dry_run, when: :ignore_missing
1546
+ # end
1547
+ # args_def.bind(ignore_missing: true)
1548
+ # # => raise ArgumentError, ':ignore_missing requires :dry_run'
1549
+ #
1550
+ # @param name [Symbol] the option/operand name that must be present
1551
+ #
1552
+ # @option kwargs [Symbol] :when the trigger argument; when present, *name* must also be present
1553
+ #
1554
+ # @return [void]
1555
+ #
1556
+ # @raise [ArgumentError] if `when:` is not provided
1557
+ #
1558
+ # @raise [ArgumentError] if *name* or the `when:` trigger is not a known option
1559
+ # or operand
1560
+ #
1561
+ # @raise [ArgumentError] if the trigger is present and *name* is absent when
1562
+ # binding arguments
1563
+ def requires(name, **kwargs)
1564
+ condition = kwargs.delete(:when)
1565
+ raise ArgumentError, 'requires: `when:` keyword is required' unless condition
1566
+ raise ArgumentError, "requires: unknown keyword arguments: #{kwargs.keys.inspect}" unless kwargs.empty?
1567
+
1568
+ sym = name.to_sym
1569
+ validate_requires_name!(sym)
1570
+ canonical_trigger = resolve_requires_condition(condition)
1571
+ @requires_one_of << { names: [@alias_map[sym] || sym], condition: canonical_trigger, single: true }
1572
+ end
1573
+
1574
+ # rubocop:disable Layout/LineLength
1575
+
1576
+ # Restrict a value option to a fixed set of accepted strings
1577
+ #
1578
+ # Declares that the named option must only receive values from the given list
1579
+ # when a value is provided. Validation runs during {#bind}, after type checking.
1580
+ # `nil` and absent values are always skipped. Empty strings are skipped when
1581
+ # `allow_empty: true` is set on the option. For `repeatable: true` options
1582
+ # each element of the array is validated individually.
1583
+ #
1584
+ # @example Constrain chmod to '+x' or '-x'
1585
+ # args_def = Arguments.define do
1586
+ # value_option :chmod, inline: true
1587
+ # allowed_values :chmod, in: ['+x', '-x']
1588
+ # end
1589
+ # args_def.bind(chmod: '+x').to_a # => ['--chmod=+x']
1590
+ # args_def.bind(chmod: 'rx')
1591
+ # # => raise ArgumentError, 'Invalid value for :chmod: expected one of ["+x", "-x"], got "rx"'
1592
+ # args_def.bind.to_a # => [] # (absent — no error)
1593
+ #
1594
+ # @example Constrain cleanup to an enumerated set
1595
+ # args_def = Arguments.define do
1596
+ # value_option :cleanup, inline: true
1597
+ # allowed_values :cleanup, in: %w[verbatim whitespace strip]
1598
+ # end
1599
+ # args_def.bind(cleanup: 'verbatim').to_a # => ['--cleanup=verbatim']
1600
+ # args_def.bind(cleanup: 'compact')
1601
+ # # => raise ArgumentError, 'Invalid value for :cleanup: expected one of ["verbatim", "whitespace", "strip"], got "compact"'
1602
+ #
1603
+ # @example Repeatable option — each element is validated
1604
+ # args_def = Arguments.define do
1605
+ # value_option :strategy, inline: true, repeatable: true
1606
+ # allowed_values :strategy, in: %w[ours theirs]
1607
+ # end
1608
+ # args_def.bind(strategy: %w[ours theirs]).to_a
1609
+ # # => ['--strategy=ours', '--strategy=theirs']
1610
+ # args_def.bind(strategy: %w[ours other])
1611
+ # # => raise ArgumentError, 'Invalid value for :strategy: expected one of ["ours", "theirs"], got "other"'
1612
+ #
1613
+ # @param name [Symbol] the option name (primary or alias); must refer to a
1614
+ # previously defined {#value_option} or {#flag_or_value_option}
1615
+ #
1616
+ # @param in [#each] accepted values enumerable. Each value is coerced with
1617
+ # `to_s` and compared as a string.
1618
+ #
1619
+ # For {#flag_or_value_option} variants (including `negatable: true`),
1620
+ # boolean values (`true` / `false`) are skipped by this check because they
1621
+ # control flag-emission behavior rather than representing candidate string
1622
+ # values.
1623
+ #
1624
+ # @return [void]
1625
+ #
1626
+ # @raise [ArgumentError] if `name` is not a known option at definition time
1627
+ #
1628
+ # @raise [ArgumentError] if `name` refers to a non-value option (e.g., a flag)
1629
+ #
1630
+ # @raise [ArgumentError] during {#bind} if the bound value is not in the
1631
+ # accepted set, with a message of the form:
1632
+ # `"Invalid value for :name: expected one of [...], got \"actual\""`
1633
+ def allowed_values(name, in:)
1634
+ sym = name.to_sym
1635
+ defn = validate_allowed_values_definition!(sym)
1636
+ defn[:allowed_values] = coerce_allowed_values_set!(sym, binding.local_variable_get(:in))
1637
+ end
1638
+
1639
+ # rubocop:enable Layout/LineLength
1640
+
1641
+ # Define an operand (positional argument in Ruby terminology)
1642
+ #
1643
+ # Operands are mapped to values following Ruby method signature
1644
+ # semantics. Required operands before a repeatable are filled left-to-right,
1645
+ # required operands after a repeatable are filled from the end, and the
1646
+ # repeatable gets whatever remains in the middle.
1647
+ #
1648
+ # @example Required operand (like `def clone(repository)`)
1649
+ # args_def = Arguments.define do
1650
+ # operand :repository, required: true
1651
+ # end
1652
+ # args_def.bind('https://github.com/user/repo').to_a
1653
+ # # => ['https://github.com/user/repo']
1654
+ #
1655
+ # @example Optional operand with default (like `def log(commit = 'HEAD')`)
1656
+ # args_def = Arguments.define do
1657
+ # operand :commit, default: 'HEAD'
1658
+ # end
1659
+ # args_def.bind().to_a # => ['HEAD']
1660
+ # args_def.bind('main').to_a # => ['main']
1661
+ #
1662
+ # @example Repeatable operand (like `def add(*paths)`)
1663
+ # args_def = Arguments.define do
1664
+ # operand :paths, repeatable: true
1665
+ # end
1666
+ # args_def.bind('file1', 'file2', 'file3').to_a
1667
+ # # => ['file1', 'file2', 'file3']
1668
+ #
1669
+ # @example Required repeatable with at least one value (like `def rm(*paths)` with validation)
1670
+ # args_def = Arguments.define do
1671
+ # operand :paths, repeatable: true, required: true
1672
+ # end
1673
+ # args_def.bind() #=> raise ArgumentError, "at least one value is required for paths"
1674
+ # args_def.bind('file1').to_a # => ['file1']
1675
+ #
1676
+ # @example git mv pattern (like `def mv(*sources, destination)`)
1677
+ # args_def = Arguments.define do
1678
+ # operand :sources, repeatable: true, required: true
1679
+ # operand :destination, required: true
1680
+ # end
1681
+ # args_def.bind('src1', 'src2', 'dest').to_a # => ['src1', 'src2', 'dest']
1682
+ # args_def.bind('src', 'dest').to_a # => ['src', 'dest']
1683
+ #
1684
+ # @example Optional before variadic with required after (like `def foo(a = 'default', *middle, b)`)
1685
+ # args_def = Arguments.define do
1686
+ # operand :a, default: 'default_a'
1687
+ # operand :middle, repeatable: true
1688
+ # operand :b, required: true
1689
+ # end
1690
+ # args_def.bind('x').to_a # => ['default_a', 'x']
1691
+ # args_def.bind('x', 'y').to_a # => ['x', 'y']
1692
+ # args_def.bind('x', 'm', 'y').to_a # => ['x', 'm', 'y']
1693
+ #
1694
+ # @example Operand after end_of_options boundary (pathspec after --)
1695
+ # args_def = Arguments.define do
1696
+ # flag_option :force
1697
+ # end_of_options
1698
+ # operand :paths, repeatable: true
1699
+ # end
1700
+ # args_def.bind('file1', 'file2', force: true).to_a
1701
+ # # => ['--force', '--', 'file1', 'file2']
1702
+ #
1703
+ # @example Complex pattern (like `def diff(commit1, commit2 = nil, *paths)`)
1704
+ # args_def = Arguments.define do
1705
+ # operand :commit1, required: true
1706
+ # operand :commit2
1707
+ # end_of_options
1708
+ # operand :paths, repeatable: true
1709
+ # end
1710
+ # args_def.bind('HEAD~1').to_a # => ['HEAD~1']
1711
+ # args_def.bind('HEAD~1', 'HEAD').to_a # => ['HEAD~1', 'HEAD']
1712
+ # args_def.bind('HEAD~1', 'HEAD', 'file.rb').to_a
1713
+ # # => ['HEAD~1', 'HEAD', '--', 'file.rb']
1714
+ #
1715
+ # @example Required operand that allows nil (like `git checkout [tree-ish] -- paths`)
1716
+ # args_def = Arguments.define do
1717
+ # operand :tree_ish, required: true, allow_nil: true
1718
+ # end_of_options
1719
+ # operand :paths, repeatable: true
1720
+ # end
1721
+ # args_def.bind(nil, 'file1.txt', 'file2.txt').to_a
1722
+ # # => ['--', 'file1.txt', 'file2.txt']
1723
+ # args_def.bind('HEAD', 'file.rb').to_a
1724
+ # # => ['HEAD', '--', 'file.rb']
1725
+ # args_def.bind(nil, 'file.rb').to_a
1726
+ # # => ['--', 'file.rb']
1727
+ #
1728
+ # @param name [Symbol] the operand name (used in error messages)
1729
+ #
1730
+ # @param required [Boolean] whether the argument is required. For repeatable
1731
+ # operands, this means at least one value must be provided.
1732
+ #
1733
+ # @param repeatable [Boolean] whether the argument accepts multiple values
1734
+ # (like Ruby's splat operator *args). Only one repeatable operand is
1735
+ # allowed per definition; attempting to define a second will raise an
1736
+ # ArgumentError.
1737
+ #
1738
+ # @param default [Object] the default value if not provided. For repeatable
1739
+ # operands, this should be an array (e.g., `default: ['.']`).
1740
+ #
1741
+ # @param allow_nil [Boolean] whether nil is a valid value for a required
1742
+ # operand. When true, nil consumes the operand slot but is omitted
1743
+ # from output. This is useful for commands like `git checkout` where
1744
+ # the tree-ish is required to consume a slot but may be nil to restore
1745
+ # from the index. Defaults to false.
1746
+ #
1747
+ # @param skip_cli [Boolean] whether this operand participates in binding,
1748
+ # validation, and accessors but is omitted from CLI argv emission.
1749
+ # Defaults to false.
1750
+ #
1751
+ # @return [void]
1752
+ #
1753
+ # @raise [ArgumentError] during {#bind} if the operand appears before a '--'
1754
+ # boundary (or no boundary exists) and the bound value starts with '-'
1755
+ def operand(name, required: false, repeatable: false, default: nil, allow_nil: false,
1756
+ skip_cli: false)
1757
+ validate_single_repeatable!(name) if repeatable
1758
+ add_operand_definition(name, required, repeatable, default, allow_nil, skip_cli)
1759
+ end
1760
+
1761
+ # Bind positionals and options, returning a Bound object with accessor methods
1762
+ #
1763
+ # Unlike the internal build method which returns a raw Array, this method
1764
+ # returns a {Bound} object that:
1765
+ # - Provides accessor methods for all defined options and positional arguments
1766
+ # - Automatically normalizes option aliases to their canonical names
1767
+ # - Supports splatting via `to_ary` for seamless use with `command(*bound)`
1768
+ #
1769
+ # @example Simple splatting (same behavior as build)
1770
+ # def call(*, **)
1771
+ # @execution_context.command_capturing(*ARGS.bind(*, **))
1772
+ # end
1773
+ #
1774
+ # @example Inspecting options before command execution
1775
+ # args_def = Arguments.define do
1776
+ # flag_option :force
1777
+ # flag_option :remotes, as: ['-r', '--remotes']
1778
+ # operand :branch_names, repeatable: true
1779
+ # end
1780
+ # bound_args = args_def.bind('branch1', 'branch2', force: true, remotes: true)
1781
+ # bound_args.force? # => true
1782
+ # bound_args.remotes? # => true
1783
+ # bound_args.branch_names # => ['branch1', 'branch2']
1784
+ #
1785
+ # @example Hash-style access for reserved names
1786
+ # args_def = Arguments.define do
1787
+ # value_option :hash
1788
+ # end
1789
+ # bound_args = args_def.bind(hash: 'abc123')
1790
+ # bound_args[:hash] # => 'abc123'
1791
+ #
1792
+ # @param positionals [Array] positional argument values
1793
+ #
1794
+ # @param opts [Hash] the keyword options
1795
+ #
1796
+ # @option opts [Object] :"option_name" value for any option registered in this argument definition
1797
+ #
1798
+ # @return [Bound] a frozen object with accessor methods for all arguments
1799
+ #
1800
+ # @raise [ArgumentError] if unsupported options are provided or validation fails
1801
+ #
1802
+ # @raise [ArgumentError] if an operand value before a '--' boundary starts with '-'
1803
+ def bind(*positionals, **opts)
1804
+ normalized_opts = validate_and_normalize_options!(opts)
1805
+ allocated_positionals = allocate_and_validate_positionals(positionals)
1806
+ validate_bind_inputs!(normalized_opts, allocated_positionals)
1807
+
1808
+ args_array = build_ordered_arguments(allocated_positionals, normalized_opts)
1809
+ options_hash = build_options_hash(normalized_opts)
1810
+ execution_option_names = option_names_by_type(:execution_option)
1811
+ flag_names = option_names_by_type(:flag)
1812
+
1813
+ Bound.new(args_array, options_hash, allocated_positionals, execution_option_names, flag_names)
1814
+ end
1815
+
1816
+ # Option types allowed after a '--' separator boundary (they do not produce CLI flags)
1817
+ OPTION_TYPES_AFTER_SEPARATOR = %i[value_as_operand execution_option].freeze
1818
+
1819
+ # Sentinel object placed in the build array by an :end_of_options definition.
1820
+ # It is later replaced by the stored `as:` value (default `'--'`) if any element
1821
+ # follows it, or stripped if it is last.
1822
+ # Uses Object identity comparison (== is not overridden) so it can never collide
1823
+ # with the literal string '--' or any other real argument value.
1824
+ END_OF_OPTIONS_MARKER = Object.new.freeze
1825
+ private_constant :END_OF_OPTIONS_MARKER
1826
+
1827
+ # Option types that accept a string value — eligible for `allowed_values` constraints
1828
+ VALUE_OPTION_TYPES_FOR_ALLOWED_VALUES = %i[
1829
+ value inline_value value_as_operand
1830
+ flag_or_value flag_or_inline_value
1831
+ ].freeze
1832
+
1833
+ # The subset of VALUE_OPTION_TYPES_FOR_ALLOWED_VALUES whose boolean values
1834
+ # carry semantic meaning (true = emit flag, false = suppress flag) and must
1835
+ # skip allowed_values validation rather than being compared against the set.
1836
+ FLAG_OR_VALUE_OPTION_TYPES = %i[
1837
+ flag_or_value flag_or_inline_value
1838
+ ].freeze
1839
+
1840
+ private
1841
+
1842
+ # Run all cross-field validations on bound inputs
1843
+ #
1844
+ # @param normalized_opts [Hash] normalized keyword options
1845
+ #
1846
+ # @param allocated_positionals [Hash] allocated positional arguments
1847
+ #
1848
+ # @return [void]
1849
+ #
1850
+ def validate_bind_inputs!(normalized_opts, allocated_positionals)
1851
+ validate_no_option_like_operands!(allocated_positionals)
1852
+ validate_conflicts!(normalized_opts, allocated_positionals)
1853
+ validate_forbidden_values!(normalized_opts, allocated_positionals)
1854
+ validate_requires_one_of!(normalized_opts, allocated_positionals)
1855
+ end
1856
+
1857
+ # Collect option names whose definition type is one of the given types
1858
+ #
1859
+ # @param types [Array<Symbol>] the option types to match
1860
+ #
1861
+ # @return [Array<Symbol>] matching option names
1862
+ #
1863
+ def option_names_by_type(*types)
1864
+ @option_definitions.each_with_object([]) do |(name, definition), names|
1865
+ names << name if types.include?(definition[:type])
1866
+ end
1867
+ end
1868
+
1869
+ # Validate and normalize keyword options
1870
+ #
1871
+ # @param opts [Hash] raw keyword options
1872
+ #
1873
+ # @option opts [Object] :"option_name" value for any option registered in this argument definition
1874
+ #
1875
+ # @return [Hash] normalized options with aliases resolved
1876
+ #
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
+ #
1892
+ # @return [Hash{Symbol => Object}] option values with defaults applied
1893
+ def build_options_hash(normalized_opts)
1894
+ result = {}
1895
+ @option_definitions.each_key do |name|
1896
+ result[name] = normalized_opts.key?(name) ? normalized_opts[name] : default_option_value(name)
1897
+ end
1898
+ result
1899
+ end
1900
+
1901
+ # Get the default value for an option when not provided
1902
+ #
1903
+ # @param name [Symbol] the option name
1904
+ #
1905
+ # @return [Object] the default value (false for flags, nil for values)
1906
+ def default_option_value(name)
1907
+ definition = @option_definitions[name]
1908
+ case definition[:type]
1909
+ when :flag
1910
+ false
1911
+ end
1912
+ end
1913
+
1914
+ # Determine the internal option type based on inline and as_operand modifiers
1915
+ #
1916
+ # @param inline [Boolean] whether to use inline format (--flag=value)
1917
+ #
1918
+ # @param as_operand [Boolean] whether to output as operand (positional argument)
1919
+ #
1920
+ # @return [Symbol] the internal option type
1921
+ #
1922
+ def determine_value_option_type(inline, as_operand)
1923
+ if as_operand
1924
+ :value_as_operand
1925
+ elsif inline
1926
+ :inline_value
1927
+ else
1928
+ :value
1929
+ end
1930
+ end
1931
+
1932
+ # Validate value modifier combinations
1933
+ #
1934
+ # @param names [Symbol, Array<Symbol>] the option name(s)
1935
+ #
1936
+ # @param inline [Boolean] whether inline: true was specified
1937
+ #
1938
+ # @param as_operand [Boolean] whether as_operand: true was specified
1939
+ #
1940
+ # @raise [ArgumentError] if invalid modifier combination is used
1941
+ #
1942
+ def validate_value_modifiers!(names, inline, as_operand)
1943
+ primary = Array(names).first
1944
+ raise ArgumentError, "inline: and as_operand: cannot both be true for :#{primary}" if inline && as_operand
1945
+ end
1946
+
1947
+ # Register an option with optional aliases
1948
+ #
1949
+ # @param names [Symbol, Array<Symbol>] the option name(s), first is primary
1950
+ #
1951
+ # @param definition [Hash] the option definition
1952
+ #
1953
+ # @option definition [Symbol] :type the DSL option type (e.g. `:flag`, `:value`)
1954
+ #
1955
+ # @option definition [String, Array<String>, nil] :as the CLI flag name override
1956
+ #
1957
+ # @option definition [Class, nil] :expected_type the required Ruby class for the value
1958
+ #
1959
+ # @option definition [Proc, nil] :validator a callable that validates the value
1960
+ #
1961
+ # @option definition [Boolean] :required (false) whether the option is required
1962
+ #
1963
+ # @option definition [Boolean] :allow_nil whether nil is a valid value
1964
+ #
1965
+ # @option definition [Proc, nil] :builder a custom argument builder callable
1966
+ #
1967
+ # @option definition [Integer, nil] :max_times maximum repeat count for flag options
1968
+ #
1969
+ # @option definition [Boolean] :allow_empty whether empty values should be emitted
1970
+ #
1971
+ # @option definition [Boolean] :repeatable whether the option accepts multiple values
1972
+ #
1973
+ # @option definition [String] :key_separator separator used by key-value options
1974
+ #
1975
+ # @return [void]
1976
+ #
1977
+ def register_option(names, **definition)
1978
+ keys = Array(names)
1979
+ primary = keys.first
1980
+ definition[:aliases] = keys
1981
+ validate_no_duplicate_aliases!(keys)
1982
+ validate_no_companion_collision!(keys)
1983
+ validate_option_after_separator!(definition[:type], primary)
1984
+ validate_as_parameter!(definition, primary)
1985
+ apply_type_validator!(definition, primary)
1986
+ store_option(primary, keys, definition)
1987
+ end
1988
+
1989
+ # Store option in the option definitions and alias map
1990
+ #
1991
+ # @param primary [Symbol] the primary option name
1992
+ #
1993
+ # @param keys [Array<Symbol>] all alias keys including the primary
1994
+ #
1995
+ # @param definition [Hash] the option definition hash
1996
+ #
1997
+ # @return [void]
1998
+ #
1999
+ # @api private
2000
+ def store_option(primary, keys, definition)
2001
+ @option_definitions[primary] = definition
2002
+ keys.each { |key| @alias_map[key] = primary }
2003
+ @ordered_definitions << { kind: :option, name: primary }
2004
+ end
2005
+
2006
+ # Raise if any of `keys` collides with a previously synthesized `no_<name>`
2007
+ # companion entry. This catches the case where a user declares
2008
+ # `flag_option :foo, negatable: true` followed by `flag_option :no_foo`.
2009
+ #
2010
+ # @param keys [Array<Symbol>] the option alias keys to check
2011
+ #
2012
+ # @return [void]
2013
+ #
2014
+ # @raise [ArgumentError] if any key is already registered as a negatable companion
2015
+ #
2016
+ # @api private
2017
+ def validate_no_companion_collision!(keys)
2018
+ keys.each do |key|
2019
+ next unless @negatable_companions.include?(key)
2020
+
2021
+ raise ArgumentError,
2022
+ "option key :#{key} is already registered as a negatable companion"
2023
+ end
2024
+ end
2025
+
2026
+ # Raise if the `keys` array contains duplicate entries
2027
+ #
2028
+ # Duplicate aliases in a single declaration (e.g. `flag_option %i[foo foo]`)
2029
+ # are a programming mistake and would silently overwrite each other in
2030
+ # `@alias_map`. Catching them at definition time makes the error obvious.
2031
+ #
2032
+ # @param keys [Array<Symbol>] the option alias keys to check for duplicates
2033
+ #
2034
+ # @return [void]
2035
+ #
2036
+ # @raise [ArgumentError] if the keys array contains duplicate entries
2037
+ #
2038
+ # @api private
2039
+ def validate_no_duplicate_aliases!(keys)
2040
+ seen = Set.new
2041
+ keys.each do |key|
2042
+ raise ArgumentError, "duplicate alias key :#{key} in option definition" unless seen.add?(key)
2043
+ end
2044
+ end
2045
+
2046
+ # Validate that max_times is nil or an Integer >= 2
2047
+ #
2048
+ # @param option_name [Symbol] the option name (for error messages)
2049
+ #
2050
+ # @param max_times [Integer, nil] the max_times value to validate
2051
+ #
2052
+ # @return [void]
2053
+ #
2054
+ # @raise [ArgumentError] if max_times is not nil and not an Integer >= 2
2055
+ #
2056
+ # @api private
2057
+ def validate_max_times!(option_name, max_times)
2058
+ return if max_times.nil?
2059
+
2060
+ return if max_times.is_a?(Integer) && max_times >= 2
2061
+
2062
+ raise ArgumentError, "max_times for :#{option_name} must be an Integer >= 2"
2063
+ end
2064
+
2065
+ # Register two companion :flag entries for a negatable flag option
2066
+ #
2067
+ # Registers a positive entry for `names` and a boolean-only negative entry for
2068
+ # `:no_<primary>`. An automatic conflict is added so that both being
2069
+ # true at bind time raises ArgumentError.
2070
+ #
2071
+ # @param names [Symbol, Array<Symbol>] the option name(s); first is primary
2072
+ #
2073
+ # @param as [String, nil] the CLI flag string, or nil to derive from name
2074
+ #
2075
+ # @param required [Boolean] whether at least one of the pair must be provided
2076
+ #
2077
+ # @param allow_nil [Boolean] whether nil is a permitted bound value
2078
+ #
2079
+ # @param max_times [Integer, nil] maximum number of times the flag may be repeated
2080
+ #
2081
+ # @return [void]
2082
+ #
2083
+ # @api private
2084
+ def register_negatable_flag_pair(names, as:, required:, allow_nil:, max_times:)
2085
+ primary = Array(names).first
2086
+ validate_negatable_allow_nil!(primary, required: required, allow_nil: allow_nil)
2087
+ prepare_negatable!(primary, names, as)
2088
+
2089
+ register_option(names, type: :flag, as: as, expected_type: nil, validator: nil,
2090
+ required: false, allow_nil: allow_nil, max_times: max_times)
2091
+ register_negative_companion(primary, as: as, required: required)
2092
+ end
2093
+
2094
+ # Register a positive flag-or-value entry and a boolean-only negative companion
2095
+ # entry for a negatable flag-or-value option
2096
+ #
2097
+ # @param names [Symbol, Array<Symbol>] the option name(s); first is primary
2098
+ #
2099
+ # @param as [String, nil] the CLI flag string, or nil to derive from name
2100
+ #
2101
+ # @param type [Class, Array<Class>, nil] expected Ruby type(s) for the value
2102
+ #
2103
+ # @param inline [Boolean] whether to use inline format (--flag=value)
2104
+ #
2105
+ # @param repeatable [Boolean] whether the option may be given multiple times
2106
+ #
2107
+ # @param required [Boolean] whether at least one of the pair must be provided
2108
+ #
2109
+ # @param allow_nil [Boolean] whether nil is a permitted bound value
2110
+ #
2111
+ # @return [void]
2112
+ #
2113
+ # @api private
2114
+ def register_negatable_flag_or_value_pair(names, as:, type:, inline:, repeatable:, required:, allow_nil:)
2115
+ primary = Array(names).first
2116
+ validate_negatable_allow_nil!(primary, required: required, allow_nil: allow_nil)
2117
+ prepare_negatable!(primary, names, as)
2118
+
2119
+ positive_type = inline ? :flag_or_inline_value : :flag_or_value
2120
+ register_option(names, type: positive_type, as: as, expected_type: type,
2121
+ repeatable: repeatable, required: false, allow_nil: allow_nil)
2122
+ register_negative_companion(primary, as: as, required: required)
2123
+ end
2124
+
2125
+ # Run shared validations for a negatable option before registering either side
2126
+ #
2127
+ # @param primary [Symbol] the primary option name
2128
+ #
2129
+ # @param names [Symbol, Array<Symbol>] the option name(s) including aliases
2130
+ #
2131
+ # @param as [String, nil] the CLI flag string, or nil to derive from name
2132
+ #
2133
+ # @return [void]
2134
+ #
2135
+ # @raise [ArgumentError] if any negatable precondition is violated
2136
+ #
2137
+ # @api private
2138
+ def prepare_negatable!(primary, names, as)
2139
+ validate_negatable_primary_key!(primary)
2140
+ validate_negatable_as_not_array!(primary, as)
2141
+ validate_negatable_as_long_form!(primary, as)
2142
+ no_name = :"no_#{primary}"
2143
+ validate_no_negatable_collision!(no_name)
2144
+ validate_no_companion_in_alias_list!(no_name, Array(names))
2145
+ end
2146
+
2147
+ # Register the synthesized `no_<primary>` flag entry, the auto-conflict, and
2148
+ # (when `required: true`) the auto requires_one_of group
2149
+ #
2150
+ # @param primary [Symbol] the primary option name
2151
+ #
2152
+ # @param as [String, nil] the positive CLI flag string used to derive the negative form
2153
+ #
2154
+ # @param required [Boolean] whether an auto requires_one_of group should be added
2155
+ #
2156
+ # @return [void]
2157
+ #
2158
+ # @api private
2159
+ def register_negative_companion(primary, as:, required:)
2160
+ no_name = :"no_#{primary}"
2161
+ positive_flag = as || default_arg_spec(primary)
2162
+ negative_flag = negate_flag(positive_flag)
2163
+
2164
+ register_option(no_name, type: :flag, as: negative_flag, expected_type: nil, validator: nil,
2165
+ required: false, allow_nil: true)
2166
+ @negatable_companions << no_name
2167
+ @conflicts << [primary, no_name]
2168
+ @requires_one_of << { names: [primary, no_name], condition: nil, single: false } if required
2169
+ end
2170
+
2171
+ # Raise if `allow_nil: false` is combined with `negatable: true` and `required: true`
2172
+ #
2173
+ # When `negatable: true` and `required: true`, the "required" constraint is enforced
2174
+ # by an auto `requires_one_of` group (either the primary or its `no_<name>` companion
2175
+ # must be present). Because the primary option is internally registered with
2176
+ # `required: false`, the `allow_nil: false` nil-check never runs, making the
2177
+ # combination silently misleading. Fail at definition time instead.
2178
+ #
2179
+ # @param key [Symbol] the primary option name (for the error message)
2180
+ #
2181
+ # @param required [Boolean] whether the option is required
2182
+ #
2183
+ # @param allow_nil [Boolean] whether nil is allowed
2184
+ #
2185
+ # @return [void]
2186
+ #
2187
+ # @raise [ArgumentError] if `required: true` and `allow_nil: false` are combined
2188
+ # with `negatable: true`
2189
+ #
2190
+ def validate_negatable_allow_nil!(key, required:, allow_nil:)
2191
+ return unless required && allow_nil == false
2192
+
2193
+ raise ArgumentError,
2194
+ "allow_nil: false cannot be used with negatable: true and required: true on :#{key} " \
2195
+ '(nil is caught by the auto requires_one_of group, not allow_nil)'
2196
+ end
2197
+
2198
+ # Validate that the primary key is snake_case so a meaningful no_ companion can be generated
2199
+ #
2200
+ # @param key [Symbol] the primary option name to validate
2201
+ #
2202
+ # @return [void]
2203
+ #
2204
+ # @raise [ArgumentError] if key is not snake_case
2205
+ #
2206
+ # @api private
2207
+ def validate_negatable_primary_key!(key)
2208
+ return if key.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
2209
+
2210
+ raise ArgumentError,
2211
+ "negatable: true requires a snake_case primary key, got :#{key} " \
2212
+ "(would generate :no_#{key} which is not a meaningful negative form)"
2213
+ end
2214
+
2215
+ # Raise if as: is an Array when negatable: true
2216
+ #
2217
+ # Arrays for as: are not compatible with negatable: true regardless of the
2218
+ # underlying option type — the synthesized +--no-<flag>+ form has no sensible
2219
+ # mapping when the positive form expands to multiple CLI tokens.
2220
+ #
2221
+ # @param primary [Symbol] the primary option name (for error messages)
2222
+ #
2223
+ # @param as [Object] the as: parameter value to check
2224
+ #
2225
+ # @return [void]
2226
+ #
2227
+ # @raise [ArgumentError] if as: is an Array
2228
+ #
2229
+ # @api private
2230
+ def validate_negatable_as_not_array!(primary, as)
2231
+ return unless as.is_a?(Array)
2232
+
2233
+ raise ArgumentError,
2234
+ "arrays for as: parameter cannot be combined with negatable: true (option :#{primary})"
2235
+ end
2236
+
2237
+ # Raise if as: is given as a short-form flag (e.g. +-S+) when negatable: true.
2238
+ # Negation requires a long-form flag because the synthesized companion is
2239
+ # always +--no-<flag>+; deriving it from a short flag would yield a
2240
+ # nonexistent git form like +--no-S+.
2241
+ #
2242
+ # @param primary [Symbol] the primary option name (for error messages)
2243
+ #
2244
+ # @param as [String, nil] the as: parameter value to check
2245
+ #
2246
+ # @return [void]
2247
+ #
2248
+ # @raise [ArgumentError] if as: is not nil and not a long-form flag
2249
+ #
2250
+ # @api private
2251
+ def validate_negatable_as_long_form!(primary, as)
2252
+ return if as.nil?
2253
+ return if as.is_a?(String) && as.start_with?('--')
2254
+
2255
+ raise ArgumentError,
2256
+ "negatable: true requires a long-form (--flag) value for as: on :#{primary}, got #{as.inspect}"
2257
+ end
2258
+
2259
+ # Raise if the generated no_ companion key is already registered
2260
+ #
2261
+ # @param no_name [Symbol] the synthesized companion key (e.g. :no_force)
2262
+ #
2263
+ # @return [void]
2264
+ #
2265
+ # @raise [ArgumentError] if the companion key is already in the alias map
2266
+ #
2267
+ # @api private
2268
+ def validate_no_negatable_collision!(no_name)
2269
+ return unless @alias_map.key?(no_name)
2270
+
2271
+ raise ArgumentError,
2272
+ "negatable: true would register :#{no_name} but that key is already registered"
2273
+ end
2274
+
2275
+ # Raise if the synthesized companion key appears in the same declaration's alias list
2276
+ #
2277
+ # This catches e.g. `flag_option %i[foo no_foo], negatable: true` where `:no_foo`
2278
+ # is listed as an alias and would be silently overwritten when the companion is
2279
+ # registered, corrupting `@alias_map` and `@option_definitions`.
2280
+ #
2281
+ # @param no_name [Symbol] the synthesized companion key (e.g. :no_force)
2282
+ #
2283
+ # @param keys [Array<Symbol>] the alias list from the current declaration
2284
+ #
2285
+ # @return [void]
2286
+ #
2287
+ # @raise [ArgumentError] if the companion key is already listed as an alias
2288
+ #
2289
+ # @api private
2290
+ def validate_no_companion_in_alias_list!(no_name, keys)
2291
+ return unless keys.include?(no_name)
2292
+
2293
+ raise ArgumentError,
2294
+ "negatable: true would register :#{no_name} as a companion, but :#{no_name} " \
2295
+ 'is already listed as an alias in the same declaration'
2296
+ end
2297
+
2298
+ # Validate that flag-producing options are not defined after a '--' boundary
2299
+ #
2300
+ # @param type [Symbol] the option type
2301
+ #
2302
+ # @param option_name [Symbol] the primary option name
2303
+ #
2304
+ # @return [void]
2305
+ #
2306
+ # @raise [ArgumentError] if a flag-producing option is defined after '--'
2307
+ #
2308
+ def validate_option_after_separator!(type, option_name)
2309
+ return unless @past_separator
2310
+ return if OPTION_TYPES_AFTER_SEPARATOR.include?(type)
2311
+
2312
+ raise ArgumentError,
2313
+ "option :#{option_name} cannot be defined after a '--' separator " \
2314
+ 'boundary because its flags would be treated as operands by git'
2315
+ end
2316
+
2317
+ # Apply a type-checking validator to a definition when expected_type is set
2318
+ #
2319
+ # @param definition [Hash] the option definition hash (mutated in place)
2320
+ #
2321
+ # @param option_name [Symbol] the option name (for error messages)
2322
+ #
2323
+ # @return [void]
2324
+ #
2325
+ # @raise [ArgumentError] if both `expected_type` and `validator` are specified
2326
+ #
2327
+ # @api private
2328
+ def apply_type_validator!(definition, option_name)
2329
+ return unless definition[:expected_type]
2330
+
2331
+ if definition[:validator]
2332
+ raise ArgumentError,
2333
+ "cannot specify both type: and validator: for :#{option_name}"
2334
+ end
2335
+
2336
+ definition[:validator] = create_type_validator(option_name, definition[:expected_type])
2337
+ end
2338
+
2339
+ # Validate that array as: values are only used with flag-type options
2340
+ #
2341
+ # @param definition [Hash] the option definition hash
2342
+ #
2343
+ # @param option_name [Symbol] the primary option name (for error messages)
2344
+ #
2345
+ # @return [void]
2346
+ #
2347
+ # @raise [ArgumentError] if as: is an Array and the type is not :flag
2348
+ #
2349
+ # @api private
2350
+ def validate_as_parameter!(definition, option_name)
2351
+ return unless definition[:as].is_a?(Array)
2352
+
2353
+ return if definition[:type] == :flag
2354
+
2355
+ type = definition[:type]
2356
+ raise ArgumentError,
2357
+ "arrays for as: parameter are only supported for flag types, not :#{type} (option :#{option_name})"
2358
+ end
2359
+
2360
+ # Build arguments by iterating over definitions in their defined order
2361
+ #
2362
+ # @param allocated_positionals [Hash] the allocated positional values
2363
+ #
2364
+ # @param normalized_opts [Hash] normalized keyword options
2365
+ #
2366
+ # @return [Array<String>] the command-line arguments
2367
+ #
2368
+ def build_ordered_arguments(allocated_positionals, normalized_opts)
2369
+ args = []
2370
+
2371
+ @ordered_definitions.each do |entry|
2372
+ if entry[:kind] == :end_of_options
2373
+ args << END_OF_OPTIONS_MARKER
2374
+ else
2375
+ build_entry(args, entry, normalized_opts, allocated_positionals)
2376
+ end
2377
+ end
2378
+
2379
+ resolve_end_of_options_marker(args)
2380
+ end
2381
+
2382
+ # Build a single definition entry and append to args
2383
+ #
2384
+ # @param args [Array<String>] the argument array to append to
2385
+ #
2386
+ # @param entry [Hash] the definition entry with :kind and name/flag
2387
+ #
2388
+ # @param normalized_opts [Hash] normalized keyword options
2389
+ #
2390
+ # @param allocated_positionals [Hash] the allocated positional values
2391
+ #
2392
+ # @return [void]
2393
+ #
2394
+ def build_entry(args, entry, normalized_opts, allocated_positionals)
2395
+ case entry[:kind]
2396
+ when :static
2397
+ args << entry[:flag]
2398
+ when :option
2399
+ build_option(args, entry[:name], @option_definitions[entry[:name]], normalized_opts[entry[:name]])
2400
+ when :operand
2401
+ build_single_positional(args, entry[:name], allocated_positionals)
2402
+ # simplecov:disable this case should be unreachable
2403
+ else
2404
+ raise ArgumentError, "unknown entry kind: #{entry[:kind].inspect}"
2405
+ end
2406
+ # simplecov:enable
2407
+ end
2408
+
2409
+ # Replace the END_OF_OPTIONS_MARKER with the stored `as:` value if any element
2410
+ # follows it, or strip it
2411
+ #
2412
+ # @param args [Array] the built argument array (may contain END_OF_OPTIONS_MARKER)
2413
+ #
2414
+ # @return [Array<String>] the argument array with the marker resolved
2415
+ #
2416
+ def resolve_end_of_options_marker(args)
2417
+ idx = args.index(END_OF_OPTIONS_MARKER)
2418
+ return args unless idx
2419
+
2420
+ if idx == args.size - 1
2421
+ args.delete_at(idx) # nothing follows — strip
2422
+ else
2423
+ args[idx] = @end_of_options_as # something follows — make it real
2424
+ end
2425
+ args
2426
+ end
2427
+
2428
+ # Allocate positionals and perform validation, returning the allocation hash
2429
+ #
2430
+ # @param positionals [Array] positional argument values
2431
+ #
2432
+ # @return [Hash] allocation of positional names to values
2433
+ #
2434
+ def allocate_and_validate_positionals(positionals)
2435
+ positionals = normalize_positionals(positionals)
2436
+ allocation, consumed_count = allocate_positionals(positionals)
2437
+
2438
+ @operand_definitions.each do |definition|
2439
+ value = allocation[definition[:name]]
2440
+ validate_required_positional(value, definition)
2441
+ validate_no_nil_values!(value, definition)
2442
+ end
2443
+
2444
+ check_unexpected_positionals(positionals, consumed_count)
2445
+ allocation
2446
+ end
2447
+
2448
+ # Build a single positional argument
2449
+ #
2450
+ # @param args [Array<String>] the argument array to append to
2451
+ #
2452
+ # @param name [Symbol] the positional argument name
2453
+ #
2454
+ # @param allocation [Hash] the allocated positional values
2455
+ #
2456
+ # @return [void]
2457
+ #
2458
+ def build_single_positional(args, name, allocation)
2459
+ definition = @operand_definitions.find { |d| d[:name] == name }
2460
+ return if definition[:skip_cli]
2461
+
2462
+ value = allocation[name]
2463
+ append_positional_to_args(args, value, definition)
2464
+ end
2465
+
2466
+ # Raise if a second repeatable operand is declared
2467
+ #
2468
+ # @param name [Symbol] the name of the operand being added
2469
+ #
2470
+ # @return [void]
2471
+ #
2472
+ # @raise [ArgumentError] if a repeatable operand is already registered
2473
+ #
2474
+ # @api private
2475
+ def validate_single_repeatable!(name)
2476
+ existing_repeatable = @operand_definitions.find { |d| d[:repeatable] }
2477
+ return unless existing_repeatable
2478
+
2479
+ raise ArgumentError,
2480
+ "only one repeatable operand is allowed; :#{existing_repeatable[:name]} is already repeatable, " \
2481
+ "cannot add :#{name} as repeatable"
2482
+ end
2483
+
2484
+ # Append a positional operand definition to the internal arrays
2485
+ #
2486
+ # @param name [Symbol] the operand name
2487
+ #
2488
+ # @param required [Boolean] whether the operand is required
2489
+ #
2490
+ # @param repeatable [Boolean] whether the operand may accept multiple values
2491
+ #
2492
+ # @param default [Object] the default value when the operand is not provided
2493
+ #
2494
+ # @param allow_nil [Boolean] whether nil is a permitted value
2495
+ #
2496
+ # @param skip_cli [Boolean] whether to omit this operand from the CLI array
2497
+ #
2498
+ # @return [void]
2499
+ #
2500
+ # @api private
2501
+ def add_operand_definition(name, required, repeatable, default, allow_nil, skip_cli)
2502
+ @operand_definitions << {
2503
+ name: name, required: required, repeatable: repeatable,
2504
+ default: default, allow_nil: allow_nil, skip_cli: skip_cli
2505
+ }
2506
+ @ordered_definitions << { kind: :operand, name: name }
2507
+ end
2508
+
2509
+ # Maps option types to their build strategy (Symbol method name or lambda)
2510
+ #
2511
+ # @api private
2512
+ BUILDERS = {
2513
+ flag: :build_flag,
2514
+ value: lambda do |args, arg_spec, value, definition|
2515
+ if definition[:repeatable]
2516
+ Array(value).each { |v| args << arg_spec << v.to_s }
2517
+ else
2518
+ args << arg_spec << value.to_s
2519
+ end
2520
+ end,
2521
+ inline_value: :build_inline_value,
2522
+ flag_or_inline_value: :build_flag_or_inline_value,
2523
+ flag_or_value: :build_flag_or_value,
2524
+ value_as_operand: lambda do |args, _, value, definition|
2525
+ # Validate array usage when repeatable is false
2526
+ if value.is_a?(Array) && !definition[:repeatable]
2527
+ raise ArgumentError,
2528
+ "value_as_operand :#{definition[:aliases].first} requires repeatable: true to accept an array"
2529
+ end
2530
+
2531
+ # Validate no nil values in array
2532
+ if definition[:repeatable] && value.is_a?(Array) && value.any?(&:nil?)
2533
+ raise ArgumentError,
2534
+ "nil values are not allowed in value_as_operand :#{definition[:aliases].first}"
2535
+ end
2536
+
2537
+ # Add values as positional arguments
2538
+ if definition[:repeatable]
2539
+ Array(value).each { |v| args << v.to_s }
2540
+ else
2541
+ args << value.to_s
2542
+ end
2543
+ end,
2544
+ key_value: :build_key_value,
2545
+ inline_key_value: :build_inline_key_value,
2546
+ custom: lambda do |args, _, value, definition|
2547
+ result = definition[:builder]&.call(value)
2548
+ result.is_a?(Array) ? args.concat(result) : (args << result if result)
2549
+ end,
2550
+ execution_option: ->(*) {}
2551
+ }.freeze
2552
+ private_constant :BUILDERS
2553
+
2554
+ # Build a single option's CLI tokens and append them to args
2555
+ #
2556
+ # @param args [Array<String>] the argument array to append to
2557
+ #
2558
+ # @param name [Symbol] the canonical option name
2559
+ #
2560
+ # @param definition [Hash] the option definition hash
2561
+ #
2562
+ # @param value [Object] the bound value for the option
2563
+ #
2564
+ # @return [void]
2565
+ #
2566
+ # @api private
2567
+ def build_option(args, name, definition, value)
2568
+ return if should_skip_option?(value, definition)
2569
+
2570
+ arg_spec = definition[:as] || default_arg_spec(name)
2571
+ builder = BUILDERS[definition[:type]]
2572
+ if builder.is_a?(Symbol)
2573
+ send(builder, args, arg_spec, value, definition)
2574
+ else
2575
+ builder.call(args, arg_spec, value, definition)
2576
+ end
2577
+ end
2578
+
2579
+ # Generate the default argument specification based on option name length
2580
+ #
2581
+ # POSIX convention: single-character options use single dash (-f),
2582
+ # multi-character options use double dash (--force)
2583
+ #
2584
+ # @param name [Symbol] the option name
2585
+ #
2586
+ # @return [String] the argument specification (e.g., '-f' or '--force')
2587
+ #
2588
+ def default_arg_spec(name)
2589
+ name_str = name.to_s.tr('_', '-')
2590
+ name_str.length == 1 ? "-#{name_str}" : "--#{name_str}"
2591
+ end
2592
+
2593
+ # Check if an argument specification is for a short (single-character) option
2594
+ #
2595
+ # @param arg_spec [String] the argument specification
2596
+ #
2597
+ # @return [Boolean] true if this is a short option (single dash, single char)
2598
+ #
2599
+ def short_option?(arg_spec)
2600
+ arg_spec.is_a?(String) && arg_spec.match?(/\A-[^-]\z/)
2601
+ end
2602
+
2603
+ # Build key_value option tokens and append to args
2604
+ #
2605
+ # @param args [Array<String>] the argument array to append to
2606
+ #
2607
+ # @param arg_spec [String] the CLI flag string
2608
+ #
2609
+ # @param value [Hash, Array] the key-value input
2610
+ #
2611
+ # @param definition [Hash] the option definition hash
2612
+ #
2613
+ # @return [void]
2614
+ #
2615
+ # @api private
2616
+ def build_key_value(args, arg_spec, value, definition)
2617
+ sep = definition[:key_separator] || '='
2618
+ option_name = definition[:aliases].first
2619
+ normalize_key_value_pairs(value).each do |pair|
2620
+ validate_key_value_pair_size!(pair, option_name)
2621
+ k, v = pair
2622
+ validate_key_value_key!(k, sep, option_name)
2623
+ validate_key_value_value!(v, option_name)
2624
+ args << arg_spec << (v.nil? ? k.to_s : "#{k}#{sep}#{v}")
2625
+ end
2626
+ end
2627
+
2628
+ # Build inline_key_value option tokens and append to args
2629
+ #
2630
+ # @param args [Array<String>] the argument array to append to
2631
+ #
2632
+ # @param arg_spec [String] the CLI flag string
2633
+ #
2634
+ # @param value [Hash, Array] the key-value input
2635
+ #
2636
+ # @param definition [Hash] the option definition hash
2637
+ #
2638
+ # @return [void]
2639
+ #
2640
+ # @api private
2641
+ def build_inline_key_value(args, arg_spec, value, definition)
2642
+ sep = definition[:key_separator] || '='
2643
+ option_name = definition[:aliases].first
2644
+ normalize_key_value_pairs(value).each do |pair|
2645
+ validate_key_value_pair_size!(pair, option_name)
2646
+ k, v = pair
2647
+ validate_key_value_key!(k, sep, option_name)
2648
+ validate_key_value_value!(v, option_name)
2649
+ args << "#{arg_spec}=#{v.nil? ? k.to_s : "#{k}#{sep}#{v}"}"
2650
+ end
2651
+ end
2652
+
2653
+ # Build inline value option with POSIX-compliant formatting
2654
+ #
2655
+ # Short options (single-char) use no separator: -n3
2656
+ # Long options (multi-char) use = separator: --name=value
2657
+ #
2658
+ # @param args [Array<String>] the argument array to append to
2659
+ #
2660
+ # @param arg_spec [String] the CLI flag string
2661
+ #
2662
+ # @param value [Object] the bound value
2663
+ #
2664
+ # @param definition [Hash] the option definition hash
2665
+ #
2666
+ # @return [void]
2667
+ #
2668
+ # @api private
2669
+ def build_inline_value(args, arg_spec, value, definition)
2670
+ sep = inline_value_separator(arg_spec)
2671
+ if definition[:repeatable]
2672
+ Array(value).each { |v| args << "#{arg_spec}#{sep}#{v}" }
2673
+ else
2674
+ args << "#{arg_spec}#{sep}#{value}"
2675
+ end
2676
+ end
2677
+
2678
+ # Build flag or inline value option with POSIX-compliant formatting
2679
+ #
2680
+ # @param args [Array<String>] the argument array to append to
2681
+ #
2682
+ # @param arg_spec [String] the CLI flag string
2683
+ #
2684
+ # @param value [Boolean, Object] the bound value
2685
+ #
2686
+ # @param definition [Hash] the option definition hash
2687
+ #
2688
+ # @return [void]
2689
+ #
2690
+ # @api private
2691
+ def build_flag_or_inline_value(args, arg_spec, value, definition)
2692
+ each_flag_or_value_value(value, definition, 'flag_or_inline_value') do |v|
2693
+ next if v == false
2694
+
2695
+ args << (v == true ? arg_spec : "#{arg_spec}#{inline_value_separator(arg_spec)}#{v}")
2696
+ end
2697
+ end
2698
+
2699
+ # Build flag or value option
2700
+ #
2701
+ # @param args [Array<String>] the argument array to append to
2702
+ #
2703
+ # @param arg_spec [String] the CLI flag string
2704
+ #
2705
+ # @param value [Boolean, Object] the bound value
2706
+ #
2707
+ # @param definition [Hash] the option definition hash
2708
+ #
2709
+ # @return [void]
2710
+ #
2711
+ # @api private
2712
+ def build_flag_or_value(args, arg_spec, value, definition)
2713
+ each_flag_or_value_value(value, definition, 'flag_or_value') do |v|
2714
+ next if v == false
2715
+
2716
+ if v == true
2717
+ args << arg_spec
2718
+ else
2719
+ args << arg_spec << v.to_s
2720
+ end
2721
+ end
2722
+ end
2723
+
2724
+ # Iterate over each individual flag-or-value element, validating and yielding
2725
+ #
2726
+ # @param value [Object] the bound value (scalar or array for repeatable)
2727
+ #
2728
+ # @param definition [Hash] the option definition hash
2729
+ #
2730
+ # @param option_type [String] the option type name (for error messages)
2731
+ #
2732
+ # @return [void]
2733
+ #
2734
+ # @yield [value] each validated element
2735
+ #
2736
+ # @yieldparam value [Object] the validated scalar value (true, false, or a non-nil object)
2737
+ #
2738
+ # @api private
2739
+ def each_flag_or_value_value(value, definition, option_type)
2740
+ values = definition[:repeatable] ? Array(value) : [value]
2741
+ values.each do |v|
2742
+ validate_flag_or_value_type!(v, option_type)
2743
+ yield v
2744
+ end
2745
+ end
2746
+
2747
+ # Validate that a flag_or_value element is not nil.
2748
+ #
2749
+ # Boolean values (true/false) control flag presence/absence. Any other non-nil
2750
+ # object is accepted and converted to a CLI argument string via `#to_s`.
2751
+ # Nil is rejected only within repeatable arrays — non-repeatable nil values are
2752
+ # filtered out earlier by `should_skip_option?` and never reach here.
2753
+ #
2754
+ # @param value [Object] the element value to validate
2755
+ #
2756
+ # @param option_type [String] the option type name (for error messages)
2757
+ #
2758
+ # @return [void]
2759
+ #
2760
+ # @raise [ArgumentError] if value is nil
2761
+ #
2762
+ # @api private
2763
+ def validate_flag_or_value_type!(value, option_type)
2764
+ return unless value.nil?
2765
+
2766
+ raise ArgumentError,
2767
+ "Invalid value for #{option_type}: nil is not allowed as an array element; " \
2768
+ 'expected true, false, or a non-nil object that responds to #to_s'
2769
+ end
2770
+
2771
+ # Determine the separator to use for inline values based on option type
2772
+ #
2773
+ # POSIX convention:
2774
+ # - Short options (single dash, single char like -n): no separator (-n3)
2775
+ # - Long options (double dash like --name): = separator (--name=value)
2776
+ #
2777
+ # @param arg_spec [String] the argument specification
2778
+ #
2779
+ # @return [String] empty string ('') for short options, '=' for long options;
2780
+ # never returns nil, safe to concatenate directly
2781
+ #
2782
+ def inline_value_separator(arg_spec)
2783
+ short_option?(arg_spec) ? '' : '='
2784
+ end
2785
+
2786
+ # Build flag option tokens and append to args
2787
+ #
2788
+ # @param args [Array<String>] the argument array to append to
2789
+ #
2790
+ # @param arg_spec [String, Array<String>] the CLI flag string(s)
2791
+ #
2792
+ # @param value [Boolean, Integer] the bound flag value
2793
+ #
2794
+ # @param definition [Hash] the option definition hash
2795
+ #
2796
+ # @return [void]
2797
+ #
2798
+ # @api private
2799
+ def build_flag(args, arg_spec, value, definition)
2800
+ count = normalize_flag_value!(value, definition)
2801
+ append_repeated_flag(args, arg_spec, count)
2802
+ end
2803
+
2804
+ # Append a flag to the args array the given number of times
2805
+ #
2806
+ # @param args [Array<String>] the argument array to append to
2807
+ #
2808
+ # @param arg_spec [String, Array<String>] the CLI flag string(s)
2809
+ #
2810
+ # @param count [Integer] the number of times to append the flag
2811
+ #
2812
+ # @return [void]
2813
+ #
2814
+ # @api private
2815
+ def append_repeated_flag(args, arg_spec, count)
2816
+ return if count <= 0
2817
+
2818
+ count.times do
2819
+ arg_spec.is_a?(Array) ? args.concat(arg_spec) : args << arg_spec
2820
+ end
2821
+ end
2822
+
2823
+ # Normalize a flag bound value to a repeat count integer
2824
+ #
2825
+ # @param value [Boolean, Integer, nil] the bound flag value
2826
+ #
2827
+ # @param definition [Hash] the option definition hash
2828
+ #
2829
+ # @return [Integer] the number of times the flag should be emitted
2830
+ #
2831
+ # @raise [ArgumentError] if the value is not valid for this flag type
2832
+ #
2833
+ # @api private
2834
+ def normalize_flag_value!(value, definition)
2835
+ return 1 if value == true
2836
+ return 0 if value.nil? || value == false
2837
+
2838
+ option_name = definition[:aliases].first
2839
+ max_times = definition[:max_times]
2840
+
2841
+ raise_flag_type_boolean_error!(value, definition) if max_times.nil?
2842
+
2843
+ return normalize_flag_integer_value!(value, option_name, max_times) if value.is_a?(Integer)
2844
+
2845
+ raise ArgumentError, "Invalid value for :#{option_name}: expected true, false, or a positive Integer"
2846
+ end
2847
+
2848
+ # Raise a type error for a flag option that received a non-boolean value without max_times
2849
+ #
2850
+ # @param value [Object] the invalid value
2851
+ #
2852
+ # @param definition [Hash] the option definition hash
2853
+ #
2854
+ # @return [void]
2855
+ #
2856
+ # @raise [ArgumentError] always
2857
+ #
2858
+ # @api private
2859
+ def raise_flag_type_boolean_error!(value, definition)
2860
+ raise_flag_boolean_error!(definition[:aliases].first, value)
2861
+ end
2862
+
2863
+ # Raise an ArgumentError describing a non-boolean value for a boolean-only flag option
2864
+ #
2865
+ # @param option_name [Symbol] the option name (for the error message)
2866
+ #
2867
+ # @param value [Object] the invalid value received
2868
+ #
2869
+ # @return [void]
2870
+ #
2871
+ # @raise [ArgumentError] always
2872
+ #
2873
+ # @api private
2874
+ def raise_flag_boolean_error!(option_name, value)
2875
+ raise ArgumentError,
2876
+ "flag_option :#{option_name} expects a boolean value, got #{value.inspect} (#{value.class})"
2877
+ end
2878
+
2879
+ # Validate and return an integer flag repeat count within the max_times limit
2880
+ #
2881
+ # @param value [Integer] the integer value to validate
2882
+ #
2883
+ # @param option_name [Symbol] the option name (for error messages)
2884
+ #
2885
+ # @param max_times [Integer] the maximum allowed repeat count
2886
+ #
2887
+ # @return [Integer] the validated repeat count
2888
+ #
2889
+ # @raise [ArgumentError] if value is not a positive Integer or exceeds max_times
2890
+ #
2891
+ # @api private
2892
+ def normalize_flag_integer_value!(value, option_name, max_times)
2893
+ raise ArgumentError, "Invalid value for :#{option_name}: expected a positive Integer" if value <= 0
2894
+
2895
+ raise_max_times_exceeded!(option_name, value, max_times) if value > max_times
2896
+
2897
+ value
2898
+ end
2899
+
2900
+ # Raise an ArgumentError when a flag repeat count exceeds max_times
2901
+ #
2902
+ # @param option_name [Symbol] the option name (for the error message)
2903
+ #
2904
+ # @param value [Integer] the value that exceeded the limit
2905
+ #
2906
+ # @param max_times [Integer] the configured maximum
2907
+ #
2908
+ # @return [void]
2909
+ #
2910
+ # @raise [ArgumentError] always
2911
+ #
2912
+ # @api private
2913
+ def raise_max_times_exceeded!(option_name, value, max_times)
2914
+ raise ArgumentError,
2915
+ "#{option_name}: #{value} exceeds max_times: #{max_times} for :#{option_name}"
2916
+ end
2917
+
2918
+ # Negate a flag by adding --no- prefix
2919
+ #
2920
+ # For short options (-f), expands to --no-f
2921
+ # For long options (--force), transforms to --no-force
2922
+ #
2923
+ # @param arg_spec [String] the argument specification
2924
+ #
2925
+ # @return [String] the negated flag
2926
+ #
2927
+ def negate_flag(arg_spec)
2928
+ if short_option?(arg_spec)
2929
+ # -f => --no-f
2930
+ "--no-#{arg_spec[1]}"
2931
+ else
2932
+ # --force => --no-force
2933
+ arg_spec.sub(/\A--/, '--no-')
2934
+ end
2935
+ end
2936
+
2937
+ # Determine whether an option value should be skipped during CLI build
2938
+ #
2939
+ # @param value [Object] the bound option value
2940
+ #
2941
+ # @param definition [Hash] the option definition hash
2942
+ #
2943
+ # @return [Boolean] true if the option should be omitted from the CLI array
2944
+ #
2945
+ # @api private
2946
+ def should_skip_option?(value, definition)
2947
+ return true if value.nil?
2948
+ return true if value == false && %i[flag_or_inline_value flag_or_value].include?(definition[:type])
2949
+ return skip_value_as_operand_array?(value, definition) if value.is_a?(Array)
2950
+
2951
+ value.respond_to?(:empty?) && value.empty? && !definition[:allow_empty]
2952
+ end
2953
+
2954
+ # For value_as_operand, empty arrays always skip regardless of allow_empty
2955
+ # (allow_empty only applies to empty strings, not empty arrays)
2956
+ #
2957
+ # @param value [Array] the array value to evaluate
2958
+ #
2959
+ # @param definition [Hash] the option definition hash
2960
+ #
2961
+ # @return [Boolean] true if the option should be skipped
2962
+ #
2963
+ # @api private
2964
+ def skip_value_as_operand_array?(value, definition)
2965
+ return value.empty? if definition[:type] == :value_as_operand
2966
+
2967
+ value.empty? && !definition[:allow_empty]
2968
+ end
2969
+
2970
+ # Normalize key-value input to an array of [key, value] pairs
2971
+ #
2972
+ # Accepts:
2973
+ # - Hash: { 'key' => 'value' } or { 'key' => ['v1', 'v2'] }
2974
+ # - Array of arrays: [['key', 'value'], ['key2', 'value2']]
2975
+ # - Single array pair: ['key', 'value']
2976
+ #
2977
+ # @param value [Hash, Array] the input value
2978
+ #
2979
+ # @return [Array<Array>] array of [key, value] pairs
2980
+ #
2981
+ def normalize_key_value_pairs(value)
2982
+ case value
2983
+ when Hash then normalize_hash_to_pairs(value)
2984
+ when Array then normalize_array_to_pairs(value)
2985
+ else
2986
+ raise ArgumentError,
2987
+ "key_value option must be a Hash or Array, got #{value.class}"
2988
+ end
2989
+ end
2990
+
2991
+ # Normalize a Hash to an array of [key, value] pairs
2992
+ #
2993
+ # @param hash [Hash] the input hash
2994
+ #
2995
+ # @return [Array<Array>] array of [key, value] pairs
2996
+ #
2997
+ # @api private
2998
+ def normalize_hash_to_pairs(hash)
2999
+ hash.flat_map do |k, v|
3000
+ v.is_a?(Array) ? v.map { |val| [k, val] } : [[k, v]]
3001
+ end
3002
+ end
3003
+
3004
+ # Normalize an Array to an array of [key, value] pairs
3005
+ #
3006
+ # @param array [Array] the input array
3007
+ #
3008
+ # @return [Array<Array>] array of [key, value] pairs
3009
+ #
3010
+ # @raise [ArgumentError] if the array is not a pair or array of pairs
3011
+ #
3012
+ # @api private
3013
+ def normalize_array_to_pairs(array)
3014
+ # Check if it's a single [key, value] pair or array of pairs
3015
+ if array.size == 2 && !array.first.is_a?(Array)
3016
+ [array]
3017
+ elsif array.any? { |e| !e.is_a?(Array) }
3018
+ # Flat array with non-pair elements (e.g., ['a', 'b', 'c'])
3019
+ raise ArgumentError, 'key_value array input must be a [key, value] pair or array of pairs'
3020
+ else
3021
+ array
3022
+ end
3023
+ end
3024
+
3025
+ # Validate that a key-value pair array has at most 2 elements
3026
+ #
3027
+ # @param pair [Array] the pair to validate
3028
+ #
3029
+ # @param option_name [Symbol] the option name for error messages
3030
+ #
3031
+ # @return [void]
3032
+ #
3033
+ # @raise [ArgumentError] if pair has more than 2 elements
3034
+ #
3035
+ # @api private
3036
+ def validate_key_value_pair_size!(pair, option_name)
3037
+ return unless pair.is_a?(Array) && pair.size > 2
3038
+
3039
+ raise ArgumentError,
3040
+ "key_value :#{option_name} pair #{pair.inspect} has too many elements (expected [key, value])"
3041
+ end
3042
+
3043
+ # Validate a key for key_value options
3044
+ #
3045
+ # @param key [Object] the key to validate
3046
+ #
3047
+ # @param separator [String] the key-value separator
3048
+ #
3049
+ # @param option_name [Symbol] the option name for error messages
3050
+ #
3051
+ # @return [void]
3052
+ #
3053
+ # @raise [ArgumentError] if key is nil, empty, or contains the separator
3054
+ #
3055
+ # @api private
3056
+ def validate_key_value_key!(key, separator, option_name)
3057
+ key_str = key.to_s
3058
+ raise ArgumentError, "key_value :#{option_name} requires a non-empty key" if key.nil? || key_str.empty?
3059
+
3060
+ return unless key_str.include?(separator)
3061
+
3062
+ raise ArgumentError,
3063
+ "key_value :#{option_name} key #{key_str.inspect} cannot contain the separator #{separator.inspect}"
3064
+ end
3065
+
3066
+ # Validate a value for key_value options
3067
+ #
3068
+ # @param value [Object] the value to validate
3069
+ #
3070
+ # @param option_name [Symbol] the option name for error messages
3071
+ #
3072
+ # @return [void]
3073
+ #
3074
+ # @raise [ArgumentError] if value is a Hash or Array (non-scalar)
3075
+ #
3076
+ # @api private
3077
+ def validate_key_value_value!(value, option_name)
3078
+ return if value.nil?
3079
+ return unless value.is_a?(Hash) || value.is_a?(Array)
3080
+
3081
+ raise ArgumentError,
3082
+ "key_value :#{option_name} value must be a scalar (String, Symbol, Numeric, nil), " \
3083
+ "got #{value.class}: #{value.inspect}"
3084
+ end
3085
+
3086
+ # Flatten a single-element array wrapper if the first element is itself an array
3087
+ #
3088
+ # @param positionals [Array] the raw positional values from the splat
3089
+ #
3090
+ # @return [Array] normalized positionals array
3091
+ #
3092
+ # @api private
3093
+ def normalize_positionals(positionals)
3094
+ # Flatten if first element is an array (allows both splat and array syntax)
3095
+ positionals = positionals.first if positionals.size == 1 && positionals.first.is_a?(Array)
3096
+ Array(positionals)
3097
+ end
3098
+
3099
+ # Allocate positional arguments to definitions following Ruby semantics
3100
+ # Returns [allocation_hash, consumed_count] where consumed_count is the
3101
+ # number of non-nil positionals that were consumed by definitions.
3102
+ #
3103
+ # @param positionals [Array] the positional argument values
3104
+ #
3105
+ # @return [Array(Hash, Integer)] tuple of allocation hash and consumed count
3106
+ #
3107
+ # @api private
3108
+ def allocate_positionals(positionals)
3109
+ OperandAllocator.new(@operand_definitions).allocate(positionals)
3110
+ end
3111
+
3112
+ # Append a positional argument's value(s) to the CLI args array
3113
+ #
3114
+ # @param args [Array<String>] the argument array to append to
3115
+ #
3116
+ # @param value [Object] the positional value
3117
+ #
3118
+ # @param definition [Hash] the operand definition hash
3119
+ #
3120
+ # @return [void]
3121
+ #
3122
+ # @api private
3123
+ def append_positional_to_args(args, value, definition)
3124
+ return if positional_value_empty?(value, definition)
3125
+
3126
+ append_positional_value(args, value, definition[:repeatable])
3127
+ end
3128
+
3129
+ # Return true if a positional value is empty and should be skipped
3130
+ #
3131
+ # @param value [Object] the positional value
3132
+ #
3133
+ # @param definition [Hash] the operand definition hash
3134
+ #
3135
+ # @return [Boolean] true if the value should be skipped
3136
+ #
3137
+ # @api private
3138
+ def positional_value_empty?(value, definition)
3139
+ return true if value.nil?
3140
+
3141
+ definition[:repeatable] && value.respond_to?(:empty?) && value.empty?
3142
+ end
3143
+
3144
+ # Append a positional value to the args array, expanding arrays when repeatable
3145
+ #
3146
+ # @param args [Array<String>] the argument array to append to
3147
+ #
3148
+ # @param value [Object] the positional value
3149
+ #
3150
+ # @param repeatable [Boolean] whether the operand accepts multiple values
3151
+ #
3152
+ # @return [void]
3153
+ #
3154
+ # @api private
3155
+ def append_positional_value(args, value, repeatable)
3156
+ if repeatable
3157
+ args.concat(Array(value).map(&:to_s))
3158
+ else
3159
+ args << value.to_s
3160
+ end
3161
+ end
3162
+
3163
+ # Raise if more positional values were provided than definitions can absorb
3164
+ #
3165
+ # @param positionals [Array] the full list of provided positional values
3166
+ #
3167
+ # @param consumed_count [Integer] the number of positionals consumed by definitions
3168
+ #
3169
+ # @return [void]
3170
+ #
3171
+ # @raise [ArgumentError] if extra positional values remain after allocation
3172
+ #
3173
+ # @api private
3174
+ def check_unexpected_positionals(positionals, consumed_count)
3175
+ provided_count = positionals.compact.size
3176
+
3177
+ return if provided_count <= consumed_count
3178
+
3179
+ unexpected_count = provided_count - consumed_count
3180
+ unexpected = positionals.compact.last(unexpected_count)
3181
+ raise ArgumentError, "Unexpected positional arguments: #{unexpected.join(', ')}"
3182
+ end
3183
+
3184
+ # Raise if a required positional argument is missing or empty
3185
+ #
3186
+ # @param value [Object] the allocated positional value
3187
+ #
3188
+ # @param definition [Hash] the operand definition hash
3189
+ #
3190
+ # @return [void]
3191
+ #
3192
+ # @raise [ArgumentError] if a required positional is missing
3193
+ #
3194
+ # @api private
3195
+ def validate_required_positional(value, definition)
3196
+ return unless definition[:required]
3197
+ return if definition[:allow_nil] && value.nil?
3198
+ return unless value_empty?(value)
3199
+
3200
+ raise ArgumentError, "at least one value is required for #{definition[:name]}" if definition[:repeatable]
3201
+
3202
+ raise ArgumentError, "#{definition[:name]} is required"
3203
+ end
3204
+
3205
+ # Raise if a repeatable positional array contains nil values
3206
+ #
3207
+ # @param value [Object] the allocated positional value
3208
+ #
3209
+ # @param definition [Hash] the operand definition hash
3210
+ #
3211
+ # @return [void]
3212
+ #
3213
+ # @raise [ArgumentError] if the array contains nil elements
3214
+ #
3215
+ # @api private
3216
+ def validate_no_nil_values!(value, definition)
3217
+ return unless definition[:repeatable]
3218
+ return if value.nil? # Allow nil as "not provided"
3219
+
3220
+ # For repeatable positionals, check if array contains any nil values
3221
+ values = Array(value)
3222
+ return unless values.any?(&:nil?)
3223
+
3224
+ raise ArgumentError, "nil values are not allowed in repeatable positional argument: #{definition[:name]}"
3225
+ end
3226
+
3227
+ # Reject operand values that look like command-line options
3228
+ #
3229
+ # Operands appearing before a '--' separator boundary (or all operands
3230
+ # if no boundary exists) are validated to ensure they don't start with
3231
+ # a hyphen, which could be misinterpreted as a git option.
3232
+ #
3233
+ # @param allocation [Hash{Symbol => Object}] the allocated operand values
3234
+ #
3235
+ # @return [void]
3236
+ #
3237
+ # @raise [ArgumentError] if any pre-separator operand value starts with '-'
3238
+ #
3239
+ def validate_no_option_like_operands!(allocation)
3240
+ pre_separator_operands = operand_names_before_separator
3241
+ pre_separator_operands.each do |name|
3242
+ value = allocation[name]
3243
+ check_operand_not_option_like(name, value)
3244
+ end
3245
+ end
3246
+
3247
+ # Determine which operands appear before any '--' separator boundary
3248
+ #
3249
+ # Walks the ordered definitions and collects operand names until hitting
3250
+ # a `literal '--'` or an `end_of_options` declaration. All operands after
3251
+ # any such boundary are excluded from option-like validation.
3252
+ #
3253
+ # @return [Array<Symbol>] operand names that need option-like validation
3254
+ #
3255
+ def operand_names_before_separator
3256
+ names = []
3257
+ @ordered_definitions.each do |defn|
3258
+ break if separator_boundary_active?(defn)
3259
+
3260
+ names << defn[:name] if defn[:kind] == :operand && !operand_skip_cli?(defn[:name])
3261
+ end
3262
+ names
3263
+ end
3264
+
3265
+ # Check if an operand is configured with skip_cli: true
3266
+ #
3267
+ # @param name [Symbol] the operand name
3268
+ #
3269
+ # @return [Boolean] true if operand has skip_cli enabled
3270
+ #
3271
+ def operand_skip_cli?(name)
3272
+ operand_def = @operand_definitions.find { |d| d[:name] == name }
3273
+ operand_def[:skip_cli] == true
3274
+ end
3275
+
3276
+ # Check if a definition represents an active '--' separator boundary
3277
+ #
3278
+ # A `literal '--'` is always active. An `end_of_options` entry is also always active,
3279
+ # even when its runtime `--` may be suppressed by {#resolve_end_of_options_marker}.
3280
+ #
3281
+ # @param defn [Hash] a definition entry from @ordered_definitions
3282
+ #
3283
+ # @return [Boolean] true if this definition is an active '--' boundary
3284
+ #
3285
+ def separator_boundary_active?(defn)
3286
+ return true if literal_separator_flag?(defn)
3287
+ return true if defn[:kind] == :end_of_options
3288
+
3289
+ false
3290
+ end
3291
+
3292
+ # Check if a definition is a literal '--' static flag
3293
+ #
3294
+ # @param defn [Hash] the entry definition
3295
+ #
3296
+ # @return [Boolean] true if the definition emits a literal `--` boundary
3297
+ #
3298
+ def literal_separator_flag?(defn)
3299
+ defn[:kind] == :static && defn[:flag] == '--'
3300
+ end
3301
+
3302
+ # Check that a single operand value does not look like a command-line option
3303
+ #
3304
+ # @param name [Symbol] the operand name
3305
+ #
3306
+ # @param value [Object] the operand value
3307
+ #
3308
+ # @return [void]
3309
+ #
3310
+ # @raise [ArgumentError] if the value starts with '-'
3311
+ #
3312
+ def check_operand_not_option_like(name, value)
3313
+ case value
3314
+ when String
3315
+ raise_option_like_error(name, value) if value.start_with?('-')
3316
+ when Array
3317
+ raise_option_like_array_error(name, value)
3318
+ end
3319
+ end
3320
+
3321
+ # Raise if a single operand string value looks like a command-line option
3322
+ #
3323
+ # @param name [Symbol] the operand name (for the error message)
3324
+ #
3325
+ # @param value [String] the option-like string value
3326
+ #
3327
+ # @return [void]
3328
+ #
3329
+ # @raise [ArgumentError] if the string value starts with '-'
3330
+ #
3331
+ # @api private
3332
+ def raise_option_like_error(name, value)
3333
+ raise ArgumentError, "operand :#{name} value '#{value}' looks like a command-line option"
3334
+ end
3335
+
3336
+ # Raise if any element of an operand array looks like a command-line option
3337
+ #
3338
+ # @param name [Symbol] the operand name (for the error message)
3339
+ #
3340
+ # @param values [Array] the array of operand values to check
3341
+ #
3342
+ # @return [void]
3343
+ #
3344
+ # @raise [ArgumentError] if any array element starts with '-'
3345
+ #
3346
+ # @api private
3347
+ def raise_option_like_array_error(name, values)
3348
+ invalid = values.select { |v| v.is_a?(String) && v.start_with?('-') }
3349
+ return if invalid.empty?
3350
+
3351
+ raise ArgumentError,
3352
+ "operand :#{name} contains option-like values: #{invalid.map { |v| "'#{v}'" }.join(', ')}"
3353
+ end
3354
+
3355
+ # Check if a positional value is empty (not provided)
3356
+ #
3357
+ # Only nil means "not provided" for positionals. Empty strings and empty
3358
+ # arrays are valid values that should be passed through.
3359
+ #
3360
+ # @param value [Object] the value to check
3361
+ #
3362
+ # @return [Boolean] true if the value is nil
3363
+ #
3364
+ def value_empty?(value)
3365
+ value.nil?
3366
+ end
3367
+
3368
+ # Raise if opts contains any keys not registered in the alias map
3369
+ #
3370
+ # @param opts [Hash] the raw keyword options to check
3371
+ #
3372
+ # @option opts [Object] :"option_name" value for any registered option name
3373
+ #
3374
+ # @return [void]
3375
+ #
3376
+ # @raise [ArgumentError] if any option key is not registered
3377
+ #
3378
+ # @api private
3379
+ def validate_unsupported_options!(opts)
3380
+ unsupported = opts.keys - @alias_map.keys
3381
+ return if unsupported.empty?
3382
+
3383
+ raise ArgumentError, "Unsupported options: #{unsupported.map(&:inspect).join(', ')}"
3384
+ end
3385
+
3386
+ # Raise if the caller provided more than one alias from the same option group
3387
+ #
3388
+ # @param opts [Hash] the raw keyword options
3389
+ #
3390
+ # @option opts [Object] :"option_name" value for any registered option name
3391
+ #
3392
+ # @return [void]
3393
+ #
3394
+ # @raise [ArgumentError] if conflicting aliases are both present
3395
+ #
3396
+ # @api private
3397
+ def validate_conflicting_aliases!(opts)
3398
+ @option_definitions.each_value do |definition|
3399
+ aliases = definition[:aliases]
3400
+ next unless aliases.size > 1
3401
+
3402
+ provided = aliases & opts.keys
3403
+ next unless provided.size > 1
3404
+
3405
+ raise ArgumentError, "Conflicting options: #{provided.map(&:inspect).join(' and ')}"
3406
+ end
3407
+ end
3408
+
3409
+ # Rekey opts so every alias maps to its primary option name
3410
+ #
3411
+ # @param opts [Hash] the raw keyword options
3412
+ #
3413
+ # @option opts [Object] :"option_name" value for any registered option name
3414
+ #
3415
+ # @return [Hash] a new hash with all keys resolved to primary names
3416
+ #
3417
+ # @api private
3418
+ def normalize_aliases(opts)
3419
+ opts.transform_keys { |key| @alias_map[key] || key }
3420
+ end
3421
+
3422
+ # Raise if any required options are missing or nil when allow_nil is false
3423
+ #
3424
+ # @param opts [Hash] the normalized keyword options
3425
+ #
3426
+ # @option opts [Object] :"option_name" value for any registered option name
3427
+ #
3428
+ # @return [void]
3429
+ #
3430
+ # @raise [ArgumentError] if required options are missing or nil
3431
+ #
3432
+ # @api private
3433
+ def validate_required_options!(opts)
3434
+ missing, nil_not_allowed = collect_required_option_errors(opts)
3435
+ raise_missing_options_error(missing) if missing.any?
3436
+ raise_nil_options_error(nil_not_allowed) if nil_not_allowed.any?
3437
+ end
3438
+
3439
+ # Collect missing and nil-not-allowed option names from opts
3440
+ #
3441
+ # @param opts [Hash] the normalized keyword options
3442
+ #
3443
+ # @option opts [Object] :"option_name" value for any registered option name
3444
+ #
3445
+ # @return [Array(Array<Symbol>, Array<Symbol>)] tuple of missing and nil-invalid names
3446
+ #
3447
+ # @api private
3448
+ def collect_required_option_errors(opts)
3449
+ missing = []
3450
+ nil_not_allowed = []
3451
+ @option_definitions.each do |name, definition|
3452
+ next unless definition[:required]
3453
+
3454
+ missing << name unless opts.key?(name)
3455
+ nil_not_allowed << name if opts.key?(name) && opts[name].nil? && definition[:allow_nil] == false
3456
+ end
3457
+ [missing, nil_not_allowed]
3458
+ end
3459
+
3460
+ # Raise an error listing required options that were not provided
3461
+ #
3462
+ # @param missing [Array<Symbol>] the names of missing required options
3463
+ #
3464
+ # @return [void]
3465
+ #
3466
+ # @raise [ArgumentError] always
3467
+ #
3468
+ # @api private
3469
+ def raise_missing_options_error(missing)
3470
+ raise ArgumentError, "Required options not provided: #{missing.map(&:inspect).join(', ')}"
3471
+ end
3472
+
3473
+ # Raise an error listing required options that were provided as nil
3474
+ #
3475
+ # @param nil_not_allowed [Array<Symbol>] the names of options given nil when not allowed
3476
+ #
3477
+ # @return [void]
3478
+ #
3479
+ # @raise [ArgumentError] always
3480
+ #
3481
+ # @api private
3482
+ def raise_nil_options_error(nil_not_allowed)
3483
+ raise ArgumentError, "Required options cannot be nil: #{nil_not_allowed.map(&:inspect).join(', ')}"
3484
+ end
3485
+
3486
+ # Run validators and allowed_values checks for each option present in opts
3487
+ #
3488
+ # @param opts [Hash] the normalized keyword options
3489
+ #
3490
+ # @option opts [Object] :"option_name" value for any registered option name
3491
+ #
3492
+ # @return [void]
3493
+ #
3494
+ # @api private
3495
+ def validate_option_values!(opts)
3496
+ @option_definitions.each do |name, definition|
3497
+ next unless opts.key?(name)
3498
+
3499
+ validate_single_option!(name, opts[name], definition)
3500
+ end
3501
+ end
3502
+
3503
+ # Run the validator and allowed_values check for a single option value
3504
+ #
3505
+ # @param name [Symbol] the option name
3506
+ #
3507
+ # @param value [Object] the option value to validate
3508
+ #
3509
+ # @param definition [Hash] the option definition hash
3510
+ #
3511
+ # @return [void]
3512
+ #
3513
+ # @raise [ArgumentError] if validation fails
3514
+ #
3515
+ # @api private
3516
+ def validate_single_option!(name, value, definition)
3517
+ run_validator!(name, value, definition[:validator]) if definition[:validator]
3518
+ check_allowed_values!(name, value, definition) if definition[:allowed_values]
3519
+ end
3520
+
3521
+ # Call the validator proc and raise if it returns an error
3522
+ #
3523
+ # @param name [Symbol] the option name (for the default error message)
3524
+ #
3525
+ # @param value [Object] the option value to validate
3526
+ #
3527
+ # @param validator [Proc] the validator callable
3528
+ #
3529
+ # @return [void]
3530
+ #
3531
+ # @raise [ArgumentError] if the validator returns any value other than `true`
3532
+ #
3533
+ # @api private
3534
+ def run_validator!(name, value, validator)
3535
+ result = validator.call(value)
3536
+ return if result == true
3537
+
3538
+ error_msg = result.is_a?(String) ? result : "Invalid value for option: #{name}"
3539
+ raise ArgumentError, error_msg
3540
+ end
3541
+
3542
+ # Check that the option value is within the declared allowed_values set
3543
+ #
3544
+ # @param name [Symbol] the option name
3545
+ #
3546
+ # @param value [Object] the option value to check
3547
+ #
3548
+ # @param definition [Hash] the option definition hash
3549
+ #
3550
+ # @return [void]
3551
+ #
3552
+ # @raise [ArgumentError] if the value is not in the allowed set
3553
+ #
3554
+ # @api private
3555
+ def check_allowed_values!(name, value, definition)
3556
+ allowed = definition[:allowed_values]
3557
+ type = definition[:type]
3558
+ if definition[:repeatable]
3559
+ check_repeatable_allowed_values!(name, value, allowed, definition[:allow_empty], type)
3560
+ else
3561
+ check_single_allowed_value!(name, value, allowed, definition[:allow_empty], type)
3562
+ end
3563
+ end
3564
+
3565
+ # Convert an allowed_values enumerable to a frozen array of strings
3566
+ #
3567
+ # @param sym [Symbol] the option name (for error messages)
3568
+ #
3569
+ # @param values [Enumerable] the allowed values to coerce
3570
+ #
3571
+ # @return [Array<String>] a frozen array of stringified allowed values
3572
+ #
3573
+ # @raise [ArgumentError] if values is not Enumerable or is empty
3574
+ #
3575
+ # @api private
3576
+ def coerce_allowed_values_set!(sym, values)
3577
+ unless values.respond_to?(:map)
3578
+ raise ArgumentError,
3579
+ "allowed_values :#{sym} expects an Enumerable for `in:`, got #{values.class}"
3580
+ end
3581
+ arr = values.map(&:to_s)
3582
+ raise ArgumentError, "allowed_values :#{sym} must specify at least one allowed value" if arr.empty?
3583
+
3584
+ arr.freeze
3585
+ end
3586
+
3587
+ # Look up and validate the option definition for an allowed_values declaration
3588
+ #
3589
+ # @param sym [Symbol] the option or alias name
3590
+ #
3591
+ # @return [Hash] the option definition hash
3592
+ #
3593
+ # @raise [ArgumentError] if sym is unknown or not a value option type
3594
+ #
3595
+ # @api private
3596
+ def validate_allowed_values_definition!(sym)
3597
+ primary = @alias_map[sym]
3598
+ defn = primary && @option_definitions[primary]
3599
+ unless defn
3600
+ raise ArgumentError, ":#{sym} is not a value option" if @operand_definitions.any? { |d| d[:name] == sym }
3601
+
3602
+ raise ArgumentError, "unknown argument :#{sym} in allowed_values declaration"
3603
+ end
3604
+ unless VALUE_OPTION_TYPES_FOR_ALLOWED_VALUES.include?(defn[:type])
3605
+ raise ArgumentError, ":#{sym} is not a value option"
3606
+ end
3607
+
3608
+ defn
3609
+ end
3610
+
3611
+ # Check each element of a repeatable option value against the allowed set
3612
+ #
3613
+ # @param name [Symbol] the option name
3614
+ #
3615
+ # @param values [Object] the repeatable value (array or scalar)
3616
+ #
3617
+ # @param allowed [Array<String>] the allowed string values
3618
+ #
3619
+ # @param allow_empty [Boolean] whether empty strings are permitted
3620
+ #
3621
+ # @param type [Symbol] the option type (for boolean skip logic)
3622
+ #
3623
+ # @return [void]
3624
+ #
3625
+ # @raise [ArgumentError] if any element is not in the allowed set
3626
+ #
3627
+ # @api private
3628
+ def check_repeatable_allowed_values!(name, values, allowed, allow_empty, type)
3629
+ Array(values).each do |v|
3630
+ next if skip_allowed_values_check?(v, allow_empty, type)
3631
+
3632
+ unless allowed.include?(v.to_s)
3633
+ raise ArgumentError,
3634
+ "Invalid value for :#{name}: expected one of #{allowed.inspect}, got #{v.inspect}"
3635
+ end
3636
+ end
3637
+ end
3638
+
3639
+ # Check a single option value against the allowed set
3640
+ #
3641
+ # @param name [Symbol] the option name
3642
+ #
3643
+ # @param value [Object] the value to check
3644
+ #
3645
+ # @param allowed [Array<String>] the allowed string values
3646
+ #
3647
+ # @param allow_empty [Boolean] whether empty strings are permitted
3648
+ #
3649
+ # @param type [Symbol] the option type (for boolean skip logic)
3650
+ #
3651
+ # @return [void]
3652
+ #
3653
+ # @raise [ArgumentError] if the value is not in the allowed set
3654
+ #
3655
+ # @api private
3656
+ def check_single_allowed_value!(name, value, allowed, allow_empty, type)
3657
+ return if skip_allowed_values_check?(value, allow_empty, type)
3658
+
3659
+ return if allowed.include?(value.to_s)
3660
+
3661
+ raise ArgumentError,
3662
+ "Invalid value for :#{name}: expected one of #{allowed.inspect}, got #{value.inspect}"
3663
+ end
3664
+
3665
+ # Return true if an allowed_values check should be bypassed for this value
3666
+ #
3667
+ # @param value [Object] the value being checked
3668
+ #
3669
+ # @param allow_empty [Boolean] whether empty strings should be skipped
3670
+ #
3671
+ # @param type [Symbol] the option type (for flag_or_value boolean skip)
3672
+ #
3673
+ # @return [Boolean] true if the check should be skipped
3674
+ #
3675
+ # @api private
3676
+ def skip_allowed_values_check?(value, allow_empty, type)
3677
+ return true if value.nil?
3678
+ # Only skip boolean values for flag_or_value option types where true/false carry
3679
+ # semantic meaning (true = emit flag, false = suppress flag). For plain value
3680
+ # options, a boolean is an invalid value and should fail the allowed_values check.
3681
+ return true if [true, false].include?(value) && FLAG_OR_VALUE_OPTION_TYPES.include?(type)
3682
+ return true if value.to_s.empty? && allow_empty
3683
+
3684
+ false
3685
+ end
3686
+
3687
+ # Build a lambda that validates a value is one of the expected types
3688
+ #
3689
+ # @param option_name [Symbol] the option name (for error messages)
3690
+ #
3691
+ # @param expected_type [Class, Array<Class>] the expected Ruby type(s)
3692
+ #
3693
+ # @return [Proc] a validator lambda
3694
+ #
3695
+ # @api private
3696
+ def create_type_validator(option_name, expected_type)
3697
+ types = Array(expected_type)
3698
+
3699
+ lambda do |value|
3700
+ return true if value.nil? # nil values are universally skipped by should_skip_option?
3701
+ return true if types.any? { |t| value.is_a?(t) }
3702
+
3703
+ # Generate a helpful error message
3704
+ type_names = types.map(&:name).join(' or ')
3705
+ actual_type = value.class.name
3706
+ "The :#{option_name} option must be a #{type_names}, but was a #{actual_type}"
3707
+ end
3708
+ end
3709
+
3710
+ # Raise if any conflicting argument names are both present in the bound values
3711
+ #
3712
+ # @param opts [Hash] normalized keyword options
3713
+ #
3714
+ # @param allocated_positionals [Hash] the allocated positional values
3715
+ #
3716
+ # @option opts [Object] :"option_name" value for any registered option name
3717
+ #
3718
+ # @return [void]
3719
+ #
3720
+ # @raise [ArgumentError] if conflicting arguments are both present
3721
+ #
3722
+ # @api private
3723
+ def validate_conflicts!(opts, allocated_positionals = {})
3724
+ @conflicts.each do |conflict_group|
3725
+ provided = conflict_group.select { |name| conflict_present?(name, opts, allocated_positionals) }
3726
+ next if provided.size <= 1
3727
+
3728
+ formatted = provided.map { |name| ":#{name}" }.join(' and ')
3729
+ raise ArgumentError, "cannot specify #{formatted}"
3730
+ end
3731
+ end
3732
+
3733
+ # Return true if a named argument should be counted as present during conflict checking
3734
+ #
3735
+ # For registered keyword options only looks in opts; positional slots use
3736
+ # allocated_positionals. This prevents a positional operand that shares a
3737
+ # name with a keyword option from spuriously triggering keyword conflicts.
3738
+ #
3739
+ # @param name [Symbol] the argument name to check
3740
+ #
3741
+ # @param opts [Hash] normalized keyword options
3742
+ #
3743
+ # @param allocated_positionals [Hash] the allocated positional values
3744
+ #
3745
+ # @option opts [Object] :"option_name" value for any registered option name
3746
+ #
3747
+ # @return [Boolean] true if the named argument has a present bound value
3748
+ #
3749
+ # @api private
3750
+ def conflict_present?(name, opts, allocated_positionals)
3751
+ canonical_name = @alias_map[name] || name
3752
+ value = if @option_definitions.key?(canonical_name)
3753
+ opts[canonical_name]
3754
+ else
3755
+ allocated_positionals[canonical_name]
3756
+ end
3757
+ argument_present?(value)
3758
+ end
3759
+
3760
+ # Validate that no bound values match a forbidden exact-value tuple
3761
+ #
3762
+ # @param opts [Hash] normalized keyword options (aliases already resolved)
3763
+ #
3764
+ # @param allocated_positionals [Hash] the allocated positional values
3765
+ #
3766
+ # @option opts [Object] :"option_name" value for any registered option name
3767
+ #
3768
+ # @return [void]
3769
+ #
3770
+ # @raise [ArgumentError] if all names in a forbidden tuple are present with
3771
+ # their declared values
3772
+ #
3773
+ def validate_forbidden_values!(opts, allocated_positionals = {})
3774
+ @forbidden_values.each do |tuple|
3775
+ next unless forbidden_tuple_matches?(tuple, opts, allocated_positionals)
3776
+
3777
+ formatted = tuple.map { |name, value| ":#{name}=#{value.inspect}" }.join(' with ')
3778
+ raise ArgumentError, "cannot specify #{formatted}"
3779
+ end
3780
+ end
3781
+
3782
+ # Return true if every name in the tuple has a bound value equal to the
3783
+ # declared forbidden value.
3784
+ #
3785
+ # The check only fires when the key is actually present (bound) — an absent
3786
+ # key never triggers a forbidden-values match.
3787
+ #
3788
+ # @param tuple [Hash{Symbol => Object}] canonical name → forbidden value
3789
+ #
3790
+ # @param opts [Hash] normalized keyword options
3791
+ #
3792
+ # @param allocated_positionals [Hash] the allocated positional values
3793
+ #
3794
+ # @option opts [Object] :"option_name" value for any registered option name
3795
+ #
3796
+ # @return [Boolean] true if all tuple entries match the bound values
3797
+ #
3798
+ def forbidden_tuple_matches?(tuple, opts, allocated_positionals)
3799
+ tuple.all? do |name, forbidden_value|
3800
+ if opts.key?(name)
3801
+ opts[name] == forbidden_value
3802
+ elsif allocated_positionals.key?(name)
3803
+ allocated_positionals[name] == forbidden_value
3804
+ else
3805
+ false
3806
+ end
3807
+ end
3808
+ end
3809
+
3810
+ # Validate conditional and unconditional requires_one_of groups
3811
+ #
3812
+ # Each entry in @requires_one_of is a Hash with keys:
3813
+ # :names — Array of canonical argument names that must collectively satisfy
3814
+ # the at-least-one constraint
3815
+ # :condition — canonical trigger name (Symbol), or nil for unconditional groups
3816
+ # :single — true when declared via `requires` (affects error message wording)
3817
+ #
3818
+ # @param opts [Hash] normalized keyword options (aliases already resolved)
3819
+ #
3820
+ # @param allocated_positionals [Hash] the allocated positional values
3821
+ #
3822
+ # @option opts [Object] :"option_name" value for any registered option name
3823
+ #
3824
+ # @return [void]
3825
+ #
3826
+ # @raise [ArgumentError] if none of the arguments in any applicable group is present
3827
+ #
3828
+ def validate_requires_one_of!(opts, allocated_positionals = {})
3829
+ @requires_one_of.each do |entry|
3830
+ validate_requires_one_of_entry!(entry, opts, allocated_positionals)
3831
+ end
3832
+ end
3833
+
3834
+ # Validate a single requires_one_of entry
3835
+ #
3836
+ # @param entry [Hash] the group entry with :names, :condition, :single keys
3837
+ #
3838
+ # @param opts [Hash] normalized keyword options
3839
+ #
3840
+ # @param allocated_positionals [Hash] the allocated positional values
3841
+ #
3842
+ # @option opts [Object] :"option_name" value for any registered option name
3843
+ #
3844
+ # @return [void]
3845
+ #
3846
+ def validate_requires_one_of_entry!(entry, opts, allocated_positionals)
3847
+ condition = entry[:condition]
3848
+
3849
+ return if condition && !conflict_present?(condition, opts, allocated_positionals)
3850
+
3851
+ names = entry[:names]
3852
+ return if names.any? { |n| conflict_present?(n, opts, allocated_positionals) }
3853
+
3854
+ raise ArgumentError, requires_one_of_error_message(names, condition, entry[:single])
3855
+ end
3856
+
3857
+ # Build the error message for a failed requires_one_of check
3858
+ #
3859
+ # @param names [Array<Symbol>] the required argument names
3860
+ #
3861
+ # @param condition [Symbol, nil] the trigger argument name, or nil for unconditional
3862
+ #
3863
+ # @param single [Boolean] true when declared via `requires` (single required arg)
3864
+ #
3865
+ # @return [String] the error message
3866
+ #
3867
+ def requires_one_of_error_message(names, condition, single)
3868
+ formatted = names.map { |name| ":#{name}" }.join(', ')
3869
+ return "at least one of #{formatted} must be provided" unless condition
3870
+ return ":#{condition} requires #{formatted}" if single
3871
+
3872
+ ":#{condition} requires at least one of #{formatted}"
3873
+ end
3874
+
3875
+ # Validate a single name used in a requires_one_of declaration
3876
+ #
3877
+ # @param sym [Symbol] the name to validate
3878
+ #
3879
+ # @return [void]
3880
+ #
3881
+ # @raise [ArgumentError] if sym is not a known option or operand
3882
+ #
3883
+ def validate_requires_one_of_name!(sym)
3884
+ raise ArgumentError, "unknown argument :#{sym} in requires_one_of declaration" unless known_argument?(sym)
3885
+ end
3886
+
3887
+ # Validate a single name used in a requires or conditional requires_one_of declaration
3888
+ #
3889
+ # @param sym [Symbol] the name to validate
3890
+ #
3891
+ # @return [void]
3892
+ #
3893
+ # @raise [ArgumentError] if sym is not a known option or operand
3894
+ #
3895
+ def validate_requires_name!(sym)
3896
+ raise ArgumentError, "unknown argument :#{sym} in requires declaration" unless known_argument?(sym)
3897
+ end
3898
+
3899
+ # Canonicalize an array of argument names for a requires_one_of group
3900
+ #
3901
+ # Validates each name, resolves aliases to their primary name, and deduplicates.
3902
+ # For options, canonical name comes from alias_map; for positional-only operands
3903
+ # the name is used directly.
3904
+ #
3905
+ # @param names [Array<Symbol, String>] raw argument names
3906
+ #
3907
+ # @return [Array<Symbol>] canonical, deduplicated names
3908
+ #
3909
+ def canonicalize_requires_names(names)
3910
+ names.map do |name|
3911
+ sym = name.to_sym
3912
+ validate_requires_one_of_name!(sym)
3913
+ @alias_map[sym] || sym
3914
+ end.uniq
3915
+ end
3916
+
3917
+ # Validate and canonicalize the `when:` condition for requires/requires_one_of
3918
+ #
3919
+ # @param condition [Symbol, nil] the raw trigger argument name
3920
+ #
3921
+ # @return [Symbol, nil] canonical trigger name, or nil when condition is nil
3922
+ #
3923
+ def resolve_requires_condition(condition)
3924
+ return nil unless condition
3925
+
3926
+ trigger_sym = condition.to_sym
3927
+ validate_requires_name!(trigger_sym)
3928
+ @alias_map[trigger_sym] || trigger_sym
3929
+ end
3930
+
3931
+ # Return true if the given name refers to a defined option or operand
3932
+ #
3933
+ # @param name [Symbol] the argument name to look up
3934
+ #
3935
+ # @return [Boolean] true if the name is an option, alias, or operand
3936
+ def known_argument?(name)
3937
+ @alias_map.key?(name) || @operand_definitions.any? { |d| d[:name] == name }
3938
+ end
3939
+
3940
+ # Return true if a conflict-group value should be considered "present"
3941
+ #
3942
+ # A value is absent (not present) when it is nil, false, an empty array,
3943
+ # or an empty string. All other values — including non-empty arrays — are
3944
+ # present, regardless of their contents. This keeps validation consistent
3945
+ # with CLI emission: repeatable options (value_option, inline_value, etc.)
3946
+ # emit tokens for non-empty arrays even when every element is '' or false.
3947
+ #
3948
+ # @param value [Object] the argument value to test
3949
+ #
3950
+ # @return [Boolean] true if the value counts as present for validation
3951
+ def argument_present?(value)
3952
+ return false if value.nil?
3953
+ return false if value == false
3954
+ return false if value == []
3955
+ return false if value == ''
3956
+
3957
+ true
3958
+ end
3959
+
3960
+ # Bound arguments object returned by {Arguments#bind}
3961
+ #
3962
+ # Provides accessor methods for all defined options and positional arguments,
3963
+ # with automatic normalization of aliases to their canonical names.
3964
+ #
3965
+ # For every `flag_option`, both a plain accessor (e.g. `bound.force`) and a
3966
+ # `?`-suffixed predicate alias (e.g. `bound.force?`) are generated, following
3967
+ # Ruby convention for boolean predicates. Plain accessors are kept for backward
3968
+ # compatibility. `value_option` fields only receive plain accessors.
3969
+ #
3970
+ # **Reserved-name exception:** if the `?`-suffixed name conflicts with a name
3971
+ # in {RESERVED_NAMES} (e.g. `nil?`, `frozen?`), the predicate alias is *not*
3972
+ # generated to avoid overriding built-in `Object` methods. Use hash-style
3973
+ # access (`bound[:nil]`) when the flag name is reserved.
3974
+ #
3975
+ # @example Accessing bound arguments
3976
+ # args_def = Arguments.define do
3977
+ # flag_option :force
3978
+ # flag_option :remotes, as: ['-r', '--remotes']
3979
+ # operand :branch_names, repeatable: true
3980
+ # end
3981
+ # bound = args_def.bind('branch1', 'branch2', force: true, remotes: true)
3982
+ # bound.force # => true
3983
+ # bound.force? # => true # ? alias for flag_option
3984
+ # bound.remotes # => true
3985
+ # bound.remotes? # => true # ? alias for flag_option
3986
+ # bound.branch_names # => ['branch1', 'branch2']
3987
+ #
3988
+ # @example Splatting for command execution
3989
+ # args_def = Arguments.define do
3990
+ # flag_option :force
3991
+ # operand :file
3992
+ # end
3993
+ # bound = args_def.bind('test.txt', force: true)
3994
+ # bound.to_a # => ['--force', 'test.txt']
3995
+ #
3996
+ # @example Hash-style access for reserved names
3997
+ # args_def = Arguments.define do
3998
+ # value_option :hash
3999
+ # end
4000
+ # bound = args_def.bind(hash: 'abc123')
4001
+ # bound[:hash] # => 'abc123'
4002
+ #
4003
+ # @api private
4004
+ #
4005
+ class Bound
4006
+ # Names that cannot have accessor methods defined (would override Object methods)
4007
+ RESERVED_NAMES = (Object.instance_methods + [:to_ary]).freeze
4008
+
4009
+ # Canonical frozen empty hash returned by {#execution_options} when no
4010
+ # non-nil execution options are present.
4011
+ #
4012
+ # @return [Hash{Symbol => Object}] frozen empty execution options hash
4013
+ EMPTY_EXECUTION_OPTIONS = {}.freeze
4014
+
4015
+ # Execution options and values for command execution.
4016
+ #
4017
+ # Includes only options declared via {Arguments#execution_option} and
4018
+ # excludes options with nil values.
4019
+ #
4020
+ # @return [Hash{Symbol => Object}] frozen hash of execution option values
4021
+ attr_reader :execution_options
4022
+
4023
+ # Initialize a new frozen Bound object with accessor methods for all defined arguments
4024
+ #
4025
+ # @param args_array [Array<String>] the CLI argument array (frozen)
4026
+ #
4027
+ # @param options [Hash{Symbol => Object}] normalized options hash (frozen)
4028
+ #
4029
+ # @param positionals [Hash{Symbol => Object}] positional arguments hash (frozen)
4030
+ #
4031
+ # @param execution_option_names [Array<Symbol>] option names declared via {Arguments#execution_option}
4032
+ #
4033
+ # @param flag_names [Array<Symbol>] option names declared via {Arguments#flag_option}
4034
+ #
4035
+ # @option options [Object] :"option_name" bound value for any registered option name
4036
+ #
4037
+ def initialize(args_array, options, positionals, execution_option_names = [], flag_names = [])
4038
+ @args_array = args_array.freeze
4039
+ @options = options.freeze
4040
+ @positionals = positionals.freeze
4041
+ @execution_options = build_execution_options(execution_option_names)
4042
+
4043
+ # Define accessor methods (skip reserved names)
4044
+ @options.each_key { |name| define_accessor(name, @options) }
4045
+ @positionals.each_key { |name| define_accessor(name, @positionals) }
4046
+ define_flag_predicate_accessors(flag_names)
4047
+
4048
+ freeze
4049
+ end
4050
+
4051
+ # Returns the CLI arguments array for splatting
4052
+ #
4053
+ # This enables direct splatting: `command(*bound_args)`.
4054
+ #
4055
+ # Operands declared with `skip_cli: true` are intentionally excluded.
4056
+ #
4057
+ # @return [Array<String>] the CLI arguments
4058
+ def to_ary
4059
+ @args_array
4060
+ end
4061
+
4062
+ # Returns the CLI arguments array for splatting
4063
+ #
4064
+ # Ruby's splat operator in array literals uses `to_a` for expansion.
4065
+ # This enables: `['git', 'branch', *bound_args]`.
4066
+ #
4067
+ # Operands declared with `skip_cli: true` are intentionally excluded.
4068
+ #
4069
+ # @return [Array<String>] the CLI arguments
4070
+ def to_a
4071
+ @args_array
4072
+ end
4073
+
4074
+ # Hash-style access to option and positional values
4075
+ #
4076
+ # Use this for reserved names (like :hash, :class) that cannot have
4077
+ # accessor methods defined.
4078
+ #
4079
+ # @param key [Symbol] the option or positional name
4080
+ #
4081
+ # @return [Object, nil] the value, or nil if not found
4082
+ def [](key)
4083
+ return @options[key] if @options.key?(key)
4084
+ return @positionals[key] if @positionals.key?(key)
4085
+
4086
+ nil
4087
+ end
4088
+
4089
+ private
4090
+
4091
+ # Build the execution_options hash from the given option names
4092
+ #
4093
+ # @param execution_option_names [Array<Symbol>] option names declared as execution options
4094
+ #
4095
+ # @return [Hash{Symbol => Object}] frozen hash of non-nil execution option values
4096
+ #
4097
+ # @api private
4098
+ def build_execution_options(execution_option_names)
4099
+ result = execution_option_names.each_with_object({}) do |name, values|
4100
+ value = @options[name]
4101
+ values[name] = value unless value.nil?
4102
+ end
4103
+
4104
+ result.empty? ? EMPTY_EXECUTION_OPTIONS : result.freeze
4105
+ end
4106
+
4107
+ # Define an accessor method for the given name
4108
+ #
4109
+ # For `flag_option` names, a `?`-suffixed predicate alias is also defined
4110
+ # by {#initialize} after all plain accessors have been set up.
4111
+ #
4112
+ # @param name [Symbol] the option or positional name
4113
+ #
4114
+ # @param source [Hash] the hash to read from (@options or @positionals)
4115
+ #
4116
+ def define_accessor(name, source)
4117
+ return if RESERVED_NAMES.include?(name)
4118
+
4119
+ define_singleton_method(name) { source[name] }
4120
+ end
4121
+
4122
+ # Define `?`-suffixed predicate aliases for each flag option
4123
+ #
4124
+ # Skips any name whose `?` form appears in {RESERVED_NAMES} and skips
4125
+ # names that are not present in the options hash.
4126
+ #
4127
+ # @param flag_names [Array<Symbol>] flag option names
4128
+ #
4129
+ def define_flag_predicate_accessors(flag_names)
4130
+ flag_names.each do |name|
4131
+ predicate_name = :"#{name}?"
4132
+ next if RESERVED_NAMES.include?(predicate_name)
4133
+
4134
+ define_singleton_method(predicate_name) { flag_predicate?(@options[name]) }
4135
+ end
4136
+ end
4137
+
4138
+ # Return true if a flag value represents a truthy flag state
4139
+ #
4140
+ # @param value [Boolean, Integer, nil] the flag value
4141
+ #
4142
+ # @return [Boolean] true if the flag value represents an enabled flag
4143
+ #
4144
+ # @api private
4145
+ def flag_predicate?(value)
4146
+ return value.positive? if value.is_a?(Integer)
4147
+
4148
+ value == true
4149
+ end
4150
+ end
4151
+ end
4152
+
4153
+ # Allocates operand (positional argument) values to definitions following Ruby semantics.
4154
+ #
4155
+ # This class handles the complex logic of mapping positional values to their
4156
+ # definitions, supporting required, optional, and repeatable operands.
4157
+ #
4158
+ # @api private
4159
+ class OperandAllocator
4160
+ # @param definitions [Array<Hash>] operand definitions
4161
+ def initialize(definitions)
4162
+ @definitions = definitions
4163
+ end
4164
+
4165
+ # Allocate values to definitions
4166
+ #
4167
+ # @param values [Array] the positional argument values
4168
+ #
4169
+ # @return [Array(Hash, Integer)] tuple of allocation hash and consumed count
4170
+ def allocate(values)
4171
+ allocation = {}
4172
+ repeatable_index = @definitions.find_index { |d| d[:repeatable] }
4173
+
4174
+ consumed = if repeatable_index.nil?
4175
+ allocate_without_repeatable(values, allocation)
4176
+ else
4177
+ allocate_with_repeatable(values, allocation, repeatable_index)
4178
+ end
4179
+
4180
+ [allocation, consumed]
4181
+ end
4182
+
4183
+ private
4184
+
4185
+ # Allocate when there's no repeatable positional, following Ruby semantics:
4186
+ # - Required positionals at the END are reserved first
4187
+ # - Leading positionals get remaining values left-to-right
4188
+ # - Optional positionals are skipped when there aren't enough values
4189
+ #
4190
+ # @param values [Array] the positional argument values
4191
+ #
4192
+ # @param allocation [Hash] the allocation hash to populate
4193
+ #
4194
+ # @return [Integer] the number of non-nil positionals consumed
4195
+ #
4196
+ # @api private
4197
+ def allocate_without_repeatable(values, allocation)
4198
+ trailing = count_trailing_required
4199
+ leading_defs = @definitions[0...(@definitions.size - trailing)]
4200
+ trailing_defs = @definitions[(@definitions.size - trailing)..]
4201
+
4202
+ values_for_leading = [values.size - trailing, 0].max
4203
+ leading_values = values[0...values_for_leading]
4204
+ trailing_values = values[values_for_leading..]
4205
+
4206
+ consumed = allocate_leading(allocation, leading_defs, leading_values)
4207
+ consumed + allocate_trailing(allocation, trailing_defs, trailing_values)
4208
+ end
4209
+
4210
+ # Count the number of trailing required definitions
4211
+ #
4212
+ # @return [Integer] number of required definitions at the end of @definitions
4213
+ #
4214
+ # @api private
4215
+ def count_trailing_required
4216
+ count = 0
4217
+ @definitions.reverse_each do |d|
4218
+ break unless required?(d)
4219
+
4220
+ count += 1
4221
+ end
4222
+ count
4223
+ end
4224
+
4225
+ # Return true if a definition is required (required and has no default)
4226
+ #
4227
+ # @param definition [Hash] the operand definition hash
4228
+ #
4229
+ # @return [Boolean] true if the operand must be provided and has no default
4230
+ #
4231
+ # @api private
4232
+ def required?(definition)
4233
+ definition[:required] && definition[:default].nil?
4234
+ end
4235
+
4236
+ # Allocate leading positionals (those before any trailing required)
4237
+ # Required positionals consume values; optional ones only consume if extras available
4238
+ #
4239
+ # @param allocation [Hash] the allocation hash to populate
4240
+ #
4241
+ # @param definitions [Array<Hash>] the leading operand definitions
4242
+ #
4243
+ # @param values [Array] the values available for leading allocation
4244
+ #
4245
+ # @return [Integer] the number of non-nil positionals consumed
4246
+ #
4247
+ # @api private
4248
+ def allocate_leading(allocation, definitions, values)
4249
+ return 0 if definitions.empty?
4250
+
4251
+ state = LeadingAllocationState.new(definitions, values, method(:required?))
4252
+ state.allocate(allocation)
4253
+ end
4254
+
4255
+ # Allocate trailing required positionals from the end of the values array
4256
+ #
4257
+ # @param allocation [Hash] the allocation hash to populate
4258
+ #
4259
+ # @param definitions [Array<Hash>] the trailing operand definitions
4260
+ #
4261
+ # @param values [Array] the values remaining for trailing allocation
4262
+ #
4263
+ # @return [Integer] the number of non-nil positionals consumed
4264
+ #
4265
+ # @api private
4266
+ def allocate_trailing(allocation, definitions, values)
4267
+ consumed = 0
4268
+ definitions.each_with_index do |definition, index|
4269
+ allocation[definition[:name]] = index < values.size ? values[index] : definition[:default]
4270
+ consumed += 1 if index < values.size
4271
+ end
4272
+ consumed
4273
+ end
4274
+
4275
+ # Allocate values when a repeatable definition is present
4276
+ #
4277
+ # @param values [Array] the positional argument values
4278
+ #
4279
+ # @param allocation [Hash] the allocation hash to populate
4280
+ #
4281
+ # @param repeatable_index [Integer] the index of the repeatable definition
4282
+ #
4283
+ # @return [Integer] total non-nil positionals consumed
4284
+ #
4285
+ # @api private
4286
+ def allocate_with_repeatable(values, allocation, repeatable_index)
4287
+ parts = split_around_repeatable(repeatable_index)
4288
+ slices = calculate_repeatable_slices(values, parts)
4289
+
4290
+ pre_consumed = allocate_pre_repeatable_smart(allocation, parts[:pre], slices[:pre_values])
4291
+ repeatable_consumed = allocate_repeatable(
4292
+ allocation, parts[:repeatable], values, slices[:var_start], slices[:var_end]
4293
+ )
4294
+ post_consumed = allocate_post_repeatable(allocation, parts[:post], values, slices[:post_start])
4295
+
4296
+ pre_consumed + repeatable_consumed + post_consumed
4297
+ end
4298
+
4299
+ # Calculate start/end indices for pre, repeatable, and post slices
4300
+ #
4301
+ # @param values [Array] the full positional values array
4302
+ #
4303
+ # @param parts [Hash] the pre/repeatable/post definition parts
4304
+ #
4305
+ # @return [Hash] slice indices: :pre_values, :var_start, :var_end, :post_start
4306
+ #
4307
+ # @api private
4308
+ def calculate_repeatable_slices(values, parts)
4309
+ post_required_count = count_required(parts[:post])
4310
+ pre_available = [values.size - post_required_count, 0].max
4311
+ pre_end = [pre_available, parts[:pre].size].min
4312
+ post_start = [values.size - parts[:post].size, pre_end].max
4313
+
4314
+ {
4315
+ pre_values: values[0...pre_end],
4316
+ var_start: pre_end,
4317
+ var_end: post_start,
4318
+ post_start: post_start
4319
+ }
4320
+ end
4321
+
4322
+ # Count required definitions in the given list
4323
+ #
4324
+ # @param definitions [Array<Hash>] the operand definitions to count
4325
+ #
4326
+ # @return [Integer] the number of required definitions
4327
+ #
4328
+ # @api private
4329
+ def count_required(definitions)
4330
+ definitions.count { |d| required?(d) }
4331
+ end
4332
+
4333
+ # Split @definitions into pre, repeatable, and post parts
4334
+ #
4335
+ # @param repeatable_index [Integer] the index of the repeatable definition
4336
+ #
4337
+ # @return [Hash] parts hash with :pre, :repeatable, and :post keys
4338
+ #
4339
+ # @api private
4340
+ def split_around_repeatable(repeatable_index)
4341
+ {
4342
+ pre: @definitions[0...repeatable_index],
4343
+ repeatable: @definitions[repeatable_index],
4344
+ post: @definitions[(repeatable_index + 1)..]
4345
+ }
4346
+ end
4347
+
4348
+ # Allocate pre-repeatable positionals with Ruby-like semantics
4349
+ # (required get values first, optional only if extra values available)
4350
+ #
4351
+ # @param allocation [Hash] the allocation hash to populate
4352
+ #
4353
+ # @param definitions [Array<Hash>] the pre-repeatable operand definitions
4354
+ #
4355
+ # @param values [Array] the values available for pre-repeatable allocation
4356
+ #
4357
+ # @return [Integer] the number of non-nil positionals consumed
4358
+ #
4359
+ # @api private
4360
+ def allocate_pre_repeatable_smart(allocation, definitions, values)
4361
+ return 0 if definitions.empty?
4362
+
4363
+ state = LeadingAllocationState.new(definitions, values, method(:required?))
4364
+ state.allocate(allocation)
4365
+ end
4366
+
4367
+ # Allocate the repeatable definition's slice of values
4368
+ #
4369
+ # @param allocation [Hash] the allocation hash to populate
4370
+ #
4371
+ # @param definition [Hash] the repeatable operand definition
4372
+ #
4373
+ # @param values [Array] the full values array
4374
+ #
4375
+ # @param start_idx [Integer] the start index of the repeatable slice
4376
+ #
4377
+ # @param end_idx [Integer] the end index (exclusive) of the repeatable slice
4378
+ #
4379
+ # @return [Integer] the number of non-nil values consumed by the repeatable
4380
+ #
4381
+ # @api private
4382
+ def allocate_repeatable(allocation, definition, values, start_idx, end_idx)
4383
+ repeatable_values = values[start_idx...end_idx] || []
4384
+ allocation[definition[:name]] =
4385
+ if repeatable_values.empty? || repeatable_values.all?(&:nil?)
4386
+ definition[:default]
4387
+ else
4388
+ repeatable_values
4389
+ end
4390
+ repeatable_values.compact.size
4391
+ end
4392
+
4393
+ # Allocate post-repeatable positionals from the end of the values array
4394
+ #
4395
+ # @param allocation [Hash] the allocation hash to populate
4396
+ #
4397
+ # @param definitions [Array<Hash>] the post-repeatable operand definitions
4398
+ #
4399
+ # @param values [Array] the full values array
4400
+ #
4401
+ # @param post_start [Integer] the index where post definitions begin
4402
+ #
4403
+ # @return [Integer] the number of non-nil positionals consumed
4404
+ #
4405
+ # @api private
4406
+ def allocate_post_repeatable(allocation, definitions, values, post_start)
4407
+ consumed = 0
4408
+ definitions.each_with_index do |definition, offset|
4409
+ pos_index = post_start + offset
4410
+ value = pos_index < values.size ? values[pos_index] : nil
4411
+ allocation[definition[:name]] = value.nil? ? definition[:default] : value
4412
+ consumed += 1 if pos_index < values.size && !values[pos_index].nil?
4413
+ end
4414
+ consumed
4415
+ end
4416
+
4417
+ # Encapsulates state for allocating leading positionals
4418
+ #
4419
+ # @api private
4420
+ class LeadingAllocationState
4421
+ # Initialize allocation state for the given leading definitions and values
4422
+ #
4423
+ # @param definitions [Array<Hash>] the leading operand definitions
4424
+ #
4425
+ # @param values [Array] the values available for allocation
4426
+ #
4427
+ # @param required_check [Method] callable returning true if a definition is required
4428
+ #
4429
+ # @return [void]
4430
+ #
4431
+ def initialize(definitions, values, required_check)
4432
+ @definitions = definitions
4433
+ @values = values
4434
+ @required_check = required_check
4435
+ @required_count = definitions.count { |d| required_check.call(d) }
4436
+ @extra_for_optionals = [values.size - @required_count, 0].max
4437
+ @val_idx = 0
4438
+ @opt_idx = 0
4439
+ @consumed = 0
4440
+ end
4441
+
4442
+ # Allocates leading positional values and returns consumed non-nil count
4443
+ #
4444
+ # @param allocation [Hash{Symbol => Object}] allocation hash to populate
4445
+ #
4446
+ # @return [Integer] number of non-nil positional values consumed
4447
+ #
4448
+ def allocate(allocation)
4449
+ @definitions.each { |definition| allocate_one(allocation, definition) }
4450
+ @consumed
4451
+ end
4452
+
4453
+ private
4454
+
4455
+ # Allocate a single definition, dispatching to required or optional branch
4456
+ #
4457
+ # @param allocation [Hash] the allocation hash to populate
4458
+ #
4459
+ # @param definition [Hash] the operand definition to allocate
4460
+ #
4461
+ # @return [void]
4462
+ #
4463
+ # @api private
4464
+ def allocate_one(allocation, definition)
4465
+ if @required_check.call(definition)
4466
+ allocate_required(allocation, definition)
4467
+ else
4468
+ allocate_optional(allocation, definition)
4469
+ end
4470
+ end
4471
+
4472
+ # Allocate a required definition, consuming the next value unconditionally
4473
+ #
4474
+ # @param allocation [Hash] the allocation hash to populate
4475
+ #
4476
+ # @param definition [Hash] the operand definition to allocate
4477
+ #
4478
+ # @return [void]
4479
+ #
4480
+ # @api private
4481
+ def allocate_required(allocation, definition)
4482
+ allocation[definition[:name]] = value_or_default(definition)
4483
+ @consumed += 1 if @val_idx < @values.size
4484
+ @val_idx += 1
4485
+ end
4486
+
4487
+ # Allocate an optional definition, consuming only if extra values remain
4488
+ #
4489
+ # @param allocation [Hash] the allocation hash to populate
4490
+ #
4491
+ # @param definition [Hash] the operand definition to allocate
4492
+ #
4493
+ # @return [void]
4494
+ #
4495
+ # @api private
4496
+ def allocate_optional(allocation, definition)
4497
+ if @opt_idx < @extra_for_optionals
4498
+ allocation[definition[:name]] = value_or_default(definition)
4499
+ @consumed += 1
4500
+ @val_idx += 1
4501
+ else
4502
+ allocation[definition[:name]] = definition[:default]
4503
+ end
4504
+ @opt_idx += 1
4505
+ end
4506
+
4507
+ # Return the current value from @values or the definition default if exhausted
4508
+ #
4509
+ # @param definition [Hash] the operand definition
4510
+ #
4511
+ # @return [Object] the value or default
4512
+ #
4513
+ # @api private
4514
+ def value_or_default(definition)
4515
+ @val_idx < @values.size ? @values[@val_idx] : definition[:default]
4516
+ end
4517
+ end
4518
+ end
4519
+ # rubocop:enable Metrics/ParameterLists
4520
+ end
4521
+ end