Implement CRuby-compatible command-line option processing#891
Merged
Conversation
Replace the clap-based CLI with a hand-rolled CRuby-style option
parser and wire up the semantics exercised by ruby/spec's
command_line category (146 -> 10 failures locally, of which 4 are
debug-build-only artifacts):
- clustered short switches with attached values (-naF:, -072, -Ke,
-ne '...'), '--' terminator, and CRuby-style error messages
- -v/--version/--copyright print RUBY_DESCRIPTION/RUBY_COPYRIGHT;
-v alone exits without reading stdin
- -w / -W[level] / -W:category (deprecated, experimental, ...) wired
to $VERBOSE and Warning[]; -d/--debug sets $DEBUG and $VERBOSE
- -c syntax check ('Syntax OK' / syntax error + exit 1)
- -n/-p implicit 'while gets ... end' wrap, synthesized at the AST
level in the prism lowerer so BEGIN/END blocks still hoist and run
once; -a ($F autosplit), -l (chomp + $\), -F ($;), -0 ($/)
- Kernel#gets now honours $/ (nil = slurp, '' = paragraph mode) and
reads ARGV files ARGF-style before falling back to stdin
- -s switch-to-global parsing, -C/-X chdir, -x[dir] shebang scan
(plus automatic scan when the first line is a non-ruby shebang),
-S RUBYPATH/PATH script lookup
- RUBYOPT processing with CRuby's allowed-switch subset and
'invalid switch in RUBYOPT' errors; RUBYLIB prepended to
$LOAD_PATH; -I now prepends (File.expand_path semantics, symlinks
preserved) instead of appending canonicalized paths
- --enable/--disable features: gems, did_you_mean, rubyopt,
frozen-string-literal, all (frozen default honoured by the parser
as a process-wide fallback; per-file magic comments still win, and
hyphenated '# frozen-string-literal:' comments are now recognized)
- -E/-U/-K encodings: default_external/internal, -K also sets the
main script's source encoding (__ENCODING__); -U/-E:int conflict
error; Encoding.find('filesystem'/'locale'/'external'/'internal')
- IO#puts stringifies via the value's to_s method (rb_obj_as_string)
- $-a/$-l/$-p gvar mirrors reflect the actual CLI switches
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
The CLI rewrite made the debug build's '=> <value>' stderr echo fire for script files too, polluting specs that capture stderr (core/set's 'Set is available without explicit requiring' compares combined output). Restore the old behavior: only -e programs echo their result. Also drop a now-redundant scoped 'use std::io::Read'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
- Uncaught runtime errors now print CRuby's compact report: the 'file:line:in <method>: message (Class)' first line followed by TAB-indented 'from' frames. The source excerpt with a caret is kept for SyntaxError only, matching CRuby (which shows source context only for syntax errors). - --backtrace-limit=N truncates the from-frames to N entries plus a '... K levels...' marker, both in the top-level report and in Exception#full_message (which now also uses detailed_message, i.e. 'message (Class)' instead of 'Class: message'). - The toplevel frame is displayed as '<main>' (CRuby) instead of the internal '/main' name, everywhere func_description is used (backtrace, caller, JIT logs). Fixes the three --backtrace-limit specs in ruby/spec command_line (command_line: 6 -> 3 remaining failures, all chilled-string ones). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
String literals in files without a frozen_string_literal pragma are now 'chilled': mutating one emits the CRuby deprecation warning 'literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)' (gated by Warning[:deprecated]) once, then the string behaves as plain mutable. - The literal template is flag-marked at bytecode emission (header flag bit 8 distinguishes literal-born chilled strings from Symbol#to_s ones, whose warning wording differs); Value::value_deep_copy propagates the mark to each per-execution copy, covering the VM and both JIT backends through the single shared runtime hook. - --debug / --debug-frozen-string-literal records each frozen/chilled literal's creation site: FrozenError messages gain ', created at file:line' and the chilled warning is followed by 'file:line: warning: the string was created here'. - monoruby's own runtime Ruby (builtins / stdlib stubs under the install root) is exempt from chilling, mirroring CRuby's stdlib which carries explicit pragmas throughout. - The interpolation seed literal is now emitted plain (never chilled/frozen) — the concat result is a fresh mutable string. - A chilled string that was later frozen raises FrozenError without the chilled warning, like CRuby. ruby/spec command_line: 175 examples, 0 real failures remaining (the one debug-build failure is the -e result echo artifact). core/string: 3 fewer failures than before, no new errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #891 +/- ##
==========================================
- Coverage 90.90% 90.87% -0.04%
==========================================
Files 189 189
Lines 121624 122778 +1154
==========================================
+ Hits 110563 111572 +1009
- Misses 11061 11206 +145 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Kernel#warn's 'str = +""' was warning about mutating a chilled empty literal: the Ruby-level +@ only dup'ed frozen receivers, so a chilled one came back still chilled and the first '<<' fired the deprecation warning. CRuby's str_uplus dups chilled strings precisely so that +'literal' is the silent migration idiom. Reimplement String#+@ as a Rust builtin (chilled-ness has no Ruby-level predicate): frozen or chilled -> dup (which clears both flags), otherwise self. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
'return' raised from a block (or an eval) whose outer chain passes
through a lambda must exit the lambda, not the lexically-outermost
method: CRuby returns 5 from '-> { [1].each { return 5 } }.call' and
:eval from '-> { eval(%q(return :eval)) }.call'. err_method_return
resolved the unwind target with outermost_lfp(), which walks the whole
outer chain and thus tunneled through lambda frames, terminating the
main frame instead (the process exited silently). Walk the chain
frame-by-frame and stop at the first non-block-style frame (a method,
the toplevel, a promoted lambda, or a define_method body).
This also un-wedges mspec: core/kernel/eval_spec.rb's lambda-return
example killed the runner mid-file ('did not reset the current
example'), aborting the whole core/kernel category run.
Also add the Kernel#chomp / Kernel#chop module functions (the -n/-p
loop companions: '$_ = $_.chomp(rs)' / '$_ = $_.chop' in the calling
scope, TypeError when $_ is not a String).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
Message formats (verified against CRuby 4.0.2; introduced in 3.4): - NoMethodError/NameError receiver rendering: 'for nil' / 'for true' / 'for class C' / 'for module M' / 'for an instance of C' via a shared receiver_description helper (method_not_found, super variant, private/protected method calls). - Method-name quoting switches from `name' to 'name' across the NoMethodError/NameError family (core/exception: 50 -> 44 failures). Coverage (PR #891 dropped patch coverage to ~42% because the new CLI code never runs under CI): - The debug-build '=> value' echo for -e one-liners now only prints on an interactive terminal, so the instrumented (debug) binary passes the command_line specs when stderr is piped. - bin/test's ruby/spec loop gains the command_line category (verified 0 failures at CI's pinned spec revision baf5738 with a debug build). - New tests/command_line.rs: 19 integration tests spawning the real binary to exercise the option parser directly under nextest — version/copyright/help, -n/-p/-a/-l/-F/-0, -s, -c, -C/-X, -x, -S, RUBYOPT/RUBYLIB, -W/-d, -E/-U/-K, --enable/--disable, --backtrace-limit, '--' handling, stdin scripts, $/-aware gets, and Kernel#chomp/chop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
The differential harness already passed --disable-gem; widen it to --disable=gems,rubyopt (hermetic against a user's RUBYOPT) and apply the same flags to begin_end.rs's bare 'ruby' spawn, which was still paying the ~50ms rubygems boot per test. The bigdecimal tests are unaffected: their scripts 'require "rubygems"' explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
CRuby only says 'an instance of X' when the receiver's class is a plain class; an object with a singleton class (def obj.meth / extend) keeps the '#<Object:0x...>' rendering. The basicobject spec at CI's pinned revision checks exactly that (singleton_method_added ERROR on amd64). Verified against CRuby 4.0.2; pinned-revision basicobject / method / unboundmethod specs are green again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK
sisshiki1969
pushed a commit
that referenced
this pull request
Jul 12, 2026
The CLI-option rework (#891) landed while this PR was open. Semantic reconciliation with the main-as-binding-eval change beyond the textual conflict (both sides append a Lowerer field assignment): - Backtraces labeled the main script "block in <main>" — its body is now technically a block of the empty TOPLEVEL_BINDING frame. Stamp the fid with the internal `/main` name and special-case it in `func_description`, restoring the "<main>" label (pinned by command_line/backtrace_limit_spec, which is green on master since #891). - That stamp made `__method__` / `__callee__` report :"/main" at the top level; filter the internal label — the top level is not a method, CRuby reports nil. command_line: 175 examples 0F/0E (as on master). language stays 20F/14E. Full cargo test green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XLHRVwyWsn5Pp8zgNcZaK
sisshiki1969
added a commit
that referenced
this pull request
Jul 12, 2026
* DATA constant and main-script TOPLEVEL_BINDING Fixes 11 language specs (31F -> 20F): all 7 `The DATA constant` and all 4 `The TOPLEVEL_BINDING constant` failures. DATA: prism's `data_loc` is surfaced as `ParseResult::data_offset` (the byte just past the `__END__` line). The main-script runner opens the script file read-only at that offset and defines `DATA`. Only the main script does this — a required file's `__END__` must not (re)define it, and `-e`/stdin sources (no backing file) get none. TOPLEVEL_BINDING: previously `startup.rb`'s own toplevel binding, which leaked 21 startup locals and never saw the main script's. Now the runtime creates it at init as a Binding over an empty, outer-less heap frame, and `Executor::exec_main_script` executes the main script *inside* it (the binding-eval machinery: compile with the binding's scopes, run on the grown binding frame). That is what gives CRuby's semantics: empty while `-r` requires run, then exactly the main script's parse-time locals — live values, merged with dynamically-set Binding variables, and excluding locals from required files and eval. Two main-script-specific knobs on the shared binding-eval path: - The compiled body gets a method-style + proc-method Meta (like a define_method body), so a toplevel `return` — bare, or from a block — unwinds to the script frame and terminates the script instead of walking the binding's outer chain to a dead frame and raising LocalJumpError. - The parse is flagged `main_script`, keeping script-level parse behaviours (the runtime "argument of top-level return is ignored" warning) that eval parses suppress. Verified: language 20F/14E (baseline minus the 11, nothing else moved); all CI core spec categories 0F/0E; full cargo test green (one unrelated nonblock-IO load-flake passes standalone and on rerun); new spawn-based integration tests in tests/main_script.rs cover DATA, the binding's locals lifecycle, and toplevel return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XLHRVwyWsn5Pp8zgNcZaK * Rebase onto #891; keep <main> labels and nil __method__ at top level The CLI-option rework (#891) landed while this PR was open. Semantic reconciliation with the main-as-binding-eval change beyond the textual conflict (both sides append a Lowerer field assignment): - Backtraces labeled the main script "block in <main>" — its body is now technically a block of the empty TOPLEVEL_BINDING frame. Stamp the fid with the internal `/main` name and special-case it in `func_description`, restoring the "<main>" label (pinned by command_line/backtrace_limit_spec, which is green on master since #891). - That stamp made `__method__` / `__callee__` report :"/main" at the top level; filter the internal label — the top level is not a method, CRuby reports nil. command_line: 175 examples 0F/0E (as on master). language stays 20F/14E. Full cargo test green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XLHRVwyWsn5Pp8zgNcZaK --------- Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Jul 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Running ruby/spec's command_line category against monoruby (with CRuby 4.0.2 as the reference) showed 146 failures + 1 error out of 175 examples, almost all rooted in the clap-based CLI rejecting CRuby's switches with exit status 2. This PR replaces the CLI with a hand-rolled CRuby-style option parser, wires up the semantics the specs exercise, aligns the uncaught-error report with CRuby, and implements chilled string literals.
Result: 175 examples, 0 failures (command_line category, release build).
Changes
Option parser (
main.rs)-naF:,-072,-Ke,-ne '...'),--terminator, CRuby-styleinvalid optionerrors (exit 1).-v/--version/--copyrightprintRUBY_DESCRIPTION/RUBY_COPYRIGHT;-valone exits without reading stdin.-w/-W[level]/-W:categorywired to$VERBOSEandWarning[];-d/--debugsets$DEBUGand$VERBOSE.-csyntax check,-sswitch-to-global parsing,-C/-Xchdir,-x[dir]shebang scan (plus the automatic scan when the first line is a non-ruby#!),-SRUBYPATH/PATH lookup.RUBYOPTprocessing with CRuby's allowed-switch subset (invalid switch in RUBYOPTotherwise);RUBYLIBprepended to$LOAD_PATH;-Inow prepends withFile.expand_pathsemantics (no symlink resolution).--enable/--disablefeatures:gems,did_you_mean,rubyopt,frozen-string-literal,all(unknown features warn);--disable-gemskeeps working via the generic feature path.-E/-U/-K/--encoding/--external-encoding/--internal-encodingset the default external/internal encodings;-Uvs-E ext:intconflict rejected like CRuby.-n/-ploop (parser/)The implicit
while gets ... endwrap is synthesized at the AST level in the prism lowerer (armed per main-script path, consumed on first match), soBEGIN/ENDblocks still hoist and run exactly once.-aadds$F = $_.split,-ladds$_ = $_.chomp($/)and$\ = $/,-pappendsprint $_.Error report alignment +
--backtrace-limitfile:line:in 'meth': msg (Class)+ TAB-indentedfromframes); the source excerpt with a caret is kept for SyntaxError only, matching CRuby.--backtrace-limit=Ntruncates frames to N plus... K levels..., both in the top-level report and inException#full_message(which now usesdetailed_message, i.e.msg (Class)).<main>(CRuby) instead of the internal/mainname.Chilled string literals (CRuby 3.4 migration path)
literal string will be frozen in the future ...(gated byWarning[:deprecated]). The template is flag-marked at bytecode emission;Value::value_deep_copypropagates the mark, covering the VM and both JIT backends through the single shared runtime hook.--debug/--debug-frozen-string-literalrecords literal creation sites: FrozenError gains, created at file:line; the chilled warning is followed byfile:line: warning: the string was created here.String#+@is now a Rust builtin that dups frozen and chilled receivers, so the+"literal"idiom mutates silently, like CRuby.Runtime support
Kernel#getshonours$/(nil = slurp,""= paragraph mode) and readsARGVfiles ARGF-style before falling back to stdin.IO#putsstringifies non-String values through theirto_smethod.$-a/$-l/$-pgvar mirrors reflect the actual CLI switches (still read-only).Encoding.findresolvesfilesystem/locale/external/internal.# frozen-string-literal:magic-comment spelling (used by the stockrbconfig.rb) is now recognized.Testing
cargo test --lib --tests: all 30 suites green locally.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK