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 +4 -4
- data/CHANGELOG.md +17 -1
- data/README.ja.md +62 -40
- data/README.md +74 -39
- data/lib/rubycli/argument_parser.rb +68 -8
- data/lib/rubycli/command_line.rb +10 -8
- data/lib/rubycli/documentation_registry.rb +67 -12
- data/lib/rubycli/help_renderer.rb +162 -116
- data/lib/rubycli/type_utils.rb +28 -1
- data/lib/rubycli/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14562bc06b2479369c2951a54cac6f8f502e5ff81ae453b25bdb225b2017f671
|
|
4
|
+
data.tar.gz: 731a6704bac4ca7d9471b4ff00d7476745e81c442666e99ca3b710d70c9e4622
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
+

|
|
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 <
|
|
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
|
|
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 <
|
|
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
|
|
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]
|
|
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
|
-
-
|
|
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
|
-
|
|
182
|
+
Rubycli は RubyGems からインストールできます。
|
|
181
183
|
|
|
182
184
|
```bash
|
|
183
|
-
|
|
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"
|
|
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]
|
|
253
|
-
| キーワード引数 | 同上 | `--flag -f
|
|
248
|
+
| 用途 | YARD 互換 | Rubycli 標準 |
|
|
249
|
+
| ---- | --------- | ----------- |
|
|
250
|
+
| 位置引数 | `@param name [Type] 説明` | `NAME [Type] 説明` |
|
|
251
|
+
| キーワード引数 | 同上 | `--flag -f VALUE [Type] 説明` |
|
|
254
252
|
| 戻り値 | `@return [Type] 説明` | `=> [Type] 説明` |
|
|
255
253
|
|
|
256
|
-
|
|
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`
|
|
261
|
-
- `--name ARG1`
|
|
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
|
|
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
|
|
329
|
+
Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=<CLAMP>] [--notify]
|
|
308
330
|
|
|
309
331
|
Positional arguments:
|
|
310
|
-
AMOUNT
|
|
311
|
-
|
|
332
|
+
AMOUNT [Integer] required 処理対象の数値
|
|
333
|
+
FACTOR optional (default: 2)
|
|
312
334
|
|
|
313
335
|
Options:
|
|
314
|
-
--clamp
|
|
315
|
-
--notify
|
|
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
|
-
##
|
|
336
|
-
|
|
337
|
-
CLI 実行時に `--json-args` を付けると、後続の引数が JSON として解釈され Ruby オブジェクトに変換されます。
|
|
357
|
+
## 引数解析モード
|
|
338
358
|
|
|
339
|
-
|
|
340
|
-
rubycli --json-args my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]'
|
|
341
|
-
```
|
|
359
|
+
### 既定のリテラル解析
|
|
342
360
|
|
|
343
|
-
|
|
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
|
-
|
|
363
|
+
### JSON モード
|
|
346
364
|
|
|
347
|
-
`--
|
|
365
|
+
CLI 実行時に `--json-args`(短縮形 `-j`)を付けると、後続の引数が厳格に JSON として解釈されます。
|
|
348
366
|
|
|
349
367
|
```bash
|
|
350
|
-
rubycli
|
|
368
|
+
rubycli -j my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]'
|
|
351
369
|
```
|
|
352
370
|
|
|
353
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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 <
|
|
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
|
|
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 <
|
|
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
|
|
120
|
+
Usage: hello_app_with_docs.rb greet NAME [--shout]
|
|
117
121
|
|
|
118
122
|
Positional arguments:
|
|
119
|
-
NAME [String]
|
|
123
|
+
NAME [String] required Name to greet
|
|
120
124
|
|
|
121
125
|
Options:
|
|
122
|
-
--shout [Boolean]
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
189
|
+
Rubycli is published on RubyGems.
|
|
181
190
|
|
|
182
191
|
```bash
|
|
183
|
-
|
|
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"
|
|
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
|
-
#
|
|
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
|
|
230
|
+
ruby examples/hello_app_with_require.rb greet Taro
|
|
226
231
|
#=> Hello, Taro!
|
|
227
232
|
|
|
228
|
-
ruby
|
|
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 |
|
|
253
|
-
| ------- | --------------- |
|
|
254
|
-
| Positional argument | `@param name [Type] Description` | `NAME [Type] Description`
|
|
255
|
-
| Keyword option | Same as above | `--flag -f
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
338
|
+
Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=<CLAMP>] [--notify]
|
|
310
339
|
|
|
311
340
|
Positional arguments:
|
|
312
|
-
AMOUNT
|
|
313
|
-
|
|
341
|
+
AMOUNT [Integer] required Base amount to process
|
|
342
|
+
FACTOR optional (default: 2)
|
|
314
343
|
|
|
315
344
|
Options:
|
|
316
|
-
--clamp
|
|
317
|
-
--notify
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return 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
|
|
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
|
-
|
|
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(
|
|
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}"
|
data/lib/rubycli/command_line.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Rubycli
|
|
4
4
|
module CommandLine
|
|
5
5
|
USAGE = <<~USAGE
|
|
6
|
-
Usage: rubycli [--new|-n] [--pre-script
|
|
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
|
|
16
|
-
--json-args
|
|
17
|
-
--eval-args
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
label = doc&.label || name.to_s.upcase
|
|
173
|
-
"<#{label}>"
|
|
107
|
+
positional_usage_token(type, name, positional_map[name])
|
|
174
108
|
when :opt
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
233
|
-
return
|
|
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
|
-
|
|
238
|
-
|
|
245
|
+
name.to_s.upcase
|
|
246
|
+
end
|
|
239
247
|
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
258
|
+
nil
|
|
244
259
|
end
|
|
245
260
|
end
|
|
246
261
|
|
|
247
|
-
def
|
|
248
|
-
return nil unless definition
|
|
249
|
-
return nil if definition.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
return
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
|
|
331
|
+
ellipsis = core.end_with?('...')
|
|
332
|
+
core = core[0..-4] if ellipsis
|
|
290
333
|
|
|
291
|
-
|
|
292
|
-
|
|
334
|
+
formatted_core = if core.start_with?('<') && core.end_with?('>')
|
|
335
|
+
core
|
|
336
|
+
else
|
|
337
|
+
"<#{core}>"
|
|
338
|
+
end
|
|
293
339
|
|
|
294
|
-
|
|
295
|
-
|
|
340
|
+
formatted_core = "#{formatted_core}..." if ellipsis
|
|
341
|
+
optional ? "[#{formatted_core}]" : formatted_core
|
|
296
342
|
end
|
|
297
343
|
end
|
|
298
344
|
end
|
data/lib/rubycli/type_utils.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/rubycli/version.rb
CHANGED
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.
|
|
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-
|
|
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:
|
|
64
|
+
summary: Python Fire-inspired doc-comment CLI wrapper delivering a Ruby Fire experience.
|
|
65
65
|
test_files: []
|