Skip to content

Implement CRuby-compatible command-line option processing#891

Merged
sisshiki1969 merged 9 commits into
masterfrom
claude/cruby-4-0-2-specs-olt61c
Jul 12, 2026
Merged

Implement CRuby-compatible command-line option processing#891
sisshiki1969 merged 9 commits into
masterfrom
claude/cruby-4-0-2-specs-olt61c

Conversation

@sisshiki1969

@sisshiki1969 sisshiki1969 commented Jul 12, 2026

Copy link
Copy Markdown
Owner

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)

  • Clustered short switches with attached values (-naF:, -072, -Ke, -ne '...'), -- terminator, CRuby-style invalid option errors (exit 1).
  • -v / --version / --copyright print RUBY_DESCRIPTION / RUBY_COPYRIGHT; -v alone exits without reading stdin.
  • -w / -W[level] / -W:category wired to $VERBOSE and Warning[]; -d/--debug sets $DEBUG and $VERBOSE.
  • -c syntax check, -s switch-to-global parsing, -C/-X chdir, -x[dir] shebang scan (plus the automatic scan when the first line is a non-ruby #!), -S RUBYPATH/PATH lookup.
  • RUBYOPT processing with CRuby's allowed-switch subset (invalid switch in RUBYOPT otherwise); RUBYLIB prepended to $LOAD_PATH; -I now prepends with File.expand_path semantics (no symlink resolution).
  • --enable/--disable features: gems, did_you_mean, rubyopt, frozen-string-literal, all (unknown features warn); --disable-gems keeps working via the generic feature path.
  • -E/-U/-K/--encoding/--external-encoding/--internal-encoding set the default external/internal encodings; -U vs -E ext:int conflict rejected like CRuby.

-n / -p loop (parser/)

The implicit while gets ... end wrap is synthesized at the AST level in the prism lowerer (armed per main-script path, consumed on first match), so BEGIN/END blocks still hoist and run exactly once. -a adds $F = $_.split, -l adds $_ = $_.chomp($/) and $\ = $/, -p appends print $_.

Error report alignment + --backtrace-limit

  • Uncaught runtime errors now print CRuby's compact report (file:line:in 'meth': msg (Class) + TAB-indented from frames); the source excerpt with a caret is kept for SyntaxError only, matching CRuby.
  • --backtrace-limit=N truncates frames to N plus ... K levels..., both in the top-level report and in Exception#full_message (which now uses detailed_message, i.e. msg (Class)).
  • The toplevel frame displays as <main> (CRuby) instead of the internal /main name.

Chilled string literals (CRuby 3.4 migration path)

  • String literals in pragma-less files are chilled: first mutation warns literal string will be frozen in the future ... (gated by Warning[:deprecated]). The template is flag-marked at bytecode emission; Value::value_deep_copy propagates the mark, covering the VM and both JIT backends through the single shared runtime hook.
  • --debug / --debug-frozen-string-literal records literal creation sites: FrozenError gains , created at file:line; the chilled warning is followed by file: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.
  • monoruby's own runtime Ruby (builtins / stdlib stubs under the install root) is exempt from chilling — CRuby's stdlib counterparts all carry explicit pragmas.

Runtime support

  • Kernel#gets honours $/ (nil = slurp, "" = paragraph mode) and reads ARGV files ARGF-style before falling back to stdin.
  • IO#puts stringifies non-String values through their to_s method.
  • $-a / $-l / $-p gvar mirrors reflect the actual CLI switches (still read-only).
  • Encoding.find resolves filesystem / locale / external / internal.
  • The hyphenated # frozen-string-literal: magic-comment spelling (used by the stock rbconfig.rb) is now recognized.

Testing

  • ruby/spec command_line: 146 failures + 1 error → 0 failures (release build, CRuby 4.0.2 reference).
  • ruby/spec core/string + core/set: 171 → 168 failures (3 fixed by chilled literals), no new errors.
  • cargo test --lib --tests: all 30 suites green locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpzdoFu46d2ChJpSzeWsNK

claude added 4 commits July 12, 2026 09:53
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

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.79078% with 149 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.87%. Comparing base (f6b232e) to head (686cace).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
monoruby/src/main.rs 88.87% 68 Missing ⚠️
monoruby/src/builtins/kernel.rs 67.40% 44 Missing ⚠️
monoruby/src/value.rs 68.53% 28 Missing ⚠️
monoruby/src/builtins/encoding.rs 77.77% 4 Missing ⚠️
monoruby/src/builtins/io.rs 80.00% 1 Missing ⚠️
monoruby/src/bytecodegen.rs 97.50% 1 Missing ⚠️
monoruby/src/globals.rs 97.72% 1 Missing ⚠️
monoruby/src/globals/error.rs 97.22% 1 Missing ⚠️
monoruby/src/globals/gvar.rs 92.30% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

claude added 5 commits July 12, 2026 10:39
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 sisshiki1969 merged commit b32acec into master Jul 12, 2026
2 of 4 checks passed
@sisshiki1969 sisshiki1969 deleted the claude/cruby-4-0-2-specs-olt61c branch July 12, 2026 12:36
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants