diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d3e8b93 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Ruby ${{ matrix.ruby }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - "3.0" + - "3.2" + - "3.4" + - "4.0" + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: Install Ruby 3.0 test compatibility + if: matrix.ruby == '3.0' + run: gem install minitest --version 5.26.1 --no-document + - name: Install Ruby 4 test compatibility + if: matrix.ruby == '4.0' + run: gem install minitest-mock --version 5.27.0 --no-document + - name: Run tests + run: ruby -Ilib:test -e 'Dir["test/*_test.rb"].sort.each { |file| require File.expand_path(file) }' + + coverage: + name: Coverage + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: "3.4" + - name: Enforce coverage thresholds + run: ruby -Ilib:test test/coverage_runner.rb + env: + COVERAGE_BASE_REF: ${{ github.event_name == 'push' && github.event.created == false && github.event.deleted == false && github.event.before || format('origin/{0}', github.base_ref || github.event.repository.default_branch) }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 726644b..344465f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,38 @@ # Changelog -# Changelog +## [Unreleased] + +### Fixed +- Parameterless commands now reject unexpected arguments without invoking the target method or attempting implicit return-value traversal. +- Required options now report a missing value instead of consuming the following option token, while explicit values such as `true` remain valid. +- Required options accept negative exponent, digit-separated decimal, and radix notation such as `-1e3`, `-1_000`, and `-0x10` without mistaking them for another option. +- Required options in eval mode accept space-separated lambda and unary expressions without consuming known option tokens as values. +- Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. +- Repeated loads retain assigned constant aliases when the source file is unchanged. +- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, including loop initializers and receivers found by Ruby's lexical constant fallback, without reviving failed assignments or aliases behind disabled conditions. +- Constant discovery analyzes the preloaded source without triggering autoloads, so inactive autoload branches and self-removing target files do not add side effects or fail after a successful load. +- Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. +- Framework argument errors raised by constructors are also wrapped in the same user-facing runner error. +- Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. +- Runner tests now execute without terminating the Minitest process and assert converted command arguments instead of stubbed targets. +- `--check --new` now inspects exposed instance/class commands without running constructors, while `--check` rejects pre-scripts instead of evaluating them. +- `--check` now inspects explicitly selected commands even when their methods or defining procs come from required files. +- Explicit nested constant names no longer fall back to inherited top-level constants, and malformed pre-scripts now produce contextual Rubycli errors. +- Rest, optional-before-required, and trailing-required positional arguments now follow Ruby's argument binding rules for conversion, validation, and help output. +- Documented scalar/list conversions now preserve numeric-, boolean-, and null-looking strings, handle repeated booleans, return real `DateTime` values, accept JSON arrays, and reject scalar/array values where `JSON`/`Hash` shapes do not allow them. +- Positional `Symbol` annotations preserve colon-prefixed symbol literals instead of embedding the colon in the symbol name. +- Assignment-like positional values remain positional unless they match a keyword, while matching assignments use the same documented conversion as long options. +- YARD positional tags are aligned by parameter name instead of comment order. +- Eval-mode local variables and command-line strict/check/result-output flags no longer leak across separate programmatic runs. +- Constructor and command eval arguments share one binding per Runner execution without enabling eval mode while the target file loads. +- Required options accept a lone `-` value, bare rest placeholders render with an ellipsis, and quoted positional/option `String[]` elements remain strings. +- Invalid Ruby syntax passed through strict eval mode now produces a user-facing Rubycli argument error instead of leaking a `SyntaxError` backtrace. +- Circular arrays/hashes returned by commands now fall back to inspected output instead of raising a JSON nesting error. + +### Testing +- Added dependency-free overall line, branch, and changed-line coverage gates plus GitHub Actions checks spanning the supported Ruby range. +- Changed-line coverage now treats new library files that were never loaded by the test suite as uncovered, including non-ASCII paths quoted by Git. +- Push coverage compares against the previous commit and falls back to the default branch when a new ref has no previous commit or a ref is deleted. ## [0.1.7] - 2025-11-12 diff --git a/README.ja.md b/README.ja.md index db8f0bb..6d701b8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -2,11 +2,37 @@ ![Rubycli ロゴ](assets/rubycli-logo.png) -Rubycli は Ruby のクラス/モジュールにある公開メソッドの定義と、そのメソッドに付けたドキュメントコメントから CLI を自動生成する小さなフレームワークです。Python Fire にインスパイアされていますが、互換や公式ポートを目指すものではありません。Ruby のコメント記法と型アノテーションに合わせて設計しており、コメントに書いた型ヒントや繰り返し指定が CLI の引数解釈もコントロールします。 +[![Gem Version](https://img.shields.io/gem/v/rubycli)](https://rubygems.org/gems/rubycli) -> English guide is available in [README.md](README.md). +Rubycli は、既存の Ruby クラス/モジュールをそのままコマンドラインインターフェースにするツールです。 +公開メソッドの定義と、メソッドに付けたドキュメントコメントを読み取って CLI を組み立てるため、 +最小構成ではスクリプト側の変更が一切不要です(`require "rubycli"` すら要りません)。 +コメント内の型アノテーションは単なる説明ではなく、CLI 引数の解釈そのものを制御します +(例: `TAG... [String[]]` と書くと配列としてパースされます)。 -### 1. Rubycli を意識しない既存スクリプト +[Python Fire](https://github.com/google/python-fire) にインスパイアされていますが、 +移植や公式プロジェクトではなく、Ruby のコメント記法と型アノテーションに焦点を当てた独自実装です。 + +> English documentation: [README.md](README.md) + +![Rubycli のデモ(コマンド生成と実行の様子)](assets/rubycli-demo.gif) + +## インストール + +```bash +gem install rubycli +``` + +```ruby +# Gemfile +gem "rubycli" +``` + +Ruby 3.0 以上が必要です。ライセンスは [MIT](LICENSE) です。 + +## クイックスタート + +### 1. 既存スクリプトをそのまま実行する ```ruby # hello_app.rb @@ -19,7 +45,8 @@ module HelloApp end ``` -> リポジトリには `examples/hello_app.rb` を同梱しているので、プロジェクト直下で `rubycli examples/hello_app.rb` を実行すると公開コマンドをすぐに確認できます。 +同じ内容を `examples/hello_app.rb` として同梱しているので、以下のコマンドは +プロジェクト直下でそのまま試せます。 ```bash rubycli examples/hello_app.rb @@ -30,11 +57,13 @@ Usage: hello_app.rb COMMAND [arguments] Available commands: Class methods: - greet + greet NAME Detailed command help: hello_app.rb COMMAND help ``` +引数が足りない場合はスタックトレースではなく使い方が表示されます。 + ```bash rubycli examples/hello_app.rb greet ``` @@ -52,16 +81,15 @@ rubycli examples/hello_app.rb greet Hanako #=> Hello, Hanako! ``` -`rubycli examples/hello_app.rb --help` を実行しても同じヘルプが表示されます。 - -### 2. コメントのヒントを足してオプションを有効化 +`rubycli examples/hello_app.rb --help` でも、コマンド未指定時と同じ一覧が表示されます。 -> まだ `require "rubycli"` は不要です。コメントでオプション解析とヘルプを制御します。 +### 2. コメントを足して型付きオプションを有効にする -**簡潔なプレースホルダ記法** +この段階でも `require "rubycli"` は不要です。コメントだけでオプション解析とヘルプが変わります。 +簡潔なプレースホルダ記法と YARD タグのどちらでも書けます。 ```ruby -# hello_app.rb +# 簡潔なプレースホルダ記法 module HelloApp module_function @@ -75,10 +103,8 @@ module HelloApp end ``` -**YARD タグでも同様に動作** - ```ruby -# hello_app.rb +# YARD タグ module HelloApp module_function @@ -92,42 +118,31 @@ module HelloApp end ``` -> README に合わせたドキュメント付きの版は `examples/hello_app_with_docs.rb` として同梱しています。 - -```bash -rubycli examples/hello_app_with_docs.rb -``` - -```text -Usage: hello_app_with_docs.rb COMMAND [arguments] - -Available commands: - Class methods: - greet [--shout] - -Detailed command help: hello_app_with_docs.rb COMMAND help -``` +ドキュメント付きの版は `examples/hello_app_with_docs.rb` として同梱しています。 +このファイル名は定義している定数(`HelloApp`)と一致しないため、実行時に +`--auto-target` / `-a` を付けるか、定数名を明示してください +(詳細は後述の[対象定数の解決](#対象定数の解決)を参照)。 ```bash -rubycli examples/hello_app_with_docs.rb greet --help +rubycli -a examples/hello_app_with_docs.rb greet --help ``` ```text Usage: hello_app_with_docs.rb greet NAME [--shout] Positional arguments: - NAME [String] required 挨拶対象 + NAME [String] required Name to greet Options: - --shout [Boolean] optional 大文字で出力 (default: false) + --shout [Boolean] optional Print in uppercase (default: false) ``` ```bash -rubycli examples/hello_app_with_docs.rb greet --shout Hanako +rubycli -a examples/hello_app_with_docs.rb greet --shout Hanako #=> HELLO, HANAKO! ``` -CLI に公開したくないヘルパーは、特異クラス側で `private` として定義してください: +CLI に公開したくないヘルパーは、特異クラス側で `private` として定義します。 ```ruby module HelloApp @@ -135,99 +150,19 @@ module HelloApp private def internal_ping(url) - # CLI コマンドとしては露出しない + # CLI コマンドとしては公開されない end end end ``` -### 3. (任意)スクリプト内にランナーを組み込む - -`ruby hello_app.rb ...` の形で呼び出したい場合だけ `require "rubycli"` を追加し、`Rubycli.run` に制御を渡します(後述のクイックスタート参照)。 - -## 定数解決モード - -Rubycli は「ファイル名を CamelCase にした定数」を公開対象だと想定しています。ファイル名とクラス/モジュール名が一致しない場合は、次のモードで挙動を切り替えられます。 - -| モード | 有効化方法 | 挙動 | -| --- | --- | --- | -| `strict`(デフォルト) | 何もしない / `RUBYCLI_AUTO_TARGET=strict` | CamelCase が一致しないとエラーになります。検出した定数一覧と再実行コマンド例を表示します。 | -| `auto` | `--auto-target`(短縮 `-a`) または `RUBYCLI_AUTO_TARGET=auto` | ファイル内で CLI として実行できる定数が 1 つだけなら自動選択します。複数あれば従来通りエラーで案内します。 | - -大規模なコードベースでも安全側を保ちながら、どうしても自動選択したいときだけ 1 フラグで切り替えられます。 - -> **インスタンスメソッド専用のクラスについて** – 公開メソッドがインスタンス側(`def greet` など)にしか無い場合は、`--new` を付けて事前にインスタンス化しないと CLI から呼び出せません。クラスメソッドを 1 つ用意するか、`--new` を明示して実行してください。`--new` を付ければ `rubycli --help` でもインスタンスメソッドが一覧に現れ、`rubycli --check --new` でコメントの lint も実行できます。初期化時に引数が必要なら `--new=VALUE` のように続けて指定できます(通常の引数と同様に YAML/JSON ライクな安全パースに加え、`--json-args` / `--eval-args` / `--eval-lax` も適用可能)。`initialize` に書いたコメントも通常の CLI メソッドと同様に型変換に反映されます。 - -> 補足: `--new 1` のようにスペース区切りで 1 つだけ値を渡すと、後続トークンがパス扱いされやすいため `--new=VALUE` のように `=` 付きで指定するのが確実です。 - -## 開発方針 - -- **便利さが最優先** – 既存の Ruby スクリプトを最小の手間で CLI 化できることを目的にしており、Python Fire の完全移植は目指していません。 -- **インスパイアであってポートではない** – アイデアの出自は Fire ですが、同等機能を揃える予定は基本的にありません。Fire 由来の未実装機能は仕様です。 -- **メソッド定義が土台、コメントが挙動を補強** – 公開メソッドのシグネチャが CLI に露出する範囲と必須/任意を決めますが、コメントに `TAG...` や `[Integer]` を書くと同じ引数でも配列化や型変換が行われます。さらに Rubycli は `--names='["Alice","Bob"]'` のような JSON/YAML らしい入力を自動的に安全なリテラルとして評価します。`rubycli --check パス/対象.rb` でコメントと実装のズレ(未定義の型ラベルや列挙値の誤記を含む)を DidYouMean の候補付きで検査し、通常実行時に `--strict` を付ければドキュメント通りでない入力をその場でエラーにできます。 -- **軽量メンテナンス** – 実装の多くは AI 支援で作られており、深い Ruby メタプログラミングを伴う大規模拡張は想定外です。Fire 互換を求める PR は事前相談をお願いします。 - -## 特徴 - -- コメントベースで CLI オプションやヘルプを自動生成 -- YARD 形式と `NAME [Type] 説明…` の簡潔記法を同時サポート -- 引数はデフォルトで安全なリテラルとして解釈し、必要に応じて厳格 JSON モードや Ruby eval モードを切り替え可能 -- `--pre-script`(エイリアス: `--init`)で任意の Ruby コードを評価し、その結果オブジェクトを公開 -- `--check` でコメント整合性を lint、`--strict` で入力値をドキュメント通りに強制する二段構えのガード -- `examples/new_mode_runner.rb` ではインスタンス専用クラスを `--new=VALUE` で初期化し、eval/JSON モードや pre-script を組み合わせる例を示しています。 - -### サンプル / 付属例 - -- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb`: 最小のモジュール関数とドキュメント付きの版 -- `examples/typed_arguments_demo.rb`: 標準ライブラリ型 (Date/Time/BigDecimal/Pathname) の coercion -- `examples/strict_choices_demo.rb`: リテラル列挙と `--strict` の組み合わせ -- `examples/new_mode_runner.rb`: インスタンス専用クラスを `--new=VALUE` で初期化し、eval/JSON/pre-script を組み合わせる例 - -#### サンプルコマンド - -- `rubycli examples/new_mode_runner.rb run --new='["a","b","c"]' --mode reverse` -- `rubycli --json-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{"source":"json"}'` -- `rubycli --eval-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{tags: [:a, :b]}'` -- `rubycli --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' examples/new_mode_runner.rb run --mode summary` - -> 補足: `--strict` はコメントに書かれた型/許可値をそのまま信頼して検証するため、コメントが誤記だと実行時には検出できません。CI では必ず `rubycli --check` を走らせ、`--strict` は「 lint を通過したドキュメントを本番で厳密に守る」用途に使ってください。 - -## Python Fire との違い - -- **コメント対応のヘルプ生成**: コメントがあればヘルプに反映しつつ、最終的な判断は常にライブなメソッド定義に基づきます。 -- **型に基づく解析**: `NAME [String]` や YARD タグから型を推論し、真偽値・配列・数値などを自動変換します。 -- **厳密な整合性チェック**: `rubycli --check` でコメントと実装のズレ(未定義の型ラベルや列挙値の誤記など)をコード実行前に検査し、通常実行時に `--strict` を付ければドキュメントで宣言した型・許可値以外の入力を拒否できます。 -- **Ruby 向け拡張**: キーワード引数やブロック (`@yield*`) といった Ruby 固有の構文に合わせたパーサや `RUBYCLI_*` 環境変数を用意しています。 - -| 機能 | Python Fire | Rubycli | -| ---- | ----------- | -------- | -| 属性の辿り方 | オブジェクトを辿ってプロパティ/属性を自動公開 | 対象オブジェクトの公開メソッドをそのまま公開(暗黙の辿りは無し) | -| クラス初期化 | `__init__` 引数を CLI で自動受け取りインスタンス化 | `--new` 指定時だけ初期化(コンストラクタ引数は `--new=VALUE` で渡せる。YAML/JSON らしいリテラルは安全にパース、`--json-args` / `--eval-args` / `--eval-lax` も適用可能。より複雑なら pre-script や自前ファクトリを利用) | -| インタラクティブシェル | コマンド未指定時に Fire REPL を提供 | インタラクティブモード無し。コマンド実行専用 | -| 情報源 | 反射で引数・プロパティを解析 | ライブなメソッド定義を基点にしつつコメントをヘルプへ反映 | -| 辞書/配列 | dict/list を自動でサブコマンド化 | クラス/モジュールのメソッドに特化(辞書自動展開なし) | - -## インストール - -Rubycli は RubyGems からインストールできます。 - -```bash -gem install rubycli -``` - -Bundler 例: - -```ruby -# Gemfile -gem "rubycli" -``` - -## クイックスタート(Rubycli をスクリプトに組み込む) +### 3. (任意)スクリプトにランナーを組み込む -ステップ3では `require "rubycli"` を追加し、スクリプト自身から CLI を起動できるようにします。 +`ruby スクリプト.rb ...` の形で起動したい場合は、gem を require して +`Rubycli.run` に委譲します(`examples/hello_app_with_require.rb` として同梱)。 ```ruby -# hello_app.rb +# hello_app_with_require.rb require "rubycli" module HelloApp @@ -247,133 +182,174 @@ end Rubycli.run(HelloApp) ``` -実行例: - ```bash -ruby hello_app.rb greet Taro -#=> Hello, Taro! - -ruby hello_app.rb greet Taro --shout +ruby examples/hello_app_with_require.rb greet Taro --shout #=> HELLO, TARO! ``` -`require "rubycli"` を書かなくても、付属コマンドから同じファイルを実行できます: +付属の `rubycli` コマンド経由で実行した場合は、メソッドの戻り値が自動で標準出力に表示されます。 -```bash -rubycli path/to/hello_app.rb greet --shout Hanako -``` +## 対象定数の解決 -クラス/モジュール名を省略した場合でも、ファイル名に対応する定義を自動で推測し、ネストした `Module1::Inner::Runner` のようなクラスも見つけ出します。CLI から実行するとメソッドの戻り値は常に標準出力へ表示されます。 +Rubycli は「ファイル名を CamelCase にした定数」を公開対象と想定します。 +一致しない場合の挙動はモードで切り替えられます。 + +| モード | 有効化方法 | 挙動 | +| --- | --- | --- | +| `strict`(既定) | 何もしない / `RUBYCLI_AUTO_TARGET=strict` | CamelCase 名が一致しないとエラー。検出した定数一覧と再実行方法を表示します。 | +| `auto` | `--auto-target` / `-a` / `RUBYCLI_AUTO_TARGET=auto` | CLI として実行できる定数がファイル内に 1 つだけなら自動選択します。 | -別の定数を明示的に指定したい場合は、ファイルパスの後ろに続けてください: +ファイルパスの後ろに定数名を明示することもできます。1 ファイルに候補が複数ある場合や、 +ネストした定数を選びたい場合に便利です。 ```bash rubycli scripts/multi_runner.rb Admin::Runner list --active ``` -1つのファイルに複数の候補がある場合や、ファイル名と異なるネストした定義を選びたいときに便利です。 +`Module1::Inner::Runner` のようなネストした定数も検出できます。 + +## インスタンスメソッド専用クラスと `--new` + +公開メソッドがインスタンス側にしかないクラスは、`--new` を付けて事前にインスタンス化しないと +CLI から呼び出せません(Rubycli から見えるコマンドが 1 つもない状態になります)。 + +- `--new` を付けると `--help` の一覧にインスタンスメソッドが現れ、 + `rubycli --check --new` でコメントの lint も実行できます。 +- コンストラクタに引数が必要な場合は、**ファイルパスより前に** `--new=VALUE` の形で渡します。 + 値は安全な YAML/JSON ライクなリテラルとして解釈され、`initialize` に付けたコメントも + 通常の CLI メソッドと同様に型変換へ反映されます。 +- スペース区切りの `--new VALUE` は値がファイルパスと誤認されやすいため、 + `--new=VALUE` の形を推奨します。 + +実行例(`examples/new_mode_runner.rb`): + +```bash +rubycli --new='["a","b","c"]' examples/new_mode_runner.rb run --mode reverse +#=> ["c", "b", "a"] +``` ## コメント記法 +YARD タグと短縮形のどちらでも書けます。 + | 用途 | YARD 互換 | Rubycli 標準 | | ---- | --------- | ----------- | | 位置引数 | `@param name [Type] 説明` | `NAME [Type] 説明` | | キーワード引数 | 同上 | `--flag -f VALUE [Type] 説明` | | 戻り値 | `@return [Type] 説明` | `=> [Type] 説明` | -短いオプション(`-f` など)は任意で、登場順も自由です。Rubycli 標準の書き方では次の例が同義になります。 +短いオプション(`-f` など)は任意で、順序も自由です。次の 3 つは同義です。 - `--flag -f VALUE [Type] 説明` - `--flag VALUE [Type] 説明` - `-f --flag VALUE [Type] 説明` -README のサンプルは既定スタイルとして大文字プレースホルダ(`NAME`, `VALUE` など)を使用しています。次項以降の表記揺れは、必要に応じて選べる追加記法です。 +型は `[String]` でも `(String)` でも指定でき、`(String, nil)` のように複数型も書けます。 ### 互換プレースホルダ表記 -コメントやヘルプ出力では次の表記も同じ意味として解釈されます。 +コメントの解析とヘルプ出力の両方で、次の表記も同じ意味として扱われます。 -- 山括弧で値を明示: `--flag `, `NAME []` -- ロングオプションの `=` 付き表記: `--flag=` +- 山括弧: `--flag `, `NAME []` +- `=` 付きロングオプション: `--flag=` - 繰り返し指定: `VALUE...`, `...` -実行時には `--flag VALUE`, `--flag `, `--flag=` のどれで入力しても同じ扱いです。プロジェクトで読みやすいスタイルを選択してください。`[VALUE]` や `[VALUE...]` のような表記を使うと、真偽値・任意値・リストなどの推論が働きます。値プレースホルダを省略したオプション(例: `--quiet`)は自動で Boolean フラグとして扱われます。 - -> 補足: コメント内で任意引数を角括弧で表す必要はありません。Ruby 側のメソッドシグネチャから必須/任意は自動判定され、ヘルプ出力では Rubycli が適切に角括弧を追加します。 - -型ヒントは `[String]` や `(String)` のように角括弧/丸括弧で指定できます。複数型は `(String, nil)` のように列挙してください。 - -`VALUE...` のような繰り返し指定(`TAG...` など)や、`[String[]]` / `Array` といった配列型の注釈が付いたオプションは配列として扱われます。JSON/YAML 形式のリスト(例: `--tags '["build","test"]'`)を渡すか、カンマ区切り文字列(`--tags "build,test"`)を渡すことで配列に変換されます。スペース区切りの複数値入力(`--tags build test`)にはまだ対応しておらず、繰り返し注記のないオプションは従来どおりスカラーとして扱われます。`--strict` 実行時は各要素の型も検証されるため、`[String[]]` と書かれているのに `--tags [1,2]` のような数値配列を渡すと即エラーになります。 +実行時には `--flag VALUE`, `--flag `, `--flag=` のどれも同じ扱いなので、 +プロジェクトで読みやすいスタイルを選んでください。任意引数を自分で角括弧に包む必要は +ありません。必須/任意は Ruby のメソッドシグネチャから自動判定され、ヘルプ出力では +Rubycli が角括弧を補います。 -JSON やカンマ区切りで表現しづらいシンボル配列・ハッシュなどを渡したい場合は eval モード(`--eval-args`/`-e` または `--eval-lax`/`-E`)を有効にし、ドキュメントで宣言した型に合わせた Ruby リテラルを渡してください。スペース区切りが未対応でも、安全に複数選択を指定できます(後述の eval 例を参照)。 +注釈が部分的な場合の推論規則: -代表的な推論例: +- `ARG1` のように型を省略したプレースホルダは `String` として扱われます。 +- 値プレースホルダのないオプション(`--verbose`)は Boolean フラグになります。 +- 位置引数を Boolean にするには `[Boolean]` の明示が必要です。`NAME 説明` のように + 型を省略すると、Ruby 側のデフォルト値に関わらず `String` とみなされます。 -- `ARG1` のように型ラベルを省略したプレースホルダは既定で `String` として扱われます。 -- `--name ARG1` のようにオプションへプレースホルダだけを指定しても同じく `String` が推論されます。 -- `--verbose` のように値プレースホルダを省略したオプションは Boolean フラグとして扱われます。 -- 位置引数を Boolean にしたい場合は必ず `[Boolean]` を明示してください。`NAME 説明` や `@param name 説明` のように型を省略すると、Ruby 側のデフォルト値に関わらず `String` とみなされます。 +### 配列と繰り返し値 -### リテラル列挙による制約 +`TAG...` のような繰り返し指定、または `[String[]]` / `Array` のような配列型注釈が +付いたオプションは配列としてパースされます。JSON/YAML 形式のリスト +(`--tags '["build","test"]'`)とカンマ区切り文字列(`--tags "build,test"`)の両方を +受け付けます。スペース区切りの複数値(`--tags build test`)には対応しておらず、 +繰り返し注記のないオプションはスカラーのままです。`--strict` 実行時は各要素の型も +検証されるため、`[String[]]` と書かれた注釈に対して `--tags [1,2]` を渡すとエラーになります。 +`--tags '["true","null"]'` のように引用された要素は、別のリテラルに見える内容でも文字列の +まま保持されます。 -`--format MODE [:json, :yaml, :auto]` や `LEVEL [:info, :warn]` のように型注釈内へ許容リテラルを列挙すると、ヘルプに選択肢を表示しつつ Rubycli が入力制約として解釈します。シンボル・文字列(裸の単語も可)・真偽値・数値・`nil` に対応し、型ヒントと混在させて `--channel TARGET [:stdout, :stderr, Boolean]` のような宣言も書けます。`%i[info warn]` / `%w[debug info]` などの短縮記法も展開されるため、`LEVEL %i[info warn]` でも同じ効果になります。通常実行では許可外の入力に警告を表示して続行し、`--strict` を付けた場合は `Rubycli::ArgumentError` を送出して即座に停止します。 +### リテラル列挙(enum) -> シンボルと文字列は厳密に区別されます。`[:info, :warn]` と書いた場合は `:info` のようにコロン付きで入力してください。`["info", "warn"]` を選んだ場合はプレーンな文字列のみ受け付けます。 +許容値の集合を型注釈の中に直接書けます: `--format MODE [:json, :yaml, :auto]`、 +`LEVEL [:info, :warn]` など。シンボル・文字列(裸の単語も可)・真偽値・数値・`nil` に対応し、 +`--channel TARGET [:stdout, :stderr, Boolean]` のように通常の型とも混在できます。 +`%i[info warn]` / `%w[debug info]` の短縮記法も展開されます。選択肢は常にヘルプへ表示され、 +許可外の値は `--strict` なしなら警告のみで続行、`--strict` 付きなら中断します。 -> 列挙は各スカラー値に適用されます。`[Symbol[]]` のような配列注釈に対して「許可される組み合わせ」をリテラルで書く構文(例: `[%i[foo bar][]]`)は未サポートなので、必要に応じて文章で説明するか、eval モードで Ruby の配列を渡してください。 +シンボルと文字列は厳密に区別されます。`[:info, :warn]` にはコロン付きの `:info` を、 +`["info", "warn"]` にはプレーンな文字列を入力してください。 ```bash -# literal choice デモ (examples/strict_choices_demo.rb) -ruby examples/strict_choices_demo.rb report warn --format json -#=> [WARN] format=json +# examples/strict_choices_demo.rb — LEVEL の注釈は [:info, :warn, :error] +rubycli examples/strict_choices_demo.rb report :warn --format json +#=> [WARN] format=json (続けて戻り値のハッシュが表示される) -# --strict を付けると仕様外の値で即エラー -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report debug -#=> Rubycli::ArgumentError: Value "debug" for LEVEL is not allowed: allowed values are :info, :warn, :error -``` - -```bash -# シンボル入力はコロンを付ける -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report :warn -#=> [WARN] format=text +# 裸の文字列はシンボルと一致しない: 警告して続行 +rubycli examples/strict_choices_demo.rb report warn +#=> [WARN] LEVEL must be one of :info, :warn, :error (received "warn") (use --strict to abort on invalid input) -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report warn -#=> Rubycli::ArgumentError: Value "warn" for LEVEL is not allowed: allowed values are :info, :warn, :error +# --strict を付けると許可外の入力で中断 +rubycli --strict examples/strict_choices_demo.rb report debug +#=> [ERROR] LEVEL must be one of :info, :warn, :error (received "debug") ``` -### 標準ライブラリ型ヒント +列挙は各スカラー引数に適用されます。`[%i[foo bar][]]` のような「配列の許容組み合わせ」を +リテラルで書く構文は未サポートです。 + +### 標準ライブラリの型ヒント -コメントに `Date` や `Time`, `BigDecimal`, `Pathname` など標準ライブラリの型名を書けば、Rubycli が必要な `require` を行った上で CLI 引数をその型へ変換します。 +コメントに `Date`、`Time`、`BigDecimal`、`Pathname` などの標準クラスを書くと、 +Rubycli が必要な stdlib を読み込んだ上で CLI 入力をその型へ変換します。 +ハンドラには実際のオブジェクトが渡るため、追加のパース処理は不要です。 ```bash -# examples/typed_arguments_demo.rb より -ruby examples/typed_arguments_demo.rb ingest \ +# examples/typed_arguments_demo.rb を参照 +rubycli examples/typed_arguments_demo.rb ingest \ --date 2024-12-25 \ --moment 2024-12-25T10:00:00Z \ --budget 123.45 \ --input ./data/input.csv ``` -ハンドラ側には `Date` / `Time` / `BigDecimal` / `Pathname` のインスタンスがそのまま渡るため、追加のパース処理は不要です。 +各オプションには既定値があるため、`... ingest --budget 999.99` のように +1 つずつ試すこともできます。 -各オプションには既定値があるため、`ruby examples/typed_arguments_demo.rb ingest --budget 999.99` のように個別の型だけ試すこともできます。 +`@example`、`@raise`、`@see`、`@deprecated` などその他の YARD タグは、 +現状ヘルプ出力には反映されません。 -`@example` や `@raise`, `@see`, `@deprecated` などその他の YARD タグは、現状ヘルプ出力には反映されません。 +> すべての記法をまとめて試すには +> `rubycli examples/documentation_style_showcase.rb canonical --help` などの +> showcase コマンドを実行してください。 -> すべての記法をまとめて試したい場合は `rubycli examples/documentation_style_showcase.rb canonical --help` や `... angled --help` などを実行してみてください。 +### YARD 互換コメントを併用する際の注意 -従来の `@param` 記法も既定で利用できます。簡潔なプレースホルダ記法だけに限定したい場合は `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` を設定してください(厳格モードでの検証は継続されます)。 +- `**kwargs` を受け取るメソッドでも、キーは自動では公開されません。CLI で使わせたいキーは + すべて `--long-name PLACEHOLDER [Type] 説明` の行として明示してください。 +- `@param` 行に続く箇条書きや補足行は CLI 生成には使われません。補足情報は + オプションの説明文に含めてください。 +- 簡潔なプレースホルダ記法へ統一したい場合は `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` を + 設定します。`@param`/`@return` タグが警告扱いになり、段階的に移行できます。 -### コメントが不足している場合のフォールバック +### コメントが不足している場合 -Rubycli は常に実装中のメソッドシグネチャを信頼します。コメントに書いていない引数やオプションがあっても、定義そのものから名前や初期値を推論して CLI に表示します。 +Rubycli は常に実装のメソッドシグネチャを信頼します。コメントに書かれていない引数も、 +定義から名前・既定値・型を推論して CLI に公開されます。 ```ruby -# fallback_example.rb +# examples/fallback_example.rb module FallbackExample module_function - # AMOUNT [Integer] 処理対象の数値 + # AMOUNT [Integer] Base amount to process def scale(amount, factor = 2, clamp: nil, notify: false) result = amount * factor result = [result, clamp].min if clamp @@ -383,20 +359,6 @@ module FallbackExample end ``` -```bash -rubycli examples/fallback_example.rb -``` - -```text -Usage: fallback_example.rb COMMAND [arguments] - -Available commands: - Class methods: - scale AMOUNT [] [--clamp=] [--notify] - -Detailed command help: fallback_example.rb COMMAND help -``` - ```bash rubycli examples/fallback_example.rb scale --help ``` @@ -405,52 +367,51 @@ rubycli examples/fallback_example.rb scale --help Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=] [--notify] Positional arguments: - AMOUNT [Integer] required 処理対象の数値 - FACTOR optional (default: 2) + AMOUNT [Integer] required Base amount to process + FACTOR [String] optional (default: 2) Options: --clamp= [String] optional (default: nil) --notify [Boolean] optional (default: false) ``` -`AMOUNT` だけがドキュメント化されていますが、`factor` や `clamp`, `notify` も自動的に補完され、既定値や型が推論されていることがわかります。開発時は `rubycli --check 対象.rb` でコメントとシグネチャの矛盾を検出し、本番実行で `--strict` を付ければ仕様外の入力をその場で弾けます。 - -#### 存在しない引数やオプションをコメントに書いた場合 - -- **整合しないコメントは詳細テキストへフォールバック** – 実装に存在しないオプション(例: `--ghost`)や位置引数(例: `EXTRA`)を記述すると、その行はヘルプ末尾の詳細セクションに素のテキストとして表示され、実際の引数としては機能しません。厳格モードなら `Extra positional argument comments were found: EXTRA` のような警告が出て、位置引数のズレにも気付きやすくなります。 +ドキュメント化されているのは `AMOUNT` だけですが、`factor`・`clamp`・`notify` も +推論された既定値・型付きで表示されます。 -> 実際に確認したい場合は `rubycli examples/fallback_example_with_extra_docs.rb scale --help` を試してみてください。 +コメントだけで実引数が増えることはありません。実装に存在しないオプション(例: `--ghost`)や +位置引数をコメントに書いた場合、その行はヘルプ末尾の詳細セクションに素のテキストとして +表示されるだけで、strict モードでは位置引数のズレに対する警告も出ます。動作するデモ: +`rubycli examples/fallback_example_with_extra_docs.rb scale --help` -コメントだけでは実装を拡張できません。メソッドシグネチャとコメントを一致させておくことで、ヘルプと挙動の整合性を保てます。 +開発中は `rubycli --check 対象.rb` でコメントと実装のズレ(未定義の型ラベルや列挙値の +誤記を含む。DidYouMean の候補付き)を検出し、実行時に `--strict` を付ければ仕様外の +入力を警告ではなくエラーにできます。 -### YARD 互換コメントを併用する際の注意点 - -- `**kwargs` を受け取るメソッドでも、Rubycli は個別のキーワードコメント(`--config-path ...` など)が無い限りヘルプへ露出させません。CLI で使わせたいキーはすべて `--LONG-NAME PLACEHOLDER [Type] 説明` の行として明示してください。 -- `@param` で位置引数を記述した場合も解析できますが、位置引数・キーワード引数を同じ行形式で列挙する必要があります。`@param source Path` のように書いても、キーワード向けのロングオプションが自動生成されるわけではありません。 -- `@param` の行に続く箇条書きや補足行は CLI の自動生成には使われません。補足情報を表示したい場合は、`--flag ...` 行の説明に含めるか、README など別のドキュメントで扱ってください。 -- `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` にすると `@param`/`@return` などのタグは警告扱いになります。プロジェクト内で簡潔記法へ統一するときはこの環境変数で段階的に移行できます。 +> `--strict` はコメントに書かれた型・許容値をそのまま信頼します。コメント自体の誤記は +> 実行時には検出できないため、CI で `rubycli --check` を回した上で `--strict` を +> 使ってください。 ## 引数解析モード ### 既定のリテラル解析 -Rubycli は `{` や `[`、クォート、YAML の先頭記号といった「構造化リテラルらしい」形の引数に対して `Psych.safe_load` を試み、成功すれば Ruby の配列/ハッシュ/真偽値に変換してからメソッドへ渡します。たとえば `--names='["Alice","Bob"]'` や `--config='{foo: 1}'` のような値は追加フラグ無しでネイティブな配列・ハッシュとして届きます。一方、プレーンな `1,2,3` のような文字列はこの段階ではそのまま維持されます(コメントで `String[]` や `TAG...` と宣言されている場合は後段で配列に整形されます)。扱えない形式は自動的に文字列へフォールバックするため、`"2024-01-01"` のような値もそのまま文字列で受け取れますし、構文が崩れていても CLI 全体が落ちることはありません。 - -### JSON モード - -CLI 実行時に `--json-args`(短縮形 `-j`)を付けると、後続の引数が厳格に JSON として解釈されます。 - -```bash -rubycli -j my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]' -``` +`{`、`[`、クォート、YAML 記号で始まる「構造化リテラルらしい」引数は `Psych.safe_load` で +解釈され、`--names='["Alice","Bob"]'` や `--config='{foo: 1}'` は追加フラグなしで +ネイティブな配列・ハッシュとして届きます。`1,2,3` のようなプレーンな文字列はこの段階では +そのまま維持され(コメントで `String[]` や `TAG...` と宣言されていれば後段で配列化)、 +解釈できない形式は元の文字列にフォールバックします。`"2024-01-01"` は文字列のまま届き、 +構文が崩れた入力でも実行全体は落ちません。 -YAML 固有の書き方は拒否され、無効な JSON であれば `JSON::ParserError` が発生するため、入力の妥当性を強く保証したいときに便利です。プログラム側では `Rubycli.with_json_mode(true) { … }` で有効化できます。 +### JSON モード(`--json-args` / `-j`) -### Eval モード +後続の引数を厳格に JSON として解釈します。YAML 固有の記法は拒否され、無効な JSON は +`JSON::ParserError` になるため、silent fallback ではなく明示的な失敗が欲しい場合に +便利です。プログラムからは `Rubycli.with_json_mode(true) { ... }` で切り替えられます。 -`--eval-args`(短縮形 `-e`)を使うと、後続の引数を Ruby コードとして評価した結果を CLI に渡せます。JSON や YAML では表現しづらいオブジェクトを扱いたいときに便利ですが、評価は `Object.new.instance_eval { binding }` 上で行われるため、信頼できる入力に限定してください。コード内では `Rubycli.with_eval_mode(true) { … }` で切り替えられます。 +### Eval モード(`--eval-args` / `-e`、`--eval-lax` / `-E`) -Ruby 評価はシンボルや配列/ハッシュもそのまま扱えるため、列挙値の組み合わせをオプションへ渡すときにも役立ちます。 +各引数を Ruby 式として評価してから渡します。シンボル配列・Range・インライン計算など、 +JSON では書きにくい値に便利です。 ```bash rubycli -E scripts/report_runner.rb publish \ @@ -458,56 +419,111 @@ rubycli -E scripts/report_runner.rb publish \ --channels '[:email, :slack]' ``` -Ruby 評価を使いつつ、構文エラーが出たときは元の文字列にフォールバックさせたい場合は `--eval-lax`(短縮形 `-E`)を指定します。`--eval-args` と同じく eval モードを有効にしますが、Ruby として解釈できなかったトークン(例: 素の `https://example.com`)は警告を出した上でそのまま渡すため、`60*60*24*14` のような式と文字列を気軽に混在させられます。 +評価は隔離された binding(`Object.new.instance_eval { binding }`)内で行われます。 +1回の Runner 実行では、`--new=VALUE` のコンストラクタ引数と選択したコマンドの引数を含む +すべての eval 引数が同じ binding を共有し、実行終了時に破棄されます。入力そのものは +信頼できる呼び出し元に限定してください。プログラムからは +`Rubycli.with_eval_mode(true) { ... }` で切り替えられます。 -`--json-args`/`-j` は `--eval-args`/`-e` および `--eval-lax`/`-E` と同時指定できません。どのモードも既定のリテラル解析を拡張する位置づけなので、用途に応じて厳格な JSON か Ruby eval(通常/lax)のいずれかを選択してください。 +`--eval-lax` / `-E` は `--eval-args` と同様に eval モードを有効にしつつ、Ruby として +解釈できなかったトークン(例: 素の `https://example.com`)は警告を出して元の文字列の +まま渡します。`60*60*24*14` のような式と通常の文字列を混在させたいときに便利です。 + +`--json-args` と eval 系フラグは同時指定できません(両方あるとエラーになります)。 ## Pre-script ブートストラップ -付属 CLI を起動するときに `--pre-script SRC`(別名: `--init`)を指定すると、公開メソッドを呼び出す前に任意の Ruby コードを評価できます。評価は隔離された binding 内で行われ、以下のローカル変数があらかじめ用意されています。 +`--pre-script SRC`(別名: `--init`)を付けると、コマンド解決の前に任意の Ruby コードを +評価できます。評価は隔離された binding 内で行われ、次のローカル変数が用意されています。 -- `target` – `--new` を適用する前のクラス/モジュール -- `current` / `instance` – 現在公開予定のオブジェクト(`--new` を指定した場合は生成済みインスタンス) +- `target` — 元のクラス/モジュール(`--new` 適用前) +- `current` / `instance` — そのまま公開される予定のオブジェクト -スクリプトの最後に評価された値が新しい公開対象になります。`nil` を返した場合は直前のオブジェクトを維持します。 +最後に評価された値が新しい公開対象になります(`nil` を返すと直前のオブジェクトを維持)。 +`SRC` にはインラインの Ruby コードとファイルパスのどちらも指定できます。 -インラインで書く例: +実行例 — `--new` で作られるインスタンスを、自分で組み立てたものに差し替える: ```bash -rubycli --pre-script 'InitArgRunner.new(source: "cli", retries: 2)' \ - lib/init_arg_runner.rb summarize --verbose +rubycli --new='["a"]' \ + --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' \ + examples/new_mode_runner.rb run --mode summary ``` -ファイルに切り出す例: +## フラグと環境変数 -```ruby -# scripts/bootstrap_runner.rb -instance = InitArgRunner.new(source: "preset") -instance.logger = Logger.new($stdout) -instance -``` +| フラグ / 環境変数 | 説明 | 既定値 | +| ---------------- | ---- | ------ | +| `--auto-target` / `-a`, `RUBYCLI_AUTO_TARGET=auto` | ファイル名と定数名が一致しないときに自動選択 | `strict` | +| `--new[=VALUE]` | コマンド解決前にインスタンス化。`VALUE` はコンストラクタ引数 | off | +| `--pre-script SRC` / `--init SRC` | 公開対象オブジェクトを Ruby コードで構築・差し替え | off | +| `--check` | コメントと実装のズレを検査(コマンドは実行しない) | off | +| `--strict` | ドキュメントの型・許容値を強制。仕様外入力はエラー | off | +| `--json-args` / `-j` | 引数を厳格に JSON として解釈 | off | +| `--eval-args` / `-e`, `--eval-lax` / `-E` | 引数を Ruby として評価(lax は失敗時に素の文字列へフォールバック) | off | +| `RUBYCLI_DEBUG=true` | デバッグログを表示 | `false` | +| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | YARD `@param` 行を無効化(互換性のため既定は有効) | `ON` | -```bash -rubycli --pre-script scripts/bootstrap_runner.rb \ - lib/init_arg_runner.rb summarize --verbose -``` +## ライブラリ API + +- `Rubycli.parse_arguments(argv, method)` — コメント情報を考慮した引数解析 +- `Rubycli.available_commands(target)` — 公開 CLI コマンド一覧 +- `Rubycli.usage_for_method(name, method)` — 指定メソッドのヘルプ生成 +- `Rubycli.method_description(method)` — 構造化されたドキュメント取得 + +## Python Fire との違い + +- **コメント対応のヘルプ生成** — コメントはヘルプを豊かにしますが、最終的な判断は常に + ライブなメソッド定義に基づきます。 +- **型に基づく解析** — プレースホルダ記法と YARD タグから、真偽値・配列・数値などへ + 追加コードなしで変換します。 +- **二段構えの検証** — `--check` はコマンドを実行せずにドキュメントのズレを lint し、 + `--strict` はドキュメントの型・許容値を実行時の契約として強制します。 +- **Ruby 中心の設計** — キーワード引数、ブロックドキュメント(`@yield*` タグ)、 + `RUBYCLI_*` 環境変数に対応します。 + +| 機能 | Python Fire | Rubycli | +| ---- | ----------- | ------- | +| 属性の辿り方 | プロパティ/属性を再帰的に自動公開 | 対象の公開メソッドのみ公開(暗黙の辿りなし) | +| クラス初期化 | `__init__` 引数を自動で受け取る | `--new` 指定時のみ初期化。引数は `--new=VALUE`、複雑な構築は pre-script | +| インタラクティブシェル | コマンド未指定時に Fire REPL | なし。コマンド実行専用 | +| 情報源 | 純粋なリフレクション | ライブなメソッド定義 + コメントをヘルプへ反映 | +| 辞書/配列 | dict/list を自動でサブコマンド化 | クラス/モジュールのメソッドに特化(自動展開なし) | + +## 開発方針 + +- **便利さを最優先** — 既存の Ruby スクリプトを最小の手間で CLI 化することが目的です。 + Python Fire との機能一致は目標ではなく、Fire 由来の未実装機能は基本的に仕様です。 +- **メソッド定義が土台、コメントが補強** — 公開範囲と必須/任意はシグネチャが決め、 + コメントは型・ヘルプ・検証を補強します。 +- **軽量メンテナンス** — 実装の多くは AI 支援で作られており、深い Ruby メタプログラミングに + 踏み込む拡張は想定外です。互換性追求の PR は事前にご相談ください。 -この仕組みを使えば、`--new` のシンプルさを保ったまま、DI 風の初期化やラッパーオブジェクトの準備といった高度な前処理を CLI で行えます。 +## 同梱サンプル -## 環境変数とフラグ +- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb` — 最小のモジュール関数、 + ドキュメントなし/あり +- `examples/hello_app_with_require.rb` — `Rubycli.run` の組み込み +- `examples/typed_arguments_demo.rb` — 標準ライブラリ型の変換 + (Date/Time/BigDecimal/Pathname) +- `examples/strict_choices_demo.rb` — リテラル列挙と `--strict` +- `examples/new_mode_runner.rb` — `--new=VALUE` で初期化するインスタンス専用クラス +- `examples/documentation_style_showcase.rb` — 全コメント記法のショーケース +- `examples/fallback_example.rb` / `examples/fallback_example_with_extra_docs.rb` + — シグネチャからの補完とコメント不一致のデモ -| 変数 / フラグ | 説明 | 既定値 | -| ------------- | ---- | ------ | -| `RUBYCLI_DEBUG=true` | デバッグログ表示 | `false` | -| `--check` | コメント/実装のズレを検査し、コマンドは実行しない | `off` | -| `--strict` | ドキュメントで許可した型・値以外をエラーとして拒否 | `off` | -| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | レガシーな `@param` 記法を無効化(互換性のため既定では ON) | `ON` | +## 開発時の検証 + +全テストを実行し、リポジトリのカバレッジ基準 +(全体 line 90%、branch 70%、`origin/main` から変更した実行可能行 90%) +を検査するには次を実行します。 + +```bash +ruby -Ilib:test test/coverage_runner.rb +``` -## Rubycli API +## ライセンス -- `Rubycli.parse_arguments(argv, method)` – コメント情報を考慮した引数解析 -- `Rubycli.available_commands(target)` – 公開 CLI コマンド一覧 -- `Rubycli.usage_for_method(name, method)` – 指定メソッドのヘルプ生成 -- `Rubycli.method_description(method)` – 構造化されたドキュメント取得 +MIT。[LICENSE](LICENSE) を参照してください。 -ご意見・フィードバックは Issue や Pull Request でお寄せください。 +ご意見・不具合報告は Issue や Pull Request でお寄せください。 diff --git a/README.md b/README.md index 47fcb52..cc76867 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,39 @@ ![Rubycli logo](assets/rubycli-logo.png) -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). +[![Gem Version](https://img.shields.io/gem/v/rubycli)](https://rubygems.org/gems/rubycli) -> 🇯🇵 Japanese documentation is available in [README.ja.md](README.ja.md). +Rubycli turns existing Ruby classes and modules into command-line interfaces. +It inspects public method definitions and the doc comments attached to them, so +in the simplest case your script needs no changes at all — not even +`require "rubycli"`. Type annotations in comments are not just documentation: +they drive how CLI arguments are parsed (for example, `TAG... [String[]]` +forces array parsing). + +Rubycli is inspired by [Python Fire](https://github.com/google/python-fire) but +is not a port or an official project; the focus is Ruby's documentation +conventions and type annotations. + +> 🇯🇵 Japanese documentation: [README.ja.md](README.ja.md) ![Rubycli demo showing generated commands and invocation](assets/rubycli-demo.gif) -### 1. Existing Ruby script (Rubycli unaware) +## Installation + +```bash +gem install rubycli +``` + +```ruby +# Gemfile +gem "rubycli" +``` + +Requires Ruby 3.0 or later. Licensed under [MIT](LICENSE). + +## Quick start + +### 1. Run an existing script as-is ```ruby # hello_app.rb @@ -21,7 +47,8 @@ module HelloApp end ``` -> Try it yourself: this repository ships with `examples/hello_app.rb`, so from the project root you can run `rubycli examples/hello_app.rb` to explore the generated commands. +This repository ships the same file as `examples/hello_app.rb`, so you can try +everything below from the project root. ```bash rubycli examples/hello_app.rb @@ -32,11 +59,13 @@ Usage: hello_app.rb COMMAND [arguments] Available commands: Class methods: - greet + greet NAME Detailed command help: hello_app.rb COMMAND help ``` +Missing arguments produce a usage message instead of a stack trace: + ```bash rubycli examples/hello_app.rb greet ``` @@ -54,16 +83,16 @@ rubycli examples/hello_app.rb greet Hanako #=> Hello, Hanako! ``` -Running `rubycli examples/hello_app.rb --help` prints the same summary as invoking it without a command. - -### 2. Add documentation hints for richer flags +`rubycli examples/hello_app.rb --help` prints the same summary as invoking it +without a command. -> Still no `require "rubycli"` needed; comments alone drive option parsing and help text. +### 2. Add doc comments for typed options -**Concise placeholder style** +Still no `require "rubycli"` needed; comments alone drive option parsing and +help text. Both the concise placeholder style and YARD-style tags work: ```ruby -# hello_app.rb +# Concise placeholder style module HelloApp module_function @@ -77,10 +106,8 @@ module HelloApp end ``` -**YARD-style tags work too** - ```ruby -# hello_app.rb +# YARD-style tags module HelloApp module_function @@ -94,24 +121,13 @@ module HelloApp end ``` -> The documented variant lives at `examples/hello_app_with_docs.rb` if you want to follow along locally. - -```bash -rubycli examples/hello_app_with_docs.rb -``` - -```text -Usage: hello_app_with_docs.rb COMMAND [arguments] - -Available commands: - Class methods: - greet [--shout] - -Detailed command help: hello_app_with_docs.rb COMMAND help -``` +The documented variant lives at `examples/hello_app_with_docs.rb`. Its file +name does not match the constant it defines (`HelloApp`), so pass +`--auto-target` / `-a` or name the constant explicitly — see +[Target constant resolution](#target-constant-resolution) below. ```bash -rubycli examples/hello_app_with_docs.rb greet --help +rubycli -a examples/hello_app_with_docs.rb greet --help ``` ```text @@ -125,11 +141,11 @@ Options: ``` ```bash -rubycli examples/hello_app_with_docs.rb greet --shout Hanako +rubycli -a examples/hello_app_with_docs.rb greet --shout Hanako #=> HELLO, HANAKO! ``` -Need to keep a helper off the CLI? Define it as `private` on the singleton class: +To keep a helper off the CLI, define it as `private` on the singleton class: ```ruby module HelloApp @@ -143,95 +159,10 @@ module HelloApp end ``` -### 3. (Optional) Embed the runner inside your script - -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`). - -```bash -ruby examples/hello_app_with_require.rb greet Hanako --shout -#=> HELLO, HANAKO! -``` - -## Constant resolution modes - -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: - -| Mode | How to enable | Behaviour | -| --- | --- | --- | -| `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. | -| `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. | - -This keeps large projects safe by default but still provides a one-flag escape hatch when you prefer the fully automatic behaviour. - -> **Instance-only classes** – If a class only defines public *instance* methods (for example, it exposes functionality via `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`. Passing `--new` also makes those instance methods appear in `rubycli --help` output and allows `rubycli --check --new` to lint their documentation. When your constructor needs arguments, pass them inline with `--new=VALUE` (safe YAML/JSON-like parsing by default; `--json-args` for strict JSON, `--eval-args` / `--eval-lax` for Ruby literals). Any comments on `initialize` are respected for type coercion just like regular CLI methods. - -> Hint: Single values should be passed as `--new=value` so they aren’t mistaken for the next path/command. Space-separated single tokens like `--new 1` may be treated as the following path unless they look obviously structured. - -## Project Philosophy - -- **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. -- **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.” -- **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. Rubycli also auto-parses inputs that look like JSON/YAML literals (for example `--names='["Alice","Bob"]'`) before enforcing the documented type. Run `rubycli --check path/to/script.rb` to lint documentation mismatches—including undefined type labels or enumerated values, with DidYouMean suggestions for `Booalean`-style typos—and pass `--strict` during normal runs when you want invalid input to abort instead of merely warning. -- **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. - -## Features - -- Comment-aware CLI generation with both YARD-style tags and concise placeholders -- Automatic option signature inference (`NAME [Type] Description…`) without extra DSLs -- Safe literal parsing out of the box (arrays / hashes / booleans) with opt-in strict JSON and Ruby eval modes -- Optional pre-script hook (`--pre-script` / `--init`) to evaluate Ruby and expose the resulting object -- Dedicated CLI flags for quality gates: `--check` lints documentation/comments without running commands, and `--strict` treats documented types/choices as hard requirements -- Example `examples/new_mode_runner.rb` demonstrates instance-only classes with `--new=VALUE` constructor arguments, eval/JSON modes, and a pre-script initialization pattern. - -### Examples - -- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb`: minimal module-function variants, with and without docs -- `examples/typed_arguments_demo.rb`: stdlib type coercions (Date/Time/BigDecimal/Pathname) -- `examples/strict_choices_demo.rb`: literal enumerations and `--strict` -- `examples/new_mode_runner.rb`: instance-only class initialized via `--new=VALUE` with eval/JSON/pre-script combinations - -> Tip: `--strict` trusts whatever types/choices your comments spell out—if the annotations are misspelled, runtime enforcement has nothing reliable to compare against. Keep `rubycli --check` in CI so documentation typos are caught before production runs that rely on `--strict`. - -## How it differs from Python Fire - -- **Comment-aware help** – Rubycli leans on doc comments when present but still reflects the live method signature, keeping code as the ultimate authority. -- **Type-aware parsing** – Placeholder syntax (`NAME [String]`) and YARD tags let Rubycli coerce arguments to booleans, arrays, numerics, etc. without additional code. -- **Strict validation** – `rubycli --check` lint runs catch documentation drift (including undefined type labels or enumerated values) without executing commands, while runtime `--strict` runs turn those documented types/choices into enforceable contracts. -- **Ruby-centric tooling** – Supports Ruby-specific conventions such as optional keyword arguments, block documentation (`@yield*` tags), and `RUBYCLI_*` environment toggles. - -| Capability | Python Fire | Rubycli | -| ---------- | ----------- | -------- | -| Attribute traversal | Recursively exposes attributes/properties on demand | Exposes public methods defined on the target; no implicit traversal | -| Constructor handling | Automatically prompts for `__init__` args when instantiating classes | `--new` instantiates and accepts constructor arguments via `--new=VALUE` (safe YAML/JSON-like parsing by default; `--json-args` for strict JSON, `--eval-args` / `--eval-lax` for Ruby literals). Use pre-scripts or your own factories for more complex wiring. | -| Interactive shell | Offers Fire-specific REPL when invoked without command | No interactive shell mode; strictly command execution | -| Input discovery | Pure reflection, no doc comments required | Doc comments drive option names, placeholders, and validation | -| Data structures | Dictionaries / lists become subcommands by default | Focused on class or module methods; no automatic dict/list expansion | - -#### Example commands - -- `rubycli examples/new_mode_runner.rb run --new='["a","b","c"]' --mode reverse` -- `rubycli --json-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{"source":"json"}'` -- `rubycli --eval-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{tags: [:a, :b]}'` -- `rubycli --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' examples/new_mode_runner.rb run --mode summary` - -## Installation - -Rubycli is published on RubyGems. - -```bash -gem install rubycli -``` - -Bundler example: +### 3. Optional: embed the runner in your script -```ruby -# Gemfile -gem "rubycli" -``` - -## Quick start (embed Rubycli in the script) - -Step 3 adds `require "rubycli"` so the script can invoke the CLI directly (see `examples/hello_app_with_require.rb`): +If you prefer launching via plain `ruby`, require the gem and delegate to +`Rubycli.run` (shipped as `examples/hello_app_with_require.rb`): ```ruby # hello_app_with_require.rb @@ -254,131 +185,183 @@ end Rubycli.run(HelloApp) ``` -Run it: - ```bash -ruby examples/hello_app_with_require.rb greet Taro -#=> Hello, Taro! - ruby examples/hello_app_with_require.rb greet Taro --shout #=> HELLO, TARO! ``` -To launch the same file without adding `require "rubycli"`, use the bundled executable: +When you run a file through the bundled `rubycli` executable instead, return +values are printed automatically. -```bash -rubycli path/to/hello_app.rb greet --shout Hanako -``` +## Target constant resolution + +Rubycli assumes that the file name (CamelCased) matches the class or module you +want to expose. When it does not, choose how eagerly Rubycli should pick a +constant: -When you omit `CLASS_OR_MODULE`, Rubycli now infers it from the file name and even locates nested constants such as `Module1::Inner::Runner`. Return values are printed by default when you run the bundled CLI. +| Mode | How to enable | Behaviour | +| --- | --- | --- | +| `strict` (default) | nothing / `RUBYCLI_AUTO_TARGET=strict` | Fails unless the CamelCase name matches. The error lists the detected constants and shows how to rerun. | +| `auto` | `--auto-target` / `-a` / `RUBYCLI_AUTO_TARGET=auto` | If exactly one constant in the file defines CLI-callable methods, it is selected automatically. | -Need to target a different constant explicitly? Provide it after the file path: +You can always name the constant explicitly after the file path — useful when a +file defines several candidates or a nested constant: ```bash rubycli scripts/multi_runner.rb Admin::Runner list --active ``` -This is useful when a file defines multiple candidates or when you want a nested constant that does not match the file name. +Nested constants such as `Module1::Inner::Runner` are found as well. + +## Instance-only classes and `--new` + +If a class only defines public *instance* methods, run Rubycli with `--new` so +the class is instantiated before commands are resolved; otherwise Rubycli sees +no CLI-callable methods. + +- `--new` also makes instance methods appear in `--help` output, and lets + `rubycli --check --new` lint their documentation. +- When the constructor needs arguments, pass them with `--new=VALUE` **before + the file path**. Values are parsed as safe YAML/JSON-like literals, and + comments on `initialize` drive type coercion just like regular CLI methods. +- Prefer the `--new=VALUE` form over a space-separated `--new VALUE`, so the + value is not mistaken for the file path. + +Example (`examples/new_mode_runner.rb`): + +```bash +rubycli --new='["a","b","c"]' examples/new_mode_runner.rb run --mode reverse +#=> ["c", "b", "a"] +``` ## Comment syntax -Rubycli parses a hybrid format – you can stick to familiar YARD tags or use short forms. +Rubycli parses a hybrid format — familiar YARD tags or short forms: | Purpose | YARD-compatible | Rubycli style | | ------- | --------------- | ------------- | | Positional argument | `@param name [Type] Description` | `NAME [Type] Description` | -| Keyword option | Same as above | `--flag -f VALUE [Type] Description` | +| Keyword option | same | `--flag -f VALUE [Type] Description` | | Return value | `@return [Type] Description` | `=> [Type] Description` | -Short options are optional and order-independent, so the following examples are equivalent in Rubycli’s default style: +Short options are optional and order-independent; these are equivalent: - `--flag -f VALUE [Type] Description` - `--flag VALUE [Type] Description` - `-f --flag VALUE [Type] Description` -Our examples keep the classic uppercase placeholders (`NAME`, `VALUE`) as the canonical style; the variations below are optional sugar. +Types can be written as `[String]` or `(String)`, and unions as +`(String, nil)`. ### Alternate placeholder notations -Rubycli also understands these syntaxes when parsing comments and rendering help: - -- Angle brackets for user input: `--flag ` or `NAME []` -- Inline equals for long options: `--flag=` -- Trailing ellipsis for repeated values: `VALUE...` or `...` +These are understood both when parsing comments and when rendering help: -The CLI treats `--flag VALUE`, `--flag `, and `--flag=` 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. +- Angle brackets: `--flag `, `NAME []` +- Inline equals: `--flag=` +- Trailing ellipsis for repeated values: `VALUE...`, `...` -> 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. +At runtime `--flag VALUE`, `--flag `, and `--flag=` are +identical — document with whichever variant your team prefers. You do not need +to bracket optional arguments yourself: Rubycli already knows which parameters +are optional from the Ruby signature and adds the brackets in generated help. -You can annotate types using `[String]` or `(String)`—they both convey the same hint, and you can list multiple types such as `(String, nil)`. +Inference rules when annotations are partial: -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`). 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. Strict mode still verifies each element against the documented type, so `--tags [1,2]` will fail when the docs say `[String[]]`. +- A bare placeholder such as `ARG1` (no type) is treated as `String`. +- An option with no value placeholder (`--verbose`) becomes a Boolean flag. +- Positional arguments only become booleans with an explicit `[Boolean]`; + a bare `NAME Description` falls back to `String` regardless of the Ruby + default value. -Need to pass structures that are awkward to express as JSON (for example symbol arrays or hashes)? Enable eval mode (`--eval-args`/`-e` or `--eval-lax`/`-E`) and supply a Ruby literal that matches the documented type; the example in the eval section below shows how to pass multiple enum selections safely even though space-separated syntax remains unsupported. +### Arrays and repeated values -Common inference rules: - -- Writing a placeholder such as `ARG1` (without `[String]`) makes Rubycli treat it as a `String`. -- Using that placeholder in an option line (`--name ARG1`) also infers a `String`. -- Omitting the placeholder entirely (`--verbose`) produces a Boolean flag. -- Positional arguments only become booleans when you annotate `[Boolean]`; a bare `NAME Description` (or `@param name Description`) falls back to `String`, regardless of the Ruby default value. +Options documented with an ellipsis (`TAG...`) or an array type +(`[String[]]`, `Array`) are parsed as arrays. Both JSON/YAML list +syntax (`--tags '["build","test"]'`) and comma-delimited strings +(`--tags "build,test"`) are accepted. Space-separated multi-value flags +(`--tags build test`) are not supported, and options without a repeated/array +hint stay scalars. `--strict` verifies each element against the documented +type, so `--tags [1,2]` fails when the docs say `[String[]]`. Quoted elements +remain strings even when their contents look like other literals, such as +`--tags '["true","null"]'`. ### Literal choices and enums -You can express a finite set of accepted values directly inside the type annotation, for example `--format MODE [:json, :yaml, :auto]` or `LEVEL [:info, :warn]`. Symbols, strings (including barewords), booleans, numbers, and `nil` are supported, and you can mix literal entries with broader types such as `--channel TARGET [:stdout, :stderr, Boolean]`. `%i[info warn]` / `%w[debug info]` short-hands expand as expected, so `LEVEL %i[info warn]` works the same as the explicit array form. Rubycli always records these choices in the generated help; when you run with `--strict`, any value outside the documented set results in `Rubycli::ArgumentError`, otherwise a warning is printed and execution proceeds. - -> Symbols and strings are compared strictly. `[:info, :warn]` requires symbol inputs such as `:info`, while `["info", "warn"]` only accepts plain strings. Prefix a value with `:` at the CLI to pass a symbol. +A finite set of accepted values can be written directly inside the type +annotation: `--format MODE [:json, :yaml, :auto]` or `LEVEL [:info, :warn]`. +Symbols, strings (including barewords), booleans, numbers, and `nil` are +supported; literals can be mixed with broader types +(`--channel TARGET [:stdout, :stderr, Boolean]`), and `%i[info warn]` / +`%w[debug info]` shorthands expand as expected. The choices always appear in +generated help; without `--strict` an out-of-range value only prints a warning, +with `--strict` it aborts. -> Literal enums currently apply to each scalar argument. If an option is documented as an array (for example `[Symbol[]]`), spell out the allowed members in prose for now—combined literal arrays such as `[%i[foo bar][]]` are not supported. +Symbols and strings are compared strictly: `[:info, :warn]` requires symbol +input such as `:info` (prefix the value with `:` at the CLI), while +`["info", "warn"]` only accepts plain strings. ```bash -# literal choices + booleans (see examples/strict_choices_demo.rb) -ruby examples/strict_choices_demo.rb report warn --format json -#=> [WARN] format=json - -# the same command with --strict will abort when values drift -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report debug -#=> Rubycli::ArgumentError: Value "debug" for LEVEL is not allowed: allowed values are :info, :warn, :error -``` +# see examples/strict_choices_demo.rb — LEVEL is documented as [:info, :warn, :error] +rubycli examples/strict_choices_demo.rb report :warn --format json +#=> [WARN] format=json (followed by the returned hash) -```bash -# symbol values stay distinct -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report :warn -#=> [WARN] format=text +# a plain string is not the documented symbol: warn and continue +rubycli examples/strict_choices_demo.rb report warn +#=> [WARN] LEVEL must be one of :info, :warn, :error (received "warn") (use --strict to abort on invalid input) -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report warn -#=> Rubycli::ArgumentError: Value "warn" for LEVEL is not allowed: allowed values are :info, :warn, :error +# with --strict, out-of-range input aborts +rubycli --strict examples/strict_choices_demo.rb report debug +#=> [ERROR] LEVEL must be one of :info, :warn, :error (received "debug") ``` +Literal enums currently apply to each scalar argument; combined literal arrays +such as `[%i[foo bar][]]` are not supported. + ### Standard library type hints -Doc comments can reference standard classes such as `Date`, `Time`, `BigDecimal`, or `Pathname`. Rubycli loads the necessary stdlib files on demand and coerces CLI inputs using the documented types. +Doc comments can reference standard classes such as `Date`, `Time`, +`BigDecimal`, or `Pathname`. Rubycli loads the required stdlib on demand and +coerces CLI inputs, so the handler receives real objects without manual +parsing: ```bash # see examples/typed_arguments_demo.rb -ruby examples/typed_arguments_demo.rb ingest \ +rubycli examples/typed_arguments_demo.rb ingest \ --date 2024-12-25 \ --moment 2024-12-25T10:00:00Z \ --budget 123.45 \ --input ./data/input.csv ``` -This command prints a normalized summary and the handler receives real `Date`, `Time`, `BigDecimal`, and `Pathname` objects without manual parsing. +Every option there has a default, so you can also experiment one at a time +(`... ingest --budget 999.99`). -Each option has a sensible default, so you can also experiment one at a time (for example `ruby examples/typed_arguments_demo.rb ingest --budget 999.99`). +Other YARD tags such as `@example`, `@raise`, `@see`, and `@deprecated` are +currently ignored by the help renderer. -Other YARD tags such as `@example`, `@raise`, `@see`, and `@deprecated` are currently ignored by the CLI renderer. +> To explore every notation in one script, try +> `rubycli examples/documentation_style_showcase.rb canonical --help` and the +> other showcase commands. -> 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. +### Notes on YARD-style comments -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). +- Methods that accept `**kwargs` do not expose those keys automatically; every + key you want on the CLI needs its own `--long-name PLACEHOLDER [Type] ...` + line. +- Bullet lists or free-form lines following a `@param` line are not used for + CLI generation; put supplementary text in the option's description instead. +- To enforce the concise placeholder syntax exclusively, set + `RUBYCLI_ALLOW_PARAM_COMMENT=OFF`; `@param`/`@return` tags then produce + warnings, which helps a gradual migration. ### When docs are missing or incomplete -Rubycli always trusts the live method signature. If a parameter (or option) is undocumented, the CLI still exposes it using the parameter name and default values inferred from the method definition: +Rubycli always trusts the live method signature. Undocumented parameters are +still exposed, with names, defaults, and types inferred from the definition: ```ruby -# fallback_example.rb +# examples/fallback_example.rb module FallbackExample module_function @@ -392,20 +375,6 @@ module FallbackExample end ``` -```bash -rubycli examples/fallback_example.rb -``` - -```text -Usage: fallback_example.rb COMMAND [arguments] - -Available commands: - Class methods: - scale AMOUNT [] [--clamp=] [--notify] - -Detailed command help: fallback_example.rb COMMAND help -``` - ```bash rubycli examples/fallback_example.rb scale --help ``` @@ -415,50 +384,56 @@ Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=] [--notify] Positional arguments: AMOUNT [Integer] required Base amount to process - FACTOR optional (default: 2) + FACTOR [String] optional (default: 2) Options: --clamp= [String] optional (default: nil) --notify [Boolean] optional (default: false) ``` -Here only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are still presented with sensible defaults and inferred types. Run `rubycli --check path/to/script.rb` during development to surface mismatches between comments and signatures, and pass `--strict` when executing commands to enforce the documented types/choices. +Only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are presented +with inferred defaults and types. -#### What if the docs mention arguments that do not exist? +Comments never add live parameters by themselves. Lines that reference +non-existent options (say `--ghost`) or positionals are shown verbatim in the +help's detail section instead of becoming real arguments, and strict mode warns +about positional mismatches. For a runnable mismatch demo: +`rubycli examples/fallback_example_with_extra_docs.rb scale --help`. -- **Out-of-sync lines fall back to plain text** – Comments that reference non-existent options (for example `--ghost`) or positionals (such as `EXTRA`) are emitted verbatim in the help’s detail section. They do not materialize as real arguments, and strict mode still warns about positional mismatches (`Extra positional argument comments were found: EXTRA`) so you can reconcile the docs. +Run `rubycli --check path/to/script.rb` during development to lint +documentation drift — including undefined type labels and enum typos, with +DidYouMean suggestions — and pass `--strict` at runtime when invalid input +should abort instead of merely warning. -> Want to see this behaviour? Try `rubycli examples/fallback_example_with_extra_docs.rb scale --help` for a runnable mismatch demo. - -In short, comments never add live parameters by themselves; they enrich or describe what your method already supports. +> `--strict` trusts whatever types/choices your comments spell out. Keep +> `rubycli --check` in CI so documentation typos are caught before production +> runs that rely on `--strict`. ## Argument parsing modes ### Default literal parsing -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. - -### JSON mode - -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: - -```bash -rubycli -j my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]' -``` - -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) { … }`. - -## Eval mode +Arguments that look like structured literals (starting with `{`, `[`, quotes, +or YAML markers) are parsed with `Psych.safe_load`, so +`--names='["Alice","Bob"]'` or `--config='{foo: 1}'` arrive as native arrays +and hashes without extra flags. Plain strings like `1,2,3` stay untouched at +this stage (a later pass normalises them into arrays when the docs declare +`String[]` or `TAG...`), 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. -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: +### JSON mode (`--json-args` / `-j`) -```bash -rubycli -e scripts/data_cli.rb DataCLI run '(1..10).to_a' -``` +Parses subsequent arguments strictly as JSON. YAML-only syntax is rejected and +invalid payloads raise `JSON::ParserError` — for callers who want explicit +failures instead of silent fallbacks. Programmatic equivalent: +`Rubycli.with_json_mode(true) { ... }`. -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) { … }`. +### Eval mode (`--eval-args` / `-e`, `--eval-lax` / `-E`) -Because Ruby evaluation understands symbols, arrays, and hashes, it’s a convenient way to pass literal enum combinations to options that expect arrays: +Evaluates each argument as a Ruby expression before it is forwarded, which is +handy for objects that are awkward as JSON — symbol arrays, ranges, inline +math: ```bash rubycli -E scripts/report_runner.rb publish \ @@ -466,56 +441,120 @@ rubycli -E scripts/report_runner.rb publish \ --channels '[:email, :slack]' ``` -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. +Evaluation happens inside an isolated binding +(`Object.new.instance_eval { binding }`). All eval arguments in one Runner +execution, including `--new=VALUE` constructor arguments and the selected +command's arguments, share that binding; it is discarded after the execution. +Treat this as unsafe input: do not enable it for untrusted callers. +Programmatic equivalent: `Rubycli.with_eval_mode(true) { ... }`. -`--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. +`--eval-lax` / `-E` behaves like `--eval-args`, but tokens that fail to parse +as Ruby (for example a bare `https://example.com`) produce a warning and are +forwarded as the original string — convenient for mixing expressions like +`60*60*24*14` with plain values. + +`--json-args` cannot be combined with either eval variant; Rubycli raises an +error if both are present. ## Pre-script bootstrap -Add `--pre-script SRC` (alias: `--init`) when launching the bundled CLI to run arbitrary Ruby code before exposing methods. The code runs inside an isolated binding where the following locals are pre-populated: +`--pre-script SRC` (alias: `--init`) runs arbitrary Ruby before commands are +resolved, inside an isolated binding with these locals pre-populated: -- `target` – the original class or module (before `--new` instantiation) -- `current` / `instance` – the object that would otherwise be exposed (after `--new` if specified) +- `target` — the original class or module (before `--new` instantiation) +- `current` / `instance` — the object that would otherwise be exposed -The last evaluated value becomes the new public target. Returning `nil` keeps the previous object. +The last evaluated value becomes the new public target (`nil` keeps the +previous object). `SRC` can be inline Ruby or a file path. -Inline example: +Example — replace the `--new`-built instance with a hand-built one: ```bash -rubycli --pre-script 'InitArgRunner.new(source: "cli", retries: 2)' \ - lib/init_arg_runner.rb summarize --verbose +rubycli --new='["a"]' \ + --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' \ + examples/new_mode_runner.rb run --mode summary ``` -File example: +## Flags and environment variables -```bash -# scripts/bootstrap_runner.rb -instance = InitArgRunner.new(source: "preset") -instance.logger = Logger.new($stdout) -instance -``` +| Flag / Env | Description | Default | +| ---------- | ----------- | ------- | +| `--auto-target` / `-a`, `RUBYCLI_AUTO_TARGET=auto` | Auto-select the target constant when the file name does not match | `strict` | +| `--new[=VALUE]` | Instantiate the class before resolving commands; `VALUE` feeds the constructor | off | +| `--pre-script SRC` / `--init SRC` | Run Ruby code to build/replace the exposed object | off | +| `--check` | Lint documentation/comments without executing commands | off | +| `--strict` | Enforce documented types/choices; invalid input aborts | off | +| `--json-args` / `-j` | Parse arguments strictly as JSON | off | +| `--eval-args` / `-e`, `--eval-lax` / `-E` | Evaluate arguments as Ruby (lax: fall back to the raw string) | off | +| `RUBYCLI_DEBUG=true` | Print debug logs | `false` | +| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | Disable YARD `@param` lines (on by default for compatibility) | `ON` | -```bash -rubycli --pre-script scripts/bootstrap_runner.rb \ - lib/init_arg_runner.rb summarize --verbose -``` +## Library helpers + +- `Rubycli.parse_arguments(argv, method)` — parse argv with comment metadata +- `Rubycli.available_commands(target)` — list CLI-exposable methods +- `Rubycli.usage_for_method(name, method)` — render usage for a single method +- `Rubycli.method_description(method)` — fetch structured documentation info -This keeps `--new` available for quick zero-argument instantiation while allowing richer bootstrapping when needed. +## How it differs from Python Fire -## Environment variables & flags +- **Comment-aware help** — doc comments enrich the help, but the live method + signature stays the ultimate authority. +- **Type-aware parsing** — placeholder syntax and YARD tags coerce arguments to + booleans, arrays, numerics, and more without additional code. +- **Two-stage validation** — `--check` lints documentation drift without + executing commands; `--strict` turns documented types/choices into + enforceable runtime contracts. +- **Ruby-centric** — keyword arguments, block documentation (`@yield*` tags), + and `RUBYCLI_*` environment toggles. -| Flag / Env | Description | Default | +| Capability | Python Fire | Rubycli | | ---------- | ----------- | ------- | -| `RUBYCLI_DEBUG=true` | Print debug logs | `false` | -| `--check` | Validate documentation/comments without executing commands | `off` | -| `--strict` | Enforce documented choices/types; invalid input aborts | `off` | -| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | Disable legacy `@param` lines (defaults to on today for compatibility) | `ON` | +| Attribute traversal | Recursively exposes attributes/properties | Exposes public methods on the target; no implicit traversal | +| Constructor handling | Prompts for `__init__` args automatically | `--new` instantiates; constructor args via `--new=VALUE`, richer wiring via pre-scripts | +| Interactive shell | Fire-specific REPL when invoked without a command | No interactive shell; strictly command execution | +| Input discovery | Pure reflection, no doc comments | Doc comments drive option names, placeholders, and validation | +| Data structures | Dicts/lists become subcommands | Class/module methods only; no automatic dict/list expansion | + +## Project philosophy + +- **Convenience first** — wrap existing Ruby scripts with almost no manual + plumbing. Fidelity with Python Fire is not a goal; missing Fire features are + generally by design. +- **Method definitions first, comments augment** — signatures determine what is + exposed and what is required; comments refine types, help text, and + validation. +- **Lightweight maintenance** — much of the implementation was generated with + AI assistance; contributions that dive into deep Ruby metaprogramming are out + of scope. Please discuss expectations before opening parity PRs. + +## Bundled examples + +- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb` — minimal + module-function variants, without and with docs +- `examples/hello_app_with_require.rb` — embedded `Rubycli.run` +- `examples/typed_arguments_demo.rb` — stdlib type coercions + (Date/Time/BigDecimal/Pathname) +- `examples/strict_choices_demo.rb` — literal enumerations and `--strict` +- `examples/new_mode_runner.rb` — instance-only class initialized via + `--new=VALUE` +- `examples/documentation_style_showcase.rb` — every comment notation in one + script +- `examples/fallback_example.rb` / `examples/fallback_example_with_extra_docs.rb` + — signature fallback and doc-mismatch demos + +## Development verification + +Run the full test suite and enforce the repository's coverage thresholds +(90% overall line coverage, 70% branch coverage, and 90% coverage of +executable lines changed from `origin/main`) with: -## Library helpers +```bash +ruby -Ilib:test test/coverage_runner.rb +``` + +## License -- `Rubycli.parse_arguments(argv, method)` – parse argv with comment metadata -- `Rubycli.available_commands(target)` – list CLI exposable methods -- `Rubycli.usage_for_method(name, method)` – render usage for a single method -- `Rubycli.method_description(method)` – fetch structured documentation info +MIT. See [LICENSE](LICENSE). -Feedback and issues are welcome while we prepare the public release. +Feedback and issues are welcome. diff --git a/lib/rubycli.rb b/lib/rubycli.rb index a8b789c..59e6ecd 100644 --- a/lib/rubycli.rb +++ b/lib/rubycli.rb @@ -221,24 +221,33 @@ def execute( raise Error, '--json-args cannot be combined with --eval-args or --eval-lax' end - runner_target, full_path = prepare_runner_target( - target_path, - class_name, - new: new, - new_args: new_args, - json_mode: json, - eval_mode: eval_args, - eval_lax: eval_lax, - pre_scripts: pre_scripts, - constant_mode: constant_mode - ) - - original_program_name = $PROGRAM_NAME + original_program_name = nil original_argv = nil - $PROGRAM_NAME = File.basename(full_path) - original_argv = ARGV.dup - ARGV.replace(Array(cli_args).dup) - run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax) + execution = proc do + runner_target, full_path = prepare_runner_target( + target_path, + class_name, + new: new, + new_args: new_args, + json_mode: json, + eval_mode: eval_args, + eval_lax: eval_lax, + pre_scripts: pre_scripts, + constant_mode: constant_mode + ) + + original_program_name = $PROGRAM_NAME + $PROGRAM_NAME = File.basename(full_path) + original_argv = ARGV.dup + ARGV.replace(Array(cli_args).dup) + run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax) + end + + if eval_args + Rubycli.eval_coercer.with_eval_binding(&execution) + else + execution.call + end ensure $PROGRAM_NAME = original_program_name if original_program_name ARGV.replace(original_argv) if original_argv @@ -255,35 +264,27 @@ def check( eval_mode: false, eval_lax: false ) - raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty? previous_doc_check = Rubycli.environment.doc_check_mode? + raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty? + unless Array(pre_scripts).empty? + raise Error, '--check cannot be combined with --pre-script or --init' + end + Rubycli.environment.clear_documentation_issues! Rubycli.environment.enable_doc_check! - runner_target, full_path = prepare_runner_target( + runner_target, full_path = resolve_runner_target( target_path, class_name, - new: new, - new_args: new_args, - json_mode: json_mode, - eval_mode: eval_mode, - eval_lax: eval_lax, - pre_scripts: pre_scripts, - constant_mode: constant_mode + constant_mode: constant_mode, + instantiate: new ) original_program_name = $PROGRAM_NAME $PROGRAM_NAME = File.basename(full_path) - catalog = Rubycli.cli.command_catalog_for(runner_target) - Array(catalog&.entries).each do |entry| - method_obj = entry&.method - Rubycli.documentation_registry.metadata_for(method_obj) if method_obj - end - - if catalog&.entries&.empty? && runner_target.respond_to?(:call) - method_obj = runner_target.method(:call) rescue nil - Rubycli.documentation_registry.metadata_for(method_obj) if method_obj + documentation_methods_for(runner_target, full_path, instantiate: new).each do |method_obj| + Rubycli.documentation_registry.metadata_for(method_obj) end issues = Rubycli.environment.documentation_issues @@ -318,6 +319,8 @@ def evaluate_pre_script(source, base_target, current_target) end rescue Errno::ENOENT raise PreScriptError, "Pre-script file not found: #{context}" + rescue SyntaxError => e + raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}" rescue StandardError => e raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}" end @@ -354,7 +357,7 @@ def constantize(name, defined_constants: nil, full_path: nil) raise Error, "Unable to resolve class/module name: #{name.inspect}" if parts.empty? parts.reduce(Object) do |context, const_name| - context.const_get(const_name) + context.const_get(const_name, false) end rescue NameError message = build_missing_constant_message(name, defined_constants, full_path) @@ -378,7 +381,7 @@ def instantiate_target(target, initializer_args = nil) else target end - rescue ArgumentError => e + rescue ::ArgumentError, Rubycli::ArgumentError => e raise Error, "Failed to instantiate target: #{e.message}" end @@ -388,7 +391,7 @@ def run_with_modes(target, json:, eval_args:, eval_lax:) if json Rubycli.with_json_mode(true, &runner) elsif eval_args - Rubycli.with_eval_mode(true, lax: eval_lax, &runner) + Rubycli.with_eval_mode(true, lax: eval_lax, reuse_binding: true, &runner) else runner.call end @@ -405,7 +408,7 @@ def parse_initializer_arguments(raw_value, target, json_mode:, eval_mode:, eval_ keyword_args = {} Rubycli.argument_mode_controller.with_json_mode(json_mode) do - Rubycli.argument_mode_controller.with_eval_mode(eval_mode, lax: eval_lax) do + Rubycli.argument_mode_controller.with_eval_mode(eval_mode, lax: eval_lax, reuse_binding: true) do positional_args, keyword_args = Rubycli.argument_parser.parse(tokens.dup, initializer_method) Rubycli.apply_argument_coercions(positional_args, keyword_args) Rubycli.argument_parser.validate_inputs(initializer_method, positional_args, keyword_args) @@ -434,6 +437,21 @@ def prepare_runner_target( pre_scripts: [], constant_mode: nil ) + target, full_path = resolve_runner_target( + target_path, + class_name, + constant_mode: constant_mode, + instantiate: new + ) + + initializer_args = new ? parse_initializer_arguments(new_args, target, json_mode: json_mode, eval_mode: eval_mode, eval_lax: eval_lax) : nil + + runner_target = new ? instantiate_target(target, initializer_args) : target + runner_target = apply_pre_scripts(pre_scripts, target, runner_target) + [runner_target, full_path] + end + + def resolve_runner_target(target_path, class_name, constant_mode:, instantiate:) full_path = find_target_path(target_path) capture = Rubycli.constant_capture capture.capture(full_path) { load full_path } @@ -453,15 +471,33 @@ def prepare_runner_target( camelize(File.basename(full_path, '.rb')), candidates, constant_mode, - instantiate: new + instantiate: instantiate ) end - initializer_args = new ? parse_initializer_arguments(new_args, target, json_mode: json_mode, eval_mode: eval_mode, eval_lax: eval_lax) : nil + [target, full_path] + end - runner_target = new ? instantiate_target(target, initializer_args) : target - runner_target = apply_pre_scripts(pre_scripts, target, runner_target) - [runner_target, full_path] + def documentation_methods_for(target, full_path, instantiate:) + unless target.is_a?(Module) + catalog = Rubycli.cli.command_catalog_for(target) + methods = Array(catalog&.entries).filter_map(&:method) + if methods.empty? && target.respond_to?(:call) + callable = target.method(:call) rescue nil + methods << callable if callable + end + return methods + end + + class_methods = target.singleton_class.public_instance_methods(false) + .map { |name| target.method(name) } + .select { |method_obj| Rubycli.cli.send(:exposable_method?, method_obj) } + return class_methods unless instantiate + + instance_methods = target.public_instance_methods(false) + .map { |name| target.instance_method(name) } + .select { |method_obj| Rubycli.cli.send(:exposable_method?, method_obj) } + target.is_a?(Class) ? instance_methods + class_methods : instance_methods end def build_constant_candidates(path, constant_names) diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index cccbf82..cf8d43c 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -1,4 +1,5 @@ require 'did_you_mean' +require 'ripper' require_relative 'type_utils' require_relative 'arguments/token_stream' @@ -18,9 +19,11 @@ def initialize(environment:, documentation_registry:, json_coercer:, debug_logge def parse(args, method = nil) pos_args = [] + raw_pos_args = [] kw_args = {} kw_param_names = extract_keyword_parameter_names(method) + required_kw_param_names = extract_required_keyword_parameter_names(method) debug_log "Available keyword parameters: #{kw_param_names.inspect}" metadata = method ? @documentation_registry.metadata_for(method) : { options: [], returns: [], summary: nil } @@ -28,6 +31,9 @@ def parse(args, method = nil) cli_aliases = build_cli_alias_map(option_defs) option_lookup = build_option_lookup(option_defs) type_converters = build_type_converter_map(option_defs) + known_option_names = ( + kw_param_names + cli_aliases.keys + option_lookup.keys.map(&:to_s) + ).uniq stream = Arguments::TokenStream.new(args) @@ -36,8 +42,9 @@ def parse(args, method = nil) if token == '--' stream.advance - rest_tokens = stream.consume_remaining.map { |value| convert_arg(value) } - pos_args.concat(rest_tokens) + rest_tokens = stream.consume_remaining + raw_pos_args.concat(rest_tokens) + pos_args.concat(rest_tokens.map { |value| convert_arg(value) }) break elsif option_token?(token) stream.advance @@ -48,18 +55,21 @@ def parse(args, method = nil) kw_args, cli_aliases, option_lookup, - type_converters + type_converters, + required_kw_param_names, + known_option_names ) - elsif assignment_token?(token) + elsif assignment_token_for_method?(token, method, kw_param_names) stream.advance - process_assignment_token(token, kw_args) + process_assignment_token(token, kw_args, option_lookup, type_converters) else + raw_pos_args << token pos_args << convert_arg(token) stream.advance end end - pos_args = convert_positional_arguments(pos_args, method, metadata) + pos_args = convert_positional_arguments(pos_args, raw_pos_args, method, metadata) debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}" [pos_args, kw_args] end @@ -88,12 +98,25 @@ def extract_keyword_parameter_names(method) .map { |_, name| name.to_s } end + def extract_required_keyword_parameter_names(method) + return [] unless method + + method.parameters + .select { |type, _| type == :keyreq } + .map { |_, name| name.to_s } + end + def option_token?(token) token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/ end - def assignment_token?(token) - !split_assignment_token(token).nil? + def assignment_token_for_method?(token, method, kw_param_names) + key, = split_assignment_token(token) + return false unless key + return true unless method + return true if kw_param_names.include?(key) + + method.parameters.any? { |type, _| type == :keyrest } end def process_option_token( @@ -103,7 +126,9 @@ def process_option_token( kw_args, cli_aliases, option_lookup, - type_converters + type_converters, + required_kw_param_names, + known_option_names ) token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/ cli_key = Regexp.last_match(1).tr('-', '_') @@ -117,7 +142,11 @@ def process_option_token( final_key_sym = final_key.to_sym option_meta = option_lookup[final_key_sym] - requires_value = option_meta ? option_meta[:requires_value] : nil + requires_value = if option_meta + option_meta[:requires_value] + else + required_kw_param_names.include?(final_key) + end option_label = option_meta&.long || "--#{final_key.tr('_', '-')}" value_capture = if embedded_value @@ -126,15 +155,18 @@ def process_option_token( capture_option_value( option_meta, stream, - requires_value + requires_value, + known_option_names ) + elsif requires_value + capture_required_option_value(option_label, stream, known_option_names) elsif (next_token = stream.current) && !looks_like_option?(next_token) stream.consume else 'true' end - if requires_value && (value_capture.nil? || value_capture == 'true') + if requires_value && value_capture.nil? raise ArgumentError, "Option '#{option_label}' requires a value" end @@ -147,7 +179,19 @@ def process_option_token( kw_args[final_key_sym] = converted_value end - def capture_option_value(option_meta, stream, requires_value) + + def capture_required_option_value(option_label, stream, known_option_names) + next_token = stream.current + option_like_value = looks_like_option?(next_token) && + !valid_eval_option_value?(next_token, known_option_names) + if next_token.nil? || option_like_value + raise ArgumentError, "Option '#{option_label}' requires a value" + end + + stream.consume + end + + def capture_option_value(option_meta, stream, requires_value, known_option_names) if option_meta[:boolean_flag] if (next_token = stream.current) && TypeUtils.boolean_string?(next_token) return stream.consume @@ -161,10 +205,7 @@ def capture_option_value(option_meta, stream, requires_value) elsif requires_value == false return 'true' elsif requires_value - next_token = stream.current - raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token - - return stream.consume + return capture_required_option_value(option_meta.long, stream, known_option_names) elsif (next_token = stream.current) && !looks_like_option?(next_token) return stream.consume else @@ -172,9 +213,28 @@ def capture_option_value(option_meta, stream, requires_value) end end - def process_assignment_token(token, kw_args) + def valid_eval_option_value?(token, known_option_names) + return false unless Rubycli.eval_mode? + return false if known_option_token?(token, known_option_names) + + !Ripper.sexp(token).nil? + end + + def known_option_token?(token, known_option_names) + match = token&.match(/\A-{1,2}([a-zA-Z0-9_-]+)(?:=.*)?\z/) + return false unless match + + key = match[1].tr('-', '_') + return true if known_option_names.include?(key) + + known_option_names.one? { |name| name.start_with?(key) } + end + + def process_assignment_token(token, kw_args, option_lookup, type_converters) key, value = split_assignment_token(token) - kw_args[key.to_sym] = convert_arg(value) + keyword = key.to_sym + option_meta = option_lookup[keyword] + kw_args[keyword] = convert_option_value(keyword, value, option_meta, type_converters) end def split_assignment_token(token) @@ -187,33 +247,103 @@ def split_assignment_token(token) [key, value] end - def convert_positional_arguments(pos_args, method, metadata) + def convert_positional_arguments(pos_args, raw_pos_args, method, metadata) return pos_args unless method + return pos_args if Rubycli.eval_mode? || Rubycli.json_mode? positional_map = metadata[:positionals_map] || {} return pos_args if positional_map.empty? converted = pos_args.dup - method.parameters.each_with_index do |(type, name), index| - next unless %i[req opt].include?(type) + positional_argument_bindings(method, converted.size).each do |type, name, indexes| definition = positional_map[name] next unless definition - next if index >= converted.size - converter = converter_for_definition(definition) - next unless converter + case type + when :req, :opt + index = indexes.first + next unless index + + converter = converter_for_definition(definition) + next unless converter + + begin + converted[index] = converter.call(raw_pos_args[index]) + rescue StandardError => e + label = definition.label || definition.placeholder || name.to_s.upcase + raise ArgumentError, "Value '#{converted[index]}' for #{label} is invalid: #{e.message}" + end + when :rest + next if indexes.empty? + + converter = converter_for_rest_definition(definition) + next unless converter - begin - converted[index] = converter.call(converted[index]) - rescue StandardError => e label = definition.label || definition.placeholder || name.to_s.upcase - raise ArgumentError, "Value '#{converted[index]}' for #{label} is invalid: #{e.message}" + indexes.each do |index| + value = raw_pos_args[index] + converted[index] = converter.call(value) + rescue StandardError => e + raise ArgumentError, "Value '#{value}' for #{label} is invalid: #{e.message}" + end end end converted end + def positional_argument_bindings(method_obj, argument_count) + parameters = method_obj.parameters.select { |type, _| %i[req opt rest].include?(type) } + first_flexible = parameters.index { |type, _| %i[opt rest].include?(type) } + + unless first_flexible + return parameters.each_with_index.map do |(type, name), index| + [type, name, index < argument_count ? [index] : []] + end + end + + last_flexible = parameters.rindex { |type, _| %i[opt rest].include?(type) } + bindings = [] + cursor = 0 + + parameters[0...first_flexible].each do |type, name| + indexes = cursor < argument_count ? [cursor] : [] + bindings << [type, name, indexes] + cursor += 1 unless indexes.empty? + end + + trailing = parameters[(last_flexible + 1)..] || [] + trailing_start = [argument_count - trailing.size, cursor].max + middle_end = [trailing_start, argument_count].min + + parameters[first_flexible..last_flexible].each do |type, name| + indexes = if type == :rest + (cursor...middle_end).to_a + elsif cursor < middle_end + [cursor] + else + [] + end + bindings << [type, name, indexes] + cursor += indexes.size + end + + trailing.each_with_index do |(type, name), offset| + index = trailing_start + offset + bindings << [type, name, index < argument_count ? [index] : []] + end + + bindings + end + + def converter_for_rest_definition(definition) + scalar_types = Array(definition.types).compact.map do |type| + normalized = type.to_s.strip + array_inner_type(normalized) || normalized + end + build_converter_for_types(scalar_types) + end + def converter_for_definition(definition) types = Array(definition.types).compact return nil if types.empty? @@ -225,13 +355,21 @@ def converter_for_definition(definition) if normalized.start_with?('Array<') && normalized.end_with?('>') inner = normalized[6..-2].strip element_converter = converter_for_single_type(inner) - return ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + return ->(value) { + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end + } elsif normalized.end_with?('[]') inner = normalized[0..-3] element_converter = converter_for_single_type(inner) - return ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + return ->(value) { + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end + } elsif normalized.casecmp('Array').zero? - return ->(value) { TypeUtils.parse_list(value) } + return ->(value) { list_items(value) } else single = converter_for_single_type(normalized) return single if single @@ -265,15 +403,23 @@ def validate_positional_arguments(method_obj, metadata, positional_args) return if positional_args.nil? || positional_args.empty? positional_map = metadata[:positionals_map] || {} - ordered_params = method_obj.parameters.select { |type, _| %i[req opt].include?(type) } - - ordered_params.each_with_index do |(_, name), index| + positional_argument_bindings(method_obj, positional_args.size).each do |type, name, indexes| definition = positional_map[name] next unless definition - next if index >= positional_args.size - label = definition.label || definition.placeholder || name.to_s.upcase - enforce_value_against_definition(definition, positional_args[index], label) + + case type + when :req, :opt + index = indexes.first + next unless index + + enforce_value_against_definition(definition, positional_args[index], label) + when :rest + next if indexes.empty? + + values = indexes.map { |index| positional_args[index] } + enforce_value_against_definition(definition, values, label) + end end end @@ -564,7 +710,10 @@ def converter_for_single_type(type) when 'Boolean', 'TrueClass', 'FalseClass' ->(value) { TypeUtils.convert_boolean(value) } when 'Symbol' - ->(value) { value.to_sym } + ->(value) { + converted_value = convert_arg(value) + converted_value.is_a?(Symbol) ? converted_value : value.to_sym + } when 'BigDecimal', 'Decimal' require 'bigdecimal' ->(value) { @@ -579,14 +728,31 @@ def converter_for_single_type(type) when 'Date' require 'date' ->(value) { Date.parse(value) } - when 'Time', 'DateTime' + when 'Time' require 'time' ->(value) { Time.parse(value) } - when 'JSON', 'Hash' + when 'DateTime' + require 'date' + ->(value) { DateTime.parse(value) } + when 'JSON' ->(value) { - return value if value.is_a?(Hash) + parsed_value = convert_arg(value) + unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array) + parsed_value = JSON.parse(value) + end + unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array) + raise ::ArgumentError, 'JSON value must be an object or array' + end - JSON.parse(value) + parsed_value + } + when 'Hash' + ->(value) { + parsed_value = convert_arg(value) + parsed_value = JSON.parse(value) unless parsed_value.is_a?(Hash) + raise ::ArgumentError, 'Hash value must be an object' unless parsed_value.is_a?(Hash) + + parsed_value } when 'Pathname' require 'pathname' @@ -599,19 +765,33 @@ def converter_for_single_type(type) if normalized.start_with?('Array<') && normalized.end_with?('>') inner = normalized[6..-2].strip element_converter = converter_for_single_type(inner) - ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + ->(value) { + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end + } elsif normalized.end_with?('[]') inner = normalized[0..-3] element_converter = converter_for_single_type(inner) - ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + ->(value) { + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end + } elsif normalized == 'Array' - ->(value) { TypeUtils.parse_list(value) } + ->(value) { list_items(value) } else nil end end end + def list_items(value) + converted_value = convert_arg(value) + source = converted_value.is_a?(Array) ? converted_value : value + TypeUtils.parse_list(source) + end + def convert_option_value(keyword, value, option_meta, type_converters) if Rubycli.eval_mode? || Rubycli.json_mode? return convert_arg(value) @@ -621,17 +801,7 @@ def convert_option_value(keyword, value, option_meta, type_converters) converted_value = convert_arg(value) return converted_value unless converter - original_input = value if value.is_a?(String) - expects_list = option_meta && option_meta.types.any? { |type| - type.to_s.end_with?('[]') || type.to_s.start_with?('Array<') - } - - value_for_converter = converted_value - if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',') - value_for_converter = original_input - end - - converter.call(value_for_converter) + converter.call(value) rescue StandardError => e option_label = option_meta&.long || option_meta&.short || keyword raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}" @@ -639,9 +809,16 @@ def convert_option_value(keyword, value, option_meta, type_converters) def looks_like_option?(token) return false unless token - return false if token == '--' + return false if token == '--' || token == '-' - token.start_with?('-') && !(token =~ /\A-?\d+(\.\d+)?\z/) + decimal_digits = '\d(?:_?\d)*' + negative_decimal = token.match?( + /\A-(?:#{decimal_digits}(?:\.(?:#{decimal_digits})?)?|\.#{decimal_digits})(?:[eE][+-]?#{decimal_digits})?\z/ + ) + negative_radix = token.match?( + /\A-0(?:[xX][0-9a-fA-F_]+|[bB][01_]+|[oO][0-7_]+|[dD][0-9_]+)\z/ + ) + token.start_with?('-') && !negative_decimal && !negative_radix end end end diff --git a/lib/rubycli/cli.rb b/lib/rubycli/cli.rb index 9a92b33..7fb4cfa 100644 --- a/lib/rubycli/cli.rb +++ b/lib/rubycli/cli.rb @@ -134,22 +134,15 @@ def execute_method(method_obj, command, args, cli_mode) end end - def execute_parameterless_method(method_obj, command, args, cli_mode) + def execute_parameterless_method(method_obj, command, args, _cli_mode) if help_requested_for_parameterless?(args) puts usage_for_method(command, method_obj) return 0 end - begin - result = method_obj.call - debug_log "Parameterless method returned: #{result.inspect}" - if result - return run(result, args, false) - end - 0 - rescue StandardError => e - handle_execution_error(e, command, method_obj, [], {}, cli_mode) - end + puts "Command '#{command}' does not accept arguments." + puts usage_for_method(command, method_obj) + 1 end def execute_method_with_params(method_obj, command, args, cli_mode) diff --git a/lib/rubycli/command_line.rb b/lib/rubycli/command_line.rb index 743f7d6..5fd7a00 100644 --- a/lib/rubycli/command_line.rb +++ b/lib/rubycli/command_line.rb @@ -32,6 +32,9 @@ module CommandLine module_function def run(argv = ARGV) + previous_doc_check = Rubycli.environment.doc_check_mode? + previous_strict_input = Rubycli.environment.strict_input? + previous_print_result = Rubycli.environment.print_result? args = Array(argv).dup Rubycli.environment.enable_print_result! @@ -183,6 +186,24 @@ def run(argv = ARGV) rescue Rubycli::Runner::Error => e warn "[ERROR] #{e.message}" 1 + ensure + if previous_doc_check + Rubycli.environment.enable_doc_check! + else + Rubycli.environment.disable_doc_check! + end + + if previous_strict_input + Rubycli.environment.enable_strict_input! + else + Rubycli.environment.disable_strict_input! + end + + if previous_print_result + Rubycli.environment.enable_print_result! + else + Rubycli.environment.disable_print_result! + end end def likely_new_args_value?(token) diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index c1c2a84..53577d5 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -1,27 +1,62 @@ # frozen_string_literal: true +require 'ripper' + module Rubycli # Observes constants defined while loading a file. class ConstantCapture def initialize @captured = Hash.new { |hash, key| hash[key] = [] } + @assignment_definitions = {} end def capture(file) - trace = TracePoint.new(:class) do |tp| + normalized_file = normalize(file) + source = File.read(normalized_file) + previous_names = @captured[normalized_file].dup + previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {}) + executed_line_contexts = Hash.new { |hash, line| hash[line] = [] } + assignment_events = Hash.new { |hash, line| hash[line] = [] } + observed_events = [] + @captured[normalized_file] = [] + before_snapshot = constant_snapshot(normalized_file) + trace = TracePoint.new(:class, :line, :call, :return, :b_call, :b_return, :c_call, :c_return) do |tp| location = tp.path - next unless location && same_file?(file, location) - - constant_name = qualified_name_for(tp.self) - next unless constant_name + next unless location && same_file?(normalized_file, location) - @captured[file] << constant_name + observed_events << [tp.event, tp.self, tp.lineno, tp.method_id] end trace.enable yield ensure trace&.disable + if normalized_file && before_snapshot + current_assignment_definitions = assigned_constant_definitions(source) + apply_trace_events( + observed_events, + executed_line_contexts, + assignment_events, + normalized_file + ) + after_snapshot = constant_snapshot(normalized_file) + changed_names = after_snapshot.keys.select do |name| + before_snapshot[name] != after_snapshot[name] + end + retained_names = previous_names.select do |name| + previous_definitions = previous_assignment_definitions.fetch(name, []) + current_definitions = current_assignment_definitions.fetch(name, []) + definition_active = active_definition_retained?( + previous_definitions, + current_definitions, + executed_line_contexts, + assignment_events + ) + after_snapshot.key?(name) && definition_active + end + @captured[normalized_file].concat(changed_names).concat(retained_names) + @assignment_definitions[normalized_file] = current_assignment_definitions + end end def constants_for(file) @@ -46,5 +81,530 @@ def qualified_name_for(target) name end + + def constant_snapshot(file) + ObjectSpace.each_object(Module).each_with_object({}) do |owner, snapshot| + owner_name = owner.equal?(Object) ? '' : owner.name + next if owner_name.nil? || owner_name.start_with?('#<') + + safe_module_constants(owner).each do |constant_name| + next if safe_autoload?(owner, constant_name) + + location = safe_const_source_location(owner, constant_name) + next unless location && same_file?(file, location[0]) + + value = safe_const_get(owner, constant_name) + next unless value.is_a?(Module) + + name = qualified_constant_name(owner_name, constant_name) + snapshot[name] = [location, value.object_id] + end + end + rescue StandardError + {} + end + + def safe_module_constants(owner) + owner.constants(false) + rescue StandardError + [] + end + + def safe_autoload?(owner, constant_name) + owner.autoload?(constant_name, false) + rescue StandardError + false + end + + def safe_const_source_location(owner, constant_name) + owner.const_source_location(constant_name, false) + rescue StandardError + nil + end + + def safe_const_get(owner, constant_name) + owner.const_get(constant_name, false) + rescue StandardError + nil + end + + def qualified_constant_name(owner_name, constant_name) + return constant_name.to_s if owner_name.empty? + + "#{owner_name}::#{constant_name}" + end + + def apply_trace_events(events, executed_line_contexts, assignment_events, file) + active_context_events = [] + events.each do |event, target, line, method_id| + case event + when :line + executed_line_contexts[line] << active_context_events.dup + when :call, :b_call + active_context_events << event + when :return + remove_active_context_event(active_context_events, :call) + when :b_return + remove_active_context_event(active_context_events, :b_call) + when :c_call + if method_id == :const_added || (method_id == :warn && target.equal?(Warning)) + assignment_events[line] << method_id + end + when :c_return + @captured[file].concat(constants_set_at(target, file, line)) if method_id == :const_set + when :class + constant_name = qualified_name_for(target) + @captured[file] << constant_name if constant_name + end + end + end + + def remove_active_context_event(active_context_events, event) + index = active_context_events.rindex(event) + active_context_events.delete_at(index) if index + end + + def constants_set_at(owner, file, line) + owner_name = owner.equal?(Object) ? '' : owner.name + return [] if owner_name.nil? || owner_name.start_with?('#<') + + safe_module_constants(owner).filter_map do |constant_name| + location = safe_const_source_location(owner, constant_name) + next unless location && same_file?(file, location[0]) && location[1] == line + + value = safe_const_get(owner, constant_name) + next unless value.is_a?(Module) + + qualified_constant_name(owner_name, constant_name) + end + end + + def active_definition_retained?( + previous_definitions, + current_definitions, + executed_line_contexts, + assignment_events + ) + previous_definitions.any? do |previous| + current_definitions.any? do |current| + next false unless previous[:signature] == current[:signature] + + events = assignment_events[current[:line]] + assignment_observed = events.include?(:const_added) || events.count(:warn) >= 2 + persistence_observed = current[:persistence_line] && + !current[:persistence_ambiguous] && + executed_line_contexts[current[:persistence_line]].any? do |active_context_events| + context_requirements_met?( + active_context_events, + current[:required_context_events] + ) + end + assignment_observed || persistence_observed + end + end + end + + def context_requirements_met?(active_context_events, required_context_events) + required_context_events.uniq.all? do |event| + active_context_events.count(event) >= required_context_events.count(event) + end + end + + def assigned_constant_definitions(source) + syntax_tree = Ripper.sexp(source) + return {} unless syntax_tree + + collect_assigned_constant_definitions(syntax_tree, [], [], {}) + end + + def collect_assigned_constant_definitions(node, namespace, contexts, definitions) + return definitions unless node.is_a?(Array) + + case node.first + when :module + nested_name = constant_name_from_node(node[1], namespace) + collect_assigned_constant_definitions(node[2], Array(nested_name&.split('::')), contexts, definitions) + when :class + nested_name = constant_name_from_node(node[1], namespace) + collect_assigned_constant_definitions(node[3], Array(nested_name&.split('::')), contexts, definitions) + when :assign, :opassign + signature = [node.first, assignment_operator(node), contexts.map(&:first)] + collect_assignment_targets(node[1], namespace, definitions, signature, contexts) + node.drop(2).each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + when :massign + signature = [node.first, nil, contexts.map(&:first)] + collect_assignment_targets(node[1], namespace, definitions, signature, contexts) + node.drop(2).each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + when :method_add_arg, :command_call, :command + collect_const_set_definition(node, namespace, contexts, definitions) + node.each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + when :if, :unless + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + condition = canonical_syntax(node[1]) + condition_line = source_line(node[1]) + then_context = contexts + [[[node.first, :then, condition], condition_line]] + else_context = contexts + [[[node.first, :else, condition], condition_line]] + collect_assigned_constant_definitions(node[2], namespace, then_context, definitions) + collect_assigned_constant_definitions(node[3], namespace, else_context, definitions) + when :while, :until + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + body_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]] + collect_assigned_constant_definitions(node[2], namespace, body_context, definitions) + when :if_mod, :unless_mod, :while_mod, :until_mod + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + branch_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]] + collect_assigned_constant_definitions(node[2], namespace, branch_context, definitions) + when :ifop + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + condition = canonical_syntax(node[1]) + condition_line = source_line(node[1]) + then_context = contexts + [[[node.first, :then, condition], condition_line]] + else_context = contexts + [[[node.first, :else, condition], condition_line]] + collect_assigned_constant_definitions(node[2], namespace, then_context, definitions) + collect_assigned_constant_definitions(node[3], namespace, else_context, definitions) + when :binary + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + right_contexts = contexts + if %i[&& ||].include?(node[2]) + right_contexts += [[[node.first, node[2], canonical_syntax(node[1])], source_line(node[1])]] + end + collect_assigned_constant_definitions(node[3], namespace, right_contexts, definitions) + when :case + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + case_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]] + collect_assigned_constant_definitions(node[2], namespace, case_context, definitions) + when :for + collect_assigned_constant_definitions(node[2], namespace, contexts, definitions) + body_context = contexts + [[[node.first, canonical_syntax(node[2])], source_line(node[1])]] + collect_assigned_constant_definitions(node[3], namespace, body_context, definitions) + when :method_add_block + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + lazy_context = contexts + [[[:block], source_line(node[1]), :b_call]] + collect_assigned_constant_definitions(node[2], namespace, lazy_context, definitions) + when :def, :defs + lazy_context = contexts + [[[node.first], source_line(node), :call]] + node.drop(1).each do |child| + collect_assigned_constant_definitions(child, namespace, lazy_context, definitions) + end + when :lambda + lazy_context = contexts + [[[node.first], source_line(node), :b_call]] + node.drop(1).each do |child| + collect_assigned_constant_definitions(child, namespace, lazy_context, definitions) + end + when :do_block, :brace_block + node.drop(1).each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + else + node.each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + end + + definitions + end + + def collect_assignment_targets(node, namespace, definitions, signature, contexts) + assigned_name = constant_name_from_node(node, namespace) + if assigned_name + line = source_line(node) + persistence = persistence_metadata( + contexts, + assigned_name, + namespace, + line, + signature[1] == '||=' + ) + definition = { + signature: signature, + line: line, + persistence_line: persistence[:line], + persistence_ambiguous: persistence[:ambiguous], + required_context_events: persistence[:required_context_events] + } + definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] + elsif node.is_a?(Array) + node.each { |child| collect_assignment_targets(child, namespace, definitions, signature, contexts) } + end + end + + def assignment_operator(node) + node.first == :opassign ? node.dig(2, 1) : nil + end + + def collect_const_set_definition(node, namespace, contexts, definitions) + receiver, method_name, arguments = call_parts(node) + return unless method_name == 'const_set' + + constant_name = literal_constant_name(arguments.first) + return unless constant_name + + owner_name = if receiver.nil? || self_receiver?(receiver) + namespace.join('::') + elsif object_receiver?(receiver) + '' + else + receiver_constant_name_from_node(receiver, namespace) + end + return if owner_name.nil? + + assigned_name = [owner_name, constant_name].reject(&:empty?).join('::') + signature = [:const_set, nil, contexts.map(&:first)] + line = source_line(node) + persistence = persistence_metadata(contexts, assigned_name, namespace, line, false) + definition = { + signature: signature, + line: line, + persistence_line: persistence[:line], + persistence_ambiguous: persistence[:ambiguous], + required_context_events: persistence[:required_context_events] + } + definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] + end + + def call_parts(node) + case node.first + when :method_add_arg + call = node[1] + receiver = call[1] if call&.first == :call + method_token = call&.first == :call ? call[3] : call&.[](1) + [receiver, method_token&.[](1), call_arguments(node[2])] + when :command_call + [node[1], node.dig(3, 1), call_arguments(node[4])] + when :command + [nil, node.dig(1, 1), call_arguments(node[2])] + end + end + + def call_arguments(node) + return [] unless node.is_a?(Array) + return call_arguments(node[1]) if node.first == :arg_paren + return Array(node[1]) if node.first == :args_add_block + + [] + end + + def literal_constant_name(node) + return nil unless node.is_a?(Array) + return nil unless %i[symbol_literal string_literal].include?(node.first) + + token = find_syntax_token(node) { |type, _value| %i[@const @ident @tstring_content].include?(type) } + token&.[](1) + end + + def object_receiver?(node) + node&.first == :var_ref && node.dig(1, 0) == :@const && node.dig(1, 1) == 'Object' + end + + def self_receiver?(node) + node&.first == :var_ref && node.dig(1, 0) == :@kw && node.dig(1, 1) == 'self' + end + + def persistence_metadata(contexts, assigned_name, namespace, assignment_line, short_circuit_assignment) + guard = contexts.reverse.find do |context| + missing_constant_guard?(context.first, assigned_name, namespace) + end + persistence_line = guard&.[](1) + persistence_line ||= assignment_line if short_circuit_assignment + return { line: nil, ambiguous: false, required_context_events: [] } unless persistence_line + + same_line_contexts = contexts.select { |context| context[1] == persistence_line } + ambiguous = same_line_contexts.any? do |context| + !lazy_context?(context) && + !missing_constant_guard?(context.first, assigned_name, namespace) + end + required_context_events = same_line_contexts.filter_map do |context| + context[2] + end + { + line: persistence_line, + ambiguous: ambiguous, + required_context_events: required_context_events + } + end + + def missing_constant_guard?(context, assigned_name, namespace) + condition, expected = guarded_condition(context) + return false unless condition + + condition, expected = unwrap_guard_condition(condition, expected) + expected == false && guarded_constant_name(condition, namespace) == assigned_name + end + + def guarded_condition(context) + case context.first + when :if + [context[2], context[1] == :then] + when :unless + [context[2], context[1] != :then] + when :if_mod + [context[1], true] + when :unless_mod + [context[1], false] + when :while, :while_mod + [context[1], true] + when :until, :until_mod + [context[1], false] + when :ifop + [context[2], context[1] == :then] + when :binary + [context[2], context[1] == :'&&'] + end + end + + def unwrap_guard_condition(condition, expected) + loop do + case condition&.first + when :paren + expressions = condition[1] + break unless expressions.is_a?(Array) && expressions.length == 1 + + condition = expressions.first + when :unary + break unless condition[1] == :! + + condition = condition[2] + expected = !expected + when :call + break unless condition.dig(3, 1) == 'nil?' + + condition = condition[1] + expected = !expected + when :binary + left, operator, right = condition.drop(1) + break unless operator == :== && nil_literal?(right) + + condition = left + expected = !expected + else + break + end + end + [condition, expected] + end + + def nil_literal?(node) + node&.first == :var_ref && node.dig(1, 0) == :@kw && node.dig(1, 1) == 'nil' + end + + def guarded_constant_name(condition, namespace) + if condition&.first == :defined + return constant_name_from_node(condition[1], namespace) + end + + receiver, method_name, arguments = call_parts(condition) + return nil unless method_name == 'const_defined?' + + constant_name = literal_constant_name(arguments.first) + return nil unless constant_name + + owner_name = if receiver.nil? || self_receiver?(receiver) + namespace.join('::') + elsif object_receiver?(receiver) + '' + else + receiver_constant_name_from_node(receiver, namespace) + end + return nil if owner_name.nil? + + [owner_name, constant_name].reject(&:empty?).join('::') + end + + def receiver_constant_name_from_node(node, namespace) + return nil unless node.is_a?(Array) + + case node.first + when :var_ref, :const_ref + receiver_constant_name_from_node(node[1], namespace) + when :@const + resolve_lexical_constant_name(node[1], namespace) + when :const_path_ref + parent_name = receiver_constant_name_from_node(node[1], namespace) + child_name = node.dig(2, 1) + [parent_name, child_name].compact.join('::') + when :top_const_ref + node.dig(1, 1) + end + end + + def resolve_lexical_constant_name(constant_name, namespace) + namespace.length.downto(0) do |depth| + candidate = (namespace.first(depth) + [constant_name]).join('::') + return candidate if constant_path_defined?(candidate) + end + + (namespace + [constant_name]).join('::') + end + + def constant_path_defined?(name) + parts = name.split('::') + parts.each_with_index.reduce(Object) do |owner, (part, index)| + return false unless owner.is_a?(Module) && owner.const_defined?(part, false) + return true if index == parts.length - 1 + return false if owner.autoload?(part, false) + + owner.const_get(part, false) + end + true + rescue StandardError + false + end + + def lazy_context?(context) + !context[2].nil? + end + + def find_syntax_token(node, &predicate) + return nil unless node.is_a?(Array) + return node if node.first.to_s.start_with?('@') && predicate.call(node[0], node[1]) + + node.each do |child| + token = find_syntax_token(child, &predicate) + return token if token + end + nil + end + + def canonical_syntax(node) + return node unless node.is_a?(Array) + return [node[0], node[1]] if node.first.to_s.start_with?('@') + + node.map { |child| canonical_syntax(child) } + end + + def source_line(node) + return nil unless node.is_a?(Array) + return node.dig(2, 0) if node.first.to_s.start_with?('@') + + node.each do |child| + line = source_line(child) + return line if line + end + nil + end + + def constant_name_from_node(node, namespace) + return nil unless node.is_a?(Array) + + case node.first + when :var_field, :const_ref, :var_ref + constant_name_from_node(node[1], namespace) + when :@const + (namespace + [node[1]]).join('::') + when :const_path_field, :const_path_ref + parent_name = receiver_constant_name_from_node(node[1], namespace) + child_name = node.dig(2, 1) + [parent_name, child_name].compact.join('::') + when :top_const_field, :top_const_ref + node.dig(1, 1) + end + end + end end diff --git a/lib/rubycli/documentation/metadata_parser.rb b/lib/rubycli/documentation/metadata_parser.rb index bc53081..987fcec 100644 --- a/lib/rubycli/documentation/metadata_parser.rb +++ b/lib/rubycli/documentation/metadata_parser.rb @@ -571,11 +571,11 @@ def align_and_validate_parameter_docs(method_obj, metadata, defaults) method_obj.parameters.each do |type, name| case type - when :req, :opt - doc = positional_defs.shift + when :req, :opt, :rest + doc = take_positional_definition(positional_defs, name) if doc doc.param_name = name - doc.default_value = defaults[name] + doc.default_value = type == :rest ? [] : defaults[name] positional_map[name] = doc else @environment.handle_documentation_issue( @@ -587,10 +587,10 @@ def align_and_validate_parameter_docs(method_obj, metadata, defaults) fallback = PositionalDefinition.new( placeholder: name.to_s, label: name.to_s.upcase, - types: ['String'], + types: [type == :rest ? 'String[]' : 'String'], description: nil, param_name: name, - default_value: defaults[name], + default_value: type == :rest ? [] : defaults[name], inline_type_annotation: false, inline_type_text: nil, doc_format: :auto_generated, @@ -660,6 +660,18 @@ def align_and_validate_parameter_docs(method_obj, metadata, defaults) end end + def take_positional_definition(positional_defs, parameter_name) + tagged_index = positional_defs.index do |definition| + definition.doc_format == :tagged_param && definition.param_name == parameter_name + end + return positional_defs.delete_at(tagged_index) if tagged_index + + tagless_index = positional_defs.index { |definition| definition.doc_format != :tagged_param } + return positional_defs.delete_at(tagless_index) if tagless_index + + nil + end + def detail_line_for_extra_positional(doc) return nil unless doc diff --git a/lib/rubycli/environment.rb b/lib/rubycli/environment.rb index a641f7c..7ad0d9f 100644 --- a/lib/rubycli/environment.rb +++ b/lib/rubycli/environment.rb @@ -45,6 +45,10 @@ def strict_input? @strict_input end + def disable_strict_input! + @strict_input = false + end + def documentation_issues @documentation_issues.dup end @@ -92,6 +96,10 @@ def enable_print_result! @print_result = true end + def disable_print_result! + @print_result = false + end + private def fetch_env_value(key, default) diff --git a/lib/rubycli/eval_coercer.rb b/lib/rubycli/eval_coercer.rb index cdcd7f2..c95aca0 100644 --- a/lib/rubycli/eval_coercer.rb +++ b/lib/rubycli/eval_coercer.rb @@ -2,7 +2,7 @@ module Rubycli class EvalCoercer THREAD_KEY = :rubycli_eval_mode LAX_THREAD_KEY = :rubycli_eval_lax_mode - EVAL_BINDING = Object.new.instance_eval { binding } + BINDING_THREAD_KEY = :rubycli_eval_binding def eval_mode? Thread.current[THREAD_KEY] == true @@ -12,15 +12,28 @@ def eval_lax_mode? Thread.current[LAX_THREAD_KEY] == true end - def with_eval_mode(enabled = true, lax: false) + def with_eval_mode(enabled = true, lax: false, reuse_binding: false) previous = Thread.current[THREAD_KEY] previous_lax = Thread.current[LAX_THREAD_KEY] + previous_binding = Thread.current[BINDING_THREAD_KEY] Thread.current[THREAD_KEY] = enabled Thread.current[LAX_THREAD_KEY] = enabled && lax + if enabled + Thread.current[BINDING_THREAD_KEY] = reuse_binding && previous_binding ? previous_binding : isolated_binding + end yield ensure Thread.current[THREAD_KEY] = previous Thread.current[LAX_THREAD_KEY] = previous_lax + Thread.current[BINDING_THREAD_KEY] = previous_binding + end + + def with_eval_binding + previous_binding = Thread.current[BINDING_THREAD_KEY] + Thread.current[BINDING_THREAD_KEY] = isolated_binding + yield + ensure + Thread.current[BINDING_THREAD_KEY] = previous_binding end def coerce_eval_value(value) @@ -34,7 +47,7 @@ def coerce_eval_value(value) else value end - rescue StandardError => e + rescue SyntaxError, StandardError => e raise Rubycli::ArgumentError, "Failed to evaluate Ruby code: #{e.message}" end @@ -44,7 +57,7 @@ def evaluate_string(expression) trimmed = expression.strip return trimmed if trimmed.empty? - EVAL_BINDING.eval(trimmed) + (Thread.current[BINDING_THREAD_KEY] || isolated_binding).eval(trimmed) rescue SyntaxError, NameError => e if eval_lax_mode? warn "[WARN] Failed to evaluate argument as Ruby (#{e.message.strip}). Passing it through because --eval-lax is enabled." @@ -53,5 +66,9 @@ def evaluate_string(expression) raise end end + + def isolated_binding + Object.new.instance_eval { binding } + end end end diff --git a/lib/rubycli/help_renderer.rb b/lib/rubycli/help_renderer.rb index ff4be0b..f80ac33 100644 --- a/lib/rubycli/help_renderer.rb +++ b/lib/rubycli/help_renderer.rb @@ -247,7 +247,7 @@ def literal_token?(token) end def positional_requirement(kind) - kind == :opt ? 'optional' : 'required' + %i[opt rest].include?(kind) ? 'optional' : 'required' end def positional_default(definition) @@ -272,7 +272,7 @@ def required_keyword_names(method) def ordered_positionals(method, metadata) positional_map = metadata[:positionals_map] || {} method.parameters.each_with_object([]) do |(type, name), memo| - next unless %i[req opt].include?(type) + next unless %i[req opt rest].include?(type) definition = positional_map[name] label = display_label_for(definition, name) @@ -319,7 +319,10 @@ def required_placeholder(placeholder, definition, name) def optional_placeholder(placeholder, definition, name) unless placeholder.nil? || placeholder.strip.empty? || auto_generated_placeholder?(placeholder, definition, name) - return placeholder.strip + documented = placeholder.strip + return documented if documented.start_with?('[') && documented.end_with?(']') + + return "[#{documented}]" end "[#{default_positional_label(definition, name, uppercase: true)}]" @@ -327,7 +330,10 @@ def optional_placeholder(placeholder, definition, name) def rest_placeholder(placeholder, definition, name) unless placeholder.nil? || placeholder.strip.empty? || auto_generated_placeholder?(placeholder, definition, name) - return placeholder.strip + documented = placeholder.strip + documented = documented[1..-2].strip if documented.start_with?('[') && documented.end_with?(']') + documented = "#{documented}..." unless documented.end_with?('...') + return "[#{documented}]" end base = default_positional_label(definition, name, uppercase: true) diff --git a/lib/rubycli/result_emitter.rb b/lib/rubycli/result_emitter.rb index cf8bab2..e0901fe 100644 --- a/lib/rubycli/result_emitter.rb +++ b/lib/rubycli/result_emitter.rb @@ -34,7 +34,7 @@ def format_result_output(result) result.inspect end end - rescue JSON::GeneratorError + rescue JSON::GeneratorError, JSON::NestingError result.inspect end end diff --git a/lib/rubycli/type_utils.rb b/lib/rubycli/type_utils.rb index fe5d3af..92a13ac 100644 --- a/lib/rubycli/type_utils.rb +++ b/lib/rubycli/type_utils.rb @@ -76,7 +76,7 @@ def infer_types_from_placeholder(types, placeholder_info, include_optional_boole working = working.map do |type| next type if type.nil? || type.empty? - if boolean_type?(type) || nil_type?(type) + if (boolean_type?(type) && placeholder_info[:optional]) || nil_type?(type) type elsif type.end_with?('[]') || type.start_with?('Array<') || type.casecmp('Array').zero? array_type_present = true diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index 8626ef4..a8f7068 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -32,18 +32,137 @@ module StdTypeSamples # --date DATE [Date] Planned date # --moment TIME [Time] Execution timestamp + # --occurred-at MOMENT [DateTime] Calendar timestamp # --budget AMOUNT [BigDecimal] Budget amount # --input FILE [Pathname] Input file - def ingest(date:, moment:, budget:, input:) + def ingest(date:, moment:, occurred_at:, budget:, input:) { date: date, moment: moment, + occurred_at: occurred_at, budget: budget, input: input } end end +module UndocumentedKeywordSamples + module_function + + def call(name:, verbose: false) + [name, verbose] + end +end + +module RestParameterSamples + module_function + + # VALUES... [Symbol] Values to collect + def collect(*values) + values + end + + # LEVELS... [:info, :warn] Allowed levels + def choose(*levels) + levels + end + + # HEAD [String] Required head + # VALUES... [Symbol] Remaining values + def with_head(head, *values) + [head, values] + end + + # HEAD [String] Required head + # VALUES... [Symbol] Middle values + # TAIL [Integer] Required tail + def with_tail(head, *values, tail) + [head, values, tail] + end + + # PREFIX [Symbol] Optional prefix + # VALUE [Integer] Required value + def optional_before_required(prefix = :default, value) + [prefix, value] + end +end + +module JsonTypeSamples + module_function + + # --payload VALUE [JSON] JSON payload + def accept(payload:) + payload + end + + # --payload VALUE [Hash] Hash payload + def accept_hash(payload:) + payload + end +end + +module IntegerOptionSamples + module_function + + # --count VALUE [Integer] Required count + def count(count:) + count + end +end + +module EvalRequiredOptionSamples + module_function + + def run(callback:, verbose: false) + [callback, verbose] + end +end + +module ScalarTypeSamples + module_function + + # CODE [String] Positional code + # --label VALUE [String] Label code + def strings(code, label:) + [code, label] + end + + # VALUE [Symbol] Symbol value + def symbol(value) + value + end + + # --codes VALUES... [String] String codes + def string_list(codes:) + codes + end + + # --codes VALUES... [Array] Generic string codes + def generic_string_list(codes:) + codes + end + + # ITEMS [String[]] Positional string codes + def positional_string_list(items) + items + end + + # ITEMS [Array] Generic positional string codes + def generic_positional_string_list(items) + items + end + + # --flags VALUES... [Boolean] Boolean flags + def boolean_list(flags:) + flags + end + + # VALUE [String] Assignment-like text + def text(value) + value + end +end + class ArgumentParserTest < Minitest::Test def setup @environment = Rubycli::Environment.new(env: {}, argv: []) @@ -66,6 +185,53 @@ def test_parses_tagged_options_and_booleans assert_equal({ greeting: 'Hi', shout: true, punctuation: '!' }, kw_args) end + def test_required_option_rejects_following_option_as_its_value + method = DocExamples::TaggedSamples.new.method(:greet) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['Alice', '--greeting', '--shout'], method) + end + + assert_includes error.message, "Option '--greeting' requires a value" + end + + def test_required_string_option_accepts_true_as_an_explicit_value + method = DocExamples::TaggedSamples.new.method(:greet) + + pos_args, kw_args = @parser.parse(['Alice', '--greeting', 'true'], method) + + assert_equal ['Alice'], pos_args + assert_equal({ greeting: 'true' }, kw_args) + end + + def test_required_option_accepts_lone_dash_as_its_value + method = StdTypeSamples.method(:ingest) + + pos_args, kw_args = @parser.parse(['--input', '-'], method) + + assert_empty pos_args + assert_equal Pathname.new('-'), kw_args[:input] + end + + def test_double_dash_preserves_following_option_like_values_as_positionals + callable = ->(*values) { values } + + pos_args, kw_args = @parser.parse(['--', '--literal', 'name=value'], callable) + + assert_equal ['--literal', 'name=value'], pos_args + assert_empty kw_args + end + + def test_undocumented_required_keyword_rejects_following_option_as_its_value + method = UndocumentedKeywordSamples.method(:call) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--name', '--verbose'], method) + end + + assert_includes error.message, "Option '--name' requires a value" + end + def test_parses_concise_options_with_short_alias_and_array_conversion method = DocExamples::ConciseSamples.new.method(:describe) args = ['subject', '2', '-s', 'dramatic', '--tags', 'alpha,beta'] @@ -182,6 +348,34 @@ def test_preserves_keyword_values_under_eval_mode end end + def test_eval_mode_accepts_option_looking_required_values + method = EvalRequiredOptionSamples.method(:run) + + Rubycli.with_eval_mode(true) do + _pos_args, lambda_args = @parser.parse(['--callback', '-> { 42 }'], method) + _pos_args, unary_args = @parser.parse(['--callback', '-some_value'], method) + + assert_equal({ callback: '-> { 42 }' }, lambda_args) + assert_equal({ callback: '-some_value' }, unary_args) + end + end + + def test_eval_mode_does_not_consume_known_option_as_required_value + method = EvalRequiredOptionSamples.method(:run) + + Rubycli.with_eval_mode(true) do + plain_error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--callback', '--verbose'], method) + end + embedded_error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--callback', '--verbose=1'], method) + end + + assert_includes plain_error.message, "Option '--callback' requires a value" + assert_includes embedded_error.message, "Option '--callback' requires a value" + end + end + def test_validate_inputs_warns_when_values_outside_choices method = ValidationSamples.method(:check) warnings = [] @@ -254,6 +448,7 @@ def test_standard_type_hints_convert_to_stdlib_classes args = [ '--date', '2024-12-25', '--moment', '2024-12-25T10:00:00Z', + '--occurred-at', '2024-12-25T10:00:00+09:00', '--budget', '123.45', '--input', '/tmp/data.txt' ] @@ -262,7 +457,271 @@ def test_standard_type_hints_convert_to_stdlib_classes assert_empty pos_args assert_instance_of Date, kw_args[:date] assert_instance_of Time, kw_args[:moment] + assert_instance_of DateTime, kw_args[:occurred_at] assert_instance_of BigDecimal, kw_args[:budget] assert_instance_of Pathname, kw_args[:input] + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_required_option_accepts_negative_exponent_value + method = StdTypeSamples.method(:ingest) + + pos_args, kw_args = @parser.parse(['--budget', '-1e3'], method) + + assert_empty pos_args + assert_equal BigDecimal('-1e3'), kw_args[:budget] + end + + def test_required_integer_option_accepts_negative_radix_values + method = IntegerOptionSamples.method(:count) + + { + '-0x10' => -16, + '-0x1_0' => -16, + '-0b10' => -2, + '-0o10' => -8, + '-0d10' => -10 + }.each do |token, expected| + pos_args, kw_args = @parser.parse(['--count', token], method) + + assert_empty pos_args + assert_equal({ count: expected }, kw_args) + end + end + + def test_required_integer_option_accepts_negative_decimal_with_separator + method = IntegerOptionSamples.method(:count) + + pos_args, kw_args = @parser.parse(['--count', '-1_000'], method) + + assert_empty pos_args + assert_equal({ count: -1000 }, kw_args) + end + + def test_json_type_accepts_an_array_literal + method = JsonTypeSamples.method(:accept) + + pos_args, kw_args = @parser.parse(['--payload', '[1,2]'], method) + + assert_empty pos_args + assert_equal({ payload: [1, 2] }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_json_type_rejects_scalar_json + method = JsonTypeSamples.method(:accept) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--payload', '1'], method) + end + + assert_includes error.message, 'JSON value must be an object or array' + end + + def test_hash_type_rejects_an_array_literal + method = JsonTypeSamples.method(:accept_hash) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--payload', '[1,2]'], method) + end + + assert_includes error.message, 'Hash value must be an object' + end + + def test_string_annotations_preserve_numeric_looking_tokens + method = ScalarTypeSamples.method(:strings) + + pos_args, kw_args = @parser.parse(['00123', '--label', '00456'], method) + + assert_equal ['00123'], pos_args + assert_equal({ label: '00456' }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_string_annotations_preserve_boolean_and_null_looking_tokens + method = ScalarTypeSamples.method(:strings) + + %w[true false null].each do |token| + pos_args, kw_args = @parser.parse([token, '--label', token], method) + + assert_equal [token], pos_args + assert_equal({ label: token }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + end + + def test_symbol_annotation_converts_numeric_looking_token + method = ScalarTypeSamples.method(:symbol) + + pos_args, kw_args = @parser.parse(['123'], method) + + assert_equal [:"123"], pos_args + assert_empty kw_args + end + + def test_symbol_annotation_preserves_symbol_literal_value + method = ScalarTypeSamples.method(:symbol) + + pos_args, kw_args = @parser.parse([':foo'], method) + + assert_equal [:foo], pos_args + assert_empty kw_args + end + + def test_string_array_annotation_preserves_numeric_looking_token + method = ScalarTypeSamples.method(:string_list) + + pos_args, kw_args = @parser.parse(['--codes', '001'], method) + + assert_empty pos_args + assert_equal({ codes: ['001'] }, kw_args) + end + + def test_string_array_annotation_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:string_list) + + pos_args, kw_args = @parser.parse(['--codes', '["true","null"]'], method) + + assert_empty pos_args + assert_equal({ codes: %w[true null] }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_generic_string_array_annotation_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:generic_string_list) + + pos_args, kw_args = @parser.parse(['--codes', '["true","null"]'], method) + + assert_empty pos_args + assert_equal({ codes: %w[true null] }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_positional_string_array_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:positional_string_list) + + pos_args, kw_args = @parser.parse(['["true","null"]'], method) + + assert_equal [%w[true null]], pos_args + assert_empty kw_args + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_generic_positional_string_array_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:generic_positional_string_list) + + pos_args, kw_args = @parser.parse(['["true","null"]'], method) + + assert_equal [%w[true null]], pos_args + assert_empty kw_args + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_required_repeated_boolean_option_consumes_and_converts_its_value + method = ScalarTypeSamples.method(:boolean_list) + metadata = @registry.metadata_for(method) + + assert_equal ['Boolean[]'], metadata[:options].first.types + + pos_args, kw_args = @parser.parse(['--flags', 'true,false'], method) + + assert_empty pos_args + assert_equal({ flags: [true, false] }, kw_args) + assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--flags', 'true,nope'], method) + end + end + + def test_assignment_like_token_remains_positional_without_matching_keyword + method = ScalarTypeSamples.method(:text) + + pos_args, kw_args = @parser.parse(['name=value'], method) + + assert_equal ['name=value'], pos_args + assert_empty kw_args + end + + def test_assignment_token_still_sets_a_matching_keyword + method = UndocumentedKeywordSamples.method(:call) + + pos_args, kw_args = @parser.parse(['name=Ruby'], method) + + assert_empty pos_args + assert_equal({ name: 'Ruby' }, kw_args) + end + + def test_assignment_token_uses_matching_keyword_type_conversion + method = ScalarTypeSamples.method(:strings) + + pos_args, kw_args = @parser.parse(['code', 'label=00456'], method) + + assert_equal ['code'], pos_args + assert_equal({ label: '00456' }, kw_args) + end + + def test_rest_parameter_metadata_converts_every_remaining_positional + method = RestParameterSamples.method(:collect) + metadata = @registry.metadata_for(method) + + assert_equal [:values], metadata[:positionals_map].keys + assert_equal ['Symbol[]'], metadata[:positionals_map][:values].types + + pos_args, kw_args = @parser.parse(%w[alpha beta], method) + + assert_equal %i[alpha beta], pos_args + assert_empty kw_args + end + + def test_rest_parameter_strict_validation_checks_every_remaining_positional + method = RestParameterSamples.method(:choose) + pos_args, kw_args = @parser.parse([':info', 'oops'], method) + @environment.enable_strict_input! + + error = assert_raises(Rubycli::ArgumentError) do + @parser.validate_inputs(method, pos_args, kw_args) + end + + assert_includes error.message, 'oops' + end + + def test_rest_parameter_conversion_allows_no_remaining_values + method = RestParameterSamples.method(:with_head) + + pos_args, kw_args = @parser.parse(['head'], method) + + assert_equal ['head'], pos_args + assert_empty kw_args + end + + def test_rest_parameter_reserves_trailing_required_arguments + method = RestParameterSamples.method(:with_tail) + + pos_args, kw_args = @parser.parse(%w[head alpha beta 7], method) + + assert_equal ['head', :alpha, :beta, 7], pos_args + assert_empty kw_args + assert_equal ['head', %i[alpha beta], 7], method.call(*pos_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_optional_positional_reserves_a_following_required_argument + method = RestParameterSamples.method(:optional_before_required) + + pos_args, kw_args = @parser.parse(['7'], method) + + assert_equal [7], pos_args + assert_empty kw_args + assert_equal [:default, 7], method.call(*pos_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } end end diff --git a/test/cli_test.rb b/test/cli_test.rb index b467ab3..353ccfd 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -57,6 +57,31 @@ def self.info assert_includes out, 'Usage: rubycli info' end + def test_parameterless_method_rejects_extra_arguments_without_invoking_method + target = Class.new do + class << self + attr_accessor :calls + end + + self.calls = 0 + + def self.info + self.calls += 1 + nil + end + end + + status = nil + out, _err = capture_io do + status = @cli.run(target, ['info', 'unexpected'], true) + end + + assert_equal 1, status + assert_equal 0, target.calls + assert_includes out, "Command 'info' does not accept arguments." + assert_includes out, 'Usage: rubycli info' + end + def test_help_input_skips_strict_validation method_obj = ChoiceDocSamples.method(:report) status = nil @@ -67,6 +92,153 @@ def test_help_input_skips_strict_validation assert_includes out, 'Usage:' assert_equal '', err end + + def test_top_level_help_lists_available_commands_without_invoking_them + target = Module.new do + def self.greet(name) + "Hello, #{name}" + end + end + + status = nil + out, err = capture_io do + status = @cli.run(target, ['--help']) + end + + assert_equal 0, status + assert_includes out, 'Available commands:' + assert_includes out, 'greet' + assert_equal '', err + end + + def test_run_invokes_a_documented_command_with_converted_arguments + received = nil + target = Module.new do + define_singleton_method(:repeat) do |count| + received = count + count * 2 + end + end + + status = @cli.run(target, %w[repeat 3]) + + assert_equal 0, status + assert_equal 3, received + end + + def test_missing_command_prints_help_for_non_callable_target + target = Module.new do + def self.available + :ok + end + end + + status = nil + out, _err = capture_io do + status = @cli.run(target, ['missing']) + end + + assert_equal 1, status + assert_includes out, "Command 'missing' is not available." + assert_includes out, 'available' + end + + def test_callable_target_receives_reconstructed_keyword_arguments + received = nil + target = lambda do |name:, loud: false| + received = [name, loud] + :ok + end + + status = @cli.run(target, %w[--name Ruby --loud]) + + assert_equal 0, status + assert_equal ['Ruby', true], received + end + + def test_missing_required_argument_returns_usage_in_cli_mode + target = Module.new do + def self.greet(name) + name + end + end + + status = nil + out, _err = capture_io do + status = @cli.run(target, ['greet'], true) + end + + assert_equal 1, status + assert_includes out, 'wrong number of arguments' + assert_includes out, 'Usage: rubycli greet' + end + + def test_application_argument_error_is_not_reclassified_as_usage_error + target = Module.new do + def self.explode(value) + raise ::ArgumentError, "wrong number of arguments inside #{value}" + end + end + + error = assert_raises(::ArgumentError) do + @cli.run(target, %w[explode payload], true) + end + + assert_includes error.message, 'inside payload' + end + + def test_hyphenated_command_resolves_to_snake_case_method + target = Module.new do + def self.hello_world + :ok + end + end + + method_obj = @cli.find_method(target, 'hello-world') + + assert_equal :hello_world, method_obj.name + assert_equal target.method(:hello_world), method_obj + end + + def test_instance_catalog_exposes_duplicate_instance_and_class_commands + klass = Class.new do + def run + :instance + end + + def self.run + :class + end + end + target = klass.new + + catalog = @cli.command_catalog_for(target) + + assert_equal %w[class::run run], catalog.commands.sort + assert_equal ['run'], catalog.duplicates + assert_equal :instance, catalog.lookup('instance::run').method.call + assert_equal :class, catalog.lookup('class::run').method.call + assert_equal ['run'], catalog.entries_for(:instance).map(&:command) + end + + def test_generated_accessors_are_not_exposed_as_commands + klass = Class.new do + attr_accessor :value + + def run + :ok + end + end + + assert_equal ['run'], @cli.available_commands(klass.new) + end + + def test_usage_and_description_delegate_to_documented_method_renderer + method_obj = ChoiceDocSamples.method(:report) + + assert_includes @cli.usage_for_method('report', method_obj), 'Usage: rubycli report LEVEL' + assert_equal 'LEVEL', @cli.method_description(method_obj) + end end module ChoiceDocSamples module_function diff --git a/test/command_line_test.rb b/test/command_line_test.rb index b4d645d..630384c 100644 --- a/test/command_line_test.rb +++ b/test/command_line_test.rb @@ -1,8 +1,17 @@ # frozen_string_literal: true require 'test_helper' +require 'tempfile' class CommandLineTest < Minitest::Test + def setup + @previous_print_result = Rubycli.environment.print_result? + end + + def teardown + Rubycli.environment.instance_variable_set(:@print_result, @previous_print_result) + end + def test_returns_usage_when_no_arguments status = nil out, err = capture_io do @@ -176,6 +185,37 @@ def test_accepts_short_flag_for_check_mode assert_equal false, captured[:opts][:new] end + def test_check_mode_does_not_leak_into_a_later_programmatic_run + argv = ['--check', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:check, 0) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + refute Rubycli.environment.doc_check_mode? + end + + def test_strict_mode_does_not_leak_into_a_later_programmatic_run + argv = ['--strict', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, nil) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + refute Rubycli.environment.strict_input? + end + + def test_print_result_mode_does_not_leak_into_a_later_programmatic_run + argv = ['test/fixtures/doc_examples.rb'] + Rubycli.environment.instance_variable_set(:@print_result, false) + + Rubycli::Runner.stub(:execute, nil) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + refute Rubycli.environment.print_result? + end + def test_pre_script_allows_space_separated_value argv = [ '--pre-script', @@ -214,4 +254,156 @@ def test_debug_flag_is_rejected_with_helpful_message assert_includes err, 'RUBYCLI_DEBUG=true' end end + + def test_help_flag_returns_success_without_invoking_runner + Rubycli::Runner.stub(:execute, ->(*) { flunk 'Runner should not be invoked for help' }) do + status = nil + out, err = capture_io do + status = Rubycli::CommandLine.run(['--help']) + end + + assert_equal 0, status + assert_includes out, 'Usage: rubycli' + assert_equal '', err + end + end + + def test_long_new_flag_consumes_a_separate_list_argument + captured = nil + argv = ['--new', 'alpha,beta', 'examples/new_mode_runner.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal 'alpha,beta', captured.last[:new_args] + assert_equal true, captured.last[:new] + end + + def test_short_new_flag_without_constructor_value_preserves_target_path + captured = nil + argv = ['-n', 'examples/new_mode_runner.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal 'examples/new_mode_runner.rb', captured.first.first + assert_nil captured.last[:new_args] + assert_equal true, captured.last[:new] + end + + def test_init_alias_records_its_inline_source_context + captured = nil + argv = ['--init=current', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal( + [{ value: 'current', context: '(inline --init)' }], + captured.last[:pre_scripts] + ) + end + + def test_pre_script_without_source_is_rejected + status = nil + out, err = capture_io do + status = Rubycli::CommandLine.run(['--pre-script']) + end + + assert_equal 1, status + assert_equal '', out + assert_includes err, '--pre-script requires a file path or inline Ruby code' + end + + def test_pre_script_file_records_its_expanded_path + captured = nil + Tempfile.create(['rubycli-pre-script', '.rb']) do |file| + file.write('current') + file.flush + argv = ['--pre-script', file.path, 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal File.expand_path(file.path), captured.last[:pre_scripts].first[:context] + end + end + + def test_auto_target_and_print_result_flags_are_forwarded_or_consumed + captured = nil + argv = ['--print-result', '--auto-target', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal :auto, captured.last[:constant_mode] + assert_empty captured.first[2] + end + + def test_flags_without_target_print_usage + status = nil + out, err = capture_io do + status = Rubycli::CommandLine.run(['--strict']) + end + + assert_equal 1, status + assert_includes out, 'Usage: rubycli' + assert_equal '', err + end + + def test_check_rejects_argument_modes_and_forwarded_commands + [ + [['--check', '--json-args', 'target.rb'], '--check cannot be combined'], + [['--check', 'target.rb', 'run'], '--check does not accept command arguments'] + ].each do |argv, message| + status = nil + _out, err = capture_io do + status = Rubycli::CommandLine.run(argv) + end + + assert_equal 1, status + assert_includes err, message + end + end + + def test_runner_errors_are_reported_without_backtrace + [ + Rubycli::Runner::PreScriptError.new('bad pre-script'), + Rubycli::Runner::Error.new('bad runner') + ].each do |runner_error| + status = nil + _out, err = capture_io do + Rubycli::Runner.stub(:execute, ->(*) { raise runner_error }) do + status = Rubycli::CommandLine.run(['target.rb']) + end + end + + assert_equal 1, status + assert_includes err, runner_error.message + refute_includes err, 'test/command_line_test.rb' + end + end + + def test_preexisting_environment_modes_are_restored + Rubycli.environment.enable_doc_check! + Rubycli.environment.enable_strict_input! + Rubycli.environment.enable_print_result! + + Rubycli::Runner.stub(:execute, nil) do + assert_equal 0, Rubycli::CommandLine.run(['target.rb']) + end + + assert Rubycli.environment.doc_check_mode? + assert Rubycli.environment.strict_input? + assert Rubycli.environment.print_result? + ensure + Rubycli.environment.disable_doc_check! + Rubycli.environment.disable_strict_input! + Rubycli.environment.instance_variable_set(:@print_result, @previous_print_result) + end end diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index eee88b5..74dae15 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -32,6 +32,893 @@ def test_ignores_constants_from_other_files end end + def test_records_class_assigned_to_constant + capture = Rubycli::ConstantCapture.new + Tempfile.create(['assigned_class', '.rb']) do |file| + file.write("CaptureAssignedClass = Class.new do\n def self.run; end\nend\n") + file.flush + + capture.capture(file.path) { load file.path } + + assert_includes capture.constants_for(file.path), 'CaptureAssignedClass' + ensure + cleanup_constant(:CaptureAssignedClass) + end + end + + def test_records_module_assigned_to_constant + capture = Rubycli::ConstantCapture.new + Tempfile.create(['assigned_module', '.rb']) do |file| + file.write("CaptureAssignedModule = Module.new do\n def self.run; end\nend\n") + file.flush + + capture.capture(file.path) { load file.path } + + assert_includes capture.constants_for(file.path), 'CaptureAssignedModule' + ensure + cleanup_constant(:CaptureAssignedModule) + end + end + + def test_records_assigned_alias_when_same_file_is_loaded_twice + capture = Rubycli::ConstantCapture.new + Tempfile.create(['assigned_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureAliasSource + def self.run; end + end + CaptureAssignedAlias = CaptureAliasSource + RUBY + file.flush + + 2.times do + capture_io do + capture.capture(file.path) { load file.path } + end + assert_includes capture.constants_for(file.path), 'CaptureAssignedAlias' + end + ensure + cleanup_constant(:CaptureAssignedAlias) + cleanup_constant(:CaptureAliasSource) + end + end + + def test_records_assigned_alias_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_assigned_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureEditedAliasSource + def self.run; end + end + CaptureEditedAssignedAlias = CaptureEditedAliasSource + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureEditedAssignedAlias' + ensure + cleanup_constant(:CaptureEditedAssignedAlias) + cleanup_constant(:CaptureEditedAliasSource) + end + end + + def test_records_guarded_constant_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_guarded_constant', '.rb']) do |file| + source = <<~RUBY + CaptureGuardedRunner ||= Class.new do + def self.run; end + end + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureGuardedRunner' + ensure + cleanup_constant(:CaptureGuardedRunner) + end + end + + def test_retains_defined_guarded_alias_when_same_file_is_loaded_twice + capture = Rubycli::ConstantCapture.new + Tempfile.create(['defined_guarded_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDefinedGuardedAliasSource + def self.run; end + end + unless defined?(CaptureDefinedGuardedAssignedAlias) + CaptureDefinedGuardedAssignedAlias = CaptureDefinedGuardedAliasSource + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureDefinedGuardedAssignedAlias' + end + ensure + cleanup_constant(:CaptureDefinedGuardedAssignedAlias) + cleanup_constant(:CaptureDefinedGuardedAliasSource) + end + end + + def test_does_not_retain_assigned_alias_removed_from_edited_source + capture = Rubycli::ConstantCapture.new + Tempfile.create(['removed_assigned_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureRemovedAliasSource + def self.run; end + end + CaptureRemovedAssignedAlias = CaptureRemovedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureRemovedAliasSource + def self.run; end + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureRemovedAliasSource' + refute_includes capture.constants_for(file.path), 'CaptureRemovedAssignedAlias' + ensure + cleanup_constant(:CaptureRemovedAssignedAlias) + cleanup_constant(:CaptureRemovedAliasSource) + end + end + + def test_records_qualified_and_absolute_aliases_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_qualified_aliases', '.rb']) do |file| + source = <<~RUBY + module CaptureQualifiedAliasOwner; end + module CaptureQualifiedAliasSource + def self.run; end + end + CaptureQualifiedAliasOwner::Runner = CaptureQualifiedAliasSource + ::CaptureAbsoluteAssignedAlias = CaptureQualifiedAliasSource + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureQualifiedAliasOwner::Runner' + assert_includes capture.constants_for(file.path), 'CaptureAbsoluteAssignedAlias' + ensure + if Object.const_defined?(:CaptureQualifiedAliasOwner) + owner = Object.const_get(:CaptureQualifiedAliasOwner) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureAbsoluteAssignedAlias) + cleanup_constant(:CaptureQualifiedAliasOwner) + cleanup_constant(:CaptureQualifiedAliasSource) + end + end + + def test_records_multiple_assigned_aliases_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_multiple_aliases', '.rb']) do |file| + source = <<~RUBY + module CaptureMultipleAliasSource + def self.run; end + end + CaptureMultipleAliasOne, CaptureMultipleAliasTwo = CaptureMultipleAliasSource, CaptureMultipleAliasSource + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureMultipleAliasOne' + assert_includes capture.constants_for(file.path), 'CaptureMultipleAliasTwo' + ensure + cleanup_constant(:CaptureMultipleAliasOne) + cleanup_constant(:CaptureMultipleAliasTwo) + cleanup_constant(:CaptureMultipleAliasSource) + end + end + + def test_does_not_retain_alias_when_edited_assignment_is_not_executed + capture = Rubycli::ConstantCapture.new + Tempfile.create(['skipped_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureSkippedAliasSource + def self.run; end + end + CaptureSkippedAssignedAlias = CaptureSkippedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureSkippedAliasSource + def self.run; end + end + if false + CaptureSkippedAssignedAlias = CaptureSkippedAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureSkippedAssignedAlias' + ensure + cleanup_constant(:CaptureSkippedAssignedAlias) + cleanup_constant(:CaptureSkippedAliasSource) + end + end + + def test_retains_multiline_conditional_alias_when_assignment_executes_after_edit + capture = Rubycli::ConstantCapture.new + Tempfile.create(['executed_conditional_edited_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureExecutedConditionalAliasSource + def self.run; end + end + enabled = true + if enabled + CaptureExecutedConditionalAssignedAlias = CaptureExecutedConditionalAliasSource + end + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureExecutedConditionalAssignedAlias' + ensure + cleanup_constant(:CaptureExecutedConditionalAssignedAlias) + cleanup_constant(:CaptureExecutedConditionalAliasSource) + end + end + + def test_does_not_retain_multiline_conditional_alias_when_runtime_guard_changes + capture = Rubycli::ConstantCapture.new + Tempfile.create(['disabled_conditional_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDisabledConditionalAliasSource + def self.run; end + end + enabled = true + if enabled + CaptureDisabledConditionalAssignedAlias = CaptureDisabledConditionalAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureDisabledConditionalAliasSource + def self.run; end + end + enabled = false + if enabled + CaptureDisabledConditionalAssignedAlias = CaptureDisabledConditionalAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureDisabledConditionalAssignedAlias' + ensure + cleanup_constant(:CaptureDisabledConditionalAssignedAlias) + cleanup_constant(:CaptureDisabledConditionalAliasSource) + end + end + + def test_does_not_retain_alias_from_inactive_branch_when_source_is_unchanged + capture = Rubycli::ConstantCapture.new + previous_mode = ENV['RUBYCLI_CAPTURE_MODE'] + Tempfile.create(['unchanged_dynamic_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDynamicAliasSource + def self.run; end + end + if ENV['RUBYCLI_CAPTURE_MODE'] == 'a' + CaptureDynamicAliasA = CaptureDynamicAliasSource + else + CaptureDynamicAliasB = CaptureDynamicAliasSource + end + RUBY + file.flush + + ENV['RUBYCLI_CAPTURE_MODE'] = 'a' + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureDynamicAliasA' + + ENV['RUBYCLI_CAPTURE_MODE'] = 'b' + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureDynamicAliasA' + assert_includes capture.constants_for(file.path), 'CaptureDynamicAliasB' + ensure + ENV['RUBYCLI_CAPTURE_MODE'] = previous_mode + cleanup_constant(:CaptureDynamicAliasA) + cleanup_constant(:CaptureDynamicAliasB) + cleanup_constant(:CaptureDynamicAliasSource) + end + end + + def test_does_not_retain_alias_when_edited_assignment_is_short_circuited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['short_circuited_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureShortCircuitedAliasSource + def self.run; end + end + CaptureShortCircuitedAssignedAlias = CaptureShortCircuitedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureShortCircuitedAliasSource + def self.run; end + end + false && (CaptureShortCircuitedAssignedAlias = CaptureShortCircuitedAliasSource) + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureShortCircuitedAssignedAlias' + ensure + cleanup_constant(:CaptureShortCircuitedAssignedAlias) + cleanup_constant(:CaptureShortCircuitedAliasSource) + end + end + + def test_does_not_retain_alias_when_edited_postfix_assignment_is_skipped + capture = Rubycli::ConstantCapture.new + Tempfile.create(['postfix_skipped_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CapturePostfixSkippedAliasSource + def self.run; end + end + CapturePostfixSkippedAssignedAlias = CapturePostfixSkippedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CapturePostfixSkippedAliasSource + def self.run; end + end + CapturePostfixSkippedAssignedAlias = CapturePostfixSkippedAliasSource if false + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CapturePostfixSkippedAssignedAlias' + ensure + cleanup_constant(:CapturePostfixSkippedAssignedAlias) + cleanup_constant(:CapturePostfixSkippedAliasSource) + end + end + + def test_retains_alias_when_edited_postfix_assignment_executes + capture = Rubycli::ConstantCapture.new + Tempfile.create(['postfix_executed_edited_alias', '.rb']) do |file| + source = <<~RUBY + module CapturePostfixExecutedAliasSource + def self.run; end + end + CapturePostfixExecutedAssignedAlias = CapturePostfixExecutedAliasSource if true + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CapturePostfixExecutedAssignedAlias' + ensure + cleanup_constant(:CapturePostfixExecutedAssignedAlias) + cleanup_constant(:CapturePostfixExecutedAliasSource) + end + end + + def test_records_const_set_alias_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_const_set_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureConstSetAliasSource + def self.run; end + end + Object.const_set(:CaptureConstSetAssignedAlias, CaptureConstSetAliasSource) + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureConstSetAssignedAlias' + ensure + cleanup_constant(:CaptureConstSetAssignedAlias) + cleanup_constant(:CaptureConstSetAliasSource) + end + end + + def test_retains_guarded_const_set_alias_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_guarded_const_set_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureGuardedConstSetAliasSource + def self.run; end + end + unless Object.const_defined?(:CaptureGuardedConstSetAssignedAlias, false) + Object.const_set(:CaptureGuardedConstSetAssignedAlias, CaptureGuardedConstSetAliasSource) + end + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureGuardedConstSetAssignedAlias' + ensure + cleanup_constant(:CaptureGuardedConstSetAssignedAlias) + cleanup_constant(:CaptureGuardedConstSetAliasSource) + end + end + + def test_retains_self_const_set_alias_inside_namespace + capture = Rubycli::ConstantCapture.new + Tempfile.create(['self_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureSelfConstSetAliasSource + def self.run; end + end + module CaptureSelfConstSetOwner + self.const_set(:Runner, CaptureSelfConstSetAliasSource) unless const_defined?(:Runner, false) + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureSelfConstSetOwner::Runner' + end + ensure + if Object.const_defined?(:CaptureSelfConstSetOwner) + owner = Object.const_get(:CaptureSelfConstSetOwner) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureSelfConstSetOwner) + cleanup_constant(:CaptureSelfConstSetAliasSource) + end + end + + def test_resolves_const_set_receiver_through_lexical_fallback + capture = Rubycli::ConstantCapture.new + Tempfile.create(['fallback_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureFallbackConstSetSource + def self.run; end + end + module CaptureFallbackConstSetRoot + module Owner; end + end + module CaptureFallbackConstSetNamespace + CaptureFallbackConstSetRoot::Owner.const_set(:Runner, CaptureFallbackConstSetSource) unless CaptureFallbackConstSetRoot::Owner.const_defined?(:Runner, false) + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureFallbackConstSetRoot::Owner::Runner' + end + ensure + if Object.const_defined?(:CaptureFallbackConstSetRoot) + root = Object.const_get(:CaptureFallbackConstSetRoot) + owner = root.const_get(:Owner, false) if root.const_defined?(:Owner, false) + owner.send(:remove_const, :Runner) if owner&.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureFallbackConstSetNamespace) + cleanup_constant(:CaptureFallbackConstSetRoot) + cleanup_constant(:CaptureFallbackConstSetSource) + end + end + + def test_does_not_trigger_autoload_while_resolving_const_set_receiver + capture = Rubycli::ConstantCapture.new + previous_marker = ENV['RUBYCLI_AUTOLOAD_CAPTURED'] + Tempfile.create(['autoload_side_effect', '.rb']) do |autoload_file| + autoload_file.write(<<~RUBY) + ENV['RUBYCLI_AUTOLOAD_CAPTURED'] = 'yes' + module CaptureAutoloadConstSetOwner; end + RUBY + autoload_file.flush + + Tempfile.create(['autoload_const_set_alias', '.rb']) do |target_file| + target_file.write(<<~RUBY) + autoload :CaptureAutoloadConstSetOwner, #{autoload_file.path.dump} + if false + CaptureAutoloadConstSetOwner.const_set(:Runner, Module.new) + end + module CaptureAutoloadActualRunner + def self.run; end + end + RUBY + target_file.flush + + capture_io { capture.capture(target_file.path) { load target_file.path } } + + assert_includes capture.constants_for(target_file.path), 'CaptureAutoloadActualRunner' + assert_nil ENV['RUBYCLI_AUTOLOAD_CAPTURED'] + end + ensure + ENV['RUBYCLI_AUTOLOAD_CAPTURED'] = previous_marker + cleanup_constant(:CaptureAutoloadConstSetOwner) + cleanup_constant(:CaptureAutoloadActualRunner) + end + end + + def test_uses_preloaded_source_when_target_deletes_itself + capture = Rubycli::ConstantCapture.new + Tempfile.create(['self_deleting_capture', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureSelfDeletingRunner + def self.run; end + end + File.delete(__FILE__) + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureSelfDeletingRunner' + ensure + cleanup_constant(:CaptureSelfDeletingRunner) + end + end + + def test_does_not_match_existence_guard_for_another_namespace + capture = Rubycli::ConstantCapture.new + Tempfile.create(['qualified_guard_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureQualifiedGuardAliasSource + def self.run; end + end + module CaptureGuardedOwner; end + module CaptureAssignedOwner; end + unless defined?(CaptureGuardedOwner::Runner) + CaptureAssignedOwner::Runner = CaptureQualifiedGuardAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + Object.const_get(:CaptureGuardedOwner).const_set(:Runner, Module.new) + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureAssignedOwner::Runner' + ensure + %i[CaptureGuardedOwner CaptureAssignedOwner].each do |owner_name| + next unless Object.const_defined?(owner_name) + + owner = Object.const_get(owner_name) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureGuardedOwner) + cleanup_constant(:CaptureAssignedOwner) + cleanup_constant(:CaptureQualifiedGuardAliasSource) + end + end + + def test_retains_guarded_alias_in_immediately_executed_block + capture = Rubycli::ConstantCapture.new + Tempfile.create(['immediate_block_guarded_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureImmediateBlockAliasSource + def self.run; end + end + 1.times do; CaptureImmediateBlockAssignedAlias = CaptureImmediateBlockAliasSource unless defined?(CaptureImmediateBlockAssignedAlias); end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureImmediateBlockAssignedAlias' + end + ensure + cleanup_constant(:CaptureImmediateBlockAssignedAlias) + cleanup_constant(:CaptureImmediateBlockAliasSource) + end + end + + def test_does_not_retain_guarded_alias_in_uninvoked_block + capture = Rubycli::ConstantCapture.new + previous_mode = ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] + Tempfile.create(['conditional_block_guarded_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureConditionalBlockAliasSource + def self.run; end + end + install = proc do; CaptureConditionalBlockAssignedAlias = CaptureConditionalBlockAliasSource unless defined?(CaptureConditionalBlockAssignedAlias); end + install.call if ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] == 'on' + RUBY + file.flush + + ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] = 'on' + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureConditionalBlockAssignedAlias' + end + + ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] = 'off' + capture_io { capture.capture(file.path) { load file.path } } + refute_includes capture.constants_for(file.path), 'CaptureConditionalBlockAssignedAlias' + ensure + ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] = previous_mode + cleanup_constant(:CaptureConditionalBlockAssignedAlias) + cleanup_constant(:CaptureConditionalBlockAliasSource) + end + end + + def test_retains_aliases_for_equivalent_existence_guard_forms + capture = Rubycli::ConstantCapture.new + Tempfile.create(['equivalent_guard_forms', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureEquivalentGuardSource + def self.run; end + end + module CaptureEquivalentGuardOwner; end + CaptureEquivalentGuardOwner.const_defined?(:Runner, false) || (CaptureEquivalentGuardOwner::Runner = CaptureEquivalentGuardSource) + CaptureNegatedGuardAlias = CaptureEquivalentGuardSource if !(defined?(CaptureNegatedGuardAlias)) + defined?(CaptureTernaryGuardAlias) ? nil : (CaptureTernaryGuardAlias = CaptureEquivalentGuardSource) + CaptureNilPredicateGuardAlias = CaptureEquivalentGuardSource if defined?(CaptureNilPredicateGuardAlias).nil? + CaptureNilEqualityGuardAlias = CaptureEquivalentGuardSource if defined?(CaptureNilEqualityGuardAlias) == nil + install = -> { CaptureLambdaGuardAlias = CaptureEquivalentGuardSource unless defined?(CaptureLambdaGuardAlias) } + install.call + RUBY + file.flush + + expected_names = %w[ + CaptureEquivalentGuardOwner::Runner + CaptureNegatedGuardAlias + CaptureTernaryGuardAlias + CaptureNilPredicateGuardAlias + CaptureNilEqualityGuardAlias + CaptureLambdaGuardAlias + ] + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + expected_names.each do |name| + assert_includes capture.constants_for(file.path), name + end + end + ensure + if Object.const_defined?(:CaptureEquivalentGuardOwner) + owner = Object.const_get(:CaptureEquivalentGuardOwner) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureEquivalentGuardOwner) + cleanup_constant(:CaptureEquivalentGuardSource) + cleanup_constant(:CaptureNegatedGuardAlias) + cleanup_constant(:CaptureTernaryGuardAlias) + cleanup_constant(:CaptureNilPredicateGuardAlias) + cleanup_constant(:CaptureNilEqualityGuardAlias) + cleanup_constant(:CaptureLambdaGuardAlias) + end + end + + def test_retains_aliases_initialized_by_loop_existence_guards + skip 'Ruby 2.x does not reliably emit loop-condition line events' if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.0') + + capture = Rubycli::ConstantCapture.new + Tempfile.create(['loop_guarded_aliases', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureLoopGuardSource + def self.run; end + end + while !defined?(CaptureWhileGuardAlias) + CaptureWhileGuardAlias = CaptureLoopGuardSource + end + CaptureUntilGuardAlias = CaptureLoopGuardSource until defined?(CaptureUntilGuardAlias) + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureWhileGuardAlias' + assert_includes capture.constants_for(file.path), 'CaptureUntilGuardAlias' + end + ensure + cleanup_constant(:CaptureWhileGuardAlias) + cleanup_constant(:CaptureUntilGuardAlias) + cleanup_constant(:CaptureLoopGuardSource) + end + end + + def test_retains_namespace_qualified_defined_guard + capture = Rubycli::ConstantCapture.new + Tempfile.create(['namespace_qualified_guard', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureQualifiedGuardSource + def self.run; end + end + module CaptureQualifiedGuardNamespace + Runner = CaptureQualifiedGuardSource unless defined?(CaptureQualifiedGuardNamespace::Runner) + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureQualifiedGuardNamespace::Runner' + end + ensure + if Object.const_defined?(:CaptureQualifiedGuardNamespace) + owner = Object.const_get(:CaptureQualifiedGuardNamespace) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureQualifiedGuardNamespace) + cleanup_constant(:CaptureQualifiedGuardSource) + end + end + + def test_does_not_retain_const_set_moved_into_uninvoked_method + capture = Rubycli::ConstantCapture.new + Tempfile.create(['deferred_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDeferredConstSetAliasSource + def self.run; end + end + Object.const_set(:CaptureDeferredConstSetAssignedAlias, CaptureDeferredConstSetAliasSource) + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureDeferredConstSetAliasSource + def self.run; end + end + def capture_install_deferred_alias + Object.const_set(:CaptureDeferredConstSetAssignedAlias, CaptureDeferredConstSetAliasSource) + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureDeferredConstSetAssignedAlias' + ensure + Object.send(:remove_method, :capture_install_deferred_alias) if Object.private_method_defined?(:capture_install_deferred_alias) + cleanup_constant(:CaptureDeferredConstSetAssignedAlias) + cleanup_constant(:CaptureDeferredConstSetAliasSource) + end + end + + def test_does_not_retain_postfix_const_set_when_runtime_guard_changes + capture = Rubycli::ConstantCapture.new + previous_mode = ENV['RUBYCLI_CONST_SET_MODE'] + Tempfile.create(['postfix_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CapturePostfixConstSetAliasSource + def self.run; end + end + Object.const_set(:CapturePostfixConstSetAssignedAlias, CapturePostfixConstSetAliasSource) if ENV['RUBYCLI_CONST_SET_MODE'] == 'on' + RUBY + file.flush + + ENV['RUBYCLI_CONST_SET_MODE'] = 'on' + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CapturePostfixConstSetAssignedAlias' + + ENV['RUBYCLI_CONST_SET_MODE'] = 'off' + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CapturePostfixConstSetAssignedAlias' + ensure + ENV['RUBYCLI_CONST_SET_MODE'] = previous_mode + cleanup_constant(:CapturePostfixConstSetAssignedAlias) + cleanup_constant(:CapturePostfixConstSetAliasSource) + end + end + + def test_does_not_retain_assignment_when_rhs_raises_before_assignment + capture = Rubycli::ConstantCapture.new + previous_failure = ENV['RUBYCLI_CAPTURE_FAILURE'] + Tempfile.create(['raising_assignment_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureRaisingAliasSource + def self.run; end + end + begin + CaptureRaisingAssignedAlias = (ENV['RUBYCLI_CAPTURE_FAILURE'] == 'yes' ? raise('boom') : CaptureRaisingAliasSource) + rescue RuntimeError + end + RUBY + file.flush + + ENV['RUBYCLI_CAPTURE_FAILURE'] = 'no' + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureRaisingAssignedAlias' + + ENV['RUBYCLI_CAPTURE_FAILURE'] = 'yes' + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureRaisingAssignedAlias' + ensure + ENV['RUBYCLI_CAPTURE_FAILURE'] = previous_failure + cleanup_constant(:CaptureRaisingAssignedAlias) + cleanup_constant(:CaptureRaisingAliasSource) + end + end + private def cleanup_constant(name) diff --git a/test/coverage_gate_test.rb b/test/coverage_gate_test.rb new file mode 100644 index 0000000..d135949 --- /dev/null +++ b/test/coverage_gate_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'test_helper' +require_relative 'support/coverage_gate' + +class CoverageGateTest < Minitest::Test + def test_changed_lines_extracts_added_and_replaced_new_lines + diff = <<~DIFF + diff --git a/lib/example.rb b/lib/example.rb + --- a/lib/example.rb + +++ b/lib/example.rb + @@ -1 +1,2 @@ + -old + +replacement + +added + @@ -5,0 +7 @@ + +tail + DIFF + + assert_equal( + { 'lib/example.rb' => [1, 2, 7] }, + CoverageGate.changed_lines(diff) + ) + end + + def test_changed_lines_ignores_deleted_files_and_non_library_paths + diff = <<~DIFF + diff --git a/lib/removed.rb b/lib/removed.rb + --- a/lib/removed.rb + +++ /dev/null + @@ -1 +0,0 @@ + -removed + diff --git a/README.md b/README.md + --- a/README.md + +++ b/README.md + @@ -1 +1 @@ + -old + +new + DIFF + + assert_equal({}, CoverageGate.changed_lines(diff, path_prefix: 'lib/')) + end + + def test_changed_lines_decodes_git_quoted_paths + diff = <<~'DIFF' + diff --git "a/lib/\343\203\254\343\203\223\343\203\245\343\203\274.rb" "b/lib/\343\203\254\343\203\223\343\203\245\343\203\274.rb" + --- /dev/null + +++ "b/lib/\343\203\254\343\203\223\343\203\245\343\203\274.rb" + @@ -0,0 +1 @@ + +puts :untested + DIFF + + assert_equal( + { 'lib/レビュー.rb' => [1] }, + CoverageGate.changed_lines(diff) + ) + end + + def test_coverage_stats_count_only_executable_changed_lines + changed_lines = { 'lib/example.rb' => [1, 2, 3, 4, 8] } + line_hits = { + 'lib/example.rb' => [nil, 1, 0, 2] + } + + stats = CoverageGate.coverage_stats(changed_lines, line_hits) + + assert_equal 2, stats.covered + assert_equal 3, stats.total + assert_equal [['lib/example.rb', 3]], stats.uncovered + end + + def test_coverage_stats_treat_unloaded_changed_files_as_uncovered + changed_lines = { 'lib/unloaded.rb' => [1, 2] } + + stats = CoverageGate.coverage_stats(changed_lines, {}) + + assert_equal 0, stats.covered + assert_equal 2, stats.total + assert_equal( + [['lib/unloaded.rb', 1], ['lib/unloaded.rb', 2]], + stats.uncovered + ) + end +end diff --git a/test/coverage_runner.rb b/test/coverage_runner.rb new file mode 100644 index 0000000..6a4cdda --- /dev/null +++ b/test/coverage_runner.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'coverage' +require_relative 'support/coverage_gate' + +MIN_LINE_COVERAGE = 90.0 +MIN_BRANCH_COVERAGE = 70.0 +MIN_CHANGED_LINE_COVERAGE = 90.0 + +Coverage.start(lines: true, branches: true) + +require_relative 'test_helper' + +def coverage_percentage(covered, total) + return 100.0 if total.zero? + + covered.fdiv(total) * 100 +end + +Minitest.after_run do + library_root = File.expand_path('../lib', __dir__) + File::SEPARATOR + rows = Coverage.result.filter_map do |path, data| + next unless path.start_with?(library_root) + + line_hits = Array(data[:lines]) + line_counts = line_hits.compact + branch_counts = (data[:branches] || {}).values.flat_map(&:values) + { + file: path.delete_prefix(library_root), + line_hits: line_hits, + covered_lines: line_counts.count(&:positive?), + total_lines: line_counts.size, + covered_branches: branch_counts.count(&:positive?), + total_branches: branch_counts.size + } + end.sort_by { |row| row[:file] } + + covered_lines = rows.sum { |row| row[:covered_lines] } + total_lines = rows.sum { |row| row[:total_lines] } + covered_branches = rows.sum { |row| row[:covered_branches] } + total_branches = rows.sum { |row| row[:total_branches] } + line_coverage = coverage_percentage(covered_lines, total_lines) + branch_coverage = coverage_percentage(covered_branches, total_branches) + coverage_base_ref = ENV.fetch('COVERAGE_BASE_REF', 'origin/main') + changed_stats = nil + changed_error = nil + + begin + diff = CoverageGate.diff_against(coverage_base_ref, chdir: File.expand_path('..', __dir__)) + changed_line_numbers = CoverageGate.changed_lines(diff) + line_hits = rows.to_h { |row| ["lib/#{row[:file]}", row[:line_hits]] } + changed_stats = CoverageGate.coverage_stats(changed_line_numbers, line_hits) + rescue CoverageGate::Error => e + changed_error = e.message + end + + puts + puts 'Coverage by file:' + rows.each do |row| + file_line_coverage = coverage_percentage(row[:covered_lines], row[:total_lines]) + file_branch_coverage = coverage_percentage(row[:covered_branches], row[:total_branches]) + puts format( + ' %-45s lines %6.2f%% (%d/%d), branches %6.2f%% (%d/%d)', + row[:file], + file_line_coverage, + row[:covered_lines], + row[:total_lines], + file_branch_coverage, + row[:covered_branches], + row[:total_branches] + ) + end + + puts format( + 'Total coverage: lines %.2f%% (%d/%d), branches %.2f%% (%d/%d)', + line_coverage, + covered_lines, + total_lines, + branch_coverage, + covered_branches, + total_branches + ) + if changed_stats + changed_line_coverage = coverage_percentage(changed_stats.covered, changed_stats.total) + puts format( + 'Changed-line coverage against %s: %.2f%% (%d/%d)', + coverage_base_ref, + changed_line_coverage, + changed_stats.covered, + changed_stats.total + ) + unless changed_stats.uncovered.empty? + puts "Uncovered changed lines: #{changed_stats.uncovered.map { |path, line| "#{path}:#{line}" }.join(', ')}" + end + else + puts "Changed-line coverage unavailable: #{changed_error}" + end + + failures = [] + failures << format('line coverage %.2f%% is below %.2f%%', line_coverage, MIN_LINE_COVERAGE) if line_coverage < MIN_LINE_COVERAGE + if branch_coverage < MIN_BRANCH_COVERAGE + failures << format('branch coverage %.2f%% is below %.2f%%', branch_coverage, MIN_BRANCH_COVERAGE) + end + if changed_stats + if changed_line_coverage < MIN_CHANGED_LINE_COVERAGE + failures << format( + 'changed-line coverage %.2f%% is below %.2f%%', + changed_line_coverage, + MIN_CHANGED_LINE_COVERAGE + ) + end + else + failures << "changed-line coverage unavailable: #{changed_error}" + end + + abort "Coverage gate failed: #{failures.join('; ')}" unless failures.empty? +end + +Dir[File.expand_path('*_test.rb', __dir__)].sort.each { |file| require file } diff --git a/test/documentation_registry_test.rb b/test/documentation_registry_test.rb index fad869e..7bf4ec4 100644 --- a/test/documentation_registry_test.rb +++ b/test/documentation_registry_test.rb @@ -40,6 +40,16 @@ module InlineDocSamples def search(query:); end end +module TaggedOrderSamples + module_function + + # @param second [Integer] Second value + # @param first [String] First value + def reorder(first, second) + [first, second] + end +end + class DocumentationRegistryTest < Minitest::Test def setup @environment = Rubycli::Environment.new(env: {}, argv: []) @@ -122,6 +132,16 @@ def test_tagged_param_with_positional_conversion assert_equal ['Boolean'], verbose_opt.types end + def test_tagged_positionals_are_aligned_by_parameter_name + metadata = @registry.metadata_for(TaggedOrderSamples.method(:reorder)) + + assert_equal %i[first second], metadata[:positionals_map].keys + assert_equal ['String'], metadata[:positionals_map][:first].types + assert_equal 'First value', metadata[:positionals_map][:first].description + assert_equal ['Integer'], metadata[:positionals_map][:second].types + assert_equal 'Second value', metadata[:positionals_map][:second].description + end + def test_concise_format_includes_inline_annotations method = DocExamples::ConciseSamples.instance_method(:describe) metadata = @registry.metadata_for(method) diff --git a/test/help_renderer_test.rb b/test/help_renderer_test.rb index 8321aa9..d4fe92f 100644 --- a/test/help_renderer_test.rb +++ b/test/help_renderer_test.rb @@ -10,6 +10,34 @@ module HelpEnumSamples def report(level, format: 'text'); end end +module RestHelpSamples + module_function + + # VALUES... [Symbol] Values to collect + def collect(*values) + values + end +end + +module BareRestHelpSamples + module_function + + # ITEMS [String] Items to collect + def collect(*items) + items + end +end + +module OptionalPositionalHelpSamples + module_function + + # PREFIX [Symbol] Optional prefix + # VALUE [Integer] Required value + def sequence(prefix = :default, value) + [prefix, value] + end +end + class HelpRendererTest < Minitest::Test def setup environment = Rubycli::Environment.new(env: {}, argv: []) @@ -95,6 +123,30 @@ def test_literal_choices_render_in_help_tables assert_includes usage, '--format= ["text", "json"]' end + def test_rest_parameter_is_optional_and_rendered_in_positional_table + method = RestHelpSamples.method(:collect) + usage = @renderer.usage_for_method('collect', method) + + assert_includes usage, 'Usage: rubycli collect [VALUES...]' + assert_includes usage, 'VALUES... [Symbol[]] optional Values to collect' + end + + def test_rest_parameter_usage_adds_ellipsis_to_bare_documented_placeholder + method = BareRestHelpSamples.method(:collect) + usage = @renderer.usage_for_method('collect', method) + + assert_includes usage, 'Usage: rubycli collect [ITEMS...]' + end + + def test_documented_optional_positional_is_bracketed_in_usage + method = OptionalPositionalHelpSamples.method(:sequence) + usage = @renderer.usage_for_method('sequence', method) + + assert_includes usage, 'Usage: rubycli sequence [PREFIX] VALUE' + assert_includes usage, 'PREFIX [Symbol] optional Optional prefix' + assert_includes usage, 'VALUE [Integer] required Required value' + end + private def assert_usage(expected, actual) diff --git a/test/mode_coercion_test.rb b/test/mode_coercion_test.rb index da2a32d..9e047c8 100644 --- a/test/mode_coercion_test.rb +++ b/test/mode_coercion_test.rb @@ -41,6 +41,19 @@ def test_eval_mode_handles_strings_with_embedded_quotes assert_equal({}, kw_args) end + def test_eval_binding_does_not_leak_locals_between_mode_scopes + Rubycli.with_eval_mode(true) do + assert_equal 41, Rubycli.coerce_eval_value('review_local = 41') + end + + observed = nil + Rubycli.with_eval_mode(true) do + observed = Rubycli.coerce_eval_value('defined?(review_local)') + end + + assert_nil observed + end + def test_eval_lax_mode_falls_back_to_original_string_on_syntax_error pos_args = ['https://example.com/'] kw_args = { ttl: '60*60*2' } @@ -117,4 +130,47 @@ def test_json_coercion_invalid_payload end assert_match(/Failed to parse as JSON/, error.message) end + + def test_json_coercer_recursively_converts_arrays_and_hash_values + input = ['1', { 'enabled' => 'true', 'count' => 2 }] + + result = Rubycli.json_coercer.coerce_json_value(input) + + assert_equal [1, { 'enabled' => true, 'count' => 2 }], result + end + + def test_json_mode_is_restored_when_the_block_raises + error = assert_raises(RuntimeError) do + Rubycli.with_json_mode(true) do + assert Rubycli.json_mode? + raise 'boom' + end + end + + assert_equal 'boom', error.message + refute Rubycli.json_mode? + end + + def test_disabled_json_mode_remains_disabled_inside_scope + Rubycli.with_json_mode(false) do + refute Rubycli.json_mode? + end + + refute Rubycli.json_mode? + end + + def test_eval_mode_wraps_invalid_ruby_syntax_as_argument_error + pos_args = ['{'] + kw_args = {} + + error = assert_raises(Rubycli::ArgumentError) do + Rubycli.with_eval_mode(true) do + Rubycli.apply_argument_coercions(pos_args, kw_args) + end + end + + assert_includes error.message, 'Failed to evaluate Ruby code' + assert_includes error.message, 'syntax error' + refute Rubycli.eval_mode? + end end diff --git a/test/result_emitter_test.rb b/test/result_emitter_test.rb index 990d23e..16a28f1 100644 --- a/test/result_emitter_test.rb +++ b/test/result_emitter_test.rb @@ -37,4 +37,74 @@ def test_skips_nil_and_class_results assert_equal '', output end + + def test_circular_array_falls_back_to_inspect + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + result = [] + result << result + + output, _err = capture_io do + emitter.emit(result) + end + + assert_equal "[[...]]\n", output + end + + def test_emits_scalars_as_plain_text + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + + output, _err = capture_io do + emitter.emit('text') + emitter.emit(42) + emitter.emit(true) + emitter.emit(false) + end + + assert_equal "text\n42\ntrue\nfalse\n", output + end + + def test_suppresses_empty_string_result + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + + output, _err = capture_io do + emitter.emit('') + end + + assert_equal '', output + end + + def test_serializes_objects_through_to_h_or_to_ary + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + hash_like = Struct.new(:name).new('Ruby') + array_like = Class.new do + def to_ary + %w[alpha beta] + end + end.new + + output, _err = capture_io do + emitter.emit(hash_like) + emitter.emit(array_like) + end + + assert_includes output, "\"name\": \"Ruby\"" + assert_includes output, '"alpha"' + assert_includes output, '"beta"' + end + + def test_falls_back_to_inspect_for_plain_objects + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + result = Object.new + + output, _err = capture_io do + emitter.emit(result) + end + + assert_equal "#{result.inspect}\n", output + end end diff --git a/test/runner_test.rb b/test/runner_test.rb index 137a6b1..36aca19 100644 --- a/test/runner_test.rb +++ b/test/runner_test.rb @@ -4,6 +4,58 @@ require 'tmpdir' class RunnerTest < Minitest::Test + def test_execute_rejects_json_and_eval_modes_before_loading_target + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.execute('missing-target.rb', json: true, eval_args: true) + end + + assert_includes error.message, '--json-args cannot be combined' + refute_includes error.message, 'File not found' + end + + def test_instantiate_target_supports_keyword_constructor_arguments + target = Class.new do + attr_reader :name + + def initialize(name:) + @name = name + end + end + + instance = Rubycli::Runner.instantiate_target(target, [[], { name: 'Ruby' }]) + + assert_instance_of target, instance + assert_equal 'Ruby', instance.name + end + + def test_instantiate_target_extends_modules_and_preserves_plain_objects + extension = Module.new do + def greeting + 'hello' + end + end + plain_target = Object.new + + extended = Rubycli::Runner.instantiate_target(extension) + + assert_equal 'hello', extended.greeting + assert_same plain_target, Rubycli::Runner.instantiate_target(plain_target) + end + + def test_find_target_path_adds_rb_extension_and_reports_missing_file + Dir.mktmpdir do |dir| + path_without_extension = File.join(dir, 'extension_runner') + File.write("#{path_without_extension}.rb", "# runner\n") + + assert_equal "#{path_without_extension}.rb", Rubycli::Runner.find_target_path(path_without_extension) + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.find_target_path(File.join(dir, 'missing')) + end + assert_includes error.message, 'File not found' + end + end + def test_execute_infers_constant_and_instantiates_when_new_flag Dir.mktmpdir do |dir| file = File.join(dir, 'sample_runner.rb') @@ -74,6 +126,33 @@ def run end end + def test_initializer_argument_error_is_wrapped_as_runner_error + target = Class.new do + def initialize(required); end + end + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.instantiate_target(target) + end + + assert_includes error.message, 'Failed to instantiate target' + assert_includes error.message, 'wrong number of arguments' + end + + def test_framework_argument_error_from_initializer_is_wrapped_as_runner_error + target = Class.new do + def initialize + raise Rubycli::ArgumentError, 'invalid constructor value' + end + end + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.instantiate_target(target) + end + + assert_equal 'Failed to instantiate target: invalid constructor value', error.message + end + def test_auto_mode_selects_single_constant_when_names_differ Dir.mktmpdir do |dir| file = File.join(dir, 'cli_entry.rb') @@ -121,6 +200,20 @@ def test_invalid_pre_script_source_raises_error assert_match(/Failed to evaluate pre-script/, error.message) end + def test_pre_script_syntax_error_is_wrapped_with_source_context + error = assert_raises(Rubycli::Runner::PreScriptError) do + Rubycli::Runner.apply_pre_scripts( + [{ value: '{', context: '(inline --pre-script)' }], + Object, + Object + ) + end + + assert_includes error.message, 'Failed to evaluate pre-script' + assert_includes error.message, '(inline --pre-script)' + assert_includes error.message, 'syntax error' + end + def test_strict_mode_requires_explicit_constant_when_names_differ Dir.mktmpdir do |dir| file = File.join(dir, 'cli_entry.rb') @@ -141,6 +234,35 @@ def self.run; end end end + def test_explicit_nested_constant_does_not_fall_back_to_ancestor + Object.const_set(:InheritedPayload, Class.new) + + Dir.mktmpdir do |dir| + file = File.join(dir, 'explicit_constant_host.rb') + File.write(file, <<~RUBY) + class ExplicitConstantHost + def self.run + :host + end + end + RUBY + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.prepare_runner_target( + file, + 'ExplicitConstantHost::InheritedPayload', + constant_mode: :strict + ) + end + + assert_includes error.message, 'ExplicitConstantHost::InheritedPayload' + ensure + Object.send(:remove_const, :ExplicitConstantHost) if Object.const_defined?(:ExplicitConstantHost, false) + end + ensure + Object.send(:remove_const, :InheritedPayload) if Object.const_defined?(:InheritedPayload, false) + end + def test_error_when_matching_constant_has_no_cli_methods Dir.mktmpdir do |dir| file = File.join(dir, 'lonely_runner.rb') @@ -253,8 +375,11 @@ def self.combine(config, flag) RUBY parsed = nil - Rubycli.stub(:call_target, ->(_method, pos_args, kw_args) { parsed = { pos: pos_args, kw: kw_args } }) do - Rubycli::Runner.execute(file, nil, ['combine', '{"foo":1}', 'true'], constant_mode: :strict, eval_args: false, json: true, new: false) + run_without_exit = ->(target, *_args) { Rubycli.cli.run(target, ARGV.dup, false) } + Rubycli.stub(:run, run_without_exit) do + Rubycli.stub(:call_target, ->(_method, pos_args, kw_args) { parsed = { pos: pos_args, kw: kw_args }; nil }) do + Rubycli::Runner.execute(file, nil, ['combine', '{"foo":1}', 'true'], constant_mode: :strict, eval_args: false, json: true, new: false) + end end assert_equal([{ 'foo' => 1 }, true], parsed[:pos]) assert_equal({}, parsed[:kw]) @@ -262,9 +387,22 @@ def self.combine(config, flag) previous_strict = Rubycli.environment.strict_input? begin Rubycli.environment.enable_strict_input! - assert_raises(Rubycli::ArgumentError) do - Rubycli::Runner.execute(file, nil, ['combine', 'not-a-hash', 'maybe'], constant_mode: :strict, eval_args: false, json: true, new: false) + status = nil + _out, err = capture_io do + Rubycli.stub(:run, run_without_exit) do + status = Rubycli::Runner.execute( + file, + nil, + ['combine', '"not-a-hash"', '"maybe"'], + constant_mode: :strict, + eval_args: false, + json: true, + new: false + ) + end end + assert_equal 1, status + assert_includes err, 'CONFIG must be Hash' ensure Rubycli.environment.instance_variable_set(:@strict_input, previous_strict) end @@ -302,7 +440,7 @@ def run Rubycli.stub(:run, ->(target, *_args) { captured = target }) do Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{"b":2}', eval_args: true, constant_mode: :strict) end - assert_equal({ 'b' => 2 }, captured.opts) + assert_equal({ b: 2 }, captured.opts) error = assert_raises(Rubycli::Runner::Error) do Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{b:2}', json: true, constant_mode: :strict) @@ -311,7 +449,7 @@ def run captured = nil Rubycli.stub(:run, ->(target, *_args) { captured = target }) do - Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{retry: 2}', eval_args: false, eval_lax: true, json: false, constant_mode: :strict) + Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{retry: 2}', eval_args: true, eval_lax: true, json: false, constant_mode: :strict) end assert_equal({ retry: 2 }, captured.opts) @@ -323,7 +461,7 @@ def run ['run'], new: true, new_args: '{retry: 3}', - eval_args: false, + eval_args: true, eval_lax: true, json: false, constant_mode: :strict @@ -333,7 +471,10 @@ def run captured = nil Rubycli.stub(:run, ->(target, *_args) { captured = target }) do - Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: 'not{json', eval_args: false, eval_lax: true, json: false, constant_mode: :strict) + _out, err = capture_io do + Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: 'not{json', eval_args: true, eval_lax: true, json: false, constant_mode: :strict) + end + assert_includes err, 'Passing it through because --eval-lax is enabled' end # eval-lax falls back to raw string on parse error assert_equal 'not{json', captured.opts @@ -347,7 +488,7 @@ def test_eval_lax_on_regular_cli_arguments file = File.join(dir, 'eval_lax_runner.rb') File.write(file, <<~RUBY) class EvalLaxRunner - # values [String[]] + # VALUES [Symbol[]] def self.run(values) values end @@ -355,21 +496,97 @@ def self.run(values) RUBY captured = nil - Rubycli.stub(:run, ->(target, *_args) { captured = target }) do + run_without_exit = ->(target, *_args) { Rubycli.cli.run(target, ARGV.dup, false) } + Rubycli.stub(:run, run_without_exit) do + Rubycli.stub(:call_target, ->(_method, pos_args, _kw_args) { captured = pos_args.first; nil }) do + Rubycli::Runner.execute( + file, + nil, + ['run', '[:foo, :bar]'], + eval_args: true, + eval_lax: true, + json: false, + constant_mode: :strict + ) + end + end + + assert_equal %i[foo bar], captured + ensure + Object.send(:remove_const, :EvalLaxRunner) if Object.const_defined?(:EvalLaxRunner) + end + end + + def test_eval_binding_is_shared_between_initializer_and_command_within_one_execution + Dir.mktmpdir do |dir| + file = File.join(dir, 'shared_eval_binding_runner.rb') + File.write(file, <<~RUBY) + class SharedEvalBindingRunner + attr_reader :seed + + # SEED [Integer] + def initialize(seed) + @seed = seed + end + + # VALUE [Integer] + def add(value) + seed + value + end + end + RUBY + + captured = nil + run_without_exit = ->(target, *_args) { Rubycli.cli.run(target, ARGV.dup, false) } + Rubycli.stub(:run, run_without_exit) do + Rubycli.stub(:call_target, ->(method, pos_args, _kw_args) { + captured = [method.receiver.seed, pos_args.first] + nil + }) do + Rubycli::Runner.execute( + file, + nil, + ['add', 'shared_seed + 2'], + new: true, + new_args: 'shared_seed = 40', + eval_args: true, + constant_mode: :strict + ) + end + end + + assert_equal [40, 42], captured + ensure + Object.send(:remove_const, :SharedEvalBindingRunner) if Object.const_defined?(:SharedEvalBindingRunner) + end + end + + def test_eval_mode_is_disabled_while_loading_the_target_file + Dir.mktmpdir do |dir| + file = File.join(dir, 'eval_load_mode_runner.rb') + File.write(file, <<~RUBY) + class EvalLoadModeRunner + LOADED_WITH_EVAL_MODE = Rubycli.eval_mode? + + def self.run + :ok + end + end + RUBY + + Rubycli.stub(:run, ->(*) {}) do Rubycli::Runner.execute( file, - nil, - ['run', '[:foo, :bar]'], - eval_args: false, - eval_lax: true, - json: false, + 'EvalLoadModeRunner', + [], + eval_args: true, constant_mode: :strict ) end - assert_equal %i[foo bar], captured + assert_equal false, EvalLoadModeRunner::LOADED_WITH_EVAL_MODE ensure - Object.send(:remove_const, :EvalLaxRunner) if Object.const_defined?(:EvalLaxRunner) + Object.send(:remove_const, :EvalLoadModeRunner) if Object.const_defined?(:EvalLoadModeRunner) end end @@ -431,4 +648,177 @@ def self.run(name) Object.send(:remove_const, :DocCheckRunner) if Object.const_defined?(:DocCheckRunner) end end + + def test_check_lints_explicit_class_methods_defined_in_a_required_file + Dir.mktmpdir do |dir| + dependency = File.join(dir, 'dependency_check_runner.rb') + entry = File.join(dir, 'dependency_check_entry.rb') + File.write(dependency, <<~RUBY) + class DependencyCheckRunner + # @param name [String] Documented name + # @param extra [String] This param does not exist + def self.run(name) + name + end + end + RUBY + File.write(entry, "require #{dependency.inspect}\n") + + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(entry, 'DependencyCheckRunner') + end + + assert_equal 1, status + refute_empty Rubycli.environment.documentation_issues + ensure + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :DependencyCheckRunner) if Object.const_defined?(:DependencyCheckRunner) + end + end + + def test_check_lints_singleton_method_defined_from_an_external_proc + Dir.mktmpdir do |dir| + dependency = File.join(dir, 'external_check_proc.rb') + entry = File.join(dir, 'external_proc_check_runner.rb') + File.write(dependency, <<~RUBY) + # @param name [String] Documented name + # @param extra [String] This param does not exist + ExternalCheckProc = proc { |name| name } + RUBY + File.write(entry, <<~RUBY) + require #{dependency.inspect} + + class ExternalProcCheckRunner + define_singleton_method(:run, &ExternalCheckProc) + end + RUBY + + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(entry, 'ExternalProcCheckRunner') + end + + assert_equal 1, status + refute_empty Rubycli.environment.documentation_issues + ensure + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :ExternalProcCheckRunner) if Object.const_defined?(:ExternalProcCheckRunner) + Object.send(:remove_const, :ExternalCheckProc) if Object.const_defined?(:ExternalCheckProc) + end + end + + def test_check_new_lints_instance_methods_without_instantiating + Dir.mktmpdir do |dir| + file = File.join(dir, 'side_effect_check_runner.rb') + File.write(file, <<~RUBY) + class SideEffectCheckRunner + def initialize + $side_effect_check_runner_initializations += 1 + end + + # @param name [String] Documented name + # @param extra [String] This param does not exist + def run(name) + name + end + end + RUBY + + $side_effect_check_runner_initializations = 0 + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(file, 'SideEffectCheckRunner', new: true) + end + + assert_equal 1, status + assert_equal 0, $side_effect_check_runner_initializations + refute_empty Rubycli.environment.documentation_issues + ensure + $side_effect_check_runner_initializations = nil + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :SideEffectCheckRunner) if Object.const_defined?(:SideEffectCheckRunner) + end + end + + def test_check_rejects_pre_scripts_without_evaluating_them + Dir.mktmpdir do |dir| + file = File.join(dir, 'pre_script_check_runner.rb') + File.write(file, <<~RUBY) + class PreScriptCheckRunner + def self.run + :ok + end + end + RUBY + + $pre_script_check_evaluations = 0 + Rubycli.environment.enable_doc_check! + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.check( + file, + 'PreScriptCheckRunner', + pre_scripts: [{ value: '$pre_script_check_evaluations += 1', context: '(inline --pre-script)' }] + ) + end + + assert_includes error.message, '--check cannot be combined with --pre-script' + assert_equal 0, $pre_script_check_evaluations + assert Rubycli.environment.doc_check_mode? + ensure + $pre_script_check_evaluations = nil + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :PreScriptCheckRunner) if Object.const_defined?(:PreScriptCheckRunner) + end + end + + def test_check_new_ignores_generated_attribute_writers + Dir.mktmpdir do |dir| + file = File.join(dir, 'accessor_check_runner.rb') + File.write(file, <<~RUBY) + class AccessorCheckRunner + # Writable value. + attr_writer :value + + def run + :ok + end + end + RUBY + + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(file, 'AccessorCheckRunner', new: true) + end + + assert_equal 0, status + assert_empty Rubycli.environment.documentation_issues + ensure + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :AccessorCheckRunner) if Object.const_defined?(:AccessorCheckRunner) + end + end + end diff --git a/test/support/coverage_gate.rb b/test/support/coverage_gate.rb new file mode 100644 index 0000000..fb4390a --- /dev/null +++ b/test/support/coverage_gate.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'open3' + +module CoverageGate + CoverageStats = Struct.new(:covered, :total, :uncovered, keyword_init: true) + Error = Class.new(StandardError) + + module_function + + def changed_lines(diff, path_prefix: 'lib/') + lines_by_path = Hash.new { |hash, path| hash[path] = [] } + current_path = nil + current_new_line = nil + + diff.each_line do |line| + if line.start_with?('+++ ') + path = line.delete_prefix('+++ ').strip + path = decode_git_path(path) + current_path = path == '/dev/null' ? nil : path.delete_prefix('b/') + current_path = nil unless current_path&.start_with?(path_prefix) + next + end + + if (match = line.match(/\A@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)) + current_new_line = match[1].to_i + next + end + + next unless current_new_line + + case line + when /\A\+(?!\+\+)/ + lines_by_path[current_path] << current_new_line if current_path + current_new_line += 1 + when /\A-(?!--)/ + next + when /\A / + current_new_line += 1 + end + end + + lines_by_path.transform_values { |lines| lines.uniq.sort } + end + + def decode_git_path(path) + return path unless path.start_with?('"') && path.end_with?('"') + + escaped = path.byteslice(1, path.bytesize - 2).b + decoded = escaped.gsub(/\\(?:[0-7]{3}|.)/n) do |sequence| + escape = sequence.byteslice(1..) + if escape.match?(/\A[0-7]{3}\z/) + [escape.to_i(8)].pack('C') + else + { + 'a' => "\a", 'b' => "\b", 't' => "\t", 'n' => "\n", + 'v' => "\v", 'f' => "\f", 'r' => "\r", '\\' => '\\', '"' => '"' + }.fetch(escape, escape) + end + end + decoded.force_encoding(Encoding::UTF_8) + end + + def coverage_stats(changed_lines_by_path, line_hits_by_path) + covered = 0 + total = 0 + uncovered = [] + + changed_lines_by_path.each do |path, line_numbers| + line_hits = line_hits_by_path[path] + unless line_hits + total += line_numbers.size + uncovered.concat(line_numbers.map { |line_number| [path, line_number] }) + next + end + + line_numbers.each do |line_number| + hits = line_hits[line_number - 1] + next if hits.nil? + + total += 1 + if hits.positive? + covered += 1 + else + uncovered << [path, line_number] + end + end + end + + CoverageStats.new(covered: covered, total: total, uncovered: uncovered) + end + + def diff_against(base_ref, chdir:) + merge_base, merge_error, merge_status = Open3.capture3( + 'git', 'merge-base', 'HEAD', base_ref, + chdir: chdir + ) + unless merge_status.success? + raise Error, "cannot resolve coverage base #{base_ref.inspect}: #{merge_error.strip}" + end + + diff, diff_error, diff_status = Open3.capture3( + 'git', 'diff', '--unified=0', '--no-color', merge_base.strip, '--', 'lib', + chdir: chdir + ) + raise Error, "cannot read changed lines: #{diff_error.strip}" unless diff_status.success? + + diff + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c3d7335..74b86a5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,7 +2,9 @@ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +ENV['MT_NO_PLUGINS'] = '1' require 'minitest/autorun' +require 'minitest/mock' require 'rubycli' Dir[File.expand_path('fixtures/**/*.rb', __dir__)].sort.each do |path|