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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14562bc06b2479369c2951a54cac6f8f502e5ff81ae453b25bdb225b2017f671
4
- data.tar.gz: 731a6704bac4ca7d9471b4ff00d7476745e81c442666e99ca3b710d70c9e4622
3
+ metadata.gz: a71a438637363ea54e22377b645dc99a26a0c91692571bf57a68368403e32308
4
+ data.tar.gz: 367b354b2ffde5ee80e7ee3ee5b2858f15cbf8097cc8346fd812b780f41443e2
5
5
  SHA512:
6
- metadata.gz: 2ef973534a268ac8475f21f9d26ef16202ab5f4a89a69c3ea94a0700f1d89a5b17e533d5e821f8c6833855c91f72a5d5d6fcbfb75716c9a3648bf52e9c26600b
7
- data.tar.gz: c2cc78a8dbc2ec9185c6fcb598ea5be2160ea336021932e2ab7ffb58b5b2be5f8fad078ed379a26a33c66241ed69a17c1edd9bd852663299747a630edd9da235
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
  ![Rubycli ロゴ](assets/rubycli-logo.png)
4
4
 
5
- Rubycli は Ruby のクラス/モジュールに書いたコメントから CLI を自動生成する小さなフレームワークです。Python Fire にインスパイアされていますが、互換や公式ポートを目指すものではありません。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
- - **コードが一次情報、コメントは補助**メソッド定義こそが真実であり、コメントはヘルプを豊かにする付加情報です。コメントと実装のズレを観測したいときだけ `RUBYCLI_STRICT=ON` で厳格モードを有効化し、警告を受け取ります。
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-args`/`-e` と `--json-args`/`-j` は同時指定できません。どちらのモードも既定のリテラル解析を拡張する位置づけなので、用途に応じて厳格な JSON Ruby 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
  ![Rubycli logo](assets/rubycli-logo.png)
4
4
 
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.
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
- - **Comments enrich, code decides** – Method signatures remain the source of truth; optional documentation comments add richer help output and can surface warnings when you opt into strict mode (`RUBYCLI_STRICT=ON`).
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 | Requires explicit `--new` plus comment docs; no automatic prompting |
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-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.
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
- i = 0
29
- while i < args.size
30
- token = args[i]
30
+ stream = Arguments::TokenStream.new(args)
31
+
32
+ until stream.finished?
33
+ token = stream.current
31
34
 
32
35
  if token == '--'
33
- rest_tokens = (args[(i + 1)..-1] || []).map { |value| convert_arg(value) }
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
- end
37
-
38
- if option_token?(token)
39
- i = process_option_token(
40
+ elsif option_token?(token)
41
+ stream.advance
42
+ process_option_token(
40
43
  token,
41
- args,
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
- args,
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, current_index = if embedded_value
112
- [embedded_value, current_index]
113
- elsif option_meta
114
- capture_option_value(
115
- option_meta,
116
- args,
117
- current_index,
118
- requires_value
119
- )
120
- elsif current_index + 1 < args.size && !looks_like_option?(args[current_index + 1])
121
- current_index += 1
122
- [args[current_index], current_index]
123
- else
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
- def capture_option_value(option_meta, args, current_index, requires_value)
143
- new_index = current_index
144
- value = if option_meta[:boolean_flag]
145
- if new_index + 1 < args.size && TypeUtils.boolean_string?(args[new_index + 1])
146
- new_index += 1
147
- args[new_index]
148
- else
149
- 'true'
150
- end
151
- elsif option_meta[:optional_value]
152
- if new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
153
- new_index += 1
154
- args[new_index]
155
- else
156
- true
157
- end
158
- elsif requires_value == false
159
- 'true'
160
- elsif requires_value
161
- if new_index + 1 >= args.size
162
- raise ArgumentError, "Option '#{option_meta.long}' requires a value"
163
- end
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
- 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)
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
- exit(0)
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
- exit(1)
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
- exit(0)
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
- exit(0)
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
- exit(1)
187
+ 1
185
188
  else
186
189
  raise error
187
190
  end