git 4.3.2 → 5.0.0.beta.1

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