rubycli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9809e4d2f9c1ddb0b97f7c7cede2d665888726952e96ed5b14584e9230c617b
4
- data.tar.gz: 538af61eb924c66fdd0bf26df5bdcfaba66fbec76daa70ddbfcef943d2cfa654
3
+ metadata.gz: 14562bc06b2479369c2951a54cac6f8f502e5ff81ae453b25bdb225b2017f671
4
+ data.tar.gz: 731a6704bac4ca7d9471b4ff00d7476745e81c442666e99ca3b710d70c9e4622
5
5
  SHA512:
6
- metadata.gz: efc21b5eddfb00f727ae0a056c7507ee36e27cc3da47c7bad0d68f310862f6cb3f7511328ec9bc2ac40f1493fa9b340892482a858623c57ec6ff8a9069ea9f73
7
- data.tar.gz: 1f21f11831d6b90b4981034827c33ac9a27a48385fae73cf22e806b7d5728835209f231977c1ca6f609cecd7bdfe12cae30d74adeaf409e0231e616262ced993
6
+ metadata.gz: 2ef973534a268ac8475f21f9d26ef16202ab5f4a89a69c3ea94a0700f1d89a5b17e533d5e821f8c6833855c91f72a5d5d6fcbfb75716c9a3648bf52e9c26600b
7
+ data.tar.gz: c2cc78a8dbc2ec9185c6fcb598ea5be2160ea336021932e2ab7ffb58b5b2be5f8fad078ed379a26a33c66241ed69a17c1edd9bd852663299747a630edd9da235
data/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.0] - Unreleased
3
+ ## [0.1.2] - 2025-11-06
4
+
5
+ ### Added
6
+ - Example `examples/documentation_style_showcase.rb` covering all supported documentation notations.
7
+ - Parenthesised `(Type)` and `(type: Type)` annotations for positional and option documentation.
8
+
9
+ ### Documentation
10
+ - Linked the showcase example from the English and Japanese READMEs to highlight the new syntax.
11
+ - Clarified that optional arguments do not require brackets in comments and noted the current comma-delimited behaviour for repeated values.
12
+ - Documented the refined literal parsing guard rails so only structured values auto-coerce while plain strings stay untouched unless type hints request otherwise.
13
+
14
+ ### Fixed
15
+ - Restored uppercase positional placeholders in the generated help output so the default style stays consistent.
16
+ - Help renderer now preserves documented placeholder casing instead of wrapping everything in `<...>`.
17
+ - Default literal parsing now skips generic strings to avoid collapsing comma-separated inputs, while still supporting array coercion when documentation specifies list types.
18
+
19
+ ## [0.1.1] - 2025-11-01
4
20
 
5
21
  ### Added
6
22
  - Initial public release of Rubycli with the `rubycli` executable for running documented Ruby classes and modules.
data/README.ja.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Rubycli — Python Fire 風の Ruby 向け CLI
2
2
 
3
+ ![Rubycli ロゴ](assets/rubycli-logo.png)
4
+
3
5
  Rubycli は Ruby のクラス/モジュールに書いたコメントから CLI を自動生成する小さなフレームワークです。Python Fire にインスパイアされていますが、互換や公式ポートを目指すものではありません。Ruby のコメント記法と型アノテーションに合わせて設計しています。
4
6
 
5
7
  > English guide is available in [README.md](README.md).
@@ -28,7 +30,7 @@ Usage: hello_app.rb COMMAND [arguments]
28
30
 
29
31
  Available commands:
30
32
  Class methods:
31
- greet <name>
33
+ greet <NAME>
32
34
 
33
35
  Detailed command help: hello_app.rb COMMAND help
34
36
  Enable debug logging: --debug or RUBYCLI_DEBUG=true
@@ -40,10 +42,10 @@ rubycli examples/hello_app.rb greet
40
42
 
41
43
  ```text
42
44
  Error: wrong number of arguments (given 0, expected 1)
43
- Usage: hello_app.rb greet <NAME>
45
+ Usage: hello_app.rb greet NAME
44
46
 
45
47
  Positional arguments:
46
- NAME
48
+ NAME required
47
49
  ```
48
50
 
49
51
  ```bash
@@ -102,7 +104,7 @@ Usage: hello_app_with_docs.rb COMMAND [arguments]
102
104
 
103
105
  Available commands:
104
106
  Class methods:
105
- greet <name> [--shout=<value>]
107
+ greet <NAME> [--shout]
106
108
 
107
109
  Detailed command help: hello_app_with_docs.rb COMMAND help
108
110
  Enable debug logging: --debug or RUBYCLI_DEBUG=true
@@ -113,13 +115,13 @@ rubycli examples/hello_app_with_docs.rb greet --help
113
115
  ```
114
116
 
115
117
  ```text
116
- Usage: hello_app_with_docs.rb greet <NAME> [--shout]
118
+ Usage: hello_app_with_docs.rb greet NAME [--shout]
117
119
 
118
120
  Positional arguments:
119
- NAME [String] 挨拶対象
121
+ NAME [String] required 挨拶対象
120
122
 
121
123
  Options:
122
- --shout [Boolean] 大文字で出力 (default: false)
124
+ --shout [Boolean] optional 大文字で出力 (default: false)
123
125
  ```
124
126
 
125
127
  ```bash
@@ -156,7 +158,7 @@ end
156
158
 
157
159
  - コメントベースで CLI オプションやヘルプを自動生成
158
160
  - YARD 形式と `NAME [Type] 説明…` の簡潔記法を同時サポート
159
- - `--json-args` で渡された引数を自動的に JSON パース
161
+ - 引数はデフォルトで安全なリテラルとして解釈し、必要に応じて厳格 JSON モードや Ruby eval モードを切り替え可能
160
162
  - `--pre-script`(エイリアス: `--init`)で任意の Ruby コードを評価し、その結果オブジェクトを公開
161
163
  - `RUBYCLI_STRICT=ON` で有効化できる厳格モードにより、コメントとシグネチャの矛盾を警告として検知可能
162
164
 
@@ -177,21 +179,17 @@ end
177
179
 
178
180
  ## インストール
179
181
 
180
- まだ RubyGems で公開していません。リポジトリをクローンしてローカルパスを Bundler に指定するか、`.gemspec` 追加後に `gem build` で `.gem` を作成してインストールしてください。
182
+ Rubycli RubyGems からインストールできます。
181
183
 
182
184
  ```bash
183
- git clone https://github.com/inakaegg/rubycli.git
184
- cd rubycli
185
- # gem build rubycli.gemspec
186
- gem build rubycli.gemspec
187
- gem install rubycli-<version>.gem
185
+ gem install rubycli
188
186
  ```
189
187
 
190
188
  Bundler 例:
191
189
 
192
190
  ```ruby
193
191
  # Gemfile
194
- gem "rubycli", path: "path/to/rubycli"
192
+ gem "rubycli"
195
193
  ```
196
194
 
197
195
  ## クイックスタート(Rubycli をスクリプトに組み込む)
@@ -247,22 +245,46 @@ rubycli scripts/multi_runner.rb Admin::Runner list --active
247
245
 
248
246
  ## コメント記法
249
247
 
250
- | 用途 | YARD 互換 | 簡潔記法 |
251
- | ---- | --------- | -------- |
252
- | 位置引数 | `@param name [Type] 説明` | `NAME [Type] 説明`(NAME は大文字) |
253
- | キーワード引数 | 同上 | `--flag -f FLAG [Type] 説明` |
248
+ | 用途 | YARD 互換 | Rubycli 標準 |
249
+ | ---- | --------- | ----------- |
250
+ | 位置引数 | `@param name [Type] 説明` | `NAME [Type] 説明` |
251
+ | キーワード引数 | 同上 | `--flag -f VALUE [Type] 説明` |
254
252
  | 戻り値 | `@return [Type] 説明` | `=> [Type] 説明` |
255
253
 
256
- 型は `String` `Integer` のほか、`String[]` や `Array<String>`, `String | nil` なども利用できます。`[VALUE]` や `[VALUE...]` といったプレースホルダ表現で、真偽値や可変長引数を推論させられます。型名を省略した大文字プレースホルダ(例: `--quiet`)は自動的に Boolean フラグとして扱われます。
254
+ 短いオプション(`-f` など)は任意で、登場順も自由です。Rubycli 標準の書き方では次の例が同義になります。
255
+
256
+ - `--flag -f VALUE [Type] 説明`
257
+ - `--flag VALUE [Type] 説明`
258
+ - `-f --flag VALUE [Type] 説明`
259
+
260
+ README のサンプルは既定スタイルとして大文字プレースホルダ(`NAME`, `VALUE` など)を使用しています。次項以降の表記揺れは、必要に応じて選べる追加記法です。
261
+
262
+ ### 互換プレースホルダ表記
263
+
264
+ コメントやヘルプ出力では次の表記も同じ意味として解釈されます。
265
+
266
+ - 山括弧で値を明示: `--flag <value>`, `NAME [<value>]`
267
+ - ロングオプションの `=` 付き表記: `--flag=<value>`
268
+ - 繰り返し指定: `VALUE...`, `<value>...`
269
+
270
+ 実行時には `--flag VALUE`, `--flag <value>`, `--flag=<value>` のどれで入力しても同じ扱いです。プロジェクトで読みやすいスタイルを選択してください。`[VALUE]` や `[VALUE...]` のような表記を使うと、真偽値・任意値・リストなどの推論が働きます。値プレースホルダを省略したオプション(例: `--quiet`)は自動で Boolean フラグとして扱われます。
271
+
272
+ > 補足: コメント内で任意引数を角括弧で表す必要はありません。Ruby 側のメソッドシグネチャから必須/任意は自動判定され、ヘルプ出力では Rubycli が適切に角括弧を追加します。
273
+
274
+ 型ヒントは `[String]`, `(String)`, `(type: String)` のように角括弧・丸括弧・`type:` プレフィックスのいずれでも指定できます。複数型は `(String, nil)` や `(type: String, nil)` のように列挙してください。
275
+
276
+ `VALUE...` のような繰り返し指定(`TAG...` など)や、`[String[]]` / `Array<String>` といった配列型の注釈が付いたオプションは配列として扱われます。JSON/YAML 形式のリスト(例: `--tags '["build","test"]'`)を渡すか、カンマ区切り文字列(`--tags "build,test"`)を渡すことで配列に変換されます。スペース区切りの複数値入力(`--tags build test`)にはまだ対応しておらず、繰り返し注記のないオプションは従来どおりスカラーとして扱われます。
257
277
 
258
278
  代表的な推論例:
259
279
 
260
- - `ARG1` のように型ラベルを省略した大文字プレースホルダは既定で `String` として扱われます。
261
- - `--name ARG1` のようにオプションへ大文字プレースホルダだけを指定しても同じく `String` が推論されます。
280
+ - `ARG1` のように型ラベルを省略したプレースホルダは既定で `String` として扱われます。
281
+ - `--name ARG1` のようにオプションへプレースホルダだけを指定しても同じく `String` が推論されます。
262
282
  - `--verbose` のように値プレースホルダを省略したオプションは Boolean フラグとして扱われます。
263
283
 
264
284
  `@example` や `@raise`, `@see`, `@deprecated` などその他の YARD タグは、現状ヘルプ出力には反映されません。
265
285
 
286
+ > すべての記法をまとめて試したい場合は `rubycli examples/documentation_style_showcase.rb canonical --help` や `... angled --help` などを実行してみてください。
287
+
266
288
  従来の `@param` 記法も既定で利用できます。簡潔なプレースホルダ記法だけに限定したい場合は `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` を設定してください(厳格モードでの検証は継続されます)。
267
289
 
268
290
  ### コメントが不足している場合のフォールバック
@@ -293,7 +315,7 @@ Usage: fallback_example.rb COMMAND [arguments]
293
315
 
294
316
  Available commands:
295
317
  Class methods:
296
- scale <amount> [<factor>] [--clamp=<value>] [--notify=<value>]
318
+ scale AMOUNT [<FACTOR>] [--clamp=<value>] [--notify]
297
319
 
298
320
  Detailed command help: fallback_example.rb COMMAND help
299
321
  Enable debug logging: --debug or RUBYCLI_DEBUG=true
@@ -304,15 +326,15 @@ rubycli examples/fallback_example.rb scale --help
304
326
  ```
305
327
 
306
328
  ```text
307
- Usage: fallback_example.rb scale <AMOUNT> [<FACTOR>] [--clamp=<value>] [--notify]
329
+ Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=<CLAMP>] [--notify]
308
330
 
309
331
  Positional arguments:
310
- AMOUNT [Integer] 処理対象の数値
311
- [FACTOR] (default: 2)
332
+ AMOUNT [Integer] required 処理対象の数値
333
+ FACTOR optional (default: 2)
312
334
 
313
335
  Options:
314
- --clamp CLAMP (type: String) (default: nil)
315
- --notify (type: Boolean) (default: false)
336
+ --clamp=<CLAMP> [String] optional (default: nil)
337
+ --notify [Boolean] optional (default: false)
316
338
  ```
317
339
 
318
340
  `AMOUNT` だけがドキュメント化されていますが、`factor` や `clamp`, `notify` も自動的に補完され、既定値や型が推論されていることがわかります。コメントとシグネチャの矛盾を早期に検知したい場合は `RUBYCLI_STRICT=ON` で厳格モードを有効化してください。
@@ -332,27 +354,27 @@ Options:
332
354
  - `@param` の行に続く箇条書きや補足行は CLI の自動生成には使われません。補足情報を表示したい場合は、`--flag ...` 行の説明に含めるか、README など別のドキュメントで扱ってください。
333
355
  - `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` にすると `@param`/`@return` などのタグは警告扱いになります。プロジェクト内で簡潔記法へ統一するときはこの環境変数で段階的に移行できます。
334
356
 
335
- ## JSON モード
336
-
337
- CLI 実行時に `--json-args` を付けると、後続の引数が JSON として解釈され Ruby オブジェクトに変換されます。
357
+ ## 引数解析モード
338
358
 
339
- ```bash
340
- rubycli --json-args my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]'
341
- ```
359
+ ### 既定のリテラル解析
342
360
 
343
- プログラム側では `Rubycli.with_json_mode(true) { }` で同じ効果を得られます。
361
+ Rubycli `{` や `[`、クォート、YAML の先頭記号といった「構造化リテラルらしい」形の引数に対して `Psych.safe_load` を試み、成功すれば Ruby の配列/ハッシュ/真偽値に変換してからメソッドへ渡します。たとえば `--names='["Alice","Bob"]'` や `--config='{foo: 1}'` のような値は追加フラグ無しでネイティブな配列・ハッシュとして届きます。一方、プレーンな `1,2,3` のような文字列はこの段階ではそのまま維持されます(コメントで `String[]` や `TAG...` と宣言されている場合は後段で配列に整形されます)。扱えない形式は自動的に文字列へフォールバックするため、`"2024-01-01"` のような値もそのまま文字列で受け取れますし、構文が崩れていても CLI 全体が落ちることはありません。
344
362
 
345
- ## Eval モード
363
+ ### JSON モード
346
364
 
347
- `--eval-args` を使うと、後続の引数を Ruby コードとして評価した結果を CLI に渡せます。JSON では表現しづらいオブジェクトを扱いたいときに便利です。
365
+ CLI 実行時に `--json-args`(短縮形 `-j`)を付けると、後続の引数が厳格に JSON として解釈されます。
348
366
 
349
367
  ```bash
350
- rubycli --eval-args scripts/data_cli.rb DataCLI run '(1..10).to_a'
368
+ rubycli -j my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]'
351
369
  ```
352
370
 
353
- 評価は `Object.new.instance_eval { binding }` に対して行われるため、信頼できる環境でのみ利用してください。プログラム側からは `Rubycli.with_eval_mode(true) { … }` で有効化できます。
371
+ YAML 固有の書き方は拒否され、無効な JSON であれば `JSON::ParserError` が発生するため、入力の妥当性を強く保証したいときに便利です。プログラム側では `Rubycli.with_json_mode(true) { … }` で有効化できます。
372
+
373
+ ### Eval モード
374
+
375
+ `--eval-args`(短縮形 `-e`)を使うと、後続の引数を Ruby コードとして評価した結果を CLI に渡せます。JSON や YAML では表現しづらいオブジェクトを扱いたいときに便利ですが、評価は `Object.new.instance_eval { binding }` 上で行われるため、信頼できる入力に限定してください。コード内では `Rubycli.with_eval_mode(true) { … }` で切り替えられます。
354
376
 
355
- `--eval-args` と `--json-args` は同時指定できません。両方付けた場合はエラーになります。
377
+ `--eval-args`/`-e` と `--json-args`/`-j` は同時指定できません。どちらのモードも既定のリテラル解析を拡張する位置づけなので、用途に応じて厳格な JSON か Ruby eval のどちらかを選択してください。
356
378
 
357
379
  ## Pre-script ブートストラップ
358
380
 
data/README.md CHANGED
@@ -1,9 +1,13 @@
1
1
  # Rubycli — Python Fire-inspired CLI for Ruby
2
2
 
3
+ ![Rubycli logo](assets/rubycli-logo.png)
4
+
3
5
  Rubycli turns existing Ruby classes and modules into CLIs by reading their documentation comments. It is inspired by [Python Fire](https://github.com/google/python-fire) but is not a drop-in port or an official project; the focus here is Ruby’s documentation conventions and type annotations.
4
6
 
5
7
  > 🇯🇵 Japanese documentation is available in [README.ja.md](README.ja.md).
6
8
 
9
+ ![Rubycli demo showing generated commands and invocation](assets/rubycli-demo.gif)
10
+
7
11
  ### 1. Existing Ruby script (Rubycli unaware)
8
12
 
9
13
  ```ruby
@@ -28,7 +32,7 @@ Usage: hello_app.rb COMMAND [arguments]
28
32
 
29
33
  Available commands:
30
34
  Class methods:
31
- greet <name>
35
+ greet <NAME>
32
36
 
33
37
  Detailed command help: hello_app.rb COMMAND help
34
38
  Enable debug logging: --debug or RUBYCLI_DEBUG=true
@@ -40,10 +44,10 @@ rubycli examples/hello_app.rb greet
40
44
 
41
45
  ```text
42
46
  Error: wrong number of arguments (given 0, expected 1)
43
- Usage: hello_app.rb greet <NAME>
47
+ Usage: hello_app.rb greet NAME
44
48
 
45
49
  Positional arguments:
46
- NAME
50
+ NAME required
47
51
  ```
48
52
 
49
53
  ```bash
@@ -102,7 +106,7 @@ Usage: hello_app_with_docs.rb COMMAND [arguments]
102
106
 
103
107
  Available commands:
104
108
  Class methods:
105
- greet <name> [--shout=<value>]
109
+ greet <NAME> [--shout]
106
110
 
107
111
  Detailed command help: hello_app_with_docs.rb COMMAND help
108
112
  Enable debug logging: --debug or RUBYCLI_DEBUG=true
@@ -113,13 +117,13 @@ rubycli examples/hello_app_with_docs.rb greet --help
113
117
  ```
114
118
 
115
119
  ```text
116
- Usage: hello_app_with_docs.rb greet <NAME> [--shout]
120
+ Usage: hello_app_with_docs.rb greet NAME [--shout]
117
121
 
118
122
  Positional arguments:
119
- NAME [String] Name to greet
123
+ NAME [String] required Name to greet
120
124
 
121
125
  Options:
122
- --shout [Boolean] Print in uppercase (default: false)
126
+ --shout [Boolean] optional Print in uppercase (default: false)
123
127
  ```
124
128
 
125
129
  ```bash
@@ -143,7 +147,12 @@ end
143
147
 
144
148
  ### 3. (Optional) Embed the runner inside your script
145
149
 
146
- Prefer to launch via `ruby hello_app.rb ...`? Require the gem and delegate to `Rubycli.run` (see Quick start below).
150
+ Prefer to launch via `ruby ...` directly? Require the gem and delegate to `Rubycli.run` (see Quick start below for `examples/hello_app_with_require.rb`).
151
+
152
+ ```bash
153
+ ruby examples/hello_app_with_require.rb greet Hanako --shout
154
+ #=> HELLO, HANAKO!
155
+ ```
147
156
 
148
157
  ## Project Philosophy
149
158
 
@@ -156,7 +165,7 @@ Prefer to launch via `ruby hello_app.rb ...`? Require the gem and delegate to `R
156
165
 
157
166
  - Comment-aware CLI generation with both YARD-style tags and concise placeholders
158
167
  - Automatic option signature inference (`NAME [Type] Description…`) without extra DSLs
159
- - Optional JSON coercion for arguments passed via `--json-args`
168
+ - Safe literal parsing out of the box (arrays / hashes / booleans) with opt-in strict JSON and Ruby eval modes
160
169
  - Optional pre-script hook (`--pre-script` / `--init`) to evaluate Ruby and expose the resulting object
161
170
  - Opt-in strict mode (`RUBYCLI_STRICT=ON`) that emits warnings whenever comments contradict method signatures
162
171
 
@@ -177,29 +186,25 @@ Prefer to launch via `ruby hello_app.rb ...`? Require the gem and delegate to `R
177
186
 
178
187
  ## Installation
179
188
 
180
- The library is not published on RubyGems yet. Clone the repository and point Bundler to the local path, or build a `.gem` once the `.gemspec` is added.
189
+ Rubycli is published on RubyGems.
181
190
 
182
191
  ```bash
183
- git clone https://github.com/inakaegg/rubycli.git
184
- cd rubycli
185
- # gem build rubycli.gemspec
186
- gem build rubycli.gemspec
187
- gem install rubycli-<version>.gem
192
+ gem install rubycli
188
193
  ```
189
194
 
190
195
  Bundler example:
191
196
 
192
197
  ```ruby
193
198
  # Gemfile
194
- gem "rubycli", path: "path/to/rubycli"
199
+ gem "rubycli"
195
200
  ```
196
201
 
197
202
  ## Quick start (embed Rubycli in the script)
198
203
 
199
- Step 3 adds `require "rubycli"` so the script can invoke the CLI directly:
204
+ Step 3 adds `require "rubycli"` so the script can invoke the CLI directly (see `examples/hello_app_with_require.rb`):
200
205
 
201
206
  ```ruby
202
- # hello_app.rb
207
+ # hello_app_with_require.rb
203
208
  require "rubycli"
204
209
 
205
210
  module HelloApp
@@ -222,10 +227,10 @@ Rubycli.run(HelloApp)
222
227
  Run it:
223
228
 
224
229
  ```bash
225
- ruby hello_app.rb greet Taro
230
+ ruby examples/hello_app_with_require.rb greet Taro
226
231
  #=> Hello, Taro!
227
232
 
228
- ruby hello_app.rb greet Taro --shout
233
+ ruby examples/hello_app_with_require.rb greet Taro --shout
229
234
  #=> HELLO, TARO!
230
235
  ```
231
236
 
@@ -249,22 +254,46 @@ This is useful when a file defines multiple candidates or when you want a nested
249
254
 
250
255
  Rubycli parses a hybrid format – you can stick to familiar YARD tags or use short forms.
251
256
 
252
- | Purpose | YARD-compatible | Concise form |
253
- | ------- | --------------- | ------------ |
254
- | Positional argument | `@param name [Type] Description` | `NAME [Type] Description` (`NAME` must be uppercase) |
255
- | Keyword option | Same as above | `--flag -f FLAG [Type] Description` |
257
+ | Purpose | YARD-compatible | Rubycli style |
258
+ | ------- | --------------- | ------------- |
259
+ | Positional argument | `@param name [Type] Description` | `NAME [Type] Description` |
260
+ | Keyword option | Same as above | `--flag -f VALUE [Type] Description` |
256
261
  | Return value | `@return [Type] Description` | `=> [Type] Description` |
257
262
 
258
- Types accept `String`, `Integer`, `String[]`, `Array<String>`, union `String | nil`, etc. Optional placeholders like `[VALUE]` or `[VALUE...]` let Rubycli infer boolean flags, optional values, and list coercion. When you omit the type on an uppercase placeholder (for example `--quiet`), Rubycli infers a Boolean flag automatically.
263
+ Short options are optional and order-independent, so the following examples are equivalent in Rubycli’s default style:
264
+
265
+ - `--flag -f VALUE [Type] Description`
266
+ - `--flag VALUE [Type] Description`
267
+ - `-f --flag VALUE [Type] Description`
268
+
269
+ Our examples keep the classic uppercase placeholders (`NAME`, `VALUE`) as the canonical style; the variations below are optional sugar.
270
+
271
+ ### Alternate placeholder notations
272
+
273
+ Rubycli also understands these syntaxes when parsing comments and rendering help:
274
+
275
+ - Angle brackets for user input: `--flag <value>` or `NAME [<value>]`
276
+ - Inline equals for long options: `--flag=<value>`
277
+ - Trailing ellipsis for repeated values: `VALUE...` or `<value>...`
278
+
279
+ The CLI treats `--flag VALUE`, `--flag <value>`, and `--flag=<value>` identically at runtime—document with whichever variant your team prefers. Optional placeholders like `[VALUE]` or `[VALUE...]` let Rubycli infer boolean flags, optional values, and list coercion. When you omit the placeholder entirely (for example `--quiet`), Rubycli infers a Boolean flag automatically.
280
+
281
+ > Tip: You do not need to wrap optional arguments in brackets inside the comment. Rubycli already knows which parameters are optional from the Ruby signature and will introduce the brackets in generated help.
282
+
283
+ You can annotate types using `[String]`, `(String)`, or `(type: String)`—they all convey the same hint, and you can list multiple types such as `(String, nil)` or `(type: String, nil)`.
284
+
285
+ Repeated values (`VALUE...`) now materialize as arrays automatically whenever the option is documented with an ellipsis (for example `TAG...`) or an explicit array type hint (`[String[]]`, `Array<String>`). Supply either JSON/YAML list syntax (`--tags "[\"build\",\"test\"]"`) or a comma-delimited string (`--tags "build,test"`); Rubycli will coerce both forms to arrays. Space-separated multi-value flags (`--tags build test`) are still not supported, and options without a repeated/array hint continue to be parsed as scalars.
259
286
 
260
287
  Common inference rules:
261
288
 
262
- - Writing a bare uppercase placeholder such as `ARG1` (without `[String]`) makes Rubycli treat it as a `String`.
289
+ - Writing a placeholder such as `ARG1` (without `[String]`) makes Rubycli treat it as a `String`.
263
290
  - Using that placeholder in an option line (`--name ARG1`) also infers a `String`.
264
291
  - Omitting the placeholder entirely (`--verbose`) produces a Boolean flag.
265
292
 
266
293
  Other YARD tags such as `@example`, `@raise`, `@see`, and `@deprecated` are currently ignored by the CLI renderer.
267
294
 
295
+ > Want to explore every notation in a single script? Try `rubycli examples/documentation_style_showcase.rb canonical --help`, `... angled --help`, or the other showcase commands.
296
+
268
297
  YARD-style `@param` annotations continue to work out of the box. If you want to enforce the concise placeholder syntax exclusively, set `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` (strict mode still applies either way).
269
298
 
270
299
  ### When docs are missing or incomplete
@@ -295,7 +324,7 @@ Usage: fallback_example.rb COMMAND [arguments]
295
324
 
296
325
  Available commands:
297
326
  Class methods:
298
- scale <amount> [<factor>] [--clamp=<value>] [--notify=<value>]
327
+ scale AMOUNT [<FACTOR>] [--clamp=<value>] [--notify]
299
328
 
300
329
  Detailed command help: fallback_example.rb COMMAND help
301
330
  Enable debug logging: --debug or RUBYCLI_DEBUG=true
@@ -306,15 +335,15 @@ rubycli examples/fallback_example.rb scale --help
306
335
  ```
307
336
 
308
337
  ```text
309
- Usage: fallback_example.rb scale <AMOUNT> [<FACTOR>] [--clamp=<value>] [--notify]
338
+ Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=<CLAMP>] [--notify]
310
339
 
311
340
  Positional arguments:
312
- AMOUNT [Integer] Base amount to process
313
- [FACTOR] (default: 2)
341
+ AMOUNT [Integer] required Base amount to process
342
+ FACTOR optional (default: 2)
314
343
 
315
344
  Options:
316
- --clamp CLAMP (type: String) (default: nil)
317
- --notify (type: Boolean) (default: false)
345
+ --clamp=<CLAMP> [String] optional (default: nil)
346
+ --notify [Boolean] optional (default: false)
318
347
  ```
319
348
 
320
349
  Here only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are still presented with sensible defaults and inferred types. Enable strict mode (`RUBYCLI_STRICT=ON`) if you want mismatches between comments and signatures to surface as warnings during development.
@@ -327,27 +356,33 @@ Here only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are still
327
356
 
328
357
  In short, comments never add live parameters by themselves; they enrich or describe what your method already supports.
329
358
 
330
- ## JSON mode
359
+ ## Argument parsing modes
360
+
361
+ ### Default literal parsing
362
+
363
+ Rubycli tries to interpret arguments that look like structured literals (values starting with `{`, `[`, quotes, or YAML front matter) using `Psych.safe_load` before handing them to your code. That means values such as `--names='["Alice","Bob"]'` or `--config='{foo: 1}'` arrive as native arrays / hashes without any extra flags. Plain strings like `1,2,3` stay untouched at this stage (if the documentation declares `String[]` or `TAG...`, a later pass still normalises them into arrays), and unsupported constructs fall back to the original text, so `"2024-01-01"` remains a string and malformed payloads still reach your method instead of killing the run.
364
+
365
+ ### JSON mode
331
366
 
332
- Supply `--json-args` when invoking the runner and Rubycli will parse subsequent arguments as JSON before passing them to your method:
367
+ Supply `--json-args` (or the shorthand `-j`) when invoking the runner and Rubycli will parse subsequent arguments strictly as JSON before passing them to your method:
333
368
 
334
369
  ```bash
335
- rubycli --json-args my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]'
370
+ rubycli -j my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]'
336
371
  ```
337
372
 
338
- Programmatically you can call `Rubycli.with_json_mode(true) { … }`.
373
+ This mode rejects YAML-only syntax and raises `JSON::ParserError` when the payload is invalid, which is handy for callers who want explicit failures instead of silent fallbacks. Programmatically you can call `Rubycli.with_json_mode(true) { … }`.
339
374
 
340
375
  ## Eval mode
341
376
 
342
- Use `--eval-args` to evaluate Ruby expressions before they are forwarded to your CLI. This is handy when you want to pass rich objects that are awkward to express as JSON:
377
+ Use `--eval-args` (or the shorthand `-e`) to evaluate Ruby expressions before they are forwarded to your CLI. This is handy when you want to pass rich objects that are awkward to express as JSON:
343
378
 
344
379
  ```bash
345
- rubycli --eval-args scripts/data_cli.rb DataCLI run '(1..10).to_a'
380
+ rubycli -e scripts/data_cli.rb DataCLI run '(1..10).to_a'
346
381
  ```
347
382
 
348
383
  Under the hood Rubycli evaluates each argument inside an isolated binding (`Object.new.instance_eval { binding }`). Treat this as unsafe input: do not enable it for untrusted callers. The mode can also be toggled programmatically via `Rubycli.with_eval_mode(true) { … }`.
349
384
 
350
- `--eval-args` and `--json-args` are mutually exclusive; Rubycli will raise an error if both are present.
385
+ `--eval-args`/`-e` and `--json-args`/`-j` are mutually exclusive; Rubycli will raise an error if both are present. Both modes augment the default literal parsing, so you can pick either strict JSON or full Ruby evaluation when the defaults are not enough.
351
386
 
352
387
  ## Pre-script bootstrap
353
388
 
@@ -1,3 +1,4 @@
1
+ require 'psych'
1
2
  require_relative 'type_utils'
2
3
 
3
4
  module Rubycli
@@ -203,11 +204,24 @@ module Rubycli
203
204
  end
204
205
 
205
206
  def convert_arg(arg)
206
- return nil if arg.nil? || arg.casecmp('nil').zero?
207
- return true if arg.casecmp('true').zero?
208
- return false if arg.casecmp('false').zero?
209
- return arg.to_i if integer_string?(arg)
210
- return arg.to_f if float_string?(arg)
207
+ return arg if Rubycli.eval_mode? || Rubycli.json_mode?
208
+ return arg unless arg.is_a?(String)
209
+
210
+ trimmed = arg.strip
211
+ return arg if trimmed.empty?
212
+
213
+ if literal_like?(trimmed)
214
+ literal = try_literal_parse(arg)
215
+ return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
216
+ end
217
+
218
+ return nil if null_literal?(trimmed)
219
+
220
+ lower = trimmed.downcase
221
+ return true if lower == 'true'
222
+ return false if lower == 'false'
223
+ return arg.to_i if integer_string?(trimmed)
224
+ return arg.to_f if float_string?(trimmed)
211
225
 
212
226
  arg
213
227
  end
@@ -220,6 +234,37 @@ module Rubycli
220
234
  str =~ /\A-?\d+\.\d+\z/
221
235
  end
222
236
 
237
+ LITERAL_PARSE_FAILURE = Object.new
238
+
239
+ def try_literal_parse(value)
240
+ return LITERAL_PARSE_FAILURE unless value.is_a?(String)
241
+
242
+ trimmed = value.strip
243
+ return value if trimmed.empty?
244
+
245
+ literal = Psych.safe_load(trimmed, aliases: false)
246
+ return literal unless literal.nil? && !null_literal?(trimmed)
247
+
248
+ LITERAL_PARSE_FAILURE
249
+ rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::Exception
250
+ LITERAL_PARSE_FAILURE
251
+ end
252
+
253
+ def null_literal?(value)
254
+ return false unless value
255
+
256
+ %w[null ~].include?(value.downcase)
257
+ end
258
+
259
+ def literal_like?(value)
260
+ return false unless value
261
+ return true if value.start_with?('[', '{', '"', "'")
262
+ return true if value.start_with?('---')
263
+ return true if value.match?(/\A(?:true|false|null|nil)\z/i)
264
+
265
+ false
266
+ end
267
+
223
268
 
224
269
  def build_cli_alias_map(option_defs)
225
270
  option_defs.each_with_object({}) do |opt, memo|
@@ -286,7 +331,7 @@ module Rubycli
286
331
  when 'String'
287
332
  ->(value) { value }
288
333
  when 'Integer', 'Fixnum'
289
- ->(value) { Integer(value, 10) }
334
+ ->(value) { Integer(value) }
290
335
  when 'Float'
291
336
  ->(value) { Float(value) }
292
337
  when 'Numeric'
@@ -324,10 +369,25 @@ module Rubycli
324
369
  end
325
370
 
326
371
  def convert_option_value(keyword, value, option_meta, type_converters)
372
+ if Rubycli.eval_mode? || Rubycli.json_mode?
373
+ return convert_arg(value)
374
+ end
375
+
327
376
  converter = type_converters[keyword]
328
- return convert_arg(value) unless converter
377
+ converted_value = convert_arg(value)
378
+ return converted_value unless converter
379
+
380
+ original_input = value if value.is_a?(String)
381
+ expects_list = option_meta && option_meta.types.any? { |type|
382
+ type.to_s.end_with?('[]') || type.to_s.start_with?('Array<')
383
+ }
384
+
385
+ value_for_converter = converted_value
386
+ if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',')
387
+ value_for_converter = original_input
388
+ end
329
389
 
330
- converter.call(value)
390
+ converter.call(value_for_converter)
331
391
  rescue StandardError => e
332
392
  option_label = option_meta&.long || option_meta&.short || keyword
333
393
  raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
@@ -3,7 +3,7 @@
3
3
  module Rubycli
4
4
  module CommandLine
5
5
  USAGE = <<~USAGE
6
- Usage: rubycli [--new|-n] [--pre-script SRC] [--json-args | --eval-args] TARGET_PATH [CLASS_OR_MODULE] [-- CLI_ARGS...]
6
+ Usage: rubycli [--new|-n] [--pre-script=<src>] [--json-args|-j | --eval-args|-e] <target-path> [<class-or-module>] [-- <cli-args>...]
7
7
 
8
8
  Examples:
9
9
  rubycli scripts/sample_runner.rb echo --message hello
@@ -12,14 +12,16 @@ module Rubycli
12
12
 
13
13
  Options:
14
14
  --new, -n Instantiate the class/module before invoking CLI commands
15
- --pre-script SRC Evaluate Ruby code and use its result as the exposed target (--init alias)
16
- --json-args Treat all following arguments as JSON
17
- --eval-args Evaluate following arguments as Ruby code
15
+ --pre-script=<src> Evaluate Ruby code and use its result as the exposed target (--init alias; also accepts space-separated form)
16
+ --json-args, -j Parse all following arguments strictly as JSON (no YAML literals)
17
+ --eval-args, -e Evaluate following arguments as Ruby code
18
18
  (Note: --json-args and --eval-args are mutually exclusive)
19
+ (Note: Every option that accepts a value understands both --flag=value and --flag value forms.)
19
20
 
20
- When CLASS_OR_MODULE is omitted, Rubycli infers it from the file name in CamelCase.
21
+ When <class-or-module> is omitted, Rubycli infers it from the file name in CamelCase.
22
+ Arguments are parsed as safe literals by default; pick a mode above if you need strict JSON or Ruby eval.
21
23
  Method return values are printed to STDOUT by default.
22
- CLI_ARGS are forwarded to Rubycli unchanged.
24
+ <cli-args> are forwarded to Rubycli unchanged.
23
25
  USAGE
24
26
 
25
27
  module_function
@@ -63,10 +65,10 @@ module Rubycli
63
65
  end
64
66
  context = File.file?(src) ? File.expand_path(src) : "(inline #{flag})"
65
67
  pre_script_sources << { value: src, context: context }
66
- when '--json-args'
68
+ when '--json-args', '-j'
67
69
  json_mode = true
68
70
  args.shift
69
- when '--eval-args'
71
+ when '--eval-args', '-e'
70
72
  eval_mode = true
71
73
  args.shift
72
74
  when '--print-result'
@@ -201,9 +201,27 @@ module Rubycli
201
201
  normalized.each do |token|
202
202
  token_without_at = token.start_with?('@') ? token[1..] : token
203
203
  if token.start_with?('--')
204
- long_option = token
204
+ if (eq_index = token.index('='))
205
+ long_option = token[0...eq_index]
206
+ inline_value = token[(eq_index + 1)..]
207
+ if value_name.nil? && inline_value && !inline_value.strip.empty?
208
+ value_name = inline_value.strip
209
+ next
210
+ end
211
+ else
212
+ long_option = token
213
+ end
205
214
  elsif token.start_with?('-')
206
- short_option = token
215
+ if (eq_index = token.index('='))
216
+ short_option = token[0...eq_index]
217
+ inline_value = token[(eq_index + 1)..]
218
+ if value_name.nil? && inline_value && !inline_value.strip.empty?
219
+ value_name = inline_value.strip
220
+ next
221
+ end
222
+ else
223
+ short_option = token
224
+ end
207
225
  elsif value_name.nil? && placeholder_token?(token_without_at)
208
226
  value_name = token_without_at
209
227
  elsif type_token.nil? && type_token_candidate?(token)
@@ -275,16 +293,28 @@ module Rubycli
275
293
 
276
294
  long_option = nil
277
295
  short_option = nil
296
+ inline_value_from_long = nil
297
+ inline_value_from_short = nil
278
298
  remaining = []
279
299
 
280
300
  tokens.each do |token|
281
301
  if long_option.nil? && token.start_with?('--')
282
- long_option = token
302
+ if (eq_index = token.index('='))
303
+ long_option = token[0...eq_index]
304
+ inline_value_from_long = token[(eq_index + 1)..]
305
+ else
306
+ long_option = token
307
+ end
283
308
  next
284
309
  end
285
310
 
286
311
  if short_option.nil? && token.start_with?('-') && !token.start_with?('--')
287
- short_option = token
312
+ if (eq_index = token.index('='))
313
+ short_option = token[0...eq_index]
314
+ inline_value_from_short = token[(eq_index + 1)..]
315
+ else
316
+ short_option = token
317
+ end
288
318
  next
289
319
  end
290
320
 
@@ -294,7 +324,7 @@ module Rubycli
294
324
  return nil unless long_option
295
325
 
296
326
  type_token = nil
297
- value_name = nil
327
+ value_name = [inline_value_from_long, inline_value_from_short].compact.map(&:strip).find { |val| !val.empty? }
298
328
  description_tokens = []
299
329
 
300
330
  remaining.each do |token|
@@ -335,12 +365,12 @@ module Rubycli
335
365
  def parse_positional_line(line)
336
366
  return nil if line.start_with?('--') || line.start_with?('-')
337
367
 
338
- tokens = line.split(/\s+/)
368
+ tokens = combine_bracketed_tokens(line.split(/\s+/))
339
369
  placeholder = tokens.shift
340
370
  return nil unless placeholder
341
371
 
342
372
  clean_placeholder = placeholder.delete('[]')
343
- return nil unless clean_placeholder.match?(/\A[A-Z][A-Z0-9_]*\z/)
373
+ return nil unless placeholder_token?(clean_placeholder)
344
374
 
345
375
  type_token = nil
346
376
  if tokens.first && type_token_candidate?(tokens.first)
@@ -626,6 +656,7 @@ module Rubycli
626
656
  BigDecimal
627
657
  File
628
658
  Pathname
659
+ nil
629
660
  ].freeze
630
661
 
631
662
  def parse_type_annotation(type_str)
@@ -633,7 +664,9 @@ module Rubycli
633
664
 
634
665
  cleaned = type_str.strip
635
666
  cleaned = cleaned.delete_prefix('@')
667
+ cleaned = cleaned[1..-2].strip if cleaned.start_with?('(') && cleaned.end_with?(')')
636
668
  cleaned = cleaned[1..-2] if cleaned.start_with?('[') && cleaned.end_with?(']')
669
+ cleaned = cleaned.sub(/\Atype\s*:\s*/i, '')
637
670
  cleaned.split(/[,|]/).map { |token| normalize_type_token(token) }.reject(&:empty?)
638
671
  end
639
672
 
@@ -641,13 +674,29 @@ module Rubycli
641
674
  return false unless token
642
675
 
643
676
  candidate = token.strip.delete_prefix('@')
644
- candidate = candidate.delete_prefix('[').delete_suffix(']')
645
- candidate = candidate.gsub(/\.\.\./, '')
677
+ return false if candidate.empty?
678
+
679
+ optional = candidate.start_with?('[') && candidate.end_with?(']')
680
+ candidate = candidate[1..-2].strip if optional
681
+ return false if candidate.empty?
682
+
646
683
  candidate = candidate.gsub(/[,\|]/, '')
647
- candidate = candidate.gsub(/[^A-Za-z0-9_]/, '')
648
684
  return false if candidate.empty?
649
685
 
650
- candidate == candidate.upcase && candidate.match?(/[A-Z]/)
686
+ ellipsis = candidate.end_with?('...')
687
+ candidate = candidate[0..-4] if ellipsis
688
+ candidate = candidate.strip
689
+ return false if candidate.empty?
690
+
691
+ if candidate.start_with?('<') && candidate.end_with?('>')
692
+ inner = candidate[1..-2]
693
+ inner.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
694
+ else
695
+ cleaned = candidate.gsub(/[^A-Za-z0-9_]/, '')
696
+ return false if cleaned.empty?
697
+
698
+ cleaned == cleaned.upcase && cleaned.match?(/[A-Z]/)
699
+ end
651
700
  end
652
701
 
653
702
  def type_token_candidate?(token)
@@ -709,6 +758,7 @@ module Rubycli
709
758
  def combine_bracketed_tokens(tokens)
710
759
  combined = []
711
760
  buffer = nil
761
+ closing = nil
712
762
 
713
763
  tokens.each do |token|
714
764
  next if token.nil?
@@ -716,12 +766,17 @@ module Rubycli
716
766
  if buffer
717
767
  buffer << ' ' unless token.empty?
718
768
  buffer << token
719
- if token.include?(']')
769
+ if closing && token.include?(closing)
720
770
  combined << buffer
721
771
  buffer = nil
772
+ closing = nil
722
773
  end
723
774
  elsif token.start_with?('[') && !token.include?(']')
724
775
  buffer = token.dup
776
+ closing = ']'
777
+ elsif token.start_with?('(') && !token.include?(')')
778
+ buffer = token.dup
779
+ closing = ')'
725
780
  else
726
781
  combined << token
727
782
  end
@@ -53,21 +53,8 @@ module Rubycli
53
53
  summary = metadata[:summary]
54
54
  return summary if summary && !summary.empty?
55
55
 
56
- params = method_obj.parameters
57
- return "(no arguments)" if params.empty?
58
-
59
- param_desc = params.map { |type, name|
60
- case type
61
- when :req then "<#{name}>"
62
- when :opt then "[<#{name}>]"
63
- when :rest then "[<#{name}>...]"
64
- when :keyreq then "--#{name.to_s.tr("_", "-")}=<value>"
65
- when :key then "[--#{name.to_s.tr("_", "-")}=<value>]"
66
- when :keyrest then "[--<option>...]"
67
- end
68
- }.compact.join(" ")
69
-
70
- param_desc.empty? ? "(no arguments)" : param_desc
56
+ params_str = format_method_parameters(method_obj.parameters, metadata)
57
+ params_str.empty? ? "(no arguments)" : params_str
71
58
  end
72
59
 
73
60
  def usage_for_method(command, method)
@@ -78,59 +65,8 @@ module Rubycli
78
65
  options = metadata[:options] || []
79
66
  positionals_in_order = ordered_positionals(method, metadata)
80
67
 
81
- if positionals_in_order.any?
82
- labels = positionals_in_order.map { |info| info[:label] }
83
- max_label_length = labels.map(&:length).max || 0
84
-
85
- usage_lines << ""
86
- usage_lines << "Positional arguments:"
87
- positionals_in_order.each do |info|
88
- definition = info[:definition]
89
- description_parts = []
90
- if definition&.inline_type_annotation && definition.inline_type_text
91
- description_parts << definition.inline_type_text
92
- end
93
- type_info = positional_type_display(definition)
94
- if type_info && type_info_first?(definition)
95
- description_parts << type_info
96
- end
97
- description_parts << info[:description] if info[:description]
98
- description_parts << type_info if type_info && !type_info_first?(definition)
99
- default_text = positional_default_display(definition)
100
- description_parts << default_text if default_text
101
- description_text = description_parts.join(' ')
102
- line = " #{info[:label].ljust(max_label_length)}"
103
- line += " #{description_text}" unless description_text.empty?
104
- usage_lines << line
105
- end
106
- end
107
-
108
- if options.any?
109
- option_labels = options.map { |opt| option_flag_with_placeholder(opt) }
110
- max_label_length = option_labels.map(&:length).max || 0
111
-
112
- usage_lines << "" unless usage_lines.last == ""
113
- usage_lines << "Options:"
114
- options.each_with_index do |opt, idx|
115
- label = option_labels[idx]
116
- description_parts = []
117
- if opt.inline_type_annotation && opt.inline_type_text
118
- description_parts << opt.inline_type_text
119
- end
120
- type_info = option_type_display(opt)
121
- if type_info && type_info_first?(opt)
122
- description_parts << type_info
123
- end
124
- description_parts << opt.description if opt.description
125
- description_parts << type_info if type_info && !type_info_first?(opt)
126
- default_info = option_default_display(opt)
127
- description_parts << default_info if default_info
128
- description_text = description_parts.join(' ')
129
- line = " #{label.ljust(max_label_length)}"
130
- line += " #{description_text}" unless description_text.empty?
131
- usage_lines << line
132
- end
133
- end
68
+ usage_lines.concat(render_positionals(positionals_in_order)) if positionals_in_order.any?
69
+ usage_lines.concat(render_options(options, required_keyword_names(method))) if options.any?
134
70
 
135
71
  returns = metadata[:returns] || []
136
72
  if returns.any?
@@ -168,14 +104,11 @@ module Rubycli
168
104
  parameters.map { |type, name|
169
105
  case type
170
106
  when :req
171
- doc = positional_map[name]
172
- label = doc&.label || name.to_s.upcase
173
- "<#{label}>"
107
+ positional_usage_token(type, name, positional_map[name])
174
108
  when :opt
175
- doc = positional_map[name]
176
- label = doc&.label || name.to_s.upcase
177
- "[<#{label}>]"
178
- when :rest then "[<#{name}>...]"
109
+ positional_usage_token(type, name, positional_map[name])
110
+ when :rest
111
+ positional_usage_token(type, name, positional_map[name])
179
112
  when :keyreq
180
113
  opt = option_map[name]
181
114
  if opt
@@ -202,53 +135,163 @@ module Rubycli
202
135
  when :keyrest then "[--<option>...]"
203
136
  else ""
204
137
  end
205
- }.reject(&:empty?).join(" ")
138
+ }.compact.reject(&:empty?).join(" ")
206
139
  end
207
140
 
208
141
  def auto_generated_option_usage_label(name, opt)
209
142
  base_flag = "--#{name.to_s.tr('_', '-')}"
210
143
  return base_flag if opt.boolean_flag
211
144
 
212
- "#{base_flag}=<value>"
145
+ value_name = opt.value_name
146
+ formatted = if value_name && !value_name.to_s.strip.empty?
147
+ ensure_angle_bracket_placeholder(value_name)
148
+ else
149
+ "<value>"
150
+ end
151
+ "#{base_flag}=#{formatted}"
152
+ end
153
+
154
+ def render_positionals(positionals_in_order)
155
+ rows = positionals_in_order.map do |info|
156
+ definition = info[:definition]
157
+ label = info[:label]
158
+ type = formatted_types(definition&.types)
159
+ requirement = positional_requirement(info[:kind])
160
+ description_parts = []
161
+ description_parts << info[:description] if info[:description]
162
+ default_text = positional_default(definition)
163
+ description_parts << default_text if default_text
164
+ [label, type, requirement, description_parts.join(' ')]
165
+ end
166
+ table_block("Positional arguments:", rows)
167
+ end
168
+
169
+ def render_options(options, required_keywords)
170
+ rows = options.map do |opt|
171
+ label = option_flag_with_placeholder(opt)
172
+ type = formatted_types(opt.types)
173
+ requirement = required_keywords.include?(opt.keyword) ? 'required' : 'optional'
174
+ description_parts = []
175
+ description_parts << opt.description if opt.description
176
+ default_text = option_default(opt)
177
+ description_parts << default_text if default_text
178
+ [label, type, requirement, description_parts.join(' ').strip]
179
+ end
180
+ table_block("Options:", rows)
181
+ end
182
+
183
+ def table_block(header, rows)
184
+ return [] if rows.empty?
185
+
186
+ cols = rows.transpose
187
+ widths = cols.map { |col| col.map { |value| value.to_s.length }.max || 0 }
188
+
189
+ lines = ["", header]
190
+ rows.each do |row|
191
+ padded = row.each_with_index.map do |value, idx|
192
+ text = value.to_s
193
+ idx < row.length - 1 ? text.ljust(widths[idx]) : text
194
+ end
195
+ lines << " #{padded.join(' ')}".rstrip
196
+ end
197
+ lines
198
+ end
199
+
200
+ def formatted_types(types)
201
+ type_list = Array(types).compact.map(&:to_s).reject(&:empty?).uniq
202
+ return '' if type_list.empty?
203
+
204
+ "[#{type_list.join(', ')}]"
205
+ end
206
+
207
+ def positional_requirement(kind)
208
+ kind == :opt ? 'optional' : 'required'
209
+ end
210
+
211
+ def positional_default(definition)
212
+ return nil unless definition
213
+ value = definition.default_value
214
+ return nil if value.nil? || value.to_s.empty?
215
+
216
+ "(default: #{value})"
213
217
  end
214
218
 
219
+ def option_default(opt)
220
+ value = opt.default_value
221
+ return nil if value.nil? || value.to_s.empty?
222
+
223
+ "(default: #{value})"
224
+ end
225
+
226
+ def required_keyword_names(method)
227
+ method.parameters.select { |type, _| type == :keyreq }.map { |_, name| name }
228
+ end
229
+
215
230
  def ordered_positionals(method, metadata)
216
231
  positional_map = metadata[:positionals_map] || {}
217
232
  method.parameters.each_with_object([]) do |(type, name), memo|
218
233
  next unless %i[req opt].include?(type)
219
234
 
220
235
  definition = positional_map[name]
221
- label = if definition
222
- type == :opt ? "[#{definition.label}]" : definition.label
223
- else
224
- base = name.to_s.upcase
225
- type == :opt ? "[#{base}]" : base
226
- end
236
+ label = display_label_for(definition, name)
227
237
  description = definition&.description
228
- memo << { label: label, description: description, definition: definition }
238
+ memo << { label: label, description: description, definition: definition, kind: type }
229
239
  end
230
240
  end
231
241
 
232
- def positional_type_display(definition)
233
- return nil unless definition
234
- return nil if definition.inline_type_annotation
235
- return nil if definition.types.nil? || definition.types.empty?
242
+ def display_label_for(definition, name)
243
+ return definition.label if definition&.label && !definition.label.to_s.empty?
236
244
 
237
- unique_types = definition.types.reject(&:empty?).uniq
238
- return nil if unique_types.empty?
245
+ name.to_s.upcase
246
+ end
239
247
 
240
- if definition.respond_to?(:doc_format) && definition.doc_format == :rubycli
241
- "[#{unique_types.join(', ')}]"
248
+ def positional_usage_token(type, name, definition)
249
+ placeholder = extract_positional_placeholder(definition)
250
+ case type
251
+ when :req
252
+ required_placeholder(placeholder, definition, name)
253
+ when :opt
254
+ optional_placeholder(placeholder, definition, name)
255
+ when :rest
256
+ rest_placeholder(placeholder, definition, name)
242
257
  else
243
- "(type: #{unique_types.join(' | ')})"
258
+ nil
244
259
  end
245
260
  end
246
261
 
247
- def positional_default_display(definition)
248
- return nil unless definition && definition.default_value
249
- return nil if definition.default_value.to_s.empty?
262
+ def extract_positional_placeholder(definition)
263
+ return nil unless definition
264
+ return nil if definition.doc_format.nil?
265
+
266
+ token = definition.placeholder.to_s.strip
267
+ token.empty? ? nil : token
268
+ end
269
+
270
+ def required_placeholder(placeholder, definition, name)
271
+ return placeholder.strip unless placeholder.nil? || placeholder.strip.empty?
272
+
273
+ default_positional_label(definition, name, uppercase: true)
274
+ end
275
+
276
+ def optional_placeholder(placeholder, definition, name)
277
+ return placeholder.strip unless placeholder.nil? || placeholder.strip.empty?
278
+
279
+ "[#{default_positional_label(definition, name, uppercase: true)}]"
280
+ end
281
+
282
+ def rest_placeholder(placeholder, definition, name)
283
+ return placeholder.strip unless placeholder.nil? || placeholder.strip.empty?
250
284
 
251
- "(default: #{definition.default_value})"
285
+ base = default_positional_label(definition, name, uppercase: true)
286
+ "[#{base}...]"
287
+ end
288
+
289
+ def default_positional_label(definition, name, uppercase:)
290
+ label = definition&.label
291
+ label = label.to_s.strip unless label.nil?
292
+ label = nil if label.respond_to?(:empty?) && label.empty?
293
+ base = label || name.to_s
294
+ uppercase ? base.upcase : base
252
295
  end
253
296
 
254
297
  def option_flag_with_placeholder(opt)
@@ -257,7 +300,13 @@ module Rubycli
257
300
  flag_label = flags.join(", ")
258
301
  placeholder = option_value_placeholder(opt)
259
302
  if placeholder
260
- "#{flag_label} #{placeholder}"
303
+ formatted = ensure_angle_bracket_placeholder(placeholder)
304
+ if formatted.start_with?('[') && formatted.end_with?(']')
305
+ inner = formatted[1..-2]
306
+ "#{flag_label}[=#{inner}]"
307
+ else
308
+ "#{flag_label}=#{formatted}"
309
+ end
261
310
  else
262
311
  flag_label
263
312
  end
@@ -271,28 +320,25 @@ module Rubycli
271
320
  first_non_nil_type
272
321
  end
273
322
 
274
- def option_type_display(opt)
275
- return nil if opt.inline_type_annotation
276
- return nil if opt.types.nil? || opt.types.empty?
277
-
278
- unique_types = opt.types.reject(&:empty?).uniq
279
- return nil if unique_types.empty?
323
+ def ensure_angle_bracket_placeholder(placeholder)
324
+ raw = placeholder.to_s.strip
325
+ return raw if raw.empty?
280
326
 
281
- if opt.respond_to?(:doc_format) && opt.doc_format == :rubycli
282
- "[#{unique_types.join(', ')}]"
283
- else
284
- "(type: #{unique_types.join(' | ')})"
285
- end
286
- end
327
+ optional = raw.start_with?('[') && raw.end_with?(']')
328
+ core = optional ? raw[1..-2].strip : raw
329
+ return raw if core.empty?
287
330
 
288
- def option_default_display(opt)
289
- return nil if opt.default_value.nil? || opt.default_value.to_s.empty?
331
+ ellipsis = core.end_with?('...')
332
+ core = core[0..-4] if ellipsis
290
333
 
291
- "(default: #{opt.default_value})"
292
- end
334
+ formatted_core = if core.start_with?('<') && core.end_with?('>')
335
+ core
336
+ else
337
+ "<#{core}>"
338
+ end
293
339
 
294
- def type_info_first?(definition)
295
- definition.respond_to?(:doc_format) && definition.doc_format == :rubycli
340
+ formatted_core = "#{formatted_core}..." if ellipsis
341
+ optional ? "[#{formatted_core}]" : formatted_core
296
342
  end
297
343
  end
298
344
  end
@@ -19,6 +19,10 @@ module Rubycli
19
19
  trimmed = trimmed.delete_prefix('@')
20
20
  return '' if trimmed.empty?
21
21
 
22
+ trimmed = trimmed[1..-2].strip if trimmed.start_with?('(') && trimmed.end_with?(')')
23
+ trimmed = trimmed.sub(/\Atype\s*:\s*/i, '').strip
24
+ return '' if trimmed.empty?
25
+
22
26
  if trimmed.include?('<') && trimmed.end_with?('>')
23
27
  trimmed
24
28
  elsif trimmed.end_with?('[]')
@@ -66,7 +70,29 @@ module Rubycli
66
70
  working.unshift('Boolean') unless working.any? { |type| boolean_type?(type) }
67
71
  end
68
72
 
69
- working.uniq
73
+ if placeholder_info[:list]
74
+ array_type_present = false
75
+
76
+ working = working.map do |type|
77
+ next type if type.nil? || type.empty?
78
+
79
+ if boolean_type?(type) || nil_type?(type)
80
+ type
81
+ elsif type.end_with?('[]') || type.start_with?('Array<') || type.casecmp('Array').zero?
82
+ array_type_present = true
83
+ type
84
+ else
85
+ array_type_present = true
86
+ "#{type}[]"
87
+ end
88
+ end
89
+
90
+ unless array_type_present
91
+ working << 'String[]'
92
+ end
93
+ end
94
+
95
+ working.compact.uniq
70
96
  end
71
97
 
72
98
  def determine_requires_value(value_placeholder:, types:, boolean_flag:, optional_value:)
@@ -101,6 +127,7 @@ module Rubycli
101
127
 
102
128
  def parse_list(value)
103
129
  return [] if value.nil?
130
+ return value if value.is_a?(Array)
104
131
 
105
132
  value.to_s.split(',').map(&:strip).reject(&:empty?)
106
133
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubycli
4
- VERSION = '0.1.1'
4
+ VERSION = '0.1.2'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubycli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - inakaegg
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-11-01 00:00:00.000000000 Z
10
+ date: 2025-11-06 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Rubycli turns plain Ruby classes and modules into command-line interfaces
13
13
  by reading their documentation comments, inspired by Python Fire but tailored for
@@ -61,5 +61,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
61
  requirements: []
62
62
  rubygems_version: 3.6.2
63
63
  specification_version: 4
64
- summary: Doc-comment driven CLI wrapper for Ruby classes and modules.
64
+ summary: Python Fire-inspired doc-comment CLI wrapper delivering a Ruby Fire experience.
65
65
  test_files: []