rubycli 0.1.2 → 0.1.4
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 +25 -0
- data/README.ja.md +19 -4
- data/README.md +19 -4
- data/lib/rubycli/argument_mode_controller.rb +69 -0
- data/lib/rubycli/argument_parser.rb +53 -122
- data/lib/rubycli/arguments/token_stream.rb +41 -0
- data/lib/rubycli/arguments/value_converter.rb +74 -0
- data/lib/rubycli/cli.rb +9 -6
- data/lib/rubycli/command_line.rb +20 -4
- data/lib/rubycli/constant_capture.rb +50 -0
- data/lib/rubycli/documentation/comment_extractor.rb +52 -0
- data/lib/rubycli/documentation/metadata_parser.rb +838 -0
- data/lib/rubycli/documentation_registry.rb +10 -852
- data/lib/rubycli/environment.rb +8 -1
- data/lib/rubycli/eval_coercer.rb +16 -1
- data/lib/rubycli/version.rb +1 -1
- data/lib/rubycli.rb +196 -118
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a71a438637363ea54e22377b645dc99a26a0c91692571bf57a68368403e32308
|
|
4
|
+
data.tar.gz: 367b354b2ffde5ee80e7ee3ee5b2858f15cbf8097cc8346fd812b780f41443e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5eb055a5cbaa3daf0d311b67922a0c9bb7dbb8a75c2844524dc21f5faed232f560c8f1aaea6caf96737fea0b979625813b1ea5cc6c589ecee405b994b07ee16f
|
|
7
|
+
data.tar.gz: f64107c28083a96674970f8414090e47b77a749f8d3c9393ff9ca972a747ebe36a5da128d1c75a313ca570631436509392985477d1eefe271722eb4b046c69a8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.4] - 2025-11-08
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Re-cut the release that was briefly published as 0.1.3 (and yanked) so RubyGems now hosts the complete set of changes listed below.
|
|
7
|
+
|
|
8
|
+
> _Note:_ 0.1.3 was yanked before general availability; consumers should upgrade directly to 0.1.4.
|
|
9
|
+
|
|
10
|
+
## [0.1.3] - 2025-11-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- TracePoint-backed constant capture so Rubycli can detect CLI classes/modules even when they are defined indirectly; bundled example and tests illustrate the behavior.
|
|
14
|
+
- Strict/auto constant selection modes with the new `--auto-target` / `-a` flag so single callable constants are picked automatically when requested.
|
|
15
|
+
- `--eval-lax` / `-E` argument mode that evaluates Ruby inputs but gracefully falls back to raw strings on parse failures.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Argument parsing internals were modularized so JSON/eval coercion now flows through a dedicated controller, simplifying future extensions.
|
|
19
|
+
- `rubycli` now returns explicit status codes for success and failure, improving scriptability.
|
|
20
|
+
- CLI constant selection errors provide clearer guidance, document the `--new` behavior, and consistently refer to the `--auto-target` flag.
|
|
21
|
+
|
|
22
|
+
### Documentation
|
|
23
|
+
- Clarified the authoritative source for documentation comments, reorganized helper logic in the showcase example, and expanded guidance around constant modes.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- CLI no longer dumps a Ruby backtrace when `Rubycli::Runner` reports user-facing errors; only the curated guidance is shown.
|
|
27
|
+
|
|
3
28
|
## [0.1.2] - 2025-11-06
|
|
4
29
|
|
|
5
30
|
### Added
|
data/README.ja.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
Rubycli は Ruby
|
|
5
|
+
Rubycli は Ruby のクラス/モジュールにある公開メソッドの定義と、そのメソッドに付けたドキュメントコメントから CLI を自動生成する小さなフレームワークです。Python Fire にインスパイアされていますが、互換や公式ポートを目指すものではありません。Ruby のコメント記法と型アノテーションに合わせて設計しており、コメントに書いた型ヒントや繰り返し指定が CLI の引数解釈もコントロールします。
|
|
6
6
|
|
|
7
7
|
> English guide is available in [README.md](README.md).
|
|
8
8
|
|
|
@@ -147,11 +147,24 @@ end
|
|
|
147
147
|
|
|
148
148
|
`ruby hello_app.rb ...` の形で呼び出したい場合だけ `require "rubycli"` を追加し、`Rubycli.run` に制御を渡します(後述のクイックスタート参照)。
|
|
149
149
|
|
|
150
|
+
## 定数解決モード
|
|
151
|
+
|
|
152
|
+
Rubycli は「ファイル名を CamelCase にした定数」を公開対象だと想定しています。ファイル名とクラス/モジュール名が一致しない場合は、次のモードで挙動を切り替えられます。
|
|
153
|
+
|
|
154
|
+
| モード | 有効化方法 | 挙動 |
|
|
155
|
+
| --- | --- | --- |
|
|
156
|
+
| `strict`(デフォルト) | 何もしない / `RUBYCLI_AUTO_TARGET=strict` | CamelCase が一致しないとエラーになります。検出した定数一覧と再実行コマンド例を表示します。 |
|
|
157
|
+
| `auto` | `--auto-target`(短縮 `-a`) または `RUBYCLI_AUTO_TARGET=auto` | ファイル内で CLI として実行できる定数が 1 つだけなら自動選択します。複数あれば従来通りエラーで案内します。 |
|
|
158
|
+
|
|
159
|
+
大規模なコードベースでも安全側を保ちながら、どうしても自動選択したいときだけ 1 フラグで切り替えられます。
|
|
160
|
+
|
|
161
|
+
> **インスタンスメソッド専用のクラスについて** – 公開メソッドがインスタンス側(`attr_reader` や `def greet`)にしか無い場合は、`--new` を付けて事前にインスタンス化しないと CLI から呼び出せません。クラスメソッドを 1 つ用意するか、`--new` を明示して実行してください。
|
|
162
|
+
|
|
150
163
|
## 開発方針
|
|
151
164
|
|
|
152
165
|
- **便利さが最優先** – 既存の Ruby スクリプトを最小の手間で CLI 化できることを目的にしており、Python Fire の完全移植は目指していません。
|
|
153
166
|
- **インスパイアであってポートではない** – アイデアの出自は Fire ですが、同等機能を揃える予定は基本的にありません。Fire 由来の未実装機能は仕様です。
|
|
154
|
-
-
|
|
167
|
+
- **メソッド定義が土台、コメントが挙動を補強** – 公開メソッドのシグネチャが CLI に露出する範囲と必須/任意を決めますが、コメントに `TAG...` や `[Integer]` を書くと同じ引数でも配列化や型変換が行われます。コメントと実装のズレを観測したいときだけ `RUBYCLI_STRICT=ON` で厳格モードを有効化し、警告を受け取ります。
|
|
155
168
|
- **軽量メンテナンス** – 実装の多くは AI 支援で作られており、深い Ruby メタプログラミングを伴う大規模拡張は想定外です。Fire 互換を求める PR は事前相談をお願いします。
|
|
156
169
|
|
|
157
170
|
## 特徴
|
|
@@ -172,7 +185,7 @@ end
|
|
|
172
185
|
| 機能 | Python Fire | Rubycli |
|
|
173
186
|
| ---- | ----------- | -------- |
|
|
174
187
|
| 属性の辿り方 | オブジェクトを辿ってプロパティ/属性を自動公開 | 対象オブジェクトの公開メソッドをそのまま公開(暗黙の辿りは無し) |
|
|
175
|
-
| クラス初期化 | `__init__` 引数を CLI で自動受け取りインスタンス化 | `--new`
|
|
188
|
+
| クラス初期化 | `__init__` 引数を CLI で自動受け取りインスタンス化 | `--new` 指定時だけ引数なしで明示的に初期化(初期化引数の CLI 受け渡しは未対応なので、必要なら pre-script や自前ファクトリで注入) |
|
|
176
189
|
| インタラクティブシェル | コマンド未指定時に Fire REPL を提供 | インタラクティブモード無し。コマンド実行専用 |
|
|
177
190
|
| 情報源 | 反射で引数・プロパティを解析 | ライブなメソッド定義を基点にしつつコメントをヘルプへ反映 |
|
|
178
191
|
| 辞書/配列 | dict/list を自動でサブコマンド化 | クラス/モジュールのメソッドに特化(辞書自動展開なし) |
|
|
@@ -374,7 +387,9 @@ YAML 固有の書き方は拒否され、無効な JSON であれば `JSON::Pars
|
|
|
374
387
|
|
|
375
388
|
`--eval-args`(短縮形 `-e`)を使うと、後続の引数を Ruby コードとして評価した結果を CLI に渡せます。JSON や YAML では表現しづらいオブジェクトを扱いたいときに便利ですが、評価は `Object.new.instance_eval { binding }` 上で行われるため、信頼できる入力に限定してください。コード内では `Rubycli.with_eval_mode(true) { … }` で切り替えられます。
|
|
376
389
|
|
|
377
|
-
`--eval-
|
|
390
|
+
Ruby 評価を使いつつ、構文エラーが出たときは元の文字列にフォールバックさせたい場合は `--eval-lax`(短縮形 `-E`)を指定します。`--eval-args` と同じく eval モードを有効にしますが、Ruby として解釈できなかったトークン(例: 素の `https://example.com`)は警告を出した上でそのまま渡すため、`60*60*24*14` のような式と文字列を気軽に混在させられます。
|
|
391
|
+
|
|
392
|
+
`--json-args`/`-j` は `--eval-args`/`-e` および `--eval-lax`/`-E` と同時指定できません。どのモードも既定のリテラル解析を拡張する位置づけなので、用途に応じて厳格な JSON か Ruby eval(通常/lax)のいずれかを選択してください。
|
|
378
393
|
|
|
379
394
|
## Pre-script ブートストラップ
|
|
380
395
|
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
Rubycli turns existing Ruby classes and modules into CLIs by
|
|
5
|
+
Rubycli turns existing Ruby classes and modules into CLIs by inspecting their public method definitions and the doc comments attached to those methods. 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, and those annotations can actively change how a CLI argument is coerced (for example, `TAG... [String[]]` forces array parsing).
|
|
6
6
|
|
|
7
7
|
> 🇯🇵 Japanese documentation is available in [README.ja.md](README.ja.md).
|
|
8
8
|
|
|
@@ -154,11 +154,24 @@ ruby examples/hello_app_with_require.rb greet Hanako --shout
|
|
|
154
154
|
#=> HELLO, HANAKO!
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
## Constant resolution modes
|
|
158
|
+
|
|
159
|
+
Rubycli assumes that the file name (CamelCased) matches the class or module you want to expose. When that is not the case you can choose how eagerly Rubycli should pick a constant:
|
|
160
|
+
|
|
161
|
+
| Mode | How to enable | Behaviour |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| `strict` (default) | do nothing / `RUBYCLI_AUTO_TARGET=strict` | Fails unless the CamelCase name matches. The error lists the detected constants and gives explicit rerun instructions. |
|
|
164
|
+
| `auto` | `--auto-target`, `-a`, or `RUBYCLI_AUTO_TARGET=auto` | If exactly one constant in that file defines CLI-callable methods, Rubycli auto-selects it; otherwise you still get the friendly error message. |
|
|
165
|
+
|
|
166
|
+
This keeps large projects safe by default but still provides a one-flag escape hatch when you prefer the fully automatic behaviour.
|
|
167
|
+
|
|
168
|
+
> **Instance-only classes** – If a class only defines public *instance* methods (for example, it exposes functionality via `attr_reader` or `def greet` on the instance), you must run Rubycli with `--new` so the class is instantiated before commands are resolved. Otherwise Rubycli cannot see any CLI-callable methods. Add at least one public class method when you do not want to rely on `--new`.
|
|
169
|
+
|
|
157
170
|
## Project Philosophy
|
|
158
171
|
|
|
159
172
|
- **Convenience first** – The goal is to wrap existing Ruby scripts in a CLI with almost no manual plumbing. Fidelity with Python Fire is not a requirement.
|
|
160
173
|
- **Inspired, not a port** – We borrow ideas from Python Fire, but we do not aim for feature parity. Missing Fire features are generally “by design.”
|
|
161
|
-
- **
|
|
174
|
+
- **Method definitions first, comments augment behavior** – Public method signatures determine what gets exposed (and which arguments are required), while doc comments like `TAG...` or `[Integer]` can turn the very same CLI value into arrays, integers, booleans, etc. Enable strict mode (`RUBYCLI_STRICT=ON`) when you want warnings about mismatches.
|
|
162
175
|
- **Lightweight maintenance** – Much of the implementation was generated with AI assistance; contributions that diverge into deep Ruby metaprogramming are out of scope. Please discuss expectations before opening parity PRs.
|
|
163
176
|
|
|
164
177
|
## Features
|
|
@@ -179,7 +192,7 @@ ruby examples/hello_app_with_require.rb greet Hanako --shout
|
|
|
179
192
|
| Capability | Python Fire | Rubycli |
|
|
180
193
|
| ---------- | ----------- | -------- |
|
|
181
194
|
| Attribute traversal | Recursively exposes attributes/properties on demand | Exposes public methods defined on the target; no implicit traversal |
|
|
182
|
-
| Constructor handling | Automatically prompts for `__init__` args when instantiating classes |
|
|
195
|
+
| Constructor handling | Automatically prompts for `__init__` args when instantiating classes | `--new` simply instantiates without passing CLI args (use pre-scripts or your own factories if you need injected dependencies) |
|
|
183
196
|
| Interactive shell | Offers Fire-specific REPL when invoked without command | No interactive shell mode; strictly command execution |
|
|
184
197
|
| Input discovery | Pure reflection, no doc comments required | Doc comments drive option names, placeholders, and validation |
|
|
185
198
|
| Data structures | Dictionaries / lists become subcommands by default | Focused on class or module methods; no automatic dict/list expansion |
|
|
@@ -382,7 +395,9 @@ rubycli -e scripts/data_cli.rb DataCLI run '(1..10).to_a'
|
|
|
382
395
|
|
|
383
396
|
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) { … }`.
|
|
384
397
|
|
|
385
|
-
`--eval-
|
|
398
|
+
Need Ruby evaluation plus a safety net? Pass `--eval-lax` (or `-E`). It flips on eval mode just like `--eval-args`, but if Ruby fails to parse a token (for example, a bare `https://example.com`), Rubycli emits a warning and forwards the original string unchanged. This lets you mix inline math (`60*60*24*14`) with literal values without constantly juggling quotes.
|
|
399
|
+
|
|
400
|
+
`--json-args`/`-j` cannot be combined with either `--eval-args`/`-e` or `--eval-lax`/`-E`; Rubycli will raise an error if both are present. Both modes augment the default literal parsing, so you can pick either strict JSON or one of the Ruby eval variants when the defaults are not enough.
|
|
386
401
|
|
|
387
402
|
## Pre-script bootstrap
|
|
388
403
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycli
|
|
4
|
+
# Coordinates json/eval argument modes and enforces mutual exclusion.
|
|
5
|
+
class ArgumentModeController
|
|
6
|
+
def initialize(json_coercer:, eval_coercer:)
|
|
7
|
+
@json_coercer = json_coercer
|
|
8
|
+
@eval_coercer = eval_coercer
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def json_mode?
|
|
12
|
+
@json_coercer.json_mode?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def eval_mode?
|
|
16
|
+
@eval_coercer.eval_mode?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with_json_mode(enabled = true, &block)
|
|
20
|
+
enforce_mutual_exclusion!(:json, enabled)
|
|
21
|
+
@json_coercer.with_json_mode(enabled, &block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_eval_mode(enabled = true, **options, &block)
|
|
25
|
+
enforce_mutual_exclusion!(:eval, enabled)
|
|
26
|
+
@eval_coercer.with_eval_mode(enabled, **options, &block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def apply_argument_coercions(positional_args, keyword_args)
|
|
30
|
+
ensure_modes_compatible!
|
|
31
|
+
|
|
32
|
+
if json_mode?
|
|
33
|
+
coerce_values!(positional_args, keyword_args) { |value| @json_coercer.coerce_json_value(value) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if eval_mode?
|
|
37
|
+
coerce_values!(positional_args, keyword_args) { |value| @eval_coercer.coerce_eval_value(value) }
|
|
38
|
+
end
|
|
39
|
+
rescue ::ArgumentError => e
|
|
40
|
+
raise Rubycli::ArgumentError, e.message
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def enforce_mutual_exclusion!(mode, enabled)
|
|
46
|
+
return unless enabled
|
|
47
|
+
|
|
48
|
+
case mode
|
|
49
|
+
when :json
|
|
50
|
+
raise Rubycli::ArgumentError, '--json-args cannot be combined with --eval-args or --eval-lax' if eval_mode?
|
|
51
|
+
when :eval
|
|
52
|
+
raise Rubycli::ArgumentError, '--json-args cannot be combined with --eval-args or --eval-lax' if json_mode?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ensure_modes_compatible!
|
|
57
|
+
if json_mode? && eval_mode?
|
|
58
|
+
raise Rubycli::ArgumentError, '--json-args cannot be combined with --eval-args or --eval-lax'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def coerce_values!(positional_args, keyword_args)
|
|
63
|
+
positional_args.map! { |value| yield(value) }
|
|
64
|
+
keyword_args.keys.each do |key|
|
|
65
|
+
keyword_args[key] = yield(keyword_args[key])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
require 'psych'
|
|
2
1
|
require_relative 'type_utils'
|
|
2
|
+
require_relative 'arguments/token_stream'
|
|
3
|
+
require_relative 'arguments/value_converter'
|
|
3
4
|
|
|
4
5
|
module Rubycli
|
|
5
6
|
class ArgumentParser
|
|
@@ -10,6 +11,7 @@ module Rubycli
|
|
|
10
11
|
@documentation_registry = documentation_registry
|
|
11
12
|
@json_coercer = json_coercer
|
|
12
13
|
@debug_logger = debug_logger
|
|
14
|
+
@value_converter = Arguments::ValueConverter.new
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def parse(args, method = nil)
|
|
@@ -25,21 +27,21 @@ module Rubycli
|
|
|
25
27
|
option_lookup = build_option_lookup(option_defs)
|
|
26
28
|
type_converters = build_type_converter_map(option_defs)
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
stream = Arguments::TokenStream.new(args)
|
|
31
|
+
|
|
32
|
+
until stream.finished?
|
|
33
|
+
token = stream.current
|
|
31
34
|
|
|
32
35
|
if token == '--'
|
|
33
|
-
|
|
36
|
+
stream.advance
|
|
37
|
+
rest_tokens = stream.consume_remaining.map { |value| convert_arg(value) }
|
|
34
38
|
pos_args.concat(rest_tokens)
|
|
35
39
|
break
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
i = process_option_token(
|
|
40
|
+
elsif option_token?(token)
|
|
41
|
+
stream.advance
|
|
42
|
+
process_option_token(
|
|
40
43
|
token,
|
|
41
|
-
|
|
42
|
-
i,
|
|
44
|
+
stream,
|
|
43
45
|
kw_param_names,
|
|
44
46
|
kw_args,
|
|
45
47
|
cli_aliases,
|
|
@@ -47,12 +49,12 @@ module Rubycli
|
|
|
47
49
|
type_converters
|
|
48
50
|
)
|
|
49
51
|
elsif assignment_token?(token)
|
|
52
|
+
stream.advance
|
|
50
53
|
process_assignment_token(token, kw_args)
|
|
51
54
|
else
|
|
52
55
|
pos_args << convert_arg(token)
|
|
56
|
+
stream.advance
|
|
53
57
|
end
|
|
54
|
-
|
|
55
|
-
i += 1
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
|
|
@@ -85,8 +87,7 @@ module Rubycli
|
|
|
85
87
|
|
|
86
88
|
def process_option_token(
|
|
87
89
|
token,
|
|
88
|
-
|
|
89
|
-
current_index,
|
|
90
|
+
stream,
|
|
90
91
|
kw_param_names,
|
|
91
92
|
kw_args,
|
|
92
93
|
cli_aliases,
|
|
@@ -108,21 +109,19 @@ module Rubycli
|
|
|
108
109
|
requires_value = option_meta ? option_meta[:requires_value] : nil
|
|
109
110
|
option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
|
|
110
111
|
|
|
111
|
-
value_capture
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
['true', current_index]
|
|
125
|
-
end
|
|
112
|
+
value_capture = if embedded_value
|
|
113
|
+
embedded_value
|
|
114
|
+
elsif option_meta
|
|
115
|
+
capture_option_value(
|
|
116
|
+
option_meta,
|
|
117
|
+
stream,
|
|
118
|
+
requires_value
|
|
119
|
+
)
|
|
120
|
+
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
121
|
+
stream.consume
|
|
122
|
+
else
|
|
123
|
+
'true'
|
|
124
|
+
end
|
|
126
125
|
|
|
127
126
|
if requires_value && (value_capture.nil? || value_capture == 'true')
|
|
128
127
|
raise ArgumentError, "Option '#{option_label}' requires a value"
|
|
@@ -136,40 +135,30 @@ module Rubycli
|
|
|
136
135
|
)
|
|
137
136
|
|
|
138
137
|
kw_args[final_key_sym] = converted_value
|
|
139
|
-
current_index
|
|
140
138
|
end
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
new_index += 1
|
|
165
|
-
args[new_index]
|
|
166
|
-
elsif new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
|
|
167
|
-
new_index += 1
|
|
168
|
-
args[new_index]
|
|
169
|
-
else
|
|
170
|
-
'true'
|
|
171
|
-
end
|
|
172
|
-
[value, new_index]
|
|
139
|
+
def capture_option_value(option_meta, stream, requires_value)
|
|
140
|
+
if option_meta[:boolean_flag]
|
|
141
|
+
if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
|
|
142
|
+
return stream.consume
|
|
143
|
+
end
|
|
144
|
+
return 'true'
|
|
145
|
+
elsif option_meta[:optional_value]
|
|
146
|
+
if (next_token = stream.current) && !looks_like_option?(next_token)
|
|
147
|
+
return stream.consume
|
|
148
|
+
end
|
|
149
|
+
return true
|
|
150
|
+
elsif requires_value == false
|
|
151
|
+
return 'true'
|
|
152
|
+
elsif requires_value
|
|
153
|
+
next_token = stream.current
|
|
154
|
+
raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token
|
|
155
|
+
|
|
156
|
+
return stream.consume
|
|
157
|
+
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
158
|
+
return stream.consume
|
|
159
|
+
else
|
|
160
|
+
return 'true'
|
|
161
|
+
end
|
|
173
162
|
end
|
|
174
163
|
|
|
175
164
|
def process_assignment_token(token, kw_args)
|
|
@@ -204,65 +193,7 @@ module Rubycli
|
|
|
204
193
|
end
|
|
205
194
|
|
|
206
195
|
def convert_arg(arg)
|
|
207
|
-
|
|
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)
|
|
225
|
-
|
|
226
|
-
arg
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def integer_string?(str)
|
|
230
|
-
str =~ /\A-?\d+\z/
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def float_string?(str)
|
|
234
|
-
str =~ /\A-?\d+\.\d+\z/
|
|
235
|
-
end
|
|
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
|
|
196
|
+
@value_converter.convert(arg)
|
|
266
197
|
end
|
|
267
198
|
|
|
268
199
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycli
|
|
4
|
+
module Arguments
|
|
5
|
+
# Lightweight mutable cursor over CLI tokens.
|
|
6
|
+
class TokenStream
|
|
7
|
+
def initialize(tokens)
|
|
8
|
+
@tokens = Array(tokens).dup
|
|
9
|
+
@index = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def current
|
|
13
|
+
@tokens[@index]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def peek(offset = 1)
|
|
17
|
+
@tokens[@index + offset]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def advance(count = 1)
|
|
21
|
+
@index += count
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def consume
|
|
25
|
+
value = current
|
|
26
|
+
advance
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def consume_remaining
|
|
31
|
+
remaining = @tokens[@index..] || []
|
|
32
|
+
@index = @tokens.length
|
|
33
|
+
remaining
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finished?
|
|
37
|
+
@index >= @tokens.length
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'psych'
|
|
4
|
+
|
|
5
|
+
module Rubycli
|
|
6
|
+
module Arguments
|
|
7
|
+
# Converts raw CLI tokens into Ruby primitives when safe to do so.
|
|
8
|
+
class ValueConverter
|
|
9
|
+
LITERAL_PARSE_FAILURE = Object.new
|
|
10
|
+
|
|
11
|
+
def convert(value)
|
|
12
|
+
return value if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
13
|
+
return value unless value.is_a?(String)
|
|
14
|
+
|
|
15
|
+
trimmed = value.strip
|
|
16
|
+
return value if trimmed.empty?
|
|
17
|
+
|
|
18
|
+
if literal_like?(trimmed)
|
|
19
|
+
literal = try_literal_parse(value)
|
|
20
|
+
return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
return nil if null_literal?(trimmed)
|
|
24
|
+
|
|
25
|
+
lower = trimmed.downcase
|
|
26
|
+
return true if lower == 'true'
|
|
27
|
+
return false if lower == 'false'
|
|
28
|
+
return value.to_i if integer_string?(trimmed)
|
|
29
|
+
return value.to_f if float_string?(trimmed)
|
|
30
|
+
|
|
31
|
+
value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def integer_string?(str)
|
|
37
|
+
str =~ /\A-?\d+\z/
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def float_string?(str)
|
|
41
|
+
str =~ /\A-?\d+\.\d+\z/
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def try_literal_parse(value)
|
|
45
|
+
return LITERAL_PARSE_FAILURE unless value.is_a?(String)
|
|
46
|
+
|
|
47
|
+
trimmed = value.strip
|
|
48
|
+
return value if trimmed.empty?
|
|
49
|
+
|
|
50
|
+
literal = Psych.safe_load(trimmed, aliases: false)
|
|
51
|
+
return literal unless literal.nil? && !null_literal?(trimmed)
|
|
52
|
+
|
|
53
|
+
LITERAL_PARSE_FAILURE
|
|
54
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::Exception
|
|
55
|
+
LITERAL_PARSE_FAILURE
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def null_literal?(value)
|
|
59
|
+
return false unless value
|
|
60
|
+
|
|
61
|
+
%w[null ~].include?(value.downcase)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def literal_like?(value)
|
|
65
|
+
return false unless value
|
|
66
|
+
return true if value.start_with?('[', '{', '"', "'")
|
|
67
|
+
return true if value.start_with?('---')
|
|
68
|
+
return true if value.match?(/\A(?:true|false|null|nil)\z/i)
|
|
69
|
+
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/rubycli/cli.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Rubycli
|
|
|
37
37
|
|
|
38
38
|
if should_show_help?(args)
|
|
39
39
|
@help_renderer.print_help(target, catalog)
|
|
40
|
-
|
|
40
|
+
return 0
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
command = args.shift
|
|
@@ -105,7 +105,7 @@ module Rubycli
|
|
|
105
105
|
error_msg = "Command '#{command}' is not available."
|
|
106
106
|
puts error_msg
|
|
107
107
|
@help_renderer.print_help(target, catalog)
|
|
108
|
-
|
|
108
|
+
1
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
@@ -116,6 +116,7 @@ module Rubycli
|
|
|
116
116
|
begin
|
|
117
117
|
result = Rubycli.call_target(target, pos_args, kw_args)
|
|
118
118
|
@result_emitter.emit(result)
|
|
119
|
+
0
|
|
119
120
|
rescue StandardError => e
|
|
120
121
|
handle_execution_error(e, command, method, pos_args, kw_args, cli_mode)
|
|
121
122
|
end
|
|
@@ -132,15 +133,16 @@ module Rubycli
|
|
|
132
133
|
def execute_parameterless_method(method_obj, command, args, cli_mode)
|
|
133
134
|
if help_requested_for_parameterless?(args)
|
|
134
135
|
puts usage_for_method(command, method_obj)
|
|
135
|
-
|
|
136
|
+
return 0
|
|
136
137
|
end
|
|
137
138
|
|
|
138
139
|
begin
|
|
139
140
|
result = method_obj.call
|
|
140
141
|
debug_log "Parameterless method returned: #{result.inspect}"
|
|
141
142
|
if result
|
|
142
|
-
run(result, args, false)
|
|
143
|
+
return run(result, args, false)
|
|
143
144
|
end
|
|
145
|
+
0
|
|
144
146
|
rescue StandardError => e
|
|
145
147
|
handle_execution_error(e, command, method_obj, [], {}, cli_mode)
|
|
146
148
|
end
|
|
@@ -152,12 +154,13 @@ module Rubycli
|
|
|
152
154
|
|
|
153
155
|
if should_show_method_help?(pos_args, kw_args)
|
|
154
156
|
puts usage_for_method(command, method_obj)
|
|
155
|
-
|
|
157
|
+
return 0
|
|
156
158
|
end
|
|
157
159
|
|
|
158
160
|
begin
|
|
159
161
|
result = Rubycli.call_target(method_obj, pos_args, kw_args)
|
|
160
162
|
@result_emitter.emit(result)
|
|
163
|
+
0
|
|
161
164
|
rescue StandardError => e
|
|
162
165
|
handle_execution_error(e, command, method_obj, pos_args, kw_args, cli_mode)
|
|
163
166
|
end
|
|
@@ -181,7 +184,7 @@ module Rubycli
|
|
|
181
184
|
if cli_mode && !arguments_match?(method_obj, pos_args, kw_args) && usage_error?(error)
|
|
182
185
|
puts "Error: #{error.message}"
|
|
183
186
|
puts usage_for_method(command, method_obj)
|
|
184
|
-
|
|
187
|
+
1
|
|
185
188
|
else
|
|
186
189
|
raise error
|
|
187
190
|
end
|